@ours.network/fleet 0.9.4 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +148 -30
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +41 -11
  6. package/dist/cli.js +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
@@ -0,0 +1,322 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { createConnection, createServer } from 'node:net';
4
+ import { join } from 'node:path';
5
+ import { SessionControlError } from './types.js';
6
+ const MAX_LINE_BYTES = 64 * 1024;
7
+ /**
8
+ * One line saying what a control failure does — and does not — prove about the
9
+ * agent. Only `offline` is evidence that it is gone; every other kind used to
10
+ * be rendered as "is not running", which is how a busy agent got restarted.
11
+ */
12
+ export function livenessNote(kind, name) {
13
+ switch (kind) {
14
+ case 'offline':
15
+ return `'${name}' is confirmed offline.`;
16
+ case 'control-unavailable':
17
+ return `this says nothing about whether '${name}' is alive — its control plane did not answer; ` +
18
+ `check: ours-fleet status ${name}`;
19
+ case 'timeout':
20
+ return `'${name}' did not answer in time; a busy agent looks exactly like this. ` +
21
+ `Check: ours-fleet status ${name}`;
22
+ case 'rejected':
23
+ return `'${name}' is running and refused the request.`;
24
+ case 'backend':
25
+ return `this is a transport failure, not evidence that '${name}' is gone; ` +
26
+ `check: ours-fleet status ${name}`;
27
+ }
28
+ }
29
+ /** The taxonomy, in the order generated guidance presents it. */
30
+ export function oversightTaxonomy(name = '<Name>') {
31
+ return [
32
+ {
33
+ result: 'queued',
34
+ meaning: `the session accepted the prompt for '${name}'; a turn already running is not a failure.`,
35
+ action: 'Nothing. Do not resend, and do not read the absence of a reply as a stall — '
36
+ + `check progress with: ours-fleet peek ${name}`,
37
+ restartJustified: false,
38
+ },
39
+ {
40
+ result: 'timeout',
41
+ meaning: livenessNote('timeout', name),
42
+ action: 'Treat delivery as UNCERTAIN — the request may already have been acted on, so do not '
43
+ + 'resend it blindly.',
44
+ restartJustified: false,
45
+ },
46
+ {
47
+ result: 'rejected',
48
+ meaning: livenessNote('rejected', name),
49
+ action: 'Fix the request, not the agent. A refusal is proof of life.',
50
+ restartJustified: false,
51
+ },
52
+ {
53
+ result: 'control-unavailable',
54
+ meaning: livenessNote('control-unavailable', name),
55
+ action: 'Read the role logs as well. The control plane and the agent are separate things, '
56
+ + 'and one being unreachable is not evidence about the other.',
57
+ restartJustified: false,
58
+ },
59
+ {
60
+ result: 'backend',
61
+ meaning: livenessNote('backend', name),
62
+ action: 'Investigate the transport, not the agent.',
63
+ restartJustified: false,
64
+ },
65
+ {
66
+ result: 'offline',
67
+ meaning: livenessNote('offline', name),
68
+ action: `This is the ONLY result that justifies a restart on its own: ours-fleet restart ${name} `
69
+ + 'for a permanent role. Read the logs first.',
70
+ restartJustified: true,
71
+ },
72
+ ];
73
+ }
74
+ /**
75
+ * The taxonomy as guidance lines, for a briefing or any generated document.
76
+ * The shipped oversee-agents skills carry these same lines, and a test holds
77
+ * them to it — so an overseer reading its briefing and an overseer reading the
78
+ * skill cannot be given different rules.
79
+ */
80
+ export function oversightTaxonomyLines(name = '<Name>') {
81
+ return oversightTaxonomy(name).map(r => `- **${r.result}** — ${r.meaning} → ${r.action}`);
82
+ }
83
+ export const controlSocketPath = (stateDir) => join(stateDir, '.control.sock');
84
+ export const controlTokenPath = (stateDir) => join(stateDir, '.control-token');
85
+ function sameToken(actual, supplied) {
86
+ const a = Buffer.from(actual);
87
+ const b = Buffer.from(supplied);
88
+ return a.length === b.length && timingSafeEqual(a, b);
89
+ }
90
+ /** Private, versioned JSONL control plane for CLI and future console frontends. */
91
+ export class RoleControlServer {
92
+ session;
93
+ log;
94
+ server;
95
+ socketPath;
96
+ token;
97
+ sockets = new Set();
98
+ constructor(stateDir, session, log) {
99
+ this.session = session;
100
+ this.log = log;
101
+ this.socketPath = controlSocketPath(stateDir);
102
+ const tokenPath = controlTokenPath(stateDir);
103
+ this.token = existsSync(tokenPath)
104
+ ? readFileSync(tokenPath, 'utf8').trim()
105
+ : randomBytes(32).toString('hex');
106
+ writeFileSync(tokenPath, this.token + '\n', { mode: 0o600 });
107
+ chmodSync(tokenPath, 0o600);
108
+ rmSync(this.socketPath, { force: true });
109
+ this.server = createServer(socket => this.accept(socket));
110
+ }
111
+ async start() {
112
+ await new Promise((resolve, reject) => {
113
+ this.server.once('error', reject);
114
+ this.server.listen(this.socketPath, () => {
115
+ this.server.off('error', reject);
116
+ try {
117
+ chmodSync(this.socketPath, 0o600);
118
+ }
119
+ catch { /* platform dependent */ }
120
+ resolve();
121
+ });
122
+ });
123
+ }
124
+ async close() {
125
+ for (const socket of this.sockets)
126
+ socket.destroy();
127
+ await new Promise(resolve => this.server.close(() => resolve()));
128
+ rmSync(this.socketPath, { force: true });
129
+ }
130
+ accept(socket) {
131
+ this.sockets.add(socket);
132
+ socket.setEncoding('utf8');
133
+ let buffer = '';
134
+ let unsubscribe;
135
+ socket.on('data', chunk => {
136
+ buffer += chunk;
137
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) {
138
+ socket.destroy(new Error('control request too large'));
139
+ return;
140
+ }
141
+ for (;;) {
142
+ const newline = buffer.indexOf('\n');
143
+ if (newline < 0)
144
+ break;
145
+ const line = buffer.slice(0, newline);
146
+ buffer = buffer.slice(newline + 1);
147
+ if (!line.trim())
148
+ continue;
149
+ void this.handle(line, socket).then(stop => {
150
+ if (stop) {
151
+ if (unsubscribe) {
152
+ unsubscribe();
153
+ this.session.setControllerAttached(false);
154
+ }
155
+ unsubscribe = stop;
156
+ }
157
+ });
158
+ }
159
+ });
160
+ socket.on('close', () => {
161
+ this.sockets.delete(socket);
162
+ if (unsubscribe) {
163
+ unsubscribe();
164
+ this.session.setControllerAttached(false);
165
+ }
166
+ });
167
+ socket.on('error', error => this.log(`control socket: ${error.message}`));
168
+ }
169
+ async handle(line, socket) {
170
+ let request;
171
+ try {
172
+ request = JSON.parse(line);
173
+ }
174
+ catch {
175
+ this.write(socket, { version: 1, id: '?', ok: false, error: 'invalid JSON' });
176
+ return;
177
+ }
178
+ if (request.version !== 1 || typeof request.id !== 'string'
179
+ || !sameToken(this.token, request.token ?? '')) {
180
+ this.write(socket, { version: 1, id: request.id ?? '?', ok: false, error: 'unauthorized' });
181
+ return;
182
+ }
183
+ try {
184
+ switch (request.command) {
185
+ case 'status':
186
+ case 'snapshot':
187
+ this.write(socket, { version: 1, id: request.id, ok: true, result: this.session.snapshot() });
188
+ return;
189
+ case 'submit_prompt': {
190
+ if (!request.text?.trim())
191
+ throw new SessionControlError('rejected', 'text is required');
192
+ // Answer on QUEUE ACCEPTANCE, not on turn completion. A turn can run
193
+ // for minutes; blocking here made every `send` into a busy agent time
194
+ // out, and the timeout was then reported as a dead agent.
195
+ const queued = await this.session.queuePrompt(request.text);
196
+ this.write(socket, {
197
+ version: 1, id: request.id, ok: true,
198
+ result: {
199
+ state: 'queued', promptId: queued.promptId, queuedBehind: queued.queuedBehind,
200
+ },
201
+ });
202
+ return;
203
+ }
204
+ case 'respond_permission': {
205
+ if (!request.permissionId || !request.optionId)
206
+ throw new SessionControlError('rejected', 'permissionId and optionId are required');
207
+ const accepted = this.session.respondPermission(request.permissionId, request.optionId);
208
+ this.write(socket, {
209
+ version: 1, id: request.id, ok: accepted,
210
+ result: { accepted },
211
+ error: accepted ? undefined : 'stale or invalid permission response',
212
+ kind: accepted ? undefined : 'rejected',
213
+ });
214
+ return;
215
+ }
216
+ case 'interrupt':
217
+ await this.session.interrupt();
218
+ this.write(socket, { version: 1, id: request.id, ok: true });
219
+ return;
220
+ case 'follow': {
221
+ const since = Number.isFinite(request.since) ? Number(request.since) : 0;
222
+ this.write(socket, {
223
+ version: 1, id: request.id, ok: true,
224
+ result: { events: this.session.eventsSince(since), snapshot: this.session.snapshot() },
225
+ });
226
+ this.session.setControllerAttached(true);
227
+ return this.session.subscribe(event => {
228
+ if (!socket.destroyed)
229
+ socket.write(JSON.stringify({ version: 1, event }) + '\n');
230
+ });
231
+ }
232
+ }
233
+ }
234
+ catch (error) {
235
+ // Carry the session's own classification to the caller. Losing it here is
236
+ // what forced the CLI to invent one.
237
+ this.write(socket, {
238
+ version: 1,
239
+ id: request.id,
240
+ ok: false,
241
+ error: error?.message ?? String(error),
242
+ kind: error instanceof SessionControlError ? error.kind : 'backend',
243
+ });
244
+ }
245
+ }
246
+ write(socket, response) {
247
+ if (!socket.destroyed)
248
+ socket.write(JSON.stringify(response) + '\n');
249
+ }
250
+ }
251
+ /**
252
+ * Send one control request. Every failure mode is classified: a missing token
253
+ * or socket is `control-unavailable`, a silent server is `timeout`, and a
254
+ * response that is not parseable JSON is `backend`. The caller never has to
255
+ * infer liveness from an exception message.
256
+ */
257
+ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
258
+ let token;
259
+ try {
260
+ token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
261
+ }
262
+ catch (error) {
263
+ throw new SessionControlError('control-unavailable', `cannot read the role control token: ${error?.message ?? String(error)}`);
264
+ }
265
+ const id = randomUUID();
266
+ const socket = createConnection(controlSocketPath(stateDir));
267
+ socket.setEncoding('utf8');
268
+ return new Promise((resolve, reject) => {
269
+ const timer = setTimeout(() => {
270
+ socket.destroy();
271
+ reject(new SessionControlError('timeout', `the role control plane did not answer '${request.command}' within ${timeoutMs}ms`));
272
+ }, timeoutMs);
273
+ let buffer = '';
274
+ socket.once('error', error => {
275
+ clearTimeout(timer);
276
+ const code = error.code;
277
+ reject(new SessionControlError(code === 'ENOENT' || code === 'ECONNREFUSED' ? 'control-unavailable' : 'backend', `role control socket: ${error.message}`));
278
+ });
279
+ socket.on('data', chunk => {
280
+ buffer += chunk;
281
+ const newline = buffer.indexOf('\n');
282
+ if (newline < 0)
283
+ return;
284
+ clearTimeout(timer);
285
+ try {
286
+ resolve(JSON.parse(buffer.slice(0, newline)));
287
+ }
288
+ catch (error) {
289
+ reject(new SessionControlError('backend', `malformed control response: ${error?.message ?? String(error)}`));
290
+ }
291
+ socket.end();
292
+ });
293
+ socket.once('connect', () => socket.write(JSON.stringify({
294
+ version: 1, id, token, ...request,
295
+ }) + '\n'));
296
+ });
297
+ }
298
+ export async function followControl(stateDir, onMessage) {
299
+ const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
300
+ const socket = createConnection(controlSocketPath(stateDir));
301
+ socket.setEncoding('utf8');
302
+ let buffer = '';
303
+ socket.on('data', chunk => {
304
+ buffer += chunk;
305
+ for (;;) {
306
+ const newline = buffer.indexOf('\n');
307
+ if (newline < 0)
308
+ break;
309
+ const line = buffer.slice(0, newline);
310
+ buffer = buffer.slice(newline + 1);
311
+ if (line.trim())
312
+ onMessage(JSON.parse(line));
313
+ }
314
+ });
315
+ await new Promise((resolve, reject) => {
316
+ socket.once('connect', resolve);
317
+ socket.once('error', reject);
318
+ });
319
+ const send = (request) => socket.write(JSON.stringify({ version: 1, id: randomUUID(), token, ...request }) + '\n');
320
+ send({ command: 'follow', since: 0 });
321
+ return { socket, send };
322
+ }
@@ -0,0 +1,14 @@
1
+ import type { SessionEvent, SessionEventKind } from './types.js';
2
+ /** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
3
+ export declare class SessionEvents {
4
+ private readonly path;
5
+ private seq;
6
+ private readonly events;
7
+ private readonly listeners;
8
+ constructor(path: string);
9
+ emit(kind: SessionEventKind, fields?: Omit<SessionEvent, 'version' | 'seq' | 'at' | 'kind'>): SessionEvent;
10
+ since(seq: number): SessionEvent[];
11
+ subscribe(listener: (event: SessionEvent) => void): () => void;
12
+ private restore;
13
+ private rotateIfNeeded;
14
+ }
@@ -0,0 +1,67 @@
1
+ import { appendFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ const MAX_EVENTS = 1_000;
3
+ const MAX_EVENT_FILE_BYTES = 2 * 1024 * 1024;
4
+ /** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
5
+ export class SessionEvents {
6
+ path;
7
+ seq = 0;
8
+ events = [];
9
+ listeners = new Set();
10
+ constructor(path) {
11
+ this.path = path;
12
+ this.restore();
13
+ }
14
+ emit(kind, fields = {}) {
15
+ const event = {
16
+ version: 1,
17
+ seq: ++this.seq,
18
+ at: new Date().toISOString(),
19
+ kind,
20
+ ...fields,
21
+ };
22
+ this.events.push(event);
23
+ if (this.events.length > MAX_EVENTS)
24
+ this.events.shift();
25
+ try {
26
+ this.rotateIfNeeded();
27
+ appendFileSync(this.path, JSON.stringify(event) + '\n', { mode: 0o600 });
28
+ }
29
+ catch { /* diagnostics must never terminate a role */ }
30
+ for (const listener of this.listeners)
31
+ listener(event);
32
+ return event;
33
+ }
34
+ since(seq) {
35
+ return this.events.filter(event => event.seq > seq);
36
+ }
37
+ subscribe(listener) {
38
+ this.listeners.add(listener);
39
+ return () => this.listeners.delete(listener);
40
+ }
41
+ restore() {
42
+ try {
43
+ if (!existsSync(this.path))
44
+ return;
45
+ const lines = readFileSync(this.path, 'utf8').trim().split('\n').slice(-MAX_EVENTS);
46
+ for (const line of lines) {
47
+ const event = JSON.parse(line);
48
+ if (event.version === 1 && typeof event.seq === 'number') {
49
+ this.events.push(event);
50
+ this.seq = Math.max(this.seq, event.seq);
51
+ }
52
+ }
53
+ }
54
+ catch { /* begin a fresh projection if the diagnostic file is corrupt */ }
55
+ }
56
+ rotateIfNeeded() {
57
+ if (!existsSync(this.path) || statSync(this.path).size < MAX_EVENT_FILE_BYTES)
58
+ return;
59
+ const rotated = this.path + '.1';
60
+ try {
61
+ renameSync(this.path, rotated);
62
+ }
63
+ catch {
64
+ writeFileSync(this.path, '', { mode: 0o600 });
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,27 @@
1
+ import type { Tmux } from '../tmux.js';
2
+ import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
3
+ /** SessionHandle adapter for the existing tmux transport. */
4
+ export declare class TmuxSession implements SessionHandle {
5
+ private readonly name;
6
+ readonly pid: number;
7
+ private readonly tmux;
8
+ private readonly processAlive;
9
+ readonly backend: "tmux";
10
+ constructor(name: string, pid: number, tmux: Tmux, processAlive: (pid: number) => boolean);
11
+ isAlive(): boolean;
12
+ snapshot(): SessionSnapshot;
13
+ queuePrompt(text: string): Promise<QueuedPrompt>;
14
+ submitPrompt(text: string): Promise<TurnResult>;
15
+ interrupt(): Promise<void>;
16
+ respondPermission(): boolean;
17
+ eventsSince(): SessionEvent[];
18
+ subscribe(): () => void;
19
+ setControllerAttached(): void;
20
+ /**
21
+ * A tmux pane's exit is only visible through the record its shell wrapper
22
+ * writes; the runner owns that file and classifies it. Nothing observable
23
+ * from here, so say `null` rather than guess.
24
+ */
25
+ exitResult(): ExitRecord | null;
26
+ close(): Promise<void>;
27
+ }
@@ -0,0 +1,76 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { SessionControlError, turnResult } from './types.js';
3
+ /** SessionHandle adapter for the existing tmux transport. */
4
+ export class TmuxSession {
5
+ name;
6
+ pid;
7
+ tmux;
8
+ processAlive;
9
+ backend = 'tmux';
10
+ constructor(name, pid, tmux, processAlive) {
11
+ this.name = name;
12
+ this.pid = pid;
13
+ this.tmux = tmux;
14
+ this.processAlive = processAlive;
15
+ }
16
+ isAlive() {
17
+ return this.processAlive(this.pid);
18
+ }
19
+ snapshot() {
20
+ return {
21
+ backend: 'tmux',
22
+ alive: this.isAlive(),
23
+ readiness: this.isAlive() ? 'idle' : 'failed',
24
+ };
25
+ }
26
+ async queuePrompt(text) {
27
+ if (!this.isAlive())
28
+ throw new SessionControlError('offline', `tmux pane for '${this.name}' is offline`);
29
+ try {
30
+ await this.tmux.sendText(this.name, text);
31
+ }
32
+ catch (error) {
33
+ throw new SessionControlError('backend', error?.message ?? String(error));
34
+ }
35
+ // Keystrokes carry no terminal result: tmux cannot tell us how the turn ended.
36
+ return {
37
+ promptId: randomUUID(),
38
+ queuedBehind: 0,
39
+ completion: Promise.resolve(turnResult(true, 'inconclusive')),
40
+ };
41
+ }
42
+ async submitPrompt(text) {
43
+ try {
44
+ return await (await this.queuePrompt(text)).completion;
45
+ }
46
+ catch (error) {
47
+ if (error instanceof SessionControlError)
48
+ return turnResult(false, 'failed', error.message);
49
+ throw error;
50
+ }
51
+ }
52
+ async interrupt() {
53
+ await this.tmux.sendKey(this.name, 'C-c');
54
+ }
55
+ respondPermission() {
56
+ return false;
57
+ }
58
+ eventsSince() {
59
+ return [];
60
+ }
61
+ subscribe() {
62
+ return () => { };
63
+ }
64
+ setControllerAttached() { }
65
+ /**
66
+ * A tmux pane's exit is only visible through the record its shell wrapper
67
+ * writes; the runner owns that file and classifies it. Nothing observable
68
+ * from here, so say `null` rather than guess.
69
+ */
70
+ exitResult() {
71
+ return null;
72
+ }
73
+ async close() {
74
+ await this.tmux.kill(this.name);
75
+ }
76
+ }
@@ -0,0 +1,138 @@
1
+ import type { SessionBackendId } from '../config.js';
2
+ export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
3
+ export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
4
+ /**
5
+ * Two independent facts about one turn, deliberately kept apart:
6
+ *
7
+ * - `accepted` — the live session took responsibility for the prompt. It says
8
+ * nothing about what the agent then did with it.
9
+ * - `outcome` / `succeeded` — how the turn TERMINATED. Only `completed` is a
10
+ * terminal success. A refusal or a cancellation is a prompt that was
11
+ * delivered and then not carried out; every caller that needs the work
12
+ * actually done (mail delivery, role startup) must treat it as a failure.
13
+ *
14
+ * Collapsing the two is what let a refused wake commit its notification cursor
15
+ * and a refused startup prompt log the role as up.
16
+ */
17
+ export interface TurnResult {
18
+ accepted: boolean;
19
+ outcome: TurnOutcome;
20
+ succeeded: boolean;
21
+ detail?: string;
22
+ }
23
+ /**
24
+ * Why a control operation failed. The distinctions exist because collapsing
25
+ * them is what made a busy agent look dead: only `offline` is evidence that the
26
+ * session is gone, and `timeout` explicitly does NOT say the prompt was lost.
27
+ */
28
+ export type ControlFailureKind = 'offline' | 'control-unavailable' | 'timeout' | 'rejected' | 'backend';
29
+ export declare class SessionControlError extends Error {
30
+ readonly kind: ControlFailureKind;
31
+ constructor(kind: ControlFailureKind, message: string);
32
+ }
33
+ /**
34
+ * A prompt the live session has taken responsibility for. Interactive callers
35
+ * stop here: the session has the prompt, and waiting for the turn to finish is
36
+ * a different question with a different, much longer, timescale.
37
+ */
38
+ export interface QueuedPrompt {
39
+ promptId: string;
40
+ /** Turns already queued ahead of this one. 0 means it starts immediately. */
41
+ queuedBehind: number;
42
+ /** The turn's terminal result. Never rejects. */
43
+ completion: Promise<TurnResult>;
44
+ }
45
+ /**
46
+ * How a session's process ended.
47
+ *
48
+ * `unknown` is the honest answer when no evidence was recorded — the previous
49
+ * code wrote the word `crash` there, asserting a failure it had not observed.
50
+ * `session-destroyed` (the console was torn down out from under a live process)
51
+ * and `program-exit` (the program decided to leave) are different events and
52
+ * must not collapse into one another, because they imply different next starts.
53
+ */
54
+ export type ExitClass = 'clean' | 'program-exit' | 'signal' | 'session-destroyed' | 'unknown';
55
+ export interface ExitRecord {
56
+ version: 1;
57
+ class: ExitClass;
58
+ /** Exit code, when the program exited of its own accord. */
59
+ code?: number;
60
+ /** Signal that killed it, when one did. */
61
+ signal?: string;
62
+ /** Raw wait status as the pane shell saw it (tmux only). */
63
+ status?: number;
64
+ at?: string;
65
+ /** One line an operator can read. */
66
+ detail: string;
67
+ }
68
+ /**
69
+ * Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
70
+ * signal evidence a pane wrapper can give us.
71
+ */
72
+ export declare function classifyShellStatus(status: number): ExitRecord;
73
+ /** Classify a child process exit reported directly by node. */
74
+ export declare function classifyChildExit(code: number | null, signal: string | null): ExitRecord;
75
+ /** The single definition of terminal success. Nothing else may re-derive it. */
76
+ export declare const isTerminalSuccess: (outcome: TurnOutcome) => boolean;
77
+ /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
78
+ export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string): TurnResult;
79
+ export interface SessionSnapshot {
80
+ backend: SessionBackendId;
81
+ alive: boolean;
82
+ readiness: SessionReadiness;
83
+ sessionId?: string;
84
+ lastError?: string;
85
+ pendingPermissionId?: string;
86
+ }
87
+ export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'turn_stop' | 'error';
88
+ /** What a settled permission request resolved to. */
89
+ export type PermissionDecision = 'allowed' | 'denied' | 'cancelled';
90
+ export interface SessionEvent {
91
+ version: 1;
92
+ seq: number;
93
+ at: string;
94
+ kind: SessionEventKind;
95
+ turnId?: string;
96
+ toolCallId?: string;
97
+ permissionId?: string;
98
+ text?: string;
99
+ title?: string;
100
+ status?: string;
101
+ stopReason?: string;
102
+ options?: Array<{
103
+ optionId: string;
104
+ name: string;
105
+ kind: string;
106
+ }>;
107
+ /** What was decided. */
108
+ decision?: PermissionDecision;
109
+ /** Whether policy decided it, or a human answered the prompt. */
110
+ decisionSource?: 'automatic' | 'manual';
111
+ /** The configured policy that produced an automatic decision. */
112
+ policy?: string;
113
+ /** Why, in one human-readable line. */
114
+ reason?: string;
115
+ /** The option actually selected, when one was. */
116
+ optionId?: string;
117
+ }
118
+ export interface SessionHandle {
119
+ readonly backend: SessionBackendId;
120
+ readonly pid: number;
121
+ isAlive(): boolean;
122
+ snapshot(): SessionSnapshot;
123
+ /**
124
+ * Hand the session a prompt and return as soon as it has accepted
125
+ * responsibility for it. Throws `SessionControlError` if it cannot.
126
+ */
127
+ queuePrompt(text: string): Promise<QueuedPrompt>;
128
+ /** Queue a prompt and wait for its terminal result. */
129
+ submitPrompt(text: string): Promise<TurnResult>;
130
+ interrupt(): Promise<void>;
131
+ respondPermission(permissionId: string, optionId: string): boolean;
132
+ eventsSince(seq: number): SessionEvent[];
133
+ subscribe(listener: (event: SessionEvent) => void): () => void;
134
+ setControllerAttached(attached: boolean): void;
135
+ /** How the backing process ended, or null while it is still running. */
136
+ exitResult(): ExitRecord | null;
137
+ close(): Promise<void>;
138
+ }
@@ -0,0 +1,42 @@
1
+ export class SessionControlError extends Error {
2
+ kind;
3
+ constructor(kind, message) {
4
+ super(message);
5
+ this.kind = kind;
6
+ this.name = 'SessionControlError';
7
+ }
8
+ }
9
+ /**
10
+ * Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
11
+ * signal evidence a pane wrapper can give us.
12
+ */
13
+ export function classifyShellStatus(status) {
14
+ if (!Number.isFinite(status))
15
+ return { version: 1, class: 'unknown', detail: 'pane wrote an unreadable exit status' };
16
+ if (status === 0)
17
+ return { version: 1, class: 'clean', code: 0, status, detail: 'exited cleanly (code 0)' };
18
+ if (status > 128) {
19
+ const signal = status - 128;
20
+ return {
21
+ version: 1, class: 'signal', signal: `SIG${signal}`, status,
22
+ detail: `killed by signal ${signal} (shell status ${status})`,
23
+ };
24
+ }
25
+ return { version: 1, class: 'program-exit', code: status, status, detail: `exited with code ${status}` };
26
+ }
27
+ /** Classify a child process exit reported directly by node. */
28
+ export function classifyChildExit(code, signal) {
29
+ if (signal)
30
+ return { version: 1, class: 'signal', signal, detail: `killed by ${signal}` };
31
+ if (code === 0)
32
+ return { version: 1, class: 'clean', code: 0, detail: 'exited cleanly (code 0)' };
33
+ if (code === null)
34
+ return { version: 1, class: 'unknown', detail: 'the process ended with neither a code nor a signal' };
35
+ return { version: 1, class: 'program-exit', code, detail: `exited with code ${code}` };
36
+ }
37
+ /** The single definition of terminal success. Nothing else may re-derive it. */
38
+ export const isTerminalSuccess = (outcome) => outcome === 'completed';
39
+ /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
40
+ export function turnResult(accepted, outcome, detail) {
41
+ return { accepted, outcome, succeeded: isTerminalSuccess(outcome), detail };
42
+ }