aibroker 0.35.2 → 0.36.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/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/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 +77 -0
- package/dist/daemon/manage.d.ts.map +1 -0
- package/dist/daemon/manage.js +942 -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,942 @@
|
|
|
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
|
+
const STATE_FILE = join(homedir(), ".aibroker", "managers.json");
|
|
31
|
+
/** How often every manager is looked at. Cheap: one content read per managed session. */
|
|
32
|
+
const TICK_MS = 20_000;
|
|
33
|
+
/**
|
|
34
|
+
* A session with no goal is not working on one, whatever else it is doing.
|
|
35
|
+
*
|
|
36
|
+
* The first version also required a long silence, and that never fired: a
|
|
37
|
+
* session answering messages moves its transcript and resets any quiet timer,
|
|
38
|
+
* so the loop waited for a silence that conversation kept postponing. The grace
|
|
39
|
+
* below exists only to avoid arming in the middle of the turn that just ended
|
|
40
|
+
* the last goal.
|
|
41
|
+
*/
|
|
42
|
+
const NO_GOAL_GRACE_MS = 30_000;
|
|
43
|
+
/** Never re-arm twice inside this window, whatever the signals say. */
|
|
44
|
+
const REARM_COOLDOWN_MS = 90_000;
|
|
45
|
+
/**
|
|
46
|
+
* How long an armed goal is believed before the manager stops waiting for it.
|
|
47
|
+
*
|
|
48
|
+
* Not a timeout on the work. A ceiling on the manager's willingness to sit on a
|
|
49
|
+
* signal that may never arrive — an item can legitimately end without the
|
|
50
|
+
* marker clearing, and a loop with no ceiling waits forever while every
|
|
51
|
+
* heartbeat reads healthy.
|
|
52
|
+
*/
|
|
53
|
+
const GOAL_MAX_AGE_MS = 45 * 60_000;
|
|
54
|
+
/**
|
|
55
|
+
* The status-line marker shown while a goal is armed.
|
|
56
|
+
*
|
|
57
|
+
* IT IS A PROXY AND IT LIES. Read off the terminal, it does not clear when the
|
|
58
|
+
* goal is met — it once read "active" for ninety minutes after the session had
|
|
59
|
+
* finished, committed six times and gone idle. It is used here only in
|
|
60
|
+
* conjunction with the age ceiling above, never on its own.
|
|
61
|
+
*/
|
|
62
|
+
const GOAL_ACTIVE = /\/goal\s+active/i;
|
|
63
|
+
/** Verdicts that mean the session has run out of goal and said so. */
|
|
64
|
+
const OUT_OF_GOAL = [
|
|
65
|
+
/goal could not be achieved/i,
|
|
66
|
+
/goal not achieved/i,
|
|
67
|
+
/could not achieve the goal/i,
|
|
68
|
+
];
|
|
69
|
+
/**
|
|
70
|
+
* A duration nobody can print nonsense from.
|
|
71
|
+
*
|
|
72
|
+
* The defect this closes: a sentinel `0` meaning "no timestamp" was subtracted
|
|
73
|
+
* from the clock and formatted as an age, so a log line read "armed 29,779,818
|
|
74
|
+
* min" — the age of the Unix epoch, internally correct and externally absurd.
|
|
75
|
+
* One code path was fixed; this closes the class, because the next path to
|
|
76
|
+
* format a duration from a suspect timestamp would have printed it again.
|
|
77
|
+
*
|
|
78
|
+
* Anything beyond a month is not a duration in this system, it is a bad
|
|
79
|
+
* subtraction, and saying so is more useful than a number with eight digits.
|
|
80
|
+
*/
|
|
81
|
+
const IMPLAUSIBLE_MS = 31 * 24 * 60 * 60_000;
|
|
82
|
+
function minutesSince(then, now) {
|
|
83
|
+
const ms = now - then;
|
|
84
|
+
if (!Number.isFinite(ms) || ms < 0 || ms > IMPLAUSIBLE_MS)
|
|
85
|
+
return "an unknown time";
|
|
86
|
+
return `${Math.round(ms / 60000)} min`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Repair history lines produced by that defect, once, on load.
|
|
90
|
+
*
|
|
91
|
+
* Normally a log is struck forward rather than rewritten — a record that edits
|
|
92
|
+
* its own history is worth less than one that does not. This is the exception
|
|
93
|
+
* and it is narrow: the line is not a claim anybody needs to audit, it is a
|
|
94
|
+
* garbled rendering of an event that did happen, produced by a bug that no
|
|
95
|
+
* longer exists. What it records is preserved; only the impossible number goes.
|
|
96
|
+
*/
|
|
97
|
+
function repairHistory(s) {
|
|
98
|
+
let changed = false;
|
|
99
|
+
for (const m of Object.values(s)) {
|
|
100
|
+
for (const h of m.history ?? []) {
|
|
101
|
+
const bad = h.what.match(/armed (\d{7,}) min/);
|
|
102
|
+
if (bad) {
|
|
103
|
+
h.what = h.what.replace(bad[0], "armed for an unknown time (a defect in this manager's own arithmetic, fixed 2026-08-15)");
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return changed;
|
|
109
|
+
}
|
|
110
|
+
function loadState() {
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(STATE_FILE)) {
|
|
113
|
+
const s = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
114
|
+
if (repairHistory(s)) {
|
|
115
|
+
saveState(s);
|
|
116
|
+
log("[manage] repaired history lines left by the epoch-duration defect");
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
log(`[manage] state unreadable, starting empty — ${e.message}`);
|
|
123
|
+
}
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
function saveState(s) {
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
log(`[manage] could not save state — ${e.message}`);
|
|
132
|
+
}
|
|
133
|
+
for (const m of Object.values(s))
|
|
134
|
+
mirrorToRepo(m);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Put what a session knows INTO THE PROJECT, not beside it.
|
|
138
|
+
*
|
|
139
|
+
* On one machine this is tidiness. Across machines it is the entire
|
|
140
|
+
* synchronisation mechanism: git already moves work between developers, so
|
|
141
|
+
* knowledge committed to the repository travels with the branch, merges with
|
|
142
|
+
* the branch and arrives on every machine without anybody building a protocol
|
|
143
|
+
* for it. Knowledge in a home directory reaches exactly one machine, and which
|
|
144
|
+
* machine that is depends on where somebody happened to be sitting.
|
|
145
|
+
*
|
|
146
|
+
* The same argument in the other direction is why this file is not the record:
|
|
147
|
+
* `~/.aibroker` is per-machine state — sockets, tokens, timers — and that is
|
|
148
|
+
* correct for things that describe a machine. An objective describes the WORK,
|
|
149
|
+
* so it belongs where the work is.
|
|
150
|
+
*
|
|
151
|
+
* Written as markdown rather than the state JSON because the reader is the next
|
|
152
|
+
* agent to open the repository, possibly on another machine, possibly weeks
|
|
153
|
+
* later. It should not need this program to make sense of what it finds.
|
|
154
|
+
*/
|
|
155
|
+
function mirrorToRepo(m) {
|
|
156
|
+
try {
|
|
157
|
+
// Resolve the pane NOW rather than trusting one recorded at creation.
|
|
158
|
+
// A field captured once is a field that is absent on every record made
|
|
159
|
+
// before it existed and wrong for any session that has since moved — and
|
|
160
|
+
// both of those fail silently, which is how a mirror stops mirroring
|
|
161
|
+
// without anybody noticing.
|
|
162
|
+
const tty = m.tty ?? snapshotTty(m.sessionId);
|
|
163
|
+
const proc = tty ? processReading(tty) : { isSession: false, pid: null };
|
|
164
|
+
if (!proc.pid)
|
|
165
|
+
return;
|
|
166
|
+
const cwd = repoRootFor(proc.pid);
|
|
167
|
+
if (!cwd)
|
|
168
|
+
return;
|
|
169
|
+
const dir = join(cwd, ".aibroker");
|
|
170
|
+
if (!existsSync(dir))
|
|
171
|
+
mkdirSync(dir, { recursive: true });
|
|
172
|
+
const recent = m.history.slice(-12).map((h) => `- ${h.at} — ${h.what}`).join("\n");
|
|
173
|
+
const body = `# Session: ${m.name}\n\n` +
|
|
174
|
+
`_Written by the manager. It travels with this branch, which is the point:\n` +
|
|
175
|
+
`whichever machine picks this work up next reads it here rather than\n` +
|
|
176
|
+
`rediscovering it._\n\n` +
|
|
177
|
+
`## Standing objective\n\n${m.objective}\n\n` +
|
|
178
|
+
(m.pending.length ? `## Waiting to be carried into the next cycle\n\n${m.pending.map((p) => `- ${p}`).join("\n")}\n\n` : "") +
|
|
179
|
+
(m.noScreen ? `## Screen\n\nScreen work is currently withheld - the operator has the machine.\n\n` : "") +
|
|
180
|
+
`## Recent\n\n${recent || "- nothing yet"}\n`;
|
|
181
|
+
const file = join(dir, `session-${m.name.replace(/[^A-Za-z0-9._-]/g, "-")}.md`);
|
|
182
|
+
// Only write on change. A file rewritten every twenty seconds turns a
|
|
183
|
+
// repository into a stream of no-op commits and trains everyone to ignore it.
|
|
184
|
+
const before = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
185
|
+
if (before !== body)
|
|
186
|
+
writeFileSync(file, body);
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
log(`[manage] could not mirror into the repository — ${e.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** The pane device for a session, captured once at start. */
|
|
193
|
+
function snapshotTty(sessionId) {
|
|
194
|
+
return discoverLiveSessions().find((s) => s.id === sessionId)?.tty;
|
|
195
|
+
}
|
|
196
|
+
/** The checkout a process is sitting in, or null if it is not in one. */
|
|
197
|
+
function repoRootFor(pid) {
|
|
198
|
+
try {
|
|
199
|
+
const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", pid, "-a", "-d", "cwd", "-Fn"], {
|
|
200
|
+
encoding: "utf8",
|
|
201
|
+
timeout: 4_000,
|
|
202
|
+
});
|
|
203
|
+
const cwd = cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1);
|
|
204
|
+
if (!cwd)
|
|
205
|
+
return null;
|
|
206
|
+
const root = execFileSync("/usr/bin/env", ["git", "-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
207
|
+
encoding: "utf8",
|
|
208
|
+
timeout: 4_000,
|
|
209
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
210
|
+
}).trim();
|
|
211
|
+
return root || null;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let state = loadState();
|
|
218
|
+
let timer = null;
|
|
219
|
+
function hash(s) {
|
|
220
|
+
let h = 0;
|
|
221
|
+
for (let i = 0; i < s.length; i++)
|
|
222
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
223
|
+
return String(h);
|
|
224
|
+
}
|
|
225
|
+
function note(m, what) {
|
|
226
|
+
// Local time, like the daemon log. A history stamped in UTC beside a log
|
|
227
|
+
// stamped locally is two clocks in one investigation, and the whole point of
|
|
228
|
+
// this record is to be read at three in the morning by someone in a hurry.
|
|
229
|
+
const d = new Date();
|
|
230
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
231
|
+
const at = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
232
|
+
m.history.push({ at, what });
|
|
233
|
+
if (m.history.length > 40)
|
|
234
|
+
m.history = m.history.slice(-40);
|
|
235
|
+
log(`[manage:${m.name}] ${what}`);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Resolve a session by whatever the caller knows — its id, or its name.
|
|
239
|
+
*
|
|
240
|
+
* The hook knows the working directory and the terminal session; a person
|
|
241
|
+
* knows the name. Both have to land on the same record.
|
|
242
|
+
*/
|
|
243
|
+
export function resolveSession(idOrName) {
|
|
244
|
+
const live = discoverLiveSessions();
|
|
245
|
+
// The terminal's own id may arrive as "w3t1p0:UUID"; the pane is the UUID.
|
|
246
|
+
const id = idOrName.includes(":") ? (idOrName.split(":").pop() ?? idOrName) : idOrName;
|
|
247
|
+
const byId = live.find((s) => s.id === id || s.aibrokerId === id);
|
|
248
|
+
if (byId)
|
|
249
|
+
return { sessionId: byId.id, name: byId.paiName ?? byId.name ?? id };
|
|
250
|
+
const needle = idOrName.toLowerCase();
|
|
251
|
+
const byName = live.find((s) => (s.paiName ?? "").toLowerCase() === needle || (s.name ?? "").toLowerCase().includes(needle));
|
|
252
|
+
if (byName)
|
|
253
|
+
return { sessionId: byName.id, name: byName.paiName ?? byName.name ?? idOrName };
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* What is actually running in this pane, from the PROCESS TABLE.
|
|
258
|
+
*
|
|
259
|
+
* WHY THIS EXISTS AT ALL. Everything below used to be inferred from the text on
|
|
260
|
+
* screen, and that is hopeless: three filters in a row settled on chrome, one of
|
|
261
|
+
* them reporting a "use /clear to free up context" tip as the session's activity
|
|
262
|
+
* for minutes. Parsing a terminal UI means guessing at somebody's prompt theme,
|
|
263
|
+
* their status line and the framework's own banners — a proxy for a question the
|
|
264
|
+
* operating system answers exactly.
|
|
265
|
+
*
|
|
266
|
+
* Each pane has a tty and the session process sits on it. That answers exactly
|
|
267
|
+
* ONE question, which is the one worth asking here: is there a `claude` on this
|
|
268
|
+
* tty at all, or is the pane a bare shell? A goal typed at a shell prompt runs
|
|
269
|
+
* as shell commands — that has happened, and it was harmless only by luck.
|
|
270
|
+
*
|
|
271
|
+
* It does NOT answer "is it working". An earlier version read that from a
|
|
272
|
+
* `caffeinate` child and it discriminated perfectly across five panes — and it
|
|
273
|
+
* is still the wrong thing to depend on, because it is an implementation detail
|
|
274
|
+
* of one client on one operating system. A signal that happens to correlate
|
|
275
|
+
* today is the definition of a proxy, and this file has been caught by four of
|
|
276
|
+
* them already. That question belongs to the transcript below, which is the
|
|
277
|
+
* session's own record rather than a side effect of it.
|
|
278
|
+
*/
|
|
279
|
+
function processReading(tty) {
|
|
280
|
+
const dev = tty.replace(/^\/dev\//, "");
|
|
281
|
+
let out = "";
|
|
282
|
+
try {
|
|
283
|
+
out = execFileSync("/bin/ps", ["-t", dev, "-o", "pid=,ppid=,etime=,command="], {
|
|
284
|
+
encoding: "utf8",
|
|
285
|
+
timeout: 4_000,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// No processes on that tty, or ps refused. Either way nothing can be said.
|
|
290
|
+
return { isSession: false, pid: null };
|
|
291
|
+
}
|
|
292
|
+
const rows = out
|
|
293
|
+
.split("\n")
|
|
294
|
+
.map((l) => l.trim())
|
|
295
|
+
.filter(Boolean)
|
|
296
|
+
.map((l) => {
|
|
297
|
+
const m = l.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
298
|
+
return m ? { pid: m[1], ppid: m[2], etime: m[3], cmd: m[4] } : null;
|
|
299
|
+
})
|
|
300
|
+
.filter((r) => r !== null);
|
|
301
|
+
const claude = rows.find((r) => /(^|\/)claude$/.test(r.cmd.split(/\s+/)[0]));
|
|
302
|
+
if (!claude)
|
|
303
|
+
return { isSession: false, pid: null };
|
|
304
|
+
return { isSession: true, pid: claude.pid };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* What the session is doing, from its own transcript — the authority.
|
|
308
|
+
*
|
|
309
|
+
* THE PROCESS TABLE WAS THE SECOND WRONG ANSWER. Reading the screen was the
|
|
310
|
+
* first: three filters in a row settled on chrome. Then `caffeinate`, which
|
|
311
|
+
* discriminated perfectly across five panes and is still wrong to depend on —
|
|
312
|
+
* it is an implementation detail of one client on one operating system, and a
|
|
313
|
+
* signal that happens to correlate today is the definition of a proxy. The
|
|
314
|
+
* question was never "what did this spawn", it is "what is the session doing",
|
|
315
|
+
* and the session writes that down itself.
|
|
316
|
+
*
|
|
317
|
+
* Every session keeps a JSONL transcript: one entry per message, each carrying
|
|
318
|
+
* a timestamp, the tool being called by name, and real token usage. From it,
|
|
319
|
+
* without parsing a single line of terminal output:
|
|
320
|
+
*
|
|
321
|
+
* - WORKING or NOT: the last entry is a tool call awaiting its result, or it
|
|
322
|
+
* is finished text. No inference from spinners.
|
|
323
|
+
* - WHAT: the tool's own name, as the client recorded it.
|
|
324
|
+
* - CONTEXT: summed from usage rather than scraped off somebody's status bar,
|
|
325
|
+
* which required a particular status bar and gave nothing without it.
|
|
326
|
+
* - WHEN: the entry's timestamp, so "how long has this been going" is a
|
|
327
|
+
* subtraction rather than a guess.
|
|
328
|
+
*/
|
|
329
|
+
function transcriptReading(claudePid) {
|
|
330
|
+
const none = { working: null, doing: null, contextK: null, lastAt: null };
|
|
331
|
+
try {
|
|
332
|
+
// The transcript directory is named for the session's working directory,
|
|
333
|
+
// which the process itself is the authority on.
|
|
334
|
+
const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", claudePid, "-a", "-d", "cwd", "-Fn"], {
|
|
335
|
+
encoding: "utf8",
|
|
336
|
+
timeout: 4_000,
|
|
337
|
+
});
|
|
338
|
+
const cwd = cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1);
|
|
339
|
+
if (!cwd)
|
|
340
|
+
return none;
|
|
341
|
+
const dir = join(homedir(), ".claude", "projects", cwd.replace(/\//g, "-"));
|
|
342
|
+
if (!existsSync(dir))
|
|
343
|
+
return none;
|
|
344
|
+
// The live transcript is the one being written. Newest wins; a session that
|
|
345
|
+
// has not written for a long time will show that in its own timestamp
|
|
346
|
+
// rather than being silently mistaken for a fresh one.
|
|
347
|
+
const newest = readdirSync(dir)
|
|
348
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
349
|
+
.map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs }))
|
|
350
|
+
.sort((a, b) => b.m - a.m)[0];
|
|
351
|
+
if (!newest)
|
|
352
|
+
return none;
|
|
353
|
+
// Only the tail is needed and these files reach tens of megabytes.
|
|
354
|
+
const raw = execFileSync("/usr/bin/tail", ["-n", "40", join(dir, newest.f)], {
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
timeout: 4_000,
|
|
357
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
358
|
+
});
|
|
359
|
+
const msgs = [];
|
|
360
|
+
for (const line of raw.split("\n")) {
|
|
361
|
+
if (!line.trim())
|
|
362
|
+
continue;
|
|
363
|
+
try {
|
|
364
|
+
const j = JSON.parse(line);
|
|
365
|
+
if (j.type === "assistant" || j.type === "user")
|
|
366
|
+
msgs.push(j);
|
|
367
|
+
}
|
|
368
|
+
catch { /* a truncated first line is normal when tailing */ }
|
|
369
|
+
}
|
|
370
|
+
if (!msgs.length)
|
|
371
|
+
return none;
|
|
372
|
+
const last = msgs[msgs.length - 1];
|
|
373
|
+
const lastAt = last.timestamp ? Date.parse(last.timestamp) : null;
|
|
374
|
+
// A tool call with no result after it is work in flight. A finished
|
|
375
|
+
// assistant message is a turn that has ended.
|
|
376
|
+
const lastAssistant = [...msgs].reverse().find((m) => m.type === "assistant");
|
|
377
|
+
const content = lastAssistant?.message?.content;
|
|
378
|
+
const toolUse = Array.isArray(content) ? content.filter((c) => c.type === "tool_use") : [];
|
|
379
|
+
const working = last.type === "assistant" ? toolUse.length > 0 : true;
|
|
380
|
+
const doing = toolUse.length
|
|
381
|
+
? toolUse.map((t) => t.name).join(", ")
|
|
382
|
+
: last.type === "user"
|
|
383
|
+
? "waiting on a tool result"
|
|
384
|
+
: null;
|
|
385
|
+
const u = lastAssistant?.message?.usage;
|
|
386
|
+
const contextK = u
|
|
387
|
+
? Math.round(((u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)) / 1000)
|
|
388
|
+
: null;
|
|
389
|
+
return { working, doing, contextK, lastAt };
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return none;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The status line, assembled from the sources in order of authority.
|
|
397
|
+
*
|
|
398
|
+
* The transcript first, because it is the session's own record: the tool by
|
|
399
|
+
* name, the context from real usage, the time of the last entry. The process
|
|
400
|
+
* table second, for the one thing it settles — whether this is a session at
|
|
401
|
+
* all. The screen last and only for the goal marker, which exists nowhere else.
|
|
402
|
+
*
|
|
403
|
+
* Each line says where it came from. That is not decoration: the pane readings
|
|
404
|
+
* have been wrong for ninety minutes at a stretch, and a reader who cannot tell
|
|
405
|
+
* which number came from the transcript and which was scraped off a status bar
|
|
406
|
+
* cannot tell which one to doubt.
|
|
407
|
+
*/
|
|
408
|
+
function liveReading(sessionId, idleSec) {
|
|
409
|
+
const snap = discoverLiveSessions().find((s) => s.id === sessionId);
|
|
410
|
+
const proc = snap?.tty ? processReading(snap.tty) : { isSession: false, pid: null };
|
|
411
|
+
if (!proc.isSession) {
|
|
412
|
+
return " no session process on that pane — it is a bare shell, or the session has exited";
|
|
413
|
+
}
|
|
414
|
+
const t = proc.pid ? transcriptReading(proc.pid) : { working: null, doing: null, contextK: null, lastAt: null };
|
|
415
|
+
const out = [];
|
|
416
|
+
if (t.lastAt !== null) {
|
|
417
|
+
const agoSec = Math.round((Date.now() - t.lastAt) / 1000);
|
|
418
|
+
out.push(` ${t.working ? "working" : "idle"} · last transcript entry ${agoSec < 90 ? `${agoSec}s` : `${Math.round(agoSec / 60)} min`} ago` +
|
|
419
|
+
(t.doing ? ` · ${t.doing}` : ""));
|
|
420
|
+
if (t.contextK !== null)
|
|
421
|
+
out.push(` context ${t.contextK}k tokens (from the transcript's own usage, not the status bar)`);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
out.push(` a session is running, but its transcript could not be read — falling back to the screen`);
|
|
425
|
+
}
|
|
426
|
+
const content = readPane(sessionId);
|
|
427
|
+
if (GOAL_ACTIVE.test(content)) {
|
|
428
|
+
out.push(` goal marker present on screen (a proxy — it lingers after a goal is met)`);
|
|
429
|
+
}
|
|
430
|
+
out.push(` pane unchanged for ${idleSec}s`);
|
|
431
|
+
return out.join("\n");
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* What the session appears to be doing, right now, read fresh.
|
|
435
|
+
*
|
|
436
|
+
* EVERYTHING HERE IS A READING AND IS LABELLED AS ONE. The goal marker is
|
|
437
|
+
* scraped off a status line and has been wrong by ninety minutes; "busy" is
|
|
438
|
+
* inferred from a spinner. The point is not to be authoritative — it is that
|
|
439
|
+
* asking the manager what is going on should not require going and looking, and
|
|
440
|
+
* a reading you know is a reading beats no reading at all.
|
|
441
|
+
*/
|
|
442
|
+
function paneReading(content) {
|
|
443
|
+
if (!content)
|
|
444
|
+
return " the pane could not be read";
|
|
445
|
+
const lines = content.split("\n").map((l) => l.trimEnd());
|
|
446
|
+
const marker = GOAL_ACTIVE.test(content);
|
|
447
|
+
// Two formats appear depending on the status line in use: "81% context used"
|
|
448
|
+
// and "Context: 730K / 1000K". Reading only the first reported nothing at all
|
|
449
|
+
// on a session using the second, which looks exactly like a session with no
|
|
450
|
+
// context reading rather than a reader that cannot see this one.
|
|
451
|
+
const ctx = content.match(/(\d{1,3})%\s*context\s*used/i)?.[1] ??
|
|
452
|
+
(() => {
|
|
453
|
+
const m = content.match(/Context:\s*([\d.]+)K\s*\/\s*([\d.]+)K/i);
|
|
454
|
+
return m ? String(Math.round((Number(m[1]) / Number(m[2])) * 100)) : undefined;
|
|
455
|
+
})();
|
|
456
|
+
const busy = /·\s*↓|tokens\)|esc to interrupt|✻|✽/i.test(content);
|
|
457
|
+
/**
|
|
458
|
+
* What it is doing — taken from the STRUCTURE, not from guessing at prose.
|
|
459
|
+
*
|
|
460
|
+
* Three attempts failed before this one, each a filter over "which line looks
|
|
461
|
+
* like real output": a blocklist of chrome (missed two entries), then a
|
|
462
|
+
* word-count test (settled on a "Use /clear to start fresh" tip and reported
|
|
463
|
+
* it as the session's activity for minutes on end). Both were proxies for a
|
|
464
|
+
* question the terminal already answers explicitly.
|
|
465
|
+
*
|
|
466
|
+
* Looking at an actual pane settles it. Tool invocations are marked with a
|
|
467
|
+
* bullet and name what is running. The activity line carries the elapsed time
|
|
468
|
+
* and the tokens drawn. Those are the two facts worth having, they are
|
|
469
|
+
* identifiable by their own markers rather than by their wording, and the tip
|
|
470
|
+
* banner that fooled the last version shares a prefix with real output but
|
|
471
|
+
* carries neither marker.
|
|
472
|
+
*/
|
|
473
|
+
const doing = lines.filter((l) => /^\s*⏺/.test(l)).slice(-1)[0]?.replace(/^\s*⏺\s*/, "");
|
|
474
|
+
const activity = content.match(/([A-Za-z]+…)\s*\(([^)]*)\)/);
|
|
475
|
+
const elapsed = activity?.[2];
|
|
476
|
+
const parts = [
|
|
477
|
+
` looks ${busy ? "busy" : "idle"}`,
|
|
478
|
+
ctx ? `context ${ctx}%` : null,
|
|
479
|
+
// The elapsed time is the number that tells you whether to worry. A session
|
|
480
|
+
// ninety minutes into one turn is either deep in something or stuck, and
|
|
481
|
+
// both are worth knowing; neither is visible from "busy".
|
|
482
|
+
elapsed ? `on this turn ${elapsed.replace(/\s*·\s*/g, ", ")}` : null,
|
|
483
|
+
`goal marker ${marker ? "present" : "absent"}${marker ? " (a proxy — it lingers after a goal is met)" : ""}`,
|
|
484
|
+
].filter(Boolean);
|
|
485
|
+
return ` ${parts.join(" · ")}${doing ? `\n doing: ${doing.trim().slice(0, 110)}` : ""}`;
|
|
486
|
+
}
|
|
487
|
+
/** The text actually typed at the session. Short goal, context by reference. */
|
|
488
|
+
function goalText(m) {
|
|
489
|
+
const extra = m.pending.length ? ` OPERATOR, since you were last armed: ${m.pending.join(" ")}` : "";
|
|
490
|
+
// The screen rule has to ride along with EVERY arming. Delivered once, it
|
|
491
|
+
// lasts only until the session next reads a goal — and the goal is what tells
|
|
492
|
+
// it what to do. So a standing rule that is not in the goal is a rule with a
|
|
493
|
+
// lifetime of one turn, and the next arming would send it back to clicking.
|
|
494
|
+
const hands = m.noScreen
|
|
495
|
+
? " THE OPERATOR HAS THE SCREEN: do no screen or pointer work at all, and do not ask for it. Everything else continues as normal. Where something would need checking on screen, write down what would need checking instead of checking it."
|
|
496
|
+
: "";
|
|
497
|
+
return `/goal ${m.objective}${hands}${extra}`;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Did it land? Look for the goal's own words in the transcript.
|
|
501
|
+
*
|
|
502
|
+
* NOT "did the content change" — that was the first version and it could not
|
|
503
|
+
* tell a goal that arrived from text stranded unsubmitted in the input line,
|
|
504
|
+
* which is the exact failure it existed to catch. A session prints for a dozen
|
|
505
|
+
* reasons; only the item's own words say the item is there.
|
|
506
|
+
*/
|
|
507
|
+
function seenInContent(content, fragment) {
|
|
508
|
+
if (!content)
|
|
509
|
+
return false;
|
|
510
|
+
return content.replace(/\s+/g, "").includes(fragment.replace(/\s+/g, ""));
|
|
511
|
+
}
|
|
512
|
+
function readPane(sessionId) {
|
|
513
|
+
try {
|
|
514
|
+
return readSessionContent(sessionId, 60)?.content ?? "";
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return "";
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async function sleep(ms) {
|
|
521
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
522
|
+
}
|
|
523
|
+
async function arm(m, reason) {
|
|
524
|
+
const text = goalText(m);
|
|
525
|
+
const fragment = m.objective.slice(0, 40);
|
|
526
|
+
/**
|
|
527
|
+
* NEVER TYPE A GOAL INTO A BARE SHELL.
|
|
528
|
+
*
|
|
529
|
+
* This is not hypothetical. A predecessor of this loop went on typing after
|
|
530
|
+
* its session had exited, and the goal landed at a zsh prompt: `/goal` became
|
|
531
|
+
* "no such file or directory" and the sentences after it became commands —
|
|
532
|
+
* `always`, `they`, `the`, all "command not found". Harmless that time
|
|
533
|
+
* entirely by luck, since a goal is prose and prose is mostly not commands.
|
|
534
|
+
* A goal whose wording happened to begin a line with a real command would
|
|
535
|
+
* have run it, in the operator's own shell, with no confirmation.
|
|
536
|
+
*
|
|
537
|
+
* `atPrompt` is exactly the discriminator: it is false for a session running
|
|
538
|
+
* Claude — the foreground process is node whether it is working or idle — and
|
|
539
|
+
* true when the shell itself is waiting for input. So true means the thing we
|
|
540
|
+
* are managing is gone, and the right move is to say so and stop, not to keep
|
|
541
|
+
* typing into whatever is there now.
|
|
542
|
+
*/
|
|
543
|
+
const live = readSessionContent(m.sessionId, 5);
|
|
544
|
+
if (!live) {
|
|
545
|
+
note(m, "the session could not be read — not typing anything");
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
if (live.atPrompt) {
|
|
549
|
+
m.paused = true;
|
|
550
|
+
note(m, "PAUSED — that pane is at a shell prompt, so the session has exited. Not typing a goal into a shell. `resume` once it is back.");
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
if (!typeIntoSession(m.sessionId, text)) {
|
|
554
|
+
note(m, `could not type into the session (${reason}) — will retry`);
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
// Typed is not sent, and sent is not received.
|
|
558
|
+
for (let i = 0; i < 5; i++) {
|
|
559
|
+
await sleep(2_000);
|
|
560
|
+
if (seenInContent(readPane(m.sessionId), fragment)) {
|
|
561
|
+
m.lastRearmAt = Date.now();
|
|
562
|
+
const carried = m.pending.length;
|
|
563
|
+
m.pending = [];
|
|
564
|
+
note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
note(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
function reasonToArm(m, content, now) {
|
|
572
|
+
if (m.paused)
|
|
573
|
+
return null;
|
|
574
|
+
if (OUT_OF_GOAL.some((re) => re.test(content)))
|
|
575
|
+
return "the session reported its goal could not be achieved";
|
|
576
|
+
// `now` zeroes the timestamp to force an arming. Without this the age is
|
|
577
|
+
// computed from the epoch and the log says "armed 29779818 min ago", which is
|
|
578
|
+
// true of a number and nonsense about the world — the kind of line that costs
|
|
579
|
+
// somebody ten minutes at three in the morning.
|
|
580
|
+
if (m.lastRearmAt === 0)
|
|
581
|
+
return "asked to arm now";
|
|
582
|
+
const quietFor = Math.max(0, now - m.lastChangeAt);
|
|
583
|
+
const armedFor = Math.max(0, now - m.lastRearmAt);
|
|
584
|
+
const marker = GOAL_ACTIVE.test(content);
|
|
585
|
+
if (!marker && quietFor > NO_GOAL_GRACE_MS)
|
|
586
|
+
return `no goal armed (idle ${Math.round(quietFor / 1000)}s)`;
|
|
587
|
+
// The ceiling. Without it a stale marker strands the loop indefinitely while
|
|
588
|
+
// every log line reads healthy — which is what a stalled loop looks like from
|
|
589
|
+
// outside, and is why this exists rather than trusting the marker.
|
|
590
|
+
if (armedFor > GOAL_MAX_AGE_MS) {
|
|
591
|
+
return `armed ${minutesSince(m.lastRearmAt, now)} with no sign of a new goal — assuming it lapsed`;
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
async function tick() {
|
|
596
|
+
const now = Date.now();
|
|
597
|
+
let dirty = false;
|
|
598
|
+
for (const m of Object.values(state)) {
|
|
599
|
+
const content = readPane(m.sessionId);
|
|
600
|
+
if (!content) {
|
|
601
|
+
// A session that cannot be read is not necessarily gone; say so once per
|
|
602
|
+
// tick rather than dropping it, because dropping it silently is how a
|
|
603
|
+
// manager stops managing without anybody noticing.
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const h = hash(content);
|
|
607
|
+
if (h !== m.lastHash) {
|
|
608
|
+
m.lastHash = h;
|
|
609
|
+
m.lastChangeAt = now;
|
|
610
|
+
dirty = true;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* A timed screen decision reverts itself.
|
|
614
|
+
*
|
|
615
|
+
* Checked before anything else in the tick, because the whole value is that
|
|
616
|
+
* it happens without a person: "hands on for eight hours" has to hand the
|
|
617
|
+
* screen back at the eighth hour whether or not anybody is awake to ask.
|
|
618
|
+
*/
|
|
619
|
+
if (m.handsUntil && now >= m.handsUntil) {
|
|
620
|
+
const wasOff = m.handsWas === true;
|
|
621
|
+
delete m.handsUntil;
|
|
622
|
+
delete m.handsWas;
|
|
623
|
+
m.noScreen = !wasOff;
|
|
624
|
+
typeIntoSession(m.sessionId, m.noScreen
|
|
625
|
+
? "The time you had the screen for is up — my controls. The operator may be back at the machine, so stop screen and pointer work now, write down how far you got and what still needs checking on screen, and carry on with everything that does not need it."
|
|
626
|
+
: "your controls. The screen is yours again — the operator's hold has expired. You may resume visual work where your notes left it.");
|
|
627
|
+
note(m, m.noScreen ? "timed grant expired — screen work stopped" : "timed hold expired — screen work permitted again");
|
|
628
|
+
dirty = true;
|
|
629
|
+
}
|
|
630
|
+
if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
|
|
631
|
+
continue;
|
|
632
|
+
const reason = reasonToArm(m, content, now);
|
|
633
|
+
if (!reason)
|
|
634
|
+
continue;
|
|
635
|
+
await arm(m, reason);
|
|
636
|
+
dirty = true;
|
|
637
|
+
}
|
|
638
|
+
if (dirty)
|
|
639
|
+
saveState(state);
|
|
640
|
+
}
|
|
641
|
+
export function startManagerLoop() {
|
|
642
|
+
if (timer)
|
|
643
|
+
return;
|
|
644
|
+
state = loadState();
|
|
645
|
+
const n = Object.keys(state).length;
|
|
646
|
+
if (n)
|
|
647
|
+
log(`[manage] resuming ${n} managed session${n > 1 ? "s" : ""}`);
|
|
648
|
+
timer = setInterval(() => {
|
|
649
|
+
void tick().catch((e) => log(`[manage] tick failed — ${e.message}`));
|
|
650
|
+
}, TICK_MS);
|
|
651
|
+
timer.unref?.();
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The whole operator surface, in one call.
|
|
655
|
+
*
|
|
656
|
+
* `/manage <objective>` start managing this session with that objective
|
|
657
|
+
* `/manage <message>` once running, an instruction carried into the next arming
|
|
658
|
+
* `/manage` what is it doing
|
|
659
|
+
* `/manage off` stop
|
|
660
|
+
* `/manage pause|resume` stop arming without forgetting the objective
|
|
661
|
+
* `/manage now` arm immediately, whatever the signals say
|
|
662
|
+
*/
|
|
663
|
+
export async function handleManage(sessionIdOrName, rawArg) {
|
|
664
|
+
const arg = (rawArg ?? "").trim();
|
|
665
|
+
/**
|
|
666
|
+
* `machine/session` is managed by that machine's own hub.
|
|
667
|
+
*
|
|
668
|
+
* Not proxied, delegated. The remote hub owns its panes, reads its own
|
|
669
|
+
* transcripts and types into its own terminals; a manager here would be
|
|
670
|
+
* guessing about all three across a network. So the objective is handed over
|
|
671
|
+
* and lives there, which is also what makes it survive this machine being
|
|
672
|
+
* closed — the developer keeps working when the manager goes home, which is
|
|
673
|
+
* the entire point of giving them their own computer.
|
|
674
|
+
*/
|
|
675
|
+
{
|
|
676
|
+
const { forwardToPeer } = await import("./peer-handlers.js");
|
|
677
|
+
const forwarded = await forwardToPeer(sessionIdOrName, "manage", { arg });
|
|
678
|
+
if (forwarded) {
|
|
679
|
+
return forwarded.ok
|
|
680
|
+
? { ok: true, message: forwarded.result?.message ?? "done", managed: forwarded.result?.managed }
|
|
681
|
+
: { ok: false, message: forwarded.error ?? "the peer refused it" };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const resolved = resolveSession(sessionIdOrName);
|
|
685
|
+
if (!resolved)
|
|
686
|
+
return { ok: false, message: `no live session matches "${sessionIdOrName}"` };
|
|
687
|
+
const { sessionId, name } = resolved;
|
|
688
|
+
const existing = state[sessionId];
|
|
689
|
+
const word = arg.toLowerCase();
|
|
690
|
+
// help — the grammar, from the thing that implements it.
|
|
691
|
+
//
|
|
692
|
+
// Written here rather than in the CLI and the hook and the tool description,
|
|
693
|
+
// because three copies of one list is how they end up disagreeing. Everything
|
|
694
|
+
// that answers `manage` reads this same text.
|
|
695
|
+
if (word === "help" || word === "?" || word === "--help" || word === "-h") {
|
|
696
|
+
return {
|
|
697
|
+
ok: true,
|
|
698
|
+
managed: !!existing,
|
|
699
|
+
message: `manage — keep a session working on a standing objective.\n\n` +
|
|
700
|
+
` <objective> start managing, or once running, an instruction carried\n` +
|
|
701
|
+
` into the next arming ("do the tests before the docs")\n` +
|
|
702
|
+
` status what the session looks like right now, and what the\n` +
|
|
703
|
+
` manager has done. Also: state, what, info, show\n` +
|
|
704
|
+
` hands off the operator needs the screen: stops visual work at once,\n` +
|
|
705
|
+
` keeps everything else going, and says why\n` +
|
|
706
|
+
` hands on give the screen back\n` +
|
|
707
|
+
` hands on|off for 8 hours | 30m\n` +
|
|
708
|
+
` same, but it reverts by itself — a permission that ends\n` +
|
|
709
|
+
` only when somebody remembers outlives its reason\n` +
|
|
710
|
+
` set <text> REPLACE the standing objective. Plain text on a running\n` +
|
|
711
|
+
` manager is a one-shot note; this changes what it re-arms\n` +
|
|
712
|
+
` now arm immediately, whatever the signals say\n` +
|
|
713
|
+
` pause stop arming, keep the objective\n` +
|
|
714
|
+
` resume start arming again\n` +
|
|
715
|
+
` off stop managing entirely\n` +
|
|
716
|
+
` help this\n\n` +
|
|
717
|
+
`Three ways in:\n` +
|
|
718
|
+
` aibroker manage <session> … any shell. Works while the session is busy.\n` +
|
|
719
|
+
` /btw manage … inside the session; answers by notification,\n` +
|
|
720
|
+
` because a busy session cannot print a reply.\n` +
|
|
721
|
+
` /btw manage <in words> anything not in the list above goes to the\n` +
|
|
722
|
+
` model, which reads it and calls the tool.\n\n` +
|
|
723
|
+
`${existing ? `Currently managing ${name}: ${existing.objective}` : `${name} is not being managed.`}`,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (word === "off" || word === "stop") {
|
|
727
|
+
if (!existing)
|
|
728
|
+
return { ok: true, message: `${name} was not being managed`, managed: false };
|
|
729
|
+
delete state[sessionId];
|
|
730
|
+
saveState(state);
|
|
731
|
+
log(`[manage:${name}] stopped by the operator`);
|
|
732
|
+
return { ok: true, message: `stopped managing ${name}`, managed: false };
|
|
733
|
+
}
|
|
734
|
+
// "status" is what a person actually types when they want the status, and the
|
|
735
|
+
// first version took it as an objective and started managing the session with
|
|
736
|
+
// the objective "status". Anything that reads as a question about state is a
|
|
737
|
+
// question about state; only text that is not one of these becomes an
|
|
738
|
+
// objective. Getting this wrong is silent and sets the session working on a
|
|
739
|
+
// word.
|
|
740
|
+
const ASKING = new Set(["status", "state", "what", "what?", "?", "info", "show"]);
|
|
741
|
+
if (!arg || ASKING.has(word)) {
|
|
742
|
+
if (!existing)
|
|
743
|
+
return { ok: true, message: `${name} is not being managed. /manage <objective> to start.`, managed: false };
|
|
744
|
+
const last = existing.history.slice(-4).map((h) => ` ${h.at.slice(11)} ${h.what}`).join("\n");
|
|
745
|
+
const age = Math.round((Date.now() - existing.lastRearmAt) / 60000);
|
|
746
|
+
const idle = Math.round((Date.now() - existing.lastChangeAt) / 1000);
|
|
747
|
+
// Two separate things, kept separate: what the manager has DONE, and what
|
|
748
|
+
// the session appears to be doing. Running them together is how a record of
|
|
749
|
+
// one gets read as evidence about the other.
|
|
750
|
+
return {
|
|
751
|
+
ok: true,
|
|
752
|
+
managed: true,
|
|
753
|
+
message: `managing ${name}${existing.paused ? " (paused)" : ""}\n` +
|
|
754
|
+
`objective: ${existing.objective}\n` +
|
|
755
|
+
`\nright now:\n` +
|
|
756
|
+
liveReading(sessionId, idle) +
|
|
757
|
+
`\n\nthe manager: last armed ${age} min ago` +
|
|
758
|
+
(existing.pending.length ? `, ${existing.pending.length} instruction(s) waiting to go out` : "") +
|
|
759
|
+
(last ? `\n${last}` : ""),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* set — REPLACE the standing objective, rather than adding a note to it.
|
|
764
|
+
*
|
|
765
|
+
* Without this there was no way to correct one. Free text on a running
|
|
766
|
+
* manager becomes a one-shot instruction, so a mistake in the objective could
|
|
767
|
+
* only be answered by a note that itself expires — and the objective is
|
|
768
|
+
* re-read on EVERY arming, so anything wrong in it is re-asserted forever
|
|
769
|
+
* rather than misleading once. That is the difference between an objective
|
|
770
|
+
* and a message, and it is why this needs its own verb.
|
|
771
|
+
*/
|
|
772
|
+
const setMatch = arg.match(/^(?:set|objective|replace)\s+([\s\S]+)$/i);
|
|
773
|
+
if (setMatch && existing) {
|
|
774
|
+
const before = existing.objective;
|
|
775
|
+
existing.objective = setMatch[1].trim();
|
|
776
|
+
// Notes written against the old objective may not make sense against the
|
|
777
|
+
// new one; say so rather than silently carrying them over.
|
|
778
|
+
const dropped = existing.pending.length;
|
|
779
|
+
existing.pending = [];
|
|
780
|
+
note(existing, `objective replaced${dropped ? `, ${dropped} pending instruction(s) dropped with it` : ""}`);
|
|
781
|
+
saveState(state);
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
managed: true,
|
|
785
|
+
message: `objective replaced for ${name}.\n was: ${before.slice(0, 80)}${before.length > 80 ? "…" : ""}\n now: ${existing.objective.slice(0, 80)}${existing.objective.length > 80 ? "…" : ""}` +
|
|
786
|
+
(dropped ? `\n ${dropped} pending instruction(s) dropped — they were written against the old objective.` : ""),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* hands off / hands on — take the screen back without stopping the work.
|
|
791
|
+
*
|
|
792
|
+
* `pause` is the wrong tool for this: it stops the manager, and what is
|
|
793
|
+
* wanted is the opposite — the session keeps working, it just stops touching
|
|
794
|
+
* the screen. A session driving the pointer is the one thing that cannot
|
|
795
|
+
* share a machine with its operator.
|
|
796
|
+
*
|
|
797
|
+
* DELIVERY IS THE HARD PART, and an instruction carried into the next arming
|
|
798
|
+
* is useless here: the next arming may be twenty minutes away and the pointer
|
|
799
|
+
* is moving now. Two things happen instead, and neither waits for a goal.
|
|
800
|
+
*
|
|
801
|
+
* First, the message is typed into the session directly, so it lands at the
|
|
802
|
+
* next tool-call boundary — seconds, for a session that is clicking.
|
|
803
|
+
*
|
|
804
|
+
* Second, it opens with the exact phrase the screen-control tool's own hook
|
|
805
|
+
* watches for. That revokes control at the TOOL, so the next click fails with
|
|
806
|
+
* an error explaining why, rather than depending on the session having read
|
|
807
|
+
* and obeyed a sentence. Enforced beats cooperative when the cost of it being
|
|
808
|
+
* ignored is the operator losing their pointer mid-sentence.
|
|
809
|
+
*/
|
|
810
|
+
const handsMatch = arg.match(/^hands?\s+(off|on)\b\s*(.*)$/i);
|
|
811
|
+
if (handsMatch || word === "nogui" || word === "gui") {
|
|
812
|
+
const off = handsMatch ? /off/i.test(handsMatch[1]) : word === "nogui";
|
|
813
|
+
if (!existing)
|
|
814
|
+
return { ok: false, message: `${name} is not being managed` };
|
|
815
|
+
/**
|
|
816
|
+
* A DURATION, because the useful case is bounded in both directions.
|
|
817
|
+
*
|
|
818
|
+
* "hands on for eight hours" is the overnight grant: it may drive the screen
|
|
819
|
+
* while nobody is at the machine, and it gives the screen BACK before
|
|
820
|
+
* somebody sits down — without that person having to remember to revoke it.
|
|
821
|
+
* "hands off for thirty minutes" is the mirror: take the machine, and have
|
|
822
|
+
* visual work resume by itself rather than staying stopped because nobody
|
|
823
|
+
* said the word.
|
|
824
|
+
*
|
|
825
|
+
* Both matter for the same reason: a permission that only ends when a person
|
|
826
|
+
* remembers to end it is a permission that outlives its reason.
|
|
827
|
+
*/
|
|
828
|
+
const dur = (handsMatch?.[2] ?? "").match(/(?:for\s+)?(\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)\b/i);
|
|
829
|
+
if (dur) {
|
|
830
|
+
const n = Number(dur[1]);
|
|
831
|
+
const unit = dur[2].toLowerCase();
|
|
832
|
+
const ms = /^h/.test(unit) ? n * 3_600_000 : n * 60_000;
|
|
833
|
+
existing.handsUntil = Date.now() + ms;
|
|
834
|
+
existing.handsWas = off;
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
delete existing.handsUntil;
|
|
838
|
+
delete existing.handsWas;
|
|
839
|
+
}
|
|
840
|
+
existing.noScreen = off;
|
|
841
|
+
if (off) {
|
|
842
|
+
// The reason comes FIRST, and the phrase is inside a sentence rather than
|
|
843
|
+
// barked on its own. "my controls" alone revokes the tool and explains
|
|
844
|
+
// nothing — a session that has just lost the pointer mid-task, with no
|
|
845
|
+
// reason given, will either guess or stop, and both are worse than being
|
|
846
|
+
// told. What it needs is: why, what to stop, what to leave behind, what to
|
|
847
|
+
// do instead, and when this ends.
|
|
848
|
+
const msg = "THE OPERATOR NEEDS THE SCREEN — my controls. This is not a fault and not a criticism of what you were doing; " +
|
|
849
|
+
"they have come back to the machine and cannot share a pointer with you. " +
|
|
850
|
+
"So: stop all screen and pointer work now, mid-task if necessary. " +
|
|
851
|
+
"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. " +
|
|
852
|
+
"Then KEEP WORKING on everything that does not need the screen: reading code, diagnosing, writing, tests, notes. There is plenty of that. " +
|
|
853
|
+
"Do not stop and do not wait. The screen comes back to you when you are told the controls are yours again.";
|
|
854
|
+
typeIntoSession(sessionId, msg);
|
|
855
|
+
note(existing, "hands off — screen work stopped, non-visual work continues");
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
typeIntoSession(sessionId, "your controls. The screen is yours again — you may resume visual work where your notes left it.");
|
|
859
|
+
note(existing, "hands on — screen work permitted again");
|
|
860
|
+
}
|
|
861
|
+
saveState(state);
|
|
862
|
+
return {
|
|
863
|
+
ok: true,
|
|
864
|
+
managed: true,
|
|
865
|
+
message: (off
|
|
866
|
+
? `${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.`
|
|
867
|
+
: `${name}: screen work permitted again.`) +
|
|
868
|
+
(existing.handsUntil
|
|
869
|
+
? `\n reverts by itself at ${new Date(existing.handsUntil).toLocaleTimeString("de-DE")} — no need to remember.`
|
|
870
|
+
: `\n stays this way until you say otherwise.`),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
if (word === "pause" || word === "resume") {
|
|
874
|
+
if (!existing)
|
|
875
|
+
return { ok: false, message: `${name} is not being managed` };
|
|
876
|
+
existing.paused = word === "pause";
|
|
877
|
+
saveState(state);
|
|
878
|
+
return { ok: true, managed: true, message: `${word === "pause" ? "paused" : "resumed"} managing ${name}` };
|
|
879
|
+
}
|
|
880
|
+
if (!existing) {
|
|
881
|
+
/**
|
|
882
|
+
* REFUSE TO MANAGE ANYTHING THAT IS NOT A SESSION.
|
|
883
|
+
*
|
|
884
|
+
* `aibroker manage status CaseLeaf` — keyword first, session second —
|
|
885
|
+
* resolved to the plain shell the command was typed in, and the remainder
|
|
886
|
+
* became an objective: a manager was created for a `-zsh` pane, silently,
|
|
887
|
+
* with the objective "status CaseLeaf". Nothing would ever have come of it
|
|
888
|
+
* except goals typed at a shell prompt.
|
|
889
|
+
*
|
|
890
|
+
* The arm path already refuses a bare shell. That is too late: by then a
|
|
891
|
+
* manager exists, appears in every listing, and has to be found and removed
|
|
892
|
+
* by somebody who did not create it on purpose. Check at the point of
|
|
893
|
+
* creation, where the mistake is still one command old.
|
|
894
|
+
*/
|
|
895
|
+
const probe = readSessionContent(sessionId, 5);
|
|
896
|
+
if (probe?.atPrompt) {
|
|
897
|
+
return {
|
|
898
|
+
ok: false,
|
|
899
|
+
message: `${name} is a shell prompt, not a running session — refusing to manage it.\n` +
|
|
900
|
+
`If you meant a different session, name it first: aibroker manage <session> <objective>`,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const m = {
|
|
904
|
+
sessionId,
|
|
905
|
+
name,
|
|
906
|
+
objective: arg,
|
|
907
|
+
pending: [],
|
|
908
|
+
history: [],
|
|
909
|
+
// Give the session the benefit of the grace period rather than arming
|
|
910
|
+
// on top of whatever it is doing at the moment the operator types this.
|
|
911
|
+
lastRearmAt: Date.now(),
|
|
912
|
+
lastChangeAt: Date.now(),
|
|
913
|
+
lastHash: hash(readPane(sessionId)),
|
|
914
|
+
tty: snapshotTty(sessionId),
|
|
915
|
+
paused: false,
|
|
916
|
+
startedAt: Date.now(),
|
|
917
|
+
};
|
|
918
|
+
state[sessionId] = m;
|
|
919
|
+
note(m, "started");
|
|
920
|
+
saveState(state);
|
|
921
|
+
startManagerLoop();
|
|
922
|
+
return {
|
|
923
|
+
ok: true,
|
|
924
|
+
managed: true,
|
|
925
|
+
message: `managing ${name}. It will be re-armed with this objective whenever it stops:\n ${arg}`,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (word === "now") {
|
|
929
|
+
existing.lastRearmAt = 0;
|
|
930
|
+
saveState(state);
|
|
931
|
+
return { ok: true, managed: true, message: `${name} will be armed on the next tick` };
|
|
932
|
+
}
|
|
933
|
+
existing.pending.push(arg);
|
|
934
|
+
note(existing, `operator: ${arg.slice(0, 80)}`);
|
|
935
|
+
saveState(state);
|
|
936
|
+
return {
|
|
937
|
+
ok: true,
|
|
938
|
+
managed: true,
|
|
939
|
+
message: `noted for ${name} — it goes out with the next arming (${existing.pending.length} pending)`,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
//# sourceMappingURL=manage.js.map
|