@nowcrew/daemon 0.5.33 → 0.5.35

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: {
@@ -116,6 +116,9 @@ async function launchLegacyRuntime(request) {
116
116
  ...common,
117
117
  systemPromptPath: request.systemPromptPath,
118
118
  wakePrompt: request.wakePrompt,
119
+ ...(request.agentRoot === undefined ? {} : {
120
+ projectSkillsDirectory: join(request.agentRoot, ".crew", "claude-skills"),
121
+ }),
119
122
  ...(request.sessionId === undefined ? {} : {
120
123
  sessionId: request.sessionId,
121
124
  resume: request.resume,
@@ -318,10 +321,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
318
321
  }
319
322
  }
320
323
  const runtimeLaunchAt = Date.now();
321
- const child = await launchRuntime({
324
+ const launchRequest = {
322
325
  runtime: runtime.name,
323
326
  bin: runtime.name,
324
327
  cwd: workspace.runDir,
328
+ ...(input.projectSkills === undefined ? {} : { agentRoot: workspace.dir }),
325
329
  systemPromptPath: workspace.systemPromptPath,
326
330
  systemPrompt,
327
331
  wakePrompt,
@@ -334,7 +338,10 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
334
338
  ...(attachmentPlan.nativeImagePaths.length > 0
335
339
  ? { imagePaths: attachmentPlan.nativeImagePaths }
336
340
  : {}),
337
- });
341
+ };
342
+ const child = input.projectSkills !== undefined && dependencies.projectSkills !== undefined
343
+ ? await dependencies.projectSkills.prepareAndLaunch(input.launch.agentsRoot, input.handle, input.projectSkills, () => launchRuntime(launchRequest))
344
+ : await launchRuntime(launchRequest);
338
345
  if (child.cancel !== undefined) {
339
346
  dependencies.cancellation?.register(child.cancel);
340
347
  }
@@ -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,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,112 @@
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
+ export function createProjectSkillsController(deps) {
36
+ let scanned = Object.freeze([]);
37
+ const operationTail = createPromiseTail();
38
+ let initialization = null;
39
+ let initializationState = "idle";
40
+ const refresh = async () => {
41
+ scanned = await scanProjects(await deps.registry.list());
42
+ };
43
+ const publishCurrent = async () => {
44
+ await deps.publish(Object.freeze({
45
+ type: "machine:projects",
46
+ projects: Object.freeze(scanned.map((project) => project.inventory)),
47
+ }));
48
+ };
49
+ const enqueue = (operation) => operationTail.enqueue(operation);
50
+ return {
51
+ initialize() {
52
+ if (initialization !== null)
53
+ return initialization;
54
+ initializationState = "initializing";
55
+ initialization = enqueue(refresh).then(() => { initializationState = "ready"; }, (error) => {
56
+ initializationState = "failed";
57
+ throw error;
58
+ });
59
+ return initialization;
60
+ },
61
+ publishCurrent: () => enqueue(publishCurrent),
62
+ scannedProjects: () => scanned,
63
+ handle(input) {
64
+ return enqueue(async () => {
65
+ const parsed = ProjectCommandSchema.safeParse(input);
66
+ if (!parsed.success)
67
+ return { ok: false, error: "invalid_project_command" };
68
+ if (initializationState !== "ready") {
69
+ return { ok: false, error: "project_skills_unavailable" };
70
+ }
71
+ const command = parsed.data;
72
+ try {
73
+ if (command.type === "agent:skills:sync") {
74
+ if (deps.reconcile === undefined)
75
+ return { ok: false, error: "skill_projection_failed" };
76
+ return {
77
+ ok: true,
78
+ data: { bindings: await deps.reconcile(command.handle, command.bindings) },
79
+ };
80
+ }
81
+ if (command.type === "project:add") {
82
+ await deps.registry.add(command.projectId, command.root);
83
+ }
84
+ else if (command.type === "project:remove") {
85
+ await deps.registry.remove(command.projectId);
86
+ }
87
+ else {
88
+ const projects = await deps.registry.list();
89
+ if (!projects.some((project) => project.projectId === command.projectId)) {
90
+ return { ok: false, error: "project_not_found" };
91
+ }
92
+ }
93
+ await refresh();
94
+ await publishCurrent();
95
+ return { ok: true, data: { projectId: command.projectId } };
96
+ }
97
+ catch (error) {
98
+ return {
99
+ ok: false,
100
+ error: error instanceof ProjectRegistryError
101
+ ? error.code
102
+ : error.code === "skill_name_conflict"
103
+ ? "skill_name_conflict"
104
+ : error.code === "skill_projection_failed"
105
+ ? "skill_projection_failed"
106
+ : "project_operation_failed",
107
+ };
108
+ }
109
+ });
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,116 @@
1
+ import { lstat, mkdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ export class ProjectProjectionError extends Error {
5
+ code;
6
+ constructor(code) {
7
+ super(code);
8
+ this.code = code;
9
+ this.name = "ProjectProjectionError";
10
+ }
11
+ }
12
+ const exists = async (path) => lstat(path).then(() => true, () => false);
13
+ async function switchProjectionSet(targets) {
14
+ const moved = [];
15
+ try {
16
+ for (const target of targets) {
17
+ const hadPrevious = await exists(target.target);
18
+ if (hadPrevious)
19
+ await rename(target.target, target.backup);
20
+ const state = { target, hadPrevious, activated: false };
21
+ moved.push(state);
22
+ await rename(target.staging, target.target);
23
+ state.activated = true;
24
+ }
25
+ }
26
+ catch (error) {
27
+ for (const state of [...moved].reverse()) {
28
+ if (state.activated)
29
+ await rm(state.target.target, { recursive: true, force: true }).catch(() => { });
30
+ if (state.hadPrevious)
31
+ await rename(state.target.backup, state.target.target).catch(() => { });
32
+ }
33
+ throw error;
34
+ }
35
+ await Promise.all(targets.map((target) => rm(target.backup, { recursive: true, force: true })));
36
+ }
37
+ export function createProjectSkillsReconciler(deps) {
38
+ const platform = deps.platform ?? process.platform;
39
+ const reconcileUnlocked = async (handle, bindings) => {
40
+ if (platform !== "darwin" && platform !== "linux") {
41
+ throw new ProjectProjectionError("skill_projection_failed");
42
+ }
43
+ const projects = deps.scannedProjects();
44
+ const uniqueBindings = [...new Map(bindings.map((binding) => [
45
+ `${binding.projectId}\0${binding.skillName}`,
46
+ binding,
47
+ ])).values()].sort((left, right) => left.projectId.localeCompare(right.projectId) || left.skillName.localeCompare(right.skillName));
48
+ const names = new Map();
49
+ for (const binding of uniqueBindings) {
50
+ const owner = names.get(binding.skillName);
51
+ if (owner !== undefined && owner !== binding.projectId) {
52
+ throw new ProjectProjectionError("skill_name_conflict");
53
+ }
54
+ names.set(binding.skillName, binding.projectId);
55
+ }
56
+ const linked = [];
57
+ const resolutions = [];
58
+ for (const binding of uniqueBindings) {
59
+ const project = projects.find((candidate) => candidate.inventory.projectId === binding.projectId
60
+ && candidate.inventory.status === "available");
61
+ const skill = project?.resolved.find((candidate) => candidate.name === binding.skillName);
62
+ const available = skill !== undefined && (await stat(skill.sourcePath).catch(() => null))?.isDirectory() === true;
63
+ resolutions.push(Object.freeze({ ...binding, status: available ? "linked" : "unavailable" }));
64
+ if (available && skill !== undefined)
65
+ linked.push({ binding, skill });
66
+ }
67
+ const agentRoot = join(deps.agentsRoot, handle);
68
+ const id = randomUUID();
69
+ const targets = [
70
+ {
71
+ runtime: "codex",
72
+ target: join(agentRoot, ".agents", "skills"),
73
+ staging: join(agentRoot, ".agents", `.skills-next-${id}`),
74
+ backup: join(agentRoot, ".agents", `.skills-previous-${id}`),
75
+ },
76
+ {
77
+ runtime: "claude",
78
+ target: join(agentRoot, ".crew", "claude-skills", ".claude", "skills"),
79
+ staging: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-next-${id}`),
80
+ backup: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-previous-${id}`),
81
+ },
82
+ ];
83
+ try {
84
+ for (const target of targets) {
85
+ await mkdir(dirname(target.target), { recursive: true });
86
+ await mkdir(target.staging, { mode: 0o700 });
87
+ for (const item of linked) {
88
+ await symlink(item.skill.sourcePath, join(target.staging, item.binding.skillName), "dir");
89
+ }
90
+ }
91
+ for (const target of targets)
92
+ await deps.beforeSwitch?.(target.runtime);
93
+ await switchProjectionSet(targets);
94
+ await writeFile(join(agentRoot, ".nowwork-root"), "", { encoding: "utf8", mode: 0o600 });
95
+ return Object.freeze(resolutions);
96
+ }
97
+ catch {
98
+ await Promise.all(targets.map((target) => rm(target.staging, { recursive: true, force: true })));
99
+ throw new ProjectProjectionError("skill_projection_failed");
100
+ }
101
+ };
102
+ return {
103
+ reconcile(handle, bindings) {
104
+ return deps.coordinator.runExclusive(deps.agentsRoot, handle, () => reconcileUnlocked(handle, bindings));
105
+ },
106
+ prepareAndLaunch(agentsRoot, handle, bindings, launch) {
107
+ if (agentsRoot !== deps.agentsRoot) {
108
+ return Promise.reject(new ProjectProjectionError("skill_projection_failed"));
109
+ }
110
+ return deps.coordinator.runExclusive(deps.agentsRoot, handle, async () => {
111
+ await reconcileUnlocked(handle, bindings);
112
+ return launch();
113
+ });
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,101 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { isProjectId, MAX_MACHINE_PROJECTS } from "./types.js";
5
+ import { createPromiseTail } from "../promise-tail.js";
6
+ import { atomicPrivateWrite } from "../atomic-private-write.js";
7
+ const RegistryFileSchema = z.object({
8
+ version: z.literal(1),
9
+ projects: z.record(z.object({ root: z.string().min(1).max(4_096) }).strict())
10
+ .refine((projects) => Object.keys(projects).length <= MAX_MACHINE_PROJECTS),
11
+ }).strict();
12
+ export class ProjectRegistryError extends Error {
13
+ code;
14
+ constructor(code) {
15
+ super(code);
16
+ this.code = code;
17
+ this.name = "ProjectRegistryError";
18
+ }
19
+ }
20
+ const immutableList = (projects) => Object.freeze(Object.entries(projects)
21
+ .map(([projectId, project]) => Object.freeze({ projectId, root: project.root }))
22
+ .sort((left, right) => left.projectId.localeCompare(right.projectId)));
23
+ export function createProjectRegistry(agentsRoot) {
24
+ const path = join(agentsRoot, ".crew", "projects.json");
25
+ const writeTail = createPromiseTail();
26
+ const load = async () => {
27
+ let raw;
28
+ try {
29
+ raw = await readFile(path, "utf8");
30
+ }
31
+ catch (error) {
32
+ if (error.code === "ENOENT")
33
+ return Object.freeze({});
34
+ throw new ProjectRegistryError("project_registry_corrupt");
35
+ }
36
+ try {
37
+ const parsed = RegistryFileSchema.parse(JSON.parse(raw));
38
+ for (const [projectId, project] of Object.entries(parsed.projects)) {
39
+ if (!isProjectId(projectId) || !isAbsolute(project.root)) {
40
+ throw new ProjectRegistryError("project_registry_corrupt");
41
+ }
42
+ }
43
+ return Object.freeze(Object.fromEntries(Object.entries(parsed.projects).map(([projectId, project]) => [
44
+ projectId,
45
+ Object.freeze({ root: resolve(project.root) }),
46
+ ])));
47
+ }
48
+ catch (error) {
49
+ if (error instanceof ProjectRegistryError)
50
+ throw error;
51
+ throw new ProjectRegistryError("project_registry_corrupt");
52
+ }
53
+ };
54
+ const save = async (projects) => {
55
+ await atomicPrivateWrite(path, `${JSON.stringify({ version: 1, projects }, null, 2)}\n`);
56
+ };
57
+ const enqueue = (operation) => writeTail.enqueue(operation);
58
+ return {
59
+ path,
60
+ async list() {
61
+ await writeTail.wait();
62
+ return immutableList(await load());
63
+ },
64
+ add(projectId, root) {
65
+ return enqueue(async () => {
66
+ if (!isProjectId(projectId))
67
+ throw new ProjectRegistryError("project_id_invalid");
68
+ if (!isAbsolute(root))
69
+ throw new ProjectRegistryError("project_path_invalid");
70
+ const normalizedRoot = resolve(root);
71
+ const rootStat = await stat(normalizedRoot).catch(() => null);
72
+ if (!rootStat?.isDirectory())
73
+ throw new ProjectRegistryError("project_path_invalid");
74
+ const projects = await load();
75
+ const existing = projects[projectId];
76
+ if (existing?.root === normalizedRoot)
77
+ return Object.freeze({ projectId, root: normalizedRoot });
78
+ if (existing !== undefined)
79
+ throw new ProjectRegistryError("project_id_conflict");
80
+ if (Object.keys(projects).length >= MAX_MACHINE_PROJECTS) {
81
+ throw new ProjectRegistryError("project_limit_exceeded");
82
+ }
83
+ const next = Object.freeze({ ...projects, [projectId]: Object.freeze({ root: normalizedRoot }) });
84
+ await save(next);
85
+ return Object.freeze({ projectId, root: normalizedRoot });
86
+ });
87
+ },
88
+ remove(projectId) {
89
+ return enqueue(async () => {
90
+ if (!isProjectId(projectId))
91
+ throw new ProjectRegistryError("project_id_invalid");
92
+ const projects = await load();
93
+ if (projects[projectId] === undefined)
94
+ return false;
95
+ const next = Object.fromEntries(Object.entries(projects).filter(([candidate]) => candidate !== projectId));
96
+ await save(Object.freeze(next));
97
+ return true;
98
+ });
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,103 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isProjectSkillName, MAX_MACHINE_PROJECT_SKILLS, MAX_PROJECT_SKILL_DESCRIPTION_LENGTH, MAX_PROJECT_SKILLS_PER_PROJECT, } from "./types.js";
4
+ import { parseSkillFrontmatter } from "../skill-frontmatter.js";
5
+ const unavailable = (projectId, scannedAt, errorCode) => Object.freeze({
6
+ inventory: Object.freeze({
7
+ projectId,
8
+ status: "unavailable",
9
+ skills: Object.freeze([]),
10
+ invalidSkillCount: 0,
11
+ errorCode,
12
+ scannedAt,
13
+ }),
14
+ resolved: Object.freeze([]),
15
+ });
16
+ export async function scanProject(project, now = () => new Date()) {
17
+ const scannedAt = now().toISOString();
18
+ const projectStat = await stat(project.root).catch(() => null);
19
+ if (!projectStat?.isDirectory())
20
+ return unavailable(project.projectId, scannedAt, "project_path_unavailable");
21
+ const skillsRoot = join(project.root, ".agents", "skills");
22
+ let entries;
23
+ try {
24
+ entries = await readdir(skillsRoot, { withFileTypes: true });
25
+ }
26
+ catch (error) {
27
+ if (error.code === "ENOENT") {
28
+ return Object.freeze({
29
+ inventory: Object.freeze({
30
+ projectId: project.projectId,
31
+ status: "available",
32
+ skills: Object.freeze([]),
33
+ invalidSkillCount: 0,
34
+ errorCode: null,
35
+ scannedAt,
36
+ }),
37
+ resolved: Object.freeze([]),
38
+ });
39
+ }
40
+ return unavailable(project.projectId, scannedAt, "project_skills_unreadable");
41
+ }
42
+ let invalidSkillCount = 0;
43
+ const candidates = [];
44
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
45
+ if (entry.name.startsWith(".") || !entry.isDirectory())
46
+ continue;
47
+ const sourcePath = join(skillsRoot, entry.name);
48
+ const markdown = await readFile(join(sourcePath, "SKILL.md"), "utf8").catch(() => null);
49
+ if (markdown === null) {
50
+ invalidSkillCount += 1;
51
+ continue;
52
+ }
53
+ const frontmatter = parseSkillFrontmatter(markdown);
54
+ const name = frontmatter?.name ?? entry.name;
55
+ const description = frontmatter?.description ?? "";
56
+ if (!isProjectSkillName(name) || description.length > MAX_PROJECT_SKILL_DESCRIPTION_LENGTH) {
57
+ invalidSkillCount += 1;
58
+ continue;
59
+ }
60
+ candidates.push(Object.freeze({ name, description, sourcePath }));
61
+ }
62
+ const counts = new Map();
63
+ for (const candidate of candidates)
64
+ counts.set(candidate.name, (counts.get(candidate.name) ?? 0) + 1);
65
+ const resolved = candidates.filter((candidate) => {
66
+ if (counts.get(candidate.name) === 1)
67
+ return true;
68
+ invalidSkillCount += 1;
69
+ return false;
70
+ }).sort((left, right) => left.name.localeCompare(right.name));
71
+ const skills = resolved.map(({ name, description }) => Object.freeze({ name, description }));
72
+ if (skills.length > MAX_PROJECT_SKILLS_PER_PROJECT) {
73
+ return unavailable(project.projectId, scannedAt, "project_skill_limit_exceeded");
74
+ }
75
+ return Object.freeze({
76
+ inventory: Object.freeze({
77
+ projectId: project.projectId,
78
+ status: "available",
79
+ skills: Object.freeze(skills),
80
+ invalidSkillCount,
81
+ errorCode: null,
82
+ scannedAt,
83
+ }),
84
+ resolved: Object.freeze(resolved),
85
+ });
86
+ }
87
+ export function boundScannedProjects(projects) {
88
+ let skillCount = 0;
89
+ return Object.freeze(projects.map((project) => {
90
+ if (project.inventory.status !== "available")
91
+ return project;
92
+ const nextCount = skillCount + project.inventory.skills.length;
93
+ if (nextCount <= MAX_MACHINE_PROJECT_SKILLS) {
94
+ skillCount = nextCount;
95
+ return project;
96
+ }
97
+ return unavailable(project.inventory.projectId, project.inventory.scannedAt, "machine_project_skill_limit_exceeded");
98
+ }));
99
+ }
100
+ export async function scanProjects(projects, now = () => new Date()) {
101
+ const sorted = [...projects].sort((left, right) => left.projectId.localeCompare(right.projectId));
102
+ return boundScannedProjects(await Promise.all(sorted.map((project) => scanProject(project, now))));
103
+ }
@@ -0,0 +1,12 @@
1
+ export const PROJECT_SKILLS_CAPABILITY = "project_skills_v1";
2
+ export const MAX_PROJECT_ID_LENGTH = 64;
3
+ export const MAX_PROJECT_SKILL_NAME_LENGTH = 128;
4
+ export const MAX_PROJECT_SKILL_DESCRIPTION_LENGTH = 1_000;
5
+ export const MAX_PROJECT_SKILLS_PER_PROJECT = 1_000;
6
+ export const MAX_MACHINE_PROJECTS = 100;
7
+ export const MAX_MACHINE_PROJECT_SKILLS = 1_000;
8
+ export const MAX_AGENT_PROJECT_SKILL_BINDINGS = 500;
9
+ const PROJECT_ID = /^[a-z0-9][a-z0-9._-]*$/u;
10
+ const SKILL_NAME = /^[a-z0-9][a-z0-9._:-]*$/u;
11
+ export const isProjectId = (value) => value.length > 0 && value.length <= MAX_PROJECT_ID_LENGTH && PROJECT_ID.test(value);
12
+ export const isProjectSkillName = (value) => value.length > 0 && value.length <= MAX_PROJECT_SKILL_NAME_LENGTH && SKILL_NAME.test(value);
@@ -0,0 +1,25 @@
1
+ export function createKeyedPromiseTail() {
2
+ const tails = new Map();
3
+ return {
4
+ enqueue(key, operation) {
5
+ const previous = tails.get(key) ?? Promise.resolve();
6
+ const current = previous.catch(() => { }).then(operation);
7
+ const tail = current.then(() => undefined);
8
+ tails.set(key, tail);
9
+ void tail.finally(() => {
10
+ if (tails.get(key) === tail)
11
+ tails.delete(key);
12
+ }).catch(() => { });
13
+ return current;
14
+ },
15
+ pending: (key) => tails.get(key),
16
+ };
17
+ }
18
+ export function createPromiseTail() {
19
+ const queue = createKeyedPromiseTail();
20
+ const key = "singleton";
21
+ return {
22
+ enqueue: (operation) => queue.enqueue(key, operation),
23
+ wait: () => queue.pending(key)?.catch(() => { }) ?? Promise.resolve(),
24
+ };
25
+ }
package/dist/runner.js CHANGED
@@ -154,6 +154,9 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
154
154
  exitCode: local.exitCode,
155
155
  finalText: local.finalText,
156
156
  errorMessage: local.errorMessage,
157
+ ...(input.scheduled.serverOwnsFailureNotice?.() === true
158
+ ? { suppressFailureNotice: true }
159
+ : {}),
157
160
  send: (content) => sendAgentMessage(config.serverUrl, credential.token, input.channelId, {
158
161
  content,
159
162
  force: true,
@@ -194,6 +197,7 @@ export async function reportScheduledStartFailure(config, input) {
194
197
  exitCode: -1,
195
198
  finalText: null,
196
199
  errorMessage: input.errorMessage,
200
+ ...(input.serverOwnsFailureNotice?.() === true ? { suppressFailureNotice: true } : {}),
197
201
  send: (content) => sendAgentMessage(config.serverUrl, credential.token, input.channelId, {
198
202
  content,
199
203
  force: true,
@@ -28,6 +28,8 @@ export function buildClaudeArgs(input) {
28
28
  if (input.sessionId) {
29
29
  args.push(input.resume ? "--resume" : "--session-id", input.sessionId);
30
30
  }
31
+ if (input.projectSkillsDirectory)
32
+ args.push("--add-dir", input.projectSkillsDirectory);
31
33
  if (input.effectivePermission === undefined) {
32
34
  if (input.dangerous)
33
35
  args.push("--dangerously-skip-permissions");
@@ -14,6 +14,7 @@ const RunnerInputSchema = z.object({
14
14
  reasoning: z.string().min(1).optional(),
15
15
  sessionId: z.string().min(1).optional(),
16
16
  imagePaths: z.array(z.string().min(1)).optional(),
17
+ projectRootMarkers: z.array(z.string().min(1).max(64).regex(/^[A-Za-z0-9._-]+$/u)).max(16).optional(),
17
18
  resume: z.boolean(),
18
19
  }).strict();
19
20
  const RPC_TIMEOUT_MS = 30_000;
@@ -31,6 +32,14 @@ const MAX_TRANSIENT_TURN_RETRIES = 2;
31
32
  const TRANSIENT_TURN_RETRY_DELAY_MS = 15_000;
32
33
  const TRANSIENT_TURN_RETRY_BACKOFF_FACTOR = 3;
33
34
  const PROCESS_TREE_STOP_TIMEOUT_MS = 1_000;
35
+ export function codexAppServerArgs(projectRootMarkers) {
36
+ return [
37
+ ...(projectRootMarkers === undefined
38
+ ? []
39
+ : ["-c", `project_root_markers=${JSON.stringify(projectRootMarkers)}`]),
40
+ "app-server", "--listen", "stdio://",
41
+ ];
42
+ }
34
43
  const TRANSIENT_TURN_ERROR_PATTERNS = [
35
44
  /at capacity/i,
36
45
  /overloaded/i,
@@ -345,7 +354,7 @@ async function stopChildTree(child) {
345
354
  }
346
355
  async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs) {
347
356
  const attemptStartedAt = Date.now();
348
- const child = spawn(bin, ["app-server", "--listen", "stdio://"], {
357
+ const child = spawn(bin, codexAppServerArgs(input.projectRootMarkers), {
349
358
  cwd: process.cwd(),
350
359
  env: process.env,
351
360
  stdio: ["pipe", "pipe", "pipe"],
@@ -18,6 +18,9 @@ export async function deliverScheduledReport(input) {
18
18
  let source = "none";
19
19
  let content = null;
20
20
  if (input.exitCode !== 0) {
21
+ if (input.suppressFailureNotice === true) {
22
+ return { required: false, attempted: false, delivered: false, source: "none" };
23
+ }
21
24
  source = "failure_notice";
22
25
  const detail = input.errorMessage?.trim().slice(0, 500);
23
26
  content = `Scheduled job "${title}" failed${detail ? `: ${detail}` : ` (exit ${input.exitCode})`}.`;
package/dist/serve.js CHANGED
@@ -8,7 +8,7 @@ import { randomUUID } from "node:crypto";
8
8
  import { initSlog, dslog, setSlogDefaults, drainSpool, flushSlog } from "./slog.js";
9
9
  import { mergeRunAgentResults, reportScheduledStartFailure, runAgent } from "./runner.js";
10
10
  import { buildOriginDecisionRetryPrompt, buildScheduledPrompt } from "./prompt.js";
11
- import { collectMachineHello, DAEMON_CAPABILITIES, EXECUTION_PROTOCOL, } from "./machine-info.js";
11
+ import { collectMachineHello, daemonCapabilities, EXECUTION_PROTOCOL, } from "./machine-info.js";
12
12
  import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
13
13
  import { listSkills } from "./skills.js";
14
14
  import { inspectRaftWorkspace, importRaftWorkspace } from "./workspace-import.js";
@@ -35,26 +35,35 @@ import { detectDaemonUpdateEligibility, } from "./daemon-update-eligibility.js";
35
35
  import { createDaemonUpdateController } from "./daemon-update-controller.js";
36
36
  import { installExactDaemonUpdate } from "./daemon-updater.js";
37
37
  import { scheduleServiceRestart } from "./computer-service.js";
38
+ import { createProjectRegistry } from "./project-skills/registry.js";
39
+ import { createProjectSkillsController, } from "./project-skills/controller.js";
40
+ import { PROJECT_SKILLS_CAPABILITY } from "./project-skills/types.js";
41
+ import { createAgentProjectionCoordinator } from "./project-skills/agent-projection-coordinator.js";
42
+ import { createProjectSkillsReconciler, ProjectProjectionError, } from "./project-skills/reconciler.js";
38
43
  // normalize.ts 的活动种类 → activity 枚举
39
44
  const ACTIVITY_MAP = {
40
45
  init: "working", text: "thinking", reading: "reading", sending: "sending",
41
46
  checking: "checking", claiming: "claiming", crew: "working", tool: "working",
42
47
  tool_result: "working", done: "done", error: "error",
43
48
  };
44
- export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe) {
49
+ export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe, projectSkillsAvailable = true) {
45
50
  const query = new URLSearchParams({ key: machineToken });
46
51
  if (executionBackendCapability(runtimePlatform, jobObjectProbe).supported) {
47
52
  query.set("execution_min", String(EXECUTION_PROTOCOL.min));
48
53
  query.set("execution_max", String(EXECUTION_PROTOCOL.max));
49
54
  }
50
- for (const capability of DAEMON_CAPABILITIES)
51
- query.append("capability", capability);
55
+ for (const capability of daemonCapabilities(runtimePlatform)) {
56
+ if (capability !== PROJECT_SKILLS_CAPABILITY || projectSkillsAvailable) {
57
+ query.append("capability", capability);
58
+ }
59
+ }
52
60
  return `${serverUrl.replace(/^http/, "ws").replace(/\/+$/, "")}/daemon/connect?${query.toString()}`;
53
61
  }
54
62
  export function serve(config, opts = {}) {
55
- const wsUrl = buildControlPlaneUrl(config.serverUrl, config.machineToken);
56
63
  let stopped = false;
57
64
  let ws = null;
65
+ // 当前连接的 server 能力(ready 帧下发;重连后由新 ready 帧刷新)。
66
+ let serverCapabilities = new Set();
58
67
  let backoff = 1000;
59
68
  const maxBackoff = opts.maxBackoffMs ?? 30_000;
60
69
  const testShutdown = readTestShutdownConfiguration(process.env);
@@ -97,6 +106,45 @@ export function serve(config, opts = {}) {
97
106
  catch { /* reconnect/timeout reconciliation handles a lost status frame */ }
98
107
  },
99
108
  });
109
+ const projectionCoordinator = createAgentProjectionCoordinator();
110
+ let projectSkillsController;
111
+ const projectSkillsReconciler = opts.projectSkills?.reconciler ?? createProjectSkillsReconciler({
112
+ agentsRoot: config.agentsRoot,
113
+ coordinator: projectionCoordinator,
114
+ scannedProjects: () => projectSkillsController.scannedProjects(),
115
+ });
116
+ projectSkillsController = opts.projectSkills?.controller ?? createProjectSkillsController({
117
+ registry: createProjectRegistry(config.agentsRoot),
118
+ publish: (frame) => {
119
+ if (!serverCapabilities.has(PROJECT_SKILLS_CAPABILITY))
120
+ return;
121
+ try {
122
+ if (ws?.readyState === WebSocket.OPEN)
123
+ ws.send(JSON.stringify(frame));
124
+ }
125
+ catch { /* 下一次 ready 或项目操作会重新发送完整快照 */ }
126
+ },
127
+ reconcile: (handle, bindings) => projectSkillsReconciler.reconcile(handle, bindings),
128
+ });
129
+ const projectSkillsInitialized = projectSkillsController.initialize().then(() => true, (error) => {
130
+ dslog("project_skills.registry_failed", "本机项目注册表读取失败", {
131
+ level: "ERROR", error_code: error.code ?? "project_registry_corrupt",
132
+ });
133
+ return false;
134
+ });
135
+ let projectSkillsAvailable = false;
136
+ const initializedProjectSkillsReconciler = {
137
+ reconcile(handle, bindings) {
138
+ return projectSkillsAvailable
139
+ ? projectSkillsReconciler.reconcile(handle, bindings)
140
+ : Promise.reject(new ProjectProjectionError("skill_projection_failed"));
141
+ },
142
+ prepareAndLaunch(agentsRoot, handle, bindings, launch) {
143
+ return projectSkillsAvailable
144
+ ? projectSkillsReconciler.prepareAndLaunch(agentsRoot, handle, bindings, launch)
145
+ : Promise.reject(new ProjectProjectionError("skill_projection_failed"));
146
+ },
147
+ };
100
148
  const safeExecutionSend = (frame) => {
101
149
  try {
102
150
  if (ws?.readyState !== WebSocket.OPEN)
@@ -205,6 +253,7 @@ export function serve(config, opts = {}) {
205
253
  function connect() {
206
254
  if (stopped)
207
255
  return;
256
+ const wsUrl = buildControlPlaneUrl(config.serverUrl, config.machineToken, process.platform, undefined, projectSkillsAvailable);
208
257
  ws = createWebSocket(wsUrl);
209
258
  ws.on("open", () => {
210
259
  const openedSocket = ws;
@@ -234,9 +283,15 @@ export function serve(config, opts = {}) {
234
283
  void helloPromise
235
284
  .then((hello) => {
236
285
  detectedExecutionRuntimes = hello.executionRuntimes;
286
+ const effectiveHello = projectSkillsAvailable
287
+ ? hello
288
+ : {
289
+ ...hello,
290
+ capabilities: hello.capabilities.filter((capability) => capability !== PROJECT_SKILLS_CAPABILITY),
291
+ };
237
292
  try {
238
- ws?.send(JSON.stringify(hello));
239
- log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · installed=[${hello.runtimes.join(",")}] · executable=[${hello.executionRuntimes.join(",")}] · agents=[${hello.agentHandles.join(",")}]`);
293
+ ws?.send(JSON.stringify(effectiveHello));
294
+ log(`📤 已上报机器信息: ${effectiveHello.hostname} · ${effectiveHello.os} · installed=[${effectiveHello.runtimes.join(",")}] · executable=[${effectiveHello.executionRuntimes.join(",")}] · agents=[${effectiveHello.agentHandles.join(",")}]`);
240
295
  }
241
296
  catch { /* 非 OPEN,忽略 */ }
242
297
  })
@@ -437,6 +492,7 @@ export function serve(config, opts = {}) {
437
492
  }
438
493
  const execution = executeProtocol(config, spec, {
439
494
  ...opts.execution?.dependencies,
495
+ projectSkills: opts.execution?.dependencies?.projectSkills ?? initializedProjectSkillsReconciler,
440
496
  ...(agentMemory === undefined ? {} : { agentMemory }),
441
497
  journal: executionJournal,
442
498
  facts: {
@@ -506,6 +562,14 @@ export function serve(config, opts = {}) {
506
562
  // ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
507
563
  const r = msg;
508
564
  setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
565
+ // 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。
566
+ serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
567
+ ? r.serverCapabilities.filter((c) => typeof c === "string")
568
+ : []);
569
+ if (serverCapabilities.has(PROJECT_SKILLS_CAPABILITY)) {
570
+ if (await projectSkillsInitialized)
571
+ await projectSkillsController.publishCurrent();
572
+ }
509
573
  return;
510
574
  }
511
575
  if (msg.type === "error") {
@@ -517,6 +581,22 @@ export function serve(config, opts = {}) {
517
581
  }
518
582
  return;
519
583
  }
584
+ if (msg.type === "project:add"
585
+ || msg.type === "project:remove"
586
+ || msg.type === "project:rescan"
587
+ || msg.type === "agent:skills:sync") {
588
+ const request = msg;
589
+ if (typeof request.reqId !== "string")
590
+ return;
591
+ const result = serverCapabilities.has(PROJECT_SKILLS_CAPABILITY)
592
+ ? await projectSkillsController.handle(msg)
593
+ : { ok: false, error: "capability_unavailable" };
594
+ try {
595
+ ws?.send(JSON.stringify({ type: "fs:result", reqId: request.reqId, ...result }));
596
+ }
597
+ catch { /* server 会按请求超时处理 */ }
598
+ return;
599
+ }
520
600
  // 导入 raft agent 工作区:inspect 反填 name/description;import 复制用户内容
521
601
  if (msg.type === "raft:inspect" || msg.type === "raft:import") {
522
602
  const req = msg;
@@ -579,6 +659,9 @@ export function serve(config, opts = {}) {
579
659
  : {}),
580
660
  })
581
661
  : null;
662
+ // 活取值:发报告的时刻按"当前连接"的 server 能力判定(滚动发布中 run 可能跨重连;
663
+ // 断线时集合被清空 → 回退为 daemon 自行发通知,方向安全)。
664
+ const serverOwnsFailureNotice = () => serverCapabilities.has("scheduled_failure_notice_v1");
582
665
  const threadId = msg.wake?.threadId;
583
666
  const taskKey = scheduled ? scheduled.runId : (threadId ?? msg.channelId);
584
667
  const key = `${msg.agentHandle}:${taskKey}`;
@@ -744,6 +827,7 @@ export function serve(config, opts = {}) {
744
827
  title: scheduled.title,
745
828
  outputPolicy: scheduled.outputPolicy,
746
829
  externalNotificationPolicy: scheduled.externalNotificationPolicy,
830
+ serverOwnsFailureNotice,
747
831
  },
748
832
  } : {}),
749
833
  ...(wakeOrigin ? { wakeOrigin, originDecisionAttempt: attempt } : {}),
@@ -852,6 +936,7 @@ export function serve(config, opts = {}) {
852
936
  channelId: msg.channelId,
853
937
  scheduled,
854
938
  errorMessage: e.message,
939
+ serverOwnsFailureNotice,
855
940
  });
856
941
  }
857
942
  catch { /* token 也不可用时只能让 server 按 runtime failure 终态化 */ }
@@ -889,6 +974,8 @@ export function serve(config, opts = {}) {
889
974
  ws.on("close", (code) => {
890
975
  if (stopped)
891
976
  return;
977
+ // server 能力随连接失效;下个连接的 ready 帧重新声明(防降级重连后沿用过期能力)。
978
+ serverCapabilities = new Set();
892
979
  completionRetransmitter.pause();
893
980
  // 4001 = 控制面应用级「鉴权失败」关闭码(见 server control-plane.ts)。即使上面的
894
981
  // error 帧因 close 抢先而丢失,也能据关闭码识别这是凭证失效——退避拉满,不再每秒热循环。
@@ -926,6 +1013,7 @@ export function serve(config, opts = {}) {
926
1013
  flush: flushSlog,
927
1014
  writeStderr: (line) => process.stderr.write(line),
928
1015
  });
1016
+ projectSkillsAvailable = await projectSkillsInitialized;
929
1017
  connect();
930
1018
  })();
931
1019
  const stop = () => {
@@ -0,0 +1,23 @@
1
+ export function parseSkillFrontmatter(markdown) {
2
+ const match = markdown.match(/^---[\t ]*\r?\n([\s\S]*?)\r?\n---(?:[\t ]*\r?\n|$)/u);
3
+ if (!match)
4
+ return Object.freeze({});
5
+ let name;
6
+ let description;
7
+ for (const line of match[1]?.split(/\r?\n/u) ?? []) {
8
+ const field = line.match(/^(name|description)[\t ]*:[\t ]*(.*?)[\t ]*$/u);
9
+ if (!field)
10
+ continue;
11
+ const value = (field[2] ?? "")
12
+ .replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, "$1$2")
13
+ .trim();
14
+ if (field[1] === "name")
15
+ name = value;
16
+ if (field[1] === "description")
17
+ description = value;
18
+ }
19
+ return Object.freeze({
20
+ ...(name === undefined ? {} : { name }),
21
+ ...(description === undefined ? {} : { description }),
22
+ });
23
+ }
package/dist/skills.js CHANGED
@@ -9,25 +9,8 @@
9
9
  import { readdir, readFile, stat } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { homedir } from "node:os";
12
+ import { parseSkillFrontmatter } from "./skill-frontmatter.js";
12
13
  const globalSkillsDir = () => process.env.CREW_GLOBAL_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
13
- /** 从 SKILL.md 顶部 YAML frontmatter 取 name / description (简易解析,够用)。 */
14
- function parseFrontmatter(md) {
15
- const m = md.match(/^---\s*\n([\s\S]*?)\n---/);
16
- if (!m)
17
- return {};
18
- const out = {};
19
- for (const line of m[1].split("\n")) {
20
- const kv = line.match(/^(name|description)\s*:\s*(.+?)\s*$/);
21
- if (kv) {
22
- const val = kv[2].replace(/^["']|["']$/g, "");
23
- if (kv[1] === "name")
24
- out.name = val;
25
- else
26
- out.description = val;
27
- }
28
- }
29
- return out;
30
- }
31
14
  async function readSkillsFrom(dir, scope) {
32
15
  const names = await readdir(dir).catch(() => []);
33
16
  const skills = [];
@@ -39,7 +22,7 @@ async function readSkillsFrom(dir, scope) {
39
22
  if (!s || !s.isFile())
40
23
  continue;
41
24
  const md = await readFile(skillMd, "utf8").catch(() => "");
42
- const fm = parseFrontmatter(md);
25
+ const fm = parseSkillFrontmatter(md);
43
26
  skills.push({ scope, name: fm.name || name, description: fm.description ?? "" });
44
27
  }
45
28
  return skills;
@@ -1,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { join } from "node:path";
2
3
  import { startDormantSupervisor, } from "./execution-supervisor.js";
3
4
  import { buildClaudeArgs } from "./runtimes/claude.js";
4
5
  import { RuntimeCancelledError } from "./runtime-cancellation.js";
@@ -19,6 +20,9 @@ export function supervisorLaunch(request) {
19
20
  cwd: request.cwd,
20
21
  env: request.env,
21
22
  systemPromptPath: request.systemPromptPath,
23
+ ...(request.agentRoot === undefined ? {} : {
24
+ projectSkillsDirectory: join(request.agentRoot, ".crew", "claude-skills"),
25
+ }),
22
26
  ...(request.sessionId === undefined ? {} : {
23
27
  sessionId: request.sessionId,
24
28
  resume: request.resume,
@@ -45,6 +49,9 @@ export function supervisorLaunch(request) {
45
49
  ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }),
46
50
  ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }),
47
51
  ...(request.imagePaths === undefined ? {} : { imagePaths: request.imagePaths }),
52
+ ...(request.agentRoot === undefined ? {} : {
53
+ projectRootMarkers: [".git", ".nowwork-root"],
54
+ }),
48
55
  resume: request.resume,
49
56
  }),
50
57
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.5.33",
3
+ "version": "0.5.35",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",