linkshell-cli 0.5.34 → 0.5.36

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 (40) hide show
  1. package/dist/cli/src/runtime/acp/acp-client.d.ts +38 -0
  2. package/dist/cli/src/runtime/acp/acp-client.js +134 -20
  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 +48 -0
  5. package/dist/cli/src/runtime/acp/agent-process-discovery.js +267 -0
  6. package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +1 -0
  7. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +23 -0
  8. package/dist/cli/src/runtime/acp/agent-workspace.js +834 -90
  9. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  10. package/dist/cli/src/runtime/acp/provider-resolver.d.ts +3 -1
  11. package/dist/cli/src/runtime/acp/provider-resolver.js +13 -4
  12. package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
  13. package/dist/cli/src/runtime/bridge-session.d.ts +1 -0
  14. package/dist/cli/src/runtime/bridge-session.js +49 -18
  15. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  16. package/dist/cli/src/runtime/hook-command.d.ts +19 -0
  17. package/dist/cli/src/runtime/hook-command.js +31 -0
  18. package/dist/cli/src/runtime/hook-command.js.map +1 -0
  19. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  20. package/dist/shared-protocol/src/index.d.ts +1161 -939
  21. package/dist/shared-protocol/src/index.js +43 -1
  22. package/dist/shared-protocol/src/index.js.map +1 -1
  23. package/package.json +3 -3
  24. package/src/runtime/acp/acp-client.ts +153 -22
  25. package/src/runtime/acp/agent-process-discovery.ts +301 -0
  26. package/src/runtime/acp/agent-workspace.ts +844 -96
  27. package/src/runtime/acp/provider-resolver.ts +18 -3
  28. package/src/runtime/bridge-session.ts +46 -18
  29. package/src/runtime/hook-command.ts +35 -0
  30. package/web/assets/{FileBrowser-9wRS6a0E.js → FileBrowser-Coy7Szjx.js} +1 -1
  31. package/web/assets/{PortPreview-yvXMn63g.js → PortPreview-D-XovbV1.js} +1 -1
  32. package/web/assets/{TerminalPanel-xtGQI_fv.js → TerminalPanel-Cah56AtC.js} +1 -1
  33. package/web/assets/{UsageDashboard-ByQ6mehF.js → UsageDashboard-DfkjJagC.js} +1 -1
  34. package/web/assets/index-BsnDaeCt.css +33 -0
  35. package/web/assets/index-ClZzQ4ba.js +35 -0
  36. package/web/assets/{vendor-highlight-Cus7zBI4.js → vendor-highlight-NXWAAyIv.js} +1 -1
  37. package/web/assets/{vendor-react-0zU5oKmd.js → vendor-react-M7xG3qiw.js} +8 -8
  38. package/web/index.html +4 -4
  39. package/web/assets/index-Vtyd7s5T.css +0 -33
  40. package/web/assets/index-pqslvKcS.js +0 -35
@@ -7,6 +7,7 @@ import { ClaudeSdkClient } from "./claude-sdk-client.js";
7
7
  import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
8
8
  import { aggregateUsage } from "../usage-report.js";
9
9
  import { resolveAgentCommand } from "./provider-resolver.js";
10
+ import { agentProviderLabel, discoverAgentProcessesAsync, isLiveConversationId, isProtocolAgentProvider, liveConversationId, } from "./agent-process-discovery.js";
10
11
  // Normalize the many status spellings Claude/Codex emit into our enum. Unknown
11
12
  // values fall back to "pending" (known-but-not-yet-up) rather than dropping the
12
13
  // server. Kept liberal because the real event vocabulary isn't fully pinned.
@@ -24,6 +25,19 @@ function normalizeMcpStatus(raw) {
24
25
  return "disabled";
25
26
  return "pending";
26
27
  }
28
+ function mcpStatusFromRecord(rec) {
29
+ if (normalizeMcpStatus(rec.authStatus ?? rec.auth) === "needs_auth")
30
+ return "needs_auth";
31
+ return normalizeMcpStatus(rec.status ?? rec.state ?? rec.authStatus);
32
+ }
33
+ function mcpListFromProviderResult(value) {
34
+ const raw = asRecord(value);
35
+ const list = Array.isArray(value) ? value :
36
+ Array.isArray(raw?.data) ? raw.data :
37
+ Array.isArray(raw?.servers) ? raw.servers :
38
+ value;
39
+ return parseMcpServerList(list);
40
+ }
27
41
  // Parse an MCP server list from either an array of {name,status,...} objects or
28
42
  // a {name: status} record — Claude's init `mcp_servers` shape isn't fixed.
29
43
  function parseMcpServerList(value) {
@@ -37,7 +51,7 @@ function parseMcpServerList(value) {
37
51
  continue;
38
52
  out.push({
39
53
  name,
40
- status: normalizeMcpStatus(rec.status ?? rec.state),
54
+ status: mcpStatusFromRecord(rec),
41
55
  error: firstString(rec, ["error", "message", "reason"]),
42
56
  toolCount: firstNumber(rec, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(rec.tools) ? rec.tools.length : undefined),
43
57
  });
@@ -56,7 +70,7 @@ function parseMcpServerList(value) {
56
70
  const inner = asRecord(raw);
57
71
  out.push({
58
72
  name,
59
- status: normalizeMcpStatus(inner ? (inner.status ?? inner.state) : raw),
73
+ status: inner ? mcpStatusFromRecord(inner) : normalizeMcpStatus(raw),
60
74
  error: inner ? firstString(inner, ["error", "message", "reason"]) : undefined,
61
75
  toolCount: inner ? (firstNumber(inner, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(inner.tools) ? inner.tools.length : undefined)) : undefined,
62
76
  });
@@ -232,6 +246,47 @@ function normalizePlanStatus(value) {
232
246
  return "in_progress";
233
247
  return "pending";
234
248
  }
249
+ function applyThreadSettingsToConversation(conversation, value) {
250
+ const raw = asRecord(value);
251
+ const settings = asRecord(raw?.threadSettings) ?? asRecord(raw?.settings) ?? raw;
252
+ if (!settings)
253
+ return;
254
+ const model = firstString(settings, ["model", "modelId", "currentModel"]);
255
+ if (model)
256
+ conversation.model = model;
257
+ const effort = firstString(settings, ["effort", "reasoningEffort", "reasoning_effort"])
258
+ ?? firstString(asRecord(asRecord(settings.collaborationMode ?? settings.collaboration_mode)?.settings), ["reasoning_effort", "reasoningEffort"]);
259
+ if (effort)
260
+ conversation.reasoningEffort = effort;
261
+ const collab = asRecord(settings.collaborationMode ?? settings.collaboration_mode);
262
+ const mode = typeof settings.collaborationMode === "string"
263
+ ? settings.collaborationMode
264
+ : typeof settings.collaboration_mode === "string"
265
+ ? settings.collaboration_mode
266
+ : firstString(collab, ["mode"]);
267
+ if (mode === "plan")
268
+ conversation.collaborationMode = "plan";
269
+ else if (mode === "default")
270
+ conversation.collaborationMode = "default";
271
+ const permission = firstString(settings, ["permissionMode", "permission_mode"]);
272
+ if (permission === "read_only" || permission === "workspace_write" || permission === "full_access") {
273
+ conversation.permissionMode = permission;
274
+ return;
275
+ }
276
+ const sandboxValue = settings.sandboxPolicy ?? settings.sandbox_policy;
277
+ const sandbox = asRecord(sandboxValue);
278
+ const sandboxType = typeof sandboxValue === "string" ? sandboxValue : firstString(sandbox, ["type"]);
279
+ if (!sandboxType)
280
+ return;
281
+ const norm = sandboxType.replace(/-/g, "").toLowerCase();
282
+ conversation.permissionMode = norm.includes("readonly")
283
+ ? "read_only"
284
+ : norm.includes("workspace")
285
+ ? "workspace_write"
286
+ : norm.includes("danger") || norm.includes("full")
287
+ ? "full_access"
288
+ : conversation.permissionMode;
289
+ }
235
290
  function agentStatusFromThreadStatus(value) {
236
291
  const raw = asRecord(value);
237
292
  const status = typeof value === "string"
@@ -697,11 +752,7 @@ function previewText(text) {
697
752
  return text.replace(/\s+/g, " ").trim().slice(0, 160);
698
753
  }
699
754
  function providerLabel(provider) {
700
- if (provider === "codex")
701
- return "Codex";
702
- if (provider === "claude")
703
- return "Claude";
704
- return "Custom";
755
+ return agentProviderLabel(provider);
705
756
  }
706
757
  const ALL_REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
707
758
  const ALL_PERMISSION_MODES = ["read_only", "workspace_write", "full_access"];
@@ -1058,6 +1109,28 @@ function mergeCommands(...groups) {
1058
1109
  }
1059
1110
  return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
1060
1111
  }
1112
+ function parseSkillCommands(provider, value) {
1113
+ const raw = asRecord(value);
1114
+ const groups = Array.isArray(value) ? value :
1115
+ Array.isArray(raw?.data) ? raw.data :
1116
+ Array.isArray(raw?.skills) ? [raw] :
1117
+ [];
1118
+ const skills = [];
1119
+ for (const group of groups) {
1120
+ if (typeof group === "string" && group) {
1121
+ skills.push(group);
1122
+ continue;
1123
+ }
1124
+ const rec = asRecord(group);
1125
+ if (!rec)
1126
+ continue;
1127
+ if (Array.isArray(rec.skills))
1128
+ skills.push(...rec.skills);
1129
+ else if (firstString(rec, ["name", "command", "id"]))
1130
+ skills.push(group);
1131
+ }
1132
+ return runtimeCommands(provider, skills);
1133
+ }
1061
1134
  function runtimeCommands(provider, value) {
1062
1135
  const raw = asRecord(value);
1063
1136
  const commandsValue = Array.isArray(value) ? value :
@@ -1390,11 +1463,11 @@ function parseRemoteSessions(value) {
1390
1463
  const id = firstString(source, ["id", "threadId", "sessionId", "agentSessionId"]);
1391
1464
  if (!id)
1392
1465
  continue;
1393
- // Only accept a transcript-derived status the provider actually reported
1394
- // (Claude's listSessions sets "running"/"idle" from the on-disk transcript).
1395
- // Anything else is left undefined so the sync site keeps its own status.
1396
- const reportedStatus = firstString(source, ["status"]);
1397
- const status = reportedStatus === "running" || reportedStatus === "idle" ? reportedStatus : undefined;
1466
+ // Claude reports "running"/"idle" strings; Codex thread/list uses a tagged
1467
+ // object ({ type: "active"|"idle"|"notLoaded"|"systemError" }). Both map
1468
+ // through the same helper. Unknown / notLoaded stay undefined so the sync
1469
+ // site keeps its own status.
1470
+ const status = agentStatusFromThreadStatus(source.status);
1398
1471
  result.push({
1399
1472
  id,
1400
1473
  cwd: firstString(source, ["cwd", "workingDirectory", "workspacePath"]),
@@ -1679,10 +1752,9 @@ export class AgentWorkspaceProxy {
1679
1752
  conversations = new Map();
1680
1753
  conversationByAgentSessionId = new Map();
1681
1754
  timelines = new Map();
1682
- // Conversations the user "deleted" (forgot). We never touch the agent's
1683
- // on-disk transcript, so syncProviderSessions would otherwise re-list and
1684
- // resurrect them. Tombstone by agentSessionId (the stable provider key) so a
1685
- // forgotten conversation stays gone for the life of this workspace process.
1755
+ // Conversations the user forgot from the LinkShell list. Tombstone by
1756
+ // agentSessionId so syncProviderSessions cannot resurrect them. This is
1757
+ // NOT Codex thread/delete (that hard-deletes the on-disk rollout).
1686
1758
  deletedAgentSessionIds = new Set();
1687
1759
  // Opaque codex turns cursor per conversation, pointing at OLDER history to
1688
1760
  // page through (captured at hydration, advanced on each history.request).
@@ -1695,6 +1767,12 @@ export class AgentWorkspaceProxy {
1695
1767
  structuredInputWaiters = new Map();
1696
1768
  itemConversationIds = new Map();
1697
1769
  toolConversationIds = new Map();
1770
+ processPollTimer;
1771
+ processPollInFlight = false;
1772
+ processMissCounts = new Map();
1773
+ lastDiscoveredProviders = new Set();
1774
+ static PROCESS_POLL_MS = 3_000;
1775
+ static PROCESS_MISS_LIMIT = 2;
1698
1776
  constructor(input) {
1699
1777
  this.input = input;
1700
1778
  }
@@ -1715,36 +1793,89 @@ export class AgentWorkspaceProxy {
1715
1793
  const conversation = this.conversations.get(payload.conversationId);
1716
1794
  if (!conversation)
1717
1795
  break;
1796
+ const raw = asRecord(envelope.payload) ?? {};
1797
+ const model = payload.model ?? (typeof raw.model === "string" ? raw.model : undefined);
1798
+ const reasoningEffort = payload.reasoningEffort
1799
+ ?? (typeof raw.reasoningEffort === "string" ? raw.reasoningEffort : undefined);
1800
+ const permissionMode = payload.permissionMode
1801
+ ?? (raw.permissionMode === "read_only" || raw.permissionMode === "workspace_write" || raw.permissionMode === "full_access"
1802
+ ? raw.permissionMode
1803
+ : undefined);
1804
+ const collaborationMode = payload.collaborationMode
1805
+ ?? (raw.collaborationMode === "default" || raw.collaborationMode === "plan"
1806
+ ? raw.collaborationMode
1807
+ : undefined);
1718
1808
  if (payload.title !== undefined) {
1719
1809
  const trimmed = payload.title.trim();
1720
1810
  conversation.title = trimmed === "" ? undefined : trimmed;
1721
1811
  }
1722
1812
  if (payload.archived !== undefined)
1723
1813
  conversation.archived = payload.archived;
1814
+ if (model !== undefined) {
1815
+ const trimmed = model.trim();
1816
+ if (trimmed)
1817
+ conversation.model = trimmed;
1818
+ }
1819
+ if (reasoningEffort !== undefined)
1820
+ conversation.reasoningEffort = reasoningEffort;
1821
+ if (permissionMode !== undefined)
1822
+ conversation.permissionMode = permissionMode;
1823
+ if (collaborationMode !== undefined)
1824
+ conversation.collaborationMode = collaborationMode;
1724
1825
  conversation.lastActivityAt = Date.now();
1725
1826
  // Echo the updated record so every client refreshes (the web store
1726
1827
  // force-replaces by id on any event carrying `conversation`).
1727
1828
  this.emitConversation(conversation);
1829
+ if (conversation.provider === "codex" && conversation.agentSessionId) {
1830
+ const client = this.clientForProvider(conversation.provider);
1831
+ const settingsChanged = model !== undefined ||
1832
+ reasoningEffort !== undefined ||
1833
+ permissionMode !== undefined ||
1834
+ collaborationMode !== undefined;
1835
+ try {
1836
+ const acp = client;
1837
+ if (payload.title !== undefined && typeof acp?.setThreadName === "function") {
1838
+ await acp.setThreadName({
1839
+ sessionId: conversation.agentSessionId,
1840
+ name: conversation.title ?? "",
1841
+ });
1842
+ }
1843
+ if (payload.archived === true && typeof acp?.archiveThread === "function") {
1844
+ await acp.archiveThread({ sessionId: conversation.agentSessionId });
1845
+ }
1846
+ else if (payload.archived === false && typeof acp?.unarchiveThread === "function") {
1847
+ await acp.unarchiveThread({ sessionId: conversation.agentSessionId });
1848
+ }
1849
+ if (settingsChanged && typeof acp?.updateThreadSettings === "function") {
1850
+ await acp.updateThreadSettings({
1851
+ sessionId: conversation.agentSessionId,
1852
+ model,
1853
+ reasoningEffort,
1854
+ permissionMode,
1855
+ collaborationMode,
1856
+ cwd: conversation.cwd,
1857
+ });
1858
+ }
1859
+ }
1860
+ catch (error) {
1861
+ if (this.input.verbose) {
1862
+ process.stderr.write(`[agent:v2] conversation update provider call failed: ${error instanceof Error ? error.message : String(error)}\n`);
1863
+ }
1864
+ }
1865
+ }
1728
1866
  break;
1729
1867
  }
1730
1868
  case "agent.v2.conversation.delete": {
1731
1869
  const payload = parseTypedPayload("agent.v2.conversation.delete", envelope.payload);
1732
- const conversation = this.conversations.get(payload.conversationId);
1733
- // Forget the conversation from the workspace's tracked set. We do NOT
1734
- // delete the provider's on-disk transcript — only stop tracking it and
1735
- // tombstone its session id so syncProviderSessions won't re-add it.
1736
- if (conversation?.agentSessionId) {
1737
- this.deletedAgentSessionIds.add(conversation.agentSessionId);
1738
- this.conversationByAgentSessionId.delete(conversation.agentSessionId);
1739
- }
1740
- this.conversations.delete(payload.conversationId);
1741
- this.timelines.delete(payload.conversationId);
1742
- this.historyCursors.delete(payload.conversationId);
1743
- this.input.send(createEnvelope({
1744
- type: "agent.v2.conversation.deleted",
1745
- sessionId: this.input.sessionId,
1746
- payload: { conversationId: payload.conversationId },
1747
- }));
1870
+ // Forget from LinkShell only. Never call Codex thread/delete here —
1871
+ // that hard-deletes the on-disk rollout. Archive is the destructive-ish
1872
+ // user action; this menu item is "hide from the tree".
1873
+ this.forgetConversationLocally(payload.conversationId);
1874
+ break;
1875
+ }
1876
+ case "agent.v2.mcp.login": {
1877
+ const payload = parseTypedPayload("agent.v2.mcp.login", envelope.payload);
1878
+ await this.startMcpLogin(payload);
1748
1879
  break;
1749
1880
  }
1750
1881
  case "agent.v2.conversation.list": {
@@ -1817,6 +1948,10 @@ export class AgentWorkspaceProxy {
1817
1948
  }
1818
1949
  }
1819
1950
  stop() {
1951
+ if (this.processPollTimer) {
1952
+ clearInterval(this.processPollTimer);
1953
+ this.processPollTimer = undefined;
1954
+ }
1820
1955
  for (const watcher of this.fsWatchers) {
1821
1956
  try {
1822
1957
  watcher.close();
@@ -1847,7 +1982,9 @@ export class AgentWorkspaceProxy {
1847
1982
  fsSyncTimer;
1848
1983
  fsSyncRunning = false;
1849
1984
  lastPushedConversationSignature;
1985
+ lastHydrateAt = new Map();
1850
1986
  static FS_SYNC_THROTTLE_MS = 1500;
1987
+ static EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
1851
1988
  startSessionWatchers() {
1852
1989
  const roots = [
1853
1990
  join(homedir(), ".claude", "projects"),
@@ -1901,17 +2038,40 @@ export class AgentWorkspaceProxy {
1901
2038
  // and client re-renders. A cheap signature over the render-relevant fields
1902
2039
  // gates the push.
1903
2040
  const signature = this.conversationListSignature(conversations);
1904
- if (signature === this.lastPushedConversationSignature)
1905
- return;
1906
- this.lastPushedConversationSignature = signature;
1907
- // Push the conversation list (statuses) — lighter than a full snapshot and
1908
- // exactly what refreshes the tree. The web handles an unsolicited
1909
- // conversation.list.result the same as a requested one.
1910
- this.input.send(createEnvelope({
1911
- type: "agent.v2.conversation.list.result",
1912
- sessionId: this.input.sessionId,
1913
- payload: { conversations },
1914
- }));
2041
+ if (signature !== this.lastPushedConversationSignature) {
2042
+ this.lastPushedConversationSignature = signature;
2043
+ // Push the conversation list (statuses) — lighter than a full snapshot and
2044
+ // exactly what refreshes the tree. The web handles an unsolicited
2045
+ // conversation.list.result the same as a requested one.
2046
+ this.input.send(createEnvelope({
2047
+ type: "agent.v2.conversation.list.result",
2048
+ sessionId: this.input.sessionId,
2049
+ payload: { conversations },
2050
+ }));
2051
+ }
2052
+ // External sessions (status running / waiting_permission but LinkShell is
2053
+ // not driving the turn) have no live event stream on this app-server.
2054
+ // Re-hydrate on a short throttle so the Web timeline still moves.
2055
+ const now = Date.now();
2056
+ for (const conversation of conversations) {
2057
+ if (conversation.status !== "running" && conversation.status !== "waiting_permission")
2058
+ continue;
2059
+ if (this.currentTurnIds.has(conversation.id))
2060
+ continue;
2061
+ const last = this.lastHydrateAt.get(conversation.id) ?? 0;
2062
+ if (now - last < AgentWorkspaceProxy.EXTERNAL_HYDRATE_THROTTLE_MS)
2063
+ continue;
2064
+ this.lastHydrateAt.set(conversation.id, now);
2065
+ try {
2066
+ await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
2067
+ this.sendSnapshot(conversation.id);
2068
+ }
2069
+ catch (error) {
2070
+ if (this.input.verbose) {
2071
+ process.stderr.write(`[agent:v2] external hydrate failed for ${conversation.id}: ${error instanceof Error ? error.message : String(error)}\n`);
2072
+ }
2073
+ }
2074
+ }
1915
2075
  }
1916
2076
  catch (error) {
1917
2077
  if (this.input.verbose) {
@@ -1929,7 +2089,7 @@ export class AgentWorkspaceProxy {
1929
2089
  * membership are what matter. */
1930
2090
  conversationListSignature(conversations) {
1931
2091
  return conversations
1932
- .map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}`)
2092
+ .map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}${c.lastMessagePreview ?? ""}`)
1933
2093
  .sort()
1934
2094
  .join("");
1935
2095
  }
@@ -1981,6 +2141,160 @@ export class AgentWorkspaceProxy {
1981
2141
  // Start watching the on-disk session stores so externally-started sessions
1982
2142
  // and status changes surface live, not just at connect time.
1983
2143
  this.startSessionWatchers();
2144
+ this.startProcessDiscovery();
2145
+ }
2146
+ startProcessDiscovery() {
2147
+ void this.pollDiscoveredProcesses();
2148
+ if (this.processPollTimer)
2149
+ return;
2150
+ this.processPollTimer = setInterval(() => {
2151
+ void this.pollDiscoveredProcesses();
2152
+ }, AgentWorkspaceProxy.PROCESS_POLL_MS);
2153
+ this.processPollTimer.unref?.();
2154
+ }
2155
+ async pollDiscoveredProcesses() {
2156
+ if (this.processPollInFlight)
2157
+ return;
2158
+ this.processPollInFlight = true;
2159
+ try {
2160
+ const snapshots = this.input.discoverProcesses
2161
+ ? this.input.discoverProcesses()
2162
+ : await discoverAgentProcessesAsync();
2163
+ this.applyDiscoveredProcesses(snapshots);
2164
+ }
2165
+ catch {
2166
+ // A failed ps scan must not tear down the workspace or hitch the PTY.
2167
+ }
2168
+ finally {
2169
+ this.processPollInFlight = false;
2170
+ }
2171
+ }
2172
+ /** Open Island rule: a visible agent session is a live process. Two missed
2173
+ * polls (~6s) drop the running signal so a killed CLI does not linger. */
2174
+ reconcileDiscoveredProcesses() {
2175
+ let snapshots = [];
2176
+ try {
2177
+ snapshots = this.input.discoverProcesses?.() ?? [];
2178
+ }
2179
+ catch {
2180
+ snapshots = [];
2181
+ }
2182
+ this.applyDiscoveredProcesses(snapshots);
2183
+ }
2184
+ applyDiscoveredProcesses(snapshots) {
2185
+ const seenKeys = new Set();
2186
+ const discoveredProviders = new Set();
2187
+ let listChanged = false;
2188
+ for (const snapshot of snapshots) {
2189
+ discoveredProviders.add(snapshot.provider);
2190
+ const key = snapshot.sessionId
2191
+ ? `${snapshot.provider}:${snapshot.sessionId}`
2192
+ : `${snapshot.provider}:${snapshot.pid}`;
2193
+ seenKeys.add(key);
2194
+ this.processMissCounts.set(key, 0);
2195
+ const existingId = snapshot.sessionId
2196
+ ? this.conversationByAgentSessionId.get(snapshot.sessionId)
2197
+ : undefined;
2198
+ const liveId = liveConversationId(snapshot);
2199
+ const conversationId = existingId ?? liveId;
2200
+ const existing = this.conversations.get(conversationId);
2201
+ const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
2202
+ const driving = this.currentTurnIds.has(conversationId);
2203
+ const status = driving
2204
+ ? existing?.status ?? "running"
2205
+ : existing?.status === "waiting_permission"
2206
+ ? "waiting_permission"
2207
+ : "running";
2208
+ if (existing) {
2209
+ const changed = existing.status !== status || existing.cwd !== cwd || !existing.title;
2210
+ existing.status = status;
2211
+ existing.cwd = cwd;
2212
+ if (!existing.title)
2213
+ existing.title = agentProviderLabel(snapshot.provider);
2214
+ if (snapshot.sessionId && !existing.agentSessionId) {
2215
+ existing.agentSessionId = snapshot.sessionId;
2216
+ this.conversationByAgentSessionId.set(snapshot.sessionId, existing.id);
2217
+ }
2218
+ if (changed) {
2219
+ listChanged = true;
2220
+ this.emitConversation(existing);
2221
+ }
2222
+ continue;
2223
+ }
2224
+ const now = Date.now();
2225
+ const conversation = {
2226
+ id: conversationId,
2227
+ agentSessionId: snapshot.sessionId,
2228
+ provider: snapshot.provider,
2229
+ cwd,
2230
+ title: agentProviderLabel(snapshot.provider),
2231
+ status,
2232
+ archived: false,
2233
+ lastMessagePreview: "本机终端中的 Agent 进程",
2234
+ lastActivityAt: now,
2235
+ createdAt: now,
2236
+ };
2237
+ this.conversations.set(conversation.id, conversation);
2238
+ if (snapshot.sessionId)
2239
+ this.conversationByAgentSessionId.set(snapshot.sessionId, conversation.id);
2240
+ this.emitConversation(conversation);
2241
+ listChanged = true;
2242
+ }
2243
+ for (const [key, misses] of this.processMissCounts) {
2244
+ if (seenKeys.has(key))
2245
+ continue;
2246
+ const next = misses + 1;
2247
+ this.processMissCounts.set(key, next);
2248
+ if (next < AgentWorkspaceProxy.PROCESS_MISS_LIMIT)
2249
+ continue;
2250
+ this.processMissCounts.delete(key);
2251
+ const [provider, idPart] = key.split(":");
2252
+ if (!provider || !idPart)
2253
+ continue;
2254
+ const conversation = [...this.conversations.values()].find((item) => {
2255
+ if (item.provider !== provider)
2256
+ return false;
2257
+ if (item.agentSessionId === idPart)
2258
+ return true;
2259
+ return item.id === `agent-live-${provider}-${idPart}`;
2260
+ });
2261
+ if (!conversation)
2262
+ continue;
2263
+ if (this.currentTurnIds.has(conversation.id))
2264
+ continue;
2265
+ if (conversation.status === "waiting_permission")
2266
+ continue;
2267
+ if (conversation.status === "running") {
2268
+ conversation.status = "idle";
2269
+ conversation.lastActivityAt = Date.now();
2270
+ this.emitConversation(conversation);
2271
+ listChanged = true;
2272
+ }
2273
+ if (isLiveConversationId(conversation.id) && !conversation.agentSessionId) {
2274
+ this.conversations.delete(conversation.id);
2275
+ this.timelines.delete(conversation.id);
2276
+ this.input.send(createEnvelope({
2277
+ type: "agent.v2.conversation.deleted",
2278
+ sessionId: this.input.sessionId,
2279
+ payload: { conversationId: conversation.id },
2280
+ }));
2281
+ listChanged = true;
2282
+ }
2283
+ }
2284
+ const catalogChanged = discoveredProviders.size !== this.lastDiscoveredProviders.size ||
2285
+ [...discoveredProviders].some((provider) => !this.lastDiscoveredProviders.has(provider));
2286
+ this.lastDiscoveredProviders = discoveredProviders;
2287
+ if (catalogChanged)
2288
+ this.sendCapabilities();
2289
+ if (listChanged) {
2290
+ const conversations = [...this.conversations.values()].filter((item) => !item.archived);
2291
+ this.lastPushedConversationSignature = this.conversationListSignature(conversations);
2292
+ this.input.send(createEnvelope({
2293
+ type: "agent.v2.conversation.list.result",
2294
+ sessionId: this.input.sessionId,
2295
+ payload: { conversations },
2296
+ }));
2297
+ }
1984
2298
  }
1985
2299
  async ensureProviderClient(provider) {
1986
2300
  const existing = this.clients.get(provider);
@@ -2127,9 +2441,11 @@ export class AgentWorkspaceProxy {
2127
2441
  }
2128
2442
  }
2129
2443
  }
2444
+ const listedMcp = await this.listProviderMcpServers(client);
2130
2445
  if (provider === "codex") {
2131
2446
  // Codex `model/list` is unreliable across versions — make sure mobile always
2132
2447
  // sees a usable model picker by merging in a static fallback list.
2448
+ const skillCommands = await this.loadProviderSkillCommands(provider, client);
2133
2449
  const merged = {
2134
2450
  models: runtimeCapabilities?.models?.length
2135
2451
  ? runtimeCapabilities.models
@@ -2138,13 +2454,15 @@ export class AgentWorkspaceProxy {
2138
2454
  reasoningEfforts: runtimeCapabilities?.reasoningEfforts?.length
2139
2455
  ? runtimeCapabilities.reasoningEfforts
2140
2456
  : [...ALL_REASONING_EFFORTS],
2141
- commands: runtimeCapabilities?.commands,
2457
+ commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
2142
2458
  modes: runtimeCapabilities?.modes,
2143
2459
  currentMode: runtimeCapabilities?.currentMode,
2144
2460
  // Preserve any MCP status already accumulated from startupStatus events.
2145
2461
  mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
2146
2462
  };
2147
2463
  this.providerCapabilities.set(provider, merged);
2464
+ if (listedMcp.length > 0)
2465
+ this.mergeMcpServers(provider, listedMcp);
2148
2466
  return;
2149
2467
  }
2150
2468
  if (runtimeCapabilities) {
@@ -2157,8 +2475,46 @@ export class AgentWorkspaceProxy {
2157
2475
  map.set(name, desc);
2158
2476
  runtimeCapabilities = { ...runtimeCapabilities, mcpServers: map };
2159
2477
  }
2478
+ const skillCommands = await this.loadProviderSkillCommands(provider, client);
2479
+ if (skillCommands.length > 0) {
2480
+ runtimeCapabilities = {
2481
+ ...runtimeCapabilities,
2482
+ commands: mergeCommands(runtimeCapabilities.commands, skillCommands),
2483
+ };
2484
+ }
2160
2485
  this.providerCapabilities.set(provider, runtimeCapabilities);
2161
2486
  }
2487
+ if (listedMcp.length > 0)
2488
+ this.mergeMcpServers(provider, listedMcp);
2489
+ }
2490
+ async listProviderMcpServers(client) {
2491
+ const listMcpServers = client.listMcpServers;
2492
+ if (typeof listMcpServers !== "function")
2493
+ return [];
2494
+ try {
2495
+ return mcpListFromProviderResult(await listMcpServers.call(client));
2496
+ }
2497
+ catch (error) {
2498
+ if (this.input.verbose) {
2499
+ process.stderr.write(`[agent:v2] mcpServerStatus/list failed: ${error instanceof Error ? error.message : String(error)}\n`);
2500
+ }
2501
+ return [];
2502
+ }
2503
+ }
2504
+ async loadProviderSkillCommands(provider, client) {
2505
+ const listSkills = client.listSkills;
2506
+ if (typeof listSkills !== "function")
2507
+ return [];
2508
+ try {
2509
+ const result = await listSkills.call(client, { cwd: this.input.cwd });
2510
+ return parseSkillCommands(provider, result);
2511
+ }
2512
+ catch (error) {
2513
+ if (this.input.verbose) {
2514
+ process.stderr.write(`[agent:v2] skills/list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
2515
+ }
2516
+ return [];
2517
+ }
2162
2518
  }
2163
2519
  async syncProviderSessions() {
2164
2520
  await this.initialize();
@@ -2217,9 +2573,11 @@ export class AgentWorkspaceProxy {
2217
2573
  ? existing?.status ?? "running"
2218
2574
  : remoteRunning
2219
2575
  ? "running"
2220
- : existing?.status === "error" || existing?.status === "waiting_permission"
2221
- ? existing.status
2222
- : "idle";
2576
+ : remote.status === "error"
2577
+ ? "error"
2578
+ : existing?.status === "error" || existing?.status === "waiting_permission"
2579
+ ? existing.status
2580
+ : "idle";
2223
2581
  const conversation = {
2224
2582
  id: conversationId,
2225
2583
  agentSessionId,
@@ -2249,7 +2607,7 @@ export class AgentWorkspaceProxy {
2249
2607
  }
2250
2608
  }
2251
2609
  }
2252
- async hydrateConversationFromProvider(conversation, seedResult) {
2610
+ async hydrateConversationFromProvider(conversation, seedResult, options) {
2253
2611
  let client = this.clientForProvider(conversation.provider);
2254
2612
  if (!client || !conversation.agentSessionId)
2255
2613
  return;
@@ -2319,8 +2677,9 @@ export class AgentWorkspaceProxy {
2319
2677
  }
2320
2678
  const thread = threadFromProviderResult(source);
2321
2679
  const activeTurnId = activeTurnIdFromThread(thread);
2322
- if (activeTurnId)
2680
+ if (activeTurnId && options?.rememberActiveTurn !== false) {
2323
2681
  this.rememberTurnConversationId(conversation.id, activeTurnId);
2682
+ }
2324
2683
  const model = firstString(thread, ["model", "modelId", "currentModel"]);
2325
2684
  if (model && !conversation.model)
2326
2685
  conversation.model = model;
@@ -2393,7 +2752,11 @@ export class AgentWorkspaceProxy {
2393
2752
  }
2394
2753
  }
2395
2754
  sendCapabilities() {
2396
- const providers = this.input.availableProviders.map((provider) => {
2755
+ const catalog = [...new Set([
2756
+ ...this.input.availableProviders,
2757
+ ...this.lastDiscoveredProviders,
2758
+ ])];
2759
+ const providers = catalog.map((provider) => {
2397
2760
  const client = this.clients.get(provider);
2398
2761
  const protocol = this.agentProtocols.get(provider);
2399
2762
  const runtimeCapabilities = this.providerCapabilities.get(provider);
@@ -2402,13 +2765,18 @@ export class AgentWorkspaceProxy {
2402
2765
  const isClaudeFallback = protocol === "claude-stream-json";
2403
2766
  const supportsPermission = enabled && !isClaudeFallback;
2404
2767
  const supportsReasoningEffort = enabled && !isClaudeFallback;
2768
+ const protocolBacked = isProtocolAgentProvider(provider);
2405
2769
  const commands = mergeCommands(defaultProviderCommands(provider, this.input.cwd, enabled), runtimeCapabilities?.commands);
2406
2770
  const currentMode = [...this.conversations.values()].find((conversation) => conversation.provider === provider)?.collaborationMode;
2407
2771
  return {
2408
2772
  id: provider,
2409
2773
  label: providerLabel(provider),
2410
2774
  enabled,
2411
- reason: enabled ? undefined : this.providerErrors.get(provider) ?? `${providerLabel(provider)} 未安装或启动失败`,
2775
+ reason: enabled
2776
+ ? undefined
2777
+ : protocolBacked
2778
+ ? this.providerErrors.get(provider) ?? `${providerLabel(provider)} 未安装或启动失败`
2779
+ : `${providerLabel(provider)} 已在本机检测;远程对话请在终端继续`,
2412
2780
  supportsImages,
2413
2781
  supportsPermission,
2414
2782
  supportsPlan: enabled,
@@ -2445,11 +2813,11 @@ export class AgentWorkspaceProxy {
2445
2813
  sessionId: this.input.sessionId,
2446
2814
  payload: {
2447
2815
  enabled: anyEnabled,
2448
- provider: this.input.availableProviders[0] ?? "codex",
2816
+ provider: this.input.availableProviders[0] ?? catalog[0] ?? "codex",
2449
2817
  providers,
2450
2818
  protocolVersion: 1,
2451
2819
  workspaceProtocolVersion: 2,
2452
- error: anyEnabled ? undefined : "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。",
2820
+ error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
2453
2821
  supportsSessionList: anyEnabled,
2454
2822
  supportsSessionLoad: anyEnabled,
2455
2823
  supportsImages: providers.some((p) => p.supportsImages),
@@ -2465,13 +2833,34 @@ export class AgentWorkspaceProxy {
2465
2833
  if (!provider) {
2466
2834
  return this.openFailure(payload, "没有可用的 Agent provider。");
2467
2835
  }
2468
- if (!this.input.availableProviders.includes(provider)) {
2469
- return this.openFailure(payload, `${providerLabel(provider)} 未安装或不可用。`);
2470
- }
2471
2836
  let cwd = payload.cwd ?? this.input.cwd;
2472
2837
  let agentSessionId = payload.agentSessionId;
2473
2838
  let existingConversation = (payload.conversationId ? this.conversations.get(payload.conversationId) : undefined) ??
2474
2839
  (agentSessionId ? this.conversations.get(this.conversationByAgentSessionId.get(agentSessionId) ?? "") : undefined);
2840
+ if (existingConversation &&
2841
+ !this.clientForProvider(existingConversation.provider) &&
2842
+ !isProtocolAgentProvider(existingConversation.provider)) {
2843
+ this.activeConversationId = existingConversation.id;
2844
+ this.input.send(createEnvelope({
2845
+ type: "agent.v2.conversation.opened",
2846
+ sessionId: this.input.sessionId,
2847
+ payload: {
2848
+ conversation: existingConversation,
2849
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
2850
+ requestedConversationId: payload.conversationId,
2851
+ },
2852
+ }));
2853
+ return existingConversation;
2854
+ }
2855
+ if (!isProtocolAgentProvider(provider) && !resolveAgentCommand({
2856
+ provider,
2857
+ command: this.input.command,
2858
+ })) {
2859
+ return this.openFailure(payload, `${providerLabel(provider)} 还不能从远程新建对话。在本机终端启动后,会话会出现在列表里。`);
2860
+ }
2861
+ if (!this.input.availableProviders.includes(provider) && !existingConversation) {
2862
+ return this.openFailure(payload, `${providerLabel(provider)} 未安装或不可用。`);
2863
+ }
2475
2864
  if (!payload.cwd && existingConversation?.cwd)
2476
2865
  cwd = existingConversation.cwd;
2477
2866
  let client = await this.ensureProviderClient(provider);
@@ -2498,14 +2887,15 @@ export class AgentWorkspaceProxy {
2498
2887
  return this.openFailure(payload, message, cwd);
2499
2888
  }
2500
2889
  // Fork: seed a NEW conversation from an existing one's transcript, truncated
2501
- // at forkFromTurnId. Claude only — the SDK client exposes forkSession(); the
2502
- // stream-json fallback and Codex don't, so we notify and fall through to a
2503
- // plain new conversation rather than pretend. On success we set agentSessionId
2504
- // to the freshly-written transcript id and let the normal load path hydrate it.
2890
+ // at forkFromTurnId. Claude uses the SDK forkSession(); Codex uses
2891
+ // thread/fork. Other providers notify and fall through to a plain new
2892
+ // conversation. On success we set agentSessionId to the new thread id and
2893
+ // let the normal load path hydrate it.
2505
2894
  if (payload.forkFromConversationId) {
2506
2895
  const source = this.conversations.get(payload.forkFromConversationId);
2507
2896
  const sourceSessionId = source?.agentSessionId;
2508
2897
  const forkable = client.forkSession;
2898
+ const forkThread = client.forkThread;
2509
2899
  if (provider === "claude" && typeof forkable === "function" && sourceSessionId) {
2510
2900
  if (source?.cwd && !payload.cwd)
2511
2901
  cwd = source.cwd;
@@ -2527,6 +2917,39 @@ export class AgentWorkspaceProxy {
2527
2917
  this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
2528
2918
  }
2529
2919
  }
2920
+ else if (provider === "codex" && typeof forkThread === "function" && sourceSessionId) {
2921
+ if (source?.cwd && !payload.cwd)
2922
+ cwd = source.cwd;
2923
+ try {
2924
+ const forked = await forkThread.call(client, {
2925
+ sessionId: sourceSessionId,
2926
+ lastTurnId: payload.forkFromTurnId,
2927
+ });
2928
+ const forkedId = this.extractSessionId(forked);
2929
+ if (forkedId) {
2930
+ agentSessionId = forkedId;
2931
+ payload = {
2932
+ ...payload,
2933
+ model: payload.model ?? source?.model,
2934
+ reasoningEffort: payload.reasoningEffort ?? source?.reasoningEffort,
2935
+ permissionMode: payload.permissionMode ?? source?.permissionMode,
2936
+ collaborationMode: payload.collaborationMode ?? source?.collaborationMode,
2937
+ title: payload.title ?? (source?.title ? `${source.title} (分叉)` : undefined),
2938
+ };
2939
+ existingConversation = undefined;
2940
+ }
2941
+ else {
2942
+ this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
2943
+ }
2944
+ }
2945
+ catch (error) {
2946
+ this.emitNotice({
2947
+ kind: "warning",
2948
+ title: "分叉失败",
2949
+ detail: error instanceof Error ? error.message : "无法复制该会话的历史记录,已新建空会话。",
2950
+ });
2951
+ }
2952
+ }
2530
2953
  else {
2531
2954
  this.emitNotice({
2532
2955
  kind: "native_unsupported",
@@ -2727,12 +3150,16 @@ export class AgentWorkspaceProxy {
2727
3150
  if (!conversation)
2728
3151
  return;
2729
3152
  if (!conversation.agentSessionId) {
2730
- this.rejectAgentAction(conversation, "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
3153
+ this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3154
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3155
+ : "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
2731
3156
  return;
2732
3157
  }
2733
3158
  const client = this.clientForProvider(conversation.provider) ?? await this.ensureProviderClient(conversation.provider);
2734
3159
  if (!client) {
2735
- this.rejectAgentAction(conversation, this.providerErrors.get(conversation.provider) ?? `${providerLabel(conversation.provider)} 未连接,消息没有发送。`, "error");
3160
+ this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3161
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3162
+ : this.providerErrors.get(conversation.provider) ?? `${providerLabel(conversation.provider)} 未连接,消息没有发送。`, "error");
2736
3163
  return;
2737
3164
  }
2738
3165
  const protocol = this.protocolForProvider(conversation.provider);
@@ -3036,14 +3463,37 @@ export class AgentWorkspaceProxy {
3036
3463
  this.emitStatus(conversation.id, "idle", "上下文已重置,已创建新的 Codex thread。");
3037
3464
  return;
3038
3465
  }
3039
- if (command.name === "review" || command.name === "subagents") {
3040
- const prompt = command.name === "review"
3041
- ? args || "Review the current local changes."
3042
- : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
3466
+ if (command.name === "review") {
3467
+ const startReview = client?.startReview;
3468
+ if (typeof startReview === "function" && conversation.agentSessionId) {
3469
+ conversation.status = "running";
3470
+ conversation.lastActivityAt = now;
3471
+ this.emitConversation(conversation);
3472
+ const result = await startReview.call(client, {
3473
+ sessionId: conversation.agentSessionId,
3474
+ prompt: args || undefined,
3475
+ });
3476
+ const turnId = this.extractTurnId(result);
3477
+ if (turnId)
3478
+ this.rememberTurnConversationId(conversation.id, turnId);
3479
+ return;
3480
+ }
3043
3481
  await this.sendPrompt({
3044
3482
  conversationId: conversation.id,
3045
3483
  clientMessageId: id(command.name),
3046
- contentBlocks: [{ type: "text", text: prompt }],
3484
+ contentBlocks: [{ type: "text", text: args || "Review the current local changes." }],
3485
+ model: conversation.model,
3486
+ reasoningEffort: conversation.reasoningEffort,
3487
+ permissionMode: conversation.permissionMode,
3488
+ collaborationMode: conversation.collaborationMode,
3489
+ });
3490
+ return;
3491
+ }
3492
+ if (command.name === "subagents") {
3493
+ await this.sendPrompt({
3494
+ conversationId: conversation.id,
3495
+ clientMessageId: id(command.name),
3496
+ contentBlocks: [{ type: "text", text: args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results." }],
3047
3497
  model: conversation.model,
3048
3498
  reasoningEffort: conversation.reasoningEffort,
3049
3499
  permissionMode: conversation.permissionMode,
@@ -3083,8 +3533,73 @@ export class AgentWorkspaceProxy {
3083
3533
  }
3084
3534
  return {};
3085
3535
  }
3086
- // Merge MCP server descriptors into a provider's runtime capabilities,
3087
- // last-writer-wins by server name. Creates the map lazily.
3536
+ async startMcpLogin(payload) {
3537
+ const provider = payload.provider
3538
+ ?? this.input.availableProviders.find((item) => this.clients.has(item))
3539
+ ?? this.input.availableProviders[0];
3540
+ const sendResult = (input) => {
3541
+ this.input.send(createEnvelope({
3542
+ type: "agent.v2.mcp.login.result",
3543
+ sessionId: this.input.sessionId,
3544
+ payload: {
3545
+ ...(provider ? { provider } : {}),
3546
+ serverName: payload.serverName,
3547
+ authorizationUrl: input.authorizationUrl,
3548
+ error: input.error,
3549
+ },
3550
+ }));
3551
+ };
3552
+ if (!provider) {
3553
+ sendResult({ error: "没有可用的 Agent 提供方。" });
3554
+ return;
3555
+ }
3556
+ const client = this.clientForProvider(provider);
3557
+ const startMcpOAuth = client?.startMcpOAuth;
3558
+ if (typeof startMcpOAuth !== "function") {
3559
+ sendResult({ error: "请在主机上的 Claude / Codex 完成该 MCP 的授权。" });
3560
+ return;
3561
+ }
3562
+ try {
3563
+ const result = await startMcpOAuth.call(client, { serverName: payload.serverName });
3564
+ const raw = asRecord(result);
3565
+ const authorizationUrl = firstString(raw, [
3566
+ "authorizationUrl",
3567
+ "authorization_url",
3568
+ "url",
3569
+ "loginUrl",
3570
+ ]);
3571
+ if (!authorizationUrl) {
3572
+ sendResult({ error: "主机没有返回授权地址。" });
3573
+ return;
3574
+ }
3575
+ this.mergeMcpServers(provider, [{
3576
+ name: payload.serverName,
3577
+ status: "needs_auth",
3578
+ }]);
3579
+ this.sendCapabilities();
3580
+ sendResult({ authorizationUrl });
3581
+ }
3582
+ catch (error) {
3583
+ sendResult({ error: error instanceof Error ? error.message : String(error) });
3584
+ }
3585
+ }
3586
+ handleMcpOAuthCompleted(params) {
3587
+ const raw = asRecord(params) ?? {};
3588
+ const name = firstString(raw, ["name", "server", "serverName"]);
3589
+ if (!name)
3590
+ return;
3591
+ const success = raw.success !== false && !raw.error;
3592
+ const provider = this.input.availableProviders.find((item) => this.clients.has(item))
3593
+ ?? this.input.availableProviders[0];
3594
+ if (!provider)
3595
+ return;
3596
+ this.mergeMcpServers(provider, [{
3597
+ name,
3598
+ status: success ? "connected" : "needs_auth",
3599
+ error: success ? undefined : firstString(raw, ["error", "message"]),
3600
+ }]);
3601
+ this.sendCapabilities();
3602
+ }
3088
3603
  mergeMcpServers(provider, servers) {
3089
3604
  if (servers.length === 0)
3090
3605
  return;
@@ -3112,7 +3627,7 @@ export class AgentWorkspaceProxy {
3112
3627
  return;
3113
3628
  this.mergeMcpServers(provider, [{
3114
3629
  name,
3115
- status: normalizeMcpStatus(raw.status ?? raw.state),
3630
+ status: mcpStatusFromRecord(raw),
3116
3631
  error: firstString(raw, ["error", "message", "reason"]),
3117
3632
  toolCount: firstNumber(raw, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(raw.tools) ? raw.tools.length : undefined),
3118
3633
  }]);
@@ -3162,9 +3677,11 @@ export class AgentWorkspaceProxy {
3162
3677
  this.handleMcpStartupStatus(method, params);
3163
3678
  return;
3164
3679
  }
3165
- if (method.startsWith("account/") ||
3166
- method === "serverRequest/resolved" ||
3167
- method === "mcpServer/oauthLogin/completed") {
3680
+ if (method === "mcpServer/oauthLogin/completed") {
3681
+ this.handleMcpOAuthCompleted(params);
3682
+ return;
3683
+ }
3684
+ if (method.startsWith("account/") || method === "serverRequest/resolved") {
3168
3685
  return;
3169
3686
  }
3170
3687
  const conversationId = this.conversationIdFromParams(params) ?? this.fallbackConversationId();
@@ -3197,6 +3714,46 @@ export class AgentWorkspaceProxy {
3197
3714
  }
3198
3715
  return;
3199
3716
  }
3717
+ if (method === "thread/name/updated") {
3718
+ if (conversationId) {
3719
+ const raw = asRecord(params);
3720
+ const title = firstString(raw, ["name", "title"]) ?? firstString(asRecord(raw?.thread), ["name", "title"]);
3721
+ const conversation = this.conversations.get(conversationId);
3722
+ if (conversation && title !== undefined) {
3723
+ conversation.title = title;
3724
+ conversation.lastActivityAt = Date.now();
3725
+ this.emitConversation(conversation);
3726
+ }
3727
+ }
3728
+ return;
3729
+ }
3730
+ if (method === "thread/settings/updated") {
3731
+ if (conversationId) {
3732
+ const conversation = this.conversations.get(conversationId);
3733
+ if (conversation) {
3734
+ applyThreadSettingsToConversation(conversation, params);
3735
+ conversation.lastActivityAt = Date.now();
3736
+ this.emitConversation(conversation);
3737
+ }
3738
+ }
3739
+ return;
3740
+ }
3741
+ if (method === "thread/archived" || method === "thread/unarchived") {
3742
+ if (conversationId) {
3743
+ const conversation = this.conversations.get(conversationId);
3744
+ if (conversation) {
3745
+ conversation.archived = method === "thread/archived";
3746
+ conversation.lastActivityAt = Date.now();
3747
+ this.emitConversation(conversation);
3748
+ }
3749
+ }
3750
+ return;
3751
+ }
3752
+ if (method === "thread/deleted") {
3753
+ if (conversationId)
3754
+ this.forgetConversationLocally(conversationId);
3755
+ return;
3756
+ }
3200
3757
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
3201
3758
  this.handleStructuredInput(params);
3202
3759
  return;
@@ -3257,6 +3814,13 @@ export class AgentWorkspaceProxy {
3257
3814
  case "item/agentMessage/delta":
3258
3815
  this.handleAgentMessageDelta(params);
3259
3816
  return;
3817
+ case "item/reasoning/summaryTextDelta":
3818
+ case "item/reasoning/textDelta":
3819
+ this.handleReasoningDelta(params);
3820
+ return;
3821
+ case "item/reasoning/summaryPartAdded":
3822
+ this.handleReasoningDelta(params, { partBoundary: true });
3823
+ return;
3260
3824
  case "turn/plan/updated":
3261
3825
  this.handlePlanUpdated(params);
3262
3826
  return;
@@ -3321,9 +3885,65 @@ export class AgentWorkspaceProxy {
3321
3885
  updatedAt: Date.now(),
3322
3886
  isStreaming: true,
3323
3887
  };
3324
- this.upsertItem(conversationId, item);
3888
+ this.applyStreamingTextDelta(conversationId, item, existing, delta);
3325
3889
  this.updateConversationPreview(conversationId, text, "running");
3326
3890
  }
3891
+ handleReasoningDelta(params, options) {
3892
+ const raw = asRecord(params);
3893
+ if (!raw)
3894
+ return;
3895
+ const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
3896
+ if (!conversationId)
3897
+ return;
3898
+ const itemId = firstString(raw, ["itemId", "id", "messageId"]) ?? id("thinking");
3899
+ const existing = this.findItem(conversationId, itemId);
3900
+ const delta = firstString(raw, ["delta", "text", "content", "summary"])
3901
+ ?? (options?.partBoundary ? "\n" : undefined);
3902
+ if (!delta)
3903
+ return;
3904
+ const text = `${existing?.text ?? ""}${delta}`;
3905
+ const item = {
3906
+ id: itemId,
3907
+ conversationId,
3908
+ type: "status",
3909
+ kind: "thinking",
3910
+ role: "system",
3911
+ turnId: this.extractTurnId(raw) ?? this.currentTurnIds.get(conversationId),
3912
+ itemId,
3913
+ content: [{ type: "text", text }],
3914
+ text,
3915
+ createdAt: existing?.createdAt ?? Date.now(),
3916
+ updatedAt: Date.now(),
3917
+ isStreaming: true,
3918
+ };
3919
+ this.applyStreamingTextDelta(conversationId, item, existing, delta);
3920
+ }
3921
+ applyStreamingTextDelta(conversationId, item, existing, delta) {
3922
+ if (!existing) {
3923
+ this.upsertItem(conversationId, item);
3924
+ return;
3925
+ }
3926
+ this.rememberItemConversationId(conversationId, item);
3927
+ const timeline = this.timelines.get(conversationId) ?? [];
3928
+ const index = timeline.findIndex((entry) => entry.id === item.id);
3929
+ if (index >= 0)
3930
+ timeline[index] = item;
3931
+ else
3932
+ timeline.push(item);
3933
+ this.timelines.set(conversationId, timeline);
3934
+ this.input.send(createEnvelope({
3935
+ type: "agent.v2.event",
3936
+ sessionId: this.input.sessionId,
3937
+ payload: {
3938
+ conversationId,
3939
+ patch: {
3940
+ itemId: item.id,
3941
+ textDelta: delta,
3942
+ updatedAt: item.updatedAt,
3943
+ },
3944
+ },
3945
+ }));
3946
+ }
3327
3947
  handlePlanUpdated(params) {
3328
3948
  const raw = asRecord(params);
3329
3949
  const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
@@ -3789,19 +4409,14 @@ export class AgentWorkspaceProxy {
3789
4409
  const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
3790
4410
  const rawToolCall = asRecord(raw.toolCall) ?? raw;
3791
4411
  // Codex app-server command/file approvals carry `command`/`reason`/`cwd`
3792
- // (not toolCall/context). Surface them so the card actually shows what's
3793
- // being approved; fall back to the Claude/MCP shape otherwise.
3794
- const codexCommand = firstString(raw, ["command"]);
3795
- const codexCwd = firstString(raw, ["cwd"]);
4412
+ // (not toolCall/context). Keep those fields as JSON so the web card can
4413
+ // render `$ command` / a path instead of a raw dump.
3796
4414
  const permission = {
3797
4415
  requestId,
3798
- toolName: firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]) ??
3799
- (codexCommand ? "shell" : undefined),
3800
- toolInput: codexCommand
3801
- ? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
3802
- : stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
4416
+ toolName: permissionToolName(raw, rawToolCall),
4417
+ toolInput: permissionToolInput(raw, rawToolCall),
3803
4418
  context: firstString(raw, ["context", "description", "message", "title", "reason"]),
3804
- options: parsePermissionOptions(raw.options),
4419
+ options: permissionOptionsFromRequest(raw, source),
3805
4420
  };
3806
4421
  this.pendingPermissions.set(requestId, permission);
3807
4422
  if (source)
@@ -4075,6 +4690,22 @@ export class AgentWorkspaceProxy {
4075
4690
  payload: { conversationId, conversation: conversation ? { ...conversation } : undefined, item },
4076
4691
  }));
4077
4692
  }
4693
+ forgetConversationLocally(conversationId) {
4694
+ const conversation = this.conversations.get(conversationId);
4695
+ if (conversation?.agentSessionId) {
4696
+ this.deletedAgentSessionIds.add(conversation.agentSessionId);
4697
+ this.conversationByAgentSessionId.delete(conversation.agentSessionId);
4698
+ }
4699
+ this.conversations.delete(conversationId);
4700
+ this.timelines.delete(conversationId);
4701
+ this.historyCursors.delete(conversationId);
4702
+ this.lastHydrateAt.delete(conversationId);
4703
+ this.input.send(createEnvelope({
4704
+ type: "agent.v2.conversation.deleted",
4705
+ sessionId: this.input.sessionId,
4706
+ payload: { conversationId },
4707
+ }));
4708
+ }
4078
4709
  emitConversation(conversation) {
4079
4710
  this.input.send(createEnvelope({
4080
4711
  type: "agent.v2.event",
@@ -4418,6 +5049,77 @@ export class AgentWorkspaceProxy {
4418
5049
  }, 0);
4419
5050
  }
4420
5051
  }
5052
+ function permissionToolName(raw, rawToolCall) {
5053
+ const named = firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]);
5054
+ if (named)
5055
+ return named;
5056
+ if (firstString(raw, ["command"]))
5057
+ return "shell";
5058
+ if (permissionFilePath(raw))
5059
+ return "Edit";
5060
+ return undefined;
5061
+ }
5062
+ function permissionFilePath(raw) {
5063
+ const direct = firstString(raw, ["path", "filePath", "file_path"]);
5064
+ if (direct)
5065
+ return direct;
5066
+ const changes = Array.isArray(raw.changes) ? raw.changes : undefined;
5067
+ const first = changes ? asRecord(changes[0]) : undefined;
5068
+ return firstString(first, ["path", "filePath", "file_path"]);
5069
+ }
5070
+ function permissionToolInput(raw, rawToolCall) {
5071
+ const command = firstString(raw, ["command"]);
5072
+ const cwd = firstString(raw, ["cwd"]);
5073
+ const path = permissionFilePath(raw);
5074
+ if (command || path) {
5075
+ return JSON.stringify({
5076
+ ...(command ? { command } : {}),
5077
+ ...(cwd ? { cwd } : {}),
5078
+ ...(path ? { path } : {}),
5079
+ });
5080
+ }
5081
+ return stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall);
5082
+ }
5083
+ function officialDecisionKey(value) {
5084
+ if (typeof value === "string" && value.trim())
5085
+ return value.trim();
5086
+ const raw = asRecord(value);
5087
+ return firstString(raw, ["decision", "id", "kind", "type", "optionId"]);
5088
+ }
5089
+ function officialDecisionOption(key) {
5090
+ const normalized = key.replace(/[\s_-]/g, "").toLowerCase();
5091
+ if (normalized === "accept" || normalized === "allow" || normalized === "allowonce") {
5092
+ return { id: key === "accept" ? "accept" : key, label: "允许一次", kind: "allow" };
5093
+ }
5094
+ if (normalized === "acceptforsession" ||
5095
+ normalized === "allowsession" ||
5096
+ normalized === "allowforsession" ||
5097
+ normalized === "session") {
5098
+ return { id: "acceptForSession", label: "本会话允许", kind: "allow" };
5099
+ }
5100
+ if (normalized === "decline" || normalized === "deny" || normalized === "reject") {
5101
+ return { id: normalized === "decline" ? "decline" : key, label: "拒绝", kind: "deny" };
5102
+ }
5103
+ if (normalized === "cancel") {
5104
+ return { id: "cancel", label: "取消", kind: "deny" };
5105
+ }
5106
+ return undefined;
5107
+ }
5108
+ function parseOfficialDecisions(value) {
5109
+ const options = [];
5110
+ const seen = new Set();
5111
+ for (const entry of value) {
5112
+ const key = officialDecisionKey(entry);
5113
+ if (!key)
5114
+ continue;
5115
+ const mapped = officialDecisionOption(key);
5116
+ if (!mapped || seen.has(mapped.id))
5117
+ continue;
5118
+ seen.add(mapped.id);
5119
+ options.push(mapped);
5120
+ }
5121
+ return options;
5122
+ }
4421
5123
  function parsePermissionOptions(value) {
4422
5124
  if (!Array.isArray(value)) {
4423
5125
  return [
@@ -4425,17 +5127,24 @@ function parsePermissionOptions(value) {
4425
5127
  { id: "deny", label: "拒绝", kind: "deny" },
4426
5128
  ];
4427
5129
  }
5130
+ const official = parseOfficialDecisions(value);
5131
+ if (official.length > 0 && value.every((entry) => officialDecisionOption(officialDecisionKey(entry) ?? ""))) {
5132
+ return official;
5133
+ }
4428
5134
  const options = value
4429
5135
  .map((entry, index) => {
4430
5136
  const raw = asRecord(entry) ?? {};
4431
- const idValue = raw.optionId ?? raw.id ?? raw.kind ?? `option-${index + 1}`;
4432
- const labelValue = raw.name ?? raw.label ?? raw.kind ?? String(idValue);
5137
+ const idValue = raw.optionId ?? raw.id ?? raw.kind ?? raw.decision ?? `option-${index + 1}`;
5138
+ const labelValue = raw.name ?? raw.label ?? raw.kind ?? raw.decision ?? String(idValue);
4433
5139
  const id = String(idValue);
4434
5140
  const label = String(labelValue);
5141
+ const official = officialDecisionOption(id);
5142
+ if (official)
5143
+ return { ...official, id, label: official.label === "允许一次" && label !== id ? label : official.label };
4435
5144
  const normalized = `${id} ${label}`.toLowerCase();
4436
- const kind = normalized.includes("reject") || normalized.includes("deny")
5145
+ const kind = /reject|deny|decline|cancel/.test(normalized)
4437
5146
  ? "deny"
4438
- : normalized.includes("allow")
5147
+ : /allow|accept/.test(normalized)
4439
5148
  ? "allow"
4440
5149
  : "other";
4441
5150
  return { id, label, kind };
@@ -4448,6 +5157,35 @@ function parsePermissionOptions(value) {
4448
5157
  { id: "deny", label: "拒绝", kind: "deny" },
4449
5158
  ];
4450
5159
  }
5160
+ function permissionOptionsFromRequest(raw, source) {
5161
+ const decisions = raw.availableDecisions;
5162
+ if (Array.isArray(decisions) && decisions.length > 0) {
5163
+ const official = parseOfficialDecisions(decisions);
5164
+ if (official.length > 0)
5165
+ return official;
5166
+ }
5167
+ if (Array.isArray(raw.options)) {
5168
+ return parsePermissionOptions(raw.options);
5169
+ }
5170
+ if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
5171
+ return [
5172
+ { id: "accept", label: "允许一次", kind: "allow" },
5173
+ { id: "acceptForSession", label: "本会话允许", kind: "allow" },
5174
+ { id: "decline", label: "拒绝", kind: "deny" },
5175
+ ];
5176
+ }
5177
+ if (source === "item/permissions/requestApproval") {
5178
+ return [
5179
+ { id: "turn", label: "本次允许", kind: "allow" },
5180
+ { id: "session", label: "本会话允许", kind: "allow" },
5181
+ { id: "deny", label: "拒绝", kind: "deny" },
5182
+ ];
5183
+ }
5184
+ return [
5185
+ { id: "allow", label: "允许", kind: "allow" },
5186
+ { id: "deny", label: "拒绝", kind: "deny" },
5187
+ ];
5188
+ }
4451
5189
  function selectPermissionOption(permission, outcome) {
4452
5190
  if (outcome === "cancelled")
4453
5191
  return "cancelled";
@@ -4518,7 +5256,13 @@ function formatStructuredInputResponseForSource(source, answers, input) {
4518
5256
  }
4519
5257
  function formatPermissionResponse(source, outcome, optionId) {
4520
5258
  if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
4521
- return { decision: outcome === "allow" ? "accept" : outcome === "deny" ? "decline" : "cancel" };
5259
+ if (outcome === "cancelled" || optionId === "cancel")
5260
+ return { decision: "cancel" };
5261
+ if (outcome === "deny")
5262
+ return { decision: "decline" };
5263
+ if (optionId === "acceptForSession")
5264
+ return { decision: "acceptForSession" };
5265
+ return { decision: "accept" };
4522
5266
  }
4523
5267
  if (source === "item/permissions/requestApproval") {
4524
5268
  if (outcome === "allow") {