tokenmaxxing 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -94,11 +94,13 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
94
94
  - **Config**: `slack.json` (0600 - it holds the xoxb-/xapp- tokens) with per-channel links `{channel, repo, permissionMode, model?}`. `serve setup` prints the app manifest (bot scopes: app_mentions:read, assistant:write, channels:history, groups:history, chat:write, files:write, im:history, users:read; agent_view enabled; events incl. app_mention, message.channels/groups/im, app_home_opened, app_context_changed; socket mode) and prompts for the tokens; `serve link <channel-id> <repo>` manages links (channel IDs only - names drift, ids don't).
95
95
  - **Thread = session**: a bot mention in a linked channel subscribes the thread and opens the session in the linked repo checkout (normal mode, user decision 2026-07-18 superseding the same-day worktree-per-thread default; a later same-day decision: the agent cuts its own worktree FIRST for any mutating task - read-only turns stay parallel in the shared checkout - taught by the serve-session skill, since the recorded cwd can never move), and records `{threadId, cwd, sessionId}` under `slack-threads/`. Resume is cwd-keyed in claude, so the cwd stays byte-stable for the thread's life; records from the worktree era pin their old `slack-worktrees/<threadKey>` cwd and keep working (those worktrees are never auto-deleted - they hold the thread's work).
96
96
  - **Thread close-out (finish_thread)**: when the user says the work is finished, the model calls the in-process MCP tool `finish_thread` (a per-turn `createSdkMcpServer` in relayThread, `alwaysLoad: true`, granted via `allowedTools` since nobody can answer a permission prompt over Slack). The handler runs in the daemon but only flags the turn outcome; after the turn ends (claude subprocess gone, segments posted) serve.ts runs `cleanupThread`: delete the `slack-threads/` record, `thread.unsubscribe()`, and post one confirmation line. Threads run in the shared repo checkout, so there is nothing on disk to collect and the checkout is never touched; a fresh mention after close-out starts a new session.
97
- - **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (never a persistent streaming query - the SDK subprocess reads credentials at spawn, so per-turn spawns are what let `ensureBestAccount()` land each turn on the freshest account, and the daemon can restart without losing threads). `stopHookCheck` rides along as the SDK Stop hook. Streamed `text_delta`s feed `thread.post(AsyncIterable)` (the adapter debounces edits); tool-only turns post the final result text.
97
+ - **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (the SDK subprocess reads credentials at spawn, so per-turn spawns are what let `ensureBestAccount()` land each turn on the freshest account, and the daemon can restart without losing threads). `stopHookCheck` rides along as the SDK Stop hook. Streamed `text_delta`s feed `thread.post(AsyncIterable)` (the adapter debounces edits); tool-only turns post the final result text.
98
+ - **Steering (2026-07-27, owner decision: steering is the default for a mid-turn reply)**: the per-turn query takes STREAMING input (`prompt` as an AsyncIterable of stream-json user messages) instead of a one-shot string, so a relayable reply landing while the turn runs is written onto the live child's stdin. The CLI folds it into the running turn at the next tool boundary (the same queued-message injection interactive Claude Code uses; a message that misses the last fold window runs as its own turn in the same child before exit, so nothing drops - both verified against claude 2.1.220). serve-side: `relayThread` exposes a per-attempt `steer()` via `onSteer`, refused once the attempt's result arrives (stdin ends then, and the SDK silently drops writes to an ended stdin); `buildServeRuntime` keeps one acceptor per live thread and tries it per arriving message - refusals (idle thread, parked pool, a non-empty inbox or waiting turn for ordering, an out-of-order late arrival, drain) fall back to the inbox path. Steered text appends to the durable `activeTurn.prompt` (kill-replays and limit-resumes carry it) and folds into retry prompts; steered message ids ride the marker (`steeredMessageIds`) wearing the same hourglass-to-terminal reaction lifecycle; an inbound takeover of a deferred turn adopts the held turn's unsettled ids so no hourglass is orphaned, and a steer answers a resumed turn's pending ask (attention cleared) like a queued turn would. SUCCESS IS STICKY per attempt: a post-fold-window steer's own drained turn can emit a second, errored result after the primary answer already succeeded, and marking the turn failed then would re-run completed work through the retry machinery - the steer is announced lost in-thread instead, `outcome.steerLost` settles the steered messages' reactions as failed (a lost instruction must never read green; attribution is per-turn, a documented tradeoff on TurnOutcomeSchema), a limit classification stays sticky across a child's errored results (a drained turn's generic death must not declassify the primary turn's recoverable limit), and each turn's result-only answer is flushed AT its result so a trailing turn's notice or result can neither suppress nor clobber a tool-only answer (all adversarial-review catches). Two CLI gotchas are load-bearing: steered messages carry NO uuid (the CLI dedupes by uuid and silently swallows a reuse) and NO priority (default "next" folds politely; "now" is a hard interrupt).
99
+ - **Arrival dispatch (2026-07-27, replacing the chat queue strategy)**: Chat runs `concurrency: {strategy: "concurrent"}` - every message reaches `onMessage` the moment Slack delivers it. The queue strategy's 30s dispatch-lock lease made mid-turn messages invisible until the turn ended and could REORDER them (a reply enqueued behind a live lease was released only when a later message took the expired lock, newest dispatched first - adversarial-review catch on the steering redesign, present at any TTL). The daemon owns everything the queue provided: per-thread turn ordering via the serialized chain (`serializedTurn` counts only TURN-producing work, so reaction notes and nudges can never gate steering - a second review catch); mid-turn folding via steering; and batching via a per-thread INBOX - un-steered messages accumulate and drain as ONE folded turn, sorted by Slack ts (ids are timestamps), so an upstream arrival race cannot reorder the prompt and a burst behind a long turn costs one metered spawn, not N. Per-thread arrival decisions are themselves chained (steer-or-inbox for one message completes before the next begins), and chat's message-id dedupe runs before its concurrency branch, so the app_mention + message.channels double-delivery stays deduped.
98
100
  - **Safety posture**: per-link `permissionMode`, default `acceptEdits`; `--yolo` (alias `--dangerous`) opts a link into `bypassPermissions`, and relayThread pairs it with the SDK's mandatory `allowDangerouslySkipPermissions: true` opt-in. `AskUserQuestion` is disallowed (unanswerable over Slack; the model asks in prose instead). Turn failures post a trimmed message-only diagnostic (never a raw error body). Outsiders must not drive sessions (harvested from Slaude at its 2026-07-18 shutdown): `isOutsideAuthor` fail-closed rejects any message whose team-origin fields disagree with the home workspace's `workspaceTeamId` (captured via auth.test at setup and re-captured at every daemon start, so the reference can never go stale against a rotated token), so Slack Connect externals and cross-workspace guests are silently ignored and can never open a session.
99
101
  - **Serve skills** (`src/serve-plugin/`, ships in the package via `files: ["src"]`): a Claude Code plugin loaded per turn (`plugins: [{type: "local", path}]`; discovered skills are enabled by default, so no `skills` option). `tokenmaxxing:ask-the-user` teaches the decision protocol - when input is needed, tag the requester with the raw Slack mention token `<@U...>`, ask compactly, END the turn (the thread reply is the next turn); `tokenmaxxing:serve-session` documents how the session runs (shared repo checkout, resume across turns, worktree-by-default for mutating tasks, handoff). The one dynamic fact skills cannot carry - who asked - rides in per turn via a `UserPromptSubmit` hook whose `additionalContext` ("Slack relay context: ...", built by `serveTurnContext`) names the triggering message author's mention token. The mention survives the pipeline because streamed `markdown_text` deltas pass verbatim and the post-and-edit fallback's `finalize` only linkifies bare `@U...`, never escaping an already-formed `<@U...>`. Skills are independent of the system prompt choice (probe-verified 2026-07-18 under the custom-string `SLACK_SYSTEM_PROMPT`: the init message lists the plugin + both skills + the Skill tool; the skill listing arrives as a conversation system-reminder, and CLAUDE.md loads via settingSources).
100
102
  - **Socket lifecycle**: `bot.initialize()` starts the persistent auto-reconnecting SocketModeClient; the daemon then just stays alive. The leased `startSocketModeListener` API must never be looped: it returns instantly without `waitUntil` and the loop starves the event loop (live incident 2026-07-18 - connected but silent).
101
- - **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread; queue-skipped messages (`context.skipped`) fold into the next prompt, with the queue-entry TTL raised to 1h (expiry is silent - no app callback in chat 4.34.0 - so it must outlast a depleted-pool park plus a long turn); per-thread turn serialization is owned by the daemon itself (a promise chain per thread id), because the SDK's queue dispatch lock has a 30s TTL extended only between dispatches and every claude turn outlives it; unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
103
+ - **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread; per-thread turn serialization is owned by the daemon itself (a promise chain per thread id), and message batching by the daemon's inbox (see the arrival-dispatch bullet; the chat queue strategy and its skipped/TTL machinery were retired with the 2026-07-27 steering redesign, though `context.skipped` is still merged defensively when present); unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
102
104
  - **Restart resilience** (0.19.0, from the 2026-07-18 dead-thread incident: a deploy restart cut a turn mid-answer and left the thread deaf to follow-ups): startup re-subscribes every `slack-threads/` record straight on the state adapter (`state.subscribe(threadId)`; message routing checks `stateAdapter.isSubscribed`, verified in chat 4.34.0), so open threads survive restarts without needing a fresh mention. SIGTERM/SIGINT drains instead of dying: new turns are dropped loudly (`serve.drain_dropped` plus a tracked in-thread notice - a log-only drop reads as the bot thinking), tracked in-flight turns get up to 300s to finish (a re-snapshotting wait, so late-added notices still flush), then `Chat.shutdown()`; a second signal forces exit; the drain also aborts any depleted-pool park so a countdown never delays a restart. The claude child spawns detached in its own process group (`detachedClaudeSpawn` via the SDK's `spawnClaudeCodeProcess` hook), because a terminal Ctrl-C signals the whole foreground group and a non-detached child died with the daemon before the drain could save the turn; the signal handlers register before `bot.initialize()` so no turn can start while the process still has default signal disposition, and a rejecting `Chat.shutdown()` is caught so the drain always reaches its exit. When a `thread.post` rejects mid-turn (e.g. Slack finalizes an idle stream after an undocumented window: `message_not_in_streaming_state`), relayThread salvages the dead segment in TEXT space: proven-delivered text is a mirror StreamingMarkdownRenderer's committable prefix over the confirmed chunks (each pull proves the previous append landed; the adapter's renderer holds back the trailing unterminated line until its post-iteration forced flush, so chunk-granular salvage would miss a final reply with no trailing newline - the live 2026-07-21 incident), and the remainder re-posts as a fresh message split at line boundaries, plus unconfirmed cards, bounded by a futility budget that refills on delivery progress; the textLost diagnostic remains only for exhausted salvage, and ambiguous failures duplicate a tail line rather than lose it.
103
105
  - **Interrupted-turn recovery** (from the second 2026-07-18 restart incident: a redeploy killed a ship turn 8 minutes in with zero notice - drain cannot save a long turn, and a group-wide kill can take the claude child even detached): the thread record persists the claude session id the moment the init message assigns it, and every turn is wrapped in a durable `activeTurn` marker (original prompt, start time, resume count) written before the spawn and cleared when the turn returns - except that a turn failing DURING a drain keeps its marker, since that failure is presumed to be the shutdown signal killing the child. On startup, a surviving marker means a restart killed that turn: the daemon posts a notice into the thread (the chat-sdk's documented proactive handle, rebuilt with the thread's newest human message as streaming recipient context so the resumed turn keeps its native task cards, that message's author becoming the turn's requester) and auto-resumes the work - resuming the recorded session with a continuation prompt, or replaying the original prompt fresh when the kill landed before the session opened; resumed turns settle like inbound ones (outcome log + finish_thread GC). Retries cap at 3 (each spends real quota) with a loud give-up notice posted before its marker clears; a per-thread turn lock keeps a startup resume from ever racing an inbound message turn in the same cwd, every recovery branch recomputes its decision from a fresh record reload under that lock (so a resume superseded by a faster inbound turn no-ops instead of double-running), and a blocking singleton flock makes a new daemon generation wait for the previous one - drain included - to fully exit before touching any thread record. Uncatchable deaths cannot leak a working child either: SIGHUP drains like SIGTERM (its default disposition skips the exit hook that kills the detached group), and the marker records the child's group pid plus its C-locale ps start-time token at spawn, so recovery kills only an exactly-identified SIGKILL-orphaned claude (never a recycled pid) before resuming its turn.
104
106
  - **Depleted-pool recovery** (harvested from Slaude, reshaped around the pool): relayThread consumes the spawn-boundary switch decision instead of discarding it - a depleted pool with a known recovery inside the message's one 14min parking deadline (shared across chained parks, so the thread's queue slot is never held longer in total) posts a park notice and retries at the reset. A mid-turn limit - detected ONLY on errored results, since `is_error` can ride subtype `"success"` (`Claude AI usage limit reached|<epoch>`) and a successful answer discussing limits must never be re-run - is persisted into usage.json first (`recordObservedLimit`: the serve process has no statusLine tee, and the snapshot TTL would otherwise feed the retry the stale pre-limit state), then retried silently after a short beat, so a pool swap makes the hiccup invisible. Bounded recoveries; every drop the relay performs is announced in-thread.
package/README.md CHANGED
@@ -23,6 +23,33 @@ bun add -g tokenmaxxing
23
23
  tokenmaxxing init
24
24
  ```
25
25
 
26
+ Or with Nix (same source-run-by-Bun package; `init` still owns credentials, the `claude` shim, and settings merges). Install onto PATH first, then init — `nix run ... -- init` alone leaves supervisor shims without a stable `tokenmaxxing` on PATH after the ephemeral run exits:
27
+
28
+ ```sh
29
+ nix profile install github:anaclumos/tokenmaxxing
30
+ tokenmaxxing init
31
+ ```
32
+
33
+ nix-darwin:
34
+
35
+ ```nix
36
+ # flake inputs: tokenmaxxing.url = "github:anaclumos/tokenmaxxing";
37
+ modules = [
38
+ inputs.tokenmaxxing.darwinModules.withOverlay
39
+ { programs.tokenmaxxing.enable = true; }
40
+ ];
41
+ # then: tokenmaxxing init
42
+ ```
43
+
44
+ Home Manager:
45
+
46
+ ```nix
47
+ imports = [ inputs.tokenmaxxing.homeManagerModules.default ];
48
+ programs.tokenmaxxing.enable = true;
49
+ programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.default;
50
+ # then: tokenmaxxing init
51
+ ```
52
+
26
53
  `init` imports the account you're already on, installs the `claude` supervisor + four `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
27
54
 
28
55
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,10 +33,11 @@
33
33
  "dev": "bun run src/main.ts",
34
34
  "test": "bun test",
35
35
  "typecheck": "tsc --noEmit",
36
- "prepublishOnly": "bun run typecheck && bun run test"
36
+ "prepublishOnly": "bun run typecheck && bun run test",
37
+ "nix:bun": "bunx bun2nix@2.1.2 -o bun.nix"
37
38
  },
38
39
  "devDependencies": {
39
- "@types/bun": "latest",
40
+ "@types/bun": "1.3.14",
40
41
  "typescript": "^5.6.0"
41
42
  },
42
43
  "dependencies": {
package/src/cli/serve.ts CHANGED
@@ -21,7 +21,7 @@ import { delay, omit, uniq } from "es-toolkit";
21
21
  import { z } from "zod";
22
22
  import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
23
23
  import { createSlackAdapter } from "@chat-adapter/slack";
24
- import { createMemoryState } from "@chat-adapter/state-memory";
24
+ import { MemoryStateAdapter } from "@chat-adapter/state-memory";
25
25
  import {
26
26
  bareChannelId,
27
27
  isChannelId,
@@ -334,6 +334,10 @@ export function buildServeRuntime(seam: {
334
334
  * relayThread). */
335
335
  onSpawn?: (pid: number) => void;
336
336
  drainSignal?: AbortSignal;
337
+ /** steering seam (see relayThread): a live attempt's steer function, or
338
+ * null when the attempt ends. steer runs its onAccept callback
339
+ * synchronously inside acceptance, before the child sees the text. */
340
+ onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
337
341
  }) => Promise<TurnOutcome>;
338
342
  cleanup: (input: { threadId: string }) => CleanupOutcome;
339
343
  /** add/remove a status reaction on a message (production: the Slack
@@ -373,6 +377,33 @@ export function buildServeRuntime(seam: {
373
377
  const unlinkedLogged = new Set<string>();
374
378
  // corrupt thread records already logged as skipped this run (see nudgeSweep).
375
379
  const sweepSkipLogged = new Set<string>();
380
+ // Steering registry: one acceptor per thread with a LIVE query attempt
381
+ // (registered by runTurn via the relay's onSteer hook, removed when the
382
+ // attempt ends). onMessage tries the acceptor before the inbox - steering
383
+ // is the default for a mid-turn reply (owner decision 2026-07-27); a
384
+ // refusal (attempt ending, mention-only text, out-of-order arrival) falls
385
+ // back to the inbox path. A thread only ever appears here after handleTurn
386
+ // ran under this daemon's cfg, so the channel is known linked.
387
+ const liveSteers = new Map<string, (m: { relayed: { text: string; authorId: string; id: string }[] }) => Promise<boolean>>();
388
+ // TURN-PRODUCING serialized work per thread (running plus waiting). Only
389
+ // turn producers count (adversarial-review catch: reaction notes and nudge
390
+ // bookkeeping ride the same serialized chain, and counting them silently
391
+ // disabled steering for the rest of any turn a user reacted to). A steer
392
+ // is only ordered when nothing waits behind the live turn.
393
+ const turnDepth = new Map<string, number>();
394
+ // Un-steered messages waiting for the next turn, folded and drained as ONE
395
+ // turn (sorted by Slack ts, which restores order for any upstream arrival
396
+ // race). This is the daemon-owned replacement for the chat queue's
397
+ // skipped-message folding. Each entry carries its delivery's mention flag,
398
+ // so the drained turn derives mention-ness from the RETAINED messages - a
399
+ // mention dropped by the overflow cap must not leave a phantom flag behind
400
+ // (cubic review catch on PR #50).
401
+ const pendingInbox = new Map<string, { text: string; authorId: string; id: string; isMention: boolean }[]>();
402
+ // Per-thread arrival ordering: the steer attempt and the inbox push for
403
+ // one message run to completion before the next message's do, so two
404
+ // near-simultaneous replies can never interleave at the acceptor's await
405
+ // points and land on the child's stdin out of order.
406
+ const arrivalChains = new Map<string, Promise<void>>();
376
407
 
377
408
  /** Best-effort status reaction: reaction state is decoration, so every
378
409
  * failure (missing reactions:write until the app is reinstalled,
@@ -391,7 +422,9 @@ export function buildServeRuntime(seam: {
391
422
  * before the spawn, cleared when the turn returns, so a marker surviving
392
423
  * into the next daemon start identifies a turn a restart killed mid-run.
393
424
  * The session id persists the moment init assigns it - a first-turn kill
394
- * must stay resumable. */
425
+ * must stay resumable. Returns the steered message ids alongside the
426
+ * outcome: they joined the turn mid-run, so the caller's settle must
427
+ * close their reaction lifecycle too. */
395
428
  const runTurn = async (input: {
396
429
  thread: { id: string; post: (m: StreamingPlan) => Promise<unknown> };
397
430
  record: SlackThread;
@@ -400,14 +433,17 @@ export function buildServeRuntime(seam: {
400
433
  sessionId: string | null;
401
434
  marker: ActiveTurn;
402
435
  link: SlackLink;
403
- }): Promise<TurnOutcome> => {
436
+ }): Promise<{ outcome: TurnOutcome; steeredMessageIds: string[] }> => {
404
437
  let record: SlackThread = { ...input.record, activeTurn: input.marker };
405
438
  saveSlackThread(record);
406
- // the whole turn (parks and retries included) reads as "being processed".
407
- if (input.marker.messageId) {
408
- await setStatus({ threadId: input.thread.id, messageId: input.marker.messageId, emoji: STATUS_EMOJI.processing, op: "add" });
439
+ // the whole turn (parks and retries included) reads as "being processed";
440
+ // a resumed marker's steered messages re-arm their hourglass too
441
+ // (setStatus swallows already_reacted).
442
+ for (const id of [input.marker.messageId, ...(input.marker.steeredMessageIds ?? [])]) {
443
+ if (id) await setStatus({ threadId: input.thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "add" });
409
444
  }
410
445
  let outcome: TurnOutcome | null = null;
446
+ let steeredMessageIds = input.marker.steeredMessageIds ?? [];
411
447
  try {
412
448
  outcome = await seam.relay({
413
449
  cwd: record.cwd,
@@ -424,9 +460,14 @@ export function buildServeRuntime(seam: {
424
460
  onSpawn: (pid) => {
425
461
  // the lstart token makes the pid a verifiable identity for the
426
462
  // orphan reaper; a child dead before ps sees it persists without
427
- // one, and an identity-less pid is never signaled.
463
+ // one, and an identity-less pid is never signaled. Built on the
464
+ // CURRENT marker (a steer may have grown it), with the previous
465
+ // spawn's identity dropped: a retry child must never inherit the
466
+ // dead child's lstart, or the reaper would skip (or mis-verify)
467
+ // the live group.
428
468
  const startedAt = pidStartTime(pid);
429
- record = { ...record, activeTurn: { ...input.marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
469
+ const marker = omit(record.activeTurn ?? input.marker, ["pid", "pidStartedAt"]);
470
+ record = { ...record, activeTurn: { ...marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
430
471
  saveSlackThread(record);
431
472
  },
432
473
  onSessionId: (sessionId) => {
@@ -434,9 +475,115 @@ export function buildServeRuntime(seam: {
434
475
  saveSlackThread(record);
435
476
  },
436
477
  drainSignal: drainAbort.signal,
478
+ // While an attempt is steerable, a relayable mid-turn message folds
479
+ // into the RUNNING turn (owner decision 2026-07-27: steering is the
480
+ // default; the queued next turn is only the fallback). The acceptor
481
+ // runs its marker mutation synchronously after a successful steer:
482
+ // the steered text becomes part of the durable prompt (replays and
483
+ // retries must include it) before any await can interleave with the
484
+ // turn's own marker writes.
485
+ onSteer: (steerText) => {
486
+ if (steerText === null) {
487
+ liveSteers.delete(input.thread.id);
488
+ return;
489
+ }
490
+ liveSteers.set(input.thread.id, async (m) => {
491
+ const stripped = m.relayed
492
+ .map((r) => ({ text: stripLeadingMention({ text: r.text, botUserId: seam.botUserId() }), authorId: r.authorId, id: r.id }))
493
+ .filter((r) => r.text !== "");
494
+ if (stripped.length === 0) return false;
495
+ // out-of-order insurance: Slack ids are timestamps, so a message
496
+ // OLDER than anything this turn already carries arrived late
497
+ // through an upstream race - refuse it into the inbox, whose
498
+ // drain sorts by ts, instead of folding it after its successor.
499
+ const seen = record.activeTurn ?? input.marker;
500
+ const newestSeen = Math.max(Number(seen.messageId ?? 0) || 0, ...(seen.steeredMessageIds ?? []).map((id) => Number(id) || 0));
501
+ if (stripped.some((r) => (Number(r.id) || 0) < newestSeen)) return false;
502
+ // per-turn steer budget (cursor security review, round 2 on
503
+ // PR #50): accepted steers grow the durable prompt and the
504
+ // child's queue outside the inbox's 100-entry cap, so a flood
505
+ // could balloon one turn without bound. Far past any human
506
+ // steering cadence, a reply takes the capped inbox path instead.
507
+ if ((seen.steeredMessageIds?.length ?? 0) + stripped.length > 25) return false;
508
+ // hourglass BEFORE the steer so a settle racing this acceptor
509
+ // can never leave an unremovable reaction; a refusal takes it
510
+ // back off.
511
+ for (const r of stripped) {
512
+ await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "add" });
513
+ }
514
+ // authors the turn does not know yet get named inline: the
515
+ // UserPromptSubmit context does not re-fire for folded mid-turn
516
+ // messages, so attribution rides the message itself.
517
+ const text = stripped
518
+ .map((r) => (input.requesterIds.includes(r.authorId) ? r.text : `Message from <@${r.authorId}>:\n${r.text}`))
519
+ .join("\n\n");
520
+ // The durable commit runs INSIDE steer's acceptance, in the same
521
+ // JS tick as its liveness check (adversarial-review catch, round
522
+ // 3: this acceptor runs on the arrival chain and can resume from
523
+ // its hourglass awaits AFTER the turn ended - a write-ahead save
524
+ // here resurrected the finished turn's marker on disk, and its
525
+ // refusal rollback clobbered post-turn state with a stale
526
+ // snapshot). Acceptance proves the turn is live, so the closure
527
+ // record is disk-faithful; the marker still grows durably BEFORE
528
+ // the text reaches the child's stdin (a crash in between replays
529
+ // a steer the child may never have seen - duplication over
530
+ // loss); and a refusal commits nothing, so a stale invocation is
531
+ // a harmless fall-through to the inbox.
532
+ let asked: SlackThread["attention"];
533
+ const commit = () => {
534
+ const mergedRequesters = uniq([...input.requesterIds, ...stripped.map((r) => r.authorId)]);
535
+ const marker = record.activeTurn ?? input.marker;
536
+ const grownIds = [...(marker.steeredMessageIds ?? []), ...stripped.map((r) => r.id)];
537
+ let next = { ...record, activeTurn: { ...marker, prompt: `${marker.prompt}\n\n${text}`, requesterIds: mergedRequesters, steeredMessageIds: grownIds } };
538
+ // the user responded: a pending ask is answered by the steer
539
+ // just like by a queued turn (adversarial-review catch:
540
+ // leaving it would strand the question mark and fire a
541
+ // spurious nudge about an ask this very message answered).
542
+ const pendingAsk = next.attention;
543
+ if (pendingAsk) next = omit(next, ["attention"]);
544
+ // all-or-nothing (cubic review catch, round 4): the durable
545
+ // save runs before ANY outer mutation, so a failed write
546
+ // leaves the turn's view untouched and the refusal fallback
547
+ // below starts from clean state - without this ordering the
548
+ // settle would stamp the never-delivered message with the
549
+ // turn's outcome emoji.
550
+ saveSlackThread(next);
551
+ steeredMessageIds = grownIds;
552
+ record = next;
553
+ asked = pendingAsk;
554
+ for (const r of stripped) {
555
+ if (!input.requesterIds.includes(r.authorId)) input.requesterIds.push(r.authorId);
556
+ }
557
+ };
558
+ // a throwing commit (the durable save failing) escapes steer()
559
+ // BEFORE the text reaches the child or the relay records it
560
+ // (cubic review catch, round 4): treat it as a refusal, so the
561
+ // message keeps its inbox fallback instead of vanishing into
562
+ // the generic task_crashed log with its hourglass stranded. If
563
+ // the disk stays broken, the inbox turn's own marker write
564
+ // surfaces it loudly through the crash-notice path.
565
+ let accepted = false;
566
+ try {
567
+ accepted = steerText(text, commit);
568
+ } catch (e) {
569
+ log("serve.steer_commit_failed", { thread: input.thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
570
+ }
571
+ if (!accepted) {
572
+ for (const r of stripped) {
573
+ await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "remove" });
574
+ }
575
+ return false;
576
+ }
577
+ log("serve.steered", { thread: input.thread.id, texts: stripped.length });
578
+ if (asked?.messageId) {
579
+ await setStatus({ threadId: input.thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
580
+ }
581
+ return true;
582
+ });
583
+ },
437
584
  });
438
585
  record = { ...record, sessionId: outcome.sessionId };
439
- return outcome;
586
+ return { outcome, steeredMessageIds };
440
587
  } finally {
441
588
  // a failure DURING a drain is presumed to be the shutdown signal killing
442
589
  // the claude child (terminal Ctrl-C and group signals hit the whole
@@ -462,6 +609,9 @@ export function buildServeRuntime(seam: {
462
609
  if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop && deferUntil === null) {
463
610
  log("serve.drop_unannounced", { thread: input.thread.id });
464
611
  }
612
+ // belt-and-braces: the relay clears its steer hook per attempt, but the
613
+ // registry entry must never outlive the turn that owns it.
614
+ liveSteers.delete(input.thread.id);
465
615
  if (deferUntil !== null && record.activeTurn) {
466
616
  record = { ...record, activeTurn: { ...record.activeTurn, resumeAt: deferUntil } };
467
617
  saveSlackThread(record);
@@ -474,7 +624,7 @@ export function buildServeRuntime(seam: {
474
624
 
475
625
  const handleTurn = async (input: {
476
626
  thread: ServeThread;
477
- /** every relayed message this turn (queue-skipped + triggering), text
627
+ /** every relayed message this turn (the inbox batch, ts-sorted), text
478
628
  * paired with its author id: a decision may be owed to an earlier
479
629
  * folded sender, and a sender whose whole message was the bot mention
480
630
  * contributes no prompt text, so text and author filter together
@@ -529,11 +679,10 @@ export function buildServeRuntime(seam: {
529
679
  return;
530
680
  }
531
681
  log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
532
- // relayed carries queue-skipped messages plus the triggering one: the
533
- // queue strategy hands a turn only the LATEST message and the rest via
534
- // context.skipped, so they are folded into one prompt here. A message
535
- // that is empty once its bot mention is stripped contributes neither
536
- // prompt text nor a requester id (cursor review catch 2026-07-18).
682
+ // relayed carries every message the inbox batched for this turn, folded
683
+ // into one prompt here. A message that is empty once its bot mention is
684
+ // stripped contributes neither prompt text nor a requester id (cursor
685
+ // review catch 2026-07-18).
537
686
  const stripped = input.relayed
538
687
  .map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
539
688
  .filter((m) => m.text !== "");
@@ -575,6 +724,12 @@ export function buildServeRuntime(seam: {
575
724
  // prompt re-appears alongside the session transcript that already holds
576
725
  // its partial work, and the newer message steers.
577
726
  const deferred = record.activeTurn?.resumeAt !== undefined ? record.activeTurn : null;
727
+ // a taken-over deferral's messages still wear their processing hourglass
728
+ // (a deferral settles nothing); this turn serves their held prompt, so
729
+ // it adopts their ids and settles them with its own outcome - without
730
+ // the adoption the old trigger's hourglass would read "processing"
731
+ // forever once the fold replaced its marker.
732
+ const adoptedIds = deferred ? [...(deferred.messageId ? [deferred.messageId] : []), ...(deferred.steeredMessageIds ?? [])] : [];
578
733
  if (deferred) {
579
734
  const timer = deferredTimers.get(thread.id);
580
735
  if (timer !== undefined) clearTimeout(timer);
@@ -612,16 +767,16 @@ export function buildServeRuntime(seam: {
612
767
  // agent feature + assistant:write (the adapter warns instead of throwing).
613
768
  await thread.startTyping();
614
769
  const startedAt = Date.now();
615
- const outcome = await runTurn({
770
+ const { outcome, steeredMessageIds } = await runTurn({
616
771
  thread,
617
772
  record,
618
773
  prompt,
619
774
  requesterIds,
620
775
  sessionId: record.sessionId,
621
- marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds },
776
+ marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds, ...(adoptedIds.length > 0 ? { steeredMessageIds: adoptedIds } : {}) },
622
777
  link,
623
778
  });
624
- await settleTurn({ thread, outcome, startedAt, messageId, requesterIds });
779
+ await settleTurn({ thread, outcome, startedAt, messageId, steeredMessageIds, requesterIds });
625
780
  };
626
781
 
627
782
  /** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
@@ -634,6 +789,9 @@ export function buildServeRuntime(seam: {
634
789
  startedAt: number;
635
790
  /** the triggering message carrying the status reactions; absent = skip. */
636
791
  messageId?: string;
792
+ /** messages steered into the turn mid-run: they carry the same reaction
793
+ * lifecycle as the trigger and settle with the same emoji. */
794
+ steeredMessageIds?: string[];
637
795
  /** the turn's asked users, persisted when the model flagged attention. */
638
796
  requesterIds?: string[];
639
797
  }) => {
@@ -657,10 +815,19 @@ export function buildServeRuntime(seam: {
657
815
  // below makes the question mark unremovable forever.
658
816
  const killedByDrain = draining && outcome.failed && !outcome.announcedDrop && !outcome.resultReceived;
659
817
  const deferredForResume = outcome.deferUntil !== null;
660
- if (input.messageId && !killedByDrain && !deferredForResume) {
818
+ if (!killedByDrain && !deferredForResume) {
661
819
  const emoji = outcome.failed ? STATUS_EMOJI.failed : outcome.attention && !outcome.finish ? STATUS_EMOJI.attention : STATUS_EMOJI.done;
662
- await setStatus({ threadId: thread.id, messageId: input.messageId, emoji, op: "add" });
663
- await setStatus({ threadId: thread.id, messageId: input.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
820
+ // steerLost: a steered follow-up's own drained turn failed after the
821
+ // primary turn succeeded, so the steered messages settle as failed -
822
+ // matching the in-thread re-send notice; a lost instruction must never
823
+ // read green (see TurnOutcomeSchema.steerLost for the per-turn
824
+ // attribution tradeoff).
825
+ const steeredEmoji = outcome.steerLost ? STATUS_EMOJI.failed : emoji;
826
+ for (const id of [input.messageId, ...(input.steeredMessageIds ?? [])]) {
827
+ if (!id) continue;
828
+ await setStatus({ threadId: thread.id, messageId: id, emoji: id === input.messageId ? emoji : steeredEmoji, op: "add" });
829
+ await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
830
+ }
664
831
  }
665
832
  // the model asked the user for a decision: mark the thread waiting so the
666
833
  // nudge sweep and the reaction-answer path can see it. Persisted even on
@@ -755,12 +922,12 @@ export function buildServeRuntime(seam: {
755
922
  }
756
923
  };
757
924
 
758
- // Per-thread serialization owned HERE, not only by the SDK's queue lock:
759
- // chat 4.34.0 acquires the dispatch lock with a 30s TTL and extends it only
760
- // BETWEEN dispatches, so any handler outliving 30s (every claude turn, any
761
- // depleted-pool park) lets a later message take the expired lock and start
762
- // a second concurrent handler in the same thread and cwd (review catch,
763
- // PR #18). Escaped messages run as sequential turns in arrival order.
925
+ // Per-thread serialization owned HERE: with concurrent dispatch (see
926
+ // runDaemon's Chat config) chat provides no per-thread locking at all, so
927
+ // this chain is the ONLY thing keeping two claude turns off one thread's
928
+ // cwd and session. It predates the concurrent switch for the same reason
929
+ // in weaker form: chat's old queue lock expired 30s into any claude turn
930
+ // and let a second handler start anyway (review catch, PR #18).
764
931
  const threadTurns = new Map<string, Promise<void>>();
765
932
  const serialized = (threadId: string, run: () => Promise<void>) => {
766
933
  const prev = threadTurns.get(threadId) ?? Promise.resolve();
@@ -782,18 +949,101 @@ export function buildServeRuntime(seam: {
782
949
  })();
783
950
  return next;
784
951
  };
952
+ /** serialized + the turnDepth count, for callers that produce a claude
953
+ * TURN (inbox drains, reaction answers, recoveries). Bookkeeping riders
954
+ * on the chain (reaction notes, nudges) use bare `serialized` so they
955
+ * never gate steering. */
956
+ const serializedTurn = (threadId: string, run: () => Promise<void>) => {
957
+ turnDepth.set(threadId, (turnDepth.get(threadId) ?? 0) + 1);
958
+ const next = serialized(threadId, run);
959
+ void next.catch(() => {}).finally(() => {
960
+ const depth = (turnDepth.get(threadId) ?? 1) - 1;
961
+ if (depth <= 0) turnDepth.delete(threadId);
962
+ else turnDepth.set(threadId, depth);
963
+ });
964
+ return next;
965
+ };
785
966
 
786
967
  // both Chat SDK callbacks funnel here. Filter EVERY message, trigger
787
968
  // included: an outsider (or our own post) arriving last must not discard
788
- // relayable home-workspace messages queued behind the turn - the queue hands
789
- // the handler only the latest message and the rest ride context.skipped
790
- // (review catch, PR #18).
969
+ // relayable home-workspace messages delivered alongside it (review catch,
970
+ // PR #18). context.skipped is empty under concurrent dispatch but stays
971
+ // merged defensively - a chat release reintroducing batching must not
972
+ // silently drop messages.
791
973
  const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
792
974
  const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId, id: m.id }));
793
975
  if (relayed.length === 0) return; // outsider mentions never open a session
794
- await tracked(serialized(input.thread.id, async () => {
976
+ // arrivals for one thread DECIDE strictly one at a time, in delivery
977
+ // order: the steer-or-inbox decision for this message completes before
978
+ // the next message's begins, so two near-simultaneous replies can never
979
+ // interleave at the acceptor's await points. The chain holds only the
980
+ // DECISION - a scheduled turn's completion is awaited outside it, or a
981
+ // long turn would hold every later arrival hostage and steering could
982
+ // never engage.
983
+ const prev = arrivalChains.get(input.thread.id) ?? Promise.resolve();
984
+ const job = (async () => {
985
+ try {
986
+ await prev;
987
+ } catch { /* the previous arrival's failure was surfaced to its own caller */ }
988
+ return dispatchArrival({ thread: input.thread, relayed, isMention: input.isMention });
989
+ })();
990
+ const link = job.then(
991
+ () => {},
992
+ () => {},
993
+ );
994
+ arrivalChains.set(input.thread.id, link);
995
+ void link.then(() => {
996
+ if (arrivalChains.get(input.thread.id) === link) arrivalChains.delete(input.thread.id);
997
+ });
998
+ // tracked from the DECISION on (codex review catch on PR #50): the drain
999
+ // waits on activeTurns, and an arrival still deciding at shutdown lived
1000
+ // nowhere else - the daemon could exit before the message reached the
1001
+ // inbox, a marker, or the drop notice.
1002
+ await tracked((async () => {
1003
+ const scheduled = await job;
1004
+ if (scheduled) await scheduled.turn;
1005
+ })());
1006
+ };
1007
+
1008
+ /** One message's steer-or-inbox decision. Steering first (owner decision
1009
+ * 2026-07-27): a reply landing while the thread's turn is running folds
1010
+ * into that turn instead of waiting behind it. Guarded to an empty inbox
1011
+ * and no waiting turn, or the fold would reorder this message ahead of
1012
+ * one already waiting; a drain keeps its loud-drop contract. Every
1013
+ * refusal falls through to the inbox, whose drain runs ALL waiting
1014
+ * messages as one folded turn - the daemon-owned replacement for the
1015
+ * chat queue's skipped-message folding, sorted by Slack ts so an
1016
+ * upstream arrival race cannot reorder the prompt. Returns the tracked
1017
+ * drain-turn promise when this arrival scheduled one, so the caller can
1018
+ * await the turn without holding the arrival chain. */
1019
+ const dispatchArrival = async (input: { thread: ServeThread; relayed: { text: string; authorId: string; id: string }[]; isMention: boolean }): Promise<{ turn: Promise<void> } | null> => {
1020
+ const threadId = input.thread.id;
1021
+ if (!draining && (pendingInbox.get(threadId)?.length ?? 0) === 0 && (turnDepth.get(threadId) ?? 0) <= 1) {
1022
+ const accept = liveSteers.get(threadId);
1023
+ if (accept && (await accept({ relayed: input.relayed }))) return null;
1024
+ }
1025
+ const inbox = pendingInbox.get(threadId) ?? [];
1026
+ const hadPending = inbox.length > 0;
1027
+ inbox.push(...input.relayed.map((r) => ({ ...r, isMention: input.isMention })));
1028
+ // hard cap, replacing the retired chat queue's maxQueueSize bound
1029
+ // (cursor security review on PR #50): without it a flood during one
1030
+ // long turn grows memory and the folded prompt without limit. Newest
1031
+ // dropped, LOUDLY - the old queue's silent drop-oldest ate the earliest
1032
+ // instructions, the worse failure.
1033
+ if (inbox.length > 100) {
1034
+ log("serve.inbox_dropped", { thread: threadId, dropped: inbox.length - 100 });
1035
+ inbox.length = 100;
1036
+ }
1037
+ pendingInbox.set(threadId, inbox);
1038
+ // one drain per non-empty inbox: later arrivals fold into the batch the
1039
+ // already-scheduled drain snapshots when it finally runs.
1040
+ if (hadPending) return null;
1041
+ const turn = tracked(serializedTurn(threadId, async () => {
1042
+ const batch = (pendingInbox.get(threadId) ?? []).sort((a, b) => Number(a.id) - Number(b.id));
1043
+ pendingInbox.delete(threadId);
1044
+ if (batch.length === 0) return;
795
1045
  try {
796
- await handleTurn({ thread: input.thread, relayed, isMention: input.isMention });
1046
+ await handleTurn({ thread: input.thread, relayed: batch, isMention: batch.some((m) => m.isMention) });
797
1047
  } catch (e) {
798
1048
  // an escaped handleTurn throw (a state-file parse, a Slack API
799
1049
  // rejection outside relayThread's never-throws boundary) previously
@@ -825,13 +1075,14 @@ export function buildServeRuntime(seam: {
825
1075
  }
826
1076
  // a crash after runTurn added the hourglass would otherwise read as
827
1077
  // "processing" forever (codex review catch); setStatus never throws.
828
- const messageId = relayed.at(-1)?.id;
1078
+ const messageId = batch.at(-1)?.id;
829
1079
  if (messageId) {
830
1080
  await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.failed, op: "add" });
831
1081
  await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
832
1082
  }
833
1083
  }
834
1084
  }));
1085
+ return { turn };
835
1086
  };
836
1087
 
837
1088
  /** A user reaction in a tracked thread. While the thread waits on an asked
@@ -872,6 +1123,14 @@ export function buildServeRuntime(seam: {
872
1123
  log("serve.reaction_dropped", { thread: input.threadId, reason: "unlinked-channel" });
873
1124
  return;
874
1125
  }
1126
+ // bare `serialized` on purpose, so a reaction can never gate steering
1127
+ // (adversarial-review catch: counting these bookkeeping riders in
1128
+ // turnDepth silently disabled steering for the rest of any turn a user
1129
+ // reacted to). Accepted tradeoff (documented, WONTFIX): in the rare
1130
+ // shape where an ANSWER turn is queued here behind a live turn, a
1131
+ // fresh reply may steer the live turn ahead of the queued answer - both
1132
+ // still reach the model, and an answer coexisting with a live turn only
1133
+ // occurs in multi-user threads.
875
1134
  await tracked(serialized(input.threadId, async () => {
876
1135
  const fresh = loadSlackThread(input.threadId);
877
1136
  if (!fresh) return; // finished while queued
@@ -1052,7 +1311,9 @@ export function buildServeRuntime(seam: {
1052
1311
  try {
1053
1312
  const { thread, requesterIds } = await seam.streamable(record.threadId);
1054
1313
  const link = linkForChannel(cfg, bareChannelId(thread.channelId));
1055
- await serialized(record.threadId, async () => {
1314
+ // serializedTurn: a recovery runs a real claude turn, so it must gate
1315
+ // steering-order like any other queued turn.
1316
+ await serializedTurn(record.threadId, async () => {
1056
1317
  // a drain signal can land between the scan and this turn; leave the
1057
1318
  // marker at its previous count so the next start retries.
1058
1319
  if (draining) return;
@@ -1086,10 +1347,11 @@ export function buildServeRuntime(seam: {
1086
1347
  // with the user never told the daemon gave up.
1087
1348
  await thread.post(decision.notice);
1088
1349
  saveSlackThread(omit(fresh, ["activeTurn"]));
1089
- // the abandoned turn's message must not keep reading as processing.
1090
- if (turn.messageId) {
1091
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.failed, op: "add" });
1092
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
1350
+ // the abandoned turn's messages must not keep reading as processing.
1351
+ for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
1352
+ if (!id) continue;
1353
+ await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
1354
+ await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
1093
1355
  }
1094
1356
  return;
1095
1357
  }
@@ -1127,9 +1389,10 @@ export function buildServeRuntime(seam: {
1127
1389
  // or its hourglass reads "processing" forever (cubic review
1128
1390
  // catch on PR #43). The unlinked abandon above stays reactionless
1129
1391
  // on purpose: unlinked channels are contractually untouchable.
1130
- if (turn.messageId) {
1131
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.failed, op: "add" });
1132
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
1392
+ for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
1393
+ if (!id) continue;
1394
+ await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
1395
+ await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
1133
1396
  }
1134
1397
  saveSlackThread(omit(fresh, ["activeTurn"]));
1135
1398
  return;
@@ -1153,8 +1416,8 @@ export function buildServeRuntime(seam: {
1153
1416
  // nudge and answer-gate the users who were actually asked (vercel
1154
1417
  // review catch on PR #43); older markers without the field fall back.
1155
1418
  const resumedRequesterIds = decision.marker.requesterIds ?? requesterIds;
1156
- const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
1157
- await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, requesterIds: resumedRequesterIds });
1419
+ const { outcome, steeredMessageIds } = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
1420
+ await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, steeredMessageIds, requesterIds: resumedRequesterIds });
1158
1421
  });
1159
1422
  } catch (e) {
1160
1423
  const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
@@ -1276,24 +1539,25 @@ async function runDaemon(): Promise<number> {
1276
1539
  // held directly (not only via Chat) so startup can re-subscribe recorded
1277
1540
  // threads: subscriptions live in this in-memory state and die with the
1278
1541
  // process, and only a fresh mention would otherwise revive a thread.
1279
- const state = createMemoryState();
1542
+ const state = new MemoryStateAdapter();
1280
1543
  const bot = new Chat({
1281
1544
  userName: "tokenmaxxing",
1282
1545
  adapters: { slack },
1283
1546
  state,
1284
- // per-thread lock with queueing: a message landing mid-turn waits its turn
1285
- // instead of racing a second claude spawn on the same cwd. Queue-entry TTL
1286
- // expiry is SILENT (chat 4.34.0 has no app callback for it), so the TTL
1287
- // must outlast the longest legitimate hold: a depleted-pool park
1288
- // (PARK_MAX_MS 14min) plus a long claude turn. Expired-and-folded beats
1289
- // silently-vanished, hence a full hour. maxQueueSize matters for the same
1290
- // reason: the SDK default is 10 with a LOG-LESS drop-oldest trim
1291
- // (@chat-adapter/state-memory enqueue splices the front, and the SDK's
1292
- // message-dropped log only fires for drop-newest), so a >10-message burst
1293
- // behind one long turn silently ate the oldest instructions
1294
- // (adversarial-review catch). 100 outlasts any legitimate burst; the TTL
1295
- // stays the real bound.
1296
- concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000, maxQueueSize: 100 },
1547
+ // CONCURRENT dispatch (steering redesign, superseding the queue strategy
1548
+ // and its 1h-TTL/size-100 tuning): every message reaches onMessage the
1549
+ // moment Slack delivers it, in arrival order. The queue strategy's
1550
+ // 30s dispatch-lock lease made mid-turn messages invisible AND could
1551
+ // reorder them - a reply enqueued behind a live lease was only released
1552
+ // when a LATER message took the expired lock, dispatching newest-first
1553
+ // (adversarial-review catch; chat 4.34.0 handleQueueOrDebounce). The
1554
+ // daemon owns everything the queue used to provide: per-thread turn
1555
+ // ordering (the serialized chain), mid-turn folding (the steering path),
1556
+ // and batching of waiting messages (the per-thread inbox in
1557
+ // buildServeRuntime, drained sorted as ONE folded turn). Chat's
1558
+ // message-id dedupe runs before the concurrency branch, so the
1559
+ // app_mention + message.channels double-delivery stays deduped.
1560
+ concurrency: { strategy: "concurrent" },
1297
1561
  // without this a cards-only segment in post-and-edit fallback would
1298
1562
  // strand a bare "..." placeholder message.
1299
1563
  fallbackStreamingPlaceholderText: null,
@@ -33,14 +33,65 @@ export function isBinDirAhead(): boolean {
33
33
  }
34
34
  }
35
35
 
36
+ // Optional "1"/"true"/"yes" flag; unset/empty → undefined (feature off).
37
+ const EnvFlagSchema = z.enum(["1", "true", "yes"]).optional().catch(undefined);
38
+
39
+ /** True when this process is the Nix-packaged CLI (flake startScript sets
40
+ * TOKENMAXXING_NIX=1; store-path Bun.main is the fallback for wraps that
41
+ * forget the env). Env overrides parse at the read site. */
42
+ export function isNixPackaged(): boolean {
43
+ if (EnvFlagSchema.parse(process.env.TOKENMAXXING_NIX) != null) return true;
44
+ try {
45
+ return realpathSync(Bun.main).startsWith("/nix/store/");
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** True when a Nix module owns the periodic check timer; init must not write
52
+ * a second imperative unit. */
53
+ export function skipImperativeTimer(): boolean {
54
+ return EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_TIMER) != null;
55
+ }
56
+
57
+ /** Nix supervisor shim: prefer a PATH-stable `tokenmaxxing` (profile /
58
+ * current-system, excluding this binDir) so upgrades/GC of an old store
59
+ * generation stay reachable; fall back to bun+entry for the rare
60
+ * `nix run ... -- init` case where nothing is on PATH yet (works until that
61
+ * generation is GC'd — docs steer users to `nix profile install` first). */
62
+ function nixSupervisorShim(bun: string, entry: string): string {
63
+ return `#!/bin/sh
64
+ dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
65
+ old_ifs=$IFS
66
+ IFS=:
67
+ new_path=
68
+ for p in $PATH; do
69
+ [ "$p" = "$dir" ] && continue
70
+ if [ -n "$new_path" ]; then new_path="$new_path:$p"; else new_path="$p"; fi
71
+ done
72
+ IFS=$old_ifs
73
+ PATH=$new_path
74
+ export PATH
75
+ if command -v tokenmaxxing >/dev/null 2>&1; then
76
+ exec tokenmaxxing "$@"
77
+ fi
78
+ exec ${JSON.stringify(bun)} run ${JSON.stringify(entry)} "$@"
79
+ `;
80
+ }
81
+
36
82
  export function installSupervisor(): InstallOutcome {
37
83
  mkdirSync(paths.binDir, { recursive: true });
38
84
  const target = installedBin(); // binDir/tokenmaxxing
39
85
  // Resolve the entry through the global-bin symlink (bun add -g links
40
86
  // ~/.bun/bin/tokenmaxxing → the package's src/main.ts) so the shim points
41
- // into the installed package tree, where its imports resolve.
87
+ // into the installed package tree, where its imports resolve. Nix shims
88
+ // prefer PATH first (see nixSupervisorShim).
42
89
  const entry = realpathSync(Bun.main);
43
- writeFileAtomic(target, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} run ${JSON.stringify(entry)} "$@"\n`, 0o755);
90
+ if (isNixPackaged()) {
91
+ writeFileAtomic(target, nixSupervisorShim(process.execPath, entry), 0o755);
92
+ } else {
93
+ writeFileAtomic(target, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} run ${JSON.stringify(entry)} "$@"\n`, 0o755);
94
+ }
44
95
 
45
96
  // the on-PATH `claude` wrapper
46
97
  writeFileAtomic(paths.supervisorLink, `#!/bin/sh\nexec ${JSON.stringify(target)} __supervise "$@"\n`, 0o755);
@@ -174,6 +225,10 @@ function run(cmd: string[]): boolean {
174
225
  * place but activation failed (e.g. systemd user session absent over ssh) -
175
226
  * the caller prints the manual activation step. */
176
227
  function installCheckTimer(): boolean {
228
+ // Nix module owns the timer (TOKENMAXXING_SKIP_TIMER): do not write a second
229
+ // unit that would double-fire or clobber the declarative one.
230
+ if (skipImperativeTimer()) return true;
231
+
177
232
  if (process.platform === "darwin") {
178
233
  const plist = launchdPlist();
179
234
  writeFileAtomic(
@@ -243,6 +298,9 @@ export function timerActivationHint(): string {
243
298
 
244
299
  /** True when the timer unit exists AND the service manager reports it loaded. */
245
300
  export function checkTimerHealthy(): boolean {
301
+ // Declarative Nix timer: init wrote nothing; doctor must not demand the
302
+ // imperative unit.
303
+ if (skipImperativeTimer()) return true;
246
304
  if (process.platform === "darwin") {
247
305
  const domain = launchdDomain();
248
306
  return existsSync(launchdPlist()) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`]);
@@ -305,6 +363,8 @@ function systemdTimerActive(): "active" | "not-active" | "unavailable" {
305
363
  * loaded must deactivate successfully, and an unanswerable probe (service
306
364
  * manager unusable) reports false rather than pretending it is gone. */
307
365
  function uninstallCheckTimer(): boolean {
366
+ // Nix owns the timer: do not bootout/disable the declarative unit.
367
+ if (skipImperativeTimer()) return true;
308
368
  if (process.platform === "darwin") {
309
369
  const domain = launchdDomain();
310
370
  const loaded = launchdJobLoaded();
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
9
9
  import { join } from "node:path";
10
10
  import { z } from "zod";
11
11
  import { delay } from "es-toolkit";
12
- import { createSdkMcpServer, query, tool, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
12
+ import { createSdkMcpServer, query, tool, type SDKUserMessage, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
13
13
  import { StreamingMarkdownRenderer, type StreamChunk } from "chat";
14
14
  import { ensureBestAccount, pooledOptions, stopHookCheck, type SwapDecision } from "../sdk.ts";
15
15
  import { POST_SWAP_COOLDOWN_MS } from "./decide.ts";
@@ -56,6 +56,14 @@ export const TurnOutcomeSchema = z.object({
56
56
  * thread's activeTurn marker with resumeAt and the daemon resumes the turn
57
57
  * itself, instead of the old "re-send it once the pool recovers" drop. */
58
58
  deferUntil: z.number().nullable(),
59
+ /** a steered follow-up's own drained turn FAILED after the primary turn
60
+ * already succeeded (success stays sticky, the loss is announced
61
+ * in-thread): the caller settles the steered messages' reactions as
62
+ * failed, never as done - a lost instruction must not read green.
63
+ * Attribution is per-turn, not per-message (steer() carries text only),
64
+ * so when several messages were steered a successfully folded one can
65
+ * read failed too - accepted: a false re-send ask beats a false green. */
66
+ steerLost: z.boolean(),
59
67
  });
60
68
  export type TurnOutcome = z.infer<typeof TurnOutcomeSchema>;
61
69
 
@@ -234,6 +242,60 @@ export function serveTurnContext(input: { requesterIds: string[] }): string {
234
242
  const SegmentChunkSchema = z.union([z.string(), z.custom<StreamChunk>()]);
235
243
  type SegmentChunk = z.infer<typeof SegmentChunkSchema>;
236
244
 
245
+ /** The exact user-message shape the SDK's own string-prompt path writes to the
246
+ * child's stdin (verified in @anthropic-ai/claude-agent-sdk 0.3.214: the SDK
247
+ * serializes yielded messages verbatim, adding nothing). No uuid on purpose:
248
+ * the CLI dedupes stream-json user messages by uuid and silently swallows a
249
+ * reused one, so a uuid derived from a Slack message ts would eat retries.
250
+ * No priority either: the default "next" folds the message into the running
251
+ * turn at the next tool boundary, while "now" is an undocumented hard
252
+ * interrupt that would abort the turn mid-tool (both verified in the claude
253
+ * 2.1.220 binary). */
254
+ function steerUserMessage(text: string): SDKUserMessage {
255
+ return { type: "user", session_id: "", message: { role: "user", content: [{ type: "text", text }] }, parent_tool_use_id: null };
256
+ }
257
+
258
+ /** A hand-pushed async iterable of SDK user messages: the streaming-input
259
+ * prompt for one query attempt. The initial prompt is pushed before query()
260
+ * and steered messages join mid-turn; end() closes the child's stdin (the
261
+ * SDK ends the stream when the iterable finishes). */
262
+ function pushableMessages(): {
263
+ iterable: AsyncIterable<SDKUserMessage>;
264
+ push: (m: SDKUserMessage) => void;
265
+ end: () => void;
266
+ } {
267
+ const queued: SDKUserMessage[] = [];
268
+ let cursor = 0;
269
+ let done = false;
270
+ let notify: (() => void) | null = null;
271
+ return {
272
+ push(m) {
273
+ queued.push(m);
274
+ notify?.();
275
+ },
276
+ end() {
277
+ done = true;
278
+ notify?.();
279
+ },
280
+ iterable: {
281
+ async *[Symbol.asyncIterator]() {
282
+ while (true) {
283
+ while (cursor < queued.length) {
284
+ const next = queued[cursor]!;
285
+ cursor += 1;
286
+ yield next;
287
+ }
288
+ if (done) return;
289
+ await new Promise<void>((resolve) => {
290
+ notify = resolve;
291
+ });
292
+ notify = null;
293
+ }
294
+ },
295
+ },
296
+ };
297
+ }
298
+
237
299
  /** A hand-pushed async iterable: relayThread feeds one of these per Slack
238
300
  * message segment while thread.post concurrently drains it. */
239
301
  function pushableStream(): {
@@ -478,8 +540,22 @@ export async function relayThread(input: {
478
540
  /** daemon shutdown signal: aborts park/retry sleeps so a drain never sits
479
541
  * out a depleted-pool countdown. */
480
542
  drainSignal?: AbortSignal;
543
+ /** Steering seam. Called with a steer function while a query attempt is
544
+ * live and with null when it ends; steer(text) returns true when the text
545
+ * was written into the RUNNING attempt's stdin (the CLI folds it into the
546
+ * turn at the next tool boundary, or runs it as its own turn in the same
547
+ * child when the fold window is gone - either way it reaches the session,
548
+ * verified against claude 2.1.220), false once the attempt's result
549
+ * arrived or the attempt died - the caller then queues the message as a
550
+ * normal next turn instead. Accepted texts also fold into any RETRY
551
+ * attempt's prompt, mirroring the existing resend-the-full-prompt retry
552
+ * tradeoff (duplication in the resumed transcript beats loss).
553
+ * input.requesterIds is shared by reference on purpose: the caller may
554
+ * push a steer author's id so a retry attempt's UserPromptSubmit context
555
+ * names them (the hook does not fire for folded mid-turn messages). */
556
+ onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
481
557
  }): Promise<TurnOutcome> {
482
- const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, attention: false, announcedDrop: false, resultReceived: false, deferUntil: null };
558
+ const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, attention: false, announcedDrop: false, resultReceived: false, deferUntil: null, steerLost: false };
483
559
  let segment: ReturnType<typeof pushableStream> | null = null;
484
560
  // acc mirrors the segment's pushed text (bounded by SEGMENT_TEXT_MAX plus a
485
561
  // small overshoot): fence parity must be computed over the ACCUMULATED text,
@@ -846,8 +922,15 @@ export async function relayThread(input: {
846
922
  // deferral notice explains the pause; the raw line would invite a manual
847
923
  // re-send of work the daemon resumes itself - cubic catch, PR #44).
848
924
  let pendingFailureLine: string | null = null;
849
- // what the next spawn submits: the original message, until a transient
850
- // retry swaps in the continuation wrapper (see the retry branch).
925
+ // texts steered into this message's turn, kept across retries: a retry
926
+ // resumes the session and re-sends the attempt prompt (the established
927
+ // duplication-beats-loss tradeoff), so steered text folds into every
928
+ // retry attempt's first message too - a steer written just before a
929
+ // mid-turn limit killed the child must not vanish from the turn it joined.
930
+ const steeredTexts: string[] = [];
931
+ // what the next spawn submits ahead of the steered texts: the original
932
+ // message, until a transient retry swaps in the continuation wrapper (see
933
+ // the retry branch).
851
934
  let prompt = input.prompt;
852
935
  const runQueryOnce = async () => {
853
936
  postedText = false;
@@ -863,11 +946,47 @@ export async function relayThread(input: {
863
946
  // inside the try: a malformed claude.json must fail the TURN, not the
864
947
  // relay's never-throws contract.
865
948
  let spawnOrg: string | null = null;
949
+ // Streaming input: the prompt rides an open stdin stream instead of a
950
+ // one-shot string, which is what lets a mid-turn Slack reply steer the
951
+ // running turn (the CLI folds a queued stream-json user message into the
952
+ // current turn at the next tool boundary; one that misses the last fold
953
+ // window runs as its own turn in the same child before exit, so nothing
954
+ // is ever dropped - both verified against claude 2.1.220 + SDK 0.3.214).
955
+ // steer() refuses the moment the attempt's result arrives: stdin ends
956
+ // then, and the SDK silently drops writes to an ended stdin.
957
+ const stream = pushableMessages();
958
+ let steerable = true;
959
+ const steer = (text: string, onAccept?: () => void): boolean => {
960
+ if (!steerable) return false;
961
+ // the caller's SYNCHRONOUS durable commit runs inside the acceptance,
962
+ // in the same JS tick as the liveness check and BEFORE the child sees
963
+ // the text (adversarial-review catch, round 3): steerable=true here
964
+ // proves the turn has not ended, so the commit's view of the turn
965
+ // state cannot be stale, a crash between commit and push replays a
966
+ // steer the child never saw (duplication over loss), and a refusal
967
+ // commits NOTHING - a stale acceptor invocation racing the turn's end
968
+ // can no longer resurrect a finished turn's marker or clobber
969
+ // post-turn state. A THROWING commit escapes to the caller before
970
+ // anything is pushed or recorded here: the stream, steeredTexts, and
971
+ // the sticky flags are untouched, so the caller can treat the throw
972
+ // as a refusal and a later steer still works.
973
+ onAccept?.();
974
+ steeredTexts.push(text);
975
+ stream.push(steerUserMessage(text));
976
+ // a steer answers a LIVE ask too (codex review catch on PR #50): when
977
+ // need_attention already fired in this attempt, the ask exists only as
978
+ // this sticky flag until settleTurn persists it - left set, the
979
+ // answered ask would still get a question mark and a nudge. A later
980
+ // need_attention call re-arms it for a genuinely new ask.
981
+ outcome.attention = false;
982
+ return true;
983
+ };
866
984
  try {
867
985
  spawnOrg = readOAuthAccount()?.organizationUuid ?? null;
868
986
  const pooled = pooledOptions();
987
+ stream.push(steerUserMessage([prompt, ...steeredTexts].join("\n\n")));
869
988
  const q = query({
870
- prompt,
989
+ prompt: stream.iterable,
871
990
  options: {
872
991
  ...pooled,
873
992
  // claude >= 2.1.142 emits the structured Task tools by default and
@@ -934,8 +1053,18 @@ export async function relayThread(input: {
934
1053
  ...(outcome.sessionId ? { resume: outcome.sessionId } : {}),
935
1054
  },
936
1055
  });
1056
+ // registered only while this attempt is live (cleared in the finally):
1057
+ // the caller's fallback for a refused steer is the normal queued turn.
1058
+ input.onSteer?.(steer);
937
1059
  const mapState = newStreamMapState();
938
1060
  let result: string | null = null;
1061
+ // reply text streamed since the last result boundary: each turn in the
1062
+ // child (the primary one, plus any post-fold-window steer drained as
1063
+ // its own turn) delivers its answer independently - a tool-only turn's
1064
+ // answer lives ONLY in its result message, and flushing it at that
1065
+ // result is what keeps a trailing turn's notice or result from
1066
+ // suppressing or clobbering it (adversarial-review catch, round 2).
1067
+ let streamedSinceResult = false;
939
1068
  for await (const message of q) {
940
1069
  if (message.type === "system" && message.subtype === "init") {
941
1070
  // persist BEFORE the turn ends so a first-turn kill stays
@@ -946,41 +1075,82 @@ export async function relayThread(input: {
946
1075
  }
947
1076
  if (message.type === "result") {
948
1077
  outcome.sessionId = message.session_id;
1078
+ // any result ends steerability and closes the child's stdin: a
1079
+ // steer arriving now queues as its own next turn instead. A steer
1080
+ // accepted BEFORE this that missed its fold window still runs -
1081
+ // the CLI drains queued commands as their own turns in this same
1082
+ // child before exiting, emitting a further result each time, so
1083
+ // this loop just keeps consuming until the child exits (end() is
1084
+ // idempotent).
1085
+ steerable = false;
1086
+ stream.end();
949
1087
  // is_error can ride a "success" subtype (a mid-turn usage limit
950
1088
  // arrives exactly that way: result "Claude AI usage limit
951
1089
  // reached|<epoch>"), so errored is a field check, not a subtype
952
1090
  // check - and only an errored result is ever limit-classified.
953
1091
  if (message.is_error || message.subtype !== "success") {
954
1092
  const text = erroredResultText(message);
955
- outcome.failed = true;
956
- outcome.rateLimited = isRateLimitText({ text });
957
- // persist the observation: the retry's decision otherwise re-reads
958
- // the stale pre-limit snapshot (poll TTL) and respawns the same
959
- // depleted account - a serve process has no statusLine tee.
960
- if (outcome.rateLimited) await recordObservedLimit({ text, now: Date.now(), org: spawnOrg });
961
- // hold the REAL errored text for the terminal diagnostic (codex
962
- // review catches: a streamed-then-errored turn used to end with
963
- // a truncated answer and only the x reaction, and a no-text
964
- // errored turn used to discard the reason for a generic line).
965
- // The depleted-pool probe still outranks the line.
966
- else {
967
- const reason = text.slice(0, 200) || "no error detail";
968
- pendingFailureLine = postedText
969
- ? `tokenmaxxing: the turn errored before finishing (${reason}) - the reply above may be incomplete.`
970
- : `tokenmaxxing: the turn errored without a result (${reason}) - trying again may help.`;
1093
+ const limited = isRateLimitText({ text });
1094
+ // persist the observation either way: the next spawn decision
1095
+ // must see the limit even when this turn does not retry.
1096
+ if (limited) await recordObservedLimit({ text, now: Date.now(), org: spawnOrg });
1097
+ if (outcome.resultReceived) {
1098
+ // SUCCESS IS STICKY within an attempt (adversarial-review
1099
+ // catch): this errored result belongs to a post-fold-window
1100
+ // steer's own drained turn, arriving AFTER the primary turn
1101
+ // already succeeded and delivered its answer. Marking the turn
1102
+ // failed here would send the whole prompt back through the
1103
+ // retry/defer machinery and re-run completed work - the exact
1104
+ // duplicate-execution the outcome contract forbids. The steer
1105
+ // is announced lost instead (drop-beats-false-promise), and
1106
+ // steerLost settles the steered messages' reactions as failed.
1107
+ outcome.steerLost = true;
1108
+ log("serve.steered_turn_failed", { limited });
1109
+ await notify(
1110
+ limited
1111
+ ? "a steered follow-up message hit a usage limit before it could run - please re-send it."
1112
+ : "a steered follow-up message failed - please re-send it.",
1113
+ );
1114
+ } else {
1115
+ outcome.failed = true;
1116
+ // a limit classification is sticky across a child's errored
1117
+ // results (adversarial-review catch, round 2): the drained
1118
+ // steer turn's generic death must not declassify the primary
1119
+ // turn's recoverable limit back to a plain failure.
1120
+ outcome.rateLimited = outcome.rateLimited || limited;
1121
+ // hold the REAL errored text for the terminal diagnostic (codex
1122
+ // review catches: a streamed-then-errored turn used to end with
1123
+ // a truncated answer and only the x reaction, and a no-text
1124
+ // errored turn used to discard the reason for a generic line).
1125
+ // The depleted-pool probe still outranks the line.
1126
+ if (!limited) {
1127
+ const reason = text.slice(0, 200) || "no error detail";
1128
+ pendingFailureLine = postedText
1129
+ ? `tokenmaxxing: the turn errored before finishing (${reason}) - the reply above may be incomplete.`
1130
+ : `tokenmaxxing: the turn errored without a result (${reason}) - trying again may help.`;
1131
+ }
971
1132
  }
972
1133
  } else {
973
1134
  result = message.result;
974
1135
  outcome.resultReceived = true;
1136
+ // a turn that streamed no reply text (tool-only turns) still
1137
+ // reports: its answer is flushed HERE, per result, not after the
1138
+ // loop - a later turn in the same child must not suppress it. A
1139
+ // paragraph break ahead of it when text already posted, or two
1140
+ // result-only answers would concatenate mid-line (codex review
1141
+ // catch on PR #50).
1142
+ if (!streamedSinceResult && result) await pushText(`${postedText ? "\n\n" : ""}${result}`);
975
1143
  }
1144
+ streamedSinceResult = false;
976
1145
  }
977
1146
  for (const part of agentEventChunks({ state: mapState, message })) {
978
1147
  if (part instanceof Object) await push(part);
979
- else await pushText(part);
1148
+ else {
1149
+ if (part.trim() !== "") streamedSinceResult = true;
1150
+ await pushText(part);
1151
+ }
980
1152
  }
981
1153
  }
982
- // a turn that produced no streamed text (tool-only turns) still reports.
983
- if (!postedText && result) await pushText(result);
984
1154
  } catch (e) {
985
1155
  outcome.failed = true;
986
1156
  const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
@@ -991,6 +1161,13 @@ export async function relayThread(input: {
991
1161
  // deferral's own notice explains the pause better than a raw child
992
1162
  // error that reads as "please re-send".
993
1163
  if (!outcome.rateLimited) pendingFailureLine = `tokenmaxxing: turn failed: ${detail}`;
1164
+ } finally {
1165
+ // a died attempt must stop accepting steers (they would be silent
1166
+ // drops on an ended stdin) and must release the caller's steer hook
1167
+ // before the retry loop decides anything.
1168
+ steerable = false;
1169
+ stream.end();
1170
+ input.onSteer?.(null);
994
1171
  }
995
1172
  };
996
1173
 
@@ -133,6 +133,15 @@ const ActiveTurnSchema = z.object({
133
133
  * Absent on older records: recovery falls back to the streamable
134
134
  * handle's newest-author derivation. */
135
135
  requesterIds: z.array(z.string()).optional(),
136
+ /** Slack ids of messages STEERED into this turn mid-run: they carry the
137
+ * same hourglass-to-terminal reaction lifecycle as the triggering
138
+ * message, so a killed or deferred turn's recovery must settle them too.
139
+ * Their text is already folded into `prompt` at steer time, which is what
140
+ * makes replays and retries include what the user steered in. An inbound
141
+ * takeover of a DEFERRED turn also adopts the held turn's unsettled ids
142
+ * here: the takeover serves their held prompt, and without the adoption
143
+ * the old trigger's hourglass would read "processing" forever. */
144
+ steeredMessageIds: z.array(z.string()).optional(),
136
145
  });
137
146
  export type ActiveTurn = z.infer<typeof ActiveTurnSchema>;
138
147