@nowcrew/daemon 0.5.11 → 0.5.13

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 ADDED
@@ -0,0 +1,77 @@
1
+ # NowWork Daemon
2
+
3
+ The daemon is NowWork's local execution adapter. It holds a machine credential, keeps one control-plane
4
+ WebSocket open, and launches coding runtimes on that machine. Product policy remains on the server.
5
+
6
+ ## Run
7
+
8
+ Use the connection command generated by **Add Computer**:
9
+
10
+ ```bash
11
+ npx @nowcrew/daemon@latest --server-url https://nowwork.example --api-key sk_machine_...
12
+ ```
13
+
14
+ The process stays resident and reconnects with backoff. For local development, run
15
+ `pnpm --filter @nowcrew/daemon daemon`. `crew-daemon run --agent <handle> --channel <id>` is the legacy
16
+ manual one-shot entry.
17
+
18
+ ## Execution Boundary
19
+
20
+ The server selects the machine and sends a validated `execution:start` containing:
21
+
22
+ - fully rendered system and wake prompts;
23
+ - runtime/model/reasoning and timeout;
24
+ - requested permission and channel/thread identifiers;
25
+ - reporting flags for final text, activity, and console streams.
26
+
27
+ The daemon intersects requested permission with local policy, checks advertised resource limits, prepares
28
+ the local workspace/environment, and launches only a built-in runtime adapter (`claude`, `codex`, or
29
+ `kimi`). It does not decide collaboration rules, scheduled output policy, fallback delivery, or thread
30
+ behavior. Those are server responsibilities.
31
+
32
+ Protocol support and limits are advertised in `machine:hello`. Unknown required protocol semantics are
33
+ rejected before side effects. Protocol-0 `agent:start` remains only for the server-governed compatibility
34
+ window.
35
+
36
+ ## Reliability
37
+
38
+ Protocol-v1 lifecycle is `accepted → started → completed`, followed by a server
39
+ `execution:completion-ack`. Reconnect uses `execution:sync`; cancellation uses `execution:cancel`.
40
+ Activity and console frames are best-effort, while lifecycle and terminal effects are durable on the
41
+ server.
42
+
43
+ Before accepting work, the daemon writes a local journal entry under:
44
+
45
+ ```text
46
+ <CREW_AGENTS_ROOT>/.crew/executions/<executionId>.json
47
+ ```
48
+
49
+ `CREW_AGENTS_ROOT` defaults to `~/.crew/agents`. On restart, accepted entries become interrupted failures;
50
+ running supervisors are identity-checked and terminated before interruption is reported. A lock prevents
51
+ two daemon processes from sharing one journal.
52
+
53
+ ## Local Policy
54
+
55
+ Useful environment controls:
56
+
57
+ ```text
58
+ CREW_RUNTIME_SAFE=1
59
+ CREW_EXECUTION_MAX_PROMPT_BYTES=256000
60
+ CREW_EXECUTION_MAX_TIMEOUT_MS=3600000
61
+ CREW_EXECUTION_MAX_EVENT_BYTES=64000
62
+ CREW_MAX_PARALLEL=4
63
+ CREW_EXECUTION_MAX_QUEUED_PER_AGENT=32
64
+ ```
65
+
66
+ Local policy can reduce server-requested access and limits; it cannot grant more access than requested.
67
+ Provider credentials and configured environment are prepared locally and never carried in execution
68
+ control frames.
69
+
70
+ ## Code Map
71
+
72
+ - `src/serve.ts`: connection, negotiation, routing, sync, and legacy boundary.
73
+ - `src/execution-runner.ts`: spec admission and lifecycle reporting.
74
+ - `src/local-executor.ts` and `src/execution-supervisor.ts`: runtime process boundary.
75
+ - `src/execution-journal.ts`: crash-safe local execution facts.
76
+ - `src/runner.ts`: protocol-0 compatibility runner.
77
+ - `src/runtimes/`: built-in runtime adapters.
package/dist/config.js CHANGED
@@ -4,8 +4,38 @@ import { dirname, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
6
  import { detectDaemonLang, translateDaemon } from "./i18n.js";
7
+ export const DEFAULT_EXECUTION_LIMITS = Object.freeze({
8
+ maxPromptBytes: 256_000,
9
+ maxTimeoutMs: 3_600_000,
10
+ maxEventBytes: 64_000,
11
+ maxParallelPerAgent: 4,
12
+ maxQueuedPerAgent: 32,
13
+ });
14
+ // Fits the largest mandatory v1 lifecycle envelope (UUID + timestamps + outcome facts) with margin.
15
+ export const MIN_EXECUTION_EVENT_BYTES = 512;
16
+ export const MAX_EXECUTION_TIMEOUT_MS = 2_147_483_647;
7
17
  export class ConfigError extends Error {
8
18
  }
19
+ function positiveIntegerEnv(env, field, fallback) {
20
+ const raw = env[field];
21
+ const value = raw === undefined ? fallback : Number(raw);
22
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
23
+ throw new ConfigError(`${field} must be a finite positive integer`);
24
+ }
25
+ return value;
26
+ }
27
+ function integerEnvAtLeast(env, field, fallback, minimum) {
28
+ const value = positiveIntegerEnv(env, field, fallback);
29
+ if (value < minimum)
30
+ throw new ConfigError(`${field} must be at least ${minimum}`);
31
+ return value;
32
+ }
33
+ function integerEnvAtMost(env, field, fallback, maximum) {
34
+ const value = positiveIntegerEnv(env, field, fallback);
35
+ if (value > maximum)
36
+ throw new ConfigError(`${field} must be at most ${maximum}`);
37
+ return value;
38
+ }
9
39
  function defaultCliPath() {
10
40
  // 已发布场景(npx):crew CLI 是 daemon 的依赖,从 node_modules 解析 @nowcrew/cli。
11
41
  try {
@@ -24,6 +54,13 @@ export function loadConfig(env = process.env) {
24
54
  const lang = detectDaemonLang(env);
25
55
  throw new ConfigError(translateDaemon(lang, "Missing CREW_MACHINE_TOKEN (sk_machine_*, printed by seed)"));
26
56
  }
57
+ const executionLimits = Object.freeze({
58
+ maxPromptBytes: positiveIntegerEnv(env, "CREW_EXECUTION_MAX_PROMPT_BYTES", DEFAULT_EXECUTION_LIMITS.maxPromptBytes),
59
+ maxTimeoutMs: integerEnvAtMost(env, "CREW_EXECUTION_MAX_TIMEOUT_MS", DEFAULT_EXECUTION_LIMITS.maxTimeoutMs, MAX_EXECUTION_TIMEOUT_MS),
60
+ maxEventBytes: integerEnvAtLeast(env, "CREW_EXECUTION_MAX_EVENT_BYTES", DEFAULT_EXECUTION_LIMITS.maxEventBytes, MIN_EXECUTION_EVENT_BYTES),
61
+ maxParallelPerAgent: positiveIntegerEnv(env, "CREW_MAX_PARALLEL", DEFAULT_EXECUTION_LIMITS.maxParallelPerAgent),
62
+ maxQueuedPerAgent: positiveIntegerEnv(env, "CREW_EXECUTION_MAX_QUEUED_PER_AGENT", DEFAULT_EXECUTION_LIMITS.maxQueuedPerAgent),
63
+ });
27
64
  return {
28
65
  serverUrl,
29
66
  machineToken,
@@ -38,5 +75,6 @@ export function loadConfig(env = process.env) {
38
75
  sessionSoftTokens: env.CREW_SESSION_SOFT_TOKENS != null ? Number(env.CREW_SESSION_SOFT_TOKENS) : 90_000,
39
76
  sessionMaxTurns: env.CREW_SESSION_MAX_TURNS != null ? Number(env.CREW_SESSION_MAX_TURNS) : 30,
40
77
  productName: env.CREW_PRODUCT_NAME ?? "nowwork",
78
+ executionLimits,
41
79
  };
42
80
  }
@@ -0,0 +1,59 @@
1
+ import { DaemonToServerExecutionFrameSchema, } from "./execution-protocol.js";
2
+ export function executionFrameBytes(frame) {
3
+ return Buffer.byteLength(JSON.stringify(frame), "utf8");
4
+ }
5
+ function withBoundedString(frame, field, maxBytes, optional, minimumCharacters = 0) {
6
+ const raw = frame[field];
7
+ if (typeof raw !== "string" || executionFrameBytes(frame) <= maxBytes)
8
+ return frame;
9
+ const characters = Array.from(raw);
10
+ let low = 0;
11
+ let high = characters.length;
12
+ let best = null;
13
+ while (low <= high) {
14
+ const middle = Math.floor((low + high) / 2);
15
+ const candidate = { ...frame, [field]: characters.slice(0, middle).join("") };
16
+ if (middle >= minimumCharacters && executionFrameBytes(candidate) <= maxBytes) {
17
+ best = candidate;
18
+ low = middle + 1;
19
+ }
20
+ else {
21
+ high = middle - 1;
22
+ }
23
+ }
24
+ if (best !== null && (!optional || best[field] !== ""))
25
+ return best;
26
+ if (optional) {
27
+ const { [field]: _omitted, ...withoutField } = frame;
28
+ return withoutField;
29
+ }
30
+ return best ?? frame;
31
+ }
32
+ export function boundExecutionFrame(input, maxBytes) {
33
+ if (executionFrameBytes(input) <= maxBytes)
34
+ return input;
35
+ let frame = { ...input };
36
+ if (input.type === "execution:activity") {
37
+ frame = withBoundedString(frame, "detail", maxBytes, false);
38
+ }
39
+ else if (input.type === "execution:console") {
40
+ frame = withBoundedString(frame, "text", maxBytes, false);
41
+ }
42
+ else if (input.type === "execution:rejected") {
43
+ frame = withBoundedString(frame, "message", maxBytes, true);
44
+ }
45
+ else if (input.type === "execution:completed") {
46
+ for (const field of ["finalText", "errorMessage", "model", "terminationSignal"]) {
47
+ frame = withBoundedString(frame, field, maxBytes, true);
48
+ }
49
+ if (executionFrameBytes(frame) > maxBytes && "usage" in frame) {
50
+ const { usage: _usage, ...withoutUsage } = frame;
51
+ frame = withoutUsage;
52
+ }
53
+ frame = withBoundedString(frame, "errorCode", maxBytes, false, 1);
54
+ }
55
+ if (executionFrameBytes(frame) > maxBytes) {
56
+ throw new RangeError(`Mandatory ${input.type} envelope exceeds maxEventBytes=${maxBytes}`);
57
+ }
58
+ return DaemonToServerExecutionFrameSchema.parse(frame);
59
+ }
@@ -0,0 +1,262 @@
1
+ import { mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ const OwnerSchema = z.object({
6
+ pid: z.number().int().positive(),
7
+ processIdentity: z.string().min(1),
8
+ token: z.string().uuid(),
9
+ }).strict();
10
+ export class JournalLockedError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "JournalLockedError";
14
+ }
15
+ }
16
+ export class JournalLockCorruptionError extends Error {
17
+ constructor(path, cause) {
18
+ super(`Invalid execution journal lock: ${path}`, { cause });
19
+ this.name = "JournalLockCorruptionError";
20
+ }
21
+ }
22
+ export const defaultJournalLockFileSystem = {
23
+ mkdir,
24
+ readFile: (path) => readFile(path, "utf8"),
25
+ readdir,
26
+ rename,
27
+ rm,
28
+ rmdir,
29
+ stat,
30
+ unlink,
31
+ writeDurableFile: async (path, contents) => {
32
+ const handle = await open(path, "wx", 0o600);
33
+ try {
34
+ await handle.writeFile(contents, "utf8");
35
+ await handle.sync();
36
+ }
37
+ finally {
38
+ await handle.close();
39
+ }
40
+ },
41
+ };
42
+ const defaultRegistry = { leases: new Map() };
43
+ export function createJournalLeaseRegistry() {
44
+ return { leases: new Map() };
45
+ }
46
+ const codeOf = (error) => error instanceof Error && "code" in error ? error.code : undefined;
47
+ function processLeaseMap(registry) {
48
+ return registry.leases;
49
+ }
50
+ export function createJournalLease(options) {
51
+ const fileSystem = options.fileSystem ?? defaultJournalLockFileSystem;
52
+ const registry = processLeaseMap(options.registry ?? defaultRegistry);
53
+ const now = options.now ?? (() => new Date());
54
+ const orphanGraceMs = options.orphanGraceMs ?? 30_000;
55
+ const lockDirectory = join(options.directory, ".journal.lock");
56
+ let attached = false;
57
+ let status = "open";
58
+ const ownerFileName = (token) => `owner.${token}.json`;
59
+ const parseOwner = async (fileName) => {
60
+ const path = join(lockDirectory, fileName);
61
+ try {
62
+ const owner = OwnerSchema.parse(JSON.parse(await fileSystem.readFile(path)));
63
+ if (fileName !== ownerFileName(owner.token))
64
+ throw new Error("owner token does not match filename");
65
+ return owner;
66
+ }
67
+ catch (error) {
68
+ throw new JournalLockCorruptionError(path, error);
69
+ }
70
+ };
71
+ const readOwner = async () => {
72
+ let names;
73
+ try {
74
+ names = (await fileSystem.readdir(lockDirectory)).map(String).sort();
75
+ }
76
+ catch (error) {
77
+ if (codeOf(error) === "ENOENT")
78
+ return null;
79
+ throw error;
80
+ }
81
+ if (names.length === 0) {
82
+ const lockStat = await fileSystem.stat(lockDirectory);
83
+ if (now().valueOf() - lockStat.mtimeMs < orphanGraceMs) {
84
+ throw new JournalLockedError("Execution journal lock owner installation is in progress");
85
+ }
86
+ try {
87
+ await fileSystem.rmdir(lockDirectory);
88
+ await options.syncDirectory(options.directory);
89
+ }
90
+ catch (error) {
91
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
92
+ throw error;
93
+ }
94
+ return null;
95
+ }
96
+ if (names.length !== 1 || !/^owner\.[0-9a-f-]+\.json$/i.test(names[0])) {
97
+ throw new JournalLockCorruptionError(lockDirectory, new Error("lock directory must contain one owner"));
98
+ }
99
+ return { owner: await parseOwner(names[0]), fileName: names[0] };
100
+ };
101
+ const validateInstalledOwner = async (lease) => {
102
+ const observed = await readOwner();
103
+ if (observed === null
104
+ || observed.fileName !== lease.ownerFile
105
+ || JSON.stringify(observed.owner) !== JSON.stringify(lease.owner)) {
106
+ throw new JournalLockCorruptionError(lockDirectory, new Error("installed owner changed"));
107
+ }
108
+ };
109
+ const finishInstall = async (lease) => {
110
+ if (!lease.ownerInstalled) {
111
+ try {
112
+ await fileSystem.rename(lease.ownerTemp, join(lockDirectory, lease.ownerFile));
113
+ }
114
+ catch (error) {
115
+ if (codeOf(error) !== "ENOENT")
116
+ throw error;
117
+ await validateInstalledOwner(lease);
118
+ }
119
+ lease.ownerInstalled = true;
120
+ }
121
+ await validateInstalledOwner(lease);
122
+ if (!lease.lockDirectorySynced) {
123
+ await options.syncDirectory(lockDirectory);
124
+ lease.lockDirectorySynced = true;
125
+ }
126
+ if (!lease.executionsDirectorySynced) {
127
+ await options.syncDirectory(options.directory);
128
+ lease.executionsDirectorySynced = true;
129
+ }
130
+ await fileSystem.rm(lease.ownerTemp, { force: true }).catch(() => undefined);
131
+ };
132
+ const install = async () => {
133
+ const token = randomUUID();
134
+ const owner = OwnerSchema.parse({
135
+ pid: options.currentPid,
136
+ processIdentity: await options.captureCurrentIdentity(),
137
+ token,
138
+ });
139
+ const ownerTemp = join(options.directory, `.journal-owner.${token}.tmp`);
140
+ await fileSystem.writeDurableFile(ownerTemp, `${JSON.stringify(owner)}\n`);
141
+ try {
142
+ for (;;) {
143
+ try {
144
+ await fileSystem.mkdir(lockDirectory);
145
+ const lease = {
146
+ owner,
147
+ ownerFile: ownerFileName(token),
148
+ ownerTemp,
149
+ refs: 1,
150
+ ownerInstalled: false,
151
+ lockDirectorySynced: false,
152
+ executionsDirectorySynced: false,
153
+ releasing: false,
154
+ ownerRemoved: false,
155
+ lockDirectoryRemoved: false,
156
+ releaseSynced: false,
157
+ };
158
+ registry.set(options.directory, lease);
159
+ attached = true;
160
+ await finishInstall(lease);
161
+ return lease;
162
+ }
163
+ catch (error) {
164
+ if (codeOf(error) !== "EEXIST")
165
+ throw error;
166
+ }
167
+ const observed = await readOwner();
168
+ if (observed === null)
169
+ continue;
170
+ const identity = await options.inspectIdentity(observed.owner.pid);
171
+ if (identity === observed.owner.processIdentity) {
172
+ throw new JournalLockedError(`Execution journal is locked by process ${observed.owner.pid}`);
173
+ }
174
+ await options.hooks?.beforeRemoveObservedOwner?.(observed.owner);
175
+ try {
176
+ await fileSystem.unlink(join(lockDirectory, observed.fileName));
177
+ }
178
+ catch (error) {
179
+ if (codeOf(error) !== "ENOENT")
180
+ throw error;
181
+ continue;
182
+ }
183
+ try {
184
+ await fileSystem.rmdir(lockDirectory);
185
+ await options.syncDirectory(options.directory);
186
+ }
187
+ catch (error) {
188
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
189
+ throw error;
190
+ }
191
+ }
192
+ }
193
+ finally {
194
+ if (!attached)
195
+ await fileSystem.rm(ownerTemp, { force: true }).catch(() => undefined);
196
+ }
197
+ };
198
+ const acquire = async () => {
199
+ if (status !== "open")
200
+ throw new JournalLockedError(`Journal lease is ${status}`);
201
+ if (attached) {
202
+ const lease = registry.get(options.directory);
203
+ if (lease === undefined)
204
+ throw new JournalLockedError("In-process journal lease is missing");
205
+ await finishInstall(lease);
206
+ return;
207
+ }
208
+ const existing = registry.get(options.directory);
209
+ if (existing !== undefined) {
210
+ if (existing.releasing)
211
+ throw new JournalLockedError("Journal lease release is pending");
212
+ await finishInstall(existing);
213
+ existing.refs += 1;
214
+ attached = true;
215
+ return;
216
+ }
217
+ await install();
218
+ };
219
+ const close = async () => {
220
+ if (status === "closed")
221
+ return;
222
+ if (!attached) {
223
+ status = "closed";
224
+ return;
225
+ }
226
+ const lease = registry.get(options.directory);
227
+ if (lease === undefined)
228
+ throw new JournalLockedError("In-process journal lease is missing");
229
+ if (status === "open" && lease.refs > 1) {
230
+ lease.refs -= 1;
231
+ attached = false;
232
+ status = "closed";
233
+ return;
234
+ }
235
+ status = "closing";
236
+ lease.releasing = true;
237
+ if (!lease.ownerRemoved) {
238
+ await fileSystem.unlink(join(lockDirectory, lease.ownerFile));
239
+ lease.ownerRemoved = true;
240
+ }
241
+ if (!lease.lockDirectoryRemoved) {
242
+ await fileSystem.rmdir(lockDirectory);
243
+ lease.lockDirectoryRemoved = true;
244
+ }
245
+ if (!lease.releaseSynced) {
246
+ await options.syncDirectory(options.directory);
247
+ lease.releaseSynced = true;
248
+ }
249
+ lease.refs -= 1;
250
+ registry.delete(options.directory);
251
+ attached = false;
252
+ status = "closed";
253
+ };
254
+ return {
255
+ acquire,
256
+ assertUsable: () => {
257
+ if (status !== "open")
258
+ throw new JournalLockedError(`Execution journal lease is ${status}`);
259
+ },
260
+ close,
261
+ };
262
+ }