linkshell-cli 0.2.72 → 0.2.73
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/dist/cli/src/index.js +2 -1
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +65 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +1070 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -0
- package/dist/cli/src/runtime/bridge-session.d.ts +1 -0
- package/dist/cli/src/runtime/bridge-session.js +42 -2
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +4230 -619
- package/dist/shared-protocol/src/index.js +123 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +2 -1
- package/src/runtime/acp/agent-workspace.ts +1222 -0
- package/src/runtime/bridge-session.ts +47 -3
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
createEnvelope,
|
|
4
|
+
parseTypedPayload,
|
|
5
|
+
type Envelope,
|
|
6
|
+
} from "@linkshell/protocol";
|
|
7
|
+
import { AcpClient } from "./acp-client.js";
|
|
8
|
+
import type { AgentProvider } from "./provider-resolver.js";
|
|
9
|
+
import { resolveAgentCommand } from "./provider-resolver.js";
|
|
10
|
+
|
|
11
|
+
type AgentStatus = "unavailable" | "idle" | "running" | "waiting_permission" | "error";
|
|
12
|
+
type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
|
|
13
|
+
|
|
14
|
+
interface AgentContentBlock {
|
|
15
|
+
type: "text" | "image";
|
|
16
|
+
text?: string;
|
|
17
|
+
data?: string;
|
|
18
|
+
mimeType?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AgentToolCall {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
input?: string;
|
|
25
|
+
output?: string;
|
|
26
|
+
createdAt?: number;
|
|
27
|
+
status: "pending" | "running" | "completed" | "failed";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AgentPlanStep {
|
|
31
|
+
id: string;
|
|
32
|
+
text: string;
|
|
33
|
+
status: "pending" | "in_progress" | "completed";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface AgentPermission {
|
|
37
|
+
requestId: string;
|
|
38
|
+
toolName?: string;
|
|
39
|
+
toolInput?: string;
|
|
40
|
+
context?: string;
|
|
41
|
+
options: { id: string; label: string; kind: "allow" | "deny" | "other" }[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AgentConversation {
|
|
45
|
+
id: string;
|
|
46
|
+
agentSessionId?: string;
|
|
47
|
+
provider: AgentProvider;
|
|
48
|
+
cwd: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
reasoningEffort?: string;
|
|
52
|
+
permissionMode?: AgentPermissionMode;
|
|
53
|
+
status: AgentStatus;
|
|
54
|
+
archived: boolean;
|
|
55
|
+
lastMessagePreview?: string;
|
|
56
|
+
lastActivityAt: number;
|
|
57
|
+
createdAt: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface AgentTimelineItem {
|
|
61
|
+
id: string;
|
|
62
|
+
conversationId: string;
|
|
63
|
+
type: "message" | "tool_call" | "plan" | "permission" | "status" | "error";
|
|
64
|
+
role?: "user" | "assistant" | "system";
|
|
65
|
+
content?: AgentContentBlock[];
|
|
66
|
+
text?: string;
|
|
67
|
+
toolCall?: AgentToolCall;
|
|
68
|
+
plan?: AgentPlanStep[];
|
|
69
|
+
permission?: AgentPermission;
|
|
70
|
+
status?: AgentStatus;
|
|
71
|
+
error?: string;
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
73
|
+
createdAt: number;
|
|
74
|
+
updatedAt?: number;
|
|
75
|
+
isStreaming?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PendingPermissionWaiter {
|
|
79
|
+
resolve: (value: unknown) => void;
|
|
80
|
+
timer: ReturnType<typeof setTimeout>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const PERMISSION_TIMEOUT_MS = 5 * 60_000;
|
|
84
|
+
const MAX_TIMELINE_ITEMS = 200;
|
|
85
|
+
|
|
86
|
+
function id(prefix: string): string {
|
|
87
|
+
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stringify(value: unknown): string {
|
|
91
|
+
if (typeof value === "string") return value;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.stringify(value, null, 2);
|
|
94
|
+
} catch {
|
|
95
|
+
return String(value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
100
|
+
return typeof value === "object" && value ? value as Record<string, unknown> : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function firstString(value: Record<string, unknown> | undefined, keys: string[]): string | undefined {
|
|
104
|
+
if (!value) return undefined;
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
const next = value[key];
|
|
107
|
+
if (typeof next === "string" && next.length > 0) return next;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractItem(value: unknown): Record<string, unknown> | undefined {
|
|
113
|
+
const raw = asRecord(value);
|
|
114
|
+
if (!raw) return undefined;
|
|
115
|
+
return asRecord(raw.item) ?? raw;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stringifyDefined(value: unknown): string | undefined {
|
|
119
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
120
|
+
return stringify(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function appendCapped(current: string | undefined, delta: string, maxLength: number): string {
|
|
124
|
+
const next = `${current ?? ""}${delta}`;
|
|
125
|
+
if (next.length <= maxLength) return next;
|
|
126
|
+
return next.slice(next.length - maxLength);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function decodeBase64(value: string | undefined): string | undefined {
|
|
130
|
+
if (!value) return undefined;
|
|
131
|
+
try {
|
|
132
|
+
return Buffer.from(value, "base64").toString("utf8");
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeToolStatus(value: unknown, completedFallback = false): AgentToolCall["status"] {
|
|
139
|
+
if (value === "completed" || value === "succeeded" || value === "success" || value === "applied") {
|
|
140
|
+
return "completed";
|
|
141
|
+
}
|
|
142
|
+
if (value === "failed" || value === "error" || value === "declined" || value === "cancelled") {
|
|
143
|
+
return "failed";
|
|
144
|
+
}
|
|
145
|
+
if (value === "pending" || value === "queued") return "pending";
|
|
146
|
+
if (value === "running" || value === "inProgress" || value === "executing") return "running";
|
|
147
|
+
return completedFallback ? "completed" : "running";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizePlanStatus(value: unknown): AgentPlanStep["status"] {
|
|
151
|
+
if (value === "completed" || value === "done") return "completed";
|
|
152
|
+
if (value === "inProgress" || value === "running" || value === "active") return "in_progress";
|
|
153
|
+
return "pending";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function nameFromToolMethod(method: string): string {
|
|
157
|
+
if (method.includes("commandExecution")) return "命令";
|
|
158
|
+
if (method.includes("fileChange")) return "文件修改";
|
|
159
|
+
if (method.includes("mcpToolCall")) return "MCP 工具";
|
|
160
|
+
return "工具";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toolNameFromItem(item: Record<string, unknown>): string | undefined {
|
|
164
|
+
const itemType = firstString(item, ["type"]);
|
|
165
|
+
if (itemType === "commandExecution") return "命令";
|
|
166
|
+
if (itemType === "fileChange") return "文件修改";
|
|
167
|
+
if (itemType === "mcpToolCall") {
|
|
168
|
+
const server = firstString(item, ["server"]);
|
|
169
|
+
const tool = firstString(item, ["tool", "toolName", "name"]);
|
|
170
|
+
return [server, tool].filter(Boolean).join(" · ") || "MCP 工具";
|
|
171
|
+
}
|
|
172
|
+
if (itemType === "dynamicToolCall") {
|
|
173
|
+
const namespace = firstString(item, ["namespace"]);
|
|
174
|
+
const tool = firstString(item, ["tool", "toolName", "name"]);
|
|
175
|
+
return [namespace, tool].filter(Boolean).join(" · ") || "工具";
|
|
176
|
+
}
|
|
177
|
+
return firstString(item, ["toolName", "tool", "name", "title"]) ?? itemType;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function summarizeFileChanges(changes: unknown[]): string | undefined {
|
|
181
|
+
const lines = changes
|
|
182
|
+
.map((change) => {
|
|
183
|
+
const raw = asRecord(change);
|
|
184
|
+
if (!raw) return undefined;
|
|
185
|
+
const path =
|
|
186
|
+
firstString(raw, ["path", "file", "filePath", "absolutePath", "relativePath"]) ??
|
|
187
|
+
firstString(asRecord(raw.update), ["path", "file", "filePath"]);
|
|
188
|
+
const kind = firstString(raw, ["kind", "type", "operation", "action"]);
|
|
189
|
+
return [kind, path].filter(Boolean).join(" ");
|
|
190
|
+
})
|
|
191
|
+
.filter((line): line is string => Boolean(line));
|
|
192
|
+
return lines.length > 0 ? lines.slice(0, 8).join("\n") : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toolInputFromItem(item: Record<string, unknown>): string | undefined {
|
|
196
|
+
const itemType = firstString(item, ["type"]);
|
|
197
|
+
if (itemType === "commandExecution") {
|
|
198
|
+
const command = firstString(item, ["command"]);
|
|
199
|
+
const cwd = firstString(item, ["cwd"]);
|
|
200
|
+
if (command && cwd) return `${command}\n\ncwd: ${cwd}`;
|
|
201
|
+
return command ?? cwd;
|
|
202
|
+
}
|
|
203
|
+
if (itemType === "fileChange") {
|
|
204
|
+
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
205
|
+
return summarizeFileChanges(changes);
|
|
206
|
+
}
|
|
207
|
+
return stringifyDefined(item.arguments ?? item.input ?? item.toolInput);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function titleFromCwd(cwd: string): string {
|
|
211
|
+
return basename(cwd) || cwd || "Agent";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function textFromBlocks(blocks: AgentContentBlock[]): string {
|
|
215
|
+
return blocks
|
|
216
|
+
.map((block) => block.type === "text" ? block.text ?? "" : `[${block.mimeType ?? "image"} attachment]`)
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function previewText(text: string): string {
|
|
222
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export class AgentWorkspaceProxy {
|
|
226
|
+
private client: AcpClient | undefined;
|
|
227
|
+
private initialized = false;
|
|
228
|
+
private status: AgentStatus = "unavailable";
|
|
229
|
+
private error: string | undefined;
|
|
230
|
+
private activeConversationId: string | undefined;
|
|
231
|
+
private currentTurnId: string | undefined;
|
|
232
|
+
private conversations = new Map<string, AgentConversation>();
|
|
233
|
+
private conversationByAgentSessionId = new Map<string, string>();
|
|
234
|
+
private timelines = new Map<string, AgentTimelineItem[]>();
|
|
235
|
+
private toolOutputBuffers = new Map<string, string>();
|
|
236
|
+
private pendingPermissions = new Map<string, AgentPermission>();
|
|
237
|
+
private permissionWaiters = new Map<string, PendingPermissionWaiter>();
|
|
238
|
+
private permissionSources = new Map<string, string>();
|
|
239
|
+
private toolConversationIds = new Map<string, string>();
|
|
240
|
+
|
|
241
|
+
constructor(
|
|
242
|
+
private readonly input: {
|
|
243
|
+
sessionId: string;
|
|
244
|
+
cwd: string;
|
|
245
|
+
provider: AgentProvider;
|
|
246
|
+
command?: string;
|
|
247
|
+
send: (envelope: Envelope) => void;
|
|
248
|
+
verbose?: boolean;
|
|
249
|
+
},
|
|
250
|
+
) {}
|
|
251
|
+
|
|
252
|
+
async handleEnvelope(envelope: Envelope): Promise<void> {
|
|
253
|
+
switch (envelope.type) {
|
|
254
|
+
case "agent.v2.capabilities.request":
|
|
255
|
+
await this.initialize();
|
|
256
|
+
this.sendCapabilities();
|
|
257
|
+
break;
|
|
258
|
+
case "agent.v2.conversation.open": {
|
|
259
|
+
const payload = parseTypedPayload("agent.v2.conversation.open", envelope.payload);
|
|
260
|
+
await this.openConversation(payload);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "agent.v2.conversation.list": {
|
|
264
|
+
const payload = parseTypedPayload("agent.v2.conversation.list", envelope.payload);
|
|
265
|
+
const conversations = [...this.conversations.values()].filter((conversation) =>
|
|
266
|
+
payload.includeArchived ? true : !conversation.archived,
|
|
267
|
+
);
|
|
268
|
+
this.input.send(createEnvelope({
|
|
269
|
+
type: "agent.v2.conversation.list.result",
|
|
270
|
+
sessionId: this.input.sessionId,
|
|
271
|
+
payload: { conversations },
|
|
272
|
+
}));
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
case "agent.v2.snapshot.request": {
|
|
276
|
+
const payload = parseTypedPayload("agent.v2.snapshot.request", envelope.payload);
|
|
277
|
+
this.sendSnapshot(payload.conversationId);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case "agent.v2.prompt": {
|
|
281
|
+
const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
|
|
282
|
+
await this.sendPrompt(payload);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "agent.v2.cancel": {
|
|
286
|
+
const payload = parseTypedPayload("agent.v2.cancel", envelope.payload);
|
|
287
|
+
const conversation = this.conversations.get(payload.conversationId);
|
|
288
|
+
this.cancelPendingPermissions(payload.conversationId);
|
|
289
|
+
this.client?.cancel({
|
|
290
|
+
sessionId: conversation?.agentSessionId,
|
|
291
|
+
turnId: this.currentTurnId,
|
|
292
|
+
});
|
|
293
|
+
this.currentTurnId = undefined;
|
|
294
|
+
this.updateConversationStatus(payload.conversationId, "idle");
|
|
295
|
+
this.emitStatus(payload.conversationId, "idle", "已停止");
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "agent.v2.permission.respond": {
|
|
299
|
+
const payload = parseTypedPayload("agent.v2.permission.respond", envelope.payload);
|
|
300
|
+
this.respondPermission(payload);
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
stop(): void {
|
|
307
|
+
this.client?.stop();
|
|
308
|
+
this.client = undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async initialize(): Promise<void> {
|
|
312
|
+
if (this.initialized) return;
|
|
313
|
+
await this.ensureClient();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private async ensureClient(): Promise<void> {
|
|
317
|
+
if (this.client) return;
|
|
318
|
+
|
|
319
|
+
const resolved = resolveAgentCommand({
|
|
320
|
+
provider: this.input.provider,
|
|
321
|
+
command: this.input.command,
|
|
322
|
+
});
|
|
323
|
+
if (!resolved) {
|
|
324
|
+
this.status = "unavailable";
|
|
325
|
+
this.error = `Agent Workspace requires --agent-command for ${this.input.provider}`;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
this.client = new AcpClient({
|
|
331
|
+
command: resolved.command,
|
|
332
|
+
protocol: resolved.protocol,
|
|
333
|
+
framing: resolved.framing,
|
|
334
|
+
cwd: this.input.cwd,
|
|
335
|
+
onNotification: (method, params) => this.handleNotification(method, params),
|
|
336
|
+
onRequest: (method, params) => this.handleRequest(method, params),
|
|
337
|
+
onExit: (message) => this.handleExit(message),
|
|
338
|
+
});
|
|
339
|
+
await this.client.initialize();
|
|
340
|
+
this.initialized = true;
|
|
341
|
+
this.status = "idle";
|
|
342
|
+
this.error = undefined;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
this.client?.stop();
|
|
345
|
+
this.client = undefined;
|
|
346
|
+
this.status = "error";
|
|
347
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private sendCapabilities(): void {
|
|
352
|
+
const enabled = Boolean(this.client && this.initialized && !this.error);
|
|
353
|
+
this.input.send(createEnvelope({
|
|
354
|
+
type: "agent.v2.capabilities",
|
|
355
|
+
sessionId: this.input.sessionId,
|
|
356
|
+
payload: {
|
|
357
|
+
enabled,
|
|
358
|
+
provider: this.input.provider,
|
|
359
|
+
protocolVersion: 1,
|
|
360
|
+
workspaceProtocolVersion: 2,
|
|
361
|
+
error: enabled ? undefined : this.error,
|
|
362
|
+
supportsSessionList: enabled,
|
|
363
|
+
supportsSessionLoad: enabled,
|
|
364
|
+
supportsImages: false,
|
|
365
|
+
supportsAudio: false,
|
|
366
|
+
supportsPermission: enabled,
|
|
367
|
+
supportsPlan: enabled,
|
|
368
|
+
supportsCancel: enabled,
|
|
369
|
+
},
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async openConversation(payload: {
|
|
374
|
+
conversationId?: string;
|
|
375
|
+
agentSessionId?: string;
|
|
376
|
+
cwd?: string;
|
|
377
|
+
provider?: AgentProvider;
|
|
378
|
+
model?: string;
|
|
379
|
+
reasoningEffort?: string;
|
|
380
|
+
permissionMode?: AgentPermissionMode;
|
|
381
|
+
title?: string;
|
|
382
|
+
}): Promise<AgentConversation | undefined> {
|
|
383
|
+
await this.ensureClient();
|
|
384
|
+
this.sendCapabilities();
|
|
385
|
+
if (!this.client) return undefined;
|
|
386
|
+
|
|
387
|
+
const cwd = payload.cwd ?? this.input.cwd;
|
|
388
|
+
let agentSessionId = payload.agentSessionId;
|
|
389
|
+
const existingConversation =
|
|
390
|
+
(payload.conversationId ? this.conversations.get(payload.conversationId) : undefined) ??
|
|
391
|
+
(agentSessionId ? this.conversations.get(this.conversationByAgentSessionId.get(agentSessionId) ?? "") : undefined);
|
|
392
|
+
|
|
393
|
+
if (existingConversation) {
|
|
394
|
+
this.activeConversationId = existingConversation.id;
|
|
395
|
+
this.input.send(createEnvelope({
|
|
396
|
+
type: "agent.v2.conversation.opened",
|
|
397
|
+
sessionId: this.input.sessionId,
|
|
398
|
+
payload: {
|
|
399
|
+
conversation: existingConversation,
|
|
400
|
+
snapshot: this.timelines.get(existingConversation.id) ?? [],
|
|
401
|
+
},
|
|
402
|
+
}));
|
|
403
|
+
return existingConversation;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const result = agentSessionId
|
|
408
|
+
? await this.client.loadSession({ sessionId: agentSessionId, cwd })
|
|
409
|
+
: await this.client.newSession({ cwd });
|
|
410
|
+
agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
const conversationId = payload.conversationId ?? `agent:${agentSessionId}`;
|
|
413
|
+
const conversation: AgentConversation = {
|
|
414
|
+
id: conversationId,
|
|
415
|
+
agentSessionId,
|
|
416
|
+
provider: payload.provider ?? this.input.provider,
|
|
417
|
+
cwd,
|
|
418
|
+
title: payload.title ?? titleFromCwd(cwd),
|
|
419
|
+
model: payload.model,
|
|
420
|
+
reasoningEffort: payload.reasoningEffort,
|
|
421
|
+
permissionMode: payload.permissionMode,
|
|
422
|
+
status: "idle",
|
|
423
|
+
archived: false,
|
|
424
|
+
lastActivityAt: now,
|
|
425
|
+
createdAt: now,
|
|
426
|
+
};
|
|
427
|
+
this.conversations.set(conversation.id, conversation);
|
|
428
|
+
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
429
|
+
this.activeConversationId = conversation.id;
|
|
430
|
+
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
431
|
+
this.input.send(createEnvelope({
|
|
432
|
+
type: "agent.v2.conversation.opened",
|
|
433
|
+
sessionId: this.input.sessionId,
|
|
434
|
+
payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
|
|
435
|
+
}));
|
|
436
|
+
return conversation;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
439
|
+
this.status = "error";
|
|
440
|
+
this.error = message;
|
|
441
|
+
const fallbackId = payload.conversationId ?? id("agent-conversation");
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const conversation: AgentConversation = {
|
|
444
|
+
id: fallbackId,
|
|
445
|
+
provider: payload.provider ?? this.input.provider,
|
|
446
|
+
cwd,
|
|
447
|
+
title: payload.title ?? titleFromCwd(cwd),
|
|
448
|
+
model: payload.model,
|
|
449
|
+
reasoningEffort: payload.reasoningEffort,
|
|
450
|
+
permissionMode: payload.permissionMode,
|
|
451
|
+
status: "error",
|
|
452
|
+
archived: false,
|
|
453
|
+
lastMessagePreview: message,
|
|
454
|
+
lastActivityAt: now,
|
|
455
|
+
createdAt: now,
|
|
456
|
+
};
|
|
457
|
+
this.conversations.set(conversation.id, conversation);
|
|
458
|
+
this.activeConversationId = conversation.id;
|
|
459
|
+
this.addItem(conversation.id, {
|
|
460
|
+
id: id("error"),
|
|
461
|
+
conversationId: conversation.id,
|
|
462
|
+
type: "error",
|
|
463
|
+
error: message,
|
|
464
|
+
createdAt: now,
|
|
465
|
+
});
|
|
466
|
+
this.input.send(createEnvelope({
|
|
467
|
+
type: "agent.v2.conversation.opened",
|
|
468
|
+
sessionId: this.input.sessionId,
|
|
469
|
+
payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
|
|
470
|
+
}));
|
|
471
|
+
return conversation;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private async sendPrompt(payload: {
|
|
476
|
+
conversationId: string;
|
|
477
|
+
clientMessageId: string;
|
|
478
|
+
contentBlocks: AgentContentBlock[];
|
|
479
|
+
model?: string;
|
|
480
|
+
reasoningEffort?: string;
|
|
481
|
+
permissionMode?: AgentPermissionMode;
|
|
482
|
+
}): Promise<void> {
|
|
483
|
+
const conversation =
|
|
484
|
+
this.conversations.get(payload.conversationId) ??
|
|
485
|
+
await this.openConversation({ conversationId: payload.conversationId });
|
|
486
|
+
if (!conversation || !this.client || !conversation.agentSessionId) return;
|
|
487
|
+
|
|
488
|
+
conversation.model = payload.model ?? conversation.model;
|
|
489
|
+
conversation.reasoningEffort = payload.reasoningEffort ?? conversation.reasoningEffort;
|
|
490
|
+
conversation.permissionMode = payload.permissionMode ?? conversation.permissionMode;
|
|
491
|
+
conversation.status = "running";
|
|
492
|
+
conversation.lastActivityAt = Date.now();
|
|
493
|
+
this.activeConversationId = conversation.id;
|
|
494
|
+
|
|
495
|
+
const userText = textFromBlocks(payload.contentBlocks);
|
|
496
|
+
this.addItem(conversation.id, {
|
|
497
|
+
id: payload.clientMessageId,
|
|
498
|
+
conversationId: conversation.id,
|
|
499
|
+
type: "message",
|
|
500
|
+
role: "user",
|
|
501
|
+
content: payload.contentBlocks,
|
|
502
|
+
text: userText,
|
|
503
|
+
createdAt: Date.now(),
|
|
504
|
+
});
|
|
505
|
+
this.emitConversation(conversation);
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
const result = await this.client.prompt({
|
|
509
|
+
sessionId: conversation.agentSessionId,
|
|
510
|
+
content: payload.contentBlocks,
|
|
511
|
+
clientMessageId: payload.clientMessageId,
|
|
512
|
+
model: payload.model,
|
|
513
|
+
reasoningEffort: payload.reasoningEffort,
|
|
514
|
+
permissionMode: payload.permissionMode,
|
|
515
|
+
cwd: conversation.cwd,
|
|
516
|
+
});
|
|
517
|
+
this.currentTurnId = this.extractTurnId(result) ?? this.currentTurnId;
|
|
518
|
+
if (conversation.status === "running") {
|
|
519
|
+
this.updateConversationStatus(conversation.id, "idle");
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
523
|
+
this.updateConversationStatus(conversation.id, "error", message);
|
|
524
|
+
this.addItem(conversation.id, {
|
|
525
|
+
id: id("error"),
|
|
526
|
+
conversationId: conversation.id,
|
|
527
|
+
type: "error",
|
|
528
|
+
error: message,
|
|
529
|
+
createdAt: Date.now(),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private handleRequest(method: string, params: unknown): Promise<unknown> | unknown {
|
|
535
|
+
if (
|
|
536
|
+
method === "session/request_permission" ||
|
|
537
|
+
method.endsWith("/requestApproval") ||
|
|
538
|
+
method === "mcpServer/elicitation/request" ||
|
|
539
|
+
method === "item/tool/requestUserInput"
|
|
540
|
+
) {
|
|
541
|
+
return this.handlePermission(params, true, method);
|
|
542
|
+
}
|
|
543
|
+
if (this.input.verbose) {
|
|
544
|
+
process.stderr.write(`[agent:v2:request] unsupported ${method}\n`);
|
|
545
|
+
}
|
|
546
|
+
return {};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private handleNotification(method: string, params: unknown): void {
|
|
550
|
+
if (this.input.verbose) {
|
|
551
|
+
process.stderr.write(`[agent:v2] ${method} ${stringify(params).slice(0, 500)}\n`);
|
|
552
|
+
}
|
|
553
|
+
if (
|
|
554
|
+
method === "initialized" ||
|
|
555
|
+
method.startsWith("account/") ||
|
|
556
|
+
method.startsWith("mcpServer/startupStatus/") ||
|
|
557
|
+
method === "thread/status/changed" ||
|
|
558
|
+
method === "thread/tokenUsage/updated" ||
|
|
559
|
+
method === "turn/diff/updated" ||
|
|
560
|
+
method === "serverRequest/resolved" ||
|
|
561
|
+
method === "mcpServer/oauthLogin/completed"
|
|
562
|
+
) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const conversationId = this.conversationIdFromParams(params) ?? this.activeConversationId;
|
|
567
|
+
if (method === "thread/started") {
|
|
568
|
+
const agentSessionId = this.extractSessionId(params);
|
|
569
|
+
if (agentSessionId && conversationId) {
|
|
570
|
+
this.conversationByAgentSessionId.set(agentSessionId, conversationId);
|
|
571
|
+
const conversation = this.conversations.get(conversationId);
|
|
572
|
+
if (conversation) conversation.agentSessionId = agentSessionId;
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (method === "turn/started") {
|
|
577
|
+
this.currentTurnId = this.extractTurnId(params) ?? this.currentTurnId;
|
|
578
|
+
if (conversationId) this.updateConversationStatus(conversationId, "running");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (method === "turn/completed") {
|
|
582
|
+
this.currentTurnId = undefined;
|
|
583
|
+
if (conversationId) this.updateConversationStatus(conversationId, "idle");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (method === "session/request_permission") {
|
|
587
|
+
this.handlePermission(params, false, method);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
switch (method) {
|
|
592
|
+
case "item/agentMessage/delta":
|
|
593
|
+
this.handleAgentMessageDelta(params);
|
|
594
|
+
return;
|
|
595
|
+
case "turn/plan/updated":
|
|
596
|
+
this.handlePlanUpdated(params);
|
|
597
|
+
return;
|
|
598
|
+
case "item/plan/delta":
|
|
599
|
+
this.handlePlanDelta(params);
|
|
600
|
+
return;
|
|
601
|
+
case "item/started":
|
|
602
|
+
this.handleItemStarted(params);
|
|
603
|
+
return;
|
|
604
|
+
case "item/completed":
|
|
605
|
+
this.handleItemCompleted(params);
|
|
606
|
+
return;
|
|
607
|
+
case "item/commandExecution/outputDelta":
|
|
608
|
+
case "item/fileChange/outputDelta":
|
|
609
|
+
case "item/mcpToolCall/progress":
|
|
610
|
+
this.handleToolDelta(method, params);
|
|
611
|
+
return;
|
|
612
|
+
case "item/fileChange/patchUpdated":
|
|
613
|
+
this.handleFilePatchUpdated(params);
|
|
614
|
+
return;
|
|
615
|
+
case "command/exec/outputDelta":
|
|
616
|
+
this.handleCommandExecDelta(params);
|
|
617
|
+
return;
|
|
618
|
+
case "item/autoApprovalReview/started":
|
|
619
|
+
case "item/autoApprovalReview/completed":
|
|
620
|
+
case "item/commandExecution/terminalInteraction":
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (method === "session/update") {
|
|
625
|
+
this.handleSessionUpdate(params);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private handleAgentMessageDelta(params: unknown): void {
|
|
631
|
+
const raw = asRecord(params);
|
|
632
|
+
if (!raw) return;
|
|
633
|
+
const conversationId = this.conversationIdFromParams(raw) ?? this.activeConversationId;
|
|
634
|
+
if (!conversationId) return;
|
|
635
|
+
const itemId = firstString(raw, ["itemId", "id", "messageId"]) ?? id("msg");
|
|
636
|
+
const delta = firstString(raw, ["delta", "text", "content"]);
|
|
637
|
+
if (!delta) return;
|
|
638
|
+
const existing = this.findItem(conversationId, itemId);
|
|
639
|
+
const text = `${existing?.text ?? ""}${delta}`;
|
|
640
|
+
const item: AgentTimelineItem = {
|
|
641
|
+
id: itemId,
|
|
642
|
+
conversationId,
|
|
643
|
+
type: "message",
|
|
644
|
+
role: "assistant",
|
|
645
|
+
content: [{ type: "text", text }],
|
|
646
|
+
text,
|
|
647
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
648
|
+
updatedAt: Date.now(),
|
|
649
|
+
isStreaming: true,
|
|
650
|
+
};
|
|
651
|
+
this.upsertItem(conversationId, item);
|
|
652
|
+
this.updateConversationPreview(conversationId, text, "running");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private handlePlanUpdated(params: unknown): void {
|
|
656
|
+
const raw = asRecord(params);
|
|
657
|
+
const conversationId = this.conversationIdFromParams(raw) ?? this.activeConversationId;
|
|
658
|
+
if (!conversationId) return;
|
|
659
|
+
const plan = Array.isArray(raw?.plan) ? raw.plan : [];
|
|
660
|
+
const steps = plan
|
|
661
|
+
.map((entry, index) => {
|
|
662
|
+
const step = asRecord(entry);
|
|
663
|
+
const text = firstString(step, ["text", "title", "description", "message"]);
|
|
664
|
+
if (!text) return undefined;
|
|
665
|
+
return {
|
|
666
|
+
id: firstString(step, ["id"]) ?? `plan-${index + 1}`,
|
|
667
|
+
text,
|
|
668
|
+
status: normalizePlanStatus(step?.status),
|
|
669
|
+
} satisfies AgentPlanStep;
|
|
670
|
+
})
|
|
671
|
+
.filter((step): step is AgentPlanStep => Boolean(step));
|
|
672
|
+
if (steps.length === 0) return;
|
|
673
|
+
this.upsertItem(conversationId, {
|
|
674
|
+
id: "plan",
|
|
675
|
+
conversationId,
|
|
676
|
+
type: "plan",
|
|
677
|
+
plan: steps,
|
|
678
|
+
createdAt: this.findItem(conversationId, "plan")?.createdAt ?? Date.now(),
|
|
679
|
+
updatedAt: Date.now(),
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private handlePlanDelta(params: unknown): void {
|
|
684
|
+
const raw = asRecord(params);
|
|
685
|
+
if (!raw) return;
|
|
686
|
+
const conversationId = this.conversationIdFromParams(raw) ?? this.activeConversationId;
|
|
687
|
+
if (!conversationId) return;
|
|
688
|
+
const itemId = firstString(raw, ["itemId", "id"]) ?? "plan";
|
|
689
|
+
const delta = firstString(raw, ["delta", "text"]);
|
|
690
|
+
if (!delta) return;
|
|
691
|
+
const existing = this.findItem(conversationId, itemId);
|
|
692
|
+
const text = `${existing?.text ?? ""}${delta}`;
|
|
693
|
+
const step: AgentPlanStep = { id: itemId, text, status: "in_progress" };
|
|
694
|
+
this.upsertItem(conversationId, {
|
|
695
|
+
id: itemId,
|
|
696
|
+
conversationId,
|
|
697
|
+
type: "plan",
|
|
698
|
+
text,
|
|
699
|
+
plan: [step],
|
|
700
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
701
|
+
updatedAt: Date.now(),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private handleItemStarted(params: unknown): void {
|
|
706
|
+
const item = extractItem(params);
|
|
707
|
+
if (!item) return;
|
|
708
|
+
const itemType = firstString(item, ["type"]);
|
|
709
|
+
if (itemType === "agentMessage" || itemType === "assistantMessage") {
|
|
710
|
+
this.handleCompletedMessageItem(item, true);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (itemType === "plan") {
|
|
714
|
+
this.handlePlanUpdated({ plan: [item] });
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const conversationId = this.conversationIdFromParams(item) ?? this.activeConversationId;
|
|
718
|
+
const toolCall = this.toolCallFromItem(item, "running");
|
|
719
|
+
if (!conversationId || !toolCall) return;
|
|
720
|
+
this.toolConversationIds.set(toolCall.id, conversationId);
|
|
721
|
+
this.upsertTool(conversationId, toolCall);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private handleItemCompleted(params: unknown): void {
|
|
725
|
+
const item = extractItem(params);
|
|
726
|
+
if (!item) return;
|
|
727
|
+
const itemType = firstString(item, ["type"]);
|
|
728
|
+
if (itemType === "agentMessage" || itemType === "assistantMessage") {
|
|
729
|
+
this.handleCompletedMessageItem(item, false);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const conversationId = this.conversationIdFromParams(item) ?? this.activeConversationId;
|
|
733
|
+
const toolCall = this.toolCallFromItem(item, normalizeToolStatus(item.status, true));
|
|
734
|
+
if (!conversationId || !toolCall) return;
|
|
735
|
+
const bufferedOutput = this.toolOutputBuffers.get(toolCall.id);
|
|
736
|
+
if (bufferedOutput && !toolCall.output) toolCall.output = bufferedOutput;
|
|
737
|
+
this.upsertTool(conversationId, toolCall);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private handleToolDelta(method: string, params: unknown): void {
|
|
741
|
+
const raw = asRecord(params);
|
|
742
|
+
if (!raw) return;
|
|
743
|
+
const itemId = firstString(raw, ["itemId", "id", "toolCallId"]) ?? id("tool");
|
|
744
|
+
const delta = firstString(raw, ["delta", "message", "text"]);
|
|
745
|
+
if (!delta) return;
|
|
746
|
+
const conversationId =
|
|
747
|
+
this.conversationIdFromParams(raw) ??
|
|
748
|
+
this.toolConversationIds.get(itemId) ??
|
|
749
|
+
this.activeConversationId;
|
|
750
|
+
if (!conversationId) return;
|
|
751
|
+
const output = appendCapped(this.toolOutputBuffers.get(itemId), delta, 6000);
|
|
752
|
+
this.toolOutputBuffers.set(itemId, output);
|
|
753
|
+
const existing = this.findTool(conversationId, itemId);
|
|
754
|
+
this.upsertTool(conversationId, {
|
|
755
|
+
id: itemId,
|
|
756
|
+
name: existing?.name ?? nameFromToolMethod(method),
|
|
757
|
+
input: existing?.input,
|
|
758
|
+
output,
|
|
759
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
760
|
+
status: "running",
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private handleFilePatchUpdated(params: unknown): void {
|
|
765
|
+
const raw = asRecord(params);
|
|
766
|
+
if (!raw) return;
|
|
767
|
+
const itemId = firstString(raw, ["itemId", "id"]) ?? id("file");
|
|
768
|
+
const conversationId =
|
|
769
|
+
this.conversationIdFromParams(raw) ??
|
|
770
|
+
this.toolConversationIds.get(itemId) ??
|
|
771
|
+
this.activeConversationId;
|
|
772
|
+
if (!conversationId) return;
|
|
773
|
+
const output = summarizeFileChanges(Array.isArray(raw.changes) ? raw.changes : []);
|
|
774
|
+
const existing = this.findTool(conversationId, itemId);
|
|
775
|
+
this.upsertTool(conversationId, {
|
|
776
|
+
id: itemId,
|
|
777
|
+
name: existing?.name ?? "文件修改",
|
|
778
|
+
input: existing?.input,
|
|
779
|
+
output: output || existing?.output,
|
|
780
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
781
|
+
status: existing?.status ?? "running",
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private handleCommandExecDelta(params: unknown): void {
|
|
786
|
+
const raw = asRecord(params);
|
|
787
|
+
if (!raw) return;
|
|
788
|
+
const processId = firstString(raw, ["processId", "id"]) ?? id("exec");
|
|
789
|
+
const delta =
|
|
790
|
+
firstString(raw, ["delta", "text"]) ??
|
|
791
|
+
decodeBase64(firstString(raw, ["deltaBase64"]));
|
|
792
|
+
if (!delta) return;
|
|
793
|
+
const conversationId =
|
|
794
|
+
this.conversationIdFromParams(raw) ??
|
|
795
|
+
this.toolConversationIds.get(processId) ??
|
|
796
|
+
this.activeConversationId;
|
|
797
|
+
if (!conversationId) return;
|
|
798
|
+
const output = appendCapped(this.toolOutputBuffers.get(processId), delta, 6000);
|
|
799
|
+
this.toolOutputBuffers.set(processId, output);
|
|
800
|
+
const existing = this.findTool(conversationId, processId);
|
|
801
|
+
this.upsertTool(conversationId, {
|
|
802
|
+
id: processId,
|
|
803
|
+
name: existing?.name ?? "命令输出",
|
|
804
|
+
input: existing?.input,
|
|
805
|
+
output,
|
|
806
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
807
|
+
status: "running",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private handleCompletedMessageItem(item: Record<string, unknown>, streaming: boolean): void {
|
|
812
|
+
const conversationId = this.conversationIdFromParams(item) ?? this.activeConversationId;
|
|
813
|
+
if (!conversationId) return;
|
|
814
|
+
const itemId = firstString(item, ["id"]) ?? id("msg");
|
|
815
|
+
const existing = this.findItem(conversationId, itemId);
|
|
816
|
+
const content = firstString(item, ["text", "content", "message"]) ?? existing?.text;
|
|
817
|
+
if (!content) return;
|
|
818
|
+
this.upsertItem(conversationId, {
|
|
819
|
+
id: itemId,
|
|
820
|
+
conversationId,
|
|
821
|
+
type: "message",
|
|
822
|
+
role: "assistant",
|
|
823
|
+
content: [{ type: "text", text: content }],
|
|
824
|
+
text: content,
|
|
825
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
826
|
+
updatedAt: Date.now(),
|
|
827
|
+
isStreaming: streaming,
|
|
828
|
+
});
|
|
829
|
+
this.updateConversationPreview(conversationId, content, streaming ? "running" : "idle");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private handleSessionUpdate(params: unknown): void {
|
|
833
|
+
const raw = asRecord(params) ?? {};
|
|
834
|
+
const nested = asRecord(raw.params) ?? {};
|
|
835
|
+
const text =
|
|
836
|
+
firstString(raw, ["delta", "text", "content", "message"]) ??
|
|
837
|
+
firstString(nested, ["delta", "text", "content", "message"]);
|
|
838
|
+
if (!text) return;
|
|
839
|
+
const conversationId = this.conversationIdFromParams(raw) ?? this.activeConversationId;
|
|
840
|
+
if (!conversationId) return;
|
|
841
|
+
if (firstString(raw, ["toolName", "tool", "name"])) {
|
|
842
|
+
this.upsertTool(conversationId, {
|
|
843
|
+
id: firstString(raw, ["toolCallId", "callId", "id"]) ?? id("tool"),
|
|
844
|
+
name: firstString(raw, ["toolName", "tool", "name"]) ?? "tool",
|
|
845
|
+
input: stringify(raw.input ?? raw.toolInput ?? ""),
|
|
846
|
+
output: stringify(raw.output ?? raw.result ?? ""),
|
|
847
|
+
createdAt: Date.now(),
|
|
848
|
+
status: raw.status === "completed" || raw.status === "failed" || raw.status === "running"
|
|
849
|
+
? raw.status
|
|
850
|
+
: "running",
|
|
851
|
+
});
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const role = raw.role === "user" || raw.role === "system" ? raw.role : "assistant";
|
|
855
|
+
this.upsertItem(conversationId, {
|
|
856
|
+
id: firstString(raw, ["messageId", "id"]) ?? id("msg"),
|
|
857
|
+
conversationId,
|
|
858
|
+
type: "message",
|
|
859
|
+
role,
|
|
860
|
+
content: [{ type: "text", text }],
|
|
861
|
+
text,
|
|
862
|
+
createdAt: Date.now(),
|
|
863
|
+
updatedAt: Date.now(),
|
|
864
|
+
isStreaming: raw.done === false || raw.isStreaming === true,
|
|
865
|
+
});
|
|
866
|
+
this.updateConversationPreview(conversationId, text, raw.done === true ? "idle" : "running");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private toolCallFromItem(
|
|
870
|
+
item: Record<string, unknown>,
|
|
871
|
+
fallbackStatus: AgentToolCall["status"],
|
|
872
|
+
): AgentToolCall | undefined {
|
|
873
|
+
const itemId = firstString(item, ["id", "itemId", "toolCallId"]);
|
|
874
|
+
if (!itemId) return undefined;
|
|
875
|
+
const itemType = firstString(item, ["type"]);
|
|
876
|
+
const output =
|
|
877
|
+
firstString(item, ["aggregatedOutput", "output", "stdout", "stderr"]) ??
|
|
878
|
+
stringifyDefined(item.result ?? item.error ?? item.contentItems);
|
|
879
|
+
return {
|
|
880
|
+
id: itemId,
|
|
881
|
+
name: toolNameFromItem(item) ?? itemType ?? "tool",
|
|
882
|
+
input: toolInputFromItem(item),
|
|
883
|
+
output: output ?? this.toolOutputBuffers.get(itemId),
|
|
884
|
+
createdAt: Date.now(),
|
|
885
|
+
status: normalizeToolStatus(item.status, fallbackStatus === "completed"),
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private handlePermission(
|
|
890
|
+
params: unknown,
|
|
891
|
+
waitForResponse: boolean,
|
|
892
|
+
source?: string,
|
|
893
|
+
): Promise<unknown> | void {
|
|
894
|
+
const raw = asRecord(params) ?? {};
|
|
895
|
+
const conversationId = this.conversationIdFromParams(raw) ?? this.activeConversationId;
|
|
896
|
+
if (!conversationId) return waitForResponse ? Promise.resolve({ outcome: { outcome: "cancelled" } }) : undefined;
|
|
897
|
+
const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
|
|
898
|
+
const rawToolCall = asRecord(raw.toolCall) ?? raw;
|
|
899
|
+
const permission: AgentPermission = {
|
|
900
|
+
requestId,
|
|
901
|
+
toolName: firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]),
|
|
902
|
+
toolInput: stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
|
|
903
|
+
context: firstString(raw, ["context", "description", "message", "title"]),
|
|
904
|
+
options: parsePermissionOptions(raw.options),
|
|
905
|
+
};
|
|
906
|
+
this.pendingPermissions.set(requestId, permission);
|
|
907
|
+
if (source) this.permissionSources.set(requestId, source);
|
|
908
|
+
this.updateConversationStatus(conversationId, "waiting_permission");
|
|
909
|
+
const item: AgentTimelineItem = {
|
|
910
|
+
id: `permission:${requestId}`,
|
|
911
|
+
conversationId,
|
|
912
|
+
type: "permission",
|
|
913
|
+
permission,
|
|
914
|
+
createdAt: Date.now(),
|
|
915
|
+
updatedAt: Date.now(),
|
|
916
|
+
};
|
|
917
|
+
this.upsertItem(conversationId, item);
|
|
918
|
+
this.input.send(createEnvelope({
|
|
919
|
+
type: "agent.v2.permission.request",
|
|
920
|
+
sessionId: this.input.sessionId,
|
|
921
|
+
payload: { conversationId, ...permission, item },
|
|
922
|
+
}));
|
|
923
|
+
|
|
924
|
+
if (!waitForResponse) return;
|
|
925
|
+
return new Promise((resolve) => {
|
|
926
|
+
const timer = setTimeout(() => {
|
|
927
|
+
this.pendingPermissions.delete(requestId);
|
|
928
|
+
this.permissionWaiters.delete(requestId);
|
|
929
|
+
this.permissionSources.delete(requestId);
|
|
930
|
+
resolve(formatPermissionResponse(source, "cancelled", "cancelled"));
|
|
931
|
+
this.updateConversationStatus(conversationId, "idle");
|
|
932
|
+
}, PERMISSION_TIMEOUT_MS);
|
|
933
|
+
this.permissionWaiters.set(requestId, { resolve, timer });
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private respondPermission(payload: {
|
|
938
|
+
conversationId: string;
|
|
939
|
+
requestId: string;
|
|
940
|
+
outcome: "allow" | "deny" | "cancelled";
|
|
941
|
+
optionId?: string;
|
|
942
|
+
}): void {
|
|
943
|
+
const permission = this.pendingPermissions.get(payload.requestId);
|
|
944
|
+
this.pendingPermissions.delete(payload.requestId);
|
|
945
|
+
const selectedOptionId =
|
|
946
|
+
payload.optionId ?? selectPermissionOption(permission, payload.outcome);
|
|
947
|
+
const waiter = this.permissionWaiters.get(payload.requestId);
|
|
948
|
+
if (waiter) {
|
|
949
|
+
clearTimeout(waiter.timer);
|
|
950
|
+
this.permissionWaiters.delete(payload.requestId);
|
|
951
|
+
waiter.resolve(formatPermissionResponse(
|
|
952
|
+
this.permissionSources.get(payload.requestId),
|
|
953
|
+
payload.outcome,
|
|
954
|
+
selectedOptionId,
|
|
955
|
+
));
|
|
956
|
+
this.permissionSources.delete(payload.requestId);
|
|
957
|
+
} else {
|
|
958
|
+
this.client?.respondPermission({
|
|
959
|
+
sessionId: this.conversations.get(payload.conversationId)?.agentSessionId,
|
|
960
|
+
requestId: payload.requestId,
|
|
961
|
+
outcome: payload.outcome === "cancelled" ? "deny" : payload.outcome,
|
|
962
|
+
optionId: selectedOptionId,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
this.updateConversationStatus(payload.conversationId, "running");
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private addItem(conversationId: string, item: AgentTimelineItem): void {
|
|
969
|
+
const timeline = this.timelines.get(conversationId) ?? [];
|
|
970
|
+
timeline.push(item);
|
|
971
|
+
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
|
972
|
+
if (timeline.length > MAX_TIMELINE_ITEMS) {
|
|
973
|
+
timeline.splice(0, timeline.length - MAX_TIMELINE_ITEMS);
|
|
974
|
+
}
|
|
975
|
+
this.timelines.set(conversationId, timeline);
|
|
976
|
+
this.emitItem(conversationId, item);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private upsertItem(conversationId: string, item: AgentTimelineItem): void {
|
|
980
|
+
const timeline = this.timelines.get(conversationId) ?? [];
|
|
981
|
+
const index = timeline.findIndex((entry) => entry.id === item.id);
|
|
982
|
+
if (index >= 0) timeline[index] = item;
|
|
983
|
+
else timeline.push(item);
|
|
984
|
+
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
|
985
|
+
if (timeline.length > MAX_TIMELINE_ITEMS) {
|
|
986
|
+
timeline.splice(0, timeline.length - MAX_TIMELINE_ITEMS);
|
|
987
|
+
}
|
|
988
|
+
this.timelines.set(conversationId, timeline);
|
|
989
|
+
this.emitItem(conversationId, item);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private upsertTool(conversationId: string, toolCall: AgentToolCall): void {
|
|
993
|
+
const existing = this.findTool(conversationId, toolCall.id);
|
|
994
|
+
const nextToolCall = {
|
|
995
|
+
...toolCall,
|
|
996
|
+
createdAt: existing?.createdAt ?? toolCall.createdAt ?? Date.now(),
|
|
997
|
+
};
|
|
998
|
+
this.toolConversationIds.set(nextToolCall.id, conversationId);
|
|
999
|
+
this.upsertItem(conversationId, {
|
|
1000
|
+
id: `tool:${nextToolCall.id}`,
|
|
1001
|
+
conversationId,
|
|
1002
|
+
type: "tool_call",
|
|
1003
|
+
toolCall: nextToolCall,
|
|
1004
|
+
createdAt: nextToolCall.createdAt ?? Date.now(),
|
|
1005
|
+
updatedAt: Date.now(),
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private findItem(conversationId: string, itemId: string): AgentTimelineItem | undefined {
|
|
1010
|
+
return this.timelines.get(conversationId)?.find((item) => item.id === itemId);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private findTool(conversationId: string, toolId: string): AgentToolCall | undefined {
|
|
1014
|
+
const item = this.timelines.get(conversationId)?.find((entry) =>
|
|
1015
|
+
entry.type === "tool_call" && entry.toolCall?.id === toolId,
|
|
1016
|
+
);
|
|
1017
|
+
return item?.toolCall;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private emitItem(conversationId: string, item: AgentTimelineItem): void {
|
|
1021
|
+
const conversation = this.conversations.get(conversationId);
|
|
1022
|
+
this.input.send(createEnvelope({
|
|
1023
|
+
type: "agent.v2.event",
|
|
1024
|
+
sessionId: this.input.sessionId,
|
|
1025
|
+
payload: { conversationId, conversation, item },
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private emitConversation(conversation: AgentConversation): void {
|
|
1030
|
+
this.input.send(createEnvelope({
|
|
1031
|
+
type: "agent.v2.event",
|
|
1032
|
+
sessionId: this.input.sessionId,
|
|
1033
|
+
payload: { conversationId: conversation.id, conversation },
|
|
1034
|
+
}));
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
private emitStatus(conversationId: string, status: AgentStatus, text?: string): void {
|
|
1038
|
+
this.addItem(conversationId, {
|
|
1039
|
+
id: id("status"),
|
|
1040
|
+
conversationId,
|
|
1041
|
+
type: "status",
|
|
1042
|
+
status,
|
|
1043
|
+
text,
|
|
1044
|
+
createdAt: Date.now(),
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private updateConversationPreview(
|
|
1049
|
+
conversationId: string,
|
|
1050
|
+
text: string,
|
|
1051
|
+
status?: AgentStatus,
|
|
1052
|
+
): void {
|
|
1053
|
+
const conversation = this.conversations.get(conversationId);
|
|
1054
|
+
if (!conversation) return;
|
|
1055
|
+
conversation.lastMessagePreview = previewText(text);
|
|
1056
|
+
conversation.lastActivityAt = Date.now();
|
|
1057
|
+
if (status) conversation.status = status;
|
|
1058
|
+
this.emitConversation(conversation);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private updateConversationStatus(
|
|
1062
|
+
conversationId: string,
|
|
1063
|
+
status: AgentStatus,
|
|
1064
|
+
error?: string,
|
|
1065
|
+
): void {
|
|
1066
|
+
const conversation = this.conversations.get(conversationId);
|
|
1067
|
+
if (!conversation) return;
|
|
1068
|
+
conversation.status = status;
|
|
1069
|
+
if (error) conversation.lastMessagePreview = error;
|
|
1070
|
+
conversation.lastActivityAt = Date.now();
|
|
1071
|
+
this.emitConversation(conversation);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
private sendSnapshot(conversationId?: string): void {
|
|
1075
|
+
const conversations = [...this.conversations.values()];
|
|
1076
|
+
const items = conversationId
|
|
1077
|
+
? this.timelines.get(conversationId) ?? []
|
|
1078
|
+
: [...this.timelines.values()].flat();
|
|
1079
|
+
this.input.send(createEnvelope({
|
|
1080
|
+
type: "agent.v2.snapshot",
|
|
1081
|
+
sessionId: this.input.sessionId,
|
|
1082
|
+
payload: {
|
|
1083
|
+
conversations,
|
|
1084
|
+
activeConversationId: this.activeConversationId,
|
|
1085
|
+
items,
|
|
1086
|
+
},
|
|
1087
|
+
}));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private conversationIdFromParams(params: unknown): string | undefined {
|
|
1091
|
+
const raw = asRecord(params);
|
|
1092
|
+
const agentSessionId = this.extractSessionId(raw);
|
|
1093
|
+
if (agentSessionId) return this.conversationByAgentSessionId.get(agentSessionId);
|
|
1094
|
+
const threadId = firstString(raw, ["threadId", "sessionId", "agentSessionId"]);
|
|
1095
|
+
if (threadId) return this.conversationByAgentSessionId.get(threadId);
|
|
1096
|
+
return undefined;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private handleExit(message: string): void {
|
|
1100
|
+
this.cancelPendingPermissions();
|
|
1101
|
+
this.status = "error";
|
|
1102
|
+
this.error = message;
|
|
1103
|
+
this.client = undefined;
|
|
1104
|
+
for (const conversation of this.conversations.values()) {
|
|
1105
|
+
conversation.status = "error";
|
|
1106
|
+
conversation.lastMessagePreview = message;
|
|
1107
|
+
conversation.lastActivityAt = Date.now();
|
|
1108
|
+
this.emitConversation(conversation);
|
|
1109
|
+
this.addItem(conversation.id, {
|
|
1110
|
+
id: id("error"),
|
|
1111
|
+
conversationId: conversation.id,
|
|
1112
|
+
type: "error",
|
|
1113
|
+
error: message,
|
|
1114
|
+
createdAt: Date.now(),
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
private cancelPendingPermissions(conversationId?: string): void {
|
|
1120
|
+
for (const [requestId, waiter] of this.permissionWaiters) {
|
|
1121
|
+
clearTimeout(waiter.timer);
|
|
1122
|
+
waiter.resolve(formatPermissionResponse(
|
|
1123
|
+
this.permissionSources.get(requestId),
|
|
1124
|
+
"cancelled",
|
|
1125
|
+
"cancelled",
|
|
1126
|
+
));
|
|
1127
|
+
this.pendingPermissions.delete(requestId);
|
|
1128
|
+
this.permissionSources.delete(requestId);
|
|
1129
|
+
}
|
|
1130
|
+
this.permissionWaiters.clear();
|
|
1131
|
+
if (conversationId) this.updateConversationStatus(conversationId, "idle");
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private extractSessionId(value: unknown): string | undefined {
|
|
1135
|
+
const raw = asRecord(value);
|
|
1136
|
+
if (!raw) return undefined;
|
|
1137
|
+
const thread = asRecord(raw.thread);
|
|
1138
|
+
if (thread) {
|
|
1139
|
+
const threadId = firstString(thread, ["id", "threadId"]);
|
|
1140
|
+
if (threadId) return threadId;
|
|
1141
|
+
}
|
|
1142
|
+
return firstString(raw, ["sessionId", "id", "agentSessionId", "threadId"]);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private extractTurnId(value: unknown): string | undefined {
|
|
1146
|
+
const raw = asRecord(value);
|
|
1147
|
+
if (!raw) return undefined;
|
|
1148
|
+
const turn = asRecord(raw.turn);
|
|
1149
|
+
if (turn) {
|
|
1150
|
+
const turnId = firstString(turn, ["id", "turnId"]);
|
|
1151
|
+
if (turnId) return turnId;
|
|
1152
|
+
}
|
|
1153
|
+
return firstString(raw, ["turnId", "id"]);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function parsePermissionOptions(value: unknown): AgentPermission["options"] {
|
|
1158
|
+
if (!Array.isArray(value)) {
|
|
1159
|
+
return [
|
|
1160
|
+
{ id: "allow", label: "允许", kind: "allow" },
|
|
1161
|
+
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
1162
|
+
];
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const options = value
|
|
1166
|
+
.map((entry, index) => {
|
|
1167
|
+
const raw = asRecord(entry) ?? {};
|
|
1168
|
+
const idValue = raw.optionId ?? raw.id ?? raw.kind ?? `option-${index + 1}`;
|
|
1169
|
+
const labelValue = raw.name ?? raw.label ?? raw.kind ?? String(idValue);
|
|
1170
|
+
const id = String(idValue);
|
|
1171
|
+
const label = String(labelValue);
|
|
1172
|
+
const normalized = `${id} ${label}`.toLowerCase();
|
|
1173
|
+
const kind: AgentPermission["options"][number]["kind"] = normalized.includes("reject") || normalized.includes("deny")
|
|
1174
|
+
? "deny"
|
|
1175
|
+
: normalized.includes("allow")
|
|
1176
|
+
? "allow"
|
|
1177
|
+
: "other";
|
|
1178
|
+
return { id, label, kind };
|
|
1179
|
+
})
|
|
1180
|
+
.filter((option) => option.id.length > 0 && option.label.length > 0);
|
|
1181
|
+
|
|
1182
|
+
return options.length > 0
|
|
1183
|
+
? options
|
|
1184
|
+
: [
|
|
1185
|
+
{ id: "allow", label: "允许", kind: "allow" },
|
|
1186
|
+
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
1187
|
+
];
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function selectPermissionOption(
|
|
1191
|
+
permission: AgentPermission | undefined,
|
|
1192
|
+
outcome: "allow" | "deny" | "cancelled",
|
|
1193
|
+
): string {
|
|
1194
|
+
if (outcome === "cancelled") return "cancelled";
|
|
1195
|
+
const option = permission?.options.find((item) => item.kind === outcome);
|
|
1196
|
+
return option?.id ?? outcome;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function formatPermissionResponse(
|
|
1200
|
+
source: string | undefined,
|
|
1201
|
+
outcome: "allow" | "deny" | "cancelled",
|
|
1202
|
+
optionId: string,
|
|
1203
|
+
): unknown {
|
|
1204
|
+
if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
|
|
1205
|
+
return { decision: outcome === "allow" ? "accept" : outcome === "deny" ? "decline" : "cancel" };
|
|
1206
|
+
}
|
|
1207
|
+
if (source === "item/permissions/requestApproval") {
|
|
1208
|
+
if (outcome === "allow") {
|
|
1209
|
+
return {
|
|
1210
|
+
permissions: { type: "managed", network: { enabled: true }, fileSystem: { type: "fullAccess" } },
|
|
1211
|
+
scope: optionId.includes("session") ? "session" : "turn",
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
return { permissions: { type: "managed", network: { enabled: false }, fileSystem: { type: "readOnly" } } };
|
|
1215
|
+
}
|
|
1216
|
+
return {
|
|
1217
|
+
outcome:
|
|
1218
|
+
outcome === "cancelled"
|
|
1219
|
+
? { outcome: "cancelled" }
|
|
1220
|
+
: { outcome: "selected", optionId },
|
|
1221
|
+
};
|
|
1222
|
+
}
|