@ours.network/fleet 0.9.7 → 0.9.8

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.
Files changed (2) hide show
  1. package/dist/monitor.js +82 -5
  2. package/package.json +1 -1
package/dist/monitor.js CHANGED
@@ -234,11 +234,73 @@ export function looksRunning(pane) {
234
234
  return true; // "(12s · … tokens)" elapsed meter
235
235
  return false;
236
236
  }
237
- /** Is the injected line still sitting unsubmitted in the composer (bottom of pane)? */
237
+ const COMPOSER_TOP = /^[^\S\n]*[╭┌][─━]/;
238
+ const COMPOSER_BOTTOM = /^[^\S\n]*[╰└][─━]/;
239
+ const COMPOSER_PROMPT = /^[^\S\n]*(?:[│┃|][^\S\n]*)?[❯›>][^\S\n]*$/;
240
+ /**
241
+ * Remove wrapping-only whitespace and box chrome from composer rows. Notification
242
+ * lines contain no meaningful whitespace distinction, so this lets a fragment
243
+ * cross an arbitrary terminal wrap without matching unrelated transcript text.
244
+ */
245
+ function normalizeComposerRows(rows) {
246
+ return rows.map((raw, i) => {
247
+ let row = raw;
248
+ if (i > 0)
249
+ row = row.replace(/^[^\S\n]*(?:[│┃|][^\S\n]*)?/, '');
250
+ row = row.replace(/[^\S\n]*(?:[│┃|])?[^\S\n]*$/, '');
251
+ return row;
252
+ }).join('').replace(/\s+/g, '');
253
+ }
254
+ /**
255
+ * Is the injected line still sitting unsubmitted in the composer?
256
+ *
257
+ * The footer has variable height and the line may wrap across any number of
258
+ * rows, so a fixed tail window cannot identify the composer. Prefer the final
259
+ * bordered composer region; for a borderless/truncated capture, require the
260
+ * notification prefix to follow a composer prompt. This keeps old submitted
261
+ * wake lines in the transcript from causing stray Enters.
262
+ */
238
263
  function stillInComposer(pane, line) {
239
- const frag = line.slice(0, 48);
240
- const tail = pane.split('\n').slice(-4).join('\n');
241
- return tail.includes(frag);
264
+ if (!pane)
265
+ return false; // dead pane: do not waste Enters
266
+ const lines = pane.split('\n');
267
+ let bottom = -1;
268
+ for (let i = lines.length - 1; i >= 0; i--) {
269
+ if (COMPOSER_BOTTOM.test(lines[i])) {
270
+ bottom = i;
271
+ break;
272
+ }
273
+ }
274
+ let top = -1;
275
+ if (bottom >= 0) {
276
+ for (let i = bottom - 1; i >= 0; i--) {
277
+ if (COMPOSER_TOP.test(lines[i])) {
278
+ top = i;
279
+ break;
280
+ }
281
+ }
282
+ }
283
+ const from = top >= 0 ? top + 1 : 0;
284
+ const to = bottom >= 0 ? bottom : lines.length;
285
+ let wakeRow = -1;
286
+ let wakeColumn = -1;
287
+ for (let i = to - 1; i >= from; i--) {
288
+ const column = lines[i].lastIndexOf(PREFIX);
289
+ if (column >= 0) {
290
+ wakeRow = i;
291
+ wakeColumn = column;
292
+ break;
293
+ }
294
+ }
295
+ if (wakeRow < 0)
296
+ return false;
297
+ // Without both box boundaries, only trust text visibly in a composer prompt.
298
+ // This is the safe fallback for borderless TUIs and truncated captures.
299
+ if (top < 0 && !COMPOSER_PROMPT.test(lines[wakeRow].slice(0, wakeColumn)))
300
+ return false;
301
+ const rows = lines.slice(wakeRow, to);
302
+ rows[0] = rows[0].slice(wakeColumn);
303
+ return normalizeComposerRows(rows).includes(line.replace(/\s+/g, ''));
242
304
  }
243
305
  export class Monitor {
244
306
  name;
@@ -412,18 +474,33 @@ export class Monitor {
412
474
  // Verify submission for THIS line even if stop() arrives mid-flight: the text
413
475
  // is already in the composer and we want it submitted (at-least-once). A truly
414
476
  // dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
415
- for (let i = 0; i < MAX_ENTER_RETRIES; i++) {
477
+ for (let i = 0; i < MAX_ENTER_RETRIES;) {
416
478
  await this.deps.sleep(POST_VERIFY_MS);
417
479
  const capture = await safeCapture(this.deps.tmux, this.name);
418
480
  if (!capture.ok) {
419
481
  this.degrade('delivery', 'capture failed during injection verification');
420
482
  return false;
421
483
  }
484
+ // A dialog can appear after the initial send. Never let a verification
485
+ // retry confirm it. Wait under the same bounded modal policy as initial
486
+ // injection, then re-capture immediately before considering Enter.
487
+ if (looksModal(capture.pane)) {
488
+ const state = await this.awaitInjectable(pid);
489
+ if (state === 'ready')
490
+ continue;
491
+ if (state === 'offline')
492
+ this.degrade('offline', 'offline during injection verification');
493
+ else if (state === 'modal')
494
+ this.degrade('modal', `modal wedge during injection verification — ` +
495
+ `no Enter sent for ${MODAL_GIVE_UP_MS / 1000}s`);
496
+ return false;
497
+ }
422
498
  if (!stillInComposer(capture.pane, line)) {
423
499
  delivered = true;
424
500
  break;
425
501
  }
426
502
  await this.deps.tmux.sendKey(this.name, 'Enter');
503
+ i++;
427
504
  }
428
505
  if (!delivered) {
429
506
  this.degrade('delivery', 'injection unverified');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",