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
@@ -12,6 +12,14 @@ import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
12
12
  import { aggregateUsage } from "../usage-report.js";
13
13
  import type { AgentProtocol, AgentProvider } from "./provider-resolver.js";
14
14
  import { resolveAgentCommand } from "./provider-resolver.js";
15
+ import {
16
+ agentProviderLabel,
17
+ discoverAgentProcessesAsync,
18
+ isLiveConversationId,
19
+ isProtocolAgentProvider,
20
+ liveConversationId,
21
+ type AgentProcessSnapshot,
22
+ } from "./agent-process-discovery.js";
15
23
 
16
24
  type AgentStatus = "unavailable" | "idle" | "running" | "waiting_permission" | "error";
17
25
  type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
@@ -173,6 +181,21 @@ function normalizeMcpStatus(raw: unknown): AgentMcpServerStatus {
173
181
  return "pending";
174
182
  }
175
183
 
184
+ function mcpStatusFromRecord(rec: Record<string, unknown>): AgentMcpServerStatus {
185
+ if (normalizeMcpStatus(rec.authStatus ?? rec.auth) === "needs_auth") return "needs_auth";
186
+ return normalizeMcpStatus(rec.status ?? rec.state ?? rec.authStatus);
187
+ }
188
+
189
+ function mcpListFromProviderResult(value: unknown): AgentMcpServerDescriptor[] {
190
+ const raw = asRecord(value);
191
+ const list =
192
+ Array.isArray(value) ? value :
193
+ Array.isArray(raw?.data) ? raw.data :
194
+ Array.isArray(raw?.servers) ? raw.servers :
195
+ value;
196
+ return parseMcpServerList(list);
197
+ }
198
+
176
199
  // Parse an MCP server list from either an array of {name,status,...} objects or
177
200
  // a {name: status} record — Claude's init `mcp_servers` shape isn't fixed.
178
201
  function parseMcpServerList(value: unknown): AgentMcpServerDescriptor[] {
@@ -185,7 +208,7 @@ function parseMcpServerList(value: unknown): AgentMcpServerDescriptor[] {
185
208
  if (!name) continue;
186
209
  out.push({
187
210
  name,
188
- status: normalizeMcpStatus(rec.status ?? rec.state),
211
+ status: mcpStatusFromRecord(rec),
189
212
  error: firstString(rec, ["error", "message", "reason"]),
190
213
  toolCount: firstNumber(rec, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(rec.tools) ? rec.tools.length : undefined),
191
214
  });
@@ -202,7 +225,7 @@ function parseMcpServerList(value: unknown): AgentMcpServerDescriptor[] {
202
225
  const inner = asRecord(raw);
203
226
  out.push({
204
227
  name,
205
- status: normalizeMcpStatus(inner ? (inner.status ?? inner.state) : raw),
228
+ status: inner ? mcpStatusFromRecord(inner) : normalizeMcpStatus(raw),
206
229
  error: inner ? firstString(inner, ["error", "message", "reason"]) : undefined,
207
230
  toolCount: inner ? (firstNumber(inner, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(inner.tools) ? inner.tools.length : undefined)) : undefined,
208
231
  });
@@ -456,6 +479,42 @@ function normalizePlanStatus(value: unknown): AgentPlanStep["status"] {
456
479
  return "pending";
457
480
  }
458
481
 
482
+ function applyThreadSettingsToConversation(conversation: AgentConversation, value: unknown): void {
483
+ const raw = asRecord(value);
484
+ const settings = asRecord(raw?.threadSettings) ?? asRecord(raw?.settings) ?? raw;
485
+ if (!settings) return;
486
+ const model = firstString(settings, ["model", "modelId", "currentModel"]);
487
+ if (model) conversation.model = model;
488
+ const effort = firstString(settings, ["effort", "reasoningEffort", "reasoning_effort"])
489
+ ?? firstString(asRecord(asRecord(settings.collaborationMode ?? settings.collaboration_mode)?.settings), ["reasoning_effort", "reasoningEffort"]);
490
+ if (effort) conversation.reasoningEffort = effort;
491
+ const collab = asRecord(settings.collaborationMode ?? settings.collaboration_mode);
492
+ const mode = typeof settings.collaborationMode === "string"
493
+ ? settings.collaborationMode
494
+ : typeof settings.collaboration_mode === "string"
495
+ ? settings.collaboration_mode
496
+ : firstString(collab, ["mode"]);
497
+ if (mode === "plan") conversation.collaborationMode = "plan";
498
+ else if (mode === "default") conversation.collaborationMode = "default";
499
+ const permission = firstString(settings, ["permissionMode", "permission_mode"]);
500
+ if (permission === "read_only" || permission === "workspace_write" || permission === "full_access") {
501
+ conversation.permissionMode = permission;
502
+ return;
503
+ }
504
+ const sandboxValue = settings.sandboxPolicy ?? settings.sandbox_policy;
505
+ const sandbox = asRecord(sandboxValue);
506
+ const sandboxType = typeof sandboxValue === "string" ? sandboxValue : firstString(sandbox, ["type"]);
507
+ if (!sandboxType) return;
508
+ const norm = sandboxType.replace(/-/g, "").toLowerCase();
509
+ conversation.permissionMode = norm.includes("readonly")
510
+ ? "read_only"
511
+ : norm.includes("workspace")
512
+ ? "workspace_write"
513
+ : norm.includes("danger") || norm.includes("full")
514
+ ? "full_access"
515
+ : conversation.permissionMode;
516
+ }
517
+
459
518
  function agentStatusFromThreadStatus(value: unknown): AgentStatus | undefined {
460
519
  const raw = asRecord(value);
461
520
  const status = typeof value === "string"
@@ -923,9 +982,7 @@ function previewText(text: string): string {
923
982
  }
924
983
 
925
984
  function providerLabel(provider: AgentProvider): string {
926
- if (provider === "codex") return "Codex";
927
- if (provider === "claude") return "Claude";
928
- return "Custom";
985
+ return agentProviderLabel(provider);
929
986
  }
930
987
 
931
988
  interface AgentModelOption {
@@ -1314,6 +1371,27 @@ function mergeCommands(...groups: Array<AgentCommandDescriptor[] | undefined>):
1314
1371
  return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
1315
1372
  }
1316
1373
 
1374
+ function parseSkillCommands(provider: AgentProvider, value: unknown): AgentCommandDescriptor[] {
1375
+ const raw = asRecord(value);
1376
+ const groups =
1377
+ Array.isArray(value) ? value :
1378
+ Array.isArray(raw?.data) ? raw.data :
1379
+ Array.isArray(raw?.skills) ? [raw] :
1380
+ [];
1381
+ const skills: unknown[] = [];
1382
+ for (const group of groups) {
1383
+ if (typeof group === "string" && group) {
1384
+ skills.push(group);
1385
+ continue;
1386
+ }
1387
+ const rec = asRecord(group);
1388
+ if (!rec) continue;
1389
+ if (Array.isArray(rec.skills)) skills.push(...rec.skills);
1390
+ else if (firstString(rec, ["name", "command", "id"])) skills.push(group);
1391
+ }
1392
+ return runtimeCommands(provider, skills);
1393
+ }
1394
+
1317
1395
  function runtimeCommands(provider: AgentProvider, value: unknown): AgentCommandDescriptor[] {
1318
1396
  const raw = asRecord(value);
1319
1397
  const commandsValue =
@@ -1628,12 +1706,11 @@ function parseRemoteSessions(value: unknown): Array<{
1628
1706
  const source = nestedThread ?? session;
1629
1707
  const id = firstString(source, ["id", "threadId", "sessionId", "agentSessionId"]);
1630
1708
  if (!id) continue;
1631
- // Only accept a transcript-derived status the provider actually reported
1632
- // (Claude's listSessions sets "running"/"idle" from the on-disk transcript).
1633
- // Anything else is left undefined so the sync site keeps its own status.
1634
- const reportedStatus = firstString(source, ["status"]);
1635
- const status: AgentStatus | undefined =
1636
- reportedStatus === "running" || reportedStatus === "idle" ? reportedStatus : undefined;
1709
+ // Claude reports "running"/"idle" strings; Codex thread/list uses a tagged
1710
+ // object ({ type: "active"|"idle"|"notLoaded"|"systemError" }). Both map
1711
+ // through the same helper. Unknown / notLoaded stay undefined so the sync
1712
+ // site keeps its own status.
1713
+ const status = agentStatusFromThreadStatus(source.status);
1637
1714
  result.push({
1638
1715
  id,
1639
1716
  cwd: firstString(source, ["cwd", "workingDirectory", "workspacePath"]),
@@ -1933,10 +2010,9 @@ export class AgentWorkspaceProxy {
1933
2010
  private conversations = new Map<string, AgentConversation>();
1934
2011
  private conversationByAgentSessionId = new Map<string, string>();
1935
2012
  private timelines = new Map<string, AgentTimelineItem[]>();
1936
- // Conversations the user "deleted" (forgot). We never touch the agent's
1937
- // on-disk transcript, so syncProviderSessions would otherwise re-list and
1938
- // resurrect them. Tombstone by agentSessionId (the stable provider key) so a
1939
- // forgotten conversation stays gone for the life of this workspace process.
2013
+ // Conversations the user forgot from the LinkShell list. Tombstone by
2014
+ // agentSessionId so syncProviderSessions cannot resurrect them. This is
2015
+ // NOT Codex thread/delete (that hard-deletes the on-disk rollout).
1940
2016
  private deletedAgentSessionIds = new Set<string>();
1941
2017
  // Opaque codex turns cursor per conversation, pointing at OLDER history to
1942
2018
  // page through (captured at hydration, advanced on each history.request).
@@ -1950,6 +2026,13 @@ export class AgentWorkspaceProxy {
1950
2026
  private itemConversationIds = new Map<string, string>();
1951
2027
  private toolConversationIds = new Map<string, string>();
1952
2028
 
2029
+ private processPollTimer: ReturnType<typeof setInterval> | undefined;
2030
+ private processPollInFlight = false;
2031
+ private processMissCounts = new Map<string, number>();
2032
+ private lastDiscoveredProviders = new Set<string>();
2033
+ private static readonly PROCESS_POLL_MS = 3_000;
2034
+ private static readonly PROCESS_MISS_LIMIT = 2;
2035
+
1953
2036
  constructor(
1954
2037
  private readonly input: {
1955
2038
  sessionId: string;
@@ -1958,6 +2041,7 @@ export class AgentWorkspaceProxy {
1958
2041
  command?: string;
1959
2042
  send: (envelope: Envelope) => void;
1960
2043
  verbose?: boolean;
2044
+ discoverProcesses?: () => AgentProcessSnapshot[];
1961
2045
  },
1962
2046
  ) {}
1963
2047
 
@@ -1977,35 +2061,83 @@ export class AgentWorkspaceProxy {
1977
2061
  const payload = parseTypedPayload("agent.v2.conversation.update", envelope.payload);
1978
2062
  const conversation = this.conversations.get(payload.conversationId);
1979
2063
  if (!conversation) break;
2064
+ const raw = asRecord(envelope.payload) ?? {};
2065
+ const model = payload.model ?? (typeof raw.model === "string" ? raw.model : undefined);
2066
+ const reasoningEffort = payload.reasoningEffort
2067
+ ?? (typeof raw.reasoningEffort === "string" ? raw.reasoningEffort : undefined);
2068
+ const permissionMode = payload.permissionMode
2069
+ ?? (raw.permissionMode === "read_only" || raw.permissionMode === "workspace_write" || raw.permissionMode === "full_access"
2070
+ ? raw.permissionMode
2071
+ : undefined);
2072
+ const collaborationMode = payload.collaborationMode
2073
+ ?? (raw.collaborationMode === "default" || raw.collaborationMode === "plan"
2074
+ ? raw.collaborationMode
2075
+ : undefined);
1980
2076
  if (payload.title !== undefined) {
1981
2077
  const trimmed = payload.title.trim();
1982
2078
  conversation.title = trimmed === "" ? undefined : trimmed;
1983
2079
  }
1984
2080
  if (payload.archived !== undefined) conversation.archived = payload.archived;
2081
+ if (model !== undefined) {
2082
+ const trimmed = model.trim();
2083
+ if (trimmed) conversation.model = trimmed;
2084
+ }
2085
+ if (reasoningEffort !== undefined) conversation.reasoningEffort = reasoningEffort;
2086
+ if (permissionMode !== undefined) conversation.permissionMode = permissionMode;
2087
+ if (collaborationMode !== undefined) conversation.collaborationMode = collaborationMode;
1985
2088
  conversation.lastActivityAt = Date.now();
1986
2089
  // Echo the updated record so every client refreshes (the web store
1987
2090
  // force-replaces by id on any event carrying `conversation`).
1988
2091
  this.emitConversation(conversation);
2092
+ if (conversation.provider === "codex" && conversation.agentSessionId) {
2093
+ const client = this.clientForProvider(conversation.provider);
2094
+ const settingsChanged =
2095
+ model !== undefined ||
2096
+ reasoningEffort !== undefined ||
2097
+ permissionMode !== undefined ||
2098
+ collaborationMode !== undefined;
2099
+ try {
2100
+ const acp = client as AcpClient | undefined;
2101
+ if (payload.title !== undefined && typeof acp?.setThreadName === "function") {
2102
+ await acp.setThreadName({
2103
+ sessionId: conversation.agentSessionId,
2104
+ name: conversation.title ?? "",
2105
+ });
2106
+ }
2107
+ if (payload.archived === true && typeof acp?.archiveThread === "function") {
2108
+ await acp.archiveThread({ sessionId: conversation.agentSessionId });
2109
+ } else if (payload.archived === false && typeof acp?.unarchiveThread === "function") {
2110
+ await acp.unarchiveThread({ sessionId: conversation.agentSessionId });
2111
+ }
2112
+ if (settingsChanged && typeof acp?.updateThreadSettings === "function") {
2113
+ await acp.updateThreadSettings({
2114
+ sessionId: conversation.agentSessionId,
2115
+ model,
2116
+ reasoningEffort,
2117
+ permissionMode,
2118
+ collaborationMode,
2119
+ cwd: conversation.cwd,
2120
+ });
2121
+ }
2122
+ } catch (error) {
2123
+ if (this.input.verbose) {
2124
+ process.stderr.write(`[agent:v2] conversation update provider call failed: ${error instanceof Error ? error.message : String(error)}\n`);
2125
+ }
2126
+ }
2127
+ }
1989
2128
  break;
1990
2129
  }
1991
2130
  case "agent.v2.conversation.delete": {
1992
2131
  const payload = parseTypedPayload("agent.v2.conversation.delete", envelope.payload);
1993
- const conversation = this.conversations.get(payload.conversationId);
1994
- // Forget the conversation from the workspace's tracked set. We do NOT
1995
- // delete the provider's on-disk transcript — only stop tracking it and
1996
- // tombstone its session id so syncProviderSessions won't re-add it.
1997
- if (conversation?.agentSessionId) {
1998
- this.deletedAgentSessionIds.add(conversation.agentSessionId);
1999
- this.conversationByAgentSessionId.delete(conversation.agentSessionId);
2000
- }
2001
- this.conversations.delete(payload.conversationId);
2002
- this.timelines.delete(payload.conversationId);
2003
- this.historyCursors.delete(payload.conversationId);
2004
- this.input.send(createEnvelope({
2005
- type: "agent.v2.conversation.deleted",
2006
- sessionId: this.input.sessionId,
2007
- payload: { conversationId: payload.conversationId },
2008
- }));
2132
+ // Forget from LinkShell only. Never call Codex thread/delete here —
2133
+ // that hard-deletes the on-disk rollout. Archive is the destructive-ish
2134
+ // user action; this menu item is "hide from the tree".
2135
+ this.forgetConversationLocally(payload.conversationId);
2136
+ break;
2137
+ }
2138
+ case "agent.v2.mcp.login": {
2139
+ const payload = parseTypedPayload("agent.v2.mcp.login", envelope.payload);
2140
+ await this.startMcpLogin(payload);
2009
2141
  break;
2010
2142
  }
2011
2143
  case "agent.v2.conversation.list": {
@@ -2084,6 +2216,10 @@ export class AgentWorkspaceProxy {
2084
2216
  }
2085
2217
 
2086
2218
  stop(): void {
2219
+ if (this.processPollTimer) {
2220
+ clearInterval(this.processPollTimer);
2221
+ this.processPollTimer = undefined;
2222
+ }
2087
2223
  for (const watcher of this.fsWatchers) {
2088
2224
  try { watcher.close(); } catch { /* already closed */ }
2089
2225
  }
@@ -2112,7 +2248,9 @@ export class AgentWorkspaceProxy {
2112
2248
  private fsSyncTimer: ReturnType<typeof setTimeout> | undefined;
2113
2249
  private fsSyncRunning = false;
2114
2250
  private lastPushedConversationSignature: string | undefined;
2251
+ private lastHydrateAt = new Map<string, number>();
2115
2252
  private static readonly FS_SYNC_THROTTLE_MS = 1500;
2253
+ private static readonly EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
2116
2254
 
2117
2255
  private startSessionWatchers(): void {
2118
2256
  const roots = [
@@ -2164,16 +2302,36 @@ export class AgentWorkspaceProxy {
2164
2302
  // and client re-renders. A cheap signature over the render-relevant fields
2165
2303
  // gates the push.
2166
2304
  const signature = this.conversationListSignature(conversations);
2167
- if (signature === this.lastPushedConversationSignature) return;
2168
- this.lastPushedConversationSignature = signature;
2169
- // Push the conversation list (statuses) — lighter than a full snapshot and
2170
- // exactly what refreshes the tree. The web handles an unsolicited
2171
- // conversation.list.result the same as a requested one.
2172
- this.input.send(createEnvelope({
2173
- type: "agent.v2.conversation.list.result",
2174
- sessionId: this.input.sessionId,
2175
- payload: { conversations },
2176
- }));
2305
+ if (signature !== this.lastPushedConversationSignature) {
2306
+ this.lastPushedConversationSignature = signature;
2307
+ // Push the conversation list (statuses) — lighter than a full snapshot and
2308
+ // exactly what refreshes the tree. The web handles an unsolicited
2309
+ // conversation.list.result the same as a requested one.
2310
+ this.input.send(createEnvelope({
2311
+ type: "agent.v2.conversation.list.result",
2312
+ sessionId: this.input.sessionId,
2313
+ payload: { conversations },
2314
+ }));
2315
+ }
2316
+ // External sessions (status running / waiting_permission but LinkShell is
2317
+ // not driving the turn) have no live event stream on this app-server.
2318
+ // Re-hydrate on a short throttle so the Web timeline still moves.
2319
+ const now = Date.now();
2320
+ for (const conversation of conversations) {
2321
+ if (conversation.status !== "running" && conversation.status !== "waiting_permission") continue;
2322
+ if (this.currentTurnIds.has(conversation.id)) continue;
2323
+ const last = this.lastHydrateAt.get(conversation.id) ?? 0;
2324
+ if (now - last < AgentWorkspaceProxy.EXTERNAL_HYDRATE_THROTTLE_MS) continue;
2325
+ this.lastHydrateAt.set(conversation.id, now);
2326
+ try {
2327
+ await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
2328
+ this.sendSnapshot(conversation.id);
2329
+ } catch (error) {
2330
+ if (this.input.verbose) {
2331
+ process.stderr.write(`[agent:v2] external hydrate failed for ${conversation.id}: ${error instanceof Error ? error.message : String(error)}\n`);
2332
+ }
2333
+ }
2334
+ }
2177
2335
  } catch (error) {
2178
2336
  if (this.input.verbose) {
2179
2337
  process.stderr.write(`[agent:v2] fs-watch sync failed: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -2190,7 +2348,7 @@ export class AgentWorkspaceProxy {
2190
2348
  * membership are what matter. */
2191
2349
  private conversationListSignature(conversations: AgentConversation[]): string {
2192
2350
  return conversations
2193
- .map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}`)
2351
+ .map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}${c.lastMessagePreview ?? ""}`)
2194
2352
  .sort()
2195
2353
  .join("");
2196
2354
  }
@@ -2244,6 +2402,156 @@ export class AgentWorkspaceProxy {
2244
2402
  // Start watching the on-disk session stores so externally-started sessions
2245
2403
  // and status changes surface live, not just at connect time.
2246
2404
  this.startSessionWatchers();
2405
+ this.startProcessDiscovery();
2406
+ }
2407
+
2408
+ private startProcessDiscovery(): void {
2409
+ void this.pollDiscoveredProcesses();
2410
+ if (this.processPollTimer) return;
2411
+ this.processPollTimer = setInterval(() => {
2412
+ void this.pollDiscoveredProcesses();
2413
+ }, AgentWorkspaceProxy.PROCESS_POLL_MS);
2414
+ this.processPollTimer.unref?.();
2415
+ }
2416
+
2417
+ private async pollDiscoveredProcesses(): Promise<void> {
2418
+ if (this.processPollInFlight) return;
2419
+ this.processPollInFlight = true;
2420
+ try {
2421
+ const snapshots = this.input.discoverProcesses
2422
+ ? this.input.discoverProcesses()
2423
+ : await discoverAgentProcessesAsync();
2424
+ this.applyDiscoveredProcesses(snapshots);
2425
+ } catch {
2426
+ // A failed ps scan must not tear down the workspace or hitch the PTY.
2427
+ } finally {
2428
+ this.processPollInFlight = false;
2429
+ }
2430
+ }
2431
+
2432
+ /** Open Island rule: a visible agent session is a live process. Two missed
2433
+ * polls (~6s) drop the running signal so a killed CLI does not linger. */
2434
+ reconcileDiscoveredProcesses(): void {
2435
+ let snapshots: AgentProcessSnapshot[] = [];
2436
+ try {
2437
+ snapshots = this.input.discoverProcesses?.() ?? [];
2438
+ } catch {
2439
+ snapshots = [];
2440
+ }
2441
+ this.applyDiscoveredProcesses(snapshots);
2442
+ }
2443
+
2444
+ private applyDiscoveredProcesses(snapshots: AgentProcessSnapshot[]): void {
2445
+
2446
+ const seenKeys = new Set<string>();
2447
+ const discoveredProviders = new Set<string>();
2448
+ let listChanged = false;
2449
+
2450
+ for (const snapshot of snapshots) {
2451
+ discoveredProviders.add(snapshot.provider);
2452
+ const key = snapshot.sessionId
2453
+ ? `${snapshot.provider}:${snapshot.sessionId}`
2454
+ : `${snapshot.provider}:${snapshot.pid}`;
2455
+ seenKeys.add(key);
2456
+ this.processMissCounts.set(key, 0);
2457
+
2458
+ const existingId = snapshot.sessionId
2459
+ ? this.conversationByAgentSessionId.get(snapshot.sessionId)
2460
+ : undefined;
2461
+ const liveId = liveConversationId(snapshot);
2462
+ const conversationId = existingId ?? liveId;
2463
+ const existing = this.conversations.get(conversationId);
2464
+ const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
2465
+ const driving = this.currentTurnIds.has(conversationId);
2466
+ const status: AgentStatus = driving
2467
+ ? existing?.status ?? "running"
2468
+ : existing?.status === "waiting_permission"
2469
+ ? "waiting_permission"
2470
+ : "running";
2471
+
2472
+ if (existing) {
2473
+ const changed = existing.status !== status || existing.cwd !== cwd || !existing.title;
2474
+ existing.status = status;
2475
+ existing.cwd = cwd;
2476
+ if (!existing.title) existing.title = agentProviderLabel(snapshot.provider);
2477
+ if (snapshot.sessionId && !existing.agentSessionId) {
2478
+ existing.agentSessionId = snapshot.sessionId;
2479
+ this.conversationByAgentSessionId.set(snapshot.sessionId, existing.id);
2480
+ }
2481
+ if (changed) {
2482
+ listChanged = true;
2483
+ this.emitConversation(existing);
2484
+ }
2485
+ continue;
2486
+ }
2487
+
2488
+ const now = Date.now();
2489
+ const conversation: AgentConversation = {
2490
+ id: conversationId,
2491
+ agentSessionId: snapshot.sessionId,
2492
+ provider: snapshot.provider,
2493
+ cwd,
2494
+ title: agentProviderLabel(snapshot.provider),
2495
+ status,
2496
+ archived: false,
2497
+ lastMessagePreview: "本机终端中的 Agent 进程",
2498
+ lastActivityAt: now,
2499
+ createdAt: now,
2500
+ };
2501
+ this.conversations.set(conversation.id, conversation);
2502
+ if (snapshot.sessionId) this.conversationByAgentSessionId.set(snapshot.sessionId, conversation.id);
2503
+ this.emitConversation(conversation);
2504
+ listChanged = true;
2505
+ }
2506
+
2507
+ for (const [key, misses] of this.processMissCounts) {
2508
+ if (seenKeys.has(key)) continue;
2509
+ const next = misses + 1;
2510
+ this.processMissCounts.set(key, next);
2511
+ if (next < AgentWorkspaceProxy.PROCESS_MISS_LIMIT) continue;
2512
+ this.processMissCounts.delete(key);
2513
+ const [provider, idPart] = key.split(":");
2514
+ if (!provider || !idPart) continue;
2515
+ const conversation = [...this.conversations.values()].find((item) => {
2516
+ if (item.provider !== provider) return false;
2517
+ if (item.agentSessionId === idPart) return true;
2518
+ return item.id === `agent-live-${provider}-${idPart}`;
2519
+ });
2520
+ if (!conversation) continue;
2521
+ if (this.currentTurnIds.has(conversation.id)) continue;
2522
+ if (conversation.status === "waiting_permission") continue;
2523
+ if (conversation.status === "running") {
2524
+ conversation.status = "idle";
2525
+ conversation.lastActivityAt = Date.now();
2526
+ this.emitConversation(conversation);
2527
+ listChanged = true;
2528
+ }
2529
+ if (isLiveConversationId(conversation.id) && !conversation.agentSessionId) {
2530
+ this.conversations.delete(conversation.id);
2531
+ this.timelines.delete(conversation.id);
2532
+ this.input.send(createEnvelope({
2533
+ type: "agent.v2.conversation.deleted",
2534
+ sessionId: this.input.sessionId,
2535
+ payload: { conversationId: conversation.id },
2536
+ }));
2537
+ listChanged = true;
2538
+ }
2539
+ }
2540
+
2541
+ const catalogChanged =
2542
+ discoveredProviders.size !== this.lastDiscoveredProviders.size ||
2543
+ [...discoveredProviders].some((provider) => !this.lastDiscoveredProviders.has(provider));
2544
+ this.lastDiscoveredProviders = discoveredProviders;
2545
+ if (catalogChanged) this.sendCapabilities();
2546
+ if (listChanged) {
2547
+ const conversations = [...this.conversations.values()].filter((item) => !item.archived);
2548
+ this.lastPushedConversationSignature = this.conversationListSignature(conversations);
2549
+ this.input.send(createEnvelope({
2550
+ type: "agent.v2.conversation.list.result",
2551
+ sessionId: this.input.sessionId,
2552
+ payload: { conversations },
2553
+ }));
2554
+ }
2247
2555
  }
2248
2556
 
2249
2557
  private async ensureProviderClient(provider: AgentProvider): Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined> {
@@ -2398,9 +2706,11 @@ export class AgentWorkspaceProxy {
2398
2706
  }
2399
2707
  }
2400
2708
  }
2709
+ const listedMcp = await this.listProviderMcpServers(client);
2401
2710
  if (provider === "codex") {
2402
2711
  // Codex `model/list` is unreliable across versions — make sure mobile always
2403
2712
  // sees a usable model picker by merging in a static fallback list.
2713
+ const skillCommands = await this.loadProviderSkillCommands(provider, client);
2404
2714
  const merged: ProviderRuntimeCapabilities = {
2405
2715
  models: runtimeCapabilities?.models?.length
2406
2716
  ? runtimeCapabilities.models
@@ -2409,13 +2719,14 @@ export class AgentWorkspaceProxy {
2409
2719
  reasoningEfforts: runtimeCapabilities?.reasoningEfforts?.length
2410
2720
  ? runtimeCapabilities.reasoningEfforts
2411
2721
  : [...ALL_REASONING_EFFORTS],
2412
- commands: runtimeCapabilities?.commands,
2722
+ commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
2413
2723
  modes: runtimeCapabilities?.modes,
2414
2724
  currentMode: runtimeCapabilities?.currentMode,
2415
2725
  // Preserve any MCP status already accumulated from startupStatus events.
2416
2726
  mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
2417
2727
  };
2418
2728
  this.providerCapabilities.set(provider, merged);
2729
+ if (listedMcp.length > 0) this.mergeMcpServers(provider, listedMcp);
2419
2730
  return;
2420
2731
  }
2421
2732
  if (runtimeCapabilities) {
@@ -2427,8 +2738,48 @@ export class AgentWorkspaceProxy {
2427
2738
  for (const [name, desc] of runtimeCapabilities.mcpServers ?? []) map.set(name, desc);
2428
2739
  runtimeCapabilities = { ...runtimeCapabilities, mcpServers: map };
2429
2740
  }
2741
+ const skillCommands = await this.loadProviderSkillCommands(provider, client);
2742
+ if (skillCommands.length > 0) {
2743
+ runtimeCapabilities = {
2744
+ ...runtimeCapabilities,
2745
+ commands: mergeCommands(runtimeCapabilities.commands, skillCommands),
2746
+ };
2747
+ }
2430
2748
  this.providerCapabilities.set(provider, runtimeCapabilities);
2431
2749
  }
2750
+ if (listedMcp.length > 0) this.mergeMcpServers(provider, listedMcp);
2751
+ }
2752
+
2753
+ private async listProviderMcpServers(
2754
+ client: AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient,
2755
+ ): Promise<AgentMcpServerDescriptor[]> {
2756
+ const listMcpServers = (client as { listMcpServers?: () => Promise<unknown> }).listMcpServers;
2757
+ if (typeof listMcpServers !== "function") return [];
2758
+ try {
2759
+ return mcpListFromProviderResult(await listMcpServers.call(client));
2760
+ } catch (error) {
2761
+ if (this.input.verbose) {
2762
+ process.stderr.write(`[agent:v2] mcpServerStatus/list failed: ${error instanceof Error ? error.message : String(error)}\n`);
2763
+ }
2764
+ return [];
2765
+ }
2766
+ }
2767
+
2768
+ private async loadProviderSkillCommands(
2769
+ provider: AgentProvider,
2770
+ client: AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient,
2771
+ ): Promise<AgentCommandDescriptor[]> {
2772
+ const listSkills = (client as { listSkills?: (input: { cwd: string }) => Promise<unknown> }).listSkills;
2773
+ if (typeof listSkills !== "function") return [];
2774
+ try {
2775
+ const result = await listSkills.call(client, { cwd: this.input.cwd });
2776
+ return parseSkillCommands(provider, result);
2777
+ } catch (error) {
2778
+ if (this.input.verbose) {
2779
+ process.stderr.write(`[agent:v2] skills/list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
2780
+ }
2781
+ return [];
2782
+ }
2432
2783
  }
2433
2784
 
2434
2785
  private async syncProviderSessions(): Promise<void> {
@@ -2485,9 +2836,11 @@ export class AgentWorkspaceProxy {
2485
2836
  ? existing?.status ?? "running"
2486
2837
  : remoteRunning
2487
2838
  ? "running"
2488
- : existing?.status === "error" || existing?.status === "waiting_permission"
2489
- ? existing.status
2490
- : "idle";
2839
+ : remote.status === "error"
2840
+ ? "error"
2841
+ : existing?.status === "error" || existing?.status === "waiting_permission"
2842
+ ? existing.status
2843
+ : "idle";
2491
2844
  const conversation: AgentConversation = {
2492
2845
  id: conversationId,
2493
2846
  agentSessionId,
@@ -2520,6 +2873,7 @@ export class AgentWorkspaceProxy {
2520
2873
  private async hydrateConversationFromProvider(
2521
2874
  conversation: AgentConversation,
2522
2875
  seedResult?: unknown,
2876
+ options?: { rememberActiveTurn?: boolean },
2523
2877
  ): Promise<void> {
2524
2878
  let client = this.clientForProvider(conversation.provider);
2525
2879
  if (!client || !conversation.agentSessionId) return;
@@ -2589,7 +2943,9 @@ export class AgentWorkspaceProxy {
2589
2943
 
2590
2944
  const thread = threadFromProviderResult(source);
2591
2945
  const activeTurnId = activeTurnIdFromThread(thread);
2592
- if (activeTurnId) this.rememberTurnConversationId(conversation.id, activeTurnId);
2946
+ if (activeTurnId && options?.rememberActiveTurn !== false) {
2947
+ this.rememberTurnConversationId(conversation.id, activeTurnId);
2948
+ }
2593
2949
  const model = firstString(thread, ["model", "modelId", "currentModel"]);
2594
2950
  if (model && !conversation.model) conversation.model = model;
2595
2951
 
@@ -2662,7 +3018,11 @@ export class AgentWorkspaceProxy {
2662
3018
  }
2663
3019
 
2664
3020
  private sendCapabilities(): void {
2665
- const providers = this.input.availableProviders.map((provider) => {
3021
+ const catalog = [...new Set([
3022
+ ...this.input.availableProviders,
3023
+ ...this.lastDiscoveredProviders,
3024
+ ])];
3025
+ const providers = catalog.map((provider) => {
2666
3026
  const client = this.clients.get(provider);
2667
3027
  const protocol = this.agentProtocols.get(provider);
2668
3028
  const runtimeCapabilities = this.providerCapabilities.get(provider);
@@ -2671,6 +3031,7 @@ export class AgentWorkspaceProxy {
2671
3031
  const isClaudeFallback = protocol === "claude-stream-json";
2672
3032
  const supportsPermission = enabled && !isClaudeFallback;
2673
3033
  const supportsReasoningEffort = enabled && !isClaudeFallback;
3034
+ const protocolBacked = isProtocolAgentProvider(provider);
2674
3035
  const commands = mergeCommands(
2675
3036
  defaultProviderCommands(provider, this.input.cwd, enabled),
2676
3037
  runtimeCapabilities?.commands,
@@ -2680,7 +3041,11 @@ export class AgentWorkspaceProxy {
2680
3041
  id: provider,
2681
3042
  label: providerLabel(provider),
2682
3043
  enabled,
2683
- reason: enabled ? undefined : this.providerErrors.get(provider) ?? `${providerLabel(provider)} 未安装或启动失败`,
3044
+ reason: enabled
3045
+ ? undefined
3046
+ : protocolBacked
3047
+ ? this.providerErrors.get(provider) ?? `${providerLabel(provider)} 未安装或启动失败`
3048
+ : `${providerLabel(provider)} 已在本机检测;远程对话请在终端继续`,
2684
3049
  supportsImages,
2685
3050
  supportsPermission,
2686
3051
  supportsPlan: enabled,
@@ -2717,11 +3082,11 @@ export class AgentWorkspaceProxy {
2717
3082
  sessionId: this.input.sessionId,
2718
3083
  payload: {
2719
3084
  enabled: anyEnabled,
2720
- provider: this.input.availableProviders[0] ?? "codex",
3085
+ provider: this.input.availableProviders[0] ?? catalog[0] ?? "codex",
2721
3086
  providers,
2722
3087
  protocolVersion: 1,
2723
3088
  workspaceProtocolVersion: 2,
2724
- error: anyEnabled ? undefined : "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。",
3089
+ error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
2725
3090
  supportsSessionList: anyEnabled,
2726
3091
  supportsSessionLoad: anyEnabled,
2727
3092
  supportsImages: providers.some((p) => p.supportsImages),
@@ -2750,18 +3115,44 @@ export class AgentWorkspaceProxy {
2750
3115
  if (!provider) {
2751
3116
  return this.openFailure(payload, "没有可用的 Agent provider。");
2752
3117
  }
2753
- if (!this.input.availableProviders.includes(provider)) {
2754
- return this.openFailure(
2755
- payload,
2756
- `${providerLabel(provider)} 未安装或不可用。`,
2757
- );
2758
- }
2759
3118
 
2760
3119
  let cwd = payload.cwd ?? this.input.cwd;
2761
3120
  let agentSessionId = payload.agentSessionId;
2762
3121
  let existingConversation =
2763
3122
  (payload.conversationId ? this.conversations.get(payload.conversationId) : undefined) ??
2764
3123
  (agentSessionId ? this.conversations.get(this.conversationByAgentSessionId.get(agentSessionId) ?? "") : undefined);
3124
+ if (
3125
+ existingConversation &&
3126
+ !this.clientForProvider(existingConversation.provider) &&
3127
+ !isProtocolAgentProvider(existingConversation.provider)
3128
+ ) {
3129
+ this.activeConversationId = existingConversation.id;
3130
+ this.input.send(createEnvelope({
3131
+ type: "agent.v2.conversation.opened",
3132
+ sessionId: this.input.sessionId,
3133
+ payload: {
3134
+ conversation: existingConversation,
3135
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
3136
+ requestedConversationId: payload.conversationId,
3137
+ },
3138
+ }));
3139
+ return existingConversation;
3140
+ }
3141
+ if (!isProtocolAgentProvider(provider) && !resolveAgentCommand({
3142
+ provider,
3143
+ command: this.input.command,
3144
+ })) {
3145
+ return this.openFailure(
3146
+ payload,
3147
+ `${providerLabel(provider)} 还不能从远程新建对话。在本机终端启动后,会话会出现在列表里。`,
3148
+ );
3149
+ }
3150
+ if (!this.input.availableProviders.includes(provider) && !existingConversation) {
3151
+ return this.openFailure(
3152
+ payload,
3153
+ `${providerLabel(provider)} 未安装或不可用。`,
3154
+ );
3155
+ }
2765
3156
  if (!payload.cwd && existingConversation?.cwd) cwd = existingConversation.cwd;
2766
3157
 
2767
3158
  let client = await this.ensureProviderClient(provider);
@@ -2789,14 +3180,15 @@ export class AgentWorkspaceProxy {
2789
3180
  }
2790
3181
 
2791
3182
  // Fork: seed a NEW conversation from an existing one's transcript, truncated
2792
- // at forkFromTurnId. Claude only — the SDK client exposes forkSession(); the
2793
- // stream-json fallback and Codex don't, so we notify and fall through to a
2794
- // plain new conversation rather than pretend. On success we set agentSessionId
2795
- // to the freshly-written transcript id and let the normal load path hydrate it.
3183
+ // at forkFromTurnId. Claude uses the SDK forkSession(); Codex uses
3184
+ // thread/fork. Other providers notify and fall through to a plain new
3185
+ // conversation. On success we set agentSessionId to the new thread id and
3186
+ // let the normal load path hydrate it.
2796
3187
  if (payload.forkFromConversationId) {
2797
3188
  const source = this.conversations.get(payload.forkFromConversationId);
2798
3189
  const sourceSessionId = source?.agentSessionId;
2799
3190
  const forkable = (client as { forkSession?: (i: { sourceSessionId: string; uptoTurnId?: string }) => { sessionId: string } | undefined }).forkSession;
3191
+ const forkThread = (client as { forkThread?: (i: { sessionId: string; lastTurnId?: string }) => Promise<unknown> }).forkThread;
2800
3192
  if (provider === "claude" && typeof forkable === "function" && sourceSessionId) {
2801
3193
  if (source?.cwd && !payload.cwd) cwd = source.cwd;
2802
3194
  const forked = forkable.call(client, { sourceSessionId, uptoTurnId: payload.forkFromTurnId });
@@ -2815,6 +3207,35 @@ export class AgentWorkspaceProxy {
2815
3207
  } else {
2816
3208
  this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
2817
3209
  }
3210
+ } else if (provider === "codex" && typeof forkThread === "function" && sourceSessionId) {
3211
+ if (source?.cwd && !payload.cwd) cwd = source.cwd;
3212
+ try {
3213
+ const forked = await forkThread.call(client, {
3214
+ sessionId: sourceSessionId,
3215
+ lastTurnId: payload.forkFromTurnId,
3216
+ });
3217
+ const forkedId = this.extractSessionId(forked);
3218
+ if (forkedId) {
3219
+ agentSessionId = forkedId;
3220
+ payload = {
3221
+ ...payload,
3222
+ model: payload.model ?? source?.model,
3223
+ reasoningEffort: payload.reasoningEffort ?? source?.reasoningEffort,
3224
+ permissionMode: payload.permissionMode ?? source?.permissionMode,
3225
+ collaborationMode: payload.collaborationMode ?? source?.collaborationMode,
3226
+ title: payload.title ?? (source?.title ? `${source.title} (分叉)` : undefined),
3227
+ };
3228
+ existingConversation = undefined;
3229
+ } else {
3230
+ this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
3231
+ }
3232
+ } catch (error) {
3233
+ this.emitNotice({
3234
+ kind: "warning",
3235
+ title: "分叉失败",
3236
+ detail: error instanceof Error ? error.message : "无法复制该会话的历史记录,已新建空会话。",
3237
+ });
3238
+ }
2818
3239
  } else {
2819
3240
  this.emitNotice({
2820
3241
  kind: "native_unsupported",
@@ -3057,7 +3478,9 @@ export class AgentWorkspaceProxy {
3057
3478
  if (!conversation.agentSessionId) {
3058
3479
  this.rejectAgentAction(
3059
3480
  conversation,
3060
- "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。",
3481
+ isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3482
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3483
+ : "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。",
3061
3484
  );
3062
3485
  return;
3063
3486
  }
@@ -3065,7 +3488,9 @@ export class AgentWorkspaceProxy {
3065
3488
  if (!client) {
3066
3489
  this.rejectAgentAction(
3067
3490
  conversation,
3068
- this.providerErrors.get(conversation.provider) ?? `${providerLabel(conversation.provider)} 未连接,消息没有发送。`,
3491
+ isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3492
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3493
+ : this.providerErrors.get(conversation.provider) ?? `${providerLabel(conversation.provider)} 未连接,消息没有发送。`,
3069
3494
  "error",
3070
3495
  );
3071
3496
  return;
@@ -3411,14 +3836,37 @@ export class AgentWorkspaceProxy {
3411
3836
  return;
3412
3837
  }
3413
3838
 
3414
- if (command.name === "review" || command.name === "subagents") {
3415
- const prompt = command.name === "review"
3416
- ? args || "Review the current local changes."
3417
- : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
3839
+ if (command.name === "review") {
3840
+ const startReview = (client as { startReview?: (input: { sessionId: string; prompt?: string }) => Promise<unknown> })?.startReview;
3841
+ if (typeof startReview === "function" && conversation.agentSessionId) {
3842
+ conversation.status = "running";
3843
+ conversation.lastActivityAt = now;
3844
+ this.emitConversation(conversation);
3845
+ const result = await startReview.call(client, {
3846
+ sessionId: conversation.agentSessionId,
3847
+ prompt: args || undefined,
3848
+ });
3849
+ const turnId = this.extractTurnId(result);
3850
+ if (turnId) this.rememberTurnConversationId(conversation.id, turnId);
3851
+ return;
3852
+ }
3418
3853
  await this.sendPrompt({
3419
3854
  conversationId: conversation.id,
3420
3855
  clientMessageId: id(command.name),
3421
- contentBlocks: [{ type: "text", text: prompt }],
3856
+ contentBlocks: [{ type: "text", text: args || "Review the current local changes." }],
3857
+ model: conversation.model,
3858
+ reasoningEffort: conversation.reasoningEffort,
3859
+ permissionMode: conversation.permissionMode,
3860
+ collaborationMode: conversation.collaborationMode,
3861
+ });
3862
+ return;
3863
+ }
3864
+
3865
+ if (command.name === "subagents") {
3866
+ await this.sendPrompt({
3867
+ conversationId: conversation.id,
3868
+ clientMessageId: id(command.name),
3869
+ contentBlocks: [{ type: "text", text: args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results." }],
3422
3870
  model: conversation.model,
3423
3871
  reasoningEffort: conversation.reasoningEffort,
3424
3872
  permissionMode: conversation.permissionMode,
@@ -3460,8 +3908,72 @@ export class AgentWorkspaceProxy {
3460
3908
  return {};
3461
3909
  }
3462
3910
 
3463
- // Merge MCP server descriptors into a provider's runtime capabilities,
3464
- // last-writer-wins by server name. Creates the map lazily.
3911
+ private async startMcpLogin(payload: { provider?: AgentProvider; serverName: string }): Promise<void> {
3912
+ const provider = payload.provider
3913
+ ?? this.input.availableProviders.find((item) => this.clients.has(item))
3914
+ ?? this.input.availableProviders[0];
3915
+ const sendResult = (input: { authorizationUrl?: string; error?: string }) => {
3916
+ this.input.send(createEnvelope({
3917
+ type: "agent.v2.mcp.login.result",
3918
+ sessionId: this.input.sessionId,
3919
+ payload: {
3920
+ ...(provider ? { provider } : {}),
3921
+ serverName: payload.serverName,
3922
+ authorizationUrl: input.authorizationUrl,
3923
+ error: input.error,
3924
+ },
3925
+ }));
3926
+ };
3927
+ if (!provider) {
3928
+ sendResult({ error: "没有可用的 Agent 提供方。" });
3929
+ return;
3930
+ }
3931
+ const client = this.clientForProvider(provider);
3932
+ const startMcpOAuth = (client as { startMcpOAuth?: (input: { serverName: string }) => Promise<unknown> } | undefined)?.startMcpOAuth;
3933
+ if (typeof startMcpOAuth !== "function") {
3934
+ sendResult({ error: "请在主机上的 Claude / Codex 完成该 MCP 的授权。" });
3935
+ return;
3936
+ }
3937
+ try {
3938
+ const result = await startMcpOAuth.call(client, { serverName: payload.serverName });
3939
+ const raw = asRecord(result);
3940
+ const authorizationUrl = firstString(raw, [
3941
+ "authorizationUrl",
3942
+ "authorization_url",
3943
+ "url",
3944
+ "loginUrl",
3945
+ ]);
3946
+ if (!authorizationUrl) {
3947
+ sendResult({ error: "主机没有返回授权地址。" });
3948
+ return;
3949
+ }
3950
+ this.mergeMcpServers(provider, [{
3951
+ name: payload.serverName,
3952
+ status: "needs_auth",
3953
+ }]);
3954
+ this.sendCapabilities();
3955
+ sendResult({ authorizationUrl });
3956
+ } catch (error) {
3957
+ sendResult({ error: error instanceof Error ? error.message : String(error) });
3958
+ }
3959
+ }
3960
+
3961
+ private handleMcpOAuthCompleted(params: unknown): void {
3962
+ const raw = asRecord(params) ?? {};
3963
+ const name = firstString(raw, ["name", "server", "serverName"]);
3964
+ if (!name) return;
3965
+ const success = raw.success !== false && !raw.error;
3966
+ const provider = this.input.availableProviders.find((item) => this.clients.has(item))
3967
+ ?? this.input.availableProviders[0];
3968
+ if (!provider) return;
3969
+ this.mergeMcpServers(provider, [{
3970
+ name,
3971
+ status: success ? "connected" : "needs_auth",
3972
+ error: success ? undefined : firstString(raw, ["error", "message"]),
3973
+ }]);
3974
+ this.sendCapabilities();
3975
+ }
3976
+
3465
3977
  private mergeMcpServers(provider: AgentProvider, servers: AgentMcpServerDescriptor[]): void {
3466
3978
  if (servers.length === 0) return;
3467
3979
  const existing = this.providerCapabilities.get(provider) ?? {};
@@ -3486,7 +3998,7 @@ export class AgentWorkspaceProxy {
3486
3998
  if (!name) return;
3487
3999
  this.mergeMcpServers(provider, [{
3488
4000
  name,
3489
- status: normalizeMcpStatus(raw.status ?? raw.state),
4001
+ status: mcpStatusFromRecord(raw),
3490
4002
  error: firstString(raw, ["error", "message", "reason"]),
3491
4003
  toolCount: firstNumber(raw, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(raw.tools) ? raw.tools.length : undefined),
3492
4004
  }]);
@@ -3538,11 +4050,11 @@ export class AgentWorkspaceProxy {
3538
4050
  this.handleMcpStartupStatus(method, params);
3539
4051
  return;
3540
4052
  }
3541
- if (
3542
- method.startsWith("account/") ||
3543
- method === "serverRequest/resolved" ||
3544
- method === "mcpServer/oauthLogin/completed"
3545
- ) {
4053
+ if (method === "mcpServer/oauthLogin/completed") {
4054
+ this.handleMcpOAuthCompleted(params);
4055
+ return;
4056
+ }
4057
+ if (method.startsWith("account/") || method === "serverRequest/resolved") {
3546
4058
  return;
3547
4059
  }
3548
4060
 
@@ -3577,6 +4089,45 @@ export class AgentWorkspaceProxy {
3577
4089
  }
3578
4090
  return;
3579
4091
  }
4092
+ if (method === "thread/name/updated") {
4093
+ if (conversationId) {
4094
+ const raw = asRecord(params);
4095
+ const title = firstString(raw, ["name", "title"]) ?? firstString(asRecord(raw?.thread), ["name", "title"]);
4096
+ const conversation = this.conversations.get(conversationId);
4097
+ if (conversation && title !== undefined) {
4098
+ conversation.title = title;
4099
+ conversation.lastActivityAt = Date.now();
4100
+ this.emitConversation(conversation);
4101
+ }
4102
+ }
4103
+ return;
4104
+ }
4105
+ if (method === "thread/settings/updated") {
4106
+ if (conversationId) {
4107
+ const conversation = this.conversations.get(conversationId);
4108
+ if (conversation) {
4109
+ applyThreadSettingsToConversation(conversation, params);
4110
+ conversation.lastActivityAt = Date.now();
4111
+ this.emitConversation(conversation);
4112
+ }
4113
+ }
4114
+ return;
4115
+ }
4116
+ if (method === "thread/archived" || method === "thread/unarchived") {
4117
+ if (conversationId) {
4118
+ const conversation = this.conversations.get(conversationId);
4119
+ if (conversation) {
4120
+ conversation.archived = method === "thread/archived";
4121
+ conversation.lastActivityAt = Date.now();
4122
+ this.emitConversation(conversation);
4123
+ }
4124
+ }
4125
+ return;
4126
+ }
4127
+ if (method === "thread/deleted") {
4128
+ if (conversationId) this.forgetConversationLocally(conversationId);
4129
+ return;
4130
+ }
3580
4131
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
3581
4132
  this.handleStructuredInput(params);
3582
4133
  return;
@@ -3637,6 +4188,13 @@ export class AgentWorkspaceProxy {
3637
4188
  case "item/agentMessage/delta":
3638
4189
  this.handleAgentMessageDelta(params);
3639
4190
  return;
4191
+ case "item/reasoning/summaryTextDelta":
4192
+ case "item/reasoning/textDelta":
4193
+ this.handleReasoningDelta(params);
4194
+ return;
4195
+ case "item/reasoning/summaryPartAdded":
4196
+ this.handleReasoningDelta(params, { partBoundary: true });
4197
+ return;
3640
4198
  case "turn/plan/updated":
3641
4199
  this.handlePlanUpdated(params);
3642
4200
  return;
@@ -3700,10 +4258,68 @@ export class AgentWorkspaceProxy {
3700
4258
  updatedAt: Date.now(),
3701
4259
  isStreaming: true,
3702
4260
  };
3703
- this.upsertItem(conversationId, item);
4261
+ this.applyStreamingTextDelta(conversationId, item, existing, delta);
3704
4262
  this.updateConversationPreview(conversationId, text, "running");
3705
4263
  }
3706
4264
 
4265
+ private handleReasoningDelta(params: unknown, options?: { partBoundary?: boolean }): void {
4266
+ const raw = asRecord(params);
4267
+ if (!raw) return;
4268
+ const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
4269
+ if (!conversationId) return;
4270
+ const itemId = firstString(raw, ["itemId", "id", "messageId"]) ?? id("thinking");
4271
+ const existing = this.findItem(conversationId, itemId);
4272
+ const delta = firstString(raw, ["delta", "text", "content", "summary"])
4273
+ ?? (options?.partBoundary ? "\n" : undefined);
4274
+ if (!delta) return;
4275
+ const text = `${existing?.text ?? ""}${delta}`;
4276
+ const item: AgentTimelineItem = {
4277
+ id: itemId,
4278
+ conversationId,
4279
+ type: "status",
4280
+ kind: "thinking",
4281
+ role: "system",
4282
+ turnId: this.extractTurnId(raw) ?? this.currentTurnIds.get(conversationId),
4283
+ itemId,
4284
+ content: [{ type: "text", text }],
4285
+ text,
4286
+ createdAt: existing?.createdAt ?? Date.now(),
4287
+ updatedAt: Date.now(),
4288
+ isStreaming: true,
4289
+ };
4290
+ this.applyStreamingTextDelta(conversationId, item, existing, delta);
4291
+ }
4292
+
4293
+ private applyStreamingTextDelta(
4294
+ conversationId: string,
4295
+ item: AgentTimelineItem,
4296
+ existing: AgentTimelineItem | undefined,
4297
+ delta: string,
4298
+ ): void {
4299
+ if (!existing) {
4300
+ this.upsertItem(conversationId, item);
4301
+ return;
4302
+ }
4303
+ this.rememberItemConversationId(conversationId, item);
4304
+ const timeline = this.timelines.get(conversationId) ?? [];
4305
+ const index = timeline.findIndex((entry) => entry.id === item.id);
4306
+ if (index >= 0) timeline[index] = item;
4307
+ else timeline.push(item);
4308
+ this.timelines.set(conversationId, timeline);
4309
+ this.input.send(createEnvelope({
4310
+ type: "agent.v2.event",
4311
+ sessionId: this.input.sessionId,
4312
+ payload: {
4313
+ conversationId,
4314
+ patch: {
4315
+ itemId: item.id,
4316
+ textDelta: delta,
4317
+ updatedAt: item.updatedAt,
4318
+ },
4319
+ },
4320
+ }));
4321
+ }
4322
+
3707
4323
  private handlePlanUpdated(params: unknown): void {
3708
4324
  const raw = asRecord(params);
3709
4325
  const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
@@ -4174,20 +4790,14 @@ export class AgentWorkspaceProxy {
4174
4790
  const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
4175
4791
  const rawToolCall = asRecord(raw.toolCall) ?? raw;
4176
4792
  // Codex app-server command/file approvals carry `command`/`reason`/`cwd`
4177
- // (not toolCall/context). Surface them so the card actually shows what's
4178
- // being approved; fall back to the Claude/MCP shape otherwise.
4179
- const codexCommand = firstString(raw, ["command"]);
4180
- const codexCwd = firstString(raw, ["cwd"]);
4793
+ // (not toolCall/context). Keep those fields as JSON so the web card can
4794
+ // render `$ command` / a path instead of a raw dump.
4181
4795
  const permission: AgentPermission = {
4182
4796
  requestId,
4183
- toolName:
4184
- firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]) ??
4185
- (codexCommand ? "shell" : undefined),
4186
- toolInput: codexCommand
4187
- ? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
4188
- : stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
4797
+ toolName: permissionToolName(raw, rawToolCall),
4798
+ toolInput: permissionToolInput(raw, rawToolCall),
4189
4799
  context: firstString(raw, ["context", "description", "message", "title", "reason"]),
4190
- options: parsePermissionOptions(raw.options),
4800
+ options: permissionOptionsFromRequest(raw, source),
4191
4801
  };
4192
4802
  this.pendingPermissions.set(requestId, permission);
4193
4803
  if (source) this.permissionSources.set(requestId, source);
@@ -4499,6 +5109,23 @@ export class AgentWorkspaceProxy {
4499
5109
  }));
4500
5110
  }
4501
5111
 
5112
+ private forgetConversationLocally(conversationId: string): void {
5113
+ const conversation = this.conversations.get(conversationId);
5114
+ if (conversation?.agentSessionId) {
5115
+ this.deletedAgentSessionIds.add(conversation.agentSessionId);
5116
+ this.conversationByAgentSessionId.delete(conversation.agentSessionId);
5117
+ }
5118
+ this.conversations.delete(conversationId);
5119
+ this.timelines.delete(conversationId);
5120
+ this.historyCursors.delete(conversationId);
5121
+ this.lastHydrateAt.delete(conversationId);
5122
+ this.input.send(createEnvelope({
5123
+ type: "agent.v2.conversation.deleted",
5124
+ sessionId: this.input.sessionId,
5125
+ payload: { conversationId },
5126
+ }));
5127
+ }
5128
+
4502
5129
  private emitConversation(conversation: AgentConversation): void {
4503
5130
  this.input.send(createEnvelope({
4504
5131
  type: "agent.v2.event",
@@ -4886,6 +5513,84 @@ export class AgentWorkspaceProxy {
4886
5513
  }
4887
5514
  }
4888
5515
 
5516
+ function permissionToolName(
5517
+ raw: Record<string, unknown>,
5518
+ rawToolCall: Record<string, unknown>,
5519
+ ): string | undefined {
5520
+ const named = firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]);
5521
+ if (named) return named;
5522
+ if (firstString(raw, ["command"])) return "shell";
5523
+ if (permissionFilePath(raw)) return "Edit";
5524
+ return undefined;
5525
+ }
5526
+
5527
+ function permissionFilePath(raw: Record<string, unknown>): string | undefined {
5528
+ const direct = firstString(raw, ["path", "filePath", "file_path"]);
5529
+ if (direct) return direct;
5530
+ const changes = Array.isArray(raw.changes) ? raw.changes : undefined;
5531
+ const first = changes ? asRecord(changes[0]) : undefined;
5532
+ return firstString(first, ["path", "filePath", "file_path"]);
5533
+ }
5534
+
5535
+ function permissionToolInput(
5536
+ raw: Record<string, unknown>,
5537
+ rawToolCall: Record<string, unknown>,
5538
+ ): string {
5539
+ const command = firstString(raw, ["command"]);
5540
+ const cwd = firstString(raw, ["cwd"]);
5541
+ const path = permissionFilePath(raw);
5542
+ if (command || path) {
5543
+ return JSON.stringify({
5544
+ ...(command ? { command } : {}),
5545
+ ...(cwd ? { cwd } : {}),
5546
+ ...(path ? { path } : {}),
5547
+ });
5548
+ }
5549
+ return stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall);
5550
+ }
5551
+
5552
+ function officialDecisionKey(value: unknown): string | undefined {
5553
+ if (typeof value === "string" && value.trim()) return value.trim();
5554
+ const raw = asRecord(value);
5555
+ return firstString(raw, ["decision", "id", "kind", "type", "optionId"]);
5556
+ }
5557
+
5558
+ function officialDecisionOption(key: string): AgentPermission["options"][number] | undefined {
5559
+ const normalized = key.replace(/[\s_-]/g, "").toLowerCase();
5560
+ if (normalized === "accept" || normalized === "allow" || normalized === "allowonce") {
5561
+ return { id: key === "accept" ? "accept" : key, label: "允许一次", kind: "allow" };
5562
+ }
5563
+ if (
5564
+ normalized === "acceptforsession" ||
5565
+ normalized === "allowsession" ||
5566
+ normalized === "allowforsession" ||
5567
+ normalized === "session"
5568
+ ) {
5569
+ return { id: "acceptForSession", label: "本会话允许", kind: "allow" };
5570
+ }
5571
+ if (normalized === "decline" || normalized === "deny" || normalized === "reject") {
5572
+ return { id: normalized === "decline" ? "decline" : key, label: "拒绝", kind: "deny" };
5573
+ }
5574
+ if (normalized === "cancel") {
5575
+ return { id: "cancel", label: "取消", kind: "deny" };
5576
+ }
5577
+ return undefined;
5578
+ }
5579
+
5580
+ function parseOfficialDecisions(value: unknown[]): AgentPermission["options"] {
5581
+ const options: AgentPermission["options"] = [];
5582
+ const seen = new Set<string>();
5583
+ for (const entry of value) {
5584
+ const key = officialDecisionKey(entry);
5585
+ if (!key) continue;
5586
+ const mapped = officialDecisionOption(key);
5587
+ if (!mapped || seen.has(mapped.id)) continue;
5588
+ seen.add(mapped.id);
5589
+ options.push(mapped);
5590
+ }
5591
+ return options;
5592
+ }
5593
+
4889
5594
  function parsePermissionOptions(value: unknown): AgentPermission["options"] {
4890
5595
  if (!Array.isArray(value)) {
4891
5596
  return [
@@ -4894,19 +5599,27 @@ function parsePermissionOptions(value: unknown): AgentPermission["options"] {
4894
5599
  ];
4895
5600
  }
4896
5601
 
5602
+ const official = parseOfficialDecisions(value);
5603
+ if (official.length > 0 && value.every((entry) => officialDecisionOption(officialDecisionKey(entry) ?? ""))) {
5604
+ return official;
5605
+ }
5606
+
4897
5607
  const options = value
4898
5608
  .map((entry, index) => {
4899
5609
  const raw = asRecord(entry) ?? {};
4900
- const idValue = raw.optionId ?? raw.id ?? raw.kind ?? `option-${index + 1}`;
4901
- const labelValue = raw.name ?? raw.label ?? raw.kind ?? String(idValue);
5610
+ const idValue = raw.optionId ?? raw.id ?? raw.kind ?? raw.decision ?? `option-${index + 1}`;
5611
+ const labelValue = raw.name ?? raw.label ?? raw.kind ?? raw.decision ?? String(idValue);
4902
5612
  const id = String(idValue);
4903
5613
  const label = String(labelValue);
5614
+ const official = officialDecisionOption(id);
5615
+ if (official) return { ...official, id, label: official.label === "允许一次" && label !== id ? label : official.label };
4904
5616
  const normalized = `${id} ${label}`.toLowerCase();
4905
- const kind: AgentPermission["options"][number]["kind"] = normalized.includes("reject") || normalized.includes("deny")
4906
- ? "deny"
4907
- : normalized.includes("allow")
4908
- ? "allow"
4909
- : "other";
5617
+ const kind: AgentPermission["options"][number]["kind"] =
5618
+ /reject|deny|decline|cancel/.test(normalized)
5619
+ ? "deny"
5620
+ : /allow|accept/.test(normalized)
5621
+ ? "allow"
5622
+ : "other";
4910
5623
  return { id, label, kind };
4911
5624
  })
4912
5625
  .filter((option) => option.id.length > 0 && option.label.length > 0);
@@ -4919,6 +5632,38 @@ function parsePermissionOptions(value: unknown): AgentPermission["options"] {
4919
5632
  ];
4920
5633
  }
4921
5634
 
5635
+ function permissionOptionsFromRequest(
5636
+ raw: Record<string, unknown>,
5637
+ source?: string,
5638
+ ): AgentPermission["options"] {
5639
+ const decisions = raw.availableDecisions;
5640
+ if (Array.isArray(decisions) && decisions.length > 0) {
5641
+ const official = parseOfficialDecisions(decisions);
5642
+ if (official.length > 0) return official;
5643
+ }
5644
+ if (Array.isArray(raw.options)) {
5645
+ return parsePermissionOptions(raw.options);
5646
+ }
5647
+ if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
5648
+ return [
5649
+ { id: "accept", label: "允许一次", kind: "allow" },
5650
+ { id: "acceptForSession", label: "本会话允许", kind: "allow" },
5651
+ { id: "decline", label: "拒绝", kind: "deny" },
5652
+ ];
5653
+ }
5654
+ if (source === "item/permissions/requestApproval") {
5655
+ return [
5656
+ { id: "turn", label: "本次允许", kind: "allow" },
5657
+ { id: "session", label: "本会话允许", kind: "allow" },
5658
+ { id: "deny", label: "拒绝", kind: "deny" },
5659
+ ];
5660
+ }
5661
+ return [
5662
+ { id: "allow", label: "允许", kind: "allow" },
5663
+ { id: "deny", label: "拒绝", kind: "deny" },
5664
+ ];
5665
+ }
5666
+
4922
5667
  function selectPermissionOption(
4923
5668
  permission: AgentPermission | undefined,
4924
5669
  outcome: "allow" | "deny" | "cancelled",
@@ -5013,7 +5758,10 @@ function formatPermissionResponse(
5013
5758
  optionId: string,
5014
5759
  ): unknown {
5015
5760
  if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
5016
- return { decision: outcome === "allow" ? "accept" : outcome === "deny" ? "decline" : "cancel" };
5761
+ if (outcome === "cancelled" || optionId === "cancel") return { decision: "cancel" };
5762
+ if (outcome === "deny") return { decision: "decline" };
5763
+ if (optionId === "acceptForSession") return { decision: "acceptForSession" };
5764
+ return { decision: "accept" };
5017
5765
  }
5018
5766
  if (source === "item/permissions/requestApproval") {
5019
5767
  if (outcome === "allow") {