agent-relay-runner 0.11.9 → 0.12.1
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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/hooks/relay-status.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +4 -0
- package/src/adapter.ts +30 -0
- package/src/adapters/claude-transcript.ts +52 -0
- package/src/adapters/claude.ts +27 -0
- package/src/adapters/codex.ts +122 -3
- package/src/control-server.ts +39 -0
- package/src/runner.ts +357 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.6"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
|
@@ -53,6 +53,25 @@ relay_post_session_turn() {
|
|
|
53
53
|
-d "$body" >/dev/null 2>&1 || true
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
relay_post_user_prompt() {
|
|
57
|
+
# Mirror a prompt the human typed directly into this Claude session (web
|
|
58
|
+
# terminal / TUI) into the dashboard chat, and hand over the transcript path so
|
|
59
|
+
# the runner can tail reasoning/tool steps for the turn. The runner dedups
|
|
60
|
+
# prompts it injected itself. Fire-and-forget; never blocks or fails the turn.
|
|
61
|
+
local payload="${1:-}"
|
|
62
|
+
local port="${AGENT_RELAY_RUNNER_PORT:-}"
|
|
63
|
+
[ -z "$port" ] && return 0
|
|
64
|
+
[ -z "$payload" ] && return 0
|
|
65
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
66
|
+
local body
|
|
67
|
+
body="$(printf '%s' "$payload" | jq -c '{prompt: (.prompt // ""), transcriptPath: (.transcript_path // "")}' 2>/dev/null || true)"
|
|
68
|
+
[ -z "$body" ] && return 0
|
|
69
|
+
case "$body" in *'"prompt":""'*) return 0 ;; esac
|
|
70
|
+
curl -fsS --max-time 3 -X POST "http://127.0.0.1:${port}/user-prompt" \
|
|
71
|
+
-H 'Content-Type: application/json' \
|
|
72
|
+
-d "$body" >/dev/null 2>&1 || true
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
relay_pending_reply_stop_decision() {
|
|
57
76
|
local port="${AGENT_RELAY_RUNNER_PORT:-}"
|
|
58
77
|
[ -z "$port" ] && return 0
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks/relay-status.sh"
|
|
4
|
+
payload="$(cat || true)"
|
|
4
5
|
relay_post_status busy
|
|
6
|
+
# Mirror a terminal/TUI-typed prompt into the dashboard chat and start reasoning
|
|
7
|
+
# tailing for this turn. No-op for prompts the runner injected (chat box / relay).
|
|
8
|
+
relay_post_user_prompt "$payload"
|
|
5
9
|
# Re-surface the request-review reminder each turn while there is unmerged
|
|
6
10
|
# committed work — so a long session can't "forget" to land it. Silent otherwise.
|
|
7
11
|
relay_emit_additional_context UserPromptSubmit "$(relay_review_reminder_text || true)"
|
package/src/adapter.ts
CHANGED
|
@@ -27,6 +27,20 @@ export interface ProviderStatusEvent {
|
|
|
27
27
|
|
|
28
28
|
export type ProviderStatusUpdate = SemanticStatus | ProviderStatusEvent;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A session-mirror event surfaced by an adapter that learns about session
|
|
32
|
+
* activity through provider events rather than hooks/transcripts (e.g. the Codex
|
|
33
|
+
* app-server). The runner turns these into `kind: "session"` chat messages, the
|
|
34
|
+
* same lane Claude's transcript capture uses. Provider-independent boundary.
|
|
35
|
+
*/
|
|
36
|
+
export interface ProviderSessionEvent {
|
|
37
|
+
type: "prompt" | "response" | "reasoning" | "tool";
|
|
38
|
+
body: string;
|
|
39
|
+
origin?: "chat" | "terminal" | "provider";
|
|
40
|
+
turnId?: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
export interface ProviderConfig {
|
|
31
45
|
command: string;
|
|
32
46
|
defaultArgs: string[];
|
|
@@ -36,6 +50,9 @@ export interface ProviderConfig {
|
|
|
36
50
|
defaultApprovalMode: string;
|
|
37
51
|
defaultTags: string[];
|
|
38
52
|
chatCaptureMode: "final" | "full";
|
|
53
|
+
// When false, the runner does not stream reasoning/tool steps into chat. Defaults
|
|
54
|
+
// to enabled (steps render discreetly, never as chat bubbles).
|
|
55
|
+
reasoningCapture?: boolean;
|
|
39
56
|
headless: {
|
|
40
57
|
tmuxPrefix: string;
|
|
41
58
|
shutdownTimeoutMs: number;
|
|
@@ -110,11 +127,24 @@ export interface ProviderAdapter {
|
|
|
110
127
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
111
128
|
compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
112
129
|
clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
130
|
+
// Interrupt the in-flight turn without ending the session (ESC for Claude's
|
|
131
|
+
// tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
|
|
132
|
+
// the runner boundary; each adapter does what its provider actually supports.
|
|
133
|
+
interrupt?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
134
|
+
// Out-of-band activity probe for the busy-state reconciler: returns the real
|
|
135
|
+
// provider activity when the runner's claim state may have gone stale (e.g. the
|
|
136
|
+
// turn was interrupted from the web terminal so no Stop hook fired). "unknown"
|
|
137
|
+
// means the provider can't be cheaply probed and the reconciler should defer.
|
|
138
|
+
probeActivity?(process: ManagedProcess): Promise<"busy" | "idle" | "unknown">;
|
|
113
139
|
terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
|
|
114
140
|
respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
|
|
115
141
|
deliverInitialPrompt?(process: ManagedProcess, prompt: string): Promise<void>;
|
|
116
142
|
deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
|
|
117
143
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
|
|
144
|
+
// Subscribe to session-mirror events from providers that emit them directly
|
|
145
|
+
// (Codex app-server item events). Claude mirrors via hooks/transcript instead,
|
|
146
|
+
// so it leaves this unimplemented.
|
|
147
|
+
onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
|
|
118
148
|
// Headless providers with no tmux session (e.g. the Codex app-server) still
|
|
119
149
|
// warrant an automatic restart on unexpected exit. Returning true opts the
|
|
120
150
|
// provider into the runner's restart-with-backoff path.
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
interface TranscriptBlock {
|
|
14
14
|
type?: string;
|
|
15
15
|
text?: string;
|
|
16
|
+
thinking?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
input?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TurnStep {
|
|
22
|
+
type: "reasoning" | "tool";
|
|
23
|
+
text: string;
|
|
24
|
+
label?: string;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
interface TranscriptMessage {
|
|
@@ -134,6 +143,49 @@ export function extractFinalAssistantMessage(jsonl: string): string {
|
|
|
134
143
|
* string or an array of content blocks (same shape as transcript entries).
|
|
135
144
|
* Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
|
|
136
145
|
*/
|
|
146
|
+
/**
|
|
147
|
+
* Extract the ordered reasoning and tool steps for the most recent turn (since
|
|
148
|
+
* the last real user prompt). Used by the reasoning tailer to stream discreet
|
|
149
|
+
* progress into chat while a turn is in flight. Returns steps in transcript order
|
|
150
|
+
* so the tailer can emit only the ones it hasn't seen yet by index.
|
|
151
|
+
*/
|
|
152
|
+
export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
|
|
153
|
+
const lines = jsonl.split("\n");
|
|
154
|
+
let steps: TurnStep[] = [];
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (!trimmed) continue;
|
|
158
|
+
let entry: TranscriptEntry;
|
|
159
|
+
try {
|
|
160
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (isRealUserPrompt(entry)) {
|
|
165
|
+
steps = [];
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (entry.type !== "assistant") continue;
|
|
169
|
+
for (const b of blocks(entry.message)) {
|
|
170
|
+
if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
|
|
171
|
+
steps.push({ type: "reasoning", text: b.thinking.trim() });
|
|
172
|
+
} else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
|
|
173
|
+
steps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input) });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return steps;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Compact one-line summary of a tool invocation for the discreet activity row. */
|
|
181
|
+
export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
|
|
182
|
+
const str = (key: string): string | undefined => (input && typeof input[key] === "string" ? (input[key] as string) : undefined);
|
|
183
|
+
const candidate = str("command") ?? str("file_path") ?? str("path") ?? str("pattern") ?? str("query") ?? str("url") ?? str("description") ?? str("prompt");
|
|
184
|
+
const summary = candidate ? candidate.replace(/\s+/g, " ").trim() : "";
|
|
185
|
+
if (!summary) return name;
|
|
186
|
+
return summary.length > 200 ? `${summary.slice(0, 197)}…` : summary;
|
|
187
|
+
}
|
|
188
|
+
|
|
137
189
|
export function extractHookAssistantMessage(content: unknown): string {
|
|
138
190
|
if (typeof content === "string") return content.trim();
|
|
139
191
|
if (!Array.isArray(content)) return "";
|
package/src/adapters/claude.ts
CHANGED
|
@@ -60,6 +60,33 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
60
60
|
return { method: "tmux-inject", command: "/clear" };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
64
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
65
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
66
|
+
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session to interrupt");
|
|
67
|
+
// The same ESC the web terminal's aux key sends: cancels the in-flight turn
|
|
68
|
+
// and drops Claude back to its input box without ending the session.
|
|
69
|
+
const result = Bun.spawnSync(tmuxCommand(socket, "send-keys", "-t", session, "Escape"), {
|
|
70
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
71
|
+
});
|
|
72
|
+
if (result.exitCode !== 0) {
|
|
73
|
+
const stderr = result.stderr.toString().trim();
|
|
74
|
+
throw new Error(`tmux interrupt failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
75
|
+
}
|
|
76
|
+
return { method: "tmux-escape" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async probeActivity(process: ManagedProcess): Promise<"busy" | "idle" | "unknown"> {
|
|
80
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
81
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
82
|
+
if (!session || !tmuxHasSession(session, socket)) return "unknown";
|
|
83
|
+
let pane: string;
|
|
84
|
+
try { pane = captureTmuxPane(session, socket); } catch { return "unknown"; }
|
|
85
|
+
if (claudePaneIsBusy(pane)) return "busy";
|
|
86
|
+
if (claudePaneLooksReady(pane)) return "idle";
|
|
87
|
+
return "unknown";
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
|
|
64
91
|
const monitor = process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
|
|
65
92
|
// A monitor object always exists for headless claude (it proxies to the runner
|
package/src/adapters/codex.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirS
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
4
|
import type { ContextState, Message } from "agent-relay-sdk";
|
|
5
|
-
import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
|
|
5
|
+
import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
|
|
6
6
|
import { workspaceDepsNoteFromEnv } from "../relay-instructions";
|
|
7
7
|
|
|
8
8
|
/** Relay context prepended to a Codex agent's first turn: the standard relay
|
|
@@ -24,13 +24,42 @@ type PendingCodexApproval = {
|
|
|
24
24
|
export class CodexAdapter implements ProviderAdapter {
|
|
25
25
|
readonly provider = "codex";
|
|
26
26
|
private statusCb: (status: ProviderStatusUpdate) => void = () => {};
|
|
27
|
+
private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
|
|
27
28
|
private readonly subagentThreads = new Map<string, { label?: string; role?: string; parentId?: string }>();
|
|
28
29
|
private readonly pendingApprovals = new Map<string, PendingCodexApproval>();
|
|
30
|
+
// Active turn id for the main thread, captured from turn/started so an interrupt
|
|
31
|
+
// can target the in-flight turn. Cleared on turn/completed.
|
|
32
|
+
private activeTurnId?: string;
|
|
33
|
+
// Assistant message text accumulated across the current turn's agentMessage items,
|
|
34
|
+
// flushed as one session response on turn/completed (mirrors Claude's chatCaptureMode).
|
|
35
|
+
private turnMessages: string[] = [];
|
|
36
|
+
private captureMode: "final" | "full" = "final";
|
|
29
37
|
|
|
30
38
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
|
|
31
39
|
this.statusCb = cb;
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
onSessionEvent(cb: (event: ProviderSessionEvent) => void): void {
|
|
43
|
+
this.sessionEventCb = cb;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
47
|
+
const client = process.meta?.client as CodexAppClient | undefined;
|
|
48
|
+
if (!client) throw new Error("Codex App Server client is unavailable");
|
|
49
|
+
const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
|
|
50
|
+
if (!threadId) throw new Error("Codex thread is not ready");
|
|
51
|
+
if (!this.activeTurnId) throw new Error("no active Codex turn to interrupt");
|
|
52
|
+
await client.turnInterrupt(threadId, this.activeTurnId);
|
|
53
|
+
return { method: "turn-interrupt", turnId: this.activeTurnId };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Codex streams thread/status continuously, so the runner's claim state never
|
|
57
|
+
// goes stale the way Claude's can after an out-of-band interrupt. No cheap probe
|
|
58
|
+
// is needed — defer to the live status stream.
|
|
59
|
+
async probeActivity(): Promise<"busy" | "idle" | "unknown"> {
|
|
60
|
+
return "unknown";
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
// The Codex app-server is headless and has no tmux session, but an unexpected
|
|
35
64
|
// exit should still be restarted with backoff rather than resolved as a final exit.
|
|
36
65
|
supportsUnexpectedExitRestart(): boolean {
|
|
@@ -38,6 +67,7 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
38
67
|
}
|
|
39
68
|
|
|
40
69
|
async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
|
|
70
|
+
this.captureMode = (config.providerConfig as ProviderConfig).chatCaptureMode ?? "final";
|
|
41
71
|
const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
|
|
42
72
|
const appServer = Bun.spawn([args.command, ...args.args], {
|
|
43
73
|
cwd: args.cwd,
|
|
@@ -295,16 +325,25 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
295
325
|
if (threadId && this.subagentThreads.has(threadId)) {
|
|
296
326
|
this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
|
|
297
327
|
} else {
|
|
298
|
-
|
|
328
|
+
const turn = isRecord(params?.turn) ? params.turn : undefined;
|
|
329
|
+
this.activeTurnId = stringValue(turn?.id);
|
|
330
|
+
this.turnMessages = [];
|
|
331
|
+
this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
|
|
299
332
|
}
|
|
300
333
|
}
|
|
301
334
|
if (method.includes("turn/completed") || method.includes("turn.completed")) {
|
|
302
335
|
if (threadId && this.subagentThreads.has(threadId)) {
|
|
303
336
|
this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
|
|
304
337
|
} else {
|
|
305
|
-
this.
|
|
338
|
+
this.flushTurnResponse();
|
|
339
|
+
const completedTurnId = this.activeTurnId;
|
|
340
|
+
this.activeTurnId = undefined;
|
|
341
|
+
this.statusCb({ status: "idle", reason: "provider-turn", id: completedTurnId });
|
|
306
342
|
}
|
|
307
343
|
}
|
|
344
|
+
if ((method.includes("item/completed") || method.includes("item.completed")) && !isSubagent) {
|
|
345
|
+
this.handleCodexItem(isRecord(params?.item) ? params.item : undefined);
|
|
346
|
+
}
|
|
308
347
|
if (method.includes("thread/status")) {
|
|
309
348
|
const status = statusType(params?.status);
|
|
310
349
|
if (threadId && this.subagentThreads.has(threadId)) {
|
|
@@ -317,6 +356,40 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
317
356
|
}
|
|
318
357
|
}
|
|
319
358
|
|
|
359
|
+
// Turn one completed Codex thread item into a session-mirror event. agentMessage
|
|
360
|
+
// text is accumulated and flushed as a single response on turn/completed; the rest
|
|
361
|
+
// (user prompt echo, reasoning, tool steps) is surfaced as it lands.
|
|
362
|
+
private handleCodexItem(item: Record<string, unknown> | undefined): void {
|
|
363
|
+
if (!item) return;
|
|
364
|
+
const type = stringValue(item.type);
|
|
365
|
+
const turnId = this.activeTurnId;
|
|
366
|
+
if (type === "agentMessage") {
|
|
367
|
+
const text = stringValue(item.text)?.trim();
|
|
368
|
+
if (text) this.turnMessages.push(text);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (type === "userMessage") {
|
|
372
|
+
const text = codexUserMessageText(item.content);
|
|
373
|
+
if (text) this.sessionEventCb({ type: "prompt", origin: "terminal", body: text, ...(turnId ? { turnId } : {}) });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (type === "reasoning") {
|
|
377
|
+
const text = codexReasoningText(item);
|
|
378
|
+
if (text) this.sessionEventCb({ type: "reasoning", origin: "provider", body: text, ...(turnId ? { turnId } : {}) });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const tool = codexToolSummary(type, item);
|
|
382
|
+
if (tool) this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, ...(turnId ? { turnId } : {}) });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private flushTurnResponse(): void {
|
|
386
|
+
if (!this.turnMessages.length) return;
|
|
387
|
+
const joined = this.captureMode === "full" ? this.turnMessages.join("\n\n") : this.turnMessages[this.turnMessages.length - 1]!;
|
|
388
|
+
this.turnMessages = [];
|
|
389
|
+
const text = joined.trim();
|
|
390
|
+
if (text) this.sessionEventCb({ type: "response", origin: "provider", body: text, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
|
|
391
|
+
}
|
|
392
|
+
|
|
320
393
|
private providerStateFromThreadStatus(status: unknown, params?: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
321
394
|
const state = codexProviderStateFromThreadStatus(status, params);
|
|
322
395
|
if (state?.state !== "blocked" || state.reason !== "waitingOnApproval" || state.pendingApproval) return state;
|
|
@@ -337,6 +410,52 @@ function codexApprovalFromServerRequest(message: { id: string | number; method:
|
|
|
337
410
|
};
|
|
338
411
|
}
|
|
339
412
|
|
|
413
|
+
/** Extract the human text from a Codex userMessage item's content (UserInput[]). */
|
|
414
|
+
export function codexUserMessageText(content: unknown): string {
|
|
415
|
+
if (typeof content === "string") return content.trim();
|
|
416
|
+
if (!Array.isArray(content)) return "";
|
|
417
|
+
return content
|
|
418
|
+
.filter(isRecord)
|
|
419
|
+
.filter((part) => part.type === "text" || part.type === "input_text" || part.type === "output_text")
|
|
420
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
421
|
+
.filter(Boolean)
|
|
422
|
+
.join("")
|
|
423
|
+
.trim();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Extract reasoning text from a Codex reasoning item (content[] preferred, summary[] fallback). */
|
|
427
|
+
export function codexReasoningText(item: Record<string, unknown>): string {
|
|
428
|
+
const stringsOf = (value: unknown): string[] =>
|
|
429
|
+
Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && v.trim().length > 0) : [];
|
|
430
|
+
const content = stringsOf(item.content);
|
|
431
|
+
const text = (content.length ? content : stringsOf(item.summary)).join("\n\n").trim();
|
|
432
|
+
return text;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Build a compact { label, body } activity summary for a Codex tool item. */
|
|
436
|
+
export function codexToolSummary(type: string | undefined, item: Record<string, unknown>): { label: string; body: string } | null {
|
|
437
|
+
const oneLine = (value: unknown): string => (typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "");
|
|
438
|
+
const clip = (text: string): string => (text.length > 200 ? `${text.slice(0, 197)}…` : text);
|
|
439
|
+
if (type === "commandExecution") {
|
|
440
|
+
const command = oneLine(item.command);
|
|
441
|
+
return { label: "Shell", body: clip(command || "command") };
|
|
442
|
+
}
|
|
443
|
+
if (type === "fileChange") {
|
|
444
|
+
const changes = Array.isArray(item.changes) ? item.changes.filter(isRecord) : [];
|
|
445
|
+
const files = changes.map((c) => stringValue(c.path) ?? stringValue(c.file) ?? "").filter(Boolean);
|
|
446
|
+
return { label: "Edit", body: clip(files.length ? files.join(", ") : "file changes") };
|
|
447
|
+
}
|
|
448
|
+
if (type === "mcpToolCall" || type === "dynamicToolCall") {
|
|
449
|
+
const tool = stringValue(item.tool) ?? "tool";
|
|
450
|
+
const server = stringValue(item.server) ?? stringValue(item.namespace);
|
|
451
|
+
return { label: server ? `${server}/${tool}` : tool, body: clip(oneLine(JSON.stringify(item.arguments ?? {})) || tool) };
|
|
452
|
+
}
|
|
453
|
+
if (type === "webSearch") {
|
|
454
|
+
return { label: "Search", body: clip(oneLine(item.query) || "web search") };
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
340
459
|
function codexApprovalMethod(method: string): boolean {
|
|
341
460
|
return method === "execCommandApproval" ||
|
|
342
461
|
method === "applyPatchApproval" ||
|
package/src/control-server.ts
CHANGED
|
@@ -24,6 +24,10 @@ interface ControlServerOptions {
|
|
|
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
26
|
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void>;
|
|
27
|
+
// A provider UserPromptSubmit hook hands over the prompt the human typed
|
|
28
|
+
// directly into the session (web terminal / TUI) so the runner can mirror it
|
|
29
|
+
// into the dashboard chat and start tailing the turn transcript for reasoning.
|
|
30
|
+
onUserPrompt?(input: { prompt: string; transcriptPath?: string }): Promise<void>;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function startControlServer(options: ControlServerOptions): ControlServer {
|
|
@@ -66,6 +70,9 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
66
70
|
if (url.pathname === "/session-turn" && req.method === "POST") {
|
|
67
71
|
return handleSessionTurn(req, options);
|
|
68
72
|
}
|
|
73
|
+
if (url.pathname === "/user-prompt" && req.method === "POST") {
|
|
74
|
+
return handleUserPrompt(req, options);
|
|
75
|
+
}
|
|
69
76
|
if (url.pathname === "/monitor") {
|
|
70
77
|
const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
|
|
71
78
|
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
@@ -216,6 +223,27 @@ export function claudePermissionApprovalView(id: string, body: Record<string, un
|
|
|
216
223
|
choices: [],
|
|
217
224
|
};
|
|
218
225
|
}
|
|
226
|
+
// ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
|
|
227
|
+
// match the AskUserQuestion matcher), which used to render the raw tool_input
|
|
228
|
+
// JSON with generic Approve/Deny buttons. Surface the plan as markdown with the
|
|
229
|
+
// real plan-mode choices instead. approve → allow (exit plan mode and proceed);
|
|
230
|
+
// deny → keep planning.
|
|
231
|
+
if (toolName === "ExitPlanMode") {
|
|
232
|
+
const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
|
|
233
|
+
? toolInput.plan
|
|
234
|
+
: JSON.stringify(toolInput);
|
|
235
|
+
return {
|
|
236
|
+
id,
|
|
237
|
+
provider: "claude",
|
|
238
|
+
kind: "plan",
|
|
239
|
+
title: "Claude is ready to code",
|
|
240
|
+
body: plan,
|
|
241
|
+
choices: [
|
|
242
|
+
{ id: "approve", label: "Approve plan" },
|
|
243
|
+
{ id: "deny", label: "Keep planning" },
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
219
247
|
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
220
248
|
const description = typeof toolInput.description === "string" ? toolInput.description : "";
|
|
221
249
|
const bodyText = [
|
|
@@ -304,6 +332,17 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
|
|
|
304
332
|
}
|
|
305
333
|
}
|
|
306
334
|
|
|
335
|
+
async function handleUserPrompt(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
336
|
+
if (!options.onUserPrompt) return Response.json({ ok: false, reason: "prompt echo unavailable" });
|
|
337
|
+
const body = await req.json().catch(() => null);
|
|
338
|
+
const prompt = isRecord(body) && typeof body.prompt === "string" ? body.prompt : "";
|
|
339
|
+
if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
|
|
340
|
+
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
|
|
341
|
+
// Fire-and-forget: the hook must not block Claude's turn on relay round-trips.
|
|
342
|
+
void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath })).catch(() => {});
|
|
343
|
+
return Response.json({ ok: true });
|
|
344
|
+
}
|
|
345
|
+
|
|
307
346
|
async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
308
347
|
const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
|
|
309
348
|
const status = body?.status;
|
package/src/runner.ts
CHANGED
|
@@ -2,14 +2,14 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import type { AgentProfile, ContextState, Message, ProviderCapabilities, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
|
|
5
|
+
import type { AgentProfile, ContextState, Message, MessageSessionMeta, ProviderCapabilities, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
|
|
6
6
|
import { RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
|
|
7
7
|
import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
|
|
8
|
-
import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
8
|
+
import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
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, extractFinalAssistantMessage, extractHookAssistantMessage, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
12
|
+
import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, 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";
|
|
@@ -68,6 +68,27 @@ const RAPID_EXIT_MS = 30 * 1000;
|
|
|
68
68
|
const MAX_RAPID_UNEXPECTED_EXITS = 3;
|
|
69
69
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
70
70
|
const LOG_TAIL_BYTES = 128 * 1024;
|
|
71
|
+
// A UserPromptSubmit echo matching a runner-injected prompt within this window is
|
|
72
|
+
// the same prompt arriving back from the provider — drop it to avoid a duplicate.
|
|
73
|
+
const PROMPT_ECHO_DEDUP_MS = 30_000;
|
|
74
|
+
// Busy reconciler: a conservative LAST-RESORT backstop for a turn that ended
|
|
75
|
+
// without the provider's Stop hook clearing busy (e.g. ESC straight into the web
|
|
76
|
+
// terminal). It must never fire during a live turn, so it (a) only counts idle
|
|
77
|
+
// after it has actually observed the provider busy, and (b) requires a long,
|
|
78
|
+
// unbroken idle streak — an active turn shows its working spinner well within
|
|
79
|
+
// this window, which resets the streak. ~32s of uninterrupted idle = really done.
|
|
80
|
+
const BUSY_RECONCILE_POLL_MS = 4_000;
|
|
81
|
+
const BUSY_RECONCILE_IDLE_CONFIRM = 8;
|
|
82
|
+
// After a dashboard interrupt, give the provider a moment to drop out of its turn,
|
|
83
|
+
// then reconcile immediately so the user sees "stopped" without waiting for the backstop.
|
|
84
|
+
const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
|
|
85
|
+
// Relay-injected content (delivered messages, memory context) is wrapped with
|
|
86
|
+
// these markers; a UserPromptSubmit echo starting with one is a runner injection,
|
|
87
|
+
// not a human typing into the terminal, so it must not be mirrored as a prompt.
|
|
88
|
+
const RELAY_INJECTION_MARKERS = ["[relay message #", "[agent-relay"];
|
|
89
|
+
// Reasoning tailer poll cadence (item 5). Coarse on purpose — reasoning is a
|
|
90
|
+
// discreet progress signal, not a token stream, so ~1.2s keeps it light.
|
|
91
|
+
const REASONING_POLL_MS = 1_200;
|
|
71
92
|
const CLAUDE_RESUME_RE = /\bclaude\s+--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
|
|
72
93
|
|
|
73
94
|
interface RunnerTimelineEvent {
|
|
@@ -115,6 +136,28 @@ export class AgentRunner {
|
|
|
115
136
|
private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
|
|
116
137
|
private pendingTimelineEvent?: RunnerTimelineEvent;
|
|
117
138
|
private pendingPromptMessageId?: number;
|
|
139
|
+
// Session-mirror: a synthesized id grouping a turn's reasoning/tool steps and
|
|
140
|
+
// its final response. Set when a provider-turn starts, cleared when it ends.
|
|
141
|
+
private currentTurnId?: string;
|
|
142
|
+
// Prompt-echo dedup: the last prompt the runner itself injected (chat box or
|
|
143
|
+
// initial prompt). A UserPromptSubmit hook echo matching this within the window
|
|
144
|
+
// is the same prompt arriving back from the provider and must not double-post.
|
|
145
|
+
private lastInjectedPrompt?: { text: string; at: number };
|
|
146
|
+
// Busy reconciler: consecutive idle probes observed while claims still say busy.
|
|
147
|
+
private busyReconcileIdleStreak = 0;
|
|
148
|
+
private busyReconcileTimer?: ReturnType<typeof setInterval>;
|
|
149
|
+
// The reconciler only trusts an "idle" reading once it has seen the provider
|
|
150
|
+
// actually busy this turn — so a flaky/always-idle probe can never false-clear.
|
|
151
|
+
private busyReconcileSawBusy = false;
|
|
152
|
+
// Verbose session-mirror diagnostics (turn lifecycle, reconciler probes, tail
|
|
153
|
+
// emits) → a dedicated clean log. Always on for key transitions; AGENT_RELAY_SESSION_DEBUG=1 adds the high-frequency probe/emit lines.
|
|
154
|
+
private readonly sessionDebugVerbose = process.env.AGENT_RELAY_SESSION_DEBUG === "1";
|
|
155
|
+
// Tracks whether the provider is in a legitimate blocked/approval state, so the
|
|
156
|
+
// busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
|
|
157
|
+
private providerBlocked = false;
|
|
158
|
+
// Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
|
|
159
|
+
// from the Claude transcript into chat as discreet session events.
|
|
160
|
+
private reasoningTail?: { timer: ReturnType<typeof setInterval>; seen: Set<string> };
|
|
118
161
|
private scratch?: SessionScratchLayout;
|
|
119
162
|
|
|
120
163
|
constructor(private readonly options: RunnerOptions) {
|
|
@@ -195,6 +238,7 @@ export class AgentRunner {
|
|
|
195
238
|
onTerminalAttachSpec: () => this.terminalAttachSpec(),
|
|
196
239
|
onReplyObligations: () => this.http.listReplyObligations(this.agentId),
|
|
197
240
|
onSessionTurn: (input) => this.publishSessionTurn(input),
|
|
241
|
+
onUserPrompt: (input) => this.handleUserPrompt(input),
|
|
198
242
|
});
|
|
199
243
|
this.writeRunnerInfoFile();
|
|
200
244
|
this.options.adapter.onStatusChange((status) => {
|
|
@@ -207,6 +251,7 @@ export class AgentRunner {
|
|
|
207
251
|
this.setProviderStatus(status);
|
|
208
252
|
if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
|
|
209
253
|
});
|
|
254
|
+
this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
|
|
210
255
|
this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
|
|
211
256
|
this.bus.on("command", (type, params, commandId, command) => {
|
|
212
257
|
void this.handleCommand(type, params, commandId, command);
|
|
@@ -250,6 +295,8 @@ export class AgentRunner {
|
|
|
250
295
|
this.httpLivenessTimer = undefined;
|
|
251
296
|
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
252
297
|
this.tokenRenewTimer = undefined;
|
|
298
|
+
this.disarmBusyReconciler();
|
|
299
|
+
this.stopReasoningTail();
|
|
253
300
|
this.control?.stop();
|
|
254
301
|
await this.bus.close();
|
|
255
302
|
}
|
|
@@ -428,7 +475,7 @@ export class AgentRunner {
|
|
|
428
475
|
private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
|
|
429
476
|
const target = typeof command?.target === "string" ? command.target : this.agentId;
|
|
430
477
|
if (target !== this.agentId && target !== this.options.runnerId) return;
|
|
431
|
-
if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "prompt.inject") return;
|
|
478
|
+
if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "agent.interrupt" && type !== "prompt.inject") return;
|
|
432
479
|
|
|
433
480
|
const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
|
|
434
481
|
if (exitAfterCommand) {
|
|
@@ -451,6 +498,11 @@ export class AgentRunner {
|
|
|
451
498
|
} else if (type === "agent.clearContext") {
|
|
452
499
|
if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
|
|
453
500
|
providerResult = await this.options.adapter.clearContext(this.process);
|
|
501
|
+
} else if (type === "agent.interrupt") {
|
|
502
|
+
if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
|
|
503
|
+
this.sessionLog("interrupt requested from dashboard");
|
|
504
|
+
providerResult = await this.options.adapter.interrupt(this.process);
|
|
505
|
+
this.scheduleInterruptReconcile();
|
|
454
506
|
} else if (type === "agent.injectContext") {
|
|
455
507
|
if (!this.process) throw new Error("provider process is unavailable");
|
|
456
508
|
providerResult = await this.injectContext(params);
|
|
@@ -546,6 +598,9 @@ export class AgentRunner {
|
|
|
546
598
|
if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
|
|
547
599
|
const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
|
|
548
600
|
if (messageId) this.pendingPromptMessageId = messageId;
|
|
601
|
+
// Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
|
|
602
|
+
// prompt already created its own session message shown in the dashboard.
|
|
603
|
+
this.lastInjectedPrompt = { text: body.trim(), at: Date.now() };
|
|
549
604
|
await this.options.adapter.deliverInitialPrompt(this.process, body);
|
|
550
605
|
return { injected: true, messageId };
|
|
551
606
|
}
|
|
@@ -785,6 +840,24 @@ export class AgentRunner {
|
|
|
785
840
|
...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
|
|
786
841
|
};
|
|
787
842
|
}
|
|
843
|
+
if (typeof update !== "string" && update.providerState) {
|
|
844
|
+
const state = (update.providerState as { state?: unknown }).state;
|
|
845
|
+
this.providerBlocked = state === "blocked";
|
|
846
|
+
} else if (status === "idle") {
|
|
847
|
+
this.providerBlocked = false;
|
|
848
|
+
}
|
|
849
|
+
if (status === "busy" && reason === "provider-turn") {
|
|
850
|
+
if (!this.currentTurnId) {
|
|
851
|
+
this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
|
|
852
|
+
this.sessionLog(`turn started (turn ${this.currentTurnId})`);
|
|
853
|
+
}
|
|
854
|
+
this.armBusyReconciler();
|
|
855
|
+
} else if (status === "idle" && reason === "provider-turn") {
|
|
856
|
+
if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
|
|
857
|
+
this.currentTurnId = undefined;
|
|
858
|
+
this.disarmBusyReconciler();
|
|
859
|
+
this.stopReasoningTail();
|
|
860
|
+
}
|
|
788
861
|
if (status === "busy") {
|
|
789
862
|
this.claims.clearTerminalStatus();
|
|
790
863
|
this.claims.startWork(reason, id, typeof update === "string" ? {} : {
|
|
@@ -811,14 +884,16 @@ export class AgentRunner {
|
|
|
811
884
|
this.publishStatus();
|
|
812
885
|
}
|
|
813
886
|
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
887
|
+
// Session-mirror lane: capture the assistant turn from the Claude transcript and
|
|
888
|
+
// post it as a "session" message so it shows in the dashboard chat with zero
|
|
889
|
+
// agent tokens. Capture is UNCONDITIONAL — it no longer depends on a triggering
|
|
890
|
+
// relay message existing, so turns started from the web terminal (which create
|
|
891
|
+
// no relay message) are mirrored too. A reply obligation, when present, is still
|
|
892
|
+
// used as replyTo so the Stop hook stops nagging the agent to /reply.
|
|
819
893
|
private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void> {
|
|
820
|
-
|
|
821
|
-
|
|
894
|
+
const turnId = this.currentTurnId;
|
|
895
|
+
this.stopReasoningTail();
|
|
896
|
+
// Optional correlation for threading + obligation clearing — never a capture gate.
|
|
822
897
|
let replyToMessageId: number | undefined;
|
|
823
898
|
const pendingPrompt = this.pendingPromptMessageId;
|
|
824
899
|
if (pendingPrompt) {
|
|
@@ -830,52 +905,269 @@ export class AgentRunner {
|
|
|
830
905
|
const obligation = [...obligations].reverse().find((o) => o.from === "user");
|
|
831
906
|
replyToMessageId = obligation?.messageId;
|
|
832
907
|
} catch {
|
|
833
|
-
|
|
908
|
+
// fall through and capture without correlation
|
|
834
909
|
}
|
|
835
910
|
}
|
|
836
|
-
if (!replyToMessageId) return;
|
|
837
911
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
// The Stop hook can fire before the final assistant entry is flushed to
|
|
845
|
-
// disk. Claude Code writes thinking and text as separate entries (both with
|
|
846
|
-
// end_turn), so the transcript can "look complete" while the text entry is
|
|
847
|
-
// still pending. Retry until both the transcript has an end_turn AND the
|
|
848
|
-
// extraction yields non-empty text.
|
|
912
|
+
// The Stop hook can fire before the final assistant entry is flushed to disk.
|
|
913
|
+
// Claude writes thinking and text as separate entries (both with end_turn), so
|
|
914
|
+
// the transcript can "look complete" while the text entry is still pending.
|
|
915
|
+
// Retry until both the transcript has an end_turn AND extraction yields text.
|
|
849
916
|
let body = "";
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
917
|
+
let jsonl: string | undefined;
|
|
918
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
|
|
919
|
+
if (jsonl !== undefined) {
|
|
920
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
921
|
+
if (attempt > 0) {
|
|
922
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
923
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
|
|
924
|
+
}
|
|
925
|
+
if (!transcriptLooksComplete(jsonl)) continue;
|
|
926
|
+
const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
|
|
927
|
+
body = extract(jsonl);
|
|
928
|
+
if (body) break;
|
|
854
929
|
}
|
|
855
|
-
if (!transcriptLooksComplete(jsonl)) continue;
|
|
856
|
-
const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
|
|
857
|
-
body = extract(jsonl);
|
|
858
|
-
if (body) break;
|
|
859
930
|
}
|
|
860
|
-
// Fallback:
|
|
861
|
-
//
|
|
862
|
-
// content in-memory before the hook even fires.
|
|
931
|
+
// Fallback: last_assistant_message from the Stop hook payload, which bypasses
|
|
932
|
+
// the transcript file race entirely.
|
|
863
933
|
if (!body && input.lastAssistantMessage) {
|
|
864
934
|
body = extractHookAssistantMessage(input.lastAssistantMessage);
|
|
865
935
|
}
|
|
866
|
-
|
|
936
|
+
// A pure tool-use turn with no closing text is fine to skip — its reasoning and
|
|
937
|
+
// tool steps already carried the visibility into chat.
|
|
938
|
+
if (!body) {
|
|
939
|
+
this.sessionLog(`response capture: no closing text for turn ${turnId ?? "?"} (skipped)`);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
867
942
|
|
|
943
|
+
this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
|
|
944
|
+
await this.publishSessionEvent({
|
|
945
|
+
from: this.agentId,
|
|
946
|
+
to: "user",
|
|
947
|
+
body,
|
|
948
|
+
...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
|
|
949
|
+
session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Post one session-mirror event (prompt echo, assistant response, reasoning or
|
|
954
|
+
// tool step) as a `kind: "session"` relay message tagged with payload.session so
|
|
955
|
+
// the dashboard can render the live provider session faithfully. Display-only:
|
|
956
|
+
// session messages are never delivered back into a provider.
|
|
957
|
+
private async publishSessionEvent(input: {
|
|
958
|
+
from: string;
|
|
959
|
+
to: string;
|
|
960
|
+
body: string;
|
|
961
|
+
session: MessageSessionMeta;
|
|
962
|
+
replyTo?: number;
|
|
963
|
+
}): Promise<void> {
|
|
868
964
|
try {
|
|
869
965
|
await this.http.sendMessage({
|
|
966
|
+
from: input.from,
|
|
967
|
+
to: input.to,
|
|
968
|
+
...(input.replyTo ? { replyTo: input.replyTo } : {}),
|
|
969
|
+
kind: "session",
|
|
970
|
+
body: input.body,
|
|
971
|
+
payload: { session: { provider: this.options.provider, ...input.session } },
|
|
972
|
+
});
|
|
973
|
+
} catch (error) {
|
|
974
|
+
this.logRunnerDiagnostic(`session ${input.session.type} capture failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// A human typed a prompt directly into the provider (web terminal / TUI). Mirror
|
|
979
|
+
// it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
|
|
980
|
+
// tailing for the turn. Skips prompts the runner itself injected (chat box, relay
|
|
981
|
+
// deliveries) so those aren't double-posted.
|
|
982
|
+
private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
|
|
983
|
+
if (!this.currentTurnId) this.currentTurnId = crypto.randomUUID();
|
|
984
|
+
const text = input.prompt.trim();
|
|
985
|
+
if (text && !this.isRunnerInjectedPrompt(text)) {
|
|
986
|
+
this.sessionLog(`prompt echoed from terminal (${text.length} chars)`);
|
|
987
|
+
await this.publishSessionEvent({
|
|
988
|
+
from: "user",
|
|
989
|
+
to: this.agentId,
|
|
990
|
+
body: text,
|
|
991
|
+
session: { type: "prompt", origin: "terminal", turnId: this.currentTurnId },
|
|
992
|
+
});
|
|
993
|
+
} else if (text) {
|
|
994
|
+
this.sessionDebug("user-prompt hook: skipped echo (runner-injected)");
|
|
995
|
+
}
|
|
996
|
+
if (input.transcriptPath) this.startReasoningTail(input.transcriptPath);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Route a provider-emitted session event (Codex app-server) into the chat mirror.
|
|
1000
|
+
// Mirrors the same semantics as the Claude lane: prompts are echoed with dedup,
|
|
1001
|
+
// and a response is only auto-captured when the agent won't separately reply to a
|
|
1002
|
+
// relay obligation (so relay-triggered turns aren't double-posted).
|
|
1003
|
+
private async publishProviderSessionEvent(event: ProviderSessionEvent): Promise<void> {
|
|
1004
|
+
const body = event.body.trim();
|
|
1005
|
+
if (!body) return;
|
|
1006
|
+
const turnId = event.turnId ?? this.currentTurnId;
|
|
1007
|
+
if (event.type === "prompt") {
|
|
1008
|
+
if (this.isRunnerInjectedPrompt(body)) return;
|
|
1009
|
+
await this.publishSessionEvent({
|
|
1010
|
+
from: "user",
|
|
1011
|
+
to: this.agentId,
|
|
1012
|
+
body,
|
|
1013
|
+
session: { type: "prompt", origin: event.origin ?? "terminal", ...(turnId ? { turnId } : {}) },
|
|
1014
|
+
});
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (event.type === "response") {
|
|
1018
|
+
// If a relay message is awaiting the agent's own reply, let the agent answer
|
|
1019
|
+
// it (Codex agents reply via their relay skills) instead of double-posting.
|
|
1020
|
+
let replyToMessageId: number | undefined;
|
|
1021
|
+
const pendingPrompt = this.pendingPromptMessageId;
|
|
1022
|
+
if (pendingPrompt) {
|
|
1023
|
+
replyToMessageId = pendingPrompt;
|
|
1024
|
+
this.pendingPromptMessageId = undefined;
|
|
1025
|
+
} else {
|
|
1026
|
+
try {
|
|
1027
|
+
const obligations = await this.http.listReplyObligations(this.agentId);
|
|
1028
|
+
if (obligations.some((o) => o.from === "user")) return;
|
|
1029
|
+
} catch {
|
|
1030
|
+
// capture anyway on lookup failure
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
await this.publishSessionEvent({
|
|
870
1034
|
from: this.agentId,
|
|
871
1035
|
to: "user",
|
|
872
|
-
replyTo: replyToMessageId,
|
|
873
|
-
kind: "session",
|
|
874
1036
|
body,
|
|
1037
|
+
...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
|
|
1038
|
+
session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) },
|
|
875
1039
|
});
|
|
876
|
-
|
|
877
|
-
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (this.options.providerConfig.reasoningCapture === false) return;
|
|
1043
|
+
await this.publishSessionEvent({
|
|
1044
|
+
from: this.agentId,
|
|
1045
|
+
to: "user",
|
|
1046
|
+
body,
|
|
1047
|
+
session: { type: event.type, origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.label ? { label: event.label } : {}) },
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private isRunnerInjectedPrompt(text: string): boolean {
|
|
1052
|
+
if (RELAY_INJECTION_MARKERS.some((marker) => text.startsWith(marker))) return true;
|
|
1053
|
+
const recent = this.lastInjectedPrompt;
|
|
1054
|
+
if (recent && recent.text === text && Date.now() - recent.at < PROMPT_ECHO_DEDUP_MS) {
|
|
1055
|
+
this.lastInjectedPrompt = undefined;
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// --- Busy-state reconciler (item 2) -------------------------------------------------
|
|
1062
|
+
// A safety net for turns that end out of band (interrupted from the web terminal,
|
|
1063
|
+
// a hook that never fired) where the runner would otherwise stay stuck "busy".
|
|
1064
|
+
private armBusyReconciler(): void {
|
|
1065
|
+
if (this.busyReconcileTimer || !this.options.adapter.probeActivity) return;
|
|
1066
|
+
this.busyReconcileIdleStreak = 0;
|
|
1067
|
+
this.busyReconcileSawBusy = false;
|
|
1068
|
+
this.busyReconcileTimer = setInterval(() => { void this.runBusyReconcile(); }, BUSY_RECONCILE_POLL_MS);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private disarmBusyReconciler(): void {
|
|
1072
|
+
if (this.busyReconcileTimer) clearInterval(this.busyReconcileTimer);
|
|
1073
|
+
this.busyReconcileTimer = undefined;
|
|
1074
|
+
this.busyReconcileIdleStreak = 0;
|
|
1075
|
+
this.busyReconcileSawBusy = false;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private async runBusyReconcile(): Promise<void> {
|
|
1079
|
+
if (this.stopped || !this.process || !this.options.adapter.probeActivity) { this.disarmBusyReconciler(); return; }
|
|
1080
|
+
// Only act while the runner still believes a provider turn is in flight, and
|
|
1081
|
+
// never override a legitimate approval/blocked state.
|
|
1082
|
+
if (this.claims.currentStatus() !== "busy" || this.providerBlocked) { this.busyReconcileIdleStreak = 0; return; }
|
|
1083
|
+
if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) { this.disarmBusyReconciler(); return; }
|
|
1084
|
+
let activity: "busy" | "idle" | "unknown";
|
|
1085
|
+
try { activity = await this.options.adapter.probeActivity(this.process); } catch { return; }
|
|
1086
|
+
if (activity === "busy") this.busyReconcileSawBusy = true;
|
|
1087
|
+
// Reset the streak on anything that isn't a confident idle — and never start
|
|
1088
|
+
// counting until we've actually observed the provider busy this turn.
|
|
1089
|
+
if (activity !== "idle" || !this.busyReconcileSawBusy) {
|
|
1090
|
+
if (activity !== "idle") this.busyReconcileIdleStreak = 0;
|
|
1091
|
+
this.sessionDebug(`reconcile probe=${activity} sawBusy=${this.busyReconcileSawBusy} streak=${this.busyReconcileIdleStreak}`);
|
|
1092
|
+
return;
|
|
878
1093
|
}
|
|
1094
|
+
this.busyReconcileIdleStreak += 1;
|
|
1095
|
+
this.sessionDebug(`reconcile probe=idle streak=${this.busyReconcileIdleStreak}/${BUSY_RECONCILE_IDLE_CONFIRM}`);
|
|
1096
|
+
if (this.busyReconcileIdleStreak < BUSY_RECONCILE_IDLE_CONFIRM) return;
|
|
1097
|
+
this.disarmBusyReconciler();
|
|
1098
|
+
this.forceClearProviderTurn("backstop reconciler");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Force-clear a stuck provider-turn claim directly. Unlike the idle status path
|
|
1102
|
+
// it does NOT depend on a matching claim id (the Stop hook keys busy as
|
|
1103
|
+
// provider-turn:provider-turn, but reconciliation has no specific id), and it
|
|
1104
|
+
// deliberately leaves the reasoning tail alone so a late clear can't truncate
|
|
1105
|
+
// a turn's activity stream.
|
|
1106
|
+
private forceClearProviderTurn(reason: string): void {
|
|
1107
|
+
if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) return;
|
|
1108
|
+
this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
|
|
1109
|
+
this.claims.clearWorkKind("provider-turn");
|
|
1110
|
+
this.currentTurnId = undefined;
|
|
1111
|
+
this.publishStatus();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// After a dashboard interrupt, the provider should drop out of its turn; reconcile
|
|
1115
|
+
// promptly so the busy indicator clears even if the Stop hook doesn't fire.
|
|
1116
|
+
private scheduleInterruptReconcile(): void {
|
|
1117
|
+
setTimeout(() => {
|
|
1118
|
+
if (this.stopped || !this.process) return;
|
|
1119
|
+
void (async () => {
|
|
1120
|
+
if (this.claims.currentStatus() !== "busy" || this.providerBlocked) return;
|
|
1121
|
+
let activity: "busy" | "idle" | "unknown" = "unknown";
|
|
1122
|
+
try { if (this.options.adapter.probeActivity) activity = await this.options.adapter.probeActivity(this.process!); } catch { return; }
|
|
1123
|
+
this.sessionDebug(`post-interrupt reconcile probe=${activity}`);
|
|
1124
|
+
if (activity === "idle") this.forceClearProviderTurn("post-interrupt");
|
|
1125
|
+
})();
|
|
1126
|
+
}, INTERRUPT_RECONCILE_DELAY_MS);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// --- Reasoning tailer (item 5) ------------------------------------------------------
|
|
1130
|
+
// Tail the in-flight turn's Claude transcript and surface new reasoning/tool steps
|
|
1131
|
+
// as discreet session events. Coalesced and coarse; the final response still comes
|
|
1132
|
+
// through publishSessionTurn.
|
|
1133
|
+
private startReasoningTail(transcriptPath: string): void {
|
|
1134
|
+
if (this.options.providerConfig.reasoningCapture === false) return;
|
|
1135
|
+
this.stopReasoningTail();
|
|
1136
|
+
// Track emitted steps by content signature, not by index/count: the "latest
|
|
1137
|
+
// turn" window in the transcript can shrink/reset (a tool_result entry, a
|
|
1138
|
+
// mid-turn user line), and an index cursor would then either re-emit or stall
|
|
1139
|
+
// and drop the rest of the turn. A seen-set is idempotent under any reshuffle.
|
|
1140
|
+
const seen = new Set<string>();
|
|
1141
|
+
const turnIdAtStart = this.currentTurnId;
|
|
1142
|
+
const poll = async (): Promise<void> => {
|
|
1143
|
+
let jsonl: string;
|
|
1144
|
+
try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
|
|
1145
|
+
let steps: ReturnType<typeof extractLatestTurnSteps>;
|
|
1146
|
+
try { steps = extractLatestTurnSteps(jsonl); } catch { return; }
|
|
1147
|
+
const turnId = this.currentTurnId ?? turnIdAtStart;
|
|
1148
|
+
let emitted = 0;
|
|
1149
|
+
for (const step of steps) {
|
|
1150
|
+
const sig = `${step.type}${step.label ?? ""}${step.text}`;
|
|
1151
|
+
if (seen.has(sig)) continue;
|
|
1152
|
+
seen.add(sig);
|
|
1153
|
+
emitted += 1;
|
|
1154
|
+
void this.publishSessionEvent({
|
|
1155
|
+
from: this.agentId,
|
|
1156
|
+
to: "user",
|
|
1157
|
+
body: step.text,
|
|
1158
|
+
session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (emitted) this.sessionDebug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
|
|
1162
|
+
};
|
|
1163
|
+
this.reasoningTail = { seen, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
|
|
1164
|
+
this.sessionLog(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
|
|
1165
|
+
void poll();
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private stopReasoningTail(): void {
|
|
1169
|
+
if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
|
|
1170
|
+
this.reasoningTail = undefined;
|
|
879
1171
|
}
|
|
880
1172
|
|
|
881
1173
|
private publishStatus(): void {
|
|
@@ -981,6 +1273,24 @@ export class AgentRunner {
|
|
|
981
1273
|
}
|
|
982
1274
|
}
|
|
983
1275
|
|
|
1276
|
+
// Session-mirror diagnostics → a dedicated, ANSI-free, greppable log per agent
|
|
1277
|
+
// (NOT the provider's TUI stdout, which is unreadable). This is the single place
|
|
1278
|
+
// to look when chat/terminal sync misbehaves. Key transitions always log here.
|
|
1279
|
+
private sessionLog(message: string): void {
|
|
1280
|
+
try {
|
|
1281
|
+
const logDir = join(process.env.HOME || ".", ".agent-relay", "logs");
|
|
1282
|
+
mkdirSync(logDir, { recursive: true });
|
|
1283
|
+
appendFileSync(join(logDir, `session-mirror-${safeLogName(this.agentId)}.log`), `[${new Date().toISOString()}] ${message}\n`);
|
|
1284
|
+
} catch {
|
|
1285
|
+
// best-effort
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Verbose, high-frequency lines (per-probe, per-emit) — only when AGENT_RELAY_SESSION_DEBUG=1.
|
|
1290
|
+
private sessionDebug(message: string): void {
|
|
1291
|
+
if (this.sessionDebugVerbose) this.sessionLog(message);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
984
1294
|
private ensureScratch(): void {
|
|
985
1295
|
try {
|
|
986
1296
|
this.scratch = ensureSessionScratch({
|
|
@@ -1451,6 +1761,13 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
|
|
|
1451
1761
|
liveSession: {
|
|
1452
1762
|
capture: true,
|
|
1453
1763
|
inject: Boolean(options.adapter.deliverInitialPrompt),
|
|
1764
|
+
interrupt: Boolean(options.adapter.interrupt),
|
|
1765
|
+
// Both providers mirror directly-typed prompts and stream reasoning/tool
|
|
1766
|
+
// activity into chat (Claude via hooks + transcript tail, Codex via
|
|
1767
|
+
// app-server item events).
|
|
1768
|
+
promptEcho: true,
|
|
1769
|
+
reasoning: true,
|
|
1770
|
+
slashCommands: options.provider === "claude" || options.provider === "codex",
|
|
1454
1771
|
},
|
|
1455
1772
|
source: "runtime",
|
|
1456
1773
|
confidence: "reported",
|