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

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 (74) hide show
  1. package/README.md +38 -10
  2. package/dist/agent-memory/policy.js +3 -1
  3. package/dist/daemon-update-eligibility.js +1 -1
  4. package/dist/execution-journal.js +9 -6
  5. package/dist/execution-protocol.js +2 -1
  6. package/dist/execution-runner.js +5 -3
  7. package/dist/list-models.js +2 -0
  8. package/dist/local-execution-lease.js +22 -0
  9. package/dist/local-executor.js +57 -37
  10. package/dist/local-memory/candidate.js +43 -0
  11. package/dist/local-memory/index-budget.js +8 -0
  12. package/dist/local-memory/inventory-cli.js +61 -0
  13. package/dist/local-memory/inventory-preview.js +154 -0
  14. package/dist/local-memory/inventory-source.js +159 -0
  15. package/dist/local-memory/pending-match.js +29 -0
  16. package/dist/local-memory/pending-query.js +100 -0
  17. package/dist/local-memory/pending-schema.js +29 -0
  18. package/dist/local-memory/pending-source.js +126 -0
  19. package/dist/local-memory/pending.js +34 -84
  20. package/dist/local-memory/prompt.js +14 -11
  21. package/dist/local-memory/read-cli.js +59 -8
  22. package/dist/local-memory/runtime.js +24 -17
  23. package/dist/local-memory/work-log-execution.js +31 -0
  24. package/dist/local-memory/work-log.js +58 -0
  25. package/dist/local-memory/write.js +27 -58
  26. package/dist/machine-info.js +54 -16
  27. package/dist/main.js +4 -69
  28. package/dist/native-thread-context.js +59 -0
  29. package/dist/pre-admission-rejection.js +64 -0
  30. package/dist/project-skills/execution-adapter.js +3 -2
  31. package/dist/project-skills/runtime-launch.js +14 -0
  32. package/dist/prompt.js +15 -15
  33. package/dist/provider-env.js +3 -4
  34. package/dist/runner.js +14 -15
  35. package/dist/runtime-probe.js +34 -5
  36. package/dist/runtimes/acp-probe-transport.js +69 -0
  37. package/dist/runtimes/claude-session-start-hook.js +24 -0
  38. package/dist/runtimes/claude-thread-runner.js +266 -0
  39. package/dist/runtimes/claude.js +23 -4
  40. package/dist/runtimes/codex-app-server-runner.js +43 -4
  41. package/dist/runtimes/deepseek-harness-profile.js +92 -0
  42. package/dist/runtimes/deepseek-harness-runner.js +224 -0
  43. package/dist/runtimes/deepseek-harness.js +64 -11
  44. package/dist/runtimes/harness-probe-retry.js +42 -0
  45. package/dist/runtimes/kimi-acp-runner.js +63 -10
  46. package/dist/runtimes/opencode-runner.js +7 -1
  47. package/dist/serve.js +58 -88
  48. package/dist/session.js +4 -0
  49. package/dist/storage-identity.js +0 -3
  50. package/dist/storage-serve-adapter.js +6 -72
  51. package/dist/supervised-runtime.js +28 -18
  52. package/dist/thread-input-delivery.js +217 -0
  53. package/package.json +2 -2
  54. package/dist/daemon-migration-controller.js +0 -211
  55. package/dist/daemon-migration-wiring.js +0 -22
  56. package/dist/storage-control.js +0 -212
  57. package/dist/storage-copy.js +0 -628
  58. package/dist/storage-credential-guard.js +0 -21
  59. package/dist/storage-doctor.js +0 -228
  60. package/dist/storage-manifest.js +0 -1027
  61. package/dist/storage-migration-controller.js +0 -440
  62. package/dist/storage-migration-manifest.js +0 -105
  63. package/dist/storage-migration-policy.js +0 -100
  64. package/dist/storage-runtime-controller.js +0 -381
  65. package/dist/storage-runtime-readiness.js +0 -240
  66. package/dist/storage-safe-fs.js +0 -302
  67. package/dist/storage-status-delivery.js +0 -224
  68. package/dist/storage-status-replay.js +0 -140
  69. package/dist/storage-target-confirmation.js +0 -129
  70. package/dist/storage-worker-command.js +0 -37
  71. package/dist/storage-worker-executor.js +0 -449
  72. package/dist/storage-worker-handoff.js +0 -151
  73. package/dist/storage-worker-operation.js +0 -294
  74. 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.
@@ -354,6 +373,15 @@ Runtime launch, which recovers from a missed notification or daemon restart. Ski
354
373
  are visible through the symlink immediately; adding, deleting, renaming, or changing frontmatter requires
355
374
  a rescan. Web descriptions remain at the last inventory value until that rescan.
356
375
 
376
+ ## 目录投影测试的平台范围
377
+
378
+ `test/directory-projection.test.ts` 在所有平台运行,覆盖路径检查、复制发布、只读权限请求及失败恢复。
379
+ 其受控文件系统夹具记录目录 `chmod` 请求,但保留宿主目录的写权限,避免把 macOS 的目录重命名规则当作 Windows 行为。
380
+ 生产代码的只读保护不受此夹具影响。
381
+
382
+ `test/directory-projection.windows.test.ts` 仅在 Windows 上运行,使用真实文件系统验证只读目录发布与文件保护。
383
+ 非 Windows 的全量测试会明确跳过该文件,不能据此宣称 Windows 原生行为已验证。
384
+
357
385
  ## Reliability
358
386
 
359
387
  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
  }
@@ -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 }),
@@ -885,13 +887,13 @@ export async function runExecution(config, input, dependencies) {
885
887
  } : {}),
886
888
  },
887
889
  session: {
888
- enabled: selfTraining ? false : config.resume,
890
+ enabled: spec.instructions.maintenancePurpose !== undefined ? false : config.resume,
889
891
  warmMs: config.resumeWarmMs,
890
892
  budgetTokens: config.sessionBudgetTokens,
891
893
  softTokens: config.sessionSoftTokens,
892
894
  maxTurns: config.sessionMaxTurns,
893
895
  },
894
- ...(selfTraining ? { maintenancePurpose: "self_training" } : {}),
896
+ ...(spec.instructions.maintenancePurpose === undefined ? {} : { maintenancePurpose: spec.instructions.maintenancePurpose }),
895
897
  localMemoryV2Enabled,
896
898
  };
897
899
  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";
@@ -15,6 +16,11 @@ import { deliverCloudReports, prepareCloudReportExecution } from "./cloud-report
15
16
  import { reportDeliveryContext } from "./cloud-report-receipt.js";
16
17
  import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
17
18
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
19
+ import { NativeThreadContext, isolateMaintenanceSession } from "./native-thread-context.js";
20
+ import { prepareThreadDelivery } from "./thread-input-delivery.js";
21
+ import { resolveMemoryPruneWorkLog, workLogSha256 } from "./local-memory/work-log.js";
22
+ import { withKeyedLease } from "./local-execution-lease.js";
23
+ import { withWorkLogExecution, withWorkLogCommit, workLogExecutionKey } from "./local-memory/work-log-execution.js";
18
24
  import { providerErrorFromExit } from "./provider-error.js";
19
25
  import { createConsoleFormatter } from "./console-formatter.js";
20
26
  import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
@@ -108,6 +114,9 @@ async function launchLegacyRuntime(request) {
108
114
  if (request.runtime === "deepseek-harness") {
109
115
  throw new Error("DeepSeek Harness requires execution protocol v1");
110
116
  }
117
+ if (request.runtime !== "claude")
118
+ request = { ...request, wakePrompt: (await prepareThreadDelivery({ env: request.env,
119
+ resumed: false, sessionId: "unverified-cold-legacy", wakePrompt: request.wakePrompt })).prompt };
111
120
  const common = {
112
121
  bin: request.bin,
113
122
  cwd: request.cwd,
@@ -154,27 +163,12 @@ async function launchLegacyRuntime(request) {
154
163
  function resolvePrompt(prompt, context) {
155
164
  return typeof prompt === "string" ? prompt : prompt(context);
156
165
  }
157
- const nativeSessionLeaseTails = new Map();
158
166
  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
167
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
168
+ input = isolateMaintenanceSession(input);
169
+ return withWorkLogExecution(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), () => executeLocalWithSession(input, callbacks, dependencies), dependencies.cancellation);
170
+ }
171
+ async function executeLocalWithSession(input, callbacks, dependencies) {
178
172
  const versionedProjectSkills = input.projectSkills !== undefined
179
173
  && !Array.isArray(input.projectSkills);
180
174
  if (versionedProjectSkills && (input.runtime.name !== "codex" && input.runtime.name !== "claude")) {
@@ -255,6 +249,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
255
249
  let localMemoryResultPath = null;
256
250
  let executionWorkspace = workspace;
257
251
  let localMemoryTelemetry = null;
252
+ let nativeThreadContext = null;
258
253
  const inheritedEnv = { ...process.env };
259
254
  const reportRoots = {
260
255
  root: workspace.dir,
@@ -294,12 +289,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
294
289
  : null;
295
290
  const currentPrior = storedCurrentPrior !== null
296
291
  && storedCurrentPrior.sessionContextFingerprint === input.sessionContextFingerprint
292
+ && storedCurrentPrior.runtime === runtime.name
293
+ && storedCurrentPrior.threadRoot === input.resumeKey
297
294
  ? storedCurrentPrior
298
295
  : 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.
296
+ // Import compatible legacy metadata across the protocol boundary.
303
297
  const legacyRunDir = input.session.enabled
304
298
  && supportsNativeResume
305
299
  && input.keyMode === "opaque"
@@ -315,7 +309,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
315
309
  const legacyPrior = legacyRunDir === null || (currentPrior !== null && !legacyCwdPinned)
316
310
  ? null
317
311
  : await readSession(legacyRunDir);
318
- const prior = currentPrior ?? legacyPrior;
312
+ const compatibleLegacyPrior = legacyPrior?.runtime === runtime.name && legacyPrior?.threadRoot === input.resumeKey
313
+ ? legacyPrior : null;
314
+ const prior = currentPrior ?? compatibleLegacyPrior;
319
315
  const sessionExists = workspace.sessionResume || legacyPrior !== null;
320
316
  const usesLegacyCwd = legacyRunDir !== null
321
317
  && (legacyCwdPinned || (currentPrior === null && legacyPrior !== null));
@@ -336,11 +332,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
336
332
  ? prior?.model ?? currentModel
337
333
  : currentModel;
338
334
  const resumeSessionId = supportsNativeResume && sessionExists
339
- ? pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, input.session.budgetTokens, input.session.maxTurns, providerFp)
335
+ ? 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
336
  : null;
341
337
  const resuming = resumeSessionId !== null;
342
338
  const rotatedForBudget = !resuming && supportsNativeResume && sessionExists
343
- && pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
339
+ && pickResumeId(prior, Date.now(), input.resumeKey ? 0 : input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
344
340
  const nearBudget = resuming && isNearBudget(prior, input.session.softTokens);
345
341
  const rotated = Boolean(workspace.agentSessionId && sessionExists && !resuming);
346
342
  // Codex and Kimi choose their own id on a fresh start, so the native id persisted in
@@ -349,7 +345,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
349
345
  ? await rotateAgentSession(workspace.sessionDir)
350
346
  : workspace.agentSessionId);
351
347
  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 = {
348
+ let promptContext = {
353
349
  workspace: executionWorkspace,
354
350
  resuming,
355
351
  rotatedForBudget,
@@ -388,8 +384,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
388
384
  }
389
385
  }
390
386
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
391
- const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
392
- memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
387
+ let baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
388
+ memoryPruneTraceId = parseMemoryPruneTraceId(baseWakePrompt);
389
+ if (memoryPruneTraceId !== null) {
390
+ executionWorkspace = await resolveMemoryPruneWorkLog(executionWorkspace, input);
391
+ promptContext = { ...promptContext, workspace: executionWorkspace };
392
+ baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
393
+ }
393
394
  if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
394
395
  && isLocalMemoryV2Eligible({
395
396
  enabledForHandle: true,
@@ -402,12 +403,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
402
403
  }
403
404
  const memoryPrompt = await prepareLocalMemoryPrompt({
404
405
  agentDir: executionWorkspace.dir, executionId: input.executionId, agentHandle: input.handle,
405
- enabled: input.localMemoryV2Enabled === true, query: wakePrompt,
406
+ enabled: input.localMemoryV2Enabled === true, query: baseWakePrompt,
406
407
  resultPath: localMemoryResultPath, maxBytes: input.maxSystemPromptBytes,
407
408
  renderBase: (reservedPromptBytes) => resolvePrompt(input.systemPrompt, { ...promptContext, reservedPromptBytes }),
408
409
  });
409
410
  localMemoryResultPath = memoryPrompt.resultPath;
410
411
  const systemPrompt = memoryPrompt.systemPrompt;
412
+ const wakePrompt = baseWakePrompt + memoryPrompt.wakePromptSuffix;
411
413
  if (!isolatedMaintenance)
412
414
  try {
413
415
  localMemoryTelemetry = createLocalMemoryTelemetry({
@@ -466,6 +468,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
466
468
  }
467
469
  memoryPruneFailurePhase = "runtime_prepare";
468
470
  const crewHome = isolatedMaintenance ? executionWorkspace.runDir : workspace.dir;
471
+ const contextThreadId = input.threadId ?? input.resumeKey;
472
+ if (contextThreadId && input.maintenancePurpose === undefined) {
473
+ nativeThreadContext = new NativeThreadContext(join(executionWorkspace.runDir, `.crew-context-${input.executionId}.json`), contextThreadId, runtime.name, resumeSessionId, prior);
474
+ await nativeThreadContext.initialize();
475
+ }
469
476
  const baseEnv = {
470
477
  ...inheritedEnv,
471
478
  ...sanitizeEnvVars(providerConfig.envVars),
@@ -478,6 +485,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
478
485
  CREW_TOKEN: input.launch.token,
479
486
  CREW_CHANNEL: input.channelId,
480
487
  CREW_HOME: crewHome,
488
+ // Empty values override any stale inherited hints from an enclosing runtime.
489
+ CREW_THREAD_ROOT: contextThreadId ?? "",
490
+ CREW_THREAD_CONTEXT_FILE: nativeThreadContext?.path ?? "",
481
491
  // Keep the daemon-selected storage root authoritative for runtimes.
482
492
  // In profile-backed NowWork sessions the daemon config may differ from
483
493
  // the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
@@ -525,7 +535,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
525
535
  const effectiveSystemPrompt = abilityContext === undefined
526
536
  ? systemPrompt
527
537
  : `${systemPrompt}\n\n${abilityContext.prompt}`;
528
- if (Buffer.byteLength(effectiveSystemPrompt, "utf8") > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
538
+ if (Buffer.byteLength(effectiveSystemPrompt, "utf8") + Buffer.byteLength(memoryPrompt.wakePromptSuffix, "utf8")
539
+ > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
529
540
  throw new Error("ability_runtime_context_size_exceeded");
530
541
  }
531
542
  memoryPruneFailurePhase = "prompt_write";
@@ -559,7 +570,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
559
570
  });
560
571
  }
561
572
  const trainingRoots = input.trainingProjectContext?.projects.map((project) => project.root) ?? [];
562
- const { codexSkillRoots, claudeAdditionalDirectories } = await projectSkillRuntimeDirectories({
573
+ const { codexSkillRoots, claudeAdditionalDirectories, harnessSkillRoots } = await projectSkillRuntimeDirectories({
563
574
  runtime: runtime.name,
564
575
  ...(runtimeRoot === undefined ? {} : { runtimeRoot }),
565
576
  agentRoot: workspace.dir,
@@ -597,7 +608,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
597
608
  const workspaceRoots = [...legacyRoots, ...trainingRoots];
598
609
  const launchRequest = {
599
610
  runtime: runtime.name,
600
- bin: runtime.name === "deepseek-harness" ? "dsh-acp-demo" : runtime.name,
611
+ bin: runtime.name === "deepseek-harness" ? deepSeekHarnessLauncher(runtimeEnv).bin : runtime.name,
612
+ ...(runtime.name === "deepseek-harness" ? { harnessStateRoot: executionWorkspace.runDir, harnessSkillRoots: harnessSkillRoots ?? [] } : {}),
601
613
  cwd: input.projectContext?.primary?.root ?? executionWorkspace.runDir,
602
614
  ...(runtimeRoot !== undefined
603
615
  || (input.projectSkills === undefined && input.abilityRelease === undefined)
@@ -801,6 +813,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
801
813
  const event = parseLine(line);
802
814
  if (!event)
803
815
  return;
816
+ nativeThreadContext?.observe(event);
804
817
  localMemoryTelemetry?.observe(event);
805
818
  if (isRuntimeReadyEvent(runtime.name, event))
806
819
  markRuntimeReady();
@@ -918,14 +931,17 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
918
931
  memoryPruneRuntimeExitCode = exitCode;
919
932
  if (localMemoryResultPath !== null) {
920
933
  memoryPruneFailurePhase = "memory_commit";
921
- const committed = await commitLocalMemoryResultAfterExit({
922
- resultPath: localMemoryResultPath,
934
+ const resultPath = localMemoryResultPath;
935
+ const commit = () => commitLocalMemoryResultAfterExit({
936
+ resultPath,
923
937
  agentDir: executionWorkspace.dir,
924
938
  executionId: input.executionId,
925
939
  pruneTraceId: memoryPruneTraceId,
926
940
  exitCode,
927
941
  workLogPath: executionWorkspace.workLogPath,
942
+ expectedWorkLogSha256: workLogSha256(executionWorkspace.workLog),
928
943
  });
944
+ const committed = exitCode !== 0 ? await commit() : await withWorkLogCommit(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), commit, dependencies.cancellation);
929
945
  if (committed.status === "failed" || committed.status === "missing") {
930
946
  const reason = committed.error ?? "local_memory_result_missing";
931
947
  dslog("local_memory.commit_failed", "结构化本地记忆提交或清理未完成,保留可用恢复材料", {
@@ -959,9 +975,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
959
975
  ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
960
976
  : (resuming ? prior?.contextTokens : undefined);
961
977
  await writeSession(workspace.sessionDir, {
978
+ runtime: runtime.name,
979
+ ...(input.resumeKey === undefined ? {} : { threadRoot: input.resumeKey }),
980
+ ...(exitCode === 0 ? await nativeThreadContext?.checkpoint() ?? {} : {}),
962
981
  sessionId,
963
982
  lastRunAt: Date.now(),
964
- turns: resuming ? (prior?.turns ?? 0) + 1 : 1,
983
+ turns: nativeThreadContext?.confirmed ? (prior?.turns ?? 0) + 1 : 1,
965
984
  model: currentModel,
966
985
  providerFingerprint: providerFp,
967
986
  ...(input.sessionContextFingerprint === undefined
@@ -995,7 +1014,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
995
1014
  ...(usage === undefined ? {} : { usage }),
996
1015
  model: observedModel,
997
1016
  runtime: runtime.name,
998
- resumed: resuming,
1017
+ resumed: nativeThreadContext?.confirmed ?? false,
999
1018
  sessionId,
1000
1019
  errorMessage: errorTail || null,
1001
1020
  ...(providerError === undefined ? {} : { providerError }),
@@ -1007,6 +1026,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1007
1026
  };
1008
1027
  }
1009
1028
  finally {
1029
+ await nativeThreadContext?.cleanup().catch(() => undefined);
1010
1030
  startupReservationState.current?.release();
1011
1031
  try {
1012
1032
  await localMemoryTelemetry?.finish();
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import { containsLikelySecret } from "../agent-memory/policy.js";
3
+ const CategorySchema = z.enum([
4
+ "preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons",
5
+ ]);
6
+ export const LocalMemorySingleLineText = z.string().trim().min(1).max(500).regex(/^[^\r\n]+$/u);
7
+ export const LocalMemoryChangeSchema = z.object({
8
+ operation: z.enum(["create", "update", "supersede"]),
9
+ memoryId: LocalMemorySingleLineText.regex(/^[^<>]+$/u).nullable(),
10
+ baseRevision: z.number().int().nonnegative().nullable(),
11
+ supersedesMemoryId: LocalMemorySingleLineText.regex(/^[^<>]+$/u).nullable(),
12
+ title: LocalMemorySingleLineText.max(120),
13
+ kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
14
+ summary: LocalMemorySingleLineText,
15
+ triggers: z.array(LocalMemorySingleLineText.max(80)).min(1).max(16),
16
+ priority: z.enum(["high", "medium", "low"]),
17
+ detailCategory: CategorySchema,
18
+ detailBody: z.string().trim().min(1).max(12_000),
19
+ reviewAfter: LocalMemorySingleLineText.nullable(),
20
+ }).strict();
21
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
22
+ const RESERVED_MEMORY_MARKER = /NOWWORK:(?:MEMORY_ENTRY|MEMORY_INDEX):/iu;
23
+ export function validateLocalMemoryChange(change) {
24
+ const text = [change.title, change.summary, ...change.triggers, change.detailBody].join("\n");
25
+ if (containsLikelySecret(text))
26
+ throw new Error("local_memory_secret_candidate");
27
+ if (CONTROL_CHARACTER_PATTERN.test(text))
28
+ throw new Error("local_memory_control_character");
29
+ if (RESERVED_MEMORY_MARKER.test(text))
30
+ throw new Error("local_memory_reserved_marker");
31
+ if (change.operation === "create"
32
+ && (change.memoryId !== null || change.baseRevision !== null || change.supersedesMemoryId !== null)) {
33
+ throw new Error("local_memory_create_revision_invalid");
34
+ }
35
+ if (change.operation === "update"
36
+ && (change.memoryId === null || change.baseRevision === null || change.supersedesMemoryId !== null)) {
37
+ throw new Error("local_memory_update_revision_missing");
38
+ }
39
+ if (change.operation === "supersede"
40
+ && (change.memoryId !== null || change.baseRevision === null || change.supersedesMemoryId === null)) {
41
+ throw new Error("local_memory_supersede_revision_missing");
42
+ }
43
+ }
@@ -19,6 +19,14 @@ export function renderLocalMemoryIndexCard(entry) {
19
19
  + `- updated: ${entry.updated}\n`
20
20
  + (entry.reviewAfter === null ? "" : `- review_after: ${entry.reviewAfter}\n`);
21
21
  }
22
+ /** 写入和预演共用完整索引,预算包含起止标记与分隔换行。 */
23
+ export function renderGeneratedIndex(entries) {
24
+ const cards = entries
25
+ .filter(entry => PROJECTABLE_STATUSES.has(entry.status))
26
+ .map(renderLocalMemoryIndexCard)
27
+ .join("\n");
28
+ return `<!-- NOWWORK:MEMORY_INDEX:BEGIN schema=1 -->\n${cards}<!-- NOWWORK:MEMORY_INDEX:END -->`;
29
+ }
22
30
  function compareEntries(left, right) {
23
31
  const statusRank = (status) => status === "pinned" ? 0 : 1;
24
32
  const priorityRank = (priority) => (priority === "high" ? 0 : priority === "medium" ? 1 : 2);
@@ -0,0 +1,61 @@
1
+ const CommandSchema = z.object({
2
+ "agent-dir": z.string().min(1).refine(isAbsolute),
3
+ "dry-run": z.literal(true),
4
+ }).strict();
5
+ const USAGE = "只读记忆盘点与转换预演,不写文件,不支持 --apply。\n"
6
+ + "pnpm --filter @nowcrew/daemon exec tsx src/local-memory/inventory-cli.ts --agent-dir <单个Agent绝对路径> --dry-run\n"
7
+ + "报告包含原始记忆,需作为敏感本地资料保存;退出码0仅代表盘点及格式预演无阻塞,不代表批准迁移。\n";
8
+ export async function runMemoryInventoryCommand(args, io) {
9
+ if (args.length === 1 && args[0] === "--help") {
10
+ io.out(USAGE);
11
+ return 0;
12
+ }
13
+ let command;
14
+ try {
15
+ const parsed = parseArgs({ args, allowPositionals: false, options: {
16
+ "agent-dir": { type: "string" }, "dry-run": { type: "boolean" },
17
+ } });
18
+ command = CommandSchema.parse(parsed.values);
19
+ }
20
+ catch {
21
+ io.err(USAGE);
22
+ return 2;
23
+ }
24
+ try {
25
+ const scan = await scanMemorySources(command["agent-dir"]);
26
+ const scanComplete = scan.issues.length === 0;
27
+ const analysis = previewMemoryInventory(scan.sources, scanComplete);
28
+ const complete = scanComplete && !analysis.issues.some((issue) => issue.code === "missing_detail");
29
+ const issues = [...scan.issues, ...analysis.issues];
30
+ const referenced = new Set(analysis.entries.map((entry) => entry.sourceFields.file));
31
+ const files = scan.sources.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 }));
32
+ const report = {
33
+ schemaVersion: 1, mode: "dry-run", agentDir: scan.root, applySupported: false,
34
+ complete, scanComplete, requiresHumanReview: true,
35
+ coverage: ["MEMORY.md", "notes/**", ".crew/memory/pending/**", "根目录的 .memory-result.v1-*.json"],
36
+ limitations: ["不扫描历史执行目录;result清单仅覆盖Agent根目录。", "两遍摘要比对不等同跨文件原子快照,正式迁移仍需停止写入并重新核对。"],
37
+ limits: inventoryLimits, totalBytes: scan.totalBytes, files, ...analysis, issues,
38
+ pending: files.filter((file) => file.path.startsWith(".crew/memory/pending/")).map((file) => file.path),
39
+ orphanNotes: files.filter((file) => file.path.startsWith("notes/") && !referenced.has(file.path)).map((file) => file.path),
40
+ resultFiles: files.filter((file) => file.path.startsWith(".memory-result.v1-")).map((file) => file.path),
41
+ };
42
+ io.out(JSON.stringify(report, null, 2) + "\n");
43
+ return issues.length ? 1 : 0;
44
+ }
45
+ catch (error) {
46
+ const code = error instanceof Error ? error.code ?? error.message : "inventory_failed";
47
+ io.err(`记忆盘点失败:${code}\n`);
48
+ return 1;
49
+ }
50
+ }
51
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
52
+ process.exitCode = await runMemoryInventoryCommand(process.argv.slice(2), {
53
+ out: (text) => process.stdout.write(text), err: (text) => process.stderr.write(text),
54
+ });
55
+ }
56
+ import { isAbsolute, resolve } from "node:path";
57
+ import { pathToFileURL } from "node:url";
58
+ import { parseArgs } from "node:util";
59
+ import { z } from "zod";
60
+ import { inventoryLimits, scanMemorySources } from "./inventory-source.js";
61
+ import { previewMemoryInventory } from "./inventory-preview.js";