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
|
@@ -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 "
|
|
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
|
-
|
|
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
|
+
}
|
package/src/control-server.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|