aibroker 0.61.9 → 0.62.4
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 +265 -5
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +837 -118
- 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 +15 -8
- 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);
|
|
@@ -1643,8 +1822,8 @@ export function oneLine(text) {
|
|
|
1643
1822
|
function landsWhen(m) {
|
|
1644
1823
|
if (m.paused)
|
|
1645
1824
|
return " The manager is paused — nothing is armed until you resume it.";
|
|
1646
|
-
return (" This
|
|
1647
|
-
"
|
|
1825
|
+
return (" This arms on the next tick — every ask is now, and a line typed at a\n" +
|
|
1826
|
+
" session mid-turn queues at the turn boundary and interrupts nothing.");
|
|
1648
1827
|
}
|
|
1649
1828
|
/**
|
|
1650
1829
|
* Standing rules: how to work, written once, typed at every arming.
|
|
@@ -1892,6 +2071,228 @@ 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
|
+
/**
|
|
2095
|
+
* Change the standing objective on a managed entry.
|
|
2096
|
+
*
|
|
2097
|
+
* EVERY ask for arming is now (2026-10-17 14:48), and a changed objective is
|
|
2098
|
+
* an ask: the new text must reach the session, not whenever the lapse timer
|
|
2099
|
+
* next gets around to it, but on the very next tick. `0` is the sentinel the
|
|
2100
|
+
* arm gates read as "arm now" — idle or queued behind a running turn, which
|
|
2101
|
+
* is safe because a queued line lands at the turn boundary and interrupts
|
|
2102
|
+
* nothing.
|
|
2103
|
+
*/
|
|
2104
|
+
export function setObjective(m, objective) {
|
|
2105
|
+
m.objective = objective;
|
|
2106
|
+
m.lastRearmAt = 0;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Queue an operator instruction, and demand the arming that delivers it.
|
|
2110
|
+
*
|
|
2111
|
+
* The queue only goes out with an arming, so an instruction that did not
|
|
2112
|
+
* demand one waited for the lapse timer — an arming that had nothing to do
|
|
2113
|
+
* with what the operator just said, and one they may wait the better part of
|
|
2114
|
+
* an hour for. Every ask is now: the instruction rides out on the next tick.
|
|
2115
|
+
*/
|
|
2116
|
+
export function queueInstruction(m, instruction) {
|
|
2117
|
+
m.pending.push(instruction);
|
|
2118
|
+
m.lastRearmAt = 0;
|
|
2119
|
+
}
|
|
2120
|
+
// ── only the operator ends or shortens a goal ────────────────────────────────
|
|
2121
|
+
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)';
|
|
2122
|
+
const TOKEN_TTL_MS = 5 * 60_000;
|
|
2123
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2124
|
+
const tokenDir = () => join(homedir(), ".aibroker", "operator-tokens");
|
|
2125
|
+
/** Written by hooks/operator-manage-token.mjs from the operator's own typed prompt. */
|
|
2126
|
+
export function writeOperatorToken(session, action, now = Date.now()) {
|
|
2127
|
+
mkdirSync(tokenDir(), { recursive: true, mode: 0o700 });
|
|
2128
|
+
writeFileSync(join(tokenDir(), `${slug(session)}-${action}.json`), JSON.stringify({ expiresAt: now + TOKEN_TTL_MS }), { mode: 0o600 });
|
|
2129
|
+
}
|
|
2130
|
+
/** One-shot: the file is removed whether or not it was still valid. */
|
|
2131
|
+
function consumeToken(sessions, action, now) {
|
|
2132
|
+
let ok = false;
|
|
2133
|
+
for (const s of new Set(sessions.map(slug).filter(Boolean))) {
|
|
2134
|
+
const file = join(tokenDir(), `${s}-${action}.json`);
|
|
2135
|
+
try {
|
|
2136
|
+
ok = JSON.parse(readFileSync(file, "utf8")).expiresAt > now || ok;
|
|
2137
|
+
unlinkSync(file);
|
|
2138
|
+
}
|
|
2139
|
+
catch { /* no token under this name */ }
|
|
2140
|
+
}
|
|
2141
|
+
return ok;
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Agents cannot end or shorten a goal. Returns the refusal, or null when the
|
|
2145
|
+
* call may go ahead. `operator` is set only by the CLI (outside any agent
|
|
2146
|
+
* environment) and by the prompt hook; otherwise a one-shot token written from
|
|
2147
|
+
* the operator's typed prompt is required.
|
|
2148
|
+
* ponytail: a process that writes the daemon socket directly can still claim
|
|
2149
|
+
* `operator`; the socket is local-user trust, tighten with a peer credential.
|
|
2150
|
+
*/
|
|
2151
|
+
export function refuseAgentManage(sessionIdOrName, rawArg, opts, operator, now = Date.now()) {
|
|
2152
|
+
if (operator)
|
|
2153
|
+
return null;
|
|
2154
|
+
const arg = rawArg.trim().toLowerCase();
|
|
2155
|
+
const resolved = resolveSession(sessionIdOrName);
|
|
2156
|
+
const cur = resolved ? state[resolved.sessionId]?.shift?.until : undefined;
|
|
2157
|
+
const names = [sessionIdOrName, resolved?.name ?? ""];
|
|
2158
|
+
const ending = /^(off|stop)$/.test(arg) ? "off" :
|
|
2159
|
+
arg === "pause" ? "pause" :
|
|
2160
|
+
/^shift\s+(off|stop|end|stand down)\b/.test(arg) ? "shift-off" : null;
|
|
2161
|
+
if (ending)
|
|
2162
|
+
return consumeToken(names, ending, now) ? null : AGENT_REFUSAL;
|
|
2163
|
+
const shift = arg.match(/^shift\b\s*([\s\S]*)$/);
|
|
2164
|
+
const next = shift ? now + parseShift(shift[1], new Date(now)).hours * 3_600_000 : opts.until ? parseUntilSpec(opts.until, new Date(now)) : null;
|
|
2165
|
+
// Minute precision: the same clock time is not a shortening because of float/ms drift.
|
|
2166
|
+
const minute = (t) => Math.floor(t / 60_000);
|
|
2167
|
+
return next !== null && cur !== undefined && cur > now && minute(next) < minute(cur) ? AGENT_REFUSAL : null;
|
|
2168
|
+
}
|
|
2169
|
+
/** "08:00", "until 8pm", "8h", "90m" → epoch ms; null when it reads as none of these. */
|
|
2170
|
+
export function parseUntilSpec(spec, now = new Date()) {
|
|
2171
|
+
const t = spec.trim().toLowerCase();
|
|
2172
|
+
if (/^\d{1,2}(:\d{2})?\s*(am|pm)?$/.test(t))
|
|
2173
|
+
return parseUntilClock(`until ${t}`, now);
|
|
2174
|
+
if (/^(until|till|til)\s/.test(t))
|
|
2175
|
+
return parseUntilClock(t, now);
|
|
2176
|
+
const d = t.match(/^(?:for\s+)?(\d+(?:[.,]\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)$/);
|
|
2177
|
+
if (!d)
|
|
2178
|
+
return null;
|
|
2179
|
+
const n = Number(d[1].replace(",", "."));
|
|
2180
|
+
const ms = n * (/^h/.test(d[2]) ? 3_600_000 : 60_000);
|
|
2181
|
+
return now.getTime() + Math.min(ms, SHIFT_MAX_HOURS * 3_600_000);
|
|
2182
|
+
}
|
|
2183
|
+
const NUDGE_EVERY_MIN = 5;
|
|
2184
|
+
const NUDGE_EVERY_MAX = 480;
|
|
2185
|
+
/** Minutes between idle nudges: 30, "30", "30m", "1h". Null outside 5..480. */
|
|
2186
|
+
export function parseEveryMin(v) {
|
|
2187
|
+
const t = String(v ?? "").trim().toLowerCase();
|
|
2188
|
+
const m = t.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)?$/);
|
|
2189
|
+
if (!m)
|
|
2190
|
+
return null;
|
|
2191
|
+
const n = Math.round(Number(m[1]) * (m[2] && m[2].startsWith("h") ? 60 : 1));
|
|
2192
|
+
return n >= NUDGE_EVERY_MIN && n <= NUDGE_EVERY_MAX ? n : null;
|
|
2193
|
+
}
|
|
2194
|
+
/** When the next nudge (with `every`) or the ~46-50 min lapse re-arm falls due. */
|
|
2195
|
+
export function nextDueAt(m) {
|
|
2196
|
+
if (m.paused)
|
|
2197
|
+
return null;
|
|
2198
|
+
if (m.nudgeEveryMin) {
|
|
2199
|
+
return Math.max(m.lastChangeAt, m.lastNudgeAt ?? 0, m.lastRearmAt) + m.nudgeEveryMin * 60_000;
|
|
2200
|
+
}
|
|
2201
|
+
return m.lastRearmAt + GOAL_MAX_AGE_MS;
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Should a short "continue the goal" line be typed now? Only for a session
|
|
2205
|
+
* with an `every` interval whose goal marker is still shown (a missing marker
|
|
2206
|
+
* is the full re-arm, handled by reasonToArm), idle for that long, with an
|
|
2207
|
+
* EMPTY input line — text there is somebody's, same rule as pointer-guard.
|
|
2208
|
+
* The caller has already ruled out a busy session.
|
|
2209
|
+
*/
|
|
2210
|
+
export function nudgeDue(m, content, now) {
|
|
2211
|
+
if (!m.nudgeEveryMin || m.paused || shiftOver(m, now))
|
|
2212
|
+
return false;
|
|
2213
|
+
if (!GOAL_ACTIVE.test(content))
|
|
2214
|
+
return false;
|
|
2215
|
+
if (promptUnsentText(content) !== null)
|
|
2216
|
+
return false;
|
|
2217
|
+
if (QUEUED_HINT_RE.test(content.split("\n").slice(-8).join("\n")))
|
|
2218
|
+
return false;
|
|
2219
|
+
const due = nextDueAt(m);
|
|
2220
|
+
return due !== null && now >= due;
|
|
2221
|
+
}
|
|
2222
|
+
export const NUDGE_TEXT = "Continue working toward your standing goal.";
|
|
2223
|
+
/**
|
|
2224
|
+
* The API-retry banner, seen live 2026-10-17 14:50:
|
|
2225
|
+
*
|
|
2226
|
+
* Goal still active · the API was unavailable or the connection dropped ·
|
|
2227
|
+
* retrying in 1 min (1/3) · send a message to retry now
|
|
2228
|
+
*
|
|
2229
|
+
* Claude Code retries three times, then gives up — and the goal sits frozen
|
|
2230
|
+
* over a session that is not working but sleeping between retries. The banner
|
|
2231
|
+
* itself offers the way out — any message retries now — so the manager takes
|
|
2232
|
+
* it: one minimal continue per minute, for as long as the banner lasts,
|
|
2233
|
+
* deterministically and with no cap, because a stretch of API trouble can
|
|
2234
|
+
* outlast any count.
|
|
2235
|
+
*/
|
|
2236
|
+
export const API_RETRY = /the API was unavailable or the connection dropped|retrying in \d+ min \(\d+\/3\)/;
|
|
2237
|
+
/** At most one retry-breaking message per minute while the banner persists. */
|
|
2238
|
+
export const API_RETRY_EVERY_MS = 60_000;
|
|
2239
|
+
/** The history note about the banner dedups; the nudge itself is 1/60s, not the note. */
|
|
2240
|
+
const API_RETRY_NOTE_EVERY_MS = 5 * 60_000;
|
|
2241
|
+
export const API_RETRY_TEXT = "Continue now — the API is back. Pick the turn up where it dropped.";
|
|
2242
|
+
/**
|
|
2243
|
+
* Type the retry-breaking continue when this pane shows the banner and a
|
|
2244
|
+
* minute has passed since the last one. Never over text sitting unsent on the
|
|
2245
|
+
* input line (skip silently; the next tick tries again), never while paused,
|
|
2246
|
+
* never past a shift's end — the banner is not a reason to restart a
|
|
2247
|
+
* stood-down session. Returns true when a line went out.
|
|
2248
|
+
*
|
|
2249
|
+
* The tick calls it before the busy gate: the retry wait IS the busy state
|
|
2250
|
+
* here, and typing mid-turn is safe because the line queues at the turn
|
|
2251
|
+
* boundary. `type` is injectable so the caller's exact behavior is testable
|
|
2252
|
+
* without a terminal; the tick passes nothing and gets the real one.
|
|
2253
|
+
*/
|
|
2254
|
+
export function apiRetryNudge(m, content, now, type = typeIntoSession) {
|
|
2255
|
+
if (m.paused || shiftOver(m, now))
|
|
2256
|
+
return false;
|
|
2257
|
+
if (!API_RETRY.test(content))
|
|
2258
|
+
return false;
|
|
2259
|
+
if (promptUnsentText(content) !== null)
|
|
2260
|
+
return false;
|
|
2261
|
+
if (now - (m.lastApiRetryAt ?? 0) < API_RETRY_EVERY_MS)
|
|
2262
|
+
return false;
|
|
2263
|
+
type(m.sessionId, API_RETRY_TEXT);
|
|
2264
|
+
m.lastApiRetryAt = now;
|
|
2265
|
+
if (!m.apiRetryNotedAt || now - m.apiRetryNotedAt >= API_RETRY_NOTE_EVERY_MS) {
|
|
2266
|
+
note(m, "API-retry banner on the pane — typed a continue to break the retry wait (1/min while it lasts)");
|
|
2267
|
+
m.apiRetryNotedAt = now;
|
|
2268
|
+
}
|
|
2269
|
+
return true;
|
|
2270
|
+
}
|
|
2271
|
+
const hhmm = (t) => {
|
|
2272
|
+
const d = new Date(t);
|
|
2273
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
2274
|
+
};
|
|
2275
|
+
/** One block for every managed session: name, goal, until, next due, context, last handover, state. */
|
|
2276
|
+
export function statusLines(sessions, now, kOf = () => null) {
|
|
2277
|
+
if (sessions.length === 0)
|
|
2278
|
+
return ["no sessions are being managed"];
|
|
2279
|
+
return sessions.map((m) => {
|
|
2280
|
+
const goal = oneLine(m.objective);
|
|
2281
|
+
const due = nextDueAt(m);
|
|
2282
|
+
const k = kOf(m);
|
|
2283
|
+
const pct = k !== null ? Math.round((k / (m.contextWindowK ?? 1000)) * 100) : null;
|
|
2284
|
+
const state = m.paused ? "paused" : m.shift ? (m.shift.plain ? "supervised" : "shift") : "on";
|
|
2285
|
+
return [
|
|
2286
|
+
m.name,
|
|
2287
|
+
`goal: ${goal.length > 80 ? `${goal.slice(0, 79)}…` : goal}`,
|
|
2288
|
+
`until: ${m.shift ? hhmm(m.shift.until) : "-"}`,
|
|
2289
|
+
`next ${m.nudgeEveryMin ? "nudge" : "arm"}: ${due === null ? "-" : due <= now ? "due" : `in ${Math.round((due - now) / 60_000)}m`}`,
|
|
2290
|
+
`ctx: ${k !== null ? `${pct}% ${k}k` : "?"}`,
|
|
2291
|
+
`handover: ${m.handoverDoneAt ? hhmm(m.handoverDoneAt) : "-"}`,
|
|
2292
|
+
state,
|
|
2293
|
+
].join(" | ");
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
1895
2296
|
/**
|
|
1896
2297
|
* The standing objective for a shift, written once here instead of by hand
|
|
1897
2298
|
* every night. It says what to work on and what "done" means; how to work is
|
|
@@ -2011,12 +2412,23 @@ export function briefSlug(m) {
|
|
|
2011
2412
|
* The line typed at the session: `/goal` plus a pointer to the brief, plus as
|
|
2012
2413
|
* much of the objective's first words as fits. Never longer than `max`.
|
|
2013
2414
|
*/
|
|
2014
|
-
export function buildGoalLine(briefPath, objective, max = GOAL_LINE_MAX_CHARS) {
|
|
2015
|
-
const head = `/goal Follow ${briefPath}
|
|
2415
|
+
export function buildGoalLine(briefPath, objective, until = null, max = GOAL_LINE_MAX_CHARS) {
|
|
2416
|
+
const head = `/goal ${noDoneStatePrefix(until)} Follow ${briefPath}`;
|
|
2016
2417
|
const room = max - head.length - 2;
|
|
2017
2418
|
const words = oneLine(objective);
|
|
2018
2419
|
return room >= 20 && words ? `${head}: ${truncateAtWord(words, room)}` : head;
|
|
2019
2420
|
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Opens EVERY armed goal, so Claude Code's evaluator has no done-state to judge
|
|
2423
|
+
* met: a goal that names one ("until the fix is in build N") is cleared the
|
|
2424
|
+
* moment the session says it is. Only the operator ends a goal. Never dropped
|
|
2425
|
+
* to fit the line — the objective is shortened first.
|
|
2426
|
+
*/
|
|
2427
|
+
export function noDoneStatePrefix(until) {
|
|
2428
|
+
return until !== null
|
|
2429
|
+
? `Keep working until ${hhmm(until)}. There is no earlier done-state. Only the operator ends this goal.`
|
|
2430
|
+
: "Keep working. There is no done-state. Only the operator ends this goal.";
|
|
2431
|
+
}
|
|
2020
2432
|
/** Everything that does not fit on the typed line, as the file it points to. */
|
|
2021
2433
|
export function buildBrief(parts) {
|
|
2022
2434
|
const sections = [];
|
|
@@ -2060,12 +2472,12 @@ function goalText(m) {
|
|
|
2060
2472
|
objective: m.objective,
|
|
2061
2473
|
agentish: AG2_SPEC,
|
|
2062
2474
|
rulesSource: rules ? standingRulesSource() : "",
|
|
2063
|
-
shiftRules: m.shift && m.objective !== shift ? shift : "",
|
|
2475
|
+
shiftRules: m.shift && !m.shift.plain && m.objective !== shift ? shift : "",
|
|
2064
2476
|
hands,
|
|
2065
2477
|
pending: m.pending,
|
|
2066
2478
|
}), { mode: 0o600 });
|
|
2067
2479
|
chmodSync(path, 0o600);
|
|
2068
|
-
return { text: buildGoalLine(foldHome(path), m.objective) };
|
|
2480
|
+
return { text: buildGoalLine(foldHome(path), m.objective, m.shift?.until ?? null) };
|
|
2069
2481
|
}
|
|
2070
2482
|
/**
|
|
2071
2483
|
* Did it arm? Within a bounded wait the pane must show the goal marker with an
|
|
@@ -2079,7 +2491,7 @@ export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
|
|
|
2079
2491
|
let unsent = null;
|
|
2080
2492
|
for (let i = 0; i < waits; i++) {
|
|
2081
2493
|
await deps.sleep(waitMs);
|
|
2082
|
-
const pane = deps.readPane();
|
|
2494
|
+
const pane = await deps.readPane();
|
|
2083
2495
|
unsent = promptUnsentText(pane);
|
|
2084
2496
|
if (GOAL_ACTIVE.test(pane) && !unsent)
|
|
2085
2497
|
return true;
|
|
@@ -2088,6 +2500,15 @@ export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
|
|
|
2088
2500
|
deps.clearInput();
|
|
2089
2501
|
return false;
|
|
2090
2502
|
}
|
|
2503
|
+
/** readPane for async callers (tick, arm): the iTerm read does not block the event loop. */
|
|
2504
|
+
async function readPaneAsync(sessionId, opts = {}) {
|
|
2505
|
+
try {
|
|
2506
|
+
return (await readSessionContentAsync(sessionId, 60, opts))?.content ?? "";
|
|
2507
|
+
}
|
|
2508
|
+
catch {
|
|
2509
|
+
return "";
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2091
2512
|
function readPane(sessionId, opts = {}) {
|
|
2092
2513
|
try {
|
|
2093
2514
|
return readSessionContent(sessionId, 60, opts)?.content ?? "";
|
|
@@ -2099,7 +2520,60 @@ function readPane(sessionId, opts = {}) {
|
|
|
2099
2520
|
async function sleep(ms) {
|
|
2100
2521
|
return new Promise((r) => setTimeout(r, ms));
|
|
2101
2522
|
}
|
|
2102
|
-
|
|
2523
|
+
/**
|
|
2524
|
+
* Read the input line back after a paste, polling while it is still empty: under
|
|
2525
|
+
* load the terminal has not rendered the paste after a fixed 300 ms. Returns as
|
|
2526
|
+
* soon as the line is non-empty (the caller decides match vs mangled), or the
|
|
2527
|
+
* empty line once the bound is spent. Never decides anything itself.
|
|
2528
|
+
*/
|
|
2529
|
+
export async function readBackTyped(deps) {
|
|
2530
|
+
const delays = deps.delaysMs ?? [300, 300, 500, 800, 1_000, 1_000, 1_100]; // ~5 s
|
|
2531
|
+
let waitedMs = 0;
|
|
2532
|
+
let line = "";
|
|
2533
|
+
for (const d of delays) {
|
|
2534
|
+
await deps.sleep(d);
|
|
2535
|
+
waitedMs += d;
|
|
2536
|
+
line = await deps.read();
|
|
2537
|
+
if (line)
|
|
2538
|
+
break;
|
|
2539
|
+
}
|
|
2540
|
+
return { line, waitedMs };
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Wrap `run` so a call made while the previous one is still pending is skipped
|
|
2544
|
+
* (onSkip) instead of started. The tick yields to the event loop on every pane
|
|
2545
|
+
* read, so under load one outlasts TICK_MS and the next interval would arm the
|
|
2546
|
+
* same session a second time.
|
|
2547
|
+
*/
|
|
2548
|
+
export function nonOverlapping(run, onSkip) {
|
|
2549
|
+
let running = false;
|
|
2550
|
+
return async () => {
|
|
2551
|
+
if (running)
|
|
2552
|
+
return onSkip();
|
|
2553
|
+
running = true;
|
|
2554
|
+
try {
|
|
2555
|
+
await run();
|
|
2556
|
+
}
|
|
2557
|
+
finally {
|
|
2558
|
+
running = false;
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
/** Sessions with an arm() in flight; a second arm for the same id is refused. */
|
|
2563
|
+
const arming = new Set();
|
|
2564
|
+
/** Run fn unless `id` is already in `busy`; returns undefined when refused. */
|
|
2565
|
+
export async function tryExclusive(busy, id, fn) {
|
|
2566
|
+
if (busy.has(id))
|
|
2567
|
+
return undefined;
|
|
2568
|
+
busy.add(id);
|
|
2569
|
+
try {
|
|
2570
|
+
return await fn();
|
|
2571
|
+
}
|
|
2572
|
+
finally {
|
|
2573
|
+
busy.delete(id);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
export async function arm(m, reason) {
|
|
2103
2577
|
const { text } = goalText(m);
|
|
2104
2578
|
/**
|
|
2105
2579
|
* NEVER TYPE A GOAL INTO A BARE SHELL.
|
|
@@ -2121,15 +2595,15 @@ async function arm(m, reason) {
|
|
|
2121
2595
|
* the thing we are managing is gone, and the right move is to say so and
|
|
2122
2596
|
* stop, not to keep typing into whatever is there now.
|
|
2123
2597
|
*/
|
|
2124
|
-
const live =
|
|
2598
|
+
const live = await readSessionContentAsync(m.sessionId, 5);
|
|
2125
2599
|
if (!live) {
|
|
2126
2600
|
note(m, "the session could not be read — not typing anything");
|
|
2127
|
-
return
|
|
2601
|
+
return "failed";
|
|
2128
2602
|
}
|
|
2129
2603
|
if (live.atPrompt && !isClaudePane(live.name)) {
|
|
2130
2604
|
m.paused = true;
|
|
2131
2605
|
notify(m, "PAUSED — that pane is at a shell prompt, so the session has exited. Not typing a goal into a shell. `resume` once it is back.");
|
|
2132
|
-
return
|
|
2606
|
+
return "failed";
|
|
2133
2607
|
}
|
|
2134
2608
|
/**
|
|
2135
2609
|
* TEXT ON THE INPUT LINE — READ BACK FIRST, NEVER DESTROY LIVE INPUT.
|
|
@@ -2146,11 +2620,14 @@ async function arm(m, reason) {
|
|
|
2146
2620
|
* SKIPPED and retried next tick — never cleared, never typed over —
|
|
2147
2621
|
* unless the IDENTICAL text has sat there for GHOST_TICKS consecutive
|
|
2148
2622
|
* ticks (2 minutes) while the session is idle, which is inputLineDecision's
|
|
2149
|
-
* job to decide.
|
|
2150
|
-
*
|
|
2151
|
-
*
|
|
2623
|
+
* job to decide. A skip is NOT a failure: it counts toward nothing (on
|
|
2624
|
+
* 2026-10-17 14:42 the operator typing while a demanded arm waited was
|
|
2625
|
+
* counted as arm failures, crossed the cap, and the arm was never even
|
|
2626
|
+
* typed — see test/manage-arm-skip.test.ts). The skip says so in the log at
|
|
2627
|
+
* most once per 5 minutes; a DEMANDED arm held this way says so loudly, and
|
|
2628
|
+
* once.
|
|
2152
2629
|
*/
|
|
2153
|
-
const onLine = promptUnsentText(
|
|
2630
|
+
const onLine = promptUnsentText(await readPaneAsync(m.sessionId));
|
|
2154
2631
|
const armIdleMs = Math.max(0, Date.now() - m.lastChangeAt);
|
|
2155
2632
|
const armIdle = armIdleMs >= HANDOVER_IDLE_FLOOR_MS;
|
|
2156
2633
|
const prevLine = inputLineTracking.get(m.sessionId);
|
|
@@ -2161,8 +2638,23 @@ async function arm(m, reason) {
|
|
|
2161
2638
|
inputLineTracking.delete(m.sessionId);
|
|
2162
2639
|
const lineDecision = inputLineDecision({ text: onLine ?? "", sameForTicks, idle: armIdle, idleMs: armIdleMs });
|
|
2163
2640
|
if (lineDecision.action === "skip") {
|
|
2164
|
-
|
|
2165
|
-
|
|
2641
|
+
if (m.lastRearmAt !== 0) {
|
|
2642
|
+
delete m.forcedArmHoldNotedAt;
|
|
2643
|
+
if (!m.armSkipNotedAt || Date.now() - m.armSkipNotedAt >= 5 * 60_000) {
|
|
2644
|
+
note(m, `arm deferred: input line holds '${onLine}' (unsent for ${sameForTicks} tick${sameForTicks === 1 ? "" : "s"}, idle ${Math.round(armIdleMs / 1000)}s)`);
|
|
2645
|
+
m.armSkipNotedAt = Date.now();
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
else {
|
|
2649
|
+
// The operator demanded this arm; what is holding it is their own
|
|
2650
|
+
// unsent line. Say so, once, where it will be seen.
|
|
2651
|
+
if (!m.forcedArmHoldNotedAt) {
|
|
2652
|
+
notify(m, `a demanded arm (${reason}) is NOT being typed: the input line holds the operator's own unsent text ` +
|
|
2653
|
+
`("${(onLine ?? "").slice(0, 60)}") — clear or send that line to let the arm through; it is not typed over`);
|
|
2654
|
+
m.forcedArmHoldNotedAt = Date.now();
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
return "skipped";
|
|
2166
2658
|
}
|
|
2167
2659
|
if (lineDecision.action === "clear-then-type") {
|
|
2168
2660
|
note(m, lineDecision.logCleared ?? "clearing persistent input-line text before typing");
|
|
@@ -2186,37 +2678,97 @@ async function arm(m, reason) {
|
|
|
2186
2678
|
// (observed 2026-09-13), and arm() is otherwise fine typing into a
|
|
2187
2679
|
// busy session (the goal text below queues harmlessly behind it) —
|
|
2188
2680
|
// it is specifically the escape sequence that is not safe there.
|
|
2189
|
-
if (armIdle && needsVimEscape(
|
|
2681
|
+
if (armIdle && needsVimEscape(await readPaneAsync(m.sessionId))) {
|
|
2190
2682
|
escapeInputMode(m.sessionId);
|
|
2191
2683
|
}
|
|
2192
2684
|
if (!pasteTextIntoSession(m.sessionId, text)) {
|
|
2193
2685
|
note(m, `could not type into the session (${reason}) — will retry`);
|
|
2194
|
-
return
|
|
2686
|
+
return "failed";
|
|
2195
2687
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2688
|
+
const { line: echoedLine, waitedMs } = await readBackTyped({
|
|
2689
|
+
read: async () => promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "",
|
|
2690
|
+
sleep,
|
|
2691
|
+
});
|
|
2198
2692
|
if (!typedLineMatches(echoedLine, text)) {
|
|
2199
2693
|
sendControlU(m.sessionId);
|
|
2200
|
-
|
|
2201
|
-
|
|
2694
|
+
await sleep(300);
|
|
2695
|
+
const left = promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "";
|
|
2696
|
+
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"}`);
|
|
2697
|
+
return "failed";
|
|
2202
2698
|
}
|
|
2203
2699
|
sendEnterKey(m.sessionId);
|
|
2204
2700
|
// Typed is not sent, and sent is not received.
|
|
2205
2701
|
const landed = await confirmArmed(text, {
|
|
2206
|
-
readPane: () =>
|
|
2702
|
+
readPane: () => readPaneAsync(m.sessionId, { fresh: true }),
|
|
2207
2703
|
sleep,
|
|
2208
2704
|
clearInput: () => sendControlU(m.sessionId),
|
|
2209
2705
|
});
|
|
2210
2706
|
if (landed) {
|
|
2211
2707
|
m.lastRearmAt = Date.now();
|
|
2708
|
+
delete m.forcedArmHoldNotedAt;
|
|
2212
2709
|
const carried = m.pending.length;
|
|
2213
2710
|
m.pending = [];
|
|
2214
2711
|
armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
|
|
2215
2712
|
note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
|
|
2216
|
-
return
|
|
2713
|
+
return "armed";
|
|
2217
2714
|
}
|
|
2218
2715
|
notify(m, `typed but the goal never took (no active marker, or the line stayed in the input) — input cleared, treating as NOT armed (${reason})`);
|
|
2219
|
-
return
|
|
2716
|
+
return "failed";
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* The book-keeping one arm attempt's outcome earns, shared by every caller
|
|
2720
|
+
* that retries on the tick.
|
|
2721
|
+
*
|
|
2722
|
+
* ONLY a "failed" counts. A skip is not here because it is not a fault: the
|
|
2723
|
+
* guard that declined (the operator's own text on the input line) is doing
|
|
2724
|
+
* its job, and the demand — or the lapse — is still pending on the next tick.
|
|
2725
|
+
* Counting skips is the 2026-10-17 14:42 fault: three ticks of the operator
|
|
2726
|
+
* typing crossed this cap, the cap consumed the forced-arm sentinel, and the
|
|
2727
|
+
* manager went silent exactly when it had been told to act.
|
|
2728
|
+
*/
|
|
2729
|
+
export function applyArmResult(m, result, now) {
|
|
2730
|
+
if (result === "armed") {
|
|
2731
|
+
m.armFails = 0;
|
|
2732
|
+
m.armFailStreak = 0;
|
|
2733
|
+
delete m.stuckSince;
|
|
2734
|
+
delete m.blockedSince;
|
|
2735
|
+
delete m.blockedAlertedAt;
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
if (result === "skipped")
|
|
2739
|
+
return;
|
|
2740
|
+
m.armFails = (m.armFails ?? 0) + 1;
|
|
2741
|
+
m.armFailStreak = (m.armFailStreak ?? 0) + 1;
|
|
2742
|
+
if (m.armFails >= ARM_ATTEMPTS) {
|
|
2743
|
+
m.armFails = 0;
|
|
2744
|
+
// The cap must not eat the sentinel: `lastRearmAt === 0` is a pending
|
|
2745
|
+
// DEMANDED arm, and stamping it with `now` is what turned "arm NOW" into
|
|
2746
|
+
// a 90-second cooldown and then a permanent mute on 2026-10-17 14:42.
|
|
2747
|
+
// A pending forced arm survives the cap.
|
|
2748
|
+
if (m.lastRearmAt !== 0)
|
|
2749
|
+
m.lastRearmAt = now;
|
|
2750
|
+
m.stuckSince ??= now;
|
|
2751
|
+
// No operator push here. Pushing on every cap is the 144-alerts-overnight
|
|
2752
|
+
// bug: this fires again every ~2-4 minutes for as long as arming keeps
|
|
2753
|
+
// failing, and it is content-agnostic — it cannot tell "mid-turn, will
|
|
2754
|
+
// clear itself" from "stuck on a modal for hours". The blocked check
|
|
2755
|
+
// above owns the operator alert now: it has `armFailStreak`/`stuckSince`
|
|
2756
|
+
// (which survive this reset) plus a static-pane confirmation, and its
|
|
2757
|
+
// own BLOCKED_REALERT_MS dedup. This stays log-only.
|
|
2758
|
+
note(m, `arming keeps failing (streak ${m.armFailStreak}) — holding off; the blocked check owns the operator alert now`);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Whether the stuck backoff — arming having crossed ARM_ATTEMPTS — may hold
|
|
2763
|
+
* this session's arm back.
|
|
2764
|
+
*
|
|
2765
|
+
* A DEMANDED arm (`lastRearmAt === 0`) is never held by it. This is the other
|
|
2766
|
+
* half of the 2026-10-17 14:42 fix: once the cap had marked the session stuck
|
|
2767
|
+
* mid-demand, this gate muted every later tick, so the demanded arm was never
|
|
2768
|
+
* even typed. A demand outranks a backoff it had no part in earning.
|
|
2769
|
+
*/
|
|
2770
|
+
export function stuckBackoffHolds(m) {
|
|
2771
|
+
return m.stuckSince !== undefined && m.lastRearmAt !== 0;
|
|
2220
2772
|
}
|
|
2221
2773
|
/**
|
|
2222
2774
|
* Why a managed session should be treated as BLOCKED — waiting on a human
|
|
@@ -2250,8 +2802,70 @@ export function blockedReason(m, content, now) {
|
|
|
2250
2802
|
}
|
|
2251
2803
|
return null;
|
|
2252
2804
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2805
|
+
const STOPPED_REASON = "turn ended with nothing running — re-arming";
|
|
2806
|
+
const STOP_SETTLE_MS = 30_000;
|
|
2807
|
+
const STOP_NUDGE_COOLDOWN_MS = 3 * 60_000;
|
|
2808
|
+
// Status-line "N shells still running" / "N background tasks still running" and
|
|
2809
|
+
// the footer's "▶N" worker count — N must be > 0. The footer's "← N agents" and
|
|
2810
|
+
// "· N shells" (no "still running") are always-on, not work, and must not match.
|
|
2811
|
+
const BACKGROUND_RUNNING_RE = /\b[1-9]\d*\s+(?:background\s+)?(?:shells?|tasks?)\s+still\s+running\b|▶\s*[1-9]\d*/i;
|
|
2812
|
+
const LIVE_REGION_LINES = 20;
|
|
2813
|
+
/**
|
|
2814
|
+
* The session's turn ended (last transcript record is an assistant turn with no
|
|
2815
|
+
* tool call pending), settled for STOP_SETTLE_MS, the prompt is empty and
|
|
2816
|
+
* nothing spins or runs in the background. Pure; never true over typed input.
|
|
2817
|
+
*/
|
|
2818
|
+
export function turnStoppedIdle(t, paneText, now) {
|
|
2819
|
+
if (!t || t.working !== false || t.lastAt === null || now - t.lastAt < STOP_SETTLE_MS)
|
|
2820
|
+
return false;
|
|
2821
|
+
if (promptUnsentText(paneText) !== null)
|
|
2822
|
+
return false;
|
|
2823
|
+
const last = paneText.split("\n").slice(-LIVE_REGION_LINES).join("\n");
|
|
2824
|
+
return !QUEUED_HINT_RE.test(last) && !SPINNER_RE.test(last) && !BACKGROUND_RUNNING_RE.test(last);
|
|
2825
|
+
}
|
|
2826
|
+
export const GOAL_GONE_REASON = "goal gone before until, re-armed";
|
|
2827
|
+
export const FORCED_ARM_REASON = "asked to arm now";
|
|
2828
|
+
/** How long one queued arm is awaited before another may be typed. */
|
|
2829
|
+
export const QUEUED_ARM_WAIT_MS = 10 * 60_000;
|
|
2830
|
+
export const GOAL_GONE_BUSY_REASON = "goal gone before until, re-armed (session busy, queued)";
|
|
2831
|
+
export const GOAL_FORCED_BUSY_REASON = "asked to arm now (session busy, queued)";
|
|
2832
|
+
/**
|
|
2833
|
+
* An arm due while the session is mid-turn: Claude Code queues a submitted
|
|
2834
|
+
* line and runs it at the next turn boundary, so arming now interrupts
|
|
2835
|
+
* nothing. Same guards as the idle rule (not paused, until ahead, input line
|
|
2836
|
+
* empty) plus at most one pending arm. Clears the pending mark once the marker
|
|
2837
|
+
* is back or the turn is over.
|
|
2838
|
+
*
|
|
2839
|
+
* Forced arms too (`lastRearmAt === 0`, set by `now`, a new objective, a
|
|
2840
|
+
* queued instruction, or supervise with a new goal), because a session arming
|
|
2841
|
+
* ITSELF through the MCP tool is by definition mid-turn when it asks —
|
|
2842
|
+
* waiting for an idle pane defers its request until a hard-working session
|
|
2843
|
+
* happens to go quiet, which may be never. Unlike the goal-gone case the
|
|
2844
|
+
* forced arm fires with the old marker still on screen: the point of the call
|
|
2845
|
+
* is to replace that goal with the new text.
|
|
2846
|
+
*/
|
|
2847
|
+
export function busyGoalGoneReason(m, content, now, busy) {
|
|
2848
|
+
if (!busy) {
|
|
2849
|
+
delete m.queuedArmAt;
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
const forced = reasonToArm(m, content, now) === FORCED_ARM_REASON;
|
|
2853
|
+
if (!forced && GOAL_ACTIVE.test(content)) {
|
|
2854
|
+
delete m.queuedArmAt;
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
if (m.queuedArmAt !== undefined && now - m.queuedArmAt < QUEUED_ARM_WAIT_MS)
|
|
2858
|
+
return null;
|
|
2859
|
+
if (forced)
|
|
2860
|
+
return GOAL_FORCED_BUSY_REASON;
|
|
2861
|
+
return reasonToArm({ ...m, lastRearmAt: m.lastRearmAt || 1 }, content, now) === GOAL_GONE_REASON
|
|
2862
|
+
? GOAL_GONE_BUSY_REASON
|
|
2863
|
+
: null;
|
|
2864
|
+
}
|
|
2865
|
+
/** A shift/supervision whose `until` has passed: nothing proactive is typed into the session any more. */
|
|
2866
|
+
export const shiftOver = (m, now) => !!m.shift && now >= m.shift.until;
|
|
2867
|
+
export function reasonToArm(m, content, now) {
|
|
2868
|
+
if (m.paused || shiftOver(m, now))
|
|
2255
2869
|
return null;
|
|
2256
2870
|
if (OUT_OF_GOAL.some((re) => re.test(content)))
|
|
2257
2871
|
return "the session reported its goal could not be achieved";
|
|
@@ -2260,10 +2874,15 @@ function reasonToArm(m, content, now) {
|
|
|
2260
2874
|
// true of a number and nonsense about the world — the kind of line that costs
|
|
2261
2875
|
// somebody ten minutes at three in the morning.
|
|
2262
2876
|
if (m.lastRearmAt === 0)
|
|
2263
|
-
return
|
|
2877
|
+
return FORCED_ARM_REASON;
|
|
2264
2878
|
const quietFor = Math.max(0, now - m.lastChangeAt);
|
|
2265
2879
|
const armedFor = Math.max(0, now - m.lastRearmAt);
|
|
2266
2880
|
const marker = GOAL_ACTIVE.test(content);
|
|
2881
|
+
// The goal was cleared (the session's own doing, or the evaluator's) while the
|
|
2882
|
+
// operator's stretch is still running: no lapse timer, re-arm on this tick.
|
|
2883
|
+
// Paused is the operator's; an occupied input line is never typed over.
|
|
2884
|
+
if (!marker && m.shift && m.shift.until > now && promptUnsentText(content) === null)
|
|
2885
|
+
return GOAL_GONE_REASON;
|
|
2267
2886
|
if (!marker && quietFor > NO_GOAL_GRACE_MS)
|
|
2268
2887
|
return `no goal armed (idle ${Math.round(quietFor / 1000)}s)`;
|
|
2269
2888
|
/**
|
|
@@ -2368,7 +2987,7 @@ async function tick() {
|
|
|
2368
2987
|
}
|
|
2369
2988
|
}
|
|
2370
2989
|
for (const m of Object.values(state)) {
|
|
2371
|
-
const content =
|
|
2990
|
+
const content = await readPaneAsync(m.sessionId);
|
|
2372
2991
|
if (!content) {
|
|
2373
2992
|
// A session that cannot be read is not necessarily gone; say so once per
|
|
2374
2993
|
// tick rather than dropping it, because dropping it silently is how a
|
|
@@ -2388,13 +3007,17 @@ async function tick() {
|
|
|
2388
3007
|
// it once per session per tick is the same reading at a fraction of the
|
|
2389
3008
|
// cost.
|
|
2390
3009
|
const tickTty = m.tty ?? snapshotTty(m.sessionId);
|
|
2391
|
-
const
|
|
2392
|
-
const
|
|
2393
|
-
const
|
|
3010
|
+
const tickProc = tickTty ? processReading(tickTty) : null;
|
|
3011
|
+
const tickPid = tickProc?.pid ?? null;
|
|
3012
|
+
const tickUuid = tickProc?.uuid ?? (tickTty ? discoverLiveSessions().find((s) => s.id === m.sessionId)?.attachUuid : undefined);
|
|
3013
|
+
const tickTranscript = tickPid ? transcriptReading(tickPid, tickUuid) : null;
|
|
3014
|
+
// A turn that ended with nothing running is a stop, not work: the 2-minute
|
|
3015
|
+
// "recent transcript" window must not hide it.
|
|
3016
|
+
const stopped = turnStoppedIdle(tickTranscript, content, now);
|
|
3017
|
+
const working = !stopped && sessionIsWorking(tickTranscript, now);
|
|
2394
3018
|
const tickUsed = tickTranscript?.contextK ?? null;
|
|
2395
|
-
const
|
|
2396
|
-
const
|
|
2397
|
-
const tickDir = tickDirCandidate && existsSync(tickDirCandidate) ? tickDirCandidate : null;
|
|
3019
|
+
const tickDir = tickPid ? projectTranscriptDir(tickPid, tickUuid) : null;
|
|
3020
|
+
const tickCwd = tickPid ? sessionProjectCwd(tickPid, tickUuid) : null;
|
|
2398
3021
|
// Sample context for the fill-rate reading in `status`. Pushed only when
|
|
2399
3022
|
// a real number is available — never a synthetic 0 for a tick where the
|
|
2400
3023
|
// transcript could not be read, which would read as a session that
|
|
@@ -2812,10 +3435,12 @@ async function tick() {
|
|
|
2812
3435
|
const grownBy = used !== null && m.handoverDoneK !== undefined ? used - m.handoverDoneK : null;
|
|
2813
3436
|
if (decision.due) {
|
|
2814
3437
|
const notesDirExists = tickCwd ? existsSync(join(tickCwd, "Notes")) : false;
|
|
2815
|
-
const
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
:
|
|
3438
|
+
const target = pickHandoverTarget({
|
|
3439
|
+
handoverFile: m.handoverFile,
|
|
3440
|
+
sinkDescription: projectSinkDescription(tickCwd),
|
|
3441
|
+
defaultPath: defaultHandoverTarget(tickCwd, notesDirExists),
|
|
3442
|
+
});
|
|
3443
|
+
const askedPath = target.path;
|
|
2819
3444
|
m.handoverAskedAt = now;
|
|
2820
3445
|
m.handoverAskedPath = askedPath;
|
|
2821
3446
|
m.handoverWas = askedPath ? fileFingerprint(askedPath) : undefined;
|
|
@@ -2828,7 +3453,9 @@ async function tick() {
|
|
|
2828
3453
|
: "";
|
|
2829
3454
|
const whereClause = askedPath
|
|
2830
3455
|
? `Update ${askedPath}. ${carry}`
|
|
2831
|
-
:
|
|
3456
|
+
: target.description
|
|
3457
|
+
? `Update ${target.description}. `
|
|
3458
|
+
: `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
3459
|
// Wording follows the band: IMMEDIATE has no time to spare and says
|
|
2833
3460
|
// so; REFRESH and a work-triggered WARM-UP both mean the session has
|
|
2834
3461
|
// already written one and this is about the work SINCE, not a repeat
|
|
@@ -2850,7 +3477,7 @@ async function tick() {
|
|
|
2850
3477
|
notify(m, `at ${used}k tokens (${decision.reason})` +
|
|
2851
3478
|
(grownBy !== null ? `, ${grownBy}k of new work since the last one` : "") +
|
|
2852
3479
|
` — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}` +
|
|
2853
|
-
(
|
|
3480
|
+
(target.source !== "none" ? ` — target (${target.source}): ${askedPath ?? target.description}` : " — no target resolved, asked without naming a path"));
|
|
2854
3481
|
// The idle age that justified typing into this session now — the
|
|
2855
3482
|
// un-gated ask reaches every managed session, not just the one
|
|
2856
3483
|
// opted-in session it used to, so the evidence for "this was a safe
|
|
@@ -2887,6 +3514,33 @@ async function tick() {
|
|
|
2887
3514
|
* from when it would otherwise fire — it never suppresses one, since a
|
|
2888
3515
|
* genuinely stuck session goes quiet on the transcript too.
|
|
2889
3516
|
*/
|
|
3517
|
+
/**
|
|
3518
|
+
* API-RETRY KEEP-ALIVE — ahead of every gate below that continues on a
|
|
3519
|
+
* busy or blocked pane, because the retry wait IS the busy state here and
|
|
3520
|
+
* waiting it out loses the stretch: Claude Code retries three times and
|
|
3521
|
+
* gives up, leaving the goal frozen over a session that is only sleeping
|
|
3522
|
+
* between its own retries. The banner coexists with "Goal still active",
|
|
3523
|
+
* so this never re-arms — it only breaks the retry wait.
|
|
3524
|
+
*/
|
|
3525
|
+
if (apiRetryNudge(m, content, now)) {
|
|
3526
|
+
dirty = true;
|
|
3527
|
+
continue;
|
|
3528
|
+
}
|
|
3529
|
+
// Goal gone — or a forced arm (now / supervise with a new goal, asked by a
|
|
3530
|
+
// session that is itself mid-turn) — mid-turn: queue the arm behind the
|
|
3531
|
+
// turn instead of waiting for an idle tick.
|
|
3532
|
+
const queuedReason = busyGoalGoneReason(m, content, now, working && !paneLooksIdle(content, now - m.lastChangeAt));
|
|
3533
|
+
if (queuedReason && now - m.lastRearmAt >= REARM_COOLDOWN_MS) {
|
|
3534
|
+
// Pending whether or not the read-back saw it land: a queued line is invisible until the turn ends.
|
|
3535
|
+
m.queuedArmAt = now;
|
|
3536
|
+
const queued = await tryExclusive(arming, m.sessionId, () => arm(m, queuedReason));
|
|
3537
|
+
if (queued === undefined)
|
|
3538
|
+
delete m.queuedArmAt;
|
|
3539
|
+
else if (queued === "failed")
|
|
3540
|
+
note(m, `${queuedReason} — not confirmed, waiting for the turn to end or the marker`);
|
|
3541
|
+
dirty = true;
|
|
3542
|
+
continue;
|
|
3543
|
+
}
|
|
2890
3544
|
if (!m.paused && lease) {
|
|
2891
3545
|
if (working) {
|
|
2892
3546
|
const hadState = m.blockedSince !== undefined || m.blockedAlertedAt !== undefined || m.stuckSince !== undefined;
|
|
@@ -2949,13 +3603,43 @@ async function tick() {
|
|
|
2949
3603
|
// REARM_COOLDOWN_MS. This is what actually fixes the overnight bug: the
|
|
2950
3604
|
// old code kept re-arming (and re-spamming) forever because each retype
|
|
2951
3605
|
// reset lastChangeAt and the freeze detector never got a quiet window.
|
|
2952
|
-
|
|
3606
|
+
//
|
|
3607
|
+
// But a DEMANDED arm is never held here (2026-10-17 14:42): the cap had
|
|
3608
|
+
// marked the session stuck while the operator was mid-demand, and this
|
|
3609
|
+
// gate then muted every tick after it — the demanded arm was never even
|
|
3610
|
+
// typed. A demand outranks a backoff it had no part in earning.
|
|
3611
|
+
if (stuckBackoffHolds(m))
|
|
2953
3612
|
continue;
|
|
2954
3613
|
if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
|
|
2955
3614
|
continue;
|
|
2956
|
-
|
|
2957
|
-
|
|
3615
|
+
let reason = reasonToArm(m, content, now);
|
|
3616
|
+
const over = shiftOver(m, now);
|
|
3617
|
+
if (over && m.shift && !m.shift.lapseNoted) {
|
|
3618
|
+
m.shift.lapseNoted = true;
|
|
3619
|
+
note(m, "until passed — no more nudges/arms");
|
|
3620
|
+
dirty = true;
|
|
3621
|
+
}
|
|
3622
|
+
if (stopped && !m.paused && !over && (!reason || reason.startsWith("no goal armed")) && !GOAL_ACTIVE.test(content)) {
|
|
3623
|
+
reason = STOPPED_REASON;
|
|
3624
|
+
}
|
|
3625
|
+
if (!reason && stopped && !m.paused && !over && now - (m.lastNudgeAt ?? 0) >= STOP_NUDGE_COOLDOWN_MS) {
|
|
3626
|
+
// Marker still up but the turn ended on nothing: nudge now, not at the lapse timer.
|
|
3627
|
+
typeIntoSession(m.sessionId, NUDGE_TEXT);
|
|
3628
|
+
m.lastNudgeAt = now;
|
|
3629
|
+
note(m, `${STOPPED_REASON.replace("re-arming", "nudged to continue")}`);
|
|
3630
|
+
dirty = true;
|
|
2958
3631
|
continue;
|
|
3632
|
+
}
|
|
3633
|
+
if (!reason) {
|
|
3634
|
+
// Idle at an empty prompt past the `every` interval: a short nudge, not the full goal.
|
|
3635
|
+
if (nudgeDue(m, content, now)) {
|
|
3636
|
+
typeIntoSession(m.sessionId, NUDGE_TEXT);
|
|
3637
|
+
m.lastNudgeAt = now;
|
|
3638
|
+
note(m, `idle ${m.nudgeEveryMin}+ min at an empty prompt — nudged to continue`);
|
|
3639
|
+
dirty = true;
|
|
3640
|
+
}
|
|
3641
|
+
continue;
|
|
3642
|
+
}
|
|
2959
3643
|
/**
|
|
2960
3644
|
* A GOAL THAT CANNOT BE CONFIRMED IS NOT RETRIED FOREVER.
|
|
2961
3645
|
*
|
|
@@ -2972,32 +3656,19 @@ async function tick() {
|
|
|
2972
3656
|
* ordinary rules — the session is still managed, and the next genuine
|
|
2973
3657
|
* lapse arms it — and the operator is told, because a manager that has
|
|
2974
3658
|
* given up on delivering a goal is the one thing it must never do quietly.
|
|
3659
|
+
*
|
|
3660
|
+
* Counting is for GENUINE FAILURES only. On 2026-10-17 14:42 the count
|
|
3661
|
+
* also took guard skips: the operator, mid-demand ("arm NOW"), was typing
|
|
3662
|
+
* into the pane, three occupied-line ticks crossed the cap, the cap
|
|
3663
|
+
* consumed the forced-arm sentinel, and the manager — told to act — never
|
|
3664
|
+
* typed at all. A guard declining is the system working, not the attempt
|
|
3665
|
+
* failing.
|
|
2975
3666
|
*/
|
|
2976
|
-
const armed = await arm(m, reason);
|
|
3667
|
+
const armed = await tryExclusive(arming, m.sessionId, () => arm(m, reason));
|
|
3668
|
+
if (armed === undefined)
|
|
3669
|
+
continue; // another arm for this session is in flight
|
|
2977
3670
|
dirty = true;
|
|
2978
|
-
|
|
2979
|
-
m.armFails = 0;
|
|
2980
|
-
m.armFailStreak = 0;
|
|
2981
|
-
delete m.stuckSince;
|
|
2982
|
-
delete m.blockedSince;
|
|
2983
|
-
delete m.blockedAlertedAt;
|
|
2984
|
-
continue;
|
|
2985
|
-
}
|
|
2986
|
-
m.armFails = (m.armFails ?? 0) + 1;
|
|
2987
|
-
m.armFailStreak = (m.armFailStreak ?? 0) + 1;
|
|
2988
|
-
if (m.armFails >= ARM_ATTEMPTS) {
|
|
2989
|
-
m.armFails = 0;
|
|
2990
|
-
m.lastRearmAt = now;
|
|
2991
|
-
m.stuckSince ??= now;
|
|
2992
|
-
// No operator push here. Pushing on every cap is the 144-alerts-overnight
|
|
2993
|
-
// bug: this fires again every ~2-4 minutes for as long as arming keeps
|
|
2994
|
-
// failing, and it is content-agnostic — it cannot tell "mid-turn, will
|
|
2995
|
-
// clear itself" from "stuck on a modal for hours". The blocked check
|
|
2996
|
-
// above owns the operator alert now: it has `armFailStreak`/`stuckSince`
|
|
2997
|
-
// (which survive this reset) plus a static-pane confirmation, and its
|
|
2998
|
-
// own BLOCKED_REALERT_MS dedup. This stays log-only.
|
|
2999
|
-
note(m, `arming keeps failing (streak ${m.armFailStreak}) — holding off; the blocked check owns the operator alert now`);
|
|
3000
|
-
}
|
|
3671
|
+
applyArmResult(m, armed, now);
|
|
3001
3672
|
}
|
|
3002
3673
|
if (dirty)
|
|
3003
3674
|
saveState(state);
|
|
@@ -3012,8 +3683,17 @@ export function startManagerLoop() {
|
|
|
3012
3683
|
const n = Object.keys(state).length;
|
|
3013
3684
|
if (n)
|
|
3014
3685
|
log(`[manage] resuming ${n} managed session${n > 1 ? "s" : ""}`);
|
|
3686
|
+
let skipLogged = false;
|
|
3687
|
+
const guarded = nonOverlapping(tick, () => {
|
|
3688
|
+
if (skipLogged)
|
|
3689
|
+
return;
|
|
3690
|
+
skipLogged = true;
|
|
3691
|
+
log("[manage] tick still running — skipping overlapping tick");
|
|
3692
|
+
});
|
|
3015
3693
|
timer = setInterval(() => {
|
|
3016
|
-
void
|
|
3694
|
+
void guarded()
|
|
3695
|
+
.then(() => { skipLogged = false; })
|
|
3696
|
+
.catch((e) => log(`[manage] tick failed — ${e.message}`));
|
|
3017
3697
|
}, TICK_MS);
|
|
3018
3698
|
timer.unref?.();
|
|
3019
3699
|
}
|
|
@@ -3044,8 +3724,13 @@ export function stopManaging(s, sessionId, name) {
|
|
|
3044
3724
|
* `/manage pause|resume` stop arming without forgetting the objective
|
|
3045
3725
|
* `/manage now` arm immediately, whatever the signals say
|
|
3046
3726
|
*/
|
|
3047
|
-
export async function handleManage(sessionIdOrName, rawArg) {
|
|
3727
|
+
export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
|
|
3048
3728
|
const arg = (rawArg ?? "").trim();
|
|
3729
|
+
// Every managed session in one block — before any peer forwarding or
|
|
3730
|
+
// single-session resolution, since "all" names no session.
|
|
3731
|
+
if (sessionIdOrName.trim().toLowerCase() === "all" && (!arg || arg.toLowerCase() === "status")) {
|
|
3732
|
+
return { ok: true, message: statusLines(Object.values(state), Date.now(), contextK).join("\n") };
|
|
3733
|
+
}
|
|
3049
3734
|
/**
|
|
3050
3735
|
* `machine/session` is managed by that machine's own hub.
|
|
3051
3736
|
*
|
|
@@ -3069,6 +3754,36 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3069
3754
|
if (!resolved)
|
|
3070
3755
|
return { ok: false, message: `no live session matches "${sessionIdOrName}"` };
|
|
3071
3756
|
const { sessionId, name } = resolved;
|
|
3757
|
+
/**
|
|
3758
|
+
* supervise — a session puts itself under the manager in one call:
|
|
3759
|
+
* {goal, until, every}. goal = standing objective, until = end of the
|
|
3760
|
+
* stretch (clock time or duration), every = idle-nudge interval in minutes.
|
|
3761
|
+
*/
|
|
3762
|
+
if (arg.toLowerCase() === "supervise") {
|
|
3763
|
+
const until = opts.until ? parseUntilSpec(opts.until) : null;
|
|
3764
|
+
const every = opts.every !== undefined && opts.every !== "" ? parseEveryMin(opts.every) : null;
|
|
3765
|
+
if (opts.until && until === null)
|
|
3766
|
+
return { ok: false, message: `cannot read until="${opts.until}" — use a clock time (08:00) or a duration (8h, 90m)` };
|
|
3767
|
+
if (opts.every !== undefined && opts.every !== "" && every === null)
|
|
3768
|
+
return { ok: false, message: `every="${opts.every}" must be ${NUDGE_EVERY_MIN}-${NUDGE_EVERY_MAX} minutes` };
|
|
3769
|
+
const goal = (opts.goal ?? "").trim();
|
|
3770
|
+
if (!state[sessionId]) {
|
|
3771
|
+
if (!goal)
|
|
3772
|
+
return { ok: false, message: `${name} is not being managed — supervise needs a goal` };
|
|
3773
|
+
const started = await handleManage(sessionId, `start ${goal}`);
|
|
3774
|
+
if (!started.ok)
|
|
3775
|
+
return started;
|
|
3776
|
+
}
|
|
3777
|
+
const m = state[sessionId];
|
|
3778
|
+
applySupervise(m, goal, until, every);
|
|
3779
|
+
saveState(state);
|
|
3780
|
+
startManagerLoop();
|
|
3781
|
+
return {
|
|
3782
|
+
ok: true,
|
|
3783
|
+
managed: true,
|
|
3784
|
+
message: `${name} is supervised${until !== null ? ` until ${hhmm(until)}` : ""}${m.nudgeEveryMin ? `, nudged after ${m.nudgeEveryMin} min idle` : ""}.\n ${statusLines([m], Date.now(), contextK)[0]}`,
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3072
3787
|
const existing = state[sessionId];
|
|
3073
3788
|
const word = arg.toLowerCase();
|
|
3074
3789
|
// help — the grammar, from the thing that implements it.
|
|
@@ -3118,7 +3833,8 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3118
3833
|
` a bounded stretch of autonomous work on the tracker's open\n` +
|
|
3119
3834
|
` issues: objective, screen decision, expiry and arming in one\n` +
|
|
3120
3835
|
` sentence. \`shift off\` stands it down without killing it\n` +
|
|
3121
|
-
` now arm immediately, whatever the signals say
|
|
3836
|
+
` now arm immediately, whatever the signals say — safe mid-turn,\n` +
|
|
3837
|
+
` the /goal line queues behind a running turn\n` +
|
|
3122
3838
|
` pause stop arming, keep the objective\n` +
|
|
3123
3839
|
` resume start arming again\n` +
|
|
3124
3840
|
` off stop managing entirely\n` +
|
|
@@ -3331,7 +4047,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3331
4047
|
const setMatch = arg.match(/^(?:set|objective|replace|start)\s+([\s\S]+)$/i);
|
|
3332
4048
|
if (setMatch && existing) {
|
|
3333
4049
|
const before = existing.objective;
|
|
3334
|
-
existing
|
|
4050
|
+
setObjective(existing, setMatch[1].trim());
|
|
3335
4051
|
// Notes written against the old objective may not make sense against the
|
|
3336
4052
|
// new one; say so rather than silently carrying them over.
|
|
3337
4053
|
const dropped = existing.pending.length;
|
|
@@ -3363,7 +4079,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3363
4079
|
const addMatch = arg.match(/^(?:add|also|append|extend)\s+([\s\S]+)$/i);
|
|
3364
4080
|
if (addMatch && existing) {
|
|
3365
4081
|
const extra = addMatch[1].trim();
|
|
3366
|
-
existing
|
|
4082
|
+
setObjective(existing, `${existing.objective} Also: ${extra}`);
|
|
3367
4083
|
note(existing, `objective extended: ${extra.slice(0, 80)}`);
|
|
3368
4084
|
saveState(state);
|
|
3369
4085
|
return {
|
|
@@ -3546,7 +4262,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3546
4262
|
}
|
|
3547
4263
|
/**
|
|
3548
4264
|
* handover-at <k> — this session's own warm-up margin, overriding the
|
|
3549
|
-
* default
|
|
4265
|
+
* default 50k.
|
|
3550
4266
|
*
|
|
3551
4267
|
* A fixed FRACTION of a fixed window cannot be right for every session —
|
|
3552
4268
|
* that was tried (0.82 of a 1000k window) and the ground moved under it:
|
|
@@ -3617,9 +4333,12 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3617
4333
|
objective,
|
|
3618
4334
|
pending: [],
|
|
3619
4335
|
history: [],
|
|
3620
|
-
//
|
|
3621
|
-
//
|
|
3622
|
-
|
|
4336
|
+
// EVERY ask for arming is now (2026-10-17 14:48). The grace period this
|
|
4337
|
+
// line once gave — don't arm on top of whatever the session is doing as
|
|
4338
|
+
// the operator types — predates safe mid-turn queuing: a queued arm
|
|
4339
|
+
// lands at the turn boundary and interrupts nothing, so its reason is
|
|
4340
|
+
// gone, and 0 arms on the very next tick.
|
|
4341
|
+
lastRearmAt: 0,
|
|
3623
4342
|
lastChangeAt: Date.now(),
|
|
3624
4343
|
lastHash: hash(readPane(sessionId)),
|
|
3625
4344
|
tty: snapshotTty(sessionId),
|
|
@@ -3645,7 +4364,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3645
4364
|
return { ok: true, managed: true, message: `${name} will be armed on the next tick` };
|
|
3646
4365
|
}
|
|
3647
4366
|
const instruction = arg.match(/^instruct\s+([\s\S]+)$/i)?.[1].trim() ?? arg;
|
|
3648
|
-
existing
|
|
4367
|
+
queueInstruction(existing, instruction);
|
|
3649
4368
|
note(existing, `operator: ${instruction.slice(0, 80)}`);
|
|
3650
4369
|
saveState(state);
|
|
3651
4370
|
return {
|