@nowcrew/daemon 0.6.101-beta.3 → 0.6.101-beta.4

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.
File without changes
@@ -3,10 +3,25 @@ import { renameSync } from "node:fs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { join, resolve } from "node:path";
5
5
  import { z } from "zod";
6
+ const ProtectedGroupSchema = z.object({ pid: z.number().int().positive(), processIdentity: z.string().min(1) }).strict();
7
+ export async function inspectProtectedProcessGroup(pid) {
8
+ try {
9
+ process.kill(-pid, 0);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ESRCH")
14
+ return false;
15
+ if (error.code === "EPERM")
16
+ return true;
17
+ throw error;
18
+ }
19
+ }
6
20
  const OwnerSchema = z.object({
7
21
  pid: z.number().int().positive(),
8
22
  processIdentity: z.string().min(1),
9
23
  token: z.string().uuid(),
24
+ protectedGroup: ProtectedGroupSchema.optional(),
10
25
  }).strict();
11
26
  export class JournalLockedError extends Error {
12
27
  journalPath;
@@ -144,7 +159,8 @@ export async function inspectJournalLock(options) {
144
159
  let inspection;
145
160
  try {
146
161
  const identity = await options.inspectIdentity(owner.pid);
147
- inspection = identity === owner.processIdentity
162
+ const protectedAlive = owner.protectedGroup !== undefined && await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(owner.protectedGroup.pid);
163
+ inspection = identity === owner.processIdentity || protectedAlive
148
164
  ? {
149
165
  status: "owned",
150
166
  ownerPid: owner.pid,
@@ -333,7 +349,9 @@ export function createJournalLease(options) {
333
349
  if (observed === null)
334
350
  continue;
335
351
  const identity = await options.inspectIdentity(observed.owner.pid);
336
- if (identity === observed.owner.processIdentity) {
352
+ const protectedAlive = observed.owner.protectedGroup !== undefined
353
+ && await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(observed.owner.protectedGroup.pid);
354
+ if (identity === observed.owner.processIdentity || protectedAlive) {
337
355
  throw new JournalLockedError(`Execution journal is locked by process ${observed.owner.pid}`, { journalPath, ownerPid: observed.owner.pid });
338
356
  }
339
357
  await options.hooks?.beforeRemoveObservedOwner?.(observed.owner);
@@ -408,6 +426,9 @@ export function createJournalLease(options) {
408
426
  status = "closed";
409
427
  return;
410
428
  }
429
+ if (lease.owner.protectedGroup && await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(lease.owner.protectedGroup.pid)) {
430
+ throw new JournalLockedError("Protected maintenance process group is still alive", { journalPath, ownerPid: lease.owner.protectedGroup.pid });
431
+ }
411
432
  status = "closing";
412
433
  lease.releasing = true;
413
434
  await awaitAbortable((async () => {
@@ -426,8 +447,36 @@ export function createJournalLease(options) {
426
447
  status = "closed";
427
448
  void cleanupReleasedDirectory(releasedDirectory);
428
449
  };
450
+ const protectProcessGroup = async (raw) => {
451
+ const group = ProtectedGroupSchema.parse(raw);
452
+ const lease = registry.get(options.directory);
453
+ if (!attached || status !== "open" || !lease || lease.releasing || lease.refs !== 1)
454
+ throw new JournalLockedError("Lease cannot protect a worker", { journalPath });
455
+ await validateInstalledOwner(lease);
456
+ if (await options.inspectIdentity(group.pid) !== group.processIdentity
457
+ || !await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(group.pid))
458
+ throw new JournalLockedError("Worker identity is absent", { journalPath });
459
+ if (lease.owner.protectedGroup) {
460
+ if (JSON.stringify(lease.owner.protectedGroup) === JSON.stringify(group))
461
+ return;
462
+ throw new JournalLockedError("Lease already protects a worker", { journalPath });
463
+ }
464
+ const owner = { ...lease.owner, protectedGroup: group };
465
+ const temp = join(options.directory, `.journal-owner.${lease.owner.token}.${randomUUID()}.tmp`);
466
+ try {
467
+ await fileSystem.writeDurableFile(temp, `${JSON.stringify(owner)}\n`);
468
+ await fileSystem.rename(temp, join(lockDirectory, lease.ownerFile));
469
+ lease.owner = owner;
470
+ await options.syncDirectory(lockDirectory);
471
+ await options.syncDirectory(options.directory);
472
+ }
473
+ finally {
474
+ await fileSystem.rm(temp, { force: true }).catch(() => undefined);
475
+ }
476
+ };
429
477
  return {
430
478
  acquire,
479
+ protectProcessGroup,
431
480
  assertUsable: () => {
432
481
  if (status !== "open") {
433
482
  throw new JournalLockedError(`Execution journal lease is ${status}`, { journalPath });
@@ -302,6 +302,16 @@ export function createHostExecutionCoordinator(options = {}) {
302
302
  }
303
303
  let releasePromise = null;
304
304
  return {
305
+ protectProcessGroup: async (pid) => {
306
+ const processIdentity = await defaultProcessController.inspectIdentity(pid);
307
+ if (processIdentity === null)
308
+ throw new Error("maintenance worker identity is absent");
309
+ for (const lease of leases) {
310
+ if (!lease.protectProcessGroup)
311
+ throw new Error("lease process-group protection is unavailable");
312
+ await lease.protectProcessGroup({ pid, processIdentity });
313
+ }
314
+ },
305
315
  release: () => {
306
316
  if (releasePromise !== null)
307
317
  return releasePromise;
@@ -1,3 +1,4 @@
1
+ import { RUNTIME_TOOL_UPDATES_CAPABILITY } from "./runtime-tool-update-protocol.js";
1
2
  import { MAINTENANCE_CAPABILITY } from "./marketplace-maintenance-protocol.js";
2
3
  import { retryHarnessProbe } from "./runtimes/harness-probe-retry.js";
3
4
  import { MARKETPLACE_CAPABILITY } from "./marketplace-protocol.js";
@@ -40,6 +41,7 @@ export const NATIVE_PROJECT_WORKSPACE_READINESS = Object.freeze({
40
41
  nativeRuntimeAdaptersReady: true,
41
42
  });
42
43
  export const DAEMON_CAPABILITIES = Object.freeze([
44
+ RUNTIME_TOOL_UPDATES_CAPABILITY,
43
45
  MARKETPLACE_CAPABILITY,
44
46
  MAINTENANCE_CAPABILITY,
45
47
  "scheduled_job_v1",
@@ -74,7 +76,7 @@ export const daemonCapabilities = (runtimePlatform = process.platform, readiness
74
76
  const nativeWorkspaceReady = nativeWorkspacePlatform
75
77
  && readiness.resolverReady
76
78
  && readiness.nativeRuntimeAdaptersReady;
77
- return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => (((capability !== MARKETPLACE_CAPABILITY && capability !== MAINTENANCE_CAPABILITY) || nativeWorkspacePlatform)
79
+ return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => (((capability !== MARKETPLACE_CAPABILITY && capability !== MAINTENANCE_CAPABILITY && capability !== RUNTIME_TOOL_UPDATES_CAPABILITY) || nativeWorkspacePlatform)
78
80
  && (capability !== PROJECT_WORKSPACES_CAPABILITY || nativeWorkspaceReady)
79
81
  && (capability !== "message_file_preview_v1" || nativeWorkspacePlatform)
80
82
  && (nativeWorkspacePlatform
@@ -138,6 +138,7 @@ export function createMachineRuntimeEnvironment(options) {
138
138
  };
139
139
  return {
140
140
  provider,
141
+ refresh: refreshRuntimes,
141
142
  prewarm: () => {
142
143
  if (stopped || warmed)
143
144
  return;
package/dist/main.js CHANGED
File without changes
@@ -0,0 +1,126 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtemp, readFile, rm, stat, 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 { RuntimeToolResponseSchema } from "./runtime-tool-update-protocol.js";
7
+ export function runtimeToolEnvironment(env) {
8
+ // Keep only machine identity and lookup settings. Never pass runtime/control-plane credentials or Node injection flags.
9
+ return Object.fromEntries(Object.entries(env).filter(([key]) => /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TMP|TEMP|LANG|LC_[A-Z_]+|XDG_CONFIG_HOME|XDG_CACHE_HOME)$/u.test(key)));
10
+ }
11
+ async function readReport(dir) {
12
+ const file = join(dir, "report.json");
13
+ if ((await stat(file)).size > 256_000)
14
+ throw new Error("invalid_report");
15
+ return RuntimeToolResponseSchema.parse(JSON.parse(await readFile(file, "utf8")));
16
+ }
17
+ async function stopGroup(pid) {
18
+ if (!pid)
19
+ return true;
20
+ try {
21
+ process.kill(-pid, "SIGKILL");
22
+ }
23
+ catch (error) {
24
+ return error.code === "ESRCH";
25
+ }
26
+ const deadline = Date.now() + 3000;
27
+ while (Date.now() < deadline) {
28
+ try {
29
+ process.kill(-pid, 0);
30
+ }
31
+ catch (error) {
32
+ return error.code === "ESRCH";
33
+ }
34
+ await new Promise(resolve => setTimeout(resolve, 50));
35
+ }
36
+ return false;
37
+ }
38
+ export function createRuntimeToolProgressPoller(read, progress, intervalMs = 250) {
39
+ let active = true;
40
+ let pending = null;
41
+ const interval = setInterval(() => {
42
+ if (!active || pending || !progress)
43
+ return;
44
+ pending = read().then(async (data) => { if (active)
45
+ await progress(data); })
46
+ .catch(() => { }).finally(() => { pending = null; });
47
+ }, intervalMs);
48
+ return { async stop() { active = false; clearInterval(interval); await pending; } };
49
+ }
50
+ async function supervise(dir, env, start, progress, protectProcessGroup) {
51
+ const child = spawn(process.execPath, [join(dir, "runner.mjs"), "--managed-worker"], { cwd: dir, env, detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
52
+ const closed = new Promise(resolve => { child.once("error", () => resolve(null)); child.once("close", resolve); });
53
+ const polling = createRuntimeToolProgressPoller(() => readReport(dir), progress);
54
+ let expired = false;
55
+ const timer = setTimeout(() => { expired = true; void stopGroup(child.pid); }, start ? 12 * 60_000 : 120_000);
56
+ let launchFailed = false;
57
+ try {
58
+ if (start) {
59
+ if (!protectProcessGroup || !child.pid)
60
+ throw new Error("lease_protection_unavailable");
61
+ await protectProcessGroup(child.pid);
62
+ }
63
+ await new Promise((resolve, reject) => child.send({ type: "runtime-tools:go" }, error => error ? reject(error) : resolve()));
64
+ }
65
+ catch {
66
+ launchFailed = true;
67
+ await stopGroup(child.pid);
68
+ }
69
+ const code = await closed;
70
+ clearTimeout(timer);
71
+ await polling.stop();
72
+ // A successful parent exit does not prove its package-manager descendants stopped.
73
+ const stopped = await stopGroup(child.pid);
74
+ return { stopped, success: stopped && !expired && !launchFailed && code === 0 };
75
+ }
76
+ export async function runRuntimeToolBridge(config, request, acquireEnv, progress, deps = {}) {
77
+ let dir;
78
+ let started = false;
79
+ let processStopped = true;
80
+ const failure = (errorCode) => ({ processStopped, data: request.action === "start" ? { operation: {
81
+ operationId: request.operationId, state: started ? "unknown" : "failed", currentIndex: 0, targets: request.targets,
82
+ items: request.targets.map(t => ({ runtime: t.installation.runtime, state: started ? "unknown" : "failed", errorCode })),
83
+ updatedAt: new Date().toISOString(), errorCode,
84
+ } } : {} });
85
+ try {
86
+ const response = await (deps.fetch ?? fetch)(`${config.serverUrl.replace(/\/+$/, "")}/machine/runtime-tool-updates/runner/${request.runner.releaseId}`, {
87
+ headers: { authorization: `Bearer ${config.machineToken}` }, redirect: "error", signal: AbortSignal.timeout(15_000),
88
+ });
89
+ if (!response.ok || !response.body)
90
+ return failure("runner_unavailable");
91
+ const chunks = [];
92
+ let bytes = 0;
93
+ for await (const chunk of response.body) {
94
+ bytes += chunk.length;
95
+ if (bytes > 512_000)
96
+ return failure("runner_unavailable");
97
+ chunks.push(chunk);
98
+ }
99
+ const content = Buffer.concat(chunks);
100
+ if (createHash("sha256").update(content).digest("hex") !== request.runner.sha256)
101
+ return failure("runner_unavailable");
102
+ const env = runtimeToolEnvironment(await acquireEnv());
103
+ dir = await mkdtemp(join(tmpdir(), "nowwork-runtime-update-"));
104
+ await writeFile(join(dir, "runner.mjs"), content, { mode: 0o600 });
105
+ await writeFile(join(dir, "request.json"), JSON.stringify(request), { mode: 0o600 });
106
+ started = true;
107
+ processStopped = false;
108
+ const outcome = await supervise(dir, env, request.action === "start", progress, deps.protectProcessGroup);
109
+ processStopped = outcome.stopped;
110
+ if (!outcome.success)
111
+ return failure("operation_unknown");
112
+ const data = await readReport(dir);
113
+ if (request.action === "start" && data.operation?.operationId !== request.operationId)
114
+ return failure("operation_unknown");
115
+ if (request.action === "inspect" && !data.snapshot)
116
+ return failure("inspection_failed");
117
+ return { data, processStopped };
118
+ }
119
+ catch {
120
+ return failure(started ? "operation_unknown" : "runner_unavailable");
121
+ }
122
+ finally {
123
+ if (dir && processStopped)
124
+ await rm(dir, { recursive: true, force: true });
125
+ }
126
+ }
@@ -0,0 +1,151 @@
1
+ import { RuntimeToolRequestSchema, RuntimeToolResponseSchema } from "./runtime-tool-update-protocol.js";
2
+ const finalStates = new Set(["completed", "failed", "unknown", "cancelled"]);
3
+ const phaseRank = (state) => ({ queued: 0, preflight: 1, installing: 2, verifying: 3, completed: 4, failed: 4, unknown: 4, cancelled: 4 })[state];
4
+ function advancesProgress(previous, next) {
5
+ if (finalStates.has(previous.state) || finalStates.has(next.state)
6
+ || next.operationId !== previous.operationId || JSON.stringify(next.targets) !== JSON.stringify(previous.targets)
7
+ || next.currentIndex < previous.currentIndex || next.currentIndex >= previous.targets.length
8
+ || (next.currentIndex === previous.currentIndex && phaseRank(next.state) < phaseRank(previous.state)))
9
+ return false;
10
+ return previous.items.every((item, index) => {
11
+ const candidate = next.items[index];
12
+ if (!candidate || candidate.runtime !== item.runtime)
13
+ return false;
14
+ return finalStates.has(item.state) ? candidate.state === item.state && candidate.errorCode === item.errorCode
15
+ : phaseRank(candidate.state) >= phaseRank(item.state);
16
+ });
17
+ }
18
+ export function createRuntimeToolUpdateController(deps) {
19
+ const entries = new Map();
20
+ let current = null;
21
+ let stopped = false;
22
+ let orphaned = false;
23
+ const now = deps.now ?? Date.now;
24
+ const date = () => new Date(now()).toISOString();
25
+ const wait = deps.wait ?? (() => new Promise(resolve => setTimeout(resolve, 500)));
26
+ const fail = (error) => ({ ok: false, error });
27
+ const result = (data) => ({ ok: true, data });
28
+ const set = (entry, patch) => { entry.operation = { ...entry.operation, ...patch, updatedAt: date() }; };
29
+ async function execute(entry) {
30
+ const deadline = now() + 10 * 60_000;
31
+ let acceptingProgress = false;
32
+ let lease = null;
33
+ try {
34
+ while (!stopped && entry.operation.state === "queued" && now() < deadline && !lease) {
35
+ lease = await deps.acquire();
36
+ if (!lease)
37
+ await wait();
38
+ }
39
+ if (entry.operation.state === "cancelled")
40
+ return;
41
+ if (!lease || stopped || now() >= deadline) {
42
+ set(entry, { state: "failed", errorCode: "runtime_busy" });
43
+ return;
44
+ }
45
+ set(entry, { state: "preflight" });
46
+ // Mark unsafe before spawning: an exception or lost response cannot authorize another writer.
47
+ entry.processStopped = false;
48
+ acceptingProgress = true;
49
+ const output = await deps.run(entry.request, raw => {
50
+ if (!acceptingProgress)
51
+ return;
52
+ const parsed = RuntimeToolResponseSchema.safeParse(raw);
53
+ if (parsed.success && parsed.data.operation && advancesProgress(entry.operation, parsed.data.operation))
54
+ entry.operation = parsed.data.operation;
55
+ }, lease.protectProcessGroup);
56
+ acceptingProgress = false;
57
+ const data = RuntimeToolResponseSchema.parse(output.data);
58
+ entry.processStopped = output.processStopped;
59
+ if (!data.operation || data.operation.operationId !== entry.operation.operationId
60
+ || JSON.stringify(data.operation.targets) !== JSON.stringify(entry.request.targets)
61
+ || data.operation.currentIndex < entry.operation.currentIndex || !finalStates.has(data.operation.state))
62
+ throw new Error("operation_mismatch");
63
+ entry.operation = data.operation;
64
+ if (!entry.processStopped)
65
+ set(entry, { state: "unknown", errorCode: "operation_unknown" });
66
+ }
67
+ catch {
68
+ acceptingProgress = false;
69
+ set(entry, { state: "unknown", errorCode: "operation_unknown" });
70
+ }
71
+ finally {
72
+ acceptingProgress = false;
73
+ // Retain the barrier if process-group termination could not be proved.
74
+ if (entry.processStopped) {
75
+ try {
76
+ await lease?.release();
77
+ }
78
+ catch {
79
+ entry.processStopped = false;
80
+ set(entry, { state: "unknown", errorCode: "operation_unknown" });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return {
86
+ async handle(raw) {
87
+ const parsed = RuntimeToolRequestSchema.safeParse(raw);
88
+ if (!parsed.success || stopped)
89
+ return fail("invalid_request");
90
+ const request = parsed.data;
91
+ if (request.action === "inspect") {
92
+ try {
93
+ const data = RuntimeToolResponseSchema.parse((await deps.run(request)).data);
94
+ return data.snapshot ? result(data) : fail("inspection_failed");
95
+ }
96
+ catch {
97
+ return fail("inspection_failed");
98
+ }
99
+ }
100
+ const entry = entries.get(request.operationId);
101
+ if (request.action === "start") {
102
+ if (entry)
103
+ return result({ operation: entry.operation });
104
+ if (orphaned || [...entries.values()].some(x => x.operation.state === "unknown"))
105
+ return fail("operation_unknown");
106
+ if (current)
107
+ return fail("runtime_busy");
108
+ if (entries.size >= 100)
109
+ return fail("operation_limit");
110
+ if (new Set(request.targets.map(t => t.installation.runtime)).size !== request.targets.length)
111
+ return fail("invalid_request");
112
+ const operation = { operationId: request.operationId, state: "queued", currentIndex: 0, targets: request.targets,
113
+ items: request.targets.map(t => ({ runtime: t.installation.runtime, state: "queued", errorCode: null })), updatedAt: date(), errorCode: null };
114
+ const next = { operation, request, processStopped: true };
115
+ entries.set(request.operationId, next);
116
+ current = execute(next).finally(() => { current = null; });
117
+ return result({ operation });
118
+ }
119
+ if (!entry) {
120
+ orphaned = true;
121
+ return fail("operation_unknown");
122
+ }
123
+ if (request.action === "status")
124
+ return result({ operation: entry.operation });
125
+ if (request.action === "cancel") {
126
+ if (entry.operation.state !== "queued")
127
+ return fail("already_started");
128
+ set(entry, { state: "cancelled", items: entry.operation.items.map(x => ({ ...x, state: "cancelled" })) });
129
+ return result({ operation: entry.operation });
130
+ }
131
+ if (!entry.processStopped || current)
132
+ return fail("operation_unknown");
133
+ try {
134
+ const output = await deps.run({ action: "inspect", runner: entry.request.runner });
135
+ const snapshot = RuntimeToolResponseSchema.parse(output.data).snapshot;
136
+ if (!snapshot || !output.processStopped)
137
+ return fail("operation_unknown");
138
+ const matches = entry.operation.targets.every(target => snapshot.tools.some(tool => tool.runtime === target.installation.runtime
139
+ && tool.version === target.targetVersion && tool.path === target.installation.path && tool.source === target.installation.source
140
+ && tool.prefix === target.installation.prefix && tool.brewPath === target.installation.brewPath && tool.automatic));
141
+ set(entry, { state: matches ? "completed" : "failed", errorCode: matches ? null : "verification_failed", snapshot,
142
+ items: entry.operation.items.map(x => ({ ...x, state: matches ? "completed" : "failed", errorCode: matches ? null : "verification_failed" })) });
143
+ return result({ operation: entry.operation, snapshot });
144
+ }
145
+ catch {
146
+ return fail("inspection_failed");
147
+ }
148
+ },
149
+ async stop() { stopped = true; await current; },
150
+ };
151
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ export const RUNTIME_TOOL_UPDATES_CAPABILITY = "runtime_tool_updates_v1";
3
+ const boundedPath = z.string().min(1).max(4096);
4
+ export const RuntimeToolRunnerSchema = z.object({ releaseId: z.string().regex(/^[a-f0-9]{64}$/), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
5
+ export const RuntimeToolInstallationSchema = z.object({
6
+ runtime: z.enum(["codex", "claude"]), path: boundedPath, realPath: boundedPath,
7
+ version: z.string().max(100).nullable(), source: z.enum(["npm", "homebrew", "native", "unknown"]),
8
+ nodeVersion: z.string().max(100).nullable(), nodePath: boundedPath.nullable(), npmPath: boundedPath.nullable(), prefix: boundedPath.nullable(),
9
+ brewPath: boundedPath.nullable(), brewKind: z.enum(["formula", "cask"]).nullable(), brewName: z.enum(["codex", "claude-code"]).nullable(),
10
+ fingerprint: z.string().regex(/^[a-f0-9]{64}$/), alternatives: z.array(boundedPath).max(32), automatic: z.boolean(), reason: z.string().max(100).nullable(),
11
+ }).strict();
12
+ export const RuntimeToolSnapshotSchema = z.object({ checkedAt: z.string().datetime(), tools: z.array(RuntimeToolInstallationSchema).max(2) }).strict();
13
+ export const RuntimeToolTargetSchema = z.object({ installation: RuntimeToolInstallationSchema, targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/).max(100) }).strict();
14
+ export const RuntimeToolStateSchema = z.enum(["queued", "preflight", "installing", "verifying", "completed", "failed", "unknown", "cancelled"]);
15
+ export const RuntimeToolOperationSchema = z.object({ operationId: z.string().uuid(), state: RuntimeToolStateSchema, currentIndex: z.number().int().min(0).max(2),
16
+ targets: z.array(RuntimeToolTargetSchema).min(1).max(2), items: z.array(z.object({ runtime: z.enum(["codex", "claude"]), state: RuntimeToolStateSchema, errorCode: z.string().max(100).nullable() }).strict()).max(2),
17
+ updatedAt: z.string().datetime(), errorCode: z.string().max(100).nullable(), snapshot: RuntimeToolSnapshotSchema.optional(),
18
+ }).strict();
19
+ export const RuntimeToolRequestSchema = z.discriminatedUnion("action", [
20
+ z.object({ action: z.literal("inspect"), runner: RuntimeToolRunnerSchema }).strict(),
21
+ z.object({ action: z.literal("start"), operationId: z.string().uuid(), targets: z.array(RuntimeToolTargetSchema).min(1).max(2), runner: RuntimeToolRunnerSchema }).strict(),
22
+ ...["status", "cancel", "verify"].map(action => z.object({ action: z.literal(action), operationId: z.string().uuid() }).strict()),
23
+ ]);
24
+ export const RuntimeToolResponseSchema = z.object({ snapshot: RuntimeToolSnapshotSchema.optional(), operation: RuntimeToolOperationSchema.optional() }).strict();
@@ -0,0 +1,54 @@
1
+ import { prepareDaemonRestart } from "./daemon-updater.js";
2
+ import { createRuntimeToolUpdateController } from "./runtime-tool-update-controller.js";
3
+ import { augmentedPath } from "./runtime-path.js";
4
+ import { runRuntimeToolBridge } from "./runtime-tool-update-bridge.js";
5
+ export async function acquireRuntimeToolLease(localSlots, host) {
6
+ const local = await prepareDaemonRestart({ localSlots });
7
+ if (!local.ok)
8
+ return null;
9
+ try {
10
+ const shared = await host.tryAcquireExclusiveExecution();
11
+ if (!shared) {
12
+ await local.release();
13
+ return null;
14
+ }
15
+ let released = false;
16
+ return { protectProcessGroup: async (pid) => {
17
+ if (!shared.protectProcessGroup)
18
+ throw new Error("lease protection unavailable");
19
+ await shared.protectProcessGroup(pid);
20
+ }, async release() { if (released)
21
+ return; await shared.release(); await local.release(); released = true; } };
22
+ }
23
+ catch (error) {
24
+ await local.release();
25
+ throw error;
26
+ }
27
+ }
28
+ export function createServeRuntimeToolUpdates(config, localSlots, host, acquireEnv, refresh) {
29
+ const controller = createRuntimeToolUpdateController({ acquire: () => acquireRuntimeToolLease(localSlots, host),
30
+ run: async (request, progress, protectProcessGroup) => {
31
+ const result = await runRuntimeToolBridge(config, request, async () => {
32
+ const env = await acquireEnv();
33
+ return { ...env, PATH: augmentedPath(env, {}, process.platform, "append") };
34
+ }, progress, { ...(protectProcessGroup ? { protectProcessGroup } : {}) });
35
+ if (request.action === "start" && result.processStopped)
36
+ refresh();
37
+ return result;
38
+ } });
39
+ return {
40
+ handle(raw, send) {
41
+ if (!raw || typeof raw !== "object" || !("type" in raw) || raw.type !== "runtime-tools:operation")
42
+ return false;
43
+ const { reqId, type: _type, ...request } = raw;
44
+ if (typeof reqId === "string") {
45
+ if (process.platform !== "linux" && process.platform !== "darwin")
46
+ send({ type: "fs:result", reqId, ok: false, error: "unsupported_platform" });
47
+ else
48
+ void controller.handle(request).then(result => send({ type: "fs:result", reqId, ...result })).catch(() => send({ type: "fs:result", reqId, ok: false, error: "operation_unknown" }));
49
+ }
50
+ return true;
51
+ },
52
+ stop: controller.stop,
53
+ };
54
+ }
package/dist/serve.js CHANGED
@@ -48,6 +48,7 @@ import { initializedProjectSkillsReconciler } from "./project-skills/initialized
48
48
  import { parseMemoryPruneTraceId } from "./memory-prune-diagnostics.js";
49
49
  import { probeRuntimeHealth } from "./runtime-health.js";
50
50
  import { createServeMarketplace } from "./marketplace-serve.js";
51
+ import { createServeRuntimeToolUpdates } from "./runtime-tool-update-serve.js";
51
52
  import { buildControlPlaneUrl } from "./control-plane-url.js";
52
53
  import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
53
54
  import { bindStorageServerIdentity, createStorageIdentityAdmission, } from "./storage-serve-adapter.js";
@@ -210,6 +211,7 @@ export function serve(config, opts = {}) {
210
211
  onError: (code, context) => dslog("execution.runtime_detection_failed", "机器环境或 runtime 探测失败", { level: "ERROR", error_code: code, ...context }),
211
212
  });
212
213
  machineRuntime.prewarm();
214
+ const runtimeToolUpdates = createServeRuntimeToolUpdates(config, localSlots, hostCoordinator, async () => ({ ...(await machineRuntime.provider.acquire()).env }), () => machineRuntime.refresh());
213
215
  const initializedProjectSkills = initializedProjectSkillsReconciler(() => projectSkillsController.ensureScanned(), projectSkillsReconciler);
214
216
  const configuredCapabilities = opts.machineInfo?.capabilities ?? daemonCapabilities(process.platform);
215
217
  const prepareCapabilities = async () => daemonCapabilityBindings(process.platform, [...configuredCapabilities, ...await managedDaemonCapabilities(updateEligibility)]);
@@ -842,6 +844,12 @@ export function serve(config, opts = {}) {
842
844
  }
843
845
  return;
844
846
  }
847
+ const runtimeUpdateSocket = ws;
848
+ if (runtimeToolUpdates.handle(msg, frame => { try {
849
+ runtimeUpdateSocket?.send(JSON.stringify(frame));
850
+ }
851
+ catch { /* Server reconciles via status. */ } }))
852
+ return;
845
853
  if (marketplace.handle(msg, frame => { try {
846
854
  ws?.send(JSON.stringify(frame));
847
855
  }
@@ -1326,6 +1334,7 @@ export function serve(config, opts = {}) {
1326
1334
  webSocketClosed,
1327
1335
  updateController.drain(),
1328
1336
  marketplace.stop(),
1337
+ runtimeToolUpdates.stop(),
1329
1338
  executionFrameQueue,
1330
1339
  ...executionRuns.values(),
1331
1340
  ...[...legacyRuns.values()].map((run) => run.done),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.101-beta.3",
3
+ "version": "0.6.101-beta.4",
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,12 +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
- "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",
48
- "typecheck": "tsc --noEmit"
49
50
  }
50
- }
51
+ }