@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 +6 -1
- package/dist/monitor.d.ts +19 -4
- package/dist/monitor.js +70 -13
- package/package.json +1 -1
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`.
|
|
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?
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
/**
|
|
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?
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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 (
|
|
200
|
+
if (POINTED_OPTION.test(pane))
|
|
159
201
|
return true;
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
"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",
|