memorix 1.1.7 → 1.1.8
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/CHANGELOG.md +13 -0
- package/CLAUDE.md +6 -1
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/TEAM.md +86 -86
- package/dist/cli/index.js +730 -150
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/index.html +201 -201
- package/dist/dashboard/static/style.css +3584 -3584
- package/dist/index.js +15 -8
- package/dist/index.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +13 -0
- package/dist/memcode-runtime/package.json +4 -4
- package/dist/sdk.js +15 -8
- package/dist/sdk.js.map +1 -1
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +18 -0
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/SETUP.md +10 -0
- package/docs/dev-log/progress.txt +35 -26
- package/package.json +1 -1
- package/src/audit/index.ts +156 -156
- package/src/cli/commands/agent-integrations.ts +623 -0
- package/src/cli/commands/audit-list.ts +89 -89
- package/src/cli/commands/background.ts +659 -659
- package/src/cli/commands/cleanup.ts +255 -255
- package/src/cli/commands/doctor.ts +23 -0
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +66 -66
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest-commit.ts +153 -153
- package/src/cli/commands/ingest-image.ts +73 -73
- package/src/cli/commands/ingest-log.ts +180 -180
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +96 -96
- package/src/cli/commands/message.ts +121 -121
- package/src/cli/commands/poll.ts +70 -70
- package/src/cli/commands/purge-all-memory.ts +85 -85
- package/src/cli/commands/purge-project-memory.ts +83 -83
- package/src/cli/commands/reasoning.ts +132 -132
- package/src/cli/commands/repair.ts +60 -0
- package/src/cli/commands/retention.ts +108 -108
- package/src/cli/commands/serve-shared.ts +118 -118
- package/src/cli/commands/setup.ts +3 -3
- package/src/cli/commands/skills.ts +123 -123
- package/src/cli/commands/task.ts +192 -192
- package/src/cli/commands/transfer.ts +73 -73
- package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/Panels.tsx +632 -632
- package/src/cli/tui/Sidebar.tsx +179 -179
- package/src/cli/tui/chat-service.ts +742 -742
- package/src/cli/tui/data.ts +547 -547
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/tui/markdown-render.tsx +371 -371
- package/src/cli/tui/theme.ts +178 -178
- package/src/cli/tui/use-mouse.ts +157 -157
- package/src/cli/tui/useNavigation.ts +56 -56
- package/src/cli/update-checker.ts +211 -211
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/codegraph/context-pack.ts +5 -1
- package/src/codegraph/lite-provider.ts +5 -1
- package/src/codegraph/project-context.ts +5 -1
- package/src/compact/token-budget.ts +74 -74
- package/src/config/behavior.ts +59 -59
- package/src/dashboard/project-classification.ts +64 -64
- package/src/dashboard/static/index.html +201 -201
- package/src/dashboard/static/style.css +3584 -3584
- package/src/embedding/fastembed-provider.ts +142 -142
- package/src/embedding/transformers-provider.ts +111 -111
- package/src/git/extractor.ts +209 -209
- package/src/git/hooks-path.ts +85 -85
- package/src/git/noise-filter.ts +210 -210
- package/src/hooks/installers/index.ts +4 -4
- package/src/hooks/official-skills.ts +1 -1
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/rules/memorix-agent-rules.md +2 -2
- package/src/hooks/significance-filter.ts +250 -250
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/provider.ts +885 -885
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/auto-relations.ts +107 -107
- package/src/memory/consolidation.ts +302 -302
- package/src/memory/disclosure-policy.ts +141 -141
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/formation/extract.ts +361 -361
- package/src/memory/formation/index.ts +417 -417
- package/src/memory/formation/resolve.ts +344 -344
- package/src/memory/formation/types.ts +315 -315
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/refs.ts +94 -94
- package/src/memory/retention.ts +433 -433
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +523 -523
- package/src/multimodal/image-loader.ts +143 -143
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/spawn-helper.ts +286 -286
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/context-compact.ts +188 -188
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +380 -380
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/orchestrate/verify-gate.ts +219 -219
- package/src/orchestrate/worktree.ts +232 -232
- package/src/project/aliases.ts +374 -374
- package/src/project/detector.ts +268 -268
- package/src/rules/adapters/claude-code.ts +99 -99
- package/src/rules/adapters/codex.ts +97 -97
- package/src/rules/adapters/copilot.ts +124 -124
- package/src/rules/adapters/cursor.ts +114 -114
- package/src/rules/adapters/kiro.ts +126 -126
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/adapters/windsurf.ts +83 -83
- package/src/rules/syncer.ts +235 -235
- package/src/sdk.ts +327 -327
- package/src/search/intent-detector.ts +289 -289
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/chat-store.ts +119 -119
- package/src/store/file-lock.ts +100 -100
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/obs-store.ts +255 -255
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/session-store.ts +259 -259
- package/src/store/sqlite-store.ts +339 -339
- package/src/team/event-bus.ts +76 -76
- package/src/team/file-locks.ts +173 -173
- package/src/team/handoff.ts +167 -167
- package/src/team/messages.ts +203 -203
- package/src/team/poll.ts +132 -132
- package/src/team/tasks.ts +211 -211
- package/src/wiki/generator.ts +237 -237
- package/src/wiki/knowledge-graph.ts +334 -334
- package/src/wiki/types.ts +85 -85
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
- package/src/workspace/workflow-sync.ts +131 -131
|
@@ -1,286 +1,286 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared spawn logic for agent adapters.
|
|
3
|
-
*
|
|
4
|
-
* Encapsulates: child_process.spawn, ring buffer wiring,
|
|
5
|
-
* timeout handling, and abort/SIGKILL escalation.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { spawn as nodeSpawn, execSync } from 'node:child_process';
|
|
9
|
-
import { RingBuffer } from '../ring-buffer.js';
|
|
10
|
-
import type { AgentProcess, AgentProcessResult, AgentMessage, SpawnOptions } from './types.js';
|
|
11
|
-
|
|
12
|
-
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Check if a CLI command is available on the system PATH.
|
|
16
|
-
*
|
|
17
|
-
* Uses `where` (Windows) or `which` (Unix) instead of `--version`
|
|
18
|
-
* because npm global installs create `.ps1`/`.cmd` wrappers that
|
|
19
|
-
* Node.js `execSync` cannot invoke without a shell on Windows.
|
|
20
|
-
*/
|
|
21
|
-
export function isCommandAvailable(command: string): boolean {
|
|
22
|
-
const probe = process.platform === 'win32' ? `where ${command}` : `which ${command}`;
|
|
23
|
-
try {
|
|
24
|
-
execSync(probe, { stdio: 'ignore', timeout: 5_000 });
|
|
25
|
-
return true;
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
const DEFAULT_TAIL_LINES = 50;
|
|
31
|
-
const SIGKILL_GRACE_MS = 5_000;
|
|
32
|
-
|
|
33
|
-
export function spawnAgent(
|
|
34
|
-
command: string,
|
|
35
|
-
args: string[],
|
|
36
|
-
opts: SpawnOptions,
|
|
37
|
-
/** If provided, write this to stdin then close it (avoids shell escaping issues) */
|
|
38
|
-
stdinData?: string,
|
|
39
|
-
): AgentProcess {
|
|
40
|
-
const child = nodeSpawn(command, args, {
|
|
41
|
-
cwd: opts.cwd,
|
|
42
|
-
env: { ...process.env, ...opts.env },
|
|
43
|
-
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
44
|
-
// Windows: use shell to resolve CLI tools on PATH
|
|
45
|
-
shell: process.platform === 'win32',
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// Write prompt via stdin to avoid shell argument escaping issues.
|
|
49
|
-
// Must handle EPIPE / early-close — if the child exits before we finish
|
|
50
|
-
// writing (bad args, crash, etc.), write() would emit an unhandled error
|
|
51
|
-
// that kills the orchestrator process.
|
|
52
|
-
let stdinError: string | undefined;
|
|
53
|
-
if (stdinData && child.stdin) {
|
|
54
|
-
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
|
|
55
|
-
// Swallow EPIPE and other write errors — they are expected when
|
|
56
|
-
// the child process closes stdin early. Record for diagnostics.
|
|
57
|
-
stdinError = `stdin write error: ${err.code ?? err.message}`;
|
|
58
|
-
});
|
|
59
|
-
child.stdin.write(stdinData, () => {
|
|
60
|
-
try { child.stdin!.end(); } catch { /* already destroyed */ }
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const ring = new RingBuffer(opts.tailLines ?? DEFAULT_TAIL_LINES);
|
|
65
|
-
child.stdout?.on('data', (chunk: Buffer) => ring.push(chunk.toString()));
|
|
66
|
-
child.stderr?.on('data', (chunk: Buffer) => ring.push(chunk.toString()));
|
|
67
|
-
|
|
68
|
-
let killed = false;
|
|
69
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
71
|
-
|
|
72
|
-
if (timeoutMs > 0) {
|
|
73
|
-
timer = setTimeout(() => {
|
|
74
|
-
killed = true;
|
|
75
|
-
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
76
|
-
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
77
|
-
}, timeoutMs);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const completion = new Promise<AgentProcessResult>((resolve) => {
|
|
81
|
-
child.on('exit', (code, signal) => {
|
|
82
|
-
if (timer) clearTimeout(timer);
|
|
83
|
-
const tail = stdinError ? `${stdinError}\n${ring.toString()}` : ring.toString();
|
|
84
|
-
resolve({
|
|
85
|
-
exitCode: code,
|
|
86
|
-
signal: signal ?? null,
|
|
87
|
-
tailOutput: tail,
|
|
88
|
-
killed,
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
child.on('error', (err) => {
|
|
92
|
-
if (timer) clearTimeout(timer);
|
|
93
|
-
const tail = stdinError ? `${stdinError}\n` : '';
|
|
94
|
-
resolve({
|
|
95
|
-
exitCode: null,
|
|
96
|
-
signal: null,
|
|
97
|
-
tailOutput: `${tail}spawn error: ${err.message}\n${ring.toString()}`,
|
|
98
|
-
killed: false,
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
pid: child.pid ?? -1,
|
|
105
|
-
completion,
|
|
106
|
-
abort() {
|
|
107
|
-
killed = true;
|
|
108
|
-
if (timer) clearTimeout(timer);
|
|
109
|
-
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
110
|
-
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Spawn agent with streaming stdout line parser.
|
|
117
|
-
*
|
|
118
|
-
* Returns an AgentProcess whose `messages` field is an AsyncIterable<AgentMessage>
|
|
119
|
-
* driven by a per-line callback on stdout. The onStdoutLine callback should parse
|
|
120
|
-
* each line and return AgentMessage[] events (empty array = skip).
|
|
121
|
-
*
|
|
122
|
-
* stderr still goes to ring buffer only. tailOutput still works.
|
|
123
|
-
*/
|
|
124
|
-
export function spawnAgentWithStream(
|
|
125
|
-
command: string,
|
|
126
|
-
args: string[],
|
|
127
|
-
opts: SpawnOptions,
|
|
128
|
-
stdinData: string | undefined,
|
|
129
|
-
onStdoutLine: (line: string) => AgentMessage[],
|
|
130
|
-
onCompletion?: (result: AgentProcessResult) => AgentProcessResult,
|
|
131
|
-
): AgentProcess {
|
|
132
|
-
const child = nodeSpawn(command, args, {
|
|
133
|
-
cwd: opts.cwd,
|
|
134
|
-
env: { ...process.env, ...opts.env },
|
|
135
|
-
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
136
|
-
shell: process.platform === 'win32',
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
let stdinError: string | undefined;
|
|
140
|
-
if (stdinData && child.stdin) {
|
|
141
|
-
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
|
|
142
|
-
stdinError = `stdin write error: ${err.code ?? err.message}`;
|
|
143
|
-
});
|
|
144
|
-
child.stdin.write(stdinData, () => {
|
|
145
|
-
try { child.stdin!.end(); } catch { /* already destroyed */ }
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const ring = new RingBuffer(opts.tailLines ?? DEFAULT_TAIL_LINES);
|
|
150
|
-
child.stderr?.on('data', (chunk: Buffer) => ring.push(chunk.toString()));
|
|
151
|
-
|
|
152
|
-
// ── Message queue for AsyncIterable ──
|
|
153
|
-
type Waiter = { resolve: (v: IteratorResult<AgentMessage>) => void };
|
|
154
|
-
const MAX_BUFFER_SIZE = 1024;
|
|
155
|
-
const buffer: AgentMessage[] = [];
|
|
156
|
-
const waiters: Waiter[] = [];
|
|
157
|
-
let done = false;
|
|
158
|
-
|
|
159
|
-
function pushMessage(msg: AgentMessage): void {
|
|
160
|
-
if (waiters.length > 0) {
|
|
161
|
-
const w = waiters.shift()!;
|
|
162
|
-
w.resolve({ value: msg, done: false });
|
|
163
|
-
} else {
|
|
164
|
-
if (buffer.length >= MAX_BUFFER_SIZE) {
|
|
165
|
-
// Drop oldest message to prevent unbounded growth
|
|
166
|
-
buffer.shift();
|
|
167
|
-
}
|
|
168
|
-
buffer.push(msg);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function closeStream(): void {
|
|
173
|
-
done = true;
|
|
174
|
-
for (const w of waiters) {
|
|
175
|
-
w.resolve({ value: undefined as unknown as AgentMessage, done: true });
|
|
176
|
-
}
|
|
177
|
-
waiters.length = 0;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const messages: AsyncIterable<AgentMessage> = {
|
|
181
|
-
[Symbol.asyncIterator]() {
|
|
182
|
-
return {
|
|
183
|
-
next(): Promise<IteratorResult<AgentMessage>> {
|
|
184
|
-
if (buffer.length > 0) {
|
|
185
|
-
return Promise.resolve({ value: buffer.shift()!, done: false });
|
|
186
|
-
}
|
|
187
|
-
if (done) {
|
|
188
|
-
return Promise.resolve({ value: undefined as unknown as AgentMessage, done: true });
|
|
189
|
-
}
|
|
190
|
-
return new Promise<IteratorResult<AgentMessage>>(resolve => {
|
|
191
|
-
waiters.push({ resolve });
|
|
192
|
-
});
|
|
193
|
-
},
|
|
194
|
-
};
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
// ── Stdout line parsing ──
|
|
199
|
-
let stdoutPartial = '';
|
|
200
|
-
child.stdout?.on('data', (chunk: Buffer) => {
|
|
201
|
-
const text = chunk.toString();
|
|
202
|
-
ring.push(text);
|
|
203
|
-
stdoutPartial += text;
|
|
204
|
-
const lines = stdoutPartial.split('\n');
|
|
205
|
-
stdoutPartial = lines.pop() ?? '';
|
|
206
|
-
for (const line of lines) {
|
|
207
|
-
if (!line.trim()) continue;
|
|
208
|
-
try {
|
|
209
|
-
const msgs = onStdoutLine(line);
|
|
210
|
-
for (const m of msgs) pushMessage(m);
|
|
211
|
-
} catch { /* skip unparseable lines */ }
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
let killed = false;
|
|
216
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
218
|
-
|
|
219
|
-
if (timeoutMs > 0) {
|
|
220
|
-
timer = setTimeout(() => {
|
|
221
|
-
killed = true;
|
|
222
|
-
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
223
|
-
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
224
|
-
}, timeoutMs);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const completion = new Promise<AgentProcessResult>((resolve) => {
|
|
228
|
-
let exitCode: number | null = null;
|
|
229
|
-
let exitSignal: string | null = null;
|
|
230
|
-
let exited = false;
|
|
231
|
-
let stdoutClosed = false;
|
|
232
|
-
let stderrClosed = false;
|
|
233
|
-
|
|
234
|
-
function tryResolve(): void {
|
|
235
|
-
// Only resolve after both: process exited AND stdout/stderr fully drained
|
|
236
|
-
if (!exited || !stdoutClosed || !stderrClosed) return;
|
|
237
|
-
if (timer) clearTimeout(timer);
|
|
238
|
-
// Process any remaining partial line
|
|
239
|
-
if (stdoutPartial.trim()) {
|
|
240
|
-
try {
|
|
241
|
-
const msgs = onStdoutLine(stdoutPartial);
|
|
242
|
-
for (const m of msgs) pushMessage(m);
|
|
243
|
-
} catch { /* skip */ }
|
|
244
|
-
}
|
|
245
|
-
closeStream();
|
|
246
|
-
const tail = stdinError ? `${stdinError}\n${ring.toString()}` : ring.toString();
|
|
247
|
-
const baseResult: AgentProcessResult = { exitCode, signal: exitSignal, tailOutput: tail, killed };
|
|
248
|
-
resolve(onCompletion ? onCompletion(baseResult) : baseResult);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
child.on('exit', (code, signal) => {
|
|
252
|
-
exited = true;
|
|
253
|
-
exitCode = code;
|
|
254
|
-
exitSignal = signal ?? null;
|
|
255
|
-
tryResolve();
|
|
256
|
-
});
|
|
257
|
-
child.stdout?.on('close', () => { stdoutClosed = true; tryResolve(); });
|
|
258
|
-
child.stderr?.on('close', () => { stderrClosed = true; tryResolve(); });
|
|
259
|
-
// Fallback: if stdout/stderr are null (e.g. inherited), mark closed immediately
|
|
260
|
-
if (!child.stdout) { stdoutClosed = true; }
|
|
261
|
-
if (!child.stderr) { stderrClosed = true; }
|
|
262
|
-
|
|
263
|
-
child.on('error', (err) => {
|
|
264
|
-
if (timer) clearTimeout(timer);
|
|
265
|
-
closeStream();
|
|
266
|
-
const tail = stdinError ? `${stdinError}\n` : '';
|
|
267
|
-
resolve({
|
|
268
|
-
exitCode: null, signal: null,
|
|
269
|
-
tailOutput: `${tail}spawn error: ${err.message}\n${ring.toString()}`,
|
|
270
|
-
killed: false,
|
|
271
|
-
});
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
return {
|
|
276
|
-
pid: child.pid ?? -1,
|
|
277
|
-
completion,
|
|
278
|
-
messages,
|
|
279
|
-
abort() {
|
|
280
|
-
killed = true;
|
|
281
|
-
if (timer) clearTimeout(timer);
|
|
282
|
-
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
283
|
-
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
284
|
-
},
|
|
285
|
-
};
|
|
286
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared spawn logic for agent adapters.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates: child_process.spawn, ring buffer wiring,
|
|
5
|
+
* timeout handling, and abort/SIGKILL escalation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn as nodeSpawn, execSync } from 'node:child_process';
|
|
9
|
+
import { RingBuffer } from '../ring-buffer.js';
|
|
10
|
+
import type { AgentProcess, AgentProcessResult, AgentMessage, SpawnOptions } from './types.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a CLI command is available on the system PATH.
|
|
16
|
+
*
|
|
17
|
+
* Uses `where` (Windows) or `which` (Unix) instead of `--version`
|
|
18
|
+
* because npm global installs create `.ps1`/`.cmd` wrappers that
|
|
19
|
+
* Node.js `execSync` cannot invoke without a shell on Windows.
|
|
20
|
+
*/
|
|
21
|
+
export function isCommandAvailable(command: string): boolean {
|
|
22
|
+
const probe = process.platform === 'win32' ? `where ${command}` : `which ${command}`;
|
|
23
|
+
try {
|
|
24
|
+
execSync(probe, { stdio: 'ignore', timeout: 5_000 });
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const DEFAULT_TAIL_LINES = 50;
|
|
31
|
+
const SIGKILL_GRACE_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
export function spawnAgent(
|
|
34
|
+
command: string,
|
|
35
|
+
args: string[],
|
|
36
|
+
opts: SpawnOptions,
|
|
37
|
+
/** If provided, write this to stdin then close it (avoids shell escaping issues) */
|
|
38
|
+
stdinData?: string,
|
|
39
|
+
): AgentProcess {
|
|
40
|
+
const child = nodeSpawn(command, args, {
|
|
41
|
+
cwd: opts.cwd,
|
|
42
|
+
env: { ...process.env, ...opts.env },
|
|
43
|
+
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
44
|
+
// Windows: use shell to resolve CLI tools on PATH
|
|
45
|
+
shell: process.platform === 'win32',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Write prompt via stdin to avoid shell argument escaping issues.
|
|
49
|
+
// Must handle EPIPE / early-close — if the child exits before we finish
|
|
50
|
+
// writing (bad args, crash, etc.), write() would emit an unhandled error
|
|
51
|
+
// that kills the orchestrator process.
|
|
52
|
+
let stdinError: string | undefined;
|
|
53
|
+
if (stdinData && child.stdin) {
|
|
54
|
+
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
|
|
55
|
+
// Swallow EPIPE and other write errors — they are expected when
|
|
56
|
+
// the child process closes stdin early. Record for diagnostics.
|
|
57
|
+
stdinError = `stdin write error: ${err.code ?? err.message}`;
|
|
58
|
+
});
|
|
59
|
+
child.stdin.write(stdinData, () => {
|
|
60
|
+
try { child.stdin!.end(); } catch { /* already destroyed */ }
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ring = new RingBuffer(opts.tailLines ?? DEFAULT_TAIL_LINES);
|
|
65
|
+
child.stdout?.on('data', (chunk: Buffer) => ring.push(chunk.toString()));
|
|
66
|
+
child.stderr?.on('data', (chunk: Buffer) => ring.push(chunk.toString()));
|
|
67
|
+
|
|
68
|
+
let killed = false;
|
|
69
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
71
|
+
|
|
72
|
+
if (timeoutMs > 0) {
|
|
73
|
+
timer = setTimeout(() => {
|
|
74
|
+
killed = true;
|
|
75
|
+
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
76
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const completion = new Promise<AgentProcessResult>((resolve) => {
|
|
81
|
+
child.on('exit', (code, signal) => {
|
|
82
|
+
if (timer) clearTimeout(timer);
|
|
83
|
+
const tail = stdinError ? `${stdinError}\n${ring.toString()}` : ring.toString();
|
|
84
|
+
resolve({
|
|
85
|
+
exitCode: code,
|
|
86
|
+
signal: signal ?? null,
|
|
87
|
+
tailOutput: tail,
|
|
88
|
+
killed,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
child.on('error', (err) => {
|
|
92
|
+
if (timer) clearTimeout(timer);
|
|
93
|
+
const tail = stdinError ? `${stdinError}\n` : '';
|
|
94
|
+
resolve({
|
|
95
|
+
exitCode: null,
|
|
96
|
+
signal: null,
|
|
97
|
+
tailOutput: `${tail}spawn error: ${err.message}\n${ring.toString()}`,
|
|
98
|
+
killed: false,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
pid: child.pid ?? -1,
|
|
105
|
+
completion,
|
|
106
|
+
abort() {
|
|
107
|
+
killed = true;
|
|
108
|
+
if (timer) clearTimeout(timer);
|
|
109
|
+
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
110
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Spawn agent with streaming stdout line parser.
|
|
117
|
+
*
|
|
118
|
+
* Returns an AgentProcess whose `messages` field is an AsyncIterable<AgentMessage>
|
|
119
|
+
* driven by a per-line callback on stdout. The onStdoutLine callback should parse
|
|
120
|
+
* each line and return AgentMessage[] events (empty array = skip).
|
|
121
|
+
*
|
|
122
|
+
* stderr still goes to ring buffer only. tailOutput still works.
|
|
123
|
+
*/
|
|
124
|
+
export function spawnAgentWithStream(
|
|
125
|
+
command: string,
|
|
126
|
+
args: string[],
|
|
127
|
+
opts: SpawnOptions,
|
|
128
|
+
stdinData: string | undefined,
|
|
129
|
+
onStdoutLine: (line: string) => AgentMessage[],
|
|
130
|
+
onCompletion?: (result: AgentProcessResult) => AgentProcessResult,
|
|
131
|
+
): AgentProcess {
|
|
132
|
+
const child = nodeSpawn(command, args, {
|
|
133
|
+
cwd: opts.cwd,
|
|
134
|
+
env: { ...process.env, ...opts.env },
|
|
135
|
+
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
136
|
+
shell: process.platform === 'win32',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let stdinError: string | undefined;
|
|
140
|
+
if (stdinData && child.stdin) {
|
|
141
|
+
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
|
|
142
|
+
stdinError = `stdin write error: ${err.code ?? err.message}`;
|
|
143
|
+
});
|
|
144
|
+
child.stdin.write(stdinData, () => {
|
|
145
|
+
try { child.stdin!.end(); } catch { /* already destroyed */ }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ring = new RingBuffer(opts.tailLines ?? DEFAULT_TAIL_LINES);
|
|
150
|
+
child.stderr?.on('data', (chunk: Buffer) => ring.push(chunk.toString()));
|
|
151
|
+
|
|
152
|
+
// ── Message queue for AsyncIterable ──
|
|
153
|
+
type Waiter = { resolve: (v: IteratorResult<AgentMessage>) => void };
|
|
154
|
+
const MAX_BUFFER_SIZE = 1024;
|
|
155
|
+
const buffer: AgentMessage[] = [];
|
|
156
|
+
const waiters: Waiter[] = [];
|
|
157
|
+
let done = false;
|
|
158
|
+
|
|
159
|
+
function pushMessage(msg: AgentMessage): void {
|
|
160
|
+
if (waiters.length > 0) {
|
|
161
|
+
const w = waiters.shift()!;
|
|
162
|
+
w.resolve({ value: msg, done: false });
|
|
163
|
+
} else {
|
|
164
|
+
if (buffer.length >= MAX_BUFFER_SIZE) {
|
|
165
|
+
// Drop oldest message to prevent unbounded growth
|
|
166
|
+
buffer.shift();
|
|
167
|
+
}
|
|
168
|
+
buffer.push(msg);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function closeStream(): void {
|
|
173
|
+
done = true;
|
|
174
|
+
for (const w of waiters) {
|
|
175
|
+
w.resolve({ value: undefined as unknown as AgentMessage, done: true });
|
|
176
|
+
}
|
|
177
|
+
waiters.length = 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const messages: AsyncIterable<AgentMessage> = {
|
|
181
|
+
[Symbol.asyncIterator]() {
|
|
182
|
+
return {
|
|
183
|
+
next(): Promise<IteratorResult<AgentMessage>> {
|
|
184
|
+
if (buffer.length > 0) {
|
|
185
|
+
return Promise.resolve({ value: buffer.shift()!, done: false });
|
|
186
|
+
}
|
|
187
|
+
if (done) {
|
|
188
|
+
return Promise.resolve({ value: undefined as unknown as AgentMessage, done: true });
|
|
189
|
+
}
|
|
190
|
+
return new Promise<IteratorResult<AgentMessage>>(resolve => {
|
|
191
|
+
waiters.push({ resolve });
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// ── Stdout line parsing ──
|
|
199
|
+
let stdoutPartial = '';
|
|
200
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
201
|
+
const text = chunk.toString();
|
|
202
|
+
ring.push(text);
|
|
203
|
+
stdoutPartial += text;
|
|
204
|
+
const lines = stdoutPartial.split('\n');
|
|
205
|
+
stdoutPartial = lines.pop() ?? '';
|
|
206
|
+
for (const line of lines) {
|
|
207
|
+
if (!line.trim()) continue;
|
|
208
|
+
try {
|
|
209
|
+
const msgs = onStdoutLine(line);
|
|
210
|
+
for (const m of msgs) pushMessage(m);
|
|
211
|
+
} catch { /* skip unparseable lines */ }
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
let killed = false;
|
|
216
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
218
|
+
|
|
219
|
+
if (timeoutMs > 0) {
|
|
220
|
+
timer = setTimeout(() => {
|
|
221
|
+
killed = true;
|
|
222
|
+
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
223
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
224
|
+
}, timeoutMs);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const completion = new Promise<AgentProcessResult>((resolve) => {
|
|
228
|
+
let exitCode: number | null = null;
|
|
229
|
+
let exitSignal: string | null = null;
|
|
230
|
+
let exited = false;
|
|
231
|
+
let stdoutClosed = false;
|
|
232
|
+
let stderrClosed = false;
|
|
233
|
+
|
|
234
|
+
function tryResolve(): void {
|
|
235
|
+
// Only resolve after both: process exited AND stdout/stderr fully drained
|
|
236
|
+
if (!exited || !stdoutClosed || !stderrClosed) return;
|
|
237
|
+
if (timer) clearTimeout(timer);
|
|
238
|
+
// Process any remaining partial line
|
|
239
|
+
if (stdoutPartial.trim()) {
|
|
240
|
+
try {
|
|
241
|
+
const msgs = onStdoutLine(stdoutPartial);
|
|
242
|
+
for (const m of msgs) pushMessage(m);
|
|
243
|
+
} catch { /* skip */ }
|
|
244
|
+
}
|
|
245
|
+
closeStream();
|
|
246
|
+
const tail = stdinError ? `${stdinError}\n${ring.toString()}` : ring.toString();
|
|
247
|
+
const baseResult: AgentProcessResult = { exitCode, signal: exitSignal, tailOutput: tail, killed };
|
|
248
|
+
resolve(onCompletion ? onCompletion(baseResult) : baseResult);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
child.on('exit', (code, signal) => {
|
|
252
|
+
exited = true;
|
|
253
|
+
exitCode = code;
|
|
254
|
+
exitSignal = signal ?? null;
|
|
255
|
+
tryResolve();
|
|
256
|
+
});
|
|
257
|
+
child.stdout?.on('close', () => { stdoutClosed = true; tryResolve(); });
|
|
258
|
+
child.stderr?.on('close', () => { stderrClosed = true; tryResolve(); });
|
|
259
|
+
// Fallback: if stdout/stderr are null (e.g. inherited), mark closed immediately
|
|
260
|
+
if (!child.stdout) { stdoutClosed = true; }
|
|
261
|
+
if (!child.stderr) { stderrClosed = true; }
|
|
262
|
+
|
|
263
|
+
child.on('error', (err) => {
|
|
264
|
+
if (timer) clearTimeout(timer);
|
|
265
|
+
closeStream();
|
|
266
|
+
const tail = stdinError ? `${stdinError}\n` : '';
|
|
267
|
+
resolve({
|
|
268
|
+
exitCode: null, signal: null,
|
|
269
|
+
tailOutput: `${tail}spawn error: ${err.message}\n${ring.toString()}`,
|
|
270
|
+
killed: false,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
pid: child.pid ?? -1,
|
|
277
|
+
completion,
|
|
278
|
+
messages,
|
|
279
|
+
abort() {
|
|
280
|
+
killed = true;
|
|
281
|
+
if (timer) clearTimeout(timer);
|
|
282
|
+
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
283
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, SIGKILL_GRACE_MS);
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|