@zhushanwen/subagent-engine-sdk 0.2.0

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.
Files changed (139) hide show
  1. package/dist/best-effort.cjs +88 -0
  2. package/dist/best-effort.d.cts +14 -0
  3. package/dist/best-effort.d.ts +14 -0
  4. package/dist/best-effort.js +8 -0
  5. package/dist/chunk-2DIMPZCQ.js +0 -0
  6. package/dist/chunk-365AUV6N.js +67 -0
  7. package/dist/chunk-3K2P2CM2.js +127 -0
  8. package/dist/chunk-75QEMUGV.js +58 -0
  9. package/dist/chunk-7I4XGL5J.js +95 -0
  10. package/dist/chunk-A75XDJIC.js +227 -0
  11. package/dist/chunk-DSQ7JQKM.js +39 -0
  12. package/dist/chunk-EJMF63R5.js +19 -0
  13. package/dist/chunk-GT6YLLN4.js +46 -0
  14. package/dist/chunk-HYES77BR.js +109 -0
  15. package/dist/chunk-JSBRDJBE.js +30 -0
  16. package/dist/chunk-LEOBWKRM.js +128 -0
  17. package/dist/chunk-N3RL6OVM.js +38 -0
  18. package/dist/chunk-OPMY4G4M.js +27 -0
  19. package/dist/chunk-PPEPBVCC.js +120 -0
  20. package/dist/chunk-PYO3YR7W.js +16 -0
  21. package/dist/chunk-RDH3ZOV6.js +46 -0
  22. package/dist/chunk-RULLX6C6.js +11 -0
  23. package/dist/chunk-X24SFZYW.js +6646 -0
  24. package/dist/chunk-YFSN3D5N.js +216 -0
  25. package/dist/chunk-ZOFFJNJD.js +136 -0
  26. package/dist/chunk-ZXEAW25V.js +132 -0
  27. package/dist/cli-entry.cjs +119 -0
  28. package/dist/cli-entry.d.cts +24 -0
  29. package/dist/cli-entry.d.ts +24 -0
  30. package/dist/cli-entry.js +8 -0
  31. package/dist/contract-types-sSlgppBC.d.cts +352 -0
  32. package/dist/contract-types-sSlgppBC.d.ts +352 -0
  33. package/dist/data-dir.cjs +117 -0
  34. package/dist/data-dir.d.cts +20 -0
  35. package/dist/data-dir.d.ts +20 -0
  36. package/dist/data-dir.js +12 -0
  37. package/dist/env.cjs +205 -0
  38. package/dist/env.d.cts +73 -0
  39. package/dist/env.d.ts +73 -0
  40. package/dist/env.js +16 -0
  41. package/dist/error-codes-DHco5-i_.d.cts +118 -0
  42. package/dist/error-codes-Dhss2Kmk.d.ts +118 -0
  43. package/dist/error-message.cjs +40 -0
  44. package/dist/error-message.d.cts +3 -0
  45. package/dist/error-message.d.ts +3 -0
  46. package/dist/error-message.js +7 -0
  47. package/dist/index.cjs +8375 -0
  48. package/dist/index.d.cts +23 -0
  49. package/dist/index.d.ts +23 -0
  50. package/dist/index.js +268 -0
  51. package/dist/journal-io.cjs +70 -0
  52. package/dist/journal-io.d.cts +12 -0
  53. package/dist/journal-io.d.ts +12 -0
  54. package/dist/journal-io.js +7 -0
  55. package/dist/journal-replay.cjs +256 -0
  56. package/dist/journal-replay.d.cts +45 -0
  57. package/dist/journal-replay.d.ts +45 -0
  58. package/dist/journal-replay.js +17 -0
  59. package/dist/kill-chain.cjs +221 -0
  60. package/dist/kill-chain.d.cts +74 -0
  61. package/dist/kill-chain.d.ts +74 -0
  62. package/dist/kill-chain.js +23 -0
  63. package/dist/logger.cjs +84 -0
  64. package/dist/logger.d.cts +24 -0
  65. package/dist/logger.d.ts +24 -0
  66. package/dist/logger.js +11 -0
  67. package/dist/logs/stderr-rotation.cjs +151 -0
  68. package/dist/logs/stderr-rotation.d.cts +37 -0
  69. package/dist/logs/stderr-rotation.d.ts +37 -0
  70. package/dist/logs/stderr-rotation.js +23 -0
  71. package/dist/nesting-guard.cjs +95 -0
  72. package/dist/nesting-guard.d.cts +65 -0
  73. package/dist/nesting-guard.d.ts +65 -0
  74. package/dist/nesting-guard.js +15 -0
  75. package/dist/node-executor.cjs +180 -0
  76. package/dist/node-executor.d.cts +63 -0
  77. package/dist/node-executor.d.ts +63 -0
  78. package/dist/node-executor.js +16 -0
  79. package/dist/paths.cjs +55 -0
  80. package/dist/paths.d.cts +8 -0
  81. package/dist/paths.d.ts +8 -0
  82. package/dist/paths.js +15 -0
  83. package/dist/port-contract.cjs +35 -0
  84. package/dist/port-contract.d.cts +88 -0
  85. package/dist/port-contract.d.ts +88 -0
  86. package/dist/port-contract.js +7 -0
  87. package/dist/protocol/index.cjs +400 -0
  88. package/dist/protocol/index.d.cts +685 -0
  89. package/dist/protocol/index.d.ts +685 -0
  90. package/dist/protocol/index.js +89 -0
  91. package/dist/relay-env.cjs +71 -0
  92. package/dist/relay-env.d.cts +37 -0
  93. package/dist/relay-env.d.ts +37 -0
  94. package/dist/relay-env.js +23 -0
  95. package/dist/schema-emulation.cjs +6680 -0
  96. package/dist/schema-emulation.d.cts +39 -0
  97. package/dist/schema-emulation.d.ts +39 -0
  98. package/dist/schema-emulation.js +11 -0
  99. package/dist/spawn.cjs +200 -0
  100. package/dist/spawn.d.cts +80 -0
  101. package/dist/spawn.d.ts +80 -0
  102. package/dist/spawn.js +16 -0
  103. package/dist/ui-channels.cjs +120 -0
  104. package/dist/ui-channels.d.cts +60 -0
  105. package/dist/ui-channels.d.ts +60 -0
  106. package/dist/ui-channels.js +9 -0
  107. package/dist/ui-types.cjs +18 -0
  108. package/dist/ui-types.d.cts +62 -0
  109. package/dist/ui-types.d.ts +62 -0
  110. package/dist/ui-types.js +1 -0
  111. package/package.json +58 -0
  112. package/src/best-effort.ts +37 -0
  113. package/src/cli-entry.ts +77 -0
  114. package/src/data-dir.ts +88 -0
  115. package/src/env.ts +265 -0
  116. package/src/error-message.ts +22 -0
  117. package/src/index.ts +63 -0
  118. package/src/journal-io.ts +82 -0
  119. package/src/journal-replay.ts +432 -0
  120. package/src/kill-chain.ts +265 -0
  121. package/src/logger.ts +105 -0
  122. package/src/logs/stderr-rotation.ts +166 -0
  123. package/src/nesting-guard.ts +140 -0
  124. package/src/node-executor.ts +272 -0
  125. package/src/paths.ts +48 -0
  126. package/src/port-contract.ts +117 -0
  127. package/src/protocol/contract-types.ts +378 -0
  128. package/src/protocol/engine-protocol.ts +81 -0
  129. package/src/protocol/error-codes.ts +179 -0
  130. package/src/protocol/frames.ts +145 -0
  131. package/src/protocol/index.ts +12 -0
  132. package/src/protocol/methods.ts +229 -0
  133. package/src/protocol/reverse-channels.ts +274 -0
  134. package/src/protocol/schema.ts +154 -0
  135. package/src/relay-env.ts +60 -0
  136. package/src/schema-emulation.ts +192 -0
  137. package/src/spawn.ts +246 -0
  138. package/src/ui-channels.ts +219 -0
  139. package/src/ui-types.ts +84 -0
package/src/logger.ts ADDED
@@ -0,0 +1,105 @@
1
+ // src/logger.ts
2
+ //
3
+ // 日志 facade(引擎侧原语,自 core src/core/logger.ts 迁入 @zhushanwen/subagent-engine-sdk)。
4
+ // 迁移处置(impl-plan §2.1:「日志 facade」直接搬;kill-chain 行「logger 走 SDK facade」):
5
+ // core 版经 getHostServices()(configureCore 时序契约)动态解析宿主实现;SDK 进程
6
+ // (引擎 CLI)无 configureCore 通道,同款「调用时动态解析」语义改为经可注入
7
+ // LoggerSink——引擎 CLI 启动早期 configureLoggerSink(sink) 注入宿主形态 sink,
8
+ // 未注入时落缺省 console 出口(对齐 core NULL_HOST.log 语义:warn/error 走 console、
9
+ // debug no-op)。facade 代理而非绑死实现的时序契约(模块顶层 getLogger 先于配置、
10
+ // 配置后透明切换)与 core 版逐字一致。
11
+ //
12
+ // CJS 多 entry 内联副本的实例分裂影响 = facadeCache 分裂(同 component 引用不同);
13
+ // facade 无状态、sink 解析每次调用发生,无语义后果——与 core 版同结论。
14
+
15
+ /**
16
+ * SDK 侧 logger sink(引擎 CLI 注入宿主形态日志出口;core 版对应 HostServices.log)。
17
+ */
18
+ export interface LoggerSink {
19
+ log(level: LogLevel, component: string, message: string, data?: unknown): void;
20
+ }
21
+
22
+ /** 日志级别。对齐 core logger 的 LogLevel(三值,无 info)。 */
23
+ export type LogLevel = "debug" | "warn" | "error";
24
+
25
+ /** logger 接口。与 core CoreLogger 结构兼容——迁移调用面(方法名/参数序)逐文件等价。 */
26
+ export interface CoreLogger {
27
+ debug(msg: string, data?: unknown): void;
28
+ warn(msg: string, data?: unknown): void;
29
+ error(msg: string, data?: unknown): void;
30
+ }
31
+
32
+ // sink 配置态:globalThis[Symbol.for] slot(core host-services 同款范式)——模块级
33
+ // `let` 在 dist 双形态 / CJS 多 entry 内联副本下会被分裂,slot 形态跨副本一致。
34
+ const SINK_SLOT_KEY = Symbol.for("@zhushanwen/subagent-engine-sdk.logger-sink");
35
+
36
+ type SinkSlot = { current: LoggerSink | undefined };
37
+
38
+ function getSinkSlot(): SinkSlot {
39
+ let slot = Reflect.get(globalThis, SINK_SLOT_KEY) as SinkSlot | undefined;
40
+ if (!slot) {
41
+ slot = { current: undefined };
42
+ Reflect.set(globalThis, SINK_SLOT_KEY, slot);
43
+ }
44
+ return slot;
45
+ }
46
+
47
+ /** 缺省 console 出口(未注入 sink 时的日志不丢)。格式对齐 core NULL_HOST.log。 */
48
+ const CONSOLE_SINK: LoggerSink = {
49
+ log(level, component, message, data) {
50
+ const line = `[${component}] ${message}`;
51
+ // data 作第二参数;缺省时必须省略——node console 会把显式 undefined 格式化成
52
+ // " undefined" 尾巴污染每行输出。
53
+ if (level === "error") {
54
+ if (data === undefined) console.error(line);
55
+ else console.error(line, data);
56
+ return;
57
+ }
58
+ if (level === "warn") {
59
+ if (data === undefined) console.warn(line);
60
+ else console.warn(line, data);
61
+ return;
62
+ }
63
+ // debug 缺省 no-op:对齐 core NULL_HOST 语义(未配置期多为模块加载窗口,刷屏无
64
+ // 诊断价值);warn/error 不可静默。
65
+ },
66
+ };
67
+
68
+ function currentSink(): LoggerSink {
69
+ return getSinkSlot().current ?? CONSOLE_SINK;
70
+ }
71
+
72
+ /**
73
+ * 注入日志 sink(引擎 CLI 启动早期调用;重复调用后者覆盖——测试切 sink 依赖此语义)。
74
+ * 解析发生在每次 logger 方法调用时,模块顶层已缓存的 logger 透明切换,无加载顺序依赖。
75
+ */
76
+ export function configureLoggerSink(sink: LoggerSink): void {
77
+ getSinkSlot().current = sink;
78
+ }
79
+
80
+ /** 测试隔离专用:清空 sink 配置态(生产禁用)。 */
81
+ export function resetLoggerSinkForTests(): void {
82
+ getSinkSlot().current = undefined;
83
+ }
84
+
85
+ // 与 core getLogger singleton 惯例对齐(同 component 同引用)。
86
+ // facade 自身无状态(解析发生在方法内),缓存只为引用稳定,不影响透明切换。
87
+ const facadeCache = new Map<string, CoreLogger>();
88
+
89
+ export function getLogger(component: string): CoreLogger {
90
+ const existing = facadeCache.get(component);
91
+ if (existing) return existing;
92
+ const facade: CoreLogger = {
93
+ debug(msg, data) {
94
+ currentSink().log("debug", component, msg, data);
95
+ },
96
+ warn(msg, data) {
97
+ currentSink().log("warn", component, msg, data);
98
+ },
99
+ error(msg, data) {
100
+ currentSink().log("error", component, msg, data);
101
+ },
102
+ };
103
+ facadeCache.set(component, facade);
104
+ return facade;
105
+ }
@@ -0,0 +1,166 @@
1
+ // src/logs/stderr-rotation.ts
2
+ //
3
+ // 引擎侧 stderr tee 的实例维度文件名 + 轮转 + 过期清理(单源实现,前缀参数化——
4
+ // 两引擎包(pi-subagent-cli / zcode-subagent-cli)各自的 logs/stderr-rotation.ts
5
+ // 是本模块的薄包装,只绑定各自前缀常量)。
6
+ //
7
+ // 清理三判据(三者同时成立才删,缺一不删):
8
+ // 1. 同前缀(调用方绑定);2. pid 已死(process.kill(pid,0) 跨实例探测,存活实例
9
+ // 的文件一律不删);3. mtime 过期(XYZ_LOG_KEEP_DAYS,缺省 7 天)。
10
+ // 轮转参数读宿主同款 env:XYZ_LOG_MAX_BYTES(缺省 50MB)/ XYZ_LOG_KEEP_DAYS。
11
+
12
+ import * as fs from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+
15
+ /** 缺省轮转阈值(与宿主 logger 同源缺省:50MB)。 */
16
+ const BYTES_PER_KB = 1024;
17
+ const DEFAULT_MAX_FILE_MB = 50;
18
+ const MS_PER_SECOND = 1000;
19
+ const SECONDS_PER_MINUTE = 60;
20
+ const MINUTES_PER_HOUR = 60;
21
+ const HOURS_PER_DAY = 24;
22
+
23
+ export const DEFAULT_STDERR_MAX_BYTES = DEFAULT_MAX_FILE_MB * BYTES_PER_KB * BYTES_PER_KB;
24
+
25
+ const DAY_MS = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND;
26
+
27
+ /** 缺省保留天数(与宿主 logger 同源缺省:7 天)。 */
28
+ export const DEFAULT_STDERR_KEEP_DAYS = 7;
29
+
30
+ /** 轮转参数(env 覆盖面 = 宿主 logger 同款 XYZ_LOG_* 两键)。 */
31
+ export interface StderrRotationParams {
32
+ maxBytes: number;
33
+ keepDays: number;
34
+ }
35
+
36
+ function positiveIntEnv(env: NodeJS.ProcessEnv, key: string): number | undefined {
37
+ const raw = env[key];
38
+ if (raw === undefined || raw.trim() === "") return undefined;
39
+ const n = Number(raw);
40
+ return Number.isFinite(n) && n > 0 ? n : undefined;
41
+ }
42
+
43
+ /** 读 env 解析轮转参数(非法值回缺省——取证面配置不拖垮主通道)。 */
44
+ export function stderrRotationParams(env: NodeJS.ProcessEnv): StderrRotationParams {
45
+ return {
46
+ maxBytes:
47
+ positiveIntEnv(env, "XYZ_LOG_MAX_BYTES") ?? DEFAULT_STDERR_MAX_BYTES,
48
+ keepDays:
49
+ positiveIntEnv(env, "XYZ_LOG_KEEP_DAYS") ?? DEFAULT_STDERR_KEEP_DAYS,
50
+ };
51
+ }
52
+
53
+ /** 实例维度 stderr tee 路径:<engineDataDir>/logs/<prefix><pid>.log。 */
54
+ export function stderrLogPathFor(engineDataDir: string, pid: number, prefix: string): string {
55
+ return join(engineDataDir, "logs", `${prefix}${pid}.log`);
56
+ }
57
+
58
+ /** 从文件名解析实例 pid(非本前缀形态返回 undefined——调用方跳过)。 */
59
+ export function stderrLogPidOf(fileName: string, prefix: string): number | undefined {
60
+ if (!fileName.startsWith(prefix)) return undefined;
61
+ const rest = fileName.slice(prefix.length);
62
+ // 兼容轮转副本形态 <pid>.log.<timestamp>
63
+ const pidToken = rest.split(".", 1)[0] ?? "";
64
+ if (!/^\d+$/.test(pidToken)) return undefined;
65
+ return Number(pidToken);
66
+ }
67
+
68
+ /** pid 存活探测(跨实例:ESRCH = 死;EPERM = 存活但非属主,按存活保守处理)。 */
69
+ export function isPidAlive(pid: number): boolean {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch (err) {
74
+ return (err as NodeJS.ErrnoException).code === "EPERM";
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 尺寸轮转:当前文件超过 maxBytes 时 rename 为 `<path>.<时间戳>` 副本并返回
80
+ * true(调用方懒重开新文件继续 append)。rename 失败返回 false(继续 append 原
81
+ * 文件——轮转是取证面优化,不是主通道)。
82
+ */
83
+ export function rotateStderrLogIfNeeded(
84
+ path: string,
85
+ params: StderrRotationParams,
86
+ ): boolean {
87
+ let size: number;
88
+ try {
89
+ size = fs.statSync(path).size;
90
+ } catch {
91
+ return false;
92
+ }
93
+ if (size <= params.maxBytes) return false;
94
+ const rotated = `${path}.${Date.now()}`;
95
+ try {
96
+ fs.renameSync(path, rotated);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ export interface CleanupResult {
104
+ /** 已删除文件数。 */
105
+ deleted: number;
106
+ /** 命中前缀但跳过(pid 存活 / mtime 未过期 / 探测不确定)的文件数。 */
107
+ skipped: number;
108
+ }
109
+
110
+ /**
111
+ * 过期清理:扫描 logsDir 下同前缀文件,三判据同时成立才删。目录缺失/读失败返回
112
+ * 零删除(取证面 best-effort)。
113
+ */
114
+ export function cleanupStaleStderrLogs(
115
+ logsDir: string,
116
+ env: NodeJS.ProcessEnv,
117
+ prefix: string,
118
+ now: number = Date.now(),
119
+ ): CleanupResult {
120
+ const params = stderrRotationParams(env);
121
+ const expiryMs = params.keepDays * DAY_MS;
122
+ let deleted = 0;
123
+ let skipped = 0;
124
+ let names: string[];
125
+ try {
126
+ names = fs.readdirSync(logsDir);
127
+ } catch {
128
+ return { deleted, skipped };
129
+ }
130
+ for (const name of names) {
131
+ const pid = stderrLogPidOf(name, prefix);
132
+ if (pid === undefined) continue;
133
+ // 判据 2:pid 存活 → 该实例的文件(含轮转副本)一律不删
134
+ if (isPidAlive(pid)) {
135
+ skipped += 1;
136
+ continue;
137
+ }
138
+ // 判据 3:mtime 未过期 → 保留
139
+ const full = join(logsDir, name);
140
+ try {
141
+ if (fs.statSync(full).mtimeMs > now - expiryMs) {
142
+ skipped += 1;
143
+ continue;
144
+ }
145
+ } catch {
146
+ continue;
147
+ }
148
+ try {
149
+ fs.rmSync(full, { force: true });
150
+ deleted += 1;
151
+ } catch {
152
+ skipped += 1;
153
+ }
154
+ }
155
+ return { deleted, skipped };
156
+ }
157
+
158
+ /** 便捷入口:对 tee 路径所在 logs 目录做过期清理(流打开时机调用,每代一次)。 */
159
+ export function cleanupSiblingStderrLogs(
160
+ logPath: string,
161
+ env: NodeJS.ProcessEnv,
162
+ prefix: string,
163
+ now?: number,
164
+ ): CleanupResult {
165
+ return cleanupStaleStderrLogs(dirname(logPath), env, prefix, now);
166
+ }
@@ -0,0 +1,140 @@
1
+ // src/nesting-guard.ts
2
+ //
3
+ // 嵌套防护(引擎侧原语,自 core execution/engine/common/nesting-guard.ts 迁入
4
+ // @zhushanwen/subagent-engine-sdk,实现体逐字等价)。迁移处置:无 core 内部依赖 →
5
+ // 直接搬(impl-plan §2.1 原语迁移处置表);唯二差异:
6
+ // 1. nestedSpawnRejectedError 自 core common/errors.ts 内联进本模块(errors.ts 不在
7
+ // 迁移清单,本模块只消费它这一个构造器;文案与恢复指引逐字保留);
8
+ // 2. 头部注释的模块路径改为 SDK 落点。
9
+ //
10
+ // 设计权威源:docs/architecture/subagent-engine-abstraction.md D8(嵌套防护双层):
11
+ // 统一 XYZ_AGENT_SUBAGENT=1 标记(所有引擎 spawn 都注入,引擎 adapter 检测到即拒绝
12
+ // 递归派发)+ 剥离各引擎原生标记(CC 的 CLAUDECODE / zsub 的 ZSW_NESTED / pi 的
13
+ // PI_SUBAGENT_*)防继承泄漏——子代理环境的旧标记会让孙代理误判自己已在嵌套层。
14
+ //
15
+ // 为什么 env 标记是唯一跨引擎可靠手段(被否方案见设计):「隔离目录里不装扩展」
16
+ // 依赖配置洁癖,且 opencode/CC 会吃项目级配置;env 由宿主显式控制,随 spawn 必达。
17
+ //
18
+ // [D3-⑤ 嵌套防护合一] 进程内执行嵌套上下文(原 SubagentService.execCtxAls,pi 路径
19
+ // 私有)并入本文件——「嵌套防护」的两层机制(跨进程 env 标记 / 进程内 ALS 深度计数)
20
+ // 单点于公共层。设计权威源:docs/design/subagent-dual-track-convergence.md §3.3 D3-⑤
21
+ // + 双轨清单 #10。
22
+
23
+ import { AsyncLocalStorage } from "node:async_hooks";
24
+
25
+ /** 统一嵌套标记 env 名(D8)。值恒 '1'。 */
26
+ export const NESTED_SPAWN_ENV = "XYZ_AGENT_SUBAGENT";
27
+
28
+ /** 需剥离的引擎原生嵌套标记(精确名)。 */
29
+ const NATIVE_NESTED_KEYS: readonly string[] = ["CLAUDECODE", "ZSW_NESTED"];
30
+
31
+ /** 需剥离的引擎原生嵌套标记前缀(pi 家族)。 */
32
+ const NATIVE_NESTED_PREFIXES: readonly string[] = ["PI_SUBAGENT_"];
33
+
34
+ /** env 对象形状(NodeJS.ProcessEnv 的结构子集,测试可传普通对象)。 */
35
+ export type SpawnEnv = Record<string, string | undefined>;
36
+
37
+ /**
38
+ * nested_spawn_rejected 的结构化错误(自 core errors.ts 内联;code/recovery 与 core
39
+ * 词表逐字一致,EngineError 类形态对齐 SDK protocol/error-codes.ts 的 EngineSdkError)。
40
+ */
41
+ export class NestedSpawnRejectedError extends Error {
42
+ readonly code = "nested_spawn_rejected";
43
+ /** 恢复指引:指向具体下一步,非安慰性文案。 */
44
+ readonly recovery: string;
45
+
46
+ constructor() {
47
+ super(
48
+ "nested_spawn_rejected: this process is already a subagent (XYZ_AGENT_SUBAGENT=1)",
49
+ );
50
+ this.name = "NestedSpawnRejectedError";
51
+ this.recovery =
52
+ "Subagents must not spawn further subagents (unbounded recursion guard). " +
53
+ "Do the work directly inside the current task instead of delegating.";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 构造子代理 spawn env:注入 XYZ_AGENT_SUBAGENT=1 + 剥离引擎原生嵌套标记。
59
+ * 返回新对象,不改入参(spawn env 组装链中的多层 spread 安全)。
60
+ */
61
+ export function buildNestedSpawnEnv(baseEnv: SpawnEnv): SpawnEnv {
62
+ const env: SpawnEnv = {};
63
+ for (const [key, value] of Object.entries(baseEnv)) {
64
+ if (NATIVE_NESTED_KEYS.includes(key)) continue;
65
+ if (NATIVE_NESTED_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
66
+ env[key] = value;
67
+ }
68
+ env[NESTED_SPAWN_ENV] = "1";
69
+ return env;
70
+ }
71
+
72
+ /**
73
+ * 嵌套 spawn 防护断言:检测到统一标记(本进程已是 subagent)抛
74
+ * nested_spawn_rejected——文案说明防护规则、指向 task 内自行完成。
75
+ * 调用点:subagent 工具入口(进程创建前拒绝,D11 处置三级)。
76
+ */
77
+ export function assertNotNestedSpawn(env: SpawnEnv): void {
78
+ if (env[NESTED_SPAWN_ENV] === "1") {
79
+ throw new NestedSpawnRejectedError();
80
+ }
81
+ }
82
+
83
+ // ============================================================
84
+ // [D3-⑤] 进程内执行嵌套上下文(原 SubagentService.execCtxAls 下沉)
85
+ // ============================================================
86
+
87
+ /** 执行嵌套状态:当前正在跑的 record 身份 + 递归深度(D-033 通用嵌套深度护栏的计数载体)。 */
88
+ export interface ExecutionNestingState {
89
+ recordId: string | undefined;
90
+ depth: number;
91
+ }
92
+
93
+ /**
94
+ * 进程内执行嵌套上下文([D3-⑤] 从 SubagentService 的 execCtxAls 私有字段并入公共层)。
95
+ *
96
+ * 机制(原样迁移,行为零变化):
97
+ * - ALS 按异步调用链传递当前 record 身份:B run() 期间包 this 上下文,B 内创建 C 时
98
+ * 读到 B → C.parentRecordId=B.id、C.depth=B.depth+1;主 session 链上无 store → 顶层。
99
+ * - 进程级基线兜底 [ALS 断裂修复]:pi RPC mode 的 stdin JSONL 是事件回调式
100
+ * (attachJsonlLineReader stream.on("data")),每个命令是独立异步链,enterWith 的
101
+ * store 不会贯穿到后续 tool 调用事件(实测:递归第二层 parentRecordId/depth 丢失
102
+ * 而 rootSessionId 正确)。基线 = 本进程自己的身份(initSession 从 env 读取):
103
+ * 读 ALS store 失败时兜底,保证「本进程派发的 subagent 都是本进程记录的孩子」。
104
+ *
105
+ * 实例归属:per-Service(基线随宿主进程身份而异),Service 构造时创建并持有。
106
+ * 深度上限判据(MAX_FORK_DEPTH)留在调用方——上限常量属 execution 层
107
+ * (session-context-resolver),公共层只提供状态存取单点。
108
+ */
109
+ export class ExecutionNestingContext {
110
+ private readonly als = new AsyncLocalStorage<ExecutionNestingState>();
111
+ private baselineState: ExecutionNestingState | null = null;
112
+
113
+ /** 建立进程级基线(initSession:有 env 自我标记 → env 身份;根进程 → null 顶层)。 */
114
+ setBaseline(state: ExecutionNestingState | null): void {
115
+ this.baselineState = state;
116
+ }
117
+
118
+ /** 读当前嵌套状态:ALS store 优先,断裂时基线兜底(顶层 = null)。 */
119
+ current(): ExecutionNestingState | null {
120
+ return this.als.getStore() ?? this.baselineState;
121
+ }
122
+
123
+ /**
124
+ * 读进程级基线(与 current() 的差异:不看 ALS store——直接父归属校验等场景要的是
125
+ * 「本进程自己的身份」而非「当前异步链正在跑的 record 身份」)。
126
+ */
127
+ baseline(): ExecutionNestingState | null {
128
+ return this.baselineState;
129
+ }
130
+
131
+ /** 包裹执行(B run() 期间挂 B 身份——内层创建 C 时 current() 读到 B)。 */
132
+ run<T>(state: ExecutionNestingState, fn: () => T): T {
133
+ return this.als.run(state, fn);
134
+ }
135
+
136
+ /** 顶层 enterWith(initSession 建立基线身份后挂入当前异步链)。 */
137
+ enterWith(state: ExecutionNestingState): void {
138
+ this.als.enterWith(state);
139
+ }
140
+ }
@@ -0,0 +1,272 @@
1
+ // src/node-executor.ts
2
+ //
3
+ // 引擎 CLI 启动解析(W9,impl-plan §2.9「启动解析(宿主 × 平台二维矩阵)」)。
4
+ //
5
+ // 为什么在 SDK:core/引擎包都不可 import runtime(runtime 包只存在于 xyz-agent 宿主
6
+ // 链),而矩阵的三宿主(pi 扩展 / runtime sidecar / standalone)两侧都要消费同一套
7
+ // 解析规则——探针逻辑在 SDK 侧复刻 runtime 先例
8
+ // packages/runtime/src/infra/relay/relay-env.ts:47-90(行为保持一致:同超时、同
9
+ // --eval process.exit(0) 探针体、同 PATH/HOME 最小 env、同 Electron RUN_AS_NODE 条件)。
10
+ //
11
+ // 矩阵(规格逐行对应):
12
+ // ① pi 扩展宿主(打包):process.execPath 是 Bun standalone binary,再拉就是又起
13
+ // 一个 pi——必须用注入执行器 XYZ_AGENT_ENGINE_NODE(与 relay 的
14
+ // XYZ_SUBAGENT_RELAY_NODE 不复用,单一名字);执行器为 Electron 二进制时同时带
15
+ // ELECTRON_RUN_AS_NODE=1;首次使用前跑探针,失败 → engine_not_found + 指引。
16
+ // ② runtime sidecar:process.execPath + ELECTRON_RUN_AS_NODE=1(sidecar 本身由主进程
17
+ // 以该形态 spawn,其 execPath 即宿主 Electron / dev node)。
18
+ // ③ standalone pi / zsw:PATH node(缺 node → engine_not_found + 安装指引)。
19
+ // Windows:入口 .mjs 不需 shim;引擎声明 .cmd → 禁 shell:true,改显式
20
+ // cmd.exe /c + 参数数组。
21
+ //
22
+ // env 名常量与 packages/shared/src/constants.ts 的 W9 挂载块同源(SDK 不 import
23
+ // shared——F9,见 env.ts 头注释同款理由)。
24
+
25
+ import { spawn } from "node:child_process";
26
+
27
+ import { EngineSdkError } from "./protocol/error-codes.ts";
28
+
29
+ /** L0 注入的引擎执行器路径 env(与 relay 的 XYZ_SUBAGENT_RELAY_NODE 不复用)。 */
30
+ export const ENGINE_NODE_ENV = "XYZ_AGENT_ENGINE_NODE";
31
+
32
+ /** 探针超时(与 relay-env.ts 先例一致:spawn 执行器跑 --eval "process.exit(0)" 的上限)。 */
33
+ const PROBE_TIMEOUT_MS = 5_000;
34
+
35
+ /**
36
+ * 探针:验证执行器能以纯 node 语义执行 JS(先例 = runtime relay-env.ts:47-90)。
37
+ *
38
+ * 为什么需要:打包态 pi 宿主的候选执行器可能是 Electron 二进制——直接当 node 用会
39
+ * 拉起 GUI,必须同 env 注入 ELECTRON_RUN_AS_NODE=1 才是纯 node 模式;探针被证伪则
40
+ * 按矩阵① 报 engine_not_found(带可操作指引),不静默回落 PATH 探测。
41
+ */
42
+ export function probeNodeExecutor(
43
+ execPath: string,
44
+ isElectron: boolean,
45
+ ): Promise<boolean> {
46
+ return new Promise((resolve) => {
47
+ let settled = false;
48
+ let child: ReturnType<typeof spawn> | null = null;
49
+ const finish = (ok: boolean): void => {
50
+ if (settled) return;
51
+ settled = true;
52
+ clearTimeout(timer);
53
+ try {
54
+ child?.kill("SIGKILL");
55
+ } catch {
56
+ // 已退出,正常路径
57
+ void 0;
58
+ }
59
+ resolve(ok);
60
+ };
61
+ const env: Record<string, string> = {};
62
+ if (process.env.PATH !== undefined) env.PATH = process.env.PATH;
63
+ if (process.env.HOME !== undefined) env.HOME = process.env.HOME;
64
+ if (isElectron) env.ELECTRON_RUN_AS_NODE = "1";
65
+
66
+ try {
67
+ child = spawn(execPath, ["--eval", "process.exit(0)"], {
68
+ env,
69
+ stdio: "ignore",
70
+ windowsHide: true,
71
+ });
72
+ } catch {
73
+ resolve(false);
74
+ return;
75
+ }
76
+ const timer = setTimeout(() => finish(false), PROBE_TIMEOUT_MS);
77
+ timer.unref();
78
+ child.on("error", () => finish(false));
79
+ child.on("exit", (code) => finish(code === 0));
80
+ });
81
+ }
82
+
83
+ /** 探针结果缓存(key = execPath:isElectron)。失败也缓存——重试窗口留给宿主重启。 */
84
+ const probeCache = new Map<string, Promise<boolean>>();
85
+
86
+ function probeCached(execPath: string, isElectron: boolean): Promise<boolean> {
87
+ const key = `${execPath}:${isElectron}`;
88
+ let p = probeCache.get(key);
89
+ if (!p) {
90
+ p = probeNodeExecutor(execPath, isElectron);
91
+ probeCache.set(key, p);
92
+ }
93
+ return p;
94
+ }
95
+
96
+ /** 测试钩子:清探针缓存(生产无调用方;与 relay-env resetRelayNodeProbeCache 同款)。 */
97
+ export function resetEngineNodeProbeCache(): void {
98
+ probeCache.clear();
99
+ }
100
+
101
+ /** 宿主形态(矩阵行;EngineClient 的 hostKind 自由字符串经 hostKindOf 归一)。 */
102
+ export type EngineNodeHostKind = "pi-extension" | "runtime-sidecar" | "standalone";
103
+
104
+ /** resolveEngineNodeLaunch 的可注入项(测试隔离用;生产全部走缺省推导)。 */
105
+ export interface EngineNodeLaunchOptions {
106
+ /** 引擎 CLI 入口绝对路径(descriptor command / staged bin 解析结果)。 */
107
+ entryPath: string;
108
+ /** 入口附加参数(descriptor args;缺省 [])。 */
109
+ args?: readonly string[];
110
+ /** 宿主形态。 */
111
+ hostKind: EngineNodeHostKind;
112
+ /** 读取 XYZ_AGENT_ENGINE_NODE 的 env 快照(矩阵① 的注入通道)。 */
113
+ env?: Record<string, string | undefined>;
114
+ /** 宿主 process.execPath(缺省 process.execPath)。 */
115
+ execPath?: string;
116
+ /** 宿主自身是否 Electron(缺省 process.versions.electron !== undefined)。 */
117
+ isElectronHost?: boolean;
118
+ /** 平台覆盖(缺省 process.platform)。 */
119
+ platform?: NodeJS.Platform;
120
+ }
121
+
122
+ /** 解析结果:spawn(command, args) 形态 + buildEngineChildEnv 的 electronRunAsNode 输入。 */
123
+ export interface EngineNodeLaunch {
124
+ command: string;
125
+ args: string[];
126
+ /** 执行器为 Electron 二进制 → true(L0 注入 ELECTRON_RUN_AS_NODE=1)。 */
127
+ electronRunAsNode: boolean;
128
+ }
129
+
130
+ function engineNotFound(detail: string, recovery: string): EngineSdkError {
131
+ return new EngineSdkError("engine_not_found", detail, recovery);
132
+ }
133
+
134
+ /** 传入 opts.env 的快照(矩阵① env 缺省 = 宿主 process.env)。 */
135
+ function launchEnvOf(opts: EngineNodeLaunchOptions): Record<string, string | undefined> {
136
+ return opts.env ?? process.env;
137
+ }
138
+
139
+ /**
140
+ * 矩阵①:pi 扩展宿主(打包)——必须用注入执行器 XYZ_AGENT_ENGINE_NODE
141
+ * (env 缺失 = engine_not_found;执行器探针失败 = engine_not_found + 指引)。
142
+ */
143
+ async function resolvePiExtensionLaunch(
144
+ entryPath: string,
145
+ entryArgs: string[],
146
+ env: Record<string, string | undefined>,
147
+ ): Promise<EngineNodeLaunch> {
148
+ const engineNode = env[ENGINE_NODE_ENV]?.trim();
149
+ if (engineNode === undefined || engineNode === "") {
150
+ throw engineNotFound(
151
+ `pi extension host must spawn engines via injected executor ${ENGINE_NODE_ENV}, `
152
+ + `but it is not set (host process.execPath is the pi binary, not a node executor)`,
153
+ `The xyz-agent runtime injects ${ENGINE_NODE_ENV} when spawning the pi host. `
154
+ + `If you are running the extension inside a packaged app, report this as a packaging `
155
+ + `regression; standalone pi installs do not use the pi-extension host kind.`,
156
+ );
157
+ }
158
+ // 执行器为 Electron 二进制时宿主会同时注入 ELECTRON_RUN_AS_NODE=1(矩阵① 同点注入)
159
+ const isElectron = env.ELECTRON_RUN_AS_NODE === "1";
160
+ if (!(await probeCached(engineNode, isElectron))) {
161
+ throw engineNotFound(
162
+ `injected node executor failed the probe: ${engineNode} (isElectron=${isElectron})`,
163
+ `Verify the executor exists and can run plain node semantics `
164
+ + `(Electron binaries need ELECTRON_RUN_AS_NODE=1). The host re-probes after restart.`,
165
+ );
166
+ }
167
+ return { command: engineNode, args: [entryPath, ...entryArgs], electronRunAsNode: isElectron };
168
+ }
169
+
170
+ /**
171
+ * 矩阵②:runtime sidecar——process.execPath + ELECTRON_RUN_AS_NODE=1(Electron 宿主时,
172
+ * 探针先行)。
173
+ */
174
+ async function resolveRuntimeSidecarLaunch(
175
+ entryPath: string,
176
+ entryArgs: string[],
177
+ opts: EngineNodeLaunchOptions,
178
+ ): Promise<EngineNodeLaunch> {
179
+ const execPath = opts.execPath ?? process.execPath;
180
+ const isElectron = opts.isElectronHost ?? process.versions.electron !== undefined;
181
+ if (!(await probeCached(execPath, isElectron))) {
182
+ throw engineNotFound(
183
+ `runtime sidecar executor failed the probe: ${execPath} (isElectron=${isElectron})`,
184
+ `The sidecar process.execPath must run plain node semantics (ELECTRON_RUN_AS_NODE=1 `
185
+ + `for Electron binaries). Check how the runtime sidecar was spawned.`,
186
+ );
187
+ }
188
+ return { command: execPath, args: [entryPath, ...entryArgs], electronRunAsNode: isElectron };
189
+ }
190
+
191
+ /** 矩阵③:standalone——PATH node(探针被证伪 = engine_not_found + 安装指引)。 */
192
+ async function resolveStandaloneLaunch(
193
+ entryPath: string,
194
+ entryArgs: string[],
195
+ ): Promise<EngineNodeLaunch> {
196
+ if (!(await probeCached("node", false))) {
197
+ throw engineNotFound(
198
+ `no usable 'node' on PATH for standalone engine launch (entry: ${entryPath})`,
199
+ `Install Node.js >= 22 and ensure 'node' is on PATH `
200
+ + `(https://nodejs.org/), or configure the engine explicitly via subagents/config.json.`,
201
+ );
202
+ }
203
+ return { command: "node", args: [entryPath, ...entryArgs], electronRunAsNode: false };
204
+ }
205
+
206
+ /**
207
+ * 宿主 × 平台二维矩阵解析(W9 §2.9)。返回 spawn argv;不直接 spawn——调用方
208
+ * (EngineClient / runtime)持有各自的平台参数(detached / 进程组收割语义)。
209
+ *
210
+ * 判定序(与规格逐行对应):
211
+ * Windows + entry .cmd → cmd.exe /c 显式形态(禁 shell:true 的注入面);
212
+ * pi-extension → 注入执行器(env XYZ_AGENT_ENGINE_NODE),缺失/探针失败 =
213
+ * engine_not_found + 指引;
214
+ * runtime-sidecar → process.execPath(Electron 时 electronRunAsNode,探针先行);
215
+ * standalone → PATH node(探针失败 = engine_not_found + 安装指引);
216
+ * 其余(dev 形态、非 Windows 非 .cmd、无矩阵命中的兜底)→ 直接 spawn 入口本体
217
+ * (shebang `#!/usr/bin/env node` 走 PATH node——引擎 bin 均带 shebang,dev/独立
218
+ * 安装形态与现状一致)。
219
+ */
220
+ export async function resolveEngineNodeLaunch(
221
+ opts: EngineNodeLaunchOptions,
222
+ ): Promise<EngineNodeLaunch> {
223
+ const platform = opts.platform ?? process.platform;
224
+ const entryArgs = [...(opts.args ?? [])];
225
+
226
+ // Windows 规则:入口 .cmd → 显式 cmd.exe /c + 参数数组(禁 shell:true)
227
+ if (platform === "win32" && opts.entryPath.toLowerCase().endsWith(".cmd")) {
228
+ return {
229
+ command: "cmd.exe",
230
+ args: ["/c", opts.entryPath, ...entryArgs],
231
+ electronRunAsNode: false,
232
+ };
233
+ }
234
+
235
+ // 非 JS 入口(原生二进制 / shell 脚本形态的引擎或测试 fixture)自管理执行体:
236
+ // 不经 node 执行器改写、不探针——直接 spawn 入口本体(shebang/原生入口语义)。
237
+ if (!/\.(?:js|mjs|cjs|ts)$/i.test(opts.entryPath)) {
238
+ return { command: opts.entryPath, args: entryArgs, electronRunAsNode: false };
239
+ }
240
+
241
+ if (opts.hostKind === "pi-extension") {
242
+ return resolvePiExtensionLaunch(opts.entryPath, entryArgs, launchEnvOf(opts));
243
+ }
244
+
245
+ if (opts.hostKind === "runtime-sidecar") {
246
+ return resolveRuntimeSidecarLaunch(opts.entryPath, entryArgs, opts);
247
+ }
248
+
249
+ if (opts.hostKind === "standalone") {
250
+ return resolveStandaloneLaunch(opts.entryPath, entryArgs);
251
+ }
252
+
253
+ // 非矩阵形态(理论不可达——hostKind 是封闭联合);保守回落直接 spawn 入口本体。
254
+ return { command: opts.entryPath, args: entryArgs, electronRunAsNode: false };
255
+ }
256
+
257
+ /**
258
+ * EngineClient 的自由字符串 hostKind('pi' / 'runtime' / …)→ 矩阵行归一:
259
+ * 'runtime*' → runtime-sidecar(②:sidecar execPath 权威);
260
+ * env 已注入 XYZ_AGENT_ENGINE_NODE(打包态 runtime 注入主 pi 进程的通道)→
261
+ * pi-extension(①:必须用注入执行器——pi 宿主自身 execPath 是 pi binary);
262
+ * 其余 → standalone(③:PATH node)。
263
+ */
264
+ export function hostKindOf(
265
+ hostKind: string,
266
+ env: Record<string, string | undefined> = process.env,
267
+ ): EngineNodeHostKind {
268
+ if (hostKind.startsWith("runtime")) return "runtime-sidecar";
269
+ const engineNode = env[ENGINE_NODE_ENV]?.trim();
270
+ if (engineNode !== undefined && engineNode !== "") return "pi-extension";
271
+ return "standalone";
272
+ }