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.
@@ -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: snapshotTimelineItems(this.timelines.get(existingConversation.id) ?? []),
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: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
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: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
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 (conversation.provider !== "codex") {
1989
- this.addItem(conversation.id, {
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: { conversationId, conversation, item: snapshotTimelineItem(item, { stripImages: false }) },
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: { conversationId: conversation.id, conversation },
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 items = conversationId
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) return;
3081
- const result = conversation.provider === "codex"
3082
- ? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd)
3083
- : conversation.provider === "claude"
3084
- ? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id)
3085
- : { items: [] };
3086
- if (result.items.length === 0) return;
3087
- const items = result.items
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) as AgentTimelineItem[];
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;