Skip to content

Commit c7937ae

Browse files
shreyas-lyzrclaude
andcommitted
v1.0.0 — SkillsFlow: visual workflow builder, drag-reorder, speaker mute, UI fixes
- Add SkillFlows builder with drag-and-reorder step cards and approval gates - Add speaker mute button to silence agent audio output - Add "+ New Workflow" button in saved flows section - Fix controls row clipping by enabling flex-wrap - Add flow execution engine with approval gates via Telegram/WhatsApp - Add workflow CRUD API (save, load, delete flow definitions) - Bump version to 1.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56dcdcc commit c7937ae

4 files changed

Lines changed: 749 additions & 10 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gitclaw",
3-
"version": "0.5.0",
3+
"version": "1.0.0",
44
"description": "A universal git-native multimodal always learning AI Agent (TinyHuman)",
55
"author": "shreyaskapale",
66
"license": "MIT",

src/voice/server.ts

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { ComposioAdapter } from "../composio/index.js";
1313
import type { GCToolDefinition } from "../sdk-types.js";
1414
import { appendMessage, loadHistory, deleteHistory, summarizeHistory } from "./chat-history.js";
1515
import { getVoiceContext, getAgentContext } from "../context.js";
16+
import { discoverSkills } from "../skills.js";
17+
import { discoverWorkflows, loadFlowDefinition, saveFlowDefinition, deleteFlowDefinition } from "../workflows.js";
1618

1719
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
1820
const 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(/\{input\}/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-z0-9]+(?:-[a-z0-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-z0-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

Comments
 (0)