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
|
@@ -7,6 +7,7 @@ import { ClaudeSdkClient } from "./claude-sdk-client.js";
|
|
|
7
7
|
import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
|
|
8
8
|
import { aggregateUsage } from "../usage-report.js";
|
|
9
9
|
import { resolveAgentCommand } from "./provider-resolver.js";
|
|
10
|
+
import { agentProviderLabel, discoverAgentProcessesAsync, isLiveConversationId, isProtocolAgentProvider, liveConversationId, } from "./agent-process-discovery.js";
|
|
10
11
|
// Normalize the many status spellings Claude/Codex emit into our enum. Unknown
|
|
11
12
|
// values fall back to "pending" (known-but-not-yet-up) rather than dropping the
|
|
12
13
|
// server. Kept liberal because the real event vocabulary isn't fully pinned.
|
|
@@ -24,6 +25,19 @@ function normalizeMcpStatus(raw) {
|
|
|
24
25
|
return "disabled";
|
|
25
26
|
return "pending";
|
|
26
27
|
}
|
|
28
|
+
function mcpStatusFromRecord(rec) {
|
|
29
|
+
if (normalizeMcpStatus(rec.authStatus ?? rec.auth) === "needs_auth")
|
|
30
|
+
return "needs_auth";
|
|
31
|
+
return normalizeMcpStatus(rec.status ?? rec.state ?? rec.authStatus);
|
|
32
|
+
}
|
|
33
|
+
function mcpListFromProviderResult(value) {
|
|
34
|
+
const raw = asRecord(value);
|
|
35
|
+
const list = Array.isArray(value) ? value :
|
|
36
|
+
Array.isArray(raw?.data) ? raw.data :
|
|
37
|
+
Array.isArray(raw?.servers) ? raw.servers :
|
|
38
|
+
value;
|
|
39
|
+
return parseMcpServerList(list);
|
|
40
|
+
}
|
|
27
41
|
// Parse an MCP server list from either an array of {name,status,...} objects or
|
|
28
42
|
// a {name: status} record — Claude's init `mcp_servers` shape isn't fixed.
|
|
29
43
|
function parseMcpServerList(value) {
|
|
@@ -37,7 +51,7 @@ function parseMcpServerList(value) {
|
|
|
37
51
|
continue;
|
|
38
52
|
out.push({
|
|
39
53
|
name,
|
|
40
|
-
status:
|
|
54
|
+
status: mcpStatusFromRecord(rec),
|
|
41
55
|
error: firstString(rec, ["error", "message", "reason"]),
|
|
42
56
|
toolCount: firstNumber(rec, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(rec.tools) ? rec.tools.length : undefined),
|
|
43
57
|
});
|
|
@@ -56,7 +70,7 @@ function parseMcpServerList(value) {
|
|
|
56
70
|
const inner = asRecord(raw);
|
|
57
71
|
out.push({
|
|
58
72
|
name,
|
|
59
|
-
status:
|
|
73
|
+
status: inner ? mcpStatusFromRecord(inner) : normalizeMcpStatus(raw),
|
|
60
74
|
error: inner ? firstString(inner, ["error", "message", "reason"]) : undefined,
|
|
61
75
|
toolCount: inner ? (firstNumber(inner, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(inner.tools) ? inner.tools.length : undefined)) : undefined,
|
|
62
76
|
});
|
|
@@ -232,6 +246,47 @@ function normalizePlanStatus(value) {
|
|
|
232
246
|
return "in_progress";
|
|
233
247
|
return "pending";
|
|
234
248
|
}
|
|
249
|
+
function applyThreadSettingsToConversation(conversation, value) {
|
|
250
|
+
const raw = asRecord(value);
|
|
251
|
+
const settings = asRecord(raw?.threadSettings) ?? asRecord(raw?.settings) ?? raw;
|
|
252
|
+
if (!settings)
|
|
253
|
+
return;
|
|
254
|
+
const model = firstString(settings, ["model", "modelId", "currentModel"]);
|
|
255
|
+
if (model)
|
|
256
|
+
conversation.model = model;
|
|
257
|
+
const effort = firstString(settings, ["effort", "reasoningEffort", "reasoning_effort"])
|
|
258
|
+
?? firstString(asRecord(asRecord(settings.collaborationMode ?? settings.collaboration_mode)?.settings), ["reasoning_effort", "reasoningEffort"]);
|
|
259
|
+
if (effort)
|
|
260
|
+
conversation.reasoningEffort = effort;
|
|
261
|
+
const collab = asRecord(settings.collaborationMode ?? settings.collaboration_mode);
|
|
262
|
+
const mode = typeof settings.collaborationMode === "string"
|
|
263
|
+
? settings.collaborationMode
|
|
264
|
+
: typeof settings.collaboration_mode === "string"
|
|
265
|
+
? settings.collaboration_mode
|
|
266
|
+
: firstString(collab, ["mode"]);
|
|
267
|
+
if (mode === "plan")
|
|
268
|
+
conversation.collaborationMode = "plan";
|
|
269
|
+
else if (mode === "default")
|
|
270
|
+
conversation.collaborationMode = "default";
|
|
271
|
+
const permission = firstString(settings, ["permissionMode", "permission_mode"]);
|
|
272
|
+
if (permission === "read_only" || permission === "workspace_write" || permission === "full_access") {
|
|
273
|
+
conversation.permissionMode = permission;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const sandboxValue = settings.sandboxPolicy ?? settings.sandbox_policy;
|
|
277
|
+
const sandbox = asRecord(sandboxValue);
|
|
278
|
+
const sandboxType = typeof sandboxValue === "string" ? sandboxValue : firstString(sandbox, ["type"]);
|
|
279
|
+
if (!sandboxType)
|
|
280
|
+
return;
|
|
281
|
+
const norm = sandboxType.replace(/-/g, "").toLowerCase();
|
|
282
|
+
conversation.permissionMode = norm.includes("readonly")
|
|
283
|
+
? "read_only"
|
|
284
|
+
: norm.includes("workspace")
|
|
285
|
+
? "workspace_write"
|
|
286
|
+
: norm.includes("danger") || norm.includes("full")
|
|
287
|
+
? "full_access"
|
|
288
|
+
: conversation.permissionMode;
|
|
289
|
+
}
|
|
235
290
|
function agentStatusFromThreadStatus(value) {
|
|
236
291
|
const raw = asRecord(value);
|
|
237
292
|
const status = typeof value === "string"
|
|
@@ -697,11 +752,7 @@ function previewText(text) {
|
|
|
697
752
|
return text.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
698
753
|
}
|
|
699
754
|
function providerLabel(provider) {
|
|
700
|
-
|
|
701
|
-
return "Codex";
|
|
702
|
-
if (provider === "claude")
|
|
703
|
-
return "Claude";
|
|
704
|
-
return "Custom";
|
|
755
|
+
return agentProviderLabel(provider);
|
|
705
756
|
}
|
|
706
757
|
const ALL_REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
|
|
707
758
|
const ALL_PERMISSION_MODES = ["read_only", "workspace_write", "full_access"];
|
|
@@ -1058,6 +1109,28 @@ function mergeCommands(...groups) {
|
|
|
1058
1109
|
}
|
|
1059
1110
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1060
1111
|
}
|
|
1112
|
+
function parseSkillCommands(provider, value) {
|
|
1113
|
+
const raw = asRecord(value);
|
|
1114
|
+
const groups = Array.isArray(value) ? value :
|
|
1115
|
+
Array.isArray(raw?.data) ? raw.data :
|
|
1116
|
+
Array.isArray(raw?.skills) ? [raw] :
|
|
1117
|
+
[];
|
|
1118
|
+
const skills = [];
|
|
1119
|
+
for (const group of groups) {
|
|
1120
|
+
if (typeof group === "string" && group) {
|
|
1121
|
+
skills.push(group);
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
const rec = asRecord(group);
|
|
1125
|
+
if (!rec)
|
|
1126
|
+
continue;
|
|
1127
|
+
if (Array.isArray(rec.skills))
|
|
1128
|
+
skills.push(...rec.skills);
|
|
1129
|
+
else if (firstString(rec, ["name", "command", "id"]))
|
|
1130
|
+
skills.push(group);
|
|
1131
|
+
}
|
|
1132
|
+
return runtimeCommands(provider, skills);
|
|
1133
|
+
}
|
|
1061
1134
|
function runtimeCommands(provider, value) {
|
|
1062
1135
|
const raw = asRecord(value);
|
|
1063
1136
|
const commandsValue = Array.isArray(value) ? value :
|
|
@@ -1390,11 +1463,11 @@ function parseRemoteSessions(value) {
|
|
|
1390
1463
|
const id = firstString(source, ["id", "threadId", "sessionId", "agentSessionId"]);
|
|
1391
1464
|
if (!id)
|
|
1392
1465
|
continue;
|
|
1393
|
-
//
|
|
1394
|
-
// (
|
|
1395
|
-
//
|
|
1396
|
-
|
|
1397
|
-
const status =
|
|
1466
|
+
// Claude reports "running"/"idle" strings; Codex thread/list uses a tagged
|
|
1467
|
+
// object ({ type: "active"|"idle"|"notLoaded"|"systemError" }). Both map
|
|
1468
|
+
// through the same helper. Unknown / notLoaded stay undefined so the sync
|
|
1469
|
+
// site keeps its own status.
|
|
1470
|
+
const status = agentStatusFromThreadStatus(source.status);
|
|
1398
1471
|
result.push({
|
|
1399
1472
|
id,
|
|
1400
1473
|
cwd: firstString(source, ["cwd", "workingDirectory", "workspacePath"]),
|
|
@@ -1664,6 +1737,9 @@ function previewFromTimelineItem(item) {
|
|
|
1664
1737
|
export class AgentWorkspaceProxy {
|
|
1665
1738
|
input;
|
|
1666
1739
|
clients = new Map();
|
|
1740
|
+
// In-flight client starts, so concurrent ensureProviderClient calls (eager
|
|
1741
|
+
// initialize() racing conversation.open) share one subprocess spawn.
|
|
1742
|
+
clientStartPromises = new Map();
|
|
1667
1743
|
agentProtocols = new Map();
|
|
1668
1744
|
providerCapabilities = new Map();
|
|
1669
1745
|
providerErrors = new Map();
|
|
@@ -1676,10 +1752,9 @@ export class AgentWorkspaceProxy {
|
|
|
1676
1752
|
conversations = new Map();
|
|
1677
1753
|
conversationByAgentSessionId = new Map();
|
|
1678
1754
|
timelines = new Map();
|
|
1679
|
-
// Conversations the user
|
|
1680
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
// forgotten conversation stays gone for the life of this workspace process.
|
|
1755
|
+
// Conversations the user forgot from the LinkShell list. Tombstone by
|
|
1756
|
+
// agentSessionId so syncProviderSessions cannot resurrect them. This is
|
|
1757
|
+
// NOT Codex thread/delete (that hard-deletes the on-disk rollout).
|
|
1683
1758
|
deletedAgentSessionIds = new Set();
|
|
1684
1759
|
// Opaque codex turns cursor per conversation, pointing at OLDER history to
|
|
1685
1760
|
// page through (captured at hydration, advanced on each history.request).
|
|
@@ -1692,6 +1767,12 @@ export class AgentWorkspaceProxy {
|
|
|
1692
1767
|
structuredInputWaiters = new Map();
|
|
1693
1768
|
itemConversationIds = new Map();
|
|
1694
1769
|
toolConversationIds = new Map();
|
|
1770
|
+
processPollTimer;
|
|
1771
|
+
processPollInFlight = false;
|
|
1772
|
+
processMissCounts = new Map();
|
|
1773
|
+
lastDiscoveredProviders = new Set();
|
|
1774
|
+
static PROCESS_POLL_MS = 3_000;
|
|
1775
|
+
static PROCESS_MISS_LIMIT = 2;
|
|
1695
1776
|
constructor(input) {
|
|
1696
1777
|
this.input = input;
|
|
1697
1778
|
}
|
|
@@ -1712,36 +1793,89 @@ export class AgentWorkspaceProxy {
|
|
|
1712
1793
|
const conversation = this.conversations.get(payload.conversationId);
|
|
1713
1794
|
if (!conversation)
|
|
1714
1795
|
break;
|
|
1796
|
+
const raw = asRecord(envelope.payload) ?? {};
|
|
1797
|
+
const model = payload.model ?? (typeof raw.model === "string" ? raw.model : undefined);
|
|
1798
|
+
const reasoningEffort = payload.reasoningEffort
|
|
1799
|
+
?? (typeof raw.reasoningEffort === "string" ? raw.reasoningEffort : undefined);
|
|
1800
|
+
const permissionMode = payload.permissionMode
|
|
1801
|
+
?? (raw.permissionMode === "read_only" || raw.permissionMode === "workspace_write" || raw.permissionMode === "full_access"
|
|
1802
|
+
? raw.permissionMode
|
|
1803
|
+
: undefined);
|
|
1804
|
+
const collaborationMode = payload.collaborationMode
|
|
1805
|
+
?? (raw.collaborationMode === "default" || raw.collaborationMode === "plan"
|
|
1806
|
+
? raw.collaborationMode
|
|
1807
|
+
: undefined);
|
|
1715
1808
|
if (payload.title !== undefined) {
|
|
1716
1809
|
const trimmed = payload.title.trim();
|
|
1717
1810
|
conversation.title = trimmed === "" ? undefined : trimmed;
|
|
1718
1811
|
}
|
|
1719
1812
|
if (payload.archived !== undefined)
|
|
1720
1813
|
conversation.archived = payload.archived;
|
|
1814
|
+
if (model !== undefined) {
|
|
1815
|
+
const trimmed = model.trim();
|
|
1816
|
+
if (trimmed)
|
|
1817
|
+
conversation.model = trimmed;
|
|
1818
|
+
}
|
|
1819
|
+
if (reasoningEffort !== undefined)
|
|
1820
|
+
conversation.reasoningEffort = reasoningEffort;
|
|
1821
|
+
if (permissionMode !== undefined)
|
|
1822
|
+
conversation.permissionMode = permissionMode;
|
|
1823
|
+
if (collaborationMode !== undefined)
|
|
1824
|
+
conversation.collaborationMode = collaborationMode;
|
|
1721
1825
|
conversation.lastActivityAt = Date.now();
|
|
1722
1826
|
// Echo the updated record so every client refreshes (the web store
|
|
1723
1827
|
// force-replaces by id on any event carrying `conversation`).
|
|
1724
1828
|
this.emitConversation(conversation);
|
|
1829
|
+
if (conversation.provider === "codex" && conversation.agentSessionId) {
|
|
1830
|
+
const client = this.clientForProvider(conversation.provider);
|
|
1831
|
+
const settingsChanged = model !== undefined ||
|
|
1832
|
+
reasoningEffort !== undefined ||
|
|
1833
|
+
permissionMode !== undefined ||
|
|
1834
|
+
collaborationMode !== undefined;
|
|
1835
|
+
try {
|
|
1836
|
+
const acp = client;
|
|
1837
|
+
if (payload.title !== undefined && typeof acp?.setThreadName === "function") {
|
|
1838
|
+
await acp.setThreadName({
|
|
1839
|
+
sessionId: conversation.agentSessionId,
|
|
1840
|
+
name: conversation.title ?? "",
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
if (payload.archived === true && typeof acp?.archiveThread === "function") {
|
|
1844
|
+
await acp.archiveThread({ sessionId: conversation.agentSessionId });
|
|
1845
|
+
}
|
|
1846
|
+
else if (payload.archived === false && typeof acp?.unarchiveThread === "function") {
|
|
1847
|
+
await acp.unarchiveThread({ sessionId: conversation.agentSessionId });
|
|
1848
|
+
}
|
|
1849
|
+
if (settingsChanged && typeof acp?.updateThreadSettings === "function") {
|
|
1850
|
+
await acp.updateThreadSettings({
|
|
1851
|
+
sessionId: conversation.agentSessionId,
|
|
1852
|
+
model,
|
|
1853
|
+
reasoningEffort,
|
|
1854
|
+
permissionMode,
|
|
1855
|
+
collaborationMode,
|
|
1856
|
+
cwd: conversation.cwd,
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
catch (error) {
|
|
1861
|
+
if (this.input.verbose) {
|
|
1862
|
+
process.stderr.write(`[agent:v2] conversation update provider call failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1725
1866
|
break;
|
|
1726
1867
|
}
|
|
1727
1868
|
case "agent.v2.conversation.delete": {
|
|
1728
1869
|
const payload = parseTypedPayload("agent.v2.conversation.delete", envelope.payload);
|
|
1729
|
-
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
this.
|
|
1738
|
-
this.timelines.delete(payload.conversationId);
|
|
1739
|
-
this.historyCursors.delete(payload.conversationId);
|
|
1740
|
-
this.input.send(createEnvelope({
|
|
1741
|
-
type: "agent.v2.conversation.deleted",
|
|
1742
|
-
sessionId: this.input.sessionId,
|
|
1743
|
-
payload: { conversationId: payload.conversationId },
|
|
1744
|
-
}));
|
|
1870
|
+
// Forget from LinkShell only. Never call Codex thread/delete here —
|
|
1871
|
+
// that hard-deletes the on-disk rollout. Archive is the destructive-ish
|
|
1872
|
+
// user action; this menu item is "hide from the tree".
|
|
1873
|
+
this.forgetConversationLocally(payload.conversationId);
|
|
1874
|
+
break;
|
|
1875
|
+
}
|
|
1876
|
+
case "agent.v2.mcp.login": {
|
|
1877
|
+
const payload = parseTypedPayload("agent.v2.mcp.login", envelope.payload);
|
|
1878
|
+
await this.startMcpLogin(payload);
|
|
1745
1879
|
break;
|
|
1746
1880
|
}
|
|
1747
1881
|
case "agent.v2.conversation.list": {
|
|
@@ -1814,6 +1948,10 @@ export class AgentWorkspaceProxy {
|
|
|
1814
1948
|
}
|
|
1815
1949
|
}
|
|
1816
1950
|
stop() {
|
|
1951
|
+
if (this.processPollTimer) {
|
|
1952
|
+
clearInterval(this.processPollTimer);
|
|
1953
|
+
this.processPollTimer = undefined;
|
|
1954
|
+
}
|
|
1817
1955
|
for (const watcher of this.fsWatchers) {
|
|
1818
1956
|
try {
|
|
1819
1957
|
watcher.close();
|
|
@@ -1844,7 +1982,9 @@ export class AgentWorkspaceProxy {
|
|
|
1844
1982
|
fsSyncTimer;
|
|
1845
1983
|
fsSyncRunning = false;
|
|
1846
1984
|
lastPushedConversationSignature;
|
|
1985
|
+
lastHydrateAt = new Map();
|
|
1847
1986
|
static FS_SYNC_THROTTLE_MS = 1500;
|
|
1987
|
+
static EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
|
|
1848
1988
|
startSessionWatchers() {
|
|
1849
1989
|
const roots = [
|
|
1850
1990
|
join(homedir(), ".claude", "projects"),
|
|
@@ -1898,17 +2038,40 @@ export class AgentWorkspaceProxy {
|
|
|
1898
2038
|
// and client re-renders. A cheap signature over the render-relevant fields
|
|
1899
2039
|
// gates the push.
|
|
1900
2040
|
const signature = this.conversationListSignature(conversations);
|
|
1901
|
-
if (signature
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
}
|
|
2041
|
+
if (signature !== this.lastPushedConversationSignature) {
|
|
2042
|
+
this.lastPushedConversationSignature = signature;
|
|
2043
|
+
// Push the conversation list (statuses) — lighter than a full snapshot and
|
|
2044
|
+
// exactly what refreshes the tree. The web handles an unsolicited
|
|
2045
|
+
// conversation.list.result the same as a requested one.
|
|
2046
|
+
this.input.send(createEnvelope({
|
|
2047
|
+
type: "agent.v2.conversation.list.result",
|
|
2048
|
+
sessionId: this.input.sessionId,
|
|
2049
|
+
payload: { conversations },
|
|
2050
|
+
}));
|
|
2051
|
+
}
|
|
2052
|
+
// External sessions (status running / waiting_permission but LinkShell is
|
|
2053
|
+
// not driving the turn) have no live event stream on this app-server.
|
|
2054
|
+
// Re-hydrate on a short throttle so the Web timeline still moves.
|
|
2055
|
+
const now = Date.now();
|
|
2056
|
+
for (const conversation of conversations) {
|
|
2057
|
+
if (conversation.status !== "running" && conversation.status !== "waiting_permission")
|
|
2058
|
+
continue;
|
|
2059
|
+
if (this.currentTurnIds.has(conversation.id))
|
|
2060
|
+
continue;
|
|
2061
|
+
const last = this.lastHydrateAt.get(conversation.id) ?? 0;
|
|
2062
|
+
if (now - last < AgentWorkspaceProxy.EXTERNAL_HYDRATE_THROTTLE_MS)
|
|
2063
|
+
continue;
|
|
2064
|
+
this.lastHydrateAt.set(conversation.id, now);
|
|
2065
|
+
try {
|
|
2066
|
+
await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
|
|
2067
|
+
this.sendSnapshot(conversation.id);
|
|
2068
|
+
}
|
|
2069
|
+
catch (error) {
|
|
2070
|
+
if (this.input.verbose) {
|
|
2071
|
+
process.stderr.write(`[agent:v2] external hydrate failed for ${conversation.id}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
1912
2075
|
}
|
|
1913
2076
|
catch (error) {
|
|
1914
2077
|
if (this.input.verbose) {
|
|
@@ -1926,7 +2089,7 @@ export class AgentWorkspaceProxy {
|
|
|
1926
2089
|
* membership are what matter. */
|
|
1927
2090
|
conversationListSignature(conversations) {
|
|
1928
2091
|
return conversations
|
|
1929
|
-
.map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}`)
|
|
2092
|
+
.map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}${c.lastMessagePreview ?? ""}`)
|
|
1930
2093
|
.sort()
|
|
1931
2094
|
.join("");
|
|
1932
2095
|
}
|
|
@@ -1978,11 +2141,183 @@ export class AgentWorkspaceProxy {
|
|
|
1978
2141
|
// Start watching the on-disk session stores so externally-started sessions
|
|
1979
2142
|
// and status changes surface live, not just at connect time.
|
|
1980
2143
|
this.startSessionWatchers();
|
|
2144
|
+
this.startProcessDiscovery();
|
|
2145
|
+
}
|
|
2146
|
+
startProcessDiscovery() {
|
|
2147
|
+
void this.pollDiscoveredProcesses();
|
|
2148
|
+
if (this.processPollTimer)
|
|
2149
|
+
return;
|
|
2150
|
+
this.processPollTimer = setInterval(() => {
|
|
2151
|
+
void this.pollDiscoveredProcesses();
|
|
2152
|
+
}, AgentWorkspaceProxy.PROCESS_POLL_MS);
|
|
2153
|
+
this.processPollTimer.unref?.();
|
|
2154
|
+
}
|
|
2155
|
+
async pollDiscoveredProcesses() {
|
|
2156
|
+
if (this.processPollInFlight)
|
|
2157
|
+
return;
|
|
2158
|
+
this.processPollInFlight = true;
|
|
2159
|
+
try {
|
|
2160
|
+
const snapshots = this.input.discoverProcesses
|
|
2161
|
+
? this.input.discoverProcesses()
|
|
2162
|
+
: await discoverAgentProcessesAsync();
|
|
2163
|
+
this.applyDiscoveredProcesses(snapshots);
|
|
2164
|
+
}
|
|
2165
|
+
catch {
|
|
2166
|
+
// A failed ps scan must not tear down the workspace or hitch the PTY.
|
|
2167
|
+
}
|
|
2168
|
+
finally {
|
|
2169
|
+
this.processPollInFlight = false;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
/** Open Island rule: a visible agent session is a live process. Two missed
|
|
2173
|
+
* polls (~6s) drop the running signal so a killed CLI does not linger. */
|
|
2174
|
+
reconcileDiscoveredProcesses() {
|
|
2175
|
+
let snapshots = [];
|
|
2176
|
+
try {
|
|
2177
|
+
snapshots = this.input.discoverProcesses?.() ?? [];
|
|
2178
|
+
}
|
|
2179
|
+
catch {
|
|
2180
|
+
snapshots = [];
|
|
2181
|
+
}
|
|
2182
|
+
this.applyDiscoveredProcesses(snapshots);
|
|
2183
|
+
}
|
|
2184
|
+
applyDiscoveredProcesses(snapshots) {
|
|
2185
|
+
const seenKeys = new Set();
|
|
2186
|
+
const discoveredProviders = new Set();
|
|
2187
|
+
let listChanged = false;
|
|
2188
|
+
for (const snapshot of snapshots) {
|
|
2189
|
+
discoveredProviders.add(snapshot.provider);
|
|
2190
|
+
const key = snapshot.sessionId
|
|
2191
|
+
? `${snapshot.provider}:${snapshot.sessionId}`
|
|
2192
|
+
: `${snapshot.provider}:${snapshot.pid}`;
|
|
2193
|
+
seenKeys.add(key);
|
|
2194
|
+
this.processMissCounts.set(key, 0);
|
|
2195
|
+
const existingId = snapshot.sessionId
|
|
2196
|
+
? this.conversationByAgentSessionId.get(snapshot.sessionId)
|
|
2197
|
+
: undefined;
|
|
2198
|
+
const liveId = liveConversationId(snapshot);
|
|
2199
|
+
const conversationId = existingId ?? liveId;
|
|
2200
|
+
const existing = this.conversations.get(conversationId);
|
|
2201
|
+
const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
|
|
2202
|
+
const driving = this.currentTurnIds.has(conversationId);
|
|
2203
|
+
const status = driving
|
|
2204
|
+
? existing?.status ?? "running"
|
|
2205
|
+
: existing?.status === "waiting_permission"
|
|
2206
|
+
? "waiting_permission"
|
|
2207
|
+
: "running";
|
|
2208
|
+
if (existing) {
|
|
2209
|
+
const changed = existing.status !== status || existing.cwd !== cwd || !existing.title;
|
|
2210
|
+
existing.status = status;
|
|
2211
|
+
existing.cwd = cwd;
|
|
2212
|
+
if (!existing.title)
|
|
2213
|
+
existing.title = agentProviderLabel(snapshot.provider);
|
|
2214
|
+
if (snapshot.sessionId && !existing.agentSessionId) {
|
|
2215
|
+
existing.agentSessionId = snapshot.sessionId;
|
|
2216
|
+
this.conversationByAgentSessionId.set(snapshot.sessionId, existing.id);
|
|
2217
|
+
}
|
|
2218
|
+
if (changed) {
|
|
2219
|
+
listChanged = true;
|
|
2220
|
+
this.emitConversation(existing);
|
|
2221
|
+
}
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
2224
|
+
const now = Date.now();
|
|
2225
|
+
const conversation = {
|
|
2226
|
+
id: conversationId,
|
|
2227
|
+
agentSessionId: snapshot.sessionId,
|
|
2228
|
+
provider: snapshot.provider,
|
|
2229
|
+
cwd,
|
|
2230
|
+
title: agentProviderLabel(snapshot.provider),
|
|
2231
|
+
status,
|
|
2232
|
+
archived: false,
|
|
2233
|
+
lastMessagePreview: "本机终端中的 Agent 进程",
|
|
2234
|
+
lastActivityAt: now,
|
|
2235
|
+
createdAt: now,
|
|
2236
|
+
};
|
|
2237
|
+
this.conversations.set(conversation.id, conversation);
|
|
2238
|
+
if (snapshot.sessionId)
|
|
2239
|
+
this.conversationByAgentSessionId.set(snapshot.sessionId, conversation.id);
|
|
2240
|
+
this.emitConversation(conversation);
|
|
2241
|
+
listChanged = true;
|
|
2242
|
+
}
|
|
2243
|
+
for (const [key, misses] of this.processMissCounts) {
|
|
2244
|
+
if (seenKeys.has(key))
|
|
2245
|
+
continue;
|
|
2246
|
+
const next = misses + 1;
|
|
2247
|
+
this.processMissCounts.set(key, next);
|
|
2248
|
+
if (next < AgentWorkspaceProxy.PROCESS_MISS_LIMIT)
|
|
2249
|
+
continue;
|
|
2250
|
+
this.processMissCounts.delete(key);
|
|
2251
|
+
const [provider, idPart] = key.split(":");
|
|
2252
|
+
if (!provider || !idPart)
|
|
2253
|
+
continue;
|
|
2254
|
+
const conversation = [...this.conversations.values()].find((item) => {
|
|
2255
|
+
if (item.provider !== provider)
|
|
2256
|
+
return false;
|
|
2257
|
+
if (item.agentSessionId === idPart)
|
|
2258
|
+
return true;
|
|
2259
|
+
return item.id === `agent-live-${provider}-${idPart}`;
|
|
2260
|
+
});
|
|
2261
|
+
if (!conversation)
|
|
2262
|
+
continue;
|
|
2263
|
+
if (this.currentTurnIds.has(conversation.id))
|
|
2264
|
+
continue;
|
|
2265
|
+
if (conversation.status === "waiting_permission")
|
|
2266
|
+
continue;
|
|
2267
|
+
if (conversation.status === "running") {
|
|
2268
|
+
conversation.status = "idle";
|
|
2269
|
+
conversation.lastActivityAt = Date.now();
|
|
2270
|
+
this.emitConversation(conversation);
|
|
2271
|
+
listChanged = true;
|
|
2272
|
+
}
|
|
2273
|
+
if (isLiveConversationId(conversation.id) && !conversation.agentSessionId) {
|
|
2274
|
+
this.conversations.delete(conversation.id);
|
|
2275
|
+
this.timelines.delete(conversation.id);
|
|
2276
|
+
this.input.send(createEnvelope({
|
|
2277
|
+
type: "agent.v2.conversation.deleted",
|
|
2278
|
+
sessionId: this.input.sessionId,
|
|
2279
|
+
payload: { conversationId: conversation.id },
|
|
2280
|
+
}));
|
|
2281
|
+
listChanged = true;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
const catalogChanged = discoveredProviders.size !== this.lastDiscoveredProviders.size ||
|
|
2285
|
+
[...discoveredProviders].some((provider) => !this.lastDiscoveredProviders.has(provider));
|
|
2286
|
+
this.lastDiscoveredProviders = discoveredProviders;
|
|
2287
|
+
if (catalogChanged)
|
|
2288
|
+
this.sendCapabilities();
|
|
2289
|
+
if (listChanged) {
|
|
2290
|
+
const conversations = [...this.conversations.values()].filter((item) => !item.archived);
|
|
2291
|
+
this.lastPushedConversationSignature = this.conversationListSignature(conversations);
|
|
2292
|
+
this.input.send(createEnvelope({
|
|
2293
|
+
type: "agent.v2.conversation.list.result",
|
|
2294
|
+
sessionId: this.input.sessionId,
|
|
2295
|
+
payload: { conversations },
|
|
2296
|
+
}));
|
|
2297
|
+
}
|
|
1981
2298
|
}
|
|
1982
2299
|
async ensureProviderClient(provider) {
|
|
1983
2300
|
const existing = this.clients.get(provider);
|
|
1984
2301
|
if (existing)
|
|
1985
2302
|
return existing;
|
|
2303
|
+
// Dedupe concurrent starts: eager initialize() and conversation.open can
|
|
2304
|
+
// race here before this.clients is populated (it's only set AFTER the
|
|
2305
|
+
// client finishes initializing), which would spawn the provider subprocess
|
|
2306
|
+
// twice. Memoize the in-flight promise so both callers share one spawn;
|
|
2307
|
+
// drop it once settled so a failed start can be retried.
|
|
2308
|
+
const inflight = this.clientStartPromises.get(provider);
|
|
2309
|
+
if (inflight)
|
|
2310
|
+
return inflight;
|
|
2311
|
+
const start = this.startProviderClient(provider);
|
|
2312
|
+
this.clientStartPromises.set(provider, start);
|
|
2313
|
+
try {
|
|
2314
|
+
return await start;
|
|
2315
|
+
}
|
|
2316
|
+
finally {
|
|
2317
|
+
this.clientStartPromises.delete(provider);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
async startProviderClient(provider) {
|
|
1986
2321
|
const resolved = resolveAgentCommand({
|
|
1987
2322
|
provider,
|
|
1988
2323
|
command: this.input.command,
|
|
@@ -2033,7 +2368,7 @@ export class AgentWorkspaceProxy {
|
|
|
2033
2368
|
const client = await tryCreateClient(resolved);
|
|
2034
2369
|
this.clients.set(provider, client);
|
|
2035
2370
|
this.providerErrors.delete(provider);
|
|
2036
|
-
|
|
2371
|
+
this.refreshProviderCapabilitiesInBackground(provider, client, resolved.protocol);
|
|
2037
2372
|
this.status = "idle";
|
|
2038
2373
|
this.error = undefined;
|
|
2039
2374
|
this.sendCapabilities();
|
|
@@ -2054,7 +2389,7 @@ export class AgentWorkspaceProxy {
|
|
|
2054
2389
|
const client = await tryCreateClient(fallback);
|
|
2055
2390
|
this.clients.set(provider, client);
|
|
2056
2391
|
this.providerErrors.delete(provider);
|
|
2057
|
-
|
|
2392
|
+
this.refreshProviderCapabilitiesInBackground(provider, client, fallback.protocol);
|
|
2058
2393
|
this.status = "idle";
|
|
2059
2394
|
this.error = undefined;
|
|
2060
2395
|
this.sendCapabilities();
|
|
@@ -2074,6 +2409,22 @@ export class AgentWorkspaceProxy {
|
|
|
2074
2409
|
return undefined;
|
|
2075
2410
|
}
|
|
2076
2411
|
}
|
|
2412
|
+
/** Kick off runtime capability discovery WITHOUT blocking the caller.
|
|
2413
|
+
* refreshProviderCapabilities spawns a warm provider subprocess (measured
|
|
2414
|
+
* 858ms warm, multi-second cold for Claude), and nothing on the open/prompt
|
|
2415
|
+
* path needs it — sendCapabilities and prompt-time model resolution both
|
|
2416
|
+
* degrade gracefully to static fallbacks until providerCapabilities is
|
|
2417
|
+
* populated. When discovery lands we push a fresh agent.v2.capabilities so
|
|
2418
|
+
* clients pick up the real model/command list. */
|
|
2419
|
+
refreshProviderCapabilitiesInBackground(provider, client, protocol) {
|
|
2420
|
+
void this.refreshProviderCapabilities(provider, client, protocol)
|
|
2421
|
+
.then(() => this.sendCapabilities())
|
|
2422
|
+
.catch((error) => {
|
|
2423
|
+
if (this.input.verbose) {
|
|
2424
|
+
process.stderr.write(`[agent:v2] capability refresh failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2425
|
+
}
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2077
2428
|
async refreshProviderCapabilities(provider, client, protocol) {
|
|
2078
2429
|
if (client instanceof AcpClient && protocol !== "codex-app-server")
|
|
2079
2430
|
return;
|
|
@@ -2090,9 +2441,11 @@ export class AgentWorkspaceProxy {
|
|
|
2090
2441
|
}
|
|
2091
2442
|
}
|
|
2092
2443
|
}
|
|
2444
|
+
const listedMcp = await this.listProviderMcpServers(client);
|
|
2093
2445
|
if (provider === "codex") {
|
|
2094
2446
|
// Codex `model/list` is unreliable across versions — make sure mobile always
|
|
2095
2447
|
// sees a usable model picker by merging in a static fallback list.
|
|
2448
|
+
const skillCommands = await this.loadProviderSkillCommands(provider, client);
|
|
2096
2449
|
const merged = {
|
|
2097
2450
|
models: runtimeCapabilities?.models?.length
|
|
2098
2451
|
? runtimeCapabilities.models
|
|
@@ -2101,13 +2454,15 @@ export class AgentWorkspaceProxy {
|
|
|
2101
2454
|
reasoningEfforts: runtimeCapabilities?.reasoningEfforts?.length
|
|
2102
2455
|
? runtimeCapabilities.reasoningEfforts
|
|
2103
2456
|
: [...ALL_REASONING_EFFORTS],
|
|
2104
|
-
commands: runtimeCapabilities?.commands,
|
|
2457
|
+
commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
|
|
2105
2458
|
modes: runtimeCapabilities?.modes,
|
|
2106
2459
|
currentMode: runtimeCapabilities?.currentMode,
|
|
2107
2460
|
// Preserve any MCP status already accumulated from startupStatus events.
|
|
2108
2461
|
mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
|
|
2109
2462
|
};
|
|
2110
2463
|
this.providerCapabilities.set(provider, merged);
|
|
2464
|
+
if (listedMcp.length > 0)
|
|
2465
|
+
this.mergeMcpServers(provider, listedMcp);
|
|
2111
2466
|
return;
|
|
2112
2467
|
}
|
|
2113
2468
|
if (runtimeCapabilities) {
|
|
@@ -2120,8 +2475,46 @@ export class AgentWorkspaceProxy {
|
|
|
2120
2475
|
map.set(name, desc);
|
|
2121
2476
|
runtimeCapabilities = { ...runtimeCapabilities, mcpServers: map };
|
|
2122
2477
|
}
|
|
2478
|
+
const skillCommands = await this.loadProviderSkillCommands(provider, client);
|
|
2479
|
+
if (skillCommands.length > 0) {
|
|
2480
|
+
runtimeCapabilities = {
|
|
2481
|
+
...runtimeCapabilities,
|
|
2482
|
+
commands: mergeCommands(runtimeCapabilities.commands, skillCommands),
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2123
2485
|
this.providerCapabilities.set(provider, runtimeCapabilities);
|
|
2124
2486
|
}
|
|
2487
|
+
if (listedMcp.length > 0)
|
|
2488
|
+
this.mergeMcpServers(provider, listedMcp);
|
|
2489
|
+
}
|
|
2490
|
+
async listProviderMcpServers(client) {
|
|
2491
|
+
const listMcpServers = client.listMcpServers;
|
|
2492
|
+
if (typeof listMcpServers !== "function")
|
|
2493
|
+
return [];
|
|
2494
|
+
try {
|
|
2495
|
+
return mcpListFromProviderResult(await listMcpServers.call(client));
|
|
2496
|
+
}
|
|
2497
|
+
catch (error) {
|
|
2498
|
+
if (this.input.verbose) {
|
|
2499
|
+
process.stderr.write(`[agent:v2] mcpServerStatus/list failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2500
|
+
}
|
|
2501
|
+
return [];
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
async loadProviderSkillCommands(provider, client) {
|
|
2505
|
+
const listSkills = client.listSkills;
|
|
2506
|
+
if (typeof listSkills !== "function")
|
|
2507
|
+
return [];
|
|
2508
|
+
try {
|
|
2509
|
+
const result = await listSkills.call(client, { cwd: this.input.cwd });
|
|
2510
|
+
return parseSkillCommands(provider, result);
|
|
2511
|
+
}
|
|
2512
|
+
catch (error) {
|
|
2513
|
+
if (this.input.verbose) {
|
|
2514
|
+
process.stderr.write(`[agent:v2] skills/list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2515
|
+
}
|
|
2516
|
+
return [];
|
|
2517
|
+
}
|
|
2125
2518
|
}
|
|
2126
2519
|
async syncProviderSessions() {
|
|
2127
2520
|
await this.initialize();
|
|
@@ -2180,9 +2573,11 @@ export class AgentWorkspaceProxy {
|
|
|
2180
2573
|
? existing?.status ?? "running"
|
|
2181
2574
|
: remoteRunning
|
|
2182
2575
|
? "running"
|
|
2183
|
-
:
|
|
2184
|
-
?
|
|
2185
|
-
: "
|
|
2576
|
+
: remote.status === "error"
|
|
2577
|
+
? "error"
|
|
2578
|
+
: existing?.status === "error" || existing?.status === "waiting_permission"
|
|
2579
|
+
? existing.status
|
|
2580
|
+
: "idle";
|
|
2186
2581
|
const conversation = {
|
|
2187
2582
|
id: conversationId,
|
|
2188
2583
|
agentSessionId,
|
|
@@ -2212,7 +2607,7 @@ export class AgentWorkspaceProxy {
|
|
|
2212
2607
|
}
|
|
2213
2608
|
}
|
|
2214
2609
|
}
|
|
2215
|
-
async hydrateConversationFromProvider(conversation, seedResult) {
|
|
2610
|
+
async hydrateConversationFromProvider(conversation, seedResult, options) {
|
|
2216
2611
|
let client = this.clientForProvider(conversation.provider);
|
|
2217
2612
|
if (!client || !conversation.agentSessionId)
|
|
2218
2613
|
return;
|
|
@@ -2282,8 +2677,9 @@ export class AgentWorkspaceProxy {
|
|
|
2282
2677
|
}
|
|
2283
2678
|
const thread = threadFromProviderResult(source);
|
|
2284
2679
|
const activeTurnId = activeTurnIdFromThread(thread);
|
|
2285
|
-
if (activeTurnId)
|
|
2680
|
+
if (activeTurnId && options?.rememberActiveTurn !== false) {
|
|
2286
2681
|
this.rememberTurnConversationId(conversation.id, activeTurnId);
|
|
2682
|
+
}
|
|
2287
2683
|
const model = firstString(thread, ["model", "modelId", "currentModel"]);
|
|
2288
2684
|
if (model && !conversation.model)
|
|
2289
2685
|
conversation.model = model;
|
|
@@ -2356,7 +2752,11 @@ export class AgentWorkspaceProxy {
|
|
|
2356
2752
|
}
|
|
2357
2753
|
}
|
|
2358
2754
|
sendCapabilities() {
|
|
2359
|
-
const
|
|
2755
|
+
const catalog = [...new Set([
|
|
2756
|
+
...this.input.availableProviders,
|
|
2757
|
+
...this.lastDiscoveredProviders,
|
|
2758
|
+
])];
|
|
2759
|
+
const providers = catalog.map((provider) => {
|
|
2360
2760
|
const client = this.clients.get(provider);
|
|
2361
2761
|
const protocol = this.agentProtocols.get(provider);
|
|
2362
2762
|
const runtimeCapabilities = this.providerCapabilities.get(provider);
|
|
@@ -2365,13 +2765,18 @@ export class AgentWorkspaceProxy {
|
|
|
2365
2765
|
const isClaudeFallback = protocol === "claude-stream-json";
|
|
2366
2766
|
const supportsPermission = enabled && !isClaudeFallback;
|
|
2367
2767
|
const supportsReasoningEffort = enabled && !isClaudeFallback;
|
|
2768
|
+
const protocolBacked = isProtocolAgentProvider(provider);
|
|
2368
2769
|
const commands = mergeCommands(defaultProviderCommands(provider, this.input.cwd, enabled), runtimeCapabilities?.commands);
|
|
2369
2770
|
const currentMode = [...this.conversations.values()].find((conversation) => conversation.provider === provider)?.collaborationMode;
|
|
2370
2771
|
return {
|
|
2371
2772
|
id: provider,
|
|
2372
2773
|
label: providerLabel(provider),
|
|
2373
2774
|
enabled,
|
|
2374
|
-
reason: enabled
|
|
2775
|
+
reason: enabled
|
|
2776
|
+
? undefined
|
|
2777
|
+
: protocolBacked
|
|
2778
|
+
? this.providerErrors.get(provider) ?? `${providerLabel(provider)} 未安装或启动失败`
|
|
2779
|
+
: `${providerLabel(provider)} 已在本机检测;远程对话请在终端继续`,
|
|
2375
2780
|
supportsImages,
|
|
2376
2781
|
supportsPermission,
|
|
2377
2782
|
supportsPlan: enabled,
|
|
@@ -2408,11 +2813,11 @@ export class AgentWorkspaceProxy {
|
|
|
2408
2813
|
sessionId: this.input.sessionId,
|
|
2409
2814
|
payload: {
|
|
2410
2815
|
enabled: anyEnabled,
|
|
2411
|
-
provider: this.input.availableProviders[0] ?? "codex",
|
|
2816
|
+
provider: this.input.availableProviders[0] ?? catalog[0] ?? "codex",
|
|
2412
2817
|
providers,
|
|
2413
2818
|
protocolVersion: 1,
|
|
2414
2819
|
workspaceProtocolVersion: 2,
|
|
2415
|
-
error: anyEnabled ? undefined : "
|
|
2820
|
+
error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
|
|
2416
2821
|
supportsSessionList: anyEnabled,
|
|
2417
2822
|
supportsSessionLoad: anyEnabled,
|
|
2418
2823
|
supportsImages: providers.some((p) => p.supportsImages),
|
|
@@ -2428,13 +2833,34 @@ export class AgentWorkspaceProxy {
|
|
|
2428
2833
|
if (!provider) {
|
|
2429
2834
|
return this.openFailure(payload, "没有可用的 Agent provider。");
|
|
2430
2835
|
}
|
|
2431
|
-
if (!this.input.availableProviders.includes(provider)) {
|
|
2432
|
-
return this.openFailure(payload, `${providerLabel(provider)} 未安装或不可用。`);
|
|
2433
|
-
}
|
|
2434
2836
|
let cwd = payload.cwd ?? this.input.cwd;
|
|
2435
2837
|
let agentSessionId = payload.agentSessionId;
|
|
2436
2838
|
let existingConversation = (payload.conversationId ? this.conversations.get(payload.conversationId) : undefined) ??
|
|
2437
2839
|
(agentSessionId ? this.conversations.get(this.conversationByAgentSessionId.get(agentSessionId) ?? "") : undefined);
|
|
2840
|
+
if (existingConversation &&
|
|
2841
|
+
!this.clientForProvider(existingConversation.provider) &&
|
|
2842
|
+
!isProtocolAgentProvider(existingConversation.provider)) {
|
|
2843
|
+
this.activeConversationId = existingConversation.id;
|
|
2844
|
+
this.input.send(createEnvelope({
|
|
2845
|
+
type: "agent.v2.conversation.opened",
|
|
2846
|
+
sessionId: this.input.sessionId,
|
|
2847
|
+
payload: {
|
|
2848
|
+
conversation: existingConversation,
|
|
2849
|
+
snapshot: this.timelines.get(existingConversation.id) ?? [],
|
|
2850
|
+
requestedConversationId: payload.conversationId,
|
|
2851
|
+
},
|
|
2852
|
+
}));
|
|
2853
|
+
return existingConversation;
|
|
2854
|
+
}
|
|
2855
|
+
if (!isProtocolAgentProvider(provider) && !resolveAgentCommand({
|
|
2856
|
+
provider,
|
|
2857
|
+
command: this.input.command,
|
|
2858
|
+
})) {
|
|
2859
|
+
return this.openFailure(payload, `${providerLabel(provider)} 还不能从远程新建对话。在本机终端启动后,会话会出现在列表里。`);
|
|
2860
|
+
}
|
|
2861
|
+
if (!this.input.availableProviders.includes(provider) && !existingConversation) {
|
|
2862
|
+
return this.openFailure(payload, `${providerLabel(provider)} 未安装或不可用。`);
|
|
2863
|
+
}
|
|
2438
2864
|
if (!payload.cwd && existingConversation?.cwd)
|
|
2439
2865
|
cwd = existingConversation.cwd;
|
|
2440
2866
|
let client = await this.ensureProviderClient(provider);
|
|
@@ -2461,14 +2887,15 @@ export class AgentWorkspaceProxy {
|
|
|
2461
2887
|
return this.openFailure(payload, message, cwd);
|
|
2462
2888
|
}
|
|
2463
2889
|
// Fork: seed a NEW conversation from an existing one's transcript, truncated
|
|
2464
|
-
// at forkFromTurnId. Claude
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
//
|
|
2890
|
+
// at forkFromTurnId. Claude uses the SDK forkSession(); Codex uses
|
|
2891
|
+
// thread/fork. Other providers notify and fall through to a plain new
|
|
2892
|
+
// conversation. On success we set agentSessionId to the new thread id and
|
|
2893
|
+
// let the normal load path hydrate it.
|
|
2468
2894
|
if (payload.forkFromConversationId) {
|
|
2469
2895
|
const source = this.conversations.get(payload.forkFromConversationId);
|
|
2470
2896
|
const sourceSessionId = source?.agentSessionId;
|
|
2471
2897
|
const forkable = client.forkSession;
|
|
2898
|
+
const forkThread = client.forkThread;
|
|
2472
2899
|
if (provider === "claude" && typeof forkable === "function" && sourceSessionId) {
|
|
2473
2900
|
if (source?.cwd && !payload.cwd)
|
|
2474
2901
|
cwd = source.cwd;
|
|
@@ -2490,6 +2917,39 @@ export class AgentWorkspaceProxy {
|
|
|
2490
2917
|
this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
|
|
2491
2918
|
}
|
|
2492
2919
|
}
|
|
2920
|
+
else if (provider === "codex" && typeof forkThread === "function" && sourceSessionId) {
|
|
2921
|
+
if (source?.cwd && !payload.cwd)
|
|
2922
|
+
cwd = source.cwd;
|
|
2923
|
+
try {
|
|
2924
|
+
const forked = await forkThread.call(client, {
|
|
2925
|
+
sessionId: sourceSessionId,
|
|
2926
|
+
lastTurnId: payload.forkFromTurnId,
|
|
2927
|
+
});
|
|
2928
|
+
const forkedId = this.extractSessionId(forked);
|
|
2929
|
+
if (forkedId) {
|
|
2930
|
+
agentSessionId = forkedId;
|
|
2931
|
+
payload = {
|
|
2932
|
+
...payload,
|
|
2933
|
+
model: payload.model ?? source?.model,
|
|
2934
|
+
reasoningEffort: payload.reasoningEffort ?? source?.reasoningEffort,
|
|
2935
|
+
permissionMode: payload.permissionMode ?? source?.permissionMode,
|
|
2936
|
+
collaborationMode: payload.collaborationMode ?? source?.collaborationMode,
|
|
2937
|
+
title: payload.title ?? (source?.title ? `${source.title} (分叉)` : undefined),
|
|
2938
|
+
};
|
|
2939
|
+
existingConversation = undefined;
|
|
2940
|
+
}
|
|
2941
|
+
else {
|
|
2942
|
+
this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
catch (error) {
|
|
2946
|
+
this.emitNotice({
|
|
2947
|
+
kind: "warning",
|
|
2948
|
+
title: "分叉失败",
|
|
2949
|
+
detail: error instanceof Error ? error.message : "无法复制该会话的历史记录,已新建空会话。",
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2493
2953
|
else {
|
|
2494
2954
|
this.emitNotice({
|
|
2495
2955
|
kind: "native_unsupported",
|
|
@@ -2690,12 +3150,16 @@ export class AgentWorkspaceProxy {
|
|
|
2690
3150
|
if (!conversation)
|
|
2691
3151
|
return;
|
|
2692
3152
|
if (!conversation.agentSessionId) {
|
|
2693
|
-
this.rejectAgentAction(conversation,
|
|
3153
|
+
this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
|
|
3154
|
+
? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
|
|
3155
|
+
: "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
|
|
2694
3156
|
return;
|
|
2695
3157
|
}
|
|
2696
3158
|
const client = this.clientForProvider(conversation.provider) ?? await this.ensureProviderClient(conversation.provider);
|
|
2697
3159
|
if (!client) {
|
|
2698
|
-
this.rejectAgentAction(conversation,
|
|
3160
|
+
this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
|
|
3161
|
+
? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
|
|
3162
|
+
: this.providerErrors.get(conversation.provider) ?? `${providerLabel(conversation.provider)} 未连接,消息没有发送。`, "error");
|
|
2699
3163
|
return;
|
|
2700
3164
|
}
|
|
2701
3165
|
const protocol = this.protocolForProvider(conversation.provider);
|
|
@@ -2999,14 +3463,37 @@ export class AgentWorkspaceProxy {
|
|
|
2999
3463
|
this.emitStatus(conversation.id, "idle", "上下文已重置,已创建新的 Codex thread。");
|
|
3000
3464
|
return;
|
|
3001
3465
|
}
|
|
3002
|
-
if (command.name === "review"
|
|
3003
|
-
const
|
|
3004
|
-
|
|
3005
|
-
|
|
3466
|
+
if (command.name === "review") {
|
|
3467
|
+
const startReview = client?.startReview;
|
|
3468
|
+
if (typeof startReview === "function" && conversation.agentSessionId) {
|
|
3469
|
+
conversation.status = "running";
|
|
3470
|
+
conversation.lastActivityAt = now;
|
|
3471
|
+
this.emitConversation(conversation);
|
|
3472
|
+
const result = await startReview.call(client, {
|
|
3473
|
+
sessionId: conversation.agentSessionId,
|
|
3474
|
+
prompt: args || undefined,
|
|
3475
|
+
});
|
|
3476
|
+
const turnId = this.extractTurnId(result);
|
|
3477
|
+
if (turnId)
|
|
3478
|
+
this.rememberTurnConversationId(conversation.id, turnId);
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
await this.sendPrompt({
|
|
3482
|
+
conversationId: conversation.id,
|
|
3483
|
+
clientMessageId: id(command.name),
|
|
3484
|
+
contentBlocks: [{ type: "text", text: args || "Review the current local changes." }],
|
|
3485
|
+
model: conversation.model,
|
|
3486
|
+
reasoningEffort: conversation.reasoningEffort,
|
|
3487
|
+
permissionMode: conversation.permissionMode,
|
|
3488
|
+
collaborationMode: conversation.collaborationMode,
|
|
3489
|
+
});
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
if (command.name === "subagents") {
|
|
3006
3493
|
await this.sendPrompt({
|
|
3007
3494
|
conversationId: conversation.id,
|
|
3008
3495
|
clientMessageId: id(command.name),
|
|
3009
|
-
contentBlocks: [{ type: "text", text:
|
|
3496
|
+
contentBlocks: [{ type: "text", text: args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results." }],
|
|
3010
3497
|
model: conversation.model,
|
|
3011
3498
|
reasoningEffort: conversation.reasoningEffort,
|
|
3012
3499
|
permissionMode: conversation.permissionMode,
|
|
@@ -3046,8 +3533,73 @@ export class AgentWorkspaceProxy {
|
|
|
3046
3533
|
}
|
|
3047
3534
|
return {};
|
|
3048
3535
|
}
|
|
3049
|
-
|
|
3050
|
-
|
|
3536
|
+
async startMcpLogin(payload) {
|
|
3537
|
+
const provider = payload.provider
|
|
3538
|
+
?? this.input.availableProviders.find((item) => this.clients.has(item))
|
|
3539
|
+
?? this.input.availableProviders[0];
|
|
3540
|
+
const sendResult = (input) => {
|
|
3541
|
+
this.input.send(createEnvelope({
|
|
3542
|
+
type: "agent.v2.mcp.login.result",
|
|
3543
|
+
sessionId: this.input.sessionId,
|
|
3544
|
+
payload: {
|
|
3545
|
+
...(provider ? { provider } : {}),
|
|
3546
|
+
serverName: payload.serverName,
|
|
3547
|
+
authorizationUrl: input.authorizationUrl,
|
|
3548
|
+
error: input.error,
|
|
3549
|
+
},
|
|
3550
|
+
}));
|
|
3551
|
+
};
|
|
3552
|
+
if (!provider) {
|
|
3553
|
+
sendResult({ error: "没有可用的 Agent 提供方。" });
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
const client = this.clientForProvider(provider);
|
|
3557
|
+
const startMcpOAuth = client?.startMcpOAuth;
|
|
3558
|
+
if (typeof startMcpOAuth !== "function") {
|
|
3559
|
+
sendResult({ error: "请在主机上的 Claude / Codex 完成该 MCP 的授权。" });
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
try {
|
|
3563
|
+
const result = await startMcpOAuth.call(client, { serverName: payload.serverName });
|
|
3564
|
+
const raw = asRecord(result);
|
|
3565
|
+
const authorizationUrl = firstString(raw, [
|
|
3566
|
+
"authorizationUrl",
|
|
3567
|
+
"authorization_url",
|
|
3568
|
+
"url",
|
|
3569
|
+
"loginUrl",
|
|
3570
|
+
]);
|
|
3571
|
+
if (!authorizationUrl) {
|
|
3572
|
+
sendResult({ error: "主机没有返回授权地址。" });
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
this.mergeMcpServers(provider, [{
|
|
3576
|
+
name: payload.serverName,
|
|
3577
|
+
status: "needs_auth",
|
|
3578
|
+
}]);
|
|
3579
|
+
this.sendCapabilities();
|
|
3580
|
+
sendResult({ authorizationUrl });
|
|
3581
|
+
}
|
|
3582
|
+
catch (error) {
|
|
3583
|
+
sendResult({ error: error instanceof Error ? error.message : String(error) });
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
handleMcpOAuthCompleted(params) {
|
|
3587
|
+
const raw = asRecord(params) ?? {};
|
|
3588
|
+
const name = firstString(raw, ["name", "server", "serverName"]);
|
|
3589
|
+
if (!name)
|
|
3590
|
+
return;
|
|
3591
|
+
const success = raw.success !== false && !raw.error;
|
|
3592
|
+
const provider = this.input.availableProviders.find((item) => this.clients.has(item))
|
|
3593
|
+
?? this.input.availableProviders[0];
|
|
3594
|
+
if (!provider)
|
|
3595
|
+
return;
|
|
3596
|
+
this.mergeMcpServers(provider, [{
|
|
3597
|
+
name,
|
|
3598
|
+
status: success ? "connected" : "needs_auth",
|
|
3599
|
+
error: success ? undefined : firstString(raw, ["error", "message"]),
|
|
3600
|
+
}]);
|
|
3601
|
+
this.sendCapabilities();
|
|
3602
|
+
}
|
|
3051
3603
|
mergeMcpServers(provider, servers) {
|
|
3052
3604
|
if (servers.length === 0)
|
|
3053
3605
|
return;
|
|
@@ -3075,7 +3627,7 @@ export class AgentWorkspaceProxy {
|
|
|
3075
3627
|
return;
|
|
3076
3628
|
this.mergeMcpServers(provider, [{
|
|
3077
3629
|
name,
|
|
3078
|
-
status:
|
|
3630
|
+
status: mcpStatusFromRecord(raw),
|
|
3079
3631
|
error: firstString(raw, ["error", "message", "reason"]),
|
|
3080
3632
|
toolCount: firstNumber(raw, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(raw.tools) ? raw.tools.length : undefined),
|
|
3081
3633
|
}]);
|
|
@@ -3125,9 +3677,11 @@ export class AgentWorkspaceProxy {
|
|
|
3125
3677
|
this.handleMcpStartupStatus(method, params);
|
|
3126
3678
|
return;
|
|
3127
3679
|
}
|
|
3128
|
-
if (method
|
|
3129
|
-
|
|
3130
|
-
|
|
3680
|
+
if (method === "mcpServer/oauthLogin/completed") {
|
|
3681
|
+
this.handleMcpOAuthCompleted(params);
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
if (method.startsWith("account/") || method === "serverRequest/resolved") {
|
|
3131
3685
|
return;
|
|
3132
3686
|
}
|
|
3133
3687
|
const conversationId = this.conversationIdFromParams(params) ?? this.fallbackConversationId();
|
|
@@ -3160,6 +3714,46 @@ export class AgentWorkspaceProxy {
|
|
|
3160
3714
|
}
|
|
3161
3715
|
return;
|
|
3162
3716
|
}
|
|
3717
|
+
if (method === "thread/name/updated") {
|
|
3718
|
+
if (conversationId) {
|
|
3719
|
+
const raw = asRecord(params);
|
|
3720
|
+
const title = firstString(raw, ["name", "title"]) ?? firstString(asRecord(raw?.thread), ["name", "title"]);
|
|
3721
|
+
const conversation = this.conversations.get(conversationId);
|
|
3722
|
+
if (conversation && title !== undefined) {
|
|
3723
|
+
conversation.title = title;
|
|
3724
|
+
conversation.lastActivityAt = Date.now();
|
|
3725
|
+
this.emitConversation(conversation);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
if (method === "thread/settings/updated") {
|
|
3731
|
+
if (conversationId) {
|
|
3732
|
+
const conversation = this.conversations.get(conversationId);
|
|
3733
|
+
if (conversation) {
|
|
3734
|
+
applyThreadSettingsToConversation(conversation, params);
|
|
3735
|
+
conversation.lastActivityAt = Date.now();
|
|
3736
|
+
this.emitConversation(conversation);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
if (method === "thread/archived" || method === "thread/unarchived") {
|
|
3742
|
+
if (conversationId) {
|
|
3743
|
+
const conversation = this.conversations.get(conversationId);
|
|
3744
|
+
if (conversation) {
|
|
3745
|
+
conversation.archived = method === "thread/archived";
|
|
3746
|
+
conversation.lastActivityAt = Date.now();
|
|
3747
|
+
this.emitConversation(conversation);
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
if (method === "thread/deleted") {
|
|
3753
|
+
if (conversationId)
|
|
3754
|
+
this.forgetConversationLocally(conversationId);
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3163
3757
|
if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
|
|
3164
3758
|
this.handleStructuredInput(params);
|
|
3165
3759
|
return;
|
|
@@ -3220,6 +3814,13 @@ export class AgentWorkspaceProxy {
|
|
|
3220
3814
|
case "item/agentMessage/delta":
|
|
3221
3815
|
this.handleAgentMessageDelta(params);
|
|
3222
3816
|
return;
|
|
3817
|
+
case "item/reasoning/summaryTextDelta":
|
|
3818
|
+
case "item/reasoning/textDelta":
|
|
3819
|
+
this.handleReasoningDelta(params);
|
|
3820
|
+
return;
|
|
3821
|
+
case "item/reasoning/summaryPartAdded":
|
|
3822
|
+
this.handleReasoningDelta(params, { partBoundary: true });
|
|
3823
|
+
return;
|
|
3223
3824
|
case "turn/plan/updated":
|
|
3224
3825
|
this.handlePlanUpdated(params);
|
|
3225
3826
|
return;
|
|
@@ -3284,9 +3885,65 @@ export class AgentWorkspaceProxy {
|
|
|
3284
3885
|
updatedAt: Date.now(),
|
|
3285
3886
|
isStreaming: true,
|
|
3286
3887
|
};
|
|
3287
|
-
this.
|
|
3888
|
+
this.applyStreamingTextDelta(conversationId, item, existing, delta);
|
|
3288
3889
|
this.updateConversationPreview(conversationId, text, "running");
|
|
3289
3890
|
}
|
|
3891
|
+
handleReasoningDelta(params, options) {
|
|
3892
|
+
const raw = asRecord(params);
|
|
3893
|
+
if (!raw)
|
|
3894
|
+
return;
|
|
3895
|
+
const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
|
|
3896
|
+
if (!conversationId)
|
|
3897
|
+
return;
|
|
3898
|
+
const itemId = firstString(raw, ["itemId", "id", "messageId"]) ?? id("thinking");
|
|
3899
|
+
const existing = this.findItem(conversationId, itemId);
|
|
3900
|
+
const delta = firstString(raw, ["delta", "text", "content", "summary"])
|
|
3901
|
+
?? (options?.partBoundary ? "\n" : undefined);
|
|
3902
|
+
if (!delta)
|
|
3903
|
+
return;
|
|
3904
|
+
const text = `${existing?.text ?? ""}${delta}`;
|
|
3905
|
+
const item = {
|
|
3906
|
+
id: itemId,
|
|
3907
|
+
conversationId,
|
|
3908
|
+
type: "status",
|
|
3909
|
+
kind: "thinking",
|
|
3910
|
+
role: "system",
|
|
3911
|
+
turnId: this.extractTurnId(raw) ?? this.currentTurnIds.get(conversationId),
|
|
3912
|
+
itemId,
|
|
3913
|
+
content: [{ type: "text", text }],
|
|
3914
|
+
text,
|
|
3915
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
3916
|
+
updatedAt: Date.now(),
|
|
3917
|
+
isStreaming: true,
|
|
3918
|
+
};
|
|
3919
|
+
this.applyStreamingTextDelta(conversationId, item, existing, delta);
|
|
3920
|
+
}
|
|
3921
|
+
applyStreamingTextDelta(conversationId, item, existing, delta) {
|
|
3922
|
+
if (!existing) {
|
|
3923
|
+
this.upsertItem(conversationId, item);
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
this.rememberItemConversationId(conversationId, item);
|
|
3927
|
+
const timeline = this.timelines.get(conversationId) ?? [];
|
|
3928
|
+
const index = timeline.findIndex((entry) => entry.id === item.id);
|
|
3929
|
+
if (index >= 0)
|
|
3930
|
+
timeline[index] = item;
|
|
3931
|
+
else
|
|
3932
|
+
timeline.push(item);
|
|
3933
|
+
this.timelines.set(conversationId, timeline);
|
|
3934
|
+
this.input.send(createEnvelope({
|
|
3935
|
+
type: "agent.v2.event",
|
|
3936
|
+
sessionId: this.input.sessionId,
|
|
3937
|
+
payload: {
|
|
3938
|
+
conversationId,
|
|
3939
|
+
patch: {
|
|
3940
|
+
itemId: item.id,
|
|
3941
|
+
textDelta: delta,
|
|
3942
|
+
updatedAt: item.updatedAt,
|
|
3943
|
+
},
|
|
3944
|
+
},
|
|
3945
|
+
}));
|
|
3946
|
+
}
|
|
3290
3947
|
handlePlanUpdated(params) {
|
|
3291
3948
|
const raw = asRecord(params);
|
|
3292
3949
|
const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
|
|
@@ -3752,19 +4409,14 @@ export class AgentWorkspaceProxy {
|
|
|
3752
4409
|
const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
|
|
3753
4410
|
const rawToolCall = asRecord(raw.toolCall) ?? raw;
|
|
3754
4411
|
// Codex app-server command/file approvals carry `command`/`reason`/`cwd`
|
|
3755
|
-
// (not toolCall/context).
|
|
3756
|
-
//
|
|
3757
|
-
const codexCommand = firstString(raw, ["command"]);
|
|
3758
|
-
const codexCwd = firstString(raw, ["cwd"]);
|
|
4412
|
+
// (not toolCall/context). Keep those fields as JSON so the web card can
|
|
4413
|
+
// render `$ command` / a path instead of a raw dump.
|
|
3759
4414
|
const permission = {
|
|
3760
4415
|
requestId,
|
|
3761
|
-
toolName:
|
|
3762
|
-
|
|
3763
|
-
toolInput: codexCommand
|
|
3764
|
-
? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
|
|
3765
|
-
: stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
|
|
4416
|
+
toolName: permissionToolName(raw, rawToolCall),
|
|
4417
|
+
toolInput: permissionToolInput(raw, rawToolCall),
|
|
3766
4418
|
context: firstString(raw, ["context", "description", "message", "title", "reason"]),
|
|
3767
|
-
options:
|
|
4419
|
+
options: permissionOptionsFromRequest(raw, source),
|
|
3768
4420
|
};
|
|
3769
4421
|
this.pendingPermissions.set(requestId, permission);
|
|
3770
4422
|
if (source)
|
|
@@ -4038,6 +4690,22 @@ export class AgentWorkspaceProxy {
|
|
|
4038
4690
|
payload: { conversationId, conversation: conversation ? { ...conversation } : undefined, item },
|
|
4039
4691
|
}));
|
|
4040
4692
|
}
|
|
4693
|
+
forgetConversationLocally(conversationId) {
|
|
4694
|
+
const conversation = this.conversations.get(conversationId);
|
|
4695
|
+
if (conversation?.agentSessionId) {
|
|
4696
|
+
this.deletedAgentSessionIds.add(conversation.agentSessionId);
|
|
4697
|
+
this.conversationByAgentSessionId.delete(conversation.agentSessionId);
|
|
4698
|
+
}
|
|
4699
|
+
this.conversations.delete(conversationId);
|
|
4700
|
+
this.timelines.delete(conversationId);
|
|
4701
|
+
this.historyCursors.delete(conversationId);
|
|
4702
|
+
this.lastHydrateAt.delete(conversationId);
|
|
4703
|
+
this.input.send(createEnvelope({
|
|
4704
|
+
type: "agent.v2.conversation.deleted",
|
|
4705
|
+
sessionId: this.input.sessionId,
|
|
4706
|
+
payload: { conversationId },
|
|
4707
|
+
}));
|
|
4708
|
+
}
|
|
4041
4709
|
emitConversation(conversation) {
|
|
4042
4710
|
this.input.send(createEnvelope({
|
|
4043
4711
|
type: "agent.v2.event",
|
|
@@ -4381,6 +5049,77 @@ export class AgentWorkspaceProxy {
|
|
|
4381
5049
|
}, 0);
|
|
4382
5050
|
}
|
|
4383
5051
|
}
|
|
5052
|
+
function permissionToolName(raw, rawToolCall) {
|
|
5053
|
+
const named = firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]);
|
|
5054
|
+
if (named)
|
|
5055
|
+
return named;
|
|
5056
|
+
if (firstString(raw, ["command"]))
|
|
5057
|
+
return "shell";
|
|
5058
|
+
if (permissionFilePath(raw))
|
|
5059
|
+
return "Edit";
|
|
5060
|
+
return undefined;
|
|
5061
|
+
}
|
|
5062
|
+
function permissionFilePath(raw) {
|
|
5063
|
+
const direct = firstString(raw, ["path", "filePath", "file_path"]);
|
|
5064
|
+
if (direct)
|
|
5065
|
+
return direct;
|
|
5066
|
+
const changes = Array.isArray(raw.changes) ? raw.changes : undefined;
|
|
5067
|
+
const first = changes ? asRecord(changes[0]) : undefined;
|
|
5068
|
+
return firstString(first, ["path", "filePath", "file_path"]);
|
|
5069
|
+
}
|
|
5070
|
+
function permissionToolInput(raw, rawToolCall) {
|
|
5071
|
+
const command = firstString(raw, ["command"]);
|
|
5072
|
+
const cwd = firstString(raw, ["cwd"]);
|
|
5073
|
+
const path = permissionFilePath(raw);
|
|
5074
|
+
if (command || path) {
|
|
5075
|
+
return JSON.stringify({
|
|
5076
|
+
...(command ? { command } : {}),
|
|
5077
|
+
...(cwd ? { cwd } : {}),
|
|
5078
|
+
...(path ? { path } : {}),
|
|
5079
|
+
});
|
|
5080
|
+
}
|
|
5081
|
+
return stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall);
|
|
5082
|
+
}
|
|
5083
|
+
function officialDecisionKey(value) {
|
|
5084
|
+
if (typeof value === "string" && value.trim())
|
|
5085
|
+
return value.trim();
|
|
5086
|
+
const raw = asRecord(value);
|
|
5087
|
+
return firstString(raw, ["decision", "id", "kind", "type", "optionId"]);
|
|
5088
|
+
}
|
|
5089
|
+
function officialDecisionOption(key) {
|
|
5090
|
+
const normalized = key.replace(/[\s_-]/g, "").toLowerCase();
|
|
5091
|
+
if (normalized === "accept" || normalized === "allow" || normalized === "allowonce") {
|
|
5092
|
+
return { id: key === "accept" ? "accept" : key, label: "允许一次", kind: "allow" };
|
|
5093
|
+
}
|
|
5094
|
+
if (normalized === "acceptforsession" ||
|
|
5095
|
+
normalized === "allowsession" ||
|
|
5096
|
+
normalized === "allowforsession" ||
|
|
5097
|
+
normalized === "session") {
|
|
5098
|
+
return { id: "acceptForSession", label: "本会话允许", kind: "allow" };
|
|
5099
|
+
}
|
|
5100
|
+
if (normalized === "decline" || normalized === "deny" || normalized === "reject") {
|
|
5101
|
+
return { id: normalized === "decline" ? "decline" : key, label: "拒绝", kind: "deny" };
|
|
5102
|
+
}
|
|
5103
|
+
if (normalized === "cancel") {
|
|
5104
|
+
return { id: "cancel", label: "取消", kind: "deny" };
|
|
5105
|
+
}
|
|
5106
|
+
return undefined;
|
|
5107
|
+
}
|
|
5108
|
+
function parseOfficialDecisions(value) {
|
|
5109
|
+
const options = [];
|
|
5110
|
+
const seen = new Set();
|
|
5111
|
+
for (const entry of value) {
|
|
5112
|
+
const key = officialDecisionKey(entry);
|
|
5113
|
+
if (!key)
|
|
5114
|
+
continue;
|
|
5115
|
+
const mapped = officialDecisionOption(key);
|
|
5116
|
+
if (!mapped || seen.has(mapped.id))
|
|
5117
|
+
continue;
|
|
5118
|
+
seen.add(mapped.id);
|
|
5119
|
+
options.push(mapped);
|
|
5120
|
+
}
|
|
5121
|
+
return options;
|
|
5122
|
+
}
|
|
4384
5123
|
function parsePermissionOptions(value) {
|
|
4385
5124
|
if (!Array.isArray(value)) {
|
|
4386
5125
|
return [
|
|
@@ -4388,17 +5127,24 @@ function parsePermissionOptions(value) {
|
|
|
4388
5127
|
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
4389
5128
|
];
|
|
4390
5129
|
}
|
|
5130
|
+
const official = parseOfficialDecisions(value);
|
|
5131
|
+
if (official.length > 0 && value.every((entry) => officialDecisionOption(officialDecisionKey(entry) ?? ""))) {
|
|
5132
|
+
return official;
|
|
5133
|
+
}
|
|
4391
5134
|
const options = value
|
|
4392
5135
|
.map((entry, index) => {
|
|
4393
5136
|
const raw = asRecord(entry) ?? {};
|
|
4394
|
-
const idValue = raw.optionId ?? raw.id ?? raw.kind ?? `option-${index + 1}`;
|
|
4395
|
-
const labelValue = raw.name ?? raw.label ?? raw.kind ?? String(idValue);
|
|
5137
|
+
const idValue = raw.optionId ?? raw.id ?? raw.kind ?? raw.decision ?? `option-${index + 1}`;
|
|
5138
|
+
const labelValue = raw.name ?? raw.label ?? raw.kind ?? raw.decision ?? String(idValue);
|
|
4396
5139
|
const id = String(idValue);
|
|
4397
5140
|
const label = String(labelValue);
|
|
5141
|
+
const official = officialDecisionOption(id);
|
|
5142
|
+
if (official)
|
|
5143
|
+
return { ...official, id, label: official.label === "允许一次" && label !== id ? label : official.label };
|
|
4398
5144
|
const normalized = `${id} ${label}`.toLowerCase();
|
|
4399
|
-
const kind =
|
|
5145
|
+
const kind = /reject|deny|decline|cancel/.test(normalized)
|
|
4400
5146
|
? "deny"
|
|
4401
|
-
: normalized
|
|
5147
|
+
: /allow|accept/.test(normalized)
|
|
4402
5148
|
? "allow"
|
|
4403
5149
|
: "other";
|
|
4404
5150
|
return { id, label, kind };
|
|
@@ -4411,6 +5157,35 @@ function parsePermissionOptions(value) {
|
|
|
4411
5157
|
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
4412
5158
|
];
|
|
4413
5159
|
}
|
|
5160
|
+
function permissionOptionsFromRequest(raw, source) {
|
|
5161
|
+
const decisions = raw.availableDecisions;
|
|
5162
|
+
if (Array.isArray(decisions) && decisions.length > 0) {
|
|
5163
|
+
const official = parseOfficialDecisions(decisions);
|
|
5164
|
+
if (official.length > 0)
|
|
5165
|
+
return official;
|
|
5166
|
+
}
|
|
5167
|
+
if (Array.isArray(raw.options)) {
|
|
5168
|
+
return parsePermissionOptions(raw.options);
|
|
5169
|
+
}
|
|
5170
|
+
if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
|
|
5171
|
+
return [
|
|
5172
|
+
{ id: "accept", label: "允许一次", kind: "allow" },
|
|
5173
|
+
{ id: "acceptForSession", label: "本会话允许", kind: "allow" },
|
|
5174
|
+
{ id: "decline", label: "拒绝", kind: "deny" },
|
|
5175
|
+
];
|
|
5176
|
+
}
|
|
5177
|
+
if (source === "item/permissions/requestApproval") {
|
|
5178
|
+
return [
|
|
5179
|
+
{ id: "turn", label: "本次允许", kind: "allow" },
|
|
5180
|
+
{ id: "session", label: "本会话允许", kind: "allow" },
|
|
5181
|
+
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
5182
|
+
];
|
|
5183
|
+
}
|
|
5184
|
+
return [
|
|
5185
|
+
{ id: "allow", label: "允许", kind: "allow" },
|
|
5186
|
+
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
5187
|
+
];
|
|
5188
|
+
}
|
|
4414
5189
|
function selectPermissionOption(permission, outcome) {
|
|
4415
5190
|
if (outcome === "cancelled")
|
|
4416
5191
|
return "cancelled";
|
|
@@ -4481,7 +5256,13 @@ function formatStructuredInputResponseForSource(source, answers, input) {
|
|
|
4481
5256
|
}
|
|
4482
5257
|
function formatPermissionResponse(source, outcome, optionId) {
|
|
4483
5258
|
if (source === "item/commandExecution/requestApproval" || source === "item/fileChange/requestApproval") {
|
|
4484
|
-
|
|
5259
|
+
if (outcome === "cancelled" || optionId === "cancel")
|
|
5260
|
+
return { decision: "cancel" };
|
|
5261
|
+
if (outcome === "deny")
|
|
5262
|
+
return { decision: "decline" };
|
|
5263
|
+
if (optionId === "acceptForSession")
|
|
5264
|
+
return { decision: "acceptForSession" };
|
|
5265
|
+
return { decision: "accept" };
|
|
4485
5266
|
}
|
|
4486
5267
|
if (source === "item/permissions/requestApproval") {
|
|
4487
5268
|
if (outcome === "allow") {
|