mixdog 0.9.55 → 0.9.59

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -13,6 +13,17 @@ import {
13
13
  import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
14
14
  import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
15
15
  import { createRunTurn } from '../src/tui/engine/turn.mjs';
16
+ import { createContextState } from '../src/tui/engine/context-state.mjs';
17
+ import {
18
+ restoreTranscriptItems,
19
+ restoredAssistantTranscriptItems,
20
+ restoredTranscriptMetadata,
21
+ } from '../src/tui/engine/session-api-ext.mjs';
22
+ import {
23
+ attachAssistantTranscriptCompletion,
24
+ persistedAssistantTranscriptMetadata,
25
+ } from '../src/runtime/agent/orchestrator/session/manager/ask-session.mjs';
26
+ import { attachAssistantTranscriptMetadata } from '../src/runtime/agent/orchestrator/session/agent-loop.mjs';
16
27
  import {
17
28
  buildTranscriptRowIndexIncremental,
18
29
  transcriptItemsWithStableTail,
@@ -191,12 +202,16 @@ test('failed mid-turn compact leaves prior transcript items untouched', async ()
191
202
 
192
203
  test('stream flush keeps settled items identity and finalize appends one assistant', async () => {
193
204
  let identityStable = false;
205
+ let persistedAssistantMessage = null;
194
206
  const harness = makeTurnHarness(async (_text, options) => {
195
207
  const before = harness.getState().items;
196
208
  options.onTextDelta('settled line\n');
197
209
  await wait(30);
198
210
  identityStable = harness.getState().items === before;
199
211
  assert.match(harness.getState().streamingTail?.text || '', /settled line/);
212
+ persistedAssistantMessage = {
213
+ meta: { transcript: persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now()) },
214
+ };
200
215
  return { result: { content: 'settled line\n' }, session: { messages: [] } };
201
216
  });
202
217
 
@@ -207,6 +222,157 @@ test('stream flush keeps settled items identity and finalize appends one assista
207
222
  assert.equal(assistants.length, 1);
208
223
  assert.equal(assistants[0].streaming, false);
209
224
  assert.equal(assistants[0].text, 'settled line\n');
225
+ assert.equal(
226
+ restoredTranscriptMetadata(persistedAssistantMessage).at,
227
+ assistants[0].at,
228
+ 'restored assistant timestamp must match its live creation timestamp',
229
+ );
230
+ });
231
+
232
+ test('no-delta final assistant persists the timestamp later used by the live item', async () => {
233
+ let persistedAssistantMessage = null;
234
+ const harness = makeTurnHarness(async (_text, options) => {
235
+ persistedAssistantMessage = {
236
+ meta: { transcript: persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now()) },
237
+ };
238
+ return { result: { content: 'final without deltas' }, session: { messages: [] } };
239
+ });
240
+
241
+ assert.equal(await harness.runTurn('go'), 'done');
242
+ const assistant = harness.getState().items.find((item) => item.kind === 'assistant');
243
+ assert.equal(assistant?.text, 'final without deltas');
244
+ assert.equal(restoredTranscriptMetadata(persistedAssistantMessage).at, assistant?.at);
245
+ });
246
+
247
+ test('assistant completion metadata survives session resume projection', () => {
248
+ const turnStartedAt = Date.parse('2026-07-18T01:00:00Z');
249
+ const messages = [
250
+ { role: 'user', content: 'Question', meta: { transcript: { at: turnStartedAt } } },
251
+ {
252
+ role: 'assistant',
253
+ content: 'Answer',
254
+ meta: { transcript: { at: turnStartedAt + 1_000, model: 'model-a' } },
255
+ },
256
+ ];
257
+ assert.equal(attachAssistantTranscriptCompletion(messages, {
258
+ status: 'done',
259
+ verb: 'Mapped',
260
+ elapsedMs: 2_000,
261
+ }, turnStartedAt), true);
262
+ let sequence = 0;
263
+ const restored = restoredAssistantTranscriptItems(messages[1], () => `restored-${++sequence}`);
264
+ assert.equal(restored.length, 2);
265
+ assert.deepEqual(restored[0], {
266
+ kind: 'assistant',
267
+ id: 'restored-1',
268
+ text: 'Answer',
269
+ at: turnStartedAt + 1_000,
270
+ model: 'model-a',
271
+ });
272
+ assert.deepEqual(restored[1], {
273
+ kind: 'turndone',
274
+ id: 'restored-2',
275
+ status: 'done',
276
+ verb: 'Mapped',
277
+ elapsedMs: 2_000,
278
+ at: turnStartedAt + 1_000,
279
+ });
280
+ });
281
+
282
+ test('cold transcript restore incrementally projects only the visible tail', () => {
283
+ let oldBodyReads = 0;
284
+ const oldMessages = Array.from({ length: 4_000 }, (_, index) => {
285
+ const message = { role: index % 2 === 0 ? 'user' : 'assistant' };
286
+ Object.defineProperty(message, 'content', {
287
+ enumerable: true,
288
+ get() {
289
+ oldBodyReads += 1;
290
+ return `old body ${index}`;
291
+ },
292
+ });
293
+ return message;
294
+ });
295
+ const recentMessages = Array.from({ length: 400 }, (_, index) => ({
296
+ role: index % 2 === 0 ? 'user' : 'assistant',
297
+ content: `recent body ${index}`,
298
+ ...(index % 2 === 1
299
+ ? { meta: { transcript: { completion: { status: 'done', elapsedMs: index } } } }
300
+ : {}),
301
+ }));
302
+ const messages = [...oldMessages, ...recentMessages];
303
+ const restored = restoreTranscriptItems(messages, {
304
+ sessionId: 'fast',
305
+ itemLimit: 64,
306
+ });
307
+ const recentFull = restoreTranscriptItems(recentMessages, { sessionId: 'recent' });
308
+
309
+ assert.equal(oldBodyReads, 0, 'tail restore must not read old message bodies');
310
+ assert.equal(restored.length, 64);
311
+ assert.deepEqual(
312
+ restored.map((item) => [item.kind, item.text || '', item.status || '']),
313
+ recentFull.slice(-64).map((item) => [item.kind, item.text || '', item.status || '']),
314
+ );
315
+ assert.match(restored[0].id, /^hist_fast_4\d{3}_\d+$/);
316
+ });
317
+
318
+ test('restored transcripts publish estimated context before a new provider turn', () => {
319
+ let state = {
320
+ stats: { inputTokens: 0, latestInputTokens: 0, latestPromptTokens: 0 },
321
+ busy: false,
322
+ spinner: null,
323
+ thinking: null,
324
+ };
325
+ const context = createContextState({
326
+ runtime: {
327
+ contextStatus: () => ({
328
+ contextWindow: 20_000,
329
+ currentEstimatedTokens: 1_250,
330
+ usedTokens: 1_250,
331
+ usedSource: 'estimated',
332
+ messages: { count: 2 },
333
+ }),
334
+ },
335
+ getState: () => state,
336
+ updateState: (patch) => { state = { ...state, ...patch }; },
337
+ getPendingSessionReset: () => false,
338
+ });
339
+ context.syncContextStats({ allowEstimated: true });
340
+ assert.equal(state.stats.currentContextTokens, 0);
341
+ assert.equal(state.stats.currentEstimatedContextTokens, 1_250);
342
+ assert.equal(state.stats.currentContextSource, 'estimated');
343
+ });
344
+
345
+ test('multi-tool preamble and final assistant preserve distinct live timestamps on restore', async () => {
346
+ const persistedAssistantMessages = [];
347
+ const takeAssistantTranscriptMetadata = (options) => {
348
+ const transcript = persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now());
349
+ delete options.transcriptMeta.assistantAt;
350
+ return transcript;
351
+ };
352
+ const harness = makeTurnHarness(async (_text, options) => {
353
+ options.onAssistantText('tool preamble');
354
+ await options.onToolCall(1, [{ id: 'call_1', name: 'grep', input: { pattern: 'x' } }]);
355
+ persistedAssistantMessages.push(attachAssistantTranscriptMetadata(
356
+ { role: 'assistant', content: 'tool preamble', toolCalls: [] },
357
+ { takeAssistantTranscriptMetadata: () => takeAssistantTranscriptMetadata(options) },
358
+ ));
359
+ options.onToolResult({ tool_call_id: 'call_1', content: 'ok' });
360
+ persistedAssistantMessages.push({
361
+ role: 'assistant',
362
+ content: 'final answer',
363
+ meta: { transcript: persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now()) },
364
+ });
365
+ return { result: { content: 'final answer' }, session: { messages: [] } };
366
+ });
367
+
368
+ assert.equal(await harness.runTurn('go'), 'done');
369
+ const liveAssistants = harness.getState().items.filter((item) => item.kind === 'assistant');
370
+ assert.equal(liveAssistants.length, 2);
371
+ assert.equal(new Set(liveAssistants.map((item) => item.at)).size, 2);
372
+ assert.deepEqual(
373
+ persistedAssistantMessages.map((message) => restoredTranscriptMetadata(message).at),
374
+ liveAssistants.map((item) => item.at),
375
+ );
210
376
  });
211
377
 
212
378
  test('abort after stream progress settles the tail and leaves no orphan', async () => {
@@ -516,6 +682,7 @@ test('transcript spill caps the live window and restores every item in order', (
516
682
  assert.deepEqual(live, original.slice(-live.length));
517
683
  assert.ok(live.length <= TRANSCRIPT_LIVE_ITEM_CAP);
518
684
  spill.reset();
685
+ spill.dispose();
519
686
  });
520
687
 
521
688
  test('spill restore succeeds before its asynchronous worker write completes', () => {
package/src/app.mjs CHANGED
@@ -113,6 +113,29 @@ export async function run(argv = []) {
113
113
  );
114
114
  return 1;
115
115
  }
116
+ // Stale-bundle guard: a dist built before hot runtime sources caused weeks
117
+ // of ghost failures (compaction placeholder patches) that no longer existed
118
+ // in src. Sample a few hot files — a newer source than the bundle means the
119
+ // running behavior will NOT match the tree, so warn loudly.
120
+ try {
121
+ const { statSync } = await import('node:fs');
122
+ const bundleMtime = statSync(bundle).mtimeMs;
123
+ const hotSources = [
124
+ join(__dirname, 'runtime', 'agent', 'orchestrator', 'session', 'agent-loop.mjs'),
125
+ join(__dirname, 'runtime', 'agent', 'orchestrator', 'session', 'loop', 'stored-tool-args.mjs'),
126
+ join(__dirname, 'tui', 'engine', 'session-api-ext.mjs'),
127
+ join(__dirname, 'standalone', 'agent-tool.mjs'),
128
+ ];
129
+ const stale = hotSources.some((file) => {
130
+ try { return statSync(file).mtimeMs > bundleMtime + 1_000; } catch { return false; }
131
+ });
132
+ if (stale) {
133
+ process.stderr.write(
134
+ 'mixdog: TUI bundle is OLDER than runtime sources — behavior will not match the tree.\n'
135
+ + ' Rebuild with: npm run build:tui\n',
136
+ );
137
+ }
138
+ } catch { /* advisory only */ }
116
139
  const { runTui } = await import('./tui/dist/index.mjs');
117
140
  bootProfile('tui:imported');
118
141
  return await runTui(opts);
package/src/cli.mjs CHANGED
@@ -10,6 +10,12 @@ import { stagedChildExitCode } from './runtime/shared/staged-child-result.mjs';
10
10
 
11
11
  const argv = process.argv.slice(2);
12
12
 
13
+ // React/ink resolve their build flavor from NODE_ENV at require time. Unset
14
+ // NODE_ENV loads react-reconciler's DEVELOPMENT build, whose per-commit debug
15
+ // bookkeeping consumed ~16% of TUI frame CPU under transcript load (cpuprofile
16
+ // 2026-07-20). Default to production; an explicit NODE_ENV still wins.
17
+ process.env.NODE_ENV ||= 'production';
18
+
13
19
  let swapped = false;
14
20
  const invocation = classifyCliInvocation(argv);
15
21
  const skipHostPrelude = invocation.kind === 'headless' || invocation.skipHostPrelude === true;
@@ -128,7 +128,14 @@ function powershell(script) {
128
128
  // secret encryption/decryption. (pwsh/PS7 is intentionally not used: it does
129
129
  // not auto-load the ProtectedData assembly, so DPAPI silently fails there.)
130
130
  const exe = resolvePowershellExe();
131
+ const profiled = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
132
+ const startedAt = profiled ? Date.now() : 0;
131
133
  const r = run(exe, ['-NonInteractive', '-NoProfile', '-Command', script], { timeout: POWERSHELL_TIMEOUT_MS });
134
+ if (profiled) {
135
+ const caller = (new Error().stack || '').split('\n').slice(2, 5)
136
+ .map((line) => line.trim().replace(/\s+/g, '_')).join('<-');
137
+ try { process.stderr.write(`[mixdog-boot] keychain:sync-powershell ms=${Date.now() - startedAt} by=${caller}\n`); } catch {}
138
+ }
132
139
  if (r.error && r.error.code === 'ETIMEDOUT') {
133
140
  throw new Error(`[keychain] PowerShell DPAPI command timed out after ${POWERSHELL_TIMEOUT_MS}ms (host: ${exe})`);
134
141
  }
@@ -138,7 +145,7 @@ function powershell(script) {
138
145
  return r;
139
146
  }
140
147
 
141
- function powershellAsync(script) {
148
+ function powershellAsync(script, input = null) {
142
149
  return new Promise((resolve) => {
143
150
  let child;
144
151
  try {
@@ -149,7 +156,7 @@ function powershellAsync(script) {
149
156
  env: powershellEnv(),
150
157
  shell: false,
151
158
  windowsHide: true,
152
- stdio: ['ignore', 'pipe', 'pipe'],
159
+ stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
153
160
  timeout: POWERSHELL_TIMEOUT_MS,
154
161
  },
155
162
  );
@@ -172,6 +179,10 @@ function powershellAsync(script) {
172
179
  };
173
180
  child.once('error', (error) => finish(null, error));
174
181
  child.once('close', (status) => finish(status));
182
+ if (input != null) {
183
+ child.stdin.on('error', () => { /* child exited early — close() reports it */ });
184
+ child.stdin.end(input);
185
+ }
175
186
  });
176
187
  }
177
188
 
@@ -348,30 +359,69 @@ function win32Get(account) {
348
359
  return out;
349
360
  }
350
361
 
362
+ // Batch DPAPI decrypt used by prewarmSecrets: ONE PowerShell host decrypts
363
+ // every ciphertext. Windows CreateProcess for powershell.exe is a SYNCHRONOUS
364
+ // multi-hundred-ms operation on the calling thread (AV scanning), so the old
365
+ // per-secret spawn fan-out froze the caller's event loop for N x spawn cost —
366
+ // observed as a ~5s desktop boot stall with near-zero CPU. Accounts and
367
+ // ciphertexts travel via stdin (never interpolated into the script); decrypted
368
+ // values return base64-wrapped so arbitrary secret bytes survive the pipe.
369
+ const PS_UNPROTECT_BATCH = [
370
+ 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;',
371
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
372
+ '$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser;',
373
+ 'while (($line = [Console]::In.ReadLine()) -ne $null) {',
374
+ ' $parts = $line.Split("|", 2);',
375
+ ' if ($parts.Count -lt 2) { continue }',
376
+ ' try {',
377
+ ' $enc = [Convert]::FromBase64String($parts[1]);',
378
+ ' $dec = [System.Security.Cryptography.ProtectedData]::Unprotect($enc, $null, $scope);',
379
+ ' [Console]::Out.WriteLine($parts[0] + "|" + [Convert]::ToBase64String($dec));',
380
+ ' } catch {}',
381
+ '}',
382
+ ].join(' ');
383
+
351
384
  async function prewarmSecrets() {
352
385
  try {
353
386
  if (platform() !== 'win32' || KEYCHAIN_CACHE_TTL_MS === 0) return;
354
387
  const dir = secretsDir();
355
388
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
356
389
  const epoch = _cacheEpoch;
357
- await Promise.all(entries
358
- .filter((entry) => entry.isFile() && entry.name.endsWith('.dpapi'))
359
- .map(async (entry) => {
360
- const account = entry.name.slice(0, -'.dpapi'.length);
361
- const generation = _cacheGenerations.get(account) || 0;
362
- try {
363
- const b64 = (await fs.promises.readFile(path.join(dir, entry.name), 'utf8')).trim();
364
- if (!b64) return;
365
- const r = await powershellAsync(`& { ${PS_UNPROTECT} } '${b64}'`);
366
- const value = (r.stdout || '').trim();
367
- if (r.error || r.status !== 0 || !value) return;
368
- if (_cacheEpoch !== epoch || (_cacheGenerations.get(account) || 0) !== generation) return;
369
- _cacheSet(account, value);
370
- } catch {
371
- // Individual corrupt, deleted, or undecryptable entries must
372
- // not prevent the remaining secrets from warming.
373
- }
374
- }));
390
+ const rows = [];
391
+ for (const entry of entries) {
392
+ if (!entry.isFile() || !entry.name.endsWith('.dpapi')) continue;
393
+ const account = entry.name.slice(0, -'.dpapi'.length);
394
+ try {
395
+ const b64 = (await fs.promises.readFile(path.join(dir, entry.name), 'utf8')).trim();
396
+ // Account names are filenames (no '|' on Windows) and ciphertexts
397
+ // are base64 — the line protocol below stays unambiguous.
398
+ if (b64) rows.push({ account, generation: _cacheGenerations.get(account) || 0, b64 });
399
+ } catch {
400
+ // Individual corrupt or deleted entries must not prevent the
401
+ // remaining secrets from warming.
402
+ }
403
+ }
404
+ if (rows.length === 0) return;
405
+ const r = await powershellAsync(
406
+ PS_UNPROTECT_BATCH,
407
+ rows.map((row) => `${row.account}|${row.b64}`).join('\n') + '\n',
408
+ );
409
+ if (r.error || r.status !== 0) return;
410
+ const generations = new Map(rows.map((row) => [row.account, row.generation]));
411
+ for (const line of String(r.stdout || '').split(/\r?\n/)) {
412
+ const separator = line.indexOf('|');
413
+ if (separator <= 0) continue;
414
+ const account = line.slice(0, separator);
415
+ if (!generations.has(account)) continue;
416
+ try {
417
+ const value = Buffer.from(line.slice(separator + 1), 'base64').toString('utf8');
418
+ if (!value) continue;
419
+ if (_cacheEpoch !== epoch || (_cacheGenerations.get(account) || 0) !== generations.get(account)) continue;
420
+ _cacheSet(account, value);
421
+ } catch {
422
+ // An undecodable row must not prevent the remaining secrets from warming.
423
+ }
424
+ }
375
425
  } catch {
376
426
  // Prewarming is an optional startup optimization and must never fail boot.
377
427
  }
@@ -285,7 +285,7 @@ export function makeAgentDispatch(opts = {}) {
285
285
  }
286
286
  const agent = opts.agent;
287
287
 
288
- return async function agentDispatch({ prompt, preset: presetArg, sourceName: sourceNameArg, parentSignal: callParentSignal, idleTimeoutMs: callIdleTimeoutMs }) {
288
+ return async function agentDispatch({ prompt, preset: presetArg, sourceName: sourceNameArg, parentSignal: callParentSignal, idleTimeoutMs: callIdleTimeoutMs, cwd: callCwd }) {
289
289
  if (typeof prompt !== 'string' || !prompt) {
290
290
  throw new Error(`[agent-dispatch] prompt required for agent "${agent}"`);
291
291
  }
@@ -355,7 +355,9 @@ export function makeAgentDispatch(opts = {}) {
355
355
  // and the downstream skill-discovery path tolerates null. Combined
356
356
  // with the frozen agent skill meta-tools (collect.mjs) this keeps
357
357
  // every caller on the same provider cache shard.
358
- const cwd = (typeof opts.cwd === 'string' && opts.cwd) ? opts.cwd : null;
358
+ const cwd = (typeof callCwd === 'string' && callCwd)
359
+ ? callCwd
360
+ : ((typeof opts.cwd === 'string' && opts.cwd) ? opts.cwd : null);
359
361
 
360
362
  // Unified dispatch: Pool B/C share bit-identical tools + system prompt
361
363
  // unless a hidden role declares a narrow toolSchemaProfile. Per-role
@@ -10,7 +10,8 @@
10
10
  * BP_1 system#1 (1h) — shared tool policy + compact skill manifest
11
11
  * BP_2 system#2 (1h) — role/system rules (Lead / agent / hidden role)
12
12
  * BP_3 system#3 (1h) — stable memory/meta marker (sessionMarker system block; tier3)
13
- * BP_4 messages (5m agents/hidden; Lead linked to autoClear — see below) —
13
+ * BP_4 messages (1h public/hidden agents; Lead linked to autoClear,
14
+ * whose Anthropic default is 1h — see below) —
14
15
  * sliding tool_result / prior user-text tail
15
16
  *
16
17
  * Tool schemas still sit before system in the provider prompt prefix. We do
@@ -19,8 +20,8 @@
19
20
  * agent worker tool schemas byte-stable is therefore still load-bearing.
20
21
  *
21
22
  * Tier 3 gets its own BP because memory/meta context is stable within the
22
- * session. The sliding messages BP handles tool_result accumulation and
23
- * per-call task/event data without pinning volatile text into the 1h tier.
23
+ * session. The sliding 1h messages BP handles tool_result accumulation and
24
+ * per-call task/event data while isolating volatile text from stable prefix BPs.
24
25
  *
25
26
  * Non-breakpoint providers:
26
27
  * - OpenAI (public): prompt_cache_key + prompt_cache_retention=24h
@@ -73,18 +74,18 @@ function isOneShotMaintenanceAgent(agent) {
73
74
  * BP1~3 stay at 1h: the system/role/tier3 prefix is shared across sessions
74
75
  * (pool-stable), so the 2x write premium is amortized cross-session and the
75
76
  * warm window survives per-session gaps. The volatile message tail (BP4) is
76
- * per-session and trace data (2026-06) shows request gaps are p99 4.5min
77
- * 5m+ gaps mean a cold tail anyway, and the smart-compact path rebuilds the
78
- * history at that boundary. So hidden-agent and public-agent sessions keep
79
- * the cheaper 5m tail TTL (1.25x write vs 2x), aligned with the 5m
80
- * terminal-reap window for agents.
77
+ * per-session, but the 2026-07-16 6.8h trace supports a 1h tail: 24/25 Lead
78
+ * intra-session gaps over 5m were agent waits (5–27m), during which autoClear
79
+ * correctly cannot fire; 32.7% of agent intra-session gaps exceeded 5m due to
80
+ * long tool executions, with sessions surviving through reviewer fix-loop
81
+ * reuse; and no gap over 1h was observed. Tail-cost simulation (input-token
82
+ * equivalents) is Lead 1h=11.75M vs 5m=20.11M and agents 1h=14.46M vs
83
+ * 5m=45.04M: misses that rewrite the accumulated tail dominate the 2x-vs-1.25x
84
+ * write premium. Hidden and public-agent sessions therefore use a 1h tail.
81
85
  *
82
- * Lead sessions are linked to the user's autoClear idle-sweep config
83
- * instead of the fixed 5m default (see resolveLeadMessagesTtl): a Lead
84
- * session that autoClear will only reap after a long/never idle window
85
- * benefits from the 1h tail TTL (fewer writes over a long-lived session),
86
- * while a short idle-sweep window means the tail is going cold anyway and
87
- * 5m is cheaper to write.
86
+ * Lead sessions are linked to the user's autoClear idle-sweep config (see
87
+ * resolveLeadMessagesTtl); its Anthropic default is 1h, while explicit
88
+ * shorter overrides retain the shorter-sweep behavior.
88
89
  * (Tail TTL only affects explicit-breakpoint providers — Anthropic; no-op
89
90
  * elsewhere.)
90
91
  *
@@ -120,12 +121,12 @@ export function resolveCacheStrategy(agent, { autoClear } = {}) {
120
121
  return { tools: 'none', system: 'none', tier3: 'none', messages: 'none' };
121
122
  }
122
123
  if (getHiddenAgent(agent)) {
123
- return { tools: 'none', system: '1h', tier3: '1h', messages: '5m' };
124
+ return { tools: 'none', system: '1h', tier3: '1h', messages: '1h' };
124
125
  }
125
126
  if (agent && agent !== 'lead') {
126
- // Public (non-hidden, non-lead) agents keep the flat 5m tail — only
127
+ // Public (non-hidden, non-lead) agents keep the flat 1h tail — only
127
128
  // the Lead session's tail is linked to autoClear.
128
- return { tools: 'none', system: '1h', tier3: '1h', messages: '5m' };
129
+ return { tools: 'none', system: '1h', tier3: '1h', messages: '1h' };
129
130
  }
130
131
  // Lead session (agent === 'lead', or no agent — raw/CLI callers default
131
132
  // to Lead behavior): message tail TTL is linked to autoClear (see
@@ -21,6 +21,15 @@ const MIXDOG_SLOW_TOOL_TRACE_NAMES = new Set(
21
21
  .map((name) => name.trim())
22
22
  .filter(Boolean)
23
23
  );
24
+ const RECOVERED_ERROR_MESSAGE_MAX_CHARS = 300;
25
+
26
+ function compactRecoveredErrorMessage(value) {
27
+ if (value == null) return null;
28
+ const message = String(value);
29
+ return message.length > RECOVERED_ERROR_MESSAGE_MAX_CHARS
30
+ ? `${message.slice(0, 299)}…`
31
+ : message;
32
+ }
24
33
 
25
34
  function traceAgentLoop({ sessionId, iteration, sendMs, messageCount, bodyBytesEst, agent = null }) {
26
35
  // Two emit modes, no behavior change either way:
@@ -67,6 +76,7 @@ function traceAgentCompact({
67
76
  model,
68
77
  error,
69
78
  error_code,
79
+ recovered_error,
70
80
  details,
71
81
  }) {
72
82
  appendAgentTrace({
@@ -96,6 +106,14 @@ function traceAgentCompact({
96
106
  model: model || null,
97
107
  error: error || null,
98
108
  error_code: error_code || null,
109
+ recovered_error: recovered_error && typeof recovered_error === 'object'
110
+ ? {
111
+ code: recovered_error.code ?? null,
112
+ // Capture already strips ANSI/newlines; retain this writer-side
113
+ // cap for any future compact-meta caller.
114
+ message: compactRecoveredErrorMessage(recovered_error.message),
115
+ }
116
+ : null,
99
117
  details: details && typeof details === 'object' ? details : null,
100
118
  });
101
119
  }
@@ -3,6 +3,10 @@ import { AsyncLocalStorage } from 'node:async_hooks';
3
3
 
4
4
  export const PROVIDER_ACCOUNT_CONCURRENCY = 64;
5
5
  export const PROVIDER_ACCOUNT_MAX_QUEUE = 1024;
6
+ // A cooldown longer than this is a quota-window block (subscription limit),
7
+ // not a transient burst: parking requests silently for it would look like a
8
+ // dead chat. Longer cooldowns fail fast with a visible error instead.
9
+ export const PROVIDER_COOLDOWN_FAIL_FAST_MS = 60_000;
6
10
  const MAX_TIMEOUT_MS = 2_147_483_647;
7
11
  const currentAdmission = new AsyncLocalStorage();
8
12
 
@@ -23,6 +27,27 @@ function isAnthropicLane(key) {
23
27
  return provider === 'anthropic' || provider === 'anthropic-oauth';
24
28
  }
25
29
 
30
+ function providerLabelForLane(key) {
31
+ const prefix = String(key).split(':', 1)[0];
32
+ const lower = prefix.toLowerCase();
33
+ if (lower === 'anthropic-oauth') return 'Anthropic OAuth';
34
+ if (lower === 'anthropic') return 'Anthropic';
35
+ return prefix;
36
+ }
37
+
38
+ // Compact duration without spaces/colons so err-text's `retryAfter=([^\s:]+)`
39
+ // capture keeps the full value (e.g. 1h42m, 3m20s, 45s).
40
+ function formatCooldownMs(ms) {
41
+ const totalSec = Math.max(1, Math.ceil((Number(ms) || 0) / 1000));
42
+ if (totalSec < 60) return `${totalSec}s`;
43
+ const totalMin = Math.floor(totalSec / 60);
44
+ const sec = totalSec % 60;
45
+ if (totalMin < 60) return sec ? `${totalMin}m${sec}s` : `${totalMin}m`;
46
+ const hr = Math.floor(totalMin / 60);
47
+ const min = totalMin % 60;
48
+ return min ? `${hr}h${min}m` : `${hr}h`;
49
+ }
50
+
26
51
  function retryAfterMs(error, now) {
27
52
  const explicit = Number(error?.retryAfterMs);
28
53
  if (Number.isFinite(explicit) && explicit >= 0) return explicit;
@@ -47,6 +72,28 @@ export class ProviderAdmissionQueueOverflowError extends Error {
47
72
  }
48
73
  }
49
74
 
75
+ /**
76
+ * Deterministic refusal while a long rate-limit cooldown is active. Surfaced
77
+ * to the caller immediately (turn fails with a visible error) instead of
78
+ * silently parking the request until the quota window resets. httpStatus 429
79
+ * routes it through the existing quota/rate-limit error presentation.
80
+ */
81
+ export class ProviderCooldownError extends Error {
82
+ constructor(key, cooldownUntil, now) {
83
+ const remaining = Math.max(0, Number(cooldownUntil) - Number(now));
84
+ super(
85
+ `${providerLabelForLane(key)} rate-limit cooldown active; retryAfter=${formatCooldownMs(remaining)} — `
86
+ + 'wait for the quota window reset, or re-login / switch the provider account to continue now.',
87
+ );
88
+ this.name = 'ProviderCooldownError';
89
+ this.code = 'EPROVIDERCOOLDOWN';
90
+ this.httpStatus = 429;
91
+ this.laneKey = key;
92
+ this.cooldownUntil = cooldownUntil;
93
+ this.retryAfterMs = remaining;
94
+ }
95
+ }
96
+
50
97
  /**
51
98
  * Per-account FIFO admission. Anthropic lanes use rate-limit cooldown and
52
99
  * additive recovery; other providers retain a fixed concurrency limit.
@@ -95,6 +142,12 @@ export class ProviderAdmissionScheduler {
95
142
  cooldownTimer: null,
96
143
  };
97
144
  this.lanes.set(laneKey, lane);
145
+ // Long cooldown = quota window. Fail fast with a visible error; short
146
+ // cooldowns (bursts) keep the silent park-and-drain smoothing below.
147
+ if (lane.adaptive && lane.cooldownUntil - this.now() > PROVIDER_COOLDOWN_FAIL_FAST_MS) {
148
+ this._scheduleCooldown(laneKey, lane);
149
+ return Promise.reject(new ProviderCooldownError(laneKey, lane.cooldownUntil, this.now()));
150
+ }
98
151
  if (lane.queue.length >= this.maxQueue) {
99
152
  return Promise.reject(new ProviderAdmissionQueueOverflowError(laneKey, this.maxQueue));
100
153
  }
@@ -120,6 +173,9 @@ export class ProviderAdmissionScheduler {
120
173
 
121
174
  _drain(key, lane) {
122
175
  if (lane.adaptive && lane.cooldownUntil > this.now()) {
176
+ if (lane.cooldownUntil - this.now() > PROVIDER_COOLDOWN_FAIL_FAST_MS) {
177
+ this._rejectQueueForCooldown(key, lane);
178
+ }
123
179
  this._scheduleCooldown(key, lane);
124
180
  return;
125
181
  }
@@ -187,9 +243,55 @@ export class ProviderAdmissionScheduler {
187
243
  Math.min(Number.MAX_SAFE_INTEGER, now + retryAfterMs(error, now)),
188
244
  );
189
245
  this._scheduleCooldown(key, lane);
246
+ // A quota-window cooldown must not leave already-queued requests
247
+ // hanging silently for hours — reject them with the same visible error
248
+ // the fail-fast admission path raises.
249
+ if (lane.cooldownUntil - now > PROVIDER_COOLDOWN_FAIL_FAST_MS) {
250
+ this._rejectQueueForCooldown(key, lane);
251
+ }
190
252
  return true;
191
253
  }
192
254
 
255
+ _rejectQueueForCooldown(key, lane) {
256
+ if (!lane.queue.length) return;
257
+ for (const item of lane.queue.splice(0)) {
258
+ if (item.canceled) {
259
+ this._detach(item);
260
+ continue;
261
+ }
262
+ item.canceled = true;
263
+ this._detach(item);
264
+ item.task = null;
265
+ item.reject(new ProviderCooldownError(key, lane.cooldownUntil, this.now()));
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Clear rate-limit cooldowns (optionally for one provider). Called when
271
+ * provider credentials change — a re-login / account switch invalidates
272
+ * the old account's quota window, so requests must flow again immediately.
273
+ * Returns the number of lanes reset.
274
+ */
275
+ resetCooldowns(providerName = null) {
276
+ const wanted = providerName ? String(providerName).toLowerCase() : null;
277
+ let resetCount = 0;
278
+ for (const [key, lane] of [...this.lanes]) {
279
+ if (!lane.adaptive) continue;
280
+ if (wanted && String(key).split(':', 1)[0].toLowerCase() !== wanted) continue;
281
+ if (lane.cooldownUntil <= this.now() && lane.limit >= this.concurrency) continue;
282
+ lane.cooldownUntil = 0;
283
+ lane.limit = this.concurrency;
284
+ lane.recoverySuccesses = 0;
285
+ if (lane.cooldownTimer) {
286
+ this.clearTimer(lane.cooldownTimer);
287
+ lane.cooldownTimer = null;
288
+ }
289
+ resetCount += 1;
290
+ this._drain(key, lane);
291
+ }
292
+ return resetCount;
293
+ }
294
+
193
295
  _recordSuccess(lane) {
194
296
  if (!lane.adaptive || lane.limit >= this.concurrency) return;
195
297
  lane.recoverySuccesses += 1;
@@ -304,6 +406,15 @@ export function providerAdmissionKey(providerName, provider) {
304
406
  export const providerAdmissionScheduler = new ProviderAdmissionScheduler();
305
407
  const WRAPPED = Symbol.for('mixdog.providerAdmissionWrapped');
306
408
 
409
+ /**
410
+ * Process-wide cooldown reset on the shared scheduler. Wired to provider auth
411
+ * mutations (login / API-key save / forget) so switching accounts resumes
412
+ * chat without a process restart.
413
+ */
414
+ export function resetProviderAdmissionCooldowns(providerName = null) {
415
+ return providerAdmissionScheduler.resetCooldowns(providerName);
416
+ }
417
+
307
418
  export function wrapProviderAdmission(provider, providerName, scheduler = providerAdmissionScheduler) {
308
419
  if (!provider || typeof provider.send !== 'function' || provider[WRAPPED]) return provider;
309
420
  const originalSend = provider.send;