@parall/claude-agent 1.24.0 → 1.26.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.
@@ -7,6 +7,7 @@ export class ClaudeSessionManager {
7
7
  logger;
8
8
  sessionIds = new Map();
9
9
  pendingForkParents = new Map();
10
+ processes = new Map();
10
11
  constructor(mainSessionKey, stateFilePath, logger) {
11
12
  this.mainSessionKey = mainSessionKey;
12
13
  this.stateFilePath = stateFilePath;
@@ -43,6 +44,122 @@ export class ClaudeSessionManager {
43
44
  cleanupFork(sessionKey) {
44
45
  this.pendingForkParents.delete(sessionKey);
45
46
  this.sessionIds.delete(sessionKey);
47
+ const handle = this.processes.get(sessionKey);
48
+ if (handle) {
49
+ this.processes.delete(sessionKey);
50
+ this.closeHandle(handle, `fork ${sessionKey} cleanup`);
51
+ }
52
+ }
53
+ getProcess(sessionKey) {
54
+ return this.processes.get(sessionKey);
55
+ }
56
+ registerProcess(sessionKey, handle) {
57
+ const existing = this.processes.get(sessionKey);
58
+ if (existing && existing !== handle) {
59
+ this.logger?.warn(`claude-agent: replacing existing process handle for ${sessionKey}; closing previous`);
60
+ this.closeHandle(existing, `replaced for ${sessionKey}`);
61
+ }
62
+ this.processes.set(sessionKey, handle);
63
+ // Auto-clear on exit so the map does not accumulate dead handles.
64
+ handle.exitPromise
65
+ .finally(() => {
66
+ const current = this.processes.get(sessionKey);
67
+ if (current === handle) {
68
+ this.processes.delete(sessionKey);
69
+ }
70
+ })
71
+ .catch(() => {
72
+ // exitPromise is resolved, never rejected, but guard anyway.
73
+ });
74
+ }
75
+ clearProcess(sessionKey, handle) {
76
+ const current = this.processes.get(sessionKey);
77
+ if (!current)
78
+ return;
79
+ if (handle && current !== handle)
80
+ return;
81
+ this.processes.delete(sessionKey);
82
+ }
83
+ /** Grace period (ms) between SIGTERM and SIGKILL during shutdown. */
84
+ static SHUTDOWN_GRACE_MS = 5_000;
85
+ async shutdownAll() {
86
+ const handles = [...this.processes.entries()];
87
+ this.processes.clear();
88
+ await Promise.all(handles.map(([sessionKey, handle]) => this.shutdownOne(sessionKey, handle)));
89
+ }
90
+ async shutdownOne(sessionKey, handle) {
91
+ this.closeHandle(handle, `shutdown ${sessionKey}`);
92
+ // Wait for SIGTERM to take effect, but bound the wait: if the child
93
+ // ignores SIGTERM (buggy tool, uninterruptible syscall, etc.) we must
94
+ // not block the whole agent exit forever. Escalate to SIGKILL after the
95
+ // grace window and then wait once more for the kernel to reap it.
96
+ const timedOut = await this.raceWithTimeout(handle.exitPromise, ClaudeSessionManager.SHUTDOWN_GRACE_MS);
97
+ if (!timedOut)
98
+ return;
99
+ if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
100
+ this.logger?.warn(`claude-agent: SIGTERM timed out for ${sessionKey}, escalating to SIGKILL`);
101
+ try {
102
+ handle.proc.kill("SIGKILL");
103
+ }
104
+ catch (error) {
105
+ this.logger?.warn(`claude-agent: SIGKILL failed for ${sessionKey}: ${String(error)}`);
106
+ }
107
+ }
108
+ // Bound the post-SIGKILL wait too: on the rare kernel path where even
109
+ // SIGKILL delivery is delayed (uninterruptible D-state, zombie reaping
110
+ // stuck on a parent bookkeeping path), gateway disconnect must still
111
+ // make progress. Warn and move on if the reap is not observed in time.
112
+ const killTimedOut = await this.raceWithTimeout(handle.exitPromise, ClaudeSessionManager.SHUTDOWN_GRACE_MS);
113
+ if (killTimedOut) {
114
+ this.logger?.warn(`claude-agent: subprocess for ${sessionKey} not reaped after SIGKILL within ${ClaudeSessionManager.SHUTDOWN_GRACE_MS}ms; continuing shutdown`);
115
+ }
116
+ }
117
+ /**
118
+ * Wait up to `ms` for `promise`. Resolves `false` if the promise settled
119
+ * in time, `true` if the timeout fired first. Never rejects.
120
+ */
121
+ raceWithTimeout(promise, ms) {
122
+ return new Promise((resolve) => {
123
+ let settled = false;
124
+ const timer = setTimeout(() => {
125
+ if (settled)
126
+ return;
127
+ settled = true;
128
+ resolve(true);
129
+ }, ms);
130
+ // Don't keep the event loop alive purely on the timeout.
131
+ if (typeof timer.unref === "function")
132
+ timer.unref();
133
+ promise
134
+ .catch(() => {
135
+ /* exitPromise does not reject; guard anyway */
136
+ })
137
+ .finally(() => {
138
+ if (settled)
139
+ return;
140
+ settled = true;
141
+ clearTimeout(timer);
142
+ resolve(false);
143
+ });
144
+ });
145
+ }
146
+ closeHandle(handle, reason) {
147
+ try {
148
+ if (!handle.proc.stdin.destroyed) {
149
+ handle.proc.stdin.end();
150
+ }
151
+ }
152
+ catch (error) {
153
+ this.logger?.warn(`claude-agent: failed to close stdin (${reason}): ${String(error)}`);
154
+ }
155
+ if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
156
+ try {
157
+ handle.proc.kill("SIGTERM");
158
+ }
159
+ catch (error) {
160
+ this.logger?.warn(`claude-agent: failed to SIGTERM claude subprocess (${reason}): ${String(error)}`);
161
+ }
162
+ }
46
163
  }
47
164
  restore() {
48
165
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGxD,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EACrC,aAAa,CAAC,EAAE,aAAa,QAiC9B"}
1
+ {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGxD,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EACrC,aAAa,CAAC,EAAE,aAAa,QAmB9B"}
package/dist/workspace.js CHANGED
@@ -1,35 +1,20 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, buildIdentity, } from "@parall/agent-core";
3
+ import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, buildIdentity, buildSkillReferences, writeSkillFiles, } from "@parall/agent-core";
4
4
  import { ensureLocalAttachmentGitExclude } from "@parall/agent-core/internal/attachment-input";
5
5
  export function ensureClaudeWorkspace(workspaceDir, log, agentIdentity) {
6
- const CLAUDE_MD = [
6
+ const systemPrompt = [
7
7
  buildIdentity(agentIdentity),
8
8
  BRIDGE_WORKSPACE_INSTRUCTIONS,
9
9
  PRLL_BEHAVIOR,
10
10
  PRLL_REFERENCE_GUIDE,
11
+ buildSkillReferences(workspaceDir),
11
12
  ].join("\n\n");
12
13
  fs.mkdirSync(workspaceDir, { recursive: true });
13
14
  fs.mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
14
- // CLAUDE.md is bridge-managed: always overwrite so a hosted PVC keeps the
15
- // current guardrails (e.g. the `no-reply` instruction). Operators must not
16
- // hand-edit this file — workspace customizations should go into AGENTS.md
17
- // / SOUL.md / TOOLS.md, which are env-driven and untouched here. Surface a
18
- // warning when we replace a file whose content diverges, so an operator
19
- // who did edit it locally gets a signal instead of silently losing changes.
20
- const claudeMdPath = path.join(workspaceDir, "CLAUDE.md");
21
- if (log) {
22
- try {
23
- const existing = fs.readFileSync(claudeMdPath, "utf8");
24
- if (existing !== CLAUDE_MD) {
25
- log.warn(`claude-agent: overwriting divergent ${claudeMdPath} with bridge-managed template ` +
26
- `(local edits to CLAUDE.md are not preserved — customize AGENTS.md / SOUL.md / TOOLS.md instead)`);
27
- }
28
- }
29
- catch {
30
- // file missing or unreadable — first-boot case, no warning needed
31
- }
32
- }
33
- fs.writeFileSync(claudeMdPath, CLAUDE_MD, "utf8");
15
+ const parallDir = path.join(workspaceDir, ".parall");
16
+ fs.mkdirSync(parallDir, { recursive: true });
17
+ fs.writeFileSync(path.join(parallDir, "system-prompt.md"), systemPrompt, "utf8");
18
+ writeSkillFiles(path.join(parallDir, "skills"));
34
19
  ensureLocalAttachmentGitExclude(workspaceDir);
35
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/claude-agent",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Claude Code bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,9 +25,9 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
- "@parall/agent-core": "1.24.0",
29
- "@parall/cli": "1.24.0",
30
- "@parall/sdk": "1.24.0"
28
+ "@parall/agent-core": "1.26.0",
29
+ "@parall/cli": "1.26.0",
30
+ "@parall/sdk": "1.26.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",
package/src/config.ts CHANGED
@@ -88,6 +88,12 @@ export function sessionStateFilePathForRuntime(stateDir: string, runtimeKey: str
88
88
  return path.join(stateDir, "sessions", `${fileName}.json`);
89
89
  }
90
90
 
91
+ export function contextFilePathForSession(stateDir: string, sessionKey: string): string {
92
+ const fileName = Buffer.from(sessionKey).toString("base64url");
93
+ return path.join(stateDir, "dispatch-context", `${fileName}.json`);
94
+ }
95
+
96
+ /** @deprecated Use contextFilePathForSession. */
91
97
  export function stepIdFilePathForSession(stateDir: string, sessionKey: string): string {
92
98
  const fileName = Buffer.from(sessionKey).toString("base64url");
93
99
  return path.join(stateDir, "step-ids", `${fileName}.txt`);
package/src/dispatch.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { spawn } from "node:child_process";
3
4
  import {
@@ -9,11 +10,15 @@ import type {
9
10
  DispatchAdapter,
10
11
  DispatchOpts,
11
12
  ForkOpts,
13
+ GatewayLogger,
12
14
  RuntimeEvent,
13
15
  } from "@parall/agent-core";
14
16
  import type { ClaudeAgentConfig } from "./config.js";
15
- import { parseClaudeStreamJson } from "./output-parser.js";
16
- import { ClaudeSessionManager } from "./session-manager.js";
17
+ import { parseClaudeStreamJson, type ClaudeParsedEvent } from "./output-parser.js";
18
+ import {
19
+ ClaudeSessionManager,
20
+ type ClaudeProcessHandle,
21
+ } from "./session-manager.js";
17
22
 
18
23
  type ClaudeCodeAdapterOptions = Pick<
19
24
  ClaudeAgentConfig,
@@ -29,25 +34,26 @@ type ClaudeCodeAdapterOptions = Pick<
29
34
  | "workspaceDir"
30
35
  > & {
31
36
  sessionManager: ClaudeSessionManager;
37
+ apiUrl: string;
38
+ apiKey: string;
39
+ orgId: string;
40
+ contextFilePathForSession?: (sessionKey: string) => string;
41
+ /** @deprecated Use contextFilePathForSession. */
42
+ stepIdFilePathForSession?: (sessionKey: string) => string;
32
43
  };
33
44
 
34
45
  export function buildSpawnEnv(
35
46
  parentEnv: NodeJS.ProcessEnv,
36
47
  claudeHome: string,
37
48
  context: DispatchOpts["context"],
38
- opts: { allowApiKey: boolean },
49
+ opts: { allowApiKey: boolean; effortLevel?: string },
39
50
  ): NodeJS.ProcessEnv {
40
51
  const env: NodeJS.ProcessEnv = { ...parentEnv };
41
- // Default: behave like the hosted Claude container — OAuth via
42
- // ~/.claude/.credentials.json only. Inheriting the operator's
43
- // ANTHROPIC_API_KEY would silently divert billing to Anthropic API
44
- // pay-per-use instead of the connected Claude.ai subscription.
45
- // Opt back in with PRLL_CLAUDE_ALLOW_API_KEY=1.
46
52
  if (!opts.allowApiKey) {
47
53
  delete env.ANTHROPIC_API_KEY;
48
54
  delete env.ANTHROPIC_AUTH_TOKEN;
49
55
  }
50
- return {
56
+ const result: NodeJS.ProcessEnv = {
51
57
  ...env,
52
58
  HOME: claudeHome,
53
59
  PRLL_API_URL: context.apiUrl,
@@ -57,12 +63,53 @@ export function buildSpawnEnv(
57
63
  PRLL_CHAT_ID: context.chatId ?? "",
58
64
  PRLL_TRIGGER_MESSAGE_ID: context.triggerMessageId ?? "",
59
65
  PRLL_NO_REPLY: context.noReply ? "1" : "",
66
+ PRLL_CONTEXT_FILE: context.contextFilePath ?? "",
60
67
  PRLL_STEP_ID_FILE: context.stepIdFilePath ?? "",
61
68
  };
69
+ if (opts.effortLevel) {
70
+ result.CLAUDE_CODE_EFFORT_LEVEL = opts.effortLevel;
71
+ }
72
+ return result;
62
73
  }
63
74
 
75
+ /**
76
+ * Per-sessionKey long-lived process state. The process stays alive across
77
+ * dispatches; each dispatch writes one NDJSON user message to stdin and
78
+ * drains stdout until `turn_end`. Between dispatches the process is idle —
79
+ * gateway's `drainMainBuffer` serializes dispatch calls so no concurrent
80
+ * access to the same process occurs.
81
+ */
82
+ type ProcessState = {
83
+ handle: ClaudeProcessHandle;
84
+ parser: AsyncGenerator<ClaudeParsedEvent>;
85
+ done: boolean;
86
+ needsRestart: boolean;
87
+ };
88
+
64
89
  export class ClaudeCodeAdapter implements DispatchAdapter {
65
- constructor(private readonly opts: ClaudeCodeAdapterOptions) {}
90
+ private readonly processes = new Map<string, ProcessState>();
91
+ private shuttingDown = false;
92
+ private _model: string | undefined;
93
+ private _effortLevel: string | undefined;
94
+
95
+ constructor(private readonly opts: ClaudeCodeAdapterOptions) {
96
+ this._model = opts.model;
97
+ }
98
+
99
+ get currentModel(): string | undefined { return this._model; }
100
+ get currentEffort(): string | undefined { return this._effortLevel; }
101
+
102
+ updateConfig(config: { model?: string | null; effort?: string | null }): void {
103
+ const modelChanged = config.model !== undefined && config.model !== this._model;
104
+ const effortChanged = config.effort !== undefined && config.effort !== this._effortLevel;
105
+ if (modelChanged) this._model = config.model ?? undefined;
106
+ if (effortChanged) this._effortLevel = config.effort ?? undefined;
107
+ if (modelChanged || effortChanged) {
108
+ for (const [, state] of this.processes) {
109
+ state.needsRestart = true;
110
+ }
111
+ }
112
+ }
66
113
 
67
114
  async *dispatch({ event, bodyForAgent, sessionKey, context }: DispatchOpts): AsyncIterable<RuntimeEvent> {
68
115
  let promptBody = bodyForAgent;
@@ -81,107 +128,211 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
81
128
  }
82
129
 
83
130
  try {
84
- const args = this.buildArgs(sessionKey, promptBody);
85
- const env = buildSpawnEnv(process.env, this.opts.claudeHome, context, {
86
- allowApiKey: this.opts.allowApiKey,
87
- });
88
-
89
- context.log?.info(`claude-agent[${context.accountId}]: spawn ${this.opts.claudeBin} ${args.slice(0, -1).join(" ")}`);
131
+ yield* this.runTurn(sessionKey, promptBody, context.log);
132
+ } finally {
133
+ releasePreparedAttachments();
134
+ }
135
+ }
90
136
 
91
- const proc = spawn(this.opts.claudeBin, args, {
92
- cwd: this.opts.workspaceDir,
93
- env,
94
- stdio: ["ignore", "pipe", "pipe"],
95
- });
137
+ forkSession({ sessionKey }: ForkOpts) {
138
+ return this.opts.sessionManager.createForkSession(sessionKey);
139
+ }
96
140
 
97
- if (!proc.stdout || !proc.stderr) {
98
- throw new Error("Claude subprocess did not provide stdio pipes");
99
- }
141
+ cleanupFork({ fork }: CleanupForkOpts) {
142
+ const state = this.processes.get(fork.sessionKey);
143
+ if (state) {
144
+ this.killProcess(fork.sessionKey, state);
145
+ }
146
+ this.opts.sessionManager.cleanupFork(fork.sessionKey);
147
+ }
100
148
 
101
- const stderr: string[] = [];
102
- proc.stderr.on("data", (chunk) => {
103
- stderr.push(chunk.toString());
104
- });
149
+ async shutdown(): Promise<void> {
150
+ this.shuttingDown = true;
151
+ for (const [sessionKey, state] of this.processes) {
152
+ this.killProcess(sessionKey, state);
153
+ }
154
+ this.processes.clear();
155
+ await this.opts.sessionManager.shutdownAll();
156
+ }
105
157
 
106
- const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
107
- proc.once("error", reject);
108
- proc.once("close", (code, signal) => resolve({ code, signal }));
109
- });
158
+ private async *runTurn(
159
+ sessionKey: string,
160
+ promptBody: string,
161
+ log: GatewayLogger | undefined,
162
+ ): AsyncGenerator<RuntimeEvent> {
163
+ let state: ProcessState;
164
+ try {
165
+ state = this.ensureProcess(sessionKey, log);
166
+ } catch (err) {
167
+ yield { type: "error", message: `Claude spawn failed: ${String(err)}` };
168
+ return;
169
+ }
170
+ const groupKey = randomUUID();
171
+ let sawError = false;
110
172
 
111
- const groupKey = randomUUID();
112
- let sawError = false;
113
- let completed = false;
173
+ try {
174
+ this.writeUserMessage(state.handle, promptBody);
175
+ } catch (err) {
176
+ yield { type: "error", message: `Claude stdin write failed: ${String(err)}` };
177
+ this.killProcess(sessionKey, state);
178
+ return;
179
+ }
114
180
 
115
- try {
116
- for await (const streamEvent of parseClaudeStreamJson(proc.stdout)) {
117
- if (streamEvent.type === "session_id") {
118
- this.opts.sessionManager.recordSessionId(sessionKey, streamEvent.sessionId);
119
- continue;
120
- }
121
-
122
- if (streamEvent.type === "error") {
123
- sawError = true;
124
- yield streamEvent;
125
- continue;
126
- }
127
-
128
- if (streamEvent.type === "text") {
129
- // Runtime output contract (symmetric with OpenClaw channel): Claude's
130
- // plain text is never projected as a chat message. To reply, the agent
131
- // must explicitly run `parall messages send` / `dm` via Bash. Text
132
- // events are still recorded as suppressed session steps for audit.
133
- yield {
134
- ...streamEvent,
135
- project: false,
136
- groupKey,
137
- };
138
- continue;
139
- }
181
+ while (true) {
182
+ const next = await state.parser.next();
140
183
 
141
- yield {
142
- ...streamEvent,
143
- groupKey,
144
- };
145
- }
146
- completed = true;
147
- } finally {
148
- if (!completed && proc.exitCode === null && proc.signalCode === null) {
149
- proc.kill("SIGTERM");
150
- }
151
- const { code, signal } = await exitPromise;
152
- if ((code ?? 0) !== 0 && !sawError) {
153
- const detail = stderr.join("").trim();
184
+ if (next.done) {
185
+ state.done = true;
186
+ this.processes.delete(sessionKey);
187
+ if (!sawError) {
188
+ const detail = state.handle.stderrChunks.join("").trim();
189
+ const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null } as const));
154
190
  yield {
155
191
  type: "error",
156
- message: detail || `Claude exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`,
192
+ message: detail
193
+ || `Claude exited with code ${exit.code ?? "unknown"}${exit.signal ? ` (${exit.signal})` : ""}`,
157
194
  };
158
195
  }
196
+ return;
159
197
  }
160
- } finally {
161
- releasePreparedAttachments();
198
+
199
+ const parsed = next.value;
200
+
201
+ if (parsed.type === "session_id") {
202
+ this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
203
+ continue;
204
+ }
205
+
206
+ if (parsed.type === "turn_end") {
207
+ if (state.needsRestart) {
208
+ this.killProcess(sessionKey, state);
209
+ }
210
+ return;
211
+ }
212
+
213
+ if (parsed.type === "error") {
214
+ sawError = true;
215
+ yield parsed;
216
+ continue;
217
+ }
218
+
219
+ if (parsed.type === "text") {
220
+ yield { ...parsed, project: false, groupKey };
221
+ continue;
222
+ }
223
+
224
+ yield { ...parsed, groupKey };
162
225
  }
163
226
  }
164
227
 
165
- forkSession({ sessionKey }: ForkOpts) {
166
- return this.opts.sessionManager.createForkSession(sessionKey);
228
+ private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
229
+ if (this.shuttingDown) {
230
+ throw new Error("claude-agent: adapter shutting down, refusing new process");
231
+ }
232
+
233
+ const existing = this.processes.get(sessionKey);
234
+ if (existing && !existing.done) {
235
+ const { proc } = existing.handle;
236
+ if (existing.needsRestart) {
237
+ this.killProcess(sessionKey, existing);
238
+ } else if (proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed) {
239
+ return existing;
240
+ } else {
241
+ this.processes.delete(sessionKey);
242
+ }
243
+ }
244
+
245
+ const handle = this.spawnProcess(sessionKey, log);
246
+ const parser = parseClaudeStreamJson(handle.proc.stdout!);
247
+ const state: ProcessState = { handle, parser, done: false, needsRestart: false };
248
+ this.processes.set(sessionKey, state);
249
+ this.opts.sessionManager.registerProcess(sessionKey, handle);
250
+ return state;
167
251
  }
168
252
 
169
- cleanupFork({ fork }: CleanupForkOpts) {
170
- this.opts.sessionManager.cleanupFork(fork.sessionKey);
253
+ private spawnProcess(sessionKey: string, log: GatewayLogger | undefined): ClaudeProcessHandle {
254
+ const args = this.buildArgs(sessionKey);
255
+ const env = buildSpawnEnv(
256
+ process.env,
257
+ this.opts.claudeHome,
258
+ this.buildPlaceholderContext(sessionKey),
259
+ { allowApiKey: this.opts.allowApiKey, effortLevel: this._effortLevel },
260
+ );
261
+
262
+ log?.info(
263
+ `claude-agent: spawn long-lived ${this.opts.claudeBin} (session ${sessionKey}, model=${this._model || "default"}, effort=${this._effortLevel || "default"}, mode=${this.opts.permissionMode})`,
264
+ );
265
+
266
+ const proc = spawn(this.opts.claudeBin, args, {
267
+ cwd: this.opts.workspaceDir,
268
+ env,
269
+ stdio: ["pipe", "pipe", "pipe"],
270
+ });
271
+
272
+ if (!proc.stdout || !proc.stderr || !proc.stdin) {
273
+ throw new Error("Claude subprocess did not provide stdio pipes");
274
+ }
275
+
276
+ const stderrChunks: string[] = [];
277
+ proc.stderr.on("data", (chunk: Buffer) => {
278
+ stderrChunks.push(chunk.toString());
279
+ });
280
+ proc.stdin.on("error", () => {
281
+ // Absorb async EPIPE / ERR_STREAM_WRITE_AFTER_END when the child
282
+ // exits between our write check and the kernel delivering the data.
283
+ // The next parser.next() will see the stream end and surface an error
284
+ // RuntimeEvent through the normal turn-end path.
285
+ });
286
+
287
+ const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
288
+ proc.once("close", (code, signal) => resolve({ code, signal }));
289
+ proc.once("error", () => resolve({ code: null, signal: null }));
290
+ });
291
+
292
+ return { proc, exitPromise, stderrChunks };
293
+ }
294
+
295
+ private killProcess(sessionKey: string, state: ProcessState) {
296
+ state.done = true;
297
+ const current = this.processes.get(sessionKey);
298
+ if (current === state) {
299
+ this.processes.delete(sessionKey);
300
+ }
301
+ try {
302
+ state.handle.proc.stdin.end();
303
+ } catch { /* best-effort */ }
304
+ if (state.handle.proc.exitCode === null && state.handle.proc.signalCode === null) {
305
+ try {
306
+ state.handle.proc.kill("SIGTERM");
307
+ } catch { /* best-effort */ }
308
+ }
309
+ }
310
+
311
+ private writeUserMessage(handle: ClaudeProcessHandle, text: string) {
312
+ const payload = JSON.stringify({
313
+ type: "user",
314
+ message: {
315
+ role: "user",
316
+ content: [{ type: "text", text }],
317
+ },
318
+ });
319
+ handle.proc.stdin.write(`${payload}\n`);
171
320
  }
172
321
 
173
- private buildArgs(sessionKey: string, prompt: string): string[] {
322
+ private buildArgs(sessionKey: string): string[] {
174
323
  const args = [
175
- "--print",
324
+ "-p",
176
325
  "--verbose",
326
+ "--input-format",
327
+ "stream-json",
177
328
  "--output-format",
178
329
  "stream-json",
179
330
  "--permission-mode",
180
331
  this.opts.permissionMode,
181
332
  ];
182
333
 
183
- if (this.opts.model) {
184
- args.push("--model", this.opts.model);
334
+ if (this._model) {
335
+ args.push("--model", this._model);
185
336
  }
186
337
 
187
338
  if (this.opts.allowedTools.length > 0) {
@@ -192,6 +343,11 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
192
343
  args.push("--disallowedTools", this.opts.disallowedTools.join(","));
193
344
  }
194
345
 
346
+ args.push(
347
+ "--append-system-prompt-file",
348
+ path.join(this.opts.workspaceDir, ".parall", "system-prompt.md"),
349
+ );
350
+
195
351
  if (this.opts.appendSystemPrompt) {
196
352
  args.push("--append-system-prompt", this.opts.appendSystemPrompt);
197
353
  }
@@ -201,7 +357,25 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
201
357
  }
202
358
 
203
359
  args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
204
- args.push(prompt);
205
360
  return args;
206
361
  }
362
+
363
+ private buildPlaceholderContext(sessionKey: string): DispatchOpts["context"] {
364
+ return {
365
+ accountId: "",
366
+ apiUrl: this.opts.apiUrl,
367
+ apiKey: this.opts.apiKey,
368
+ orgId: this.opts.orgId,
369
+ agentUserId: "",
370
+ runtimeType: "",
371
+ runtimeKey: "",
372
+ sessionId: "",
373
+ chatId: "",
374
+ triggerMessageId: "",
375
+ noReply: false,
376
+ contextFilePath: this.opts.contextFilePathForSession?.(sessionKey) ?? "",
377
+ stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey) ?? "",
378
+ client: undefined as unknown as DispatchOpts["context"]["client"],
379
+ };
380
+ }
207
381
  }