@ours.network/fleet 0.9.5 → 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 (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 +63 -25
  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
package/dist/runner.js CHANGED
@@ -2,8 +2,8 @@ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'yaml';
5
- import { agentDir, home, stateRoot } from './paths.js';
6
- import { loadConfig, findRole, resolvePermissions } from './config.js';
5
+ import { agentDir, stateRoot } from './paths.js';
6
+ import { loadConfig, findRole, isolationContextFor, resolvePermissions, } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
8
  import { Tmux } from './tmux.js';
9
9
  import { createMonitor } from './monitor.js';
@@ -14,6 +14,7 @@ import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
14
  import { AcpSession } from './session/acp.js';
15
15
  import { RoleControlServer } from './session/control.js';
16
16
  import { TmuxSession } from './session/tmux.js';
17
+ import { classifyShellStatus } from './session/types.js';
17
18
  const defaultDeps = () => ({
18
19
  tmux: new Tmux(),
19
20
  exec: realExec,
@@ -42,7 +43,12 @@ export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = lau
42
43
  const env = { PATH: process.env.PATH ?? '', ...launch.env, ...(roleEnv ?? {}) };
43
44
  const envPfx = 'env ' + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
44
45
  const cmd = paneArgv.map(shq).join(' ');
45
- return `${envPfx} ${cmd}; echo $? > ${shq(exitStatusPath)}`;
46
+ // Write a structured record, not a bare number: the wait status alone cannot
47
+ // say whether the file is missing because the program never exited or because
48
+ // nothing ever wrote it. `printf` is POSIX; no shell branching is needed
49
+ // because classification happens in one place, in TypeScript.
50
+ const record = `'{"version":1,"backend":"tmux","status":'"$__ofs"'}'`;
51
+ return `${envPfx} ${cmd}; __ofs=$?; printf %s ${record} > ${shq(exitStatusPath)}`;
46
52
  }
47
53
  /** Adapt runner deps and the role's daemon-profile overrides for the monitor. */
48
54
  function monitorDeps(deps, roleEnv) {
@@ -59,6 +65,89 @@ function monitorDeps(deps, roleEnv) {
59
65
  timers: { set: (fn, ms) => setTimeout(fn, ms), clear: t => clearTimeout(t) },
60
66
  };
61
67
  }
68
+ /**
69
+ * Read the pane's `.exit-status`. Three shapes are accepted: the structured
70
+ * record written above, a bare number left by a pre-upgrade pane (so an
71
+ * in-place upgrade does not misread a real exit), and anything else — which is
72
+ * `unknown`, never an invented failure. A missing file returns null so the
73
+ * caller can distinguish "no record" from "a record saying unknown".
74
+ */
75
+ export function readExitRecord(path) {
76
+ if (!existsSync(path))
77
+ return null;
78
+ const raw = readFileSync(path, 'utf8').trim();
79
+ if (!raw)
80
+ return { version: 1, class: 'unknown', detail: 'the pane left an empty exit record' };
81
+ if (/^-?\d+$/.test(raw))
82
+ return classifyShellStatus(Number(raw)); // legacy `echo $?`
83
+ try {
84
+ const parsed = JSON.parse(raw);
85
+ if (typeof parsed.status === 'number')
86
+ return classifyShellStatus(parsed.status);
87
+ }
88
+ catch { /* fall through to unknown */ }
89
+ return { version: 1, class: 'unknown', detail: `unreadable exit record: ${raw.slice(0, 120)}` };
90
+ }
91
+ // ─── Restart-loop containment (3.2) ──────────────────────────────────────────
92
+ //
93
+ // The child-session restart loop used to BE the service manager: systemd's
94
+ // `Restart=always RestartSec=2` and launchd's `KeepAlive`. Neither can count,
95
+ // so a program that dies instantly was relaunched every two seconds forever,
96
+ // and each relaunch was a fresh process with no memory of the previous one.
97
+ // The count now lives with the role, in its state directory, so it survives the
98
+ // runner being restarted and is consistent across both service managers.
99
+ export const RESTART_LEDGER_FILE = '.restart-ledger.json';
100
+ /** Consecutive immediate failures tolerated before the agent is held down. */
101
+ export const RESTART_FAIL_THRESHOLD = 5;
102
+ const RESTART_BACKOFF_BASE_MS = 2_000;
103
+ const RESTART_BACKOFF_MAX_MS = 60_000;
104
+ /** How often a held-down runner re-reads its ledger, so `up` can release it. */
105
+ const HELD_DOWN_POLL_MS = 5_000;
106
+ const emptyLedger = () => ({
107
+ version: 1,
108
+ consecutiveImmediateFailures: 0,
109
+ lastReason: '',
110
+ nextDelayMs: 0,
111
+ resumeDiscarded: false,
112
+ circuit: 'closed',
113
+ updatedAt: new Date(0).toISOString(),
114
+ });
115
+ /** Bounded exponential backoff for the nth consecutive immediate failure. */
116
+ export function backoffFor(consecutiveFailures) {
117
+ if (consecutiveFailures <= 0)
118
+ return 0;
119
+ return Math.min(RESTART_BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1), RESTART_BACKOFF_MAX_MS);
120
+ }
121
+ /** Read a role's restart ledger; a missing or corrupt one starts clean. */
122
+ export function readRestartLedger(dir) {
123
+ try {
124
+ const raw = JSON.parse(readFileSync(join(dir, RESTART_LEDGER_FILE), 'utf8'));
125
+ if (raw.version !== 1)
126
+ return emptyLedger();
127
+ return { ...emptyLedger(), ...raw, version: 1 };
128
+ }
129
+ catch {
130
+ return emptyLedger();
131
+ }
132
+ }
133
+ export function writeRestartLedger(dir, ledger) {
134
+ try {
135
+ mkdirSync(dir, { recursive: true });
136
+ writeFileSync(join(dir, RESTART_LEDGER_FILE), JSON.stringify(ledger, null, 2) + '\n');
137
+ }
138
+ catch { /* diagnostics must never take the role down */ }
139
+ }
140
+ /**
141
+ * Close the circuit and forget the failure streak. Called by an explicit
142
+ * operator `up`/`restart`, which is the only thing that may release a held-down
143
+ * role — a held-down runner polls this file, so a role can be released without
144
+ * bouncing its unit.
145
+ */
146
+ export function resetRestartLedger(dir) {
147
+ if (!existsSync(dir))
148
+ return;
149
+ writeRestartLedger(dir, { ...emptyLedger(), updatedAt: new Date().toISOString() });
150
+ }
62
151
  /** Filename spawnTemp writes into a temp agent dir to carry the fleet start-stagger. */
63
152
  export const START_STAGGER_FILE = '.start-stagger-ms';
64
153
  /** Read the start-stagger a temp agent was spawned with (0 if none / unreadable). */
@@ -151,7 +240,7 @@ function resolveConfigPath(dir, explicit) {
151
240
  return undefined;
152
241
  return readFileSync(marker, 'utf8').trim() || undefined;
153
242
  }
154
- /** One supervised session lifecycle. The supervisor re-invokes us after we return. */
243
+ /** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
155
244
  export async function runOnce(name, opts = {}, partialDeps = {}) {
156
245
  const deps = { ...defaultDeps(), ...partialDeps };
157
246
  const temp = opts.temp === true;
@@ -197,12 +286,10 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
197
286
  // env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
198
287
  let wrappedArgv = launch.argv;
199
288
  if (role.isolation) {
200
- const addDirs = role.harness === 'codex'
201
- ? (role.harness_options?.add_dirs ?? [])
202
- : [];
203
- const ctx = {
204
- stateDir: dir, runCwd, home: home(), harness: role.harness, additionalWriteDirs: addDirs,
205
- };
289
+ // The SAME context config validation and doctor judged (5.2): a policy
290
+ // checked against a different mount set than the one that launches is not a
291
+ // check at all.
292
+ const ctx = { ...isolationContextFor(role), stateDir: dir, runCwd };
206
293
  const policy = resolveIsolation(role.isolation, ctx);
207
294
  const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
208
295
  const degradedMarker = join(dir, '.isolation-degraded');
@@ -255,6 +342,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
255
342
  let acpSession;
256
343
  let control;
257
344
  if (sessionBackend === 'acp') {
345
+ const perms = role.permissions ?? resolvePermissions(undefined, undefined);
346
+ // Say once, at startup, that this role will decide permission requests by
347
+ // itself. Without it the only trace of an auto-denied tool call is a turn
348
+ // that quietly did less than it was asked to.
349
+ if (perms.unattended === 'deny')
350
+ deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
351
+ `permission requests are automatically denied once each (reject_once) and the turn continues`);
258
352
  acpSession = await AcpSession.start({
259
353
  name,
260
354
  argv: wrappedArgv,
@@ -262,7 +356,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
262
356
  env: { ...launch.env, ...(role.env ?? {}) },
263
357
  stateDir: dir,
264
358
  mode,
265
- permissions: role.permissions ?? resolvePermissions(undefined, undefined),
359
+ permissions: perms,
266
360
  log: deps.log,
267
361
  });
268
362
  pid = acpSession.pid;
@@ -270,20 +364,27 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
270
364
  control = new RoleControlServer(dir, acpSession, deps.log);
271
365
  await control.start();
272
366
  resolvedMonitorDeps.delivery = {
367
+ // A wake is only delivered when its turn TERMINATES successfully. A
368
+ // refusal or a cancellation reached the agent and was not acted on, so
369
+ // the monitor must keep its cursor and try again.
273
370
  submit: async (text) => {
274
371
  const result = await acpSession.submitPrompt(text);
275
- return { accepted: result.accepted, detail: result.detail };
372
+ return { succeeded: result.succeeded, outcome: result.outcome, detail: result.detail };
276
373
  },
277
374
  };
278
375
  const firstPrompt = mode === 'fresh'
279
376
  ? `Read and follow ${join(dir, 'briefing.md')} now.`
280
377
  : adapter.vocabulary.restartPrompt(role.identity, join(dir, 'WORKLOG.md'), role);
378
+ // Wait for the first turn's TERMINAL result. An agent that accepts the
379
+ // startup prompt and then refuses it has not started; logging the role as
380
+ // up would hide a role that never read its briefing.
281
381
  const started = await acpSession.submitPrompt(firstPrompt);
282
- if (!started.accepted) {
382
+ if (!started.succeeded) {
283
383
  monitor?.stop();
284
384
  await control.close();
285
385
  await acpSession.close();
286
- throw new Error(`[${name}] ACP session rejected startup prompt: ${started.detail ?? started.outcome}`);
386
+ throw new Error(`[${name}] ACP startup prompt ${started.outcome}` +
387
+ `${started.detail ? `: ${started.detail}` : ''}`);
287
388
  }
288
389
  }
289
390
  else {
@@ -316,18 +417,137 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
316
417
  if (acpSession)
317
418
  await acpSession.close();
318
419
  const elapsed = (deps.now() - start) / 1000;
319
- const code = existsSync(exitFile) ? readFileSync(exitFile, 'utf8').trim() : 'crash';
420
+ // Establish what actually happened before deciding anything. Absence of a
421
+ // record is `unknown` — except when the console itself is gone, which is a
422
+ // different event with a different consequence.
423
+ const exitRecord = acpSession
424
+ ? acpSession.exitResult()
425
+ ?? { version: 1, class: 'unknown', detail: 'the ACP agent stopped without reporting an exit' }
426
+ : readExitRecord(exitFile)
427
+ ?? (await deps.tmux.has(name)
428
+ ? { version: 1, class: 'unknown', detail: 'the pane process ended without writing an exit record' }
429
+ : { version: 1, class: 'session-destroyed', detail: `the tmux session '${name}' no longer exists` });
430
+ writeFileSync(exitFile, JSON.stringify({
431
+ ...exitRecord, at: new Date(deps.now()).toISOString(), elapsedSecs: Number(elapsed.toFixed(1)),
432
+ }) + '\n');
433
+ let rotated = false;
320
434
  const rotate = (why) => {
321
435
  writeFileSync(sidFile, randomUUID() + '\n');
322
436
  rmSync(bootedFile, { force: true });
437
+ rotated = true;
323
438
  deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
324
439
  };
325
- if (code === '0' && adapter.exitPolicy.cleanExitIsFresh)
440
+ if (exitRecord.class === 'clean' && adapter.exitPolicy.cleanExitIsFresh)
326
441
  rotate(`clean exit (code 0)`);
327
- else if (mode === 'resume' && elapsed < adapter.exitPolicy.fastFailSecs)
328
- rotate(`resume failed fast (${elapsed.toFixed(0)}s, code ${code})`);
442
+ else if (exitRecord.class === 'session-destroyed')
443
+ // Someone tore the console down; the agent did not fail. Rotating here
444
+ // would discard a live conversation for an operator action.
445
+ deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
446
+ else if (mode === 'resume' && elapsed < adapter.exitPolicy.fastFailSecs) {
447
+ // Self-heal a poisoned resume — but only once per failure sequence. Rotating
448
+ // on every attempt would discard the conversation again and again while the
449
+ // real cause (a broken command, a missing binary) went unaddressed.
450
+ if (opts.allowResumeRotation === false)
451
+ deps.log(`[${name}] resume failed fast again (${elapsed.toFixed(0)}s, ${exitRecord.detail}) ` +
452
+ `-> resume state was already discarded once; keeping it`);
453
+ else
454
+ rotate(`resume failed fast (${elapsed.toFixed(0)}s, ${exitRecord.detail})`);
455
+ }
329
456
  else
330
- deps.log(`[${name}] exited (code ${code}, ${elapsed.toFixed(0)}s) -> next start RESUMES context`);
457
+ deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
458
+ return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode };
459
+ }
460
+ /**
461
+ * The persistent supervisor for one permanent role: run child sessions in a
462
+ * loop, count consecutive immediate failures across them, back off between
463
+ * attempts, and after `RESTART_FAIL_THRESHOLD` hold the agent down while
464
+ * staying alive — so the service manager has nothing to restart and cannot
465
+ * resume the two-second loop behind our back.
466
+ *
467
+ * `attempt` is injectable so the policy can be tested against a fake clock and
468
+ * fake child instead of real sessions.
469
+ */
470
+ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt = runOnce) {
471
+ const deps = { ...defaultDeps(), ...partialDeps };
472
+ const dir = agentDir(name);
473
+ mkdirSync(dir, { recursive: true });
474
+ const shouldStop = deps.shouldStop ?? (() => false);
475
+ const stamp = () => new Date(deps.now()).toISOString();
476
+ while (!shouldStop()) {
477
+ let ledger = readRestartLedger(dir);
478
+ if (ledger.circuit === 'open') {
479
+ // Held down. Stay alive — exiting would hand the role straight back to
480
+ // the service manager — and watch for an operator reset.
481
+ await deps.sleep(HELD_DOWN_POLL_MS);
482
+ continue;
483
+ }
484
+ let result;
485
+ try {
486
+ result = await attempt(name, { configPath: opts.configPath, allowResumeRotation: !ledger.resumeDiscarded }, deps);
487
+ }
488
+ catch (e) {
489
+ // A session that could not even start is an immediate failure like any
490
+ // other; it must count, or an unstartable role loops forever.
491
+ result = {
492
+ elapsedSecs: 0,
493
+ exit: { version: 1, class: 'unknown', detail: e instanceof Error ? e.message : String(e) },
494
+ rotated: false,
495
+ mode: 'fresh',
496
+ };
497
+ }
498
+ // Re-read: the attempt itself may have taken minutes, and an operator may
499
+ // have reset the ledger meanwhile.
500
+ ledger = readRestartLedger(dir);
501
+ const fastFailSecs = fastFailSecsFor(name, opts.configPath);
502
+ const immediate = result.elapsedSecs < fastFailSecs;
503
+ if (!immediate) {
504
+ // A session that ran for a while is not a restart loop, whatever ended it.
505
+ writeRestartLedger(dir, {
506
+ ...emptyLedger(),
507
+ lastReason: result.exit.detail,
508
+ updatedAt: stamp(),
509
+ });
510
+ continue;
511
+ }
512
+ const failures = ledger.consecutiveImmediateFailures + 1;
513
+ const reason = `${result.exit.detail} after ${result.elapsedSecs.toFixed(1)}s`;
514
+ const next = {
515
+ version: 1,
516
+ consecutiveImmediateFailures: failures,
517
+ lastReason: reason,
518
+ nextDelayMs: backoffFor(failures),
519
+ resumeDiscarded: ledger.resumeDiscarded || result.rotated,
520
+ circuit: failures >= RESTART_FAIL_THRESHOLD ? 'open' : 'closed',
521
+ updatedAt: stamp(),
522
+ };
523
+ if (next.circuit === 'open') {
524
+ next.openedAt = stamp();
525
+ next.nextDelayMs = 0;
526
+ writeRestartLedger(dir, next);
527
+ deps.log(`[${name}] HELD DOWN after ${failures} immediate failures at ${next.openedAt} — ` +
528
+ `${reason}; the agent will not be restarted until: ours-fleet restart ${name}`);
529
+ continue;
530
+ }
531
+ writeRestartLedger(dir, next);
532
+ deps.log(`[${name}] immediate failure ${failures}/${RESTART_FAIL_THRESHOLD} (${reason}) ` +
533
+ `-> backing off ${next.nextDelayMs}ms`);
534
+ await deps.sleep(next.nextDelayMs);
535
+ }
536
+ return readRestartLedger(dir);
537
+ }
538
+ /**
539
+ * How short an attempt has to be to count as immediate. The role's harness
540
+ * decides; an unreadable config falls back to the common 20s so a broken config
541
+ * cannot disable the breaker.
542
+ */
543
+ function fastFailSecsFor(name, configPath) {
544
+ try {
545
+ const role = findRole(loadConfig(configPath), name);
546
+ return getAdapter(role.harness).exitPolicy.fastFailSecs;
547
+ }
548
+ catch {
549
+ return 20;
550
+ }
331
551
  }
332
552
  /** Temp-agent entrypoint: run one session, then remove the temp dir. */
333
553
  export async function runTemp(name, deps = {}) {
@@ -1,5 +1,5 @@
1
1
  import type { CommonPermissions } from '../config.js';
2
- import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
2
+ import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, TurnOutcome, TurnResult } from './types.js';
3
3
  export interface AcpSessionOptions {
4
4
  name: string;
5
5
  argv: string[];
@@ -10,6 +10,12 @@ export interface AcpSessionOptions {
10
10
  permissions: CommonPermissions;
11
11
  log(line: string): void;
12
12
  }
13
+ /**
14
+ * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
15
+ * cancellation are the two ways a delivered prompt ends without being carried
16
+ * out; every other stop reason ran the turn to an end the agent chose.
17
+ */
18
+ export declare function classifyStopReason(stopReason: string | undefined): TurnOutcome;
13
19
  /**
14
20
  * Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
15
21
  * human/automation attachment happens through the fleet role-control protocol.
@@ -27,22 +33,37 @@ export declare class AcpSession implements SessionHandle {
27
33
  private readiness;
28
34
  private lastError?;
29
35
  private promptTail;
36
+ private queueDepth;
37
+ private exit;
30
38
  private capabilities?;
31
39
  private controllerCount;
32
40
  private constructor();
33
41
  static start(options: AcpSessionOptions): Promise<AcpSession>;
34
42
  isAlive(): boolean;
35
43
  snapshot(): SessionSnapshot;
44
+ /**
45
+ * Accept responsibility for a prompt, then return. The turn itself may run
46
+ * for minutes behind other queued turns; making an interactive caller wait
47
+ * for it is what turned a busy agent into a timeout and then into "dead".
48
+ */
49
+ queuePrompt(text: string): Promise<QueuedPrompt>;
36
50
  submitPrompt(text: string): Promise<TurnResult>;
37
51
  interrupt(): Promise<void>;
38
52
  respondPermission(permissionId: string, optionId: string): boolean;
39
53
  eventsSince(seq: number): SessionEvent[];
40
54
  subscribe(listener: (event: SessionEvent) => void): () => void;
41
55
  setControllerAttached(attached: boolean): void;
56
+ exitResult(): ExitRecord | null;
42
57
  close(): Promise<void>;
43
58
  private initialize;
44
59
  private runPrompt;
45
60
  private requestPermission;
61
+ /**
62
+ * Resolve a permission request from policy alone and leave a record of it.
63
+ * Nothing else in the system can observe an automatic decision, so an
64
+ * unrecorded one is indistinguishable from a request that was never made.
65
+ */
66
+ private settleAutomatically;
46
67
  private withinAutomaticBoundary;
47
68
  private recordUpdate;
48
69
  private fail;
@@ -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')