@nowcrew/daemon 0.6.73 → 0.6.75

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.
@@ -2,7 +2,19 @@ import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { systemCommandRunner } from "./computer-service.js";
4
4
  import { daemonGlobalInstallation } from "./daemon-installation.js";
5
- const RELEASED_VERSION_RE = /^\d+\.\d+\.\d+$/;
5
+ import { daemonReleaseChannel, isDaemonReleaseVersion } from "./daemon-release-version.js";
6
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
7
+ function configuredRegistryArgs(allowLoopbackHttp) {
8
+ try {
9
+ return process.env.CREW_NPM_REGISTRY === undefined ? [] : [
10
+ "--registry",
11
+ normalizeDaemonNpmRegistry(process.env.CREW_NPM_REGISTRY, { allowLoopbackHttp }),
12
+ ];
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
6
18
  export async function prepareDaemonRestart(input) {
7
19
  const local = input.localSlots.tryAcquireExclusive();
8
20
  if (local === null)
@@ -28,8 +40,32 @@ async function readPackageVersion(packageRoot) {
28
40
  const body = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
29
41
  return typeof body.version === "string" ? body.version : "";
30
42
  }
43
+ async function restoreInstalledVersion(input, runner, version, registryArgs) {
44
+ if (!isDaemonReleaseVersion(version))
45
+ return false;
46
+ const result = await runner((input.platform ?? process.platform) === "win32" ? "npm.cmd" : "npm", [
47
+ "install",
48
+ "--global",
49
+ "--prefix",
50
+ input.npmPrefix,
51
+ "--ignore-scripts",
52
+ "--no-audit",
53
+ "--no-fund",
54
+ ...registryArgs,
55
+ `@nowcrew/daemon@${version}`,
56
+ ]).catch(() => null);
57
+ if (result?.exitCode !== 0)
58
+ return false;
59
+ try {
60
+ const installedVersion = await (input.readInstalledVersion ?? readPackageVersion)(input.packageRoot);
61
+ return installedVersion === version;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
31
67
  export async function installExactDaemonUpdate(input) {
32
- if (!RELEASED_VERSION_RE.test(input.targetVersion)) {
68
+ if (!isDaemonReleaseVersion(input.targetVersion)) {
33
69
  return { ok: false, errorCode: "ineligible" };
34
70
  }
35
71
  const installation = daemonGlobalInstallation(input.currentEntryPath ?? process.argv[1] ?? "", input.platform ?? process.platform);
@@ -62,6 +98,25 @@ export async function installExactDaemonUpdate(input) {
62
98
  return { ok: false, errorCode: "ineligible" };
63
99
  }
64
100
  const runner = input.runner ?? systemCommandRunner;
101
+ let previousVersion = "";
102
+ try {
103
+ previousVersion = await (input.readInstalledVersion ?? readPackageVersion)(input.packageRoot);
104
+ }
105
+ catch {
106
+ // Do not replace a package when there is no verified version to restore.
107
+ await release();
108
+ return { ok: false, errorCode: "install_failed" };
109
+ }
110
+ if (!isDaemonReleaseVersion(previousVersion)) {
111
+ await release();
112
+ return { ok: false, errorCode: "install_failed" };
113
+ }
114
+ const registryArgs = configuredRegistryArgs(daemonReleaseChannel(previousVersion) === "beta"
115
+ || daemonReleaseChannel(input.targetVersion) === "beta");
116
+ if (registryArgs === null) {
117
+ await release();
118
+ return { ok: false, errorCode: "install_failed" };
119
+ }
65
120
  try {
66
121
  await input.onInstalling?.();
67
122
  }
@@ -77,6 +132,7 @@ export async function installExactDaemonUpdate(input) {
77
132
  "--ignore-scripts",
78
133
  "--no-audit",
79
134
  "--no-fund",
135
+ ...registryArgs,
80
136
  `@nowcrew/daemon@${input.targetVersion}`,
81
137
  ]).catch(() => null);
82
138
  if (result === null || result.exitCode !== 0) {
@@ -91,6 +147,10 @@ export async function installExactDaemonUpdate(input) {
91
147
  // Version verification is authoritative; unreadable package metadata is a mismatch.
92
148
  }
93
149
  if (installedVersion !== input.targetVersion) {
150
+ const restored = await restoreInstalledVersion(input, runner, previousVersion, registryArgs);
151
+ if (!restored) {
152
+ console.error(`[daemon-update] version verification failed; restore of ${previousVersion} was unsuccessful`);
153
+ }
94
154
  await release();
95
155
  return { ok: false, errorCode: "version_mismatch" };
96
156
  }
@@ -8,9 +8,19 @@ function retainedMarkerPrefix(value, marker) {
8
8
  }
9
9
  return 0;
10
10
  }
11
+ /**
12
+ * 跨 chunk 的契约标签流式解码器:push() 逐段返回标签内文本(供 answerStream 实时转发);
13
+ * 同时记住整轮里**最后一个完整闭合**的标签块(`lastClosedBlock`),供完结时作为契约答案——
14
+ * 只有闭合了的块才算数:半截块(被工具调用拆开的前半段)与叙述里提到的开标签
15
+ * ("我会把结论包在 <marker> 里,先去跑测试")都不会被当成答案(review of a027bf15, C1)。
16
+ * 所有 runtime 的文本都经此解码器(local-executor 事件循环),因此"后到者胜"在
17
+ * 覆盖式与增量式 runtime 上语义一致。
18
+ */
11
19
  export class ExternalAnswerDecoder {
12
20
  state = "outside";
13
21
  pending = "";
22
+ block = "";
23
+ closed = null;
14
24
  push(text) {
15
25
  if (!text)
16
26
  return [];
@@ -21,16 +31,32 @@ export class ExternalAnswerDecoder {
21
31
  finish() {
22
32
  this.pending = "";
23
33
  this.state = "outside";
34
+ this.block = "";
24
35
  return [];
25
36
  }
37
+ /** 整轮里最后一个完整闭合的标签块(已 trim);没有闭合过任何块则为 null。 */
38
+ get lastClosedBlock() {
39
+ return this.closed;
40
+ }
26
41
  consume(value, output) {
27
42
  this.pending = "";
28
43
  const marker = this.state === "outside" ? EXTERNAL_ANSWER_OPEN : EXTERNAL_ANSWER_CLOSE;
29
44
  const markerAt = value.indexOf(marker);
30
45
  if (markerAt >= 0) {
31
- if (this.state === "inside" && markerAt > 0)
32
- output.push(value.slice(0, markerAt));
33
- this.state = this.state === "outside" ? "inside" : "outside";
46
+ if (this.state === "inside") {
47
+ const head = value.slice(0, markerAt);
48
+ if (head)
49
+ output.push(head);
50
+ const completed = `${this.block}${head}`.trim();
51
+ if (completed)
52
+ this.closed = completed;
53
+ this.block = "";
54
+ this.state = "outside";
55
+ }
56
+ else {
57
+ this.block = "";
58
+ this.state = "inside";
59
+ }
34
60
  const remaining = value.slice(markerAt + marker.length);
35
61
  if (remaining)
36
62
  this.consume(remaining, output);
@@ -39,14 +65,18 @@ export class ExternalAnswerDecoder {
39
65
  const retained = retainedMarkerPrefix(value, marker);
40
66
  const safe = retained > 0 ? value.slice(0, -retained) : value;
41
67
  this.pending = retained > 0 ? value.slice(-retained) : "";
42
- if (this.state === "inside" && safe)
68
+ if (this.state === "inside" && safe) {
43
69
  output.push(safe);
70
+ this.block += safe;
71
+ }
44
72
  }
45
73
  }
46
74
  export function decodeExternalOutputEvent(runtime, event, decoder) {
47
75
  if (runtime === "claude") {
48
76
  const candidate = (event ?? {});
49
77
  const delta = candidate.event?.delta;
78
+ if (candidate.parent_tool_use_id != null)
79
+ return [];
50
80
  if (candidate.type !== "stream_event"
51
81
  || candidate.event?.type !== "content_block_delta"
52
82
  || delta?.type !== "text_delta"
package/dist/i18n.js CHANGED
@@ -51,12 +51,14 @@ const zh = {
51
51
  "Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.": "daemon 已升级,并已请求重启 '{{name}}';请用 status 确认。",
52
52
  "Refused daemon upgrade for '{{name}}': {{reason}}": "已拒绝升级 daemon '{{name}}':{{reason}}",
53
53
  "Upgraded daemon. Installed services were not restarted; pass --profile to restart one.": "daemon 已升级;已安装服务尚未重启,可传入 --profile 重启指定服务。",
54
+ "Cross-channel daemon upgrade requires --profile so the server environment can be verified": "跨发布渠道升级 daemon 需要传入 --profile,以便校验服务端环境。",
54
55
  "Service lifecycle requires the built daemon entry (.js), not a TypeScript development entry": "服务生命周期必须使用已构建的 daemon 入口(.js),不能使用 TypeScript 开发入口",
56
+ "Installed daemon release tag '{{installed}}' does not match profile '{{profile}}' release tag '{{target}}'": "已安装 daemon 的发布标签 '{{installed}}' 与配置 '{{profile}}' 要求的发布标签 '{{target}}' 不一致",
55
57
  "Installed '{{id}}'. Use status to confirm runtime state.": "已安装 '{{id}}';请用 status 确认运行状态。",
56
58
  "Uninstalled '{{id}}'.": "已卸载 '{{id}}'。",
57
59
  "{{action}} request accepted for '{{id}}'. Verify with status.": "已接受对 '{{id}}' 的 {{action}} 请求;请用 status 确认。",
58
60
  "--token-stdin requires a token on standard input": "--token-stdin 需要从标准输入读取令牌",
59
- "Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest": "服务生命周期需要全局安装 @nowcrew/daemon;请运行 npm install --global @nowcrew/daemon@latest",
61
+ "Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@{{tag}}": "服务生命周期需要全局安装 @nowcrew/daemon;请运行 npm install --global @nowcrew/daemon@{{tag}}",
60
62
  "Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}": "配置 '{{profile}}' 与配置 '{{conflict}}' 解析到了同一个 agents root '{{agentsRoot}}'。请保存为唯一目录,例如:{{command}}",
61
63
  "Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If this profile is managed as a service, use 'crew-daemon stop --profile {{profileName}}' or 'crew-daemon restart --profile {{profileName}}'.": "daemon 已在运行(PID={{ownerPid}}),agents root 为 '{{agentsRoot}}'。日志目录:'{{journalPath}}'。不要删除活跃进程持有的 journal 锁。请先停止现有 daemon 再重试;如果此 profile 由系统服务管理,请使用 'crew-daemon stop --profile {{profileName}}' 或 'crew-daemon restart --profile {{profileName}}'。",
62
64
  "Daemon is already running (PID={{ownerPid}}) for agents root '{{agentsRoot}}'. Journal: '{{journalPath}}'. Do not delete the active journal lock. Stop the existing daemon before retrying. If it is managed as a service, use 'crew-daemon stop --profile <name>' or 'crew-daemon restart --profile <name>'.": "daemon 已在运行(PID={{ownerPid}}),agents root 为 '{{agentsRoot}}'。日志目录:'{{journalPath}}'。不要删除活跃进程持有的 journal 锁。请先停止现有 daemon 再重试;如果它由系统服务管理,请使用 'crew-daemon stop --profile <name>' 或 'crew-daemon restart --profile <name>'。",
@@ -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 { listClaudeModels } from "./runtimes/claude-models.js";
4
5
  import { listHermesModels } from "./runtimes/hermes-models.js";
5
6
  const execFileRaw = promisify(execFile);
6
7
  const MODEL_PROBE_TIMEOUT_MS = 8_000;
@@ -30,23 +31,6 @@ export async function listRuntimeModels(runtime, options = {}) {
30
31
  return null;
31
32
  }
32
33
  }
33
- /** claude CLI 没有模型枚举命令,但 `--model` 的族别名(fable/opus/sonnet/haiku)恒指
34
- * 本机已装 CLI 版本的最新对应模型(2.1.252 帮助与二进制文案实证)——上报别名即
35
- * 「上报本机 claude 实际可用的模型」,CLI 升级零维护、永不过期。
36
- * `--version` 只作安装探测(未安装/不可执行时抛错,由上层回落到 server 静态兜底),
37
- * 版本号拼进展示名让用户看得到清单来自哪台机器的哪个 CLI。
38
- * 思考强度不在此上报:claude 的 --effort 档位由 web 端 REASONING_CATALOG 静态维护。 */
39
- async function listClaudeModels() {
40
- const rawVersion = (await execFileP("claude", ["--version"])).stdout.trim();
41
- const version = rawVersion.replace(/\s*\(Claude Code\)\s*$/, "").trim();
42
- const suffix = version ? ` · Claude Code ${version}` : "";
43
- return [
44
- { id: "fable", label: `Fable (latest)${suffix}` },
45
- { id: "sonnet", label: `Sonnet (latest)${suffix}`, default: true },
46
- { id: "opus", label: `Opus (latest)${suffix}` },
47
- { id: "haiku", label: `Haiku (latest)${suffix}` },
48
- ];
49
- }
50
34
  function parseCodexModels(stdout) {
51
35
  try {
52
36
  const parsed = JSON.parse(stdout);
@@ -27,42 +27,11 @@ import { ProjectProjectionError, } from "./project-skills/reconciler.js";
27
27
  import { formatProjectSkillRuntimeWarning } from "./project-skills/runtime-warning.js";
28
28
  import { projectSkillRuntimeDirectories, redactProjectSkillRuntimeRootText, redactProjectSkillRuntimeRootValue, } from "./project-skills/runtime-launch.js";
29
29
  import { logIdentityRosterRuntimePrepared } from "./identity-roster-telemetry.js";
30
- import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPruneFilesWithinDeadline, parseMemoryPruneTraceId, } from "./memory-prune-diagnostics.js";
30
+ import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPruneFilesWithinDeadline, logMemoryPruneDiagnosticsFailure, memoryPruneSnapshotFields, parseMemoryPruneTraceId, } from "./memory-prune-diagnostics.js";
31
31
  import { createLocalMemoryTelemetry, logLocalMemoryContextPrepareFailure, logLocalMemoryDiagnosticsFailure, } from "./local-memory-telemetry.js";
32
32
  import { CodexStartupStageParser } from "./codex-startup-stage.js";
33
33
  import { formatRuntimeStartupError } from "./runtime-startup-error.js";
34
34
  import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, renderLocalMemoryResultInstructions, } from "./local-memory/runtime.js";
35
- function memoryPruneFileFactFields(fileFacts) {
36
- const fields = {};
37
- for (const [label, fact] of Object.entries(fileFacts)) {
38
- fields[`${label}_exists`] = fact.exists;
39
- fields[`${label}_size`] = fact.size;
40
- fields[`${label}_mtime_ms`] = fact.mtime_ms;
41
- fields[`${label}_sha256`] = fact.sha256;
42
- fields[`${label}_hash_skipped_reason`] = fact.hash_skipped_reason;
43
- fields[`${label}_error`] = fact.error;
44
- fields[`${label}_markdown_heading_count`] = fact.markdown_heading_count;
45
- fields[`${label}_lessons_reference_count`] = fact.lessons_reference_count;
46
- fields[`${label}_same_as_shared_path`] = fact.same_as_shared_path;
47
- }
48
- return fields;
49
- }
50
- function memoryPruneSnapshotFields({ notes_manifest: _notesManifest, ...fileFacts }) {
51
- return memoryPruneFileFactFields(fileFacts);
52
- }
53
- function logMemoryPruneDiagnosticsFailure(input, traceId, phase, error) {
54
- const errorCode = error?.code;
55
- dslog("memory_prune.diagnostics_failed", "长期记忆收尾诊断失败", {
56
- level: "WARN",
57
- execution_id: input.executionId,
58
- agent_handle: input.handle,
59
- task_key: input.taskKey,
60
- prune_trace_id: traceId,
61
- diagnostics_phase: phase,
62
- error_name: error instanceof Error ? error.name : "unknown",
63
- error_code: errorCode,
64
- });
65
- }
66
35
  export class RuntimeRunningCallbackTimeoutError extends Error {
67
36
  constructor(runtime, timeoutMs) {
68
37
  super(`${runtime} runtime-running callback timed out after ${timeoutMs}ms`);
@@ -732,6 +701,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
732
701
  let observedModel = currentModel;
733
702
  let finalText = null;
734
703
  let sentViaCrew = false;
704
+ // 契约答案解码器:除了给 answerStream 实时转发,还记住整轮最后一个**闭合**的标签块。
705
+ // finalText 只保留"最后一条"助手消息,模型打完 <nowwork_external_answer> 标签后若又干了
706
+ // 别的(如顺手 git commit),收尾消息会覆盖 finalText,只从 finalText 提取就把已履约的
707
+ // 结论整段丢掉——server 回落 finalText 后频道里只剩无标签的尾声
708
+ // (incident: 2026-09-02, boss-alarm 排查结论未投递)。所有 runtime 的文本都喂给它。
735
709
  const externalOutput = new ExternalAnswerDecoder();
736
710
  // 每轮独立:tool_use/result 关联状态不能跨 execution 泄漏。
737
711
  const consoleFormatter = createConsoleFormatter();
@@ -1011,8 +985,14 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1011
985
  finalText: input.captureFinal && finalText !== null
1012
986
  ? stripExternalAnswerMarkers(finalText)
1013
987
  : null,
1014
- externalAnswer: input.captureFinal && finalText !== null
1015
- ? extractExternalAnswer(finalText)
988
+ // 契约答案 = 整轮最后一个闭合的标签块(六个 runtime 语义一致的"后到者胜",半截块与
989
+ // 叙述里的开标签不算数);从未闭合过时才回落到对 finalText 的宽容提取,保住"收尾被
990
+ // 截断"这一老兜底。解码器存的是原文,这里补脱敏。注意这放宽了"externalAnswer 非空
991
+ // ⇒ finalText 非空"的旧不变式;是否上报仍由 execution-runner 按 exitCode===0 把关。
992
+ externalAnswer: input.captureFinal
993
+ ? (externalOutput.lastClosedBlock !== null
994
+ ? redactProjectSkillRuntimeRootText(externalOutput.lastClosedBlock, activeProjectSkillRuntimeRoot)
995
+ : finalText !== null ? extractExternalAnswer(finalText) : null)
1016
996
  : null,
1017
997
  sentViaCrew,
1018
998
  };
@@ -20,6 +20,7 @@ import { probeOpenCodeRun } from "./runtimes/opencode.js";
20
20
  import { RUNTIME_HEALTH_PROBE_CAPABILITY } from "./runtime-health.js";
21
21
  import { probeDeepSeekHarnessAcp } from "./runtimes/deepseek-harness.js";
22
22
  import { daemonHome } from "./computer-profile.js";
23
+ import { daemonReleaseProvenance } from "./release-provenance.js";
23
24
  export { executableRuntimes } from "./runtime-capabilities.js";
24
25
  const execFileP = promisify(execFile);
25
26
  export const PROJECT_WORKSPACES_CAPABILITY = "project_workspaces_v1";
@@ -182,6 +183,15 @@ export async function collectMachineHello(agentsRoot, executionLimits, runtimePl
182
183
  hostname: hostname(),
183
184
  os: `${osPlatform()} ${arch()}`,
184
185
  daemonVersion: daemonVersion(),
186
+ ...(() => {
187
+ const provenance = daemonReleaseProvenance(daemonVersion());
188
+ return {
189
+ releaseChannel: provenance.releaseChannel,
190
+ releaseTag: provenance.releaseTag,
191
+ ...(provenance.gitSha === null ? {} : { gitSha: provenance.gitSha }),
192
+ ...(provenance.artifactDigest === null ? {} : { artifactDigest: provenance.artifactDigest }),
193
+ };
194
+ })(),
185
195
  runtimes,
186
196
  executionRuntimes,
187
197
  capabilities,
package/dist/main.js CHANGED
@@ -11,6 +11,8 @@ import { join } from "node:path";
11
11
  import { loadConfig, ConfigError } from "./config.js";
12
12
  import { detectDaemonLang, formatDaemonText, translateDaemon } from "./i18n.js";
13
13
  import { cliVersion, daemonVersion } from "./machine-info.js";
14
+ import { daemonReleaseProvenance } from "./release-provenance.js";
15
+ import { assertReleaseTagForServerUrl } from "./automatic-install-target.js";
14
16
  import { runAgent } from "./runner.js";
15
17
  import { serve } from "./serve.js";
16
18
  import { initSlog, flushSlog } from "./slog.js";
@@ -57,11 +59,12 @@ async function main() {
57
59
  "daemon-home": { type: "string" },
58
60
  },
59
61
  });
60
- // 无子命令时默认 serve(对齐 `npx @nowcrew/daemon@latest --server-url ... --api-key ...`)
62
+ // 无子命令时默认 serve(对齐当前发布 channel 的 npx 入口)
61
63
  const cmd = positionals[0] ?? "serve";
62
64
  if (cmd !== "run" && cmd !== "serve") {
65
+ const releaseTag = daemonReleaseProvenance(daemonVersion()).releaseTag;
63
66
  process.stderr.write(td("Usage:") + "\n" +
64
- " npx @nowcrew/daemon@latest --server-url <url> --api-key <sk_machine_*> # " + td("connect and stay resident") + "\n" +
67
+ ` npx @nowcrew/daemon@${releaseTag} --server-url <url> --api-key <sk_machine_*> # ` + td("connect and stay resident") + "\n" +
65
68
  " crew-daemon run --agent <h> --channel <id> [--wake ...] # " + td("run once manually") + "\n");
66
69
  process.exit(2);
67
70
  }
@@ -112,8 +115,10 @@ async function main() {
112
115
  }
113
116
  throw e;
114
117
  }
118
+ const provenance = daemonReleaseProvenance(daemonVersion());
119
+ assertReleaseTagForServerUrl(config.serverUrl, provenance.releaseTag);
115
120
  if (cmd === "serve") {
116
- process.stdout.write(formatDaemonLogLine(`🛰️ crew-daemon v${daemonVersion()} (cli v${cliVersion()}) ${td("resident, connecting to")} ${config.serverUrl} ${td("control plane")}...`) + "\n");
121
+ process.stdout.write(formatDaemonLogLine(`🛰️ crew-daemon v${daemonVersion()} (cli v${cliVersion()}, ${provenance.releaseChannel}/${provenance.releaseTag}) ${td("resident, connecting to")} ${config.serverUrl} ${td("control plane")}...`) + "\n");
117
122
  const installation = values.profile === undefined
118
123
  ? null
119
124
  : daemonGlobalInstallation(builtDaemonEntry(), process.platform);
@@ -199,6 +204,10 @@ async function main() {
199
204
  initSlog(config.serverUrl, config.machineToken, {
200
205
  daemonVersion: daemonVersion(),
201
206
  cliVersion: cliVersion(),
207
+ releaseChannel: provenance.releaseChannel,
208
+ releaseTag: provenance.releaseTag,
209
+ gitSha: provenance.gitSha,
210
+ artifactDigest: provenance.artifactDigest,
202
211
  ...(values.profile === undefined ? {} : { profileName: values.profile }),
203
212
  agentsRoot: config.agentsRoot,
204
213
  }); // 一次性 run 模式也上报 SLS(runner 里的埋点生效)
@@ -3,6 +3,7 @@ import { createReadStream } from "node:fs";
3
3
  import { lstat, readdir } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
6
+ import { dslog } from "./slog.js";
6
7
  const TRACE_PATTERN = /\[memory-prune trace_id=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/i;
7
8
  export const MEMORY_PRUNE_HASH_LIMIT_BYTES = 10 * 1024 * 1024;
8
9
  export const MEMORY_PRUNE_NOTES_MANIFEST_LIMIT = 128;
@@ -391,3 +392,35 @@ export function evaluateMemoryPrunePostcondition(before, after, execution) {
391
392
  outcome = "no_files_changed";
392
393
  return { outcome, ...facts };
393
394
  }
395
+ // —— 收尾诊断的 SLS 字段拍平与失败日志(自 local-executor 迁入,签名用结构子集避免类型回环) ——
396
+ function memoryPruneFileFactFields(fileFacts) {
397
+ const fields = {};
398
+ for (const [label, fact] of Object.entries(fileFacts)) {
399
+ fields[`${label}_exists`] = fact.exists;
400
+ fields[`${label}_size`] = fact.size;
401
+ fields[`${label}_mtime_ms`] = fact.mtime_ms;
402
+ fields[`${label}_sha256`] = fact.sha256;
403
+ fields[`${label}_hash_skipped_reason`] = fact.hash_skipped_reason;
404
+ fields[`${label}_error`] = fact.error;
405
+ fields[`${label}_markdown_heading_count`] = fact.markdown_heading_count;
406
+ fields[`${label}_lessons_reference_count`] = fact.lessons_reference_count;
407
+ fields[`${label}_same_as_shared_path`] = fact.same_as_shared_path;
408
+ }
409
+ return fields;
410
+ }
411
+ export function memoryPruneSnapshotFields({ notes_manifest: _notesManifest, ...fileFacts }) {
412
+ return memoryPruneFileFactFields(fileFacts);
413
+ }
414
+ export function logMemoryPruneDiagnosticsFailure(input, traceId, phase, error) {
415
+ const errorCode = error?.code;
416
+ dslog("memory_prune.diagnostics_failed", "长期记忆收尾诊断失败", {
417
+ level: "WARN",
418
+ execution_id: input.executionId,
419
+ agent_handle: input.handle,
420
+ task_key: input.taskKey,
421
+ prune_trace_id: traceId,
422
+ diagnostics_phase: phase,
423
+ error_name: error instanceof Error ? error.name : "unknown",
424
+ error_code: errorCode,
425
+ });
426
+ }
@@ -0,0 +1,32 @@
1
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
2
+ function isLoopbackHostname(hostname) {
3
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
4
+ }
5
+ export function normalizeDaemonNpmRegistry(value, options = {}) {
6
+ const raw = value?.trim();
7
+ if (!raw)
8
+ return DEFAULT_NPM_REGISTRY;
9
+ let parsed;
10
+ try {
11
+ parsed = new URL(raw);
12
+ }
13
+ catch {
14
+ throw new Error("npm registry must be an HTTPS URL without credentials");
15
+ }
16
+ if (!/^[A-Za-z0-9._~/%-]*$/u.test(parsed.pathname)) {
17
+ throw new Error("npm registry must be an HTTPS URL without credentials or unsafe path characters");
18
+ }
19
+ if ((parsed.protocol !== "https:" && !(parsed.protocol === "http:"
20
+ && isLoopbackHostname(parsed.hostname)
21
+ && options.allowLoopbackHttp === true))
22
+ || parsed.username !== ""
23
+ || parsed.password !== ""
24
+ || parsed.search !== ""
25
+ || parsed.hash !== "") {
26
+ throw new Error("npm registry must be an HTTPS URL without credentials");
27
+ }
28
+ return parsed.toString().replace(/\/+$/u, "");
29
+ }
30
+ export function npmRegistryArgs(value) {
31
+ return ["--registry", normalizeDaemonNpmRegistry(value)];
32
+ }
@@ -0,0 +1,60 @@
1
+ import { cliVersion, daemonVersion } from "./machine-info.js";
2
+ import { daemonReleaseProvenance } from "./release-provenance.js";
3
+ import { dslog, initSlog } from "./slog.js";
4
+ export function initReleaseSlog(input) {
5
+ const provenance = daemonReleaseProvenance(daemonVersion());
6
+ initSlog(input.serverUrl, input.machineToken, {
7
+ daemonVersion: daemonVersion(),
8
+ cliVersion: cliVersion(),
9
+ releaseChannel: provenance.releaseChannel,
10
+ releaseTag: provenance.releaseTag,
11
+ gitSha: provenance.gitSha,
12
+ artifactDigest: provenance.artifactDigest,
13
+ ...(input.profileName === undefined ? {} : { profileName: input.profileName }),
14
+ agentsRoot: input.agentsRoot,
15
+ });
16
+ }
17
+ export function reportReleaseChannelError(log) {
18
+ const provenance = daemonReleaseProvenance(daemonVersion());
19
+ log(`🛑 线上环境只允许正式版 daemon,当前 ${provenance.releaseTag} 包不可连接。`);
20
+ dslog("daemon.ws_release_rejected", "线上控制面拒绝非正式 daemon 包", {
21
+ level: "ERROR",
22
+ release_channel: provenance.releaseChannel,
23
+ release_tag: provenance.releaseTag,
24
+ });
25
+ }
26
+ export function reportReleaseArtifactError(log) {
27
+ log("🛑 daemon artifact digest 未通过 server allowlist,当前安装包不能连接控制面。");
28
+ dslog("daemon.ws_artifact_rejected", "控制面拒绝未获批准的 daemon artifact", {
29
+ level: "ERROR",
30
+ });
31
+ }
32
+ export function reportReleaseChannelClose(code, log) {
33
+ if (code !== 4002)
34
+ return false;
35
+ log("🛑 线上环境只允许正式版 daemon,beta 包不能连接控制面 (code 4002)。");
36
+ return true;
37
+ }
38
+ export function reportReleaseError(code, log) {
39
+ if (code === "DAEMON_RELEASE_CHANNEL_MISMATCH") {
40
+ reportReleaseChannelError(log);
41
+ return true;
42
+ }
43
+ if (code === "DAEMON_RELEASE_ARTIFACT_MISMATCH") {
44
+ reportReleaseArtifactError(log);
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ export function reportDaemonConnectionClose(code, log) {
50
+ if (code === 4001) {
51
+ log("🛑 控制面以鉴权失败关闭连接 (code 4001):机器凭证无效或已吊销。");
52
+ log(" 请在 NowWork 重新 \"Add Computer\" 获取新连接命令再重跑(当前 --api-key 已失效)。");
53
+ return true;
54
+ }
55
+ if (code === 4003) {
56
+ reportReleaseArtifactError(log);
57
+ return true;
58
+ }
59
+ return reportReleaseChannelClose(code, log);
60
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { daemonReleaseChannel } from "./daemon-release-version.js";
6
+ function validSha(value) {
7
+ return value !== undefined && /^[a-f0-9]{40}$/u.test(value) ? value : null;
8
+ }
9
+ function validDigest(value) {
10
+ return value !== undefined && /^[a-f0-9]{64}$/u.test(value) ? value : null;
11
+ }
12
+ function collectArtifactFiles(root, directory, files) {
13
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
14
+ const absolutePath = resolve(directory, entry.name);
15
+ if (entry.isDirectory()) {
16
+ if (!collectArtifactFiles(root, absolutePath, files))
17
+ return false;
18
+ continue;
19
+ }
20
+ // Reject links and special files so local and packed artifacts share one
21
+ // regular-file-only digest contract.
22
+ if (!entry.isFile())
23
+ return false;
24
+ files.push({
25
+ relativePath: relative(root, absolutePath).split(sep).join("/"),
26
+ bytes: readFileSync(absolutePath),
27
+ });
28
+ }
29
+ return true;
30
+ }
31
+ function artifactDigestState(distDirectory) {
32
+ let distStat;
33
+ try {
34
+ distStat = lstatSync(distDirectory);
35
+ }
36
+ catch (error) {
37
+ return error.code === "ENOENT"
38
+ ? { exists: false, digest: null }
39
+ : { exists: true, digest: null };
40
+ }
41
+ if (!distStat.isDirectory())
42
+ return { exists: true, digest: null };
43
+ try {
44
+ const files = [];
45
+ if (!collectArtifactFiles(distDirectory, distDirectory, files))
46
+ return { exists: true, digest: null };
47
+ files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
48
+ const hash = createHash("sha256");
49
+ for (const file of files) {
50
+ hash.update(file.relativePath, "utf8");
51
+ hash.update(Buffer.from([0]));
52
+ hash.update(file.bytes);
53
+ hash.update(Buffer.from([0]));
54
+ }
55
+ return { exists: true, digest: hash.digest("hex") };
56
+ }
57
+ catch {
58
+ return { exists: true, digest: null };
59
+ }
60
+ }
61
+ /** Compute the canonical content digest for a daemon dist directory. */
62
+ export function computeArtifactContentSha256(distDirectory) {
63
+ return artifactDigestState(distDirectory).digest;
64
+ }
65
+ function defaultDistDirectory() {
66
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
67
+ return basename(moduleDirectory) === "dist" ? moduleDirectory : resolve(moduleDirectory, "../dist");
68
+ }
69
+ export function daemonReleaseProvenance(version, env = process.env, options = {}) {
70
+ const channel = daemonReleaseChannel(version) ?? "stable";
71
+ const canonicalTag = channel === "beta" ? "beta" : "latest";
72
+ // The package version is the artifact identity. An inherited environment tag
73
+ // is advisory and must not create a stable/beta mismatch in hello logs.
74
+ const configuredTag = env.CREW_DAEMON_RELEASE_TAG;
75
+ const releaseTag = configuredTag === canonicalTag ? configuredTag : canonicalTag;
76
+ const localArtifact = artifactDigestState(options.distDirectory ?? defaultDistDirectory());
77
+ return Object.freeze({
78
+ packageName: "@nowcrew/daemon",
79
+ packageVersion: version,
80
+ releaseChannel: channel,
81
+ releaseTag,
82
+ gitSha: validSha(env.CREW_DAEMON_GIT_SHA),
83
+ artifactDigest: localArtifact.exists
84
+ ? localArtifact.digest
85
+ : validDigest(env.CREW_DAEMON_ARTIFACT_DIGEST),
86
+ });
87
+ }