@oh-my-pi/pi-coding-agent 17.0.2 → 17.0.4
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 +80 -20
- package/dist/cli.js +4087 -4108
- package/dist/types/config/settings-schema.d.ts +7 -3
- package/dist/types/eval/agent-bridge.d.ts +7 -19
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +15 -1
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/lsp/types.d.ts +3 -0
- package/dist/types/mcp/transports/stdio.d.ts +29 -0
- package/dist/types/mnemopi/state.d.ts +32 -9
- package/dist/types/modes/components/agent-hub.d.ts +15 -0
- package/dist/types/registry/persisted-agents.d.ts +3 -0
- package/dist/types/sdk.d.ts +14 -3
- package/dist/types/session/session-entries.d.ts +6 -1
- package/dist/types/session/session-manager.d.ts +5 -0
- package/dist/types/task/executor.d.ts +26 -1
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/parallel.d.ts +14 -0
- package/dist/types/task/structured-subagent.d.ts +111 -0
- package/dist/types/task/types.d.ts +51 -0
- package/dist/types/tools/fetch.d.ts +4 -5
- package/dist/types/tools/hub/messaging.d.ts +5 -1
- package/dist/types/tools/index.d.ts +16 -1
- package/dist/types/tools/todo.d.ts +31 -0
- package/dist/types/tui/tree-list.d.ts +7 -0
- package/dist/types/utils/markit.d.ts +11 -0
- package/package.json +12 -12
- package/src/cli/file-processor.ts +1 -2
- package/src/config/settings-schema.ts +4 -3
- package/src/eval/__tests__/agent-bridge.test.ts +133 -47
- package/src/eval/__tests__/prelude-agent.test.ts +29 -0
- package/src/eval/agent-bridge.ts +104 -477
- package/src/eval/jl/prelude.jl +7 -6
- package/src/eval/js/shared/prelude.txt +5 -4
- package/src/eval/py/prelude.py +11 -39
- package/src/eval/rb/prelude.rb +5 -6
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
- package/src/lsp/client.ts +57 -0
- package/src/lsp/index.ts +62 -6
- package/src/lsp/types.ts +3 -0
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/mcp/transports/stdio.test.ts +269 -1
- package/src/mcp/transports/stdio.ts +152 -1
- package/src/mnemopi/backend.ts +1 -1
- package/src/mnemopi/embed-worker.ts +8 -7
- package/src/mnemopi/state.ts +45 -18
- package/src/modes/components/agent-hub.ts +1 -72
- package/src/modes/components/bash-execution.ts +7 -3
- package/src/modes/components/eval-execution.ts +3 -1
- package/src/modes/components/transcript-container.ts +13 -7
- package/src/modes/interactive-mode.ts +30 -8
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/task.md +12 -8
- package/src/prompts/tools/write.md +1 -1
- package/src/registry/persisted-agents.ts +74 -0
- package/src/sdk.ts +129 -87
- package/src/session/agent-session.ts +75 -21
- package/src/session/session-entries.ts +6 -1
- package/src/session/session-loader.ts +31 -3
- package/src/session/session-manager.ts +9 -0
- package/src/session/streaming-output.ts +41 -1
- package/src/stt/recorder.ts +68 -55
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +99 -21
- package/src/task/index.ts +258 -429
- package/src/task/parallel.ts +43 -0
- package/src/task/persisted-revive.ts +19 -7
- package/src/task/structured-subagent.ts +642 -0
- package/src/task/types.ts +61 -0
- package/src/tools/__tests__/eval-description.test.ts +1 -1
- package/src/tools/fetch.ts +28 -105
- package/src/tools/hub/messaging.ts +16 -3
- package/src/tools/index.ts +47 -14
- package/src/tools/path-utils.ts +1 -0
- package/src/tools/read.ts +14 -26
- package/src/tools/todo.ts +126 -13
- package/src/tui/tree-list.ts +39 -0
- package/src/utils/markit.ts +12 -0
- package/src/utils/zip.ts +16 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
2
5
|
|
|
3
|
-
import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
|
|
6
|
+
import { resolveStdioSpawnCommand, StdioTransport, terminateStdioProcess } from "./stdio";
|
|
4
7
|
|
|
5
8
|
describe("resolveStdioSpawnCommand", () => {
|
|
6
9
|
it("hides Windows executable MCP servers when the host has no console", async () => {
|
|
@@ -149,3 +152,268 @@ describe.skipIf(process.platform === "win32")("StdioTransport request write stal
|
|
|
149
152
|
}
|
|
150
153
|
}, 8000);
|
|
151
154
|
});
|
|
155
|
+
|
|
156
|
+
function processExists(pid: number): boolean {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0);
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Regression for #5578: `close()` used a bare `this.#process.kill()` (direct
|
|
166
|
+
// SIGTERM, no wait, no escalate, no process-group signal), so a detached
|
|
167
|
+
// session-leader child (or a grandchild it spawned) that ignores/traps
|
|
168
|
+
// SIGTERM survived host exit and became an orphan pinned to PID 1 on Linux.
|
|
169
|
+
// `sleep`/`bun`/POSIX signal semantics are exercised directly here rather
|
|
170
|
+
// than through `StdioTransport.connect()`, because `connect()` derives
|
|
171
|
+
// `detached` from `resolveStdioSpawnCommand()`, which is tied to the host's
|
|
172
|
+
// real `process.platform` — a POSIX detached session cannot be reproduced
|
|
173
|
+
// end-to-end through `connect()` on a non-Linux dev/CI host, but a real
|
|
174
|
+
// detached process group can still be spawned directly on any POSIX host.
|
|
175
|
+
describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => {
|
|
176
|
+
it("escalates a detached child that traps SIGTERM to SIGKILL", async () => {
|
|
177
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-kill-solo-"));
|
|
178
|
+
const scriptPath = path.join(tempDir, "child.mjs");
|
|
179
|
+
const readyPath = path.join(tempDir, "ready");
|
|
180
|
+
await fs.writeFile(
|
|
181
|
+
scriptPath,
|
|
182
|
+
[
|
|
183
|
+
"import { writeFileSync } from 'node:fs';",
|
|
184
|
+
"process.on('SIGTERM', () => {});",
|
|
185
|
+
`writeFileSync(${JSON.stringify(readyPath)}, '1');`,
|
|
186
|
+
"setInterval(() => {}, 60_000);",
|
|
187
|
+
].join("\n"),
|
|
188
|
+
);
|
|
189
|
+
const proc = Bun.spawn(["bun", "run", scriptPath], {
|
|
190
|
+
stdin: "ignore",
|
|
191
|
+
stdout: "ignore",
|
|
192
|
+
stderr: "ignore",
|
|
193
|
+
detached: true,
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
// Wait for the child to actually register its SIGTERM handler before
|
|
197
|
+
// signaling it: signaling too early races the child's startup and
|
|
198
|
+
// hits the default (terminate) action instead of exercising the trap.
|
|
199
|
+
for (let i = 0; i < 100; i++) {
|
|
200
|
+
try {
|
|
201
|
+
await fs.access(readyPath);
|
|
202
|
+
break;
|
|
203
|
+
} catch {
|
|
204
|
+
await Bun.sleep(20);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const started = performance.now();
|
|
209
|
+
await terminateStdioProcess(proc, true);
|
|
210
|
+
await proc.exited;
|
|
211
|
+
const elapsedMs = performance.now() - started;
|
|
212
|
+
|
|
213
|
+
expect(proc.signalCode).toBe("SIGKILL");
|
|
214
|
+
// Escalation only fires after the ~1s SIGTERM grace window elapses —
|
|
215
|
+
// a too-fast exit would mean SIGKILL fired without waiting.
|
|
216
|
+
expect(elapsedMs).toBeGreaterThanOrEqual(900);
|
|
217
|
+
} finally {
|
|
218
|
+
try {
|
|
219
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
220
|
+
} catch {
|
|
221
|
+
// Already gone.
|
|
222
|
+
}
|
|
223
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
}, 5000);
|
|
226
|
+
|
|
227
|
+
it("reaches a SIGTERM-trapping grandchild through the group SIGKILL, not just the direct child", async () => {
|
|
228
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-group-kill-"));
|
|
229
|
+
const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
|
|
230
|
+
const parentScriptPath = path.join(tempDir, "parent.mjs");
|
|
231
|
+
const grandchildPidPath = path.join(tempDir, "grandchild.pid");
|
|
232
|
+
|
|
233
|
+
// Grandchild also traps SIGTERM, so only an unrestricted SIGKILL to the
|
|
234
|
+
// whole group — not a signal to the direct (parent) child alone — can
|
|
235
|
+
// reach and stop it.
|
|
236
|
+
await fs.writeFile(
|
|
237
|
+
grandchildScriptPath,
|
|
238
|
+
[
|
|
239
|
+
"import { writeFileSync } from 'node:fs';",
|
|
240
|
+
"process.on('SIGTERM', () => {});",
|
|
241
|
+
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
|
|
242
|
+
"setInterval(() => {}, 60_000);",
|
|
243
|
+
].join("\n"),
|
|
244
|
+
);
|
|
245
|
+
await fs.writeFile(
|
|
246
|
+
parentScriptPath,
|
|
247
|
+
[
|
|
248
|
+
"process.on('SIGTERM', () => {});",
|
|
249
|
+
`Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
|
|
250
|
+
"setInterval(() => {}, 60_000);",
|
|
251
|
+
].join("\n"),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const proc = Bun.spawn(["bun", "run", parentScriptPath], {
|
|
255
|
+
stdin: "ignore",
|
|
256
|
+
stdout: "ignore",
|
|
257
|
+
stderr: "ignore",
|
|
258
|
+
detached: true,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Polls real wall-clock time rather than an event/promise: the
|
|
263
|
+
// grandchild process is a real external OS process writing to a real
|
|
264
|
+
// file, with no in-process signal this test can `await` directly.
|
|
265
|
+
let grandchildPid: number | undefined;
|
|
266
|
+
for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
|
|
267
|
+
try {
|
|
268
|
+
grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
|
|
269
|
+
} catch {
|
|
270
|
+
await Bun.sleep(20);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
|
|
274
|
+
expect(processExists(grandchildPid)).toBe(true);
|
|
275
|
+
|
|
276
|
+
await terminateStdioProcess(proc, true);
|
|
277
|
+
await proc.exited;
|
|
278
|
+
expect(proc.signalCode).toBe("SIGKILL");
|
|
279
|
+
|
|
280
|
+
// The group SIGKILL is delivered to every member simultaneously, but
|
|
281
|
+
// give the kernel a brief window to finish reaping before asserting.
|
|
282
|
+
let grandchildAlive = processExists(grandchildPid);
|
|
283
|
+
for (let i = 0; i < 25 && grandchildAlive; i++) {
|
|
284
|
+
await Bun.sleep(20);
|
|
285
|
+
grandchildAlive = processExists(grandchildPid);
|
|
286
|
+
}
|
|
287
|
+
expect(grandchildAlive).toBe(false);
|
|
288
|
+
} finally {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
291
|
+
} catch {
|
|
292
|
+
// Already gone.
|
|
293
|
+
}
|
|
294
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
}, 8000);
|
|
297
|
+
|
|
298
|
+
it("still reaps a SIGTERM-trapping grandchild after the detached leader exits cooperatively", async () => {
|
|
299
|
+
// Regression: the leader exiting within the SIGTERM grace window used to
|
|
300
|
+
// be treated as proof the whole process group was gone, so `close()`
|
|
301
|
+
// returned early and never delivered a group SIGKILL — leaving exactly
|
|
302
|
+
// the orphaned grandchild this change is meant to reap. Unlike the
|
|
303
|
+
// group-SIGKILL test above, the leader here does NOT trap SIGTERM, so
|
|
304
|
+
// it exits promptly on its own; only the grandchild ignores signals.
|
|
305
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-leader-exit-group-kill-"));
|
|
306
|
+
const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
|
|
307
|
+
const parentScriptPath = path.join(tempDir, "parent.mjs");
|
|
308
|
+
const grandchildPidPath = path.join(tempDir, "grandchild.pid");
|
|
309
|
+
|
|
310
|
+
await fs.writeFile(
|
|
311
|
+
grandchildScriptPath,
|
|
312
|
+
[
|
|
313
|
+
"import { writeFileSync } from 'node:fs';",
|
|
314
|
+
"process.on('SIGTERM', () => {});",
|
|
315
|
+
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
|
|
316
|
+
"setInterval(() => {}, 60_000);",
|
|
317
|
+
].join("\n"),
|
|
318
|
+
);
|
|
319
|
+
// No SIGTERM handler here: the default action (terminate) fires as soon
|
|
320
|
+
// as the group SIGTERM lands, well inside TERM_GRACE_MS.
|
|
321
|
+
await fs.writeFile(
|
|
322
|
+
parentScriptPath,
|
|
323
|
+
[
|
|
324
|
+
`Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
|
|
325
|
+
"setInterval(() => {}, 60_000);",
|
|
326
|
+
].join("\n"),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const proc = Bun.spawn(["bun", "run", parentScriptPath], {
|
|
330
|
+
stdin: "ignore",
|
|
331
|
+
stdout: "ignore",
|
|
332
|
+
stderr: "ignore",
|
|
333
|
+
detached: true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
let grandchildPid: number | undefined;
|
|
338
|
+
for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
|
|
339
|
+
try {
|
|
340
|
+
grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
|
|
341
|
+
} catch {
|
|
342
|
+
await Bun.sleep(20);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
|
|
346
|
+
expect(processExists(grandchildPid)).toBe(true);
|
|
347
|
+
|
|
348
|
+
const started = performance.now();
|
|
349
|
+
await terminateStdioProcess(proc, true);
|
|
350
|
+
await proc.exited;
|
|
351
|
+
const elapsedMs = performance.now() - started;
|
|
352
|
+
|
|
353
|
+
// The leader exits on the initial SIGTERM (no trap), so this must not
|
|
354
|
+
// block for the ~1s TERM grace window before sweeping the group.
|
|
355
|
+
expect(elapsedMs).toBeLessThan(700);
|
|
356
|
+
|
|
357
|
+
let grandchildAlive = processExists(grandchildPid);
|
|
358
|
+
for (let i = 0; i < 25 && grandchildAlive; i++) {
|
|
359
|
+
await Bun.sleep(20);
|
|
360
|
+
grandchildAlive = processExists(grandchildPid);
|
|
361
|
+
}
|
|
362
|
+
expect(grandchildAlive).toBe(false);
|
|
363
|
+
} finally {
|
|
364
|
+
try {
|
|
365
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
366
|
+
} catch {
|
|
367
|
+
// Already gone.
|
|
368
|
+
}
|
|
369
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
370
|
+
}
|
|
371
|
+
}, 8000);
|
|
372
|
+
|
|
373
|
+
it("never attempts a process-group signal when the transport did not spawn detached", async () => {
|
|
374
|
+
const proc = Bun.spawn(["bun", "-e", "await Bun.sleep(60_000)"], {
|
|
375
|
+
stdin: "ignore",
|
|
376
|
+
stdout: "ignore",
|
|
377
|
+
stderr: "ignore",
|
|
378
|
+
detached: false,
|
|
379
|
+
});
|
|
380
|
+
const killSpy = spyOn(process, "kill");
|
|
381
|
+
try {
|
|
382
|
+
await terminateStdioProcess(proc, false);
|
|
383
|
+
await proc.exited;
|
|
384
|
+
|
|
385
|
+
// Only the direct-child `Subprocess.kill()` path may run; the global
|
|
386
|
+
// `process.kill()` (used exclusively for the negative-pid group
|
|
387
|
+
// signal) must never be reached.
|
|
388
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
389
|
+
} finally {
|
|
390
|
+
killSpy.mockRestore();
|
|
391
|
+
try {
|
|
392
|
+
proc.kill("SIGKILL");
|
|
393
|
+
} catch {
|
|
394
|
+
// Already gone.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}, 5000);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe.skipIf(process.platform === "win32")("StdioTransport.close teardown", () => {
|
|
401
|
+
it("closes a well-behaved child promptly without escalating to SIGKILL", async () => {
|
|
402
|
+
const transport = new StdioTransport({
|
|
403
|
+
command: "bun",
|
|
404
|
+
args: ["-e", "await Bun.sleep(60_000)"],
|
|
405
|
+
});
|
|
406
|
+
try {
|
|
407
|
+
await transport.connect();
|
|
408
|
+
const started = performance.now();
|
|
409
|
+
await transport.close();
|
|
410
|
+
const elapsedMs = performance.now() - started;
|
|
411
|
+
|
|
412
|
+
// No SIGTERM trap => the child dies almost immediately; close() must
|
|
413
|
+
// not block for the full ~1s TERM grace window before returning.
|
|
414
|
+
expect(elapsedMs).toBeLessThan(700);
|
|
415
|
+
} finally {
|
|
416
|
+
await transport.close();
|
|
417
|
+
}
|
|
418
|
+
}, 5000);
|
|
419
|
+
});
|
|
@@ -404,6 +404,132 @@ export function writeFrame(stdin: FrameSink, frame: string): boolean {
|
|
|
404
404
|
}
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
/** Grace window to observe a cooperative exit after SIGTERM before escalating to SIGKILL. */
|
|
408
|
+
const TERM_GRACE_MS = 1000;
|
|
409
|
+
/** Grace window to observe SIGKILL taking effect before `close()` gives up and returns. */
|
|
410
|
+
const KILL_GRACE_MS = 500;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The subset of `Subprocess` that termination needs. Decoupled from the
|
|
414
|
+
* `Subprocess<In, Out, Err>` stdio generics — `#process`'s pipes are
|
|
415
|
+
* irrelevant to signaling — so tests can exercise it against a plain
|
|
416
|
+
* `Bun.spawn(cmd, { stdio: "ignore" })` child without fighting the generics.
|
|
417
|
+
*/
|
|
418
|
+
interface KillableSubprocess {
|
|
419
|
+
readonly pid: number;
|
|
420
|
+
readonly exited: Promise<number>;
|
|
421
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Race `exited` against a timer. Resolves `true` once the process has exited
|
|
426
|
+
* within `timeoutMs`, `false` if the timer wins first. `exited` resolving OR
|
|
427
|
+
* rejecting both count as "exited" — mirrors `waitForExit()` in
|
|
428
|
+
* `lsp/client.ts`, which treats the same ambiguity (Bun documents
|
|
429
|
+
* `Subprocess.exited` as resolve-only, but a settle either way means there is
|
|
430
|
+
* nothing left to wait on).
|
|
431
|
+
*
|
|
432
|
+
* The timer is always cleared before returning — win or lose — so a process
|
|
433
|
+
* that exits promptly never leaves a dangling `timeoutMs` timer holding the
|
|
434
|
+
* event loop open behind it.
|
|
435
|
+
*/
|
|
436
|
+
async function waitForProcessExit(exited: Promise<number>, timeoutMs: number): Promise<boolean> {
|
|
437
|
+
const { promise: timedOut, resolve: resolveTimedOut } = Promise.withResolvers<false>();
|
|
438
|
+
const timer = setTimeout(() => resolveTimedOut(false), timeoutMs);
|
|
439
|
+
try {
|
|
440
|
+
return await Promise.race([
|
|
441
|
+
exited.then(
|
|
442
|
+
() => true,
|
|
443
|
+
() => true,
|
|
444
|
+
),
|
|
445
|
+
timedOut,
|
|
446
|
+
]);
|
|
447
|
+
} finally {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** `true` when `error` is a Node errno exception carrying the given `code`. */
|
|
453
|
+
function isErrnoCode(error: unknown, code: string): boolean {
|
|
454
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
455
|
+
return error.code === code;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Signal `signal` to `proc`. When `detached` is true on a POSIX platform,
|
|
460
|
+
* targets the whole process group via the negative-pid convention
|
|
461
|
+
* (`process.kill(-pid, signal)`) so a detached session leader's descendants —
|
|
462
|
+
* not just the direct child — receive it too; a bare direct-child signal
|
|
463
|
+
* never reaches grandchildren the child itself spawned.
|
|
464
|
+
*
|
|
465
|
+
* `ESRCH` from the group signal means the group is already gone — that is a
|
|
466
|
+
* success (nothing left to signal), not a failure — so it does not fall
|
|
467
|
+
* through. Any other group-signal failure (e.g. `EPERM`) falls back to
|
|
468
|
+
* signaling the direct child as a last resort. Non-detached transports
|
|
469
|
+
* (macOS, Windows, or POSIX where detach did not apply) always signal the
|
|
470
|
+
* direct child only: a negative-pid signal outside a detached session could
|
|
471
|
+
* hit an unrelated process group.
|
|
472
|
+
*/
|
|
473
|
+
function signalStdioProcess(
|
|
474
|
+
proc: KillableSubprocess,
|
|
475
|
+
detached: boolean,
|
|
476
|
+
signal: NodeJS.Signals,
|
|
477
|
+
platform: NodeJS.Platform,
|
|
478
|
+
): void {
|
|
479
|
+
if (detached && platform !== "win32") {
|
|
480
|
+
try {
|
|
481
|
+
process.kill(-proc.pid, signal);
|
|
482
|
+
return;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (isErrnoCode(error, "ESRCH")) return;
|
|
485
|
+
// Fall through to the direct-child signal below.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
proc.kill(signal);
|
|
490
|
+
} catch {
|
|
491
|
+
// Already gone.
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached`
|
|
497
|
+
* on POSIX, direct child otherwise), wait up to `TERM_GRACE_MS` for a
|
|
498
|
+
* cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS`
|
|
499
|
+
* more only when the leader itself hadn't already exited. A detached
|
|
500
|
+
* leader's cooperative exit does not prove the whole process group is gone
|
|
501
|
+
* (a grandchild can outlive it and ignore SIGTERM), so detached transports
|
|
502
|
+
* always fire the group SIGKILL sweep, even after a clean SIGTERM exit.
|
|
503
|
+
* Every step is a no-op-safe signal against an already-exited target, so
|
|
504
|
+
* repeat calls (idempotent `close()`) never throw.
|
|
505
|
+
*
|
|
506
|
+
* Exported so tests can exercise group-signal escalation with an explicit
|
|
507
|
+
* `detached`/`platform` pair: `StdioTransport.connect()` derives `detached`
|
|
508
|
+
* from `resolveStdioSpawnCommand()`, which is tied to the host's real
|
|
509
|
+
* `process.platform`, so a POSIX detached session cannot be reproduced
|
|
510
|
+
* end-to-end through `connect()` on a non-Linux dev/CI host.
|
|
511
|
+
*/
|
|
512
|
+
export async function terminateStdioProcess(
|
|
513
|
+
proc: KillableSubprocess,
|
|
514
|
+
detached: boolean,
|
|
515
|
+
platform: NodeJS.Platform = process.platform,
|
|
516
|
+
): Promise<void> {
|
|
517
|
+
signalStdioProcess(proc, detached, "SIGTERM", platform);
|
|
518
|
+
const exitedOnTerm = await waitForProcessExit(proc.exited, TERM_GRACE_MS);
|
|
519
|
+
// A non-detached transport has no process group beyond the leader itself:
|
|
520
|
+
// once it exits, there is nothing left to signal. A detached transport's
|
|
521
|
+
// leader exiting is NOT proof the group is empty — a grandchild it spawned
|
|
522
|
+
// can still be alive and ignoring SIGTERM — so detached transports always
|
|
523
|
+
// fall through to the group SIGKILL, even on a cooperative leader exit.
|
|
524
|
+
if (exitedOnTerm && !detached) return;
|
|
525
|
+
signalStdioProcess(proc, detached, "SIGKILL", platform);
|
|
526
|
+
// Once the leader has already exited there is no further `exited` signal
|
|
527
|
+
// to wait on for this call — the SIGKILL above is a fire-and-forget sweep
|
|
528
|
+
// for any surviving group members — so only block on the grace window
|
|
529
|
+
// when the leader itself is still the thing being escalated against.
|
|
530
|
+
if (!exitedOnTerm) await waitForProcessExit(proc.exited, KILL_GRACE_MS);
|
|
531
|
+
}
|
|
532
|
+
|
|
407
533
|
/**
|
|
408
534
|
* Stdio transport for MCP servers.
|
|
409
535
|
* Spawns a subprocess and communicates via stdin/stdout.
|
|
@@ -419,6 +545,12 @@ export class StdioTransport implements MCPTransport {
|
|
|
419
545
|
>();
|
|
420
546
|
#connected = false;
|
|
421
547
|
#readLoop: Promise<void> | null = null;
|
|
548
|
+
/**
|
|
549
|
+
* Set from `resolveStdioSpawnCommand()`'s `detached` flag in `connect()`.
|
|
550
|
+
* Gates process-group signaling in `close()` — only a transport that
|
|
551
|
+
* actually spawned into its own session may target it.
|
|
552
|
+
*/
|
|
553
|
+
#detached = false;
|
|
422
554
|
|
|
423
555
|
onClose?: () => void;
|
|
424
556
|
onError?: (error: Error) => void;
|
|
@@ -469,6 +601,7 @@ export class StdioTransport implements MCPTransport {
|
|
|
469
601
|
detached: spawnCommand.detached,
|
|
470
602
|
windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
|
|
471
603
|
});
|
|
604
|
+
this.#detached = spawnCommand.detached;
|
|
472
605
|
|
|
473
606
|
this.#connected = true;
|
|
474
607
|
|
|
@@ -730,8 +863,26 @@ export class StdioTransport implements MCPTransport {
|
|
|
730
863
|
}
|
|
731
864
|
|
|
732
865
|
if (this.#process) {
|
|
733
|
-
|
|
866
|
+
// Grab the handle and null the field immediately (before any
|
|
867
|
+
// `await`) so a concurrent/repeat `close()` sees `#process` already
|
|
868
|
+
// cleared and skips straight past this block — no double-signal.
|
|
869
|
+
const proc = this.#process;
|
|
734
870
|
this.#process = null;
|
|
871
|
+
|
|
872
|
+
// 1. Cooperative EOF first: a well-behaved server sees stdin close
|
|
873
|
+
// and can exit on its own before any signal is sent. Guarded — the
|
|
874
|
+
// sink can throw if the pipe is already closed/dead (e.g. the child
|
|
875
|
+
// already exited and the read loop got there first).
|
|
876
|
+
try {
|
|
877
|
+
proc.stdin.end();
|
|
878
|
+
} catch {
|
|
879
|
+
// Already closed/dead.
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 2-3. Group-aware SIGTERM (when this transport actually spawned
|
|
883
|
+
// detached), bounded wait, then escalate to SIGKILL. See
|
|
884
|
+
// `terminateStdioProcess` for the exact signaling/escalation rules.
|
|
885
|
+
await terminateStdioProcess(proc, this.#detached);
|
|
735
886
|
}
|
|
736
887
|
|
|
737
888
|
if (this.#readLoop) {
|
package/src/mnemopi/backend.ts
CHANGED
|
@@ -145,7 +145,7 @@ export const mnemopiBackend: MemoryBackend = {
|
|
|
145
145
|
state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
|
|
146
146
|
setMnemopiSessionState(session, state);
|
|
147
147
|
}
|
|
148
|
-
await state?.consolidate();
|
|
148
|
+
await state?.consolidate({ full: true });
|
|
149
149
|
} catch (error) {
|
|
150
150
|
logger.warn("Mnemopi: enqueue failed.", { error: String(error) });
|
|
151
151
|
}
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
* in either process.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type
|
|
13
|
-
import { loadFastembed } from "@oh-my-pi/pi-mnemopi/core/fastembed-runtime";
|
|
12
|
+
import { defaultLocalModelInitializer, type StandardEmbeddingModel } from "@oh-my-pi/pi-mnemopi/core";
|
|
14
13
|
import type { MnemopiEmbedModelId, MnemopiEmbedTransport, MnemopiEmbedWorkerInbound } from "./embed-protocol";
|
|
15
14
|
|
|
16
15
|
interface LoadedModel {
|
|
@@ -25,12 +24,14 @@ let loaded: Promise<LoadedModel> | null = null;
|
|
|
25
24
|
let loadedKey = "";
|
|
26
25
|
|
|
27
26
|
async function loadModel(model: MnemopiEmbedModelId, cacheDir: string | undefined): Promise<LoadedModel> {
|
|
28
|
-
|
|
27
|
+
// Route through mnemopi's shared initializer so the worker inherits BOTH
|
|
28
|
+
// cache heals (sidecar re-fetch AND corrupt-model quarantine/retry) —
|
|
29
|
+
// fastembed/onnxruntime still load only in this child address space, the
|
|
30
|
+
// initializer calls loadFastembed() itself.
|
|
29
31
|
// Cast: `model` arrives as a string from the parent (resolved by
|
|
30
|
-
// mnemopi's `fastembedModelName`)
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
const instance = await FlagEmbedding.init({
|
|
32
|
+
// mnemopi's `fastembedModelName`); the parent only ever passes pre-vetted
|
|
33
|
+
// fast-* identifiers.
|
|
34
|
+
const instance = await defaultLocalModelInitializer({
|
|
34
35
|
model: model as StandardEmbeddingModel,
|
|
35
36
|
cacheDir,
|
|
36
37
|
showDownloadProgress: false,
|
package/src/mnemopi/state.ts
CHANGED
|
@@ -454,18 +454,23 @@ export class MnemopiSessionState {
|
|
|
454
454
|
this.lastRetainedTurn = userTurns;
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
-
async forceRetainCurrentSession(): Promise<void> {
|
|
457
|
+
async forceRetainCurrentSession(options: { extract?: boolean } = {}): Promise<void> {
|
|
458
458
|
if (this.aliasOf) return;
|
|
459
459
|
const flat = extractMessages(this.session.sessionManager);
|
|
460
|
-
await this.retainMessages(flat, this.sessionId);
|
|
460
|
+
await this.retainMessages(flat, this.sessionId, options);
|
|
461
461
|
this.lastRetainedTurn = flat.filter(message => message.role === "user").length;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
async retainMessages(
|
|
464
|
+
async retainMessages(
|
|
465
|
+
messages: Array<{ role: string; content: string }>,
|
|
466
|
+
sourceId: string,
|
|
467
|
+
options: { extract?: boolean } = {},
|
|
468
|
+
): Promise<void> {
|
|
465
469
|
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
|
|
466
470
|
if (!transcript) return;
|
|
467
471
|
const { transcript: extractText } = prepareUserRetentionTranscript(messages);
|
|
468
472
|
const { transcript: embedText } = prepareEmbeddableRetentionTranscript(messages);
|
|
473
|
+
const shouldExtract = options.extract !== false && extractText !== null;
|
|
469
474
|
this.rememberInScope(transcript, {
|
|
470
475
|
source: "coding-agent-transcript",
|
|
471
476
|
importance: 0.65,
|
|
@@ -476,9 +481,9 @@ export class MnemopiSessionState {
|
|
|
476
481
|
cwd: this.session.sessionManager.getCwd(),
|
|
477
482
|
},
|
|
478
483
|
scope: "bank",
|
|
479
|
-
extract:
|
|
480
|
-
extractEntities:
|
|
481
|
-
extractText,
|
|
484
|
+
extract: shouldExtract,
|
|
485
|
+
extractEntities: shouldExtract,
|
|
486
|
+
extractText: shouldExtract ? extractText : null,
|
|
482
487
|
embedText,
|
|
483
488
|
veracity: "unknown",
|
|
484
489
|
memoryType: "episode",
|
|
@@ -530,22 +535,42 @@ export class MnemopiSessionState {
|
|
|
530
535
|
* otherwise enqueue would report success while leaving the subagent's
|
|
531
536
|
* retained memories unconsolidated until the parent eventually shuts down
|
|
532
537
|
* (PR #2327 review).
|
|
538
|
+
*
|
|
539
|
+
* @param options.full - When true, run `sleepAllSessions` on every owned bank
|
|
540
|
+
* (the full cross-session consolidation used by `/memory enqueue`). When
|
|
541
|
+
* false (the default), run only `sleep` on the current session for a
|
|
542
|
+
* lighter, bounded shutdown pass.
|
|
543
|
+
* @param options.sleep - When false, skips the bank sleep step entirely.
|
|
544
|
+
* Used on the interactive shutdown path so `dispose` does not block on
|
|
545
|
+
* synchronous consolidation of old working rows from previous sessions.
|
|
546
|
+
* @param options.extract - When false, the retained transcript is stored but
|
|
547
|
+
* no LLM fact extraction is scheduled. Used on the interactive shutdown path
|
|
548
|
+
* so `dispose` does not block on a fresh LLM round-trip.
|
|
533
549
|
*/
|
|
534
|
-
async consolidate(): Promise<void> {
|
|
535
|
-
await this.forceRetainCurrentSession();
|
|
550
|
+
async consolidate(options: { full?: boolean; extract?: boolean; sleep?: boolean } = {}): Promise<void> {
|
|
551
|
+
await this.forceRetainCurrentSession({ extract: options.extract });
|
|
536
552
|
for (const memory of this.scoped.owned) {
|
|
537
553
|
await memory.flushExtractions();
|
|
538
|
-
|
|
554
|
+
if (options.sleep === false) continue;
|
|
555
|
+
if (options.full) {
|
|
556
|
+
memory.sleepAllSessions(false);
|
|
557
|
+
} else {
|
|
558
|
+
memory.sleep(false);
|
|
559
|
+
}
|
|
539
560
|
}
|
|
540
561
|
}
|
|
541
562
|
|
|
542
563
|
/**
|
|
543
|
-
* Release the per-session resources. Defaults to running
|
|
544
|
-
* before closing handles
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
564
|
+
* Release the per-session resources. Defaults to running a lighter
|
|
565
|
+
* {@link consolidate} pass before closing handles: it retains the current
|
|
566
|
+
* transcript and flushes in-flight extractions, but skips the synchronous
|
|
567
|
+
* bank sleep so normal session shutdown returns promptly. Full promotion of
|
|
568
|
+
* working memory into long-term storage is still performed by the explicit
|
|
569
|
+
* `/memory enqueue` and backend enqueue paths. Callers that are about to
|
|
570
|
+
* delete the DB files — e.g. `mnemopiBackend.clear` — pass
|
|
571
|
+
* `{ consolidate: false }` to skip the retain/flush pass, since spending
|
|
572
|
+
* tokens on memories that will be wiped on the next line is wasted work
|
|
573
|
+
* (PR #2327 review).
|
|
549
574
|
*
|
|
550
575
|
* `timeoutMs` caps how long the consolidate await blocks the caller
|
|
551
576
|
* (the user-visible `/quit` / `/exit` shutdown path passes this so
|
|
@@ -569,9 +594,11 @@ export class MnemopiSessionState {
|
|
|
569
594
|
closeOwned();
|
|
570
595
|
return;
|
|
571
596
|
}
|
|
572
|
-
const consolidatePromise = this.consolidate().catch(
|
|
573
|
-
|
|
574
|
-
|
|
597
|
+
const consolidatePromise = this.consolidate({ full: false, extract: false, sleep: false }).catch(
|
|
598
|
+
(error: unknown) => {
|
|
599
|
+
logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
|
|
600
|
+
},
|
|
601
|
+
);
|
|
575
602
|
const { timeoutMs } = options;
|
|
576
603
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
577
604
|
const TIMED_OUT = Symbol("mnemopi.dispose.timedOut");
|