linkshell-cli 0.5.1 → 0.5.4

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.
Files changed (47) hide show
  1. package/dist/cli/src/index.js +23 -1
  2. package/dist/cli/src/index.js.map +1 -1
  3. package/dist/cli/src/runtime/acp/acp-client.d.ts +16 -0
  4. package/dist/cli/src/runtime/acp/acp-client.js +81 -21
  5. package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
  6. package/dist/cli/src/runtime/acp/agent-workspace-git.d.ts +63 -0
  7. package/dist/cli/src/runtime/acp/agent-workspace-git.js +297 -0
  8. package/dist/cli/src/runtime/acp/agent-workspace-git.js.map +1 -0
  9. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +14 -0
  10. package/dist/cli/src/runtime/acp/agent-workspace.js +845 -88
  11. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  12. package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +17 -1
  13. package/dist/cli/src/runtime/acp/claude-sdk-client.js +331 -19
  14. package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
  15. package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -0
  16. package/dist/cli/src/runtime/acp/claude-stream-json-client.js +87 -14
  17. package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
  18. package/dist/cli/src/runtime/acp/json-rpc.d.ts +1 -0
  19. package/dist/cli/src/runtime/acp/json-rpc.js +6 -0
  20. package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -1
  21. package/dist/cli/src/runtime/acp-relay.d.ts +23 -0
  22. package/dist/cli/src/runtime/acp-relay.js +73 -0
  23. package/dist/cli/src/runtime/acp-relay.js.map +1 -0
  24. package/dist/cli/src/runtime/bridge-session.d.ts +37 -0
  25. package/dist/cli/src/runtime/bridge-session.js +318 -69
  26. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  27. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  28. package/dist/shared-protocol/src/index.d.ts +5436 -3886
  29. package/dist/shared-protocol/src/index.js +127 -13
  30. package/dist/shared-protocol/src/index.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/index.ts +29 -1
  33. package/src/runtime/acp/acp-client.ts +96 -21
  34. package/src/runtime/acp/agent-workspace.ts +916 -83
  35. package/src/runtime/acp/claude-sdk-client.ts +352 -31
  36. package/src/runtime/acp/claude-stream-json-client.ts +89 -16
  37. package/src/runtime/acp/json-rpc.ts +5 -0
  38. package/src/runtime/bridge-session.ts +343 -75
  39. package/dist/cli/src/runtime/acp/claude-sessions.d.ts +0 -57
  40. package/dist/cli/src/runtime/acp/claude-sessions.js +0 -506
  41. package/dist/cli/src/runtime/acp/claude-sessions.js.map +0 -1
  42. package/dist/cli/src/runtime/acp/codex-rpc-bridge.d.ts +0 -34
  43. package/dist/cli/src/runtime/acp/codex-rpc-bridge.js +0 -204
  44. package/dist/cli/src/runtime/acp/codex-rpc-bridge.js.map +0 -1
  45. package/dist/cli/src/runtime/acp/codex-sessions.d.ts +0 -60
  46. package/dist/cli/src/runtime/acp/codex-sessions.js +0 -731
  47. package/dist/cli/src/runtime/acp/codex-sessions.js.map +0 -1
@@ -201,6 +201,7 @@ interface PendingStructuredInputWaiter {
201
201
  resolve: (value: unknown) => void;
202
202
  timer: ReturnType<typeof setTimeout>;
203
203
  source?: string;
204
+ input: AgentStructuredInput;
204
205
  }
205
206
 
206
207
  const PERMISSION_TIMEOUT_MS = 5 * 60_000;
@@ -210,6 +211,17 @@ function id(prefix: string): string {
210
211
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
211
212
  }
212
213
 
214
+ function normalizeAgentSessionIdSegment(value: string): string {
215
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
216
+ }
217
+
218
+ export function makeAgentV2RemoteConversationId(
219
+ provider: AgentProvider,
220
+ agentSessionId: string,
221
+ ): string {
222
+ return `agent-remote-${provider}-${normalizeAgentSessionIdSegment(agentSessionId)}`;
223
+ }
224
+
213
225
  function stringify(value: unknown): string {
214
226
  if (typeof value === "string") return value;
215
227
  try {
@@ -264,6 +276,12 @@ function extractItem(value: unknown): Record<string, unknown> | undefined {
264
276
  return asRecord(raw.item) ?? raw;
265
277
  }
266
278
 
279
+ function extractTurn(value: unknown): Record<string, unknown> | undefined {
280
+ const raw = asRecord(value);
281
+ if (!raw) return undefined;
282
+ return asRecord(raw.turn) ?? raw;
283
+ }
284
+
267
285
  function stringifyDefined(value: unknown): string | undefined {
268
286
  if (value === undefined || value === null || value === "") return undefined;
269
287
  return stringify(value);
@@ -302,6 +320,29 @@ function normalizePlanStatus(value: unknown): AgentPlanStep["status"] {
302
320
  return "pending";
303
321
  }
304
322
 
323
+ function agentStatusFromThreadStatus(value: unknown): AgentStatus | undefined {
324
+ const raw = asRecord(value);
325
+ const status = typeof value === "string"
326
+ ? value
327
+ : firstString(raw, ["status", "state", "phase", "type"]) ??
328
+ firstString(asRecord(raw?.status), ["status", "state", "phase", "type"]);
329
+ const normalized = normalizedIdentifier(status);
330
+ if (!normalized) return undefined;
331
+ if (["active", "running", "inprogress", "busy", "working", "streaming"].includes(normalized)) {
332
+ return "running";
333
+ }
334
+ if (["idle", "ready", "completed", "complete", "done", "finished"].includes(normalized)) {
335
+ return "idle";
336
+ }
337
+ if (["waitingpermission", "waitingforpermission", "requirespermission", "needspermission"].includes(normalized)) {
338
+ return "waiting_permission";
339
+ }
340
+ if (["error", "failed", "systemerror", "fatal"].includes(normalized)) {
341
+ return "error";
342
+ }
343
+ return undefined;
344
+ }
345
+
305
346
  function nameFromToolMethod(method: string): string {
306
347
  if (method.includes("commandExecution")) return "命令";
307
348
  if (method.includes("fileChange")) return "文件修改";
@@ -670,7 +711,9 @@ function parseStructuredInputQuestion(value: unknown, index: number): AgentStruc
670
711
  question,
671
712
  isOther: raw.isOther === true,
672
713
  isSecret: raw.isSecret === true || raw.secret === true,
673
- selectionLimit: firstNumber(raw, ["selectionLimit", "maxSelections"]),
714
+ selectionLimit: raw.multiSelect === true
715
+ ? options.length || undefined
716
+ : firstNumber(raw, ["selectionLimit", "maxSelections"]),
674
717
  options: options.length > 0 ? options : undefined,
675
718
  };
676
719
  }
@@ -1237,6 +1280,7 @@ function parseRemoteSessions(value: unknown): Array<{
1237
1280
  const raw = asRecord(value);
1238
1281
  const sessionsValue =
1239
1282
  Array.isArray(value) ? value :
1283
+ Array.isArray(raw?.data) ? raw.data :
1240
1284
  Array.isArray(raw?.threads) ? raw.threads :
1241
1285
  Array.isArray(raw?.sessions) ? raw.sessions :
1242
1286
  Array.isArray(raw?.items) ? raw.items :
@@ -1262,7 +1306,7 @@ function parseRemoteSessions(value: unknown): Array<{
1262
1306
  result.push({
1263
1307
  id,
1264
1308
  cwd: firstString(source, ["cwd", "workingDirectory", "workspacePath"]),
1265
- title: firstString(source, ["title", "name", "summary"]),
1309
+ title: firstString(source, ["title", "name", "summary", "preview"]),
1266
1310
  model: firstString(source, ["model", "modelId"]),
1267
1311
  createdAt: parseTimestamp(source.createdAt ?? source.created_at),
1268
1312
  lastActivityAt: parseTimestamp(source.lastActivityAt ?? source.updatedAt ?? source.modifiedAt ?? source.lastModified ?? source.updated_at),
@@ -1271,10 +1315,279 @@ function parseRemoteSessions(value: unknown): Array<{
1271
1315
  return result;
1272
1316
  }
1273
1317
 
1318
+ function threadFromProviderResult(value: unknown): Record<string, unknown> | undefined {
1319
+ const raw = asRecord(value);
1320
+ return asRecord(raw?.thread) ?? raw;
1321
+ }
1322
+
1323
+ function threadFromTurnsListResult(sessionId: string, value: unknown): Record<string, unknown> | undefined {
1324
+ const raw = asRecord(value);
1325
+ const order = firstString(raw, ["sortDirection", "order", "sortOrder", "sort"]);
1326
+ const turns =
1327
+ Array.isArray(raw?.turns) ? raw.turns :
1328
+ Array.isArray(raw?.data) ? raw.data :
1329
+ Array.isArray(raw?.items) ? raw.items :
1330
+ Array.isArray(value) ? value :
1331
+ undefined;
1332
+ if (!turns) return undefined;
1333
+ const extracted = turns
1334
+ .map(extractTurn)
1335
+ .filter((turn): turn is Record<string, unknown> => Boolean(turn));
1336
+ const chronological = order && /asc/i.test(order)
1337
+ ? extracted
1338
+ : [...extracted].reverse();
1339
+ return { id: sessionId, turns: chronological };
1340
+ }
1341
+
1342
+ function turnItemsFromThread(thread: Record<string, unknown> | undefined): Record<string, unknown>[] {
1343
+ if (!thread) return [];
1344
+ const turnsValue = Array.isArray(thread.turns)
1345
+ ? thread.turns
1346
+ : Array.isArray(thread.items)
1347
+ ? [{ id: firstString(thread, ["turnId", "id"]), items: thread.items }]
1348
+ : [];
1349
+ const result: Record<string, unknown>[] = [];
1350
+ for (const turnEntry of turnsValue) {
1351
+ const turn = extractTurn(turnEntry);
1352
+ if (!turn) continue;
1353
+ const turnId = firstString(turn, ["id", "turnId"]);
1354
+ const createdAt = parseTimestamp(
1355
+ turn.createdAt ??
1356
+ turn.created_at ??
1357
+ turn.startedAt ??
1358
+ turn.started_at ??
1359
+ turn.completedAt ??
1360
+ turn.completed_at ??
1361
+ turn.updatedAt ??
1362
+ turn.updated_at,
1363
+ );
1364
+ const itemsRecord = asRecord(turn.items);
1365
+ const items = Array.isArray(turn.items)
1366
+ ? turn.items
1367
+ : Array.isArray(itemsRecord?.data)
1368
+ ? itemsRecord.data
1369
+ : Array.isArray(itemsRecord?.items)
1370
+ ? itemsRecord.items
1371
+ : Array.isArray(turn.item)
1372
+ ? turn.item
1373
+ : Array.isArray(turn.events)
1374
+ ? turn.events
1375
+ : [];
1376
+ for (const itemEntry of items) {
1377
+ const item = extractItem(itemEntry);
1378
+ if (!item) continue;
1379
+ result.push({
1380
+ ...item,
1381
+ ...(turnId && !firstString(item, ["turnId"]) ? { turnId } : {}),
1382
+ __turnCreatedAt: createdAt,
1383
+ });
1384
+ }
1385
+ }
1386
+ return result;
1387
+ }
1388
+
1389
+ function activeTurnIdFromThread(thread: Record<string, unknown> | undefined): string | undefined {
1390
+ if (!thread) return undefined;
1391
+ const turnsValue = Array.isArray(thread.turns)
1392
+ ? thread.turns
1393
+ : Array.isArray(thread.items)
1394
+ ? [{ id: firstString(thread, ["turnId", "id"]), items: thread.items, status: thread.status }]
1395
+ : [];
1396
+ let activeTurnId: string | undefined;
1397
+ for (const turnEntry of turnsValue) {
1398
+ const turn = extractTurn(turnEntry);
1399
+ if (!turn) continue;
1400
+ const turnId = firstString(turn, ["id", "turnId"]);
1401
+ if (!turnId) continue;
1402
+ const status =
1403
+ firstString(turn, ["status", "state", "phase"]) ??
1404
+ firstString(asRecord(turn.status), ["status", "state", "phase", "type"]);
1405
+ const normalized = normalizedIdentifier(status);
1406
+ if ([
1407
+ "active",
1408
+ "running",
1409
+ "inprogress",
1410
+ "busy",
1411
+ "working",
1412
+ "streaming",
1413
+ "waitingpermission",
1414
+ "waitingforpermission",
1415
+ "requirespermission",
1416
+ "needspermission",
1417
+ ].includes(normalized)) {
1418
+ activeTurnId = turnId;
1419
+ }
1420
+ }
1421
+ return activeTurnId;
1422
+ }
1423
+
1424
+ function timelineItemFromProviderItem(
1425
+ item: Record<string, unknown>,
1426
+ conversationId: string,
1427
+ index: number,
1428
+ ): AgentTimelineItem | undefined {
1429
+ const itemType = firstString(item, ["type"]);
1430
+ const normalized = normalizedIdentifier(itemType);
1431
+ const itemId = firstString(item, ["id", "itemId", "messageId", "toolCallId"]) ?? `history-${index + 1}`;
1432
+ const createdAt =
1433
+ parseTimestamp(item.createdAt ?? item.created_at ?? item.timestamp ?? item.__turnCreatedAt) ??
1434
+ Date.now() + index;
1435
+ const base = {
1436
+ id: itemId,
1437
+ conversationId,
1438
+ turnId: firstString(item, ["turnId"]),
1439
+ itemId,
1440
+ createdAt,
1441
+ updatedAt: parseTimestamp(item.updatedAt ?? item.updated_at) ?? createdAt,
1442
+ isStreaming: false,
1443
+ };
1444
+
1445
+ if (
1446
+ normalized === "usermessage" ||
1447
+ normalized === "userinput" ||
1448
+ (normalized === "message" && item.role === "user")
1449
+ ) {
1450
+ const content = contentBlocksFromItem(item);
1451
+ const text = textFromBlocks(content);
1452
+ if (!content.length && !text) return undefined;
1453
+ return {
1454
+ ...base,
1455
+ type: "message",
1456
+ kind: "chat",
1457
+ role: "user",
1458
+ content,
1459
+ text,
1460
+ };
1461
+ }
1462
+
1463
+ if (
1464
+ normalized === "agentmessage" ||
1465
+ normalized === "assistantmessage" ||
1466
+ (normalized === "message" && item.role !== "user")
1467
+ ) {
1468
+ const content = contentBlocksFromItem(item);
1469
+ const text = textFromBlocks(content);
1470
+ if (!content.length && !text) return undefined;
1471
+ return {
1472
+ ...base,
1473
+ type: "message",
1474
+ kind: "chat",
1475
+ role: item.role === "system" ? "system" : "assistant",
1476
+ content,
1477
+ text,
1478
+ };
1479
+ }
1480
+
1481
+ if (normalized === "reasoning" || normalized === "thinking") {
1482
+ const text = firstString(item, ["text", "content", "summary", "message"]) ??
1483
+ stringifyDefined(item.contentItems ?? item.summary);
1484
+ if (!text) return undefined;
1485
+ return {
1486
+ ...base,
1487
+ type: "status",
1488
+ kind: "thinking",
1489
+ role: "system",
1490
+ text,
1491
+ };
1492
+ }
1493
+
1494
+ if (normalized === "enteredreviewmode" || normalized === "exitedreviewmode") {
1495
+ const text = firstString(item, ["review", "text", "content", "summary", "message"]);
1496
+ return {
1497
+ ...base,
1498
+ type: "status",
1499
+ kind: "review",
1500
+ role: "system",
1501
+ text: text ?? (normalized === "enteredreviewmode" ? "正在审查" : "审查已完成"),
1502
+ };
1503
+ }
1504
+
1505
+ if (normalized === "contextcompaction") {
1506
+ return {
1507
+ ...base,
1508
+ type: "status",
1509
+ kind: "context_compaction",
1510
+ role: "system",
1511
+ text: firstString(item, ["text", "summary", "message"]) ?? "上下文已压缩",
1512
+ };
1513
+ }
1514
+
1515
+ if (isSubagentItemType(itemType)) {
1516
+ const subagent = decodeSubagentAction(item, normalizeToolStatus(item.status, true));
1517
+ if (!subagent) return undefined;
1518
+ return {
1519
+ ...base,
1520
+ type: "status",
1521
+ kind: "subagent_action",
1522
+ role: "system",
1523
+ text: summarizeSubagentAction(subagent),
1524
+ subagent,
1525
+ };
1526
+ }
1527
+
1528
+ if (isToolItemType(itemType) || toolNameFromItem(item)) {
1529
+ const toolId = firstString(item, ["id", "itemId", "toolCallId"]) ?? itemId;
1530
+ const toolCall: AgentToolCall = {
1531
+ id: toolId,
1532
+ name: toolNameFromItem(item) ?? "工具",
1533
+ input: toolInputFromItem(item),
1534
+ output:
1535
+ normalized === "filechange" || normalized === "diff"
1536
+ ? extractDiffText(item) ?? firstString(item, ["output", "summary"])
1537
+ : firstString(item, ["aggregatedOutput", "output", "stdout", "stderr"]) ??
1538
+ stringifyDefined(item.result ?? item.error ?? item.contentItems),
1539
+ createdAt,
1540
+ status: normalizeToolStatus(item.status, true),
1541
+ };
1542
+ const kind: AgentTimelineKind = toolCall.name.includes("文件") || normalized === "filechange" || normalized === "diff"
1543
+ ? "file_change"
1544
+ : toolCall.name.includes("命令") || normalized === "commandexecution"
1545
+ ? "command_execution"
1546
+ : "tool_activity";
1547
+ return {
1548
+ ...base,
1549
+ id: `tool:${toolId}`,
1550
+ type: "tool_call",
1551
+ kind,
1552
+ itemId: toolId,
1553
+ toolCall,
1554
+ commandExecution: kind === "command_execution" ? commandExecutionFromTool(toolCall) : undefined,
1555
+ fileChange: kind === "file_change" ? fileChangeFromTool(toolCall) : undefined,
1556
+ };
1557
+ }
1558
+
1559
+ return undefined;
1560
+ }
1561
+
1562
+ function timelineItemsFromProviderThread(
1563
+ value: unknown,
1564
+ conversationId: string,
1565
+ ): AgentTimelineItem[] {
1566
+ const items = turnItemsFromThread(threadFromProviderResult(value))
1567
+ .map((item, index) => timelineItemFromProviderItem(item, conversationId, index))
1568
+ .filter((item): item is AgentTimelineItem => Boolean(item));
1569
+ const byId = new Map<string, AgentTimelineItem>();
1570
+ for (const item of items) {
1571
+ byId.set(item.id, item);
1572
+ }
1573
+ return [...byId.values()].sort((a, b) => a.createdAt - b.createdAt).slice(-MAX_TIMELINE_ITEMS);
1574
+ }
1575
+
1576
+ function previewFromTimelineItem(item: AgentTimelineItem): string | undefined {
1577
+ if (item.error) return item.error;
1578
+ if (item.text) return item.text;
1579
+ if (item.content?.length) return textFromBlocks(item.content);
1580
+ if (item.toolCall) return `${item.toolCall.name} · ${item.toolCall.status}`;
1581
+ if (item.permission) return `需要授权 ${item.permission.toolName ?? ""}`.trim();
1582
+ if (item.subagent) return summarizeSubagentAction(item.subagent);
1583
+ return undefined;
1584
+ }
1585
+
1274
1586
  export class AgentWorkspaceProxy {
1275
1587
  private clients = new Map<AgentProvider, AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient>();
1276
1588
  private agentProtocols = new Map<AgentProvider, AgentProtocol>();
1277
1589
  private providerCapabilities = new Map<AgentProvider, ProviderRuntimeCapabilities>();
1590
+ private providerErrors = new Map<AgentProvider, string>();
1278
1591
  private initialized = false;
1279
1592
  private status: AgentStatus = "unavailable";
1280
1593
  private error: string | undefined;
@@ -1284,6 +1597,9 @@ export class AgentWorkspaceProxy {
1284
1597
  private conversations = new Map<string, AgentConversation>();
1285
1598
  private conversationByAgentSessionId = new Map<string, string>();
1286
1599
  private timelines = new Map<string, AgentTimelineItem[]>();
1600
+ // Opaque codex turns cursor per conversation, pointing at OLDER history to
1601
+ // page through (captured at hydration, advanced on each history.request).
1602
+ private historyCursors = new Map<string, string | undefined>();
1287
1603
  private toolOutputBuffers = new Map<string, string>();
1288
1604
  private pendingPermissions = new Map<string, AgentPermission>();
1289
1605
  private permissionWaiters = new Map<string, PendingPermissionWaiter>();
@@ -1334,6 +1650,11 @@ export class AgentWorkspaceProxy {
1334
1650
  this.sendSnapshot(payload.conversationId);
1335
1651
  break;
1336
1652
  }
1653
+ case "agent.v2.history.request": {
1654
+ const payload = parseTypedPayload("agent.v2.history.request", envelope.payload);
1655
+ await this.loadOlderHistory(payload);
1656
+ break;
1657
+ }
1337
1658
  case "agent.v2.prompt": {
1338
1659
  const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
1339
1660
  await this.sendPrompt(payload);
@@ -1347,11 +1668,21 @@ export class AgentWorkspaceProxy {
1347
1668
  case "agent.v2.cancel": {
1348
1669
  const payload = parseTypedPayload("agent.v2.cancel", envelope.payload);
1349
1670
  const conversation = this.conversations.get(payload.conversationId);
1671
+ if (!conversation) break;
1672
+ const turnId = this.currentTurnIds.get(payload.conversationId);
1673
+ if (this.protocolForProvider(conversation.provider) === "codex-app-server" && !turnId) {
1674
+ this.rejectAgentAction(
1675
+ conversation,
1676
+ "无法停止 Codex:当前运行 turn 尚未同步,请重新打开对话后再试。",
1677
+ conversation.status,
1678
+ );
1679
+ break;
1680
+ }
1350
1681
  this.cancelPendingPermissions(payload.conversationId);
1351
- const cancelClient = conversation ? this.clientForProvider(conversation.provider) : undefined;
1682
+ const cancelClient = this.clientForProvider(conversation.provider);
1352
1683
  cancelClient?.cancel({
1353
- sessionId: conversation?.agentSessionId,
1354
- turnId: this.currentTurnIds.get(payload.conversationId),
1684
+ sessionId: conversation.agentSessionId,
1685
+ turnId,
1355
1686
  });
1356
1687
  this.forgetCurrentTurn(payload.conversationId);
1357
1688
  this.updateConversationStatus(payload.conversationId, "idle");
@@ -1382,10 +1713,39 @@ export class AgentWorkspaceProxy {
1382
1713
  return this.clients.get(provider);
1383
1714
  }
1384
1715
 
1716
+ private shouldRecycleProviderClient(provider: AgentProvider, error: unknown): boolean {
1717
+ if (provider !== "codex") return false;
1718
+ const message = error instanceof Error ? error.message : String(error);
1719
+ return /ACP agent is not running|ACP request timed out|Transport channel closed|TokenRefreshFailed|Failed to parse server response|channel closed|EPIPE|ECONNRESET/i.test(message);
1720
+ }
1721
+
1722
+ private async recycleProviderClient(
1723
+ provider: AgentProvider,
1724
+ error: unknown,
1725
+ ): Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined> {
1726
+ if (!this.shouldRecycleProviderClient(provider, error)) return undefined;
1727
+ const message = error instanceof Error ? error.message : String(error);
1728
+ const existing = this.clients.get(provider);
1729
+ if (existing) {
1730
+ try { existing.stop(); } catch {}
1731
+ }
1732
+ this.clients.delete(provider);
1733
+ this.agentProtocols.delete(provider);
1734
+ this.providerErrors.set(provider, message);
1735
+ return this.ensureProviderClient(provider);
1736
+ }
1737
+
1385
1738
  private protocolForProvider(provider: AgentProvider): AgentProtocol | undefined {
1386
1739
  return this.agentProtocols.get(provider);
1387
1740
  }
1388
1741
 
1742
+ private defaultModelForProvider(provider: AgentProvider): string | undefined {
1743
+ const capabilities = this.providerCapabilities.get(provider);
1744
+ const defaultModel = capabilities?.defaultModel?.trim();
1745
+ if (defaultModel) return defaultModel;
1746
+ return capabilities?.models?.find((model) => model.id && model.id !== "default")?.id;
1747
+ }
1748
+
1389
1749
  private async initialize(): Promise<void> {
1390
1750
  if (this.initialized) return;
1391
1751
  this.initialized = true;
@@ -1452,6 +1812,7 @@ export class AgentWorkspaceProxy {
1452
1812
  try {
1453
1813
  const client = await tryCreateClient(resolved);
1454
1814
  this.clients.set(provider, client);
1815
+ this.providerErrors.delete(provider);
1455
1816
  await this.refreshProviderCapabilities(provider, client, resolved.protocol);
1456
1817
  this.status = "idle";
1457
1818
  this.error = undefined;
@@ -1471,6 +1832,7 @@ export class AgentWorkspaceProxy {
1471
1832
  };
1472
1833
  const client = await tryCreateClient(fallback);
1473
1834
  this.clients.set(provider, client);
1835
+ this.providerErrors.delete(provider);
1474
1836
  await this.refreshProviderCapabilities(provider, client, fallback.protocol);
1475
1837
  this.status = "idle";
1476
1838
  this.error = undefined;
@@ -1485,6 +1847,8 @@ export class AgentWorkspaceProxy {
1485
1847
  if (this.input.verbose) {
1486
1848
  process.stderr.write(`[agent:v2] failed to start ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
1487
1849
  }
1850
+ this.providerErrors.set(provider, error instanceof Error ? error.message : String(error));
1851
+ this.sendCapabilities();
1488
1852
  return undefined;
1489
1853
  }
1490
1854
  }
@@ -1532,12 +1896,21 @@ export class AgentWorkspaceProxy {
1532
1896
  await this.initialize();
1533
1897
  for (const [provider, client] of this.clients) {
1534
1898
  try {
1535
- const result = await client.listSessions();
1899
+ let activeClient = client;
1900
+ let result: unknown;
1901
+ try {
1902
+ result = await activeClient.listSessions();
1903
+ } catch (error) {
1904
+ const recovered = await this.recycleProviderClient(provider, error);
1905
+ if (!recovered) throw error;
1906
+ activeClient = recovered;
1907
+ result = await activeClient.listSessions();
1908
+ }
1536
1909
  for (const remote of parseRemoteSessions(result)) {
1537
1910
  const agentSessionId = remote.id;
1538
1911
  const existingId = this.conversationByAgentSessionId.get(agentSessionId);
1539
1912
  const now = Date.now();
1540
- const conversationId = existingId ?? `agent:${agentSessionId}`;
1913
+ const conversationId = existingId ?? makeAgentV2RemoteConversationId(provider, agentSessionId);
1541
1914
  const existing = this.conversations.get(conversationId);
1542
1915
  const cwd = remote.cwd ?? existing?.cwd ?? this.input.cwd;
1543
1916
  const conversation: AgentConversation = {
@@ -1568,6 +1941,104 @@ export class AgentWorkspaceProxy {
1568
1941
  }
1569
1942
  }
1570
1943
 
1944
+ private async hydrateConversationFromProvider(
1945
+ conversation: AgentConversation,
1946
+ seedResult?: unknown,
1947
+ ): Promise<void> {
1948
+ let client = this.clientForProvider(conversation.provider);
1949
+ if (!client || !conversation.agentSessionId) return;
1950
+ let source = seedResult;
1951
+ const hasSeedTurns = turnItemsFromThread(threadFromProviderResult(seedResult)).length > 0;
1952
+ if (!hasSeedTurns) {
1953
+ const readSession = (client as { readSession?: (input: { sessionId: string; includeTurns?: boolean }) => Promise<unknown> }).readSession;
1954
+ if (typeof readSession === "function") {
1955
+ try {
1956
+ source = await readSession.call(client, {
1957
+ sessionId: conversation.agentSessionId,
1958
+ includeTurns: true,
1959
+ });
1960
+ } catch (error) {
1961
+ const recovered = await this.recycleProviderClient(conversation.provider, error);
1962
+ if (recovered) client = recovered;
1963
+ if (this.input.verbose) {
1964
+ process.stderr.write(`[agent:v2] thread/read hydration failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`);
1965
+ }
1966
+ }
1967
+ }
1968
+ if (turnItemsFromThread(threadFromProviderResult(source)).length === 0) {
1969
+ const listTurns = (client as { listTurns?: (input: {
1970
+ sessionId: string;
1971
+ limit?: number;
1972
+ cursor?: string;
1973
+ sortDirection?: "asc" | "desc";
1974
+ itemsView?: "summary" | "full";
1975
+ }) => Promise<unknown> }).listTurns;
1976
+ if (typeof listTurns === "function") {
1977
+ try {
1978
+ let turnsResult: unknown;
1979
+ try {
1980
+ turnsResult = await listTurns.call(client, {
1981
+ sessionId: conversation.agentSessionId,
1982
+ limit: MAX_TIMELINE_ITEMS,
1983
+ sortDirection: "desc",
1984
+ itemsView: "full",
1985
+ });
1986
+ } catch (error) {
1987
+ const recovered = await this.recycleProviderClient(conversation.provider, error);
1988
+ if (!recovered) throw error;
1989
+ client = recovered;
1990
+ const recoveredListTurns = (client as { listTurns?: typeof listTurns }).listTurns;
1991
+ if (typeof recoveredListTurns !== "function") throw error;
1992
+ turnsResult = await recoveredListTurns.call(client, {
1993
+ sessionId: conversation.agentSessionId,
1994
+ limit: MAX_TIMELINE_ITEMS,
1995
+ sortDirection: "desc",
1996
+ itemsView: "full",
1997
+ });
1998
+ }
1999
+ const turnsThread = threadFromTurnsListResult(conversation.agentSessionId, turnsResult);
2000
+ if (turnsThread) source = { thread: turnsThread };
2001
+ // Capture the opaque cursor pointing at OLDER turns so the client
2002
+ // can page further back via agent.v2.history.request.
2003
+ const olderCursor = firstString(asRecord(turnsResult), ["nextCursor", "next_cursor", "cursor"]);
2004
+ this.historyCursors.set(conversation.id, olderCursor);
2005
+ } catch (error) {
2006
+ if (this.input.verbose) {
2007
+ process.stderr.write(`[agent:v2] thread/turns/list hydration failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`);
2008
+ }
2009
+ }
2010
+ }
2011
+ }
2012
+ }
2013
+
2014
+ const thread = threadFromProviderResult(source);
2015
+ const activeTurnId = activeTurnIdFromThread(thread);
2016
+ if (activeTurnId) this.rememberTurnConversationId(conversation.id, activeTurnId);
2017
+ const model = firstString(thread, ["model", "modelId", "currentModel"]);
2018
+ if (model && !conversation.model) conversation.model = model;
2019
+
2020
+ const hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
2021
+ if (hydratedItems.length === 0) return;
2022
+ const existing = this.timelines.get(conversation.id) ?? [];
2023
+ const merged = new Map<string, AgentTimelineItem>();
2024
+ for (const item of [...existing, ...hydratedItems]) {
2025
+ merged.set(item.id, item);
2026
+ }
2027
+ const nextItems = [...merged.values()]
2028
+ .sort((a, b) => a.createdAt - b.createdAt)
2029
+ .slice(-MAX_TIMELINE_ITEMS);
2030
+ this.timelines.set(conversation.id, nextItems);
2031
+ for (const item of nextItems) {
2032
+ this.rememberItemConversationId(conversation.id, item);
2033
+ }
2034
+ const lastPreview = [...nextItems].reverse()
2035
+ .map((item) => previewText(previewFromTimelineItem(item) ?? ""))
2036
+ .find(Boolean);
2037
+ if (lastPreview && !conversation.lastMessagePreview) {
2038
+ conversation.lastMessagePreview = lastPreview;
2039
+ }
2040
+ }
2041
+
1571
2042
  private sendCapabilities(): void {
1572
2043
  const providers = this.input.availableProviders.map((provider) => {
1573
2044
  const client = this.clients.get(provider);
@@ -1587,7 +2058,7 @@ export class AgentWorkspaceProxy {
1587
2058
  id: provider,
1588
2059
  label: providerLabel(provider),
1589
2060
  enabled,
1590
- reason: enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`,
2061
+ reason: enabled ? undefined : this.providerErrors.get(provider) ?? `${providerLabel(provider)} 未安装或启动失败`,
1591
2062
  supportsImages,
1592
2063
  supportsPermission,
1593
2064
  supportsPlan: enabled,
@@ -1659,24 +2130,71 @@ export class AgentWorkspaceProxy {
1659
2130
  );
1660
2131
  }
1661
2132
 
1662
- const client = await this.ensureProviderClient(provider);
1663
- if (!client) {
1664
- return this.openFailure(
1665
- payload,
1666
- `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`,
1667
- );
1668
- }
1669
-
1670
- const cwd = payload.cwd ?? this.input.cwd;
2133
+ let cwd = payload.cwd ?? this.input.cwd;
1671
2134
  let agentSessionId = payload.agentSessionId;
1672
2135
  let existingConversation =
1673
2136
  (payload.conversationId ? this.conversations.get(payload.conversationId) : undefined) ??
1674
2137
  (agentSessionId ? this.conversations.get(this.conversationByAgentSessionId.get(agentSessionId) ?? "") : undefined);
2138
+ if (!payload.cwd && existingConversation?.cwd) cwd = existingConversation.cwd;
2139
+
2140
+ let client = await this.ensureProviderClient(provider);
2141
+ if (!client) {
2142
+ const message = this.providerErrors.get(provider) ?? `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`;
2143
+ if (existingConversation) {
2144
+ this.activeConversationId = existingConversation.id;
2145
+ this.input.send(createEnvelope({
2146
+ type: "agent.v2.conversation.opened",
2147
+ sessionId: this.input.sessionId,
2148
+ payload: {
2149
+ conversation: existingConversation,
2150
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
2151
+ requestedConversationId: payload.conversationId,
2152
+ providerError: message,
2153
+ },
2154
+ }));
2155
+ this.sendCapabilities();
2156
+ return existingConversation;
2157
+ }
2158
+ if (payload.conversationId) {
2159
+ return this.openOfflineHistory(payload, message, cwd);
2160
+ }
2161
+ return this.openFailure(payload, message, cwd);
2162
+ }
1675
2163
 
1676
2164
  if (existingConversation && existingConversation.status !== "error" && existingConversation.agentSessionId) {
1677
- if (payload.conversationId && existingConversation.id !== payload.conversationId) {
2165
+ const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
2166
+ if (
2167
+ payload.conversationId &&
2168
+ payload.conversationId === requestedCanonicalId &&
2169
+ existingConversation.id !== payload.conversationId
2170
+ ) {
1678
2171
  existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
1679
2172
  }
2173
+ if ((this.timelines.get(existingConversation.id) ?? []).length === 0 && existingConversation.status !== "running") {
2174
+ try {
2175
+ const existingAgentSessionId = existingConversation.agentSessionId!;
2176
+ let result: unknown;
2177
+ try {
2178
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
2179
+ } catch (error) {
2180
+ const recovered = await this.recycleProviderClient(provider, error);
2181
+ if (!recovered) throw error;
2182
+ client = recovered;
2183
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
2184
+ }
2185
+ const nextAgentSessionId = this.extractSessionId(result);
2186
+ if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
2187
+ this.conversationByAgentSessionId.delete(existingAgentSessionId);
2188
+ existingConversation.agentSessionId = nextAgentSessionId;
2189
+ this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
2190
+ }
2191
+ await this.hydrateConversationFromProvider(existingConversation, result);
2192
+ } catch (error) {
2193
+ if (this.input.verbose) {
2194
+ process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
2195
+ }
2196
+ }
2197
+ }
1680
2198
  this.activeConversationId = existingConversation.id;
1681
2199
  this.input.send(createEnvelope({
1682
2200
  type: "agent.v2.conversation.opened",
@@ -1684,18 +2202,29 @@ export class AgentWorkspaceProxy {
1684
2202
  payload: {
1685
2203
  conversation: existingConversation,
1686
2204
  snapshot: this.timelines.get(existingConversation.id) ?? [],
2205
+ requestedConversationId: payload.conversationId,
1687
2206
  },
1688
2207
  }));
1689
2208
  return existingConversation;
1690
2209
  }
1691
2210
 
1692
2211
  try {
1693
- const result = agentSessionId
1694
- ? await client.loadSession({ sessionId: agentSessionId, cwd })
1695
- : await client.newSession({ cwd });
2212
+ let result: unknown;
2213
+ try {
2214
+ result = agentSessionId
2215
+ ? await client.loadSession({ sessionId: agentSessionId, cwd })
2216
+ : await client.newSession({ cwd });
2217
+ } catch (error) {
2218
+ const recovered = await this.recycleProviderClient(provider, error);
2219
+ if (!recovered) throw error;
2220
+ client = recovered;
2221
+ result = agentSessionId
2222
+ ? await client.loadSession({ sessionId: agentSessionId, cwd })
2223
+ : await client.newSession({ cwd });
2224
+ }
1696
2225
  agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
1697
2226
  const now = Date.now();
1698
- const conversationId = payload.conversationId ?? `agent:${agentSessionId}`;
2227
+ const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
1699
2228
  const conversation: AgentConversation = {
1700
2229
  ...existingConversation,
1701
2230
  id: conversationId,
@@ -1717,10 +2246,15 @@ export class AgentWorkspaceProxy {
1717
2246
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
1718
2247
  this.activeConversationId = conversation.id;
1719
2248
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
2249
+ await this.hydrateConversationFromProvider(conversation, result);
1720
2250
  this.input.send(createEnvelope({
1721
2251
  type: "agent.v2.conversation.opened",
1722
2252
  sessionId: this.input.sessionId,
1723
- payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
2253
+ payload: {
2254
+ conversation,
2255
+ snapshot: this.timelines.get(conversation.id) ?? [],
2256
+ requestedConversationId: payload.conversationId,
2257
+ },
1724
2258
  }));
1725
2259
  return conversation;
1726
2260
  } catch (error) {
@@ -1772,15 +2306,71 @@ export class AgentWorkspaceProxy {
1772
2306
  this.input.send(createEnvelope({
1773
2307
  type: "agent.v2.conversation.opened",
1774
2308
  sessionId: this.input.sessionId,
1775
- payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
2309
+ payload: {
2310
+ conversation,
2311
+ snapshot: this.timelines.get(conversation.id) ?? [],
2312
+ requestedConversationId: payload.conversationId,
2313
+ },
1776
2314
  }));
1777
2315
  return conversation;
1778
2316
  }
1779
2317
 
2318
+ private openOfflineHistory(
2319
+ payload: {
2320
+ conversationId?: string;
2321
+ agentSessionId?: string;
2322
+ cwd?: string;
2323
+ provider?: AgentProvider;
2324
+ model?: string;
2325
+ reasoningEffort?: string;
2326
+ permissionMode?: AgentPermissionMode;
2327
+ collaborationMode?: AgentCollaborationMode;
2328
+ title?: string;
2329
+ },
2330
+ message: string,
2331
+ cwd = payload.cwd ?? this.input.cwd,
2332
+ ): AgentConversation {
2333
+ const now = Date.now();
2334
+ const conversation: AgentConversation = {
2335
+ id: payload.conversationId!,
2336
+ agentSessionId: payload.agentSessionId,
2337
+ provider: payload.provider ?? this.input.availableProviders[0] ?? "codex",
2338
+ cwd,
2339
+ title: payload.title ?? titleFromCwd(cwd),
2340
+ model: payload.model,
2341
+ reasoningEffort: payload.reasoningEffort,
2342
+ permissionMode: payload.permissionMode,
2343
+ collaborationMode: payload.collaborationMode,
2344
+ status: "idle",
2345
+ archived: false,
2346
+ lastActivityAt: now,
2347
+ createdAt: now,
2348
+ };
2349
+ this.conversations.set(conversation.id, conversation);
2350
+ if (conversation.agentSessionId) {
2351
+ this.conversationByAgentSessionId.set(conversation.agentSessionId, conversation.id);
2352
+ }
2353
+ this.activeConversationId = conversation.id;
2354
+ this.input.send(createEnvelope({
2355
+ type: "agent.v2.conversation.opened",
2356
+ sessionId: this.input.sessionId,
2357
+ payload: {
2358
+ conversation,
2359
+ snapshot: this.timelines.get(conversation.id) ?? [],
2360
+ requestedConversationId: payload.conversationId,
2361
+ providerError: message,
2362
+ },
2363
+ }));
2364
+ this.sendCapabilities();
2365
+ return conversation;
2366
+ }
2367
+
1780
2368
  private async sendPrompt(payload: {
1781
2369
  conversationId: string;
1782
2370
  clientMessageId: string;
1783
2371
  contentBlocks: AgentContentBlock[];
2372
+ delivery?: "auto" | "new_turn" | "steer";
2373
+ targetTurnId?: string;
1784
2374
  model?: string;
1785
2375
  reasoningEffort?: string;
1786
2376
  permissionMode?: AgentPermissionMode;
@@ -1791,39 +2381,28 @@ export class AgentWorkspaceProxy {
1791
2381
  await this.openConversation({ conversationId: payload.conversationId });
1792
2382
  if (!conversation) return;
1793
2383
  if (!conversation.agentSessionId) {
1794
- this.addItem(payload.conversationId, {
1795
- id: id("error"),
1796
- conversationId: payload.conversationId,
1797
- type: "error",
1798
- error: "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。",
1799
- createdAt: Date.now(),
1800
- });
2384
+ this.rejectAgentAction(
2385
+ conversation,
2386
+ "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。",
2387
+ );
1801
2388
  return;
1802
2389
  }
1803
- const client = this.clientForProvider(conversation.provider);
2390
+ const client = this.clientForProvider(conversation.provider) ?? await this.ensureProviderClient(conversation.provider);
1804
2391
  if (!client) {
1805
- this.addItem(conversation.id, {
1806
- id: id("error"),
1807
- conversationId: conversation.id,
1808
- type: "error",
1809
- error: `${providerLabel(conversation.provider)} 未连接,消息没有发送。`,
1810
- createdAt: Date.now(),
1811
- });
2392
+ this.rejectAgentAction(
2393
+ conversation,
2394
+ this.providerErrors.get(conversation.provider) ?? `${providerLabel(conversation.provider)} 未连接,消息没有发送。`,
2395
+ "error",
2396
+ );
1812
2397
  return;
1813
2398
  }
1814
2399
 
1815
2400
  const protocol = this.protocolForProvider(conversation.provider);
1816
2401
  if (payload.contentBlocks.some((block) => block.type === "image") && !protocolSupportsImages(protocol)) {
1817
- conversation.status = "idle";
1818
- conversation.lastActivityAt = Date.now();
1819
- this.emitConversation(conversation);
1820
- this.addItem(conversation.id, {
1821
- id: id("error"),
1822
- conversationId: conversation.id,
1823
- type: "error",
1824
- error: "当前 Agent provider 暂不支持图片输入,请升级 CLI 或切换到 Codex。",
1825
- createdAt: Date.now(),
1826
- });
2402
+ this.rejectAgentAction(
2403
+ conversation,
2404
+ "当前 Agent provider 暂不支持图片输入,请升级 CLI 或切换到 Codex。",
2405
+ );
1827
2406
  return;
1828
2407
  }
1829
2408
 
@@ -1832,7 +2411,35 @@ export class AgentWorkspaceProxy {
1832
2411
  reasoningEffort: conversation.reasoningEffort,
1833
2412
  permissionMode: conversation.permissionMode,
1834
2413
  };
1835
- conversation.model = payload.model ?? conversation.model;
2414
+ const effectiveModel = payload.model ??
2415
+ conversation.model ??
2416
+ (protocol === "codex-app-server" ? this.defaultModelForProvider(conversation.provider) : undefined);
2417
+ const wasRunning = conversation.status === "running";
2418
+ const activeTurnId = payload.targetTurnId ?? this.currentTurnIds.get(conversation.id);
2419
+ const shouldSteer = Boolean(
2420
+ payload.delivery !== "new_turn" &&
2421
+ wasRunning &&
2422
+ protocol === "codex-app-server" &&
2423
+ activeTurnId,
2424
+ );
2425
+ if (wasRunning && payload.delivery !== "new_turn" && protocol !== "codex-app-server") {
2426
+ this.addItem(conversation.id, {
2427
+ id: id("error"),
2428
+ conversationId: conversation.id,
2429
+ type: "error",
2430
+ error: `${providerLabel(conversation.provider)} 当前不支持运行中追加输入。请等待本轮结束,或先停止后再发送。`,
2431
+ createdAt: Date.now(),
2432
+ });
2433
+ return;
2434
+ }
2435
+ if (payload.delivery === "steer" && protocol === "codex-app-server" && !activeTurnId) {
2436
+ this.rejectAgentAction(
2437
+ conversation,
2438
+ "当前没有可追加输入的 Codex turn。请等待本轮开始后重试,或作为新消息发送。",
2439
+ );
2440
+ return;
2441
+ }
2442
+ conversation.model = effectiveModel ?? conversation.model;
1836
2443
  conversation.reasoningEffort = payload.reasoningEffort ?? conversation.reasoningEffort;
1837
2444
  conversation.permissionMode = payload.permissionMode ?? conversation.permissionMode;
1838
2445
  conversation.collaborationMode = payload.collaborationMode ?? conversation.collaborationMode;
@@ -1840,14 +2447,14 @@ export class AgentWorkspaceProxy {
1840
2447
  conversation.lastActivityAt = Date.now();
1841
2448
  this.activeConversationId = conversation.id;
1842
2449
 
1843
- if (payload.model && payload.model !== priorSettings.model) {
2450
+ if (effectiveModel && effectiveModel !== priorSettings.model) {
1844
2451
  const runtimeCapabilities = this.providerCapabilities.get(conversation.provider);
1845
- const label = runtimeCapabilities?.models?.find((m) => m.id === payload.model)?.label ?? payload.model;
2452
+ const label = runtimeCapabilities?.models?.find((m) => m.id === effectiveModel)?.label ?? effectiveModel;
1846
2453
  this.emitNotice({
1847
2454
  conversationId: conversation.id,
1848
2455
  kind: "model_changed",
1849
2456
  title: `已切换模型 · ${label}`,
1850
- detail: `${providerLabel(conversation.provider)} 下次回复将使用 ${payload.model}`,
2457
+ detail: `${providerLabel(conversation.provider)} 下次回复将使用 ${effectiveModel}`,
1851
2458
  });
1852
2459
  }
1853
2460
  if (payload.reasoningEffort && payload.reasoningEffort !== priorSettings.reasoningEffort) {
@@ -1873,21 +2480,62 @@ export class AgentWorkspaceProxy {
1873
2480
  role: "user",
1874
2481
  content: payload.contentBlocks,
1875
2482
  text: userText,
2483
+ metadata: shouldSteer ? { delivery: "steer", targetTurnId: activeTurnId } : undefined,
1876
2484
  createdAt: Date.now(),
1877
2485
  });
1878
2486
  this.emitConversation(conversation);
1879
2487
 
1880
- try {
1881
- const result = await client.prompt({
1882
- sessionId: conversation.agentSessionId,
2488
+ const promptAsNewTurn = () =>
2489
+ client.prompt({
2490
+ sessionId: conversation.agentSessionId!,
1883
2491
  content: payload.contentBlocks,
1884
2492
  clientMessageId: payload.clientMessageId,
1885
- model: payload.model,
2493
+ model: effectiveModel,
1886
2494
  reasoningEffort: payload.reasoningEffort,
1887
2495
  permissionMode: payload.permissionMode,
1888
2496
  collaborationMode: payload.collaborationMode ?? conversation.collaborationMode,
1889
2497
  cwd: conversation.cwd,
1890
2498
  });
2499
+
2500
+ try {
2501
+ const steer = (client as { steer?: (input: { sessionId: string; turnId: string; content: AgentContentBlock[] }) => Promise<unknown> }).steer;
2502
+ let result: unknown;
2503
+ if (shouldSteer && typeof steer === "function") {
2504
+ try {
2505
+ result = await steer.call(client, {
2506
+ sessionId: conversation.agentSessionId,
2507
+ turnId: activeTurnId!,
2508
+ content: payload.contentBlocks,
2509
+ });
2510
+ } catch (steerError) {
2511
+ this.forgetCurrentTurn(conversation.id, activeTurnId);
2512
+ const userItem = this.findItem(conversation.id, payload.clientMessageId);
2513
+ if (userItem) {
2514
+ this.upsertItem(conversation.id, {
2515
+ ...userItem,
2516
+ metadata: {
2517
+ ...(userItem.metadata ?? {}),
2518
+ delivery: "new_turn",
2519
+ fallbackFrom: "steer",
2520
+ failedTargetTurnId: activeTurnId,
2521
+ },
2522
+ updatedAt: Date.now(),
2523
+ });
2524
+ }
2525
+ this.addItem(conversation.id, {
2526
+ id: id("status"),
2527
+ conversationId: conversation.id,
2528
+ type: "status",
2529
+ role: "system",
2530
+ text: "Codex 未接收运行中追加输入,已改为发送新消息。",
2531
+ status: "running",
2532
+ createdAt: Date.now(),
2533
+ });
2534
+ result = await promptAsNewTurn();
2535
+ }
2536
+ } else {
2537
+ result = await promptAsNewTurn();
2538
+ }
1891
2539
  const nextAgentSessionId = this.extractSessionId(result);
1892
2540
  if (nextAgentSessionId && nextAgentSessionId !== conversation.agentSessionId) {
1893
2541
  if (conversation.agentSessionId) this.conversationByAgentSessionId.delete(conversation.agentSessionId);
@@ -2120,6 +2768,9 @@ export class AgentWorkspaceProxy {
2120
2768
  }
2121
2769
 
2122
2770
  private handleRequest(method: string, params: unknown): Promise<unknown> | unknown {
2771
+ if (method === "claude/askUserQuestion") {
2772
+ return this.handleStructuredInput({ ...(asRecord(params) ?? {}), source: method }, true);
2773
+ }
2123
2774
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
2124
2775
  return this.handleStructuredInput(params, true);
2125
2776
  }
@@ -2167,7 +2818,6 @@ export class AgentWorkspaceProxy {
2167
2818
  if (
2168
2819
  method.startsWith("account/") ||
2169
2820
  method.startsWith("mcpServer/startupStatus/") ||
2170
- method === "thread/status/changed" ||
2171
2821
  method === "thread/tokenUsage/updated" ||
2172
2822
  method === "serverRequest/resolved" ||
2173
2823
  method === "mcpServer/oauthLogin/completed"
@@ -2176,6 +2826,25 @@ export class AgentWorkspaceProxy {
2176
2826
  }
2177
2827
 
2178
2828
  const conversationId = this.conversationIdFromParams(params) ?? this.fallbackConversationId();
2829
+ if (method === "thread/status/changed") {
2830
+ const status = agentStatusFromThreadStatus(params);
2831
+ if (conversationId && status) {
2832
+ const raw = asRecord(params);
2833
+ const turnId = this.extractTurnId(raw);
2834
+ if (turnId && (status === "running" || status === "waiting_permission")) {
2835
+ this.rememberTurnConversationId(conversationId, turnId);
2836
+ }
2837
+ if (status === "idle" || status === "error") {
2838
+ this.forgetCurrentTurn(conversationId, turnId);
2839
+ }
2840
+ const message =
2841
+ status === "error"
2842
+ ? firstString(raw, ["message", "error", "reason"])
2843
+ : undefined;
2844
+ this.updateConversationStatus(conversationId, status, message);
2845
+ }
2846
+ return;
2847
+ }
2179
2848
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
2180
2849
  this.handleStructuredInput(params);
2181
2850
  return;
@@ -2685,9 +3354,8 @@ export class AgentWorkspaceProxy {
2685
3354
  const raw = asRecord(params) ?? {};
2686
3355
  const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
2687
3356
  const source = firstString(raw, ["method", "source", "requestMethod"]);
2688
- const formatResponse = source === "mcpServer/elicitation/request"
2689
- ? formatMcpElicitationResponse
2690
- : formatStructuredInputResponse;
3357
+ const formatResponse = (answers: Record<string, string[]>, input?: AgentStructuredInput) =>
3358
+ formatStructuredInputResponseForSource(source, answers, input);
2691
3359
  if (!conversationId) return waitForResponse ? Promise.resolve(formatResponse({})) : undefined;
2692
3360
  const structuredInput = decodeStructuredInput(raw);
2693
3361
  if (!structuredInput) return waitForResponse ? Promise.resolve(formatResponse({})) : undefined;
@@ -2711,13 +3379,13 @@ export class AgentWorkspaceProxy {
2711
3379
  const timer = setTimeout(() => {
2712
3380
  this.pendingStructuredInputs.delete(structuredInput.requestId);
2713
3381
  this.structuredInputWaiters.delete(structuredInput.requestId);
2714
- resolve(formatResponse({}));
3382
+ resolve(formatResponse({}, structuredInput));
2715
3383
  this.markStructuredInput(conversationId, structuredInput.requestId, {
2716
3384
  inputPending: false,
2717
3385
  inputError: "等待用户输入超时",
2718
3386
  });
2719
3387
  }, PERMISSION_TIMEOUT_MS);
2720
- this.structuredInputWaiters.set(structuredInput.requestId, { resolve, timer, source });
3388
+ this.structuredInputWaiters.set(structuredInput.requestId, { resolve, timer, source, input: structuredInput });
2721
3389
  });
2722
3390
  }
2723
3391
 
@@ -2758,11 +3426,20 @@ export class AgentWorkspaceProxy {
2758
3426
  if (!conversationId) return waitForResponse ? Promise.resolve({ outcome: { outcome: "cancelled" } }) : undefined;
2759
3427
  const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
2760
3428
  const rawToolCall = asRecord(raw.toolCall) ?? raw;
3429
+ // Codex app-server command/file approvals carry `command`/`reason`/`cwd`
3430
+ // (not toolCall/context). Surface them so the card actually shows what's
3431
+ // being approved; fall back to the Claude/MCP shape otherwise.
3432
+ const codexCommand = firstString(raw, ["command"]);
3433
+ const codexCwd = firstString(raw, ["cwd"]);
2761
3434
  const permission: AgentPermission = {
2762
3435
  requestId,
2763
- toolName: firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]),
2764
- toolInput: stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
2765
- context: firstString(raw, ["context", "description", "message", "title"]),
3436
+ toolName:
3437
+ firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]) ??
3438
+ (codexCommand ? "shell" : undefined),
3439
+ toolInput: codexCommand
3440
+ ? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
3441
+ : stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
3442
+ context: firstString(raw, ["context", "description", "message", "title", "reason"]),
2766
3443
  options: parsePermissionOptions(raw.options),
2767
3444
  };
2768
3445
  this.pendingPermissions.set(requestId, permission);
@@ -2773,6 +3450,12 @@ export class AgentWorkspaceProxy {
2773
3450
  conversationId,
2774
3451
  type: "permission",
2775
3452
  permission,
3453
+ metadata: {
3454
+ protocol: "v2",
3455
+ permissionLive: true,
3456
+ permissionExpired: false,
3457
+ permissionPending: false,
3458
+ },
2776
3459
  createdAt: Date.now(),
2777
3460
  updatedAt: Date.now(),
2778
3461
  };
@@ -2803,10 +3486,13 @@ export class AgentWorkspaceProxy {
2803
3486
  optionId?: string;
2804
3487
  }): void {
2805
3488
  const permission = this.pendingPermissions.get(payload.requestId);
3489
+ const waiter = this.permissionWaiters.get(payload.requestId);
3490
+ const existingItem = this.findItem(payload.conversationId, `permission:${payload.requestId}`);
3491
+ const alreadyResolved = Boolean(existingItem?.metadata?.permissionOutcome);
3492
+ if (!permission && !waiter && alreadyResolved) return;
2806
3493
  this.pendingPermissions.delete(payload.requestId);
2807
3494
  const selectedOptionId =
2808
3495
  payload.optionId ?? selectPermissionOption(permission, payload.outcome);
2809
- const waiter = this.permissionWaiters.get(payload.requestId);
2810
3496
  if (waiter) {
2811
3497
  clearTimeout(waiter.timer);
2812
3498
  this.permissionWaiters.delete(payload.requestId);
@@ -2832,7 +3518,9 @@ export class AgentWorkspaceProxy {
2832
3518
  permissionError: undefined,
2833
3519
  permissionPending: false,
2834
3520
  });
2835
- this.updateConversationStatus(payload.conversationId, "running");
3521
+ if (permission || waiter || existingItem) {
3522
+ this.updateConversationStatus(payload.conversationId, "running");
3523
+ }
2836
3524
  }
2837
3525
 
2838
3526
  private respondStructuredInput(payload: {
@@ -2841,15 +3529,15 @@ export class AgentWorkspaceProxy {
2841
3529
  answers: Record<string, string[]>;
2842
3530
  }): void {
2843
3531
  const pending = this.pendingStructuredInputs.get(payload.requestId);
2844
- this.pendingStructuredInputs.delete(payload.requestId);
2845
3532
  const waiter = this.structuredInputWaiters.get(payload.requestId);
3533
+ const existingItem = this.findItem(payload.conversationId, `input:${payload.requestId}`);
3534
+ const alreadySubmitted = existingItem?.metadata?.inputSubmitted === true;
3535
+ if (!pending && !waiter && alreadySubmitted) return;
3536
+ this.pendingStructuredInputs.delete(payload.requestId);
2846
3537
  if (waiter) {
2847
3538
  clearTimeout(waiter.timer);
2848
3539
  this.structuredInputWaiters.delete(payload.requestId);
2849
- const formatResponse = waiter.source === "mcpServer/elicitation/request"
2850
- ? formatMcpElicitationResponse
2851
- : formatStructuredInputResponse;
2852
- waiter.resolve(formatResponse(payload.answers));
3540
+ waiter.resolve(formatStructuredInputResponseForSource(waiter.source, payload.answers, waiter.input));
2853
3541
  }
2854
3542
  this.markStructuredInput(payload.conversationId, payload.requestId, {
2855
3543
  inputPending: false,
@@ -2858,7 +3546,9 @@ export class AgentWorkspaceProxy {
2858
3546
  inputError: undefined,
2859
3547
  answers: payload.answers,
2860
3548
  });
2861
- this.updateConversationStatus(pending?.conversationId ?? payload.conversationId, "running");
3549
+ if (pending || waiter || existingItem) {
3550
+ this.updateConversationStatus(pending?.conversationId ?? payload.conversationId, "running");
3551
+ }
2862
3552
  }
2863
3553
 
2864
3554
  private markPermission(
@@ -3058,7 +3748,7 @@ export class AgentWorkspaceProxy {
3058
3748
  this.input.send(createEnvelope({
3059
3749
  type: "agent.v2.event",
3060
3750
  sessionId: this.input.sessionId,
3061
- payload: { conversationId, conversation, item },
3751
+ payload: { conversationId, conversation: conversation ? { ...conversation } : undefined, item },
3062
3752
  }));
3063
3753
  }
3064
3754
 
@@ -3066,7 +3756,7 @@ export class AgentWorkspaceProxy {
3066
3756
  this.input.send(createEnvelope({
3067
3757
  type: "agent.v2.event",
3068
3758
  sessionId: this.input.sessionId,
3069
- payload: { conversationId: conversation.id, conversation },
3759
+ payload: { conversationId: conversation.id, conversation: { ...conversation } },
3070
3760
  }));
3071
3761
  }
3072
3762
 
@@ -3101,6 +3791,21 @@ export class AgentWorkspaceProxy {
3101
3791
  }));
3102
3792
  }
3103
3793
 
3794
+ private rejectAgentAction(
3795
+ conversation: AgentConversation,
3796
+ message: string,
3797
+ status: AgentStatus = "idle",
3798
+ ): void {
3799
+ this.updateConversationStatus(conversation.id, status, message);
3800
+ this.addItem(conversation.id, {
3801
+ id: id("error"),
3802
+ conversationId: conversation.id,
3803
+ type: "error",
3804
+ error: message,
3805
+ createdAt: Date.now(),
3806
+ });
3807
+ }
3808
+
3104
3809
  private updateConversationPreview(
3105
3810
  conversationId: string,
3106
3811
  text: string,
@@ -3143,6 +3848,76 @@ export class AgentWorkspaceProxy {
3143
3848
  }));
3144
3849
  }
3145
3850
 
3851
+ /** Page OLDER transcript history for a conversation (client scrolling up).
3852
+ * Only Codex's app-server exposes paginated reads; other providers report
3853
+ * no more history. Pure pass-through — does not touch the bounded live
3854
+ * timeline. The client prepends the returned (ascending) items. */
3855
+ private async loadOlderHistory(payload: {
3856
+ conversationId: string;
3857
+ cursor?: string;
3858
+ limit: number;
3859
+ }): Promise<void> {
3860
+ const sendResult = (
3861
+ items: AgentTimelineItem[],
3862
+ nextCursor?: string,
3863
+ ): void => {
3864
+ this.input.send(createEnvelope({
3865
+ type: "agent.v2.history.result",
3866
+ sessionId: this.input.sessionId,
3867
+ payload: {
3868
+ conversationId: payload.conversationId,
3869
+ items,
3870
+ nextCursor,
3871
+ hasMore: Boolean(nextCursor),
3872
+ },
3873
+ }));
3874
+ };
3875
+
3876
+ const conversation = this.conversations.get(payload.conversationId);
3877
+ if (!conversation?.agentSessionId) return sendResult([], undefined);
3878
+
3879
+ const client = this.clientForProvider(conversation.provider);
3880
+ const listTurns = (client as {
3881
+ listTurns?: (input: {
3882
+ sessionId: string;
3883
+ limit?: number;
3884
+ cursor?: string;
3885
+ sortDirection?: "asc" | "desc";
3886
+ itemsView?: "summary" | "full";
3887
+ }) => Promise<unknown>;
3888
+ } | undefined)?.listTurns;
3889
+ if (typeof listTurns !== "function") return sendResult([], undefined);
3890
+
3891
+ // Client-supplied cursor wins; else the one captured at hydration. Absent
3892
+ // cursor means we're already at the start of history.
3893
+ const cursor = payload.cursor ?? this.historyCursors.get(payload.conversationId);
3894
+ if (!cursor) return sendResult([], undefined);
3895
+
3896
+ try {
3897
+ const turnsResult = await listTurns.call(client, {
3898
+ sessionId: conversation.agentSessionId,
3899
+ limit: payload.limit,
3900
+ cursor,
3901
+ sortDirection: "desc",
3902
+ itemsView: "full",
3903
+ });
3904
+ const turnsThread = threadFromTurnsListResult(conversation.agentSessionId, turnsResult);
3905
+ const items = turnsThread
3906
+ ? timelineItemsFromProviderThread({ thread: turnsThread }, payload.conversationId)
3907
+ : [];
3908
+ const nextCursor = firstString(asRecord(turnsResult), ["nextCursor", "next_cursor", "cursor"]);
3909
+ this.historyCursors.set(payload.conversationId, nextCursor);
3910
+ sendResult(items, nextCursor);
3911
+ } catch (error) {
3912
+ if (this.input.verbose) {
3913
+ process.stderr.write(
3914
+ `[agent:v2] history page failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`,
3915
+ );
3916
+ }
3917
+ sendResult([], undefined);
3918
+ }
3919
+ }
3920
+
3146
3921
  private conversationIdFromParams(params: unknown): string | undefined {
3147
3922
  const raw = asRecord(params);
3148
3923
  if (!raw) return undefined;
@@ -3228,12 +4003,22 @@ export class AgentWorkspaceProxy {
3228
4003
  private handleProviderExit(provider: AgentProvider, message: string): void {
3229
4004
  this.clients.delete(provider);
3230
4005
  this.agentProtocols.delete(provider);
3231
- this.cancelPendingPermissions();
4006
+ this.providerErrors.set(provider, message);
4007
+ for (const conversation of this.conversations.values()) {
4008
+ if (conversation.provider === provider && conversation.status === "waiting_permission") {
4009
+ this.cancelPendingPermissions(conversation.id, false);
4010
+ }
4011
+ }
3232
4012
  for (const conversation of this.conversations.values()) {
3233
4013
  if (conversation.provider !== provider) continue;
4014
+ if (conversation.status !== "running" && conversation.status !== "waiting_permission") {
4015
+ this.emitConversation(conversation);
4016
+ continue;
4017
+ }
3234
4018
  conversation.status = "error";
3235
4019
  conversation.lastMessagePreview = message;
3236
4020
  conversation.lastActivityAt = Date.now();
4021
+ this.forgetCurrentTurn(conversation.id);
3237
4022
  this.emitConversation(conversation);
3238
4023
  this.addItem(conversation.id, {
3239
4024
  id: id("error"),
@@ -3246,8 +4031,9 @@ export class AgentWorkspaceProxy {
3246
4031
  this.sendCapabilities();
3247
4032
  }
3248
4033
 
3249
- private cancelPendingPermissions(conversationId?: string): void {
4034
+ private cancelPendingPermissions(conversationId?: string, updateStatus = true): void {
3250
4035
  for (const [requestId, waiter] of this.permissionWaiters) {
4036
+ if (conversationId && this.itemConversationIds.get(requestId) !== conversationId) continue;
3251
4037
  clearTimeout(waiter.timer);
3252
4038
  waiter.resolve(formatPermissionResponse(
3253
4039
  this.permissionSources.get(requestId),
@@ -3257,11 +4043,12 @@ export class AgentWorkspaceProxy {
3257
4043
  this.pendingPermissions.delete(requestId);
3258
4044
  this.permissionSources.delete(requestId);
3259
4045
  }
3260
- this.permissionWaiters.clear();
4046
+ if (!conversationId) this.permissionWaiters.clear();
3261
4047
  for (const [requestId, waiter] of this.structuredInputWaiters) {
3262
- clearTimeout(waiter.timer);
3263
- waiter.resolve(formatStructuredInputResponse({}));
3264
4048
  const pending = this.pendingStructuredInputs.get(requestId);
4049
+ if (conversationId && pending?.conversationId !== conversationId) continue;
4050
+ clearTimeout(waiter.timer);
4051
+ waiter.resolve(formatStructuredInputResponseForSource(waiter.source, {}, waiter.input));
3265
4052
  if (pending) {
3266
4053
  this.markStructuredInput(pending.conversationId, requestId, {
3267
4054
  inputPending: false,
@@ -3269,9 +4056,10 @@ export class AgentWorkspaceProxy {
3269
4056
  });
3270
4057
  }
3271
4058
  this.pendingStructuredInputs.delete(requestId);
4059
+ this.structuredInputWaiters.delete(requestId);
3272
4060
  }
3273
- this.structuredInputWaiters.clear();
3274
- if (conversationId) this.updateConversationStatus(conversationId, "idle");
4061
+ if (!conversationId) this.structuredInputWaiters.clear();
4062
+ if (conversationId && updateStatus) this.updateConversationStatus(conversationId, "idle");
3275
4063
  }
3276
4064
 
3277
4065
  private extractSessionId(value: unknown): string | undefined {
@@ -3359,6 +4147,37 @@ function formatStructuredInputResponse(answers: Record<string, string[]>): unkno
3359
4147
  };
3360
4148
  }
3361
4149
 
4150
+ function formatClaudeAskUserQuestionResponse(
4151
+ answers: Record<string, string[]>,
4152
+ input?: AgentStructuredInput,
4153
+ ): unknown {
4154
+ const questions = input?.questions ?? [];
4155
+ const byQuestionText: Record<string, string | string[]> = {};
4156
+ for (const question of questions) {
4157
+ const values = answers[question.id] ?? answers[question.question] ?? [];
4158
+ const cleaned = values.map((value) => value.trim()).filter(Boolean);
4159
+ if (cleaned.length === 0) continue;
4160
+ byQuestionText[question.question] = question.selectionLimit && question.selectionLimit > 1
4161
+ ? cleaned
4162
+ : cleaned[0] ?? "";
4163
+ }
4164
+ return {
4165
+ behavior: "allow",
4166
+ updatedInput: {
4167
+ questions: questions.map((question) => ({
4168
+ question: question.question,
4169
+ header: question.header,
4170
+ options: question.options?.map((option) => ({
4171
+ label: option.label,
4172
+ description: option.description,
4173
+ })) ?? [],
4174
+ multiSelect: Boolean(question.selectionLimit && question.selectionLimit > 1),
4175
+ })),
4176
+ answers: byQuestionText,
4177
+ },
4178
+ };
4179
+ }
4180
+
3362
4181
  function formatMcpElicitationResponse(answers: Record<string, string[]>): unknown {
3363
4182
  const content = Object.fromEntries(
3364
4183
  Object.entries(answers).map(([questionId, values]) => [
@@ -3373,6 +4192,20 @@ function formatMcpElicitationResponse(answers: Record<string, string[]>): unknow
3373
4192
  };
3374
4193
  }
3375
4194
 
4195
+ function formatStructuredInputResponseForSource(
4196
+ source: string | undefined,
4197
+ answers: Record<string, string[]>,
4198
+ input?: AgentStructuredInput,
4199
+ ): unknown {
4200
+ if (source === "mcpServer/elicitation/request") {
4201
+ return formatMcpElicitationResponse(answers);
4202
+ }
4203
+ if (source === "claude/askUserQuestion") {
4204
+ return formatClaudeAskUserQuestionResponse(answers, input);
4205
+ }
4206
+ return formatStructuredInputResponse(answers);
4207
+ }
4208
+
3376
4209
  function formatPermissionResponse(
3377
4210
  source: string | undefined,
3378
4211
  outcome: "allow" | "deny" | "cancelled",