linkshell-cli 0.3.12 → 0.3.14
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/runtime/acp/acp-client.d.ts +4 -0
- package/dist/cli/src/runtime/acp/acp-client.js +11 -0
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +17 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +674 -50
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sessions.d.ts +3 -1
- package/dist/cli/src/runtime/acp/claude-sessions.js +16 -8
- package/dist/cli/src/runtime/acp/claude-sessions.js.map +1 -1
- package/dist/cli/src/runtime/acp/codex-sessions.d.ts +5 -1
- package/dist/cli/src/runtime/acp/codex-sessions.js +2 -2
- package/dist/cli/src/runtime/acp/codex-sessions.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.js +4 -3
- 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 +6361 -2337
- package/dist/shared-protocol/src/index.js +69 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/runtime/acp/acp-client.ts +12 -0
- package/src/runtime/acp/agent-workspace.ts +741 -52
- package/src/runtime/acp/claude-sessions.ts +15 -6
- package/src/runtime/acp/codex-sessions.ts +4 -1
- package/src/runtime/bridge-session.ts +4 -5
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { basename, join, relative } from "node:path";
|
|
4
5
|
import {
|
|
@@ -19,6 +20,7 @@ type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
|
|
|
19
20
|
type AgentCollaborationMode = "default" | "plan";
|
|
20
21
|
type AgentCommandExecutionKind = "prompt" | "native" | "local_ui";
|
|
21
22
|
type AgentCommandSource = "built_in" | "custom" | "project" | "user" | "linkshell";
|
|
23
|
+
type AgentSyncSource = "device" | "device-history" | "device-live" | "app-server" | "cache";
|
|
22
24
|
|
|
23
25
|
interface AgentContentBlock {
|
|
24
26
|
type: "text" | "image";
|
|
@@ -164,6 +166,11 @@ interface AgentConversation {
|
|
|
164
166
|
collaborationMode?: AgentCollaborationMode;
|
|
165
167
|
status: AgentStatus;
|
|
166
168
|
archived: boolean;
|
|
169
|
+
timelineRevision?: number;
|
|
170
|
+
historyComplete?: boolean;
|
|
171
|
+
runningTurnId?: string;
|
|
172
|
+
source?: AgentSyncSource;
|
|
173
|
+
canonical?: boolean;
|
|
167
174
|
lastMessagePreview?: string;
|
|
168
175
|
lastActivityAt: number;
|
|
169
176
|
createdAt: number;
|
|
@@ -174,6 +181,9 @@ interface AgentTimelineItem {
|
|
|
174
181
|
conversationId: string;
|
|
175
182
|
type: "message" | "tool_call" | "plan" | "permission" | "status" | "error";
|
|
176
183
|
kind?: AgentTimelineKind;
|
|
184
|
+
revision?: number;
|
|
185
|
+
source?: AgentSyncSource;
|
|
186
|
+
canonical?: boolean;
|
|
177
187
|
turnId?: string;
|
|
178
188
|
itemId?: string;
|
|
179
189
|
role?: "user" | "assistant" | "system";
|
|
@@ -205,15 +215,51 @@ interface PendingStructuredInputWaiter {
|
|
|
205
215
|
source?: string;
|
|
206
216
|
}
|
|
207
217
|
|
|
218
|
+
interface AgentRevisionEvent {
|
|
219
|
+
revision: number;
|
|
220
|
+
item?: AgentTimelineItem;
|
|
221
|
+
conversation?: AgentConversation;
|
|
222
|
+
}
|
|
223
|
+
|
|
208
224
|
const PERMISSION_TIMEOUT_MS = 5 * 60_000;
|
|
209
225
|
const MAX_TIMELINE_ITEMS = 200;
|
|
210
226
|
const MAX_SNAPSHOT_ITEMS = 80;
|
|
211
227
|
const MAX_SNAPSHOT_TEXT_BYTES = 128 * 1024;
|
|
228
|
+
const MAX_DELTA_EVENTS = 500;
|
|
229
|
+
const HISTORY_PAGE_MAX_ITEMS = 500;
|
|
212
230
|
|
|
213
231
|
function id(prefix: string): string {
|
|
214
232
|
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
215
233
|
}
|
|
216
234
|
|
|
235
|
+
function clampHistoryCursor(value: string | undefined, fallback: number, max: number): number {
|
|
236
|
+
if (!value) return fallback;
|
|
237
|
+
const parsed = Number.parseInt(value, 10);
|
|
238
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
239
|
+
return Math.max(0, Math.min(max, parsed));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function appServerText(value: unknown): string | undefined {
|
|
243
|
+
if (typeof value === "string") {
|
|
244
|
+
const text = value.trim();
|
|
245
|
+
return text || undefined;
|
|
246
|
+
}
|
|
247
|
+
if (Array.isArray(value)) {
|
|
248
|
+
const text = value
|
|
249
|
+
.map((part) => appServerText(part))
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
.join("\n")
|
|
252
|
+
.trim();
|
|
253
|
+
return text || undefined;
|
|
254
|
+
}
|
|
255
|
+
const record = asRecord(value);
|
|
256
|
+
if (!record) return undefined;
|
|
257
|
+
if (typeof record.text === "string") return appServerText(record.text);
|
|
258
|
+
if (typeof record.content === "string") return appServerText(record.content);
|
|
259
|
+
if (typeof record.message === "string") return appServerText(record.message);
|
|
260
|
+
return appServerText(record.content ?? record.message ?? record.parts);
|
|
261
|
+
}
|
|
262
|
+
|
|
217
263
|
function stringify(value: unknown): string {
|
|
218
264
|
if (typeof value === "string") return value;
|
|
219
265
|
try {
|
|
@@ -835,6 +881,30 @@ interface ProviderRuntimeCapabilities {
|
|
|
835
881
|
const ALL_REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
836
882
|
const CLAUDE_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
|
|
837
883
|
const AGENT_PERMISSION_MODES: AgentPermissionMode[] = ["read_only", "workspace_write", "full_access"];
|
|
884
|
+
const COMMAND_OUTPUT_MAX_BYTES = 96 * 1024;
|
|
885
|
+
const LINKSHELL_NATIVE_COMMANDS: Array<{
|
|
886
|
+
name: string;
|
|
887
|
+
description: string;
|
|
888
|
+
category: string;
|
|
889
|
+
argsMode?: AgentCommandDescriptor["argsMode"];
|
|
890
|
+
destructive?: boolean;
|
|
891
|
+
providers?: AgentProvider[];
|
|
892
|
+
}> = [
|
|
893
|
+
{ name: "status", description: "Show current Agent and workspace status", category: "LinkShell", argsMode: "none" },
|
|
894
|
+
{ name: "plan", description: "Enter Plan mode for the next turn", category: "Agent", argsMode: "none" },
|
|
895
|
+
{ name: "exit-plan", description: "Exit Plan mode", category: "Agent", argsMode: "none" },
|
|
896
|
+
{ name: "review", description: "Ask the Agent to review current local changes", category: "Agent", argsMode: "optional" },
|
|
897
|
+
{ name: "subagents", description: "Ask the Agent to split work across subagents when useful", category: "Agent", argsMode: "optional" },
|
|
898
|
+
{ name: "compact", description: "Compact the active Codex context", category: "Codex", argsMode: "none", providers: ["codex"] },
|
|
899
|
+
{ name: "clear", description: "Start a fresh Agent context for this conversation", category: "Agent", argsMode: "none", destructive: true },
|
|
900
|
+
{ name: "git-status", description: "Show branch and working tree status", category: "Git", argsMode: "none" },
|
|
901
|
+
{ name: "git-diff", description: "Show a compact diffstat for current changes", category: "Git", argsMode: "none" },
|
|
902
|
+
{ name: "git-commit", description: "Commit staged changes with the given message", category: "Git", argsMode: "required" },
|
|
903
|
+
{ name: "git-pull", description: "Pull with fast-forward only", category: "Git", argsMode: "none" },
|
|
904
|
+
{ name: "git-push", description: "Push the current branch", category: "Git", argsMode: "none" },
|
|
905
|
+
{ name: "git-stash", description: "Stash current working tree changes", category: "Git", argsMode: "optional" },
|
|
906
|
+
{ name: "git-stash-pop", description: "Pop the latest stash", category: "Git", argsMode: "none" },
|
|
907
|
+
];
|
|
838
908
|
const CLAUDE_REMOTE_HIDDEN_COMMANDS = new Set([
|
|
839
909
|
"add-dir",
|
|
840
910
|
"agents",
|
|
@@ -1107,17 +1177,30 @@ function customClaudeCommands(cwd: string): AgentCommandDescriptor[] {
|
|
|
1107
1177
|
|
|
1108
1178
|
function defaultProviderCommands(provider: AgentProvider, cwd: string, enabled: boolean): AgentCommandDescriptor[] {
|
|
1109
1179
|
const disabledReason = enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`;
|
|
1180
|
+
const linkshellCommands = LINKSHELL_NATIVE_COMMANDS
|
|
1181
|
+
.filter((command) => !command.providers || command.providers.includes(provider))
|
|
1182
|
+
.map((command) => makeCommand({
|
|
1183
|
+
provider,
|
|
1184
|
+
name: command.name,
|
|
1185
|
+
description: command.description,
|
|
1186
|
+
source: "linkshell",
|
|
1187
|
+
category: command.category,
|
|
1188
|
+
argsMode: command.argsMode ?? "optional",
|
|
1189
|
+
destructive: command.destructive,
|
|
1190
|
+
disabledReason,
|
|
1191
|
+
executionKind: "native",
|
|
1192
|
+
}));
|
|
1110
1193
|
if (provider === "codex") {
|
|
1111
|
-
return
|
|
1194
|
+
return linkshellCommands;
|
|
1112
1195
|
}
|
|
1113
1196
|
if (provider === "claude") {
|
|
1114
1197
|
const custom = customClaudeCommands(cwd).map((command) => ({
|
|
1115
1198
|
...command,
|
|
1116
1199
|
disabledReason: command.disabledReason ?? disabledReason,
|
|
1117
1200
|
}));
|
|
1118
|
-
return custom;
|
|
1201
|
+
return [...linkshellCommands, ...custom];
|
|
1119
1202
|
}
|
|
1120
|
-
return
|
|
1203
|
+
return linkshellCommands;
|
|
1121
1204
|
}
|
|
1122
1205
|
|
|
1123
1206
|
function mergeCommands(...groups: Array<AgentCommandDescriptor[] | undefined>): AgentCommandDescriptor[] {
|
|
@@ -1136,6 +1219,81 @@ function mergeCommands(...groups: Array<AgentCommandDescriptor[] | undefined>):
|
|
|
1136
1219
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1137
1220
|
}
|
|
1138
1221
|
|
|
1222
|
+
function isGitNativeCommand(name: string): boolean {
|
|
1223
|
+
return name === "git-status" ||
|
|
1224
|
+
name === "git-diff" ||
|
|
1225
|
+
name === "git-commit" ||
|
|
1226
|
+
name === "git-pull" ||
|
|
1227
|
+
name === "git-push" ||
|
|
1228
|
+
name === "git-stash" ||
|
|
1229
|
+
name === "git-stash-pop";
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function gitCommandArgs(name: string, args?: string): { display: string; argv: string[] } {
|
|
1233
|
+
const message = args?.trim();
|
|
1234
|
+
switch (name) {
|
|
1235
|
+
case "git-status":
|
|
1236
|
+
return { display: "git status --short --branch", argv: ["status", "--short", "--branch"] };
|
|
1237
|
+
case "git-diff":
|
|
1238
|
+
return { display: "git diff --stat", argv: ["diff", "--stat"] };
|
|
1239
|
+
case "git-commit":
|
|
1240
|
+
if (!message) throw new Error("请先输入提交信息,例如 /git-commit fix mobile agent timeline");
|
|
1241
|
+
return { display: `git commit -m ${JSON.stringify(message)}`, argv: ["commit", "-m", message] };
|
|
1242
|
+
case "git-pull":
|
|
1243
|
+
return { display: "git pull --ff-only", argv: ["pull", "--ff-only"] };
|
|
1244
|
+
case "git-push":
|
|
1245
|
+
return { display: "git push", argv: ["push"] };
|
|
1246
|
+
case "git-stash":
|
|
1247
|
+
return {
|
|
1248
|
+
display: "git stash push -u",
|
|
1249
|
+
argv: ["stash", "push", "-u", "-m", message || "LinkShell mobile stash"],
|
|
1250
|
+
};
|
|
1251
|
+
case "git-stash-pop":
|
|
1252
|
+
return { display: "git stash pop", argv: ["stash", "pop"] };
|
|
1253
|
+
default:
|
|
1254
|
+
throw new Error(`未知 Git 命令:/${name}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function runProcess(
|
|
1259
|
+
command: string,
|
|
1260
|
+
args: string[],
|
|
1261
|
+
options: { cwd: string; maxBytes?: number },
|
|
1262
|
+
): Promise<{ output: string; exitCode: number | null; signal: NodeJS.Signals | null }> {
|
|
1263
|
+
return new Promise((resolve, reject) => {
|
|
1264
|
+
const child = spawn(command, args, {
|
|
1265
|
+
cwd: options.cwd,
|
|
1266
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1267
|
+
windowsHide: true,
|
|
1268
|
+
});
|
|
1269
|
+
const maxBytes = options.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES;
|
|
1270
|
+
let output = "";
|
|
1271
|
+
let bytes = 0;
|
|
1272
|
+
let truncated = false;
|
|
1273
|
+
const append = (chunk: Buffer) => {
|
|
1274
|
+
if (bytes >= maxBytes) {
|
|
1275
|
+
truncated = true;
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
const remaining = maxBytes - bytes;
|
|
1279
|
+
const slice = chunk.byteLength > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
1280
|
+
output += slice.toString("utf8");
|
|
1281
|
+
bytes += slice.byteLength;
|
|
1282
|
+
if (slice.byteLength < chunk.byteLength) truncated = true;
|
|
1283
|
+
};
|
|
1284
|
+
child.stdout.on("data", append);
|
|
1285
|
+
child.stderr.on("data", append);
|
|
1286
|
+
child.once("error", reject);
|
|
1287
|
+
child.once("close", (exitCode, signal) => {
|
|
1288
|
+
resolve({
|
|
1289
|
+
output: `${output.trimEnd()}${truncated ? "\n\n[truncated by LinkShell]" : ""}`,
|
|
1290
|
+
exitCode,
|
|
1291
|
+
signal,
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1139
1297
|
function runtimeCommands(provider: AgentProvider, value: unknown): AgentCommandDescriptor[] {
|
|
1140
1298
|
const raw = asRecord(value);
|
|
1141
1299
|
const commandsValue =
|
|
@@ -1233,6 +1391,8 @@ function parseRemoteSessions(value: unknown): Array<{
|
|
|
1233
1391
|
createdAt?: number;
|
|
1234
1392
|
lastActivityAt?: number;
|
|
1235
1393
|
archived?: boolean;
|
|
1394
|
+
status?: AgentStatus;
|
|
1395
|
+
runningTurnId?: string;
|
|
1236
1396
|
}> {
|
|
1237
1397
|
const raw = asRecord(value);
|
|
1238
1398
|
const sessionsValue =
|
|
@@ -1249,6 +1409,8 @@ function parseRemoteSessions(value: unknown): Array<{
|
|
|
1249
1409
|
createdAt?: number;
|
|
1250
1410
|
lastActivityAt?: number;
|
|
1251
1411
|
archived?: boolean;
|
|
1412
|
+
status?: AgentStatus;
|
|
1413
|
+
runningTurnId?: string;
|
|
1252
1414
|
}> = [];
|
|
1253
1415
|
for (const entry of sessionsValue) {
|
|
1254
1416
|
const session = asRecord(entry);
|
|
@@ -1268,11 +1430,23 @@ function parseRemoteSessions(value: unknown): Array<{
|
|
|
1268
1430
|
createdAt: parseTimestamp(source.createdAt ?? source.created_at),
|
|
1269
1431
|
lastActivityAt: parseTimestamp(source.lastActivityAt ?? source.updatedAt ?? source.modifiedAt ?? source.lastModified ?? source.updated_at),
|
|
1270
1432
|
archived: typeof source.archived === "boolean" ? source.archived : undefined,
|
|
1433
|
+
status: normalizeAgentStatus(firstString(source, ["status", "state", "phase"])),
|
|
1434
|
+
runningTurnId: firstString(source, ["runningTurnId", "running_turn_id", "turnId", "activeTurnId"]),
|
|
1271
1435
|
});
|
|
1272
1436
|
}
|
|
1273
1437
|
return result;
|
|
1274
1438
|
}
|
|
1275
1439
|
|
|
1440
|
+
function normalizeAgentStatus(value: string | undefined): AgentStatus | undefined {
|
|
1441
|
+
if (!value) return undefined;
|
|
1442
|
+
const normalized = value.toLowerCase();
|
|
1443
|
+
if (normalized === "running" || normalized === "in_progress" || normalized === "busy") return "running";
|
|
1444
|
+
if (normalized === "waiting_permission" || normalized === "waiting" || normalized === "blocked") return "waiting_permission";
|
|
1445
|
+
if (normalized === "error" || normalized === "failed") return "error";
|
|
1446
|
+
if (normalized === "idle" || normalized === "completed" || normalized === "done") return "idle";
|
|
1447
|
+
return undefined;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1276
1450
|
export class AgentWorkspaceProxy {
|
|
1277
1451
|
private clients = new Map<AgentProvider, AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient>();
|
|
1278
1452
|
private agentProtocols = new Map<AgentProvider, AgentProtocol>();
|
|
@@ -1286,6 +1460,8 @@ export class AgentWorkspaceProxy {
|
|
|
1286
1460
|
private conversations = new Map<string, AgentConversation>();
|
|
1287
1461
|
private conversationByAgentSessionId = new Map<string, string>();
|
|
1288
1462
|
private timelines = new Map<string, AgentTimelineItem[]>();
|
|
1463
|
+
private conversationRevisions = new Map<string, number>();
|
|
1464
|
+
private revisionEvents = new Map<string, AgentRevisionEvent[]>();
|
|
1289
1465
|
private toolOutputBuffers = new Map<string, string>();
|
|
1290
1466
|
private pendingPermissions = new Map<string, AgentPermission>();
|
|
1291
1467
|
private permissionWaiters = new Map<string, PendingPermissionWaiter>();
|
|
@@ -1326,7 +1502,7 @@ export class AgentWorkspaceProxy {
|
|
|
1326
1502
|
this.input.send(createEnvelope({
|
|
1327
1503
|
type: "agent.v2.conversation.list.result",
|
|
1328
1504
|
hostDeviceId: this.input.hostDeviceId,
|
|
1329
|
-
payload: { conversations },
|
|
1505
|
+
payload: { conversations: conversations.map((conversation) => this.conversationSnapshot(conversation)) },
|
|
1330
1506
|
}));
|
|
1331
1507
|
break;
|
|
1332
1508
|
}
|
|
@@ -1335,6 +1511,16 @@ export class AgentWorkspaceProxy {
|
|
|
1335
1511
|
this.sendSnapshot(payload.conversationId);
|
|
1336
1512
|
break;
|
|
1337
1513
|
}
|
|
1514
|
+
case "agent.v2.history.request": {
|
|
1515
|
+
const payload = parseTypedPayload("agent.v2.history.request", envelope.payload);
|
|
1516
|
+
await this.sendHistoryPage(payload);
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
case "agent.v2.delta.request": {
|
|
1520
|
+
const payload = parseTypedPayload("agent.v2.delta.request", envelope.payload);
|
|
1521
|
+
this.sendDelta(payload);
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1338
1524
|
case "agent.v2.prompt": {
|
|
1339
1525
|
const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
|
|
1340
1526
|
await this.sendPrompt(payload);
|
|
@@ -1543,14 +1729,20 @@ export class AgentWorkspaceProxy {
|
|
|
1543
1729
|
reasoningEffort: existing?.reasoningEffort,
|
|
1544
1730
|
permissionMode: existing?.permissionMode,
|
|
1545
1731
|
collaborationMode: existing?.collaborationMode,
|
|
1546
|
-
status: existing?.status ?? "idle",
|
|
1732
|
+
status: remote.status ?? existing?.status ?? "idle",
|
|
1547
1733
|
archived: remote.archived ?? existing?.archived ?? false,
|
|
1734
|
+
timelineRevision: existing?.timelineRevision ?? this.getRevision(conversationId),
|
|
1735
|
+
historyComplete: existing?.historyComplete ?? false,
|
|
1736
|
+
runningTurnId: remote.runningTurnId ?? this.currentTurnIds.get(conversationId),
|
|
1737
|
+
source: "device",
|
|
1738
|
+
canonical: true,
|
|
1548
1739
|
lastMessagePreview: existing?.lastMessagePreview,
|
|
1549
1740
|
lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
|
|
1550
1741
|
createdAt: remote.createdAt ?? existing?.createdAt ?? now,
|
|
1551
1742
|
};
|
|
1552
1743
|
this.conversations.set(conversation.id, conversation);
|
|
1553
1744
|
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
1745
|
+
if (remote.runningTurnId) this.rememberTurnConversationId(conversation.id, remote.runningTurnId);
|
|
1554
1746
|
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
1555
1747
|
}
|
|
1556
1748
|
}
|
|
@@ -1645,12 +1837,18 @@ export class AgentWorkspaceProxy {
|
|
|
1645
1837
|
}
|
|
1646
1838
|
this.hydrateStoredTimeline(existingConversation);
|
|
1647
1839
|
this.activeConversationId = existingConversation.id;
|
|
1840
|
+
const snapshot = this.latestSnapshot(existingConversation.id);
|
|
1648
1841
|
this.input.send(createEnvelope({
|
|
1649
1842
|
type: "agent.v2.conversation.opened",
|
|
1650
1843
|
hostDeviceId: this.input.hostDeviceId,
|
|
1651
1844
|
payload: {
|
|
1652
|
-
conversation: existingConversation,
|
|
1653
|
-
snapshot:
|
|
1845
|
+
conversation: this.conversationSnapshot(existingConversation),
|
|
1846
|
+
snapshot: snapshot.items,
|
|
1847
|
+
revision: this.getRevision(existingConversation.id),
|
|
1848
|
+
cursor: snapshot.cursor,
|
|
1849
|
+
hasMore: snapshot.hasMore,
|
|
1850
|
+
source: "device-history",
|
|
1851
|
+
canonical: true,
|
|
1654
1852
|
},
|
|
1655
1853
|
}));
|
|
1656
1854
|
return existingConversation;
|
|
@@ -1694,6 +1892,11 @@ export class AgentWorkspaceProxy {
|
|
|
1694
1892
|
collaborationMode: payload.collaborationMode ?? existingConversation?.collaborationMode,
|
|
1695
1893
|
status: "idle",
|
|
1696
1894
|
archived: existingConversation?.archived ?? false,
|
|
1895
|
+
timelineRevision: existingConversation?.timelineRevision ?? this.getRevision(conversationId),
|
|
1896
|
+
historyComplete: existingConversation?.historyComplete ?? false,
|
|
1897
|
+
runningTurnId: this.currentTurnIds.get(conversationId),
|
|
1898
|
+
source: "device",
|
|
1899
|
+
canonical: true,
|
|
1697
1900
|
lastMessagePreview: existingConversation?.status === "error" ? undefined : existingConversation?.lastMessagePreview,
|
|
1698
1901
|
lastActivityAt: now,
|
|
1699
1902
|
createdAt: existingConversation?.createdAt ?? now,
|
|
@@ -1703,10 +1906,19 @@ export class AgentWorkspaceProxy {
|
|
|
1703
1906
|
this.activeConversationId = conversation.id;
|
|
1704
1907
|
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
1705
1908
|
this.hydrateStoredTimeline(conversation);
|
|
1909
|
+
const snapshot = this.latestSnapshot(conversation.id);
|
|
1706
1910
|
this.input.send(createEnvelope({
|
|
1707
1911
|
type: "agent.v2.conversation.opened",
|
|
1708
1912
|
hostDeviceId: this.input.hostDeviceId,
|
|
1709
|
-
payload: {
|
|
1913
|
+
payload: {
|
|
1914
|
+
conversation: this.conversationSnapshot(conversation),
|
|
1915
|
+
snapshot: snapshot.items,
|
|
1916
|
+
revision: this.getRevision(conversation.id),
|
|
1917
|
+
cursor: snapshot.cursor,
|
|
1918
|
+
hasMore: snapshot.hasMore,
|
|
1919
|
+
source: "device-history",
|
|
1920
|
+
canonical: true,
|
|
1921
|
+
},
|
|
1710
1922
|
}));
|
|
1711
1923
|
return conversation;
|
|
1712
1924
|
} catch (error) {
|
|
@@ -1742,6 +1954,10 @@ export class AgentWorkspaceProxy {
|
|
|
1742
1954
|
collaborationMode: payload.collaborationMode,
|
|
1743
1955
|
status: "error",
|
|
1744
1956
|
archived: false,
|
|
1957
|
+
timelineRevision: this.getRevision(fallbackId),
|
|
1958
|
+
historyComplete: false,
|
|
1959
|
+
source: "device",
|
|
1960
|
+
canonical: true,
|
|
1745
1961
|
lastMessagePreview: message,
|
|
1746
1962
|
lastActivityAt: now,
|
|
1747
1963
|
createdAt: now,
|
|
@@ -1758,7 +1974,14 @@ export class AgentWorkspaceProxy {
|
|
|
1758
1974
|
this.input.send(createEnvelope({
|
|
1759
1975
|
type: "agent.v2.conversation.opened",
|
|
1760
1976
|
hostDeviceId: this.input.hostDeviceId,
|
|
1761
|
-
payload: {
|
|
1977
|
+
payload: {
|
|
1978
|
+
conversation: this.conversationSnapshot(conversation),
|
|
1979
|
+
snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []),
|
|
1980
|
+
revision: this.getRevision(conversation.id),
|
|
1981
|
+
hasMore: false,
|
|
1982
|
+
source: "device",
|
|
1983
|
+
canonical: true,
|
|
1984
|
+
},
|
|
1762
1985
|
}));
|
|
1763
1986
|
return conversation;
|
|
1764
1987
|
}
|
|
@@ -1985,14 +2208,8 @@ export class AgentWorkspaceProxy {
|
|
|
1985
2208
|
return;
|
|
1986
2209
|
}
|
|
1987
2210
|
|
|
1988
|
-
if (
|
|
1989
|
-
this.
|
|
1990
|
-
id: id("error"),
|
|
1991
|
-
conversationId: conversation.id,
|
|
1992
|
-
type: "error",
|
|
1993
|
-
error: `${command.title} 暂无 ${providerLabel(conversation.provider)} 原生实现。`,
|
|
1994
|
-
createdAt: now,
|
|
1995
|
-
});
|
|
2211
|
+
if (isGitNativeCommand(command.name)) {
|
|
2212
|
+
await this.executeGitNativeCommand(conversation, command.name, args);
|
|
1996
2213
|
return;
|
|
1997
2214
|
}
|
|
1998
2215
|
|
|
@@ -2009,6 +2226,22 @@ export class AgentWorkspaceProxy {
|
|
|
2009
2226
|
return;
|
|
2010
2227
|
}
|
|
2011
2228
|
|
|
2229
|
+
if (command.name === "review" || command.name === "subagents") {
|
|
2230
|
+
const prompt = command.name === "review"
|
|
2231
|
+
? args || "Review the current local changes."
|
|
2232
|
+
: args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
|
|
2233
|
+
await this.sendPrompt({
|
|
2234
|
+
conversationId: conversation.id,
|
|
2235
|
+
clientMessageId: id(command.name),
|
|
2236
|
+
contentBlocks: [{ type: "text", text: prompt }],
|
|
2237
|
+
model: conversation.model,
|
|
2238
|
+
reasoningEffort: conversation.reasoningEffort,
|
|
2239
|
+
permissionMode: conversation.permissionMode,
|
|
2240
|
+
collaborationMode: conversation.collaborationMode,
|
|
2241
|
+
});
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2012
2245
|
if (command.name === "compact") {
|
|
2013
2246
|
if (!(client instanceof AcpClient)) throw new Error("当前 Codex runtime 不支持原生 compact。");
|
|
2014
2247
|
conversation.status = "running";
|
|
@@ -2046,22 +2279,6 @@ export class AgentWorkspaceProxy {
|
|
|
2046
2279
|
return;
|
|
2047
2280
|
}
|
|
2048
2281
|
|
|
2049
|
-
if (command.name === "review" || command.name === "subagents") {
|
|
2050
|
-
const prompt = command.name === "review"
|
|
2051
|
-
? args || "Review the current local changes."
|
|
2052
|
-
: args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
|
|
2053
|
-
await this.sendPrompt({
|
|
2054
|
-
conversationId: conversation.id,
|
|
2055
|
-
clientMessageId: id(command.name),
|
|
2056
|
-
contentBlocks: [{ type: "text", text: prompt }],
|
|
2057
|
-
model: conversation.model,
|
|
2058
|
-
reasoningEffort: conversation.reasoningEffort,
|
|
2059
|
-
permissionMode: conversation.permissionMode,
|
|
2060
|
-
collaborationMode: conversation.collaborationMode,
|
|
2061
|
-
});
|
|
2062
|
-
return;
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
2282
|
throw new Error(`命令暂未实现:/${command.name}`);
|
|
2066
2283
|
} catch (error) {
|
|
2067
2284
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2076,6 +2293,46 @@ export class AgentWorkspaceProxy {
|
|
|
2076
2293
|
}
|
|
2077
2294
|
}
|
|
2078
2295
|
|
|
2296
|
+
private async executeGitNativeCommand(
|
|
2297
|
+
conversation: AgentConversation,
|
|
2298
|
+
commandName: string,
|
|
2299
|
+
args?: string,
|
|
2300
|
+
): Promise<void> {
|
|
2301
|
+
const git = gitCommandArgs(commandName, args);
|
|
2302
|
+
const toolId = id(commandName);
|
|
2303
|
+
const now = Date.now();
|
|
2304
|
+
conversation.status = "running";
|
|
2305
|
+
conversation.lastMessagePreview = git.display;
|
|
2306
|
+
conversation.lastActivityAt = now;
|
|
2307
|
+
this.emitConversation(conversation);
|
|
2308
|
+
this.upsertTool(conversation.id, {
|
|
2309
|
+
id: toolId,
|
|
2310
|
+
name: "命令",
|
|
2311
|
+
input: `${git.display}\n\ncwd: ${conversation.cwd}`,
|
|
2312
|
+
createdAt: now,
|
|
2313
|
+
status: "running",
|
|
2314
|
+
});
|
|
2315
|
+
const result = await runProcess("git", git.argv, {
|
|
2316
|
+
cwd: conversation.cwd,
|
|
2317
|
+
maxBytes: COMMAND_OUTPUT_MAX_BYTES,
|
|
2318
|
+
});
|
|
2319
|
+
const ok = result.exitCode === 0;
|
|
2320
|
+
this.upsertTool(conversation.id, {
|
|
2321
|
+
id: toolId,
|
|
2322
|
+
name: "命令",
|
|
2323
|
+
input: `${git.display}\n\ncwd: ${conversation.cwd}`,
|
|
2324
|
+
output: result.output || (ok ? "完成" : `退出码 ${result.exitCode ?? "unknown"}`),
|
|
2325
|
+
createdAt: now,
|
|
2326
|
+
status: ok ? "completed" : "failed",
|
|
2327
|
+
});
|
|
2328
|
+
conversation.status = ok ? "idle" : "error";
|
|
2329
|
+
conversation.lastMessagePreview = ok
|
|
2330
|
+
? `${git.display} 完成`
|
|
2331
|
+
: `${git.display} 失败`;
|
|
2332
|
+
conversation.lastActivityAt = Date.now();
|
|
2333
|
+
this.emitConversation(conversation);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2079
2336
|
private handleRequest(method: string, params: unknown): Promise<unknown> | unknown {
|
|
2080
2337
|
if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
|
|
2081
2338
|
return this.handleStructuredInput(params, true);
|
|
@@ -2734,6 +2991,14 @@ export class AgentWorkspaceProxy {
|
|
|
2734
2991
|
this.permissionWaiters.delete(requestId);
|
|
2735
2992
|
this.permissionSources.delete(requestId);
|
|
2736
2993
|
resolve(formatPermissionResponse(source, "cancelled", "cancelled"));
|
|
2994
|
+
this.markPermission(conversationId, requestId, {
|
|
2995
|
+
permissionOutcome: "cancelled",
|
|
2996
|
+
optionId: "cancelled",
|
|
2997
|
+
permissionLive: false,
|
|
2998
|
+
permissionPending: false,
|
|
2999
|
+
permissionExpired: true,
|
|
3000
|
+
permissionError: "等待授权超时",
|
|
3001
|
+
});
|
|
2737
3002
|
this.updateConversationStatus(conversationId, "idle");
|
|
2738
3003
|
}, PERMISSION_TIMEOUT_MS);
|
|
2739
3004
|
this.permissionWaiters.set(requestId, { resolve, timer });
|
|
@@ -2773,6 +3038,8 @@ export class AgentWorkspaceProxy {
|
|
|
2773
3038
|
this.markPermission(payload.conversationId, payload.requestId, {
|
|
2774
3039
|
permissionOutcome: payload.outcome,
|
|
2775
3040
|
optionId: selectedOptionId,
|
|
3041
|
+
permissionLive: false,
|
|
3042
|
+
permissionExpired: false,
|
|
2776
3043
|
permissionError: undefined,
|
|
2777
3044
|
permissionPending: false,
|
|
2778
3045
|
});
|
|
@@ -2833,6 +3100,228 @@ export class AgentWorkspaceProxy {
|
|
|
2833
3100
|
});
|
|
2834
3101
|
}
|
|
2835
3102
|
|
|
3103
|
+
private getRevision(conversationId: string): number {
|
|
3104
|
+
return this.conversationRevisions.get(conversationId) ??
|
|
3105
|
+
this.conversations.get(conversationId)?.timelineRevision ??
|
|
3106
|
+
0;
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
private setRevisionFloor(conversationId: string, revision: number): number {
|
|
3110
|
+
const nextRevision = Math.max(this.getRevision(conversationId), revision);
|
|
3111
|
+
this.conversationRevisions.set(conversationId, nextRevision);
|
|
3112
|
+
const conversation = this.conversations.get(conversationId);
|
|
3113
|
+
if (conversation) {
|
|
3114
|
+
conversation.timelineRevision = nextRevision;
|
|
3115
|
+
conversation.runningTurnId = this.currentTurnIds.get(conversationId);
|
|
3116
|
+
}
|
|
3117
|
+
return nextRevision;
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
private conversationSnapshot(conversation: AgentConversation): AgentConversation {
|
|
3121
|
+
return {
|
|
3122
|
+
...conversation,
|
|
3123
|
+
timelineRevision: this.getRevision(conversation.id),
|
|
3124
|
+
historyComplete: conversation.historyComplete ?? false,
|
|
3125
|
+
runningTurnId: this.currentTurnIds.get(conversation.id),
|
|
3126
|
+
source: conversation.source ?? "device",
|
|
3127
|
+
canonical: conversation.canonical ?? true,
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
private annotateTimelineItem(
|
|
3132
|
+
item: AgentTimelineItem,
|
|
3133
|
+
revision: number | undefined,
|
|
3134
|
+
source: AgentSyncSource,
|
|
3135
|
+
): AgentTimelineItem {
|
|
3136
|
+
return {
|
|
3137
|
+
...item,
|
|
3138
|
+
revision: revision ?? item.revision,
|
|
3139
|
+
source: item.source ?? source,
|
|
3140
|
+
canonical: item.canonical ?? true,
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
private recordRevisionEvent(
|
|
3145
|
+
conversationId: string,
|
|
3146
|
+
change: { item?: AgentTimelineItem; conversation?: AgentConversation },
|
|
3147
|
+
): AgentRevisionEvent {
|
|
3148
|
+
const revision = this.getRevision(conversationId) + 1;
|
|
3149
|
+
this.conversationRevisions.set(conversationId, revision);
|
|
3150
|
+
const conversation = this.conversations.get(conversationId);
|
|
3151
|
+
if (conversation) {
|
|
3152
|
+
conversation.timelineRevision = revision;
|
|
3153
|
+
conversation.runningTurnId = this.currentTurnIds.get(conversationId);
|
|
3154
|
+
}
|
|
3155
|
+
const event: AgentRevisionEvent = {
|
|
3156
|
+
revision,
|
|
3157
|
+
item: change.item ? this.annotateTimelineItem(change.item, revision, "device-live") : undefined,
|
|
3158
|
+
conversation: change.conversation ? this.conversationSnapshot(change.conversation) : undefined,
|
|
3159
|
+
};
|
|
3160
|
+
const events = this.revisionEvents.get(conversationId) ?? [];
|
|
3161
|
+
events.push(event);
|
|
3162
|
+
if (events.length > MAX_DELTA_EVENTS) {
|
|
3163
|
+
events.splice(0, events.length - MAX_DELTA_EVENTS);
|
|
3164
|
+
}
|
|
3165
|
+
this.revisionEvents.set(conversationId, events);
|
|
3166
|
+
return event;
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
private storedTimeline(conversation: AgentConversation, maxItems = HISTORY_PAGE_MAX_ITEMS): AgentTimelineItem[] {
|
|
3170
|
+
if (!conversation.agentSessionId) return [];
|
|
3171
|
+
const result = conversation.provider === "codex"
|
|
3172
|
+
? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd, { maxItems })
|
|
3173
|
+
: conversation.provider === "claude"
|
|
3174
|
+
? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id, { maxItems })
|
|
3175
|
+
: { items: [] };
|
|
3176
|
+
return result.items
|
|
3177
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
3178
|
+
.map((item, index) => this.annotateTimelineItem(item as AgentTimelineItem, index + 1, "device-history"));
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
private canonicalTimeline(conversation: AgentConversation, maxItems = HISTORY_PAGE_MAX_ITEMS): AgentTimelineItem[] {
|
|
3182
|
+
const merged = new Map<string, AgentTimelineItem>();
|
|
3183
|
+
for (const item of this.storedTimeline(conversation, maxItems)) {
|
|
3184
|
+
merged.set(item.id, item);
|
|
3185
|
+
}
|
|
3186
|
+
for (const item of this.timelines.get(conversation.id) ?? []) {
|
|
3187
|
+
merged.set(item.id, this.annotateTimelineItem(item, item.revision, item.source ?? "device-live"));
|
|
3188
|
+
}
|
|
3189
|
+
const items = [...merged.values()]
|
|
3190
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
3191
|
+
.slice(-maxItems);
|
|
3192
|
+
this.setRevisionFloor(conversation.id, Math.max(this.getRevision(conversation.id), items.length));
|
|
3193
|
+
return items;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
private async appServerTimeline(conversation: AgentConversation, maxItems = HISTORY_PAGE_MAX_ITEMS): Promise<AgentTimelineItem[]> {
|
|
3197
|
+
if (conversation.provider !== "codex" || !conversation.agentSessionId) return [];
|
|
3198
|
+
if (this.protocolForProvider(conversation.provider) !== "codex-app-server") return [];
|
|
3199
|
+
const client = this.clientForProvider(conversation.provider);
|
|
3200
|
+
const listTurns = (client as { listTurns?: (input: { sessionId: string; limit?: number }) => Promise<unknown> } | undefined)?.listTurns;
|
|
3201
|
+
if (typeof listTurns !== "function") return [];
|
|
3202
|
+
try {
|
|
3203
|
+
const result = await listTurns.call(client, { sessionId: conversation.agentSessionId, limit: maxItems });
|
|
3204
|
+
return this.timelineFromAppServerTurns(conversation.id, conversation.agentSessionId, result);
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
if (this.input.verbose) {
|
|
3207
|
+
process.stderr.write(`[agent:v2] thread/turns/list failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
3208
|
+
}
|
|
3209
|
+
return [];
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
private timelineFromAppServerTurns(
|
|
3214
|
+
conversationId: string,
|
|
3215
|
+
agentSessionId: string,
|
|
3216
|
+
value: unknown,
|
|
3217
|
+
): AgentTimelineItem[] {
|
|
3218
|
+
const raw = asRecord(value);
|
|
3219
|
+
const turns =
|
|
3220
|
+
Array.isArray(value) ? value :
|
|
3221
|
+
Array.isArray(raw?.turns) ? raw.turns :
|
|
3222
|
+
Array.isArray(raw?.items) ? raw.items :
|
|
3223
|
+
Array.isArray(raw?.entries) ? raw.entries :
|
|
3224
|
+
[];
|
|
3225
|
+
const items: AgentTimelineItem[] = [];
|
|
3226
|
+
turns.forEach((entry, index) => {
|
|
3227
|
+
const turn = asRecord(entry);
|
|
3228
|
+
if (!turn) return;
|
|
3229
|
+
const turnId = firstString(turn, ["id", "turnId", "turn_id"]) ?? `turn-${index + 1}`;
|
|
3230
|
+
const createdAt = parseTimestamp(turn.createdAt ?? turn.created_at ?? turn.startedAt ?? turn.started_at) ?? Date.now() + index;
|
|
3231
|
+
const updatedAt = parseTimestamp(turn.updatedAt ?? turn.updated_at ?? turn.completedAt ?? turn.completed_at);
|
|
3232
|
+
const userText = appServerText(turn.input ?? turn.prompt ?? turn.user ?? turn.userMessage ?? turn.request);
|
|
3233
|
+
if (userText) {
|
|
3234
|
+
items.push({
|
|
3235
|
+
id: `app-server:${agentSessionId}:${turnId}:user`,
|
|
3236
|
+
conversationId,
|
|
3237
|
+
type: "message",
|
|
3238
|
+
kind: "chat",
|
|
3239
|
+
turnId,
|
|
3240
|
+
role: "user",
|
|
3241
|
+
content: [{ type: "text", text: userText }],
|
|
3242
|
+
text: userText,
|
|
3243
|
+
createdAt,
|
|
3244
|
+
updatedAt,
|
|
3245
|
+
metadata: { source: "app-server", provider: "codex" },
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
const assistantText = appServerText(turn.output ?? turn.response ?? turn.assistant ?? turn.assistantMessage ?? turn.result);
|
|
3249
|
+
if (assistantText) {
|
|
3250
|
+
items.push({
|
|
3251
|
+
id: `app-server:${agentSessionId}:${turnId}:assistant`,
|
|
3252
|
+
conversationId,
|
|
3253
|
+
type: "message",
|
|
3254
|
+
kind: "chat",
|
|
3255
|
+
turnId,
|
|
3256
|
+
role: "assistant",
|
|
3257
|
+
content: [{ type: "text", text: assistantText }],
|
|
3258
|
+
text: assistantText,
|
|
3259
|
+
createdAt: updatedAt ?? createdAt + 1,
|
|
3260
|
+
updatedAt,
|
|
3261
|
+
metadata: { source: "app-server", provider: "codex" },
|
|
3262
|
+
});
|
|
3263
|
+
}
|
|
3264
|
+
const nestedItems = Array.isArray(turn.items) ? turn.items : Array.isArray(turn.messages) ? turn.messages : [];
|
|
3265
|
+
for (const nested of nestedItems) {
|
|
3266
|
+
const nestedRecord = asRecord(nested);
|
|
3267
|
+
if (!nestedRecord) continue;
|
|
3268
|
+
const text = appServerText(nestedRecord.content ?? nestedRecord.text ?? nestedRecord.message);
|
|
3269
|
+
const role = nestedRecord.role === "user" || nestedRecord.role === "assistant" || nestedRecord.role === "system"
|
|
3270
|
+
? nestedRecord.role
|
|
3271
|
+
: undefined;
|
|
3272
|
+
if (!text || !role) continue;
|
|
3273
|
+
const itemId = firstString(nestedRecord, ["id", "itemId", "messageId"]) ?? `${role}-${items.length + 1}`;
|
|
3274
|
+
items.push({
|
|
3275
|
+
id: `app-server:${agentSessionId}:${turnId}:${itemId}`,
|
|
3276
|
+
conversationId,
|
|
3277
|
+
type: "message",
|
|
3278
|
+
kind: "chat",
|
|
3279
|
+
turnId,
|
|
3280
|
+
itemId,
|
|
3281
|
+
role,
|
|
3282
|
+
content: [{ type: "text", text }],
|
|
3283
|
+
text,
|
|
3284
|
+
createdAt: parseTimestamp(nestedRecord.createdAt ?? nestedRecord.created_at) ?? createdAt + items.length,
|
|
3285
|
+
updatedAt: parseTimestamp(nestedRecord.updatedAt ?? nestedRecord.updated_at),
|
|
3286
|
+
metadata: { source: "app-server", provider: "codex" },
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
});
|
|
3290
|
+
return items
|
|
3291
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
3292
|
+
.map((item, index) => this.annotateTimelineItem(item, index + 1, "app-server"));
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
private mergeCanonicalTimelineItems(items: AgentTimelineItem[], maxItems: number): AgentTimelineItem[] {
|
|
3296
|
+
const byKey = new Map<string, AgentTimelineItem>();
|
|
3297
|
+
for (const item of items.sort((a, b) => a.createdAt - b.createdAt)) {
|
|
3298
|
+
const key = item.type === "message" && item.role && item.text
|
|
3299
|
+
? `${item.role}:${item.text.replace(/\s+/g, " ").trim().slice(0, 500)}`
|
|
3300
|
+
: item.id;
|
|
3301
|
+
const existing = byKey.get(key);
|
|
3302
|
+
if (!existing || item.source === "app-server" || (item.updatedAt ?? item.createdAt) >= (existing.updatedAt ?? existing.createdAt)) {
|
|
3303
|
+
byKey.set(key, item);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
return [...byKey.values()]
|
|
3307
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
3308
|
+
.slice(-maxItems);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
private latestSnapshot(conversationId: string): {
|
|
3312
|
+
items: AgentTimelineItem[];
|
|
3313
|
+
cursor?: string;
|
|
3314
|
+
hasMore: boolean;
|
|
3315
|
+
} {
|
|
3316
|
+
const timeline = this.timelines.get(conversationId) ?? [];
|
|
3317
|
+
const start = Math.max(0, timeline.length - MAX_SNAPSHOT_ITEMS);
|
|
3318
|
+
return {
|
|
3319
|
+
items: timeline.slice(start).map((item) => snapshotTimelineItem(item)),
|
|
3320
|
+
cursor: start > 0 ? String(start) : undefined,
|
|
3321
|
+
hasMore: start > 0,
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
|
|
2836
3325
|
private addItem(conversationId: string, item: AgentTimelineItem): void {
|
|
2837
3326
|
this.rememberItemConversationId(conversationId, item);
|
|
2838
3327
|
const timeline = this.timelines.get(conversationId) ?? [];
|
|
@@ -2964,6 +3453,17 @@ export class AgentWorkspaceProxy {
|
|
|
2964
3453
|
.sort((a, b) => a.createdAt - b.createdAt)
|
|
2965
3454
|
.slice(-MAX_TIMELINE_ITEMS),
|
|
2966
3455
|
);
|
|
3456
|
+
const oldRevision = this.conversationRevisions.get(oldId) ?? 0;
|
|
3457
|
+
if (oldRevision > 0) {
|
|
3458
|
+
this.conversationRevisions.delete(oldId);
|
|
3459
|
+
this.setRevisionFloor(newId, oldRevision);
|
|
3460
|
+
}
|
|
3461
|
+
const oldEvents = this.revisionEvents.get(oldId);
|
|
3462
|
+
if (oldEvents) {
|
|
3463
|
+
this.revisionEvents.delete(oldId);
|
|
3464
|
+
const existingEvents = this.revisionEvents.get(newId) ?? [];
|
|
3465
|
+
this.revisionEvents.set(newId, [...existingEvents, ...oldEvents].slice(-MAX_DELTA_EVENTS));
|
|
3466
|
+
}
|
|
2967
3467
|
|
|
2968
3468
|
for (const [agentSessionId, conversationId] of this.conversationByAgentSessionId) {
|
|
2969
3469
|
if (conversationId === oldId) {
|
|
@@ -2999,18 +3499,48 @@ export class AgentWorkspaceProxy {
|
|
|
2999
3499
|
|
|
3000
3500
|
private emitItem(conversationId: string, item: AgentTimelineItem): void {
|
|
3001
3501
|
const conversation = this.conversations.get(conversationId);
|
|
3502
|
+
const itemSnapshot = snapshotTimelineItem(item, { stripImages: false });
|
|
3503
|
+
const event = this.recordRevisionEvent(conversationId, { conversation, item: itemSnapshot });
|
|
3002
3504
|
this.input.send(createEnvelope({
|
|
3003
3505
|
type: "agent.v2.event",
|
|
3004
3506
|
hostDeviceId: this.input.hostDeviceId,
|
|
3005
|
-
payload: {
|
|
3507
|
+
payload: {
|
|
3508
|
+
conversationId,
|
|
3509
|
+
conversation: event.conversation,
|
|
3510
|
+
item: event.item,
|
|
3511
|
+
revision: event.revision,
|
|
3512
|
+
source: "device-live",
|
|
3513
|
+
canonical: true,
|
|
3514
|
+
},
|
|
3006
3515
|
}));
|
|
3007
3516
|
}
|
|
3008
3517
|
|
|
3009
3518
|
private emitConversation(conversation: AgentConversation): void {
|
|
3519
|
+
const event = this.recordRevisionEvent(conversation.id, { conversation });
|
|
3010
3520
|
this.input.send(createEnvelope({
|
|
3011
3521
|
type: "agent.v2.event",
|
|
3012
3522
|
hostDeviceId: this.input.hostDeviceId,
|
|
3013
|
-
payload: {
|
|
3523
|
+
payload: {
|
|
3524
|
+
conversationId: conversation.id,
|
|
3525
|
+
conversation: event.conversation,
|
|
3526
|
+
revision: event.revision,
|
|
3527
|
+
source: "device-live",
|
|
3528
|
+
canonical: true,
|
|
3529
|
+
},
|
|
3530
|
+
}));
|
|
3531
|
+
this.input.send(createEnvelope({
|
|
3532
|
+
type: "agent.v2.running_state",
|
|
3533
|
+
hostDeviceId: this.input.hostDeviceId,
|
|
3534
|
+
payload: {
|
|
3535
|
+
conversationId: conversation.id,
|
|
3536
|
+
status: conversation.status,
|
|
3537
|
+
runningTurnId: this.currentTurnIds.get(conversation.id),
|
|
3538
|
+
revision: event.revision,
|
|
3539
|
+
error: conversation.status === "error" ? conversation.lastMessagePreview : undefined,
|
|
3540
|
+
updatedAt: conversation.lastActivityAt,
|
|
3541
|
+
source: "device-live",
|
|
3542
|
+
canonical: true,
|
|
3543
|
+
},
|
|
3014
3544
|
}));
|
|
3015
3545
|
}
|
|
3016
3546
|
|
|
@@ -3059,17 +3589,143 @@ export class AgentWorkspaceProxy {
|
|
|
3059
3589
|
const conversation = this.conversations.get(this.activeConversationId);
|
|
3060
3590
|
if (conversation) this.hydrateStoredTimeline(conversation);
|
|
3061
3591
|
}
|
|
3062
|
-
const conversations = [...this.conversations.values()];
|
|
3063
|
-
const
|
|
3064
|
-
? snapshotTimelineItems(this.timelines.get(conversationId) ?? [])
|
|
3065
|
-
: [];
|
|
3592
|
+
const conversations = [...this.conversations.values()].map((conversation) => this.conversationSnapshot(conversation));
|
|
3593
|
+
const snapshot = conversationId ? this.latestSnapshot(conversationId) : { items: [], cursor: undefined, hasMore: false };
|
|
3066
3594
|
this.input.send(createEnvelope({
|
|
3067
3595
|
type: "agent.v2.snapshot",
|
|
3068
3596
|
hostDeviceId: this.input.hostDeviceId,
|
|
3069
3597
|
payload: {
|
|
3070
3598
|
conversations,
|
|
3071
3599
|
activeConversationId: this.activeConversationId,
|
|
3072
|
-
items,
|
|
3600
|
+
items: snapshot.items,
|
|
3601
|
+
revision: conversationId ? this.getRevision(conversationId) : undefined,
|
|
3602
|
+
cursor: snapshot.cursor,
|
|
3603
|
+
hasMore: snapshot.hasMore,
|
|
3604
|
+
source: "device-history",
|
|
3605
|
+
canonical: true,
|
|
3606
|
+
},
|
|
3607
|
+
}));
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
private async sendHistoryPage(payload: {
|
|
3611
|
+
conversationId: string;
|
|
3612
|
+
cursor?: string;
|
|
3613
|
+
limit?: number;
|
|
3614
|
+
direction?: "older" | "newer";
|
|
3615
|
+
}): Promise<void> {
|
|
3616
|
+
const conversation = this.conversations.get(payload.conversationId);
|
|
3617
|
+
if (!conversation) return;
|
|
3618
|
+
const limit = Math.min(Math.max(payload.limit ?? MAX_SNAPSHOT_ITEMS, 1), 200);
|
|
3619
|
+
const items = this.mergeCanonicalTimelineItems([
|
|
3620
|
+
...this.canonicalTimeline(conversation, HISTORY_PAGE_MAX_ITEMS),
|
|
3621
|
+
...await this.appServerTimeline(conversation, HISTORY_PAGE_MAX_ITEMS),
|
|
3622
|
+
], HISTORY_PAGE_MAX_ITEMS);
|
|
3623
|
+
this.timelines.set(
|
|
3624
|
+
conversation.id,
|
|
3625
|
+
items.slice(-MAX_TIMELINE_ITEMS).map((item) => this.annotateTimelineItem(item, item.revision, item.source ?? "device-history")),
|
|
3626
|
+
);
|
|
3627
|
+
for (const item of this.timelines.get(conversation.id) ?? []) {
|
|
3628
|
+
this.rememberItemConversationId(conversation.id, item);
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
const direction = payload.direction ?? "older";
|
|
3632
|
+
let page: AgentTimelineItem[];
|
|
3633
|
+
let cursor: string | undefined;
|
|
3634
|
+
let hasMore = false;
|
|
3635
|
+
if (direction === "newer") {
|
|
3636
|
+
const start = clampHistoryCursor(payload.cursor, 0, items.length);
|
|
3637
|
+
const end = Math.min(items.length, start + limit);
|
|
3638
|
+
page = items.slice(start, end);
|
|
3639
|
+
hasMore = end < items.length;
|
|
3640
|
+
cursor = hasMore ? String(end) : undefined;
|
|
3641
|
+
} else {
|
|
3642
|
+
const end = clampHistoryCursor(payload.cursor, items.length, items.length);
|
|
3643
|
+
const start = Math.max(0, end - limit);
|
|
3644
|
+
page = items.slice(start, end);
|
|
3645
|
+
hasMore = start > 0;
|
|
3646
|
+
cursor = hasMore ? String(start) : undefined;
|
|
3647
|
+
}
|
|
3648
|
+
conversation.historyComplete = !hasMore;
|
|
3649
|
+
const revision = this.setRevisionFloor(conversation.id, Math.max(this.getRevision(conversation.id), items.length));
|
|
3650
|
+
this.input.send(createEnvelope({
|
|
3651
|
+
type: "agent.v2.history.page",
|
|
3652
|
+
hostDeviceId: this.input.hostDeviceId,
|
|
3653
|
+
payload: {
|
|
3654
|
+
conversationId: conversation.id,
|
|
3655
|
+
conversation: this.conversationSnapshot(conversation),
|
|
3656
|
+
items: page.map((item) => snapshotTimelineItem(item)),
|
|
3657
|
+
revision,
|
|
3658
|
+
cursor,
|
|
3659
|
+
hasMore,
|
|
3660
|
+
source: "device-history",
|
|
3661
|
+
canonical: true,
|
|
3662
|
+
},
|
|
3663
|
+
}));
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
private sendDelta(payload: {
|
|
3667
|
+
conversationId: string;
|
|
3668
|
+
sinceRevision?: number;
|
|
3669
|
+
limit?: number;
|
|
3670
|
+
}): void {
|
|
3671
|
+
const conversation = this.conversations.get(payload.conversationId);
|
|
3672
|
+
if (!conversation) return;
|
|
3673
|
+
this.hydrateStoredTimeline(conversation);
|
|
3674
|
+
const sinceRevision = payload.sinceRevision ?? 0;
|
|
3675
|
+
const limit = Math.min(Math.max(payload.limit ?? 100, 1), 500);
|
|
3676
|
+
const events = this.revisionEvents.get(conversation.id) ?? [];
|
|
3677
|
+
const oldestAvailable = events[0]?.revision ?? this.getRevision(conversation.id);
|
|
3678
|
+
const newestRevision = this.getRevision(conversation.id);
|
|
3679
|
+
const reset = sinceRevision > 0 &&
|
|
3680
|
+
(
|
|
3681
|
+
sinceRevision > newestRevision ||
|
|
3682
|
+
(
|
|
3683
|
+
sinceRevision < newestRevision &&
|
|
3684
|
+
(events.length === 0 || sinceRevision < oldestAvailable - 1)
|
|
3685
|
+
)
|
|
3686
|
+
);
|
|
3687
|
+
|
|
3688
|
+
if (reset) {
|
|
3689
|
+
const snapshot = this.latestSnapshot(conversation.id);
|
|
3690
|
+
this.input.send(createEnvelope({
|
|
3691
|
+
type: "agent.v2.delta",
|
|
3692
|
+
hostDeviceId: this.input.hostDeviceId,
|
|
3693
|
+
payload: {
|
|
3694
|
+
conversationId: conversation.id,
|
|
3695
|
+
conversation: this.conversationSnapshot(conversation),
|
|
3696
|
+
items: snapshot.items,
|
|
3697
|
+
sinceRevision,
|
|
3698
|
+
revision: newestRevision,
|
|
3699
|
+
reset: true,
|
|
3700
|
+
cursor: snapshot.cursor,
|
|
3701
|
+
hasMore: snapshot.hasMore,
|
|
3702
|
+
source: "device-history",
|
|
3703
|
+
canonical: true,
|
|
3704
|
+
},
|
|
3705
|
+
}));
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
const changed = events
|
|
3710
|
+
.filter((event) => event.revision > sinceRevision)
|
|
3711
|
+
.slice(-limit);
|
|
3712
|
+
const itemsById = new Map<string, AgentTimelineItem>();
|
|
3713
|
+
for (const event of changed) {
|
|
3714
|
+
if (event.item) itemsById.set(event.item.id, event.item);
|
|
3715
|
+
}
|
|
3716
|
+
this.input.send(createEnvelope({
|
|
3717
|
+
type: "agent.v2.delta",
|
|
3718
|
+
hostDeviceId: this.input.hostDeviceId,
|
|
3719
|
+
payload: {
|
|
3720
|
+
conversationId: conversation.id,
|
|
3721
|
+
conversation: this.conversationSnapshot(conversation),
|
|
3722
|
+
items: [...itemsById.values()].map((item) => snapshotTimelineItem(item)),
|
|
3723
|
+
sinceRevision,
|
|
3724
|
+
revision: newestRevision,
|
|
3725
|
+
reset: false,
|
|
3726
|
+
hasMore: changed.length === limit && events.some((event) => event.revision > sinceRevision && event.revision < changed[0]!.revision),
|
|
3727
|
+
source: "device-live",
|
|
3728
|
+
canonical: true,
|
|
3073
3729
|
},
|
|
3074
3730
|
}));
|
|
3075
3731
|
}
|
|
@@ -3077,18 +3733,25 @@ export class AgentWorkspaceProxy {
|
|
|
3077
3733
|
private hydrateStoredTimeline(conversation: AgentConversation): void {
|
|
3078
3734
|
if (!conversation.agentSessionId) return;
|
|
3079
3735
|
const existing = this.timelines.get(conversation.id) ?? [];
|
|
3080
|
-
if (existing.length > 0)
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
const
|
|
3736
|
+
if (existing.length > 0) {
|
|
3737
|
+
this.setRevisionFloor(conversation.id, Math.max(
|
|
3738
|
+
existing.length,
|
|
3739
|
+
...existing.map((item) => item.revision ?? 0),
|
|
3740
|
+
));
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
const stored = this.storedTimeline(conversation, MAX_TIMELINE_ITEMS);
|
|
3744
|
+
conversation.historyComplete = stored.length <= MAX_SNAPSHOT_ITEMS;
|
|
3745
|
+
if (stored.length === 0) {
|
|
3746
|
+
this.setRevisionFloor(conversation.id, this.getRevision(conversation.id));
|
|
3747
|
+
return;
|
|
3748
|
+
}
|
|
3749
|
+
const items = stored
|
|
3088
3750
|
.sort((a, b) => a.createdAt - b.createdAt)
|
|
3089
|
-
.slice(-MAX_TIMELINE_ITEMS)
|
|
3751
|
+
.slice(-MAX_TIMELINE_ITEMS);
|
|
3090
3752
|
this.timelines.set(conversation.id, items);
|
|
3091
3753
|
for (const item of items) this.rememberItemConversationId(conversation.id, item);
|
|
3754
|
+
this.setRevisionFloor(conversation.id, Math.max(items.length, ...items.map((item) => item.revision ?? 0)));
|
|
3092
3755
|
const lastMessage = [...items].reverse().find((item) => item.text?.trim());
|
|
3093
3756
|
if (lastMessage?.text && !conversation.lastMessagePreview) {
|
|
3094
3757
|
conversation.lastMessagePreview = previewText(lastMessage.text);
|
|
@@ -3156,6 +3819,8 @@ export class AgentWorkspaceProxy {
|
|
|
3156
3819
|
private rememberTurnConversationId(conversationId: string, turnId: string): void {
|
|
3157
3820
|
this.currentTurnIds.set(conversationId, turnId);
|
|
3158
3821
|
this.turnConversationIds.set(turnId, conversationId);
|
|
3822
|
+
const conversation = this.conversations.get(conversationId);
|
|
3823
|
+
if (conversation) conversation.runningTurnId = turnId;
|
|
3159
3824
|
}
|
|
3160
3825
|
|
|
3161
3826
|
private forgetCurrentTurn(conversationId: string, turnId?: string): void {
|
|
@@ -3163,6 +3828,8 @@ export class AgentWorkspaceProxy {
|
|
|
3163
3828
|
this.currentTurnIds.delete(conversationId);
|
|
3164
3829
|
if (turnId) this.turnConversationIds.delete(turnId);
|
|
3165
3830
|
if (currentTurnId && currentTurnId !== turnId) this.turnConversationIds.delete(currentTurnId);
|
|
3831
|
+
const conversation = this.conversations.get(conversationId);
|
|
3832
|
+
if (conversation) conversation.runningTurnId = undefined;
|
|
3166
3833
|
}
|
|
3167
3834
|
|
|
3168
3835
|
private rememberItemConversationId(conversationId: string, item: AgentTimelineItem): void {
|
|
@@ -3204,32 +3871,54 @@ export class AgentWorkspaceProxy {
|
|
|
3204
3871
|
|
|
3205
3872
|
private cancelPendingPermissions(conversationId?: string): void {
|
|
3206
3873
|
for (const [requestId, waiter] of this.permissionWaiters) {
|
|
3874
|
+
const ownerConversationId = this.conversationIdForPermissionRequest(requestId);
|
|
3875
|
+
if (conversationId && ownerConversationId && ownerConversationId !== conversationId) continue;
|
|
3207
3876
|
clearTimeout(waiter.timer);
|
|
3208
3877
|
waiter.resolve(formatPermissionResponse(
|
|
3209
3878
|
this.permissionSources.get(requestId),
|
|
3210
3879
|
"cancelled",
|
|
3211
3880
|
"cancelled",
|
|
3212
3881
|
));
|
|
3882
|
+
if (ownerConversationId) {
|
|
3883
|
+
this.markPermission(ownerConversationId, requestId, {
|
|
3884
|
+
permissionOutcome: "cancelled",
|
|
3885
|
+
optionId: "cancelled",
|
|
3886
|
+
permissionLive: false,
|
|
3887
|
+
permissionPending: false,
|
|
3888
|
+
permissionExpired: false,
|
|
3889
|
+
permissionError: "已停止",
|
|
3890
|
+
});
|
|
3891
|
+
}
|
|
3892
|
+
this.permissionWaiters.delete(requestId);
|
|
3213
3893
|
this.pendingPermissions.delete(requestId);
|
|
3214
3894
|
this.permissionSources.delete(requestId);
|
|
3215
3895
|
}
|
|
3216
|
-
this.permissionWaiters.clear();
|
|
3217
3896
|
for (const [requestId, waiter] of this.structuredInputWaiters) {
|
|
3897
|
+
const pending = this.pendingStructuredInputs.get(requestId);
|
|
3898
|
+
if (conversationId && pending?.conversationId && pending.conversationId !== conversationId) continue;
|
|
3218
3899
|
clearTimeout(waiter.timer);
|
|
3219
3900
|
waiter.resolve(formatStructuredInputResponse({}));
|
|
3220
|
-
const pending = this.pendingStructuredInputs.get(requestId);
|
|
3221
3901
|
if (pending) {
|
|
3222
3902
|
this.markStructuredInput(pending.conversationId, requestId, {
|
|
3223
3903
|
inputPending: false,
|
|
3224
3904
|
inputError: "已停止",
|
|
3225
3905
|
});
|
|
3226
3906
|
}
|
|
3907
|
+
this.structuredInputWaiters.delete(requestId);
|
|
3227
3908
|
this.pendingStructuredInputs.delete(requestId);
|
|
3228
3909
|
}
|
|
3229
|
-
this.structuredInputWaiters.clear();
|
|
3230
3910
|
if (conversationId) this.updateConversationStatus(conversationId, "idle");
|
|
3231
3911
|
}
|
|
3232
3912
|
|
|
3913
|
+
private conversationIdForPermissionRequest(requestId: string): string | undefined {
|
|
3914
|
+
for (const [conversationId, timeline] of this.timelines) {
|
|
3915
|
+
if (timeline.some((item) => item.type === "permission" && item.permission?.requestId === requestId)) {
|
|
3916
|
+
return conversationId;
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
return undefined;
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3233
3922
|
private extractSessionId(value: unknown): string | undefined {
|
|
3234
3923
|
const raw = asRecord(value);
|
|
3235
3924
|
if (!raw) return undefined;
|