linkshell-cli 0.5.39 → 0.5.41
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-workspace.d.ts +12 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +411 -128
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/runtime/acp/agent-workspace.ts +419 -125
- package/web/assets/{FileBrowser-DtVanRUn.js → FileBrowser-wOAfY-xj.js} +1 -1
- package/web/assets/{PortPreview-DC2-UmHo.js → PortPreview-Cbvv7yzA.js} +1 -1
- package/web/assets/{TerminalPanel-D4gHM4Ta.js → TerminalPanel-CTSklkct.js} +1 -1
- package/web/assets/{UsageDashboard-ZnvuvqWc.js → UsageDashboard-CJ1AsNmd.js} +1 -1
- package/web/assets/index-q85psYZ5.js +36 -0
- package/web/index.html +1 -1
- package/web/assets/index-DKdJgZ9D.js +0 -36
|
@@ -1502,6 +1502,147 @@ function findCodexRolloutPath(sessionId, root = join(homedir(), ".codex", "sessi
|
|
|
1502
1502
|
}
|
|
1503
1503
|
return undefined;
|
|
1504
1504
|
}
|
|
1505
|
+
function readJsonlPrefix(path, maxBytes = 8192) {
|
|
1506
|
+
let fd;
|
|
1507
|
+
try {
|
|
1508
|
+
const size = statSync(path).size;
|
|
1509
|
+
if (size <= 0)
|
|
1510
|
+
return "";
|
|
1511
|
+
fd = openSync(path, "r");
|
|
1512
|
+
const buf = Buffer.alloc(Math.min(maxBytes, size));
|
|
1513
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
1514
|
+
return buf.toString("utf8");
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
return undefined;
|
|
1518
|
+
}
|
|
1519
|
+
finally {
|
|
1520
|
+
if (fd !== undefined) {
|
|
1521
|
+
try {
|
|
1522
|
+
closeSync(fd);
|
|
1523
|
+
}
|
|
1524
|
+
catch { /* ignore */ }
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
/** Newest Codex rollouts as catalog metadata. Reads the first line only. */
|
|
1529
|
+
function listCodexSessionsFromDisk(limit = 80) {
|
|
1530
|
+
const files = [];
|
|
1531
|
+
walkCodexRollouts(join(homedir(), ".codex", "sessions"), (path, filenameId, mt) => {
|
|
1532
|
+
files.push({ path, filenameId, mt });
|
|
1533
|
+
});
|
|
1534
|
+
files.sort((a, b) => b.mt - a.mt);
|
|
1535
|
+
const out = [];
|
|
1536
|
+
const seen = new Set();
|
|
1537
|
+
for (const file of files) {
|
|
1538
|
+
if (out.length >= limit)
|
|
1539
|
+
break;
|
|
1540
|
+
const prefix = readJsonlPrefix(file.path);
|
|
1541
|
+
const first = prefix
|
|
1542
|
+
? prefix.slice(0, prefix.indexOf("\n") >= 0 ? prefix.indexOf("\n") : prefix.length)
|
|
1543
|
+
: "";
|
|
1544
|
+
let sessionId = file.filenameId;
|
|
1545
|
+
let cwd;
|
|
1546
|
+
let createdAt;
|
|
1547
|
+
if (first) {
|
|
1548
|
+
try {
|
|
1549
|
+
const payload = asRecord(asRecord(JSON.parse(first))?.payload) ?? asRecord(JSON.parse(first));
|
|
1550
|
+
sessionId = firstString(payload, ["session_id", "id"]) ?? file.filenameId;
|
|
1551
|
+
cwd = firstString(payload, ["cwd"]);
|
|
1552
|
+
createdAt = parseTimestamp(payload?.timestamp ?? asRecord(JSON.parse(first))?.timestamp);
|
|
1553
|
+
}
|
|
1554
|
+
catch {
|
|
1555
|
+
sessionId = file.filenameId;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (seen.has(sessionId))
|
|
1559
|
+
continue;
|
|
1560
|
+
seen.add(sessionId);
|
|
1561
|
+
out.push({
|
|
1562
|
+
id: sessionId,
|
|
1563
|
+
cwd,
|
|
1564
|
+
title: cwd ? titleFromCwd(cwd) : undefined,
|
|
1565
|
+
createdAt,
|
|
1566
|
+
lastActivityAt: file.mt,
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
return out;
|
|
1570
|
+
}
|
|
1571
|
+
function listClaudeSessionsFromDisk(limit = 80) {
|
|
1572
|
+
const root = join(homedir(), ".claude", "projects");
|
|
1573
|
+
if (!existsSync(root))
|
|
1574
|
+
return [];
|
|
1575
|
+
const files = [];
|
|
1576
|
+
let projectDirs = [];
|
|
1577
|
+
try {
|
|
1578
|
+
projectDirs = readdirSync(root).map((name) => join(root, name));
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
return [];
|
|
1582
|
+
}
|
|
1583
|
+
for (const dir of projectDirs) {
|
|
1584
|
+
let stat;
|
|
1585
|
+
try {
|
|
1586
|
+
stat = statSync(dir);
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
if (!stat.isDirectory())
|
|
1592
|
+
continue;
|
|
1593
|
+
const dirName = basename(dir);
|
|
1594
|
+
const cwd = dirName.startsWith("-") ? `/${dirName.slice(1).replace(/-/g, "/")}` : undefined;
|
|
1595
|
+
let entries;
|
|
1596
|
+
try {
|
|
1597
|
+
entries = readdirSync(dir);
|
|
1598
|
+
}
|
|
1599
|
+
catch {
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
for (const name of entries) {
|
|
1603
|
+
if (!name.endsWith(".jsonl"))
|
|
1604
|
+
continue;
|
|
1605
|
+
const path = join(dir, name);
|
|
1606
|
+
let fileStat;
|
|
1607
|
+
try {
|
|
1608
|
+
fileStat = statSync(path);
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (!fileStat.isFile())
|
|
1614
|
+
continue;
|
|
1615
|
+
files.push({
|
|
1616
|
+
id: name.replace(/\.jsonl$/, ""),
|
|
1617
|
+
mt: fileStat.mtimeMs,
|
|
1618
|
+
cwd,
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
files.sort((a, b) => b.mt - a.mt);
|
|
1623
|
+
const out = [];
|
|
1624
|
+
const seen = new Set();
|
|
1625
|
+
for (const file of files) {
|
|
1626
|
+
if (out.length >= limit)
|
|
1627
|
+
break;
|
|
1628
|
+
if (seen.has(file.id))
|
|
1629
|
+
continue;
|
|
1630
|
+
seen.add(file.id);
|
|
1631
|
+
out.push({
|
|
1632
|
+
id: file.id,
|
|
1633
|
+
cwd: file.cwd,
|
|
1634
|
+
title: file.cwd ? titleFromCwd(file.cwd) : undefined,
|
|
1635
|
+
lastActivityAt: file.mt,
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
return out;
|
|
1639
|
+
}
|
|
1640
|
+
function withTimeout(promise, ms, message) {
|
|
1641
|
+
return new Promise((resolve, reject) => {
|
|
1642
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
1643
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1505
1646
|
function findLatestCodexRolloutForCwd(cwd, root = join(homedir(), ".codex", "sessions")) {
|
|
1506
1647
|
const wanted = cwd.replace(/\/+$/, "");
|
|
1507
1648
|
const candidates = [];
|
|
@@ -1978,8 +2119,8 @@ function previewFromTimelineItem(item) {
|
|
|
1978
2119
|
export class AgentWorkspaceProxy {
|
|
1979
2120
|
input;
|
|
1980
2121
|
clients = new Map();
|
|
1981
|
-
// In-flight client starts, so concurrent ensureProviderClient calls
|
|
1982
|
-
//
|
|
2122
|
+
// In-flight client starts, so concurrent ensureProviderClient calls
|
|
2123
|
+
// (prompt racing conversation.open) share one subprocess spawn.
|
|
1983
2124
|
clientStartPromises = new Map();
|
|
1984
2125
|
agentProtocols = new Map();
|
|
1985
2126
|
providerCapabilities = new Map();
|
|
@@ -2022,7 +2163,6 @@ export class AgentWorkspaceProxy {
|
|
|
2022
2163
|
case "agent.v2.capabilities.request":
|
|
2023
2164
|
await this.initialize();
|
|
2024
2165
|
this.sendCapabilities();
|
|
2025
|
-
this.sendUsageReport();
|
|
2026
2166
|
break;
|
|
2027
2167
|
case "agent.v2.conversation.open": {
|
|
2028
2168
|
const payload = parseTypedPayload("agent.v2.conversation.open", envelope.payload);
|
|
@@ -2142,7 +2282,7 @@ export class AgentWorkspaceProxy {
|
|
|
2142
2282
|
this.input.send(createEnvelope({
|
|
2143
2283
|
type: "agent.v2.conversation.list.result",
|
|
2144
2284
|
sessionId: this.input.sessionId,
|
|
2145
|
-
payload: { conversations },
|
|
2285
|
+
payload: { conversations: this.listedConversations(conversations) },
|
|
2146
2286
|
}));
|
|
2147
2287
|
break;
|
|
2148
2288
|
}
|
|
@@ -2244,13 +2384,18 @@ export class AgentWorkspaceProxy {
|
|
|
2244
2384
|
fsSyncRunning = false;
|
|
2245
2385
|
lastPushedConversationSignature;
|
|
2246
2386
|
lastHydrateAt = new Map();
|
|
2387
|
+
/** Conversations the web actually opened — the only ones we tail/hydrate. */
|
|
2388
|
+
watchedConversationIds = new Set();
|
|
2247
2389
|
/** Session ids this LinkShell process created via app-server `newSession`. */
|
|
2248
2390
|
ownedCodexSessionIds = new Set();
|
|
2249
2391
|
/** Conversations attached to an external Codex TUI/desktop rollout (same file, not a copy). */
|
|
2250
2392
|
attachedExternalCodex = new Set();
|
|
2251
2393
|
codexFollow = new Map();
|
|
2252
|
-
static FS_SYNC_THROTTLE_MS =
|
|
2394
|
+
static FS_SYNC_THROTTLE_MS = 1500;
|
|
2253
2395
|
static EXTERNAL_HYDRATE_THROTTLE_MS = 400;
|
|
2396
|
+
static LIST_IDLE_CAP = 40;
|
|
2397
|
+
static LIST_HARD_CAP = 80;
|
|
2398
|
+
static CLIENT_START_TIMEOUT_MS = 12_000;
|
|
2254
2399
|
startSessionWatchers() {
|
|
2255
2400
|
const roots = [
|
|
2256
2401
|
join(homedir(), ".claude", "projects"),
|
|
@@ -2296,7 +2441,7 @@ export class AgentWorkspaceProxy {
|
|
|
2296
2441
|
// A store directory may have appeared since the last call — attach a
|
|
2297
2442
|
// watcher to it now (no-op for roots already watched).
|
|
2298
2443
|
this.startSessionWatchers();
|
|
2299
|
-
const conversations = [...this.conversations.values()].filter((c) => !c.archived);
|
|
2444
|
+
const conversations = this.listedConversations([...this.conversations.values()].filter((c) => !c.archived));
|
|
2300
2445
|
// Only broadcast when the list actually changed since the last push. An
|
|
2301
2446
|
// fs-watch fires on every transcript append (once per streamed token for
|
|
2302
2447
|
// an active session), but most of those don't change any field the tree
|
|
@@ -2315,12 +2460,14 @@ export class AgentWorkspaceProxy {
|
|
|
2315
2460
|
payload: { conversations },
|
|
2316
2461
|
}));
|
|
2317
2462
|
}
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
2320
|
-
// Re-hydrate on a short throttle so the Web timeline still moves.
|
|
2463
|
+
// Only tail conversations the client opened. Scanning every running
|
|
2464
|
+
// disk session (hundreds of Codex rollouts) floods the gateway.
|
|
2321
2465
|
const now = Date.now();
|
|
2322
|
-
for (const
|
|
2323
|
-
|
|
2466
|
+
for (const conversationId of this.watchedConversationIds) {
|
|
2467
|
+
const conversation = this.conversations.get(conversationId);
|
|
2468
|
+
if (!conversation)
|
|
2469
|
+
continue;
|
|
2470
|
+
if (conversation.control !== "attached")
|
|
2324
2471
|
continue;
|
|
2325
2472
|
if (this.currentTurnIds.has(conversation.id))
|
|
2326
2473
|
continue;
|
|
@@ -2329,9 +2476,10 @@ export class AgentWorkspaceProxy {
|
|
|
2329
2476
|
continue;
|
|
2330
2477
|
this.lastHydrateAt.set(conversation.id, now);
|
|
2331
2478
|
try {
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2479
|
+
if (conversation.provider === "codex") {
|
|
2480
|
+
if (this.followCodexRolloutDisk(conversation, true) > 0) {
|
|
2481
|
+
this.sendSnapshot(conversation.id);
|
|
2482
|
+
}
|
|
2335
2483
|
}
|
|
2336
2484
|
}
|
|
2337
2485
|
catch (error) {
|
|
@@ -2355,6 +2503,58 @@ export class AgentWorkspaceProxy {
|
|
|
2355
2503
|
* Deliberately excludes usage token counts / lastActivityAt timestamps that
|
|
2356
2504
|
* tick on every append without altering the tree — status, title, model, and
|
|
2357
2505
|
* membership are what matter. */
|
|
2506
|
+
listedConversations(conversations) {
|
|
2507
|
+
const ranked = [...conversations].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
2508
|
+
const out = [];
|
|
2509
|
+
const seen = new Set();
|
|
2510
|
+
const take = (conversation) => {
|
|
2511
|
+
if (seen.has(conversation.id))
|
|
2512
|
+
return;
|
|
2513
|
+
seen.add(conversation.id);
|
|
2514
|
+
out.push(conversation);
|
|
2515
|
+
};
|
|
2516
|
+
for (const conversation of ranked) {
|
|
2517
|
+
if (out.length >= AgentWorkspaceProxy.LIST_HARD_CAP)
|
|
2518
|
+
break;
|
|
2519
|
+
if (conversation.status === "running" ||
|
|
2520
|
+
conversation.status === "waiting_permission" ||
|
|
2521
|
+
conversation.control === "owned" ||
|
|
2522
|
+
this.watchedConversationIds.has(conversation.id)) {
|
|
2523
|
+
take(conversation);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
for (const conversation of ranked) {
|
|
2527
|
+
if (out.length >= AgentWorkspaceProxy.LIST_IDLE_CAP)
|
|
2528
|
+
break;
|
|
2529
|
+
take(conversation);
|
|
2530
|
+
}
|
|
2531
|
+
return out;
|
|
2532
|
+
}
|
|
2533
|
+
pickListedRemotes(remotes, provider, runningIds) {
|
|
2534
|
+
const ranked = [...remotes].sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
2535
|
+
const out = [];
|
|
2536
|
+
const seen = new Set();
|
|
2537
|
+
const take = (remote) => {
|
|
2538
|
+
if (seen.has(remote.id))
|
|
2539
|
+
return;
|
|
2540
|
+
seen.add(remote.id);
|
|
2541
|
+
out.push(remote);
|
|
2542
|
+
};
|
|
2543
|
+
for (const remote of ranked) {
|
|
2544
|
+
const conversationId = makeAgentV2RemoteConversationId(provider, remote.id);
|
|
2545
|
+
if (this.ownedCodexSessionIds.has(remote.id) ||
|
|
2546
|
+
this.watchedConversationIds.has(conversationId) ||
|
|
2547
|
+
runningIds?.has(remote.id)) {
|
|
2548
|
+
take(remote);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
for (const remote of ranked) {
|
|
2552
|
+
if (out.length >= AgentWorkspaceProxy.LIST_IDLE_CAP)
|
|
2553
|
+
break;
|
|
2554
|
+
take(remote);
|
|
2555
|
+
}
|
|
2556
|
+
return out;
|
|
2557
|
+
}
|
|
2358
2558
|
conversationListSignature(conversations) {
|
|
2359
2559
|
return conversations
|
|
2360
2560
|
.map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}${c.lastMessagePreview ?? ""}`)
|
|
@@ -2400,14 +2600,11 @@ export class AgentWorkspaceProxy {
|
|
|
2400
2600
|
if (this.initialized)
|
|
2401
2601
|
return;
|
|
2402
2602
|
this.initialized = true;
|
|
2403
|
-
// Eagerly start all detected providers so capabilities report real status
|
|
2404
|
-
const startPromises = this.input.availableProviders.map((p) => this.ensureProviderClient(p));
|
|
2405
|
-
await Promise.allSettled(startPromises);
|
|
2406
2603
|
this.status = "idle";
|
|
2407
2604
|
this.error = undefined;
|
|
2605
|
+
// Presence only. Spawning every detected CLI here (especially
|
|
2606
|
+
// `grok agent stdio`) blocked the sequential control plane on connect.
|
|
2408
2607
|
this.sendCapabilities();
|
|
2409
|
-
// Start watching the on-disk session stores so externally-started sessions
|
|
2410
|
-
// and status changes surface live, not just at connect time.
|
|
2411
2608
|
this.startSessionWatchers();
|
|
2412
2609
|
this.startProcessDiscovery();
|
|
2413
2610
|
}
|
|
@@ -2566,7 +2763,7 @@ export class AgentWorkspaceProxy {
|
|
|
2566
2763
|
if (catalogChanged)
|
|
2567
2764
|
this.sendCapabilities();
|
|
2568
2765
|
if (listChanged) {
|
|
2569
|
-
const conversations = [...this.conversations.values()].filter((item) => !item.archived);
|
|
2766
|
+
const conversations = this.listedConversations([...this.conversations.values()].filter((item) => !item.archived));
|
|
2570
2767
|
this.lastPushedConversationSignature = this.conversationListSignature(conversations);
|
|
2571
2768
|
this.input.send(createEnvelope({
|
|
2572
2769
|
type: "agent.v2.conversation.list.result",
|
|
@@ -2579,10 +2776,9 @@ export class AgentWorkspaceProxy {
|
|
|
2579
2776
|
const existing = this.clients.get(provider);
|
|
2580
2777
|
if (existing)
|
|
2581
2778
|
return existing;
|
|
2582
|
-
// Dedupe concurrent starts:
|
|
2583
|
-
//
|
|
2584
|
-
//
|
|
2585
|
-
// twice. Memoize the in-flight promise so both callers share one spawn;
|
|
2779
|
+
// Dedupe concurrent starts: conversation.open and prompt can race here
|
|
2780
|
+
// before this.clients is populated (it's only set AFTER initialize).
|
|
2781
|
+
// Memoize the in-flight promise so both callers share one spawn;
|
|
2586
2782
|
// drop it once settled so a failed start can be retried.
|
|
2587
2783
|
const inflight = this.clientStartPromises.get(provider);
|
|
2588
2784
|
if (inflight)
|
|
@@ -2640,8 +2836,17 @@ export class AgentWorkspaceProxy {
|
|
|
2640
2836
|
onRequest: (method, params) => this.handleRequest(method, params),
|
|
2641
2837
|
onExit: (message) => this.handleProviderExit(provider, message),
|
|
2642
2838
|
});
|
|
2643
|
-
|
|
2644
|
-
|
|
2839
|
+
try {
|
|
2840
|
+
await withTimeout(client.initialize(), AgentWorkspaceProxy.CLIENT_START_TIMEOUT_MS, `${providerLabel(provider)} 启动超时`);
|
|
2841
|
+
return client;
|
|
2842
|
+
}
|
|
2843
|
+
catch (error) {
|
|
2844
|
+
try {
|
|
2845
|
+
client.stop();
|
|
2846
|
+
}
|
|
2847
|
+
catch { /* ignore */ }
|
|
2848
|
+
throw error;
|
|
2849
|
+
}
|
|
2645
2850
|
};
|
|
2646
2851
|
try {
|
|
2647
2852
|
const client = await tryCreateClient(resolved);
|
|
@@ -2809,96 +3014,112 @@ export class AgentWorkspaceProxy {
|
|
|
2809
3014
|
return [];
|
|
2810
3015
|
}
|
|
2811
3016
|
}
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
// reads only the fresh few). Claude reports status via parseRemoteSessions.
|
|
2832
|
-
const codexRunning = provider === "codex" ? runningCodexSessionIds(Date.now()) : undefined;
|
|
2833
|
-
for (const remote of parseRemoteSessions(result)) {
|
|
2834
|
-
const agentSessionId = remote.id;
|
|
2835
|
-
// Skip conversations the user explicitly forgot — don't resurrect them.
|
|
2836
|
-
if (this.deletedAgentSessionIds.has(agentSessionId))
|
|
2837
|
-
continue;
|
|
2838
|
-
const existingId = this.conversationByAgentSessionId.get(agentSessionId);
|
|
2839
|
-
const now = Date.now();
|
|
2840
|
-
const conversationId = existingId ?? makeAgentV2RemoteConversationId(provider, agentSessionId);
|
|
2841
|
-
const existing = this.conversations.get(conversationId);
|
|
2842
|
-
const cwd = remote.cwd ?? existing?.cwd ?? this.input.cwd;
|
|
2843
|
-
const model = remote.model ?? existing?.model;
|
|
2844
|
-
// Usage read straight from the on-disk transcript (decoupled from any
|
|
2845
|
-
// live LinkShell turn) — so history + externally-started sessions all
|
|
2846
|
-
// carry a context meter. Falls back to any existing live-turn usage.
|
|
2847
|
-
const usageFields = normalizeUsageFields(remote.usage, model);
|
|
2848
|
-
const usage = usageFields
|
|
2849
|
-
? { ...usageFields, updatedAt: remote.lastActivityAt ?? now }
|
|
2850
|
-
: existing?.usage;
|
|
2851
|
-
// Status precedence. While LinkShell is driving a turn for this
|
|
2852
|
-
// conversation, the in-memory status is authoritative — the transcript
|
|
2853
|
-
// lags (the SDK hasn't flushed the in-flight turn to disk), so trusting
|
|
2854
|
-
// it would flicker a live "running" turn back to idle. Otherwise the
|
|
2855
|
-
// transcript-derived running signal is authoritative (Claude reports it
|
|
2856
|
-
// via remote.status; Codex via the on-disk rollout set): surface
|
|
2857
|
-
// "running" while an external process drives it, and let it clear once
|
|
2858
|
-
// the external turn finishes. We only fall back to the existing status
|
|
2859
|
-
// to keep error/waiting_permission sticky across a re-sync — the
|
|
2860
|
-
// transcript signals never emit those, so they live only in `existing`;
|
|
2861
|
-
// defaulting everything else to idle avoids a purely-external session
|
|
2862
|
-
// getting stuck at "running" forever.
|
|
2863
|
-
const remoteRunning = remote.status === "running" || (codexRunning?.has(agentSessionId) ?? false);
|
|
2864
|
-
const linkshellDriving = this.currentTurnIds.has(conversationId);
|
|
2865
|
-
const status = linkshellDriving
|
|
2866
|
-
? existing?.status ?? "running"
|
|
2867
|
-
: remoteRunning
|
|
2868
|
-
? "running"
|
|
2869
|
-
: remote.status === "error"
|
|
2870
|
-
? "error"
|
|
2871
|
-
: existing?.status === "error" || existing?.status === "waiting_permission"
|
|
2872
|
-
? existing.status
|
|
2873
|
-
: "idle";
|
|
2874
|
-
const conversation = {
|
|
2875
|
-
id: conversationId,
|
|
2876
|
-
agentSessionId,
|
|
2877
|
-
provider,
|
|
2878
|
-
cwd,
|
|
2879
|
-
title: remote.title ?? existing?.title ?? titleFromCwd(cwd),
|
|
2880
|
-
model,
|
|
2881
|
-
reasoningEffort: existing?.reasoningEffort,
|
|
2882
|
-
permissionMode: existing?.permissionMode,
|
|
2883
|
-
collaborationMode: existing?.collaborationMode,
|
|
2884
|
-
status,
|
|
2885
|
-
archived: existing?.archived ?? false,
|
|
2886
|
-
lastMessagePreview: existing?.lastMessagePreview,
|
|
2887
|
-
lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
|
|
2888
|
-
createdAt: remote.createdAt ?? existing?.createdAt ?? now,
|
|
2889
|
-
usage,
|
|
2890
|
-
group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
|
|
2891
|
-
workspaceRoots: existing?.workspaceRoots,
|
|
2892
|
-
};
|
|
2893
|
-
this.conversations.set(conversation.id, conversation);
|
|
2894
|
-
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
2895
|
-
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
3017
|
+
listDiskSessions(provider) {
|
|
3018
|
+
if (provider === "codex")
|
|
3019
|
+
return listCodexSessionsFromDisk(AgentWorkspaceProxy.LIST_HARD_CAP);
|
|
3020
|
+
if (provider === "claude")
|
|
3021
|
+
return listClaudeSessionsFromDisk(AgentWorkspaceProxy.LIST_HARD_CAP);
|
|
3022
|
+
return [];
|
|
3023
|
+
}
|
|
3024
|
+
async listClientSessions(provider) {
|
|
3025
|
+
const client = this.clients.get(provider);
|
|
3026
|
+
if (!client)
|
|
3027
|
+
return [];
|
|
3028
|
+
try {
|
|
3029
|
+
return parseRemoteSessions(await client.listSessions());
|
|
3030
|
+
}
|
|
3031
|
+
catch (error) {
|
|
3032
|
+
const recovered = await this.recycleProviderClient(provider, error);
|
|
3033
|
+
if (!recovered) {
|
|
3034
|
+
if (this.input.verbose) {
|
|
3035
|
+
process.stderr.write(`[agent:v2] session list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2896
3036
|
}
|
|
3037
|
+
return [];
|
|
2897
3038
|
}
|
|
2898
|
-
|
|
3039
|
+
try {
|
|
3040
|
+
return parseRemoteSessions(await recovered.listSessions());
|
|
3041
|
+
}
|
|
3042
|
+
catch (retryError) {
|
|
2899
3043
|
if (this.input.verbose) {
|
|
2900
|
-
process.stderr.write(`[agent:v2] session list failed for ${provider}: ${
|
|
3044
|
+
process.stderr.write(`[agent:v2] session list failed for ${provider}: ${retryError instanceof Error ? retryError.message : String(retryError)}\n`);
|
|
2901
3045
|
}
|
|
3046
|
+
return [];
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
upsertRemoteSession(provider, remote, now, runningIds) {
|
|
3051
|
+
const agentSessionId = remote.id;
|
|
3052
|
+
if (this.deletedAgentSessionIds.has(agentSessionId))
|
|
3053
|
+
return;
|
|
3054
|
+
const existingId = this.conversationByAgentSessionId.get(agentSessionId);
|
|
3055
|
+
const conversationId = existingId ?? makeAgentV2RemoteConversationId(provider, agentSessionId);
|
|
3056
|
+
const existing = this.conversations.get(conversationId);
|
|
3057
|
+
const cwd = remote.cwd ?? existing?.cwd ?? this.input.cwd;
|
|
3058
|
+
const model = remote.model ?? existing?.model;
|
|
3059
|
+
const usageFields = normalizeUsageFields(remote.usage, model);
|
|
3060
|
+
const usage = usageFields
|
|
3061
|
+
? { ...usageFields, updatedAt: remote.lastActivityAt ?? now }
|
|
3062
|
+
: existing?.usage;
|
|
3063
|
+
const remoteRunning = remote.status === "running" || (runningIds?.has(agentSessionId) ?? false);
|
|
3064
|
+
const linkshellDriving = this.currentTurnIds.has(conversationId);
|
|
3065
|
+
const owned = Boolean(agentSessionId && this.ownedCodexSessionIds.has(agentSessionId));
|
|
3066
|
+
const attached = !owned && (existing?.control === "attached" ||
|
|
3067
|
+
this.attachedExternalCodex.has(conversationId) ||
|
|
3068
|
+
remoteRunning);
|
|
3069
|
+
if (attached)
|
|
3070
|
+
this.attachedExternalCodex.add(conversationId);
|
|
3071
|
+
const status = linkshellDriving
|
|
3072
|
+
? existing?.status ?? "running"
|
|
3073
|
+
: remoteRunning
|
|
3074
|
+
? "running"
|
|
3075
|
+
: remote.status === "error"
|
|
3076
|
+
? "error"
|
|
3077
|
+
: existing?.status === "error" || existing?.status === "waiting_permission"
|
|
3078
|
+
? existing.status
|
|
3079
|
+
: "idle";
|
|
3080
|
+
const conversation = {
|
|
3081
|
+
id: conversationId,
|
|
3082
|
+
agentSessionId,
|
|
3083
|
+
provider,
|
|
3084
|
+
cwd,
|
|
3085
|
+
title: remote.title ?? existing?.title ?? titleFromCwd(cwd),
|
|
3086
|
+
model,
|
|
3087
|
+
reasoningEffort: existing?.reasoningEffort,
|
|
3088
|
+
permissionMode: existing?.permissionMode,
|
|
3089
|
+
collaborationMode: existing?.collaborationMode,
|
|
3090
|
+
control: owned ? "owned" : attached ? "attached" : existing?.control,
|
|
3091
|
+
status,
|
|
3092
|
+
archived: existing?.archived ?? false,
|
|
3093
|
+
lastMessagePreview: existing?.lastMessagePreview,
|
|
3094
|
+
lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
|
|
3095
|
+
createdAt: remote.createdAt ?? existing?.createdAt ?? now,
|
|
3096
|
+
usage,
|
|
3097
|
+
group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
|
|
3098
|
+
workspaceRoots: existing?.workspaceRoots,
|
|
3099
|
+
};
|
|
3100
|
+
this.conversations.set(conversation.id, conversation);
|
|
3101
|
+
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
3102
|
+
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
3103
|
+
}
|
|
3104
|
+
async syncProviderSessions() {
|
|
3105
|
+
await this.initialize();
|
|
3106
|
+
const now = Date.now();
|
|
3107
|
+
const codexRunning = runningCodexSessionIds(now);
|
|
3108
|
+
const providers = new Set([
|
|
3109
|
+
...this.input.availableProviders,
|
|
3110
|
+
...this.clients.keys(),
|
|
3111
|
+
]);
|
|
3112
|
+
for (const provider of providers) {
|
|
3113
|
+
const byId = new Map();
|
|
3114
|
+
for (const remote of this.listDiskSessions(provider))
|
|
3115
|
+
byId.set(remote.id, remote);
|
|
3116
|
+
for (const remote of await this.listClientSessions(provider)) {
|
|
3117
|
+
const prev = byId.get(remote.id);
|
|
3118
|
+
byId.set(remote.id, prev ? { ...prev, ...remote } : remote);
|
|
3119
|
+
}
|
|
3120
|
+
const runningIds = provider === "codex" ? codexRunning : undefined;
|
|
3121
|
+
for (const remote of this.pickListedRemotes([...byId.values()], provider, runningIds)) {
|
|
3122
|
+
this.upsertRemoteSession(provider, remote, now, runningIds);
|
|
2902
3123
|
}
|
|
2903
3124
|
}
|
|
2904
3125
|
}
|
|
@@ -3146,26 +3367,38 @@ export class AgentWorkspaceProxy {
|
|
|
3146
3367
|
...this.lastDiscoveredProviders,
|
|
3147
3368
|
])];
|
|
3148
3369
|
const providers = catalog.map((provider) => {
|
|
3370
|
+
const resolved = resolveAgentCommand({
|
|
3371
|
+
provider,
|
|
3372
|
+
command: this.input.command,
|
|
3373
|
+
});
|
|
3149
3374
|
const client = this.clients.get(provider);
|
|
3150
|
-
const protocol = this.agentProtocols.get(provider);
|
|
3375
|
+
const protocol = this.agentProtocols.get(provider) ?? resolved?.protocol;
|
|
3151
3376
|
const runtimeCapabilities = this.providerCapabilities.get(provider);
|
|
3152
3377
|
const advertised = runtimeCapabilities?.advertised
|
|
3153
3378
|
?? (client instanceof AcpClient ? client.advertised : undefined);
|
|
3154
|
-
const
|
|
3379
|
+
const canStart = Boolean(resolved) && (this.input.availableProviders.includes(provider) || Boolean(this.input.command?.trim()));
|
|
3380
|
+
const enabled = Boolean(client) || canStart;
|
|
3155
3381
|
const isAcp = protocol === "acp";
|
|
3156
|
-
const supportsImages =
|
|
3382
|
+
const supportsImages = Boolean(client) && protocolSupportsImages(protocol, advertised);
|
|
3157
3383
|
const isClaudeFallback = protocol === "claude-stream-json";
|
|
3158
|
-
const supportsPermission =
|
|
3159
|
-
const supportsReasoningEffort =
|
|
3160
|
-
const
|
|
3161
|
-
const
|
|
3162
|
-
|
|
3163
|
-
|
|
3384
|
+
const supportsPermission = Boolean(client) && !isClaudeFallback;
|
|
3385
|
+
const supportsReasoningEffort = (Boolean(client) || (canStart && protocol === "codex-app-server")) && !isClaudeFallback;
|
|
3386
|
+
const diskListable = provider === "codex" || provider === "claude";
|
|
3387
|
+
const supportsSessionList = Boolean(client)
|
|
3388
|
+
? (isAcp ? Boolean(advertised?.listSession) : !isClaudeFallback)
|
|
3389
|
+
: canStart && diskListable;
|
|
3390
|
+
const supportsSessionLoad = Boolean(client)
|
|
3391
|
+
? (isAcp ? Boolean(advertised?.loadSession) : !isClaudeFallback)
|
|
3392
|
+
: canStart;
|
|
3393
|
+
const supportsCancel = Boolean(client)
|
|
3394
|
+
? (isAcp ? Boolean(advertised?.cancel) : true)
|
|
3395
|
+
: canStart && !isAcp;
|
|
3396
|
+
const supportsFork = Boolean(client) && (isAcp
|
|
3164
3397
|
? Boolean(advertised?.forkSession)
|
|
3165
3398
|
: protocol === "codex-app-server" || protocol === "claude-agent-sdk");
|
|
3166
|
-
const supportsSetModel =
|
|
3167
|
-
? Boolean(advertised?.setModel)
|
|
3168
|
-
: protocol === "codex-app-server"
|
|
3399
|
+
const supportsSetModel = Boolean(client)
|
|
3400
|
+
? (isAcp ? Boolean(advertised?.setModel) : protocol === "codex-app-server")
|
|
3401
|
+
: canStart && protocol === "codex-app-server";
|
|
3169
3402
|
const protocolBacked = isProtocolAgentProvider(provider);
|
|
3170
3403
|
const commands = mergeCommands(defaultProviderCommands(provider, this.input.cwd, enabled), runtimeCapabilities?.commands);
|
|
3171
3404
|
const currentMode = [...this.conversations.values()].find((conversation) => conversation.provider === provider)?.collaborationMode;
|
|
@@ -3182,8 +3415,8 @@ export class AgentWorkspaceProxy {
|
|
|
3182
3415
|
supportsPermission,
|
|
3183
3416
|
supportsPlan: enabled && (isAcp ? Boolean(advertised?.embeddedContext) : true),
|
|
3184
3417
|
supportsCancel,
|
|
3185
|
-
models: runtimeCapabilities?.models ?? [{ id: "default", label: "默认模型" }],
|
|
3186
|
-
defaultModel: runtimeCapabilities?.defaultModel ?? "default",
|
|
3418
|
+
models: runtimeCapabilities?.models ?? (provider === "codex" ? [...CODEX_FALLBACK_MODELS] : [{ id: "default", label: "默认模型" }]),
|
|
3419
|
+
defaultModel: runtimeCapabilities?.defaultModel ?? (provider === "codex" ? CODEX_FALLBACK_DEFAULT_MODEL : "default"),
|
|
3187
3420
|
reasoningEfforts: supportsReasoningEffort
|
|
3188
3421
|
? runtimeCapabilities?.reasoningEfforts ?? [...ALL_REASONING_EFFORTS]
|
|
3189
3422
|
: [],
|
|
@@ -3235,7 +3468,42 @@ export class AgentWorkspaceProxy {
|
|
|
3235
3468
|
},
|
|
3236
3469
|
}));
|
|
3237
3470
|
}
|
|
3471
|
+
shouldOpenAsAttachedView(conversation) {
|
|
3472
|
+
if (conversation.control === "attached")
|
|
3473
|
+
return true;
|
|
3474
|
+
if (this.attachedExternalCodex.has(conversation.id))
|
|
3475
|
+
return true;
|
|
3476
|
+
if (isLiveConversationId(conversation.id))
|
|
3477
|
+
return true;
|
|
3478
|
+
if (conversation.provider === "codex" &&
|
|
3479
|
+
conversation.agentSessionId &&
|
|
3480
|
+
!this.ownedCodexSessionIds.has(conversation.agentSessionId) &&
|
|
3481
|
+
conversation.status === "running") {
|
|
3482
|
+
return true;
|
|
3483
|
+
}
|
|
3484
|
+
return false;
|
|
3485
|
+
}
|
|
3486
|
+
openAttachedView(conversation, requestedConversationId) {
|
|
3487
|
+
conversation.control = "attached";
|
|
3488
|
+
this.attachedExternalCodex.add(conversation.id);
|
|
3489
|
+
this.watchedConversationIds.add(conversation.id);
|
|
3490
|
+
if (conversation.provider === "codex") {
|
|
3491
|
+
this.followCodexRolloutDisk(conversation, false);
|
|
3492
|
+
}
|
|
3493
|
+
this.activeConversationId = conversation.id;
|
|
3494
|
+
this.input.send(createEnvelope({
|
|
3495
|
+
type: "agent.v2.conversation.opened",
|
|
3496
|
+
sessionId: this.input.sessionId,
|
|
3497
|
+
payload: {
|
|
3498
|
+
conversation,
|
|
3499
|
+
snapshot: this.timelines.get(conversation.id) ?? [],
|
|
3500
|
+
requestedConversationId,
|
|
3501
|
+
},
|
|
3502
|
+
}));
|
|
3503
|
+
return conversation;
|
|
3504
|
+
}
|
|
3238
3505
|
async openConversation(payload) {
|
|
3506
|
+
await this.initialize();
|
|
3239
3507
|
const provider = payload.provider ?? this.input.availableProviders[0];
|
|
3240
3508
|
if (!provider) {
|
|
3241
3509
|
return this.openFailure(payload, "没有可用的 Agent provider。");
|
|
@@ -3270,6 +3538,15 @@ export class AgentWorkspaceProxy {
|
|
|
3270
3538
|
}
|
|
3271
3539
|
if (!payload.cwd && existingConversation?.cwd)
|
|
3272
3540
|
cwd = existingConversation.cwd;
|
|
3541
|
+
if (existingConversation && this.shouldOpenAsAttachedView(existingConversation)) {
|
|
3542
|
+
return this.openAttachedView(existingConversation, payload.conversationId);
|
|
3543
|
+
}
|
|
3544
|
+
if (existingConversation?.provider === "codex" &&
|
|
3545
|
+
existingConversation.agentSessionId &&
|
|
3546
|
+
!this.ownedCodexSessionIds.has(existingConversation.agentSessionId) &&
|
|
3547
|
+
runningCodexSessionIds(Date.now()).has(existingConversation.agentSessionId)) {
|
|
3548
|
+
return this.openAttachedView(existingConversation, payload.conversationId);
|
|
3549
|
+
}
|
|
3273
3550
|
let client = await this.ensureProviderClient(provider);
|
|
3274
3551
|
if (!client) {
|
|
3275
3552
|
const message = this.providerErrors.get(provider) ?? `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`;
|
|
@@ -3497,6 +3774,7 @@ export class AgentWorkspaceProxy {
|
|
|
3497
3774
|
this.conversations.set(conversation.id, conversation);
|
|
3498
3775
|
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
3499
3776
|
this.activeConversationId = conversation.id;
|
|
3777
|
+
this.watchedConversationIds.add(conversation.id);
|
|
3500
3778
|
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
3501
3779
|
await this.hydrateConversationFromProvider(conversation, result);
|
|
3502
3780
|
this.input.send(createEnvelope({
|
|
@@ -5302,10 +5580,15 @@ export class AgentWorkspaceProxy {
|
|
|
5302
5580
|
this.emitConversation(conversation);
|
|
5303
5581
|
}
|
|
5304
5582
|
sendSnapshot(conversationId) {
|
|
5305
|
-
const conversations = [...this.conversations.values()];
|
|
5583
|
+
const conversations = this.listedConversations([...this.conversations.values()].filter((conversation) => !conversation.archived));
|
|
5584
|
+
if (conversationId && !conversations.some((conversation) => conversation.id === conversationId)) {
|
|
5585
|
+
const extra = this.conversations.get(conversationId);
|
|
5586
|
+
if (extra)
|
|
5587
|
+
conversations.push(extra);
|
|
5588
|
+
}
|
|
5306
5589
|
const items = conversationId
|
|
5307
5590
|
? this.timelines.get(conversationId) ?? []
|
|
5308
|
-
: [...this.
|
|
5591
|
+
: [...this.watchedConversationIds].flatMap((id) => this.timelines.get(id) ?? []);
|
|
5309
5592
|
this.input.send(createEnvelope({
|
|
5310
5593
|
type: "agent.v2.snapshot",
|
|
5311
5594
|
sessionId: this.input.sessionId,
|