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.
@@ -20,6 +20,7 @@ type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
20
20
  type AgentCollaborationMode = "default" | "plan";
21
21
  type AgentCommandExecutionKind = "prompt" | "native" | "local_ui";
22
22
  type AgentCommandSource = "built_in" | "custom" | "project" | "user" | "linkshell";
23
+ type AgentSyncSource = "device" | "device-history" | "device-live" | "app-server" | "cache";
23
24
 
24
25
  interface AgentContentBlock {
25
26
  type: "text" | "image";
@@ -165,6 +166,11 @@ interface AgentConversation {
165
166
  collaborationMode?: AgentCollaborationMode;
166
167
  status: AgentStatus;
167
168
  archived: boolean;
169
+ timelineRevision?: number;
170
+ historyComplete?: boolean;
171
+ runningTurnId?: string;
172
+ source?: AgentSyncSource;
173
+ canonical?: boolean;
168
174
  lastMessagePreview?: string;
169
175
  lastActivityAt: number;
170
176
  createdAt: number;
@@ -175,6 +181,9 @@ interface AgentTimelineItem {
175
181
  conversationId: string;
176
182
  type: "message" | "tool_call" | "plan" | "permission" | "status" | "error";
177
183
  kind?: AgentTimelineKind;
184
+ revision?: number;
185
+ source?: AgentSyncSource;
186
+ canonical?: boolean;
178
187
  turnId?: string;
179
188
  itemId?: string;
180
189
  role?: "user" | "assistant" | "system";
@@ -206,15 +215,51 @@ interface PendingStructuredInputWaiter {
206
215
  source?: string;
207
216
  }
208
217
 
218
+ interface AgentRevisionEvent {
219
+ revision: number;
220
+ item?: AgentTimelineItem;
221
+ conversation?: AgentConversation;
222
+ }
223
+
209
224
  const PERMISSION_TIMEOUT_MS = 5 * 60_000;
210
225
  const MAX_TIMELINE_ITEMS = 200;
211
226
  const MAX_SNAPSHOT_ITEMS = 80;
212
227
  const MAX_SNAPSHOT_TEXT_BYTES = 128 * 1024;
228
+ const MAX_DELTA_EVENTS = 500;
229
+ const HISTORY_PAGE_MAX_ITEMS = 500;
213
230
 
214
231
  function id(prefix: string): string {
215
232
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
216
233
  }
217
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
+
218
263
  function stringify(value: unknown): string {
219
264
  if (typeof value === "string") return value;
220
265
  try {
@@ -1346,6 +1391,8 @@ function parseRemoteSessions(value: unknown): Array<{
1346
1391
  createdAt?: number;
1347
1392
  lastActivityAt?: number;
1348
1393
  archived?: boolean;
1394
+ status?: AgentStatus;
1395
+ runningTurnId?: string;
1349
1396
  }> {
1350
1397
  const raw = asRecord(value);
1351
1398
  const sessionsValue =
@@ -1362,6 +1409,8 @@ function parseRemoteSessions(value: unknown): Array<{
1362
1409
  createdAt?: number;
1363
1410
  lastActivityAt?: number;
1364
1411
  archived?: boolean;
1412
+ status?: AgentStatus;
1413
+ runningTurnId?: string;
1365
1414
  }> = [];
1366
1415
  for (const entry of sessionsValue) {
1367
1416
  const session = asRecord(entry);
@@ -1381,15 +1430,29 @@ function parseRemoteSessions(value: unknown): Array<{
1381
1430
  createdAt: parseTimestamp(source.createdAt ?? source.created_at),
1382
1431
  lastActivityAt: parseTimestamp(source.lastActivityAt ?? source.updatedAt ?? source.modifiedAt ?? source.lastModified ?? source.updated_at),
1383
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"]),
1384
1435
  });
1385
1436
  }
1386
1437
  return result;
1387
1438
  }
1388
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
+
1389
1450
  export class AgentWorkspaceProxy {
1390
1451
  private clients = new Map<AgentProvider, AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient>();
1391
1452
  private agentProtocols = new Map<AgentProvider, AgentProtocol>();
1392
1453
  private providerCapabilities = new Map<AgentProvider, ProviderRuntimeCapabilities>();
1454
+ private providerCapabilityErrors = new Map<AgentProvider, string>();
1455
+ private capabilitiesRevision = 0;
1393
1456
  private initialized = false;
1394
1457
  private status: AgentStatus = "unavailable";
1395
1458
  private error: string | undefined;
@@ -1399,6 +1462,8 @@ export class AgentWorkspaceProxy {
1399
1462
  private conversations = new Map<string, AgentConversation>();
1400
1463
  private conversationByAgentSessionId = new Map<string, string>();
1401
1464
  private timelines = new Map<string, AgentTimelineItem[]>();
1465
+ private conversationRevisions = new Map<string, number>();
1466
+ private revisionEvents = new Map<string, AgentRevisionEvent[]>();
1402
1467
  private toolOutputBuffers = new Map<string, string>();
1403
1468
  private pendingPermissions = new Map<string, AgentPermission>();
1404
1469
  private permissionWaiters = new Map<string, PendingPermissionWaiter>();
@@ -1439,7 +1504,7 @@ export class AgentWorkspaceProxy {
1439
1504
  this.input.send(createEnvelope({
1440
1505
  type: "agent.v2.conversation.list.result",
1441
1506
  hostDeviceId: this.input.hostDeviceId,
1442
- payload: { conversations },
1507
+ payload: { conversations: conversations.map((conversation) => this.conversationSnapshot(conversation)) },
1443
1508
  }));
1444
1509
  break;
1445
1510
  }
@@ -1448,6 +1513,16 @@ export class AgentWorkspaceProxy {
1448
1513
  this.sendSnapshot(payload.conversationId);
1449
1514
  break;
1450
1515
  }
1516
+ case "agent.v2.history.request": {
1517
+ const payload = parseTypedPayload("agent.v2.history.request", envelope.payload);
1518
+ await this.sendHistoryPage(payload);
1519
+ break;
1520
+ }
1521
+ case "agent.v2.delta.request": {
1522
+ const payload = parseTypedPayload("agent.v2.delta.request", envelope.payload);
1523
+ this.sendDelta(payload);
1524
+ break;
1525
+ }
1451
1526
  case "agent.v2.prompt": {
1452
1527
  const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
1453
1528
  await this.sendPrompt(payload);
@@ -1614,8 +1689,12 @@ export class AgentWorkspaceProxy {
1614
1689
  try {
1615
1690
  const result = await listModels.call(client);
1616
1691
  const runtimeCapabilities = parseModelListCapabilities(result);
1617
- if (runtimeCapabilities) this.providerCapabilities.set(provider, runtimeCapabilities);
1692
+ if (runtimeCapabilities) {
1693
+ this.providerCapabilities.set(provider, runtimeCapabilities);
1694
+ this.providerCapabilityErrors.delete(provider);
1695
+ }
1618
1696
  } catch (error) {
1697
+ this.providerCapabilityErrors.set(provider, error instanceof Error ? error.message : String(error));
1619
1698
  if (this.input.verbose) {
1620
1699
  process.stderr.write(`[agent:v2] model/list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
1621
1700
  }
@@ -1656,14 +1735,20 @@ export class AgentWorkspaceProxy {
1656
1735
  reasoningEffort: existing?.reasoningEffort,
1657
1736
  permissionMode: existing?.permissionMode,
1658
1737
  collaborationMode: existing?.collaborationMode,
1659
- status: existing?.status ?? "idle",
1738
+ status: remote.status ?? existing?.status ?? "idle",
1660
1739
  archived: remote.archived ?? existing?.archived ?? false,
1740
+ timelineRevision: existing?.timelineRevision ?? this.getRevision(conversationId),
1741
+ historyComplete: existing?.historyComplete ?? false,
1742
+ runningTurnId: remote.runningTurnId ?? this.currentTurnIds.get(conversationId),
1743
+ source: "device",
1744
+ canonical: true,
1661
1745
  lastMessagePreview: existing?.lastMessagePreview,
1662
1746
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
1663
1747
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
1664
1748
  };
1665
1749
  this.conversations.set(conversation.id, conversation);
1666
1750
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
1751
+ if (remote.runningTurnId) this.rememberTurnConversationId(conversation.id, remote.runningTurnId);
1667
1752
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1668
1753
  }
1669
1754
  }
@@ -1674,6 +1759,7 @@ export class AgentWorkspaceProxy {
1674
1759
  const protocol = this.agentProtocols.get(provider);
1675
1760
  const runtimeCapabilities = this.providerCapabilities.get(provider);
1676
1761
  const enabled = Boolean(client);
1762
+ const hasRuntimeModels = Boolean(runtimeCapabilities?.models?.length);
1677
1763
  const supportsImages = enabled && protocolSupportsImages(protocol);
1678
1764
  const isClaudeFallback = protocol === "claude-stream-json";
1679
1765
  const supportsPermission = enabled && !isClaudeFallback;
@@ -1688,12 +1774,15 @@ export class AgentWorkspaceProxy {
1688
1774
  label: providerLabel(provider),
1689
1775
  enabled,
1690
1776
  reason: enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`,
1777
+ providerProtocol: protocol,
1691
1778
  supportsImages,
1692
1779
  supportsPermission,
1693
1780
  supportsPlan: enabled,
1694
1781
  supportsCancel: enabled,
1695
1782
  models: runtimeCapabilities?.models ?? [{ id: "default", label: "默认模型" }],
1696
1783
  defaultModel: runtimeCapabilities?.defaultModel,
1784
+ modelsSource: hasRuntimeModels ? "runtime" : enabled ? "fallback" : "unavailable",
1785
+ modelListError: this.providerCapabilityErrors.get(provider),
1697
1786
  reasoningEfforts: supportsReasoningEffort
1698
1787
  ? runtimeCapabilities?.reasoningEfforts ?? (provider === "claude" ? [...CLAUDE_REASONING_EFFORTS] : [...ALL_REASONING_EFFORTS])
1699
1788
  : [],
@@ -1722,6 +1811,7 @@ export class AgentWorkspaceProxy {
1722
1811
  providers,
1723
1812
  protocolVersion: 1,
1724
1813
  workspaceProtocolVersion: 2,
1814
+ capabilitiesRevision: ++this.capabilitiesRevision,
1725
1815
  error: anyEnabled ? undefined : "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。",
1726
1816
  supportsSessionList: anyEnabled,
1727
1817
  supportsSessionLoad: anyEnabled,
@@ -1758,12 +1848,18 @@ export class AgentWorkspaceProxy {
1758
1848
  }
1759
1849
  this.hydrateStoredTimeline(existingConversation);
1760
1850
  this.activeConversationId = existingConversation.id;
1851
+ const snapshot = this.latestSnapshot(existingConversation.id);
1761
1852
  this.input.send(createEnvelope({
1762
1853
  type: "agent.v2.conversation.opened",
1763
1854
  hostDeviceId: this.input.hostDeviceId,
1764
1855
  payload: {
1765
- conversation: existingConversation,
1766
- snapshot: snapshotTimelineItems(this.timelines.get(existingConversation.id) ?? []),
1856
+ conversation: this.conversationSnapshot(existingConversation),
1857
+ snapshot: snapshot.items,
1858
+ revision: this.getRevision(existingConversation.id),
1859
+ cursor: snapshot.cursor,
1860
+ hasMore: snapshot.hasMore,
1861
+ source: "device-history",
1862
+ canonical: true,
1767
1863
  },
1768
1864
  }));
1769
1865
  return existingConversation;
@@ -1807,6 +1903,11 @@ export class AgentWorkspaceProxy {
1807
1903
  collaborationMode: payload.collaborationMode ?? existingConversation?.collaborationMode,
1808
1904
  status: "idle",
1809
1905
  archived: existingConversation?.archived ?? false,
1906
+ timelineRevision: existingConversation?.timelineRevision ?? this.getRevision(conversationId),
1907
+ historyComplete: existingConversation?.historyComplete ?? false,
1908
+ runningTurnId: this.currentTurnIds.get(conversationId),
1909
+ source: "device",
1910
+ canonical: true,
1810
1911
  lastMessagePreview: existingConversation?.status === "error" ? undefined : existingConversation?.lastMessagePreview,
1811
1912
  lastActivityAt: now,
1812
1913
  createdAt: existingConversation?.createdAt ?? now,
@@ -1816,10 +1917,19 @@ export class AgentWorkspaceProxy {
1816
1917
  this.activeConversationId = conversation.id;
1817
1918
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1818
1919
  this.hydrateStoredTimeline(conversation);
1920
+ const snapshot = this.latestSnapshot(conversation.id);
1819
1921
  this.input.send(createEnvelope({
1820
1922
  type: "agent.v2.conversation.opened",
1821
1923
  hostDeviceId: this.input.hostDeviceId,
1822
- payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1924
+ payload: {
1925
+ conversation: this.conversationSnapshot(conversation),
1926
+ snapshot: snapshot.items,
1927
+ revision: this.getRevision(conversation.id),
1928
+ cursor: snapshot.cursor,
1929
+ hasMore: snapshot.hasMore,
1930
+ source: "device-history",
1931
+ canonical: true,
1932
+ },
1823
1933
  }));
1824
1934
  return conversation;
1825
1935
  } catch (error) {
@@ -1855,6 +1965,10 @@ export class AgentWorkspaceProxy {
1855
1965
  collaborationMode: payload.collaborationMode,
1856
1966
  status: "error",
1857
1967
  archived: false,
1968
+ timelineRevision: this.getRevision(fallbackId),
1969
+ historyComplete: false,
1970
+ source: "device",
1971
+ canonical: true,
1858
1972
  lastMessagePreview: message,
1859
1973
  lastActivityAt: now,
1860
1974
  createdAt: now,
@@ -1871,7 +1985,14 @@ export class AgentWorkspaceProxy {
1871
1985
  this.input.send(createEnvelope({
1872
1986
  type: "agent.v2.conversation.opened",
1873
1987
  hostDeviceId: this.input.hostDeviceId,
1874
- payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1988
+ payload: {
1989
+ conversation: this.conversationSnapshot(conversation),
1990
+ snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []),
1991
+ revision: this.getRevision(conversation.id),
1992
+ hasMore: false,
1993
+ source: "device",
1994
+ canonical: true,
1995
+ },
1875
1996
  }));
1876
1997
  return conversation;
1877
1998
  }
@@ -2881,6 +3002,14 @@ export class AgentWorkspaceProxy {
2881
3002
  this.permissionWaiters.delete(requestId);
2882
3003
  this.permissionSources.delete(requestId);
2883
3004
  resolve(formatPermissionResponse(source, "cancelled", "cancelled"));
3005
+ this.markPermission(conversationId, requestId, {
3006
+ permissionOutcome: "cancelled",
3007
+ optionId: "cancelled",
3008
+ permissionLive: false,
3009
+ permissionPending: false,
3010
+ permissionExpired: true,
3011
+ permissionError: "等待授权超时",
3012
+ });
2884
3013
  this.updateConversationStatus(conversationId, "idle");
2885
3014
  }, PERMISSION_TIMEOUT_MS);
2886
3015
  this.permissionWaiters.set(requestId, { resolve, timer });
@@ -2920,6 +3049,8 @@ export class AgentWorkspaceProxy {
2920
3049
  this.markPermission(payload.conversationId, payload.requestId, {
2921
3050
  permissionOutcome: payload.outcome,
2922
3051
  optionId: selectedOptionId,
3052
+ permissionLive: false,
3053
+ permissionExpired: false,
2923
3054
  permissionError: undefined,
2924
3055
  permissionPending: false,
2925
3056
  });
@@ -2980,6 +3111,228 @@ export class AgentWorkspaceProxy {
2980
3111
  });
2981
3112
  }
2982
3113
 
3114
+ private getRevision(conversationId: string): number {
3115
+ return this.conversationRevisions.get(conversationId) ??
3116
+ this.conversations.get(conversationId)?.timelineRevision ??
3117
+ 0;
3118
+ }
3119
+
3120
+ private setRevisionFloor(conversationId: string, revision: number): number {
3121
+ const nextRevision = Math.max(this.getRevision(conversationId), revision);
3122
+ this.conversationRevisions.set(conversationId, nextRevision);
3123
+ const conversation = this.conversations.get(conversationId);
3124
+ if (conversation) {
3125
+ conversation.timelineRevision = nextRevision;
3126
+ conversation.runningTurnId = this.currentTurnIds.get(conversationId);
3127
+ }
3128
+ return nextRevision;
3129
+ }
3130
+
3131
+ private conversationSnapshot(conversation: AgentConversation): AgentConversation {
3132
+ return {
3133
+ ...conversation,
3134
+ timelineRevision: this.getRevision(conversation.id),
3135
+ historyComplete: conversation.historyComplete ?? false,
3136
+ runningTurnId: this.currentTurnIds.get(conversation.id),
3137
+ source: conversation.source ?? "device",
3138
+ canonical: conversation.canonical ?? true,
3139
+ };
3140
+ }
3141
+
3142
+ private annotateTimelineItem(
3143
+ item: AgentTimelineItem,
3144
+ revision: number | undefined,
3145
+ source: AgentSyncSource,
3146
+ ): AgentTimelineItem {
3147
+ return {
3148
+ ...item,
3149
+ revision: revision ?? item.revision,
3150
+ source: item.source ?? source,
3151
+ canonical: item.canonical ?? true,
3152
+ };
3153
+ }
3154
+
3155
+ private recordRevisionEvent(
3156
+ conversationId: string,
3157
+ change: { item?: AgentTimelineItem; conversation?: AgentConversation },
3158
+ ): AgentRevisionEvent {
3159
+ const revision = this.getRevision(conversationId) + 1;
3160
+ this.conversationRevisions.set(conversationId, revision);
3161
+ const conversation = this.conversations.get(conversationId);
3162
+ if (conversation) {
3163
+ conversation.timelineRevision = revision;
3164
+ conversation.runningTurnId = this.currentTurnIds.get(conversationId);
3165
+ }
3166
+ const event: AgentRevisionEvent = {
3167
+ revision,
3168
+ item: change.item ? this.annotateTimelineItem(change.item, revision, "device-live") : undefined,
3169
+ conversation: change.conversation ? this.conversationSnapshot(change.conversation) : undefined,
3170
+ };
3171
+ const events = this.revisionEvents.get(conversationId) ?? [];
3172
+ events.push(event);
3173
+ if (events.length > MAX_DELTA_EVENTS) {
3174
+ events.splice(0, events.length - MAX_DELTA_EVENTS);
3175
+ }
3176
+ this.revisionEvents.set(conversationId, events);
3177
+ return event;
3178
+ }
3179
+
3180
+ private storedTimeline(conversation: AgentConversation, maxItems = HISTORY_PAGE_MAX_ITEMS): AgentTimelineItem[] {
3181
+ if (!conversation.agentSessionId) return [];
3182
+ const result = conversation.provider === "codex"
3183
+ ? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd, { maxItems })
3184
+ : conversation.provider === "claude"
3185
+ ? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id, { maxItems })
3186
+ : { items: [] };
3187
+ return result.items
3188
+ .sort((a, b) => a.createdAt - b.createdAt)
3189
+ .map((item, index) => this.annotateTimelineItem(item as AgentTimelineItem, index + 1, "device-history"));
3190
+ }
3191
+
3192
+ private canonicalTimeline(conversation: AgentConversation, maxItems = HISTORY_PAGE_MAX_ITEMS): AgentTimelineItem[] {
3193
+ const merged = new Map<string, AgentTimelineItem>();
3194
+ for (const item of this.storedTimeline(conversation, maxItems)) {
3195
+ merged.set(item.id, item);
3196
+ }
3197
+ for (const item of this.timelines.get(conversation.id) ?? []) {
3198
+ merged.set(item.id, this.annotateTimelineItem(item, item.revision, item.source ?? "device-live"));
3199
+ }
3200
+ const items = [...merged.values()]
3201
+ .sort((a, b) => a.createdAt - b.createdAt)
3202
+ .slice(-maxItems);
3203
+ this.setRevisionFloor(conversation.id, Math.max(this.getRevision(conversation.id), items.length));
3204
+ return items;
3205
+ }
3206
+
3207
+ private async appServerTimeline(conversation: AgentConversation, maxItems = HISTORY_PAGE_MAX_ITEMS): Promise<AgentTimelineItem[]> {
3208
+ if (conversation.provider !== "codex" || !conversation.agentSessionId) return [];
3209
+ if (this.protocolForProvider(conversation.provider) !== "codex-app-server") return [];
3210
+ const client = this.clientForProvider(conversation.provider);
3211
+ const listTurns = (client as { listTurns?: (input: { sessionId: string; limit?: number }) => Promise<unknown> } | undefined)?.listTurns;
3212
+ if (typeof listTurns !== "function") return [];
3213
+ try {
3214
+ const result = await listTurns.call(client, { sessionId: conversation.agentSessionId, limit: maxItems });
3215
+ return this.timelineFromAppServerTurns(conversation.id, conversation.agentSessionId, result);
3216
+ } catch (error) {
3217
+ if (this.input.verbose) {
3218
+ process.stderr.write(`[agent:v2] thread/turns/list failed: ${error instanceof Error ? error.message : String(error)}\n`);
3219
+ }
3220
+ return [];
3221
+ }
3222
+ }
3223
+
3224
+ private timelineFromAppServerTurns(
3225
+ conversationId: string,
3226
+ agentSessionId: string,
3227
+ value: unknown,
3228
+ ): AgentTimelineItem[] {
3229
+ const raw = asRecord(value);
3230
+ const turns =
3231
+ Array.isArray(value) ? value :
3232
+ Array.isArray(raw?.turns) ? raw.turns :
3233
+ Array.isArray(raw?.items) ? raw.items :
3234
+ Array.isArray(raw?.entries) ? raw.entries :
3235
+ [];
3236
+ const items: AgentTimelineItem[] = [];
3237
+ turns.forEach((entry, index) => {
3238
+ const turn = asRecord(entry);
3239
+ if (!turn) return;
3240
+ const turnId = firstString(turn, ["id", "turnId", "turn_id"]) ?? `turn-${index + 1}`;
3241
+ const createdAt = parseTimestamp(turn.createdAt ?? turn.created_at ?? turn.startedAt ?? turn.started_at) ?? Date.now() + index;
3242
+ const updatedAt = parseTimestamp(turn.updatedAt ?? turn.updated_at ?? turn.completedAt ?? turn.completed_at);
3243
+ const userText = appServerText(turn.input ?? turn.prompt ?? turn.user ?? turn.userMessage ?? turn.request);
3244
+ if (userText) {
3245
+ items.push({
3246
+ id: `app-server:${agentSessionId}:${turnId}:user`,
3247
+ conversationId,
3248
+ type: "message",
3249
+ kind: "chat",
3250
+ turnId,
3251
+ role: "user",
3252
+ content: [{ type: "text", text: userText }],
3253
+ text: userText,
3254
+ createdAt,
3255
+ updatedAt,
3256
+ metadata: { source: "app-server", provider: "codex" },
3257
+ });
3258
+ }
3259
+ const assistantText = appServerText(turn.output ?? turn.response ?? turn.assistant ?? turn.assistantMessage ?? turn.result);
3260
+ if (assistantText) {
3261
+ items.push({
3262
+ id: `app-server:${agentSessionId}:${turnId}:assistant`,
3263
+ conversationId,
3264
+ type: "message",
3265
+ kind: "chat",
3266
+ turnId,
3267
+ role: "assistant",
3268
+ content: [{ type: "text", text: assistantText }],
3269
+ text: assistantText,
3270
+ createdAt: updatedAt ?? createdAt + 1,
3271
+ updatedAt,
3272
+ metadata: { source: "app-server", provider: "codex" },
3273
+ });
3274
+ }
3275
+ const nestedItems = Array.isArray(turn.items) ? turn.items : Array.isArray(turn.messages) ? turn.messages : [];
3276
+ for (const nested of nestedItems) {
3277
+ const nestedRecord = asRecord(nested);
3278
+ if (!nestedRecord) continue;
3279
+ const text = appServerText(nestedRecord.content ?? nestedRecord.text ?? nestedRecord.message);
3280
+ const role = nestedRecord.role === "user" || nestedRecord.role === "assistant" || nestedRecord.role === "system"
3281
+ ? nestedRecord.role
3282
+ : undefined;
3283
+ if (!text || !role) continue;
3284
+ const itemId = firstString(nestedRecord, ["id", "itemId", "messageId"]) ?? `${role}-${items.length + 1}`;
3285
+ items.push({
3286
+ id: `app-server:${agentSessionId}:${turnId}:${itemId}`,
3287
+ conversationId,
3288
+ type: "message",
3289
+ kind: "chat",
3290
+ turnId,
3291
+ itemId,
3292
+ role,
3293
+ content: [{ type: "text", text }],
3294
+ text,
3295
+ createdAt: parseTimestamp(nestedRecord.createdAt ?? nestedRecord.created_at) ?? createdAt + items.length,
3296
+ updatedAt: parseTimestamp(nestedRecord.updatedAt ?? nestedRecord.updated_at),
3297
+ metadata: { source: "app-server", provider: "codex" },
3298
+ });
3299
+ }
3300
+ });
3301
+ return items
3302
+ .sort((a, b) => a.createdAt - b.createdAt)
3303
+ .map((item, index) => this.annotateTimelineItem(item, index + 1, "app-server"));
3304
+ }
3305
+
3306
+ private mergeCanonicalTimelineItems(items: AgentTimelineItem[], maxItems: number): AgentTimelineItem[] {
3307
+ const byKey = new Map<string, AgentTimelineItem>();
3308
+ for (const item of items.sort((a, b) => a.createdAt - b.createdAt)) {
3309
+ const key = item.type === "message" && item.role && item.text
3310
+ ? `${item.role}:${item.text.replace(/\s+/g, " ").trim().slice(0, 500)}`
3311
+ : item.id;
3312
+ const existing = byKey.get(key);
3313
+ if (!existing || item.source === "app-server" || (item.updatedAt ?? item.createdAt) >= (existing.updatedAt ?? existing.createdAt)) {
3314
+ byKey.set(key, item);
3315
+ }
3316
+ }
3317
+ return [...byKey.values()]
3318
+ .sort((a, b) => a.createdAt - b.createdAt)
3319
+ .slice(-maxItems);
3320
+ }
3321
+
3322
+ private latestSnapshot(conversationId: string): {
3323
+ items: AgentTimelineItem[];
3324
+ cursor?: string;
3325
+ hasMore: boolean;
3326
+ } {
3327
+ const timeline = this.timelines.get(conversationId) ?? [];
3328
+ const start = Math.max(0, timeline.length - MAX_SNAPSHOT_ITEMS);
3329
+ return {
3330
+ items: timeline.slice(start).map((item) => snapshotTimelineItem(item)),
3331
+ cursor: start > 0 ? String(start) : undefined,
3332
+ hasMore: start > 0,
3333
+ };
3334
+ }
3335
+
2983
3336
  private addItem(conversationId: string, item: AgentTimelineItem): void {
2984
3337
  this.rememberItemConversationId(conversationId, item);
2985
3338
  const timeline = this.timelines.get(conversationId) ?? [];
@@ -3111,6 +3464,17 @@ export class AgentWorkspaceProxy {
3111
3464
  .sort((a, b) => a.createdAt - b.createdAt)
3112
3465
  .slice(-MAX_TIMELINE_ITEMS),
3113
3466
  );
3467
+ const oldRevision = this.conversationRevisions.get(oldId) ?? 0;
3468
+ if (oldRevision > 0) {
3469
+ this.conversationRevisions.delete(oldId);
3470
+ this.setRevisionFloor(newId, oldRevision);
3471
+ }
3472
+ const oldEvents = this.revisionEvents.get(oldId);
3473
+ if (oldEvents) {
3474
+ this.revisionEvents.delete(oldId);
3475
+ const existingEvents = this.revisionEvents.get(newId) ?? [];
3476
+ this.revisionEvents.set(newId, [...existingEvents, ...oldEvents].slice(-MAX_DELTA_EVENTS));
3477
+ }
3114
3478
 
3115
3479
  for (const [agentSessionId, conversationId] of this.conversationByAgentSessionId) {
3116
3480
  if (conversationId === oldId) {
@@ -3146,18 +3510,48 @@ export class AgentWorkspaceProxy {
3146
3510
 
3147
3511
  private emitItem(conversationId: string, item: AgentTimelineItem): void {
3148
3512
  const conversation = this.conversations.get(conversationId);
3513
+ const itemSnapshot = snapshotTimelineItem(item, { stripImages: false });
3514
+ const event = this.recordRevisionEvent(conversationId, { conversation, item: itemSnapshot });
3149
3515
  this.input.send(createEnvelope({
3150
3516
  type: "agent.v2.event",
3151
3517
  hostDeviceId: this.input.hostDeviceId,
3152
- payload: { conversationId, conversation, item: snapshotTimelineItem(item, { stripImages: false }) },
3518
+ payload: {
3519
+ conversationId,
3520
+ conversation: event.conversation,
3521
+ item: event.item,
3522
+ revision: event.revision,
3523
+ source: "device-live",
3524
+ canonical: true,
3525
+ },
3153
3526
  }));
3154
3527
  }
3155
3528
 
3156
3529
  private emitConversation(conversation: AgentConversation): void {
3530
+ const event = this.recordRevisionEvent(conversation.id, { conversation });
3157
3531
  this.input.send(createEnvelope({
3158
3532
  type: "agent.v2.event",
3159
3533
  hostDeviceId: this.input.hostDeviceId,
3160
- payload: { conversationId: conversation.id, conversation },
3534
+ payload: {
3535
+ conversationId: conversation.id,
3536
+ conversation: event.conversation,
3537
+ revision: event.revision,
3538
+ source: "device-live",
3539
+ canonical: true,
3540
+ },
3541
+ }));
3542
+ this.input.send(createEnvelope({
3543
+ type: "agent.v2.running_state",
3544
+ hostDeviceId: this.input.hostDeviceId,
3545
+ payload: {
3546
+ conversationId: conversation.id,
3547
+ status: conversation.status,
3548
+ runningTurnId: this.currentTurnIds.get(conversation.id),
3549
+ revision: event.revision,
3550
+ error: conversation.status === "error" ? conversation.lastMessagePreview : undefined,
3551
+ updatedAt: conversation.lastActivityAt,
3552
+ source: "device-live",
3553
+ canonical: true,
3554
+ },
3161
3555
  }));
3162
3556
  }
3163
3557
 
@@ -3206,17 +3600,143 @@ export class AgentWorkspaceProxy {
3206
3600
  const conversation = this.conversations.get(this.activeConversationId);
3207
3601
  if (conversation) this.hydrateStoredTimeline(conversation);
3208
3602
  }
3209
- const conversations = [...this.conversations.values()];
3210
- const items = conversationId
3211
- ? snapshotTimelineItems(this.timelines.get(conversationId) ?? [])
3212
- : [];
3603
+ const conversations = [...this.conversations.values()].map((conversation) => this.conversationSnapshot(conversation));
3604
+ const snapshot = conversationId ? this.latestSnapshot(conversationId) : { items: [], cursor: undefined, hasMore: false };
3213
3605
  this.input.send(createEnvelope({
3214
3606
  type: "agent.v2.snapshot",
3215
3607
  hostDeviceId: this.input.hostDeviceId,
3216
3608
  payload: {
3217
3609
  conversations,
3218
3610
  activeConversationId: this.activeConversationId,
3219
- items,
3611
+ items: snapshot.items,
3612
+ revision: conversationId ? this.getRevision(conversationId) : undefined,
3613
+ cursor: snapshot.cursor,
3614
+ hasMore: snapshot.hasMore,
3615
+ source: "device-history",
3616
+ canonical: true,
3617
+ },
3618
+ }));
3619
+ }
3620
+
3621
+ private async sendHistoryPage(payload: {
3622
+ conversationId: string;
3623
+ cursor?: string;
3624
+ limit?: number;
3625
+ direction?: "older" | "newer";
3626
+ }): Promise<void> {
3627
+ const conversation = this.conversations.get(payload.conversationId);
3628
+ if (!conversation) return;
3629
+ const limit = Math.min(Math.max(payload.limit ?? MAX_SNAPSHOT_ITEMS, 1), 200);
3630
+ const items = this.mergeCanonicalTimelineItems([
3631
+ ...this.canonicalTimeline(conversation, HISTORY_PAGE_MAX_ITEMS),
3632
+ ...await this.appServerTimeline(conversation, HISTORY_PAGE_MAX_ITEMS),
3633
+ ], HISTORY_PAGE_MAX_ITEMS);
3634
+ this.timelines.set(
3635
+ conversation.id,
3636
+ items.slice(-MAX_TIMELINE_ITEMS).map((item) => this.annotateTimelineItem(item, item.revision, item.source ?? "device-history")),
3637
+ );
3638
+ for (const item of this.timelines.get(conversation.id) ?? []) {
3639
+ this.rememberItemConversationId(conversation.id, item);
3640
+ }
3641
+
3642
+ const direction = payload.direction ?? "older";
3643
+ let page: AgentTimelineItem[];
3644
+ let cursor: string | undefined;
3645
+ let hasMore = false;
3646
+ if (direction === "newer") {
3647
+ const start = clampHistoryCursor(payload.cursor, 0, items.length);
3648
+ const end = Math.min(items.length, start + limit);
3649
+ page = items.slice(start, end);
3650
+ hasMore = end < items.length;
3651
+ cursor = hasMore ? String(end) : undefined;
3652
+ } else {
3653
+ const end = clampHistoryCursor(payload.cursor, items.length, items.length);
3654
+ const start = Math.max(0, end - limit);
3655
+ page = items.slice(start, end);
3656
+ hasMore = start > 0;
3657
+ cursor = hasMore ? String(start) : undefined;
3658
+ }
3659
+ conversation.historyComplete = !hasMore;
3660
+ const revision = this.setRevisionFloor(conversation.id, Math.max(this.getRevision(conversation.id), items.length));
3661
+ this.input.send(createEnvelope({
3662
+ type: "agent.v2.history.page",
3663
+ hostDeviceId: this.input.hostDeviceId,
3664
+ payload: {
3665
+ conversationId: conversation.id,
3666
+ conversation: this.conversationSnapshot(conversation),
3667
+ items: page.map((item) => snapshotTimelineItem(item)),
3668
+ revision,
3669
+ cursor,
3670
+ hasMore,
3671
+ source: "device-history",
3672
+ canonical: true,
3673
+ },
3674
+ }));
3675
+ }
3676
+
3677
+ private sendDelta(payload: {
3678
+ conversationId: string;
3679
+ sinceRevision?: number;
3680
+ limit?: number;
3681
+ }): void {
3682
+ const conversation = this.conversations.get(payload.conversationId);
3683
+ if (!conversation) return;
3684
+ this.hydrateStoredTimeline(conversation);
3685
+ const sinceRevision = payload.sinceRevision ?? 0;
3686
+ const limit = Math.min(Math.max(payload.limit ?? 100, 1), 500);
3687
+ const events = this.revisionEvents.get(conversation.id) ?? [];
3688
+ const oldestAvailable = events[0]?.revision ?? this.getRevision(conversation.id);
3689
+ const newestRevision = this.getRevision(conversation.id);
3690
+ const reset = sinceRevision > 0 &&
3691
+ (
3692
+ sinceRevision > newestRevision ||
3693
+ (
3694
+ sinceRevision < newestRevision &&
3695
+ (events.length === 0 || sinceRevision < oldestAvailable - 1)
3696
+ )
3697
+ );
3698
+
3699
+ if (reset) {
3700
+ const snapshot = this.latestSnapshot(conversation.id);
3701
+ this.input.send(createEnvelope({
3702
+ type: "agent.v2.delta",
3703
+ hostDeviceId: this.input.hostDeviceId,
3704
+ payload: {
3705
+ conversationId: conversation.id,
3706
+ conversation: this.conversationSnapshot(conversation),
3707
+ items: snapshot.items,
3708
+ sinceRevision,
3709
+ revision: newestRevision,
3710
+ reset: true,
3711
+ cursor: snapshot.cursor,
3712
+ hasMore: snapshot.hasMore,
3713
+ source: "device-history",
3714
+ canonical: true,
3715
+ },
3716
+ }));
3717
+ return;
3718
+ }
3719
+
3720
+ const changed = events
3721
+ .filter((event) => event.revision > sinceRevision)
3722
+ .slice(-limit);
3723
+ const itemsById = new Map<string, AgentTimelineItem>();
3724
+ for (const event of changed) {
3725
+ if (event.item) itemsById.set(event.item.id, event.item);
3726
+ }
3727
+ this.input.send(createEnvelope({
3728
+ type: "agent.v2.delta",
3729
+ hostDeviceId: this.input.hostDeviceId,
3730
+ payload: {
3731
+ conversationId: conversation.id,
3732
+ conversation: this.conversationSnapshot(conversation),
3733
+ items: [...itemsById.values()].map((item) => snapshotTimelineItem(item)),
3734
+ sinceRevision,
3735
+ revision: newestRevision,
3736
+ reset: false,
3737
+ hasMore: changed.length === limit && events.some((event) => event.revision > sinceRevision && event.revision < changed[0]!.revision),
3738
+ source: "device-live",
3739
+ canonical: true,
3220
3740
  },
3221
3741
  }));
3222
3742
  }
@@ -3224,18 +3744,25 @@ export class AgentWorkspaceProxy {
3224
3744
  private hydrateStoredTimeline(conversation: AgentConversation): void {
3225
3745
  if (!conversation.agentSessionId) return;
3226
3746
  const existing = this.timelines.get(conversation.id) ?? [];
3227
- if (existing.length > 0) return;
3228
- const result = conversation.provider === "codex"
3229
- ? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd)
3230
- : conversation.provider === "claude"
3231
- ? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id)
3232
- : { items: [] };
3233
- if (result.items.length === 0) return;
3234
- const items = result.items
3747
+ if (existing.length > 0) {
3748
+ this.setRevisionFloor(conversation.id, Math.max(
3749
+ existing.length,
3750
+ ...existing.map((item) => item.revision ?? 0),
3751
+ ));
3752
+ return;
3753
+ }
3754
+ const stored = this.storedTimeline(conversation, MAX_TIMELINE_ITEMS);
3755
+ conversation.historyComplete = stored.length <= MAX_SNAPSHOT_ITEMS;
3756
+ if (stored.length === 0) {
3757
+ this.setRevisionFloor(conversation.id, this.getRevision(conversation.id));
3758
+ return;
3759
+ }
3760
+ const items = stored
3235
3761
  .sort((a, b) => a.createdAt - b.createdAt)
3236
- .slice(-MAX_TIMELINE_ITEMS) as AgentTimelineItem[];
3762
+ .slice(-MAX_TIMELINE_ITEMS);
3237
3763
  this.timelines.set(conversation.id, items);
3238
3764
  for (const item of items) this.rememberItemConversationId(conversation.id, item);
3765
+ this.setRevisionFloor(conversation.id, Math.max(items.length, ...items.map((item) => item.revision ?? 0)));
3239
3766
  const lastMessage = [...items].reverse().find((item) => item.text?.trim());
3240
3767
  if (lastMessage?.text && !conversation.lastMessagePreview) {
3241
3768
  conversation.lastMessagePreview = previewText(lastMessage.text);
@@ -3303,6 +3830,8 @@ export class AgentWorkspaceProxy {
3303
3830
  private rememberTurnConversationId(conversationId: string, turnId: string): void {
3304
3831
  this.currentTurnIds.set(conversationId, turnId);
3305
3832
  this.turnConversationIds.set(turnId, conversationId);
3833
+ const conversation = this.conversations.get(conversationId);
3834
+ if (conversation) conversation.runningTurnId = turnId;
3306
3835
  }
3307
3836
 
3308
3837
  private forgetCurrentTurn(conversationId: string, turnId?: string): void {
@@ -3310,6 +3839,8 @@ export class AgentWorkspaceProxy {
3310
3839
  this.currentTurnIds.delete(conversationId);
3311
3840
  if (turnId) this.turnConversationIds.delete(turnId);
3312
3841
  if (currentTurnId && currentTurnId !== turnId) this.turnConversationIds.delete(currentTurnId);
3842
+ const conversation = this.conversations.get(conversationId);
3843
+ if (conversation) conversation.runningTurnId = undefined;
3313
3844
  }
3314
3845
 
3315
3846
  private rememberItemConversationId(conversationId: string, item: AgentTimelineItem): void {
@@ -3351,32 +3882,54 @@ export class AgentWorkspaceProxy {
3351
3882
 
3352
3883
  private cancelPendingPermissions(conversationId?: string): void {
3353
3884
  for (const [requestId, waiter] of this.permissionWaiters) {
3885
+ const ownerConversationId = this.conversationIdForPermissionRequest(requestId);
3886
+ if (conversationId && ownerConversationId && ownerConversationId !== conversationId) continue;
3354
3887
  clearTimeout(waiter.timer);
3355
3888
  waiter.resolve(formatPermissionResponse(
3356
3889
  this.permissionSources.get(requestId),
3357
3890
  "cancelled",
3358
3891
  "cancelled",
3359
3892
  ));
3893
+ if (ownerConversationId) {
3894
+ this.markPermission(ownerConversationId, requestId, {
3895
+ permissionOutcome: "cancelled",
3896
+ optionId: "cancelled",
3897
+ permissionLive: false,
3898
+ permissionPending: false,
3899
+ permissionExpired: false,
3900
+ permissionError: "已停止",
3901
+ });
3902
+ }
3903
+ this.permissionWaiters.delete(requestId);
3360
3904
  this.pendingPermissions.delete(requestId);
3361
3905
  this.permissionSources.delete(requestId);
3362
3906
  }
3363
- this.permissionWaiters.clear();
3364
3907
  for (const [requestId, waiter] of this.structuredInputWaiters) {
3908
+ const pending = this.pendingStructuredInputs.get(requestId);
3909
+ if (conversationId && pending?.conversationId && pending.conversationId !== conversationId) continue;
3365
3910
  clearTimeout(waiter.timer);
3366
3911
  waiter.resolve(formatStructuredInputResponse({}));
3367
- const pending = this.pendingStructuredInputs.get(requestId);
3368
3912
  if (pending) {
3369
3913
  this.markStructuredInput(pending.conversationId, requestId, {
3370
3914
  inputPending: false,
3371
3915
  inputError: "已停止",
3372
3916
  });
3373
3917
  }
3918
+ this.structuredInputWaiters.delete(requestId);
3374
3919
  this.pendingStructuredInputs.delete(requestId);
3375
3920
  }
3376
- this.structuredInputWaiters.clear();
3377
3921
  if (conversationId) this.updateConversationStatus(conversationId, "idle");
3378
3922
  }
3379
3923
 
3924
+ private conversationIdForPermissionRequest(requestId: string): string | undefined {
3925
+ for (const [conversationId, timeline] of this.timelines) {
3926
+ if (timeline.some((item) => item.type === "permission" && item.permission?.requestId === requestId)) {
3927
+ return conversationId;
3928
+ }
3929
+ }
3930
+ return undefined;
3931
+ }
3932
+
3380
3933
  private extractSessionId(value: unknown): string | undefined {
3381
3934
  const raw = asRecord(value);
3382
3935
  if (!raw) return undefined;