agent-relay-runner 0.11.8 → 0.12.0
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 +58 -0
- package/plugins/claude/hooks/session-start.sh +6 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +7 -0
- package/src/adapter.ts +34 -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 +524 -67
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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
|
|
@@ -82,3 +101,42 @@ relay_json_bool_field() {
|
|
|
82
101
|
relay_json_escape() {
|
|
83
102
|
printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
84
103
|
}
|
|
104
|
+
|
|
105
|
+
# Print a short "request-review when done" reminder to stdout IFF this agent owns
|
|
106
|
+
# an isolated workspace whose branch has committed work not yet integrated into
|
|
107
|
+
# base. Prints nothing otherwise — so plain chat, shared-mode, and no-change
|
|
108
|
+
# sessions never pay context/token cost. Reads the workspace from
|
|
109
|
+
# AGENT_RELAY_WORKSPACE_JSON (set by the orchestrator at spawn) and does a local,
|
|
110
|
+
# network-free git count. Always returns 0 (never aborts its caller).
|
|
111
|
+
relay_review_reminder_text() {
|
|
112
|
+
local ws="${AGENT_RELAY_WORKSPACE_JSON:-}"
|
|
113
|
+
[ -z "$ws" ] && return 0
|
|
114
|
+
local mode worktree base id branch ahead
|
|
115
|
+
mode="$(relay_json_string_field mode "$ws")"
|
|
116
|
+
[ "$mode" = "isolated" ] || return 0
|
|
117
|
+
worktree="$(relay_json_string_field worktreePath "$ws")"
|
|
118
|
+
base="$(relay_json_string_field baseSha "$ws")"
|
|
119
|
+
[ -z "$base" ] && base="$(relay_json_string_field baseRef "$ws")"
|
|
120
|
+
id="$(relay_json_string_field id "$ws")"
|
|
121
|
+
branch="$(relay_json_string_field branch "$ws")"
|
|
122
|
+
[ -n "$worktree" ] || return 0
|
|
123
|
+
[ -n "$base" ] || return 0
|
|
124
|
+
[ -n "$id" ] || return 0
|
|
125
|
+
ahead="$(git -C "$worktree" rev-list --count "${base}..HEAD" 2>/dev/null || echo 0)"
|
|
126
|
+
case "$ahead" in ''|*[!0-9]*) ahead=0 ;; esac
|
|
127
|
+
[ "$ahead" -gt 0 ] || return 0
|
|
128
|
+
printf '[agent-relay] You have %s committed change(s) on `%s` that are not yet integrated into base. If your task is complete, request review so Agent Relay can auto-land it: POST /api/workspaces/%s/actions with {"action":"request-review"}. If you are still working, ignore this — it only appears while there is unmerged committed work.' \
|
|
129
|
+
"$ahead" "${branch:-this branch}" "$id"
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Wrap reminder text in the Claude Code additionalContext envelope for a given
|
|
134
|
+
# hook event. Emits nothing when the text is empty.
|
|
135
|
+
relay_emit_additional_context() {
|
|
136
|
+
local event="${1:-}" text="${2:-}"
|
|
137
|
+
[ -z "$event" ] && return 0
|
|
138
|
+
[ -z "$text" ] && return 0
|
|
139
|
+
printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}' \
|
|
140
|
+
"$event" "$(relay_json_escape "$text")"
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
@@ -12,3 +12,9 @@ case "$source_kind" in
|
|
|
12
12
|
*)
|
|
13
13
|
;;
|
|
14
14
|
esac
|
|
15
|
+
|
|
16
|
+
# Re-prime the request-review reminder when a session (re)starts — crucially on
|
|
17
|
+
# source=="compact", which is how it survives a context compaction (PreCompact
|
|
18
|
+
# cannot inject post-compact context; SessionStart can). No-op on a fresh startup
|
|
19
|
+
# with no committed work, and silent for non-isolated/no-change sessions.
|
|
20
|
+
relay_emit_additional_context SessionStart "$(relay_review_reminder_text || true)"
|
|
@@ -1,4 +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"
|
|
9
|
+
# Re-surface the request-review reminder each turn while there is unmerged
|
|
10
|
+
# committed work — so a long session can't "forget" to land it. Silent otherwise.
|
|
11
|
+
relay_emit_additional_context UserPromptSubmit "$(relay_review_reminder_text || true)"
|
package/src/adapter.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface ProviderStatusEvent {
|
|
|
12
12
|
status: string;
|
|
13
13
|
id?: string;
|
|
14
14
|
timestamp?: number;
|
|
15
|
+
title?: string;
|
|
16
|
+
body?: string;
|
|
17
|
+
icon?: string;
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
15
19
|
};
|
|
16
20
|
id?: string;
|
|
17
21
|
label?: string;
|
|
@@ -23,6 +27,20 @@ export interface ProviderStatusEvent {
|
|
|
23
27
|
|
|
24
28
|
export type ProviderStatusUpdate = SemanticStatus | ProviderStatusEvent;
|
|
25
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
|
+
|
|
26
44
|
export interface ProviderConfig {
|
|
27
45
|
command: string;
|
|
28
46
|
defaultArgs: string[];
|
|
@@ -32,6 +50,9 @@ export interface ProviderConfig {
|
|
|
32
50
|
defaultApprovalMode: string;
|
|
33
51
|
defaultTags: string[];
|
|
34
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;
|
|
35
56
|
headless: {
|
|
36
57
|
tmuxPrefix: string;
|
|
37
58
|
shutdownTimeoutMs: number;
|
|
@@ -106,11 +127,24 @@ export interface ProviderAdapter {
|
|
|
106
127
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
107
128
|
compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
108
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">;
|
|
109
139
|
terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
|
|
110
140
|
respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
|
|
111
141
|
deliverInitialPrompt?(process: ManagedProcess, prompt: string): Promise<void>;
|
|
112
142
|
deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
|
|
113
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;
|
|
114
148
|
// Headless providers with no tmux session (e.g. the Codex app-server) still
|
|
115
149
|
// warrant an automatic restart on unexpected exit. Returning true opts the
|
|
116
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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
|
-
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
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";
|
|
@@ -67,6 +67,32 @@ const UNEXPECTED_EXIT_WINDOW_MS = 2 * 60 * 1000;
|
|
|
67
67
|
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
|
+
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: how often to probe real provider activity, and how many
|
|
75
|
+
// consecutive idle probes confirm a stuck-busy state should be cleared.
|
|
76
|
+
const BUSY_RECONCILE_POLL_MS = 4_000;
|
|
77
|
+
const BUSY_RECONCILE_IDLE_CONFIRM = 3;
|
|
78
|
+
// Relay-injected content (delivered messages, memory context) is wrapped with
|
|
79
|
+
// these markers; a UserPromptSubmit echo starting with one is a runner injection,
|
|
80
|
+
// not a human typing into the terminal, so it must not be mirrored as a prompt.
|
|
81
|
+
const RELAY_INJECTION_MARKERS = ["[relay message #", "[agent-relay"];
|
|
82
|
+
// Reasoning tailer poll cadence (item 5). Coarse on purpose — reasoning is a
|
|
83
|
+
// discreet progress signal, not a token stream, so ~1.2s keeps it light.
|
|
84
|
+
const REASONING_POLL_MS = 1_200;
|
|
85
|
+
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;
|
|
86
|
+
|
|
87
|
+
interface RunnerTimelineEvent {
|
|
88
|
+
status: string;
|
|
89
|
+
id?: string;
|
|
90
|
+
timestamp: number;
|
|
91
|
+
title?: string;
|
|
92
|
+
body?: string;
|
|
93
|
+
icon?: string;
|
|
94
|
+
metadata?: Record<string, unknown>;
|
|
95
|
+
}
|
|
70
96
|
|
|
71
97
|
export class AgentRunner {
|
|
72
98
|
private readonly agentId: string;
|
|
@@ -101,8 +127,24 @@ export class AgentRunner {
|
|
|
101
127
|
private readonly unexpectedExitTimes: number[] = [];
|
|
102
128
|
private readonly pendingMessages = new Map<number, Message>();
|
|
103
129
|
private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
|
|
104
|
-
private pendingTimelineEvent?:
|
|
130
|
+
private pendingTimelineEvent?: RunnerTimelineEvent;
|
|
105
131
|
private pendingPromptMessageId?: number;
|
|
132
|
+
// Session-mirror: a synthesized id grouping a turn's reasoning/tool steps and
|
|
133
|
+
// its final response. Set when a provider-turn starts, cleared when it ends.
|
|
134
|
+
private currentTurnId?: string;
|
|
135
|
+
// Prompt-echo dedup: the last prompt the runner itself injected (chat box or
|
|
136
|
+
// initial prompt). A UserPromptSubmit hook echo matching this within the window
|
|
137
|
+
// is the same prompt arriving back from the provider and must not double-post.
|
|
138
|
+
private lastInjectedPrompt?: { text: string; at: number };
|
|
139
|
+
// Busy reconciler: consecutive idle probes observed while claims still say busy.
|
|
140
|
+
private busyReconcileIdleStreak = 0;
|
|
141
|
+
private busyReconcileTimer?: ReturnType<typeof setInterval>;
|
|
142
|
+
// Tracks whether the provider is in a legitimate blocked/approval state, so the
|
|
143
|
+
// busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
|
|
144
|
+
private providerBlocked = false;
|
|
145
|
+
// Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
|
|
146
|
+
// from the Claude transcript into chat as discreet session events.
|
|
147
|
+
private reasoningTail?: { timer: ReturnType<typeof setInterval>; emitted: number };
|
|
106
148
|
private scratch?: SessionScratchLayout;
|
|
107
149
|
|
|
108
150
|
constructor(private readonly options: RunnerOptions) {
|
|
@@ -183,6 +225,7 @@ export class AgentRunner {
|
|
|
183
225
|
onTerminalAttachSpec: () => this.terminalAttachSpec(),
|
|
184
226
|
onReplyObligations: () => this.http.listReplyObligations(this.agentId),
|
|
185
227
|
onSessionTurn: (input) => this.publishSessionTurn(input),
|
|
228
|
+
onUserPrompt: (input) => this.handleUserPrompt(input),
|
|
186
229
|
});
|
|
187
230
|
this.writeRunnerInfoFile();
|
|
188
231
|
this.options.adapter.onStatusChange((status) => {
|
|
@@ -195,6 +238,7 @@ export class AgentRunner {
|
|
|
195
238
|
this.setProviderStatus(status);
|
|
196
239
|
if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
|
|
197
240
|
});
|
|
241
|
+
this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
|
|
198
242
|
this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
|
|
199
243
|
this.bus.on("command", (type, params, commandId, command) => {
|
|
200
244
|
void this.handleCommand(type, params, commandId, command);
|
|
@@ -238,6 +282,8 @@ export class AgentRunner {
|
|
|
238
282
|
this.httpLivenessTimer = undefined;
|
|
239
283
|
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
240
284
|
this.tokenRenewTimer = undefined;
|
|
285
|
+
this.disarmBusyReconciler();
|
|
286
|
+
this.stopReasoningTail();
|
|
241
287
|
this.control?.stop();
|
|
242
288
|
await this.bus.close();
|
|
243
289
|
}
|
|
@@ -416,7 +462,7 @@ export class AgentRunner {
|
|
|
416
462
|
private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
|
|
417
463
|
const target = typeof command?.target === "string" ? command.target : this.agentId;
|
|
418
464
|
if (target !== this.agentId && target !== this.options.runnerId) return;
|
|
419
|
-
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;
|
|
465
|
+
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;
|
|
420
466
|
|
|
421
467
|
const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
|
|
422
468
|
if (exitAfterCommand) {
|
|
@@ -439,6 +485,9 @@ export class AgentRunner {
|
|
|
439
485
|
} else if (type === "agent.clearContext") {
|
|
440
486
|
if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
|
|
441
487
|
providerResult = await this.options.adapter.clearContext(this.process);
|
|
488
|
+
} else if (type === "agent.interrupt") {
|
|
489
|
+
if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
|
|
490
|
+
providerResult = await this.options.adapter.interrupt(this.process);
|
|
442
491
|
} else if (type === "agent.injectContext") {
|
|
443
492
|
if (!this.process) throw new Error("provider process is unavailable");
|
|
444
493
|
providerResult = await this.injectContext(params);
|
|
@@ -534,6 +583,9 @@ export class AgentRunner {
|
|
|
534
583
|
if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
|
|
535
584
|
const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
|
|
536
585
|
if (messageId) this.pendingPromptMessageId = messageId;
|
|
586
|
+
// Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
|
|
587
|
+
// prompt already created its own session message shown in the dashboard.
|
|
588
|
+
this.lastInjectedPrompt = { text: body.trim(), at: Date.now() };
|
|
537
589
|
await this.options.adapter.deliverInitialPrompt(this.process, body);
|
|
538
590
|
return { injected: true, messageId };
|
|
539
591
|
}
|
|
@@ -579,9 +631,69 @@ export class AgentRunner {
|
|
|
579
631
|
const recent = this.unexpectedExitTimes.filter((time) => now - time <= UNEXPECTED_EXIT_WINDOW_MS);
|
|
580
632
|
recent.push(now);
|
|
581
633
|
this.unexpectedExitTimes.splice(0, this.unexpectedExitTimes.length, ...recent);
|
|
634
|
+
const diagnostics = this.providerExitDiagnostics(status, runtimeMs);
|
|
635
|
+
|
|
636
|
+
this.publishRunnerTimelineEvent({
|
|
637
|
+
status: "provider.exit_detected",
|
|
638
|
+
id: `provider-exit-${this.providerSessionId}-${now}`,
|
|
639
|
+
timestamp: now,
|
|
640
|
+
title: "Provider exited",
|
|
641
|
+
body: `${this.options.provider} reported ${status} after ${Math.round(runtimeMs / 1000)}s`,
|
|
642
|
+
icon: "ti-plug-off",
|
|
643
|
+
metadata: {
|
|
644
|
+
eventType: "provider.exit_detected",
|
|
645
|
+
...diagnostics,
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
if (this.shouldStopUnexpectedProviderExit(diagnostics)) {
|
|
650
|
+
const hasResumeId = typeof diagnostics.claudeResumeId === "string" && diagnostics.claudeResumeId.length > 0;
|
|
651
|
+
console.warn(`[runner] ${this.options.provider} exited; leaving agent offline for manual recovery`);
|
|
652
|
+
this.publishRunnerTimelineEvent({
|
|
653
|
+
status: "provider.restart_decision",
|
|
654
|
+
id: `provider-restart-decision-${this.providerSessionId}-${now}`,
|
|
655
|
+
timestamp: Date.now(),
|
|
656
|
+
title: "Provider restart skipped",
|
|
657
|
+
body: hasResumeId
|
|
658
|
+
? "Claude exited; runner will not auto-resume. Resume id captured for manual recovery."
|
|
659
|
+
: "Claude exited; runner will not restart automatically.",
|
|
660
|
+
icon: "ti-player-stop",
|
|
661
|
+
metadata: {
|
|
662
|
+
eventType: "provider.restart_decision",
|
|
663
|
+
decision: "stop-surface",
|
|
664
|
+
reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
|
|
665
|
+
...diagnostics,
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
this.process = undefined;
|
|
669
|
+
this.setProviderStatus({
|
|
670
|
+
status,
|
|
671
|
+
reason: "provider-turn",
|
|
672
|
+
id: `provider-exit-${this.providerSessionId}`,
|
|
673
|
+
clear: ["provider-turn", "subagent"],
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
582
677
|
|
|
583
678
|
if (runtimeMs < RAPID_EXIT_MS && recent.length > MAX_RAPID_UNEXPECTED_EXITS) {
|
|
584
679
|
console.error(`[runner] provider session exited ${recent.length} times within ${Math.round(UNEXPECTED_EXIT_WINDOW_MS / 1000)}s; giving up`);
|
|
680
|
+
this.publishRunnerTimelineEvent({
|
|
681
|
+
status: "provider.restart_decision",
|
|
682
|
+
id: `provider-restart-decision-${this.providerSessionId}-${now}`,
|
|
683
|
+
timestamp: Date.now(),
|
|
684
|
+
title: "Provider restart skipped",
|
|
685
|
+
body: `rapid unexpected exits exceeded ${MAX_RAPID_UNEXPECTED_EXITS}`,
|
|
686
|
+
icon: "ti-alert-triangle",
|
|
687
|
+
metadata: {
|
|
688
|
+
eventType: "provider.restart_decision",
|
|
689
|
+
decision: "give-up",
|
|
690
|
+
reason: "rapid-unexpected-provider-exits",
|
|
691
|
+
rapidExitCount: recent.length,
|
|
692
|
+
rapidExitWindowMs: UNEXPECTED_EXIT_WINDOW_MS,
|
|
693
|
+
maxRapidUnexpectedExits: MAX_RAPID_UNEXPECTED_EXITS,
|
|
694
|
+
...diagnostics,
|
|
695
|
+
},
|
|
696
|
+
});
|
|
585
697
|
this.setProviderStatus(status);
|
|
586
698
|
this.options.onProviderExit?.(0);
|
|
587
699
|
return;
|
|
@@ -589,6 +701,23 @@ export class AgentRunner {
|
|
|
589
701
|
|
|
590
702
|
const delayMs = Math.min(10_000, Math.max(500, 500 * recent.length));
|
|
591
703
|
console.warn(`[runner] provider session exited unexpectedly after ${Math.round(runtimeMs / 1000)}s; restarting in ${delayMs}ms`);
|
|
704
|
+
this.publishRunnerTimelineEvent({
|
|
705
|
+
status: "provider.restart_decision",
|
|
706
|
+
id: `provider-restart-decision-${this.providerSessionId}-${now}`,
|
|
707
|
+
timestamp: Date.now(),
|
|
708
|
+
title: "Provider restart scheduled",
|
|
709
|
+
body: `runner will start a fresh ${this.options.provider} provider in ${delayMs}ms`,
|
|
710
|
+
icon: "ti-refresh",
|
|
711
|
+
metadata: {
|
|
712
|
+
eventType: "provider.restart_decision",
|
|
713
|
+
decision: "restart-fresh",
|
|
714
|
+
reason: "unexpected-headless-terminal-exit",
|
|
715
|
+
delayMs,
|
|
716
|
+
rapidExitCount: recent.length,
|
|
717
|
+
rapidExitWindowMs: UNEXPECTED_EXIT_WINDOW_MS,
|
|
718
|
+
...diagnostics,
|
|
719
|
+
},
|
|
720
|
+
});
|
|
592
721
|
await Bun.sleep(delayMs);
|
|
593
722
|
if (this.stopped || this.exitCommandInProgress) return;
|
|
594
723
|
try {
|
|
@@ -605,6 +734,10 @@ export class AgentRunner {
|
|
|
605
734
|
}
|
|
606
735
|
}
|
|
607
736
|
|
|
737
|
+
private shouldStopUnexpectedProviderExit(diagnostics: Record<string, unknown>): boolean {
|
|
738
|
+
return this.options.provider === "claude" && diagnostics.exitCommandInProgress !== true;
|
|
739
|
+
}
|
|
740
|
+
|
|
608
741
|
private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<void> {
|
|
609
742
|
this.lifecycleAction = hard ? "killing" : "shutting-down";
|
|
610
743
|
this.publishStatus();
|
|
@@ -620,6 +753,46 @@ export class AgentRunner {
|
|
|
620
753
|
this.stopped = true;
|
|
621
754
|
}
|
|
622
755
|
|
|
756
|
+
private publishRunnerTimelineEvent(event: RunnerTimelineEvent): void {
|
|
757
|
+
this.pendingTimelineEvent = {
|
|
758
|
+
...event,
|
|
759
|
+
metadata: {
|
|
760
|
+
source: "runner",
|
|
761
|
+
provider: this.options.provider,
|
|
762
|
+
runnerId: this.options.runnerId,
|
|
763
|
+
agentId: this.agentId,
|
|
764
|
+
policyName: this.options.policyName ?? null,
|
|
765
|
+
spawnRequestId: this.options.spawnRequestId ?? null,
|
|
766
|
+
label: this.options.label ?? null,
|
|
767
|
+
providerSessionId: this.providerSessionId,
|
|
768
|
+
...(event.metadata ?? {}),
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
this.publishStatus();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private providerExitDiagnostics(status: SemanticStatus, runtimeMs: number): Record<string, unknown> {
|
|
775
|
+
const tmuxSession = typeof this.process?.meta?.tmuxSession === "string" ? this.process.meta.tmuxSession : undefined;
|
|
776
|
+
const tmuxSocket = typeof this.process?.meta?.tmuxSocket === "string" ? this.process.meta.tmuxSocket : undefined;
|
|
777
|
+
const exitSource = tmuxSession ? "tmux-session-ended" : this.process?.process ? "process-exit" : "provider-status";
|
|
778
|
+
const logFile = typeof process.env.AGENT_RELAY_LOG_FILE === "string" ? process.env.AGENT_RELAY_LOG_FILE : undefined;
|
|
779
|
+
const claudeResumeId = this.options.provider === "claude" && logFile ? latestClaudeResumeIdFromLogFile(logFile) : undefined;
|
|
780
|
+
return {
|
|
781
|
+
status,
|
|
782
|
+
runtimeMs: Number.isFinite(runtimeMs) ? runtimeMs : null,
|
|
783
|
+
exitSource,
|
|
784
|
+
exitCommandInProgress: this.exitCommandInProgress,
|
|
785
|
+
stopped: this.stopped,
|
|
786
|
+
restartInProgress: this.restartInProgress,
|
|
787
|
+
restartPending: this.restartPending,
|
|
788
|
+
headless: this.options.headless,
|
|
789
|
+
hasTerminalSession: Boolean(tmuxSession),
|
|
790
|
+
tmuxSession: tmuxSession ?? null,
|
|
791
|
+
tmuxSocket: tmuxSocket ?? null,
|
|
792
|
+
claudeResumeId: claudeResumeId ?? null,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
623
796
|
private async updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<void> {
|
|
624
797
|
await this.bus.updateCommand(commandId, { status, ...(result ? { result } : {}), ...(error ? { error } : {}) });
|
|
625
798
|
}
|
|
@@ -646,8 +819,26 @@ export class AgentRunner {
|
|
|
646
819
|
status: update.timeline.status,
|
|
647
820
|
...(update.timeline.id ? { id: update.timeline.id } : {}),
|
|
648
821
|
timestamp: update.timeline.timestamp ?? Date.now(),
|
|
822
|
+
...(update.timeline.title ? { title: update.timeline.title } : {}),
|
|
823
|
+
...(update.timeline.body ? { body: update.timeline.body } : {}),
|
|
824
|
+
...(update.timeline.icon ? { icon: update.timeline.icon } : {}),
|
|
825
|
+
...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
|
|
649
826
|
};
|
|
650
827
|
}
|
|
828
|
+
if (typeof update !== "string" && update.providerState) {
|
|
829
|
+
const state = (update.providerState as { state?: unknown }).state;
|
|
830
|
+
this.providerBlocked = state === "blocked";
|
|
831
|
+
} else if (status === "idle") {
|
|
832
|
+
this.providerBlocked = false;
|
|
833
|
+
}
|
|
834
|
+
if (status === "busy" && reason === "provider-turn") {
|
|
835
|
+
if (!this.currentTurnId) this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
|
|
836
|
+
this.armBusyReconciler();
|
|
837
|
+
} else if (status === "idle" && reason === "provider-turn") {
|
|
838
|
+
this.currentTurnId = undefined;
|
|
839
|
+
this.disarmBusyReconciler();
|
|
840
|
+
this.stopReasoningTail();
|
|
841
|
+
}
|
|
651
842
|
if (status === "busy") {
|
|
652
843
|
this.claims.clearTerminalStatus();
|
|
653
844
|
this.claims.startWork(reason, id, typeof update === "string" ? {} : {
|
|
@@ -674,14 +865,16 @@ export class AgentRunner {
|
|
|
674
865
|
this.publishStatus();
|
|
675
866
|
}
|
|
676
867
|
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
868
|
+
// Session-mirror lane: capture the assistant turn from the Claude transcript and
|
|
869
|
+
// post it as a "session" message so it shows in the dashboard chat with zero
|
|
870
|
+
// agent tokens. Capture is UNCONDITIONAL — it no longer depends on a triggering
|
|
871
|
+
// relay message existing, so turns started from the web terminal (which create
|
|
872
|
+
// no relay message) are mirrored too. A reply obligation, when present, is still
|
|
873
|
+
// used as replyTo so the Stop hook stops nagging the agent to /reply.
|
|
682
874
|
private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void> {
|
|
683
|
-
|
|
684
|
-
|
|
875
|
+
const turnId = this.currentTurnId;
|
|
876
|
+
this.stopReasoningTail();
|
|
877
|
+
// Optional correlation for threading + obligation clearing — never a capture gate.
|
|
685
878
|
let replyToMessageId: number | undefined;
|
|
686
879
|
const pendingPrompt = this.pendingPromptMessageId;
|
|
687
880
|
if (pendingPrompt) {
|
|
@@ -693,52 +886,216 @@ export class AgentRunner {
|
|
|
693
886
|
const obligation = [...obligations].reverse().find((o) => o.from === "user");
|
|
694
887
|
replyToMessageId = obligation?.messageId;
|
|
695
888
|
} catch {
|
|
696
|
-
|
|
889
|
+
// fall through and capture without correlation
|
|
697
890
|
}
|
|
698
891
|
}
|
|
699
|
-
if (!replyToMessageId) return;
|
|
700
892
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
// The Stop hook can fire before the final assistant entry is flushed to
|
|
708
|
-
// disk. Claude Code writes thinking and text as separate entries (both with
|
|
709
|
-
// end_turn), so the transcript can "look complete" while the text entry is
|
|
710
|
-
// still pending. Retry until both the transcript has an end_turn AND the
|
|
711
|
-
// extraction yields non-empty text.
|
|
893
|
+
// The Stop hook can fire before the final assistant entry is flushed to disk.
|
|
894
|
+
// Claude writes thinking and text as separate entries (both with end_turn), so
|
|
895
|
+
// the transcript can "look complete" while the text entry is still pending.
|
|
896
|
+
// Retry until both the transcript has an end_turn AND extraction yields text.
|
|
712
897
|
let body = "";
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
898
|
+
let jsonl: string | undefined;
|
|
899
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
|
|
900
|
+
if (jsonl !== undefined) {
|
|
901
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
902
|
+
if (attempt > 0) {
|
|
903
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
904
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
|
|
905
|
+
}
|
|
906
|
+
if (!transcriptLooksComplete(jsonl)) continue;
|
|
907
|
+
const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
|
|
908
|
+
body = extract(jsonl);
|
|
909
|
+
if (body) break;
|
|
717
910
|
}
|
|
718
|
-
if (!transcriptLooksComplete(jsonl)) continue;
|
|
719
|
-
const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
|
|
720
|
-
body = extract(jsonl);
|
|
721
|
-
if (body) break;
|
|
722
911
|
}
|
|
723
|
-
// Fallback:
|
|
724
|
-
//
|
|
725
|
-
// content in-memory before the hook even fires.
|
|
912
|
+
// Fallback: last_assistant_message from the Stop hook payload, which bypasses
|
|
913
|
+
// the transcript file race entirely.
|
|
726
914
|
if (!body && input.lastAssistantMessage) {
|
|
727
915
|
body = extractHookAssistantMessage(input.lastAssistantMessage);
|
|
728
916
|
}
|
|
917
|
+
// A pure tool-use turn with no closing text is fine to skip — its reasoning and
|
|
918
|
+
// tool steps already carried the visibility into chat.
|
|
729
919
|
if (!body) return;
|
|
730
920
|
|
|
921
|
+
await this.publishSessionEvent({
|
|
922
|
+
from: this.agentId,
|
|
923
|
+
to: "user",
|
|
924
|
+
body,
|
|
925
|
+
...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
|
|
926
|
+
session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Post one session-mirror event (prompt echo, assistant response, reasoning or
|
|
931
|
+
// tool step) as a `kind: "session"` relay message tagged with payload.session so
|
|
932
|
+
// the dashboard can render the live provider session faithfully. Display-only:
|
|
933
|
+
// session messages are never delivered back into a provider.
|
|
934
|
+
private async publishSessionEvent(input: {
|
|
935
|
+
from: string;
|
|
936
|
+
to: string;
|
|
937
|
+
body: string;
|
|
938
|
+
session: MessageSessionMeta;
|
|
939
|
+
replyTo?: number;
|
|
940
|
+
}): Promise<void> {
|
|
731
941
|
try {
|
|
732
942
|
await this.http.sendMessage({
|
|
943
|
+
from: input.from,
|
|
944
|
+
to: input.to,
|
|
945
|
+
...(input.replyTo ? { replyTo: input.replyTo } : {}),
|
|
946
|
+
kind: "session",
|
|
947
|
+
body: input.body,
|
|
948
|
+
payload: { session: { provider: this.options.provider, ...input.session } },
|
|
949
|
+
});
|
|
950
|
+
} catch (error) {
|
|
951
|
+
this.logRunnerDiagnostic(`session ${input.session.type} capture failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// A human typed a prompt directly into the provider (web terminal / TUI). Mirror
|
|
956
|
+
// it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
|
|
957
|
+
// tailing for the turn. Skips prompts the runner itself injected (chat box, relay
|
|
958
|
+
// deliveries) so those aren't double-posted.
|
|
959
|
+
private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
|
|
960
|
+
if (!this.currentTurnId) this.currentTurnId = crypto.randomUUID();
|
|
961
|
+
const text = input.prompt.trim();
|
|
962
|
+
if (text && !this.isRunnerInjectedPrompt(text)) {
|
|
963
|
+
await this.publishSessionEvent({
|
|
964
|
+
from: "user",
|
|
965
|
+
to: this.agentId,
|
|
966
|
+
body: text,
|
|
967
|
+
session: { type: "prompt", origin: "terminal", turnId: this.currentTurnId },
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
if (input.transcriptPath) this.startReasoningTail(input.transcriptPath);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Route a provider-emitted session event (Codex app-server) into the chat mirror.
|
|
974
|
+
// Mirrors the same semantics as the Claude lane: prompts are echoed with dedup,
|
|
975
|
+
// and a response is only auto-captured when the agent won't separately reply to a
|
|
976
|
+
// relay obligation (so relay-triggered turns aren't double-posted).
|
|
977
|
+
private async publishProviderSessionEvent(event: ProviderSessionEvent): Promise<void> {
|
|
978
|
+
const body = event.body.trim();
|
|
979
|
+
if (!body) return;
|
|
980
|
+
const turnId = event.turnId ?? this.currentTurnId;
|
|
981
|
+
if (event.type === "prompt") {
|
|
982
|
+
if (this.isRunnerInjectedPrompt(body)) return;
|
|
983
|
+
await this.publishSessionEvent({
|
|
984
|
+
from: "user",
|
|
985
|
+
to: this.agentId,
|
|
986
|
+
body,
|
|
987
|
+
session: { type: "prompt", origin: event.origin ?? "terminal", ...(turnId ? { turnId } : {}) },
|
|
988
|
+
});
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (event.type === "response") {
|
|
992
|
+
// If a relay message is awaiting the agent's own reply, let the agent answer
|
|
993
|
+
// it (Codex agents reply via their relay skills) instead of double-posting.
|
|
994
|
+
let replyToMessageId: number | undefined;
|
|
995
|
+
const pendingPrompt = this.pendingPromptMessageId;
|
|
996
|
+
if (pendingPrompt) {
|
|
997
|
+
replyToMessageId = pendingPrompt;
|
|
998
|
+
this.pendingPromptMessageId = undefined;
|
|
999
|
+
} else {
|
|
1000
|
+
try {
|
|
1001
|
+
const obligations = await this.http.listReplyObligations(this.agentId);
|
|
1002
|
+
if (obligations.some((o) => o.from === "user")) return;
|
|
1003
|
+
} catch {
|
|
1004
|
+
// capture anyway on lookup failure
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
await this.publishSessionEvent({
|
|
733
1008
|
from: this.agentId,
|
|
734
1009
|
to: "user",
|
|
735
|
-
replyTo: replyToMessageId,
|
|
736
|
-
kind: "session",
|
|
737
1010
|
body,
|
|
1011
|
+
...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
|
|
1012
|
+
session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) },
|
|
738
1013
|
});
|
|
739
|
-
|
|
740
|
-
this.logRunnerDiagnostic(`session turn capture failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1014
|
+
return;
|
|
741
1015
|
}
|
|
1016
|
+
if (this.options.providerConfig.reasoningCapture === false) return;
|
|
1017
|
+
await this.publishSessionEvent({
|
|
1018
|
+
from: this.agentId,
|
|
1019
|
+
to: "user",
|
|
1020
|
+
body,
|
|
1021
|
+
session: { type: event.type, origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.label ? { label: event.label } : {}) },
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
private isRunnerInjectedPrompt(text: string): boolean {
|
|
1026
|
+
if (RELAY_INJECTION_MARKERS.some((marker) => text.startsWith(marker))) return true;
|
|
1027
|
+
const recent = this.lastInjectedPrompt;
|
|
1028
|
+
if (recent && recent.text === text && Date.now() - recent.at < PROMPT_ECHO_DEDUP_MS) {
|
|
1029
|
+
this.lastInjectedPrompt = undefined;
|
|
1030
|
+
return true;
|
|
1031
|
+
}
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// --- Busy-state reconciler (item 2) -------------------------------------------------
|
|
1036
|
+
// A safety net for turns that end out of band (interrupted from the web terminal,
|
|
1037
|
+
// a hook that never fired) where the runner would otherwise stay stuck "busy".
|
|
1038
|
+
private armBusyReconciler(): void {
|
|
1039
|
+
if (this.busyReconcileTimer || !this.options.adapter.probeActivity) return;
|
|
1040
|
+
this.busyReconcileIdleStreak = 0;
|
|
1041
|
+
this.busyReconcileTimer = setInterval(() => { void this.runBusyReconcile(); }, BUSY_RECONCILE_POLL_MS);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private disarmBusyReconciler(): void {
|
|
1045
|
+
if (this.busyReconcileTimer) clearInterval(this.busyReconcileTimer);
|
|
1046
|
+
this.busyReconcileTimer = undefined;
|
|
1047
|
+
this.busyReconcileIdleStreak = 0;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private async runBusyReconcile(): Promise<void> {
|
|
1051
|
+
if (this.stopped || !this.process || !this.options.adapter.probeActivity) { this.disarmBusyReconciler(); return; }
|
|
1052
|
+
// Only act while the runner still believes a provider turn is in flight, and
|
|
1053
|
+
// never override a legitimate approval/blocked state.
|
|
1054
|
+
if (this.claims.currentStatus() !== "busy" || this.providerBlocked) { this.busyReconcileIdleStreak = 0; return; }
|
|
1055
|
+
if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) { this.disarmBusyReconciler(); return; }
|
|
1056
|
+
let activity: "busy" | "idle" | "unknown";
|
|
1057
|
+
try { activity = await this.options.adapter.probeActivity(this.process); } catch { return; }
|
|
1058
|
+
if (activity !== "idle") { this.busyReconcileIdleStreak = 0; return; }
|
|
1059
|
+
this.busyReconcileIdleStreak += 1;
|
|
1060
|
+
if (this.busyReconcileIdleStreak < BUSY_RECONCILE_IDLE_CONFIRM) return;
|
|
1061
|
+
this.logRunnerDiagnostic(`busy reconciler cleared a stuck provider-turn (idle confirmed ${this.busyReconcileIdleStreak}x)`);
|
|
1062
|
+
const turnId = this.currentTurnId;
|
|
1063
|
+
this.disarmBusyReconciler();
|
|
1064
|
+
this.setProviderStatus({ status: "idle", reason: "provider-turn", id: turnId ?? "provider-turn" });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// --- Reasoning tailer (item 5) ------------------------------------------------------
|
|
1068
|
+
// Tail the in-flight turn's Claude transcript and surface new reasoning/tool steps
|
|
1069
|
+
// as discreet session events. Coalesced and coarse; the final response still comes
|
|
1070
|
+
// through publishSessionTurn.
|
|
1071
|
+
private startReasoningTail(transcriptPath: string): void {
|
|
1072
|
+
if (this.options.providerConfig.reasoningCapture === false) return;
|
|
1073
|
+
this.stopReasoningTail();
|
|
1074
|
+
const state = { emitted: 0, timer: undefined as unknown as ReturnType<typeof setInterval> };
|
|
1075
|
+
const poll = async (): Promise<void> => {
|
|
1076
|
+
let jsonl: string;
|
|
1077
|
+
try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
|
|
1078
|
+
const steps = extractLatestTurnSteps(jsonl);
|
|
1079
|
+
const turnId = this.currentTurnId;
|
|
1080
|
+
for (let i = state.emitted; i < steps.length; i++) {
|
|
1081
|
+
const step = steps[i]!;
|
|
1082
|
+
void this.publishSessionEvent({
|
|
1083
|
+
from: this.agentId,
|
|
1084
|
+
to: "user",
|
|
1085
|
+
body: step.text,
|
|
1086
|
+
session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
if (steps.length > state.emitted) state.emitted = steps.length;
|
|
1090
|
+
};
|
|
1091
|
+
state.timer = setInterval(() => { void poll(); }, REASONING_POLL_MS);
|
|
1092
|
+
this.reasoningTail = state;
|
|
1093
|
+
void poll();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private stopReasoningTail(): void {
|
|
1097
|
+
if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
|
|
1098
|
+
this.reasoningTail = undefined;
|
|
742
1099
|
}
|
|
743
1100
|
|
|
744
1101
|
private publishStatus(): void {
|
|
@@ -874,9 +1231,20 @@ export class AgentRunner {
|
|
|
874
1231
|
private scheduleRuntimeTokenRenewal(delayMs?: number): void {
|
|
875
1232
|
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
876
1233
|
this.tokenRenewTimer = undefined;
|
|
877
|
-
if (
|
|
878
|
-
const
|
|
879
|
-
|
|
1234
|
+
if (this.stopped) return;
|
|
1235
|
+
const canSelfRenew = this.isRuntimeTokenRenewable();
|
|
1236
|
+
const canRemint = this.canRemintViaOrchestrator();
|
|
1237
|
+
// Keep the renewal clock ticking as long as the session can recover its token
|
|
1238
|
+
// by EITHER path. Without the re-mint fallback an expired token would stop the
|
|
1239
|
+
// timer forever (the old deadlock that stranded live agents off the bus).
|
|
1240
|
+
if (!canSelfRenew && !canRemint) return;
|
|
1241
|
+
let computedDelay = delayMs;
|
|
1242
|
+
if (computedDelay === undefined) {
|
|
1243
|
+
computedDelay = canSelfRenew
|
|
1244
|
+
? runtimeTokenRenewDelayMs(this.currentTokenExpiresAt!, Date.now())
|
|
1245
|
+
: TOKEN_RENEW_RETRY_MS; // expired but re-mintable → retry via orchestrator soon
|
|
1246
|
+
if (computedDelay === undefined) computedDelay = TOKEN_RENEW_RETRY_MS;
|
|
1247
|
+
}
|
|
880
1248
|
const schedule = runtimeTokenRenewTimerSchedule(computedDelay);
|
|
881
1249
|
if (!schedule) return;
|
|
882
1250
|
this.tokenRenewTimer = setTimeout(() => {
|
|
@@ -889,6 +1257,8 @@ export class AgentRunner {
|
|
|
889
1257
|
}, schedule.delayMs);
|
|
890
1258
|
}
|
|
891
1259
|
|
|
1260
|
+
// Can the runner self-renew right now? Requires a non-expired runner-profile token
|
|
1261
|
+
// (the relay rejects renewal of an expired token).
|
|
892
1262
|
private isRuntimeTokenRenewable(): boolean {
|
|
893
1263
|
return Boolean(
|
|
894
1264
|
this.currentToken &&
|
|
@@ -898,32 +1268,36 @@ export class AgentRunner {
|
|
|
898
1268
|
);
|
|
899
1269
|
}
|
|
900
1270
|
|
|
1271
|
+
// Can the runner recover its token via the orchestrator? Works even when the token
|
|
1272
|
+
// is already expired — the orchestrator's standing credential is the authority.
|
|
1273
|
+
private canRemintViaOrchestrator(): boolean {
|
|
1274
|
+
return Boolean(
|
|
1275
|
+
process.env.AGENT_RELAY_ORCHESTRATOR_URL &&
|
|
1276
|
+
this.currentToken &&
|
|
1277
|
+
(this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
901
1281
|
private async renewRuntimeToken(): Promise<void> {
|
|
902
|
-
if (this.stopped || this.tokenRenewInFlight || !this.
|
|
1282
|
+
if (this.stopped || this.tokenRenewInFlight || !this.currentToken) return;
|
|
903
1283
|
this.tokenRenewInFlight = true;
|
|
904
1284
|
try {
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
this.
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
};
|
|
922
|
-
this.bus.reconnectTransport("runtime token renewed");
|
|
923
|
-
this.publishStatus();
|
|
924
|
-
this.scheduleRuntimeTokenRenewal();
|
|
925
|
-
} catch (error) {
|
|
926
|
-
this.logRuntimeTokenRenewalFailure(error);
|
|
1285
|
+
// Preferred path: self-renew directly against the relay while the token is
|
|
1286
|
+
// still valid. Cheapest and needs no orchestrator round-trip.
|
|
1287
|
+
if (this.isRuntimeTokenRenewable()) {
|
|
1288
|
+
try {
|
|
1289
|
+
const renewed = await this.http.renewRuntimeToken();
|
|
1290
|
+
this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-renewed");
|
|
1291
|
+
return;
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
this.logRuntimeTokenRenewalFailure(error);
|
|
1294
|
+
// Relay unreachable or token rejected — fall through to orchestrator re-mint.
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
// Recovery path: token expired, or self-renew failed. Ask the orchestrator —
|
|
1298
|
+
// it holds a long-lived credential and can mint a fresh runner token, so a
|
|
1299
|
+
// live session heals instead of being stranded off the bus.
|
|
1300
|
+
if (this.canRemintViaOrchestrator() && await this.remintViaOrchestrator()) return;
|
|
927
1301
|
this.pendingTimelineEvent = {
|
|
928
1302
|
status: "runtime-token-renewal-failed",
|
|
929
1303
|
timestamp: Date.now(),
|
|
@@ -935,6 +1309,56 @@ export class AgentRunner {
|
|
|
935
1309
|
}
|
|
936
1310
|
}
|
|
937
1311
|
|
|
1312
|
+
// Apply a freshly issued token across every live surface — runner state, the
|
|
1313
|
+
// RunnerOptions bag (re-injected into the provider on respawn), the HTTP client,
|
|
1314
|
+
// the bus client — then force a bus handshake with the new token and reschedule.
|
|
1315
|
+
private applyRenewedToken(
|
|
1316
|
+
token: string,
|
|
1317
|
+
record: { jti: string; profileId?: string; expiresAt?: number },
|
|
1318
|
+
status: "runtime-token-renewed" | "runtime-token-reminted",
|
|
1319
|
+
): void {
|
|
1320
|
+
this.currentToken = token;
|
|
1321
|
+
this.currentTokenJti = record.jti;
|
|
1322
|
+
this.currentTokenProfileId = record.profileId ?? this.currentTokenProfileId;
|
|
1323
|
+
this.currentTokenExpiresAt = record.expiresAt;
|
|
1324
|
+
this.options.token = token;
|
|
1325
|
+
this.options.tokenJti = record.jti;
|
|
1326
|
+
this.options.tokenProfileId = this.currentTokenProfileId;
|
|
1327
|
+
this.options.tokenExpiresAt = this.currentTokenExpiresAt;
|
|
1328
|
+
this.http.setToken(token);
|
|
1329
|
+
this.bus.setToken(token);
|
|
1330
|
+
this.httpLivenessAuthFailed = false;
|
|
1331
|
+
this.pendingTimelineEvent = { status, id: record.jti, timestamp: Date.now() };
|
|
1332
|
+
this.bus.reconnectTransport(status === "runtime-token-reminted" ? "runtime token re-minted" : "runtime token renewed");
|
|
1333
|
+
this.publishStatus();
|
|
1334
|
+
this.scheduleRuntimeTokenRenewal();
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Recover the runtime token through the orchestrator. The runner proxies its own
|
|
1338
|
+
// (possibly expired) token; the orchestrator re-mints it via the relay using its
|
|
1339
|
+
// standing credential. Returns true on success.
|
|
1340
|
+
private async remintViaOrchestrator(): Promise<boolean> {
|
|
1341
|
+
const orchUrl = process.env.AGENT_RELAY_ORCHESTRATOR_URL;
|
|
1342
|
+
if (!orchUrl || !this.currentToken) return false;
|
|
1343
|
+
try {
|
|
1344
|
+
const res = await fetch(`${orchUrl.replace(/\/+$/, "")}/api/runtime-tokens/runner-renew`, {
|
|
1345
|
+
method: "POST",
|
|
1346
|
+
headers: { "Content-Type": "application/json" },
|
|
1347
|
+
body: JSON.stringify({ token: this.currentToken }),
|
|
1348
|
+
signal: AbortSignal.timeout(10_000),
|
|
1349
|
+
});
|
|
1350
|
+
if (!res.ok) return false;
|
|
1351
|
+
const renewed = await res.json() as { token?: string; record?: { jti: string; profileId?: string; expiresAt?: number } };
|
|
1352
|
+
if (!renewed?.token || !renewed.record) return false;
|
|
1353
|
+
this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-reminted");
|
|
1354
|
+
this.logRunnerDiagnostic(`[runner] runtime token re-minted via orchestrator (jti ${renewed.record.jti})`);
|
|
1355
|
+
return true;
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
this.logRuntimeTokenRenewalFailure(error);
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
938
1362
|
private logRuntimeTokenRenewalFailure(error: unknown): void {
|
|
939
1363
|
const key = httpErrorKey(error);
|
|
940
1364
|
const now = Date.now();
|
|
@@ -1146,6 +1570,32 @@ export function runnerShouldRestartUnexpectedProviderExit(
|
|
|
1146
1570
|
&& input.hasTerminalSession;
|
|
1147
1571
|
}
|
|
1148
1572
|
|
|
1573
|
+
export function latestClaudeResumeIdFromText(text: string): string | undefined {
|
|
1574
|
+
let latest: string | undefined;
|
|
1575
|
+
CLAUDE_RESUME_RE.lastIndex = 0;
|
|
1576
|
+
for (let match = CLAUDE_RESUME_RE.exec(text); match; match = CLAUDE_RESUME_RE.exec(text)) {
|
|
1577
|
+
latest = match[1];
|
|
1578
|
+
}
|
|
1579
|
+
return latest;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
export function latestClaudeResumeIdFromLogFile(path: string): string | undefined {
|
|
1583
|
+
let fd: number | undefined;
|
|
1584
|
+
try {
|
|
1585
|
+
const stat = statSync(path);
|
|
1586
|
+
const length = Math.min(stat.size, LOG_TAIL_BYTES);
|
|
1587
|
+
const offset = Math.max(0, stat.size - length);
|
|
1588
|
+
const buffer = Buffer.alloc(length);
|
|
1589
|
+
fd = openSync(path, "r");
|
|
1590
|
+
readSync(fd, buffer, 0, length, offset);
|
|
1591
|
+
return latestClaudeResumeIdFromText(buffer.toString("utf8"));
|
|
1592
|
+
} catch {
|
|
1593
|
+
return undefined;
|
|
1594
|
+
} finally {
|
|
1595
|
+
if (fd !== undefined) closeSync(fd);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1149
1599
|
function commandTimeoutMs(params: Record<string, unknown>, fallback = 10_000): number {
|
|
1150
1600
|
const raw = params.timeoutMs;
|
|
1151
1601
|
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return fallback;
|
|
@@ -1221,6 +1671,13 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
|
|
|
1221
1671
|
liveSession: {
|
|
1222
1672
|
capture: true,
|
|
1223
1673
|
inject: Boolean(options.adapter.deliverInitialPrompt),
|
|
1674
|
+
interrupt: Boolean(options.adapter.interrupt),
|
|
1675
|
+
// Both providers mirror directly-typed prompts and stream reasoning/tool
|
|
1676
|
+
// activity into chat (Claude via hooks + transcript tail, Codex via
|
|
1677
|
+
// app-server item events).
|
|
1678
|
+
promptEcho: true,
|
|
1679
|
+
reasoning: true,
|
|
1680
|
+
slashCommands: options.provider === "claude" || options.provider === "codex",
|
|
1224
1681
|
},
|
|
1225
1682
|
source: "runtime",
|
|
1226
1683
|
confidence: "reported",
|