linkshell-cli 0.5.37 → 0.5.39

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 (35) hide show
  1. package/dist/cli/src/runtime/acp/acp-client.d.ts +21 -0
  2. package/dist/cli/src/runtime/acp/acp-client.js +101 -5
  3. package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
  4. package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +1 -1
  5. package/dist/cli/src/runtime/acp/agent-process-discovery.js +12 -1
  6. package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +1 -1
  7. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +137 -0
  8. package/dist/cli/src/runtime/acp/agent-workspace.js +607 -97
  9. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  10. package/dist/cli/src/runtime/acp/provider-resolver.js +28 -2
  11. package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
  12. package/dist/cli/src/runtime/bridge-session.d.ts +0 -48
  13. package/dist/cli/src/runtime/bridge-session.js +10 -1039
  14. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  15. package/dist/cli/src/runtime/hook-command.d.ts +5 -30
  16. package/dist/cli/src/runtime/hook-command.js +54 -41
  17. package/dist/cli/src/runtime/hook-command.js.map +1 -1
  18. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  19. package/dist/shared-protocol/src/index.d.ts +164 -8
  20. package/dist/shared-protocol/src/index.js +6 -0
  21. package/dist/shared-protocol/src/index.js.map +1 -1
  22. package/package.json +3 -3
  23. package/src/runtime/acp/acp-client.ts +116 -5
  24. package/src/runtime/acp/agent-process-discovery.ts +8 -1
  25. package/src/runtime/acp/agent-workspace.ts +557 -50
  26. package/src/runtime/acp/provider-resolver.ts +31 -2
  27. package/src/runtime/bridge-session.ts +9 -1117
  28. package/src/runtime/hook-command.ts +47 -47
  29. package/web/assets/{FileBrowser-Coy7Szjx.js → FileBrowser-DtVanRUn.js} +1 -1
  30. package/web/assets/{PortPreview-D-XovbV1.js → PortPreview-DC2-UmHo.js} +1 -1
  31. package/web/assets/{TerminalPanel-Cah56AtC.js → TerminalPanel-D4gHM4Ta.js} +1 -1
  32. package/web/assets/{UsageDashboard-DfkjJagC.js → UsageDashboard-ZnvuvqWc.js} +1 -1
  33. package/web/assets/index-DKdJgZ9D.js +36 -0
  34. package/web/index.html +1 -1
  35. package/web/assets/index-ClZzQ4ba.js +0 -35
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync, watch } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, relative } from "node:path";
4
4
  import { createEnvelope, parseTypedPayload, } from "@linkshell/protocol";
@@ -242,8 +242,9 @@ function normalizeToolStatus(value, completedFallback = false) {
242
242
  function normalizePlanStatus(value) {
243
243
  if (value === "completed" || value === "done")
244
244
  return "completed";
245
- if (value === "inProgress" || value === "running" || value === "active")
245
+ if (value === "inProgress" || value === "in_progress" || value === "running" || value === "active") {
246
246
  return "in_progress";
247
+ }
247
248
  return "pending";
248
249
  }
249
250
  function applyThreadSettingsToConversation(conversation, value) {
@@ -577,7 +578,9 @@ function contentBlocksFromItem(item) {
577
578
  const text = firstString(item, ["text", "message"]);
578
579
  return text ? [{ type: "text", text }] : [];
579
580
  }
580
- function protocolSupportsImages(protocol) {
581
+ function protocolSupportsImages(protocol, advertised) {
582
+ if (protocol === "acp")
583
+ return Boolean(advertised?.images);
581
584
  return protocol === "codex-app-server" ||
582
585
  protocol === "claude-agent-sdk" ||
583
586
  protocol === "claude-stream-json";
@@ -1442,6 +1445,244 @@ function codexSessionConfig(sessionId) {
1442
1445
  }
1443
1446
  return cfg;
1444
1447
  }
1448
+ const CODEX_ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
1449
+ function walkCodexRollouts(root, onFile) {
1450
+ if (!existsSync(root))
1451
+ return;
1452
+ const walk = (dir) => {
1453
+ let entries;
1454
+ try {
1455
+ entries = readdirSync(dir);
1456
+ }
1457
+ catch {
1458
+ return;
1459
+ }
1460
+ for (const name of entries) {
1461
+ const path = join(dir, name);
1462
+ let stat;
1463
+ try {
1464
+ stat = statSync(path);
1465
+ }
1466
+ catch {
1467
+ continue;
1468
+ }
1469
+ if (stat.isDirectory()) {
1470
+ walk(path);
1471
+ continue;
1472
+ }
1473
+ const match = CODEX_ROLLOUT_NAME.exec(name);
1474
+ if (match?.[1])
1475
+ onFile(path, match[1], stat.mtimeMs);
1476
+ }
1477
+ };
1478
+ walk(root);
1479
+ }
1480
+ function findCodexRolloutPath(sessionId, root = join(homedir(), ".codex", "sessions")) {
1481
+ const byName = [];
1482
+ const recent = [];
1483
+ walkCodexRollouts(root, (path, filenameId, mt) => {
1484
+ recent.push({ path, mt });
1485
+ if (filenameId === sessionId)
1486
+ byName.push({ path, mt });
1487
+ });
1488
+ if (byName.length > 0) {
1489
+ byName.sort((a, b) => b.mt - a.mt);
1490
+ return byName[0]?.path;
1491
+ }
1492
+ recent.sort((a, b) => b.mt - a.mt);
1493
+ for (const candidate of recent.slice(0, 80)) {
1494
+ try {
1495
+ const fdText = readFileSync(candidate.path, "utf8");
1496
+ const first = fdText.slice(0, fdText.indexOf("\n") >= 0 ? fdText.indexOf("\n") : fdText.length);
1497
+ const metaId = firstString(asRecord(asRecord(JSON.parse(first))?.payload), ["session_id", "id"]);
1498
+ if (metaId === sessionId)
1499
+ return candidate.path;
1500
+ }
1501
+ catch { /* skip */ }
1502
+ }
1503
+ return undefined;
1504
+ }
1505
+ function findLatestCodexRolloutForCwd(cwd, root = join(homedir(), ".codex", "sessions")) {
1506
+ const wanted = cwd.replace(/\/+$/, "");
1507
+ const candidates = [];
1508
+ walkCodexRollouts(root, (path, filenameId, mt) => {
1509
+ candidates.push({ path, sessionId: filenameId, mt });
1510
+ });
1511
+ candidates.sort((a, b) => b.mt - a.mt);
1512
+ for (const candidate of candidates.slice(0, 40)) {
1513
+ try {
1514
+ const text = readFileSync(candidate.path, "utf8");
1515
+ const first = text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length);
1516
+ const payload = asRecord(asRecord(JSON.parse(first))?.payload);
1517
+ const fileCwd = firstString(payload, ["cwd"])?.replace(/\/+$/, "");
1518
+ const metaId = firstString(payload, ["session_id", "id"]) ?? candidate.sessionId;
1519
+ if (fileCwd === wanted)
1520
+ return { path: candidate.path, sessionId: metaId };
1521
+ }
1522
+ catch {
1523
+ continue;
1524
+ }
1525
+ }
1526
+ return undefined;
1527
+ }
1528
+ function publicWorkspaceRoots(roots) {
1529
+ if (!Array.isArray(roots))
1530
+ return undefined;
1531
+ const cleaned = roots
1532
+ .filter((root) => typeof root === "string" && root.length > 0)
1533
+ .filter((root) => !root.includes("/.codex/visualizations/"));
1534
+ return cleaned.length > 0 ? cleaned : undefined;
1535
+ }
1536
+ function isInjectedCodexUserText(text) {
1537
+ const trimmed = text.trim();
1538
+ return (trimmed.startsWith("<app-context>") ||
1539
+ trimmed.startsWith("<recommended_plugins>") ||
1540
+ trimmed.startsWith("<skills_instructions>") ||
1541
+ trimmed.startsWith("# AGENTS.md") ||
1542
+ trimmed.startsWith("You are `/root`"));
1543
+ }
1544
+ function textsFromCodexContent(content) {
1545
+ if (typeof content === "string" && content.trim())
1546
+ return [content];
1547
+ if (!Array.isArray(content))
1548
+ return [];
1549
+ const texts = [];
1550
+ for (const block of content) {
1551
+ const rec = asRecord(block);
1552
+ const text = typeof rec?.text === "string" ? rec.text : undefined;
1553
+ if (text?.trim())
1554
+ texts.push(text);
1555
+ }
1556
+ return texts;
1557
+ }
1558
+ /** Incremental Codex jsonl parser so live TUI/desktop sessions can tail the file. */
1559
+ export class CodexRolloutParser {
1560
+ toolNames = new Map();
1561
+ index = 0;
1562
+ workspaceRoots;
1563
+ sessionCwd;
1564
+ consume(text, conversationId) {
1565
+ const items = [];
1566
+ for (const line of text.split(/\r?\n/)) {
1567
+ if (!line.trim())
1568
+ continue;
1569
+ const next = this.consumeLine(line, conversationId);
1570
+ if (next)
1571
+ items.push(next);
1572
+ }
1573
+ return items;
1574
+ }
1575
+ consumeLine(line, conversationId) {
1576
+ let record;
1577
+ try {
1578
+ record = asRecord(JSON.parse(line));
1579
+ }
1580
+ catch {
1581
+ return undefined;
1582
+ }
1583
+ if (!record)
1584
+ return undefined;
1585
+ const payload = asRecord(record.payload) ?? record;
1586
+ const recordType = firstString(record, ["type"]);
1587
+ const payloadType = firstString(payload, ["type"]);
1588
+ const roots = publicWorkspaceRoots(payload.workspace_roots ?? payload.workspaceRoots);
1589
+ if (roots)
1590
+ this.workspaceRoots = roots;
1591
+ const cwd = firstString(payload, ["cwd"]);
1592
+ if (cwd)
1593
+ this.sessionCwd = cwd;
1594
+ const createdAt = parseTimestamp(record.timestamp ?? payload.timestamp) ?? Date.now();
1595
+ if (recordType === "response_item" && payloadType === "message") {
1596
+ const roleRaw = firstString(payload, ["role"]);
1597
+ if (roleRaw !== "user" && roleRaw !== "assistant")
1598
+ return undefined;
1599
+ const texts = textsFromCodexContent(payload.content).filter((text) => roleRaw === "assistant" || !isInjectedCodexUserText(text));
1600
+ if (texts.length === 0)
1601
+ return undefined;
1602
+ const text = texts.join("\n\n");
1603
+ this.index += 1;
1604
+ return {
1605
+ id: firstString(payload, ["id"]) ?? id(`codex-msg-${this.index}`),
1606
+ conversationId,
1607
+ type: "message",
1608
+ kind: "chat",
1609
+ role: roleRaw,
1610
+ text,
1611
+ content: [{ type: "text", text }],
1612
+ createdAt,
1613
+ updatedAt: createdAt,
1614
+ };
1615
+ }
1616
+ if (recordType === "response_item" && payloadType === "reasoning") {
1617
+ const texts = textsFromCodexContent(payload.content ?? payload.summary);
1618
+ if (texts.length === 0)
1619
+ return undefined;
1620
+ this.index += 1;
1621
+ return {
1622
+ id: firstString(payload, ["id"]) ?? id(`codex-think-${this.index}`),
1623
+ conversationId,
1624
+ type: "status",
1625
+ kind: "thinking",
1626
+ text: texts.join("\n"),
1627
+ createdAt,
1628
+ updatedAt: createdAt,
1629
+ };
1630
+ }
1631
+ if (recordType === "response_item" && (payloadType === "custom_tool_call" || payloadType === "function_call")) {
1632
+ const callId = firstString(payload, ["call_id", "id"]) ?? id(`codex-tool-${this.index}`);
1633
+ const name = firstString(payload, ["name"]) ?? "tool";
1634
+ this.toolNames.set(callId, name);
1635
+ const input = firstString(payload, ["input", "arguments"]) ?? stringify(payload.input ?? payload.arguments ?? "");
1636
+ this.index += 1;
1637
+ return {
1638
+ id: `tool:${callId}`,
1639
+ conversationId,
1640
+ type: "tool_call",
1641
+ kind: name === "exec" || name.includes("command") ? "command_execution" : "tool_activity",
1642
+ itemId: callId,
1643
+ toolCall: {
1644
+ id: callId,
1645
+ name,
1646
+ input,
1647
+ createdAt,
1648
+ status: "running",
1649
+ },
1650
+ createdAt,
1651
+ updatedAt: createdAt,
1652
+ };
1653
+ }
1654
+ if (recordType === "response_item" && (payloadType === "custom_tool_call_output" || payloadType === "function_call_output")) {
1655
+ const callId = firstString(payload, ["call_id", "id"]);
1656
+ if (!callId)
1657
+ return undefined;
1658
+ const outputTexts = textsFromCodexContent(payload.output);
1659
+ const output = outputTexts.join("\n") || stringify(payload.output ?? "");
1660
+ this.index += 1;
1661
+ return {
1662
+ id: `tool:${callId}`,
1663
+ conversationId,
1664
+ type: "tool_call",
1665
+ kind: "tool_activity",
1666
+ itemId: callId,
1667
+ toolCall: {
1668
+ id: callId,
1669
+ name: this.toolNames.get(callId) ?? "tool",
1670
+ output,
1671
+ createdAt,
1672
+ status: "completed",
1673
+ },
1674
+ createdAt,
1675
+ updatedAt: createdAt,
1676
+ };
1677
+ }
1678
+ return undefined;
1679
+ }
1680
+ }
1681
+ /** Parse a Codex on-disk rollout into timeline items. App-server thread/read
1682
+ * only sees threads it owns; desktop/TUI sessions live in jsonl. */
1683
+ export function timelineItemsFromCodexRolloutText(text, conversationId) {
1684
+ return new CodexRolloutParser().consume(text, conversationId).slice(-MAX_TIMELINE_ITEMS);
1685
+ }
1445
1686
  function parseRemoteSessions(value) {
1446
1687
  const raw = asRecord(value);
1447
1688
  const sessionsValue = Array.isArray(value) ? value :
@@ -1826,6 +2067,22 @@ export class AgentWorkspaceProxy {
1826
2067
  // Echo the updated record so every client refreshes (the web store
1827
2068
  // force-replaces by id on any event carrying `conversation`).
1828
2069
  this.emitConversation(conversation);
2070
+ if (conversation.agentSessionId) {
2071
+ const client = this.clientForProvider(conversation.provider);
2072
+ if (client instanceof AcpClient && model?.trim() && client.advertised.setModel && conversation.provider !== "codex") {
2073
+ try {
2074
+ await client.setSessionModel({
2075
+ sessionId: conversation.agentSessionId,
2076
+ modelId: model.trim(),
2077
+ });
2078
+ }
2079
+ catch (error) {
2080
+ if (this.input.verbose) {
2081
+ process.stderr.write(`[agent:v2] session/set_model failed: ${error instanceof Error ? error.message : String(error)}\n`);
2082
+ }
2083
+ }
2084
+ }
2085
+ }
1829
2086
  if (conversation.provider === "codex" && conversation.agentSessionId) {
1830
2087
  const client = this.clientForProvider(conversation.provider);
1831
2088
  const settingsChanged = model !== undefined ||
@@ -1919,6 +2176,10 @@ export class AgentWorkspaceProxy {
1919
2176
  const conversation = this.conversations.get(payload.conversationId);
1920
2177
  if (!conversation)
1921
2178
  break;
2179
+ if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
2180
+ this.rejectAgentAction(conversation, `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 无法停止它,请在那边操作。`, conversation.status);
2181
+ break;
2182
+ }
1922
2183
  const turnId = this.currentTurnIds.get(payload.conversationId);
1923
2184
  if (this.protocolForProvider(conversation.provider) === "codex-app-server" && !turnId) {
1924
2185
  this.rejectAgentAction(conversation, "无法停止 Codex:当前运行 turn 尚未同步,请重新打开对话后再试。", conversation.status);
@@ -1983,8 +2244,13 @@ export class AgentWorkspaceProxy {
1983
2244
  fsSyncRunning = false;
1984
2245
  lastPushedConversationSignature;
1985
2246
  lastHydrateAt = new Map();
1986
- static FS_SYNC_THROTTLE_MS = 1500;
1987
- static EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
2247
+ /** Session ids this LinkShell process created via app-server `newSession`. */
2248
+ ownedCodexSessionIds = new Set();
2249
+ /** Conversations attached to an external Codex TUI/desktop rollout (same file, not a copy). */
2250
+ attachedExternalCodex = new Set();
2251
+ codexFollow = new Map();
2252
+ static FS_SYNC_THROTTLE_MS = 400;
2253
+ static EXTERNAL_HYDRATE_THROTTLE_MS = 400;
1988
2254
  startSessionWatchers() {
1989
2255
  const roots = [
1990
2256
  join(homedir(), ".claude", "projects"),
@@ -2064,7 +2330,9 @@ export class AgentWorkspaceProxy {
2064
2330
  this.lastHydrateAt.set(conversation.id, now);
2065
2331
  try {
2066
2332
  await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
2067
- this.sendSnapshot(conversation.id);
2333
+ if (this.followCodexRolloutDisk(conversation, true) > 0) {
2334
+ this.sendSnapshot(conversation.id);
2335
+ }
2068
2336
  }
2069
2337
  catch (error) {
2070
2338
  if (this.input.verbose) {
@@ -2192,11 +2460,18 @@ export class AgentWorkspaceProxy {
2192
2460
  : `${snapshot.provider}:${snapshot.pid}`;
2193
2461
  seenKeys.add(key);
2194
2462
  this.processMissCounts.set(key, 0);
2195
- const existingId = snapshot.sessionId
2196
- ? this.conversationByAgentSessionId.get(snapshot.sessionId)
2463
+ let sessionId = snapshot.sessionId;
2464
+ if (!sessionId && snapshot.provider === "codex") {
2465
+ const matched = findLatestCodexRolloutForCwd(snapshot.cwd ?? this.input.cwd);
2466
+ if (matched)
2467
+ sessionId = matched.sessionId;
2468
+ }
2469
+ const existingId = sessionId
2470
+ ? this.conversationByAgentSessionId.get(sessionId)
2197
2471
  : undefined;
2198
- const liveId = liveConversationId(snapshot);
2199
- const conversationId = existingId ?? liveId;
2472
+ const liveId = liveConversationId({ ...snapshot, sessionId });
2473
+ const conversationId = existingId
2474
+ ?? (sessionId ? makeAgentV2RemoteConversationId(snapshot.provider, sessionId) : liveId);
2200
2475
  const existing = this.conversations.get(conversationId);
2201
2476
  const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
2202
2477
  const driving = this.currentTurnIds.has(conversationId);
@@ -2211,10 +2486,12 @@ export class AgentWorkspaceProxy {
2211
2486
  existing.cwd = cwd;
2212
2487
  if (!existing.title)
2213
2488
  existing.title = agentProviderLabel(snapshot.provider);
2214
- if (snapshot.sessionId && !existing.agentSessionId) {
2215
- existing.agentSessionId = snapshot.sessionId;
2216
- this.conversationByAgentSessionId.set(snapshot.sessionId, existing.id);
2489
+ if (sessionId && !existing.agentSessionId) {
2490
+ existing.agentSessionId = sessionId;
2491
+ this.conversationByAgentSessionId.set(sessionId, existing.id);
2217
2492
  }
2493
+ this.attachedExternalCodex.add(existing.id);
2494
+ existing.control = "attached";
2218
2495
  if (changed) {
2219
2496
  listChanged = true;
2220
2497
  this.emitConversation(existing);
@@ -2224,7 +2501,7 @@ export class AgentWorkspaceProxy {
2224
2501
  const now = Date.now();
2225
2502
  const conversation = {
2226
2503
  id: conversationId,
2227
- agentSessionId: snapshot.sessionId,
2504
+ agentSessionId: sessionId,
2228
2505
  provider: snapshot.provider,
2229
2506
  cwd,
2230
2507
  title: agentProviderLabel(snapshot.provider),
@@ -2233,10 +2510,12 @@ export class AgentWorkspaceProxy {
2233
2510
  lastMessagePreview: "本机终端中的 Agent 进程",
2234
2511
  lastActivityAt: now,
2235
2512
  createdAt: now,
2513
+ control: "attached",
2236
2514
  };
2237
2515
  this.conversations.set(conversation.id, conversation);
2238
- if (snapshot.sessionId)
2239
- this.conversationByAgentSessionId.set(snapshot.sessionId, conversation.id);
2516
+ if (sessionId)
2517
+ this.conversationByAgentSessionId.set(sessionId, conversation.id);
2518
+ this.attachedExternalCodex.add(conversation.id);
2240
2519
  this.emitConversation(conversation);
2241
2520
  listChanged = true;
2242
2521
  }
@@ -2426,8 +2705,15 @@ export class AgentWorkspaceProxy {
2426
2705
  });
2427
2706
  }
2428
2707
  async refreshProviderCapabilities(provider, client, protocol) {
2429
- if (client instanceof AcpClient && protocol !== "codex-app-server")
2430
- return;
2708
+ if (client instanceof AcpClient) {
2709
+ const prior = this.providerCapabilities.get(provider);
2710
+ this.providerCapabilities.set(provider, {
2711
+ ...prior,
2712
+ advertised: client.advertised,
2713
+ });
2714
+ if (protocol !== "codex-app-server")
2715
+ return;
2716
+ }
2431
2717
  const listModels = client.listModels;
2432
2718
  let runtimeCapabilities;
2433
2719
  if (typeof listModels === "function") {
@@ -2457,6 +2743,8 @@ export class AgentWorkspaceProxy {
2457
2743
  commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
2458
2744
  modes: runtimeCapabilities?.modes,
2459
2745
  currentMode: runtimeCapabilities?.currentMode,
2746
+ advertised: this.providerCapabilities.get(provider)?.advertised
2747
+ ?? (client instanceof AcpClient ? client.advertised : undefined),
2460
2748
  // Preserve any MCP status already accumulated from startupStatus events.
2461
2749
  mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
2462
2750
  };
@@ -2482,7 +2770,12 @@ export class AgentWorkspaceProxy {
2482
2770
  commands: mergeCommands(runtimeCapabilities.commands, skillCommands),
2483
2771
  };
2484
2772
  }
2485
- this.providerCapabilities.set(provider, runtimeCapabilities);
2773
+ this.providerCapabilities.set(provider, {
2774
+ ...runtimeCapabilities,
2775
+ advertised: runtimeCapabilities.advertised
2776
+ ?? this.providerCapabilities.get(provider)?.advertised
2777
+ ?? (client instanceof AcpClient ? client.advertised : undefined),
2778
+ });
2486
2779
  }
2487
2780
  if (listedMcp.length > 0)
2488
2781
  this.mergeMcpServers(provider, listedMcp);
@@ -2594,6 +2887,8 @@ export class AgentWorkspaceProxy {
2594
2887
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
2595
2888
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
2596
2889
  usage,
2890
+ group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
2891
+ workspaceRoots: existing?.workspaceRoots,
2597
2892
  };
2598
2893
  this.conversations.set(conversation.id, conversation);
2599
2894
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
@@ -2729,7 +3024,13 @@ export class AgentWorkspaceProxy {
2729
3024
  if (turnCount > 0)
2730
3025
  this.historyCursors.set(conversation.id, String(turnCount));
2731
3026
  }
2732
- const hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
3027
+ let hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
3028
+ if (conversation.provider === "codex") {
3029
+ this.followCodexRolloutDisk(conversation, false);
3030
+ }
3031
+ if (hydratedItems.length === 0) {
3032
+ hydratedItems = this.timelines.get(conversation.id) ?? [];
3033
+ }
2733
3034
  if (hydratedItems.length === 0)
2734
3035
  return;
2735
3036
  const existing = this.timelines.get(conversation.id) ?? [];
@@ -2747,9 +3048,97 @@ export class AgentWorkspaceProxy {
2747
3048
  const lastPreview = [...nextItems].reverse()
2748
3049
  .map((item) => previewText(previewFromTimelineItem(item) ?? ""))
2749
3050
  .find(Boolean);
2750
- if (lastPreview && !conversation.lastMessagePreview) {
3051
+ if (lastPreview)
2751
3052
  conversation.lastMessagePreview = lastPreview;
3053
+ }
3054
+ /** Tail a Codex desktop/TUI rollout. `emit` sends agent.v2.event for new lines
3055
+ * so a phone/web client sees tokens as the jsonl grows, not only on open. */
3056
+ followCodexRolloutDisk(conversation, emit) {
3057
+ if (conversation.provider !== "codex")
3058
+ return 0;
3059
+ let path = conversation.agentSessionId
3060
+ ? findCodexRolloutPath(conversation.agentSessionId)
3061
+ : undefined;
3062
+ if (!path && conversation.cwd) {
3063
+ const matched = findLatestCodexRolloutForCwd(conversation.cwd);
3064
+ if (matched) {
3065
+ path = matched.path;
3066
+ if (!conversation.agentSessionId) {
3067
+ conversation.agentSessionId = matched.sessionId;
3068
+ this.conversationByAgentSessionId.set(matched.sessionId, conversation.id);
3069
+ }
3070
+ }
2752
3071
  }
3072
+ if (!path)
3073
+ return 0;
3074
+ let follow = this.codexFollow.get(conversation.id);
3075
+ if (!follow || follow.path !== path) {
3076
+ follow = { path, offset: 0, carry: "", parser: new CodexRolloutParser() };
3077
+ this.codexFollow.set(conversation.id, follow);
3078
+ }
3079
+ let stat;
3080
+ try {
3081
+ stat = statSync(path);
3082
+ }
3083
+ catch {
3084
+ return 0;
3085
+ }
3086
+ if (stat.size < follow.offset) {
3087
+ follow.offset = 0;
3088
+ follow.carry = "";
3089
+ follow.parser = new CodexRolloutParser();
3090
+ }
3091
+ if (stat.size === follow.offset && !follow.carry)
3092
+ return 0;
3093
+ const length = stat.size - follow.offset;
3094
+ const buf = Buffer.alloc(length);
3095
+ const fd = openSync(path, "r");
3096
+ try {
3097
+ readSync(fd, buf, 0, length, follow.offset);
3098
+ }
3099
+ finally {
3100
+ closeSync(fd);
3101
+ }
3102
+ follow.offset = stat.size;
3103
+ const combined = follow.carry + buf.toString("utf8");
3104
+ const lastNl = combined.lastIndexOf("\n");
3105
+ if (lastNl < 0) {
3106
+ follow.carry = combined;
3107
+ return 0;
3108
+ }
3109
+ const complete = combined.slice(0, lastNl + 1);
3110
+ follow.carry = combined.slice(lastNl + 1);
3111
+ const items = follow.parser.consume(complete, conversation.id);
3112
+ if (items.length === 0)
3113
+ return 0;
3114
+ if (emit) {
3115
+ for (const item of items)
3116
+ this.upsertItem(conversation.id, item);
3117
+ }
3118
+ else {
3119
+ const existing = this.timelines.get(conversation.id) ?? [];
3120
+ const merged = new Map(existing.map((item) => [item.id, item]));
3121
+ for (const item of items)
3122
+ merged.set(item.id, item);
3123
+ const nextItems = [...merged.values()]
3124
+ .sort((a, b) => a.createdAt - b.createdAt)
3125
+ .slice(-MAX_TIMELINE_ITEMS);
3126
+ this.timelines.set(conversation.id, nextItems);
3127
+ for (const item of nextItems)
3128
+ this.rememberItemConversationId(conversation.id, item);
3129
+ }
3130
+ const timeline = this.timelines.get(conversation.id) ?? [];
3131
+ const lastPreview = [...timeline].reverse()
3132
+ .map((item) => previewText(previewFromTimelineItem(item) ?? ""))
3133
+ .find(Boolean);
3134
+ if (lastPreview)
3135
+ conversation.lastMessagePreview = lastPreview;
3136
+ conversation.lastActivityAt = Date.now();
3137
+ if (follow.parser.workspaceRoots?.length)
3138
+ conversation.workspaceRoots = follow.parser.workspaceRoots;
3139
+ if (follow.parser.sessionCwd && !conversation.cwd)
3140
+ conversation.cwd = follow.parser.sessionCwd;
3141
+ return items.length;
2753
3142
  }
2754
3143
  sendCapabilities() {
2755
3144
  const catalog = [...new Set([
@@ -2760,11 +3149,23 @@ export class AgentWorkspaceProxy {
2760
3149
  const client = this.clients.get(provider);
2761
3150
  const protocol = this.agentProtocols.get(provider);
2762
3151
  const runtimeCapabilities = this.providerCapabilities.get(provider);
3152
+ const advertised = runtimeCapabilities?.advertised
3153
+ ?? (client instanceof AcpClient ? client.advertised : undefined);
2763
3154
  const enabled = Boolean(client);
2764
- const supportsImages = enabled && protocolSupportsImages(protocol);
3155
+ const isAcp = protocol === "acp";
3156
+ const supportsImages = enabled && protocolSupportsImages(protocol, advertised);
2765
3157
  const isClaudeFallback = protocol === "claude-stream-json";
2766
3158
  const supportsPermission = enabled && !isClaudeFallback;
2767
3159
  const supportsReasoningEffort = enabled && !isClaudeFallback;
3160
+ const supportsSessionList = enabled && (isAcp ? Boolean(advertised?.listSession) : !isClaudeFallback);
3161
+ const supportsSessionLoad = enabled && (isAcp ? Boolean(advertised?.loadSession) : !isClaudeFallback);
3162
+ const supportsCancel = enabled && (isAcp ? Boolean(advertised?.cancel) : true);
3163
+ const supportsFork = enabled && (isAcp
3164
+ ? Boolean(advertised?.forkSession)
3165
+ : protocol === "codex-app-server" || protocol === "claude-agent-sdk");
3166
+ const supportsSetModel = enabled && (isAcp
3167
+ ? Boolean(advertised?.setModel)
3168
+ : protocol === "codex-app-server");
2768
3169
  const protocolBacked = isProtocolAgentProvider(provider);
2769
3170
  const commands = mergeCommands(defaultProviderCommands(provider, this.input.cwd, enabled), runtimeCapabilities?.commands);
2770
3171
  const currentMode = [...this.conversations.values()].find((conversation) => conversation.provider === provider)?.collaborationMode;
@@ -2779,8 +3180,8 @@ export class AgentWorkspaceProxy {
2779
3180
  : `${providerLabel(provider)} 已在本机检测;远程对话请在终端继续`,
2780
3181
  supportsImages,
2781
3182
  supportsPermission,
2782
- supportsPlan: enabled,
2783
- supportsCancel: enabled,
3183
+ supportsPlan: enabled && (isAcp ? Boolean(advertised?.embeddedContext) : true),
3184
+ supportsCancel,
2784
3185
  models: runtimeCapabilities?.models ?? [{ id: "default", label: "默认模型" }],
2785
3186
  defaultModel: runtimeCapabilities?.defaultModel ?? "default",
2786
3187
  reasoningEfforts: supportsReasoningEffort
@@ -2795,11 +3196,17 @@ export class AgentWorkspaceProxy {
2795
3196
  currentMode,
2796
3197
  features: {
2797
3198
  images: supportsImages,
3199
+ audio: Boolean(advertised?.audio),
2798
3200
  permissions: supportsPermission,
2799
- plan: enabled,
2800
- cancel: enabled,
3201
+ plan: enabled && (protocol === "acp" ? Boolean(advertised?.embeddedContext) : true),
3202
+ cancel: supportsCancel,
2801
3203
  reasoningEffort: supportsReasoningEffort,
2802
3204
  streamJsonFallback: isClaudeFallback,
3205
+ loadSession: supportsSessionLoad,
3206
+ sessionList: supportsSessionList,
3207
+ sessionFork: supportsFork,
3208
+ setModel: supportsSetModel,
3209
+ mcp: Boolean(advertised?.mcp) || Boolean(runtimeCapabilities?.mcpServers && runtimeCapabilities.mcpServers.size > 0),
2803
3210
  },
2804
3211
  mcpServers: runtimeCapabilities?.mcpServers && runtimeCapabilities.mcpServers.size > 0
2805
3212
  ? [...runtimeCapabilities.mcpServers.values()]
@@ -2818,13 +3225,13 @@ export class AgentWorkspaceProxy {
2818
3225
  protocolVersion: 1,
2819
3226
  workspaceProtocolVersion: 2,
2820
3227
  error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
2821
- supportsSessionList: anyEnabled,
2822
- supportsSessionLoad: anyEnabled,
3228
+ supportsSessionList: providers.some((p) => p.features.sessionList),
3229
+ supportsSessionLoad: providers.some((p) => p.features.loadSession),
2823
3230
  supportsImages: providers.some((p) => p.supportsImages),
2824
- supportsAudio: false,
3231
+ supportsAudio: providers.some((p) => p.features.audio),
2825
3232
  supportsPermission: anyPermission,
2826
- supportsPlan: anyEnabled,
2827
- supportsCancel: anyEnabled,
3233
+ supportsPlan: providers.some((p) => p.supportsPlan),
3234
+ supportsCancel: providers.some((p) => p.supportsCancel),
2828
3235
  },
2829
3236
  }));
2830
3237
  }
@@ -2917,7 +3324,7 @@ export class AgentWorkspaceProxy {
2917
3324
  this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
2918
3325
  }
2919
3326
  }
2920
- else if (provider === "codex" && typeof forkThread === "function" && sourceSessionId) {
3327
+ else if (typeof forkThread === "function" && sourceSessionId) {
2921
3328
  if (source?.cwd && !payload.cwd)
2922
3329
  cwd = source.cwd;
2923
3330
  try {
@@ -2958,61 +3365,95 @@ export class AgentWorkspaceProxy {
2958
3365
  });
2959
3366
  }
2960
3367
  }
2961
- if (existingConversation && existingConversation.status !== "error" && existingConversation.agentSessionId) {
2962
- const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
2963
- if (payload.conversationId &&
2964
- payload.conversationId === requestedCanonicalId &&
2965
- existingConversation.id !== payload.conversationId) {
2966
- existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
3368
+ if (existingConversation &&
3369
+ !existingConversation.agentSessionId &&
3370
+ provider === "codex") {
3371
+ const matched = findLatestCodexRolloutForCwd(existingConversation.cwd ?? cwd);
3372
+ if (matched) {
3373
+ existingConversation.agentSessionId = matched.sessionId;
3374
+ this.conversationByAgentSessionId.set(matched.sessionId, existingConversation.id);
2967
3375
  }
2968
- // Hydrate the transcript from disk when we have no timeline yet, UNLESS
2969
- // LinkShell is itself driving a live turn for this conversation — in that
2970
- // case the event stream is the source of truth and re-hydrating would be
2971
- // redundant/racy. We key on currentTurnIds (LinkShell-driving), NOT on
2972
- // status === "running": an EXTERNALLY-driven session also reports
2973
- // "running" now (live-status feature) but has no event stream, so gating
2974
- // on status would skip hydration and leave its timeline stuck empty
2975
- // ("加载对话记录…" forever).
2976
- if ((this.timelines.get(existingConversation.id) ?? []).length === 0 &&
3376
+ }
3377
+ if (existingConversation && existingConversation.status !== "error") {
3378
+ const owned = Boolean(existingConversation.agentSessionId &&
3379
+ this.ownedCodexSessionIds.has(existingConversation.agentSessionId));
3380
+ // External Codex (desktop/TUI) is the same session on disk. Attach by
3381
+ // tailing that rollout — never loadSession/newSession (those mint another thread).
3382
+ if (provider === "codex" &&
3383
+ !owned &&
3384
+ this.attachedExternalCodex.has(existingConversation.id) &&
2977
3385
  !this.currentTurnIds.has(existingConversation.id)) {
2978
- try {
2979
- const existingAgentSessionId = existingConversation.agentSessionId;
2980
- let result;
3386
+ existingConversation.control = "attached";
3387
+ this.followCodexRolloutDisk(existingConversation, false);
3388
+ this.activeConversationId = existingConversation.id;
3389
+ this.input.send(createEnvelope({
3390
+ type: "agent.v2.conversation.opened",
3391
+ sessionId: this.input.sessionId,
3392
+ payload: {
3393
+ conversation: existingConversation,
3394
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
3395
+ requestedConversationId: payload.conversationId,
3396
+ },
3397
+ }));
3398
+ return existingConversation;
3399
+ }
3400
+ if (existingConversation.agentSessionId) {
3401
+ const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
3402
+ if (payload.conversationId &&
3403
+ payload.conversationId === requestedCanonicalId &&
3404
+ existingConversation.id !== payload.conversationId) {
3405
+ existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
3406
+ }
3407
+ // Hydrate the transcript from disk when we have no timeline yet, UNLESS
3408
+ // LinkShell is itself driving a live turn for this conversation — in that
3409
+ // case the event stream is the source of truth and re-hydrating would be
3410
+ // redundant/racy. We key on currentTurnIds (LinkShell-driving), NOT on
3411
+ // status === "running": an EXTERNALLY-driven session also reports
3412
+ // "running" now (live-status feature) but has no event stream, so gating
3413
+ // on status would skip hydration and leave its timeline stuck empty
3414
+ // ("加载对话记录…" forever).
3415
+ if ((this.timelines.get(existingConversation.id) ?? []).length === 0 &&
3416
+ !this.currentTurnIds.has(existingConversation.id)) {
2981
3417
  try {
2982
- result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
3418
+ const existingAgentSessionId = existingConversation.agentSessionId;
3419
+ let result;
3420
+ try {
3421
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
3422
+ }
3423
+ catch (error) {
3424
+ const recovered = await this.recycleProviderClient(provider, error);
3425
+ if (!recovered)
3426
+ throw error;
3427
+ client = recovered;
3428
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
3429
+ }
3430
+ const nextAgentSessionId = this.extractSessionId(result);
3431
+ if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
3432
+ this.conversationByAgentSessionId.delete(existingAgentSessionId);
3433
+ existingConversation.agentSessionId = nextAgentSessionId;
3434
+ this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
3435
+ }
3436
+ await this.hydrateConversationFromProvider(existingConversation, result);
2983
3437
  }
2984
3438
  catch (error) {
2985
- const recovered = await this.recycleProviderClient(provider, error);
2986
- if (!recovered)
2987
- throw error;
2988
- client = recovered;
2989
- result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
2990
- }
2991
- const nextAgentSessionId = this.extractSessionId(result);
2992
- if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
2993
- this.conversationByAgentSessionId.delete(existingAgentSessionId);
2994
- existingConversation.agentSessionId = nextAgentSessionId;
2995
- this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
2996
- }
2997
- await this.hydrateConversationFromProvider(existingConversation, result);
2998
- }
2999
- catch (error) {
3000
- if (this.input.verbose) {
3001
- process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
3439
+ if (this.input.verbose) {
3440
+ process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
3441
+ }
3442
+ await this.hydrateConversationFromProvider(existingConversation, undefined);
3002
3443
  }
3003
3444
  }
3445
+ this.activeConversationId = existingConversation.id;
3446
+ this.input.send(createEnvelope({
3447
+ type: "agent.v2.conversation.opened",
3448
+ sessionId: this.input.sessionId,
3449
+ payload: {
3450
+ conversation: existingConversation,
3451
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
3452
+ requestedConversationId: payload.conversationId,
3453
+ },
3454
+ }));
3455
+ return existingConversation;
3004
3456
  }
3005
- this.activeConversationId = existingConversation.id;
3006
- this.input.send(createEnvelope({
3007
- type: "agent.v2.conversation.opened",
3008
- sessionId: this.input.sessionId,
3009
- payload: {
3010
- conversation: existingConversation,
3011
- snapshot: this.timelines.get(existingConversation.id) ?? [],
3012
- requestedConversationId: payload.conversationId,
3013
- },
3014
- }));
3015
- return existingConversation;
3016
3457
  }
3017
3458
  try {
3018
3459
  let result;
@@ -3031,6 +3472,8 @@ export class AgentWorkspaceProxy {
3031
3472
  : await client.newSession({ cwd });
3032
3473
  }
3033
3474
  agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
3475
+ if (provider === "codex")
3476
+ this.ownedCodexSessionIds.add(agentSessionId);
3034
3477
  const now = Date.now();
3035
3478
  const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
3036
3479
  const conversation = {
@@ -3040,6 +3483,7 @@ export class AgentWorkspaceProxy {
3040
3483
  provider,
3041
3484
  cwd,
3042
3485
  title: payload.title ?? existingConversation?.title ?? titleFromCwd(cwd),
3486
+ control: "owned",
3043
3487
  model: payload.model ?? existingConversation?.model,
3044
3488
  reasoningEffort: payload.reasoningEffort ?? existingConversation?.reasoningEffort,
3045
3489
  permissionMode: payload.permissionMode ?? existingConversation?.permissionMode,
@@ -3149,9 +3593,13 @@ export class AgentWorkspaceProxy {
3149
3593
  await this.openConversation({ conversationId: payload.conversationId });
3150
3594
  if (!conversation)
3151
3595
  return;
3596
+ if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
3597
+ this.rejectAgentAction(conversation, `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 只旁观同一份记录,停止和插话请在那边操作。`, conversation.status);
3598
+ return;
3599
+ }
3152
3600
  if (!conversation.agentSessionId) {
3153
3601
  this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3154
- ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3602
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。请打开终端面板继续。`
3155
3603
  : "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
3156
3604
  return;
3157
3605
  }
@@ -3163,7 +3611,9 @@ export class AgentWorkspaceProxy {
3163
3611
  return;
3164
3612
  }
3165
3613
  const protocol = this.protocolForProvider(conversation.provider);
3166
- if (payload.contentBlocks.some((block) => block.type === "image") && !protocolSupportsImages(protocol)) {
3614
+ const advertised = this.providerCapabilities.get(conversation.provider)?.advertised
3615
+ ?? (client instanceof AcpClient ? client.advertised : undefined);
3616
+ if (payload.contentBlocks.some((block) => block.type === "image") && !protocolSupportsImages(protocol, advertised)) {
3167
3617
  this.rejectAgentAction(conversation, "当前 Agent provider 暂不支持图片输入,请升级 CLI 或切换到 Codex。");
3168
3618
  return;
3169
3619
  }
@@ -4223,33 +4673,93 @@ export class AgentWorkspaceProxy {
4223
4673
  }
4224
4674
  handleSessionUpdate(params) {
4225
4675
  const raw = asRecord(params) ?? {};
4226
- const nested = asRecord(raw.params) ?? {};
4227
- const text = firstString(raw, ["delta", "text", "content", "message"]) ??
4228
- firstString(nested, ["delta", "text", "content", "message"]);
4229
- const content = contentBlocksFromItem(raw);
4230
- if (!text && content.length === 0)
4231
- return;
4232
- const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
4676
+ const update = asRecord(raw.update) ?? raw;
4677
+ const conversationId = this.conversationIdFromParams(raw)
4678
+ ?? this.conversationIdFromParams(update)
4679
+ ?? this.fallbackConversationId();
4233
4680
  if (!conversationId)
4234
4681
  return;
4235
- if (firstString(raw, ["toolName", "tool", "name"])) {
4682
+ const kind = normalizedIdentifier(firstString(update, ["sessionUpdate", "session_update", "type"]));
4683
+ if (kind === "agentthoughtchunk" || kind === "agentthought") {
4684
+ const contentBlock = asRecord(update.content);
4685
+ const thought = (typeof contentBlock?.text === "string" ? contentBlock.text : undefined)
4686
+ ?? firstString(update, ["delta", "text", "content", "message"]);
4687
+ if (!thought)
4688
+ return;
4689
+ this.handleReasoningDelta({
4690
+ ...raw,
4691
+ itemId: firstString(update, ["itemId", "id"]) ?? "thinking",
4692
+ delta: thought,
4693
+ });
4694
+ return;
4695
+ }
4696
+ if (kind === "toolcall" || kind === "toolcallupdate") {
4697
+ const toolCallId = firstString(update, ["toolCallId", "id"]) ?? id("tool");
4698
+ const existing = this.findTool(conversationId, toolCallId);
4699
+ const statusRaw = firstString(update, ["status"]);
4700
+ const status = statusRaw === "in_progress" ? "running"
4701
+ : statusRaw === "completed" || statusRaw === "failed" || statusRaw === "pending" || statusRaw === "running"
4702
+ ? statusRaw
4703
+ : existing?.status ?? "running";
4704
+ const input = stringifyDefined(update.rawInput ?? update.input);
4705
+ const output = stringifyDefined(update.rawOutput ?? update.output);
4706
+ this.upsertTool(conversationId, {
4707
+ id: toolCallId,
4708
+ name: firstString(update, ["title", "kind", "name"]) ?? existing?.name ?? "tool",
4709
+ ...(input ? { input } : {}),
4710
+ ...(output ? { output } : {}),
4711
+ createdAt: existing?.createdAt ?? Date.now(),
4712
+ status,
4713
+ });
4714
+ return;
4715
+ }
4716
+ if (kind === "plan") {
4717
+ const entries = Array.isArray(update.entries) ? update.entries : [];
4718
+ this.handlePlanUpdated({
4719
+ ...raw,
4720
+ plan: entries.map((entry) => {
4721
+ const step = asRecord(entry) ?? {};
4722
+ return {
4723
+ text: firstString(step, ["content", "text", "title", "description"]),
4724
+ status: step.status,
4725
+ };
4726
+ }),
4727
+ });
4728
+ return;
4729
+ }
4730
+ const nested = asRecord(raw.params) ?? asRecord(update.params) ?? {};
4731
+ const contentBlock = asRecord(update.content);
4732
+ const text = firstString(update, ["delta", "text", "content", "message"]) ??
4733
+ firstString(raw, ["delta", "text", "content", "message"]) ??
4734
+ firstString(nested, ["delta", "text", "content", "message"]) ??
4735
+ (typeof contentBlock?.text === "string" ? contentBlock.text : undefined);
4736
+ const content = contentBlocksFromItem(update).length > 0
4737
+ ? contentBlocksFromItem(update)
4738
+ : contentBlocksFromItem(raw);
4739
+ if (firstString(raw, ["toolName", "tool", "name"]) || firstString(update, ["toolName", "tool", "name"])) {
4236
4740
  this.upsertTool(conversationId, {
4237
- id: firstString(raw, ["toolCallId", "callId", "id"]) ?? id("tool"),
4238
- name: firstString(raw, ["toolName", "tool", "name"]) ?? "tool",
4239
- input: stringify(raw.input ?? raw.toolInput ?? ""),
4240
- output: stringify(raw.output ?? raw.result ?? ""),
4741
+ id: firstString(update, ["toolCallId", "callId", "id"])
4742
+ ?? firstString(raw, ["toolCallId", "callId", "id"])
4743
+ ?? id("tool"),
4744
+ name: firstString(update, ["toolName", "tool", "name"])
4745
+ ?? firstString(raw, ["toolName", "tool", "name"])
4746
+ ?? "tool",
4747
+ input: stringify(update.input ?? update.toolInput ?? raw.input ?? raw.toolInput ?? ""),
4748
+ output: stringify(update.output ?? update.result ?? raw.output ?? raw.result ?? ""),
4241
4749
  createdAt: Date.now(),
4242
- status: raw.status === "completed" || raw.status === "failed" || raw.status === "running"
4243
- ? raw.status
4750
+ status: update.status === "completed" || raw.status === "completed" || update.status === "failed" || raw.status === "failed" || update.status === "running" || raw.status === "running"
4751
+ ? (update.status ?? raw.status)
4244
4752
  : "running",
4245
4753
  });
4246
4754
  return;
4247
4755
  }
4756
+ if (!text && content.length === 0)
4757
+ return;
4248
4758
  const role = raw.role === "user" || raw.role === "system" ? raw.role : "assistant";
4249
4759
  const blocks = content.length > 0 ? content : [{ type: "text", text }];
4250
4760
  const preview = textFromBlocks(blocks);
4251
4761
  this.upsertItem(conversationId, {
4252
- id: firstString(raw, ["messageId", "id"]) ?? id("msg"),
4762
+ id: firstString(update, ["messageId", "id"]) ?? firstString(raw, ["messageId", "id"]) ?? id("msg"),
4253
4763
  conversationId,
4254
4764
  type: "message",
4255
4765
  role,
@@ -4257,7 +4767,7 @@ export class AgentWorkspaceProxy {
4257
4767
  text: preview,
4258
4768
  createdAt: Date.now(),
4259
4769
  updatedAt: Date.now(),
4260
- isStreaming: raw.done === false || raw.isStreaming === true,
4770
+ isStreaming: raw.done === false || raw.isStreaming === true || kind === "agentmessagechunk",
4261
4771
  });
4262
4772
  this.updateConversationPreview(conversationId, preview || "图片附件", raw.done === true ? "idle" : "running");
4263
4773
  }