@parall/claude-agent 1.28.0 → 1.29.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.
@@ -17,6 +17,7 @@ export declare function buildSpawnEnv(parentEnv: NodeJS.ProcessEnv, claudeHome:
17
17
  export declare class ClaudeCodeAdapter implements DispatchAdapter {
18
18
  private readonly opts;
19
19
  private readonly processes;
20
+ private readonly pendingInjections;
20
21
  private shuttingDown;
21
22
  private _model;
22
23
  private _effortLevel;
@@ -27,12 +28,26 @@ export declare class ClaudeCodeAdapter implements DispatchAdapter {
27
28
  model?: string | null;
28
29
  effort?: string | null;
29
30
  }): void;
31
+ enqueueDuringDispatch(sessionKey: string, body: string): boolean;
32
+ hasPendingInjections(sessionKey: string): boolean;
30
33
  dispatch({ event, bodyForAgent, sessionKey, context }: DispatchOpts): AsyncIterable<RuntimeEvent>;
31
34
  getBranchPoint(_sessionKey: string): string | undefined;
32
35
  getSessionHistoryPath(sessionKey: string): string | undefined;
33
36
  forkSession({ sessionKey }: ForkOpts): import("@parall/agent-core").ForkSessionHandle | null;
34
37
  cleanupFork({ fork }: CleanupForkOpts): void;
35
38
  shutdown(): Promise<void>;
39
+ private static readonly STEER_TURN_TIMEOUT_MS;
40
+ /**
41
+ * Consume a steer turn whose message was already written to stdin via
42
+ * enqueueDuringDispatch. Skip the stdin write — only read parser output.
43
+ * If no output arrives within STEER_TURN_TIMEOUT_MS, the steer was
44
+ * incorporated into the previous turn and there is nothing to consume.
45
+ *
46
+ * On timeout, the losing parser.next() promise is saved to
47
+ * state.steerReadPending so the next runTurn can drain it instead of
48
+ * silently losing the first event of the subsequent turn.
49
+ */
50
+ private consumeSteerTurn;
36
51
  private runTurn;
37
52
  private ensureProcess;
38
53
  private spawnProcess;
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EAER,YAAY,EACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD,OAAO,EACL,oBAAoB,EAErB,MAAM,sBAAsB,CAAC;AAE9B,KAAK,wBAAwB,GAAG,IAAI,CAClC,iBAAiB,EACf,gBAAgB,GAChB,aAAa,GACb,cAAc,GACd,oBAAoB,GACpB,WAAW,GACX,YAAY,GACZ,iBAAiB,GACjB,OAAO,GACP,gBAAgB,GAChB,cAAc,CACjB,GAAG;IACF,cAAc,EAAE,oBAAoB,CAAC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3D,iDAAiD;IACjD,wBAAwB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;CAC3D,CAAC;AAEF,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,CAAC,UAAU,EAC5B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,YAAY,CAAC,SAAS,CAAC,EAChC,IAAI,EAAE;IAAE,WAAW,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD,MAAM,CAAC,UAAU,CAuBnB;AAgBD,qBAAa,iBAAkB,YAAW,eAAe;IAM3C,OAAO,CAAC,QAAQ,CAAC,IAAI;IALjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmC;IAC7D,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,YAAY,CAAqB;gBAEZ,IAAI,EAAE,wBAAwB;IAI3D,IAAI,YAAY,IAAI,MAAM,GAAG,SAAS,CAAwB;IAC9D,IAAI,aAAa,IAAI,MAAM,GAAG,SAAS,CAA8B;IAErE,YAAY,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAYtE,QAAQ,CAAC,EAAE,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,OAAO,EAAE,EAAE,YAAY,GAAG,aAAa,CAAC,YAAY,CAAC;IAuBxG,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIvD,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAQ7D,WAAW,CAAC,EAAE,UAAU,EAAE,EAAE,QAAQ;IAIpC,WAAW,CAAC,EAAE,IAAI,EAAE,EAAE,eAAe;IAQ/B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;YAShB,OAAO;IAgFtB,OAAO,CAAC,aAAa;IAyBrB,OAAO,CAAC,YAAY;IA8CpB,OAAO,CAAC,WAAW;IAgBnB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,SAAS;IAyCjB,OAAO,CAAC,uBAAuB;CAkBhC"}
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EAER,YAAY,EACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD,OAAO,EACL,oBAAoB,EAErB,MAAM,sBAAsB,CAAC;AAE9B,KAAK,wBAAwB,GAAG,IAAI,CAClC,iBAAiB,EACf,gBAAgB,GAChB,aAAa,GACb,cAAc,GACd,oBAAoB,GACpB,WAAW,GACX,YAAY,GACZ,iBAAiB,GACjB,OAAO,GACP,gBAAgB,GAChB,cAAc,CACjB,GAAG;IACF,cAAc,EAAE,oBAAoB,CAAC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3D,iDAAiD;IACjD,wBAAwB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;CAC3D,CAAC;AAgBF,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,CAAC,UAAU,EAC5B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,YAAY,CAAC,SAAS,CAAC,EAChC,IAAI,EAAE;IAAE,WAAW,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD,MAAM,CAAC,UAAU,CAuBnB;AAiBD,qBAAa,iBAAkB,YAAW,eAAe;IAO3C,OAAO,CAAC,QAAQ,CAAC,IAAI;IANjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmC;IAC7D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA6B;IAC/D,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,YAAY,CAAqB;gBAEZ,IAAI,EAAE,wBAAwB;IAI3D,IAAI,YAAY,IAAI,MAAM,GAAG,SAAS,CAAwB;IAC9D,IAAI,aAAa,IAAI,MAAM,GAAG,SAAS,CAA8B;IAErE,YAAY,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAY7E,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAchE,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAI1C,QAAQ,CAAC,EAAE,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,OAAO,EAAE,EAAE,YAAY,GAAG,aAAa,CAAC,YAAY,CAAC;IAiCxG,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIvD,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAQ7D,WAAW,CAAC,EAAE,UAAU,EAAE,EAAE,QAAQ;IAIpC,WAAW,CAAC,EAAE,IAAI,EAAE,EAAE,eAAe;IAQ/B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAS/B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAU;IAEvD;;;;;;;;;OASG;YACY,gBAAgB;YAsDhB,OAAO;IA2GtB,OAAO,CAAC,aAAa;IAyBrB,OAAO,CAAC,YAAY;IA+CpB,OAAO,CAAC,WAAW;IAkBnB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,SAAS;IAwCjB,OAAO,CAAC,uBAAuB;CAkBhC"}
package/dist/dispatch.js CHANGED
@@ -1,9 +1,24 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { spawn } from "node:child_process";
4
+ import { execSync, spawn } from "node:child_process";
5
5
  import { appendPreparedLocalAttachmentRefs, pinLocalAttachmentPaths, } from "@parall/agent-core/internal/attachment-input";
6
6
  import { parseClaudeStreamJson } from "./output-parser.js";
7
+ const IS_WIN32 = process.platform === "win32";
8
+ function quoteWin32Arg(arg) {
9
+ if (!/[\s"&|^<>()]/.test(arg))
10
+ return arg;
11
+ return `"${arg.replace(/"/g, '""')}"`;
12
+ }
13
+ function killWin32Tree(pid) {
14
+ try {
15
+ execSync(`taskkill /T /F /PID ${pid}`, { windowsHide: true, stdio: "ignore" });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
7
22
  export function buildSpawnEnv(parentEnv, claudeHome, context, opts) {
8
23
  const env = { ...parentEnv };
9
24
  if (!opts.allowApiKey) {
@@ -31,6 +46,7 @@ export function buildSpawnEnv(parentEnv, claudeHome, context, opts) {
31
46
  export class ClaudeCodeAdapter {
32
47
  opts;
33
48
  processes = new Map();
49
+ pendingInjections = new Map();
34
50
  shuttingDown = false;
35
51
  _model;
36
52
  _effortLevel;
@@ -53,7 +69,35 @@ export class ClaudeCodeAdapter {
53
69
  }
54
70
  }
55
71
  }
72
+ enqueueDuringDispatch(sessionKey, body) {
73
+ const state = this.processes.get(sessionKey);
74
+ if (!state || state.done)
75
+ return false;
76
+ const { proc } = state.handle;
77
+ if (proc.exitCode !== null || proc.signalCode !== null || proc.stdin.destroyed)
78
+ return false;
79
+ try {
80
+ this.writeUserMessage(state.handle, body);
81
+ this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
82
+ return true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ hasPendingInjections(sessionKey) {
89
+ return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
90
+ }
56
91
  async *dispatch({ event, bodyForAgent, sessionKey, context }) {
92
+ const pending = this.pendingInjections.get(sessionKey) ?? 0;
93
+ if (pending > 0) {
94
+ this.pendingInjections.delete(sessionKey);
95
+ context.log?.info?.(`claude-agent[${context.accountId}]: consuming ${pending} steer turn(s)`);
96
+ for (let i = 0; i < pending; i++) {
97
+ yield* this.consumeSteerTurn(sessionKey, context.log);
98
+ }
99
+ return;
100
+ }
57
101
  let promptBody = bodyForAgent;
58
102
  let releasePreparedAttachments = () => { };
59
103
  try {
@@ -103,6 +147,66 @@ export class ClaudeCodeAdapter {
103
147
  this.processes.clear();
104
148
  await this.opts.sessionManager.shutdownAll();
105
149
  }
150
+ static STEER_TURN_TIMEOUT_MS = 10_000;
151
+ /**
152
+ * Consume a steer turn whose message was already written to stdin via
153
+ * enqueueDuringDispatch. Skip the stdin write — only read parser output.
154
+ * If no output arrives within STEER_TURN_TIMEOUT_MS, the steer was
155
+ * incorporated into the previous turn and there is nothing to consume.
156
+ *
157
+ * On timeout, the losing parser.next() promise is saved to
158
+ * state.steerReadPending so the next runTurn can drain it instead of
159
+ * silently losing the first event of the subsequent turn.
160
+ */
161
+ async *consumeSteerTurn(sessionKey, log) {
162
+ const state = this.processes.get(sessionKey);
163
+ if (!state || state.done) {
164
+ throw new Error("claude-agent: process dead during steer consumption");
165
+ }
166
+ const groupKey = randomUUID();
167
+ const parserNext = state.parser.next();
168
+ const firstRead = await Promise.race([
169
+ parserNext.then((r) => ({ kind: "value", result: r })),
170
+ new Promise((resolve) => setTimeout(() => resolve({ kind: "timeout" }), ClaudeCodeAdapter.STEER_TURN_TIMEOUT_MS)),
171
+ ]);
172
+ if (firstRead.kind === "timeout") {
173
+ state.steerReadPending = parserNext;
174
+ log?.info?.(`claude-agent: steer turn timeout — steer was incorporated into previous turn`);
175
+ return;
176
+ }
177
+ let next = firstRead.result;
178
+ // eslint-disable-next-line no-constant-condition
179
+ while (true) {
180
+ if (next.done) {
181
+ state.done = true;
182
+ this.processes.delete(sessionKey);
183
+ throw new Error("claude-agent: process exited while consuming steer turn");
184
+ }
185
+ const parsed = next.value;
186
+ if (parsed.type === "session_id") {
187
+ this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
188
+ yield { type: "runtime_session", runtimeSessionId: parsed.sessionId, runtimeLaneKey: sessionKey };
189
+ }
190
+ else if (parsed.type === "turn_end") {
191
+ if (state.needsRestart)
192
+ this.killProcess(sessionKey, state);
193
+ return;
194
+ }
195
+ else if (parsed.type === "error") {
196
+ yield parsed;
197
+ }
198
+ else if (parsed.type === "text") {
199
+ yield { ...parsed, project: false, groupKey };
200
+ }
201
+ else if (parsed.type === "runtime_session") {
202
+ yield parsed;
203
+ }
204
+ else {
205
+ yield { ...parsed, groupKey };
206
+ }
207
+ next = await state.parser.next();
208
+ }
209
+ }
106
210
  async *runTurn(sessionKey, promptBody, log) {
107
211
  let state;
108
212
  try {
@@ -122,14 +226,46 @@ export class ClaudeCodeAdapter {
122
226
  this.killProcess(sessionKey, state);
123
227
  return;
124
228
  }
229
+ if (state.steerReadPending) {
230
+ const orphaned = await state.steerReadPending;
231
+ state.steerReadPending = undefined;
232
+ if (!orphaned.done) {
233
+ const parsed = orphaned.value;
234
+ if (parsed.type === "session_id") {
235
+ this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
236
+ yield { type: "runtime_session", runtimeSessionId: parsed.sessionId, runtimeLaneKey: sessionKey };
237
+ }
238
+ else if (parsed.type === "turn_end") {
239
+ if (state.needsRestart)
240
+ this.killProcess(sessionKey, state);
241
+ return;
242
+ }
243
+ else if (parsed.type === "error") {
244
+ sawError = true;
245
+ yield parsed;
246
+ }
247
+ else if (parsed.type === "text") {
248
+ yield { ...parsed, project: false, groupKey };
249
+ }
250
+ else if (parsed.type === "runtime_session") {
251
+ yield parsed;
252
+ }
253
+ else {
254
+ yield { ...parsed, groupKey };
255
+ }
256
+ }
257
+ }
125
258
  while (true) {
126
259
  const next = await state.parser.next();
127
260
  if (next.done) {
128
261
  state.done = true;
129
262
  this.processes.delete(sessionKey);
263
+ const detail = state.handle.stderrChunks.join("").trim();
264
+ const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null }));
265
+ if (detail) {
266
+ log?.warn?.(`claude-agent: subprocess stderr: ${detail}`);
267
+ }
130
268
  if (!sawError) {
131
- const detail = state.handle.stderrChunks.join("").trim();
132
- const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null }));
133
269
  yield {
134
270
  type: "error",
135
271
  message: detail
@@ -198,10 +334,11 @@ export class ClaudeCodeAdapter {
198
334
  const args = this.buildArgs(sessionKey);
199
335
  const env = buildSpawnEnv(process.env, this.opts.claudeHome, this.buildPlaceholderContext(sessionKey), { allowApiKey: this.opts.allowApiKey, effortLevel: this._effortLevel });
200
336
  log?.info(`claude-agent: spawn long-lived ${this.opts.claudeBin} (session ${sessionKey}, model=${this._model || "default"}, effort=${this._effortLevel || "default"}, mode=${this.opts.permissionMode})`);
201
- const proc = spawn(this.opts.claudeBin, args, {
337
+ const proc = spawn(IS_WIN32 ? quoteWin32Arg(this.opts.claudeBin) : this.opts.claudeBin, IS_WIN32 ? args.map(quoteWin32Arg) : args, {
202
338
  cwd: this.opts.workspaceDir,
203
339
  env,
204
340
  stdio: ["pipe", "pipe", "pipe"],
341
+ shell: IS_WIN32,
205
342
  });
206
343
  if (!proc.stdout || !proc.stderr || !proc.stdin) {
207
344
  throw new Error("Claude subprocess did not provide stdio pipes");
@@ -211,13 +348,11 @@ export class ClaudeCodeAdapter {
211
348
  stderrChunks.push(chunk.toString());
212
349
  });
213
350
  proc.stdin.on("error", () => {
214
- // stdin pipe broken: kill the process so parser.next() sees EOF
215
- // instead of hanging forever on a process that's alive but can't
216
- // accept input. If the process was already exiting (the common
217
- // EPIPE-on-close race), kill is a harmless no-op.
218
351
  proc.stdin.destroy();
219
352
  if (proc.exitCode === null && proc.signalCode === null) {
220
- proc.kill("SIGTERM");
353
+ if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
354
+ proc.kill("SIGTERM");
355
+ }
221
356
  }
222
357
  });
223
358
  const exitPromise = new Promise((resolve) => {
@@ -238,7 +373,9 @@ export class ClaudeCodeAdapter {
238
373
  catch { /* best-effort */ }
239
374
  if (state.handle.proc.exitCode === null && state.handle.proc.signalCode === null) {
240
375
  try {
241
- state.handle.proc.kill("SIGTERM");
376
+ if (!IS_WIN32 || !state.handle.proc.pid || !killWin32Tree(state.handle.proc.pid)) {
377
+ state.handle.proc.kill("SIGTERM");
378
+ }
242
379
  }
243
380
  catch { /* best-effort */ }
244
381
  }
@@ -255,7 +392,6 @@ export class ClaudeCodeAdapter {
255
392
  }
256
393
  buildArgs(sessionKey) {
257
394
  const args = [
258
- "-p",
259
395
  "--verbose",
260
396
  "--input-format",
261
397
  "stream-json",
package/dist/index.js CHANGED
@@ -1,18 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import * as os from "node:os";
3
- import { ParallAgentGateway, createPlatformConfigManager, isPlatformManagedProfile, parseShutdownDeadlineMs } from "@parall/agent-core";
3
+ import { ParallAgentGateway, createPlatformConfigManager, createLogger, isPlatformManagedProfile, parseShutdownDeadlineMs } from "@parall/agent-core";
4
4
  import { ApiError, ParallClient, ParallWs } from "@parall/sdk";
5
5
  import { buildClaudeRuntimeKey, contextFilePathForSession, resolveClaudeAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from "./config.js";
6
6
  import { ClaudeCodeAdapter } from "./dispatch.js";
7
7
  import { ClaudeSessionManager } from "./session-manager.js";
8
8
  import { ensureClaudeWorkspace } from "./workspace.js";
9
- function createLogger(prefix) {
10
- return {
11
- info: (msg) => console.log(`[${prefix}] ${msg}`),
12
- warn: (msg) => console.warn(`[${prefix}] ${msg}`),
13
- error: (msg) => console.error(`[${prefix}] ${msg}`),
14
- };
15
- }
9
+ const log = createLogger("claude-agent");
16
10
  async function getAgentMeWithLegacyFallback(client, orgId) {
17
11
  try {
18
12
  return await client.getAgentMe(orgId);
@@ -27,7 +21,6 @@ async function getAgentMeWithLegacyFallback(client, orgId) {
27
21
  }
28
22
  async function main() {
29
23
  const config = resolveClaudeAgentConfig(process.env);
30
- const log = createLogger("claude-agent");
31
24
  if (config.allowApiKey &&
32
25
  (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN)) {
33
26
  // Surface the billing path: default is OAuth (Claude.ai subscription); this
@@ -154,6 +147,6 @@ async function main() {
154
147
  }
155
148
  }
156
149
  main().catch((err) => {
157
- console.error(`[claude-agent] fatal: ${String(err)}`);
150
+ log.error(`fatal: ${String(err)}`);
158
151
  process.exitCode = 1;
159
152
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/claude-agent",
3
- "version": "1.28.0",
3
+ "version": "1.29.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.28.0",
29
- "@parall/cli": "1.28.0",
30
- "@parall/sdk": "1.28.0"
28
+ "@parall/agent-core": "1.29.0",
29
+ "@parall/cli": "1.29.0",
30
+ "@parall/sdk": "1.29.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",
package/src/dispatch.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { spawn } from "node:child_process";
4
+ import { execSync, spawn } from "node:child_process";
5
5
  import {
6
6
  appendPreparedLocalAttachmentRefs,
7
7
  pinLocalAttachmentPaths,
@@ -43,6 +43,20 @@ type ClaudeCodeAdapterOptions = Pick<
43
43
  stepIdFilePathForSession?: (sessionKey: string) => string;
44
44
  };
45
45
 
46
+ const IS_WIN32 = process.platform === "win32";
47
+
48
+ function quoteWin32Arg(arg: string): string {
49
+ if (!/[\s"&|^<>()]/.test(arg)) return arg;
50
+ return `"${arg.replace(/"/g, '""')}"`;
51
+ }
52
+
53
+ function killWin32Tree(pid: number): boolean {
54
+ try {
55
+ execSync(`taskkill /T /F /PID ${pid}`, { windowsHide: true, stdio: "ignore" });
56
+ return true;
57
+ } catch { return false; }
58
+ }
59
+
46
60
  export function buildSpawnEnv(
47
61
  parentEnv: NodeJS.ProcessEnv,
48
62
  claudeHome: string,
@@ -85,10 +99,12 @@ type ProcessState = {
85
99
  parser: AsyncGenerator<ClaudeParsedEvent>;
86
100
  done: boolean;
87
101
  needsRestart: boolean;
102
+ steerReadPending?: Promise<IteratorResult<ClaudeParsedEvent>>;
88
103
  };
89
104
 
90
105
  export class ClaudeCodeAdapter implements DispatchAdapter {
91
106
  private readonly processes = new Map<string, ProcessState>();
107
+ private readonly pendingInjections = new Map<string, number>();
92
108
  private shuttingDown = false;
93
109
  private _model: string | undefined;
94
110
  private _effortLevel: string | undefined;
@@ -112,7 +128,35 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
112
128
  }
113
129
  }
114
130
 
131
+ enqueueDuringDispatch(sessionKey: string, body: string): boolean {
132
+ const state = this.processes.get(sessionKey);
133
+ if (!state || state.done) return false;
134
+ const { proc } = state.handle;
135
+ if (proc.exitCode !== null || proc.signalCode !== null || proc.stdin.destroyed) return false;
136
+ try {
137
+ this.writeUserMessage(state.handle, body);
138
+ this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ hasPendingInjections(sessionKey: string): boolean {
146
+ return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
147
+ }
148
+
115
149
  async *dispatch({ event, bodyForAgent, sessionKey, context }: DispatchOpts): AsyncIterable<RuntimeEvent> {
150
+ const pending = this.pendingInjections.get(sessionKey) ?? 0;
151
+ if (pending > 0) {
152
+ this.pendingInjections.delete(sessionKey);
153
+ context.log?.info?.(`claude-agent[${context.accountId}]: consuming ${pending} steer turn(s)`);
154
+ for (let i = 0; i < pending; i++) {
155
+ yield* this.consumeSteerTurn(sessionKey, context.log);
156
+ }
157
+ return;
158
+ }
159
+
116
160
  let promptBody = bodyForAgent;
117
161
  let releasePreparedAttachments = () => {};
118
162
  try {
@@ -168,6 +212,72 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
168
212
  await this.opts.sessionManager.shutdownAll();
169
213
  }
170
214
 
215
+ private static readonly STEER_TURN_TIMEOUT_MS = 10_000;
216
+
217
+ /**
218
+ * Consume a steer turn whose message was already written to stdin via
219
+ * enqueueDuringDispatch. Skip the stdin write — only read parser output.
220
+ * If no output arrives within STEER_TURN_TIMEOUT_MS, the steer was
221
+ * incorporated into the previous turn and there is nothing to consume.
222
+ *
223
+ * On timeout, the losing parser.next() promise is saved to
224
+ * state.steerReadPending so the next runTurn can drain it instead of
225
+ * silently losing the first event of the subsequent turn.
226
+ */
227
+ private async *consumeSteerTurn(
228
+ sessionKey: string,
229
+ log: GatewayLogger | undefined,
230
+ ): AsyncGenerator<RuntimeEvent> {
231
+ const state = this.processes.get(sessionKey);
232
+ if (!state || state.done) {
233
+ throw new Error("claude-agent: process dead during steer consumption");
234
+ }
235
+
236
+ const groupKey = randomUUID();
237
+
238
+ const parserNext = state.parser.next();
239
+ const firstRead = await Promise.race([
240
+ parserNext.then((r) => ({ kind: "value" as const, result: r })),
241
+ new Promise<{ kind: "timeout" }>((resolve) =>
242
+ setTimeout(() => resolve({ kind: "timeout" }), ClaudeCodeAdapter.STEER_TURN_TIMEOUT_MS),
243
+ ),
244
+ ]);
245
+
246
+ if (firstRead.kind === "timeout") {
247
+ state.steerReadPending = parserNext;
248
+ log?.info?.(`claude-agent: steer turn timeout — steer was incorporated into previous turn`);
249
+ return;
250
+ }
251
+
252
+ let next = firstRead.result;
253
+ // eslint-disable-next-line no-constant-condition
254
+ while (true) {
255
+ if (next.done) {
256
+ state.done = true;
257
+ this.processes.delete(sessionKey);
258
+ throw new Error("claude-agent: process exited while consuming steer turn");
259
+ }
260
+ const parsed = next.value;
261
+
262
+ if (parsed.type === "session_id") {
263
+ this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
264
+ yield { type: "runtime_session", runtimeSessionId: parsed.sessionId, runtimeLaneKey: sessionKey };
265
+ } else if (parsed.type === "turn_end") {
266
+ if (state.needsRestart) this.killProcess(sessionKey, state);
267
+ return;
268
+ } else if (parsed.type === "error") {
269
+ yield parsed;
270
+ } else if (parsed.type === "text") {
271
+ yield { ...parsed, project: false, groupKey };
272
+ } else if (parsed.type === "runtime_session") {
273
+ yield parsed;
274
+ } else {
275
+ yield { ...parsed, groupKey };
276
+ }
277
+ next = await state.parser.next();
278
+ }
279
+ }
280
+
171
281
  private async *runTurn(
172
282
  sessionKey: string,
173
283
  promptBody: string,
@@ -191,15 +301,42 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
191
301
  return;
192
302
  }
193
303
 
304
+ if (state.steerReadPending) {
305
+ const orphaned = await state.steerReadPending;
306
+ state.steerReadPending = undefined;
307
+ if (!orphaned.done) {
308
+ const parsed = orphaned.value;
309
+ if (parsed.type === "session_id") {
310
+ this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
311
+ yield { type: "runtime_session", runtimeSessionId: parsed.sessionId, runtimeLaneKey: sessionKey };
312
+ } else if (parsed.type === "turn_end") {
313
+ if (state.needsRestart) this.killProcess(sessionKey, state);
314
+ return;
315
+ } else if (parsed.type === "error") {
316
+ sawError = true;
317
+ yield parsed;
318
+ } else if (parsed.type === "text") {
319
+ yield { ...parsed, project: false, groupKey };
320
+ } else if (parsed.type === "runtime_session") {
321
+ yield parsed;
322
+ } else {
323
+ yield { ...parsed, groupKey };
324
+ }
325
+ }
326
+ }
327
+
194
328
  while (true) {
195
329
  const next = await state.parser.next();
196
330
 
197
331
  if (next.done) {
198
332
  state.done = true;
199
333
  this.processes.delete(sessionKey);
334
+ const detail = state.handle.stderrChunks.join("").trim();
335
+ const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null } as const));
336
+ if (detail) {
337
+ log?.warn?.(`claude-agent: subprocess stderr: ${detail}`);
338
+ }
200
339
  if (!sawError) {
201
- const detail = state.handle.stderrChunks.join("").trim();
202
- const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null } as const));
203
340
  yield {
204
341
  type: "error",
205
342
  message: detail
@@ -286,10 +423,13 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
286
423
  `claude-agent: spawn long-lived ${this.opts.claudeBin} (session ${sessionKey}, model=${this._model || "default"}, effort=${this._effortLevel || "default"}, mode=${this.opts.permissionMode})`,
287
424
  );
288
425
 
289
- const proc = spawn(this.opts.claudeBin, args, {
426
+ const proc = spawn(
427
+ IS_WIN32 ? quoteWin32Arg(this.opts.claudeBin) : this.opts.claudeBin,
428
+ IS_WIN32 ? args.map(quoteWin32Arg) : args, {
290
429
  cwd: this.opts.workspaceDir,
291
430
  env,
292
431
  stdio: ["pipe", "pipe", "pipe"],
432
+ shell: IS_WIN32,
293
433
  });
294
434
 
295
435
  if (!proc.stdout || !proc.stderr || !proc.stdin) {
@@ -301,13 +441,11 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
301
441
  stderrChunks.push(chunk.toString());
302
442
  });
303
443
  proc.stdin.on("error", () => {
304
- // stdin pipe broken: kill the process so parser.next() sees EOF
305
- // instead of hanging forever on a process that's alive but can't
306
- // accept input. If the process was already exiting (the common
307
- // EPIPE-on-close race), kill is a harmless no-op.
308
444
  proc.stdin.destroy();
309
445
  if (proc.exitCode === null && proc.signalCode === null) {
310
- proc.kill("SIGTERM");
446
+ if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
447
+ proc.kill("SIGTERM");
448
+ }
311
449
  }
312
450
  });
313
451
 
@@ -330,7 +468,9 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
330
468
  } catch { /* best-effort */ }
331
469
  if (state.handle.proc.exitCode === null && state.handle.proc.signalCode === null) {
332
470
  try {
333
- state.handle.proc.kill("SIGTERM");
471
+ if (!IS_WIN32 || !state.handle.proc.pid || !killWin32Tree(state.handle.proc.pid)) {
472
+ state.handle.proc.kill("SIGTERM");
473
+ }
334
474
  } catch { /* best-effort */ }
335
475
  }
336
476
  }
@@ -348,7 +488,6 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
348
488
 
349
489
  private buildArgs(sessionKey: string): string[] {
350
490
  const args = [
351
- "-p",
352
491
  "--verbose",
353
492
  "--input-format",
354
493
  "stream-json",
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as os from "node:os";
4
- import { ParallAgentGateway, createPlatformConfigManager, isPlatformManagedProfile, parseShutdownDeadlineMs } from "@parall/agent-core";
4
+ import { ParallAgentGateway, createPlatformConfigManager, createLogger, isPlatformManagedProfile, parseShutdownDeadlineMs } from "@parall/agent-core";
5
5
  import { ApiError, ParallClient, ParallWs } from "@parall/sdk";
6
6
  import {
7
7
  buildClaudeRuntimeKey,
@@ -15,13 +15,7 @@ import { ClaudeCodeAdapter } from "./dispatch.js";
15
15
  import { ClaudeSessionManager } from "./session-manager.js";
16
16
  import { ensureClaudeWorkspace } from "./workspace.js";
17
17
 
18
- function createLogger(prefix: string) {
19
- return {
20
- info: (msg: string) => console.log(`[${prefix}] ${msg}`),
21
- warn: (msg: string) => console.warn(`[${prefix}] ${msg}`),
22
- error: (msg: string) => console.error(`[${prefix}] ${msg}`),
23
- };
24
- }
18
+ const log = createLogger("claude-agent");
25
19
 
26
20
  async function getAgentMeWithLegacyFallback(client: ParallClient, orgId: string) {
27
21
  try {
@@ -37,7 +31,6 @@ async function getAgentMeWithLegacyFallback(client: ParallClient, orgId: string)
37
31
 
38
32
  async function main() {
39
33
  const config = resolveClaudeAgentConfig(process.env);
40
- const log = createLogger("claude-agent");
41
34
 
42
35
  if (
43
36
  config.allowApiKey &&
@@ -179,6 +172,6 @@ async function main() {
179
172
  }
180
173
 
181
174
  main().catch((err) => {
182
- console.error(`[claude-agent] fatal: ${String(err)}`);
175
+ log.error(`fatal: ${String(err)}`);
183
176
  process.exitCode = 1;
184
177
  });