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