diff --git a/CHANGELOG.md b/CHANGELOG.md index a283efa..72f2d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ new version heading in the same commit. ## [Unreleased] +## [0.193.1] — 2026-07-14 +### Fixed +- **Chat replies no longer stall on "thinking…" forever.** The v1 Chat surface delivered a follow-up by + typing it into a warm resident TUI pane (`deliverToResident`); when that pane had been reaped or the + keystrokes didn't trigger a turn, the message was silently lost and the UI spun indefinitely (the DB row + still read `running`). Replies now go through **`TerminalManager.chatSend`** — every turn is a clean, + self-terminating **headless resume** run seeded with the message as the prompt (reliable turn trigger, + not injected keystrokes), and `chat/start` is likewise a headless one-shot. The run tears down at + turn-end, so the UI's "thinking…" is driven by *real* pane liveness and can't hang; a genuinely stalled + turn shows a **Resend**, and a reply sent while the prior turn is still generating returns `busy` (the + draft is kept). Trade: a cold start per turn — removed properly by the SDK runtime (see below). The + PreToolUse gate still governs every effect. (`src/terminal.ts`, `src/server.ts`, `web/src/App.tsx`, + `web/src/lib/api.ts`.) +### Added +- **`docs/sdk-chat-runtime-plan.md`** — the proposed v2: drive the chat surface via the Claude Agent SDK + (`query()` + `includePartialMessages` + `canUseTool` → the existing gateway) for token streaming and a + first-class approval hook, running beside the CLI/tmux runtime. Not started; greenlight before building. + ## [0.193.0] — 2026-07-14 ### Added - **Transfer a session to another owner.** A session's accountable human — its `run_as` — can now be diff --git a/docs/sdk-chat-runtime-plan.md b/docs/sdk-chat-runtime-plan.md new file mode 100644 index 0000000..c01ac47 --- /dev/null +++ b/docs/sdk-chat-runtime-plan.md @@ -0,0 +1,89 @@ +# Plan: SDK-driven chat runtime (native chat v2) + +**Status:** proposed — not started. This is the "do it right" successor to the hardened v1 chat +surface (`src/edge/conversation.ts` + the Chat page + `TerminalManager.chatSend`). Greenlight before +building. + +## Why + +The v1 Chat page drives Claude the only way agent-os currently can: it spawns a real `claude` CLI in a +detached **tmux** pane, seeds each turn as a resume prompt, and **reads the internal transcript JSONL** +for display. That works, but it inherits three structural limits: + +1. **Fragile driver.** Keeping a live TUI pane warm and injecting turns is racy (the 2026-07-14 stuck + "thinking…" bug). v1 hardens this by making every turn a self-terminating headless resume — reliable, + but it **cold-starts Claude per turn** (no warm latency) and can never token-stream. +2. **No streaming.** Display is message-level polling (2 s). Non-technical users read a chat app; live + typing would feel materially better. +3. **Volatile display source.** The transcript JSONL under `~/.claude/projects/**` is undocumented and + Anthropic-internal — it can change shape without notice, and it has **no approval hook** (a tool is + already executed by the time it lands in the transcript; agent-os only governs it because the + *separate* PreToolUse gate hook fired first). + +The **Claude Agent SDK** (TS/Python, officially supported) removes all three: a typed, documented event +stream with token deltas (`includePartialMessages`) and a first-class programmatic approval callback +(`canUseTool`). See `docs/agent-mcp-tools.md` and the research notes in the v1 PR. + +## Principle preserved + +The one invariant does not move: **every side effect still passes one mediated gateway.** Today that +choke point is the gate-hook script → `/api/gate` → the 7-step gateway. In v2 it becomes the SDK's +`canUseTool(tool, input)` callback → the **same** `gateway.ts` (Policy → Approvals → Budget → Identity → +Idempotency → Audit) → allow/deny. Mechanism changes from a shell hook to an in-process callback; the +trust boundary is identical. + +## Architecture — a second runtime, beside the CLI one (not a replacement) + +The CLI+tmux runtime stays: it's what gives **attachable terminals** (ttyd take-over, the shared proxy, +the real TUI for engineers). The SDK runtime is **additive**, selected only for the chat surface: + +``` +Chat page ──SSE── /api/chat/stream ──▶ SdkChatRuntime (new) + │ @anthropic-ai/claude-agent-sdk `query()` + │ includePartialMessages → stream deltas out as SSE + │ canUseTool ─────────────▶ gateway.ts (unchanged core) + │ mcpServers/env ◀──────── reuse per-session injection + └─ typed messages → the fuller renderer (Phase 2 of v1) +``` + +- **Sessions:** an `SdkChatRuntime` holds one SDK `query()` conversation per chat session (resumable via + the SDK's session id), replacing the tmux pane + resume-per-turn. No pane lifecycle, no reaper. +- **Streaming out:** a new `GET /api/chat/:id/stream` SSE endpoint forwards `stream_event` deltas + (`content_block_delta` text/tool-input) so the browser renders live typing and tool cards as they form. +- **Governance in:** `canUseTool` awaits the gateway. A `pending` (needs a human) suspends the promise + and posts the **same** approval card the console already renders inline — no second approval system. +- **Identity / secrets / MCP:** reuse the existing per-session wiring (`injectShellSecrets`, + `injectMemberGithub`, `buildMcpConfigJson`) but feed it to the SDK via `options.env` + `options.mcpServers` + instead of the launcher env. This is the bulk of the porting work. +- **Rendering:** the SDK's typed message stream feeds the completeness pass (thinking, diffs, todos, + sub-agents, images) — the renderer becomes provider-stable instead of scraping JSONL. + +## Risks / open questions (resolve during a spike) + +1. **`canUseTool` parity with the gate hook** — confirm it fires for *every* governed surface (Bash, + file writes, each MCP tool) and can enforce the crown-jewel **deny** rules. This is the gating risk; + if a tool class bypasses `canUseTool`, governance regresses. Spike this first. +2. **Subprocess vs in-process** — the SDK can run the agent loop in-process (Node) or spawn the CLI. + In-process is cleanest for streaming but changes the failure/isolation model; the Linux uid-isolation + path (`AOS_UID_ISOLATION`) assumes a spawned process. Decide per deployment. +3. **Two runtimes to maintain** — chat on the SDK, terminals on the CLI. Acceptable (different surfaces, + different needs) but a real cost. +4. **No ttyd attach for SDK sessions** — a native chat doesn't need it, but "open in terminal" wouldn't + apply to an SDK session. Keep the two surfaces distinct. + +## Rollout + +1. **Spike (1–2 d):** one agent, `query()` + `includePartialMessages` + `canUseTool` → log-only gateway + call. Prove tool-call coverage/parity and streaming end-to-end. Kill-criteria: any governed tool that + `canUseTool` can't see. +2. **Wire governance:** `canUseTool` → real `gateway.ts`; approvals suspend/resume via the existing store. +3. **Port per-session injection** (secrets/GitHub/MCP) to SDK options. +4. **SSE + renderer:** stream deltas to the Chat page; land the completeness renderer. +5. **Flag + coexist:** `AOS_SDK_CHAT` selects the SDK runtime for the chat surface only; CLI/tmux stays + the default everywhere else. Graduate once parity is proven. + +## Relationship to v1 + +v1 (hardened, transcript-driven) stays shippable and is the fallback. v2 swaps the **driver** and +**display source** under the same Chat page and the same gateway; the page, the approval cards, and the +session model are largely reused. diff --git a/package-lock.json b/package-lock.json index b1f9712..5b2c272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.193.0", + "version": "0.193.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.193.0", + "version": "0.193.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 9386ab6..03446ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.193.0", + "version": "0.193.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/server.ts b/src/server.ts index 63a7e04..34928ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1906,8 +1906,10 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const message = String(b.message || '').trim(); if (!agent || !message) return sendJson(res, 400, { error: 'agent and message are required' }); if (!os.team.canRun(me, agent)) return sendJson(res, 403, { error: `you are not assigned to run "${agent}"` }); - // spawnedBy=chat provenance, runAs=me (accountable human for the turn), resident=true. - const s = tm.createSession(agent, chatTitle(message, agent), message, `chat:${me.id}`, false, undefined, undefined, me.id, undefined, true); + // Provenance chat:me, runAs=me (accountable human). HEADLESS one-shot (not resident): the greeting + // turn runs then tears itself down, so every subsequent turn is a clean headless resume (chatSend) — + // no warm pane to race/reap, and `alive` stays honest. See TerminalManager.chatSend. + const s = tm.createSession(agent, chatTitle(message, agent), message, `chat:${me.id}`, true, undefined, undefined, me.id, undefined, false); return sendJson(res, 200, { id: s.id, tmux: s.tmux }); } // Read the friendly conversation timeline for a session (poll this like the rest of the console). @@ -1921,9 +1923,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const convo = claudeId ? readConversation(claudeId) : { turns: [], found: false }; return sendJson(res, 200, { agent, ...convo }); } - // Reply into a session (the human's next turn). Warm path types into the live resident pane; cold - // path (a reaped session) resumes the SAME transcript seeded with the message — identical to a Slack - // thread follow-up. The replier becomes the accountable run-as for this turn. + // Reply into a chat session (the human's next turn) as a clean, self-terminating headless resume run + // seeded with the message. `busy` (409) means the prior turn is still generating — the caller keeps the + // draft and asks the human to resend shortly. The replier is the accountable run-as for this turn. const chatReplyMatch = p.match(/^\/api\/sessions\/([\w-]+)\/reply$/); if (method === 'POST' && chatReplyMatch) { const id = chatReplyMatch[1]; @@ -1932,9 +1934,10 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const b = await readBody(req); const message = String(b.message || '').trim(); if (!message) return sendJson(res, 400, { error: 'message is required' }); - if (tm.deliverToResident(id, message)) return sendJson(res, 200, { status: 'delivered' }); - if (tm.reviveResident(id, message, me.id)) return sendJson(res, 200, { status: 'revived' }); - return sendJson(res, 409, { error: 'this session could not accept the message' }); + const r = tm.chatSend(id, message, me.id); + if (r === 'busy') return sendJson(res, 409, { status: 'busy', error: 'the agent is still working on the previous message — resend in a moment' }); + if (r === 'error') return sendJson(res, 409, { error: 'this session could not accept the message' }); + return sendJson(res, 200, { status: 'sent' }); } // Session activity: "which agent-os primitives did this run use?" — the session's audit stream, // classified into a chronological timeline + a grouped count summary. Same visibility as the terminal diff --git a/src/terminal.ts b/src/terminal.ts index d609c9e..ca43e04 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -1194,6 +1194,40 @@ export class TerminalManager { return true; } + /** + * Send the human's next turn into a NATIVE-CONSOLE chat session. Unlike Slack's warm send-keys path + * (`deliverToResident`), every turn here is a clean, SELF-TERMINATING governed run: a HEADLESS in-place + * resume of the same transcript, seeded with the message as the prompt. This is the deliberate trade the + * "harden" pass makes over the warm-pane model — the message is the launch prompt (reliably starts a + * turn, vs. injected keystrokes that may not), and the run tears itself down at turn-end so `alive` + * reflects reality (no perpetual "thinking…" when a pane dies silently). Cost: a cold start per turn; + * v2 (Agent SDK runtime) removes that. The PreToolUse gate hook still governs every effect. + * + * Returns: 'busy' (a prior turn is still running — the caller asks the human to resend shortly, so we + * never double-launch two claudes on one transcript), 'sent' (a fresh resume run was launched), or + * 'error' (unknown / non-resumable session). + */ + chatSend(sessionId: string, message: string, runAs?: string): 'sent' | 'busy' | 'error' { + const row = this.db.prepare('SELECT agent, secret, claude_session_id, run_as, spawned_by FROM term_sessions WHERE id = ?') + .get<{ agent: string; secret: string | null; claude_session_id: string | null; run_as: string | null; spawned_by: string | null }>(sessionId); + if (!row || !row.claude_session_id) return 'error'; + const body = (message || '').trim(); + if (!body) return 'error'; + if (this.isAlive(sessionId)) return 'busy'; // a turn is still generating — don't launch a competing run + const actingMember = runAs ?? row.run_as ?? undefined; + const hasSlack = !!this.db.prepare('SELECT 1 FROM slack_threads WHERE session_id = ?').get(sessionId); + const hasDiscord = !!this.db.prepare('SELECT 1 FROM discord_threads WHERE session_id = ?').get(sessionId); + this.db.prepare("UPDATE term_sessions SET status = 'running', headless = 1, resident = 0, task = ?, run_as = ?, last_activity = ?, updated_at = ? WHERE id = ?") + .run(body, actingMember ?? row.run_as ?? null, Date.now(), Date.now(), sessionId); + this.audit(sessionId, row.agent, 'chat.turn', { runAs: actingMember ?? null }); + this.launchClaudeCode({ + id: sessionId, agent: row.agent, task: body, secret: row.secret ?? randomBytes(24).toString('hex'), + actingMember, spawnedBy: row.spawned_by ?? undefined, hasSlack, hasDiscord, + headless: true, resident: false, resume: true, claudeSessionId: row.claude_session_id, + }); + return 'sent'; + } + /** * "Take over" an unattended run: CLAIM its live, attachable TUI so a human can watch and steer — with * ZERO disruption. Unattended automation/task runs are now a real interactive claude in a detached tmux diff --git a/web/src/App.tsx b/web/src/App.tsx index 876048a..f34fc80 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2854,10 +2854,15 @@ function ChatPage({ agents, sessions, messages, selected, onSelect }: { // Conversation timeline for the selected session (polled). const [convo, setConvo] = useState([]) const [found, setFound] = useState(false) + // Turn tracking: when we send, record the time + the agent-turn count then, so we can tell "still + // working" from "the run ended without replying" — and never spin "thinking…" forever. + const [sentAt, setSentAt] = useState(null) + const [awaitBase, setAwaitBase] = useState(0) + const [lastSent, setLastSent] = useState('') const scrollRef = useRef(null) useEffect(() => { - if (!selected) { setConvo([]); setFound(false); return } + if (!selected) { setConvo([]); setFound(false); setSentAt(null); return } let stop = false const poll = async () => { const r = await api.conversation(selected) @@ -2875,7 +2880,20 @@ function ChatPage({ agents, sessions, messages, selected, onSelect }: { const pending = messages.filter((m) => m.sessionId === selected && (m.type === 'approval' || m.type === 'question') && (m.status ?? 'pending') === 'pending') const agentOf = (id?: string) => chatAgents.find((a) => a.id === id) || agents.find((a) => a.id === id) const activeAgent = agentOf(active?.agent) - const working = !!active && (active.alive ?? active.status === 'running') + // A turn is only ever "live" while the run's pane is actually alive — the honest signal a headless turn + // gives us (it tears down at turn-end). `agentTurns` = replies+activity so far, so we can detect a new + // agent turn landing after a send. + const alive = !!active?.alive + const agentTurns = convo.filter((t) => t.kind !== 'user').length + const gotReply = sentAt != null && agentTurns > awaitBase + // Clear the awaiting flag the moment a fresh agent turn arrives. + useEffect(() => { if (gotReply) setSentAt(null) }, [gotReply]) + const awaiting = sentAt != null + // Stalled = we sent, the run is no longer alive, and enough time passed for a cold start to have booted, + // yet no new agent turn appeared. Shows a Resend instead of an endless spinner. + const stalled = awaiting && !alive && Date.now() - (sentAt ?? 0) > 12_000 + const thinking = awaiting && !stalled + const working = alive || thinking const startChat = async () => { const agent = newAgent || chatAgents[0]?.id @@ -2898,7 +2916,23 @@ function ChatPage({ agents, sessions, messages, selected, onSelect }: { setDraft('') const r = await api.reply(selected, message) setBusy(false) - if (r.error) setErr(r.error) + if (r.status === 'busy') { setErr('The agent is still working — resend in a moment.'); setDraft(message); return } + if (r.error) { setErr(r.error); return } + // Sent: begin awaiting a reply (baseline = agent turns so far). + setLastSent(message) + setAwaitBase(convo.filter((t) => t.kind !== 'user').length) + setSentAt(Date.now()) + } + + const resend = async () => { + if (!selected || !lastSent || busy) return + setBusy(true); setErr('') + const r = await api.reply(selected, lastSent) + setBusy(false) + if (r.status === 'busy') { setErr('Still working — try again in a moment.'); return } + if (r.error) { setErr(r.error); return } + setAwaitBase(convo.filter((t) => t.kind !== 'user').length) + setSentAt(Date.now()) } const onKey = (e: ReactKeyboardEvent, fn: () => void) => { @@ -2987,9 +3021,14 @@ function ChatPage({ agents, sessions, messages, selected, onSelect }: { ? : , )} - {working && convo.length > 0 && convo[convo.length - 1].kind === 'user' && ( -
thinking…
- )} + {stalled ? ( +
+ No reply came back. + +
+ ) : thinking ? ( +
thinking…
+ ) : null} {/* Inline approvals / questions — the trust surface, in plain language */} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 30f3998..d73d4c8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1023,9 +1023,10 @@ export const api = { /** Start a chat with an agent — spawns a warm resident session. Returns its id. */ startChat: (agent: string, message: string) => call<{ id?: string; tmux?: string; error?: string }>('POST', '/api/chat/start', { agent, message }), - /** Send the human's next turn into a chat session (warm deliver, else resume). */ + /** Send the human's next turn into a chat session — a clean headless resume run. `busy` = a prior + * turn is still generating (keep the draft, resend shortly). */ reply: (id: string, message: string) => - call<{ status?: 'delivered' | 'revived'; error?: string }>('POST', `/api/sessions/${id}/reply`, { message }), + call<{ status?: 'sent' | 'busy'; error?: string }>('POST', `/api/sessions/${id}/reply`, { message }), /** Upload a pasted/dropped/picked file (ANY type) into a live session; the server saves it in the * agent's folder and types the path into the running claude. `dataB64` is base64 (no data: prefix); * `ext` e.g. 'pdf'; `name` is the original filename, preserved when given. */