@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.
- 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 +25 -0
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +207 -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 +264 -84
- 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/dist/session-manager.js
CHANGED
|
@@ -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 {
|
package/dist/workspace.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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.
|
|
29
|
-
"@parall/cli": "1.
|
|
30
|
-
"@parall/sdk": "1.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
131
|
+
yield* this.runTurn(sessionKey, promptBody, context.log);
|
|
132
|
+
} finally {
|
|
133
|
+
releasePreparedAttachments();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
88
136
|
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
95
|
-
});
|
|
143
|
+
forkSession({ sessionKey }: ForkOpts) {
|
|
144
|
+
return this.opts.sessionManager.createForkSession(sessionKey);
|
|
145
|
+
}
|
|
96
146
|
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
198
|
+
message: detail
|
|
199
|
+
|| `Claude exited with code ${exit.code ?? "unknown"}${exit.signal ? ` (${exit.signal})` : ""}`,
|
|
157
200
|
};
|
|
158
201
|
}
|
|
202
|
+
return;
|
|
159
203
|
}
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
this.
|
|
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
|
|
328
|
+
private buildArgs(sessionKey: string): string[] {
|
|
174
329
|
const args = [
|
|
175
|
-
"
|
|
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.
|
|
184
|
-
args.push("--model", this.
|
|
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
|
}
|