aibroker 0.35.2 → 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/core/hybrid.d.ts.map +1 -1
- package/dist/core/hybrid.js +6 -0
- package/dist/core/hybrid.js.map +1 -1
- package/dist/daemon/cli.js +78 -0
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +66 -1
- package/dist/daemon/core-handlers.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/index.d.ts.map +1 -1
- package/dist/daemon/index.js +13 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/machine.d.ts +63 -0
- package/dist/daemon/machine.d.ts.map +1 -0
- package/dist/daemon/machine.js +153 -0
- package/dist/daemon/machine.js.map +1 -0
- package/dist/daemon/manage.d.ts +169 -0
- package/dist/daemon/manage.d.ts.map +1 -0
- package/dist/daemon/manage.js +1773 -0
- package/dist/daemon/manage.js.map +1 -0
- package/dist/daemon/peer-cli.d.ts +9 -0
- package/dist/daemon/peer-cli.d.ts.map +1 -0
- package/dist/daemon/peer-cli.js +164 -0
- package/dist/daemon/peer-cli.js.map +1 -0
- package/dist/daemon/peer-handlers.d.ts +35 -0
- package/dist/daemon/peer-handlers.d.ts.map +1 -0
- package/dist/daemon/peer-handlers.js +165 -0
- package/dist/daemon/peer-handlers.js.map +1 -0
- package/dist/daemon/standup.d.ts +69 -0
- package/dist/daemon/standup.d.ts.map +1 -0
- package/dist/daemon/standup.js +141 -0
- package/dist/daemon/standup.js.map +1 -0
- package/dist/ipc/peering.d.ts +103 -0
- package/dist/ipc/peering.d.ts.map +1 -0
- package/dist/ipc/peering.js +185 -0
- package/dist/ipc/peering.js.map +1 -0
- package/dist/ipc/server.d.ts +16 -0
- package/dist/ipc/server.d.ts.map +1 -1
- package/dist/ipc/server.js +76 -0
- package/dist/ipc/server.js.map +1 -1
- package/dist/mcp/index.js +45 -0
- package/dist/mcp/index.js.map +1 -1
- package/hooks/manage-hook.mjs +204 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/manage.ts — keep a session working on a standing objective.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM THIS SOLVES. A session driven by a goal decides at the end of a
|
|
5
|
+
* cycle whether the goal was met, and then stops. Left alone it stops for the
|
|
6
|
+
* night; re-armed, it works for as long as you let it. Two nights of running
|
|
7
|
+
* that by hand produced sixteen hours of unattended work and a list of ways the
|
|
8
|
+
* arrangement breaks, all of which are answered here.
|
|
9
|
+
*
|
|
10
|
+
* WHY IT LIVES IN THE DAEMON RATHER THAN IN A SESSION. The first version was a
|
|
11
|
+
* script driven from another Claude session, which worked and cost that session
|
|
12
|
+
* its whole context — and worse, talking to the manager meant interrupting the
|
|
13
|
+
* manager, because observing occupied the same turn the instruction would have
|
|
14
|
+
* arrived on. An instrument that consumes the channel it is watched through
|
|
15
|
+
* cannot be redirected without being stopped. So: a process with a mailbox.
|
|
16
|
+
* Writing to a mailbox never requires the reader to be idle.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT DOES NOT DO. It does not judge the work. It re-arms an objective, it
|
|
19
|
+
* carries one-shot instructions from the operator into the next arming, and it
|
|
20
|
+
* says what it did. Everything requiring judgement stays with the person.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { log } from "../core/log.js";
|
|
27
|
+
import { readSessionContent } from "./session-content.js";
|
|
28
|
+
import { typeIntoSession } from "../transport/sync-facade.js";
|
|
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";
|
|
33
|
+
const STATE_FILE = join(homedir(), ".aibroker", "managers.json");
|
|
34
|
+
/** How often every manager is looked at. Cheap: one content read per managed session. */
|
|
35
|
+
const TICK_MS = 20_000;
|
|
36
|
+
/**
|
|
37
|
+
* A session with no goal is not working on one, whatever else it is doing.
|
|
38
|
+
*
|
|
39
|
+
* The first version also required a long silence, and that never fired: a
|
|
40
|
+
* session answering messages moves its transcript and resets any quiet timer,
|
|
41
|
+
* so the loop waited for a silence that conversation kept postponing. The grace
|
|
42
|
+
* below exists only to avoid arming in the middle of the turn that just ended
|
|
43
|
+
* the last goal.
|
|
44
|
+
*/
|
|
45
|
+
const NO_GOAL_GRACE_MS = 30_000;
|
|
46
|
+
/** Never re-arm twice inside this window, whatever the signals say. */
|
|
47
|
+
const REARM_COOLDOWN_MS = 90_000;
|
|
48
|
+
/**
|
|
49
|
+
* How long an armed goal is believed before the manager stops waiting for it.
|
|
50
|
+
*
|
|
51
|
+
* Not a timeout on the work. A ceiling on the manager's willingness to sit on a
|
|
52
|
+
* signal that may never arrive — an item can legitimately end without the
|
|
53
|
+
* marker clearing, and a loop with no ceiling waits forever while every
|
|
54
|
+
* heartbeat reads healthy.
|
|
55
|
+
*/
|
|
56
|
+
const GOAL_MAX_AGE_MS = 45 * 60_000;
|
|
57
|
+
/**
|
|
58
|
+
* The status-line marker shown while a goal is armed.
|
|
59
|
+
*
|
|
60
|
+
* IT IS A PROXY AND IT LIES. Read off the terminal, it does not clear when the
|
|
61
|
+
* goal is met — it once read "active" for ninety minutes after the session had
|
|
62
|
+
* finished, committed six times and gone idle. It is used here only in
|
|
63
|
+
* conjunction with the age ceiling above, never on its own.
|
|
64
|
+
*/
|
|
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;
|
|
176
|
+
/** Verdicts that mean the session has run out of goal and said so. */
|
|
177
|
+
const OUT_OF_GOAL = [
|
|
178
|
+
/goal could not be achieved/i,
|
|
179
|
+
/goal not achieved/i,
|
|
180
|
+
/could not achieve the goal/i,
|
|
181
|
+
];
|
|
182
|
+
/**
|
|
183
|
+
* A duration nobody can print nonsense from.
|
|
184
|
+
*
|
|
185
|
+
* The defect this closes: a sentinel `0` meaning "no timestamp" was subtracted
|
|
186
|
+
* from the clock and formatted as an age, so a log line read "armed 29,779,818
|
|
187
|
+
* min" — the age of the Unix epoch, internally correct and externally absurd.
|
|
188
|
+
* One code path was fixed; this closes the class, because the next path to
|
|
189
|
+
* format a duration from a suspect timestamp would have printed it again.
|
|
190
|
+
*
|
|
191
|
+
* Anything beyond a month is not a duration in this system, it is a bad
|
|
192
|
+
* subtraction, and saying so is more useful than a number with eight digits.
|
|
193
|
+
*/
|
|
194
|
+
const IMPLAUSIBLE_MS = 31 * 24 * 60 * 60_000;
|
|
195
|
+
function minutesSince(then, now) {
|
|
196
|
+
const ms = now - then;
|
|
197
|
+
if (!Number.isFinite(ms) || ms < 0 || ms > IMPLAUSIBLE_MS)
|
|
198
|
+
return "an unknown time";
|
|
199
|
+
return `${Math.round(ms / 60000)} min`;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Repair history lines produced by that defect, once, on load.
|
|
203
|
+
*
|
|
204
|
+
* Normally a log is struck forward rather than rewritten — a record that edits
|
|
205
|
+
* its own history is worth less than one that does not. This is the exception
|
|
206
|
+
* and it is narrow: the line is not a claim anybody needs to audit, it is a
|
|
207
|
+
* garbled rendering of an event that did happen, produced by a bug that no
|
|
208
|
+
* longer exists. What it records is preserved; only the impossible number goes.
|
|
209
|
+
*/
|
|
210
|
+
function repairHistory(s) {
|
|
211
|
+
let changed = false;
|
|
212
|
+
for (const m of Object.values(s)) {
|
|
213
|
+
for (const h of m.history ?? []) {
|
|
214
|
+
const bad = h.what.match(/armed (\d{7,}) min/);
|
|
215
|
+
if (bad) {
|
|
216
|
+
h.what = h.what.replace(bad[0], "armed for an unknown time (a defect in this manager's own arithmetic, fixed 2026-08-15)");
|
|
217
|
+
changed = true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return changed;
|
|
222
|
+
}
|
|
223
|
+
function loadState() {
|
|
224
|
+
try {
|
|
225
|
+
if (existsSync(STATE_FILE)) {
|
|
226
|
+
const s = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
227
|
+
if (repairHistory(s)) {
|
|
228
|
+
saveState(s);
|
|
229
|
+
log("[manage] repaired history lines left by the epoch-duration defect");
|
|
230
|
+
}
|
|
231
|
+
return s;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
log(`[manage] state unreadable, starting empty — ${e.message}`);
|
|
236
|
+
}
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
function saveState(s) {
|
|
240
|
+
try {
|
|
241
|
+
writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
log(`[manage] could not save state — ${e.message}`);
|
|
245
|
+
}
|
|
246
|
+
for (const m of Object.values(s))
|
|
247
|
+
mirrorToRepo(m);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Put what a session knows INTO THE PROJECT, not beside it.
|
|
251
|
+
*
|
|
252
|
+
* On one machine this is tidiness. Across machines it is the entire
|
|
253
|
+
* synchronisation mechanism: git already moves work between developers, so
|
|
254
|
+
* knowledge committed to the repository travels with the branch, merges with
|
|
255
|
+
* the branch and arrives on every machine without anybody building a protocol
|
|
256
|
+
* for it. Knowledge in a home directory reaches exactly one machine, and which
|
|
257
|
+
* machine that is depends on where somebody happened to be sitting.
|
|
258
|
+
*
|
|
259
|
+
* The same argument in the other direction is why this file is not the record:
|
|
260
|
+
* `~/.aibroker` is per-machine state — sockets, tokens, timers — and that is
|
|
261
|
+
* correct for things that describe a machine. An objective describes the WORK,
|
|
262
|
+
* so it belongs where the work is.
|
|
263
|
+
*
|
|
264
|
+
* Written as markdown rather than the state JSON because the reader is the next
|
|
265
|
+
* agent to open the repository, possibly on another machine, possibly weeks
|
|
266
|
+
* later. It should not need this program to make sense of what it finds.
|
|
267
|
+
*/
|
|
268
|
+
function mirrorToRepo(m) {
|
|
269
|
+
try {
|
|
270
|
+
// Resolve the pane NOW rather than trusting one recorded at creation.
|
|
271
|
+
// A field captured once is a field that is absent on every record made
|
|
272
|
+
// before it existed and wrong for any session that has since moved — and
|
|
273
|
+
// both of those fail silently, which is how a mirror stops mirroring
|
|
274
|
+
// without anybody noticing.
|
|
275
|
+
const tty = m.tty ?? snapshotTty(m.sessionId);
|
|
276
|
+
const proc = tty ? processReading(tty) : { isSession: false, pid: null };
|
|
277
|
+
if (!proc.pid)
|
|
278
|
+
return;
|
|
279
|
+
const cwd = repoRootFor(proc.pid);
|
|
280
|
+
if (!cwd)
|
|
281
|
+
return;
|
|
282
|
+
const dir = join(cwd, ".aibroker");
|
|
283
|
+
if (!existsSync(dir))
|
|
284
|
+
mkdirSync(dir, { recursive: true });
|
|
285
|
+
const recent = m.history.slice(-12).map((h) => `- ${h.at} — ${h.what}`).join("\n");
|
|
286
|
+
const body = `# Session: ${m.name}\n\n` +
|
|
287
|
+
`_Written by the manager. It travels with this branch, which is the point:\n` +
|
|
288
|
+
`whichever machine picks this work up next reads it here rather than\n` +
|
|
289
|
+
`rediscovering it._\n\n` +
|
|
290
|
+
`## Standing objective\n\n${m.objective}\n\n` +
|
|
291
|
+
(m.pending.length ? `## Waiting to be carried into the next cycle\n\n${m.pending.map((p) => `- ${p}`).join("\n")}\n\n` : "") +
|
|
292
|
+
(m.noScreen ? `## Screen\n\nScreen work is currently withheld - the operator has the machine.\n\n` : "") +
|
|
293
|
+
`## Recent\n\n${recent || "- nothing yet"}\n`;
|
|
294
|
+
const file = join(dir, `session-${m.name.replace(/[^A-Za-z0-9._-]/g, "-")}.md`);
|
|
295
|
+
// Only write on change. A file rewritten every twenty seconds turns a
|
|
296
|
+
// repository into a stream of no-op commits and trains everyone to ignore it.
|
|
297
|
+
const before = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
298
|
+
if (before !== body)
|
|
299
|
+
writeFileSync(file, body);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
log(`[manage] could not mirror into the repository — ${e.message}`);
|
|
303
|
+
}
|
|
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
|
+
}
|
|
426
|
+
/** The pane device for a session, captured once at start. */
|
|
427
|
+
function snapshotTty(sessionId) {
|
|
428
|
+
return discoverLiveSessions().find((s) => s.id === sessionId)?.tty;
|
|
429
|
+
}
|
|
430
|
+
/** The checkout a process is sitting in, or null if it is not in one. */
|
|
431
|
+
function repoRootFor(pid) {
|
|
432
|
+
try {
|
|
433
|
+
const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", pid, "-a", "-d", "cwd", "-Fn"], {
|
|
434
|
+
encoding: "utf8",
|
|
435
|
+
timeout: 4_000,
|
|
436
|
+
});
|
|
437
|
+
const cwd = cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1);
|
|
438
|
+
if (!cwd)
|
|
439
|
+
return null;
|
|
440
|
+
const root = execFileSync("/usr/bin/env", ["git", "-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
441
|
+
encoding: "utf8",
|
|
442
|
+
timeout: 4_000,
|
|
443
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
444
|
+
}).trim();
|
|
445
|
+
return root || null;
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
let state = loadState();
|
|
452
|
+
let timer = null;
|
|
453
|
+
function hash(s) {
|
|
454
|
+
let h = 0;
|
|
455
|
+
for (let i = 0; i < s.length; i++)
|
|
456
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
457
|
+
return String(h);
|
|
458
|
+
}
|
|
459
|
+
function note(m, what) {
|
|
460
|
+
// Local time, like the daemon log. A history stamped in UTC beside a log
|
|
461
|
+
// stamped locally is two clocks in one investigation, and the whole point of
|
|
462
|
+
// this record is to be read at three in the morning by someone in a hurry.
|
|
463
|
+
const d = new Date();
|
|
464
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
465
|
+
const at = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
466
|
+
m.history.push({ at, what });
|
|
467
|
+
if (m.history.length > 40)
|
|
468
|
+
m.history = m.history.slice(-40);
|
|
469
|
+
log(`[manage:${m.name}] ${what}`);
|
|
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
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Resolve a session by whatever the caller knows — its id, or its name.
|
|
581
|
+
*
|
|
582
|
+
* The hook knows the working directory and the terminal session; a person
|
|
583
|
+
* knows the name. Both have to land on the same record.
|
|
584
|
+
*/
|
|
585
|
+
export function resolveSession(idOrName) {
|
|
586
|
+
const live = discoverLiveSessions();
|
|
587
|
+
// The terminal's own id may arrive as "w3t1p0:UUID"; the pane is the UUID.
|
|
588
|
+
const id = idOrName.includes(":") ? (idOrName.split(":").pop() ?? idOrName) : idOrName;
|
|
589
|
+
const byId = live.find((s) => s.id === id || s.aibrokerId === id);
|
|
590
|
+
if (byId)
|
|
591
|
+
return { sessionId: byId.id, name: byId.paiName ?? byId.name ?? id };
|
|
592
|
+
const needle = idOrName.toLowerCase();
|
|
593
|
+
const byName = live.find((s) => (s.paiName ?? "").toLowerCase() === needle || (s.name ?? "").toLowerCase().includes(needle));
|
|
594
|
+
if (byName)
|
|
595
|
+
return { sessionId: byName.id, name: byName.paiName ?? byName.name ?? idOrName };
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* What is actually running in this pane, from the PROCESS TABLE.
|
|
600
|
+
*
|
|
601
|
+
* WHY THIS EXISTS AT ALL. Everything below used to be inferred from the text on
|
|
602
|
+
* screen, and that is hopeless: three filters in a row settled on chrome, one of
|
|
603
|
+
* them reporting a "use /clear to free up context" tip as the session's activity
|
|
604
|
+
* for minutes. Parsing a terminal UI means guessing at somebody's prompt theme,
|
|
605
|
+
* their status line and the framework's own banners — a proxy for a question the
|
|
606
|
+
* operating system answers exactly.
|
|
607
|
+
*
|
|
608
|
+
* Each pane has a tty and the session process sits on it. That answers exactly
|
|
609
|
+
* ONE question, which is the one worth asking here: is there a `claude` on this
|
|
610
|
+
* tty at all, or is the pane a bare shell? A goal typed at a shell prompt runs
|
|
611
|
+
* as shell commands — that has happened, and it was harmless only by luck.
|
|
612
|
+
*
|
|
613
|
+
* It does NOT answer "is it working". An earlier version read that from a
|
|
614
|
+
* `caffeinate` child and it discriminated perfectly across five panes — and it
|
|
615
|
+
* is still the wrong thing to depend on, because it is an implementation detail
|
|
616
|
+
* of one client on one operating system. A signal that happens to correlate
|
|
617
|
+
* today is the definition of a proxy, and this file has been caught by four of
|
|
618
|
+
* them already. That question belongs to the transcript below, which is the
|
|
619
|
+
* session's own record rather than a side effect of it.
|
|
620
|
+
*/
|
|
621
|
+
function processReading(tty) {
|
|
622
|
+
const dev = tty.replace(/^\/dev\//, "");
|
|
623
|
+
let out = "";
|
|
624
|
+
try {
|
|
625
|
+
out = execFileSync("/bin/ps", ["-t", dev, "-o", "pid=,ppid=,etime=,command="], {
|
|
626
|
+
encoding: "utf8",
|
|
627
|
+
timeout: 4_000,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// No processes on that tty, or ps refused. Either way nothing can be said.
|
|
632
|
+
return { isSession: false, pid: null };
|
|
633
|
+
}
|
|
634
|
+
const rows = out
|
|
635
|
+
.split("\n")
|
|
636
|
+
.map((l) => l.trim())
|
|
637
|
+
.filter(Boolean)
|
|
638
|
+
.map((l) => {
|
|
639
|
+
const m = l.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
640
|
+
return m ? { pid: m[1], ppid: m[2], etime: m[3], cmd: m[4] } : null;
|
|
641
|
+
})
|
|
642
|
+
.filter((r) => r !== null);
|
|
643
|
+
const claude = rows.find((r) => /(^|\/)claude$/.test(r.cmd.split(/\s+/)[0]));
|
|
644
|
+
if (!claude)
|
|
645
|
+
return { isSession: false, pid: null };
|
|
646
|
+
return { isSession: true, pid: claude.pid };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* What the session is doing, from its own transcript — the authority.
|
|
650
|
+
*
|
|
651
|
+
* THE PROCESS TABLE WAS THE SECOND WRONG ANSWER. Reading the screen was the
|
|
652
|
+
* first: three filters in a row settled on chrome. Then `caffeinate`, which
|
|
653
|
+
* discriminated perfectly across five panes and is still wrong to depend on —
|
|
654
|
+
* it is an implementation detail of one client on one operating system, and a
|
|
655
|
+
* signal that happens to correlate today is the definition of a proxy. The
|
|
656
|
+
* question was never "what did this spawn", it is "what is the session doing",
|
|
657
|
+
* and the session writes that down itself.
|
|
658
|
+
*
|
|
659
|
+
* Every session keeps a JSONL transcript: one entry per message, each carrying
|
|
660
|
+
* a timestamp, the tool being called by name, and real token usage. From it,
|
|
661
|
+
* without parsing a single line of terminal output:
|
|
662
|
+
*
|
|
663
|
+
* - WORKING or NOT: the last entry is a tool call awaiting its result, or it
|
|
664
|
+
* is finished text. No inference from spinners.
|
|
665
|
+
* - WHAT: the tool's own name, as the client recorded it.
|
|
666
|
+
* - CONTEXT: summed from usage rather than scraped off somebody's status bar,
|
|
667
|
+
* which required a particular status bar and gave nothing without it.
|
|
668
|
+
* - WHEN: the entry's timestamp, so "how long has this been going" is a
|
|
669
|
+
* subtraction rather than a guess.
|
|
670
|
+
*/
|
|
671
|
+
function transcriptReading(claudePid) {
|
|
672
|
+
const none = { working: null, doing: null, contextK: null, lastAt: null };
|
|
673
|
+
try {
|
|
674
|
+
// The transcript directory is named for the session's working directory,
|
|
675
|
+
// which the process itself is the authority on.
|
|
676
|
+
const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", claudePid, "-a", "-d", "cwd", "-Fn"], {
|
|
677
|
+
encoding: "utf8",
|
|
678
|
+
timeout: 4_000,
|
|
679
|
+
});
|
|
680
|
+
const cwd = cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1);
|
|
681
|
+
if (!cwd)
|
|
682
|
+
return none;
|
|
683
|
+
const dir = join(homedir(), ".claude", "projects", cwd.replace(/\//g, "-"));
|
|
684
|
+
if (!existsSync(dir))
|
|
685
|
+
return none;
|
|
686
|
+
// The live transcript is the one being written. Newest wins; a session that
|
|
687
|
+
// has not written for a long time will show that in its own timestamp
|
|
688
|
+
// rather than being silently mistaken for a fresh one.
|
|
689
|
+
const newest = readdirSync(dir)
|
|
690
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
691
|
+
.map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs }))
|
|
692
|
+
.sort((a, b) => b.m - a.m)[0];
|
|
693
|
+
if (!newest)
|
|
694
|
+
return none;
|
|
695
|
+
// Only the tail is needed and these files reach tens of megabytes.
|
|
696
|
+
const raw = execFileSync("/usr/bin/tail", ["-n", "40", join(dir, newest.f)], {
|
|
697
|
+
encoding: "utf8",
|
|
698
|
+
timeout: 4_000,
|
|
699
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
700
|
+
});
|
|
701
|
+
const msgs = [];
|
|
702
|
+
for (const line of raw.split("\n")) {
|
|
703
|
+
if (!line.trim())
|
|
704
|
+
continue;
|
|
705
|
+
try {
|
|
706
|
+
const j = JSON.parse(line);
|
|
707
|
+
if (j.type === "assistant" || j.type === "user")
|
|
708
|
+
msgs.push(j);
|
|
709
|
+
}
|
|
710
|
+
catch { /* a truncated first line is normal when tailing */ }
|
|
711
|
+
}
|
|
712
|
+
if (!msgs.length)
|
|
713
|
+
return none;
|
|
714
|
+
const last = msgs[msgs.length - 1];
|
|
715
|
+
const lastAt = last.timestamp ? Date.parse(last.timestamp) : null;
|
|
716
|
+
// A tool call with no result after it is work in flight. A finished
|
|
717
|
+
// assistant message is a turn that has ended.
|
|
718
|
+
const lastAssistant = [...msgs].reverse().find((m) => m.type === "assistant");
|
|
719
|
+
const content = lastAssistant?.message?.content;
|
|
720
|
+
const toolUse = Array.isArray(content) ? content.filter((c) => c.type === "tool_use") : [];
|
|
721
|
+
const working = last.type === "assistant" ? toolUse.length > 0 : true;
|
|
722
|
+
const doing = toolUse.length
|
|
723
|
+
? toolUse.map((t) => t.name).join(", ")
|
|
724
|
+
: last.type === "user"
|
|
725
|
+
? "waiting on a tool result"
|
|
726
|
+
: null;
|
|
727
|
+
const u = lastAssistant?.message?.usage;
|
|
728
|
+
const contextK = u
|
|
729
|
+
? Math.round(((u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)) / 1000)
|
|
730
|
+
: null;
|
|
731
|
+
return { working, doing, contextK, lastAt };
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
return none;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* The status line, assembled from the sources in order of authority.
|
|
739
|
+
*
|
|
740
|
+
* The transcript first, because it is the session's own record: the tool by
|
|
741
|
+
* name, the context from real usage, the time of the last entry. The process
|
|
742
|
+
* table second, for the one thing it settles — whether this is a session at
|
|
743
|
+
* all. The screen last and only for the goal marker, which exists nowhere else.
|
|
744
|
+
*
|
|
745
|
+
* Each line says where it came from. That is not decoration: the pane readings
|
|
746
|
+
* have been wrong for ninety minutes at a stretch, and a reader who cannot tell
|
|
747
|
+
* which number came from the transcript and which was scraped off a status bar
|
|
748
|
+
* cannot tell which one to doubt.
|
|
749
|
+
*/
|
|
750
|
+
function liveReading(sessionId, idleSec) {
|
|
751
|
+
const snap = discoverLiveSessions().find((s) => s.id === sessionId);
|
|
752
|
+
const proc = snap?.tty ? processReading(snap.tty) : { isSession: false, pid: null };
|
|
753
|
+
if (!proc.isSession) {
|
|
754
|
+
return " no session process on that pane — it is a bare shell, or the session has exited";
|
|
755
|
+
}
|
|
756
|
+
const t = proc.pid ? transcriptReading(proc.pid) : { working: null, doing: null, contextK: null, lastAt: null };
|
|
757
|
+
const out = [];
|
|
758
|
+
if (t.lastAt !== null) {
|
|
759
|
+
const agoSec = Math.round((Date.now() - t.lastAt) / 1000);
|
|
760
|
+
out.push(` ${t.working ? "working" : "idle"} · last transcript entry ${agoSec < 90 ? `${agoSec}s` : `${Math.round(agoSec / 60)} min`} ago` +
|
|
761
|
+
(t.doing ? ` · ${t.doing}` : ""));
|
|
762
|
+
if (t.contextK !== null)
|
|
763
|
+
out.push(` context ${t.contextK}k tokens (from the transcript's own usage, not the status bar)`);
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
out.push(` a session is running, but its transcript could not be read — falling back to the screen`);
|
|
767
|
+
}
|
|
768
|
+
const content = readPane(sessionId);
|
|
769
|
+
if (GOAL_ACTIVE.test(content)) {
|
|
770
|
+
out.push(` goal marker present on screen (a proxy — it lingers after a goal is met)`);
|
|
771
|
+
}
|
|
772
|
+
out.push(` pane unchanged for ${idleSec}s`);
|
|
773
|
+
return out.join("\n");
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* What the session appears to be doing, right now, read fresh.
|
|
777
|
+
*
|
|
778
|
+
* EVERYTHING HERE IS A READING AND IS LABELLED AS ONE. The goal marker is
|
|
779
|
+
* scraped off a status line and has been wrong by ninety minutes; "busy" is
|
|
780
|
+
* inferred from a spinner. The point is not to be authoritative — it is that
|
|
781
|
+
* asking the manager what is going on should not require going and looking, and
|
|
782
|
+
* a reading you know is a reading beats no reading at all.
|
|
783
|
+
*/
|
|
784
|
+
function paneReading(content) {
|
|
785
|
+
if (!content)
|
|
786
|
+
return " the pane could not be read";
|
|
787
|
+
const lines = content.split("\n").map((l) => l.trimEnd());
|
|
788
|
+
const marker = GOAL_ACTIVE.test(content);
|
|
789
|
+
// Two formats appear depending on the status line in use: "81% context used"
|
|
790
|
+
// and "Context: 730K / 1000K". Reading only the first reported nothing at all
|
|
791
|
+
// on a session using the second, which looks exactly like a session with no
|
|
792
|
+
// context reading rather than a reader that cannot see this one.
|
|
793
|
+
const ctx = content.match(/(\d{1,3})%\s*context\s*used/i)?.[1] ??
|
|
794
|
+
(() => {
|
|
795
|
+
const m = content.match(/Context:\s*([\d.]+)K\s*\/\s*([\d.]+)K/i);
|
|
796
|
+
return m ? String(Math.round((Number(m[1]) / Number(m[2])) * 100)) : undefined;
|
|
797
|
+
})();
|
|
798
|
+
const busy = /·\s*↓|tokens\)|esc to interrupt|✻|✽/i.test(content);
|
|
799
|
+
/**
|
|
800
|
+
* What it is doing — taken from the STRUCTURE, not from guessing at prose.
|
|
801
|
+
*
|
|
802
|
+
* Three attempts failed before this one, each a filter over "which line looks
|
|
803
|
+
* like real output": a blocklist of chrome (missed two entries), then a
|
|
804
|
+
* word-count test (settled on a "Use /clear to start fresh" tip and reported
|
|
805
|
+
* it as the session's activity for minutes on end). Both were proxies for a
|
|
806
|
+
* question the terminal already answers explicitly.
|
|
807
|
+
*
|
|
808
|
+
* Looking at an actual pane settles it. Tool invocations are marked with a
|
|
809
|
+
* bullet and name what is running. The activity line carries the elapsed time
|
|
810
|
+
* and the tokens drawn. Those are the two facts worth having, they are
|
|
811
|
+
* identifiable by their own markers rather than by their wording, and the tip
|
|
812
|
+
* banner that fooled the last version shares a prefix with real output but
|
|
813
|
+
* carries neither marker.
|
|
814
|
+
*/
|
|
815
|
+
const doing = lines.filter((l) => /^\s*⏺/.test(l)).slice(-1)[0]?.replace(/^\s*⏺\s*/, "");
|
|
816
|
+
const activity = content.match(/([A-Za-z]+…)\s*\(([^)]*)\)/);
|
|
817
|
+
const elapsed = activity?.[2];
|
|
818
|
+
const parts = [
|
|
819
|
+
` looks ${busy ? "busy" : "idle"}`,
|
|
820
|
+
ctx ? `context ${ctx}%` : null,
|
|
821
|
+
// The elapsed time is the number that tells you whether to worry. A session
|
|
822
|
+
// ninety minutes into one turn is either deep in something or stuck, and
|
|
823
|
+
// both are worth knowing; neither is visible from "busy".
|
|
824
|
+
elapsed ? `on this turn ${elapsed.replace(/\s*·\s*/g, ", ")}` : null,
|
|
825
|
+
`goal marker ${marker ? "present" : "absent"}${marker ? " (a proxy — it lingers after a goal is met)" : ""}`,
|
|
826
|
+
].filter(Boolean);
|
|
827
|
+
return ` ${parts.join(" · ")}${doing ? `\n doing: ${doing.trim().slice(0, 110)}` : ""}`;
|
|
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
|
+
}
|
|
859
|
+
/** The text actually typed at the session. Short goal, context by reference. */
|
|
860
|
+
function goalText(m) {
|
|
861
|
+
const extra = m.pending.length ? ` OPERATOR, since you were last armed: ${m.pending.join(" ")}` : "";
|
|
862
|
+
// The screen rule has to ride along with EVERY arming. Delivered once, it
|
|
863
|
+
// lasts only until the session next reads a goal — and the goal is what tells
|
|
864
|
+
// it what to do. So a standing rule that is not in the goal is a rule with a
|
|
865
|
+
// lifetime of one turn, and the next arming would send it back to clicking.
|
|
866
|
+
const hands = m.noScreen
|
|
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."
|
|
868
|
+
: "";
|
|
869
|
+
return oneLine(`/goal ${m.objective}${hands}${extra}`);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Did it land? Look for the goal's own words in the transcript.
|
|
873
|
+
*
|
|
874
|
+
* NOT "did the content change" — that was the first version and it could not
|
|
875
|
+
* tell a goal that arrived from text stranded unsubmitted in the input line,
|
|
876
|
+
* which is the exact failure it existed to catch. A session prints for a dozen
|
|
877
|
+
* reasons; only the item's own words say the item is there.
|
|
878
|
+
*/
|
|
879
|
+
function seenInContent(content, fragment) {
|
|
880
|
+
if (!content)
|
|
881
|
+
return false;
|
|
882
|
+
return content.replace(/\s+/g, "").includes(fragment.replace(/\s+/g, ""));
|
|
883
|
+
}
|
|
884
|
+
function readPane(sessionId) {
|
|
885
|
+
try {
|
|
886
|
+
return readSessionContent(sessionId, 60)?.content ?? "";
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return "";
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async function sleep(ms) {
|
|
893
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
894
|
+
}
|
|
895
|
+
async function arm(m, reason) {
|
|
896
|
+
const text = goalText(m);
|
|
897
|
+
const fragment = m.objective.slice(0, 40);
|
|
898
|
+
/**
|
|
899
|
+
* NEVER TYPE A GOAL INTO A BARE SHELL.
|
|
900
|
+
*
|
|
901
|
+
* This is not hypothetical. A predecessor of this loop went on typing after
|
|
902
|
+
* its session had exited, and the goal landed at a zsh prompt: `/goal` became
|
|
903
|
+
* "no such file or directory" and the sentences after it became commands —
|
|
904
|
+
* `always`, `they`, `the`, all "command not found". Harmless that time
|
|
905
|
+
* entirely by luck, since a goal is prose and prose is mostly not commands.
|
|
906
|
+
* A goal whose wording happened to begin a line with a real command would
|
|
907
|
+
* have run it, in the operator's own shell, with no confirmation.
|
|
908
|
+
*
|
|
909
|
+
* `atPrompt` is exactly the discriminator: it is false for a session running
|
|
910
|
+
* Claude — the foreground process is node whether it is working or idle — and
|
|
911
|
+
* true when the shell itself is waiting for input. So true means the thing we
|
|
912
|
+
* are managing is gone, and the right move is to say so and stop, not to keep
|
|
913
|
+
* typing into whatever is there now.
|
|
914
|
+
*/
|
|
915
|
+
const live = readSessionContent(m.sessionId, 5);
|
|
916
|
+
if (!live) {
|
|
917
|
+
note(m, "the session could not be read — not typing anything");
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
if (live.atPrompt) {
|
|
921
|
+
m.paused = true;
|
|
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})`);
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
if (!typeIntoSession(m.sessionId, text)) {
|
|
942
|
+
note(m, `could not type into the session (${reason}) — will retry`);
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
// Typed is not sent, and sent is not received.
|
|
946
|
+
for (let i = 0; i < 5; i++) {
|
|
947
|
+
await sleep(2_000);
|
|
948
|
+
if (seenInContent(readPane(m.sessionId), fragment)) {
|
|
949
|
+
m.lastRearmAt = Date.now();
|
|
950
|
+
const carried = m.pending.length;
|
|
951
|
+
m.pending = [];
|
|
952
|
+
armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
|
|
953
|
+
note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
notify(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
function reasonToArm(m, content, now) {
|
|
961
|
+
if (m.paused)
|
|
962
|
+
return null;
|
|
963
|
+
if (OUT_OF_GOAL.some((re) => re.test(content)))
|
|
964
|
+
return "the session reported its goal could not be achieved";
|
|
965
|
+
// `now` zeroes the timestamp to force an arming. Without this the age is
|
|
966
|
+
// computed from the epoch and the log says "armed 29779818 min ago", which is
|
|
967
|
+
// true of a number and nonsense about the world — the kind of line that costs
|
|
968
|
+
// somebody ten minutes at three in the morning.
|
|
969
|
+
if (m.lastRearmAt === 0)
|
|
970
|
+
return "asked to arm now";
|
|
971
|
+
const quietFor = Math.max(0, now - m.lastChangeAt);
|
|
972
|
+
const armedFor = Math.max(0, now - m.lastRearmAt);
|
|
973
|
+
const marker = GOAL_ACTIVE.test(content);
|
|
974
|
+
if (!marker && quietFor > NO_GOAL_GRACE_MS)
|
|
975
|
+
return `no goal armed (idle ${Math.round(quietFor / 1000)}s)`;
|
|
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`;
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
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
|
+
}
|
|
1049
|
+
async function tick() {
|
|
1050
|
+
const now = Date.now();
|
|
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
|
+
}
|
|
1061
|
+
for (const m of Object.values(state)) {
|
|
1062
|
+
const content = readPane(m.sessionId);
|
|
1063
|
+
if (!content) {
|
|
1064
|
+
// A session that cannot be read is not necessarily gone; say so once per
|
|
1065
|
+
// tick rather than dropping it, because dropping it silently is how a
|
|
1066
|
+
// manager stops managing without anybody noticing.
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const h = hash(content);
|
|
1070
|
+
if (h !== m.lastHash) {
|
|
1071
|
+
m.lastHash = h;
|
|
1072
|
+
m.lastChangeAt = now;
|
|
1073
|
+
dirty = true;
|
|
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
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* A timed screen decision reverts itself.
|
|
1101
|
+
*
|
|
1102
|
+
* Checked before anything else in the tick, because the whole value is that
|
|
1103
|
+
* it happens without a person: "hands on for eight hours" has to hand the
|
|
1104
|
+
* screen back at the eighth hour whether or not anybody is awake to ask.
|
|
1105
|
+
*/
|
|
1106
|
+
if (m.handsUntil && now >= m.handsUntil) {
|
|
1107
|
+
const wasOff = m.handsWas === true;
|
|
1108
|
+
delete m.handsUntil;
|
|
1109
|
+
delete m.handsWas;
|
|
1110
|
+
m.noScreen = !wasOff;
|
|
1111
|
+
typeIntoSession(m.sessionId, m.noScreen
|
|
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."
|
|
1113
|
+
: "your controls. The screen is yours again — the operator's hold has expired. You may resume visual work where your notes left it.");
|
|
1114
|
+
notify(m, m.noScreen ? "timed grant expired — screen work stopped" : "timed hold expired — screen work permitted again");
|
|
1115
|
+
dirty = true;
|
|
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
|
+
}
|
|
1348
|
+
if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
|
|
1349
|
+
continue;
|
|
1350
|
+
const reason = reasonToArm(m, content, now);
|
|
1351
|
+
if (!reason)
|
|
1352
|
+
continue;
|
|
1353
|
+
await arm(m, reason);
|
|
1354
|
+
dirty = true;
|
|
1355
|
+
}
|
|
1356
|
+
if (dirty)
|
|
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);
|
|
1361
|
+
}
|
|
1362
|
+
export function startManagerLoop() {
|
|
1363
|
+
if (timer)
|
|
1364
|
+
return;
|
|
1365
|
+
state = loadState();
|
|
1366
|
+
const n = Object.keys(state).length;
|
|
1367
|
+
if (n)
|
|
1368
|
+
log(`[manage] resuming ${n} managed session${n > 1 ? "s" : ""}`);
|
|
1369
|
+
timer = setInterval(() => {
|
|
1370
|
+
void tick().catch((e) => log(`[manage] tick failed — ${e.message}`));
|
|
1371
|
+
}, TICK_MS);
|
|
1372
|
+
timer.unref?.();
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* The whole operator surface, in one call.
|
|
1376
|
+
*
|
|
1377
|
+
* `/manage <objective>` start managing this session with that objective
|
|
1378
|
+
* `/manage <message>` once running, an instruction carried into the next arming
|
|
1379
|
+
* `/manage` what is it doing
|
|
1380
|
+
* `/manage off` stop
|
|
1381
|
+
* `/manage pause|resume` stop arming without forgetting the objective
|
|
1382
|
+
* `/manage now` arm immediately, whatever the signals say
|
|
1383
|
+
*/
|
|
1384
|
+
export async function handleManage(sessionIdOrName, rawArg) {
|
|
1385
|
+
const arg = (rawArg ?? "").trim();
|
|
1386
|
+
/**
|
|
1387
|
+
* `machine/session` is managed by that machine's own hub.
|
|
1388
|
+
*
|
|
1389
|
+
* Not proxied, delegated. The remote hub owns its panes, reads its own
|
|
1390
|
+
* transcripts and types into its own terminals; a manager here would be
|
|
1391
|
+
* guessing about all three across a network. So the objective is handed over
|
|
1392
|
+
* and lives there, which is also what makes it survive this machine being
|
|
1393
|
+
* closed — the developer keeps working when the manager goes home, which is
|
|
1394
|
+
* the entire point of giving them their own computer.
|
|
1395
|
+
*/
|
|
1396
|
+
{
|
|
1397
|
+
const { forwardToPeer } = await import("./peer-handlers.js");
|
|
1398
|
+
const forwarded = await forwardToPeer(sessionIdOrName, "manage", { arg });
|
|
1399
|
+
if (forwarded) {
|
|
1400
|
+
return forwarded.ok
|
|
1401
|
+
? { ok: true, message: forwarded.result?.message ?? "done", managed: forwarded.result?.managed }
|
|
1402
|
+
: { ok: false, message: forwarded.error ?? "the peer refused it" };
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
const resolved = resolveSession(sessionIdOrName);
|
|
1406
|
+
if (!resolved)
|
|
1407
|
+
return { ok: false, message: `no live session matches "${sessionIdOrName}"` };
|
|
1408
|
+
const { sessionId, name } = resolved;
|
|
1409
|
+
const existing = state[sessionId];
|
|
1410
|
+
const word = arg.toLowerCase();
|
|
1411
|
+
// help — the grammar, from the thing that implements it.
|
|
1412
|
+
//
|
|
1413
|
+
// Written here rather than in the CLI and the hook and the tool description,
|
|
1414
|
+
// because three copies of one list is how they end up disagreeing. Everything
|
|
1415
|
+
// that answers `manage` reads this same text.
|
|
1416
|
+
if (word === "help" || word === "?" || word === "--help" || word === "-h") {
|
|
1417
|
+
return {
|
|
1418
|
+
ok: true,
|
|
1419
|
+
managed: !!existing,
|
|
1420
|
+
message: `manage — keep a session working on a standing objective.\n\n` +
|
|
1421
|
+
` <objective> start managing, or once running, an instruction carried\n` +
|
|
1422
|
+
` into the next arming ("do the tests before the docs")\n` +
|
|
1423
|
+
` status what the session looks like right now, and what the\n` +
|
|
1424
|
+
` manager has done. Also: state, what, info, show\n` +
|
|
1425
|
+
` hands off the operator needs the screen: stops visual work at once,\n` +
|
|
1426
|
+
` keeps everything else going, and says why\n` +
|
|
1427
|
+
` hands on give the screen back\n` +
|
|
1428
|
+
` hands on|off for 8 hours | 30m\n` +
|
|
1429
|
+
` same, but it reverts by itself — a permission that ends\n` +
|
|
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` +
|
|
1436
|
+
` set <text> REPLACE the standing objective. Plain text on a running\n` +
|
|
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` +
|
|
1440
|
+
` now arm immediately, whatever the signals say\n` +
|
|
1441
|
+
` pause stop arming, keep the objective\n` +
|
|
1442
|
+
` resume start arming again\n` +
|
|
1443
|
+
` off stop managing entirely\n` +
|
|
1444
|
+
` help this\n\n` +
|
|
1445
|
+
`Three ways in:\n` +
|
|
1446
|
+
` aibroker manage <session> … any shell. Works while the session is busy.\n` +
|
|
1447
|
+
` /btw manage … inside the session; answers by notification,\n` +
|
|
1448
|
+
` because a busy session cannot print a reply.\n` +
|
|
1449
|
+
` /btw manage <in words> anything not in the list above goes to the\n` +
|
|
1450
|
+
` model, which reads it and calls the tool.\n\n` +
|
|
1451
|
+
`${existing ? `Currently managing ${name}: ${existing.objective}` : `${name} is not being managed.`}`,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
if (word === "off" || word === "stop") {
|
|
1455
|
+
if (!existing)
|
|
1456
|
+
return { ok: true, message: `${name} was not being managed`, managed: false };
|
|
1457
|
+
delete state[sessionId];
|
|
1458
|
+
saveState(state);
|
|
1459
|
+
log(`[manage:${name}] stopped by the operator`);
|
|
1460
|
+
return { ok: true, message: `stopped managing ${name}`, managed: false };
|
|
1461
|
+
}
|
|
1462
|
+
// "status" is what a person actually types when they want the status, and the
|
|
1463
|
+
// first version took it as an objective and started managing the session with
|
|
1464
|
+
// the objective "status". Anything that reads as a question about state is a
|
|
1465
|
+
// question about state; only text that is not one of these becomes an
|
|
1466
|
+
// objective. Getting this wrong is silent and sets the session working on a
|
|
1467
|
+
// word.
|
|
1468
|
+
const ASKING = new Set(["status", "state", "what", "what?", "?", "info", "show"]);
|
|
1469
|
+
if (!arg || ASKING.has(word)) {
|
|
1470
|
+
if (!existing)
|
|
1471
|
+
return { ok: true, message: `${name} is not being managed. /manage <objective> to start.`, managed: false };
|
|
1472
|
+
const last = existing.history.slice(-4).map((h) => ` ${h.at.slice(11)} ${h.what}`).join("\n");
|
|
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`;
|
|
1480
|
+
const idle = Math.round((Date.now() - existing.lastChangeAt) / 1000);
|
|
1481
|
+
// Two separate things, kept separate: what the manager has DONE, and what
|
|
1482
|
+
// the session appears to be doing. Running them together is how a record of
|
|
1483
|
+
// one gets read as evidence about the other.
|
|
1484
|
+
return {
|
|
1485
|
+
ok: true,
|
|
1486
|
+
managed: true,
|
|
1487
|
+
message: `managing ${name}${existing.paused ? " (paused)" : ""}\n` +
|
|
1488
|
+
`objective: ${existing.objective}\n` +
|
|
1489
|
+
`\nright now:\n` +
|
|
1490
|
+
liveReading(sessionId, idle) +
|
|
1491
|
+
`\n\nthe manager: ${armed}` +
|
|
1492
|
+
(existing.pending.length ? `, ${existing.pending.length} instruction(s) waiting to go out` : "") +
|
|
1493
|
+
(last ? `\n${last}` : ""),
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* set — REPLACE the standing objective, rather than adding a note to it.
|
|
1498
|
+
*
|
|
1499
|
+
* Without this there was no way to correct one. Free text on a running
|
|
1500
|
+
* manager becomes a one-shot instruction, so a mistake in the objective could
|
|
1501
|
+
* only be answered by a note that itself expires — and the objective is
|
|
1502
|
+
* re-read on EVERY arming, so anything wrong in it is re-asserted forever
|
|
1503
|
+
* rather than misleading once. That is the difference between an objective
|
|
1504
|
+
* and a message, and it is why this needs its own verb.
|
|
1505
|
+
*/
|
|
1506
|
+
const setMatch = arg.match(/^(?:set|objective|replace)\s+([\s\S]+)$/i);
|
|
1507
|
+
if (setMatch && existing) {
|
|
1508
|
+
const before = existing.objective;
|
|
1509
|
+
existing.objective = setMatch[1].trim();
|
|
1510
|
+
// Notes written against the old objective may not make sense against the
|
|
1511
|
+
// new one; say so rather than silently carrying them over.
|
|
1512
|
+
const dropped = existing.pending.length;
|
|
1513
|
+
existing.pending = [];
|
|
1514
|
+
note(existing, `objective replaced${dropped ? `, ${dropped} pending instruction(s) dropped with it` : ""}`);
|
|
1515
|
+
saveState(state);
|
|
1516
|
+
return {
|
|
1517
|
+
ok: true,
|
|
1518
|
+
managed: true,
|
|
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) +
|
|
1521
|
+
(dropped ? `\n ${dropped} pending instruction(s) dropped — they were written against the old objective.` : ""),
|
|
1522
|
+
};
|
|
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
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* hands off / hands on — take the screen back without stopping the work.
|
|
1553
|
+
*
|
|
1554
|
+
* `pause` is the wrong tool for this: it stops the manager, and what is
|
|
1555
|
+
* wanted is the opposite — the session keeps working, it just stops touching
|
|
1556
|
+
* the screen. A session driving the pointer is the one thing that cannot
|
|
1557
|
+
* share a machine with its operator.
|
|
1558
|
+
*
|
|
1559
|
+
* DELIVERY IS THE HARD PART, and an instruction carried into the next arming
|
|
1560
|
+
* is useless here: the next arming may be twenty minutes away and the pointer
|
|
1561
|
+
* is moving now. Two things happen instead, and neither waits for a goal.
|
|
1562
|
+
*
|
|
1563
|
+
* First, the message is typed into the session directly, so it lands at the
|
|
1564
|
+
* next tool-call boundary — seconds, for a session that is clicking.
|
|
1565
|
+
*
|
|
1566
|
+
* Second, it opens with the exact phrase the screen-control tool's own hook
|
|
1567
|
+
* watches for. That revokes control at the TOOL, so the next click fails with
|
|
1568
|
+
* an error explaining why, rather than depending on the session having read
|
|
1569
|
+
* and obeyed a sentence. Enforced beats cooperative when the cost of it being
|
|
1570
|
+
* ignored is the operator losing their pointer mid-sentence.
|
|
1571
|
+
*/
|
|
1572
|
+
const handsMatch = arg.match(/^hands?\s+(off|on)\b\s*(.*)$/i);
|
|
1573
|
+
if (handsMatch || word === "nogui" || word === "gui") {
|
|
1574
|
+
const off = handsMatch ? /off/i.test(handsMatch[1]) : word === "nogui";
|
|
1575
|
+
if (!existing)
|
|
1576
|
+
return { ok: false, message: `${name} is not being managed` };
|
|
1577
|
+
/**
|
|
1578
|
+
* A DURATION, because the useful case is bounded in both directions.
|
|
1579
|
+
*
|
|
1580
|
+
* "hands on for eight hours" is the overnight grant: it may drive the screen
|
|
1581
|
+
* while nobody is at the machine, and it gives the screen BACK before
|
|
1582
|
+
* somebody sits down — without that person having to remember to revoke it.
|
|
1583
|
+
* "hands off for thirty minutes" is the mirror: take the machine, and have
|
|
1584
|
+
* visual work resume by itself rather than staying stopped because nobody
|
|
1585
|
+
* said the word.
|
|
1586
|
+
*
|
|
1587
|
+
* Both matter for the same reason: a permission that only ends when a person
|
|
1588
|
+
* remembers to end it is a permission that outlives its reason.
|
|
1589
|
+
*/
|
|
1590
|
+
const dur = (handsMatch?.[2] ?? "").match(/(?:for\s+)?(\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)\b/i);
|
|
1591
|
+
if (dur) {
|
|
1592
|
+
const n = Number(dur[1]);
|
|
1593
|
+
const unit = dur[2].toLowerCase();
|
|
1594
|
+
const ms = /^h/.test(unit) ? n * 3_600_000 : n * 60_000;
|
|
1595
|
+
existing.handsUntil = Date.now() + ms;
|
|
1596
|
+
existing.handsWas = off;
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
delete existing.handsUntil;
|
|
1600
|
+
delete existing.handsWas;
|
|
1601
|
+
}
|
|
1602
|
+
existing.noScreen = off;
|
|
1603
|
+
if (off) {
|
|
1604
|
+
// The reason comes FIRST, and the phrase is inside a sentence rather than
|
|
1605
|
+
// barked on its own. "my controls" alone revokes the tool and explains
|
|
1606
|
+
// nothing — a session that has just lost the pointer mid-task, with no
|
|
1607
|
+
// reason given, will either guess or stop, and both are worse than being
|
|
1608
|
+
// told. What it needs is: why, what to stop, what to leave behind, what to
|
|
1609
|
+
// do instead, and when this ends.
|
|
1610
|
+
const msg = "THE OPERATOR NEEDS THE SCREEN — my controls. This is not a fault and not a criticism of what you were doing; " +
|
|
1611
|
+
"they have come back to the machine and cannot share a pointer with you. " +
|
|
1612
|
+
"So: stop all screen and pointer work now, mid-task if necessary. " +
|
|
1613
|
+
"Write into your notes exactly how far you got and what still needs verifying on screen, in enough detail that somebody can resume it cold — that record is the only thing being asked of the work you are abandoning. " +
|
|
1614
|
+
"Then KEEP WORKING on everything that does not need the screen: reading code, diagnosing, writing, tests, notes. There is plenty of that. " +
|
|
1615
|
+
"Do not stop and do not wait. The screen comes back to you when you are told the controls are yours again.";
|
|
1616
|
+
typeIntoSession(sessionId, msg);
|
|
1617
|
+
note(existing, "hands off — screen work stopped, non-visual work continues");
|
|
1618
|
+
}
|
|
1619
|
+
else {
|
|
1620
|
+
typeIntoSession(sessionId, "your controls. The screen is yours again — you may resume visual work where your notes left it.");
|
|
1621
|
+
note(existing, "hands on — screen work permitted again");
|
|
1622
|
+
}
|
|
1623
|
+
saveState(state);
|
|
1624
|
+
return {
|
|
1625
|
+
ok: true,
|
|
1626
|
+
managed: true,
|
|
1627
|
+
message: (off
|
|
1628
|
+
? `${name}: screen work stopped and the message is on its way. It keeps working on everything that needs no screen, and every arming carries the same rule.`
|
|
1629
|
+
: `${name}: screen work permitted again.`) +
|
|
1630
|
+
(existing.handsUntil
|
|
1631
|
+
? `\n reverts by itself at ${new Date(existing.handsUntil).toLocaleTimeString("de-DE")} — no need to remember.`
|
|
1632
|
+
: `\n stays this way until you say otherwise.`),
|
|
1633
|
+
};
|
|
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
|
+
}
|
|
1704
|
+
if (word === "pause" || word === "resume") {
|
|
1705
|
+
if (!existing)
|
|
1706
|
+
return { ok: false, message: `${name} is not being managed` };
|
|
1707
|
+
existing.paused = word === "pause";
|
|
1708
|
+
saveState(state);
|
|
1709
|
+
return { ok: true, managed: true, message: `${word === "pause" ? "paused" : "resumed"} managing ${name}` };
|
|
1710
|
+
}
|
|
1711
|
+
if (!existing) {
|
|
1712
|
+
/**
|
|
1713
|
+
* REFUSE TO MANAGE ANYTHING THAT IS NOT A SESSION.
|
|
1714
|
+
*
|
|
1715
|
+
* `aibroker manage status <session>` — keyword first, session second —
|
|
1716
|
+
* resolved to the plain shell the command was typed in, and the remainder
|
|
1717
|
+
* became an objective: a manager was created for a `-zsh` pane, silently,
|
|
1718
|
+
* with the objective "status <session>". Nothing would ever have come of it
|
|
1719
|
+
* except goals typed at a shell prompt.
|
|
1720
|
+
*
|
|
1721
|
+
* The arm path already refuses a bare shell. That is too late: by then a
|
|
1722
|
+
* manager exists, appears in every listing, and has to be found and removed
|
|
1723
|
+
* by somebody who did not create it on purpose. Check at the point of
|
|
1724
|
+
* creation, where the mistake is still one command old.
|
|
1725
|
+
*/
|
|
1726
|
+
const probe = readSessionContent(sessionId, 5);
|
|
1727
|
+
if (probe?.atPrompt) {
|
|
1728
|
+
return {
|
|
1729
|
+
ok: false,
|
|
1730
|
+
message: `${name} is a shell prompt, not a running session — refusing to manage it.\n` +
|
|
1731
|
+
`If you meant a different session, name it first: aibroker manage <session> <objective>`,
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
const m = {
|
|
1735
|
+
sessionId,
|
|
1736
|
+
name,
|
|
1737
|
+
objective: arg,
|
|
1738
|
+
pending: [],
|
|
1739
|
+
history: [],
|
|
1740
|
+
// Give the session the benefit of the grace period rather than arming
|
|
1741
|
+
// on top of whatever it is doing at the moment the operator types this.
|
|
1742
|
+
lastRearmAt: Date.now(),
|
|
1743
|
+
lastChangeAt: Date.now(),
|
|
1744
|
+
lastHash: hash(readPane(sessionId)),
|
|
1745
|
+
tty: snapshotTty(sessionId),
|
|
1746
|
+
paused: false,
|
|
1747
|
+
startedAt: Date.now(),
|
|
1748
|
+
};
|
|
1749
|
+
state[sessionId] = m;
|
|
1750
|
+
note(m, "started");
|
|
1751
|
+
saveState(state);
|
|
1752
|
+
startManagerLoop();
|
|
1753
|
+
return {
|
|
1754
|
+
ok: true,
|
|
1755
|
+
managed: true,
|
|
1756
|
+
message: `managing ${name}. It will be re-armed with this objective whenever it stops:\n ${arg}`,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
if (word === "now") {
|
|
1760
|
+
existing.lastRearmAt = 0;
|
|
1761
|
+
saveState(state);
|
|
1762
|
+
return { ok: true, managed: true, message: `${name} will be armed on the next tick` };
|
|
1763
|
+
}
|
|
1764
|
+
existing.pending.push(arg);
|
|
1765
|
+
note(existing, `operator: ${arg.slice(0, 80)}`);
|
|
1766
|
+
saveState(state);
|
|
1767
|
+
return {
|
|
1768
|
+
ok: true,
|
|
1769
|
+
managed: true,
|
|
1770
|
+
message: `noted for ${name} — it goes out with the next arming (${existing.pending.length} pending)`,
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
//# sourceMappingURL=manage.js.map
|