From 1511bc9011e60466e9eac955132d273360e7d9e2 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Thu, 17 Sep 2026 16:20:47 +0530 Subject: [PATCH] =?UTF-8?q?feat(sessions):=20pause=20a=20session=20?= =?UTF-8?q?=E2=80=94=20take=20the=20agent's=20memory,=20keep=20its=20conve?= =?UTF-8?q?rsation=20(v0.446.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new session status, `paused`: neither live nor terminal. Pausing kills the agent's claude — which is what actually frees the ~500 MB of context, the runtime-account slot and the concurrency-cap slot — and leaves the transcript on disk, so Resume relaunches on the SAME conversation with the full context back (`claude --resume`). While paused the session is readable and nothing else. `reachable()` refuses a paused row, which covers every path that types into a live pane; every path that RESURRECTS checks the status itself, because those read a false from `reachable()` as "the pane is gone, relaunch it" — the opposite of a pause. Those are `reviveResident` (Slack/Discord/ClickUp/Telegram thread + DM continuity), `chatSend`, `takeoverRun`, `takeoverToTerminal`, `reloadSession`, and the wake-up resume lane, where a finishing delegate's news is kept QUEUED for the resume rather than dropped. The ttyd WebSocket authz refuses a paused id (a tab left open never passes through /api/sessions/:id/attach), and the stop-block /resume route refuses one too — attaching would resurrect the agent by the back door and leave the status lying. A pause is deliberately not an ending: no episode, no completion card, and a status no roll-up scores — `outcome.ts` would have read it `incomplete`/`stopped-midway`, `agent-stats` would have counted it against the maturity tier that gates unattended cross-agent edits, and the 14-day tidy would have archived a conversation somebody meant to come back to. Stop stays available on a paused run. Resume preserves `resident` but brings an unattended run back attended and claimed, mirroring `takeoverRun`: it seeds no prompt, so left headless it would be idle-reaped within the hour and the person who pressed Resume would watch it vanish. Pinned by scripts/session-pause-test.cjs (62 checks incl. a real-HTTP round trip) and an extended scripts/session-revive-gates-test.cjs (a paused session offers neither attach-Resume nor Take over, and always renders read-only). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 18 ++ docs/session-lifecycle-hooks.md | 48 ++++- package-lock.json | 4 +- package.json | 4 +- scripts/session-pause-test.cjs | 275 ++++++++++++++++++++++++++ scripts/session-revive-gates-test.cjs | 15 +- src/edge/outcome.ts | 5 +- src/edge/wakeups.ts | 18 ++ src/server.ts | 38 ++++ src/state/agent-stats.ts | 6 +- src/state/db.ts | 12 +- src/terminal.ts | 186 +++++++++++++++-- web/src/App.tsx | 142 ++++++++++--- web/src/lib/api.ts | 13 +- web/src/v2/SessionViewer.tsx | 1 + web/src/v2/data.ts | 1 + 16 files changed, 728 insertions(+), 58 deletions(-) create mode 100644 scripts/session-pause-test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9bc049..95039fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ new version heading in the same commit. ## [Unreleased] +## [0.446.0] - 2026-09-17 +### Added +- **Pause a session.** A new session status, `paused`, that is neither live nor finished: pausing kills the + agent's claude — giving the box back its ~500 MB, its runtime-account slot and its concurrency-cap slot — + while leaving the conversation on disk, so Resume relaunches on the SAME transcript with the full context + back (`claude --resume`). While paused the session is **readable and nothing else**: its conversation + timeline scrolls as usual, and every path that could hand the agent a process back refuses it by name — + a chat-thread reply (Slack/Discord/ClickUp/Telegram and DM continuity), a console chat message, a Quick + Shortcut inject, Take over, Reload, the browser terminal's WebSocket, and the wake-up resume lane, where + a finishing delegate's news stays **queued** for the resume instead of being dropped. A pause is + deliberately not an ending — no episode, no completion card, and no roll-up scores it: the outcome + verdict skips it, the maturity score counts it as in-flight rather than as a stopped run, and the + stale-session tidy won't archive a conversation someone means to come back to. Stop stays available on a + paused run, for "I'm not coming back". Refused where it would be a one-way trip (a run with no resumable + conversation is told to stop instead). Pinned by `scripts/session-pause-test.cjs`. + **For users:** Pause a session from the terminal's **Operations** menu to stop an agent without losing + its work — the conversation stays readable, and Resume picks it up exactly where it left off with its + full context. [Open Sessions](#/sessions) ## [0.445.1] - 2026-09-17 ### Fixed - **Agent operating notes brought back in line with the tools agents actually have.** The OS-owned diff --git a/docs/session-lifecycle-hooks.md b/docs/session-lifecycle-hooks.md index a3f72230..ca687f06 100644 --- a/docs/session-lifecycle-hooks.md +++ b/docs/session-lifecycle-hooks.md @@ -57,7 +57,8 @@ spins on (`Session.working`), because a warm pane outlives the turn it answered, Set by: `UserPromptSubmit`, a server-side delivery (`chatSend` / inject), a resident spawn. Cleared by: `clearTurnBusy`, called from `markTurnIdle` (Stop), `StopFailure`, `SessionEnd`, and every -terminal status transition (`done` / `stopped` / `crashed`). +terminal status transition (`done` / `stopped` / `crashed`), plus `paused` (see below), which is not +terminal but does mean no turn is in flight. **It used to be a one-way latch.** The clear lived inside `markTurnIdle`'s `resident` branch only, so a member's own interactive session returned before reaching it and never cleared — the console drew a @@ -68,8 +69,8 @@ spinner on finished sessions forever. Live northwind carried the flag on 72 of 5 that no missed signal can strand a spinner: 1. `busy_since` is set; -2. the row is not `stopped`/`crashed` (`done` still counts — an agent that calls `report` flips its row - mid-turn and keeps working); +2. the row is not `stopped`/`crashed`/`paused` (`done` still counts — an agent that calls `report` flips + its row mid-turn and keeps working); 3. the runtime is still there (a pane that died mid-turn leaves the flag set); 4. no turn-END was recorded after the turn started (`last_activity > busy_since`) — this is what heals rows latched by an older build, with no migration needed; @@ -78,6 +79,47 @@ that no missed signal can strand a spinner: A one-time migration in `src/state/db.ts` also NULLs the already-latched rows (terminal ones, plus any turn older than the 2h ceiling); a genuinely in-flight turn is untouched, so it is safe on a live box. +## `paused` — the status that is neither live nor terminal + +`TerminalManager.pauseSession` kills a session's claude and stamps the row `paused`. Killing the process +is the point: it is what frees the ~500 MB of context, the runtime-account slot and the concurrency-cap +slot (every counter reads `status = 'running'`). The conversation is untouched — claude's transcript is a +file on disk — so `resumeSession` relaunches with `claude --resume ` and the full context is +back. Pausing is refused unless there is a live pane AND a pinned, resumable conversation; without the +latter a "pause" would be a one-way stop wearing the wrong word. + +It is a STATUS rather than a flag over `stopped` because a paused run has not finished, and three +roll-ups would otherwise score it as one: `outcome.ts` (which would read it `incomplete` / +`stopped-midway`), `agent-stats.ts` (where it would count against the maturity tier that decides whether +an agent may edit a teammate unattended), and the 14-day stale-session tidy (which would archive a +conversation somebody meant to come back to). All three exclude it. + +**The invariant: nothing may hand a paused agent a process back except `resumeSession`.** Two layers +enforce it, and both are needed: + +- `reachable()` refuses a `paused` row, which covers every path that types into a live pane — inject, + `deliverToResident`, a pasted file, the wake-up inject lanes. +- Every path that RESURRECTS checks `status === 'paused'` itself, because those read a false from + `reachable()` as "the pane is gone, relaunch it" — the exact opposite of a pause. Those are + `reviveResident` (the cold path behind Slack/Discord/ClickUp/Telegram thread continuity and DM + continuity), `chatSend`, `takeoverRun`, `takeoverToTerminal`, `reloadSession`, and the wake-up **resume + lane** in `wakeups.ts`, where a finishing delegate would otherwise start a fresh claude on the paused + transcript. That last one keeps the wake-up **pending** rather than dropping it, so the news is + delivered by the resume. + +Two more edges outside the manager: `sharedTerminalAuthz` refuses the ttyd WebSocket for a paused session +(a tab left open or a pasted terminal URL never passes through `/api/sessions/:id/attach`), and the +generic `/resume` route — which only lifts the stay-stopped sentinel — refuses a paused row, since +letting the terminal attach would resurrect the agent by the back door and leave the status lying. +`blockResume` drops the same sentinel a stop does, so ttyd's silent auto-reconnect can't revive it either. + +`stopSession` deliberately still works on a paused row: pausing is not a decision to end the run, and +Stop is how you say "I'm not coming back" — it writes the episode and clears the paused stamp. + +Pinned by `scripts/session-pause-test.cjs` (the server half) and `scripts/session-revive-gates-test.cjs` +(the console half — a paused session offers neither attach-Resume nor Take over, and always renders +read-only). + ## Testing `scripts/turn-lifecycle-test.cjs` (in `npm run test:governance`) pins the whole machine against a stubbed diff --git a/package-lock.json b/package-lock.json index bd7a77b3..b5a21f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.445.1", + "version": "0.446.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.445.1", + "version": "0.446.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 509c313f..270b5902 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.445.1", + "version": "0.446.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/context-injection-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/session-pause-test.cjs b/scripts/session-pause-test.cjs new file mode 100644 index 00000000..1a037a95 --- /dev/null +++ b/scripts/session-pause-test.cjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node +/* Session PAUSE — suspend an agent (freeing its memory) without ending its run, then bring the same + * conversation back. + * + * The feature is one status (`paused`) plus a promise: while a session holds it, NOTHING may give the + * agent a process back except a deliberate resume. That promise is the whole test surface, because every + * way it can break is silent — a Slack reply, a delegate finishing, a chat message or a stale browser tab + * would each relaunch the agent and leave the row reading `paused` while its claude runs, which is + * exactly the state the feature exists to make impossible. + * + * The other half is what a pause must NOT do. It is not an ending: no episode, no completion card, and a + * status no roll-up scores — a paused run must not be graded `incomplete`, counted against the agent's + * maturity, or archived by the stale-session tidy. + * + * Isolated home; the session backend is stubbed, so no tmux and no real `claude` are involved. */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-pause-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +delete process.env.AGENT_OS_SECRET_KEY; + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d ? ' — ' + d : ''}`)); + +const { loadAgentOS } = require(path.join(ROOT, 'dist/kernel.js')); +const { TerminalManager } = require(path.join(ROOT, 'dist/terminal.js')); + +const aos = loadAgentOS(); +const tm = new TerminalManager(aos, 'http://127.0.0.1:0', path.join(HOME, 'tmux.sock')); + +// ── stub backend: we control which panes are "alive" and record every effect ──────────────────── +let livePanes = new Set(); +// `launching` — not the backend's spawn list — is how we detect a relaunch: launchAgentRuntime stamps it +// SYNCHRONOUSLY and defers the actual spawn to a setImmediate, so counting spawns would make every +// "nothing relaunched" assertion below pass for the wrong reason. +const relaunching = (id) => tm.launching.has(id); +const killed = []; // panes torn down +tm.backend.aliveNames = () => new Set(livePanes); +tm.backend.injectText = (_s, tmux) => livePanes.has(tmux); +tm.backend.spawn = (_s, o) => { livePanes.add(o.tmuxName); }; +tm.backend.kill = (_s, tmux) => { killed.push(tmux); livePanes.delete(tmux); }; +tm.backend.capturePane = () => ''; +tm.backend.hasClient = () => false; + +const row = (id) => aos.db.prepare('SELECT * FROM term_sessions WHERE id = ?').get(id); +const auditTypes = (id) => aos.db.prepare('SELECT type FROM audit_events WHERE run_id = ? ORDER BY ts').all(id).map((r) => r.type); + +let n = 0; +/** A live claude-code session. `resumable` by construction: it has a pinned transcript id. */ +const mkSession = (o = {}) => { + const id = 'ses_pause_' + (++n); + const cols = { + id, agent: 'agent-author', title: 'run', task: 'do a thing', tmux: 'aos-' + id, status: 'running', + headless: 0, resident: 0, claude_session_id: 'cs-' + id, secret: 'sec', spawned_by: 'm_alice', + run_as: 'm_alice', busy_since: Date.now(), last_activity: Date.now(), + created_at: Date.now(), updated_at: Date.now(), ...o, + }; + aos.db.prepare(`INSERT INTO term_sessions (id,agent,title,task,tmux,status,headless,resident,claude_session_id,secret,spawned_by,run_as,busy_since,last_activity,created_at,updated_at) + VALUES (@id,@agent,@title,@task,@tmux,@status,@headless,@resident,@claude_session_id,@secret,@spawned_by,@run_as,@busy_since,@last_activity,@created_at,@updated_at)`).run(cols); + livePanes.add(cols.tmux); + return id; +}; + +console.log('\n\x1b[1m1) pausing kills the agent and keeps the run\x1b[0m'); +{ + const id = mkSession(); + const r = tm.pauseSession(id, 'alice@example.com'); + assert(r.ok, 'pause succeeds on a live session'); + assert(!livePanes.has('aos-' + id), 'the pane (and with it the claude process) is killed'); + const after = row(id); + assert(after.status === 'paused', `status is 'paused'`, after.status); + assert(after.busy_since === null, 'busy_since is cleared — nothing is generating'); + assert(after.paused_by === 'alice@example.com', 'paused_by records who did it'); + assert(after.paused_at > 0, 'paused_at records when'); + assert(after.claude_session_id === 'cs-' + id, 'the conversation id survives — that is what resume replays'); + assert(auditTypes(id).includes('session.paused'), 'audited session.paused'); + // A pause is NOT an ending: an episode would tell Dreaming and the consolidator the run is over. + assert(!auditTypes(id).includes('episode.stored'), 'no episode is written — the run is not over'); + const cards = aos.db.prepare("SELECT COUNT(*) c FROM messages WHERE session_id = ? AND type = 'completed'").get(id).c; + assert(cards === 0, 'no completion card is posted'); + assert(tm.isPaused(id), 'isPaused() reports it'); + assert(!tm.reachable(id), 'reachable() refuses it — every keystroke path reads this'); +} + +console.log('\n\x1b[1m2) nothing may give a paused agent a process back\x1b[0m'); +{ + const id = mkSession(); + tm.pauseSession(id, 'alice@example.com'); + // Each of these is a real wake path, and each would otherwise relaunch claude on the transcript. + assert(tm.reviveResident(id, 'any news?') === false, 'a chat-thread reply (Slack/Discord/ClickUp/Telegram + DM continuity) is refused'); + assert(tm.chatSend(id, 'hello?') === 'paused', 'a console chat message reports paused rather than relaunching'); + assert(tm.deliverToResident(id, 'hi') === false, 'send-keys delivery is refused'); + assert(tm.injectToSession(id, 'hi', true, 'alice@example.com').ok === false, 'a Quick Shortcut inject is refused'); + assert(tm.takeoverRun(id, 'alice@example.com').ok === false, 'take-over is refused (it would resurrect it)'); + assert(tm.takeoverToTerminal(id, 'alice@example.com').ok === false, 'chat take-over is refused'); + assert(tm.reloadSession(id, 'alice@example.com').ok === false, 'reload is refused'); + + assert(!relaunching(id), 'not one of them started a runtime launch'); + assert(row(id).status === 'paused', 'the row is still paused after all of it'); +} + +console.log('\n\x1b[1m3) a paused run holds no slot and is not counted as live\x1b[0m'); +{ + const id = mkSession(); + const liveBefore = tm.aliveSessionCount(); + const admitBefore = tm.admissionSessionCount(); + tm.pauseSession(id, 'alice@example.com'); + assert(tm.aliveSessionCount() === liveBefore - 1, 'it stops counting toward the live-session count'); + assert(tm.admissionSessionCount() === admitBefore - 1, 'it releases its concurrency-cap work slot'); + assert(tm.runningSessionCount() === aos.db.prepare("SELECT COUNT(*) c FROM term_sessions WHERE status = 'running'").get().c, + 'the DB fallback count agrees'); +} + +console.log('\n\x1b[1m4) resuming brings the SAME conversation back\x1b[0m'); +{ + const id = mkSession(); + tm.pauseSession(id, 'alice@example.com'); + const r = tm.resumeSession(id, 'alice@example.com'); + assert(r.ok, 'resume succeeds'); + assert(relaunching(id), 'a runtime launch is started — the agent gets its process back'); + const after = row(id); + assert(after.status === 'running', 'the row is running again'); + assert(after.paused_at === null && after.paused_by === null, 'the paused stamp is cleared'); + assert(after.claude_session_id === 'cs-' + id, 'still the same conversation — context is restored from disk'); + assert(auditTypes(id).includes('session.unpaused'), 'audited session.unpaused'); + assert(!tm.isPaused(id), 'no longer paused'); + // Resume seeds no prompt, so an unattended run would have nothing to do and be idle-reaped within the + // hour — the human who pressed Resume would watch it vanish. It comes back attended and claimed. + assert(after.headless === 0 && after.claimed_by === 'alice@example.com', 'it comes back attended, claimed by whoever resumed it'); +} + +console.log('\n\x1b[1m5) a resident chat resumes resident, so its next message is warm\x1b[0m'); +{ + const id = mkSession({ resident: 1, headless: 0, spawned_by: 'chat:agent-author' }); + tm.pauseSession(id, 'alice@example.com'); + tm.resumeSession(id, 'alice@example.com'); + assert(row(id).resident === 1, 'resident is preserved across the pause'); + assert(tm.reachable(id), 'and it is reachable again — a chat turn goes to the live pane'); +} + +console.log('\n\x1b[1m6) the edges of pause/resume themselves\x1b[0m'); +{ + // Nothing to pause: no live agent means no memory to take away. + const dead = mkSession({ status: 'stopped' }); + livePanes.delete('aos-' + dead); + assert(tm.pauseSession(dead, 'alice@example.com').ok === false, 'a session with no live agent cannot be paused'); + + // A run with no conversation cannot come back, so "pause" would be a one-way stop wearing the wrong word. + const noConvo = mkSession({ claude_session_id: null }); + const r = tm.pauseSession(noConvo, 'alice@example.com'); + assert(r.ok === false && /stop it instead/.test(r.error || ''), 'a run with no resumable conversation is refused, and told to stop instead'); + assert(livePanes.has('aos-' + noConvo), 'and its pane is left alone — a refused pause kills nothing'); + + const id = mkSession(); + assert(tm.resumeSession(id, 'alice@example.com').ok === false, 'resuming a session that is not paused is refused'); + tm.pauseSession(id, 'alice@example.com'); + assert(tm.pauseSession(id, 'alice@example.com').ok === true, 'pausing twice is idempotent, not an error'); + + // Stop stays available on a paused run — "I'm not coming back" — and it ends it properly. + tm.stopSession(id, 'alice@example.com'); + const after = row(id); + assert(after.status === 'stopped', 'a paused session can still be stopped for good'); + assert(after.paused_at === null && after.paused_by === null, 'stopping clears the paused stamp rather than leaving it on the row forever'); + assert(tm.resumeSession(id, 'alice@example.com').ok === false, 'and it can no longer be resumed'); +} + +console.log('\n\x1b[1m7) no roll-up scores a paused run\x1b[0m'); +{ + const id = mkSession(); + tm.pauseSession(id, 'alice@example.com'); + // The outcome roll-up: `paused` must be excluded with `running`, or the only branch it could reach + // stamps it 'incomplete'/'stopped-midway' — the opposite of what the status means. + const src = fs.readFileSync(path.join(ROOT, 'src/edge/outcome.ts'), 'utf8'); + assert(/status NOT IN \('running','paused'\)/.test(src), 'outcome.ts excludes paused rows from scoring'); + // The maturity roll-up: paused counts as in-flight, never as a finished or a stopped run. + const stats = fs.readFileSync(path.join(ROOT, 'src/state/agent-stats.ts'), 'utf8'); + assert(/r\.status === 'running' \|\| r\.status === 'paused'/.test(stats), 'agent-stats counts paused as in-flight'); + // The stale-session tidy + the "ended recently" feed count both enumerate statuses, so paused is + // excluded by construction — pin it, because adding it there would archive a live conversation. + for (const [file, label] of [['src/edge/session-tidy.ts', 'the stale-session tidy'], ['src/edge/improvements.ts', 'the declutter tile']]) { + const s = fs.readFileSync(path.join(ROOT, file), 'utf8'); + assert(!/IN \('stopped','crashed','paused'\)/.test(s) && !/'paused'/.test(s), `${label} never archives a paused session`); + } +} + +console.log('\n\x1b[1m8) a delegate finishing must not resume a paused caller\x1b[0m'); +{ + // The wake-up resume lane does not type into a session — it starts a FRESH claude on the transcript, + // which is the pause being undone by a delegate completing its work. The news must WAIT, not be lost. + const src = fs.readFileSync(path.join(ROOT, 'src/edge/wakeups.ts'), 'utf8'); + assert(/pausedTranscript\(newest\.transcript\)/.test(src), 'the resume lane checks for a paused caller'); + assert(/status = 'paused'/.test(src), 'and it matches on the paused status'); + const guard = src.slice(src.indexOf('pausedTranscript(newest.transcript)')); + assert(/this\.bump\(pending\)/.test(guard.slice(0, 300)) && /queued: true/.test(guard.slice(0, 400)), + 'the wake-up stays QUEUED for the resume rather than being dropped'); +} + +console.log('\n\x1b[1m9) the browser terminal cannot attach to a paused session\x1b[0m'); +{ + // The ttyd WebSocket authz is the enforcement point: a direct terminal URL (a tab left open, a pasted + // link) never passes through /api/sessions/:id/attach. + const src = fs.readFileSync(path.join(ROOT, 'src/server.ts'), 'utf8'); + const authz = src.slice(src.indexOf('function sharedTerminalAuthz'), src.indexOf('function sharedTerminalAuthz') + 1200); + assert(/if \(tm\.isPaused\(id\)\) return false/.test(authz), 'sharedTerminalAuthz refuses a paused session id'); + assert(/this session is paused — resume it to use its terminal/.test(src), 'and the attach route says so in words'); + // The generic /resume route only lifts the stay-stopped sentinel; used on a paused row it would + // resurrect the agent by the back door and leave the status lying. + assert(/use resume \(unpause\) instead/.test(src), 'the stop-block /resume route refuses a paused session'); +} + +console.log('\n\x1b[1m10) end to end over real HTTP — the routes, the gate and the attach refusal\x1b[0m'); +(async () => { + // A second, registry-backed runtime on an ephemeral port: this is the only way to prove the routes are + // WIRED (a handler that isn't reached 404s, then falls through to the login gate as a 401 — the + // stale-server symptom that reads as an auth bug) and that the authz layer sits in front of them. + const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); + const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js')); + const registry = new TenantRegistry(ROOT, 0, path.join(ROOT, 'config/agent-os.config.json')); + registry.bootAll(); + const { os: haos, tm: htm } = registry.default(); + const server = createHttpServer(registry); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + + let live = new Set(); + htm.backend.aliveNames = () => new Set(live); + htm.backend.kill = (_s, tmux) => { live.delete(tmux); }; + htm.backend.spawn = (_s, o) => { live.add(o.tmuxName); }; + htm.backend.capturePane = () => ''; + htm.backend.hasClient = () => false; + + const post = (u, cookie) => fetch(base + u, { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: '{}' }); + + // Unauthenticated: 401 means ROUTED and login-gated. A 404 would mean the route isn't there at all. + assert((await post('/api/sessions/ses_nope/pause')).status === 401, 'POST /pause is routed and login-gated'); + assert((await post('/api/sessions/ses_nope/unpause')).status === 401, 'POST /unpause is routed and login-gated'); + + const owner = haos.team.listMembers().find((m) => m.role === 'owner'); + const cookie = `aos_sid=${haos.team.createSession(owner.id)}`; + const sid = 'ses_http_pause'; + haos.db.prepare(`INSERT INTO term_sessions (id,agent,title,task,tmux,status,headless,resident,claude_session_id,secret,spawned_by,run_as,created_at,updated_at) + VALUES (?,?,?,?,?,'running',0,0,?,?,?,?,?,?)`) + .run(sid, 'agent-author', 'run', 'task', 'aos-' + sid, 'cs-http', 'sec', owner.id, owner.id, Date.now(), Date.now()); + live.add('aos-' + sid); + + const paused = await post(`/api/sessions/${sid}/pause`, cookie).then((r) => r.json()); + assert(paused.ok === true, 'an authenticated owner can pause it over HTTP', paused); + assert(haos.db.prepare('SELECT status FROM term_sessions WHERE id = ?').get(sid).status === 'paused', 'the row is paused'); + + // The browser terminal must be refused while paused — this is the "readable, never usable" half. + const attach = await fetch(base + `/api/sessions/${sid}/attach`, { headers: { cookie } }); + assert(attach.status === 409, 'GET /attach refuses a paused session (409)', attach.status); + // …and the stop-block /resume route must not become a back door that resurrects it. + assert((await post(`/api/sessions/${sid}/resume`, cookie)).status === 400, 'POST /resume refuses a paused session'); + // Take over would resurrect it too. + assert((await post(`/api/sessions/${sid}/interactive`, cookie)).status === 400, 'POST /interactive refuses a paused session'); + // The transcript stays readable — that is what "see it by scrolling" means. + assert((await fetch(base + `/api/sessions/${sid}/conversation`, { headers: { cookie } })).status === 200, 'the conversation is still readable while paused'); + + const back = await post(`/api/sessions/${sid}/unpause`, cookie).then((r) => r.json()); + assert(back.ok === true, 'unpause succeeds over HTTP', back); + assert(haos.db.prepare('SELECT status FROM term_sessions WHERE id = ?').get(sid).status === 'running', 'and the row is running again'); + + server.close(); + try { registry.stopAll(); } catch { /* best effort */ } + + console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m`); + try { fs.rmSync(HOME, { recursive: true, force: true }); } catch { /* best effort */ } + process.exit(fail ? 1 : 0); +})(); diff --git a/scripts/session-revive-gates-test.cjs b/scripts/session-revive-gates-test.cjs index bffcf059..4eaab188 100755 --- a/scripts/session-revive-gates-test.cjs +++ b/scripts/session-revive-gates-test.cjs @@ -34,7 +34,7 @@ const liftEnded = () => { return m[1].replace(/Boolean\(session\) && /, '').replace(/session!/g, 's').replace(/ && !overrideAttach/, ''); }; -const gates = new Function(`${lift('isLive')}\n${lift('canResume')}\n${lift('canGoInteractive')}\nconst ended = (s) => ${liftEnded()};\nreturn { isLive, canResume, canGoInteractive, ended }`)(); +const gates = new Function(`${lift('isLive')}\n${lift('isPaused')}\n${lift('canResume')}\n${lift('canGoInteractive')}\nconst ended = (s) => ${liftEnded()};\nreturn { isLive, isPaused, canResume, canGoInteractive, ended }`)(); /** The session shapes the server actually produces. `forkable` ⇒ a pinned claude_session_id exists. */ const S = (o) => ({ status: 'done', alive: false, forkable: true, ...o }); @@ -48,6 +48,13 @@ const CASES = [ ['stopped attended session (has env)', S({ status: 'stopped', resumable: true }), true, false, false], ['stopped after being claimed (no env)', S({ status: 'stopped', claimedBy: 'a@b' }), false, true, true], ['crashed attended session (has env)', S({ status: 'crashed', resumable: true }), true, false, false], + // PAUSED — suspended by a human, not finished. Whatever lane it was on, it is read-only and offers + // NEITHER Resume-by-attach (attach.sh holds its stay-paused sentinel) nor Take over (the server refuses + // it): its one way back is the dedicated Resume button, which calls /unpause. An attended, resumable + // paused session is the case that would otherwise attach to a terminal that never opens. + ['paused attended session (has env)', S({ status: 'paused', resumable: true }), false, false, true], + ['paused unattended run', S({ status: 'paused', headless: true, resumable: true }), false, false, true], + ['paused resident chat', S({ status: 'paused', resumable: true, resident: true }), false, false, true], ]; console.log('\n1) each session shape offers exactly what can actually revive it'); @@ -61,7 +68,11 @@ console.log('\n2) the invariants behind the table'); for (const [name, s] of CASES) { check(`${name}: Resume and Take over are never both offered`, !(gates.canResume(s) && gates.canGoInteractive(s))); // A dead run with a conversation must have a way back — otherwise the session is a dead end in the UI. - if (!gates.isLive(s) && s.forkable) + // A PAUSED run is deliberately exempt: it is not a dead end (Resume → /unpause is rendered in the + // transcript header) but it must offer neither of THESE two, both of which relaunch behind the + // server's back. Asserting the exemption, so a future edit can't quietly hand it one of them. + if (gates.isPaused(s)) check(`${name}: neither attach-resume nor take-over`, !gates.canResume(s) && !gates.canGoInteractive(s)); + else if (!gates.isLive(s) && s.forkable) check(`${name}: dead but revivable → one of the two is offered`, gates.canResume(s) || gates.canGoInteractive(s)); // Never attach to a pane that is gone with nothing to bring it back (the raw tmux error). if (!gates.isLive(s) && !s.resumable) check(`${name}: never attached to`, gates.ended(s)); diff --git a/src/edge/outcome.ts b/src/edge/outcome.ts index efeb2c4f..3d127515 100644 --- a/src/edge/outcome.ts +++ b/src/edge/outcome.ts @@ -287,7 +287,10 @@ export function deriveRunOutcomes( .prepare( 'SELECT id, agent, status, spawned_by, claude_session_id, rating, outcome, tool_calls, active_ms, created_at, ' + 'COALESCE(updated_at, created_at) AS ended FROM term_sessions ' + - "WHERE created_at >= ? AND created_at < ? AND status != 'running'", + // `paused` joins `running` in the exclusion: a paused run has not finished, so scoring it would + // stamp a verdict on work a human explicitly intends to come back to — and the only branch it + // could land in reads "stopped-midway", which is the opposite of what the status means. + "WHERE created_at >= ? AND created_at < ? AND status NOT IN ('running','paused')", ) .all(since, until); diff --git a/src/edge/wakeups.ts b/src/edge/wakeups.ts index dccf7246..8dcc7c12 100644 --- a/src/edge/wakeups.ts +++ b/src/edge/wakeups.ts @@ -203,6 +203,15 @@ export class WakeupQueue { return this.deliver(agent, opts); } + /** Has a human PAUSED the session that owns this transcript? The resume lane's veto — see its use + * below. Matches on the pinned claude id, which is what `--resume` would reopen; a transcript with no + * paused session (the ordinary case) costs one indexed lookup. */ + private pausedTranscript(claudeSessionId: string): boolean { + return this.db + .prepare("SELECT 1 FROM term_sessions WHERE claude_session_id = ? AND status = 'paused' LIMIT 1") + .get(claudeSessionId) != null; + } + /** * Deliver every pending wake-up for ONE agent, coalesced, down the highest-priority reachable lane. * Undelivered work stays pending (attempts bumped) — never dropped, never doubled. @@ -275,6 +284,15 @@ export class WakeupQueue { // Lane 3 — nothing this batch may speak into. Resume the transcript in a fresh `poke:` run. One // session for ALL pending wake-ups: they were coalesced above, so N completions cost one claude, not N. if (doneOnly) return this.dropDone(agentId, pending, live.length ? 'done-no-own-pane' : 'done-cold-caller'); + // …unless a human PAUSED the conversation this lane would resume. Lanes 1 and 2 can't reach a paused + // run (`reachable` refuses it), but this one doesn't type into a session — it starts a fresh claude on + // the transcript, which is precisely the pause being undone by a delegate finishing its work. Keep the + // wake-up PENDING rather than dropping it: resuming the session is what should deliver it, so the news + // is waiting in the queue the moment somebody presses Resume. + if (this.pausedTranscript(newest.transcript)) { + this.bump(pending); + return { ok: false, reason: 'the caller session is paused — queued until it is resumed', queued: true }; + } if (opts.budget !== undefined && opts.budget <= 0) { this.bump(pending); return { ok: false, reason: 'at the concurrency cap — queued for the next tick', queued: true }; diff --git a/src/server.ts b/src/server.ts index 7b7feac4..943926ee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3491,6 +3491,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: if (!message) return sendJson(res, 400, { error: 'message is required' }); 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 === 'paused') return sendJson(res, 409, { status: 'paused', error: 'this session is paused — resume it to keep talking' }); if (r === 'error') return sendJson(res, 409, { error: 'this session could not accept the message' }); return sendJson(res, 200, { status: 'sent' }); } @@ -3639,6 +3640,10 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const id = attachMatch[1]; if (!tm.sessionAgent(id)) return sendJson(res, 404, { error: 'unknown session' }); if (!tm.canOperateSession(id, me)) return sendJson(res, 403, { error: 'not allowed to attach to this session' }); + // Attaching is what resurrects a session (attach.sh replays its launch env), so a paused one must not + // be attachable — the console renders its transcript read-only instead. The stay-paused sentinel + // already makes attach.sh refuse; this is the honest error rather than a terminal that opens blank. + if (tm.isPaused(id)) return sendJson(res, 409, { error: 'this session is paused — resume it to use its terminal' }); try { const attachUrl = await tm.attachUrl(id); return sendJson(res, attachUrl ? 200 : 404, attachUrl ? { url: attachUrl } : { error: 'unknown session' }); @@ -3719,6 +3724,28 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: if (!tm.canOperateSession(id, me)) return sendJson(res, 403, { error: 'not allowed to manage this session' }); return sendJson(res, 200, { ok: tm.stopSession(id, me.email) }); } + // Pause a session: kill its claude (giving the box back its memory) but keep the conversation on disk, + // so Resume below brings the SAME transcript back. While paused the session is readable and nothing + // else — every wake path refuses it, including the browser terminal (see sharedTerminalAuthz). Same + // per-member gate as stop. + const pauseMatch = p.match(/^\/api\/sessions\/([\w-]+)\/pause$/); + if (method === 'POST' && pauseMatch) { + const id = pauseMatch[1]; + if (!tm.sessionAgent(id)) return sendJson(res, 404, { error: 'unknown session' }); + if (!tm.canOperateSession(id, me)) return sendJson(res, 403, { error: 'not allowed to manage this session' }); + const r = tm.pauseSession(id, me.email); + return sendJson(res, r.ok ? 200 : 400, r); + } + // Un-pause: relaunch the agent on the same conversation (`claude --resume`), seeded with no prompt, so + // the human lands in a live TUI holding the full transcript. Same per-member gate as pause. + const unpauseMatch = p.match(/^\/api\/sessions\/([\w-]+)\/unpause$/); + if (method === 'POST' && unpauseMatch) { + const id = unpauseMatch[1]; + if (!tm.sessionAgent(id)) return sendJson(res, 404, { error: 'unknown session' }); + if (!tm.canOperateSession(id, me)) return sendJson(res, 403, { error: 'not allowed to manage this session' }); + const r = tm.resumeSession(id, me.email); + return sendJson(res, r.ok ? 200 : 400, r); + } // Take over a run: if it's still LIVE, CLAIM its TUI and attach — no kill, no resume, nothing // interrupted. If it ENDED/STOPPED as a headless run, RESURRECT it in place (`claude --resume` the same // transcript) as a claimed interactive TUI. Either way it's marked sticky so it isn't auto-closed at @@ -3789,6 +3816,10 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const id = resumeMatch[1]; if (!tm.sessionAgent(id)) return sendJson(res, 404, { error: 'unknown session' }); if (!tm.canOperateSession(id, me)) return sendJson(res, 403, { error: 'not allowed to manage this session' }); + // A PAUSED session has its own resume (`/unpause`), which relaunches the agent. Lifting the sentinel + // here and letting the terminal attach would resurrect it by the back door, leaving the row stamped + // `paused` while its claude runs — the one state the whole feature exists to make impossible. + if (tm.isPaused(id)) return sendJson(res, 400, { error: 'this session is paused — use resume (unpause) instead' }); tm.allowResume(id); return sendJson(res, 200, { ok: true }); } @@ -4370,6 +4401,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const sent = tm.chatSend(existing.sessionId, message, me.id); if (sent === 'sent') return sendJson(res, 200, { ok: true, sessionId: existing.sessionId }); if (sent === 'busy') return sendJson(res, 409, { ok: false, status: 'busy', error: 'the strategist is still working on your last message — resend in a moment' }); + // A paused conversation is a deliberate act, so — unlike the 'error' fall-through below — do NOT + // start a fresh one behind the person's back: say why and let them resume the one they paused. + if (sent === 'paused') return sendJson(res, 409, { ok: false, status: 'paused', error: 'this conversation is paused — resume it to keep talking' }); // 'error' = the row can't take a message and can't be relaunched (a crashed/stopped conversation). // Falling through to a NEW conversation is the only non-dead-end: refusing here would leave the room // permanently mute with no way for the person to recover it. @@ -7988,6 +8022,10 @@ function sharedTerminalAuthz(os: AgentOS, tm: TerminalManager, req: http.Incomin const arg = new URL(req.url || '/', 'http://localhost').searchParams.get('arg'); if (!arg) return true; // ttyd asset/probe — no targeted session, so a valid login suffices const id = arg.replace(/^aos-/, ''); + // A PAUSED session is readable, never usable: refuse the WebSocket outright rather than rely on the + // stay-paused sentinel alone. This is the enforcement point — a direct ttyd URL (a tab left open, a + // pasted link) reaches here without going through /api/sessions/:id/attach. + if (tm.isPaused(id)) return false; return !!tm.sessionAgent(id) && tm.canOperateSession(id, me); } // ── hosted-app reverse proxy (/apps//…) ────────────────────────────────── diff --git a/src/state/agent-stats.ts b/src/state/agent-stats.ts index e1cbda3c..a522036f 100644 --- a/src/state/agent-stats.ts +++ b/src/state/agent-stats.ts @@ -119,7 +119,11 @@ export function computeAgentStats(db: Db, agentIds?: string[]): AgentStats[] { s.runs.total++; if (r.rating === 'up') s.rated.up++; else if (r.rating === 'down') s.rated.down++; - if (r.status === 'running') s.runs.running++; + // `paused` counts as in-flight, not finished. Falling through to `done` (the else) would have fed a + // suspended run into the maturity score's success denominator as a completed one, and folding it into + // `stopped` would have counted a deliberate pause against the agent — the tier that decides whether it + // may edit a teammate unattended. + if (r.status === 'running' || r.status === 'paused') s.runs.running++; else if (r.status === 'stopped') s.runs.stopped++; else if (r.status === 'crashed') s.runs.crashed++; else s.runs.done++; diff --git a/src/state/db.ts b/src/state/db.ts index ece60877..b7edd625 100644 --- a/src/state/db.ts +++ b/src/state/db.ts @@ -1259,7 +1259,7 @@ function migrate(db: Db): void { // the console drew a spinner on all of them. Every end path now clears it; this NULLs the ones already // latched: anything terminal, plus any turn older than the 2h wedged-turn ceiling. A genuinely // in-flight turn (running, started within the window) is untouched, so this is safe on a live box. - db.exec("UPDATE term_sessions SET busy_since = NULL WHERE busy_since IS NOT NULL AND (status IN ('done','stopped','crashed') OR busy_since < (CAST(strftime('%s','now') AS INTEGER) * 1000) - 7200000)"); + db.exec("UPDATE term_sessions SET busy_since = NULL WHERE busy_since IS NOT NULL AND (status IN ('done','stopped','crashed','paused') OR busy_since < (CAST(strftime('%s','now') AS INTEGER) * 1000) - 7200000)"); // Private-to-owners agents: when 1, ONLY the owner role runs/sees the agent (admins excluded, the // role/member grants void) — the tightest tier below the owner+admin default. NULL/0 = default floor. @@ -1346,6 +1346,16 @@ function migrate(db: Db): void { // caller swallows instead of a second task for one ticket. NULL for every ordinary task. addColumn(db, 'tasks', 'external_key', 'TEXT'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_external_key ON tasks(tenant, external_key) WHERE external_key IS NOT NULL'); + + // PAUSE (status `paused`): a session whose claude was killed to give the box its memory back, with the + // conversation left on disk so a deliberate resume brings it back via `claude --resume`. Deliberately a + // STATUS rather than a flag over `stopped`: a paused run is not a finished one, and folding it into + // `stopped` would have scored it `incomplete` in the outcome roll-up, counted it against the agent's + // maturity, and let the 14-day stale-session tidy archive a conversation somebody meant to come back to. + // `paused_by` is the member who did it (the console says "paused by …"); `paused_at` is when, and is the + // flag every "is this paused" check reads through the status, not through these columns. + addColumn(db, 'term_sessions', 'paused_at', 'INTEGER'); + addColumn(db, 'term_sessions', 'paused_by', 'TEXT'); } /** Add a column only if it isn't already present (SQLite has no ADD COLUMN IF NOT EXISTS). */ diff --git a/src/terminal.ts b/src/terminal.ts index 54cae9d3..2243c9a3 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -265,8 +265,18 @@ You can edit your OWN definition, so keep it current instead of repeating the sa * - `stopped` — a human halted it (`stopSession`). * - `crashed` — the pane died with no end signal at all (kill/OOM/reboot), caught by the liveness sweep. * A terminal row can go back to `running` via `markResumed` when the browser reattaches and resumes. + * + * `paused` is the one state that is NEITHER live nor terminal — a run a human deliberately suspended + * ({@link TerminalManager.pauseSession}). Its claude was killed, so the box gets the ~500 MB and the + * runtime-account slot back and the agent cannot act; its transcript stays on disk, so + * {@link TerminalManager.resumeSession} brings the SAME conversation back with `claude --resume`. It is a + * status rather than a flag on `stopped` because a paused run is not a finished one: every roll-up that + * scores a `stopped` run (the outcome verdict, the agent's maturity, the stale-session tidy) must leave + * a paused one alone, and the reapers — which all enumerate statuses explicitly — must never touch it. + * Nothing but {@link TerminalManager.resumeSession} and {@link TerminalManager.stopSession} moves a row + * out of it: every wake path (chat continuity, wake-ups, take-over, reload, inject) refuses it by name. */ -export type SessionStatus = 'running' | 'done' | 'stopped' | 'crashed'; +export type SessionStatus = 'running' | 'done' | 'stopped' | 'crashed' | 'paused'; /** * Every distinct way a session gets initiated, normalized for the console's origin badge. Resolved @@ -363,6 +373,11 @@ export interface Session { * identity (e.g. a company-identity automation run). Drives the sessions-list Owner filter. */ runAsLabel?: string; createdAt: number; + /** When this session was paused, and by whom (member email / principal). Both undefined unless + * `status === 'paused'` — {@link TerminalManager.resumeSession} clears them. Purely for display + * ("paused 2h ago by …"); every behavioural check reads `status`, never these. */ + pausedAt?: number; + pausedBy?: string; /** Last time the session's status changed (report/end/stop/resume/crash); = createdAt until the * first transition. Lets the sessions list sort by recent activity, not just creation. */ updatedAt: number; @@ -598,6 +613,8 @@ interface SessionRow { turns: number | null; tool_calls: number | null; archived_at?: number | null; + paused_at: number | null; + paused_by: string | null; busy_since: number | null; /** Last turn-END (or delivery) stamp. Paired with `busy_since` it says whether the CURRENT turn is * still in flight — see {@link TerminalManager.isWorking}. */ @@ -1168,7 +1185,7 @@ export class TerminalManager { */ private isWorking(r: { id: string; tmux: string; status: string; busy_since: number | null; last_activity: number | null; created_at: number }, alive: Set | null): boolean { if (r.busy_since == null) return false; - if (r.status === 'stopped' || r.status === 'crashed') return false; + if (r.status === 'stopped' || r.status === 'crashed' || r.status === 'paused') return false; if (r.last_activity != null && r.last_activity > r.busy_since) return false; if (r.busy_since < Date.now() - MID_TURN_MAX_MS) return false; if (r.busy_since === r.created_at && r.busy_since < Date.now() - LAUNCH_TURN_GRACE_MS) return false; @@ -2315,14 +2332,27 @@ export class TerminalManager { * saw `done`, skipped the live pane, and spawned `ses_441cec`, which died 28s later — the poke was * never seen. So the two were folded into this one; don't reintroduce a status-based variant. * - * Refuses only a status that means someone ended the run deliberately (`stopped`) or the sweep buried - * it (`crashed`) — a pane surviving either is a leftover, not a destination. - */ + * Refuses only a status that means someone ended the run deliberately (`stopped`), suspended it + * deliberately (`paused`), or the sweep buried it (`crashed`) — a pane surviving any of those is a + * leftover, not a destination. `paused` is the load-bearing one for the pause feature: it is what makes + * every KEYSTROKE path (inject, chat delivery, a pasted file, a wake-up's inject lanes) refuse the run + * without each of them having to know the word. The RESURRECT paths must still check it themselves — + * they read a false from here as "it's dead, relaunch it", which is the opposite of what a pause means. + */ + /** Is this run suspended by a human? The veto every RESURRECT path owes the pause feature. + * {@link reachable} answers "can I type into it" and a paused run answers no — but a no from there is + * read by `reviveResident`/`chatSend`/`takeoverRun`/`reloadSession` as "the pane is gone, relaunch + * it", which would silently undo the pause. So each of them asks this FIRST. Cheap single-column read; + * callers that already hold the row should compare `status` directly instead. */ + isPaused(sessionId: string): boolean { + return this.db.prepare("SELECT 1 FROM term_sessions WHERE id = ? AND status = 'paused'").get(sessionId) != null; + } + reachable(sessionId: string): boolean { if (this.launching.has(sessionId)) return true; // scheduled; its pane is imminent const r = this.db.prepare('SELECT tmux, status FROM term_sessions WHERE id = ?').get<{ tmux: string; status: string }>(sessionId); if (!r) return false; - if (r.status === 'stopped' || r.status === 'crashed') return false; // deliberately ended — don't revive by keystroke + if (r.status === 'stopped' || r.status === 'crashed' || r.status === 'paused') return false; // deliberately ended/suspended — don't revive by keystroke const alive = this.backend.aliveNames(); if (!alive) return r.status === 'running'; // launcher backend: can't poll the pane, so fall back to the row return alive.has(r.tmux); @@ -2382,7 +2412,7 @@ export class TerminalManager { const alive = this.backend.aliveNames(); for (const r of rows) { const live = this.launching.has(r.id) // scheduled; its pane is imminent (mirrors `reachable`) - || (r.status !== 'stopped' && r.status !== 'crashed' + || (r.status !== 'stopped' && r.status !== 'crashed' && r.status !== 'paused' && (alive ? alive.has(r.tmux) : r.status === 'running')); // no poll possible → trust the row if (live) out[r.task_id] = { sessionId: r.id, agent: r.agent, since: r.created_at }; } @@ -2414,7 +2444,9 @@ export class TerminalManager { * A session we just delivered into was stamped `done` by its own `report` but is demonstrably still * running — put the row back in step with reality so the console spins on `working` and the crash * sweep watches it again. Terminal states set by a human (`stopped`) or the sweep (`crashed`) are - * never touched: `reachable` already refuses to deliver into those. + * never touched: `reachable` already refuses to deliver into those, and neither is `paused` — the + * UPDATE is scoped `AND status = 'done'`, so a paused row cannot be walked back to running by a stray + * delivery even if one somehow reached it. */ private restoreRunningAfterDelivery(sessionId: string): void { this.db.prepare("UPDATE term_sessions SET status = 'running', updated_at = ? WHERE id = ? AND status = 'done'") @@ -3261,6 +3293,7 @@ export class TerminalManager { const row = this.db.prepare('SELECT agent, secret, claude_session_id, run_as, spawned_by, status FROM term_sessions WHERE id = ?') .get<{ agent: string; secret: string | null; claude_session_id: string | null; run_as: string | null; spawned_by: string | null; status: string }>(sessionId); if (!row || !row.claude_session_id) return false; + if (row.status === 'paused') return false; // suspended by a human — a chat reply must not undo that if (this.reachable(sessionId)) return false; // caller should have delivered instead const body = (text || '').trim(); if (!body) return false; @@ -3300,12 +3333,16 @@ export class TerminalManager { * * A message typed while a turn is generating is DELIVERED, not refused — claude queues it and reads it * at the turn boundary (the same hand-off Slack threads rely on), so 'busy' is no longer returned for - * a live pane. Returns 'sent', or 'error' for an unknown / non-resumable session. + * a live pane. Returns 'sent', 'paused' when a human has suspended the session (the caller tells them + * to resume it), or 'error' for an 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, tmux FROM term_sessions WHERE id = ?') - .get<{ agent: string; secret: string | null; claude_session_id: string | null; run_as: string | null; spawned_by: string | null; tmux: string }>(sessionId); + chatSend(sessionId: string, message: string, runAs?: string): 'sent' | 'busy' | 'paused' | 'error' { + const row = this.db.prepare('SELECT agent, secret, claude_session_id, run_as, spawned_by, tmux, status FROM term_sessions WHERE id = ?') + .get<{ agent: string; secret: string | null; claude_session_id: string | null; run_as: string | null; spawned_by: string | null; tmux: string; status: string }>(sessionId); if (!row || !row.claude_session_id) return 'error'; + // Suspended by a human. The pane is gone, so without this the COLD branch below would relaunch the + // agent — the message would silently resume the very session someone paused. + if (row.status === 'paused') return 'paused'; const body = (message || '').trim(); if (!body) return 'error'; const actingMember = this.resolveActingMember(runAs ?? row.run_as ?? undefined); @@ -3386,7 +3423,7 @@ export class TerminalManager { .get<{ agent: string; claude_session_id: string | null; task: string | null; status: string; tmux: string; run_as: string | null; spawned_by: string | null }>(sessionId); if (!row || !row.claude_session_id) return; if (row.task !== body) return; // a newer message superseded this one - if (row.status === 'stopped' || row.status === 'crashed') return; // deliberately torn down meanwhile + if (row.status === 'stopped' || row.status === 'crashed' || row.status === 'paused') return; // deliberately torn down/suspended meanwhile const after = this.transcriptMark(row.claude_session_id); // Anything at all landed → the turn started. (A brand-new transcript counts: before was null.) if (after && (!before || after.size > before.size || after.mtime > before.mtime)) return; @@ -3460,9 +3497,12 @@ export class TerminalManager { * resume (a headless run that never got a pinned claude session id — nothing to `--resume`). */ takeoverRun(sessionId: string, by: string): { ok: boolean; error?: string } { - 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); + const row = this.db.prepare('SELECT agent, secret, claude_session_id, run_as, spawned_by, status FROM term_sessions WHERE id = ?') + .get<{ agent: string; secret: string | null; claude_session_id: string | null; run_as: string | null; spawned_by: string | null; status: string }>(sessionId); if (!row) return { ok: false, error: 'unknown session' }; + // A paused run reads as "dead with a conversation", which is exactly the shape this resurrects. Say + // so instead: resuming is a deliberate act with its own button, not a side effect of taking over. + if (row.status === 'paused') return { ok: false, error: 'this session is paused — resume it first' }; const manifest = this.os.agents.get(row.agent); if (!runtimeSupports(manifest?.runtime, 'attachableUnattended') || !manifest?.dir) { return { ok: false, error: `this agent's runtime has no attachable session to take over` }; @@ -3504,9 +3544,10 @@ export class TerminalManager { * / non-claude-code session. */ takeoverToTerminal(sessionId: string, by: string): { ok: boolean; error?: string } { - 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); + const row = this.db.prepare('SELECT agent, secret, claude_session_id, run_as, spawned_by, status FROM term_sessions WHERE id = ?') + .get<{ agent: string; secret: string | null; claude_session_id: string | null; run_as: string | null; spawned_by: string | null; status: string }>(sessionId); if (!row) return { ok: false, error: 'unknown session' }; + if (row.status === 'paused') return { ok: false, error: 'this session is paused — resume it first' }; const manifest = this.os.agents.get(row.agent); // Resurrecting a chat as a warm resident TUI needs the resident-chat capability, not just resume. if (!runtimeSupports(manifest?.runtime, 'residentChat') || !manifest?.dir) { @@ -8596,6 +8637,10 @@ export class TerminalManager { markEnded(sessionId: string): void { const s = this.db.prepare('SELECT agent, status FROM term_sessions WHERE id = ?').get<{ agent: string; status: string }>(sessionId); if (!s) return; + // A PAUSED row has no process to have ended. The only way this fires on one is a late signal from the + // launcher we just killed, and acting on it would write the run's episode (declaring it over) and drop + // its notifications while a human is still planning to come back to it. + if (s.status === 'paused') return; // A "natural end": the row was still live and the process returned on its own — as opposed to a human // `stopSession` (already 'stopped') or a crash the sweep caught ('crashed'). Only a natural end earns // the completion-fallback card below, so closing a session yourself doesn't ping you about it. @@ -8734,7 +8779,12 @@ export class TerminalManager { // (an attachable unattended run has no `-p` tee to fall back on). Best-effort; never blocks the stop. this.captureTranscript(sessionId, space, r.tmux); this.backend.kill(space, r.tmux); - if (r.status === 'running') this.db.prepare("UPDATE term_sessions SET status = 'stopped', busy_since = NULL, updated_at = ? WHERE id = ?").run(Date.now(), sessionId); + // `paused` rides along with `running`: pausing is not a decision to end the run, so "stop" is still + // available on a paused session and is how you say "I'm not coming back" — it writes the episode and + // clears the paused stamp, which `running` alone would have left on the row forever. + if (r.status === 'running' || r.status === 'paused') { + this.db.prepare("UPDATE term_sessions SET status = 'stopped', busy_since = NULL, paused_at = NULL, paused_by = NULL, updated_at = ? WHERE id = ?").run(Date.now(), sessionId); + } this.clearNotifications(sessionId); // The agent that asked is now dead — no one can answer its open questions or act on its approvals. // Cancel both so they leave "Needs you" and become dismissable, rather than hanging forever. @@ -8757,6 +8807,102 @@ export class TerminalManager { return true; } + /** + * PAUSE a live session: take the agent's memory away and leave its conversation on disk. + * + * The mechanics are {@link stopSession}'s — snapshot the pane, kill the tmux shell — and the whole + * point is that killing the pane kills the claude process, which is what actually frees the ~500 MB of + * context, the runtime-account slot and the concurrency slot (every counter reads `status = 'running'`, + * so a paused row stops holding one the moment it flips). The agent cannot act while paused: it has no + * process to act with, and every path that could give it one back refuses a `paused` row by name. + * + * What makes it a PAUSE and not a stop is everything it deliberately does NOT do: no episode is + * written (the run is not over, and an episode would tell Dreaming and the consolidator otherwise), no + * completion card is posted, and the row lands on `paused` rather than `stopped` so no roll-up scores + * it — not the outcome verdict, not the agent's maturity, not the 14-day stale-session tidy. + * + * It DOES cancel pending questions and approvals, for the same reason `stopSession` does: the process + * that raised them is gone, so nobody can deliver an answer to it, and a card left open is a card that + * hangs in someone's Inbox forever. A resumed agent asks again — that is the visible cost of pausing a + * run mid-question, and it is better than an un-answerable card. + * + * Refused unless there is a live pane to pause AND a conversation to come back to (a pinned session id + * on a runtime that can `--resume`). Without the latter a "pause" would be a one-way stop wearing the + * wrong word, so the caller is told to stop it instead. + */ + pauseSession(sessionId: string, by: string): { ok: boolean; error?: string } { + const r = this.db.prepare('SELECT agent, tmux, status, spawned_by, run_as, claude_session_id FROM term_sessions WHERE id = ?') + .get<{ agent: string; tmux: string; status: string; spawned_by: string | null; run_as: string | null; claude_session_id: string | null }>(sessionId); + if (!r) return { ok: false, error: 'unknown session' }; + if (r.status === 'paused') return { ok: true }; // idempotent + // Liveness is the PANE, not the row (`reachable`'s whole lesson): a run that called `report` reads + // `done` with a very-much-alive claude holding hundreds of MB, and that is exactly a session worth + // pausing. A row with no pane has nothing to pause — there is no memory left to take away. + if (!this.reachable(sessionId)) return { ok: false, error: 'this session has no live agent to pause' }; + if (!r.claude_session_id || !runtimeSupports(this.os.agents.get(r.agent)?.runtime, 'resume')) { + return { ok: false, error: 'this run has no resumable conversation — stop it instead (pausing it could not be undone)' }; + } + const space = this.spaceFor(r.run_as ?? r.spawned_by); + // Snapshot the pane before it dies so the console's read-only view has the terminal scrollback to + // show alongside the transcript — a paused session is meant to be READ while it is paused. + this.captureTranscript(sessionId, space, r.tmux); + this.backend.kill(space, r.tmux); + const now = Date.now(); + this.db.prepare("UPDATE term_sessions SET status = 'paused', busy_since = NULL, paused_at = ?, paused_by = ?, updated_at = ? WHERE id = ?") + .run(now, by, now, sessionId); + this.clearNotifications(sessionId); + this.cancelPendingQuestions(sessionId, by); + this.cancelPendingApprovals(sessionId, by); + // A pause must STAY paused. ttyd (disableReconnect=false) re-dials the moment the pane's tmux dies and + // re-runs attach.sh, which would `claude --resume` the session straight back to life on a tab someone + // left open — the same sentinel `stopSession` drops, cleared only by the deliberate resume below. + this.blockResume(sessionId); + this.audit(sessionId, by, 'session.paused', { tmux: r.tmux, agent: r.agent }); + return { ok: true }; + } + + /** + * RESUME a paused session: relaunch the agent on the SAME conversation (`claude --resume `), + * seeded with no prompt so the human lands in a steerable TUI holding the full transcript. The context + * comes back because it was never in memory to begin with — claude's transcript is a file on disk, and + * the pause only ever killed the process reading it. + * + * Lane on the way back: `resident` is preserved (a warm chat session resumes warm, so the next message + * is a warm turn), but an UNATTENDED run comes back ATTENDED and claimed by whoever resumed it — + * exactly what {@link takeoverRun} does, and for the same reason. Resuming seeds no prompt, so an + * unattended run has nothing to do when it wakes; left `headless` it would be idle-reaped within the + * hour, and the human who pressed Resume would watch the session they just brought back disappear. A + * claimed run is sticky against every reaper but the long claimed-abandoned janitor, which is the right + * ceiling for "a person is looking after this one now". + */ + resumeSession(sessionId: string, by: string): { ok: boolean; error?: string } { + const row = this.db.prepare('SELECT agent, secret, status, claude_session_id, run_as, spawned_by, resident, claimed_by FROM term_sessions WHERE id = ?') + .get<{ agent: string; secret: string | null; status: string; claude_session_id: string | null; run_as: string | null; spawned_by: string | null; resident: number | null; claimed_by: string | null }>(sessionId); + if (!row) return { ok: false, error: 'unknown session' }; + if (row.status !== 'paused') return { ok: false, error: 'this session is not paused' }; + // Can't happen through `pauseSession` (it refuses a run with no conversation) but a row could have + // been migrated or hand-edited — never relaunch a claude with nothing to resume into. + if (!row.claude_session_id) return { ok: false, error: 'this run has no conversation to resume' }; + // Lift the stay-paused sentinel BEFORE relaunching: attach.sh reads it on every (re)connect, and a + // resume that left it in place would come up and be refused by its own terminal. + this.allowResume(sessionId); + const now = Date.now(); + const resident = row.resident ? 1 : 0; + this.db.prepare("UPDATE term_sessions SET status = 'running', headless = 0, claimed_by = COALESCE(claimed_by, ?), claimed_at = COALESCE(claimed_at, ?), paused_at = NULL, paused_by = NULL, last_activity = ?, updated_at = ? WHERE id = ?") + .run(by, now, now, now, sessionId); + 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); + const hasClickup = !!this.db.prepare('SELECT 1 FROM clickup_threads WHERE session_id = ?').get(sessionId); + const hasTelegram = !!this.db.prepare('SELECT 1 FROM telegram_threads WHERE session_id = ?').get(sessionId); + this.audit(sessionId, by, 'session.unpaused', { agent: row.agent }); + this.launchAgentRuntime({ + id: sessionId, agent: row.agent, task: '', secret: row.secret ?? randomBytes(24).toString('hex'), + actingMember: row.run_as ?? undefined, spawnedBy: row.spawned_by ?? undefined, hasSlack, hasDiscord, hasClickup, hasTelegram, + headless: false, resident: !!resident, resume: true, claudeSessionId: row.claude_session_id, + }); + return { ok: true }; + } + /** * Restart a resumable session's agent process IN PLACE, keeping its conversation. Kills the live pane * and leaves the session resurrectable (no stop-marker) so the next terminal (re)attach relaunches it @@ -8777,6 +8923,8 @@ export class TerminalManager { reloadSession(sessionId: string, by: string, opts?: { rotate?: boolean }): { ok: boolean; error?: string; account?: string; note?: string } { const r = this.db.prepare('SELECT agent, tmux, status, spawned_by, run_as FROM term_sessions WHERE id = ?').get<{ agent: string; tmux: string; status: string; spawned_by: string | null; run_as: string | null }>(sessionId); if (!r) return { ok: false, error: 'unknown session' }; + // A reload is a relaunch, so it would resurrect a paused run. Resume is the one way back. + if (r.status === 'paused') return { ok: false, error: 'this session is paused — resume it first' }; // Reload only works for a resurrectable session — one whose persisted launch env attach.sh can // `claude --resume` from. A headless run (no env) has nothing to restart into. if (!this.os.paths || !fs.existsSync(path.join(this.os.paths.connectors, `session-${sessionId}.env`))) { @@ -9525,7 +9673,7 @@ function buildAskAgentPrompt(id: string, callerAgent: string, question: string, } function toSession(r: SessionRow): Session { - return { id: r.id, agent: r.agent, title: r.title, task: r.task, tmux: r.tmux, status: r.status, threadId: r.claude_session_id ?? r.id, spawnedBy: r.spawned_by ?? undefined, runAs: r.run_as ?? undefined, headless: !!r.headless, claimedBy: r.claimed_by ?? undefined, createdAt: r.created_at, updatedAt: r.updated_at ?? r.created_at, rating: r.rating === 'up' || r.rating === 'down' ? r.rating : undefined, ratedBy: r.rated_by ?? undefined, ratedAt: r.rated_at ?? undefined, costUsd: r.cost_usd ?? undefined, tokens: r.cost_usd != null ? { input: r.input_tokens ?? 0, output: r.output_tokens ?? 0, cacheRead: r.cache_read_tokens ?? 0, cacheWrite: r.cache_write_tokens ?? 0 } : undefined, outcome: r.outcome ?? undefined, summary: r.report_summary ?? undefined, activeMs: r.active_ms ?? undefined, turns: r.turns ?? undefined, toolCalls: r.tool_calls ?? undefined, insights: r.gov_approvals != null ? { actions: r.gov_actions ?? 0, approvals: r.gov_approvals, denied: r.gov_denied ?? 0, errors: r.gov_errors ?? 0 } : undefined, model: r.model ?? undefined, effort: r.effort ?? undefined, outputStyle: r.output_style ?? undefined, blockedMs: r.blocked_ms ?? undefined, artifacts: r.artifacts ?? undefined }; + return { id: r.id, agent: r.agent, title: r.title, task: r.task, tmux: r.tmux, status: r.status, threadId: r.claude_session_id ?? r.id, spawnedBy: r.spawned_by ?? undefined, runAs: r.run_as ?? undefined, headless: !!r.headless, claimedBy: r.claimed_by ?? undefined, createdAt: r.created_at, updatedAt: r.updated_at ?? r.created_at, rating: r.rating === 'up' || r.rating === 'down' ? r.rating : undefined, ratedBy: r.rated_by ?? undefined, ratedAt: r.rated_at ?? undefined, costUsd: r.cost_usd ?? undefined, tokens: r.cost_usd != null ? { input: r.input_tokens ?? 0, output: r.output_tokens ?? 0, cacheRead: r.cache_read_tokens ?? 0, cacheWrite: r.cache_write_tokens ?? 0 } : undefined, outcome: r.outcome ?? undefined, summary: r.report_summary ?? undefined, activeMs: r.active_ms ?? undefined, turns: r.turns ?? undefined, toolCalls: r.tool_calls ?? undefined, insights: r.gov_approvals != null ? { actions: r.gov_actions ?? 0, approvals: r.gov_approvals, denied: r.gov_denied ?? 0, errors: r.gov_errors ?? 0 } : undefined, model: r.model ?? undefined, effort: r.effort ?? undefined, outputStyle: r.output_style ?? undefined, blockedMs: r.blocked_ms ?? undefined, artifacts: r.artifacts ?? undefined, pausedAt: r.paused_at ?? undefined, pausedBy: r.paused_by ?? undefined }; } /** One task on a `task.proposed` card. Title/assignee are snapshots from filing; status is hydrated live. */ diff --git a/web/src/App.tsx b/web/src/App.tsx index 2f449314..0328770f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type KeyboardEvent as ReactKeyboardEvent, type ChangeEvent as ReactChangeEvent } from 'react' import { Inbox as InboxIcon, TerminalSquare, Play, Plus, Check, X, Square, Rocket, Plug, Trash2, Users, User, LogOut, Copy, Zap, Brain, Building2, ChevronDown, SlidersHorizontal, Pencil, FileText, HelpCircle, CheckCircle2, XCircle, Clock, Send, LayoutGrid, List, ArrowLeft, Bot, FolderTree, Folder, File as FileIcon, FileCode, Save, ChevronRight, Sparkles, Package, Image as ImageIcon, Film, Download, Search, BookText, BookOpen, History as HistoryIcon, ScrollText, Bell, AlertTriangle, Activity, Lightbulb, Moon, Upload, FolderPlus, ListChecks, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, RefreshCw, ThumbsUp, ThumbsDown, Target, ExternalLink, Paperclip, KeyRound, Blocks, FilePlus, Maximize2, Minimize2, Filter, Share2, Lock, Gauge, Timer } from 'lucide-react' // The session-status glyph set (see STATE_META) — one icon per state, plus the chain rail's verdict icons. -import { LoaderCircle, CircleSmall, CircleStop, CircleCheck, CircleX, CircleSlash, Circle, CircleDot, CircleDashed, Ban, Copy as CopyIcon } from 'lucide-react' +import { LoaderCircle, CircleSmall, CircleStop, CircleCheck, CircleX, CircleSlash, Circle, CircleDot, CircleDashed, Ban, Pause, Copy as CopyIcon } from 'lucide-react' import { GitPullRequest, GitPullRequestClosed, GitMerge, Wrench, Code2, Bug, MessageSquare, Mail, Megaphone, PenTool, Database, Server, Cloud, Shield, Calendar, LineChart, BarChart3, DollarSign, ShoppingCart, Headphones, Cog, Compass, Flag, Heart, Star, Globe, GitBranch, Palette, Camera, Music, Feather, Wand2, Boxes, Terminal, Webhook, CalendarClock, Hash, Cpu, MoreHorizontal, Power, PowerOff, Pin, PinOff, type LucideIcon } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -50,6 +50,11 @@ const canApprove = (role: Role, level: 'head' | 'owner'): boolean => * for the green dot: an interactive session that reported `done` but keeps an attachable pane is live. */ const isLive = (s: Session): boolean => Boolean(s.alive) || s.status === 'running' +/** Suspended by a human: its agent was killed (memory freed), its conversation kept. Readable, never + * usable — Resume is the only affordance the console offers, and the server refuses every other path + * (attach, take over, reload, chat, wake-up) by name, so anything else here would be a dead button. */ +const isPaused = (s: Session): boolean => s.status === 'paused' + /** How long the Sessions/Chat views may hold a full `/api/sessions` payload before rebuilding it. Live * state does NOT wait on this — it refreshes every tick off `/api/sessions/summary` (see the feed poll), * and a row that DROPS OUT of the summary is re-read by id on the same tick (`reconcileEnded` below), so @@ -109,7 +114,7 @@ function mergeSessionRows(prev: Session[], fresh: Session[]): Session[] { * * `headless` is deliberately NOT in the dot any more: the hollow ring now means "not busy", and the * unattended/interactive axis has its own marker (ModeBadge / the sidebar's Cpu glyph). */ -type SessionState = 'waiting' | 'working' | 'idle' | 'stopped' | 'crashed' | 'done' +type SessionState = 'waiting' | 'working' | 'idle' | 'paused' | 'stopped' | 'crashed' | 'done' /** Resolve a session's state. `waiting` may be forced by the caller — the console unions the * server-authoritative `s.blocked` with open `notification` cards (a runtime permission prompt raises @@ -117,6 +122,9 @@ type SessionState = 'waiting' | 'working' | 'idle' | 'stopped' | 'crashed' | 'do const sessionState = (s: Session, waiting = false): SessionState => waiting || s.blocked ? 'waiting' : isLive(s) ? (s.working ? 'working' : 'idle') + // `paused` is checked before the terminal states and is deliberately NOT one of them: the run hasn't + // finished, it is suspended mid-conversation and one click from being live again. + : s.status === 'paused' ? 'paused' : s.status === 'stopped' ? 'stopped' : s.status === 'crashed' ? 'crashed' : 'done' // done (and any unknown legacy value) @@ -144,7 +152,7 @@ const sessionState = (s: Session, waiting = false): SessionState => * * `toneDark` is the same role on the dark terminal tab strip (bg-neutral-900), where the -600 shades go * muddy. Two tones, one vocabulary — never a second set of words. */ -type StatusRole = 'queued' | 'active' | 'busy' | 'needsHuman' | 'ready' | 'ok' | 'partial' | 'failed' | 'crashed' | 'halted' | 'ended' | 'inactive' +type StatusRole = 'queued' | 'active' | 'busy' | 'needsHuman' | 'ready' | 'ok' | 'partial' | 'failed' | 'crashed' | 'halted' | 'paused' | 'ended' | 'inactive' const ROLE: Record = { queued: { icon: Circle, anim: '', tone: 'text-muted-foreground/70', toneDark: 'text-neutral-400' }, active: { icon: CircleDot, anim: '', tone: 'text-emerald-600', toneDark: 'text-emerald-300' }, @@ -156,6 +164,9 @@ const ROLE: Record ({ failed: 'bg-red-500/15 text-red-600', crashed: 'bg-red-500/15 text-red-600', halted: 'bg-amber-500/15 text-amber-600', + paused: 'bg-sky-500/15 text-sky-600', ended: 'bg-muted text-muted-foreground', inactive: 'bg-muted text-muted-foreground', }[r]) @@ -181,6 +193,7 @@ const STATE_META: Record (outcome ? VERDICT_ * so it borrows the live half of the status vocabulary (working / ready / needs you). */ const resultLabel = (s: Session, waiting = false): string => { if (waiting || s.blocked || isLive(s)) return statusLabel(s, waiting) - if (s.status === 'crashed' || s.status === 'stopped') return s.status + // A paused run has no result — it hasn't finished. Say the status, don't reach for an outcome. + if (s.status === 'crashed' || s.status === 'stopped' || s.status === 'paused') return s.status if (!s.outcome) return s.status // not stamped yet — fall back to the process view const v = verdictOf(s.outcome) return v ? VERDICT_META[v].label : s.outcome // an unmapped value prints as the agent wrote it @@ -278,6 +292,7 @@ const resultLabel = (s: Session, waiting = false): string => { const resultTone = (s: Session, waiting = false): string => { if (waiting || s.blocked || isLive(s)) return STATE_META[sessionState(s, waiting)].tone if (s.status === 'crashed') return 'text-red-600' + if (s.status === 'paused') return STATE_META.paused.tone const v = verdictOf(s.outcome) if (v && v !== 'none') return VERDICT_META[v].tone if (s.status === 'stopped') return 'text-amber-600' @@ -341,7 +356,7 @@ function SessionInsights({ s, chain = 0, className = '' }: { s: Session; chain?: * session is just "open", not "resume". Attended runs only: an unattended run also carries an env now, * but its human entry point is Take over (which resurrects it AND claims it), not a bare Resume that * would hand it straight back to the turn-end reaper. */ -const canResume = (s: Session): boolean => Boolean(s.resumable) && !s.headless && !isLive(s) +const canResume = (s: Session): boolean => Boolean(s.resumable) && !s.headless && !isLive(s) && !isPaused(s) /** Resume a stopped session from the console: lift the server-side stop-block, THEN open/focus its * terminal. A plain stop leaves the block in place so ttyd's silent auto-reconnect can't revive the @@ -365,7 +380,8 @@ const resumeAndOpen = (s: Session, onOpen: (tmux: string, title: string) => void * aos-…", tmux's own error, seen live on instawp 2026-08-27). `takeoverRun` handles exactly this * case server-side — it resurrects the transcript and writes the env. */ const canGoInteractive = (s: Session): boolean => - isLive(s) + isPaused(s) ? false // paused has exactly one way back, and it is Resume — see isPaused + : isLive(s) ? Boolean(s.headless) && !s.claimedBy : Boolean(s.forkable) && (Boolean(s.headless) || !s.resumable) @@ -410,7 +426,7 @@ const sessionSource = (s: Session): SessionSource => { // `chains` is not a lifecycle state — it narrows to sessions that took part in a HAND-OFF (a caller // that delegated, or a delegate). It rides in this filter because that's where people already look to // cut the list down, and it's resolved in `filtered` (it needs the whole list to know who called whom). -type SessionStatusFilter = 'all' | 'live' | 'working' | 'blocked' | 'chains' | 'done' | 'stopped' | 'crashed' +type SessionStatusFilter = 'all' | 'live' | 'working' | 'blocked' | 'chains' | 'done' | 'paused' | 'stopped' | 'crashed' const matchesStatus = (s: Session, f: SessionStatusFilter): boolean => f === 'all' || f === 'chains' ? true // `chains` is applied separately — it needs the whole list : f === 'live' ? isLive(s) @@ -421,7 +437,7 @@ const matchesStatus = (s: Session, f: SessionStatusFilter): boolean => // Filter labels — shared by the dropdown options AND the collapsed trigger (base-ui's SelectValue // renders the raw value unless given a formatter, so the two must read from one source). const SESSION_STATUS_LABELS: Record = - { all: 'All statuses', live: 'Live', working: 'Working', blocked: 'Needs you', chains: 'Hand-offs', done: 'Done', stopped: 'Stopped', crashed: 'Crashed' } + { all: 'All statuses', live: 'Live', working: 'Working', blocked: 'Needs you', chains: 'Hand-offs', done: 'Done', paused: 'Paused', stopped: 'Stopped', crashed: 'Crashed' } const SESSION_SOURCE_LABELS: Record<'all' | SessionSource, string> = { all: 'All sources', member: 'Member', automation: 'Automation', task: 'Task', chat: 'Chat' } @@ -459,7 +475,8 @@ const SESSION_SORT_KEYS: SessionSortKey[] = ['created', 'title', 'agent', 'id', const DEFAULT_SORT_KEY: SessionSortKey = 'updated' /** Status ordering for the Status-column sort: live → done → stopped → crashed. */ const statusRank = (s: Session): number => - isLive(s) ? 0 : s.status === 'done' ? 1 : s.status === 'stopped' ? 2 : s.status === 'crashed' ? 3 : 4 + // Paused sorts directly under the live rows: it is the closest thing to live there is — one click away. + isLive(s) ? 0 : s.status === 'paused' ? 1 : s.status === 'done' ? 2 : s.status === 'stopped' ? 3 : s.status === 'crashed' ? 4 : 5 /** Ascending comparison for a given column; direction is applied by the caller. */ const compareSessions = (a: Session, b: Session, key: SessionSortKey): number => { switch (key) { @@ -1698,6 +1715,21 @@ function Console({ me }: { me: Member }) { else nav('sessions') } } + // Pause: kill the agent (the box gets its memory back) and keep the conversation. Unlike Stop we do NOT + // hop away from the tab — the whole point is that the paused session stays open and readable, it just + // re-renders as its read-only transcript with a Resume button. + const pauseSession = async (id: string) => { + const r = await api.pauseSession(id) + await reloadSessions() + if (!r.ok && r.error) alert(r.error) + } + // Resume: the server relaunches the agent on the same conversation. Reload the list so the row flips + // back to live; the open terminal remounts itself off the fresh status. + const unpauseSession = async (id: string) => { + const r = await api.unpauseSession(id) + await reloadSessions() + if (!r.ok && r.error) alert(r.error) + } // Human verdict on a finished run — feeds the agent maturity score. Clicking the active thumb clears it. const rateSession = async (id: string, rating: 'up' | 'down' | null) => { await api.rateSession(id, rating) @@ -2012,7 +2044,7 @@ function Console({ me }: { me: Member }) { {route === 'setup' && nav('setup', id)} onDone={refreshState} />} {route === 'agents' && nav('agents', id)} run={runAgent} onEdit={openAgent} onNew={() => nav('new-agent')} onDelete={deleteAgent} onDuplicate={duplicateAgent} onRescan={rescanAgents} onImport={importAgent} onRefresh={refreshState} nav={nav} />} {route === 'new-agent' && { await refreshState(); nav('agents', id) }} />} - {route === 'sessions' && nav('agents')} onStop={stopSession} onDelete={deleteSession} onRate={rateSession} onRename={renameSession} onTransfer={transferSession} onBulkStop={stopSessions} onBulkDelete={deleteSessions} urlQuery={urlQuery} onFiltersChange={setUrlQuery} />} + {route === 'sessions' && nav('agents')} onStop={stopSession} onPause={pauseSession} onUnpause={unpauseSession} onDelete={deleteSession} onRate={rateSession} onRename={renameSession} onTransfer={transferSession} onBulkStop={stopSessions} onBulkDelete={deleteSessions} urlQuery={urlQuery} onFiltersChange={setUrlQuery} />} {route === 'overview' && me.role === 'owner' && } {route === 'inbox' && nav('tasks', id)} onOpenGoal={(id) => nav('goals', id)} />} {route === 'cockpit' && nav('chat', id)} onOpenTerminal={openTerminal} nav={nav} />} @@ -2023,7 +2055,7 @@ function Console({ me }: { me: Member }) { {route === 'automations' && } {route === 'feed' && } {route === 'goals' && } - {route === 'tasks' && } + {route === 'tasks' && } {route === 'memory' && } {route === 'insights' && } {route === 'kb' && } @@ -3150,7 +3182,12 @@ function TerminalFrame({ session, tmux, onActivity, ops, standalone }: { session // closed) or a run with no persisted launch env for attach.sh to replay (attaching lands on tmux's raw // "can't find session: aos-…"). An attended run WITH an env keeps the normal attach/resume path — that // attach is exactly what resurrects it. - const ended = Boolean(session) && !isLive(session!) && (Boolean(session!.headless) || !session!.resumable) && !overrideAttach + // A PAUSED session is ALWAYS read-only, whatever its lane: the server refuses to attach it (the + // WebSocket authz rejects the id and attach.sh holds its stay-paused sentinel), so an attended, + // resumable one would otherwise land on a terminal that never opens. Resume is its only way out, and + // it is offered right in the transcript header below. + const paused = Boolean(session) && isPaused(session!) && !overrideAttach + const ended = Boolean(session) && (isPaused(session!) || (!isLive(session!) && (Boolean(session!.headless) || !session!.resumable))) && !overrideAttach // A LIVE unattended run can be taken over — attach to its streaming pane (see canGoInteractive). Hidden // once claimed (overrideAttach flips it off immediately; the prop's claimedBy follows on the next poll). const showTakeover = Boolean(session) && !overrideAttach && canGoInteractive(session!) @@ -3193,7 +3230,25 @@ function TerminalFrame({ session, tmux, onActivity, ops, standalone }: { session // log, then to the reported outcome) rather than attaching to a dead terminal. It is not a dead END, // though: if the run can be resurrected (`canGoInteractive`) the transcript header offers it, and taking // it over flips `overrideAttach` so this same frame re-attaches to the freshly resumed pane. - if (ended && session) return + // Resume (un-pause): the server relaunches the agent on the same conversation, then `overrideAttach` + // + a `nonce` bump remount the frame so it attaches to the freshly-resumed pane. + const unpause = async () => { + if (!session?.id || takingOver) return + setTakingOver(true); setErr('') + const r = await api.unpauseSession(session.id) + setTakingOver(false) + if (!r.ok) { setErr(r.error || 'could not resume this session'); return } + setOverrideAttach(true); setNonce((n) => n + 1) + } + if (ended && session) + return ( + + ) if (err) return
⚠ {err}
if (!wsUrl) return
opening terminal…
return ( @@ -3339,20 +3394,22 @@ function BriefCard({ text, taskId }: { text: string; taskId?: string }) { * back into a live TUI. Server-side `takeoverRun` relaunches `claude --resume` on the same transcript, * claims it for the human, and persists the launch env; the frame then re-attaches to the new pane. * Rendered only when `canGoInteractive` says this run can actually come back (see its note). */ -function ResumeRunButton({ onTakeOver, takingOver }: { onTakeOver: () => void; takingOver?: boolean }) { +function ResumeRunButton({ onTakeOver, takingOver, paused }: { onTakeOver: () => void; takingOver?: boolean; paused?: boolean }) { return ( ) } -function EndedSession({ session, onTakeOver, takingOver }: { session: Session; onTakeOver?: () => void; takingOver?: boolean }) { +function EndedSession({ session, onTakeOver, takingOver, paused }: { session: Session; onTakeOver?: () => void; takingOver?: boolean; paused?: boolean }) { type Phase = 'loading' | 'timeline' | 'raw-only' | 'report' const [phase, setPhase] = useState('loading') const [turns, setTurns] = useState([]) @@ -3394,7 +3451,7 @@ function EndedSession({ session, onTakeOver, takingOver }: { session: Session; o if (phase === 'loading') return
loading transcript…
- if (phase === 'report') return + if (phase === 'report') return const canToggle = phase === 'timeline' // a raw-only run has nothing friendlier to switch back to const showingRaw = raw || phase === 'raw-only' @@ -3409,7 +3466,7 @@ function EndedSession({ session, onTakeOver, takingOver }: { session: Session; o session={session} right={ <> - {onTakeOver && } + {onTakeOver && } {canToggle && (