mixdog 0.9.94 → 0.9.95

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 (161) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/lead/01-general.md +1 -2
  11. package/src/rules/lead/lead-brief.md +4 -5
  12. package/src/rules/lead/lead-tool.md +3 -2
  13. package/src/rules/shared/01-tool.md +28 -34
  14. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  17. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  19. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  20. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  24. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  28. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  32. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  33. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  34. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  35. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  36. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  38. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  39. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  40. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  41. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  42. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  43. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  46. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  48. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  49. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  50. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -6
  51. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +22 -0
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +6 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +3 -4
  64. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -3
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  68. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  69. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +53 -12
  72. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  73. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  74. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  78. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +148 -15
  79. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  80. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  81. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  82. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  83. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  84. package/src/runtime/channels/backends/discord.mjs +5 -14
  85. package/src/runtime/channels/backends/telegram.mjs +0 -5
  86. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  87. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  88. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  89. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  90. package/src/runtime/media/renditions.mjs +21 -2
  91. package/src/runtime/memory/tool-defs.mjs +6 -6
  92. package/src/runtime/shared/atomic-file.mjs +53 -0
  93. package/src/runtime/shared/background-tasks.mjs +6 -3
  94. package/src/runtime/shared/resource-admission.mjs +40 -1
  95. package/src/runtime/shared/tool-status.mjs +10 -1
  96. package/src/runtime/shared/tool-surface.mjs +7 -7
  97. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  98. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  99. package/src/session-runtime/context-status.mjs +9 -3
  100. package/src/session-runtime/lifecycle-api.mjs +35 -5
  101. package/src/session-runtime/mcp-glue.mjs +11 -6
  102. package/src/session-runtime/provider-models.mjs +94 -43
  103. package/src/session-runtime/runtime-core.mjs +46 -8
  104. package/src/session-runtime/runtime-tunables.mjs +4 -0
  105. package/src/session-runtime/self-update.mjs +33 -4
  106. package/src/session-runtime/session-lifecycle.mjs +2 -0
  107. package/src/session-runtime/session-text.mjs +2 -1
  108. package/src/session-runtime/session-turn-api.mjs +106 -22
  109. package/src/session-runtime/workflow.mjs +11 -4
  110. package/src/standalone/agent-tool.mjs +4 -4
  111. package/src/standalone/backend-daemon.mjs +570 -0
  112. package/src/standalone/channel-daemon-transport.mjs +141 -1
  113. package/src/standalone/channel-worker.mjs +3 -2
  114. package/src/standalone/engine-daemon-client.mjs +894 -0
  115. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  116. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  117. package/src/standalone/engine-daemon-service.mjs +864 -0
  118. package/src/standalone/engine-daemon-transport.mjs +603 -0
  119. package/src/tui/App.jsx +62 -47
  120. package/src/tui/app/app-format.mjs +4 -2
  121. package/src/tui/app/app-view.jsx +5 -0
  122. package/src/tui/app/channel-pickers.mjs +7 -6
  123. package/src/tui/app/core-memory-picker.mjs +4 -4
  124. package/src/tui/app/extension-pickers.mjs +20 -18
  125. package/src/tui/app/maintenance-pickers.mjs +27 -27
  126. package/src/tui/app/onboarding-steps.mjs +23 -18
  127. package/src/tui/app/prompt-submit.mjs +13 -2
  128. package/src/tui/app/route-pickers.mjs +13 -8
  129. package/src/tui/app/settings-picker.mjs +55 -58
  130. package/src/tui/app/slash-dispatch.mjs +32 -28
  131. package/src/tui/app/transcript-window.mjs +19 -0
  132. package/src/tui/app/usage-context-panels.mjs +22 -7
  133. package/src/tui/app/use-mouse-input.mjs +25 -3
  134. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  135. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  136. package/src/tui/app/use-transcript-window.mjs +15 -0
  137. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  138. package/src/tui/components/PromptInput.jsx +10 -0
  139. package/src/tui/components/Spinner.jsx +89 -95
  140. package/src/tui/components/TextEntryPanel.jsx +14 -0
  141. package/src/tui/components/ToolExecution.jsx +2 -2
  142. package/src/tui/dist/index.mjs +1534 -9801
  143. package/src/tui/engine/agent-job-feed.mjs +2 -2
  144. package/src/tui/engine/live-share.mjs +76 -3
  145. package/src/tui/engine/session-api-ext.mjs +23 -1
  146. package/src/tui/engine/session-api.mjs +81 -41
  147. package/src/tui/engine/session-flow.mjs +43 -4
  148. package/src/tui/engine/tool-card-results.mjs +6 -0
  149. package/src/tui/engine/turn.mjs +8 -1
  150. package/src/tui/engine-local-session.mjs +1108 -0
  151. package/src/tui/engine.mjs +16 -1065
  152. package/src/tui/index.jsx +40 -2
  153. package/src/tui/markdown/stream-fence.mjs +1 -1
  154. package/src/tui/spinner-meta.mjs +80 -0
  155. package/src/tui/spinner-verbs.mjs +35 -0
  156. package/src/ui/statusline-segments.mjs +43 -10
  157. package/src/ui/statusline.mjs +10 -1
  158. package/scripts/tmp-cdp-errors.mjs +0 -41
  159. package/scripts/tmp-cdp-inspect.mjs +0 -41
  160. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  161. package/src/standalone/channel-daemon.mjs +0 -226
@@ -299,7 +299,12 @@ class OutputForwarder {
299
299
  /** Extract new assistant text + tool logs from transcript since readFileSize */
300
300
  extractNewText(uncapped = false) {
301
301
  const { lines: newLines, nextFileSize } = this.readNewLines(uncapped);
302
- let newText = "";
302
+ // One JSONL assistant entry is one semantic output block. Joining entries
303
+ // with a single newline glued a preamble directly onto its following tool
304
+ // card, and glued consecutive Run/Update cards into one dense script wall.
305
+ // Preserve every block's internal Markdown, but put exactly one blank line
306
+ // between visible entry blocks.
307
+ const blocks = [];
303
308
  for (const l of newLines) {
304
309
  try {
305
310
  const entry = JSON.parse(l);
@@ -323,7 +328,7 @@ class OutputForwarder {
323
328
  let diffContent = shown.join("\n");
324
329
  if (diffLines.length > 15) diffContent += "\n... +" + (diffLines.length - 15) + " lines";
325
330
  const block = safeCodeBlock(diffContent, "diff");
326
- newText += block + "\n";
331
+ blocks.push(block);
327
332
  }
328
333
  }
329
334
  continue;
@@ -368,12 +373,15 @@ class OutputForwarder {
368
373
  }
369
374
  }
370
375
  }
371
- if (parts.length) newText += parts.join("\n") + "\n";
376
+ if (parts.length) {
377
+ const block = parts.join("\n").trim();
378
+ if (block) blocks.push(block);
379
+ }
372
380
  }
373
381
  } catch {
374
382
  }
375
383
  }
376
- return { text: newText.trim(), nextFileSize };
384
+ return { text: blocks.join("\n\n"), nextFileSize };
377
385
  }
378
386
  // ── Single-send gate ──────────────────────────────────────────────
379
387
  // All Discord sends pass through sendOnce() so duplicate concurrent sends are avoided.
@@ -410,6 +418,13 @@ class OutputForwarder {
410
418
  this.commitReadProgress(item.nextFileSize);
411
419
  return;
412
420
  }
421
+ // The active transcript/turn owns Discord continuation spacing. A daemon
422
+ // backend is shared by every client and scheduler, so backend-global send
423
+ // counts cannot decide whether THIS session's item is its first response.
424
+ // Freeze the decision on the queue item so retries remain byte-identical.
425
+ if (typeof item._continuation !== "boolean") {
426
+ item._continuation = this.sentCount > 0;
427
+ }
413
428
  // Backend owns chunking + send retry: pass the whole (unchunked) text once.
414
429
  // On failure the backend has already exhausted its per-chunk retries and
415
430
  // throws; we re-throw so drainQueue/scheduleRetry requeues the whole item.
@@ -418,7 +433,10 @@ class OutputForwarder {
418
433
  // failure so the backend resumes at the failed chunk instead of
419
434
  // re-sending chunks that already landed. The token is opaque here —
420
435
  // the forwarder only stores/passes/clears it, never interprets it.
421
- await this.cb.send(targetChannelId, formatted, item._resumeToken ? { resumeToken: item._resumeToken } : undefined);
436
+ await this.cb.send(targetChannelId, formatted, {
437
+ continuation: item._continuation,
438
+ ...(item._resumeToken ? { resumeToken: item._resumeToken } : {}),
439
+ });
422
440
  dropTrace("discord.send.ok", null);
423
441
  // A zombie completion (send abandoned past the settlement cap, gen bumped)
424
442
  // must not mutate ANY item/queue state — including the resume token.
@@ -426,6 +444,7 @@ class OutputForwarder {
426
444
  // Full success — drop any stale token so a later reuse of this item
427
445
  // object can't carry a dead token.
428
446
  item._resumeToken = undefined;
447
+ item._continuation = undefined;
429
448
  } catch (err) {
430
449
  // Persist the opaque resume token (if any) on the item so the requeued
431
450
  // retry resumes from the failed chunk.
@@ -115,7 +115,7 @@ class Scheduler {
115
115
  setSendHandler(fn) {
116
116
  this.sendFn = fn;
117
117
  }
118
- /** Probe for a live Lead bridge seat. When set, the CC-parity interactive
118
+ /** Probe for a live Lead bridge seat. When set, the interactive
119
119
  * inject only fires while it reports true; otherwise the visible-session
120
120
  * fallback keeps the fire from being lost. Unset → inject when wired. */
121
121
  setInjectReadyCheck(fn) {
@@ -435,7 +435,7 @@ function forwardLifecycleToDiscord(channelId, content) {
435
435
  scheduler.setInjectHandler((channelId, name, content, options) => {
436
436
  injectAndRecord(channelId, name, content, options);
437
437
  });
438
- // CC-parity interactive schedule fires may only inject while this process
438
+ // Interactive schedule fires may only inject while this process
439
439
  // actually holds the live bridge seat; otherwise the scheduler falls back to
440
440
  // the visible-session run so the fire is never lost.
441
441
  scheduler.setInjectReadyCheck(() => bridgeRuntimeConnected && currentOwnerState().owned);
@@ -219,6 +219,24 @@ async function grabVideoFrame(sourcePath, spec) {
219
219
  // Two tiles asking for the same missing rendition must not both encode it.
220
220
  const inflight = new Map();
221
221
  const failedUntil = new Map();
222
+ const FAILED_RENDITION_CACHE_MAX = 256;
223
+
224
+ function pruneFailedRenditions(now = Date.now()) {
225
+ for (const [key, expiresAt] of failedUntil) {
226
+ if (expiresAt <= now) failedUntil.delete(key);
227
+ }
228
+ while (failedUntil.size > FAILED_RENDITION_CACHE_MAX) {
229
+ const oldest = failedUntil.keys().next().value;
230
+ if (oldest === undefined) break;
231
+ failedUntil.delete(oldest);
232
+ }
233
+ }
234
+
235
+ function rememberFailedRendition(key) {
236
+ failedUntil.delete(key);
237
+ failedUntil.set(key, Date.now() + FAILED_RENDITION_COOLDOWN_MS);
238
+ pruneFailedRenditions();
239
+ }
222
240
 
223
241
  async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
224
242
  if (kind === 'video') {
@@ -254,6 +272,7 @@ export async function ensureRendition({
254
272
  if (cached) return cached;
255
273
  if (!generate) return null;
256
274
  const key = `${variant}:${id}`;
275
+ pruneFailedRenditions();
257
276
  if ((failedUntil.get(key) || 0) > Date.now()) return null;
258
277
  const pending = inflight.get(key)
259
278
  ?? scheduleRendition(
@@ -262,11 +281,11 @@ export async function ensureRendition({
262
281
  )
263
282
  .then((result) => {
264
283
  if (result) failedUntil.delete(key);
265
- else failedUntil.set(key, Date.now() + FAILED_RENDITION_COOLDOWN_MS);
284
+ else rememberFailedRendition(key);
266
285
  return result;
267
286
  })
268
287
  .catch(() => {
269
- failedUntil.set(key, Date.now() + FAILED_RENDITION_COOLDOWN_MS);
288
+ rememberFailedRendition(key);
270
289
  return null;
271
290
  })
272
291
  .finally(() => { inflight.delete(key); });
@@ -34,12 +34,10 @@ export const TOOL_DEFS = [
34
34
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
35
35
  limit: { type: 'number', description: 'Max rows/items.' },
36
36
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
37
- project_id: { type: 'string', description: 'Optional core pool override: common or slug. Omitted values are inferred from cwd/session, then fall back to common.' },
37
+ project_id: { type: 'string', description: 'Core pool: common or slug; inferred from cwd/session when omitted.' },
38
38
  },
39
- anyOf: [
40
- { properties: { action: { const: 'status' } }, required: ['action'] },
41
- { properties: { action: { const: 'core' } }, required: ['action', 'op'] },
42
- ],
39
+ // `op` is required for action=core — stated in its own description and
40
+ // enforced by the handler; the anyOf branches only restated that.
43
41
  additionalProperties: false,
44
42
  required: ['action'],
45
43
  },
@@ -58,7 +56,9 @@ export const TOOL_DEFS = [
58
56
  limit: { type: 'number', description: 'Max entries.' },
59
57
  offset: { type: 'number', description: 'Skip entries.' },
60
58
  sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
61
- category: { anyOf: [{ type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, { type: 'array', items: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, minItems: 1 }], description: 'Category filter.' },
59
+ // Categories are listed once here instead of twice as enums; the
60
+ // handler validates against VALID_CATEGORY.
61
+ category: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Category filter, string or array: rule|constraint|decision|fact|goal|preference|task|issue.' },
62
62
  includeArchived: { type: 'boolean', description: 'Include archived entries.' },
63
63
  includeMembers: { type: 'boolean', description: 'Include chunk members.' },
64
64
  includeRaw: { type: 'boolean', description: 'Include raw/episode rows.' },
@@ -24,6 +24,7 @@ import { dirname, basename, join } from 'path';
24
24
  import { randomBytes } from 'crypto';
25
25
  import { execFile, execFileSync } from 'child_process';
26
26
  import { promisify } from 'util';
27
+ import { AsyncLocalStorage } from 'node:async_hooks';
27
28
 
28
29
  const _execFileAsync = promisify(execFile);
29
30
 
@@ -44,6 +45,14 @@ const DEFAULT_LOCK_TIMEOUT_MS = Number.isFinite(_configuredLockTimeoutMs)
44
45
  // current lock apart from a same-pid prior/other instance's leftover.
45
46
  const _OWNER_TOKEN = randomBytes(12).toString('hex');
46
47
 
48
+ // Lock paths whose OS lock is currently held by an ASYNC holder in THIS
49
+ // process. That holder releases from the event loop, so a sync waiter which
50
+ // parks the loop in Atomics.wait can never observe the release: the wait is a
51
+ // self-deadlock that always burns the full timeout (observed: the shared
52
+ // session-pending-messages spool freezing the daemon — PTYs, engines, IPC —
53
+ // for 2s per poll). Sync callers fail fast against it instead of waiting.
54
+ const _osHeldPaths = new Set();
55
+
47
56
  function sleepSync(ms) {
48
57
  try {
49
58
  const buf = new SharedArrayBuffer(4);
@@ -111,6 +120,13 @@ export async function renameWithRetry(src, dst, opts = {}) {
111
120
  export function withFileLockSync(lockPath, fn, opts = {}) {
112
121
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
113
122
  const staleMs = Number.isFinite(opts.staleMs) ? opts.staleMs : 30000;
123
+ // A WAITING sync acquire can never win against this process's async holder.
124
+ // try-once callers keep the OS-level path so their contract is unchanged.
125
+ if (timeoutMs > 0 && _osHeldPaths.has(lockPath)) {
126
+ const selfErr = new Error(`atomic lock contended (async holder in this process): ${lockPath} [${_describeLockHolder(lockPath)}]`);
127
+ selfErr.code = 'ELOCKCONTENDED';
128
+ throw selfErr;
129
+ }
114
130
  const deadline = Date.now() + timeoutMs;
115
131
  mkdirSync(dirname(lockPath), { recursive: true });
116
132
  let attempt = 0;
@@ -306,7 +322,42 @@ function _asyncSleep(ms) {
306
322
  return new Promise((resolve) => { setTimeout(resolve, Math.max(1, Number(ms) || 1)); });
307
323
  }
308
324
 
325
+ // ── In-process serialization ────────────────────────────────────────
326
+ // The file lock arbitrates between PROCESSES. Inside one process (the engine
327
+ // daemon now hosts every session, the TUI and the desktop) several callers
328
+ // reach the same path concurrently, and an OS-level lock cannot express
329
+ // "already mine": contenders spun until ELOCKTIMEOUT, and a nested call inside
330
+ // a holder waited for itself forever (observed: session-pending-messages and
331
+ // mixdog-config held by our own pid for minutes while every submit hung).
332
+ // One async queue per lock path removes that class of failure entirely — no
333
+ // retry loop involved — and a re-entrant call runs straight through.
334
+ const _lockQueues = new Map();
335
+ const _heldLockPaths = new AsyncLocalStorage();
336
+
309
337
  export async function withFileLock(lockPath, fn, opts = {}) {
338
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
339
+ const held = _heldLockPaths.getStore();
340
+ // Re-entrant: this async context already owns the path. Waiting here would
341
+ // be waiting for ourselves.
342
+ if (held?.has(lockPath)) return fn();
343
+ // try-once callers must keep failing fast on contention, so they skip the
344
+ // queue and contend for the OS lock directly.
345
+ if (timeoutMs <= 0) return _withOsFileLock(lockPath, fn, opts);
346
+ const nextHeld = new Set(held || []);
347
+ nextHeld.add(lockPath);
348
+ const previous = _lockQueues.get(lockPath) ?? Promise.resolve();
349
+ const task = previous
350
+ .catch(() => {})
351
+ .then(() => _heldLockPaths.run(nextHeld, () => _withOsFileLock(lockPath, fn, opts)));
352
+ const settled = task.then(() => {}, () => {});
353
+ _lockQueues.set(lockPath, settled);
354
+ void settled.then(() => {
355
+ if (_lockQueues.get(lockPath) === settled) _lockQueues.delete(lockPath);
356
+ });
357
+ return task;
358
+ }
359
+
360
+ async function _withOsFileLock(lockPath, fn, opts = {}) {
310
361
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
311
362
  const staleMs = Number.isFinite(opts.staleMs) ? opts.staleMs : 30000;
312
363
  const deadline = Date.now() + timeoutMs;
@@ -342,6 +393,7 @@ export async function withFileLock(lockPath, fn, opts = {}) {
342
393
  continue;
343
394
  }
344
395
  try { writeFileSync(fd, `${process.pid} ${Date.now()} ${_OWNER_TOKEN}\n`, 'utf8'); } catch {}
396
+ _osHeldPaths.add(lockPath);
345
397
  try {
346
398
  // Async ACL enforcement (promisified execFile) so a secret-bearing
347
399
  // async holder never blocks the event loop on icacls; identical
@@ -349,6 +401,7 @@ export async function withFileLock(lockPath, fn, opts = {}) {
349
401
  if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(lockPath);
350
402
  return await fn();
351
403
  } finally {
404
+ _osHeldPaths.delete(lockPath);
352
405
  try { closeSync(fd); } catch {}
353
406
  try {
354
407
  if (_lockOwnedBySelf(lockPath)) unlinkSync(lockPath);
@@ -128,14 +128,14 @@ function hasScopeCriteria(scope) {
128
128
  return Boolean(scope?.callerSessionId || scope?.routingSessionId || scope?.clientHostPid);
129
129
  }
130
130
 
131
- function taskMatchesScope(task, options = {}) {
131
+ function taskMatchesScope(task, options = {}, { includeUnattributed = true } = {}) {
132
132
  if (!task) return false;
133
133
  const scope = normalizeTaskScope(options);
134
134
  if (!hasScopeCriteria(scope)) return true;
135
135
  const taskSessionId = task.ownerSessionId || task.notifyContext?.callerSessionId || task.notifyContext?.routingSessionId || null;
136
136
  const taskClientHostPid = task.clientHostPid || task.notifyContext?.clientHostPid || null;
137
137
  // Legacy/unattributed tasks remain visible so old async jobs are still recoverable.
138
- if (!taskSessionId && !taskClientHostPid) return true;
138
+ if (!taskSessionId && !taskClientHostPid) return includeUnattributed;
139
139
  if (scope.callerSessionId && taskSessionId === scope.callerSessionId) return true;
140
140
  if (scope.routingSessionId && taskSessionId === scope.routingSessionId) return true;
141
141
  if (scope.clientHostPid && taskClientHostPid === scope.clientHostPid) return true;
@@ -278,7 +278,10 @@ export function cancelBackgroundTasks(options = {}) {
278
278
  let cancelled = 0;
279
279
  for (const task of [...tasks.values()]) {
280
280
  if (wanted && task.surface !== wanted) continue;
281
- if (!taskMatchesScope(task, options)) continue;
281
+ // Scoped teardown must be exact. Legacy tasks without ownership stay
282
+ // visible/recoverable, but may not be claimed by whichever session happens
283
+ // to close first.
284
+ if (!taskMatchesScope(task, options, { includeUnattributed: false })) continue;
282
285
  if (TERMINAL_STATUSES.has(task.status)) continue;
283
286
  try { task.cancel?.(); } catch {}
284
287
  completeBackgroundTask(task.taskId, { status: 'cancelled', error: reason, notify: options.notify === true });
@@ -54,6 +54,19 @@ function abortError(signal) {
54
54
  : new Error(String(signal?.reason || 'resource admission canceled'));
55
55
  }
56
56
 
57
+ function sleep(ms, signal) {
58
+ return new Promise((resolve) => {
59
+ const finish = () => {
60
+ clearTimeout(timer);
61
+ signal?.removeEventListener?.('abort', finish);
62
+ resolve();
63
+ };
64
+ const timer = setTimeout(finish, ms);
65
+ timer.unref?.();
66
+ signal?.addEventListener?.('abort', finish, { once: true });
67
+ });
68
+ }
69
+
57
70
  class ResourcePressureError extends Error {
58
71
  constructor(message, details = {}) {
59
72
  super(`resource pressure: ${message}`);
@@ -93,6 +106,14 @@ export class ResourceAdmissionController {
93
106
  // Live leases for saturation diagnostics only (labels/ages in snapshot()).
94
107
  this.activeLeases = new Set();
95
108
  this.context = new AsyncLocalStorage();
109
+ // Host free memory is an INSTANTANEOUS sample: a GC pause or a just-exited
110
+ // child can dip it below the floor for a few hundred ms. Re-measure a
111
+ // bounded number of times before refusing, so a transient dip costs
112
+ // latency instead of a failed tool call.
113
+ this.memoryRetry = {
114
+ attempts: nonNegativeInt(env.MIXDOG_MEMORY_PRESSURE_RETRIES, 3),
115
+ delayMs: nonNegativeInt(env.MIXDOG_MEMORY_PRESSURE_RETRY_MS, 400),
116
+ };
96
117
  }
97
118
 
98
119
  _memoryError(kind) {
@@ -231,11 +252,29 @@ export class ResourceAdmissionController {
231
252
  return this.context.run(lease, task);
232
253
  }
233
254
 
255
+ async _acquireAfterMemoryDip(kind, options, pressure) {
256
+ const lane = kind === 'shell' ? 'shell' : 'agent';
257
+ const { attempts, delayMs } = this.memoryRetry;
258
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
259
+ await sleep(delayMs, options.signal);
260
+ if (options.signal?.aborted) throw abortError(options.signal);
261
+ if (!this._memoryError(lane)) return this.acquire(kind, options);
262
+ }
263
+ throw this._memoryError(lane) || pressure;
264
+ }
265
+
234
266
  acquire(kind, { signal = null, label = null, dependency = 'scoped' } = {}) {
235
267
  const lane = kind === 'shell' ? 'shell' : 'agent';
236
268
  if (signal?.aborted) return Promise.reject(abortError(signal));
237
269
  const pressure = this._memoryError(lane);
238
- if (pressure) return Promise.reject(pressure);
270
+ if (pressure) {
271
+ // Only host free memory self-heals on this timescale; our own RSS does
272
+ // not, so RSS pressure still rejects immediately.
273
+ if (pressure.metric !== 'free-memory' || this.memoryRetry.attempts < 1) {
274
+ return Promise.reject(pressure);
275
+ }
276
+ return this._acquireAfterMemoryDip(kind, { signal, label, dependency }, pressure);
277
+ }
239
278
  const ambientParent = this.context.getStore();
240
279
  const detachedDependency = dependency === 'detached' && ambientParent?.controller === this;
241
280
  const parent = detachedDependency ? null : this._suspendParent(ambientParent);
@@ -23,5 +23,14 @@ export function toolResultTerminalStatus(text) {
23
23
  const bracketed = body.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
24
24
  if (bracketed) return normalizeToolTerminalStatus(bracketed);
25
25
  const inline = body.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
26
- return normalizeToolTerminalStatus(inline);
26
+ const fromInline = normalizeToolTerminalStatus(inline);
27
+ if (fromInline) return fromInline;
28
+ // Bare control bodies written on cancel/crash (current + legacy).
29
+ const trimmed = body.trim();
30
+ if (/^(?:cancelled|canceled)$/i.test(trimmed)) return 'cancelled';
31
+ if (/^(?:interrupted by (?:user|process restart)|tool execution aborted|\[tool execution was interrupted\])$/i.test(trimmed)) {
32
+ return 'cancelled';
33
+ }
34
+ if (/^the user doesn't want to proceed with this tool use\b/i.test(trimmed)) return 'cancelled';
35
+ return '';
27
36
  }
@@ -467,15 +467,15 @@ export function toolWorkUnit(name, args = {}, category = '') {
467
467
  noun: 'file',
468
468
  });
469
469
  }
470
- // post_shell runs a verification command inside the SAME tool call after
471
- // the patch applies; without the suffix the spinner sits on "Editing 1
472
- // file" for the whole (possibly long) verify run and reads like a stuck
473
- // edit. Surface both phases in the active label.
474
- const verifying = typeof a.post_shell === 'string' && a.post_shell.trim() !== '';
475
470
  return unitDescriptor('Patch', {
476
471
  count: patchFileCount(a) || 1,
477
- active: (creating ? 'Creating' : deleting ? 'Deleting' : 'Editing') + (verifying ? ' & verifying' : ''),
478
- done: (creating ? 'Created' : deleting ? 'Deleted' : 'Edited') + (verifying ? ' & verified' : ''),
472
+ // post_shell is NOT named here: the turn engine already splits such a
473
+ // call into this edit card plus a trailing Shell card that carries the
474
+ // real command, its output and its failure banner (turn.mjs
475
+ // postShellTwins). Repeating "& verifying" in the header declared the
476
+ // verify phase twice and truncated the label on narrow panes.
477
+ active: creating ? 'Creating' : deleting ? 'Deleting' : 'Editing',
478
+ done: creating ? 'Created' : deleting ? 'Deleted' : 'Edited',
479
479
  noun: 'file',
480
480
  });
481
481
  }