aibroker 0.36.0 → 0.38.0

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.
@@ -27,6 +27,9 @@ import { log } from "../core/log.js";
27
27
  import { readSessionContent } from "./session-content.js";
28
28
  import { typeIntoSession } from "../transport/sync-facade.js";
29
29
  import { discoverLiveSessions } from "../core/session-discovery.js";
30
+ import { hasPailotClients } from "../adapters/pailot/gateway.js";
31
+ import { getAibpBridge } from "../core/state.js";
32
+ import { listDialogs, answerDialog } from "./dialogs.js";
30
33
  const STATE_FILE = join(homedir(), ".aibroker", "managers.json");
31
34
  /** How often every manager is looked at. Cheap: one content read per managed session. */
32
35
  const TICK_MS = 20_000;
@@ -42,6 +45,16 @@ const TICK_MS = 20_000;
42
45
  const NO_GOAL_GRACE_MS = 30_000;
43
46
  /** Never re-arm twice inside this window, whatever the signals say. */
44
47
  const REARM_COOLDOWN_MS = 90_000;
48
+ /**
49
+ * How many times a goal may be typed without confirmation before giving up.
50
+ *
51
+ * Small on purpose. Each attempt is a whole objective pasted into the session's
52
+ * input; against a busy session those queue rather than run, and a queue of
53
+ * goals fires in sequence later against whatever state exists by then. Three is
54
+ * enough to ride out a slow screen read and few enough that the queue stays
55
+ * harmless if none of them landed.
56
+ */
57
+ const ARM_ATTEMPTS = 3;
45
58
  /**
46
59
  * How long an armed goal is believed before the manager stops waiting for it.
47
60
  *
@@ -60,6 +73,116 @@ const GOAL_MAX_AGE_MS = 45 * 60_000;
60
73
  * conjunction with the age ceiling above, never on its own.
61
74
  */
62
75
  const GOAL_ACTIVE = /\/goal\s+active/i;
76
+ /**
77
+ * Where a session is asked to hand over, as a share of its context.
78
+ *
79
+ * NOT where it dies — where it should stop and write down what it knows while
80
+ * it still can. A session at the wall cannot compose a handover, because
81
+ * composing one is exactly the sort of work it no longer has room for. The
82
+ * margin has to be big enough to write in.
83
+ *
84
+ * Deliberately conservative. Rolling over early costs one cycle of re-reading a
85
+ * file; rolling over late costs everything the session had not written down,
86
+ * and that loss is silent — the successor does not know what it was not told.
87
+ */
88
+ const HANDOVER_AT = 0.82;
89
+ /**
90
+ * How long the manager waits for the handover before giving up on it.
91
+ *
92
+ * A rollover that hangs is worse than no rollover: the session is paused, not
93
+ * working, and nobody is told. If the handover does not arrive the manager says
94
+ * so and leaves the session alone rather than clearing it — clearing a session
95
+ * that failed to write its handover destroys precisely what the rollover
96
+ * existed to preserve.
97
+ */
98
+ const HANDOVER_GRACE_MS = 6 * 60_000;
99
+ /**
100
+ * How long a managed session may show no change at all before it is armed.
101
+ *
102
+ * Chosen well above any turn that is merely slow — builds, long test runs and
103
+ * an agent thinking hard all move the screen inside this — so that firing means
104
+ * something is actually wrong rather than something is taking a while. The
105
+ * penalty for firing early is one queued prompt; the penalty for firing late is
106
+ * measured in hours of a session sitting at an empty prompt, so the number
107
+ * leans towards firing.
108
+ */
109
+ const STUCK_AFTER_MS = 12 * 60_000;
110
+ /**
111
+ * The startup banner, which is the pane's only POSITIVE evidence of a clear.
112
+ *
113
+ * The first version of this test asked the opposite question — whether the
114
+ * goal's words had left the screen — and that was wrong in a way worth
115
+ * recording, because it looked obviously right. A pane scrolls. The words of a
116
+ * goal set an hour ago are gone from it during any long turn, so their absence
117
+ * is the normal condition of a working session, not the signature of a cleared
118
+ * one. It declared every rollover complete the moment the clear was typed.
119
+ *
120
+ * A banner is drawn on exactly two occasions: the session starting, and the
121
+ * session being cleared. Inside the window where a clear has just been typed,
122
+ * only the second is possible.
123
+ */
124
+ const CLEARED_BANNER = /Claude Code v\d/;
125
+ /**
126
+ * How long a clear may stay unaccounted for before the guard lets go.
127
+ *
128
+ * The guard exists so a second clear is never typed while one is outstanding,
129
+ * and that is right. But it was released by exactly one event — seeing a clear
130
+ * land — and an event that may never happen is not a release, it is a lock: an
131
+ * operator who deletes the queued clear, or a terminal that drops it, leaves
132
+ * the session unable to roll over again for the rest of its life. That is a
133
+ * worse failure than the one being prevented, and quieter.
134
+ *
135
+ * The interval is long because it is a backstop, not a retry. Nothing here
136
+ * hurries: a rollover still has to earn its way back by writing a handover
137
+ * first, which takes minutes and cannot be faked.
138
+ */
139
+ const CLEAR_PENDING_MAX_MS = 30 * 60_000;
140
+ /**
141
+ * How long before a session that has handed over is asked to do it again.
142
+ *
143
+ * Needed only because the handover no longer ends in a clear. The context that
144
+ * triggered the request stays high afterwards — that is the point, the session
145
+ * keeps its context and keeps working — so without a cooldown the threshold
146
+ * re-qualifies it on the very next tick and it is interrupted every twenty
147
+ * seconds to write the same file.
148
+ *
149
+ * A session that keeps its handover current as it works, which is the habit
150
+ * this encourages, will usually have nothing to add when re-asked. The
151
+ * interval is set for the case where it does.
152
+ */
153
+ const HANDOVER_REASK_MS = 30 * 60_000;
154
+ /**
155
+ * How much NEW work may accumulate before the handover is asked for again.
156
+ *
157
+ * A time cooldown alone left the gap this closes. The handover is written at
158
+ * the threshold and the session then keeps working to the wall — on a 1M
159
+ * window that is nearly 200k tokens of thinking that the file does not
160
+ * describe, and it is exactly the stretch compaction throws away. The document
161
+ * meant to survive compaction was reliably stale by the moment it was needed.
162
+ *
163
+ * Measured in context growth rather than minutes because that is what the risk
164
+ * is actually made of: an idle hour costs nothing, and twenty minutes of hard
165
+ * work costs everything not written down.
166
+ */
167
+ const HANDOVER_REASK_K = 120;
168
+ /**
169
+ * A floor under re-asking, so growth cannot trigger a stream of requests.
170
+ *
171
+ * Writing a handover itself consumes context, so without this a session near
172
+ * the wall could be asked again almost immediately on the strength of the
173
+ * growth its own last handover caused.
174
+ */
175
+ const HANDOVER_MIN_GAP_MS = 8 * 60_000;
176
+ /**
177
+ * Input the terminal is holding rather than running.
178
+ *
179
+ * A `/clear` typed into a session that is mid-turn does not execute; it waits,
180
+ * and the terminal says so. Seeing this means the clear has NOT landed however
181
+ * fresh the rest of the screen looks, so it vetoes the banner test — a banner
182
+ * still on screen from a session's own start would otherwise be read as proof
183
+ * of a clear that is still sitting in the queue.
184
+ */
185
+ const QUEUED_INPUT = /queued message/i;
63
186
  /** Verdicts that mean the session has run out of goal and said so. */
64
187
  const OUT_OF_GOAL = [
65
188
  /goal could not be achieved/i,
@@ -189,6 +312,133 @@ function mirrorToRepo(m) {
189
312
  log(`[manage] could not mirror into the repository — ${e.message}`);
190
313
  }
191
314
  }
315
+ /**
316
+ * A cheap fingerprint of a file, or "" if it is not there.
317
+ *
318
+ * Size and modification time rather than a hash: this runs every twenty seconds
319
+ * against a file that may be hundreds of kilobytes, and the question is only
320
+ * "did it change", which those two answer without reading anything. An absent
321
+ * file fingerprints as empty so that CREATING one counts as a change — the
322
+ * first handover a session ever writes is exactly the case a naive comparison
323
+ * would miss.
324
+ */
325
+ function fileFingerprint(path) {
326
+ try {
327
+ const s = statSync(path);
328
+ return `${s.size}:${Math.round(s.mtimeMs)}`;
329
+ }
330
+ catch {
331
+ return "";
332
+ }
333
+ }
334
+ /** This session's context in thousands of tokens, from its own transcript. */
335
+ function contextK(m) {
336
+ const tty = m.tty ?? snapshotTty(m.sessionId);
337
+ const pid = tty ? processReading(tty).pid : null;
338
+ return pid ? transcriptReading(pid).contextK : null;
339
+ }
340
+ /**
341
+ * Close a rollover out, however it ended, and guarantee an arming follows.
342
+ *
343
+ * EVERY exit from a rollover goes through here, which is the point. When the
344
+ * clearing of this state and the scheduling of the next arming are separate
345
+ * acts at separate call sites, some branch eventually does the first without
346
+ * the second — and that branch leaves a cleared session sitting at an empty
347
+ * prompt, reporting "working", for as long as the ordinary arming rules take to
348
+ * notice. Binding the two together makes that combination unwriteable.
349
+ *
350
+ * `lastRearmAt = 0` is the sentinel that says arm on the next tick regardless
351
+ * of the on-screen goal marker. That is not a shortcut: after a clear the
352
+ * marker is a leftover from a screen that no longer exists, so the one signal
353
+ * that would hold the arming back is also the one signal guaranteed to be
354
+ * stale.
355
+ */
356
+ function endRollover(m) {
357
+ delete m.handoverAskedAt;
358
+ delete m.handoverWas;
359
+ delete m.clearTypedAt;
360
+ delete m.contextAtClear;
361
+ // clearPendingSince deliberately survives: it records a clear that is still
362
+ // out there somewhere, and forgetting it is what let a second one be typed.
363
+ m.lastRearmAt = 0;
364
+ }
365
+ /** A clear was seen to land. Forget it, and let rollovers happen again. */
366
+ function clearLanded(m) {
367
+ delete m.clearPendingSince;
368
+ endRollover(m);
369
+ }
370
+ /**
371
+ * Is there text sitting unsent in the session's prompt?
372
+ *
373
+ * TYPING ON TOP OF IT DESTROYS IT. The manager pastes into the same input line
374
+ * a person types into, and it sends a backspace first to escape vi normal
375
+ * mode — so an objective armed over half-typed text eats a character of that
376
+ * text and then runs the two together as one prompt. The operator's sentence
377
+ * and the standing objective arrive merged and mangled, and neither does what
378
+ * it meant to.
379
+ *
380
+ * This never bit while nobody was at the keyboard, which is exactly the kind of
381
+ * assumption that holds until an operator sits down at seven in the morning and
382
+ * starts a sentence.
383
+ *
384
+ * The live input line is the one enclosed by the terminal's rules at the foot
385
+ * of the pane, not the `❯` lines further up — those are scrollback, commands
386
+ * that already ran. So the rule immediately above it is what identifies it.
387
+ */
388
+ export function promptUnsentText(content) {
389
+ const lines = content.split("\n");
390
+ for (let i = 1; i < lines.length; i++) {
391
+ // The rule may carry a title — "──── Name ──" — so match its start, not
392
+ // the whole line. Requiring the run of box-drawing characters up front
393
+ // keeps this from matching prose that happens to begin with a dash.
394
+ const isRuleAbove = /^\s*[─—-]{10,}/.test(lines[i - 1]);
395
+ if (!isRuleAbove)
396
+ continue;
397
+ const m = lines[i].match(/^\s*❯\s*(.*)$/);
398
+ if (!m)
399
+ continue;
400
+ const typed = m[1].trim();
401
+ // The terminal's own hint about held input is not the operator's text.
402
+ if (!typed || /^press up to edit/i.test(typed))
403
+ continue;
404
+ return typed;
405
+ }
406
+ return null;
407
+ }
408
+ export function promptHasUnsentText(content) {
409
+ return promptUnsentText(content) !== null;
410
+ }
411
+ /**
412
+ * Expand the date tokens in a handover path, against the clock right now.
413
+ *
414
+ * Deliberately resolved at the moment of use. A managed session is meant to
415
+ * outlive the day it started on, so any date fixed at the moment the path was
416
+ * SET is a date that will be wrong by morning — and wrong in the quietest way,
417
+ * since the file it names still exists and still opens.
418
+ *
419
+ * Exported for the tests, which is also where the accepted tokens are pinned.
420
+ */
421
+ export function resolveHandoverPath(template, at = new Date()) {
422
+ const p = (n) => String(n).padStart(2, "0");
423
+ const yyyy = String(at.getFullYear());
424
+ const mm = p(at.getMonth() + 1);
425
+ const dd = p(at.getDate());
426
+ return template
427
+ .replace(/\{date\}/gi, `${yyyy}-${mm}-${dd}`)
428
+ .replace(/\{yyyy\}/gi, yyyy)
429
+ .replace(/\{mm\}/gi, mm)
430
+ .replace(/\{dd\}/gi, dd);
431
+ }
432
+ /** Did the handover actually land? Read the file; do not believe the session. */
433
+ function handoverChanged(m) {
434
+ // The path asked for, not the template resolved afresh: if the clock crossed
435
+ // midnight between the request and this check, resolving again would compare
436
+ // a file nobody was asked to write against a fingerprint taken from another.
437
+ const path = m.handoverAskedPath ?? (m.handoverFile ? resolveHandoverPath(m.handoverFile) : undefined);
438
+ if (!path)
439
+ return false;
440
+ return fileFingerprint(path) !== (m.handoverWas ?? "");
441
+ }
192
442
  /** The pane device for a session, captured once at start. */
193
443
  function snapshotTty(sessionId) {
194
444
  return discoverLiveSessions().find((s) => s.id === sessionId)?.tty;
@@ -234,6 +484,114 @@ function note(m, what) {
234
484
  m.history = m.history.slice(-40);
235
485
  log(`[manage:${m.name}] ${what}`);
236
486
  }
487
+ /**
488
+ * Tell the operator something happened, wherever they are.
489
+ *
490
+ * Goes out unconditionally, because the normal path already does the right
491
+ * thing with an absent phone: it pushes over APNs and queues for catch-up. An
492
+ * alert is a MESSAGE — a rollover, a dead session — and a message is still
493
+ * worth reading an hour after it was sent, which is precisely what the queue is
494
+ * for. Gating this on a live connection would mean the events worth waking
495
+ * someone for are the ones only delivered when they were already watching.
496
+ *
497
+ * Failure is swallowed on purpose. The manager's job is keeping a session
498
+ * working; it must not stop doing that because a notification did not go out.
499
+ */
500
+ function alertOperator(text) {
501
+ try {
502
+ getAibpBridge()?.routeToMobile("", text, "TEXT");
503
+ }
504
+ catch (e) {
505
+ log(`[manage] could not reach the phone — ${e.message}`);
506
+ }
507
+ }
508
+ /**
509
+ * The periodic reading — sent only if someone is actually looking.
510
+ *
511
+ * The opposite call from an alert, for the opposite kind of content. "Armed
512
+ * twice, 400k context" is worth knowing at the time and worth nothing four
513
+ * hours later, so queueing it would mean picking the phone up to a stack of
514
+ * expired weather reports with the one that mattered somewhere inside. No app
515
+ * connected, no report, and nothing kept to deliver later.
516
+ */
517
+ function reportToOperator(text) {
518
+ if (!hasPailotClients())
519
+ return;
520
+ alertOperator(text);
521
+ }
522
+ /**
523
+ * A note that is ALSO worth a buzz on the phone.
524
+ *
525
+ * The line between this and `note` is who caused the event. Anything the
526
+ * operator just did — set an objective, take the screen back, start managing —
527
+ * is recorded and not sent, because telling someone what they themselves just
528
+ * typed is how a notification channel teaches its reader to ignore it. What
529
+ * gets sent is what the MANAGER decided on its own while nobody was watching:
530
+ * a rollover, a session that died, a goal that would not land.
531
+ *
532
+ * Routine armings are not here. They are real, and frequent, and belong in the
533
+ * periodic report where they arrive as a count instead of sixteen buzzes.
534
+ */
535
+ function notify(m, what) {
536
+ note(m, what);
537
+ alertOperator(`${m.name} — ${what}`);
538
+ }
539
+ /**
540
+ * How often the operator hears from the manager when nothing is wrong.
541
+ *
542
+ * Long, because the report competes with the alerts for the same attention: a
543
+ * channel that speaks every few minutes about nothing is one whose alerts get
544
+ * swiped away unread. Half an hour is roughly "next time you glance at it".
545
+ */
546
+ const REPORT_EVERY_MS = 30 * 60_000;
547
+ /** When the operator was last told the state of things. */
548
+ let lastReportAt = 0;
549
+ /**
550
+ * Armings since the last report, per session. NOT persisted, on purpose — the
551
+ * count answers "how much has the manager had to intervene lately", and a
552
+ * figure carried across a daemon restart would answer a different question
553
+ * while looking like that one.
554
+ */
555
+ const armingsSinceReport = new Map();
556
+ /**
557
+ * The periodic reading: what every managed session is doing, in one message.
558
+ *
559
+ * One message for all of them rather than one each, because the useful thing on
560
+ * a phone is a page you take in at a glance, and armings across several
561
+ * sessions are the same event happening in several places.
562
+ */
563
+ function reportIfDue(now) {
564
+ const managed = Object.values(state);
565
+ // Nothing is being managed, so there is nothing to report and the clock is
566
+ // held at now — otherwise a report would be overdue the moment one starts.
567
+ if (managed.length === 0) {
568
+ lastReportAt = now;
569
+ return;
570
+ }
571
+ // First tick after a restart. Start the clock rather than reporting, so
572
+ // restarting the daemon is not itself a reason for the phone to buzz.
573
+ if (lastReportAt === 0) {
574
+ lastReportAt = now;
575
+ return;
576
+ }
577
+ if (now - lastReportAt < REPORT_EVERY_MS)
578
+ return;
579
+ lastReportAt = now;
580
+ const mins = Math.round(REPORT_EVERY_MS / 60_000);
581
+ const lines = managed.map((m) => {
582
+ const armings = armingsSinceReport.get(m.sessionId) ?? 0;
583
+ armingsSinceReport.set(m.sessionId, 0);
584
+ const k = contextK(m);
585
+ const quietFor = Math.round((now - m.lastChangeAt) / 60_000);
586
+ const doing = m.paused ? "paused" : quietFor >= 2 ? `quiet for ${quietFor} min` : "working";
587
+ const last = m.history.at(-1);
588
+ return (`• ${m.name} — ${doing}` +
589
+ (k !== null ? `, ${k}k context` : "") +
590
+ `, ${armings} arming${armings === 1 ? "" : "s"} in ${mins} min` +
591
+ (last ? `\n last: ${last.what.slice(0, 110)}` : ""));
592
+ });
593
+ reportToOperator(`Manager report\n${lines.join("\n")}`);
594
+ }
237
595
  /**
238
596
  * Resolve a session by whatever the caller knows — its id, or its name.
239
597
  *
@@ -484,6 +842,36 @@ function paneReading(content) {
484
842
  ].filter(Boolean);
485
843
  return ` ${parts.join(" · ")}${doing ? `\n doing: ${doing.trim().slice(0, 110)}` : ""}`;
486
844
  }
845
+ /**
846
+ * Collapse text to a single line, for anything about to be TYPED at a prompt.
847
+ *
848
+ * At a prompt a newline is the submit key, so a multi-line objective does not
849
+ * arrive as a long goal — it arrives as a short one, followed by its own
850
+ * remainder as a second, contextless prompt, and the session acts on both. The
851
+ * damage is silent: what was sent looks right in the state file and wrong only
852
+ * on screen.
853
+ *
854
+ * Applied at the point text becomes keystrokes rather than at each point text
855
+ * is set, so it covers every route in — shell heredoc, MCP call, appended
856
+ * text, operator notes — including the ones added later.
857
+ */
858
+ export function oneLine(text) {
859
+ return text.replace(/\s*[\r\n]+\s*/g, " ").trim();
860
+ }
861
+ /**
862
+ * When a change to the objective actually reaches the session.
863
+ *
864
+ * Printed because the answer is "not yet", and that has already been misread as
865
+ * the change having failed. Editing an objective types nothing at the session;
866
+ * it changes what the NEXT arming says, and arming waits for the session to
867
+ * stop. Saying so costs one line and removes the whole question.
868
+ */
869
+ function landsWhen(m) {
870
+ if (m.paused)
871
+ return " The manager is paused — nothing is armed until you resume it.";
872
+ return (" This changes what the next arming says; it types nothing now.\n" +
873
+ " The next arming comes when the session stops — or immediately, with `now`.");
874
+ }
487
875
  /** The text actually typed at the session. Short goal, context by reference. */
488
876
  function goalText(m) {
489
877
  const extra = m.pending.length ? ` OPERATOR, since you were last armed: ${m.pending.join(" ")}` : "";
@@ -494,7 +882,7 @@ function goalText(m) {
494
882
  const hands = m.noScreen
495
883
  ? " THE OPERATOR HAS THE SCREEN: do no screen or pointer work at all, and do not ask for it. Everything else continues as normal. Where something would need checking on screen, write down what would need checking instead of checking it."
496
884
  : "";
497
- return `/goal ${m.objective}${hands}${extra}`;
885
+ return oneLine(`/goal ${m.objective}${hands}${extra}`);
498
886
  }
499
887
  /**
500
888
  * Did it land? Look for the goal's own words in the transcript.
@@ -547,9 +935,28 @@ async function arm(m, reason) {
547
935
  }
548
936
  if (live.atPrompt) {
549
937
  m.paused = true;
550
- note(m, "PAUSED — that pane is at a shell prompt, so the session has exited. Not typing a goal into a shell. `resume` once it is back.");
938
+ notify(m, "PAUSED — that pane is at a shell prompt, so the session has exited. Not typing a goal into a shell. `resume` once it is back.");
551
939
  return false;
552
940
  }
941
+ /**
942
+ * TEXT ON THE INPUT LINE IS NOTED, NEVER OBEYED.
943
+ *
944
+ * This used to refuse to arm while anything sat unsent in the prompt, to
945
+ * avoid running the manager's goal into a half-typed sentence. The intention
946
+ * was right and the mechanism could not support it: the terminal offers a
947
+ * greyed-out SUGGESTION on that same line, accepted with Tab, and in a
948
+ * captured pane no colour survives to tell the two apart. So a suggestion
949
+ * read as somebody mid-sentence, and since a suggestion never finishes being
950
+ * typed, the refusal never lifted. A session sat idle with its goal spent and
951
+ * its work unfinished while every log line reported the guard working.
952
+ *
953
+ * Arming is the one thing that must not be blocked by a signal this weak. A
954
+ * stalled agent is certain and unbounded; running into somebody's half-typed
955
+ * line is occasional and costs one prompt they can retype. So the reading is
956
+ * kept — it is worth having in the record when a goal arrives mangled — and
957
+ * it decides nothing.
958
+ */
959
+ const onLine = promptUnsentText(readPane(m.sessionId));
553
960
  if (!typeIntoSession(m.sessionId, text)) {
554
961
  note(m, `could not type into the session (${reason}) — will retry`);
555
962
  return false;
@@ -561,11 +968,15 @@ async function arm(m, reason) {
561
968
  m.lastRearmAt = Date.now();
562
969
  const carried = m.pending.length;
563
970
  m.pending = [];
564
- note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
971
+ armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
972
+ note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}` +
973
+ // Recorded because it is the one thing that explains a goal arriving
974
+ // with somebody's half-sentence welded to the front of it.
975
+ (onLine ? ` — the input line held "${onLine.slice(0, 60)}" when this went in` : ""));
565
976
  return true;
566
977
  }
567
978
  }
568
- note(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
979
+ notify(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
569
980
  return false;
570
981
  }
571
982
  function reasonToArm(m, content, now) {
@@ -584,17 +995,91 @@ function reasonToArm(m, content, now) {
584
995
  const marker = GOAL_ACTIVE.test(content);
585
996
  if (!marker && quietFor > NO_GOAL_GRACE_MS)
586
997
  return `no goal armed (idle ${Math.round(quietFor / 1000)}s)`;
587
- // The ceiling. Without it a stale marker strands the loop indefinitely while
588
- // every log line reads healthy — which is what a stalled loop looks like from
589
- // outside, and is why this exists rather than trusting the marker.
590
- if (armedFor > GOAL_MAX_AGE_MS) {
591
- return `armed ${minutesSince(m.lastRearmAt, now)} with no sign of a new goal — assuming it lapsed`;
998
+ /**
999
+ * The ceiling — but only over a session that has gone quiet.
1000
+ *
1001
+ * It exists because the on-screen marker lingers after a goal is met, so a
1002
+ * session that finished long ago can look armed forever. What it must not do
1003
+ * is fire over a session that is plainly still working, and it did: a
1004
+ * six-hour turn crosses the ceiling every forty-five minutes, so the standing
1005
+ * objective was re-typed into a session far past the point it describes.
1006
+ *
1007
+ * That is not the harmless duplicate it first appears. An objective is
1008
+ * usually written as a starting instruction — go through all of X, sort them,
1009
+ * begin — and delivering it to a session deep in the work reads as an
1010
+ * instruction to start over. The manager's own recovery mechanism becomes the
1011
+ * thing that undoes the work.
1012
+ *
1013
+ * A moving pane is direct evidence the session is engaged, and evidence beats
1014
+ * the inference drawn from a timer. So the ceiling now needs both: the goal
1015
+ * looks old AND nothing is happening.
1016
+ */
1017
+ if (armedFor > GOAL_MAX_AGE_MS && quietFor > NO_GOAL_GRACE_MS) {
1018
+ return `armed ${minutesSince(m.lastRearmAt, now)} with no sign of a new goal, and quiet for ${Math.round(quietFor / 1000)}s — assuming it lapsed`;
592
1019
  }
593
1020
  return null;
594
1021
  }
1022
+ /**
1023
+ * A file whose modification time proves the manager loop is still turning.
1024
+ *
1025
+ * The process being alive is NOT the same claim, and only the weaker one is
1026
+ * observable from outside: a wedged loop inside a healthy process satisfies
1027
+ * launchd, answers the socket, and manages nothing. Whatever supervises this
1028
+ * from outside needs a fact that only a completed tick can produce, so each
1029
+ * tick stamps one.
1030
+ *
1031
+ * Written every tick rather than on change, because "nothing changed" is a
1032
+ * normal and frequent outcome here — a heartbeat that stops during quiet
1033
+ * periods reports the healthy case as a failure.
1034
+ */
1035
+ const HEARTBEAT_FILE = join(homedir(), ".aibroker", "manage-heartbeat");
1036
+ function beat() {
1037
+ try {
1038
+ writeFileSync(HEARTBEAT_FILE, String(Date.now()));
1039
+ }
1040
+ catch {
1041
+ // A heartbeat that cannot be written must not take the manager down with
1042
+ // it; the supervisor treats silence as a stall, which is the safe reading.
1043
+ }
1044
+ }
1045
+ /**
1046
+ * Look for blocking modals, but not on every tick.
1047
+ *
1048
+ * The check costs an AppleScript round trip, and the machine running these
1049
+ * sessions is often the machine they are driving — the same contention that
1050
+ * makes a pane read slow. Once a minute is far faster than a person noticing,
1051
+ * and cheap enough to leave running forever.
1052
+ */
1053
+ const DIALOG_EVERY_TICKS = 3;
1054
+ let tickCount = 0;
1055
+ function answerBlockingDialogs() {
1056
+ if (Object.keys(state).length === 0)
1057
+ return;
1058
+ for (const d of listDialogs()) {
1059
+ const pressed = answerDialog(d);
1060
+ if (pressed) {
1061
+ log(`[dialogs] pressed "${pressed}" on ${d.process} — ${d.title}`);
1062
+ alertOperator(`A system dialog was blocking work — pressed "${pressed}" on ${d.process}${d.title ? ` (${d.title})` : ""}.`);
1063
+ }
1064
+ else {
1065
+ // Unrecognised prompt: say so and leave it. A dialog nobody can answer
1066
+ // safely still needs somebody told, or it blocks the night in silence.
1067
+ alertOperator(`A dialog from ${d.process} is on screen and I will not answer it — buttons: ${d.buttons.join(", ") || "none readable"}${d.title ? ` — "${d.title}"` : ""}.`);
1068
+ }
1069
+ }
1070
+ }
595
1071
  async function tick() {
596
1072
  const now = Date.now();
597
1073
  let dirty = false;
1074
+ beat();
1075
+ if (++tickCount % DIALOG_EVERY_TICKS === 0) {
1076
+ try {
1077
+ answerBlockingDialogs();
1078
+ }
1079
+ catch (e) {
1080
+ log(`[dialogs] check failed — ${e.message}`);
1081
+ }
1082
+ }
598
1083
  for (const m of Object.values(state)) {
599
1084
  const content = readPane(m.sessionId);
600
1085
  if (!content) {
@@ -609,6 +1094,30 @@ async function tick() {
609
1094
  m.lastChangeAt = now;
610
1095
  dirty = true;
611
1096
  }
1097
+ /**
1098
+ * THE BACKSTOP: a managed session whose screen has not moved in a long time.
1099
+ *
1100
+ * Every specific fault above is a fault somebody already thought of. This
1101
+ * one is for the faults nobody has thought of yet, and it is deliberately
1102
+ * ignorant of causes: it does not care whether a rollover misfired, a goal
1103
+ * failed to land, a clear ate the prompt, or something new. It knows only
1104
+ * that a session under management has shown no sign of life for a long
1105
+ * time, which is never a state worth preserving.
1106
+ *
1107
+ * Arming is the response because arming is the cheap direction to be wrong
1108
+ * in. Against a genuinely busy session it queues one prompt behind a long
1109
+ * turn, costing nothing; against a dead one it is the whole recovery. The
1110
+ * asymmetry is the argument — and it is why this fires on a signal as crude
1111
+ * as "nothing changed", which no more precise test would improve on.
1112
+ */
1113
+ if (!m.paused && !m.handoverAskedAt && now - m.lastChangeAt > STUCK_AFTER_MS) {
1114
+ notify(m, `nothing has moved on that screen for ${minutesSince(m.lastChangeAt, now)} — arming, because a managed session is never meant to be still this long`);
1115
+ // Counted as a change so a session that stays stuck is not re-armed every
1116
+ // tick: this is a recovery, and a recovery that repeats is a loop.
1117
+ m.lastChangeAt = now;
1118
+ m.lastRearmAt = 0;
1119
+ dirty = true;
1120
+ }
612
1121
  /**
613
1122
  * A timed screen decision reverts itself.
614
1123
  *
@@ -624,19 +1133,281 @@ async function tick() {
624
1133
  typeIntoSession(m.sessionId, m.noScreen
625
1134
  ? "The time you had the screen for is up — my controls. The operator may be back at the machine, so stop screen and pointer work now, write down how far you got and what still needs checking on screen, and carry on with everything that does not need it."
626
1135
  : "your controls. The screen is yours again — the operator's hold has expired. You may resume visual work where your notes left it.");
627
- note(m, m.noScreen ? "timed grant expired — screen work stopped" : "timed hold expired — screen work permitted again");
1136
+ notify(m, m.noScreen ? "timed grant expired — screen work stopped" : "timed hold expired — screen work permitted again");
628
1137
  dirty = true;
629
1138
  }
1139
+ /**
1140
+ * A clear that was typed earlier, landing late.
1141
+ *
1142
+ * Kept OUTSIDE the rollover block because that block is no longer running
1143
+ * by the time this usually happens: the rollover gave up waiting, the
1144
+ * session stayed in its turn for another half hour, and the clear finally
1145
+ * executed with nothing left watching for it. Without this, the pending
1146
+ * flag would never be lifted and the session could never roll over again —
1147
+ * a safety catch that, having done its job once, quietly became a lock.
1148
+ */
1149
+ if (m.clearPendingSince && !m.clearTypedAt) {
1150
+ if (CLEARED_BANNER.test(content) && !QUEUED_INPUT.test(content)) {
1151
+ notify(m, `the clear typed ${minutesSince(m.clearPendingSince, now)} ago has landed — arming the fresh session`);
1152
+ clearLanded(m);
1153
+ dirty = true;
1154
+ }
1155
+ }
1156
+ /**
1157
+ * ROLLING OVER BEFORE THE WALL.
1158
+ *
1159
+ * A session that fills its context does not degrade gracefully; it starts
1160
+ * losing the thread while still appearing to work, which is the worst of
1161
+ * both — it is producing output nobody should trust. So at a threshold it
1162
+ * is asked to write down what it knows, and only once that is ON DISK is it
1163
+ * cleared and re-armed.
1164
+ *
1165
+ * THE ORDER IS THE WHOLE DESIGN, and the previous attempt at this got it
1166
+ * wrong: it cleared first and deleted the very file it was meant to
1167
+ * preserve. Nothing here deletes anything, the handover is verified by
1168
+ * reading the file back rather than by the session saying it wrote one, and
1169
+ * a session that does not produce a handover is left alone rather than
1170
+ * cleared. Losing a cycle is recoverable; clearing an unrecorded session is
1171
+ * not.
1172
+ */
1173
+ if (!m.paused && m.handoverAskedAt) {
1174
+ const wrote = handoverChanged(m);
1175
+ if (wrote && !m.clearAfterHandover) {
1176
+ /**
1177
+ * HANDOVER WRITTEN, AND THAT IS THE WHOLE JOB.
1178
+ *
1179
+ * Clearing used to follow automatically and it was the wrong half of
1180
+ * the idea. A clear cannot execute while a turn is running, and a
1181
+ * session working towards a goal does not end its turn — so the clear
1182
+ * waited in the input queue, and every fresh attempt added another,
1183
+ * until a queue of them stood ready to fire in sequence against
1184
+ * whatever sessions happened to exist by then.
1185
+ *
1186
+ * Meanwhile the thing it was protecting against turned out to be
1187
+ * handled: the terminal compacts by itself at the limit and the
1188
+ * session carries on working through it. What compaction costs is
1189
+ * detail, and detail is exactly what the handover has already written
1190
+ * to disk. So the valuable half runs and the dangerous half does not,
1191
+ * unless somebody asks for it by name.
1192
+ */
1193
+ notify(m, "handover written — leaving the session to compact on its own rather than clearing it");
1194
+ m.handoverDoneAt = now;
1195
+ m.handoverDoneK = contextK(m) ?? undefined;
1196
+ delete m.handoverAskedAt;
1197
+ delete m.handoverWas;
1198
+ delete m.handoverAskedPath;
1199
+ dirty = true;
1200
+ continue;
1201
+ }
1202
+ if (wrote) {
1203
+ /**
1204
+ * A CLEAR CANNOT LAND WHILE A GOAL IS ARMED.
1205
+ *
1206
+ * Observed rather than reasoned: the goal enforcement blocks the turn
1207
+ * from ending, the terminal will not read queued input until the turn
1208
+ * ends, and so `/clear` sits in the input line indefinitely while the
1209
+ * session repeats that it has nothing to add. The blocker does give up
1210
+ * after several attempts, which is why this waits rather than retries.
1211
+ *
1212
+ * TYPING IT AGAIN IS THE WRONG MOVE and the tempting one: a second
1213
+ * `/clear` queues behind the first and fires afterwards, against the
1214
+ * FRESH context — wiping the very session that just started. So this
1215
+ * types once, then watches the context figure, which is the artefact.
1216
+ */
1217
+ if (!m.clearTypedAt) {
1218
+ if (m.clearPendingSince && now - m.clearPendingSince < CLEAR_PENDING_MAX_MS) {
1219
+ // An earlier clear is still unaccounted for. Typing another would
1220
+ // put two in a queue that fires against two different sessions.
1221
+ notify(m, `a clear typed ${minutesSince(m.clearPendingSince, now)} ago has still not landed — not typing another, and not rolling over again until it does`);
1222
+ endRollover(m);
1223
+ dirty = true;
1224
+ continue;
1225
+ }
1226
+ notify(m, "handover written — asking it to clear");
1227
+ typeIntoSession(m.sessionId, "/clear");
1228
+ m.clearTypedAt = now;
1229
+ m.clearPendingSince = now;
1230
+ m.contextAtClear = contextK(m) ?? undefined;
1231
+ dirty = true;
1232
+ continue;
1233
+ }
1234
+ /**
1235
+ * DID THE CLEAR LAND? ASK THE SCREEN, NOT ONLY THE NUMBER.
1236
+ *
1237
+ * The context figure was the sole test and it failed in the one way
1238
+ * that mattered: `contextK` can return null — the pane's pid, and
1239
+ * thence its transcript, is not always resolvable — and a null at the
1240
+ * moment the clear was typed leaves `contextAtClear` undefined, which
1241
+ * makes the drop test unsatisfiable FOREVER AFTER. Not flaky: a
1242
+ * rollover begun during that blind moment could never be seen to
1243
+ * finish, however cleanly it did.
1244
+ *
1245
+ * So the pane corroborates, via the startup banner — see
1246
+ * CLEARED_BANNER for why that particular mark and not the more obvious
1247
+ * one. Either witness alone is enough; neither is trusted to be
1248
+ * available.
1249
+ */
1250
+ /**
1251
+ * THE BANNER IS THE PROOF. THE NUMBER IS ONLY THE DETAIL.
1252
+ *
1253
+ * A falling context figure was the original test and it cannot do the
1254
+ * job, because a clear is not the only thing that empties a context:
1255
+ * the terminal compacts on its own near the limit, and compaction
1256
+ * produces exactly the same collapse in the same figure. Believing it
1257
+ * would mean declaring a clear that never happened, dropping the guard
1258
+ * that stops another being typed, and arming a session that is still
1259
+ * mid-turn with clears queued behind it.
1260
+ *
1261
+ * Compaction redraws no banner. Only starting and clearing do, and
1262
+ * inside this window only clearing is possible — so the banner alone
1263
+ * decides, and the number is reported beside it because it is useful
1264
+ * to read, not because it is being trusted.
1265
+ */
1266
+ const nowK = contextK(m);
1267
+ const fell = m.contextAtClear !== undefined && nowK !== null && nowK < m.contextAtClear / 2;
1268
+ if (CLEARED_BANNER.test(content) && !QUEUED_INPUT.test(content)) {
1269
+ notify(m, fell
1270
+ ? `cleared — fresh session on the pane, context fell from ${m.contextAtClear}k to ${nowK}k; re-arming`
1271
+ : "cleared — the pane is showing a fresh session; re-arming");
1272
+ clearLanded(m);
1273
+ dirty = true;
1274
+ continue;
1275
+ }
1276
+ if (now - m.clearTypedAt > HANDOVER_GRACE_MS) {
1277
+ /**
1278
+ * GIVING UP ON THE ROLLOVER IS NOT GIVING UP ON THE SESSION.
1279
+ *
1280
+ * This is where eight hours went. The branch was right to refuse a
1281
+ * SECOND clear — that would fire against a fresh context and wipe
1282
+ * it — but it also declined to arm, and those are different acts. It
1283
+ * then left `lastRearmAt` untouched, so ordinary arming stayed
1284
+ * blocked behind the stale on-screen goal marker until the 45-minute
1285
+ * ceiling expired. A cleared session sat at an empty prompt for the
1286
+ * whole of it, reading as "working" the entire time.
1287
+ *
1288
+ * Arming is safe under BOTH readings of an ambiguous outcome. If the
1289
+ * clear did land, arming is exactly what the fresh session needs. If
1290
+ * it did not, arming re-states the objective to a session that still
1291
+ * has its context, which costs one prompt. There is no reading in
1292
+ * which doing nothing is the better move, so this no longer does
1293
+ * nothing.
1294
+ */
1295
+ notify(m, "the clear was typed and could not be confirmed — NOT typing a second one, " +
1296
+ "but arming anyway: an armed session is safe whether or not the clear landed");
1297
+ endRollover(m);
1298
+ dirty = true;
1299
+ }
1300
+ continue;
1301
+ }
1302
+ if (now - m.handoverAskedAt > HANDOVER_GRACE_MS) {
1303
+ notify(m, "asked for a handover and did not get one — NOT clearing, the session keeps its context");
1304
+ delete m.handoverAskedAt;
1305
+ delete m.handoverWas;
1306
+ dirty = true;
1307
+ }
1308
+ // Still waiting. Do not arm anything on top of a session that is writing.
1309
+ continue;
1310
+ }
1311
+ // No new rollover while a clear is still unaccounted for. Context stays
1312
+ // high precisely because the clear has not landed, so without this the
1313
+ // threshold re-qualifies the session every tick and the rollover machinery
1314
+ // runs in a circle, each lap adding another clear to the queue.
1315
+ if (!m.paused && !m.handoverAskedAt && !m.clearPendingSince && m.handoverFile) {
1316
+ // The pane is resolved here rather than carried in from elsewhere in the
1317
+ // tick, so this block does not depend on the order of what precedes it.
1318
+ const tty = m.tty ?? snapshotTty(m.sessionId);
1319
+ const pid = tty ? processReading(tty).pid : null;
1320
+ const t = pid ? transcriptReading(pid) : null;
1321
+ const used = t?.contextK ?? null;
1322
+ /**
1323
+ * TWO WAYS TO BECOME DUE, because a handover goes out of date two ways.
1324
+ *
1325
+ * By the clock, which is the ordinary case. And by work done since the
1326
+ * last one, which is the case that mattered and was missing: a session
1327
+ * asked at the threshold keeps working to the wall, and everything it
1328
+ * learns in that stretch is absent from the file precisely when
1329
+ * compaction discards it. The second trigger keeps the document current
1330
+ * with the work rather than with the hour.
1331
+ */
1332
+ const sinceLast = now - (m.handoverDoneAt ?? 0);
1333
+ const grownBy = used !== null && m.handoverDoneK !== undefined ? used - m.handoverDoneK : null;
1334
+ const dueByTime = sinceLast > HANDOVER_REASK_MS;
1335
+ const dueByWork = grownBy !== null && grownBy >= HANDOVER_REASK_K && sinceLast > HANDOVER_MIN_GAP_MS;
1336
+ // 1M is the window these sessions run in; treat anything else as unknown
1337
+ // rather than guessing, because a wrong denominator rolls over a session
1338
+ // that had plenty of room left.
1339
+ if ((dueByTime || dueByWork) && used !== null && used / 1000 >= HANDOVER_AT) {
1340
+ const askedPath = resolveHandoverPath(m.handoverFile);
1341
+ m.handoverAskedAt = now;
1342
+ m.handoverAskedPath = askedPath;
1343
+ m.handoverWas = fileFingerprint(askedPath);
1344
+ // A dated handover starts empty each day, and an empty one is worse
1345
+ // than none: it reads as authoritative and says nothing. So the
1346
+ // instruction carries the rule for that case rather than assuming the
1347
+ // session will think of it at the moment it is running out of room.
1348
+ const carry = existsSync(askedPath)
1349
+ ? ""
1350
+ : `That file does not exist yet — start it by carrying forward from the most recent handover beside it whatever still matters, especially anything written nowhere else. `;
1351
+ // A top-up reads differently from a first request: the session has
1352
+ // already written one and needs to know this is about the work SINCE,
1353
+ // not a repeat it can satisfy by confirming the file is still there.
1354
+ const topUp = dueByWork && !dueByTime && grownBy !== null;
1355
+ typeIntoSession(m.sessionId, (topUp
1356
+ ? `Bring your handover up to date — you are at ${used}k tokens, ${grownBy}k of work since you last wrote it, and the terminal will compact before long. Everything you have learned in that stretch is currently written nowhere but this context, which is the part compaction takes. `
1357
+ : `Write your handover now — you are at ${used}k tokens and the terminal will compact before long. `) +
1358
+ `Update ${askedPath}. ${carry}Three things: where the current item stands, what you would do next and why, ` +
1359
+ `and — the irreplaceable part — anything you know that is written nowhere else. Commit it. ` +
1360
+ (m.clearAfterHandover
1361
+ ? `You will be cleared once that file has changed on disk, and not before.`
1362
+ : `Then carry straight on with the work; you are not being cleared. And keep that file current as you go — anything you work out after writing it is at risk until it is on disk.`));
1363
+ notify(m, topUp
1364
+ ? `at ${used}k tokens, ${grownBy}k of new work since the last one — asked to bring the handover up to date`
1365
+ : `at ${used}k tokens — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}`);
1366
+ dirty = true;
1367
+ continue;
1368
+ }
1369
+ }
630
1370
  if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
631
1371
  continue;
632
1372
  const reason = reasonToArm(m, content, now);
633
1373
  if (!reason)
634
1374
  continue;
635
- await arm(m, reason);
1375
+ /**
1376
+ * A GOAL THAT CANNOT BE CONFIRMED IS NOT RETRIED FOREVER.
1377
+ *
1378
+ * Arming types the objective and then reads it back off the pane; when the
1379
+ * read fails the attempt is honestly recorded as not armed, and the
1380
+ * arm-now sentinel stays set so the next tick tries again. That is right
1381
+ * once and wrong indefinitely: a session busy in a long turn queues the
1382
+ * text instead of showing it, so the read keeps failing while every
1383
+ * attempt adds another copy of the goal to its input queue. Three of them
1384
+ * waiting to fire in sequence is the same fault the clears had, reached by
1385
+ * a different road.
1386
+ *
1387
+ * So the retries are counted and stopped. Backing off restores the
1388
+ * ordinary rules — the session is still managed, and the next genuine
1389
+ * lapse arms it — and the operator is told, because a manager that has
1390
+ * given up on delivering a goal is the one thing it must never do quietly.
1391
+ */
1392
+ const armed = await arm(m, reason);
636
1393
  dirty = true;
1394
+ if (armed) {
1395
+ m.armFails = 0;
1396
+ continue;
1397
+ }
1398
+ m.armFails = (m.armFails ?? 0) + 1;
1399
+ if (m.armFails >= ARM_ATTEMPTS) {
1400
+ m.armFails = 0;
1401
+ m.lastRearmAt = now;
1402
+ notify(m, `typed the goal ${ARM_ATTEMPTS} times without being able to confirm it landed — stopping, so it is not queued again. ` +
1403
+ `The session is usually mid-turn when this happens; it stays managed and will arm at the next real lapse.`);
1404
+ }
637
1405
  }
638
1406
  if (dirty)
639
1407
  saveState(state);
1408
+ // Last, so the report describes the state this tick left behind rather than
1409
+ // the one it found.
1410
+ reportIfDue(now);
640
1411
  }
641
1412
  export function startManagerLoop() {
642
1413
  if (timer)
@@ -707,8 +1478,15 @@ export async function handleManage(sessionIdOrName, rawArg) {
707
1478
  ` hands on|off for 8 hours | 30m\n` +
708
1479
  ` same, but it reverts by itself — a permission that ends\n` +
709
1480
  ` only when somebody remembers outlives its reason\n` +
1481
+ ` handover <path> [clear]\n` +
1482
+ ` where this session writes what it knows. At 82% context it\n` +
1483
+ ` is asked to update that file, then carries on — the terminal\n` +
1484
+ ` compacts by itself and the file is what survives it. Add\n` +
1485
+ ` "clear" to also clear the session (queues, in a long turn)\n` +
710
1486
  ` set <text> REPLACE the standing objective. Plain text on a running\n` +
711
1487
  ` manager is a one-shot note; this changes what it re-arms\n` +
1488
+ ` add <text> EXTEND the standing objective. Say it to the session\n` +
1489
+ ` instead and the next arming forgets it\n` +
712
1490
  ` now arm immediately, whatever the signals say\n` +
713
1491
  ` pause stop arming, keep the objective\n` +
714
1492
  ` resume start arming again\n` +
@@ -742,7 +1520,13 @@ export async function handleManage(sessionIdOrName, rawArg) {
742
1520
  if (!existing)
743
1521
  return { ok: true, message: `${name} is not being managed. /manage <objective> to start.`, managed: false };
744
1522
  const last = existing.history.slice(-4).map((h) => ` ${h.at.slice(11)} ${h.what}`).join("\n");
745
- const age = Math.round((Date.now() - existing.lastRearmAt) / 60000);
1523
+ // 0 is the sentinel for "arm on the next tick", not a timestamp. Subtracting
1524
+ // from it prints the age of the epoch — a seven-digit number, in the one
1525
+ // window where somebody is watching this line to see whether an arming
1526
+ // happened. Say what the state actually is instead.
1527
+ const armed = existing.lastRearmAt === 0
1528
+ ? "arming on the next tick"
1529
+ : `last armed ${minutesSince(existing.lastRearmAt, Date.now())} ago`;
746
1530
  const idle = Math.round((Date.now() - existing.lastChangeAt) / 1000);
747
1531
  // Two separate things, kept separate: what the manager has DONE, and what
748
1532
  // the session appears to be doing. Running them together is how a record of
@@ -754,7 +1538,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
754
1538
  `objective: ${existing.objective}\n` +
755
1539
  `\nright now:\n` +
756
1540
  liveReading(sessionId, idle) +
757
- `\n\nthe manager: last armed ${age} min ago` +
1541
+ `\n\nthe manager: ${armed}` +
758
1542
  (existing.pending.length ? `, ${existing.pending.length} instruction(s) waiting to go out` : "") +
759
1543
  (last ? `\n${last}` : ""),
760
1544
  };
@@ -782,10 +1566,38 @@ export async function handleManage(sessionIdOrName, rawArg) {
782
1566
  return {
783
1567
  ok: true,
784
1568
  managed: true,
785
- message: `objective replaced for ${name}.\n was: ${before.slice(0, 80)}${before.length > 80 ? "…" : ""}\n now: ${existing.objective.slice(0, 80)}${existing.objective.length > 80 ? "…" : ""}` +
1569
+ message: `objective replaced for ${name}.\n was: ${before.slice(0, 80)}${before.length > 80 ? "…" : ""}\n now: ${existing.objective.slice(0, 80)}${existing.objective.length > 80 ? "…" : ""}\n` +
1570
+ landsWhen(existing) +
786
1571
  (dropped ? `\n ${dropped} pending instruction(s) dropped — they were written against the old objective.` : ""),
787
1572
  };
788
1573
  }
1574
+ /**
1575
+ * add — EXTEND the standing objective instead of replacing it.
1576
+ *
1577
+ * The alternative is to say it to the session directly, and for anything whose
1578
+ * result lands on disk that works fine. It fails for anything meant to steer
1579
+ * the work, and fails silently: the objective is re-typed at every arming, so
1580
+ * the session is periodically returned to a description of the job that never
1581
+ * mentioned the thing you added. Rewriting the whole objective to append one
1582
+ * sentence is the workaround this exists to remove, and a costly one, since
1583
+ * retyping something long is how a constraint gets dropped by accident.
1584
+ *
1585
+ * Joined with a space rather than a paragraph break because this text is typed
1586
+ * at a prompt, where a newline submits — see goalText.
1587
+ */
1588
+ const addMatch = arg.match(/^(?:add|also|append|extend)\s+([\s\S]+)$/i);
1589
+ if (addMatch && existing) {
1590
+ const extra = addMatch[1].trim();
1591
+ existing.objective = `${existing.objective} Also: ${extra}`;
1592
+ note(existing, `objective extended: ${extra.slice(0, 80)}`);
1593
+ saveState(state);
1594
+ return {
1595
+ ok: true,
1596
+ managed: true,
1597
+ message: `objective extended for ${name}.\n added: ${extra}\n` +
1598
+ landsWhen(existing),
1599
+ };
1600
+ }
789
1601
  /**
790
1602
  * hands off / hands on — take the screen back without stopping the work.
791
1603
  *
@@ -870,6 +1682,75 @@ export async function handleManage(sessionIdOrName, rawArg) {
870
1682
  : `\n stays this way until you say otherwise.`),
871
1683
  };
872
1684
  }
1685
+ /**
1686
+ * handover <path> — where this session writes what it knows.
1687
+ *
1688
+ * Rollover is OFF until this is set, deliberately. Clearing a session that has
1689
+ * nowhere to write is destroying it, and a default guess at a path would be a
1690
+ * guess about somebody's project conventions with an unrecoverable failure
1691
+ * mode. Naming the file is the act of consenting to be rolled over.
1692
+ */
1693
+ /**
1694
+ * `handover <path> [clear]` — where to write, and whether to clear after.
1695
+ *
1696
+ * The trailing word is what makes clearing opt-in. It reads as an
1697
+ * afterthought and is the opposite: without it this asks a session to record
1698
+ * what it knows and then leaves it alone, which is the behaviour that has
1699
+ * actually held up. With it, the session is also cleared — worth having for a
1700
+ * session that idles between items, and worth refusing to do by default for
1701
+ * one that works in long turns, where the clear cannot execute and merely
1702
+ * queues.
1703
+ */
1704
+ const handoverMatch = arg.match(/^handover\s+(.+)$/i);
1705
+ if (handoverMatch && existing) {
1706
+ const rest = handoverMatch[1].trim();
1707
+ const wantsClear = /\s+clear$/i.test(rest);
1708
+ const path = rest.replace(/\s+clear$/i, "").trim();
1709
+ const previous = existing.handoverFile;
1710
+ existing.handoverFile = path;
1711
+ existing.clearAfterHandover = wantsClear;
1712
+ delete existing.handoverDoneAt;
1713
+ delete existing.handoverDoneK;
1714
+ delete existing.handoverAskedPath;
1715
+ const resolved = resolveHandoverPath(path);
1716
+ /**
1717
+ * Tell the session its target moved, now rather than at the next threshold.
1718
+ *
1719
+ * A session that maintains its handover as it works — which is the habit
1720
+ * worth having — keeps writing to whatever path it last heard. Left
1721
+ * uninformed it goes on updating a file nobody will read again, and the
1722
+ * change looks like it worked right up until the moment somebody needs the
1723
+ * file. Sent unconditionally: it is one line, it is only sent when the path
1724
+ * actually changed, and withholding it to protect whatever might be on the
1725
+ * input line trades a certain wrong file for a possible retyped sentence.
1726
+ */
1727
+ if (previous && previous !== path) {
1728
+ typeIntoSession(sessionId, `Your handover file has moved: write it to ${resolved} from now on, not ${resolveHandoverPath(previous)}. ` +
1729
+ (existsSync(resolved)
1730
+ ? `Keep it current as you work.`
1731
+ : `It does not exist yet — start it by carrying forward whatever still matters from the old one, especially anything written nowhere else, and keep it current as you work.`));
1732
+ }
1733
+ note(existing, `handover file set to ${path} — asked for at ${Math.round(HANDOVER_AT * 100)}% context${wantsClear ? ", then cleared" : ", no clear"}`);
1734
+ saveState(state);
1735
+ return {
1736
+ ok: true,
1737
+ managed: true,
1738
+ message: `${name} will be asked to hand over at ${Math.round(HANDOVER_AT * 100)}% of its context, into ${path}.\n` +
1739
+ (resolved === path
1740
+ ? ""
1741
+ : ` Today that resolves to ${resolved}; the date is worked out each time it is asked for, not now.\n`) +
1742
+ (wantsClear
1743
+ ? ` It is then cleared, once that file has changed on disk and not before.\n` +
1744
+ ` Note: a clear cannot run while a turn is in progress — for a session that works in\n` +
1745
+ ` long turns it will queue rather than take effect. Prefer the default there.`
1746
+ : ` It is NOT cleared — it keeps its context and carries on, and the terminal compacts\n` +
1747
+ ` when it needs to. The handover is what makes that compaction cheap.\n` +
1748
+ ` Add the word "clear" after the path if you want the old behaviour.`) +
1749
+ (existsSync(resolved)
1750
+ ? ""
1751
+ : `\n NOTE: ${resolved} does not exist yet. It counts as changed when first written, and the\n request will tell the session to carry forward what still matters from the most recent one beside it.`),
1752
+ };
1753
+ }
873
1754
  if (word === "pause" || word === "resume") {
874
1755
  if (!existing)
875
1756
  return { ok: false, message: `${name} is not being managed` };
@@ -881,10 +1762,10 @@ export async function handleManage(sessionIdOrName, rawArg) {
881
1762
  /**
882
1763
  * REFUSE TO MANAGE ANYTHING THAT IS NOT A SESSION.
883
1764
  *
884
- * `aibroker manage status CaseLeaf` — keyword first, session second —
1765
+ * `aibroker manage status <session>` — keyword first, session second —
885
1766
  * resolved to the plain shell the command was typed in, and the remainder
886
1767
  * became an objective: a manager was created for a `-zsh` pane, silently,
887
- * with the objective "status CaseLeaf". Nothing would ever have come of it
1768
+ * with the objective "status <session>". Nothing would ever have come of it
888
1769
  * except goals typed at a shell prompt.
889
1770
  *
890
1771
  * The arm path already refuses a bare shell. That is too late: by then a