agent-relay-runner 0.127.5 → 0.127.7
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/src/adapter.ts +81 -4
- package/src/adapters/claude-permission.ts +209 -0
- package/src/{claude-prompt-gates.ts → adapters/claude-prompt-gates.ts} +11 -5
- package/src/adapters/claude-session-capture.ts +235 -0
- package/src/adapters/claude.ts +18 -26
- package/src/{codex-version.ts → adapters/codex-version.ts} +1 -1
- package/src/adapters/{claude-quota-harvest.ts → provider-quota-harvest.ts} +3 -3
- package/src/control-server.ts +130 -278
- package/src/launch-assembly.ts +2 -2
- package/src/profile-home.ts +1 -1
- package/src/runner-core.ts +155 -322
- package/src/runner-helpers.ts +36 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.127.
|
|
3
|
+
"version": "0.127.7",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"agent-relay-providers": "0.104.4",
|
|
26
|
-
"agent-relay-sdk": "0.2.
|
|
26
|
+
"agent-relay-sdk": "0.2.121",
|
|
27
27
|
"callmux": "0.23.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
package/src/adapter.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentProfile, LivenessSignal, Message, MessageFinalityConfidence, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
|
|
1
|
+
import type { AgentProfile, InteractivePrompt, LivenessSignal, Message, MessageFinalityConfidence, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
|
|
2
2
|
import { isRecord } from "agent-relay-sdk/types/guards";
|
|
3
3
|
import type { LivenessInputs } from "./liveness";
|
|
4
4
|
import type { SessionEvent } from "./session-insights";
|
|
@@ -48,12 +48,15 @@ export interface ProviderSessionEvent {
|
|
|
48
48
|
body: string;
|
|
49
49
|
origin?: "chat" | "terminal" | "provider";
|
|
50
50
|
turnId?: string;
|
|
51
|
+
occurredAt?: number;
|
|
51
52
|
/** True only when this response is the completed provider turn, not an intermediate flush. */
|
|
52
53
|
final?: boolean;
|
|
53
54
|
/** Whether the turn-final edge was provider-native or reconstructed. Set only alongside `final`. */
|
|
54
55
|
confidence?: MessageFinalityConfidence;
|
|
55
56
|
/** Why the provider turn ended. Set only alongside `final`. */
|
|
56
57
|
endedBy?: MessageTurnEndReason;
|
|
58
|
+
/** Whether this response may clear a direct user obligation; Codex keeps this false. */
|
|
59
|
+
replyToUserObligation?: boolean;
|
|
57
60
|
label?: string;
|
|
58
61
|
status?: "running" | "completed" | "failed";
|
|
59
62
|
streaming?: boolean;
|
|
@@ -63,6 +66,71 @@ export interface ProviderSessionEvent {
|
|
|
63
66
|
stepId?: string;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
export type ProviderSessionEventBatch = ProviderSessionEvent[];
|
|
70
|
+
|
|
71
|
+
export interface ProviderSessionTurnInput {
|
|
72
|
+
transcriptPath: string;
|
|
73
|
+
lastAssistantMessage?: unknown;
|
|
74
|
+
promptId?: string;
|
|
75
|
+
isPreFlush?: boolean;
|
|
76
|
+
toolName?: string;
|
|
77
|
+
toolInput?: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ProviderSessionTurnContext {
|
|
81
|
+
turnId?: string;
|
|
82
|
+
turnStartedAt?: number;
|
|
83
|
+
chatCaptureMode: ProviderConfig["chatCaptureMode"];
|
|
84
|
+
reasoningCapture?: boolean;
|
|
85
|
+
settlePollIntervalMs: number;
|
|
86
|
+
settlePollTimeoutMs: number;
|
|
87
|
+
flushSessionEvents(timeoutMs: number): Promise<boolean>;
|
|
88
|
+
pendingSessionEventCount(): number;
|
|
89
|
+
log(message: string): void;
|
|
90
|
+
debug(message: string): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ProviderSessionTurnResult {
|
|
94
|
+
reasoningSettled?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ProviderUserPromptInput {
|
|
98
|
+
prompt: string;
|
|
99
|
+
transcriptPath?: string;
|
|
100
|
+
promptId?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ProviderPermissionResolvedPrompt {
|
|
104
|
+
provider: "claude";
|
|
105
|
+
kind: "questions" | "plan" | "command" | "tool";
|
|
106
|
+
title: string;
|
|
107
|
+
body: string;
|
|
108
|
+
promptBody?: string;
|
|
109
|
+
toolName: string;
|
|
110
|
+
hookEventName: string;
|
|
111
|
+
approvalId: string;
|
|
112
|
+
decision: ProviderPermissionDecisionInput["decision"];
|
|
113
|
+
decisionLabel: string;
|
|
114
|
+
occurredAt: number;
|
|
115
|
+
resolvedAt: number;
|
|
116
|
+
decisionReason: ProviderPermissionDecisionReason;
|
|
117
|
+
questions?: unknown[];
|
|
118
|
+
answers?: Record<string, string>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ProviderPermissionPromptHandler {
|
|
122
|
+
source: string;
|
|
123
|
+
resolvedLabel: string;
|
|
124
|
+
buildView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt;
|
|
125
|
+
buildHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown>;
|
|
126
|
+
buildResolvedPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt;
|
|
127
|
+
// #1173: for prompt kinds whose PreToolUse hook is answered immediately (deny-then-hold —
|
|
128
|
+
// see control-server.ts's AskUserQuestion branch) rather than held open for the decision,
|
|
129
|
+
// this renders the eventual "answer" decision as the normal user-prompt text to inject back
|
|
130
|
+
// into the session. Absent for prompt kinds that still ride the hook response directly.
|
|
131
|
+
buildAnswerInjectionPrompt?(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string;
|
|
132
|
+
}
|
|
133
|
+
|
|
66
134
|
export interface ProviderConfig {
|
|
67
135
|
command: string;
|
|
68
136
|
defaultArgs: string[];
|
|
@@ -159,6 +227,9 @@ export interface ProviderPermissionDecisionReason {
|
|
|
159
227
|
|
|
160
228
|
export interface ProviderAdapter {
|
|
161
229
|
provider: string;
|
|
230
|
+
statusSourceId?: string;
|
|
231
|
+
stopObligationPath?: string;
|
|
232
|
+
permissionPromptHandler?: ProviderPermissionPromptHandler;
|
|
162
233
|
spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
|
|
163
234
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
164
235
|
compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
|
|
@@ -193,6 +264,12 @@ export interface ProviderAdapter {
|
|
|
193
264
|
probePaneBusy?(process: ManagedProcess): Promise<boolean>;
|
|
194
265
|
terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
|
|
195
266
|
respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
|
|
267
|
+
captureSessionTurn?(input: ProviderSessionTurnInput, ctx: ProviderSessionTurnContext): Promise<ProviderSessionTurnResult | void>;
|
|
268
|
+
handleUserPrompt?(input: ProviderUserPromptInput, ctx: ProviderSessionTurnContext): Promise<void>;
|
|
269
|
+
drainSessionTrace?(ctx: ProviderSessionTurnContext): Promise<void>;
|
|
270
|
+
settleSessionTraceForToolUse?(transcriptPath: string, toolName: string | undefined, toolInput: unknown, ctx: ProviderSessionTurnContext): Promise<{ settled: boolean; jsonl?: string }>;
|
|
271
|
+
stopSessionTrace?(): void;
|
|
272
|
+
publishPassiveTelemetry?(): Promise<void>;
|
|
196
273
|
// `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
|
|
197
274
|
// first (cold-start) delivery vs. a fast re-attempt after a ready signal (#329).
|
|
198
275
|
deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void>;
|
|
@@ -205,10 +282,10 @@ export interface ProviderAdapter {
|
|
|
205
282
|
seedsInitialPromptAtLaunch?: boolean;
|
|
206
283
|
deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
|
|
207
284
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
|
|
208
|
-
// Subscribe to session-mirror events from providers
|
|
209
|
-
//
|
|
210
|
-
// so it leaves this unimplemented.
|
|
285
|
+
// Subscribe to session-mirror events from providers. Batch callbacks preserve
|
|
286
|
+
// shared idempotency for multiple trace steps discovered in one provider poll.
|
|
211
287
|
onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
|
|
288
|
+
onSessionEvents?(cb: (events: ProviderSessionEventBatch) => void): void;
|
|
212
289
|
// §4.1 of the Runner↔provider contract (#725 / #746): fold the runner-tracked raw
|
|
213
290
|
// transport/timeline/token/work-kind signals into ONE normalized LivenessSignal the
|
|
214
291
|
// server reads instead of six raw `meta` fields. Each adapter owns what counts as
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { InteractivePrompt } from "agent-relay-sdk";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
|
+
import type { ProviderPermissionDecisionInput, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt } from "../adapter";
|
|
4
|
+
|
|
5
|
+
function decisionReasonMessage(decision: ProviderPermissionDecisionInput, fallback: string): string {
|
|
6
|
+
return decision.reason || decision.decisionReason?.message || fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const claudePermissionPromptHandler: ProviderPermissionPromptHandler = {
|
|
10
|
+
source: "claude",
|
|
11
|
+
resolvedLabel: "Claude approval resolved",
|
|
12
|
+
buildView: claudePermissionApprovalView,
|
|
13
|
+
buildHookResponse: claudePermissionHookResponse,
|
|
14
|
+
buildResolvedPrompt: claudeResolvedPermissionPrompt,
|
|
15
|
+
buildAnswerInjectionPrompt: claudeAnswerInjectionPrompt,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
|
|
19
|
+
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
20
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
21
|
+
if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
|
|
22
|
+
const count = toolInput.questions.length;
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
kind: "questions",
|
|
26
|
+
title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
|
|
27
|
+
body: "",
|
|
28
|
+
questions: toolInput.questions as InteractivePrompt["questions"],
|
|
29
|
+
choices: [],
|
|
30
|
+
reasoningSettled,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (toolName === "ExitPlanMode") {
|
|
34
|
+
const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
|
|
35
|
+
? toolInput.plan
|
|
36
|
+
: JSON.stringify(toolInput);
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
kind: "plan",
|
|
40
|
+
title: "Claude is ready to code",
|
|
41
|
+
body: plan,
|
|
42
|
+
choices: [
|
|
43
|
+
{ id: "approve", label: "Approve plan" },
|
|
44
|
+
{ id: "deny", label: "Keep planning" },
|
|
45
|
+
],
|
|
46
|
+
reasoningSettled,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
50
|
+
const description = typeof toolInput.description === "string" ? toolInput.description : "";
|
|
51
|
+
const bodyText = [
|
|
52
|
+
command || description || JSON.stringify(toolInput),
|
|
53
|
+
typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
|
|
54
|
+
typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
|
|
55
|
+
].filter(Boolean).join("\n");
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
|
|
59
|
+
title: `Approve ${toolName}`,
|
|
60
|
+
body: bodyText,
|
|
61
|
+
choices: [
|
|
62
|
+
{ id: "approve", label: "Approve" },
|
|
63
|
+
{ id: "approve-session", label: "Approve session" },
|
|
64
|
+
{ id: "deny", label: "Deny" },
|
|
65
|
+
{ id: "abort", label: "Abort" },
|
|
66
|
+
],
|
|
67
|
+
reasoningSettled,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
|
|
72
|
+
if (body.hook_event_name === "PreToolUse") {
|
|
73
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
74
|
+
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
75
|
+
return {
|
|
76
|
+
hookSpecificOutput: {
|
|
77
|
+
hookEventName: "PreToolUse",
|
|
78
|
+
permissionDecision: "allow",
|
|
79
|
+
updatedInput: { ...toolInput, answers: decision.answers },
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
hookSpecificOutput: {
|
|
85
|
+
hookEventName: "PreToolUse",
|
|
86
|
+
permissionDecision: "deny",
|
|
87
|
+
permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const hookEventName = "PermissionRequest";
|
|
92
|
+
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
93
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
94
|
+
return {
|
|
95
|
+
hookSpecificOutput: {
|
|
96
|
+
hookEventName,
|
|
97
|
+
decision: {
|
|
98
|
+
behavior: "allow",
|
|
99
|
+
updatedInput: { ...toolInput, answers: decision.answers },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (decision.decision === "approve" || decision.decision === "approve-session") {
|
|
105
|
+
const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
|
|
106
|
+
return {
|
|
107
|
+
hookSpecificOutput: {
|
|
108
|
+
hookEventName,
|
|
109
|
+
decision: {
|
|
110
|
+
behavior: "allow",
|
|
111
|
+
...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
hookSpecificOutput: {
|
|
118
|
+
hookEventName,
|
|
119
|
+
decision: {
|
|
120
|
+
behavior: "deny",
|
|
121
|
+
message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
|
|
122
|
+
interrupt: decision.decision === "abort",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt {
|
|
129
|
+
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
130
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
131
|
+
const kind: ProviderPermissionResolvedPrompt["kind"] = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
|
|
132
|
+
? view.kind
|
|
133
|
+
: toolName.toLowerCase() === "bash" ? "command" : "tool";
|
|
134
|
+
const questions = Array.isArray(view.questions)
|
|
135
|
+
? view.questions
|
|
136
|
+
: Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
137
|
+
const promptBody = typeof view.body === "string" ? view.body : "";
|
|
138
|
+
const title = typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`;
|
|
139
|
+
const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
|
|
140
|
+
return {
|
|
141
|
+
provider: "claude",
|
|
142
|
+
kind,
|
|
143
|
+
title,
|
|
144
|
+
body: claudeResolvedPermissionBody({ kind, title, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
|
|
145
|
+
...(promptBody ? { promptBody } : {}),
|
|
146
|
+
toolName,
|
|
147
|
+
hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
|
|
148
|
+
approvalId: decision.approvalId,
|
|
149
|
+
decision: decision.decision,
|
|
150
|
+
decisionLabel,
|
|
151
|
+
decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
|
|
152
|
+
occurredAt,
|
|
153
|
+
resolvedAt: Date.now(),
|
|
154
|
+
...(questions ? { questions } : {}),
|
|
155
|
+
...(decision.answers ? { answers: decision.answers } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// #1173: AskUserQuestion's PreToolUse hook is answered with `deny` immediately (see
|
|
160
|
+
// control-server.ts) so Claude flushes its buffered reasoning instead of blocking on the human.
|
|
161
|
+
// The user's actual answer arrives afterward as a normal injected prompt, not as a hook
|
|
162
|
+
// response — this renders that prompt's text from the same view/decision shape the old
|
|
163
|
+
// blocking flow used, so the model sees a clear, structured hand-off instead of silence.
|
|
164
|
+
function claudeAnswerInjectionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string {
|
|
165
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
166
|
+
const questions = Array.isArray(view.questions) ? view.questions : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
167
|
+
const rows = resolvedQuestionRows(questions, decision.answers, "(no answer given)");
|
|
168
|
+
const qa = rows.map((row) => `Q: ${row.question}\nA: ${row.answer}`).join("\n\n");
|
|
169
|
+
return [
|
|
170
|
+
"You previously called AskUserQuestion with the following question(s); it was intercepted so the user could answer directly via Agent Relay instead of through the tool call:",
|
|
171
|
+
qa,
|
|
172
|
+
"The user has now answered above. Continue the task using their answer(s) — do not call AskUserQuestion again for the same question(s).",
|
|
173
|
+
].join("\n\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
|
|
177
|
+
if (input.kind === "questions") {
|
|
178
|
+
return [
|
|
179
|
+
input.title,
|
|
180
|
+
input.decisionLabel,
|
|
181
|
+
...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
|
|
182
|
+
].filter(Boolean).join("\n\n");
|
|
183
|
+
}
|
|
184
|
+
const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
|
|
185
|
+
return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
|
|
189
|
+
const answerMap = answers ?? {};
|
|
190
|
+
const rows = (questions ?? []).map((question, index) => {
|
|
191
|
+
const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
|
|
192
|
+
const label = typeof record.question === "string" && record.question.trim()
|
|
193
|
+
? record.question.trim()
|
|
194
|
+
: typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
|
|
195
|
+
const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
|
|
196
|
+
return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
|
|
197
|
+
});
|
|
198
|
+
return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function claudePermissionDecisionLabel(kind: string, decision: ProviderPermissionDecisionInput["decision"]): string {
|
|
202
|
+
if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
|
|
203
|
+
if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
|
|
204
|
+
if (decision === "approve-session") return "Approved for session";
|
|
205
|
+
if (decision === "approve") return "Approved";
|
|
206
|
+
if (decision === "abort") return "Aborted";
|
|
207
|
+
if (decision === "answer") return "Answered";
|
|
208
|
+
return "Denied";
|
|
209
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { isRecord, type ProviderState } from "agent-relay-sdk";
|
|
3
|
-
import type { ProviderStatusUpdate, RunnerSpawnConfig } from "
|
|
4
|
-
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "
|
|
3
|
+
import type { ProviderStatusUpdate, RunnerSpawnConfig } from "../adapter";
|
|
4
|
+
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
5
5
|
|
|
6
6
|
export interface ClaudeConfigPromptGatePrevention {
|
|
7
7
|
id: string;
|
|
@@ -37,6 +37,7 @@ export interface ClaudePromptGatePaneContext {
|
|
|
37
37
|
sessionName?: string;
|
|
38
38
|
busy?: boolean;
|
|
39
39
|
nowMs?: number;
|
|
40
|
+
sourceId?: string;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export interface ClaudePromptGateAction {
|
|
@@ -44,7 +45,7 @@ export interface ClaudePromptGateAction {
|
|
|
44
45
|
label: string;
|
|
45
46
|
policy: string;
|
|
46
47
|
keystroke: string;
|
|
47
|
-
source:
|
|
48
|
+
source: string;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export interface ClaudePromptGatePaneResult {
|
|
@@ -113,7 +114,7 @@ const panePromptGates: ClaudePanePromptGate[] = [
|
|
|
113
114
|
kind: match.kind ?? "usage_limit",
|
|
114
115
|
...(match.resetAt ? { resetAt: match.resetAt } : {}),
|
|
115
116
|
message: match.message,
|
|
116
|
-
source: context
|
|
117
|
+
source: paneSource(context),
|
|
117
118
|
});
|
|
118
119
|
},
|
|
119
120
|
},
|
|
@@ -217,7 +218,7 @@ export function evaluateClaudePromptGatePane(
|
|
|
217
218
|
label: detected.entry.label,
|
|
218
219
|
policy: detected.entry.policy,
|
|
219
220
|
keystroke: detected.entry.keystroke,
|
|
220
|
-
source:
|
|
221
|
+
source: paneSource(context),
|
|
221
222
|
};
|
|
222
223
|
return {
|
|
223
224
|
action,
|
|
@@ -226,6 +227,11 @@ export function evaluateClaudePromptGatePane(
|
|
|
226
227
|
};
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
function paneSource(context: ClaudePromptGatePaneContext): string {
|
|
231
|
+
const source = context.sourceId ?? "claude-pane";
|
|
232
|
+
return context.sessionName ? `${source}:${context.sessionName}` : source;
|
|
233
|
+
}
|
|
234
|
+
|
|
229
235
|
function firstDetectedPaneGate(text: string, nowMs?: number): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
|
|
230
236
|
for (const entry of panePromptGates) {
|
|
231
237
|
const match = entry.match(text, nowMs);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { errMessage, type MessageSessionMeta } from "agent-relay-sdk";
|
|
3
|
+
import type { ManagedProcess, ProviderSessionEvent, ProviderSessionEventBatch, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderSessionTurnResult, ProviderUserPromptInput } from "../adapter";
|
|
4
|
+
import type { SessionEvent } from "../session-insights";
|
|
5
|
+
import {
|
|
6
|
+
collectClaudeSessionEvents,
|
|
7
|
+
countTranscriptEntries,
|
|
8
|
+
extractAssistantTurnForPrompt,
|
|
9
|
+
extractHookAssistantMessage,
|
|
10
|
+
extractLastAssistantTurn,
|
|
11
|
+
extractLastAssistantTurnAfterEntry,
|
|
12
|
+
extractLatestTurnSteps,
|
|
13
|
+
stepDedupKeys,
|
|
14
|
+
transcriptHasToolUseAnchor,
|
|
15
|
+
transcriptLooksComplete,
|
|
16
|
+
} from "./claude-transcript";
|
|
17
|
+
import { IncrementalClaudeTranscriptTail } from "./claude-transcript-tail";
|
|
18
|
+
|
|
19
|
+
interface ReasoningTailState {
|
|
20
|
+
timer: ReturnType<typeof setInterval>;
|
|
21
|
+
emittedNarrationKeys: Set<string>;
|
|
22
|
+
poll(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const REASONING_POLL_MS = 1_200;
|
|
26
|
+
|
|
27
|
+
export class ClaudeSessionCapture {
|
|
28
|
+
private narrativeFlushEntryCount = 0;
|
|
29
|
+
private reasoningTail?: ReasoningTailState;
|
|
30
|
+
private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
|
|
31
|
+
private sessionEventsCb: (events: ProviderSessionEventBatch) => void = (events) => { for (const event of events) this.sessionEventCb(event); };
|
|
32
|
+
|
|
33
|
+
onSessionEvent(cb: (event: ProviderSessionEvent) => void): void {
|
|
34
|
+
this.sessionEventCb = cb;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
onSessionEvents(cb: (events: ProviderSessionEventBatch) => void): void {
|
|
38
|
+
this.sessionEventsCb = cb;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async captureSessionTurn(input: ProviderSessionTurnInput, ctx: ProviderSessionTurnContext): Promise<ProviderSessionTurnResult | void> {
|
|
42
|
+
if (input.isPreFlush) {
|
|
43
|
+
if (!input.transcriptPath) return { reasoningSettled: true };
|
|
44
|
+
const settled = await this.settlePollForToolUse(input.transcriptPath, input.toolName, input.toolInput, ctx);
|
|
45
|
+
const jsonl = settled.jsonl;
|
|
46
|
+
if (jsonl === undefined) return { reasoningSettled: false };
|
|
47
|
+
const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
|
|
48
|
+
await this.drainReasoningTail(ctx);
|
|
49
|
+
this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
|
|
50
|
+
if (body && !this.preFlushBodyAlreadyMirroredByReasoningTail(jsonl, body)) {
|
|
51
|
+
ctx.log(`pre-flush narrative for turn ${ctx.turnId ?? "?"} (${body.length} chars)`);
|
|
52
|
+
this.emit({ type: "response", origin: "provider", body, final: false, ...(ctx.turnId ? { turnId: ctx.turnId } : {}) });
|
|
53
|
+
}
|
|
54
|
+
await ctx.flushSessionEvents(2_000).then(
|
|
55
|
+
(ok) => { if (!ok) ctx.log(`pre-flush session outbox incomplete before blocking control (${ctx.pendingSessionEventCount()} pending)`); },
|
|
56
|
+
(error) => ctx.log(`pre-flush session outbox failed before blocking control: ${errMessage(error)}`),
|
|
57
|
+
);
|
|
58
|
+
return { reasoningSettled: settled.settled };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const turnId = input.promptId ?? ctx.turnId;
|
|
62
|
+
await this.drainReasoningTail(ctx);
|
|
63
|
+
this.stopSessionTrace();
|
|
64
|
+
const flushCount = this.narrativeFlushEntryCount;
|
|
65
|
+
this.narrativeFlushEntryCount = 0;
|
|
66
|
+
|
|
67
|
+
let body = extractHookAssistantMessage(input.lastAssistantMessage);
|
|
68
|
+
if (ctx.chatCaptureMode === "full" && input.transcriptPath) {
|
|
69
|
+
let jsonl: string | undefined;
|
|
70
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
|
|
71
|
+
if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
|
|
72
|
+
let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
|
|
73
|
+
if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
|
|
74
|
+
if (full) body = full;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!body && ctx.chatCaptureMode === "final" && input.transcriptPath) {
|
|
78
|
+
let jsonl: string | undefined;
|
|
79
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
|
|
80
|
+
if (jsonl !== undefined) {
|
|
81
|
+
const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
|
|
82
|
+
body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!body) {
|
|
86
|
+
ctx.log(`response capture: no closing text for turn ${turnId ?? "?"} (skipped)`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ctx.log(`response captured for turn ${turnId ?? "?"} (${body.length} chars)`);
|
|
91
|
+
this.emit({
|
|
92
|
+
type: "response",
|
|
93
|
+
origin: "provider",
|
|
94
|
+
body,
|
|
95
|
+
final: true,
|
|
96
|
+
confidence: "definitive",
|
|
97
|
+
endedBy: "completed",
|
|
98
|
+
replyToUserObligation: true,
|
|
99
|
+
...(turnId ? { turnId } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async handleUserPrompt(input: ProviderUserPromptInput, ctx: ProviderSessionTurnContext): Promise<void> {
|
|
104
|
+
if (input.transcriptPath) this.startReasoningTail(input.transcriptPath, ctx);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async drainReasoningTail(ctx: ProviderSessionTurnContext): Promise<void> {
|
|
108
|
+
if (ctx.reasoningCapture === false) return;
|
|
109
|
+
await this.reasoningTail?.poll();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
stopSessionTrace(): void {
|
|
113
|
+
if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
|
|
114
|
+
this.reasoningTail = undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async settlePollForToolUse(transcriptPath: string, toolName: string | undefined, toolInput: unknown, ctx: ProviderSessionTurnContext): Promise<{ settled: boolean; jsonl?: string }> {
|
|
118
|
+
const deadline = Date.now() + ctx.settlePollTimeoutMs;
|
|
119
|
+
for (;;) {
|
|
120
|
+
let jsonl: string | undefined;
|
|
121
|
+
try { jsonl = await readFile(transcriptPath, "utf8"); } catch { jsonl = undefined; }
|
|
122
|
+
if (!toolName) return { settled: Boolean(jsonl), jsonl };
|
|
123
|
+
if (jsonl !== undefined && transcriptHasToolUseAnchor(jsonl, toolName, toolInput)) return { settled: true, jsonl };
|
|
124
|
+
if (Date.now() >= deadline) return { settled: false, jsonl };
|
|
125
|
+
await Bun.sleep(ctx.settlePollIntervalMs);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private startReasoningTail(transcriptPath: string, ctx: ProviderSessionTurnContext): void {
|
|
130
|
+
if (ctx.reasoningCapture === false) return;
|
|
131
|
+
this.stopSessionTrace();
|
|
132
|
+
const seen = new Set<string>();
|
|
133
|
+
const emittedNarrationKeys = new Set<string>();
|
|
134
|
+
const transcriptTail = new IncrementalClaudeTranscriptTail();
|
|
135
|
+
const turnIdAtStart = ctx.turnId;
|
|
136
|
+
let seeded = false;
|
|
137
|
+
let pendingPoll = Promise.resolve();
|
|
138
|
+
const pollBody = async (): Promise<void> => {
|
|
139
|
+
const result = await transcriptTail.poll(transcriptPath);
|
|
140
|
+
if (!result.available) return;
|
|
141
|
+
if (result.reset) { seen.clear(); emittedNarrationKeys.clear(); seeded = false; }
|
|
142
|
+
if (!result.changed && (seeded || (!result.steps.length && !result.transcriptLooksComplete))) return;
|
|
143
|
+
const keyed = stepDedupKeys(result.steps).map((sig, i) => ({ sig, step: result.steps[i]! }));
|
|
144
|
+
if (!seeded) {
|
|
145
|
+
seeded = true;
|
|
146
|
+
if (result.transcriptLooksComplete) {
|
|
147
|
+
for (const { sig } of keyed) seen.add(sig);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const turnId = ctx.turnId ?? turnIdAtStart;
|
|
151
|
+
let emitted = 0;
|
|
152
|
+
const events: ProviderSessionEvent[] = [];
|
|
153
|
+
for (const { sig, step } of keyed) {
|
|
154
|
+
if (seen.has(sig)) continue;
|
|
155
|
+
seen.add(sig);
|
|
156
|
+
emitted += 1;
|
|
157
|
+
events.push({
|
|
158
|
+
type: step.type,
|
|
159
|
+
origin: "provider",
|
|
160
|
+
body: step.text,
|
|
161
|
+
...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
|
|
162
|
+
...(turnId ? { turnId } : {}),
|
|
163
|
+
...(step.label ? { label: step.label } : {}),
|
|
164
|
+
});
|
|
165
|
+
if (step.type === "narration") emittedNarrationKeys.add(sig);
|
|
166
|
+
}
|
|
167
|
+
if (events.length) this.emitBatch(events);
|
|
168
|
+
if (emitted) ctx.debug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
|
|
169
|
+
};
|
|
170
|
+
const poll = (): Promise<void> => {
|
|
171
|
+
const next = pendingPoll.then(pollBody, pollBody);
|
|
172
|
+
pendingPoll = next.catch(() => {});
|
|
173
|
+
return next;
|
|
174
|
+
};
|
|
175
|
+
this.reasoningTail = { emittedNarrationKeys, poll, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
|
|
176
|
+
ctx.log(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
|
|
177
|
+
void poll();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
|
|
181
|
+
const tail = this.reasoningTail;
|
|
182
|
+
if (!tail || !tail.emittedNarrationKeys.size) return false;
|
|
183
|
+
const steps = extractLatestTurnSteps(jsonl);
|
|
184
|
+
const keys = stepDedupKeys(steps);
|
|
185
|
+
const mirrored = steps
|
|
186
|
+
.map((step, index) => ({ step, key: keys[index]! }))
|
|
187
|
+
.filter(({ step, key }) => step.type === "narration" && tail.emittedNarrationKeys.has(key))
|
|
188
|
+
.map(({ step }) => step.text)
|
|
189
|
+
.join("\n\n")
|
|
190
|
+
.trim();
|
|
191
|
+
const trimmed = body.trim();
|
|
192
|
+
return mirrored === trimmed || mirrored.endsWith(`\n\n${trimmed}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private emit(event: ProviderSessionEvent): void {
|
|
196
|
+
this.sessionEventCb(event);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private emitBatch(events: ProviderSessionEventBatch): void {
|
|
200
|
+
this.sessionEventsCb(events);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function providerSessionEventToMessageSession(event: ProviderSessionEvent): MessageSessionMeta {
|
|
205
|
+
return {
|
|
206
|
+
type: event.type,
|
|
207
|
+
origin: event.origin ?? "provider",
|
|
208
|
+
...(event.turnId ? { turnId: event.turnId } : {}),
|
|
209
|
+
...(event.label ? { label: event.label } : {}),
|
|
210
|
+
...(event.status ? { status: event.status } : {}),
|
|
211
|
+
...(event.streaming !== undefined ? { streaming: event.streaming } : {}),
|
|
212
|
+
...(event.stepId ? { stepId: event.stepId } : {}),
|
|
213
|
+
...(event.final ? { final: true } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function collectClaudeTranscriptSessionEvents(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null> {
|
|
218
|
+
if (!ctx.transcriptPath) return null;
|
|
219
|
+
let jsonl: string;
|
|
220
|
+
try {
|
|
221
|
+
jsonl = await readFile(ctx.transcriptPath, "utf8");
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return collectClaudeSessionEvents(jsonl);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function collectClaudeTranscriptArchiveSegment(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null> {
|
|
229
|
+
if (!ctx.transcriptPath) return null;
|
|
230
|
+
try {
|
|
231
|
+
return await readFile(ctx.transcriptPath, "utf8");
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|