agent-relay-runner 0.10.23 → 0.10.25

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.23",
3
+ "version": "0.10.25",
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.23",
4
+ "version": "0.10.25",
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"
@@ -49,6 +49,28 @@ function assistantText(entry: TranscriptEntry): string {
49
49
  .join("\n\n");
50
50
  }
51
51
 
52
+ /**
53
+ * Returns true when the transcript's final parseable entry is an assistant
54
+ * message with stop_reason "end_turn", meaning the full turn has been flushed.
55
+ * Used to detect a race where the Stop hook reads the file before the last
56
+ * assistant block is written to disk.
57
+ */
58
+ export function transcriptLooksComplete(jsonl: string): boolean {
59
+ const lines = jsonl.split("\n");
60
+ let lastAssistantStopReason: string | undefined;
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed) continue;
64
+ try {
65
+ const entry = JSON.parse(trimmed) as TranscriptEntry;
66
+ if (entry.type === "assistant") lastAssistantStopReason = entry.message?.stop_reason;
67
+ } catch {
68
+ continue;
69
+ }
70
+ }
71
+ return lastAssistantStopReason === "end_turn";
72
+ }
73
+
52
74
  /**
53
75
  * Returns the concatenated assistant `text` for the most recent turn (since the
54
76
  * last real user prompt), or "" if there is nothing user-visible to surface.
@@ -74,3 +96,19 @@ export function extractLastAssistantTurn(jsonl: string): string {
74
96
  }
75
97
  return collected.join("\n\n").trim();
76
98
  }
99
+
100
+ /**
101
+ * Extract text from the `last_assistant_message` field in the Stop hook
102
+ * payload. This is the content of the final assistant message — either a plain
103
+ * string or an array of content blocks (same shape as transcript entries).
104
+ * Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
105
+ */
106
+ export function extractHookAssistantMessage(content: unknown): string {
107
+ if (typeof content === "string") return content.trim();
108
+ if (!Array.isArray(content)) return "";
109
+ return (content as TranscriptBlock[])
110
+ .filter((b) => b.type === "text" && typeof b.text === "string")
111
+ .map((b) => b.text!.trim())
112
+ .filter(Boolean)
113
+ .join("\n\n");
114
+ }
@@ -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 } from "./adapters/claude-transcript";
12
+ import { extractLastAssistantTurn, 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;
@@ -690,7 +690,27 @@ export class AgentRunner {
690
690
  } catch {
691
691
  return;
692
692
  }
693
- const body = extractLastAssistantTurn(jsonl);
693
+ // The Stop hook can fire before the final assistant entry is flushed to
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
+ body = extractLastAssistantTurn(jsonl);
706
+ if (body) break;
707
+ }
708
+ // Fallback: use last_assistant_message from the Stop hook payload directly.
709
+ // This bypasses the transcript file race entirely — Claude Code provides the
710
+ // content in-memory before the hook even fires.
711
+ if (!body && input.lastAssistantMessage) {
712
+ body = extractHookAssistantMessage(input.lastAssistantMessage);
713
+ }
694
714
  if (!body) return;
695
715
 
696
716
  try {