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.
Files changed (68) hide show
  1. package/dist/adapters/iterm/core.d.ts +8 -0
  2. package/dist/adapters/iterm/core.d.ts.map +1 -1
  3. package/dist/adapters/iterm/core.js +77 -17
  4. package/dist/adapters/iterm/core.js.map +1 -1
  5. package/dist/adapters/iterm/sessions.d.ts +6 -0
  6. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  7. package/dist/adapters/iterm/sessions.js +25 -2
  8. package/dist/adapters/iterm/sessions.js.map +1 -1
  9. package/dist/adapters/pailot/gateway.d.ts +7 -0
  10. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  11. package/dist/adapters/pailot/gateway.js +17 -9
  12. package/dist/adapters/pailot/gateway.js.map +1 -1
  13. package/dist/core/claude-attach.d.ts +13 -0
  14. package/dist/core/claude-attach.d.ts.map +1 -0
  15. package/dist/core/claude-attach.js +22 -0
  16. package/dist/core/claude-attach.js.map +1 -0
  17. package/dist/core/state.d.ts +38 -5
  18. package/dist/core/state.d.ts.map +1 -1
  19. package/dist/core/state.js +107 -15
  20. package/dist/core/state.js.map +1 -1
  21. package/dist/daemon/agent-env.d.ts +7 -0
  22. package/dist/daemon/agent-env.d.ts.map +1 -0
  23. package/dist/daemon/agent-env.js +9 -0
  24. package/dist/daemon/agent-env.js.map +1 -0
  25. package/dist/daemon/cli.js +2 -0
  26. package/dist/daemon/cli.js.map +1 -1
  27. package/dist/daemon/core-handlers.d.ts +35 -3
  28. package/dist/daemon/core-handlers.d.ts.map +1 -1
  29. package/dist/daemon/core-handlers.js +143 -71
  30. package/dist/daemon/core-handlers.js.map +1 -1
  31. package/dist/daemon/forge-release.d.ts +41 -0
  32. package/dist/daemon/forge-release.d.ts.map +1 -0
  33. package/dist/daemon/forge-release.js +127 -0
  34. package/dist/daemon/forge-release.js.map +1 -0
  35. package/dist/daemon/inbound.d.ts +2 -0
  36. package/dist/daemon/inbound.d.ts.map +1 -1
  37. package/dist/daemon/inbound.js +17 -6
  38. package/dist/daemon/inbound.js.map +1 -1
  39. package/dist/daemon/index.js +2 -2
  40. package/dist/daemon/index.js.map +1 -1
  41. package/dist/daemon/mailbox-watch.js +1 -1
  42. package/dist/daemon/mailbox-watch.js.map +1 -1
  43. package/dist/daemon/manage.d.ts +265 -5
  44. package/dist/daemon/manage.d.ts.map +1 -1
  45. package/dist/daemon/manage.js +837 -118
  46. package/dist/daemon/manage.js.map +1 -1
  47. package/dist/daemon/pointer-guard.d.ts +37 -0
  48. package/dist/daemon/pointer-guard.d.ts.map +1 -0
  49. package/dist/daemon/pointer-guard.js +100 -0
  50. package/dist/daemon/pointer-guard.js.map +1 -0
  51. package/dist/daemon/session-content.d.ts +8 -0
  52. package/dist/daemon/session-content.d.ts.map +1 -1
  53. package/dist/daemon/session-content.js +28 -5
  54. package/dist/daemon/session-content.js.map +1 -1
  55. package/dist/daemon/todoist-webhook.d.ts.map +1 -1
  56. package/dist/daemon/todoist-webhook.js +8 -4
  57. package/dist/daemon/todoist-webhook.js.map +1 -1
  58. package/dist/ipc/client.d.ts.map +1 -1
  59. package/dist/ipc/client.js +37 -0
  60. package/dist/ipc/client.js.map +1 -1
  61. package/dist/mcp/index.js +15 -8
  62. package/dist/mcp/index.js.map +1 -1
  63. package/dist/types/ipc.d.ts +7 -0
  64. package/dist/types/ipc.d.ts.map +1 -1
  65. package/hooks/drain-mailbox.mjs +20 -3
  66. package/hooks/manage-hook.mjs +3 -1
  67. package/hooks/operator-manage-token.mjs +45 -0
  68. package/package.json +1 -1
@@ -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 = 100; // warm-up band: ask this far below the trigger, given time or work done
104
- const REFRESH_MARGIN_K = 40; // refresh band: ask again if it has grown 40k+ since the last one
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 − 40k) — due once REFRESH_GROWN_K of new work has
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 100k) — the ordinary case, gated the
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
- for (let i = 1; i < lines.length; i++) {
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
- const typed = m[1].trim();
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
- continue;
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 = processCwd(pid);
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
- const mins = Math.round(REPORT_EVERY_MS / 60_000);
1113
- const lines = managed.map((m) => {
1114
- const armings = armingsSinceReport.get(m.sessionId) ?? 0;
1115
- armingsSinceReport.set(m.sessionId, 0);
1116
- const k = contextK(m);
1117
- const quietFor = Math.round((now - m.lastChangeAt) / 60_000);
1118
- const doing = m.paused ? "paused" : quietFor >= 2 ? `quiet for ${quietFor} min` : "working";
1119
- const last = m.history.at(-1);
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
- return { isSession: true, pid: claude.pid };
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
- const dir = projectTranscriptDir(claudePid);
1345
- if (!dir)
1346
- return none;
1347
- const picked = liveTranscriptTail(dir);
1348
- if (!picked)
1349
- return none;
1350
- const raw = picked.raw;
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 t = proc.pid ? transcriptReading(proc.pid) : { working: null, doing: null, contextK: null, lastAt: null };
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 changes what the next arming says; it types nothing now.\n" +
1647
- " The next arming comes when the session stops — or immediately, with `now`.");
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} until done`;
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
- async function arm(m, reason) {
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 = readSessionContent(m.sessionId, 5);
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 false;
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 false;
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. Skips still count toward armFails/armFailStreak below, so
2150
- * a line occupied for minutes still reaches blockedReason's operator alert
2151
- * (with a screenshot) — the daemon does not stall silently either way.
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(readPane(m.sessionId));
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
- note(m, `arm deferred: input line holds '${onLine}' (unsent for ${sameForTicks} tick${sameForTicks === 1 ? "" : "s"}, idle ${Math.round(armIdleMs / 1000)}s)`);
2165
- return false;
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(readPane(m.sessionId))) {
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 false;
2686
+ return "failed";
2195
2687
  }
2196
- await sleep(300); // let the pane catch up before reading it back
2197
- const echoedLine = promptUnsentText(readPane(m.sessionId, { fresh: true })) ?? "";
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
- note(m, `arm aborted: line read back as "${echoedLine.slice(0, 60)}" not "${text.slice(0, 60)}"`);
2201
- return false;
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: () => readPane(m.sessionId, { fresh: true }),
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 true;
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 false;
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
- function reasonToArm(m, content, now) {
2254
- if (m.paused)
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 "asked to arm now";
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 = readPane(m.sessionId);
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 tickPid = tickTty ? processReading(tickTty).pid : null;
2392
- const tickTranscript = tickPid ? transcriptReading(tickPid) : null;
2393
- const working = sessionIsWorking(tickTranscript, now);
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 tickCwd = tickPid ? sessionCwd(tickPid) : null;
2396
- const tickDirCandidate = tickCwd ? join(homedir(), ".claude", "projects", tickCwd.replace(/\//g, "-")) : null;
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 usedDefault = !m.handoverFile;
2816
- const askedPath = m.handoverFile
2817
- ? resolveHandoverPath(m.handoverFile)
2818
- : (defaultHandoverTarget(tickCwd, notesDirExists) ?? undefined);
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
- : `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. `;
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
- (askedPath ? ` — target: ${askedPath}${usedDefault ? " (default — no handoverFile set)" : ""}` : " — no target resolved, asked without naming a path"));
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
- if (m.stuckSince !== undefined)
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
- const reason = reasonToArm(m, content, now);
2957
- if (!reason)
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
- if (armed) {
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 tick().catch((e) => log(`[manage] tick failed — ${e.message}`));
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\n` +
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.objective = setMatch[1].trim();
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.objective = `${existing.objective} Also: ${extra}`;
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 100k.
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
- // Give the session the benefit of the grace period rather than arming
3621
- // on top of whatever it is doing at the moment the operator types this.
3622
- lastRearmAt: Date.now(),
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.pending.push(instruction);
4367
+ queueInstruction(existing, instruction);
3649
4368
  note(existing, `operator: ${instruction.slice(0, 80)}`);
3650
4369
  saveState(state);
3651
4370
  return {