aibroker 0.55.0 → 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,10 +25,11 @@ import { join, dirname } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
  import { log } from "../core/log.js";
27
27
  import { 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";
32
+ import { captureSessionPng } from "./screenshot.js";
32
33
  import { listDialogs, answerDialog } from "./dialogs.js";
33
34
  import { readControls, grantUntil, returnToOperator, verdict as screenVerdict, describeControls, screenGrantedClause, } from "./pointer-controls.js";
34
35
  import { AG2_SPEC } from "./agentish.js";
@@ -76,18 +77,66 @@ const GOAL_MAX_AGE_MS = 45 * 60_000;
76
77
  */
77
78
  const GOAL_ACTIVE = /\/goal\s+active/i;
78
79
  /**
79
- * Where a session is asked to hand over, as a share of its context.
80
- *
81
- * NOT where it dies — where it should stop and write down what it knows while
82
- * it still can. A session at the wall cannot compose a handover, because
83
- * composing one is exactly the sort of work it no longer has room for. The
84
- * margin has to be big enough to write in.
85
- *
86
- * Deliberately conservative. Rolling over early costs one cycle of re-reading a
87
- * file; rolling over late costs everything the session had not written down,
88
- * 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.
89
131
  */
90
- 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
+ }
91
140
  /**
92
141
  * How long the manager waits for the handover before giving up on it.
93
142
  *
@@ -109,6 +158,32 @@ const HANDOVER_GRACE_MS = 6 * 60_000;
109
158
  * leans towards firing.
110
159
  */
111
160
  const STUCK_AFTER_MS = 12 * 60_000;
161
+ /**
162
+ * How long a session may sit with arming failing before it is treated as
163
+ * BLOCKED rather than merely slow to confirm.
164
+ *
165
+ * This is the content-agnostic path, for the case the fast prompt-text match
166
+ * cannot see: a native macOS dialog (a permission or security escalation
167
+ * that even bypass mode raises) steals keyboard focus without leaving any
168
+ * trace in the pane it sits in front of. Nothing distinguishes that from an
169
+ * ordinary slow turn except that arming keeps failing — so this fires only
170
+ * once BOTH are true: ARM_ATTEMPTS have already failed, and the screen still
171
+ * has not moved since. Three minutes, not twelve, because a session already
172
+ * failing to arm is already a worse sign than a merely quiet one.
173
+ */
174
+ const BLOCKED_STATIC_MS = 3 * 60_000;
175
+ /**
176
+ * How long to wait before re-alerting the operator about a BLOCKED session.
177
+ *
178
+ * The overnight failure this exists for produced the same log line every
179
+ * tick for four hours with nobody told. The opposite fault — a buzz every
180
+ * 20 seconds for as long as the dialog sits there — is nearly as bad, since
181
+ * an alert that repeats every tick teaches its reader to swipe it away
182
+ * unread. Fifteen minutes says it once, then again if it is still true a
183
+ * while later, which is the shape a "you need to look at this" message
184
+ * should have.
185
+ */
186
+ const BLOCKED_REALERT_MS = 15 * 60_000;
112
187
  /**
113
188
  * The startup banner, which is the pane's only POSITIVE evidence of a clear.
114
189
  *
@@ -191,6 +266,23 @@ const OUT_OF_GOAL = [
191
266
  /goal not achieved/i,
192
267
  /could not achieve the goal/i,
193
268
  ];
269
+ /**
270
+ * Whether the pane is showing a Claude Code permission/approval prompt.
271
+ *
272
+ * Kept deliberately narrow. A busy pane and a stuck one both look the same
273
+ * to a change-hash — nothing moves — so telling them apart has to come from
274
+ * the actual words on screen, and the words checked here are ones a genuine
275
+ * permission dialog produces and very little else does. A background-agent
276
+ * status line ("Waiting for 1 background agent to finish") or a bare shell
277
+ * prompt must both read as false here, or every long tool call starts
278
+ * looking like a stuck session and the manager stops arming real work.
279
+ */
280
+ export function pendingPrompt(content) {
281
+ return (/Do you want to proceed\?/i.test(content) ||
282
+ /tell Claude what to do differently/i.test(content) ||
283
+ /❯\s*\d+\.\s*(Yes|No)\b/.test(content) ||
284
+ /Yes,\s*and don'?t ask again/i.test(content));
285
+ }
194
286
  /**
195
287
  * A duration nobody can print nonsense from.
196
288
  *
@@ -349,6 +441,254 @@ function fileFingerprint(path) {
349
441
  return "";
350
442
  }
351
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
+ }
352
692
  /** This session's context in thousands of tokens, from its own transcript. */
353
693
  function contextK(m) {
354
694
  const tty = m.tty ?? snapshotTty(m.sessionId);
@@ -553,6 +893,36 @@ function reportToOperator(text) {
553
893
  return;
554
894
  alertOperator(text);
555
895
  }
896
+ /**
897
+ * Alert the operator about a BLOCKED session, with a screenshot if one can
898
+ * be had.
899
+ *
900
+ * A picture of the actual prompt is worth having — "blocked" alone does not
901
+ * say whether it is a two-line yes/no or a wall of text — but capturing one
902
+ * means stealing the operator's window focus for a moment (see
903
+ * captureSessionPng), so it is only attempted at all when a phone is
904
+ * actually there to receive it. Any failure along the way — no client
905
+ * connected, the window could not be found, the capture itself errored —
906
+ * falls back to the plain text alert rather than saying nothing.
907
+ */
908
+ async function alertBlocked(m, caption) {
909
+ if (hasPailotClients()) {
910
+ try {
911
+ const shot = await captureSessionPng(m.sessionId);
912
+ if (shot) {
913
+ getAibpBridge()?.routeToMobile(m.sessionId, caption, "IMAGE", {
914
+ imageBase64: shot.buffer.toString("base64"),
915
+ mimeType: shot.mime,
916
+ });
917
+ return;
918
+ }
919
+ }
920
+ catch (e) {
921
+ log(`[manage:${m.name}] blocked-alert screenshot failed — ${e.message}`);
922
+ }
923
+ }
924
+ alertOperator(caption);
925
+ }
556
926
  /**
557
927
  * A note that is ALSO worth a buzz on the phone.
558
928
  *
@@ -587,6 +957,21 @@ let lastReportAt = 0;
587
957
  * while looking like that one.
588
958
  */
589
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();
590
975
  /**
591
976
  * The periodic reading: what every managed session is doing, in one message.
592
977
  *
@@ -716,6 +1101,37 @@ function processReading(tty) {
716
1101
  return { isSession: false, pid: null };
717
1102
  return { isSession: true, pid: claude.pid };
718
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
+ }
719
1135
  /**
720
1136
  * What the session is doing, from its own transcript — the authority.
721
1137
  *
@@ -739,20 +1155,56 @@ function processReading(tty) {
739
1155
  * - WHEN: the entry's timestamp, so "how long has this been going" is a
740
1156
  * subtraction rather than a guess.
741
1157
  */
742
- function transcriptReading(claudePid) {
743
- 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) {
744
1165
  try {
745
- // The transcript directory is named for the session's working directory,
746
- // which the process itself is the authority on.
747
1166
  const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", claudePid, "-a", "-d", "cwd", "-Fn"], {
748
1167
  encoding: "utf8",
749
1168
  timeout: 4_000,
750
1169
  });
751
- const cwd = cwdOut.split("\n").find((l) => l.startsWith("n"))?.slice(1);
752
- if (!cwd)
753
- return none;
754
- const dir = join(homedir(), ".claude", "projects", cwd.replace(/\//g, "-"));
755
- 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)
756
1208
  return none;
757
1209
  // The live transcript is the one being written. Newest wins; a session that
758
1210
  // has not written for a long time will show that in its own timestamp
@@ -795,16 +1247,134 @@ function transcriptReading(claudePid) {
795
1247
  : last.type === "user"
796
1248
  ? "waiting on a tool result"
797
1249
  : null;
798
- const u = lastAssistant?.message?.usage;
799
- const contextK = u
800
- ? Math.round(((u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)) / 1000)
801
- : 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;
802
1255
  return { working, doing, contextK, lastAt };
803
1256
  }
804
1257
  catch {
805
1258
  return none;
806
1259
  }
807
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
+ }
808
1378
  /**
809
1379
  * The status line, assembled from the sources in order of authority.
810
1380
  *
@@ -818,7 +1388,7 @@ function transcriptReading(claudePid) {
818
1388
  * which number came from the transcript and which was scraped off a status bar
819
1389
  * cannot tell which one to doubt.
820
1390
  */
821
- function liveReading(sessionId, idleSec) {
1391
+ function liveReading(sessionId, idleSec, m) {
822
1392
  const snap = discoverLiveSessions().find((s) => s.id === sessionId);
823
1393
  const proc = snap?.tty ? processReading(snap.tty) : { isSession: false, pid: null };
824
1394
  if (!proc.isSession) {
@@ -830,8 +1400,30 @@ function liveReading(sessionId, idleSec) {
830
1400
  const agoSec = Math.round((Date.now() - t.lastAt) / 1000);
831
1401
  out.push(` ${t.working ? "working" : "idle"} · last transcript entry ${agoSec < 90 ? `${agoSec}s` : `${Math.round(agoSec / 60)} min`} ago` +
832
1402
  (t.doing ? ` · ${t.doing}` : ""));
833
- if (t.contextK !== null)
1403
+ if (t.contextK !== null) {
834
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
+ }
835
1427
  }
836
1428
  else {
837
1429
  out.push(` a session is running, but its transcript could not be read — falling back to the screen`);
@@ -1298,28 +1890,75 @@ async function arm(m, reason) {
1298
1890
  return false;
1299
1891
  }
1300
1892
  /**
1301
- * TEXT ON THE INPUT LINE IS NOTED, NEVER OBEYED.
1893
+ * TEXT ON THE INPUT LINE — READ BACK FIRST, NEVER DESTROY LIVE INPUT.
1302
1894
  *
1303
- * This used to refuse to arm while anything sat unsent in the prompt, to
1304
- * avoid running the manager's goal into a half-typed sentence. The intention
1305
- * was right and the mechanism could not support it: the terminal offers a
1306
- * greyed-out SUGGESTION on that same line, accepted with Tab, and in a
1307
- * captured pane no colour survives to tell the two apart. So a suggestion
1308
- * read as somebody mid-sentence, and since a suggestion never finishes being
1309
- * typed, the refusal never lifted. A session sat idle with its goal spent and
1310
- * 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.
1311
1902
  *
1312
- * Arming is the one thing that must not be blocked by a signal this weak. A
1313
- * stalled agent is certain and unbounded; running into somebody's half-typed
1314
- * line is occasional and costs one prompt they can retype. So the reading is
1315
- * kept — it is worth having in the record when a goal arrives mangled — and
1316
- * 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.
1317
1910
  */
1318
1911
  const onLine = promptUnsentText(readPane(m.sessionId));
1319
- 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)) {
1320
1951
  note(m, `could not type into the session (${reason}) — will retry`);
1321
1952
  return false;
1322
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);
1323
1962
  // Typed is not sent, and sent is not received.
1324
1963
  for (let i = 0; i < 5; i++) {
1325
1964
  await sleep(2_000);
@@ -1328,16 +1967,49 @@ async function arm(m, reason) {
1328
1967
  const carried = m.pending.length;
1329
1968
  m.pending = [];
1330
1969
  armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
1331
- note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}` +
1332
- // Recorded because it is the one thing that explains a goal arriving
1333
- // with somebody's half-sentence welded to the front of it.
1334
- (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" : ""})` : ""}`);
1335
1975
  return true;
1336
1976
  }
1337
1977
  }
1338
1978
  notify(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
1339
1979
  return false;
1340
1980
  }
1981
+ /**
1982
+ * Why a managed session should be treated as BLOCKED — waiting on a human
1983
+ * decision arming cannot supply — rather than merely idle or slow.
1984
+ *
1985
+ * Two paths, checked in this order because they answer different questions.
1986
+ * The fast path reads the words on screen and needs nothing else: a
1987
+ * permission prompt IS a permission prompt the instant it appears, however
1988
+ * recently the pane last changed. The slow path exists for what the fast one
1989
+ * cannot see — a native macOS dialog steals keyboard focus and leaves no
1990
+ * trace in the terminal's own buffer — so it infers the same state from
1991
+ * arming's own repeated failure instead: ARM_ATTEMPTS have already come back
1992
+ * unconfirmed AND the screen has still not moved since. Neither signal alone
1993
+ * would be trustworthy (a slow turn fails to arm too; a quiet pane is often
1994
+ * just a quiet pane) but together they are the two ways this actually
1995
+ * happens.
1996
+ *
1997
+ * Returns the reason string rather than a boolean because the caller puts it
1998
+ * straight into the operator alert — computing it a second time to describe
1999
+ * what the first computation found is how the two descriptions drift apart.
2000
+ */
2001
+ export function blockedReason(m, content, now) {
2002
+ if (pendingPrompt(content))
2003
+ return "permission prompt";
2004
+ const quietFor = now - (m.lastChangeAt ?? now);
2005
+ // Stuck = we tried to arm repeatedly (streak survived the cap reset) AND,
2006
+ // now that we have stopped typing, the pane has gone static. The static
2007
+ // check matters because our own arming used to keep the pane changing.
2008
+ if (m.stuckSince !== undefined && quietFor >= BLOCKED_STATIC_MS) {
2009
+ return `stuck ${Math.round((now - m.stuckSince) / 60_000)}m, input not accepted`;
2010
+ }
2011
+ return null;
2012
+ }
1341
2013
  function reasonToArm(m, content, now) {
1342
2014
  if (m.paused)
1343
2015
  return null;
@@ -1453,6 +2125,73 @@ async function tick() {
1453
2125
  m.lastChangeAt = now;
1454
2126
  dirty = true;
1455
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
+ }
1456
2195
  /**
1457
2196
  * THE BACKSTOP: a managed session whose screen has not moved in a long time.
1458
2197
  *
@@ -1468,8 +2207,16 @@ async function tick() {
1468
2207
  * turn, costing nothing; against a dead one it is the whole recovery. The
1469
2208
  * asymmetry is the argument — and it is why this fires on a signal as crude
1470
2209
  * as "nothing changed", which no more precise test would improve on.
2210
+ *
2211
+ * Excluded once `stuckSince` is set: that means arming has already been
2212
+ * tried and failed enough to be classed as stuck, and this backstop's
2213
+ * response — type into it, and count the typing itself as a change — is
2214
+ * exactly the loop the blocked-alert exists to stop. Firing here would
2215
+ * reset `lastChangeAt` right when the blocked check needs it to keep
2216
+ * climbing, silently clearing the confirmed-blocked state without the
2217
+ * session having moved at all.
1471
2218
  */
1472
- if (!m.paused && !m.handoverAskedAt && now - m.lastChangeAt > STUCK_AFTER_MS) {
2219
+ if (!m.paused && !m.handoverAskedAt && !m.stuckSince && now - m.lastChangeAt > STUCK_AFTER_MS) {
1473
2220
  notify(m, `nothing has moved on that screen for ${minutesSince(m.lastChangeAt, now)} — arming, because a managed session is never meant to be still this long`);
1474
2221
  // Counted as a change so a session that stays stuck is not re-armed every
1475
2222
  // tick: this is a recovery, and a recovery that repeats is a loop.
@@ -1790,61 +2537,132 @@ async function tick() {
1790
2537
  // high precisely because the clear has not landed, so without this the
1791
2538
  // threshold re-qualifies the session every tick and the rollover machinery
1792
2539
  // runs in a circle, each lap adding another clear to the queue.
1793
- if (!m.paused && !m.handoverAskedAt && !m.clearPendingSince && m.handoverFile) {
1794
- // The pane is resolved here rather than carried in from elsewhere in the
1795
- // tick, so this block does not depend on the order of what precedes it.
1796
- const tty = m.tty ?? snapshotTty(m.sessionId);
1797
- const pid = tty ? processReading(tty).pid : null;
1798
- const t = pid ? transcriptReading(pid) : null;
1799
- const used = t?.contextK ?? null;
1800
- /**
1801
- * TWO WAYS TO BECOME DUE, because a handover goes out of date two ways.
1802
- *
1803
- * By the clock, which is the ordinary case. And by work done since the
1804
- * last one, which is the case that mattered and was missing: a session
1805
- * asked at the threshold keeps working to the wall, and everything it
1806
- * learns in that stretch is absent from the file precisely when
1807
- * compaction discards it. The second trigger keeps the document current
1808
- * with the work rather than with the hour.
1809
- */
1810
- 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;
1811
2555
  const grownBy = used !== null && m.handoverDoneK !== undefined ? used - m.handoverDoneK : null;
1812
- const dueByTime = sinceLast > HANDOVER_REASK_MS;
1813
- const dueByWork = grownBy !== null && grownBy >= HANDOVER_REASK_K && sinceLast > HANDOVER_MIN_GAP_MS;
1814
- // 1M is the window these sessions run in; treat anything else as unknown
1815
- // rather than guessing, because a wrong denominator rolls over a session
1816
- // that had plenty of room left.
1817
- if ((dueByTime || dueByWork) && used !== null && used / 1000 >= HANDOVER_AT) {
1818
- 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);
1819
2562
  m.handoverAskedAt = now;
1820
2563
  m.handoverAskedPath = askedPath;
1821
- m.handoverWas = fileFingerprint(askedPath);
2564
+ m.handoverWas = askedPath ? fileFingerprint(askedPath) : undefined;
1822
2565
  // A dated handover starts empty each day, and an empty one is worse
1823
2566
  // than none: it reads as authoritative and says nothing. So the
1824
2567
  // instruction carries the rule for that case rather than assuming the
1825
2568
  // session will think of it at the moment it is running out of room.
1826
- const carry = existsSync(askedPath)
1827
- ? ""
1828
- : `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. `;
1829
- // A top-up reads differently from a first request: the session has
1830
- // already written one and needs to know this is about the work SINCE,
1831
- // not a repeat it can satisfy by confirming the file is still there.
1832
- const topUp = dueByWork && !dueByTime && grownBy !== null;
1833
- typeIntoSession(m.sessionId, (topUp
1834
- ? `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. `
1835
- : `Write your handover now — you are at ${used}k tokens and the terminal will compact before long. `) +
1836
- `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, ` +
1837
2589
  `and — the irreplaceable part — anything you know that is written nowhere else. Commit it. ` +
1838
2590
  (m.clearAfterHandover
1839
2591
  ? `You will be cleared once that file has changed on disk, and not before.`
1840
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.`));
1841
- notify(m, topUp
1842
- ? `at ${used}k tokens, ${grownBy}k of new work since the last one — asked to bring the handover up to date`
1843
- : `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"}`);
1844
2603
  dirty = true;
1845
2604
  continue;
1846
2605
  }
1847
2606
  }
2607
+ /**
2608
+ * BLOCKED: waiting on a human decision that arming cannot supply.
2609
+ *
2610
+ * This is the fix for a session that hit a permission or security
2611
+ * prompt overnight — even bypass mode escalates some of these — and sat
2612
+ * answered by nobody for four hours while the manager kept typing the
2613
+ * goal at it and logging "will retry". Typing MORE text at a modal does
2614
+ * not help; at best it queues, at worst it lands somewhere the operator
2615
+ * never sees. So this is checked BEFORE any arm attempt below, and it
2616
+ * never answers the prompt itself — only says, loudly, that it exists.
2617
+ *
2618
+ * Gated on holding a screen grant (`lease`, computed above) because
2619
+ * confirming a screenshot requires bringing that iTerm2 window to the
2620
+ * front via AppleScript, which steals whatever window currently has the
2621
+ * operator's focus. Without a grant that is not this manager's window to
2622
+ * take, so a session in that state gets no special handling here and
2623
+ * simply keeps going through the ordinary arm path below — worse than
2624
+ * ideal, but never worse than before this existed.
2625
+ */
2626
+ if (!m.paused && lease) {
2627
+ const blocked = blockedReason(m, content, now);
2628
+ if (blocked) {
2629
+ if (!m.blockedAlertedAt || now - m.blockedAlertedAt >= BLOCKED_REALERT_MS) {
2630
+ const quietMin = Math.round(Math.max(0, now - m.lastChangeAt) / 60_000);
2631
+ m.blockedSince ??= now;
2632
+ m.blockedAlertedAt = now;
2633
+ const caption = `⚠️ ${m.name} looks blocked (${blocked}) — no change for ${quietMin}m. ` +
2634
+ `Needs your approve/decline; I will not answer it.`;
2635
+ void alertBlocked(m, caption);
2636
+ note(m, `blocked (${blocked}) — alerted the operator, not arming`);
2637
+ dirty = true;
2638
+ }
2639
+ continue;
2640
+ }
2641
+ // Recovery. Gated on having ALREADY confirmed-and-alerted a block —
2642
+ // not merely on armFailStreak/stuckSince being set — because quietFor
2643
+ // is, by construction, still under BLOCKED_STATIC_MS for the entire
2644
+ // ramp from "stuck" to "confirmed blocked": clearing on that alone
2645
+ // would wipe stuckSince on the very next tick, every time, and the
2646
+ // alert below would never get a chance to fire. Once blockedSince
2647
+ // exists, though, BLOCKED_STATIC_MS of true silence already elapsed,
2648
+ // so a later drop in quietFor is unambiguously the session moving on
2649
+ // its own, not an artifact of this manager's own last typed attempt.
2650
+ if (m.blockedSince !== undefined || m.blockedAlertedAt !== undefined) {
2651
+ m.armFailStreak = 0;
2652
+ delete m.stuckSince;
2653
+ delete m.blockedSince;
2654
+ delete m.blockedAlertedAt;
2655
+ dirty = true;
2656
+ }
2657
+ }
2658
+ // Once arming has failed enough to be considered stuck, stop typing into
2659
+ // the session — the only way BLOCKED_STATIC_MS of true quiet is ever
2660
+ // reached is if this manager is not the one refreshing the pane every
2661
+ // REARM_COOLDOWN_MS. This is what actually fixes the overnight bug: the
2662
+ // old code kept re-arming (and re-spamming) forever because each retype
2663
+ // reset lastChangeAt and the freeze detector never got a quiet window.
2664
+ if (m.stuckSince !== undefined)
2665
+ continue;
1848
2666
  if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
1849
2667
  continue;
1850
2668
  const reason = reasonToArm(m, content, now);
@@ -1871,14 +2689,26 @@ async function tick() {
1871
2689
  dirty = true;
1872
2690
  if (armed) {
1873
2691
  m.armFails = 0;
2692
+ m.armFailStreak = 0;
2693
+ delete m.stuckSince;
2694
+ delete m.blockedSince;
2695
+ delete m.blockedAlertedAt;
1874
2696
  continue;
1875
2697
  }
1876
2698
  m.armFails = (m.armFails ?? 0) + 1;
2699
+ m.armFailStreak = (m.armFailStreak ?? 0) + 1;
1877
2700
  if (m.armFails >= ARM_ATTEMPTS) {
1878
2701
  m.armFails = 0;
1879
2702
  m.lastRearmAt = now;
1880
- notify(m, `typed the goal ${ARM_ATTEMPTS} times without being able to confirm it landed — stopping, so it is not queued again. ` +
1881
- `The session is usually mid-turn when this happens; it stays managed and will arm at the next real lapse.`);
2703
+ m.stuckSince ??= now;
2704
+ // No operator push here. Pushing on every cap is the 144-alerts-overnight
2705
+ // bug: this fires again every ~2-4 minutes for as long as arming keeps
2706
+ // failing, and it is content-agnostic — it cannot tell "mid-turn, will
2707
+ // clear itself" from "stuck on a modal for hours". The blocked check
2708
+ // above owns the operator alert now: it has `armFailStreak`/`stuckSince`
2709
+ // (which survive this reset) plus a static-pane confirmation, and its
2710
+ // own BLOCKED_REALERT_MS dedup. This stays log-only.
2711
+ note(m, `arming keeps failing (streak ${m.armFailStreak}) — holding off; the blocked check owns the operator alert now`);
1882
2712
  }
1883
2713
  }
1884
2714
  if (dirty)
@@ -1959,10 +2789,15 @@ export async function handleManage(sessionIdOrName, rawArg) {
1959
2789
  ` \`until\` takes the next time the clock reads it, which is\n` +
1960
2790
  ` what somebody deciding at midnight actually means\n` +
1961
2791
  ` handover <path> [clear]\n` +
1962
- ` where this session writes what it knows. At 82% context it\n` +
1963
- ` is asked to update that file, then carries on — the terminal\n` +
1964
- ` compacts by itself and the file is what survives it. Add\n` +
1965
- ` "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` +
1966
2801
  ` set <text> REPLACE the standing objective. Plain text on a running\n` +
1967
2802
  ` manager is a one-shot note; this changes what it re-arms\n` +
1968
2803
  ` add <text> EXTEND the standing objective. Say it to the session\n` +
@@ -2159,7 +2994,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
2159
2994
  message: `managing ${name}${existing.paused ? " (paused)" : ""}\n` +
2160
2995
  `objective: ${existing.objective}\n` +
2161
2996
  `\nright now:\n` +
2162
- liveReading(sessionId, idle) +
2997
+ liveReading(sessionId, idle, existing) +
2163
2998
  // Who holds the screen belongs in the live reading, not in the
2164
2999
  // manager's own record: it is a fact about the machine right now, and
2165
3000
  // it is the one a watcher cannot get any other way.
@@ -2374,12 +3209,12 @@ export async function handleManage(sessionIdOrName, rawArg) {
2374
3209
  ? `Keep it current as you work.`
2375
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.`));
2376
3211
  }
2377
- 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"}`);
2378
3213
  saveState(state);
2379
3214
  return {
2380
3215
  ok: true,
2381
3216
  managed: true,
2382
- 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` +
2383
3218
  (resolved === path
2384
3219
  ? ""
2385
3220
  : ` Today that resolves to ${resolved}; the date is worked out each time it is asked for, not now.\n`) +
@@ -2395,6 +3230,37 @@ export async function handleManage(sessionIdOrName, rawArg) {
2395
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.`),
2396
3231
  };
2397
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
+ }
2398
3264
  if (word === "pause" || word === "resume") {
2399
3265
  if (!existing)
2400
3266
  return { ok: false, message: `${name} is not being managed` };