@nowcrew/daemon 0.6.98-beta.5 → 0.6.98-beta.7

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
@@ -68,10 +68,15 @@ on stderr and the final result on stdout. On Windows shells that block npm.ps1/n
68
68
  the package entry; the setup arguments and workflow are unchanged.
69
69
 
70
70
  Deployment requires `CREW_PUBLIC_URL` (or `CREW_SERVER_URL`) and
71
- `CREW_COMPUTER_TOKEN_SETUP_VERSION` set to an exact, published version known to support the Token flags.
72
- The server checks its published release catalog and channel before issuing a Token command. Leave the
73
- version unset until that installer has been released; it returns `computer_token_setup_unavailable`
74
- instead of generating a command for an unsupported package. Existing clients retain browser setup.
71
+ a compatible published installer (beta >= 0.6.98-beta.5; stable >= 0.6.99).
72
+ `CREW_COMPUTER_TOKEN_SETUP_VERSION` is an optional exact-version pin, not a feature switch.
73
+ When unset or blank, the server selects the latest beta for dev or stable for production from its refreshed release catalog.
74
+ Both the two-step installation command and the one-step npx command pin that exact version.
75
+ An explicit pin must be published, compatible and in the same channel; invalid pins do not fall back.
76
+ The command keeps `--machine` for target validation and `--setup-token` for the short-lived, one-time preapproval.
77
+ The server checks its published release catalog and channel before issuing a Token command. If no
78
+ compatible published version is available, it returns `computer_token_setup_unavailable` instead of
79
+ generating a command for an unsupported package. Existing clients retain browser setup.
75
80
 
76
81
  ## Browser-Approved Computer Setup (Compatibility)
77
82
 
@@ -110,6 +115,15 @@ to confirm Workspace, Machine, environment, profile, and storage identity. It do
110
115
  existing `.crew` data. Existing `crew-daemon install --server-url ... --api-key ...` and profile lifecycle
111
116
  commands remain available as compatibility and recovery entry points.
112
117
 
118
+ Profiles created by older installers may omit `workspaceId` and `machineId`. During setup, the installer
119
+ uses the newly authorized machine credential to call `POST /daemon/computer-setup/legacy-profile` with
120
+ the SHA-256 of the previous profile token. The server checks historical ownership within that exact
121
+ Workspace and Machine, including revoked credentials, without reactivating them. After the server,
122
+ profile name, Agent root and any existing identity fields match, normal installation persists both UUIDs.
123
+ A mismatch or failed verification stops before changing the local profile or service; do not delete a
124
+ profile or fill in UUIDs by guessing. Deploy the server endpoint before publishing the compatible daemon;
125
+ an older server cannot verify legacy profiles, and already published daemon packages remain unchanged.
126
+
113
127
  ## Machine Environment
114
128
 
115
129
  The daemon loads exported variables from its OS account’s bash/zsh configuration on macOS/Linux,
@@ -1,7 +1,8 @@
1
1
  import { homedir } from "node:os";
2
+ import { createHash } from "node:crypto";
2
3
  import { posix, win32 } from "node:path";
3
4
  import { z } from "zod";
4
- import { assertProfileAgentsRootUnique, listProfiles, loadProfile, MachineTokenSchema, removeProfile, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
5
+ import { assertProfileAgentsRootUnique, listProfiles, loadProfile, MachineTokenSchema, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
5
6
  import { buildServiceSpec, captureExactService, hardenWindowsProfile, replaceExactService, restoreExactService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, } from "./computer-service.js";
6
7
  import { daemonGlobalInstallation } from "./daemon-installation.js";
7
8
  import { buildManagedServiceRecord, installManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
@@ -15,8 +16,8 @@ import { daemonRegistryCandidates } from "./daemon-registry.js";
15
16
  import { npmInvocation } from "./npm-command.js";
16
17
  import { discoverExactLegacyService, } from "./automatic-service-migration.js";
17
18
  import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
18
- import { serverHostIdentity } from "./server-url.js";
19
- import { computerSetupRecoveryGuidance } from "./computer-setup-client.js";
19
+ import { serverHostIdentity, serverUrlsMatch } from "./server-url.js";
20
+ import { computerSetupRecoveryGuidance, createComputerSetupClient, } from "./computer-setup-client.js";
20
21
  export const automaticDaemonInstallStageMessage = {
21
22
  downloading: "Downloading the Daemon package...",
22
23
  configuring: "Configuring the Daemon service...",
@@ -63,6 +64,7 @@ function defaultDependencies() {
63
64
  version: daemonVersion(),
64
65
  ...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
65
66
  runner,
67
+ verifyLegacyProfileIdentity: (serverUrl, input) => createComputerSetupClient({ serverUrl }).verifyLegacyProfileIdentity(input),
66
68
  captureProfile: async (target) => (await listProfiles(target.daemonHome)).includes(target.profile)
67
69
  ? loadProfile(target.profile, target.daemonHome, storage)
68
70
  : null,
@@ -266,18 +268,37 @@ function setupIdentity(input) {
266
268
  machineId: z.string().uuid().parse(input.machineId),
267
269
  };
268
270
  }
269
- function assertExistingProfileIdentity(previous, identity) {
271
+ async function assertExistingProfileIdentity(previous, identity, target, machineToken, dependencies) {
270
272
  if (previous === null || identity === null)
271
273
  return;
272
- if (previous.workspaceId === undefined || previous.machineId === undefined) {
273
- throw new Error(`Profile '${previous.name}' does not record its Workspace and Machine identity`);
274
- }
275
- if (previous.workspaceId !== identity.workspaceId) {
274
+ if (previous.workspaceId !== undefined && previous.workspaceId !== identity.workspaceId) {
276
275
  throw new Error(`Profile '${previous.name}' belongs to a different Workspace identity`);
277
276
  }
278
- if (previous.machineId !== identity.machineId) {
277
+ if (previous.machineId !== undefined && previous.machineId !== identity.machineId) {
279
278
  throw new Error(`Profile '${previous.name}' belongs to a different Machine identity`);
280
279
  }
280
+ if (previous.workspaceId !== undefined && previous.machineId !== undefined)
281
+ return;
282
+ const rootIdentity = (root) => {
283
+ const resolved = resolveAgentsRoot(root, dependencies.userHome, dependencies.platform);
284
+ return dependencies.platform === "win32" ? resolved.toLowerCase() : resolved;
285
+ };
286
+ if (previous.name !== target.profile || !serverUrlsMatch(previous.serverUrl, target.serverUrl)
287
+ || rootIdentity(previous.agentsRoot) !== rootIdentity(target.agentsRoot)) {
288
+ throw new Error(`Legacy profile '${previous.name}' does not match the requested server, profile, or Agent root`);
289
+ }
290
+ if (dependencies.verifyLegacyProfileIdentity === undefined) {
291
+ throw new Error(`Cannot verify legacy profile '${previous.name}' Workspace and Machine identity`);
292
+ }
293
+ // The old credential may already have been revoked by setup. Only the current credential
294
+ // authenticates this read-only request; the old hash proves historical ownership.
295
+ const verified = await dependencies.verifyLegacyProfileIdentity(target.serverUrl, {
296
+ machineToken,
297
+ previousTokenHash: createHash("sha256").update(previous.machineToken).digest("hex"),
298
+ });
299
+ if (verified.workspaceId !== identity.workspaceId || verified.machineId !== identity.machineId) {
300
+ throw new Error(`Legacy profile '${previous.name}' belongs to a different Workspace or Machine identity`);
301
+ }
281
302
  }
282
303
  export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
283
304
  const machineToken = MachineTokenSchema.parse(input.machineToken);
@@ -314,7 +335,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
314
335
  });
315
336
  const previousProfile = await dependencies.captureProfile(target);
316
337
  await dependencies.assertProfileTarget(target, previousProfile);
317
- assertExistingProfileIdentity(previousProfile, identity);
338
+ await assertExistingProfileIdentity(previousProfile, identity, target, machineToken, dependencies);
318
339
  const previousService = await dependencies.captureService(spec, target);
319
340
  if (previousService.installed && !previousService.exact) {
320
341
  throw new Error(`Installed service '${spec.id}' does not match the requested identity`);
File without changes
@@ -12,6 +12,8 @@ import { powershellCommandArgs } from "./powershell-command.js";
12
12
  const execFileAsync = promisify(execFile);
13
13
  const WINDOWS_STOP_TIMEOUT_MS = 5_000;
14
14
  const WINDOWS_STOP_POLL_MS = 100;
15
+ const DARWIN_STOP_TIMEOUT_MS = 30_000;
16
+ const DARWIN_STOP_POLL_MS = 100;
15
17
  /** Windows cannot spawn .cmd/.bat shims directly with shell:false; route them through cmd.exe explicitly. */
16
18
  export function systemCommandInvocation(command, args, platform = process.platform, comSpec = process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe") {
17
19
  if (platform === "win32" && /\.(?:cmd|bat)$/iu.test(command)) {
@@ -186,6 +188,32 @@ async function checked(runner, command, args, action) {
186
188
  }
187
189
  return result;
188
190
  }
191
+ async function stopLaunchdService(spec, initial, runner) {
192
+ const assertQueryable = (result) => {
193
+ // launchctl uses 113 for an absent service. Other failures cannot prove it stopped.
194
+ if (result.exitCode !== 0 && result.exitCode !== 113) {
195
+ throw new Error(`launchd status failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`);
196
+ }
197
+ };
198
+ assertQueryable(initial);
199
+ if (initial.exitCode === 113)
200
+ return;
201
+ const target = `${spec.managerDomain}/${spec.id}`;
202
+ await checked(runner, "launchctl", ["bootout", target], "launchd stop");
203
+ const deadline = Date.now() + DARWIN_STOP_TIMEOUT_MS;
204
+ while (true) {
205
+ const current = await runner("launchctl", ["print", target]);
206
+ assertQueryable(current);
207
+ if (current.exitCode === 113)
208
+ return;
209
+ // bootout returns while the job is SIGTERMed and still owns its installation lease.
210
+ // Wait for removal, not merely a non-running state, before installing or bootstrapping.
211
+ if (Date.now() >= deadline) {
212
+ throw new Error(`launchd service '${spec.id}' did not unload within ${DARWIN_STOP_TIMEOUT_MS}ms`);
213
+ }
214
+ await new Promise((resolve) => setTimeout(resolve, DARWIN_STOP_POLL_MS));
215
+ }
216
+ }
189
217
  function sameWindowsPath(left, right) {
190
218
  return left !== undefined
191
219
  && left.replaceAll("/", "\\").toLowerCase() === right.replaceAll("/", "\\").toLowerCase();
@@ -356,9 +384,7 @@ export async function uninstallService(spec, runner = systemCommandRunner) {
356
384
  }
357
385
  if (spec.platform === "darwin") {
358
386
  const status = await serviceStatus(spec, runner);
359
- if (status.loaded) {
360
- await checked(runner, "launchctl", ["bootout", `${spec.managerDomain}/${spec.id}`], "launchd bootout");
361
- }
387
+ await stopLaunchdService(spec, status, runner);
362
388
  }
363
389
  else {
364
390
  const disable = await runner("systemctl", ["--user", "disable", "--now", spec.id]);
@@ -385,8 +411,7 @@ export async function serviceAction(spec, action, runner = systemCommandRunner)
385
411
  await checked(runner, "launchctl", ["bootstrap", spec.managerDomain, spec.descriptorPath], "launchd start");
386
412
  }
387
413
  else if (action === "stop") {
388
- if (status.loaded)
389
- await checked(runner, "launchctl", ["bootout", target], "launchd stop");
414
+ await stopLaunchdService(spec, status, runner);
390
415
  }
391
416
  else {
392
417
  if (status.loaded)
@@ -299,6 +299,11 @@ export function createComputerSetupClient(options) {
299
299
  return receipt.data;
300
300
  },
301
301
  tokenTarget: async (machineToken) => SetupTokenTargetSchema.parse(await request("GET", "/daemon/computer-setup/target", undefined, machineToken)),
302
+ verifyLegacyProfileIdentity: async (input) => z.object({
303
+ workspaceId: z.string().uuid(), machineId: z.string().uuid(),
304
+ }).strict().parse(await request("POST", "/daemon/computer-setup/legacy-profile", {
305
+ previousTokenHash: input.previousTokenHash,
306
+ }, input.machineToken)),
302
307
  start: async (input) => {
303
308
  return parseStart(await request("POST", "/api/auth/computer-setup/start", {
304
309
  ...(input.workspace.kind === "id"
package/dist/console.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * 会附带结构化 payload(diff/命令/todo 清单/计划…),供前端做富渲染(红绿 diff、清单勾选);
10
10
  * 前端不识别 payload.kind 时降级为纯文本行,协议向后兼容。
11
11
  *
12
- * 覆盖三种 runtime 的流:claude(stream-json)、codex(exec --json 的 item.* 事件)、
13
- * kimi(OpenAI 消息风格行)。
12
+ * 覆盖 runtime 的流:claude(stream-json)、codex(exec --json 的 item.* 事件)、
13
+ * kimi(OpenAI 消息风格行)、ACP 适配器与 Pi 适配器(pi.* 块级事件)。
14
14
  */
15
15
  import { detectDaemonLang, translateDaemon } from "./i18n.js";
16
16
  import { buildFilePreview, buildSnippetDiff } from "./console-payload.js";
@@ -280,6 +280,21 @@ export function toConsoleLines(event, runtime) {
280
280
  const text = typeof e.content === "string" ? e.content.trim() : "";
281
281
  return text ? [{ stream: "tool_result", text: clip(text, TOOL_RESULT_CAP) }] : [];
282
282
  }
283
+ if (e.type === "pi.text" && e.text)
284
+ return [{ stream: "text", text: e.text }];
285
+ if (e.type === "pi.thinking" && e.text)
286
+ return [{ stream: "thinking", text: e.text }];
287
+ if (e.type === "pi.tool_call") {
288
+ return [{ stream: "tool", text: e.title ? `⏺ ${clip(e.title, TOOL_INPUT_CAP)}` : "⏺ Pi tool" }];
289
+ }
290
+ if (e.type === "pi.tool_result") {
291
+ const text = typeof e.content === "string" ? e.content.trim() : "";
292
+ return text ? [{ stream: e.is_error ? "error" : "tool_result", text: clip(text, TOOL_RESULT_CAP) }] : [];
293
+ }
294
+ if (e.type === "pi.error") {
295
+ const message = event.message;
296
+ return typeof message === "string" && message.trim() ? [{ stream: "error", text: message.trim() }] : [];
297
+ }
283
298
  if (e.type === "system" && e.subtype === "init") {
284
299
  return [{ stream: "system", text: `● ${td("Claude Code session started")}` }];
285
300
  }
@@ -1,3 +1,4 @@
1
+ import { MAINTENANCE_CAPABILITY } from "./marketplace-maintenance-protocol.js";
1
2
  import { retryHarnessProbe } from "./runtimes/harness-probe-retry.js";
2
3
  import { MARKETPLACE_CAPABILITY } from "./marketplace-protocol.js";
3
4
  /**
@@ -40,6 +41,7 @@ export const NATIVE_PROJECT_WORKSPACE_READINESS = Object.freeze({
40
41
  });
41
42
  export const DAEMON_CAPABILITIES = Object.freeze([
42
43
  MARKETPLACE_CAPABILITY,
44
+ MAINTENANCE_CAPABILITY,
43
45
  "scheduled_job_v1",
44
46
  "reply_origin_v1",
45
47
  "origin_decision_v1",
@@ -72,7 +74,7 @@ export const daemonCapabilities = (runtimePlatform = process.platform, readiness
72
74
  const nativeWorkspaceReady = nativeWorkspacePlatform
73
75
  && readiness.resolverReady
74
76
  && readiness.nativeRuntimeAdaptersReady;
75
- return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => ((capability !== MARKETPLACE_CAPABILITY || nativeWorkspacePlatform)
77
+ return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => (((capability !== MARKETPLACE_CAPABILITY && capability !== MAINTENANCE_CAPABILITY) || nativeWorkspacePlatform)
76
78
  && (capability !== PROJECT_WORKSPACES_CAPABILITY || nativeWorkspaceReady)
77
79
  && (capability !== "message_file_preview_v1" || nativeWorkspacePlatform)
78
80
  && (nativeWorkspacePlatform
package/dist/main.js CHANGED
File without changes
@@ -1,3 +1,4 @@
1
+ import { MaintenanceRequestSchema, MaintenanceReportSchema } from "./marketplace-maintenance-protocol.js";
1
2
  import { MarketplaceRequestSchema, marketplaceActive } from "./marketplace-protocol.js";
2
3
  import { resolveMarketplaceScopes, runMarketplaceScope } from "./marketplace-runner.js";
3
4
  export function createMarketplaceController(deps) {
@@ -11,22 +12,31 @@ export function createMarketplaceController(deps) {
11
12
  const deadline = now() + 10 * 60_000;
12
13
  let lease = null;
13
14
  try {
14
- const scopes = await (deps.resolve ?? resolveMarketplaceScopes)(request.targets, deps.agentsRoot);
15
+ const scopes = request.type === "marketplace:operation" ? await (deps.resolve ?? resolveMarketplaceScopes)(request.targets, deps.agentsRoot) : [];
15
16
  // 查询不使用任务执行槽;只有写入运行时共用市场/插件目录时才等待独占保护。
16
- if (request.mode === "update") {
17
+ if (request.type === "marketplace:maintenance" || request.mode === "update") {
17
18
  while (!stopped && now() < deadline && lease === null) {
18
19
  lease = await deps.acquire();
19
20
  if (!lease)
20
21
  await wait();
21
22
  }
22
23
  if (!lease || stopped) {
23
- operations.set(id, { operationId: id, status: "failed", results: [], errorCode: "runtime_busy" });
24
+ operations.set(id, { ...operations.get(id), status: "failed", errorCode: "runtime_busy" });
24
25
  return;
25
26
  }
26
27
  }
27
28
  if (stopped || now() >= deadline)
28
29
  throw new Error("operation_deadline");
29
- operations.set(id, { operationId: id, status: "running", results: [], errorCode: null });
30
+ operations.set(id, { ...operations.get(id), status: "running" });
31
+ if (request.type === "marketplace:maintenance") {
32
+ if (!deps.maintain)
33
+ throw new Error("maintenance_unavailable");
34
+ const result = MaintenanceReportSchema.parse(await deps.maintain(request));
35
+ if (result.operationId !== id)
36
+ throw new Error("operation_mismatch");
37
+ operations.set(id, result);
38
+ return;
39
+ }
30
40
  const results = [];
31
41
  for (const scope of scopes) {
32
42
  if (stopped || now() >= deadline)
@@ -41,7 +51,9 @@ export function createMarketplaceController(deps) {
41
51
  }
42
52
  catch {
43
53
  const previous = operations.get(id);
44
- operations.set(id, { ...previous, status: previous.results.some(x => !x.errorCode) ? "partial" : "failed", errorCode: "execution_failed" });
54
+ operations.set(id, request.type === "marketplace:maintenance"
55
+ ? { ...previous, status: "unknown", errorCode: "operation_unknown" }
56
+ : { ...previous, status: previous.results.some(x => !x.errorCode) ? "partial" : "failed", errorCode: "execution_failed" });
45
57
  }
46
58
  finally {
47
59
  try {
@@ -55,22 +67,23 @@ export function createMarketplaceController(deps) {
55
67
  };
56
68
  return {
57
69
  handle(raw) {
58
- const parsed = MarketplaceRequestSchema.safeParse(raw);
70
+ const parsed = MarketplaceRequestSchema.or(MaintenanceRequestSchema).safeParse(raw);
59
71
  if (!parsed.success || stopped)
60
72
  return { ok: false, error: "invalid_request" };
61
73
  const request = parsed.data;
74
+ const version = request.type === "marketplace:maintenance" ? { version: 2 } : {};
62
75
  const existing = operations.get(request.operationId);
63
76
  if (existing)
64
77
  return { ok: true, data: existing };
65
78
  if (request.action === "status")
66
- return { ok: true, data: { operationId: request.operationId, status: "unknown", results: [], errorCode: "operation_unknown" } };
79
+ return { ok: true, data: { ...version, operationId: request.operationId, status: "unknown", results: [], errorCode: "operation_unknown" } };
67
80
  if (process.platform !== "darwin" && process.platform !== "linux")
68
81
  return { ok: false, error: "unsupported_platform" };
69
82
  if (current !== null || [...operations.values()].some(x => marketplaceActive(x.status)))
70
83
  return { ok: false, error: "runtime_busy" };
71
84
  if (operations.size >= 100)
72
85
  operations.delete(operations.keys().next().value);
73
- const accepted = { operationId: request.operationId, status: "queued", results: [], errorCode: null };
86
+ const accepted = { ...version, operationId: request.operationId, status: "queued", results: [], errorCode: null };
74
87
  operations.set(request.operationId, accepted);
75
88
  current = execute(request).finally(() => { current = null; });
76
89
  return { ok: true, data: accepted };
@@ -0,0 +1,79 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { augmentedPath } from "./runtime-path.js";
7
+ import { MaintenanceReportSchema } from "./marketplace-maintenance-protocol.js";
8
+ export function maintenanceEnvironment(env) {
9
+ // Keep the machine user's login/config, never forward control-plane credentials.
10
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !/^(CREW_|NOWCREW_|NOWWORK_)/i.test(key)));
11
+ }
12
+ async function supervise(file, id, cwd, env) {
13
+ await new Promise((resolve, reject) => {
14
+ const child = spawn(process.execPath, [file, "--run", id], { cwd, env, detached: true, stdio: "ignore" });
15
+ let expired = false;
16
+ const kill = () => { if (child.pid) {
17
+ try {
18
+ process.kill(-child.pid, "SIGKILL");
19
+ }
20
+ catch { /* already gone */ }
21
+ } };
22
+ const timer = setTimeout(() => { expired = true; kill(); }, 18 * 60_000);
23
+ child.once("error", () => { clearTimeout(timer); kill(); reject(new Error("worker_failed")); });
24
+ child.once("close", code => {
25
+ clearTimeout(timer);
26
+ // Never release the machine lock while descendants can still write shared configuration.
27
+ kill();
28
+ if (expired || code !== 0)
29
+ reject(new Error("worker_failed"));
30
+ else
31
+ resolve();
32
+ });
33
+ });
34
+ }
35
+ export async function runMaintenanceBridge(config, request, acquireEnv) {
36
+ const fail = (errorCode) => ({ version: 2, operationId: request.operationId, status: "failed", results: [], errorCode });
37
+ let dir;
38
+ let started = false;
39
+ try {
40
+ const response = await fetch(`${config.serverUrl.replace(/\/+$/, "")}/machine/marketplace-maintenance/${request.operationId}/runner/${request.plan.runner.releaseId}`, {
41
+ headers: { authorization: `Bearer ${config.machineToken}` }, redirect: "error", signal: AbortSignal.timeout(15_000),
42
+ });
43
+ if (!response.ok)
44
+ return fail("runner_unavailable");
45
+ // Bound even a malformed server response before buffering or executing it.
46
+ if (!response.body)
47
+ return fail("runner_unavailable");
48
+ const chunks = [];
49
+ let bytes = 0;
50
+ for await (const chunk of response.body) {
51
+ bytes += chunk.length;
52
+ if (bytes > 256_000)
53
+ return fail("runner_unavailable");
54
+ chunks.push(chunk);
55
+ }
56
+ const content = Buffer.concat(chunks);
57
+ if (createHash("sha256").update(content).digest("hex") !== request.plan.runner.sha256)
58
+ return fail("runner_unavailable");
59
+ const env = maintenanceEnvironment(await acquireEnv());
60
+ env.PATH = augmentedPath(env, {}, process.platform, "append");
61
+ dir = await mkdtemp(join(tmpdir(), "nowwork-maintenance-"));
62
+ const file = join(dir, "runner.mjs");
63
+ await writeFile(file, content, { mode: 0o600 });
64
+ started = true;
65
+ await supervise(file, request.operationId, dir, env);
66
+ const report = MaintenanceReportSchema.parse(JSON.parse(await readFile(join(dir, "report.json"), "utf8")));
67
+ if (report.operationId !== request.operationId)
68
+ throw new Error("operation_mismatch");
69
+ return report;
70
+ }
71
+ catch {
72
+ // A killed worker may have changed files: require readback/manual confirmation before another write.
73
+ return started ? { ...fail("operation_unknown"), status: "unknown" } : fail("runner_unavailable");
74
+ }
75
+ finally {
76
+ if (dir)
77
+ await rm(dir, { recursive: true, force: true });
78
+ }
79
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from "zod";
2
+ // 与 daemon 同名文件逐字一致;这是独立于旧单插件协议的版本化契约。
3
+ export const MAINTENANCE_CAPABILITY = "marketplace_codex_maintenance_v1";
4
+ export const MaintenancePlanSchema = z.object({
5
+ marketplace: z.literal("nowwork-pm"),
6
+ actions: z.tuple([z.literal("install-missing"), z.literal("update-installed")]),
7
+ hosts: z.tuple([z.literal("claude"), z.literal("codex"), z.literal("pi")]),
8
+ runner: z.object({ releaseId: z.string().regex(/^[a-f0-9]{64}$/), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
9
+ }).strict();
10
+ export const MaintenanceRequestSchema = z.discriminatedUnion("action", [
11
+ z.object({ type: z.literal("marketplace:maintenance"), action: z.literal("start"), operationId: z.string().uuid(), plan: MaintenancePlanSchema }).strict(),
12
+ z.object({ type: z.literal("marketplace:maintenance"), action: z.literal("status"), operationId: z.string().uuid() }).strict(),
13
+ ]);
14
+ export const MaintenanceErrorSchema = z.enum(["runtime_busy", "execution_failed", "operation_unknown", "runner_unavailable",
15
+ "manager_missing", "cli_missing", "command_failed", "verification_failed", "source_conflict", "unsupported_platform"]);
16
+ const Version = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._+-]{0,79}$/).nullable();
17
+ export const MaintenanceResultSchema = z.object({
18
+ runtime: z.enum(["claude", "codex", "pi"]), plugin: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,127}$/).nullable(),
19
+ status: z.enum(["installed", "updated", "unchanged", "empty", "missing_cli", "failed"]),
20
+ beforeVersion: Version, afterVersion: Version, targetVersion: Version,
21
+ activation: z.enum(["reload_required", "not_changed", "unverified"]), errorCode: MaintenanceErrorSchema.nullable(),
22
+ }).strict();
23
+ export const MaintenanceReportSchema = z.object({
24
+ version: z.literal(2), operationId: z.string().uuid(),
25
+ status: z.enum(["queued", "running", "completed", "partial", "failed", "unknown"]),
26
+ results: z.array(MaintenanceResultSchema).max(1500), errorCode: MaintenanceErrorSchema.nullable(),
27
+ }).strict().superRefine((r, ctx) => {
28
+ const keys = r.results.map(x => `${x.runtime}:${x.plugin}`);
29
+ if (new Set(keys).size !== keys.length)
30
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Duplicate plugin result" });
31
+ for (const x of r.results) {
32
+ if (["installed", "updated", "unchanged"].includes(x.status) && (!x.plugin || !x.targetVersion || x.targetVersion !== x.afterVersion || x.errorCode)) {
33
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Successful plugin result requires a verified target version" });
34
+ }
35
+ }
36
+ if (r.status === "completed" && (r.errorCode || ["claude", "codex", "pi"].some(host => !r.results.some(x => x.runtime === host))
37
+ || r.results.some(x => x.errorCode || x.status === "failed" || x.status === "missing_cli"))) {
38
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Completion requires verified coverage of every host" });
39
+ }
40
+ });
@@ -1,7 +1,9 @@
1
+ import { runMaintenanceBridge } from "./marketplace-maintenance-bridge.js";
1
2
  import { createMarketplaceController } from "./marketplace-controller.js";
2
3
  import { prepareDaemonRestart } from "./daemon-updater.js";
3
- export function createServeMarketplace(config, localSlots, hostCoordinator) {
4
+ export function createServeMarketplace(config, localSlots, hostCoordinator, acquireEnv = async () => ({ ...process.env })) {
4
5
  const controller = createMarketplaceController({ agentsRoot: config.agentsRoot,
6
+ maintain: request => runMaintenanceBridge(config, request, acquireEnv),
5
7
  acquire: async () => {
6
8
  // Marketplace updates can mutate shared Codex/Claude homes across profiles.
7
9
  const local = await prepareDaemonRestart({ localSlots });
@@ -29,7 +31,7 @@ export function createServeMarketplace(config, localSlots, hostCoordinator) {
29
31
  } });
30
32
  return {
31
33
  handle(raw, send) {
32
- if (!raw || typeof raw !== "object" || !("type" in raw) || raw.type !== "marketplace:operation")
34
+ if (!raw || typeof raw !== "object" || !("type" in raw) || (raw.type !== "marketplace:operation" && raw.type !== "marketplace:maintenance"))
33
35
  return false;
34
36
  const { reqId, ...request } = raw;
35
37
  if (typeof reqId === "string")
@@ -40,6 +40,7 @@ export async function runPi(bin) {
40
40
  if (inputError)
41
41
  throw new Error(`Pi prompt delivery failed: ${inputError.message}`);
42
42
  const result = decoder.finish();
43
+ await emit(result.event);
43
44
  if (!result.ok) {
44
45
  process.stderr.write(`${result.error}\n`);
45
46
  return 1;
@@ -3,6 +3,23 @@ export function buildPiArgs(options) {
3
3
  ...(options.model ? ["--model", options.model] : []),
4
4
  ...(options.reasoning && options.reasoning !== "default" ? ["--thinking", options.reasoning] : [])];
5
5
  }
6
+ const TOOL_ARG_KEYS = ["command", "path", "file_path", "pattern", "url"];
7
+ function toolTitle(name, args) {
8
+ const arg = TOOL_ARG_KEYS.map(key => args?.[key]).find((value) => typeof value === "string" && !!value.trim());
9
+ return arg ? `${name}(${arg.trim()})` : name;
10
+ }
11
+ function toolResultText(result) {
12
+ return (result?.content ?? []).map(item => item.type === "text" && typeof item.text === "string" ? item.text : "")
13
+ .filter(Boolean).join("\n");
14
+ }
15
+ function addUsage(total, next) {
16
+ if (!next)
17
+ return total;
18
+ return {
19
+ input: (total?.input ?? 0) + (next.input ?? 0), output: (total?.output ?? 0) + (next.output ?? 0),
20
+ cacheRead: (total?.cacheRead ?? 0) + (next.cacheRead ?? 0), cacheWrite: (total?.cacheWrite ?? 0) + (next.cacheWrite ?? 0),
21
+ };
22
+ }
6
23
  // 保留稳定的原因标识供 server 本地化;不要用 token 数量猜测结束原因。
7
24
  function failureReason(message) {
8
25
  const reason = message?.stopReason;
@@ -15,7 +32,17 @@ export function createPiEventDecoder() {
15
32
  let lastAssistant = null;
16
33
  let assistantOpen = false;
17
34
  let sawTurnEnd = false;
35
+ let turnUsageCounted = false;
18
36
  let usage;
37
+ // 正常 stop 可为空(任务可能要求静默);length/error/aborted 或停在工具轮都必须明确失败。
38
+ const runFailure = () => error ?? (assistantOpen
39
+ ? "pi_runtime_failed: incomplete"
40
+ : failureReason(lastAssistant)
41
+ ?? (!sawTurnEnd || lastAssistant?.stopReason === "toolUse" ? "pi_runtime_failed: incomplete" : null));
42
+ const normalizedUsage = () => usage ? { usage: {
43
+ input_tokens: usage.input ?? 0, output_tokens: usage.output ?? 0,
44
+ cache_read_input_tokens: usage.cacheRead ?? 0, cache_creation_input_tokens: usage.cacheWrite ?? 0,
45
+ } } : {};
19
46
  return {
20
47
  push(raw) {
21
48
  const e = (raw ?? {});
@@ -24,8 +51,10 @@ export function createPiEventDecoder() {
24
51
  sessionId = e.id;
25
52
  out.push({ type: "thread.started", thread_id: e.id });
26
53
  }
27
- if (e.type === "turn_start")
54
+ if (e.type === "turn_start") {
28
55
  sawTurnEnd = false;
56
+ turnUsageCounted = false;
57
+ }
29
58
  if ((e.type === "message_start" || e.type === "message_update") && e.message?.role === "assistant") {
30
59
  assistantOpen = true;
31
60
  sawTurnEnd = false;
@@ -38,34 +67,39 @@ export function createPiEventDecoder() {
38
67
  if (e.type === "message_update" && ae?.type === "thinking_delta" && typeof ae.delta === "string") {
39
68
  out.push({ type: "pi.thinking_delta", text: ae.delta });
40
69
  }
41
- if (e.type === "message_update" && typeof ae?.type === "string" && /tool(call|_call)/iu.test(ae.type)) {
42
- out.push({ type: "pi.tool_call", ...(ae.name ? { title: ae.name } : {}) });
70
+ // delta 逐 token 到达,console 只消费块结束事件,避免一个 token 一行。
71
+ if (e.type === "message_update" && ae?.type === "text_end" && ae.content?.trim()) {
72
+ out.push({ type: "pi.text", text: ae.content.trim() });
73
+ }
74
+ if (e.type === "message_update" && ae?.type === "thinking_end" && ae.content?.trim()) {
75
+ out.push({ type: "pi.thinking", text: ae.content.trim() });
76
+ }
77
+ if (e.type === "tool_execution_start" && typeof e.toolName === "string") {
78
+ out.push({ type: "pi.tool_call", title: toolTitle(e.toolName, e.args) });
79
+ }
80
+ if (e.type === "tool_execution_end") {
81
+ out.push({ type: "pi.tool_result", content: toolResultText(e.result), is_error: e.isError === true });
43
82
  }
44
83
  if (e.type === "message_end" && e.message?.role === "assistant") {
45
84
  lastAssistant = e.message;
46
- usage = e.message.usage;
85
+ usage = addUsage(usage, e.message.usage);
86
+ turnUsageCounted = true;
47
87
  assistantOpen = false;
48
88
  sawTurnEnd = false;
49
89
  }
50
90
  if (e.type === "turn_end") {
51
- // 原生 turn_end 自带本轮消息,不能把工具调用轮的结束当成整次执行成功。
91
+ // turn_end 只是 Pi 内部一轮:工具调用后会继续,provider 出错还会自动重试(agent_end.willRetry)。
52
92
  if (e.message?.role === "assistant") {
53
93
  lastAssistant = e.message;
54
- usage = e.message.usage;
94
+ if (!turnUsageCounted)
95
+ usage = addUsage(usage, e.message.usage);
55
96
  assistantOpen = false;
56
97
  }
57
98
  else if (e.message) {
58
99
  lastAssistant = null;
59
100
  }
60
101
  sawTurnEnd = true;
61
- const normalizedUsage = usage ? { usage: {
62
- input_tokens: usage.input ?? 0, output_tokens: usage.output ?? 0,
63
- cache_read_input_tokens: usage.cacheRead ?? 0, cache_creation_input_tokens: usage.cacheWrite ?? 0,
64
- } } : {};
65
- const failure = failureReason(lastAssistant);
66
- out.push(failure
67
- ? { type: "result", is_error: true, result: failure, ...normalizedUsage }
68
- : { type: "turn.completed", ...normalizedUsage });
102
+ turnUsageCounted = false;
69
103
  }
70
104
  if (e.type === "error" || e.type === "agent_error") {
71
105
  error = typeof e.error === "string" ? e.error : (e.error?.message ?? "Pi error");
@@ -74,12 +108,12 @@ export function createPiEventDecoder() {
74
108
  return out;
75
109
  },
76
110
  finish() {
77
- // 正常 stop 可为空(任务可能要求静默);length/error/aborted 必须明确失败。
78
- const failure = error ?? (assistantOpen
79
- ? "pi_runtime_failed: incomplete"
80
- : failureReason(lastAssistant)
81
- ?? (!sawTurnEnd || lastAssistant?.stopReason === "toolUse" ? "pi_runtime_failed: incomplete" : null));
82
- return { ok: failure === null, ...(failure ? { error: failure } : {}), finalText, sessionId, usage };
111
+ // 终态只在 stdout 结束后产出一次:重试/中断都以最终状态为准,usage 覆盖所有已完成轮次。
112
+ const failure = runFailure();
113
+ const event = failure
114
+ ? { type: "result", is_error: true, result: failure, ...normalizedUsage() }
115
+ : { type: "turn.completed", ...normalizedUsage() };
116
+ return { ok: failure === null, ...(failure ? { error: failure } : {}), finalText, sessionId, usage, event };
83
117
  },
84
118
  };
85
119
  }
package/dist/serve.js CHANGED
@@ -122,7 +122,7 @@ export function serve(config, opts = {}) {
122
122
  catch { /* reconnect/timeout reconciliation handles a lost status frame */ }
123
123
  },
124
124
  });
125
- const marketplace = createServeMarketplace(config, localSlots, hostCoordinator);
125
+ const marketplace = createServeMarketplace(config, localSlots, hostCoordinator, async () => ({ ...(await machineRuntime.provider.acquire()).env }));
126
126
  const storageIdentityAdmission = createStorageIdentityAdmission(opts.storageRuntime);
127
127
  const projectionCoordinator = createAgentProjectionCoordinator();
128
128
  const { projectRegistry, projectWorkspaceRegistry, projectDependencyProvisioner } = createProjectRegistryWiring(config.agentsRoot, opts.execution?.dependencies);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.98-beta.5",
3
+ "version": "0.6.98-beta.7",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -18,6 +18,14 @@
18
18
  "publishConfig": {
19
19
  "access": "public"
20
20
  },
21
+ "scripts": {
22
+ "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
23
+ "build": "tsc -p tsconfig.json",
24
+ "prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
25
+ "test": "vitest run",
26
+ "test:release-critical": "vitest run test/automatic-daemon-installer.test.ts test/automatic-install-target.test.ts test/automatic-service-migration.test.ts test/computer-cli.test.ts test/computer-service.test.ts test/computer-setup-client.test.ts test/computer-setup-orchestration.test.ts test/computer-setup.test.ts test/daemon-release-version.test.ts test/daemon-update-controller.test.ts test/daemon-update-eligibility.test.ts test/daemon-updater.test.ts test/execution-protocol.test.ts test/machine-info.test.ts --maxWorkers=2 --minWorkers=1",
27
+ "typecheck": "tsc --noEmit"
28
+ },
21
29
  "dependencies": {
22
30
  "@agentclientprotocol/sdk": "1.2.1",
23
31
  "@nowcrew/cli": "0.4.17",
@@ -39,11 +47,5 @@
39
47
  "tsx": "^4.19.0",
40
48
  "typescript": "^5.6.0",
41
49
  "vitest": "^2.1.0"
42
- },
43
- "scripts": {
44
- "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
45
- "build": "tsc -p tsconfig.json",
46
- "test": "vitest run",
47
- "typecheck": "tsc --noEmit"
48
50
  }
49
- }
51
+ }