mixdog 0.9.85 → 0.9.86

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.
Files changed (74) hide show
  1. package/README.md +7 -1
  2. package/package.json +3 -2
  3. package/src/defaults/agents.json +12 -0
  4. package/src/defaults/skills/setup/SKILL.md +44 -12
  5. package/src/help.mjs +40 -9
  6. package/src/lib/keychain-cjs.cjs +11 -1
  7. package/src/rules/agent/43-title-agent.md +22 -0
  8. package/src/rules/shared/01-tool.md +3 -3
  9. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  10. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  13. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  14. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  16. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  17. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  18. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  19. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  20. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  21. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  22. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  23. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  24. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  29. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  30. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  31. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  32. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  33. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  34. package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
  35. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  36. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  37. package/src/runtime/media/renditions.mjs +1 -1
  38. package/src/runtime/media/store.mjs +1 -1
  39. package/src/runtime/shared/atomic-file.mjs +25 -14
  40. package/src/runtime/shared/automation-attachments.mjs +3 -3
  41. package/src/runtime/shared/child-guardian.mjs +15 -5
  42. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  43. package/src/runtime/shared/resource-admission.mjs +7 -3
  44. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  45. package/src/session-runtime/lifecycle-api.mjs +34 -9
  46. package/src/session-runtime/prewarm.mjs +35 -0
  47. package/src/session-runtime/remote-control.mjs +8 -47
  48. package/src/session-runtime/remote-transcript.mjs +3 -7
  49. package/src/session-runtime/route-preparation.mjs +24 -0
  50. package/src/session-runtime/runtime-core.mjs +37 -85
  51. package/src/session-runtime/session-turn-api.mjs +9 -2
  52. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  53. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  54. package/src/standalone/agent-shard/shard-pool.mjs +442 -0
  55. package/src/standalone/agent-tool.mjs +222 -0
  56. package/src/standalone/channel-daemon-transport.mjs +126 -185
  57. package/src/standalone/channel-daemon.mjs +9 -10
  58. package/src/tui/App.jsx +16 -9
  59. package/src/tui/app/channel-pickers.mjs +3 -8
  60. package/src/tui/app/route-pickers.mjs +117 -152
  61. package/src/tui/app/settings-picker.mjs +90 -89
  62. package/src/tui/app/slash-commands.mjs +7 -7
  63. package/src/tui/app/slash-dispatch.mjs +0 -45
  64. package/src/tui/components/StatusLine.jsx +1 -1
  65. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  66. package/src/tui/dist/index.mjs +243 -323
  67. package/src/tui/engine/session-api-ext.mjs +24 -9
  68. package/src/tui/engine.mjs +7 -1
  69. package/src/tui/figures.mjs +0 -1
  70. package/src/ui/statusline-format.mjs +0 -1
  71. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  72. package/src/lib/config-cjs.cjs +0 -61
  73. package/src/runtime/shared/launcher-control.mjs +0 -258
  74. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -1,6 +1,12 @@
1
1
  import { isOffloadedToolResultText } from './tool-result-offload.mjs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { createRequire } from 'node:module';
4
+ import { bpeEncodeCount } from './token-bpe.mjs';
5
+ import {
6
+ countTokensNative,
7
+ nativeTokenCounterEnabled,
8
+ prewarmNativeTokenCounter,
9
+ } from './token-native.mjs';
4
10
  import {
5
11
  isFinalizedProviderRequestTools,
6
12
  providerNativeToolPrefixCount,
@@ -78,48 +84,49 @@ function bpeEncoder() {
78
84
  const TOKEN_COUNT_CACHE_MIN_CHARS = 512;
79
85
  const TOKEN_COUNT_CACHE_MAX_ENTRIES = 1_024;
80
86
  const tokenCountCache = new Map();
81
- // BPE merge cost is quadratic in WORD length, not text length: a degenerate
82
- // single-word run ('p'.repeat(250k), base64/minified blobs) forms one giant
83
- // pat_str word and encode() spins for minutes while the event loop is blocked
84
- // (observed live via inspector: compact-smoke hung inside tiktoken encode).
85
- // Encoding fixed-size slices caps the worst-case word at the chunk size, so
86
- // cost stays linear. Chunk cuts snap back to the nearest whitespace so the
87
- // next slice starts at a natural ` word` boundary — o200k tokenizes that
88
- // identically to the unsliced text, keeping estimates EXACT for prose
89
- // (compact-smoke asserts est === real o200k count). Only a whitespace-free
90
- // degenerate run falls back to a hard cut (±1 token per boundary, safely
91
- // inside the estimate's safety multiplier). Hard cuts avoid splitting a
92
- // surrogate pair so sliced emoji/CJK-ext code points still encode cleanly.
93
- const BPE_ENCODE_CHUNK_CHARS = 4_096;
94
- const BPE_CHUNK_BOUNDARY_SCAN = 512;
95
- function bpeEncodeCount(enc, s) {
96
- if (s.length <= BPE_ENCODE_CHUNK_CHARS) return enc.encode(s, undefined, []).length;
97
- let total = 0;
98
- let i = 0;
99
- while (i < s.length) {
100
- let end = Math.min(s.length, i + BPE_ENCODE_CHUNK_CHARS);
101
- if (end < s.length) {
102
- // Prefer cutting BEFORE a whitespace run: the next chunk then
103
- // starts with ` word`, which o200k merges exactly as it would
104
- // mid-text. Scan a bounded window so degenerate inputs stay O(1).
105
- let ws = -1;
106
- const scanFloor = Math.max(i + 1, end - BPE_CHUNK_BOUNDARY_SCAN);
107
- for (let j = end - 1; j >= scanFloor; j -= 1) {
108
- const c = s.charCodeAt(j);
109
- if (c === 0x20 || c === 0x0A || c === 0x0D || c === 0x09) { ws = j; break; }
110
- }
111
- if (ws > i) {
112
- end = ws; // next chunk starts at the whitespace
113
- } else {
114
- const last = s.charCodeAt(end - 1);
115
- if (last >= 0xD800 && last <= 0xDBFF) end += 1;
116
- }
117
- }
118
- total += enc.encode(s.slice(i, end), undefined, []).length;
119
- i = end;
87
+
88
+ function _tokenCacheSet(key, count) {
89
+ if (tokenCountCache.size >= TOKEN_COUNT_CACHE_MAX_ENTRIES) {
90
+ tokenCountCache.delete(tokenCountCache.keys().next().value);
120
91
  }
121
- return total;
92
+ tokenCountCache.set(key, count);
93
+ }
94
+
95
+ // ── Native offload for large encodes ────────────────────────────────────────
96
+ // estimateTokens is a SYNC api polled from context gauges and compaction
97
+ // pressure checks. A large fresh string (big tool result) costs tens of ms to
98
+ // encode; under many parallel agents those encodes serialize the one shared
99
+ // event loop. Strings at/above BPE_ASYNC_THRESHOLD_CHARS with no cache entry
100
+ // are therefore counted by the mixdog-token native server (SINGLE offload
101
+ // engine — no worker-thread fallback by design): the caller immediately gets
102
+ // the conservative legacy heuristic, the precise count lands in
103
+ // tokenCountCache, and the next poll returns the exact value. Smaller
104
+ // strings (the vast majority) keep exact synchronous counting. With the
105
+ // native server unavailable (MIXDOG_TOKEN_NATIVE=0, missing binary before
106
+ // the first token-v release), large strings encode synchronously — exact,
107
+ // pre-offload semantics.
108
+ const BPE_ASYNC_THRESHOLD_CHARS = 32_768;
109
+ const _nativePendingKeys = new Set();
110
+
111
+ // Offload one large count to the native mixdog-token server. Returns false
112
+ // when the native path cannot be attempted (caller encodes synchronously). A
113
+ // mid-flight failure just drops the pending key: the next poll re-evaluates
114
+ // and encodes synchronously if the server is really gone.
115
+ function _offloadTokenCountNative(key, s) {
116
+ if (!nativeTokenCounterEnabled()) return false;
117
+ _nativePendingKeys.add(key);
118
+ countTokensNative(s).then((count) => {
119
+ if (!_nativePendingKeys.delete(key)) return;
120
+ if (Number.isFinite(count) && count >= 0) _tokenCacheSet(key, count);
121
+ }).catch(() => {
122
+ _nativePendingKeys.delete(key);
123
+ });
124
+ return true;
122
125
  }
126
+
127
+ // Returns the exact count, or null when the string was handed to the native
128
+ // server
129
+ // (caller falls back to the legacy heuristic until the precise count lands).
123
130
  function bpeTokenCount(enc, s) {
124
131
  if (s.length < TOKEN_COUNT_CACHE_MIN_CHARS) return bpeEncodeCount(enc, s);
125
132
  const key = `${s.length}:${createHash('sha1').update(s).digest('base64')}`;
@@ -129,11 +136,12 @@ function bpeTokenCount(enc, s) {
129
136
  tokenCountCache.set(key, hit);
130
137
  return hit;
131
138
  }
132
- const count = bpeEncodeCount(enc, s);
133
- if (tokenCountCache.size >= TOKEN_COUNT_CACHE_MAX_ENTRIES) {
134
- tokenCountCache.delete(tokenCountCache.keys().next().value);
139
+ if (s.length >= BPE_ASYNC_THRESHOLD_CHARS) {
140
+ if (!_nativePendingKeys.has(key)) _offloadTokenCountNative(key, s);
141
+ if (_nativePendingKeys.has(key)) return null;
135
142
  }
136
- tokenCountCache.set(key, count);
143
+ const count = bpeEncodeCount(enc, s);
144
+ _tokenCacheSet(key, count);
137
145
  return count;
138
146
  }
139
147
 
@@ -203,12 +211,32 @@ export function estimateTokens(text) {
203
211
  const enc = bpeEncoder();
204
212
  if (enc) {
205
213
  try {
206
- return Math.ceil(bpeTokenCount(enc, s) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
214
+ const count = bpeTokenCount(enc, s);
215
+ // null = precise count is being computed in the worker; return the
216
+ // conservative heuristic until it lands in the cache.
217
+ if (count !== null) return Math.ceil(count * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
207
218
  } catch { /* corrupt input — degrade to the heuristic below */ }
208
219
  }
209
220
  return legacyEstimateTokens(s);
210
221
  }
211
222
 
223
+ /**
224
+ * Boot prewarm: load the o200k encoder (WASM init), run one small encode
225
+ * (JIT), and spawn the token-count worker so the first large transcript
226
+ * estimate of a session neither blocks on WASM init nor waits for the
227
+ * worker spawn. Best-effort; returns true when the encoder is live.
228
+ */
229
+ export function prewarmTokenEstimator() {
230
+ const enc = bpeEncoder();
231
+ if (enc) {
232
+ try { bpeEncodeCount(enc, 'mixdog tokenizer warmup — 워밍업 텍스트 0123456789'); } catch { /* best-effort */ }
233
+ }
234
+ // Native server boots its encoder (~100-300ms) off the first estimate;
235
+ // with no binary yet, this also kicks the one-shot release fetch.
236
+ try { prewarmNativeTokenCounter(); } catch { /* best-effort */ }
237
+ return !!enc;
238
+ }
239
+
212
240
  // Legacy conservative Unicode-aware estimate (fallback only). Iterates by
213
241
  // code point, takes the max of the weighted sum and the chars/4 ASCII floor,
214
242
  // then applies the safety multiplier.
@@ -1,9 +1,10 @@
1
1
  // Eager tool-dispatch controller, extracted from agent-loop.mjs. Owns the
2
2
  // per-turn pending promise map, the intra-turn in-flight signature set, and
3
- // the mutation epoch. Read-only tool calls start executing the instant the
4
- // provider streams a tool_use event so execution overlaps the remaining SSE
5
- // parse; writes/unknown tools wait for the serial batch loop. Behavior
6
- // identical to the inline closures it replaced.
3
+ // the mutation epoch. FULL-PARALLEL policy: every tool call starts executing
4
+ // the instant the provider streams its tool_use event (or at batch start),
5
+ // shell/MCP/writes included — the model owns ordering by splitting dependent
6
+ // work into separate turns. Only apply_patch (ordered mutation) waits for the
7
+ // serial batch loop.
7
8
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
8
9
  import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
9
10
  import { _intraTurnSig, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
@@ -11,7 +12,7 @@ import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
11
12
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
12
13
  import { executeTool } from './loop/tool-exec.mjs';
13
14
  import { crossTurnSignature } from './loop/completion-guards.mjs';
14
- import { getToolKind, isEagerDispatchable, isToolCallDedupEligible } from './loop/tool-helpers.mjs';
15
+ import { getToolKind, isParallelDispatchable, isToolCallDedupEligible } from './loop/tool-helpers.mjs';
15
16
 
16
17
  export function createEagerDispatcher({
17
18
  tools, cwd, sessionId, sessionRef, signal, opts,
@@ -40,7 +41,7 @@ export function createEagerDispatcher({
40
41
  const _eagerInFlightSigs = new Map();
41
42
  const epoch = { mutation: 0 };
42
43
  const startEagerTool = (call) => {
43
- if (!call?.id || pending.has(call.id) || !isEagerDispatchable(call.name, tools)) return null;
44
+ if (!call?.id || pending.has(call.id) || !isParallelDispatchable(call.name)) return null;
44
45
  // Never eager-execute a call whose arguments failed to parse
45
46
  // (invalid-args marker). It has no usable arguments; the serial
46
47
  // body handles it via the invalid-args feedback path.
@@ -157,26 +158,22 @@ export function createEagerDispatcher({
157
158
  const startEagerRun = (calls, startIndex, dupSet) => {
158
159
  for (let j = startIndex; j < calls.length; j += 1) {
159
160
  const call = calls[j];
160
- if (!call?.id || !isEagerDispatchable(call.name, tools)) break;
161
+ // Full-parallel: only the ordered mutation (apply_patch) is
162
+ // skipped — it executes in the serial batch body. No barrier:
163
+ // later calls keep starting in parallel past it.
164
+ if (!call?.id || !isParallelDispatchable(call.name)) continue;
161
165
  if (dupSet && dupSet.has(call.id)) continue;
162
- // A null return here is NOT a state barrier: the loop above
163
- // already breaks at the first non-eager (mutation/bash/unknown)
164
- // tool, so every call reached here is read-only. A null means a
166
+ // A null return here is NOT a state barrier. It means a
165
167
  // non-barrier stub — intra-turn in-flight dup, repeat-failure /
166
168
  // cross-turn dedup, pre-dispatch-deny, invalid-args, or a cache
167
169
  // short-circuit. `continue` (not `break`) so a stub in the
168
- // middle of a contiguous eager run does not stop LATER
169
- // independent eager reads from starting early.
170
+ // middle of the run does not stop LATER independent calls from
171
+ // starting early.
170
172
  if (!startEagerTool(call) && !pending.has(call.id)) continue;
171
173
  }
172
174
  };
173
- let _streamEagerBlocked = false;
174
175
  const onToolCall = (call) => {
175
- if (!isEagerDispatchable(call?.name, tools)) {
176
- _streamEagerBlocked = true;
177
- return;
178
- }
179
- if (_streamEagerBlocked) return;
176
+ if (!isParallelDispatchable(call?.name)) return;
180
177
  startEagerTool(call);
181
178
  };
182
179
  return { pending, epoch, startEagerTool, startEagerRun, onToolCall };
@@ -42,7 +42,7 @@ export function agentContextOverflowError({ stage, sessionId, sessionRef, model,
42
42
  // etc.). This is NOT "latest turn cannot fit the context budget" — masking it
43
43
  // as AGENT_CONTEXT_OVERFLOW hides the real failure and misroutes downstream
44
44
  // overflow handling. Genuine provider send overflow keeps AgentContextOverflowError.
45
- export class AgentCompactFailedError extends Error {
45
+ class AgentCompactFailedError extends Error {
46
46
  constructor({ stage, sessionId, provider, model }, cause) {
47
47
  const target = [provider, model].filter(Boolean).join('/') || 'target model';
48
48
  const causeMsg = cause && cause.message ? `: ${cause.message}` : '';
@@ -12,6 +12,7 @@ import {
12
12
  isSkillDisabled,
13
13
  } from '../../context/collect.mjs';
14
14
  import { isAgentOwner } from '../../agent-owner.mjs';
15
+ import { _isMutationTool } from './tool-classify.mjs';
15
16
 
16
17
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
17
18
  // to execute during SSE parsing so tool work overlaps with the rest of the
@@ -40,6 +41,18 @@ export function isEagerDispatchable(name, tools) {
40
41
  return set.has(name);
41
42
  }
42
43
 
44
+ // Full-parallel dispatch policy: EVERY tool call in an assistant turn starts
45
+ // executing immediately — ordering across dependent work is the MODEL's job
46
+ // (split into separate turns). The only exception is the ordered mutation
47
+ // (apply_patch), which stays in the serial batch body so multi-patch turns
48
+ // keep deterministic order, the all-or-nothing failure gate, and cache
49
+ // invalidation sequencing. isEagerDispatchable above remains the READ-ONLY
50
+ // classifier used for dedup eligibility, steering, and post-mutation result
51
+ // invalidation.
52
+ export function isParallelDispatchable(name) {
53
+ return typeof name === 'string' && name.length > 0 && !_isMutationTool(name);
54
+ }
55
+
43
56
  // Read-only is necessary but not sufficient for result deduplication. Loader
44
57
  // calls mutate the active session tool surface and must execute on every
45
58
  // explicit invocation so repeats can truthfully report `alreadyActive`.
@@ -3,10 +3,10 @@
3
3
  // mutex-guarded turn loop (askSession) plus the abort-aware call wrapper
4
4
  // (_api_call_with_interrupt). Behavior-preserving move — every runtime
5
5
  // helper it used in manager.mjs is now imported from its split module.
6
- import { createHash } from 'crypto';
6
+ import { createHash, randomUUID } from 'crypto';
7
7
  import { getProvider } from '../../providers/registry.mjs';
8
8
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
9
- import { loadSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
9
+ import { loadSession, saveSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
10
10
  import { createAbortController } from '../../../../shared/abort-controller.mjs';
11
11
  import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
12
12
  import { appendAgentTrace } from '../../agent-trace.mjs';
@@ -56,6 +56,12 @@ import {
56
56
  persistCompactedOutgoingAfterAskFailure,
57
57
  } from './message-sanitize.mjs';
58
58
  import { createTurnInterruptionTracker } from './turn-interruption.mjs';
59
+ import {
60
+ clearTurnCheckpoint,
61
+ recoverTurnCheckpoint,
62
+ turnMessagesForCheckpoint,
63
+ writeTurnCheckpoint,
64
+ } from './turn-checkpoint.mjs';
59
65
  import { _getAgentLoop } from './runtime-loaders.mjs';
60
66
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
61
67
  import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
@@ -208,6 +214,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
208
214
  // Hoisted so the outer finally (which runs once after the whole turn loop)
209
215
  // can compare against the last turn's generation.
210
216
  let askGeneration = 0;
217
+ let _crashRecoveryChecked = false;
211
218
  try {
212
219
  // Turn loop (pendingMessages pattern): run the current prompt, then drain
213
220
  // any `agent type=send` messages that were queued while this turn was in
@@ -262,6 +269,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
262
269
  if (preSession.closed === true) {
263
270
  throw new SessionClosedError(sessionId, 'session already closed');
264
271
  }
272
+ if (!_crashRecoveryChecked) {
273
+ _crashRecoveryChecked = true;
274
+ const recovery = recoverTurnCheckpoint(preSession);
275
+ if (recovery.changed) {
276
+ saveSession(preSession, {
277
+ sync: true,
278
+ expectedGeneration: preSession.generation,
279
+ });
280
+ if (recovery.turnToken) clearTurnCheckpoint(sessionId, recovery.turnToken);
281
+ }
282
+ }
265
283
  // A prior crash/partial-save during compaction may have pinned
266
284
  // compaction.lastStage='compacting'. This ask is starting fresh, so
267
285
  // recover the stale transient stage before the loop's pre-send compact
@@ -306,11 +324,62 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
306
324
  let cancelledUserTurnContent = '';
307
325
  let _turnOutgoing = null;
308
326
  const _turnInterruption = createTurnInterruptionTracker();
327
+ const _turnCheckpointToken = randomUUID();
328
+ const _turnCheckpointStartedAt = Date.now();
329
+ const _TURN_CHECKPOINT_THROTTLE_MS = 150;
330
+ let _turnCheckpointTimer = null;
331
+ let _turnCheckpointStopped = false;
332
+ let _turnCheckpointLastAt = 0;
333
+ let _turnCheckpointWarned = false;
334
+ const _flushTurnCheckpoint = () => {
335
+ if (_turnCheckpointStopped || !_turnOutgoing || !cancelledUserTurnContent) return false;
336
+ try {
337
+ const written = writeTurnCheckpoint({
338
+ sessionId,
339
+ generation: askGeneration,
340
+ turnToken: _turnCheckpointToken,
341
+ startedAt: _turnCheckpointStartedAt,
342
+ currentUserContent: cancelledUserTurnContent,
343
+ turnMessages: turnMessagesForCheckpoint(_turnOutgoing, cancelledUserTurnContent),
344
+ interruption: _turnInterruption.snapshot(),
345
+ });
346
+ if (written) _turnCheckpointLastAt = Date.now();
347
+ return written;
348
+ } catch (error) {
349
+ if (!_turnCheckpointWarned) {
350
+ _turnCheckpointWarned = true;
351
+ try { process.stderr.write(`[turn-checkpoint] write failed session=${sessionId}: ${error?.message || error}\n`); } catch {}
352
+ }
353
+ return false;
354
+ }
355
+ };
356
+ const _scheduleTurnCheckpoint = (immediate = false) => {
357
+ if (_turnCheckpointStopped || !_turnOutgoing) return;
358
+ if (immediate || Date.now() - _turnCheckpointLastAt >= _TURN_CHECKPOINT_THROTTLE_MS) {
359
+ if (_turnCheckpointTimer) clearTimeout(_turnCheckpointTimer);
360
+ _turnCheckpointTimer = null;
361
+ _flushTurnCheckpoint();
362
+ return;
363
+ }
364
+ if (_turnCheckpointTimer) return;
365
+ const waitMs = Math.max(1, _TURN_CHECKPOINT_THROTTLE_MS - (Date.now() - _turnCheckpointLastAt));
366
+ _turnCheckpointTimer = setTimeout(() => {
367
+ _turnCheckpointTimer = null;
368
+ _flushTurnCheckpoint();
369
+ }, waitMs);
370
+ _turnCheckpointTimer.unref?.();
371
+ };
372
+ const _stopTurnCheckpoint = () => {
373
+ _turnCheckpointStopped = true;
374
+ if (_turnCheckpointTimer) clearTimeout(_turnCheckpointTimer);
375
+ _turnCheckpointTimer = null;
376
+ };
309
377
  const _sessionStartMetaInjectedBeforeTurn = preSession.sessionStartMetaInjected === true;
310
378
  let _interruptionSnapshot = null;
311
379
  const _prepareCloseSnapshot = (abortReason) => {
312
380
  if (_interruptionSnapshot) return _interruptionSnapshot;
313
381
  if (!activeSession) return null;
382
+ _stopTurnCheckpoint();
314
383
  activeSession.liveTurnMessages = null;
315
384
  _turnInterruption.restoreTombstonedText();
316
385
  const finalized = _turnInterruption.finalize({
@@ -319,6 +388,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
319
388
  abortReason,
320
389
  });
321
390
  activeSession.messages = finalized.messages;
391
+ delete activeSession.activeTurnCheckpoint;
322
392
  if (!finalized.responsePreserved) {
323
393
  if (finalized.userTurnPreserved) {
324
394
  // A non-user detach keeps the provisional prompt but makes
@@ -455,6 +525,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
455
525
  // user turn is introduced.
456
526
  session.messages = filterModelVisibleSessionMessages(outgoing);
457
527
  session.liveTurnMessages = outgoing;
528
+ session.activeTurnCheckpoint = {
529
+ version: 1,
530
+ turnToken: _turnCheckpointToken,
531
+ startedAt: _turnCheckpointStartedAt,
532
+ };
533
+ // The sidecar lands synchronously before provider execution. Even
534
+ // if the async canonical-session preflight save has not reached its
535
+ // worker when the process is killed, recovery still has the prompt.
536
+ _scheduleTurnCheckpoint(true);
458
537
  saveSessionAsync(session, { expectedGeneration: askGeneration }).catch((err) => {
459
538
  try { process.stderr.write(`[session] preflight user-turn save failed: ${err?.message || err}\n`); } catch {}
460
539
  });
@@ -488,6 +567,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
488
567
  const agentLoop = await _getAgentLoop();
489
568
  const _trackTextDelta = (chunk) => {
490
569
  _turnInterruption.recordTextDelta(chunk);
570
+ _scheduleTurnCheckpoint();
491
571
  if (typeof askOpts?.onTextDelta === 'function') askOpts.onTextDelta(chunk);
492
572
  };
493
573
  const _trackTextReset = async (detail) => {
@@ -504,23 +584,28 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
504
584
  }
505
585
  if (!acknowledged) return false;
506
586
  _turnInterruption.tombstoneText(detail?.chars);
587
+ _scheduleTurnCheckpoint(true);
507
588
  return true;
508
589
  };
509
590
  const _trackReasoningDelta = (chunk) => {
510
591
  _turnInterruption.recordReasoningDelta(chunk);
592
+ _scheduleTurnCheckpoint();
511
593
  if (typeof askOpts?.onReasoningDelta === 'function') askOpts.onReasoningDelta(chunk);
512
594
  };
513
595
  const _trackAssistantText = (text) => {
514
596
  _turnInterruption.recordAssistantText(text);
597
+ _scheduleTurnCheckpoint(true);
515
598
  if (typeof askOpts?.onAssistantText === 'function') askOpts.onAssistantText(text);
516
599
  };
517
600
  const _trackedOnToolCall = async (iteration, calls) => {
518
601
  _turnInterruption.recordToolCalls(calls);
602
+ _scheduleTurnCheckpoint(true);
519
603
  if (typeof onToolCall === 'function') return await onToolCall(iteration, calls);
520
604
  return undefined;
521
605
  };
522
606
  const _trackToolResult = (message) => {
523
607
  _turnInterruption.recordToolResult(message);
608
+ _scheduleTurnCheckpoint(true);
524
609
  if (typeof askOpts?.onToolResult === 'function') askOpts.onToolResult(message);
525
610
  };
526
611
  const priorToolApprovalHook = session.toolApprovalHook;
@@ -539,10 +624,22 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
539
624
  onReasoningDelta: _trackReasoningDelta,
540
625
  onAssistantText: _trackAssistantText,
541
626
  takeAssistantTranscriptMetadata: _takeAssistantTranscriptMetadata,
542
- onAssistantMessageCommitted: () => _turnInterruption.markAssistantMessageCommitted(),
543
- onAssistantToolCallObserved: (call, detail) => _turnInterruption.recordToolCalls([call], detail),
544
- onProviderSendStarted: () => _turnInterruption.markProviderSendStarted(),
545
- onToolPhaseStarted: () => _turnInterruption.markToolPhaseStarted(),
627
+ onAssistantMessageCommitted: () => {
628
+ _turnInterruption.markAssistantMessageCommitted();
629
+ _scheduleTurnCheckpoint(true);
630
+ },
631
+ onAssistantToolCallObserved: (call, detail) => {
632
+ _turnInterruption.recordToolCalls([call], detail);
633
+ _scheduleTurnCheckpoint(true);
634
+ },
635
+ onProviderSendStarted: () => {
636
+ _turnInterruption.markProviderSendStarted();
637
+ _scheduleTurnCheckpoint(true);
638
+ },
639
+ onToolPhaseStarted: () => {
640
+ _turnInterruption.markToolPhaseStarted();
641
+ _scheduleTurnCheckpoint(true);
642
+ },
546
643
  onUsageDelta: (d) => {
547
644
  persistIterationMetrics(d).catch(() => {});
548
645
  // provider_send usage arrives before agentLoop appends
@@ -565,7 +662,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
565
662
  },
566
663
  onToolResult: _trackToolResult,
567
664
  onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
568
- onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
665
+ onCompactEvent: (event) => {
666
+ _scheduleTurnCheckpoint(true);
667
+ try { askOpts?.onCompactEvent?.(event); } catch {}
668
+ },
569
669
  // Claude Code parity: mid-chain queued prompt/notification
570
670
  // drain is owned by agentLoop at provider-continuation
571
671
  // boundaries (after a tool batch, before the next send).
@@ -586,7 +686,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
586
686
  // post-loop _pendingTail drain below.
587
687
  return out;
588
688
  },
589
- onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
689
+ onSteerMessage: (text, detail) => {
690
+ _scheduleTurnCheckpoint(true);
691
+ try { askOpts?.onSteerMessage?.(text, detail); } catch {}
692
+ },
590
693
  notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
591
694
  promptCacheKey: session.promptCacheKey || sessionId,
592
695
  // Provider-scoped cache key (mixdog-codex, mixdog-claude…).
@@ -639,6 +742,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
639
742
  // Turn committed into session.messages; drop the live-turn alias so
640
743
  // contextStatus() reverts to the authoritative committed transcript.
641
744
  session.liveTurnMessages = null;
745
+ _stopTurnCheckpoint();
746
+ delete session.activeTurnCheckpoint;
642
747
  const _assistantTranscriptMeta = persistedAssistantTranscriptMetadata(_rawTranscriptMeta);
643
748
  if (result.content || result.reasoningContent) {
644
749
  // Max-output recovery returns the complete concatenated text to
@@ -846,6 +951,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
846
951
  ? saveSessionAsyncDeferred
847
952
  : saveSessionAsync;
848
953
  const terminalSave = saveTerminalSession(session, { expectedGeneration: askGeneration });
954
+ terminalSave.then(
955
+ () => clearTurnCheckpoint(sessionId, _turnCheckpointToken),
956
+ () => {},
957
+ );
849
958
  finalizePendingMessageDelivery(
850
959
  session,
851
960
  _turnPendingEntries,
@@ -867,10 +976,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
867
976
  markSessionDone(sessionId, { empty: isEmptyFinal });
868
977
  _result = terminalResultPreview;
869
978
  } catch (err) {
979
+ _stopTurnCheckpoint();
870
980
  // Cancellation/error paths bypass the commit point above; drop the
871
981
  // live-turn alias so contextStatus() stops estimating from the
872
982
  // stale in-flight array once the turn unwinds.
873
- if (activeSession) activeSession.liveTurnMessages = null;
983
+ if (activeSession) {
984
+ activeSession.liveTurnMessages = null;
985
+ delete activeSession.activeTurnCheckpoint;
986
+ }
874
987
  // Restore before ANY finalization path. In particular, cancellation
875
988
  // can race the acknowledged non-streaming restart and surface as a
876
989
  // SessionClosedError; its interruption snapshot must include the
@@ -899,6 +1012,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
899
1012
  } else {
900
1013
  await durableSave;
901
1014
  }
1015
+ clearTurnCheckpoint(sessionId, _turnCheckpointToken);
902
1016
  } catch { /* cancellation cleanup is best-effort */ }
903
1017
  if (currentRuntime) currentRuntime.session = activeSession;
904
1018
  } else releasePendingMessages(sessionId, _turnPendingEntries);
@@ -918,6 +1032,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
918
1032
  const preserveProviderPartial = restoredResetText
919
1033
  || err?.liveTextEmitted === true
920
1034
  || err?.unsafeToRetry === true;
1035
+ let _errorStateDurable = false;
921
1036
  if (preserveProviderPartial && activeSession && _turnInterruption.hasResponseStarted()) {
922
1037
  const finalized = _prepareCloseSnapshot('provider-error');
923
1038
  if (finalized.responsePreserved) recordPendingMessageDelivery(activeSession, _turnPendingEntries);
@@ -932,6 +1047,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
932
1047
  } else {
933
1048
  await durableSave;
934
1049
  }
1050
+ _errorStateDurable = true;
935
1051
  } catch { /* provider-failure history persistence is best-effort */ }
936
1052
  const currentRuntime = _getRuntimeEntry(sessionId);
937
1053
  if (currentRuntime) currentRuntime.session = activeSession;
@@ -951,11 +1067,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
951
1067
  activeSession, _turnPendingEntries, durableSave,
952
1068
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
953
1069
  );
1070
+ _errorStateDurable = true;
954
1071
  } catch {}
955
1072
  } else {
956
1073
  releasePendingMessages(sessionId, _turnPendingEntries);
957
1074
  }
958
1075
  }
1076
+ if (!_errorStateDurable && activeSession) {
1077
+ try {
1078
+ await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1079
+ _errorStateDurable = true;
1080
+ } catch { /* retain checkpoint when canonical persistence fails */ }
1081
+ }
1082
+ if (_errorStateDurable) clearTurnCheckpoint(sessionId, _turnCheckpointToken);
959
1083
  markSessionError(sessionId, err && err.message ? err.message : String(err));
960
1084
  throw err;
961
1085
  }
@@ -1000,6 +1124,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1000
1124
  return _result;
1001
1125
  }
1002
1126
  } finally {
1127
+ // A thrown setup/provider path must never leave a delayed checkpoint
1128
+ // writer alive after the turn lock is released.
1003
1129
  // Clear the controller only if it's still ours (closeSession may have
1004
1130
  // swapped it). Leave the rest of the runtime entry intact so agent type=list
1005
1131
  // can still surface the final stage (done/error/cancelling).
@@ -569,18 +569,30 @@ export function hydratePendingMessages(sessionId, options = {}) {
569
569
 
570
570
  function clearPersistedPendingMessages(sessionId) {
571
571
  if (!isValidPendingSessionId(sessionId)) return;
572
- try {
573
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
574
- const next = normalizePendingStore(raw);
575
- if (!Object.prototype.hasOwnProperty.call(next.sessions, sessionId)) return undefined;
576
- delete next.sessions[sessionId];
577
- if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
578
- next.updatedAt = Date.now();
579
- return next;
580
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
581
- } catch (err) {
582
- try { process.stderr.write(`[session] pending-message clear failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
583
- }
572
+ // ASYNC, chained behind this session's persist tail. The previous SYNC
573
+ // variant self-deadlocked: closeSession ran it on the event loop while an
574
+ // in-flight async spool op (persist/ack/hydrate) in the SAME process held
575
+ // the lock — the sync wait starved the holder's continuations, so every
576
+ // such race burned the full 8s lock timeout (observed as an 8s shard-child
577
+ // stall after each one-shot session close). In-memory queues are already
578
+ // dropped synchronously by _dropPendingMessageState; only the durable
579
+ // spool row lags behind, exactly like the ack path.
580
+ const preceding = _pendingPersistTails.get(sessionId) || Promise.resolve();
581
+ const operation = preceding.catch(() => {}).then(() => updateJsonAtomic(pendingMessagesPath(), (raw) => {
582
+ const next = normalizePendingStore(raw);
583
+ if (!Object.prototype.hasOwnProperty.call(next.sessions, sessionId)) return undefined;
584
+ delete next.sessions[sessionId];
585
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
586
+ next.updatedAt = Date.now();
587
+ return next;
588
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false }))
589
+ .catch((err) => {
590
+ try { process.stderr.write(`[session] pending-message clear failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
591
+ });
592
+ _pendingPersistTails.set(sessionId, operation);
593
+ operation.finally(() => {
594
+ if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
595
+ }).catch(() => {});
584
596
  }
585
597
 
586
598
  function shouldEvictPendingSession(sessionId, ttlMs, entryTouchedAt, now = Date.now()) {
@@ -913,5 +925,12 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
913
925
  }
914
926
 
915
927
  setImmediate(() => {
928
+ // Spool hygiene belongs to the lead/daemon process. Worker-mode children
929
+ // (channels worker, agent shard) share the same spool file; a boot-time
930
+ // SYNC sweep in every child contends on the cross-process lock against
931
+ // the lead's writes (observed in the first live shard run: "atomic lock
932
+ // timeout after 8000ms: session-pending-messages.json.lock") for zero
933
+ // benefit — the lead already sweeps.
934
+ if (process.env.MIXDOG_WORKER_MODE === '1') return;
916
935
  try { sweepOrphanedPendingMessages(); } catch { /* ignore */ }
917
936
  });
@@ -11,6 +11,7 @@ import { SessionClosedError } from './session-errors.mjs';
11
11
  import { _dropPendingMessageState } from './pending-messages.mjs';
12
12
  import { _stopToolActivityHeartbeat, _getRuntimeEntry, _clearSessionRuntime } from './runtime-liveness.mjs';
13
13
  import { _closeBashSessionLazy } from './runtime-loaders.mjs';
14
+ import { clearTurnCheckpoint } from './turn-checkpoint.mjs';
14
15
 
15
16
  /**
16
17
  * Close a session. Plants a `closed=true` tombstone on disk with a bumped
@@ -69,6 +70,9 @@ export function closeSession(id, reason = 'manual', opts = {}) {
69
70
  // write could silently overwrite the session after the user resumes
70
71
  // it back (BL: burned-session late-save clobber).
71
72
  const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
73
+ // prepareCloseSnapshot was folded into the synchronous generation write
74
+ // above. Only now is it safe to remove the crash fallback.
75
+ if (typeof newGen === 'number') clearTurnCheckpoint(id);
72
76
  // 2. Mark runtime as closed so post-await validation in askSession fires.
73
77
  if (entry) {
74
78
  entry.closed = true;