Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e4cfed2
Drop in from an idle headless session; Take over after the turn from …
barelyworkingcode Oct 20, 2026
1a1b4e5
Test: drop in from idle, take over after the turn, refusals, outside …
barelyworkingcode Oct 20, 2026
6900e0d
Test: one-line toast read raw, Take over keeps its waiting text under…
barelyworkingcode Oct 20, 2026
64d7457
Test: hand-back waits on the Working group emptying, not the Idle hea…
barelyworkingcode Oct 20, 2026
1d88a3d
Merge pull request #599 from barelyworkingcode/feat/592-drop-in-idle
barelyworkingcode Oct 20, 2026
39db0af
Pin fakerelay to relay's session-adoption epic head with claudeSessio…
barelyworkingcode Oct 20, 2026
8b163e1
Merge pull request #601 from barelyworkingcode/chore/593-relay-pin
barelyworkingcode Oct 20, 2026
fb450b8
Resume an ended outside session in a terminal (#593)
barelyworkingcode Oct 20, 2026
d099a24
Test: resume an ended outside session, from the contract (#593)
barelyworkingcode Oct 20, 2026
72994f1
Test: resume an ended outside row, refusals, route checks, id never i…
barelyworkingcode Oct 20, 2026
af0a410
Fix outside-thread Resume locator: relative has, FAIL before clicking…
barelyworkingcode Oct 20, 2026
678e794
Resume: log one line per outcome; tighten selftest on live and check …
barelyworkingcode Oct 20, 2026
a06e092
Review: per-instance fixture folder, hosted-project r2 row, resume lo…
barelyworkingcode Oct 20, 2026
c11387e
Merge pull request #602 from barelyworkingcode/feat/593-resume-ended
barelyworkingcode Oct 20, 2026
7f3272e
Pin fakerelay to relay's session-adoption epic head 2faa421 (#597)
barelyworkingcode Oct 21, 2026
d2e6c64
Merge pull request #604 from barelyworkingcode/chore/597-relay-pin
barelyworkingcode Oct 21, 2026
24f5dc8
Adopt an idle outside session from the agents board (#597)
barelyworkingcode Oct 21, 2026
be85a13
Test: Adopt and Interrupt and adopt from the agents board; feature ro…
barelyworkingcode Oct 21, 2026
a561858
outside-adopt: launch claude as a person's terminal would (#597)
barelyworkingcode Oct 21, 2026
ce56d49
outside-adopt: type the prompt into the ready input box (#597)
barelyworkingcode Oct 21, 2026
5e0a04c
outside-adopt: wait for the reply on the transcript, BLOCKED on a syn…
barelyworkingcode Oct 21, 2026
1399a79
Merge pull request #606 from barelyworkingcode/feat/597-adopt
barelyworkingcode Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions chief-of-staff-outside.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion chief-of-staff.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
} });
Expand Down
29 changes: 27 additions & 2 deletions devboxverify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <name>.
Quote its last reply.`; the `reply` post (up to 120 s) must carry the marker.
The only button beside the ended row is `Resume <name> 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-<nonce>-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 <name>` (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 <name>` 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
Expand Down
Loading