@nowcrew/daemon 0.5.18 → 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +23 -0
  2. package/dist/attachments.js +196 -0
  3. package/dist/computer-cli.js +72 -12
  4. package/dist/computer-profile-lock.js +395 -0
  5. package/dist/computer-profile.js +189 -20
  6. package/dist/config.js +2 -1
  7. package/dist/console.js +175 -9
  8. package/dist/execution-event-limit.js +1 -1
  9. package/dist/execution-journal-lock.js +199 -40
  10. package/dist/execution-journal.js +42 -4
  11. package/dist/execution-protocol.js +21 -1
  12. package/dist/execution-recovery.js +71 -0
  13. package/dist/execution-runner.js +68 -77
  14. package/dist/execution-supervisor.js +79 -31
  15. package/dist/external-output.js +114 -0
  16. package/dist/i18n.js +5 -5
  17. package/dist/list-models.js +41 -5
  18. package/dist/local-executor.js +103 -14
  19. package/dist/machine-info.js +6 -1
  20. package/dist/main.js +23 -8
  21. package/dist/origin-decision.js +3 -1
  22. package/dist/prompt.js +4 -1
  23. package/dist/runner.js +14 -9
  24. package/dist/runtime-cancellation.js +74 -0
  25. package/dist/runtime-capabilities.js +38 -0
  26. package/dist/runtime-path.js +60 -0
  27. package/dist/runtimes/claude.js +9 -4
  28. package/dist/runtimes/codex-app-server-runner.js +340 -0
  29. package/dist/runtimes/codex.js +10 -4
  30. package/dist/runtimes/kimi-acp-runner.js +117 -17
  31. package/dist/runtimes/kimi.js +2 -0
  32. package/dist/runtimes/progress-watchdog.js +26 -0
  33. package/dist/serve-lifecycle.js +82 -0
  34. package/dist/serve.js +212 -212
  35. package/dist/session.js +1 -1
  36. package/dist/shared-execution-slots.js +68 -0
  37. package/dist/shutdown-deadline.js +32 -0
  38. package/dist/slog.js +34 -20
  39. package/dist/supervised-runtime.js +104 -0
  40. package/dist/websocket-shutdown.js +53 -0
  41. package/package.json +3 -3
package/README.md CHANGED
@@ -80,6 +80,25 @@ the local workspace/environment, and launches only a built-in runtime adapter (`
80
80
  `kimi`). It does not decide collaboration rules, scheduled output policy, fallback delivery, or thread
81
81
  behavior. Those are server responsibilities.
82
82
 
83
+ The stable adapters keep prompts out of provider argv wherever the provider protocol allows it:
84
+
85
+ | Runtime | Transport | System/wake input | Native resume |
86
+ | --- | --- | --- | --- |
87
+ | Claude | stream-json CLI | daemon-owned system prompt file | CLI session id |
88
+ | Codex | app-server JSON-RPC over stdio | `developerInstructions` + turn input | `thread/resume` |
89
+ | Kimi | ACP over stdio | `session/prompt` | `session/resume` (`session/load` compatibility fallback) |
90
+
91
+ Kimi ACP currently requires a Kimi account even when the CLI has a working custom provider. Only for
92
+ the explicit `Authentication required` response, the adapter retries once through Kimi's stream-json
93
+ CLI and carries the same native session id; unrelated ACP failures stay failures. The CLI fallback is
94
+ subject to the Windows UTF-16 argv guard, while protocol-v1 itself remains disabled on Windows until
95
+ durable Job Object ownership is available.
96
+
97
+ Codex and Kimi sessions use the same bounded per-task persistence and keyed lease as Claude when they
98
+ run through protocol v1. A first-progress watchdog covers a provider that accepts a turn but remains
99
+ semantically silent; after the first semantic event, the execution's configured total timeout remains
100
+ authoritative so a legitimate long-running tool is not killed for quiet output.
101
+
83
102
  Protocol support and limits are advertised in `machine:hello`. `runtimes` reports every recognized CLI
84
103
  found on `PATH`; `executionRuntimes` separately reports the installed CLIs backed by a complete built-in
85
104
  adapter. The server must use the latter for admission and treats the former as diagnostic inventory only.
@@ -104,6 +123,10 @@ Before accepting work, the daemon writes a local journal entry under:
104
123
  running supervisors are identity-checked and terminated before interruption is reported. A lock prevents
105
124
  two daemon processes from sharing one journal.
106
125
 
126
+ Protocol v1 is advertised only when the platform has a durable process-tree backend. POSIX uses an
127
+ owned process group. Native Windows intentionally fails admission until a Job Object backend has passed
128
+ crash-cleanup tests; `taskkill /T` is not treated as equivalent ownership.
129
+
107
130
  ## Local Policy
108
131
 
109
132
  Useful environment controls:
@@ -0,0 +1,196 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { basename, extname, resolve, sep } from "node:path";
3
+ export const ATTACHMENT_MAX_FILE_BYTES = 25 * 1024 * 1024;
4
+ export const ATTACHMENT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
5
+ const throwIfAborted = (signal) => {
6
+ if (signal?.aborted)
7
+ throw signal.reason ?? new Error("Attachment materialization aborted");
8
+ };
9
+ function truncateUtf8(value, maxBytes) {
10
+ let bytes = 0;
11
+ let output = "";
12
+ for (const character of value) {
13
+ const size = Buffer.byteLength(character, "utf8");
14
+ if (bytes + size > maxBytes)
15
+ break;
16
+ output += character;
17
+ bytes += size;
18
+ }
19
+ return output;
20
+ }
21
+ function safeFilename(value) {
22
+ const leaf = basename(value.replace(/\\/gu, "/"))
23
+ .replace(/[\u0000-\u001f\u007f]/gu, "_")
24
+ .replace(/^\.+/u, "")
25
+ .trim();
26
+ return truncateUtf8(leaf, 180) || "attachment";
27
+ }
28
+ function collisionName(filename, index) {
29
+ if (index === 1)
30
+ return filename;
31
+ const extension = extname(filename);
32
+ const stem = extension ? filename.slice(0, -extension.length) : filename;
33
+ return `${stem}-${index}${extension}`;
34
+ }
35
+ function containedPath(directory, filename) {
36
+ const target = resolve(directory, filename);
37
+ if (!target.startsWith(`${resolve(directory)}${sep}`)) {
38
+ throw new Error("Attachment path escapes the execution directory");
39
+ }
40
+ return target;
41
+ }
42
+ function checkedRedirect(value, base) {
43
+ const url = new URL(value, base);
44
+ if (url.protocol !== "https:" || url.username || url.password) {
45
+ throw new Error("Attachment redirects must use credential-free HTTPS URLs");
46
+ }
47
+ return url;
48
+ }
49
+ async function readBounded(response, maxBytes) {
50
+ const declared = Number(response.headers.get("content-length") ?? "");
51
+ if (Number.isFinite(declared) && declared > maxBytes) {
52
+ throw new Error(`Attachment exceeds the ${maxBytes} byte limit`);
53
+ }
54
+ if (!response.body)
55
+ throw new Error("Attachment response body is empty");
56
+ const reader = response.body.getReader();
57
+ const chunks = [];
58
+ let size = 0;
59
+ while (true) {
60
+ const part = await reader.read();
61
+ if (part.done)
62
+ break;
63
+ const chunk = Buffer.from(part.value);
64
+ size += chunk.length;
65
+ if (size > maxBytes) {
66
+ await reader.cancel();
67
+ throw new Error(`Attachment exceeds the ${maxBytes} byte limit`);
68
+ }
69
+ chunks.push(chunk);
70
+ }
71
+ return Buffer.concat(chunks, size);
72
+ }
73
+ async function downloadAttachment(input) {
74
+ const initial = new URL(`/agent/attachments/${encodeURIComponent(input.attachment.id)}/download`, input.serverUrl);
75
+ if (!["http:", "https:"].includes(initial.protocol)
76
+ || initial.username || initial.password) {
77
+ throw new Error("Invalid NowWork attachment download URL");
78
+ }
79
+ let current = initial;
80
+ const controller = new AbortController();
81
+ let timedOut = false;
82
+ const onAbort = () => controller.abort(input.signal?.reason ?? new Error("Attachment materialization aborted"));
83
+ input.signal?.addEventListener("abort", onAbort, { once: true });
84
+ if (input.signal?.aborted)
85
+ onAbort();
86
+ const timer = setTimeout(() => {
87
+ timedOut = true;
88
+ controller.abort(new Error(`Attachment download timed out after ${input.timeoutMs}ms`));
89
+ }, input.timeoutMs);
90
+ try {
91
+ let response = await input.fetchImpl(current, {
92
+ redirect: "manual",
93
+ headers: { authorization: `Bearer ${input.token}` },
94
+ signal: controller.signal,
95
+ });
96
+ for (let redirects = 0; [301, 302, 303, 307, 308].includes(response.status); redirects += 1) {
97
+ if (redirects >= input.maxRedirects)
98
+ throw new Error("Attachment redirect limit exceeded");
99
+ const location = response.headers.get("location");
100
+ if (!location)
101
+ throw new Error("Attachment redirect is missing a location");
102
+ current = checkedRedirect(location, current);
103
+ response = await input.fetchImpl(current, { redirect: "manual", signal: controller.signal });
104
+ }
105
+ if (!response.ok)
106
+ throw new Error(`Attachment download failed with status ${response.status}`);
107
+ const data = await readBounded(response, input.maxFileBytes);
108
+ throwIfAborted(input.signal);
109
+ if (data.length !== input.attachment.sizeBytes) {
110
+ throw new Error(`Attachment size mismatch: expected ${input.attachment.sizeBytes}, received ${data.length}`);
111
+ }
112
+ return data;
113
+ }
114
+ catch (error) {
115
+ if (input.signal?.aborted) {
116
+ throw input.signal.reason ?? new Error("Attachment materialization aborted");
117
+ }
118
+ if (timedOut) {
119
+ throw new Error(`Attachment download timed out after ${input.timeoutMs}ms`);
120
+ }
121
+ throw error;
122
+ }
123
+ finally {
124
+ clearTimeout(timer);
125
+ input.signal?.removeEventListener("abort", onAbort);
126
+ }
127
+ }
128
+ export function executionAttachmentDirectory(runDir, executionId) {
129
+ const attachmentsRoot = resolve(runDir, "attachments");
130
+ const executionKey = executionId.replace(/[^A-Za-z0-9._-]/gu, "_");
131
+ const directory = resolve(attachmentsRoot, executionKey);
132
+ if (!directory.startsWith(`${attachmentsRoot}${sep}`)) {
133
+ throw new Error("Attachment directory escapes the run directory");
134
+ }
135
+ return directory;
136
+ }
137
+ export async function materializeAttachments(input) {
138
+ throwIfAborted(input.signal);
139
+ const maxFileBytes = input.maxFileBytes ?? ATTACHMENT_MAX_FILE_BYTES;
140
+ const maxTotalBytes = input.maxTotalBytes ?? ATTACHMENT_MAX_TOTAL_BYTES;
141
+ const total = input.attachments.reduce((sum, attachment) => sum + attachment.sizeBytes, 0);
142
+ if (input.attachments.some((attachment) => attachment.sizeBytes > maxFileBytes)
143
+ || total > maxTotalBytes) {
144
+ throw new Error("Attachment metadata exceeds the configured byte limit");
145
+ }
146
+ const directory = executionAttachmentDirectory(input.runDir, input.executionId);
147
+ const used = new Set();
148
+ const materialized = [];
149
+ try {
150
+ await rm(directory, { recursive: true, force: true });
151
+ throwIfAborted(input.signal);
152
+ await mkdir(directory, { recursive: true, mode: 0o700 });
153
+ throwIfAborted(input.signal);
154
+ for (const attachment of input.attachments) {
155
+ const base = safeFilename(attachment.filename);
156
+ let collision = 1;
157
+ let filename = collisionName(base, collision);
158
+ while (used.has(filename.toLowerCase())) {
159
+ collision += 1;
160
+ filename = collisionName(base, collision);
161
+ }
162
+ used.add(filename.toLowerCase());
163
+ const path = containedPath(directory, filename);
164
+ const data = await downloadAttachment({
165
+ serverUrl: input.serverUrl,
166
+ token: input.token,
167
+ attachment,
168
+ fetchImpl: input.fetchImpl ?? fetch,
169
+ maxFileBytes,
170
+ maxRedirects: input.maxRedirects ?? 3,
171
+ timeoutMs: input.timeoutMs ?? 10_000,
172
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
173
+ });
174
+ throwIfAborted(input.signal);
175
+ await writeFile(path, data, {
176
+ flag: "wx",
177
+ mode: 0o600,
178
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
179
+ });
180
+ materialized.push({ ...attachment, filename, path });
181
+ }
182
+ return { directory, attachments: materialized };
183
+ }
184
+ catch (error) {
185
+ try {
186
+ await rm(directory, { recursive: true, force: true });
187
+ }
188
+ catch (cleanupError) {
189
+ throw new AggregateError([error, cleanupError], "Attachment materialization failed and its execution directory could not be removed");
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+ export async function cleanupMaterializedAttachments(directory) {
195
+ await rm(directory, { recursive: true, force: true });
196
+ }
@@ -2,8 +2,10 @@ import { parseArgs } from "node:util";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { daemonHome, listProfiles, loadProfile, profileIsPrivate, profilePath, publicProfile, removeProfile, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
5
+ import { assertProfileAgentsRootUnique, daemonHome, inspectProfileAgentsRoot, listProfiles, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, profileIsPrivate, profilePath, publicProfile, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
6
6
  import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
7
+ import { defaultProcessController } from "./execution-journal.js";
8
+ import { inspectJournalLock } from "./execution-journal-lock.js";
7
9
  import { detectDaemonLang, formatDaemonText } from "./i18n.js";
8
10
  const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
9
11
  export function builtDaemonEntry(moduleUrl = import.meta.url) {
@@ -60,6 +62,17 @@ function requireValue(value, name, td) {
60
62
  throw new Error(td("Missing {{name}}", { name }));
61
63
  return value;
62
64
  }
65
+ function translatedError(error, td) {
66
+ if (error instanceof ProfileAgentsRootConflictError) {
67
+ return td(PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, {
68
+ profile: error.profile,
69
+ conflict: error.conflict,
70
+ agentsRoot: error.agentsRoot,
71
+ command: error.command,
72
+ });
73
+ }
74
+ return td(error.message);
75
+ }
63
76
  export async function runComputerCommand(argv, overrides = {}) {
64
77
  const command = argv[0];
65
78
  if (!command || !COMPUTER_COMMANDS.has(command))
@@ -88,6 +101,7 @@ export async function runComputerCommand(argv, overrides = {}) {
88
101
  const home = daemonHome(deps.env);
89
102
  const profileStorage = {
90
103
  platform: deps.platform,
104
+ userHome: deps.userHome,
91
105
  ...(deps.profileProtector ? { protector: deps.profileProtector } : {}),
92
106
  ...(deps.platform === "win32"
93
107
  ? { harden: (path) => hardenWindowsProfile(path, deps.runner) }
@@ -124,22 +138,28 @@ export async function runComputerCommand(argv, overrides = {}) {
124
138
  const machineToken = (fromStdin ? await deps.readStdin() : deps.env.CREW_MACHINE_TOKEN)?.trim();
125
139
  if (!machineToken)
126
140
  throw new Error(td("Missing machine token; use CREW_MACHINE_TOKEN or --token-stdin"));
127
- await saveProfile({
141
+ const profile = {
128
142
  name,
129
143
  serverUrl: requireValue(parsed.values["server-url"], "--server-url", td),
130
144
  machineToken,
131
- ...(parsed.values["agents-root"] ? { agentsRoot: resolve(parsed.values["agents-root"]) } : {}),
145
+ ...(parsed.values["agents-root"]
146
+ ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
147
+ : {}),
132
148
  ...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
133
- }, home, profileStorage);
149
+ };
150
+ await saveProfile(profile, home, profileStorage);
134
151
  deps.stdout(`${td("Saved profile '{{name}}' with private credentials.", { name })}\n`);
135
152
  return 0;
136
153
  }
137
154
  const profileName = parsed.values.profile;
138
155
  if (command === "upgrade") {
139
- const spec = profileName
156
+ const restartProfile = profileName
157
+ ? await loadProfile(profileName, home, profileStorage)
158
+ : null;
159
+ const spec = restartProfile
140
160
  ? buildServiceSpec({
141
161
  ...deps,
142
- profile: (await loadProfile(profileName, home, profileStorage)).name,
162
+ profile: restartProfile.name,
143
163
  profileHome: home,
144
164
  })
145
165
  : null;
@@ -152,9 +172,18 @@ export async function runComputerCommand(argv, overrides = {}) {
152
172
  throw new Error(td("Service '{{id}}' is not installed", { id: spec.id }));
153
173
  }
154
174
  await upgradeDaemon(deps.platform, deps.runner);
155
- if (profileName) {
175
+ if (restartProfile) {
176
+ try {
177
+ await assertProfileAgentsRootUnique(restartProfile, home, deps.userHome, profileStorage);
178
+ }
179
+ catch (error) {
180
+ if (!(error instanceof ProfileAgentsRootConflictError))
181
+ throw error;
182
+ deps.stdout(`${td("Upgraded daemon but skipped restart for '{{name}}': {{reason}}", { name: restartProfile.name, reason: translatedError(error, td) })}\n`);
183
+ return 0;
184
+ }
156
185
  await serviceAction(spec, "restart", deps.runner);
157
- deps.stdout(`${td("Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.", { name: profileName })}\n`);
186
+ deps.stdout(`${td("Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.", { name: restartProfile.name })}\n`);
158
187
  }
159
188
  else {
160
189
  deps.stdout(`${td("Upgraded daemon. Installed services were not restarted; pass --profile to restart one.")}\n`);
@@ -162,7 +191,10 @@ export async function runComputerCommand(argv, overrides = {}) {
162
191
  return 0;
163
192
  }
164
193
  const name = requireValue(profileName, "--profile", td);
165
- await loadProfile(name, home, profileStorage);
194
+ const profile = await loadProfile(name, home, profileStorage);
195
+ if (command === "install" || command === "start" || command === "restart") {
196
+ await assertProfileAgentsRootUnique(profile, home, deps.userHome, profileStorage);
197
+ }
166
198
  if (!deps.entryPath.endsWith(".js")) {
167
199
  throw new Error(td("Service lifecycle requires the built daemon entry (.js), not a TypeScript development entry"));
168
200
  }
@@ -199,16 +231,44 @@ export async function runComputerCommand(argv, overrides = {}) {
199
231
  const privateFile = deps.platform === "win32"
200
232
  ? await windowsProfileIsPrivate(profilePath(name, home), deps.runner)
201
233
  : await profileIsPrivate(name, home, deps.platform);
202
- const checks = await doctorService(spec, privateFile, deps.runner);
234
+ const serviceChecks = await doctorService(spec, privateFile, deps.runner);
235
+ const rootInspection = await inspectProfileAgentsRoot(profile, home, deps.userHome, profileStorage);
236
+ const journalPath = resolve(rootInspection.agentsRoot, ".crew", "executions");
237
+ const journalLock = await inspectJournalLock({
238
+ directory: journalPath,
239
+ inspectIdentity: defaultProcessController.inspectIdentity,
240
+ });
241
+ const checks = [
242
+ ...serviceChecks,
243
+ {
244
+ name: "agents-root-unique",
245
+ ok: rootInspection.duplicateProfiles.length === 0,
246
+ detail: rootInspection.duplicateProfiles.length === 0
247
+ ? "unique"
248
+ : `also used by: ${rootInspection.duplicateProfiles.join(", ")}`,
249
+ },
250
+ {
251
+ name: "journal-lock-health",
252
+ ok: journalLock.status === "unlocked" || journalLock.status === "owned",
253
+ detail: journalLock.detail,
254
+ },
255
+ ];
203
256
  const registered = (await serviceStatus(spec, deps.runner)).installed;
204
- deps.stdout(`${JSON.stringify({ checks, registered }, null, 2)}\n`);
257
+ deps.stdout(`${JSON.stringify({
258
+ agentsRoot: rootInspection.agentsRoot,
259
+ journalPath,
260
+ duplicateProfiles: rootInspection.duplicateProfiles,
261
+ journalLock,
262
+ checks,
263
+ registered,
264
+ }, null, 2)}\n`);
205
265
  return checks.every((check) => check.ok) ? 0 : 4;
206
266
  }
207
267
  deps.stderr(usage(td));
208
268
  return 2;
209
269
  }
210
270
  catch (error) {
211
- deps.stderr(`crew-daemon: ${td(error.message)}\n`);
271
+ deps.stderr(`crew-daemon: ${translatedError(error, td)}\n`);
212
272
  return 1;
213
273
  }
214
274
  }