mixdog 0.9.25 → 0.9.27
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.
- package/package.json +2 -1
- package/scripts/arg-guard-test.mjs +68 -0
- package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/cli.mjs +40 -4
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +75 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/staged-install-worker.mjs +14 -0
- package/src/runtime/shared/staged-update.mjs +530 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +12 -0
- package/src/session-runtime/runtime-core.mjs +41 -23
- package/src/standalone/agent-tool/tool-def.mjs +1 -1
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +63 -7
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/Spinner.jsx +5 -2
- package/src/tui/components/StatusLine.jsx +7 -7
- package/src/tui/components/ToolExecution.jsx +36 -62
- package/src/tui/display-width.mjs +18 -8
- package/src/tui/dist/index.mjs +502 -324
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/hooks/useSharedTick.mjs +103 -0
- package/src/tui/index.jsx +12 -2
- package/src/tui/markdown/format-token.mjs +46 -8
- package/src/tui/markdown/render-ansi.mjs +24 -1
- package/src/tui/markdown/stream-fence.mjs +60 -0
- package/src/tui/markdown/streaming-markdown.mjs +22 -1
- package/src/ui/statusline-segments.mjs +12 -1
- package/vendor/ink/build/display-width.js +10 -8
- package/src/runtime/shared/schedules-store.mjs +0 -82
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"smoke:freevars": "node scripts/freevar-smoke.mjs",
|
|
49
49
|
"smoke:logguard": "node scripts/log-writer-guard-smoke.mjs",
|
|
50
50
|
"test:toolcall": "node --test scripts/toolcall-args-test.mjs",
|
|
51
|
+
"test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
|
|
51
52
|
"test:providers": "node --test scripts/provider-toolcall-test.mjs",
|
|
52
53
|
"test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
|
|
53
54
|
"failures": "node scripts/tool-failures.mjs",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Regression tests for validateBuiltinArgs numeric coercion + over-max clamp:
|
|
2
|
+
// - integer-shaped strings ("5") coerce to numbers instead of erroring
|
|
3
|
+
// - over-max integers clamp to the cap instead of erroring
|
|
4
|
+
// - truly non-numeric / below-min input keeps the existing error
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { validateBuiltinArgs } from '../src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs';
|
|
8
|
+
|
|
9
|
+
test('read offset/limit as numeric strings coerce to numbers', () => {
|
|
10
|
+
const a = { path: 'x.mjs', offset: '5', limit: '40' };
|
|
11
|
+
assert.equal(validateBuiltinArgs('read', a), null);
|
|
12
|
+
assert.equal(a.offset, 5);
|
|
13
|
+
assert.equal(a.limit, 40);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('read line/context as numeric strings coerce and window', () => {
|
|
17
|
+
const a = { path: 'x.mjs', line: '100', context: '5' };
|
|
18
|
+
assert.equal(validateBuiltinArgs('read', a), null);
|
|
19
|
+
assert.equal(a.offset, 94);
|
|
20
|
+
assert.equal(a.limit, 11);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('grep head_limit/offset/-C as numeric strings coerce', () => {
|
|
24
|
+
const a = { pattern: 'x', head_limit: '3', offset: '10', '-C': '2' };
|
|
25
|
+
assert.equal(validateBuiltinArgs('grep', a), null);
|
|
26
|
+
assert.equal(a.head_limit, 3);
|
|
27
|
+
assert.equal(a.offset, 10);
|
|
28
|
+
assert.equal(a['-C'], 2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('list/find/glob head_limit as numeric string coerces', () => {
|
|
32
|
+
const l = { path: '.', head_limit: '5' };
|
|
33
|
+
assert.equal(validateBuiltinArgs('list', l), null);
|
|
34
|
+
assert.equal(l.head_limit, 5);
|
|
35
|
+
const f = { query: 'x', head_limit: '5' };
|
|
36
|
+
assert.equal(validateBuiltinArgs('find', f), null);
|
|
37
|
+
assert.equal(f.head_limit, 5);
|
|
38
|
+
const g = { pattern: '*.mjs', head_limit: '5' };
|
|
39
|
+
assert.equal(validateBuiltinArgs('glob', g), null);
|
|
40
|
+
assert.equal(g.head_limit, 5);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('over-max integer clamps to the cap instead of erroring', () => {
|
|
44
|
+
// read n cap is 0..10000; a huge value clamps down instead of erroring.
|
|
45
|
+
const a = { path: 'x.mjs', n: 999999999 };
|
|
46
|
+
assert.equal(validateBuiltinArgs('read', a), null);
|
|
47
|
+
assert.equal(a.n, 10000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('over-max numeric-string clamps too', () => {
|
|
51
|
+
const a = { path: 'x.mjs', n: '999999999' };
|
|
52
|
+
assert.equal(validateBuiltinArgs('read', a), null);
|
|
53
|
+
assert.equal(a.n, 10000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('non-numeric string still errors', () => {
|
|
57
|
+
assert.match(validateBuiltinArgs('read', { path: 'x.mjs', limit: 'soon' }), /must be an integer/);
|
|
58
|
+
assert.match(validateBuiltinArgs('list', { path: '.', head_limit: 'soon' }), /finite integer/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('below-min (negative) still errors', () => {
|
|
62
|
+
assert.match(validateBuiltinArgs('read', { path: 'x.mjs', offset: -1 }), /must be >= 0/);
|
|
63
|
+
assert.match(validateBuiltinArgs('list', { path: '.', head_limit: '-2' }), /must be >= 0/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('fractional numeric string is not coerced (still errors)', () => {
|
|
67
|
+
assert.match(validateBuiltinArgs('read', { path: 'x.mjs', limit: '3.5' }), /must be an integer/);
|
|
68
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test for the pre-send compacted-placeholder invariant: no
|
|
3
|
+
// provider-visible assistant toolCall may ship a `[mixdog compacted …]`
|
|
4
|
+
// placeholder body that the model could copy back as apply_patch input.
|
|
5
|
+
// scrubCompactedPlaceholderToolCalls is the single enforcement point invoked
|
|
6
|
+
// by repairTranscriptBeforeProviderSend right before provider.send.
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import {
|
|
10
|
+
compactToolCallsForHistory,
|
|
11
|
+
scrubCompactedPlaceholderToolCalls,
|
|
12
|
+
} from '../src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs';
|
|
13
|
+
import { repairTranscriptBeforeProviderSend } from '../src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs';
|
|
14
|
+
|
|
15
|
+
// A patch body longer than the body limit compacts to a marker-alone string.
|
|
16
|
+
const BIG_PATCH = '*** Begin Patch\n' + '+x\n'.repeat(2000) + '*** End Patch';
|
|
17
|
+
|
|
18
|
+
function assistantWithCompactedPatch(id = 'call_1') {
|
|
19
|
+
const calls = [{ id, name: 'apply_patch', arguments: { patch: BIG_PATCH, base_path: '/repo' } }];
|
|
20
|
+
return { role: 'assistant', content: '', toolCalls: compactToolCallsForHistory(calls) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('compact leaves a placeholder patch body (precondition)', () => {
|
|
24
|
+
const msg = assistantWithCompactedPatch();
|
|
25
|
+
assert.match(msg.toolCalls[0].arguments.patch, /^\[mixdog compacted /);
|
|
26
|
+
assert.equal(msg.toolCalls[0].arguments.base_path, '/repo');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('scrub drops the placeholder patch key, keeps other args', () => {
|
|
30
|
+
const msg = assistantWithCompactedPatch();
|
|
31
|
+
scrubCompactedPlaceholderToolCalls([msg]);
|
|
32
|
+
assert.equal('patch' in msg.toolCalls[0].arguments, false);
|
|
33
|
+
assert.equal(msg.toolCalls[0].arguments.base_path, '/repo');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('scrub is recursive across nested batch body args', () => {
|
|
37
|
+
const calls = [{
|
|
38
|
+
id: 'call_2', name: 'edit',
|
|
39
|
+
arguments: { edits: [{ path: 'a.js', old_string: BIG_PATCH, new_string: 'ok' }] },
|
|
40
|
+
}];
|
|
41
|
+
const msg = { role: 'assistant', content: '', toolCalls: compactToolCallsForHistory(calls) };
|
|
42
|
+
assert.match(msg.toolCalls[0].arguments.edits[0].old_string, /^\[mixdog compacted /);
|
|
43
|
+
scrubCompactedPlaceholderToolCalls([msg]);
|
|
44
|
+
const edit = msg.toolCalls[0].arguments.edits[0];
|
|
45
|
+
assert.equal('old_string' in edit, false);
|
|
46
|
+
assert.equal(edit.path, 'a.js');
|
|
47
|
+
assert.equal(edit.new_string, 'ok');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('scrub leaves real (restored) patch bodies untouched', () => {
|
|
51
|
+
const real = '*** Begin Patch\n@@\n-a\n+b\n*** End Patch';
|
|
52
|
+
const msg = { role: 'assistant', content: '', toolCalls: [
|
|
53
|
+
{ id: 'call_3', name: 'apply_patch', arguments: { patch: real } },
|
|
54
|
+
] };
|
|
55
|
+
scrubCompactedPlaceholderToolCalls([msg]);
|
|
56
|
+
assert.equal(msg.toolCalls[0].arguments.patch, real);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('scrub ignores non-assistant / non-toolCall messages', () => {
|
|
60
|
+
const msgs = [
|
|
61
|
+
{ role: 'user', content: 'hi' },
|
|
62
|
+
{ role: 'tool', content: 'x', toolCallId: 'call_1' },
|
|
63
|
+
{ role: 'assistant', content: 'text only' },
|
|
64
|
+
];
|
|
65
|
+
assert.doesNotThrow(() => scrubCompactedPlaceholderToolCalls(msgs));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('repairTranscriptBeforeProviderSend enforces the invariant end-to-end', () => {
|
|
69
|
+
const msgs = [
|
|
70
|
+
{ role: 'user', content: 'do it' },
|
|
71
|
+
assistantWithCompactedPatch('call_9'),
|
|
72
|
+
{ role: 'tool', content: 'applied', toolCallId: 'call_9' },
|
|
73
|
+
];
|
|
74
|
+
repairTranscriptBeforeProviderSend(msgs, null);
|
|
75
|
+
const asst = msgs.find((m) => m.role === 'assistant');
|
|
76
|
+
assert.equal('patch' in asst.toolCalls[0].arguments, false);
|
|
77
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { foldUserTextIntoToolResultTail } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
4
|
+
import { _buildRequestBodyForCacheSmoke } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
|
|
5
|
+
|
|
6
|
+
// A tool_result turn followed by a plain user text turn.
|
|
7
|
+
function baseTranscript(userMeta) {
|
|
8
|
+
return [
|
|
9
|
+
{ role: 'user', content: 'do the thing' },
|
|
10
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 't1', name: 'read', arguments: {} }] },
|
|
11
|
+
{ role: 'tool', toolCallId: 't1', content: 'file contents here' },
|
|
12
|
+
{ role: 'user', content: 'actually stop and do X instead', ...(userMeta ? { meta: userMeta } : {}) },
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('plain user text after tool_result is folded into the tool_result turn', () => {
|
|
17
|
+
const body = _buildRequestBodyForCacheSmoke(baseTranscript(null), 'claude-sonnet-4');
|
|
18
|
+
const msgs = body.messages;
|
|
19
|
+
const lastUser = msgs[msgs.length - 1];
|
|
20
|
+
assert.equal(lastUser.role, 'user');
|
|
21
|
+
const hasToolResult = lastUser.content.some((b) => b.type === 'tool_result');
|
|
22
|
+
assert.ok(hasToolResult, 'trailing user turn carries the tool_result');
|
|
23
|
+
assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'plain text folded into tool_result');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('steering-tagged user text after tool_result stays a separate user turn', () => {
|
|
27
|
+
const body = _buildRequestBodyForCacheSmoke(baseTranscript({ source: 'steering' }), 'claude-sonnet-4');
|
|
28
|
+
const msgs = body.messages;
|
|
29
|
+
const lastUser = msgs[msgs.length - 1];
|
|
30
|
+
assert.equal(lastUser.role, 'user', 'steering message is its own user turn');
|
|
31
|
+
const isToolResultTurn = Array.isArray(lastUser.content)
|
|
32
|
+
&& lastUser.content.some((b) => b.type === 'tool_result');
|
|
33
|
+
assert.equal(isToolResultTurn, false, 'steering turn is not a tool_result turn');
|
|
34
|
+
assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'steering text preserved as user input');
|
|
35
|
+
const prev = msgs[msgs.length - 2];
|
|
36
|
+
assert.ok(
|
|
37
|
+
Array.isArray(prev.content) && prev.content.some((b) => b.type === 'tool_result'),
|
|
38
|
+
'tool_result stays on its own preceding turn',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('foldUserTextIntoToolResultTail unit: folds plain text tail', () => {
|
|
43
|
+
const result = [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'r' }] }];
|
|
44
|
+
assert.equal(foldUserTextIntoToolResultTail(result, 'hi'), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// --- API-key Anthropic provider (anthropic.mjs) path ---
|
|
48
|
+
import { _toAnthropicMessagesForTest } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
|
|
49
|
+
|
|
50
|
+
test('anthropic.mjs: plain user text after tool_result is folded', () => {
|
|
51
|
+
const msgs = _toAnthropicMessagesForTest(baseTranscript(null));
|
|
52
|
+
const lastUser = msgs[msgs.length - 1];
|
|
53
|
+
assert.equal(lastUser.role, 'user');
|
|
54
|
+
assert.ok(lastUser.content.some((b) => b.type === 'tool_result'), 'trailing turn carries tool_result');
|
|
55
|
+
assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'plain text folded into tool_result');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('anthropic.mjs: steering-tagged user text stays a separate user turn', () => {
|
|
59
|
+
const msgs = _toAnthropicMessagesForTest(baseTranscript({ source: 'steering' }));
|
|
60
|
+
const lastUser = msgs[msgs.length - 1];
|
|
61
|
+
assert.equal(lastUser.role, 'user', 'steering message is its own user turn');
|
|
62
|
+
const isToolResultTurn = Array.isArray(lastUser.content)
|
|
63
|
+
&& lastUser.content.some((b) => b.type === 'tool_result');
|
|
64
|
+
assert.equal(isToolResultTurn, false, 'steering turn is not a tool_result turn');
|
|
65
|
+
assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'steering text preserved as user input');
|
|
66
|
+
const prev = msgs[msgs.length - 2];
|
|
67
|
+
assert.ok(
|
|
68
|
+
Array.isArray(prev.content) && prev.content.some((b) => b.type === 'tool_result'),
|
|
69
|
+
'tool_result stays on its own preceding turn',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import * as crypto from 'node:crypto';
|
|
4
|
-
import {
|
|
4
|
+
import { mkdtempSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { readMarkdownDocument } from '../src/runtime/shared/markdown-frontmatter.mjs';
|
|
8
8
|
|
|
9
9
|
// DATA_DIR resolves from MIXDOG_DATA_DIR at module import time (via
|
|
10
10
|
// ./config.mjs -> resolvePluginData), so the env var MUST be set before
|
|
11
|
-
// the first dynamic import of webhook.mjs.
|
|
11
|
+
// the first dynamic import of webhook.mjs. Endpoint defs + delivery dedup now
|
|
12
|
+
// live in PG; this smoke suite deliberately exercises ONLY the pure,
|
|
13
|
+
// PG-free surface (signature verify + header helpers) so it needs no live DB.
|
|
12
14
|
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-webhook-test-'));
|
|
13
15
|
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
14
16
|
|
|
15
17
|
const {
|
|
16
18
|
extractSignature,
|
|
17
19
|
verifySignature,
|
|
18
|
-
loadEndpointConfig,
|
|
19
20
|
STRIPE_TOLERANCE_MS,
|
|
20
21
|
} = await import('../src/runtime/channels/lib/webhook.mjs');
|
|
22
|
+
const { extractDeliveryId, buildHeadersSummary } = await import(
|
|
23
|
+
'../src/runtime/channels/lib/webhook/deliveries.mjs'
|
|
24
|
+
);
|
|
21
25
|
|
|
22
26
|
function hmacHex(secret, payload) {
|
|
23
27
|
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
@@ -141,56 +145,45 @@ test('extractSignature: returns null when no known signature header is present',
|
|
|
141
145
|
assert.equal(extractSignature(headers, 'github'), null);
|
|
142
146
|
});
|
|
143
147
|
|
|
144
|
-
// ──
|
|
145
|
-
|
|
146
|
-
test('
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
assert.equal(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test('loadEndpointConfig: returns null for a name with no WEBHOOK.md', () => {
|
|
186
|
-
assert.equal(loadEndpointConfig('never-registered'), null);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// loadEndpointConfig only surfaces the frontmatter (by design — see
|
|
190
|
-
// webhook.mjs comment above loadEndpointConfig); confirm the underlying
|
|
191
|
-
// shared parser it delegates to also recovers the markdown body, since
|
|
192
|
-
// the loader's frontmatter/body split is not independently exercised
|
|
193
|
-
// through the exported loadEndpointConfig surface.
|
|
148
|
+
// ── delivery-id extraction: header precedence + null fallback ─────────
|
|
149
|
+
|
|
150
|
+
test('extractDeliveryId: prefers x-github-delivery over other id headers', () => {
|
|
151
|
+
const headers = { 'x-github-delivery': 'gh-1', 'x-delivery-id': 'dl-1', 'x-request-id': 'rq-1' };
|
|
152
|
+
assert.equal(extractDeliveryId(headers), 'gh-1');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('extractDeliveryId: falls back to x-delivery-id then x-request-id', () => {
|
|
156
|
+
assert.equal(extractDeliveryId({ 'x-delivery-id': 'dl-1', 'x-request-id': 'rq-1' }), 'dl-1');
|
|
157
|
+
assert.equal(extractDeliveryId({ 'x-request-id': 'rq-1' }), 'rq-1');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('extractDeliveryId: returns null when no id header is present', () => {
|
|
161
|
+
assert.equal(extractDeliveryId({ 'content-type': 'application/json' }), null);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── headers summary: event/delivery/content-type + signature presence ─
|
|
165
|
+
|
|
166
|
+
test('buildHeadersSummary: captures event, delivery, content-type and signature presence', () => {
|
|
167
|
+
const summary = buildHeadersSummary({
|
|
168
|
+
'x-github-event': 'issues',
|
|
169
|
+
'x-github-delivery': 'gh-1',
|
|
170
|
+
'x-hub-signature-256': 'sha256=abc',
|
|
171
|
+
'content-type': 'application/json',
|
|
172
|
+
});
|
|
173
|
+
assert.equal(summary.event_type, 'issues');
|
|
174
|
+
assert.equal(summary.delivery_id, 'gh-1');
|
|
175
|
+
assert.equal(summary.signature_present, true);
|
|
176
|
+
assert.equal(summary.content_type, 'application/json');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('buildHeadersSummary: signature_present is false when no signature header is present', () => {
|
|
180
|
+
const summary = buildHeadersSummary({ 'x-github-event': 'push' });
|
|
181
|
+
assert.equal(summary.signature_present, false);
|
|
182
|
+
assert.equal(summary.event_type, 'push');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// The webhook body/instructions still round-trip through the shared markdown
|
|
186
|
+
// parser (frontmatter + body split); confirm that pure surface directly.
|
|
194
187
|
test('readMarkdownDocument: WEBHOOK.md-shaped input yields frontmatter + body', () => {
|
|
195
188
|
const raw = [
|
|
196
189
|
'---',
|
package/src/cli.mjs
CHANGED
|
@@ -1,10 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { performPendingSwap, registerLiveSession } from './runtime/shared/staged-update.mjs';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const argv = process.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
// Pre-import self-update swap: BEFORE any runtime module is imported, if a
|
|
9
|
+
// completed staged newer version exists and no other mixdog session is live,
|
|
10
|
+
// atomically swap the global package dir into place. Doing it here (and only
|
|
11
|
+
// here) means the process goes on to load the NEW files, and a live session
|
|
12
|
+
// never has its files overwritten mid-run. Any obstacle → false, run current.
|
|
13
|
+
let swapped = false;
|
|
14
|
+
try { swapped = performPendingSwap(); } catch { swapped = false; }
|
|
15
|
+
|
|
16
|
+
// Register this process in the live-session refcount BEFORE any runtime module
|
|
17
|
+
// loads, so a concurrently-launching mixdog's liveness check can see us and
|
|
18
|
+
// defer its own swap. Unregister is hooked on process 'exit' (and on graceful
|
|
19
|
+
// close). Harmless for the swap decision above — our own pid is excluded.
|
|
20
|
+
try { registerLiveSession(); } catch { /* advisory refcount only */ }
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
// If we actually swapped, re-exec a fresh node process so the new package
|
|
24
|
+
// loads with a clean module cache (no stale pre-swap modules mixed in). The
|
|
25
|
+
// env guard prevents re-swap / relaunch loops. Foreground + inherited stdio
|
|
26
|
+
// keeps the interactive TUI intact. Best-effort: if the re-exec spawn fails,
|
|
27
|
+
// fall through and run in-place.
|
|
28
|
+
if (swapped && !process.env.MIXDOG_SWAP_REEXEC) {
|
|
29
|
+
try {
|
|
30
|
+
const { spawnSync } = await import('node:child_process');
|
|
31
|
+
const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...argv], {
|
|
32
|
+
stdio: 'inherit',
|
|
33
|
+
env: { ...process.env, MIXDOG_SWAP_REEXEC: '1' },
|
|
34
|
+
});
|
|
35
|
+
if (!r.error) return Number.isInteger(r.status) ? r.status : 0;
|
|
36
|
+
} catch { /* fall through to in-place run */ }
|
|
37
|
+
}
|
|
38
|
+
const { run } = await import('./app.mjs');
|
|
39
|
+
return await run(argv);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
main().then((code) => {
|
|
43
|
+
process.exit(Number.isInteger(code) ? code : 0);
|
|
8
44
|
}).catch((error) => {
|
|
9
45
|
process.stderr.write(`${error?.stack || error?.message || String(error)}\n`);
|
|
10
46
|
process.exit(1);
|
|
@@ -261,7 +261,12 @@ TUI 피커 없음 (settings-api `setSystemShell`만 존재).
|
|
|
261
261
|
### 스케줄 / 웹훅 ("스케줄 추가", "웹훅")
|
|
262
262
|
|
|
263
263
|
1. **확인**: `/schedules` / `/webhooks` (둘 다 `/channels` 허브의 섹션 딥링크).
|
|
264
|
-
2. **변경**: 해당 피커에서
|
|
264
|
+
2. **변경**: 해당 피커에서 추가/편집.
|
|
265
|
+
- **스케줄 저장소 = PG 테이블 `scheduler.schedules`** (더 이상 `<mixdogData>/schedules/<n>/SCHEDULE.md` 파일 아님). 관리 API `saveSchedule/deleteSchedule/setScheduleEnabled/listSchedules` (`channel-admin.mjs`) → 스토어 `schedules-db.mjs`.
|
|
266
|
+
- **필드**: 반복형은 `time`(5·6필드 cron) + 선택 `days`(cron 요일필드로 접힘: daily→`*`, weekday→`1-5`, weekend→`0,6`, `mon,wed,fri`/`1,3,5`→숫자; 못 매핑하면 에러) → `when_cron`. 1회성은 `at`(datetime) → `when_at`. `time`·`at`은 **택1**(스토어 XOR). `channel` 지정 시 `target=channel`+`channel_id`(이때 `model` 필수), 없으면 `target=session`. 본문(instructions)=`prompt`.
|
|
267
|
+
- **레거시 마이그레이션**: 기존 `schedules/` 디렉터리는 스토어 첫 init 시 SCHEDULE.md들을 테이블로 1회 자동 임포트(같은 days→cron 접힘) 후 디렉터리를 `schedules.migrated`로 rename(삭제 안 함). 한 줄 요약 로그.
|
|
268
|
+
- **웹훅 저장소 = PG 테이블 `webhooks.endpoints`** (더 이상 `<mixdogData>/webhooks/<n>/WEBHOOK.md` + `secret` 파일 아님). 관리 API `saveWebhook/deleteWebhook/setWebhookEnabled/listWebhooks` (`channel-admin.mjs`) → 스토어 `webhooks-db.mjs`(`upsertEndpoint/deleteEndpoint/setEndpointEnabled/listEndpoints`). 필드: `parser`(github·generic·stripe·sentry), 선택 `channel`(지정 시 `model` 필수)→`channel_id`, `secret`(미지정 시 랜덤 생성, 컬럼에 저장), 본문(instructions), `enabled`.
|
|
269
|
+
- **웹훅 레거시 마이그레이션**: 기존 `webhooks/` 디렉터리는 스토어 첫 init 시 각 `WEBHOOK.md`+`secret`을 `upsertEndpoint`로 1회 자동 임포트 후 디렉터리를 **삭제**(rename 아님 — 사용자 선택). 부분 실패 시 디렉터리 보존·로그·다음 부팅 재시도. 옛 per-endpoint deliveries(중복제거 이력)는 임포트 안 함(리셋 허용).
|
|
265
270
|
3. **검증**: 피커 목록 반영; 스케줄은 다음 발동 시각 표시.
|
|
266
271
|
|
|
267
272
|
### 원격 세션 ("리모트 가져와")
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Tool Use
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
- Every turn carries ALL independent calls you can already specify — a
|
|
4
|
+
second consecutive single-lookup turn is a defect, not a style choice;
|
|
5
|
+
serialize only on real data dependency. Merge variants/scopes into ONE
|
|
6
|
+
call wherever the schema accepts arrays (`pattern[]`, `path[]`,
|
|
7
|
+
`symbols[]`, `query[]`).
|
|
6
8
|
- Route by what is already known: known symbol/relation → `code_graph`;
|
|
7
9
|
exact text in a known scope → `grep`; unknown location, machine-wide/
|
|
8
10
|
out-of-repo whereabouts, or concept-level question → `explore` (which uses
|
|
@@ -31,4 +33,9 @@
|
|
|
31
33
|
unit (function/section) — over-read once instead of paging the same file
|
|
32
34
|
in small windows across turns; a 3rd window into one file means the first
|
|
33
35
|
should have been wider.
|
|
34
|
-
- Don't mix `apply_patch` with shell or other state-changing calls in one
|
|
36
|
+
- Don't mix `apply_patch` with shell or other state-changing calls in one
|
|
37
|
+
turn; batch independent-file patches in one turn, then verify them all in
|
|
38
|
+
ONE shell call the next.
|
|
39
|
+
- Consecutive single `shell` or `apply_patch` turns are a batching miss
|
|
40
|
+
unless output-dependent: chain shell commands with `;`/`&&` or call in
|
|
41
|
+
parallel; put all known edits in ONE patch.
|
|
@@ -349,7 +349,9 @@ export function traceAgentBatch({ sessionId, toolCallCount }) {
|
|
|
349
349
|
appendAgentTrace({
|
|
350
350
|
sessionId,
|
|
351
351
|
kind: 'batch',
|
|
352
|
-
tool_call_count
|
|
352
|
+
// trace_events has no tool_call_count column — top-level unknown
|
|
353
|
+
// fields are dropped at insert time, so carry it in payload (jsonb).
|
|
354
|
+
payload: { tool_call_count: toolCallCount },
|
|
353
355
|
});
|
|
354
356
|
}
|
|
355
357
|
|
|
@@ -3,6 +3,7 @@ import { appendFile } from 'fs/promises';
|
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { getPluginData } from './config.mjs';
|
|
6
|
+
import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../shared/service-discovery.mjs';
|
|
6
7
|
|
|
7
8
|
const WARNED_KEYS = new Set();
|
|
8
9
|
|
|
@@ -15,6 +16,10 @@ const _FLUSH_INTERVAL_MS = 5000;
|
|
|
15
16
|
const _FLUSH_BATCH_SIZE = 500;
|
|
16
17
|
let _flushTimer = null;
|
|
17
18
|
let _serviceUrl = null;
|
|
19
|
+
// Port of the discovery advert `_serviceUrl` was resolved from (null when it
|
|
20
|
+
// came from legacy active-instance.json). Lets a flush connect-failure distrust
|
|
21
|
+
// a recycled-pid corpse advert so the next resolve falls back to legacy/buffer.
|
|
22
|
+
let _serviceAdvertPort = null;
|
|
18
23
|
let _flushInFlight = false;
|
|
19
24
|
let _localTracePath = null;
|
|
20
25
|
let _localTraceBuffer = [];
|
|
@@ -197,6 +202,11 @@ try {
|
|
|
197
202
|
function _resolveServiceUrl() {
|
|
198
203
|
if (_serviceUrl) return _serviceUrl;
|
|
199
204
|
try {
|
|
205
|
+
// Prefer the single-writer discovery advert (discovery/memory.json),
|
|
206
|
+
// pid-validated; fall back to legacy active-instance.json memory_port.
|
|
207
|
+
const advertPort = readServicePort('memory', { requirePid: false });
|
|
208
|
+
if (advertPort) { _serviceAdvertPort = advertPort; _serviceUrl = `http://127.0.0.1:${advertPort}`; return _serviceUrl; }
|
|
209
|
+
_serviceAdvertPort = null;
|
|
200
210
|
const runtimeRoot = process.env.MIXDOG_RUNTIME_ROOT
|
|
201
211
|
? join(process.env.MIXDOG_RUNTIME_ROOT)
|
|
202
212
|
: join(os.tmpdir(), 'mixdog');
|
|
@@ -245,6 +255,13 @@ async function _flush() {
|
|
|
245
255
|
}
|
|
246
256
|
} catch (err) {
|
|
247
257
|
_serviceUrl = null;
|
|
258
|
+
// Discovery-first consumer with no health probe: a connect failure
|
|
259
|
+
// means the pid-validated advert points at a dead (recycled-pid)
|
|
260
|
+
// port. Distrust it (connection-level errors ONLY — a timeout is a
|
|
261
|
+
// slow-but-alive daemon, not a corpse) so the next resolve falls back
|
|
262
|
+
// to legacy/buffer instead of re-trusting the same advert.
|
|
263
|
+
if (_serviceAdvertPort && isConnRefuseError(err)) markServiceUnreachable('memory', _serviceAdvertPort);
|
|
264
|
+
_serviceAdvertPort = null;
|
|
248
265
|
warnAgentOnce('agent-trace:flush-fetch', `[agent-trace] flush fetch failed (${err?.message}) — dropping batch`);
|
|
249
266
|
}
|
|
250
267
|
if (_buffer.length >= _FLUSH_BATCH_SIZE) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// tree (taskkill /T /F on Windows, SIGTERM->SIGKILL of every descendant on
|
|
10
10
|
// POSIX). No external dependencies.
|
|
11
11
|
import { spawn } from 'node:child_process';
|
|
12
|
+
import { detachedSpawnOpts } from '../../../shared/spawn-flags.mjs';
|
|
12
13
|
|
|
13
14
|
const isWin = process.platform === 'win32';
|
|
14
15
|
|
|
@@ -107,9 +108,8 @@ export function killStdioChildTreeFast(transport) {
|
|
|
107
108
|
if (isWin) {
|
|
108
109
|
try {
|
|
109
110
|
const cp = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
110
|
-
windowsHide: true,
|
|
111
|
-
detached: true,
|
|
112
111
|
stdio: 'ignore',
|
|
112
|
+
...detachedSpawnOpts,
|
|
113
113
|
});
|
|
114
114
|
cp.unref();
|
|
115
115
|
} catch { /* ignore */ }
|
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'os';
|
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
|
|
7
7
|
import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
|
|
8
|
+
import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../../shared/service-discovery.mjs';
|
|
8
9
|
// --- Types ---
|
|
9
10
|
/** Known auto-detect targets: port file path relative to tmpdir.
|
|
10
11
|
* Note: `mixdog` used to self-loopback via active-instance.json's
|
|
@@ -13,7 +14,7 @@ import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
|
|
|
13
14
|
* in-process through agent's toolExecutor (see orchestrator/internal-tools),
|
|
14
15
|
* so this registry is for genuinely external port-based MCP targets only. */
|
|
15
16
|
const AUTO_DETECT_PORTS = {
|
|
16
|
-
'mixdog-memory': { dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
|
|
17
|
+
'mixdog-memory': { discovery: 'memory', dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
|
|
17
18
|
};
|
|
18
19
|
const DEFAULT_MCP_CALL_TIMEOUT_MS = 0;
|
|
19
20
|
// Per-server STARTUP handshake budget (connect + listTools). Codex parity: 10s.
|
|
@@ -452,20 +453,31 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
452
453
|
const client = new Client({ name: `mixdog-agent/${name}`, version: '1.0.0' });
|
|
453
454
|
let transport;
|
|
454
455
|
const kind = resolveMcpTransportKind(cfg);
|
|
456
|
+
// When the autoDetect port comes from a discovery advert (not the legacy
|
|
457
|
+
// port file), remember { service, port } so a connect/handshake failure can
|
|
458
|
+
// distrust it — a pid-live advert can point at a recycled-pid corpse port,
|
|
459
|
+
// and this transport has no other health probe of its own.
|
|
460
|
+
let _autoDetectAdvert = null;
|
|
455
461
|
// Auto-detect: read port from a running service's port file
|
|
456
462
|
if (kind === 'autoDetect') {
|
|
457
463
|
const spec = AUTO_DETECT_PORTS[cfg.autoDetect];
|
|
458
464
|
if (!spec)
|
|
459
465
|
throw new Error(`Unknown autoDetect target: "${cfg.autoDetect}"`);
|
|
460
|
-
|
|
466
|
+
// Prefer the single-writer discovery advert (discovery/<service>.json),
|
|
467
|
+
// pid-validated. Fall back to the legacy active-instance.json portField
|
|
468
|
+
// when no live advert is present (cross-version compat).
|
|
469
|
+
let port = spec.discovery ? readServicePort(spec.discovery, { requirePid: false }) : null;
|
|
470
|
+
if (port && spec.discovery) _autoDetectAdvert = { service: spec.discovery, port };
|
|
471
|
+
let portFile = null;
|
|
472
|
+
if (!port) {
|
|
473
|
+
portFile = spec.dir === 'mixdog' && process.env.MIXDOG_RUNTIME_ROOT
|
|
461
474
|
? join(process.env.MIXDOG_RUNTIME_ROOT, spec.file)
|
|
462
475
|
: join(tmpdir(), spec.dir, spec.file);
|
|
463
|
-
|
|
476
|
+
if (!existsSync(portFile)) {
|
|
464
477
|
throw new Error(`autoDetect server "${name}": port file missing (${portFile})`);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (spec.portField) {
|
|
478
|
+
}
|
|
479
|
+
const raw = readFileSync(portFile, 'utf-8').trim();
|
|
480
|
+
if (spec.portField) {
|
|
469
481
|
try {
|
|
470
482
|
const json = JSON.parse(raw);
|
|
471
483
|
const v = json[spec.portField];
|
|
@@ -477,12 +489,13 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
477
489
|
if (jsonErr instanceof Error && jsonErr.message.startsWith('autoDetect server')) throw jsonErr;
|
|
478
490
|
throw new Error(`autoDetect server "${name}": invalid JSON in port file ${portFile}`);
|
|
479
491
|
}
|
|
480
|
-
|
|
481
|
-
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
482
494
|
port = parseInt(raw, 10);
|
|
495
|
+
}
|
|
483
496
|
}
|
|
484
497
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
485
|
-
throw new Error(`autoDetect server "${name}": invalid port value in ${portFile}`);
|
|
498
|
+
throw new Error(`autoDetect server "${name}": invalid port value${portFile ? ` in ${portFile}` : ''}`);
|
|
486
499
|
}
|
|
487
500
|
const url = `http://127.0.0.1:${port}${spec.endpoint}`;
|
|
488
501
|
transport = new StreamableHTTPClientTransport(new URL(url));
|
|
@@ -568,6 +581,11 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
568
581
|
({ instructions, toolsResult } = await handshake);
|
|
569
582
|
}
|
|
570
583
|
} catch (err) {
|
|
584
|
+
// A discovery-advert port that fails to connect is a corpse (recycled
|
|
585
|
+
// pid): distrust it so the next connect falls back to the legacy port
|
|
586
|
+
// file instead of re-trusting the same advert. Connection-level errors
|
|
587
|
+
// ONLY — a startup/handshake timeout is a slow-but-alive server.
|
|
588
|
+
if (_autoDetectAdvert && isConnRefuseError(err)) markServiceUnreachable(_autoDetectAdvert.service, _autoDetectAdvert.port);
|
|
571
589
|
if (startupTimedOut) {
|
|
572
590
|
// Tear down the pending transport/child so a hung handshake never
|
|
573
591
|
// leaks a stdio process or socket — fire-and-forget (like the
|
|
@@ -396,7 +396,14 @@ function toAnthropicMessages(messages) {
|
|
|
396
396
|
// turn after tool_result renders as `</function_results>\n\nHuman:`
|
|
397
397
|
// on the wire and trains the model toward 3-token empty end_turn
|
|
398
398
|
// completions (see foldUserTextIntoToolResultTail).
|
|
399
|
-
|
|
399
|
+
// EXCEPTION: steering-origin user messages (human/TUI interjections)
|
|
400
|
+
// must keep their own user turn so their provenance survives — folding
|
|
401
|
+
// them into the preceding tool_result would disguise user input as
|
|
402
|
+
// tool output. Anthropic accepts a user text message after a
|
|
403
|
+
// tool_result message, so the distinct turn stays request-valid.
|
|
404
|
+
const isSteering = m.role === 'user' && m.meta?.source === 'steering';
|
|
405
|
+
if (m.role === 'user' && !isSteering
|
|
406
|
+
&& foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
|
|
400
407
|
continue;
|
|
401
408
|
}
|
|
402
409
|
result.push({ role: m.role, content: normalizeContentForAnthropic(m.content) });
|