mixdog 0.9.41 → 0.9.44

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 (73) hide show
  1. package/package.json +3 -2
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/ansi-color-capability-test.mjs +90 -0
  4. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  5. package/scripts/arg-guard-test.mjs +16 -0
  6. package/scripts/compact-pressure-test.mjs +256 -1
  7. package/scripts/generate-runtime-manifest.mjs +39 -22
  8. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  9. package/scripts/internal-comms-bench.mjs +0 -1
  10. package/scripts/internal-comms-smoke.mjs +38 -39
  11. package/scripts/internal-tools-normalization-test.mjs +52 -0
  12. package/scripts/maintenance-default-routes-test.mjs +164 -0
  13. package/scripts/max-output-recovery-test.mjs +49 -0
  14. package/scripts/mcp-client-normalization-test.mjs +45 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  16. package/scripts/provider-toolcall-test.mjs +23 -0
  17. package/scripts/result-classification-test.mjs +75 -0
  18. package/scripts/routing-corpus.mjs +1 -1
  19. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  20. package/scripts/smoke.mjs +1 -1
  21. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  22. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  23. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  24. package/scripts/tui-transcript-perf-test.mjs +56 -1
  25. package/src/agents/reviewer/AGENT.md +5 -3
  26. package/src/rules/lead/lead-brief.md +5 -4
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  28. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  29. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  34. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  35. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  39. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  44. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  45. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  46. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  48. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  49. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  50. package/src/runtime/memory/tool-defs.mjs +4 -3
  51. package/src/runtime/shared/tool-surface.mjs +1 -2
  52. package/src/session-runtime/context-status.mjs +8 -2
  53. package/src/standalone/agent-tool/render.mjs +2 -0
  54. package/src/standalone/agent-tool.mjs +267 -72
  55. package/src/standalone/explore-tool.mjs +17 -16
  56. package/src/tui/app/provider-setup-picker.mjs +1 -0
  57. package/src/tui/app/use-transcript-window.mjs +5 -1
  58. package/src/tui/components/StatusLine.jsx +11 -32
  59. package/src/tui/dist/index.mjs +257 -106
  60. package/src/tui/engine/session-api.mjs +3 -0
  61. package/src/tui/engine/session-flow.mjs +19 -8
  62. package/src/tui/engine/turn.mjs +24 -2
  63. package/src/tui/engine.mjs +0 -1
  64. package/src/tui/index.jsx +3 -2
  65. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  66. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  67. package/src/ui/ansi.mjs +85 -5
  68. package/src/ui/statusline-agents.mjs +0 -8
  69. package/src/ui/statusline-format.mjs +7 -7
  70. package/src/ui/statusline.mjs +2 -4
  71. package/src/workflows/default/WORKFLOW.md +29 -20
  72. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  73. package/src/workflows/bench/WORKFLOW.md +0 -60
@@ -985,10 +985,12 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
985
985
  // the execution policy on Windows PowerShell; pwsh on macOS/Linux
986
986
  // accepts the parameter as a no-op, so it is safe unconditionally.
987
987
  " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $innerPath)",
988
- // -WindowStyle is a Windows-only Start-Process parameter; pwsh on
989
- // macOS/Linux throws "not supported on this platform". Add it only on win32.
988
+ // Do not set WindowStyle: Hidden creates a separate transient conhost.
989
+ // On Windows, explicitly reuse the wrapper's CREATE_NO_WINDOW console.
990
+ // NoNewWindow is unavailable on non-Windows pwsh, so only add it for
991
+ // Windows (where $IsWindows is absent/null in Windows PowerShell 5.1).
990
992
  ' $spArgs = @{ FilePath = $exe; ArgumentList = $argList; RedirectStandardOutput = $stdoutPath; RedirectStandardError = $stderrPath; PassThru = $true }',
991
- ' if ($IsWindows -or $null -eq $IsWindows) { $spArgs[\'WindowStyle\'] = \'Hidden\' }',
993
+ ' if ($IsWindows -or $null -eq $IsWindows) { $spArgs[\'NoNewWindow\'] = $true }',
992
994
  ' $p = Start-Process @spArgs',
993
995
  ' if ($timeoutMs -gt 0 -and -not $p.WaitForExit($timeoutMs)) {',
994
996
  // Kill the whole process TREE, not just the direct child: Start-Process
@@ -1039,8 +1041,8 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
1039
1041
  // No `-WindowStyle Hidden` CLI switch: windowsHide:true on the spawn below
1040
1042
  // already gives CREATE_NO_WINDOW, and the visible command-line token trips
1041
1043
  // Defender's hidden-PowerShell dropper signature (PowhidSubExec). The
1042
- // in-wrapper Start-Process WindowStyle=Hidden (above) stays it lives in
1043
- // the staged .ps1, not on the command line, and is still needed there.
1044
+ // in-wrapper Start-Process uses Windows-only NoNewWindow to reuse this
1045
+ // hidden console rather than creating a transient conhost window.
1044
1046
  // `-ExecutionPolicy` only applies to Windows PowerShell; build per-platform.
1045
1047
  const isWin = process.platform === 'win32';
1046
1048
  const wrapperArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "generated_at": "2026-06-30T06:23:20.385Z",
3
+ "generated_at": "2026-07-13T06:08:53.718Z",
4
4
  "release_tag": "runtime-v0.4.0",
5
5
  "pg": {
6
6
  "major": 16,
@@ -12,28 +12,28 @@
12
12
  "assets": {
13
13
  "darwin-arm64": {
14
14
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-darwin-arm64-pg16.4-pgvector0.8.2.tar.gz",
15
- "sha256": "7a642006ef60ccf8c385ba89c761c2b6f4927f6f01a1c4fb152604e6b41fe3d2",
16
- "size": 23397361
15
+ "sha256": "c11f4b3046f0eeecf88790d2fff88cee1834231f96c11ec315a7a97180ea1413",
16
+ "size": 23397436
17
17
  },
18
18
  "darwin-x64": {
19
19
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-darwin-x64-pg16.4-pgvector0.8.2.tar.gz",
20
- "sha256": "8e39bfc3f06d88e9854946d5d83f7d77d9530f99284d7c1c51872c12f389f7e7",
21
- "size": 23687666
20
+ "sha256": "ef93b837b1e3ea0b30591f2d31de10f9a628db88ad00183d271d539fe3cb0adb",
21
+ "size": 23687427
22
22
  },
23
23
  "linux-arm64": {
24
24
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-linux-arm64-pg16.4-pgvector0.8.2.tar.gz",
25
- "sha256": "64ff170013b9543ad8843c98cbfe14019e6be6ea3d142ec189b2bea0dd1dc797",
26
- "size": 22885036
25
+ "sha256": "288d566fd38dd80ce2dcfa68083a76b6a9400fbd8020453cf72197d3cc161315",
26
+ "size": 22884871
27
27
  },
28
28
  "linux-x64": {
29
29
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-linux-x64-pg16.4-pgvector0.8.2.tar.gz",
30
- "sha256": "28ee64125a0125ff36c47560c08ce8f38e49aef034c010c7e4867e8cdd783385",
31
- "size": 23276106
30
+ "sha256": "31b40d32d51afb85963e33557c3140e378b21c7cc54cec794049276a2160afd3",
31
+ "size": 23282291
32
32
  },
33
33
  "win32-x64": {
34
34
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-win32-x64-pg16.4-pgvector0.8.2.tar.gz",
35
- "sha256": "6faed8a49b3303b0adb1c5715b2b19c5cc47df2e738d586d0d0bd432f1ca035d",
36
- "size": 44036958
35
+ "sha256": "5d3d9d6e3ae4a93bb0bde615365bdda3ab5ff7206c0d1a066ed51b316932a8f8",
36
+ "size": 44037023
37
37
  }
38
38
  }
39
39
  }
@@ -37,7 +37,6 @@ const BUNDLED_MANIFEST_PATH = fileURLToPath(new URL('../data/runtime-manifest.js
37
37
 
38
38
  // GitHub raw URL fallback — used only when no cached or bundled manifest exists.
39
39
  const MANIFEST_URL = 'https://raw.githubusercontent.com/tribgames/mixdog/main/src/runtime/memory/data/runtime-manifest.json'
40
- const LEGACY_RUNTIME_RELEASE_REPOSITORY = 'trib-plugin/mixdog'
41
40
 
42
41
  // ---------------------------------------------------------------------------
43
42
  // Platform key
@@ -183,7 +182,7 @@ function runtimeAssetUrlCandidates(url) {
183
182
  .split(/[\s,;]+/u)
184
183
  .map((repo) => repo.trim())
185
184
  .filter(Boolean)
186
- for (const repo of [...overrides, LEGACY_RUNTIME_RELEASE_REPOSITORY]) {
185
+ for (const repo of overrides) {
187
186
  if (repo && repo !== releaseRepo) add(value.replace(`/github.com/${releaseRepo}/`, `/github.com/${repo}/`))
188
187
  }
189
188
  return out
@@ -8,21 +8,22 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Core-memory mutation and status. Use recall for retrieval. Persist with action:"core" op:"add" when: user corrects/confirms your approach, states a durable preference, or says "remember". Only what code/git/docs cannot derive; include the why. Never transient task state. No prior recall dedup needed — the cycle dedups.',
11
+ description: 'Core-memory mutation and status. Use recall for retrieval. Persist with action:"core" op:"add" when: user corrects/confirms your approach, states a durable preference, or says "remember". Core add/edit requires project_id + category + element + summary. Only what code/git/docs cannot derive; include the why. Never transient task state. No prior recall dedup needed — the cycle dedups.',
12
12
  inputSchema: {
13
13
  type: 'object',
14
14
  properties: {
15
15
  action: { type: 'string', enum: ['core','status'], description: 'Operation.' },
16
16
  op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
17
17
  id: { type: 'number', description: 'Exact memory id.' },
18
- element: { type: 'string', description: 'Memory key/title. Max 40 chars.' },
19
- summary: { type: 'string', description: 'Memory content: 1 fact, 1-2 sentences, max 100 chars.' },
18
+ element: { type: 'string', maxLength: 40, description: 'Memory key/title. Max 40 chars.' },
19
+ summary: { type: 'string', maxLength: 100, description: 'Memory content: 1 fact, 1-2 sentences, max 100 chars.' },
20
20
  category: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'], description: 'Category.' },
21
21
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
22
22
  limit: { type: 'number', description: 'Max rows/items.' },
23
23
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
24
24
  project_id: { type: 'string', description: 'Core pool: common, slug, or *. Required for core add/edit; there is no default pool.' },
25
25
  },
26
+ additionalProperties: false,
26
27
  required: ['action'],
27
28
  },
28
29
  },
@@ -514,8 +514,7 @@ export function toolWorkUnit(name, args = {}, category = '') {
514
514
  case 'memory': {
515
515
  const action = String(a.action || '').toLowerCase();
516
516
  const op = String(a.op || '').toLowerCase();
517
- const isMutation = op === 'add' || op === 'edit' || op === 'delete' || op === 'promote' || op === 'dismiss'
518
- || (action === 'core' && !op);
517
+ const isMutation = op === 'add' || op === 'edit' || op === 'delete' || op === 'promote' || op === 'dismiss';
519
518
  if (isMutation) return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Writing', done: 'Wrote', noun: 'memory item' });
520
519
  return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Checking', done: 'Checked', noun: 'memory item' });
521
520
  }
@@ -2,8 +2,10 @@ import {
2
2
  estimateRequestReserveTokens,
3
3
  estimateToolSchemaTokens,
4
4
  estimateTranscriptContextUsage,
5
+ contextMessagesRevision,
5
6
  resolveSessionCompactPolicy,
6
7
  summarizeContextMessages,
8
+ toolSchemaSignature,
7
9
  } from '../runtime/agent/orchestrator/session/context-utils.mjs';
8
10
  import { estimateToolSchemaBreakdown } from './tool-catalog.mjs';
9
11
 
@@ -16,7 +18,7 @@ export function createContextStatus({ getSession, getRoute, getCurrentCwd, getMo
16
18
  let contextStatusCacheKey = null;
17
19
  let contextStatusCacheValue = null;
18
20
 
19
- function contextStatusCacheKeyFor({ messages, tools }) {
21
+ function contextStatusCacheKeyFor({ messages, tools, messagesRevision, toolsSignature }) {
20
22
  const session = getSession();
21
23
  const route = getRoute();
22
24
  const compaction = session?.compaction || {};
@@ -30,11 +32,13 @@ export function createContextStatus({ getSession, getRoute, getCurrentCwd, getMo
30
32
  mode: getMode(),
31
33
  messages,
32
34
  messageCount: messages.length,
35
+ messagesRevision,
33
36
  lastMessage,
34
37
  lastMessageRole: lastMessage?.role || null,
35
38
  lastMessageContent: lastMessage?.content || null,
36
39
  tools,
37
40
  toolCount: tools.length,
41
+ toolsSignature,
38
42
  contextWindow: session?.contextWindow || null,
39
43
  rawContextWindow: session?.rawContextWindow || null,
40
44
  effectiveContextWindowPercent: session?.effectiveContextWindowPercent || null,
@@ -85,7 +89,9 @@ export function createContextStatus({ getSession, getRoute, getCurrentCwd, getMo
85
89
  const liveTurnMessages = Array.isArray(session?.liveTurnMessages) ? session.liveTurnMessages : null;
86
90
  const messages = liveTurnMessages || (Array.isArray(session?.messages) ? session.messages : []);
87
91
  const tools = Array.isArray(session?.tools) ? session.tools : [];
88
- const cacheKey = contextStatusCacheKeyFor({ messages, tools });
92
+ const messagesRevision = contextMessagesRevision(messages);
93
+ const toolsSignature = toolSchemaSignature(tools);
94
+ const cacheKey = contextStatusCacheKeyFor({ messages, tools, messagesRevision, toolsSignature });
89
95
  if (contextStatusCacheValue && sameContextStatusCacheKey(cacheKey, contextStatusCacheKey)) {
90
96
  return contextStatusCacheValue;
91
97
  }
@@ -37,6 +37,8 @@ export function abnormalEmptyFinishError(result, agent) {
37
37
  // Only tagged for PUBLIC agents (hidden agents legitimately emit empty
38
38
  // terminal turns and are left untagged by the loop).
39
39
  return `agent '${agent}' finished without a final answer (stopReason=${stopReason ?? 'none'}, ${iterations} iterations, ${toolCallsTotal} tool calls)`;
40
+ case 'refusal':
41
+ return `agent '${agent}' response was refused by the safety classifier (${iterations} iterations, ${toolCallsTotal} tool calls)`;
40
42
  default:
41
43
  return null;
42
44
  }
@@ -90,6 +90,72 @@ const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIME
90
90
  // startDeferredSpawnJob) so the agent tool call itself still returns task_id
91
91
  // immediately.
92
92
  const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 0);
93
+ const TAG_TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
94
+ const MAX_TAG_TOMBSTONES = 500;
95
+
96
+ /**
97
+ * Resolve the route for a public agent spawn. Explore and Maintainer inherit
98
+ * Main only after their explicit agent, workflow, and maintenance routes.
99
+ */
100
+ export function resolveAgentSpawnPreset(config, args = {}) {
101
+ if (args.provider && args.model) {
102
+ return {
103
+ presetName: args.preset || '__direct__',
104
+ preset: {
105
+ id: '__direct__',
106
+ name: '__DIRECT__',
107
+ type: 'agent',
108
+ provider: clean(args.provider),
109
+ model: clean(args.model),
110
+ effort: clean(args.effort) || undefined,
111
+ fast: args.fast === true,
112
+ tools: 'full',
113
+ },
114
+ };
115
+ }
116
+
117
+ const agentName = normalizeAgentName(args.agent);
118
+ const configuredDefault = clean(config?.defaultProvider);
119
+ const fallbackProvider = configuredDefault && isKnownProvider(configuredDefault)
120
+ ? configuredDefault
121
+ : DEFAULT_PROVIDER;
122
+ const workflowSlot = agentName === 'explore' ? 'explorer'
123
+ : (agentName === 'maintainer' ? 'memory' : '');
124
+ const maintenanceSlot = agentName === 'explore' ? 'explore'
125
+ : (agentName === 'maintainer' ? 'memory' : '');
126
+ const agentRoute = !clean(args.preset)
127
+ ? (normalizeAgentRoute(config?.agents?.[agentName], fallbackProvider)
128
+ || (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance, fallbackProvider) : null)
129
+ || normalizeAgentRoute(config?.workflowRoutes?.[workflowSlot], fallbackProvider)
130
+ || normalizeAgentRoute(config?.maintenance?.[maintenanceSlot], fallbackProvider))
131
+ : null;
132
+ if (agentRoute) {
133
+ return {
134
+ presetName: agentPresetName(agentName),
135
+ preset: {
136
+ id: `agent-${agentName}`,
137
+ name: agentPresetName(agentName),
138
+ type: 'agent',
139
+ provider: agentRoute.provider,
140
+ model: agentRoute.model,
141
+ effort: agentRoute.effort,
142
+ fast: agentRoute.fast === true,
143
+ tools: 'full',
144
+ },
145
+ };
146
+ }
147
+
148
+ const mainPreset = !clean(args.preset) && (agentName === 'explore' || agentName === 'maintainer')
149
+ ? findPreset(config, config?.default)
150
+ : null;
151
+ if (mainPreset) return { presetName: mainPreset.id || mainPreset.name, preset: mainPreset };
152
+
153
+ const presetName = clean(args.preset) || DEFAULT_AGENT_PRESETS[agentName];
154
+ if (!presetName) throw new Error(`agent: agent "${agentName}" has no model assignment`);
155
+ const preset = findPreset(config, presetName) || synthesizePreset(config, presetName);
156
+ if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
157
+ return { presetName, preset };
158
+ }
93
159
  let lastSpawnStartAt = 0;
94
160
  async function waitForSpawnStagger() {
95
161
  if (SPAWN_STAGGER_MS <= 0) return;
@@ -177,13 +243,48 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
177
243
  .filter(keepWorkerRow);
178
244
  }
179
245
 
246
+ function normalizeTagTombstones(value, { cap = true, priorityKeys = null } = {}) {
247
+ const source = Array.isArray(value?.tombstones)
248
+ ? value.tombstones
249
+ : (value?.tombstones && typeof value.tombstones === 'object'
250
+ ? Object.values(value.tombstones)
251
+ : []);
252
+ const now = Date.now();
253
+ const cutoff = now - TAG_TOMBSTONE_TTL_MS;
254
+ const rows = source
255
+ .filter((row) => row && typeof row === 'object')
256
+ .map((row) => {
257
+ const parsedReapedAt = Date.parse(clean(row.reapedAt)) || 0;
258
+ return {
259
+ tag: clean(row.tag),
260
+ agent: clean(row.agent) || null,
261
+ cwd: clean(row.cwd) || null,
262
+ clientHostPid: positiveInt(row.clientHostPid),
263
+ // A future clock must not outrank tombstones created by this process.
264
+ reapedAt: parsedReapedAt ? new Date(Math.min(parsedReapedAt, now)).toISOString() : null,
265
+ };
266
+ })
267
+ .filter((row) => row.tag && row.reapedAt && (Date.parse(row.reapedAt) || 0) >= cutoff)
268
+ .sort((a, b) => {
269
+ const aPriority = priorityKeys?.has(tagTombstoneKey(a)) ? 1 : 0;
270
+ const bPriority = priorityKeys?.has(tagTombstoneKey(b)) ? 1 : 0;
271
+ return bPriority - aPriority
272
+ || (Date.parse(b.reapedAt) || 0) - (Date.parse(a.reapedAt) || 0);
273
+ });
274
+ return cap ? rows.slice(0, MAX_TAG_TOMBSTONES) : rows;
275
+ }
276
+
277
+ function tagTombstoneKey(row = {}) {
278
+ return `${positiveInt(row.clientHostPid) || 0}\0${clean(row.tag)}`;
279
+ }
280
+
180
281
  // Mtime-keyed parse cache for the worker index. A single spawn calls
181
282
  // refreshTagsFromSessions()/resolveTag()/nextTag() which each re-read and
182
283
  // re-JSON.parse this file; across a parallel fanout that is O(spawns^2)
183
284
  // synchronous reads of the same bytes on the event loop. Cache the parsed,
184
285
  // normalized rows and reuse them while the file mtime+size is unchanged.
185
286
  // Writes bump _workerRowsCacheDirty so the very next read re-parses.
186
- let _workerRowsCache = null; // { mtimeMs, size, rows }
287
+ let _workerRowsCache = null; // { mtimeMs, size, rows, tombstones }
187
288
  let _workerRowsCacheDirty = true;
188
289
  function invalidateWorkerRowsCache() {
189
290
  _workerRowsCacheDirty = true;
@@ -200,15 +301,26 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
200
301
  return _workerRowsCache.rows;
201
302
  }
202
303
  let rows = [];
304
+ let tombstones = [];
203
305
  try {
204
- rows = normalizeWorkerRows(JSON.parse(readFileSync(file, 'utf8')));
306
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
307
+ rows = normalizeWorkerRows(parsed);
308
+ tombstones = normalizeTagTombstones(parsed);
205
309
  } catch {
206
310
  rows = [];
311
+ tombstones = [];
207
312
  }
208
- _workerRowsCache = { mtimeMs: st.mtimeMs, size: st.size, rows };
313
+ _workerRowsCache = { mtimeMs: st.mtimeMs, size: st.size, rows, tombstones };
209
314
  _workerRowsCacheDirty = false;
210
315
  return rows;
211
316
  }
317
+ function readAllTagTombstones() {
318
+ readAllWorkerRows();
319
+ return _workerRowsCache?.tombstones || [];
320
+ }
321
+ function readTagTombstones(context = {}) {
322
+ return readAllTagTombstones().filter((row) => rowMatchesContext(row, context));
323
+ }
212
324
  function readWorkerRows(context = {}) {
213
325
  const rows = readAllWorkerRows();
214
326
  if (rows.length === 0) return rows;
@@ -229,13 +341,25 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
229
341
  const key = workerRowKey(row);
230
342
  if (key) byKey.set(key, row);
231
343
  }
232
- mutator(byKey);
344
+ const tombstonesByKey = new Map();
345
+ for (const row of normalizeTagTombstones(cur, { cap: false })) {
346
+ tombstonesByKey.set(tagTombstoneKey(row), row);
347
+ }
348
+ const priorityTombstoneKeys = new Set();
349
+ mutator(byKey, tombstonesByKey, priorityTombstoneKeys);
233
350
  const workers = {};
234
351
  for (const row of [...byKey.values()].filter(keepWorkerRow)) {
235
352
  const key = workerRowKey(row);
236
353
  if (key) workers[key] = row;
237
354
  }
238
- return { version: 1, updatedAt: new Date().toISOString(), workers };
355
+ const tombstones = {};
356
+ for (const row of normalizeTagTombstones(
357
+ { tombstones: [...tombstonesByKey.values()] },
358
+ { priorityKeys: priorityTombstoneKeys },
359
+ )) {
360
+ tombstones[tagTombstoneKey(row)] = row;
361
+ }
362
+ return { version: 2, updatedAt: new Date().toISOString(), workers, tombstones };
239
363
  }, { lock: true });
240
364
  // This process just rewrote the index; force the next read to re-parse
241
365
  // even if the new mtime/size happen to collide with the cached stat.
@@ -504,6 +628,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
504
628
  }
505
629
 
506
630
  function refreshTagsFromSessions({ scanSessions = false, context = {} } = {}) {
631
+ transitionStaleNonterminalRows(context);
507
632
  const indexedRows = refreshTagsFromIndex(context);
508
633
  const indexedKeys = new Set(indexedRows.map((row) => `${row.tag}\0${row.sessionId}`));
509
634
  for (const [tag, sessionId] of [...tags.entries()]) {
@@ -555,6 +680,50 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
555
680
  if (id) removeWorkerRow({ sessionId: id });
556
681
  }
557
682
 
683
+ function tombstoneTerminalSession(tag, sessionId, session = null) {
684
+ const value = clean(tag);
685
+ const id = clean(sessionId);
686
+ if (!value || !id) {
687
+ forgetTerminalSession(value, id);
688
+ return;
689
+ }
690
+ if (tags.get(value) === id) {
691
+ tags.delete(value);
692
+ tagAgents.delete(value);
693
+ tagCwds.delete(value);
694
+ }
695
+ const tombstone = {
696
+ tag: value,
697
+ agent: clean(session?.agent) || null,
698
+ cwd: clean(session?.cwd) || null,
699
+ clientHostPid: positiveInt(session?.clientHostPid),
700
+ reapedAt: new Date().toISOString(),
701
+ };
702
+ flushWorkerIndexMutations();
703
+ writeWorkerRows((byKey, tombstonesByKey, priorityTombstoneKeys) => {
704
+ for (const [key, row] of [...byKey.entries()]) {
705
+ if (clean(row.sessionId) === id) byKey.delete(key);
706
+ }
707
+ const tombstoneKey = tagTombstoneKey(tombstone);
708
+ tombstonesByKey.set(tombstoneKey, tombstone);
709
+ priorityTombstoneKeys.add(tombstoneKey);
710
+ });
711
+ }
712
+
713
+ function tagTombstoneForTag(tag, context = {}) {
714
+ const value = clean(tag);
715
+ if (!value || value.startsWith('sess_')) return null;
716
+ return readTagTombstones(context).find((row) => row.tag === value) || null;
717
+ }
718
+
719
+ function consumeTagTombstone(tombstone) {
720
+ if (!tombstone?.tag) return false;
721
+ const key = tagTombstoneKey(tombstone);
722
+ flushWorkerIndexMutations();
723
+ writeWorkerRows((_byKey, tombstonesByKey) => tombstonesByKey.delete(key));
724
+ return true;
725
+ }
726
+
558
727
  function cancelReap(sessionId) {
559
728
  const handle = reapTimers.get(sessionId);
560
729
  if (!handle) return false;
@@ -571,9 +740,10 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
571
740
  if (reapMs == null) return;
572
741
  const handle = setTimeout(() => {
573
742
  reapTimers.delete(sessionId);
743
+ const session = getLiveSession(sessionId);
574
744
  try { mgr.hideSessionFromList?.(sessionId); } catch {}
575
745
  const tag = tagForSession(sessionId);
576
- forgetTerminalSession(tag, sessionId);
746
+ tombstoneTerminalSession(tag, sessionId, session);
577
747
  clearAgentStatuslineRoute(sessionId);
578
748
  try { mgr.closeSession(sessionId, 'terminal-reap'); } catch {}
579
749
  }, reapMs);
@@ -581,6 +751,47 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
581
751
  reapTimers.set(sessionId, handle);
582
752
  }
583
753
 
754
+ function transitionStaleNonterminalRows(context = {}) {
755
+ const staleRows = readWorkerRows(context).filter((row) => {
756
+ if (isTerminalWorkerStatus(row.status || row.stage)) return false;
757
+ if (getLiveSession(clean(row.sessionId))) return false;
758
+ const rowTime = workerRowTime(row);
759
+ const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), row.provider);
760
+ // A row with no timestamp has no usable heartbeat at all. Explicitly
761
+ // disabled terminal reaping still gets the tombstone TTL as a finite
762
+ // stale-heartbeat bound, so malformed/running index rows cannot block a
763
+ // tag forever.
764
+ return rowTime <= 0 || Date.now() - rowTime >= (reapMs ?? TAG_TOMBSTONE_TTL_MS);
765
+ });
766
+ if (staleRows.length === 0) return false;
767
+ flushWorkerIndexMutations();
768
+ const nowIso = new Date().toISOString();
769
+ writeWorkerRows((byKey, tombstonesByKey, priorityTombstoneKeys) => {
770
+ for (const row of staleRows) {
771
+ const sessionId = clean(row.sessionId);
772
+ for (const [key, candidate] of [...byKey.entries()]) {
773
+ if (clean(candidate.sessionId) === sessionId) byKey.delete(key);
774
+ }
775
+ const tombstone = {
776
+ tag: clean(row.tag),
777
+ agent: clean(row.agent) || null,
778
+ cwd: clean(row.cwd) || null,
779
+ clientHostPid: positiveInt(row.clientHostPid),
780
+ reapedAt: nowIso,
781
+ };
782
+ const tombstoneKey = tagTombstoneKey(tombstone);
783
+ tombstonesByKey.set(tombstoneKey, tombstone);
784
+ priorityTombstoneKeys.add(tombstoneKey);
785
+ if (tags.get(tombstone.tag) === sessionId) {
786
+ tags.delete(tombstone.tag);
787
+ tagAgents.delete(tombstone.tag);
788
+ tagCwds.delete(tombstone.tag);
789
+ }
790
+ }
791
+ });
792
+ return true;
793
+ }
794
+
584
795
  function isSessionBusy(sessionId) {
585
796
  const runtime = mgr.getSessionRuntime?.(sessionId);
586
797
  if (runtime?.controller?.signal && !runtime.controller.signal.aborted) return true;
@@ -594,55 +805,6 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
594
805
  // chain-gate defaults to the spawn-prep cap (see provider-init.mjs comments).
595
806
  const { ensureProvider } = createProviderInit(reg, DEFAULT_SPAWN_PREP_TIMEOUT_MS);
596
807
 
597
- function resolvePreset(config, args) {
598
- if (args.provider && args.model) {
599
- return {
600
- presetName: args.preset || '__direct__',
601
- preset: {
602
- id: '__direct__',
603
- name: '__DIRECT__',
604
- type: 'agent',
605
- provider: clean(args.provider),
606
- model: clean(args.model),
607
- effort: clean(args.effort) || undefined,
608
- fast: args.fast === true,
609
- tools: 'full',
610
- },
611
- };
612
- }
613
-
614
- const agentName = normalizeAgentName(args.agent);
615
- const configuredDefault = clean(config?.defaultProvider);
616
- const fallbackProvider = configuredDefault && isKnownProvider(configuredDefault)
617
- ? configuredDefault
618
- : DEFAULT_PROVIDER;
619
- const agentRoute = !clean(args.preset)
620
- ? (normalizeAgentRoute(config?.agents?.[agentName], fallbackProvider)
621
- || (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance, fallbackProvider) : null))
622
- : null;
623
- if (agentRoute) {
624
- return {
625
- presetName: agentPresetName(agentName),
626
- preset: {
627
- id: `agent-${agentName}`,
628
- name: agentPresetName(agentName),
629
- type: 'agent',
630
- provider: agentRoute.provider,
631
- model: agentRoute.model,
632
- effort: agentRoute.effort,
633
- fast: agentRoute.fast === true,
634
- tools: 'full',
635
- },
636
- };
637
- }
638
-
639
- const presetName = clean(args.preset) || DEFAULT_AGENT_PRESETS[agentName];
640
- if (!presetName) throw new Error(`agent: agent "${agentName}" has no model assignment`);
641
- const preset = findPreset(config, presetName) || synthesizePreset(config, presetName);
642
- if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
643
- return { presetName, preset };
644
- }
645
-
646
808
  function list({ scanSessions = false, context = {} } = {}) {
647
809
  refreshTagsFromSessions({ scanSessions, context });
648
810
  const now = Date.now();
@@ -1028,7 +1190,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1028
1190
  if (!agent) throw new Error('agent spawn: agent is required');
1029
1191
  const agentPermission = readAgentFrontmatterPermission(agent, dataDir, STANDALONE_SOURCE_ROOT);
1030
1192
  const agentPerm = normalizeAgentPermission(agentPermission) || null;
1031
- const { presetName, preset } = resolvePreset(config, args);
1193
+ const { presetName, preset } = resolveAgentSpawnPreset(config, args);
1032
1194
  await ensureProvider(config, preset.provider);
1033
1195
  if (prepState?.timedOut) {
1034
1196
  throw new Error('agent spawn prep timed out before session bind');
@@ -1522,7 +1684,10 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1522
1684
  tagAgents.clear();
1523
1685
  tagCwds.clear();
1524
1686
  flushWorkerIndexMutations();
1525
- writeWorkerRows((byKey) => byKey.clear());
1687
+ writeWorkerRows((byKey, tombstonesByKey) => {
1688
+ byKey.clear();
1689
+ tombstonesByKey.clear();
1690
+ });
1526
1691
  return { closed, failed };
1527
1692
  }
1528
1693
 
@@ -1589,25 +1754,26 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1589
1754
  if (!fallbackTag || fallbackTag.startsWith('sess_') || !isDeadTarget) throw err;
1590
1755
  const prompt = clean(args.message || args.prompt);
1591
1756
  if (!prompt) throw err;
1592
- // A retained row is proof that this terminal owned the tag. Once the
1593
- // reaper removes that row, an explicit agent plus usable cwd is enough
1594
- // to cold-spawn a replacement without retaining closed sessions.
1757
+ // A retained row or reap tombstone proves that this terminal owned
1758
+ // the tag. Unknown tags stay errors even when the caller supplies an
1759
+ // agent/cwd: typo absorption requires persisted same-tag evidence.
1760
+ // Absorption identity is always terminal-local, even when live
1761
+ // resolution was explicitly requested across all terminals.
1762
+ const ownershipContext = context;
1595
1763
  let inheritedRow = null;
1596
1764
  try {
1597
- inheritedRow = readWorkerRows(scopedContext).find((row) => clean(row.tag) === fallbackTag) || null;
1765
+ inheritedRow = readWorkerRows(ownershipContext).find((row) => clean(row.tag) === fallbackTag) || null;
1598
1766
  } catch { inheritedRow = null; }
1599
- // Never use explicit cold-spawn identity to take over a tag retained
1600
- // by another terminal. In-memory maps can contain peer entries, so
1601
- // inspect the persisted index directly.
1602
- const peerRow = readAllWorkerRows().find((row) => (
1603
- clean(row.tag) === fallbackTag && !rowMatchesContext(row, scopedContext)
1604
- ));
1767
+ const inheritedTombstone = tagTombstoneForTag(fallbackTag, ownershipContext);
1605
1768
  const explicitAgent = clean(args.agent);
1606
- const explicitCwd = clean(args.cwd) || clean(callerCwd);
1607
- if (peerRow || (!inheritedRow && (!explicitAgent || !explicitCwd))) throw err;
1769
+ // A local proof wins even if another terminal also owns this tag.
1770
+ // With no local proof, foreign rows/tombstones and unknown tags are
1771
+ // both non-absorbable and retain the original not-found error.
1772
+ if (!inheritedRow && !inheritedTombstone) throw err;
1608
1773
  const inheritedSessionId = clean(inheritedRow?.sessionId);
1609
- const inheritedAgent = explicitAgent || clean(inheritedRow?.agent) || 'worker';
1610
- const inheritedCwd = clean(args.cwd) || clean(inheritedRow?.cwd) || clean(callerCwd);
1774
+ const inheritedAgent = explicitAgent || clean(inheritedRow?.agent) || clean(inheritedTombstone?.agent);
1775
+ const inheritedCwd = clean(args.cwd) || clean(inheritedRow?.cwd) || clean(inheritedTombstone?.cwd) || clean(callerCwd);
1776
+ if (!inheritedAgent || !inheritedCwd) throw err;
1611
1777
  // Drop this terminal's in-memory trace and remove ONLY the persisted
1612
1778
  // row matching inheritedRow.sessionId. Do NOT call forgetTag here: it
1613
1779
  // does a tag-wide removeWorkerRow({tag,sessionId}) (L556) whose OR
@@ -1618,6 +1784,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1618
1784
  try { tags.delete(fallbackTag); tagAgents.delete(fallbackTag); tagCwds.delete(fallbackTag); } catch {}
1619
1785
  }
1620
1786
  if (inheritedSessionId) { try { removeWorkerRow({ sessionId: inheritedSessionId }); } catch {} }
1787
+ if (inheritedTombstone) consumeTagTombstone(inheritedTombstone);
1621
1788
  const spawnArgs = {
1622
1789
  ...args,
1623
1790
  type: 'spawn',
@@ -1638,6 +1805,8 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1638
1805
  // 3) lingering terminal trace -> reap trace and fresh spawn under same tag
1639
1806
  // 4) genuinely new tag -> fresh deferred spawn
1640
1807
  const explicitTag = clean(args.tag);
1808
+ let respawned = false;
1809
+ let spawnArgs = args;
1641
1810
  if (explicitTag) {
1642
1811
  // Resolve a LIVE same-tag session in this terminal (busy or idle).
1643
1812
  let liveSessionId = null;
@@ -1658,9 +1827,35 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1658
1827
  }
1659
1828
  if (hasTerminalTrace(explicitTag, scopedContext)) {
1660
1829
  reapTerminalTraceForTag(explicitTag, scopedContext);
1830
+ respawned = true;
1831
+ } else {
1832
+ // Tombstone inheritance never honors allTerminals/global scope.
1833
+ const tombstone = tagTombstoneForTag(explicitTag, context);
1834
+ if (tombstone) {
1835
+ consumeTagTombstone(tombstone);
1836
+ spawnArgs = {
1837
+ ...args,
1838
+ agent: clean(args.agent) || clean(tombstone.agent),
1839
+ ...(clean(args.cwd) || !clean(tombstone.cwd) ? {} : { cwd: tombstone.cwd }),
1840
+ };
1841
+ respawned = true;
1842
+ } else {
1843
+ const foreignTombstone = readAllTagTombstones().find((row) => (
1844
+ clean(row.tag) === explicitTag && !rowMatchesContext(row, context)
1845
+ ));
1846
+ if (foreignTombstone) {
1847
+ throw new Error(`agent spawn: tag "${explicitTag}" belongs to another terminal`);
1848
+ }
1849
+ }
1661
1850
  }
1662
1851
  }
1663
- const job = startDeferredSpawnJob(args, callerCwd, context, notifyContext);
1852
+ const job = startDeferredSpawnJob(
1853
+ spawnArgs,
1854
+ callerCwd,
1855
+ context,
1856
+ notifyContext,
1857
+ respawned ? { respawned: true } : {},
1858
+ );
1664
1859
  return renderResult(renderJob(job, false));
1665
1860
  }
1666
1861
  throw new Error(`agent: unknown type "${type}"`);