@ours.network/fleet 0.9.3 → 0.10.0-nightly.1

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.
@@ -14,8 +14,16 @@ const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
14
14
  /** Resolve & validate the per-role sandbox mode, throwing on an unknown value. */
15
15
  function sandboxMode(role) {
16
16
  const s = role.harness_options?.sandbox;
17
- if (s == null)
17
+ if (s == null) {
18
+ const filesystem = role.permissions?.filesystem;
19
+ if (filesystem === 'read-only')
20
+ return 'read-only';
21
+ if (filesystem === 'unrestricted')
22
+ return 'danger-full-access';
23
+ if (filesystem === 'workspace')
24
+ return 'workspace-write';
18
25
  return undefined;
26
+ }
19
27
  if (!SANDBOX_MODES.includes(s))
20
28
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
21
29
  return s;
@@ -24,8 +32,14 @@ function sandboxMode(role) {
24
32
  function approvalPolicy(role) {
25
33
  const o = role.harness_options;
26
34
  const a = o?.approval ?? o?.permission_mode;
27
- if (a == null)
35
+ if (a == null) {
36
+ const approval = role.permissions?.approval;
37
+ if (approval === 'allow')
38
+ return 'never';
39
+ if (approval === 'ask' || approval === 'deny')
40
+ return 'on-request';
28
41
  return undefined;
42
+ }
29
43
  if (!APPROVAL_POLICIES.includes(a))
30
44
  throw new Error(`invalid harness_options.approval "${a}"; allowed: ${APPROVAL_POLICIES.join(', ')}`);
31
45
  return a;
@@ -173,6 +187,29 @@ export function makeCodexAdapter(exec = realExec) {
173
187
  this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
174
188
  return { argv, env: prep.env };
175
189
  },
190
+ buildAcpLaunch(role, prep) {
191
+ const configured = role.session_options?.acp?.command;
192
+ const argv = Array.isArray(configured)
193
+ ? [...configured]
194
+ : typeof configured === 'string'
195
+ ? ['sh', '-c', configured]
196
+ : ['codex-acp'];
197
+ return { argv, env: prep.env };
198
+ },
199
+ translatePermissions(permissions) {
200
+ return {
201
+ native: {
202
+ approval: permissions.approval === 'allow' ? 'never' : 'on-request',
203
+ sandbox: permissions.filesystem === 'read-only'
204
+ ? 'read-only'
205
+ : permissions.filesystem === 'unrestricted'
206
+ ? 'danger-full-access'
207
+ : 'workspace-write',
208
+ },
209
+ exact: true,
210
+ warnings: [],
211
+ };
212
+ },
176
213
  vocabulary: {
177
214
  bindTool: 'choose_identity',
178
215
  createTool: 'create_identity',
@@ -1,4 +1,4 @@
1
- import type { ResolvedRole } from '../config.js';
1
+ import type { CommonPermissions, ResolvedRole } from '../config.js';
2
2
  export interface PrereqCheck {
3
3
  name: string;
4
4
  ok: boolean;
@@ -26,6 +26,15 @@ export interface Launch {
26
26
  argv: string[];
27
27
  env: Record<string, string>;
28
28
  }
29
+ export interface AcpLaunch {
30
+ argv: string[];
31
+ env: Record<string, string>;
32
+ }
33
+ export interface PermissionTranslation {
34
+ native: Record<string, unknown>;
35
+ exact: boolean;
36
+ warnings: string[];
37
+ }
29
38
  /** Harness-correct wording/tool names used to generate briefing.md. */
30
39
  export interface BriefingVocab {
31
40
  bindTool: string;
@@ -57,6 +66,8 @@ export interface HarnessAdapter {
57
66
  validateOptions(opts: unknown): ValidationError[];
58
67
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
59
68
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
69
+ buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
70
+ translatePermissions?(permissions: CommonPermissions): PermissionTranslation;
60
71
  vocabulary: BriefingVocab;
61
72
  exitPolicy: ExitPolicy;
62
73
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- export { loadConfig, findRole, ConfigError } from './config.js';
2
- export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry } from './config.js';
3
- export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, RoleDirs, ValidationError, } from './harness/types.js';
1
+ export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
+ export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, SessionOptions, } from './config.js';
3
+ export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, AcpLaunch, PermissionTranslation, RoleDirs, ValidationError, } from './harness/types.js';
4
+ export type { SessionHandle, SessionSnapshot, SessionEvent, TurnResult, } from './session/types.js';
5
+ export { AcpSession } from './session/acp.js';
6
+ export { TmuxSession } from './session/tmux.js';
4
7
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
5
8
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
6
9
  export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
- export { loadConfig, findRole, ConfigError } from './config.js';
1
+ export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
+ export { AcpSession } from './session/acp.js';
3
+ export { TmuxSession } from './session/tmux.js';
2
4
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
3
5
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
4
6
  export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
package/dist/monitor.d.ts CHANGED
@@ -38,6 +38,13 @@ export interface MonitorDeps {
38
38
  set(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
39
39
  clear(t: ReturnType<typeof setTimeout>): void;
40
40
  };
41
+ /** Structured prompt delivery used by ACP sessions. Tmux remains the fallback. */
42
+ delivery?: {
43
+ submit(text: string): Promise<{
44
+ accepted: boolean;
45
+ detail?: string;
46
+ }>;
47
+ };
41
48
  }
42
49
  /** Best-effort daemon config (issue #17): the fields the MCP client reads. */
43
50
  interface DaemonConfig {
@@ -80,9 +87,19 @@ export declare function filterEvents(events: NotifyEvent[], wakeSources: string[
80
87
  export declare function formatNotificationLine(events: NotifyEvent[]): string;
81
88
  /**
82
89
  * Heuristic: does the pane show a modal selection dialog we must not `Enter`
83
- * into? Markers are the deployed Claude Code trust/permission dialogs a `❯`
84
- * pointer beside numbered options, or a "Do you want …" prompt (design §3.2,
85
- * open question (a): refine empirically). A running turn is NOT modal.
90
+ * into? Two independent signals, both requiring the *option* shape, not just a
91
+ * loose numbered line:
92
+ *
93
+ * 1. the `❯` pointer sitting on a numbered option — `❯ 1. Use this MCP server`;
94
+ * 2. a dialog marker ("Do you want …", "Enter to confirm") with ≥2 numbered
95
+ * options within `OPTION_WINDOW` lines — this still catches a dialog captured
96
+ * mid-redraw, before its pointer row is painted.
97
+ *
98
+ * A running turn, a prose list, and a markdown step list are all NOT modal.
99
+ * Erring modal is the safe direction (a wake is retried; an `Enter` into a live
100
+ * permission dialog is not undoable), which is why signal 2 is kept — but a bare
101
+ * marker with no options no longer suffices, because Claude Code closes turns
102
+ * with exactly that prose ("Do you want me to open the PR?").
86
103
  */
87
104
  export declare function looksModal(pane: string): boolean;
88
105
  /**
@@ -102,6 +119,8 @@ export declare function looksApiError(pane: string): boolean;
102
119
  export declare function looksRunning(pane: string): boolean;
103
120
  export interface MonitorOpts {
104
121
  name: string;
122
+ /** Ours identity whose notification stream is authoritative (may differ from role name). */
123
+ identity?: string;
105
124
  agentDir: string;
106
125
  cfg: MonitorConfig;
107
126
  deps: MonitorDeps;
@@ -114,12 +133,16 @@ export interface MonitorHandle {
114
133
  }
115
134
  export declare class Monitor {
116
135
  private readonly name;
136
+ private readonly identity;
117
137
  private readonly cfg;
118
138
  private readonly deps;
119
139
  private readonly ep;
120
140
  private readonly statusPath;
121
141
  private readonly cursorPath;
142
+ private readonly statePath;
122
143
  private cursor;
144
+ private deliveredCursor;
145
+ private pendingState;
123
146
  private fatal;
124
147
  private stopped;
125
148
  private bootDeadline;
@@ -127,7 +150,7 @@ export declare class Monitor {
127
150
  private apiErrorStreak;
128
151
  private readonly turnFailThreshold;
129
152
  constructor(o: MonitorOpts);
130
- /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
153
+ /** Resume the last delivered cursor; only a brand-new monitor primes at stream tip. */
131
154
  prime(): Promise<void>;
132
155
  /** Long-poll → filter → coalesce → inject, until the pane pid dies or stop(). */
133
156
  run(pid: number): Promise<void>;
@@ -154,12 +177,19 @@ export declare class Monitor {
154
177
  * no-ops (delivery is still verified downstream).
155
178
  */
156
179
  private clearComposer;
157
- /** Block until the console can accept input; classify offline/stopped/ready. */
180
+ /**
181
+ * Block until the console can accept input; classify offline/stopped/ready, or
182
+ * `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
183
+ * what keeps a modal from wedging delivery silently: we still never `Enter` into
184
+ * the dialog, but the give-up is reported instead of retried forever.
185
+ */
158
186
  private awaitInjectable;
159
187
  private doFetch;
160
188
  private advance;
161
189
  private persistCursor;
162
190
  private readPersistedCursor;
191
+ /** Atomically persist body-free delivery state; restart always resumes from deliveredCursor. */
192
+ private persistState;
163
193
  private setStatus;
164
194
  }
165
195
  export declare function createMonitor(o: MonitorOpts): Monitor;
package/dist/monitor.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  // Code constants (not config — YAGNI, design §2).
@@ -9,6 +9,15 @@ const BOOT_GRACE_MS = 15_000; // hold injection until the TUI is up
9
9
  const POST_VERIFY_MS = 1_000;
10
10
  const MAX_ENTER_RETRIES = 2;
11
11
  const MODAL_RETRY_MS = 5_000;
12
+ // How long delivery may wait on a modal before giving up. Unbounded waiting made
13
+ // any modal — real or false-positive — wedge wake delivery forever while
14
+ // `.monitor-status` still read `armed`, so the failure was invisible from outside.
15
+ // 2 minutes: long enough that a human answering a real dialog is not punished with
16
+ // a spurious `degraded`, short enough that a wedge shows up in the status while
17
+ // it still matters. Giving up drops nothing — the events stay covered by
18
+ // unread.json / the SessionStart backlog, and the next wake retries.
19
+ const MODAL_GIVE_UP_MS = 120_000;
20
+ const MAX_MODAL_WAITS = Math.floor(MODAL_GIVE_UP_MS / MODAL_RETRY_MS);
12
21
  // Keys that reset the composer to empty before we type a wake, so a human's
13
22
  // unsubmitted keystrokes can't concatenate with — or wedge (e.g. via an open
14
23
  // slash-command menu that captures Enter) — the injected line. C-e moves to end
@@ -148,18 +157,57 @@ export function formatNotificationLine(events) {
148
157
  ].filter(Boolean).join(', ');
149
158
  return `${PREFIX} ${compact} — run get_messages`;
150
159
  }
160
+ // ─── Modal-dialog detection (design §3.2, refined empirically) ────────────────
161
+ //
162
+ // `❯` is Claude Code's ordinary composer prompt, so it is on screen in nearly
163
+ // every capture. Testing for it *anywhere* in the pane therefore says nothing;
164
+ // paired with "a numbered line anywhere", it flagged every prose list ("1) foo
165
+ // 2) bar") and every markdown step list as a dialog. What actually distinguishes
166
+ // a select dialog is that its pointer sits ON one of the numbered options.
167
+ //
168
+ // `[^\S\n]` is horizontal whitespace: unlike `\s` it cannot span a line break,
169
+ // so these patterns can't stitch a `❯` in the composer onto a number ten lines up.
170
+ /** `❯ 1. Yes` — the pointer on a numbered option. The shape of every CC dialog. */
171
+ const POINTED_OPTION = /❯[^\S\n]*\d+[.)][^\S\n]+\S/;
172
+ /** A dialog's own chrome. Neither is sufficient alone — see `looksModal`. */
173
+ const DIALOG_MARKERS = [/\bDo you want\b/i, /\bEnter to confirm\b/i];
174
+ /**
175
+ * A numbered option as a dialog paints it: line start (or a box border), then the
176
+ * number. Anchored so prose can't match mid-sentence ("…rewrote 3. Then we…").
177
+ */
178
+ const OPTION_LINE = /^[^\S\n]*(?:[│┃|][^\S\n]*)?(?:❯[^\S\n]*)?\d+[.)][^\S\n]+\S/;
179
+ /** How far from a marker line the options may sit (footers trail them, questions lead). */
180
+ const OPTION_WINDOW = 10;
181
+ /** Options rendered inline on the marker's own line: "…? 1. Yes 2. No". */
182
+ const INLINE_OPTION = /\d+[.)][^\S\n]+\S/g;
151
183
  /**
152
184
  * Heuristic: does the pane show a modal selection dialog we must not `Enter`
153
- * into? Markers are the deployed Claude Code trust/permission dialogs a `❯`
154
- * pointer beside numbered options, or a "Do you want …" prompt (design §3.2,
155
- * open question (a): refine empirically). A running turn is NOT modal.
185
+ * into? Two independent signals, both requiring the *option* shape, not just a
186
+ * loose numbered line:
187
+ *
188
+ * 1. the `❯` pointer sitting on a numbered option — `❯ 1. Use this MCP server`;
189
+ * 2. a dialog marker ("Do you want …", "Enter to confirm") with ≥2 numbered
190
+ * options within `OPTION_WINDOW` lines — this still catches a dialog captured
191
+ * mid-redraw, before its pointer row is painted.
192
+ *
193
+ * A running turn, a prose list, and a markdown step list are all NOT modal.
194
+ * Erring modal is the safe direction (a wake is retried; an `Enter` into a live
195
+ * permission dialog is not undoable), which is why signal 2 is kept — but a bare
196
+ * marker with no options no longer suffices, because Claude Code closes turns
197
+ * with exactly that prose ("Do you want me to open the PR?").
156
198
  */
157
199
  export function looksModal(pane) {
158
- if (/Do you want\b/i.test(pane))
200
+ if (POINTED_OPTION.test(pane))
159
201
  return true;
160
- const hasPointer = /❯/.test(pane);
161
- const hasNumbered = /(^|\n)\s*[❯>]?\s*\d+[.)]\s+\S/.test(pane);
162
- return hasPointer && hasNumbered;
202
+ const lines = pane.split('\n');
203
+ const marker = lines.findIndex(l => DIALOG_MARKERS.some(re => re.test(l)));
204
+ if (marker < 0)
205
+ return false;
206
+ const from = Math.max(0, marker - OPTION_WINDOW);
207
+ const near = lines.slice(from, marker + OPTION_WINDOW + 1);
208
+ if (near.filter(l => OPTION_LINE.test(l)).length >= 2)
209
+ return true;
210
+ return (lines[marker].match(INLINE_OPTION) ?? []).length >= 2;
163
211
  }
164
212
  /**
165
213
  * Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
@@ -194,12 +242,16 @@ function stillInComposer(pane, line) {
194
242
  }
195
243
  export class Monitor {
196
244
  name;
245
+ identity;
197
246
  cfg;
198
247
  deps;
199
248
  ep;
200
249
  statusPath;
201
250
  cursorPath;
251
+ statePath;
202
252
  cursor = null;
253
+ deliveredCursor = null;
254
+ pendingState = null;
203
255
  fatal = false;
204
256
  stopped = false;
205
257
  bootDeadline = 0;
@@ -210,16 +262,25 @@ export class Monitor {
210
262
  turnFailThreshold;
211
263
  constructor(o) {
212
264
  this.name = o.name;
265
+ this.identity = o.identity ?? o.name;
213
266
  this.cfg = o.cfg;
214
267
  this.deps = o.deps;
215
268
  this.ep = resolveEndpoint(o.deps.env);
216
269
  this.statusPath = join(o.agentDir, '.monitor-status');
217
270
  this.cursorPath = join(o.agentDir, '.notify-cursor');
271
+ this.statePath = join(o.agentDir, '.monitor-state.json');
218
272
  const n = o.cfg.turn_fail_threshold;
219
273
  this.turnFailThreshold = typeof n === 'number' && n >= 1 ? n : DEFAULT_TURN_FAIL_THRESHOLD;
220
274
  }
221
- /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
275
+ /** Resume the last delivered cursor; only a brand-new monitor primes at stream tip. */
222
276
  async prime() {
277
+ const persisted = this.readPersistedCursor();
278
+ if (persisted !== null) {
279
+ this.cursor = persisted;
280
+ this.deliveredCursor = persisted;
281
+ this.setStatus('armed');
282
+ return;
283
+ }
223
284
  try {
224
285
  const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
225
286
  this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
@@ -232,7 +293,7 @@ export class Monitor {
232
293
  this.setStatus(`failed: ${e.message}`);
233
294
  }
234
295
  else {
235
- this.cursor = this.readPersistedCursor();
296
+ this.cursor = null;
236
297
  this.setStatus(`degraded: prime failed (${msg(e)})`);
237
298
  }
238
299
  }
@@ -243,6 +304,7 @@ export class Monitor {
243
304
  return;
244
305
  this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
245
306
  let backoff = 0;
307
+ const pending = [];
246
308
  while (!this.stopped) {
247
309
  if (!this.deps.isAlive(pid)) {
248
310
  this.setStatus('degraded: session offline');
@@ -266,12 +328,35 @@ export class Monitor {
266
328
  await this.deps.sleep(backoff);
267
329
  continue;
268
330
  }
269
- this.advance(body.cursor);
331
+ this.advance(body.cursor, false);
270
332
  const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
271
- if (batch.length === 0)
333
+ pending.push(...batch);
334
+ if (pending.length === 0) {
335
+ this.persistCursor();
272
336
  continue;
273
- await this.coalesce(batch);
274
- await this.deliver(pid, batch);
337
+ }
338
+ this.pendingState = {
339
+ count: pending.length,
340
+ eventTypes: uniq(pending.map(event => event.event ?? 'unknown')),
341
+ attempts: (this.pendingState?.attempts ?? 0) + 1,
342
+ };
343
+ this.persistState();
344
+ await this.coalesce(pending);
345
+ // Do not durably commit this cursor until the session explicitly accepts
346
+ // the wake. If delivery fails or the runner crashes, the daemon replays
347
+ // from the last committed cursor and the wake is attempted again.
348
+ let accepted = false;
349
+ try {
350
+ accepted = await this.deliver(pid, pending);
351
+ }
352
+ catch (e) {
353
+ this.setStatus(`degraded: delivery failed (${msg(e)})`);
354
+ }
355
+ if (accepted) {
356
+ pending.length = 0;
357
+ this.pendingState = null;
358
+ this.persistCursor();
359
+ }
275
360
  }
276
361
  }
277
362
  stop() {
@@ -288,19 +373,31 @@ export class Monitor {
288
373
  return;
289
374
  try {
290
375
  const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS);
291
- this.advance(more.cursor);
376
+ this.advance(more.cursor, false);
292
377
  batch.push(...filterEvents(more.events ?? [], this.cfg.wake_sources));
293
378
  }
294
379
  catch { /* no stragglers / abort — deliver what we have */ }
295
380
  }
296
381
  async deliver(pid, batch) {
382
+ const line = formatNotificationLine(batch);
383
+ if (this.deps.delivery) {
384
+ const result = await this.deps.delivery.submit(line);
385
+ if (!result.accepted) {
386
+ this.setStatus(`degraded: ACP prompt not accepted${result.detail ? ` (${result.detail})` : ''}`);
387
+ return false;
388
+ }
389
+ this.recordTurn('completed');
390
+ return true;
391
+ }
297
392
  const state = await this.awaitInjectable(pid);
298
393
  if (state !== 'ready') {
299
394
  if (state === 'offline')
300
395
  this.setStatus('degraded: offline during delivery');
301
- return; // events remain covered by unread.json / SessionStart backlog
396
+ else if (state === 'modal')
397
+ this.setStatus(`degraded: modal wedge — pane held a dialog for ` +
398
+ `${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
399
+ return false;
302
400
  }
303
- const line = formatNotificationLine(batch);
304
401
  await this.clearComposer(); // start from an empty composer
305
402
  await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
306
403
  let delivered = false;
@@ -309,8 +406,12 @@ export class Monitor {
309
406
  // dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
310
407
  for (let i = 0; i < MAX_ENTER_RETRIES; i++) {
311
408
  await this.deps.sleep(POST_VERIFY_MS);
312
- const pane = await safeCapture(this.deps.tmux, this.name);
313
- if (!stillInComposer(pane, line)) {
409
+ const capture = await safeCapture(this.deps.tmux, this.name);
410
+ if (!capture.ok) {
411
+ this.setStatus('degraded: capture failed during injection verification');
412
+ return false;
413
+ }
414
+ if (!stillInComposer(capture.pane, line)) {
314
415
  delivered = true;
315
416
  break;
316
417
  }
@@ -318,12 +419,13 @@ export class Monitor {
318
419
  }
319
420
  if (!delivered) {
320
421
  this.setStatus('degraded: injection unverified');
321
- return;
422
+ return false;
322
423
  }
323
424
  // The wake landed and a turn started; observe how that turn terminates so a
324
425
  // refusal-wedge (every turn dies with `API Error:` while delivery stays green)
325
426
  // becomes visible in `.monitor-status` instead of masquerading as armed (#19).
326
427
  await this.observeTurnOutcome(pid);
428
+ return true;
327
429
  }
328
430
  /**
329
431
  * Watch the pane until the just-triggered turn settles, then fold its outcome
@@ -338,12 +440,16 @@ export class Monitor {
338
440
  return; // shutting down — leave status
339
441
  if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
340
442
  return; // loop marks offline
341
- const pane = await safeCapture(this.deps.tmux, this.name);
342
- if (looksApiError(pane)) {
443
+ const capture = await safeCapture(this.deps.tmux, this.name);
444
+ if (!capture.ok) {
445
+ this.setStatus('degraded: capture failed during turn observation');
446
+ return;
447
+ }
448
+ if (looksApiError(capture.pane)) {
343
449
  this.recordTurn('api-error');
344
450
  return;
345
451
  }
346
- if (!looksRunning(pane)) {
452
+ if (!looksRunning(capture.pane)) {
347
453
  this.recordTurn('completed');
348
454
  return;
349
455
  }
@@ -374,8 +480,14 @@ export class Monitor {
374
480
  for (const key of COMPOSER_CLEAR_KEYS)
375
481
  await this.deps.tmux.sendKey(this.name, key);
376
482
  }
377
- /** Block until the console can accept input; classify offline/stopped/ready. */
483
+ /**
484
+ * Block until the console can accept input; classify offline/stopped/ready, or
485
+ * `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
486
+ * what keeps a modal from wedging delivery silently: we still never `Enter` into
487
+ * the dialog, but the give-up is reported instead of retried forever.
488
+ */
378
489
  async awaitInjectable(pid) {
490
+ let modalWaits = 0;
379
491
  for (;;) {
380
492
  if (this.stopped)
381
493
  return 'stopped';
@@ -386,12 +498,17 @@ export class Monitor {
386
498
  await this.deps.sleep(this.bootDeadline - now);
387
499
  continue;
388
500
  }
389
- const pane = await safeCapture(this.deps.tmux, this.name);
390
- if (looksModal(pane)) {
501
+ const capture = await safeCapture(this.deps.tmux, this.name);
502
+ if (!capture.ok) {
503
+ this.setStatus('degraded: capture failed while checking session readiness');
391
504
  await this.deps.sleep(MODAL_RETRY_MS);
392
505
  continue;
393
506
  }
394
- return 'ready';
507
+ if (!looksModal(capture.pane))
508
+ return 'ready';
509
+ if (modalWaits++ >= MAX_MODAL_WAITS)
510
+ return 'modal';
511
+ await this.deps.sleep(MODAL_RETRY_MS);
395
512
  }
396
513
  }
397
514
  async doFetch(since, holdMs) {
@@ -400,7 +517,7 @@ export class Monitor {
400
517
  const timer = this.deps.timers.set(() => ctrl.abort(), holdMs);
401
518
  let resp;
402
519
  try {
403
- resp = await this.deps.fetch(`${this.ep.url(this.name)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
520
+ resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
404
521
  }
405
522
  finally {
406
523
  this.deps.timers.clear(timer);
@@ -412,16 +529,22 @@ export class Monitor {
412
529
  throw new Error(`daemon returned HTTP ${resp.status}`);
413
530
  return resp.json();
414
531
  }
415
- advance(cursor) {
532
+ advance(cursor, persist = true) {
416
533
  if (typeof cursor === 'number' && cursor !== this.cursor) {
417
534
  this.cursor = cursor;
418
- this.persistCursor();
535
+ if (persist)
536
+ this.persistCursor();
537
+ else
538
+ this.persistState();
419
539
  }
420
540
  }
421
541
  persistCursor() {
422
542
  try {
423
- if (this.cursor !== null)
543
+ if (this.cursor !== null) {
544
+ this.deliveredCursor = this.cursor;
424
545
  writeFileSync(this.cursorPath, `${this.cursor}\n`);
546
+ this.persistState();
547
+ }
425
548
  }
426
549
  catch (e) {
427
550
  this.deps.log(`[${this.name}] monitor: failed to persist cursor: ${msg(e)}`);
@@ -429,15 +552,46 @@ export class Monitor {
429
552
  }
430
553
  readPersistedCursor() {
431
554
  try {
555
+ if (existsSync(this.statePath)) {
556
+ const state = JSON.parse(readFileSync(this.statePath, 'utf8'));
557
+ if ((state.identity !== undefined && state.identity !== this.identity)
558
+ || (state.profileKey !== undefined && state.profileKey !== this.ep.origin))
559
+ return null;
560
+ if (typeof state.deliveredCursor === 'number') {
561
+ this.deliveredCursor = state.deliveredCursor;
562
+ return state.deliveredCursor;
563
+ }
564
+ }
432
565
  if (!existsSync(this.cursorPath))
433
566
  return null;
434
567
  const n = parseInt(readFileSync(this.cursorPath, 'utf8').trim(), 10);
568
+ if (Number.isFinite(n))
569
+ this.deliveredCursor = n;
435
570
  return Number.isFinite(n) ? n : null;
436
571
  }
437
572
  catch {
438
573
  return null;
439
574
  }
440
575
  }
576
+ /** Atomically persist body-free delivery state; restart always resumes from deliveredCursor. */
577
+ persistState() {
578
+ try {
579
+ const tmp = `${this.statePath}.${process.pid}.tmp`;
580
+ writeFileSync(tmp, JSON.stringify({
581
+ version: 1,
582
+ identity: this.identity,
583
+ profileKey: this.ep.origin,
584
+ observedCursor: this.cursor,
585
+ deliveredCursor: this.deliveredCursor,
586
+ pending: this.pendingState,
587
+ updatedAt: new Date(this.deps.now()).toISOString(),
588
+ }, null, 2) + '\n', { mode: 0o600 });
589
+ renameSync(tmp, this.statePath);
590
+ }
591
+ catch (e) {
592
+ this.deps.log(`[${this.name}] monitor: failed to persist state: ${msg(e)}`);
593
+ }
594
+ }
441
595
  setStatus(s) {
442
596
  try {
443
597
  writeFileSync(this.statusPath, `${s}\n`);
@@ -454,10 +608,10 @@ export function createMonitor(o) {
454
608
  }
455
609
  async function safeCapture(tmux, name) {
456
610
  try {
457
- return await tmux.capture(name);
611
+ return { ok: true, pane: await tmux.capture(name) };
458
612
  }
459
613
  catch {
460
- return '';
614
+ return { ok: false, pane: '' };
461
615
  }
462
616
  }
463
617
  const msg = (e) => e?.message ?? String(e);