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.
- package/dist/agentish/index.d.ts +24 -0
- package/dist/agentish/index.d.ts.map +1 -1
- package/dist/agentish/index.js +99 -8
- package/dist/agentish/index.js.map +1 -1
- package/dist/daemon/agentish-cli.d.ts.map +1 -1
- package/dist/daemon/agentish-cli.js +37 -3
- package/dist/daemon/agentish-cli.js.map +1 -1
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +35 -0
- package/dist/daemon/core-handlers.js.map +1 -1
- package/dist/daemon/manage.d.ts +256 -0
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +962 -96
- package/dist/daemon/manage.js.map +1 -1
- package/dist/daemon/screenshot.d.ts +13 -0
- package/dist/daemon/screenshot.d.ts.map +1 -1
- package/dist/daemon/screenshot.js +60 -29
- package/dist/daemon/screenshot.js.map +1 -1
- package/dist/transport/sync-facade.d.ts +19 -0
- package/dist/transport/sync-facade.d.ts.map +1 -1
- package/dist/transport/sync-facade.js +32 -0
- package/dist/transport/sync-facade.js.map +1 -1
- package/dist/transport/tmux.d.ts +2 -0
- package/dist/transport/tmux.d.ts.map +1 -1
- package/dist/transport/tmux.js +7 -0
- package/dist/transport/tmux.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon/manage.js
CHANGED
|
@@ -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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
1893
|
+
* TEXT ON THE INPUT LINE — READ BACK FIRST, NEVER DESTROY LIVE INPUT.
|
|
1302
1894
|
*
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
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
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
:
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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,
|
|
1842
|
-
|
|
1843
|
-
|
|
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
|
-
|
|
1881
|
-
|
|
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.
|
|
1963
|
-
`
|
|
1964
|
-
`
|
|
1965
|
-
`
|
|
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
|
|
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
|
|
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` };
|