aibroker 0.61.8 → 0.62.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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 +9 -0
  10. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  11. package/dist/adapters/pailot/gateway.js +60 -14
  12. package/dist/adapters/pailot/gateway.js.map +1 -1
  13. package/dist/adapters/pailot/message-queue.d.ts +10 -0
  14. package/dist/adapters/pailot/message-queue.d.ts.map +1 -1
  15. package/dist/adapters/pailot/message-queue.js +112 -12
  16. package/dist/adapters/pailot/message-queue.js.map +1 -1
  17. package/dist/adapters/pailot/mqtt-broker.d.ts +6 -0
  18. package/dist/adapters/pailot/mqtt-broker.d.ts.map +1 -1
  19. package/dist/adapters/pailot/mqtt-broker.js +8 -0
  20. package/dist/adapters/pailot/mqtt-broker.js.map +1 -1
  21. package/dist/core/claude-attach.d.ts +13 -0
  22. package/dist/core/claude-attach.d.ts.map +1 -0
  23. package/dist/core/claude-attach.js +22 -0
  24. package/dist/core/claude-attach.js.map +1 -0
  25. package/dist/core/state.d.ts +38 -5
  26. package/dist/core/state.d.ts.map +1 -1
  27. package/dist/core/state.js +107 -15
  28. package/dist/core/state.js.map +1 -1
  29. package/dist/daemon/agent-env.d.ts +7 -0
  30. package/dist/daemon/agent-env.d.ts.map +1 -0
  31. package/dist/daemon/agent-env.js +9 -0
  32. package/dist/daemon/agent-env.js.map +1 -0
  33. package/dist/daemon/cli.js +2 -0
  34. package/dist/daemon/cli.js.map +1 -1
  35. package/dist/daemon/core-handlers.d.ts +35 -3
  36. package/dist/daemon/core-handlers.d.ts.map +1 -1
  37. package/dist/daemon/core-handlers.js +143 -71
  38. package/dist/daemon/core-handlers.js.map +1 -1
  39. package/dist/daemon/forge-release.d.ts +41 -0
  40. package/dist/daemon/forge-release.d.ts.map +1 -0
  41. package/dist/daemon/forge-release.js +127 -0
  42. package/dist/daemon/forge-release.js.map +1 -0
  43. package/dist/daemon/inbound.d.ts +2 -0
  44. package/dist/daemon/inbound.d.ts.map +1 -1
  45. package/dist/daemon/inbound.js +17 -6
  46. package/dist/daemon/inbound.js.map +1 -1
  47. package/dist/daemon/index.js +2 -2
  48. package/dist/daemon/index.js.map +1 -1
  49. package/dist/daemon/mailbox-watch.js +1 -1
  50. package/dist/daemon/mailbox-watch.js.map +1 -1
  51. package/dist/daemon/manage.d.ts +147 -5
  52. package/dist/daemon/manage.d.ts.map +1 -1
  53. package/dist/daemon/manage.js +618 -72
  54. package/dist/daemon/manage.js.map +1 -1
  55. package/dist/daemon/pointer-guard.d.ts +37 -0
  56. package/dist/daemon/pointer-guard.d.ts.map +1 -0
  57. package/dist/daemon/pointer-guard.js +100 -0
  58. package/dist/daemon/pointer-guard.js.map +1 -0
  59. package/dist/daemon/session-content.d.ts +8 -0
  60. package/dist/daemon/session-content.d.ts.map +1 -1
  61. package/dist/daemon/session-content.js +28 -5
  62. package/dist/daemon/session-content.js.map +1 -1
  63. package/dist/daemon/todoist-webhook.d.ts.map +1 -1
  64. package/dist/daemon/todoist-webhook.js +8 -4
  65. package/dist/daemon/todoist-webhook.js.map +1 -1
  66. package/dist/ipc/client.d.ts.map +1 -1
  67. package/dist/ipc/client.js +37 -0
  68. package/dist/ipc/client.js.map +1 -1
  69. package/dist/mcp/index.js +14 -7
  70. package/dist/mcp/index.js.map +1 -1
  71. package/dist/types/ipc.d.ts +7 -0
  72. package/dist/types/ipc.d.ts.map +1 -1
  73. package/hooks/drain-mailbox.mjs +20 -3
  74. package/hooks/manage-hook.mjs +3 -1
  75. package/hooks/operator-manage-token.mjs +45 -0
  76. 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);
@@ -1892,6 +2071,154 @@ export function parseShift(text, now = new Date()) {
1892
2071
  workers: Math.min(8, Math.max(1, workers)),
1893
2072
  };
1894
2073
  }
2074
+ // ── self-supervision: one call from the session itself ───────────────────────
2075
+ /** "self" or omitted means the caller's own session; anything else is taken as given. */
2076
+ export function resolveManageTarget(session, callerId) {
2077
+ const s = (session ?? "").trim();
2078
+ return !s || s.toLowerCase() === "self" ? (callerId ?? null) : s;
2079
+ }
2080
+ /** Put goal / until / every on a managed entry; the screen is never granted by supervise. */
2081
+ export function applySupervise(m, goal, until, every) {
2082
+ if (goal) {
2083
+ m.objective = goal;
2084
+ m.pending = [];
2085
+ m.lastRearmAt = 0;
2086
+ }
2087
+ if (every !== null)
2088
+ m.nudgeEveryMin = every;
2089
+ if (until !== null)
2090
+ m.shift = { until, workers: 1, visual: false, startedAt: Date.now(), plain: true };
2091
+ m.paused = false;
2092
+ note(m, `supervise: until ${until !== null ? hhmm(until) : "open-ended"}, nudge every ${m.nudgeEveryMin ?? "-"} min`);
2093
+ }
2094
+ // ── only the operator ends or shortens a goal ────────────────────────────────
2095
+ export const AGENT_REFUSAL = 'refused: only the operator ends or shortens a goal (say "manage <session> off" yourself in a session, or run it from your own terminal)';
2096
+ const TOKEN_TTL_MS = 5 * 60_000;
2097
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2098
+ const tokenDir = () => join(homedir(), ".aibroker", "operator-tokens");
2099
+ /** Written by hooks/operator-manage-token.mjs from the operator's own typed prompt. */
2100
+ export function writeOperatorToken(session, action, now = Date.now()) {
2101
+ mkdirSync(tokenDir(), { recursive: true, mode: 0o700 });
2102
+ writeFileSync(join(tokenDir(), `${slug(session)}-${action}.json`), JSON.stringify({ expiresAt: now + TOKEN_TTL_MS }), { mode: 0o600 });
2103
+ }
2104
+ /** One-shot: the file is removed whether or not it was still valid. */
2105
+ function consumeToken(sessions, action, now) {
2106
+ let ok = false;
2107
+ for (const s of new Set(sessions.map(slug).filter(Boolean))) {
2108
+ const file = join(tokenDir(), `${s}-${action}.json`);
2109
+ try {
2110
+ ok = JSON.parse(readFileSync(file, "utf8")).expiresAt > now || ok;
2111
+ unlinkSync(file);
2112
+ }
2113
+ catch { /* no token under this name */ }
2114
+ }
2115
+ return ok;
2116
+ }
2117
+ /**
2118
+ * Agents cannot end or shorten a goal. Returns the refusal, or null when the
2119
+ * call may go ahead. `operator` is set only by the CLI (outside any agent
2120
+ * environment) and by the prompt hook; otherwise a one-shot token written from
2121
+ * the operator's typed prompt is required.
2122
+ * ponytail: a process that writes the daemon socket directly can still claim
2123
+ * `operator`; the socket is local-user trust, tighten with a peer credential.
2124
+ */
2125
+ export function refuseAgentManage(sessionIdOrName, rawArg, opts, operator, now = Date.now()) {
2126
+ if (operator)
2127
+ return null;
2128
+ const arg = rawArg.trim().toLowerCase();
2129
+ const resolved = resolveSession(sessionIdOrName);
2130
+ const cur = resolved ? state[resolved.sessionId]?.shift?.until : undefined;
2131
+ const names = [sessionIdOrName, resolved?.name ?? ""];
2132
+ const ending = /^(off|stop)$/.test(arg) ? "off" :
2133
+ arg === "pause" ? "pause" :
2134
+ /^shift\s+(off|stop|end|stand down)\b/.test(arg) ? "shift-off" : null;
2135
+ if (ending)
2136
+ return consumeToken(names, ending, now) ? null : AGENT_REFUSAL;
2137
+ const shift = arg.match(/^shift\b\s*([\s\S]*)$/);
2138
+ const next = shift ? now + parseShift(shift[1], new Date(now)).hours * 3_600_000 : opts.until ? parseUntilSpec(opts.until, new Date(now)) : null;
2139
+ // Minute precision: the same clock time is not a shortening because of float/ms drift.
2140
+ const minute = (t) => Math.floor(t / 60_000);
2141
+ return next !== null && cur !== undefined && cur > now && minute(next) < minute(cur) ? AGENT_REFUSAL : null;
2142
+ }
2143
+ /** "08:00", "until 8pm", "8h", "90m" → epoch ms; null when it reads as none of these. */
2144
+ export function parseUntilSpec(spec, now = new Date()) {
2145
+ const t = spec.trim().toLowerCase();
2146
+ if (/^\d{1,2}(:\d{2})?\s*(am|pm)?$/.test(t))
2147
+ return parseUntilClock(`until ${t}`, now);
2148
+ if (/^(until|till|til)\s/.test(t))
2149
+ return parseUntilClock(t, now);
2150
+ const d = t.match(/^(?:for\s+)?(\d+(?:[.,]\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)$/);
2151
+ if (!d)
2152
+ return null;
2153
+ const n = Number(d[1].replace(",", "."));
2154
+ const ms = n * (/^h/.test(d[2]) ? 3_600_000 : 60_000);
2155
+ return now.getTime() + Math.min(ms, SHIFT_MAX_HOURS * 3_600_000);
2156
+ }
2157
+ const NUDGE_EVERY_MIN = 5;
2158
+ const NUDGE_EVERY_MAX = 480;
2159
+ /** Minutes between idle nudges: 30, "30", "30m", "1h". Null outside 5..480. */
2160
+ export function parseEveryMin(v) {
2161
+ const t = String(v ?? "").trim().toLowerCase();
2162
+ const m = t.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)?$/);
2163
+ if (!m)
2164
+ return null;
2165
+ const n = Math.round(Number(m[1]) * (m[2] && m[2].startsWith("h") ? 60 : 1));
2166
+ return n >= NUDGE_EVERY_MIN && n <= NUDGE_EVERY_MAX ? n : null;
2167
+ }
2168
+ /** When the next nudge (with `every`) or the ~46-50 min lapse re-arm falls due. */
2169
+ export function nextDueAt(m) {
2170
+ if (m.paused)
2171
+ return null;
2172
+ if (m.nudgeEveryMin) {
2173
+ return Math.max(m.lastChangeAt, m.lastNudgeAt ?? 0, m.lastRearmAt) + m.nudgeEveryMin * 60_000;
2174
+ }
2175
+ return m.lastRearmAt + GOAL_MAX_AGE_MS;
2176
+ }
2177
+ /**
2178
+ * Should a short "continue the goal" line be typed now? Only for a session
2179
+ * with an `every` interval whose goal marker is still shown (a missing marker
2180
+ * is the full re-arm, handled by reasonToArm), idle for that long, with an
2181
+ * EMPTY input line — text there is somebody's, same rule as pointer-guard.
2182
+ * The caller has already ruled out a busy session.
2183
+ */
2184
+ export function nudgeDue(m, content, now) {
2185
+ if (!m.nudgeEveryMin || m.paused || shiftOver(m, now))
2186
+ return false;
2187
+ if (!GOAL_ACTIVE.test(content))
2188
+ return false;
2189
+ if (promptUnsentText(content) !== null)
2190
+ return false;
2191
+ if (QUEUED_HINT_RE.test(content.split("\n").slice(-8).join("\n")))
2192
+ return false;
2193
+ const due = nextDueAt(m);
2194
+ return due !== null && now >= due;
2195
+ }
2196
+ export const NUDGE_TEXT = "Continue working toward your standing goal.";
2197
+ const hhmm = (t) => {
2198
+ const d = new Date(t);
2199
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
2200
+ };
2201
+ /** One block for every managed session: name, goal, until, next due, context, last handover, state. */
2202
+ export function statusLines(sessions, now, kOf = () => null) {
2203
+ if (sessions.length === 0)
2204
+ return ["no sessions are being managed"];
2205
+ return sessions.map((m) => {
2206
+ const goal = oneLine(m.objective);
2207
+ const due = nextDueAt(m);
2208
+ const k = kOf(m);
2209
+ const pct = k !== null ? Math.round((k / (m.contextWindowK ?? 1000)) * 100) : null;
2210
+ const state = m.paused ? "paused" : m.shift ? (m.shift.plain ? "supervised" : "shift") : "on";
2211
+ return [
2212
+ m.name,
2213
+ `goal: ${goal.length > 80 ? `${goal.slice(0, 79)}…` : goal}`,
2214
+ `until: ${m.shift ? hhmm(m.shift.until) : "-"}`,
2215
+ `next ${m.nudgeEveryMin ? "nudge" : "arm"}: ${due === null ? "-" : due <= now ? "due" : `in ${Math.round((due - now) / 60_000)}m`}`,
2216
+ `ctx: ${k !== null ? `${pct}% ${k}k` : "?"}`,
2217
+ `handover: ${m.handoverDoneAt ? hhmm(m.handoverDoneAt) : "-"}`,
2218
+ state,
2219
+ ].join(" | ");
2220
+ });
2221
+ }
1895
2222
  /**
1896
2223
  * The standing objective for a shift, written once here instead of by hand
1897
2224
  * every night. It says what to work on and what "done" means; how to work is
@@ -2011,12 +2338,23 @@ export function briefSlug(m) {
2011
2338
  * The line typed at the session: `/goal` plus a pointer to the brief, plus as
2012
2339
  * much of the objective's first words as fits. Never longer than `max`.
2013
2340
  */
2014
- export function buildGoalLine(briefPath, objective, max = GOAL_LINE_MAX_CHARS) {
2015
- const head = `/goal Follow ${briefPath} until done`;
2341
+ export function buildGoalLine(briefPath, objective, until = null, max = GOAL_LINE_MAX_CHARS) {
2342
+ const head = `/goal ${noDoneStatePrefix(until)} Follow ${briefPath}`;
2016
2343
  const room = max - head.length - 2;
2017
2344
  const words = oneLine(objective);
2018
2345
  return room >= 20 && words ? `${head}: ${truncateAtWord(words, room)}` : head;
2019
2346
  }
2347
+ /**
2348
+ * Opens EVERY armed goal, so Claude Code's evaluator has no done-state to judge
2349
+ * met: a goal that names one ("until the fix is in build N") is cleared the
2350
+ * moment the session says it is. Only the operator ends a goal. Never dropped
2351
+ * to fit the line — the objective is shortened first.
2352
+ */
2353
+ export function noDoneStatePrefix(until) {
2354
+ return until !== null
2355
+ ? `Keep working until ${hhmm(until)}. There is no earlier done-state. Only the operator ends this goal.`
2356
+ : "Keep working. There is no done-state. Only the operator ends this goal.";
2357
+ }
2020
2358
  /** Everything that does not fit on the typed line, as the file it points to. */
2021
2359
  export function buildBrief(parts) {
2022
2360
  const sections = [];
@@ -2060,12 +2398,12 @@ function goalText(m) {
2060
2398
  objective: m.objective,
2061
2399
  agentish: AG2_SPEC,
2062
2400
  rulesSource: rules ? standingRulesSource() : "",
2063
- shiftRules: m.shift && m.objective !== shift ? shift : "",
2401
+ shiftRules: m.shift && !m.shift.plain && m.objective !== shift ? shift : "",
2064
2402
  hands,
2065
2403
  pending: m.pending,
2066
2404
  }), { mode: 0o600 });
2067
2405
  chmodSync(path, 0o600);
2068
- return { text: buildGoalLine(foldHome(path), m.objective) };
2406
+ return { text: buildGoalLine(foldHome(path), m.objective, m.shift?.until ?? null) };
2069
2407
  }
2070
2408
  /**
2071
2409
  * Did it arm? Within a bounded wait the pane must show the goal marker with an
@@ -2079,7 +2417,7 @@ export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
2079
2417
  let unsent = null;
2080
2418
  for (let i = 0; i < waits; i++) {
2081
2419
  await deps.sleep(waitMs);
2082
- const pane = deps.readPane();
2420
+ const pane = await deps.readPane();
2083
2421
  unsent = promptUnsentText(pane);
2084
2422
  if (GOAL_ACTIVE.test(pane) && !unsent)
2085
2423
  return true;
@@ -2088,6 +2426,15 @@ export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
2088
2426
  deps.clearInput();
2089
2427
  return false;
2090
2428
  }
2429
+ /** readPane for async callers (tick, arm): the iTerm read does not block the event loop. */
2430
+ async function readPaneAsync(sessionId, opts = {}) {
2431
+ try {
2432
+ return (await readSessionContentAsync(sessionId, 60, opts))?.content ?? "";
2433
+ }
2434
+ catch {
2435
+ return "";
2436
+ }
2437
+ }
2091
2438
  function readPane(sessionId, opts = {}) {
2092
2439
  try {
2093
2440
  return readSessionContent(sessionId, 60, opts)?.content ?? "";
@@ -2099,6 +2446,59 @@ function readPane(sessionId, opts = {}) {
2099
2446
  async function sleep(ms) {
2100
2447
  return new Promise((r) => setTimeout(r, ms));
2101
2448
  }
2449
+ /**
2450
+ * Read the input line back after a paste, polling while it is still empty: under
2451
+ * load the terminal has not rendered the paste after a fixed 300 ms. Returns as
2452
+ * soon as the line is non-empty (the caller decides match vs mangled), or the
2453
+ * empty line once the bound is spent. Never decides anything itself.
2454
+ */
2455
+ export async function readBackTyped(deps) {
2456
+ const delays = deps.delaysMs ?? [300, 300, 500, 800, 1_000, 1_000, 1_100]; // ~5 s
2457
+ let waitedMs = 0;
2458
+ let line = "";
2459
+ for (const d of delays) {
2460
+ await deps.sleep(d);
2461
+ waitedMs += d;
2462
+ line = await deps.read();
2463
+ if (line)
2464
+ break;
2465
+ }
2466
+ return { line, waitedMs };
2467
+ }
2468
+ /**
2469
+ * Wrap `run` so a call made while the previous one is still pending is skipped
2470
+ * (onSkip) instead of started. The tick yields to the event loop on every pane
2471
+ * read, so under load one outlasts TICK_MS and the next interval would arm the
2472
+ * same session a second time.
2473
+ */
2474
+ export function nonOverlapping(run, onSkip) {
2475
+ let running = false;
2476
+ return async () => {
2477
+ if (running)
2478
+ return onSkip();
2479
+ running = true;
2480
+ try {
2481
+ await run();
2482
+ }
2483
+ finally {
2484
+ running = false;
2485
+ }
2486
+ };
2487
+ }
2488
+ /** Sessions with an arm() in flight; a second arm for the same id is refused. */
2489
+ const arming = new Set();
2490
+ /** Run fn unless `id` is already in `busy`; returns undefined when refused. */
2491
+ export async function tryExclusive(busy, id, fn) {
2492
+ if (busy.has(id))
2493
+ return undefined;
2494
+ busy.add(id);
2495
+ try {
2496
+ return await fn();
2497
+ }
2498
+ finally {
2499
+ busy.delete(id);
2500
+ }
2501
+ }
2102
2502
  async function arm(m, reason) {
2103
2503
  const { text } = goalText(m);
2104
2504
  /**
@@ -2121,7 +2521,7 @@ async function arm(m, reason) {
2121
2521
  * the thing we are managing is gone, and the right move is to say so and
2122
2522
  * stop, not to keep typing into whatever is there now.
2123
2523
  */
2124
- const live = readSessionContent(m.sessionId, 5);
2524
+ const live = await readSessionContentAsync(m.sessionId, 5);
2125
2525
  if (!live) {
2126
2526
  note(m, "the session could not be read — not typing anything");
2127
2527
  return false;
@@ -2150,7 +2550,7 @@ async function arm(m, reason) {
2150
2550
  * a line occupied for minutes still reaches blockedReason's operator alert
2151
2551
  * (with a screenshot) — the daemon does not stall silently either way.
2152
2552
  */
2153
- const onLine = promptUnsentText(readPane(m.sessionId));
2553
+ const onLine = promptUnsentText(await readPaneAsync(m.sessionId));
2154
2554
  const armIdleMs = Math.max(0, Date.now() - m.lastChangeAt);
2155
2555
  const armIdle = armIdleMs >= HANDOVER_IDLE_FLOOR_MS;
2156
2556
  const prevLine = inputLineTracking.get(m.sessionId);
@@ -2186,24 +2586,28 @@ async function arm(m, reason) {
2186
2586
  // (observed 2026-09-13), and arm() is otherwise fine typing into a
2187
2587
  // busy session (the goal text below queues harmlessly behind it) —
2188
2588
  // it is specifically the escape sequence that is not safe there.
2189
- if (armIdle && needsVimEscape(readPane(m.sessionId))) {
2589
+ if (armIdle && needsVimEscape(await readPaneAsync(m.sessionId))) {
2190
2590
  escapeInputMode(m.sessionId);
2191
2591
  }
2192
2592
  if (!pasteTextIntoSession(m.sessionId, text)) {
2193
2593
  note(m, `could not type into the session (${reason}) — will retry`);
2194
2594
  return false;
2195
2595
  }
2196
- await sleep(300); // let the pane catch up before reading it back
2197
- const echoedLine = promptUnsentText(readPane(m.sessionId, { fresh: true })) ?? "";
2596
+ const { line: echoedLine, waitedMs } = await readBackTyped({
2597
+ read: async () => promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "",
2598
+ sleep,
2599
+ });
2198
2600
  if (!typedLineMatches(echoedLine, text)) {
2199
2601
  sendControlU(m.sessionId);
2200
- note(m, `arm aborted: line read back as "${echoedLine.slice(0, 60)}" not "${text.slice(0, 60)}"`);
2602
+ await sleep(300);
2603
+ const left = promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "";
2604
+ note(m, `arm aborted: after ${waitedMs}ms line read back as "${echoedLine.slice(0, 60)}" not "${text.slice(0, 60)}"; after Ctrl-U the input ${left ? `still holds ${left.length} chars ("${left.slice(0, 60)}")` : "is empty"}`);
2201
2605
  return false;
2202
2606
  }
2203
2607
  sendEnterKey(m.sessionId);
2204
2608
  // Typed is not sent, and sent is not received.
2205
2609
  const landed = await confirmArmed(text, {
2206
- readPane: () => readPane(m.sessionId, { fresh: true }),
2610
+ readPane: () => readPaneAsync(m.sessionId, { fresh: true }),
2207
2611
  sleep,
2208
2612
  clearInput: () => sendControlU(m.sessionId),
2209
2613
  });
@@ -2250,8 +2654,53 @@ export function blockedReason(m, content, now) {
2250
2654
  }
2251
2655
  return null;
2252
2656
  }
2253
- function reasonToArm(m, content, now) {
2254
- if (m.paused)
2657
+ const STOPPED_REASON = "turn ended with nothing running — re-arming";
2658
+ const STOP_SETTLE_MS = 30_000;
2659
+ const STOP_NUDGE_COOLDOWN_MS = 3 * 60_000;
2660
+ // Status-line "N shells still running" / "N background tasks still running" and
2661
+ // the footer's "▶N" worker count — N must be > 0. The footer's "← N agents" and
2662
+ // "· N shells" (no "still running") are always-on, not work, and must not match.
2663
+ const BACKGROUND_RUNNING_RE = /\b[1-9]\d*\s+(?:background\s+)?(?:shells?|tasks?)\s+still\s+running\b|▶\s*[1-9]\d*/i;
2664
+ const LIVE_REGION_LINES = 20;
2665
+ /**
2666
+ * The session's turn ended (last transcript record is an assistant turn with no
2667
+ * tool call pending), settled for STOP_SETTLE_MS, the prompt is empty and
2668
+ * nothing spins or runs in the background. Pure; never true over typed input.
2669
+ */
2670
+ export function turnStoppedIdle(t, paneText, now) {
2671
+ if (!t || t.working !== false || t.lastAt === null || now - t.lastAt < STOP_SETTLE_MS)
2672
+ return false;
2673
+ if (promptUnsentText(paneText) !== null)
2674
+ return false;
2675
+ const last = paneText.split("\n").slice(-LIVE_REGION_LINES).join("\n");
2676
+ return !QUEUED_HINT_RE.test(last) && !SPINNER_RE.test(last) && !BACKGROUND_RUNNING_RE.test(last);
2677
+ }
2678
+ export const GOAL_GONE_REASON = "goal gone before until, re-armed";
2679
+ /** How long one queued arm is awaited before another may be typed. */
2680
+ export const QUEUED_ARM_WAIT_MS = 10 * 60_000;
2681
+ export const GOAL_GONE_BUSY_REASON = "goal gone before until, re-armed (session busy, queued)";
2682
+ /**
2683
+ * Goal gone while the session is mid-turn: Claude Code queues a submitted line
2684
+ * and runs it at the next turn boundary, so arming now interrupts nothing.
2685
+ * Same guards as the idle rule (not paused, until ahead, input line empty) plus
2686
+ * at most one pending arm. Clears the pending mark once the marker is back or
2687
+ * the turn is over.
2688
+ */
2689
+ export function busyGoalGoneReason(m, content, now, busy) {
2690
+ if (!busy || GOAL_ACTIVE.test(content)) {
2691
+ delete m.queuedArmAt;
2692
+ return null;
2693
+ }
2694
+ if (m.queuedArmAt !== undefined && now - m.queuedArmAt < QUEUED_ARM_WAIT_MS)
2695
+ return null;
2696
+ return reasonToArm({ ...m, lastRearmAt: m.lastRearmAt || 1 }, content, now) === GOAL_GONE_REASON
2697
+ ? GOAL_GONE_BUSY_REASON
2698
+ : null;
2699
+ }
2700
+ /** A shift/supervision whose `until` has passed: nothing proactive is typed into the session any more. */
2701
+ export const shiftOver = (m, now) => !!m.shift && now >= m.shift.until;
2702
+ export function reasonToArm(m, content, now) {
2703
+ if (m.paused || shiftOver(m, now))
2255
2704
  return null;
2256
2705
  if (OUT_OF_GOAL.some((re) => re.test(content)))
2257
2706
  return "the session reported its goal could not be achieved";
@@ -2264,6 +2713,11 @@ function reasonToArm(m, content, now) {
2264
2713
  const quietFor = Math.max(0, now - m.lastChangeAt);
2265
2714
  const armedFor = Math.max(0, now - m.lastRearmAt);
2266
2715
  const marker = GOAL_ACTIVE.test(content);
2716
+ // The goal was cleared (the session's own doing, or the evaluator's) while the
2717
+ // operator's stretch is still running: no lapse timer, re-arm on this tick.
2718
+ // Paused is the operator's; an occupied input line is never typed over.
2719
+ if (!marker && m.shift && m.shift.until > now && promptUnsentText(content) === null)
2720
+ return GOAL_GONE_REASON;
2267
2721
  if (!marker && quietFor > NO_GOAL_GRACE_MS)
2268
2722
  return `no goal armed (idle ${Math.round(quietFor / 1000)}s)`;
2269
2723
  /**
@@ -2368,7 +2822,7 @@ async function tick() {
2368
2822
  }
2369
2823
  }
2370
2824
  for (const m of Object.values(state)) {
2371
- const content = readPane(m.sessionId);
2825
+ const content = await readPaneAsync(m.sessionId);
2372
2826
  if (!content) {
2373
2827
  // A session that cannot be read is not necessarily gone; say so once per
2374
2828
  // tick rather than dropping it, because dropping it silently is how a
@@ -2388,13 +2842,17 @@ async function tick() {
2388
2842
  // it once per session per tick is the same reading at a fraction of the
2389
2843
  // cost.
2390
2844
  const tickTty = m.tty ?? snapshotTty(m.sessionId);
2391
- const tickPid = tickTty ? processReading(tickTty).pid : null;
2392
- const tickTranscript = tickPid ? transcriptReading(tickPid) : null;
2393
- const working = sessionIsWorking(tickTranscript, now);
2845
+ const tickProc = tickTty ? processReading(tickTty) : null;
2846
+ const tickPid = tickProc?.pid ?? null;
2847
+ const tickUuid = tickProc?.uuid ?? (tickTty ? discoverLiveSessions().find((s) => s.id === m.sessionId)?.attachUuid : undefined);
2848
+ const tickTranscript = tickPid ? transcriptReading(tickPid, tickUuid) : null;
2849
+ // A turn that ended with nothing running is a stop, not work: the 2-minute
2850
+ // "recent transcript" window must not hide it.
2851
+ const stopped = turnStoppedIdle(tickTranscript, content, now);
2852
+ const working = !stopped && sessionIsWorking(tickTranscript, now);
2394
2853
  const tickUsed = tickTranscript?.contextK ?? null;
2395
- const 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;
2854
+ const tickDir = tickPid ? projectTranscriptDir(tickPid, tickUuid) : null;
2855
+ const tickCwd = tickPid ? sessionProjectCwd(tickPid, tickUuid) : null;
2398
2856
  // Sample context for the fill-rate reading in `status`. Pushed only when
2399
2857
  // a real number is available — never a synthetic 0 for a tick where the
2400
2858
  // transcript could not be read, which would read as a session that
@@ -2812,10 +3270,12 @@ async function tick() {
2812
3270
  const grownBy = used !== null && m.handoverDoneK !== undefined ? used - m.handoverDoneK : null;
2813
3271
  if (decision.due) {
2814
3272
  const notesDirExists = tickCwd ? existsSync(join(tickCwd, "Notes")) : false;
2815
- const usedDefault = !m.handoverFile;
2816
- const askedPath = m.handoverFile
2817
- ? resolveHandoverPath(m.handoverFile)
2818
- : (defaultHandoverTarget(tickCwd, notesDirExists) ?? undefined);
3273
+ const target = pickHandoverTarget({
3274
+ handoverFile: m.handoverFile,
3275
+ sinkDescription: projectSinkDescription(tickCwd),
3276
+ defaultPath: defaultHandoverTarget(tickCwd, notesDirExists),
3277
+ });
3278
+ const askedPath = target.path;
2819
3279
  m.handoverAskedAt = now;
2820
3280
  m.handoverAskedPath = askedPath;
2821
3281
  m.handoverWas = askedPath ? fileFingerprint(askedPath) : undefined;
@@ -2828,7 +3288,9 @@ async function tick() {
2828
3288
  : "";
2829
3289
  const whereClause = askedPath
2830
3290
  ? `Update ${askedPath}. ${carry}`
2831
- : `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. `;
3291
+ : target.description
3292
+ ? `Update ${target.description}. `
3293
+ : `Write it to your project's handover file (its ## Continue section, or wherever this project's convention keeps that) — nothing was set or found automatically, so use your own judgement about where that lives here. `;
2832
3294
  // Wording follows the band: IMMEDIATE has no time to spare and says
2833
3295
  // so; REFRESH and a work-triggered WARM-UP both mean the session has
2834
3296
  // already written one and this is about the work SINCE, not a repeat
@@ -2850,7 +3312,7 @@ async function tick() {
2850
3312
  notify(m, `at ${used}k tokens (${decision.reason})` +
2851
3313
  (grownBy !== null ? `, ${grownBy}k of new work since the last one` : "") +
2852
3314
  ` — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}` +
2853
- (askedPath ? ` — target: ${askedPath}${usedDefault ? " (default — no handoverFile set)" : ""}` : " — no target resolved, asked without naming a path"));
3315
+ (target.source !== "none" ? ` — target (${target.source}): ${askedPath ?? target.description}` : " — no target resolved, asked without naming a path"));
2854
3316
  // The idle age that justified typing into this session now — the
2855
3317
  // un-gated ask reaches every managed session, not just the one
2856
3318
  // opted-in session it used to, so the evidence for "this was a safe
@@ -2887,6 +3349,19 @@ async function tick() {
2887
3349
  * from when it would otherwise fire — it never suppresses one, since a
2888
3350
  * genuinely stuck session goes quiet on the transcript too.
2889
3351
  */
3352
+ // Goal gone mid-turn: queue the arm behind the turn instead of waiting for an idle tick.
3353
+ const queuedReason = busyGoalGoneReason(m, content, now, working && !paneLooksIdle(content, now - m.lastChangeAt));
3354
+ if (queuedReason && now - m.lastRearmAt >= REARM_COOLDOWN_MS) {
3355
+ // Pending whether or not the read-back saw it land: a queued line is invisible until the turn ends.
3356
+ m.queuedArmAt = now;
3357
+ const queued = await tryExclusive(arming, m.sessionId, () => arm(m, queuedReason));
3358
+ if (queued === undefined)
3359
+ delete m.queuedArmAt;
3360
+ else if (!queued)
3361
+ note(m, `${queuedReason} — not confirmed, waiting for the turn to end or the marker`);
3362
+ dirty = true;
3363
+ continue;
3364
+ }
2890
3365
  if (!m.paused && lease) {
2891
3366
  if (working) {
2892
3367
  const hadState = m.blockedSince !== undefined || m.blockedAlertedAt !== undefined || m.stuckSince !== undefined;
@@ -2953,9 +3428,34 @@ async function tick() {
2953
3428
  continue;
2954
3429
  if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
2955
3430
  continue;
2956
- const reason = reasonToArm(m, content, now);
2957
- if (!reason)
3431
+ let reason = reasonToArm(m, content, now);
3432
+ const over = shiftOver(m, now);
3433
+ if (over && m.shift && !m.shift.lapseNoted) {
3434
+ m.shift.lapseNoted = true;
3435
+ note(m, "until passed — no more nudges/arms");
3436
+ dirty = true;
3437
+ }
3438
+ if (stopped && !m.paused && !over && (!reason || reason.startsWith("no goal armed")) && !GOAL_ACTIVE.test(content)) {
3439
+ reason = STOPPED_REASON;
3440
+ }
3441
+ if (!reason && stopped && !m.paused && !over && now - (m.lastNudgeAt ?? 0) >= STOP_NUDGE_COOLDOWN_MS) {
3442
+ // Marker still up but the turn ended on nothing: nudge now, not at the lapse timer.
3443
+ typeIntoSession(m.sessionId, NUDGE_TEXT);
3444
+ m.lastNudgeAt = now;
3445
+ note(m, `${STOPPED_REASON.replace("re-arming", "nudged to continue")}`);
3446
+ dirty = true;
3447
+ continue;
3448
+ }
3449
+ if (!reason) {
3450
+ // Idle at an empty prompt past the `every` interval: a short nudge, not the full goal.
3451
+ if (nudgeDue(m, content, now)) {
3452
+ typeIntoSession(m.sessionId, NUDGE_TEXT);
3453
+ m.lastNudgeAt = now;
3454
+ note(m, `idle ${m.nudgeEveryMin}+ min at an empty prompt — nudged to continue`);
3455
+ dirty = true;
3456
+ }
2958
3457
  continue;
3458
+ }
2959
3459
  /**
2960
3460
  * A GOAL THAT CANNOT BE CONFIRMED IS NOT RETRIED FOREVER.
2961
3461
  *
@@ -2973,7 +3473,9 @@ async function tick() {
2973
3473
  * lapse arms it — and the operator is told, because a manager that has
2974
3474
  * given up on delivering a goal is the one thing it must never do quietly.
2975
3475
  */
2976
- const armed = await arm(m, reason);
3476
+ const armed = await tryExclusive(arming, m.sessionId, () => arm(m, reason));
3477
+ if (armed === undefined)
3478
+ continue; // another arm for this session is in flight
2977
3479
  dirty = true;
2978
3480
  if (armed) {
2979
3481
  m.armFails = 0;
@@ -3012,8 +3514,17 @@ export function startManagerLoop() {
3012
3514
  const n = Object.keys(state).length;
3013
3515
  if (n)
3014
3516
  log(`[manage] resuming ${n} managed session${n > 1 ? "s" : ""}`);
3517
+ let skipLogged = false;
3518
+ const guarded = nonOverlapping(tick, () => {
3519
+ if (skipLogged)
3520
+ return;
3521
+ skipLogged = true;
3522
+ log("[manage] tick still running — skipping overlapping tick");
3523
+ });
3015
3524
  timer = setInterval(() => {
3016
- void tick().catch((e) => log(`[manage] tick failed — ${e.message}`));
3525
+ void guarded()
3526
+ .then(() => { skipLogged = false; })
3527
+ .catch((e) => log(`[manage] tick failed — ${e.message}`));
3017
3528
  }, TICK_MS);
3018
3529
  timer.unref?.();
3019
3530
  }
@@ -3044,8 +3555,13 @@ export function stopManaging(s, sessionId, name) {
3044
3555
  * `/manage pause|resume` stop arming without forgetting the objective
3045
3556
  * `/manage now` arm immediately, whatever the signals say
3046
3557
  */
3047
- export async function handleManage(sessionIdOrName, rawArg) {
3558
+ export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
3048
3559
  const arg = (rawArg ?? "").trim();
3560
+ // Every managed session in one block — before any peer forwarding or
3561
+ // single-session resolution, since "all" names no session.
3562
+ if (sessionIdOrName.trim().toLowerCase() === "all" && (!arg || arg.toLowerCase() === "status")) {
3563
+ return { ok: true, message: statusLines(Object.values(state), Date.now(), contextK).join("\n") };
3564
+ }
3049
3565
  /**
3050
3566
  * `machine/session` is managed by that machine's own hub.
3051
3567
  *
@@ -3069,6 +3585,36 @@ export async function handleManage(sessionIdOrName, rawArg) {
3069
3585
  if (!resolved)
3070
3586
  return { ok: false, message: `no live session matches "${sessionIdOrName}"` };
3071
3587
  const { sessionId, name } = resolved;
3588
+ /**
3589
+ * supervise — a session puts itself under the manager in one call:
3590
+ * {goal, until, every}. goal = standing objective, until = end of the
3591
+ * stretch (clock time or duration), every = idle-nudge interval in minutes.
3592
+ */
3593
+ if (arg.toLowerCase() === "supervise") {
3594
+ const until = opts.until ? parseUntilSpec(opts.until) : null;
3595
+ const every = opts.every !== undefined && opts.every !== "" ? parseEveryMin(opts.every) : null;
3596
+ if (opts.until && until === null)
3597
+ return { ok: false, message: `cannot read until="${opts.until}" — use a clock time (08:00) or a duration (8h, 90m)` };
3598
+ if (opts.every !== undefined && opts.every !== "" && every === null)
3599
+ return { ok: false, message: `every="${opts.every}" must be ${NUDGE_EVERY_MIN}-${NUDGE_EVERY_MAX} minutes` };
3600
+ const goal = (opts.goal ?? "").trim();
3601
+ if (!state[sessionId]) {
3602
+ if (!goal)
3603
+ return { ok: false, message: `${name} is not being managed — supervise needs a goal` };
3604
+ const started = await handleManage(sessionId, `start ${goal}`);
3605
+ if (!started.ok)
3606
+ return started;
3607
+ }
3608
+ const m = state[sessionId];
3609
+ applySupervise(m, goal, until, every);
3610
+ saveState(state);
3611
+ startManagerLoop();
3612
+ return {
3613
+ ok: true,
3614
+ managed: true,
3615
+ message: `${name} is supervised${until !== null ? ` until ${hhmm(until)}` : ""}${m.nudgeEveryMin ? `, nudged after ${m.nudgeEveryMin} min idle` : ""}.\n ${statusLines([m], Date.now(), contextK)[0]}`,
3616
+ };
3617
+ }
3072
3618
  const existing = state[sessionId];
3073
3619
  const word = arg.toLowerCase();
3074
3620
  // help — the grammar, from the thing that implements it.
@@ -3546,7 +4092,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
3546
4092
  }
3547
4093
  /**
3548
4094
  * handover-at <k> — this session's own warm-up margin, overriding the
3549
- * default 100k.
4095
+ * default 50k.
3550
4096
  *
3551
4097
  * A fixed FRACTION of a fixed window cannot be right for every session —
3552
4098
  * that was tried (0.82 of a 1000k window) and the ground moved under it: