linkshell-cli 0.5.37 → 0.5.39
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 +21 -0
- package/dist/cli/src/runtime/acp/acp-client.js +101 -5
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +1 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.js +12 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +137 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +607 -97
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/provider-resolver.js +28 -2
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +0 -48
- package/dist/cli/src/runtime/bridge-session.js +10 -1039
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/runtime/hook-command.d.ts +5 -30
- package/dist/cli/src/runtime/hook-command.js +54 -41
- package/dist/cli/src/runtime/hook-command.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +164 -8
- package/dist/shared-protocol/src/index.js +6 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/runtime/acp/acp-client.ts +116 -5
- package/src/runtime/acp/agent-process-discovery.ts +8 -1
- package/src/runtime/acp/agent-workspace.ts +557 -50
- package/src/runtime/acp/provider-resolver.ts +31 -2
- package/src/runtime/bridge-session.ts +9 -1117
- package/src/runtime/hook-command.ts +47 -47
- package/web/assets/{FileBrowser-Coy7Szjx.js → FileBrowser-DtVanRUn.js} +1 -1
- package/web/assets/{PortPreview-D-XovbV1.js → PortPreview-DC2-UmHo.js} +1 -1
- package/web/assets/{TerminalPanel-Cah56AtC.js → TerminalPanel-D4gHM4Ta.js} +1 -1
- package/web/assets/{UsageDashboard-DfkjJagC.js → UsageDashboard-ZnvuvqWc.js} +1 -1
- package/web/assets/index-DKdJgZ9D.js +36 -0
- package/web/index.html +1 -1
- package/web/assets/index-ClZzQ4ba.js +0 -35
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync, watch } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join, relative } from "node:path";
|
|
4
4
|
import { createEnvelope, parseTypedPayload, } from "@linkshell/protocol";
|
|
@@ -242,8 +242,9 @@ function normalizeToolStatus(value, completedFallback = false) {
|
|
|
242
242
|
function normalizePlanStatus(value) {
|
|
243
243
|
if (value === "completed" || value === "done")
|
|
244
244
|
return "completed";
|
|
245
|
-
if (value === "inProgress" || value === "running" || value === "active")
|
|
245
|
+
if (value === "inProgress" || value === "in_progress" || value === "running" || value === "active") {
|
|
246
246
|
return "in_progress";
|
|
247
|
+
}
|
|
247
248
|
return "pending";
|
|
248
249
|
}
|
|
249
250
|
function applyThreadSettingsToConversation(conversation, value) {
|
|
@@ -577,7 +578,9 @@ function contentBlocksFromItem(item) {
|
|
|
577
578
|
const text = firstString(item, ["text", "message"]);
|
|
578
579
|
return text ? [{ type: "text", text }] : [];
|
|
579
580
|
}
|
|
580
|
-
function protocolSupportsImages(protocol) {
|
|
581
|
+
function protocolSupportsImages(protocol, advertised) {
|
|
582
|
+
if (protocol === "acp")
|
|
583
|
+
return Boolean(advertised?.images);
|
|
581
584
|
return protocol === "codex-app-server" ||
|
|
582
585
|
protocol === "claude-agent-sdk" ||
|
|
583
586
|
protocol === "claude-stream-json";
|
|
@@ -1442,6 +1445,244 @@ function codexSessionConfig(sessionId) {
|
|
|
1442
1445
|
}
|
|
1443
1446
|
return cfg;
|
|
1444
1447
|
}
|
|
1448
|
+
const CODEX_ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
|
|
1449
|
+
function walkCodexRollouts(root, onFile) {
|
|
1450
|
+
if (!existsSync(root))
|
|
1451
|
+
return;
|
|
1452
|
+
const walk = (dir) => {
|
|
1453
|
+
let entries;
|
|
1454
|
+
try {
|
|
1455
|
+
entries = readdirSync(dir);
|
|
1456
|
+
}
|
|
1457
|
+
catch {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
for (const name of entries) {
|
|
1461
|
+
const path = join(dir, name);
|
|
1462
|
+
let stat;
|
|
1463
|
+
try {
|
|
1464
|
+
stat = statSync(path);
|
|
1465
|
+
}
|
|
1466
|
+
catch {
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
if (stat.isDirectory()) {
|
|
1470
|
+
walk(path);
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
const match = CODEX_ROLLOUT_NAME.exec(name);
|
|
1474
|
+
if (match?.[1])
|
|
1475
|
+
onFile(path, match[1], stat.mtimeMs);
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
walk(root);
|
|
1479
|
+
}
|
|
1480
|
+
function findCodexRolloutPath(sessionId, root = join(homedir(), ".codex", "sessions")) {
|
|
1481
|
+
const byName = [];
|
|
1482
|
+
const recent = [];
|
|
1483
|
+
walkCodexRollouts(root, (path, filenameId, mt) => {
|
|
1484
|
+
recent.push({ path, mt });
|
|
1485
|
+
if (filenameId === sessionId)
|
|
1486
|
+
byName.push({ path, mt });
|
|
1487
|
+
});
|
|
1488
|
+
if (byName.length > 0) {
|
|
1489
|
+
byName.sort((a, b) => b.mt - a.mt);
|
|
1490
|
+
return byName[0]?.path;
|
|
1491
|
+
}
|
|
1492
|
+
recent.sort((a, b) => b.mt - a.mt);
|
|
1493
|
+
for (const candidate of recent.slice(0, 80)) {
|
|
1494
|
+
try {
|
|
1495
|
+
const fdText = readFileSync(candidate.path, "utf8");
|
|
1496
|
+
const first = fdText.slice(0, fdText.indexOf("\n") >= 0 ? fdText.indexOf("\n") : fdText.length);
|
|
1497
|
+
const metaId = firstString(asRecord(asRecord(JSON.parse(first))?.payload), ["session_id", "id"]);
|
|
1498
|
+
if (metaId === sessionId)
|
|
1499
|
+
return candidate.path;
|
|
1500
|
+
}
|
|
1501
|
+
catch { /* skip */ }
|
|
1502
|
+
}
|
|
1503
|
+
return undefined;
|
|
1504
|
+
}
|
|
1505
|
+
function findLatestCodexRolloutForCwd(cwd, root = join(homedir(), ".codex", "sessions")) {
|
|
1506
|
+
const wanted = cwd.replace(/\/+$/, "");
|
|
1507
|
+
const candidates = [];
|
|
1508
|
+
walkCodexRollouts(root, (path, filenameId, mt) => {
|
|
1509
|
+
candidates.push({ path, sessionId: filenameId, mt });
|
|
1510
|
+
});
|
|
1511
|
+
candidates.sort((a, b) => b.mt - a.mt);
|
|
1512
|
+
for (const candidate of candidates.slice(0, 40)) {
|
|
1513
|
+
try {
|
|
1514
|
+
const text = readFileSync(candidate.path, "utf8");
|
|
1515
|
+
const first = text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length);
|
|
1516
|
+
const payload = asRecord(asRecord(JSON.parse(first))?.payload);
|
|
1517
|
+
const fileCwd = firstString(payload, ["cwd"])?.replace(/\/+$/, "");
|
|
1518
|
+
const metaId = firstString(payload, ["session_id", "id"]) ?? candidate.sessionId;
|
|
1519
|
+
if (fileCwd === wanted)
|
|
1520
|
+
return { path: candidate.path, sessionId: metaId };
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return undefined;
|
|
1527
|
+
}
|
|
1528
|
+
function publicWorkspaceRoots(roots) {
|
|
1529
|
+
if (!Array.isArray(roots))
|
|
1530
|
+
return undefined;
|
|
1531
|
+
const cleaned = roots
|
|
1532
|
+
.filter((root) => typeof root === "string" && root.length > 0)
|
|
1533
|
+
.filter((root) => !root.includes("/.codex/visualizations/"));
|
|
1534
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
1535
|
+
}
|
|
1536
|
+
function isInjectedCodexUserText(text) {
|
|
1537
|
+
const trimmed = text.trim();
|
|
1538
|
+
return (trimmed.startsWith("<app-context>") ||
|
|
1539
|
+
trimmed.startsWith("<recommended_plugins>") ||
|
|
1540
|
+
trimmed.startsWith("<skills_instructions>") ||
|
|
1541
|
+
trimmed.startsWith("# AGENTS.md") ||
|
|
1542
|
+
trimmed.startsWith("You are `/root`"));
|
|
1543
|
+
}
|
|
1544
|
+
function textsFromCodexContent(content) {
|
|
1545
|
+
if (typeof content === "string" && content.trim())
|
|
1546
|
+
return [content];
|
|
1547
|
+
if (!Array.isArray(content))
|
|
1548
|
+
return [];
|
|
1549
|
+
const texts = [];
|
|
1550
|
+
for (const block of content) {
|
|
1551
|
+
const rec = asRecord(block);
|
|
1552
|
+
const text = typeof rec?.text === "string" ? rec.text : undefined;
|
|
1553
|
+
if (text?.trim())
|
|
1554
|
+
texts.push(text);
|
|
1555
|
+
}
|
|
1556
|
+
return texts;
|
|
1557
|
+
}
|
|
1558
|
+
/** Incremental Codex jsonl parser so live TUI/desktop sessions can tail the file. */
|
|
1559
|
+
export class CodexRolloutParser {
|
|
1560
|
+
toolNames = new Map();
|
|
1561
|
+
index = 0;
|
|
1562
|
+
workspaceRoots;
|
|
1563
|
+
sessionCwd;
|
|
1564
|
+
consume(text, conversationId) {
|
|
1565
|
+
const items = [];
|
|
1566
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1567
|
+
if (!line.trim())
|
|
1568
|
+
continue;
|
|
1569
|
+
const next = this.consumeLine(line, conversationId);
|
|
1570
|
+
if (next)
|
|
1571
|
+
items.push(next);
|
|
1572
|
+
}
|
|
1573
|
+
return items;
|
|
1574
|
+
}
|
|
1575
|
+
consumeLine(line, conversationId) {
|
|
1576
|
+
let record;
|
|
1577
|
+
try {
|
|
1578
|
+
record = asRecord(JSON.parse(line));
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
return undefined;
|
|
1582
|
+
}
|
|
1583
|
+
if (!record)
|
|
1584
|
+
return undefined;
|
|
1585
|
+
const payload = asRecord(record.payload) ?? record;
|
|
1586
|
+
const recordType = firstString(record, ["type"]);
|
|
1587
|
+
const payloadType = firstString(payload, ["type"]);
|
|
1588
|
+
const roots = publicWorkspaceRoots(payload.workspace_roots ?? payload.workspaceRoots);
|
|
1589
|
+
if (roots)
|
|
1590
|
+
this.workspaceRoots = roots;
|
|
1591
|
+
const cwd = firstString(payload, ["cwd"]);
|
|
1592
|
+
if (cwd)
|
|
1593
|
+
this.sessionCwd = cwd;
|
|
1594
|
+
const createdAt = parseTimestamp(record.timestamp ?? payload.timestamp) ?? Date.now();
|
|
1595
|
+
if (recordType === "response_item" && payloadType === "message") {
|
|
1596
|
+
const roleRaw = firstString(payload, ["role"]);
|
|
1597
|
+
if (roleRaw !== "user" && roleRaw !== "assistant")
|
|
1598
|
+
return undefined;
|
|
1599
|
+
const texts = textsFromCodexContent(payload.content).filter((text) => roleRaw === "assistant" || !isInjectedCodexUserText(text));
|
|
1600
|
+
if (texts.length === 0)
|
|
1601
|
+
return undefined;
|
|
1602
|
+
const text = texts.join("\n\n");
|
|
1603
|
+
this.index += 1;
|
|
1604
|
+
return {
|
|
1605
|
+
id: firstString(payload, ["id"]) ?? id(`codex-msg-${this.index}`),
|
|
1606
|
+
conversationId,
|
|
1607
|
+
type: "message",
|
|
1608
|
+
kind: "chat",
|
|
1609
|
+
role: roleRaw,
|
|
1610
|
+
text,
|
|
1611
|
+
content: [{ type: "text", text }],
|
|
1612
|
+
createdAt,
|
|
1613
|
+
updatedAt: createdAt,
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
if (recordType === "response_item" && payloadType === "reasoning") {
|
|
1617
|
+
const texts = textsFromCodexContent(payload.content ?? payload.summary);
|
|
1618
|
+
if (texts.length === 0)
|
|
1619
|
+
return undefined;
|
|
1620
|
+
this.index += 1;
|
|
1621
|
+
return {
|
|
1622
|
+
id: firstString(payload, ["id"]) ?? id(`codex-think-${this.index}`),
|
|
1623
|
+
conversationId,
|
|
1624
|
+
type: "status",
|
|
1625
|
+
kind: "thinking",
|
|
1626
|
+
text: texts.join("\n"),
|
|
1627
|
+
createdAt,
|
|
1628
|
+
updatedAt: createdAt,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
if (recordType === "response_item" && (payloadType === "custom_tool_call" || payloadType === "function_call")) {
|
|
1632
|
+
const callId = firstString(payload, ["call_id", "id"]) ?? id(`codex-tool-${this.index}`);
|
|
1633
|
+
const name = firstString(payload, ["name"]) ?? "tool";
|
|
1634
|
+
this.toolNames.set(callId, name);
|
|
1635
|
+
const input = firstString(payload, ["input", "arguments"]) ?? stringify(payload.input ?? payload.arguments ?? "");
|
|
1636
|
+
this.index += 1;
|
|
1637
|
+
return {
|
|
1638
|
+
id: `tool:${callId}`,
|
|
1639
|
+
conversationId,
|
|
1640
|
+
type: "tool_call",
|
|
1641
|
+
kind: name === "exec" || name.includes("command") ? "command_execution" : "tool_activity",
|
|
1642
|
+
itemId: callId,
|
|
1643
|
+
toolCall: {
|
|
1644
|
+
id: callId,
|
|
1645
|
+
name,
|
|
1646
|
+
input,
|
|
1647
|
+
createdAt,
|
|
1648
|
+
status: "running",
|
|
1649
|
+
},
|
|
1650
|
+
createdAt,
|
|
1651
|
+
updatedAt: createdAt,
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
if (recordType === "response_item" && (payloadType === "custom_tool_call_output" || payloadType === "function_call_output")) {
|
|
1655
|
+
const callId = firstString(payload, ["call_id", "id"]);
|
|
1656
|
+
if (!callId)
|
|
1657
|
+
return undefined;
|
|
1658
|
+
const outputTexts = textsFromCodexContent(payload.output);
|
|
1659
|
+
const output = outputTexts.join("\n") || stringify(payload.output ?? "");
|
|
1660
|
+
this.index += 1;
|
|
1661
|
+
return {
|
|
1662
|
+
id: `tool:${callId}`,
|
|
1663
|
+
conversationId,
|
|
1664
|
+
type: "tool_call",
|
|
1665
|
+
kind: "tool_activity",
|
|
1666
|
+
itemId: callId,
|
|
1667
|
+
toolCall: {
|
|
1668
|
+
id: callId,
|
|
1669
|
+
name: this.toolNames.get(callId) ?? "tool",
|
|
1670
|
+
output,
|
|
1671
|
+
createdAt,
|
|
1672
|
+
status: "completed",
|
|
1673
|
+
},
|
|
1674
|
+
createdAt,
|
|
1675
|
+
updatedAt: createdAt,
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
return undefined;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
/** Parse a Codex on-disk rollout into timeline items. App-server thread/read
|
|
1682
|
+
* only sees threads it owns; desktop/TUI sessions live in jsonl. */
|
|
1683
|
+
export function timelineItemsFromCodexRolloutText(text, conversationId) {
|
|
1684
|
+
return new CodexRolloutParser().consume(text, conversationId).slice(-MAX_TIMELINE_ITEMS);
|
|
1685
|
+
}
|
|
1445
1686
|
function parseRemoteSessions(value) {
|
|
1446
1687
|
const raw = asRecord(value);
|
|
1447
1688
|
const sessionsValue = Array.isArray(value) ? value :
|
|
@@ -1826,6 +2067,22 @@ export class AgentWorkspaceProxy {
|
|
|
1826
2067
|
// Echo the updated record so every client refreshes (the web store
|
|
1827
2068
|
// force-replaces by id on any event carrying `conversation`).
|
|
1828
2069
|
this.emitConversation(conversation);
|
|
2070
|
+
if (conversation.agentSessionId) {
|
|
2071
|
+
const client = this.clientForProvider(conversation.provider);
|
|
2072
|
+
if (client instanceof AcpClient && model?.trim() && client.advertised.setModel && conversation.provider !== "codex") {
|
|
2073
|
+
try {
|
|
2074
|
+
await client.setSessionModel({
|
|
2075
|
+
sessionId: conversation.agentSessionId,
|
|
2076
|
+
modelId: model.trim(),
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
catch (error) {
|
|
2080
|
+
if (this.input.verbose) {
|
|
2081
|
+
process.stderr.write(`[agent:v2] session/set_model failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
1829
2086
|
if (conversation.provider === "codex" && conversation.agentSessionId) {
|
|
1830
2087
|
const client = this.clientForProvider(conversation.provider);
|
|
1831
2088
|
const settingsChanged = model !== undefined ||
|
|
@@ -1919,6 +2176,10 @@ export class AgentWorkspaceProxy {
|
|
|
1919
2176
|
const conversation = this.conversations.get(payload.conversationId);
|
|
1920
2177
|
if (!conversation)
|
|
1921
2178
|
break;
|
|
2179
|
+
if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
|
|
2180
|
+
this.rejectAgentAction(conversation, `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 无法停止它,请在那边操作。`, conversation.status);
|
|
2181
|
+
break;
|
|
2182
|
+
}
|
|
1922
2183
|
const turnId = this.currentTurnIds.get(payload.conversationId);
|
|
1923
2184
|
if (this.protocolForProvider(conversation.provider) === "codex-app-server" && !turnId) {
|
|
1924
2185
|
this.rejectAgentAction(conversation, "无法停止 Codex:当前运行 turn 尚未同步,请重新打开对话后再试。", conversation.status);
|
|
@@ -1983,8 +2244,13 @@ export class AgentWorkspaceProxy {
|
|
|
1983
2244
|
fsSyncRunning = false;
|
|
1984
2245
|
lastPushedConversationSignature;
|
|
1985
2246
|
lastHydrateAt = new Map();
|
|
1986
|
-
|
|
1987
|
-
|
|
2247
|
+
/** Session ids this LinkShell process created via app-server `newSession`. */
|
|
2248
|
+
ownedCodexSessionIds = new Set();
|
|
2249
|
+
/** Conversations attached to an external Codex TUI/desktop rollout (same file, not a copy). */
|
|
2250
|
+
attachedExternalCodex = new Set();
|
|
2251
|
+
codexFollow = new Map();
|
|
2252
|
+
static FS_SYNC_THROTTLE_MS = 400;
|
|
2253
|
+
static EXTERNAL_HYDRATE_THROTTLE_MS = 400;
|
|
1988
2254
|
startSessionWatchers() {
|
|
1989
2255
|
const roots = [
|
|
1990
2256
|
join(homedir(), ".claude", "projects"),
|
|
@@ -2064,7 +2330,9 @@ export class AgentWorkspaceProxy {
|
|
|
2064
2330
|
this.lastHydrateAt.set(conversation.id, now);
|
|
2065
2331
|
try {
|
|
2066
2332
|
await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
|
|
2067
|
-
this.
|
|
2333
|
+
if (this.followCodexRolloutDisk(conversation, true) > 0) {
|
|
2334
|
+
this.sendSnapshot(conversation.id);
|
|
2335
|
+
}
|
|
2068
2336
|
}
|
|
2069
2337
|
catch (error) {
|
|
2070
2338
|
if (this.input.verbose) {
|
|
@@ -2192,11 +2460,18 @@ export class AgentWorkspaceProxy {
|
|
|
2192
2460
|
: `${snapshot.provider}:${snapshot.pid}`;
|
|
2193
2461
|
seenKeys.add(key);
|
|
2194
2462
|
this.processMissCounts.set(key, 0);
|
|
2195
|
-
|
|
2196
|
-
|
|
2463
|
+
let sessionId = snapshot.sessionId;
|
|
2464
|
+
if (!sessionId && snapshot.provider === "codex") {
|
|
2465
|
+
const matched = findLatestCodexRolloutForCwd(snapshot.cwd ?? this.input.cwd);
|
|
2466
|
+
if (matched)
|
|
2467
|
+
sessionId = matched.sessionId;
|
|
2468
|
+
}
|
|
2469
|
+
const existingId = sessionId
|
|
2470
|
+
? this.conversationByAgentSessionId.get(sessionId)
|
|
2197
2471
|
: undefined;
|
|
2198
|
-
const liveId = liveConversationId(snapshot);
|
|
2199
|
-
const conversationId = existingId
|
|
2472
|
+
const liveId = liveConversationId({ ...snapshot, sessionId });
|
|
2473
|
+
const conversationId = existingId
|
|
2474
|
+
?? (sessionId ? makeAgentV2RemoteConversationId(snapshot.provider, sessionId) : liveId);
|
|
2200
2475
|
const existing = this.conversations.get(conversationId);
|
|
2201
2476
|
const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
|
|
2202
2477
|
const driving = this.currentTurnIds.has(conversationId);
|
|
@@ -2211,10 +2486,12 @@ export class AgentWorkspaceProxy {
|
|
|
2211
2486
|
existing.cwd = cwd;
|
|
2212
2487
|
if (!existing.title)
|
|
2213
2488
|
existing.title = agentProviderLabel(snapshot.provider);
|
|
2214
|
-
if (
|
|
2215
|
-
existing.agentSessionId =
|
|
2216
|
-
this.conversationByAgentSessionId.set(
|
|
2489
|
+
if (sessionId && !existing.agentSessionId) {
|
|
2490
|
+
existing.agentSessionId = sessionId;
|
|
2491
|
+
this.conversationByAgentSessionId.set(sessionId, existing.id);
|
|
2217
2492
|
}
|
|
2493
|
+
this.attachedExternalCodex.add(existing.id);
|
|
2494
|
+
existing.control = "attached";
|
|
2218
2495
|
if (changed) {
|
|
2219
2496
|
listChanged = true;
|
|
2220
2497
|
this.emitConversation(existing);
|
|
@@ -2224,7 +2501,7 @@ export class AgentWorkspaceProxy {
|
|
|
2224
2501
|
const now = Date.now();
|
|
2225
2502
|
const conversation = {
|
|
2226
2503
|
id: conversationId,
|
|
2227
|
-
agentSessionId:
|
|
2504
|
+
agentSessionId: sessionId,
|
|
2228
2505
|
provider: snapshot.provider,
|
|
2229
2506
|
cwd,
|
|
2230
2507
|
title: agentProviderLabel(snapshot.provider),
|
|
@@ -2233,10 +2510,12 @@ export class AgentWorkspaceProxy {
|
|
|
2233
2510
|
lastMessagePreview: "本机终端中的 Agent 进程",
|
|
2234
2511
|
lastActivityAt: now,
|
|
2235
2512
|
createdAt: now,
|
|
2513
|
+
control: "attached",
|
|
2236
2514
|
};
|
|
2237
2515
|
this.conversations.set(conversation.id, conversation);
|
|
2238
|
-
if (
|
|
2239
|
-
this.conversationByAgentSessionId.set(
|
|
2516
|
+
if (sessionId)
|
|
2517
|
+
this.conversationByAgentSessionId.set(sessionId, conversation.id);
|
|
2518
|
+
this.attachedExternalCodex.add(conversation.id);
|
|
2240
2519
|
this.emitConversation(conversation);
|
|
2241
2520
|
listChanged = true;
|
|
2242
2521
|
}
|
|
@@ -2426,8 +2705,15 @@ export class AgentWorkspaceProxy {
|
|
|
2426
2705
|
});
|
|
2427
2706
|
}
|
|
2428
2707
|
async refreshProviderCapabilities(provider, client, protocol) {
|
|
2429
|
-
if (client instanceof AcpClient
|
|
2430
|
-
|
|
2708
|
+
if (client instanceof AcpClient) {
|
|
2709
|
+
const prior = this.providerCapabilities.get(provider);
|
|
2710
|
+
this.providerCapabilities.set(provider, {
|
|
2711
|
+
...prior,
|
|
2712
|
+
advertised: client.advertised,
|
|
2713
|
+
});
|
|
2714
|
+
if (protocol !== "codex-app-server")
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2431
2717
|
const listModels = client.listModels;
|
|
2432
2718
|
let runtimeCapabilities;
|
|
2433
2719
|
if (typeof listModels === "function") {
|
|
@@ -2457,6 +2743,8 @@ export class AgentWorkspaceProxy {
|
|
|
2457
2743
|
commands: mergeCommands(runtimeCapabilities?.commands, skillCommands),
|
|
2458
2744
|
modes: runtimeCapabilities?.modes,
|
|
2459
2745
|
currentMode: runtimeCapabilities?.currentMode,
|
|
2746
|
+
advertised: this.providerCapabilities.get(provider)?.advertised
|
|
2747
|
+
?? (client instanceof AcpClient ? client.advertised : undefined),
|
|
2460
2748
|
// Preserve any MCP status already accumulated from startupStatus events.
|
|
2461
2749
|
mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
|
|
2462
2750
|
};
|
|
@@ -2482,7 +2770,12 @@ export class AgentWorkspaceProxy {
|
|
|
2482
2770
|
commands: mergeCommands(runtimeCapabilities.commands, skillCommands),
|
|
2483
2771
|
};
|
|
2484
2772
|
}
|
|
2485
|
-
this.providerCapabilities.set(provider,
|
|
2773
|
+
this.providerCapabilities.set(provider, {
|
|
2774
|
+
...runtimeCapabilities,
|
|
2775
|
+
advertised: runtimeCapabilities.advertised
|
|
2776
|
+
?? this.providerCapabilities.get(provider)?.advertised
|
|
2777
|
+
?? (client instanceof AcpClient ? client.advertised : undefined),
|
|
2778
|
+
});
|
|
2486
2779
|
}
|
|
2487
2780
|
if (listedMcp.length > 0)
|
|
2488
2781
|
this.mergeMcpServers(provider, listedMcp);
|
|
@@ -2594,6 +2887,8 @@ export class AgentWorkspaceProxy {
|
|
|
2594
2887
|
lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
|
|
2595
2888
|
createdAt: remote.createdAt ?? existing?.createdAt ?? now,
|
|
2596
2889
|
usage,
|
|
2890
|
+
group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
|
|
2891
|
+
workspaceRoots: existing?.workspaceRoots,
|
|
2597
2892
|
};
|
|
2598
2893
|
this.conversations.set(conversation.id, conversation);
|
|
2599
2894
|
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
@@ -2729,7 +3024,13 @@ export class AgentWorkspaceProxy {
|
|
|
2729
3024
|
if (turnCount > 0)
|
|
2730
3025
|
this.historyCursors.set(conversation.id, String(turnCount));
|
|
2731
3026
|
}
|
|
2732
|
-
|
|
3027
|
+
let hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
|
|
3028
|
+
if (conversation.provider === "codex") {
|
|
3029
|
+
this.followCodexRolloutDisk(conversation, false);
|
|
3030
|
+
}
|
|
3031
|
+
if (hydratedItems.length === 0) {
|
|
3032
|
+
hydratedItems = this.timelines.get(conversation.id) ?? [];
|
|
3033
|
+
}
|
|
2733
3034
|
if (hydratedItems.length === 0)
|
|
2734
3035
|
return;
|
|
2735
3036
|
const existing = this.timelines.get(conversation.id) ?? [];
|
|
@@ -2747,9 +3048,97 @@ export class AgentWorkspaceProxy {
|
|
|
2747
3048
|
const lastPreview = [...nextItems].reverse()
|
|
2748
3049
|
.map((item) => previewText(previewFromTimelineItem(item) ?? ""))
|
|
2749
3050
|
.find(Boolean);
|
|
2750
|
-
if (lastPreview
|
|
3051
|
+
if (lastPreview)
|
|
2751
3052
|
conversation.lastMessagePreview = lastPreview;
|
|
3053
|
+
}
|
|
3054
|
+
/** Tail a Codex desktop/TUI rollout. `emit` sends agent.v2.event for new lines
|
|
3055
|
+
* so a phone/web client sees tokens as the jsonl grows, not only on open. */
|
|
3056
|
+
followCodexRolloutDisk(conversation, emit) {
|
|
3057
|
+
if (conversation.provider !== "codex")
|
|
3058
|
+
return 0;
|
|
3059
|
+
let path = conversation.agentSessionId
|
|
3060
|
+
? findCodexRolloutPath(conversation.agentSessionId)
|
|
3061
|
+
: undefined;
|
|
3062
|
+
if (!path && conversation.cwd) {
|
|
3063
|
+
const matched = findLatestCodexRolloutForCwd(conversation.cwd);
|
|
3064
|
+
if (matched) {
|
|
3065
|
+
path = matched.path;
|
|
3066
|
+
if (!conversation.agentSessionId) {
|
|
3067
|
+
conversation.agentSessionId = matched.sessionId;
|
|
3068
|
+
this.conversationByAgentSessionId.set(matched.sessionId, conversation.id);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
2752
3071
|
}
|
|
3072
|
+
if (!path)
|
|
3073
|
+
return 0;
|
|
3074
|
+
let follow = this.codexFollow.get(conversation.id);
|
|
3075
|
+
if (!follow || follow.path !== path) {
|
|
3076
|
+
follow = { path, offset: 0, carry: "", parser: new CodexRolloutParser() };
|
|
3077
|
+
this.codexFollow.set(conversation.id, follow);
|
|
3078
|
+
}
|
|
3079
|
+
let stat;
|
|
3080
|
+
try {
|
|
3081
|
+
stat = statSync(path);
|
|
3082
|
+
}
|
|
3083
|
+
catch {
|
|
3084
|
+
return 0;
|
|
3085
|
+
}
|
|
3086
|
+
if (stat.size < follow.offset) {
|
|
3087
|
+
follow.offset = 0;
|
|
3088
|
+
follow.carry = "";
|
|
3089
|
+
follow.parser = new CodexRolloutParser();
|
|
3090
|
+
}
|
|
3091
|
+
if (stat.size === follow.offset && !follow.carry)
|
|
3092
|
+
return 0;
|
|
3093
|
+
const length = stat.size - follow.offset;
|
|
3094
|
+
const buf = Buffer.alloc(length);
|
|
3095
|
+
const fd = openSync(path, "r");
|
|
3096
|
+
try {
|
|
3097
|
+
readSync(fd, buf, 0, length, follow.offset);
|
|
3098
|
+
}
|
|
3099
|
+
finally {
|
|
3100
|
+
closeSync(fd);
|
|
3101
|
+
}
|
|
3102
|
+
follow.offset = stat.size;
|
|
3103
|
+
const combined = follow.carry + buf.toString("utf8");
|
|
3104
|
+
const lastNl = combined.lastIndexOf("\n");
|
|
3105
|
+
if (lastNl < 0) {
|
|
3106
|
+
follow.carry = combined;
|
|
3107
|
+
return 0;
|
|
3108
|
+
}
|
|
3109
|
+
const complete = combined.slice(0, lastNl + 1);
|
|
3110
|
+
follow.carry = combined.slice(lastNl + 1);
|
|
3111
|
+
const items = follow.parser.consume(complete, conversation.id);
|
|
3112
|
+
if (items.length === 0)
|
|
3113
|
+
return 0;
|
|
3114
|
+
if (emit) {
|
|
3115
|
+
for (const item of items)
|
|
3116
|
+
this.upsertItem(conversation.id, item);
|
|
3117
|
+
}
|
|
3118
|
+
else {
|
|
3119
|
+
const existing = this.timelines.get(conversation.id) ?? [];
|
|
3120
|
+
const merged = new Map(existing.map((item) => [item.id, item]));
|
|
3121
|
+
for (const item of items)
|
|
3122
|
+
merged.set(item.id, item);
|
|
3123
|
+
const nextItems = [...merged.values()]
|
|
3124
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
3125
|
+
.slice(-MAX_TIMELINE_ITEMS);
|
|
3126
|
+
this.timelines.set(conversation.id, nextItems);
|
|
3127
|
+
for (const item of nextItems)
|
|
3128
|
+
this.rememberItemConversationId(conversation.id, item);
|
|
3129
|
+
}
|
|
3130
|
+
const timeline = this.timelines.get(conversation.id) ?? [];
|
|
3131
|
+
const lastPreview = [...timeline].reverse()
|
|
3132
|
+
.map((item) => previewText(previewFromTimelineItem(item) ?? ""))
|
|
3133
|
+
.find(Boolean);
|
|
3134
|
+
if (lastPreview)
|
|
3135
|
+
conversation.lastMessagePreview = lastPreview;
|
|
3136
|
+
conversation.lastActivityAt = Date.now();
|
|
3137
|
+
if (follow.parser.workspaceRoots?.length)
|
|
3138
|
+
conversation.workspaceRoots = follow.parser.workspaceRoots;
|
|
3139
|
+
if (follow.parser.sessionCwd && !conversation.cwd)
|
|
3140
|
+
conversation.cwd = follow.parser.sessionCwd;
|
|
3141
|
+
return items.length;
|
|
2753
3142
|
}
|
|
2754
3143
|
sendCapabilities() {
|
|
2755
3144
|
const catalog = [...new Set([
|
|
@@ -2760,11 +3149,23 @@ export class AgentWorkspaceProxy {
|
|
|
2760
3149
|
const client = this.clients.get(provider);
|
|
2761
3150
|
const protocol = this.agentProtocols.get(provider);
|
|
2762
3151
|
const runtimeCapabilities = this.providerCapabilities.get(provider);
|
|
3152
|
+
const advertised = runtimeCapabilities?.advertised
|
|
3153
|
+
?? (client instanceof AcpClient ? client.advertised : undefined);
|
|
2763
3154
|
const enabled = Boolean(client);
|
|
2764
|
-
const
|
|
3155
|
+
const isAcp = protocol === "acp";
|
|
3156
|
+
const supportsImages = enabled && protocolSupportsImages(protocol, advertised);
|
|
2765
3157
|
const isClaudeFallback = protocol === "claude-stream-json";
|
|
2766
3158
|
const supportsPermission = enabled && !isClaudeFallback;
|
|
2767
3159
|
const supportsReasoningEffort = enabled && !isClaudeFallback;
|
|
3160
|
+
const supportsSessionList = enabled && (isAcp ? Boolean(advertised?.listSession) : !isClaudeFallback);
|
|
3161
|
+
const supportsSessionLoad = enabled && (isAcp ? Boolean(advertised?.loadSession) : !isClaudeFallback);
|
|
3162
|
+
const supportsCancel = enabled && (isAcp ? Boolean(advertised?.cancel) : true);
|
|
3163
|
+
const supportsFork = enabled && (isAcp
|
|
3164
|
+
? Boolean(advertised?.forkSession)
|
|
3165
|
+
: protocol === "codex-app-server" || protocol === "claude-agent-sdk");
|
|
3166
|
+
const supportsSetModel = enabled && (isAcp
|
|
3167
|
+
? Boolean(advertised?.setModel)
|
|
3168
|
+
: protocol === "codex-app-server");
|
|
2768
3169
|
const protocolBacked = isProtocolAgentProvider(provider);
|
|
2769
3170
|
const commands = mergeCommands(defaultProviderCommands(provider, this.input.cwd, enabled), runtimeCapabilities?.commands);
|
|
2770
3171
|
const currentMode = [...this.conversations.values()].find((conversation) => conversation.provider === provider)?.collaborationMode;
|
|
@@ -2779,8 +3180,8 @@ export class AgentWorkspaceProxy {
|
|
|
2779
3180
|
: `${providerLabel(provider)} 已在本机检测;远程对话请在终端继续`,
|
|
2780
3181
|
supportsImages,
|
|
2781
3182
|
supportsPermission,
|
|
2782
|
-
supportsPlan: enabled,
|
|
2783
|
-
supportsCancel
|
|
3183
|
+
supportsPlan: enabled && (isAcp ? Boolean(advertised?.embeddedContext) : true),
|
|
3184
|
+
supportsCancel,
|
|
2784
3185
|
models: runtimeCapabilities?.models ?? [{ id: "default", label: "默认模型" }],
|
|
2785
3186
|
defaultModel: runtimeCapabilities?.defaultModel ?? "default",
|
|
2786
3187
|
reasoningEfforts: supportsReasoningEffort
|
|
@@ -2795,11 +3196,17 @@ export class AgentWorkspaceProxy {
|
|
|
2795
3196
|
currentMode,
|
|
2796
3197
|
features: {
|
|
2797
3198
|
images: supportsImages,
|
|
3199
|
+
audio: Boolean(advertised?.audio),
|
|
2798
3200
|
permissions: supportsPermission,
|
|
2799
|
-
plan: enabled,
|
|
2800
|
-
cancel:
|
|
3201
|
+
plan: enabled && (protocol === "acp" ? Boolean(advertised?.embeddedContext) : true),
|
|
3202
|
+
cancel: supportsCancel,
|
|
2801
3203
|
reasoningEffort: supportsReasoningEffort,
|
|
2802
3204
|
streamJsonFallback: isClaudeFallback,
|
|
3205
|
+
loadSession: supportsSessionLoad,
|
|
3206
|
+
sessionList: supportsSessionList,
|
|
3207
|
+
sessionFork: supportsFork,
|
|
3208
|
+
setModel: supportsSetModel,
|
|
3209
|
+
mcp: Boolean(advertised?.mcp) || Boolean(runtimeCapabilities?.mcpServers && runtimeCapabilities.mcpServers.size > 0),
|
|
2803
3210
|
},
|
|
2804
3211
|
mcpServers: runtimeCapabilities?.mcpServers && runtimeCapabilities.mcpServers.size > 0
|
|
2805
3212
|
? [...runtimeCapabilities.mcpServers.values()]
|
|
@@ -2818,13 +3225,13 @@ export class AgentWorkspaceProxy {
|
|
|
2818
3225
|
protocolVersion: 1,
|
|
2819
3226
|
workspaceProtocolVersion: 2,
|
|
2820
3227
|
error: anyEnabled ? undefined : "没有可远程驱动的 Agent。请安装 Claude Code 或 Codex CLI;其它 Agent 只要在本机终端运行就会出现在列表里。",
|
|
2821
|
-
supportsSessionList:
|
|
2822
|
-
supportsSessionLoad:
|
|
3228
|
+
supportsSessionList: providers.some((p) => p.features.sessionList),
|
|
3229
|
+
supportsSessionLoad: providers.some((p) => p.features.loadSession),
|
|
2823
3230
|
supportsImages: providers.some((p) => p.supportsImages),
|
|
2824
|
-
supportsAudio:
|
|
3231
|
+
supportsAudio: providers.some((p) => p.features.audio),
|
|
2825
3232
|
supportsPermission: anyPermission,
|
|
2826
|
-
supportsPlan:
|
|
2827
|
-
supportsCancel:
|
|
3233
|
+
supportsPlan: providers.some((p) => p.supportsPlan),
|
|
3234
|
+
supportsCancel: providers.some((p) => p.supportsCancel),
|
|
2828
3235
|
},
|
|
2829
3236
|
}));
|
|
2830
3237
|
}
|
|
@@ -2917,7 +3324,7 @@ export class AgentWorkspaceProxy {
|
|
|
2917
3324
|
this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
|
|
2918
3325
|
}
|
|
2919
3326
|
}
|
|
2920
|
-
else if (
|
|
3327
|
+
else if (typeof forkThread === "function" && sourceSessionId) {
|
|
2921
3328
|
if (source?.cwd && !payload.cwd)
|
|
2922
3329
|
cwd = source.cwd;
|
|
2923
3330
|
try {
|
|
@@ -2958,61 +3365,95 @@ export class AgentWorkspaceProxy {
|
|
|
2958
3365
|
});
|
|
2959
3366
|
}
|
|
2960
3367
|
}
|
|
2961
|
-
if (existingConversation &&
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
existingConversation =
|
|
3368
|
+
if (existingConversation &&
|
|
3369
|
+
!existingConversation.agentSessionId &&
|
|
3370
|
+
provider === "codex") {
|
|
3371
|
+
const matched = findLatestCodexRolloutForCwd(existingConversation.cwd ?? cwd);
|
|
3372
|
+
if (matched) {
|
|
3373
|
+
existingConversation.agentSessionId = matched.sessionId;
|
|
3374
|
+
this.conversationByAgentSessionId.set(matched.sessionId, existingConversation.id);
|
|
2967
3375
|
}
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
//
|
|
2973
|
-
//
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
3376
|
+
}
|
|
3377
|
+
if (existingConversation && existingConversation.status !== "error") {
|
|
3378
|
+
const owned = Boolean(existingConversation.agentSessionId &&
|
|
3379
|
+
this.ownedCodexSessionIds.has(existingConversation.agentSessionId));
|
|
3380
|
+
// External Codex (desktop/TUI) is the same session on disk. Attach by
|
|
3381
|
+
// tailing that rollout — never loadSession/newSession (those mint another thread).
|
|
3382
|
+
if (provider === "codex" &&
|
|
3383
|
+
!owned &&
|
|
3384
|
+
this.attachedExternalCodex.has(existingConversation.id) &&
|
|
2977
3385
|
!this.currentTurnIds.has(existingConversation.id)) {
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
3386
|
+
existingConversation.control = "attached";
|
|
3387
|
+
this.followCodexRolloutDisk(existingConversation, false);
|
|
3388
|
+
this.activeConversationId = existingConversation.id;
|
|
3389
|
+
this.input.send(createEnvelope({
|
|
3390
|
+
type: "agent.v2.conversation.opened",
|
|
3391
|
+
sessionId: this.input.sessionId,
|
|
3392
|
+
payload: {
|
|
3393
|
+
conversation: existingConversation,
|
|
3394
|
+
snapshot: this.timelines.get(existingConversation.id) ?? [],
|
|
3395
|
+
requestedConversationId: payload.conversationId,
|
|
3396
|
+
},
|
|
3397
|
+
}));
|
|
3398
|
+
return existingConversation;
|
|
3399
|
+
}
|
|
3400
|
+
if (existingConversation.agentSessionId) {
|
|
3401
|
+
const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
|
|
3402
|
+
if (payload.conversationId &&
|
|
3403
|
+
payload.conversationId === requestedCanonicalId &&
|
|
3404
|
+
existingConversation.id !== payload.conversationId) {
|
|
3405
|
+
existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
|
|
3406
|
+
}
|
|
3407
|
+
// Hydrate the transcript from disk when we have no timeline yet, UNLESS
|
|
3408
|
+
// LinkShell is itself driving a live turn for this conversation — in that
|
|
3409
|
+
// case the event stream is the source of truth and re-hydrating would be
|
|
3410
|
+
// redundant/racy. We key on currentTurnIds (LinkShell-driving), NOT on
|
|
3411
|
+
// status === "running": an EXTERNALLY-driven session also reports
|
|
3412
|
+
// "running" now (live-status feature) but has no event stream, so gating
|
|
3413
|
+
// on status would skip hydration and leave its timeline stuck empty
|
|
3414
|
+
// ("加载对话记录…" forever).
|
|
3415
|
+
if ((this.timelines.get(existingConversation.id) ?? []).length === 0 &&
|
|
3416
|
+
!this.currentTurnIds.has(existingConversation.id)) {
|
|
2981
3417
|
try {
|
|
2982
|
-
|
|
3418
|
+
const existingAgentSessionId = existingConversation.agentSessionId;
|
|
3419
|
+
let result;
|
|
3420
|
+
try {
|
|
3421
|
+
result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
|
|
3422
|
+
}
|
|
3423
|
+
catch (error) {
|
|
3424
|
+
const recovered = await this.recycleProviderClient(provider, error);
|
|
3425
|
+
if (!recovered)
|
|
3426
|
+
throw error;
|
|
3427
|
+
client = recovered;
|
|
3428
|
+
result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
|
|
3429
|
+
}
|
|
3430
|
+
const nextAgentSessionId = this.extractSessionId(result);
|
|
3431
|
+
if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
|
|
3432
|
+
this.conversationByAgentSessionId.delete(existingAgentSessionId);
|
|
3433
|
+
existingConversation.agentSessionId = nextAgentSessionId;
|
|
3434
|
+
this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
|
|
3435
|
+
}
|
|
3436
|
+
await this.hydrateConversationFromProvider(existingConversation, result);
|
|
2983
3437
|
}
|
|
2984
3438
|
catch (error) {
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
|
|
2990
|
-
}
|
|
2991
|
-
const nextAgentSessionId = this.extractSessionId(result);
|
|
2992
|
-
if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
|
|
2993
|
-
this.conversationByAgentSessionId.delete(existingAgentSessionId);
|
|
2994
|
-
existingConversation.agentSessionId = nextAgentSessionId;
|
|
2995
|
-
this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
|
|
2996
|
-
}
|
|
2997
|
-
await this.hydrateConversationFromProvider(existingConversation, result);
|
|
2998
|
-
}
|
|
2999
|
-
catch (error) {
|
|
3000
|
-
if (this.input.verbose) {
|
|
3001
|
-
process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
3439
|
+
if (this.input.verbose) {
|
|
3440
|
+
process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
3441
|
+
}
|
|
3442
|
+
await this.hydrateConversationFromProvider(existingConversation, undefined);
|
|
3002
3443
|
}
|
|
3003
3444
|
}
|
|
3445
|
+
this.activeConversationId = existingConversation.id;
|
|
3446
|
+
this.input.send(createEnvelope({
|
|
3447
|
+
type: "agent.v2.conversation.opened",
|
|
3448
|
+
sessionId: this.input.sessionId,
|
|
3449
|
+
payload: {
|
|
3450
|
+
conversation: existingConversation,
|
|
3451
|
+
snapshot: this.timelines.get(existingConversation.id) ?? [],
|
|
3452
|
+
requestedConversationId: payload.conversationId,
|
|
3453
|
+
},
|
|
3454
|
+
}));
|
|
3455
|
+
return existingConversation;
|
|
3004
3456
|
}
|
|
3005
|
-
this.activeConversationId = existingConversation.id;
|
|
3006
|
-
this.input.send(createEnvelope({
|
|
3007
|
-
type: "agent.v2.conversation.opened",
|
|
3008
|
-
sessionId: this.input.sessionId,
|
|
3009
|
-
payload: {
|
|
3010
|
-
conversation: existingConversation,
|
|
3011
|
-
snapshot: this.timelines.get(existingConversation.id) ?? [],
|
|
3012
|
-
requestedConversationId: payload.conversationId,
|
|
3013
|
-
},
|
|
3014
|
-
}));
|
|
3015
|
-
return existingConversation;
|
|
3016
3457
|
}
|
|
3017
3458
|
try {
|
|
3018
3459
|
let result;
|
|
@@ -3031,6 +3472,8 @@ export class AgentWorkspaceProxy {
|
|
|
3031
3472
|
: await client.newSession({ cwd });
|
|
3032
3473
|
}
|
|
3033
3474
|
agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
|
|
3475
|
+
if (provider === "codex")
|
|
3476
|
+
this.ownedCodexSessionIds.add(agentSessionId);
|
|
3034
3477
|
const now = Date.now();
|
|
3035
3478
|
const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
|
|
3036
3479
|
const conversation = {
|
|
@@ -3040,6 +3483,7 @@ export class AgentWorkspaceProxy {
|
|
|
3040
3483
|
provider,
|
|
3041
3484
|
cwd,
|
|
3042
3485
|
title: payload.title ?? existingConversation?.title ?? titleFromCwd(cwd),
|
|
3486
|
+
control: "owned",
|
|
3043
3487
|
model: payload.model ?? existingConversation?.model,
|
|
3044
3488
|
reasoningEffort: payload.reasoningEffort ?? existingConversation?.reasoningEffort,
|
|
3045
3489
|
permissionMode: payload.permissionMode ?? existingConversation?.permissionMode,
|
|
@@ -3149,9 +3593,13 @@ export class AgentWorkspaceProxy {
|
|
|
3149
3593
|
await this.openConversation({ conversationId: payload.conversationId });
|
|
3150
3594
|
if (!conversation)
|
|
3151
3595
|
return;
|
|
3596
|
+
if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
|
|
3597
|
+
this.rejectAgentAction(conversation, `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 只旁观同一份记录,停止和插话请在那边操作。`, conversation.status);
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3152
3600
|
if (!conversation.agentSessionId) {
|
|
3153
3601
|
this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
|
|
3154
|
-
? `${providerLabel(conversation.provider)}
|
|
3602
|
+
? `${providerLabel(conversation.provider)} 正在本机终端中运行。请打开终端面板继续。`
|
|
3155
3603
|
: "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
|
|
3156
3604
|
return;
|
|
3157
3605
|
}
|
|
@@ -3163,7 +3611,9 @@ export class AgentWorkspaceProxy {
|
|
|
3163
3611
|
return;
|
|
3164
3612
|
}
|
|
3165
3613
|
const protocol = this.protocolForProvider(conversation.provider);
|
|
3166
|
-
|
|
3614
|
+
const advertised = this.providerCapabilities.get(conversation.provider)?.advertised
|
|
3615
|
+
?? (client instanceof AcpClient ? client.advertised : undefined);
|
|
3616
|
+
if (payload.contentBlocks.some((block) => block.type === "image") && !protocolSupportsImages(protocol, advertised)) {
|
|
3167
3617
|
this.rejectAgentAction(conversation, "当前 Agent provider 暂不支持图片输入,请升级 CLI 或切换到 Codex。");
|
|
3168
3618
|
return;
|
|
3169
3619
|
}
|
|
@@ -4223,33 +4673,93 @@ export class AgentWorkspaceProxy {
|
|
|
4223
4673
|
}
|
|
4224
4674
|
handleSessionUpdate(params) {
|
|
4225
4675
|
const raw = asRecord(params) ?? {};
|
|
4226
|
-
const
|
|
4227
|
-
const
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
if (!text && content.length === 0)
|
|
4231
|
-
return;
|
|
4232
|
-
const conversationId = this.conversationIdFromParams(raw) ?? this.fallbackConversationId();
|
|
4676
|
+
const update = asRecord(raw.update) ?? raw;
|
|
4677
|
+
const conversationId = this.conversationIdFromParams(raw)
|
|
4678
|
+
?? this.conversationIdFromParams(update)
|
|
4679
|
+
?? this.fallbackConversationId();
|
|
4233
4680
|
if (!conversationId)
|
|
4234
4681
|
return;
|
|
4235
|
-
|
|
4682
|
+
const kind = normalizedIdentifier(firstString(update, ["sessionUpdate", "session_update", "type"]));
|
|
4683
|
+
if (kind === "agentthoughtchunk" || kind === "agentthought") {
|
|
4684
|
+
const contentBlock = asRecord(update.content);
|
|
4685
|
+
const thought = (typeof contentBlock?.text === "string" ? contentBlock.text : undefined)
|
|
4686
|
+
?? firstString(update, ["delta", "text", "content", "message"]);
|
|
4687
|
+
if (!thought)
|
|
4688
|
+
return;
|
|
4689
|
+
this.handleReasoningDelta({
|
|
4690
|
+
...raw,
|
|
4691
|
+
itemId: firstString(update, ["itemId", "id"]) ?? "thinking",
|
|
4692
|
+
delta: thought,
|
|
4693
|
+
});
|
|
4694
|
+
return;
|
|
4695
|
+
}
|
|
4696
|
+
if (kind === "toolcall" || kind === "toolcallupdate") {
|
|
4697
|
+
const toolCallId = firstString(update, ["toolCallId", "id"]) ?? id("tool");
|
|
4698
|
+
const existing = this.findTool(conversationId, toolCallId);
|
|
4699
|
+
const statusRaw = firstString(update, ["status"]);
|
|
4700
|
+
const status = statusRaw === "in_progress" ? "running"
|
|
4701
|
+
: statusRaw === "completed" || statusRaw === "failed" || statusRaw === "pending" || statusRaw === "running"
|
|
4702
|
+
? statusRaw
|
|
4703
|
+
: existing?.status ?? "running";
|
|
4704
|
+
const input = stringifyDefined(update.rawInput ?? update.input);
|
|
4705
|
+
const output = stringifyDefined(update.rawOutput ?? update.output);
|
|
4706
|
+
this.upsertTool(conversationId, {
|
|
4707
|
+
id: toolCallId,
|
|
4708
|
+
name: firstString(update, ["title", "kind", "name"]) ?? existing?.name ?? "tool",
|
|
4709
|
+
...(input ? { input } : {}),
|
|
4710
|
+
...(output ? { output } : {}),
|
|
4711
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
4712
|
+
status,
|
|
4713
|
+
});
|
|
4714
|
+
return;
|
|
4715
|
+
}
|
|
4716
|
+
if (kind === "plan") {
|
|
4717
|
+
const entries = Array.isArray(update.entries) ? update.entries : [];
|
|
4718
|
+
this.handlePlanUpdated({
|
|
4719
|
+
...raw,
|
|
4720
|
+
plan: entries.map((entry) => {
|
|
4721
|
+
const step = asRecord(entry) ?? {};
|
|
4722
|
+
return {
|
|
4723
|
+
text: firstString(step, ["content", "text", "title", "description"]),
|
|
4724
|
+
status: step.status,
|
|
4725
|
+
};
|
|
4726
|
+
}),
|
|
4727
|
+
});
|
|
4728
|
+
return;
|
|
4729
|
+
}
|
|
4730
|
+
const nested = asRecord(raw.params) ?? asRecord(update.params) ?? {};
|
|
4731
|
+
const contentBlock = asRecord(update.content);
|
|
4732
|
+
const text = firstString(update, ["delta", "text", "content", "message"]) ??
|
|
4733
|
+
firstString(raw, ["delta", "text", "content", "message"]) ??
|
|
4734
|
+
firstString(nested, ["delta", "text", "content", "message"]) ??
|
|
4735
|
+
(typeof contentBlock?.text === "string" ? contentBlock.text : undefined);
|
|
4736
|
+
const content = contentBlocksFromItem(update).length > 0
|
|
4737
|
+
? contentBlocksFromItem(update)
|
|
4738
|
+
: contentBlocksFromItem(raw);
|
|
4739
|
+
if (firstString(raw, ["toolName", "tool", "name"]) || firstString(update, ["toolName", "tool", "name"])) {
|
|
4236
4740
|
this.upsertTool(conversationId, {
|
|
4237
|
-
id: firstString(
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4741
|
+
id: firstString(update, ["toolCallId", "callId", "id"])
|
|
4742
|
+
?? firstString(raw, ["toolCallId", "callId", "id"])
|
|
4743
|
+
?? id("tool"),
|
|
4744
|
+
name: firstString(update, ["toolName", "tool", "name"])
|
|
4745
|
+
?? firstString(raw, ["toolName", "tool", "name"])
|
|
4746
|
+
?? "tool",
|
|
4747
|
+
input: stringify(update.input ?? update.toolInput ?? raw.input ?? raw.toolInput ?? ""),
|
|
4748
|
+
output: stringify(update.output ?? update.result ?? raw.output ?? raw.result ?? ""),
|
|
4241
4749
|
createdAt: Date.now(),
|
|
4242
|
-
status: raw.status === "completed" || raw.status === "failed" || raw.status === "running"
|
|
4243
|
-
? raw.status
|
|
4750
|
+
status: update.status === "completed" || raw.status === "completed" || update.status === "failed" || raw.status === "failed" || update.status === "running" || raw.status === "running"
|
|
4751
|
+
? (update.status ?? raw.status)
|
|
4244
4752
|
: "running",
|
|
4245
4753
|
});
|
|
4246
4754
|
return;
|
|
4247
4755
|
}
|
|
4756
|
+
if (!text && content.length === 0)
|
|
4757
|
+
return;
|
|
4248
4758
|
const role = raw.role === "user" || raw.role === "system" ? raw.role : "assistant";
|
|
4249
4759
|
const blocks = content.length > 0 ? content : [{ type: "text", text }];
|
|
4250
4760
|
const preview = textFromBlocks(blocks);
|
|
4251
4761
|
this.upsertItem(conversationId, {
|
|
4252
|
-
id: firstString(raw, ["messageId", "id"]) ?? id("msg"),
|
|
4762
|
+
id: firstString(update, ["messageId", "id"]) ?? firstString(raw, ["messageId", "id"]) ?? id("msg"),
|
|
4253
4763
|
conversationId,
|
|
4254
4764
|
type: "message",
|
|
4255
4765
|
role,
|
|
@@ -4257,7 +4767,7 @@ export class AgentWorkspaceProxy {
|
|
|
4257
4767
|
text: preview,
|
|
4258
4768
|
createdAt: Date.now(),
|
|
4259
4769
|
updatedAt: Date.now(),
|
|
4260
|
-
isStreaming: raw.done === false || raw.isStreaming === true,
|
|
4770
|
+
isStreaming: raw.done === false || raw.isStreaming === true || kind === "agentmessagechunk",
|
|
4261
4771
|
});
|
|
4262
4772
|
this.updateConversationPreview(conversationId, preview || "图片附件", raw.done === true ? "idle" : "running");
|
|
4263
4773
|
}
|