Skip to content

Commit 6127186

Browse files
serge-the-devclaude
andcommitted
feat(coding): #3 Step 1 — one smart Agent input (answer from context OR delegate)
The Agent chat used to always relay to Claude. Now it routes: a new POST /coding/sessions/:id/agent runs a tool-loop — the BYOK LLM answers from the terminal + session memory for questions, or calls a `drive_claude` tool to delegate actions to Claude Code (which then runs + the finish watcher notifies). `@claude` / `/run` forces delegation. Context-first, delegate-on-action. This is the reusable engine the cross-repo Overseer (#9) lifts to global scope — swap per-session context for all-repos context + memory + drive_claude(repoId). Frontend: Send now calls /agent; placeholder/tooltip updated; the read-only Status button stays for a forced context summary. Refs #3 #9 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d96210f commit 6127186

3 files changed

Lines changed: 109 additions & 10 deletions

File tree

‎store/console/console-coding-session.js‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -263,26 +263,31 @@
263263
await callExplain(q);
264264
}
265265

266-
// The Agent chat: relay your words to Claude on your behalf — it types your
267-
// message into the CLI (drives it). Persisted as a chat turn (chat:true) so it
268-
// survives reload; a durable server-side watcher then summarizes + notifies when
269-
// Claude finishes (chat polling below shows that reply, even minutes later).
266+
// The Agent chat (one smart input): the server decides per message — answer from
267+
// the terminal + history, OR delegate to Claude Code (it types the instruction
268+
// into the CLI for you). Prefix with `@claude` to force delegation. When it
269+
// delegates, the durable watcher summarizes + notifies on finish (chat poll shows
270+
// that reply later). This calls the tool-loop endpoint that the Overseer reuses.
270271
async function sendCodingInstruction() {
271272
if (!currentInstance || !currentCodingSession) return;
272273
const input = document.getElementById('inst-coding-ask');
273274
const text = (input.value || '').trim();
274275
if (!text) return;
275276
input.value = '';
276277
codingSummaryHistory.push({ role: 'user', content: text });
278+
codingSummaryBusy = true;
277279
renderCodingSummary();
278-
setCodingRunState('thinking'); // optimistic: disable Send/mic until idle confirms
279280
try {
280-
await api(`/v1/instances/${currentInstance.id}/coding/sessions/${currentCodingSession}/message`, {
281-
method: 'POST', body: JSON.stringify({ text, chat: true }),
281+
const d = await api(`/v1/instances/${currentInstance.id}/coding/sessions/${currentCodingSession}/agent`, {
282+
method: 'POST', body: JSON.stringify({ message: text }),
282283
});
283-
setTimeout(pollCodingTerminal, 300);
284+
codingSummaryHistory.push({ role: 'assistant', content: d.reply || '(no response)' });
285+
speakCoding(d.reply);
286+
if (d.delegated) { setCodingRunState('thinking'); setTimeout(pollCodingTerminal, 300); } // Claude is now working
284287
} catch (e) {
285288
codingSummaryHistory.push({ role: 'assistant', content: 'Could not reach the agent: ' + e.message });
289+
} finally {
290+
codingSummaryBusy = false;
286291
renderCodingSummary();
287292
}
288293
}

‎store/console/index.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,10 @@ <h3 id="inst-activity-heading" style="font-size:0.95rem;margin-bottom:0.75rem">R
346346
<div id="inst-coding-view-summary" class="coding-view">
347347
<div id="inst-coding-summary-thread" style="background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:0.7rem 0.85rem;overflow:auto;font-size:0.86rem;line-height:1.5;touch-action:manipulation"></div>
348348
<div style="display:flex;gap:0.35rem;margin-top:0.5rem;flex-wrap:wrap">
349-
<input id="inst-coding-ask" placeholder="Tell the agent what to do — it types into Claude for you…" style="flex:1;min-width:140px" onkeydown="if(event.key==='Enter')sendCodingInstruction()">
349+
<input id="inst-coding-ask" placeholder="Ask about it, or tell it to do something — it routes for you (@claude forces)…" style="flex:1;min-width:140px" onkeydown="if(event.key==='Enter')sendCodingInstruction()">
350350
<button type="button" id="inst-coding-mic" class="btn btn-outline btn-sm" onclick="startCodingDictation(this)" title="Dictate">🎤</button>
351351
<button type="button" id="inst-coding-speak" class="btn btn-outline btn-sm" onclick="toggleCodingVoiceOutput(this)" title="Read replies aloud">🔊</button>
352-
<button type="button" id="inst-coding-send" class="btn btn-primary btn-sm" onclick="sendCodingInstruction()" title="Send to Claude — the agent types it into the CLI for you, then reports back">Send</button>
352+
<button type="button" id="inst-coding-send" class="btn btn-primary btn-sm" onclick="sendCodingInstruction()" title="Send — it answers from context or drives Claude, whichever fits. Prefix @claude to force delegation.">Send</button>
353353
<button type="button" class="btn btn-outline btn-sm" onclick="refreshCodingSummary()" title="What's it doing right now? (read-only — doesn't interrupt Claude)">Status</button>
354354
<button type="button" class="btn btn-outline btn-sm" onclick="clearCodingSummary()" title="Clear this conversation (keeps the activity log)">🧹</button>
355355
</div>

‎workers/api/src/routes/coding.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,100 @@ codingRoutes.post("/:instanceId/coding/sessions/:sessionId/explain", async (c) =
337337
return c.json({ reply });
338338
});
339339

340+
/**
341+
* Send an instruction to the repo's Claude (drive the CLI) + spin up the finish
342+
* watcher (deduped). Shared by the Agent endpoint's delegate path. Returns an ack.
343+
*/
344+
async function driveClaude(
345+
c: Context<{ Bindings: Env }>,
346+
instanceId: string,
347+
uid: string,
348+
sessionId: string,
349+
instruction: string,
350+
): Promise<{ delegated: boolean; reply: string }> {
351+
const conn = await getRunnerConn(c.env, instanceId, uid);
352+
if (!conn) return { delegated: false, reply: "No coding runner connected — start it with: pags up" };
353+
await appendTimeline(c.env, { sessionId, instanceId, userId: uid, type: "command", content: instruction }).catch(() => undefined);
354+
const act = () => callRunner(conn, "/coding/act", { sessionId, action: { kind: "message", text: instruction } }).catch(() => null);
355+
let snap = await act();
356+
const session = await getSession(c.env, instanceId, uid, sessionId);
357+
const repo = session ? await getRepo(c.env, instanceId, uid, session.repoId) : null;
358+
if (snap === null && session && repo) {
359+
await startSessionOnRunner(c.env, instanceId, uid, session, repo); // reattach a session lost to a runner restart
360+
snap = await act();
361+
}
362+
// Finish watcher (one per send: stamp the session so only the latest notifies).
363+
const watchId = `cw-${sessionId}-${Date.now()}`;
364+
await c.env.DB.prepare("UPDATE coding_sessions SET watch_workflow_id = ?1 WHERE id = ?2 AND instance_id = ?3 AND user_id = ?4")
365+
.bind(watchId, sessionId, instanceId, uid)
366+
.run()
367+
.catch(() => undefined);
368+
await c.env.CODING_SESSION.create({
369+
id: watchId,
370+
params: { instanceId, userId: uid, sessionId, repoId: repo?.id ?? "", mode: "watch", watchId, goal: { objective: instruction, repo: repo?.name ?? "your repo", clientType: session?.clientType ?? "claude" } },
371+
}).catch(() => undefined);
372+
const reply = `On it — I asked Claude to: ${instruction}`;
373+
await appendTimeline(c.env, { sessionId, instanceId, userId: uid, type: "chat_assistant", content: reply }).catch(() => undefined);
374+
return { delegated: true, reply };
375+
}
376+
377+
/**
378+
* The Agent chat (Step 1 of #3): ONE input that either answers from the terminal +
379+
* history, or DELEGATES to Claude Code via the `drive_claude` tool — the LLM
380+
* decides. `@claude`/`/run` forces delegation. This tool-loop is the reusable core
381+
* the cross-repo Overseer (#9) will lift to global scope.
382+
*/
383+
codingRoutes.post("/:instanceId/coding/sessions/:sessionId/agent", async (c) => {
384+
const { uid, instanceId } = await requireOwned(c);
385+
const sessionId = c.req.param("sessionId");
386+
const body = (await c.req.json().catch(() => ({}))) as { message?: string };
387+
const raw = String(body.message ?? "").trim();
388+
if (!raw) return c.json({ error: "message is required" }, 400);
389+
await appendTimeline(c.env, { sessionId, instanceId, userId: uid, type: "chat_user", content: raw }).catch(() => undefined);
390+
391+
// Explicit force-delegate.
392+
if (/^(@claude|\/run)\b/i.test(raw)) {
393+
const cleaned = raw.replace(/^(@claude|\/run)\s*/i, "").trim() || raw;
394+
return c.json(await driveClaude(c, instanceId, uid, sessionId, cleaned));
395+
}
396+
397+
// Otherwise: one tool-enabled call — answer from context OR call drive_claude.
398+
const conn = await getRunnerConn(c.env, instanceId, uid);
399+
let pane = "";
400+
if (conn) {
401+
const snap = (await callRunner(conn, "/coding/capture", { sessionId }).catch(() => null)) as { pane?: string } | null;
402+
pane = snap?.pane ?? "";
403+
}
404+
const memory = await contextForCopilot(c.env, sessionId);
405+
const system =
406+
"You are the co-pilot for an AI coding agent working in the user's repo. ONE rule decides what you do:\n" +
407+
"- If the user is ASKING (status, what happened, why, explain) → answer concisely FROM the terminal + session memory below. Never claim you did work.\n" +
408+
"- If the user wants something DONE (change/add/fix/run/build/test) OR you'd need live repo state you don't have → call the `drive_claude` tool with ONE clear instruction for Claude Code to execute. Don't try to do the work yourself.\n" +
409+
"Plain language, tight. Default to answering; delegate when it's an action.";
410+
const userMsg = `User: ${raw}\n\nSESSION MEMORY (recent):\n${memory || "(none)"}\n\nTERMINAL (recent):\n${pane.slice(-6000) || "(no live terminal)"}`;
411+
const tools = [
412+
{
413+
type: "function",
414+
function: {
415+
name: "drive_claude",
416+
description: "Delegate an action to Claude Code running in the repo (it edits files, runs commands). Use for any request to DO work.",
417+
parameters: { type: "object", properties: { instruction: { type: "string", description: "A single clear instruction for Claude Code." } }, required: ["instruction"] },
418+
},
419+
},
420+
];
421+
const res = (await runUserWorkersAi(c.env, uid, "claude-sonnet-4-6", {
422+
messages: [{ role: "system", content: system }, { role: "user", content: userMsg }],
423+
tools,
424+
maxTokens: 700,
425+
}).catch(() => ({ response: "" }))) as { response?: string; tool_calls?: Array<{ name: string; arguments?: Record<string, unknown> }> };
426+
const call = res.tool_calls?.find((t) => t.name === "drive_claude");
427+
const instruction = call && typeof call.arguments?.instruction === "string" ? (call.arguments.instruction as string).trim() : "";
428+
if (instruction) return c.json(await driveClaude(c, instanceId, uid, sessionId, instruction));
429+
const reply = res.response || "(no response)";
430+
await appendTimeline(c.env, { sessionId, instanceId, userId: uid, type: "chat_assistant", content: reply }).catch(() => undefined);
431+
return c.json({ delegated: false, reply });
432+
});
433+
340434
/** Load a session's persisted conversation (so the console restores it on open). */
341435
codingRoutes.get("/:instanceId/coding/sessions/:sessionId/timeline", async (c) => {
342436
const { uid, instanceId } = await requireOwned(c);

0 commit comments

Comments
 (0)