@nowcrew/daemon 0.5.34 → 0.5.36

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
@@ -166,6 +166,52 @@ Old daemons that omit `executionRuntimes` are conservatively interpreted as the
166
166
  CLIs and server-supported adapters. Unknown required protocol semantics are rejected before side effects.
167
167
  Protocol-0 `agent:start` remains only for the server-governed compatibility window.
168
168
 
169
+ ## Project Skills
170
+
171
+ On macOS and Linux, an owner or admin can register multiple local project repositories from a Computer
172
+ profile and bind selected Skills to individual Agents. The daemon keeps the only durable copy of each
173
+ absolute path in:
174
+
175
+ ```text
176
+ <CREW_AGENTS_ROOT>/.crew/projects.json
177
+ ```
178
+
179
+ Each project is scanned at `<project>/.agents/skills/*/SKILL.md`. The server stores only a bounded,
180
+ path-free inventory and logical `(projectId, skillName)` bindings. The local path crosses the server only
181
+ while an add command is forwarded to the selected online Computer; it is not written to the workspace
182
+ database, structured logs, inventory frames, or responses. Project Skills are disabled on Windows.
183
+
184
+ One binding set is projected into both Runtime discovery layouts:
185
+
186
+ ```text
187
+ <agentsRoot>/<handle>/.agents/skills/<name> # Codex symlink
188
+ <agentsRoot>/<handle>/.crew/claude-skills/.claude/skills/<name> # Claude symlink
189
+ ```
190
+
191
+ The two complete directories switch under one per-Agent reconcile/launch lock. A failed switch restores
192
+ the prior complete projection; a missing project or Skill removes only that link and does not block other
193
+ work. These are ordinary writable symlinks, not snapshots or a sandbox: an Agent running as the daemon
194
+ user can modify the source Skill through them. That write-through risk is intentionally accepted.
195
+
196
+ Codex protocol-v1 task directories are opaque
197
+ `<agentsRoot>/<handle>/tasks/<safe-prefix>-<sha256>` paths. The daemon writes `.nowwork-root` at the Agent
198
+ root and adds it to Codex `project_root_markers`, so upward discovery reaches `.agents/skills` without
199
+ changing the task cwd. Claude receives
200
+ `--add-dir <agentsRoot>/<handle>/.crew/claude-skills`; Claude treats those Skills as `projectSettings`,
201
+ so a machine policy that disables project settings also disables this source even when the projection is
202
+ healthy.
203
+
204
+ Existing user-level discovery is unchanged. Claude may still load `~/.claude/skills`; Codex may still
205
+ load `$CODEX_HOME/skills`. The DeepSeek Codex variant uses its Agent-private `.codex-deepseek` home.
206
+ Project bindings do not hide or rewrite any of those sources.
207
+
208
+ Binding changes reconcile immediately when the Computer is online. On reconnect the server sends the
209
+ complete binding set for every Agent assigned to that Computer, including empty sets that clear stale
210
+ links left by offline unbinds. Every non-empty execution snapshot is also reconciled immediately before
211
+ Runtime launch, which recovers from a missed notification or daemon restart. Skill file content changes
212
+ are visible through the symlink immediately; adding, deleting, renaming, or changing frontmatter requires
213
+ a rescan. Web descriptions remain at the last inventory value until that rescan.
214
+
169
215
  ## Reliability
170
216
 
171
217
  Protocol-v1 lifecycle is `accepted → started → completed`, followed by a server
@@ -0,0 +1,18 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ export async function atomicPrivateWrite(path, content, beforeCommit) {
5
+ const directory = dirname(path);
6
+ await mkdir(directory, { recursive: true, mode: 0o700 });
7
+ await chmod(directory, 0o700);
8
+ const temporary = `${path}.${randomUUID()}.tmp`;
9
+ try {
10
+ await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
11
+ await beforeCommit?.(temporary);
12
+ await rename(temporary, path);
13
+ await chmod(path, 0o600);
14
+ }
15
+ finally {
16
+ await rm(temporary, { force: true });
17
+ }
18
+ }
@@ -1,13 +1,13 @@
1
- import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { access, readFile, readdir, rm, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { dirname, posix, resolve, win32 } from "node:path";
6
- import { randomUUID } from "node:crypto";
5
+ import { posix, resolve, win32 } from "node:path";
7
6
  import { spawn } from "node:child_process";
8
7
  import { z } from "zod";
9
8
  import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
10
9
  import { formatDaemonText } from "./i18n.js";
10
+ import { atomicPrivateWrite } from "./atomic-private-write.js";
11
11
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
12
12
  const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
13
13
  const url = new URL(value);
@@ -130,20 +130,6 @@ export function validateProfileName(name) {
130
130
  export function profilePath(name, home = daemonHome()) {
131
131
  return resolve(home, "profiles", `${validateProfileName(name)}.json`);
132
132
  }
133
- async function atomicPrivateWrite(path, content, beforeCommit) {
134
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
135
- await chmod(dirname(path), 0o700);
136
- const temporary = `${path}.${randomUUID()}.tmp`;
137
- try {
138
- await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
139
- await beforeCommit?.(temporary);
140
- await rename(temporary, path);
141
- await chmod(path, 0o600);
142
- }
143
- finally {
144
- await rm(temporary, { force: true });
145
- }
146
- }
147
133
  const DPAPI_PROTECT = "$p=[Console]::In.ReadToEnd();$b=[Text.Encoding]::UTF8.GetBytes($p);$e=[Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Convert]::ToBase64String($e)";
148
134
  const DPAPI_UNPROTECT = "$p=[Console]::In.ReadToEnd();$b=[Convert]::FromBase64String($p);$d=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Console]::Out.Write([Text.Encoding]::UTF8.GetString($d))";
149
135
  async function powershellStdin(script, input) {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { MAX_AGENT_PROJECT_SKILL_BINDINGS } from "./project-skills/types.js";
2
3
  const ExecutionIdSchema = z.string().uuid();
3
4
  const ProtocolVersionSchema = z.literal(1);
4
5
  const TimestampSchema = z.string().datetime({ offset: true });
@@ -10,13 +11,17 @@ const ExitCodeSchema = z.number().int().min(MIN_SIGNED_32_INTEGER).max(MAX_PG_IN
10
11
  const RuntimeSchema = z.enum(["claude", "codex", "kimi"]);
11
12
  const UnsafePathCharacterSchema = /[\p{Cc}<>:"/\\|?*]/u;
12
13
  const WindowsReservedNameSchema = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
13
- const AgentHandleSchema = z.string().min(1).max(64).refine((handle) => handle !== "."
14
+ export const AgentHandleSchema = z.string().min(1).max(64).refine((handle) => handle !== "."
14
15
  && handle !== ".."
15
16
  && !UnsafePathCharacterSchema.test(handle)
16
17
  && !WindowsReservedNameSchema.test(handle)
17
18
  && !handle.endsWith(".")
18
19
  && !handle.endsWith(" ")
19
20
  && Buffer.byteLength(handle, "utf8") <= 255, "agent handle must be a cross-platform safe filesystem component");
21
+ const ProjectSkillRefSchema = z.object({
22
+ projectId: z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9._-]*$/u),
23
+ skillName: z.string().min(1).max(128).regex(/^[a-z0-9][a-z0-9._:-]*$/u),
24
+ }).strict();
20
25
  export const ReasoningSchema = z.enum([
21
26
  "default",
22
27
  "none",
@@ -99,6 +104,7 @@ export const ExecutionStartSchema = z.object({
99
104
  id: z.string().min(1),
100
105
  handle: AgentHandleSchema,
101
106
  memoryEnabled: z.boolean().optional(),
107
+ projectSkills: z.array(ProjectSkillRefSchema).max(MAX_AGENT_PROJECT_SKILL_BINDINGS).optional(),
102
108
  }).strict(),
103
109
  workspace: z.object({
104
110
  taskKey: z.string().min(1).max(200),
@@ -505,6 +505,7 @@ export async function runExecution(config, input, dependencies) {
505
505
  ...(dependencies.startupTimeoutMs === undefined
506
506
  ? {}
507
507
  : { startupTimeoutMs: dependencies.startupTimeoutMs }),
508
+ ...(dependencies.projectSkills === undefined ? {} : { projectSkills: dependencies.projectSkills }),
508
509
  launchRuntime: async (request) => {
509
510
  if (launchClosed || dependencies.cancellation?.isRequested())
510
511
  throw new ExecutionCancelledError();
@@ -572,6 +573,7 @@ export async function runExecution(config, input, dependencies) {
572
573
  ...(spec.workspace.resumeKey === undefined ? {} : { resumeKey: spec.workspace.resumeKey }),
573
574
  ...(spec.context.wakeMessageId === undefined ? {} : { wakeMessageId: spec.context.wakeMessageId }),
574
575
  ...(spec.context.attachments === undefined ? {} : { attachments: spec.context.attachments }),
576
+ ...(spec.agent.projectSkills === undefined ? {} : { projectSkills: spec.agent.projectSkills }),
575
577
  systemPrompt: boundedSystemPrompt,
576
578
  wakePrompt: spec.instructions.wakePrompt,
577
579
  runtime: {
@@ -18,6 +18,19 @@ import { routeRuntimeAttachments, runtimeCapability, } from "./runtime-capabilit
18
18
  import { awaitWithCancellation, RuntimeCancelledError, } from "./runtime-cancellation.js";
19
19
  import { isRuntimeReadyEvent, } from "./runtime-startup-gate.js";
20
20
  import { dslog } from "./slog.js";
21
+ import { inspectMemoryPruneFiles, parseMemoryPruneTraceId, } from "./memory-prune-diagnostics.js";
22
+ function memoryPruneSnapshotFields(snapshot) {
23
+ const fields = {};
24
+ for (const [label, fact] of Object.entries(snapshot)) {
25
+ fields[`${label}_exists`] = fact.exists;
26
+ fields[`${label}_size`] = fact.size;
27
+ fields[`${label}_mtime_ms`] = fact.mtime_ms;
28
+ fields[`${label}_sha256`] = fact.sha256;
29
+ fields[`${label}_hash_skipped_reason`] = fact.hash_skipped_reason;
30
+ fields[`${label}_error`] = fact.error;
31
+ }
32
+ return fields;
33
+ }
21
34
  function truncateUtf8(value, maxBytes) {
22
35
  if (maxBytes <= 0)
23
36
  return "";
@@ -116,6 +129,9 @@ async function launchLegacyRuntime(request) {
116
129
  ...common,
117
130
  systemPromptPath: request.systemPromptPath,
118
131
  wakePrompt: request.wakePrompt,
132
+ ...(request.agentRoot === undefined ? {} : {
133
+ projectSkillsDirectory: join(request.agentRoot, ".crew", "claude-skills"),
134
+ }),
119
135
  ...(request.sessionId === undefined ? {} : {
120
136
  sessionId: request.sessionId,
121
137
  resume: request.resume,
@@ -201,6 +217,10 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
201
217
  let materialized = null;
202
218
  let knownAttachmentDirectory = null;
203
219
  let startupReservation = null;
220
+ let memoryPruneTraceId = null;
221
+ let memoryPruneRuntimeExitCode;
222
+ let memoryPruneExecutorCompleted = false;
223
+ let memoryPruneFailurePhase = "diagnostics_before";
204
224
  try {
205
225
  if (isDeepSeekCodex && !providerConfig.providerApiKey) {
206
226
  throw new Error("DeepSeek API key is not configured for this Agent");
@@ -265,7 +285,20 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
265
285
  }
266
286
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
267
287
  const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
288
+ memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
289
+ if (memoryPruneTraceId !== null) {
290
+ const snapshot = await inspectMemoryPruneFiles(workspace.dir, workspace.workLogPath);
291
+ dslog("memory_prune.files_before", "长期记忆收尾执行前文件指纹", {
292
+ execution_id: input.executionId,
293
+ agent_handle: input.handle,
294
+ task_key: input.taskKey,
295
+ prune_trace_id: memoryPruneTraceId,
296
+ ...memoryPruneSnapshotFields(snapshot),
297
+ });
298
+ }
299
+ memoryPruneFailurePhase = "prompt_write";
268
300
  await awaitWithCancellation(writeFile(workspace.systemPromptPath, systemPrompt, "utf8"), dependencies.cancellation);
301
+ memoryPruneFailurePhase = "runtime_prepare";
269
302
  const inheritedEnv = { ...process.env };
270
303
  for (const key of Object.keys(inheritedEnv)) {
271
304
  if (key.startsWith("CREW_AGENT_MEMORY_"))
@@ -318,10 +351,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
318
351
  }
319
352
  }
320
353
  const runtimeLaunchAt = Date.now();
321
- const child = await launchRuntime({
354
+ memoryPruneFailurePhase = "runtime_launch";
355
+ const launchRequest = {
322
356
  runtime: runtime.name,
323
357
  bin: runtime.name,
324
358
  cwd: workspace.runDir,
359
+ ...(input.projectSkills === undefined ? {} : { agentRoot: workspace.dir }),
325
360
  systemPromptPath: workspace.systemPromptPath,
326
361
  systemPrompt,
327
362
  wakePrompt,
@@ -334,7 +369,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
334
369
  ...(attachmentPlan.nativeImagePaths.length > 0
335
370
  ? { imagePaths: attachmentPlan.nativeImagePaths }
336
371
  : {}),
337
- });
372
+ };
373
+ const child = input.projectSkills !== undefined && dependencies.projectSkills !== undefined
374
+ ? await dependencies.projectSkills.prepareAndLaunch(input.launch.agentsRoot, input.handle, input.projectSkills, () => launchRuntime(launchRequest))
375
+ : await launchRuntime(launchRequest);
376
+ memoryPruneFailurePhase = "runtime_execution";
338
377
  if (child.cancel !== undefined) {
339
378
  dependencies.cancellation?.register(child.cancel);
340
379
  }
@@ -449,6 +488,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
449
488
  throw error;
450
489
  }
451
490
  const { exitCode, spawnError, terminationSignal } = runtimeExit;
491
+ memoryPruneRuntimeExitCode = exitCode;
452
492
  const errorTail = [
453
493
  stderrTail.trim(),
454
494
  spawnError,
@@ -463,6 +503,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
463
503
  }
464
504
  }
465
505
  if (input.session.enabled && supportsNativeResume && sessionId) {
506
+ memoryPruneFailurePhase = "session_finalize";
466
507
  const contextTokens = usage
467
508
  ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
468
509
  : (resuming ? prior?.contextTokens : undefined);
@@ -476,6 +517,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
476
517
  ...(contextTokens === undefined ? {} : { contextTokens }),
477
518
  });
478
519
  }
520
+ memoryPruneExecutorCompleted = true;
479
521
  return {
480
522
  workspaceRunDir: workspace.runDir,
481
523
  exitCode,
@@ -498,6 +540,27 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
498
540
  }
499
541
  finally {
500
542
  startupReservation?.release();
543
+ if (memoryPruneTraceId !== null) {
544
+ const snapshot = await inspectMemoryPruneFiles(workspace.dir, workspace.workLogPath);
545
+ dslog("memory_prune.files_after", "长期记忆收尾执行后文件指纹", {
546
+ execution_id: input.executionId,
547
+ agent_handle: input.handle,
548
+ task_key: input.taskKey,
549
+ prune_trace_id: memoryPruneTraceId,
550
+ runtime_exit_code: memoryPruneRuntimeExitCode,
551
+ executor_outcome: memoryPruneExecutorCompleted ? "succeeded" : "failed",
552
+ ...memoryPruneSnapshotFields(snapshot),
553
+ });
554
+ dslog("memory_prune.execution_completed", "长期记忆收尾执行结束", {
555
+ execution_id: input.executionId,
556
+ agent_handle: input.handle,
557
+ task_key: input.taskKey,
558
+ prune_trace_id: memoryPruneTraceId,
559
+ executor_outcome: memoryPruneExecutorCompleted ? "succeeded" : "failed",
560
+ runtime_exit_code: memoryPruneRuntimeExitCode,
561
+ ...(memoryPruneExecutorCompleted ? {} : { failure_phase: memoryPruneFailurePhase }),
562
+ });
563
+ }
501
564
  const attachmentDirectories = new Set([
502
565
  ...(knownAttachmentDirectory === null ? [] : [knownAttachmentDirectory]),
503
566
  ...(materialized === null ? [] : [materialized.directory]),
@@ -27,7 +27,11 @@ export const DAEMON_CAPABILITIES = [
27
27
  "execution_answer_stream_v1",
28
28
  "execution_machine_queue_v1",
29
29
  "execution_agent_memory_policy_v1",
30
+ "project_skills_v1",
30
31
  ];
32
+ export const daemonCapabilities = (runtimePlatform = process.platform) => runtimePlatform === "darwin" || runtimePlatform === "linux"
33
+ ? DAEMON_CAPABILITIES
34
+ : DAEMON_CAPABILITIES.filter((capability) => capability !== "project_skills_v1");
31
35
  export const EXECUTION_PROTOCOL = Object.freeze({ min: 1, max: 1 });
32
36
  /** 候选 runtime CLI:展示名 → 可执行文件名。 */
33
37
  const RUNTIME_BINS = [
@@ -117,7 +121,7 @@ export async function collectMachineHello(agentsRoot, executionLimits, runtimePl
117
121
  daemonVersion: daemonVersion(),
118
122
  runtimes,
119
123
  executionRuntimes,
120
- capabilities: [...DAEMON_CAPABILITIES, ...additionalCapabilities],
124
+ capabilities: [...daemonCapabilities(runtimePlatform), ...additionalCapabilities],
121
125
  ...(backend.supported ? {
122
126
  executionProtocol: EXECUTION_PROTOCOL,
123
127
  executionLimits: Object.freeze({
@@ -0,0 +1,57 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ const TRACE_PATTERN = /\[memory-prune trace_id=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/i;
6
+ export const MEMORY_PRUNE_HASH_LIMIT_BYTES = 10 * 1024 * 1024;
7
+ export function parseMemoryPruneTraceId(wakePrompt) {
8
+ return TRACE_PATTERN.exec(wakePrompt)?.[1] ?? null;
9
+ }
10
+ async function inspectFile(path) {
11
+ let metadata;
12
+ try {
13
+ metadata = await stat(path);
14
+ }
15
+ catch (error) {
16
+ const code = error.code;
17
+ if (code === "ENOENT")
18
+ return { exists: false };
19
+ return { exists: false, error: code ?? (error instanceof Error ? error.name : "unknown") };
20
+ }
21
+ const fact = { exists: true, size: metadata.size, mtime_ms: metadata.mtimeMs };
22
+ if (!metadata.isFile())
23
+ return { ...fact, hash_skipped_reason: "not_regular_file" };
24
+ if (metadata.size > MEMORY_PRUNE_HASH_LIMIT_BYTES) {
25
+ return { ...fact, hash_skipped_reason: "file_too_large" };
26
+ }
27
+ try {
28
+ const hash = createHash("sha256");
29
+ let bytesRead = 0;
30
+ const stream = createReadStream(path);
31
+ try {
32
+ for await (const chunk of stream) {
33
+ bytesRead += chunk.length;
34
+ if (bytesRead > MEMORY_PRUNE_HASH_LIMIT_BYTES) {
35
+ return { ...fact, hash_skipped_reason: "grew_too_large" };
36
+ }
37
+ hash.update(chunk);
38
+ }
39
+ }
40
+ finally {
41
+ stream.destroy();
42
+ }
43
+ return { ...fact, sha256: hash.digest("hex") };
44
+ }
45
+ catch (error) {
46
+ const code = error.code;
47
+ return { ...fact, error: code ?? (error instanceof Error ? error.name : "unknown") };
48
+ }
49
+ }
50
+ export async function inspectMemoryPruneFiles(homeDir, workLogPath) {
51
+ const [memory, lessons, workLog] = await Promise.all([
52
+ inspectFile(join(homeDir, "MEMORY.md")),
53
+ inspectFile(join(homeDir, "notes", "lessons.md")),
54
+ inspectFile(workLogPath),
55
+ ]);
56
+ return { memory, lessons, work_log: workLog };
57
+ }
@@ -0,0 +1,10 @@
1
+ import { createKeyedPromiseTail } from "../promise-tail.js";
2
+ export function createAgentProjectionCoordinator() {
3
+ const tails = createKeyedPromiseTail();
4
+ return {
5
+ runExclusive(agentsRoot, handle, operation) {
6
+ const key = JSON.stringify([agentsRoot, handle]);
7
+ return tails.enqueue(key, operation);
8
+ },
9
+ };
10
+ }
@@ -0,0 +1,167 @@
1
+ import { z } from "zod";
2
+ import { scanProjects } from "./scanner.js";
3
+ import { ProjectRegistryError } from "./registry.js";
4
+ import { isProjectId, isProjectSkillName, MAX_AGENT_PROJECT_SKILL_BINDINGS, } from "./types.js";
5
+ import { AgentHandleSchema } from "../execution-protocol.js";
6
+ import { createPromiseTail } from "../promise-tail.js";
7
+ const ProjectIdSchema = z.string().refine(isProjectId);
8
+ const ProjectCommandSchema = z.discriminatedUnion("type", [
9
+ z.object({
10
+ type: z.literal("project:add"),
11
+ reqId: z.string().min(1).max(128),
12
+ projectId: ProjectIdSchema,
13
+ root: z.string().min(1).max(4_096),
14
+ }).strict(),
15
+ z.object({
16
+ type: z.literal("project:remove"),
17
+ reqId: z.string().min(1).max(128),
18
+ projectId: ProjectIdSchema,
19
+ }).strict(),
20
+ z.object({
21
+ type: z.literal("project:rescan"),
22
+ reqId: z.string().min(1).max(128),
23
+ projectId: ProjectIdSchema,
24
+ }).strict(),
25
+ z.object({
26
+ type: z.literal("agent:skills:sync"),
27
+ reqId: z.string().min(1).max(128),
28
+ handle: AgentHandleSchema,
29
+ bindings: z.array(z.object({
30
+ projectId: ProjectIdSchema,
31
+ skillName: z.string().refine(isProjectSkillName),
32
+ }).strict()).max(MAX_AGENT_PROJECT_SKILL_BINDINGS),
33
+ }).strict(),
34
+ ]);
35
+ const DEFAULT_PROJECT_SCAN_TIMEOUT_MS = 10_000;
36
+ class ProjectScanTimeoutError extends Error {
37
+ code = "project_scan_timeout";
38
+ constructor() {
39
+ super("project_scan_timeout");
40
+ this.name = "ProjectScanTimeoutError";
41
+ }
42
+ }
43
+ const withinScanDeadline = async (operation, timeoutMs) => {
44
+ let timer;
45
+ try {
46
+ return await Promise.race([
47
+ operation,
48
+ new Promise((_resolve, reject) => {
49
+ timer = setTimeout(() => reject(new ProjectScanTimeoutError()), timeoutMs);
50
+ timer.unref?.();
51
+ }),
52
+ ]);
53
+ }
54
+ finally {
55
+ if (timer !== undefined)
56
+ clearTimeout(timer);
57
+ }
58
+ };
59
+ export function createProjectSkillsController(deps) {
60
+ let scanned = Object.freeze([]);
61
+ const operationTail = createPromiseTail();
62
+ let initialization = null;
63
+ let initializationState = "idle";
64
+ const scan = deps.scan ?? scanProjects;
65
+ const scanTimeoutMs = deps.scanTimeoutMs ?? DEFAULT_PROJECT_SCAN_TIMEOUT_MS;
66
+ const scanCurrent = async (deferredProjectId) => withinScanDeadline(scan(await deps.registry.list(), undefined, [
67
+ ...scanned
68
+ .map((project) => project.inventory.projectId)
69
+ .filter((projectId) => projectId !== deferredProjectId),
70
+ ...(deferredProjectId === undefined ? [] : [deferredProjectId]),
71
+ ]), scanTimeoutMs);
72
+ const refresh = async (deferredProjectId) => {
73
+ const next = await scanCurrent(deferredProjectId);
74
+ scanned = next;
75
+ };
76
+ const publishCurrent = async () => {
77
+ await deps.publish(Object.freeze({
78
+ type: "machine:projects",
79
+ projects: Object.freeze(scanned.map((project) => project.inventory)),
80
+ }));
81
+ };
82
+ const enqueue = (operation) => operationTail.enqueue(operation);
83
+ const initialize = () => {
84
+ if (initializationState === "ready" && initialization !== null)
85
+ return initialization;
86
+ if (initializationState === "initializing" && initialization !== null)
87
+ return initialization;
88
+ initializationState = "initializing";
89
+ const attempt = enqueue(refresh).then(() => { initializationState = "ready"; }, (error) => {
90
+ initializationState = "failed";
91
+ if (initialization === attempt)
92
+ initialization = null;
93
+ throw error;
94
+ });
95
+ initialization = attempt;
96
+ return attempt;
97
+ };
98
+ return {
99
+ initialize,
100
+ publishCurrent: () => enqueue(publishCurrent),
101
+ scannedProjects: () => scanned,
102
+ async handle(input) {
103
+ const parsed = ProjectCommandSchema.safeParse(input);
104
+ if (!parsed.success)
105
+ return { ok: false, error: "invalid_project_command" };
106
+ if (parsed.data.type === "agent:skills:sync") {
107
+ try {
108
+ await initialize();
109
+ }
110
+ catch {
111
+ return { ok: false, error: "project_skills_unavailable" };
112
+ }
113
+ }
114
+ return enqueue(async () => {
115
+ const command = parsed.data;
116
+ try {
117
+ if (command.type === "agent:skills:sync") {
118
+ if (deps.reconcile === undefined)
119
+ return { ok: false, error: "skill_projection_failed" };
120
+ return {
121
+ ok: true,
122
+ data: { bindings: await deps.reconcile(command.handle, command.bindings) },
123
+ };
124
+ }
125
+ if (command.type === "project:add") {
126
+ const existed = (await deps.registry.list())
127
+ .some((project) => project.projectId === command.projectId);
128
+ await deps.registry.add(command.projectId, command.root);
129
+ const next = await scanCurrent();
130
+ const added = next.find((project) => project.inventory.projectId === command.projectId);
131
+ if (!existed && added?.inventory.errorCode === "machine_project_skill_limit_exceeded") {
132
+ await deps.registry.remove(command.projectId);
133
+ return { ok: false, error: "machine_project_skill_limit_exceeded" };
134
+ }
135
+ scanned = next;
136
+ }
137
+ else if (command.type === "project:remove") {
138
+ await deps.registry.remove(command.projectId);
139
+ }
140
+ else {
141
+ const projects = await deps.registry.list();
142
+ if (!projects.some((project) => project.projectId === command.projectId)) {
143
+ return { ok: false, error: "project_not_found" };
144
+ }
145
+ }
146
+ if (command.type !== "project:add") {
147
+ await refresh(command.type === "project:rescan" ? command.projectId : undefined);
148
+ }
149
+ await publishCurrent();
150
+ return { ok: true, data: { projectId: command.projectId } };
151
+ }
152
+ catch (error) {
153
+ return {
154
+ ok: false,
155
+ error: error instanceof ProjectRegistryError
156
+ ? error.code
157
+ : error.code === "skill_name_conflict"
158
+ ? "skill_name_conflict"
159
+ : error.code === "skill_projection_failed"
160
+ ? "skill_projection_failed"
161
+ : "project_operation_failed",
162
+ };
163
+ }
164
+ });
165
+ },
166
+ };
167
+ }