@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/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/dispatch.d.ts +24 -0
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +202 -78
- package/dist/index.js +51 -3
- package/dist/output-parser.d.ts +4 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +19 -5
- package/dist/session-manager.d.ts +23 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +117 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +7 -22
- package/package.json +4 -4
- package/src/config.ts +6 -0
- package/src/dispatch.ts +259 -85
- package/src/index.ts +50 -2
- package/src/output-parser.ts +31 -6
- package/src/session-manager.ts +144 -0
- package/src/workspace.ts +10 -22
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:
|
|
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();
|
package/src/output-parser.ts
CHANGED
|
@@ -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"
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
}
|
package/src/session-manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
}
|