agent-relay-runner 0.62.2 → 0.63.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.
@@ -126,6 +126,50 @@ export function extractLastAssistantTurn(jsonl: string): string {
126
126
  return collected.join("\n\n").trim();
127
127
  }
128
128
 
129
+ /**
130
+ * Like extractLastAssistantTurn but skips the first `afterEntry` valid JSON entries.
131
+ * Used when a partial transcript flush already emitted entries 1..afterEntry so the
132
+ * Stop-hook full-mode capture doesn't re-emit the already-published text.
133
+ */
134
+ export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: number): string {
135
+ const lines = jsonl.split("\n");
136
+ const collected: string[] = [];
137
+ let entryIndex = 0;
138
+ for (const line of lines) {
139
+ const trimmed = line.trim();
140
+ if (!trimmed) continue;
141
+ let entry: TranscriptEntry;
142
+ try {
143
+ entry = JSON.parse(trimmed) as TranscriptEntry;
144
+ } catch {
145
+ continue;
146
+ }
147
+ entryIndex++;
148
+ if (isSidechainEntry(entry)) continue;
149
+ if (isRealUserPrompt(entry)) {
150
+ collected.length = 0;
151
+ continue;
152
+ }
153
+ if (entryIndex <= afterEntry) continue;
154
+ const text = assistantText(entry);
155
+ if (text) collected.push(text);
156
+ }
157
+ return collected.join("\n\n").trim();
158
+ }
159
+
160
+ /**
161
+ * Count valid JSON entries in a JSONL string. Used to record the high-water-mark
162
+ * after a pre-flush so the Stop hook knows where to resume in full-capture mode.
163
+ */
164
+ export function countTranscriptEntries(jsonl: string): number {
165
+ let count = 0;
166
+ for (const line of jsonl.split("\n")) {
167
+ if (!line.trim()) continue;
168
+ try { JSON.parse(line); count++; } catch {}
169
+ }
170
+ return count;
171
+ }
172
+
129
173
  /**
130
174
  * Returns only the text from the LAST assistant entry in the current turn.
131
175
  * Unlike extractLastAssistantTurn which collects all intermediate text between
@@ -450,25 +450,21 @@ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[]
450
450
  }
451
451
 
452
452
  export function claudeLaunchArgs(defaultArgs: string[], providerArgs: string[], approvalMode?: string): string[] {
453
+ const stripToolArgs = approvalMode === "read-only";
453
454
  return [
454
- ...stripClaudePermissionArgs(defaultArgs),
455
- ...stripClaudePermissionArgs(providerArgs),
455
+ ...stripClaudePermissionArgs(defaultArgs, stripToolArgs),
456
+ ...stripClaudePermissionArgs(providerArgs, stripToolArgs),
456
457
  ...claudePermissionModeArgs(approvalMode),
457
458
  ];
458
459
  }
459
460
 
460
461
  export function claudePermissionModeArgs(approvalMode?: string): string[] {
461
462
  if (approvalMode === "open") return ["--dangerously-skip-permissions"];
462
- if (approvalMode === "read-only") return [
463
- "--permission-mode",
464
- "dontAsk",
465
- "--allowedTools",
466
- "Read,Grep,Glob,LS,Bash(agent-relay *)",
467
- ];
463
+ if (approvalMode === "read-only") return ["--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob,LS,Bash"];
468
464
  return ["--permission-mode", "default"];
469
465
  }
470
466
 
471
- function stripClaudePermissionArgs(args: string[]): string[] {
467
+ function stripClaudePermissionArgs(args: string[], stripToolArgs = false): string[] {
472
468
  const result: string[] = [];
473
469
  for (let i = 0; i < args.length; i++) {
474
470
  const arg = args[i];
@@ -484,6 +480,10 @@ function stripClaudePermissionArgs(args: string[]): string[] {
484
480
  i++;
485
481
  continue;
486
482
  }
483
+ if (stripToolArgs && /^--(?:tools|allowedTools|allowed-tools|disallowedTools|disallowed-tools)(?:=|$)/.test(arg)) {
484
+ if (!arg.includes("=")) i++;
485
+ continue;
486
+ }
487
487
  result.push(arg);
488
488
  }
489
489
  return result;
@@ -0,0 +1,84 @@
1
+ type ProviderActivity = "busy" | "idle" | "unknown";
2
+
3
+ const BUSY_RECONCILE_POLL_MS = 4_000;
4
+ const BUSY_RECONCILE_IDLE_CONFIRM = 8;
5
+ const BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY = 15;
6
+ const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
7
+
8
+ interface BusyReconcilerDeps {
9
+ isStopped(): boolean;
10
+ hasProcess(): boolean;
11
+ currentStatus(): "idle" | "busy" | "offline" | "error";
12
+ isProviderBlocked(): boolean;
13
+ hasProviderTurn(): boolean;
14
+ canProbeActivity(): boolean;
15
+ probeActivity(): Promise<ProviderActivity> | undefined;
16
+ clearProviderTurn(reason: string): void;
17
+ sessionDebug(message: string): void;
18
+ }
19
+
20
+ export class BusyReconciler {
21
+ private idleStreak = 0;
22
+ private timer?: ReturnType<typeof setInterval>;
23
+ private sawBusy = false;
24
+
25
+ constructor(private readonly deps: BusyReconcilerDeps) {}
26
+
27
+ arm(): void {
28
+ if (this.timer || !this.deps.canProbeActivity()) return;
29
+ this.idleStreak = 0;
30
+ this.sawBusy = false;
31
+ this.timer = setInterval(() => { void this.run(); }, BUSY_RECONCILE_POLL_MS);
32
+ }
33
+
34
+ disarm(): void {
35
+ if (this.timer) clearInterval(this.timer);
36
+ this.timer = undefined;
37
+ this.idleStreak = 0;
38
+ this.sawBusy = false;
39
+ }
40
+
41
+ scheduleInterruptReconcile(): void {
42
+ setTimeout(() => {
43
+ if (this.deps.isStopped() || !this.deps.hasProcess()) return;
44
+ void (async () => {
45
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) return;
46
+ const probe = this.deps.probeActivity();
47
+ let activity: ProviderActivity = "unknown";
48
+ try { if (probe) activity = await probe; } catch { return; }
49
+ this.deps.sessionDebug(`post-interrupt reconcile probe=${activity}`);
50
+ if (activity === "idle") this.deps.clearProviderTurn("post-interrupt");
51
+ })();
52
+ }, INTERRUPT_RECONCILE_DELAY_MS);
53
+ }
54
+
55
+ private async run(): Promise<void> {
56
+ const probe = this.deps.probeActivity();
57
+ if (this.deps.isStopped() || !this.deps.hasProcess() || !probe) {
58
+ this.disarm();
59
+ return;
60
+ }
61
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) {
62
+ this.idleStreak = 0;
63
+ return;
64
+ }
65
+ if (!this.deps.hasProviderTurn()) {
66
+ this.disarm();
67
+ return;
68
+ }
69
+ let activity: ProviderActivity;
70
+ try { activity = await probe; } catch { return; }
71
+ if (activity === "busy") this.sawBusy = true;
72
+ if (activity !== "idle") {
73
+ this.idleStreak = 0;
74
+ this.deps.sessionDebug(`reconcile probe=${activity} sawBusy=${this.sawBusy} streak=${this.idleStreak}`);
75
+ return;
76
+ }
77
+ this.idleStreak += 1;
78
+ const confirm = this.sawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
79
+ this.deps.sessionDebug(`reconcile probe=idle sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
80
+ if (this.idleStreak < confirm) return;
81
+ this.disarm();
82
+ this.deps.clearProviderTurn(this.sawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)");
83
+ }
84
+ }
@@ -36,7 +36,9 @@ interface ControlServerOptions {
36
36
  // Phase 1 live-session lane: a provider Stop hook hands over its transcript
37
37
  // path so the runner can capture the assistant turn and surface it in the
38
38
  // dashboard chat without the agent re-emitting it via /reply.
39
- onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void>;
39
+ // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
40
+ // un-mirrored narrative before the interactive form appears in chat (#435).
41
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void>;
40
42
  // A provider UserPromptSubmit hook hands over the prompt the human typed
41
43
  // directly into the session (web terminal / TUI) so the runner can mirror it
42
44
  // into the dashboard chat and start tailing the turn transcript for reasoning.
@@ -221,6 +223,14 @@ async function handlePermissionRequest(
221
223
  ): Promise<Response> {
222
224
  const body = await req.json().catch(() => null);
223
225
  if (!isRecord(body)) return Response.json({});
226
+ // #435: flush any un-mirrored narrative from the transcript before the form
227
+ // appears in chat — the transcript already has the assistant text block at
228
+ // PreToolUse time, but the Stop hook (which normally captures it) won't fire
229
+ // until after the user answers, so the form would otherwise arrive first.
230
+ const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
231
+ if (transcriptPath && options.onSessionTurn) {
232
+ await options.onSessionTurn({ transcriptPath, isPreFlush: true }).catch(() => {});
233
+ }
224
234
  const approvalId = crypto.randomUUID();
225
235
  const view = claudePermissionApprovalView(approvalId, body);
226
236
  options.onStatus({
@@ -0,0 +1,86 @@
1
+ import type { SendMessageInput } from "agent-relay-sdk";
2
+ import type { RelayHttpClient } from "agent-relay-sdk";
3
+ import { relayMcpEndpoint } from "./relay-mcp";
4
+ import type { OutboxRecord } from "./outbox";
5
+ import { deliverContinuationArchiveRecord } from "./continuation-archive";
6
+ import { logger } from "./logger";
7
+ import { isHttpAuthError, isHttpStatusError } from "./runner-helpers";
8
+
9
+ interface RunnerOutboxDelivery {
10
+ record: OutboxRecord;
11
+ http: RelayHttpClient;
12
+ relayUrl: string;
13
+ token?: string;
14
+ updatePayload(seq: number, payload: unknown): void;
15
+ sessionLog(message: string): void;
16
+ recoverRuntimeTokenAfterAuthFailure(source: string): void;
17
+ }
18
+
19
+ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Promise<void> {
20
+ const { record, http } = input;
21
+ try {
22
+ if (record.kind === "session-message") {
23
+ await http.sendMessage({
24
+ ...(record.payload as SendMessageInput),
25
+ occurredAt: record.occurredAt,
26
+ idempotencyKey: record.idempotencyKey,
27
+ });
28
+ return;
29
+ }
30
+ if (record.kind === "insight") {
31
+ await http.recordInsightObservation({
32
+ ...(record.payload as Parameters<RelayHttpClient["recordInsightObservation"]>[0]),
33
+ occurredAt: record.occurredAt,
34
+ });
35
+ return;
36
+ }
37
+ if (record.kind === "continuation-archive") {
38
+ await deliverContinuationArchiveRecord({
39
+ record,
40
+ http,
41
+ updatePayload: input.updatePayload,
42
+ sessionLog: input.sessionLog,
43
+ });
44
+ return;
45
+ }
46
+ if (record.kind === "mcp-tool-call") {
47
+ await deliverBufferedMcpCall(input);
48
+ return;
49
+ }
50
+ logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
51
+ } catch (error) {
52
+ if (isHttpStatusError(error, 409)) return;
53
+ if (isHttpAuthError(error)) input.recoverRuntimeTokenAfterAuthFailure("outbox");
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ export async function deliverBufferedMcpCall(input: {
59
+ record: OutboxRecord;
60
+ relayUrl: string;
61
+ token?: string;
62
+ recoverRuntimeTokenAfterAuthFailure(source: string): void;
63
+ }): Promise<void> {
64
+ const payload = input.record.payload as { tool: string; arguments: Record<string, unknown> };
65
+ const headers: Record<string, string> = { "content-type": "application/json" };
66
+ if (input.token) headers.authorization = `Bearer ${input.token}`;
67
+ const response = await fetch(relayMcpEndpoint(input.relayUrl), {
68
+ method: "POST",
69
+ headers,
70
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: payload.tool, arguments: payload.arguments } }),
71
+ });
72
+ if (response.status === 401 || response.status === 403) {
73
+ input.recoverRuntimeTokenAfterAuthFailure("mcp-outbox");
74
+ throw new Error(`relay rejected buffered ${payload.tool} with ${response.status}`);
75
+ }
76
+ if (response.status >= 500) throw new Error(`relay ${response.status} on buffered ${payload.tool}`);
77
+ if (!response.ok) {
78
+ const body = await response.text().catch(() => "");
79
+ logger.warn("mcp-outbox", `buffered ${payload.tool} permanently rejected (${response.status}); dropping: ${body.slice(0, 200)}`);
80
+ return;
81
+ }
82
+ const json = await response.json().catch(() => null) as { error?: { message?: string } } | null;
83
+ if (json?.error) {
84
+ logger.warn("mcp-outbox", `buffered ${payload.tool} returned a tool error; dropping: ${json.error.message ?? "(no detail)"}`);
85
+ }
86
+ }
package/src/rate-limit.ts CHANGED
@@ -14,7 +14,7 @@ export const RATE_LIMIT_BLOCK_REASON = "rate_limit";
14
14
  // fall back to the resume sweep's poll window.
15
15
  const MAX_RESET_AHEAD_MS = 7 * 24 * 60 * 60 * 1000;
16
16
 
17
- export interface RateLimitHoldInput {
17
+ interface RateLimitHoldInput {
18
18
  errorType?: string;
19
19
  /** Unix ms the limit window resets, when known. */
20
20
  resetAt?: number;
@@ -58,7 +58,7 @@ const LIMIT_PHRASE_RE = /(?:hit (?:your|the)\s*(?:[\w-]+\s+)*limit|usage limit|\
58
58
  const RESET_AT_RE = /\bresets?\b(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
59
59
  const TRY_AGAIN_RE = /\btry again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
60
60
 
61
- export interface PaneRateLimit {
61
+ interface PaneRateLimit {
62
62
  /** The matched limit line, for the chat notice / observability. */
63
63
  message: string;
64
64
  /** Unix ms reset, when parseable from the pane (else undefined → resume falls back to poll). */