agent-relay-runner 0.127.5 → 0.127.6
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 +76 -4
- package/src/adapters/claude-permission.ts +191 -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 +34 -265
- package/src/launch-assembly.ts +2 -2
- package/src/profile-home.ts +1 -1
- package/src/runner-core.ts +110 -321
- 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.6",
|
|
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,66 @@ 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
|
+
}
|
|
128
|
+
|
|
66
129
|
export interface ProviderConfig {
|
|
67
130
|
command: string;
|
|
68
131
|
defaultArgs: string[];
|
|
@@ -159,6 +222,9 @@ export interface ProviderPermissionDecisionReason {
|
|
|
159
222
|
|
|
160
223
|
export interface ProviderAdapter {
|
|
161
224
|
provider: string;
|
|
225
|
+
statusSourceId?: string;
|
|
226
|
+
stopObligationPath?: string;
|
|
227
|
+
permissionPromptHandler?: ProviderPermissionPromptHandler;
|
|
162
228
|
spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
|
|
163
229
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
164
230
|
compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
|
|
@@ -193,6 +259,12 @@ export interface ProviderAdapter {
|
|
|
193
259
|
probePaneBusy?(process: ManagedProcess): Promise<boolean>;
|
|
194
260
|
terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
|
|
195
261
|
respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
|
|
262
|
+
captureSessionTurn?(input: ProviderSessionTurnInput, ctx: ProviderSessionTurnContext): Promise<ProviderSessionTurnResult | void>;
|
|
263
|
+
handleUserPrompt?(input: ProviderUserPromptInput, ctx: ProviderSessionTurnContext): Promise<void>;
|
|
264
|
+
drainSessionTrace?(ctx: ProviderSessionTurnContext): Promise<void>;
|
|
265
|
+
settleSessionTraceForToolUse?(transcriptPath: string, toolName: string | undefined, toolInput: unknown, ctx: ProviderSessionTurnContext): Promise<{ settled: boolean; jsonl?: string }>;
|
|
266
|
+
stopSessionTrace?(): void;
|
|
267
|
+
publishPassiveTelemetry?(): Promise<void>;
|
|
196
268
|
// `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
|
|
197
269
|
// first (cold-start) delivery vs. a fast re-attempt after a ready signal (#329).
|
|
198
270
|
deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void>;
|
|
@@ -205,10 +277,10 @@ export interface ProviderAdapter {
|
|
|
205
277
|
seedsInitialPromptAtLaunch?: boolean;
|
|
206
278
|
deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
|
|
207
279
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
|
|
208
|
-
// Subscribe to session-mirror events from providers
|
|
209
|
-
//
|
|
210
|
-
// so it leaves this unimplemented.
|
|
280
|
+
// Subscribe to session-mirror events from providers. Batch callbacks preserve
|
|
281
|
+
// shared idempotency for multiple trace steps discovered in one provider poll.
|
|
211
282
|
onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
|
|
283
|
+
onSessionEvents?(cb: (events: ProviderSessionEventBatch) => void): void;
|
|
212
284
|
// §4.1 of the Runner↔provider contract (#725 / #746): fold the runner-tracked raw
|
|
213
285
|
// transport/timeline/token/work-kind signals into ONE normalized LivenessSignal the
|
|
214
286
|
// server reads instead of six raw `meta` fields. Each adapter owns what counts as
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
};
|
|
16
|
+
|
|
17
|
+
function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
|
|
18
|
+
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
19
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
20
|
+
if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
|
|
21
|
+
const count = toolInput.questions.length;
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
kind: "questions",
|
|
25
|
+
title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
|
|
26
|
+
body: "",
|
|
27
|
+
questions: toolInput.questions as InteractivePrompt["questions"],
|
|
28
|
+
choices: [],
|
|
29
|
+
reasoningSettled,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (toolName === "ExitPlanMode") {
|
|
33
|
+
const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
|
|
34
|
+
? toolInput.plan
|
|
35
|
+
: JSON.stringify(toolInput);
|
|
36
|
+
return {
|
|
37
|
+
id,
|
|
38
|
+
kind: "plan",
|
|
39
|
+
title: "Claude is ready to code",
|
|
40
|
+
body: plan,
|
|
41
|
+
choices: [
|
|
42
|
+
{ id: "approve", label: "Approve plan" },
|
|
43
|
+
{ id: "deny", label: "Keep planning" },
|
|
44
|
+
],
|
|
45
|
+
reasoningSettled,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
49
|
+
const description = typeof toolInput.description === "string" ? toolInput.description : "";
|
|
50
|
+
const bodyText = [
|
|
51
|
+
command || description || JSON.stringify(toolInput),
|
|
52
|
+
typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
|
|
53
|
+
typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
|
|
54
|
+
].filter(Boolean).join("\n");
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
|
|
58
|
+
title: `Approve ${toolName}`,
|
|
59
|
+
body: bodyText,
|
|
60
|
+
choices: [
|
|
61
|
+
{ id: "approve", label: "Approve" },
|
|
62
|
+
{ id: "approve-session", label: "Approve session" },
|
|
63
|
+
{ id: "deny", label: "Deny" },
|
|
64
|
+
{ id: "abort", label: "Abort" },
|
|
65
|
+
],
|
|
66
|
+
reasoningSettled,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
|
|
71
|
+
if (body.hook_event_name === "PreToolUse") {
|
|
72
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
73
|
+
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
74
|
+
return {
|
|
75
|
+
hookSpecificOutput: {
|
|
76
|
+
hookEventName: "PreToolUse",
|
|
77
|
+
permissionDecision: "allow",
|
|
78
|
+
updatedInput: { ...toolInput, answers: decision.answers },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
hookSpecificOutput: {
|
|
84
|
+
hookEventName: "PreToolUse",
|
|
85
|
+
permissionDecision: "deny",
|
|
86
|
+
permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const hookEventName = "PermissionRequest";
|
|
91
|
+
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
92
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
93
|
+
return {
|
|
94
|
+
hookSpecificOutput: {
|
|
95
|
+
hookEventName,
|
|
96
|
+
decision: {
|
|
97
|
+
behavior: "allow",
|
|
98
|
+
updatedInput: { ...toolInput, answers: decision.answers },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (decision.decision === "approve" || decision.decision === "approve-session") {
|
|
104
|
+
const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
|
|
105
|
+
return {
|
|
106
|
+
hookSpecificOutput: {
|
|
107
|
+
hookEventName,
|
|
108
|
+
decision: {
|
|
109
|
+
behavior: "allow",
|
|
110
|
+
...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
hookSpecificOutput: {
|
|
117
|
+
hookEventName,
|
|
118
|
+
decision: {
|
|
119
|
+
behavior: "deny",
|
|
120
|
+
message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
|
|
121
|
+
interrupt: decision.decision === "abort",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt {
|
|
128
|
+
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
129
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
130
|
+
const kind: ProviderPermissionResolvedPrompt["kind"] = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
|
|
131
|
+
? view.kind
|
|
132
|
+
: toolName.toLowerCase() === "bash" ? "command" : "tool";
|
|
133
|
+
const questions = Array.isArray(view.questions)
|
|
134
|
+
? view.questions
|
|
135
|
+
: Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
136
|
+
const promptBody = typeof view.body === "string" ? view.body : "";
|
|
137
|
+
const title = typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`;
|
|
138
|
+
const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
|
|
139
|
+
return {
|
|
140
|
+
provider: "claude",
|
|
141
|
+
kind,
|
|
142
|
+
title,
|
|
143
|
+
body: claudeResolvedPermissionBody({ kind, title, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
|
|
144
|
+
...(promptBody ? { promptBody } : {}),
|
|
145
|
+
toolName,
|
|
146
|
+
hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
|
|
147
|
+
approvalId: decision.approvalId,
|
|
148
|
+
decision: decision.decision,
|
|
149
|
+
decisionLabel,
|
|
150
|
+
decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
|
|
151
|
+
occurredAt,
|
|
152
|
+
resolvedAt: Date.now(),
|
|
153
|
+
...(questions ? { questions } : {}),
|
|
154
|
+
...(decision.answers ? { answers: decision.answers } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
|
|
159
|
+
if (input.kind === "questions") {
|
|
160
|
+
return [
|
|
161
|
+
input.title,
|
|
162
|
+
input.decisionLabel,
|
|
163
|
+
...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
|
|
164
|
+
].filter(Boolean).join("\n\n");
|
|
165
|
+
}
|
|
166
|
+
const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
|
|
167
|
+
return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
|
|
171
|
+
const answerMap = answers ?? {};
|
|
172
|
+
const rows = (questions ?? []).map((question, index) => {
|
|
173
|
+
const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
|
|
174
|
+
const label = typeof record.question === "string" && record.question.trim()
|
|
175
|
+
? record.question.trim()
|
|
176
|
+
: typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
|
|
177
|
+
const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
|
|
178
|
+
return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
|
|
179
|
+
});
|
|
180
|
+
return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function claudePermissionDecisionLabel(kind: string, decision: ProviderPermissionDecisionInput["decision"]): string {
|
|
184
|
+
if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
|
|
185
|
+
if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
|
|
186
|
+
if (decision === "approve-session") return "Approved for session";
|
|
187
|
+
if (decision === "approve") return "Approved";
|
|
188
|
+
if (decision === "abort") return "Aborted";
|
|
189
|
+
if (decision === "answer") return "Answered";
|
|
190
|
+
return "Denied";
|
|
191
|
+
}
|
|
@@ -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
|
+
}
|