@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 +6 -1
- package/dist/monitor.d.ts +28 -4
- package/dist/monitor.js +88 -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
|
/**
|
|
@@ -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
|
-
/**
|
|
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?
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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.
|
|
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",
|