agent-relay-runner 0.127.9 → 0.127.11

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.11",
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.124",
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.11",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -47,8 +47,8 @@ var RUNNER_PROMPT_TEMPLATE_DEFAULTS = [
47
47
  category: "runner",
48
48
  title: "Codex Relay Context",
49
49
  description: "Relay context note injected into Codex sessions.",
50
- template: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id \u2014 relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own \u2014 find them later with relay_find_agents spawnedBy:me. For replies, prefer relay_reply or relay_send_message; CLI fallback: agent-relay /reply <messageId> \"<your reply>\". If a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide",
51
- defaultTemplate: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id \u2014 relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own \u2014 find them later with relay_find_agents spawnedBy:me. For replies, prefer relay_reply or relay_send_message; CLI fallback: agent-relay /reply <messageId> \"<your reply>\". If a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide",
50
+ template: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id \u2014 relay fills it from your token. Your identity is injected at startup as the `## Agent Relay Identity` block; use relay_whoami only for the full verbose identity dump. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own \u2014 find them later with relay_find_agents spawnedBy:me. For replies, prefer relay_reply or relay_send_message; CLI fallback: agent-relay /reply <messageId> \"<your reply>\". If a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide",
51
+ defaultTemplate: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id \u2014 relay fills it from your token. Your identity is injected at startup as the `## Agent Relay Identity` block; use relay_whoami only for the full verbose identity dump. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own \u2014 find them later with relay_find_agents spawnedBy:me. For replies, prefer relay_reply or relay_send_message; CLI fallback: agent-relay /reply <messageId> \"<your reply>\". If a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide",
52
52
  variables: []
53
53
  },
54
54
  {
@@ -1,5 +1,6 @@
1
1
  import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
2
2
  import type { ProviderStatusUpdate } from "../adapter";
3
+ import type { ClaudeProbeActivity } from "./claude-session-probe";
3
4
  import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
4
5
 
5
6
  // LEGACY PANE-SCRAPE FALLBACK (#766/#768) — busy/idle is now driven primarily by
@@ -19,8 +20,8 @@ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-l
19
20
  // (the cross-version anchor; the "esc to interrupt" hint was already dropped in 2.1.x).
20
21
  // FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
21
22
  // transcript discussing "esc to interrupt", or this very comment shown in a pane)
22
- // reads as ready/busy. Tolerated because the markers are CC-specific enough to be
23
- // rare in real output; if it bites, gate on the LAST N lines only (the live footer).
23
+ // reads as ready/busy. The activity reconciler gates on the LAST N lines only (the
24
+ // live footer); direct raw predicates are still used by readiness/wait gates.
24
25
  // History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
25
26
  export function claudePaneLooksReady(text: string): boolean {
26
27
  // Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
@@ -43,6 +44,7 @@ export function claudePaneLooksReady(text: string): boolean {
43
44
  // (ctrl+o to expand)" truncation marker, the idle input box, or the persistent
44
45
  // "/btw … without interrupting Claude's current work" queue hint.
45
46
  const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
47
+ const CLAUDE_ACTIVITY_FOOTER_LINES = 10;
46
48
 
47
49
  export function claudePaneIsBusy(text: string): boolean {
48
50
  // Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
@@ -52,6 +54,17 @@ export function claudePaneIsBusy(text: string): boolean {
52
54
  return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
53
55
  }
54
56
 
57
+ function claudeActivityFooter(text: string): string {
58
+ return text.split("\n").slice(-CLAUDE_ACTIVITY_FOOTER_LINES).join("\n");
59
+ }
60
+
61
+ export function claudeActivityFromProbeAndPane(probe: ClaudeProbeActivity | undefined, pane: string): ClaudeProbeActivity {
62
+ const footer = claudeActivityFooter(pane);
63
+ if (probe === "busy" || claudePaneIsBusy(footer)) return "busy";
64
+ if (claudePaneLooksReady(footer)) return "idle";
65
+ return probe === "idle" ? "unknown" : probe ?? "unknown";
66
+ }
67
+
55
68
  // #286: detect the subscription usage-limit modal in the pane and turn it into the
56
69
  // provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
57
70
  // at reset. Mirrors claudeModelUnavailableStatus, but non-terminal (idle, not error).
@@ -12,7 +12,7 @@ import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArg
12
12
  import { claudeProviderMessageText } from "./claude-delivery";
13
13
  import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
14
14
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "./claude-prompt-gates";
15
- import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
15
+ import { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
16
16
  import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
17
17
  import { CLAUDE_TMUX_READY_TIMEOUT_MS, captureTmuxPane, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSocketName, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
18
18
  import { claudePermissionPromptHandler } from "./claude-permission";
@@ -138,12 +138,11 @@ export class ClaudeAdapter implements ProviderAdapter {
138
138
  tmuxSocket: socket,
139
139
  configHomeEnv: (process.meta?.env as Record<string, string | undefined> | undefined)?.CLAUDE_CONFIG_DIR,
140
140
  });
141
- if (managed?.reading.available) return claudeProbeActivity(managed.reading.status);
142
- let pane: string;
143
- try { pane = captureTmuxPane(session, socket); } catch { return "unknown"; }
144
- if (claudePaneIsBusy(pane)) return "busy";
145
- if (claudePaneLooksReady(pane)) return "idle";
146
- return "unknown";
141
+ const probe = managed?.reading.available ? claudeProbeActivity(managed.reading.status) : undefined;
142
+ if (probe === "busy") return "busy";
143
+ let pane: string | undefined;
144
+ try { pane = captureTmuxPane(session, socket); } catch { return probe ?? "unknown"; }
145
+ return claudeActivityFromProbeAndPane(probe, pane);
147
146
  }
148
147
 
149
148
  // #769 P1-A: an independent pane-busy read, consumed ONLY as a veto on the busy
@@ -532,7 +531,7 @@ export { tmuxHasSession };
532
531
 
533
532
  // Re-export pane-status detectors (moved to ./claude-status-detectors) so existing
534
533
  // import paths (including claude.test.ts) continue to resolve via this module.
535
- export { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
534
+ export { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
536
535
 
537
536
  // Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
538
537
  export { shellQuote };
@@ -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
@@ -140,7 +140,7 @@ function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig
140
140
 
141
141
  function readHostClaudeConfig(): Record<string, unknown> | undefined {
142
142
  try {
143
- const hostPath = join(homedir(), ".claude.json");
143
+ const hostPath = join(providerSourceHome("claude"), ".claude.json");
144
144
  if (!existsSync(hostPath)) return undefined;
145
145
  return JSON.parse(readFileSync(hostPath, "utf8")) as Record<string, unknown>;
146
146
  } catch {