@nowcrew/daemon 0.6.88-beta.1 → 0.6.88-beta.11

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 (40) hide show
  1. package/README.md +23 -10
  2. package/dist/agent-memory/policy.js +3 -1
  3. package/dist/cloud-report-receipt.js +83 -0
  4. package/dist/cloud-report-uploader.js +9 -0
  5. package/dist/execution-protocol.js +1 -1
  6. package/dist/execution-runner.js +3 -2
  7. package/dist/list-models.js +2 -0
  8. package/dist/local-execution-lease.js +22 -0
  9. package/dist/local-executor.js +69 -64
  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 +57 -8
  22. package/dist/local-memory/runtime.js +61 -6
  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 +17 -56
  26. package/dist/native-thread-context.js +59 -0
  27. package/dist/prompt.js +15 -15
  28. package/dist/runner.js +14 -15
  29. package/dist/runtime-environment-telemetry.js +27 -0
  30. package/dist/runtimes/claude-session-start-hook.js +24 -0
  31. package/dist/runtimes/claude-thread-runner.js +266 -0
  32. package/dist/runtimes/claude.js +23 -4
  33. package/dist/runtimes/codex-app-server-runner.js +43 -4
  34. package/dist/runtimes/kimi-acp-runner.js +6 -2
  35. package/dist/runtimes/opencode-runner.js +7 -1
  36. package/dist/serve.js +7 -8
  37. package/dist/session.js +4 -0
  38. package/dist/supervised-runtime.js +11 -16
  39. package/dist/thread-input-delivery.js +217 -0
  40. package/package.json +2 -2
package/README.md CHANGED
@@ -242,26 +242,26 @@ The stable adapters keep prompts out of provider argv wherever the provider prot
242
242
 
243
243
  | Runtime | Transport | System/wake input | Native resume |
244
244
  | --- | --- | --- | --- |
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` |
245
+ | Claude | stream-json CLI | daemon-owned system prompt file + user input | CLI session id; thread-bound: matching native SessionStart proof + SDK initialization |
246
+ | Codex | app-server JSON-RPC over stdio | `developerInstructions` + turn input | `thread/resume`; thread-bound: returned id must match |
247
+ | Kimi | ACP over stdio | `session/prompt` | unbound: `session/resume` (`session/load` fallback); thread-bound: cold |
248
+ | Hermes | ACP over stdio | `session/prompt` | unbound: `session/resume` (`session/load` fallback); thread-bound: cold |
249
+ | OpenCode | `run --format json` | stdin | unbound: `--session`; thread-bound: cold |
250
250
  | DeepSeek Harness | ACP over stdio | `session/prompt` | none (fresh session per execution) |
251
251
 
252
252
  Kimi ACP currently requires a Kimi account even when the CLI has a working custom provider. Only for
253
253
  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
254
+ CLI; only unbound executions can carry a saved native session id. Unrelated ACP failures stay failures. The CLI fallback is
255
255
  subject to the Windows UTF-16 argv guard, while protocol-v1 itself remains disabled on Windows until
256
256
  durable Job Object ownership is available.
257
257
 
258
258
  Hermes uses only ACP capabilities advertised by the installed CLI. Model and effort choices come from
259
259
  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.
260
+ machine-local. For unbound executions, a missing resumed ACP session is retried once as a fresh session.
261
261
 
262
262
  OpenCode receives the prompt through stdin and runs with both cwd and `PWD` anchored to the Agent
263
263
  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,
264
+ compatibility fallback). For unbound executions, a missing resumed session is retried once without `--session`; malformed,
265
265
  empty, incomplete, or explicitly failed JSON streams remain failures. NowWork never writes
266
266
  `opencode.json` or replaces `OPENCODE_CONFIG_CONTENT`. The daemon advertises OpenCode as executable
267
267
  only when `opencode run --help` exposes `--format`, `--dangerously-skip-permissions`, `--dir`,
@@ -291,8 +291,12 @@ uses fresh sessions and committed text; it does not claim resume, images, MCP, r
291
291
  or transcript support. Agent environment variables cannot override `DEEPSEEK_*`, `DSH_*`, or
292
292
  `NOWCREW_DEEPSEEK_HARNESS_CONFIG`; those values always come from the daemon service environment.
293
293
 
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
294
+ Unbound Codex, Kimi, Hermes, and OpenCode sessions retain the same configured warm/budget policy and keyed
295
+ lease as Claude through protocol v1. Thread-bound Claude/Codex sessions keep their native session across
296
+ warm-cache expiry, token estimates, and turn counts; verified restoration receives only accepted-cursor
297
+ deltas. New sessions and runtimes without verified restoration receive complete current-thread history
298
+ before the business turn. See the [thread context contract](../docs/thread-scoped-native-context.md).
299
+ A first-progress watchdog covers a provider that accepts a turn but remains
296
300
  semantically silent; after the first semantic event, the execution's configured total timeout remains
297
301
  authoritative so a legitimate long-running tool is not killed for quiet output.
298
302
  DeepSeek Harness is execution-v1-only and intentionally skips native session persistence.
@@ -354,6 +358,15 @@ Runtime launch, which recovers from a missed notification or daemon restart. Ski
354
358
  are visible through the symlink immediately; adding, deleting, renaming, or changing frontmatter requires
355
359
  a rescan. Web descriptions remain at the last inventory value until that rescan.
356
360
 
361
+ ## 目录投影测试的平台范围
362
+
363
+ `test/directory-projection.test.ts` 在所有平台运行,覆盖路径检查、复制发布、只读权限请求及失败恢复。
364
+ 其受控文件系统夹具记录目录 `chmod` 请求,但保留宿主目录的写权限,避免把 macOS 的目录重命名规则当作 Windows 行为。
365
+ 生产代码的只读保护不受此夹具影响。
366
+
367
+ `test/directory-projection.windows.test.ts` 仅在 Windows 上运行,使用真实文件系统验证只读目录发布与文件保护。
368
+ 非 Windows 的全量测试会明确跳过该文件,不能据此宣称 Windows 原生行为已验证。
369
+
357
370
  ## Reliability
358
371
 
359
372
  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
  }
@@ -0,0 +1,83 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ const MAX_REPORT_BYTES = 32 * 1024 * 1024;
5
+ export function reportDeliveryContext(executionId, runDir, facts) {
6
+ return {
7
+ version: 1,
8
+ provider: facts.provider,
9
+ executionId,
10
+ receiptFile: join(runDir, `.qa-report-delivery-${createHash("sha256").update(executionId).digest("hex")}.json`),
11
+ ...(facts.ossUploadScript === undefined ? {} : { script: facts.ossUploadScript }),
12
+ };
13
+ }
14
+ function inside(root, file) {
15
+ const part = relative(root, file);
16
+ return part !== ".." && !part.startsWith("../") && !part.startsWith("..\\") && !isAbsolute(part);
17
+ }
18
+ export async function verifiedReportReceipt(input) {
19
+ if (!input.receiptFile || !input.answer)
20
+ return false;
21
+ try {
22
+ const stat = await lstat(input.receiptFile);
23
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
24
+ return false;
25
+ const receipt = JSON.parse(await readFile(input.receiptFile, "utf8"));
26
+ if (!receipt || typeof receipt !== "object")
27
+ return false;
28
+ const r = receipt;
29
+ if (r.version !== 1 || r.executionId !== input.executionId || r.evidenceComplete !== true
30
+ || !Array.isArray(r.runIds) || r.runIds.length === 0 || r.runIds.length > 100
31
+ || r.runIds.some(id => typeof id !== "string" || !id || id.length > 256)
32
+ || typeof r.file !== "string" || !isAbsolute(r.file) || !r.file.endsWith(".html")
33
+ || typeof r.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(r.sha256)
34
+ || typeof r.url !== "string")
35
+ return false;
36
+ const url = new URL(r.url);
37
+ if (url.protocol !== "https:" || url.hostname !== "ai-claw-in.nowcoder.com"
38
+ || !url.pathname.endsWith(".html") || url.username || url.password || url.search || url.hash)
39
+ return false;
40
+ const answerUrls = input.answer.match(/https:\/\/ai-claw-in\.nowcoder\.com\/[^\s<>"')\]]+/g) ?? [];
41
+ if (!answerUrls.includes(url.href))
42
+ return false;
43
+ const file = await realpath(r.file);
44
+ const roots = await Promise.all([input.root, input.qaReportRoot ?? join(input.root, "qa-report-server-v2")]
45
+ .map(root => realpath(resolve(root)).catch(() => null)));
46
+ if (!roots.some(root => root !== null && inside(root, file)))
47
+ return false;
48
+ const fileStat = await lstat(file);
49
+ if (!fileStat.isFile() || fileStat.size > MAX_REPORT_BYTES)
50
+ return false;
51
+ const expectedHash = createHash("sha256").update(await readFile(file)).digest("hex");
52
+ if (expectedHash !== r.sha256)
53
+ return false;
54
+ const response = await fetch(url.href, { redirect: "error", signal: AbortSignal.timeout(15_000) });
55
+ if (!response.ok || !/^text\/html(?:;|$)/i.test(response.headers.get("content-type") ?? "")
56
+ || /attachment/i.test(response.headers.get("content-disposition") ?? "") || !response.body) {
57
+ await response.body?.cancel();
58
+ return false;
59
+ }
60
+ const reader = response.body.getReader();
61
+ const hash = createHash("sha256");
62
+ let bytes = 0;
63
+ try {
64
+ while (true) {
65
+ const chunk = await reader.read();
66
+ if (chunk.done)
67
+ break;
68
+ bytes += chunk.value.byteLength;
69
+ if (bytes > MAX_REPORT_BYTES)
70
+ return false;
71
+ hash.update(chunk.value);
72
+ }
73
+ return bytes === fileStat.size && hash.digest("hex") === expectedHash;
74
+ }
75
+ finally {
76
+ await reader.cancel().catch(() => undefined);
77
+ reader.releaseLock();
78
+ }
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
@@ -4,6 +4,7 @@ import { extname, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { cloudReportUploadFacts } from "./cloud-report-upload.js";
6
6
  import { dslog } from "./slog.js";
7
+ import { verifiedReportReceipt } from "./cloud-report-receipt.js";
7
8
  const execFileP = promisify(execFile);
8
9
  const REPORT_FILENAMES = new Set(["report.md", "result.json"]);
9
10
  const MAX_DEPTH = 8;
@@ -166,6 +167,14 @@ export async function deliverCloudReports(input) {
166
167
  if (!input.facts.enabled || input.facts.ossUploadScript === undefined || input.before === null) {
167
168
  return { finalText: input.finalText, externalAnswer: input.externalAnswer };
168
169
  }
170
+ if (input.captureFinal && await verifiedReportReceipt({
171
+ ...input, answer: input.externalAnswer ?? input.finalText,
172
+ })) {
173
+ dslog("cloud_report_upload.already_delivered", "本次执行的 HTML 交付收据及内容已验证,跳过重复上传", {
174
+ execution_id: input.executionId, agent_handle: input.agentHandle,
175
+ });
176
+ return { finalText: input.finalText, externalAnswer: input.externalAnswer };
177
+ }
169
178
  try {
170
179
  const result = await (input.upload ?? uploadCloudReports)({
171
180
  script: input.facts.ossUploadScript,
@@ -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,
@@ -844,6 +844,7 @@ export async function runExecution(config, input, dependencies) {
844
844
  executionId: spec.executionId,
845
845
  handle: spec.agent.handle,
846
846
  channelId: spec.context.channelId,
847
+ ...(spec.context.threadId === undefined ? {} : { threadId: spec.context.threadId }),
847
848
  keyMode: "opaque",
848
849
  taskKey: spec.workspace.taskKey,
849
850
  ...(spec.workspace.resumeKey === undefined ? {} : { resumeKey: spec.workspace.resumeKey }),
@@ -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
+ }
@@ -12,8 +12,14 @@ import { spawnKimi, KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
12
12
  import { applyDeepSeekHarnessMachineEnv, applyProviderEnv, providerFingerprint, } from "./provider-env.js";
13
13
  import { augmentedPath } from "./runtime-path.js";
14
14
  import { deliverCloudReports, prepareCloudReportExecution } from "./cloud-report-uploader.js";
15
+ import { reportDeliveryContext } from "./cloud-report-receipt.js";
15
16
  import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
16
17
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
18
+ import { NativeThreadContext, isolateMaintenanceSession } from "./native-thread-context.js";
19
+ import { prepareThreadDelivery } from "./thread-input-delivery.js";
20
+ import { resolveMemoryPruneWorkLog, workLogSha256 } from "./local-memory/work-log.js";
21
+ import { withKeyedLease } from "./local-execution-lease.js";
22
+ import { withWorkLogExecution, withWorkLogCommit, workLogExecutionKey } from "./local-memory/work-log-execution.js";
17
23
  import { providerErrorFromExit } from "./provider-error.js";
18
24
  import { createConsoleFormatter } from "./console-formatter.js";
19
25
  import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
@@ -34,7 +40,8 @@ import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPr
34
40
  import { createLocalMemoryTelemetry, logLocalMemoryContextPrepareFailure, logLocalMemoryDiagnosticsFailure, } from "./local-memory-telemetry.js";
35
41
  import { CodexStartupStageParser } from "./codex-startup-stage.js";
36
42
  import { formatRuntimeStartupError } from "./runtime-startup-error.js";
37
- import { machinePackageManagerEnv, packageManagerEnvFacts } from "./package-manager-env.js";
43
+ import { machinePackageManagerEnv } from "./package-manager-env.js";
44
+ import { logRuntimeEnvironmentContract } from "./runtime-environment-telemetry.js";
38
45
  import { sanitizeEnvVars } from "./runtime-env.js";
39
46
  import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, } from "./local-memory/runtime.js";
40
47
  import { prepareLocalMemoryPrompt } from "./local-memory/prompt.js";
@@ -106,6 +113,9 @@ async function launchLegacyRuntime(request) {
106
113
  if (request.runtime === "deepseek-harness") {
107
114
  throw new Error("DeepSeek Harness requires execution protocol v1");
108
115
  }
116
+ if (request.runtime !== "claude")
117
+ request = { ...request, wakePrompt: (await prepareThreadDelivery({ env: request.env,
118
+ resumed: false, sessionId: "unverified-cold-legacy", wakePrompt: request.wakePrompt })).prompt };
109
119
  const common = {
110
120
  bin: request.bin,
111
121
  cwd: request.cwd,
@@ -152,27 +162,12 @@ async function launchLegacyRuntime(request) {
152
162
  function resolvePrompt(prompt, context) {
153
163
  return typeof prompt === "string" ? prompt : prompt(context);
154
164
  }
155
- const nativeSessionLeaseTails = new Map();
156
165
  const activeMemoryPrunes = new Map();
157
- async function withKeyedLease(key, operation, cancellation) {
158
- const predecessor = nativeSessionLeaseTails.get(key) ?? Promise.resolve();
159
- let release;
160
- const current = new Promise((resolve) => { release = resolve; });
161
- const tail = predecessor.then(() => current);
162
- nativeSessionLeaseTails.set(key, tail);
163
- try {
164
- await awaitWithCancellation(predecessor, cancellation);
165
- return await operation();
166
- }
167
- finally {
168
- release();
169
- void tail.then(() => {
170
- if (nativeSessionLeaseTails.get(key) === tail)
171
- nativeSessionLeaseTails.delete(key);
172
- });
173
- }
174
- }
175
166
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
167
+ input = isolateMaintenanceSession(input);
168
+ return withWorkLogExecution(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), () => executeLocalWithSession(input, callbacks, dependencies), dependencies.cancellation);
169
+ }
170
+ async function executeLocalWithSession(input, callbacks, dependencies) {
176
171
  const versionedProjectSkills = input.projectSkills !== undefined
177
172
  && !Array.isArray(input.projectSkills);
178
173
  if (versionedProjectSkills && (input.runtime.name !== "codex" && input.runtime.name !== "claude")) {
@@ -253,6 +248,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
253
248
  let localMemoryResultPath = null;
254
249
  let executionWorkspace = workspace;
255
250
  let localMemoryTelemetry = null;
251
+ let nativeThreadContext = null;
256
252
  const inheritedEnv = { ...process.env };
257
253
  const reportRoots = {
258
254
  root: workspace.dir,
@@ -260,6 +256,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
260
256
  };
261
257
  const cloudReportPreparation = await prepareCloudReportExecution({ env: inheritedEnv, ...reportRoots, executionId: input.executionId, agentHandle: input.handle });
262
258
  const { facts: cloudReportUpload, snapshot: reportSnapshot } = cloudReportPreparation;
259
+ const reportContext = reportDeliveryContext(input.executionId, workspace.runDir, cloudReportUpload);
263
260
  for (const key of Object.keys(inheritedEnv)) {
264
261
  if (key.startsWith("CREW_AGENT_MEMORY_") || ["CREW_CLOUD_PROVIDER", "CREW_OSS_UPLOAD_SCRIPT", "CREW_OSS_REPORT_UPLOAD"].includes(key))
265
262
  delete inheritedEnv[key];
@@ -291,12 +288,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
291
288
  : null;
292
289
  const currentPrior = storedCurrentPrior !== null
293
290
  && storedCurrentPrior.sessionContextFingerprint === input.sessionContextFingerprint
291
+ && storedCurrentPrior.runtime === runtime.name
292
+ && storedCurrentPrior.threadRoot === input.resumeKey
294
293
  ? storedCurrentPrior
295
294
  : null;
296
- // Interactive V1 uses collision-resistant session directories, while the
297
- // older legacy path stored metadata under tasks/<safe thread key>. Import
298
- // that metadata once so an existing thread can cross the protocol boundary
299
- // without losing its native runtime conversation.
295
+ // Import compatible legacy metadata across the protocol boundary.
300
296
  const legacyRunDir = input.session.enabled
301
297
  && supportsNativeResume
302
298
  && input.keyMode === "opaque"
@@ -312,7 +308,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
312
308
  const legacyPrior = legacyRunDir === null || (currentPrior !== null && !legacyCwdPinned)
313
309
  ? null
314
310
  : await readSession(legacyRunDir);
315
- const prior = currentPrior ?? legacyPrior;
311
+ const compatibleLegacyPrior = legacyPrior?.runtime === runtime.name && legacyPrior?.threadRoot === input.resumeKey
312
+ ? legacyPrior : null;
313
+ const prior = currentPrior ?? compatibleLegacyPrior;
316
314
  const sessionExists = workspace.sessionResume || legacyPrior !== null;
317
315
  const usesLegacyCwd = legacyRunDir !== null
318
316
  && (legacyCwdPinned || (currentPrior === null && legacyPrior !== null));
@@ -333,11 +331,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
333
331
  ? prior?.model ?? currentModel
334
332
  : currentModel;
335
333
  const resumeSessionId = supportsNativeResume && sessionExists
336
- ? pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, input.session.budgetTokens, input.session.maxTurns, providerFp)
334
+ ? 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)
337
335
  : null;
338
336
  const resuming = resumeSessionId !== null;
339
337
  const rotatedForBudget = !resuming && supportsNativeResume && sessionExists
340
- && pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
338
+ && pickResumeId(prior, Date.now(), input.resumeKey ? 0 : input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
341
339
  const nearBudget = resuming && isNearBudget(prior, input.session.softTokens);
342
340
  const rotated = Boolean(workspace.agentSessionId && sessionExists && !resuming);
343
341
  // Codex and Kimi choose their own id on a fresh start, so the native id persisted in
@@ -346,7 +344,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
346
344
  ? await rotateAgentSession(workspace.sessionDir)
347
345
  : workspace.agentSessionId);
348
346
  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 }) });
349
- const promptContext = {
347
+ let promptContext = {
350
348
  workspace: executionWorkspace,
351
349
  resuming,
352
350
  rotatedForBudget,
@@ -385,8 +383,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
385
383
  }
386
384
  }
387
385
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
388
- const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
389
- memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
386
+ let baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
387
+ memoryPruneTraceId = parseMemoryPruneTraceId(baseWakePrompt);
388
+ if (memoryPruneTraceId !== null) {
389
+ executionWorkspace = await resolveMemoryPruneWorkLog(executionWorkspace, input);
390
+ promptContext = { ...promptContext, workspace: executionWorkspace };
391
+ baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
392
+ }
390
393
  if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
391
394
  && isLocalMemoryV2Eligible({
392
395
  enabledForHandle: true,
@@ -399,12 +402,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
399
402
  }
400
403
  const memoryPrompt = await prepareLocalMemoryPrompt({
401
404
  agentDir: executionWorkspace.dir, executionId: input.executionId, agentHandle: input.handle,
402
- enabled: input.localMemoryV2Enabled === true, query: wakePrompt,
405
+ enabled: input.localMemoryV2Enabled === true, query: baseWakePrompt,
403
406
  resultPath: localMemoryResultPath, maxBytes: input.maxSystemPromptBytes,
404
407
  renderBase: (reservedPromptBytes) => resolvePrompt(input.systemPrompt, { ...promptContext, reservedPromptBytes }),
405
408
  });
406
409
  localMemoryResultPath = memoryPrompt.resultPath;
407
410
  const systemPrompt = memoryPrompt.systemPrompt;
411
+ const wakePrompt = baseWakePrompt + memoryPrompt.wakePromptSuffix;
408
412
  if (!isolatedMaintenance)
409
413
  try {
410
414
  localMemoryTelemetry = createLocalMemoryTelemetry({
@@ -463,6 +467,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
463
467
  }
464
468
  memoryPruneFailurePhase = "runtime_prepare";
465
469
  const crewHome = isolatedMaintenance ? executionWorkspace.runDir : workspace.dir;
470
+ const contextThreadId = input.threadId ?? input.resumeKey;
471
+ if (contextThreadId && input.maintenancePurpose === undefined) {
472
+ nativeThreadContext = new NativeThreadContext(join(executionWorkspace.runDir, `.crew-context-${input.executionId}.json`), contextThreadId, runtime.name, resumeSessionId, prior);
473
+ await nativeThreadContext.initialize();
474
+ }
466
475
  const baseEnv = {
467
476
  ...inheritedEnv,
468
477
  ...sanitizeEnvVars(providerConfig.envVars),
@@ -475,6 +484,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
475
484
  CREW_TOKEN: input.launch.token,
476
485
  CREW_CHANNEL: input.channelId,
477
486
  CREW_HOME: crewHome,
487
+ // Empty values override any stale inherited hints from an enclosing runtime.
488
+ CREW_THREAD_ROOT: contextThreadId ?? "",
489
+ CREW_THREAD_CONTEXT_FILE: nativeThreadContext?.path ?? "",
478
490
  // Keep the daemon-selected storage root authoritative for runtimes.
479
491
  // In profile-backed NowWork sessions the daemon config may differ from
480
492
  // the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
@@ -485,6 +497,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
485
497
  // Expose only the training root; ability-specific workspace layout stays
486
498
  // owned by the training package rather than the NowWork daemon.
487
499
  QA_RUNTIME_ROOT: join(crewHome, "training"),
500
+ QA_REPORT_DELIVERY_CONTEXT: JSON.stringify(reportContext),
488
501
  CREW_TASK_DIR: executionWorkspace.runDir,
489
502
  CREW_TASK_LOG: executionWorkspace.workLogPath,
490
503
  ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
@@ -504,30 +517,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
504
517
  const childEnv = runtime.name === "deepseek-harness"
505
518
  ? applyDeepSeekHarnessMachineEnv(providerEnv, inheritedEnv)
506
519
  : providerEnv;
507
- dslog("runtime.environment_contract", "Runtime 子进程环境契约", {
508
- execution_id: input.executionId,
509
- agent_handle: input.handle,
510
- runtime: runtime.name,
511
- qa_runtime_root: childEnv.QA_RUNTIME_ROOT ?? null,
512
- qa_runtime_root_present: childEnv.QA_RUNTIME_ROOT !== undefined,
513
- inherited_qa_runtime_root_present: inheritedEnv.QA_RUNTIME_ROOT !== undefined,
514
- ...packageManagerEnvFacts(childEnv),
515
- training_root: join(crewHome, "training"),
516
- cloud_provider: cloudReportUpload.provider,
517
- oss_report_upload_enabled: cloudReportUpload.enabled,
518
- project_context_present: input.projectContext !== undefined,
519
- project_ids: JSON.stringify(input.projectContext === undefined
520
- ? []
521
- : [
522
- ...(input.projectContext.primary === undefined
523
- ? []
524
- : [input.projectContext.primary.projectId]),
525
- ...input.projectContext.secondary.map((project) => project.projectId),
526
- ]),
527
- ability_release_present: input.abilityRelease !== undefined,
528
- ability_release_id: input.abilityRelease?.releaseId,
529
- ability_root_commit: input.abilityRelease?.rootCommit,
530
- });
520
+ logRuntimeEnvironmentContract(input, childEnv, inheritedEnv, crewHome, cloudReportUpload);
531
521
  const warn = dependencies.warnRuntime ?? warnRuntimeLocally;
532
522
  const launchRuntime = dependencies.launchRuntime ?? (async (launchReq) => {
533
523
  if (launchReq.runtime === "codex" && launchReq.codexCollaborationTools === true) {
@@ -544,7 +534,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
544
534
  const effectiveSystemPrompt = abilityContext === undefined
545
535
  ? systemPrompt
546
536
  : `${systemPrompt}\n\n${abilityContext.prompt}`;
547
- if (Buffer.byteLength(effectiveSystemPrompt, "utf8") > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
537
+ if (Buffer.byteLength(effectiveSystemPrompt, "utf8") + Buffer.byteLength(memoryPrompt.wakePromptSuffix, "utf8")
538
+ > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
548
539
  throw new Error("ability_runtime_context_size_exceeded");
549
540
  }
550
541
  memoryPruneFailurePhase = "prompt_write";
@@ -820,6 +811,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
820
811
  const event = parseLine(line);
821
812
  if (!event)
822
813
  return;
814
+ nativeThreadContext?.observe(event);
823
815
  localMemoryTelemetry?.observe(event);
824
816
  if (isRuntimeReadyEvent(runtime.name, event))
825
817
  markRuntimeReady();
@@ -936,22 +928,30 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
936
928
  localMemoryTelemetry?.markRuntimeExited(exitCode);
937
929
  memoryPruneRuntimeExitCode = exitCode;
938
930
  if (localMemoryResultPath !== null) {
939
- const committed = await commitLocalMemoryResultAfterExit({
940
- resultPath: localMemoryResultPath,
931
+ memoryPruneFailurePhase = "memory_commit";
932
+ const resultPath = localMemoryResultPath;
933
+ const commit = () => commitLocalMemoryResultAfterExit({
934
+ resultPath,
941
935
  agentDir: executionWorkspace.dir,
942
936
  executionId: input.executionId,
943
937
  pruneTraceId: memoryPruneTraceId,
944
938
  exitCode,
939
+ workLogPath: executionWorkspace.workLogPath,
940
+ expectedWorkLogSha256: workLogSha256(executionWorkspace.workLog),
945
941
  });
946
- if (committed.status === "failed") {
947
- dslog("local_memory.commit_failed", "结构化本地记忆候选未落地,保留结果文件", {
948
- level: "WARN",
942
+ const committed = exitCode !== 0 ? await commit() : await withWorkLogCommit(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), commit, dependencies.cancellation);
943
+ if (committed.status === "failed" || committed.status === "missing") {
944
+ const reason = committed.error ?? "local_memory_result_missing";
945
+ dslog("local_memory.commit_failed", "结构化本地记忆提交或清理未完成,保留可用恢复材料", {
946
+ level: "ERROR",
949
947
  execution_id: input.executionId,
950
948
  agent_handle: input.handle,
951
- error_code: committed.error,
949
+ error_code: reason,
952
950
  result_path: localMemoryResultPath,
953
951
  });
952
+ throw new Error(`local_memory_commit_failed: ${reason}`);
954
953
  }
954
+ memoryPruneFailurePhase = "runtime_finalize";
955
955
  }
956
956
  const errorTail = [
957
957
  stderrTail.trim(),
@@ -973,9 +973,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
973
973
  ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
974
974
  : (resuming ? prior?.contextTokens : undefined);
975
975
  await writeSession(workspace.sessionDir, {
976
+ runtime: runtime.name,
977
+ ...(input.resumeKey === undefined ? {} : { threadRoot: input.resumeKey }),
978
+ ...(exitCode === 0 ? await nativeThreadContext?.checkpoint() ?? {} : {}),
976
979
  sessionId,
977
980
  lastRunAt: Date.now(),
978
- turns: resuming ? (prior?.turns ?? 0) + 1 : 1,
981
+ turns: nativeThreadContext?.confirmed ? (prior?.turns ?? 0) + 1 : 1,
979
982
  model: currentModel,
980
983
  providerFingerprint: providerFp,
981
984
  ...(input.sessionContextFingerprint === undefined
@@ -991,6 +994,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
991
994
  const cloudReportResult = exitCode === 0
992
995
  ? await deliverCloudReports({
993
996
  facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText,
997
+ receiptFile: reportContext.receiptFile,
994
998
  externalAnswer: externalOutput.lastClosedBlock
995
999
  ?? (finalText === null ? null : extractExternalAnswer(finalText)),
996
1000
  captureFinal: input.captureFinal, executionId: input.executionId, agentHandle: input.handle,
@@ -1008,7 +1012,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1008
1012
  ...(usage === undefined ? {} : { usage }),
1009
1013
  model: observedModel,
1010
1014
  runtime: runtime.name,
1011
- resumed: resuming,
1015
+ resumed: nativeThreadContext?.confirmed ?? false,
1012
1016
  sessionId,
1013
1017
  errorMessage: errorTail || null,
1014
1018
  ...(providerError === undefined ? {} : { providerError }),
@@ -1020,6 +1024,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1020
1024
  };
1021
1025
  }
1022
1026
  finally {
1027
+ await nativeThreadContext?.cleanup().catch(() => undefined);
1023
1028
  startupReservationState.current?.release();
1024
1029
  try {
1025
1030
  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);