@nowcrew/daemon 0.6.66 → 0.6.67

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 (57) hide show
  1. package/dist/agent-ability/materializer.js +20 -1
  2. package/dist/agent-ability/resolver.js +7 -10
  3. package/dist/agent-ability/runtime.js +3 -0
  4. package/dist/automatic-install-target.js +15 -10
  5. package/dist/computer-cli.js +10 -1
  6. package/dist/computer-service.js +41 -1
  7. package/dist/config.js +44 -0
  8. package/dist/daemon-migration-controller.js +16 -18
  9. package/dist/execution-runner.js +3 -1
  10. package/dist/local-execution-prompt.js +14 -9
  11. package/dist/local-executor.js +56 -3
  12. package/dist/local-memory/index-budget.js +74 -0
  13. package/dist/local-memory/pending.js +111 -0
  14. package/dist/local-memory/runtime.js +87 -0
  15. package/dist/local-memory/types.js +1 -0
  16. package/dist/local-memory/write.js +448 -0
  17. package/dist/machine-info.js +3 -2
  18. package/dist/main.js +83 -0
  19. package/dist/managed-service-lifecycle.js +47 -2
  20. package/dist/managed-service-startup.js +2 -0
  21. package/dist/profile-layout.js +10 -4
  22. package/dist/project-skills/controller.js +2 -0
  23. package/dist/prompt.js +19 -1
  24. package/dist/runner.js +3 -0
  25. package/dist/runtimes/codex-app-server-runner.js +54 -14
  26. package/dist/runtimes/codex-command-safety.js +75 -0
  27. package/dist/runtimes/codex-contract.js +20 -5
  28. package/dist/runtimes/codex-turn-retry.js +69 -0
  29. package/dist/runtimes/codex.js +4 -0
  30. package/dist/serve.js +68 -3
  31. package/dist/storage-config.js +175 -0
  32. package/dist/storage-control.js +193 -0
  33. package/dist/storage-copy.js +609 -0
  34. package/dist/storage-credential-guard.js +21 -0
  35. package/dist/storage-doctor.js +228 -0
  36. package/dist/storage-identity.js +29 -0
  37. package/dist/storage-layout.js +143 -0
  38. package/dist/storage-manifest.js +855 -0
  39. package/dist/storage-migration-controller.js +417 -0
  40. package/dist/storage-migration-manifest.js +105 -0
  41. package/dist/storage-migration-policy.js +100 -0
  42. package/dist/storage-runtime-controller.js +339 -0
  43. package/dist/storage-runtime-readiness.js +184 -0
  44. package/dist/storage-runtime.js +39 -0
  45. package/dist/storage-safe-fs.js +302 -0
  46. package/dist/storage-serve-adapter.js +65 -0
  47. package/dist/storage-status-delivery.js +209 -0
  48. package/dist/storage-status-replay.js +125 -0
  49. package/dist/storage-target-confirmation.js +129 -0
  50. package/dist/storage-worker-command.js +27 -0
  51. package/dist/storage-worker-executor.js +384 -0
  52. package/dist/storage-worker-handoff.js +151 -0
  53. package/dist/storage-worker-operation.js +284 -0
  54. package/dist/storage-worker-status.js +232 -0
  55. package/dist/supervised-runtime.js +3 -0
  56. package/dist/workspace.js +24 -1
  57. package/package.json +1 -1
@@ -14,6 +14,7 @@ export async function assertManagedServiceStartup(input) {
14
14
  nodePath: input.nodePath,
15
15
  entryPath: input.entryPath,
16
16
  profileHome: input.daemonHome,
17
+ ...(input.environment === undefined ? {} : { environment: input.environment }),
17
18
  });
18
19
  const readRegistry = input.readManagedServices
19
20
  ?? (() => readManagedServiceRegistry({ userHome: input.userHome }));
@@ -42,6 +43,7 @@ export async function assertManagedServiceStartup(input) {
42
43
  nodePath: input.nodePath,
43
44
  entryPath: input.entryPath,
44
45
  profileHome: input.daemonHome,
46
+ ...(input.environment === undefined ? {} : { environment: input.environment }),
45
47
  legacyServiceId: true,
46
48
  });
47
49
  const legacyDescriptor = await readDescriptor(legacySpec);
@@ -2,6 +2,10 @@ import { posix, win32 } from "node:path";
2
2
  export const PROFILE_LAYOUT_VERSION = 1;
3
3
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
4
4
  const LOCAL_INSTANCE_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/u;
5
+ /** Accept the legacy `in` tag at the boundary, but never persist it in a new layout. */
6
+ export function normalizeProfileEnvironment(value) {
7
+ return value === "in" ? "production" : value;
8
+ }
5
9
  export function validateLocalInstanceName(value) {
6
10
  const normalized = value.trim().toLowerCase();
7
11
  if (!LOCAL_INSTANCE_NAME.test(normalized) || normalized.includes("..")) {
@@ -10,19 +14,21 @@ export function validateLocalInstanceName(value) {
10
14
  return normalized;
11
15
  }
12
16
  export function profileForLocalInstance(environment, localInstanceName) {
13
- const profile = `nw-${environment}-${validateLocalInstanceName(localInstanceName)}`;
17
+ const canonicalEnvironment = normalizeProfileEnvironment(environment);
18
+ const profile = `nw-${canonicalEnvironment}-${validateLocalInstanceName(localInstanceName)}`;
14
19
  if (!PROFILE_NAME.test(profile))
15
20
  throw new Error("derived daemon profile exceeds 48 characters");
16
21
  return profile;
17
22
  }
18
23
  export function profileLayout(input) {
24
+ const environment = normalizeProfileEnvironment(input.environment);
19
25
  const localInstanceName = validateLocalInstanceName(input.localInstanceName);
20
- const profile = profileForLocalInstance(input.environment, localInstanceName);
26
+ const profile = profileForLocalInstance(environment, localInstanceName);
21
27
  const paths = input.platform === "win32" ? win32 : posix;
22
- const base = `${input.environment}-${localInstanceName}`;
28
+ const base = `${environment}-${localInstanceName}`;
23
29
  return Object.freeze({
24
30
  layoutVersion: PROFILE_LAYOUT_VERSION,
25
- environment: input.environment,
31
+ environment,
26
32
  localInstanceName,
27
33
  profile,
28
34
  daemonHome: paths.join(input.userHome, ".crew", `daemon-${base}`),
@@ -183,6 +183,8 @@ export function createProjectSkillsController(deps) {
183
183
  const parsed = ProjectCommandSchema.safeParse(input);
184
184
  if (!parsed.success)
185
185
  return { ok: false, error: "invalid_project_command" };
186
+ if (deps.isAdmissionClosed?.() === true)
187
+ return { ok: false, error: "storage_migration_quiescing" };
186
188
  try {
187
189
  assertCurrentEpoch(epochToken);
188
190
  }
package/dist/prompt.js CHANGED
@@ -305,7 +305,7 @@ ${memoryTaskReadRule}
305
305
  - **跨任务协调看实时清单**:用 \`crew task list --mine\` 查你当前所有任务及其状态(谁在排队、谁在评审)。例:你一次只能测一个页面时,据此判断"正在测 #1、#2 排队",在 #2 的工作日志里标注"等 #1 完成"。
306
306
  - 需要更多背景时再 \`Read $CREW_HOME/notes/<topic>.md\`;不要一次把所有笔记读进上下文。
307
307
 
308
- ${communicationSection}
308
+ ${communicationSection}${ctx.collaborationToolsEnabled === true ? buildCollaborationToolsPrompt() : ""}
309
309
 
310
310
  CLI 成功时打印人类可读文本到 stdout;失败时 stderr 给出错误,并用**退出码**告诉你下一步:
311
311
  - \`4\` = 任务已被他人认领 / 不可认领 → 停手,别抢,转做别的。
@@ -385,3 +385,21 @@ export function buildOriginDecisionRetryPrompt(channelId, threadId) {
385
385
  "普通内部消息不算外部回复决策;--send-draft 也不是外部回复开关。",
386
386
  ].join("\n");
387
387
  }
388
+ /**
389
+ * Codex 原生 collaboration.* 工具的用法与边界(spec 2d;2026-08-28 对 Codex 0.149.1/0.150.1 实测)。
390
+ * 只在 daemon 开关打开且 runtime 为 codex 时注入;是本机 runtime 能力事实,不是产品策略。
391
+ * 契约来自实测,不编造 Codex 未声明的返回字段。
392
+ */
393
+ export function buildCollaborationToolsPrompt() {
394
+ return `
395
+
396
+ ## Codex 原生协作工具
397
+ 本机 Codex 已开启 \`collaboration.*\` 子 agent 工具。它们完全在 Codex 进程内运行:子 agent 的工作对 NowWork 的频道、线程、任务、execution 记录和按 agent 的 token 统计**都不可见**,token 并入你本轮的用量。只用于你自己会汇总的内部交叉核对、并行调研;绝不用于人类或其它 NowWork 成员需要观察、审计或接手的事。
398
+ - **\`collaboration.spawn_agent\`** —— 必填 \`task_name\`(仅小写字母/数字/下划线)、\`message\`;可选 \`fork_turns\`(\`"none"\`|\`"all"\`|\`"3"\` 这样的轮数,默认 \`"all"\`)、\`model\`、\`reasoning_effort\`。立即启动独立 turn,你不阻塞;子 agent 跑完后 idle 而非销毁。新 agent 用 **canonical 任务名路径**引用,如 \`/root/api_review\`。含 root 最多 4 个活跃 agent。
399
+ - **\`collaboration.followup_task\`** —— 必填 \`target\`(canonical 路径,不是 UUID)、\`message\`。异步投递并把 idle 的目标唤醒进新 turn,可重复。
400
+ - **\`collaboration.send_message\`** —— 必填 \`target\`、\`message\`。只入队,**不**启动 turn;要触发工作用 followup_task。
401
+ - **\`collaboration.list_agents\`** —— 可选 \`path_prefix\`。只读,列出 live agents。
402
+ - **\`collaboration.wait_agent\`** —— 可选 \`timeout_ms\`(10000–3600000,默认 30000)。阻塞你直到任一子 agent 有更新、有新输入或超时。
403
+ - **没有任何返回值有保证的 schema**:不要去解析返回值里的 \`agent_id\`/\`id\`/\`accepted\` 之类字段;用 \`list_agents\` 和 \`wait_agent\` 获取状态。
404
+ - **不要调用 \`collaboration.interrupt_agent\`**:它会把你自己这轮 turn 一起中断(2026-08-10 线上事故),中断后本轮结果全部丢失且不会重跑。让子 agent 自然跑完或直接忽略其结果。`;
405
+ }
package/dist/runner.js CHANGED
@@ -58,6 +58,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
58
58
  ...(input.wakeOrigin ? { wakeOrigin: input.wakeOrigin } : {}),
59
59
  memory: capMemoryForInject(workspace.memory),
60
60
  ...(resuming ? {} : { workLog: capWorkLogForInject(workspace.workLog) }),
61
+ collaborationToolsEnabled: config.codexCollaborationTools && runtime === "codex",
61
62
  }),
62
63
  wakePrompt: ({ resuming, rotatedForBudget, nearBudget }) => {
63
64
  const distillHint = nearBudget
@@ -74,6 +75,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
74
75
  name: runtime,
75
76
  ...(providerConfig.model ? { model: providerConfig.model } : {}),
76
77
  ...(reasoning.success ? { reasoning: reasoning.data } : {}),
78
+ codexCollaborationTools: config.codexCollaborationTools,
77
79
  },
78
80
  effectivePermission: config.dangerous ? "full_access" : "workspace_write",
79
81
  captureFinal: true,
@@ -105,6 +107,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
105
107
  softTokens: config.sessionSoftTokens,
106
108
  maxTurns: config.sessionMaxTurns,
107
109
  },
110
+ localMemoryV2Enabled: config.localMemoryV2Handles?.has(input.handle) === true,
108
111
  }, {
109
112
  onActivity,
110
113
  onConsole,
@@ -6,7 +6,8 @@ import { pathToFileURL } from "node:url";
6
6
  import spawn from "cross-spawn";
7
7
  import { z } from "zod";
8
8
  import { signalSupervisorTree } from "../execution-supervisor.js";
9
- import { assertCodexNativeWorkspaceVersion, assertCodexProjectWorkspaceResponse, assertCodexSkillsListResponse, boundedCodexPlan, codexProjectWorkspaceUnsupported, } from "./codex-contract.js";
9
+ import { assertCodexCollaborationVersion, assertCodexNativeWorkspaceVersion, assertCodexProjectWorkspaceResponse, assertCodexSkillsListResponse, boundedCodexPlan, codexProjectWorkspaceUnsupported, } from "./codex-contract.js";
10
+ import { decideTurnRetry, itemMakesTurnUnsafe } from "./codex-turn-retry.js";
10
11
  import { startFirstProgressWatchdog } from "./progress-watchdog.js";
11
12
  const RunnerInputSchema = z.object({
12
13
  systemPrompt: z.string().min(1),
@@ -20,6 +21,7 @@ const RunnerInputSchema = z.object({
20
21
  cwd: z.string().min(1).optional(),
21
22
  workspaceRoots: z.array(z.string().min(1)).optional(),
22
23
  skillRoots: z.array(z.string().min(1)).optional(),
24
+ codexCollaborationTools: z.boolean().optional(),
23
25
  resume: z.boolean(),
24
26
  }).strict();
25
27
  const RPC_TIMEOUT_MS = 30_000;
@@ -37,12 +39,17 @@ const MAX_TRANSIENT_TURN_RETRIES = 2;
37
39
  const TRANSIENT_TURN_RETRY_DELAY_MS = 15_000;
38
40
  const TRANSIENT_TURN_RETRY_BACKOFF_FACTOR = 3;
39
41
  const PROCESS_TREE_STOP_TIMEOUT_MS = 1_000;
42
+ /** 进程级:一旦某个 collabAgentToolCall 的 item/completed 没有对应 item/started,本执行后续永不重试(spec R4-3)。 */
43
+ let collabStartedMissing = false;
40
44
  const USER_INPUT_UNAVAILABLE_MESSAGE = "NowWork cannot collect interactive user input in this headless run; ask the user in the final response instead of assuming an answer.";
41
- export function codexAppServerArgs(projectRootMarkers) {
45
+ export function codexAppServerArgs(input) {
42
46
  return [
43
- ...(projectRootMarkers === undefined
47
+ ...(input.projectRootMarkers === undefined
44
48
  ? []
45
- : ["-c", `project_root_markers=${JSON.stringify(projectRootMarkers)}`]),
49
+ : ["-c", `project_root_markers=${JSON.stringify(input.projectRootMarkers)}`]),
50
+ // multi_agent 按开关;multi_agent_v2 无条件关,钉死已验证的 v1 契约(spec 2b)。
51
+ "-c", `features.multi_agent=${input.collaborationTools === true}`,
52
+ "-c", "features.multi_agent_v2=false",
46
53
  "app-server", "--listen", "stdio://",
47
54
  ];
48
55
  }
@@ -53,7 +60,8 @@ const TRANSIENT_TURN_ERROR_PATTERNS = [
53
60
  /too many requests/i,
54
61
  ];
55
62
  /** 模型侧瞬态失败(网关过载/限流)→ 换个时间整轮重试大概率成功。
56
- * interrupted 不算:那是取消信号或 codex collab 连带中断,重跑语义不明确。 */
63
+ * interrupted 单独归为 collab_interrupted:2026-08-10 实锤是模型自己调 collaboration.interrupt_agent
64
+ * 连带把父 turn 置为 interrupted。两类原因都只在本 turn 无白名单外工具活动时重试(codex-turn-retry.ts)。 */
57
65
  export function isTransientTurnFailure(detail) {
58
66
  return TRANSIENT_TURN_ERROR_PATTERNS.some((pattern) => pattern.test(detail));
59
67
  }
@@ -413,7 +421,10 @@ async function stopChildTree(child) {
413
421
  }
414
422
  async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs) {
415
423
  const attemptStartedAt = Date.now();
416
- const child = spawn(bin, codexAppServerArgs(input.projectRootMarkers), {
424
+ const child = spawn(bin, codexAppServerArgs({
425
+ ...(input.projectRootMarkers === undefined ? {} : { projectRootMarkers: input.projectRootMarkers }),
426
+ ...(input.codexCollaborationTools === undefined ? {} : { collaborationTools: input.codexCollaborationTools }),
427
+ }), {
417
428
  cwd: process.cwd(),
418
429
  env: process.env,
419
430
  stdio: ["pipe", "pipe", "pipe"],
@@ -434,6 +445,8 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
434
445
  let threadId = null;
435
446
  let announcedThreadId = null;
436
447
  let turnId = null;
448
+ let turnHasUnsafeSideEffect = false;
449
+ const startedItemIds = new Set();
437
450
  let lastUsage;
438
451
  let completionResolve;
439
452
  let completionReject;
@@ -452,6 +465,20 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
452
465
  || method === "item/agentMessage/delta" || method === "item/completed") {
453
466
  firstProgress.observe();
454
467
  }
468
+ if (method === "item/started" || method === "item/completed") {
469
+ const item = params !== null && typeof params === "object"
470
+ ? params.item
471
+ : undefined;
472
+ if (method === "item/started" && typeof item?.id === "string")
473
+ startedItemIds.add(item.id);
474
+ if (method === "item/completed" && item?.type === "collabAgentToolCall"
475
+ && (typeof item.id !== "string" || !startedItemIds.has(item.id)) && !collabStartedMissing) {
476
+ collabStartedMissing = true;
477
+ process.stderr.write("[codex-app-server] collab_item_started_missing\n");
478
+ }
479
+ if (itemMakesTurnUnsafe(item))
480
+ turnHasUnsafeSideEffect = true;
481
+ }
455
482
  for (const event of mapCodexNotification(method, params)) {
456
483
  const eventThreadId = typeof event.thread_id === "string" ? event.thread_id : null;
457
484
  if (event.type === "thread.started" && eventThreadId === announcedThreadId)
@@ -496,6 +523,8 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
496
523
  if (input.workspaceRoots !== undefined || input.skillRoots !== undefined) {
497
524
  assertCodexNativeWorkspaceVersion(initialized);
498
525
  }
526
+ if (input.codexCollaborationTools === true)
527
+ assertCodexCollaborationVersion(initialized);
499
528
  logStage(activeStage, "ok", attempt, stageStartedAt);
500
529
  rpc.notify("initialized");
501
530
  activeStage = input.resume ? "thread_resume" : "thread_start";
@@ -580,15 +609,19 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
580
609
  }),
581
610
  });
582
611
  if (completed.turn?.status === "completed")
583
- return { code: 0, initializeTimedOut: false };
612
+ return { code: 0, initializeTimedOut: false, unsafeToRetry: false };
584
613
  const detail = completed.turn?.error?.message ?? `turn status ${completed.turn?.status ?? "unknown"}`;
585
614
  process.stderr.write(sanitizeCodexDiagnostic(`Codex turn failed: ${detail.slice(0, ERROR_MESSAGE_CAP)}\n`, secrets));
615
+ const status = completed.turn?.status;
616
+ const retryReason = cancelling ? undefined
617
+ : status === "interrupted" ? "collab_interrupted"
618
+ : status === "failed" && isTransientTurnFailure(detail) ? "transient_turn_failure"
619
+ : undefined;
586
620
  return {
587
- code: completed.turn?.status === "interrupted" ? 130 : 1,
621
+ code: status === "interrupted" ? 130 : 1,
588
622
  initializeTimedOut: false,
589
- transientTurnFailure: !cancelling
590
- && completed.turn?.status === "failed"
591
- && isTransientTurnFailure(detail),
623
+ ...(retryReason === undefined ? {} : { retryReason }),
624
+ unsafeToRetry: turnHasUnsafeSideEffect || collabStartedMissing,
592
625
  };
593
626
  }
594
627
  catch (error) {
@@ -601,7 +634,7 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
601
634
  logStage(activeStage, "error", attempt, stageStartedAt);
602
635
  }
603
636
  process.stderr.write(`Codex app-server execution failed: ${safeErrorMessage(error, secrets)}\n`);
604
- return { code: 1, initializeTimedOut };
637
+ return { code: 1, initializeTimedOut, unsafeToRetry: false };
605
638
  }
606
639
  finally {
607
640
  firstProgress.stop();
@@ -633,13 +666,20 @@ export async function runCodexAppServer(bin, options = {}) {
633
666
  process.stderr.write(`[codex-app-server] stage=retry status=start attempt=${attempt + 1} elapsed_ms=0 reason=initialize_timeout\n`);
634
667
  continue;
635
668
  }
636
- if (result.transientTurnFailure && turnRetries < MAX_TRANSIENT_TURN_RETRIES) {
669
+ const decision = decideTurnRetry(result, turnRetries, MAX_TRANSIENT_TURN_RETRIES);
670
+ if (decision.kind === "retry") {
637
671
  const delayMs = turnRetryDelayMs * TRANSIENT_TURN_RETRY_BACKOFF_FACTOR ** turnRetries;
638
672
  turnRetries += 1;
639
- process.stderr.write(`[codex-app-server] stage=retry status=start attempt=${attempt + 1} elapsed_ms=0 reason=transient_turn_failure\n`);
673
+ process.stderr.write(`[codex-app-server] stage=retry status=start attempt=${attempt + 1} elapsed_ms=0 reason=${decision.reason}\n`);
640
674
  await new Promise((resolve) => setTimeout(resolve, delayMs));
641
675
  continue;
642
676
  }
677
+ if (decision.kind === "skipped") {
678
+ process.stderr.write(`[codex-app-server] stage=retry status=skipped attempt=${attempt} reason=${decision.reason}_unsafe\n`);
679
+ }
680
+ else if (decision.kind === "exhausted") {
681
+ process.stderr.write(`[codex-app-server] stage=retry status=exhausted attempt=${attempt} reason=${decision.reason}\n`);
682
+ }
643
683
  return result.code;
644
684
  }
645
685
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Codex app-server 重试门控用的「只读 crew 命令」判定(spec Part 1,R3-1/R4-1)。
3
+ *
4
+ * 白名单 + 封闭文法,不是黑名单:任何解析不确定都返回 false。只回答"这是不是白名单里某条
5
+ * crew 只读命令的一次干净调用",不做"读/写"语义判断。任何非 crew 命令(sed/rg/git/python…)
6
+ * 一律 false——白名单之外的一切都视为不可安全重试。零 IO,可独立单测。
7
+ */
8
+ /** 封闭列表,由 cli/src/run.ts 命令面反推;新增 CLI 只读命令时同步维护。漏加只会更保守。 */
9
+ export const READ_ONLY_CREW_COMMANDS = new Set([
10
+ "whoami",
11
+ "server info",
12
+ "message read",
13
+ "message check",
14
+ "thread read",
15
+ "task list",
16
+ "schedule list",
17
+ "schedule runs",
18
+ "reminder list",
19
+ "reminder log",
20
+ "resolve",
21
+ "channel members",
22
+ "integration env",
23
+ "integration list",
24
+ ]);
25
+ const SINGLE_VERB = new Set(["whoami", "resolve"]);
26
+ // 一层 shell 包装:bash/sh/zsh -c / -lc / -cl,后跟被单引号或双引号整体包裹的正文。
27
+ const WRAPPER = /^(?:\/usr\/bin\/|\/bin\/)?(?:bash|sh|zsh)\s+-(?:l?c|cl)\s+(.+)$/su;
28
+ // 整串禁字符:出现任一即拒绝(含单个 &、引号、$、括号、换行、制表、反斜杠)。
29
+ const FORBIDDEN = /[&;|<>$`(){}\\'"\n\r\t]/u;
30
+ const OPTION_TOKEN = /^--?[A-Za-z][A-Za-z0-9-]*(?:=[A-Za-z0-9._:/@#%+-]+)?$/u;
31
+ const VALUE_TOKEN = /^[A-Za-z0-9._:/@#%+][A-Za-z0-9._:/@#%+-]+$/u;
32
+ function unwrap(command) {
33
+ const match = WRAPPER.exec(command);
34
+ if (match === null)
35
+ return command;
36
+ const quoted = match[1] ?? "";
37
+ const quote = quoted[0];
38
+ if (quote !== "'" && quote !== "\"")
39
+ return null;
40
+ if (quoted.length < 2 || quoted[quoted.length - 1] !== quote)
41
+ return null;
42
+ const body = quoted.slice(1, -1);
43
+ if (body.includes(quote))
44
+ return null;
45
+ if (quote === "\"" && /[$\\]/u.test(body))
46
+ return null;
47
+ return body;
48
+ }
49
+ export function isReadOnlyCrewCommand(command) {
50
+ const body = unwrap(command);
51
+ if (body === null)
52
+ return false;
53
+ if (FORBIDDEN.test(body))
54
+ return false;
55
+ const trimmed = body.trim();
56
+ if (trimmed === "" || /\s{2,}/u.test(trimmed))
57
+ return false;
58
+ const tokens = trimmed.split(" ");
59
+ if (tokens[0] !== "crew")
60
+ return false;
61
+ const verb = tokens[1];
62
+ if (verb === undefined)
63
+ return false;
64
+ let consumed;
65
+ if (SINGLE_VERB.has(verb)) {
66
+ consumed = 2;
67
+ }
68
+ else {
69
+ const pair = `${verb} ${tokens[2] ?? ""}`;
70
+ if (!READ_ONLY_CREW_COMMANDS.has(pair))
71
+ return false;
72
+ consumed = 3;
73
+ }
74
+ return tokens.slice(consumed).every((token) => OPTION_TOKEN.test(token) || VALUE_TOKEN.test(token));
75
+ }
@@ -1,6 +1,7 @@
1
- import { CODEX_NATIVE_WORKSPACE_MIN_VERSION } from "./codex.js";
1
+ import { CODEX_COLLABORATION_MIN_VERSION, CODEX_NATIVE_WORKSPACE_MIN_VERSION } from "./codex.js";
2
2
  import { z } from "zod";
3
3
  export const CODEX_PROJECT_WORKSPACE_UNSUPPORTED = "codex_project_workspace_unsupported";
4
+ export const CODEX_COLLABORATION_UNSUPPORTED = "codex_collaboration_unsupported";
4
5
  export const CODEX_PLAN_ENTRY_LIMIT = 16;
5
6
  // 16 steps remain inside the server's 4,000 UTF-16-unit plan payload cap even
6
7
  // when every retained code point is a surrogate pair (for example, emoji).
@@ -51,18 +52,32 @@ const versionTuple = (version) => {
51
52
  const versionAtLeast = (actual, minimum) => actual[0] > minimum[0]
52
53
  || (actual[0] === minimum[0] && (actual[1] > minimum[1]
53
54
  || (actual[1] === minimum[1] && actual[2] >= minimum[2])));
54
- /** Native roots require the app-server version returned by initialize, not a separate shell probe. */
55
- export function assertCodexNativeWorkspaceVersion(response) {
55
+ function initializeVersion(response) {
56
56
  const parsed = NativeInitializeResponseSchema.safeParse(response);
57
57
  if (!parsed.success)
58
- throw codexProjectWorkspaceUnsupported();
58
+ return null;
59
59
  const match = /(?:^|[/\s])(\d+\.\d+\.\d+)(?=$|[\s(])/u.exec(parsed.data.userAgent);
60
- const actual = match === null ? null : versionTuple(match[1] ?? "");
60
+ return match === null ? null : versionTuple(match[1] ?? "");
61
+ }
62
+ /** Native roots require the app-server version returned by initialize, not a separate shell probe. */
63
+ export function assertCodexNativeWorkspaceVersion(response) {
64
+ const actual = initializeVersion(response);
61
65
  const minimum = versionTuple(CODEX_NATIVE_WORKSPACE_MIN_VERSION);
62
66
  if (actual === null || minimum === null || !versionAtLeast(actual, minimum)) {
63
67
  throw codexProjectWorkspaceUnsupported();
64
68
  }
65
69
  }
70
+ /** Collaboration tools are exposed only on an app-server whose contract was verified (spec 2b). */
71
+ export function assertCodexCollaborationVersion(response) {
72
+ const actual = initializeVersion(response);
73
+ const minimum = versionTuple(CODEX_COLLABORATION_MIN_VERSION);
74
+ if (actual === null || minimum === null || !versionAtLeast(actual, minimum)) {
75
+ throw codexCollaborationUnsupported();
76
+ }
77
+ }
78
+ export function codexCollaborationUnsupported() {
79
+ return new Error(CODEX_COLLABORATION_UNSUPPORTED);
80
+ }
66
81
  const PLAN_STATUSES = new Set(["pending", "inProgress", "completed"]);
67
82
  const clipCharacters = (value, limit) => [...value.trim().replace(/\s+/gu, " ")].slice(0, limit).join("");
68
83
  /** Treat plan updates as bounded user-visible data; any malformed retained step rejects the event. */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Codex app-server turn 级重试决策(spec Part 1)。
3
+ *
4
+ * attempt 只上报两个正交事实:retryReason(为什么值得重试)与 unsafeToRetry(本 turn 是否已有
5
+ * 白名单之外的工具活动)。外层循环通过 decideTurnRetry 单一出口判断,所有原因共用一个预算。
6
+ * 2026-08-10 事故根因:模型自己调 collaboration.interrupt_agent,Codex 把父 turn 也置为
7
+ * interrupted;旧代码直接 exit 130。同时旧的网关过载重试是无条件的,会重放 crew message send。
8
+ */
9
+ import { isReadOnlyCrewCommand } from "./codex-command-safety.js";
10
+ /**
11
+ * Codex app-server v2 `codex app-server generate-json-schema` ThreadItem 枚举的全部 18 种类型,
12
+ * 逐一裁定安全性(2026-08-28 最终评审 F1:实测真实 app-server 0.150.1 发现 userMessage 缺失导致
13
+ * 所有 turn 被判不安全,回归影响过载重试与 interrupted 重试两条路径)。新增协议类型时,必须在此
14
+ * 表格里显式落座,不允许静默归类。
15
+ *
16
+ * safe(协议/叙述类,无副作用,共 9 种):
17
+ * - userMessage Codex 对我们自己发送的 prompt 的回显,每个 turn 必有
18
+ * - hookPrompt hook 触发的系统提示回显
19
+ * - agentMessage 模型最终回复文本
20
+ * - reasoning 模型推理过程(不透出给下游)
21
+ * - plan 模型计划更新
22
+ * - contextCompaction 上下文压缩通知
23
+ * - sleep 模型主动等待
24
+ * - enteredReviewMode 进入 review 模式通知
25
+ * - exitedReviewMode 退出 review 模式通知
26
+ * conditional(1 种,视具体内容而定):
27
+ * - commandExecution 仅当 isReadOnlyCrewCommand(command) 为 true 时安全
28
+ * unsafe(有副作用或无法判断,共 8 种 + 未知类型):
29
+ * - fileChange 写文件
30
+ * - mcpToolCall 调用外部 MCP 工具
31
+ * - dynamicToolCall 调用动态工具
32
+ * - collabAgentToolCall 协作工具(spawnAgent/interrupt_agent 等)
33
+ * - subAgentActivity 子 agent 活动
34
+ * - webSearch 联网搜索
35
+ * - imageView 查看图片
36
+ * - imageGeneration 生成图片
37
+ * - (任何未知/缺失 type) 一律视为不安全
38
+ */
39
+ const NARRATIVE_ITEM_TYPES = new Set([
40
+ "userMessage",
41
+ "hookPrompt",
42
+ "agentMessage",
43
+ "reasoning",
44
+ "plan",
45
+ "contextCompaction",
46
+ "sleep",
47
+ "enteredReviewMode",
48
+ "exitedReviewMode",
49
+ ]);
50
+ /** 白名单判定:只有叙述类 item 和白名单内的 crew 只读命令是安全的;其它一切(含未知类型)不安全。 */
51
+ export function itemMakesTurnUnsafe(item) {
52
+ if (item?.type === undefined)
53
+ return true;
54
+ if (NARRATIVE_ITEM_TYPES.has(item.type))
55
+ return false;
56
+ if (item.type === "commandExecution") {
57
+ return typeof item.command !== "string" || !isReadOnlyCrewCommand(item.command);
58
+ }
59
+ return true;
60
+ }
61
+ export function decideTurnRetry(result, turnRetries, maxRetries) {
62
+ if (result.retryReason === undefined)
63
+ return { kind: "return" };
64
+ if (result.unsafeToRetry)
65
+ return { kind: "skipped", reason: result.retryReason };
66
+ if (turnRetries < maxRetries)
67
+ return { kind: "retry", reason: result.retryReason };
68
+ return { kind: "exhausted", reason: result.retryReason };
69
+ }
@@ -5,6 +5,8 @@
5
5
  import spawn from "cross-spawn";
6
6
  /** Minimum CLI whose real app-server contract supports NowCrew native project workspaces. */
7
7
  export const CODEX_NATIVE_WORKSPACE_MIN_VERSION = "0.148.0";
8
+ /** Minimum CLI whose `multi_agent` collaboration tool contract was empirically verified (2026-08-28). */
9
+ export const CODEX_COLLABORATION_MIN_VERSION = "0.149.1";
8
10
  // Codex CLI 原生 model_reasoning_effort 档位(codex 0.135.0 实测:非法值时 config 解析报错枚举这六档)。
9
11
  // 注意:codex 对非法值是硬失败(进程直接退出),所以必须白名单过滤;白名单外(含 "default"、
10
12
  // claude 专属的 "max")回落 CODEX_DEFAULT_EFFORT。
@@ -22,6 +24,8 @@ export function buildCodexArgs(input) {
22
24
  ? input.reasoning
23
25
  : CODEX_DEFAULT_EFFORT;
24
26
  args.push("-c", `model_reasoning_effort=${effort}`);
27
+ // 纵深防御:exec 启动器没有 initialize 握手、无法断言版本,协作工具恒关(spec 2b)。
28
+ args.push("-c", "features.multi_agent=false", "-c", "features.multi_agent_v2=false");
25
29
  if (input.projectRootMarkers) {
26
30
  args.push("-c", `project_root_markers=${JSON.stringify(input.projectRootMarkers)}`);
27
31
  }