aibroker 0.36.0 → 0.37.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;
@@ -60,6 +63,116 @@ const GOAL_MAX_AGE_MS = 45 * 60_000;
60
63
  * conjunction with the age ceiling above, never on its own.
61
64
  */
62
65
  const GOAL_ACTIVE = /\/goal\s+active/i;
66
+ /**
67
+ * Where a session is asked to hand over, as a share of its context.
68
+ *
69
+ * NOT where it dies — where it should stop and write down what it knows while
70
+ * it still can. A session at the wall cannot compose a handover, because
71
+ * composing one is exactly the sort of work it no longer has room for. The
72
+ * margin has to be big enough to write in.
73
+ *
74
+ * Deliberately conservative. Rolling over early costs one cycle of re-reading a
75
+ * file; rolling over late costs everything the session had not written down,
76
+ * and that loss is silent — the successor does not know what it was not told.
77
+ */
78
+ const HANDOVER_AT = 0.82;
79
+ /**
80
+ * How long the manager waits for the handover before giving up on it.
81
+ *
82
+ * A rollover that hangs is worse than no rollover: the session is paused, not
83
+ * working, and nobody is told. If the handover does not arrive the manager says
84
+ * so and leaves the session alone rather than clearing it — clearing a session
85
+ * that failed to write its handover destroys precisely what the rollover
86
+ * existed to preserve.
87
+ */
88
+ const HANDOVER_GRACE_MS = 6 * 60_000;
89
+ /**
90
+ * How long a managed session may show no change at all before it is armed.
91
+ *
92
+ * Chosen well above any turn that is merely slow — builds, long test runs and
93
+ * an agent thinking hard all move the screen inside this — so that firing means
94
+ * something is actually wrong rather than something is taking a while. The
95
+ * penalty for firing early is one queued prompt; the penalty for firing late is
96
+ * measured in hours of a session sitting at an empty prompt, so the number
97
+ * leans towards firing.
98
+ */
99
+ const STUCK_AFTER_MS = 12 * 60_000;
100
+ /**
101
+ * The startup banner, which is the pane's only POSITIVE evidence of a clear.
102
+ *
103
+ * The first version of this test asked the opposite question — whether the
104
+ * goal's words had left the screen — and that was wrong in a way worth
105
+ * recording, because it looked obviously right. A pane scrolls. The words of a
106
+ * goal set an hour ago are gone from it during any long turn, so their absence
107
+ * is the normal condition of a working session, not the signature of a cleared
108
+ * one. It declared every rollover complete the moment the clear was typed.
109
+ *
110
+ * A banner is drawn on exactly two occasions: the session starting, and the
111
+ * session being cleared. Inside the window where a clear has just been typed,
112
+ * only the second is possible.
113
+ */
114
+ const CLEARED_BANNER = /Claude Code v\d/;
115
+ /**
116
+ * How long a clear may stay unaccounted for before the guard lets go.
117
+ *
118
+ * The guard exists so a second clear is never typed while one is outstanding,
119
+ * and that is right. But it was released by exactly one event — seeing a clear
120
+ * land — and an event that may never happen is not a release, it is a lock: an
121
+ * operator who deletes the queued clear, or a terminal that drops it, leaves
122
+ * the session unable to roll over again for the rest of its life. That is a
123
+ * worse failure than the one being prevented, and quieter.
124
+ *
125
+ * The interval is long because it is a backstop, not a retry. Nothing here
126
+ * hurries: a rollover still has to earn its way back by writing a handover
127
+ * first, which takes minutes and cannot be faked.
128
+ */
129
+ const CLEAR_PENDING_MAX_MS = 30 * 60_000;
130
+ /**
131
+ * How long before a session that has handed over is asked to do it again.
132
+ *
133
+ * Needed only because the handover no longer ends in a clear. The context that
134
+ * triggered the request stays high afterwards — that is the point, the session
135
+ * keeps its context and keeps working — so without a cooldown the threshold
136
+ * re-qualifies it on the very next tick and it is interrupted every twenty
137
+ * seconds to write the same file.
138
+ *
139
+ * A session that keeps its handover current as it works, which is the habit
140
+ * this encourages, will usually have nothing to add when re-asked. The
141
+ * interval is set for the case where it does.
142
+ */
143
+ const HANDOVER_REASK_MS = 30 * 60_000;
144
+ /**
145
+ * How much NEW work may accumulate before the handover is asked for again.
146
+ *
147
+ * A time cooldown alone left the gap this closes. The handover is written at
148
+ * the threshold and the session then keeps working to the wall — on a 1M
149
+ * window that is nearly 200k tokens of thinking that the file does not
150
+ * describe, and it is exactly the stretch compaction throws away. The document
151
+ * meant to survive compaction was reliably stale by the moment it was needed.
152
+ *
153
+ * Measured in context growth rather than minutes because that is what the risk
154
+ * is actually made of: an idle hour costs nothing, and twenty minutes of hard
155
+ * work costs everything not written down.
156
+ */
157
+ const HANDOVER_REASK_K = 120;
158
+ /**
159
+ * A floor under re-asking, so growth cannot trigger a stream of requests.
160
+ *
161
+ * Writing a handover itself consumes context, so without this a session near
162
+ * the wall could be asked again almost immediately on the strength of the
163
+ * growth its own last handover caused.
164
+ */
165
+ const HANDOVER_MIN_GAP_MS = 8 * 60_000;
166
+ /**
167
+ * Input the terminal is holding rather than running.
168
+ *
169
+ * A `/clear` typed into a session that is mid-turn does not execute; it waits,
170
+ * and the terminal says so. Seeing this means the clear has NOT landed however
171
+ * fresh the rest of the screen looks, so it vetoes the banner test — a banner
172
+ * still on screen from a session's own start would otherwise be read as proof
173
+ * of a clear that is still sitting in the queue.
174
+ */
175
+ const QUEUED_INPUT = /queued message/i;
63
176
  /** Verdicts that mean the session has run out of goal and said so. */
64
177
  const OUT_OF_GOAL = [
65
178
  /goal could not be achieved/i,
@@ -189,6 +302,127 @@ function mirrorToRepo(m) {
189
302
  log(`[manage] could not mirror into the repository — ${e.message}`);
190
303
  }
191
304
  }
305
+ /**
306
+ * A cheap fingerprint of a file, or "" if it is not there.
307
+ *
308
+ * Size and modification time rather than a hash: this runs every twenty seconds
309
+ * against a file that may be hundreds of kilobytes, and the question is only
310
+ * "did it change", which those two answer without reading anything. An absent
311
+ * file fingerprints as empty so that CREATING one counts as a change — the
312
+ * first handover a session ever writes is exactly the case a naive comparison
313
+ * would miss.
314
+ */
315
+ function fileFingerprint(path) {
316
+ try {
317
+ const s = statSync(path);
318
+ return `${s.size}:${Math.round(s.mtimeMs)}`;
319
+ }
320
+ catch {
321
+ return "";
322
+ }
323
+ }
324
+ /** This session's context in thousands of tokens, from its own transcript. */
325
+ function contextK(m) {
326
+ const tty = m.tty ?? snapshotTty(m.sessionId);
327
+ const pid = tty ? processReading(tty).pid : null;
328
+ return pid ? transcriptReading(pid).contextK : null;
329
+ }
330
+ /**
331
+ * Close a rollover out, however it ended, and guarantee an arming follows.
332
+ *
333
+ * EVERY exit from a rollover goes through here, which is the point. When the
334
+ * clearing of this state and the scheduling of the next arming are separate
335
+ * acts at separate call sites, some branch eventually does the first without
336
+ * the second — and that branch leaves a cleared session sitting at an empty
337
+ * prompt, reporting "working", for as long as the ordinary arming rules take to
338
+ * notice. Binding the two together makes that combination unwriteable.
339
+ *
340
+ * `lastRearmAt = 0` is the sentinel that says arm on the next tick regardless
341
+ * of the on-screen goal marker. That is not a shortcut: after a clear the
342
+ * marker is a leftover from a screen that no longer exists, so the one signal
343
+ * that would hold the arming back is also the one signal guaranteed to be
344
+ * stale.
345
+ */
346
+ function endRollover(m) {
347
+ delete m.handoverAskedAt;
348
+ delete m.handoverWas;
349
+ delete m.clearTypedAt;
350
+ delete m.contextAtClear;
351
+ // clearPendingSince deliberately survives: it records a clear that is still
352
+ // out there somewhere, and forgetting it is what let a second one be typed.
353
+ m.lastRearmAt = 0;
354
+ }
355
+ /** A clear was seen to land. Forget it, and let rollovers happen again. */
356
+ function clearLanded(m) {
357
+ delete m.clearPendingSince;
358
+ endRollover(m);
359
+ }
360
+ /**
361
+ * Is there text sitting unsent in the session's prompt?
362
+ *
363
+ * TYPING ON TOP OF IT DESTROYS IT. The manager pastes into the same input line
364
+ * a person types into, and it sends a backspace first to escape vi normal
365
+ * mode — so an objective armed over half-typed text eats a character of that
366
+ * text and then runs the two together as one prompt. The operator's sentence
367
+ * and the standing objective arrive merged and mangled, and neither does what
368
+ * it meant to.
369
+ *
370
+ * This never bit while nobody was at the keyboard, which is exactly the kind of
371
+ * assumption that holds until an operator sits down at seven in the morning and
372
+ * starts a sentence.
373
+ *
374
+ * The live input line is the one enclosed by the terminal's rules at the foot
375
+ * of the pane, not the `❯` lines further up — those are scrollback, commands
376
+ * that already ran. So the rule immediately above it is what identifies it.
377
+ */
378
+ export function promptHasUnsentText(content) {
379
+ const lines = content.split("\n");
380
+ for (let i = 1; i < lines.length; i++) {
381
+ const isRuleAbove = /^\s*[─—-]{10,}\s*$/.test(lines[i - 1]);
382
+ if (!isRuleAbove)
383
+ continue;
384
+ const m = lines[i].match(/^\s*❯\s*(.*)$/);
385
+ if (!m)
386
+ continue;
387
+ const typed = m[1].trim();
388
+ // The terminal's own hint about held input is not the operator's text.
389
+ if (!typed || /^press up to edit/i.test(typed))
390
+ continue;
391
+ return true;
392
+ }
393
+ return false;
394
+ }
395
+ /**
396
+ * Expand the date tokens in a handover path, against the clock right now.
397
+ *
398
+ * Deliberately resolved at the moment of use. A managed session is meant to
399
+ * outlive the day it started on, so any date fixed at the moment the path was
400
+ * SET is a date that will be wrong by morning — and wrong in the quietest way,
401
+ * since the file it names still exists and still opens.
402
+ *
403
+ * Exported for the tests, which is also where the accepted tokens are pinned.
404
+ */
405
+ export function resolveHandoverPath(template, at = new Date()) {
406
+ const p = (n) => String(n).padStart(2, "0");
407
+ const yyyy = String(at.getFullYear());
408
+ const mm = p(at.getMonth() + 1);
409
+ const dd = p(at.getDate());
410
+ return template
411
+ .replace(/\{date\}/gi, `${yyyy}-${mm}-${dd}`)
412
+ .replace(/\{yyyy\}/gi, yyyy)
413
+ .replace(/\{mm\}/gi, mm)
414
+ .replace(/\{dd\}/gi, dd);
415
+ }
416
+ /** Did the handover actually land? Read the file; do not believe the session. */
417
+ function handoverChanged(m) {
418
+ // The path asked for, not the template resolved afresh: if the clock crossed
419
+ // midnight between the request and this check, resolving again would compare
420
+ // a file nobody was asked to write against a fingerprint taken from another.
421
+ const path = m.handoverAskedPath ?? (m.handoverFile ? resolveHandoverPath(m.handoverFile) : undefined);
422
+ if (!path)
423
+ return false;
424
+ return fileFingerprint(path) !== (m.handoverWas ?? "");
425
+ }
192
426
  /** The pane device for a session, captured once at start. */
193
427
  function snapshotTty(sessionId) {
194
428
  return discoverLiveSessions().find((s) => s.id === sessionId)?.tty;
@@ -234,6 +468,114 @@ function note(m, what) {
234
468
  m.history = m.history.slice(-40);
235
469
  log(`[manage:${m.name}] ${what}`);
236
470
  }
471
+ /**
472
+ * Tell the operator something happened, wherever they are.
473
+ *
474
+ * Goes out unconditionally, because the normal path already does the right
475
+ * thing with an absent phone: it pushes over APNs and queues for catch-up. An
476
+ * alert is a MESSAGE — a rollover, a dead session — and a message is still
477
+ * worth reading an hour after it was sent, which is precisely what the queue is
478
+ * for. Gating this on a live connection would mean the events worth waking
479
+ * someone for are the ones only delivered when they were already watching.
480
+ *
481
+ * Failure is swallowed on purpose. The manager's job is keeping a session
482
+ * working; it must not stop doing that because a notification did not go out.
483
+ */
484
+ function alertOperator(text) {
485
+ try {
486
+ getAibpBridge()?.routeToMobile("", text, "TEXT");
487
+ }
488
+ catch (e) {
489
+ log(`[manage] could not reach the phone — ${e.message}`);
490
+ }
491
+ }
492
+ /**
493
+ * The periodic reading — sent only if someone is actually looking.
494
+ *
495
+ * The opposite call from an alert, for the opposite kind of content. "Armed
496
+ * twice, 400k context" is worth knowing at the time and worth nothing four
497
+ * hours later, so queueing it would mean picking the phone up to a stack of
498
+ * expired weather reports with the one that mattered somewhere inside. No app
499
+ * connected, no report, and nothing kept to deliver later.
500
+ */
501
+ function reportToOperator(text) {
502
+ if (!hasPailotClients())
503
+ return;
504
+ alertOperator(text);
505
+ }
506
+ /**
507
+ * A note that is ALSO worth a buzz on the phone.
508
+ *
509
+ * The line between this and `note` is who caused the event. Anything the
510
+ * operator just did — set an objective, take the screen back, start managing —
511
+ * is recorded and not sent, because telling someone what they themselves just
512
+ * typed is how a notification channel teaches its reader to ignore it. What
513
+ * gets sent is what the MANAGER decided on its own while nobody was watching:
514
+ * a rollover, a session that died, a goal that would not land.
515
+ *
516
+ * Routine armings are not here. They are real, and frequent, and belong in the
517
+ * periodic report where they arrive as a count instead of sixteen buzzes.
518
+ */
519
+ function notify(m, what) {
520
+ note(m, what);
521
+ alertOperator(`${m.name} — ${what}`);
522
+ }
523
+ /**
524
+ * How often the operator hears from the manager when nothing is wrong.
525
+ *
526
+ * Long, because the report competes with the alerts for the same attention: a
527
+ * channel that speaks every few minutes about nothing is one whose alerts get
528
+ * swiped away unread. Half an hour is roughly "next time you glance at it".
529
+ */
530
+ const REPORT_EVERY_MS = 30 * 60_000;
531
+ /** When the operator was last told the state of things. */
532
+ let lastReportAt = 0;
533
+ /**
534
+ * Armings since the last report, per session. NOT persisted, on purpose — the
535
+ * count answers "how much has the manager had to intervene lately", and a
536
+ * figure carried across a daemon restart would answer a different question
537
+ * while looking like that one.
538
+ */
539
+ const armingsSinceReport = new Map();
540
+ /**
541
+ * The periodic reading: what every managed session is doing, in one message.
542
+ *
543
+ * One message for all of them rather than one each, because the useful thing on
544
+ * a phone is a page you take in at a glance, and armings across several
545
+ * sessions are the same event happening in several places.
546
+ */
547
+ function reportIfDue(now) {
548
+ const managed = Object.values(state);
549
+ // Nothing is being managed, so there is nothing to report and the clock is
550
+ // held at now — otherwise a report would be overdue the moment one starts.
551
+ if (managed.length === 0) {
552
+ lastReportAt = now;
553
+ return;
554
+ }
555
+ // First tick after a restart. Start the clock rather than reporting, so
556
+ // restarting the daemon is not itself a reason for the phone to buzz.
557
+ if (lastReportAt === 0) {
558
+ lastReportAt = now;
559
+ return;
560
+ }
561
+ if (now - lastReportAt < REPORT_EVERY_MS)
562
+ return;
563
+ lastReportAt = now;
564
+ const mins = Math.round(REPORT_EVERY_MS / 60_000);
565
+ const lines = managed.map((m) => {
566
+ const armings = armingsSinceReport.get(m.sessionId) ?? 0;
567
+ armingsSinceReport.set(m.sessionId, 0);
568
+ const k = contextK(m);
569
+ const quietFor = Math.round((now - m.lastChangeAt) / 60_000);
570
+ const doing = m.paused ? "paused" : quietFor >= 2 ? `quiet for ${quietFor} min` : "working";
571
+ const last = m.history.at(-1);
572
+ return (`• ${m.name} — ${doing}` +
573
+ (k !== null ? `, ${k}k context` : "") +
574
+ `, ${armings} arming${armings === 1 ? "" : "s"} in ${mins} min` +
575
+ (last ? `\n last: ${last.what.slice(0, 110)}` : ""));
576
+ });
577
+ reportToOperator(`Manager report\n${lines.join("\n")}`);
578
+ }
237
579
  /**
238
580
  * Resolve a session by whatever the caller knows — its id, or its name.
239
581
  *
@@ -484,6 +826,36 @@ function paneReading(content) {
484
826
  ].filter(Boolean);
485
827
  return ` ${parts.join(" · ")}${doing ? `\n doing: ${doing.trim().slice(0, 110)}` : ""}`;
486
828
  }
829
+ /**
830
+ * Collapse text to a single line, for anything about to be TYPED at a prompt.
831
+ *
832
+ * At a prompt a newline is the submit key, so a multi-line objective does not
833
+ * arrive as a long goal — it arrives as a short one, followed by its own
834
+ * remainder as a second, contextless prompt, and the session acts on both. The
835
+ * damage is silent: what was sent looks right in the state file and wrong only
836
+ * on screen.
837
+ *
838
+ * Applied at the point text becomes keystrokes rather than at each point text
839
+ * is set, so it covers every route in — shell heredoc, MCP call, appended
840
+ * text, operator notes — including the ones added later.
841
+ */
842
+ export function oneLine(text) {
843
+ return text.replace(/\s*[\r\n]+\s*/g, " ").trim();
844
+ }
845
+ /**
846
+ * When a change to the objective actually reaches the session.
847
+ *
848
+ * Printed because the answer is "not yet", and that has already been misread as
849
+ * the change having failed. Editing an objective types nothing at the session;
850
+ * it changes what the NEXT arming says, and arming waits for the session to
851
+ * stop. Saying so costs one line and removes the whole question.
852
+ */
853
+ function landsWhen(m) {
854
+ if (m.paused)
855
+ return " The manager is paused — nothing is armed until you resume it.";
856
+ return (" This changes what the next arming says; it types nothing now.\n" +
857
+ " The next arming comes when the session stops — or immediately, with `now`.");
858
+ }
487
859
  /** The text actually typed at the session. Short goal, context by reference. */
488
860
  function goalText(m) {
489
861
  const extra = m.pending.length ? ` OPERATOR, since you were last armed: ${m.pending.join(" ")}` : "";
@@ -494,7 +866,7 @@ function goalText(m) {
494
866
  const hands = m.noScreen
495
867
  ? " 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
868
  : "";
497
- return `/goal ${m.objective}${hands}${extra}`;
869
+ return oneLine(`/goal ${m.objective}${hands}${extra}`);
498
870
  }
499
871
  /**
500
872
  * Did it land? Look for the goal's own words in the transcript.
@@ -547,7 +919,23 @@ async function arm(m, reason) {
547
919
  }
548
920
  if (live.atPrompt) {
549
921
  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.");
922
+ 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.");
923
+ return false;
924
+ }
925
+ /**
926
+ * NEVER TYPE OVER SOMEBODY MID-SENTENCE.
927
+ *
928
+ * Checked here, immediately before the keystrokes, rather than anywhere
929
+ * earlier — the operator may have started typing during the seconds this
930
+ * function spent deciding, and a check made further upstream would be
931
+ * answering a question about a screen that has since changed.
932
+ *
933
+ * Not armed and retried, deliberately: a half-written sentence is a person
934
+ * thinking, and the manager can afford to wait for anyone. It comes back on
935
+ * the next tick and every tick after.
936
+ */
937
+ if (promptHasUnsentText(readPane(m.sessionId))) {
938
+ note(m, `the operator has unsent text in the prompt — not typing over it (${reason})`);
551
939
  return false;
552
940
  }
553
941
  if (!typeIntoSession(m.sessionId, text)) {
@@ -561,11 +949,12 @@ async function arm(m, reason) {
561
949
  m.lastRearmAt = Date.now();
562
950
  const carried = m.pending.length;
563
951
  m.pending = [];
952
+ armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
564
953
  note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
565
954
  return true;
566
955
  }
567
956
  }
568
- note(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
957
+ notify(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
569
958
  return false;
570
959
  }
571
960
  function reasonToArm(m, content, now) {
@@ -584,17 +973,91 @@ function reasonToArm(m, content, now) {
584
973
  const marker = GOAL_ACTIVE.test(content);
585
974
  if (!marker && quietFor > NO_GOAL_GRACE_MS)
586
975
  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`;
976
+ /**
977
+ * The ceiling — but only over a session that has gone quiet.
978
+ *
979
+ * It exists because the on-screen marker lingers after a goal is met, so a
980
+ * session that finished long ago can look armed forever. What it must not do
981
+ * is fire over a session that is plainly still working, and it did: a
982
+ * six-hour turn crosses the ceiling every forty-five minutes, so the standing
983
+ * objective was re-typed into a session far past the point it describes.
984
+ *
985
+ * That is not the harmless duplicate it first appears. An objective is
986
+ * usually written as a starting instruction — go through all of X, sort them,
987
+ * begin — and delivering it to a session deep in the work reads as an
988
+ * instruction to start over. The manager's own recovery mechanism becomes the
989
+ * thing that undoes the work.
990
+ *
991
+ * A moving pane is direct evidence the session is engaged, and evidence beats
992
+ * the inference drawn from a timer. So the ceiling now needs both: the goal
993
+ * looks old AND nothing is happening.
994
+ */
995
+ if (armedFor > GOAL_MAX_AGE_MS && quietFor > NO_GOAL_GRACE_MS) {
996
+ 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
997
  }
593
998
  return null;
594
999
  }
1000
+ /**
1001
+ * A file whose modification time proves the manager loop is still turning.
1002
+ *
1003
+ * The process being alive is NOT the same claim, and only the weaker one is
1004
+ * observable from outside: a wedged loop inside a healthy process satisfies
1005
+ * launchd, answers the socket, and manages nothing. Whatever supervises this
1006
+ * from outside needs a fact that only a completed tick can produce, so each
1007
+ * tick stamps one.
1008
+ *
1009
+ * Written every tick rather than on change, because "nothing changed" is a
1010
+ * normal and frequent outcome here — a heartbeat that stops during quiet
1011
+ * periods reports the healthy case as a failure.
1012
+ */
1013
+ const HEARTBEAT_FILE = join(homedir(), ".aibroker", "manage-heartbeat");
1014
+ function beat() {
1015
+ try {
1016
+ writeFileSync(HEARTBEAT_FILE, String(Date.now()));
1017
+ }
1018
+ catch {
1019
+ // A heartbeat that cannot be written must not take the manager down with
1020
+ // it; the supervisor treats silence as a stall, which is the safe reading.
1021
+ }
1022
+ }
1023
+ /**
1024
+ * Look for blocking modals, but not on every tick.
1025
+ *
1026
+ * The check costs an AppleScript round trip, and the machine running these
1027
+ * sessions is often the machine they are driving — the same contention that
1028
+ * makes a pane read slow. Once a minute is far faster than a person noticing,
1029
+ * and cheap enough to leave running forever.
1030
+ */
1031
+ const DIALOG_EVERY_TICKS = 3;
1032
+ let tickCount = 0;
1033
+ function answerBlockingDialogs() {
1034
+ if (Object.keys(state).length === 0)
1035
+ return;
1036
+ for (const d of listDialogs()) {
1037
+ const pressed = answerDialog(d);
1038
+ if (pressed) {
1039
+ log(`[dialogs] pressed "${pressed}" on ${d.process} — ${d.title}`);
1040
+ alertOperator(`A system dialog was blocking work — pressed "${pressed}" on ${d.process}${d.title ? ` (${d.title})` : ""}.`);
1041
+ }
1042
+ else {
1043
+ // Unrecognised prompt: say so and leave it. A dialog nobody can answer
1044
+ // safely still needs somebody told, or it blocks the night in silence.
1045
+ 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}"` : ""}.`);
1046
+ }
1047
+ }
1048
+ }
595
1049
  async function tick() {
596
1050
  const now = Date.now();
597
1051
  let dirty = false;
1052
+ beat();
1053
+ if (++tickCount % DIALOG_EVERY_TICKS === 0) {
1054
+ try {
1055
+ answerBlockingDialogs();
1056
+ }
1057
+ catch (e) {
1058
+ log(`[dialogs] check failed — ${e.message}`);
1059
+ }
1060
+ }
598
1061
  for (const m of Object.values(state)) {
599
1062
  const content = readPane(m.sessionId);
600
1063
  if (!content) {
@@ -609,6 +1072,30 @@ async function tick() {
609
1072
  m.lastChangeAt = now;
610
1073
  dirty = true;
611
1074
  }
1075
+ /**
1076
+ * THE BACKSTOP: a managed session whose screen has not moved in a long time.
1077
+ *
1078
+ * Every specific fault above is a fault somebody already thought of. This
1079
+ * one is for the faults nobody has thought of yet, and it is deliberately
1080
+ * ignorant of causes: it does not care whether a rollover misfired, a goal
1081
+ * failed to land, a clear ate the prompt, or something new. It knows only
1082
+ * that a session under management has shown no sign of life for a long
1083
+ * time, which is never a state worth preserving.
1084
+ *
1085
+ * Arming is the response because arming is the cheap direction to be wrong
1086
+ * in. Against a genuinely busy session it queues one prompt behind a long
1087
+ * turn, costing nothing; against a dead one it is the whole recovery. The
1088
+ * asymmetry is the argument — and it is why this fires on a signal as crude
1089
+ * as "nothing changed", which no more precise test would improve on.
1090
+ */
1091
+ if (!m.paused && !m.handoverAskedAt && now - m.lastChangeAt > STUCK_AFTER_MS) {
1092
+ 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`);
1093
+ // Counted as a change so a session that stays stuck is not re-armed every
1094
+ // tick: this is a recovery, and a recovery that repeats is a loop.
1095
+ m.lastChangeAt = now;
1096
+ m.lastRearmAt = 0;
1097
+ dirty = true;
1098
+ }
612
1099
  /**
613
1100
  * A timed screen decision reverts itself.
614
1101
  *
@@ -624,9 +1111,240 @@ async function tick() {
624
1111
  typeIntoSession(m.sessionId, m.noScreen
625
1112
  ? "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
1113
  : "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");
1114
+ notify(m, m.noScreen ? "timed grant expired — screen work stopped" : "timed hold expired — screen work permitted again");
628
1115
  dirty = true;
629
1116
  }
1117
+ /**
1118
+ * A clear that was typed earlier, landing late.
1119
+ *
1120
+ * Kept OUTSIDE the rollover block because that block is no longer running
1121
+ * by the time this usually happens: the rollover gave up waiting, the
1122
+ * session stayed in its turn for another half hour, and the clear finally
1123
+ * executed with nothing left watching for it. Without this, the pending
1124
+ * flag would never be lifted and the session could never roll over again —
1125
+ * a safety catch that, having done its job once, quietly became a lock.
1126
+ */
1127
+ if (m.clearPendingSince && !m.clearTypedAt) {
1128
+ if (CLEARED_BANNER.test(content) && !QUEUED_INPUT.test(content)) {
1129
+ notify(m, `the clear typed ${minutesSince(m.clearPendingSince, now)} ago has landed — arming the fresh session`);
1130
+ clearLanded(m);
1131
+ dirty = true;
1132
+ }
1133
+ }
1134
+ /**
1135
+ * ROLLING OVER BEFORE THE WALL.
1136
+ *
1137
+ * A session that fills its context does not degrade gracefully; it starts
1138
+ * losing the thread while still appearing to work, which is the worst of
1139
+ * both — it is producing output nobody should trust. So at a threshold it
1140
+ * is asked to write down what it knows, and only once that is ON DISK is it
1141
+ * cleared and re-armed.
1142
+ *
1143
+ * THE ORDER IS THE WHOLE DESIGN, and the previous attempt at this got it
1144
+ * wrong: it cleared first and deleted the very file it was meant to
1145
+ * preserve. Nothing here deletes anything, the handover is verified by
1146
+ * reading the file back rather than by the session saying it wrote one, and
1147
+ * a session that does not produce a handover is left alone rather than
1148
+ * cleared. Losing a cycle is recoverable; clearing an unrecorded session is
1149
+ * not.
1150
+ */
1151
+ if (!m.paused && m.handoverAskedAt) {
1152
+ const wrote = handoverChanged(m);
1153
+ if (wrote && !m.clearAfterHandover) {
1154
+ /**
1155
+ * HANDOVER WRITTEN, AND THAT IS THE WHOLE JOB.
1156
+ *
1157
+ * Clearing used to follow automatically and it was the wrong half of
1158
+ * the idea. A clear cannot execute while a turn is running, and a
1159
+ * session working towards a goal does not end its turn — so the clear
1160
+ * waited in the input queue, and every fresh attempt added another,
1161
+ * until a queue of them stood ready to fire in sequence against
1162
+ * whatever sessions happened to exist by then.
1163
+ *
1164
+ * Meanwhile the thing it was protecting against turned out to be
1165
+ * handled: the terminal compacts by itself at the limit and the
1166
+ * session carries on working through it. What compaction costs is
1167
+ * detail, and detail is exactly what the handover has already written
1168
+ * to disk. So the valuable half runs and the dangerous half does not,
1169
+ * unless somebody asks for it by name.
1170
+ */
1171
+ notify(m, "handover written — leaving the session to compact on its own rather than clearing it");
1172
+ m.handoverDoneAt = now;
1173
+ m.handoverDoneK = contextK(m) ?? undefined;
1174
+ delete m.handoverAskedAt;
1175
+ delete m.handoverWas;
1176
+ delete m.handoverAskedPath;
1177
+ dirty = true;
1178
+ continue;
1179
+ }
1180
+ if (wrote) {
1181
+ /**
1182
+ * A CLEAR CANNOT LAND WHILE A GOAL IS ARMED.
1183
+ *
1184
+ * Observed rather than reasoned: the goal enforcement blocks the turn
1185
+ * from ending, the terminal will not read queued input until the turn
1186
+ * ends, and so `/clear` sits in the input line indefinitely while the
1187
+ * session repeats that it has nothing to add. The blocker does give up
1188
+ * after several attempts, which is why this waits rather than retries.
1189
+ *
1190
+ * TYPING IT AGAIN IS THE WRONG MOVE and the tempting one: a second
1191
+ * `/clear` queues behind the first and fires afterwards, against the
1192
+ * FRESH context — wiping the very session that just started. So this
1193
+ * types once, then watches the context figure, which is the artefact.
1194
+ */
1195
+ if (!m.clearTypedAt) {
1196
+ if (m.clearPendingSince && now - m.clearPendingSince < CLEAR_PENDING_MAX_MS) {
1197
+ // An earlier clear is still unaccounted for. Typing another would
1198
+ // put two in a queue that fires against two different sessions.
1199
+ 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`);
1200
+ endRollover(m);
1201
+ dirty = true;
1202
+ continue;
1203
+ }
1204
+ notify(m, "handover written — asking it to clear");
1205
+ typeIntoSession(m.sessionId, "/clear");
1206
+ m.clearTypedAt = now;
1207
+ m.clearPendingSince = now;
1208
+ m.contextAtClear = contextK(m) ?? undefined;
1209
+ dirty = true;
1210
+ continue;
1211
+ }
1212
+ /**
1213
+ * DID THE CLEAR LAND? ASK THE SCREEN, NOT ONLY THE NUMBER.
1214
+ *
1215
+ * The context figure was the sole test and it failed in the one way
1216
+ * that mattered: `contextK` can return null — the pane's pid, and
1217
+ * thence its transcript, is not always resolvable — and a null at the
1218
+ * moment the clear was typed leaves `contextAtClear` undefined, which
1219
+ * makes the drop test unsatisfiable FOREVER AFTER. Not flaky: a
1220
+ * rollover begun during that blind moment could never be seen to
1221
+ * finish, however cleanly it did.
1222
+ *
1223
+ * So the pane corroborates, via the startup banner — see
1224
+ * CLEARED_BANNER for why that particular mark and not the more obvious
1225
+ * one. Either witness alone is enough; neither is trusted to be
1226
+ * available.
1227
+ */
1228
+ /**
1229
+ * THE BANNER IS THE PROOF. THE NUMBER IS ONLY THE DETAIL.
1230
+ *
1231
+ * A falling context figure was the original test and it cannot do the
1232
+ * job, because a clear is not the only thing that empties a context:
1233
+ * the terminal compacts on its own near the limit, and compaction
1234
+ * produces exactly the same collapse in the same figure. Believing it
1235
+ * would mean declaring a clear that never happened, dropping the guard
1236
+ * that stops another being typed, and arming a session that is still
1237
+ * mid-turn with clears queued behind it.
1238
+ *
1239
+ * Compaction redraws no banner. Only starting and clearing do, and
1240
+ * inside this window only clearing is possible — so the banner alone
1241
+ * decides, and the number is reported beside it because it is useful
1242
+ * to read, not because it is being trusted.
1243
+ */
1244
+ const nowK = contextK(m);
1245
+ const fell = m.contextAtClear !== undefined && nowK !== null && nowK < m.contextAtClear / 2;
1246
+ if (CLEARED_BANNER.test(content) && !QUEUED_INPUT.test(content)) {
1247
+ notify(m, fell
1248
+ ? `cleared — fresh session on the pane, context fell from ${m.contextAtClear}k to ${nowK}k; re-arming`
1249
+ : "cleared — the pane is showing a fresh session; re-arming");
1250
+ clearLanded(m);
1251
+ dirty = true;
1252
+ continue;
1253
+ }
1254
+ if (now - m.clearTypedAt > HANDOVER_GRACE_MS) {
1255
+ /**
1256
+ * GIVING UP ON THE ROLLOVER IS NOT GIVING UP ON THE SESSION.
1257
+ *
1258
+ * This is where eight hours went. The branch was right to refuse a
1259
+ * SECOND clear — that would fire against a fresh context and wipe
1260
+ * it — but it also declined to arm, and those are different acts. It
1261
+ * then left `lastRearmAt` untouched, so ordinary arming stayed
1262
+ * blocked behind the stale on-screen goal marker until the 45-minute
1263
+ * ceiling expired. A cleared session sat at an empty prompt for the
1264
+ * whole of it, reading as "working" the entire time.
1265
+ *
1266
+ * Arming is safe under BOTH readings of an ambiguous outcome. If the
1267
+ * clear did land, arming is exactly what the fresh session needs. If
1268
+ * it did not, arming re-states the objective to a session that still
1269
+ * has its context, which costs one prompt. There is no reading in
1270
+ * which doing nothing is the better move, so this no longer does
1271
+ * nothing.
1272
+ */
1273
+ notify(m, "the clear was typed and could not be confirmed — NOT typing a second one, " +
1274
+ "but arming anyway: an armed session is safe whether or not the clear landed");
1275
+ endRollover(m);
1276
+ dirty = true;
1277
+ }
1278
+ continue;
1279
+ }
1280
+ if (now - m.handoverAskedAt > HANDOVER_GRACE_MS) {
1281
+ notify(m, "asked for a handover and did not get one — NOT clearing, the session keeps its context");
1282
+ delete m.handoverAskedAt;
1283
+ delete m.handoverWas;
1284
+ dirty = true;
1285
+ }
1286
+ // Still waiting. Do not arm anything on top of a session that is writing.
1287
+ continue;
1288
+ }
1289
+ // No new rollover while a clear is still unaccounted for. Context stays
1290
+ // high precisely because the clear has not landed, so without this the
1291
+ // threshold re-qualifies the session every tick and the rollover machinery
1292
+ // runs in a circle, each lap adding another clear to the queue.
1293
+ if (!m.paused && !m.handoverAskedAt && !m.clearPendingSince && m.handoverFile) {
1294
+ // The pane is resolved here rather than carried in from elsewhere in the
1295
+ // tick, so this block does not depend on the order of what precedes it.
1296
+ const tty = m.tty ?? snapshotTty(m.sessionId);
1297
+ const pid = tty ? processReading(tty).pid : null;
1298
+ const t = pid ? transcriptReading(pid) : null;
1299
+ const used = t?.contextK ?? null;
1300
+ /**
1301
+ * TWO WAYS TO BECOME DUE, because a handover goes out of date two ways.
1302
+ *
1303
+ * By the clock, which is the ordinary case. And by work done since the
1304
+ * last one, which is the case that mattered and was missing: a session
1305
+ * asked at the threshold keeps working to the wall, and everything it
1306
+ * learns in that stretch is absent from the file precisely when
1307
+ * compaction discards it. The second trigger keeps the document current
1308
+ * with the work rather than with the hour.
1309
+ */
1310
+ const sinceLast = now - (m.handoverDoneAt ?? 0);
1311
+ const grownBy = used !== null && m.handoverDoneK !== undefined ? used - m.handoverDoneK : null;
1312
+ const dueByTime = sinceLast > HANDOVER_REASK_MS;
1313
+ const dueByWork = grownBy !== null && grownBy >= HANDOVER_REASK_K && sinceLast > HANDOVER_MIN_GAP_MS;
1314
+ // 1M is the window these sessions run in; treat anything else as unknown
1315
+ // rather than guessing, because a wrong denominator rolls over a session
1316
+ // that had plenty of room left.
1317
+ if ((dueByTime || dueByWork) && used !== null && used / 1000 >= HANDOVER_AT) {
1318
+ const askedPath = resolveHandoverPath(m.handoverFile);
1319
+ m.handoverAskedAt = now;
1320
+ m.handoverAskedPath = askedPath;
1321
+ m.handoverWas = fileFingerprint(askedPath);
1322
+ // A dated handover starts empty each day, and an empty one is worse
1323
+ // than none: it reads as authoritative and says nothing. So the
1324
+ // instruction carries the rule for that case rather than assuming the
1325
+ // session will think of it at the moment it is running out of room.
1326
+ const carry = existsSync(askedPath)
1327
+ ? ""
1328
+ : `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. `;
1329
+ // A top-up reads differently from a first request: the session has
1330
+ // already written one and needs to know this is about the work SINCE,
1331
+ // not a repeat it can satisfy by confirming the file is still there.
1332
+ const topUp = dueByWork && !dueByTime && grownBy !== null;
1333
+ typeIntoSession(m.sessionId, (topUp
1334
+ ? `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. `
1335
+ : `Write your handover now — you are at ${used}k tokens and the terminal will compact before long. `) +
1336
+ `Update ${askedPath}. ${carry}Three things: where the current item stands, what you would do next and why, ` +
1337
+ `and — the irreplaceable part — anything you know that is written nowhere else. Commit it. ` +
1338
+ (m.clearAfterHandover
1339
+ ? `You will be cleared once that file has changed on disk, and not before.`
1340
+ : `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.`));
1341
+ notify(m, topUp
1342
+ ? `at ${used}k tokens, ${grownBy}k of new work since the last one — asked to bring the handover up to date`
1343
+ : `at ${used}k tokens — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}`);
1344
+ dirty = true;
1345
+ continue;
1346
+ }
1347
+ }
630
1348
  if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
631
1349
  continue;
632
1350
  const reason = reasonToArm(m, content, now);
@@ -637,6 +1355,9 @@ async function tick() {
637
1355
  }
638
1356
  if (dirty)
639
1357
  saveState(state);
1358
+ // Last, so the report describes the state this tick left behind rather than
1359
+ // the one it found.
1360
+ reportIfDue(now);
640
1361
  }
641
1362
  export function startManagerLoop() {
642
1363
  if (timer)
@@ -707,8 +1428,15 @@ export async function handleManage(sessionIdOrName, rawArg) {
707
1428
  ` hands on|off for 8 hours | 30m\n` +
708
1429
  ` same, but it reverts by itself — a permission that ends\n` +
709
1430
  ` only when somebody remembers outlives its reason\n` +
1431
+ ` handover <path> [clear]\n` +
1432
+ ` where this session writes what it knows. At 82% context it\n` +
1433
+ ` is asked to update that file, then carries on — the terminal\n` +
1434
+ ` compacts by itself and the file is what survives it. Add\n` +
1435
+ ` "clear" to also clear the session (queues, in a long turn)\n` +
710
1436
  ` set <text> REPLACE the standing objective. Plain text on a running\n` +
711
1437
  ` manager is a one-shot note; this changes what it re-arms\n` +
1438
+ ` add <text> EXTEND the standing objective. Say it to the session\n` +
1439
+ ` instead and the next arming forgets it\n` +
712
1440
  ` now arm immediately, whatever the signals say\n` +
713
1441
  ` pause stop arming, keep the objective\n` +
714
1442
  ` resume start arming again\n` +
@@ -742,7 +1470,13 @@ export async function handleManage(sessionIdOrName, rawArg) {
742
1470
  if (!existing)
743
1471
  return { ok: true, message: `${name} is not being managed. /manage <objective> to start.`, managed: false };
744
1472
  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);
1473
+ // 0 is the sentinel for "arm on the next tick", not a timestamp. Subtracting
1474
+ // from it prints the age of the epoch — a seven-digit number, in the one
1475
+ // window where somebody is watching this line to see whether an arming
1476
+ // happened. Say what the state actually is instead.
1477
+ const armed = existing.lastRearmAt === 0
1478
+ ? "arming on the next tick"
1479
+ : `last armed ${minutesSince(existing.lastRearmAt, Date.now())} ago`;
746
1480
  const idle = Math.round((Date.now() - existing.lastChangeAt) / 1000);
747
1481
  // Two separate things, kept separate: what the manager has DONE, and what
748
1482
  // the session appears to be doing. Running them together is how a record of
@@ -754,7 +1488,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
754
1488
  `objective: ${existing.objective}\n` +
755
1489
  `\nright now:\n` +
756
1490
  liveReading(sessionId, idle) +
757
- `\n\nthe manager: last armed ${age} min ago` +
1491
+ `\n\nthe manager: ${armed}` +
758
1492
  (existing.pending.length ? `, ${existing.pending.length} instruction(s) waiting to go out` : "") +
759
1493
  (last ? `\n${last}` : ""),
760
1494
  };
@@ -782,10 +1516,38 @@ export async function handleManage(sessionIdOrName, rawArg) {
782
1516
  return {
783
1517
  ok: true,
784
1518
  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 ? "…" : ""}` +
1519
+ 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` +
1520
+ landsWhen(existing) +
786
1521
  (dropped ? `\n ${dropped} pending instruction(s) dropped — they were written against the old objective.` : ""),
787
1522
  };
788
1523
  }
1524
+ /**
1525
+ * add — EXTEND the standing objective instead of replacing it.
1526
+ *
1527
+ * The alternative is to say it to the session directly, and for anything whose
1528
+ * result lands on disk that works fine. It fails for anything meant to steer
1529
+ * the work, and fails silently: the objective is re-typed at every arming, so
1530
+ * the session is periodically returned to a description of the job that never
1531
+ * mentioned the thing you added. Rewriting the whole objective to append one
1532
+ * sentence is the workaround this exists to remove, and a costly one, since
1533
+ * retyping something long is how a constraint gets dropped by accident.
1534
+ *
1535
+ * Joined with a space rather than a paragraph break because this text is typed
1536
+ * at a prompt, where a newline submits — see goalText.
1537
+ */
1538
+ const addMatch = arg.match(/^(?:add|also|append|extend)\s+([\s\S]+)$/i);
1539
+ if (addMatch && existing) {
1540
+ const extra = addMatch[1].trim();
1541
+ existing.objective = `${existing.objective} Also: ${extra}`;
1542
+ note(existing, `objective extended: ${extra.slice(0, 80)}`);
1543
+ saveState(state);
1544
+ return {
1545
+ ok: true,
1546
+ managed: true,
1547
+ message: `objective extended for ${name}.\n added: ${extra}\n` +
1548
+ landsWhen(existing),
1549
+ };
1550
+ }
789
1551
  /**
790
1552
  * hands off / hands on — take the screen back without stopping the work.
791
1553
  *
@@ -870,6 +1632,75 @@ export async function handleManage(sessionIdOrName, rawArg) {
870
1632
  : `\n stays this way until you say otherwise.`),
871
1633
  };
872
1634
  }
1635
+ /**
1636
+ * handover <path> — where this session writes what it knows.
1637
+ *
1638
+ * Rollover is OFF until this is set, deliberately. Clearing a session that has
1639
+ * nowhere to write is destroying it, and a default guess at a path would be a
1640
+ * guess about somebody's project conventions with an unrecoverable failure
1641
+ * mode. Naming the file is the act of consenting to be rolled over.
1642
+ */
1643
+ /**
1644
+ * `handover <path> [clear]` — where to write, and whether to clear after.
1645
+ *
1646
+ * The trailing word is what makes clearing opt-in. It reads as an
1647
+ * afterthought and is the opposite: without it this asks a session to record
1648
+ * what it knows and then leaves it alone, which is the behaviour that has
1649
+ * actually held up. With it, the session is also cleared — worth having for a
1650
+ * session that idles between items, and worth refusing to do by default for
1651
+ * one that works in long turns, where the clear cannot execute and merely
1652
+ * queues.
1653
+ */
1654
+ const handoverMatch = arg.match(/^handover\s+(.+)$/i);
1655
+ if (handoverMatch && existing) {
1656
+ const rest = handoverMatch[1].trim();
1657
+ const wantsClear = /\s+clear$/i.test(rest);
1658
+ const path = rest.replace(/\s+clear$/i, "").trim();
1659
+ const previous = existing.handoverFile;
1660
+ existing.handoverFile = path;
1661
+ existing.clearAfterHandover = wantsClear;
1662
+ delete existing.handoverDoneAt;
1663
+ delete existing.handoverDoneK;
1664
+ delete existing.handoverAskedPath;
1665
+ const resolved = resolveHandoverPath(path);
1666
+ /**
1667
+ * Tell the session its target moved, now rather than at the next threshold.
1668
+ *
1669
+ * A session that maintains its handover as it works — which is the habit
1670
+ * worth having — keeps writing to whatever path it last heard. Left
1671
+ * uninformed it goes on updating a file nobody will read again, and the
1672
+ * change looks like it worked right up until the moment somebody needs the
1673
+ * file. The guard against typing over half-written input applies here as it
1674
+ * does everywhere else; if it declines, the next request carries the path
1675
+ * anyway, so nothing is lost by staying out of the way.
1676
+ */
1677
+ if (previous && previous !== path && !promptHasUnsentText(readPane(sessionId))) {
1678
+ typeIntoSession(sessionId, `Your handover file has moved: write it to ${resolved} from now on, not ${resolveHandoverPath(previous)}. ` +
1679
+ (existsSync(resolved)
1680
+ ? `Keep it current as you work.`
1681
+ : `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.`));
1682
+ }
1683
+ note(existing, `handover file set to ${path} — asked for at ${Math.round(HANDOVER_AT * 100)}% context${wantsClear ? ", then cleared" : ", no clear"}`);
1684
+ saveState(state);
1685
+ return {
1686
+ ok: true,
1687
+ managed: true,
1688
+ message: `${name} will be asked to hand over at ${Math.round(HANDOVER_AT * 100)}% of its context, into ${path}.\n` +
1689
+ (resolved === path
1690
+ ? ""
1691
+ : ` Today that resolves to ${resolved}; the date is worked out each time it is asked for, not now.\n`) +
1692
+ (wantsClear
1693
+ ? ` It is then cleared, once that file has changed on disk and not before.\n` +
1694
+ ` Note: a clear cannot run while a turn is in progress — for a session that works in\n` +
1695
+ ` long turns it will queue rather than take effect. Prefer the default there.`
1696
+ : ` It is NOT cleared — it keeps its context and carries on, and the terminal compacts\n` +
1697
+ ` when it needs to. The handover is what makes that compaction cheap.\n` +
1698
+ ` Add the word "clear" after the path if you want the old behaviour.`) +
1699
+ (existsSync(resolved)
1700
+ ? ""
1701
+ : `\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.`),
1702
+ };
1703
+ }
873
1704
  if (word === "pause" || word === "resume") {
874
1705
  if (!existing)
875
1706
  return { ok: false, message: `${name} is not being managed` };
@@ -881,10 +1712,10 @@ export async function handleManage(sessionIdOrName, rawArg) {
881
1712
  /**
882
1713
  * REFUSE TO MANAGE ANYTHING THAT IS NOT A SESSION.
883
1714
  *
884
- * `aibroker manage status CaseLeaf` — keyword first, session second —
1715
+ * `aibroker manage status <session>` — keyword first, session second —
885
1716
  * resolved to the plain shell the command was typed in, and the remainder
886
1717
  * 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
1718
+ * with the objective "status <session>". Nothing would ever have come of it
888
1719
  * except goals typed at a shell prompt.
889
1720
  *
890
1721
  * The arm path already refuses a bare shell. That is too late: by then a