@nowcrew/daemon 0.6.88-beta.2 → 0.6.88-beta.21

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 (81) hide show
  1. package/README.md +43 -13
  2. package/dist/agent-memory/policy.js +3 -1
  3. package/dist/computer-profile.js +9 -4
  4. package/dist/config.js +0 -9
  5. package/dist/daemon-update-eligibility.js +1 -1
  6. package/dist/execution-journal.js +9 -6
  7. package/dist/execution-protocol.js +2 -1
  8. package/dist/execution-runner.js +5 -4
  9. package/dist/list-models.js +2 -0
  10. package/dist/local-execution-lease.js +22 -0
  11. package/dist/local-executor.js +58 -45
  12. package/dist/local-memory/candidate.js +43 -0
  13. package/dist/local-memory/index-budget.js +8 -0
  14. package/dist/local-memory/inventory-cli.js +61 -0
  15. package/dist/local-memory/inventory-preview.js +154 -0
  16. package/dist/local-memory/inventory-source.js +159 -0
  17. package/dist/local-memory/pending-match.js +29 -0
  18. package/dist/local-memory/pending-query.js +100 -0
  19. package/dist/local-memory/pending-schema.js +29 -0
  20. package/dist/local-memory/pending-source.js +126 -0
  21. package/dist/local-memory/pending.js +34 -84
  22. package/dist/local-memory/prompt.js +14 -11
  23. package/dist/local-memory/read-cli.js +97 -12
  24. package/dist/local-memory/runtime.js +24 -17
  25. package/dist/local-memory/work-log-execution.js +31 -0
  26. package/dist/local-memory/work-log.js +58 -0
  27. package/dist/local-memory/write.js +27 -58
  28. package/dist/machine-info.js +54 -17
  29. package/dist/main.js +4 -69
  30. package/dist/native-thread-context.js +59 -0
  31. package/dist/pre-admission-rejection.js +64 -0
  32. package/dist/project-skills/execution-adapter.js +3 -2
  33. package/dist/project-skills/runtime-launch.js +14 -0
  34. package/dist/prompt.js +15 -15
  35. package/dist/provider-env.js +3 -6
  36. package/dist/runner.js +14 -16
  37. package/dist/runtime-probe.js +34 -5
  38. package/dist/runtimes/acp-probe-transport.js +69 -0
  39. package/dist/runtimes/claude-session-start-hook.js +24 -0
  40. package/dist/runtimes/claude-thread-runner.js +266 -0
  41. package/dist/runtimes/claude.js +23 -4
  42. package/dist/runtimes/codex-app-server-runner.js +43 -4
  43. package/dist/runtimes/deepseek-harness-profile.js +92 -0
  44. package/dist/runtimes/deepseek-harness-runner.js +224 -0
  45. package/dist/runtimes/deepseek-harness.js +64 -11
  46. package/dist/runtimes/harness-probe-retry.js +42 -0
  47. package/dist/runtimes/kimi-acp-runner.js +63 -10
  48. package/dist/runtimes/opencode-runner.js +7 -1
  49. package/dist/serve.js +59 -101
  50. package/dist/session.js +4 -0
  51. package/dist/storage-identity.js +0 -3
  52. package/dist/storage-serve-adapter.js +6 -72
  53. package/dist/supervised-runtime.js +28 -18
  54. package/dist/thread-input-delivery.js +217 -0
  55. package/package.json +2 -3
  56. package/dist/codex-home-launch.js +0 -7
  57. package/dist/codex-home-mode-controller.js +0 -68
  58. package/dist/daemon-migration-controller.js +0 -211
  59. package/dist/daemon-migration-wiring.js +0 -22
  60. package/dist/runtimes/codex-home-migration-cli.js +0 -26
  61. package/dist/runtimes/codex-home-migration.js +0 -112
  62. package/dist/runtimes/codex-home.js +0 -236
  63. package/dist/storage-control.js +0 -212
  64. package/dist/storage-copy.js +0 -628
  65. package/dist/storage-credential-guard.js +0 -21
  66. package/dist/storage-doctor.js +0 -228
  67. package/dist/storage-manifest.js +0 -1027
  68. package/dist/storage-migration-controller.js +0 -440
  69. package/dist/storage-migration-manifest.js +0 -105
  70. package/dist/storage-migration-policy.js +0 -100
  71. package/dist/storage-runtime-controller.js +0 -381
  72. package/dist/storage-runtime-readiness.js +0 -240
  73. package/dist/storage-safe-fs.js +0 -302
  74. package/dist/storage-status-delivery.js +0 -224
  75. package/dist/storage-status-replay.js +0 -140
  76. package/dist/storage-target-confirmation.js +0 -129
  77. package/dist/storage-worker-command.js +0 -37
  78. package/dist/storage-worker-executor.js +0 -449
  79. package/dist/storage-worker-handoff.js +0 -151
  80. package/dist/storage-worker-operation.js +0 -294
  81. package/dist/storage-worker-status.js +0 -259
package/README.md CHANGED
@@ -3,6 +3,21 @@
3
3
  The daemon is NowWork's local execution adapter. It holds a machine credential, keeps one control-plane
4
4
  WebSocket open, and launches coding runtimes on that machine. Product policy remains on the server.
5
5
 
6
+ ## 已完成搬迁后的存储运行
7
+
8
+ NAS / `NOWWORK_HOME` 搬迁和旧 daemon 布局标准化入口已退役。daemon 不再提供内部
9
+ `storage-migration-worker` / `storage-migration-recover` 命令,不处理 `daemon:migrate`、
10
+ `daemon:storage-doctor`、`daemon:storage-migrate` 或迁移确认消息,也不再声明
11
+ `storage_migration_v1` / `storage_migration_status_v1` / `daemon_layout_migration_v1` 能力。
12
+
13
+ 现有 `installations.json` 即使仍有历史 `storageMigrationId`,也按当前 installation 解析
14
+ Agent、仓库和报告目录;启动、hello 和身份绑定不再读取迁移 receipt、outbox 或备份。
15
+ hello 继续报告布局、环境、Workspace、路径指纹与 ownership,但不再发送迁移 ID。
16
+ 每次连接仍须完成 Server 身份绑定后才能执行任务;Workspace 或 Machine 身份不匹配时拒绝执行。
17
+
18
+ 旧布局读取兼容、日常安装/升级、NAS 所有权校验、执行 journal 与 ACK/replay 继续保留。
19
+ 退役代码不会删除历史安装字段、迁移记录、receipt、备份或旧数据目录;这些数据的保留与清理另行处理。
20
+
6
21
  ## Run
7
22
 
8
23
  For a temporary foreground connection, run:
@@ -242,26 +257,26 @@ The stable adapters keep prompts out of provider argv wherever the provider prot
242
257
 
243
258
  | Runtime | Transport | System/wake input | Native resume |
244
259
  | --- | --- | --- | --- |
245
- | Claude | stream-json CLI | daemon-owned system prompt file | CLI session id |
246
- | Codex | app-server JSON-RPC over stdio | `developerInstructions` + turn input | `thread/resume` |
247
- | Kimi | ACP over stdio | `session/prompt` | `session/resume` (`session/load` compatibility fallback) |
248
- | Hermes | ACP over stdio | `session/prompt` | `session/resume` (`session/load` compatibility fallback) |
249
- | OpenCode | `run --format json` | stdin | `--session` |
260
+ | Claude | stream-json CLI | daemon-owned system prompt file + user input | CLI session id; thread-bound: matching native SessionStart proof + SDK initialization |
261
+ | Codex | app-server JSON-RPC over stdio | `developerInstructions` + turn input | `thread/resume`; thread-bound: returned id must match |
262
+ | Kimi | ACP over stdio | `session/prompt` | unbound: `session/resume` (`session/load` fallback); thread-bound: cold |
263
+ | Hermes | ACP over stdio | `session/prompt` | unbound: `session/resume` (`session/load` fallback); thread-bound: cold |
264
+ | OpenCode | `run --format json` | stdin | unbound: `--session`; thread-bound: cold |
250
265
  | DeepSeek Harness | ACP over stdio | `session/prompt` | none (fresh session per execution) |
251
266
 
252
267
  Kimi ACP currently requires a Kimi account even when the CLI has a working custom provider. Only for
253
268
  the explicit `Authentication required` response, the adapter retries once through Kimi's stream-json
254
- CLI and carries the same native session id; unrelated ACP failures stay failures. The CLI fallback is
269
+ CLI; only unbound executions can carry a saved native session id. Unrelated ACP failures stay failures. The CLI fallback is
255
270
  subject to the Windows UTF-16 argv guard, while protocol-v1 itself remains disabled on Windows until
256
271
  durable Job Object ownership is available.
257
272
 
258
273
  Hermes uses only ACP capabilities advertised by the installed CLI. Model and effort choices come from
259
274
  a short-lived discovery session; credentials, provider profiles, plugins, and `HERMES_HOME` remain
260
- machine-local. A missing resumed ACP session is retried once as a fresh session.
275
+ machine-local. For unbound executions, a missing resumed ACP session is retried once as a fresh session.
261
276
 
262
277
  OpenCode receives the prompt through stdin and runs with both cwd and `PWD` anchored to the Agent
263
278
  workspace. Models and variants come from `opencode models --verbose` (with the plain catalog as a
264
- compatibility fallback). A missing resumed session is retried once without `--session`; malformed,
279
+ compatibility fallback). For unbound executions, a missing resumed session is retried once without `--session`; malformed,
265
280
  empty, incomplete, or explicitly failed JSON streams remain failures. NowWork never writes
266
281
  `opencode.json` or replaces `OPENCODE_CONFIG_CONTENT`. The daemon advertises OpenCode as executable
267
282
  only when `opencode run --help` exposes `--format`, `--dangerously-skip-permissions`, `--dir`,
@@ -291,8 +306,12 @@ uses fresh sessions and committed text; it does not claim resume, images, MCP, r
291
306
  or transcript support. Agent environment variables cannot override `DEEPSEEK_*`, `DSH_*`, or
292
307
  `NOWCREW_DEEPSEEK_HARNESS_CONFIG`; those values always come from the daemon service environment.
293
308
 
294
- Codex, Kimi, Hermes, and OpenCode sessions use the same bounded per-task persistence and keyed lease as Claude when they
295
- run through protocol v1. A first-progress watchdog covers a provider that accepts a turn but remains
309
+ Unbound Codex, Kimi, Hermes, and OpenCode sessions retain the same configured warm/budget policy and keyed
310
+ lease as Claude through protocol v1. Thread-bound Claude/Codex sessions keep their native session across
311
+ warm-cache expiry, token estimates, and turn counts; verified restoration receives only accepted-cursor
312
+ deltas. New sessions and runtimes without verified restoration receive complete current-thread history
313
+ before the business turn. See the [thread context contract](../docs/thread-scoped-native-context.md).
314
+ A first-progress watchdog covers a provider that accepts a turn but remains
296
315
  semantically silent; after the first semantic event, the execution's configured total timeout remains
297
316
  authoritative so a legitimate long-running tool is not killed for quiet output.
298
317
  DeepSeek Harness is execution-v1-only and intentionally skips native session persistence.
@@ -343,9 +362,11 @@ changing the task cwd. Claude receives
343
362
  so a machine policy that disables project settings also disables this source even when the projection is
344
363
  healthy.
345
364
 
346
- Existing user-level discovery is unchanged. Claude may still load `~/.claude/skills`; Codex may still
347
- load `$CODEX_HOME/skills`. The DeepSeek Codex variant uses its Agent-private `.codex-deepseek` home.
348
- Project bindings do not hide or rewrite any of those sources.
365
+ Ordinary Codex always uses the shared user Home: the inherited `CODEX_HOME`, or `~/.codex` when
366
+ unset. There is no Computer-level Home mode or private-Home migration command. Existing user-level
367
+ discovery is unchanged: Claude may still load `~/.claude/skills`, and Codex may load `$CODEX_HOME/skills`
368
+ and `~/.agents/skills`. The DeepSeek Codex variant keeps its Agent-private `.codex-deepseek` Home for
369
+ provider credentials. Project bindings do not hide or rewrite any of those sources.
349
370
 
350
371
  Binding changes reconcile immediately when the Computer is online. On reconnect the server sends the
351
372
  complete binding set for every Agent assigned to that Computer, including empty sets that clear stale
@@ -354,6 +375,15 @@ Runtime launch, which recovers from a missed notification or daemon restart. Ski
354
375
  are visible through the symlink immediately; adding, deleting, renaming, or changing frontmatter requires
355
376
  a rescan. Web descriptions remain at the last inventory value until that rescan.
356
377
 
378
+ ## 目录投影测试的平台范围
379
+
380
+ `test/directory-projection.test.ts` 在所有平台运行,覆盖路径检查、复制发布、只读权限请求及失败恢复。
381
+ 其受控文件系统夹具记录目录 `chmod` 请求,但保留宿主目录的写权限,避免把 macOS 的目录重命名规则当作 Windows 行为。
382
+ 生产代码的只读保护不受此夹具影响。
383
+
384
+ `test/directory-projection.windows.test.ts` 仅在 Windows 上运行,使用真实文件系统验证只读目录发布与文件保护。
385
+ 非 Windows 的全量测试会明确跳过该文件,不能据此宣称 Windows 原生行为已验证。
386
+
357
387
  ## Reliability
358
388
 
359
389
  Protocol-v1 lifecycle is `accepted → started → completed`, followed by a server
@@ -17,7 +17,9 @@ function truncateUtf8(value, maxBytes) {
17
17
  return output;
18
18
  }
19
19
  export function extractIncomingMessage(wakePrompt) {
20
- const match = wakePrompt.match(/(?:^|\n)(?:Incoming message|来信):[ \t]*(.*?)(?=\n(?:Start with crew|先用 crew)|$)/su);
20
+ // Keep legacy wake delimiters while accepting program-delivered-context prompts.
21
+ // Stop before policy/history blocks; neither is the current user's message.
22
+ const match = wakePrompt.match(/(?:^|\n)(?:Incoming message|来信):[ \t]*(.*?)(?=\n(?:Start with crew|先用 crew|Work directly from this thread's context|直接依据程序交付的本线程上下文|No thread anchor:|没有线程锚点[,,])|$)/su);
21
23
  const content = match?.[1]?.trim();
22
24
  return content ? content : null;
23
25
  }
@@ -25,7 +25,6 @@ const ProfileFields = z.object({
25
25
  runtimePath: z.string().min(1).max(32768).refine((value) => !value.includes("\0") && !value.includes("\n") && !value.includes("\r"), "runtimePath must be a single line").optional(),
26
26
  releaseTag: z.enum(["latest", "beta"]).optional(),
27
27
  registryUrl: z.string().url().optional(),
28
- codexHomeMode: z.enum(["shared", "private"]).optional(),
29
28
  }).strict();
30
29
  function validateProfileRegistry(value, context) {
31
30
  const fields = value;
@@ -60,7 +59,13 @@ const StoredProtectedProfileSchema = ProfileFields.omit({ machineToken: true }).
60
59
  ciphertext: z.string().min(1).max(16384),
61
60
  }).strict(),
62
61
  }).strict().superRefine(validateProfileRegistry);
63
- const StoredProfileSchema = z.union([StoredPlainProfileSchema, StoredProtectedProfileSchema]);
62
+ // 旧版本可能已保存 shared 字段;读取时丢弃退休设置,其余字段仍严格校验。
63
+ const StoredProfileSchema = z.preprocess((raw) => {
64
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
65
+ return raw;
66
+ const { codexHomeMode: _retiredMode, ...fields } = raw;
67
+ return fields;
68
+ }, z.union([StoredPlainProfileSchema, StoredProtectedProfileSchema]));
64
69
  export const PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE = "Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}";
65
70
  export class ProfileAgentsRootConflictError extends Error {
66
71
  profile;
@@ -257,7 +262,6 @@ export async function saveProfile(input, home = daemonHome(), options = {}) {
257
262
  ...(profile.runtimePath ? { runtimePath: profile.runtimePath } : {}),
258
263
  ...(profile.releaseTag === undefined ? {} : { releaseTag: profile.releaseTag }),
259
264
  ...(profile.registryUrl === undefined ? {} : { registryUrl: profile.registryUrl }),
260
- ...(profile.codexHomeMode === undefined ? {} : { codexHomeMode: profile.codexHomeMode }),
261
265
  machineTokenProtected: { scheme: "dpapi-current-user", ciphertext },
262
266
  });
263
267
  await atomicPrivateWrite(profilePath(profile.name, home), `${JSON.stringify(stored, null, 2)}\n`, configured.harden);
@@ -404,5 +408,6 @@ export function applyProfileToEnv(profile, env) {
404
408
  env.CREW_NPM_REGISTRY = profile.registryUrl;
405
409
  else
406
410
  delete env.CREW_NPM_REGISTRY;
407
- env.CREW_CODEX_HOME_MODE = profile.codexHomeMode ?? "shared";
411
+ // 不再向运行时传播退休的模式开关;CODEX_HOME 沿用用户环境。
412
+ delete env.CREW_CODEX_HOME_MODE;
408
413
  }
package/dist/config.js CHANGED
@@ -111,14 +111,6 @@ function localMemoryV2Handles(env, serverUrl) {
111
111
  return new Set(handles);
112
112
  }
113
113
  export const DEFAULT_SERVER_URL = "http://127.0.0.1:3001";
114
- function codexHomeModeEnv(env) {
115
- const value = env.CREW_CODEX_HOME_MODE;
116
- if (value === undefined || value === "shared")
117
- return "shared";
118
- if (value === "private")
119
- return "private";
120
- throw new ConfigError("CREW_CODEX_HOME_MODE must be shared or private");
121
- }
122
114
  function codexCollaborationToolsEnv(env) {
123
115
  const value = (env.CREW_CODEX_COLLABORATION_TOOLS ?? "").trim().toLowerCase();
124
116
  if (value === "" || value === "0" || value === "false" || value === "off")
@@ -168,7 +160,6 @@ export function loadConfig(env = process.env) {
168
160
  : defaultQaReportRoot,
169
161
  cliPath: env.CREW_CLI_PATH ?? defaultCliPath(),
170
162
  runtimeBin: env.CREW_RUNTIME ?? "claude",
171
- codexHomeMode: codexHomeModeEnv(env),
172
163
  codexCollaborationTools: codexCollaborationToolsEnv(env),
173
164
  dangerous: env.CREW_RUNTIME_SAFE !== "1", // 默认开启 (headless agent 在自有 workspace 内运行)
174
165
  resume: env.CREW_RESUME !== "off" && env.CREW_RESUME !== "0", // 默认开启;一键回退现状用 CREW_RESUME=off
@@ -14,7 +14,7 @@ import { assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, man
14
14
  export async function managedDaemonCapabilities(eligibility) {
15
15
  try {
16
16
  return (await eligibility()).eligible
17
- ? ["daemon_update_v1", "daemon_restart_v1", "daemon_update_registry_v1", "daemon_layout_migration_v1"]
17
+ ? ["daemon_update_v1", "daemon_restart_v1", "daemon_update_registry_v1"]
18
18
  : [];
19
19
  }
20
20
  catch {
@@ -878,23 +878,26 @@ export function createExecutionJournal(agentsRoot, options = {}) {
878
878
  },
879
879
  acknowledgeCompletion: async (executionId) => {
880
880
  validateExecutionId(executionId);
881
- const updated = await serializedExecution(executionId, "acknowledge_completion", async () => {
881
+ const result = await serializedExecution(executionId, "acknowledge_completion", async () => {
882
882
  const entry = await requireRecord(executionId);
883
883
  if (entry.state !== "completed" && entry.state !== "interrupted") {
884
884
  throw new JournalTransitionError(`Cannot acknowledge ${entry.state} execution`);
885
885
  }
886
- if (entry.completionAcknowledged)
887
- return confirmDurable(entry);
886
+ if (entry.completionAcknowledged) {
887
+ return { entry: await confirmDurable(entry), newlyAcknowledged: false };
888
+ }
888
889
  const updated = JournalEntrySchema.parse({
889
890
  ...entry,
890
891
  completionAcknowledged: true,
891
892
  updatedAt: now().toISOString(),
892
893
  });
893
894
  await writeRecord(updated);
894
- return updated;
895
+ return { entry: updated, newlyAcknowledged: true };
895
896
  });
896
- await serializedDirectory(pruneInternal);
897
- return updated;
897
+ // 重复 ACK 仍确认持久化,但不能反复全目录清理并阻塞后续控制帧。
898
+ if (result.newlyAcknowledged)
899
+ await serializedDirectory(pruneInternal);
900
+ return result.entry;
898
901
  },
899
902
  get: async (executionId) => {
900
903
  validateExecutionId(executionId);
@@ -200,7 +200,7 @@ export const ExecutionStartSchema = z.object({
200
200
  language: z.enum(["zh", "en"]),
201
201
  systemPrompt: z.string(),
202
202
  wakePrompt: z.string(),
203
- maintenancePurpose: z.literal("self_training").optional(),
203
+ maintenancePurpose: z.enum(["self_training", "memory_prune"]).optional(),
204
204
  }).strict(),
205
205
  runtime: z.object({
206
206
  name: RuntimeSchema,
@@ -264,6 +264,7 @@ export const ExecutionRejectedSchema = z.object({
264
264
  reason: RejectionReasonSchema,
265
265
  message: z.string().optional(),
266
266
  at: TimestampSchema,
267
+ preAdmissionProof: z.object({ specHash: z.string().regex(/^[a-f0-9]{64}$/u) }).strict().optional(),
267
268
  }).strict();
268
269
  export const ExecutionStartedSchema = z.object({
269
270
  type: z.literal("execution:started"),
@@ -408,7 +408,8 @@ export async function runExecution(config, input, dependencies) {
408
408
  ? { permission: effectivePermission(spec, config) }
409
409
  : admission(spec, config, dependencies, initialAt);
410
410
  if ("rejected" in checked) {
411
- const frame = ExecutionRejectedSchema.parse(boundExecutionFrame(checked.rejected, config.executionLimits.maxEventBytes));
411
+ const proof = await dependencies.confirmPreAdmission?.(checked.rejected).catch(() => false);
412
+ const frame = ExecutionRejectedSchema.parse(boundExecutionFrame({ ...checked.rejected, ...(proof ? { preAdmissionProof: { specHash } } : {}) }, config.executionLimits.maxEventBytes));
412
413
  await dependencies.report(frame);
413
414
  return { kind: "rejected", frame };
414
415
  }
@@ -844,6 +845,7 @@ export async function runExecution(config, input, dependencies) {
844
845
  executionId: spec.executionId,
845
846
  handle: spec.agent.handle,
846
847
  channelId: spec.context.channelId,
848
+ ...(spec.context.threadId === undefined ? {} : { threadId: spec.context.threadId }),
847
849
  keyMode: "opaque",
848
850
  taskKey: spec.workspace.taskKey,
849
851
  ...(spec.workspace.resumeKey === undefined ? {} : { resumeKey: spec.workspace.resumeKey }),
@@ -876,7 +878,6 @@ export async function runExecution(config, input, dependencies) {
876
878
  ...(config.qaReportRoot === undefined ? {} : { qaReportRoot: config.qaReportRoot }),
877
879
  cliPath: config.cliPath,
878
880
  providerConfig,
879
- codexHomeMode: config.codexHomeMode,
880
881
  ...(providerConfig.description ? { description: providerConfig.description } : {}),
881
882
  ...(spec.reporting.allowBoundImDecision ? {
882
883
  systemEnv: {
@@ -885,13 +886,13 @@ export async function runExecution(config, input, dependencies) {
885
886
  } : {}),
886
887
  },
887
888
  session: {
888
- enabled: selfTraining ? false : config.resume,
889
+ enabled: spec.instructions.maintenancePurpose !== undefined ? false : config.resume,
889
890
  warmMs: config.resumeWarmMs,
890
891
  budgetTokens: config.sessionBudgetTokens,
891
892
  softTokens: config.sessionSoftTokens,
892
893
  maxTurns: config.sessionMaxTurns,
893
894
  },
894
- ...(selfTraining ? { maintenancePurpose: "self_training" } : {}),
895
+ ...(spec.instructions.maintenancePurpose === undefined ? {} : { maintenancePurpose: spec.instructions.maintenancePurpose }),
895
896
  localMemoryV2Enabled,
896
897
  };
897
898
  const result = await Promise.race([
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { isWin } from "./platform.js";
4
+ import { augmentedPath } from "./runtime-path.js";
4
5
  import { listClaudeModels } from "./runtimes/claude-models.js";
5
6
  import { listHermesModels } from "./runtimes/hermes-models.js";
6
7
  const execFileRaw = promisify(execFile);
@@ -8,6 +9,7 @@ const MODEL_PROBE_TIMEOUT_MS = 8_000;
8
9
  const MODEL_PROBE_MAX_BUFFER_BYTES = 1024 * 1024;
9
10
  // win32 上 npm CLI 是 .cmd shim,execFile 需 shell 才能执行;参数全是固定字面量,无注入面。
10
11
  const execFileP = (bin, args) => execFileRaw(bin, args, {
12
+ env: { ...process.env, PATH: augmentedPath() },
11
13
  shell: isWin(),
12
14
  timeout: MODEL_PROBE_TIMEOUT_MS,
13
15
  killSignal: "SIGKILL",
@@ -0,0 +1,22 @@
1
+ import { awaitWithCancellation } from "./runtime-cancellation.js";
2
+ const leaseTails = new Map();
3
+ /** 同一进程内的短操作或原生会话排队;等待取消也不会越过前一位持有者。 */
4
+ export async function withKeyedLease(key, operation, cancellation) {
5
+ const predecessor = leaseTails.get(key) ?? Promise.resolve();
6
+ // 解锁函数在等待 predecessor 前可用,保留取消等待时的队列顺序。
7
+ let unlock;
8
+ const current = new Promise(resolve => { unlock = resolve; });
9
+ const queued = predecessor.then(() => current);
10
+ leaseTails.set(key, queued);
11
+ try {
12
+ await awaitWithCancellation(predecessor, cancellation);
13
+ return await operation();
14
+ }
15
+ finally {
16
+ unlock();
17
+ void queued.then(() => {
18
+ if (leaseTails.get(key) === queued)
19
+ leaseTails.delete(key);
20
+ });
21
+ }
22
+ }
@@ -1,3 +1,4 @@
1
+ import { deepSeekHarnessLauncher } from "./runtimes/deepseek-harness.js";
1
2
  import { createInterface } from "node:readline";
2
3
  import { readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { delimiter, join } from "node:path";
@@ -6,8 +7,6 @@ import { prepareWorkspace, rotateAgentSession, safeKey, } from "./workspace.js";
6
7
  import { CLAUDE_ADDITIONAL_DIRECTORY_INSTRUCTIONS_ENV, isClaudeAdditionalDirectoryInstructionsSupported, probeClaudeVersion, spawnClaude, } from "./runtimes/claude.js";
7
8
  import { spawnCodex } from "./runtimes/codex.js";
8
9
  import { DEEPSEEK_CODEX_MODEL, DEEPSEEK_CODEX_REASONING_LEVELS, materializeDeepSeekCodexHome, } from "./runtimes/codex-deepseek-config.js";
9
- import { materializeDefaultCodexHome } from "./runtimes/codex-home.js";
10
- import { materializePrivateCodexHome } from "./codex-home-launch.js";
11
10
  import { spawnKimi, KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
12
11
  import { applyDeepSeekHarnessMachineEnv, applyProviderEnv, providerFingerprint, } from "./provider-env.js";
13
12
  import { augmentedPath } from "./runtime-path.js";
@@ -15,6 +14,11 @@ import { deliverCloudReports, prepareCloudReportExecution } from "./cloud-report
15
14
  import { reportDeliveryContext } from "./cloud-report-receipt.js";
16
15
  import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
17
16
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
17
+ import { NativeThreadContext, isolateMaintenanceSession } from "./native-thread-context.js";
18
+ import { prepareThreadDelivery } from "./thread-input-delivery.js";
19
+ import { resolveMemoryPruneWorkLog, workLogSha256 } from "./local-memory/work-log.js";
20
+ import { withKeyedLease } from "./local-execution-lease.js";
21
+ import { withWorkLogExecution, withWorkLogCommit, workLogExecutionKey } from "./local-memory/work-log-execution.js";
18
22
  import { providerErrorFromExit } from "./provider-error.js";
19
23
  import { createConsoleFormatter } from "./console-formatter.js";
20
24
  import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
@@ -108,6 +112,9 @@ async function launchLegacyRuntime(request) {
108
112
  if (request.runtime === "deepseek-harness") {
109
113
  throw new Error("DeepSeek Harness requires execution protocol v1");
110
114
  }
115
+ if (request.runtime !== "claude")
116
+ request = { ...request, wakePrompt: (await prepareThreadDelivery({ env: request.env,
117
+ resumed: false, sessionId: "unverified-cold-legacy", wakePrompt: request.wakePrompt })).prompt };
111
118
  const common = {
112
119
  bin: request.bin,
113
120
  cwd: request.cwd,
@@ -154,27 +161,12 @@ async function launchLegacyRuntime(request) {
154
161
  function resolvePrompt(prompt, context) {
155
162
  return typeof prompt === "string" ? prompt : prompt(context);
156
163
  }
157
- const nativeSessionLeaseTails = new Map();
158
164
  const activeMemoryPrunes = new Map();
159
- async function withKeyedLease(key, operation, cancellation) {
160
- const predecessor = nativeSessionLeaseTails.get(key) ?? Promise.resolve();
161
- let release;
162
- const current = new Promise((resolve) => { release = resolve; });
163
- const tail = predecessor.then(() => current);
164
- nativeSessionLeaseTails.set(key, tail);
165
- try {
166
- await awaitWithCancellation(predecessor, cancellation);
167
- return await operation();
168
- }
169
- finally {
170
- release();
171
- void tail.then(() => {
172
- if (nativeSessionLeaseTails.get(key) === tail)
173
- nativeSessionLeaseTails.delete(key);
174
- });
175
- }
176
- }
177
165
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
166
+ input = isolateMaintenanceSession(input);
167
+ return withWorkLogExecution(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), () => executeLocalWithSession(input, callbacks, dependencies), dependencies.cancellation);
168
+ }
169
+ async function executeLocalWithSession(input, callbacks, dependencies) {
178
170
  const versionedProjectSkills = input.projectSkills !== undefined
179
171
  && !Array.isArray(input.projectSkills);
180
172
  if (versionedProjectSkills && (input.runtime.name !== "codex" && input.runtime.name !== "claude")) {
@@ -255,6 +247,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
255
247
  let localMemoryResultPath = null;
256
248
  let executionWorkspace = workspace;
257
249
  let localMemoryTelemetry = null;
250
+ let nativeThreadContext = null;
258
251
  const inheritedEnv = { ...process.env };
259
252
  const reportRoots = {
260
253
  root: workspace.dir,
@@ -267,8 +260,6 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
267
260
  if (key.startsWith("CREW_AGENT_MEMORY_") || ["CREW_CLOUD_PROVIDER", "CREW_OSS_UPLOAD_SCRIPT", "CREW_OSS_REPORT_UPLOAD"].includes(key))
268
261
  delete inheritedEnv[key];
269
262
  }
270
- const sourceCodexHome = inheritedEnv.CODEX_HOME
271
- ?? (inheritedEnv.HOME === undefined ? undefined : join(inheritedEnv.HOME, ".codex"));
272
263
  let materializedDeepSeekHome;
273
264
  try {
274
265
  if (isDeepSeekCodex && !providerConfig.providerApiKey) {
@@ -294,12 +285,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
294
285
  : null;
295
286
  const currentPrior = storedCurrentPrior !== null
296
287
  && storedCurrentPrior.sessionContextFingerprint === input.sessionContextFingerprint
288
+ && storedCurrentPrior.runtime === runtime.name
289
+ && storedCurrentPrior.threadRoot === input.resumeKey
297
290
  ? storedCurrentPrior
298
291
  : null;
299
- // Interactive V1 uses collision-resistant session directories, while the
300
- // older legacy path stored metadata under tasks/<safe thread key>. Import
301
- // that metadata once so an existing thread can cross the protocol boundary
302
- // without losing its native runtime conversation.
292
+ // Import compatible legacy metadata across the protocol boundary.
303
293
  const legacyRunDir = input.session.enabled
304
294
  && supportsNativeResume
305
295
  && input.keyMode === "opaque"
@@ -315,7 +305,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
315
305
  const legacyPrior = legacyRunDir === null || (currentPrior !== null && !legacyCwdPinned)
316
306
  ? null
317
307
  : await readSession(legacyRunDir);
318
- const prior = currentPrior ?? legacyPrior;
308
+ const compatibleLegacyPrior = legacyPrior?.runtime === runtime.name && legacyPrior?.threadRoot === input.resumeKey
309
+ ? legacyPrior : null;
310
+ const prior = currentPrior ?? compatibleLegacyPrior;
319
311
  const sessionExists = workspace.sessionResume || legacyPrior !== null;
320
312
  const usesLegacyCwd = legacyRunDir !== null
321
313
  && (legacyCwdPinned || (currentPrior === null && legacyPrior !== null));
@@ -336,11 +328,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
336
328
  ? prior?.model ?? currentModel
337
329
  : currentModel;
338
330
  const resumeSessionId = supportsNativeResume && sessionExists
339
- ? pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, input.session.budgetTokens, input.session.maxTurns, providerFp)
331
+ ? pickResumeId(input.resumeKey && (!Number.isSafeInteger(prior?.threadContextSeq) || prior?.threadHistoryComplete !== true) ? null : prior, Date.now(), input.resumeKey ? 0 : input.session.warmMs, resumeComparisonModel, input.resumeKey ? 0 : input.session.budgetTokens, input.resumeKey ? 0 : input.session.maxTurns, providerFp)
340
332
  : null;
341
333
  const resuming = resumeSessionId !== null;
342
334
  const rotatedForBudget = !resuming && supportsNativeResume && sessionExists
343
- && pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
335
+ && pickResumeId(prior, Date.now(), input.resumeKey ? 0 : input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
344
336
  const nearBudget = resuming && isNearBudget(prior, input.session.softTokens);
345
337
  const rotated = Boolean(workspace.agentSessionId && sessionExists && !resuming);
346
338
  // Codex and Kimi choose their own id on a fresh start, so the native id persisted in
@@ -348,8 +340,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
348
340
  const launchSessionId = resumeSessionId ?? (rotated
349
341
  ? await rotateAgentSession(workspace.sessionDir)
350
342
  : workspace.agentSessionId);
351
- await materializePrivateCodexHome({ runtime: runtime.name, homeDir: workspace.homeDir, ...(input.launch.codexHomeMode === undefined ? {} : { mode: input.launch.codexHomeMode }), ...(sourceCodexHome === undefined ? {} : { sourceHome: sourceCodexHome }), ...(launchSessionId === undefined ? {} : { sessionId: launchSessionId }), ...(dependencies.cancellation === undefined ? {} : { cancellation: dependencies.cancellation }), ...(dependencies.materializeDefaultCodexHome === undefined ? {} : { materialize: dependencies.materializeDefaultCodexHome }) });
352
- const promptContext = {
343
+ let promptContext = {
353
344
  workspace: executionWorkspace,
354
345
  resuming,
355
346
  rotatedForBudget,
@@ -388,8 +379,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
388
379
  }
389
380
  }
390
381
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
391
- const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
392
- memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
382
+ let baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
383
+ memoryPruneTraceId = parseMemoryPruneTraceId(baseWakePrompt);
384
+ if (memoryPruneTraceId !== null) {
385
+ executionWorkspace = await resolveMemoryPruneWorkLog(executionWorkspace, input);
386
+ promptContext = { ...promptContext, workspace: executionWorkspace };
387
+ baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
388
+ }
393
389
  if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
394
390
  && isLocalMemoryV2Eligible({
395
391
  enabledForHandle: true,
@@ -402,12 +398,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
402
398
  }
403
399
  const memoryPrompt = await prepareLocalMemoryPrompt({
404
400
  agentDir: executionWorkspace.dir, executionId: input.executionId, agentHandle: input.handle,
405
- enabled: input.localMemoryV2Enabled === true, query: wakePrompt,
401
+ enabled: input.localMemoryV2Enabled === true, query: baseWakePrompt,
406
402
  resultPath: localMemoryResultPath, maxBytes: input.maxSystemPromptBytes,
407
403
  renderBase: (reservedPromptBytes) => resolvePrompt(input.systemPrompt, { ...promptContext, reservedPromptBytes }),
408
404
  });
409
405
  localMemoryResultPath = memoryPrompt.resultPath;
410
406
  const systemPrompt = memoryPrompt.systemPrompt;
407
+ const wakePrompt = baseWakePrompt + memoryPrompt.wakePromptSuffix;
411
408
  if (!isolatedMaintenance)
412
409
  try {
413
410
  localMemoryTelemetry = createLocalMemoryTelemetry({
@@ -466,6 +463,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
466
463
  }
467
464
  memoryPruneFailurePhase = "runtime_prepare";
468
465
  const crewHome = isolatedMaintenance ? executionWorkspace.runDir : workspace.dir;
466
+ const contextThreadId = input.threadId ?? input.resumeKey;
467
+ if (contextThreadId && input.maintenancePurpose === undefined) {
468
+ nativeThreadContext = new NativeThreadContext(join(executionWorkspace.runDir, `.crew-context-${input.executionId}.json`), contextThreadId, runtime.name, resumeSessionId, prior);
469
+ await nativeThreadContext.initialize();
470
+ }
469
471
  const baseEnv = {
470
472
  ...inheritedEnv,
471
473
  ...sanitizeEnvVars(providerConfig.envVars),
@@ -478,6 +480,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
478
480
  CREW_TOKEN: input.launch.token,
479
481
  CREW_CHANNEL: input.channelId,
480
482
  CREW_HOME: crewHome,
483
+ // Empty values override any stale inherited hints from an enclosing runtime.
484
+ CREW_THREAD_ROOT: contextThreadId ?? "",
485
+ CREW_THREAD_CONTEXT_FILE: nativeThreadContext?.path ?? "",
481
486
  // Keep the daemon-selected storage root authoritative for runtimes.
482
487
  // In profile-backed NowWork sessions the daemon config may differ from
483
488
  // the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
@@ -502,9 +507,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
502
507
  ? { KIMI_MODEL_THINKING_EFFORT: runtime.reasoning }
503
508
  : {}),
504
509
  };
505
- const providerEnv = applyProviderEnv(baseEnv, runtime.name, input.launch.codexHomeMode === undefined
506
- ? providerConfig
507
- : { ...providerConfig, codexHomeMode: input.launch.codexHomeMode }, workspace.homeDir, materializedDeepSeekHome);
510
+ const providerEnv = applyProviderEnv(baseEnv, runtime.name, providerConfig, workspace.homeDir, materializedDeepSeekHome);
508
511
  const childEnv = runtime.name === "deepseek-harness"
509
512
  ? applyDeepSeekHarnessMachineEnv(providerEnv, inheritedEnv)
510
513
  : providerEnv;
@@ -525,7 +528,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
525
528
  const effectiveSystemPrompt = abilityContext === undefined
526
529
  ? systemPrompt
527
530
  : `${systemPrompt}\n\n${abilityContext.prompt}`;
528
- if (Buffer.byteLength(effectiveSystemPrompt, "utf8") > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
531
+ if (Buffer.byteLength(effectiveSystemPrompt, "utf8") + Buffer.byteLength(memoryPrompt.wakePromptSuffix, "utf8")
532
+ > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
529
533
  throw new Error("ability_runtime_context_size_exceeded");
530
534
  }
531
535
  memoryPruneFailurePhase = "prompt_write";
@@ -559,7 +563,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
559
563
  });
560
564
  }
561
565
  const trainingRoots = input.trainingProjectContext?.projects.map((project) => project.root) ?? [];
562
- const { codexSkillRoots, claudeAdditionalDirectories } = await projectSkillRuntimeDirectories({
566
+ const { codexSkillRoots, claudeAdditionalDirectories, harnessSkillRoots } = await projectSkillRuntimeDirectories({
563
567
  runtime: runtime.name,
564
568
  ...(runtimeRoot === undefined ? {} : { runtimeRoot }),
565
569
  agentRoot: workspace.dir,
@@ -597,7 +601,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
597
601
  const workspaceRoots = [...legacyRoots, ...trainingRoots];
598
602
  const launchRequest = {
599
603
  runtime: runtime.name,
600
- bin: runtime.name === "deepseek-harness" ? "dsh-acp-demo" : runtime.name,
604
+ bin: runtime.name === "deepseek-harness" ? deepSeekHarnessLauncher(runtimeEnv).bin : runtime.name,
605
+ ...(runtime.name === "deepseek-harness" ? { harnessStateRoot: executionWorkspace.runDir, harnessSkillRoots: harnessSkillRoots ?? [] } : {}),
601
606
  cwd: input.projectContext?.primary?.root ?? executionWorkspace.runDir,
602
607
  ...(runtimeRoot !== undefined
603
608
  || (input.projectSkills === undefined && input.abilityRelease === undefined)
@@ -801,6 +806,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
801
806
  const event = parseLine(line);
802
807
  if (!event)
803
808
  return;
809
+ nativeThreadContext?.observe(event);
804
810
  localMemoryTelemetry?.observe(event);
805
811
  if (isRuntimeReadyEvent(runtime.name, event))
806
812
  markRuntimeReady();
@@ -918,14 +924,17 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
918
924
  memoryPruneRuntimeExitCode = exitCode;
919
925
  if (localMemoryResultPath !== null) {
920
926
  memoryPruneFailurePhase = "memory_commit";
921
- const committed = await commitLocalMemoryResultAfterExit({
922
- resultPath: localMemoryResultPath,
927
+ const resultPath = localMemoryResultPath;
928
+ const commit = () => commitLocalMemoryResultAfterExit({
929
+ resultPath,
923
930
  agentDir: executionWorkspace.dir,
924
931
  executionId: input.executionId,
925
932
  pruneTraceId: memoryPruneTraceId,
926
933
  exitCode,
927
934
  workLogPath: executionWorkspace.workLogPath,
935
+ expectedWorkLogSha256: workLogSha256(executionWorkspace.workLog),
928
936
  });
937
+ const committed = exitCode !== 0 ? await commit() : await withWorkLogCommit(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), commit, dependencies.cancellation);
929
938
  if (committed.status === "failed" || committed.status === "missing") {
930
939
  const reason = committed.error ?? "local_memory_result_missing";
931
940
  dslog("local_memory.commit_failed", "结构化本地记忆提交或清理未完成,保留可用恢复材料", {
@@ -959,9 +968,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
959
968
  ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
960
969
  : (resuming ? prior?.contextTokens : undefined);
961
970
  await writeSession(workspace.sessionDir, {
971
+ runtime: runtime.name,
972
+ ...(input.resumeKey === undefined ? {} : { threadRoot: input.resumeKey }),
973
+ ...(exitCode === 0 ? await nativeThreadContext?.checkpoint() ?? {} : {}),
962
974
  sessionId,
963
975
  lastRunAt: Date.now(),
964
- turns: resuming ? (prior?.turns ?? 0) + 1 : 1,
976
+ turns: nativeThreadContext?.confirmed ? (prior?.turns ?? 0) + 1 : 1,
965
977
  model: currentModel,
966
978
  providerFingerprint: providerFp,
967
979
  ...(input.sessionContextFingerprint === undefined
@@ -995,7 +1007,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
995
1007
  ...(usage === undefined ? {} : { usage }),
996
1008
  model: observedModel,
997
1009
  runtime: runtime.name,
998
- resumed: resuming,
1010
+ resumed: nativeThreadContext?.confirmed ?? false,
999
1011
  sessionId,
1000
1012
  errorMessage: errorTail || null,
1001
1013
  ...(providerError === undefined ? {} : { providerError }),
@@ -1007,6 +1019,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1007
1019
  };
1008
1020
  }
1009
1021
  finally {
1022
+ await nativeThreadContext?.cleanup().catch(() => undefined);
1010
1023
  startupReservationState.current?.release();
1011
1024
  try {
1012
1025
  await localMemoryTelemetry?.finish();