linkshell-cli 0.3.13 → 0.3.15

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.
@@ -13,9 +13,43 @@ const PERMISSION_TIMEOUT_MS = 5 * 60_000;
13
13
  const MAX_TIMELINE_ITEMS = 200;
14
14
  const MAX_SNAPSHOT_ITEMS = 80;
15
15
  const MAX_SNAPSHOT_TEXT_BYTES = 128 * 1024;
16
+ const MAX_DELTA_EVENTS = 500;
17
+ const HISTORY_PAGE_MAX_ITEMS = 500;
16
18
  function id(prefix) {
17
19
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
18
20
  }
21
+ function clampHistoryCursor(value, fallback, max) {
22
+ if (!value)
23
+ return fallback;
24
+ const parsed = Number.parseInt(value, 10);
25
+ if (!Number.isFinite(parsed))
26
+ return fallback;
27
+ return Math.max(0, Math.min(max, parsed));
28
+ }
29
+ function appServerText(value) {
30
+ if (typeof value === "string") {
31
+ const text = value.trim();
32
+ return text || undefined;
33
+ }
34
+ if (Array.isArray(value)) {
35
+ const text = value
36
+ .map((part) => appServerText(part))
37
+ .filter(Boolean)
38
+ .join("\n")
39
+ .trim();
40
+ return text || undefined;
41
+ }
42
+ const record = asRecord(value);
43
+ if (!record)
44
+ return undefined;
45
+ if (typeof record.text === "string")
46
+ return appServerText(record.text);
47
+ if (typeof record.content === "string")
48
+ return appServerText(record.content);
49
+ if (typeof record.message === "string")
50
+ return appServerText(record.message);
51
+ return appServerText(record.content ?? record.message ?? record.parts);
52
+ }
19
53
  function stringify(value) {
20
54
  if (typeof value === "string")
21
55
  return value;
@@ -1119,15 +1153,33 @@ function parseRemoteSessions(value) {
1119
1153
  createdAt: parseTimestamp(source.createdAt ?? source.created_at),
1120
1154
  lastActivityAt: parseTimestamp(source.lastActivityAt ?? source.updatedAt ?? source.modifiedAt ?? source.lastModified ?? source.updated_at),
1121
1155
  archived: typeof source.archived === "boolean" ? source.archived : undefined,
1156
+ status: normalizeAgentStatus(firstString(source, ["status", "state", "phase"])),
1157
+ runningTurnId: firstString(source, ["runningTurnId", "running_turn_id", "turnId", "activeTurnId"]),
1122
1158
  });
1123
1159
  }
1124
1160
  return result;
1125
1161
  }
1162
+ function normalizeAgentStatus(value) {
1163
+ if (!value)
1164
+ return undefined;
1165
+ const normalized = value.toLowerCase();
1166
+ if (normalized === "running" || normalized === "in_progress" || normalized === "busy")
1167
+ return "running";
1168
+ if (normalized === "waiting_permission" || normalized === "waiting" || normalized === "blocked")
1169
+ return "waiting_permission";
1170
+ if (normalized === "error" || normalized === "failed")
1171
+ return "error";
1172
+ if (normalized === "idle" || normalized === "completed" || normalized === "done")
1173
+ return "idle";
1174
+ return undefined;
1175
+ }
1126
1176
  export class AgentWorkspaceProxy {
1127
1177
  input;
1128
1178
  clients = new Map();
1129
1179
  agentProtocols = new Map();
1130
1180
  providerCapabilities = new Map();
1181
+ providerCapabilityErrors = new Map();
1182
+ capabilitiesRevision = 0;
1131
1183
  initialized = false;
1132
1184
  status = "unavailable";
1133
1185
  error;
@@ -1137,6 +1189,8 @@ export class AgentWorkspaceProxy {
1137
1189
  conversations = new Map();
1138
1190
  conversationByAgentSessionId = new Map();
1139
1191
  timelines = new Map();
1192
+ conversationRevisions = new Map();
1193
+ revisionEvents = new Map();
1140
1194
  toolOutputBuffers = new Map();
1141
1195
  pendingPermissions = new Map();
1142
1196
  permissionWaiters = new Map();
@@ -1166,7 +1220,7 @@ export class AgentWorkspaceProxy {
1166
1220
  this.input.send(createEnvelope({
1167
1221
  type: "agent.v2.conversation.list.result",
1168
1222
  hostDeviceId: this.input.hostDeviceId,
1169
- payload: { conversations },
1223
+ payload: { conversations: conversations.map((conversation) => this.conversationSnapshot(conversation)) },
1170
1224
  }));
1171
1225
  break;
1172
1226
  }
@@ -1175,6 +1229,16 @@ export class AgentWorkspaceProxy {
1175
1229
  this.sendSnapshot(payload.conversationId);
1176
1230
  break;
1177
1231
  }
1232
+ case "agent.v2.history.request": {
1233
+ const payload = parseTypedPayload("agent.v2.history.request", envelope.payload);
1234
+ await this.sendHistoryPage(payload);
1235
+ break;
1236
+ }
1237
+ case "agent.v2.delta.request": {
1238
+ const payload = parseTypedPayload("agent.v2.delta.request", envelope.payload);
1239
+ this.sendDelta(payload);
1240
+ break;
1241
+ }
1178
1242
  case "agent.v2.prompt": {
1179
1243
  const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
1180
1244
  await this.sendPrompt(payload);
@@ -1334,10 +1398,13 @@ export class AgentWorkspaceProxy {
1334
1398
  try {
1335
1399
  const result = await listModels.call(client);
1336
1400
  const runtimeCapabilities = parseModelListCapabilities(result);
1337
- if (runtimeCapabilities)
1401
+ if (runtimeCapabilities) {
1338
1402
  this.providerCapabilities.set(provider, runtimeCapabilities);
1403
+ this.providerCapabilityErrors.delete(provider);
1404
+ }
1339
1405
  }
1340
1406
  catch (error) {
1407
+ this.providerCapabilityErrors.set(provider, error instanceof Error ? error.message : String(error));
1341
1408
  if (this.input.verbose) {
1342
1409
  process.stderr.write(`[agent:v2] model/list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
1343
1410
  }
@@ -1377,14 +1444,21 @@ export class AgentWorkspaceProxy {
1377
1444
  reasoningEffort: existing?.reasoningEffort,
1378
1445
  permissionMode: existing?.permissionMode,
1379
1446
  collaborationMode: existing?.collaborationMode,
1380
- status: existing?.status ?? "idle",
1447
+ status: remote.status ?? existing?.status ?? "idle",
1381
1448
  archived: remote.archived ?? existing?.archived ?? false,
1449
+ timelineRevision: existing?.timelineRevision ?? this.getRevision(conversationId),
1450
+ historyComplete: existing?.historyComplete ?? false,
1451
+ runningTurnId: remote.runningTurnId ?? this.currentTurnIds.get(conversationId),
1452
+ source: "device",
1453
+ canonical: true,
1382
1454
  lastMessagePreview: existing?.lastMessagePreview,
1383
1455
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
1384
1456
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
1385
1457
  };
1386
1458
  this.conversations.set(conversation.id, conversation);
1387
1459
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
1460
+ if (remote.runningTurnId)
1461
+ this.rememberTurnConversationId(conversation.id, remote.runningTurnId);
1388
1462
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1389
1463
  }
1390
1464
  }
@@ -1394,6 +1468,7 @@ export class AgentWorkspaceProxy {
1394
1468
  const protocol = this.agentProtocols.get(provider);
1395
1469
  const runtimeCapabilities = this.providerCapabilities.get(provider);
1396
1470
  const enabled = Boolean(client);
1471
+ const hasRuntimeModels = Boolean(runtimeCapabilities?.models?.length);
1397
1472
  const supportsImages = enabled && protocolSupportsImages(protocol);
1398
1473
  const isClaudeFallback = protocol === "claude-stream-json";
1399
1474
  const supportsPermission = enabled && !isClaudeFallback;
@@ -1405,12 +1480,15 @@ export class AgentWorkspaceProxy {
1405
1480
  label: providerLabel(provider),
1406
1481
  enabled,
1407
1482
  reason: enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`,
1483
+ providerProtocol: protocol,
1408
1484
  supportsImages,
1409
1485
  supportsPermission,
1410
1486
  supportsPlan: enabled,
1411
1487
  supportsCancel: enabled,
1412
1488
  models: runtimeCapabilities?.models ?? [{ id: "default", label: "默认模型" }],
1413
1489
  defaultModel: runtimeCapabilities?.defaultModel,
1490
+ modelsSource: hasRuntimeModels ? "runtime" : enabled ? "fallback" : "unavailable",
1491
+ modelListError: this.providerCapabilityErrors.get(provider),
1414
1492
  reasoningEfforts: supportsReasoningEffort
1415
1493
  ? runtimeCapabilities?.reasoningEfforts ?? (provider === "claude" ? [...CLAUDE_REASONING_EFFORTS] : [...ALL_REASONING_EFFORTS])
1416
1494
  : [],
@@ -1439,6 +1517,7 @@ export class AgentWorkspaceProxy {
1439
1517
  providers,
1440
1518
  protocolVersion: 1,
1441
1519
  workspaceProtocolVersion: 2,
1520
+ capabilitiesRevision: ++this.capabilitiesRevision,
1442
1521
  error: anyEnabled ? undefined : "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。",
1443
1522
  supportsSessionList: anyEnabled,
1444
1523
  supportsSessionLoad: anyEnabled,
@@ -1462,12 +1541,18 @@ export class AgentWorkspaceProxy {
1462
1541
  }
1463
1542
  this.hydrateStoredTimeline(existingConversation);
1464
1543
  this.activeConversationId = existingConversation.id;
1544
+ const snapshot = this.latestSnapshot(existingConversation.id);
1465
1545
  this.input.send(createEnvelope({
1466
1546
  type: "agent.v2.conversation.opened",
1467
1547
  hostDeviceId: this.input.hostDeviceId,
1468
1548
  payload: {
1469
- conversation: existingConversation,
1470
- snapshot: snapshotTimelineItems(this.timelines.get(existingConversation.id) ?? []),
1549
+ conversation: this.conversationSnapshot(existingConversation),
1550
+ snapshot: snapshot.items,
1551
+ revision: this.getRevision(existingConversation.id),
1552
+ cursor: snapshot.cursor,
1553
+ hasMore: snapshot.hasMore,
1554
+ source: "device-history",
1555
+ canonical: true,
1471
1556
  },
1472
1557
  }));
1473
1558
  return existingConversation;
@@ -1502,6 +1587,11 @@ export class AgentWorkspaceProxy {
1502
1587
  collaborationMode: payload.collaborationMode ?? existingConversation?.collaborationMode,
1503
1588
  status: "idle",
1504
1589
  archived: existingConversation?.archived ?? false,
1590
+ timelineRevision: existingConversation?.timelineRevision ?? this.getRevision(conversationId),
1591
+ historyComplete: existingConversation?.historyComplete ?? false,
1592
+ runningTurnId: this.currentTurnIds.get(conversationId),
1593
+ source: "device",
1594
+ canonical: true,
1505
1595
  lastMessagePreview: existingConversation?.status === "error" ? undefined : existingConversation?.lastMessagePreview,
1506
1596
  lastActivityAt: now,
1507
1597
  createdAt: existingConversation?.createdAt ?? now,
@@ -1511,10 +1601,19 @@ export class AgentWorkspaceProxy {
1511
1601
  this.activeConversationId = conversation.id;
1512
1602
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1513
1603
  this.hydrateStoredTimeline(conversation);
1604
+ const snapshot = this.latestSnapshot(conversation.id);
1514
1605
  this.input.send(createEnvelope({
1515
1606
  type: "agent.v2.conversation.opened",
1516
1607
  hostDeviceId: this.input.hostDeviceId,
1517
- payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1608
+ payload: {
1609
+ conversation: this.conversationSnapshot(conversation),
1610
+ snapshot: snapshot.items,
1611
+ revision: this.getRevision(conversation.id),
1612
+ cursor: snapshot.cursor,
1613
+ hasMore: snapshot.hasMore,
1614
+ source: "device-history",
1615
+ canonical: true,
1616
+ },
1518
1617
  }));
1519
1618
  return conversation;
1520
1619
  }
@@ -1537,6 +1636,10 @@ export class AgentWorkspaceProxy {
1537
1636
  collaborationMode: payload.collaborationMode,
1538
1637
  status: "error",
1539
1638
  archived: false,
1639
+ timelineRevision: this.getRevision(fallbackId),
1640
+ historyComplete: false,
1641
+ source: "device",
1642
+ canonical: true,
1540
1643
  lastMessagePreview: message,
1541
1644
  lastActivityAt: now,
1542
1645
  createdAt: now,
@@ -1553,7 +1656,14 @@ export class AgentWorkspaceProxy {
1553
1656
  this.input.send(createEnvelope({
1554
1657
  type: "agent.v2.conversation.opened",
1555
1658
  hostDeviceId: this.input.hostDeviceId,
1556
- payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1659
+ payload: {
1660
+ conversation: this.conversationSnapshot(conversation),
1661
+ snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []),
1662
+ revision: this.getRevision(conversation.id),
1663
+ hasMore: false,
1664
+ source: "device",
1665
+ canonical: true,
1666
+ },
1557
1667
  }));
1558
1668
  return conversation;
1559
1669
  }
@@ -2510,6 +2620,14 @@ export class AgentWorkspaceProxy {
2510
2620
  this.permissionWaiters.delete(requestId);
2511
2621
  this.permissionSources.delete(requestId);
2512
2622
  resolve(formatPermissionResponse(source, "cancelled", "cancelled"));
2623
+ this.markPermission(conversationId, requestId, {
2624
+ permissionOutcome: "cancelled",
2625
+ optionId: "cancelled",
2626
+ permissionLive: false,
2627
+ permissionPending: false,
2628
+ permissionExpired: true,
2629
+ permissionError: "等待授权超时",
2630
+ });
2513
2631
  this.updateConversationStatus(conversationId, "idle");
2514
2632
  }, PERMISSION_TIMEOUT_MS);
2515
2633
  this.permissionWaiters.set(requestId, { resolve, timer });
@@ -2539,6 +2657,8 @@ export class AgentWorkspaceProxy {
2539
2657
  this.markPermission(payload.conversationId, payload.requestId, {
2540
2658
  permissionOutcome: payload.outcome,
2541
2659
  optionId: selectedOptionId,
2660
+ permissionLive: false,
2661
+ permissionExpired: false,
2542
2662
  permissionError: undefined,
2543
2663
  permissionPending: false,
2544
2664
  });
@@ -2585,6 +2705,209 @@ export class AgentWorkspaceProxy {
2585
2705
  updatedAt: Date.now(),
2586
2706
  });
2587
2707
  }
2708
+ getRevision(conversationId) {
2709
+ return this.conversationRevisions.get(conversationId) ??
2710
+ this.conversations.get(conversationId)?.timelineRevision ??
2711
+ 0;
2712
+ }
2713
+ setRevisionFloor(conversationId, revision) {
2714
+ const nextRevision = Math.max(this.getRevision(conversationId), revision);
2715
+ this.conversationRevisions.set(conversationId, nextRevision);
2716
+ const conversation = this.conversations.get(conversationId);
2717
+ if (conversation) {
2718
+ conversation.timelineRevision = nextRevision;
2719
+ conversation.runningTurnId = this.currentTurnIds.get(conversationId);
2720
+ }
2721
+ return nextRevision;
2722
+ }
2723
+ conversationSnapshot(conversation) {
2724
+ return {
2725
+ ...conversation,
2726
+ timelineRevision: this.getRevision(conversation.id),
2727
+ historyComplete: conversation.historyComplete ?? false,
2728
+ runningTurnId: this.currentTurnIds.get(conversation.id),
2729
+ source: conversation.source ?? "device",
2730
+ canonical: conversation.canonical ?? true,
2731
+ };
2732
+ }
2733
+ annotateTimelineItem(item, revision, source) {
2734
+ return {
2735
+ ...item,
2736
+ revision: revision ?? item.revision,
2737
+ source: item.source ?? source,
2738
+ canonical: item.canonical ?? true,
2739
+ };
2740
+ }
2741
+ recordRevisionEvent(conversationId, change) {
2742
+ const revision = this.getRevision(conversationId) + 1;
2743
+ this.conversationRevisions.set(conversationId, revision);
2744
+ const conversation = this.conversations.get(conversationId);
2745
+ if (conversation) {
2746
+ conversation.timelineRevision = revision;
2747
+ conversation.runningTurnId = this.currentTurnIds.get(conversationId);
2748
+ }
2749
+ const event = {
2750
+ revision,
2751
+ item: change.item ? this.annotateTimelineItem(change.item, revision, "device-live") : undefined,
2752
+ conversation: change.conversation ? this.conversationSnapshot(change.conversation) : undefined,
2753
+ };
2754
+ const events = this.revisionEvents.get(conversationId) ?? [];
2755
+ events.push(event);
2756
+ if (events.length > MAX_DELTA_EVENTS) {
2757
+ events.splice(0, events.length - MAX_DELTA_EVENTS);
2758
+ }
2759
+ this.revisionEvents.set(conversationId, events);
2760
+ return event;
2761
+ }
2762
+ storedTimeline(conversation, maxItems = HISTORY_PAGE_MAX_ITEMS) {
2763
+ if (!conversation.agentSessionId)
2764
+ return [];
2765
+ const result = conversation.provider === "codex"
2766
+ ? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd, { maxItems })
2767
+ : conversation.provider === "claude"
2768
+ ? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id, { maxItems })
2769
+ : { items: [] };
2770
+ return result.items
2771
+ .sort((a, b) => a.createdAt - b.createdAt)
2772
+ .map((item, index) => this.annotateTimelineItem(item, index + 1, "device-history"));
2773
+ }
2774
+ canonicalTimeline(conversation, maxItems = HISTORY_PAGE_MAX_ITEMS) {
2775
+ const merged = new Map();
2776
+ for (const item of this.storedTimeline(conversation, maxItems)) {
2777
+ merged.set(item.id, item);
2778
+ }
2779
+ for (const item of this.timelines.get(conversation.id) ?? []) {
2780
+ merged.set(item.id, this.annotateTimelineItem(item, item.revision, item.source ?? "device-live"));
2781
+ }
2782
+ const items = [...merged.values()]
2783
+ .sort((a, b) => a.createdAt - b.createdAt)
2784
+ .slice(-maxItems);
2785
+ this.setRevisionFloor(conversation.id, Math.max(this.getRevision(conversation.id), items.length));
2786
+ return items;
2787
+ }
2788
+ async appServerTimeline(conversation, maxItems = HISTORY_PAGE_MAX_ITEMS) {
2789
+ if (conversation.provider !== "codex" || !conversation.agentSessionId)
2790
+ return [];
2791
+ if (this.protocolForProvider(conversation.provider) !== "codex-app-server")
2792
+ return [];
2793
+ const client = this.clientForProvider(conversation.provider);
2794
+ const listTurns = client?.listTurns;
2795
+ if (typeof listTurns !== "function")
2796
+ return [];
2797
+ try {
2798
+ const result = await listTurns.call(client, { sessionId: conversation.agentSessionId, limit: maxItems });
2799
+ return this.timelineFromAppServerTurns(conversation.id, conversation.agentSessionId, result);
2800
+ }
2801
+ catch (error) {
2802
+ if (this.input.verbose) {
2803
+ process.stderr.write(`[agent:v2] thread/turns/list failed: ${error instanceof Error ? error.message : String(error)}\n`);
2804
+ }
2805
+ return [];
2806
+ }
2807
+ }
2808
+ timelineFromAppServerTurns(conversationId, agentSessionId, value) {
2809
+ const raw = asRecord(value);
2810
+ const turns = Array.isArray(value) ? value :
2811
+ Array.isArray(raw?.turns) ? raw.turns :
2812
+ Array.isArray(raw?.items) ? raw.items :
2813
+ Array.isArray(raw?.entries) ? raw.entries :
2814
+ [];
2815
+ const items = [];
2816
+ turns.forEach((entry, index) => {
2817
+ const turn = asRecord(entry);
2818
+ if (!turn)
2819
+ return;
2820
+ const turnId = firstString(turn, ["id", "turnId", "turn_id"]) ?? `turn-${index + 1}`;
2821
+ const createdAt = parseTimestamp(turn.createdAt ?? turn.created_at ?? turn.startedAt ?? turn.started_at) ?? Date.now() + index;
2822
+ const updatedAt = parseTimestamp(turn.updatedAt ?? turn.updated_at ?? turn.completedAt ?? turn.completed_at);
2823
+ const userText = appServerText(turn.input ?? turn.prompt ?? turn.user ?? turn.userMessage ?? turn.request);
2824
+ if (userText) {
2825
+ items.push({
2826
+ id: `app-server:${agentSessionId}:${turnId}:user`,
2827
+ conversationId,
2828
+ type: "message",
2829
+ kind: "chat",
2830
+ turnId,
2831
+ role: "user",
2832
+ content: [{ type: "text", text: userText }],
2833
+ text: userText,
2834
+ createdAt,
2835
+ updatedAt,
2836
+ metadata: { source: "app-server", provider: "codex" },
2837
+ });
2838
+ }
2839
+ const assistantText = appServerText(turn.output ?? turn.response ?? turn.assistant ?? turn.assistantMessage ?? turn.result);
2840
+ if (assistantText) {
2841
+ items.push({
2842
+ id: `app-server:${agentSessionId}:${turnId}:assistant`,
2843
+ conversationId,
2844
+ type: "message",
2845
+ kind: "chat",
2846
+ turnId,
2847
+ role: "assistant",
2848
+ content: [{ type: "text", text: assistantText }],
2849
+ text: assistantText,
2850
+ createdAt: updatedAt ?? createdAt + 1,
2851
+ updatedAt,
2852
+ metadata: { source: "app-server", provider: "codex" },
2853
+ });
2854
+ }
2855
+ const nestedItems = Array.isArray(turn.items) ? turn.items : Array.isArray(turn.messages) ? turn.messages : [];
2856
+ for (const nested of nestedItems) {
2857
+ const nestedRecord = asRecord(nested);
2858
+ if (!nestedRecord)
2859
+ continue;
2860
+ const text = appServerText(nestedRecord.content ?? nestedRecord.text ?? nestedRecord.message);
2861
+ const role = nestedRecord.role === "user" || nestedRecord.role === "assistant" || nestedRecord.role === "system"
2862
+ ? nestedRecord.role
2863
+ : undefined;
2864
+ if (!text || !role)
2865
+ continue;
2866
+ const itemId = firstString(nestedRecord, ["id", "itemId", "messageId"]) ?? `${role}-${items.length + 1}`;
2867
+ items.push({
2868
+ id: `app-server:${agentSessionId}:${turnId}:${itemId}`,
2869
+ conversationId,
2870
+ type: "message",
2871
+ kind: "chat",
2872
+ turnId,
2873
+ itemId,
2874
+ role,
2875
+ content: [{ type: "text", text }],
2876
+ text,
2877
+ createdAt: parseTimestamp(nestedRecord.createdAt ?? nestedRecord.created_at) ?? createdAt + items.length,
2878
+ updatedAt: parseTimestamp(nestedRecord.updatedAt ?? nestedRecord.updated_at),
2879
+ metadata: { source: "app-server", provider: "codex" },
2880
+ });
2881
+ }
2882
+ });
2883
+ return items
2884
+ .sort((a, b) => a.createdAt - b.createdAt)
2885
+ .map((item, index) => this.annotateTimelineItem(item, index + 1, "app-server"));
2886
+ }
2887
+ mergeCanonicalTimelineItems(items, maxItems) {
2888
+ const byKey = new Map();
2889
+ for (const item of items.sort((a, b) => a.createdAt - b.createdAt)) {
2890
+ const key = item.type === "message" && item.role && item.text
2891
+ ? `${item.role}:${item.text.replace(/\s+/g, " ").trim().slice(0, 500)}`
2892
+ : item.id;
2893
+ const existing = byKey.get(key);
2894
+ if (!existing || item.source === "app-server" || (item.updatedAt ?? item.createdAt) >= (existing.updatedAt ?? existing.createdAt)) {
2895
+ byKey.set(key, item);
2896
+ }
2897
+ }
2898
+ return [...byKey.values()]
2899
+ .sort((a, b) => a.createdAt - b.createdAt)
2900
+ .slice(-maxItems);
2901
+ }
2902
+ latestSnapshot(conversationId) {
2903
+ const timeline = this.timelines.get(conversationId) ?? [];
2904
+ const start = Math.max(0, timeline.length - MAX_SNAPSHOT_ITEMS);
2905
+ return {
2906
+ items: timeline.slice(start).map((item) => snapshotTimelineItem(item)),
2907
+ cursor: start > 0 ? String(start) : undefined,
2908
+ hasMore: start > 0,
2909
+ };
2910
+ }
2588
2911
  addItem(conversationId, item) {
2589
2912
  this.rememberItemConversationId(conversationId, item);
2590
2913
  const timeline = this.timelines.get(conversationId) ?? [];
@@ -2702,6 +3025,17 @@ export class AgentWorkspaceProxy {
2702
3025
  this.timelines.set(newId, [...mergedTimeline.values()]
2703
3026
  .sort((a, b) => a.createdAt - b.createdAt)
2704
3027
  .slice(-MAX_TIMELINE_ITEMS));
3028
+ const oldRevision = this.conversationRevisions.get(oldId) ?? 0;
3029
+ if (oldRevision > 0) {
3030
+ this.conversationRevisions.delete(oldId);
3031
+ this.setRevisionFloor(newId, oldRevision);
3032
+ }
3033
+ const oldEvents = this.revisionEvents.get(oldId);
3034
+ if (oldEvents) {
3035
+ this.revisionEvents.delete(oldId);
3036
+ const existingEvents = this.revisionEvents.get(newId) ?? [];
3037
+ this.revisionEvents.set(newId, [...existingEvents, ...oldEvents].slice(-MAX_DELTA_EVENTS));
3038
+ }
2705
3039
  for (const [agentSessionId, conversationId] of this.conversationByAgentSessionId) {
2706
3040
  if (conversationId === oldId) {
2707
3041
  this.conversationByAgentSessionId.set(agentSessionId, newId);
@@ -2735,17 +3069,47 @@ export class AgentWorkspaceProxy {
2735
3069
  }
2736
3070
  emitItem(conversationId, item) {
2737
3071
  const conversation = this.conversations.get(conversationId);
3072
+ const itemSnapshot = snapshotTimelineItem(item, { stripImages: false });
3073
+ const event = this.recordRevisionEvent(conversationId, { conversation, item: itemSnapshot });
2738
3074
  this.input.send(createEnvelope({
2739
3075
  type: "agent.v2.event",
2740
3076
  hostDeviceId: this.input.hostDeviceId,
2741
- payload: { conversationId, conversation, item: snapshotTimelineItem(item, { stripImages: false }) },
3077
+ payload: {
3078
+ conversationId,
3079
+ conversation: event.conversation,
3080
+ item: event.item,
3081
+ revision: event.revision,
3082
+ source: "device-live",
3083
+ canonical: true,
3084
+ },
2742
3085
  }));
2743
3086
  }
2744
3087
  emitConversation(conversation) {
3088
+ const event = this.recordRevisionEvent(conversation.id, { conversation });
2745
3089
  this.input.send(createEnvelope({
2746
3090
  type: "agent.v2.event",
2747
3091
  hostDeviceId: this.input.hostDeviceId,
2748
- payload: { conversationId: conversation.id, conversation },
3092
+ payload: {
3093
+ conversationId: conversation.id,
3094
+ conversation: event.conversation,
3095
+ revision: event.revision,
3096
+ source: "device-live",
3097
+ canonical: true,
3098
+ },
3099
+ }));
3100
+ this.input.send(createEnvelope({
3101
+ type: "agent.v2.running_state",
3102
+ hostDeviceId: this.input.hostDeviceId,
3103
+ payload: {
3104
+ conversationId: conversation.id,
3105
+ status: conversation.status,
3106
+ runningTurnId: this.currentTurnIds.get(conversation.id),
3107
+ revision: event.revision,
3108
+ error: conversation.status === "error" ? conversation.lastMessagePreview : undefined,
3109
+ updatedAt: conversation.lastActivityAt,
3110
+ source: "device-live",
3111
+ canonical: true,
3112
+ },
2749
3113
  }));
2750
3114
  }
2751
3115
  emitStatus(conversationId, status, text) {
@@ -2789,17 +3153,126 @@ export class AgentWorkspaceProxy {
2789
3153
  if (conversation)
2790
3154
  this.hydrateStoredTimeline(conversation);
2791
3155
  }
2792
- const conversations = [...this.conversations.values()];
2793
- const items = conversationId
2794
- ? snapshotTimelineItems(this.timelines.get(conversationId) ?? [])
2795
- : [];
3156
+ const conversations = [...this.conversations.values()].map((conversation) => this.conversationSnapshot(conversation));
3157
+ const snapshot = conversationId ? this.latestSnapshot(conversationId) : { items: [], cursor: undefined, hasMore: false };
2796
3158
  this.input.send(createEnvelope({
2797
3159
  type: "agent.v2.snapshot",
2798
3160
  hostDeviceId: this.input.hostDeviceId,
2799
3161
  payload: {
2800
3162
  conversations,
2801
3163
  activeConversationId: this.activeConversationId,
2802
- items,
3164
+ items: snapshot.items,
3165
+ revision: conversationId ? this.getRevision(conversationId) : undefined,
3166
+ cursor: snapshot.cursor,
3167
+ hasMore: snapshot.hasMore,
3168
+ source: "device-history",
3169
+ canonical: true,
3170
+ },
3171
+ }));
3172
+ }
3173
+ async sendHistoryPage(payload) {
3174
+ const conversation = this.conversations.get(payload.conversationId);
3175
+ if (!conversation)
3176
+ return;
3177
+ const limit = Math.min(Math.max(payload.limit ?? MAX_SNAPSHOT_ITEMS, 1), 200);
3178
+ const items = this.mergeCanonicalTimelineItems([
3179
+ ...this.canonicalTimeline(conversation, HISTORY_PAGE_MAX_ITEMS),
3180
+ ...await this.appServerTimeline(conversation, HISTORY_PAGE_MAX_ITEMS),
3181
+ ], HISTORY_PAGE_MAX_ITEMS);
3182
+ this.timelines.set(conversation.id, items.slice(-MAX_TIMELINE_ITEMS).map((item) => this.annotateTimelineItem(item, item.revision, item.source ?? "device-history")));
3183
+ for (const item of this.timelines.get(conversation.id) ?? []) {
3184
+ this.rememberItemConversationId(conversation.id, item);
3185
+ }
3186
+ const direction = payload.direction ?? "older";
3187
+ let page;
3188
+ let cursor;
3189
+ let hasMore = false;
3190
+ if (direction === "newer") {
3191
+ const start = clampHistoryCursor(payload.cursor, 0, items.length);
3192
+ const end = Math.min(items.length, start + limit);
3193
+ page = items.slice(start, end);
3194
+ hasMore = end < items.length;
3195
+ cursor = hasMore ? String(end) : undefined;
3196
+ }
3197
+ else {
3198
+ const end = clampHistoryCursor(payload.cursor, items.length, items.length);
3199
+ const start = Math.max(0, end - limit);
3200
+ page = items.slice(start, end);
3201
+ hasMore = start > 0;
3202
+ cursor = hasMore ? String(start) : undefined;
3203
+ }
3204
+ conversation.historyComplete = !hasMore;
3205
+ const revision = this.setRevisionFloor(conversation.id, Math.max(this.getRevision(conversation.id), items.length));
3206
+ this.input.send(createEnvelope({
3207
+ type: "agent.v2.history.page",
3208
+ hostDeviceId: this.input.hostDeviceId,
3209
+ payload: {
3210
+ conversationId: conversation.id,
3211
+ conversation: this.conversationSnapshot(conversation),
3212
+ items: page.map((item) => snapshotTimelineItem(item)),
3213
+ revision,
3214
+ cursor,
3215
+ hasMore,
3216
+ source: "device-history",
3217
+ canonical: true,
3218
+ },
3219
+ }));
3220
+ }
3221
+ sendDelta(payload) {
3222
+ const conversation = this.conversations.get(payload.conversationId);
3223
+ if (!conversation)
3224
+ return;
3225
+ this.hydrateStoredTimeline(conversation);
3226
+ const sinceRevision = payload.sinceRevision ?? 0;
3227
+ const limit = Math.min(Math.max(payload.limit ?? 100, 1), 500);
3228
+ const events = this.revisionEvents.get(conversation.id) ?? [];
3229
+ const oldestAvailable = events[0]?.revision ?? this.getRevision(conversation.id);
3230
+ const newestRevision = this.getRevision(conversation.id);
3231
+ const reset = sinceRevision > 0 &&
3232
+ (sinceRevision > newestRevision ||
3233
+ (sinceRevision < newestRevision &&
3234
+ (events.length === 0 || sinceRevision < oldestAvailable - 1)));
3235
+ if (reset) {
3236
+ const snapshot = this.latestSnapshot(conversation.id);
3237
+ this.input.send(createEnvelope({
3238
+ type: "agent.v2.delta",
3239
+ hostDeviceId: this.input.hostDeviceId,
3240
+ payload: {
3241
+ conversationId: conversation.id,
3242
+ conversation: this.conversationSnapshot(conversation),
3243
+ items: snapshot.items,
3244
+ sinceRevision,
3245
+ revision: newestRevision,
3246
+ reset: true,
3247
+ cursor: snapshot.cursor,
3248
+ hasMore: snapshot.hasMore,
3249
+ source: "device-history",
3250
+ canonical: true,
3251
+ },
3252
+ }));
3253
+ return;
3254
+ }
3255
+ const changed = events
3256
+ .filter((event) => event.revision > sinceRevision)
3257
+ .slice(-limit);
3258
+ const itemsById = new Map();
3259
+ for (const event of changed) {
3260
+ if (event.item)
3261
+ itemsById.set(event.item.id, event.item);
3262
+ }
3263
+ this.input.send(createEnvelope({
3264
+ type: "agent.v2.delta",
3265
+ hostDeviceId: this.input.hostDeviceId,
3266
+ payload: {
3267
+ conversationId: conversation.id,
3268
+ conversation: this.conversationSnapshot(conversation),
3269
+ items: [...itemsById.values()].map((item) => snapshotTimelineItem(item)),
3270
+ sinceRevision,
3271
+ revision: newestRevision,
3272
+ reset: false,
3273
+ hasMore: changed.length === limit && events.some((event) => event.revision > sinceRevision && event.revision < changed[0].revision),
3274
+ source: "device-live",
3275
+ canonical: true,
2803
3276
  },
2804
3277
  }));
2805
3278
  }
@@ -2807,21 +3280,23 @@ export class AgentWorkspaceProxy {
2807
3280
  if (!conversation.agentSessionId)
2808
3281
  return;
2809
3282
  const existing = this.timelines.get(conversation.id) ?? [];
2810
- if (existing.length > 0)
3283
+ if (existing.length > 0) {
3284
+ this.setRevisionFloor(conversation.id, Math.max(existing.length, ...existing.map((item) => item.revision ?? 0)));
2811
3285
  return;
2812
- const result = conversation.provider === "codex"
2813
- ? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd)
2814
- : conversation.provider === "claude"
2815
- ? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id)
2816
- : { items: [] };
2817
- if (result.items.length === 0)
3286
+ }
3287
+ const stored = this.storedTimeline(conversation, MAX_TIMELINE_ITEMS);
3288
+ conversation.historyComplete = stored.length <= MAX_SNAPSHOT_ITEMS;
3289
+ if (stored.length === 0) {
3290
+ this.setRevisionFloor(conversation.id, this.getRevision(conversation.id));
2818
3291
  return;
2819
- const items = result.items
3292
+ }
3293
+ const items = stored
2820
3294
  .sort((a, b) => a.createdAt - b.createdAt)
2821
3295
  .slice(-MAX_TIMELINE_ITEMS);
2822
3296
  this.timelines.set(conversation.id, items);
2823
3297
  for (const item of items)
2824
3298
  this.rememberItemConversationId(conversation.id, item);
3299
+ this.setRevisionFloor(conversation.id, Math.max(items.length, ...items.map((item) => item.revision ?? 0)));
2825
3300
  const lastMessage = [...items].reverse().find((item) => item.text?.trim());
2826
3301
  if (lastMessage?.text && !conversation.lastMessagePreview) {
2827
3302
  conversation.lastMessagePreview = previewText(lastMessage.text);
@@ -2890,6 +3365,9 @@ export class AgentWorkspaceProxy {
2890
3365
  rememberTurnConversationId(conversationId, turnId) {
2891
3366
  this.currentTurnIds.set(conversationId, turnId);
2892
3367
  this.turnConversationIds.set(turnId, conversationId);
3368
+ const conversation = this.conversations.get(conversationId);
3369
+ if (conversation)
3370
+ conversation.runningTurnId = turnId;
2893
3371
  }
2894
3372
  forgetCurrentTurn(conversationId, turnId) {
2895
3373
  const currentTurnId = this.currentTurnIds.get(conversationId);
@@ -2898,6 +3376,9 @@ export class AgentWorkspaceProxy {
2898
3376
  this.turnConversationIds.delete(turnId);
2899
3377
  if (currentTurnId && currentTurnId !== turnId)
2900
3378
  this.turnConversationIds.delete(currentTurnId);
3379
+ const conversation = this.conversations.get(conversationId);
3380
+ if (conversation)
3381
+ conversation.runningTurnId = undefined;
2901
3382
  }
2902
3383
  rememberItemConversationId(conversationId, item) {
2903
3384
  const keys = [
@@ -2937,28 +3418,51 @@ export class AgentWorkspaceProxy {
2937
3418
  }
2938
3419
  cancelPendingPermissions(conversationId) {
2939
3420
  for (const [requestId, waiter] of this.permissionWaiters) {
3421
+ const ownerConversationId = this.conversationIdForPermissionRequest(requestId);
3422
+ if (conversationId && ownerConversationId && ownerConversationId !== conversationId)
3423
+ continue;
2940
3424
  clearTimeout(waiter.timer);
2941
3425
  waiter.resolve(formatPermissionResponse(this.permissionSources.get(requestId), "cancelled", "cancelled"));
3426
+ if (ownerConversationId) {
3427
+ this.markPermission(ownerConversationId, requestId, {
3428
+ permissionOutcome: "cancelled",
3429
+ optionId: "cancelled",
3430
+ permissionLive: false,
3431
+ permissionPending: false,
3432
+ permissionExpired: false,
3433
+ permissionError: "已停止",
3434
+ });
3435
+ }
3436
+ this.permissionWaiters.delete(requestId);
2942
3437
  this.pendingPermissions.delete(requestId);
2943
3438
  this.permissionSources.delete(requestId);
2944
3439
  }
2945
- this.permissionWaiters.clear();
2946
3440
  for (const [requestId, waiter] of this.structuredInputWaiters) {
3441
+ const pending = this.pendingStructuredInputs.get(requestId);
3442
+ if (conversationId && pending?.conversationId && pending.conversationId !== conversationId)
3443
+ continue;
2947
3444
  clearTimeout(waiter.timer);
2948
3445
  waiter.resolve(formatStructuredInputResponse({}));
2949
- const pending = this.pendingStructuredInputs.get(requestId);
2950
3446
  if (pending) {
2951
3447
  this.markStructuredInput(pending.conversationId, requestId, {
2952
3448
  inputPending: false,
2953
3449
  inputError: "已停止",
2954
3450
  });
2955
3451
  }
3452
+ this.structuredInputWaiters.delete(requestId);
2956
3453
  this.pendingStructuredInputs.delete(requestId);
2957
3454
  }
2958
- this.structuredInputWaiters.clear();
2959
3455
  if (conversationId)
2960
3456
  this.updateConversationStatus(conversationId, "idle");
2961
3457
  }
3458
+ conversationIdForPermissionRequest(requestId) {
3459
+ for (const [conversationId, timeline] of this.timelines) {
3460
+ if (timeline.some((item) => item.type === "permission" && item.permission?.requestId === requestId)) {
3461
+ return conversationId;
3462
+ }
3463
+ }
3464
+ return undefined;
3465
+ }
2962
3466
  extractSessionId(value) {
2963
3467
  const raw = asRecord(value);
2964
3468
  if (!raw)