linkshell-cli 0.5.33 → 0.5.35

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 (38) 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 +33 -0
  8. package/dist/cli/src/runtime/acp/agent-workspace.js +873 -92
  9. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  10. package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +6 -0
  11. package/dist/cli/src/runtime/acp/claude-sdk-client.js +51 -29
  12. package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
  13. package/dist/cli/src/runtime/acp/provider-resolver.d.ts +3 -1
  14. package/dist/cli/src/runtime/acp/provider-resolver.js +13 -4
  15. package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
  16. package/dist/cli/src/runtime/bridge-session.d.ts +1 -0
  17. package/dist/cli/src/runtime/bridge-session.js +14 -3
  18. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  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 +887 -98
  27. package/src/runtime/acp/claude-sdk-client.ts +51 -22
  28. package/src/runtime/acp/provider-resolver.ts +18 -3
  29. package/src/runtime/bridge-session.ts +12 -3
  30. package/web/assets/{FileBrowser-B1vcZ0wW.js → FileBrowser-Dn_avjeH.js} +1 -1
  31. package/web/assets/{PortPreview-CugEjE0m.js → PortPreview-KYkYV_-p.js} +1 -1
  32. package/web/assets/{TerminalPanel-DgSFo-6s.js → TerminalPanel-BjxX8Jui.js} +1 -1
  33. package/web/assets/{UsageDashboard-DTRkZRsh.js → UsageDashboard-cYiZs8c8.js} +1 -1
  34. package/web/assets/index-6vyVf_xQ.js +35 -0
  35. package/web/assets/index-Ct5fhCTS.css +33 -0
  36. package/web/index.html +2 -2
  37. package/web/assets/index-ByEwVA14.js +0 -35
  38. package/web/assets/index-Vtyd7s5T.css +0 -33
@@ -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"]),
@@ -1664,6 +1737,9 @@ function previewFromTimelineItem(item) {
1664
1737
  export class AgentWorkspaceProxy {
1665
1738
  input;
1666
1739
  clients = new Map();
1740
+ // In-flight client starts, so concurrent ensureProviderClient calls (eager
1741
+ // initialize() racing conversation.open) share one subprocess spawn.
1742
+ clientStartPromises = new Map();
1667
1743
  agentProtocols = new Map();
1668
1744
  providerCapabilities = new Map();
1669
1745
  providerErrors = new Map();
@@ -1676,10 +1752,9 @@ export class AgentWorkspaceProxy {
1676
1752
  conversations = new Map();
1677
1753
  conversationByAgentSessionId = new Map();
1678
1754
  timelines = new Map();
1679
- // Conversations the user "deleted" (forgot). We never touch the agent's
1680
- // on-disk transcript, so syncProviderSessions would otherwise re-list and
1681
- // resurrect them. Tombstone by agentSessionId (the stable provider key) so a
1682
- // 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).
1683
1758
  deletedAgentSessionIds = new Set();
1684
1759
  // Opaque codex turns cursor per conversation, pointing at OLDER history to
1685
1760
  // page through (captured at hydration, advanced on each history.request).
@@ -1692,6 +1767,12 @@ export class AgentWorkspaceProxy {
1692
1767
  structuredInputWaiters = new Map();
1693
1768
  itemConversationIds = new Map();
1694
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;
1695
1776
  constructor(input) {
1696
1777
  this.input = input;
1697
1778
  }
@@ -1712,36 +1793,89 @@ export class AgentWorkspaceProxy {
1712
1793
  const conversation = this.conversations.get(payload.conversationId);
1713
1794
  if (!conversation)
1714
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);
1715
1808
  if (payload.title !== undefined) {
1716
1809
  const trimmed = payload.title.trim();
1717
1810
  conversation.title = trimmed === "" ? undefined : trimmed;
1718
1811
  }
1719
1812
  if (payload.archived !== undefined)
1720
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;
1721
1825
  conversation.lastActivityAt = Date.now();
1722
1826
  // Echo the updated record so every client refreshes (the web store
1723
1827
  // force-replaces by id on any event carrying `conversation`).
1724
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
+ }
1725
1866
  break;
1726
1867
  }
1727
1868
  case "agent.v2.conversation.delete": {
1728
1869
  const payload = parseTypedPayload("agent.v2.conversation.delete", envelope.payload);
1729
- const conversation = this.conversations.get(payload.conversationId);
1730
- // Forget the conversation from the workspace's tracked set. We do NOT
1731
- // delete the provider's on-disk transcript — only stop tracking it and
1732
- // tombstone its session id so syncProviderSessions won't re-add it.
1733
- if (conversation?.agentSessionId) {
1734
- this.deletedAgentSessionIds.add(conversation.agentSessionId);
1735
- this.conversationByAgentSessionId.delete(conversation.agentSessionId);
1736
- }
1737
- this.conversations.delete(payload.conversationId);
1738
- this.timelines.delete(payload.conversationId);
1739
- this.historyCursors.delete(payload.conversationId);
1740
- this.input.send(createEnvelope({
1741
- type: "agent.v2.conversation.deleted",
1742
- sessionId: this.input.sessionId,
1743
- payload: { conversationId: payload.conversationId },
1744
- }));
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);
1745
1879
  break;
1746
1880
  }
1747
1881
  case "agent.v2.conversation.list": {
@@ -1814,6 +1948,10 @@ export class AgentWorkspaceProxy {
1814
1948
  }
1815
1949
  }
1816
1950
  stop() {
1951
+ if (this.processPollTimer) {
1952
+ clearInterval(this.processPollTimer);
1953
+ this.processPollTimer = undefined;
1954
+ }
1817
1955
  for (const watcher of this.fsWatchers) {
1818
1956
  try {
1819
1957
  watcher.close();
@@ -1844,7 +1982,9 @@ export class AgentWorkspaceProxy {
1844
1982
  fsSyncTimer;
1845
1983
  fsSyncRunning = false;
1846
1984
  lastPushedConversationSignature;
1985
+ lastHydrateAt = new Map();
1847
1986
  static FS_SYNC_THROTTLE_MS = 1500;
1987
+ static EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
1848
1988
  startSessionWatchers() {
1849
1989
  const roots = [
1850
1990
  join(homedir(), ".claude", "projects"),
@@ -1898,17 +2038,40 @@ export class AgentWorkspaceProxy {
1898
2038
  // and client re-renders. A cheap signature over the render-relevant fields
1899
2039
  // gates the push.
1900
2040
  const signature = this.conversationListSignature(conversations);
1901
- if (signature === this.lastPushedConversationSignature)
1902
- return;
1903
- this.lastPushedConversationSignature = signature;
1904
- // Push the conversation list (statuses) — lighter than a full snapshot and
1905
- // exactly what refreshes the tree. The web handles an unsolicited
1906
- // conversation.list.result the same as a requested one.
1907
- this.input.send(createEnvelope({
1908
- type: "agent.v2.conversation.list.result",
1909
- sessionId: this.input.sessionId,
1910
- payload: { conversations },
1911
- }));
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
+ }
1912
2075
  }
1913
2076
  catch (error) {
1914
2077
  if (this.input.verbose) {
@@ -1926,7 +2089,7 @@ export class AgentWorkspaceProxy {
1926
2089
  * membership are what matter. */
1927
2090
  conversationListSignature(conversations) {
1928
2091
  return conversations
1929
- .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 ?? ""}`)
1930
2093
  .sort()
1931
2094
  .join("");
1932
2095
  }
@@ -1978,11 +2141,183 @@ export class AgentWorkspaceProxy {
1978
2141
  // Start watching the on-disk session stores so externally-started sessions
1979
2142
  // and status changes surface live, not just at connect time.
1980
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
+ }
1981
2298
  }
1982
2299
  async ensureProviderClient(provider) {
1983
2300
  const existing = this.clients.get(provider);
1984
2301
  if (existing)
1985
2302
  return existing;
2303
+ // Dedupe concurrent starts: eager initialize() and conversation.open can
2304
+ // race here before this.clients is populated (it's only set AFTER the
2305
+ // client finishes initializing), which would spawn the provider subprocess
2306
+ // twice. Memoize the in-flight promise so both callers share one spawn;
2307
+ // drop it once settled so a failed start can be retried.
2308
+ const inflight = this.clientStartPromises.get(provider);
2309
+ if (inflight)
2310
+ return inflight;
2311
+ const start = this.startProviderClient(provider);
2312
+ this.clientStartPromises.set(provider, start);
2313
+ try {
2314
+ return await start;
2315
+ }
2316
+ finally {
2317
+ this.clientStartPromises.delete(provider);
2318
+ }
2319
+ }
2320
+ async startProviderClient(provider) {
1986
2321
  const resolved = resolveAgentCommand({
1987
2322
  provider,
1988
2323
  command: this.input.command,
@@ -2033,7 +2368,7 @@ export class AgentWorkspaceProxy {
2033
2368
  const client = await tryCreateClient(resolved);
2034
2369
  this.clients.set(provider, client);
2035
2370
  this.providerErrors.delete(provider);
2036
- await this.refreshProviderCapabilities(provider, client, resolved.protocol);
2371
+ this.refreshProviderCapabilitiesInBackground(provider, client, resolved.protocol);
2037
2372
  this.status = "idle";
2038
2373
  this.error = undefined;
2039
2374
  this.sendCapabilities();
@@ -2054,7 +2389,7 @@ export class AgentWorkspaceProxy {
2054
2389
  const client = await tryCreateClient(fallback);
2055
2390
  this.clients.set(provider, client);
2056
2391
  this.providerErrors.delete(provider);
2057
- await this.refreshProviderCapabilities(provider, client, fallback.protocol);
2392
+ this.refreshProviderCapabilitiesInBackground(provider, client, fallback.protocol);
2058
2393
  this.status = "idle";
2059
2394
  this.error = undefined;
2060
2395
  this.sendCapabilities();
@@ -2074,6 +2409,22 @@ export class AgentWorkspaceProxy {
2074
2409
  return undefined;
2075
2410
  }
2076
2411
  }
2412
+ /** Kick off runtime capability discovery WITHOUT blocking the caller.
2413
+ * refreshProviderCapabilities spawns a warm provider subprocess (measured
2414
+ * 858ms warm, multi-second cold for Claude), and nothing on the open/prompt
2415
+ * path needs it — sendCapabilities and prompt-time model resolution both
2416
+ * degrade gracefully to static fallbacks until providerCapabilities is
2417
+ * populated. When discovery lands we push a fresh agent.v2.capabilities so
2418
+ * clients pick up the real model/command list. */
2419
+ refreshProviderCapabilitiesInBackground(provider, client, protocol) {
2420
+ void this.refreshProviderCapabilities(provider, client, protocol)
2421
+ .then(() => this.sendCapabilities())
2422
+ .catch((error) => {
2423
+ if (this.input.verbose) {
2424
+ process.stderr.write(`[agent:v2] capability refresh failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
2425
+ }
2426
+ });
2427
+ }
2077
2428
  async refreshProviderCapabilities(provider, client, protocol) {
2078
2429
  if (client instanceof AcpClient && protocol !== "codex-app-server")
2079
2430
  return;
@@ -2090,9 +2441,11 @@ export class AgentWorkspaceProxy {
2090
2441
  }
2091
2442
  }
2092
2443
  }
2444
+ const listedMcp = await this.listProviderMcpServers(client);
2093
2445
  if (provider === "codex") {
2094
2446
  // Codex `model/list` is unreliable across versions — make sure mobile always
2095
2447
  // sees a usable model picker by merging in a static fallback list.
2448
+ const skillCommands = await this.loadProviderSkillCommands(provider, client);
2096
2449
  const merged = {
2097
2450
  models: runtimeCapabilities?.models?.length
2098
2451
  ? runtimeCapabilities.models
@@ -2101,13 +2454,15 @@ export class AgentWorkspaceProxy {
2101
2454
  reasoningEfforts: runtimeCapabilities?.reasoningEfforts?.length
2102
2455
  ? runtimeCapabilities.reasoningEfforts
2103
2456
  : [...ALL_REASONING_EFFORTS],
2104
- commands: runtimeCapabilities?.commands,
2457
+ commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
2105
2458
  modes: runtimeCapabilities?.modes,
2106
2459
  currentMode: runtimeCapabilities?.currentMode,
2107
2460
  // Preserve any MCP status already accumulated from startupStatus events.
2108
2461
  mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
2109
2462
  };
2110
2463
  this.providerCapabilities.set(provider, merged);
2464
+ if (listedMcp.length > 0)
2465
+ this.mergeMcpServers(provider, listedMcp);
2111
2466
  return;
2112
2467
  }
2113
2468
  if (runtimeCapabilities) {
@@ -2120,8 +2475,46 @@ export class AgentWorkspaceProxy {
2120
2475
  map.set(name, desc);
2121
2476
  runtimeCapabilities = { ...runtimeCapabilities, mcpServers: map };
2122
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
+ }
2123
2485
  this.providerCapabilities.set(provider, runtimeCapabilities);
2124
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
+ }
2125
2518
  }
2126
2519
  async syncProviderSessions() {
2127
2520
  await this.initialize();
@@ -2180,9 +2573,11 @@ export class AgentWorkspaceProxy {
2180
2573
  ? existing?.status ?? "running"
2181
2574
  : remoteRunning
2182
2575
  ? "running"
2183
- : existing?.status === "error" || existing?.status === "waiting_permission"
2184
- ? existing.status
2185
- : "idle";
2576
+ : remote.status === "error"
2577
+ ? "error"
2578
+ : existing?.status === "error" || existing?.status === "waiting_permission"
2579
+ ? existing.status
2580
+ : "idle";
2186
2581
  const conversation = {
2187
2582
  id: conversationId,
2188
2583
  agentSessionId,
@@ -2212,7 +2607,7 @@ export class AgentWorkspaceProxy {
2212
2607
  }
2213
2608
  }
2214
2609
  }
2215
- async hydrateConversationFromProvider(conversation, seedResult) {
2610
+ async hydrateConversationFromProvider(conversation, seedResult, options) {
2216
2611
  let client = this.clientForProvider(conversation.provider);
2217
2612
  if (!client || !conversation.agentSessionId)
2218
2613
  return;
@@ -2282,8 +2677,9 @@ export class AgentWorkspaceProxy {
2282
2677
  }
2283
2678
  const thread = threadFromProviderResult(source);
2284
2679
  const activeTurnId = activeTurnIdFromThread(thread);
2285
- if (activeTurnId)
2680
+ if (activeTurnId && options?.rememberActiveTurn !== false) {
2286
2681
  this.rememberTurnConversationId(conversation.id, activeTurnId);
2682
+ }
2287
2683
  const model = firstString(thread, ["model", "modelId", "currentModel"]);
2288
2684
  if (model && !conversation.model)
2289
2685
  conversation.model = model;
@@ -2356,7 +2752,11 @@ export class AgentWorkspaceProxy {
2356
2752
  }
2357
2753
  }
2358
2754
  sendCapabilities() {
2359
- 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) => {
2360
2760
  const client = this.clients.get(provider);
2361
2761
  const protocol = this.agentProtocols.get(provider);
2362
2762
  const runtimeCapabilities = this.providerCapabilities.get(provider);
@@ -2365,13 +2765,18 @@ export class AgentWorkspaceProxy {
2365
2765
  const isClaudeFallback = protocol === "claude-stream-json";
2366
2766
  const supportsPermission = enabled && !isClaudeFallback;
2367
2767
  const supportsReasoningEffort = enabled && !isClaudeFallback;
2768
+ const protocolBacked = isProtocolAgentProvider(provider);
2368
2769
  const commands = mergeCommands(defaultProviderCommands(provider, this.input.cwd, enabled), runtimeCapabilities?.commands);
2369
2770
  const currentMode = [...this.conversations.values()].find((conversation) => conversation.provider === provider)?.collaborationMode;
2370
2771
  return {
2371
2772
  id: provider,
2372
2773
  label: providerLabel(provider),
2373
2774
  enabled,
2374
- 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)} 已在本机检测;远程对话请在终端继续`,
2375
2780
  supportsImages,
2376
2781
  supportsPermission,
2377
2782
  supportsPlan: enabled,
@@ -2408,11 +2813,11 @@ export class AgentWorkspaceProxy {
2408
2813
  sessionId: this.input.sessionId,
2409
2814
  payload: {
2410
2815
  enabled: anyEnabled,
2411
- provider: this.input.availableProviders[0] ?? "codex",
2816
+ provider: this.input.availableProviders[0] ?? catalog[0] ?? "codex",
2412
2817
  providers,
2413
2818
  protocolVersion: 1,
2414
2819
  workspaceProtocolVersion: 2,
2415
- error: anyEnabled ? undefined : "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。",
2820
+ error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
2416
2821
  supportsSessionList: anyEnabled,
2417
2822
  supportsSessionLoad: anyEnabled,
2418
2823
  supportsImages: providers.some((p) => p.supportsImages),
@@ -2428,13 +2833,34 @@ export class AgentWorkspaceProxy {
2428
2833
  if (!provider) {
2429
2834
  return this.openFailure(payload, "没有可用的 Agent provider。");
2430
2835
  }
2431
- if (!this.input.availableProviders.includes(provider)) {
2432
- return this.openFailure(payload, `${providerLabel(provider)} 未安装或不可用。`);
2433
- }
2434
2836
  let cwd = payload.cwd ?? this.input.cwd;
2435
2837
  let agentSessionId = payload.agentSessionId;
2436
2838
  let existingConversation = (payload.conversationId ? this.conversations.get(payload.conversationId) : undefined) ??
2437
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
+ }
2438
2864
  if (!payload.cwd && existingConversation?.cwd)
2439
2865
  cwd = existingConversation.cwd;
2440
2866
  let client = await this.ensureProviderClient(provider);
@@ -2461,14 +2887,15 @@ export class AgentWorkspaceProxy {
2461
2887
  return this.openFailure(payload, message, cwd);
2462
2888
  }
2463
2889
  // Fork: seed a NEW conversation from an existing one's transcript, truncated
2464
- // at forkFromTurnId. Claude only — the SDK client exposes forkSession(); the
2465
- // stream-json fallback and Codex don't, so we notify and fall through to a
2466
- // plain new conversation rather than pretend. On success we set agentSessionId
2467
- // 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.
2468
2894
  if (payload.forkFromConversationId) {
2469
2895
  const source = this.conversations.get(payload.forkFromConversationId);
2470
2896
  const sourceSessionId = source?.agentSessionId;
2471
2897
  const forkable = client.forkSession;
2898
+ const forkThread = client.forkThread;
2472
2899
  if (provider === "claude" && typeof forkable === "function" && sourceSessionId) {
2473
2900
  if (source?.cwd && !payload.cwd)
2474
2901
  cwd = source.cwd;
@@ -2490,6 +2917,39 @@ export class AgentWorkspaceProxy {
2490
2917
  this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
2491
2918
  }
2492
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
+ }
2493
2953
  else {
2494
2954
  this.emitNotice({
2495
2955
  kind: "native_unsupported",
@@ -2690,12 +3150,16 @@ export class AgentWorkspaceProxy {
2690
3150
  if (!conversation)
2691
3151
  return;
2692
3152
  if (!conversation.agentSessionId) {
2693
- this.rejectAgentAction(conversation, "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
3153
+ this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3154
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3155
+ : "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
2694
3156
  return;
2695
3157
  }
2696
3158
  const client = this.clientForProvider(conversation.provider) ?? await this.ensureProviderClient(conversation.provider);
2697
3159
  if (!client) {
2698
- 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");
2699
3163
  return;
2700
3164
  }
2701
3165
  const protocol = this.protocolForProvider(conversation.provider);
@@ -2999,14 +3463,37 @@ export class AgentWorkspaceProxy {
2999
3463
  this.emitStatus(conversation.id, "idle", "上下文已重置,已创建新的 Codex thread。");
3000
3464
  return;
3001
3465
  }
3002
- if (command.name === "review" || command.name === "subagents") {
3003
- const prompt = command.name === "review"
3004
- ? args || "Review the current local changes."
3005
- : 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
+ }
3481
+ await this.sendPrompt({
3482
+ conversationId: conversation.id,
3483
+ clientMessageId: id(command.name),
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") {
3006
3493
  await this.sendPrompt({
3007
3494
  conversationId: conversation.id,
3008
3495
  clientMessageId: id(command.name),
3009
- contentBlocks: [{ type: "text", text: prompt }],
3496
+ contentBlocks: [{ type: "text", text: args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results." }],
3010
3497
  model: conversation.model,
3011
3498
  reasoningEffort: conversation.reasoningEffort,
3012
3499
  permissionMode: conversation.permissionMode,
@@ -3046,8 +3533,73 @@ export class AgentWorkspaceProxy {
3046
3533
  }
3047
3534
  return {};
3048
3535
  }
3049
- // Merge MCP server descriptors into a provider's runtime capabilities,
3050
- // 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
+ }
3051
3603
  mergeMcpServers(provider, servers) {
3052
3604
  if (servers.length === 0)
3053
3605
  return;
@@ -3075,7 +3627,7 @@ export class AgentWorkspaceProxy {
3075
3627
  return;
3076
3628
  this.mergeMcpServers(provider, [{
3077
3629
  name,
3078
- status: normalizeMcpStatus(raw.status ?? raw.state),
3630
+ status: mcpStatusFromRecord(raw),
3079
3631
  error: firstString(raw, ["error", "message", "reason"]),
3080
3632
  toolCount: firstNumber(raw, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(raw.tools) ? raw.tools.length : undefined),
3081
3633
  }]);
@@ -3125,9 +3677,11 @@ export class AgentWorkspaceProxy {
3125
3677
  this.handleMcpStartupStatus(method, params);
3126
3678
  return;
3127
3679
  }
3128
- if (method.startsWith("account/") ||
3129
- method === "serverRequest/resolved" ||
3130
- 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") {
3131
3685
  return;
3132
3686
  }
3133
3687
  const conversationId = this.conversationIdFromParams(params) ?? this.fallbackConversationId();
@@ -3160,6 +3714,46 @@ export class AgentWorkspaceProxy {
3160
3714
  }
3161
3715
  return;
3162
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
+ }
3163
3757
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
3164
3758
  this.handleStructuredInput(params);
3165
3759
  return;
@@ -3220,6 +3814,13 @@ export class AgentWorkspaceProxy {
3220
3814
  case "item/agentMessage/delta":
3221
3815
  this.handleAgentMessageDelta(params);
3222
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;
3223
3824
  case "turn/plan/updated":
3224
3825
  this.handlePlanUpdated(params);
3225
3826
  return;
@@ -3284,9 +3885,65 @@ export class AgentWorkspaceProxy {
3284
3885
  updatedAt: Date.now(),
3285
3886
  isStreaming: true,
3286
3887
  };
3287
- this.upsertItem(conversationId, item);
3888
+ this.applyStreamingTextDelta(conversationId, item, existing, delta);
3288
3889
  this.updateConversationPreview(conversationId, text, "running");
3289
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
+ }
3290
3947
  handlePlanUpdated(params) {
3291
3948
  const raw = asRecord(params);
3292
3949
  const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
@@ -3752,19 +4409,14 @@ export class AgentWorkspaceProxy {
3752
4409
  const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
3753
4410
  const rawToolCall = asRecord(raw.toolCall) ?? raw;
3754
4411
  // Codex app-server command/file approvals carry `command`/`reason`/`cwd`
3755
- // (not toolCall/context). Surface them so the card actually shows what's
3756
- // being approved; fall back to the Claude/MCP shape otherwise.
3757
- const codexCommand = firstString(raw, ["command"]);
3758
- 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.
3759
4414
  const permission = {
3760
4415
  requestId,
3761
- toolName: firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]) ??
3762
- (codexCommand ? "shell" : undefined),
3763
- toolInput: codexCommand
3764
- ? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
3765
- : stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
4416
+ toolName: permissionToolName(raw, rawToolCall),
4417
+ toolInput: permissionToolInput(raw, rawToolCall),
3766
4418
  context: firstString(raw, ["context", "description", "message", "title", "reason"]),
3767
- options: parsePermissionOptions(raw.options),
4419
+ options: permissionOptionsFromRequest(raw, source),
3768
4420
  };
3769
4421
  this.pendingPermissions.set(requestId, permission);
3770
4422
  if (source)
@@ -4038,6 +4690,22 @@ export class AgentWorkspaceProxy {
4038
4690
  payload: { conversationId, conversation: conversation ? { ...conversation } : undefined, item },
4039
4691
  }));
4040
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
+ }
4041
4709
  emitConversation(conversation) {
4042
4710
  this.input.send(createEnvelope({
4043
4711
  type: "agent.v2.event",
@@ -4381,6 +5049,77 @@ export class AgentWorkspaceProxy {
4381
5049
  }, 0);
4382
5050
  }
4383
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
+ }
4384
5123
  function parsePermissionOptions(value) {
4385
5124
  if (!Array.isArray(value)) {
4386
5125
  return [
@@ -4388,17 +5127,24 @@ function parsePermissionOptions(value) {
4388
5127
  { id: "deny", label: "拒绝", kind: "deny" },
4389
5128
  ];
4390
5129
  }
5130
+ const official = parseOfficialDecisions(value);
5131
+ if (official.length > 0 && value.every((entry) => officialDecisionOption(officialDecisionKey(entry) ?? ""))) {
5132
+ return official;
5133
+ }
4391
5134
  const options = value
4392
5135
  .map((entry, index) => {
4393
5136
  const raw = asRecord(entry) ?? {};
4394
- const idValue = raw.optionId ?? raw.id ?? raw.kind ?? `option-${index + 1}`;
4395
- 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);
4396
5139
  const id = String(idValue);
4397
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 };
4398
5144
  const normalized = `${id} ${label}`.toLowerCase();
4399
- const kind = normalized.includes("reject") || normalized.includes("deny")
5145
+ const kind = /reject|deny|decline|cancel/.test(normalized)
4400
5146
  ? "deny"
4401
- : normalized.includes("allow")
5147
+ : /allow|accept/.test(normalized)
4402
5148
  ? "allow"
4403
5149
  : "other";
4404
5150
  return { id, label, kind };
@@ -4411,6 +5157,35 @@ function parsePermissionOptions(value) {
4411
5157
  { id: "deny", label: "拒绝", kind: "deny" },
4412
5158
  ];
4413
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
+ }
4414
5189
  function selectPermissionOption(permission, outcome) {
4415
5190
  if (outcome === "cancelled")
4416
5191
  return "cancelled";
@@ -4481,7 +5256,13 @@ function formatStructuredInputResponseForSource(source, answers, input) {
4481
5256
  }
4482
5257
  function formatPermissionResponse(source, outcome, optionId) {
4483
5258
  if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
4484
- 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" };
4485
5266
  }
4486
5267
  if (source === "item/permissions/requestApproval") {
4487
5268
  if (outcome === "allow") {