@parall/claude-agent 1.42.1 → 1.43.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/dispatch.d.ts +10 -0
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +30 -3
- package/dist/index.js +63 -4
- package/dist/workspace.d.ts +2 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +35 -8
- package/package.json +4 -4
- package/src/dispatch.ts +46 -4
- package/src/index.ts +63 -3
- package/src/workspace.ts +51 -10
package/dist/dispatch.d.ts
CHANGED
|
@@ -18,11 +18,20 @@ type ClaudeCodeAdapterOptions = Pick<ClaudeAgentConfig, 'additionalDirs' | 'allo
|
|
|
18
18
|
* resolve no_action instead of reply-effect dedupe).
|
|
19
19
|
*/
|
|
20
20
|
contextDirPath?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Channel-capability shim directory (agent-core's capabilityBinDir),
|
|
23
|
+
* prepended to the subprocess PATH unconditionally. The DIRECTORY is
|
|
24
|
+
* constant for the bridge's lifetime; its CONTENT tracks capability
|
|
25
|
+
* grants, so a grant or revocation reaches even a long-lived claude
|
|
26
|
+
* subprocess on its next shell command — no respawn needed.
|
|
27
|
+
*/
|
|
28
|
+
capabilityBinDir?: string;
|
|
21
29
|
};
|
|
22
30
|
export declare function buildSpawnEnv(parentEnv: NodeJS.ProcessEnv, claudeHome: string, context: DispatchOpts['context'], opts: {
|
|
23
31
|
allowApiKey: boolean;
|
|
24
32
|
effortLevel?: string;
|
|
25
33
|
wikiMountRoot?: string;
|
|
34
|
+
capabilityBinDir?: string;
|
|
26
35
|
}): NodeJS.ProcessEnv;
|
|
27
36
|
export declare class ClaudeCodeAdapter implements DispatchAdapter {
|
|
28
37
|
private readonly opts;
|
|
@@ -38,6 +47,7 @@ export declare class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
38
47
|
model?: string | null;
|
|
39
48
|
effort?: string | null;
|
|
40
49
|
}): void;
|
|
50
|
+
requestProcessRestart(): void;
|
|
41
51
|
enqueueDuringDispatch(sessionKey: string, body: string): boolean;
|
|
42
52
|
abortDispatch(sessionKey: string): void;
|
|
43
53
|
hasPendingInjections(sessionKey: string): boolean;
|
package/dist/dispatch.d.ts.map
CHANGED
|
@@ -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,EAAE,oBAAoB,EAA4B,MAAM,sBAAsB,CAAC;AAEtF,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;IAC1D;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
|
package/dist/dispatch.js
CHANGED
|
@@ -52,6 +52,23 @@ export function buildSpawnEnv(parentEnv, claudeHome, context, opts) {
|
|
|
52
52
|
if (opts.effortLevel) {
|
|
53
53
|
result.CLAUDE_CODE_EFFORT_LEVEL = opts.effortLevel;
|
|
54
54
|
}
|
|
55
|
+
// Channel-capability shims resolve ahead of any globally-installed CLI of
|
|
56
|
+
// the same name (the shim owns auth; the real binary is found further down
|
|
57
|
+
// the PATH by the shim itself). On Windows env keys are case-insensitive and
|
|
58
|
+
// the host key is usually `Path`; after the plain-object spread a literal
|
|
59
|
+
// `PATH` write would SPLIT the key (undefined which wins in the child), so
|
|
60
|
+
// reuse the parent's casing. On POSIX keys are case-SENSITIVE — `PATH` is
|
|
61
|
+
// the real one and a stray `Path` is unrelated, so match exactly there or we
|
|
62
|
+
// could prepend to the wrong variable and silently bypass the shim.
|
|
63
|
+
if (opts.capabilityBinDir) {
|
|
64
|
+
const pathKey = process.platform === 'win32'
|
|
65
|
+
? (Object.keys(result).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH')
|
|
66
|
+
: 'PATH';
|
|
67
|
+
const existing = result[pathKey];
|
|
68
|
+
result[pathKey] = existing
|
|
69
|
+
? `${opts.capabilityBinDir}${path.delimiter}${existing}`
|
|
70
|
+
: opts.capabilityBinDir;
|
|
71
|
+
}
|
|
55
72
|
return result;
|
|
56
73
|
}
|
|
57
74
|
export class ClaudeCodeAdapter {
|
|
@@ -79,9 +96,18 @@ export class ClaudeCodeAdapter {
|
|
|
79
96
|
if (effortChanged)
|
|
80
97
|
this._effortLevel = config.effort ?? undefined;
|
|
81
98
|
if (modelChanged || effortChanged) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
this.requestProcessRestart();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Mark every live subprocess for a lazy respawn (applied at its next
|
|
103
|
+
// dispatch; the session survives via --resume) — the same mechanism
|
|
104
|
+
// updateConfig uses for model/effort changes. For spawn-time inputs that
|
|
105
|
+
// live OUTSIDE this adapter's config, e.g. the .parall/system-prompt.md
|
|
106
|
+
// file: a long-lived process read it via --append-system-prompt-file at
|
|
107
|
+
// spawn, so a capability declaration change only reaches it by respawning.
|
|
108
|
+
requestProcessRestart() {
|
|
109
|
+
for (const [, state] of this.processes) {
|
|
110
|
+
state.needsRestart = true;
|
|
85
111
|
}
|
|
86
112
|
}
|
|
87
113
|
enqueueDuringDispatch(sessionKey, body) {
|
|
@@ -389,6 +415,7 @@ export class ClaudeCodeAdapter {
|
|
|
389
415
|
allowApiKey: this.opts.allowApiKey,
|
|
390
416
|
effortLevel: this._effortLevel,
|
|
391
417
|
wikiMountRoot: this.opts.workspaceDir,
|
|
418
|
+
capabilityBinDir: this.opts.capabilityBinDir,
|
|
392
419
|
});
|
|
393
420
|
log?.info(`spawn long-lived ${this.opts.claudeBin} (session ${sessionKey}, model=${this._model || 'default'}, effort=${this._effortLevel || 'default'}, mode=${this.opts.permissionMode})`);
|
|
394
421
|
const proc = spawn(IS_WIN32 ? quoteWin32Arg(this.opts.claudeBin) : this.opts.claudeBin, IS_WIN32 ? args.map(quoteWin32Arg) : args, {
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as os from 'node:os';
|
|
3
|
-
import { ParallAgentGateway, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, initAgentTelemetry, } from '@parall/agent-core';
|
|
3
|
+
import { ParallAgentGateway, capabilityBinDir, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, materializeChannelCapabilities, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, initAgentTelemetry, } from '@parall/agent-core';
|
|
4
4
|
import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
|
|
5
5
|
import { buildClaudeRuntimeKey, contextFilePathForSession, dispatchContextDirPath, resolveClaudeAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from './config.js';
|
|
6
6
|
import { ClaudeCodeAdapter } from './dispatch.js';
|
|
7
7
|
import { ClaudeSessionManager } from './session-manager.js';
|
|
8
|
-
import { ensureClaudeWorkspace } from './workspace.js';
|
|
8
|
+
import { ensureClaudeWorkspace, writeClaudeSystemPrompt } from './workspace.js';
|
|
9
9
|
const log = createLogger('claude-agent');
|
|
10
10
|
let activeLog = log;
|
|
11
11
|
async function getAgentMeWithLegacyFallback(client, orgId) {
|
|
@@ -61,11 +61,13 @@ async function main() {
|
|
|
61
61
|
const agentUserId = me.id;
|
|
62
62
|
const agentLog = childLogger(activeLog, agentUserId);
|
|
63
63
|
activeLog = agentLog;
|
|
64
|
-
|
|
64
|
+
// Shared by the initial workspace write and the config-refresh rewrite of
|
|
65
|
+
// .parall/system-prompt.md — both need identity + capability fragments.
|
|
66
|
+
const agentIdentity = {
|
|
65
67
|
userId: agentUserId,
|
|
66
68
|
displayName: me.display_name,
|
|
67
69
|
description: me.agent_profile?.description ?? undefined,
|
|
68
|
-
}
|
|
70
|
+
};
|
|
69
71
|
const runtimeKey = config.runtimeKey || buildClaudeRuntimeKey(agentUserId);
|
|
70
72
|
const sessionStateFilePath = sessionStateFilePathForRuntime(config.stateDir, runtimeKey);
|
|
71
73
|
const sessionManager = new ClaudeSessionManager(runtimeKey, sessionStateFilePath, agentLog);
|
|
@@ -81,6 +83,22 @@ async function main() {
|
|
|
81
83
|
log: agentLog,
|
|
82
84
|
});
|
|
83
85
|
const platformDefaults = await configMgr.fetch();
|
|
86
|
+
// Channel capabilities: ONE call materializes the local artifacts (the
|
|
87
|
+
// lark-cli auth shim under capabilityBinDir) and yields the system-prompt
|
|
88
|
+
// fragments, so declaration and shim can never diverge within a refresh.
|
|
89
|
+
// No freshness gate — a stale (LKG-cached) declaration fail-closes at the
|
|
90
|
+
// mint endpoint, which re-evaluates the grant on every token request.
|
|
91
|
+
const applyChannelCapabilities = () => {
|
|
92
|
+
const caps = configMgr.capabilities();
|
|
93
|
+
materializeChannelCapabilities(config.stateDir, caps, agentLog);
|
|
94
|
+
return caps.map((c) => c.fragment);
|
|
95
|
+
};
|
|
96
|
+
// Assemble the workspace AFTER the first config fetch so system-prompt.md
|
|
97
|
+
// carries the capability declarations from boot (not just from the first
|
|
98
|
+
// heat-update).
|
|
99
|
+
const bootCapabilityFragments = applyChannelCapabilities();
|
|
100
|
+
let lastCapabilityFragments = bootCapabilityFragments.join('\n\n');
|
|
101
|
+
ensureClaudeWorkspace(config.workspaceDir, agentLog, agentIdentity, bootCapabilityFragments);
|
|
84
102
|
// Model precedence: operator PIN (override) > env > server FLOOR. The server
|
|
85
103
|
// now says which it is via model_is_pin (deriveModelIsPin presence-gates the
|
|
86
104
|
// dual-read: old servers omit it → fall back to model_management). A PIN beats
|
|
@@ -115,6 +133,9 @@ async function main() {
|
|
|
115
133
|
contextFilePathForSession: (sessionKey) => contextFilePathForSession(config.stateDir, sessionKey),
|
|
116
134
|
stepIdFilePathForSession: (sessionKey) => stepIdFilePathForSession(config.stateDir, sessionKey),
|
|
117
135
|
contextDirPath: dispatchContextDirPath(config.stateDir),
|
|
136
|
+
// Constant PATH prepend; the directory's content (the lark-cli shim)
|
|
137
|
+
// tracks capability grants, so add/remove reaches live subprocesses.
|
|
138
|
+
capabilityBinDir: capabilityBinDir(config.stateDir),
|
|
118
139
|
});
|
|
119
140
|
if (resolvedEffort) {
|
|
120
141
|
adapter.updateConfig({ effort: resolvedEffort });
|
|
@@ -144,6 +165,39 @@ async function main() {
|
|
|
144
165
|
model: resolveRuntimeModel(isPin, updated.model, config.model) ?? null,
|
|
145
166
|
effort: updated.thinkingEffort ?? localEffort,
|
|
146
167
|
});
|
|
168
|
+
// Channel capabilities heat-update with the config: re-materialize the
|
|
169
|
+
// shim and rewrite the standing system prompt on EVERY refresh. The
|
|
170
|
+
// rewrite is unconditional on purpose — it is self-healing: the prompt
|
|
171
|
+
// file lives in the agent-writable workspace, so a subprocess or its
|
|
172
|
+
// tools can delete or tamper with it, and rewriting each refresh/reconnect
|
|
173
|
+
// restores the authoritative copy (do NOT gate this on a fragment change).
|
|
174
|
+
// Revocation keeps the constant pointer in place (no deny stub, no local
|
|
175
|
+
// token cache): the mint endpoint's 403 IS the revocation, nothing local
|
|
176
|
+
// to purge. The shim swap reaches even live long-lived subprocesses
|
|
177
|
+
// immediately (PATH re-resolves per command), but the system prompt was
|
|
178
|
+
// loaded at spawn — so a CHANGED declaration also marks live processes for
|
|
179
|
+
// a lazy --resume respawn, the same mechanism model changes use.
|
|
180
|
+
const fragments = applyChannelCapabilities();
|
|
181
|
+
const joinedFragments = fragments.join('\n\n');
|
|
182
|
+
// Refresh path: TOLERATE a prompt-write failure (boot is fatal instead) —
|
|
183
|
+
// a transient failure must not break the whole config heat-update. But do
|
|
184
|
+
// NOT advance the snapshot / restart unless the write actually LANDED:
|
|
185
|
+
// otherwise a later successful retry sees "no change" and never restarts
|
|
186
|
+
// the long-lived Claude subprocess, stranding it on the stale prompt. The
|
|
187
|
+
// rewrite is self-healing; leaving the snapshot put makes the next refresh
|
|
188
|
+
// retry and, on success, restart.
|
|
189
|
+
let promptWritten = true;
|
|
190
|
+
try {
|
|
191
|
+
writeClaudeSystemPrompt(config.workspaceDir, agentIdentity, fragments);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
promptWritten = false;
|
|
195
|
+
agentLog.warn(`system prompt refresh write failed (retrying next refresh): ${String(err)}`);
|
|
196
|
+
}
|
|
197
|
+
if (promptWritten && joinedFragments !== lastCapabilityFragments) {
|
|
198
|
+
lastCapabilityFragments = joinedFragments;
|
|
199
|
+
adapter.requestProcessRestart();
|
|
200
|
+
}
|
|
147
201
|
};
|
|
148
202
|
const gateway = new ParallAgentGateway({
|
|
149
203
|
accountId: agentUserId,
|
|
@@ -166,6 +220,11 @@ async function main() {
|
|
|
166
220
|
},
|
|
167
221
|
dispatchAdapter: adapter,
|
|
168
222
|
log: agentLog,
|
|
223
|
+
// Live capability view for hint routing: the channel reply hint points
|
|
224
|
+
// at the vendor CLI only while the grant is active (config manager
|
|
225
|
+
// re-fetches on agent_config.update, so revocation reaches the very
|
|
226
|
+
// next dispatch's hint).
|
|
227
|
+
getCapabilityKeys: () => configMgr.capabilities().map((c) => c.key),
|
|
169
228
|
shutdownDeadlineMs: parseShutdownDeadlineMs(process.env.PRLL_SHUTDOWN_DEADLINE_MS),
|
|
170
229
|
forkDeadlineMs: parseForkDeadlineMs(process.env.PRLL_FORK_DEADLINE_MS),
|
|
171
230
|
dispatchDeadlineMs: parseDispatchDeadlineMs(process.env.PRLL_DISPATCH_DEADLINE_MS),
|
package/dist/workspace.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentIdentity } from '@parall/agent-core';
|
|
2
|
+
export declare function writeClaudeSystemPrompt(workspaceDir: string, agentIdentity?: AgentIdentity, capabilityFragments?: string[]): void;
|
|
2
3
|
export declare function ensureClaudeWorkspace(workspaceDir: string, log?: {
|
|
3
4
|
warn: (msg: string) => void;
|
|
4
|
-
}, agentIdentity?: AgentIdentity): void;
|
|
5
|
+
}, agentIdentity?: AgentIdentity, capabilityFragments?: string[]): void;
|
|
5
6
|
//# sourceMappingURL=workspace.d.ts.map
|
package/dist/workspace.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AA8BxD,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,MAAM,EACpB,aAAa,CAAC,EAAE,aAAa,EAC7B,mBAAmB,CAAC,EAAE,MAAM,EAAE,QAgB/B;AAED,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,EAC7B,mBAAmB,CAAC,EAAE,MAAM,EAAE,QAW/B"}
|
package/dist/workspace.js
CHANGED
|
@@ -2,19 +2,46 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
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
|
-
|
|
6
|
-
|
|
5
|
+
// buildClaudeSystemPrompt assembles the standing system prompt. capabilityFragments
|
|
6
|
+
// are platform-derived capability declarations (agents.capabilities[].fragment
|
|
7
|
+
// from platform-config) — placed after the platform reference guide, before
|
|
8
|
+
// skill references. Empty/absent = no capability section.
|
|
9
|
+
function buildClaudeSystemPrompt(workspaceDir, agentIdentity, capabilityFragments) {
|
|
10
|
+
const parts = [
|
|
7
11
|
buildIdentity(agentIdentity),
|
|
8
12
|
BRIDGE_WORKSPACE_INSTRUCTIONS,
|
|
9
13
|
PRLL_BEHAVIOR,
|
|
10
14
|
PRLL_REFERENCE_GUIDE,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
];
|
|
16
|
+
if (capabilityFragments && capabilityFragments.length > 0) {
|
|
17
|
+
parts.push(capabilityFragments.join('\n\n'));
|
|
18
|
+
}
|
|
19
|
+
parts.push(buildSkillReferences(workspaceDir));
|
|
20
|
+
return parts.join('\n\n');
|
|
21
|
+
}
|
|
22
|
+
// writeClaudeSystemPrompt (re)writes .parall/system-prompt.md. Split out from
|
|
23
|
+
// ensureClaudeWorkspace so the bridge can rewrite it when platform-config
|
|
24
|
+
// capabilities heat-update — the file is read via --append-system-prompt-file
|
|
25
|
+
// on every claude spawn, so a rewrite reaches the next turn without a
|
|
26
|
+
// workspace rebuild.
|
|
27
|
+
export function writeClaudeSystemPrompt(workspaceDir, agentIdentity, capabilityFragments) {
|
|
28
|
+
// Throws on failure BY DESIGN — tolerance is a property of the CALLER, not
|
|
29
|
+
// this function. At boot (ensureClaudeWorkspace) the prompt is mandatory
|
|
30
|
+
// (every claude spawn passes it via --append-system-prompt-file), so a write
|
|
31
|
+
// failure must fail startup loudly for recovery, not degrade to an agent
|
|
32
|
+
// running without its platform instructions. The refresh hot path (index.ts)
|
|
33
|
+
// wraps this in try/catch instead, so a transient failure there degrades to a
|
|
34
|
+
// warning and the next refresh retries (the rewrite is self-healing).
|
|
15
35
|
const parallDir = path.join(workspaceDir, '.parall');
|
|
16
36
|
fs.mkdirSync(parallDir, { recursive: true });
|
|
17
|
-
fs.writeFileSync(path.join(parallDir, 'system-prompt.md'),
|
|
18
|
-
|
|
37
|
+
fs.writeFileSync(path.join(parallDir, 'system-prompt.md'), buildClaudeSystemPrompt(workspaceDir, agentIdentity, capabilityFragments), 'utf8');
|
|
38
|
+
}
|
|
39
|
+
export function ensureClaudeWorkspace(workspaceDir, log, agentIdentity, capabilityFragments) {
|
|
40
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
41
|
+
fs.mkdirSync(path.join(workspaceDir, '.claude'), { recursive: true });
|
|
42
|
+
// Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
|
|
43
|
+
// without the mandatory system prompt. The refresh path tolerates failure.
|
|
44
|
+
writeClaudeSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
|
|
45
|
+
writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
|
|
19
46
|
ensureLocalAttachmentGitExclude(workspaceDir);
|
|
20
47
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/claude-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.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/
|
|
29
|
-
"@parall/sdk": "1.
|
|
30
|
-
"@parall/
|
|
28
|
+
"@parall/agent-core": "1.43.0",
|
|
29
|
+
"@parall/sdk": "1.43.0",
|
|
30
|
+
"@parall/cli": "1.43.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^22.0.0",
|
package/src/dispatch.ts
CHANGED
|
@@ -47,6 +47,14 @@ type ClaudeCodeAdapterOptions = Pick<
|
|
|
47
47
|
* resolve no_action instead of reply-effect dedupe).
|
|
48
48
|
*/
|
|
49
49
|
contextDirPath?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Channel-capability shim directory (agent-core's capabilityBinDir),
|
|
52
|
+
* prepended to the subprocess PATH unconditionally. The DIRECTORY is
|
|
53
|
+
* constant for the bridge's lifetime; its CONTENT tracks capability
|
|
54
|
+
* grants, so a grant or revocation reaches even a long-lived claude
|
|
55
|
+
* subprocess on its next shell command — no respawn needed.
|
|
56
|
+
*/
|
|
57
|
+
capabilityBinDir?: string;
|
|
50
58
|
};
|
|
51
59
|
|
|
52
60
|
const IS_WIN32 = process.platform === 'win32';
|
|
@@ -69,7 +77,12 @@ export function buildSpawnEnv(
|
|
|
69
77
|
parentEnv: NodeJS.ProcessEnv,
|
|
70
78
|
claudeHome: string,
|
|
71
79
|
context: DispatchOpts['context'],
|
|
72
|
-
opts: {
|
|
80
|
+
opts: {
|
|
81
|
+
allowApiKey: boolean;
|
|
82
|
+
effortLevel?: string;
|
|
83
|
+
wikiMountRoot?: string;
|
|
84
|
+
capabilityBinDir?: string;
|
|
85
|
+
},
|
|
73
86
|
): NodeJS.ProcessEnv {
|
|
74
87
|
const env: NodeJS.ProcessEnv = { ...parentEnv };
|
|
75
88
|
if (!opts.allowApiKey) {
|
|
@@ -103,6 +116,24 @@ export function buildSpawnEnv(
|
|
|
103
116
|
if (opts.effortLevel) {
|
|
104
117
|
result.CLAUDE_CODE_EFFORT_LEVEL = opts.effortLevel;
|
|
105
118
|
}
|
|
119
|
+
// Channel-capability shims resolve ahead of any globally-installed CLI of
|
|
120
|
+
// the same name (the shim owns auth; the real binary is found further down
|
|
121
|
+
// the PATH by the shim itself). On Windows env keys are case-insensitive and
|
|
122
|
+
// the host key is usually `Path`; after the plain-object spread a literal
|
|
123
|
+
// `PATH` write would SPLIT the key (undefined which wins in the child), so
|
|
124
|
+
// reuse the parent's casing. On POSIX keys are case-SENSITIVE — `PATH` is
|
|
125
|
+
// the real one and a stray `Path` is unrelated, so match exactly there or we
|
|
126
|
+
// could prepend to the wrong variable and silently bypass the shim.
|
|
127
|
+
if (opts.capabilityBinDir) {
|
|
128
|
+
const pathKey =
|
|
129
|
+
process.platform === 'win32'
|
|
130
|
+
? (Object.keys(result).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH')
|
|
131
|
+
: 'PATH';
|
|
132
|
+
const existing = result[pathKey];
|
|
133
|
+
result[pathKey] = existing
|
|
134
|
+
? `${opts.capabilityBinDir}${path.delimiter}${existing}`
|
|
135
|
+
: opts.capabilityBinDir;
|
|
136
|
+
}
|
|
106
137
|
return result;
|
|
107
138
|
}
|
|
108
139
|
|
|
@@ -145,9 +176,19 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
145
176
|
if (modelChanged) this._model = config.model ?? undefined;
|
|
146
177
|
if (effortChanged) this._effortLevel = config.effort ?? undefined;
|
|
147
178
|
if (modelChanged || effortChanged) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
179
|
+
this.requestProcessRestart();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Mark every live subprocess for a lazy respawn (applied at its next
|
|
184
|
+
// dispatch; the session survives via --resume) — the same mechanism
|
|
185
|
+
// updateConfig uses for model/effort changes. For spawn-time inputs that
|
|
186
|
+
// live OUTSIDE this adapter's config, e.g. the .parall/system-prompt.md
|
|
187
|
+
// file: a long-lived process read it via --append-system-prompt-file at
|
|
188
|
+
// spawn, so a capability declaration change only reaches it by respawning.
|
|
189
|
+
requestProcessRestart(): void {
|
|
190
|
+
for (const [, state] of this.processes) {
|
|
191
|
+
state.needsRestart = true;
|
|
151
192
|
}
|
|
152
193
|
}
|
|
153
194
|
|
|
@@ -493,6 +534,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
493
534
|
allowApiKey: this.opts.allowApiKey,
|
|
494
535
|
effortLevel: this._effortLevel,
|
|
495
536
|
wikiMountRoot: this.opts.workspaceDir,
|
|
537
|
+
capabilityBinDir: this.opts.capabilityBinDir,
|
|
496
538
|
},
|
|
497
539
|
);
|
|
498
540
|
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import {
|
|
5
5
|
ParallAgentGateway,
|
|
6
|
+
capabilityBinDir,
|
|
6
7
|
createPlatformConfigManager,
|
|
7
8
|
createLogger,
|
|
8
9
|
createOtelLogger,
|
|
9
10
|
childLogger,
|
|
10
11
|
deriveModelIsPin,
|
|
12
|
+
materializeChannelCapabilities,
|
|
11
13
|
resolveRuntimeModel,
|
|
12
14
|
parseShutdownDeadlineMs,
|
|
13
15
|
parseForkDeadlineMs,
|
|
@@ -29,7 +31,7 @@ import {
|
|
|
29
31
|
} from './config.js';
|
|
30
32
|
import { ClaudeCodeAdapter } from './dispatch.js';
|
|
31
33
|
import { ClaudeSessionManager } from './session-manager.js';
|
|
32
|
-
import { ensureClaudeWorkspace } from './workspace.js';
|
|
34
|
+
import { ensureClaudeWorkspace, writeClaudeSystemPrompt } from './workspace.js';
|
|
33
35
|
|
|
34
36
|
const log = createLogger('claude-agent');
|
|
35
37
|
let activeLog = log;
|
|
@@ -90,11 +92,13 @@ async function main() {
|
|
|
90
92
|
const agentUserId = me.id;
|
|
91
93
|
const agentLog = childLogger(activeLog, agentUserId);
|
|
92
94
|
activeLog = agentLog;
|
|
93
|
-
|
|
95
|
+
// Shared by the initial workspace write and the config-refresh rewrite of
|
|
96
|
+
// .parall/system-prompt.md — both need identity + capability fragments.
|
|
97
|
+
const agentIdentity = {
|
|
94
98
|
userId: agentUserId,
|
|
95
99
|
displayName: me.display_name,
|
|
96
100
|
description: me.agent_profile?.description ?? undefined,
|
|
97
|
-
}
|
|
101
|
+
};
|
|
98
102
|
const runtimeKey = config.runtimeKey || buildClaudeRuntimeKey(agentUserId);
|
|
99
103
|
const sessionStateFilePath = sessionStateFilePathForRuntime(config.stateDir, runtimeKey);
|
|
100
104
|
const sessionManager = new ClaudeSessionManager(runtimeKey, sessionStateFilePath, agentLog);
|
|
@@ -112,6 +116,22 @@ async function main() {
|
|
|
112
116
|
log: agentLog,
|
|
113
117
|
});
|
|
114
118
|
const platformDefaults = await configMgr.fetch();
|
|
119
|
+
// Channel capabilities: ONE call materializes the local artifacts (the
|
|
120
|
+
// lark-cli auth shim under capabilityBinDir) and yields the system-prompt
|
|
121
|
+
// fragments, so declaration and shim can never diverge within a refresh.
|
|
122
|
+
// No freshness gate — a stale (LKG-cached) declaration fail-closes at the
|
|
123
|
+
// mint endpoint, which re-evaluates the grant on every token request.
|
|
124
|
+
const applyChannelCapabilities = () => {
|
|
125
|
+
const caps = configMgr.capabilities();
|
|
126
|
+
materializeChannelCapabilities(config.stateDir, caps, agentLog);
|
|
127
|
+
return caps.map((c) => c.fragment);
|
|
128
|
+
};
|
|
129
|
+
// Assemble the workspace AFTER the first config fetch so system-prompt.md
|
|
130
|
+
// carries the capability declarations from boot (not just from the first
|
|
131
|
+
// heat-update).
|
|
132
|
+
const bootCapabilityFragments = applyChannelCapabilities();
|
|
133
|
+
let lastCapabilityFragments = bootCapabilityFragments.join('\n\n');
|
|
134
|
+
ensureClaudeWorkspace(config.workspaceDir, agentLog, agentIdentity, bootCapabilityFragments);
|
|
115
135
|
// Model precedence: operator PIN (override) > env > server FLOOR. The server
|
|
116
136
|
// now says which it is via model_is_pin (deriveModelIsPin presence-gates the
|
|
117
137
|
// dual-read: old servers omit it → fall back to model_management). A PIN beats
|
|
@@ -156,6 +176,9 @@ async function main() {
|
|
|
156
176
|
stepIdFilePathForSession: (sessionKey) =>
|
|
157
177
|
stepIdFilePathForSession(config.stateDir, sessionKey),
|
|
158
178
|
contextDirPath: dispatchContextDirPath(config.stateDir),
|
|
179
|
+
// Constant PATH prepend; the directory's content (the lark-cli shim)
|
|
180
|
+
// tracks capability grants, so add/remove reaches live subprocesses.
|
|
181
|
+
capabilityBinDir: capabilityBinDir(config.stateDir),
|
|
159
182
|
});
|
|
160
183
|
if (resolvedEffort) {
|
|
161
184
|
adapter.updateConfig({ effort: resolvedEffort });
|
|
@@ -189,6 +212,38 @@ async function main() {
|
|
|
189
212
|
model: resolveRuntimeModel(isPin, updated.model, config.model) ?? null,
|
|
190
213
|
effort: updated.thinkingEffort ?? localEffort,
|
|
191
214
|
});
|
|
215
|
+
// Channel capabilities heat-update with the config: re-materialize the
|
|
216
|
+
// shim and rewrite the standing system prompt on EVERY refresh. The
|
|
217
|
+
// rewrite is unconditional on purpose — it is self-healing: the prompt
|
|
218
|
+
// file lives in the agent-writable workspace, so a subprocess or its
|
|
219
|
+
// tools can delete or tamper with it, and rewriting each refresh/reconnect
|
|
220
|
+
// restores the authoritative copy (do NOT gate this on a fragment change).
|
|
221
|
+
// Revocation keeps the constant pointer in place (no deny stub, no local
|
|
222
|
+
// token cache): the mint endpoint's 403 IS the revocation, nothing local
|
|
223
|
+
// to purge. The shim swap reaches even live long-lived subprocesses
|
|
224
|
+
// immediately (PATH re-resolves per command), but the system prompt was
|
|
225
|
+
// loaded at spawn — so a CHANGED declaration also marks live processes for
|
|
226
|
+
// a lazy --resume respawn, the same mechanism model changes use.
|
|
227
|
+
const fragments = applyChannelCapabilities();
|
|
228
|
+
const joinedFragments = fragments.join('\n\n');
|
|
229
|
+
// Refresh path: TOLERATE a prompt-write failure (boot is fatal instead) —
|
|
230
|
+
// a transient failure must not break the whole config heat-update. But do
|
|
231
|
+
// NOT advance the snapshot / restart unless the write actually LANDED:
|
|
232
|
+
// otherwise a later successful retry sees "no change" and never restarts
|
|
233
|
+
// the long-lived Claude subprocess, stranding it on the stale prompt. The
|
|
234
|
+
// rewrite is self-healing; leaving the snapshot put makes the next refresh
|
|
235
|
+
// retry and, on success, restart.
|
|
236
|
+
let promptWritten = true;
|
|
237
|
+
try {
|
|
238
|
+
writeClaudeSystemPrompt(config.workspaceDir, agentIdentity, fragments);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
promptWritten = false;
|
|
241
|
+
agentLog.warn(`system prompt refresh write failed (retrying next refresh): ${String(err)}`);
|
|
242
|
+
}
|
|
243
|
+
if (promptWritten && joinedFragments !== lastCapabilityFragments) {
|
|
244
|
+
lastCapabilityFragments = joinedFragments;
|
|
245
|
+
adapter.requestProcessRestart();
|
|
246
|
+
}
|
|
192
247
|
};
|
|
193
248
|
|
|
194
249
|
const gateway = new ParallAgentGateway({
|
|
@@ -212,6 +267,11 @@ async function main() {
|
|
|
212
267
|
},
|
|
213
268
|
dispatchAdapter: adapter,
|
|
214
269
|
log: agentLog,
|
|
270
|
+
// Live capability view for hint routing: the channel reply hint points
|
|
271
|
+
// at the vendor CLI only while the grant is active (config manager
|
|
272
|
+
// re-fetches on agent_config.update, so revocation reaches the very
|
|
273
|
+
// next dispatch's hint).
|
|
274
|
+
getCapabilityKeys: () => configMgr.capabilities().map((c) => c.key),
|
|
215
275
|
shutdownDeadlineMs: parseShutdownDeadlineMs(process.env.PRLL_SHUTDOWN_DEADLINE_MS),
|
|
216
276
|
forkDeadlineMs: parseForkDeadlineMs(process.env.PRLL_FORK_DEADLINE_MS),
|
|
217
277
|
dispatchDeadlineMs: parseDispatchDeadlineMs(process.env.PRLL_DISPATCH_DEADLINE_MS),
|
package/src/workspace.ts
CHANGED
|
@@ -11,26 +11,67 @@ import {
|
|
|
11
11
|
import type { AgentIdentity } from '@parall/agent-core';
|
|
12
12
|
import { ensureLocalAttachmentGitExclude } from '@parall/agent-core/internal/attachment-input';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// buildClaudeSystemPrompt assembles the standing system prompt. capabilityFragments
|
|
15
|
+
// are platform-derived capability declarations (agents.capabilities[].fragment
|
|
16
|
+
// from platform-config) — placed after the platform reference guide, before
|
|
17
|
+
// skill references. Empty/absent = no capability section.
|
|
18
|
+
function buildClaudeSystemPrompt(
|
|
15
19
|
workspaceDir: string,
|
|
16
|
-
log?: { warn: (msg: string) => void },
|
|
17
20
|
agentIdentity?: AgentIdentity,
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
capabilityFragments?: string[],
|
|
22
|
+
): string {
|
|
23
|
+
const parts = [
|
|
20
24
|
buildIdentity(agentIdentity),
|
|
21
25
|
BRIDGE_WORKSPACE_INSTRUCTIONS,
|
|
22
26
|
PRLL_BEHAVIOR,
|
|
23
27
|
PRLL_REFERENCE_GUIDE,
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
];
|
|
29
|
+
if (capabilityFragments && capabilityFragments.length > 0) {
|
|
30
|
+
parts.push(capabilityFragments.join('\n\n'));
|
|
31
|
+
}
|
|
32
|
+
parts.push(buildSkillReferences(workspaceDir));
|
|
33
|
+
return parts.join('\n\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// writeClaudeSystemPrompt (re)writes .parall/system-prompt.md. Split out from
|
|
37
|
+
// ensureClaudeWorkspace so the bridge can rewrite it when platform-config
|
|
38
|
+
// capabilities heat-update — the file is read via --append-system-prompt-file
|
|
39
|
+
// on every claude spawn, so a rewrite reaches the next turn without a
|
|
40
|
+
// workspace rebuild.
|
|
41
|
+
export function writeClaudeSystemPrompt(
|
|
42
|
+
workspaceDir: string,
|
|
43
|
+
agentIdentity?: AgentIdentity,
|
|
44
|
+
capabilityFragments?: string[],
|
|
45
|
+
) {
|
|
46
|
+
// Throws on failure BY DESIGN — tolerance is a property of the CALLER, not
|
|
47
|
+
// this function. At boot (ensureClaudeWorkspace) the prompt is mandatory
|
|
48
|
+
// (every claude spawn passes it via --append-system-prompt-file), so a write
|
|
49
|
+
// failure must fail startup loudly for recovery, not degrade to an agent
|
|
50
|
+
// running without its platform instructions. The refresh hot path (index.ts)
|
|
51
|
+
// wraps this in try/catch instead, so a transient failure there degrades to a
|
|
52
|
+
// warning and the next refresh retries (the rewrite is self-healing).
|
|
53
|
+
const parallDir = path.join(workspaceDir, '.parall');
|
|
54
|
+
fs.mkdirSync(parallDir, { recursive: true });
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(parallDir, 'system-prompt.md'),
|
|
57
|
+
buildClaudeSystemPrompt(workspaceDir, agentIdentity, capabilityFragments),
|
|
58
|
+
'utf8',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
26
61
|
|
|
62
|
+
export function ensureClaudeWorkspace(
|
|
63
|
+
workspaceDir: string,
|
|
64
|
+
log?: { warn: (msg: string) => void },
|
|
65
|
+
agentIdentity?: AgentIdentity,
|
|
66
|
+
capabilityFragments?: string[],
|
|
67
|
+
) {
|
|
27
68
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
28
69
|
fs.mkdirSync(path.join(workspaceDir, '.claude'), { recursive: true });
|
|
29
70
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
71
|
+
// Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
|
|
72
|
+
// without the mandatory system prompt. The refresh path tolerates failure.
|
|
73
|
+
writeClaudeSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
|
|
33
74
|
|
|
34
|
-
writeSkillFiles(path.join(
|
|
75
|
+
writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
|
|
35
76
|
ensureLocalAttachmentGitExclude(workspaceDir);
|
|
36
77
|
}
|