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.
@@ -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 changes what the next arming says; it types nothing now.\n" +
1826
- " The next arming comes when the session stops — or immediately, with `now`.");
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 false;
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 false;
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. Skips still count toward armFails/armFailStreak below, so
2550
- * a line occupied for minutes still reaches blockedReason's operator alert
2551
- * (with a screenshot) — the daemon does not stall silently either way.
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
- note(m, `arm deferred: input line holds '${onLine}' (unsent for ${sameForTicks} tick${sameForTicks === 1 ? "" : "s"}, idle ${Math.round(armIdleMs / 1000)}s)`);
2565
- return false;
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 false;
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 false;
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 true;
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 false;
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
- * Goal gone while the session is mid-turn: Claude Code queues a submitted line
2684
- * and runs it at the next turn boundary, so arming now interrupts nothing.
2685
- * Same guards as the idle rule (not paused, until ahead, input line empty) plus
2686
- * at most one pending arm. Clears the pending mark once the marker is back or
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 || GOAL_ACTIVE.test(content)) {
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 "asked to arm now";
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
- // Goal gone mid-turn: queue the arm behind the turn instead of waiting for an idle tick.
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 (!queued)
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
- if (m.stuckSince !== undefined)
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
- if (armed) {
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\n` +
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.objective = setMatch[1].trim();
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.objective = `${existing.objective} Also: ${extra}`;
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
- // Give the session the benefit of the grace period rather than arming
4167
- // on top of whatever it is doing at the moment the operator types this.
4168
- lastRearmAt: Date.now(),
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.pending.push(instruction);
4367
+ queueInstruction(existing, instruction);
4195
4368
  note(existing, `operator: ${instruction.slice(0, 80)}`);
4196
4369
  saveState(state);
4197
4370
  return {