agent-relay-runner 0.11.4 → 0.11.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
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.3"
23
+ "agent-relay-sdk": "0.2.4"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.11.4",
4
+ "version": "0.11.6",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -11,6 +11,18 @@
11
11
  ]
12
12
  }
13
13
  ],
14
+ "PreToolUse": [
15
+ {
16
+ "matcher": "AskUserQuestion",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/permission-request.sh\"",
21
+ "timeout": 900
22
+ }
23
+ ]
24
+ }
25
+ ],
14
26
  "PermissionRequest": [
15
27
  {
16
28
  "hooks": [
@@ -1,6 +1,6 @@
1
1
  import type { Message } from "agent-relay-sdk";
2
2
  import { claudeProviderMessageText } from "../../../src/adapters/claude-delivery";
3
- import { CLAUDE_READ_ONLY_RELAY_CONTEXT, CLAUDE_RELAY_CONTEXT } from "../../../src/relay-instructions";
3
+ import { CLAUDE_READ_ONLY_RELAY_CONTEXT, CLAUDE_RELAY_CONTEXT, workspaceDepsNoteFromEnv } from "../../../src/relay-instructions";
4
4
 
5
5
  const port = process.env.AGENT_RELAY_RUNNER_PORT;
6
6
  if (!port) process.exit(0);
@@ -16,6 +16,11 @@ ws.onmessage = (event) => {
16
16
  deliveryCount += 1;
17
17
  if (firstDelivery) {
18
18
  console.log(process.env.AGENT_RELAY_APPROVAL === "read-only" ? CLAUDE_READ_ONLY_RELAY_CONTEXT : CLAUDE_RELAY_CONTEXT);
19
+ const wsNote = workspaceDepsNoteFromEnv();
20
+ if (wsNote) {
21
+ console.log("");
22
+ console.log(wsNote);
23
+ }
19
24
  console.log("");
20
25
  firstDelivery = false;
21
26
  }
package/src/adapter.ts CHANGED
@@ -89,12 +89,15 @@ export interface TerminalAttachSpec {
89
89
  ttlMs?: number;
90
90
  }
91
91
 
92
- export type ProviderPermissionDecision = "approve" | "approve-session" | "deny" | "abort";
92
+ export type ProviderPermissionDecision = "approve" | "approve-session" | "deny" | "abort" | "answer";
93
93
 
94
94
  export interface ProviderPermissionDecisionInput {
95
95
  approvalId: string;
96
96
  decision: ProviderPermissionDecision;
97
97
  reason?: string;
98
+ // For "answer" decisions (Claude AskUserQuestion): maps each question's text
99
+ // to the chosen option label(s). Multi-select labels are comma-joined.
100
+ answers?: Record<string, string>;
98
101
  }
99
102
 
100
103
  export interface ProviderAdapter {
@@ -3,6 +3,13 @@ import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
4
  import type { ContextState, Message } from "agent-relay-sdk";
5
5
  import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
6
+ import { workspaceDepsNoteFromEnv } from "../relay-instructions";
7
+
8
+ /** Relay context prepended to a Codex agent's first turn: the standard relay
9
+ * blurb plus, when running in an isolated workspace, the deps caveat (#159). */
10
+ function codexRelayContextBlock(): string {
11
+ return [RELAY_CONTEXT, workspaceDepsNoteFromEnv()].filter(Boolean).join("\n\n");
12
+ }
6
13
  import { prepareCodexProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
7
14
  import { CodexAppClient, type ClientEvent } from "./codex-client";
8
15
 
@@ -159,7 +166,7 @@ export class CodexAdapter implements ProviderAdapter {
159
166
  text,
160
167
  ].filter(Boolean).join("\n\n");
161
168
  if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
162
- input = RELAY_CONTEXT + "\n\n" + input;
169
+ input = codexRelayContextBlock() + "\n\n" + input;
163
170
  process.meta = { ...(process.meta ?? {}), relayContextSent: true };
164
171
  }
165
172
  console.error(`[agent-relay] starting Codex initial prompt in thread ${threadId}`);
@@ -171,7 +178,7 @@ export class CodexAdapter implements ProviderAdapter {
171
178
  const threadId = await ensureCodexThread(process);
172
179
  let text = [codexLaunchContext(process), providerMessageText(messages)].filter(Boolean).join("\n\n");
173
180
  if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
174
- text = RELAY_CONTEXT + "\n\n" + text;
181
+ text = codexRelayContextBlock() + "\n\n" + text;
175
182
  process.meta = { ...(process.meta ?? {}), relayContextSent: true };
176
183
  }
177
184
  console.error(codexDeliveryNotice(messages, threadId));
@@ -197,9 +197,25 @@ async function handlePermissionRequest(
197
197
  return Response.json(claudePermissionHookResponse(decision, body));
198
198
  }
199
199
 
200
- function claudePermissionApprovalView(id: string, body: Record<string, unknown>): Record<string, unknown> {
200
+ export function claudePermissionApprovalView(id: string, body: Record<string, unknown>): Record<string, unknown> {
201
201
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
202
202
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
203
+ // AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
204
+ // Surface the structured questions so the dashboard can render a form and
205
+ // return the selections via an "answer" decision (handled in the hook
206
+ // response below as a PreToolUse allow + updatedInput).
207
+ if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
208
+ const count = toolInput.questions.length;
209
+ return {
210
+ id,
211
+ provider: "claude",
212
+ kind: "questions",
213
+ title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
214
+ body: "",
215
+ questions: toolInput.questions,
216
+ choices: [],
217
+ };
218
+ }
203
219
  const command = typeof toolInput.command === "string" ? toolInput.command : "";
204
220
  const description = typeof toolInput.description === "string" ? toolInput.description : "";
205
221
  const bodyText = [
@@ -222,7 +238,30 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
222
238
  };
223
239
  }
224
240
 
225
- function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
241
+ export function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
242
+ // AskUserQuestion comes through a PreToolUse hook. The only way to satisfy it
243
+ // headlessly is permissionDecision "allow" + updatedInput carrying the answers
244
+ // (echoing back the original questions). A bare "allow" is not sufficient, so
245
+ // anything that is not a populated answer is treated as a deny.
246
+ if (body.hook_event_name === "PreToolUse") {
247
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
248
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
249
+ return {
250
+ hookSpecificOutput: {
251
+ hookEventName: "PreToolUse",
252
+ permissionDecision: "allow",
253
+ updatedInput: { ...toolInput, answers: decision.answers },
254
+ },
255
+ };
256
+ }
257
+ return {
258
+ hookSpecificOutput: {
259
+ hookEventName: "PreToolUse",
260
+ permissionDecision: "deny",
261
+ permissionDecisionReason: decision.reason || "Dismissed from Agent Relay dashboard",
262
+ },
263
+ };
264
+ }
226
265
  const hookEventName = "PermissionRequest";
227
266
  if (decision.decision === "approve" || decision.decision === "approve-session") {
228
267
  const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
@@ -23,3 +23,38 @@ export const CLAUDE_READ_ONLY_RELAY_CONTEXT = `${CLAUDE_RELAY_CONTEXT}
23
23
  This Claude session is running with restricted read-only Relay permissions. Do not invoke Agent Relay skills. If you need to reply, use this Bash command shape:
24
24
 
25
25
  agent-relay /reply <messageId> "<your reply>"`;
26
+
27
+ /**
28
+ * Provider-agnostic caveat injected into every spawned agent that runs in an
29
+ * isolated workspace, regardless of which project it is working in. Isolated
30
+ * workspaces are git worktrees whose node_modules are provisioned by the
31
+ * orchestrator (see AGENT_RELAY_WORKSPACE_DEPS): symlinked from the main
32
+ * checkout by default. The agent needs to know this so it doesn't run a clean
33
+ * install that mutates the shared node_modules. Returns "" when no note applies
34
+ * (shared workspace, or deps installed fresh / unknown).
35
+ */
36
+ export function workspaceDepsNote(input: { mode?: string | null; depsMode?: string | null }): string {
37
+ if (input.mode !== "isolated") return "";
38
+ switch (input.depsMode) {
39
+ case "symlink":
40
+ return "[agent-relay] Isolated workspace: this is a git worktree, and its node_modules are SYMLINKED from the main checkout — dependencies are already installed and ready to use. Do NOT run a clean dependency install (`bun install` / `npm install` / `pnpm install`): it writes through the symlink and mutates the main checkout's shared node_modules. Build caches written under node_modules are shared too. If you genuinely need to change dependencies in isolation, ask the host to spawn with AGENT_RELAY_WORKSPACE_DEPS=install.";
41
+ case "none":
42
+ return "[agent-relay] Isolated workspace: dependencies were not provisioned (AGENT_RELAY_WORKSPACE_DEPS=none). You may need to install node_modules before typecheck/test/build work.";
43
+ default:
44
+ return "";
45
+ }
46
+ }
47
+
48
+ /** Resolve the workspace deps caveat from the runner/monitor environment.
49
+ * AGENT_RELAY_WORKSPACE_JSON carries the resolved workspace metadata (mode +
50
+ * deps) and is the authoritative source. Best-effort: never throws. */
51
+ export function workspaceDepsNoteFromEnv(env: Record<string, string | undefined> = process.env): string {
52
+ const json = env.AGENT_RELAY_WORKSPACE_JSON;
53
+ if (!json) return "";
54
+ try {
55
+ const parsed = JSON.parse(json) as { mode?: string; deps?: { mode?: string } };
56
+ return workspaceDepsNote({ mode: parsed.mode ?? null, depsMode: parsed.deps?.mode ?? null });
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
package/src/runner.ts CHANGED
@@ -500,13 +500,23 @@ export class AgentRunner {
500
500
  const approvalId = typeof params.approvalId === "string" ? params.approvalId : "";
501
501
  const decision = typeof params.decision === "string" ? params.decision : "";
502
502
  if (!approvalId) throw new Error("approvalId required");
503
- if (decision !== "approve" && decision !== "approve-session" && decision !== "deny" && decision !== "abort") {
504
- throw new Error("decision must be approve, approve-session, deny, or abort");
503
+ if (decision !== "approve" && decision !== "approve-session" && decision !== "deny" && decision !== "abort" && decision !== "answer") {
504
+ throw new Error("decision must be approve, approve-session, deny, abort, or answer");
505
+ }
506
+ const answersRaw = params.answers;
507
+ const answers = answersRaw && typeof answersRaw === "object" && !Array.isArray(answersRaw)
508
+ ? Object.fromEntries(
509
+ Object.entries(answersRaw as Record<string, unknown>).filter(([, v]) => typeof v === "string") as [string, string][],
510
+ )
511
+ : undefined;
512
+ if (decision === "answer" && (!answers || Object.keys(answers).length === 0)) {
513
+ throw new Error("answers required for answer decision");
505
514
  }
506
515
  const input: ProviderPermissionDecisionInput = {
507
516
  approvalId,
508
517
  decision: decision as ProviderPermissionDecision,
509
518
  ...(typeof params.reason === "string" ? { reason: params.reason } : {}),
519
+ ...(answers ? { answers } : {}),
510
520
  };
511
521
  if (this.control?.resolvePermissionDecision(input)) {
512
522
  return { approvalId, decision, provider: "claude" };