@ours.network/fleet 0.9.1 → 0.9.3

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/dist/config.d.ts CHANGED
@@ -13,6 +13,13 @@ export interface MonitorConfig {
13
13
  wake_sources: string[];
14
14
  batch_ms: number;
15
15
  inject: InjectMode;
16
+ /**
17
+ * Consecutive delivered wakes that must end in an `API Error:`-terminated turn
18
+ * (with no completed turn in between) before `.monitor-status` degrades to
19
+ * `turns failing (api error)` — the refusal-wedge detector (issue #19). Must be
20
+ * a positive integer; resolved default is 3. Optional so old snapshots resolve.
21
+ */
22
+ turn_fail_threshold?: number;
16
23
  }
17
24
  /** Default wake sources when a role does not list its own (design §2). */
18
25
  export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
package/dist/config.js CHANGED
@@ -10,9 +10,10 @@ export const NOTIFY_EVENT_TYPES = [
10
10
  ];
11
11
  /** Default wake sources when a role does not list its own (design §2). */
12
12
  export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
13
- const MONITOR_KEYS = ['enabled', 'wake_sources', 'batch_ms', 'inject'];
13
+ const MONITOR_KEYS = ['enabled', 'wake_sources', 'batch_ms', 'inject', 'turn_fail_threshold'];
14
14
  const INJECT_MODES = ['notification', 'full'];
15
15
  const MONITOR_DEFAULT_BATCH_MS = 2000;
16
+ const MONITOR_DEFAULT_TURN_FAIL_THRESHOLD = 3;
16
17
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
17
18
  /** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
18
19
  export function validateMonitorConfig(raw) {
@@ -30,6 +31,10 @@ export function validateMonitorConfig(raw) {
30
31
  problems.push('monitor.batch_ms: must be a non-negative number');
31
32
  if (m.inject !== undefined && !INJECT_MODES.includes(m.inject))
32
33
  problems.push(`monitor.inject: invalid value '${m.inject}'; allowed: ${INJECT_MODES.join(', ')}`);
34
+ if (m.turn_fail_threshold !== undefined
35
+ && (typeof m.turn_fail_threshold !== 'number' || !Number.isInteger(m.turn_fail_threshold)
36
+ || m.turn_fail_threshold < 1))
37
+ problems.push('monitor.turn_fail_threshold: must be a positive integer');
33
38
  if (m.wake_sources !== undefined) {
34
39
  if (!Array.isArray(m.wake_sources))
35
40
  problems.push('monitor.wake_sources: must be a list');
@@ -168,6 +173,7 @@ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
168
173
  wake_sources: merged.wake_sources ?? [...DEFAULT_WAKE_SOURCES],
169
174
  batch_ms: merged.batch_ms ?? MONITOR_DEFAULT_BATCH_MS,
170
175
  inject: merged.inject ?? 'notification',
176
+ turn_fail_threshold: merged.turn_fail_threshold ?? MONITOR_DEFAULT_TURN_FAIL_THRESHOLD,
171
177
  };
172
178
  }
173
179
  export function findRole(cfg, name) {
package/dist/monitor.d.ts CHANGED
@@ -85,6 +85,21 @@ export declare function formatNotificationLine(events: NotifyEvent[]): string;
85
85
  * open question (a): refine empirically). A running turn is NOT modal.
86
86
  */
87
87
  export declare function looksModal(pane: string): boolean;
88
+ /**
89
+ * Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
90
+ * Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
91
+ * refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
92
+ * trailing idle composer redrawn beneath it (design §3.2, refine empirically).
93
+ * The N-consecutive threshold in the Monitor debounces the odd false match.
94
+ */
95
+ export declare function looksApiError(pane: string): boolean;
96
+ /**
97
+ * Heuristic: is a turn still RUNNING in this pane? Claude Code shows a live
98
+ * "esc to interrupt" footer (often with an elapsed-seconds meter) while a turn
99
+ * streams. Absence of any running marker — and no API error — means the turn has
100
+ * settled (completed). Kept a positive check so a quiet idle pane reads as done.
101
+ */
102
+ export declare function looksRunning(pane: string): boolean;
88
103
  export interface MonitorOpts {
89
104
  name: string;
90
105
  agentDir: string;
@@ -109,6 +124,8 @@ export declare class Monitor {
109
124
  private stopped;
110
125
  private bootDeadline;
111
126
  private currentAbort;
127
+ private apiErrorStreak;
128
+ private readonly turnFailThreshold;
112
129
  constructor(o: MonitorOpts);
113
130
  /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
114
131
  prime(): Promise<void>;
@@ -118,6 +135,25 @@ export declare class Monitor {
118
135
  /** Gather stragglers arriving within batch_ms so a burst lands as one line. */
119
136
  private coalesce;
120
137
  private deliver;
138
+ /**
139
+ * Watch the pane until the just-triggered turn settles, then fold its outcome
140
+ * into the API-error streak and republish `.monitor-status`. A completed turn
141
+ * (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
142
+ * grows it. Once the streak reaches the threshold the status degrades; a later
143
+ * completed turn flips it back to armed. Detection only — no remediation (#19).
144
+ */
145
+ private observeTurnOutcome;
146
+ /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
147
+ private recordTurn;
148
+ /**
149
+ * Reset the composer to empty before typing a wake. Without this, any
150
+ * unsubmitted text a human left in the input concatenates with the injected
151
+ * line (`stray[fleet-monitor] …`) or, when it has opened a slash-command menu,
152
+ * swallows the submit Enter entirely — wedging every subsequent injection until
153
+ * the composer is cleared by hand. Best-effort: a dead pane just makes the keys
154
+ * no-ops (delivery is still verified downstream).
155
+ */
156
+ private clearComposer;
121
157
  /** Block until the console can accept input; classify offline/stopped/ready. */
122
158
  private awaitInjectable;
123
159
  private doFetch;
package/dist/monitor.js CHANGED
@@ -9,10 +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
+ // Keys that reset the composer to empty before we type a wake, so a human's
13
+ // unsubmitted keystrokes can't concatenate with — or wedge (e.g. via an open
14
+ // slash-command menu that captures Enter) — the injected line. C-e moves to end
15
+ // of line, C-u kills to start ⇒ whole single line cleared regardless of cursor.
16
+ const COMPOSER_CLEAR_KEYS = ['C-e', 'C-u'];
12
17
  const BACKOFF_STEP_MS = 1_000;
13
18
  const BACKOFF_MAX_MS = 5_000;
14
19
  const PREFIX = '[fleet-monitor]';
15
20
  const MAX_LINE = 260;
21
+ // Turn-outcome observation (issue #19): after a delivered wake, watch the pane
22
+ // until the triggered turn settles, then classify it. Code constants (not config):
23
+ const TURN_OBSERVE_POLLS = 20; // give up after ~POLLS × INTERVAL of a still-running turn
24
+ const TURN_OBSERVE_INTERVAL_MS = 1_500;
25
+ const DEFAULT_TURN_FAIL_THRESHOLD = 3; // fallback when the resolved config omits it
16
26
  class AuthError extends Error {
17
27
  }
18
28
  /** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
@@ -151,6 +161,31 @@ export function looksModal(pane) {
151
161
  const hasNumbered = /(^|\n)\s*[❯>]?\s*\d+[.)]\s+\S/.test(pane);
152
162
  return hasPointer && hasNumbered;
153
163
  }
164
+ /**
165
+ * Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
166
+ * Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
167
+ * refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
168
+ * trailing idle composer redrawn beneath it (design §3.2, refine empirically).
169
+ * The N-consecutive threshold in the Monitor debounces the odd false match.
170
+ */
171
+ export function looksApiError(pane) {
172
+ const tail = pane.split('\n').slice(-15).join('\n');
173
+ return /\bAPI Error\b/i.test(tail);
174
+ }
175
+ /**
176
+ * Heuristic: is a turn still RUNNING in this pane? Claude Code shows a live
177
+ * "esc to interrupt" footer (often with an elapsed-seconds meter) while a turn
178
+ * streams. Absence of any running marker — and no API error — means the turn has
179
+ * settled (completed). Kept a positive check so a quiet idle pane reads as done.
180
+ */
181
+ export function looksRunning(pane) {
182
+ const tail = pane.split('\n').slice(-6).join('\n');
183
+ if (/esc to interrupt/i.test(tail))
184
+ return true; // Claude Code's running footer
185
+ if (/\(\s*\d+s\b/.test(tail))
186
+ return true; // "(12s · … tokens)" elapsed meter
187
+ return false;
188
+ }
154
189
  /** Is the injected line still sitting unsubmitted in the composer (bottom of pane)? */
155
190
  function stillInComposer(pane, line) {
156
191
  const frag = line.slice(0, 48);
@@ -169,6 +204,10 @@ export class Monitor {
169
204
  stopped = false;
170
205
  bootDeadline = 0;
171
206
  currentAbort = null;
207
+ // Refusal-wedge detector (issue #19): consecutive delivered wakes whose turn
208
+ // ended in an API error with no completed turn in between.
209
+ apiErrorStreak = 0;
210
+ turnFailThreshold;
172
211
  constructor(o) {
173
212
  this.name = o.name;
174
213
  this.cfg = o.cfg;
@@ -176,6 +215,8 @@ export class Monitor {
176
215
  this.ep = resolveEndpoint(o.deps.env);
177
216
  this.statusPath = join(o.agentDir, '.monitor-status');
178
217
  this.cursorPath = join(o.agentDir, '.notify-cursor');
218
+ const n = o.cfg.turn_fail_threshold;
219
+ this.turnFailThreshold = typeof n === 'number' && n >= 1 ? n : DEFAULT_TURN_FAIL_THRESHOLD;
179
220
  }
180
221
  /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
181
222
  async prime() {
@@ -260,6 +301,7 @@ export class Monitor {
260
301
  return; // events remain covered by unread.json / SessionStart backlog
261
302
  }
262
303
  const line = formatNotificationLine(batch);
304
+ await this.clearComposer(); // start from an empty composer
263
305
  await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
264
306
  let delivered = false;
265
307
  // Verify submission for THIS line even if stop() arrives mid-flight: the text
@@ -274,7 +316,63 @@ export class Monitor {
274
316
  }
275
317
  await this.deps.tmux.sendKey(this.name, 'Enter');
276
318
  }
277
- this.setStatus(delivered ? 'armed' : 'degraded: injection unverified');
319
+ if (!delivered) {
320
+ this.setStatus('degraded: injection unverified');
321
+ return;
322
+ }
323
+ // The wake landed and a turn started; observe how that turn terminates so a
324
+ // refusal-wedge (every turn dies with `API Error:` while delivery stays green)
325
+ // becomes visible in `.monitor-status` instead of masquerading as armed (#19).
326
+ await this.observeTurnOutcome(pid);
327
+ }
328
+ /**
329
+ * Watch the pane until the just-triggered turn settles, then fold its outcome
330
+ * into the API-error streak and republish `.monitor-status`. A completed turn
331
+ * (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
332
+ * grows it. Once the streak reaches the threshold the status degrades; a later
333
+ * completed turn flips it back to armed. Detection only — no remediation (#19).
334
+ */
335
+ async observeTurnOutcome(pid) {
336
+ for (let i = 0; i < TURN_OBSERVE_POLLS; i++) {
337
+ if (this.stopped)
338
+ return; // shutting down — leave status
339
+ if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
340
+ return; // loop marks offline
341
+ const pane = await safeCapture(this.deps.tmux, this.name);
342
+ if (looksApiError(pane)) {
343
+ this.recordTurn('api-error');
344
+ return;
345
+ }
346
+ if (!looksRunning(pane)) {
347
+ this.recordTurn('completed');
348
+ return;
349
+ }
350
+ await this.deps.sleep(TURN_OBSERVE_INTERVAL_MS);
351
+ }
352
+ this.recordTurn('inconclusive'); // still running at give-up: hold the streak, don't re-arm
353
+ }
354
+ /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
355
+ recordTurn(outcome) {
356
+ if (outcome === 'api-error')
357
+ this.apiErrorStreak++;
358
+ else if (outcome === 'completed')
359
+ this.apiErrorStreak = 0;
360
+ // 'inconclusive' leaves the streak (and therefore the status) unchanged.
361
+ this.setStatus(this.apiErrorStreak >= this.turnFailThreshold
362
+ ? 'degraded: turns failing (api error)'
363
+ : 'armed');
364
+ }
365
+ /**
366
+ * Reset the composer to empty before typing a wake. Without this, any
367
+ * unsubmitted text a human left in the input concatenates with the injected
368
+ * line (`stray[fleet-monitor] …`) or, when it has opened a slash-command menu,
369
+ * swallows the submit Enter entirely — wedging every subsequent injection until
370
+ * the composer is cleared by hand. Best-effort: a dead pane just makes the keys
371
+ * no-ops (delivery is still verified downstream).
372
+ */
373
+ async clearComposer() {
374
+ for (const key of COMPOSER_CLEAR_KEYS)
375
+ await this.deps.tmux.sendKey(this.name, key);
278
376
  }
279
377
  /** Block until the console can accept input; classify offline/stopped/ready. */
280
378
  async awaitInjectable(pid) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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",