@parall/daemon 1.27.0

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,415 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { ParallWs } from "@parall/sdk";
5
+ import { agentClaudeCredentialsFileFor, agentClaudeHomeFor, agentStateDirFor, agentWorkspaceDirFor, sharedClaudeCredentialsFileFor, } from "./config.js";
6
+ import { assertAgentKey, getRuntimeAdapter } from "./runtimes.js";
7
+ /**
8
+ * Sleep that wakes early on abort. Returns true if the full delay elapsed,
9
+ * false if aborted. Used by bootstrap retry and the outer keepalive in
10
+ * `runForever` so SIGTERM during a long backoff doesn't stall shutdown.
11
+ */
12
+ function sleepCancellable(ms, signal) {
13
+ if (signal.aborted)
14
+ return Promise.resolve(false);
15
+ return new Promise((resolve) => {
16
+ const timer = setTimeout(() => {
17
+ signal.removeEventListener("abort", onAbort);
18
+ resolve(true);
19
+ }, ms);
20
+ const onAbort = () => {
21
+ clearTimeout(timer);
22
+ resolve(false);
23
+ };
24
+ signal.addEventListener("abort", onAbort, { once: true });
25
+ });
26
+ }
27
+ export { sleepCancellable };
28
+ /**
29
+ * Supervisor orchestrates one mck_ machine bearer into N
30
+ * `parall-claude-agent` subprocesses, one per AttachedAgent.
31
+ *
32
+ * Lifecycle is WS event-driven:
33
+ * 1. Bootstrap: confirm machine identity with retry.
34
+ * 2. Full reconcile: list attached agents, diff with children, spawn/kill.
35
+ * 3. Connect WS: receive machine.agent.attached / .detached / .stop
36
+ * events for incremental updates; full reconcile on every reconnect
37
+ * (machine.hello) to catch events missed while disconnected.
38
+ */
39
+ export class DaemonSupervisor {
40
+ config;
41
+ client;
42
+ log;
43
+ children = new Map();
44
+ ws = null;
45
+ running = false;
46
+ machineOrgId = null;
47
+ stopResolve = null;
48
+ constructor(config, client, log) {
49
+ this.config = config;
50
+ this.client = client;
51
+ this.log = log;
52
+ }
53
+ /** Start the supervisor. Returns a promise that resolves on `stop()`. */
54
+ async run(signal) {
55
+ if (this.running)
56
+ throw new Error("supervisor already running");
57
+ this.running = true;
58
+ const onAbort = () => {
59
+ this.stop().catch((err) => this.log.error(`stop() failed: ${String(err)}`));
60
+ };
61
+ signal.addEventListener("abort", onAbort, { once: true });
62
+ try {
63
+ if (!(await this.bootstrapWithRetry(signal))) {
64
+ signal.removeEventListener("abort", onAbort);
65
+ this.running = false;
66
+ return;
67
+ }
68
+ }
69
+ catch (err) {
70
+ signal.removeEventListener("abort", onAbort);
71
+ this.running = false;
72
+ throw err;
73
+ }
74
+ await this.fullReconcile();
75
+ this.ws = new ParallWs({
76
+ getTicket: () => this.client.getMachineWsTicket(),
77
+ wsUrl: this.config.wsUrl,
78
+ reconnect: true,
79
+ });
80
+ this.ws.on("machine.hello", (_data) => {
81
+ this.log.info("machine WS connected (machine.hello)");
82
+ void this.fullReconcile();
83
+ });
84
+ this.ws.on("machine.agent.attached", (data) => {
85
+ this.log.info(`WS: agent ${data.agent_id} attached`);
86
+ void this.handleAgentAttached(data.agent_id);
87
+ });
88
+ this.ws.on("machine.agent.detached", (data) => {
89
+ this.log.info(`WS: agent ${data.agent_id} detached`);
90
+ void this.handleAgentDetached(data.agent_id);
91
+ });
92
+ this.ws.on("machine.stop", (data) => {
93
+ this.log.info(`WS: machine.stop received (reason=${data.reason ?? "none"})`);
94
+ void this.stop();
95
+ });
96
+ this.ws.onStateChange((state) => {
97
+ if (state === "disconnected" || state === "reconnecting") {
98
+ this.log.warn(`machine WS state: ${state}`);
99
+ }
100
+ });
101
+ await this.ws.connect();
102
+ await new Promise((resolve) => {
103
+ this.stopResolve = resolve;
104
+ });
105
+ signal.removeEventListener("abort", onAbort);
106
+ }
107
+ /** Disconnect WS, cancel timers, SIGTERM all children, await exit. */
108
+ async stop() {
109
+ if (!this.running)
110
+ return;
111
+ this.running = false;
112
+ if (this.ws) {
113
+ this.ws.disconnect();
114
+ this.ws = null;
115
+ }
116
+ const exits = [];
117
+ for (const state of this.children.values()) {
118
+ state.shuttingDown = true;
119
+ if (state.restartTimer) {
120
+ clearTimeout(state.restartTimer);
121
+ state.restartTimer = null;
122
+ }
123
+ exits.push(this.terminateChild(state));
124
+ }
125
+ await Promise.allSettled(exits);
126
+ this.children.clear();
127
+ this.log.info("daemon supervisor stopped");
128
+ if (this.stopResolve) {
129
+ this.stopResolve();
130
+ this.stopResolve = null;
131
+ }
132
+ }
133
+ // ---- Bootstrap (resilient identity probe) ----
134
+ async bootstrapWithRetry(signal) {
135
+ let attempt = 0;
136
+ while (this.running && !signal.aborted) {
137
+ try {
138
+ const machine = await this.client.getMachineSelf();
139
+ this.machineOrgId = machine.org_id;
140
+ this.log.info(`daemon online — machine_id=${machine.id} org=${machine.org_id} label=${machine.label}`);
141
+ return true;
142
+ }
143
+ catch (err) {
144
+ if (this.config.bootstrapBackoffMs === 0) {
145
+ this.log.error(`getMachineSelf failed: ${String(err)} (fail-fast mode)`);
146
+ throw err;
147
+ }
148
+ const delay = Math.min(this.config.bootstrapBackoffMs * Math.pow(2, attempt), this.config.bootstrapBackoffMaxMs);
149
+ attempt += 1;
150
+ this.log.warn(`getMachineSelf failed (attempt ${attempt}): ${String(err)} — retrying in ${delay}ms`);
151
+ const slept = await sleepCancellable(delay, signal);
152
+ if (!slept)
153
+ return false;
154
+ }
155
+ }
156
+ return false;
157
+ }
158
+ // ---- Full reconcile (HTTP-based, used on boot + WS reconnect) ----
159
+ // Safe to run concurrently with WS event handlers: JS single-threaded
160
+ // event loop guarantees no mid-statement interleaving, and both
161
+ // handleAgentAttached/Detached guard on children.has()/get() so a WS
162
+ // event between the HTTP fetch and the spawn/kill loop is a no-op.
163
+ async fullReconcile() {
164
+ if (!this.running)
165
+ return;
166
+ let attached;
167
+ try {
168
+ attached = await this.client.listAttachedAgents();
169
+ }
170
+ catch (err) {
171
+ this.log.warn(`fullReconcile: listAttachedAgents failed: ${String(err)}`);
172
+ return;
173
+ }
174
+ const seen = new Set();
175
+ for (const a of attached) {
176
+ const userId = a.user?.id ?? a.profile.user_id;
177
+ if (!userId) {
178
+ this.log.warn(`skipping attached entry with no user_id (profile=${JSON.stringify(a.profile)})`);
179
+ continue;
180
+ }
181
+ if (a.user && a.user.status !== "active") {
182
+ this.log.info(`agent ${userId} not active (status=${a.user.status}) — skipping`);
183
+ continue;
184
+ }
185
+ seen.add(userId);
186
+ const existing = this.children.get(userId);
187
+ if (!existing) {
188
+ const orgId = this.machineOrgId;
189
+ if (!orgId) {
190
+ this.log.warn(`agent ${userId}: no org_id available yet; skipping`);
191
+ continue;
192
+ }
193
+ await this.spawnAgent(userId, orgId, a);
194
+ }
195
+ else if (!existing.child && !existing.restartTimer && !existing.shuttingDown) {
196
+ await this.restartChildNow(existing, "reconcile found no live child");
197
+ }
198
+ }
199
+ for (const [userId, state] of this.children) {
200
+ if (!seen.has(userId)) {
201
+ this.log.info(`agent ${userId} detached (reconcile) — terminating subprocess`);
202
+ state.shuttingDown = true;
203
+ if (state.restartTimer) {
204
+ clearTimeout(state.restartTimer);
205
+ state.restartTimer = null;
206
+ }
207
+ await this.terminateChild(state);
208
+ this.children.delete(userId);
209
+ }
210
+ }
211
+ }
212
+ // ---- WS event handlers (incremental) ----
213
+ async handleAgentAttached(agentId) {
214
+ if (this.children.has(agentId))
215
+ return;
216
+ let attached;
217
+ try {
218
+ attached = await this.client.listAttachedAgents();
219
+ }
220
+ catch (err) {
221
+ this.log.warn(`handleAgentAttached: listAttachedAgents failed: ${String(err)}`);
222
+ return;
223
+ }
224
+ const entry = attached.find((a) => (a.user?.id ?? a.profile.user_id) === agentId);
225
+ if (!entry) {
226
+ this.log.warn(`handleAgentAttached: agent ${agentId} not found in attached list`);
227
+ return;
228
+ }
229
+ if (entry.user && entry.user.status !== "active") {
230
+ this.log.info(`agent ${agentId} not active (status=${entry.user.status}) — skipping`);
231
+ return;
232
+ }
233
+ const orgId = this.machineOrgId;
234
+ if (!orgId) {
235
+ this.log.warn(`agent ${agentId}: no org_id available yet; skipping`);
236
+ return;
237
+ }
238
+ await this.spawnAgent(agentId, orgId, entry);
239
+ }
240
+ async handleAgentDetached(agentId) {
241
+ const state = this.children.get(agentId);
242
+ if (!state)
243
+ return;
244
+ this.log.info(`agent ${agentId} detached — terminating subprocess`);
245
+ state.shuttingDown = true;
246
+ if (state.restartTimer) {
247
+ clearTimeout(state.restartTimer);
248
+ state.restartTimer = null;
249
+ }
250
+ await this.terminateChild(state);
251
+ this.children.delete(agentId);
252
+ }
253
+ // ---- Spawn / restart ----
254
+ async restartChildNow(state, reason) {
255
+ if (!this.running || state.shuttingDown || state.child || state.restartTimer) {
256
+ return;
257
+ }
258
+ try {
259
+ state.credential = await this.client.mintLaunchCredential(state.agentId);
260
+ state.restartAttempts = 0;
261
+ this.log.info(`agent ${state.agentId}: restarting child (${reason})`);
262
+ this.startChild(state);
263
+ }
264
+ catch (err) {
265
+ this.log.warn(`agent ${state.agentId}: restart mint failed (${reason}): ${String(err)}`);
266
+ }
267
+ }
268
+ async spawnAgent(agentId, orgId, attached) {
269
+ let credential;
270
+ try {
271
+ credential = await this.client.mintLaunchCredential(agentId);
272
+ }
273
+ catch (err) {
274
+ this.log.error(`mintLaunchCredential ${agentId} failed: ${String(err)}`);
275
+ return;
276
+ }
277
+ const stateDir = agentStateDirFor(this.config.rootStateDir, agentId);
278
+ const workspaceDir = agentWorkspaceDirFor(this.config.rootStateDir, agentId);
279
+ const claudeHome = agentClaudeHomeFor(this.config.rootClaudeHome, agentId);
280
+ try {
281
+ fs.mkdirSync(stateDir, { recursive: true });
282
+ fs.mkdirSync(workspaceDir, { recursive: true });
283
+ fs.mkdirSync(claudeHome, { recursive: true });
284
+ this.ensureSharedCredentialLink(claudeHome, agentId);
285
+ }
286
+ catch (err) {
287
+ this.log.warn(`mkdir agent dirs (${agentId}) failed: ${String(err)}`);
288
+ }
289
+ const runtimeType = attached.profile.runtime_type ?? "claude-code";
290
+ const state = {
291
+ agentId,
292
+ orgId,
293
+ runtimeType,
294
+ child: null,
295
+ credential,
296
+ restartAttempts: 0,
297
+ restartTimer: null,
298
+ shuttingDown: false,
299
+ };
300
+ this.children.set(agentId, state);
301
+ this.startChild(state);
302
+ }
303
+ startChild(state) {
304
+ if (state.shuttingDown || !this.running)
305
+ return;
306
+ if (!state.credential) {
307
+ this.log.error(`startChild ${state.agentId}: no credential — bug`);
308
+ return;
309
+ }
310
+ assertAgentKey(state.credential.api_key);
311
+ const adapter = getRuntimeAdapter(state.runtimeType);
312
+ const dirs = {
313
+ stateDir: agentStateDirFor(this.config.rootStateDir, state.agentId),
314
+ workspaceDir: agentWorkspaceDirFor(this.config.rootStateDir, state.agentId),
315
+ claudeHome: agentClaudeHomeFor(this.config.rootClaudeHome, state.agentId),
316
+ };
317
+ const env = adapter.buildEnv({ ...process.env, PRLL_API_URL: this.config.apiUrl }, state.agentId, state.orgId, state.credential.api_key, dirs);
318
+ this.log.info(`spawning agent ${state.agentId} runtime=${state.runtimeType} bin=${adapter.bin} (attempt ${state.restartAttempts + 1})`);
319
+ const child = spawn(adapter.bin, [], {
320
+ env,
321
+ stdio: ["ignore", "inherit", "inherit"],
322
+ detached: false,
323
+ });
324
+ state.child = child;
325
+ let childSettled = false;
326
+ const settleChild = (event, code, signal, err) => {
327
+ if (childSettled)
328
+ return;
329
+ childSettled = true;
330
+ if (state.child !== child)
331
+ return;
332
+ const wasShutting = state.shuttingDown;
333
+ state.child = null;
334
+ if (err) {
335
+ this.log.error(`agent ${state.agentId} child ${event}: ${String(err)}${wasShutting ? " (shutting down)" : ""}`);
336
+ }
337
+ else {
338
+ this.log.info(`agent ${state.agentId} exited code=${code ?? "null"} signal=${signal ?? "null"}${wasShutting ? " (shutting down)" : ""}`);
339
+ }
340
+ if (wasShutting || !this.running)
341
+ return;
342
+ const delay = Math.min(this.config.restartBackoffMs * Math.pow(2, state.restartAttempts), this.config.restartBackoffMaxMs);
343
+ state.restartAttempts += 1;
344
+ this.log.warn(`agent ${state.agentId} will restart in ${delay}ms`);
345
+ state.restartTimer = setTimeout(() => {
346
+ state.restartTimer = null;
347
+ this.startChild(state);
348
+ }, delay);
349
+ };
350
+ child.once("error", (err) => settleChild("error", null, null, err));
351
+ child.once("close", (code, signal) => settleChild("close", code, signal));
352
+ setTimeout(() => {
353
+ if (state.child === child) {
354
+ state.restartAttempts = 0;
355
+ }
356
+ }, Math.max(this.config.restartBackoffMs, 30_000));
357
+ }
358
+ async terminateChild(state) {
359
+ const child = state.child;
360
+ if (!child)
361
+ return;
362
+ return new Promise((resolve) => {
363
+ const onExit = () => resolve();
364
+ child.once("exit", onExit);
365
+ try {
366
+ child.kill("SIGTERM");
367
+ }
368
+ catch (err) {
369
+ this.log.warn(`SIGTERM ${state.agentId} threw: ${String(err)}`);
370
+ child.off("exit", onExit);
371
+ resolve();
372
+ return;
373
+ }
374
+ const hardKill = setTimeout(() => {
375
+ try {
376
+ child.kill("SIGKILL");
377
+ }
378
+ catch {
379
+ /* already gone */
380
+ }
381
+ }, 10_000);
382
+ child.once("exit", () => clearTimeout(hardKill));
383
+ });
384
+ }
385
+ ensureSharedCredentialLink(agentClaudeHome, agentId) {
386
+ const sharedCredentials = path.resolve(sharedClaudeCredentialsFileFor(this.config.rootClaudeHome));
387
+ const agentCredentials = agentClaudeCredentialsFileFor(agentClaudeHome);
388
+ const agentCredentialsDir = path.dirname(agentCredentials);
389
+ fs.mkdirSync(path.dirname(sharedCredentials), { recursive: true });
390
+ fs.mkdirSync(agentCredentialsDir, { recursive: true });
391
+ try {
392
+ const existing = fs.lstatSync(agentCredentials);
393
+ if (existing.isSymbolicLink()) {
394
+ const currentTarget = fs.readlinkSync(agentCredentials);
395
+ if (path.resolve(agentCredentialsDir, currentTarget) === sharedCredentials) {
396
+ return;
397
+ }
398
+ fs.unlinkSync(agentCredentials);
399
+ }
400
+ else if (existing.isDirectory()) {
401
+ this.log.warn(`agent ${agentId}: credential path is a directory, cannot link ${agentCredentials}`);
402
+ return;
403
+ }
404
+ else {
405
+ fs.unlinkSync(agentCredentials);
406
+ }
407
+ }
408
+ catch (err) {
409
+ if (err.code !== "ENOENT") {
410
+ throw err;
411
+ }
412
+ }
413
+ fs.symlinkSync(sharedCredentials, agentCredentials);
414
+ }
415
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@parall/daemon",
3
+ "version": "1.27.0",
4
+ "description": "Per-host supervisor for daemon-mode Parall Machines — fans one mck_ bearer out into N agent-runtime subprocesses, one per attached agent",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/parall-hq/parall-mono",
9
+ "directory": "ts/daemon"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "bin": {
15
+ "parall-daemon": "./dist/index.js"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "dependencies": {
28
+ "@parall/sdk": "1.27.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "typescript": "^5.7.0"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -b",
36
+ "start": "node dist/index.js"
37
+ }
38
+ }
package/src/config.ts ADDED
@@ -0,0 +1,142 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+
4
+ /**
5
+ * Daemon-mode env. The daemon authenticates to the Parall API with an
6
+ * `mck_*` bearer token (PRLL_API_KEY) and supervises N per-agent
7
+ * `parall-claude-agent` subprocesses on this host.
8
+ *
9
+ * Compared to claude-agent's config:
10
+ * - `apiKey` is an mck_ bearer (Machine-scoped), not an agk_
11
+ * - there is no PRLL_ORG_ID — the Machine row's org_id is implicit;
12
+ * each spawned per-agent subprocess is given its own PRLL_ORG_ID
13
+ * resolved from the AttachedAgent's profile / org context
14
+ * - per-agent state lives at `<rootStateDir>/agents/<agent_id>/`
15
+ */
16
+ export type ClaudeDaemonConfig = {
17
+ apiUrl: string;
18
+ /** mck_*-prefixed Machine bearer. */
19
+ apiKey: string;
20
+ /** @deprecated Superseded by RuntimeAdapter pattern (runtimes.ts). Kept for backward compat / fallback. */
21
+ agentBin: string;
22
+ /** Root for per-agent state dirs. Each agent gets `<rootStateDir>/agents/<agent_id>`. */
23
+ rootStateDir: string;
24
+ /** Shared host home root. Per-agent HOME dirs live under `<rootClaudeHome>/agents/<agent_id>`. */
25
+ rootClaudeHome: string;
26
+ /** Optional WS URL override for the machine control-plane WebSocket. */
27
+ wsUrl?: string;
28
+ /** @deprecated Superseded by WS event-driven model; kept for backward compat. */
29
+ pollIntervalMs: number;
30
+ /** @deprecated Superseded by WS event-driven model; kept for backward compat. */
31
+ heartbeatIntervalMs: number;
32
+ /** Backoff base after a subprocess crash, in ms. */
33
+ restartBackoffMs: number;
34
+ /** Hard cap on parallel restart attempts per agent before giving up for a cycle. */
35
+ restartBackoffMaxMs: number;
36
+ /**
37
+ * Bootstrap retry: when `getMachineSelf` fails on startup (network blip,
38
+ * mck_ key not yet propagated, server warming up), wait this long and
39
+ * retry instead of crashing the daemon. Exponential backoff up to
40
+ * `bootstrapBackoffMaxMs`. Set 0 to disable (fail-fast on first error).
41
+ */
42
+ bootstrapBackoffMs: number;
43
+ bootstrapBackoffMaxMs: number;
44
+ /**
45
+ * Outer supervisor keepalive: if `supervisor.run()` rejects with an
46
+ * unexpected error, wait this long and reinstantiate. Same exp-backoff
47
+ * shape as bootstrap. Set 0 to disable (let main() exit, rely on K8s/PID-1).
48
+ */
49
+ supervisorRestartBackoffMs: number;
50
+ supervisorRestartBackoffMaxMs: number;
51
+ };
52
+
53
+ function requireEnv(env: NodeJS.ProcessEnv, name: string): string {
54
+ const value = env[name]?.trim();
55
+ if (!value) {
56
+ throw new Error(`Missing required env var: ${name}`);
57
+ }
58
+ return value;
59
+ }
60
+
61
+ function resolvePath(value: string): string {
62
+ return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
63
+ }
64
+
65
+ function parseMs(value: string | undefined, fallback: number): number {
66
+ if (!value) return fallback;
67
+ const n = Number(value);
68
+ return Number.isFinite(n) && n > 0 ? n : fallback;
69
+ }
70
+
71
+ /** Like parseMs but allows 0 (operator opt-out). */
72
+ function parseMsAllowZero(value: string | undefined, fallback: number): number {
73
+ if (value === undefined) return fallback;
74
+ const n = Number(value);
75
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
76
+ }
77
+
78
+ export function resolveClaudeDaemonConfig(env: NodeJS.ProcessEnv = process.env): ClaudeDaemonConfig {
79
+ const apiUrl = requireEnv(env, "PRLL_API_URL");
80
+ const apiKey = requireEnv(env, "PRLL_API_KEY");
81
+ if (!apiKey.startsWith("mck_")) {
82
+ // Fatal startup validation: the daemon must never run with an agent or
83
+ // human key because child launch credentials are minted from this bearer.
84
+ throw new Error(
85
+ `PRLL_API_KEY does not look like a Machine bearer (expected prefix "mck_"). ` +
86
+ `Daemon mode requires a machine-scoped key issued via POST /machines/{id}/keys.`,
87
+ );
88
+ }
89
+ const rootClaudeHome = resolvePath(env.PRLL_CLAUDE_HOME?.trim() || env.HOME || os.homedir());
90
+ const rootStateDir = resolvePath(env.PRLL_CLAUDE_STATE_DIR?.trim() || path.join(rootClaudeHome, ".parall-agent"));
91
+
92
+ return {
93
+ apiUrl,
94
+ apiKey,
95
+ agentBin: env.PRLL_CLAUDE_AGENT_BIN?.trim() || "parall-claude-agent",
96
+ rootStateDir,
97
+ rootClaudeHome,
98
+ wsUrl: env.PRLL_WS_URL?.trim() || undefined,
99
+ pollIntervalMs: parseMs(env.PRLL_DAEMON_POLL_INTERVAL_MS, 30_000),
100
+ heartbeatIntervalMs: parseMs(env.PRLL_DAEMON_HEARTBEAT_INTERVAL_MS, 30_000),
101
+ restartBackoffMs: parseMs(env.PRLL_DAEMON_RESTART_BACKOFF_MS, 5_000),
102
+ restartBackoffMaxMs: parseMs(env.PRLL_DAEMON_RESTART_BACKOFF_MAX_MS, 5 * 60_000),
103
+ bootstrapBackoffMs: parseMsAllowZero(env.PRLL_DAEMON_BOOTSTRAP_BACKOFF_MS, 2_000),
104
+ bootstrapBackoffMaxMs: parseMs(env.PRLL_DAEMON_BOOTSTRAP_BACKOFF_MAX_MS, 60_000),
105
+ supervisorRestartBackoffMs: parseMsAllowZero(env.PRLL_DAEMON_SUPERVISOR_RESTART_BACKOFF_MS, 5_000),
106
+ supervisorRestartBackoffMaxMs: parseMs(env.PRLL_DAEMON_SUPERVISOR_RESTART_BACKOFF_MAX_MS, 5 * 60_000),
107
+ };
108
+ }
109
+
110
+ function assertSafeAgentId(agentId: string): string {
111
+ if (!/^[A-Za-z0-9_-]+$/.test(agentId)) {
112
+ throw new Error(`Invalid agentId for filesystem path: ${agentId}`);
113
+ }
114
+ return agentId;
115
+ }
116
+
117
+ /** Per-agent state dir under the shared host volume. */
118
+ export function agentStateDirFor(rootStateDir: string, agentId: string): string {
119
+ return path.join(rootStateDir, "agents", assertSafeAgentId(agentId));
120
+ }
121
+
122
+ /** Per-agent HOME dir. Claude Code stores project/session state under
123
+ * `${HOME}/.claude`, so each agent gets its own HOME root while the daemon
124
+ * links shared OAuth credentials into that `.claude` directory. */
125
+ export function agentClaudeHomeFor(rootClaudeHome: string, agentId: string): string {
126
+ return path.join(rootClaudeHome, "agents", assertSafeAgentId(agentId));
127
+ }
128
+
129
+ /** Shared Claude Code OAuth credential written by server-side runtime auth. */
130
+ export function sharedClaudeCredentialsFileFor(rootClaudeHome: string): string {
131
+ return path.join(rootClaudeHome, ".claude", ".credentials.json");
132
+ }
133
+
134
+ /** Per-agent credential location inside that agent's isolated HOME. */
135
+ export function agentClaudeCredentialsFileFor(agentClaudeHome: string): string {
136
+ return path.join(agentClaudeHome, ".claude", ".credentials.json");
137
+ }
138
+
139
+ /** Per-agent workspace dir where the agent runs git commands. */
140
+ export function agentWorkspaceDirFor(rootStateDir: string, agentId: string): string {
141
+ return path.join(rootStateDir, "agents", assertSafeAgentId(agentId), "workspace");
142
+ }