agent-relay-runner 0.127.9 → 0.127.10

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.127.9",
3
+ "version": "0.127.10",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "agent-relay-providers": "0.104.4",
26
- "agent-relay-sdk": "0.2.122",
26
+ "agent-relay-sdk": "0.2.123",
27
27
  "callmux": "0.23.0"
28
28
  },
29
29
  "devDependencies": {
@@ -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.127.9",
4
+ "version": "0.127.10",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -323,34 +323,19 @@ async function handlePermissionRequest(
323
323
  const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
324
324
  const approvalId = crypto.randomUUID();
325
325
 
326
- // #1173: a PreToolUse "questions" prompt (the provider's AskUserQuestion) used to block this hook
327
- // response for up to ~15 minutes waiting on the human — which meant the turn's reasoning that
328
- // led to the question stayed buffered in the provider's process (never flushed to the transcript
329
- // JSONL) for exactly as long as the human took to answer, so the dashboard could only ever
330
- // show the question AFTER the reasoning that motivated it. Denying the tool call immediately
331
- // is the ordinary, already-supported "rejected tool call" hook contract: the provider persists the
332
- // buffered turn + a rejected tool_result right away, on its own, same as any other denied tool
333
- // — so the settle-poll harvest below (previously doomed to time out for this one case) now
334
- // finds the freshly-flushed anchor within moments. The human's eventual answer is delivered
335
- // afterward as a normal injected prompt (see the `.then` below), not as this hook's response.
336
326
  const probeView = handler.buildView(approvalId, body, true);
337
- if (body.hook_event_name === "PreToolUse" && probeView.kind === "questions" && handler.buildAnswerInjectionPrompt) {
338
- const response = Response.json(handler.buildHookResponse(
339
- { approvalId, decision: "deny", reason: PENDING_QUESTION_DENY_REASON },
340
- body,
341
- ));
342
- void awaitPermissionDecision({
327
+ const answerInput = permissionRequestQuestionAnswers(body);
328
+ if (body.hook_event_name === "PermissionRequest" && probeView.kind === "questions" && answerInput) {
329
+ return Response.json(handler.buildHookResponse({ approvalId, decision: "answer", answers: answerInput }, body));
330
+ }
331
+ if (
332
+ (body.hook_event_name === "PreToolUse" || body.hook_event_name === "PermissionRequest")
333
+ && probeView.kind === "questions"
334
+ && handler.buildAnswerInjectionPrompt
335
+ ) {
336
+ return holdQuestionWithImmediateDeny({
343
337
  options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
344
- blockedReason: "pendingQuestion",
345
- recommendedAction: "Answer the question in Agent Relay.",
346
- suppressResolvedStatus: true,
347
- }).then(({ decision, view }) => {
348
- const text = decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length
349
- ? handler.buildAnswerInjectionPrompt!(view, decision, body)
350
- : pendingQuestionDismissalPrompt(decision);
351
- return options.onAnswerInject?.({ approvalId, text });
352
- }).catch(() => {});
353
- return response;
338
+ });
354
339
  }
355
340
 
356
341
  const { decision } = await awaitPermissionDecision({
@@ -361,6 +346,51 @@ async function handlePermissionRequest(
361
346
  return Response.json(handler.buildHookResponse(decision, body));
362
347
  }
363
348
 
349
+ function permissionRequestQuestionAnswers(body: Record<string, unknown>): Record<string, string> | undefined {
350
+ if (body.hook_event_name !== "PermissionRequest") return undefined;
351
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
352
+ if (!isRecord(toolInput.answers)) return undefined;
353
+ const answers = Object.fromEntries(
354
+ Object.entries(toolInput.answers)
355
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0),
356
+ );
357
+ return Object.keys(answers).length ? answers : undefined;
358
+ }
359
+
360
+ function holdQuestionWithImmediateDeny(params: {
361
+ options: ControlServerOptions;
362
+ pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
363
+ handler: ProviderPermissionPromptHandler;
364
+ approvalId: string;
365
+ body: Record<string, unknown>;
366
+ occurredAt: number;
367
+ transcriptPath: string;
368
+ toolName?: string;
369
+ toolInput?: unknown;
370
+ }): Response {
371
+ const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput } = params;
372
+ // #1173/#1194: a fresh "questions" prompt (the provider's AskUserQuestion) must not block
373
+ // its own hook response while the human answers. Denying immediately makes the provider persist
374
+ // the buffered turn plus rejected tool_result, so the pre-flush harvest below can publish the
375
+ // reasoning before the dashboard form appears. The eventual answer is injected as a normal prompt.
376
+ const response = Response.json(handler.buildHookResponse(
377
+ { approvalId, decision: "deny", reason: PENDING_QUESTION_DENY_REASON },
378
+ body,
379
+ ));
380
+ void awaitPermissionDecision({
381
+ options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
382
+ blockedReason: "pendingQuestion",
383
+ recommendedAction: "Answer the question in Agent Relay.",
384
+ suppressResolvedStatus: true,
385
+ }).then(({ decision, view }) => {
386
+ const text = decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length
387
+ ? handler.buildAnswerInjectionPrompt!(view, decision, body)
388
+ : pendingQuestionDismissalPrompt(decision);
389
+ return options.onAnswerInject?.({ approvalId, text });
390
+ }).catch(() => {});
391
+ return response;
392
+ }
393
+
364
394
  // Shared by both the traditional block-until-decided flow and the #1173 deny-then-hold flow:
365
395
  // pre-flushes narrative (#435/#1116), builds+persists the interactive view, publishes the
366
396
  // blocked providerState, and resolves once a decision arrives (or the wait times out). Callers