@code-yeongyu/senpi-agent-core 2026.10.10 → 2026.10.12

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.
@@ -1,4 +1,6 @@
1
1
  import { isClassifierRefusal, isCursorExecResolved, readProviderDiagnostic, } from "@earendil-works/pi-ai";
2
+ import { isOAuthRefreshUnavailableError } from "@earendil-works/pi-ai/utils/oauth-refresh-error";
3
+ import { OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC } from "@earendil-works/pi-ai/utils/retry";
2
4
  const EMPTY_USAGE = {
3
5
  input: 0,
4
6
  output: 0,
@@ -71,7 +73,16 @@ export function createTerminalFailureAssistantMessage(model, reason, error, part
71
73
  model: partialMessage?.model ?? model.id,
72
74
  responseModel: partialMessage?.responseModel,
73
75
  responseId: partialMessage?.responseId,
74
- diagnostics: partialMessage?.diagnostics,
76
+ diagnostics: reason === "error" && isOAuthRefreshUnavailableError(error)
77
+ ? [
78
+ ...(partialMessage?.diagnostics ?? []),
79
+ {
80
+ type: OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC,
81
+ timestamp: Date.now(),
82
+ details: { provider: model.provider },
83
+ },
84
+ ]
85
+ : partialMessage?.diagnostics,
75
86
  usage: partialMessage?.usage ?? EMPTY_USAGE,
76
87
  stopReason: reason,
77
88
  errorMessage: errorMessage || (reason === "aborted" ? "Request was aborted" : "Error"),
@@ -1,5 +1,6 @@
1
1
  import type { Context } from "../context.ts";
2
2
  import { type ExecutionEnv, ExecutionError, FileError, type FileInfo, type Result, type ShellExecOptions, type ShellExecResult, type TextLineReader } from "../types.ts";
3
+ import { type WindowsProcessRow } from "./windows-process-tree.ts";
3
4
  /**
4
5
  * Ordered `taskkill` launchers to try, most reliable first.
5
6
  *
@@ -11,7 +12,10 @@ import { type ExecutionEnv, ExecutionError, FileError, type FileInfo, type Resul
11
12
  */
12
13
  export declare function windowsTaskkillCandidates(env?: NodeJS.ProcessEnv): string[];
13
14
  /**
14
- * Kill a process and all its children on Windows via `taskkill /T`.
15
+ * Kill a process and its descendants on Windows: one process listing (bounded by `TASKKILL_TIMEOUT_MS`)
16
+ * decides the tree, a process counting as a child only when it started at or after the parent it names,
17
+ * and `taskkill /F` ends each pid by name. `/T` would also adopt an unrelated older process through a
18
+ * recycled parent pid (senpi#2999); it is used only when no listing can be read.
15
19
  *
16
20
  * Synchronous on purpose. A caller that tears down and exits in the same tick would never
17
21
  * observe an asynchronous killer's `error` event, leaving the target alive. `spawnSync`
@@ -24,7 +28,7 @@ export declare function windowsTaskkillCandidates(env?: NodeJS.ProcessEnv): stri
24
28
  * touch descendants; nothing in-process can walk a Windows process tree without an
25
29
  * external tool, so this still beats leaving the whole tree running.
26
30
  */
27
- export declare function killWindowsProcessTree(pid: number, taskkillPaths?: string[]): void;
31
+ export declare function killWindowsProcessTree(pid: number, taskkillPaths?: string[], listProcesses?: () => readonly WindowsProcessRow[] | undefined): void;
28
32
  export declare class NodeExecutionEnv implements ExecutionEnv {
29
33
  cwd: string;
30
34
  private shellPath?;
@@ -7,6 +7,7 @@ import { basename, isAbsolute, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { ExecutionError, err, FileError, ok, toError, } from "../types.js";
9
9
  import { OutputCapture } from "../utils/output-capture.js";
10
+ import { listWindowsProcessRowsSync, windowsTreeKillArgs } from "./windows-process-tree.js";
10
11
  const MAX_TIMEOUT_MS = 2_147_483_647;
11
12
  const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
12
13
  const EXIT_STDIO_GRACE_MS = 100;
@@ -237,9 +238,14 @@ function killProcessDirectly(pid) {
237
238
  }
238
239
  /** Upper bound on how long a teardown may block waiting for `taskkill` to finish. */
239
240
  const TASKKILL_TIMEOUT_MS = 5_000;
240
- function taskkillHandledTree(pid, taskkillPath) {
241
+ function listWindowsProcesses() {
242
+ return listWindowsProcessRowsSync(TASKKILL_TIMEOUT_MS);
243
+ }
244
+ function taskkillHandledTree(taskkillPath, killArgs) {
245
+ if (killArgs.length === 0)
246
+ return true;
241
247
  try {
242
- const result = spawnSync(taskkillPath, ["/F", "/T", "/PID", String(pid)], {
248
+ const result = spawnSync(taskkillPath, [...killArgs], {
243
249
  stdio: "ignore",
244
250
  windowsHide: true,
245
251
  timeout: TASKKILL_TIMEOUT_MS,
@@ -253,7 +259,10 @@ function taskkillHandledTree(pid, taskkillPath) {
253
259
  }
254
260
  }
255
261
  /**
256
- * Kill a process and all its children on Windows via `taskkill /T`.
262
+ * Kill a process and its descendants on Windows: one process listing (bounded by `TASKKILL_TIMEOUT_MS`)
263
+ * decides the tree, a process counting as a child only when it started at or after the parent it names,
264
+ * and `taskkill /F` ends each pid by name. `/T` would also adopt an unrelated older process through a
265
+ * recycled parent pid (senpi#2999); it is used only when no listing can be read.
257
266
  *
258
267
  * Synchronous on purpose. A caller that tears down and exits in the same tick would never
259
268
  * observe an asynchronous killer's `error` event, leaving the target alive. `spawnSync`
@@ -266,9 +275,10 @@ function taskkillHandledTree(pid, taskkillPath) {
266
275
  * touch descendants; nothing in-process can walk a Windows process tree without an
267
276
  * external tool, so this still beats leaving the whole tree running.
268
277
  */
269
- export function killWindowsProcessTree(pid, taskkillPaths = windowsTaskkillCandidates()) {
278
+ export function killWindowsProcessTree(pid, taskkillPaths = windowsTaskkillCandidates(), listProcesses = listWindowsProcesses) {
279
+ const killArgs = windowsTreeKillArgs(pid, listProcesses());
270
280
  for (const taskkillPath of taskkillPaths) {
271
- if (taskkillHandledTree(pid, taskkillPath))
281
+ if (taskkillHandledTree(taskkillPath, killArgs))
272
282
  return;
273
283
  }
274
284
  killProcessDirectly(pid);
@@ -959,8 +969,16 @@ export class NodeExecutionEnv {
959
969
  }
960
970
  }
961
971
  async cleanup(_context) {
962
- for (const pid of this.activeChildPids)
963
- killProcessTree(pid);
972
+ if (process.platform === "win32" && this.activeChildPids.size > 0) {
973
+ // One listing for the whole batch: each tree kill would otherwise list every process again.
974
+ const rows = listWindowsProcesses();
975
+ for (const pid of this.activeChildPids)
976
+ killWindowsProcessTree(pid, undefined, () => rows);
977
+ }
978
+ else {
979
+ for (const pid of this.activeChildPids)
980
+ killProcessTree(pid);
981
+ }
964
982
  this.activeChildPids.clear();
965
983
  }
966
984
  }
@@ -0,0 +1,33 @@
1
+ export interface WindowsProcessRow {
2
+ readonly pid: number;
3
+ readonly parentPid: number;
4
+ readonly createdAt: bigint;
5
+ readonly name: string;
6
+ }
7
+ /** One `pid parentPid createdFileTime name` line per process; the listing's own PowerShell is left out. */
8
+ export declare const WINDOWS_PROCESS_TREE_ROWS = "Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {\n$created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }\n\"{0} {1} {2} {3}\" -f $_.ProcessId, $_.ParentProcessId, $created, $_.Name }";
9
+ export declare function parseWindowsProcessTreeRows(stdout: string): WindowsProcessRow[];
10
+ export interface WindowsTreeKillPlan {
11
+ /** Pids to end with one `taskkill /F /PID a /PID b ...` (never /T). */
12
+ readonly kill: readonly number[];
13
+ /** Processes the walk adopted that must never be killed; any entry here means only the root is killed. */
14
+ readonly refused: readonly {
15
+ readonly pid: number;
16
+ readonly name: string;
17
+ }[];
18
+ }
19
+ /**
20
+ * The root and every process that genuinely descends from it. A protected OS image or an ancestor of
21
+ * `selfPid` in that set means the walk went wrong, so only the root itself is killed (and nothing when
22
+ * the root is one of them).
23
+ */
24
+ export declare function windowsTreeKillPlan(rows: readonly WindowsProcessRow[], rootPid: number, selfPid: number): WindowsTreeKillPlan;
25
+ /** Bounded synchronous CIM listing; undefined when it cannot run or does not finish in time. */
26
+ export declare function listWindowsProcessRowsSync(timeoutMs: number): WindowsProcessRow[] | undefined;
27
+ /**
28
+ * `taskkill` arguments for one tree kill. With a process listing, the checked tree is named pid by pid
29
+ * (never `/T`). Without one, `/T` is kept: leaving every descendant running is the worse failure, and the
30
+ * recycled-pid hazard needs a pid wraparound that a missing listing does not make more likely.
31
+ */
32
+ export declare function windowsTreeKillArgs(rootPid: number, rows: readonly WindowsProcessRow[] | undefined, selfPid?: number): string[];
33
+ //# sourceMappingURL=windows-process-tree.d.ts.map
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** One `pid parentPid createdFileTime name` line per process; the listing's own PowerShell is left out. */
3
+ export const WINDOWS_PROCESS_TREE_ROWS = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
4
+ $created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }
5
+ "{0} {1} {2} {3}" -f $_.ProcessId, $_.ParentProcessId, $created, $_.Name }`;
6
+ export function parseWindowsProcessTreeRows(stdout) {
7
+ const rows = [];
8
+ for (const line of stdout.split(/\r?\n/u)) {
9
+ const [pidText, parentText, createdText, ...nameParts] = line.trim().split(/\s+/u);
10
+ const pid = Number(pidText);
11
+ const parentPid = Number(parentText);
12
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !/^\d+$/u.test(createdText ?? ""))
13
+ continue;
14
+ rows.push({ pid, parentPid, createdAt: BigInt(createdText ?? "0"), name: nameParts.join(" ") });
15
+ }
16
+ return rows;
17
+ }
18
+ const PROTECTED_IMAGES = new Set([
19
+ "system",
20
+ "smss.exe",
21
+ "csrss.exe",
22
+ "wininit.exe",
23
+ "winlogon.exe",
24
+ "services.exe",
25
+ "lsass.exe",
26
+ "lsaiso.exe",
27
+ "svchost.exe",
28
+ ]);
29
+ /**
30
+ * The root and every process that genuinely descends from it. A protected OS image or an ancestor of
31
+ * `selfPid` in that set means the walk went wrong, so only the root itself is killed (and nothing when
32
+ * the root is one of them).
33
+ */
34
+ export function windowsTreeKillPlan(rows, rootPid, selfPid) {
35
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
36
+ const root = byPid.get(rootPid);
37
+ if (root === undefined)
38
+ return { kill: [], refused: [] };
39
+ const tree = new Set([rootPid]);
40
+ let grew = true;
41
+ while (grew) {
42
+ grew = false;
43
+ for (const row of rows) {
44
+ if (tree.has(row.pid) || !tree.has(row.parentPid))
45
+ continue;
46
+ const parent = byPid.get(row.parentPid);
47
+ if (parent === undefined || row.createdAt < parent.createdAt)
48
+ continue;
49
+ tree.add(row.pid);
50
+ grew = true;
51
+ }
52
+ }
53
+ const ancestors = new Set([selfPid]);
54
+ for (let current = byPid.get(selfPid); current !== undefined;) {
55
+ const parent = byPid.get(current.parentPid);
56
+ if (parent === undefined || ancestors.has(parent.pid) || parent.createdAt > current.createdAt)
57
+ break;
58
+ ancestors.add(parent.pid);
59
+ current = parent;
60
+ }
61
+ const refused = [...tree]
62
+ .map((pid) => ({ pid, name: byPid.get(pid)?.name ?? "" }))
63
+ .filter((entry) => PROTECTED_IMAGES.has(entry.name.toLowerCase()) || ancestors.has(entry.pid));
64
+ if (refused.length === 0)
65
+ return { kill: [...tree], refused };
66
+ const rootRefused = refused.some((entry) => entry.pid === rootPid);
67
+ return { kill: rootRefused ? [] : [rootPid], refused };
68
+ }
69
+ /** Bounded synchronous CIM listing; undefined when it cannot run or does not finish in time. */
70
+ export function listWindowsProcessRowsSync(timeoutMs) {
71
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PROCESS_TREE_ROWS], {
72
+ encoding: "utf8",
73
+ windowsHide: true,
74
+ timeout: timeoutMs,
75
+ maxBuffer: 16 * 1024 * 1024,
76
+ });
77
+ if (result.error !== undefined || result.status !== 0 || typeof result.stdout !== "string")
78
+ return undefined;
79
+ const rows = parseWindowsProcessTreeRows(result.stdout);
80
+ return rows.length > 0 ? rows : undefined;
81
+ }
82
+ /**
83
+ * `taskkill` arguments for one tree kill. With a process listing, the checked tree is named pid by pid
84
+ * (never `/T`). Without one, `/T` is kept: leaving every descendant running is the worse failure, and the
85
+ * recycled-pid hazard needs a pid wraparound that a missing listing does not make more likely.
86
+ */
87
+ export function windowsTreeKillArgs(rootPid, rows, selfPid = process.pid) {
88
+ if (rows === undefined)
89
+ return ["/F", "/T", "/PID", String(rootPid)];
90
+ const pids = windowsTreeKillPlan(rows, rootPid, selfPid).kill;
91
+ if (pids.length === 0)
92
+ return [];
93
+ return ["/F", ...pids.flatMap((pid) => ["/PID", String(pid)])];
94
+ }
95
+ //# sourceMappingURL=windows-process-tree.js.map
package/dist/node.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { NodeExecutionEnv } from "./harness/env/nodejs.ts";
2
+ export { listWindowsProcessRowsSync, parseWindowsProcessTreeRows, type WindowsProcessRow, type WindowsTreeKillPlan, windowsTreeKillArgs, windowsTreeKillPlan, } from "./harness/env/windows-process-tree.ts";
2
3
  export * from "./index.ts";
3
4
  //# sourceMappingURL=node.d.ts.map
package/dist/node.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { NodeExecutionEnv } from "./harness/env/nodejs.js";
2
+ export { listWindowsProcessRowsSync, parseWindowsProcessTreeRows, windowsTreeKillArgs, windowsTreeKillPlan, } from "./harness/env/windows-process-tree.js";
2
3
  export * from "./index.js";
3
4
  //# sourceMappingURL=node.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-agent-core",
3
- "version": "2026.10.10",
3
+ "version": "2026.10.12",
4
4
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,16 +54,16 @@
54
54
  "bench:session:timing": "vitest bench --config vitest.benchmark.config.ts",
55
55
  "bench:session:memory": "tsx --tsconfig benchmark/tsconfig.json benchmark/session/loaded-footprint.ts",
56
56
  "bench:session:allocations": "tsx --tsconfig benchmark/tsconfig.json benchmark/session/allocation-profile.ts",
57
- "test": "vitest --run",
58
- "test:harness": "vitest --run --config vitest.harness.config.ts",
59
- "test:session:conformance": "vitest --run --config vitest.harness.config.ts test/harness/memory-conformance.test.ts",
60
- "coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
57
+ "test": "node ../../scripts/run-tests.mjs vitest --run",
58
+ "test:harness": "node ../../scripts/run-tests.mjs vitest --run --config vitest.harness.config.ts",
59
+ "test:session:conformance": "node ../../scripts/run-tests.mjs vitest --run --config vitest.harness.config.ts test/harness/memory-conformance.test.ts",
60
+ "coverage:harness": "node ../../scripts/run-tests.mjs vitest --run --config vitest.harness.config.ts --coverage",
61
61
  "prepublishOnly": "npm run build"
62
62
  },
63
63
  "dependencies": {
64
64
  "@earendil-works/chord": "0.99.1",
65
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10",
66
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.10",
65
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.12",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.12",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",