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
@@ -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"]),
@@ -1918,6 +1995,9 @@ function previewFromTimelineItem(item: AgentTimelineItem): string | undefined {
1918
1995
 
1919
1996
  export class AgentWorkspaceProxy {
1920
1997
  private clients = new Map<AgentProvider, AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient>();
1998
+ // In-flight client starts, so concurrent ensureProviderClient calls (eager
1999
+ // initialize() racing conversation.open) share one subprocess spawn.
2000
+ private clientStartPromises = new Map<AgentProvider, Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined>>();
1921
2001
  private agentProtocols = new Map<AgentProvider, AgentProtocol>();
1922
2002
  private providerCapabilities = new Map<AgentProvider, ProviderRuntimeCapabilities>();
1923
2003
  private providerErrors = new Map<AgentProvider, string>();
@@ -1930,10 +2010,9 @@ export class AgentWorkspaceProxy {
1930
2010
  private conversations = new Map<string, AgentConversation>();
1931
2011
  private conversationByAgentSessionId = new Map<string, string>();
1932
2012
  private timelines = new Map<string, AgentTimelineItem[]>();
1933
- // Conversations the user "deleted" (forgot). We never touch the agent's
1934
- // on-disk transcript, so syncProviderSessions would otherwise re-list and
1935
- // resurrect them. Tombstone by agentSessionId (the stable provider key) so a
1936
- // 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).
1937
2016
  private deletedAgentSessionIds = new Set<string>();
1938
2017
  // Opaque codex turns cursor per conversation, pointing at OLDER history to
1939
2018
  // page through (captured at hydration, advanced on each history.request).
@@ -1947,6 +2026,13 @@ export class AgentWorkspaceProxy {
1947
2026
  private itemConversationIds = new Map<string, string>();
1948
2027
  private toolConversationIds = new Map<string, string>();
1949
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
+
1950
2036
  constructor(
1951
2037
  private readonly input: {
1952
2038
  sessionId: string;
@@ -1955,6 +2041,7 @@ export class AgentWorkspaceProxy {
1955
2041
  command?: string;
1956
2042
  send: (envelope: Envelope) => void;
1957
2043
  verbose?: boolean;
2044
+ discoverProcesses?: () => AgentProcessSnapshot[];
1958
2045
  },
1959
2046
  ) {}
1960
2047
 
@@ -1974,35 +2061,83 @@ export class AgentWorkspaceProxy {
1974
2061
  const payload = parseTypedPayload("agent.v2.conversation.update", envelope.payload);
1975
2062
  const conversation = this.conversations.get(payload.conversationId);
1976
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);
1977
2076
  if (payload.title !== undefined) {
1978
2077
  const trimmed = payload.title.trim();
1979
2078
  conversation.title = trimmed === "" ? undefined : trimmed;
1980
2079
  }
1981
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;
1982
2088
  conversation.lastActivityAt = Date.now();
1983
2089
  // Echo the updated record so every client refreshes (the web store
1984
2090
  // force-replaces by id on any event carrying `conversation`).
1985
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
+ }
1986
2128
  break;
1987
2129
  }
1988
2130
  case "agent.v2.conversation.delete": {
1989
2131
  const payload = parseTypedPayload("agent.v2.conversation.delete", envelope.payload);
1990
- const conversation = this.conversations.get(payload.conversationId);
1991
- // Forget the conversation from the workspace's tracked set. We do NOT
1992
- // delete the provider's on-disk transcript — only stop tracking it and
1993
- // tombstone its session id so syncProviderSessions won't re-add it.
1994
- if (conversation?.agentSessionId) {
1995
- this.deletedAgentSessionIds.add(conversation.agentSessionId);
1996
- this.conversationByAgentSessionId.delete(conversation.agentSessionId);
1997
- }
1998
- this.conversations.delete(payload.conversationId);
1999
- this.timelines.delete(payload.conversationId);
2000
- this.historyCursors.delete(payload.conversationId);
2001
- this.input.send(createEnvelope({
2002
- type: "agent.v2.conversation.deleted",
2003
- sessionId: this.input.sessionId,
2004
- payload: { conversationId: payload.conversationId },
2005
- }));
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);
2006
2141
  break;
2007
2142
  }
2008
2143
  case "agent.v2.conversation.list": {
@@ -2081,6 +2216,10 @@ export class AgentWorkspaceProxy {
2081
2216
  }
2082
2217
 
2083
2218
  stop(): void {
2219
+ if (this.processPollTimer) {
2220
+ clearInterval(this.processPollTimer);
2221
+ this.processPollTimer = undefined;
2222
+ }
2084
2223
  for (const watcher of this.fsWatchers) {
2085
2224
  try { watcher.close(); } catch { /* already closed */ }
2086
2225
  }
@@ -2109,7 +2248,9 @@ export class AgentWorkspaceProxy {
2109
2248
  private fsSyncTimer: ReturnType<typeof setTimeout> | undefined;
2110
2249
  private fsSyncRunning = false;
2111
2250
  private lastPushedConversationSignature: string | undefined;
2251
+ private lastHydrateAt = new Map<string, number>();
2112
2252
  private static readonly FS_SYNC_THROTTLE_MS = 1500;
2253
+ private static readonly EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
2113
2254
 
2114
2255
  private startSessionWatchers(): void {
2115
2256
  const roots = [
@@ -2161,16 +2302,36 @@ export class AgentWorkspaceProxy {
2161
2302
  // and client re-renders. A cheap signature over the render-relevant fields
2162
2303
  // gates the push.
2163
2304
  const signature = this.conversationListSignature(conversations);
2164
- if (signature === this.lastPushedConversationSignature) return;
2165
- this.lastPushedConversationSignature = signature;
2166
- // Push the conversation list (statuses) — lighter than a full snapshot and
2167
- // exactly what refreshes the tree. The web handles an unsolicited
2168
- // conversation.list.result the same as a requested one.
2169
- this.input.send(createEnvelope({
2170
- type: "agent.v2.conversation.list.result",
2171
- sessionId: this.input.sessionId,
2172
- payload: { conversations },
2173
- }));
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
+ }
2174
2335
  } catch (error) {
2175
2336
  if (this.input.verbose) {
2176
2337
  process.stderr.write(`[agent:v2] fs-watch sync failed: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -2187,7 +2348,7 @@ export class AgentWorkspaceProxy {
2187
2348
  * membership are what matter. */
2188
2349
  private conversationListSignature(conversations: AgentConversation[]): string {
2189
2350
  return conversations
2190
- .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 ?? ""}`)
2191
2352
  .sort()
2192
2353
  .join("");
2193
2354
  }
@@ -2241,12 +2402,179 @@ export class AgentWorkspaceProxy {
2241
2402
  // Start watching the on-disk session stores so externally-started sessions
2242
2403
  // and status changes surface live, not just at connect time.
2243
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
+ }
2244
2555
  }
2245
2556
 
2246
2557
  private async ensureProviderClient(provider: AgentProvider): Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined> {
2247
2558
  const existing = this.clients.get(provider);
2248
2559
  if (existing) return existing;
2249
2560
 
2561
+ // Dedupe concurrent starts: eager initialize() and conversation.open can
2562
+ // race here before this.clients is populated (it's only set AFTER the
2563
+ // client finishes initializing), which would spawn the provider subprocess
2564
+ // twice. Memoize the in-flight promise so both callers share one spawn;
2565
+ // drop it once settled so a failed start can be retried.
2566
+ const inflight = this.clientStartPromises.get(provider);
2567
+ if (inflight) return inflight;
2568
+ const start = this.startProviderClient(provider);
2569
+ this.clientStartPromises.set(provider, start);
2570
+ try {
2571
+ return await start;
2572
+ } finally {
2573
+ this.clientStartPromises.delete(provider);
2574
+ }
2575
+ }
2576
+
2577
+ private async startProviderClient(provider: AgentProvider): Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined> {
2250
2578
  const resolved = resolveAgentCommand({
2251
2579
  provider,
2252
2580
  command: this.input.command,
@@ -2299,7 +2627,7 @@ export class AgentWorkspaceProxy {
2299
2627
  const client = await tryCreateClient(resolved);
2300
2628
  this.clients.set(provider, client);
2301
2629
  this.providerErrors.delete(provider);
2302
- await this.refreshProviderCapabilities(provider, client, resolved.protocol);
2630
+ this.refreshProviderCapabilitiesInBackground(provider, client, resolved.protocol);
2303
2631
  this.status = "idle";
2304
2632
  this.error = undefined;
2305
2633
  this.sendCapabilities();
@@ -2319,7 +2647,7 @@ export class AgentWorkspaceProxy {
2319
2647
  const client = await tryCreateClient(fallback);
2320
2648
  this.clients.set(provider, client);
2321
2649
  this.providerErrors.delete(provider);
2322
- await this.refreshProviderCapabilities(provider, client, fallback.protocol);
2650
+ this.refreshProviderCapabilitiesInBackground(provider, client, fallback.protocol);
2323
2651
  this.status = "idle";
2324
2652
  this.error = undefined;
2325
2653
  this.sendCapabilities();
@@ -2339,6 +2667,27 @@ export class AgentWorkspaceProxy {
2339
2667
  }
2340
2668
  }
2341
2669
 
2670
+ /** Kick off runtime capability discovery WITHOUT blocking the caller.
2671
+ * refreshProviderCapabilities spawns a warm provider subprocess (measured
2672
+ * 858ms warm, multi-second cold for Claude), and nothing on the open/prompt
2673
+ * path needs it — sendCapabilities and prompt-time model resolution both
2674
+ * degrade gracefully to static fallbacks until providerCapabilities is
2675
+ * populated. When discovery lands we push a fresh agent.v2.capabilities so
2676
+ * clients pick up the real model/command list. */
2677
+ private refreshProviderCapabilitiesInBackground(
2678
+ provider: AgentProvider,
2679
+ client: AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient,
2680
+ protocol: AgentProtocol,
2681
+ ): void {
2682
+ void this.refreshProviderCapabilities(provider, client, protocol)
2683
+ .then(() => this.sendCapabilities())
2684
+ .catch((error) => {
2685
+ if (this.input.verbose) {
2686
+ process.stderr.write(`[agent:v2] capability refresh failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
2687
+ }
2688
+ });
2689
+ }
2690
+
2342
2691
  private async refreshProviderCapabilities(
2343
2692
  provider: AgentProvider,
2344
2693
  client: AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient,
@@ -2357,9 +2706,11 @@ export class AgentWorkspaceProxy {
2357
2706
  }
2358
2707
  }
2359
2708
  }
2709
+ const listedMcp = await this.listProviderMcpServers(client);
2360
2710
  if (provider === "codex") {
2361
2711
  // Codex `model/list` is unreliable across versions — make sure mobile always
2362
2712
  // sees a usable model picker by merging in a static fallback list.
2713
+ const skillCommands = await this.loadProviderSkillCommands(provider, client);
2363
2714
  const merged: ProviderRuntimeCapabilities = {
2364
2715
  models: runtimeCapabilities?.models?.length
2365
2716
  ? runtimeCapabilities.models
@@ -2368,13 +2719,14 @@ export class AgentWorkspaceProxy {
2368
2719
  reasoningEfforts: runtimeCapabilities?.reasoningEfforts?.length
2369
2720
  ? runtimeCapabilities.reasoningEfforts
2370
2721
  : [...ALL_REASONING_EFFORTS],
2371
- commands: runtimeCapabilities?.commands,
2722
+ commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
2372
2723
  modes: runtimeCapabilities?.modes,
2373
2724
  currentMode: runtimeCapabilities?.currentMode,
2374
2725
  // Preserve any MCP status already accumulated from startupStatus events.
2375
2726
  mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
2376
2727
  };
2377
2728
  this.providerCapabilities.set(provider, merged);
2729
+ if (listedMcp.length > 0) this.mergeMcpServers(provider, listedMcp);
2378
2730
  return;
2379
2731
  }
2380
2732
  if (runtimeCapabilities) {
@@ -2386,8 +2738,48 @@ export class AgentWorkspaceProxy {
2386
2738
  for (const [name, desc] of runtimeCapabilities.mcpServers ?? []) map.set(name, desc);
2387
2739
  runtimeCapabilities = { ...runtimeCapabilities, mcpServers: map };
2388
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
+ }
2389
2748
  this.providerCapabilities.set(provider, runtimeCapabilities);
2390
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
+ }
2391
2783
  }
2392
2784
 
2393
2785
  private async syncProviderSessions(): Promise<void> {
@@ -2444,9 +2836,11 @@ export class AgentWorkspaceProxy {
2444
2836
  ? existing?.status ?? "running"
2445
2837
  : remoteRunning
2446
2838
  ? "running"
2447
- : existing?.status === "error" || existing?.status === "waiting_permission"
2448
- ? existing.status
2449
- : "idle";
2839
+ : remote.status === "error"
2840
+ ? "error"
2841
+ : existing?.status === "error" || existing?.status === "waiting_permission"
2842
+ ? existing.status
2843
+ : "idle";
2450
2844
  const conversation: AgentConversation = {
2451
2845
  id: conversationId,
2452
2846
  agentSessionId,
@@ -2479,6 +2873,7 @@ export class AgentWorkspaceProxy {
2479
2873
  private async hydrateConversationFromProvider(
2480
2874
  conversation: AgentConversation,
2481
2875
  seedResult?: unknown,
2876
+ options?: { rememberActiveTurn?: boolean },
2482
2877
  ): Promise<void> {
2483
2878
  let client = this.clientForProvider(conversation.provider);
2484
2879
  if (!client || !conversation.agentSessionId) return;
@@ -2548,7 +2943,9 @@ export class AgentWorkspaceProxy {
2548
2943
 
2549
2944
  const thread = threadFromProviderResult(source);
2550
2945
  const activeTurnId = activeTurnIdFromThread(thread);
2551
- if (activeTurnId) this.rememberTurnConversationId(conversation.id, activeTurnId);
2946
+ if (activeTurnId && options?.rememberActiveTurn !== false) {
2947
+ this.rememberTurnConversationId(conversation.id, activeTurnId);
2948
+ }
2552
2949
  const model = firstString(thread, ["model", "modelId", "currentModel"]);
2553
2950
  if (model && !conversation.model) conversation.model = model;
2554
2951
 
@@ -2621,7 +3018,11 @@ export class AgentWorkspaceProxy {
2621
3018
  }
2622
3019
 
2623
3020
  private sendCapabilities(): void {
2624
- 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) => {
2625
3026
  const client = this.clients.get(provider);
2626
3027
  const protocol = this.agentProtocols.get(provider);
2627
3028
  const runtimeCapabilities = this.providerCapabilities.get(provider);
@@ -2630,6 +3031,7 @@ export class AgentWorkspaceProxy {
2630
3031
  const isClaudeFallback = protocol === "claude-stream-json";
2631
3032
  const supportsPermission = enabled && !isClaudeFallback;
2632
3033
  const supportsReasoningEffort = enabled && !isClaudeFallback;
3034
+ const protocolBacked = isProtocolAgentProvider(provider);
2633
3035
  const commands = mergeCommands(
2634
3036
  defaultProviderCommands(provider, this.input.cwd, enabled),
2635
3037
  runtimeCapabilities?.commands,
@@ -2639,7 +3041,11 @@ export class AgentWorkspaceProxy {
2639
3041
  id: provider,
2640
3042
  label: providerLabel(provider),
2641
3043
  enabled,
2642
- 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)} 已在本机检测;远程对话请在终端继续`,
2643
3049
  supportsImages,
2644
3050
  supportsPermission,
2645
3051
  supportsPlan: enabled,
@@ -2676,11 +3082,11 @@ export class AgentWorkspaceProxy {
2676
3082
  sessionId: this.input.sessionId,
2677
3083
  payload: {
2678
3084
  enabled: anyEnabled,
2679
- provider: this.input.availableProviders[0] ?? "codex",
3085
+ provider: this.input.availableProviders[0] ?? catalog[0] ?? "codex",
2680
3086
  providers,
2681
3087
  protocolVersion: 1,
2682
3088
  workspaceProtocolVersion: 2,
2683
- error: anyEnabled ? undefined : "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。",
3089
+ error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
2684
3090
  supportsSessionList: anyEnabled,
2685
3091
  supportsSessionLoad: anyEnabled,
2686
3092
  supportsImages: providers.some((p) => p.supportsImages),
@@ -2709,18 +3115,44 @@ export class AgentWorkspaceProxy {
2709
3115
  if (!provider) {
2710
3116
  return this.openFailure(payload, "没有可用的 Agent provider。");
2711
3117
  }
2712
- if (!this.input.availableProviders.includes(provider)) {
2713
- return this.openFailure(
2714
- payload,
2715
- `${providerLabel(provider)} 未安装或不可用。`,
2716
- );
2717
- }
2718
3118
 
2719
3119
  let cwd = payload.cwd ?? this.input.cwd;
2720
3120
  let agentSessionId = payload.agentSessionId;
2721
3121
  let existingConversation =
2722
3122
  (payload.conversationId ? this.conversations.get(payload.conversationId) : undefined) ??
2723
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
+ }
2724
3156
  if (!payload.cwd && existingConversation?.cwd) cwd = existingConversation.cwd;
2725
3157
 
2726
3158
  let client = await this.ensureProviderClient(provider);
@@ -2748,14 +3180,15 @@ export class AgentWorkspaceProxy {
2748
3180
  }
2749
3181
 
2750
3182
  // Fork: seed a NEW conversation from an existing one's transcript, truncated
2751
- // at forkFromTurnId. Claude only — the SDK client exposes forkSession(); the
2752
- // stream-json fallback and Codex don't, so we notify and fall through to a
2753
- // plain new conversation rather than pretend. On success we set agentSessionId
2754
- // 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.
2755
3187
  if (payload.forkFromConversationId) {
2756
3188
  const source = this.conversations.get(payload.forkFromConversationId);
2757
3189
  const sourceSessionId = source?.agentSessionId;
2758
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;
2759
3192
  if (provider === "claude" && typeof forkable === "function" && sourceSessionId) {
2760
3193
  if (source?.cwd && !payload.cwd) cwd = source.cwd;
2761
3194
  const forked = forkable.call(client, { sourceSessionId, uptoTurnId: payload.forkFromTurnId });
@@ -2774,6 +3207,35 @@ export class AgentWorkspaceProxy {
2774
3207
  } else {
2775
3208
  this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
2776
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
+ }
2777
3239
  } else {
2778
3240
  this.emitNotice({
2779
3241
  kind: "native_unsupported",
@@ -3016,7 +3478,9 @@ export class AgentWorkspaceProxy {
3016
3478
  if (!conversation.agentSessionId) {
3017
3479
  this.rejectAgentAction(
3018
3480
  conversation,
3019
- "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。",
3481
+ isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3482
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3483
+ : "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。",
3020
3484
  );
3021
3485
  return;
3022
3486
  }
@@ -3024,7 +3488,9 @@ export class AgentWorkspaceProxy {
3024
3488
  if (!client) {
3025
3489
  this.rejectAgentAction(
3026
3490
  conversation,
3027
- 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)} 未连接,消息没有发送。`,
3028
3494
  "error",
3029
3495
  );
3030
3496
  return;
@@ -3370,14 +3836,37 @@ export class AgentWorkspaceProxy {
3370
3836
  return;
3371
3837
  }
3372
3838
 
3373
- if (command.name === "review" || command.name === "subagents") {
3374
- const prompt = command.name === "review"
3375
- ? args || "Review the current local changes."
3376
- : 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
+ }
3853
+ await this.sendPrompt({
3854
+ conversationId: conversation.id,
3855
+ clientMessageId: id(command.name),
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") {
3377
3866
  await this.sendPrompt({
3378
3867
  conversationId: conversation.id,
3379
3868
  clientMessageId: id(command.name),
3380
- contentBlocks: [{ type: "text", text: prompt }],
3869
+ contentBlocks: [{ type: "text", text: args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results." }],
3381
3870
  model: conversation.model,
3382
3871
  reasoningEffort: conversation.reasoningEffort,
3383
3872
  permissionMode: conversation.permissionMode,
@@ -3419,8 +3908,72 @@ export class AgentWorkspaceProxy {
3419
3908
  return {};
3420
3909
  }
3421
3910
 
3422
- // Merge MCP server descriptors into a provider's runtime capabilities,
3423
- // 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
+
3424
3977
  private mergeMcpServers(provider: AgentProvider, servers: AgentMcpServerDescriptor[]): void {
3425
3978
  if (servers.length === 0) return;
3426
3979
  const existing = this.providerCapabilities.get(provider) ?? {};
@@ -3445,7 +3998,7 @@ export class AgentWorkspaceProxy {
3445
3998
  if (!name) return;
3446
3999
  this.mergeMcpServers(provider, [{
3447
4000
  name,
3448
- status: normalizeMcpStatus(raw.status ?? raw.state),
4001
+ status: mcpStatusFromRecord(raw),
3449
4002
  error: firstString(raw, ["error", "message", "reason"]),
3450
4003
  toolCount: firstNumber(raw, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(raw.tools) ? raw.tools.length : undefined),
3451
4004
  }]);
@@ -3497,11 +4050,11 @@ export class AgentWorkspaceProxy {
3497
4050
  this.handleMcpStartupStatus(method, params);
3498
4051
  return;
3499
4052
  }
3500
- if (
3501
- method.startsWith("account/") ||
3502
- method === "serverRequest/resolved" ||
3503
- method === "mcpServer/oauthLogin/completed"
3504
- ) {
4053
+ if (method === "mcpServer/oauthLogin/completed") {
4054
+ this.handleMcpOAuthCompleted(params);
4055
+ return;
4056
+ }
4057
+ if (method.startsWith("account/") || method === "serverRequest/resolved") {
3505
4058
  return;
3506
4059
  }
3507
4060
 
@@ -3536,6 +4089,45 @@ export class AgentWorkspaceProxy {
3536
4089
  }
3537
4090
  return;
3538
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
+ }
3539
4131
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
3540
4132
  this.handleStructuredInput(params);
3541
4133
  return;
@@ -3596,6 +4188,13 @@ export class AgentWorkspaceProxy {
3596
4188
  case "item/agentMessage/delta":
3597
4189
  this.handleAgentMessageDelta(params);
3598
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;
3599
4198
  case "turn/plan/updated":
3600
4199
  this.handlePlanUpdated(params);
3601
4200
  return;
@@ -3659,10 +4258,68 @@ export class AgentWorkspaceProxy {
3659
4258
  updatedAt: Date.now(),
3660
4259
  isStreaming: true,
3661
4260
  };
3662
- this.upsertItem(conversationId, item);
4261
+ this.applyStreamingTextDelta(conversationId, item, existing, delta);
3663
4262
  this.updateConversationPreview(conversationId, text, "running");
3664
4263
  }
3665
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
+
3666
4323
  private handlePlanUpdated(params: unknown): void {
3667
4324
  const raw = asRecord(params);
3668
4325
  const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
@@ -4133,20 +4790,14 @@ export class AgentWorkspaceProxy {
4133
4790
  const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
4134
4791
  const rawToolCall = asRecord(raw.toolCall) ?? raw;
4135
4792
  // Codex app-server command/file approvals carry `command`/`reason`/`cwd`
4136
- // (not toolCall/context). Surface them so the card actually shows what's
4137
- // being approved; fall back to the Claude/MCP shape otherwise.
4138
- const codexCommand = firstString(raw, ["command"]);
4139
- 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.
4140
4795
  const permission: AgentPermission = {
4141
4796
  requestId,
4142
- toolName:
4143
- firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]) ??
4144
- (codexCommand ? "shell" : undefined),
4145
- toolInput: codexCommand
4146
- ? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
4147
- : stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
4797
+ toolName: permissionToolName(raw, rawToolCall),
4798
+ toolInput: permissionToolInput(raw, rawToolCall),
4148
4799
  context: firstString(raw, ["context", "description", "message", "title", "reason"]),
4149
- options: parsePermissionOptions(raw.options),
4800
+ options: permissionOptionsFromRequest(raw, source),
4150
4801
  };
4151
4802
  this.pendingPermissions.set(requestId, permission);
4152
4803
  if (source) this.permissionSources.set(requestId, source);
@@ -4458,6 +5109,23 @@ export class AgentWorkspaceProxy {
4458
5109
  }));
4459
5110
  }
4460
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
+
4461
5129
  private emitConversation(conversation: AgentConversation): void {
4462
5130
  this.input.send(createEnvelope({
4463
5131
  type: "agent.v2.event",
@@ -4845,6 +5513,84 @@ export class AgentWorkspaceProxy {
4845
5513
  }
4846
5514
  }
4847
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
+
4848
5594
  function parsePermissionOptions(value: unknown): AgentPermission["options"] {
4849
5595
  if (!Array.isArray(value)) {
4850
5596
  return [
@@ -4853,19 +5599,27 @@ function parsePermissionOptions(value: unknown): AgentPermission["options"] {
4853
5599
  ];
4854
5600
  }
4855
5601
 
5602
+ const official = parseOfficialDecisions(value);
5603
+ if (official.length > 0 && value.every((entry) => officialDecisionOption(officialDecisionKey(entry) ?? ""))) {
5604
+ return official;
5605
+ }
5606
+
4856
5607
  const options = value
4857
5608
  .map((entry, index) => {
4858
5609
  const raw = asRecord(entry) ?? {};
4859
- const idValue = raw.optionId ?? raw.id ?? raw.kind ?? `option-${index + 1}`;
4860
- 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);
4861
5612
  const id = String(idValue);
4862
5613
  const label = String(labelValue);
5614
+ const official = officialDecisionOption(id);
5615
+ if (official) return { ...official, id, label: official.label === "允许一次" && label !== id ? label : official.label };
4863
5616
  const normalized = `${id} ${label}`.toLowerCase();
4864
- const kind: AgentPermission["options"][number]["kind"] = normalized.includes("reject") || normalized.includes("deny")
4865
- ? "deny"
4866
- : normalized.includes("allow")
4867
- ? "allow"
4868
- : "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";
4869
5623
  return { id, label, kind };
4870
5624
  })
4871
5625
  .filter((option) => option.id.length > 0 && option.label.length > 0);
@@ -4878,6 +5632,38 @@ function parsePermissionOptions(value: unknown): AgentPermission["options"] {
4878
5632
  ];
4879
5633
  }
4880
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
+
4881
5667
  function selectPermissionOption(
4882
5668
  permission: AgentPermission | undefined,
4883
5669
  outcome: "allow" | "deny" | "cancelled",
@@ -4972,7 +5758,10 @@ function formatPermissionResponse(
4972
5758
  optionId: string,
4973
5759
  ): unknown {
4974
5760
  if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
4975
- 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" };
4976
5765
  }
4977
5766
  if (source === "item/permissions/requestApproval") {
4978
5767
  if (outcome === "allow") {