odai-dsh-agent 0.2.56 → 0.2.57

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 (70) hide show
  1. package/README.md +11 -9
  2. package/client/client.js +12 -5
  3. package/package.json +1 -1
  4. package/preset/odai/package.json +2 -1
  5. package/preset/odai/runtime/context-activation.mjs +1 -1
  6. package/preset/odai/runtime/context-activation.mjs.map +1 -1
  7. package/preset/odai/runtime/index.d.mts.map +1 -1
  8. package/preset/odai/runtime/index.mjs +51 -2
  9. package/preset/odai/runtime/index.mjs.map +1 -1
  10. package/preset/odai/runtime/lead-mode.d.mts +23 -0
  11. package/preset/odai/runtime/lead-mode.d.mts.map +1 -0
  12. package/preset/odai/runtime/lead-mode.mjs +17 -0
  13. package/preset/odai/runtime/lead-mode.mjs.map +1 -0
  14. package/preset/odai/runtime/lifecycle-runtime.d.mts.map +1 -1
  15. package/preset/odai/runtime/lifecycle-runtime.mjs +196 -13
  16. package/preset/odai/runtime/lifecycle-runtime.mjs.map +1 -1
  17. package/preset/odai/runtime/responsibility-gap.d.mts +4 -1
  18. package/preset/odai/runtime/responsibility-gap.d.mts.map +1 -1
  19. package/preset/odai/runtime/responsibility-gap.mjs +1 -0
  20. package/preset/odai/runtime/responsibility-gap.mjs.map +1 -1
  21. package/preset/odai/runtime/role-overlays.d.mts +1 -1
  22. package/preset/odai/runtime/role-overlays.d.mts.map +1 -1
  23. package/preset/odai/runtime/role-overlays.mjs +4 -1
  24. package/preset/odai/runtime/role-overlays.mjs.map +1 -1
  25. package/preset/odai/runtime/route-session.d.mts +20 -0
  26. package/preset/odai/runtime/route-session.d.mts.map +1 -0
  27. package/preset/odai/runtime/route-session.mjs +44 -0
  28. package/preset/odai/runtime/route-session.mjs.map +1 -0
  29. package/preset/odai/runtime/router.d.mts +2 -1
  30. package/preset/odai/runtime/router.d.mts.map +1 -1
  31. package/preset/odai/runtime/router.mjs +52 -1
  32. package/preset/odai/runtime/router.mjs.map +1 -1
  33. package/preset/odai/runtime/routing-config.d.mts +5 -2
  34. package/preset/odai/runtime/routing-config.d.mts.map +1 -1
  35. package/preset/odai/runtime/routing-config.mjs +25 -14
  36. package/preset/odai/runtime/routing-config.mjs.map +1 -1
  37. package/preset/odai/runtime/runtime-config.d.mts +1 -1
  38. package/preset/odai/runtime/runtime-config.d.mts.map +1 -1
  39. package/preset/odai/runtime/runtime-support.d.mts +6 -1
  40. package/preset/odai/runtime/runtime-support.d.mts.map +1 -1
  41. package/preset/odai/runtime/runtime-support.mjs +13 -7
  42. package/preset/odai/runtime/runtime-support.mjs.map +1 -1
  43. package/preset/odai/runtime/runtime-types.d.mts +2 -0
  44. package/preset/odai/runtime/runtime-types.d.mts.map +1 -1
  45. package/preset/odai/runtime/runtime-types.mjs.map +1 -1
  46. package/preset/odai/runtime/session-evidence.d.mts.map +1 -1
  47. package/preset/odai/runtime/session-evidence.mjs +6 -0
  48. package/preset/odai/runtime/session-evidence.mjs.map +1 -1
  49. package/preset/odai/runtime/skill-bundle.d.mts +1 -1
  50. package/preset/odai/runtime/skill-bundle.d.mts.map +1 -1
  51. package/preset/odai/runtime/tool-runtime.d.mts.map +1 -1
  52. package/preset/odai/runtime/tool-runtime.mjs +28 -0
  53. package/preset/odai/runtime/tool-runtime.mjs.map +1 -1
  54. package/preset/odai/skills/odai-orchestration/SKILL.md +1 -1
  55. package/preset/odai/skills/odai-orchestration/assets/routing-roles/controller.md +1 -1
  56. package/preset/odai/skills/odai-orchestration/assets/routing-roles/writer.md +9 -0
  57. package/preset/odai/skills/odai-orchestration/manifest.json +10 -4
  58. package/preset/odai/skills/odai-orchestration/references/install.md +66 -4
  59. package/preset/odai/skills/odai-orchestration/references/orchestration.md +2 -1
  60. package/preset/odai/skills/odai-orchestration/scripts/build-routing.mjs +78 -24
  61. package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.d.mts +1 -1
  62. package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.mjs +6 -5
  63. package/preset/odai/skills/odai-orchestration/scripts/install-routing.mjs +66 -15
  64. package/preset/odai/skills/odai-orchestration/scripts/local-cli.d.mts +8 -0
  65. package/preset/odai/skills/odai-orchestration/scripts/local-cli.mjs +25 -9
  66. package/preset/odai/skills/odai-orchestration/scripts/recognize-routing.d.mts +6 -0
  67. package/preset/odai/skills/odai-orchestration/scripts/recognize-routing.mjs +93 -0
  68. package/preset/odai/skills/odai-orchestration/scripts/route-hook.mjs +243 -0
  69. package/preset/odai/skills/odai-orchestration/scripts/run-role.mjs +71 -16
  70. package/preset/odai/skills/odai-orchestration/scripts/verify-routing.mjs +2 -2
@@ -20,13 +20,14 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
20
20
  const builder = path.join(scriptDir, "build-routing.mjs");
21
21
  const manifestName = "odai-routing.json";
22
22
  const requiredRoles = ["controller", "planner", "reviewer"];
23
- const configurableRoles = [...requiredRoles, "researcher", "frontend", "creative"];
23
+ const configurableRoles = [...requiredRoles, "researcher", "frontend", "creative", "writer"];
24
24
  const args = parseArgs(process.argv.slice(2));
25
25
  const roles = Object.freeze([
26
26
  ...requiredRoles,
27
27
  ...(args.researcherModel ? ["researcher"] : []),
28
28
  ...(args.frontendModel ? ["frontend"] : []),
29
29
  ...(args.creativeModel ? ["creative"] : []),
30
+ ...(args.writerModel ? ["writer"] : []),
30
31
  ]);
31
32
  const retiredByHost = {
32
33
  codex: [
@@ -41,11 +42,12 @@ const retiredByHost = {
41
42
 
42
43
  if (args.help) { printHelp(); process.exit(0); }
43
44
  if (!new Set(["codex", "claude", "copilot"]).has(args.host)) fail(`不支持的 host:${args.host || "(missing)"}`);
45
+ if ((args.namedDispatch || args.leadMode) && !["claude", "codex"].includes(args.host)) fail("--named-dispatch / --lead-mode requires claude or codex");
44
46
  if (!new Set(["project", "user"]).has(args.scope)) fail(`不支持的 scope:${args.scope}`);
45
47
  for (const role of configurableRoles) {
46
48
  if (!args[`${role}Cli`]) continue;
47
- if (args.host !== "claude") fail(`--${role}-cli is only supported with --host claude`);
48
- if (!["codex", "antigravity", "grok"].includes(args[`${role}Cli`])) fail(`--${role}-cli must be codex, antigravity, or grok`);
49
+ if (!["claude", "codex"].includes(args.host)) fail(`--${role}-cli requires claude or codex`);
50
+ if (!(args.host === "codex" ? ["claude", "antigravity", "grok"] : ["codex", "antigravity", "grok"]).includes(args[`${role}Cli`])) fail(`--${role}-cli must be ${args.host === "codex" ? "claude" : "codex"}, antigravity, or grok`);
49
51
  if (!args[`${role}Model`]) fail(`--${role}-cli 需要同时提供 --${role}-model`);
50
52
  }
51
53
  if (!args.yes) fail("安装、更新或卸载会持久化宿主配置;取得用户授权后传入 --yes");
@@ -72,6 +74,7 @@ function main() {
72
74
  for (const role of requiredRoles) if (!args[`${role}Model`]) fail(`缺少 --${role}-model`);
73
75
  if (args.researcherEffort && !args.researcherModel) fail("--researcher-effort 需要同时提供 --researcher-model");
74
76
  if (args.frontendEffort && !args.frontendModel) fail("--frontend-effort 需要同时提供 --frontend-model");
77
+ if (args.writerEffort && !args.writerModel) fail("--writer-effort 需要同时提供 --writer-model");
75
78
  if (args.creativeEffort && !args.creativeModel) fail("--creative-effort 需要同时提供 --creative-model");
76
79
  if (!existsSync(builder)) fail(`缺少路由生成器:${builder}`);
77
80
 
@@ -128,10 +131,13 @@ function main() {
128
131
 
129
132
  function buildAdapter(outputRoot, root) {
130
133
  const command = [builder, "--host", args.host, "--out", outputRoot];
134
+ if (args.namedDispatch) command.push("--named-dispatch");
135
+ if (args.leadMode) command.push("--lead-mode");
131
136
  if (args.governanceRoot) command.push("--governance-root", args.governanceRoot);
132
137
  for (const role of roles) {
133
138
  command.push(`--${role}-model`, args[`${role}Model`]);
134
139
  if (args[`${role}Effort`]) command.push(`--${role}-effort`, args[`${role}Effort`]);
140
+ if (args[`${role}Session`]) command.push(`--${role}-session`, args[`${role}Session`]);
135
141
  if (args[`${role}Cli`]) command.push(`--${role}-cli`, args[`${role}Cli`]);
136
142
  }
137
143
  if (args.host === "codex") command.push("--verifier-command", `node ${JSON.stringify(path.join(root, "odai-verify-routing.mjs"))}`);
@@ -151,6 +157,11 @@ function collectGeneratedFiles(root, layoutValue) {
151
157
  if (args.host === "claude" && args.scope === "user" && targetRelative === "agents/odai-controller.md") {
152
158
  content = Buffer.from(content.toString("utf8").replaceAll("node .claude/odai-run-role.mjs", claudeRunnerCommand()));
153
159
  }
160
+ if (args.host === "codex" && targetRelative === "named-dispatch-plugin/hooks/hooks.json") {
161
+ const value = JSON.parse(content);
162
+ for (const entries of Object.values(value.hooks)) for (const entry of entries) for (const hook of entry.hooks) hook.command = `node '${path.join(configRoot, "odai-route-hook.mjs").replaceAll("'", "'\\''")}'`;
163
+ content = Buffer.from(JSON.stringify(value, null, 2) + "\n");
164
+ }
154
165
  result.set(targetRelative, content);
155
166
  }
156
167
  return result;
@@ -176,19 +187,19 @@ function originalFileRecord(file) {
176
187
  }
177
188
 
178
189
  function mergeCodexConfig(original, generated) {
179
- if (/^\s*\[agents\.odai_(?:researcher|planner|executor|reviewer|frontend|creative)\]\s*$/m.test(original)) {
190
+ if (/^\s*\[agents\.odai_(?:researcher|planner|executor|reviewer|frontend|creative|writer)\]\s*$/m.test(original)) {
180
191
  fail("既有 Codex 配置已声明 odai 角色但不受当前安装清单管理");
181
192
  }
182
193
  const generatedFeatures = generated.search(/^\s*\[features\]\s*$/m);
183
194
  if (generatedFeatures < 0) fail("生成的 Codex 配置缺少 [features]");
184
195
  const generatedAgents = generated.search(/^\s*\[agents\.odai_/m);
185
- if (generatedAgents < 0) fail("生成的 Codex 配置缺少 odai 角色注册");
196
+
186
197
  const existingInstructions = topLevelTomlString(original, "developer_instructions");
187
198
  const generatedController = generated.slice(0, generatedFeatures).trim();
188
199
  const controller = existingInstructions
189
200
  ? appendDeveloperInstructions(generatedController, existingInstructions)
190
201
  : generatedController;
191
- const agents = generated.slice(generatedAgents).trim();
202
+ const agents = generatedAgents < 0 ? "" : generated.slice(generatedAgents).trim();
192
203
  let preserved = removeTopLevelCodexKeys(original, new Set(["model", "model_reasoning_effort", "developer_instructions"]));
193
204
  preserved = setFeatureFlag(preserved, "multi_agent", "true");
194
205
  return [controller, preserved.trim(), agents].filter(Boolean).join("\n\n") + "\n";
@@ -246,6 +257,7 @@ function setFeatureFlag(source, name, value) {
246
257
  return lines.join("\n");
247
258
  }
248
259
 
260
+
249
261
  function hostLayout(host, scope) {
250
262
  if (host === "codex") {
251
263
  const researcherFiles = [
@@ -258,6 +270,8 @@ function hostLayout(host, scope) {
258
270
  [".codex/role-contracts/odai-frontend.md", "role-contracts/odai-frontend.md"],
259
271
  ];
260
272
  const frontend = roles.includes("frontend") ? frontendFiles : [];
273
+ const writerFiles = [[".codex/agents/odai-writer.toml", "agents/odai-writer.toml"], [".codex/role-contracts/odai-writer.md", "role-contracts/odai-writer.md"]];
274
+ const writer = roles.includes("writer") ? writerFiles : [];
261
275
  const creativeFiles = [
262
276
  [".codex/agents/odai-creative.toml", "agents/odai-creative.toml"],
263
277
  [".codex/role-contracts/odai-creative.md", "role-contracts/odai-creative.md"],
@@ -274,10 +288,13 @@ function hostLayout(host, scope) {
274
288
  ...researcher,
275
289
  ...frontend,
276
290
  ...creative,
291
+ ...writer,
277
292
  ];
278
- const generatedFiles = common;
293
+ common.push(...namedFiles(host).map(file => [`.codex/${file}`, file]));
294
+ const generatedFiles = common.filter(([source]) => !roles.some(role => args[`${role}Cli`] && source === `.codex/agents/odai-${role}.toml`));
295
+ if (roles.some(role => args[`${role}Cli`])) generatedFiles.push([".codex/odai-local-cli.mjs", "odai-local-cli.mjs"]);
279
296
  return {
280
- knownFiles: [...new Set([...common, ...researcherFiles, ...frontendFiles, ...creativeFiles].map(([, target]) => target)), "hooks.json", "odai-route-hook.mjs", "odai-run-routing.mjs", "agents/odai-executor.toml", "role-contracts/odai-executor.md"],
297
+ knownFiles: [...namedFiles(host, true), "odai-local-cli.mjs", ...new Set([...common, ...researcherFiles, ...frontendFiles, ...creativeFiles, ...writerFiles].map(([, target]) => target)), "hooks.json", "odai-route-hook.mjs", "odai-run-routing.mjs", "agents/odai-executor.toml", "role-contracts/odai-executor.md"],
281
298
  managedFiles: generatedFiles.map(([, target]) => target),
282
299
  generatedFiles,
283
300
  settings: null,
@@ -288,9 +305,10 @@ function hostLayout(host, scope) {
288
305
  const local = roles.filter(role => args[`${role}Cli`]);
289
306
  const managedFiles = roles.map(role => `${args[`${role}Cli`] ? "role-contracts" : "agents"}/odai-${role}.md`);
290
307
  if (local.length) managedFiles.push(...executors);
308
+ managedFiles.push(...namedFiles(host));
291
309
  return {
292
310
  knownFiles: [...configurableRoles.map(role => `agents/odai-${role}.md`),
293
- ...configurableRoles.filter(role => role !== "controller").map(role => `role-contracts/odai-${role}.md`), ...executors],
311
+ ...configurableRoles.filter(role => role !== "controller").map(role => `role-contracts/odai-${role}.md`), ...executors, ...namedFiles(host, true)],
294
312
  managedFiles,
295
313
  generatedFiles: managedFiles.map(file => [`.claude/${file}`, file]),
296
314
  settings: { file: scope === "project" ? "settings.local.json" : "settings.json", key: "agent", value: "odai-controller",
@@ -314,6 +332,7 @@ function planSettings(root, layoutValue, manifest) {
314
332
  if (manifest) {
315
333
  if (!installed || installed.file !== spec.file || installed.key !== spec.key || installed.installed !== spec.value) fail("旧路由缺少可恢复的宿主设置记录");
316
334
  if (current[spec.key] !== spec.value) fail(`宿主设置 ${spec.key} 已被外部修改`);
335
+ restoreNamedHooks(current, installed.namedHooks);
317
336
  restorePermission(current, installed.permission);
318
337
  } else {
319
338
  const present = Object.hasOwn(current, spec.key);
@@ -321,6 +340,14 @@ function planSettings(root, layoutValue, manifest) {
321
340
  installed = { file: spec.file, key: spec.key, installed: spec.value, fileExistedBefore: existsSync(file), previous: { present, value: present ? current[spec.key] : null } };
322
341
  }
323
342
  const record = { ...installed };
343
+ delete record.namedHooks;
344
+ if (args.namedDispatch || args.leadMode) {
345
+ const command = `node '${path.join(root, "odai-route-hook.mjs").replaceAll("'", "'\\''")}'`;
346
+ const entries = Object.fromEntries(["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"].map(event => [event, { matcher: "*", hooks: [{ type: "command", command, timeout: 10 }] }]));
347
+ record.namedHooks = entries;
348
+ current.hooks ||= {};
349
+ for (const [event, entry] of Object.entries(entries)) current.hooks[event] = [...(current.hooks[event] || []), entry];
350
+ }
324
351
  delete record.permission;
325
352
  if (spec.permission) {
326
353
  const permissionsPresent = Object.hasOwn(current, "permissions");
@@ -388,7 +415,9 @@ function assertManagedState(root, layoutValue, manifest) {
388
415
  || installedSetting.installed !== expectedSetting.value) {
389
416
  fail("旧路由缺少可恢复的宿主设置记录");
390
417
  }
391
- assertPermission(readJsonObject(path.join(root, installedSetting.file)), installedSetting.permission);
418
+ const currentSettings = readJsonObject(path.join(root, installedSetting.file));
419
+ assertNamedHooks(currentSettings, installedSetting.namedHooks);
420
+ assertPermission(currentSettings, installedSetting.permission);
392
421
  } else if (installedSetting) {
393
422
  fail("旧路由包含当前宿主不支持的设置记录");
394
423
  }
@@ -415,7 +444,7 @@ function removeObsolete(root, manifest, desiredFiles) {
415
444
  if (!manifest) return;
416
445
  const desired = new Set(desiredFiles.keys());
417
446
  const obsolete = new Set([
418
- ...layout.retiredFiles.filter((relative) => manifest.files?.[relative]),
447
+ ...layout.retiredFiles.filter((relative) => manifest.files?.[relative] && !desired.has(relative)),
419
448
  ...Object.keys(manifest.files || {}).filter((relative) => !desired.has(relative)),
420
449
  ]);
421
450
  for (const relative of obsolete) if (existsSync(path.join(root, relative))) unlinkSync(path.join(root, relative));
@@ -461,6 +490,7 @@ function uninstall(root, manifestPathValue, manifest) {
461
490
  function planSettingRestore(root, spec) {
462
491
  const current = readJsonObject(path.join(root, spec.file));
463
492
  if (current[spec.key] !== spec.installed) fail(`宿主设置 ${spec.key} 已被外部修改,拒绝删除`);
493
+ restoreNamedHooks(current, spec.namedHooks);
464
494
  restorePermission(current, spec.permission);
465
495
  if (spec.previous?.present) current[spec.key] = spec.previous.value; else delete current[spec.key];
466
496
  return {
@@ -542,12 +572,15 @@ function parseArgs(values) {
542
572
  const fields = new Map([["--host", "host"], ["--scope", "scope"], ["--target", "target"], ["--governance-root", "governanceRoot"]]);
543
573
  for (const role of configurableRoles) {
544
574
  fields.set(`--${role}-model`, `${role}Model`); fields.set(`--${role}-effort`, `${role}Effort`);
575
+ if (role !== "controller") fields.set(`--${role}-session`, `${role}Session`);
545
576
  if (role !== "controller") fields.set(`--${role}-cli`, `${role}Cli`);
546
577
  }
547
578
  for (let index = 0; index < values.length; index += 1) {
548
579
  const value = values[index];
549
580
  if (value === "--help" || value === "-h") result.help = true;
550
581
  else if (value === "--uninstall") result.uninstall = true;
582
+ else if (value === "--lead-mode") result.leadMode = true;
583
+ else if (value === "--named-dispatch") result.namedDispatch = true;
551
584
  else if (value === "--yes") result.yes = true;
552
585
  else if (fields.has(value)) { const next = values[++index] || ""; if (!next || next.startsWith("--")) fail(`${value} 需要一个值`); result[fields.get(value)] = next; }
553
586
  else fail(`未知参数:${value}`);
@@ -555,20 +588,38 @@ function parseArgs(values) {
555
588
  return result;
556
589
  }
557
590
 
558
- function hasMappingArgs(value) { return configurableRoles.some((role) => value[`${role}Model`] || value[`${role}Effort`] || value[`${role}Cli`]); }
591
+ function hasMappingArgs(value) { return configurableRoles.some((role) => value[`${role}Model`] || value[`${role}Effort`] || value[`${role}Cli`] || value[`${role}Session`]); }
559
592
  function printHelp() {
560
593
  console.log(`由 odai 在取得用户授权后安装、更新或卸载宿主路由。
561
594
 
562
595
  Usage:
563
596
  node skills/odai-orchestration/scripts/install-routing.mjs --host <codex|claude|copilot> --scope <project|user> [--target <path>] \\
564
597
  --controller-model <model> --planner-model <model> --reviewer-model <model> \\
565
- [--researcher-model <model>] [--frontend-model <model>] [--creative-model <model>] [--<role>-effort <effort>] [--governance-root <path>] --yes
566
- Claude Code only: [--<role>-cli <codex|antigravity|grok>] (non-controller roles)
598
+ [--researcher-model <model>] [--frontend-model <model>] [--creative-model <model>] [--writer-model <model>] [--<role>-effort <effort>] [--governance-root <path>] --yes
599
+ Claude: [--<role>-cli <codex|antigravity|grok>]; Codex: [--<role>-cli <claude|antigravity|grok>]\n [--<role>-session <fresh|continue>] (non-controller roles)\n [--named-dispatch] [--lead-mode] (Claude / Codex only)
567
600
 
568
601
  node skills/odai-orchestration/scripts/install-routing.mjs --host <codex|claude|copilot> --scope <project|user> [--target <path>] --uninstall --yes
569
602
 
570
- 安装后用户只需正常使用 odai。总控是唯一持续任务线程并负责实施整合;planner、reviewer 以及可选 researcher、frontend、creative 只在能改变结果时启动。researcher、frontend 与 creative 映射默认不配置。更新会安全移除旧版 advisor、implementer、worker、executor 和 stage runner 托管文件。`);
603
+ 安装后用户只需正常使用 odai。总控是唯一持续任务线程并负责实施整合;planner、reviewer 以及可选 researcher、frontend、creative、writer 只在能改变结果时启动。researcher、frontend、creative 与 writer 映射默认不配置。更新会安全移除旧版 advisor、implementer、worker、executor 和 stage runner 托管文件。`);
571
604
  }
572
605
 
573
606
  function returnResult(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); }
574
607
  function fail(message) { throw new Error(message); }
608
+
609
+ function namedFiles(host, all = false) {
610
+ if (!all && !args.namedDispatch && !args.leadMode) return [];
611
+ return ["odai-route-hook.mjs", "recognize-routing.mjs", "odai-named-dispatch.json", ...(host === "codex" ? [".agents/plugins/marketplace.json", "named-dispatch-plugin/.codex-plugin/plugin.json", "named-dispatch-plugin/hooks/hooks.json"] : [])];
612
+ }
613
+ function assertNamedHooks(current, entries) {
614
+ for (const [event, entry] of Object.entries(entries || {})) {
615
+ if (!(current.hooks?.[event] || []).some(value => JSON.stringify(value) === JSON.stringify(entry))) fail(`路由 Hook 已漂移:${event}`);
616
+ }
617
+ }
618
+ function restoreNamedHooks(current, entries) {
619
+ assertNamedHooks(current, entries);
620
+ for (const [event, entry] of Object.entries(entries || {})) {
621
+ current.hooks[event] = current.hooks[event].filter(value => JSON.stringify(value) !== JSON.stringify(entry));
622
+ if (!current.hooks[event].length) delete current.hooks[event];
623
+ }
624
+ if (current.hooks && !Object.keys(current.hooks).length) delete current.hooks;
625
+ }
@@ -7,6 +7,7 @@ export interface LocalCliRoute {
7
7
  /** Validated by the selected CLI adapter. */
8
8
  reasoningEffort?: string;
9
9
  timeoutSeconds?: number;
10
+ session?: "fresh" | "continue";
10
11
  provider?: never;
11
12
  maxTokens?: never;
12
13
  }
@@ -31,6 +32,8 @@ export interface LocalCliParsedResult {
31
32
  actualReasoningEffort?: string;
32
33
  sessionId?: string;
33
34
  deniedActionsCount?: number;
35
+ boundaryStatus?: "boundary-violation";
36
+ boundaryTools?: string[];
34
37
  }
35
38
 
36
39
  export interface LocalCliExecutionResult extends Omit<LocalCliParsedResult, "status"> {
@@ -45,6 +48,7 @@ export interface ExecuteLocalCliOptions {
45
48
  prompt: string;
46
49
  cwd: string;
47
50
  signal?: AbortSignal;
51
+ resumeSessionId?: string;
48
52
  env?: NodeJS.ProcessEnv;
49
53
  /** Test seam. Injected spawns use child.kill unless killProcess is also supplied. */
50
54
  spawnProcess?: typeof spawn;
@@ -57,6 +61,8 @@ export interface ExecuteLocalCliOptions {
57
61
  }
58
62
 
59
63
  export interface LocalCliContext {
64
+ sessionId?: string;
65
+ resumeSessionId?: string;
60
66
  cwd?: string;
61
67
  prompt?: string;
62
68
  promptFile?: string;
@@ -73,3 +79,5 @@ export function parseLocalCliResult(stdout: string, requestedRoute: string | Loc
73
79
  * the direct child; process-tree cleanup on Windows is not verified.
74
80
  */
75
81
  export function executeLocalCli(options: ExecuteLocalCliOptions): Promise<LocalCliExecutionResult>;
82
+
83
+ export const AGY_READ_ONLY_TOOLS: readonly string[];
@@ -1,9 +1,10 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { spawn } from "node:child_process";
2
3
  import { chmodSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import { homedir, tmpdir } from "node:os";
4
5
  import { join, resolve } from "node:path";
5
6
  const ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
6
- const ROUTE_FIELDS = new Set(["backend", "cli", "model", "reasoningEffort", "timeoutSeconds"]);
7
+ const ROUTE_FIELDS = new Set(["backend", "cli", "model", "reasoningEffort", "timeoutSeconds", "session"]);
7
8
  const DEFAULT_TIMEOUT_SECONDS = 1200;
8
9
  const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
9
10
  const STDERR_BYTES = 8192;
@@ -29,28 +30,36 @@ export function validateLocalCliRoute(value) {
29
30
  if (value.reasoningEffort !== undefined && (typeof value.reasoningEffort !== "string" || /[^a-z]/.test(value.reasoningEffort) || !ADAPTERS[value.cli].effort.test(value.reasoningEffort))) {
30
31
  throw new TypeError(`Invalid ${value.cli} reasoningEffort`);
31
32
  }
33
+ if (value.session !== undefined && !["fresh", "continue"].includes(value.session)) throw new TypeError("session must be fresh or continue");
34
+ if (value.session === "continue" && !ADAPTERS[value.cli].continuation) throw new TypeError(`${value.cli} 暂不支持会话续接`);
32
35
  const timeoutSeconds = value.timeoutSeconds;
33
36
  if (timeoutSeconds !== undefined && (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0 || timeoutSeconds > 7200)) {
34
37
  throw new TypeError("local-cli timeoutSeconds must be an integer from 1 to 7200");
35
38
  }
36
39
  return {
37
40
  backend: "local-cli", cli: value.cli, model: value.model,
41
+ ...(value.session !== undefined ? { session: value.session } : {}),
38
42
  ...(value.reasoningEffort !== undefined ? { reasoningEffort: value.reasoningEffort } : {}),
39
43
  ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
40
44
  };
41
45
  }
42
46
 
43
- const AGY_READ_BOUNDARY = "\n\n读文件只用 view_file、grep_search、list_dir、find_by_name;命令行不可用,会被拒绝。";
47
+ export const AGY_READ_ONLY_TOOLS = Object.freeze(["read_file", "search_web", "ask_question", "manage_task", "view_file", "grep_search", "list_dir", "find_by_name"]);
48
+ const AGY_READ_BOUNDARY = "\n\n只用只读工具读取任务包中已给出路径的文件(例如 read_file,旧版本叫 view_file)。不要列目录、搜索文件、执行命令、写入或编辑文件;这些动作会被拒绝,并使本次调用作废。需要但未给出的文件,请在回交中列明所需文件,不要自行尝试。";
44
49
  const effortArgs = (route) => route.reasoningEffort ? ["--effort", route.reasoningEffort] : [];
45
50
  const ADAPTERS = Object.freeze({
46
51
  claude: {
52
+ continuation: true,
47
53
  effort: /^(low|medium|high|xhigh|max)$/,
48
54
  command: "claude", override: "ODAI_CLAUDE_COMMAND", input: "stdin",
49
- args: (route) => ["-p", "--output-format", "json", "--model", route.model, ...effortArgs(route),
50
- "--tools", "Read,Glob,Grep", "--permission-mode", "dontAsk", "--no-session-persistence"],
55
+ args: (route, { sessionId, resumeSessionId }) => ["-p", "--output-format", "json", "--model", route.model, ...effortArgs(route),
56
+ "--tools", "Read,Glob,Grep", "--permission-mode", "dontAsk", ...(route.session === "continue"
57
+ ? [resumeSessionId ? "--resume" : "--session-id", resumeSessionId || sessionId]
58
+ : ["--no-session-persistence"])],
51
59
  parse: (stdout, route) => parseClaudeResult(stdout, route.model),
52
60
  },
53
61
  codex: {
62
+ continuation: false,
54
63
  effort: /^[a-z]+$/,
55
64
  command: "codex", override: "ODAI_CODEX_COMMAND", input: "stdin",
56
65
  args: (route, { cwd }) => ["exec", "--json", "--model", route.model,
@@ -59,18 +68,20 @@ const ADAPTERS = Object.freeze({
59
68
  parse: parseCodexResult,
60
69
  },
61
70
  antigravity: {
71
+ continuation: true,
62
72
  effort: /^(low|medium|high)$/,
63
73
  command: "agy", override: "ODAI_AGY_COMMAND", input: "argv",
64
- args: (route, { prompt }) => {
74
+ args: (route, { prompt, resumeSessionId }) => {
65
75
  if (typeof prompt !== "string") throw new TypeError("antigravity requires a prompt");
66
76
  const input = prompt + AGY_READ_BOUNDARY;
67
77
  if (Buffer.byteLength(input, "utf8") > 256 * 1024) throw new TypeError("antigravity prompt exceeds 256 KiB (including read-only instructions)");
68
- return ["-p", input, "--output-format", "stream-json", "--mode", "plan", "--disable-slash-commands", "--model", route.model,
78
+ return [...(resumeSessionId ? ["--conversation", resumeSessionId] : []), "-p", input, "--output-format", "stream-json", "--mode", "plan", "--disable-slash-commands", "--model", route.model,
69
79
  ...effortArgs(route), "--print-timeout", `${route.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS}s`];
70
80
  },
71
81
  parse: parseAntigravityResult,
72
82
  },
73
83
  grok: {
84
+ continuation: false,
74
85
  effort: /^(none|minimal|low|medium|high|xhigh|max)$/,
75
86
  command: "grok", override: "ODAI_GROK_COMMAND", input: "file",
76
87
  args: (route, { cwd, promptFile }) => {
@@ -89,6 +100,8 @@ export function buildLocalCliInvocation(route, env = process.env, context = {})
89
100
  throw new TypeError(`${normalized.cli} requires cwd`);
90
101
  }
91
102
  const adapter = ADAPTERS[normalized.cli];
103
+ if (context.resumeSessionId !== undefined && (normalized.session !== "continue" || typeof context.resumeSessionId !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(context.resumeSessionId))) throw new TypeError("Invalid resume session ID");
104
+ if (normalized.cli === "claude" && normalized.session === "continue" && !context.resumeSessionId && !context.sessionId) throw new TypeError("Claude continuation requires a new session ID");
92
105
  return { command: env[adapter.override] || adapter.command, args: adapter.args(normalized, context) };
93
106
  }
94
107
 
@@ -214,6 +227,8 @@ function parseCodexResult(stdout, route, { cwd, env }) {
214
227
 
215
228
  function parseAntigravityResult(stdout, route) {
216
229
  const events = jsonLines(stdout);
230
+ const violations = [...new Set(events.filter(event => event.event === "step_update" && event.step_update?.step_type === "tool" && event.step_update?.state === "DONE" && !AGY_READ_ONLY_TOOLS.includes(event.step_update.tool_name)).map(event => String(event.step_update.tool_name ?? "<missing>")))];
231
+ if (violations.length) return { ...emptyResult("error", `boundary-violation: ${violations.join(", ")};agy 的权限设置允许了写类工具,请检查 agy 设置里的 permissions.allow。执行后检查不能撤销已发生的动作。`), boundaryStatus: "boundary-violation", boundaryTools: violations };
217
232
  const init = events.find((event) => event.event === "init");
218
233
  const payload = events.findLast((event) => event.event === "result")?.result;
219
234
  if (!isRecord(payload)) return emptyResult("error", "Antigravity returned no result event");
@@ -285,10 +300,11 @@ export function parseLocalCliResult(stdout, requestedRoute, context = {}) {
285
300
 
286
301
  /** Executes only the target CLI. Host routing and fallback policy belong to callers. */
287
302
  export async function executeLocalCli({
288
- route, prompt, cwd, signal, env = process.env, spawnProcess = spawn, killProcess,
303
+ route, prompt, cwd, signal, resumeSessionId, env = process.env, spawnProcess = spawn, killProcess,
289
304
  killGraceMs = 1000, maxOutputBytes = MAX_OUTPUT_BYTES,
290
305
  }) {
291
306
  const normalized = validateLocalCliRoute(route);
307
+ const sessionId = normalized.session === "continue" && normalized.cli === "claude" ? resumeSessionId || randomUUID() : undefined;
292
308
  if (typeof prompt !== "string") throw new TypeError("local-cli prompt must be a string");
293
309
  if (typeof cwd !== "string" || !cwd) throw new TypeError("local-cli cwd must be a non-empty string");
294
310
  if (!Number.isSafeInteger(killGraceMs) || killGraceMs < 0) throw new TypeError("killGraceMs must be a non-negative safe integer");
@@ -313,7 +329,7 @@ export async function executeLocalCli({
313
329
  promptFile = join(temporary, "prompt.txt");
314
330
  writeFileSync(promptFile, prompt, { encoding: "utf8", mode: 0o600, flag: "wx" });
315
331
  }
316
- const invocation = buildLocalCliInvocation(normalized, env, { prompt, cwd, promptFile });
332
+ const invocation = buildLocalCliInvocation(normalized, env, { prompt, cwd, promptFile, sessionId, resumeSessionId });
317
333
  return await new Promise((resolve) => {
318
334
  let child;
319
335
  let settled = false;
@@ -396,7 +412,7 @@ export async function executeLocalCli({
396
412
  finish(emptyResult("error", `${normalized.cli} exited ${terminationSignal ? `with signal ${terminationSignal}` : `with code ${code}`}${details ? `: ${details}` : ""}`));
397
413
  return;
398
414
  }
399
- finish(parseLocalCliResult(Buffer.concat(stdout).toString("utf8"), normalized, { cwd, env }));
415
+ finish({ ...parseLocalCliResult(Buffer.concat(stdout).toString("utf8"), normalized, { cwd, env }), ...(sessionId ? { sessionId } : {}) });
400
416
  });
401
417
  child.stdin.on("error", (error) => {
402
418
  // Early CLI failure often closes stdin first; close still supplies its stderr and exit code.
@@ -0,0 +1,6 @@
1
+ export type Responsibility = 'controller' | 'researcher' | 'planner' | 'reviewer' | 'frontend' | 'creative' | 'writer' | 'user';
2
+ export interface ModelRoute { backend?: string; cli?: string; provider?: string; model: string }
3
+ export interface NamedRequest { responsibility: Responsibility; timing: 'start' | 'completion'; excerpt: string; name: string; canceled?: boolean; issue?: 'unconfigured' | 'mapping-mismatch' }
4
+ export function recognizeNamedRequests(text: string, routes: Partial<Record<Responsibility, ModelRoute>>): NamedRequest[];
5
+ export function recognizeLeadSwitch(text: string, routes: Partial<Record<'planner' | 'reviewer', ModelRoute>>): { enabled: boolean; roles: string[]; issue?: string } | undefined;
6
+ export function parseLeadVerdict(text: string): "pass" | "changes-requested" | "invalid";
@@ -0,0 +1,93 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+ // Deliberately bounded grammar: an imperative delegation, never a bare role word.
13
+ export function recognizeNamedRequests(text , routes ) {
14
+ const clean = text.replace(/```[\s\S]*?```|`[^`]*`|“[^”]*”|‘[^’]*’|「[^」]*」|『[^』]*』|"[^"\n]*"|'[^'\n]*'/gu, " ").replace(/^\s*>.*$/gmu, " ");
15
+ const dynamicNames = Object.values(routes).flatMap(route => route ? [
16
+ route.backend === "local-cli" ? route.cli : route.provider,
17
+ /^[a-z]+/iu.exec(route.model)?.[0],
18
+ ].filter((name) => !!name && /^[a-z][a-z0-9_-]*$/iu.test(name)) : []);
19
+ const escape = (name ) => name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
20
+ const aliases = "Claude|克劳德|opus|sonnet|Gemini|反重力|agy|antigravity|GPT|Codex|Grok|planner|reviewer|researcher|creative|writer|frontend|规划职责|审查职责|研究职责|创意职责|写作职责|前端职责";
21
+ const names = [...new Set([...aliases.split("|"), ...dynamicNames])].sort((a, b) => b.length - a.length).map(escape).join("|");
22
+ const directive = new RegExp(`(?:让|交给|请|叫|找|用|have\\s+|let\\s+|ask\\s+|use\\s+)\\s*(${names})(?![a-z0-9_-])|交给\\s*(规划|调查|审查|创意|写作|前端)(?!职责)`, "iu");
23
+ const bare = new RegExp(`^\\s*(${names})(?![a-z0-9_-])\\s*(?:来|去)?\\s*(?:写|起草|改写|润色|翻译|定方向|审|规划|plan|write|review)`, "iu");
24
+ const result = [];
25
+ let creativeContext = false;
26
+ let delegated = false;
27
+ for (const clause of clean.split(/[,,;;。\n]|\bthen\b/iu)) {
28
+ if (/(?:以后|今后|from now on).*?(?:都|always)|^\s*(?:configure|配置|设置|映射)\b|(?:规划|审查|写作|前端|创意|调查)\s*用本机|推理档/iu.test(clause)) continue;
29
+ const match = directive.exec(clause) ?? (delegated ? bare.exec(clause) : null);
30
+ if (!match || /(?:了吗|过了|不错|怎么样|是否|要不要|能不能|吗|?|\?|last time|already)/iu.test(clause)) continue;
31
+ const prefix = clause.slice(0, match.index).trim();
32
+ // A bounded topic is allowed; an enclosing action ("写一下让…的测试") is not.
33
+ if (!/^(?:(?:先|再|然后|最后|做完|写完|改完|完成后|之后|交付前|麻烦|请你|你|不用|不要|别|无需|不必)\s*)*(?:文案|台词|对白|宣传语|商店页|规划|代码|界面)?\s*$/u.test(prefix)) continue;
34
+ delegated = true;
35
+ const name = match[1] ?? match[2] ;
36
+ const creativeObject = /文案|台词|对白|画面|美术|视觉|宣传|商店页|稿|copy|dialogue|prose/iu.test(clause);
37
+ let candidates = [];
38
+ if (/规划|计划|出方案|定方案|拆任务|\bplan\b/iu.test(clause)) candidates = ["planner"];
39
+ else if (/调查|调研|查资料|核实来源|\bresearch\b/iu.test(clause)) candidates = ["researcher"];
40
+ else if (/定方向|构思|起名|出点子/iu.test(clause)) { candidates = ["creative"]; creativeContext = true; }
41
+ else if (/界面|前端实现|做\s*UI|frontend/iu.test(clause)) candidates = ["frontend"];
42
+ else if (/(?:写(?!完|进|到|入|在)|起草|改写|润色|翻译|本地化|\bwrite\b)/iu.test(clause) && creativeObject) candidates = routes.writer ? ["writer", "creative"] : ["creative"];
43
+ else if (/审|验收|复核|检查|把关|评估|看看|\breview\b/iu.test(clause)) candidates = creativeObject || (creativeContext && !/验收|复核|检查/iu.test(clause)) ? ["creative"] : ["reviewer"];
44
+ if (!candidates.length && match[2]) candidates = [{ 规划: "planner", 调查: "researcher", 审查: "reviewer", 创意: "creative", 写作: "writer", 前端: "frontend" }[name] ];
45
+ if (!candidates.length) continue;
46
+ const explicitRole = { planner: "planner", reviewer: "reviewer", researcher: "researcher", creative: "creative", writer: "writer", frontend: "frontend", 规划职责: "planner", 审查职责: "reviewer", 研究职责: "researcher", 创意职责: "creative", 写作职责: "writer", 前端职责: "frontend", 规划: "planner", 调查: "researcher", 审查: "reviewer", 创意: "creative", 写作: "writer", 前端: "frontend" };
47
+ const role = explicitRole[name.toLowerCase()];
48
+ const matches = (route ) => {
49
+ if (!route) return false;
50
+ const value = `${route.backend === "local-cli" ? route.cli : route.provider} ${route.model}`;
51
+ if (/^(Claude|克劳德|opus|sonnet)$/iu.test(name)) return /claude|anthropic|opus|sonnet/iu.test(value);
52
+ if (/^(Gemini|反重力|agy|antigravity)$/iu.test(name)) return /antigravity|gemini/iu.test(value);
53
+ if (/^(GPT|Codex)$/iu.test(name)) return /openai|codex|gpt/iu.test(value);
54
+ if (/^Grok$/iu.test(name)) return route.backend === "local-cli" && route.cli === "grok";
55
+ return [route.backend === "local-cli" ? route.cli : route.provider, /^[a-z]+/iu.exec(route.model)?.[0]].some(value => value?.toLowerCase() === name.toLowerCase());
56
+ };
57
+ const responsibility = role ?? candidates.find(candidate => matches(routes[candidate])) ?? candidates[0] ;
58
+ const timing = /先(?:让|请|用|交给|叫|找|规划|审|验收|调研|调查|写|做|定)|\bfirst\b/iu.test(clause) ? "start" : /做完|写完|改完|完成后|之后|最后|交付前|\bafter\b|once done|when done|finally|before delivering/iu.test(clause) ? "completion" : responsibility === "reviewer" ? "completion" : "start";
59
+ const delegationPrefix = clause.slice(0, match.index + match[0].indexOf(name));
60
+ const canceled = /(?:不用|不要|别|无需|不必)\s*(?:再)?\s*(?:让|交给|请|叫|找|用)?\s*$|(?:do not|don't)\s+(?:let|have|ask|use)\s*$/iu.test(delegationPrefix);
61
+ const issue = !routes[responsibility] ? "unconfigured" : !role && !matches(routes[responsibility]) ? "mapping-mismatch" : undefined;
62
+ if (!result.some(item => item.responsibility === responsibility && item.timing === timing && item.canceled === canceled)) result.push({ responsibility, timing, excerpt: clause.trim(), name, ...(canceled ? { canceled: true } : {}), ...(issue ? { issue } : {}) });
63
+ }
64
+ return result;
65
+ }
66
+
67
+
68
+ export function recognizeLeadSwitch(text , routes ) {
69
+ const clean = text.replace(/```[\s\S]*?```|`[^`]*`|“[^”]*”|「[^」]*」|『[^』]*』|‘[^’]*’|"[^"\n]*"|'[^'\n]*'/gu, " ").replace(/^\s*>.*$/gmu, " ");
70
+ let result;
71
+ for (const raw of clean.split(/[,,;;。\n]/u)) {
72
+ const clause = raw.trim();
73
+ if (/吗|?|\?|是否|能不能|配置|设置|以后|默认|开关|测试/iu.test(clause)) continue;
74
+ if (/^(?:关闭主导模式|退出主导模式|恢复正常模式|disable lead mode)[!!]?$/iu.test(clause)) result = { enabled: false, roles: [] };
75
+ else {
76
+ const direct = /^(?:(?:这个|当前)会话\s*)?(?:开启|打开|启用|开)主导模式[!!]?$/u.test(clause) || /^enable lead mode[!!]?$/iu.test(clause);
77
+ const named = /^(?:这个|当前)会话由\s*(\S+)\s*主导[!!]?$/u.exec(clause);
78
+ if (!direct && !named) continue;
79
+ let roles = Object.keys(routes).filter(role => routes[role ]);
80
+ if (named) roles = roles.filter(role => !recognizeNamedRequests(`让 ${named[1]} ${role === "planner" ? "规划" : "验收"}`, routes).every(request => request.issue || request.responsibility !== role));
81
+ result = { enabled: roles.length > 0, roles, ...(roles.length ? {} : { issue: named ? "mapping-mismatch" : "缺少 planner、reviewer 插槽;请用 odai_routing_config 配置" }) };
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export function parseLeadVerdict(text) {
88
+ const normalize = (line) => line.trim().replace(/^[*_`"'“”‘’「」『』]+|[*_`"'“”‘’「」『』。.]+$/gu, "").trim();
89
+ const lines = text.split(/\r?\n/u).filter(line => line.trim());
90
+ const verdicts = [...text.matchAll(/\bVERDICT\s*[::]\s*[*_`"'“‘「『]*(pass|changes-requested)\b/giu)].map(match => match[1].toLowerCase());
91
+ if (new Set(verdicts).size > 1) return "invalid";
92
+ return /^VERDICT\s*[::]\s*(pass|changes-requested)$/iu.exec(normalize(lines.at(-1) ?? ""))?.[1]?.toLowerCase() ?? "invalid";
93
+ }