agent-relay-runner 0.56.2 → 0.57.1

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.56.2",
3
+ "version": "0.57.1",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.56.2",
4
+ "version": "0.57.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/runner.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, ProviderCapabilities, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
6
  import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
7
  import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
8
+ import { providerMessageText } from "./adapter";
8
9
  import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
9
10
  import { messagesWithCachedAttachments } from "./attachment-cache";
10
11
  import { resolveProjectName } from "./config";
@@ -288,6 +289,9 @@ export class AgentRunner {
288
289
  // from the Claude transcript into chat as discreet session events.
289
290
  private reasoningTail?: { timer: ReturnType<typeof setInterval>; seen: Set<string> };
290
291
  private scratch?: SessionScratchLayout;
292
+ // #417: for non-claude providers, tracks how many times each spawner obligation has
293
+ // been re-injected so a non-responsive agent can't spin in a re-injection loop.
294
+ private readonly pendingObligationInjections = new Map<number, number>();
291
295
 
292
296
  constructor(private readonly options: RunnerOptions) {
293
297
  this.agentId = options.agentId ?? options.runnerId;
@@ -570,6 +574,7 @@ export class AgentRunner {
570
574
  deliver: (messages) => this.control!.deliverToMonitor(messages),
571
575
  },
572
576
  };
577
+ const launchSeededPrompt = this.options.adapter.seedsInitialPromptAtLaunch ? this.options.prompt?.trim() : ""; if (launchSeededPrompt) this.recordInjectedPrompt(launchSeededPrompt);
573
578
  return this.options.adapter.spawn(config);
574
579
  }
575
580
 
@@ -617,25 +622,18 @@ export class AgentRunner {
617
622
 
618
623
  private async deliverInitialPrompt(): Promise<void> {
619
624
  const prompt = this.options.prompt?.trim();
620
- if (!prompt) return;
621
- // #352: adapters that seed the prompt as a launch arg (Claude's positional prompt) already
622
- // delivered it at session start. Returning BEFORE attemptInitialPromptDelivery avoids a
623
- // double-delivery AND leaves pendingInitialPrompt unset, so the #329 ready-signal retry never
624
- // arms — one prompt, one turn. Mid-session injectPrompt + message delivery are unaffected.
625
- if (this.options.adapter.seedsInitialPromptAtLaunch) return;
625
+ if (!prompt || this.options.adapter.seedsInitialPromptAtLaunch) return;
626
626
  await this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS);
627
627
  }
628
628
 
629
629
  // Deliver the spawn-time first prompt, surviving a cold-start TUI that isn't input-ready yet
630
- // (#329). A ready-timeout no longer swallow-drops the prompt (the old bug a taskless worker
631
- // that looked healthy): it's stashed for a ready-signal-driven retry (setProviderStatus) and
632
- // surfaced on the runner timeline, so the spawner sees a stuck delivery, not just a log line.
630
+ // (#329). Timeout stashes it for ready-signal retry and surfaces the stall on the timeline.
633
631
  private async attemptInitialPromptDelivery(prompt: string, readyTimeoutMs: number): Promise<void> {
634
632
  if (this.deliveringInitialPrompt || this.stopped || !this.process || !this.options.adapter.deliverInitialPrompt) return;
635
633
  this.deliveringInitialPrompt = true;
636
634
  const attempt = (this.initialPromptAttempts += 1);
637
635
  try {
638
- await this.options.adapter.deliverInitialPrompt(this.process, prompt, { readyTimeoutMs });
636
+ this.recordInjectedPrompt(prompt); await this.options.adapter.deliverInitialPrompt(this.process, prompt, { readyTimeoutMs });
639
637
  this.pendingInitialPrompt = undefined;
640
638
  } catch (error) {
641
639
  const giveUp = attempt >= MAX_INITIAL_PROMPT_ATTEMPTS;
@@ -1176,6 +1174,9 @@ export class AgentRunner {
1176
1174
  this.compactionMidTurn = false;
1177
1175
  this.disarmBusyReconciler();
1178
1176
  this.stopReasoningTail();
1177
+ // #417: claude enforces obligations via the stop.sh hook; for every other provider
1178
+ // re-inject any pending spawner obligation so it surfaces the /reply command.
1179
+ if (this.options.provider !== "claude") void this.reInjectPendingObligation();
1179
1180
  }
1180
1181
  if (status === "busy") {
1181
1182
  this.claims.clearTerminalStatus();
@@ -1208,6 +1209,49 @@ export class AgentRunner {
1208
1209
  this.publishStatus();
1209
1210
  }
1210
1211
 
1212
+ // #417: when a non-claude provider finishes a turn with a pending spawner obligation
1213
+ // still unresolved, re-inject the obligation as a synthetic message so providerMessageText
1214
+ // surfaces the specific `agent-relay /reply <id>` command. Claude handles this via the
1215
+ // stop.sh hook; every other provider needs this in-process nudge.
1216
+ private async reInjectPendingObligation(): Promise<void> {
1217
+ if (!this.process) return;
1218
+ // Filter to spawner obligations only (from ≠ "user") — user obligations go through
1219
+ // the existing codex prompt-capture path (publishProviderSessionEvent).
1220
+ const obligations = this.obligationCache.get().filter((o) => o.from !== "user");
1221
+ if (!obligations.length) return;
1222
+ const MAX_REINJECTIONS = 3;
1223
+ for (const obligation of obligations) {
1224
+ const count = this.pendingObligationInjections.get(obligation.messageId) ?? 0;
1225
+ if (count >= MAX_REINJECTIONS) continue;
1226
+ this.pendingObligationInjections.set(obligation.messageId, count + 1);
1227
+ this.sessionLog(`re-injecting pending reply obligation #${obligation.messageId} from ${obligation.from} (attempt ${count + 1}/${MAX_REINJECTIONS})`);
1228
+ const replayMessage = {
1229
+ id: obligation.messageId,
1230
+ from: obligation.from,
1231
+ to: this.agentId,
1232
+ kind: obligation.kind,
1233
+ ...(obligation.subject ? { subject: obligation.subject } : {}),
1234
+ body: obligation.bodyPreview,
1235
+ payload: {},
1236
+ readBy: [] as string[],
1237
+ createdAt: obligation.createdAt,
1238
+ };
1239
+ // Suppress the userMessage echo that providers (e.g. Codex) reflect back when
1240
+ // a turn is started with relay-formatted content — the echo body is exactly what
1241
+ // providerMessageText produces, which starts with "[relay message #" and is caught
1242
+ // by the RELAY_INJECTION_MARKERS prefix check, but record it explicitly as
1243
+ // defense-in-depth for adapters that might echo only the unformatted body text.
1244
+ this.recordInjectedPrompt(providerMessageText([replayMessage]));
1245
+ await this.options.adapter.deliver(this.process, [replayMessage]);
1246
+ // Kick off a cache refresh so a cleared obligation (agent replied) is detected
1247
+ // before the next idle fires — without this, the stale snapshot re-injects up to
1248
+ // MAX_REINJECTIONS times even after the agent has already /reply-ed.
1249
+ this.obligationCache.markDirty();
1250
+ // One obligation at a time — let the provider reply before nudging again.
1251
+ break;
1252
+ }
1253
+ }
1254
+
1211
1255
  // Session-mirror lane: capture the assistant turn from the Claude transcript and
1212
1256
  // post it as a "session" message so it shows in the dashboard chat with zero
1213
1257
  // agent tokens. Capture is UNCONDITIONAL — it no longer depends on a triggering