@@ -13,6 +13,8 @@ import { ComposioAdapter } from "../composio/index.js";
1313import type { GCToolDefinition } from "../sdk-types.js" ;
1414import { appendMessage , loadHistory , deleteHistory , summarizeHistory } from "./chat-history.js" ;
1515import { getVoiceContext , getAgentContext } from "../context.js" ;
16+ import { discoverSkills } from "../skills.js" ;
17+ import { discoverWorkflows , loadFlowDefinition , saveFlowDefinition , deleteFlowDefinition } from "../workflows.js" ;
1618
1719const dim = ( s : string ) => `\x1b[2m${ s } \x1b[0m` ;
1820const bold = ( s : string ) => `\x1b[1m${ s } \x1b[0m` ;
@@ -488,6 +490,123 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
488490 } ;
489491 }
490492
493+ // ── SkillFlow execution ─────────────────────────────────────────────
494+ // ── Approval gate state ────────────────────────────────────────────
495+ let pendingApproval : { resolve : ( approved : boolean ) => void } | null = null ;
496+
497+ function handleApprovalReply ( text : string ) : boolean {
498+ if ( ! pendingApproval ) return false ;
499+ const lower = text . trim ( ) . toLowerCase ( ) ;
500+ if ( [ "yes" , "approve" , "continue" , "ok" , "go" , "y" , "proceed" ] . includes ( lower ) ) {
501+ pendingApproval . resolve ( true ) ;
502+ pendingApproval = null ;
503+ return true ;
504+ }
505+ if ( [ "no" , "deny" , "stop" , "cancel" , "abort" , "n" , "reject" ] . includes ( lower ) ) {
506+ pendingApproval . resolve ( false ) ;
507+ pendingApproval = null ;
508+ return true ;
509+ }
510+ return false ;
511+ }
512+
513+ async function sendApprovalRequest ( channel : string , message : string ) : Promise < boolean > {
514+ // Send message via the chosen channel
515+ if ( channel === "telegram" && telegramToken && lastTelegramChatId ) {
516+ await fetch ( `https://api.telegram.org/bot${ telegramToken } /sendMessage` , {
517+ method : "POST" ,
518+ headers : { "Content-Type" : "application/json" } ,
519+ body : JSON . stringify ( { chat_id : lastTelegramChatId , text : message } ) ,
520+ } ) ;
521+ } else if ( channel === "whatsapp" && whatsappSock && whatsappConnected && lastWhatsAppJid ) {
522+ const sent = await whatsappSock . sendMessage ( lastWhatsAppJid , { text : message } ) ;
523+ if ( sent ?. key ?. id ) whatsappSentIds . add ( sent . key . id ) ;
524+ } else {
525+ return true ; // No channel available — auto-approve
526+ }
527+
528+ // Wait for reply (timeout after 5 minutes)
529+ return new Promise < boolean > ( ( resolve ) => {
530+ pendingApproval = { resolve } ;
531+ const timeout = setTimeout ( ( ) => {
532+ if ( pendingApproval ?. resolve === resolve ) {
533+ pendingApproval = null ;
534+ resolve ( false ) ; // Timeout = deny
535+ }
536+ } , 5 * 60 * 1000 ) ;
537+ const origResolve = resolve ;
538+ pendingApproval . resolve = ( val : boolean ) => {
539+ clearTimeout ( timeout ) ;
540+ origResolve ( val ) ;
541+ } ;
542+ } ) ;
543+ }
544+
545+ async function executeFlow ( flowName : string , userContext : string , sendToBrowser : ( msg : ServerMessage ) => void ) {
546+ const flowPath = join ( resolve ( opts . agentDir ) , "workflows" , flowName + ".yaml" ) ;
547+ const flow = await loadFlowDefinition ( flowPath ) ;
548+
549+ sendToBrowser ( { type : "transcript" , role : "assistant" ,
550+ text : `Running flow: ${ flow . name } (${ flow . steps . length } steps)` } ) ;
551+
552+ let runningContext = userContext ;
553+
554+ for ( let i = 0 ; i < flow . steps . length ; i ++ ) {
555+ const step = flow . steps [ i ] ;
556+
557+ // ── Approval gate step ──
558+ if ( step . skill === "__approval_gate__" ) {
559+ const channel = step . channel || "telegram" ;
560+ const customMsg = step . prompt || "" ;
561+ const approvalMsg = customMsg
562+ ? `⏸ Approval Required: ${ customMsg } \n\nReply YES to continue or NO to cancel.`
563+ : `⏸ Flow "${ flow . name } " paused at step ${ i + 1 } /${ flow . steps . length } .\n\nCompleted so far:\n${ runningContext . slice ( 0 , 500 ) } \n\nReply YES to continue or NO to cancel.` ;
564+
565+ sendToBrowser ( { type : "transcript" , role : "assistant" ,
566+ text : `⏸ Waiting for approval via ${ channel } ...` } ) ;
567+
568+ const approved = await sendApprovalRequest ( channel , approvalMsg ) ;
569+
570+ if ( ! approved ) {
571+ sendToBrowser ( { type : "transcript" , role : "assistant" ,
572+ text : `Flow "${ flow . name } " was denied at approval gate (step ${ i + 1 } ).` } ) ;
573+ return ;
574+ }
575+ sendToBrowser ( { type : "transcript" , role : "assistant" ,
576+ text : `✓ Approval received — continuing flow.` } ) ;
577+ runningContext += `\n\n[Step ${ i + 1 } : approval gate]: Approved via ${ channel } ` ;
578+ continue ;
579+ }
580+
581+ sendToBrowser ( { type : "agent_working" as any , query : `Step ${ i + 1 } /${ flow . steps . length } : ${ step . skill } ` } as any ) ;
582+
583+ const prompt = `Use the skill "${ step . skill } " (load it with /skill:${ step . skill } ).
584+ ${ step . prompt . replace ( / \{ i n p u t \} / g, userContext ) }
585+
586+ Context from previous steps:
587+ ${ runningContext } `;
588+
589+ const result = query ( {
590+ prompt,
591+ dir : opts . agentDir ,
592+ model : opts . model ,
593+ env : opts . env ,
594+ } ) ;
595+
596+ let stepOutput = "" ;
597+ for await ( const msg of result ) {
598+ if ( msg . type === "assistant" && msg . content ) stepOutput += msg . content ;
599+ if ( msg . type === "tool_use" ) sendToBrowser ( { type : "tool_call" , toolName : msg . toolName , args : msg . args } as any ) ;
600+ if ( msg . type === "tool_result" ) sendToBrowser ( { type : "tool_result" , toolName : msg . toolName , content : msg . content , isError : msg . isError } as any ) ;
601+ }
602+
603+ runningContext += `\n\n[Step ${ i + 1 } result (${ step . skill } )]: ${ stepOutput } ` ;
604+ sendToBrowser ( { type : "agent_done" as any , result : `Step ${ i + 1 } complete` } as any ) ;
605+ }
606+
607+ sendToBrowser ( { type : "transcript" , role : "assistant" , text : `Flow "${ flow . name } " completed.` } ) ;
608+ }
609+
491610 // ── File API helpers ────────────────────────────────────────────────
492611 const HIDDEN_DIRS = new Set ( [ ".git" , "node_modules" , ".gitagent" , "dist" , ".next" , "__pycache__" , ".venv" ] ) ;
493612 const agentRoot = resolve ( opts . agentDir ) ;
@@ -518,6 +637,8 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
518637 . filter ( Boolean ) ,
519638 ) ;
520639
640+ let lastTelegramChatId : number | null = null ;
641+
521642 function stopTelegramPolling ( ) {
522643 telegramPolling = false ;
523644 if ( telegramPollTimer ) { clearTimeout ( telegramPollTimer ) ; telegramPollTimer = null ; }
@@ -670,6 +791,7 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
670791 if ( ! msg ) continue ;
671792
672793 const chatId = msg . chat . id ;
794+ lastTelegramChatId = chatId ;
673795 const fromName = msg . from ?. first_name || "User" ;
674796 const fromUsername = ( msg . from ?. username || "" ) . toLowerCase ( ) ;
675797
@@ -707,6 +829,15 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
707829
708830 if ( ! userText && ! imageContext ) continue ;
709831
832+ // ── Approval gate reply check ──
833+ if ( userText && handleApprovalReply ( userText ) ) {
834+ console . log ( dim ( `[telegram] Approval reply from ${ fromName } : ${ userText } ` ) ) ;
835+ const approvalMsg : ServerMessage = { type : "transcript" , role : "user" , text : `[Telegram] ${ fromName } : ${ userText } ` } ;
836+ appendMessage ( serverOpts . agentDir , activeBranch , approvalMsg ) ;
837+ broadcastToBrowsers ( approvalMsg ) ;
838+ continue ;
839+ }
840+
710841 const fullText = `${ userText } ${ imageContext } ` . trim ( ) ;
711842 console . log ( dim ( `[telegram] ${ fromName } : ${ fullText . slice ( 0 , 100 ) } ` ) ) ;
712843
@@ -878,6 +1009,7 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
8781009 }
8791010
8801011 // ── WhatsApp state ─────────────────────────────────────────────────
1012+ let lastWhatsAppJid : string | null = null ;
8811013 let whatsappSock : any = null ;
8821014 let whatsappConnected = false ;
8831015 let whatsappPhoneNumber : string | null = null ;
@@ -1223,8 +1355,18 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
12231355 // ── Self-DM: full agent interaction ──
12241356 const text = incomingText ;
12251357 const replyJid = senderJid ;
1358+ lastWhatsAppJid = replyJid ;
12261359 console . log ( dim ( `[whatsapp] Self-DM: ${ text . slice ( 0 , 100 ) } ` ) ) ;
12271360
1361+ // ── Approval gate reply check ──
1362+ if ( handleApprovalReply ( text ) ) {
1363+ console . log ( dim ( `[whatsapp] Approval reply: ${ text } ` ) ) ;
1364+ const approvalMsg : ServerMessage = { type : "transcript" , role : "user" , text : `[WhatsApp]: ${ text } ` } ;
1365+ appendMessage ( serverOpts . agentDir , activeBranch , approvalMsg ) ;
1366+ broadcastToBrowsers ( approvalMsg ) ;
1367+ continue ;
1368+ }
1369+
12281370 // Broadcast to browser UI
12291371 const userMsg : ServerMessage = { type : "transcript" , role : "user" , text : `[WhatsApp]: ${ text } ` } ;
12301372 appendMessage ( serverOpts . agentDir , activeBranch , userMsg ) ;
@@ -1860,6 +2002,48 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
18602002 jsonReply ( res , 502 , { error : err . message } ) ;
18612003 }
18622004
2005+ // ── SkillFlows API ────────────────────────────────────────────
2006+ } else if ( url . pathname === "/api/skills/list" && req . method === "GET" ) {
2007+ try {
2008+ const skills = await discoverSkills ( agentRoot ) ;
2009+ jsonReply ( res , 200 , { skills } ) ;
2010+ } catch ( err : any ) {
2011+ jsonReply ( res , 500 , { error : err . message } ) ;
2012+ }
2013+
2014+ } else if ( url . pathname === "/api/flows/list" && req . method === "GET" ) {
2015+ try {
2016+ const workflows = await discoverWorkflows ( agentRoot ) ;
2017+ const flows = workflows . filter ( ( w ) => w . type === "flow" ) ;
2018+ jsonReply ( res , 200 , { flows } ) ;
2019+ } catch ( err : any ) {
2020+ jsonReply ( res , 500 , { error : err . message } ) ;
2021+ }
2022+
2023+ } else if ( url . pathname === "/api/flows/save" && req . method === "POST" ) {
2024+ const body = await readBody ( req ) ;
2025+ let parsed : { name : string ; description : string ; steps : { skill : string ; prompt : string ; channel ?: string } [ ] } ;
2026+ try { parsed = JSON . parse ( body ) ; } catch { return jsonReply ( res , 400 , { error : "Invalid JSON" } ) ; }
2027+ if ( ! parsed . name || ! parsed . steps ?. length ) return jsonReply ( res , 400 , { error : "Missing name or steps" } ) ;
2028+ try {
2029+ await saveFlowDefinition ( agentRoot , parsed ) ;
2030+ jsonReply ( res , 200 , { ok : true } ) ;
2031+ } catch ( err : any ) {
2032+ jsonReply ( res , 400 , { error : err . message } ) ;
2033+ }
2034+
2035+ } else if ( url . pathname === "/api/flows/delete" && req . method === "DELETE" ) {
2036+ const body = await readBody ( req ) ;
2037+ let parsed : { name : string } ;
2038+ try { parsed = JSON . parse ( body ) ; } catch { return jsonReply ( res , 400 , { error : "Invalid JSON" } ) ; }
2039+ if ( ! parsed . name ) return jsonReply ( res , 400 , { error : "Missing name" } ) ;
2040+ try {
2041+ await deleteFlowDefinition ( agentRoot , parsed . name ) ;
2042+ jsonReply ( res , 200 , { ok : true } ) ;
2043+ } catch ( err : any ) {
2044+ jsonReply ( res , 500 , { error : err . message } ) ;
2045+ }
2046+
18632047 // ── Skills Marketplace proxy ────────────────────────────────────
18642048 } else if ( url . pathname === "/api/skills-mp/proxy" && req . method === "GET" ) {
18652049 const proxyPath = url . searchParams . get ( "path" ) || "/" ;
@@ -2228,7 +2412,7 @@ a{color:#58a6ff;}</style></head>
22282412 }
22292413
22302414 // Parse browser messages into ClientMessage and forward to adapter
2231- browserWs . on ( "message" , ( data ) => {
2415+ browserWs . on ( "message" , async ( data ) => {
22322416 try {
22332417 const msg = JSON . parse ( data . toString ( ) ) as ClientMessage ;
22342418
@@ -2258,6 +2442,25 @@ a{color:#58a6ff;}</style></head>
22582442
22592443 if ( msg . type === "text" ) {
22602444 appendMessage ( opts . agentDir , activeBranch , { type : "transcript" , role : "user" , text : msg . text } ) ;
2445+
2446+ // Detect @flow -name triggers
2447+ const flowMatch = msg . text . match ( / @ ( [ a - z 0 - 9 ] + (?: - [ a - z 0 - 9 ] + ) * ) / ) ;
2448+ if ( flowMatch ) {
2449+ try {
2450+ const workflows = await discoverWorkflows ( agentRoot ) ;
2451+ const flow = workflows . find ( ( f ) => f . name === flowMatch [ 1 ] && f . type === "flow" ) ;
2452+ if ( flow ) {
2453+ const userContext = msg . text . replace ( / @ [ a - z 0 - 9 - ] + / , "" ) . trim ( ) ;
2454+ executeFlow ( flow . name , userContext , sendToBrowser ) . catch ( ( err ) => {
2455+ sendToBrowser ( { type : "transcript" , role : "assistant" , text : `Flow error: ${ err . message } ` } ) ;
2456+ } ) ;
2457+ return ; // skip adapter.send()
2458+ }
2459+ } catch {
2460+ // Fall through to normal send if flow detection fails
2461+ }
2462+ }
2463+
22612464 // Detect personal info and save to memory in background
22622465 if ( isMemoryWorthy ( msg . text ) ) {
22632466 saveMemoryInBackground ( msg . text , opts . agentDir , opts . model , opts . env , ( ) => {
0 commit comments