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 +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/hooks/relay-status.sh +5 -1
- package/plugins/claude/hooks/stop.sh +2 -1
- package/src/adapter.ts +1 -0
- package/src/adapters/claude-transcript.ts +47 -0
- package/src/config.ts +7 -0
- package/src/control-server.ts +3 -2
- package/src/runner.ts +22 -7
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"
|
package/src/adapter.ts
CHANGED
|
@@ -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
|
}
|
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, 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.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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 {
|