agent-relay-runner 0.36.2 → 0.38.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.36.2",
3
+ "version": "0.38.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.22"
23
+ "agent-relay-sdk": "0.2.23"
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.36.2",
4
+ "version": "0.38.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -157,6 +157,13 @@ export interface ProviderAdapter {
157
157
  // `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
158
158
  // first (cold-start) delivery vs. a fast re-attempt after a ready signal (#329).
159
159
  deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void>;
160
+ // When true, the adapter seeds the spawn-time initial prompt as a launch argument
161
+ // (Claude's positional `claude "<prompt>"`), so it's already delivered the instant the
162
+ // session starts. The runner must then NOT also deliver it post-launch via
163
+ // deliverInitialPrompt — that would double-deliver and re-introduce the send-keys
164
+ // onboarding race (#352). deliverInitialPrompt stays available for mid-session injection
165
+ // (dashboard chat box) and ongoing message delivery, which have no launch-arg equivalent.
166
+ seedsInitialPromptAtLaunch?: boolean;
160
167
  deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
161
168
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
162
169
  // Subscribe to session-mirror events from providers that emit them directly
@@ -15,6 +15,9 @@ import { claudeProviderMessageText } from "./claude-delivery";
15
15
 
16
16
  export class ClaudeAdapter implements ProviderAdapter {
17
17
  readonly provider = "claude";
18
+ // #352: initial prompt is seeded as Claude's positional launch arg (buildSpawnArgs) — reliable,
19
+ // no send-keys/onboarding race; tells the runner to skip the redundant post-launch delivery.
20
+ readonly seedsInitialPromptAtLaunch = true;
18
21
  private statusCb: (status: ProviderStatusUpdate) => void = () => {};
19
22
  private tmuxWatcher?: Timer;
20
23
  private turnWatcher?: Timer;
@@ -226,7 +229,11 @@ export class ClaudeAdapter implements ProviderAdapter {
226
229
  ...providerArgs,
227
230
  ];
228
231
  if (config.model) args.push("--model", config.model);
229
- if (config.prompt && !config.headless) args.push(String(config.prompt));
232
+ // #352: seed the prompt as Claude's positional arg for ALL launches (headless included)
233
+ // `claude "<prompt>"` seeds the first turn via Claude's own input handling, immune to the
234
+ // send-keys/onboarding race that silently dropped headless spawns. Long/special-char briefs
235
+ // are shell-safe (shellEscape + launcher-script externalization in buildTmuxArgs).
236
+ if (config.prompt) args.push(String(config.prompt));
230
237
  return {
231
238
  command,
232
239
  args,
package/src/runner.ts CHANGED
@@ -250,6 +250,7 @@ export class AgentRunner {
250
250
  // Session-mirror: a synthesized id grouping a turn's reasoning/tool steps and
251
251
  // its final response. Set when a provider-turn starts, cleared when it ends.
252
252
  private currentTurnId?: string;
253
+ private currentTurnStartedAt?: number;
253
254
  // Prompt-echo dedup: a short, time-bounded queue of prompts the runner itself
254
255
  // injected (chat box or initial prompt) that are still awaiting their matching
255
256
  // UserPromptSubmit echo. A single slot dropped earlier entries when several prompts
@@ -600,7 +601,13 @@ export class AgentRunner {
600
601
 
601
602
  private async deliverInitialPrompt(): Promise<void> {
602
603
  const prompt = this.options.prompt?.trim();
603
- if (prompt) await this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS);
604
+ if (!prompt) return;
605
+ // #352: adapters that seed the prompt as a launch arg (Claude's positional prompt) already
606
+ // delivered it at session start. Returning BEFORE attemptInitialPromptDelivery avoids a
607
+ // double-delivery AND leaves pendingInitialPrompt unset, so the #329 ready-signal retry never
608
+ // arms — one prompt, one turn. Mid-session injectPrompt + message delivery are unaffected.
609
+ if (this.options.adapter.seedsInitialPromptAtLaunch) return;
610
+ await this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS);
604
611
  }
605
612
 
606
613
  // Deliver the spawn-time first prompt, surviving a cold-start TUI that isn't input-ready yet
@@ -1088,12 +1095,14 @@ export class AgentRunner {
1088
1095
  if (status === "busy" && reason === "provider-turn") {
1089
1096
  if (!this.currentTurnId) {
1090
1097
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1098
+ this.currentTurnStartedAt = Date.now();
1091
1099
  this.sessionLog(`turn started (turn ${this.currentTurnId})`);
1092
1100
  }
1093
1101
  this.armBusyReconciler();
1094
1102
  } else if (status === "idle" && reason === "provider-turn") {
1095
1103
  if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
1096
1104
  this.currentTurnId = undefined;
1105
+ this.currentTurnStartedAt = undefined;
1097
1106
  this.disarmBusyReconciler();
1098
1107
  this.stopReasoningTail();
1099
1108
  }
@@ -1318,7 +1327,10 @@ export class AgentRunner {
1318
1327
  // deliveries) so those aren't double-posted.
1319
1328
  private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
1320
1329
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1321
- if (!this.currentTurnId) this.currentTurnId = crypto.randomUUID();
1330
+ if (!this.currentTurnId) {
1331
+ this.currentTurnId = crypto.randomUUID();
1332
+ this.currentTurnStartedAt = Date.now();
1333
+ }
1322
1334
  const text = input.prompt.trim();
1323
1335
  if (text && !this.isRunnerInjectedPrompt(text)) {
1324
1336
  this.sessionLog(`prompt echoed from terminal (${text.length} chars)`);
@@ -1476,7 +1488,7 @@ export class AgentRunner {
1476
1488
  if (pendingPrompt) {
1477
1489
  replyToMessageId = pendingPrompt;
1478
1490
  this.pendingPromptMessageId = undefined;
1479
- } else if (this.obligationCache.get().some((o) => o.from === "user")) {
1491
+ } else if (this.obligationCache.get().some((o) => o.from === "user" && this.obligationPredatesCurrentTurn(o))) {
1480
1492
  // The agent will answer the relay obligation itself — don't double-post (#196).
1481
1493
  return;
1482
1494
  }
@@ -1528,6 +1540,10 @@ export class AgentRunner {
1528
1540
  return false;
1529
1541
  }
1530
1542
 
1543
+ private obligationPredatesCurrentTurn(obligation: { createdAt: number }): boolean {
1544
+ return this.currentTurnStartedAt === undefined || obligation.createdAt <= this.currentTurnStartedAt;
1545
+ }
1546
+
1531
1547
  // --- Busy-state reconciler (item 2) -------------------------------------------------
1532
1548
  // A safety net for turns that end out of band (interrupted from the web terminal,
1533
1549
  // a hook that never fired) where the runner would otherwise stay stuck "busy".
@@ -1662,6 +1678,7 @@ export class AgentRunner {
1662
1678
  }
1663
1679
 
1664
1680
  private publishStatus(): void {
1681
+ this.claims.expire();
1665
1682
  const status = this.claims.currentStatus();
1666
1683
  const agentStatus = runnerAgentStatus(status);
1667
1684
  const activeWork = this.claims.activeWork();