@ours.network/fleet 0.9.5 → 0.9.8

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 (53) hide show
  1. package/README.md +101 -0
  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 +95 -21
  7. package/dist/config.d.ts +15 -1
  8. package/dist/config.js +47 -2
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +28 -1
  12. package/dist/docs.js +132 -0
  13. package/dist/doctor.js +74 -16
  14. package/dist/harness/claude-code.d.ts +39 -3
  15. package/dist/harness/claude-code.js +126 -24
  16. package/dist/harness/codex.d.ts +7 -1
  17. package/dist/harness/codex.js +57 -10
  18. package/dist/harness/registry.d.ts +2 -0
  19. package/dist/harness/registry.js +19 -0
  20. package/dist/harness/types.d.ts +50 -3
  21. package/dist/isolation/bubblewrap.js +7 -1
  22. package/dist/isolation/policy.d.ts +34 -5
  23. package/dist/isolation/policy.js +114 -7
  24. package/dist/isolation/resources.d.ts +6 -3
  25. package/dist/isolation/resources.js +6 -3
  26. package/dist/isolation/types.d.ts +19 -1
  27. package/dist/monitor.d.ts +30 -3
  28. package/dist/monitor.js +145 -30
  29. package/dist/ops.d.ts +15 -2
  30. package/dist/ops.js +32 -9
  31. package/dist/permissions.d.ts +70 -0
  32. package/dist/permissions.js +97 -0
  33. package/dist/runner.d.ts +65 -2
  34. package/dist/runner.js +239 -19
  35. package/dist/session/acp.d.ts +22 -1
  36. package/dist/session/acp.js +110 -26
  37. package/dist/session/control.d.ts +49 -1
  38. package/dist/session/control.js +116 -12
  39. package/dist/session/tmux.d.ts +8 -1
  40. package/dist/session/tmux.js +34 -4
  41. package/dist/session/types.d.ts +92 -1
  42. package/dist/session/types.js +42 -1
  43. package/dist/spawn.d.ts +27 -2
  44. package/dist/spawn.js +153 -15
  45. package/dist/supervisor/launchd.d.ts +50 -0
  46. package/dist/supervisor/launchd.js +121 -4
  47. package/dist/supervisor/none.js +22 -4
  48. package/dist/supervisor/systemd.d.ts +8 -1
  49. package/dist/supervisor/systemd.js +94 -4
  50. package/dist/supervisor/types.d.ts +36 -3
  51. package/dist/tmux.d.ts +34 -2
  52. package/dist/tmux.js +48 -11
  53. package/package.json +1 -1
@@ -5,6 +5,19 @@ import { isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { Readable, Writable } from 'node:stream';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
7
  import { SessionEvents } from './events.js';
8
+ import { SessionControlError, classifyChildExit, turnResult } from './types.js';
9
+ /**
10
+ * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
11
+ * cancellation are the two ways a delivered prompt ends without being carried
12
+ * out; every other stop reason ran the turn to an end the agent chose.
13
+ */
14
+ export function classifyStopReason(stopReason) {
15
+ switch (stopReason) {
16
+ case 'refusal': return 'refused';
17
+ case 'cancelled': return 'cancelled';
18
+ default: return 'completed';
19
+ }
20
+ }
8
21
  /**
9
22
  * Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
10
23
  * human/automation attachment happens through the fleet role-control protocol.
@@ -22,6 +35,8 @@ export class AcpSession {
22
35
  readiness = 'starting';
23
36
  lastError;
24
37
  promptTail = Promise.resolve();
38
+ queueDepth = 0;
39
+ exit = null;
25
40
  capabilities;
26
41
  controllerCount = 0;
27
42
  constructor(options, child, connection) {
@@ -33,9 +48,12 @@ export class AcpSession {
33
48
  this.sessionFile = join(options.stateDir, '.acp-session-id');
34
49
  child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
35
50
  child.once('exit', (code, signal) => {
51
+ // Record the child's real exit code/signal. The tmux path can only see a
52
+ // shell's `$?`; here the truth is available, so keep it.
53
+ this.exit = classifyChildExit(code, signal);
36
54
  if (this.readiness !== 'failed') {
37
55
  this.readiness = 'failed';
38
- this.lastError = `ACP agent exited (${code ?? signal ?? 'unknown'})`;
56
+ this.lastError = `ACP agent ${this.exit.detail}`;
39
57
  }
40
58
  this.events.emit('state', { status: 'failed', text: this.lastError });
41
59
  });
@@ -88,10 +106,33 @@ export class AcpSession {
88
106
  pendingPermissionId: this.pendingPermissions.keys().next().value,
89
107
  };
90
108
  }
91
- submitPrompt(text) {
92
- const operation = this.promptTail.then(() => this.runPrompt(text));
93
- this.promptTail = operation.then(() => undefined, () => undefined);
94
- return operation;
109
+ /**
110
+ * Accept responsibility for a prompt, then return. The turn itself may run
111
+ * for minutes behind other queued turns; making an interactive caller wait
112
+ * for it is what turned a busy agent into a timeout and then into "dead".
113
+ */
114
+ async queuePrompt(text) {
115
+ if (!this.sessionId || !this.isAlive())
116
+ throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
117
+ const promptId = randomUUID();
118
+ const queuedBehind = this.queueDepth++;
119
+ const run = this.promptTail.then(() => this.runPrompt(text, promptId));
120
+ this.promptTail = run.then(() => undefined, () => undefined);
121
+ const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
122
+ this.queueDepth = Math.max(0, this.queueDepth - 1);
123
+ return turnResult(false, 'failed', error?.message ?? String(error));
124
+ });
125
+ return { promptId, queuedBehind, completion };
126
+ }
127
+ async submitPrompt(text) {
128
+ try {
129
+ return await (await this.queuePrompt(text)).completion;
130
+ }
131
+ catch (error) {
132
+ if (error instanceof SessionControlError)
133
+ return turnResult(false, 'failed', error.message);
134
+ throw error;
135
+ }
95
136
  }
96
137
  async interrupt() {
97
138
  if (!this.sessionId)
@@ -100,10 +141,19 @@ export class AcpSession {
100
141
  }
101
142
  respondPermission(permissionId, optionId) {
102
143
  const pending = this.pendingPermissions.get(permissionId);
103
- if (!pending || !pending.options.some(option => option.optionId === optionId))
144
+ const chosen = pending?.options.find(option => option.optionId === optionId);
145
+ if (!pending || !chosen)
104
146
  return false;
105
147
  this.pendingPermissions.delete(permissionId);
106
148
  pending.resolve({ outcome: { outcome: 'selected', optionId } });
149
+ this.events.emit('permission', {
150
+ permissionId,
151
+ status: 'completed',
152
+ decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
153
+ decisionSource: 'manual',
154
+ reason: `answered from an attached controller (${chosen.kind})`,
155
+ optionId,
156
+ });
107
157
  this.readiness = 'running';
108
158
  return true;
109
159
  }
@@ -116,6 +166,9 @@ export class AcpSession {
116
166
  setControllerAttached(attached) {
117
167
  this.controllerCount = Math.max(0, this.controllerCount + (attached ? 1 : -1));
118
168
  }
169
+ exitResult() {
170
+ return this.exit;
171
+ }
119
172
  async close() {
120
173
  for (const pending of this.pendingPermissions.values())
121
174
  pending.resolve({ outcome: { outcome: 'cancelled' } });
@@ -166,11 +219,10 @@ export class AcpSession {
166
219
  this.readiness = 'idle';
167
220
  this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
168
221
  }
169
- async runPrompt(text) {
222
+ async runPrompt(text, turnId = randomUUID()) {
170
223
  if (!this.sessionId || !this.isAlive())
171
- return { accepted: false, outcome: 'failed', detail: this.lastError ?? 'ACP session is offline' };
224
+ return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
172
225
  this.readiness = 'running';
173
- const turnId = randomUUID();
174
226
  this.events.emit('state', { turnId, status: 'running' });
175
227
  try {
176
228
  const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
@@ -180,12 +232,9 @@ export class AcpSession {
180
232
  this.readiness = 'idle';
181
233
  this.events.emit('turn_stop', { turnId, stopReason: response.stopReason });
182
234
  this.events.emit('state', { status: 'idle' });
183
- const outcome = response.stopReason === 'cancelled'
184
- ? 'cancelled'
185
- : response.stopReason === 'refusal'
186
- ? 'refused'
187
- : 'completed';
188
- return { accepted: true, outcome, detail: response.stopReason };
235
+ // The prompt was accepted either way — the agent answered. Whether the
236
+ // turn SUCCEEDED is a separate question, and only `stopReason` answers it.
237
+ return turnResult(true, classifyStopReason(response.stopReason), response.stopReason);
189
238
  }
190
239
  catch (error) {
191
240
  this.lastError = error?.message ?? String(error);
@@ -193,23 +242,35 @@ export class AcpSession {
193
242
  this.events.emit('error', { turnId, text: this.lastError });
194
243
  if (this.isAlive())
195
244
  this.events.emit('state', { status: 'idle' });
196
- return { accepted: false, outcome: 'failed', detail: this.lastError };
245
+ return turnResult(false, 'failed', this.lastError);
197
246
  }
198
247
  }
199
248
  requestPermission(params) {
200
- const choose = (kinds) => params.options.find(option => kinds.includes(option.kind));
249
+ // `kinds` is a PRIORITY order. Scanning the agent's option array instead
250
+ // (`options.find(o => kinds.includes(o.kind))`) hands the choice to whatever
251
+ // order the agent happened to list, which is exactly how an automatic denial
252
+ // could land on `reject_always`.
253
+ const choose = (kinds) => {
254
+ for (const kind of kinds) {
255
+ const option = params.options.find(o => o.kind === kind);
256
+ if (option)
257
+ return option;
258
+ }
259
+ return undefined;
260
+ };
201
261
  if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
202
262
  const option = choose(['allow_always', 'allow_once']);
203
- return Promise.resolve(option
204
- ? { outcome: { outcome: 'selected', optionId: option.optionId } }
205
- : { outcome: { outcome: 'cancelled' } });
263
+ return Promise.resolve(this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`));
206
264
  }
207
- if (this.options.permissions.approval === 'deny'
208
- || (this.controllerCount === 0 && this.options.permissions.unattended === 'deny')) {
209
- const option = choose(['reject_always', 'reject_once']);
210
- return Promise.resolve(option
211
- ? { outcome: { outcome: 'selected', optionId: option.optionId } }
212
- : { outcome: { outcome: 'cancelled' } });
265
+ const unattended = this.controllerCount === 0 && this.options.permissions.unattended === 'deny';
266
+ if (this.options.permissions.approval === 'deny' || unattended) {
267
+ // reject_once FIRST: `reject_always` teaches the agent a standing rule from
268
+ // a decision no human made, so one unattended denial would silently disable
269
+ // the tool for the rest of the session.
270
+ const option = choose(['reject_once', 'reject_always']);
271
+ return Promise.resolve(this.settleAutomatically(params, option, 'denied', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended
272
+ ? 'no controller is attached, so the request cannot be shown to anyone'
273
+ : 'the role denies every permission request by policy'));
213
274
  }
214
275
  const permissionId = randomUUID();
215
276
  this.readiness = 'awaiting_permission';
@@ -226,6 +287,29 @@ export class AcpSession {
226
287
  this.pendingPermissions.set(permissionId, { options: params.options, resolve });
227
288
  });
228
289
  }
290
+ /**
291
+ * Resolve a permission request from policy alone and leave a record of it.
292
+ * Nothing else in the system can observe an automatic decision, so an
293
+ * unrecorded one is indistinguishable from a request that was never made.
294
+ */
295
+ settleAutomatically(params, option, decision, policy, reason) {
296
+ const settled = option ? decision : 'cancelled';
297
+ this.events.emit('permission', {
298
+ permissionId: randomUUID(),
299
+ toolCallId: params.toolCall.toolCallId,
300
+ title: params.toolCall.title ?? 'Permission requested',
301
+ status: 'completed',
302
+ decision: settled,
303
+ decisionSource: 'automatic',
304
+ policy,
305
+ reason: option ? reason : `${reason}, but the agent offered no matching option`,
306
+ optionId: option?.optionId,
307
+ options: params.options.map(o => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
308
+ });
309
+ return option
310
+ ? { outcome: { outcome: 'selected', optionId: option.optionId } }
311
+ : { outcome: { outcome: 'cancelled' } };
312
+ }
229
313
  withinAutomaticBoundary(params) {
230
314
  const filesystem = this.options.permissions.filesystem;
231
315
  if (filesystem === 'unrestricted')
@@ -1,5 +1,5 @@
1
1
  import { type Socket } from 'node:net';
2
- import type { SessionHandle } from './types.js';
2
+ import type { ControlFailureKind, SessionHandle } from './types.js';
3
3
  export interface ControlRequest {
4
4
  version: 1;
5
5
  id: string;
@@ -16,7 +16,49 @@ export interface ControlResponse {
16
16
  ok: boolean;
17
17
  result?: unknown;
18
18
  error?: string;
19
+ /** Why it failed, so the caller does not have to guess from the text. */
20
+ kind?: ControlFailureKind;
19
21
  }
22
+ /**
23
+ * One line saying what a control failure does — and does not — prove about the
24
+ * agent. Only `offline` is evidence that it is gone; every other kind used to
25
+ * be rendered as "is not running", which is how a busy agent got restarted.
26
+ */
27
+ export declare function livenessNote(kind: ControlFailureKind, name: string): string;
28
+ /**
29
+ * The result taxonomy an overseer judges a role by (7.2).
30
+ *
31
+ * One console command is not a liveness verdict. `peek` and `send` can fail for
32
+ * five distinct reasons and succeed for one, and only ONE of the six says the
33
+ * agent is gone — collapsing them into "not running" is how busy agents got
34
+ * restarted. This is the single definition of that vocabulary: the generated
35
+ * briefing renders it, and the shipped oversee-agents skills quote it. The
36
+ * per-result wording comes from `livenessNote` rather than being restated, so
37
+ * the words an overseer reads in its instructions are the words the CLI prints.
38
+ */
39
+ export interface OversightResult {
40
+ /** What the command reported: the one success, or the failure kind. */
41
+ result: 'queued' | ControlFailureKind;
42
+ /** What it proves about the agent. */
43
+ meaning: string;
44
+ /** What the overseer does next. */
45
+ action: string;
46
+ /**
47
+ * Whether this result ALONE justifies restarting the role. True for exactly
48
+ * one result. Every other one requires corroboration before touching a role
49
+ * that may simply be working.
50
+ */
51
+ restartJustified: boolean;
52
+ }
53
+ /** The taxonomy, in the order generated guidance presents it. */
54
+ export declare function oversightTaxonomy(name?: string): OversightResult[];
55
+ /**
56
+ * The taxonomy as guidance lines, for a briefing or any generated document.
57
+ * The shipped oversee-agents skills carry these same lines, and a test holds
58
+ * them to it — so an overseer reading its briefing and an overseer reading the
59
+ * skill cannot be given different rules.
60
+ */
61
+ export declare function oversightTaxonomyLines(name?: string): string[];
20
62
  export declare const controlSocketPath: (stateDir: string) => string;
21
63
  export declare const controlTokenPath: (stateDir: string) => string;
22
64
  /** Private, versioned JSONL control plane for CLI and future console frontends. */
@@ -34,6 +76,12 @@ export declare class RoleControlServer {
34
76
  private handle;
35
77
  private write;
36
78
  }
79
+ /**
80
+ * Send one control request. Every failure mode is classified: a missing token
81
+ * or socket is `control-unavailable`, a silent server is `timeout`, and a
82
+ * response that is not parseable JSON is `backend`. The caller never has to
83
+ * infer liveness from an exception message.
84
+ */
37
85
  export declare function controlRequest(stateDir: string, request: Omit<ControlRequest, 'version' | 'id' | 'token'>, timeoutMs?: number): Promise<ControlResponse>;
38
86
  export declare function followControl(stateDir: string, onMessage: (message: Record<string, unknown>) => void): Promise<{
39
87
  socket: Socket;
@@ -2,7 +2,84 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { createConnection, createServer } from 'node:net';
4
4
  import { join } from 'node:path';
5
+ import { SessionControlError } from './types.js';
5
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
+ }
6
83
  export const controlSocketPath = (stateDir) => join(stateDir, '.control.sock');
7
84
  export const controlTokenPath = (stateDir) => join(stateDir, '.control-token');
8
85
  function sameToken(actual, supplied) {
@@ -111,19 +188,28 @@ export class RoleControlServer {
111
188
  return;
112
189
  case 'submit_prompt': {
113
190
  if (!request.text?.trim())
114
- throw new Error('text is required');
115
- const result = await this.session.submitPrompt(request.text);
116
- this.write(socket, { version: 1, id: request.id, ok: result.accepted, result,
117
- error: result.accepted ? undefined : result.detail });
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
+ });
118
202
  return;
119
203
  }
120
204
  case 'respond_permission': {
121
205
  if (!request.permissionId || !request.optionId)
122
- throw new Error('permissionId and optionId are required');
206
+ throw new SessionControlError('rejected', 'permissionId and optionId are required');
123
207
  const accepted = this.session.respondPermission(request.permissionId, request.optionId);
124
208
  this.write(socket, {
125
209
  version: 1, id: request.id, ok: accepted,
126
- result: { accepted }, error: accepted ? undefined : 'stale or invalid permission response',
210
+ result: { accepted },
211
+ error: accepted ? undefined : 'stale or invalid permission response',
212
+ kind: accepted ? undefined : 'rejected',
127
213
  });
128
214
  return;
129
215
  }
@@ -146,11 +232,14 @@ export class RoleControlServer {
146
232
  }
147
233
  }
148
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.
149
237
  this.write(socket, {
150
238
  version: 1,
151
239
  id: request.id,
152
240
  ok: false,
153
241
  error: error?.message ?? String(error),
242
+ kind: error instanceof SessionControlError ? error.kind : 'backend',
154
243
  });
155
244
  }
156
245
  }
@@ -159,18 +248,34 @@ export class RoleControlServer {
159
248
  socket.write(JSON.stringify(response) + '\n');
160
249
  }
161
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
+ */
162
257
  export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
163
- const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
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
+ }
164
265
  const id = randomUUID();
165
266
  const socket = createConnection(controlSocketPath(stateDir));
166
267
  socket.setEncoding('utf8');
167
- const response = await new Promise((resolve, reject) => {
268
+ return new Promise((resolve, reject) => {
168
269
  const timer = setTimeout(() => {
169
270
  socket.destroy();
170
- reject(new Error('role control request timed out'));
271
+ reject(new SessionControlError('timeout', `the role control plane did not answer '${request.command}' within ${timeoutMs}ms`));
171
272
  }, timeoutMs);
172
273
  let buffer = '';
173
- socket.once('error', error => { clearTimeout(timer); reject(error); });
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
+ });
174
279
  socket.on('data', chunk => {
175
280
  buffer += chunk;
176
281
  const newline = buffer.indexOf('\n');
@@ -181,7 +286,7 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
181
286
  resolve(JSON.parse(buffer.slice(0, newline)));
182
287
  }
183
288
  catch (error) {
184
- reject(error);
289
+ reject(new SessionControlError('backend', `malformed control response: ${error?.message ?? String(error)}`));
185
290
  }
186
291
  socket.end();
187
292
  });
@@ -189,7 +294,6 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
189
294
  version: 1, id, token, ...request,
190
295
  }) + '\n'));
191
296
  });
192
- return response;
193
297
  }
194
298
  export async function followControl(stateDir, onMessage) {
195
299
  const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
@@ -1,5 +1,5 @@
1
1
  import type { Tmux } from '../tmux.js';
2
- import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
2
+ import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
3
3
  /** SessionHandle adapter for the existing tmux transport. */
4
4
  export declare class TmuxSession implements SessionHandle {
5
5
  private readonly name;
@@ -10,11 +10,18 @@ export declare class TmuxSession implements SessionHandle {
10
10
  constructor(name: string, pid: number, tmux: Tmux, processAlive: (pid: number) => boolean);
11
11
  isAlive(): boolean;
12
12
  snapshot(): SessionSnapshot;
13
+ queuePrompt(text: string): Promise<QueuedPrompt>;
13
14
  submitPrompt(text: string): Promise<TurnResult>;
14
15
  interrupt(): Promise<void>;
15
16
  respondPermission(): boolean;
16
17
  eventsSince(): SessionEvent[];
17
18
  subscribe(): () => void;
18
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;
19
26
  close(): Promise<void>;
20
27
  }
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { SessionControlError, turnResult } from './types.js';
1
3
  /** SessionHandle adapter for the existing tmux transport. */
2
4
  export class TmuxSession {
3
5
  name;
@@ -21,11 +23,31 @@ export class TmuxSession {
21
23
  readiness: this.isAlive() ? 'idle' : 'failed',
22
24
  };
23
25
  }
24
- async submitPrompt(text) {
26
+ async queuePrompt(text) {
25
27
  if (!this.isAlive())
26
- return { accepted: false, outcome: 'failed', detail: 'tmux pane is offline' };
27
- await this.tmux.sendText(this.name, text);
28
- return { accepted: true, outcome: 'inconclusive' };
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
+ }
29
51
  }
30
52
  async interrupt() {
31
53
  await this.tmux.sendKey(this.name, 'C-c');
@@ -40,6 +62,14 @@ export class TmuxSession {
40
62
  return () => { };
41
63
  }
42
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
+ }
43
73
  async close() {
44
74
  await this.tmux.kill(this.name);
45
75
  }