mixdog 0.9.39 → 0.9.41

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 (59) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
  4. package/scripts/compact-pressure-test.mjs +128 -0
  5. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  6. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  7. package/scripts/internal-comms-smoke.mjs +66 -25
  8. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  9. package/scripts/max-output-recovery-test.mjs +222 -0
  10. package/scripts/provider-toolcall-test.mjs +32 -0
  11. package/scripts/steering-drain-buckets-test.mjs +140 -5
  12. package/scripts/tui-transcript-perf-test.mjs +279 -0
  13. package/src/agents/reviewer/AGENT.md +4 -0
  14. package/src/rules/lead/lead-brief.md +11 -3
  15. package/src/rules/lead/lead-tool.md +0 -2
  16. package/src/rules/shared/01-tool.md +4 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  18. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  20. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
  25. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  28. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
  29. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  32. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  33. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  34. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  35. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  36. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  37. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  38. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
  39. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  41. package/src/runtime/shared/buffered-appender.mjs +13 -2
  42. package/src/session-runtime/config-helpers.mjs +6 -6
  43. package/src/session-runtime/lifecycle-api.mjs +4 -0
  44. package/src/session-runtime/runtime-core.mjs +1 -0
  45. package/src/session-runtime/session-turn-api.mjs +12 -0
  46. package/src/standalone/agent-tool.mjs +8 -1
  47. package/src/tui/App.jsx +2 -1
  48. package/src/tui/app/transcript-window.mjs +9 -10
  49. package/src/tui/app/use-transcript-window.mjs +38 -56
  50. package/src/tui/dist/index.mjs +354 -163
  51. package/src/tui/engine/agent-job-feed.mjs +76 -6
  52. package/src/tui/engine/session-api.mjs +7 -1
  53. package/src/tui/engine/session-flow.mjs +3 -1
  54. package/src/tui/engine/tool-card-results.mjs +10 -5
  55. package/src/tui/engine/turn.mjs +96 -36
  56. package/src/tui/engine.mjs +136 -37
  57. package/src/tui/index.jsx +2 -2
  58. package/src/workflows/bench/WORKFLOW.md +51 -27
  59. package/src/workflows/default/WORKFLOW.md +29 -24
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from 'fs';
2
- import { readdir, rmdir, unlink, writeFile } from 'fs/promises';
2
+ import { readdir, rmdir, stat, unlink, writeFile } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { getPluginData } from '../config.mjs';
5
5
  import { normalizeOutputPath } from '../tools/builtin/path-utils.mjs';
@@ -11,6 +11,7 @@ const TOOL_RESULT_SHELL_THRESHOLD_CHARS = 30_000;
11
11
  const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
12
12
  const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
13
13
  export const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
14
+ const OFFLOAD_PRUNE_MIN_AGE_MS = 10 * 60 * 1000;
14
15
 
15
16
  // Per-tool persistence limits mirror reference per-tool maxResultSizeChars
16
17
  // rather than a single global value: Grep persists at 20k (CC GrepTool), Glob
@@ -147,6 +148,47 @@ export async function clearOffloadSession(sessionId) {
147
148
  } catch { /* best-effort */ }
148
149
  }
149
150
 
151
+ // Remove sidecars that no longer occur in the live transcript. A serialized
152
+ // path match is conservative: if messages cannot be serialized, or a path is
153
+ // mentioned anywhere in a message, retain the file rather than risk deleting
154
+ // one that can still be read by the model.
155
+ export async function pruneOffloadSession(sessionId, getMessages) {
156
+ if (!sessionId || typeof getMessages !== 'function') return;
157
+ const dir = join(getPluginData(), 'tool-results', safeSessionSegment(sessionId));
158
+ if (!existsSync(dir)) return;
159
+ let candidates;
160
+ try {
161
+ const entries = await readdir(dir);
162
+ candidates = (await Promise.all(entries
163
+ .filter((name) => name.endsWith('.txt'))
164
+ .map(async (name) => {
165
+ const filePath = join(dir, name);
166
+ try {
167
+ const fileStat = await stat(filePath);
168
+ if (Date.now() - fileStat.mtimeMs < OFFLOAD_PRUNE_MIN_AGE_MS) return null;
169
+ return { name, filePath };
170
+ } catch {
171
+ return null;
172
+ }
173
+ })
174
+ )).filter(Boolean);
175
+ } catch { /* best-effort */ }
176
+ if (!candidates) return;
177
+ let serialized;
178
+ try { serialized = JSON.stringify(getMessages()); } catch { return; }
179
+ const haystack = process.platform === 'win32' ? serialized.toLowerCase() : serialized;
180
+ await Promise.all(candidates
181
+ .filter(({ name, filePath }) => {
182
+ const normalizedPath = normalizeOutputPath(filePath);
183
+ const needles = [normalizedPath, name];
184
+ return !needles.some((needle) => {
185
+ const value = process.platform === 'win32' ? needle.toLowerCase() : needle;
186
+ return haystack.includes(value);
187
+ });
188
+ })
189
+ .map(({ filePath }) => unlink(filePath).catch(() => { /* best-effort */ })));
190
+ }
191
+
150
192
  export function isOffloadedToolResultText(text) {
151
193
  return typeof text === 'string' && text.startsWith(TOOL_RESULT_OFFLOAD_PREFIX);
152
194
  }
@@ -124,23 +124,31 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
124
124
  function _combineAbortSignals(sessionSignal, externalSignal) {
125
125
  const a = sessionSignal || null;
126
126
  const b = externalSignal || null;
127
- if (!a && !b) return null;
128
- if (!a) return b;
129
- if (!b) return a;
130
- if (a === b) return a;
127
+ if (!a && !b) return { signal: null, cleanup() {} };
128
+ if (!a) return { signal: b, cleanup() {} };
129
+ if (!b) return { signal: a, cleanup() {} };
130
+ if (a === b) return { signal: a, cleanup() {} };
131
131
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
132
- try { return AbortSignal.any([a, b]); } catch { /* fall through */ }
132
+ try { return { signal: AbortSignal.any([a, b]), cleanup() {} }; } catch { /* fall through */ }
133
133
  }
134
134
  const ctl = new AbortController();
135
135
  const onAbort = (sig) => {
136
136
  if (ctl.signal.aborted) return;
137
137
  try { ctl.abort(sig?.reason); } catch { try { ctl.abort(); } catch {} }
138
138
  };
139
- if (a.aborted) { onAbort(a); return ctl.signal; }
140
- if (b.aborted) { onAbort(b); return ctl.signal; }
141
- try { a.addEventListener('abort', () => onAbort(a), { once: true }); } catch {}
142
- try { b.addEventListener('abort', () => onAbort(b), { once: true }); } catch {}
143
- return ctl.signal;
139
+ if (a.aborted) { onAbort(a); return { signal: ctl.signal, cleanup() {} }; }
140
+ if (b.aborted) { onAbort(b); return { signal: ctl.signal, cleanup() {} }; }
141
+ const onAbortA = () => onAbort(a);
142
+ const onAbortB = () => onAbort(b);
143
+ try { a.addEventListener('abort', onAbortA, { once: true }); } catch {}
144
+ try { b.addEventListener('abort', onAbortB, { once: true }); } catch {}
145
+ return {
146
+ signal: ctl.signal,
147
+ cleanup() {
148
+ try { a.removeEventListener('abort', onAbortA); } catch {}
149
+ try { b.removeEventListener('abort', onAbortB); } catch {}
150
+ },
151
+ };
144
152
  }
145
153
 
146
154
  function _prefixPowerShellUtf8(command) {
@@ -228,7 +236,11 @@ export async function executeBashTool(args, workDir, options = {}) {
228
236
  const userProvidedSession = typeof args.session_id === 'string' && args.session_id.trim().length > 0;
229
237
  const shouldCreate = args.create === true || !userProvidedSession;
230
238
  effectiveArgs = { ...effectiveArgs, create: shouldCreate };
231
- return executeBashSessionTool('bash_session', effectiveArgs, bashWorkDir, { abortSignal: combinedPersistAbort, sessionId: options?.sessionId });
239
+ try {
240
+ return await executeBashSessionTool('bash_session', effectiveArgs, bashWorkDir, { abortSignal: combinedPersistAbort.signal, sessionId: options?.sessionId });
241
+ } finally {
242
+ combinedPersistAbort.cleanup();
243
+ }
232
244
  }
233
245
 
234
246
  let command = args.command;
@@ -276,6 +288,7 @@ export async function executeBashTool(args, workDir, options = {}) {
276
288
  }
277
289
 
278
290
  let shellEffects;
291
+ let combinedBashAbort = null;
279
292
  try {
280
293
  shellEffects = await analyzeShellCommandEffects(command, bashWorkDir);
281
294
  } catch (err) {
@@ -284,10 +297,9 @@ export async function executeBashTool(args, workDir, options = {}) {
284
297
  // Keep foreground commands on a long tool-owned timeout. The MCP dispatch
285
298
  // layer must not add a shorter fallback ceiling when timeout is omitted.
286
299
  // Reference-CLI parity (opencode/codex/claude-code): sync-first, no hard
287
- // upper ceiling on a caller-provided timeout. Default 120 s (2 min) when
288
- // omitted; BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides keep
289
- // their semantics (max floored at default) but only bound the *omitted*
290
- // default — an explicit args.timeout is honored UNCAPPED.
300
+ // upper ceiling on a caller-provided total timeout. Default 120 s (2 min)
301
+ // when omitted; BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides
302
+ // bound the blocking window when timeout promotion is available.
291
303
  const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
292
304
  const DEFAULT_BASH_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
293
305
  // Background (async / run_in_background) jobs get NO omitted default: 0
@@ -302,8 +314,14 @@ export async function executeBashTool(args, workDir, options = {}) {
302
314
  : DEFAULT_BASH_TIMEOUT_MS;
303
315
  const hasExplicitTimeout = typeof args.timeout === 'number' && args.timeout > 0;
304
316
  const timeoutMs = hasExplicitTimeout ? args.timeout : defaultTimeoutMs;
305
- // Explicit caller timeout is uncapped (only the wmic-rewrite ceiling, when
306
- // present, still bounds it); the omitted default keeps the MAX ceiling.
317
+ const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
318
+ String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
319
+ );
320
+ const backgroundOnTimeout = !runInBackground
321
+ && !_bgTasksDisabled
322
+ && isAutobackgroundingAllowed(command, resolvedSpec.shellType);
323
+ // Explicit caller timeout remains the total deadline. When promotion is
324
+ // available, cap only its foreground blocking portion at MAX.
307
325
  // JS timers (setTimeout) and PS WaitForExit(ms) are 32-bit: a delay above
308
326
  // 2^31-1 wraps to a tiny/negative value and fires immediately. Clamp the
309
327
  // uncapped explicit timeout once here (~24.8 days ceiling) so every
@@ -312,11 +330,15 @@ export async function executeBashTool(args, workDir, options = {}) {
312
330
  const TIMER_MAX_MS = 2_147_483_647;
313
331
  // timeoutMs <= 0 (omitted background default) means unlimited: pass it
314
332
  // through untouched — the min() clamps below must not turn 0 into a bound.
315
- const timeout = timeoutMs <= 0
333
+ const totalTimeout = timeoutMs <= 0
316
334
  ? 0
317
- : (hasExplicitTimeout
318
- ? Math.min(timeoutMs, wmicRewrite?.timeoutMs || TIMER_MAX_MS)
319
- : Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS));
335
+ : Math.min(timeoutMs, wmicRewrite?.timeoutMs || (hasExplicitTimeout ? TIMER_MAX_MS : MAX_BASH_TIMEOUT_MS));
336
+ const timeout = hasExplicitTimeout && backgroundOnTimeout
337
+ ? Math.min(totalTimeout, MAX_BASH_TIMEOUT_MS)
338
+ : totalTimeout;
339
+ const promotedTimeoutMs = hasExplicitTimeout && backgroundOnTimeout
340
+ ? Math.max(0, totalTimeout - timeout)
341
+ : 0;
320
342
  const mergeStderr = args.merge_stderr === true;
321
343
  const longForegroundHint = foregroundLongCommandHint(command, timeout, { ...args, run_in_background: runInBackground });
322
344
  if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
@@ -423,7 +445,7 @@ export async function executeBashTool(args, workDir, options = {}) {
423
445
  let bashAbortSignal = null;
424
446
  try { bashAbortSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
425
447
  catch { bashAbortSignal = null; }
426
- const combinedBashAbort = _combineAbortSignals(bashAbortSignal, options?.abortSignal || null);
448
+ combinedBashAbort = _combineAbortSignals(bashAbortSignal, options?.abortSignal || null);
427
449
  // Sync path only: chain a trailing cwd probe so the session's final
428
450
  // working directory persists to the next shell call. Async jobs run
429
451
  // detached and are intentionally excluded (they never reach here). The
@@ -445,22 +467,17 @@ export async function executeBashTool(args, workDir, options = {}) {
445
467
  // base commands (isAutobackgroundingAllowed), (b) the truthy
446
468
  // MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS env. Never applies to
447
469
  // run_in_background (already detached, handled above).
448
- const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
449
- String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
450
- );
451
- const backgroundOnTimeout = !runInBackground
452
- && !_bgTasksDisabled
453
- && isAutobackgroundingAllowed(command, shellType);
454
470
  const result = await execShellCommand({
455
471
  shell, shellArg, shellArgs, command: syncCommand,
456
472
  env: spawnEnv,
457
473
  cwd: bashWorkDir,
458
474
  timeoutMs: timeout,
459
- abortSignal: combinedBashAbort,
475
+ abortSignal: combinedBashAbort.signal,
460
476
  autoBackgroundMs,
461
477
  // On a foreground timeout, promote the still-running child to a
462
478
  // tracked background job (unlimited) instead of killing it.
463
479
  backgroundOnTimeout,
480
+ promotedTimeoutMs,
464
481
  // Threaded so an auto-backgrounded foreground job is stamped with
465
482
  // the dispatching terminal's claude.exe pid (per-terminal scope).
466
483
  clientHostPid: options?.clientHostPid,
@@ -494,10 +511,7 @@ export async function executeBashTool(args, workDir, options = {}) {
494
511
  stdout: result.stdoutPath ? normalizeOutputPath(result.stdoutPath) : null,
495
512
  stderr: (!mergeStderr && result.stderrPath) ? normalizeOutputPath(result.stderrPath) : null,
496
513
  cwd: bashWorkDir,
497
- // Adopted foreground jobs run unlimited (timeoutMs 0)
498
- // — matches the async default; the original
499
- // foreground timeout no longer bounds the promoted job.
500
- timeoutMs: 0,
514
+ timeoutMs: result.backgroundTimeoutMs || 0,
501
515
  },
502
516
  resultType: 'shell_task_result',
503
517
  cancel: () => killShellJob(result.jobId),
@@ -517,7 +531,7 @@ export async function executeBashTool(args, workDir, options = {}) {
517
531
  const lines = [
518
532
  task ? renderBackgroundTask(task) : (result.jobId ? `[task_id: ${result.jobId}]` : null),
519
533
  '',
520
- result.backgroundMessage || 'auto-backgrounded; still running',
534
+ result.backgroundMessage || 'auto-backgrounded; still running — judge from the partial output whether waiting can finish in budget, or diagnose and pursue an alternative.',
521
535
  partialStdout ? `\n[partial stdout]\n${partialStdout}` : '',
522
536
  (!mergeStderr && partialStderr) ? `\n[partial stderr]\n${partialStderr}` : '',
523
537
  ].filter((l) => l !== null && l !== '');
@@ -592,6 +606,7 @@ export async function executeBashTool(args, workDir, options = {}) {
592
606
  return _prependDestructiveWarning(command, warningBlock ? `${warningBlock}\n${payload}` : payload);
593
607
  }
594
608
  finally {
609
+ combinedBashAbort?.cleanup?.();
595
610
  if (shellEffects.mutationMode === 'paths') {
596
611
  invalidateBuiltinResultCache(shellEffects.paths);
597
612
  markCodeGraphDirtyPaths(shellEffects.paths);
@@ -83,7 +83,7 @@ export const BUILTIN_TOOLS = [
83
83
  properties: {
84
84
  command: { type: 'string', description: 'Command.' },
85
85
  cwd: { type: 'string', description: 'Working directory; persists across calls. Omit to reuse; absolute path changes it.' },
86
- timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min) when omitted; an explicit value is honored uncapped — long-running commands may set it higher. On timeout the command is MOVED TO BACKGROUND (a task_id you can wait/status/read/cancel) and keeps running instead of being killed; sleep-like commands and MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS opt out (killed with a [timeout] marker). async/background runs with timeout omitted have NO timeout (run until done/cancelled); an explicit timeout is still enforced.` },
86
+ timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min) when omitted. On sync timeout the command is MOVED TO BACKGROUND (a task_id you can wait/status/read/cancel) and keeps running instead of being killed; an explicit timeout blocks for at most BASH_MAX_TIMEOUT_MS (default 10 min), and only its remainder beyond that cap is still enforced as a background deadline. Sleep-like commands and MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS opt out (killed with a [timeout] marker after blocking for the full explicit timeout). async/background runs with timeout omitted have NO timeout (run until done/cancelled); an explicit timeout is still enforced.` },
87
87
  merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
88
88
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
89
89
  shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows defaults to PowerShell; bash = Git Bash/POSIX.' },
@@ -500,6 +500,7 @@ export function execShellCommand({
500
500
  onProgress,
501
501
  clientHostPid,
502
502
  backgroundOnTimeout,
503
+ promotedTimeoutMs = 0,
503
504
  }) {
504
505
  return new Promise(async (resolve) => {
505
506
  const taskId = `shell_${randomUUID().slice(0, 8)}`;
@@ -741,9 +742,8 @@ export function execShellCommand({
741
742
  // BACKGROUND_MS opt-in) — an EARLIER promotion before the timeout, and
742
743
  // 2. the foreground timeout deadline (backgroundOnTimeout) — the default
743
744
  // promote-on-timeout that replaces the old tree-kill.
744
- // Either way the adopted job runs UNLIMITED (timeoutMs 0, matching the
745
- // async default): the original foreground timeout no longer bounds it, and
746
- // the adopted-job cap poll still enforces the 100 MB output ceiling.
745
+ // A capped explicit foreground timeout supplies its remaining deadline to
746
+ // the adopted job; otherwise adoption remains unlimited as before.
747
747
  // Mutually exclusive with settle() via the autoBackgrounded flag set
748
748
  // synchronously at the top before any await.
749
749
  const _autoBackground = async ({ reason = 'threshold' } = {}) => {
@@ -754,8 +754,7 @@ export function execShellCommand({
754
754
  if (child.exitCode != null || child.signalCode != null) return;
755
755
  autoBackgrounded = true;
756
756
  // The foreground capture is over; stop the local watchdogs/timers so
757
- // they cannot treeKill the now-adopted child. The adopted job runs
758
- // unlimited; refreshShellJob only enforces the output cap.
757
+ // they cannot treeKill the now-adopted child.
759
758
  if (timer) { clearTimeout(timer); timer = null; }
760
759
  _clearProgressTimer();
761
760
  if (sizeWatchdog) { clearInterval(sizeWatchdog); sizeWatchdog = null; }
@@ -777,14 +776,13 @@ export function execShellCommand({
777
776
  const stdoutPath = taskOutput.spilled ? taskOutput.stdoutPath : null;
778
777
  const stderrPath = taskOutput.spilled ? taskOutput.stderrPath : null;
779
778
  let job = null;
779
+ const adoptedTimeoutMs = reason === 'timeout' ? promotedTimeoutMs : 0;
780
780
  try {
781
781
  job = adoptForegroundShellJob({
782
782
  command,
783
783
  cwd,
784
784
  pid: child.pid,
785
- // Unlimited: the promoted job is no longer bounded by the foreground
786
- // timeout (matches the async omitted-default behavior).
787
- timeoutMs: 0,
785
+ timeoutMs: adoptedTimeoutMs,
788
786
  mergeStderr: false,
789
787
  stdoutPath,
790
788
  stderrPath,
@@ -854,9 +852,10 @@ export function execShellCommand({
854
852
  outputCaptureError: taskOutput.writeError,
855
853
  backgrounded: true,
856
854
  jobId,
855
+ backgroundTimeoutMs: adoptedTimeoutMs,
857
856
  backgroundMessage: jobId
858
- ? `${_verb}; still running — completion will be delivered as a background task notification. Use task with task_id:${jobId} only for manual wait/status/read/cancel.`
859
- : `${_verb}; still running`,
857
+ ? `${_verb}; still running. Waiting is a decision, not a default: judge from the partial output whether this will finish within your remaining budget if progress looks slow or stalled, diagnose the cause and pursue an alternative instead of waiting. Completion will be delivered as a background task notification; use task with task_id:${jobId} only for manual wait/status/read/cancel.`
858
+ : `${_verb}; still running — judge from the partial output whether waiting can finish in budget, or diagnose and pursue an alternative.`,
860
859
  }),
861
860
  );
862
861
  };
@@ -38,6 +38,13 @@ function scheduleFlush(path, q) {
38
38
  q.timer.unref?.();
39
39
  }
40
40
 
41
+ function deleteIfIdle(path, q) {
42
+ if (!q.timer && !q.flushing && !q.inFlight && q.chunks.length === 0
43
+ && queues.get(path) === q) {
44
+ queues.delete(path);
45
+ }
46
+ }
47
+
41
48
  function _flush(path, q) {
42
49
  if (q.flushing) return;
43
50
  if (q.chunks.length === 0) return;
@@ -70,7 +77,7 @@ function _flush(path, q) {
70
77
  if (q.chunks.length > 0) {
71
78
  if (q.bytes >= BUFFER_FLUSH_BYTES) _flush(path, q);
72
79
  else scheduleFlush(path, q);
73
- }
80
+ } else deleteIfIdle(path, q);
74
81
  });
75
82
  }
76
83
 
@@ -120,7 +127,10 @@ export function drainPathSync(path) {
120
127
  }
121
128
  q.inFlight = null;
122
129
  }
123
- if (q.chunks.length === 0) return;
130
+ if (q.chunks.length === 0) {
131
+ deleteIfIdle(path, q);
132
+ return;
133
+ }
124
134
  const data = q.chunks.join('');
125
135
  q.chunks = [];
126
136
  q.bytes = 0;
@@ -129,6 +139,7 @@ export function drainPathSync(path) {
129
139
  } catch {
130
140
  // Best-effort exit drain; nothing to recover from here.
131
141
  }
142
+ deleteIfIdle(path, q);
132
143
  }
133
144
 
134
145
  /**
@@ -176,16 +176,16 @@ export function autoClearProviderDefaults(providerIdleMs = null) {
176
176
  }
177
177
 
178
178
  // Agent terminal retention is deliberately narrower than Auto Clear's general
179
- // resolution: only listed provider rows participate. In particular, neither
180
- // the global idleMs nor the Advanced "default" row can retain a completed
181
- // agent, because those values do not describe a provider cache lifetime.
182
- // Null means leave the completed agent alone (no live timer, row expiry, or
183
- // durable store sweep).
179
+ // resolution: listed providers use their provider cache lifetime. Unlisted
180
+ // providers use the Advanced "default" row, or the built-in default when no
181
+ // override is configured, so completed agents are still eventually reclaimed.
184
182
  export function resolveAgentTerminalReapMs(config, provider) {
185
183
  const key = clean(provider).toLowerCase();
186
- if (!key || key === 'default' || !Object.hasOwn(AUTO_CLEAR_PROVIDER_IDLE_MS, key)) return null;
187
184
  const raw = config?.autoClear && typeof config.autoClear === 'object' ? config.autoClear : {};
188
185
  const overrides = normalizeAutoClearProviderIdleMs(raw.providerIdleMs);
186
+ if (!key || key === 'default' || !Object.hasOwn(AUTO_CLEAR_PROVIDER_IDLE_MS, key)) {
187
+ return overrides.default ?? AUTO_CLEAR_PROVIDER_IDLE_MS.default;
188
+ }
189
189
  return overrides[key] ?? AUTO_CLEAR_PROVIDER_IDLE_MS[key];
190
190
  }
191
191
 
@@ -30,6 +30,7 @@ export function createLifecycleApi(deps) {
30
30
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
31
31
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
32
32
  pushTranscriptRebind,
33
+ notificationListeners, remoteStateListeners,
33
34
  } = deps;
34
35
  return {
35
36
  async close(reason = 'cli-exit', options = {}) {
@@ -132,6 +133,9 @@ export function createLifecycleApi(deps) {
132
133
  ok = mgr.closeSession(session.id, reason, { tombstone });
133
134
  setSession(null);
134
135
  }
136
+ invalidateContextStatusCache();
137
+ notificationListeners?.clear?.();
138
+ remoteStateListeners?.clear?.();
135
139
  const shellJobsStop = globalThis.__mixdogShellJobsRuntimeLoaded === true
136
140
  ? import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs')
137
141
  .then((mod) => mod?.shutdownShellJobs?.(reason, { sync: !detach }))
@@ -2259,6 +2259,7 @@ export async function createMixdogSessionRuntime({
2259
2259
  resolveCwdPath,
2260
2260
  agentStatusState,
2261
2261
  notificationListeners,
2262
+ remoteStateListeners,
2262
2263
  awaitInitialMcpConnect,
2263
2264
  });
2264
2265
 
@@ -33,6 +33,18 @@ export function createSessionTurnApi(deps) {
33
33
  awaitInitialMcpConnect,
34
34
  } = deps;
35
35
  return {
36
+ getTurnLiveness() {
37
+ const sessionId = getSession()?.id;
38
+ if (!sessionId || typeof mgr.getSessionProgressSnapshot !== 'function') return null;
39
+ const snapshot = mgr.getSessionProgressSnapshot(sessionId);
40
+ if (!snapshot) return null;
41
+ return {
42
+ stage: snapshot.stage,
43
+ lastProgressAt: snapshot.lastProgressAt,
44
+ toolStartedAt: snapshot.toolStartedAt,
45
+ toolSelfDeadlineMs: snapshot.toolSelfDeadlineMs,
46
+ };
47
+ },
36
48
  async ask(prompt, options = {}) {
37
49
  setActiveTurnCount(getActiveTurnCount() + 1);
38
50
  // Lazy code-graph prewarm: kick off the build ONCE, on the first real
@@ -509,7 +509,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
509
509
  for (const [tag, sessionId] of [...tags.entries()]) {
510
510
  if (indexedKeys.has(`${tag}\0${sessionId}`)) continue;
511
511
  const session = getLiveSession(sessionId);
512
- if (!session || session.closed) tags.delete(tag);
512
+ if (!session || session.closed) {
513
+ tags.delete(tag);
514
+ tagAgents.delete(tag);
515
+ tagCwds.delete(tag);
516
+ }
513
517
  }
514
518
  if (!scanSessions) return;
515
519
  for (const session of mgr.listSessions({ includeClosed: false }) || []) {
@@ -1514,6 +1518,9 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1514
1518
  }
1515
1519
  for (const timer of reapTimers.values()) clearTimeout(timer);
1516
1520
  reapTimers.clear();
1521
+ tags.clear();
1522
+ tagAgents.clear();
1523
+ tagCwds.clear();
1517
1524
  flushWorkerIndexMutations();
1518
1525
  writeWorkerRows((byKey) => byKey.clear());
1519
1526
  return { closed, failed };
package/src/tui/App.jsx CHANGED
@@ -107,7 +107,6 @@ import {
107
107
  transcriptItemVariantKey,
108
108
  transcriptMeasuredRowsCache,
109
109
  buildTranscriptRowIndex,
110
- transcriptStructureSignature,
111
110
  transcriptRenderWindow,
112
111
  } from './app/transcript-window.mjs';
113
112
  import {
@@ -2951,6 +2950,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2951
2950
  transcriptMeasureRef,
2952
2951
  } = useTranscriptWindow({
2953
2952
  items: state.items,
2953
+ structureRevision: state.structureRevision,
2954
+ streamingTail: state.streamingTail,
2954
2955
  themeEpoch: state.themeEpoch,
2955
2956
  frameColumns,
2956
2957
  toolOutputExpanded,
@@ -832,7 +832,7 @@ export function buildTranscriptRowIndexIncremental(items, {
832
832
  suppressMeasuredRowHeights = false,
833
833
  measuredRowsVersion = 0,
834
834
  cacheRef = null,
835
- prefixSig = null,
835
+ prefixRevision = null,
836
836
  } = {}) {
837
837
  const allItems = Array.isArray(items) ? items : [];
838
838
  const tail = trailingStreamingItem(allItems);
@@ -850,11 +850,10 @@ export function buildTranscriptRowIndexIncremental(items, {
850
850
  }
851
851
  const prefixLen = allItems.length - 1;
852
852
  const cache = holder.current;
853
- // Prefix identity is carried by `prefixSig` (a hash of every prefix item's
854
- // WeakMap sigPart, computed once in the hook). String equality catches a
855
- // same-length MIDDLE-item replacement (tool-card patch swaps a middle object
856
- // fresh fragment different sig), which a last-item ref check would miss.
857
- // Fast path: same prefix length + tail id + prefixSig, and columns/expanded/
853
+ // Prefix identity is carried by the engine's monotonic structure revision.
854
+ // Every settled-item mutation, including same-length middle tool-card patches,
855
+ // bumps it without requiring a render-time walk over the transcript.
856
+ // Fast path: same prefix length + tail id + revision, and columns/expanded/
858
857
  // suppress/version all match. Only the tail row can differ → recompute + append.
859
858
  if (cache
860
859
  && cache.columns === columns
@@ -862,10 +861,10 @@ export function buildTranscriptRowIndexIncremental(items, {
862
861
  && cache.suppress === suppressMeasuredRowHeights
863
862
  && cache.version === measuredRowsVersion
864
863
  && cache.prefixLen === prefixLen
865
- && prefixSig != null
866
- && cache.prefixSig === prefixSig
864
+ && prefixRevision != null
865
+ && cache.prefixRevision === prefixRevision
867
866
  && cache.tailId === tail.id) {
868
- // prefixSig matches → prefix rows provably unchanged; NO prefix walk / no
867
+ // revision matches → prefix rows provably unchanged; NO prefix walk / no
869
868
  // re-estimation. Only the tail row can differ, recompute it.
870
869
  {
871
870
  const tailMeasured = suppressMeasuredRowHeights
@@ -894,7 +893,7 @@ export function buildTranscriptRowIndexIncremental(items, {
894
893
  suppress: suppressMeasuredRowHeights,
895
894
  version: measuredRowsVersion,
896
895
  prefixLen,
897
- prefixSig,
896
+ prefixRevision,
898
897
  tailId: tail.id,
899
898
  prefixRowsArr: full.rows.slice(0, prefixLen),
900
899
  prefixPrefixRows: full.prefixRows.slice(0, prefixLen + 1),