agent-relay-runner 0.91.3 → 0.92.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/package.json +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapters/claude.ts +22 -26
- package/src/adapters/codex.ts +23 -1
- package/src/claude-prompt-gates.ts +268 -0
- package/src/launch-assembly.ts +58 -19
- package/src/profile-home.ts +16 -18
- package/src/profile-projection.ts +23 -5
- package/src/provisioning.ts +83 -1
- package/src/rate-limit.ts +0 -33
- package/src/relay-injection-events.ts +6 -0
- package/src/runner-core.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.92.0",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.72"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
package/src/adapters/claude.ts
CHANGED
|
@@ -9,10 +9,11 @@ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
|
9
9
|
import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
|
|
10
10
|
import { collectClaudeSessionEvents } from "./claude-transcript";
|
|
11
11
|
import type { SessionEvent } from "../session-insights";
|
|
12
|
-
import { prepareClaudeProfileHome,
|
|
12
|
+
import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
|
|
13
13
|
import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
|
|
14
14
|
import { claudeProviderMessageText } from "./claude-delivery";
|
|
15
|
-
import { buildRateLimitProviderState,
|
|
15
|
+
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
16
|
+
import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
|
|
16
17
|
|
|
17
18
|
export class ClaudeAdapter implements ProviderAdapter {
|
|
18
19
|
readonly provider = "claude";
|
|
@@ -24,10 +25,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
24
25
|
private tmuxWatcher?: Timer;
|
|
25
26
|
private turnWatcher?: Timer;
|
|
26
27
|
private modelUnavailableReported = false;
|
|
27
|
-
|
|
28
|
-
// it + emits the hold once (not every 2s tick). Cleared when the modal leaves the pane.
|
|
29
|
-
private rateLimitReported = false;
|
|
30
|
-
private rateLimitPaneDetections = 0;
|
|
28
|
+
private promptGatePaneState: ClaudePromptGatePaneState = initialClaudePromptGatePaneState();
|
|
31
29
|
|
|
32
30
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
|
|
33
31
|
this.statusCb = cb;
|
|
@@ -223,7 +221,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
223
221
|
// Create + bootstrap + auth-link the isolated config home (no-op for host base) so the
|
|
224
222
|
// assembler's materialize step has somewhere to write the provisioned skills/plugins.
|
|
225
223
|
prepareClaudeProfileHome(config);
|
|
226
|
-
const defaultArgs =
|
|
224
|
+
const defaultArgs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
|
|
227
225
|
// #557 — ONE assembler owns the provisioning/vanilla/instruction launch surface
|
|
228
226
|
// (--setting-sources, --plugin-dir, --mcp-config, status-line, --append-system-prompt);
|
|
229
227
|
// profile-projection describes the SAME assembled result, so report == launch. The
|
|
@@ -236,7 +234,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
236
234
|
// Isolated profiles run their own CLAUDE_CONFIG_DIR and must never route through
|
|
237
235
|
// claude-rig — claude-rig resolves host rig config (and a bare `claude-rig` with no
|
|
238
236
|
// rig key just errors out). Only host-base profiles may use claude-rig.
|
|
239
|
-
const usesClaudeRig = isClaudeRig &&
|
|
237
|
+
const usesClaudeRig = isClaudeRig && profileUsesClaudeHostProviderGlobals(config);
|
|
240
238
|
const bypassRigDefaults = config.headless && usesClaudeRig && config.approvalMode !== "open";
|
|
241
239
|
const rigPrefix = usesClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
|
|
242
240
|
const command = !usesClaudeRig || bypassRigDefaults || (!config.rig && !findClaudeRigRC(config.cwd))
|
|
@@ -309,6 +307,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
309
307
|
private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
|
|
310
308
|
const { sessionName, socketName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
|
|
311
309
|
this.modelUnavailableReported = false;
|
|
310
|
+
this.promptGatePaneState = initialClaudePromptGatePaneState();
|
|
312
311
|
|
|
313
312
|
Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
|
|
314
313
|
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
@@ -370,30 +369,27 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
370
369
|
this.statusCb(status);
|
|
371
370
|
return;
|
|
372
371
|
}
|
|
373
|
-
// #
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
this.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
this.
|
|
384
|
-
this.statusCb(rateLimit.status);
|
|
372
|
+
// #663: prompt-gate fallback. Known gates should be prevented before launch;
|
|
373
|
+
// this catches missed/new blocking panes with the same #655 debounce + busy
|
|
374
|
+
// guard used for rate-limit holds.
|
|
375
|
+
const promptGate = evaluateClaudePromptGatePane(pane, this.promptGatePaneState, {
|
|
376
|
+
sessionName,
|
|
377
|
+
busy: claudePaneIsBusy(pane),
|
|
378
|
+
});
|
|
379
|
+
this.promptGatePaneState = promptGate.state;
|
|
380
|
+
if (promptGate.action) {
|
|
381
|
+
this.sendPromptGateKeystroke(sessionName, promptGate.action.keystroke, socketName);
|
|
382
|
+
if (promptGate.status) this.statusCb(promptGate.status);
|
|
385
383
|
}
|
|
386
384
|
}, 2000);
|
|
387
385
|
}
|
|
388
386
|
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
// a failed send-keys must never wedge the pane watcher.
|
|
392
|
-
private dismissRateLimitModal(sessionName: string, socketName?: string): void {
|
|
387
|
+
// Best-effort: a failed auto-dismiss must not wedge the pane watcher.
|
|
388
|
+
private sendPromptGateKeystroke(sessionName: string, keystroke: string, socketName?: string): void {
|
|
393
389
|
try {
|
|
394
|
-
Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName,
|
|
390
|
+
Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, keystroke), { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
395
391
|
} catch {
|
|
396
|
-
// ignore
|
|
392
|
+
// ignore; the timeline/status still publishes, and the next watcher tick can observe whether the pane moved.
|
|
397
393
|
}
|
|
398
394
|
}
|
|
399
395
|
|
package/src/adapters/codex.ts
CHANGED
|
@@ -101,7 +101,29 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
101
101
|
const client = process.meta?.client as CodexAppClient | undefined;
|
|
102
102
|
if (!client?.isConnected()) return "unknown";
|
|
103
103
|
const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
|
|
104
|
-
|
|
104
|
+
// #651: when no local activeTurnId, don't short-circuit to idle — query the app-server.
|
|
105
|
+
// turn/started can arrive after processing begins (or be missed entirely); a stale-idle
|
|
106
|
+
// probe would make a busy agent appear idle to the coordinator. Same fallback as #653's
|
|
107
|
+
// ensureMainTurnActive path, but for the pull (probe) direction, not the push (event) one.
|
|
108
|
+
if (!this.activeTurnId) {
|
|
109
|
+
if (!threadId) return "idle";
|
|
110
|
+
try {
|
|
111
|
+
const read = await client.threadRead(threadId, true);
|
|
112
|
+
const thread = isRecord(read.thread) ? read.thread : undefined;
|
|
113
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : [];
|
|
114
|
+
const inProgressTurn = turns.find((t) => isRecord(t) && stringValue(t.status) === "inProgress");
|
|
115
|
+
if (inProgressTurn) {
|
|
116
|
+
// Synthesize the active turn (same path as ensureMainTurnActive on item events).
|
|
117
|
+
this.ensureMainTurnActive(stringValue(inProgressTurn.id));
|
|
118
|
+
return "busy";
|
|
119
|
+
}
|
|
120
|
+
const threadStatus = statusType(thread?.status);
|
|
121
|
+
if (threadStatus === "active") return "busy";
|
|
122
|
+
return "idle";
|
|
123
|
+
} catch {
|
|
124
|
+
return "unknown";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
105
127
|
if (!threadId) return "busy";
|
|
106
128
|
try {
|
|
107
129
|
const read = await client.threadRead(threadId, true);
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
|
+
import type { ProviderStatusUpdate, RunnerSpawnConfig } from "./adapter";
|
|
4
|
+
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "./rate-limit";
|
|
5
|
+
|
|
6
|
+
export interface ClaudeConfigPromptGatePrevention {
|
|
7
|
+
id: string;
|
|
8
|
+
description: string;
|
|
9
|
+
apply(seed: Record<string, unknown>, context: { host?: Record<string, unknown>; config: RunnerSpawnConfig }): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ClaudeLaunchPromptGatePrevention {
|
|
13
|
+
id: string;
|
|
14
|
+
description: string;
|
|
15
|
+
apply(config: RunnerSpawnConfig): { strictMcpConfig?: boolean };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PaneGateMatch = { message?: string; resetAt?: number; kind?: "usage_limit" | "transient_overload"; errorType?: string };
|
|
19
|
+
|
|
20
|
+
interface ClaudePanePromptGate {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
policy: string;
|
|
24
|
+
keystroke: string;
|
|
25
|
+
minDetections: number;
|
|
26
|
+
match(text: string, nowMs?: number): PaneGateMatch | null;
|
|
27
|
+
providerState?(match: PaneGateMatch, context: ClaudePromptGatePaneContext): Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClaudePromptGatePaneState {
|
|
31
|
+
currentGateId?: string;
|
|
32
|
+
consecutiveDetections: number;
|
|
33
|
+
handledGateId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ClaudePromptGatePaneContext {
|
|
37
|
+
sessionName?: string;
|
|
38
|
+
busy?: boolean;
|
|
39
|
+
nowMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ClaudePromptGateAction {
|
|
43
|
+
gateId: string;
|
|
44
|
+
label: string;
|
|
45
|
+
policy: string;
|
|
46
|
+
keystroke: string;
|
|
47
|
+
source: "claude-pane";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ClaudePromptGatePaneResult {
|
|
51
|
+
action?: ClaudePromptGateAction;
|
|
52
|
+
status?: ProviderStatusUpdate;
|
|
53
|
+
state: ClaudePromptGatePaneState;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const claudeConfigPromptGatePreventions: ClaudeConfigPromptGatePrevention[] = [
|
|
57
|
+
{
|
|
58
|
+
id: "first-run-onboarding",
|
|
59
|
+
description: "Pre-accept Claude Code's first-run onboarding gate.",
|
|
60
|
+
apply(seed, { host }) {
|
|
61
|
+
seed.hasCompletedOnboarding = true;
|
|
62
|
+
if (typeof host?.lastOnboardingVersion === "string") seed.lastOnboardingVersion = host.lastOnboardingVersion;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "theme-selection",
|
|
67
|
+
description: "Pre-seed a theme so the first-run theme picker does not render.",
|
|
68
|
+
apply(seed, { host }) {
|
|
69
|
+
seed.theme = typeof host?.theme === "string" ? host.theme : "dark";
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "bypass-permissions-mode",
|
|
74
|
+
description: "Pre-accept Claude's open-approval bypass-permissions modal.",
|
|
75
|
+
apply(seed, { config }) {
|
|
76
|
+
if (config.approvalMode === "open") seed.bypassPermissionsModeAccepted = true;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "workspace-trust",
|
|
81
|
+
description: "Trust the managed spawn cwd and clear project onboarding.",
|
|
82
|
+
apply(seed, { config }) {
|
|
83
|
+
if (!config.cwd) return;
|
|
84
|
+
seed.projects = {
|
|
85
|
+
...(isRecord(seed.projects) ? seed.projects : {}),
|
|
86
|
+
[resolve(config.cwd)]: { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export const claudeLaunchPromptGatePreventions: ClaudeLaunchPromptGatePrevention[] = [
|
|
93
|
+
{
|
|
94
|
+
id: "project-mcp-discovery",
|
|
95
|
+
description: "Use strict controlled MCP config so Claude never prompts for ambient project .mcp.json servers.",
|
|
96
|
+
apply() {
|
|
97
|
+
return { strictMcpConfig: true };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const panePromptGates: ClaudePanePromptGate[] = [
|
|
103
|
+
{
|
|
104
|
+
id: "usage-limit",
|
|
105
|
+
label: "Usage limit",
|
|
106
|
+
policy: "wait-for-reset",
|
|
107
|
+
keystroke: "Escape",
|
|
108
|
+
minDetections: 2,
|
|
109
|
+
match: (text, nowMs) => parseClaudeRateLimitPane(text, nowMs),
|
|
110
|
+
providerState(match, context) {
|
|
111
|
+
return buildRateLimitProviderState({
|
|
112
|
+
errorType: match.errorType ?? "session_limit",
|
|
113
|
+
kind: match.kind ?? "usage_limit",
|
|
114
|
+
...(match.resetAt ? { resetAt: match.resetAt } : {}),
|
|
115
|
+
message: match.message,
|
|
116
|
+
source: context.sessionName ? `claude-pane:${context.sessionName}` : "claude-pane",
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "workspace-trust",
|
|
122
|
+
label: "Workspace trust",
|
|
123
|
+
policy: "accept-trusted-workspace",
|
|
124
|
+
keystroke: "Enter",
|
|
125
|
+
minDetections: 2,
|
|
126
|
+
match: (text) => /do you trust (?:the files in )?(?:this folder|this project|this directory)/i.test(text)
|
|
127
|
+
&& /\b(?:yes|proceed|trust|continue)\b/i.test(text)
|
|
128
|
+
? { message: lineMatching(text, /do you trust/i) }
|
|
129
|
+
: null,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "bypass-permissions-mode",
|
|
133
|
+
label: "Bypass permissions mode",
|
|
134
|
+
policy: "accept-configured-open-approval",
|
|
135
|
+
keystroke: "Enter",
|
|
136
|
+
minDetections: 2,
|
|
137
|
+
match: (text) => /bypass permissions mode/i.test(text) && /\b(?:accept|proceed|continue|yes)\b/i.test(text)
|
|
138
|
+
? { message: lineMatching(text, /bypass permissions mode/i) }
|
|
139
|
+
: null,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "project-mcp-discovery",
|
|
143
|
+
label: "Project MCP discovery",
|
|
144
|
+
policy: "reject-ambient-project-mcp",
|
|
145
|
+
keystroke: "Escape",
|
|
146
|
+
minDetections: 2,
|
|
147
|
+
match: (text) => /\bnew MCP servers? found\b/i.test(text) && /\b(?:select|enable|servers?)\b/i.test(text)
|
|
148
|
+
? { message: lineMatching(text, /new MCP servers? found/i) }
|
|
149
|
+
: null,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "theme-selection",
|
|
153
|
+
label: "Theme selection",
|
|
154
|
+
policy: "accept-default-theme",
|
|
155
|
+
keystroke: "Enter",
|
|
156
|
+
minDetections: 2,
|
|
157
|
+
match: (text) => /choose (?:the )?(?:text style|theme).*Claude Code/i.test(text) && /\b(?:dark|light) mode\b/i.test(text)
|
|
158
|
+
? { message: lineMatching(text, /choose/i) }
|
|
159
|
+
: null,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "first-run-onboarding",
|
|
163
|
+
label: "First-run onboarding",
|
|
164
|
+
policy: "accept-onboarding",
|
|
165
|
+
keystroke: "Enter",
|
|
166
|
+
minDetections: 2,
|
|
167
|
+
match: (text) => /welcome to Claude Code/i.test(text) && /\b(?:let'?s get started|continue|get started)\b/i.test(text)
|
|
168
|
+
? { message: lineMatching(text, /welcome to Claude Code/i) }
|
|
169
|
+
: null,
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
export function applyClaudeConfigPromptGatePreventions(
|
|
174
|
+
seed: Record<string, unknown>,
|
|
175
|
+
host: Record<string, unknown> | undefined,
|
|
176
|
+
config: RunnerSpawnConfig,
|
|
177
|
+
): Record<string, unknown> {
|
|
178
|
+
const next = { ...seed };
|
|
179
|
+
for (const prevention of claudeConfigPromptGatePreventions) prevention.apply(next, { host, config });
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function claudeLaunchPromptGateSettings(config: RunnerSpawnConfig): { strictMcpConfig: boolean } {
|
|
184
|
+
return {
|
|
185
|
+
strictMcpConfig: claudeLaunchPromptGatePreventions.some((prevention) => prevention.apply(config).strictMcpConfig === true),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function initialClaudePromptGatePaneState(): ClaudePromptGatePaneState {
|
|
190
|
+
return { consecutiveDetections: 0 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function evaluateClaudePromptGatePane(
|
|
194
|
+
text: string,
|
|
195
|
+
state: ClaudePromptGatePaneState,
|
|
196
|
+
context: ClaudePromptGatePaneContext = {},
|
|
197
|
+
): ClaudePromptGatePaneResult {
|
|
198
|
+
const detected = firstDetectedPaneGate(text, context.nowMs);
|
|
199
|
+
if (!detected) return { state: initialClaudePromptGatePaneState() };
|
|
200
|
+
if (context.busy) {
|
|
201
|
+
return { state: { ...state, currentGateId: undefined, consecutiveDetections: 0 } };
|
|
202
|
+
}
|
|
203
|
+
if (state.handledGateId === detected.entry.id) {
|
|
204
|
+
return { state };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const consecutiveDetections = state.currentGateId === detected.entry.id ? state.consecutiveDetections + 1 : 1;
|
|
208
|
+
const nextState: ClaudePromptGatePaneState = {
|
|
209
|
+
currentGateId: detected.entry.id,
|
|
210
|
+
consecutiveDetections,
|
|
211
|
+
...(state.handledGateId ? { handledGateId: state.handledGateId } : {}),
|
|
212
|
+
};
|
|
213
|
+
if (consecutiveDetections < detected.entry.minDetections) return { state: nextState };
|
|
214
|
+
|
|
215
|
+
const action: ClaudePromptGateAction = {
|
|
216
|
+
gateId: detected.entry.id,
|
|
217
|
+
label: detected.entry.label,
|
|
218
|
+
policy: detected.entry.policy,
|
|
219
|
+
keystroke: detected.entry.keystroke,
|
|
220
|
+
source: "claude-pane",
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
action,
|
|
224
|
+
status: promptGateStatus(detected.entry, detected.match, action, context),
|
|
225
|
+
state: { ...nextState, handledGateId: detected.entry.id },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function firstDetectedPaneGate(text: string, nowMs?: number): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
|
|
230
|
+
for (const entry of panePromptGates) {
|
|
231
|
+
const match = entry.match(text, nowMs);
|
|
232
|
+
if (match) return { entry, match };
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function promptGateStatus(
|
|
238
|
+
entry: ClaudePanePromptGate,
|
|
239
|
+
match: PaneGateMatch,
|
|
240
|
+
action: ClaudePromptGateAction,
|
|
241
|
+
context: ClaudePromptGatePaneContext,
|
|
242
|
+
): ProviderStatusUpdate {
|
|
243
|
+
const metadata = {
|
|
244
|
+
eventType: "claude.prompt_gate.auto_handled",
|
|
245
|
+
gateId: action.gateId,
|
|
246
|
+
policy: action.policy,
|
|
247
|
+
keystroke: action.keystroke,
|
|
248
|
+
source: action.source,
|
|
249
|
+
...(context.sessionName ? { sessionName: context.sessionName } : {}),
|
|
250
|
+
};
|
|
251
|
+
return {
|
|
252
|
+
status: "idle",
|
|
253
|
+
clear: ["subagent"],
|
|
254
|
+
...(entry.providerState ? { providerState: entry.providerState(match, context) } : {}),
|
|
255
|
+
timeline: {
|
|
256
|
+
status: "claude.prompt_gate.auto_handled",
|
|
257
|
+
id: `claude-prompt-gate-${entry.id}-${Date.now()}`,
|
|
258
|
+
timestamp: Date.now(),
|
|
259
|
+
title: "Claude prompt gate auto-handled",
|
|
260
|
+
body: match.message ?? `${entry.label} dismissed with ${entry.keystroke}`,
|
|
261
|
+
metadata,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function lineMatching(text: string, pattern: RegExp): string | undefined {
|
|
267
|
+
return text.split(/\r?\n/).map((line) => line.trim()).find((line) => pattern.test(line));
|
|
268
|
+
}
|
package/src/launch-assembly.ts
CHANGED
|
@@ -7,9 +7,17 @@ import {
|
|
|
7
7
|
type ProviderConfig,
|
|
8
8
|
type RunnerSpawnConfig,
|
|
9
9
|
} from "./adapter";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
claudeProjectConfigProjectionOn,
|
|
12
|
+
hostUserScopeMcpServers,
|
|
13
|
+
profileUsesClaudeHostProviderGlobals,
|
|
14
|
+
providerHomePathFor,
|
|
15
|
+
} from "./profile-home";
|
|
16
|
+
import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
|
|
11
17
|
import {
|
|
12
18
|
materializeResolvedAssets,
|
|
19
|
+
resolveClaudeProjectSkills,
|
|
20
|
+
resolveProjectClaudeSettings,
|
|
13
21
|
resolveClaudeProvisionedPlugins,
|
|
14
22
|
resolveClaudeProvisionedSkills,
|
|
15
23
|
resolveCodexProvisionedSkills,
|
|
@@ -115,6 +123,10 @@ export interface AssembledLaunch {
|
|
|
115
123
|
statusLine: boolean;
|
|
116
124
|
/** Relay-provisioned skills materialized for this launch. */
|
|
117
125
|
skills: AssembledAsset[];
|
|
126
|
+
/** Project `.claude/skills` materialized for this Claude launch (#668). */
|
|
127
|
+
projectSkills: AssembledAsset[];
|
|
128
|
+
/** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
|
|
129
|
+
projectSettings: { applied: boolean; keys: string[]; ignoredKeys: string[]; file?: string };
|
|
118
130
|
/** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
|
|
119
131
|
plugins: AssembledAsset[];
|
|
120
132
|
mcp: AssembledMcp;
|
|
@@ -161,10 +173,27 @@ function hasSettingsArg(args: string[]): boolean {
|
|
|
161
173
|
// --settings is already present (caller wins). Relocated from claude.ts (#557).
|
|
162
174
|
export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
|
|
163
175
|
if (argLists.some(hasSettingsArg)) return [];
|
|
164
|
-
return ["--settings", JSON.stringify(
|
|
176
|
+
return ["--settings", JSON.stringify(relayStatusLineSettings())];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function relayStatusLineSettings(): Record<string, unknown> {
|
|
180
|
+
return {
|
|
165
181
|
statusLine: { type: "command", command: "agent-relay context-probe --wrap", refreshInterval: 30 },
|
|
166
182
|
showThinkingSummaries: true,
|
|
167
|
-
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function claudeSettingsArgs(
|
|
187
|
+
projectSettings: Record<string, unknown>,
|
|
188
|
+
includeStatusLine: boolean,
|
|
189
|
+
...argLists: string[][]
|
|
190
|
+
): string[] {
|
|
191
|
+
if (argLists.some(hasSettingsArg)) return [];
|
|
192
|
+
const settings = {
|
|
193
|
+
...projectSettings,
|
|
194
|
+
...(includeStatusLine ? relayStatusLineSettings() : {}),
|
|
195
|
+
};
|
|
196
|
+
return Object.keys(settings).length ? ["--settings", JSON.stringify(settings)] : [];
|
|
168
197
|
}
|
|
169
198
|
|
|
170
199
|
// ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
|
|
@@ -198,11 +227,16 @@ function assembleInstructions(config: RunnerSpawnConfig): AssembledInstructions
|
|
|
198
227
|
function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]): AssembledLaunch {
|
|
199
228
|
const configHome = providerHomePathFor("claude", config);
|
|
200
229
|
const vanilla = profileIsVanillaBase(config);
|
|
230
|
+
const projectConfigProjection = claudeProjectConfigProjectionOn(config);
|
|
201
231
|
const relayPlugin = profileAllowsRelayFeature(config, "plugins");
|
|
202
232
|
const relaySkills = profileAllowsRelayFeature(config, "skills");
|
|
203
233
|
const statusLine = profileAllowsRelayFeature(config, "statusLine");
|
|
204
234
|
|
|
205
235
|
const skills = configHome ? resolveClaudeProvisionedSkills(configHome, config) : [];
|
|
236
|
+
const projectSkills = configHome
|
|
237
|
+
? resolveClaudeProjectSkills(configHome, config, new Set(skills.map((s) => s.name)))
|
|
238
|
+
: [];
|
|
239
|
+
const projectSettings = resolveProjectClaudeSettings(config);
|
|
206
240
|
const provisionedPlugins = configHome ? resolveClaudeProvisionedPlugins(configHome, config) : [];
|
|
207
241
|
|
|
208
242
|
// Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
|
|
@@ -212,7 +246,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
212
246
|
// host plugin dirs → ONLY host-base profiles; isolated/vanilla must not pull them back in.
|
|
213
247
|
const relayPluginDirs = relayPlugin ? [CLAUDE_RELAY_PLUGIN_DIR] : [];
|
|
214
248
|
const provisionedPluginDirs = provisionedPlugins.map((p) => p.dir);
|
|
215
|
-
const hostPluginDirs =
|
|
249
|
+
const hostPluginDirs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.pluginDirs : [];
|
|
216
250
|
const pluginDirs = [...new Set([...relayPluginDirs, ...provisionedPluginDirs, ...hostPluginDirs])];
|
|
217
251
|
|
|
218
252
|
const provisionedMcp = profileProvisioning(config).mcpServers;
|
|
@@ -247,16 +281,10 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
247
281
|
: {};
|
|
248
282
|
const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
|
|
249
283
|
|
|
250
|
-
// #662 —
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
// decides should load is injected via --mcp-config, which strict PRESERVES; strict only
|
|
255
|
-
// blocks what relay DIDN'T inject — above all the cwd project `.mcp.json` (the modal's
|
|
256
|
-
// source), which stays excluded. The relay plugin (monitor + turn hooks) loads via
|
|
257
|
-
// --plugin-dir and ships no MCP server, so it is untouched. Was `vanilla`-only, which left
|
|
258
|
-
// host/minimal/isolated bases exposed to the modal wedge.
|
|
259
|
-
const strictMcp = true;
|
|
284
|
+
// #662/#663 — strict controlled MCP is a Claude prompt-gate prevention entry, not a
|
|
285
|
+
// launch-assembly special case. It prevents the blocking "N new MCP servers found in
|
|
286
|
+
// this project" modal while preserving every relay-declared server in --mcp-config.
|
|
287
|
+
const strictMcp = claudeLaunchPromptGateSettings(config).strictMcpConfig;
|
|
260
288
|
|
|
261
289
|
// #662 follow-up — strict drops ALL on-disk config, including the host user-scope
|
|
262
290
|
// `~/.claude.json` MCP that a host-base agent loaded ambiently before strict. That
|
|
@@ -265,7 +293,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
265
293
|
// still excluded) when the launch both uses the host config home AND its mcp mode is host.
|
|
266
294
|
// An isolated/minimal base has its own config home and never had the real host's user MCP,
|
|
267
295
|
// so it stays relay+provisioned only even if a profile sets mode:"host".
|
|
268
|
-
const usesHostMcp =
|
|
296
|
+
const usesHostMcp = profileUsesClaudeHostProviderGlobals(config)
|
|
269
297
|
&& (!config.agentProfile || config.agentProfile.mcp.mode === "host");
|
|
270
298
|
const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
|
|
271
299
|
const hostMcpServers = Object.keys(hostMcp).filter(
|
|
@@ -277,7 +305,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
277
305
|
// Launch order matches the historical claude buildSpawnArgs block exactly so existing
|
|
278
306
|
// arg assertions hold: vanilla scope → plugin dirs → MCP → status-line → append.
|
|
279
307
|
const args = [
|
|
280
|
-
...(vanilla ? ["--setting-sources", "user"] : []),
|
|
308
|
+
...(vanilla || projectConfigProjection ? ["--setting-sources", "user"] : []),
|
|
281
309
|
...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
|
|
282
310
|
...claudeMcpLaunchArgs({
|
|
283
311
|
relayUrl: config.relayUrl,
|
|
@@ -287,7 +315,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
287
315
|
hostServers: hostMcp,
|
|
288
316
|
strict: strictMcp,
|
|
289
317
|
}),
|
|
290
|
-
...(statusLine
|
|
318
|
+
...claudeSettingsArgs(projectSettings.settings, statusLine, hostDefaultArgs, config.providerArgs),
|
|
291
319
|
...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
|
|
292
320
|
];
|
|
293
321
|
|
|
@@ -302,6 +330,13 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
302
330
|
relayPlugin,
|
|
303
331
|
statusLine,
|
|
304
332
|
skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
|
|
333
|
+
projectSkills: projectSkills.map((s) => ({ name: s.name, dir: s.dir })),
|
|
334
|
+
projectSettings: {
|
|
335
|
+
applied: projectSettings.applied,
|
|
336
|
+
keys: projectSettings.keys,
|
|
337
|
+
ignoredKeys: projectSettings.ignoredKeys,
|
|
338
|
+
...(projectSettings.file ? { file: projectSettings.file } : {}),
|
|
339
|
+
},
|
|
305
340
|
plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
|
|
306
341
|
mcp: {
|
|
307
342
|
relayEndpoint, strict: strictMcp, servers: mcpServers, projectServers, hostServers: hostMcpServers,
|
|
@@ -371,6 +406,8 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
371
406
|
relayPlugin: false,
|
|
372
407
|
statusLine: false,
|
|
373
408
|
skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
|
|
409
|
+
projectSkills: [],
|
|
410
|
+
projectSettings: { applied: false, keys: [], ignoredKeys: [] },
|
|
374
411
|
plugins: [],
|
|
375
412
|
mcp: {
|
|
376
413
|
relayEndpoint, strict: false, servers: mcpServers, projectServers, hostServers: [],
|
|
@@ -389,7 +426,7 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
389
426
|
*/
|
|
390
427
|
export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
|
|
391
428
|
if (provider === "codex") return assembleCodex(config, providerConfig);
|
|
392
|
-
const hostDefaultArgs =
|
|
429
|
+
const hostDefaultArgs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
|
|
393
430
|
return assembleClaude(config, providerConfig, hostDefaultArgs);
|
|
394
431
|
}
|
|
395
432
|
|
|
@@ -403,7 +440,9 @@ export function materializeLaunchAssembly(provider: SpawnProvider, config: Runne
|
|
|
403
440
|
const configHome = providerHomePathFor(provider, config);
|
|
404
441
|
if (!configHome) return;
|
|
405
442
|
if (provider === "claude") {
|
|
406
|
-
|
|
443
|
+
const skills = resolveClaudeProvisionedSkills(configHome, config);
|
|
444
|
+
materializeResolvedAssets(skills);
|
|
445
|
+
materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set(skills.map((s) => s.name))));
|
|
407
446
|
materializeResolvedAssets(resolveClaudeProvisionedPlugins(configHome, config));
|
|
408
447
|
return;
|
|
409
448
|
}
|
package/src/profile-home.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ProvisioningMcpServer } from "agent-relay-sdk";
|
|
|
6
6
|
import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
|
|
7
7
|
import { CLAUDE_RELAY_MANUAL } from "./relay-instructions";
|
|
8
8
|
import { providerHomeRootFromEnv } from "./config";
|
|
9
|
+
import { applyClaudeConfigPromptGatePreventions } from "./claude-prompt-gates";
|
|
9
10
|
|
|
10
11
|
type ProviderHome = {
|
|
11
12
|
path: string;
|
|
@@ -16,8 +17,18 @@ export function profileUsesHostProviderGlobals(config: { agentProfile?: RunnerSp
|
|
|
16
17
|
return !config.agentProfile || config.agentProfile.base === "host";
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
function
|
|
20
|
-
return Boolean(config.agentProfile
|
|
20
|
+
export function claudeProjectConfigProjectionOn(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
|
|
21
|
+
return Boolean(config.agentProfile?.projectSkills || config.agentProfile?.projectSettings);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function profileUsesClaudeHostProviderGlobals(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
|
|
25
|
+
return profileUsesHostProviderGlobals(config) && !claudeProjectConfigProjectionOn(config);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function profileRequiresIsolatedHome(provider: "claude" | "codex", config: RunnerSpawnConfig): boolean {
|
|
29
|
+
if (!config.agentProfile) return false;
|
|
30
|
+
if (config.agentProfile.base !== "host") return true;
|
|
31
|
+
return provider === "claude" && claudeProjectConfigProjectionOn(config);
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
// #557 — PURE: the isolated provider config home path a launch WILL use, or undefined
|
|
@@ -25,7 +36,7 @@ function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
|
|
|
25
36
|
// computes the home this way to report it + build CLAUDE_CONFIG_DIR/skill/plugin paths
|
|
26
37
|
// without side effects; prepare*ProfileHome creates it at the same path at spawn time.
|
|
27
38
|
export function providerHomePathFor(provider: "claude" | "codex", config: RunnerSpawnConfig): string | undefined {
|
|
28
|
-
if (!profileRequiresIsolatedHome(config)) return undefined;
|
|
39
|
+
if (!profileRequiresIsolatedHome(provider, config)) return undefined;
|
|
29
40
|
return providerHomePath(provider, config);
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -56,7 +67,7 @@ const CLAUDE_AUTH_ITEMS = [".credentials.json", "statsig"];
|
|
|
56
67
|
// instance-keyed home, run the provider-specific first-run bootstrap. The
|
|
57
68
|
// bootstrap step is the only genuinely provider-specific part.
|
|
58
69
|
function prepareProviderHome(provider: "claude" | "codex", config: RunnerSpawnConfig): ProviderHome | undefined {
|
|
59
|
-
if (!profileRequiresIsolatedHome(config)) return undefined;
|
|
70
|
+
if (!profileRequiresIsolatedHome(provider, config)) return undefined;
|
|
60
71
|
const target = providerHomePath(provider, config);
|
|
61
72
|
mkdirSync(target, { recursive: true });
|
|
62
73
|
const authLinked = provider === "codex"
|
|
@@ -91,20 +102,7 @@ function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig
|
|
|
91
102
|
const path = join(claudeHome, ".claude.json");
|
|
92
103
|
if (existsSync(path)) return;
|
|
93
104
|
const host = readHostClaudeConfig();
|
|
94
|
-
const seed
|
|
95
|
-
hasCompletedOnboarding: true,
|
|
96
|
-
theme: typeof host?.theme === "string" ? host.theme : "dark",
|
|
97
|
-
};
|
|
98
|
-
if (typeof host?.lastOnboardingVersion === "string") seed.lastOnboardingVersion = host.lastOnboardingVersion;
|
|
99
|
-
// Open approval = headless launch adds --dangerously-skip-permissions, which has
|
|
100
|
-
// its OWN first-run gate ("Bypass Permissions mode" accept/exit). Pre-accept it,
|
|
101
|
-
// or the agent stalls there exactly like the onboarding/trust gates.
|
|
102
|
-
if (config.approvalMode === "open") seed.bypassPermissionsModeAccepted = true;
|
|
103
|
-
if (config.cwd) {
|
|
104
|
-
seed.projects = {
|
|
105
|
-
[resolve(config.cwd)]: { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
|
|
106
|
-
};
|
|
107
|
-
}
|
|
105
|
+
const seed = applyClaudeConfigPromptGatePreventions({}, host, config);
|
|
108
106
|
writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
|
|
109
107
|
}
|
|
110
108
|
|
|
@@ -34,6 +34,8 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
|
|
|
34
34
|
add(provisioningPluginsEntry(assembled));
|
|
35
35
|
add(provisioningMcpEntry(assembled));
|
|
36
36
|
add(projectMcpEntry(assembled));
|
|
37
|
+
add(projectSkillsEntry(assembled));
|
|
38
|
+
add(projectSettingsEntry(assembled));
|
|
37
39
|
add(sharedMcpEntry(assembled));
|
|
38
40
|
if (profile) add(filesystemEntry(profile));
|
|
39
41
|
add(configHomeEntry(assembled));
|
|
@@ -159,14 +161,30 @@ function projectMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
|
159
161
|
: `Project .mcp.json servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
function projectSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
165
|
+
const optedIn = Boolean(a.profile?.projectSkills);
|
|
166
|
+
if (!optedIn) return notApplicable("projectSkills", "off", "Project .claude/skills projection is off (opt in via the projectSkills profile/spawn option).");
|
|
167
|
+
if (a.provider !== "claude") return notApplicable("projectSkills", "unsupported", "Project .claude/skills projection applies to Claude launches only.");
|
|
168
|
+
if (!a.projectSkills.length) return applied("projectSkills", "none", "Project .claude/skills projection is on, but the spawn cwd declared no valid skill dirs.");
|
|
169
|
+
const names = a.projectSkills.map((s) => s.name).join(", ");
|
|
170
|
+
return applied("projectSkills", `${a.projectSkills.length}`, `Project .claude skills materialized into Relay's managed Claude config home (${names}); host/global skills are excluded.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function projectSettingsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
174
|
+
const optedIn = Boolean(a.profile?.projectSettings);
|
|
175
|
+
if (!optedIn) return notApplicable("projectSettings", "off", "Project .claude/settings.json projection is off (opt in via the projectSettings profile/spawn option).");
|
|
176
|
+
if (a.provider !== "claude") return notApplicable("projectSettings", "unsupported", "Project .claude/settings.json projection applies to Claude launches only.");
|
|
177
|
+
const ignored = a.projectSettings.ignoredKeys.length ? `; ignored security keys: ${a.projectSettings.ignoredKeys.join(", ")}` : "";
|
|
178
|
+
return applied("projectSettings", a.projectSettings.keys.length ? `${a.projectSettings.keys.length} keys` : "none", `Project .claude/settings.json was sanitized and injected through --settings; Relay approvalMode/permission gates remain authoritative${ignored}.`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// #672/#689 — shared host-listener MCP provenance. `applied` reports the per-agent `callmux
|
|
182
|
+
// bridge` route. `not-applicable` means the resolved profile kept shared MCP off (for example a
|
|
183
|
+
// zero-host profile, explicit false, or global kill-switch normalization before launch).
|
|
166
184
|
function sharedMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
167
185
|
const optedIn = Boolean(a.profile?.sharedMcp);
|
|
168
186
|
const listener = a.mcp.sharedListener;
|
|
169
|
-
if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off
|
|
187
|
+
if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off for this resolved profile; non-identity MCP is injected per-agent.");
|
|
170
188
|
return applied("sharedMcp", "on", a.provider === "claude"
|
|
171
189
|
? `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"', injected into --mcp-config (survives --strict-mcp-config); session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`
|
|
172
190
|
: `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"' emitted as -c mcp_servers.* overrides; session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`);
|
package/src/provisioning.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
4
4
|
import type { ProvisioningAssetFile, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
|
|
@@ -74,6 +74,88 @@ export function resolveProjectMcpServers(
|
|
|
74
74
|
return out;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function resolveClaudeProjectSkills(
|
|
78
|
+
claudeHome: string,
|
|
79
|
+
config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
|
|
80
|
+
reservedNames: ReadonlySet<string> = new Set(),
|
|
81
|
+
): ResolvedAsset[] {
|
|
82
|
+
if (!config.agentProfile?.projectSkills) return [];
|
|
83
|
+
const skillsRoot = join(config.cwd, ".claude", "skills");
|
|
84
|
+
if (!existsSync(skillsRoot)) return [];
|
|
85
|
+
const targetRoot = join(claudeHome, "skills");
|
|
86
|
+
const resolved: ResolvedAsset[] = [];
|
|
87
|
+
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
88
|
+
if (!entry.isDirectory() || reservedNames.has(entry.name)) continue;
|
|
89
|
+
const source = join(skillsRoot, entry.name);
|
|
90
|
+
if (!existsSync(join(source, "SKILL.md"))) continue;
|
|
91
|
+
resolved.push({
|
|
92
|
+
name: entry.name,
|
|
93
|
+
dir: join(targetRoot, assetDirName(entry.name)),
|
|
94
|
+
source: { kind: "link", source },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return resolved.sort((a, b) => a.name.localeCompare(b.name));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const UNSAFE_PROJECT_CLAUDE_SETTINGS = new Set([
|
|
101
|
+
"apiKeyHelper",
|
|
102
|
+
"allowDangerouslySkipPermissions",
|
|
103
|
+
"allowedTools",
|
|
104
|
+
"disabledMcpjsonServers",
|
|
105
|
+
"disallowedTools",
|
|
106
|
+
"enableAllProjectMcpServers",
|
|
107
|
+
"enabledMcpjsonServers",
|
|
108
|
+
"env",
|
|
109
|
+
"hooks",
|
|
110
|
+
"mcpServers",
|
|
111
|
+
"permissionMode",
|
|
112
|
+
"permissions",
|
|
113
|
+
"statusLine",
|
|
114
|
+
"tools",
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
export interface ResolvedProjectClaudeSettings {
|
|
118
|
+
applied: boolean;
|
|
119
|
+
settings: Record<string, unknown>;
|
|
120
|
+
keys: string[];
|
|
121
|
+
ignoredKeys: string[];
|
|
122
|
+
file?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveProjectClaudeSettings(
|
|
126
|
+
config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
|
|
127
|
+
): ResolvedProjectClaudeSettings {
|
|
128
|
+
if (!config.agentProfile?.projectSettings) return { applied: false, settings: {}, keys: [], ignoredKeys: [] };
|
|
129
|
+
const file = join(config.cwd, ".claude", "settings.json");
|
|
130
|
+
if (!existsSync(file)) return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
|
|
131
|
+
let parsed: unknown;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.warn(`project .claude/settings.json projection: failed to parse ${file}: ${(err as Error).message}`);
|
|
136
|
+
return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
|
|
137
|
+
}
|
|
138
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
139
|
+
return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
|
|
140
|
+
}
|
|
141
|
+
const settings: Record<string, unknown> = {};
|
|
142
|
+
const ignoredKeys: string[] = [];
|
|
143
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
144
|
+
if (UNSAFE_PROJECT_CLAUDE_SETTINGS.has(key)) {
|
|
145
|
+
ignoredKeys.push(key);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
settings[key] = value;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
applied: true,
|
|
152
|
+
settings,
|
|
153
|
+
keys: Object.keys(settings).sort(),
|
|
154
|
+
ignoredKeys: ignoredKeys.sort(),
|
|
155
|
+
file,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
77
159
|
// A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
|
|
78
160
|
// (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
|
|
79
161
|
// source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// #286 — shared rate-limit hold construction + Claude pane detection.
|
|
2
|
-
import type { ProviderStatusUpdate } from "./adapter";
|
|
3
2
|
//
|
|
4
3
|
// Two detection arms feed the SAME provider-neutral `blocked` hold:
|
|
5
4
|
// 1. The StopFailure hook (clean API-error turn-end: API-429/auth/billing) → control-server.
|
|
@@ -126,38 +125,6 @@ function parseClaudeTransientOverloadPane(text: string): PaneRateLimit | null {
|
|
|
126
125
|
};
|
|
127
126
|
}
|
|
128
127
|
|
|
129
|
-
export interface ClaudeRateLimitPaneGateState {
|
|
130
|
-
consecutiveDetections: number;
|
|
131
|
-
reported: boolean;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function gatedClaudeRateLimitStatus(
|
|
135
|
-
text: string,
|
|
136
|
-
state: ClaudeRateLimitPaneGateState,
|
|
137
|
-
options: { sessionName?: string; busy?: boolean } = {},
|
|
138
|
-
): { status: ProviderStatusUpdate | null; state: ClaudeRateLimitPaneGateState } {
|
|
139
|
-
const parsed = parseClaudeRateLimitPane(text);
|
|
140
|
-
if (!parsed) return { status: null, state: { consecutiveDetections: 0, reported: false } };
|
|
141
|
-
if (options.busy) return { status: null, state: { consecutiveDetections: 0, reported: state.reported } };
|
|
142
|
-
const nextState = { consecutiveDetections: state.consecutiveDetections + 1, reported: state.reported };
|
|
143
|
-
if (nextState.reported || nextState.consecutiveDetections < 2) return { status: null, state: nextState };
|
|
144
|
-
nextState.reported = true;
|
|
145
|
-
return {
|
|
146
|
-
status: {
|
|
147
|
-
status: "idle",
|
|
148
|
-
clear: ["subagent"],
|
|
149
|
-
providerState: buildRateLimitProviderState({
|
|
150
|
-
errorType: parsed.errorType ?? "session_limit",
|
|
151
|
-
kind: parsed.kind ?? "usage_limit",
|
|
152
|
-
...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
|
|
153
|
-
message: parsed.message,
|
|
154
|
-
source: options.sessionName ? `claude-pane:${options.sessionName}` : "claude-pane",
|
|
155
|
-
}),
|
|
156
|
-
},
|
|
157
|
-
state: nextState,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
128
|
// The clean limit line containing the match offset, stripped of box-drawing chrome.
|
|
162
129
|
function limitLineAt(text: string, index: number): string {
|
|
163
130
|
const start = text.lastIndexOf("\n", index) + 1;
|
|
@@ -39,7 +39,9 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
39
39
|
if (!supportsLaunchAssembly(input.provider)) return events;
|
|
40
40
|
const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
|
|
41
41
|
const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
|
|
42
|
+
+ assembled.projectSkills.length
|
|
42
43
|
+ assembled.mcp.projectServers.length
|
|
44
|
+
+ (assembled.projectSettings.applied ? 1 : 0)
|
|
43
45
|
+ (assembled.relaySkills ? 1 : 0)
|
|
44
46
|
+ (assembled.relayPlugin ? 1 : 0)
|
|
45
47
|
+ (assembled.mcp.relayEndpoint ? 1 : 0)
|
|
@@ -51,6 +53,8 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
51
53
|
assembled.statusLine ? "status line" : "",
|
|
52
54
|
assembled.mcp.relayEndpoint ? "relay MCP" : "",
|
|
53
55
|
assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
|
|
56
|
+
assembled.projectSkills.length ? `${assembled.projectSkills.length} project skill${assembled.projectSkills.length === 1 ? "" : "s"}` : "",
|
|
57
|
+
assembled.projectSettings.applied ? "project settings" : "",
|
|
54
58
|
assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
|
|
55
59
|
assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
|
|
56
60
|
assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
|
|
@@ -70,6 +74,8 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
70
74
|
relayPlugin: assembled.relayPlugin,
|
|
71
75
|
statusLine: assembled.statusLine,
|
|
72
76
|
skills: assembled.skills,
|
|
77
|
+
projectSkills: assembled.projectSkills,
|
|
78
|
+
projectSettings: assembled.projectSettings,
|
|
73
79
|
plugins: assembled.plugins,
|
|
74
80
|
mcp: assembled.mcp,
|
|
75
81
|
},
|
package/src/runner-core.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { startControlServer, type ControlServer } from "./control-server";
|
|
|
16
16
|
import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
|
|
17
17
|
import { Outbox, type OutboxRecord } from "./outbox";
|
|
18
18
|
import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
19
|
-
import { profileUsesHostProviderGlobals } from "./profile-home";
|
|
19
|
+
import { profileUsesClaudeHostProviderGlobals, profileUsesHostProviderGlobals } from "./profile-home";
|
|
20
20
|
import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
|
|
21
21
|
import { RelayMcpProxy } from "./relay-mcp-proxy";
|
|
22
22
|
import { runtimeMetadata } from "./version";
|
|
@@ -526,7 +526,7 @@ export class AgentRunner {
|
|
|
526
526
|
private async spawnProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<ManagedProcess> {
|
|
527
527
|
this.providerSessionId = crypto.randomUUID();
|
|
528
528
|
this.lastTranscriptPath = undefined;
|
|
529
|
-
const includeProviderGlobals = profileUsesHostProviderGlobals(this.options);
|
|
529
|
+
const includeProviderGlobals = this.options.provider === "claude" ? profileUsesClaudeHostProviderGlobals(this.options) : profileUsesHostProviderGlobals(this.options);
|
|
530
530
|
const env = {
|
|
531
531
|
...process.env as Record<string, string>,
|
|
532
532
|
...(includeProviderGlobals ? this.options.providerConfig.env : {}),
|