aibroker 0.61.9 → 0.62.3
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/iterm/core.d.ts +8 -0
- package/dist/adapters/iterm/core.d.ts.map +1 -1
- package/dist/adapters/iterm/core.js +77 -17
- package/dist/adapters/iterm/core.js.map +1 -1
- package/dist/adapters/iterm/sessions.d.ts +6 -0
- package/dist/adapters/iterm/sessions.d.ts.map +1 -1
- package/dist/adapters/iterm/sessions.js +25 -2
- package/dist/adapters/iterm/sessions.js.map +1 -1
- package/dist/adapters/pailot/gateway.d.ts +7 -0
- package/dist/adapters/pailot/gateway.d.ts.map +1 -1
- package/dist/adapters/pailot/gateway.js +17 -9
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/core/claude-attach.d.ts +13 -0
- package/dist/core/claude-attach.d.ts.map +1 -0
- package/dist/core/claude-attach.js +22 -0
- package/dist/core/claude-attach.js.map +1 -0
- package/dist/core/state.d.ts +38 -5
- package/dist/core/state.d.ts.map +1 -1
- package/dist/core/state.js +107 -15
- package/dist/core/state.js.map +1 -1
- package/dist/daemon/agent-env.d.ts +7 -0
- package/dist/daemon/agent-env.d.ts.map +1 -0
- package/dist/daemon/agent-env.js +9 -0
- package/dist/daemon/agent-env.js.map +1 -0
- package/dist/daemon/cli.js +2 -0
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/core-handlers.d.ts +35 -3
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +143 -71
- package/dist/daemon/core-handlers.js.map +1 -1
- package/dist/daemon/forge-release.d.ts +41 -0
- package/dist/daemon/forge-release.d.ts.map +1 -0
- package/dist/daemon/forge-release.js +127 -0
- package/dist/daemon/forge-release.js.map +1 -0
- package/dist/daemon/inbound.d.ts +2 -0
- package/dist/daemon/inbound.d.ts.map +1 -1
- package/dist/daemon/inbound.js +17 -6
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +2 -2
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mailbox-watch.js +1 -1
- package/dist/daemon/mailbox-watch.js.map +1 -1
- package/dist/daemon/manage.d.ts +147 -5
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +618 -72
- package/dist/daemon/manage.js.map +1 -1
- package/dist/daemon/pointer-guard.d.ts +37 -0
- package/dist/daemon/pointer-guard.d.ts.map +1 -0
- package/dist/daemon/pointer-guard.js +100 -0
- package/dist/daemon/pointer-guard.js.map +1 -0
- package/dist/daemon/session-content.d.ts +8 -0
- package/dist/daemon/session-content.d.ts.map +1 -1
- package/dist/daemon/session-content.js +28 -5
- package/dist/daemon/session-content.js.map +1 -1
- package/dist/daemon/todoist-webhook.d.ts.map +1 -1
- package/dist/daemon/todoist-webhook.js +8 -4
- package/dist/daemon/todoist-webhook.js.map +1 -1
- package/dist/ipc/client.d.ts.map +1 -1
- package/dist/ipc/client.js +37 -0
- package/dist/ipc/client.js.map +1 -1
- package/dist/mcp/index.js +14 -7
- package/dist/mcp/index.js.map +1 -1
- package/dist/types/ipc.d.ts +7 -0
- package/dist/types/ipc.d.ts.map +1 -1
- package/hooks/drain-mailbox.mjs +20 -3
- package/hooks/manage-hook.mjs +3 -1
- package/hooks/operator-manage-token.mjs +45 -0
- package/package.json +1 -1
package/dist/daemon/manage.js
CHANGED
|
@@ -25,10 +25,11 @@ import { join, dirname } from "node:path";
|
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { log } from "../core/log.js";
|
|
27
27
|
import { timeCall } from "../core/call-timing.js";
|
|
28
|
-
import { readSessionContent } from "./session-content.js";
|
|
28
|
+
import { readSessionContent, readSessionContentAsync } from "./session-content.js";
|
|
29
29
|
import { typeIntoSession, pasteTextIntoSession, sendControlU, sendEnterKey, escapeInputMode, wasLastEnumerationReliable } from "../transport/sync-facade.js";
|
|
30
30
|
import { findItermBundleIdImpostors } from "../adapters/iterm/core.js";
|
|
31
31
|
import { processCwd } from "../core/bins.js";
|
|
32
|
+
import { parseAttachUuid, parseSessionIdArg } from "../core/claude-attach.js";
|
|
32
33
|
import { discoverLiveSessions } from "../core/session-discovery.js";
|
|
33
34
|
import { hasPailotClients } from "../adapters/pailot/gateway.js";
|
|
34
35
|
import { getAibpBridge } from "../core/state.js";
|
|
@@ -100,8 +101,8 @@ const GOAL_ACTIVE = /\/goal\s+active/i;
|
|
|
100
101
|
* because composing one is exactly the sort of work it no longer has room
|
|
101
102
|
* for. The margin has to be big enough to write in.
|
|
102
103
|
*/
|
|
103
|
-
const HANDOVER_MARGIN_K_DEFAULT =
|
|
104
|
-
const REFRESH_MARGIN_K =
|
|
104
|
+
const HANDOVER_MARGIN_K_DEFAULT = 50; // warm-up band: ask this far below the trigger, given time or work done
|
|
105
|
+
const REFRESH_MARGIN_K = 30; // refresh band: ask again if it has grown 40k+ since the last one
|
|
105
106
|
const REFRESH_GROWN_K = 40;
|
|
106
107
|
const IMMEDIATE_MARGIN_K = 15; // immediate band: ask NOW, no gating — this close, waiting is the wrong trade
|
|
107
108
|
/**
|
|
@@ -281,6 +282,12 @@ const HANDOVER_REASK_K = 120;
|
|
|
281
282
|
* growth its own last handover caused.
|
|
282
283
|
*/
|
|
283
284
|
const HANDOVER_MIN_GAP_MS = 8 * 60_000;
|
|
285
|
+
/**
|
|
286
|
+
* Least new work (K tokens) since the last confirmed handover before another
|
|
287
|
+
* is asked for in the warm-up band (refresh needs 40k, immediate is ungated). The log showed re-asks with 3k of new work: a
|
|
288
|
+
* handover that age-by-clock alone justified, with nothing new to write down.
|
|
289
|
+
*/
|
|
290
|
+
const HANDOVER_MIN_NEW_K = 20;
|
|
284
291
|
/**
|
|
285
292
|
* Input the terminal is holding rather than running.
|
|
286
293
|
*
|
|
@@ -411,7 +418,7 @@ function mirrorToRepo(m) {
|
|
|
411
418
|
const proc = tty ? processReading(tty) : { isSession: false, pid: null };
|
|
412
419
|
if (!proc.pid)
|
|
413
420
|
return;
|
|
414
|
-
const live = repoRootFor(proc.pid);
|
|
421
|
+
const live = repoRootFor(proc.pid, proc.uuid);
|
|
415
422
|
if (!live)
|
|
416
423
|
return;
|
|
417
424
|
// Pin on the first successful resolution — including for sessions that
|
|
@@ -550,12 +557,12 @@ export function bandOf(usedK, effectiveK, marginK) {
|
|
|
550
557
|
* nothing is one redundant note, the cost of NOT asking is the session
|
|
551
558
|
* compacting before it writes anything down.
|
|
552
559
|
*
|
|
553
|
-
* REFRESH (trigger −
|
|
560
|
+
* REFRESH (trigger − 30k) — due once REFRESH_GROWN_K of new work has
|
|
554
561
|
* landed since the last handover, regardless of the clock. A handover
|
|
555
562
|
* this close to the trigger that predates a lot of new work is stale
|
|
556
563
|
* exactly where staleness costs the most.
|
|
557
564
|
*
|
|
558
|
-
* WARM-UP (trigger − marginK, default
|
|
565
|
+
* WARM-UP (trigger − marginK, default 50k) — the ordinary case, gated the
|
|
559
566
|
* same way as before this was split into bands: due by the CLOCK
|
|
560
567
|
* (`lastAskAt` old enough) or by WORK done since the last one.
|
|
561
568
|
*
|
|
@@ -595,6 +602,11 @@ export function handoverDue(input) {
|
|
|
595
602
|
return { due: true, reason: "refresh" };
|
|
596
603
|
}
|
|
597
604
|
const sinceLast = input.now - (input.lastAskAt ?? 0);
|
|
605
|
+
// The immediate band stays ungated (this close to the wall, waiting is the
|
|
606
|
+
// wrong trade); warm-up by clock needs real new work, not 3k.
|
|
607
|
+
if (used >= warmUpK && grownBy !== undefined && grownBy < HANDOVER_MIN_NEW_K) {
|
|
608
|
+
return { due: false, reason: `only ${grownBy}k of new work since the last handover (min ${HANDOVER_MIN_NEW_K}k)` };
|
|
609
|
+
}
|
|
598
610
|
const dueByTime = sinceLast > HANDOVER_REASK_MS;
|
|
599
611
|
const dueByWork = grownBy !== undefined && grownBy >= HANDOVER_REASK_K && sinceLast > HANDOVER_MIN_GAP_MS;
|
|
600
612
|
if (used >= warmUpK && (dueByTime || dueByWork)) {
|
|
@@ -684,6 +696,12 @@ export function typedLineMatches(readBack, intended) {
|
|
|
684
696
|
if (normalizedReadBack.startsWith(normalizedIntended)) {
|
|
685
697
|
return true;
|
|
686
698
|
}
|
|
699
|
+
// A terminal that wraps mid-word makes the join add a space inside a word;
|
|
700
|
+
// compare with all whitespace removed as well.
|
|
701
|
+
const squash = (t) => t.replace(/\s+/g, "");
|
|
702
|
+
if (squash(normalizedReadBack).startsWith(squash(normalizedIntended))) {
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
687
705
|
// Strip Claude Code paste placeholder.
|
|
688
706
|
const placeholder = /^\[Pasted text #\d+\]\s*/;
|
|
689
707
|
const collapsed = placeholder.test(normalizedReadBack);
|
|
@@ -695,7 +713,8 @@ export function typedLineMatches(readBack, intended) {
|
|
|
695
713
|
}
|
|
696
714
|
// Tail match: read-back appears as the end of intended, if long enough.
|
|
697
715
|
if (strippedReadBack.length >= 30 &&
|
|
698
|
-
normalizedIntended.endsWith(strippedReadBack)
|
|
716
|
+
(normalizedIntended.endsWith(strippedReadBack) ||
|
|
717
|
+
squash(normalizedIntended).endsWith(squash(strippedReadBack)))) {
|
|
699
718
|
return true;
|
|
700
719
|
}
|
|
701
720
|
return false;
|
|
@@ -828,7 +847,10 @@ function clearLanded(m) {
|
|
|
828
847
|
*/
|
|
829
848
|
export function promptUnsentText(content) {
|
|
830
849
|
const lines = content.split("\n");
|
|
831
|
-
|
|
850
|
+
// Bottom-up: the live box is the LAST rule-framed ❯ line. An older box
|
|
851
|
+
// remnant in scrollback (a redraw) must never outvote it, even when the
|
|
852
|
+
// live one is empty.
|
|
853
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
832
854
|
// The rule may carry a title — "──── Name ──" — so match its start, not
|
|
833
855
|
// the whole line. Requiring the run of box-drawing characters up front
|
|
834
856
|
// keeps this from matching prose that happens to begin with a dash.
|
|
@@ -838,10 +860,21 @@ export function promptUnsentText(content) {
|
|
|
838
860
|
const m = lines[i].match(/^\s*❯\s*(.*)$/);
|
|
839
861
|
if (!m)
|
|
840
862
|
continue;
|
|
841
|
-
|
|
863
|
+
// A line longer than the pane wraps onto continuation lines inside the
|
|
864
|
+
// box, down to the closing rule; the unsent text is all of them. Without
|
|
865
|
+
// a closing rule (cut-off capture) only the ❯ line is trusted.
|
|
866
|
+
const parts = [m[1].trim()];
|
|
867
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
868
|
+
if (/^\s*[─—-]{10,}/.test(lines[j]))
|
|
869
|
+
break;
|
|
870
|
+
parts.push(lines[j].trim());
|
|
871
|
+
if (j === lines.length - 1)
|
|
872
|
+
parts.length = 1;
|
|
873
|
+
}
|
|
874
|
+
const typed = parts.filter(Boolean).join(" ");
|
|
842
875
|
// The terminal's own hint about held input is not the operator's text.
|
|
843
876
|
if (!typed || /^press up to edit/i.test(typed))
|
|
844
|
-
|
|
877
|
+
return null;
|
|
845
878
|
return typed;
|
|
846
879
|
}
|
|
847
880
|
return null;
|
|
@@ -933,11 +966,11 @@ function repoRootForSession(sessionId) {
|
|
|
933
966
|
const proc = processReading(tty);
|
|
934
967
|
if (!proc.pid)
|
|
935
968
|
return undefined;
|
|
936
|
-
return repoRootFor(proc.pid) ?? undefined;
|
|
969
|
+
return repoRootFor(proc.pid, proc.uuid) ?? undefined;
|
|
937
970
|
}
|
|
938
|
-
function repoRootFor(pid) {
|
|
971
|
+
function repoRootFor(pid, uuid) {
|
|
939
972
|
try {
|
|
940
|
-
const cwd =
|
|
973
|
+
const cwd = sessionProjectCwd(pid, uuid);
|
|
941
974
|
if (!cwd)
|
|
942
975
|
return null;
|
|
943
976
|
const root = execFileSync("/usr/bin/env", ["git", "-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
@@ -1085,6 +1118,25 @@ const lastContextLog = new Map();
|
|
|
1085
1118
|
* GHOST_TICKS more ticks, never causes one to fire early.
|
|
1086
1119
|
*/
|
|
1087
1120
|
const inputLineTracking = new Map();
|
|
1121
|
+
/**
|
|
1122
|
+
* The report's lines: one per UNPAUSED session, resetting its arming counter.
|
|
1123
|
+
* `kOf` is injectable so the policy can be tested without a live terminal.
|
|
1124
|
+
*/
|
|
1125
|
+
export function reportLines(managed, armingsSince, now, kOf = contextK) {
|
|
1126
|
+
const mins = Math.round(REPORT_EVERY_MS / 60_000);
|
|
1127
|
+
return managed.filter((m) => !m.paused).map((m) => {
|
|
1128
|
+
const armings = armingsSince.get(m.sessionId) ?? 0;
|
|
1129
|
+
armingsSince.set(m.sessionId, 0);
|
|
1130
|
+
const k = kOf(m);
|
|
1131
|
+
const quietFor = Math.round((now - m.lastChangeAt) / 60_000);
|
|
1132
|
+
const doing = quietFor >= 2 ? `quiet for ${quietFor} min` : "working";
|
|
1133
|
+
const last = m.history.at(-1);
|
|
1134
|
+
return (`• ${m.name} — ${doing}` +
|
|
1135
|
+
(k !== null ? `, ${k}k context` : "") +
|
|
1136
|
+
`, ${armings} arming${armings === 1 ? "" : "s"} in ${mins} min` +
|
|
1137
|
+
(last ? `\n last: ${last.what.slice(0, 110)}` : ""));
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1088
1140
|
/**
|
|
1089
1141
|
* The periodic reading: what every managed session is doing, in one message.
|
|
1090
1142
|
*
|
|
@@ -1109,19 +1161,14 @@ function reportIfDue(now) {
|
|
|
1109
1161
|
if (now - lastReportAt < REPORT_EVERY_MS)
|
|
1110
1162
|
return;
|
|
1111
1163
|
lastReportAt = now;
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
return (`• ${m.name} — ${doing}` +
|
|
1121
|
-
(k !== null ? `, ${k}k context` : "") +
|
|
1122
|
-
`, ${armings} arming${armings === 1 ? "" : "s"} in ${mins} min` +
|
|
1123
|
-
(last ? `\n last: ${last.what.slice(0, 110)}` : ""));
|
|
1124
|
-
});
|
|
1164
|
+
// A paused manager does nothing, so there is nothing to say about it.
|
|
1165
|
+
for (const m of managed)
|
|
1166
|
+
if (m.paused)
|
|
1167
|
+
armingsSinceReport.set(m.sessionId, 0);
|
|
1168
|
+
const lines = reportLines(managed, armingsSinceReport, now);
|
|
1169
|
+
// Only paused sessions: nothing to report, clock already held at now above.
|
|
1170
|
+
if (lines.length === 0)
|
|
1171
|
+
return;
|
|
1125
1172
|
reportToOperator(`Manager report\n${lines.join("\n")}`);
|
|
1126
1173
|
}
|
|
1127
1174
|
/**
|
|
@@ -1212,7 +1259,8 @@ function processReading(tty) {
|
|
|
1212
1259
|
const claude = rows.find((r) => /(^|\/)claude$/.test(r.cmd.split(/\s+/)[0]));
|
|
1213
1260
|
if (!claude)
|
|
1214
1261
|
return { isSession: false, pid: null };
|
|
1215
|
-
|
|
1262
|
+
// A `claude attach <id>` viewer's cwd is not the session's; the id is.
|
|
1263
|
+
return { isSession: true, pid: claude.pid, uuid: parseAttachUuid(claude.cmd) ?? parseSessionIdArg(claude.cmd) };
|
|
1216
1264
|
}
|
|
1217
1265
|
/**
|
|
1218
1266
|
* Pure extraction of context usage from raw transcript JSONL lines.
|
|
@@ -1277,19 +1325,82 @@ export function usageFromLines(lines) {
|
|
|
1277
1325
|
function sessionCwd(claudePid) {
|
|
1278
1326
|
return processCwd(claudePid);
|
|
1279
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* The session's project directory. A `claude attach` viewer sits in HOME, so
|
|
1330
|
+
* its process cwd names no project; the transcript named by the session id
|
|
1331
|
+
* records the real one in the `cwd` of its newest record. Process cwd is the
|
|
1332
|
+
* fallback when the id is unknown or the transcript carries no cwd.
|
|
1333
|
+
*/
|
|
1334
|
+
export function sessionProjectCwd(claudePid, uuid, root, procCwd = sessionCwd) {
|
|
1335
|
+
const file = uuid ? sessionTranscriptFile(uuid, root) : null;
|
|
1336
|
+
if (file) {
|
|
1337
|
+
try {
|
|
1338
|
+
const tail = execFileSync("/usr/bin/tail", ["-n", "200", file], { encoding: "utf8", timeout: 4_000, maxBuffer: 8 * 1024 * 1024 });
|
|
1339
|
+
const lines = tail.split("\n");
|
|
1340
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1341
|
+
try {
|
|
1342
|
+
const cwd = JSON.parse(lines[i]).cwd;
|
|
1343
|
+
if (typeof cwd === "string" && cwd)
|
|
1344
|
+
return cwd;
|
|
1345
|
+
}
|
|
1346
|
+
catch { /* blank or truncated line */ }
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
catch { /* unreadable transcript: fall back */ }
|
|
1350
|
+
}
|
|
1351
|
+
return procCwd(claudePid);
|
|
1352
|
+
}
|
|
1280
1353
|
/**
|
|
1281
1354
|
* The transcript directory for a session's own project, derived from its cwd.
|
|
1282
1355
|
* Shared by transcriptReading (the live session's own tail) and the
|
|
1283
1356
|
* measured-compaction reading (every .jsonl in the project, not only the
|
|
1284
1357
|
* live one).
|
|
1285
1358
|
*/
|
|
1286
|
-
function projectTranscriptDir(claudePid) {
|
|
1359
|
+
function projectTranscriptDir(claudePid, uuid) {
|
|
1360
|
+
const byId = uuid ? sessionTranscriptFile(uuid) : null;
|
|
1361
|
+
if (byId)
|
|
1362
|
+
return dirname(byId);
|
|
1287
1363
|
const cwd = sessionCwd(claudePid);
|
|
1288
1364
|
if (!cwd)
|
|
1289
1365
|
return null;
|
|
1290
1366
|
const dir = join(homedir(), ".claude", "projects", cwd.replace(/\//g, "-"));
|
|
1291
1367
|
return existsSync(dir) ? dir : null;
|
|
1292
1368
|
}
|
|
1369
|
+
const transcriptFileCache = new Map();
|
|
1370
|
+
/**
|
|
1371
|
+
* The transcript named by a Claude session id (full uuid or the short prefix
|
|
1372
|
+
* `claude attach` takes), found under any project dir. The id is the only
|
|
1373
|
+
* thing that identifies a background session's file: its viewer process sits
|
|
1374
|
+
* in HOME, so the cwd-derived project dir is some other session's. Cached per
|
|
1375
|
+
* id; re-resolved when the cached file is gone.
|
|
1376
|
+
*/
|
|
1377
|
+
export function sessionTranscriptFile(uuid, root = join(homedir(), ".claude", "projects")) {
|
|
1378
|
+
const key = `${root}\0${uuid.toLowerCase()}`;
|
|
1379
|
+
const cached = transcriptFileCache.get(key);
|
|
1380
|
+
if (cached && existsSync(cached))
|
|
1381
|
+
return cached;
|
|
1382
|
+
transcriptFileCache.delete(key);
|
|
1383
|
+
try {
|
|
1384
|
+
const prefix = uuid.toLowerCase();
|
|
1385
|
+
for (const d of readdirSync(root)) {
|
|
1386
|
+
let names;
|
|
1387
|
+
try {
|
|
1388
|
+
names = readdirSync(join(root, d));
|
|
1389
|
+
}
|
|
1390
|
+
catch {
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
const hit = names.find((f) => f.endsWith(".jsonl") && f.toLowerCase().startsWith(prefix));
|
|
1394
|
+
if (hit) {
|
|
1395
|
+
const path = join(root, d, hit);
|
|
1396
|
+
transcriptFileCache.set(key, path);
|
|
1397
|
+
return path;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch { /* no projects dir */ }
|
|
1402
|
+
return null;
|
|
1403
|
+
}
|
|
1293
1404
|
/**
|
|
1294
1405
|
* Where a handover goes when nobody has named a file — the convention this
|
|
1295
1406
|
* project already uses without the manager's help, so a session asked cold
|
|
@@ -1304,6 +1415,47 @@ export function defaultHandoverTarget(cwd, notesDirExists) {
|
|
|
1304
1415
|
return null;
|
|
1305
1416
|
return join(cwd, "Notes", "TODO.md");
|
|
1306
1417
|
}
|
|
1418
|
+
/**
|
|
1419
|
+
* The project's configured handover sink, from `.pai/project.yaml`
|
|
1420
|
+
* (`status.sink: forge` + `status.forge.anchor` / `repo`, the schema PAI's
|
|
1421
|
+
* forge-sink owns). Null for the default `todo` sink or no file, so the
|
|
1422
|
+
* caller falls through to Notes/TODO.md. Walks up from the session's cwd.
|
|
1423
|
+
* ponytail: line scan, not a YAML parse — only three scalar keys are read;
|
|
1424
|
+
* switch to the yaml package if the schema grows structure.
|
|
1425
|
+
*/
|
|
1426
|
+
export function projectSinkDescription(cwd) {
|
|
1427
|
+
let dir = cwd;
|
|
1428
|
+
for (let i = 0; dir && i < 8; i++, dir = dirname(dir) === dir ? null : dirname(dir)) {
|
|
1429
|
+
const file = join(dir, ".pai", "project.yaml");
|
|
1430
|
+
if (!existsSync(file))
|
|
1431
|
+
continue;
|
|
1432
|
+
let text;
|
|
1433
|
+
try {
|
|
1434
|
+
text = readFileSync(file, "utf8");
|
|
1435
|
+
}
|
|
1436
|
+
catch {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
const key = (k) => text.match(new RegExp(`^\\s+${k}:\\s*["']?([^\\s#"']+)`, "m"))?.[1];
|
|
1440
|
+
if (key("sink") !== "forge")
|
|
1441
|
+
return null;
|
|
1442
|
+
const anchor = key("anchor");
|
|
1443
|
+
const repo = key("repo");
|
|
1444
|
+
return (`the forge handover issue named by the anchor${anchor ? ` #${anchor}` : ""}${repo ? ` in ${repo}` : ""} ` +
|
|
1445
|
+
`(this project's status sink in .pai/project.yaml) — replace the section between <!-- pai:continue --> and <!-- /pai:continue --> in it`);
|
|
1446
|
+
}
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
/** Precedence: explicit handoverFile > project status sink > Notes/TODO.md default. */
|
|
1450
|
+
export function pickHandoverTarget(i) {
|
|
1451
|
+
if (i.handoverFile)
|
|
1452
|
+
return { path: resolveHandoverPath(i.handoverFile), source: "explicit" };
|
|
1453
|
+
if (i.sinkDescription)
|
|
1454
|
+
return { description: i.sinkDescription, source: "project sink" };
|
|
1455
|
+
if (i.defaultPath)
|
|
1456
|
+
return { path: i.defaultPath, source: "default" };
|
|
1457
|
+
return { source: "none" };
|
|
1458
|
+
}
|
|
1307
1459
|
/**
|
|
1308
1460
|
* The live transcript's tail. File mtime is not evidence of life: a stale
|
|
1309
1461
|
* transcript keeps getting non-message records appended. Among the five most
|
|
@@ -1338,16 +1490,28 @@ export function liveTranscriptTail(dir) {
|
|
|
1338
1490
|
}
|
|
1339
1491
|
return best;
|
|
1340
1492
|
}
|
|
1341
|
-
function transcriptReading(claudePid) {
|
|
1493
|
+
function transcriptReading(claudePid, uuid) {
|
|
1342
1494
|
const none = { working: null, doing: null, contextK: null, lastAt: null };
|
|
1343
1495
|
try {
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1496
|
+
// The file named by the session id wins; the newest-in-project guess is
|
|
1497
|
+
// only for a session whose id is not known.
|
|
1498
|
+
const byId = uuid ? sessionTranscriptFile(uuid) : null;
|
|
1499
|
+
let raw;
|
|
1500
|
+
let file;
|
|
1501
|
+
if (byId) {
|
|
1502
|
+
raw = execFileSync("/usr/bin/tail", ["-n", "200", byId], { encoding: "utf8", timeout: 4_000, maxBuffer: 8 * 1024 * 1024 });
|
|
1503
|
+
file = byId;
|
|
1504
|
+
}
|
|
1505
|
+
else {
|
|
1506
|
+
const dir = projectTranscriptDir(claudePid);
|
|
1507
|
+
if (!dir)
|
|
1508
|
+
return none;
|
|
1509
|
+
const picked = liveTranscriptTail(dir);
|
|
1510
|
+
if (!picked)
|
|
1511
|
+
return none;
|
|
1512
|
+
raw = picked.raw;
|
|
1513
|
+
file = join(dir, picked.file);
|
|
1514
|
+
}
|
|
1351
1515
|
const msgs = [];
|
|
1352
1516
|
for (const line of raw.split("\n")) {
|
|
1353
1517
|
if (!line.trim())
|
|
@@ -1379,7 +1543,7 @@ function transcriptReading(claudePid) {
|
|
|
1379
1543
|
// above so the pure extraction stays the single source of truth for what
|
|
1380
1544
|
// counts as "no usage" — duplicating the parse is cheap against 40 lines.
|
|
1381
1545
|
const contextK = usageFromLines(raw.split("\n")) ?? null;
|
|
1382
|
-
return { working, doing, contextK, lastAt };
|
|
1546
|
+
return { working, doing, contextK, lastAt, file };
|
|
1383
1547
|
}
|
|
1384
1548
|
catch {
|
|
1385
1549
|
return none;
|
|
@@ -1515,25 +1679,40 @@ function compactionReading(m, pid, dir) {
|
|
|
1515
1679
|
* which number came from the transcript and which was scraped off a status bar
|
|
1516
1680
|
* cannot tell which one to doubt.
|
|
1517
1681
|
*/
|
|
1682
|
+
function fmtAge(ms) {
|
|
1683
|
+
const s = Math.round(ms / 1000);
|
|
1684
|
+
return s < 90 ? `${s}s` : `${Math.round(s / 60)} min`;
|
|
1685
|
+
}
|
|
1518
1686
|
function liveReading(sessionId, idleSec, m) {
|
|
1519
1687
|
const snap = discoverLiveSessions().find((s) => s.id === sessionId);
|
|
1520
1688
|
const proc = snap?.tty ? processReading(snap.tty) : { isSession: false, pid: null };
|
|
1521
1689
|
if (!proc.isSession) {
|
|
1522
1690
|
return " no session process on that pane — it is a bare shell, or the session has exited";
|
|
1523
1691
|
}
|
|
1524
|
-
const
|
|
1692
|
+
const uuid = proc.uuid ?? snap?.attachUuid;
|
|
1693
|
+
const t = proc.pid
|
|
1694
|
+
? transcriptReading(proc.pid, uuid)
|
|
1695
|
+
: { working: null, doing: null, contextK: null, lastAt: null };
|
|
1525
1696
|
const out = [];
|
|
1526
1697
|
if (t.lastAt !== null) {
|
|
1527
1698
|
const agoSec = Math.round((Date.now() - t.lastAt) / 1000);
|
|
1528
1699
|
out.push(` ${t.working ? "working" : "idle"} · last transcript entry ${agoSec < 90 ? `${agoSec}s` : `${Math.round(agoSec / 60)} min`} ago` +
|
|
1529
1700
|
(t.doing ? ` · ${t.doing}` : ""));
|
|
1701
|
+
if (t.file) {
|
|
1702
|
+
let age = "";
|
|
1703
|
+
try {
|
|
1704
|
+
age = `, written ${fmtAge(Date.now() - statSync(t.file).mtimeMs)} ago`;
|
|
1705
|
+
}
|
|
1706
|
+
catch { /* gone */ }
|
|
1707
|
+
out.push(` transcript ${t.file}${age}`);
|
|
1708
|
+
}
|
|
1530
1709
|
if (t.contextK !== null) {
|
|
1531
1710
|
out.push(` context ${t.contextK}k tokens (from the transcript's own usage, not the status bar)`);
|
|
1532
1711
|
// The trigger, fill rate and ETA are only meaningful alongside a
|
|
1533
1712
|
// session's own margin, so all three are printed together and only
|
|
1534
1713
|
// when a managed session (`m`) is available to supply it.
|
|
1535
1714
|
if (m) {
|
|
1536
|
-
const dir = proc.pid ? projectTranscriptDir(proc.pid) : null;
|
|
1715
|
+
const dir = proc.pid ? projectTranscriptDir(proc.pid, uuid) : null;
|
|
1537
1716
|
const { effectiveK, label } = compactionReading(m, proc.pid, dir);
|
|
1538
1717
|
const marginK = resolvedMarginK(m);
|
|
1539
1718
|
const warmUpK = Math.round(effectiveK - marginK);
|
|
@@ -1892,6 +2071,154 @@ export function parseShift(text, now = new Date()) {
|
|
|
1892
2071
|
workers: Math.min(8, Math.max(1, workers)),
|
|
1893
2072
|
};
|
|
1894
2073
|
}
|
|
2074
|
+
// ── self-supervision: one call from the session itself ───────────────────────
|
|
2075
|
+
/** "self" or omitted means the caller's own session; anything else is taken as given. */
|
|
2076
|
+
export function resolveManageTarget(session, callerId) {
|
|
2077
|
+
const s = (session ?? "").trim();
|
|
2078
|
+
return !s || s.toLowerCase() === "self" ? (callerId ?? null) : s;
|
|
2079
|
+
}
|
|
2080
|
+
/** Put goal / until / every on a managed entry; the screen is never granted by supervise. */
|
|
2081
|
+
export function applySupervise(m, goal, until, every) {
|
|
2082
|
+
if (goal) {
|
|
2083
|
+
m.objective = goal;
|
|
2084
|
+
m.pending = [];
|
|
2085
|
+
m.lastRearmAt = 0;
|
|
2086
|
+
}
|
|
2087
|
+
if (every !== null)
|
|
2088
|
+
m.nudgeEveryMin = every;
|
|
2089
|
+
if (until !== null)
|
|
2090
|
+
m.shift = { until, workers: 1, visual: false, startedAt: Date.now(), plain: true };
|
|
2091
|
+
m.paused = false;
|
|
2092
|
+
note(m, `supervise: until ${until !== null ? hhmm(until) : "open-ended"}, nudge every ${m.nudgeEveryMin ?? "-"} min`);
|
|
2093
|
+
}
|
|
2094
|
+
// ── only the operator ends or shortens a goal ────────────────────────────────
|
|
2095
|
+
export const AGENT_REFUSAL = 'refused: only the operator ends or shortens a goal (say "manage <session> off" yourself in a session, or run it from your own terminal)';
|
|
2096
|
+
const TOKEN_TTL_MS = 5 * 60_000;
|
|
2097
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2098
|
+
const tokenDir = () => join(homedir(), ".aibroker", "operator-tokens");
|
|
2099
|
+
/** Written by hooks/operator-manage-token.mjs from the operator's own typed prompt. */
|
|
2100
|
+
export function writeOperatorToken(session, action, now = Date.now()) {
|
|
2101
|
+
mkdirSync(tokenDir(), { recursive: true, mode: 0o700 });
|
|
2102
|
+
writeFileSync(join(tokenDir(), `${slug(session)}-${action}.json`), JSON.stringify({ expiresAt: now + TOKEN_TTL_MS }), { mode: 0o600 });
|
|
2103
|
+
}
|
|
2104
|
+
/** One-shot: the file is removed whether or not it was still valid. */
|
|
2105
|
+
function consumeToken(sessions, action, now) {
|
|
2106
|
+
let ok = false;
|
|
2107
|
+
for (const s of new Set(sessions.map(slug).filter(Boolean))) {
|
|
2108
|
+
const file = join(tokenDir(), `${s}-${action}.json`);
|
|
2109
|
+
try {
|
|
2110
|
+
ok = JSON.parse(readFileSync(file, "utf8")).expiresAt > now || ok;
|
|
2111
|
+
unlinkSync(file);
|
|
2112
|
+
}
|
|
2113
|
+
catch { /* no token under this name */ }
|
|
2114
|
+
}
|
|
2115
|
+
return ok;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Agents cannot end or shorten a goal. Returns the refusal, or null when the
|
|
2119
|
+
* call may go ahead. `operator` is set only by the CLI (outside any agent
|
|
2120
|
+
* environment) and by the prompt hook; otherwise a one-shot token written from
|
|
2121
|
+
* the operator's typed prompt is required.
|
|
2122
|
+
* ponytail: a process that writes the daemon socket directly can still claim
|
|
2123
|
+
* `operator`; the socket is local-user trust, tighten with a peer credential.
|
|
2124
|
+
*/
|
|
2125
|
+
export function refuseAgentManage(sessionIdOrName, rawArg, opts, operator, now = Date.now()) {
|
|
2126
|
+
if (operator)
|
|
2127
|
+
return null;
|
|
2128
|
+
const arg = rawArg.trim().toLowerCase();
|
|
2129
|
+
const resolved = resolveSession(sessionIdOrName);
|
|
2130
|
+
const cur = resolved ? state[resolved.sessionId]?.shift?.until : undefined;
|
|
2131
|
+
const names = [sessionIdOrName, resolved?.name ?? ""];
|
|
2132
|
+
const ending = /^(off|stop)$/.test(arg) ? "off" :
|
|
2133
|
+
arg === "pause" ? "pause" :
|
|
2134
|
+
/^shift\s+(off|stop|end|stand down)\b/.test(arg) ? "shift-off" : null;
|
|
2135
|
+
if (ending)
|
|
2136
|
+
return consumeToken(names, ending, now) ? null : AGENT_REFUSAL;
|
|
2137
|
+
const shift = arg.match(/^shift\b\s*([\s\S]*)$/);
|
|
2138
|
+
const next = shift ? now + parseShift(shift[1], new Date(now)).hours * 3_600_000 : opts.until ? parseUntilSpec(opts.until, new Date(now)) : null;
|
|
2139
|
+
// Minute precision: the same clock time is not a shortening because of float/ms drift.
|
|
2140
|
+
const minute = (t) => Math.floor(t / 60_000);
|
|
2141
|
+
return next !== null && cur !== undefined && cur > now && minute(next) < minute(cur) ? AGENT_REFUSAL : null;
|
|
2142
|
+
}
|
|
2143
|
+
/** "08:00", "until 8pm", "8h", "90m" → epoch ms; null when it reads as none of these. */
|
|
2144
|
+
export function parseUntilSpec(spec, now = new Date()) {
|
|
2145
|
+
const t = spec.trim().toLowerCase();
|
|
2146
|
+
if (/^\d{1,2}(:\d{2})?\s*(am|pm)?$/.test(t))
|
|
2147
|
+
return parseUntilClock(`until ${t}`, now);
|
|
2148
|
+
if (/^(until|till|til)\s/.test(t))
|
|
2149
|
+
return parseUntilClock(t, now);
|
|
2150
|
+
const d = t.match(/^(?:for\s+)?(\d+(?:[.,]\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)$/);
|
|
2151
|
+
if (!d)
|
|
2152
|
+
return null;
|
|
2153
|
+
const n = Number(d[1].replace(",", "."));
|
|
2154
|
+
const ms = n * (/^h/.test(d[2]) ? 3_600_000 : 60_000);
|
|
2155
|
+
return now.getTime() + Math.min(ms, SHIFT_MAX_HOURS * 3_600_000);
|
|
2156
|
+
}
|
|
2157
|
+
const NUDGE_EVERY_MIN = 5;
|
|
2158
|
+
const NUDGE_EVERY_MAX = 480;
|
|
2159
|
+
/** Minutes between idle nudges: 30, "30", "30m", "1h". Null outside 5..480. */
|
|
2160
|
+
export function parseEveryMin(v) {
|
|
2161
|
+
const t = String(v ?? "").trim().toLowerCase();
|
|
2162
|
+
const m = t.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)?$/);
|
|
2163
|
+
if (!m)
|
|
2164
|
+
return null;
|
|
2165
|
+
const n = Math.round(Number(m[1]) * (m[2] && m[2].startsWith("h") ? 60 : 1));
|
|
2166
|
+
return n >= NUDGE_EVERY_MIN && n <= NUDGE_EVERY_MAX ? n : null;
|
|
2167
|
+
}
|
|
2168
|
+
/** When the next nudge (with `every`) or the ~46-50 min lapse re-arm falls due. */
|
|
2169
|
+
export function nextDueAt(m) {
|
|
2170
|
+
if (m.paused)
|
|
2171
|
+
return null;
|
|
2172
|
+
if (m.nudgeEveryMin) {
|
|
2173
|
+
return Math.max(m.lastChangeAt, m.lastNudgeAt ?? 0, m.lastRearmAt) + m.nudgeEveryMin * 60_000;
|
|
2174
|
+
}
|
|
2175
|
+
return m.lastRearmAt + GOAL_MAX_AGE_MS;
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Should a short "continue the goal" line be typed now? Only for a session
|
|
2179
|
+
* with an `every` interval whose goal marker is still shown (a missing marker
|
|
2180
|
+
* is the full re-arm, handled by reasonToArm), idle for that long, with an
|
|
2181
|
+
* EMPTY input line — text there is somebody's, same rule as pointer-guard.
|
|
2182
|
+
* The caller has already ruled out a busy session.
|
|
2183
|
+
*/
|
|
2184
|
+
export function nudgeDue(m, content, now) {
|
|
2185
|
+
if (!m.nudgeEveryMin || m.paused || shiftOver(m, now))
|
|
2186
|
+
return false;
|
|
2187
|
+
if (!GOAL_ACTIVE.test(content))
|
|
2188
|
+
return false;
|
|
2189
|
+
if (promptUnsentText(content) !== null)
|
|
2190
|
+
return false;
|
|
2191
|
+
if (QUEUED_HINT_RE.test(content.split("\n").slice(-8).join("\n")))
|
|
2192
|
+
return false;
|
|
2193
|
+
const due = nextDueAt(m);
|
|
2194
|
+
return due !== null && now >= due;
|
|
2195
|
+
}
|
|
2196
|
+
export const NUDGE_TEXT = "Continue working toward your standing goal.";
|
|
2197
|
+
const hhmm = (t) => {
|
|
2198
|
+
const d = new Date(t);
|
|
2199
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
2200
|
+
};
|
|
2201
|
+
/** One block for every managed session: name, goal, until, next due, context, last handover, state. */
|
|
2202
|
+
export function statusLines(sessions, now, kOf = () => null) {
|
|
2203
|
+
if (sessions.length === 0)
|
|
2204
|
+
return ["no sessions are being managed"];
|
|
2205
|
+
return sessions.map((m) => {
|
|
2206
|
+
const goal = oneLine(m.objective);
|
|
2207
|
+
const due = nextDueAt(m);
|
|
2208
|
+
const k = kOf(m);
|
|
2209
|
+
const pct = k !== null ? Math.round((k / (m.contextWindowK ?? 1000)) * 100) : null;
|
|
2210
|
+
const state = m.paused ? "paused" : m.shift ? (m.shift.plain ? "supervised" : "shift") : "on";
|
|
2211
|
+
return [
|
|
2212
|
+
m.name,
|
|
2213
|
+
`goal: ${goal.length > 80 ? `${goal.slice(0, 79)}…` : goal}`,
|
|
2214
|
+
`until: ${m.shift ? hhmm(m.shift.until) : "-"}`,
|
|
2215
|
+
`next ${m.nudgeEveryMin ? "nudge" : "arm"}: ${due === null ? "-" : due <= now ? "due" : `in ${Math.round((due - now) / 60_000)}m`}`,
|
|
2216
|
+
`ctx: ${k !== null ? `${pct}% ${k}k` : "?"}`,
|
|
2217
|
+
`handover: ${m.handoverDoneAt ? hhmm(m.handoverDoneAt) : "-"}`,
|
|
2218
|
+
state,
|
|
2219
|
+
].join(" | ");
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
1895
2222
|
/**
|
|
1896
2223
|
* The standing objective for a shift, written once here instead of by hand
|
|
1897
2224
|
* every night. It says what to work on and what "done" means; how to work is
|
|
@@ -2011,12 +2338,23 @@ export function briefSlug(m) {
|
|
|
2011
2338
|
* The line typed at the session: `/goal` plus a pointer to the brief, plus as
|
|
2012
2339
|
* much of the objective's first words as fits. Never longer than `max`.
|
|
2013
2340
|
*/
|
|
2014
|
-
export function buildGoalLine(briefPath, objective, max = GOAL_LINE_MAX_CHARS) {
|
|
2015
|
-
const head = `/goal Follow ${briefPath}
|
|
2341
|
+
export function buildGoalLine(briefPath, objective, until = null, max = GOAL_LINE_MAX_CHARS) {
|
|
2342
|
+
const head = `/goal ${noDoneStatePrefix(until)} Follow ${briefPath}`;
|
|
2016
2343
|
const room = max - head.length - 2;
|
|
2017
2344
|
const words = oneLine(objective);
|
|
2018
2345
|
return room >= 20 && words ? `${head}: ${truncateAtWord(words, room)}` : head;
|
|
2019
2346
|
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Opens EVERY armed goal, so Claude Code's evaluator has no done-state to judge
|
|
2349
|
+
* met: a goal that names one ("until the fix is in build N") is cleared the
|
|
2350
|
+
* moment the session says it is. Only the operator ends a goal. Never dropped
|
|
2351
|
+
* to fit the line — the objective is shortened first.
|
|
2352
|
+
*/
|
|
2353
|
+
export function noDoneStatePrefix(until) {
|
|
2354
|
+
return until !== null
|
|
2355
|
+
? `Keep working until ${hhmm(until)}. There is no earlier done-state. Only the operator ends this goal.`
|
|
2356
|
+
: "Keep working. There is no done-state. Only the operator ends this goal.";
|
|
2357
|
+
}
|
|
2020
2358
|
/** Everything that does not fit on the typed line, as the file it points to. */
|
|
2021
2359
|
export function buildBrief(parts) {
|
|
2022
2360
|
const sections = [];
|
|
@@ -2060,12 +2398,12 @@ function goalText(m) {
|
|
|
2060
2398
|
objective: m.objective,
|
|
2061
2399
|
agentish: AG2_SPEC,
|
|
2062
2400
|
rulesSource: rules ? standingRulesSource() : "",
|
|
2063
|
-
shiftRules: m.shift && m.objective !== shift ? shift : "",
|
|
2401
|
+
shiftRules: m.shift && !m.shift.plain && m.objective !== shift ? shift : "",
|
|
2064
2402
|
hands,
|
|
2065
2403
|
pending: m.pending,
|
|
2066
2404
|
}), { mode: 0o600 });
|
|
2067
2405
|
chmodSync(path, 0o600);
|
|
2068
|
-
return { text: buildGoalLine(foldHome(path), m.objective) };
|
|
2406
|
+
return { text: buildGoalLine(foldHome(path), m.objective, m.shift?.until ?? null) };
|
|
2069
2407
|
}
|
|
2070
2408
|
/**
|
|
2071
2409
|
* Did it arm? Within a bounded wait the pane must show the goal marker with an
|
|
@@ -2079,7 +2417,7 @@ export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
|
|
|
2079
2417
|
let unsent = null;
|
|
2080
2418
|
for (let i = 0; i < waits; i++) {
|
|
2081
2419
|
await deps.sleep(waitMs);
|
|
2082
|
-
const pane = deps.readPane();
|
|
2420
|
+
const pane = await deps.readPane();
|
|
2083
2421
|
unsent = promptUnsentText(pane);
|
|
2084
2422
|
if (GOAL_ACTIVE.test(pane) && !unsent)
|
|
2085
2423
|
return true;
|
|
@@ -2088,6 +2426,15 @@ export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
|
|
|
2088
2426
|
deps.clearInput();
|
|
2089
2427
|
return false;
|
|
2090
2428
|
}
|
|
2429
|
+
/** readPane for async callers (tick, arm): the iTerm read does not block the event loop. */
|
|
2430
|
+
async function readPaneAsync(sessionId, opts = {}) {
|
|
2431
|
+
try {
|
|
2432
|
+
return (await readSessionContentAsync(sessionId, 60, opts))?.content ?? "";
|
|
2433
|
+
}
|
|
2434
|
+
catch {
|
|
2435
|
+
return "";
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2091
2438
|
function readPane(sessionId, opts = {}) {
|
|
2092
2439
|
try {
|
|
2093
2440
|
return readSessionContent(sessionId, 60, opts)?.content ?? "";
|
|
@@ -2099,6 +2446,59 @@ function readPane(sessionId, opts = {}) {
|
|
|
2099
2446
|
async function sleep(ms) {
|
|
2100
2447
|
return new Promise((r) => setTimeout(r, ms));
|
|
2101
2448
|
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Read the input line back after a paste, polling while it is still empty: under
|
|
2451
|
+
* load the terminal has not rendered the paste after a fixed 300 ms. Returns as
|
|
2452
|
+
* soon as the line is non-empty (the caller decides match vs mangled), or the
|
|
2453
|
+
* empty line once the bound is spent. Never decides anything itself.
|
|
2454
|
+
*/
|
|
2455
|
+
export async function readBackTyped(deps) {
|
|
2456
|
+
const delays = deps.delaysMs ?? [300, 300, 500, 800, 1_000, 1_000, 1_100]; // ~5 s
|
|
2457
|
+
let waitedMs = 0;
|
|
2458
|
+
let line = "";
|
|
2459
|
+
for (const d of delays) {
|
|
2460
|
+
await deps.sleep(d);
|
|
2461
|
+
waitedMs += d;
|
|
2462
|
+
line = await deps.read();
|
|
2463
|
+
if (line)
|
|
2464
|
+
break;
|
|
2465
|
+
}
|
|
2466
|
+
return { line, waitedMs };
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* Wrap `run` so a call made while the previous one is still pending is skipped
|
|
2470
|
+
* (onSkip) instead of started. The tick yields to the event loop on every pane
|
|
2471
|
+
* read, so under load one outlasts TICK_MS and the next interval would arm the
|
|
2472
|
+
* same session a second time.
|
|
2473
|
+
*/
|
|
2474
|
+
export function nonOverlapping(run, onSkip) {
|
|
2475
|
+
let running = false;
|
|
2476
|
+
return async () => {
|
|
2477
|
+
if (running)
|
|
2478
|
+
return onSkip();
|
|
2479
|
+
running = true;
|
|
2480
|
+
try {
|
|
2481
|
+
await run();
|
|
2482
|
+
}
|
|
2483
|
+
finally {
|
|
2484
|
+
running = false;
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
/** Sessions with an arm() in flight; a second arm for the same id is refused. */
|
|
2489
|
+
const arming = new Set();
|
|
2490
|
+
/** Run fn unless `id` is already in `busy`; returns undefined when refused. */
|
|
2491
|
+
export async function tryExclusive(busy, id, fn) {
|
|
2492
|
+
if (busy.has(id))
|
|
2493
|
+
return undefined;
|
|
2494
|
+
busy.add(id);
|
|
2495
|
+
try {
|
|
2496
|
+
return await fn();
|
|
2497
|
+
}
|
|
2498
|
+
finally {
|
|
2499
|
+
busy.delete(id);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2102
2502
|
async function arm(m, reason) {
|
|
2103
2503
|
const { text } = goalText(m);
|
|
2104
2504
|
/**
|
|
@@ -2121,7 +2521,7 @@ async function arm(m, reason) {
|
|
|
2121
2521
|
* the thing we are managing is gone, and the right move is to say so and
|
|
2122
2522
|
* stop, not to keep typing into whatever is there now.
|
|
2123
2523
|
*/
|
|
2124
|
-
const live =
|
|
2524
|
+
const live = await readSessionContentAsync(m.sessionId, 5);
|
|
2125
2525
|
if (!live) {
|
|
2126
2526
|
note(m, "the session could not be read — not typing anything");
|
|
2127
2527
|
return false;
|
|
@@ -2150,7 +2550,7 @@ async function arm(m, reason) {
|
|
|
2150
2550
|
* a line occupied for minutes still reaches blockedReason's operator alert
|
|
2151
2551
|
* (with a screenshot) — the daemon does not stall silently either way.
|
|
2152
2552
|
*/
|
|
2153
|
-
const onLine = promptUnsentText(
|
|
2553
|
+
const onLine = promptUnsentText(await readPaneAsync(m.sessionId));
|
|
2154
2554
|
const armIdleMs = Math.max(0, Date.now() - m.lastChangeAt);
|
|
2155
2555
|
const armIdle = armIdleMs >= HANDOVER_IDLE_FLOOR_MS;
|
|
2156
2556
|
const prevLine = inputLineTracking.get(m.sessionId);
|
|
@@ -2186,24 +2586,28 @@ async function arm(m, reason) {
|
|
|
2186
2586
|
// (observed 2026-09-13), and arm() is otherwise fine typing into a
|
|
2187
2587
|
// busy session (the goal text below queues harmlessly behind it) —
|
|
2188
2588
|
// it is specifically the escape sequence that is not safe there.
|
|
2189
|
-
if (armIdle && needsVimEscape(
|
|
2589
|
+
if (armIdle && needsVimEscape(await readPaneAsync(m.sessionId))) {
|
|
2190
2590
|
escapeInputMode(m.sessionId);
|
|
2191
2591
|
}
|
|
2192
2592
|
if (!pasteTextIntoSession(m.sessionId, text)) {
|
|
2193
2593
|
note(m, `could not type into the session (${reason}) — will retry`);
|
|
2194
2594
|
return false;
|
|
2195
2595
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2596
|
+
const { line: echoedLine, waitedMs } = await readBackTyped({
|
|
2597
|
+
read: async () => promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "",
|
|
2598
|
+
sleep,
|
|
2599
|
+
});
|
|
2198
2600
|
if (!typedLineMatches(echoedLine, text)) {
|
|
2199
2601
|
sendControlU(m.sessionId);
|
|
2200
|
-
|
|
2602
|
+
await sleep(300);
|
|
2603
|
+
const left = promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "";
|
|
2604
|
+
note(m, `arm aborted: after ${waitedMs}ms line read back as "${echoedLine.slice(0, 60)}" not "${text.slice(0, 60)}"; after Ctrl-U the input ${left ? `still holds ${left.length} chars ("${left.slice(0, 60)}")` : "is empty"}`);
|
|
2201
2605
|
return false;
|
|
2202
2606
|
}
|
|
2203
2607
|
sendEnterKey(m.sessionId);
|
|
2204
2608
|
// Typed is not sent, and sent is not received.
|
|
2205
2609
|
const landed = await confirmArmed(text, {
|
|
2206
|
-
readPane: () =>
|
|
2610
|
+
readPane: () => readPaneAsync(m.sessionId, { fresh: true }),
|
|
2207
2611
|
sleep,
|
|
2208
2612
|
clearInput: () => sendControlU(m.sessionId),
|
|
2209
2613
|
});
|
|
@@ -2250,8 +2654,53 @@ export function blockedReason(m, content, now) {
|
|
|
2250
2654
|
}
|
|
2251
2655
|
return null;
|
|
2252
2656
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2657
|
+
const STOPPED_REASON = "turn ended with nothing running — re-arming";
|
|
2658
|
+
const STOP_SETTLE_MS = 30_000;
|
|
2659
|
+
const STOP_NUDGE_COOLDOWN_MS = 3 * 60_000;
|
|
2660
|
+
// Status-line "N shells still running" / "N background tasks still running" and
|
|
2661
|
+
// the footer's "▶N" worker count — N must be > 0. The footer's "← N agents" and
|
|
2662
|
+
// "· N shells" (no "still running") are always-on, not work, and must not match.
|
|
2663
|
+
const BACKGROUND_RUNNING_RE = /\b[1-9]\d*\s+(?:background\s+)?(?:shells?|tasks?)\s+still\s+running\b|▶\s*[1-9]\d*/i;
|
|
2664
|
+
const LIVE_REGION_LINES = 20;
|
|
2665
|
+
/**
|
|
2666
|
+
* The session's turn ended (last transcript record is an assistant turn with no
|
|
2667
|
+
* tool call pending), settled for STOP_SETTLE_MS, the prompt is empty and
|
|
2668
|
+
* nothing spins or runs in the background. Pure; never true over typed input.
|
|
2669
|
+
*/
|
|
2670
|
+
export function turnStoppedIdle(t, paneText, now) {
|
|
2671
|
+
if (!t || t.working !== false || t.lastAt === null || now - t.lastAt < STOP_SETTLE_MS)
|
|
2672
|
+
return false;
|
|
2673
|
+
if (promptUnsentText(paneText) !== null)
|
|
2674
|
+
return false;
|
|
2675
|
+
const last = paneText.split("\n").slice(-LIVE_REGION_LINES).join("\n");
|
|
2676
|
+
return !QUEUED_HINT_RE.test(last) && !SPINNER_RE.test(last) && !BACKGROUND_RUNNING_RE.test(last);
|
|
2677
|
+
}
|
|
2678
|
+
export const GOAL_GONE_REASON = "goal gone before until, re-armed";
|
|
2679
|
+
/** How long one queued arm is awaited before another may be typed. */
|
|
2680
|
+
export const QUEUED_ARM_WAIT_MS = 10 * 60_000;
|
|
2681
|
+
export const GOAL_GONE_BUSY_REASON = "goal gone before until, re-armed (session busy, queued)";
|
|
2682
|
+
/**
|
|
2683
|
+
* Goal gone while the session is mid-turn: Claude Code queues a submitted line
|
|
2684
|
+
* and runs it at the next turn boundary, so arming now interrupts nothing.
|
|
2685
|
+
* Same guards as the idle rule (not paused, until ahead, input line empty) plus
|
|
2686
|
+
* at most one pending arm. Clears the pending mark once the marker is back or
|
|
2687
|
+
* the turn is over.
|
|
2688
|
+
*/
|
|
2689
|
+
export function busyGoalGoneReason(m, content, now, busy) {
|
|
2690
|
+
if (!busy || GOAL_ACTIVE.test(content)) {
|
|
2691
|
+
delete m.queuedArmAt;
|
|
2692
|
+
return null;
|
|
2693
|
+
}
|
|
2694
|
+
if (m.queuedArmAt !== undefined && now - m.queuedArmAt < QUEUED_ARM_WAIT_MS)
|
|
2695
|
+
return null;
|
|
2696
|
+
return reasonToArm({ ...m, lastRearmAt: m.lastRearmAt || 1 }, content, now) === GOAL_GONE_REASON
|
|
2697
|
+
? GOAL_GONE_BUSY_REASON
|
|
2698
|
+
: null;
|
|
2699
|
+
}
|
|
2700
|
+
/** A shift/supervision whose `until` has passed: nothing proactive is typed into the session any more. */
|
|
2701
|
+
export const shiftOver = (m, now) => !!m.shift && now >= m.shift.until;
|
|
2702
|
+
export function reasonToArm(m, content, now) {
|
|
2703
|
+
if (m.paused || shiftOver(m, now))
|
|
2255
2704
|
return null;
|
|
2256
2705
|
if (OUT_OF_GOAL.some((re) => re.test(content)))
|
|
2257
2706
|
return "the session reported its goal could not be achieved";
|
|
@@ -2264,6 +2713,11 @@ function reasonToArm(m, content, now) {
|
|
|
2264
2713
|
const quietFor = Math.max(0, now - m.lastChangeAt);
|
|
2265
2714
|
const armedFor = Math.max(0, now - m.lastRearmAt);
|
|
2266
2715
|
const marker = GOAL_ACTIVE.test(content);
|
|
2716
|
+
// The goal was cleared (the session's own doing, or the evaluator's) while the
|
|
2717
|
+
// operator's stretch is still running: no lapse timer, re-arm on this tick.
|
|
2718
|
+
// Paused is the operator's; an occupied input line is never typed over.
|
|
2719
|
+
if (!marker && m.shift && m.shift.until > now && promptUnsentText(content) === null)
|
|
2720
|
+
return GOAL_GONE_REASON;
|
|
2267
2721
|
if (!marker && quietFor > NO_GOAL_GRACE_MS)
|
|
2268
2722
|
return `no goal armed (idle ${Math.round(quietFor / 1000)}s)`;
|
|
2269
2723
|
/**
|
|
@@ -2368,7 +2822,7 @@ async function tick() {
|
|
|
2368
2822
|
}
|
|
2369
2823
|
}
|
|
2370
2824
|
for (const m of Object.values(state)) {
|
|
2371
|
-
const content =
|
|
2825
|
+
const content = await readPaneAsync(m.sessionId);
|
|
2372
2826
|
if (!content) {
|
|
2373
2827
|
// A session that cannot be read is not necessarily gone; say so once per
|
|
2374
2828
|
// tick rather than dropping it, because dropping it silently is how a
|
|
@@ -2388,13 +2842,17 @@ async function tick() {
|
|
|
2388
2842
|
// it once per session per tick is the same reading at a fraction of the
|
|
2389
2843
|
// cost.
|
|
2390
2844
|
const tickTty = m.tty ?? snapshotTty(m.sessionId);
|
|
2391
|
-
const
|
|
2392
|
-
const
|
|
2393
|
-
const
|
|
2845
|
+
const tickProc = tickTty ? processReading(tickTty) : null;
|
|
2846
|
+
const tickPid = tickProc?.pid ?? null;
|
|
2847
|
+
const tickUuid = tickProc?.uuid ?? (tickTty ? discoverLiveSessions().find((s) => s.id === m.sessionId)?.attachUuid : undefined);
|
|
2848
|
+
const tickTranscript = tickPid ? transcriptReading(tickPid, tickUuid) : null;
|
|
2849
|
+
// A turn that ended with nothing running is a stop, not work: the 2-minute
|
|
2850
|
+
// "recent transcript" window must not hide it.
|
|
2851
|
+
const stopped = turnStoppedIdle(tickTranscript, content, now);
|
|
2852
|
+
const working = !stopped && sessionIsWorking(tickTranscript, now);
|
|
2394
2853
|
const tickUsed = tickTranscript?.contextK ?? null;
|
|
2395
|
-
const
|
|
2396
|
-
const
|
|
2397
|
-
const tickDir = tickDirCandidate && existsSync(tickDirCandidate) ? tickDirCandidate : null;
|
|
2854
|
+
const tickDir = tickPid ? projectTranscriptDir(tickPid, tickUuid) : null;
|
|
2855
|
+
const tickCwd = tickPid ? sessionProjectCwd(tickPid, tickUuid) : null;
|
|
2398
2856
|
// Sample context for the fill-rate reading in `status`. Pushed only when
|
|
2399
2857
|
// a real number is available — never a synthetic 0 for a tick where the
|
|
2400
2858
|
// transcript could not be read, which would read as a session that
|
|
@@ -2812,10 +3270,12 @@ async function tick() {
|
|
|
2812
3270
|
const grownBy = used !== null && m.handoverDoneK !== undefined ? used - m.handoverDoneK : null;
|
|
2813
3271
|
if (decision.due) {
|
|
2814
3272
|
const notesDirExists = tickCwd ? existsSync(join(tickCwd, "Notes")) : false;
|
|
2815
|
-
const
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
:
|
|
3273
|
+
const target = pickHandoverTarget({
|
|
3274
|
+
handoverFile: m.handoverFile,
|
|
3275
|
+
sinkDescription: projectSinkDescription(tickCwd),
|
|
3276
|
+
defaultPath: defaultHandoverTarget(tickCwd, notesDirExists),
|
|
3277
|
+
});
|
|
3278
|
+
const askedPath = target.path;
|
|
2819
3279
|
m.handoverAskedAt = now;
|
|
2820
3280
|
m.handoverAskedPath = askedPath;
|
|
2821
3281
|
m.handoverWas = askedPath ? fileFingerprint(askedPath) : undefined;
|
|
@@ -2828,7 +3288,9 @@ async function tick() {
|
|
|
2828
3288
|
: "";
|
|
2829
3289
|
const whereClause = askedPath
|
|
2830
3290
|
? `Update ${askedPath}. ${carry}`
|
|
2831
|
-
:
|
|
3291
|
+
: target.description
|
|
3292
|
+
? `Update ${target.description}. `
|
|
3293
|
+
: `Write it to your project's handover file (its ## Continue section, or wherever this project's convention keeps that) — nothing was set or found automatically, so use your own judgement about where that lives here. `;
|
|
2832
3294
|
// Wording follows the band: IMMEDIATE has no time to spare and says
|
|
2833
3295
|
// so; REFRESH and a work-triggered WARM-UP both mean the session has
|
|
2834
3296
|
// already written one and this is about the work SINCE, not a repeat
|
|
@@ -2850,7 +3312,7 @@ async function tick() {
|
|
|
2850
3312
|
notify(m, `at ${used}k tokens (${decision.reason})` +
|
|
2851
3313
|
(grownBy !== null ? `, ${grownBy}k of new work since the last one` : "") +
|
|
2852
3314
|
` — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}` +
|
|
2853
|
-
(
|
|
3315
|
+
(target.source !== "none" ? ` — target (${target.source}): ${askedPath ?? target.description}` : " — no target resolved, asked without naming a path"));
|
|
2854
3316
|
// The idle age that justified typing into this session now — the
|
|
2855
3317
|
// un-gated ask reaches every managed session, not just the one
|
|
2856
3318
|
// opted-in session it used to, so the evidence for "this was a safe
|
|
@@ -2887,6 +3349,19 @@ async function tick() {
|
|
|
2887
3349
|
* from when it would otherwise fire — it never suppresses one, since a
|
|
2888
3350
|
* genuinely stuck session goes quiet on the transcript too.
|
|
2889
3351
|
*/
|
|
3352
|
+
// Goal gone mid-turn: queue the arm behind the turn instead of waiting for an idle tick.
|
|
3353
|
+
const queuedReason = busyGoalGoneReason(m, content, now, working && !paneLooksIdle(content, now - m.lastChangeAt));
|
|
3354
|
+
if (queuedReason && now - m.lastRearmAt >= REARM_COOLDOWN_MS) {
|
|
3355
|
+
// Pending whether or not the read-back saw it land: a queued line is invisible until the turn ends.
|
|
3356
|
+
m.queuedArmAt = now;
|
|
3357
|
+
const queued = await tryExclusive(arming, m.sessionId, () => arm(m, queuedReason));
|
|
3358
|
+
if (queued === undefined)
|
|
3359
|
+
delete m.queuedArmAt;
|
|
3360
|
+
else if (!queued)
|
|
3361
|
+
note(m, `${queuedReason} — not confirmed, waiting for the turn to end or the marker`);
|
|
3362
|
+
dirty = true;
|
|
3363
|
+
continue;
|
|
3364
|
+
}
|
|
2890
3365
|
if (!m.paused && lease) {
|
|
2891
3366
|
if (working) {
|
|
2892
3367
|
const hadState = m.blockedSince !== undefined || m.blockedAlertedAt !== undefined || m.stuckSince !== undefined;
|
|
@@ -2953,9 +3428,34 @@ async function tick() {
|
|
|
2953
3428
|
continue;
|
|
2954
3429
|
if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
|
|
2955
3430
|
continue;
|
|
2956
|
-
|
|
2957
|
-
|
|
3431
|
+
let reason = reasonToArm(m, content, now);
|
|
3432
|
+
const over = shiftOver(m, now);
|
|
3433
|
+
if (over && m.shift && !m.shift.lapseNoted) {
|
|
3434
|
+
m.shift.lapseNoted = true;
|
|
3435
|
+
note(m, "until passed — no more nudges/arms");
|
|
3436
|
+
dirty = true;
|
|
3437
|
+
}
|
|
3438
|
+
if (stopped && !m.paused && !over && (!reason || reason.startsWith("no goal armed")) && !GOAL_ACTIVE.test(content)) {
|
|
3439
|
+
reason = STOPPED_REASON;
|
|
3440
|
+
}
|
|
3441
|
+
if (!reason && stopped && !m.paused && !over && now - (m.lastNudgeAt ?? 0) >= STOP_NUDGE_COOLDOWN_MS) {
|
|
3442
|
+
// Marker still up but the turn ended on nothing: nudge now, not at the lapse timer.
|
|
3443
|
+
typeIntoSession(m.sessionId, NUDGE_TEXT);
|
|
3444
|
+
m.lastNudgeAt = now;
|
|
3445
|
+
note(m, `${STOPPED_REASON.replace("re-arming", "nudged to continue")}`);
|
|
3446
|
+
dirty = true;
|
|
3447
|
+
continue;
|
|
3448
|
+
}
|
|
3449
|
+
if (!reason) {
|
|
3450
|
+
// Idle at an empty prompt past the `every` interval: a short nudge, not the full goal.
|
|
3451
|
+
if (nudgeDue(m, content, now)) {
|
|
3452
|
+
typeIntoSession(m.sessionId, NUDGE_TEXT);
|
|
3453
|
+
m.lastNudgeAt = now;
|
|
3454
|
+
note(m, `idle ${m.nudgeEveryMin}+ min at an empty prompt — nudged to continue`);
|
|
3455
|
+
dirty = true;
|
|
3456
|
+
}
|
|
2958
3457
|
continue;
|
|
3458
|
+
}
|
|
2959
3459
|
/**
|
|
2960
3460
|
* A GOAL THAT CANNOT BE CONFIRMED IS NOT RETRIED FOREVER.
|
|
2961
3461
|
*
|
|
@@ -2973,7 +3473,9 @@ async function tick() {
|
|
|
2973
3473
|
* lapse arms it — and the operator is told, because a manager that has
|
|
2974
3474
|
* given up on delivering a goal is the one thing it must never do quietly.
|
|
2975
3475
|
*/
|
|
2976
|
-
const armed = await arm(m, reason);
|
|
3476
|
+
const armed = await tryExclusive(arming, m.sessionId, () => arm(m, reason));
|
|
3477
|
+
if (armed === undefined)
|
|
3478
|
+
continue; // another arm for this session is in flight
|
|
2977
3479
|
dirty = true;
|
|
2978
3480
|
if (armed) {
|
|
2979
3481
|
m.armFails = 0;
|
|
@@ -3012,8 +3514,17 @@ export function startManagerLoop() {
|
|
|
3012
3514
|
const n = Object.keys(state).length;
|
|
3013
3515
|
if (n)
|
|
3014
3516
|
log(`[manage] resuming ${n} managed session${n > 1 ? "s" : ""}`);
|
|
3517
|
+
let skipLogged = false;
|
|
3518
|
+
const guarded = nonOverlapping(tick, () => {
|
|
3519
|
+
if (skipLogged)
|
|
3520
|
+
return;
|
|
3521
|
+
skipLogged = true;
|
|
3522
|
+
log("[manage] tick still running — skipping overlapping tick");
|
|
3523
|
+
});
|
|
3015
3524
|
timer = setInterval(() => {
|
|
3016
|
-
void
|
|
3525
|
+
void guarded()
|
|
3526
|
+
.then(() => { skipLogged = false; })
|
|
3527
|
+
.catch((e) => log(`[manage] tick failed — ${e.message}`));
|
|
3017
3528
|
}, TICK_MS);
|
|
3018
3529
|
timer.unref?.();
|
|
3019
3530
|
}
|
|
@@ -3044,8 +3555,13 @@ export function stopManaging(s, sessionId, name) {
|
|
|
3044
3555
|
* `/manage pause|resume` stop arming without forgetting the objective
|
|
3045
3556
|
* `/manage now` arm immediately, whatever the signals say
|
|
3046
3557
|
*/
|
|
3047
|
-
export async function handleManage(sessionIdOrName, rawArg) {
|
|
3558
|
+
export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
|
|
3048
3559
|
const arg = (rawArg ?? "").trim();
|
|
3560
|
+
// Every managed session in one block — before any peer forwarding or
|
|
3561
|
+
// single-session resolution, since "all" names no session.
|
|
3562
|
+
if (sessionIdOrName.trim().toLowerCase() === "all" && (!arg || arg.toLowerCase() === "status")) {
|
|
3563
|
+
return { ok: true, message: statusLines(Object.values(state), Date.now(), contextK).join("\n") };
|
|
3564
|
+
}
|
|
3049
3565
|
/**
|
|
3050
3566
|
* `machine/session` is managed by that machine's own hub.
|
|
3051
3567
|
*
|
|
@@ -3069,6 +3585,36 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3069
3585
|
if (!resolved)
|
|
3070
3586
|
return { ok: false, message: `no live session matches "${sessionIdOrName}"` };
|
|
3071
3587
|
const { sessionId, name } = resolved;
|
|
3588
|
+
/**
|
|
3589
|
+
* supervise — a session puts itself under the manager in one call:
|
|
3590
|
+
* {goal, until, every}. goal = standing objective, until = end of the
|
|
3591
|
+
* stretch (clock time or duration), every = idle-nudge interval in minutes.
|
|
3592
|
+
*/
|
|
3593
|
+
if (arg.toLowerCase() === "supervise") {
|
|
3594
|
+
const until = opts.until ? parseUntilSpec(opts.until) : null;
|
|
3595
|
+
const every = opts.every !== undefined && opts.every !== "" ? parseEveryMin(opts.every) : null;
|
|
3596
|
+
if (opts.until && until === null)
|
|
3597
|
+
return { ok: false, message: `cannot read until="${opts.until}" — use a clock time (08:00) or a duration (8h, 90m)` };
|
|
3598
|
+
if (opts.every !== undefined && opts.every !== "" && every === null)
|
|
3599
|
+
return { ok: false, message: `every="${opts.every}" must be ${NUDGE_EVERY_MIN}-${NUDGE_EVERY_MAX} minutes` };
|
|
3600
|
+
const goal = (opts.goal ?? "").trim();
|
|
3601
|
+
if (!state[sessionId]) {
|
|
3602
|
+
if (!goal)
|
|
3603
|
+
return { ok: false, message: `${name} is not being managed — supervise needs a goal` };
|
|
3604
|
+
const started = await handleManage(sessionId, `start ${goal}`);
|
|
3605
|
+
if (!started.ok)
|
|
3606
|
+
return started;
|
|
3607
|
+
}
|
|
3608
|
+
const m = state[sessionId];
|
|
3609
|
+
applySupervise(m, goal, until, every);
|
|
3610
|
+
saveState(state);
|
|
3611
|
+
startManagerLoop();
|
|
3612
|
+
return {
|
|
3613
|
+
ok: true,
|
|
3614
|
+
managed: true,
|
|
3615
|
+
message: `${name} is supervised${until !== null ? ` until ${hhmm(until)}` : ""}${m.nudgeEveryMin ? `, nudged after ${m.nudgeEveryMin} min idle` : ""}.\n ${statusLines([m], Date.now(), contextK)[0]}`,
|
|
3616
|
+
};
|
|
3617
|
+
}
|
|
3072
3618
|
const existing = state[sessionId];
|
|
3073
3619
|
const word = arg.toLowerCase();
|
|
3074
3620
|
// help — the grammar, from the thing that implements it.
|
|
@@ -3546,7 +4092,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3546
4092
|
}
|
|
3547
4093
|
/**
|
|
3548
4094
|
* handover-at <k> — this session's own warm-up margin, overriding the
|
|
3549
|
-
* default
|
|
4095
|
+
* default 50k.
|
|
3550
4096
|
*
|
|
3551
4097
|
* A fixed FRACTION of a fixed window cannot be right for every session —
|
|
3552
4098
|
* that was tried (0.82 of a 1000k window) and the ground moved under it:
|