agent-relay-runner 0.127.4 → 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 +82 -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 +102 -267
- package/src/launch-assembly.ts +2 -2
- package/src/mcp-outbox.ts +54 -4
- package/src/pending-permission-store.ts +148 -0
- package/src/profile-home.ts +1 -1
- package/src/runner-core.ts +134 -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.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[];
|
|
@@ -146,13 +209,22 @@ export interface ProviderPermissionDecisionInput {
|
|
|
146
209
|
approvalId: string;
|
|
147
210
|
decision: ProviderPermissionDecision;
|
|
148
211
|
reason?: string;
|
|
212
|
+
decisionReason?: ProviderPermissionDecisionReason;
|
|
149
213
|
// For "answer" decisions (Claude AskUserQuestion): maps each question's text
|
|
150
214
|
// to the chosen option label(s). Multi-select labels are comma-joined.
|
|
151
215
|
answers?: Record<string, string>;
|
|
152
216
|
}
|
|
153
217
|
|
|
218
|
+
export interface ProviderPermissionDecisionReason {
|
|
219
|
+
kind: "timeout" | "dismissed" | "answered";
|
|
220
|
+
message?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
154
223
|
export interface ProviderAdapter {
|
|
155
224
|
provider: string;
|
|
225
|
+
statusSourceId?: string;
|
|
226
|
+
stopObligationPath?: string;
|
|
227
|
+
permissionPromptHandler?: ProviderPermissionPromptHandler;
|
|
156
228
|
spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
|
|
157
229
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
158
230
|
compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
|
|
@@ -187,6 +259,12 @@ export interface ProviderAdapter {
|
|
|
187
259
|
probePaneBusy?(process: ManagedProcess): Promise<boolean>;
|
|
188
260
|
terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
|
|
189
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>;
|
|
190
268
|
// `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
|
|
191
269
|
// first (cold-start) delivery vs. a fast re-attempt after a ready signal (#329).
|
|
192
270
|
deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void>;
|
|
@@ -199,10 +277,10 @@ export interface ProviderAdapter {
|
|
|
199
277
|
seedsInitialPromptAtLaunch?: boolean;
|
|
200
278
|
deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
|
|
201
279
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
|
|
202
|
-
// Subscribe to session-mirror events from providers
|
|
203
|
-
//
|
|
204
|
-
// 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.
|
|
205
282
|
onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
|
|
283
|
+
onSessionEvents?(cb: (events: ProviderSessionEventBatch) => void): void;
|
|
206
284
|
// §4.1 of the Runner↔provider contract (#725 / #746): fold the runner-tracked raw
|
|
207
285
|
// transport/timeline/token/work-kind signals into ONE normalized LivenessSignal the
|
|
208
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
|
+
}
|