mixdog 0.9.91 → 0.9.93

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 (79) hide show
  1. package/README.md +147 -51
  2. package/package.json +6 -5
  3. package/scripts/code-graph-description-contract.mjs +6 -8
  4. package/scripts/tmp-cdp-errors.mjs +41 -0
  5. package/scripts/tmp-cdp-inspect.mjs +41 -0
  6. package/scripts/tool-overhead-microbench.mjs +60 -0
  7. package/scripts/tui-transcript-jitter-harness.mjs +2 -18
  8. package/src/agents/debugger/agent.json +1 -1
  9. package/src/agents/explore/agent.json +1 -1
  10. package/src/agents/heavy-worker/agent.json +1 -1
  11. package/src/agents/maintainer/agent.json +1 -1
  12. package/src/agents/reviewer/agent.json +1 -1
  13. package/src/agents/worker/agent.json +1 -1
  14. package/src/lib/rules-builder.cjs +5 -5
  15. package/src/output-styles/simple.md +1 -1
  16. package/src/rules/agent/00-core.md +1 -2
  17. package/src/rules/lead/01-general.md +1 -0
  18. package/src/rules/shared/01-tool.md +19 -21
  19. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +16 -3
  20. package/src/runtime/agent/orchestrator/agent-trace.mjs +17 -0
  21. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  22. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +9 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +90 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -19
  25. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +18 -1
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +44 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +10 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +3 -0
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -0
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +35 -0
  31. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +34 -5
  32. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +35 -29
  33. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -0
  34. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +11 -2
  35. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +5 -6
  36. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +31 -2
  37. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -0
  38. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +60 -31
  39. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/session/result-classification.mjs +28 -0
  41. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +71 -2
  42. package/src/runtime/agent/orchestrator/session/store/listing.mjs +17 -0
  43. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +101 -0
  44. package/src/runtime/agent/orchestrator/session/store.mjs +30 -0
  45. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -23
  46. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -21
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +12 -6
  49. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +5 -0
  50. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -3
  51. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +47 -14
  52. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +100 -0
  53. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +6 -5
  54. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  55. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +32 -2
  56. package/src/runtime/channels/backends/discord-gateway.mjs +6 -32
  57. package/src/runtime/channels/lib/inbound-handler.mjs +19 -3
  58. package/src/runtime/channels/lib/scheduler.mjs +51 -3
  59. package/src/runtime/channels/lib/worker-main.mjs +4 -0
  60. package/src/runtime/channels/tool-defs.mjs +4 -2
  61. package/src/runtime/memory/lib/query-handlers.mjs +11 -3
  62. package/src/runtime/memory/tool-defs.mjs +5 -5
  63. package/src/runtime/shared/channel-notification-routing.mjs +8 -2
  64. package/src/runtime/shared/llm/http-agent.mjs +11 -0
  65. package/src/session-runtime/lifecycle-api.mjs +26 -1
  66. package/src/session-runtime/output-styles.mjs +1 -4
  67. package/src/session-runtime/tool-catalog-data.mjs +5 -2
  68. package/src/session-runtime/workflow.mjs +25 -13
  69. package/src/standalone/agent-tool/tag-registry.mjs +5 -1
  70. package/src/standalone/explore-tool.mjs +1 -1
  71. package/src/tui/dist/index.mjs +100 -46
  72. package/src/tui/engine/agent-envelope.mjs +52 -3
  73. package/src/tui/engine/session-api.mjs +17 -0
  74. package/src/tui/engine/tui-steering-persist.mjs +24 -1
  75. package/src/tui/engine/turn.mjs +8 -9
  76. package/src/tui/engine.mjs +18 -41
  77. package/src/workflows/default/WORKFLOW.md +7 -18
  78. package/src/workflows/solo/WORKFLOW.md +0 -6
  79. package/src/workflows/solo-bench/WORKFLOW.md +17 -0
@@ -4,7 +4,7 @@
4
4
  // they never reach an external LLM. `aiWrapped: true` routes dispatches
5
5
  // through ai-wrapped-dispatch.mjs instead of the module's handleToolCall.
6
6
  // Shared period grammar for recall/search_memories.
7
- const PERIOD_DESCRIPTION = "last (recent sessions; +query topic-filter; limit=session count), Nm/Nh/Nd, today/yesterday/this_week/last_week, all, YYYY-MM-DD, date~date, HH:MM~HH:MM, or 'YYYY-MM-DD HH:MM~HH:MM'.";
7
+ const PERIOD_DESCRIPTION = "last (recent sessions; +query topic-filter), Nm/Nh/Nd, today/yesterday/this_week/last_week, all, YYYY-MM-DD, date~date, or HH:MM~HH:MM.";
8
8
  export const TOOL_DEFS = [
9
9
  {
10
10
  name: 'memory',
@@ -48,7 +48,7 @@ export const TOOL_DEFS = [
48
48
  name: 'recall',
49
49
  title: 'Recall',
50
50
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
51
- description: 'Retrieve stored memory/session history for prior-work context (resumes, earlier decisions). Query is topic/semantic, not regex; period selects the time window; id for exact follow-up.',
51
+ description: 'Retrieve stored memory/session history (prior work, resumes, decisions). Query is semantic, not regex; period=time window; id=exact follow-up.',
52
52
  inputSchema: {
53
53
  type: 'object',
54
54
  properties: {
@@ -60,10 +60,10 @@ export const TOOL_DEFS = [
60
60
  sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
61
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.' },
62
62
  includeArchived: { type: 'boolean', description: 'Include archived entries.' },
63
- includeMembers: { type: 'boolean', description: 'Include chunk members in output; does not widen the search pool.' },
64
- includeRaw: { type: 'boolean', description: 'Include unchunked raw/episode rows.' },
63
+ includeMembers: { type: 'boolean', description: 'Include chunk members.' },
64
+ includeRaw: { type: 'boolean', description: 'Include raw/episode rows.' },
65
65
  sessionOnly: { type: 'boolean', description: 'Search this session only.' },
66
- projectScope: { type: 'string', description: 'Project pool selector: inferred from cwd, common, all, or slug.' },
66
+ projectScope: { type: 'string', description: 'Pool: inferred from cwd, common, all, or slug.' },
67
67
  },
68
68
  additionalProperties: false,
69
69
  },
@@ -7,6 +7,12 @@ export function channelNotificationModelContent(params = {}) {
7
7
  }
8
8
 
9
9
  export function shouldMirrorChannelNotificationToPending(meta = {}) {
10
- const type = String(meta?.type || '').trim().toLowerCase();
11
- return type === 'schedule';
10
+ // CC parity (reference messageQueueManager.ts): every inbound channel/
11
+ // schedule notification flows through the single queue — when no live
12
+ // listener handled it (engine not subscribed yet, pane disposed, headless),
13
+ // it is mirrored into the session pending queue for the next turn instead
14
+ // of being dropped. Silent-to-agent stays excluded; the upstream content
15
+ // gate (channelNotificationModelContent → '') already enforces it, this is
16
+ // a defensive second gate.
17
+ return !(meta?.silent_to_agent === true || meta?.silent_to_agent === 'true');
12
18
  }
@@ -88,6 +88,17 @@ export function getLlmDispatcher() {
88
88
  keepAliveTimeout: envInt('MIXDOG_LLM_KEEPALIVE_MS', 60_000),
89
89
  keepAliveMaxTimeout: envInt('MIXDOG_LLM_KEEPALIVE_MAX_MS', 90_000),
90
90
  connections: envInt('MIXDOG_LLM_CONNECTIONS', 16),
91
+ // undici defaults both of these to 300s, which beheaded the Anthropic
92
+ // NON-STREAMING stall fallback (2026-08-03 regex-chess probe:
93
+ // UND_ERR_HEADERS_TIMEOUT at exactly 300s) — a non-streaming completion
94
+ // sends NO response headers until generation finishes, so a long silent
95
+ // generation legitimately exceeds 300s of header wait. Raise both to
96
+ // 600s: first-byte/idle protection is owned by the app-level watchdogs
97
+ // (provider first-byte + semantic idle + agent stall abort), which all
98
+ // fire below this transport ceiling; this layer is only the last-resort
99
+ // dead-socket bound.
100
+ headersTimeout: envInt('MIXDOG_LLM_HEADERS_TIMEOUT_MS', 600_000),
101
+ bodyTimeout: envInt('MIXDOG_LLM_BODY_TIMEOUT_MS', 600_000),
91
102
  })
92
103
  }
93
104
  // mixdog standalone: separate undici instance from Node's fetch undici, so
@@ -322,8 +322,27 @@ export function createLifecycleApi(deps) {
322
322
  const available = listLeadSessions({ refreshFromStorage: true })
323
323
  .some(row => row.id === sessionId);
324
324
  if (!available) return false;
325
+ let ownedAgentSessionIds = [];
326
+ try {
327
+ const candidates = mgr.listOwnedAgentSessionIds?.(sessionId);
328
+ if (Array.isArray(candidates)) {
329
+ ownedAgentSessionIds = [...new Set(candidates
330
+ .map(clean)
331
+ .filter(childId => childId && childId !== sessionId && /^[A-Za-z0-9_-]+$/.test(childId)))];
332
+ }
333
+ } catch { /* parent deletion remains available if child enumeration fails */ }
325
334
  const current = getSession();
326
- if (current?.id !== sessionId) return mgr.deleteSession(sessionId) === true;
335
+ if (current?.id !== sessionId) {
336
+ const deleted = mgr.deleteSession(sessionId) === true;
337
+ if (!deleted) return false;
338
+ // The parent is already irreversibly gone, so child cleanup is
339
+ // best-effort and idempotent. Any vetoed child becomes sweep-eligible
340
+ // because its retained-parent proof disappeared with the parent.
341
+ for (const childId of ownedAgentSessionIds) {
342
+ try { mgr.deleteSession(childId); } catch {}
343
+ }
344
+ return true;
345
+ }
327
346
 
328
347
  const cleanupReason = 'desktop-session-delete';
329
348
  try {
@@ -339,6 +358,12 @@ export function createLifecycleApi(deps) {
339
358
  // immediately would let a late provider/save continuation resurrect the
340
359
  // deleted conversation after the user has moved to its replacement.
341
360
  if (mgr.closeSession(sessionId, cleanupReason, { tombstone: true }) !== true) return false;
361
+ // Active parent deletion uses the same durable tombstone boundary for
362
+ // every linked child. Their files then mature with the parent instead of
363
+ // disappearing while the parent task is still retained.
364
+ for (const childId of ownedAgentSessionIds) {
365
+ try { mgr.closeSession(childId, cleanupReason, { tombstone: true }); } catch {}
366
+ }
342
367
  setSession(null);
343
368
  invalidateContextStatusCache();
344
369
  invalidatePreSessionToolSurface();
@@ -5,11 +5,8 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import { clean } from './session-text.mjs';
6
6
  import { readJsonSafe } from './fs-utils.mjs';
7
7
 
8
- const OUTPUT_STYLE_ORDER = ['simple', 'detailed', 'minimal', 'extreme-minimal'];
8
+ const OUTPUT_STYLE_ORDER = ['detailed', 'simple', 'minimal', 'extreme-minimal'];
9
9
  const OUTPUT_STYLE_ALIASES = new Map([
10
- ['compact', 'simple'],
11
- ['normal', 'simple'],
12
- ['detail', 'detailed'],
13
10
  ['extreme', 'extreme-minimal'],
14
11
  ['extremesimple', 'extreme-minimal'],
15
12
  ['extreme-simple', 'extreme-minimal'],
@@ -26,8 +26,11 @@ export const DEFERRED_DEFAULT_READONLY_TOOLS = Object.freeze([
26
26
  ]);
27
27
  export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
28
28
  'read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task',
29
- 'explore', 'apply_patch', 'agent', 'recall', 'search', 'web_fetch', 'cwd',
30
- 'session_manage', 'Skill', 'load_tool',
29
+ // cwd / session_manage / web_fetch demoted to the deferred manifest 2026-08:
30
+ // 0 / 0 / 10 calls in a 3-day 7.6k-call trace window; they auto-load on
31
+ // first direct call.
32
+ 'explore', 'apply_patch', 'agent', 'recall', 'search',
33
+ 'Skill', 'load_tool',
31
34
  ]);
32
35
 
33
36
  export const READONLY_TOOL_NAMES = new Set([
@@ -16,12 +16,18 @@ export const FIXED_AGENT_SLOTS = Object.freeze([
16
16
  // (user: 창이 크지 않으니 설명은 짧게).
17
17
  { id: 'explore', label: 'Explore', description: 'Repository exploration', workflowSlot: 'explorer' },
18
18
  { id: 'maintainer', label: 'Maintainer', description: 'Memory and upkeep', workflowSlot: 'memory' },
19
- { id: 'worker', label: 'Worker', description: 'Scoped implementation' },
20
- { id: 'heavy-worker', label: 'Heavy Worker', description: 'Multi-file implementation' },
19
+ { id: 'worker', label: 'Worker', description: 'Simple tasks' },
20
+ { id: 'heavy-worker', label: 'Heavy Worker', description: 'Complex tasks' },
21
21
  { id: 'reviewer', label: 'Reviewer', description: 'Diff and risk review' },
22
22
  { id: 'debugger', label: 'Debugger', description: 'Root-cause debugging' },
23
23
  ]);
24
24
  const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
25
+ // Slot-backed built-ins (explore/maintainer) run through their own dedicated
26
+ // channels — the explore tool and the memory cycle — so they are never
27
+ // Lead-delegation targets and stay out of the Available Agents catalog.
28
+ const BUILTIN_SLOT_AGENT_IDS = new Set(
29
+ FIXED_AGENT_SLOTS.filter((agent) => agent.workflowSlot).map((agent) => agent.id),
30
+ );
25
31
  export const DEFAULT_WORKFLOW_ID = 'default';
26
32
 
27
33
  const SEARCH_CAPABLE_PROVIDERS = new Set([
@@ -202,11 +208,13 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
202
208
  if (pack && !pack.hidden) byId.set(pack.id, pack);
203
209
  }
204
210
  }
205
- return [...byId.values()].sort((a, b) => {
206
- if (a.id === DEFAULT_WORKFLOW_ID) return -1;
207
- if (b.id === DEFAULT_WORKFLOW_ID) return 1;
208
- return a.name.localeCompare(b.name);
209
- });
211
+ // Solo leads (user decision: solo is the default working mode), the
212
+ // cowork pack (built-in id `default`) second, then customs alphabetically
213
+ // — every picker (TUI, desktop sidebar, onboarding) shares this order.
214
+ const weight = (pack) => pack.id === 'solo' ? 0
215
+ : (pack.id === DEFAULT_WORKFLOW_ID || pack.id === 'cowork') ? 1 : 2;
216
+ return [...byId.values()].sort((a, b) =>
217
+ (weight(a) - weight(b)) || a.name.localeCompare(b.name));
210
218
  }
211
219
 
212
220
  function activeWorkflowId(config) {
@@ -282,16 +290,20 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
282
290
  lines.push(pack.body);
283
291
  // A hand-edited pack may name a hidden role in its `agents:` frontmatter;
284
292
  // internal roles are never delegatable, so they never enter the catalog.
293
+ // Slot-backed built-ins are equally non-delegatable (they ride the explore
294
+ // tool / memory cycle), so they are filtered even when a pack names them.
285
295
  const agentIds = (pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id))
286
- .filter((id) => !isHiddenAgent(id));
296
+ .filter((id) => !isHiddenAgent(id) && !BUILTIN_SLOT_AGENT_IDS.has(id));
287
297
  const agentBlocks = agentIds.map((id) => loadAgentDefinition(dir, id)).filter(Boolean);
288
298
  if (agentBlocks.length) {
289
299
  lines.push('# Available Agents');
290
- for (const agent of agentBlocks) {
291
- lines.push(`## ${agent.name} (${agent.id})`);
292
- if (agent.description) lines.push(agent.description);
293
- lines.push(agent.body);
294
- }
300
+ // Name + description only: the AGENT.md body is the worker's own system
301
+ // prompt and rides in the worker session at spawn time — repeating it in
302
+ // the Lead prompt only bloats context. Lead picks agents by description
303
+ // (Claude Code whenToUse pattern); the workflow body carries the rules.
304
+ lines.push(agentBlocks
305
+ .map((agent) => `- ${agent.name} (${agent.id})${agent.description ? `: ${agent.description}` : ''}`)
306
+ .join('\n'));
295
307
  }
296
308
  return lines.join('\n\n');
297
309
  }
@@ -259,7 +259,11 @@ export function createTagRegistry({
259
259
  const tag = tagForSession(sessionId);
260
260
  tombstoneTerminalSession(tag, sessionId, session);
261
261
  clearAgentStatuslineRoute(sessionId);
262
- try { mgr.closeSession(sessionId, 'terminal-reap'); } catch {}
262
+ // Reaping expires only the reusable tag/runtime lease. The transcript is
263
+ // user-visible from the parent task's agent tab, so terminal cleanup must
264
+ // never tombstone it ahead of that parent. The normal completion path
265
+ // unloads immediately; this delayed call is an idempotent race fallback.
266
+ try { mgr.unloadSessionRuntime?.(sessionId, 'terminal-reap'); } catch {}
263
267
  }, reapMs);
264
268
  handle.unref?.();
265
269
  reapTimers.set(sessionId, handle);
@@ -18,7 +18,7 @@ export const EXPLORE_TOOL = {
18
18
  name: 'explore',
19
19
  title: 'Explore',
20
20
  annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
21
- description: 'Locator for broad/uncertain targets with no known path, repo or machine-wide (dot dirs included). query[] fans out up to 8 independent facets in parallel.',
21
+ description: 'Repo- or machine-wide locator (dot dirs included). query[] fans out up to 8 independent facets in parallel.',
22
22
  inputSchema: {
23
23
  type: 'object',
24
24
  properties: {
@@ -25135,7 +25135,7 @@ function parseAgentResultEnvelope(text, fallback = {}) {
25135
25135
  fast: fallback.fast ?? attrs.fast
25136
25136
  },
25137
25137
  result: body || agentJobStatusText({ status: fallback.status || attrs.status || "completed", taskId: fallback.taskId || attrs.task_id || attrs.taskid || "" }),
25138
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(fallback.status || attrs.status || "")
25138
+ isError: /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(fallback.status || attrs.status || "")
25139
25139
  };
25140
25140
  }
25141
25141
  function parseBackgroundTaskEnvelope(text) {
@@ -25193,7 +25193,7 @@ function parseBackgroundTaskEnvelope(text) {
25193
25193
  },
25194
25194
  result: resultBody || (!errorText ? [status ? `status: ${status}` : "", taskId ? `task_id: ${taskId}` : ""].filter(Boolean).join(" \xB7 ") : ""),
25195
25195
  rawResult: value,
25196
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(status) || /^error:/i.test(body) || Boolean(errorText)
25196
+ isError: /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(status) || /^error:/i.test(body) || Boolean(errorText)
25197
25197
  };
25198
25198
  }
25199
25199
  function isStatusOnlyAgentCompletionNotification(text) {
@@ -25255,7 +25255,7 @@ function parseSyntheticAgentMessage(text) {
25255
25255
  label,
25256
25256
  args: agentArgsWithResultMetadata({ type: agentJob.type || "notification", description: "agent notification" }, agentJob),
25257
25257
  result: result || agentJobStatusText(agentJob) || "agent notification",
25258
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(label)
25258
+ isError: /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(label)
25259
25259
  };
25260
25260
  }
25261
25261
  if (/<task-notification\b/i.test(value)) {
@@ -25273,6 +25273,54 @@ function parseSyntheticAgentMessage(text) {
25273
25273
  }
25274
25274
  return null;
25275
25275
  }
25276
+ function buildExecutionResponseToolItem(text, {
25277
+ id,
25278
+ responseKey = "",
25279
+ now = Date.now()
25280
+ } = {}) {
25281
+ const synthetic = parseSyntheticAgentMessage(text);
25282
+ if (!synthetic) return null;
25283
+ const label = synthetic.label || "notification";
25284
+ const isAgent = synthetic.name === "agent";
25285
+ const args = {
25286
+ ...synthetic.args && typeof synthetic.args === "object" ? synthetic.args : {
25287
+ type: label,
25288
+ task_id: synthetic.taskId || void 0,
25289
+ description: synthetic.summary || "execution notification"
25290
+ },
25291
+ ...isAgent ? { type: "result" } : {}
25292
+ };
25293
+ const rawResult = synthetic.rawResult ?? text;
25294
+ const responseHasBody = /\n\s*\n[\s\S]*\S/.test(String(text || ""));
25295
+ const key = String(responseKey || args.task_id || "").trim();
25296
+ return {
25297
+ kind: "tool",
25298
+ id,
25299
+ name: synthetic.name || "task",
25300
+ args,
25301
+ result: synthetic.result,
25302
+ rawResult,
25303
+ isError: synthetic.isError ?? /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(label),
25304
+ expanded: false,
25305
+ count: 1,
25306
+ completedCount: 1,
25307
+ startedAt: now,
25308
+ completedAt: now,
25309
+ ...isAgent ? {
25310
+ agentDirection: "inbound",
25311
+ agentResponseKey: key,
25312
+ agentResponseHasBody: responseHasBody,
25313
+ agentResponseAggregate: false,
25314
+ agentResponseEntries: [{
25315
+ key,
25316
+ raw: String(rawResult ?? "").trim(),
25317
+ result: synthetic.result,
25318
+ hasBody: responseHasBody,
25319
+ isError: synthetic.isError === true
25320
+ }]
25321
+ } : {}
25322
+ };
25323
+ }
25276
25324
  function parseAgentJob(text) {
25277
25325
  const value = String(text || "");
25278
25326
  const idMatch = /^agent task:\s*([^\s]+)/m.exec(value) || /^task_id:\s*([^\s]+)/m.exec(value);
@@ -26573,6 +26621,7 @@ function drainTuiSteeringPersist(leadSessionId) {
26573
26621
  return _serialize(async () => {
26574
26622
  let drained = [];
26575
26623
  let droppedStale = 0;
26624
+ let prunedOrphans = 0;
26576
26625
  try {
26577
26626
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
26578
26627
  const next = normalizePendingStore(raw);
@@ -26586,7 +26635,21 @@ function drainTuiSteeringPersist(leadSessionId) {
26586
26635
  return !stale;
26587
26636
  });
26588
26637
  drained = fresh.map(drainedRowToRestore).filter(Boolean);
26589
- if (drained.length === 0 && droppedStale === 0) return void 0;
26638
+ for (const otherKey of Object.keys(next.sessions)) {
26639
+ if (otherKey === key) continue;
26640
+ const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
26641
+ const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
26642
+ const allStale = rows.every((row) => {
26643
+ const at = Number(row?.at) || otherTouched;
26644
+ return at > 0 && now - at > STALE_STEERING_RESTORE_TTL_MS;
26645
+ });
26646
+ if (rows.length === 0 || allStale) {
26647
+ delete next.sessions[otherKey];
26648
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
26649
+ prunedOrphans += rows.length;
26650
+ }
26651
+ }
26652
+ if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return void 0;
26590
26653
  delete next.sessions[key];
26591
26654
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
26592
26655
  next.updatedAt = Date.now();
@@ -26602,6 +26665,13 @@ function drainTuiSteeringPersist(leadSessionId) {
26602
26665
  if (droppedStale > 0) {
26603
26666
  try {
26604
26667
  process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}
26668
+ `);
26669
+ } catch {
26670
+ }
26671
+ }
26672
+ if (prunedOrphans > 0) {
26673
+ try {
26674
+ process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions
26605
26675
  `);
26606
26676
  } catch {
26607
26677
  }
@@ -27403,6 +27473,7 @@ function createTurnWatchdog({
27403
27473
  }
27404
27474
 
27405
27475
  // src/tui/engine/turn.mjs
27476
+ var STREAM_BATCH_INTERVAL_MS = TUI_FRAME_MS;
27406
27477
  function createRunTurn(bag) {
27407
27478
  const {
27408
27479
  runtime,
@@ -27893,7 +27964,6 @@ function createRunTurn(bag) {
27893
27964
  thinkingSegmentStartedAt = 0;
27894
27965
  return now;
27895
27966
  };
27896
- const STREAM_BATCH_INTERVAL_MS = 16;
27897
27967
  let _batchTimer = null;
27898
27968
  let _pendingTextFlush = false;
27899
27969
  let _pendingThinkFlush = false;
@@ -30212,6 +30282,15 @@ function createEngineApiA(bag) {
30212
30282
  restoreState.requeueEntries = [];
30213
30283
  restoreState.discardExecutionPendingResumeKeys = [];
30214
30284
  }
30285
+ const pendingAfterAbortKick = setTimeout(() => {
30286
+ try {
30287
+ if (flags.disposed) return;
30288
+ if (getState().busy) return;
30289
+ if (pending.length > 0 && typeof drain === "function") void drain();
30290
+ } catch {
30291
+ }
30292
+ }, 150);
30293
+ pendingAfterAbortKick.unref?.();
30215
30294
  const abortEpoch = flags.leadTurnEpoch;
30216
30295
  const recoveryMs = Number(flags.manualAbortRecoveryMs) > 0 ? Number(flags.manualAbortRecoveryMs) : MANUAL_ABORT_RECOVERY_MS;
30217
30296
  const recoveryTimer = setTimeout(() => {
@@ -31787,56 +31866,31 @@ async function createEngineSession({
31787
31866
  });
31788
31867
  };
31789
31868
  const pushAsyncAgentResponse = (text, id = nextId(), origin = "injected", metadata = {}) => {
31790
- const synthetic = parseSyntheticAgentMessage(text);
31791
- const isAgent = synthetic?.name === "agent";
31792
- if (!isAgent) return pushUserOrSyntheticItem(text, id, origin);
31793
- const responseHasBody = /\n\s*\n[\s\S]*\S/.test(String(text || ""));
31794
- const rawResult = synthetic.rawResult ?? text;
31795
- const args = {
31796
- ...synthetic.args && typeof synthetic.args === "object" ? synthetic.args : {},
31797
- type: "result"
31798
- };
31799
- const responseKey = String(metadata.responseKey || metadata.executionId || args.task_id || "").trim();
31869
+ const responseItem = buildExecutionResponseToolItem(text, {
31870
+ id,
31871
+ responseKey: metadata.responseKey || metadata.executionId
31872
+ });
31873
+ if (!responseItem) return pushUserOrSyntheticItem(text, id, origin);
31874
+ if (responseItem.name !== "agent") {
31875
+ pushItem(responseItem);
31876
+ return true;
31877
+ }
31800
31878
  const previous = state.items.at(-1);
31801
31879
  if (previous?.kind === "tool" && previous.agentDirection === "inbound") {
31802
31880
  const patch = appendAgentResponseTail(previous, {
31803
- key: responseKey,
31804
- args,
31805
- result: synthetic.result,
31806
- rawResult,
31807
- hasBody: responseHasBody,
31808
- isError: synthetic.isError === true
31881
+ key: responseItem.agentResponseKey,
31882
+ args: responseItem.args,
31883
+ result: responseItem.result,
31884
+ rawResult: responseItem.rawResult,
31885
+ hasBody: responseItem.agentResponseHasBody,
31886
+ isError: responseItem.isError
31809
31887
  });
31810
31888
  if (patch) {
31811
31889
  patchItem(previous.id, patch);
31812
31890
  return true;
31813
31891
  }
31814
31892
  }
31815
- pushItem({
31816
- kind: "tool",
31817
- id,
31818
- name: "agent",
31819
- args,
31820
- result: synthetic.result,
31821
- rawResult,
31822
- isError: synthetic.isError === true,
31823
- expanded: false,
31824
- count: 1,
31825
- completedCount: 1,
31826
- startedAt: Date.now(),
31827
- completedAt: Date.now(),
31828
- agentDirection: "inbound",
31829
- agentResponseKey: responseKey,
31830
- agentResponseHasBody: responseHasBody,
31831
- agentResponseAggregate: false,
31832
- agentResponseEntries: [{
31833
- key: responseKey,
31834
- raw: String(rawResult ?? "").trim(),
31835
- result: synthetic.result,
31836
- hasBody: responseHasBody,
31837
- isError: synthetic.isError === true
31838
- }]
31839
- });
31893
+ pushItem(responseItem);
31840
31894
  return true;
31841
31895
  };
31842
31896
  const pushToast = (text, tone = "info", ttlMs = 3e3) => {
@@ -84,7 +84,7 @@ export function parseAgentResultEnvelope(text, fallback = {}) {
84
84
  fast: fallback.fast ?? attrs.fast,
85
85
  },
86
86
  result: body || agentJobStatusText({ status: fallback.status || attrs.status || 'completed', taskId: fallback.taskId || attrs.task_id || attrs.taskid || '' }),
87
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(fallback.status || attrs.status || ''),
87
+ isError: /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(fallback.status || attrs.status || ''),
88
88
  };
89
89
  }
90
90
 
@@ -143,7 +143,7 @@ export function parseBackgroundTaskEnvelope(text) {
143
143
  },
144
144
  result: resultBody || (!errorText ? [status ? `status: ${status}` : '', taskId ? `task_id: ${taskId}` : ''].filter(Boolean).join(' · ') : ''),
145
145
  rawResult: value,
146
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(status) || /^error:/i.test(body) || Boolean(errorText),
146
+ isError: /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(status) || /^error:/i.test(body) || Boolean(errorText),
147
147
  };
148
148
  }
149
149
 
@@ -223,7 +223,7 @@ export function parseSyntheticAgentMessage(text) {
223
223
  label,
224
224
  args: agentArgsWithResultMetadata({ type: agentJob.type || 'notification', description: 'agent notification' }, agentJob),
225
225
  result: result || agentJobStatusText(agentJob) || 'agent notification',
226
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(label),
226
+ isError: /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(label),
227
227
  };
228
228
  }
229
229
  if (/<task-notification\b/i.test(value)) {
@@ -242,6 +242,55 @@ export function parseSyntheticAgentMessage(text) {
242
242
  return null;
243
243
  }
244
244
 
245
+ export function buildExecutionResponseToolItem(text, {
246
+ id,
247
+ responseKey = '',
248
+ now = Date.now(),
249
+ } = {}) {
250
+ const synthetic = parseSyntheticAgentMessage(text);
251
+ if (!synthetic) return null;
252
+ const label = synthetic.label || 'notification';
253
+ const isAgent = synthetic.name === 'agent';
254
+ const args = {
255
+ ...(synthetic.args && typeof synthetic.args === 'object' ? synthetic.args : {
256
+ type: label,
257
+ task_id: synthetic.taskId || undefined,
258
+ description: synthetic.summary || 'execution notification',
259
+ }),
260
+ ...(isAgent ? { type: 'result' } : {}),
261
+ };
262
+ const rawResult = synthetic.rawResult ?? text;
263
+ const responseHasBody = /\n\s*\n[\s\S]*\S/.test(String(text || ''));
264
+ const key = String(responseKey || args.task_id || '').trim();
265
+ return {
266
+ kind: 'tool',
267
+ id,
268
+ name: synthetic.name || 'task',
269
+ args,
270
+ result: synthetic.result,
271
+ rawResult,
272
+ isError: synthetic.isError ?? /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(label),
273
+ expanded: false,
274
+ count: 1,
275
+ completedCount: 1,
276
+ startedAt: now,
277
+ completedAt: now,
278
+ ...(isAgent ? {
279
+ agentDirection: 'inbound',
280
+ agentResponseKey: key,
281
+ agentResponseHasBody: responseHasBody,
282
+ agentResponseAggregate: false,
283
+ agentResponseEntries: [{
284
+ key,
285
+ raw: String(rawResult ?? '').trim(),
286
+ result: synthetic.result,
287
+ hasBody: responseHasBody,
288
+ isError: synthetic.isError === true,
289
+ }],
290
+ } : {}),
291
+ };
292
+ }
293
+
245
294
  export function parseAgentJob(text) {
246
295
  const value = String(text || '');
247
296
  const idMatch = /^agent task:\s*([^\s]+)/m.exec(value) || /^task_id:\s*([^\s]+)/m.exec(value);
@@ -616,6 +616,23 @@ export function createEngineApiA(bag) {
616
616
  // ── Bounded manual-abort recovery ───────────────────────────────────
617
617
  // runtime.abort() above normally rejects the in-flight runtime.ask() so
618
618
  // the turn's own finally clears busy within a tick. If that unwind is
619
+ // starved the recovery timer below hard-releases busy. Separately from
620
+ // recovery: queued work must never strand behind a cancelled turn
621
+ // (Codex parity — abort preserves pending input for the next turn; CC
622
+ // parity — the command queue survives cancel and fires when idle). The
623
+ // drain loop that owns a normal turn continues on its own; this bounded
624
+ // kick covers unwinds where no drain owner re-checks pending after busy
625
+ // clears. drain() self-guards (busy/draining/commandBusy), so the
626
+ // drain-owned path is unchanged and a duplicate kick is a no-op.
627
+ const pendingAfterAbortKick = setTimeout(() => {
628
+ try {
629
+ if (flags.disposed) return;
630
+ if (getState().busy) return;
631
+ if (pending.length > 0 && typeof drain === 'function') void drain();
632
+ } catch { /* best-effort */ }
633
+ }, 150);
634
+ pendingAfterAbortKick.unref?.();
635
+ // If that unwind is
619
636
  // starved — e.g. a provider abort that never settles after a post-tool
620
637
  // fetch stall — busy would stay true until the far-larger turn watchdog
621
638
  // trips, wedging the TUI with dead input. Arm a short grace timer that
@@ -195,6 +195,7 @@ export function drainTuiSteeringPersist(leadSessionId) {
195
195
  return _serialize(async () => {
196
196
  let drained = [];
197
197
  let droppedStale = 0;
198
+ let prunedOrphans = 0;
198
199
  try {
199
200
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
200
201
  const next = normalizePendingStore(raw);
@@ -210,7 +211,26 @@ export function drainTuiSteeringPersist(leadSessionId) {
210
211
  return !stale;
211
212
  });
212
213
  drained = fresh.map(drainedRowToRestore).filter(Boolean);
213
- if (drained.length === 0 && droppedStale === 0) return undefined;
214
+ // Orphan cleanup: buckets of OTHER sessions whose every row already
215
+ // aged past the restore TTL can never be restored (restore is keyed
216
+ // by the live session id), so they only grow the file forever
217
+ // (observed live: queued rows from sessions closed days ago). Prune
218
+ // them under the same lock/write.
219
+ for (const otherKey of Object.keys(next.sessions)) {
220
+ if (otherKey === key) continue;
221
+ const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
222
+ const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
223
+ const allStale = rows.every((row) => {
224
+ const at = Number(row?.at) || otherTouched;
225
+ return at > 0 && (now - at) > STALE_STEERING_RESTORE_TTL_MS;
226
+ });
227
+ if (rows.length === 0 || allStale) {
228
+ delete next.sessions[otherKey];
229
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
230
+ prunedOrphans += rows.length;
231
+ }
232
+ }
233
+ if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return undefined;
214
234
  delete next.sessions[key];
215
235
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
216
236
  next.updatedAt = Date.now();
@@ -222,6 +242,9 @@ export function drainTuiSteeringPersist(leadSessionId) {
222
242
  if (droppedStale > 0) {
223
243
  try { process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}\n`); } catch {}
224
244
  }
245
+ if (prunedOrphans > 0) {
246
+ try { process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions\n`); } catch {}
247
+ }
225
248
  return drained;
226
249
  });
227
250
  }