@ours.network/fleet 0.9.3 → 0.9.4

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.
package/README.md CHANGED
@@ -268,7 +268,12 @@ straight into the console. It is a deterministic program whose lifetime is fused
268
268
  the tmux session — it primes the notification cursor *before* the session launches
269
269
  (no missed arrivals), cannot be orphaned or left deaf-but-armed, and writes its
270
270
  health to `<agentDir>/.monitor-status` (`armed | degraded | failed`), surfaced in
271
- `ours-fleet status`/`doctor`. The agent's briefing tells it **not** to arm an
271
+ `ours-fleet status`/`doctor`. Injection is held while the pane shows a modal dialog,
272
+ so an injected wake can never answer a trust/permission prompt; if the dialog is
273
+ still up after 2 minutes the monitor gives up on that wake and records
274
+ `degraded: modal wedge …` instead of waiting silently forever (the mail stays
275
+ queued — the agent drains it on the next wake or at SessionStart). The agent's
276
+ briefing tells it **not** to arm an
272
277
  in-session Monitor. Set `monitor.enabled: false` to keep the legacy behavior where
273
278
  the agent arms its own `ours-mcp watch`. `inject: full` (pushing message bodies
274
279
  inline) is on the roadmap and needs two new ours-mcp daemon endpoints; today all
package/dist/monitor.d.ts CHANGED
@@ -80,9 +80,19 @@ export declare function filterEvents(events: NotifyEvent[], wakeSources: string[
80
80
  export declare function formatNotificationLine(events: NotifyEvent[]): string;
81
81
  /**
82
82
  * 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.
83
+ * into? Two independent signals, both requiring the *option* shape, not just a
84
+ * loose numbered line:
85
+ *
86
+ * 1. the `❯` pointer sitting on a numbered option — `❯ 1. Use this MCP server`;
87
+ * 2. a dialog marker ("Do you want …", "Enter to confirm") with ≥2 numbered
88
+ * options within `OPTION_WINDOW` lines — this still catches a dialog captured
89
+ * mid-redraw, before its pointer row is painted.
90
+ *
91
+ * A running turn, a prose list, and a markdown step list are all NOT modal.
92
+ * Erring modal is the safe direction (a wake is retried; an `Enter` into a live
93
+ * permission dialog is not undoable), which is why signal 2 is kept — but a bare
94
+ * marker with no options no longer suffices, because Claude Code closes turns
95
+ * with exactly that prose ("Do you want me to open the PR?").
86
96
  */
87
97
  export declare function looksModal(pane: string): boolean;
88
98
  /**
@@ -154,7 +164,12 @@ export declare class Monitor {
154
164
  * no-ops (delivery is still verified downstream).
155
165
  */
156
166
  private clearComposer;
157
- /** Block until the console can accept input; classify offline/stopped/ready. */
167
+ /**
168
+ * Block until the console can accept input; classify offline/stopped/ready, or
169
+ * `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
170
+ * what keeps a modal from wedging delivery silently: we still never `Enter` into
171
+ * the dialog, but the give-up is reported instead of retried forever.
172
+ */
158
173
  private awaitInjectable;
159
174
  private doFetch;
160
175
  private advance;
package/dist/monitor.js CHANGED
@@ -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?
@@ -298,6 +346,9 @@ export class Monitor {
298
346
  if (state !== 'ready') {
299
347
  if (state === 'offline')
300
348
  this.setStatus('degraded: offline during delivery');
349
+ else if (state === 'modal')
350
+ this.setStatus(`degraded: modal wedge — pane held a dialog for ` +
351
+ `${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
301
352
  return; // events remain covered by unread.json / SessionStart backlog
302
353
  }
303
354
  const line = formatNotificationLine(batch);
@@ -374,8 +425,14 @@ export class Monitor {
374
425
  for (const key of COMPOSER_CLEAR_KEYS)
375
426
  await this.deps.tmux.sendKey(this.name, key);
376
427
  }
377
- /** Block until the console can accept input; classify offline/stopped/ready. */
428
+ /**
429
+ * Block until the console can accept input; classify offline/stopped/ready, or
430
+ * `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
431
+ * what keeps a modal from wedging delivery silently: we still never `Enter` into
432
+ * the dialog, but the give-up is reported instead of retried forever.
433
+ */
378
434
  async awaitInjectable(pid) {
435
+ let modalWaits = 0;
379
436
  for (;;) {
380
437
  if (this.stopped)
381
438
  return 'stopped';
@@ -387,11 +444,11 @@ export class Monitor {
387
444
  continue;
388
445
  }
389
446
  const pane = await safeCapture(this.deps.tmux, this.name);
390
- if (looksModal(pane)) {
391
- await this.deps.sleep(MODAL_RETRY_MS);
392
- continue;
393
- }
394
- return 'ready';
447
+ if (!looksModal(pane))
448
+ return 'ready';
449
+ if (modalWaits++ >= MAX_MODAL_WAITS)
450
+ return 'modal';
451
+ await this.deps.sleep(MODAL_RETRY_MS);
395
452
  }
396
453
  }
397
454
  async doFetch(since, holdMs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",