@parall/claude-agent 1.25.0 → 1.26.1

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.25.0",
3
+ "version": "1.26.1",
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.25.0",
29
- "@parall/cli": "1.25.0",
30
- "@parall/sdk": "1.25.0"
28
+ "@parall/agent-core": "1.26.1",
29
+ "@parall/cli": "1.26.1",
30
+ "@parall/sdk": "1.26.1"
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,217 @@ 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
- });
131
+ yield* this.runTurn(sessionKey, promptBody, context.log);
132
+ } finally {
133
+ releasePreparedAttachments();
134
+ }
135
+ }
88
136
 
89
- context.log?.info(`claude-agent[${context.accountId}]: spawn ${this.opts.claudeBin} ${args.slice(0, -1).join(" ")}`);
137
+ getBranchPoint(_sessionKey: string): string | undefined {
138
+ // Claude Code does not expose a branch-point API; fork scope prefix
139
+ // provides the behavioral fallback for this runtime.
140
+ return undefined;
141
+ }
90
142
 
91
- const proc = spawn(this.opts.claudeBin, args, {
92
- cwd: this.opts.workspaceDir,
93
- env,
94
- stdio: ["ignore", "pipe", "pipe"],
95
- });
143
+ forkSession({ sessionKey }: ForkOpts) {
144
+ return this.opts.sessionManager.createForkSession(sessionKey);
145
+ }
96
146
 
97
- if (!proc.stdout || !proc.stderr) {
98
- throw new Error("Claude subprocess did not provide stdio pipes");
99
- }
147
+ cleanupFork({ fork }: CleanupForkOpts) {
148
+ const state = this.processes.get(fork.sessionKey);
149
+ if (state) {
150
+ this.killProcess(fork.sessionKey, state);
151
+ }
152
+ this.opts.sessionManager.cleanupFork(fork.sessionKey);
153
+ }
100
154
 
101
- const stderr: string[] = [];
102
- proc.stderr.on("data", (chunk) => {
103
- stderr.push(chunk.toString());
104
- });
155
+ async shutdown(): Promise<void> {
156
+ this.shuttingDown = true;
157
+ for (const [sessionKey, state] of this.processes) {
158
+ this.killProcess(sessionKey, state);
159
+ }
160
+ this.processes.clear();
161
+ await this.opts.sessionManager.shutdownAll();
162
+ }
105
163
 
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
- });
164
+ private async *runTurn(
165
+ sessionKey: string,
166
+ promptBody: string,
167
+ log: GatewayLogger | undefined,
168
+ ): AsyncGenerator<RuntimeEvent> {
169
+ let state: ProcessState;
170
+ try {
171
+ state = this.ensureProcess(sessionKey, log);
172
+ } catch (err) {
173
+ yield { type: "error", message: `Claude spawn failed: ${String(err)}` };
174
+ return;
175
+ }
176
+ const groupKey = randomUUID();
177
+ let sawError = false;
110
178
 
111
- const groupKey = randomUUID();
112
- let sawError = false;
113
- let completed = false;
179
+ try {
180
+ this.writeUserMessage(state.handle, promptBody);
181
+ } catch (err) {
182
+ yield { type: "error", message: `Claude stdin write failed: ${String(err)}` };
183
+ this.killProcess(sessionKey, state);
184
+ return;
185
+ }
114
186
 
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
- }
187
+ while (true) {
188
+ const next = await state.parser.next();
140
189
 
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();
190
+ if (next.done) {
191
+ state.done = true;
192
+ this.processes.delete(sessionKey);
193
+ if (!sawError) {
194
+ const detail = state.handle.stderrChunks.join("").trim();
195
+ const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null } as const));
154
196
  yield {
155
197
  type: "error",
156
- message: detail || `Claude exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`,
198
+ message: detail
199
+ || `Claude exited with code ${exit.code ?? "unknown"}${exit.signal ? ` (${exit.signal})` : ""}`,
157
200
  };
158
201
  }
202
+ return;
159
203
  }
160
- } finally {
161
- releasePreparedAttachments();
204
+
205
+ const parsed = next.value;
206
+
207
+ if (parsed.type === "session_id") {
208
+ this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
209
+ continue;
210
+ }
211
+
212
+ if (parsed.type === "turn_end") {
213
+ if (state.needsRestart) {
214
+ this.killProcess(sessionKey, state);
215
+ }
216
+ return;
217
+ }
218
+
219
+ if (parsed.type === "error") {
220
+ sawError = true;
221
+ yield parsed;
222
+ continue;
223
+ }
224
+
225
+ if (parsed.type === "text") {
226
+ yield { ...parsed, project: false, groupKey };
227
+ continue;
228
+ }
229
+
230
+ yield { ...parsed, groupKey };
162
231
  }
163
232
  }
164
233
 
165
- forkSession({ sessionKey }: ForkOpts) {
166
- return this.opts.sessionManager.createForkSession(sessionKey);
234
+ private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
235
+ if (this.shuttingDown) {
236
+ throw new Error("claude-agent: adapter shutting down, refusing new process");
237
+ }
238
+
239
+ const existing = this.processes.get(sessionKey);
240
+ if (existing && !existing.done) {
241
+ const { proc } = existing.handle;
242
+ if (existing.needsRestart) {
243
+ this.killProcess(sessionKey, existing);
244
+ } else if (proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed) {
245
+ return existing;
246
+ } else {
247
+ this.processes.delete(sessionKey);
248
+ }
249
+ }
250
+
251
+ const handle = this.spawnProcess(sessionKey, log);
252
+ const parser = parseClaudeStreamJson(handle.proc.stdout!);
253
+ const state: ProcessState = { handle, parser, done: false, needsRestart: false };
254
+ this.processes.set(sessionKey, state);
255
+ this.opts.sessionManager.registerProcess(sessionKey, handle);
256
+ return state;
167
257
  }
168
258
 
169
- cleanupFork({ fork }: CleanupForkOpts) {
170
- this.opts.sessionManager.cleanupFork(fork.sessionKey);
259
+ private spawnProcess(sessionKey: string, log: GatewayLogger | undefined): ClaudeProcessHandle {
260
+ const args = this.buildArgs(sessionKey);
261
+ const env = buildSpawnEnv(
262
+ process.env,
263
+ this.opts.claudeHome,
264
+ this.buildPlaceholderContext(sessionKey),
265
+ { allowApiKey: this.opts.allowApiKey, effortLevel: this._effortLevel },
266
+ );
267
+
268
+ log?.info(
269
+ `claude-agent: spawn long-lived ${this.opts.claudeBin} (session ${sessionKey}, model=${this._model || "default"}, effort=${this._effortLevel || "default"}, mode=${this.opts.permissionMode})`,
270
+ );
271
+
272
+ const proc = spawn(this.opts.claudeBin, args, {
273
+ cwd: this.opts.workspaceDir,
274
+ env,
275
+ stdio: ["pipe", "pipe", "pipe"],
276
+ });
277
+
278
+ if (!proc.stdout || !proc.stderr || !proc.stdin) {
279
+ throw new Error("Claude subprocess did not provide stdio pipes");
280
+ }
281
+
282
+ const stderrChunks: string[] = [];
283
+ proc.stderr.on("data", (chunk: Buffer) => {
284
+ stderrChunks.push(chunk.toString());
285
+ });
286
+ proc.stdin.on("error", () => {
287
+ // Absorb async EPIPE / ERR_STREAM_WRITE_AFTER_END when the child
288
+ // exits between our write check and the kernel delivering the data.
289
+ // The next parser.next() will see the stream end and surface an error
290
+ // RuntimeEvent through the normal turn-end path.
291
+ });
292
+
293
+ const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
294
+ proc.once("close", (code, signal) => resolve({ code, signal }));
295
+ proc.once("error", () => resolve({ code: null, signal: null }));
296
+ });
297
+
298
+ return { proc, exitPromise, stderrChunks };
299
+ }
300
+
301
+ private killProcess(sessionKey: string, state: ProcessState) {
302
+ state.done = true;
303
+ const current = this.processes.get(sessionKey);
304
+ if (current === state) {
305
+ this.processes.delete(sessionKey);
306
+ }
307
+ try {
308
+ state.handle.proc.stdin.end();
309
+ } catch { /* best-effort */ }
310
+ if (state.handle.proc.exitCode === null && state.handle.proc.signalCode === null) {
311
+ try {
312
+ state.handle.proc.kill("SIGTERM");
313
+ } catch { /* best-effort */ }
314
+ }
315
+ }
316
+
317
+ private writeUserMessage(handle: ClaudeProcessHandle, text: string) {
318
+ const payload = JSON.stringify({
319
+ type: "user",
320
+ message: {
321
+ role: "user",
322
+ content: [{ type: "text", text }],
323
+ },
324
+ });
325
+ handle.proc.stdin.write(`${payload}\n`);
171
326
  }
172
327
 
173
- private buildArgs(sessionKey: string, prompt: string): string[] {
328
+ private buildArgs(sessionKey: string): string[] {
174
329
  const args = [
175
- "--print",
330
+ "-p",
176
331
  "--verbose",
332
+ "--input-format",
333
+ "stream-json",
177
334
  "--output-format",
178
335
  "stream-json",
179
336
  "--permission-mode",
180
337
  this.opts.permissionMode,
181
338
  ];
182
339
 
183
- if (this.opts.model) {
184
- args.push("--model", this.opts.model);
340
+ if (this._model) {
341
+ args.push("--model", this._model);
185
342
  }
186
343
 
187
344
  if (this.opts.allowedTools.length > 0) {
@@ -192,6 +349,11 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
192
349
  args.push("--disallowedTools", this.opts.disallowedTools.join(","));
193
350
  }
194
351
 
352
+ args.push(
353
+ "--append-system-prompt-file",
354
+ path.join(this.opts.workspaceDir, ".parall", "system-prompt.md"),
355
+ );
356
+
195
357
  if (this.opts.appendSystemPrompt) {
196
358
  args.push("--append-system-prompt", this.opts.appendSystemPrompt);
197
359
  }
@@ -201,7 +363,25 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
201
363
  }
202
364
 
203
365
  args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
204
- args.push(prompt);
205
366
  return args;
206
367
  }
368
+
369
+ private buildPlaceholderContext(sessionKey: string): DispatchOpts["context"] {
370
+ return {
371
+ accountId: "",
372
+ apiUrl: this.opts.apiUrl,
373
+ apiKey: this.opts.apiKey,
374
+ orgId: this.opts.orgId,
375
+ agentUserId: "",
376
+ runtimeType: "",
377
+ runtimeKey: "",
378
+ sessionId: "",
379
+ chatId: "",
380
+ triggerMessageId: "",
381
+ noReply: false,
382
+ contextFilePath: this.opts.contextFilePathForSession?.(sessionKey) ?? "",
383
+ stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey) ?? "",
384
+ client: undefined as unknown as DispatchOpts["context"]["client"],
385
+ };
386
+ }
207
387
  }