aibroker 0.55.1 → 0.56.1

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.
@@ -25,7 +25,7 @@ import { join, dirname } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
  import { log } from "../core/log.js";
27
27
  import { readSessionContent } from "./session-content.js";
28
- import { typeIntoSession } from "../transport/sync-facade.js";
28
+ import { typeIntoSession, pasteTextIntoSession, sendControlU, sendEnterKey, escapeInputMode } from "../transport/sync-facade.js";
29
29
  import { discoverLiveSessions } from "../core/session-discovery.js";
30
30
  import { hasPailotClients } from "../adapters/pailot/gateway.js";
31
31
  import { getAibpBridge } from "../core/state.js";
@@ -77,18 +77,66 @@ const GOAL_MAX_AGE_MS = 45 * 60_000;
77
77
  */
78
78
  const GOAL_ACTIVE = /\/goal\s+active/i;
79
79
  /**
80
- * Where a session is asked to hand over, as a share of its context.
81
- *
82
- * NOT where it dies — where it should stop and write down what it knows while
83
- * it still can. A session at the wall cannot compose a handover, because
84
- * composing one is exactly the sort of work it no longer has room for. The
85
- * margin has to be big enough to write in.
86
- *
87
- * Deliberately conservative. Rolling over early costs one cycle of re-reading a
88
- * file; rolling over late costs everything the session had not written down,
89
- * and that loss is silent — the successor does not know what it was not told.
80
+ * Where a session is asked to hand over — NOT a fixed share of context, a
81
+ * margin BELOW the point it actually compacts at.
82
+ *
83
+ * A fixed fraction of a fixed window was tried and measurement moved the
84
+ * ground under it twice in one investigation: 63 compactions averaged
85
+ * ~1,000k (the full window) before 2026-09-12, then the same configured
86
+ * override (80) started producing compactions at ~784k after. Reading
87
+ * "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80" off the session's own environment
88
+ * predicted NEITHER regime — the configured value did not move, the observed
89
+ * trigger did. So the trigger is now read where possible (measured off the
90
+ * session's own compact_boundary history — see measuredCompactK) rather than
91
+ * assumed, and everything downstream is a MARGIN below whatever that trigger
92
+ * turns out to be, not a fraction of a window that may not be the one in
93
+ * force.
94
+ *
95
+ * NOT where it dies — where it should stop and write down what it knows
96
+ * while it still can. A session at the wall cannot compose a handover,
97
+ * because composing one is exactly the sort of work it no longer has room
98
+ * for. The margin has to be big enough to write in.
99
+ */
100
+ const HANDOVER_MARGIN_K_DEFAULT = 100; // warm-up band: ask this far below the trigger, given time or work done
101
+ const REFRESH_MARGIN_K = 40; // refresh band: ask again if it has grown 40k+ since the last one
102
+ const REFRESH_GROWN_K = 40;
103
+ const IMMEDIATE_MARGIN_K = 15; // immediate band: ask NOW, no gating — this close, waiting is the wrong trade
104
+ /**
105
+ * How long a session must have been idle before ANY band's ask actually
106
+ * fires — un-gating the ask from `handoverFile` widened the blast radius
107
+ * from one opted-in session to every managed one, each ask a typed
108
+ * interruption. A band being satisfied says the trigger is close; it says
109
+ * nothing about whether NOW is a good moment to type into that session, and
110
+ * "idle" is the same question `arm()` already answers for the standing
111
+ * objective. Not zero even for IMMEDIATE — this close to compaction the
112
+ * urgency is real, but typing into a session mid-keystroke is still the one
113
+ * thing that must never happen, so it gets a shorter floor, not none.
114
+ */
115
+ const HANDOVER_IDLE_FLOOR_MS = 60_000;
116
+ const HANDOVER_IDLE_FLOOR_IMMEDIATE_MS = 20_000;
117
+ /**
118
+ * The percentage assumed when NEITHER a measured trigger nor a configured
119
+ * override is available.
120
+ *
121
+ * 80, not 100 (the full window). Two regimes were observed with the SAME
122
+ * configured override (80) in force: compactions averaging ~1,000k before
123
+ * 2026-09-12 and ~784k after — so the configured value does not predict the
124
+ * trigger either way, and there is no reading available that has earned the
125
+ * assumption of a full window. What is known is that the two ways to be
126
+ * wrong here cost differently: assuming too LOW wastes one summary; assuming
127
+ * too HIGH is a session that compacts before its handover lands, which loses
128
+ * work that cannot be recovered. 80 is the cheap-failure side of that
129
+ * asymmetry, not a measurement — this was tried at 100 first and the data
130
+ * argued it back down.
90
131
  */
91
- const HANDOVER_AT = 0.82;
132
+ const ASSUMED_OVERRIDE_PCT = 80;
133
+ /** Lowest and highest a session's own margin override may be set to, in K. */
134
+ const MARGIN_K_MIN = 20;
135
+ const MARGIN_K_MAX = 400;
136
+ /** The warm-up margin actually in force for a session — its own, or the default. */
137
+ function resolvedMarginK(m) {
138
+ return m.handoverMarginK ?? HANDOVER_MARGIN_K_DEFAULT;
139
+ }
92
140
  /**
93
141
  * How long the manager waits for the handover before giving up on it.
94
142
  *
@@ -393,6 +441,265 @@ function fileFingerprint(path) {
393
441
  return "";
394
442
  }
395
443
  }
444
+ /**
445
+ * Real compaction points, read straight off a project's own transcripts —
446
+ * ground truth, where a configured override or an assumed default are both
447
+ * inferences about what OUGHT to happen. Shape, from a live transcript
448
+ * (~/.claude/projects/<cwd-slug>/*.jsonl), confirmed against a real file
449
+ * carrying one of these events:
450
+ *
451
+ * { type: "system", subtype: "compact_boundary", timestamp: "2026-09-13T08:34:18.927Z",
452
+ * compactMetadata: { trigger: "auto", preTokens: 784066, postTokens: 31163, ... } }
453
+ *
454
+ * Takes the MINIMUM of the most recent three, not the average or the latest
455
+ * alone, because the two ways to be wrong here cost differently: a trigger
456
+ * estimated too LOW costs one wasted summary; estimated too HIGH is a
457
+ * session that compacts before its handover lands, which is not recoverable.
458
+ * The minimum of a small recent sample is the cheap-failure estimate, same
459
+ * reasoning as ASSUMED_OVERRIDE_PCT below.
460
+ */
461
+ export function measuredCompactK(lines) {
462
+ const events = [];
463
+ for (const line of lines) {
464
+ if (!line.trim())
465
+ continue;
466
+ try {
467
+ const j = JSON.parse(line);
468
+ if (j.type === "system" && j.subtype === "compact_boundary" && typeof j.compactMetadata?.preTokens === "number") {
469
+ events.push({ at: j.timestamp ?? "", preTokens: j.compactMetadata.preTokens });
470
+ }
471
+ }
472
+ catch { /* not JSON, or a truncated line from a tail — skip */ }
473
+ }
474
+ if (!events.length)
475
+ return undefined;
476
+ // Most recent first. String comparison is safe here: every event carries
477
+ // an ISO-8601 timestamp, which sorts lexicographically in time order.
478
+ events.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
479
+ const recent = events.slice(0, 3);
480
+ const k = Math.round(Math.min(...recent.map((e) => e.preTokens)) / 1000);
481
+ return { k, events: recent };
482
+ }
483
+ /**
484
+ * The compaction point to plan around, in K — MEASURED when a project has
485
+ * its own compact_boundary history, otherwise a percentage of the window
486
+ * (a configured override, or ASSUMED_OVERRIDE_PCT).
487
+ */
488
+ export function effectiveCompactK(input) {
489
+ const configuredK = (input.windowK * (input.overridePct ?? ASSUMED_OVERRIDE_PCT)) / 100;
490
+ // A measurement is only trusted downwards. A project whose newest compaction
491
+ // predates a regime change measures a stale HIGH value (seen: 998k against a
492
+ // real boundary of ~784k), which would push every band above the real
493
+ // trigger and never fire. Lower than configured is the finding we want;
494
+ // higher than configured is history, not the present.
495
+ if (input.measuredK !== undefined)
496
+ return Math.min(input.measuredK, configuredK);
497
+ return configuredK;
498
+ }
499
+ /** Which band a reading falls in, against the three margins below the trigger. */
500
+ export function bandOf(usedK, effectiveK, marginK) {
501
+ if (usedK >= effectiveK - IMMEDIATE_MARGIN_K)
502
+ return "immediate";
503
+ if (usedK >= effectiveK - REFRESH_MARGIN_K)
504
+ return "refresh";
505
+ if (usedK >= effectiveK - marginK)
506
+ return "warm-up";
507
+ return "below";
508
+ }
509
+ /**
510
+ * Whether a handover is due — the pure decision, extracted so it is testable
511
+ * without a pane, a process table, or a transcript file.
512
+ *
513
+ * THREE BANDS below the trigger, checked most-urgent first, because a high
514
+ * enough reading qualifies for more than one and the most urgent is the one
515
+ * that should win:
516
+ *
517
+ * IMMEDIATE (trigger − 15k) — no gating at all. This close, waiting for a
518
+ * time or work signal is the wrong trade; the cost of asking again for
519
+ * nothing is one redundant note, the cost of NOT asking is the session
520
+ * compacting before it writes anything down.
521
+ *
522
+ * REFRESH (trigger − 40k) — due once REFRESH_GROWN_K of new work has
523
+ * landed since the last handover, regardless of the clock. A handover
524
+ * this close to the trigger that predates a lot of new work is stale
525
+ * exactly where staleness costs the most.
526
+ *
527
+ * WARM-UP (trigger − marginK, default 100k) — the ordinary case, gated the
528
+ * same way as before this was split into bands: due by the CLOCK
529
+ * (`lastAskAt` old enough) or by WORK done since the last one.
530
+ *
531
+ * `contextK: undefined` — no reading available — returns due:false with a
532
+ * named reason rather than being coerced through `0`, which would read as
533
+ * "no context used" and could never cross any band. That silent failure
534
+ * mode is exactly what a `number | null` reading treated as falsy would
535
+ * produce; keeping it as its own case is the fix.
536
+ *
537
+ * `idleMs` gates every band separately from the band itself: a band being
538
+ * satisfied says the trigger is close, not that this is a safe moment to
539
+ * type. A band that is due but not idle enough reports due:false with a
540
+ * reason naming which band it was and how idle the session actually was, so
541
+ * the log reads as "was about to ask, held off" rather than "never
542
+ * qualified" — a real distinction for anyone debugging why an ask was late.
543
+ */
544
+ export function handoverDue(input) {
545
+ if (input.contextK === undefined)
546
+ return { due: false, reason: "context unknown" };
547
+ const used = input.contextK;
548
+ const marginK = input.marginK ?? HANDOVER_MARGIN_K_DEFAULT;
549
+ const immediateK = input.effectiveK - IMMEDIATE_MARGIN_K;
550
+ const refreshK = input.effectiveK - REFRESH_MARGIN_K;
551
+ const warmUpK = input.effectiveK - marginK;
552
+ const idleSec = Math.round(input.idleMs / 1000);
553
+ if (used >= immediateK) {
554
+ if (input.idleMs < HANDOVER_IDLE_FLOOR_IMMEDIATE_MS) {
555
+ return { due: false, reason: `band immediate but busy (idle ${idleSec}s)` };
556
+ }
557
+ return { due: true, reason: "immediate" };
558
+ }
559
+ const grownBy = input.handoverDoneK !== undefined ? used - input.handoverDoneK : undefined;
560
+ if (used >= refreshK && grownBy !== undefined && grownBy >= REFRESH_GROWN_K) {
561
+ if (input.idleMs < HANDOVER_IDLE_FLOOR_MS) {
562
+ return { due: false, reason: `band refresh but busy (idle ${idleSec}s)` };
563
+ }
564
+ return { due: true, reason: "refresh" };
565
+ }
566
+ const sinceLast = input.now - (input.lastAskAt ?? 0);
567
+ const dueByTime = sinceLast > HANDOVER_REASK_MS;
568
+ const dueByWork = grownBy !== undefined && grownBy >= HANDOVER_REASK_K && sinceLast > HANDOVER_MIN_GAP_MS;
569
+ if (used >= warmUpK && (dueByTime || dueByWork)) {
570
+ if (input.idleMs < HANDOVER_IDLE_FLOOR_MS) {
571
+ return { due: false, reason: `band warm-up but busy (idle ${idleSec}s)` };
572
+ }
573
+ return {
574
+ due: true,
575
+ reason: dueByWork && !dueByTime ? "warm-up — work done since the last handover" : "warm-up",
576
+ };
577
+ }
578
+ return { due: false, reason: `below the warm-up band (${used}k < ${Math.round(warmUpK)}k)` };
579
+ }
580
+ /**
581
+ * The rate context is filling, from recent readings — first/last over a
582
+ * trailing window rather than every point, because the question this answers
583
+ * ("how fast, right now") is about the recent slope, not a session's whole
584
+ * history. Undefined with fewer than two readings inside the window: a rate
585
+ * needs two points, and guessing one from a single sample is worse than
586
+ * saying nothing.
587
+ */
588
+ export function contextSlope(samples, windowMs = 600_000, now = Date.now()) {
589
+ const cutoff = now - windowMs;
590
+ const windowed = samples.filter((s) => s.at >= cutoff).sort((a, b) => a.at - b.at);
591
+ if (windowed.length < 2) {
592
+ return { kPerMin: undefined, minutesTo: () => undefined };
593
+ }
594
+ const first = windowed[0];
595
+ const last = windowed[windowed.length - 1];
596
+ const dtMin = (last.at - first.at) / 60_000;
597
+ if (dtMin <= 0)
598
+ return { kPerMin: undefined, minutesTo: () => undefined };
599
+ const kPerMin = (last.k - first.k) / dtMin;
600
+ return {
601
+ kPerMin,
602
+ minutesTo(targetK) {
603
+ if (kPerMin <= 0)
604
+ return undefined; // flat or falling — no ETA to give
605
+ const remaining = targetK - last.k;
606
+ if (remaining <= 0)
607
+ return 0;
608
+ return remaining / kPerMin;
609
+ },
610
+ };
611
+ }
612
+ /**
613
+ * Does a line read back off the pane actually carry the text just typed?
614
+ *
615
+ * The verification step in the read-back-first typing sequence (see arm()):
616
+ * type with no newline, read the pane again, confirm before sending CR. Two
617
+ * real failures this catches: a stray character surviving from something the
618
+ * terminal did not fully clear, and — observed on 2026-09-13 — a long `/goal
619
+ * …` line folding in the terminal and landing as a pasted message instead of
620
+ * a slash command, which a length- or hash-based check would not catch but a
621
+ * literal prefix match does.
622
+ *
623
+ * Tolerant of a leading `❯` prompt marker and trailing whitespace, because
624
+ * `readBack` may be a raw line straight off the pane rather than one already
625
+ * run through promptUnsentText's own stripping.
626
+ */
627
+ export function typedLineMatches(readBack, intended) {
628
+ const cleaned = readBack.replace(/^\s*❯\s*/, "").trimEnd();
629
+ return cleaned.startsWith(intended.trimEnd());
630
+ }
631
+ /**
632
+ * Whether the pane shows Claude Code's vim-keybinding status indicator — the
633
+ * ONLY condition under which escapeInputMode's 'i' keystroke means anything.
634
+ *
635
+ * Sending 'i' on a pane WITHOUT vim mode enabled is not neutral: nothing
636
+ * intercepts it as a modal command, so it lands as a literal character in an
637
+ * ordinary input line — this project's own reference note on typing into a
638
+ * Claude pane records exactly that ("a stray `i` lands literally"). Sent
639
+ * unconditionally, the read-back-first sequence would then never see what it
640
+ * typed match what it reads back, abort with Ctrl-U every time, and the
641
+ * session would never arm — silently and permanently, on every non-vim
642
+ * session. Checking the indicator before ever sending the keystroke is what
643
+ * keeps the same sequence safe on both.
644
+ */
645
+ export function needsVimEscape(paneText) {
646
+ return /--\s*(INSERT|NORMAL)\s*--/.test(paneText);
647
+ }
648
+ /**
649
+ * Whether a pane's tab title names a Claude session rather than a bare shell.
650
+ * iTerm titles encode the foreground process: idle Claude tabs report
651
+ * "(claude)", busy ones "(node)"; both are the session, only a title with
652
+ * neither is a shell. `atPrompt` alone cannot tell them apart, because an
653
+ * idle Claude pane sits at its own input line too.
654
+ */
655
+ export function isClaudePane(name) {
656
+ return (!!name &&
657
+ (name.toLowerCase().includes("claude") || name.includes("(node)") || name.includes("(npm)") || name.includes("(bun)")));
658
+ }
659
+ /**
660
+ * What to do about text already sitting in a session's input line, before
661
+ * typing anything — the pure decision behind the read-back-first sequence in
662
+ * arm() and send_to_session.
663
+ *
664
+ * NEVER type on top of live input and never clear it on sight: typing over
665
+ * it mangles two things into one prompt, and clearing on sight was tried
666
+ * once already and reverted (see test/manage-unsent-prompt.test.ts's own
667
+ * header) because a terminal's greyed-out Tab-completion suggestion cannot
668
+ * be told apart from someone mid-sentence in a captured pane — a real
669
+ * sentence eaten by an over-eager clear is worse than a delayed handover.
670
+ *
671
+ * So: EMPTY types immediately. Anything else is only ever SKIPPED — retried
672
+ * next tick, 20s away — unless the IDENTICAL text has now persisted for
673
+ * `GHOST_TICKS` consecutive ticks (2 minutes) while the session is idle, at
674
+ * which point it reads as an abandoned ghost rather than someone typing, and
675
+ * only then is it cleared. Text that changes between ticks is someone
676
+ * typing, full stop — the caller resets `sameForTicks` to 1 on any change,
677
+ * which alone keeps this skipping for as long as that keeps happening.
678
+ *
679
+ * `idleMs` and `band` are threaded through into the return value rather than
680
+ * only consumed here so the caller can log the evidence for a clear-then-type
681
+ * decision without recomputing it — a decision this rare is worth a complete
682
+ * log line, not a re-derivation from parts scattered across the caller.
683
+ */
684
+ const GHOST_TICKS = 6; // 6 * TICK_MS(20s) = 2 minutes
685
+ export function inputLineDecision(input) {
686
+ const { text, sameForTicks, idle, idleMs, band } = input;
687
+ if (!text)
688
+ return { action: "type", sameForTicks, idleMs, band };
689
+ if (sameForTicks >= GHOST_TICKS && idle) {
690
+ const totalSec = sameForTicks * (TICK_MS / 1000);
691
+ const durationLabel = `${Math.floor(totalSec / 60)}m${String(Math.round(totalSec % 60)).padStart(2, "0")}s`;
692
+ return {
693
+ action: "clear-then-type",
694
+ logCleared: `clearing persistent input-line text before typing: "${text}" ` +
695
+ `(identical for ${sameForTicks} ticks / ${durationLabel}, idle ${Math.round((idleMs ?? 0) / 1000)}s, band=${band ?? "unknown"})`,
696
+ sameForTicks,
697
+ idleMs,
698
+ band,
699
+ };
700
+ }
701
+ return { action: "skip", sameForTicks, idleMs, band };
702
+ }
396
703
  /** This session's context in thousands of tokens, from its own transcript. */
397
704
  function contextK(m) {
398
705
  const tty = m.tty ?? snapshotTty(m.sessionId);
@@ -661,6 +968,21 @@ let lastReportAt = 0;
661
968
  * while looking like that one.
662
969
  */
663
970
  const armingsSinceReport = new Map();
971
+ /**
972
+ * Rate-limiting state for the per-tick diagnostic context/trigger/band line —
973
+ * NOT persisted, same reasoning as armingsSinceReport: it answers "did this
974
+ * change since the last time it was logged", which only means anything
975
+ * against readings from the same daemon run.
976
+ */
977
+ const lastContextLog = new Map();
978
+ /**
979
+ * How many CONSECUTIVE arm() calls have seen the identical text sitting
980
+ * unsent in a session's input line — the memory inputLineDecision's
981
+ * `sameForTicks` needs to tell a ghost from someone mid-sentence. NOT
982
+ * persisted: a daemon restart losing this only delays a ghost-clear by up to
983
+ * GHOST_TICKS more ticks, never causes one to fire early.
984
+ */
985
+ const inputLineTracking = new Map();
664
986
  /**
665
987
  * The periodic reading: what every managed session is doing, in one message.
666
988
  *
@@ -790,6 +1112,37 @@ function processReading(tty) {
790
1112
  return { isSession: false, pid: null };
791
1113
  return { isSession: true, pid: claude.pid };
792
1114
  }
1115
+ /**
1116
+ * Pure extraction of context usage from raw transcript JSONL lines.
1117
+ *
1118
+ * Split out of transcriptReading() so the one rule that matters here — NO
1119
+ * usage on the tail means the context reading is UNKNOWN, never a claimed
1120
+ * zero — is checkable without a process table, lsof, or a file on disk.
1121
+ *
1122
+ * `undefined` covers every case where nothing can be said: no parseable
1123
+ * lines, no assistant message on the tail, or an assistant message whose
1124
+ * `usage` field is absent. A real reading of zero tokens (a brand new
1125
+ * session) is not this case and is returned as `0`, which is why the
1126
+ * distinction is undefined-vs-number rather than falsy-vs-truthy.
1127
+ */
1128
+ export function usageFromLines(lines) {
1129
+ const msgs = [];
1130
+ for (const line of lines) {
1131
+ if (!line.trim())
1132
+ continue;
1133
+ try {
1134
+ const j = JSON.parse(line);
1135
+ if (j.type === "assistant" || j.type === "user")
1136
+ msgs.push(j);
1137
+ }
1138
+ catch { /* a truncated first line is normal when tailing */ }
1139
+ }
1140
+ const lastAssistant = [...msgs].reverse().find((m) => m.type === "assistant");
1141
+ const u = lastAssistant?.message?.usage;
1142
+ if (!u)
1143
+ return undefined;
1144
+ return Math.round(((u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)) / 1000);
1145
+ }
793
1146
  /**
794
1147
  * What the session is doing, from its own transcript — the authority.
795
1148
  *
@@ -813,20 +1166,56 @@ function processReading(tty) {
813
1166
  * - WHEN: the entry's timestamp, so "how long has this been going" is a
814
1167
  * subtraction rather than a guess.
815
1168
  */
816
- function transcriptReading(claudePid) {
817
- const none = { working: null, doing: null, contextK: null, lastAt: null };
1169
+ /**
1170
+ * A running process's cwd, from the operating system — the actual authority
1171
+ * on where a session is rooted (a name or a config value could lag a `cd`).
1172
+ * Shared by everything below that needs it, so it is resolved with one lsof
1173
+ * call per session per tick rather than once per caller.
1174
+ */
1175
+ function sessionCwd(claudePid) {
818
1176
  try {
819
- // The transcript directory is named for the session's working directory,
820
- // which the process itself is the authority on.
821
1177
  const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", claudePid, "-a", "-d", "cwd", "-Fn"], {
822
1178
  encoding: "utf8",
823
1179
  timeout: 4_000,
824
1180
  });
825
- const cwd = cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1);
826
- if (!cwd)
827
- return none;
828
- const dir = join(homedir(), ".claude", "projects", cwd.replace(/\//g, "-"));
829
- if (!existsSync(dir))
1181
+ return cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1) ?? null;
1182
+ }
1183
+ catch {
1184
+ return null;
1185
+ }
1186
+ }
1187
+ /**
1188
+ * The transcript directory for a session's own project, derived from its cwd.
1189
+ * Shared by transcriptReading (the live session's own tail) and the
1190
+ * measured-compaction reading (every .jsonl in the project, not only the
1191
+ * live one).
1192
+ */
1193
+ function projectTranscriptDir(claudePid) {
1194
+ const cwd = sessionCwd(claudePid);
1195
+ if (!cwd)
1196
+ return null;
1197
+ const dir = join(homedir(), ".claude", "projects", cwd.replace(/\//g, "-"));
1198
+ return existsSync(dir) ? dir : null;
1199
+ }
1200
+ /**
1201
+ * Where a handover goes when nobody has named a file — the convention this
1202
+ * project already uses without the manager's help, so a session asked cold
1203
+ * has somewhere sane to write rather than nowhere at all.
1204
+ *
1205
+ * `notesDirExists` is passed in rather than checked here so this stays pure
1206
+ * and testable without touching a filesystem: the caller does one existsSync
1207
+ * and hands in the answer.
1208
+ */
1209
+ export function defaultHandoverTarget(cwd, notesDirExists) {
1210
+ if (!cwd || !notesDirExists)
1211
+ return null;
1212
+ return join(cwd, "Notes", "TODO.md");
1213
+ }
1214
+ function transcriptReading(claudePid) {
1215
+ const none = { working: null, doing: null, contextK: null, lastAt: null };
1216
+ try {
1217
+ const dir = projectTranscriptDir(claudePid);
1218
+ if (!dir)
830
1219
  return none;
831
1220
  // The live transcript is the one being written. Newest wins; a session that
832
1221
  // has not written for a long time will show that in its own timestamp
@@ -869,16 +1258,134 @@ function transcriptReading(claudePid) {
869
1258
  : last.type === "user"
870
1259
  ? "waiting on a tool result"
871
1260
  : null;
872
- const u = lastAssistant?.message?.usage;
873
- const contextK = u
874
- ? Math.round(((u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)) / 1000)
875
- : null;
1261
+ // Same rule as usageFromLines(): no usage on the tail is UNKNOWN, not a
1262
+ // reading of zero. Re-parses the same raw text rather than reusing `msgs`
1263
+ // above so the pure extraction stays the single source of truth for what
1264
+ // counts as "no usage" — duplicating the parse is cheap against 40 lines.
1265
+ const contextK = usageFromLines(raw.split("\n")) ?? null;
876
1266
  return { working, doing, contextK, lastAt };
877
1267
  }
878
1268
  catch {
879
1269
  return none;
880
1270
  }
881
1271
  }
1272
+ const overridePctCache = new Map();
1273
+ /**
1274
+ * `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`, read from where it actually governs the
1275
+ * running session — its own process environment — falling back to the
1276
+ * config file a fresh process would inherit it from. Cached for a minute per
1277
+ * pid: `ps -E` is not free to run every 20-second tick for every managed
1278
+ * session, and this value does not change inside a running process.
1279
+ */
1280
+ function readOverridePct(pid) {
1281
+ const key = pid ?? "no-pid";
1282
+ const cached = overridePctCache.get(key);
1283
+ if (cached && Date.now() - cached.at < 60_000)
1284
+ return cached.reading;
1285
+ let reading;
1286
+ if (pid) {
1287
+ try {
1288
+ // macOS `ps -E` appends the process environment after the command.
1289
+ const out = execFileSync("/bin/ps", ["-E", "-p", pid, "-o", "command="], {
1290
+ encoding: "utf8",
1291
+ timeout: 4_000,
1292
+ });
1293
+ const m = out.match(/CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=(\d+)/);
1294
+ if (m)
1295
+ reading = { pct: Number(m[1]), source: "session env" };
1296
+ }
1297
+ catch { /* process gone, or ps refused — fall through to the config file */ }
1298
+ }
1299
+ if (!reading) {
1300
+ try {
1301
+ const settings = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8"));
1302
+ const v = settings?.env?.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
1303
+ if (v !== undefined && v !== null && v !== "")
1304
+ reading = { pct: Number(v), source: "settings.json" };
1305
+ }
1306
+ catch { /* no settings file, or unreadable — assumed default applies */ }
1307
+ }
1308
+ overridePctCache.set(key, { at: Date.now(), reading });
1309
+ return reading;
1310
+ }
1311
+ const measuredCompactCache = new Map();
1312
+ /**
1313
+ * measuredCompactK, wired to a project directory — every .jsonl in it, not
1314
+ * only the session's own live transcript, because the last three
1315
+ * compactions for this PROJECT may belong to sessions that have since ended.
1316
+ *
1317
+ * Bounded to the five most recently modified files and a tail of each,
1318
+ * rather than reading whole transcripts that reach tens of megabytes: recent
1319
+ * compactions are, definitionally, recent, so they live in recently-touched
1320
+ * files near their own end. Cached by the summed mtime of those files so a
1321
+ * tick where nothing in the project changed does no file I/O at all — the
1322
+ * cache mostly does NOT help the live file, whose mtime moves most ticks,
1323
+ * but it is nearly free to keep and helps every other managed session
1324
+ * sharing this call.
1325
+ */
1326
+ function measuredCompactForProject(dir) {
1327
+ let entries;
1328
+ try {
1329
+ entries = readdirSync(dir)
1330
+ .filter((f) => f.endsWith(".jsonl"))
1331
+ .map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs }))
1332
+ .sort((a, b) => b.m - a.m)
1333
+ .slice(0, 5);
1334
+ }
1335
+ catch {
1336
+ return undefined;
1337
+ }
1338
+ if (!entries.length)
1339
+ return undefined;
1340
+ const mtimeSum = entries.reduce((s, e) => s + e.m, 0);
1341
+ const cached = measuredCompactCache.get(dir);
1342
+ if (cached && cached.mtimeSum === mtimeSum)
1343
+ return cached.result;
1344
+ const lines = [];
1345
+ for (const e of entries) {
1346
+ try {
1347
+ const raw = execFileSync("/usr/bin/tail", ["-n", "500", join(dir, e.f)], {
1348
+ encoding: "utf8",
1349
+ timeout: 4_000,
1350
+ maxBuffer: 8 * 1024 * 1024,
1351
+ });
1352
+ lines.push(...raw.split("\n"));
1353
+ }
1354
+ catch { /* file raced a delete, or tail refused — skip it */ }
1355
+ }
1356
+ const result = measuredCompactK(lines);
1357
+ measuredCompactCache.set(dir, { mtimeSum, result });
1358
+ return result;
1359
+ }
1360
+ /**
1361
+ * The compaction point to plan a session's handover around, and a one-line
1362
+ * account of where that number came from — printed in `status` and in the
1363
+ * per-tick log line, because a number nobody can trace back to a source is a
1364
+ * number nobody can debug when it is wrong again.
1365
+ */
1366
+ function compactionReading(m, pid, dir) {
1367
+ const windowK = m.contextWindowK ?? 1000;
1368
+ const measured = dir ? measuredCompactForProject(dir) : undefined;
1369
+ const override = readOverridePct(pid);
1370
+ const effectiveK = effectiveCompactK({ windowK, overridePct: override?.pct, measuredK: measured?.k });
1371
+ let label;
1372
+ const configuredK = effectiveCompactK({ windowK, overridePct: override?.pct });
1373
+ if (measured && measured.k > configuredK) {
1374
+ const src = override ? `${override.source} override=${override.pct}` : `assumed ${ASSUMED_OVERRIDE_PCT}`;
1375
+ label = `${src}; measured ${measured.k.toLocaleString("en-US")}k is stale-high and ignored`;
1376
+ }
1377
+ else if (measured) {
1378
+ const vals = measured.events.map((e) => e.preTokens.toLocaleString("en-US")).join(" / ");
1379
+ label = `measured: min of ${measured.events.length} event${measured.events.length > 1 ? "s" : ""} ${vals} in this project`;
1380
+ }
1381
+ else if (override) {
1382
+ label = `${override.source} override=${override.pct}`;
1383
+ }
1384
+ else {
1385
+ label = `assumed ${ASSUMED_OVERRIDE_PCT}, no history, no override`;
1386
+ }
1387
+ return { effectiveK, label };
1388
+ }
882
1389
  /**
883
1390
  * The status line, assembled from the sources in order of authority.
884
1391
  *
@@ -892,7 +1399,7 @@ function transcriptReading(claudePid) {
892
1399
  * which number came from the transcript and which was scraped off a status bar
893
1400
  * cannot tell which one to doubt.
894
1401
  */
895
- function liveReading(sessionId, idleSec) {
1402
+ function liveReading(sessionId, idleSec, m) {
896
1403
  const snap = discoverLiveSessions().find((s) => s.id === sessionId);
897
1404
  const proc = snap?.tty ? processReading(snap.tty) : { isSession: false, pid: null };
898
1405
  if (!proc.isSession) {
@@ -904,8 +1411,30 @@ function liveReading(sessionId, idleSec) {
904
1411
  const agoSec = Math.round((Date.now() - t.lastAt) / 1000);
905
1412
  out.push(` ${t.working ? "working" : "idle"} · last transcript entry ${agoSec < 90 ? `${agoSec}s` : `${Math.round(agoSec / 60)} min`} ago` +
906
1413
  (t.doing ? ` · ${t.doing}` : ""));
907
- if (t.contextK !== null)
1414
+ if (t.contextK !== null) {
908
1415
  out.push(` context ${t.contextK}k tokens (from the transcript's own usage, not the status bar)`);
1416
+ // The trigger, fill rate and ETA are only meaningful alongside a
1417
+ // session's own margin, so all three are printed together and only
1418
+ // when a managed session (`m`) is available to supply it.
1419
+ if (m) {
1420
+ const dir = proc.pid ? projectTranscriptDir(proc.pid) : null;
1421
+ const { effectiveK, label } = compactionReading(m, proc.pid, dir);
1422
+ const marginK = resolvedMarginK(m);
1423
+ const warmUpK = Math.round(effectiveK - marginK);
1424
+ out.push(` compaction trigger ≈${Math.round(effectiveK)}k (${label})`);
1425
+ const slope = contextSlope(m.contextSamples ?? []);
1426
+ if (slope.kPerMin === undefined) {
1427
+ out.push(` filling rate unknown (not enough recent readings) · handover from ${warmUpK}k`);
1428
+ }
1429
+ else {
1430
+ const eta = slope.minutesTo(warmUpK);
1431
+ out.push(` filling ~${slope.kPerMin >= 0 ? "" : "-"}${Math.abs(Math.round(slope.kPerMin))}k/min` +
1432
+ (eta === undefined
1433
+ ? ` · handover from ${warmUpK}k (not approaching it)`
1434
+ : ` · ~${Math.round(eta)} min to handover at ${warmUpK}k`));
1435
+ }
1436
+ }
1437
+ }
909
1438
  }
910
1439
  else {
911
1440
  out.push(` a session is running, but its transcript could not be read — falling back to the screen`);
@@ -1355,45 +1884,95 @@ async function arm(m, reason) {
1355
1884
  * A goal whose wording happened to begin a line with a real command would
1356
1885
  * have run it, in the operator's own shell, with no confirmation.
1357
1886
  *
1358
- * `atPrompt` is exactly the discriminator: it is false for a session running
1359
- * Claude — the foreground process is node whether it is working or idle — and
1360
- * true when the shell itself is waiting for input. So true means the thing we
1361
- * are managing is gone, and the right move is to say so and stop, not to keep
1362
- * typing into whatever is there now.
1887
+ * `atPrompt` was first taken as the whole discriminator, but it is not: an
1888
+ * idle Claude pane sits at its own input line and reports atPrompt too, and
1889
+ * a manager armed against it would pause claiming an exit that never
1890
+ * happened. The tab title carries what atPrompt cannot — an idle Claude tab
1891
+ * shows "(claude)", a busy one "(node)", and only a shell shows neither
1892
+ * (see isClaudePane). So atPrompt with a title that names no session means
1893
+ * the thing we are managing is gone, and the right move is to say so and
1894
+ * stop, not to keep typing into whatever is there now.
1363
1895
  */
1364
1896
  const live = readSessionContent(m.sessionId, 5);
1365
1897
  if (!live) {
1366
1898
  note(m, "the session could not be read — not typing anything");
1367
1899
  return false;
1368
1900
  }
1369
- if (live.atPrompt) {
1901
+ if (live.atPrompt && !isClaudePane(live.name)) {
1370
1902
  m.paused = true;
1371
1903
  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.");
1372
1904
  return false;
1373
1905
  }
1374
1906
  /**
1375
- * TEXT ON THE INPUT LINE IS NOTED, NEVER OBEYED.
1907
+ * TEXT ON THE INPUT LINE — READ BACK FIRST, NEVER DESTROY LIVE INPUT.
1376
1908
  *
1377
- * This used to refuse to arm while anything sat unsent in the prompt, to
1378
- * avoid running the manager's goal into a half-typed sentence. The intention
1379
- * was right and the mechanism could not support it: the terminal offers a
1380
- * greyed-out SUGGESTION on that same line, accepted with Tab, and in a
1381
- * captured pane no colour survives to tell the two apart. So a suggestion
1382
- * read as somebody mid-sentence, and since a suggestion never finishes being
1383
- * typed, the refusal never lifted. A session sat idle with its goal spent and
1384
- * its work unfinished while every log line reported the guard working.
1909
+ * An outright refusal was tried before and reverted (see the header of
1910
+ * test/manage-unsent-prompt.test.ts): the terminal offers a greyed-out
1911
+ * SUGGESTION on the same line, accepted with Tab, and no colour survives a
1912
+ * pane capture to tell it apart from somebody mid-sentence. An earlier pass
1913
+ * of THIS change reinstated that same refusal outright and was corrected
1914
+ * mid-flight — clearing on sight, or refusing forever, both risk exactly
1915
+ * the failure the revert was for.
1385
1916
  *
1386
- * Arming is the one thing that must not be blocked by a signal this weak. A
1387
- * stalled agent is certain and unbounded; running into somebody's half-typed
1388
- * line is occasional and costs one prompt they can retype. So the reading is
1389
- * kept — it is worth having in the record when a goal arrives mangled — and
1390
- * it decides nothing.
1917
+ * So: empty line types immediately (below). Anything else is only ever
1918
+ * SKIPPED and retried next tick — never cleared, never typed over —
1919
+ * unless the IDENTICAL text has sat there for GHOST_TICKS consecutive
1920
+ * ticks (2 minutes) while the session is idle, which is inputLineDecision's
1921
+ * job to decide. Skips still count toward armFails/armFailStreak below, so
1922
+ * a line occupied for minutes still reaches blockedReason's operator alert
1923
+ * (with a screenshot) — the daemon does not stall silently either way.
1391
1924
  */
1392
1925
  const onLine = promptUnsentText(readPane(m.sessionId));
1393
- if (!typeIntoSession(m.sessionId, text)) {
1926
+ const armIdleMs = Math.max(0, Date.now() - m.lastChangeAt);
1927
+ const armIdle = armIdleMs >= HANDOVER_IDLE_FLOOR_MS;
1928
+ const prevLine = inputLineTracking.get(m.sessionId);
1929
+ const sameForTicks = onLine && prevLine && prevLine.text === onLine ? prevLine.sameForTicks + 1 : 1;
1930
+ if (onLine)
1931
+ inputLineTracking.set(m.sessionId, { text: onLine, sameForTicks });
1932
+ else
1933
+ inputLineTracking.delete(m.sessionId);
1934
+ const lineDecision = inputLineDecision({ text: onLine ?? "", sameForTicks, idle: armIdle, idleMs: armIdleMs });
1935
+ if (lineDecision.action === "skip") {
1936
+ note(m, `arm deferred: input line holds '${onLine}' (unsent for ${sameForTicks} tick${sameForTicks === 1 ? "" : "s"}, idle ${Math.round(armIdleMs / 1000)}s)`);
1937
+ return false;
1938
+ }
1939
+ if (lineDecision.action === "clear-then-type") {
1940
+ note(m, lineDecision.logCleared ?? "clearing persistent input-line text before typing");
1941
+ sendControlU(m.sessionId);
1942
+ inputLineTracking.delete(m.sessionId);
1943
+ }
1944
+ // TYPE, READ BACK, VERIFY, THEN CR. Never combined in one shot: a `/goal …`
1945
+ // line was observed folding in the terminal and landing as a pasted
1946
+ // message instead of a slash command on 2026-09-13 — a failure a read-back
1947
+ // catches and a fire-and-forget send cannot.
1948
+ //
1949
+ // escapeInputMode is gated on TWO conditions, neither optional:
1950
+ // 1. needsVimEscape(...) — only send it when the pane's own status area
1951
+ // shows vim mode is actually on ("-- INSERT --" / "-- NORMAL --").
1952
+ // Unconditionally sending 'i' lands as a literal character on a
1953
+ // non-vim pane, which the read-back below would then never match —
1954
+ // aborting every single arming with Ctrl-U, forever, silently, on
1955
+ // every session that doesn't have vim mode enabled.
1956
+ // 2. armIdle — this must NEVER run while the session might still be
1957
+ // mid-turn. An Esc/keystroke sent into a running turn can cancel it
1958
+ // (observed 2026-09-13), and arm() is otherwise fine typing into a
1959
+ // busy session (the goal text below queues harmlessly behind it) —
1960
+ // it is specifically the escape sequence that is not safe there.
1961
+ if (armIdle && needsVimEscape(readPane(m.sessionId))) {
1962
+ escapeInputMode(m.sessionId);
1963
+ }
1964
+ if (!pasteTextIntoSession(m.sessionId, text)) {
1394
1965
  note(m, `could not type into the session (${reason}) — will retry`);
1395
1966
  return false;
1396
1967
  }
1968
+ await sleep(300); // let the pane catch up before reading it back
1969
+ const echoedLine = promptUnsentText(readPane(m.sessionId)) ?? "";
1970
+ if (!typedLineMatches(echoedLine, text)) {
1971
+ sendControlU(m.sessionId);
1972
+ note(m, `arm aborted: line read back as "${echoedLine.slice(0, 60)}" not "${text.slice(0, 60)}"`);
1973
+ return false;
1974
+ }
1975
+ sendEnterKey(m.sessionId);
1397
1976
  // Typed is not sent, and sent is not received.
1398
1977
  for (let i = 0; i < 5; i++) {
1399
1978
  await sleep(2_000);
@@ -1402,10 +1981,11 @@ async function arm(m, reason) {
1402
1981
  const carried = m.pending.length;
1403
1982
  m.pending = [];
1404
1983
  armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
1405
- note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}` +
1406
- // Recorded because it is the one thing that explains a goal arriving
1407
- // with somebody's half-sentence welded to the front of it.
1408
- (onLine ? ` — the input line held "${onLine.slice(0, 60)}" when this went in` : ""));
1984
+ // The read-back above already confirmed the line held exactly what was
1985
+ // typed before CR ever went out, so there is nothing left here to weld
1986
+ // a goal onto — unlike before any of this guard existed, this note
1987
+ // never has an "input line held X" case left to report.
1988
+ note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
1409
1989
  return true;
1410
1990
  }
1411
1991
  }
@@ -1559,6 +2139,73 @@ async function tick() {
1559
2139
  m.lastChangeAt = now;
1560
2140
  dirty = true;
1561
2141
  }
2142
+ // Resolved once and reused for everything below that needs the pane's
2143
+ // process and project — the context sample, the diagnostic log line, and
2144
+ // (further down) the handover-due decision. Re-resolving per use is what
2145
+ // this file did before and it is one lsof/ps/tail call each time; doing
2146
+ // it once per session per tick is the same reading at a fraction of the
2147
+ // cost.
2148
+ const tickTty = m.tty ?? snapshotTty(m.sessionId);
2149
+ const tickPid = tickTty ? processReading(tickTty).pid : null;
2150
+ const tickTranscript = tickPid ? transcriptReading(tickPid) : null;
2151
+ const tickUsed = tickTranscript?.contextK ?? null;
2152
+ const tickCwd = tickPid ? sessionCwd(tickPid) : null;
2153
+ const tickDirCandidate = tickCwd ? join(homedir(), ".claude", "projects", tickCwd.replace(/\//g, "-")) : null;
2154
+ const tickDir = tickDirCandidate && existsSync(tickDirCandidate) ? tickDirCandidate : null;
2155
+ // Sample context for the fill-rate reading in `status`. Pushed only when
2156
+ // a real number is available — never a synthetic 0 for a tick where the
2157
+ // transcript could not be read, which would read as a session that
2158
+ // stopped filling rather than one that could not be measured this tick.
2159
+ if (tickUsed !== null) {
2160
+ m.contextSamples ??= [];
2161
+ m.contextSamples.push({ at: now, k: tickUsed });
2162
+ if (m.contextSamples.length > 90)
2163
+ m.contextSamples = m.contextSamples.slice(-90);
2164
+ dirty = true;
2165
+ }
2166
+ /**
2167
+ * DIAGNOSTIC: one log line per session per tick, rate-limited.
2168
+ *
2169
+ * The daemon log had NO record of the context reading or the handover
2170
+ * decision anywhere — `liveReading()`'s numbers are computed on demand
2171
+ * for `manage status` and never written down otherwise, so the ONE
2172
+ * question worth asking after the fact ("was a handover due, and why
2173
+ * didn't it fire") had no evidence to answer it from. This is that
2174
+ * evidence. Rate-limited to when the reading actually moves — every 20s
2175
+ * tick logging an unchanged number would bury the log exactly as badly
2176
+ * as saying nothing.
2177
+ */
2178
+ // Idle for the handover ask's own purposes: NOT "time since the last
2179
+ // transcript entry" alone — a long-running tool call leaves that entry
2180
+ // old while the session is still working. Only counts as idle time when
2181
+ // the transcript's own `working` flag says the turn has actually ended;
2182
+ // otherwise forced to 0, which fails every idle floor regardless of how
2183
+ // long that entry has sat there.
2184
+ const tickIdleMs = tickTranscript?.working === false && tickTranscript.lastAt !== null
2185
+ ? Math.max(0, now - tickTranscript.lastAt)
2186
+ : 0;
2187
+ const tickCompaction = compactionReading(m, tickPid, tickDir);
2188
+ const tickHandoverDecision = handoverDue({
2189
+ contextK: tickUsed ?? undefined,
2190
+ effectiveK: tickCompaction.effectiveK,
2191
+ marginK: resolvedMarginK(m),
2192
+ lastAskAt: m.handoverDoneAt,
2193
+ handoverDoneK: m.handoverDoneK,
2194
+ idleMs: tickIdleMs,
2195
+ now,
2196
+ });
2197
+ {
2198
+ const prevLog = lastContextLog.get(m.sessionId);
2199
+ const kMoved = tickUsed !== null && (prevLog?.k === undefined || Math.abs(tickUsed - prevLog.k) >= 10);
2200
+ const reasonMoved = prevLog?.reason !== tickHandoverDecision.reason;
2201
+ if (kMoved || reasonMoved || !prevLog) {
2202
+ const band = tickUsed !== null ? bandOf(tickUsed, tickCompaction.effectiveK, resolvedMarginK(m)) : "below";
2203
+ log(`[manage] ${m.name} context=${tickUsed !== null ? `${tickUsed}k` : "unknown"} ` +
2204
+ `trigger≈${Math.round(tickCompaction.effectiveK)}k(${tickCompaction.label}) band=${band} ` +
2205
+ `due=${tickHandoverDecision.due} reason=${tickHandoverDecision.reason}`);
2206
+ lastContextLog.set(m.sessionId, { k: tickUsed ?? (prevLog?.k ?? -1), reason: tickHandoverDecision.reason });
2207
+ }
2208
+ }
1562
2209
  /**
1563
2210
  * THE BACKSTOP: a managed session whose screen has not moved in a long time.
1564
2211
  *
@@ -1904,57 +2551,69 @@ async function tick() {
1904
2551
  // high precisely because the clear has not landed, so without this the
1905
2552
  // threshold re-qualifies the session every tick and the rollover machinery
1906
2553
  // runs in a circle, each lap adding another clear to the queue.
1907
- if (!m.paused && !m.handoverAskedAt && !m.clearPendingSince && m.handoverFile) {
1908
- // The pane is resolved here rather than carried in from elsewhere in the
1909
- // tick, so this block does not depend on the order of what precedes it.
1910
- const tty = m.tty ?? snapshotTty(m.sessionId);
1911
- const pid = tty ? processReading(tty).pid : null;
1912
- const t = pid ? transcriptReading(pid) : null;
1913
- const used = t?.contextK ?? null;
1914
- /**
1915
- * TWO WAYS TO BECOME DUE, because a handover goes out of date two ways.
1916
- *
1917
- * By the clock, which is the ordinary case. And by work done since the
1918
- * last one, which is the case that mattered and was missing: a session
1919
- * asked at the threshold keeps working to the wall, and everything it
1920
- * learns in that stretch is absent from the file precisely when
1921
- * compaction discards it. The second trigger keeps the document current
1922
- * with the work rather than with the hour.
1923
- */
1924
- const sinceLast = now - (m.handoverDoneAt ?? 0);
2554
+ //
2555
+ // NOTE: `m.handoverFile` is NOT required here. It used to be, and that
2556
+ // was the actual root cause found by investigation — the ask never fires
2557
+ // for a session nobody has run `manage <session> handover <path>` on,
2558
+ // whatever the threshold arithmetic says, and most managed sessions never
2559
+ // have that command run on them. A named file stays the explicit,
2560
+ // preferred target; an unnamed session still gets asked, at a sane
2561
+ // default (see defaultHandoverTarget) or, failing that, with no path at
2562
+ // all rather than not asking.
2563
+ if (!m.paused && !m.handoverAskedAt && !m.clearPendingSince) {
2564
+ // Reuses the pid/context/decision already resolved once above, for
2565
+ // every managed session, so the diagnostic log and the actual ask are
2566
+ // never able to disagree about what was seen this tick.
2567
+ const used = tickUsed;
2568
+ const decision = tickHandoverDecision;
1925
2569
  const grownBy = used !== null && m.handoverDoneK !== undefined ? used - m.handoverDoneK : null;
1926
- const dueByTime = sinceLast > HANDOVER_REASK_MS;
1927
- const dueByWork = grownBy !== null && grownBy >= HANDOVER_REASK_K && sinceLast > HANDOVER_MIN_GAP_MS;
1928
- // 1M is the window these sessions run in; treat anything else as unknown
1929
- // rather than guessing, because a wrong denominator rolls over a session
1930
- // that had plenty of room left.
1931
- if ((dueByTime || dueByWork) && used !== null && used / 1000 >= HANDOVER_AT) {
1932
- const askedPath = resolveHandoverPath(m.handoverFile);
2570
+ if (decision.due) {
2571
+ const notesDirExists = tickCwd ? existsSync(join(tickCwd, "Notes")) : false;
2572
+ const usedDefault = !m.handoverFile;
2573
+ const askedPath = m.handoverFile
2574
+ ? resolveHandoverPath(m.handoverFile)
2575
+ : (defaultHandoverTarget(tickCwd, notesDirExists) ?? undefined);
1933
2576
  m.handoverAskedAt = now;
1934
2577
  m.handoverAskedPath = askedPath;
1935
- m.handoverWas = fileFingerprint(askedPath);
2578
+ m.handoverWas = askedPath ? fileFingerprint(askedPath) : undefined;
1936
2579
  // A dated handover starts empty each day, and an empty one is worse
1937
2580
  // than none: it reads as authoritative and says nothing. So the
1938
2581
  // instruction carries the rule for that case rather than assuming the
1939
2582
  // session will think of it at the moment it is running out of room.
1940
- const carry = existsSync(askedPath)
1941
- ? ""
1942
- : `That file does not exist yet — start it by carrying forward from the most recent handover beside it whatever still matters, especially anything written nowhere else. `;
1943
- // A top-up reads differently from a first request: the session has
1944
- // already written one and needs to know this is about the work SINCE,
1945
- // not a repeat it can satisfy by confirming the file is still there.
1946
- const topUp = dueByWork && !dueByTime && grownBy !== null;
1947
- typeIntoSession(m.sessionId, (topUp
1948
- ? `Bring your handover up to date — you are at ${used}k tokens, ${grownBy}k of work since you last wrote it, and the terminal will compact before long. Everything you have learned in that stretch is currently written nowhere but this context, which is the part compaction takes. `
1949
- : `Write your handover now — you are at ${used}k tokens and the terminal will compact before long. `) +
1950
- `Update ${askedPath}. ${carry}Three things: where the current item stands, what you would do next and why, ` +
2583
+ const carry = askedPath && !existsSync(askedPath)
2584
+ ? `That file does not exist yet — start it by carrying forward from the most recent handover beside it whatever still matters, especially anything written nowhere else. `
2585
+ : "";
2586
+ const whereClause = askedPath
2587
+ ? `Update ${askedPath}. ${carry}`
2588
+ : `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. `;
2589
+ // Wording follows the band: IMMEDIATE has no time to spare and says
2590
+ // so; REFRESH and a work-triggered WARM-UP both mean the session has
2591
+ // already written one and this is about the work SINCE, not a repeat
2592
+ // it can satisfy by confirming the file is still there; anything else
2593
+ // is a first request.
2594
+ const topUp = m.handoverDoneAt !== undefined && (decision.reason === "refresh" || decision.reason.startsWith("warm-up — work"));
2595
+ const urgent = decision.reason === "immediate";
2596
+ typeIntoSession(m.sessionId, (urgent
2597
+ ? `URGENT — write your handover NOW. You are at ${used}k tokens and compaction is imminent; there is no time left to keep working first. `
2598
+ : topUp
2599
+ ? `Bring your handover up to date — you are at ${used}k tokens${grownBy !== null ? `, ${grownBy}k of work since you last wrote it` : ""}, and the terminal will compact before long. Everything you have learned in that stretch is currently written nowhere but this context, which is the part compaction takes. `
2600
+ : `Write your handover now — you are at ${used}k tokens and the terminal will compact before long. `) +
2601
+ whereClause +
2602
+ `Three things: where the current item stands, what you would do next and why, ` +
1951
2603
  `and — the irreplaceable part — anything you know that is written nowhere else. Commit it. ` +
1952
2604
  (m.clearAfterHandover
1953
2605
  ? `You will be cleared once that file has changed on disk, and not before.`
1954
2606
  : `Then carry straight on with the work; you are not being cleared. And keep that file current as you go — anything you work out after writing it is at risk until it is on disk.`));
1955
- notify(m, topUp
1956
- ? `at ${used}k tokens, ${grownBy}k of new work since the last one — asked to bring the handover up to date`
1957
- : `at ${used}k tokens — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}`);
2607
+ notify(m, `at ${used}k tokens (${decision.reason})` +
2608
+ (grownBy !== null ? `, ${grownBy}k of new work since the last one` : "") +
2609
+ ` — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}` +
2610
+ (askedPath ? ` — target: ${askedPath}${usedDefault ? " (default — no handoverFile set)" : ""}` : " — no target resolved, asked without naming a path"));
2611
+ // The idle age that justified typing into this session now — the
2612
+ // un-gated ask reaches every managed session, not just the one
2613
+ // opted-in session it used to, so the evidence for "this was a safe
2614
+ // moment to interrupt it" belongs in the log every single time.
2615
+ log(`[manage] ${m.name} asked for handover: context=${used}k band=${bandOf(used ?? 0, tickCompaction.effectiveK, resolvedMarginK(m))} ` +
2616
+ `idle=${Math.round(tickIdleMs / 1000)}s target=${askedPath ?? "none"}`);
1958
2617
  dirty = true;
1959
2618
  continue;
1960
2619
  }
@@ -2144,10 +2803,15 @@ export async function handleManage(sessionIdOrName, rawArg) {
2144
2803
  ` \`until\` takes the next time the clock reads it, which is\n` +
2145
2804
  ` what somebody deciding at midnight actually means\n` +
2146
2805
  ` handover <path> [clear]\n` +
2147
- ` where this session writes what it knows. At 82% context it\n` +
2148
- ` is asked to update that file, then carries on — the terminal\n` +
2149
- ` compacts by itself and the file is what survives it. Add\n` +
2150
- ` "clear" to also clear the session (queues, in a long turn)\n` +
2806
+ ` where this session writes what it knows. It is asked to update\n` +
2807
+ ` that file once context nears the compaction trigger (measured\n` +
2808
+ ` from this project's own history where possible, else assumed),\n` +
2809
+ ` then carries on — the terminal compacts by itself and the file\n` +
2810
+ ` is what survives it. Add "clear" to also clear the session\n` +
2811
+ ` (queues, in a long turn)\n` +
2812
+ ` handover-at <k>\n` +
2813
+ ` this session's own warm-up margin in K tokens below the\n` +
2814
+ ` trigger, instead of the ${HANDOVER_MARGIN_K_DEFAULT}k default (range ${MARGIN_K_MIN}-${MARGIN_K_MAX})\n` +
2151
2815
  ` set <text> REPLACE the standing objective. Plain text on a running\n` +
2152
2816
  ` manager is a one-shot note; this changes what it re-arms\n` +
2153
2817
  ` add <text> EXTEND the standing objective. Say it to the session\n` +
@@ -2344,7 +3008,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
2344
3008
  message: `managing ${name}${existing.paused ? " (paused)" : ""}\n` +
2345
3009
  `objective: ${existing.objective}\n` +
2346
3010
  `\nright now:\n` +
2347
- liveReading(sessionId, idle) +
3011
+ liveReading(sessionId, idle, existing) +
2348
3012
  // Who holds the screen belongs in the live reading, not in the
2349
3013
  // manager's own record: it is a fact about the machine right now, and
2350
3014
  // it is the one a watcher cannot get any other way.
@@ -2559,12 +3223,12 @@ export async function handleManage(sessionIdOrName, rawArg) {
2559
3223
  ? `Keep it current as you work.`
2560
3224
  : `It does not exist yet — start it by carrying forward whatever still matters from the old one, especially anything written nowhere else, and keep it current as you work.`));
2561
3225
  }
2562
- note(existing, `handover file set to ${path} — asked for at ${Math.round(HANDOVER_AT * 100)}% context${wantsClear ? ", then cleared" : ", no clear"}`);
3226
+ note(existing, `handover file set to ${path} — asked for ${resolvedMarginK(existing)}k below the compaction trigger${wantsClear ? ", then cleared" : ", no clear"}`);
2563
3227
  saveState(state);
2564
3228
  return {
2565
3229
  ok: true,
2566
3230
  managed: true,
2567
- message: `${name} will be asked to hand over at ${Math.round(HANDOVER_AT * 100)}% of its context, into ${path}.\n` +
3231
+ message: `${name} will be asked to hand over ${resolvedMarginK(existing)}k below wherever its compaction trigger turns out to be (see \`manage ${name} status\` for the current reading), into ${path}.\n` +
2568
3232
  (resolved === path
2569
3233
  ? ""
2570
3234
  : ` Today that resolves to ${resolved}; the date is worked out each time it is asked for, not now.\n`) +
@@ -2580,6 +3244,37 @@ export async function handleManage(sessionIdOrName, rawArg) {
2580
3244
  : `\n NOTE: ${resolved} does not exist yet. It counts as changed when first written, and the\n request will tell the session to carry forward what still matters from the most recent one beside it.`),
2581
3245
  };
2582
3246
  }
3247
+ /**
3248
+ * handover-at <k> — this session's own warm-up margin, overriding the
3249
+ * default 100k.
3250
+ *
3251
+ * A fixed FRACTION of a fixed window cannot be right for every session —
3252
+ * that was tried (0.82 of a 1000k window) and the ground moved under it:
3253
+ * the same configured override produced compactions averaging ~1000k
3254
+ * before 2026-09-12 and ~784k after. So the margin is now measured from
3255
+ * wherever the trigger actually turns out to be for THIS project (see
3256
+ * effectiveCompactK / measuredCompactK), and this only ever overrides how
3257
+ * far below that point to ask — in K tokens, not a share of anything.
3258
+ */
3259
+ const handoverAtMatch = arg.match(/^handover-at\s+(\d+)\s*$/i);
3260
+ if (handoverAtMatch && existing) {
3261
+ const n = Number(handoverAtMatch[1]);
3262
+ if (!Number.isFinite(n) || n < MARGIN_K_MIN || n > MARGIN_K_MAX) {
3263
+ return {
3264
+ ok: false,
3265
+ message: `handover-at must be a whole number of K tokens between ${MARGIN_K_MIN} and ${MARGIN_K_MAX} (got "${handoverAtMatch[1]}")`,
3266
+ };
3267
+ }
3268
+ const wasK = resolvedMarginK(existing);
3269
+ existing.handoverMarginK = n;
3270
+ note(existing, `handover warm-up margin set to ${n}k below the trigger`);
3271
+ saveState(state);
3272
+ return {
3273
+ ok: true,
3274
+ managed: true,
3275
+ message: `${name} will now be asked to hand over ${n}k below its compaction trigger (was ${wasK}k).`,
3276
+ };
3277
+ }
2583
3278
  if (word === "pause" || word === "resume") {
2584
3279
  if (!existing)
2585
3280
  return { ok: false, message: `${name} is not being managed` };
@@ -2601,9 +3296,14 @@ export async function handleManage(sessionIdOrName, rawArg) {
2601
3296
  * manager exists, appears in every listing, and has to be found and removed
2602
3297
  * by somebody who did not create it on purpose. Check at the point of
2603
3298
  * creation, where the mistake is still one command old.
3299
+ *
3300
+ * atPrompt alone is not that check: an idle Claude pane reports atPrompt
3301
+ * too — its own input line — so refusing on it alone turned away a live
3302
+ * but idle session. Only a title that names no session (see isClaudePane)
3303
+ * is a shell to refuse.
2604
3304
  */
2605
3305
  const probe = readSessionContent(sessionId, 5);
2606
- if (probe?.atPrompt) {
3306
+ if (probe?.atPrompt && !isClaudePane(probe.name)) {
2607
3307
  return {
2608
3308
  ok: false,
2609
3309
  message: `${name} is a shell prompt, not a running session — refusing to manage it.\n` +