linkshell-cli 0.3.13 → 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.
@@ -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,11 +1430,23 @@ 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>();
@@ -1399,6 +1460,8 @@ export class AgentWorkspaceProxy {
1399
1460
  private conversations = new Map<string, AgentConversation>();
1400
1461
  private conversationByAgentSessionId = new Map<string, string>();
1401
1462
  private timelines = new Map<string, AgentTimelineItem[]>();
1463
+ private conversationRevisions = new Map<string, number>();
1464
+ private revisionEvents = new Map<string, AgentRevisionEvent[]>();
1402
1465
  private toolOutputBuffers = new Map<string, string>();
1403
1466
  private pendingPermissions = new Map<string, AgentPermission>();
1404
1467
  private permissionWaiters = new Map<string, PendingPermissionWaiter>();
@@ -1439,7 +1502,7 @@ export class AgentWorkspaceProxy {
1439
1502
  this.input.send(createEnvelope({
1440
1503
  type: "agent.v2.conversation.list.result",
1441
1504
  hostDeviceId: this.input.hostDeviceId,
1442
- payload: { conversations },
1505
+ payload: { conversations: conversations.map((conversation) => this.conversationSnapshot(conversation)) },
1443
1506
  }));
1444
1507
  break;
1445
1508
  }
@@ -1448,6 +1511,16 @@ export class AgentWorkspaceProxy {
1448
1511
  this.sendSnapshot(payload.conversationId);
1449
1512
  break;
1450
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
+ }
1451
1524
  case "agent.v2.prompt": {
1452
1525
  const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
1453
1526
  await this.sendPrompt(payload);
@@ -1656,14 +1729,20 @@ export class AgentWorkspaceProxy {
1656
1729
  reasoningEffort: existing?.reasoningEffort,
1657
1730
  permissionMode: existing?.permissionMode,
1658
1731
  collaborationMode: existing?.collaborationMode,
1659
- status: existing?.status ?? "idle",
1732
+ status: remote.status ?? existing?.status ?? "idle",
1660
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,
1661
1739
  lastMessagePreview: existing?.lastMessagePreview,
1662
1740
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
1663
1741
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
1664
1742
  };
1665
1743
  this.conversations.set(conversation.id, conversation);
1666
1744
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
1745
+ if (remote.runningTurnId) this.rememberTurnConversationId(conversation.id, remote.runningTurnId);
1667
1746
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1668
1747
  }
1669
1748
  }
@@ -1758,12 +1837,18 @@ export class AgentWorkspaceProxy {
1758
1837
  }
1759
1838
  this.hydrateStoredTimeline(existingConversation);
1760
1839
  this.activeConversationId = existingConversation.id;
1840
+ const snapshot = this.latestSnapshot(existingConversation.id);
1761
1841
  this.input.send(createEnvelope({
1762
1842
  type: "agent.v2.conversation.opened",
1763
1843
  hostDeviceId: this.input.hostDeviceId,
1764
1844
  payload: {
1765
- conversation: existingConversation,
1766
- 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,
1767
1852
  },
1768
1853
  }));
1769
1854
  return existingConversation;
@@ -1807,6 +1892,11 @@ export class AgentWorkspaceProxy {
1807
1892
  collaborationMode: payload.collaborationMode ?? existingConversation?.collaborationMode,
1808
1893
  status: "idle",
1809
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,
1810
1900
  lastMessagePreview: existingConversation?.status === "error" ? undefined : existingConversation?.lastMessagePreview,
1811
1901
  lastActivityAt: now,
1812
1902
  createdAt: existingConversation?.createdAt ?? now,
@@ -1816,10 +1906,19 @@ export class AgentWorkspaceProxy {
1816
1906
  this.activeConversationId = conversation.id;
1817
1907
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1818
1908
  this.hydrateStoredTimeline(conversation);
1909
+ const snapshot = this.latestSnapshot(conversation.id);
1819
1910
  this.input.send(createEnvelope({
1820
1911
  type: "agent.v2.conversation.opened",
1821
1912
  hostDeviceId: this.input.hostDeviceId,
1822
- 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
+ },
1823
1922
  }));
1824
1923
  return conversation;
1825
1924
  } catch (error) {
@@ -1855,6 +1954,10 @@ export class AgentWorkspaceProxy {
1855
1954
  collaborationMode: payload.collaborationMode,
1856
1955
  status: "error",
1857
1956
  archived: false,
1957
+ timelineRevision: this.getRevision(fallbackId),
1958
+ historyComplete: false,
1959
+ source: "device",
1960
+ canonical: true,
1858
1961
  lastMessagePreview: message,
1859
1962
  lastActivityAt: now,
1860
1963
  createdAt: now,
@@ -1871,7 +1974,14 @@ export class AgentWorkspaceProxy {
1871
1974
  this.input.send(createEnvelope({
1872
1975
  type: "agent.v2.conversation.opened",
1873
1976
  hostDeviceId: this.input.hostDeviceId,
1874
- 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
+ },
1875
1985
  }));
1876
1986
  return conversation;
1877
1987
  }
@@ -2881,6 +2991,14 @@ export class AgentWorkspaceProxy {
2881
2991
  this.permissionWaiters.delete(requestId);
2882
2992
  this.permissionSources.delete(requestId);
2883
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
+ });
2884
3002
  this.updateConversationStatus(conversationId, "idle");
2885
3003
  }, PERMISSION_TIMEOUT_MS);
2886
3004
  this.permissionWaiters.set(requestId, { resolve, timer });
@@ -2920,6 +3038,8 @@ export class AgentWorkspaceProxy {
2920
3038
  this.markPermission(payload.conversationId, payload.requestId, {
2921
3039
  permissionOutcome: payload.outcome,
2922
3040
  optionId: selectedOptionId,
3041
+ permissionLive: false,
3042
+ permissionExpired: false,
2923
3043
  permissionError: undefined,
2924
3044
  permissionPending: false,
2925
3045
  });
@@ -2980,6 +3100,228 @@ export class AgentWorkspaceProxy {
2980
3100
  });
2981
3101
  }
2982
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
+
2983
3325
  private addItem(conversationId: string, item: AgentTimelineItem): void {
2984
3326
  this.rememberItemConversationId(conversationId, item);
2985
3327
  const timeline = this.timelines.get(conversationId) ?? [];
@@ -3111,6 +3453,17 @@ export class AgentWorkspaceProxy {
3111
3453
  .sort((a, b) => a.createdAt - b.createdAt)
3112
3454
  .slice(-MAX_TIMELINE_ITEMS),
3113
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
+ }
3114
3467
 
3115
3468
  for (const [agentSessionId, conversationId] of this.conversationByAgentSessionId) {
3116
3469
  if (conversationId === oldId) {
@@ -3146,18 +3499,48 @@ export class AgentWorkspaceProxy {
3146
3499
 
3147
3500
  private emitItem(conversationId: string, item: AgentTimelineItem): void {
3148
3501
  const conversation = this.conversations.get(conversationId);
3502
+ const itemSnapshot = snapshotTimelineItem(item, { stripImages: false });
3503
+ const event = this.recordRevisionEvent(conversationId, { conversation, item: itemSnapshot });
3149
3504
  this.input.send(createEnvelope({
3150
3505
  type: "agent.v2.event",
3151
3506
  hostDeviceId: this.input.hostDeviceId,
3152
- 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
+ },
3153
3515
  }));
3154
3516
  }
3155
3517
 
3156
3518
  private emitConversation(conversation: AgentConversation): void {
3519
+ const event = this.recordRevisionEvent(conversation.id, { conversation });
3157
3520
  this.input.send(createEnvelope({
3158
3521
  type: "agent.v2.event",
3159
3522
  hostDeviceId: this.input.hostDeviceId,
3160
- 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
+ },
3161
3544
  }));
3162
3545
  }
3163
3546
 
@@ -3206,17 +3589,143 @@ export class AgentWorkspaceProxy {
3206
3589
  const conversation = this.conversations.get(this.activeConversationId);
3207
3590
  if (conversation) this.hydrateStoredTimeline(conversation);
3208
3591
  }
3209
- const conversations = [...this.conversations.values()];
3210
- const items = conversationId
3211
- ? snapshotTimelineItems(this.timelines.get(conversationId) ?? [])
3212
- : [];
3592
+ const conversations = [...this.conversations.values()].map((conversation) => this.conversationSnapshot(conversation));
3593
+ const snapshot = conversationId ? this.latestSnapshot(conversationId) : { items: [], cursor: undefined, hasMore: false };
3213
3594
  this.input.send(createEnvelope({
3214
3595
  type: "agent.v2.snapshot",
3215
3596
  hostDeviceId: this.input.hostDeviceId,
3216
3597
  payload: {
3217
3598
  conversations,
3218
3599
  activeConversationId: this.activeConversationId,
3219
- 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,
3220
3729
  },
3221
3730
  }));
3222
3731
  }
@@ -3224,18 +3733,25 @@ export class AgentWorkspaceProxy {
3224
3733
  private hydrateStoredTimeline(conversation: AgentConversation): void {
3225
3734
  if (!conversation.agentSessionId) return;
3226
3735
  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
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
3235
3750
  .sort((a, b) => a.createdAt - b.createdAt)
3236
- .slice(-MAX_TIMELINE_ITEMS) as AgentTimelineItem[];
3751
+ .slice(-MAX_TIMELINE_ITEMS);
3237
3752
  this.timelines.set(conversation.id, items);
3238
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)));
3239
3755
  const lastMessage = [...items].reverse().find((item) => item.text?.trim());
3240
3756
  if (lastMessage?.text && !conversation.lastMessagePreview) {
3241
3757
  conversation.lastMessagePreview = previewText(lastMessage.text);
@@ -3303,6 +3819,8 @@ export class AgentWorkspaceProxy {
3303
3819
  private rememberTurnConversationId(conversationId: string, turnId: string): void {
3304
3820
  this.currentTurnIds.set(conversationId, turnId);
3305
3821
  this.turnConversationIds.set(turnId, conversationId);
3822
+ const conversation = this.conversations.get(conversationId);
3823
+ if (conversation) conversation.runningTurnId = turnId;
3306
3824
  }
3307
3825
 
3308
3826
  private forgetCurrentTurn(conversationId: string, turnId?: string): void {
@@ -3310,6 +3828,8 @@ export class AgentWorkspaceProxy {
3310
3828
  this.currentTurnIds.delete(conversationId);
3311
3829
  if (turnId) this.turnConversationIds.delete(turnId);
3312
3830
  if (currentTurnId && currentTurnId !== turnId) this.turnConversationIds.delete(currentTurnId);
3831
+ const conversation = this.conversations.get(conversationId);
3832
+ if (conversation) conversation.runningTurnId = undefined;
3313
3833
  }
3314
3834
 
3315
3835
  private rememberItemConversationId(conversationId: string, item: AgentTimelineItem): void {
@@ -3351,32 +3871,54 @@ export class AgentWorkspaceProxy {
3351
3871
 
3352
3872
  private cancelPendingPermissions(conversationId?: string): void {
3353
3873
  for (const [requestId, waiter] of this.permissionWaiters) {
3874
+ const ownerConversationId = this.conversationIdForPermissionRequest(requestId);
3875
+ if (conversationId && ownerConversationId && ownerConversationId !== conversationId) continue;
3354
3876
  clearTimeout(waiter.timer);
3355
3877
  waiter.resolve(formatPermissionResponse(
3356
3878
  this.permissionSources.get(requestId),
3357
3879
  "cancelled",
3358
3880
  "cancelled",
3359
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);
3360
3893
  this.pendingPermissions.delete(requestId);
3361
3894
  this.permissionSources.delete(requestId);
3362
3895
  }
3363
- this.permissionWaiters.clear();
3364
3896
  for (const [requestId, waiter] of this.structuredInputWaiters) {
3897
+ const pending = this.pendingStructuredInputs.get(requestId);
3898
+ if (conversationId && pending?.conversationId && pending.conversationId !== conversationId) continue;
3365
3899
  clearTimeout(waiter.timer);
3366
3900
  waiter.resolve(formatStructuredInputResponse({}));
3367
- const pending = this.pendingStructuredInputs.get(requestId);
3368
3901
  if (pending) {
3369
3902
  this.markStructuredInput(pending.conversationId, requestId, {
3370
3903
  inputPending: false,
3371
3904
  inputError: "已停止",
3372
3905
  });
3373
3906
  }
3907
+ this.structuredInputWaiters.delete(requestId);
3374
3908
  this.pendingStructuredInputs.delete(requestId);
3375
3909
  }
3376
- this.structuredInputWaiters.clear();
3377
3910
  if (conversationId) this.updateConversationStatus(conversationId, "idle");
3378
3911
  }
3379
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
+
3380
3922
  private extractSessionId(value: unknown): string | undefined {
3381
3923
  const raw = asRecord(value);
3382
3924
  if (!raw) return undefined;