diff --git a/chief-of-staff-outside.js b/chief-of-staff-outside.js index c96d7018..87dc7bad 100644 --- a/chief-of-staff-outside.js +++ b/chief-of-staff-outside.js @@ -1,15 +1,24 @@ 'use strict'; +const { NullLogger } = require('./logger'); + // Independent, listen-only inventory. No outside text enters the managed ledger. const SCOPE = { scope: 'chief-of-staff' }; const ROOT = '/api/chief-of-staff/outside-sessions'; -const ROW_FIELDS = ['id', 'name', 'projectId', 'hostId', 'machine', 'directory', 'branch', 'state', 'since', 'updatedAt', 'source', 'outside', 'readOnly', 'tmuxSession', 'viewable', 'viewReason', 'endedAt']; +const ROW_FIELDS = ['id', 'name', 'projectId', 'hostId', 'machine', 'directory', 'branch', 'state', 'since', 'updatedAt', 'source', 'outside', 'readOnly', 'tmuxSession', 'viewable', 'viewReason', 'endedAt', 'adoptable', 'adoptReason']; const MACHINE_FIELDS = ['hostId', 'machine', 'status', 'lastProbeAt', 'error']; +const CLAUDE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const pick = (row, fields) => Object.fromEntries(fields.filter(k => row[k] !== undefined).map(k => [k, row[k]])); class ChiefOfStaffOutside { - constructor({ relayTransport, onSnapshot }) { + constructor({ relayTransport, onSnapshot, resolveProject, log }) { + this.log = log || new NullLogger(); this.transport = relayTransport; + this.resolveProject = resolveProject || (() => null); + // Row id -> Claude session id, ended rows only. Server-only: pick() keeps it out of every row, + // so no snapshot, frame, list(), detail() or tool result carries it. Replaced with each accepted snapshot. + this.claudeIds = new Map(); + this.resuming = new Set(); this.onSnapshot = onSnapshot; this.snapshot = { revision: -1, probeIntervalMs: 15000, sessions: [], machines: [] }; this.stopped = true; @@ -75,10 +84,19 @@ class ChiefOfStaffOutside { sessions: [...sessions.values()], machines: data.machines.filter(r => r && typeof r === 'object').map(r => pick(r, MACHINE_FIELDS)) }; } + _claudeIdsOf(data) { + const ids = new Map(); + for (const row of data.sessions) { + if (row?.state === 'ended' && typeof row.id === 'string' && typeof row.claudeSessionId === 'string') ids.set(row.id, row.claudeSessionId); + } + return ids; + } + _replace(data, reseed = false) { const snapshot = this._snapshotOf(data); if (!snapshot || (!reseed && snapshot.revision <= this.snapshot.revision)) return false; this.snapshot = snapshot; + this.claudeIds = this._claudeIdsOf(data); this.onSnapshot?.(this.browserSnapshot()); if (snapshot.sessions.some(r => r.state === 'ended') || this.turns.size) this._fillTurns(); return true; @@ -108,6 +126,51 @@ class ChiefOfStaffOutside { }, lastExcerpt: String(data.lastExcerpt || '').slice(-500) }; } + // Resume an ended row in a new managed terminal on the same conversation. Never throws. + // The browser sends only the row id; the conversation id is eve's own, and it becomes a CLI argument. + async resume(id, size) { + const result = await this._resume(id, size); + // One line per outcome: the row id and the result. Never the Claude session id. + const { status, body } = result; + const attrs = { op: 'outside.resume', id, http_status: status }; + if (status === 201) attrs.terminal_id = body.terminal.terminalId; + else attrs.error = body.error; + this.log[status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info']('Outside resume', attrs); + return result; + } + + async _resume(id, { cols, rows }) { + const refuse = (status, error, message) => ({ status, body: { error, message } }); + const unavailable = (message) => refuse(409, 'outside_resume_unavailable', message); + const row = this.snapshot.sessions.find(r => r.id === id); + if (!row) return refuse(404, 'outside_session_not_found', 'That session is no longer in the list.'); + if (row.state !== 'ended') return refuse(409, 'outside_session_live', 'This session is still running. Only an ended session can be resumed.'); + const project = row.projectId ? this.resolveProject(row.projectId) : null; + if (!project) return unavailable("Not in a project, so it can't be resumed."); + if (row.hostId || project.hostId) return unavailable("On a host, so it can't be resumed here."); + const claudeSessionId = this.claudeIds.get(id); + if (typeof claudeSessionId !== 'string' || !CLAUDE_SESSION_ID.test(claudeSessionId)) { + return unavailable("Relay gave no conversation id, so it can't be resumed."); + } + if (this.resuming.has(id)) return refuse(409, 'outside_resume_busy', 'This session is already being resumed.'); + this.resuming.add(id); + try { + const { status, data } = await this.transport.fetch('POST', '/api/terminals', { + templateId: 'claude-code', name: `${row.name} (resumed)`, directory: row.directory, + projectId: row.projectId, cols, rows, extraArgs: ['--resume', claudeSessionId], + }); + if (status >= 200 && status < 300) { + return data?.terminalId ? { status: 201, body: { terminal: data } } + : refuse(502, 'resume_failed', 'Relay did not return a terminal.'); + } + return refuse(status, data?.code || 'resume_failed', data?.error || `Relay answered ${status}.`); + } catch { + return { status: 502, body: { error: 'Service unavailable' } }; + } finally { + this.resuming.delete(id); + } + } + start() { if (!this.stopped) return; this.stopped = false; diff --git a/chief-of-staff.js b/chief-of-staff.js index e7c276fc..49cbcfd4 100644 --- a/chief-of-staff.js +++ b/chief-of-staff.js @@ -257,7 +257,7 @@ class ChiefOfStaff { this.state = { day: localDay(new Date(now())), calls: 0, rowCalls: 0, modelSessionId: null, personSessionId: null, limitNoticeDay: null }; this._writeChain = Promise.resolve(); - this.outside = new ChiefOfStaffOutside({ relayTransport, onSnapshot: (snapshot) => { + this.outside = new ChiefOfStaffOutside({ relayTransport, log: this.log, resolveProject: (id) => this.resolveProject(id), onSnapshot: (snapshot) => { this._fanOut({ type: 'cos_outside_sessions', ...snapshot }); this._emitStatus(); } }); diff --git a/devboxverify/README.md b/devboxverify/README.md index 53f517cb..fb39a009 100644 --- a/devboxverify/README.md +++ b/devboxverify/README.md @@ -1039,7 +1039,7 @@ and within 60 s each folder must show an Ended row under the Ended heading (`rai Both of its Claudes (console and host) are cleaned up the same way. **outside-thread.** An outside Claude with no tmux opens as a read-only thread -(G6.96, simple path). Not a screen journey (600 s). Same BLOCKED rule for a relay +(G6.96, simple path). Not a screen journey (720 s). Same BLOCKED rule for a relay with no outside inventory, and BLOCKED when claude is not on PATH. It makes a scratch git folder in Acme Corp with an empty `keepalive.log` and starts `claude -p --model haiku --permission-mode default --setting-sources project,local @@ -1065,13 +1065,38 @@ the Ended group (heading `Ended`), is a `div` with no button, and its line 2 end 60 s; eve reads it from relay once per ended row), and line 3 (`data-kind="last-turn"`) must show it. Last it asks the Chief of Staff `Summarize the outside session . Quote its last reply.`; the `reply` post (up to 120 s) must carry the marker. +The only button beside the ended row is `Resume in a terminal` (G6.106). The +journey taps it; eve's `POST /api/outside-sessions/:id/resume` must answer with a +terminal, its tab must become active, and the terminal (xterm rows plus scrollback, +whitespace removed) must show the marker from the ended conversation within 120 s. A +folder-trust prompt is answered once. The journey then closes the tab and the tab must +leave. A cleanup closes the resumed terminal on any path. - Lives in: `public/outside-thread.js`; `public/panes/outside-thread-pane.js`; - `routes/index.js` (transcript route); relay's outside transcript route. + `routes/index.js` (transcript and resume routes); `chief-of-staff-outside.js` (`resume`); relay's outside transcript route. - Traps: BLOCKED setup V-COS; BLOCKED when claude is not on PATH; no NOTRUN path. One cleanup, which also runs on FAIL and on timeout, ends the process group and then removes the scratch folder. Run it alone: `node devboxverify/main.js --only outside-thread`. +**outside-adopt.** An idle outside Claude is adopted from the rail (G6.107, simple +path). Not a screen journey (480 s). Same BLOCKED rule for a relay with no outside +inventory, and BLOCKED when tmux or claude is not on PATH. It makes a scratch git folder +in Acme Corp and starts `claude --model haiku --permission-mode default "Reply with exactly +verify--adopt and nothing else."` in a detached tmux session, with the stack's +broker wiring (BLOCKED when the reply never comes). A `cos_outside_sessions` frame must +list the folder as `idle`, and the rail row must show `Adopt ` (a row with +`adoptable` false or absent fails, naming relay's reason). The journey taps it; +eve's `POST /api/outside-sessions/:id/adopt` must answer 201 with a `sessionId` and no +`claudeSessionId`. Then the Outside row must leave the rail, `Drop in to ` must +show under Idle, the tmux session must end (the pane's claude exited), and +`relay audit --event session_adopt` must hold an `ok` row since the tap. A model id that is +not a `claude-*` id makes relay answer `model_unknown`, and the journey FAILs with that text. +A cleanup deletes the adopted session; the usual one ends tmux and removes the folder and the +transcript. +- Lives in: `public/agent-board.js`; `routes/index.js` (adopt route); `chief-of-staff-outside.js` (`adoptable`, `adoptReason`); relay's `POST /api/sessions/adopt`. +- Traps: BLOCKED setup V-COS; no NOTRUN path. Run it alone: + `node devboxverify/main.js --only outside-adopt`. + **cos-outside-hooks.** An outside Claude's hook report moves its row at once (G6.93, power path). Not a screen journey (360 s). Same BLOCKED rule for a relay with no outside inventory. It makes a scratch git folder in Acme diff --git a/devboxverify/journeys-cos-outside.js b/devboxverify/journeys-cos-outside.js index 93d5c340..6371b050 100644 --- a/devboxverify/journeys-cos-outside.js +++ b/devboxverify/journeys-cos-outside.js @@ -22,6 +22,7 @@ const CLAUDE_MODEL = 'haiku'; const INVENTORY_WITHIN_MS = 40000; // two probe intervals of 15 s, plus the first snapshot const TRUST_WITHIN_MS = 30000; const TRUST_MAX_DOWNS = 4; +const READY_WITHIN_MS = 30000; // the input box, after start-up and the MCP servers const ROW_WITHIN_MS = 60000; // a probe interval of 15 s, claude start-up, and an ssh probe const REPLY_WITHIN_MS = 120000; const COS_TURN_WITHIN_MS = 120000; @@ -132,7 +133,7 @@ async function pane(tmux, name) { // Starts `claude --model haiku ` in a detached tmux session and answers // the folder-trust prompt if it comes. Returns { trusted } once the prompt text // is on the pane or the trust bound passes. -async function startClaude(env, tools, { name, dir, prompt, settings }) { +async function startClaude(env, tools, { name, dir, prompt, settings, asPerson = false }) { // Default permission mode: the machine's own mode (auto, say) may never show a permission prompt. const args = ['--model', CLAUDE_MODEL, '--permission-mode', 'default']; if (settings) args.push('--settings', settings); @@ -142,16 +143,19 @@ async function startClaude(env, tools, { name, dir, prompt, settings }) { const sessionId = crypto.randomUUID(); tools.claudeIds.push({ id: sessionId, journey: tools.journey }); args.push('--session-id', sessionId); - args.push(prompt); + // asPerson: no prompt argument. A person types into the ready prompt, after the MCP servers are up; an argv prompt is + // answered within a second, and relay vetoes an adopt for a child that started after the first reply. + if (!asPerson) args.push(prompt); const quote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`; env.step(`start ${name}`); + // asPerson: a person's terminal has neither variable; the tmux server inherits them from an agent session, and relay refuses an "sdk-cli" entrypoint. // A running tmux server does not inherit the client's environment, so a stack's // spool goes in with -e. The spool defaults to a folder in the stack's relay config dir. const spool = env.stack ? (env.stack.outsideSpool || path.join(env.relayConfigDir, 'outside-reports')) : null; const spoolEnv = spool ? ['-e', `RELAY_OUTSIDE_SPOOL=${spool}`] : []; // A stack run's broker wiring goes in through the shell command, which reads the token file itself. await writeOutsideSettings(env, dir); - await exec(tools.tmux, ['new-session', '-d', ...spoolEnv, '-s', name, '-x', '200', '-y', '50', '-c', dir, `${shellPrefix(env)}exec ${quote(tools.claude)} ${args.map(quote).join(' ')}`], + await exec(tools.tmux, ['new-session', '-d', ...spoolEnv, '-s', name, '-x', '200', '-y', '50', '-c', dir, `${shellPrefix(env)}exec ${asPerson ? 'env -u CLAUDE_CODE_ENTRYPOINT -u CLAUDECODE ' : ''}${quote(tools.claude)} ${args.map(quote).join(' ')}`], { env: cleanEnv(), timeout: 20000 }); tools.sessions.push(name); let trusted = false; @@ -171,8 +175,14 @@ async function startClaude(env, tools, { name, dir, prompt, settings }) { } return null; } + if (asPerson) return !TRUST_PROMPT.test(text) && /\? for shortcuts/.test(text) ? true : null; return text.includes(prompt.slice(-24)) ? true : null; - }, { timeoutMs: TRUST_WITHIN_MS, intervalMs: 500 }); + }, { timeoutMs: asPerson ? TRUST_WITHIN_MS + READY_WITHIN_MS : TRUST_WITHIN_MS, intervalMs: 500 }); + if (asPerson) { + // Waits: none possible: a terminal UI has no signal. The poll above is a bounded pane capture for the input box. + await exec(tools.tmux, ['send-keys', '-t', name, '-l', prompt], { timeout: 10000 }); + await exec(tools.tmux, ['send-keys', '-t', name, 'Enter'], { timeout: 10000 }); + } return { trusted }; } @@ -685,8 +695,10 @@ async function outsideThread(env) { if (!/^Ended\b/.test(endedHead)) problems.push(`the group holding the ended row is headed "${endedHead}", not Ended`); const endedMeta = (await metaOf(endedRow.first())).split(' · ').pop(); if (endedMeta !== 'Ended') problems.push(`the ended row's line 2 ends with "${endedMeta}", not Ended`); + // G6.106: the row is no button, and the only button beside it is Resume. + const besideEnded = group.locator('.agent-row-wrap').filter({ has: page.locator('[data-state="ended"]').filter({ hasText: base }) }).getByRole('button'); if ((await endedRow.first().evaluate((el) => el.tagName)) !== 'DIV' || (await endedRow.first().locator('button').count()) > 0 - || (await group.locator('.agent-row-wrap').filter({ has: endedRow.first() }).getByRole('button').count()) > 0) problems.push('the ended row is a button or has a button beside it'); + || (await besideEnded.count()) !== 1 || !/^Resume .+ in a terminal$/.test((await besideEnded.first().getAttribute('aria-label')) || '')) problems.push('the ended row is a button, or the only button beside it is not Resume'); env.step('read the last turn on the Ended row'); const withTurn = await poll(async () => seen.sessions.find((s) => s.id === endedFrame.id && typeof s.lastExcerpt === 'string' && s.lastExcerpt.includes(marker)) || null, @@ -705,10 +717,179 @@ async function outsideThread(env) { if (post.kind !== 'reply') problems.push(`the summary question posted "${post.kind}", want reply: "${summary.slice(0, 160)}"`); else if (!summary.includes(marker)) problems.push(`the summary reply does not carry ${marker}: "${summary.slice(0, 200)}"`); + env.step('tap Resume on the Ended row'); + // Registered before the tap: whatever terminal the answer names is closed, even on a failure path. + let resumedId = ''; + env.cleanup('close the resumed terminal', async () => { if (resumedId) await env.api.closeTerminal(resumedId).catch(() => {}); }); + const answered = page.waitForResponse((r) => r.request().method() === 'POST' && r.url().includes(`/api/outside-sessions/${encodeURIComponent(endedFrame.id)}/resume`), { timeout: GONE_WITHIN_MS }) + .then((r) => r.json()).catch(() => null); + if ((await besideEnded.count()) !== 1) return result(id, FAIL, `${problems.join('; ')}${problems.length ? '; ' : ''}no single Resume button beside the ended row for ${base}, so Resume was not tapped`); + const clickedAt = Date.now(); + await besideEnded.first().click({ timeout: 5000 }); + const answer = await answered; + resumedId = answer?.terminal?.terminalId || ''; + if (!resumedId) return result(id, FAIL, `${problems.join('; ')}${problems.length ? '; ' : ''}Resume on ${base} opened no terminal (answer: ${JSON.stringify(answer || null).slice(0, 160)})`); + const tabOpen = await poll(async () => (await page.locator('.tab.active').and(page.locator(`[data-tab-id="${resumedId}"]`)).count()) > 0 || null, { timeoutMs: TRUST_WITHIN_MS, intervalMs: 250 }); + if (!tabOpen) return result(id, FAIL, `${problems.join('; ')}${problems.length ? '; ' : ''}no active terminal tab for the resumed ${base} within ${TRUST_WITHIN_MS / 1000}s of Resume`); + + env.step('wait for the conversation in the terminal'); + const termPane = page.locator('#terminal'); + let trustAnswered = false; + const history = await poll(async () => { + // xterm wraps rows even mid-word: compare with whitespace gone, over the rows and the scrollback. + const visible = await termPane.innerText({ timeout: 2000 }).catch(() => ''); + const buffered = await page.evaluate(() => { + const b = window.client?.terminalManager?.activeTerm?.()?.buffer?.active; + if (!b) return ''; + let out = ''; + for (let i = 0; i < b.length; i++) out += (b.getLine(i)?.translateToString(true) || '') + '\n'; + return out; + }).catch(() => ''); + const flat = `${visible}${buffered}`.replace(/\s+/g, ''); + if (flat.includes(marker)) return true; + if (!trustAnswered && /trust/i.test(flat)) { + trustAnswered = true; + await termPane.locator('.xterm-screen').filter({ visible: true }).last().click({ timeout: 5000 }); + await page.keyboard.press('Enter'); + } + return null; + }, { timeoutMs: Math.max(1, clickedAt + REPLY_WITHIN_MS - Date.now()), intervalMs: 500 }); + if (!history) problems.push(`"${marker}" is not in the resumed terminal within ${REPLY_WITHIN_MS / 1000}s of Resume${trustAnswered ? ' (a trust prompt was answered)' : ''}`); + + env.step('close the resumed terminal'); + await page.locator('.tab.active .tab-close').click({ timeout: 5000 }); + const gone = await expect(page.locator(`[data-tab-id="${resumedId}"]`)).toHaveCount(0, { timeout: GONE_WITHIN_MS }).then(() => true, () => false); + if (!gone) problems.push(`the tab for terminal ${resumedId} is still open ${GONE_WITHIN_MS / 1000}s after Close`); + if (problems.length) return result(id, FAIL, problems.join('; ')); return result(id, PASS, `the row for ${base} read Read-only thread; its thread showed a You entry with the prompt, a Claude entry carrying ${marker} with no script, iframe, img or on* attribute ("${sample}") and a Tool entry shown as a closed details or a short pre, with no textbox, Send or Stop; ` + `once the claude ended the tab closed with "${notice}" within ${GONE_WITHIN_MS / 1000}s; ` - + `an ended row for ${base} sat under Ended with Ended in line 2 and no button, line 3 showed ${marker}, and the Chief of Staff's summary quoted it`); + + `an ended row for ${base} sat under Ended with Ended in line 2 and Resume as its only button, line 3 showed ${marker}, and the Chief of Staff's summary quoted it; ` + + `Resume opened a terminal tab that held ${marker} from the ended conversation, and closing it closed the tab`); +} + +// The assistant records of one conversation's transcript (~/.claude/projects/*/.jsonl). `real` is true when a +// record from a model holds the marker; `synthetic` lists the text of records Claude wrote itself (model ""). +async function assistantReplies(claudeId, marker) { + const root = path.join(os.homedir(), '.claude', 'projects'); + const out = { real: false, synthetic: [] }; + let dirs; + try { dirs = await fs.promises.readdir(root); } catch { return out; } + for (const dir of dirs) { + let text; + try { text = await fs.promises.readFile(path.join(root, dir, `${claudeId}.jsonl`), 'utf8'); } catch { continue; } + for (const line of text.split('\n')) { + let rec; + try { rec = JSON.parse(line); } catch { continue; } + if (!rec || rec.type !== 'assistant' || !rec.message) continue; + const content = rec.message.content; + const said = Array.isArray(content) ? content.filter((c) => c && c.type === 'text').map((c) => c.text).join('\n') : String(content || ''); + if (rec.message.model === '') out.synthetic.push(said.trim()); + else if (said.includes(marker)) out.real = true; + } + } + return out; +} + +// G6.107, the simple path: an idle interactive Claude started in tmux under Acme shows Adopt on the +// rail. One tap and relay takes it over: the outside row goes, the process in the pane exits, and the +// agent shows under Idle with Drop in. Waits: the outside row's state from the captured frames, the +// button and the managed row by locator, the eve route's answer by waitForResponse, the pane's exit by +// the tmux session's end, relay audit by CLI exit. Claude's first reply and its folder-trust prompt +// have no hook, so they are bounded polls, as outside-thread uses. +async function outsideAdopt(env) { + const id = 'outside-adopt'; + if (env.cosSetupProblem) return result(id, BLOCKED, env.cosSetupProblem); + const tools = newTools(env, id); + if (tools.problem) return result(id, BLOCKED, tools.problem); + if (!env.relayBin) return result(id, BLOCKED, 'no relay binary to read the audit with'); + + const marker = `verify-${env.nonce}-adopt`; + const branch = `verify-${env.nonce}-adopt`; + const tmuxName = `verify-${env.nonce}-outside-adopt`; + const held = { sessionId: '' }; + // Registered before the tap: the managed session relay makes is deleted even on a failure path. + env.cleanup('delete the adopted session', async () => { if (held.sessionId) await require('./journey-kit').deleteSession(env, held.sessionId); }); + + const page = await env.newPage(); + const seen = frames(page); + await openEve(page, env); + await openChiefOfStaff(page, env); + const blocked = await inventoryProblem(seen); + if (blocked) return result(id, BLOCKED, blocked); + + env.step('create the folder'); + const dir = await kit().scratchFolder(env, 'outside-adopt'); + await gitFolder(dir, branch); + const base = path.basename(dir); + const started = await startClaude(env, tools, { name: tmuxName, dir, prompt: `Reply with exactly ${marker} and nothing else.`, asPerson: true }); + + env.step('wait for the outside reply'); + // Waits: none possible for model output; a bounded read of the conversation's transcript. The prompt sits in the + // transcript and on the pane too, so only an assistant record counts, and a one (a login or API + // error Claude writes itself) is no model answer. + const claudeId = tools.claudeIds[tools.claudeIds.length - 1].id; + const replied = await poll(async () => { + const got = await assistantReplies(claudeId, marker); + return got.real || got.synthetic.length ? got : null; + }, { timeoutMs: REPLY_WITHIN_MS, intervalMs: 1000 }); + if (!replied) return result(id, BLOCKED, `the outside claude did not reply ${marker} within ${REPLY_WITHIN_MS / 1000}s (model output${started.trusted ? '; a trust prompt was answered' : ''})`); + if (!replied.real) return result(id, BLOCKED, `the outside claude answered "": ${replied.synthetic[0]}`); + + env.step('wait for the idle row'); + const listed = await poll(async () => seen.sessions.find((s) => s.state === 'idle' && String(s.directory || '').includes(base)) || null, + { timeoutMs: ROW_WITHIN_MS + IDLE_WITHIN_MS, intervalMs: 500 }); + if (!listed) return result(id, FAIL, `no cos_outside_sessions frame listed an idle claude in ${base} within ${(ROW_WITHIN_MS + IDLE_WITHIN_MS) / 1000}s of its reply`); + const adoptButton = page.getByTestId(`rail-adopt-${listed.id}`); + const shown = await expect(adoptButton).toBeVisible({ timeout: ROW_WITHIN_MS }).then(() => true, () => false); + if (!shown) { + const why = await page.getByTestId(`rail-adopt-reason-${listed.id}`).textContent({ timeout: 1000 }).catch(() => ''); + return result(id, FAIL, `the idle row for ${base} shows no Adopt button${why ? ` (its line: "${why.trim()}")` : ''} (adoptable: ${JSON.stringify(listed.adoptable)}, reason: ${JSON.stringify(listed.adoptReason)})`); + } + const label = (await adoptButton.getAttribute('aria-label')) || ''; + const name = label.replace(/^Adopt /, ''); + + env.step('tap Adopt'); + const sentAt = Date.now(); + const answered = page.waitForResponse((r) => r.request().method() === 'POST' && r.url().includes(`/api/outside-sessions/${encodeURIComponent(listed.id)}/adopt`), { timeout: 90000 }) + .then(async (r) => ({ status: r.status(), body: await r.json().catch(() => null) })).catch(() => null); + await adoptButton.click({ timeout: 5000 }); + const answer = await answered; + held.sessionId = (answer && answer.body && answer.body.sessionId) || ''; + const problems = []; + if (!answer || answer.status !== 201 || !held.sessionId) { + return result(id, FAIL, `POST /api/outside-sessions//adopt answered ${answer ? answer.status : 'nothing'} (${JSON.stringify(answer && answer.body || null).slice(0, 200)})`); + } + if (answer.body && 'claudeSessionId' in answer.body) problems.push('the 201 carried claudeSessionId'); + + env.step('wait for the managed row'); + const dropIn = page.getByTestId('rail-agents-group-idle').getByRole('button', { name: `Drop in to ${name}`, exact: true }); + const managed = await expect(dropIn).toBeVisible({ timeout: ROW_WITHIN_MS }).then(() => true, () => false); + if (!managed) problems.push(`no "Drop in to ${name}" button under Idle within ${ROW_WITHIN_MS / 1000}s of the 201`); + const gone = await expect(outsideRows(page, base)).toHaveCount(0, { timeout: GONE_WITHIN_MS }).then(() => true, () => false); + if (!gone) problems.push(`the Outside row for ${base} is still on the rail ${GONE_WITHIN_MS / 1000}s after the 201`); + + env.step('wait for the pane to exit'); + const exited = await poll(async () => { + try { await exec(tools.tmux, ['has-session', '-t', `=${tmuxName}`], { timeout: 10000 }); return null; } catch { return true; } + }, { timeoutMs: GONE_WITHIN_MS, intervalMs: 500 }); + if (!exited) problems.push(`the outside claude in tmux ${tmuxName} is still running ${GONE_WITHIN_MS / 1000}s after the 201`); + + env.step('read relay audit'); + const audited = await poll(async () => { + const { stdout } = await exec(env.relayBin, ['audit', '--event', 'session_adopt', '--json', '--tail', '200'], { timeout: 10000, maxBuffer: 32 << 20 }); + for (const line of stdout.split('\n')) { + let o; + try { o = JSON.parse(line); } catch { continue; } + if (o && o.event === 'session_adopt' && o.outcome === 'ok' && Date.parse(o.ts) >= sentAt - 1000) return true; + } + return null; + }, { timeoutMs: HOOK_STATE_WITHIN_MS * 2, intervalMs: 1000 }); + if (!audited) problems.push('relay audit holds no session_adopt row with outcome ok since the tap'); + + if (problems.length) return result(id, FAIL, problems.join('; ')); + return result(id, PASS, `the idle row for ${base} showed Adopt ${name}; one tap answered 201 with a session and no claudeSessionId, the Outside row left the rail, "Drop in to ${name}" showed under Idle, ` + + `the claude in the tmux pane exited, and relay audit holds a session_adopt row with outcome ok`); } module.exports = { @@ -718,7 +899,10 @@ module.exports = { id: 'cos-outside-simple', claude: true, timeoutMs: 600000, areas: ['chief-of-staff'], needs: ['project:acme'], screen: true, run: cosOutsideSimple, }, outsideThread: { - id: 'outside-thread', claude: true, timeoutMs: 600000, areas: ['chief-of-staff'], needs: ['project:acme'], run: outsideThread, + id: 'outside-thread', claude: true, timeoutMs: 720000, areas: ['chief-of-staff'], needs: ['project:acme'], run: outsideThread, + }, + outsideAdopt: { + id: 'outside-adopt', claude: true, timeoutMs: 480000, areas: ['chief-of-staff'], needs: ['project:acme'], run: outsideAdopt, }, cosOutsideHooks: { id: 'cos-outside-hooks', claude: true, timeoutMs: 360000, areas: ['chief-of-staff'], needs: ['project:acme'], run: cosOutsideHooks, diff --git a/devboxverify/journeys.js b/devboxverify/journeys.js index 7ae49ea7..2ed01c41 100644 --- a/devboxverify/journeys.js +++ b/devboxverify/journeys.js @@ -4464,6 +4464,7 @@ const journeys = [ files.filesOnHost, cosOutside.cosOutsideSimple, cosOutside.outsideThread, + cosOutside.outsideAdopt, cosOutside.cosOutsideHooks, { id: 'project-mode-new', timeoutMs: 90000, areas: ['projects', 'home'], needs: [], screen: true, run: projectModeNew }, auth.addBrowserInWindow, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ab5a9836..3afaf7f4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -369,8 +369,8 @@ Code: public/task-viewer.js, public/routine-audit.js, public/routine-history.js, | G6.20 | See each agent's state on the board | Today | `relay session list` | Under "Agents", groups "Needs you", "Working", "Idle" and "Done" show counts and a row per agent with a state dot; an errored agent's row adds its provider's last stderr line and exit code, like ` (exit 127)` | `relay session list` shows the same sessions | r1: relay down: text "Can't reach relay."; r2: none: text "No agents running" | agent-board-states | g6-board.spec.js, g6-exit.spec.js | | G6.21 | Open an agent's thread from the board | Today > button "" | none | The agent's thread opens as a tab | none | none | none yet | g6-board.spec.js | | G6.22 | See an agent's state change within moments | n/a: background | n/a: background | A row moves between groups, for example from "Working" to "Needs you", within about two seconds | none | none | agent-board-states | g6-board.spec.js | -| G6.23 | Drop in to an agent that is waiting on me | Today > button "Drop in to " | `relay drop-in ` | A terminal tab opens on the same conversation; the button read "Dropping in…" while relay worked | `relay logs --event session.drop_in` ok; `relay terminal list` shows it | r1: relay refuses: toast "Can't drop in: "; r2: no terminal back: toast "Can't drop in: relay did not return a terminal."; r3: not a headless Claude session: no button | agent-drop-in | devbox: agent-drop-in | -| G6.24 | Close a dropped-in terminal to hand the agent back | Terminal > button "Close " | none | The tab closes and the agent is back under its old state on the board | none: the hand-back is not in the proof list | none | agent-drop-in | devbox: agent-drop-in | +| G6.23 | Drop in to an agent that is waiting on me or idle | Today > button "Drop in to " | `relay drop-in ` | A terminal tab opens on the same conversation; the button read "Dropping in…" while relay worked; the agent shows under "Needs you" or "Idle" | `relay logs --event session.drop_in` ok; `relay terminal list` shows it | r1: relay refuses: toast "Can't drop in: "; r2: no terminal back: toast "Can't drop in: relay did not return a terminal."; r3: not a headless Claude session, or an outside row: no button | agent-drop-in | g6-drop-in.spec.js, devbox: agent-drop-in | +| G6.24 | Close a dropped-in terminal to hand the agent back | Terminal > button "Close " | none | The tab closes and the agent is back under "Idle" with its Drop in button | none: the hand-back is not in the proof list | none | agent-drop-in | g6-drop-in.spec.js, devbox: agent-drop-in | | G6.25 | See how many agents need me on a phone | Bottom bar > button "Today" | none | The button "Today" carries a red count read as " need you" | none | r1: none need me: no count shows | none yet | g6-board.spec.js | | G6.26 | See which threads are running right now | Today | none | Under "Running", a button per running thread or routine shows a dot titled "Running" | none | r1: nothing running: text "Nothing running." | none yet | g6-board.spec.js | | G6.27 | See a running dot on a project chip | Today | none | The project button shows a dot titled " running" | none | none | none yet | none yet: #369 | @@ -387,7 +387,7 @@ Code: public/task-viewer.js, public/routine-audit.js, public/routine-history.js, | G6.38 | See every agent in the rail to the right of the thread | Chief of Staff > navigation "Agents" | `relay session list` | On a window wider than 900px the rail sits to the right of the thread, 280px wide by default. Headings "Needs you", "Working", "Idle" and "Done"; each row shows name, age, project and state words, and a last line (for an errored agent, its exit line) | `relay session list` shows the same sessions | r1: relay down: text "Can't reach relay." | cos-agent-rail | g6-cos.spec.js, g6-exit.spec.js | | G6.39 | Open an agent from a rail row | Chief of Staff > navigation "Agents" > button "" | none | The agent's thread, or its terminal, opens as a tab | none | none | cos-agent-rail | g6-cos.spec.js | | G6.40 | Show the finished agents in the rail | Chief of Staff > button "Done " | none | The "Done" group expands and lists finished agents; a second press collapses it | none | none | none yet | g6-cos.spec.js | -| G6.41 | Drop in from a rail row | Chief of Staff > button "Drop in to " | `relay drop-in ` | A terminal tab opens on the same conversation | `relay logs --event session.drop_in` ok | r1: relay refuses: toast "Can't drop in: "; r2: not a headless Claude session: no button | none yet | none yet: #369 | +| G6.41 | Drop in from a rail row | Chief of Staff > button "Drop in to " or the phone dialog "Agents" | `relay drop-in ` | A terminal tab opens on the same conversation | `relay logs --event session.drop_in` ok | r1: relay refuses: toast "Can't drop in: "; r2: not a headless Claude session: no button | none yet | g6-drop-in.spec.js | | G6.42 | See an idle agent's summary under its rail row | n/a: background | n/a: background | The row's third line shows a one-line summary once the agent's turn ends; no post appears in the thread | none | none | cos-agent-rail | g6-cos.spec.js | | G6.43 | Open the agents sheet on a phone | Chief of Staff > resize to 390x844 > button "Agents" (its name starts with the counts, like "2 idle Agents") | none | A dialog "Agents" opens with the same rows; the strip shows red, amber and green counts | none | r1: relay down: no counts show | cos-agent-rail | g6-cos.spec.js | | G6.44 | Close the agents sheet | Chief of Staff > dialog "Agents" > button "Close" | none | The sheet closes and focus returns to the strip; Escape, a tap outside or a drag down does the same | none | none | none yet | g6-cos.spec.js | @@ -439,7 +439,7 @@ Code: public/task-viewer.js, public/routine-audit.js, public/routine-history.js, | G6.90 | Add the proposed chat from its Add card | Chief of Staff > button "Add" | relay `settings.json` `projects[].chat_templates` (no relay CLI verb for chats) | The card shows "Approve on the Mac", then "Added"; it lists the chat's Name and Model and has no text field or Edit button | `chat_template.add` ok; the project's `chat_templates` lists the chat | r1: presence refused: card text "Failed: presence was refused"; presence timed out: the card keeps "Approve on the Mac" until relay answers; nothing is written either way; r2: relay refuses, such as `name_taken`: card text "Failed: "; r3: project removed: "Failed: That project is no longer available" | cos-add-chat | g6-cos-chats.spec.js | | G6.91 | Cancel the Add-a-chat card | Chief of Staff > button "Cancel" | none | The card status reads "Cancelled" and no chat is added | none | none | none yet | g6-cos-chats.spec.js | | G6.92 | Start a project's chat by name | Chief of Staff > textbox "Tell an agent" > type "start on and ask it " > press Return | none | Either a Start card (Prompt, Folder, Agent) appears, or at once a post "Started in ." with button "Open"; the chat runs without you and its reply shows in the chat view | `relay audit` shows the start by the Chief of Staff | r1: relay refuses: post "Couldn't start an agent in : " | cos-add-chat | g6-cos-chats.spec.js | -| G6.93 | Follow outside Claude work | Chief of Staff > navigation "Agents" or phone dialog "Agents" | Relay host configuration and optional outside report hooks | Outside rows show machine, folder, branch and Working, Waiting on you or Idle; waiting rows count once toward need you | Relay outside inventory snapshot | r1: the row reads Read-only thread, or Thread unavailable: Host is unreachable | cos-outside-simple, cos-outside-hooks | g6-cos-outside.spec.js | +| G6.93 | Follow outside Claude work | Chief of Staff > navigation "Agents" or phone dialog "Agents" | Relay host configuration and optional outside report hooks | Outside rows show machine, folder, branch and Working, Waiting on you or Idle; waiting rows count once toward need you; an adoptable live row also offers Adopt or Interrupt and adopt (G6.107, G6.108) | Relay outside inventory snapshot | r1: the row reads Read-only thread, or Thread unavailable: Host is unreachable | cos-outside-simple, cos-outside-hooks | g6-cos-outside.spec.js | | G6.94 | Ask about the latest outside turn | Chief of Staff > textbox "Tell an agent" > ask about an Outside row | none | Reply uses the latest turn read at request time | scoped outside detail | r1: failed read: no successful stale text | cos-outside-simple, outside-thread | devbox: cos-outside-simple, devbox: outside-thread | | G6.95 | Watch an outside tmux session safely | Chief of Staff > Outside row > button "View read-only" | Relay host show_all_tmux_sessions | Terminal shows Read-only; input, paste, drop, keybar and remote resize are suppressed; Close detaches only viewer | Relay exact inventory and backend input/resize refusal | r1: opt-in off or capability unavailable: view refused; r2: reserved outside action ID: Outside sessions are read-only. | cos-outside-simple | g6-cos-outside.spec.js | | G6.96 | Read an outside session's thread | Chief of Staff > Outside row | none | A tab shows the session's name, the line Outside, machine and folder, a Read-only badge and the conversation (You, Claude and Tool entries, oldest first); Claude entries as Markdown; a tool call as its name and a short summary with its input folded; a tool result past 400 characters or 8 lines folded; You and tool text as plain text; button "Load older" adds the previous page above; new entries show within the probe interval; the tab has no textbox, Send or Stop; the tab closes with a notice when the session leaves the list | Relay outside transcript route | r1: the machine is unreachable: the row reads Thread unavailable: Host is unreachable and opens no tab; r2: the session left the list: the tab closes and the text " left the Agents list, so its thread closed." shows | outside-thread, cos-outside-simple | g6-outside-thread.spec.js, g6-outside-thread-format.spec.js | @@ -450,7 +450,11 @@ Code: public/task-viewer.js, public/routine-audit.js, public/routine-history.js, | G6.101 | Read what happened while I was away | Chief of Staff > thread, when the card reads "Ended: " | none | One post "While you were away" gives " to · sent · ended: " and the groups "Done for you", "Waits for you" (a session at its cap reads "