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.
- package/dist/adapters/pailot/gateway.d.ts +12 -1
- package/dist/adapters/pailot/gateway.d.ts.map +1 -1
- package/dist/adapters/pailot/gateway.js +14 -3
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/adapters/pailot/mqtt-broker.d.ts +12 -0
- package/dist/adapters/pailot/mqtt-broker.d.ts.map +1 -1
- package/dist/adapters/pailot/mqtt-broker.js +18 -0
- package/dist/adapters/pailot/mqtt-broker.js.map +1 -1
- package/dist/daemon/dialogs.d.ts +37 -0
- package/dist/daemon/dialogs.d.ts.map +1 -0
- package/dist/daemon/dialogs.js +100 -0
- package/dist/daemon/dialogs.js.map +1 -0
- package/dist/daemon/manage.d.ts +92 -0
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +845 -14
- package/dist/daemon/manage.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon/manage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|