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 {
@@ -6,7 +6,7 @@ import {
6
6
  parseTypedPayload,
7
7
  type Envelope,
8
8
  } from "@linkshell/protocol";
9
- import { AcpClient } from "./acp-client.js";
9
+ import { AcpClient, type AdvertisedAcpCapabilities } from "./acp-client.js";
10
10
  import { ClaudeSdkClient } from "./claude-sdk-client.js";
11
11
  import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
12
12
  import { aggregateUsage } from "../usage-report.js";
@@ -249,11 +249,14 @@ interface AgentConversation {
249
249
  agentSessionId?: string;
250
250
  provider: AgentProvider;
251
251
  cwd: string;
252
+ workspaceRoots?: string[];
253
+ group?: string;
252
254
  title?: string;
253
255
  model?: string;
254
256
  reasoningEffort?: string;
255
257
  permissionMode?: AgentPermissionMode;
256
258
  collaborationMode?: AgentCollaborationMode;
259
+ control?: "owned" | "attached";
257
260
  status: AgentStatus;
258
261
  archived: boolean;
259
262
  lastMessagePreview?: string;
@@ -475,7 +478,9 @@ function normalizeToolStatus(value: unknown, completedFallback = false): AgentTo
475
478
 
476
479
  function normalizePlanStatus(value: unknown): AgentPlanStep["status"] {
477
480
  if (value === "completed" || value === "done") return "completed";
478
- if (value === "inProgress" || value === "running" || value === "active") return "in_progress";
481
+ if (value === "inProgress" || value === "in_progress" || value === "running" || value === "active") {
482
+ return "in_progress";
483
+ }
479
484
  return "pending";
480
485
  }
481
486
 
@@ -808,7 +813,11 @@ function contentBlocksFromItem(item: Record<string, unknown>): AgentContentBlock
808
813
  return text ? [{ type: "text", text }] : [];
809
814
  }
810
815
 
811
- function protocolSupportsImages(protocol: AgentProtocol | undefined): boolean {
816
+ function protocolSupportsImages(
817
+ protocol: AgentProtocol | undefined,
818
+ advertised?: AdvertisedAcpCapabilities,
819
+ ): boolean {
820
+ if (protocol === "acp") return Boolean(advertised?.images);
812
821
  return protocol === "codex-app-server" ||
813
822
  protocol === "claude-agent-sdk" ||
814
823
  protocol === "claude-stream-json";
@@ -997,6 +1006,7 @@ interface ProviderRuntimeCapabilities {
997
1006
  commands?: AgentCommandDescriptor[];
998
1007
  modes?: AgentModeDescriptor[];
999
1008
  currentMode?: string;
1009
+ advertised?: AdvertisedAcpCapabilities;
1000
1010
  // MCP server connection status, keyed by server name (last-writer-wins across
1001
1011
  // the init seed and live startupStatus events).
1002
1012
  mcpServers?: Map<string, AgentMcpServerDescriptor>;
@@ -1668,6 +1678,226 @@ function codexSessionConfig(sessionId: string): {
1668
1678
  return cfg;
1669
1679
  }
1670
1680
 
1681
+ const CODEX_ROLLOUT_NAME =
1682
+ /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
1683
+
1684
+ function walkCodexRollouts(
1685
+ root: string,
1686
+ onFile: (path: string, filenameId: string, mtimeMs: number) => void,
1687
+ ): void {
1688
+ if (!existsSync(root)) return;
1689
+ const walk = (dir: string): void => {
1690
+ let entries: string[];
1691
+ try { entries = readdirSync(dir); } catch { return; }
1692
+ for (const name of entries) {
1693
+ const path = join(dir, name);
1694
+ let stat;
1695
+ try { stat = statSync(path); } catch { continue; }
1696
+ if (stat.isDirectory()) { walk(path); continue; }
1697
+ const match = CODEX_ROLLOUT_NAME.exec(name);
1698
+ if (match?.[1]) onFile(path, match[1], stat.mtimeMs);
1699
+ }
1700
+ };
1701
+ walk(root);
1702
+ }
1703
+
1704
+ function findCodexRolloutPath(sessionId: string, root = join(homedir(), ".codex", "sessions")): string | undefined {
1705
+ const byName: { path: string; mt: number }[] = [];
1706
+ const recent: { path: string; mt: number }[] = [];
1707
+ walkCodexRollouts(root, (path, filenameId, mt) => {
1708
+ recent.push({ path, mt });
1709
+ if (filenameId === sessionId) byName.push({ path, mt });
1710
+ });
1711
+ if (byName.length > 0) {
1712
+ byName.sort((a, b) => b.mt - a.mt);
1713
+ return byName[0]?.path;
1714
+ }
1715
+ recent.sort((a, b) => b.mt - a.mt);
1716
+ for (const candidate of recent.slice(0, 80)) {
1717
+ try {
1718
+ const fdText = readFileSync(candidate.path, "utf8");
1719
+ const first = fdText.slice(0, fdText.indexOf("\n") >= 0 ? fdText.indexOf("\n") : fdText.length);
1720
+ const metaId = firstString(asRecord(asRecord(JSON.parse(first))?.payload), ["session_id", "id"]);
1721
+ if (metaId === sessionId) return candidate.path;
1722
+ } catch { /* skip */ }
1723
+ }
1724
+ return undefined;
1725
+ }
1726
+
1727
+ function findLatestCodexRolloutForCwd(cwd: string, root = join(homedir(), ".codex", "sessions")): { path: string; sessionId: string } | undefined {
1728
+ const wanted = cwd.replace(/\/+$/, "");
1729
+ const candidates: { path: string; sessionId: string; mt: number }[] = [];
1730
+ walkCodexRollouts(root, (path, filenameId, mt) => {
1731
+ candidates.push({ path, sessionId: filenameId, mt });
1732
+ });
1733
+ candidates.sort((a, b) => b.mt - a.mt);
1734
+ for (const candidate of candidates.slice(0, 40)) {
1735
+ try {
1736
+ const text = readFileSync(candidate.path, "utf8");
1737
+ const first = text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length);
1738
+ const payload = asRecord(asRecord(JSON.parse(first))?.payload);
1739
+ const fileCwd = firstString(payload, ["cwd"])?.replace(/\/+$/, "");
1740
+ const metaId = firstString(payload, ["session_id", "id"]) ?? candidate.sessionId;
1741
+ if (fileCwd === wanted) return { path: candidate.path, sessionId: metaId };
1742
+ } catch { continue; }
1743
+ }
1744
+ return undefined;
1745
+ }
1746
+
1747
+ function publicWorkspaceRoots(roots: unknown): string[] | undefined {
1748
+ if (!Array.isArray(roots)) return undefined;
1749
+ const cleaned = roots
1750
+ .filter((root): root is string => typeof root === "string" && root.length > 0)
1751
+ .filter((root) => !root.includes("/.codex/visualizations/"));
1752
+ return cleaned.length > 0 ? cleaned : undefined;
1753
+ }
1754
+
1755
+ function isInjectedCodexUserText(text: string): boolean {
1756
+ const trimmed = text.trim();
1757
+ return (
1758
+ trimmed.startsWith("<app-context>") ||
1759
+ trimmed.startsWith("<recommended_plugins>") ||
1760
+ trimmed.startsWith("<skills_instructions>") ||
1761
+ trimmed.startsWith("# AGENTS.md") ||
1762
+ trimmed.startsWith("You are `/root`")
1763
+ );
1764
+ }
1765
+
1766
+ function textsFromCodexContent(content: unknown): string[] {
1767
+ if (typeof content === "string" && content.trim()) return [content];
1768
+ if (!Array.isArray(content)) return [];
1769
+ const texts: string[] = [];
1770
+ for (const block of content) {
1771
+ const rec = asRecord(block);
1772
+ const text = typeof rec?.text === "string" ? rec.text : undefined;
1773
+ if (text?.trim()) texts.push(text);
1774
+ }
1775
+ return texts;
1776
+ }
1777
+
1778
+ /** Incremental Codex jsonl parser so live TUI/desktop sessions can tail the file. */
1779
+ export class CodexRolloutParser {
1780
+ private toolNames = new Map<string, string>();
1781
+ private index = 0;
1782
+ workspaceRoots: string[] | undefined;
1783
+ sessionCwd: string | undefined;
1784
+
1785
+ consume(text: string, conversationId: string): AgentTimelineItem[] {
1786
+ const items: AgentTimelineItem[] = [];
1787
+ for (const line of text.split(/\r?\n/)) {
1788
+ if (!line.trim()) continue;
1789
+ const next = this.consumeLine(line, conversationId);
1790
+ if (next) items.push(next);
1791
+ }
1792
+ return items;
1793
+ }
1794
+
1795
+ private consumeLine(line: string, conversationId: string): AgentTimelineItem | undefined {
1796
+ let record: Record<string, unknown> | undefined;
1797
+ try { record = asRecord(JSON.parse(line)); } catch { return undefined; }
1798
+ if (!record) return undefined;
1799
+ const payload = asRecord(record.payload) ?? record;
1800
+ const recordType = firstString(record, ["type"]);
1801
+ const payloadType = firstString(payload, ["type"]);
1802
+ const roots = publicWorkspaceRoots(payload.workspace_roots ?? payload.workspaceRoots);
1803
+ if (roots) this.workspaceRoots = roots;
1804
+ const cwd = firstString(payload, ["cwd"]);
1805
+ if (cwd) this.sessionCwd = cwd;
1806
+ const createdAt = parseTimestamp(record.timestamp ?? payload.timestamp) ?? Date.now();
1807
+ if (recordType === "response_item" && payloadType === "message") {
1808
+ const roleRaw = firstString(payload, ["role"]);
1809
+ if (roleRaw !== "user" && roleRaw !== "assistant") return undefined;
1810
+ const texts = textsFromCodexContent(payload.content).filter((text) =>
1811
+ roleRaw === "assistant" || !isInjectedCodexUserText(text),
1812
+ );
1813
+ if (texts.length === 0) return undefined;
1814
+ const text = texts.join("\n\n");
1815
+ this.index += 1;
1816
+ return {
1817
+ id: firstString(payload, ["id"]) ?? id(`codex-msg-${this.index}`),
1818
+ conversationId,
1819
+ type: "message",
1820
+ kind: "chat",
1821
+ role: roleRaw,
1822
+ text,
1823
+ content: [{ type: "text", text }],
1824
+ createdAt,
1825
+ updatedAt: createdAt,
1826
+ };
1827
+ }
1828
+ if (recordType === "response_item" && payloadType === "reasoning") {
1829
+ const texts = textsFromCodexContent(payload.content ?? payload.summary);
1830
+ if (texts.length === 0) return undefined;
1831
+ this.index += 1;
1832
+ return {
1833
+ id: firstString(payload, ["id"]) ?? id(`codex-think-${this.index}`),
1834
+ conversationId,
1835
+ type: "status",
1836
+ kind: "thinking",
1837
+ text: texts.join("\n"),
1838
+ createdAt,
1839
+ updatedAt: createdAt,
1840
+ };
1841
+ }
1842
+ if (recordType === "response_item" && (payloadType === "custom_tool_call" || payloadType === "function_call")) {
1843
+ const callId = firstString(payload, ["call_id", "id"]) ?? id(`codex-tool-${this.index}`);
1844
+ const name = firstString(payload, ["name"]) ?? "tool";
1845
+ this.toolNames.set(callId, name);
1846
+ const input = firstString(payload, ["input", "arguments"]) ?? stringify(payload.input ?? payload.arguments ?? "");
1847
+ this.index += 1;
1848
+ return {
1849
+ id: `tool:${callId}`,
1850
+ conversationId,
1851
+ type: "tool_call",
1852
+ kind: name === "exec" || name.includes("command") ? "command_execution" : "tool_activity",
1853
+ itemId: callId,
1854
+ toolCall: {
1855
+ id: callId,
1856
+ name,
1857
+ input,
1858
+ createdAt,
1859
+ status: "running",
1860
+ },
1861
+ createdAt,
1862
+ updatedAt: createdAt,
1863
+ };
1864
+ }
1865
+ if (recordType === "response_item" && (payloadType === "custom_tool_call_output" || payloadType === "function_call_output")) {
1866
+ const callId = firstString(payload, ["call_id", "id"]);
1867
+ if (!callId) return undefined;
1868
+ const outputTexts = textsFromCodexContent(payload.output);
1869
+ const output = outputTexts.join("\n") || stringify(payload.output ?? "");
1870
+ this.index += 1;
1871
+ return {
1872
+ id: `tool:${callId}`,
1873
+ conversationId,
1874
+ type: "tool_call",
1875
+ kind: "tool_activity",
1876
+ itemId: callId,
1877
+ toolCall: {
1878
+ id: callId,
1879
+ name: this.toolNames.get(callId) ?? "tool",
1880
+ output,
1881
+ createdAt,
1882
+ status: "completed",
1883
+ },
1884
+ createdAt,
1885
+ updatedAt: createdAt,
1886
+ };
1887
+ }
1888
+ return undefined;
1889
+ }
1890
+ }
1891
+
1892
+ /** Parse a Codex on-disk rollout into timeline items. App-server thread/read
1893
+ * only sees threads it owns; desktop/TUI sessions live in jsonl. */
1894
+ export function timelineItemsFromCodexRolloutText(
1895
+ text: string,
1896
+ conversationId: string,
1897
+ ): AgentTimelineItem[] {
1898
+ return new CodexRolloutParser().consume(text, conversationId).slice(-MAX_TIMELINE_ITEMS);
1899
+ }
1900
+
1671
1901
  function parseRemoteSessions(value: unknown): Array<{
1672
1902
  id: string;
1673
1903
  cwd?: string;
@@ -2089,6 +2319,21 @@ export class AgentWorkspaceProxy {
2089
2319
  // Echo the updated record so every client refreshes (the web store
2090
2320
  // force-replaces by id on any event carrying `conversation`).
2091
2321
  this.emitConversation(conversation);
2322
+ if (conversation.agentSessionId) {
2323
+ const client = this.clientForProvider(conversation.provider);
2324
+ if (client instanceof AcpClient && model?.trim() && client.advertised.setModel && conversation.provider !== "codex") {
2325
+ try {
2326
+ await client.setSessionModel({
2327
+ sessionId: conversation.agentSessionId,
2328
+ modelId: model.trim(),
2329
+ });
2330
+ } catch (error) {
2331
+ if (this.input.verbose) {
2332
+ process.stderr.write(`[agent:v2] session/set_model failed: ${error instanceof Error ? error.message : String(error)}\n`);
2333
+ }
2334
+ }
2335
+ }
2336
+ }
2092
2337
  if (conversation.provider === "codex" && conversation.agentSessionId) {
2093
2338
  const client = this.clientForProvider(conversation.provider);
2094
2339
  const settingsChanged =
@@ -2182,6 +2427,14 @@ export class AgentWorkspaceProxy {
2182
2427
  const payload = parseTypedPayload("agent.v2.cancel", envelope.payload);
2183
2428
  const conversation = this.conversations.get(payload.conversationId);
2184
2429
  if (!conversation) break;
2430
+ if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
2431
+ this.rejectAgentAction(
2432
+ conversation,
2433
+ `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 无法停止它,请在那边操作。`,
2434
+ conversation.status,
2435
+ );
2436
+ break;
2437
+ }
2185
2438
  const turnId = this.currentTurnIds.get(payload.conversationId);
2186
2439
  if (this.protocolForProvider(conversation.provider) === "codex-app-server" && !turnId) {
2187
2440
  this.rejectAgentAction(
@@ -2249,8 +2502,18 @@ export class AgentWorkspaceProxy {
2249
2502
  private fsSyncRunning = false;
2250
2503
  private lastPushedConversationSignature: string | undefined;
2251
2504
  private lastHydrateAt = new Map<string, number>();
2252
- private static readonly FS_SYNC_THROTTLE_MS = 1500;
2253
- private static readonly EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
2505
+ /** Session ids this LinkShell process created via app-server `newSession`. */
2506
+ private ownedCodexSessionIds = new Set<string>();
2507
+ /** Conversations attached to an external Codex TUI/desktop rollout (same file, not a copy). */
2508
+ private attachedExternalCodex = new Set<string>();
2509
+ private codexFollow = new Map<string, {
2510
+ path: string;
2511
+ offset: number;
2512
+ carry: string;
2513
+ parser: CodexRolloutParser;
2514
+ }>();
2515
+ private static readonly FS_SYNC_THROTTLE_MS = 400;
2516
+ private static readonly EXTERNAL_HYDRATE_THROTTLE_MS = 400;
2254
2517
 
2255
2518
  private startSessionWatchers(): void {
2256
2519
  const roots = [
@@ -2325,7 +2588,9 @@ export class AgentWorkspaceProxy {
2325
2588
  this.lastHydrateAt.set(conversation.id, now);
2326
2589
  try {
2327
2590
  await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
2328
- this.sendSnapshot(conversation.id);
2591
+ if (this.followCodexRolloutDisk(conversation, true) > 0) {
2592
+ this.sendSnapshot(conversation.id);
2593
+ }
2329
2594
  } catch (error) {
2330
2595
  if (this.input.verbose) {
2331
2596
  process.stderr.write(`[agent:v2] external hydrate failed for ${conversation.id}: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -2455,11 +2720,17 @@ export class AgentWorkspaceProxy {
2455
2720
  seenKeys.add(key);
2456
2721
  this.processMissCounts.set(key, 0);
2457
2722
 
2458
- const existingId = snapshot.sessionId
2459
- ? this.conversationByAgentSessionId.get(snapshot.sessionId)
2723
+ let sessionId = snapshot.sessionId;
2724
+ if (!sessionId && snapshot.provider === "codex") {
2725
+ const matched = findLatestCodexRolloutForCwd(snapshot.cwd ?? this.input.cwd);
2726
+ if (matched) sessionId = matched.sessionId;
2727
+ }
2728
+ const existingId = sessionId
2729
+ ? this.conversationByAgentSessionId.get(sessionId)
2460
2730
  : undefined;
2461
- const liveId = liveConversationId(snapshot);
2462
- const conversationId = existingId ?? liveId;
2731
+ const liveId = liveConversationId({ ...snapshot, sessionId });
2732
+ const conversationId = existingId
2733
+ ?? (sessionId ? makeAgentV2RemoteConversationId(snapshot.provider, sessionId) : liveId);
2463
2734
  const existing = this.conversations.get(conversationId);
2464
2735
  const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
2465
2736
  const driving = this.currentTurnIds.has(conversationId);
@@ -2474,10 +2745,12 @@ export class AgentWorkspaceProxy {
2474
2745
  existing.status = status;
2475
2746
  existing.cwd = cwd;
2476
2747
  if (!existing.title) existing.title = agentProviderLabel(snapshot.provider);
2477
- if (snapshot.sessionId && !existing.agentSessionId) {
2478
- existing.agentSessionId = snapshot.sessionId;
2479
- this.conversationByAgentSessionId.set(snapshot.sessionId, existing.id);
2748
+ if (sessionId && !existing.agentSessionId) {
2749
+ existing.agentSessionId = sessionId;
2750
+ this.conversationByAgentSessionId.set(sessionId, existing.id);
2480
2751
  }
2752
+ this.attachedExternalCodex.add(existing.id);
2753
+ existing.control = "attached";
2481
2754
  if (changed) {
2482
2755
  listChanged = true;
2483
2756
  this.emitConversation(existing);
@@ -2488,7 +2761,7 @@ export class AgentWorkspaceProxy {
2488
2761
  const now = Date.now();
2489
2762
  const conversation: AgentConversation = {
2490
2763
  id: conversationId,
2491
- agentSessionId: snapshot.sessionId,
2764
+ agentSessionId: sessionId,
2492
2765
  provider: snapshot.provider,
2493
2766
  cwd,
2494
2767
  title: agentProviderLabel(snapshot.provider),
@@ -2497,9 +2770,11 @@ export class AgentWorkspaceProxy {
2497
2770
  lastMessagePreview: "本机终端中的 Agent 进程",
2498
2771
  lastActivityAt: now,
2499
2772
  createdAt: now,
2773
+ control: "attached",
2500
2774
  };
2501
2775
  this.conversations.set(conversation.id, conversation);
2502
- if (snapshot.sessionId) this.conversationByAgentSessionId.set(snapshot.sessionId, conversation.id);
2776
+ if (sessionId) this.conversationByAgentSessionId.set(sessionId, conversation.id);
2777
+ this.attachedExternalCodex.add(conversation.id);
2503
2778
  this.emitConversation(conversation);
2504
2779
  listChanged = true;
2505
2780
  }
@@ -2693,7 +2968,14 @@ export class AgentWorkspaceProxy {
2693
2968
  client: AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient,
2694
2969
  protocol: AgentProtocol,
2695
2970
  ): Promise<void> {
2696
- if (client instanceof AcpClient && protocol !== "codex-app-server") return;
2971
+ if (client instanceof AcpClient) {
2972
+ const prior = this.providerCapabilities.get(provider);
2973
+ this.providerCapabilities.set(provider, {
2974
+ ...prior,
2975
+ advertised: client.advertised,
2976
+ });
2977
+ if (protocol !== "codex-app-server") return;
2978
+ }
2697
2979
  const listModels = (client as { listModels?: () => Promise<unknown> }).listModels;
2698
2980
  let runtimeCapabilities: ProviderRuntimeCapabilities | undefined;
2699
2981
  if (typeof listModels === "function") {
@@ -2722,6 +3004,8 @@ export class AgentWorkspaceProxy {
2722
3004
  commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
2723
3005
  modes: runtimeCapabilities?.modes,
2724
3006
  currentMode: runtimeCapabilities?.currentMode,
3007
+ advertised: this.providerCapabilities.get(provider)?.advertised
3008
+ ?? (client instanceof AcpClient ? client.advertised : undefined),
2725
3009
  // Preserve any MCP status already accumulated from startupStatus events.
2726
3010
  mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
2727
3011
  };
@@ -2745,7 +3029,12 @@ export class AgentWorkspaceProxy {
2745
3029
  commands: mergeCommands(runtimeCapabilities.commands, skillCommands),
2746
3030
  };
2747
3031
  }
2748
- this.providerCapabilities.set(provider, runtimeCapabilities);
3032
+ this.providerCapabilities.set(provider, {
3033
+ ...runtimeCapabilities,
3034
+ advertised: runtimeCapabilities.advertised
3035
+ ?? this.providerCapabilities.get(provider)?.advertised
3036
+ ?? (client instanceof AcpClient ? client.advertised : undefined),
3037
+ });
2749
3038
  }
2750
3039
  if (listedMcp.length > 0) this.mergeMcpServers(provider, listedMcp);
2751
3040
  }
@@ -2857,6 +3146,8 @@ export class AgentWorkspaceProxy {
2857
3146
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
2858
3147
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
2859
3148
  usage,
3149
+ group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
3150
+ workspaceRoots: existing?.workspaceRoots,
2860
3151
  };
2861
3152
  this.conversations.set(conversation.id, conversation);
2862
3153
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
@@ -2995,7 +3286,13 @@ export class AgentWorkspaceProxy {
2995
3286
  if (turnCount > 0) this.historyCursors.set(conversation.id, String(turnCount));
2996
3287
  }
2997
3288
 
2998
- const hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
3289
+ let hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
3290
+ if (conversation.provider === "codex") {
3291
+ this.followCodexRolloutDisk(conversation, false);
3292
+ }
3293
+ if (hydratedItems.length === 0) {
3294
+ hydratedItems = this.timelines.get(conversation.id) ?? [];
3295
+ }
2999
3296
  if (hydratedItems.length === 0) return;
3000
3297
  const existing = this.timelines.get(conversation.id) ?? [];
3001
3298
  const merged = new Map<string, AgentTimelineItem>();
@@ -3012,9 +3309,84 @@ export class AgentWorkspaceProxy {
3012
3309
  const lastPreview = [...nextItems].reverse()
3013
3310
  .map((item) => previewText(previewFromTimelineItem(item) ?? ""))
3014
3311
  .find(Boolean);
3015
- if (lastPreview && !conversation.lastMessagePreview) {
3016
- conversation.lastMessagePreview = lastPreview;
3312
+ if (lastPreview) conversation.lastMessagePreview = lastPreview;
3313
+ }
3314
+
3315
+ /** Tail a Codex desktop/TUI rollout. `emit` sends agent.v2.event for new lines
3316
+ * so a phone/web client sees tokens as the jsonl grows, not only on open. */
3317
+ private followCodexRolloutDisk(conversation: AgentConversation, emit: boolean): number {
3318
+ if (conversation.provider !== "codex") return 0;
3319
+ let path = conversation.agentSessionId
3320
+ ? findCodexRolloutPath(conversation.agentSessionId)
3321
+ : undefined;
3322
+ if (!path && conversation.cwd) {
3323
+ const matched = findLatestCodexRolloutForCwd(conversation.cwd);
3324
+ if (matched) {
3325
+ path = matched.path;
3326
+ if (!conversation.agentSessionId) {
3327
+ conversation.agentSessionId = matched.sessionId;
3328
+ this.conversationByAgentSessionId.set(matched.sessionId, conversation.id);
3329
+ }
3330
+ }
3331
+ }
3332
+ if (!path) return 0;
3333
+ let follow = this.codexFollow.get(conversation.id);
3334
+ if (!follow || follow.path !== path) {
3335
+ follow = { path, offset: 0, carry: "", parser: new CodexRolloutParser() };
3336
+ this.codexFollow.set(conversation.id, follow);
3337
+ }
3338
+ let stat;
3339
+ try {
3340
+ stat = statSync(path);
3341
+ } catch {
3342
+ return 0;
3343
+ }
3344
+ if (stat.size < follow.offset) {
3345
+ follow.offset = 0;
3346
+ follow.carry = "";
3347
+ follow.parser = new CodexRolloutParser();
3348
+ }
3349
+ if (stat.size === follow.offset && !follow.carry) return 0;
3350
+ const length = stat.size - follow.offset;
3351
+ const buf = Buffer.alloc(length);
3352
+ const fd = openSync(path, "r");
3353
+ try {
3354
+ readSync(fd, buf, 0, length, follow.offset);
3355
+ } finally {
3356
+ closeSync(fd);
3357
+ }
3358
+ follow.offset = stat.size;
3359
+ const combined = follow.carry + buf.toString("utf8");
3360
+ const lastNl = combined.lastIndexOf("\n");
3361
+ if (lastNl < 0) {
3362
+ follow.carry = combined;
3363
+ return 0;
3364
+ }
3365
+ const complete = combined.slice(0, lastNl + 1);
3366
+ follow.carry = combined.slice(lastNl + 1);
3367
+ const items = follow.parser.consume(complete, conversation.id);
3368
+ if (items.length === 0) return 0;
3369
+ if (emit) {
3370
+ for (const item of items) this.upsertItem(conversation.id, item);
3371
+ } else {
3372
+ const existing = this.timelines.get(conversation.id) ?? [];
3373
+ const merged = new Map(existing.map((item) => [item.id, item] as const));
3374
+ for (const item of items) merged.set(item.id, item);
3375
+ const nextItems = [...merged.values()]
3376
+ .sort((a, b) => a.createdAt - b.createdAt)
3377
+ .slice(-MAX_TIMELINE_ITEMS);
3378
+ this.timelines.set(conversation.id, nextItems);
3379
+ for (const item of nextItems) this.rememberItemConversationId(conversation.id, item);
3017
3380
  }
3381
+ const timeline = this.timelines.get(conversation.id) ?? [];
3382
+ const lastPreview = [...timeline].reverse()
3383
+ .map((item) => previewText(previewFromTimelineItem(item) ?? ""))
3384
+ .find(Boolean);
3385
+ if (lastPreview) conversation.lastMessagePreview = lastPreview;
3386
+ conversation.lastActivityAt = Date.now();
3387
+ if (follow.parser.workspaceRoots?.length) conversation.workspaceRoots = follow.parser.workspaceRoots;
3388
+ if (follow.parser.sessionCwd && !conversation.cwd) conversation.cwd = follow.parser.sessionCwd;
3389
+ return items.length;
3018
3390
  }
3019
3391
 
3020
3392
  private sendCapabilities(): void {
@@ -3026,11 +3398,23 @@ export class AgentWorkspaceProxy {
3026
3398
  const client = this.clients.get(provider);
3027
3399
  const protocol = this.agentProtocols.get(provider);
3028
3400
  const runtimeCapabilities = this.providerCapabilities.get(provider);
3401
+ const advertised = runtimeCapabilities?.advertised
3402
+ ?? (client instanceof AcpClient ? client.advertised : undefined);
3029
3403
  const enabled = Boolean(client);
3030
- const supportsImages = enabled && protocolSupportsImages(protocol);
3404
+ const isAcp = protocol === "acp";
3405
+ const supportsImages = enabled && protocolSupportsImages(protocol, advertised);
3031
3406
  const isClaudeFallback = protocol === "claude-stream-json";
3032
3407
  const supportsPermission = enabled && !isClaudeFallback;
3033
3408
  const supportsReasoningEffort = enabled && !isClaudeFallback;
3409
+ const supportsSessionList = enabled && (isAcp ? Boolean(advertised?.listSession) : !isClaudeFallback);
3410
+ const supportsSessionLoad = enabled && (isAcp ? Boolean(advertised?.loadSession) : !isClaudeFallback);
3411
+ const supportsCancel = enabled && (isAcp ? Boolean(advertised?.cancel) : true);
3412
+ const supportsFork = enabled && (isAcp
3413
+ ? Boolean(advertised?.forkSession)
3414
+ : protocol === "codex-app-server" || protocol === "claude-agent-sdk");
3415
+ const supportsSetModel = enabled && (isAcp
3416
+ ? Boolean(advertised?.setModel)
3417
+ : protocol === "codex-app-server");
3034
3418
  const protocolBacked = isProtocolAgentProvider(provider);
3035
3419
  const commands = mergeCommands(
3036
3420
  defaultProviderCommands(provider, this.input.cwd, enabled),
@@ -3048,8 +3432,8 @@ export class AgentWorkspaceProxy {
3048
3432
  : `${providerLabel(provider)} 已在本机检测;远程对话请在终端继续`,
3049
3433
  supportsImages,
3050
3434
  supportsPermission,
3051
- supportsPlan: enabled,
3052
- supportsCancel: enabled,
3435
+ supportsPlan: enabled && (isAcp ? Boolean(advertised?.embeddedContext) : true),
3436
+ supportsCancel,
3053
3437
  models: runtimeCapabilities?.models ?? [{ id: "default", label: "默认模型" }],
3054
3438
  defaultModel: runtimeCapabilities?.defaultModel ?? "default",
3055
3439
  reasoningEfforts: supportsReasoningEffort
@@ -3064,11 +3448,17 @@ export class AgentWorkspaceProxy {
3064
3448
  currentMode,
3065
3449
  features: {
3066
3450
  images: supportsImages,
3451
+ audio: Boolean(advertised?.audio),
3067
3452
  permissions: supportsPermission,
3068
- plan: enabled,
3069
- cancel: enabled,
3453
+ plan: enabled && (protocol === "acp" ? Boolean(advertised?.embeddedContext) : true),
3454
+ cancel: supportsCancel,
3070
3455
  reasoningEffort: supportsReasoningEffort,
3071
3456
  streamJsonFallback: isClaudeFallback,
3457
+ loadSession: supportsSessionLoad,
3458
+ sessionList: supportsSessionList,
3459
+ sessionFork: supportsFork,
3460
+ setModel: supportsSetModel,
3461
+ mcp: Boolean(advertised?.mcp) || Boolean(runtimeCapabilities?.mcpServers && runtimeCapabilities.mcpServers.size > 0),
3072
3462
  },
3073
3463
  mcpServers: runtimeCapabilities?.mcpServers && runtimeCapabilities.mcpServers.size > 0
3074
3464
  ? [...runtimeCapabilities.mcpServers.values()]
@@ -3087,13 +3477,13 @@ export class AgentWorkspaceProxy {
3087
3477
  protocolVersion: 1,
3088
3478
  workspaceProtocolVersion: 2,
3089
3479
  error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
3090
- supportsSessionList: anyEnabled,
3091
- supportsSessionLoad: anyEnabled,
3480
+ supportsSessionList: providers.some((p) => p.features.sessionList),
3481
+ supportsSessionLoad: providers.some((p) => p.features.loadSession),
3092
3482
  supportsImages: providers.some((p) => p.supportsImages),
3093
- supportsAudio: false,
3483
+ supportsAudio: providers.some((p) => p.features.audio),
3094
3484
  supportsPermission: anyPermission,
3095
- supportsPlan: anyEnabled,
3096
- supportsCancel: anyEnabled,
3485
+ supportsPlan: providers.some((p) => p.supportsPlan),
3486
+ supportsCancel: providers.some((p) => p.supportsCancel),
3097
3487
  },
3098
3488
  }));
3099
3489
  }
@@ -3207,7 +3597,7 @@ export class AgentWorkspaceProxy {
3207
3597
  } else {
3208
3598
  this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
3209
3599
  }
3210
- } else if (provider === "codex" && typeof forkThread === "function" && sourceSessionId) {
3600
+ } else if (typeof forkThread === "function" && sourceSessionId) {
3211
3601
  if (source?.cwd && !payload.cwd) cwd = source.cwd;
3212
3602
  try {
3213
3603
  const forked = await forkThread.call(client, {
@@ -3245,7 +3635,46 @@ export class AgentWorkspaceProxy {
3245
3635
  }
3246
3636
  }
3247
3637
 
3248
- if (existingConversation && existingConversation.status !== "error" && existingConversation.agentSessionId) {
3638
+ if (
3639
+ existingConversation &&
3640
+ !existingConversation.agentSessionId &&
3641
+ provider === "codex"
3642
+ ) {
3643
+ const matched = findLatestCodexRolloutForCwd(existingConversation.cwd ?? cwd);
3644
+ if (matched) {
3645
+ existingConversation.agentSessionId = matched.sessionId;
3646
+ this.conversationByAgentSessionId.set(matched.sessionId, existingConversation.id);
3647
+ }
3648
+ }
3649
+
3650
+ if (existingConversation && existingConversation.status !== "error") {
3651
+ const owned = Boolean(
3652
+ existingConversation.agentSessionId &&
3653
+ this.ownedCodexSessionIds.has(existingConversation.agentSessionId),
3654
+ );
3655
+ // External Codex (desktop/TUI) is the same session on disk. Attach by
3656
+ // tailing that rollout — never loadSession/newSession (those mint another thread).
3657
+ if (
3658
+ provider === "codex" &&
3659
+ !owned &&
3660
+ this.attachedExternalCodex.has(existingConversation.id) &&
3661
+ !this.currentTurnIds.has(existingConversation.id)
3662
+ ) {
3663
+ existingConversation.control = "attached";
3664
+ this.followCodexRolloutDisk(existingConversation, false);
3665
+ this.activeConversationId = existingConversation.id;
3666
+ this.input.send(createEnvelope({
3667
+ type: "agent.v2.conversation.opened",
3668
+ sessionId: this.input.sessionId,
3669
+ payload: {
3670
+ conversation: existingConversation,
3671
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
3672
+ requestedConversationId: payload.conversationId,
3673
+ },
3674
+ }));
3675
+ return existingConversation;
3676
+ }
3677
+ if (existingConversation.agentSessionId) {
3249
3678
  const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
3250
3679
  if (
3251
3680
  payload.conversationId &&
@@ -3288,6 +3717,7 @@ export class AgentWorkspaceProxy {
3288
3717
  if (this.input.verbose) {
3289
3718
  process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
3290
3719
  }
3720
+ await this.hydrateConversationFromProvider(existingConversation, undefined);
3291
3721
  }
3292
3722
  }
3293
3723
  this.activeConversationId = existingConversation.id;
@@ -3301,6 +3731,7 @@ export class AgentWorkspaceProxy {
3301
3731
  },
3302
3732
  }));
3303
3733
  return existingConversation;
3734
+ }
3304
3735
  }
3305
3736
 
3306
3737
  try {
@@ -3318,6 +3749,7 @@ export class AgentWorkspaceProxy {
3318
3749
  : await client.newSession({ cwd });
3319
3750
  }
3320
3751
  agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
3752
+ if (provider === "codex") this.ownedCodexSessionIds.add(agentSessionId);
3321
3753
  const now = Date.now();
3322
3754
  const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
3323
3755
  const conversation: AgentConversation = {
@@ -3327,6 +3759,7 @@ export class AgentWorkspaceProxy {
3327
3759
  provider,
3328
3760
  cwd,
3329
3761
  title: payload.title ?? existingConversation?.title ?? titleFromCwd(cwd),
3762
+ control: "owned",
3330
3763
  model: payload.model ?? existingConversation?.model,
3331
3764
  reasoningEffort: payload.reasoningEffort ?? existingConversation?.reasoningEffort,
3332
3765
  permissionMode: payload.permissionMode ?? existingConversation?.permissionMode,
@@ -3475,11 +3908,19 @@ export class AgentWorkspaceProxy {
3475
3908
  this.conversations.get(payload.conversationId) ??
3476
3909
  await this.openConversation({ conversationId: payload.conversationId });
3477
3910
  if (!conversation) return;
3911
+ if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
3912
+ this.rejectAgentAction(
3913
+ conversation,
3914
+ `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 只旁观同一份记录,停止和插话请在那边操作。`,
3915
+ conversation.status,
3916
+ );
3917
+ return;
3918
+ }
3478
3919
  if (!conversation.agentSessionId) {
3479
3920
  this.rejectAgentAction(
3480
3921
  conversation,
3481
3922
  isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3482
- ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3923
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。请打开终端面板继续。`
3483
3924
  : "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。",
3484
3925
  );
3485
3926
  return;
@@ -3497,7 +3938,9 @@ export class AgentWorkspaceProxy {
3497
3938
  }
3498
3939
 
3499
3940
  const protocol = this.protocolForProvider(conversation.provider);
3500
- if (payload.contentBlocks.some((block) => block.type === "image") && !protocolSupportsImages(protocol)) {
3941
+ const advertised = this.providerCapabilities.get(conversation.provider)?.advertised
3942
+ ?? (client instanceof AcpClient ? client.advertised : undefined);
3943
+ if (payload.contentBlocks.some((block) => block.type === "image") && !protocolSupportsImages(protocol, advertised)) {
3501
3944
  this.rejectAgentAction(
3502
3945
  conversation,
3503
3946
  "当前 Agent provider 暂不支持图片输入,请升级 CLI 或切换到 Codex。",
@@ -4589,32 +5032,96 @@ export class AgentWorkspaceProxy {
4589
5032
 
4590
5033
  private handleSessionUpdate(params: unknown): void {
4591
5034
  const raw = asRecord(params) ?? {};
4592
- const nested = asRecord(raw.params) ?? {};
5035
+ const update = asRecord(raw.update) ?? raw;
5036
+ const conversationId = this.conversationIdFromParams(raw)
5037
+ ?? this.conversationIdFromParams(update)
5038
+ ?? this.fallbackConversationId();
5039
+ if (!conversationId) return;
5040
+ const kind = normalizedIdentifier(firstString(update, ["sessionUpdate", "session_update", "type"]));
5041
+
5042
+ if (kind === "agentthoughtchunk" || kind === "agentthought") {
5043
+ const contentBlock = asRecord(update.content);
5044
+ const thought = (typeof contentBlock?.text === "string" ? contentBlock.text : undefined)
5045
+ ?? firstString(update, ["delta", "text", "content", "message"]);
5046
+ if (!thought) return;
5047
+ this.handleReasoningDelta({
5048
+ ...raw,
5049
+ itemId: firstString(update, ["itemId", "id"]) ?? "thinking",
5050
+ delta: thought,
5051
+ });
5052
+ return;
5053
+ }
5054
+
5055
+ if (kind === "toolcall" || kind === "toolcallupdate") {
5056
+ const toolCallId = firstString(update, ["toolCallId", "id"]) ?? id("tool");
5057
+ const existing = this.findTool(conversationId, toolCallId);
5058
+ const statusRaw = firstString(update, ["status"]);
5059
+ const status: AgentToolCall["status"] =
5060
+ statusRaw === "in_progress" ? "running"
5061
+ : statusRaw === "completed" || statusRaw === "failed" || statusRaw === "pending" || statusRaw === "running"
5062
+ ? statusRaw
5063
+ : existing?.status ?? "running";
5064
+ const input = stringifyDefined(update.rawInput ?? update.input);
5065
+ const output = stringifyDefined(update.rawOutput ?? update.output);
5066
+ this.upsertTool(conversationId, {
5067
+ id: toolCallId,
5068
+ name: firstString(update, ["title", "kind", "name"]) ?? existing?.name ?? "tool",
5069
+ ...(input ? { input } : {}),
5070
+ ...(output ? { output } : {}),
5071
+ createdAt: existing?.createdAt ?? Date.now(),
5072
+ status,
5073
+ });
5074
+ return;
5075
+ }
5076
+
5077
+ if (kind === "plan") {
5078
+ const entries = Array.isArray(update.entries) ? update.entries : [];
5079
+ this.handlePlanUpdated({
5080
+ ...raw,
5081
+ plan: entries.map((entry) => {
5082
+ const step = asRecord(entry) ?? {};
5083
+ return {
5084
+ text: firstString(step, ["content", "text", "title", "description"]),
5085
+ status: step.status,
5086
+ };
5087
+ }),
5088
+ });
5089
+ return;
5090
+ }
5091
+
5092
+ const nested = asRecord(raw.params) ?? asRecord(update.params) ?? {};
5093
+ const contentBlock = asRecord(update.content);
4593
5094
  const text =
5095
+ firstString(update, ["delta", "text", "content", "message"]) ??
4594
5096
  firstString(raw, ["delta", "text", "content", "message"]) ??
4595
- firstString(nested, ["delta", "text", "content", "message"]);
4596
- const content = contentBlocksFromItem(raw);
4597
- if (!text && content.length === 0) return;
4598
- const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
4599
- if (!conversationId) return;
4600
- if (firstString(raw, ["toolName", "tool", "name"])) {
5097
+ firstString(nested, ["delta", "text", "content", "message"]) ??
5098
+ (typeof contentBlock?.text === "string" ? contentBlock.text : undefined);
5099
+ const content = contentBlocksFromItem(update).length > 0
5100
+ ? contentBlocksFromItem(update)
5101
+ : contentBlocksFromItem(raw);
5102
+ if (firstString(raw, ["toolName", "tool", "name"]) || firstString(update, ["toolName", "tool", "name"])) {
4601
5103
  this.upsertTool(conversationId, {
4602
- id: firstString(raw, ["toolCallId", "callId", "id"]) ?? id("tool"),
4603
- name: firstString(raw, ["toolName", "tool", "name"]) ?? "tool",
4604
- input: stringify(raw.input ?? raw.toolInput ?? ""),
4605
- output: stringify(raw.output ?? raw.result ?? ""),
5104
+ id: firstString(update, ["toolCallId", "callId", "id"])
5105
+ ?? firstString(raw, ["toolCallId", "callId", "id"])
5106
+ ?? id("tool"),
5107
+ name: firstString(update, ["toolName", "tool", "name"])
5108
+ ?? firstString(raw, ["toolName", "tool", "name"])
5109
+ ?? "tool",
5110
+ input: stringify(update.input ?? update.toolInput ?? raw.input ?? raw.toolInput ?? ""),
5111
+ output: stringify(update.output ?? update.result ?? raw.output ?? raw.result ?? ""),
4606
5112
  createdAt: Date.now(),
4607
- status: raw.status === "completed" || raw.status === "failed" || raw.status === "running"
4608
- ? raw.status
5113
+ status: update.status === "completed" || raw.status === "completed" || update.status === "failed" || raw.status === "failed" || update.status === "running" || raw.status === "running"
5114
+ ? ((update.status ?? raw.status) as AgentToolCall["status"])
4609
5115
  : "running",
4610
5116
  });
4611
5117
  return;
4612
5118
  }
5119
+ if (!text && content.length === 0) return;
4613
5120
  const role = raw.role === "user" || raw.role === "system" ? raw.role : "assistant";
4614
5121
  const blocks = content.length > 0 ? content : [{ type: "text" as const, text }];
4615
5122
  const preview = textFromBlocks(blocks);
4616
5123
  this.upsertItem(conversationId, {
4617
- id: firstString(raw, ["messageId", "id"]) ?? id("msg"),
5124
+ id: firstString(update, ["messageId", "id"]) ?? firstString(raw, ["messageId", "id"]) ?? id("msg"),
4618
5125
  conversationId,
4619
5126
  type: "message",
4620
5127
  role,
@@ -4622,7 +5129,7 @@ export class AgentWorkspaceProxy {
4622
5129
  text: preview,
4623
5130
  createdAt: Date.now(),
4624
5131
  updatedAt: Date.now(),
4625
- isStreaming: raw.done === false || raw.isStreaming === true,
5132
+ isStreaming: raw.done === false || raw.isStreaming === true || kind === "agentmessagechunk",
4626
5133
  });
4627
5134
  this.updateConversationPreview(conversationId, preview || "图片附件", raw.done === true ? "idle" : "running");
4628
5135
  }