aibroker 0.62.3 → 0.62.4
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/daemon/manage.d.ts +124 -6
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +227 -54
- package/dist/daemon/manage.js.map +1 -1
- package/dist/mcp/index.js +2 -2
- package/dist/mcp/index.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon/manage.js
CHANGED
|
@@ -1822,8 +1822,8 @@ export function oneLine(text) {
|
|
|
1822
1822
|
function landsWhen(m) {
|
|
1823
1823
|
if (m.paused)
|
|
1824
1824
|
return " The manager is paused — nothing is armed until you resume it.";
|
|
1825
|
-
return (" This
|
|
1826
|
-
"
|
|
1825
|
+
return (" This arms on the next tick — every ask is now, and a line typed at a\n" +
|
|
1826
|
+
" session mid-turn queues at the turn boundary and interrupts nothing.");
|
|
1827
1827
|
}
|
|
1828
1828
|
/**
|
|
1829
1829
|
* Standing rules: how to work, written once, typed at every arming.
|
|
@@ -2091,6 +2091,32 @@ export function applySupervise(m, goal, until, every) {
|
|
|
2091
2091
|
m.paused = false;
|
|
2092
2092
|
note(m, `supervise: until ${until !== null ? hhmm(until) : "open-ended"}, nudge every ${m.nudgeEveryMin ?? "-"} min`);
|
|
2093
2093
|
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Change the standing objective on a managed entry.
|
|
2096
|
+
*
|
|
2097
|
+
* EVERY ask for arming is now (2026-10-17 14:48), and a changed objective is
|
|
2098
|
+
* an ask: the new text must reach the session, not whenever the lapse timer
|
|
2099
|
+
* next gets around to it, but on the very next tick. `0` is the sentinel the
|
|
2100
|
+
* arm gates read as "arm now" — idle or queued behind a running turn, which
|
|
2101
|
+
* is safe because a queued line lands at the turn boundary and interrupts
|
|
2102
|
+
* nothing.
|
|
2103
|
+
*/
|
|
2104
|
+
export function setObjective(m, objective) {
|
|
2105
|
+
m.objective = objective;
|
|
2106
|
+
m.lastRearmAt = 0;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Queue an operator instruction, and demand the arming that delivers it.
|
|
2110
|
+
*
|
|
2111
|
+
* The queue only goes out with an arming, so an instruction that did not
|
|
2112
|
+
* demand one waited for the lapse timer — an arming that had nothing to do
|
|
2113
|
+
* with what the operator just said, and one they may wait the better part of
|
|
2114
|
+
* an hour for. Every ask is now: the instruction rides out on the next tick.
|
|
2115
|
+
*/
|
|
2116
|
+
export function queueInstruction(m, instruction) {
|
|
2117
|
+
m.pending.push(instruction);
|
|
2118
|
+
m.lastRearmAt = 0;
|
|
2119
|
+
}
|
|
2094
2120
|
// ── only the operator ends or shortens a goal ────────────────────────────────
|
|
2095
2121
|
export const AGENT_REFUSAL = 'refused: only the operator ends or shortens a goal (say "manage <session> off" yourself in a session, or run it from your own terminal)';
|
|
2096
2122
|
const TOKEN_TTL_MS = 5 * 60_000;
|
|
@@ -2194,6 +2220,54 @@ export function nudgeDue(m, content, now) {
|
|
|
2194
2220
|
return due !== null && now >= due;
|
|
2195
2221
|
}
|
|
2196
2222
|
export const NUDGE_TEXT = "Continue working toward your standing goal.";
|
|
2223
|
+
/**
|
|
2224
|
+
* The API-retry banner, seen live 2026-10-17 14:50:
|
|
2225
|
+
*
|
|
2226
|
+
* Goal still active · the API was unavailable or the connection dropped ·
|
|
2227
|
+
* retrying in 1 min (1/3) · send a message to retry now
|
|
2228
|
+
*
|
|
2229
|
+
* Claude Code retries three times, then gives up — and the goal sits frozen
|
|
2230
|
+
* over a session that is not working but sleeping between retries. The banner
|
|
2231
|
+
* itself offers the way out — any message retries now — so the manager takes
|
|
2232
|
+
* it: one minimal continue per minute, for as long as the banner lasts,
|
|
2233
|
+
* deterministically and with no cap, because a stretch of API trouble can
|
|
2234
|
+
* outlast any count.
|
|
2235
|
+
*/
|
|
2236
|
+
export const API_RETRY = /the API was unavailable or the connection dropped|retrying in \d+ min \(\d+\/3\)/;
|
|
2237
|
+
/** At most one retry-breaking message per minute while the banner persists. */
|
|
2238
|
+
export const API_RETRY_EVERY_MS = 60_000;
|
|
2239
|
+
/** The history note about the banner dedups; the nudge itself is 1/60s, not the note. */
|
|
2240
|
+
const API_RETRY_NOTE_EVERY_MS = 5 * 60_000;
|
|
2241
|
+
export const API_RETRY_TEXT = "Continue now — the API is back. Pick the turn up where it dropped.";
|
|
2242
|
+
/**
|
|
2243
|
+
* Type the retry-breaking continue when this pane shows the banner and a
|
|
2244
|
+
* minute has passed since the last one. Never over text sitting unsent on the
|
|
2245
|
+
* input line (skip silently; the next tick tries again), never while paused,
|
|
2246
|
+
* never past a shift's end — the banner is not a reason to restart a
|
|
2247
|
+
* stood-down session. Returns true when a line went out.
|
|
2248
|
+
*
|
|
2249
|
+
* The tick calls it before the busy gate: the retry wait IS the busy state
|
|
2250
|
+
* here, and typing mid-turn is safe because the line queues at the turn
|
|
2251
|
+
* boundary. `type` is injectable so the caller's exact behavior is testable
|
|
2252
|
+
* without a terminal; the tick passes nothing and gets the real one.
|
|
2253
|
+
*/
|
|
2254
|
+
export function apiRetryNudge(m, content, now, type = typeIntoSession) {
|
|
2255
|
+
if (m.paused || shiftOver(m, now))
|
|
2256
|
+
return false;
|
|
2257
|
+
if (!API_RETRY.test(content))
|
|
2258
|
+
return false;
|
|
2259
|
+
if (promptUnsentText(content) !== null)
|
|
2260
|
+
return false;
|
|
2261
|
+
if (now - (m.lastApiRetryAt ?? 0) < API_RETRY_EVERY_MS)
|
|
2262
|
+
return false;
|
|
2263
|
+
type(m.sessionId, API_RETRY_TEXT);
|
|
2264
|
+
m.lastApiRetryAt = now;
|
|
2265
|
+
if (!m.apiRetryNotedAt || now - m.apiRetryNotedAt >= API_RETRY_NOTE_EVERY_MS) {
|
|
2266
|
+
note(m, "API-retry banner on the pane — typed a continue to break the retry wait (1/min while it lasts)");
|
|
2267
|
+
m.apiRetryNotedAt = now;
|
|
2268
|
+
}
|
|
2269
|
+
return true;
|
|
2270
|
+
}
|
|
2197
2271
|
const hhmm = (t) => {
|
|
2198
2272
|
const d = new Date(t);
|
|
2199
2273
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
@@ -2499,7 +2573,7 @@ export async function tryExclusive(busy, id, fn) {
|
|
|
2499
2573
|
busy.delete(id);
|
|
2500
2574
|
}
|
|
2501
2575
|
}
|
|
2502
|
-
async function arm(m, reason) {
|
|
2576
|
+
export async function arm(m, reason) {
|
|
2503
2577
|
const { text } = goalText(m);
|
|
2504
2578
|
/**
|
|
2505
2579
|
* NEVER TYPE A GOAL INTO A BARE SHELL.
|
|
@@ -2524,12 +2598,12 @@ async function arm(m, reason) {
|
|
|
2524
2598
|
const live = await readSessionContentAsync(m.sessionId, 5);
|
|
2525
2599
|
if (!live) {
|
|
2526
2600
|
note(m, "the session could not be read — not typing anything");
|
|
2527
|
-
return
|
|
2601
|
+
return "failed";
|
|
2528
2602
|
}
|
|
2529
2603
|
if (live.atPrompt && !isClaudePane(live.name)) {
|
|
2530
2604
|
m.paused = true;
|
|
2531
2605
|
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.");
|
|
2532
|
-
return
|
|
2606
|
+
return "failed";
|
|
2533
2607
|
}
|
|
2534
2608
|
/**
|
|
2535
2609
|
* TEXT ON THE INPUT LINE — READ BACK FIRST, NEVER DESTROY LIVE INPUT.
|
|
@@ -2546,9 +2620,12 @@ async function arm(m, reason) {
|
|
|
2546
2620
|
* SKIPPED and retried next tick — never cleared, never typed over —
|
|
2547
2621
|
* unless the IDENTICAL text has sat there for GHOST_TICKS consecutive
|
|
2548
2622
|
* ticks (2 minutes) while the session is idle, which is inputLineDecision's
|
|
2549
|
-
* job to decide.
|
|
2550
|
-
*
|
|
2551
|
-
*
|
|
2623
|
+
* job to decide. A skip is NOT a failure: it counts toward nothing (on
|
|
2624
|
+
* 2026-10-17 14:42 the operator typing while a demanded arm waited was
|
|
2625
|
+
* counted as arm failures, crossed the cap, and the arm was never even
|
|
2626
|
+
* typed — see test/manage-arm-skip.test.ts). The skip says so in the log at
|
|
2627
|
+
* most once per 5 minutes; a DEMANDED arm held this way says so loudly, and
|
|
2628
|
+
* once.
|
|
2552
2629
|
*/
|
|
2553
2630
|
const onLine = promptUnsentText(await readPaneAsync(m.sessionId));
|
|
2554
2631
|
const armIdleMs = Math.max(0, Date.now() - m.lastChangeAt);
|
|
@@ -2561,8 +2638,23 @@ async function arm(m, reason) {
|
|
|
2561
2638
|
inputLineTracking.delete(m.sessionId);
|
|
2562
2639
|
const lineDecision = inputLineDecision({ text: onLine ?? "", sameForTicks, idle: armIdle, idleMs: armIdleMs });
|
|
2563
2640
|
if (lineDecision.action === "skip") {
|
|
2564
|
-
|
|
2565
|
-
|
|
2641
|
+
if (m.lastRearmAt !== 0) {
|
|
2642
|
+
delete m.forcedArmHoldNotedAt;
|
|
2643
|
+
if (!m.armSkipNotedAt || Date.now() - m.armSkipNotedAt >= 5 * 60_000) {
|
|
2644
|
+
note(m, `arm deferred: input line holds '${onLine}' (unsent for ${sameForTicks} tick${sameForTicks === 1 ? "" : "s"}, idle ${Math.round(armIdleMs / 1000)}s)`);
|
|
2645
|
+
m.armSkipNotedAt = Date.now();
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
else {
|
|
2649
|
+
// The operator demanded this arm; what is holding it is their own
|
|
2650
|
+
// unsent line. Say so, once, where it will be seen.
|
|
2651
|
+
if (!m.forcedArmHoldNotedAt) {
|
|
2652
|
+
notify(m, `a demanded arm (${reason}) is NOT being typed: the input line holds the operator's own unsent text ` +
|
|
2653
|
+
`("${(onLine ?? "").slice(0, 60)}") — clear or send that line to let the arm through; it is not typed over`);
|
|
2654
|
+
m.forcedArmHoldNotedAt = Date.now();
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
return "skipped";
|
|
2566
2658
|
}
|
|
2567
2659
|
if (lineDecision.action === "clear-then-type") {
|
|
2568
2660
|
note(m, lineDecision.logCleared ?? "clearing persistent input-line text before typing");
|
|
@@ -2591,7 +2683,7 @@ async function arm(m, reason) {
|
|
|
2591
2683
|
}
|
|
2592
2684
|
if (!pasteTextIntoSession(m.sessionId, text)) {
|
|
2593
2685
|
note(m, `could not type into the session (${reason}) — will retry`);
|
|
2594
|
-
return
|
|
2686
|
+
return "failed";
|
|
2595
2687
|
}
|
|
2596
2688
|
const { line: echoedLine, waitedMs } = await readBackTyped({
|
|
2597
2689
|
read: async () => promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "",
|
|
@@ -2602,7 +2694,7 @@ async function arm(m, reason) {
|
|
|
2602
2694
|
await sleep(300);
|
|
2603
2695
|
const left = promptUnsentText(await readPaneAsync(m.sessionId, { fresh: true })) ?? "";
|
|
2604
2696
|
note(m, `arm aborted: after ${waitedMs}ms line read back as "${echoedLine.slice(0, 60)}" not "${text.slice(0, 60)}"; after Ctrl-U the input ${left ? `still holds ${left.length} chars ("${left.slice(0, 60)}")` : "is empty"}`);
|
|
2605
|
-
return
|
|
2697
|
+
return "failed";
|
|
2606
2698
|
}
|
|
2607
2699
|
sendEnterKey(m.sessionId);
|
|
2608
2700
|
// Typed is not sent, and sent is not received.
|
|
@@ -2613,14 +2705,70 @@ async function arm(m, reason) {
|
|
|
2613
2705
|
});
|
|
2614
2706
|
if (landed) {
|
|
2615
2707
|
m.lastRearmAt = Date.now();
|
|
2708
|
+
delete m.forcedArmHoldNotedAt;
|
|
2616
2709
|
const carried = m.pending.length;
|
|
2617
2710
|
m.pending = [];
|
|
2618
2711
|
armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
|
|
2619
2712
|
note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
|
|
2620
|
-
return
|
|
2713
|
+
return "armed";
|
|
2621
2714
|
}
|
|
2622
2715
|
notify(m, `typed but the goal never took (no active marker, or the line stayed in the input) — input cleared, treating as NOT armed (${reason})`);
|
|
2623
|
-
return
|
|
2716
|
+
return "failed";
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* The book-keeping one arm attempt's outcome earns, shared by every caller
|
|
2720
|
+
* that retries on the tick.
|
|
2721
|
+
*
|
|
2722
|
+
* ONLY a "failed" counts. A skip is not here because it is not a fault: the
|
|
2723
|
+
* guard that declined (the operator's own text on the input line) is doing
|
|
2724
|
+
* its job, and the demand — or the lapse — is still pending on the next tick.
|
|
2725
|
+
* Counting skips is the 2026-10-17 14:42 fault: three ticks of the operator
|
|
2726
|
+
* typing crossed this cap, the cap consumed the forced-arm sentinel, and the
|
|
2727
|
+
* manager went silent exactly when it had been told to act.
|
|
2728
|
+
*/
|
|
2729
|
+
export function applyArmResult(m, result, now) {
|
|
2730
|
+
if (result === "armed") {
|
|
2731
|
+
m.armFails = 0;
|
|
2732
|
+
m.armFailStreak = 0;
|
|
2733
|
+
delete m.stuckSince;
|
|
2734
|
+
delete m.blockedSince;
|
|
2735
|
+
delete m.blockedAlertedAt;
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
if (result === "skipped")
|
|
2739
|
+
return;
|
|
2740
|
+
m.armFails = (m.armFails ?? 0) + 1;
|
|
2741
|
+
m.armFailStreak = (m.armFailStreak ?? 0) + 1;
|
|
2742
|
+
if (m.armFails >= ARM_ATTEMPTS) {
|
|
2743
|
+
m.armFails = 0;
|
|
2744
|
+
// The cap must not eat the sentinel: `lastRearmAt === 0` is a pending
|
|
2745
|
+
// DEMANDED arm, and stamping it with `now` is what turned "arm NOW" into
|
|
2746
|
+
// a 90-second cooldown and then a permanent mute on 2026-10-17 14:42.
|
|
2747
|
+
// A pending forced arm survives the cap.
|
|
2748
|
+
if (m.lastRearmAt !== 0)
|
|
2749
|
+
m.lastRearmAt = now;
|
|
2750
|
+
m.stuckSince ??= now;
|
|
2751
|
+
// No operator push here. Pushing on every cap is the 144-alerts-overnight
|
|
2752
|
+
// bug: this fires again every ~2-4 minutes for as long as arming keeps
|
|
2753
|
+
// failing, and it is content-agnostic — it cannot tell "mid-turn, will
|
|
2754
|
+
// clear itself" from "stuck on a modal for hours". The blocked check
|
|
2755
|
+
// above owns the operator alert now: it has `armFailStreak`/`stuckSince`
|
|
2756
|
+
// (which survive this reset) plus a static-pane confirmation, and its
|
|
2757
|
+
// own BLOCKED_REALERT_MS dedup. This stays log-only.
|
|
2758
|
+
note(m, `arming keeps failing (streak ${m.armFailStreak}) — holding off; the blocked check owns the operator alert now`);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Whether the stuck backoff — arming having crossed ARM_ATTEMPTS — may hold
|
|
2763
|
+
* this session's arm back.
|
|
2764
|
+
*
|
|
2765
|
+
* A DEMANDED arm (`lastRearmAt === 0`) is never held by it. This is the other
|
|
2766
|
+
* half of the 2026-10-17 14:42 fix: once the cap had marked the session stuck
|
|
2767
|
+
* mid-demand, this gate muted every later tick, so the demanded arm was never
|
|
2768
|
+
* even typed. A demand outranks a backoff it had no part in earning.
|
|
2769
|
+
*/
|
|
2770
|
+
export function stuckBackoffHolds(m) {
|
|
2771
|
+
return m.stuckSince !== undefined && m.lastRearmAt !== 0;
|
|
2624
2772
|
}
|
|
2625
2773
|
/**
|
|
2626
2774
|
* Why a managed session should be treated as BLOCKED — waiting on a human
|
|
@@ -2676,23 +2824,40 @@ export function turnStoppedIdle(t, paneText, now) {
|
|
|
2676
2824
|
return !QUEUED_HINT_RE.test(last) && !SPINNER_RE.test(last) && !BACKGROUND_RUNNING_RE.test(last);
|
|
2677
2825
|
}
|
|
2678
2826
|
export const GOAL_GONE_REASON = "goal gone before until, re-armed";
|
|
2827
|
+
export const FORCED_ARM_REASON = "asked to arm now";
|
|
2679
2828
|
/** How long one queued arm is awaited before another may be typed. */
|
|
2680
2829
|
export const QUEUED_ARM_WAIT_MS = 10 * 60_000;
|
|
2681
2830
|
export const GOAL_GONE_BUSY_REASON = "goal gone before until, re-armed (session busy, queued)";
|
|
2831
|
+
export const GOAL_FORCED_BUSY_REASON = "asked to arm now (session busy, queued)";
|
|
2682
2832
|
/**
|
|
2683
|
-
*
|
|
2684
|
-
* and runs it at the next turn boundary, so arming now interrupts
|
|
2685
|
-
* Same guards as the idle rule (not paused, until ahead, input line
|
|
2686
|
-
* at most one pending arm. Clears the pending mark once the marker
|
|
2687
|
-
* the turn is over.
|
|
2833
|
+
* An arm due while the session is mid-turn: Claude Code queues a submitted
|
|
2834
|
+
* line and runs it at the next turn boundary, so arming now interrupts
|
|
2835
|
+
* nothing. Same guards as the idle rule (not paused, until ahead, input line
|
|
2836
|
+
* empty) plus at most one pending arm. Clears the pending mark once the marker
|
|
2837
|
+
* is back or the turn is over.
|
|
2838
|
+
*
|
|
2839
|
+
* Forced arms too (`lastRearmAt === 0`, set by `now`, a new objective, a
|
|
2840
|
+
* queued instruction, or supervise with a new goal), because a session arming
|
|
2841
|
+
* ITSELF through the MCP tool is by definition mid-turn when it asks —
|
|
2842
|
+
* waiting for an idle pane defers its request until a hard-working session
|
|
2843
|
+
* happens to go quiet, which may be never. Unlike the goal-gone case the
|
|
2844
|
+
* forced arm fires with the old marker still on screen: the point of the call
|
|
2845
|
+
* is to replace that goal with the new text.
|
|
2688
2846
|
*/
|
|
2689
2847
|
export function busyGoalGoneReason(m, content, now, busy) {
|
|
2690
|
-
if (!busy
|
|
2848
|
+
if (!busy) {
|
|
2849
|
+
delete m.queuedArmAt;
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
const forced = reasonToArm(m, content, now) === FORCED_ARM_REASON;
|
|
2853
|
+
if (!forced && GOAL_ACTIVE.test(content)) {
|
|
2691
2854
|
delete m.queuedArmAt;
|
|
2692
2855
|
return null;
|
|
2693
2856
|
}
|
|
2694
2857
|
if (m.queuedArmAt !== undefined && now - m.queuedArmAt < QUEUED_ARM_WAIT_MS)
|
|
2695
2858
|
return null;
|
|
2859
|
+
if (forced)
|
|
2860
|
+
return GOAL_FORCED_BUSY_REASON;
|
|
2696
2861
|
return reasonToArm({ ...m, lastRearmAt: m.lastRearmAt || 1 }, content, now) === GOAL_GONE_REASON
|
|
2697
2862
|
? GOAL_GONE_BUSY_REASON
|
|
2698
2863
|
: null;
|
|
@@ -2709,7 +2874,7 @@ export function reasonToArm(m, content, now) {
|
|
|
2709
2874
|
// true of a number and nonsense about the world — the kind of line that costs
|
|
2710
2875
|
// somebody ten minutes at three in the morning.
|
|
2711
2876
|
if (m.lastRearmAt === 0)
|
|
2712
|
-
return
|
|
2877
|
+
return FORCED_ARM_REASON;
|
|
2713
2878
|
const quietFor = Math.max(0, now - m.lastChangeAt);
|
|
2714
2879
|
const armedFor = Math.max(0, now - m.lastRearmAt);
|
|
2715
2880
|
const marker = GOAL_ACTIVE.test(content);
|
|
@@ -3349,7 +3514,21 @@ async function tick() {
|
|
|
3349
3514
|
* from when it would otherwise fire — it never suppresses one, since a
|
|
3350
3515
|
* genuinely stuck session goes quiet on the transcript too.
|
|
3351
3516
|
*/
|
|
3352
|
-
|
|
3517
|
+
/**
|
|
3518
|
+
* API-RETRY KEEP-ALIVE — ahead of every gate below that continues on a
|
|
3519
|
+
* busy or blocked pane, because the retry wait IS the busy state here and
|
|
3520
|
+
* waiting it out loses the stretch: Claude Code retries three times and
|
|
3521
|
+
* gives up, leaving the goal frozen over a session that is only sleeping
|
|
3522
|
+
* between its own retries. The banner coexists with "Goal still active",
|
|
3523
|
+
* so this never re-arms — it only breaks the retry wait.
|
|
3524
|
+
*/
|
|
3525
|
+
if (apiRetryNudge(m, content, now)) {
|
|
3526
|
+
dirty = true;
|
|
3527
|
+
continue;
|
|
3528
|
+
}
|
|
3529
|
+
// Goal gone — or a forced arm (now / supervise with a new goal, asked by a
|
|
3530
|
+
// session that is itself mid-turn) — mid-turn: queue the arm behind the
|
|
3531
|
+
// turn instead of waiting for an idle tick.
|
|
3353
3532
|
const queuedReason = busyGoalGoneReason(m, content, now, working && !paneLooksIdle(content, now - m.lastChangeAt));
|
|
3354
3533
|
if (queuedReason && now - m.lastRearmAt >= REARM_COOLDOWN_MS) {
|
|
3355
3534
|
// Pending whether or not the read-back saw it land: a queued line is invisible until the turn ends.
|
|
@@ -3357,7 +3536,7 @@ async function tick() {
|
|
|
3357
3536
|
const queued = await tryExclusive(arming, m.sessionId, () => arm(m, queuedReason));
|
|
3358
3537
|
if (queued === undefined)
|
|
3359
3538
|
delete m.queuedArmAt;
|
|
3360
|
-
else if (
|
|
3539
|
+
else if (queued === "failed")
|
|
3361
3540
|
note(m, `${queuedReason} — not confirmed, waiting for the turn to end or the marker`);
|
|
3362
3541
|
dirty = true;
|
|
3363
3542
|
continue;
|
|
@@ -3424,7 +3603,12 @@ async function tick() {
|
|
|
3424
3603
|
// REARM_COOLDOWN_MS. This is what actually fixes the overnight bug: the
|
|
3425
3604
|
// old code kept re-arming (and re-spamming) forever because each retype
|
|
3426
3605
|
// reset lastChangeAt and the freeze detector never got a quiet window.
|
|
3427
|
-
|
|
3606
|
+
//
|
|
3607
|
+
// But a DEMANDED arm is never held here (2026-10-17 14:42): the cap had
|
|
3608
|
+
// marked the session stuck while the operator was mid-demand, and this
|
|
3609
|
+
// gate then muted every tick after it — the demanded arm was never even
|
|
3610
|
+
// typed. A demand outranks a backoff it had no part in earning.
|
|
3611
|
+
if (stuckBackoffHolds(m))
|
|
3428
3612
|
continue;
|
|
3429
3613
|
if (now - m.lastRearmAt < REARM_COOLDOWN_MS)
|
|
3430
3614
|
continue;
|
|
@@ -3472,34 +3656,19 @@ async function tick() {
|
|
|
3472
3656
|
* ordinary rules — the session is still managed, and the next genuine
|
|
3473
3657
|
* lapse arms it — and the operator is told, because a manager that has
|
|
3474
3658
|
* given up on delivering a goal is the one thing it must never do quietly.
|
|
3659
|
+
*
|
|
3660
|
+
* Counting is for GENUINE FAILURES only. On 2026-10-17 14:42 the count
|
|
3661
|
+
* also took guard skips: the operator, mid-demand ("arm NOW"), was typing
|
|
3662
|
+
* into the pane, three occupied-line ticks crossed the cap, the cap
|
|
3663
|
+
* consumed the forced-arm sentinel, and the manager — told to act — never
|
|
3664
|
+
* typed at all. A guard declining is the system working, not the attempt
|
|
3665
|
+
* failing.
|
|
3475
3666
|
*/
|
|
3476
3667
|
const armed = await tryExclusive(arming, m.sessionId, () => arm(m, reason));
|
|
3477
3668
|
if (armed === undefined)
|
|
3478
3669
|
continue; // another arm for this session is in flight
|
|
3479
3670
|
dirty = true;
|
|
3480
|
-
|
|
3481
|
-
m.armFails = 0;
|
|
3482
|
-
m.armFailStreak = 0;
|
|
3483
|
-
delete m.stuckSince;
|
|
3484
|
-
delete m.blockedSince;
|
|
3485
|
-
delete m.blockedAlertedAt;
|
|
3486
|
-
continue;
|
|
3487
|
-
}
|
|
3488
|
-
m.armFails = (m.armFails ?? 0) + 1;
|
|
3489
|
-
m.armFailStreak = (m.armFailStreak ?? 0) + 1;
|
|
3490
|
-
if (m.armFails >= ARM_ATTEMPTS) {
|
|
3491
|
-
m.armFails = 0;
|
|
3492
|
-
m.lastRearmAt = now;
|
|
3493
|
-
m.stuckSince ??= now;
|
|
3494
|
-
// No operator push here. Pushing on every cap is the 144-alerts-overnight
|
|
3495
|
-
// bug: this fires again every ~2-4 minutes for as long as arming keeps
|
|
3496
|
-
// failing, and it is content-agnostic — it cannot tell "mid-turn, will
|
|
3497
|
-
// clear itself" from "stuck on a modal for hours". The blocked check
|
|
3498
|
-
// above owns the operator alert now: it has `armFailStreak`/`stuckSince`
|
|
3499
|
-
// (which survive this reset) plus a static-pane confirmation, and its
|
|
3500
|
-
// own BLOCKED_REALERT_MS dedup. This stays log-only.
|
|
3501
|
-
note(m, `arming keeps failing (streak ${m.armFailStreak}) — holding off; the blocked check owns the operator alert now`);
|
|
3502
|
-
}
|
|
3671
|
+
applyArmResult(m, armed, now);
|
|
3503
3672
|
}
|
|
3504
3673
|
if (dirty)
|
|
3505
3674
|
saveState(state);
|
|
@@ -3664,7 +3833,8 @@ export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
|
|
|
3664
3833
|
` a bounded stretch of autonomous work on the tracker's open\n` +
|
|
3665
3834
|
` issues: objective, screen decision, expiry and arming in one\n` +
|
|
3666
3835
|
` sentence. \`shift off\` stands it down without killing it\n` +
|
|
3667
|
-
` now arm immediately, whatever the signals say
|
|
3836
|
+
` now arm immediately, whatever the signals say — safe mid-turn,\n` +
|
|
3837
|
+
` the /goal line queues behind a running turn\n` +
|
|
3668
3838
|
` pause stop arming, keep the objective\n` +
|
|
3669
3839
|
` resume start arming again\n` +
|
|
3670
3840
|
` off stop managing entirely\n` +
|
|
@@ -3877,7 +4047,7 @@ export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
|
|
|
3877
4047
|
const setMatch = arg.match(/^(?:set|objective|replace|start)\s+([\s\S]+)$/i);
|
|
3878
4048
|
if (setMatch && existing) {
|
|
3879
4049
|
const before = existing.objective;
|
|
3880
|
-
existing
|
|
4050
|
+
setObjective(existing, setMatch[1].trim());
|
|
3881
4051
|
// Notes written against the old objective may not make sense against the
|
|
3882
4052
|
// new one; say so rather than silently carrying them over.
|
|
3883
4053
|
const dropped = existing.pending.length;
|
|
@@ -3909,7 +4079,7 @@ export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
|
|
|
3909
4079
|
const addMatch = arg.match(/^(?:add|also|append|extend)\s+([\s\S]+)$/i);
|
|
3910
4080
|
if (addMatch && existing) {
|
|
3911
4081
|
const extra = addMatch[1].trim();
|
|
3912
|
-
existing
|
|
4082
|
+
setObjective(existing, `${existing.objective} Also: ${extra}`);
|
|
3913
4083
|
note(existing, `objective extended: ${extra.slice(0, 80)}`);
|
|
3914
4084
|
saveState(state);
|
|
3915
4085
|
return {
|
|
@@ -4163,9 +4333,12 @@ export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
|
|
|
4163
4333
|
objective,
|
|
4164
4334
|
pending: [],
|
|
4165
4335
|
history: [],
|
|
4166
|
-
//
|
|
4167
|
-
//
|
|
4168
|
-
|
|
4336
|
+
// EVERY ask for arming is now (2026-10-17 14:48). The grace period this
|
|
4337
|
+
// line once gave — don't arm on top of whatever the session is doing as
|
|
4338
|
+
// the operator types — predates safe mid-turn queuing: a queued arm
|
|
4339
|
+
// lands at the turn boundary and interrupts nothing, so its reason is
|
|
4340
|
+
// gone, and 0 arms on the very next tick.
|
|
4341
|
+
lastRearmAt: 0,
|
|
4169
4342
|
lastChangeAt: Date.now(),
|
|
4170
4343
|
lastHash: hash(readPane(sessionId)),
|
|
4171
4344
|
tty: snapshotTty(sessionId),
|
|
@@ -4191,7 +4364,7 @@ export async function handleManage(sessionIdOrName, rawArg, opts = {}) {
|
|
|
4191
4364
|
return { ok: true, managed: true, message: `${name} will be armed on the next tick` };
|
|
4192
4365
|
}
|
|
4193
4366
|
const instruction = arg.match(/^instruct\s+([\s\S]+)$/i)?.[1].trim() ?? arg;
|
|
4194
|
-
existing
|
|
4367
|
+
queueInstruction(existing, instruction);
|
|
4195
4368
|
note(existing, `operator: ${instruction.slice(0, 80)}`);
|
|
4196
4369
|
saveState(state);
|
|
4197
4370
|
return {
|