aibroker 0.55.1 → 0.56.0

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,254 @@ 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
+ * What to do about text already sitting in a session's input line, before
650
+ * typing anything — the pure decision behind the read-back-first sequence in
651
+ * arm() and send_to_session.
652
+ *
653
+ * NEVER type on top of live input and never clear it on sight: typing over
654
+ * it mangles two things into one prompt, and clearing on sight was tried
655
+ * once already and reverted (see test/manage-unsent-prompt.test.ts's own
656
+ * header) because a terminal's greyed-out Tab-completion suggestion cannot
657
+ * be told apart from someone mid-sentence in a captured pane — a real
658
+ * sentence eaten by an over-eager clear is worse than a delayed handover.
659
+ *
660
+ * So: EMPTY types immediately. Anything else is only ever SKIPPED — retried
661
+ * next tick, 20s away — unless the IDENTICAL text has now persisted for
662
+ * `GHOST_TICKS` consecutive ticks (2 minutes) while the session is idle, at
663
+ * which point it reads as an abandoned ghost rather than someone typing, and
664
+ * only then is it cleared. Text that changes between ticks is someone
665
+ * typing, full stop — the caller resets `sameForTicks` to 1 on any change,
666
+ * which alone keeps this skipping for as long as that keeps happening.
667
+ *
668
+ * `idleMs` and `band` are threaded through into the return value rather than
669
+ * only consumed here so the caller can log the evidence for a clear-then-type
670
+ * decision without recomputing it — a decision this rare is worth a complete
671
+ * log line, not a re-derivation from parts scattered across the caller.
672
+ */
673
+ const GHOST_TICKS = 6; // 6 * TICK_MS(20s) = 2 minutes
674
+ export function inputLineDecision(input) {
675
+ const { text, sameForTicks, idle, idleMs, band } = input;
676
+ if (!text)
677
+ return { action: "type", sameForTicks, idleMs, band };
678
+ if (sameForTicks >= GHOST_TICKS && idle) {
679
+ const totalSec = sameForTicks * (TICK_MS / 1000);
680
+ const durationLabel = `${Math.floor(totalSec / 60)}m${String(Math.round(totalSec % 60)).padStart(2, "0")}s`;
681
+ return {
682
+ action: "clear-then-type",
683
+ logCleared: `clearing persistent input-line text before typing: "${text}" ` +
684
+ `(identical for ${sameForTicks} ticks / ${durationLabel}, idle ${Math.round((idleMs ?? 0) / 1000)}s, band=${band ?? "unknown"})`,
685
+ sameForTicks,
686
+ idleMs,
687
+ band,
688
+ };
689
+ }
690
+ return { action: "skip", sameForTicks, idleMs, band };
691
+ }
396
692
  /** This session's context in thousands of tokens, from its own transcript. */
397
693
  function contextK(m) {
398
694
  const tty = m.tty ?? snapshotTty(m.sessionId);
@@ -661,6 +957,21 @@ let lastReportAt = 0;
661
957
  * while looking like that one.
662
958
  */
663
959
  const armingsSinceReport = new Map();
960
+ /**
961
+ * Rate-limiting state for the per-tick diagnostic context/trigger/band line —
962
+ * NOT persisted, same reasoning as armingsSinceReport: it answers "did this
963
+ * change since the last time it was logged", which only means anything
964
+ * against readings from the same daemon run.
965
+ */
966
+ const lastContextLog = new Map();
967
+ /**
968
+ * How many CONSECUTIVE arm() calls have seen the identical text sitting
969
+ * unsent in a session's input line — the memory inputLineDecision's
970
+ * `sameForTicks` needs to tell a ghost from someone mid-sentence. NOT
971
+ * persisted: a daemon restart losing this only delays a ghost-clear by up to
972
+ * GHOST_TICKS more ticks, never causes one to fire early.
973
+ */
974
+ const inputLineTracking = new Map();
664
975
  /**
665
976
  * The periodic reading: what every managed session is doing, in one message.
666
977
  *
@@ -790,6 +1101,37 @@ function processReading(tty) {
790
1101
  return { isSession: false, pid: null };
791
1102
  return { isSession: true, pid: claude.pid };
792
1103
  }
1104
+ /**
1105
+ * Pure extraction of context usage from raw transcript JSONL lines.
1106
+ *
1107
+ * Split out of transcriptReading() so the one rule that matters here — NO
1108
+ * usage on the tail means the context reading is UNKNOWN, never a claimed
1109
+ * zero — is checkable without a process table, lsof, or a file on disk.
1110
+ *
1111
+ * `undefined` covers every case where nothing can be said: no parseable
1112
+ * lines, no assistant message on the tail, or an assistant message whose
1113
+ * `usage` field is absent. A real reading of zero tokens (a brand new
1114
+ * session) is not this case and is returned as `0`, which is why the
1115
+ * distinction is undefined-vs-number rather than falsy-vs-truthy.
1116
+ */
1117
+ export function usageFromLines(lines) {
1118
+ const msgs = [];
1119
+ for (const line of lines) {
1120
+ if (!line.trim())
1121
+ continue;
1122
+ try {
1123
+ const j = JSON.parse(line);
1124
+ if (j.type === "assistant" || j.type === "user")
1125
+ msgs.push(j);
1126
+ }
1127
+ catch { /* a truncated first line is normal when tailing */ }
1128
+ }
1129
+ const lastAssistant = [...msgs].reverse().find((m) => m.type === "assistant");
1130
+ const u = lastAssistant?.message?.usage;
1131
+ if (!u)
1132
+ return undefined;
1133
+ return Math.round(((u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)) / 1000);
1134
+ }
793
1135
  /**
794
1136
  * What the session is doing, from its own transcript — the authority.
795
1137
  *
@@ -813,20 +1155,56 @@ function processReading(tty) {
813
1155
  * - WHEN: the entry's timestamp, so "how long has this been going" is a
814
1156
  * subtraction rather than a guess.
815
1157
  */
816
- function transcriptReading(claudePid) {
817
- const none = { working: null, doing: null, contextK: null, lastAt: null };
1158
+ /**
1159
+ * A running process's cwd, from the operating system — the actual authority
1160
+ * on where a session is rooted (a name or a config value could lag a `cd`).
1161
+ * Shared by everything below that needs it, so it is resolved with one lsof
1162
+ * call per session per tick rather than once per caller.
1163
+ */
1164
+ function sessionCwd(claudePid) {
818
1165
  try {
819
- // The transcript directory is named for the session's working directory,
820
- // which the process itself is the authority on.
821
1166
  const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", claudePid, "-a", "-d", "cwd", "-Fn"], {
822
1167
  encoding: "utf8",
823
1168
  timeout: 4_000,
824
1169
  });
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))
1170
+ return cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1) ?? null;
1171
+ }
1172
+ catch {
1173
+ return null;
1174
+ }
1175
+ }
1176
+ /**
1177
+ * The transcript directory for a session's own project, derived from its cwd.
1178
+ * Shared by transcriptReading (the live session's own tail) and the
1179
+ * measured-compaction reading (every .jsonl in the project, not only the
1180
+ * live one).
1181
+ */
1182
+ function projectTranscriptDir(claudePid) {
1183
+ const cwd = sessionCwd(claudePid);
1184
+ if (!cwd)
1185
+ return null;
1186
+ const dir = join(homedir(), ".claude", "projects", cwd.replace(/\//g, "-"));
1187
+ return existsSync(dir) ? dir : null;
1188
+ }
1189
+ /**
1190
+ * Where a handover goes when nobody has named a file — the convention this
1191
+ * project already uses without the manager's help, so a session asked cold
1192
+ * has somewhere sane to write rather than nowhere at all.
1193
+ *
1194
+ * `notesDirExists` is passed in rather than checked here so this stays pure
1195
+ * and testable without touching a filesystem: the caller does one existsSync
1196
+ * and hands in the answer.
1197
+ */
1198
+ export function defaultHandoverTarget(cwd, notesDirExists) {
1199
+ if (!cwd || !notesDirExists)
1200
+ return null;
1201
+ return join(cwd, "Notes", "TODO.md");
1202
+ }
1203
+ function transcriptReading(claudePid) {
1204
+ const none = { working: null, doing: null, contextK: null, lastAt: null };
1205
+ try {
1206
+ const dir = projectTranscriptDir(claudePid);
1207
+ if (!dir)
830
1208
  return none;
831
1209
  // The live transcript is the one being written. Newest wins; a session that
832
1210
  // has not written for a long time will show that in its own timestamp
@@ -869,16 +1247,134 @@ function transcriptReading(claudePid) {
869
1247
  : last.type === "user"
870
1248
  ? "waiting on a tool result"
871
1249
  : 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;
1250
+ // Same rule as usageFromLines(): no usage on the tail is UNKNOWN, not a
1251
+ // reading of zero. Re-parses the same raw text rather than reusing `msgs`
1252
+ // above so the pure extraction stays the single source of truth for what
1253
+ // counts as "no usage" — duplicating the parse is cheap against 40 lines.
1254
+ const contextK = usageFromLines(raw.split("\n")) ?? null;
876
1255
  return { working, doing, contextK, lastAt };
877
1256
  }
878
1257
  catch {
879
1258
  return none;
880
1259
  }
881
1260
  }
1261
+ const overridePctCache = new Map();
1262
+ /**
1263
+ * `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`, read from where it actually governs the
1264
+ * running session — its own process environment — falling back to the
1265
+ * config file a fresh process would inherit it from. Cached for a minute per
1266
+ * pid: `ps -E` is not free to run every 20-second tick for every managed
1267
+ * session, and this value does not change inside a running process.
1268
+ */
1269
+ function readOverridePct(pid) {
1270
+ const key = pid ?? "no-pid";
1271
+ const cached = overridePctCache.get(key);
1272
+ if (cached && Date.now() - cached.at < 60_000)
1273
+ return cached.reading;
1274
+ let reading;
1275
+ if (pid) {
1276
+ try {
1277
+ // macOS `ps -E` appends the process environment after the command.
1278
+ const out = execFileSync("/bin/ps", ["-E", "-p", pid, "-o", "command="], {
1279
+ encoding: "utf8",
1280
+ timeout: 4_000,
1281
+ });
1282
+ const m = out.match(/CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=(\d+)/);
1283
+ if (m)
1284
+ reading = { pct: Number(m[1]), source: "session env" };
1285
+ }
1286
+ catch { /* process gone, or ps refused — fall through to the config file */ }
1287
+ }
1288
+ if (!reading) {
1289
+ try {
1290
+ const settings = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8"));
1291
+ const v = settings?.env?.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
1292
+ if (v !== undefined && v !== null && v !== "")
1293
+ reading = { pct: Number(v), source: "settings.json" };
1294
+ }
1295
+ catch { /* no settings file, or unreadable — assumed default applies */ }
1296
+ }
1297
+ overridePctCache.set(key, { at: Date.now(), reading });
1298
+ return reading;
1299
+ }
1300
+ const measuredCompactCache = new Map();
1301
+ /**
1302
+ * measuredCompactK, wired to a project directory — every .jsonl in it, not
1303
+ * only the session's own live transcript, because the last three
1304
+ * compactions for this PROJECT may belong to sessions that have since ended.
1305
+ *
1306
+ * Bounded to the five most recently modified files and a tail of each,
1307
+ * rather than reading whole transcripts that reach tens of megabytes: recent
1308
+ * compactions are, definitionally, recent, so they live in recently-touched
1309
+ * files near their own end. Cached by the summed mtime of those files so a
1310
+ * tick where nothing in the project changed does no file I/O at all — the
1311
+ * cache mostly does NOT help the live file, whose mtime moves most ticks,
1312
+ * but it is nearly free to keep and helps every other managed session
1313
+ * sharing this call.
1314
+ */
1315
+ function measuredCompactForProject(dir) {
1316
+ let entries;
1317
+ try {
1318
+ entries = readdirSync(dir)
1319
+ .filter((f) => f.endsWith(".jsonl"))
1320
+ .map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs }))
1321
+ .sort((a, b) => b.m - a.m)
1322
+ .slice(0, 5);
1323
+ }
1324
+ catch {
1325
+ return undefined;
1326
+ }
1327
+ if (!entries.length)
1328
+ return undefined;
1329
+ const mtimeSum = entries.reduce((s, e) => s + e.m, 0);
1330
+ const cached = measuredCompactCache.get(dir);
1331
+ if (cached && cached.mtimeSum === mtimeSum)
1332
+ return cached.result;
1333
+ const lines = [];
1334
+ for (const e of entries) {
1335
+ try {
1336
+ const raw = execFileSync("/usr/bin/tail", ["-n", "500", join(dir, e.f)], {
1337
+ encoding: "utf8",
1338
+ timeout: 4_000,
1339
+ maxBuffer: 8 * 1024 * 1024,
1340
+ });
1341
+ lines.push(...raw.split("\n"));
1342
+ }
1343
+ catch { /* file raced a delete, or tail refused — skip it */ }
1344
+ }
1345
+ const result = measuredCompactK(lines);
1346
+ measuredCompactCache.set(dir, { mtimeSum, result });
1347
+ return result;
1348
+ }
1349
+ /**
1350
+ * The compaction point to plan a session's handover around, and a one-line
1351
+ * account of where that number came from — printed in `status` and in the
1352
+ * per-tick log line, because a number nobody can trace back to a source is a
1353
+ * number nobody can debug when it is wrong again.
1354
+ */
1355
+ function compactionReading(m, pid, dir) {
1356
+ const windowK = m.contextWindowK ?? 1000;
1357
+ const measured = dir ? measuredCompactForProject(dir) : undefined;
1358
+ const override = readOverridePct(pid);
1359
+ const effectiveK = effectiveCompactK({ windowK, overridePct: override?.pct, measuredK: measured?.k });
1360
+ let label;
1361
+ const configuredK = effectiveCompactK({ windowK, overridePct: override?.pct });
1362
+ if (measured && measured.k > configuredK) {
1363
+ const src = override ? `${override.source} override=${override.pct}` : `assumed ${ASSUMED_OVERRIDE_PCT}`;
1364
+ label = `${src}; measured ${measured.k.toLocaleString("en-US")}k is stale-high and ignored`;
1365
+ }
1366
+ else if (measured) {
1367
+ const vals = measured.events.map((e) => e.preTokens.toLocaleString("en-US")).join(" / ");
1368
+ label = `measured: min of ${measured.events.length} event${measured.events.length > 1 ? "s" : ""} ${vals} in this project`;
1369
+ }
1370
+ else if (override) {
1371
+ label = `${override.source} override=${override.pct}`;
1372
+ }
1373
+ else {
1374
+ label = `assumed ${ASSUMED_OVERRIDE_PCT}, no history, no override`;
1375
+ }
1376
+ return { effectiveK, label };
1377
+ }
882
1378
  /**
883
1379
  * The status line, assembled from the sources in order of authority.
884
1380
  *
@@ -892,7 +1388,7 @@ function transcriptReading(claudePid) {
892
1388
  * which number came from the transcript and which was scraped off a status bar
893
1389
  * cannot tell which one to doubt.
894
1390
  */
895
- function liveReading(sessionId, idleSec) {
1391
+ function liveReading(sessionId, idleSec, m) {
896
1392
  const snap = discoverLiveSessions().find((s) => s.id === sessionId);
897
1393
  const proc = snap?.tty ? processReading(snap.tty) : { isSession: false, pid: null };
898
1394
  if (!proc.isSession) {
@@ -904,8 +1400,30 @@ function liveReading(sessionId, idleSec) {
904
1400
  const agoSec = Math.round((Date.now() - t.lastAt) / 1000);
905
1401
  out.push(` ${t.working ? "working" : "idle"} · last transcript entry ${agoSec < 90 ? `${agoSec}s` : `${Math.round(agoSec / 60)} min`} ago` +
906
1402
  (t.doing ? ` · ${t.doing}` : ""));
907
- if (t.contextK !== null)
1403
+ if (t.contextK !== null) {
908
1404
  out.push(` context ${t.contextK}k tokens (from the transcript's own usage, not the status bar)`);
1405
+ // The trigger, fill rate and ETA are only meaningful alongside a
1406
+ // session's own margin, so all three are printed together and only
1407
+ // when a managed session (`m`) is available to supply it.
1408
+ if (m) {
1409
+ const dir = proc.pid ? projectTranscriptDir(proc.pid) : null;
1410
+ const { effectiveK, label } = compactionReading(m, proc.pid, dir);
1411
+ const marginK = resolvedMarginK(m);
1412
+ const warmUpK = Math.round(effectiveK - marginK);
1413
+ out.push(` compaction trigger ≈${Math.round(effectiveK)}k (${label})`);
1414
+ const slope = contextSlope(m.contextSamples ?? []);
1415
+ if (slope.kPerMin === undefined) {
1416
+ out.push(` filling rate unknown (not enough recent readings) · handover from ${warmUpK}k`);
1417
+ }
1418
+ else {
1419
+ const eta = slope.minutesTo(warmUpK);
1420
+ out.push(` filling ~${slope.kPerMin >= 0 ? "" : "-"}${Math.abs(Math.round(slope.kPerMin))}k/min` +
1421
+ (eta === undefined
1422
+ ? ` · handover from ${warmUpK}k (not approaching it)`
1423
+ : ` · ~${Math.round(eta)} min to handover at ${warmUpK}k`));
1424
+ }
1425
+ }
1426
+ }
909
1427
  }
910
1428
  else {
911
1429
  out.push(` a session is running, but its transcript could not be read — falling back to the screen`);
@@ -1372,28 +1890,75 @@ async function arm(m, reason) {
1372
1890
  return false;
1373
1891
  }
1374
1892
  /**
1375
- * TEXT ON THE INPUT LINE IS NOTED, NEVER OBEYED.
1893
+ * TEXT ON THE INPUT LINE — READ BACK FIRST, NEVER DESTROY LIVE INPUT.
1376
1894
  *
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.
1895
+ * An outright refusal was tried before and reverted (see the header of
1896
+ * test/manage-unsent-prompt.test.ts): the terminal offers a greyed-out
1897
+ * SUGGESTION on the same line, accepted with Tab, and no colour survives a
1898
+ * pane capture to tell it apart from somebody mid-sentence. An earlier pass
1899
+ * of THIS change reinstated that same refusal outright and was corrected
1900
+ * mid-flight — clearing on sight, or refusing forever, both risk exactly
1901
+ * the failure the revert was for.
1385
1902
  *
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.
1903
+ * So: empty line types immediately (below). Anything else is only ever
1904
+ * SKIPPED and retried next tick — never cleared, never typed over —
1905
+ * unless the IDENTICAL text has sat there for GHOST_TICKS consecutive
1906
+ * ticks (2 minutes) while the session is idle, which is inputLineDecision's
1907
+ * job to decide. Skips still count toward armFails/armFailStreak below, so
1908
+ * a line occupied for minutes still reaches blockedReason's operator alert
1909
+ * (with a screenshot) — the daemon does not stall silently either way.
1391
1910
  */
1392
1911
  const onLine = promptUnsentText(readPane(m.sessionId));
1393
- if (!typeIntoSession(m.sessionId, text)) {
1912
+ const armIdleMs = Math.max(0, Date.now() - m.lastChangeAt);
1913
+ const armIdle = armIdleMs >= HANDOVER_IDLE_FLOOR_MS;
1914
+ const prevLine = inputLineTracking.get(m.sessionId);
1915
+ const sameForTicks = onLine && prevLine && prevLine.text === onLine ? prevLine.sameForTicks + 1 : 1;
1916
+ if (onLine)
1917
+ inputLineTracking.set(m.sessionId, { text: onLine, sameForTicks });
1918
+ else
1919
+ inputLineTracking.delete(m.sessionId);
1920
+ const lineDecision = inputLineDecision({ text: onLine ?? "", sameForTicks, idle: armIdle, idleMs: armIdleMs });
1921
+ if (lineDecision.action === "skip") {
1922
+ note(m, `arm deferred: input line holds '${onLine}' (unsent for ${sameForTicks} tick${sameForTicks === 1 ? "" : "s"}, idle ${Math.round(armIdleMs / 1000)}s)`);
1923
+ return false;
1924
+ }
1925
+ if (lineDecision.action === "clear-then-type") {
1926
+ note(m, lineDecision.logCleared ?? "clearing persistent input-line text before typing");
1927
+ sendControlU(m.sessionId);
1928
+ inputLineTracking.delete(m.sessionId);
1929
+ }
1930
+ // TYPE, READ BACK, VERIFY, THEN CR. Never combined in one shot: a `/goal …`
1931
+ // line was observed folding in the terminal and landing as a pasted
1932
+ // message instead of a slash command on 2026-09-13 — a failure a read-back
1933
+ // catches and a fire-and-forget send cannot.
1934
+ //
1935
+ // escapeInputMode is gated on TWO conditions, neither optional:
1936
+ // 1. needsVimEscape(...) — only send it when the pane's own status area
1937
+ // shows vim mode is actually on ("-- INSERT --" / "-- NORMAL --").
1938
+ // Unconditionally sending 'i' lands as a literal character on a
1939
+ // non-vim pane, which the read-back below would then never match —
1940
+ // aborting every single arming with Ctrl-U, forever, silently, on
1941
+ // every session that doesn't have vim mode enabled.
1942
+ // 2. armIdle — this must NEVER run while the session might still be
1943
+ // mid-turn. An Esc/keystroke sent into a running turn can cancel it
1944
+ // (observed 2026-09-13), and arm() is otherwise fine typing into a
1945
+ // busy session (the goal text below queues harmlessly behind it) —
1946
+ // it is specifically the escape sequence that is not safe there.
1947
+ if (armIdle && needsVimEscape(readPane(m.sessionId))) {
1948
+ escapeInputMode(m.sessionId);
1949
+ }
1950
+ if (!pasteTextIntoSession(m.sessionId, text)) {
1394
1951
  note(m, `could not type into the session (${reason}) — will retry`);
1395
1952
  return false;
1396
1953
  }
1954
+ await sleep(300); // let the pane catch up before reading it back
1955
+ const echoedLine = promptUnsentText(readPane(m.sessionId)) ?? "";
1956
+ if (!typedLineMatches(echoedLine, text)) {
1957
+ sendControlU(m.sessionId);
1958
+ note(m, `arm aborted: line read back as "${echoedLine.slice(0, 60)}" not "${text.slice(0, 60)}"`);
1959
+ return false;
1960
+ }
1961
+ sendEnterKey(m.sessionId);
1397
1962
  // Typed is not sent, and sent is not received.
1398
1963
  for (let i = 0; i < 5; i++) {
1399
1964
  await sleep(2_000);
@@ -1402,10 +1967,11 @@ async function arm(m, reason) {
1402
1967
  const carried = m.pending.length;
1403
1968
  m.pending = [];
1404
1969
  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` : ""));
1970
+ // The read-back above already confirmed the line held exactly what was
1971
+ // typed before CR ever went out, so there is nothing left here to weld
1972
+ // a goal onto — unlike before any of this guard existed, this note
1973
+ // never has an "input line held X" case left to report.
1974
+ note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
1409
1975
  return true;
1410
1976
  }
1411
1977
  }
@@ -1559,6 +2125,73 @@ async function tick() {
1559
2125
  m.lastChangeAt = now;
1560
2126
  dirty = true;
1561
2127
  }
2128
+ // Resolved once and reused for everything below that needs the pane's
2129
+ // process and project — the context sample, the diagnostic log line, and
2130
+ // (further down) the handover-due decision. Re-resolving per use is what
2131
+ // this file did before and it is one lsof/ps/tail call each time; doing
2132
+ // it once per session per tick is the same reading at a fraction of the
2133
+ // cost.
2134
+ const tickTty = m.tty ?? snapshotTty(m.sessionId);
2135
+ const tickPid = tickTty ? processReading(tickTty).pid : null;
2136
+ const tickTranscript = tickPid ? transcriptReading(tickPid) : null;
2137
+ const tickUsed = tickTranscript?.contextK ?? null;
2138
+ const tickCwd = tickPid ? sessionCwd(tickPid) : null;
2139
+ const tickDirCandidate = tickCwd ? join(homedir(), ".claude", "projects", tickCwd.replace(/\//g, "-")) : null;
2140
+ const tickDir = tickDirCandidate && existsSync(tickDirCandidate) ? tickDirCandidate : null;
2141
+ // Sample context for the fill-rate reading in `status`. Pushed only when
2142
+ // a real number is available — never a synthetic 0 for a tick where the
2143
+ // transcript could not be read, which would read as a session that
2144
+ // stopped filling rather than one that could not be measured this tick.
2145
+ if (tickUsed !== null) {
2146
+ m.contextSamples ??= [];
2147
+ m.contextSamples.push({ at: now, k: tickUsed });
2148
+ if (m.contextSamples.length > 90)
2149
+ m.contextSamples = m.contextSamples.slice(-90);
2150
+ dirty = true;
2151
+ }
2152
+ /**
2153
+ * DIAGNOSTIC: one log line per session per tick, rate-limited.
2154
+ *
2155
+ * The daemon log had NO record of the context reading or the handover
2156
+ * decision anywhere — `liveReading()`'s numbers are computed on demand
2157
+ * for `manage status` and never written down otherwise, so the ONE
2158
+ * question worth asking after the fact ("was a handover due, and why
2159
+ * didn't it fire") had no evidence to answer it from. This is that
2160
+ * evidence. Rate-limited to when the reading actually moves — every 20s
2161
+ * tick logging an unchanged number would bury the log exactly as badly
2162
+ * as saying nothing.
2163
+ */
2164
+ // Idle for the handover ask's own purposes: NOT "time since the last
2165
+ // transcript entry" alone — a long-running tool call leaves that entry
2166
+ // old while the session is still working. Only counts as idle time when
2167
+ // the transcript's own `working` flag says the turn has actually ended;
2168
+ // otherwise forced to 0, which fails every idle floor regardless of how
2169
+ // long that entry has sat there.
2170
+ const tickIdleMs = tickTranscript?.working === false && tickTranscript.lastAt !== null
2171
+ ? Math.max(0, now - tickTranscript.lastAt)
2172
+ : 0;
2173
+ const tickCompaction = compactionReading(m, tickPid, tickDir);
2174
+ const tickHandoverDecision = handoverDue({
2175
+ contextK: tickUsed ?? undefined,
2176
+ effectiveK: tickCompaction.effectiveK,
2177
+ marginK: resolvedMarginK(m),
2178
+ lastAskAt: m.handoverDoneAt,
2179
+ handoverDoneK: m.handoverDoneK,
2180
+ idleMs: tickIdleMs,
2181
+ now,
2182
+ });
2183
+ {
2184
+ const prevLog = lastContextLog.get(m.sessionId);
2185
+ const kMoved = tickUsed !== null && (prevLog?.k === undefined || Math.abs(tickUsed - prevLog.k) >= 10);
2186
+ const reasonMoved = prevLog?.reason !== tickHandoverDecision.reason;
2187
+ if (kMoved || reasonMoved || !prevLog) {
2188
+ const band = tickUsed !== null ? bandOf(tickUsed, tickCompaction.effectiveK, resolvedMarginK(m)) : "below";
2189
+ log(`[manage] ${m.name} context=${tickUsed !== null ? `${tickUsed}k` : "unknown"} ` +
2190
+ `trigger≈${Math.round(tickCompaction.effectiveK)}k(${tickCompaction.label}) band=${band} ` +
2191
+ `due=${tickHandoverDecision.due} reason=${tickHandoverDecision.reason}`);
2192
+ lastContextLog.set(m.sessionId, { k: tickUsed ?? (prevLog?.k ?? -1), reason: tickHandoverDecision.reason });
2193
+ }
2194
+ }
1562
2195
  /**
1563
2196
  * THE BACKSTOP: a managed session whose screen has not moved in a long time.
1564
2197
  *
@@ -1904,57 +2537,69 @@ async function tick() {
1904
2537
  // high precisely because the clear has not landed, so without this the
1905
2538
  // threshold re-qualifies the session every tick and the rollover machinery
1906
2539
  // 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);
2540
+ //
2541
+ // NOTE: `m.handoverFile` is NOT required here. It used to be, and that
2542
+ // was the actual root cause found by investigation — the ask never fires
2543
+ // for a session nobody has run `manage <session> handover <path>` on,
2544
+ // whatever the threshold arithmetic says, and most managed sessions never
2545
+ // have that command run on them. A named file stays the explicit,
2546
+ // preferred target; an unnamed session still gets asked, at a sane
2547
+ // default (see defaultHandoverTarget) or, failing that, with no path at
2548
+ // all rather than not asking.
2549
+ if (!m.paused && !m.handoverAskedAt && !m.clearPendingSince) {
2550
+ // Reuses the pid/context/decision already resolved once above, for
2551
+ // every managed session, so the diagnostic log and the actual ask are
2552
+ // never able to disagree about what was seen this tick.
2553
+ const used = tickUsed;
2554
+ const decision = tickHandoverDecision;
1925
2555
  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);
2556
+ if (decision.due) {
2557
+ const notesDirExists = tickCwd ? existsSync(join(tickCwd, "Notes")) : false;
2558
+ const usedDefault = !m.handoverFile;
2559
+ const askedPath = m.handoverFile
2560
+ ? resolveHandoverPath(m.handoverFile)
2561
+ : (defaultHandoverTarget(tickCwd, notesDirExists) ?? undefined);
1933
2562
  m.handoverAskedAt = now;
1934
2563
  m.handoverAskedPath = askedPath;
1935
- m.handoverWas = fileFingerprint(askedPath);
2564
+ m.handoverWas = askedPath ? fileFingerprint(askedPath) : undefined;
1936
2565
  // A dated handover starts empty each day, and an empty one is worse
1937
2566
  // than none: it reads as authoritative and says nothing. So the
1938
2567
  // instruction carries the rule for that case rather than assuming the
1939
2568
  // 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, ` +
2569
+ const carry = askedPath && !existsSync(askedPath)
2570
+ ? `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. `
2571
+ : "";
2572
+ const whereClause = askedPath
2573
+ ? `Update ${askedPath}. ${carry}`
2574
+ : `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. `;
2575
+ // Wording follows the band: IMMEDIATE has no time to spare and says
2576
+ // so; REFRESH and a work-triggered WARM-UP both mean the session has
2577
+ // already written one and this is about the work SINCE, not a repeat
2578
+ // it can satisfy by confirming the file is still there; anything else
2579
+ // is a first request.
2580
+ const topUp = m.handoverDoneAt !== undefined && (decision.reason === "refresh" || decision.reason.startsWith("warm-up — work"));
2581
+ const urgent = decision.reason === "immediate";
2582
+ typeIntoSession(m.sessionId, (urgent
2583
+ ? `URGENT — write your handover NOW. You are at ${used}k tokens and compaction is imminent; there is no time left to keep working first. `
2584
+ : topUp
2585
+ ? `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. `
2586
+ : `Write your handover now — you are at ${used}k tokens and the terminal will compact before long. `) +
2587
+ whereClause +
2588
+ `Three things: where the current item stands, what you would do next and why, ` +
1951
2589
  `and — the irreplaceable part — anything you know that is written nowhere else. Commit it. ` +
1952
2590
  (m.clearAfterHandover
1953
2591
  ? `You will be cleared once that file has changed on disk, and not before.`
1954
2592
  : `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"}`);
2593
+ notify(m, `at ${used}k tokens (${decision.reason})` +
2594
+ (grownBy !== null ? `, ${grownBy}k of new work since the last one` : "") +
2595
+ ` — asked for a handover${m.clearAfterHandover ? " before rolling over" : " before it compacts"}` +
2596
+ (askedPath ? ` — target: ${askedPath}${usedDefault ? " (default — no handoverFile set)" : ""}` : " — no target resolved, asked without naming a path"));
2597
+ // The idle age that justified typing into this session now — the
2598
+ // un-gated ask reaches every managed session, not just the one
2599
+ // opted-in session it used to, so the evidence for "this was a safe
2600
+ // moment to interrupt it" belongs in the log every single time.
2601
+ log(`[manage] ${m.name} asked for handover: context=${used}k band=${bandOf(used ?? 0, tickCompaction.effectiveK, resolvedMarginK(m))} ` +
2602
+ `idle=${Math.round(tickIdleMs / 1000)}s target=${askedPath ?? "none"}`);
1958
2603
  dirty = true;
1959
2604
  continue;
1960
2605
  }
@@ -2144,10 +2789,15 @@ export async function handleManage(sessionIdOrName, rawArg) {
2144
2789
  ` \`until\` takes the next time the clock reads it, which is\n` +
2145
2790
  ` what somebody deciding at midnight actually means\n` +
2146
2791
  ` 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` +
2792
+ ` where this session writes what it knows. It is asked to update\n` +
2793
+ ` that file once context nears the compaction trigger (measured\n` +
2794
+ ` from this project's own history where possible, else assumed),\n` +
2795
+ ` then carries on — the terminal compacts by itself and the file\n` +
2796
+ ` is what survives it. Add "clear" to also clear the session\n` +
2797
+ ` (queues, in a long turn)\n` +
2798
+ ` handover-at <k>\n` +
2799
+ ` this session's own warm-up margin in K tokens below the\n` +
2800
+ ` trigger, instead of the ${HANDOVER_MARGIN_K_DEFAULT}k default (range ${MARGIN_K_MIN}-${MARGIN_K_MAX})\n` +
2151
2801
  ` set <text> REPLACE the standing objective. Plain text on a running\n` +
2152
2802
  ` manager is a one-shot note; this changes what it re-arms\n` +
2153
2803
  ` add <text> EXTEND the standing objective. Say it to the session\n` +
@@ -2344,7 +2994,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
2344
2994
  message: `managing ${name}${existing.paused ? " (paused)" : ""}\n` +
2345
2995
  `objective: ${existing.objective}\n` +
2346
2996
  `\nright now:\n` +
2347
- liveReading(sessionId, idle) +
2997
+ liveReading(sessionId, idle, existing) +
2348
2998
  // Who holds the screen belongs in the live reading, not in the
2349
2999
  // manager's own record: it is a fact about the machine right now, and
2350
3000
  // it is the one a watcher cannot get any other way.
@@ -2559,12 +3209,12 @@ export async function handleManage(sessionIdOrName, rawArg) {
2559
3209
  ? `Keep it current as you work.`
2560
3210
  : `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
3211
  }
2562
- note(existing, `handover file set to ${path} — asked for at ${Math.round(HANDOVER_AT * 100)}% context${wantsClear ? ", then cleared" : ", no clear"}`);
3212
+ note(existing, `handover file set to ${path} — asked for ${resolvedMarginK(existing)}k below the compaction trigger${wantsClear ? ", then cleared" : ", no clear"}`);
2563
3213
  saveState(state);
2564
3214
  return {
2565
3215
  ok: true,
2566
3216
  managed: true,
2567
- message: `${name} will be asked to hand over at ${Math.round(HANDOVER_AT * 100)}% of its context, into ${path}.\n` +
3217
+ 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
3218
  (resolved === path
2569
3219
  ? ""
2570
3220
  : ` Today that resolves to ${resolved}; the date is worked out each time it is asked for, not now.\n`) +
@@ -2580,6 +3230,37 @@ export async function handleManage(sessionIdOrName, rawArg) {
2580
3230
  : `\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
3231
  };
2582
3232
  }
3233
+ /**
3234
+ * handover-at <k> — this session's own warm-up margin, overriding the
3235
+ * default 100k.
3236
+ *
3237
+ * A fixed FRACTION of a fixed window cannot be right for every session —
3238
+ * that was tried (0.82 of a 1000k window) and the ground moved under it:
3239
+ * the same configured override produced compactions averaging ~1000k
3240
+ * before 2026-09-12 and ~784k after. So the margin is now measured from
3241
+ * wherever the trigger actually turns out to be for THIS project (see
3242
+ * effectiveCompactK / measuredCompactK), and this only ever overrides how
3243
+ * far below that point to ask — in K tokens, not a share of anything.
3244
+ */
3245
+ const handoverAtMatch = arg.match(/^handover-at\s+(\d+)\s*$/i);
3246
+ if (handoverAtMatch && existing) {
3247
+ const n = Number(handoverAtMatch[1]);
3248
+ if (!Number.isFinite(n) || n < MARGIN_K_MIN || n > MARGIN_K_MAX) {
3249
+ return {
3250
+ ok: false,
3251
+ message: `handover-at must be a whole number of K tokens between ${MARGIN_K_MIN} and ${MARGIN_K_MAX} (got "${handoverAtMatch[1]}")`,
3252
+ };
3253
+ }
3254
+ const wasK = resolvedMarginK(existing);
3255
+ existing.handoverMarginK = n;
3256
+ note(existing, `handover warm-up margin set to ${n}k below the trigger`);
3257
+ saveState(state);
3258
+ return {
3259
+ ok: true,
3260
+ managed: true,
3261
+ message: `${name} will now be asked to hand over ${n}k below its compaction trigger (was ${wasK}k).`,
3262
+ };
3263
+ }
2583
3264
  if (word === "pause" || word === "resume") {
2584
3265
  if (!existing)
2585
3266
  return { ok: false, message: `${name} is not being managed` };