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

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.
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",
@@ -12,8 +12,11 @@ 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";
17
20
  import { providerErrorFromExit } from "./provider-error.js";
18
21
  import { createConsoleFormatter } from "./console-formatter.js";
19
22
  import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
@@ -34,7 +37,8 @@ import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPr
34
37
  import { createLocalMemoryTelemetry, logLocalMemoryContextPrepareFailure, logLocalMemoryDiagnosticsFailure, } from "./local-memory-telemetry.js";
35
38
  import { CodexStartupStageParser } from "./codex-startup-stage.js";
36
39
  import { formatRuntimeStartupError } from "./runtime-startup-error.js";
37
- import { machinePackageManagerEnv, packageManagerEnvFacts } from "./package-manager-env.js";
40
+ import { machinePackageManagerEnv } from "./package-manager-env.js";
41
+ import { logRuntimeEnvironmentContract } from "./runtime-environment-telemetry.js";
38
42
  import { sanitizeEnvVars } from "./runtime-env.js";
39
43
  import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, } from "./local-memory/runtime.js";
40
44
  import { prepareLocalMemoryPrompt } from "./local-memory/prompt.js";
@@ -106,6 +110,9 @@ async function launchLegacyRuntime(request) {
106
110
  if (request.runtime === "deepseek-harness") {
107
111
  throw new Error("DeepSeek Harness requires execution protocol v1");
108
112
  }
113
+ if (request.runtime !== "claude")
114
+ request = { ...request, wakePrompt: (await prepareThreadDelivery({ env: request.env,
115
+ resumed: false, sessionId: "unverified-cold-legacy", wakePrompt: request.wakePrompt })).prompt };
109
116
  const common = {
110
117
  bin: request.bin,
111
118
  cwd: request.cwd,
@@ -173,6 +180,7 @@ async function withKeyedLease(key, operation, cancellation) {
173
180
  }
174
181
  }
175
182
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
183
+ input = isolateMaintenanceSession(input);
176
184
  const versionedProjectSkills = input.projectSkills !== undefined
177
185
  && !Array.isArray(input.projectSkills);
178
186
  if (versionedProjectSkills && (input.runtime.name !== "codex" && input.runtime.name !== "claude")) {
@@ -253,6 +261,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
253
261
  let localMemoryResultPath = null;
254
262
  let executionWorkspace = workspace;
255
263
  let localMemoryTelemetry = null;
264
+ let nativeThreadContext = null;
256
265
  const inheritedEnv = { ...process.env };
257
266
  const reportRoots = {
258
267
  root: workspace.dir,
@@ -260,6 +269,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
260
269
  };
261
270
  const cloudReportPreparation = await prepareCloudReportExecution({ env: inheritedEnv, ...reportRoots, executionId: input.executionId, agentHandle: input.handle });
262
271
  const { facts: cloudReportUpload, snapshot: reportSnapshot } = cloudReportPreparation;
272
+ const reportContext = reportDeliveryContext(input.executionId, workspace.runDir, cloudReportUpload);
263
273
  for (const key of Object.keys(inheritedEnv)) {
264
274
  if (key.startsWith("CREW_AGENT_MEMORY_") || ["CREW_CLOUD_PROVIDER", "CREW_OSS_UPLOAD_SCRIPT", "CREW_OSS_REPORT_UPLOAD"].includes(key))
265
275
  delete inheritedEnv[key];
@@ -291,12 +301,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
291
301
  : null;
292
302
  const currentPrior = storedCurrentPrior !== null
293
303
  && storedCurrentPrior.sessionContextFingerprint === input.sessionContextFingerprint
304
+ && storedCurrentPrior.runtime === runtime.name
305
+ && storedCurrentPrior.threadRoot === input.resumeKey
294
306
  ? storedCurrentPrior
295
307
  : 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.
308
+ // Import compatible legacy metadata across the protocol boundary.
300
309
  const legacyRunDir = input.session.enabled
301
310
  && supportsNativeResume
302
311
  && input.keyMode === "opaque"
@@ -312,7 +321,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
312
321
  const legacyPrior = legacyRunDir === null || (currentPrior !== null && !legacyCwdPinned)
313
322
  ? null
314
323
  : await readSession(legacyRunDir);
315
- const prior = currentPrior ?? legacyPrior;
324
+ const compatibleLegacyPrior = legacyPrior?.runtime === runtime.name && legacyPrior?.threadRoot === input.resumeKey
325
+ ? legacyPrior : null;
326
+ const prior = currentPrior ?? compatibleLegacyPrior;
316
327
  const sessionExists = workspace.sessionResume || legacyPrior !== null;
317
328
  const usesLegacyCwd = legacyRunDir !== null
318
329
  && (legacyCwdPinned || (currentPrior === null && legacyPrior !== null));
@@ -333,11 +344,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
333
344
  ? prior?.model ?? currentModel
334
345
  : currentModel;
335
346
  const resumeSessionId = supportsNativeResume && sessionExists
336
- ? pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, input.session.budgetTokens, input.session.maxTurns, providerFp)
347
+ ? 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
348
  : null;
338
349
  const resuming = resumeSessionId !== null;
339
350
  const rotatedForBudget = !resuming && supportsNativeResume && sessionExists
340
- && pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
351
+ && pickResumeId(prior, Date.now(), input.resumeKey ? 0 : input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
341
352
  const nearBudget = resuming && isNearBudget(prior, input.session.softTokens);
342
353
  const rotated = Boolean(workspace.agentSessionId && sessionExists && !resuming);
343
354
  // Codex and Kimi choose their own id on a fresh start, so the native id persisted in
@@ -385,8 +396,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
385
396
  }
386
397
  }
387
398
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
388
- const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
389
- memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
399
+ const baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
400
+ memoryPruneTraceId = parseMemoryPruneTraceId(baseWakePrompt);
390
401
  if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
391
402
  && isLocalMemoryV2Eligible({
392
403
  enabledForHandle: true,
@@ -399,12 +410,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
399
410
  }
400
411
  const memoryPrompt = await prepareLocalMemoryPrompt({
401
412
  agentDir: executionWorkspace.dir, executionId: input.executionId, agentHandle: input.handle,
402
- enabled: input.localMemoryV2Enabled === true, query: wakePrompt,
413
+ enabled: input.localMemoryV2Enabled === true, query: baseWakePrompt,
403
414
  resultPath: localMemoryResultPath, maxBytes: input.maxSystemPromptBytes,
404
415
  renderBase: (reservedPromptBytes) => resolvePrompt(input.systemPrompt, { ...promptContext, reservedPromptBytes }),
405
416
  });
406
417
  localMemoryResultPath = memoryPrompt.resultPath;
407
418
  const systemPrompt = memoryPrompt.systemPrompt;
419
+ const wakePrompt = baseWakePrompt + memoryPrompt.wakePromptSuffix;
408
420
  if (!isolatedMaintenance)
409
421
  try {
410
422
  localMemoryTelemetry = createLocalMemoryTelemetry({
@@ -463,6 +475,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
463
475
  }
464
476
  memoryPruneFailurePhase = "runtime_prepare";
465
477
  const crewHome = isolatedMaintenance ? executionWorkspace.runDir : workspace.dir;
478
+ const contextThreadId = input.threadId ?? input.resumeKey;
479
+ if (contextThreadId && input.maintenancePurpose === undefined) {
480
+ nativeThreadContext = new NativeThreadContext(join(executionWorkspace.runDir, `.crew-context-${input.executionId}.json`), contextThreadId, runtime.name, resumeSessionId, prior);
481
+ await nativeThreadContext.initialize();
482
+ }
466
483
  const baseEnv = {
467
484
  ...inheritedEnv,
468
485
  ...sanitizeEnvVars(providerConfig.envVars),
@@ -475,6 +492,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
475
492
  CREW_TOKEN: input.launch.token,
476
493
  CREW_CHANNEL: input.channelId,
477
494
  CREW_HOME: crewHome,
495
+ // Empty values override any stale inherited hints from an enclosing runtime.
496
+ CREW_THREAD_ROOT: contextThreadId ?? "",
497
+ CREW_THREAD_CONTEXT_FILE: nativeThreadContext?.path ?? "",
478
498
  // Keep the daemon-selected storage root authoritative for runtimes.
479
499
  // In profile-backed NowWork sessions the daemon config may differ from
480
500
  // the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
@@ -485,6 +505,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
485
505
  // Expose only the training root; ability-specific workspace layout stays
486
506
  // owned by the training package rather than the NowWork daemon.
487
507
  QA_RUNTIME_ROOT: join(crewHome, "training"),
508
+ QA_REPORT_DELIVERY_CONTEXT: JSON.stringify(reportContext),
488
509
  CREW_TASK_DIR: executionWorkspace.runDir,
489
510
  CREW_TASK_LOG: executionWorkspace.workLogPath,
490
511
  ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
@@ -504,30 +525,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
504
525
  const childEnv = runtime.name === "deepseek-harness"
505
526
  ? applyDeepSeekHarnessMachineEnv(providerEnv, inheritedEnv)
506
527
  : 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
- });
528
+ logRuntimeEnvironmentContract(input, childEnv, inheritedEnv, crewHome, cloudReportUpload);
531
529
  const warn = dependencies.warnRuntime ?? warnRuntimeLocally;
532
530
  const launchRuntime = dependencies.launchRuntime ?? (async (launchReq) => {
533
531
  if (launchReq.runtime === "codex" && launchReq.codexCollaborationTools === true) {
@@ -544,7 +542,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
544
542
  const effectiveSystemPrompt = abilityContext === undefined
545
543
  ? systemPrompt
546
544
  : `${systemPrompt}\n\n${abilityContext.prompt}`;
547
- if (Buffer.byteLength(effectiveSystemPrompt, "utf8") > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
545
+ if (Buffer.byteLength(effectiveSystemPrompt, "utf8") + Buffer.byteLength(memoryPrompt.wakePromptSuffix, "utf8")
546
+ > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
548
547
  throw new Error("ability_runtime_context_size_exceeded");
549
548
  }
550
549
  memoryPruneFailurePhase = "prompt_write";
@@ -820,6 +819,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
820
819
  const event = parseLine(line);
821
820
  if (!event)
822
821
  return;
822
+ nativeThreadContext?.observe(event);
823
823
  localMemoryTelemetry?.observe(event);
824
824
  if (isRuntimeReadyEvent(runtime.name, event))
825
825
  markRuntimeReady();
@@ -936,22 +936,27 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
936
936
  localMemoryTelemetry?.markRuntimeExited(exitCode);
937
937
  memoryPruneRuntimeExitCode = exitCode;
938
938
  if (localMemoryResultPath !== null) {
939
+ memoryPruneFailurePhase = "memory_commit";
939
940
  const committed = await commitLocalMemoryResultAfterExit({
940
941
  resultPath: localMemoryResultPath,
941
942
  agentDir: executionWorkspace.dir,
942
943
  executionId: input.executionId,
943
944
  pruneTraceId: memoryPruneTraceId,
944
945
  exitCode,
946
+ workLogPath: executionWorkspace.workLogPath,
945
947
  });
946
- if (committed.status === "failed") {
947
- dslog("local_memory.commit_failed", "结构化本地记忆候选未落地,保留结果文件", {
948
- level: "WARN",
948
+ if (committed.status === "failed" || committed.status === "missing") {
949
+ const reason = committed.error ?? "local_memory_result_missing";
950
+ dslog("local_memory.commit_failed", "结构化本地记忆提交或清理未完成,保留可用恢复材料", {
951
+ level: "ERROR",
949
952
  execution_id: input.executionId,
950
953
  agent_handle: input.handle,
951
- error_code: committed.error,
954
+ error_code: reason,
952
955
  result_path: localMemoryResultPath,
953
956
  });
957
+ throw new Error(`local_memory_commit_failed: ${reason}`);
954
958
  }
959
+ memoryPruneFailurePhase = "runtime_finalize";
955
960
  }
956
961
  const errorTail = [
957
962
  stderrTail.trim(),
@@ -973,9 +978,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
973
978
  ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
974
979
  : (resuming ? prior?.contextTokens : undefined);
975
980
  await writeSession(workspace.sessionDir, {
981
+ runtime: runtime.name,
982
+ ...(input.resumeKey === undefined ? {} : { threadRoot: input.resumeKey }),
983
+ ...(exitCode === 0 ? await nativeThreadContext?.checkpoint() ?? {} : {}),
976
984
  sessionId,
977
985
  lastRunAt: Date.now(),
978
- turns: resuming ? (prior?.turns ?? 0) + 1 : 1,
986
+ turns: nativeThreadContext?.confirmed ? (prior?.turns ?? 0) + 1 : 1,
979
987
  model: currentModel,
980
988
  providerFingerprint: providerFp,
981
989
  ...(input.sessionContextFingerprint === undefined
@@ -991,6 +999,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
991
999
  const cloudReportResult = exitCode === 0
992
1000
  ? await deliverCloudReports({
993
1001
  facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText,
1002
+ receiptFile: reportContext.receiptFile,
994
1003
  externalAnswer: externalOutput.lastClosedBlock
995
1004
  ?? (finalText === null ? null : extractExternalAnswer(finalText)),
996
1005
  captureFinal: input.captureFinal, executionId: input.executionId, agentHandle: input.handle,
@@ -1008,7 +1017,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1008
1017
  ...(usage === undefined ? {} : { usage }),
1009
1018
  model: observedModel,
1010
1019
  runtime: runtime.name,
1011
- resumed: resuming,
1020
+ resumed: nativeThreadContext?.confirmed ?? false,
1012
1021
  sessionId,
1013
1022
  errorMessage: errorTail || null,
1014
1023
  ...(providerError === undefined ? {} : { providerError }),
@@ -1020,6 +1029,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1020
1029
  };
1021
1030
  }
1022
1031
  finally {
1032
+ await nativeThreadContext?.cleanup().catch(() => undefined);
1023
1033
  startupReservationState.current?.release();
1024
1034
  try {
1025
1035
  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
+ }
@@ -0,0 +1,29 @@
1
+ export function normalizePendingQuery(value) {
2
+ return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
3
+ }
4
+ function containsTerm(text, term) {
5
+ if (!term)
6
+ return false;
7
+ const startBoundary = /^[a-z0-9]/u.test(term);
8
+ const endBoundary = /[a-z0-9]$/u.test(term);
9
+ let position = text.indexOf(term);
10
+ while (position >= 0) {
11
+ const before = text[position - 1] ?? "";
12
+ const after = text[position + term.length] ?? "";
13
+ if ((!startBoundary || !/[a-z0-9_-]/u.test(before)) && (!endBoundary || !/[a-z0-9_-]/u.test(after)))
14
+ return true;
15
+ position = text.indexOf(term, position + 1);
16
+ }
17
+ return false;
18
+ }
19
+ export function pendingRecallScore(item, query) {
20
+ const normalized = normalizePendingQuery(query);
21
+ if (!normalized)
22
+ return 0;
23
+ const candidate = item.candidate;
24
+ const terms = normalized.split(/[\s,,、。!?;::()[\]{}"'`]+/u).filter(Boolean);
25
+ const text = normalizePendingQuery([candidate.title, candidate.summary, ...candidate.triggers, candidate.detailCategory, candidate.detailBody].join("\n"));
26
+ const explicit = [candidate.title, ...candidate.triggers].map(normalizePendingQuery);
27
+ const explicitScore = explicit.reduce((score, term) => score + (containsTerm(normalized, term) ? 10 : 0), 0);
28
+ return explicitScore + terms.reduce((score, term) => score + (containsTerm(text, term) ? 1 : 0), 0);
29
+ }