@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.
@@ -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,113 @@
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
+ }).strict();
11
+ export class ProjectRegistryError extends Error {
12
+ code;
13
+ constructor(code) {
14
+ super(code);
15
+ this.code = code;
16
+ this.name = "ProjectRegistryError";
17
+ }
18
+ }
19
+ const immutableList = (projects) => {
20
+ const sorted = Object.entries(projects)
21
+ .map(([projectId, project]) => Object.freeze({ projectId, root: project.root }))
22
+ .sort((left, right) => left.projectId.localeCompare(right.projectId));
23
+ if (sorted.length <= MAX_MACHINE_PROJECTS)
24
+ return Object.freeze(sorted);
25
+ const visible = sorted.slice(0, MAX_MACHINE_PROJECTS);
26
+ const boundary = visible.at(-1);
27
+ if (boundary !== undefined) {
28
+ visible[visible.length - 1] = Object.freeze({
29
+ ...boundary,
30
+ errorCode: "project_registry_limit_exceeded",
31
+ });
32
+ }
33
+ return Object.freeze(visible);
34
+ };
35
+ export function createProjectRegistry(agentsRoot) {
36
+ const path = join(agentsRoot, ".crew", "projects.json");
37
+ const writeTail = createPromiseTail();
38
+ const load = async () => {
39
+ let raw;
40
+ try {
41
+ raw = await readFile(path, "utf8");
42
+ }
43
+ catch (error) {
44
+ if (error.code === "ENOENT")
45
+ return Object.freeze({});
46
+ throw new ProjectRegistryError("project_registry_corrupt");
47
+ }
48
+ try {
49
+ const parsed = RegistryFileSchema.parse(JSON.parse(raw));
50
+ for (const [projectId, project] of Object.entries(parsed.projects)) {
51
+ if (!isProjectId(projectId) || !isAbsolute(project.root)) {
52
+ throw new ProjectRegistryError("project_registry_corrupt");
53
+ }
54
+ }
55
+ return Object.freeze(Object.fromEntries(Object.entries(parsed.projects).map(([projectId, project]) => [
56
+ projectId,
57
+ Object.freeze({ root: resolve(project.root) }),
58
+ ])));
59
+ }
60
+ catch (error) {
61
+ if (error instanceof ProjectRegistryError)
62
+ throw error;
63
+ throw new ProjectRegistryError("project_registry_corrupt");
64
+ }
65
+ };
66
+ const save = async (projects) => {
67
+ await atomicPrivateWrite(path, `${JSON.stringify({ version: 1, projects }, null, 2)}\n`);
68
+ };
69
+ const enqueue = (operation) => writeTail.enqueue(operation);
70
+ return {
71
+ path,
72
+ async list() {
73
+ await writeTail.wait();
74
+ return immutableList(await load());
75
+ },
76
+ add(projectId, root) {
77
+ return enqueue(async () => {
78
+ if (!isProjectId(projectId))
79
+ throw new ProjectRegistryError("project_id_invalid");
80
+ if (!isAbsolute(root))
81
+ throw new ProjectRegistryError("project_path_invalid");
82
+ const normalizedRoot = resolve(root);
83
+ const rootStat = await stat(normalizedRoot).catch(() => null);
84
+ if (!rootStat?.isDirectory())
85
+ throw new ProjectRegistryError("project_path_invalid");
86
+ const projects = await load();
87
+ const existing = projects[projectId];
88
+ if (existing?.root === normalizedRoot)
89
+ return Object.freeze({ projectId, root: normalizedRoot });
90
+ if (existing !== undefined)
91
+ throw new ProjectRegistryError("project_id_conflict");
92
+ if (Object.keys(projects).length >= MAX_MACHINE_PROJECTS) {
93
+ throw new ProjectRegistryError("project_limit_exceeded");
94
+ }
95
+ const next = Object.freeze({ ...projects, [projectId]: Object.freeze({ root: normalizedRoot }) });
96
+ await save(next);
97
+ return Object.freeze({ projectId, root: normalizedRoot });
98
+ });
99
+ },
100
+ remove(projectId) {
101
+ return enqueue(async () => {
102
+ if (!isProjectId(projectId))
103
+ throw new ProjectRegistryError("project_id_invalid");
104
+ const projects = await load();
105
+ if (projects[projectId] === undefined)
106
+ return false;
107
+ const next = Object.fromEntries(Object.entries(projects).filter(([candidate]) => candidate !== projectId));
108
+ await save(Object.freeze(next));
109
+ return true;
110
+ });
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,126 @@
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
+ if (project.errorCode !== undefined)
19
+ return unavailable(project.projectId, scannedAt, project.errorCode);
20
+ const projectStat = await stat(project.root).catch(() => null);
21
+ if (!projectStat?.isDirectory())
22
+ return unavailable(project.projectId, scannedAt, "project_path_unavailable");
23
+ const skillsRoot = join(project.root, ".agents", "skills");
24
+ let entries;
25
+ try {
26
+ entries = await readdir(skillsRoot, { withFileTypes: true });
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT") {
30
+ return Object.freeze({
31
+ inventory: Object.freeze({
32
+ projectId: project.projectId,
33
+ status: "available",
34
+ skills: Object.freeze([]),
35
+ invalidSkillCount: 0,
36
+ errorCode: null,
37
+ scannedAt,
38
+ }),
39
+ resolved: Object.freeze([]),
40
+ });
41
+ }
42
+ return unavailable(project.projectId, scannedAt, "project_skills_unreadable");
43
+ }
44
+ let invalidSkillCount = 0;
45
+ const candidates = [];
46
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
47
+ if (entry.name.startsWith(".") || !entry.isDirectory())
48
+ continue;
49
+ const sourcePath = join(skillsRoot, entry.name);
50
+ const markdown = await readFile(join(sourcePath, "SKILL.md"), "utf8").catch(() => null);
51
+ if (markdown === null) {
52
+ invalidSkillCount += 1;
53
+ continue;
54
+ }
55
+ const frontmatter = parseSkillFrontmatter(markdown);
56
+ const name = frontmatter.name;
57
+ const description = frontmatter.description;
58
+ if (name === undefined || description === undefined) {
59
+ invalidSkillCount += 1;
60
+ continue;
61
+ }
62
+ if (!isProjectSkillName(name) || description.length > MAX_PROJECT_SKILL_DESCRIPTION_LENGTH) {
63
+ invalidSkillCount += 1;
64
+ continue;
65
+ }
66
+ candidates.push(Object.freeze({ name, description, sourcePath }));
67
+ }
68
+ const counts = new Map();
69
+ for (const candidate of candidates)
70
+ counts.set(candidate.name, (counts.get(candidate.name) ?? 0) + 1);
71
+ const resolved = candidates.filter((candidate) => {
72
+ if (counts.get(candidate.name) === 1)
73
+ return true;
74
+ invalidSkillCount += 1;
75
+ return false;
76
+ }).sort((left, right) => left.name.localeCompare(right.name));
77
+ const skills = resolved.map(({ name, description }) => Object.freeze({ name, description }));
78
+ if (skills.length > MAX_PROJECT_SKILLS_PER_PROJECT) {
79
+ return unavailable(project.projectId, scannedAt, "project_skill_limit_exceeded");
80
+ }
81
+ return Object.freeze({
82
+ inventory: Object.freeze({
83
+ projectId: project.projectId,
84
+ status: "available",
85
+ skills: Object.freeze(skills),
86
+ invalidSkillCount,
87
+ errorCode: null,
88
+ scannedAt,
89
+ }),
90
+ resolved: Object.freeze(resolved),
91
+ });
92
+ }
93
+ export function boundScannedProjects(projects, retainedProjectIds = []) {
94
+ const retainedOrder = new Map(retainedProjectIds.map((projectId, index) => [projectId, index]));
95
+ const allocationOrder = [...projects].sort((left, right) => {
96
+ const leftOrder = retainedOrder.get(left.inventory.projectId);
97
+ const rightOrder = retainedOrder.get(right.inventory.projectId);
98
+ if (leftOrder !== undefined && rightOrder !== undefined)
99
+ return leftOrder - rightOrder;
100
+ if (leftOrder !== undefined)
101
+ return -1;
102
+ if (rightOrder !== undefined)
103
+ return 1;
104
+ return left.inventory.projectId.localeCompare(right.inventory.projectId);
105
+ });
106
+ let skillCount = 0;
107
+ const bounded = new Map();
108
+ for (const project of allocationOrder) {
109
+ if (project.inventory.status !== "available") {
110
+ bounded.set(project.inventory.projectId, project);
111
+ continue;
112
+ }
113
+ const nextCount = skillCount + project.inventory.skills.length;
114
+ if (nextCount <= MAX_MACHINE_PROJECT_SKILLS) {
115
+ skillCount = nextCount;
116
+ bounded.set(project.inventory.projectId, project);
117
+ continue;
118
+ }
119
+ bounded.set(project.inventory.projectId, unavailable(project.inventory.projectId, project.inventory.scannedAt, "machine_project_skill_limit_exceeded"));
120
+ }
121
+ return Object.freeze(projects.map((project) => bounded.get(project.inventory.projectId) ?? project));
122
+ }
123
+ export async function scanProjects(projects, now = () => new Date(), retainedProjectIds = []) {
124
+ const sorted = [...projects].sort((left, right) => left.projectId.localeCompare(right.projectId));
125
+ return boundScannedProjects(await Promise.all(sorted.map((project) => scanProject(project, now))), retainedProjectIds);
126
+ }
@@ -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})`}.`;