@code-yeongyu/senpi-codemode 2026.10.5 → 2026.10.6

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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * CPU time a process group has used so far, read from the kernel without spawning anything, in
3
+ * platform units that only ever grow: the startup watchdog compares two readings and never converts.
4
+ *
5
+ * - darwin: every member from `proc_listpgrppids`, each `ri_user_time + ri_system_time` from
6
+ * `proc_pid_rusage(RUSAGE_INFO_V2)` (libproc, via `bun:ffi`);
7
+ * - linux: every `/proc/<pid>/stat` whose process group is `pgid`, `utime + stime` in clock ticks;
8
+ * - darwin on a runtime without `bun:ffi` (Node): the members' `cputime` from one `ps` call, in
9
+ * hundredths of a second;
10
+ * - win32 and anything else: `undefined`. Windows has no process groups, and the launcher's own time
11
+ * misses the interpreter a launcher such as juliaup starts, so the caller treats the CPU as unknown.
12
+ *
13
+ * `bun:ffi` is fetched with `process.getBuiltinModule` so the module still loads on Node, the same
14
+ * runtime boundary `process-footprint.ts` in coding-agent uses. Every read is synchronous and never throws.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import { readdirSync, readFileSync } from "node:fs";
18
+
19
+ type BunFfi = typeof import("bun:ffi");
20
+ type GroupCpuReader = (pgid: number) => bigint | undefined;
21
+
22
+ const RUSAGE_INFO_V2 = 2;
23
+ const RI_USER_TIME_OFFSET = 16;
24
+ const RI_SYSTEM_TIME_OFFSET = 24;
25
+ const RUSAGE_BUFFER_BYTES = 256;
26
+ const MAX_GROUP_MEMBERS = 4096;
27
+ const PS_TIMEOUT_MS = 2_000;
28
+
29
+ let groupReader: GroupCpuReader | null | undefined;
30
+
31
+ export function readProcessGroupCpuTime(pgid: number): bigint | undefined {
32
+ if (!Number.isInteger(pgid) || pgid <= 0) return undefined;
33
+ if (groupReader === undefined) groupReader = createGroupReader() ?? null;
34
+ return groupReader?.(pgid);
35
+ }
36
+
37
+ /** `utime + stime` and the process group from `/proc/<pid>/stat` text, or `undefined` when malformed. */
38
+ export function parseProcStat(stat: string): { readonly pgrp: number; readonly cpuTicks: bigint } | undefined {
39
+ // The command name may itself contain spaces or parentheses: the fields start after the last ")".
40
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
41
+ const pgrp = Number(fields[2]);
42
+ const utime = fields[11];
43
+ const stime = fields[12];
44
+ if (!Number.isInteger(pgrp) || utime === undefined || stime === undefined) return undefined;
45
+ if (!/^\d+$/.test(utime) || !/^\d+$/.test(stime)) return undefined;
46
+ return { pgrp, cpuTicks: BigInt(utime) + BigInt(stime) };
47
+ }
48
+
49
+ /** `ps` `cputime` (`[[hh:]mm:]ss.cc`) in hundredths of a second, or `undefined` when malformed. */
50
+ export function parsePsCpuTime(text: string): bigint | undefined {
51
+ const match = /^(?:(\d+):)?(?:(\d+):)?(\d+)\.(\d{2})$/.exec(text.trim());
52
+ if (match === null) return undefined;
53
+ const [, first, second, seconds, hundredths] = match;
54
+ const hours = second === undefined ? 0 : Number(first ?? 0);
55
+ const minutes = second === undefined ? Number(first ?? 0) : Number(second);
56
+ return BigInt(((hours * 60 + minutes) * 60 + Number(seconds)) * 100 + Number(hundredths));
57
+ }
58
+
59
+ function createGroupReader(): GroupCpuReader | undefined {
60
+ if (process.platform === "linux") return linuxGroupReader;
61
+ if (process.platform !== "darwin") return undefined;
62
+ const ffi = process.getBuiltinModule("bun:ffi") as BunFfi | undefined;
63
+ if (ffi === undefined) return darwinPsGroupReader;
64
+ try {
65
+ return darwinGroupReader(ffi);
66
+ } catch {
67
+ // A library or symbol this build cannot bind: read the group through ps instead.
68
+ return darwinPsGroupReader;
69
+ }
70
+ }
71
+
72
+ function darwinPsGroupReader(pgid: number): bigint | undefined {
73
+ let listing: string;
74
+ try {
75
+ listing = execFileSync("ps", ["-A", "-o", "pgid=,cputime="], { encoding: "utf8", timeout: PS_TIMEOUT_MS });
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ let total = 0n;
80
+ let members = 0;
81
+ for (const line of listing.split("\n")) {
82
+ const [group, cputime] = line.trim().split(/\s+/);
83
+ if (group !== String(pgid) || cputime === undefined) continue;
84
+ const used = parsePsCpuTime(cputime);
85
+ if (used === undefined) continue;
86
+ total += used;
87
+ members += 1;
88
+ }
89
+ return members === 0 ? undefined : total;
90
+ }
91
+
92
+ function linuxGroupReader(pgid: number): bigint | undefined {
93
+ let entries: string[];
94
+ try {
95
+ entries = readdirSync("/proc");
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ let total = 0n;
100
+ let members = 0;
101
+ for (const entry of entries) {
102
+ if (!/^\d+$/.test(entry)) continue;
103
+ let stat: string;
104
+ try {
105
+ stat = readFileSync(`/proc/${entry}/stat`, "utf8");
106
+ } catch {
107
+ // The process exited between the directory read and this one.
108
+ continue;
109
+ }
110
+ const parsed = parseProcStat(stat);
111
+ if (parsed === undefined || parsed.pgrp !== pgid) continue;
112
+ total += parsed.cpuTicks;
113
+ members += 1;
114
+ }
115
+ return members === 0 ? undefined : total;
116
+ }
117
+
118
+ function darwinGroupReader({ dlopen, FFIType, ptr }: BunFfi): GroupCpuReader {
119
+ const library = dlopen("libSystem.B.dylib", {
120
+ proc_listpgrppids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
121
+ proc_pid_rusage: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
122
+ });
123
+ const pids = new Int32Array(MAX_GROUP_MEMBERS);
124
+ const usage = new Uint8Array(RUSAGE_BUFFER_BYTES);
125
+ const view = new DataView(usage.buffer);
126
+ return (pgid) => {
127
+ const count = library.symbols.proc_listpgrppids(pgid, ptr(pids), pids.byteLength);
128
+ if (count <= 0) return undefined;
129
+ let total = 0n;
130
+ let members = 0;
131
+ for (const pid of pids.subarray(0, Math.min(count, MAX_GROUP_MEMBERS))) {
132
+ usage.fill(0);
133
+ if (library.symbols.proc_pid_rusage(pid, RUSAGE_INFO_V2, ptr(usage)) !== 0) continue;
134
+ total += view.getBigUint64(RI_USER_TIME_OFFSET, true) + view.getBigUint64(RI_SYSTEM_TIME_OFFSET, true);
135
+ members += 1;
136
+ }
137
+ return members === 0 ? undefined : total;
138
+ };
139
+ }
@@ -5,6 +5,7 @@ import type { SessionEnvironment } from "../session-env.ts";
5
5
  import type { KernelLifecycle } from "./kernel-death.ts";
6
6
  import type { FootprintReader } from "./kernel-memory-host.ts";
7
7
  import type { SubprocessSpawn } from "./subprocess-process.ts";
8
+ import type { SubprocessStartupOptions } from "./subprocess-startup.ts";
8
9
 
9
10
  export type KernelRunInput = EvalKernelRunInput;
10
11
 
@@ -29,4 +30,6 @@ export interface SubprocessKernelOptions extends KernelLifecycle {
29
30
  readonly spawn?: SubprocessSpawn;
30
31
  readonly onMessage?: (message: KernelToHostMessage) => void;
31
32
  readonly memory?: SubprocessKernelMemory & { readonly language: EvalLanguage };
33
+ /** Fails a start that stops making progress before `ready`; absent, the kernel waits for `ready` indefinitely. */
34
+ readonly startup?: SubprocessStartupOptions;
32
35
  }
@@ -23,6 +23,7 @@ import {
23
23
  settlePendingRun,
24
24
  timeoutResult,
25
25
  } from "./subprocess-run.ts";
26
+ import { SubprocessStartupWatchdog } from "./subprocess-startup.ts";
26
27
 
27
28
  export type {
28
29
  KernelResult,
@@ -40,6 +41,7 @@ export class SubprocessKernel {
40
41
  private readonly memory: KernelMemoryHost | null;
41
42
  private process: SubprocessProcess | null = null;
42
43
  private processReady = false;
44
+ private startup: SubprocessStartupWatchdog | null = null;
43
45
  private retirementPromise: Promise<void> | null = null;
44
46
  private retirementProcess: SubprocessProcess | null = null;
45
47
  private retirementFailure: Error | null = null;
@@ -133,6 +135,7 @@ export class SubprocessKernel {
133
135
  async close(): Promise<void> {
134
136
  const wasClosed = this.closed;
135
137
  this.closed = true;
138
+ this.startup?.stop();
136
139
  if (!wasClosed) {
137
140
  this.settleAll(new KernelClosingError());
138
141
  this.runs.clearToolCalls();
@@ -197,10 +200,21 @@ export class SubprocessKernel {
197
200
  });
198
201
  this.process = process;
199
202
  this.processReady = false;
203
+ this.startup?.stop();
204
+ const startup = this.options.startup;
205
+ this.startup = startup
206
+ ? new SubprocessStartupWatchdog(startup, child.pid, (message) => this.stalled(process, message))
207
+ : null;
200
208
  this.memory?.processReplaced();
201
209
  try {
210
+ const thresholds = this.options.memory?.thresholds;
202
211
  process.send(
203
- encodeBridgeFrame({ type: "init", sessionId: this.options.sessionId, connection: this.options.connection }),
212
+ encodeBridgeFrame({
213
+ type: "init",
214
+ sessionId: this.options.sessionId,
215
+ connection: this.options.connection,
216
+ ...(thresholds === undefined ? {} : { memory: thresholds }),
217
+ }),
204
218
  );
205
219
  } catch (error) {
206
220
  const failure = new KernelStartupError(error instanceof Error ? error.message : String(error));
@@ -221,7 +235,9 @@ export class SubprocessKernel {
221
235
 
222
236
  private handleMessage(process: SubprocessProcess, message: KernelToHostMessage): void {
223
237
  if (!this.accepts(process)) return;
238
+ if (!this.processReady) this.startup?.observe(message);
224
239
  if (message.type === "ready") {
240
+ this.startup?.stop();
225
241
  this.processReady = true;
226
242
  this.runs.handleMessage(message, this.onMessage);
227
243
  this.pumpRuns();
@@ -247,6 +263,7 @@ export class SubprocessKernel {
247
263
 
248
264
  private handleExit(process: SubprocessProcess, code: number | null, signal: NodeJS.Signals | null): void {
249
265
  if (this.process !== process) return;
266
+ this.startup?.stop();
250
267
  if (process.isRetiring) {
251
268
  this.process = null;
252
269
  this.retirementFailure = null;
@@ -277,6 +294,20 @@ export class SubprocessKernel {
277
294
  onDeath(reason);
278
295
  }
279
296
 
297
+ /** A stalled start is a death: the owner replaces the kernel and the cells that never started run there. */
298
+ private stalled(process: SubprocessProcess, message: string): void {
299
+ if (!this.accepts(process)) return;
300
+ const error = new KernelStartupError(message);
301
+ const onDeath = this.options.onDeath;
302
+ if (!onDeath) {
303
+ this.failClosed(error);
304
+ return;
305
+ }
306
+ process.retire();
307
+ this.trackRetirement(process, this.terminateOwnedProcess(process));
308
+ this.die(error, message, onDeath);
309
+ }
310
+
280
311
  private accepts(process: SubprocessProcess): boolean {
281
312
  return !this.closed && this.process === process && !process.isRetiring;
282
313
  }
@@ -330,6 +361,7 @@ export class SubprocessKernel {
330
361
  }
331
362
 
332
363
  private failClosed(error: Error): void {
364
+ this.startup?.stop();
333
365
  const process = this.process;
334
366
  this.failure = error;
335
367
  this.closed = true;
@@ -0,0 +1,90 @@
1
+ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+
3
+ /**
4
+ * How long a starting interpreter may stay completely still - no output line, no new startup stage, and
5
+ * no CPU used by its process group - before startup fails. A cold Julia compiling its prelude prints
6
+ * nothing for seconds but keeps the CPU busy, so it never trips; only a runner that is silent AND idle
7
+ * (blocked on a lock, a pipe or a stopped process) does. It restarts on every sign of progress, so it is
8
+ * not a total startup budget. Where the group's CPU cannot be read it never fires.
9
+ */
10
+ export const subprocessStartupNoProgressMs = 30_000;
11
+
12
+ const STDERR_TAIL_CHARS = 2_000;
13
+ const stages = ["interpreter-launch", "stdlib-imports", "runtime-init", "host-init"] as const;
14
+ export type SubprocessStartupStage = (typeof stages)[number];
15
+
16
+ export interface SubprocessStartupOptions {
17
+ readonly label: string;
18
+ readonly noProgressMs?: number;
19
+ /** CPU time used so far by a process group (any unit that only grows); `undefined` when unreadable. */
20
+ readonly readGroupCpuTime?: (pgid: number) => bigint | undefined;
21
+ }
22
+
23
+ export class SubprocessStartupWatchdog {
24
+ readonly #options: SubprocessStartupOptions;
25
+ readonly #pid: number | undefined;
26
+ readonly #onStall: (message: string) => void;
27
+ readonly #noProgressMs: number;
28
+ #stageIndex = 0;
29
+ #stderrTail = "";
30
+ #lastCpu: bigint | undefined;
31
+ #timer: ReturnType<typeof setTimeout> | undefined;
32
+ #stopped = false;
33
+
34
+ constructor(options: SubprocessStartupOptions, pid: number | undefined, onStall: (message: string) => void) {
35
+ this.#options = options;
36
+ this.#pid = pid;
37
+ this.#onStall = onStall;
38
+ this.#noProgressMs = options.noProgressMs ?? subprocessStartupNoProgressMs;
39
+ this.#lastCpu = this.#readCpu();
40
+ this.#arm();
41
+ }
42
+
43
+ /** Any message the runner sent before `ready` is progress; a later startup stage also moves the stage. */
44
+ observe(message: KernelToHostMessage): void {
45
+ if (this.#stopped) return;
46
+ if (message.type === "status" && message.event.op === "kernel-startup") {
47
+ const stage = stages.find((candidate) => candidate === message.event.stage);
48
+ const next = stage === undefined ? -1 : stages.indexOf(stage);
49
+ if (next > this.#stageIndex) this.#stageIndex = next;
50
+ }
51
+ if (message.type === "text" && message.stream === "stderr") {
52
+ this.#stderrTail = (this.#stderrTail + message.data).slice(-STDERR_TAIL_CHARS);
53
+ }
54
+ this.#arm();
55
+ }
56
+
57
+ stop(): void {
58
+ this.#stopped = true;
59
+ clearTimeout(this.#timer);
60
+ this.#timer = undefined;
61
+ }
62
+
63
+ #arm(): void {
64
+ clearTimeout(this.#timer);
65
+ this.#timer = setTimeout(() => this.#expire(), this.#noProgressMs);
66
+ }
67
+
68
+ #expire(): void {
69
+ if (this.#stopped) return;
70
+ const cpu = this.#readCpu();
71
+ // Unreadable CPU means busy and idle look the same: never cut a start off on silence alone.
72
+ // Any change counts, a drop included: a member of the group exited, which is activity.
73
+ if (cpu === undefined || cpu !== this.#lastCpu) {
74
+ this.#lastCpu = cpu;
75
+ this.#arm();
76
+ return;
77
+ }
78
+ this.stop();
79
+ const stage = stages[this.#stageIndex] ?? "interpreter-launch";
80
+ const seconds = Math.round(this.#noProgressMs / 1000);
81
+ const tail = this.#stderrTail.trim();
82
+ this.#onStall(
83
+ `${this.#options.label} kernel stalled at ${stage}: no output, stage change or CPU use for ${seconds} s${tail ? `; stderr: ${tail}` : ""}`,
84
+ );
85
+ }
86
+
87
+ #readCpu(): bigint | undefined {
88
+ return this.#pid === undefined ? undefined : this.#options.readGroupCpuTime?.(this.#pid);
89
+ }
90
+ }
@@ -209,7 +209,7 @@ async function executeCell(
209
209
  const kernel = await execution.wait(options.kernelManager.getKernel(invocation.input.language, onMessage));
210
210
  // Computed before the handler so its construction-time snapshot captures this cell's capability.
211
211
  // Worker messages later restore that snapshot before calling host tools (#1754, #2512).
212
- const kernelTools = jsKernelTools(kernel, invocation.input.language);
212
+ const kernelTools = kernelToolsFor(kernel);
213
213
  const runBound = async (): Promise<AgentToolResult<EvalToolDetails>> => {
214
214
  const queue = kernel.queueSnapshot();
215
215
  state.queuedBehind = [...(queue.activeCellId === null ? [] : [queue.activeCellId]), ...queue.queuedCellIds];
@@ -292,16 +292,15 @@ async function executeCell(
292
292
  }
293
293
  }
294
294
 
295
- function jsKernelTools(kernel: EvalKernel, language: string): KernelToolsCapability | undefined {
296
- if (language !== "js") return undefined;
295
+ function kernelToolsFor(kernel: EvalKernel): KernelToolsCapability | undefined {
297
296
  if (!("describeKernelTools" in kernel) || typeof kernel.describeKernelTools !== "function") return undefined;
298
- const js = kernel as EvalKernel & {
297
+ const withTools = kernel as EvalKernel & {
299
298
  describeKernelTools: (names: readonly string[]) => Promise<KernelToolsDescribeResult>;
300
299
  invokeKernelTool: ExtensionKernelTools["invoke"];
301
300
  };
302
301
  return {
303
302
  capabilities: KERNEL_TOOLS_CAPABILITIES,
304
- describe: (names) => js.describeKernelTools(names),
305
- invoke: (request, options) => js.invokeKernelTool(request, options),
303
+ describe: (names) => withTools.describeKernelTools(names),
304
+ invoke: (request, options) => withTools.invokeKernelTool(request, options),
306
305
  } satisfies ExtensionKernelTools;
307
306
  }