mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -5,6 +5,11 @@ import { createEngineItemMutators, replaceEngineItemsState } from '../src/tui/en
5
5
  import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
6
6
  import { createRunTurn } from '../src/tui/engine/turn.mjs';
7
7
  import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-window.mjs';
8
+ import {
9
+ isCompletedTranscriptTail,
10
+ isCompletedTranscriptTailAppendedThisCommit,
11
+ isLiveSpinnerMetaVisible,
12
+ } from '../src/tui/app/live-spinner-visibility.mjs';
8
13
 
9
14
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
15
 
@@ -91,6 +96,7 @@ function makeTurnHarness(ask, stateOverrides = {}, bagOverrides = {}) {
91
96
 
92
97
  test('successful mid-turn compact trims history but preserves live turn references', async () => {
93
98
  let preservedTail = false;
99
+ let compactKeepsSpinnerVisible = false;
94
100
  let contextSyncs = 0;
95
101
  const harness = makeTurnHarness(async (_text, options) => {
96
102
  options.onTextDelta('before compact\n');
@@ -98,6 +104,8 @@ test('successful mid-turn compact trims history but preserves live turn referenc
98
104
  options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
99
105
  assert.equal(contextSyncs, 1, 'compact event must refresh context before returning');
100
106
  preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
107
+ compactKeepsSpinnerVisible = harness.getState().spinner?.active === true
108
+ && harness.getState().items.at(-1)?.kind === 'statusdone';
101
109
  options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
102
110
  assert.equal(contextSyncs, 2, 'each compact event refreshes context immediately');
103
111
  options.onTextDelta('after compact\n');
@@ -113,6 +121,7 @@ test('successful mid-turn compact trims history but preserves live turn referenc
113
121
 
114
122
  assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');
115
123
  assert.equal(preservedTail, true);
124
+ assert.equal(compactKeepsSpinnerVisible, true, 'a compact status leaves the active turn spinner in place');
116
125
  assert.equal(harness.getState().items.some((item) => item.id === 'old'), false);
117
126
  assert.equal(harness.getState().items.some((item) => item.id === 'current-user'), true);
118
127
  assert.equal(
@@ -125,6 +134,35 @@ test('successful mid-turn compact trims history but preserves live turn referenc
125
134
  );
126
135
  });
127
136
 
137
+ test('live spinner remains visible across compact status and follows turn teardown', () => {
138
+ const activeTurnSpinner = { active: true };
139
+ const visible = (liveSpinner, liveSpinnerIsCommand, kind) => isLiveSpinnerMetaVisible({
140
+ inputBoxHidden: false,
141
+ slashPaletteOpen: false,
142
+ liveSpinner,
143
+ liveSpinnerIsCommand,
144
+ latestTranscriptItem: kind ? { kind } : null,
145
+ });
146
+
147
+ assert.equal(visible(activeTurnSpinner, false, 'statusdone'), true);
148
+ assert.equal(visible(activeTurnSpinner, false, 'turndone'), false);
149
+ assert.equal(visible(null, false, 'turndone'), false);
150
+ assert.equal(visible({ active: true }, true, 'turndone'), true);
151
+ });
152
+
153
+ test('completed transcript tail masking requires a new done tail', () => {
154
+ assert.equal(isCompletedTranscriptTail({ id: 'turn', kind: 'turndone' }), true);
155
+ assert.equal(isCompletedTranscriptTail({ id: 'status', kind: 'statusdone' }), true);
156
+ assert.equal(isCompletedTranscriptTail({ id: 'assistant', kind: 'assistant' }), false);
157
+ assert.equal(isCompletedTranscriptTail({ id: 'user', kind: 'user' }), false);
158
+ assert.equal(isCompletedTranscriptTail(null), false);
159
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'turn', kind: 'turndone' }, 'turn'), false);
160
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'status', kind: 'statusdone' }, 'status'), false);
161
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next', kind: 'turndone' }, 'previous'), true);
162
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next-status', kind: 'statusdone' }, 'previous'), true);
163
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'assistant', kind: 'assistant' }, 'previous'), false);
164
+ });
165
+
128
166
  test('failed mid-turn compact leaves prior transcript items untouched', async () => {
129
167
  const harness = makeTurnHarness(async (_text, options) => {
130
168
  options.onCompactEvent({ status: 'failed' });
@@ -175,8 +213,7 @@ test('abort after stream progress settles the tail and leaves no orphan', async
175
213
  assert.match(assistants[0].text, /partial line/);
176
214
  });
177
215
 
178
- // Codifies pre-existing parity: text had no item/currentAssistantId before its first completed line.
179
- test('abort before a newline has no tail id and preserves prior text-drop parity', async () => {
216
+ test('abort before a newline settles the accumulated partial response', async () => {
180
217
  const harness = makeTurnHarness(async (_text, options) => {
181
218
  options.onTextDelta('partial without newline');
182
219
  await wait(30);
@@ -187,11 +224,10 @@ test('abort before a newline has no tail id and preserves prior text-drop parity
187
224
 
188
225
  assert.equal(await harness.runTurn('go'), 'cancelled');
189
226
  assert.equal(harness.getState().streamingTail, null);
190
- assert.equal(
191
- harness.getState().items.filter((item) => item.kind === 'assistant').length,
192
- 0,
193
- 'without a visible tail id, pre-newline text keeps the existing drop-on-abort behavior',
194
- );
227
+ const assistants = harness.getState().items.filter((item) => item.kind === 'assistant');
228
+ assert.equal(assistants.length, 1);
229
+ assert.equal(assistants[0].streaming, false);
230
+ assert.equal(assistants[0].text, 'partial without newline');
195
231
  });
196
232
 
197
233
  // Codifies pre-existing parity: the old in-items completed-line snapshot survived starved recovery.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import http from 'node:http';
5
+ import {
6
+ isLoopbackHttpUrl,
7
+ preDispatchDenyForSession,
8
+ } from '../src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs';
9
+ import { executeTool } from '../src/runtime/agent/orchestrator/session/loop/tool-exec.mjs';
10
+ import {
11
+ getInternalTools,
12
+ setInternalToolsProvider,
13
+ } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
14
+ import { previewSessionTools } from '../src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs';
15
+ import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.mjs';
16
+ import { dispatchSearchRuntimeTool } from '../src/session-runtime/runtime-core.mjs';
17
+ import {
18
+ fetchLoopbackText,
19
+ fetchPublicImage,
20
+ } from '../src/runtime/search/lib/http-fetch.mjs';
21
+
22
+ test('pre-dispatch keeps hook-facing web_fetch name unchanged', () => {
23
+ const local = { name: 'web_fetch', arguments: { url: 'http://127.0.0.1:4321/status' } };
24
+ const image = { name: 'web_fetch', arguments: { url: 'https://cdn.example.com/a.png?x=1' } };
25
+ const document = { name: 'web_fetch', arguments: { url: 'https://example.com/docs' } };
26
+ assert.equal(preDispatchDenyForSession({}, local), null);
27
+ assert.equal(preDispatchDenyForSession({}, image), null);
28
+ assert.equal(preDispatchDenyForSession({}, document), null);
29
+ assert.equal(local.name, 'web_fetch');
30
+ assert.equal(image.name, 'web_fetch');
31
+ assert.equal(document.name, 'web_fetch');
32
+ assert.equal(isLoopbackHttpUrl('http://localhost:80/'), true);
33
+ assert.equal(isLoopbackHttpUrl('http://192.168.1.2/'), false);
34
+ });
35
+
36
+ test('hidden routed tools remain dispatchable but absent from every model schema', () => {
37
+ setInternalToolsProvider({
38
+ tools: SEARCH_TOOL_DEFS,
39
+ executor: async () => '',
40
+ });
41
+ const registered = getInternalTools().map((tool) => tool.name);
42
+ assert.equal(registered.includes('local_fetch'), true);
43
+ assert.equal(registered.includes('image_fetch'), true);
44
+ for (const spec of ['full', 'mcp', 'readonly', ['tools:mcp']]) {
45
+ const visible = previewSessionTools(spec, []).map((tool) => tool.name);
46
+ assert.equal(visible.includes('local_fetch'), false, `local_fetch leaked for ${JSON.stringify(spec)}`);
47
+ assert.equal(visible.includes('image_fetch'), false, `image_fetch leaked for ${JSON.stringify(spec)}`);
48
+ assert.equal(visible.includes('web_fetch'), true, `web_fetch missing for ${JSON.stringify(spec)}`);
49
+ }
50
+ });
51
+
52
+ test('hook sees public name before routed dispatch and can change routing inputs', async () => {
53
+ const observed = [];
54
+ setInternalToolsProvider({
55
+ tools: SEARCH_TOOL_DEFS,
56
+ executor: async (name, args) => {
57
+ observed.push({ stage: 'dispatch', name, args });
58
+ return { content: [{ type: 'text', text: name }] };
59
+ },
60
+ });
61
+ const result = await executeTool(
62
+ 'web_fetch',
63
+ { url: 'https://example.com/document' },
64
+ process.cwd(),
65
+ 'routing-order-test',
66
+ {
67
+ beforeToolHook: async ({ name, args }) => {
68
+ observed.push({ stage: 'hook', name, args });
69
+ return { action: 'modify', args: { url: 'http://127.0.0.1:4321/status' } };
70
+ },
71
+ },
72
+ { toolCallId: 'routing-order-call' },
73
+ );
74
+ assert.equal(result, 'local_fetch');
75
+ assert.deepEqual(observed.map(({ stage, name }) => ({ stage, name })), [
76
+ { stage: 'hook', name: 'web_fetch' },
77
+ { stage: 'dispatch', name: 'local_fetch' },
78
+ ]);
79
+ });
80
+
81
+ test('cancellation signal propagates across executeTool and runtime search dispatch', async () => {
82
+ const received = [];
83
+ setInternalToolsProvider({
84
+ tools: SEARCH_TOOL_DEFS,
85
+ executor: (name, args, callerCtx) => dispatchSearchRuntimeTool(name, args, callerCtx, {
86
+ getSearchModule: async () => ({
87
+ handleToolCall: async (_name, _args, options) => {
88
+ received.push({ name: _name, signal: options.signal });
89
+ await new Promise((resolve, reject) => {
90
+ if (options.signal.aborted) reject(options.signal.reason);
91
+ else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
92
+ });
93
+ },
94
+ }),
95
+ getCurrentCwd: () => process.cwd(),
96
+ getSession: () => null,
97
+ notifyFnForSession: () => null,
98
+ runNativeWebSearch: async () => null,
99
+ }),
100
+ });
101
+ for (const [index, testCase] of [
102
+ { url: 'https://example.com/document', expectedName: 'web_fetch' },
103
+ { url: 'http://127.0.0.1:4321/status', expectedName: 'local_fetch' },
104
+ ].entries()) {
105
+ const controller = new AbortController();
106
+ const running = executeTool(
107
+ 'web_fetch',
108
+ { url: testCase.url },
109
+ process.cwd(),
110
+ `cancel-test-${index}`,
111
+ {},
112
+ { toolCallId: `cancel-call-${index}`, signal: controller.signal },
113
+ );
114
+ controller.abort(new Error(`cancelled-by-test-${index}`));
115
+ await assert.rejects(running, new RegExp(`cancelled-by-test-${index}`));
116
+ assert.equal(received[index].name, testCase.expectedName);
117
+ assert.equal(received[index].signal, controller.signal);
118
+ }
119
+ });
120
+
121
+ test('local_fetch reads loopback and rejects redirect escape', async (t) => {
122
+ const server = http.createServer((req, res) => {
123
+ if (req.url === '/escape') {
124
+ res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' });
125
+ res.end();
126
+ return;
127
+ }
128
+ res.writeHead(200, { 'content-type': 'text/plain' });
129
+ res.end('local-ok');
130
+ });
131
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
132
+ t.after(() => server.close());
133
+ const { port } = server.address();
134
+ assert.equal(await fetchLoopbackText(`http://127.0.0.1:${port}/ok`), 'local-ok');
135
+ await assert.rejects(fetchLoopbackText(`http://127.0.0.1:${port}/escape`), /non-loopback/);
136
+ await assert.rejects(fetchLoopbackText('http://10.0.0.1/'), /non-loopback/);
137
+ });
138
+
139
+ test('public image fetch is bounded, media-shaped, and blocks private redirect targets', async () => {
140
+ const png = Buffer.from('89504e470d0a1a0a', 'hex');
141
+ const okFetch = async () => new Response(png, {
142
+ status: 200,
143
+ headers: { 'content-type': 'image/png', 'content-length': String(png.length) },
144
+ });
145
+ const image = await fetchPublicImage('https://images.example.com/a.png', { fetchImpl: okFetch });
146
+ assert.deepEqual(image, { mimeType: 'image/png', data: png.toString('base64'), bytes: png.length });
147
+
148
+ let calls = 0;
149
+ const redirectFetch = async () => {
150
+ calls++;
151
+ return new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/latest/meta-data/' } });
152
+ };
153
+ await assert.rejects(
154
+ fetchPublicImage('https://images.example.com/a.png', { fetchImpl: redirectFetch }),
155
+ /private address/,
156
+ );
157
+ assert.equal(calls, 1);
158
+ });
package/src/cli.mjs CHANGED
@@ -2,12 +2,20 @@
2
2
 
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { classifyCliInvocation } from './headless-command.mjs';
5
+ import {
6
+ beginProcessLifecycle,
7
+ finishProcessLifecycle,
8
+ } from './runtime/shared/process-lifecycle.mjs';
9
+ import { stagedChildExitCode } from './runtime/shared/staged-child-result.mjs';
5
10
 
6
11
  const argv = process.argv.slice(2);
7
12
 
8
13
  let swapped = false;
9
14
  const invocation = classifyCliInvocation(argv);
10
15
  const skipHostPrelude = invocation.kind === 'headless' || invocation.skipHostPrelude === true;
16
+ if (!skipHostPrelude) {
17
+ beginProcessLifecycle({ safeCommandLine: argv.length === 0 });
18
+ }
11
19
  if (!skipHostPrelude) {
12
20
  // Interactive/general sessions retain the staged-update and live-session
13
21
  // semantics. Headless role commands skip both because those helpers touch the
@@ -37,7 +45,9 @@ async function main() {
37
45
  env: { ...process.env, MIXDOG_SWAP_REEXEC: '1' },
38
46
  windowsHide: true,
39
47
  });
40
- if (!r.error) return Number.isInteger(r.status) ? r.status : 0;
48
+ if (!r.error) {
49
+ return stagedChildExitCode(r);
50
+ }
41
51
  } catch { /* fall through to in-place run */ }
42
52
  }
43
53
  const { run } = await import('./app.mjs');
@@ -45,8 +55,11 @@ async function main() {
45
55
  }
46
56
 
47
57
  main().then((code) => {
48
- process.exit(Number.isInteger(code) ? code : 0);
58
+ const exitCode = Number.isInteger(code) ? code : 0;
59
+ finishProcessLifecycle('clean-shutdown', exitCode);
60
+ process.exit(exitCode);
49
61
  }).catch((error) => {
50
62
  process.stderr.write(`${error?.stack || error?.message || String(error)}\n`);
63
+ finishProcessLifecycle('catchable-fatal-error', 1);
51
64
  process.exit(1);
52
65
  });
@@ -41,6 +41,7 @@ import {
41
41
  watchdogPartialHandoffFromError,
42
42
  AgentStallAbortError,
43
43
  } from './agent-progress-watchdog.mjs';
44
+ import { resourceAdmission } from '../../../shared/resource-admission.mjs';
44
45
 
45
46
  // Cap agent role synthesis to ~3000 tokens (~12 KB at the 4 B/tok
46
47
  // working average). Pool B explore/recall/search answers occasionally land
@@ -300,7 +301,27 @@ export function makeAgentDispatch(opts = {}) {
300
301
  if (typeof prompt !== 'string' || !prompt) {
301
302
  throw new Error(`[agent-dispatch] prompt required for agent "${agent}"`);
302
303
  }
304
+ const admission = opts.resourceAdmission || resourceAdmission;
305
+ const admissionAbortLink = composeAgentDispatchAbortSignal([
306
+ opts.parentSignal,
307
+ callParentSignal,
308
+ ]);
309
+ let lease;
310
+ try {
311
+ lease = await admission.acquire('agent', {
312
+ signal: admissionAbortLink.signal,
313
+ label: agent,
314
+ });
315
+ } catch (error) {
316
+ admissionAbortLink.dispose();
317
+ throw error;
318
+ }
319
+ try {
303
320
 
321
+ const runAdmitted = (task) => typeof admission.runWithLease === 'function'
322
+ ? admission.runWithLease(lease, task)
323
+ : task();
324
+ return await runAdmitted(async () => {
304
325
  const config = opts.config || loadConfig({ secrets: false });
305
326
  const routeOrName = resolveMaintenanceRoute({
306
327
  preset: presetArg,
@@ -535,5 +556,10 @@ export function makeAgentDispatch(opts = {}) {
535
556
  // try/catch falls through in unexpected ways.
536
557
  try { await updateSessionStatus(session.id, terminalStatus); } catch { /* ignore */ }
537
558
  }
559
+ });
560
+ } finally {
561
+ await lease.release();
562
+ admissionAbortLink.dispose();
563
+ }
538
564
  };
539
565
  }
@@ -259,7 +259,10 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
259
259
  export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
260
260
  if (!snapshot || !policy) return null;
261
261
 
262
- const startedAt = snapshot.modelRequestStartedAt || snapshot.askStartedAt;
262
+ const startedAt = snapshot.modelRequestStartedAt || 0;
263
+ // stage=connecting with no request timestamp means the provider request is
264
+ // still in the admission queue. Queue wait is outside every watchdog.
265
+ if (!startedAt && snapshot.stage === 'connecting') return null;
263
266
  const firstTransportMs = policy.firstTransportMs ?? policy.firstResponseMs ?? 0;
264
267
  const firstSemanticMs = policy.firstSemanticMs ?? policy.firstVisibleCeilingMs ?? 0;
265
268
  // Independent fixed deadlines from request start. Transport can satisfy
@@ -247,6 +247,7 @@ export function parseGrepCoverage(resultText, toolName, toolArgs, resultKind) {
247
247
  function classifyToolFailure(resultText, toolName) {
248
248
  const raw = String(resultText ?? '');
249
249
  const text = raw.toLowerCase();
250
+ if (isExpectedToolCancellation(raw)) return 'expected-cancellation';
250
251
  // Shell renderers put the machine-readable status on the leading line.
251
252
  // Only inspect that marker: command text and stderr frequently contain
252
253
  // words such as "timeout" or "aborted" and must not rewrite a real exit
@@ -273,11 +274,22 @@ function classifyToolFailure(resultText, toolName) {
273
274
  return 'runtime/failure';
274
275
  }
275
276
 
277
+ function isExpectedToolCancellation(resultText) {
278
+ const leading = String(resultText ?? '').split(/\r?\n/)
279
+ .map((line) => line.trim())
280
+ .find((line) => line && !line.startsWith('⚠️ '))
281
+ ?.replace(/^Error:\s*/i, '') || '';
282
+ return /^Session\s+"[^"]+"\s+closed:\s*(?:aborted|closed)\s+during call\b/i.test(leading);
283
+ }
284
+
276
285
  function traceAgentToolFailure({ sessionId, iteration, toolName, toolKind, toolMs, toolArgs, agent, model, cwd, resultText, resultKind = 'error' }) {
277
286
  if (process.env.MIXDOG_AGENT_TRACE_DISABLE === '1') return;
278
287
  if (!_resolveToolFailurePath()) return;
279
288
  try {
280
289
  const cleanText = _redactLogText(String(resultText ?? ''));
290
+ // A session close is deliberate orchestration, not a tool failure.
291
+ // traceAgentTool still records the error/category on the normal trace.
292
+ if (isExpectedToolCancellation(cleanText)) return;
281
293
  const row = {
282
294
  ts: Date.now(),
283
295
  session_id: normalizeSessionId(sessionId),
@@ -69,6 +69,8 @@ function _normalize(result) {
69
69
  });
70
70
  }
71
71
  if (result && typeof result === 'object' && Array.isArray(result.content)) {
72
+ const hasStructuredMedia = result.content.some((part) => part && typeof part === 'object' && part.type !== 'text');
73
+ if (hasStructuredMedia && result.isError !== true) return result;
72
74
  const text = result.content
73
75
  .map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
74
76
  .join('\n');