Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions docs/sdk-chat-runtime-plan.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 11 additions & 8 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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];
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 45 additions & 6 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2854,10 +2854,15 @@ function ChatPage({ agents, sessions, messages, selected, onSelect }: {
// Conversation timeline for the selected session (polled).
const [convo, setConvo] = useState<ChatTurn[]>([])
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<number | null>(null)
const [awaitBase, setAwaitBase] = useState(0)
const [lastSent, setLastSent] = useState('')
const scrollRef = useRef<HTMLDivElement>(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)
Expand All @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -2987,9 +3021,14 @@ function ChatPage({ agents, sessions, messages, selected, onSelect }: {
? <ActivityCard key={i} turn={t} />
: <ChatBubble key={i} turn={t} agentIcon={activeAgent?.icon} />,
)}
{working && convo.length > 0 && convo[convo.length - 1].kind === 'user' && (
<div className="pl-9 text-xs text-muted-foreground">thinking…</div>
)}
{stalled ? (
<div className="flex items-center gap-2 pl-1 text-xs text-muted-foreground">
<span>No reply came back.</span>
<button className="font-medium text-primary hover:underline" onClick={resend}>Resend</button>
</div>
) : thinking ? (
<div className="pl-9 text-xs text-muted-foreground animate-pulse">thinking…</div>
) : null}
</div>

{/* Inline approvals / questions — the trust surface, in plain language */}
Expand Down
5 changes: 3 additions & 2 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading