@ours.network/fleet 0.9.2 → 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
  /**
@@ -145,7 +155,21 @@ export declare class Monitor {
145
155
  private observeTurnOutcome;
146
156
  /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
147
157
  private recordTurn;
148
- /** Block until the console can accept input; classify offline/stopped/ready. */
158
+ /**
159
+ * Reset the composer to empty before typing a wake. Without this, any
160
+ * unsubmitted text a human left in the input concatenates with the injected
161
+ * line (`stray[fleet-monitor] …`) or, when it has opened a slash-command menu,
162
+ * swallows the submit Enter entirely — wedging every subsequent injection until
163
+ * the composer is cleared by hand. Best-effort: a dead pane just makes the keys
164
+ * no-ops (delivery is still verified downstream).
165
+ */
166
+ private clearComposer;
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
+ */
149
173
  private awaitInjectable;
150
174
  private doFetch;
151
175
  private advance;
package/dist/monitor.js CHANGED
@@ -9,6 +9,20 @@ 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);
21
+ // Keys that reset the composer to empty before we type a wake, so a human's
22
+ // unsubmitted keystrokes can't concatenate with — or wedge (e.g. via an open
23
+ // slash-command menu that captures Enter) — the injected line. C-e moves to end
24
+ // of line, C-u kills to start ⇒ whole single line cleared regardless of cursor.
25
+ const COMPOSER_CLEAR_KEYS = ['C-e', 'C-u'];
12
26
  const BACKOFF_STEP_MS = 1_000;
13
27
  const BACKOFF_MAX_MS = 5_000;
14
28
  const PREFIX = '[fleet-monitor]';
@@ -143,18 +157,57 @@ export function formatNotificationLine(events) {
143
157
  ].filter(Boolean).join(', ');
144
158
  return `${PREFIX} ${compact} — run get_messages`;
145
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;
146
183
  /**
147
184
  * Heuristic: does the pane show a modal selection dialog we must not `Enter`
148
- * into? Markers are the deployed Claude Code trust/permission dialogs a `❯`
149
- * pointer beside numbered options, or a "Do you want …" prompt (design §3.2,
150
- * 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?").
151
198
  */
152
199
  export function looksModal(pane) {
153
- if (/Do you want\b/i.test(pane))
200
+ if (POINTED_OPTION.test(pane))
201
+ return true;
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)
154
209
  return true;
155
- const hasPointer = /❯/.test(pane);
156
- const hasNumbered = /(^|\n)\s*[❯>]?\s*\d+[.)]\s+\S/.test(pane);
157
- return hasPointer && hasNumbered;
210
+ return (lines[marker].match(INLINE_OPTION) ?? []).length >= 2;
158
211
  }
159
212
  /**
160
213
  * Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
@@ -293,9 +346,13 @@ export class Monitor {
293
346
  if (state !== 'ready') {
294
347
  if (state === 'offline')
295
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`);
296
352
  return; // events remain covered by unread.json / SessionStart backlog
297
353
  }
298
354
  const line = formatNotificationLine(batch);
355
+ await this.clearComposer(); // start from an empty composer
299
356
  await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
300
357
  let delivered = false;
301
358
  // Verify submission for THIS line even if stop() arrives mid-flight: the text
@@ -356,8 +413,26 @@ export class Monitor {
356
413
  ? 'degraded: turns failing (api error)'
357
414
  : 'armed');
358
415
  }
359
- /** Block until the console can accept input; classify offline/stopped/ready. */
416
+ /**
417
+ * Reset the composer to empty before typing a wake. Without this, any
418
+ * unsubmitted text a human left in the input concatenates with the injected
419
+ * line (`stray[fleet-monitor] …`) or, when it has opened a slash-command menu,
420
+ * swallows the submit Enter entirely — wedging every subsequent injection until
421
+ * the composer is cleared by hand. Best-effort: a dead pane just makes the keys
422
+ * no-ops (delivery is still verified downstream).
423
+ */
424
+ async clearComposer() {
425
+ for (const key of COMPOSER_CLEAR_KEYS)
426
+ await this.deps.tmux.sendKey(this.name, key);
427
+ }
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
+ */
360
434
  async awaitInjectable(pid) {
435
+ let modalWaits = 0;
361
436
  for (;;) {
362
437
  if (this.stopped)
363
438
  return 'stopped';
@@ -369,11 +444,11 @@ export class Monitor {
369
444
  continue;
370
445
  }
371
446
  const pane = await safeCapture(this.deps.tmux, this.name);
372
- if (looksModal(pane)) {
373
- await this.deps.sleep(MODAL_RETRY_MS);
374
- continue;
375
- }
376
- 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);
377
452
  }
378
453
  }
379
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.2",
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",