linkshell-cli 0.5.38 → 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/agent-process-discovery.d.ts +1 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.js +8 -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 +461 -64
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/provider-resolver.js +9 -0
- package/dist/cli/src/runtime/acp/provider-resolver.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/agent-process-discovery.ts +6 -1
- package/src/runtime/acp/agent-workspace.ts +405 -18
- package/src/runtime/acp/provider-resolver.ts +10 -0
- package/web/assets/{FileBrowser-Db2LlqTw.js → FileBrowser-DtVanRUn.js} +1 -1
- package/web/assets/{PortPreview-Cobi7sn0.js → PortPreview-DC2-UmHo.js} +1 -1
- package/web/assets/{TerminalPanel-_Wve-C8s.js → TerminalPanel-D4gHM4Ta.js} +1 -1
- package/web/assets/{UsageDashboard-DmXct3sk.js → UsageDashboard-ZnvuvqWc.js} +1 -1
- package/web/assets/index-DKdJgZ9D.js +36 -0
- package/web/index.html +1 -1
- package/web/assets/index-pnfD2xom.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";
|
|
@@ -1445,6 +1445,244 @@ function codexSessionConfig(sessionId) {
|
|
|
1445
1445
|
}
|
|
1446
1446
|
return cfg;
|
|
1447
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
|
+
}
|
|
1448
1686
|
function parseRemoteSessions(value) {
|
|
1449
1687
|
const raw = asRecord(value);
|
|
1450
1688
|
const sessionsValue = Array.isArray(value) ? value :
|
|
@@ -1938,6 +2176,10 @@ export class AgentWorkspaceProxy {
|
|
|
1938
2176
|
const conversation = this.conversations.get(payload.conversationId);
|
|
1939
2177
|
if (!conversation)
|
|
1940
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
|
+
}
|
|
1941
2183
|
const turnId = this.currentTurnIds.get(payload.conversationId);
|
|
1942
2184
|
if (this.protocolForProvider(conversation.provider) === "codex-app-server" && !turnId) {
|
|
1943
2185
|
this.rejectAgentAction(conversation, "无法停止 Codex:当前运行 turn 尚未同步,请重新打开对话后再试。", conversation.status);
|
|
@@ -2002,8 +2244,13 @@ export class AgentWorkspaceProxy {
|
|
|
2002
2244
|
fsSyncRunning = false;
|
|
2003
2245
|
lastPushedConversationSignature;
|
|
2004
2246
|
lastHydrateAt = new Map();
|
|
2005
|
-
|
|
2006
|
-
|
|
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;
|
|
2007
2254
|
startSessionWatchers() {
|
|
2008
2255
|
const roots = [
|
|
2009
2256
|
join(homedir(), ".claude", "projects"),
|
|
@@ -2083,7 +2330,9 @@ export class AgentWorkspaceProxy {
|
|
|
2083
2330
|
this.lastHydrateAt.set(conversation.id, now);
|
|
2084
2331
|
try {
|
|
2085
2332
|
await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
|
|
2086
|
-
this.
|
|
2333
|
+
if (this.followCodexRolloutDisk(conversation, true) > 0) {
|
|
2334
|
+
this.sendSnapshot(conversation.id);
|
|
2335
|
+
}
|
|
2087
2336
|
}
|
|
2088
2337
|
catch (error) {
|
|
2089
2338
|
if (this.input.verbose) {
|
|
@@ -2211,11 +2460,18 @@ export class AgentWorkspaceProxy {
|
|
|
2211
2460
|
: `${snapshot.provider}:${snapshot.pid}`;
|
|
2212
2461
|
seenKeys.add(key);
|
|
2213
2462
|
this.processMissCounts.set(key, 0);
|
|
2214
|
-
|
|
2215
|
-
|
|
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)
|
|
2216
2471
|
: undefined;
|
|
2217
|
-
const liveId = liveConversationId(snapshot);
|
|
2218
|
-
const conversationId = existingId
|
|
2472
|
+
const liveId = liveConversationId({ ...snapshot, sessionId });
|
|
2473
|
+
const conversationId = existingId
|
|
2474
|
+
?? (sessionId ? makeAgentV2RemoteConversationId(snapshot.provider, sessionId) : liveId);
|
|
2219
2475
|
const existing = this.conversations.get(conversationId);
|
|
2220
2476
|
const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
|
|
2221
2477
|
const driving = this.currentTurnIds.has(conversationId);
|
|
@@ -2230,10 +2486,12 @@ export class AgentWorkspaceProxy {
|
|
|
2230
2486
|
existing.cwd = cwd;
|
|
2231
2487
|
if (!existing.title)
|
|
2232
2488
|
existing.title = agentProviderLabel(snapshot.provider);
|
|
2233
|
-
if (
|
|
2234
|
-
existing.agentSessionId =
|
|
2235
|
-
this.conversationByAgentSessionId.set(
|
|
2489
|
+
if (sessionId && !existing.agentSessionId) {
|
|
2490
|
+
existing.agentSessionId = sessionId;
|
|
2491
|
+
this.conversationByAgentSessionId.set(sessionId, existing.id);
|
|
2236
2492
|
}
|
|
2493
|
+
this.attachedExternalCodex.add(existing.id);
|
|
2494
|
+
existing.control = "attached";
|
|
2237
2495
|
if (changed) {
|
|
2238
2496
|
listChanged = true;
|
|
2239
2497
|
this.emitConversation(existing);
|
|
@@ -2243,7 +2501,7 @@ export class AgentWorkspaceProxy {
|
|
|
2243
2501
|
const now = Date.now();
|
|
2244
2502
|
const conversation = {
|
|
2245
2503
|
id: conversationId,
|
|
2246
|
-
agentSessionId:
|
|
2504
|
+
agentSessionId: sessionId,
|
|
2247
2505
|
provider: snapshot.provider,
|
|
2248
2506
|
cwd,
|
|
2249
2507
|
title: agentProviderLabel(snapshot.provider),
|
|
@@ -2252,10 +2510,12 @@ export class AgentWorkspaceProxy {
|
|
|
2252
2510
|
lastMessagePreview: "本机终端中的 Agent 进程",
|
|
2253
2511
|
lastActivityAt: now,
|
|
2254
2512
|
createdAt: now,
|
|
2513
|
+
control: "attached",
|
|
2255
2514
|
};
|
|
2256
2515
|
this.conversations.set(conversation.id, conversation);
|
|
2257
|
-
if (
|
|
2258
|
-
this.conversationByAgentSessionId.set(
|
|
2516
|
+
if (sessionId)
|
|
2517
|
+
this.conversationByAgentSessionId.set(sessionId, conversation.id);
|
|
2518
|
+
this.attachedExternalCodex.add(conversation.id);
|
|
2259
2519
|
this.emitConversation(conversation);
|
|
2260
2520
|
listChanged = true;
|
|
2261
2521
|
}
|
|
@@ -2627,6 +2887,8 @@ export class AgentWorkspaceProxy {
|
|
|
2627
2887
|
lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
|
|
2628
2888
|
createdAt: remote.createdAt ?? existing?.createdAt ?? now,
|
|
2629
2889
|
usage,
|
|
2890
|
+
group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
|
|
2891
|
+
workspaceRoots: existing?.workspaceRoots,
|
|
2630
2892
|
};
|
|
2631
2893
|
this.conversations.set(conversation.id, conversation);
|
|
2632
2894
|
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
@@ -2762,7 +3024,13 @@ export class AgentWorkspaceProxy {
|
|
|
2762
3024
|
if (turnCount > 0)
|
|
2763
3025
|
this.historyCursors.set(conversation.id, String(turnCount));
|
|
2764
3026
|
}
|
|
2765
|
-
|
|
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
|
+
}
|
|
2766
3034
|
if (hydratedItems.length === 0)
|
|
2767
3035
|
return;
|
|
2768
3036
|
const existing = this.timelines.get(conversation.id) ?? [];
|
|
@@ -2780,9 +3048,97 @@ export class AgentWorkspaceProxy {
|
|
|
2780
3048
|
const lastPreview = [...nextItems].reverse()
|
|
2781
3049
|
.map((item) => previewText(previewFromTimelineItem(item) ?? ""))
|
|
2782
3050
|
.find(Boolean);
|
|
2783
|
-
if (lastPreview
|
|
3051
|
+
if (lastPreview)
|
|
2784
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
|
+
}
|
|
2785
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;
|
|
2786
3142
|
}
|
|
2787
3143
|
sendCapabilities() {
|
|
2788
3144
|
const catalog = [...new Set([
|
|
@@ -3009,61 +3365,95 @@ export class AgentWorkspaceProxy {
|
|
|
3009
3365
|
});
|
|
3010
3366
|
}
|
|
3011
3367
|
}
|
|
3012
|
-
if (existingConversation &&
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
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);
|
|
3018
3375
|
}
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
//
|
|
3024
|
-
//
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
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) &&
|
|
3028
3385
|
!this.currentTurnIds.has(existingConversation.id)) {
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
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)) {
|
|
3032
3417
|
try {
|
|
3033
|
-
|
|
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);
|
|
3034
3437
|
}
|
|
3035
3438
|
catch (error) {
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
|
|
3041
|
-
}
|
|
3042
|
-
const nextAgentSessionId = this.extractSessionId(result);
|
|
3043
|
-
if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
|
|
3044
|
-
this.conversationByAgentSessionId.delete(existingAgentSessionId);
|
|
3045
|
-
existingConversation.agentSessionId = nextAgentSessionId;
|
|
3046
|
-
this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
|
|
3047
|
-
}
|
|
3048
|
-
await this.hydrateConversationFromProvider(existingConversation, result);
|
|
3049
|
-
}
|
|
3050
|
-
catch (error) {
|
|
3051
|
-
if (this.input.verbose) {
|
|
3052
|
-
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);
|
|
3053
3443
|
}
|
|
3054
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;
|
|
3055
3456
|
}
|
|
3056
|
-
this.activeConversationId = existingConversation.id;
|
|
3057
|
-
this.input.send(createEnvelope({
|
|
3058
|
-
type: "agent.v2.conversation.opened",
|
|
3059
|
-
sessionId: this.input.sessionId,
|
|
3060
|
-
payload: {
|
|
3061
|
-
conversation: existingConversation,
|
|
3062
|
-
snapshot: this.timelines.get(existingConversation.id) ?? [],
|
|
3063
|
-
requestedConversationId: payload.conversationId,
|
|
3064
|
-
},
|
|
3065
|
-
}));
|
|
3066
|
-
return existingConversation;
|
|
3067
3457
|
}
|
|
3068
3458
|
try {
|
|
3069
3459
|
let result;
|
|
@@ -3082,6 +3472,8 @@ export class AgentWorkspaceProxy {
|
|
|
3082
3472
|
: await client.newSession({ cwd });
|
|
3083
3473
|
}
|
|
3084
3474
|
agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
|
|
3475
|
+
if (provider === "codex")
|
|
3476
|
+
this.ownedCodexSessionIds.add(agentSessionId);
|
|
3085
3477
|
const now = Date.now();
|
|
3086
3478
|
const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
|
|
3087
3479
|
const conversation = {
|
|
@@ -3091,6 +3483,7 @@ export class AgentWorkspaceProxy {
|
|
|
3091
3483
|
provider,
|
|
3092
3484
|
cwd,
|
|
3093
3485
|
title: payload.title ?? existingConversation?.title ?? titleFromCwd(cwd),
|
|
3486
|
+
control: "owned",
|
|
3094
3487
|
model: payload.model ?? existingConversation?.model,
|
|
3095
3488
|
reasoningEffort: payload.reasoningEffort ?? existingConversation?.reasoningEffort,
|
|
3096
3489
|
permissionMode: payload.permissionMode ?? existingConversation?.permissionMode,
|
|
@@ -3200,9 +3593,13 @@ export class AgentWorkspaceProxy {
|
|
|
3200
3593
|
await this.openConversation({ conversationId: payload.conversationId });
|
|
3201
3594
|
if (!conversation)
|
|
3202
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
|
+
}
|
|
3203
3600
|
if (!conversation.agentSessionId) {
|
|
3204
3601
|
this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
|
|
3205
|
-
? `${providerLabel(conversation.provider)}
|
|
3602
|
+
? `${providerLabel(conversation.provider)} 正在本机终端中运行。请打开终端面板继续。`
|
|
3206
3603
|
: "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
|
|
3207
3604
|
return;
|
|
3208
3605
|
}
|