@ours.network/fleet 0.17.1 → 0.17.2

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 (61) hide show
  1. package/README.md +38 -2
  2. package/dist/application/role-removal-service.js +1 -1
  3. package/dist/application/session-control.d.ts +14 -10
  4. package/dist/application/session-control.js +14 -3
  5. package/dist/atomic-file.d.ts +7 -1
  6. package/dist/atomic-file.js +33 -5
  7. package/dist/build-info.json +10 -0
  8. package/dist/capabilities.d.ts +20 -0
  9. package/dist/capabilities.js +21 -0
  10. package/dist/cli.js +98 -10
  11. package/dist/config.d.ts +9 -2
  12. package/dist/config.js +16 -2
  13. package/dist/creation.d.ts +16 -0
  14. package/dist/creation.js +28 -0
  15. package/dist/docs.d.ts +1 -1
  16. package/dist/docs.js +70 -4
  17. package/dist/doctor.d.ts +5 -0
  18. package/dist/doctor.js +87 -2
  19. package/dist/harness/acp-agent.d.ts +3 -0
  20. package/dist/harness/acp-agent.js +4 -1
  21. package/dist/harness/codex-app-server-proxy.d.ts +4 -0
  22. package/dist/harness/codex-app-server-proxy.js +133 -0
  23. package/dist/harness/codex.js +79 -11
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +1 -0
  26. package/dist/loops/manager.d.ts +42 -1
  27. package/dist/loops/manager.js +115 -16
  28. package/dist/loops/state.d.ts +46 -2
  29. package/dist/loops/state.js +81 -3
  30. package/dist/monitor.d.ts +21 -0
  31. package/dist/monitor.js +42 -0
  32. package/dist/ops.d.ts +6 -0
  33. package/dist/ops.js +46 -1
  34. package/dist/owner-channel/channel.d.ts +18 -2
  35. package/dist/owner-channel/channel.js +146 -2
  36. package/dist/owner-channel/commands.d.ts +2 -2
  37. package/dist/owner-channel/commands.js +7 -2
  38. package/dist/owner-channel/notices.d.ts +2 -0
  39. package/dist/owner-channel/notices.js +3 -0
  40. package/dist/provenance.d.ts +77 -0
  41. package/dist/provenance.js +283 -0
  42. package/dist/runner.d.ts +7 -1
  43. package/dist/runner.js +100 -14
  44. package/dist/session/acp.d.ts +40 -4
  45. package/dist/session/acp.js +157 -30
  46. package/dist/session/arbiter.d.ts +28 -2
  47. package/dist/session/arbiter.js +75 -4
  48. package/dist/session/control.js +12 -6
  49. package/dist/session/event-log.d.ts +109 -0
  50. package/dist/session/event-log.js +247 -0
  51. package/dist/session/events.d.ts +21 -0
  52. package/dist/session/events.js +105 -26
  53. package/dist/session/tmux.d.ts +3 -2
  54. package/dist/session/tmux.js +2 -0
  55. package/dist/session/types.d.ts +39 -2
  56. package/dist/session/types.js +11 -1
  57. package/dist/spawn.d.ts +3 -3
  58. package/dist/spawn.js +40 -14
  59. package/dist/temp-lifecycle.d.ts +62 -0
  60. package/dist/temp-lifecycle.js +437 -0
  61. package/package.json +5 -3
@@ -0,0 +1,247 @@
1
+ import { closeSync, existsSync, fstatSync, ftruncateSync, openSync, readFileSync, readSync, writeSync, } from 'node:fs';
2
+ export const nodeLogIo = {
3
+ openSync: (path, flags, mode) => openSync(path, flags, mode),
4
+ fstatSync: fd => fstatSync(fd),
5
+ readSync: (fd, buffer, offset, length, position) => readSync(fd, buffer, offset, length, position),
6
+ writeSync: (fd, buffer, offset, length) => writeSync(fd, buffer, offset, length),
7
+ ftruncateSync: (fd, length) => ftruncateSync(fd, length),
8
+ closeSync: fd => closeSync(fd),
9
+ };
10
+ const NEWLINE = 0x0a;
11
+ /** Enough to hold a whole event line as evidence without letting one record dominate the sidecar. */
12
+ const MAX_RAW_EVIDENCE_BYTES = 8 * 1024;
13
+ const MAX_QUARANTINE_BYTES = 1024 * 1024;
14
+ export class AtomicAppendError extends Error {
15
+ rollbackFailed;
16
+ cause;
17
+ constructor(message, rollbackFailed, cause) {
18
+ super(message);
19
+ this.rollbackFailed = rollbackFailed;
20
+ this.cause = cause;
21
+ this.name = 'AtomicAppendError';
22
+ }
23
+ }
24
+ /**
25
+ * Append one line, all or nothing. A failed write is rolled back to the byte
26
+ * length observed before it started, so a partial record never persists; if the
27
+ * file already ends mid-record (damage from before this fix), the line is put on
28
+ * a fresh line so it cannot fuse with the damaged bytes.
29
+ */
30
+ export function appendLineAtomic(path, line, options = {}) {
31
+ const io = options.io ?? nodeLogIo;
32
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 2);
33
+ const payload = line.endsWith('\n') ? line : line + '\n';
34
+ let lastError;
35
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
36
+ let fd;
37
+ let sizeBefore = 0;
38
+ try {
39
+ fd = io.openSync(path, 'a+', options.mode ?? 0o600);
40
+ sizeBefore = io.fstatSync(fd).size;
41
+ const repairedBoundary = sizeBefore > 0 && !endsWithNewline(io, fd, sizeBefore);
42
+ const buffer = Buffer.from(repairedBoundary ? '\n' + payload : payload, 'utf8');
43
+ let written = 0;
44
+ try {
45
+ while (written < buffer.length) {
46
+ const advanced = io.writeSync(fd, buffer, written, buffer.length - written);
47
+ // A write that accepts nothing would spin forever and never reach the
48
+ // bounded-retry logic, so it fails here instead.
49
+ if (advanced <= 0)
50
+ throw new Error(`write made no progress at byte ${written} of ${buffer.length}`);
51
+ written += advanced;
52
+ }
53
+ }
54
+ catch (writeError) {
55
+ throw rollback(io, fd, sizeBefore, writeError);
56
+ }
57
+ return { repairedBoundary };
58
+ }
59
+ catch (error) {
60
+ lastError = error;
61
+ // A failed rollback leaves bytes we cannot account for; retrying would
62
+ // append on top of them, so stop and let the caller report it.
63
+ if (error instanceof AtomicAppendError && error.rollbackFailed)
64
+ break;
65
+ }
66
+ finally {
67
+ if (fd !== undefined) {
68
+ try {
69
+ io.closeSync(fd);
70
+ }
71
+ catch { /* the write outcome is what matters */ }
72
+ }
73
+ }
74
+ }
75
+ throw lastError;
76
+ }
77
+ function endsWithNewline(io, fd, size) {
78
+ const tail = Buffer.alloc(1);
79
+ const read = io.readSync(fd, tail, 0, 1, size - 1);
80
+ // An unreadable tail is treated as damaged: adding a newline is always safe.
81
+ return read === 1 && tail[0] === NEWLINE;
82
+ }
83
+ function rollback(io, fd, sizeBefore, writeError) {
84
+ const detail = writeError instanceof Error ? writeError.message : String(writeError);
85
+ try {
86
+ io.ftruncateSync(fd, sizeBefore);
87
+ return new AtomicAppendError(detail, false, writeError);
88
+ }
89
+ catch (truncateError) {
90
+ const reason = truncateError instanceof Error ? truncateError.message : String(truncateError);
91
+ return new AtomicAppendError(`${detail} (rollback failed: ${reason})`, true, writeError);
92
+ }
93
+ }
94
+ /**
95
+ * Read the log line by line. One bad line costs exactly that line: everything
96
+ * before and after it is kept, and the damage is described rather than dropped.
97
+ */
98
+ export function readLog(path) {
99
+ const empty = { records: [], sequence: [], maxSeq: 0, damaged: [], gaps: [] };
100
+ if (!existsSync(path))
101
+ return empty;
102
+ let raw;
103
+ try {
104
+ raw = readFileSync(path, 'utf8');
105
+ }
106
+ catch {
107
+ return empty;
108
+ }
109
+ if (raw === '')
110
+ return empty;
111
+ const terminated = raw.endsWith('\n');
112
+ const segments = raw.split('\n');
113
+ if (terminated)
114
+ segments.pop();
115
+ const records = [];
116
+ const sequence = [];
117
+ const damaged = [];
118
+ let maxSeq = 0;
119
+ segments.forEach((segment, index) => {
120
+ if (segment.trim() === '')
121
+ return;
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(segment);
125
+ }
126
+ catch {
127
+ const isFinalSegment = index === segments.length - 1;
128
+ damaged.push({
129
+ lineNumber: index + 1,
130
+ bytes: Buffer.byteLength(segment, 'utf8'),
131
+ // An unterminated final line is a write that never committed; anything
132
+ // else sits between records that did, so it is interior corruption.
133
+ reason: isFinalSegment && !terminated ? 'truncated_tail' : 'interior_corruption',
134
+ raw: segment.slice(0, MAX_RAW_EVIDENCE_BYTES),
135
+ truncatedEvidence: segment.length > MAX_RAW_EVIDENCE_BYTES,
136
+ });
137
+ return;
138
+ }
139
+ // Unknown versions are forward compatibility, not damage: they are not
140
+ // replayed here, but they did claim their sequence number, so they still
141
+ // count towards ordering and continuation.
142
+ const seq = sequenceOf(parsed);
143
+ if (seq === undefined)
144
+ return;
145
+ sequence.push(seq);
146
+ if (seq > maxSeq)
147
+ maxSeq = seq;
148
+ if (isLogRecord(parsed))
149
+ records.push(parsed);
150
+ });
151
+ return { records, sequence, maxSeq, damaged, gaps: findSequenceGaps(sequence) };
152
+ }
153
+ /** The sequence a readable entry claims, whatever version wrote it. */
154
+ function sequenceOf(value) {
155
+ if (typeof value !== 'object' || value === null)
156
+ return undefined;
157
+ const seq = value.seq;
158
+ return typeof seq === 'number' && Number.isFinite(seq) ? seq : undefined;
159
+ }
160
+ function isLogRecord(value) {
161
+ if (typeof value !== 'object' || value === null)
162
+ return false;
163
+ const candidate = value;
164
+ return candidate.version === 1 && typeof candidate.seq === 'number';
165
+ }
166
+ /**
167
+ * Missing sequence numbers between consecutive records. Records that parse
168
+ * perfectly still leave a hole when the writes between them never landed, so
169
+ * this is computed over whatever ordered run the caller cares about — including
170
+ * a rotated stream followed by its live successor, where the hole falls on the
171
+ * boundary and neither file can see it alone.
172
+ */
173
+ export function findSequenceGaps(sequence) {
174
+ const gaps = [];
175
+ for (let i = 1; i < sequence.length; i++) {
176
+ const afterSeq = sequence[i - 1];
177
+ const beforeSeq = sequence[i];
178
+ if (beforeSeq > afterSeq + 1)
179
+ gaps.push({ afterSeq, beforeSeq, missing: beforeSeq - afterSeq - 1 });
180
+ }
181
+ return gaps;
182
+ }
183
+ /**
184
+ * Copy damaged bytes to a sidecar for later forensics. The damaged file itself
185
+ * is never read-modify-written, and a sidecar that cannot be created is reported
186
+ * rather than retried into the event writer.
187
+ */
188
+ export function quarantineDamage(path, damaged, source = path) {
189
+ const sidecar = path + '.corrupt';
190
+ if (damaged.length === 0)
191
+ return { quarantined: false, sidecar };
192
+ let alreadyHeld;
193
+ try {
194
+ alreadyHeld = existingEvidence(sidecar);
195
+ }
196
+ catch (error) {
197
+ return { quarantined: false, error: describe(error), sidecar };
198
+ }
199
+ const pending = damaged.filter(line => !alreadyHeld.has(evidenceKey(source, line.raw)));
200
+ if (pending.length === 0)
201
+ return { quarantined: true, sidecar };
202
+ for (const line of pending) {
203
+ try {
204
+ appendLineAtomic(sidecar, JSON.stringify({
205
+ quarantinedAt: new Date().toISOString(),
206
+ source,
207
+ lineNumber: line.lineNumber,
208
+ bytes: line.bytes,
209
+ reason: line.reason,
210
+ truncatedEvidence: line.truncatedEvidence,
211
+ raw: line.raw,
212
+ }));
213
+ }
214
+ catch (error) {
215
+ return { quarantined: false, error: describe(error), sidecar };
216
+ }
217
+ }
218
+ return { quarantined: true, sidecar };
219
+ }
220
+ function existingEvidence(sidecar) {
221
+ const held = new Set();
222
+ if (!existsSync(sidecar))
223
+ return held;
224
+ const raw = readFileSync(sidecar, 'utf8');
225
+ if (raw.length > MAX_QUARANTINE_BYTES)
226
+ throw new Error(`quarantine sidecar is full (${raw.length} bytes)`);
227
+ for (const line of raw.split('\n')) {
228
+ if (line.trim() === '')
229
+ continue;
230
+ try {
231
+ const entry = JSON.parse(line);
232
+ if (typeof entry.raw === 'string' && typeof entry.source === 'string') {
233
+ held.add(evidenceKey(entry.source, entry.raw));
234
+ }
235
+ }
236
+ catch { /* an unreadable sidecar entry only costs us dedup */ }
237
+ }
238
+ return held;
239
+ }
240
+ /** Damage is identified by its bytes and which file they came from, so the live
241
+ * stream and its rotated predecessor are never confused for each other. */
242
+ function evidenceKey(source, raw) {
243
+ return `${source}${raw}`;
244
+ }
245
+ function describe(error) {
246
+ return error instanceof Error ? error.message : String(error);
247
+ }
@@ -1,14 +1,35 @@
1
+ import { type DamagedLine, type SequenceGap } from './event-log.js';
1
2
  import type { SessionEvent, SessionEventKind } from './types.js';
3
+ /** What the on-disk stream is known to have lost or mangled. Never optimistic. */
4
+ export interface StreamIntegrity {
5
+ healthy: boolean;
6
+ damaged: DamagedLine[];
7
+ gaps: SequenceGap[];
8
+ quarantined: boolean;
9
+ quarantineError?: string;
10
+ /** Appends that failed outright; their events survive in memory only. */
11
+ writeFailures: number;
12
+ lastWriteError?: string;
13
+ /** Appends that had to close off a dangling partial record first. */
14
+ boundaryRepairs: number;
15
+ rotationFailed: boolean;
16
+ }
2
17
  /** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
3
18
  export declare class SessionEvents {
4
19
  private readonly path;
5
20
  private seq;
6
21
  private readonly events;
7
22
  private readonly listeners;
23
+ private readonly integrityState;
24
+ private readonly rotatedPath;
8
25
  constructor(path: string);
9
26
  emit(kind: SessionEventKind, fields?: Omit<SessionEvent, 'version' | 'seq' | 'at' | 'kind'>): SessionEvent;
10
27
  since(seq: number): SessionEvent[];
11
28
  subscribe(listener: (event: SessionEvent) => void): () => void;
29
+ /** Snapshot of what is known to be wrong with the persisted stream. */
30
+ integrity(): StreamIntegrity;
31
+ private persist;
12
32
  private restore;
33
+ private describeLoss;
13
34
  private rotateIfNeeded;
14
35
  }
@@ -1,4 +1,6 @@
1
- import { appendFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, renameSync, statSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { appendLineAtomic, findSequenceGaps, quarantineDamage, readLog, } from './event-log.js';
2
4
  const MAX_EVENTS = 1_000;
3
5
  const MAX_EVENT_FILE_BYTES = 2 * 1024 * 1024;
4
6
  /** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
@@ -7,8 +9,14 @@ export class SessionEvents {
7
9
  seq = 0;
8
10
  events = [];
9
11
  listeners = new Set();
12
+ integrityState = {
13
+ healthy: true, damaged: [], gaps: [], quarantined: false,
14
+ writeFailures: 0, boundaryRepairs: 0, rotationFailed: false,
15
+ };
16
+ rotatedPath;
10
17
  constructor(path) {
11
18
  this.path = path;
19
+ this.rotatedPath = path + '.1';
12
20
  this.restore();
13
21
  }
14
22
  emit(kind, fields = {}) {
@@ -22,16 +30,7 @@ export class SessionEvents {
22
30
  this.events.push(event);
23
31
  if (this.events.length > MAX_EVENTS)
24
32
  this.events.shift();
25
- try {
26
- this.rotateIfNeeded();
27
- // Owner-visible commentary remains live/in-memory for the authenticated
28
- // bridge, but its plaintext must not become diagnostic state on disk.
29
- const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
30
- ? { ...event, text: '[assistant commentary redacted]' }
31
- : event;
32
- appendFileSync(this.path, JSON.stringify(persisted) + '\n', { mode: 0o600 });
33
- }
34
- catch { /* diagnostics must never terminate a role */ }
33
+ this.persist(event);
35
34
  for (const listener of this.listeners)
36
35
  listener(event);
37
36
  return event;
@@ -43,30 +42,110 @@ export class SessionEvents {
43
42
  this.listeners.add(listener);
44
43
  return () => this.listeners.delete(listener);
45
44
  }
46
- restore() {
45
+ /** Snapshot of what is known to be wrong with the persisted stream. */
46
+ integrity() {
47
+ return { ...this.integrityState, damaged: [...this.integrityState.damaged], gaps: [...this.integrityState.gaps] };
48
+ }
49
+ persist(event) {
50
+ this.rotateIfNeeded();
51
+ // Owner-visible commentary remains live/in-memory for the authenticated
52
+ // bridge, but its plaintext must not become diagnostic state on disk.
53
+ const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
54
+ ? { ...event, text: '[assistant commentary redacted]' }
55
+ : event;
47
56
  try {
48
- if (!existsSync(this.path))
49
- return;
50
- const lines = readFileSync(this.path, 'utf8').trim().split('\n').slice(-MAX_EVENTS);
51
- for (const line of lines) {
52
- const event = JSON.parse(line);
53
- if (event.version === 1 && typeof event.seq === 'number') {
54
- this.events.push(event);
55
- this.seq = Math.max(this.seq, event.seq);
56
- }
57
+ const { repairedBoundary } = appendLineAtomic(this.path, JSON.stringify(persisted));
58
+ if (repairedBoundary) {
59
+ this.integrityState.boundaryRepairs++;
60
+ this.integrityState.healthy = false;
57
61
  }
58
62
  }
59
- catch { /* begin a fresh projection if the diagnostic file is corrupt */ }
63
+ catch (error) {
64
+ // Diagnostics must never terminate a role, but a silent loss is how the
65
+ // 06:32 incident stayed invisible for an hour — record it instead.
66
+ this.integrityState.writeFailures++;
67
+ this.integrityState.lastWriteError = error instanceof Error ? error.message : String(error);
68
+ this.integrityState.healthy = false;
69
+ }
70
+ }
71
+ restore() {
72
+ // Rotation renames the live stream to `.1` and only then starts a new one.
73
+ // A crash in that window leaves no live file at all, so reading only the
74
+ // live path would restart at seq 1 and re-issue numbers `.1` already holds.
75
+ const rotated = readLog(this.rotatedPath);
76
+ const live = readLog(this.path);
77
+ const records = [...rotated.records, ...live.records];
78
+ for (const record of records.slice(-MAX_EVENTS))
79
+ this.events.push(record);
80
+ // Monotonic even across damage and rotation: a sequence number already on
81
+ // disk, readable or not, is never handed out again.
82
+ this.seq = Math.max(this.seq, rotated.maxSeq, live.maxSeq);
83
+ const damaged = [...rotated.damaged, ...live.damaged];
84
+ // Records that parse perfectly still leave a hole when the writes between
85
+ // them never landed, and a hole on the rotated/live boundary is invisible to
86
+ // either file alone — so gaps are computed over the combined ordered run,
87
+ // and reported whether or not any bytes were mangled.
88
+ const gaps = findSequenceGaps([...rotated.sequence, ...live.sequence]);
89
+ if (damaged.length === 0 && gaps.length === 0)
90
+ return;
91
+ this.integrityState.healthy = false;
92
+ this.integrityState.damaged = damaged;
93
+ this.integrityState.gaps = gaps;
94
+ let error;
95
+ if (damaged.length > 0) {
96
+ const results = [
97
+ quarantineDamage(this.path, rotated.damaged, this.rotatedPath),
98
+ quarantineDamage(this.path, live.damaged, this.path),
99
+ ].filter(result => result.quarantined || result.error);
100
+ this.integrityState.quarantined = results.every(result => result.quarantined);
101
+ error = results.find(result => result.error)?.error;
102
+ if (error)
103
+ this.integrityState.quarantineError = error;
104
+ }
105
+ // Reported through the ordinary stream so it reaches replay and audit;
106
+ // never through a second attempt at the writer that just failed.
107
+ this.emit('error', { text: this.describeLoss(damaged, gaps, error) });
108
+ }
109
+ describeLoss(damaged, gaps, error) {
110
+ const interior = damaged.filter(line => line.reason === 'interior_corruption');
111
+ const tails = damaged.filter(line => line.reason === 'truncated_tail');
112
+ const parts = ['event stream integrity'];
113
+ if (interior.length) {
114
+ parts.push(`${interior.length} interior corruption at line ${interior.map(l => l.lineNumber).join(', ')}`);
115
+ }
116
+ if (tails.length)
117
+ parts.push(`${tails.length} truncated tail discarded`);
118
+ const missing = gaps.reduce((total, gap) => total + gap.missing, 0);
119
+ if (missing) {
120
+ const span = gaps.map(gap => `${gap.afterSeq}..${gap.beforeSeq}`).join(', ');
121
+ parts.push(`${missing} record${missing === 1 ? '' : 's'} lost between seq ${span} (irrecoverable)`);
122
+ }
123
+ if (damaged.length) {
124
+ parts.push(this.integrityState.quarantined
125
+ ? `damaged bytes quarantined to ${basename(this.path)}.corrupt`
126
+ : `damaged bytes NOT quarantined${error ? ` (${error})` : ''}`);
127
+ }
128
+ return parts.join('; ');
60
129
  }
61
130
  rotateIfNeeded() {
62
- if (!existsSync(this.path) || statSync(this.path).size < MAX_EVENT_FILE_BYTES)
131
+ if (!existsSync(this.path))
132
+ return;
133
+ try {
134
+ if (statSync(this.path).size < MAX_EVENT_FILE_BYTES)
135
+ return;
136
+ }
137
+ catch {
63
138
  return;
64
- const rotated = this.path + '.1';
139
+ }
65
140
  try {
66
- renameSync(this.path, rotated);
141
+ renameSync(this.path, this.rotatedPath);
142
+ this.integrityState.rotationFailed = false;
67
143
  }
68
144
  catch {
69
- writeFileSync(this.path, '', { mode: 0o600 });
145
+ // Fail closed: the previous fallback truncated the live stream, which
146
+ // destroys exactly the evidence an incident needs.
147
+ this.integrityState.rotationFailed = true;
148
+ this.integrityState.healthy = false;
70
149
  }
71
150
  }
72
151
  }
@@ -1,5 +1,5 @@
1
1
  import type { Tmux } from '../tmux.js';
2
- import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnResult } from './types.js';
2
+ import type { ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, 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;
@@ -12,7 +12,8 @@ export declare class TmuxSession implements SessionHandle {
12
12
  snapshot(): SessionSnapshot;
13
13
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
14
14
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
15
- interrupt(): Promise<void>;
15
+ /** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
16
+ interrupt(): Promise<InterruptOutcome>;
16
17
  respondPermission(): boolean;
17
18
  eventsSince(): SessionEvent[];
18
19
  subscribe(): () => void;
@@ -51,8 +51,10 @@ export class TmuxSession {
51
51
  throw error;
52
52
  }
53
53
  }
54
+ /** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
54
55
  async interrupt() {
55
56
  await this.tmux.sendKey(this.name, 'C-c');
57
+ return { state: 'settled' };
56
58
  }
57
59
  respondPermission() {
58
60
  return false;
@@ -62,10 +62,38 @@ export interface TurnResult {
62
62
  * session is gone, and `timeout` explicitly does NOT say the prompt was lost.
63
63
  */
64
64
  export type ControlFailureKind = 'offline' | 'control-unavailable' | 'timeout' | 'rejected' | 'backend';
65
+ /** Stable body-free reason shared by ACP recovery and durable ingress. */
66
+ export declare const ACP_CANCEL_DEADLINE_EXCEEDED = "ACP_CANCEL_DEADLINE_EXCEEDED";
65
67
  export declare class SessionControlError extends Error {
66
68
  readonly kind: ControlFailureKind;
67
- constructor(kind: ControlFailureKind, message: string);
69
+ /** Stable body-free machine reason for recovery/audit decisions. */
70
+ readonly reasonCode?: string | undefined;
71
+ constructor(kind: ControlFailureKind, message: string,
72
+ /** Stable body-free machine reason for recovery/audit decisions. */
73
+ reasonCode?: string | undefined);
68
74
  }
75
+ /**
76
+ * How an explicit cancellation ended. Forced recovery is a SUCCESS: the turn is
77
+ * over and the session is being reclaimed. Reporting it as a failed interrupt is
78
+ * what made owners, the control plane and the web console retry an operation
79
+ * that had already done exactly what was asked.
80
+ */
81
+ export interface InterruptOutcome {
82
+ /** `settled` — the turn (or nothing) ended cooperatively. `forced` — the adapter ignored the cancel and was restarted. */
83
+ state: 'settled' | 'forced';
84
+ /** Stable body-free reason present only for a forced recovery. */
85
+ reasonCode?: string;
86
+ }
87
+ /**
88
+ * What a `SessionHandle.interrupt` implementation may resolve. Before 0.17.1 the
89
+ * contract was `Promise<void>`, and resolving at all meant the cancellation had
90
+ * taken effect cooperatively — so an implementation written against that
91
+ * contract stays valid and keeps its exact meaning. Only in-tree consumers read
92
+ * the richer outcome, and they normalize through `interruptOutcome` first.
93
+ */
94
+ export type InterruptResult = InterruptOutcome | void;
95
+ /** The one place the legacy `void` reply is given its meaning: `settled`. */
96
+ export declare function interruptOutcome(result: InterruptResult): InterruptOutcome;
69
97
  /**
70
98
  * A prompt the live session has taken responsibility for. Interactive callers
71
99
  * stop here: the session has the prompt, and waiting for the turn to finish is
@@ -218,7 +246,16 @@ export interface SessionHandle {
218
246
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
219
247
  /** Monitor-only ACP safe-boundary delivery. Never implies human/control cancellation. */
220
248
  submitPromptAfterTool?(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
221
- interrupt(source?: TurnCancellationSource): Promise<void>;
249
+ /**
250
+ * Cancel the active turn. Resolves when the cancellation has taken effect —
251
+ * cooperatively (`settled`) or through bounded forced recovery (`forced`).
252
+ * It rejects only when the cancellation itself could not be delivered.
253
+ *
254
+ * The return type is widened to `InterruptResult` for one reason: an
255
+ * implementation written against the pre-0.17.1 `Promise<void>` contract must
256
+ * keep compiling. Read it through `interruptOutcome`, never directly.
257
+ */
258
+ interrupt(source?: TurnCancellationSource): Promise<InterruptResult>;
222
259
  respondPermission(permissionId: string, optionId: string): boolean;
223
260
  /** Generation-bound browser decision; stale/settled/invalid all fail closed. */
224
261
  respondPermissionV2?(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
@@ -1,11 +1,21 @@
1
+ /** Stable body-free reason shared by ACP recovery and durable ingress. */
2
+ export const ACP_CANCEL_DEADLINE_EXCEEDED = 'ACP_CANCEL_DEADLINE_EXCEEDED';
1
3
  export class SessionControlError extends Error {
2
4
  kind;
3
- constructor(kind, message) {
5
+ reasonCode;
6
+ constructor(kind, message,
7
+ /** Stable body-free machine reason for recovery/audit decisions. */
8
+ reasonCode) {
4
9
  super(message);
5
10
  this.kind = kind;
11
+ this.reasonCode = reasonCode;
6
12
  this.name = 'SessionControlError';
7
13
  }
8
14
  }
15
+ /** The one place the legacy `void` reply is given its meaning: `settled`. */
16
+ export function interruptOutcome(result) {
17
+ return result ?? { state: 'settled' };
18
+ }
9
19
  /**
10
20
  * Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
11
21
  * signal evidence a pane wrapper can give us.
package/dist/spawn.d.ts CHANGED
@@ -4,6 +4,7 @@ import { type OpsDeps } from './ops.js';
4
4
  import { type CreationDeps, type CreationProvenance } from './creation.js';
5
5
  import './harness/claude-code.js';
6
6
  import './harness/codex.js';
7
+ import { type SupervisorLauncher } from './temp-lifecycle.js';
7
8
  /**
8
9
  * The provenance record written by the most recent spawn in this process, so
9
10
  * the CLI can print the same summary it persisted rather than rebuilding it.
@@ -94,7 +95,6 @@ export interface SpawnDryRun {
94
95
  export declare function spawnDryRun(o: SpawnOpts): SpawnDryRun;
95
96
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
96
97
  export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: CreationDeps): Promise<string>;
97
- /** Launches the detached temp supervisor (`_run-temp <name>`). Injectable for tests. */
98
- export type SupervisorLauncher = (binPath: string, args: string[], dir: string) => void;
99
- /** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
98
+ export type { SupervisorLauncher } from './temp-lifecycle.js';
99
+ /** Temp spawn: live state under ~/.ours-fleet/tmp, independent transient supervision. */
100
100
  export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;