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.
- package/dist/cli/src/runtime/acp/acp-client.d.ts +38 -0
- package/dist/cli/src/runtime/acp/acp-client.js +134 -20
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +48 -0
- package/dist/cli/src/runtime/acp/agent-process-discovery.js +267 -0
- package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +33 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +873 -92
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +6 -0
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +51 -29
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/provider-resolver.d.ts +3 -1
- package/dist/cli/src/runtime/acp/provider-resolver.js +13 -4
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +1 -0
- package/dist/cli/src/runtime/bridge-session.js +14 -3
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +1161 -939
- package/dist/shared-protocol/src/index.js +43 -1
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/runtime/acp/acp-client.ts +153 -22
- package/src/runtime/acp/agent-process-discovery.ts +301 -0
- package/src/runtime/acp/agent-workspace.ts +887 -98
- package/src/runtime/acp/claude-sdk-client.ts +51 -22
- package/src/runtime/acp/provider-resolver.ts +18 -3
- package/src/runtime/bridge-session.ts +12 -3
- package/web/assets/{FileBrowser-B1vcZ0wW.js → FileBrowser-Dn_avjeH.js} +1 -1
- package/web/assets/{PortPreview-CugEjE0m.js → PortPreview-KYkYV_-p.js} +1 -1
- package/web/assets/{TerminalPanel-DgSFo-6s.js → TerminalPanel-BjxX8Jui.js} +1 -1
- package/web/assets/{UsageDashboard-DTRkZRsh.js → UsageDashboard-cYiZs8c8.js} +1 -1
- package/web/assets/index-6vyVf_xQ.js +35 -0
- package/web/assets/index-Ct5fhCTS.css +33 -0
- package/web/index.html +2 -2
- package/web/assets/index-ByEwVA14.js +0 -35
- 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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
1632
|
-
// (
|
|
1633
|
-
//
|
|
1634
|
-
|
|
1635
|
-
const status
|
|
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
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
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
|
-
|
|
1991
|
-
//
|
|
1992
|
-
//
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
this.
|
|
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
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
2448
|
-
?
|
|
2449
|
-
: "
|
|
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
|
|
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
|
|
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
|
|
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 : "
|
|
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
|
|
2752
|
-
//
|
|
2753
|
-
//
|
|
2754
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
3374
|
-
const
|
|
3375
|
-
|
|
3376
|
-
|
|
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:
|
|
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
|
-
|
|
3423
|
-
|
|
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:
|
|
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
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
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.
|
|
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).
|
|
4137
|
-
//
|
|
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
|
-
|
|
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:
|
|
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"] =
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
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
|
-
|
|
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") {
|