agent-relay-runner 0.10.24 → 0.10.26

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.10.24",
3
+ "version": "0.10.26",
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.10.24",
4
+ "version": "0.10.26",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -41,12 +41,16 @@ relay_post_session_turn() {
41
41
  # the captured turn clears the reply obligation before the stop decision check
42
42
  # below, so the agent is not nagged to /reply (and does not re-emit).
43
43
  local transcript_path="${1:-}"
44
+ local last_assistant_message="${2:-}"
44
45
  local port="${AGENT_RELAY_RUNNER_PORT:-}"
45
46
  [ -z "$port" ] && return 0
46
47
  [ -z "$transcript_path" ] && return 0
48
+ local body="{\"transcriptPath\":\"$(relay_json_escape "$transcript_path")\""
49
+ [ -n "$last_assistant_message" ] && body="${body},\"lastAssistantMessage\":${last_assistant_message}"
50
+ body="${body}}"
47
51
  curl -fsS -X POST "http://127.0.0.1:${port}/session-turn" \
48
52
  -H 'Content-Type: application/json' \
49
- -d "{\"transcriptPath\":\"$(relay_json_escape "$transcript_path")\"}" >/dev/null 2>&1 || true
53
+ -d "$body" >/dev/null 2>&1 || true
50
54
  }
51
55
 
52
56
  relay_pending_reply_stop_decision() {
@@ -5,7 +5,8 @@ source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/
5
5
  payload="$(cat || true)"
6
6
  stop_hook_active="$(relay_json_bool_field stop_hook_active "$payload")"
7
7
  if [ "$stop_hook_active" != "true" ]; then
8
- relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")"
8
+ last_assistant_msg="$(echo "$payload" | jq -c '.last_assistant_message // empty' 2>/dev/null || true)"
9
+ relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")" "$last_assistant_msg"
9
10
  stop_decision="$(relay_pending_reply_stop_decision)"
10
11
  if [ -n "$stop_decision" ]; then
11
12
  printf '%s\n' "$stop_decision"
package/src/adapter.ts CHANGED
@@ -31,6 +31,7 @@ export interface ProviderConfig {
31
31
  defaultCapabilities: string[];
32
32
  defaultApprovalMode: string;
33
33
  defaultTags: string[];
34
+ chatCaptureMode: "final" | "full";
34
35
  headless: {
35
36
  tmuxPrefix: string;
36
37
  shutdownTimeoutMs: number;
@@ -96,3 +96,50 @@ export function extractLastAssistantTurn(jsonl: string): string {
96
96
  }
97
97
  return collected.join("\n\n").trim();
98
98
  }
99
+
100
+ /**
101
+ * Returns only the text from the LAST assistant entry in the current turn.
102
+ * Unlike extractLastAssistantTurn which collects all intermediate text between
103
+ * tool calls, this returns just the final response — what the user sees as the
104
+ * concluding message.
105
+ */
106
+ export function extractFinalAssistantMessage(jsonl: string): string {
107
+ const lines = jsonl.split("\n");
108
+ let lastText = "";
109
+ let pastLastUserPrompt = false;
110
+ for (const line of lines) {
111
+ const trimmed = line.trim();
112
+ if (!trimmed) continue;
113
+ let entry: TranscriptEntry;
114
+ try {
115
+ entry = JSON.parse(trimmed) as TranscriptEntry;
116
+ } catch {
117
+ continue;
118
+ }
119
+ if (isRealUserPrompt(entry)) {
120
+ pastLastUserPrompt = true;
121
+ lastText = "";
122
+ continue;
123
+ }
124
+ if (!pastLastUserPrompt) continue;
125
+ const text = assistantText(entry);
126
+ if (text) lastText = text;
127
+ }
128
+ return lastText.trim();
129
+ }
130
+
131
+ /**
132
+ * Extract text from the `last_assistant_message` field in the Stop hook
133
+ * payload. This is the content of the final assistant message — either a plain
134
+ * string or an array of content blocks (same shape as transcript entries).
135
+ * Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
136
+ */
137
+ export function extractHookAssistantMessage(content: unknown): string {
138
+ if (typeof content === "string") return content.trim();
139
+ if (!Array.isArray(content)) return "";
140
+ return (content as TranscriptBlock[])
141
+ .filter((b) => b.type === "text" && typeof b.text === "string")
142
+ .map((b) => b.text!.trim())
143
+ .filter(Boolean)
144
+ .join("\n\n");
145
+ }
package/src/config.ts CHANGED
@@ -33,6 +33,7 @@ export function defaultProviderConfig(provider: string): ProviderConfig {
33
33
  defaultCapabilities: ["chat", "code", "review"],
34
34
  defaultApprovalMode: "guarded",
35
35
  defaultTags: [],
36
+ chatCaptureMode: "final",
36
37
  headless: {
37
38
  tmuxPrefix: `${provider}-relay`,
38
39
  shutdownTimeoutMs: 10_000,
@@ -63,6 +64,7 @@ export function loadProviderConfig(provider: string, home = agentRelayHome()): L
63
64
  defaultCapabilities: stringArray(raw.defaultCapabilities) ?? defaults.defaultCapabilities,
64
65
  defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
65
66
  defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
67
+ chatCaptureMode: enumValue(raw.chatCaptureMode, ["final", "full"]) ?? defaults.chatCaptureMode,
66
68
  headless: {
67
69
  tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
68
70
  shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
@@ -88,6 +90,7 @@ export function providerConfigPublic(config: LoadedProviderConfig): Record<strin
88
90
  defaultCapabilities: config.defaultCapabilities,
89
91
  defaultApprovalMode: config.defaultApprovalMode,
90
92
  defaultTags: config.defaultTags,
93
+ chatCaptureMode: config.chatCaptureMode,
91
94
  headless: config.headless,
92
95
  };
93
96
  }
@@ -120,6 +123,10 @@ function positiveInteger(value: unknown): number | undefined {
120
123
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
121
124
  }
122
125
 
126
+ function enumValue<T extends string>(value: unknown, allowed: T[]): T | undefined {
127
+ return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
128
+ }
129
+
123
130
  function stringArray(value: unknown): string[] | undefined {
124
131
  return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
125
132
  }
@@ -23,7 +23,7 @@ interface ControlServerOptions {
23
23
  // Phase 1 live-session lane: a provider Stop hook hands over its transcript
24
24
  // path so the runner can capture the assistant turn and surface it in the
25
25
  // dashboard chat without the agent re-emitting it via /reply.
26
- onSessionTurn?(input: { transcriptPath: string }): Promise<void>;
26
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void>;
27
27
  }
28
28
 
29
29
  export function startControlServer(options: ControlServerOptions): ControlServer {
@@ -253,11 +253,12 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
253
253
  const body = await req.json().catch(() => null);
254
254
  const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
255
255
  if (!transcriptPath) return Response.json({ ok: false, reason: "transcriptPath required" }, { status: 400 });
256
+ const lastAssistantMessage = isRecord(body) ? body.lastAssistantMessage : undefined;
256
257
  // Awaited on purpose: the Stop hook posts this synchronously before its
257
258
  // reply-obligation check, so the captured turn must be persisted (and the
258
259
  // obligation cleared) before this response returns.
259
260
  try {
260
- await options.onSessionTurn({ transcriptPath });
261
+ await options.onSessionTurn({ transcriptPath, lastAssistantMessage });
261
262
  return Response.json({ ok: true });
262
263
  } catch (error) {
263
264
  return Response.json({ ok: false, reason: error instanceof Error ? error.message : String(error) }, { status: 500 });
package/src/runner.ts CHANGED
@@ -9,7 +9,7 @@ import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissio
9
9
  import { messagesWithCachedAttachments } from "./attachment-cache";
10
10
  import { ClaimTracker } from "./claim-tracker";
11
11
  import { startControlServer, type ControlServer } from "./control-server";
12
- import { extractLastAssistantTurn, transcriptLooksComplete } from "./adapters/claude-transcript";
12
+ import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, transcriptLooksComplete } from "./adapters/claude-transcript";
13
13
  import { agentProfileProjectionReport } from "./profile-projection";
14
14
  import { profileUsesHostProviderGlobals } from "./profile-home";
15
15
  import { runtimeMetadata } from "./version";
@@ -665,7 +665,7 @@ export class AgentRunner {
665
665
  // dashboard chat with zero agent tokens. Posting it as a reply to the
666
666
  // triggering message also clears the reply obligation, so the Stop hook no
667
667
  // longer nags the agent to /reply — which is what made it re-emit before.
668
- private async publishSessionTurn(input: { transcriptPath: string }): Promise<void> {
668
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void> {
669
669
  // Find the triggering message to reply to: either a pending prompt injection
670
670
  // (Phase 2 direct lane) or a reply obligation from the dashboard human.
671
671
  let replyToMessageId: number | undefined;
@@ -691,12 +691,27 @@ export class AgentRunner {
691
691
  return;
692
692
  }
693
693
  // The Stop hook can fire before the final assistant entry is flushed to
694
- // disk. Retry a few times with a short delay to catch the race.
695
- for (let attempt = 0; attempt < 3 && !transcriptLooksComplete(jsonl); attempt++) {
696
- await new Promise((r) => setTimeout(r, 100));
697
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
694
+ // disk. Claude Code writes thinking and text as separate entries (both with
695
+ // end_turn), so the transcript can "look complete" while the text entry is
696
+ // still pending. Retry until both the transcript has an end_turn AND the
697
+ // extraction yields non-empty text.
698
+ let body = "";
699
+ for (let attempt = 0; attempt < 5; attempt++) {
700
+ if (attempt > 0) {
701
+ await new Promise((r) => setTimeout(r, 100));
702
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
703
+ }
704
+ if (!transcriptLooksComplete(jsonl)) continue;
705
+ const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
706
+ body = extract(jsonl);
707
+ if (body) break;
708
+ }
709
+ // Fallback: use last_assistant_message from the Stop hook payload directly.
710
+ // This bypasses the transcript file race entirely — Claude Code provides the
711
+ // content in-memory before the hook even fires.
712
+ if (!body && input.lastAssistantMessage) {
713
+ body = extractHookAssistantMessage(input.lastAssistantMessage);
698
714
  }
699
- const body = extractLastAssistantTurn(jsonl);
700
715
  if (!body) return;
701
716
 
702
717
  try {