peaks-loop-internal-runtime 0.0.31 → 0.0.33

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,8 +1,18 @@
1
1
  import type { AutoCompactEvent } from './status-protocol.js';
2
+ /**
3
+ * `vendorWindow` is OPTIONAL and, when absent, the attribute is omitted.
4
+ *
5
+ * Why it is not defaulted: this used to be filled in by the caller with
6
+ * `adapter.maxPromptBytes / 40` (204.8 for the claude adapter) — a number that
7
+ * is not a context window, handed to a child that was then told to measure "85%
8
+ * of" it. A fabricated denominator is worse than none, because the child cannot
9
+ * tell that it is fabricated. When the caller genuinely knows the window it is
10
+ * still carried verbatim (E5, rid 2026-09-13-defects-e).
11
+ */
2
12
  export interface MarkerOpts {
3
13
  rid: string;
4
14
  sid: string;
5
- vendorWindow: number;
15
+ vendorWindow?: number;
6
16
  }
7
17
  export interface ScratchPayload {
8
18
  seq: number;
@@ -11,6 +21,35 @@ export interface ScratchPayload {
11
21
  decisionsKept?: string[];
12
22
  discardedOptions?: string[];
13
23
  }
24
+ /**
25
+ * The `<peaks-auto-compact>` marker injected at the head of a detached child's
26
+ * prompt.
27
+ *
28
+ * REWRITTEN IN E5 (rid 2026-09-13-defects-e). The previous text assigned the
29
+ * child three capabilities it does not have, and the third of them was not even
30
+ * a registered command:
31
+ *
32
+ * 1. "主动 compact 自己的会话" — a model cannot compact its own session. `/compact`
33
+ * is not model-invokable, hooks may only observe or veto, there is no
34
+ * `--compact` flag, and no external process can reach a running session's
35
+ * memory. The HARNESS compacts, on its own window and its own schedule.
36
+ * 2. "把摘要 + 当前任务状态拼回 prompt 头部" — the child's prompt IS the `-p` argv
37
+ * string built at spawn time; `ClaudeAdapter.headlessArgs` prepends this
38
+ * very marker to it. Nothing rewrites it afterwards: `dispatchDetached` is
39
+ * fire-and-forget and the spawning CLI exits, so there is no re-assembly
40
+ * step in this process or any other.
41
+ * 3. "调用 peaks runtime write-compact-event CLI 记录事件" — no such verb exists.
42
+ * `peaks runtime` registers `detect` / `list` / `compact` and nothing else.
43
+ *
44
+ * The failure mode was worse than useless: at exactly the moment the child's
45
+ * context is nearly full, it was told to spend what remained on an instruction
46
+ * that could not succeed, and to believe it had.
47
+ *
48
+ * What survives is the part that IS reachable and useful: the child can write
49
+ * durable state to disk. That is stated as the action, together with the plain
50
+ * negative — nothing flows back into its context — so the child neither wastes
51
+ * effort on the impossible nor mistakes a written file for a restored context.
52
+ */
14
53
  export declare class AutoCompactAdapter {
15
54
  marker(opts: MarkerOpts): string;
16
55
  parseScratchFile(p: ScratchPayload): Partial<AutoCompactEvent>;
@@ -1 +1 @@
1
- {"version":3,"file":"auto-compact-adapter.d.ts","sourceRoot":"","sources":["../src/auto-compact-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,MAAM,WAAW,UAAU;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;CAAE;AAC/E,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IACzC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;CACvD;AAED,qBAAa,kBAAkB;IAC7B,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM;IAiBhC,gBAAgB,CAAC,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAG/D"}
1
+ {"version":3,"file":"auto-compact-adapter.d.ts","sourceRoot":"","sources":["../src/auto-compact-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D;;;;;;;;;GASG;AACH,MAAM,WAAW,UAAU;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAAE;AAChF,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IACzC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,kBAAkB;IAC7B,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM;IAuBhC,gBAAgB,CAAC,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAG/D"}
@@ -1,17 +1,52 @@
1
+ /**
2
+ * The `<peaks-auto-compact>` marker injected at the head of a detached child's
3
+ * prompt.
4
+ *
5
+ * REWRITTEN IN E5 (rid 2026-09-13-defects-e). The previous text assigned the
6
+ * child three capabilities it does not have, and the third of them was not even
7
+ * a registered command:
8
+ *
9
+ * 1. "主动 compact 自己的会话" — a model cannot compact its own session. `/compact`
10
+ * is not model-invokable, hooks may only observe or veto, there is no
11
+ * `--compact` flag, and no external process can reach a running session's
12
+ * memory. The HARNESS compacts, on its own window and its own schedule.
13
+ * 2. "把摘要 + 当前任务状态拼回 prompt 头部" — the child's prompt IS the `-p` argv
14
+ * string built at spawn time; `ClaudeAdapter.headlessArgs` prepends this
15
+ * very marker to it. Nothing rewrites it afterwards: `dispatchDetached` is
16
+ * fire-and-forget and the spawning CLI exits, so there is no re-assembly
17
+ * step in this process or any other.
18
+ * 3. "调用 peaks runtime write-compact-event CLI 记录事件" — no such verb exists.
19
+ * `peaks runtime` registers `detect` / `list` / `compact` and nothing else.
20
+ *
21
+ * The failure mode was worse than useless: at exactly the moment the child's
22
+ * context is nearly full, it was told to spend what remained on an instruction
23
+ * that could not succeed, and to believe it had.
24
+ *
25
+ * What survives is the part that IS reachable and useful: the child can write
26
+ * durable state to disk. That is stated as the action, together with the plain
27
+ * negative — nothing flows back into its context — so the child neither wastes
28
+ * effort on the impossible nor mistakes a written file for a restored context.
29
+ */
1
30
  export class AutoCompactAdapter {
2
31
  marker(opts) {
32
+ const windowAttr = opts.vendorWindow === undefined ? '' : ` vendor-window="${opts.vendorWindow}"`;
3
33
  return [
4
- `<peaks-auto-compact threshold="0.85|0.95" vendor-window="${opts.vendorWindow}">`,
5
- `协议:`,
6
- `- 你(子进程 LLM)估算自己已用上下文 85% vendor window`,
7
- ` 主动 compact 自己的会话:把对话历史摘要写到`,
8
- ` .peaks/_runtime/${opts.sid}/detached/${opts.rid}/compact/<n>.json`,
9
- ` → 把摘要 + 当前任务状态拼回 prompt 头部`,
10
- ` → 调用 peaks runtime write-compact-event CLI 记录事件`,
11
- `- 95% 同步 compact + 立刻通知 peaks 主进程`,
12
- ` (写 status.json note: 'compact-emergency')`,
13
- `- 不要等 peaks 主进程来催;子进程 LLM 自己监控自己的 context`,
14
- `- 不限费用(用户授权)—— compact 本身消耗的 token 随它去`,
34
+ `<peaks-auto-compact threshold="0.85|0.95"${windowAttr}>`,
35
+ `上下文续命协议(如实版 —— 只写你确实能做的事):`,
36
+ `- 你的会话由 vendor harness(Claude Code 自己)压缩:它按自己的 auto-compact window 触发,`,
37
+ ` 到点直接压,不需要你请求,也无法被你触发。peaks-loop 不能压缩一个正在运行的会话 ——`,
38
+ ` 没有模型可调用的 compact,外部进程也触及不到你的会话内存。`,
39
+ ` → 所以这里没有让你自己压缩会话的入口,也不要声称做过。`,
40
+ `- 你能做、也必须做的是「在被压缩之前把不能丢的东西落到磁盘」:`,
41
+ ` 1) 摘要 + 当前任务状态 + 已定决策 + 已弃选项,写到`,
42
+ ` .peaks/_runtime/${opts.sid}/detached/${opts.rid}/compact/<n>.json`,
43
+ ` (<n> 1 递增;每次新增一个文件,不要改写已有的)`,
44
+ ` 2) 进度 / 心跳照常写 status.json(progress / state / note)`,
45
+ `- 你的 prompt 在 spawn 时已经固定,没有任何机制能把摘要拼回它。写进文件里的东西`,
46
+ ` 是给 peaks 主进程和人看的进度证据,不会回流到你的上下文。`,
47
+ `- 这些文件由你自己维护:不要等 peaks 主进程来催,它不会替你写。`,
48
+ `- token 费用:peaks-loop 不设费用上限、也不因费用中断你(没有费用闸门)。`,
49
+ ` 这不对应任何 flag —— 它是 peaks-loop 没有做的事,不是一条你能引用的授权。`,
15
50
  `</peaks-auto-compact>`,
16
51
  ].join('\n');
17
52
  }
@@ -15,6 +15,13 @@ export interface DispatchResult {
15
15
  pid: number;
16
16
  dispatchRecordPath: string;
17
17
  child?: ChildProcess;
18
+ /**
19
+ * Typed launch failure, or `null` when the vendor CLI started. A vendor CLI
20
+ * that is not installed is an expected environment, not a crash: the ENOENT
21
+ * used to surface as an unhandled 'error' event (see ProcessSupervisor.spawn)
22
+ * and escape every awaiting caller's promise chain.
23
+ */
24
+ spawnError: NodeJS.ErrnoException | null;
18
25
  }
19
26
  export declare function dispatchDetached(i: DispatchInput): Promise<DispatchResult>;
20
27
  //# sourceMappingURL=dispatch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAYvD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,IAAI,GAAC,IAAI,GAAC,IAAI,GAAC,KAAK,GAAC,iBAAiB,CAAC;IACvE,MAAM,EAAE,QAAQ,GAAC,OAAO,GAAC,SAAS,CAAC;IACnC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IAClD,UAAU,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IACzC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AACD,MAAM,WAAW,cAAc;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CAAE;AAElG,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAsChF"}
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAYvD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,IAAI,GAAC,IAAI,GAAC,IAAI,GAAC,KAAK,GAAC,iBAAiB,CAAC;IACvE,MAAM,EAAE,QAAQ,GAAC,OAAO,GAAC,SAAS,CAAC;IACnC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IAClD,UAAU,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IACzC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AACD,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,kBAAkB,EAAE,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,CAAC;CAC1C;AAED,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAqDhF"}
package/dist/dispatch.js CHANGED
@@ -15,7 +15,14 @@ export async function dispatchDetached(i) {
15
15
  throw new Error(`vendor adapter not registered: ${i.vendor}`);
16
16
  const pb = new PromptBuilder();
17
17
  const ac = new AutoCompactAdapter();
18
- const marker = ac.marker({ rid: i.rid, sid: i.sid, vendorWindow: adapter.maxPromptBytes / 40 /* rough */ });
18
+ // E5 (rid 2026-09-13-defects-e): the marker used to carry
19
+ // `adapter.maxPromptBytes / 40` as its `vendor-window` — 204.8 for the claude
20
+ // adapter. That is not a context window (it is a prompt BYTE budget divided by
21
+ // forty), and the child was then told to measure "85% of" it, with no way to
22
+ // tell the number was fabricated. peaks-loop does not know the child's window
23
+ // here — the child's own harness owns it — so the attribute is omitted rather
24
+ // than invented.
25
+ const marker = ac.marker({ rid: i.rid, sid: i.sid });
19
26
  const prompt = pb.assemble({
20
27
  rid: i.rid, role: i.role, vendor: i.vendor,
21
28
  files: i.files, refs: i.refs, userTask: i.userTask,
@@ -32,13 +39,21 @@ export async function dispatchDetached(i) {
32
39
  // no-op (ProcessSupervisor forces detached:false). Pass `false` to
33
40
  // reflect the post-F2 contract explicitly.
34
41
  const handle = await sup.spawn(adapter.binary, args, { detach: false, rid: i.rid });
42
+ // `settled` never rejects; it carries the launch outcome as a value. Awaiting
43
+ // it here is what makes a missing vendor CLI an assertable result rather than
44
+ // an exception thrown from outside this function's promise chain.
45
+ const spawnError = (await handle.settled) ?? null;
35
46
  lo.register(handle.pid, i.rid, i.sid);
36
47
  // Write dispatch record (placeholder — final shape per Task 8 schema)
37
48
  const recPath = join(i.subAgentsDir, `dispatch-${i.rid}-${Date.now()}.json`);
38
49
  mkdirSync(i.subAgentsDir, { recursive: true });
39
50
  writeFileSync(recPath, JSON.stringify({
40
51
  rid: i.rid, mode: 'detached', vendor: i.vendor,
41
- status: 'running', heartbeats: [], at: Date.now(),
52
+ // The record must not claim a child is running when the launch failed —
53
+ // that is the on-disk form of "looks green, checked nothing".
54
+ status: spawnError ? 'failed' : 'running',
55
+ ...(spawnError ? { spawnError: { code: spawnError.code, message: spawnError.message } } : {}),
56
+ heartbeats: [], at: Date.now(),
42
57
  }, null, 2));
43
- return { pid: handle.pid, dispatchRecordPath: recPath, child: handle.child };
58
+ return { pid: handle.pid, dispatchRecordPath: recPath, child: handle.child, spawnError };
44
59
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const RUNTIME_VERSION = "4.0.46";
1
+ export declare const RUNTIME_VERSION = "4.0.48";
2
2
  export declare const RUNTIME_NPM_VERSION = "0.0.21";
3
3
  export { ClaudeAdapter } from './vendor/claude-adapter.js';
4
4
  export { CodexAdapter } from './vendor/codex-adapter.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // API contract: RUNTIME_VERSION tracks peaks-loop root version (4.0.x).
2
2
  // This is what consumers (sub-agents dispatched by peaks-code) check at
3
3
  // runtime to verify protocol compatibility.
4
- export const RUNTIME_VERSION = '4.0.46';
4
+ export const RUNTIME_VERSION = '4.0.48';
5
5
  // npm version: independent 0.0.x SemVer. Runtime is its own package
6
6
  // (peaks-loop-internal-runtime@NPM_VERSION) on the registry; bumps
7
7
  // per peaks release notes when its public surface actually changes
@@ -17,6 +17,15 @@ export interface SpawnHandle {
17
17
  pid: number;
18
18
  child: ChildProcess;
19
19
  kill(signal?: NodeJS.Signals): void;
20
+ /**
21
+ * Resolves once the OS confirms the launch (`'spawn'`) or the launch fails
22
+ * (`'error'`). Never rejects: `null` means the process is running; a launch
23
+ * failure resolves to the otherwise-unhandled ErrnoException (ENOENT when the
24
+ * vendor CLI is not installed). The listener that settles this is attached
25
+ * synchronously in `spawn()` — see the comment there for why no caller can
26
+ * attach one in time.
27
+ */
28
+ settled: Promise<NodeJS.ErrnoException | null>;
20
29
  }
21
30
  export declare class ProcessSupervisor {
22
31
  private readonly cfg;
@@ -1 +1 @@
1
- {"version":3,"file":"process-supervisor.d.ts","sourceRoot":"","sources":["../src/process-supervisor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,YAAY,EAAqB,MAAM,oBAAoB,CAAC;AAIzF,MAAM,WAAW,SAAS;IACxB;;;;;;;;OAQG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC3B;AACD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;CACrC;AAED,qBAAa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE;IAElD,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC;CA+BnF"}
1
+ {"version":3,"file":"process-supervisor.d.ts","sourceRoot":"","sources":["../src/process-supervisor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,YAAY,EAAqB,MAAM,oBAAoB,CAAC;AAIzF,MAAM,WAAW,SAAS;IACxB;;;;;;;;OAQG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC3B;AACD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IACpC;;;;;;;OAOG;IACH,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CAChD;AAED,qBAAa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE;IAElD,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC;CAiEnF"}
@@ -23,16 +23,48 @@ export class ProcessSupervisor {
23
23
  // Suppress the popup console window on Windows without changing
24
24
  // process group membership. The child is still part of the
25
25
  // parent's process group (no Windows process-group detach flag).
26
+ // Deliberately NOT set on POSIX — pinned by
27
+ // `tests/unit/runtime/process-supervisor-in-shell.test.ts` 1a/1b,
28
+ // which assert this platform split by name. The spawn-hygiene guard
29
+ // reads this assignment form; see `collectWindowsHideAssignments`.
26
30
  spawnOpts.windowsHide = true;
27
31
  }
28
32
  const child = nodeSpawn(binary, args, spawnOpts);
33
+ // Attach the 'error' listener in the SAME synchronous turn the child is
34
+ // created, because no caller can do it in time. A missing binary does not
35
+ // throw from spawn(): Node emits it asynchronously as an 'error' event, and
36
+ // an EventEmitter with no 'error' listener re-throws it as a process-level
37
+ // uncaught exception. That emission runs on the nextTick queue, which
38
+ // drains BEFORE the awaiting caller resumes — so the documented caller-side
39
+ // pattern (`await spawn(...); child.on('error', …)`) loses the race by
40
+ // construction, whatever caller writes it. Capturing it here turns the
41
+ // failure into a typed value on `settled` and removes the crash.
42
+ const settled = new Promise((resolve) => {
43
+ child.on('error', (err) => resolve(err));
44
+ child.on('spawn', () => resolve(null));
45
+ });
29
46
  const dir = join(this.cfg.runtimeDir, opts.rid);
30
47
  mkdirSync(dir, { recursive: true });
31
- writeFileSync(join(dir, 'pid'), String(child.pid ?? ''));
48
+ // `<rid>/pid` exists IFF a real OS process was launched. `child.pid` is
49
+ // `undefined` until the OS confirms the spawn, so a failed launch used to
50
+ // write `String(undefined ?? '')` — an EMPTY pid file. That is not a
51
+ // cosmetic wart: `Number('')` is `0`, so a cleanup path doing
52
+ // `kill(Number(readFileSync(pid)))` would send its signal to pid 0 (the
53
+ // whole process group), and an existence check for "is something running
54
+ // here" reads `true` for a launch that never happened.
55
+ //
56
+ // Nothing is lost by omitting it: the launch failure is recorded as a
57
+ // VALUE on the dispatch record (`status: 'failed'` + `spawnError`), which
58
+ // is the surface a reader should consult. Absence in the pid file is the
59
+ // one encoding that cannot be misread as a pid.
60
+ if (child.pid !== undefined) {
61
+ writeFileSync(join(dir, 'pid'), String(child.pid));
62
+ }
32
63
  return {
33
64
  pid: child.pid ?? -1,
34
65
  child,
35
66
  kill: (signal = 'SIGTERM') => child.kill(signal),
67
+ settled,
36
68
  };
37
69
  }
38
70
  }
@@ -4,7 +4,7 @@ const DETECT_TIMEOUT_MS = 3000;
4
4
  export function detectBinaryInstalled(binary) {
5
5
  const locator = process.platform === 'win32' ? 'where.exe' : 'which';
6
6
  return new Promise((resolve) => {
7
- execFile(locator, [binary], { timeout: DETECT_TIMEOUT_MS }, (error, stdout) => {
7
+ execFile(locator, [binary], { timeout: DETECT_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
8
8
  resolve(error === null && stdout.trim().length > 0);
9
9
  });
10
10
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop-internal-runtime",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "description": "Detached sub-agent runtime: ProcessSupervisor + LifecycleOwner + VendorAdapter (claude/codex/copilot) + G8 infinite-context auto-compact. Slice 2026-08-10.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,7 +16,7 @@
16
16
  "access": "public"
17
17
  },
18
18
  "peerDependencies": {
19
- "peaks-loop-shared": "0.0.80"
19
+ "peaks-loop-shared": "0.0.82"
20
20
  },
21
21
  "devDependencies": {
22
22
  "typescript": "^5.4.0",
@@ -1,25 +1,70 @@
1
1
  import type { AutoCompactEvent } from './status-protocol.js';
2
2
 
3
- export interface MarkerOpts { rid: string; sid: string; vendorWindow: number; }
3
+ /**
4
+ * `vendorWindow` is OPTIONAL and, when absent, the attribute is omitted.
5
+ *
6
+ * Why it is not defaulted: this used to be filled in by the caller with
7
+ * `adapter.maxPromptBytes / 40` (204.8 for the claude adapter) — a number that
8
+ * is not a context window, handed to a child that was then told to measure "85%
9
+ * of" it. A fabricated denominator is worse than none, because the child cannot
10
+ * tell that it is fabricated. When the caller genuinely knows the window it is
11
+ * still carried verbatim (E5, rid 2026-09-13-defects-e).
12
+ */
13
+ export interface MarkerOpts { rid: string; sid: string; vendorWindow?: number; }
4
14
  export interface ScratchPayload {
5
15
  seq: number; at: number; summary: string;
6
16
  decisionsKept?: string[]; discardedOptions?: string[];
7
17
  }
8
18
 
19
+ /**
20
+ * The `<peaks-auto-compact>` marker injected at the head of a detached child's
21
+ * prompt.
22
+ *
23
+ * REWRITTEN IN E5 (rid 2026-09-13-defects-e). The previous text assigned the
24
+ * child three capabilities it does not have, and the third of them was not even
25
+ * a registered command:
26
+ *
27
+ * 1. "主动 compact 自己的会话" — a model cannot compact its own session. `/compact`
28
+ * is not model-invokable, hooks may only observe or veto, there is no
29
+ * `--compact` flag, and no external process can reach a running session's
30
+ * memory. The HARNESS compacts, on its own window and its own schedule.
31
+ * 2. "把摘要 + 当前任务状态拼回 prompt 头部" — the child's prompt IS the `-p` argv
32
+ * string built at spawn time; `ClaudeAdapter.headlessArgs` prepends this
33
+ * very marker to it. Nothing rewrites it afterwards: `dispatchDetached` is
34
+ * fire-and-forget and the spawning CLI exits, so there is no re-assembly
35
+ * step in this process or any other.
36
+ * 3. "调用 peaks runtime write-compact-event CLI 记录事件" — no such verb exists.
37
+ * `peaks runtime` registers `detect` / `list` / `compact` and nothing else.
38
+ *
39
+ * The failure mode was worse than useless: at exactly the moment the child's
40
+ * context is nearly full, it was told to spend what remained on an instruction
41
+ * that could not succeed, and to believe it had.
42
+ *
43
+ * What survives is the part that IS reachable and useful: the child can write
44
+ * durable state to disk. That is stated as the action, together with the plain
45
+ * negative — nothing flows back into its context — so the child neither wastes
46
+ * effort on the impossible nor mistakes a written file for a restored context.
47
+ */
9
48
  export class AutoCompactAdapter {
10
49
  marker(opts: MarkerOpts): string {
50
+ const windowAttr = opts.vendorWindow === undefined ? '' : ` vendor-window="${opts.vendorWindow}"`;
11
51
  return [
12
- `<peaks-auto-compact threshold="0.85|0.95" vendor-window="${opts.vendorWindow}">`,
13
- `协议:`,
14
- `- 你(子进程 LLM)估算自己已用上下文 85% vendor window`,
15
- ` 主动 compact 自己的会话:把对话历史摘要写到`,
16
- ` .peaks/_runtime/${opts.sid}/detached/${opts.rid}/compact/<n>.json`,
17
- ` → 把摘要 + 当前任务状态拼回 prompt 头部`,
18
- ` → 调用 peaks runtime write-compact-event CLI 记录事件`,
19
- `- 95% 同步 compact + 立刻通知 peaks 主进程`,
20
- ` (写 status.json note: 'compact-emergency')`,
21
- `- 不要等 peaks 主进程来催;子进程 LLM 自己监控自己的 context`,
22
- `- 不限费用(用户授权)—— compact 本身消耗的 token 随它去`,
52
+ `<peaks-auto-compact threshold="0.85|0.95"${windowAttr}>`,
53
+ `上下文续命协议(如实版 —— 只写你确实能做的事):`,
54
+ `- 你的会话由 vendor harness(Claude Code 自己)压缩:它按自己的 auto-compact window 触发,`,
55
+ ` 到点直接压,不需要你请求,也无法被你触发。peaks-loop 不能压缩一个正在运行的会话 ——`,
56
+ ` 没有模型可调用的 compact,外部进程也触及不到你的会话内存。`,
57
+ ` → 所以这里没有让你自己压缩会话的入口,也不要声称做过。`,
58
+ `- 你能做、也必须做的是「在被压缩之前把不能丢的东西落到磁盘」:`,
59
+ ` 1) 摘要 + 当前任务状态 + 已定决策 + 已弃选项,写到`,
60
+ ` .peaks/_runtime/${opts.sid}/detached/${opts.rid}/compact/<n>.json`,
61
+ ` (<n> 1 递增;每次新增一个文件,不要改写已有的)`,
62
+ ` 2) 进度 / 心跳照常写 status.json(progress / state / note)`,
63
+ `- 你的 prompt 在 spawn 时已经固定,没有任何机制能把摘要拼回它。写进文件里的东西`,
64
+ ` 是给 peaks 主进程和人看的进度证据,不会回流到你的上下文。`,
65
+ `- 这些文件由你自己维护:不要等 peaks 主进程来催,它不会替你写。`,
66
+ `- token 费用:peaks-loop 不设费用上限、也不因费用中断你(没有费用闸门)。`,
67
+ ` 这不对应任何 flag —— 它是 peaks-loop 没有做的事,不是一条你能引用的授权。`,
23
68
  `</peaks-auto-compact>`,
24
69
  ].join('\n');
25
70
  }
@@ -27,4 +72,4 @@ export class AutoCompactAdapter {
27
72
  parseScratchFile(p: ScratchPayload): Partial<AutoCompactEvent> {
28
73
  return { at: p.at, threshold: '0.85', tokensBefore: 0, tokensAfter: 0 };
29
74
  }
30
- }
75
+ }
package/src/dispatch.ts CHANGED
@@ -19,7 +19,18 @@ export interface DispatchInput {
19
19
  runtimeDir: string; subAgentsDir: string;
20
20
  verbatimBlocks?: string[];
21
21
  }
22
- export interface DispatchResult { pid: number; dispatchRecordPath: string; child?: ChildProcess; }
22
+ export interface DispatchResult {
23
+ pid: number;
24
+ dispatchRecordPath: string;
25
+ child?: ChildProcess;
26
+ /**
27
+ * Typed launch failure, or `null` when the vendor CLI started. A vendor CLI
28
+ * that is not installed is an expected environment, not a crash: the ENOENT
29
+ * used to surface as an unhandled 'error' event (see ProcessSupervisor.spawn)
30
+ * and escape every awaiting caller's promise chain.
31
+ */
32
+ spawnError: NodeJS.ErrnoException | null;
33
+ }
23
34
 
24
35
  export async function dispatchDetached(i: DispatchInput): Promise<DispatchResult> {
25
36
  const registry = new VendorAdapterRegistry([new ClaudeAdapter(), new CodexAdapter(), new CopilotAdapter()]);
@@ -28,7 +39,14 @@ export async function dispatchDetached(i: DispatchInput): Promise<DispatchResult
28
39
 
29
40
  const pb = new PromptBuilder();
30
41
  const ac = new AutoCompactAdapter();
31
- const marker = ac.marker({ rid: i.rid, sid: i.sid, vendorWindow: adapter.maxPromptBytes / 40 /* rough */ });
42
+ // E5 (rid 2026-09-13-defects-e): the marker used to carry
43
+ // `adapter.maxPromptBytes / 40` as its `vendor-window` — 204.8 for the claude
44
+ // adapter. That is not a context window (it is a prompt BYTE budget divided by
45
+ // forty), and the child was then told to measure "85% of" it, with no way to
46
+ // tell the number was fabricated. peaks-loop does not know the child's window
47
+ // here — the child's own harness owns it — so the attribute is omitted rather
48
+ // than invented.
49
+ const marker = ac.marker({ rid: i.rid, sid: i.sid });
32
50
  const prompt = pb.assemble({
33
51
  rid: i.rid, role: i.role, vendor: i.vendor,
34
52
  files: i.files, refs: i.refs, userTask: i.userTask,
@@ -48,6 +66,10 @@ export async function dispatchDetached(i: DispatchInput): Promise<DispatchResult
48
66
  // no-op (ProcessSupervisor forces detached:false). Pass `false` to
49
67
  // reflect the post-F2 contract explicitly.
50
68
  const handle = await sup.spawn(adapter.binary, args, { detach: false, rid: i.rid });
69
+ // `settled` never rejects; it carries the launch outcome as a value. Awaiting
70
+ // it here is what makes a missing vendor CLI an assertable result rather than
71
+ // an exception thrown from outside this function's promise chain.
72
+ const spawnError = (await handle.settled) ?? null;
51
73
  lo.register(handle.pid, i.rid, i.sid);
52
74
 
53
75
  // Write dispatch record (placeholder — final shape per Task 8 schema)
@@ -55,8 +77,12 @@ export async function dispatchDetached(i: DispatchInput): Promise<DispatchResult
55
77
  mkdirSync(i.subAgentsDir, { recursive: true });
56
78
  writeFileSync(recPath, JSON.stringify({
57
79
  rid: i.rid, mode: 'detached', vendor: i.vendor,
58
- status: 'running', heartbeats: [], at: Date.now(),
80
+ // The record must not claim a child is running when the launch failed —
81
+ // that is the on-disk form of "looks green, checked nothing".
82
+ status: spawnError ? 'failed' : 'running',
83
+ ...(spawnError ? { spawnError: { code: spawnError.code, message: spawnError.message } } : {}),
84
+ heartbeats: [], at: Date.now(),
59
85
  }, null, 2));
60
86
 
61
- return { pid: handle.pid, dispatchRecordPath: recPath, child: handle.child };
87
+ return { pid: handle.pid, dispatchRecordPath: recPath, child: handle.child, spawnError };
62
88
  }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // API contract: RUNTIME_VERSION tracks peaks-loop root version (4.0.x).
2
2
  // This is what consumers (sub-agents dispatched by peaks-code) check at
3
3
  // runtime to verify protocol compatibility.
4
- export const RUNTIME_VERSION = '4.0.46';
4
+ export const RUNTIME_VERSION = '4.0.48';
5
5
 
6
6
  // npm version: independent 0.0.x SemVer. Runtime is its own package
7
7
  // (peaks-loop-internal-runtime@NPM_VERSION) on the registry; bumps
@@ -20,6 +20,15 @@ export interface SpawnHandle {
20
20
  pid: number;
21
21
  child: ChildProcess;
22
22
  kill(signal?: NodeJS.Signals): void;
23
+ /**
24
+ * Resolves once the OS confirms the launch (`'spawn'`) or the launch fails
25
+ * (`'error'`). Never rejects: `null` means the process is running; a launch
26
+ * failure resolves to the otherwise-unhandled ErrnoException (ENOENT when the
27
+ * vendor CLI is not installed). The listener that settles this is attached
28
+ * synchronously in `spawn()` — see the comment there for why no caller can
29
+ * attach one in time.
30
+ */
31
+ settled: Promise<NodeJS.ErrnoException | null>;
23
32
  }
24
33
 
25
34
  export class ProcessSupervisor {
@@ -42,18 +51,52 @@ export class ProcessSupervisor {
42
51
  // Suppress the popup console window on Windows without changing
43
52
  // process group membership. The child is still part of the
44
53
  // parent's process group (no Windows process-group detach flag).
54
+ // Deliberately NOT set on POSIX — pinned by
55
+ // `tests/unit/runtime/process-supervisor-in-shell.test.ts` 1a/1b,
56
+ // which assert this platform split by name. The spawn-hygiene guard
57
+ // reads this assignment form; see `collectWindowsHideAssignments`.
45
58
  spawnOpts.windowsHide = true;
46
59
  }
47
60
 
48
61
  const child = nodeSpawn(binary, args, spawnOpts);
62
+
63
+ // Attach the 'error' listener in the SAME synchronous turn the child is
64
+ // created, because no caller can do it in time. A missing binary does not
65
+ // throw from spawn(): Node emits it asynchronously as an 'error' event, and
66
+ // an EventEmitter with no 'error' listener re-throws it as a process-level
67
+ // uncaught exception. That emission runs on the nextTick queue, which
68
+ // drains BEFORE the awaiting caller resumes — so the documented caller-side
69
+ // pattern (`await spawn(...); child.on('error', …)`) loses the race by
70
+ // construction, whatever caller writes it. Capturing it here turns the
71
+ // failure into a typed value on `settled` and removes the crash.
72
+ const settled = new Promise<NodeJS.ErrnoException | null>((resolve) => {
73
+ child.on('error', (err: Error) => resolve(err as NodeJS.ErrnoException));
74
+ child.on('spawn', () => resolve(null));
75
+ });
76
+
49
77
  const dir = join(this.cfg.runtimeDir, opts.rid);
50
78
  mkdirSync(dir, { recursive: true });
51
- writeFileSync(join(dir, 'pid'), String(child.pid ?? ''));
79
+ // `<rid>/pid` exists IFF a real OS process was launched. `child.pid` is
80
+ // `undefined` until the OS confirms the spawn, so a failed launch used to
81
+ // write `String(undefined ?? '')` — an EMPTY pid file. That is not a
82
+ // cosmetic wart: `Number('')` is `0`, so a cleanup path doing
83
+ // `kill(Number(readFileSync(pid)))` would send its signal to pid 0 (the
84
+ // whole process group), and an existence check for "is something running
85
+ // here" reads `true` for a launch that never happened.
86
+ //
87
+ // Nothing is lost by omitting it: the launch failure is recorded as a
88
+ // VALUE on the dispatch record (`status: 'failed'` + `spawnError`), which
89
+ // is the surface a reader should consult. Absence in the pid file is the
90
+ // one encoding that cannot be misread as a pid.
91
+ if (child.pid !== undefined) {
92
+ writeFileSync(join(dir, 'pid'), String(child.pid));
93
+ }
52
94
 
53
95
  return {
54
96
  pid: child.pid ?? -1,
55
97
  child,
56
98
  kill: (signal: NodeJS.Signals = 'SIGTERM') => child.kill(signal),
99
+ settled,
57
100
  };
58
101
  }
59
102
  }
@@ -7,7 +7,7 @@ export function detectBinaryInstalled(binary: string): Promise<boolean> {
7
7
  const locator = process.platform === 'win32' ? 'where.exe' : 'which';
8
8
 
9
9
  return new Promise((resolve) => {
10
- execFile(locator, [binary], { timeout: DETECT_TIMEOUT_MS }, (error, stdout) => {
10
+ execFile(locator, [binary], { timeout: DETECT_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
11
11
  resolve(error === null && stdout.trim().length > 0);
12
12
  });
13
13
  });