@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.
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as os from "node:os";
4
- import { ParallAgentGateway, parseShutdownDeadlineMs } from "@parall/agent-core";
4
+ import { ParallAgentGateway, createPlatformConfigManager, isPlatformManagedProfile, parseShutdownDeadlineMs } from "@parall/agent-core";
5
5
  import { ApiError, ParallClient, ParallWs } from "@parall/sdk";
6
6
  import {
7
7
  buildClaudeRuntimeKey,
8
+ contextFilePathForSession,
8
9
  resolveClaudeAgentConfig,
9
10
  resolveWsUrl,
10
11
  sessionStateFilePathForRuntime,
@@ -77,11 +78,21 @@ async function main() {
77
78
  wsUrl: resolvedWsUrl,
78
79
  });
79
80
 
81
+ const configMgr = createPlatformConfigManager({ client, stateDir: config.stateDir, runtimeType: "claude-code", log });
82
+ const platformDefaults = await configMgr.fetch();
83
+ let platformManaged = isPlatformManagedProfile(me.agent_profile);
84
+ const resolvedModel = platformManaged ? (platformDefaults.model ?? config.model) : config.model;
85
+ const resolvedEffort = platformManaged
86
+ ? (platformDefaults.thinkingEffort ?? (process.env.CLAUDE_CODE_EFFORT_LEVEL?.trim() || undefined))
87
+ : (process.env.CLAUDE_CODE_EFFORT_LEVEL?.trim() || undefined);
88
+ if (platformDefaults.model) log.info(`platform config: model=${platformDefaults.model} (${platformManaged ? "applied" : "ignored, model_management=self"})`);
89
+ if (platformDefaults.thinkingEffort) log.info(`platform config: thinking_effort=${platformDefaults.thinkingEffort} (${platformManaged ? "applied" : "ignored, model_management=self"})`);
90
+
80
91
  const adapter = new ClaudeCodeAdapter({
81
92
  claudeBin: config.claudeBin,
82
93
  claudeHome: config.claudeHome,
83
94
  workspaceDir: config.workspaceDir,
84
- model: config.model,
95
+ model: resolvedModel,
85
96
  permissionMode: config.permissionMode,
86
97
  allowedTools: config.allowedTools,
87
98
  disallowedTools: config.disallowedTools,
@@ -89,7 +100,18 @@ async function main() {
89
100
  appendSystemPrompt: config.appendSystemPrompt,
90
101
  allowApiKey: config.allowApiKey,
91
102
  sessionManager,
103
+ // Static Parall credentials. The long-lived subprocess must authenticate
104
+ // against Parall's API across many dispatches, so these cannot come from
105
+ // a per-dispatch DispatchContext.
106
+ apiUrl: config.apiUrl,
107
+ apiKey: config.apiKey,
108
+ orgId: config.orgId,
109
+ contextFilePathForSession: (sessionKey) => contextFilePathForSession(config.stateDir, sessionKey),
110
+ stepIdFilePathForSession: (sessionKey) => stepIdFilePathForSession(config.stateDir, sessionKey),
92
111
  });
112
+ if (resolvedEffort) {
113
+ adapter.updateConfig({ effort: resolvedEffort });
114
+ }
93
115
 
94
116
  const gateway = new ParallAgentGateway({
95
117
  accountId: agentUserId,
@@ -113,7 +135,33 @@ async function main() {
113
135
  dispatchAdapter: adapter,
114
136
  log,
115
137
  shutdownDeadlineMs: parseShutdownDeadlineMs(process.env.PRLL_SHUTDOWN_DEADLINE_MS),
138
+ contextFilePathForSession: (sessionKey) => contextFilePathForSession(config.stateDir, sessionKey),
116
139
  stepIdFilePathForSession: (sessionKey) => stepIdFilePathForSession(config.stateDir, sessionKey),
140
+ onConfigUpdate: async () => {
141
+ const refreshed = await getAgentMeWithLegacyFallback(client, config.orgId);
142
+ platformManaged = isPlatformManagedProfile(refreshed.agent_profile);
143
+ const updated = await configMgr.fetch();
144
+ const localEffort = process.env.CLAUDE_CODE_EFFORT_LEVEL?.trim() || null;
145
+ adapter.updateConfig({
146
+ model: platformManaged ? (updated.model ?? config.model) : (config.model ?? null),
147
+ effort: platformManaged ? (updated.thinkingEffort ?? localEffort) : localEffort,
148
+ });
149
+ },
150
+ onSessionReady: async () => {
151
+ const refreshed = await getAgentMeWithLegacyFallback(client, config.orgId);
152
+ platformManaged = isPlatformManagedProfile(refreshed.agent_profile);
153
+ const updated = await configMgr.fetch();
154
+ const localEffort = process.env.CLAUDE_CODE_EFFORT_LEVEL?.trim() || null;
155
+ adapter.updateConfig({
156
+ model: platformManaged ? (updated.model ?? config.model) : (config.model ?? null),
157
+ effort: platformManaged ? (updated.thinkingEffort ?? localEffort) : localEffort,
158
+ });
159
+ },
160
+ // Long-lived `claude` subprocesses outlive a single dispatch, so the
161
+ // gateway's shuttingDown flag alone does not tear them down. Piggyback on
162
+ // onBeforeDisconnect to close stdin and SIGTERM any survivors after
163
+ // in-flight drains finish.
164
+ onBeforeDisconnect: () => adapter.shutdown(),
117
165
  });
118
166
 
119
167
  const abortController = new AbortController();
@@ -3,7 +3,18 @@ import type { RuntimeEvent } from "@parall/agent-core";
3
3
 
4
4
  export type ClaudeParsedEvent =
5
5
  | RuntimeEvent
6
- | { type: "session_id"; sessionId: string };
6
+ | { type: "session_id"; sessionId: string }
7
+ // turn_end is emitted for every `result` frame (both `is_error: true` and
8
+ // `is_error: false`); `isError` carries the frame's status so the consumer
9
+ // can tell a clean turn boundary from a failed one without inspecting the
10
+ // error event that preceded it. In stream-json input mode a single
11
+ // `claude` subprocess processes many sequential user turns on one
12
+ // long-lived stdout stream, so the per-dispatch caller needs a signal to
13
+ // stop consuming after exactly one turn without tearing down the shared
14
+ // stream. For a short-lived `--print <prompt>` invocation the final
15
+ // `result` frame arrives right before stdout EOF, so this event is
16
+ // redundant there — safe to ignore.
17
+ | { type: "turn_end"; sessionId?: string; isError: boolean };
7
18
 
8
19
  type ToolUseMeta = {
9
20
  toolName: string;
@@ -184,11 +195,25 @@ export async function* parseClaudeStreamJson(readable: Readable): AsyncGenerator
184
195
  continue;
185
196
  }
186
197
 
187
- if (eventRecord.type === "result" && eventRecord.is_error === true) {
188
- const message = asTrimmedString(eventRecord.result)
189
- || asTrimmedString(eventRecord.error)
190
- || "Claude dispatch failed";
191
- yield { type: "error", message };
198
+ if (eventRecord.type === "result") {
199
+ const isError = eventRecord.is_error === true;
200
+ if (isError) {
201
+ const message = asTrimmedString(eventRecord.result)
202
+ || asTrimmedString(eventRecord.error)
203
+ || "Claude dispatch failed";
204
+ yield { type: "error", message };
205
+ }
206
+ // Turn boundary: the subprocess lives across many turns, so any
207
+ // tool_use entries that never matched a tool_result this turn
208
+ // (tool crashed mid-call, Claude aborted, etc.) must not leak into
209
+ // the next turn's accounting. Drop them here rather than letting
210
+ // the map grow unbounded or mislabel durations on id collisions.
211
+ toolUses.clear();
212
+ yield {
213
+ type: "turn_end",
214
+ sessionId: asTrimmedString(eventRecord.session_id),
215
+ isError,
216
+ };
192
217
  }
193
218
  }
194
219
  }
@@ -1,6 +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 type { ChildProcessWithoutNullStreams } from "node:child_process";
4
5
  import type { ForkSessionHandle } from "@parall/agent-core";
5
6
 
6
7
  type PersistedMainSession = {
@@ -12,9 +13,16 @@ type ClaudeSessionManagerLogger = {
12
13
  warn(message: string): void;
13
14
  };
14
15
 
16
+ export type ClaudeProcessHandle = {
17
+ proc: ChildProcessWithoutNullStreams;
18
+ exitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
19
+ stderrChunks: string[];
20
+ };
21
+
15
22
  export class ClaudeSessionManager {
16
23
  private readonly sessionIds = new Map<string, string>();
17
24
  private readonly pendingForkParents = new Map<string, string>();
25
+ private readonly processes = new Map<string, ClaudeProcessHandle>();
18
26
 
19
27
  constructor(
20
28
  private readonly mainSessionKey: string,
@@ -57,6 +65,142 @@ export class ClaudeSessionManager {
57
65
  cleanupFork(sessionKey: string) {
58
66
  this.pendingForkParents.delete(sessionKey);
59
67
  this.sessionIds.delete(sessionKey);
68
+ const handle = this.processes.get(sessionKey);
69
+ if (handle) {
70
+ this.processes.delete(sessionKey);
71
+ this.closeHandle(handle, `fork ${sessionKey} cleanup`);
72
+ }
73
+ }
74
+
75
+ getProcess(sessionKey: string): ClaudeProcessHandle | undefined {
76
+ return this.processes.get(sessionKey);
77
+ }
78
+
79
+ registerProcess(sessionKey: string, handle: ClaudeProcessHandle) {
80
+ const existing = this.processes.get(sessionKey);
81
+ if (existing && existing !== handle) {
82
+ this.logger?.warn(
83
+ `claude-agent: replacing existing process handle for ${sessionKey}; closing previous`,
84
+ );
85
+ this.closeHandle(existing, `replaced for ${sessionKey}`);
86
+ }
87
+ this.processes.set(sessionKey, handle);
88
+ // Auto-clear on exit so the map does not accumulate dead handles.
89
+ handle.exitPromise
90
+ .finally(() => {
91
+ const current = this.processes.get(sessionKey);
92
+ if (current === handle) {
93
+ this.processes.delete(sessionKey);
94
+ }
95
+ })
96
+ .catch(() => {
97
+ // exitPromise is resolved, never rejected, but guard anyway.
98
+ });
99
+ }
100
+
101
+ clearProcess(sessionKey: string, handle?: ClaudeProcessHandle) {
102
+ const current = this.processes.get(sessionKey);
103
+ if (!current) return;
104
+ if (handle && current !== handle) return;
105
+ this.processes.delete(sessionKey);
106
+ }
107
+
108
+ /** Grace period (ms) between SIGTERM and SIGKILL during shutdown. */
109
+ private static readonly SHUTDOWN_GRACE_MS = 5_000;
110
+
111
+ async shutdownAll(): Promise<void> {
112
+ const handles = [...this.processes.entries()];
113
+ this.processes.clear();
114
+ await Promise.all(
115
+ handles.map(([sessionKey, handle]) => this.shutdownOne(sessionKey, handle)),
116
+ );
117
+ }
118
+
119
+ private async shutdownOne(sessionKey: string, handle: ClaudeProcessHandle): Promise<void> {
120
+ this.closeHandle(handle, `shutdown ${sessionKey}`);
121
+ // Wait for SIGTERM to take effect, but bound the wait: if the child
122
+ // ignores SIGTERM (buggy tool, uninterruptible syscall, etc.) we must
123
+ // not block the whole agent exit forever. Escalate to SIGKILL after the
124
+ // grace window and then wait once more for the kernel to reap it.
125
+ const timedOut = await this.raceWithTimeout(
126
+ handle.exitPromise,
127
+ ClaudeSessionManager.SHUTDOWN_GRACE_MS,
128
+ );
129
+ if (!timedOut) return;
130
+
131
+ if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
132
+ this.logger?.warn(
133
+ `claude-agent: SIGTERM timed out for ${sessionKey}, escalating to SIGKILL`,
134
+ );
135
+ try {
136
+ handle.proc.kill("SIGKILL");
137
+ } catch (error) {
138
+ this.logger?.warn(
139
+ `claude-agent: SIGKILL failed for ${sessionKey}: ${String(error)}`,
140
+ );
141
+ }
142
+ }
143
+ // Bound the post-SIGKILL wait too: on the rare kernel path where even
144
+ // SIGKILL delivery is delayed (uninterruptible D-state, zombie reaping
145
+ // stuck on a parent bookkeeping path), gateway disconnect must still
146
+ // make progress. Warn and move on if the reap is not observed in time.
147
+ const killTimedOut = await this.raceWithTimeout(
148
+ handle.exitPromise,
149
+ ClaudeSessionManager.SHUTDOWN_GRACE_MS,
150
+ );
151
+ if (killTimedOut) {
152
+ this.logger?.warn(
153
+ `claude-agent: subprocess for ${sessionKey} not reaped after SIGKILL within ${ClaudeSessionManager.SHUTDOWN_GRACE_MS}ms; continuing shutdown`,
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Wait up to `ms` for `promise`. Resolves `false` if the promise settled
160
+ * in time, `true` if the timeout fired first. Never rejects.
161
+ */
162
+ private raceWithTimeout(promise: Promise<unknown>, ms: number): Promise<boolean> {
163
+ return new Promise<boolean>((resolve) => {
164
+ let settled = false;
165
+ const timer = setTimeout(() => {
166
+ if (settled) return;
167
+ settled = true;
168
+ resolve(true);
169
+ }, ms);
170
+ // Don't keep the event loop alive purely on the timeout.
171
+ if (typeof timer.unref === "function") timer.unref();
172
+ promise
173
+ .catch(() => {
174
+ /* exitPromise does not reject; guard anyway */
175
+ })
176
+ .finally(() => {
177
+ if (settled) return;
178
+ settled = true;
179
+ clearTimeout(timer);
180
+ resolve(false);
181
+ });
182
+ });
183
+ }
184
+
185
+ private closeHandle(handle: ClaudeProcessHandle, reason: string) {
186
+ try {
187
+ if (!handle.proc.stdin.destroyed) {
188
+ handle.proc.stdin.end();
189
+ }
190
+ } catch (error) {
191
+ this.logger?.warn(
192
+ `claude-agent: failed to close stdin (${reason}): ${String(error)}`,
193
+ );
194
+ }
195
+ if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
196
+ try {
197
+ handle.proc.kill("SIGTERM");
198
+ } catch (error) {
199
+ this.logger?.warn(
200
+ `claude-agent: failed to SIGTERM claude subprocess (${reason}): ${String(error)}`,
201
+ );
202
+ }
203
+ }
60
204
  }
61
205
 
62
206
  private restore() {
package/src/workspace.ts CHANGED
@@ -5,6 +5,8 @@ import {
5
5
  PRLL_BEHAVIOR,
6
6
  PRLL_REFERENCE_GUIDE,
7
7
  buildIdentity,
8
+ buildSkillReferences,
9
+ writeSkillFiles,
8
10
  } from "@parall/agent-core";
9
11
  import type { AgentIdentity } from "@parall/agent-core";
10
12
  import { ensureLocalAttachmentGitExclude } from "@parall/agent-core/internal/attachment-input";
@@ -14,35 +16,21 @@ export function ensureClaudeWorkspace(
14
16
  log?: { warn: (msg: string) => void },
15
17
  agentIdentity?: AgentIdentity,
16
18
  ) {
17
- const CLAUDE_MD = [
19
+ const systemPrompt = [
18
20
  buildIdentity(agentIdentity),
19
21
  BRIDGE_WORKSPACE_INSTRUCTIONS,
20
22
  PRLL_BEHAVIOR,
21
23
  PRLL_REFERENCE_GUIDE,
24
+ buildSkillReferences(workspaceDir),
22
25
  ].join("\n\n");
26
+
23
27
  fs.mkdirSync(workspaceDir, { recursive: true });
24
28
  fs.mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
25
29
 
26
- // CLAUDE.md is bridge-managed: always overwrite so a hosted PVC keeps the
27
- // current guardrails (e.g. the `no-reply` instruction). Operators must not
28
- // hand-edit this file — workspace customizations should go into AGENTS.md
29
- // / SOUL.md / TOOLS.md, which are env-driven and untouched here. Surface a
30
- // warning when we replace a file whose content diverges, so an operator
31
- // who did edit it locally gets a signal instead of silently losing changes.
32
- const claudeMdPath = path.join(workspaceDir, "CLAUDE.md");
33
- if (log) {
34
- try {
35
- const existing = fs.readFileSync(claudeMdPath, "utf8");
36
- if (existing !== CLAUDE_MD) {
37
- log.warn(
38
- `claude-agent: overwriting divergent ${claudeMdPath} with bridge-managed template ` +
39
- `(local edits to CLAUDE.md are not preserved — customize AGENTS.md / SOUL.md / TOOLS.md instead)`,
40
- );
41
- }
42
- } catch {
43
- // file missing or unreadable — first-boot case, no warning needed
44
- }
45
- }
46
- fs.writeFileSync(claudeMdPath, CLAUDE_MD, "utf8");
30
+ const parallDir = path.join(workspaceDir, ".parall");
31
+ fs.mkdirSync(parallDir, { recursive: true });
32
+ fs.writeFileSync(path.join(parallDir, "system-prompt.md"), systemPrompt, "utf8");
33
+
34
+ writeSkillFiles(path.join(parallDir, "skills"));
47
35
  ensureLocalAttachmentGitExclude(workspaceDir);
48
36
  }