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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +20 -16
  2. package/dist/automatic-daemon-installer.js +15 -11
  3. package/dist/automatic-service-capture.js +27 -0
  4. package/dist/computer-main.js +0 -0
  5. package/dist/execution-journal-lock.js +51 -2
  6. package/dist/execution-protocol.js +2 -0
  7. package/dist/execution-runner.js +19 -13
  8. package/dist/host-execution-coordinator.js +10 -0
  9. package/dist/local-executor.js +8 -1
  10. package/dist/machine-info.js +4 -1
  11. package/dist/machine-runtime-environment.js +1 -0
  12. package/dist/main.js +0 -0
  13. package/dist/managed-service-lifecycle.js +10 -0
  14. package/dist/project-skills/execution-projection.js +4 -5
  15. package/dist/project-skills/reconciler.js +8 -9
  16. package/dist/project-skills/scanner.js +9 -2
  17. package/dist/project-skills/source-directory.js +13 -0
  18. package/dist/runtime-observation.js +219 -0
  19. package/dist/runtime-tool-update-bridge.js +126 -0
  20. package/dist/runtime-tool-update-controller.js +151 -0
  21. package/dist/runtime-tool-update-protocol.js +24 -0
  22. package/dist/runtime-tool-update-serve.js +54 -0
  23. package/dist/runtimes/claude-observation-runner.js +55 -0
  24. package/dist/runtimes/claude-thread-runner.js +17 -3
  25. package/dist/runtimes/claude.js +5 -3
  26. package/dist/runtimes/codex-app-server-runner.js +16 -2
  27. package/dist/runtimes/deepseek-harness-runner.js +16 -0
  28. package/dist/runtimes/kimi-acp-runner.js +19 -0
  29. package/dist/runtimes/observation-context.js +18 -0
  30. package/dist/runtimes/observation-mapper.js +314 -0
  31. package/dist/runtimes/opencode-runner.js +18 -4
  32. package/dist/runtimes/pi-runner.js +17 -1
  33. package/dist/serve.js +9 -0
  34. package/dist/token.js +4 -1
  35. package/package.json +9 -10
package/README.md CHANGED
@@ -434,23 +434,27 @@ path-free inventory and logical `(projectId, skillName)` bindings. The local pat
434
434
  while an add command is forwarded to the selected online Computer; it is not written to the workspace
435
435
  database, structured logs, inventory frames, or responses. Project Skills are disabled on Windows.
436
436
 
437
- One binding set is projected into both Runtime discovery layouts:
437
+ Skill entries may be real directories or directory symlinks, including relative, absolute, chained,
438
+ and shared directories outside the project. Only direct, non-hidden entries are scanned. Broken,
439
+ cyclic, inaccessible, and non-directory links count as invalid Skills without hiding healthy entries;
440
+ the existing frontmatter, size, name-conflict, and inventory limits apply to linked Skills too.
441
+
442
+ Each execution projects its binding set into a private Runtime discovery directory:
438
443
 
439
444
  ```text
440
- <agentsRoot>/<handle>/.agents/skills/<name> # Codex symlink
441
- <agentsRoot>/<handle>/.crew/claude-skills/.claude/skills/<name> # Claude symlink
445
+ <agentsRoot>/<handle>/.crew/project-skills/exec-<executionId>/runtime/.claude/skills/<name>
442
446
  ```
443
447
 
444
- The two complete directories switch under one per-Agent reconcile/launch lock. A failed switch restores
445
- the prior complete projection; a missing project or Skill removes only that link and does not block other
446
- work. These are ordinary writable symlinks, not snapshots or a sandbox: an Agent running as the daemon
447
- user can modify the source Skill through them. That write-through risk is intentionally accepted.
448
+ The scanner retains the entry path. Binding checks and execution startup resolve its current real
449
+ directory and check for `SKILL.md`; missing sources warn and are omitted without blocking other work.
450
+ Each execution links to that resolved directory, so retargeting an entry affects subsequent executions
451
+ while active executions keep their original target. Source file edits remain live. The projection is
452
+ removed after the runtime process tree stops; source directories and user-created links are preserved.
453
+ These are ordinary writable symlinks, not snapshots or a sandbox: an Agent running as the daemon user
454
+ can modify the source Skill through them. That write-through risk is intentionally accepted.
448
455
 
449
- Codex protocol-v1 task directories are opaque
450
- `<agentsRoot>/<handle>/tasks/<safe-prefix>-<sha256>` paths. The daemon writes `.nowwork-root` at the Agent
451
- root and adds it to Codex `project_root_markers`, so upward discovery reaches `.agents/skills` without
452
- changing the task cwd. Claude receives
453
- `--add-dir <agentsRoot>/<handle>/.crew/claude-skills`; Claude treats those Skills as `projectSettings`,
456
+ Codex registers the execution's `runtime/.claude/skills` through `skills/extraRoots/set`. Claude receives
457
+ the execution's `runtime` directory through `--add-dir`; Claude treats those Skills as `projectSettings`,
454
458
  so a machine policy that disables project settings also disables this source even when the projection is
455
459
  healthy.
456
460
 
@@ -467,10 +471,10 @@ scope is merged. The policy version in Codex's session fingerprint cold-starts o
467
471
  once, then subsequent runs can resume normally. Explicit `shell_environment_policy.set` entries, shell
468
472
  startup scripts and Agent notes remain separate configuration; this change does not correct them.
469
473
 
470
- Binding changes reconcile immediately when the Computer is online. On reconnect the server sends the
471
- complete binding set for every Agent assigned to that Computer, including empty sets that clear stale
472
- links left by offline unbinds. Every non-empty execution snapshot is also reconciled immediately before
473
- Runtime launch, which recovers from a missed notification or daemon restart. Skill file content changes
474
+ Binding changes are checked immediately when the Computer is online. On reconnect the server sends the
475
+ complete binding set for every Agent assigned to that Computer, including empty sets. Each execution
476
+ uses its server-snapshotted binding set and revalidates local source paths immediately before Runtime
477
+ launch. Skill file content changes
474
478
  are visible through the symlink immediately; adding, deleting, renaming, or changing frontmatter requires
475
479
  a rescan. Web descriptions remain at the last inventory value until that rescan.
476
480
 
@@ -6,7 +6,8 @@ import { assertProfileAgentsRootUnique, listProfiles, loadProfile, MachineTokenS
6
6
  import { buildServiceSpec, captureExactService, hardenWindowsProfile, replaceExactService, restoreExactService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, } from "./computer-service.js";
7
7
  import { daemonGlobalInstallation } from "./daemon-installation.js";
8
8
  import { buildManagedServiceRecord, installManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
9
- import { ManagedServiceConflictError, managedServiceConflict, managedServiceIdentityMatches, readManagedServiceRegistry, } from "./managed-service-registry.js";
9
+ import { readManagedServiceRegistry, } from "./managed-service-registry.js";
10
+ import { captureManagedServiceForSetup } from "./automatic-service-capture.js";
10
11
  import { resolveAutomaticInstallTarget, } from "./automatic-install-target.js";
11
12
  import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
12
13
  import { daemonVersion } from "./machine-info.js";
@@ -80,7 +81,6 @@ function defaultDependencies() {
80
81
  },
81
82
  captureService: async (spec, target) => {
82
83
  if (platform !== "win32") {
83
- managedRecords = await readManagedServiceRegistry({ userHome, localHome: localHomeForTarget(target, platform) });
84
84
  const entryPath = spec.daemonCommand[1];
85
85
  const installation = daemonGlobalInstallation(entryPath, platform);
86
86
  if (installation === null) {
@@ -94,14 +94,13 @@ function defaultDependencies() {
94
94
  entryPath,
95
95
  ...installation,
96
96
  });
97
- const { generation: _generation, createdAt: _createdAt, ...expected } = candidate;
98
- previousManagedRecord = managedRecords.find((record) => managedServiceIdentityMatches(record, expected));
99
- if (previousManagedRecord === undefined) {
100
- const conflict = managedServiceConflict(managedRecords, candidate);
101
- if (conflict !== null) {
102
- throw new ManagedServiceConflictError(conflict.resource, candidate, conflict.owner);
103
- }
104
- }
97
+ const captured = await captureManagedServiceForSetup({
98
+ spec, record: candidate, runner,
99
+ registry: { userHome, localHome: localHomeForTarget(target, platform) },
100
+ });
101
+ managedRecords = captured.managedRecords;
102
+ previousManagedRecord = captured.previousManagedRecord;
103
+ return captured.snapshot;
105
104
  }
106
105
  return captureExactService(spec, runner);
107
106
  },
@@ -175,7 +174,12 @@ function defaultDependencies() {
175
174
  ? localHome
176
175
  : localHomeForNpmPrefix(previousManagedRecord.npmPrefix, platform);
177
176
  const current = (await readManagedServiceRegistry({ userHome, localHome: restoreLocalHome })).find((record) => record.serviceId === spec.id);
178
- if (current !== undefined && JSON.stringify(current) !== JSON.stringify(previousManagedRecord)) {
177
+ const onlyHashChanged = current !== undefined && previousManagedRecord !== undefined
178
+ && JSON.stringify({ ...current, descriptorSha256: previousManagedRecord.descriptorSha256 })
179
+ === JSON.stringify(previousManagedRecord);
180
+ // Preflight may have captured the exact descriptor while leaving a stale
181
+ // registry hash untouched. Let installManagedService reconcile it in place.
182
+ if (current !== undefined && !onlyHashChanged && JSON.stringify(current) !== JSON.stringify(previousManagedRecord)) {
179
183
  await uninstallManagedService({
180
184
  spec,
181
185
  record: current,
@@ -0,0 +1,27 @@
1
+ import { captureExactService } from "./computer-service.js";
2
+ import { ManagedServiceConflictError, ManagedServiceRegistryError, managedServiceConflict, managedServiceIdentityMatches, readManagedServiceRegistry, } from "./managed-service-registry.js";
3
+ /** Read-only preflight; keep the record needed to restore the captured service. */
4
+ export async function captureManagedServiceForSetup(input) {
5
+ const managedRecords = await readManagedServiceRegistry(input.registry);
6
+ const { generation: _generation, createdAt: _createdAt, ...expected } = input.record;
7
+ const registered = managedRecords.find((record) => managedServiceIdentityMatches(record, {
8
+ ...expected, descriptorSha256: record.descriptorSha256,
9
+ }));
10
+ if (registered === undefined) {
11
+ const conflict = managedServiceConflict(managedRecords, input.record);
12
+ if (conflict !== null) {
13
+ throw new ManagedServiceConflictError(conflict.resource, input.record, conflict.owner);
14
+ }
15
+ }
16
+ const snapshot = await captureExactService(input.spec, input.runner);
17
+ if (registered !== undefined && registered.descriptorSha256 !== expected.descriptorSha256
18
+ && (!snapshot.installed || !snapshot.exact)) {
19
+ throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match the requested identity`);
20
+ }
21
+ // A stale hash may be reconciled later under the registry lock. Rollback must
22
+ // refer to the descriptor we actually captured, not the stale registry hash.
23
+ const previousManagedRecord = registered === undefined ? undefined : {
24
+ ...registered, descriptorSha256: expected.descriptorSha256,
25
+ };
26
+ return { managedRecords, previousManagedRecord, snapshot };
27
+ }
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 });
@@ -207,6 +207,8 @@ export const ExecutionStartSchema = z.object({
207
207
  model: z.string().optional(),
208
208
  reasoning: ReasoningSchema.optional(),
209
209
  timeoutMs: z.number().int().positive().optional(),
210
+ // Server-owned policy; 0 disables the total runtime timer. Capability-gated.
211
+ taskTimeoutMs: z.number().int().min(0).max(2_145_600_000).optional(),
210
212
  }).strict(),
211
213
  permissions: z.object({
212
214
  requested: PermissionSchema,
@@ -416,7 +416,7 @@ export async function runExecution(config, input, dependencies) {
416
416
  return { kind: "rejected", frame };
417
417
  }
418
418
  const { permission } = checked;
419
- const effectiveTimeoutMs = spec.runtime.timeoutMs ?? config.executionLimits.maxTimeoutMs;
419
+ const effectiveTimeoutMs = spec.runtime.taskTimeoutMs ?? spec.runtime.timeoutMs ?? config.executionLimits.maxTimeoutMs;
420
420
  const accepted = await dependencies.journal.accept(spec.executionId, specHash, {
421
421
  runtime: spec.runtime.name,
422
422
  ...(spec.runtime.model === undefined ? {} : { model: spec.runtime.model }),
@@ -492,6 +492,7 @@ export async function runExecution(config, input, dependencies) {
492
492
  let timeout;
493
493
  let timedOut = false;
494
494
  let firstOutputTimer;
495
+ let runtimeTimersStarted = false;
495
496
  let firstOutputTimedOut = false;
496
497
  let abandonedRuntimeRunningCompletion = null;
497
498
  let completion;
@@ -617,7 +618,8 @@ export async function runExecution(config, input, dependencies) {
617
618
  if (dependencies.cancellation?.isRequested())
618
619
  return;
619
620
  const cancel = runtimeCancel;
620
- if (cancel !== null && timeout === undefined && !dependencies.cancellation?.isRequested()) {
621
+ if (cancel !== null && !runtimeTimersStarted && !dependencies.cancellation?.isRequested()) {
622
+ runtimeTimersStarted = true;
621
623
  const firstOutputDeadline = config.executionLimits.maxFirstOutputWaitMs
622
624
  + config.executionLimits.firstOutputGraceMs;
623
625
  firstOutputTimer = setTimeout(() => {
@@ -629,15 +631,16 @@ export async function runExecution(config, input, dependencies) {
629
631
  rejectCancellationFailure(error);
630
632
  }
631
633
  }, firstOutputDeadline);
632
- timeout = setTimeout(() => {
633
- timedOut = true;
634
- try {
635
- void cancel().catch(rejectCancellationFailure);
636
- }
637
- catch (error) {
638
- rejectCancellationFailure(error);
639
- }
640
- }, effectiveTimeoutMs);
634
+ if (effectiveTimeoutMs > 0)
635
+ timeout = setTimeout(() => {
636
+ timedOut = true;
637
+ try {
638
+ void cancel().catch(rejectCancellationFailure);
639
+ }
640
+ catch (error) {
641
+ rejectCancellationFailure(error);
642
+ }
643
+ }, effectiveTimeoutMs);
641
644
  }
642
645
  dependencies.onRuntimePhase?.("running", spec.runtime.name);
643
646
  const journalStartedAt = Date.now();
@@ -706,9 +709,11 @@ export async function runExecution(config, input, dependencies) {
706
709
  catch { /* best-effort activity omitted when its envelope cannot fit */ }
707
710
  },
708
711
  } : {}),
709
- ...(spec.reporting.streamConsole ? {
712
+ ...(spec.reporting.streamConsole || credential.observability ? {
710
713
  onConsole: (chunk) => {
711
- if (firstOutputTimer !== undefined) {
714
+ if (!spec.reporting.streamConsole && chunk.payload?.kind !== "agent_observation")
715
+ return;
716
+ if (chunk.payload?.kind !== "agent_observation" && firstOutputTimer !== undefined) {
712
717
  clearTimeout(firstOutputTimer);
713
718
  firstOutputTimer = undefined;
714
719
  }
@@ -856,6 +861,7 @@ export async function runExecution(config, input, dependencies) {
856
861
  return appendAgentMemoryContext(base, recalledMemory, Math.max(Buffer.byteLength(base, "utf8"), baseSystemPromptBudget - (context.reservedPromptBytes ?? 0)));
857
862
  };
858
863
  const localInput = {
864
+ ...(credential.observability ? { observability: credential.observability } : {}),
859
865
  executionId: spec.executionId,
860
866
  handle: spec.agent.handle,
861
867
  channelId: spec.context.channelId,
@@ -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;
@@ -46,6 +46,7 @@ import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace,
46
46
  import { prepareLocalMemoryPrompt } from "./local-memory/prompt.js";
47
47
  import { reportLocalMemoryV2Deferred, resolveLocalMemoryV2Rollout } from "./local-memory/rollout.js";
48
48
  import { localMemoryV2Environment } from "./config.js";
49
+ import { observationEnvironment, safeRuntimeObservation } from "./runtime-observation.js";
49
50
  export class RuntimeRunningCallbackTimeoutError extends Error {
50
51
  constructor(runtime, timeoutMs) {
51
52
  super(`${runtime} runtime-running callback timed out after ${timeoutMs}ms`);
@@ -592,7 +593,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
592
593
  systemPromptPath: workspace.systemPromptPath,
593
594
  systemPrompt: effectiveSystemPrompt,
594
595
  wakePrompt,
595
- env: runtimeEnv,
596
+ env: observationEnvironment(runtimeEnv, input.observability),
596
597
  effectivePermission: input.effectivePermission,
597
598
  ...(launchModel === undefined ? {} : { model: launchModel }),
598
599
  ...(launchReasoning === undefined ? {} : { reasoning: launchReasoning }),
@@ -787,6 +788,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
787
788
  const event = parseLine(line);
788
789
  if (!event)
789
790
  return;
791
+ if (typeof event === "object" && "type" in event && event.type === "nowwork.observation") {
792
+ const payload = safeRuntimeObservation(event.observation, input.observability);
793
+ if (payload)
794
+ callbacks.onConsole?.({ stream: "system", text: "", payload });
795
+ return;
796
+ }
790
797
  nativeThreadContext?.observe(event);
791
798
  localMemoryTelemetry?.observe(event);
792
799
  if (isRuntimeReadyEvent(runtime.name, event))
@@ -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,8 @@ 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,
45
+ "execution_task_timeout_v1",
43
46
  MARKETPLACE_CAPABILITY,
44
47
  MAINTENANCE_CAPABILITY,
45
48
  "scheduled_job_v1",
@@ -74,7 +77,7 @@ export const daemonCapabilities = (runtimePlatform = process.platform, readiness
74
77
  const nativeWorkspaceReady = nativeWorkspacePlatform
75
78
  && readiness.resolverReady
76
79
  && readiness.nativeRuntimeAdaptersReady;
77
- return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => (((capability !== MARKETPLACE_CAPABILITY && capability !== MAINTENANCE_CAPABILITY) || nativeWorkspacePlatform)
80
+ return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => (((capability !== MARKETPLACE_CAPABILITY && capability !== MAINTENANCE_CAPABILITY && capability !== RUNTIME_TOOL_UPDATES_CAPABILITY) || nativeWorkspacePlatform)
78
81
  && (capability !== PROJECT_WORKSPACES_CAPABILITY || nativeWorkspaceReady)
79
82
  && (capability !== "message_file_preview_v1" || nativeWorkspacePlatform)
80
83
  && (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
@@ -103,6 +103,16 @@ export async function installManagedService(input) {
103
103
  && record.daemonHome === input.record.daemonHome);
104
104
  if (registered !== undefined && sameManagedResource(registered, input.record)) {
105
105
  const current = await readServiceDescriptor(input.spec);
106
+ // Descriptor replacement and registry commit can be interrupted separately.
107
+ // Adopt only the exact requested descriptor with all resource fields intact.
108
+ if (current === requestedDescriptor) {
109
+ assertRecordDescribesSpec(input);
110
+ await ensureServiceEnabled(input.spec, input.runner);
111
+ await replace(services.map((record) => record === registered
112
+ ? { ...record, descriptorSha256: expectedDescriptorHash }
113
+ : record));
114
+ return;
115
+ }
106
116
  if (current === null || serviceDescriptorSha256(current) !== registered.descriptorSha256) {
107
117
  throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match its registry record`);
108
118
  }
@@ -85,11 +85,10 @@ export async function materializeExecutionProjection(input) {
85
85
  continue;
86
86
  let projected;
87
87
  try {
88
- // `projectDirectory`'s symlink path never lstats the source (POSIX symlink() happily
89
- // creates a dangling link), so a source that vanished between scan and materialize would
90
- // otherwise be silently projected as a broken link instead of failing loudly.
91
- const sourceExists = await lstat(record.sourcePath).then(() => true, () => false);
92
- if (!sourceExists)
88
+ // 源路径已由 reconciler 解析;拒绝解析后消失、变成文件或被替换为链接的目录。
89
+ // POSIX symlink() 本身允许断链,不能用“创建链接成功”代替源目录检查。
90
+ const source = await lstat(record.sourcePath).catch(() => null);
91
+ if (!source?.isDirectory() || source.isSymbolicLink())
93
92
  throw new ExecutionProjectionError("link_failed");
94
93
  projected = await project(record.sourcePath, join(root.codexSkillRoot, record.skillName), platform);
95
94
  }
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Project Skill 解析 + 执行编排。
3
- * reconcile: server 同步绑定时只解析"哪些 skill 在本机可用",不碰磁盘。
3
+ * reconcile: server 同步绑定时只读取"哪些 skill 在本机可用",不写入投影。
4
4
  * prepareAndLaunch: 为这一次 execution 建独占投影目录 → 启动 runtime → 进程树确认退出后删除。
5
5
  * 没有共享目录,所以没有锁、租约、manifest、GC。ownership 未确认时目录保留,交给启动 sweep。
6
6
  */
7
- import { lstat, stat } from "node:fs/promises";
7
+ import { stat } from "node:fs/promises";
8
8
  import { join, resolve, sep } from "node:path";
9
9
  import { ExecutionProjectionError, materializeExecutionProjection, removeExecutionProjection, } from "./execution-projection.js";
10
10
  import { projectSkillResolutionRecords } from "./scanner.js";
11
+ import { resolveProjectSkillDirectory } from "./source-directory.js";
11
12
  import { compareProjectSkillRefs, } from "./types.js";
12
13
  export class ProjectProjectionError extends Error {
13
14
  code;
@@ -46,16 +47,14 @@ const canonicalConflictFreeBindings = (bindings) => {
46
47
  }
47
48
  return Object.freeze(unique);
48
49
  };
49
- /** 源目录必须是真实目录且有 SKILL.md,否则按 missing 处理(warning,不阻断)。 */
50
+ /** 每次从扫描入口解析真实目录,固定本次执行的链接目标;不可用则 warning,不阻断。 */
50
51
  const actualResolutionRecords = async (bindings, projects, platform) => Object.freeze(await Promise.all(projectSkillResolutionRecords(bindings, projects, platform).map(async (record) => {
51
52
  if (record.mode === "missing" || record.sourcePath === null)
52
53
  return record;
53
- const [source, skillFile] = await Promise.all([
54
- lstat(record.sourcePath).catch(() => null),
55
- stat(join(record.sourcePath, "SKILL.md")).catch(() => null),
56
- ]);
57
- if (source?.isDirectory() === true && !source.isSymbolicLink() && skillFile?.isFile() === true) {
58
- return record;
54
+ const directory = await resolveProjectSkillDirectory(record.sourcePath);
55
+ const skillFile = directory === null ? null : await stat(join(directory, "SKILL.md")).catch(() => null);
56
+ if (directory !== null && skillFile?.isFile() === true) {
57
+ return Object.freeze({ ...record, sourcePath: directory });
59
58
  }
60
59
  return Object.freeze({
61
60
  projectId: record.projectId,
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { compareProjectSkillRefs, isProjectSkillName, MAX_MACHINE_PROJECT_SKILLS, MAX_PROJECT_SKILL_DESCRIPTION_LENGTH, MAX_PROJECT_SKILL_FILE_BYTES, MAX_PROJECT_SKILLS_PER_PROJECT, } from "./types.js";
4
4
  import { parseSkillFrontmatter } from "../skill-frontmatter.js";
5
5
  import { normalizeProjectSkillResolutionRecords } from "./resolution-records.js";
6
+ import { resolveProjectSkillDirectory } from "./source-directory.js";
6
7
  const unavailable = (projectId, scannedAt, errorCode) => Object.freeze({
7
8
  inventory: Object.freeze({
8
9
  projectId,
@@ -45,10 +46,15 @@ export async function scanProject(project, now = () => new Date()) {
45
46
  let invalidSkillCount = 0;
46
47
  const candidates = [];
47
48
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
48
- if (entry.name.startsWith(".") || !entry.isDirectory())
49
+ if (entry.name.startsWith(".") || (!entry.isDirectory() && !entry.isSymbolicLink()))
49
50
  continue;
50
51
  const sourcePath = join(skillsRoot, entry.name);
51
- const skillPath = join(sourcePath, "SKILL.md");
52
+ const directory = await resolveProjectSkillDirectory(sourcePath);
53
+ if (directory === null) {
54
+ invalidSkillCount += 1;
55
+ continue;
56
+ }
57
+ const skillPath = join(directory, "SKILL.md");
52
58
  const skillStat = await stat(skillPath).catch(() => null);
53
59
  if (!skillStat?.isFile() || skillStat.size > MAX_PROJECT_SKILL_FILE_BYTES) {
54
60
  invalidSkillCount += 1;
@@ -70,6 +76,7 @@ export async function scanProject(project, now = () => new Date()) {
70
76
  invalidSkillCount += 1;
71
77
  continue;
72
78
  }
79
+ // 保留入口:执行前重新解析,才能识别链接删除或改指。
73
80
  candidates.push(Object.freeze({ name, description, sourcePath }));
74
81
  }
75
82
  const counts = new Map();
@@ -0,0 +1,13 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ /** 跟随用户放置的源链接;只在本次操作中使用真实目录,不把它缓存为扫描入口。 */
3
+ export async function resolveProjectSkillDirectory(sourcePath) {
4
+ try {
5
+ const directory = await realpath(sourcePath);
6
+ const info = await lstat(directory);
7
+ return info.isDirectory() && !info.isSymbolicLink() ? directory : null;
8
+ }
9
+ catch {
10
+ // 断链、循环或权限不足只使这个 Skill 不可用,不影响其它 Skill。
11
+ return null;
12
+ }
13
+ }