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
|
@@ -1724,6 +1724,134 @@ function findCodexRolloutPath(sessionId: string, root = join(homedir(), ".codex"
|
|
|
1724
1724
|
return undefined;
|
|
1725
1725
|
}
|
|
1726
1726
|
|
|
1727
|
+
type RemoteSessionRecord = {
|
|
1728
|
+
id: string;
|
|
1729
|
+
cwd?: string;
|
|
1730
|
+
title?: string;
|
|
1731
|
+
model?: string;
|
|
1732
|
+
createdAt?: number;
|
|
1733
|
+
lastActivityAt?: number;
|
|
1734
|
+
usage?: unknown;
|
|
1735
|
+
status?: AgentStatus;
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
function readJsonlPrefix(path: string, maxBytes = 8192): string | undefined {
|
|
1739
|
+
let fd: number | undefined;
|
|
1740
|
+
try {
|
|
1741
|
+
const size = statSync(path).size;
|
|
1742
|
+
if (size <= 0) return "";
|
|
1743
|
+
fd = openSync(path, "r");
|
|
1744
|
+
const buf = Buffer.alloc(Math.min(maxBytes, size));
|
|
1745
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
1746
|
+
return buf.toString("utf8");
|
|
1747
|
+
} catch {
|
|
1748
|
+
return undefined;
|
|
1749
|
+
} finally {
|
|
1750
|
+
if (fd !== undefined) {
|
|
1751
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Newest Codex rollouts as catalog metadata. Reads the first line only. */
|
|
1757
|
+
function listCodexSessionsFromDisk(limit = 80): RemoteSessionRecord[] {
|
|
1758
|
+
const files: { path: string; filenameId: string; mt: number }[] = [];
|
|
1759
|
+
walkCodexRollouts(join(homedir(), ".codex", "sessions"), (path, filenameId, mt) => {
|
|
1760
|
+
files.push({ path, filenameId, mt });
|
|
1761
|
+
});
|
|
1762
|
+
files.sort((a, b) => b.mt - a.mt);
|
|
1763
|
+
const out: RemoteSessionRecord[] = [];
|
|
1764
|
+
const seen = new Set<string>();
|
|
1765
|
+
for (const file of files) {
|
|
1766
|
+
if (out.length >= limit) break;
|
|
1767
|
+
const prefix = readJsonlPrefix(file.path);
|
|
1768
|
+
const first = prefix
|
|
1769
|
+
? prefix.slice(0, prefix.indexOf("\n") >= 0 ? prefix.indexOf("\n") : prefix.length)
|
|
1770
|
+
: "";
|
|
1771
|
+
let sessionId = file.filenameId;
|
|
1772
|
+
let cwd: string | undefined;
|
|
1773
|
+
let createdAt: number | undefined;
|
|
1774
|
+
if (first) {
|
|
1775
|
+
try {
|
|
1776
|
+
const payload = asRecord(asRecord(JSON.parse(first))?.payload) ?? asRecord(JSON.parse(first));
|
|
1777
|
+
sessionId = firstString(payload, ["session_id", "id"]) ?? file.filenameId;
|
|
1778
|
+
cwd = firstString(payload, ["cwd"]);
|
|
1779
|
+
createdAt = parseTimestamp(payload?.timestamp ?? asRecord(JSON.parse(first))?.timestamp);
|
|
1780
|
+
} catch {
|
|
1781
|
+
sessionId = file.filenameId;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
if (seen.has(sessionId)) continue;
|
|
1785
|
+
seen.add(sessionId);
|
|
1786
|
+
out.push({
|
|
1787
|
+
id: sessionId,
|
|
1788
|
+
cwd,
|
|
1789
|
+
title: cwd ? titleFromCwd(cwd) : undefined,
|
|
1790
|
+
createdAt,
|
|
1791
|
+
lastActivityAt: file.mt,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
return out;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function listClaudeSessionsFromDisk(limit = 80): RemoteSessionRecord[] {
|
|
1798
|
+
const root = join(homedir(), ".claude", "projects");
|
|
1799
|
+
if (!existsSync(root)) return [];
|
|
1800
|
+
const files: { id: string; mt: number; cwd?: string }[] = [];
|
|
1801
|
+
let projectDirs: string[] = [];
|
|
1802
|
+
try {
|
|
1803
|
+
projectDirs = readdirSync(root).map((name) => join(root, name));
|
|
1804
|
+
} catch {
|
|
1805
|
+
return [];
|
|
1806
|
+
}
|
|
1807
|
+
for (const dir of projectDirs) {
|
|
1808
|
+
let stat;
|
|
1809
|
+
try { stat = statSync(dir); } catch { continue; }
|
|
1810
|
+
if (!stat.isDirectory()) continue;
|
|
1811
|
+
const dirName = basename(dir);
|
|
1812
|
+
const cwd = dirName.startsWith("-") ? `/${dirName.slice(1).replace(/-/g, "/")}` : undefined;
|
|
1813
|
+
let entries: string[];
|
|
1814
|
+
try { entries = readdirSync(dir); } catch { continue; }
|
|
1815
|
+
for (const name of entries) {
|
|
1816
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
1817
|
+
const path = join(dir, name);
|
|
1818
|
+
let fileStat;
|
|
1819
|
+
try { fileStat = statSync(path); } catch { continue; }
|
|
1820
|
+
if (!fileStat.isFile()) continue;
|
|
1821
|
+
files.push({
|
|
1822
|
+
id: name.replace(/\.jsonl$/, ""),
|
|
1823
|
+
mt: fileStat.mtimeMs,
|
|
1824
|
+
cwd,
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
files.sort((a, b) => b.mt - a.mt);
|
|
1829
|
+
const out: RemoteSessionRecord[] = [];
|
|
1830
|
+
const seen = new Set<string>();
|
|
1831
|
+
for (const file of files) {
|
|
1832
|
+
if (out.length >= limit) break;
|
|
1833
|
+
if (seen.has(file.id)) continue;
|
|
1834
|
+
seen.add(file.id);
|
|
1835
|
+
out.push({
|
|
1836
|
+
id: file.id,
|
|
1837
|
+
cwd: file.cwd,
|
|
1838
|
+
title: file.cwd ? titleFromCwd(file.cwd) : undefined,
|
|
1839
|
+
lastActivityAt: file.mt,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
return out;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
1846
|
+
return new Promise((resolve, reject) => {
|
|
1847
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
1848
|
+
promise.then(
|
|
1849
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
1850
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
1851
|
+
);
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1727
1855
|
function findLatestCodexRolloutForCwd(cwd: string, root = join(homedir(), ".codex", "sessions")): { path: string; sessionId: string } | undefined {
|
|
1728
1856
|
const wanted = cwd.replace(/\/+$/, "");
|
|
1729
1857
|
const candidates: { path: string; sessionId: string; mt: number }[] = [];
|
|
@@ -2225,8 +2353,8 @@ function previewFromTimelineItem(item: AgentTimelineItem): string | undefined {
|
|
|
2225
2353
|
|
|
2226
2354
|
export class AgentWorkspaceProxy {
|
|
2227
2355
|
private clients = new Map<AgentProvider, AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient>();
|
|
2228
|
-
// In-flight client starts, so concurrent ensureProviderClient calls
|
|
2229
|
-
//
|
|
2356
|
+
// In-flight client starts, so concurrent ensureProviderClient calls
|
|
2357
|
+
// (prompt racing conversation.open) share one subprocess spawn.
|
|
2230
2358
|
private clientStartPromises = new Map<AgentProvider, Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined>>();
|
|
2231
2359
|
private agentProtocols = new Map<AgentProvider, AgentProtocol>();
|
|
2232
2360
|
private providerCapabilities = new Map<AgentProvider, ProviderRuntimeCapabilities>();
|
|
@@ -2280,7 +2408,6 @@ export class AgentWorkspaceProxy {
|
|
|
2280
2408
|
case "agent.v2.capabilities.request":
|
|
2281
2409
|
await this.initialize();
|
|
2282
2410
|
this.sendCapabilities();
|
|
2283
|
-
this.sendUsageReport();
|
|
2284
2411
|
break;
|
|
2285
2412
|
case "agent.v2.conversation.open": {
|
|
2286
2413
|
const payload = parseTypedPayload("agent.v2.conversation.open", envelope.payload);
|
|
@@ -2394,7 +2521,7 @@ export class AgentWorkspaceProxy {
|
|
|
2394
2521
|
this.input.send(createEnvelope({
|
|
2395
2522
|
type: "agent.v2.conversation.list.result",
|
|
2396
2523
|
sessionId: this.input.sessionId,
|
|
2397
|
-
payload: { conversations },
|
|
2524
|
+
payload: { conversations: this.listedConversations(conversations) },
|
|
2398
2525
|
}));
|
|
2399
2526
|
break;
|
|
2400
2527
|
}
|
|
@@ -2502,6 +2629,8 @@ export class AgentWorkspaceProxy {
|
|
|
2502
2629
|
private fsSyncRunning = false;
|
|
2503
2630
|
private lastPushedConversationSignature: string | undefined;
|
|
2504
2631
|
private lastHydrateAt = new Map<string, number>();
|
|
2632
|
+
/** Conversations the web actually opened — the only ones we tail/hydrate. */
|
|
2633
|
+
private watchedConversationIds = new Set<string>();
|
|
2505
2634
|
/** Session ids this LinkShell process created via app-server `newSession`. */
|
|
2506
2635
|
private ownedCodexSessionIds = new Set<string>();
|
|
2507
2636
|
/** Conversations attached to an external Codex TUI/desktop rollout (same file, not a copy). */
|
|
@@ -2512,8 +2641,11 @@ export class AgentWorkspaceProxy {
|
|
|
2512
2641
|
carry: string;
|
|
2513
2642
|
parser: CodexRolloutParser;
|
|
2514
2643
|
}>();
|
|
2515
|
-
private static readonly FS_SYNC_THROTTLE_MS =
|
|
2644
|
+
private static readonly FS_SYNC_THROTTLE_MS = 1500;
|
|
2516
2645
|
private static readonly EXTERNAL_HYDRATE_THROTTLE_MS = 400;
|
|
2646
|
+
private static readonly LIST_IDLE_CAP = 40;
|
|
2647
|
+
private static readonly LIST_HARD_CAP = 80;
|
|
2648
|
+
private static readonly CLIENT_START_TIMEOUT_MS = 12_000;
|
|
2517
2649
|
|
|
2518
2650
|
private startSessionWatchers(): void {
|
|
2519
2651
|
const roots = [
|
|
@@ -2557,7 +2689,9 @@ export class AgentWorkspaceProxy {
|
|
|
2557
2689
|
// A store directory may have appeared since the last call — attach a
|
|
2558
2690
|
// watcher to it now (no-op for roots already watched).
|
|
2559
2691
|
this.startSessionWatchers();
|
|
2560
|
-
const conversations =
|
|
2692
|
+
const conversations = this.listedConversations(
|
|
2693
|
+
[...this.conversations.values()].filter((c) => !c.archived),
|
|
2694
|
+
);
|
|
2561
2695
|
// Only broadcast when the list actually changed since the last push. An
|
|
2562
2696
|
// fs-watch fires on every transcript append (once per streamed token for
|
|
2563
2697
|
// an active session), but most of those don't change any field the tree
|
|
@@ -2576,20 +2710,22 @@ export class AgentWorkspaceProxy {
|
|
|
2576
2710
|
payload: { conversations },
|
|
2577
2711
|
}));
|
|
2578
2712
|
}
|
|
2579
|
-
//
|
|
2580
|
-
//
|
|
2581
|
-
// Re-hydrate on a short throttle so the Web timeline still moves.
|
|
2713
|
+
// Only tail conversations the client opened. Scanning every running
|
|
2714
|
+
// disk session (hundreds of Codex rollouts) floods the gateway.
|
|
2582
2715
|
const now = Date.now();
|
|
2583
|
-
for (const
|
|
2584
|
-
|
|
2716
|
+
for (const conversationId of this.watchedConversationIds) {
|
|
2717
|
+
const conversation = this.conversations.get(conversationId);
|
|
2718
|
+
if (!conversation) continue;
|
|
2719
|
+
if (conversation.control !== "attached") continue;
|
|
2585
2720
|
if (this.currentTurnIds.has(conversation.id)) continue;
|
|
2586
2721
|
const last = this.lastHydrateAt.get(conversation.id) ?? 0;
|
|
2587
2722
|
if (now - last < AgentWorkspaceProxy.EXTERNAL_HYDRATE_THROTTLE_MS) continue;
|
|
2588
2723
|
this.lastHydrateAt.set(conversation.id, now);
|
|
2589
2724
|
try {
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2725
|
+
if (conversation.provider === "codex") {
|
|
2726
|
+
if (this.followCodexRolloutDisk(conversation, true) > 0) {
|
|
2727
|
+
this.sendSnapshot(conversation.id);
|
|
2728
|
+
}
|
|
2593
2729
|
}
|
|
2594
2730
|
} catch (error) {
|
|
2595
2731
|
if (this.input.verbose) {
|
|
@@ -2611,6 +2747,63 @@ export class AgentWorkspaceProxy {
|
|
|
2611
2747
|
* Deliberately excludes usage token counts / lastActivityAt timestamps that
|
|
2612
2748
|
* tick on every append without altering the tree — status, title, model, and
|
|
2613
2749
|
* membership are what matter. */
|
|
2750
|
+
private listedConversations(conversations: AgentConversation[]): AgentConversation[] {
|
|
2751
|
+
const ranked = [...conversations].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
2752
|
+
const out: AgentConversation[] = [];
|
|
2753
|
+
const seen = new Set<string>();
|
|
2754
|
+
const take = (conversation: AgentConversation) => {
|
|
2755
|
+
if (seen.has(conversation.id)) return;
|
|
2756
|
+
seen.add(conversation.id);
|
|
2757
|
+
out.push(conversation);
|
|
2758
|
+
};
|
|
2759
|
+
for (const conversation of ranked) {
|
|
2760
|
+
if (out.length >= AgentWorkspaceProxy.LIST_HARD_CAP) break;
|
|
2761
|
+
if (
|
|
2762
|
+
conversation.status === "running" ||
|
|
2763
|
+
conversation.status === "waiting_permission" ||
|
|
2764
|
+
conversation.control === "owned" ||
|
|
2765
|
+
this.watchedConversationIds.has(conversation.id)
|
|
2766
|
+
) {
|
|
2767
|
+
take(conversation);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
for (const conversation of ranked) {
|
|
2771
|
+
if (out.length >= AgentWorkspaceProxy.LIST_IDLE_CAP) break;
|
|
2772
|
+
take(conversation);
|
|
2773
|
+
}
|
|
2774
|
+
return out;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
private pickListedRemotes<T extends { id: string; lastActivityAt?: number }>(
|
|
2778
|
+
remotes: T[],
|
|
2779
|
+
provider: AgentProvider,
|
|
2780
|
+
runningIds?: Set<string>,
|
|
2781
|
+
): T[] {
|
|
2782
|
+
const ranked = [...remotes].sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
2783
|
+
const out: T[] = [];
|
|
2784
|
+
const seen = new Set<string>();
|
|
2785
|
+
const take = (remote: T) => {
|
|
2786
|
+
if (seen.has(remote.id)) return;
|
|
2787
|
+
seen.add(remote.id);
|
|
2788
|
+
out.push(remote);
|
|
2789
|
+
};
|
|
2790
|
+
for (const remote of ranked) {
|
|
2791
|
+
const conversationId = makeAgentV2RemoteConversationId(provider, remote.id);
|
|
2792
|
+
if (
|
|
2793
|
+
this.ownedCodexSessionIds.has(remote.id) ||
|
|
2794
|
+
this.watchedConversationIds.has(conversationId) ||
|
|
2795
|
+
runningIds?.has(remote.id)
|
|
2796
|
+
) {
|
|
2797
|
+
take(remote);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
for (const remote of ranked) {
|
|
2801
|
+
if (out.length >= AgentWorkspaceProxy.LIST_IDLE_CAP) break;
|
|
2802
|
+
take(remote);
|
|
2803
|
+
}
|
|
2804
|
+
return out;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2614
2807
|
private conversationListSignature(conversations: AgentConversation[]): string {
|
|
2615
2808
|
return conversations
|
|
2616
2809
|
.map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}${c.lastMessagePreview ?? ""}`)
|
|
@@ -2658,14 +2851,11 @@ export class AgentWorkspaceProxy {
|
|
|
2658
2851
|
private async initialize(): Promise<void> {
|
|
2659
2852
|
if (this.initialized) return;
|
|
2660
2853
|
this.initialized = true;
|
|
2661
|
-
// Eagerly start all detected providers so capabilities report real status
|
|
2662
|
-
const startPromises = this.input.availableProviders.map((p) => this.ensureProviderClient(p));
|
|
2663
|
-
await Promise.allSettled(startPromises);
|
|
2664
2854
|
this.status = "idle";
|
|
2665
2855
|
this.error = undefined;
|
|
2856
|
+
// Presence only. Spawning every detected CLI here (especially
|
|
2857
|
+
// `grok agent stdio`) blocked the sequential control plane on connect.
|
|
2666
2858
|
this.sendCapabilities();
|
|
2667
|
-
// Start watching the on-disk session stores so externally-started sessions
|
|
2668
|
-
// and status changes surface live, not just at connect time.
|
|
2669
2859
|
this.startSessionWatchers();
|
|
2670
2860
|
this.startProcessDiscovery();
|
|
2671
2861
|
}
|
|
@@ -2819,7 +3009,9 @@ export class AgentWorkspaceProxy {
|
|
|
2819
3009
|
this.lastDiscoveredProviders = discoveredProviders;
|
|
2820
3010
|
if (catalogChanged) this.sendCapabilities();
|
|
2821
3011
|
if (listChanged) {
|
|
2822
|
-
const conversations =
|
|
3012
|
+
const conversations = this.listedConversations(
|
|
3013
|
+
[...this.conversations.values()].filter((item) => !item.archived),
|
|
3014
|
+
);
|
|
2823
3015
|
this.lastPushedConversationSignature = this.conversationListSignature(conversations);
|
|
2824
3016
|
this.input.send(createEnvelope({
|
|
2825
3017
|
type: "agent.v2.conversation.list.result",
|
|
@@ -2833,10 +3025,9 @@ export class AgentWorkspaceProxy {
|
|
|
2833
3025
|
const existing = this.clients.get(provider);
|
|
2834
3026
|
if (existing) return existing;
|
|
2835
3027
|
|
|
2836
|
-
// Dedupe concurrent starts:
|
|
2837
|
-
//
|
|
2838
|
-
//
|
|
2839
|
-
// twice. Memoize the in-flight promise so both callers share one spawn;
|
|
3028
|
+
// Dedupe concurrent starts: conversation.open and prompt can race here
|
|
3029
|
+
// before this.clients is populated (it's only set AFTER initialize).
|
|
3030
|
+
// Memoize the in-flight promise so both callers share one spawn;
|
|
2840
3031
|
// drop it once settled so a failed start can be retried.
|
|
2841
3032
|
const inflight = this.clientStartPromises.get(provider);
|
|
2842
3033
|
if (inflight) return inflight;
|
|
@@ -2894,8 +3085,17 @@ export class AgentWorkspaceProxy {
|
|
|
2894
3085
|
onRequest: (method, params) => this.handleRequest(method, params),
|
|
2895
3086
|
onExit: (message) => this.handleProviderExit(provider, message),
|
|
2896
3087
|
});
|
|
2897
|
-
|
|
2898
|
-
|
|
3088
|
+
try {
|
|
3089
|
+
await withTimeout(
|
|
3090
|
+
client.initialize(),
|
|
3091
|
+
AgentWorkspaceProxy.CLIENT_START_TIMEOUT_MS,
|
|
3092
|
+
`${providerLabel(provider)} 启动超时`,
|
|
3093
|
+
);
|
|
3094
|
+
return client;
|
|
3095
|
+
} catch (error) {
|
|
3096
|
+
try { client.stop(); } catch { /* ignore */ }
|
|
3097
|
+
throw error;
|
|
3098
|
+
}
|
|
2899
3099
|
};
|
|
2900
3100
|
|
|
2901
3101
|
try {
|
|
@@ -3071,92 +3271,114 @@ export class AgentWorkspaceProxy {
|
|
|
3071
3271
|
}
|
|
3072
3272
|
}
|
|
3073
3273
|
|
|
3074
|
-
private
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
// Codex's thread/list carries no live status and only knows threads
|
|
3089
|
-
// THIS app-server owns — so an externally-run `codex` shows idle. Read
|
|
3090
|
-
// running state off disk once per sync (CPU-frugal: stats all rollouts,
|
|
3091
|
-
// reads only the fresh few). Claude reports status via parseRemoteSessions.
|
|
3092
|
-
const codexRunning = provider === "codex" ? runningCodexSessionIds(Date.now()) : undefined;
|
|
3093
|
-
for (const remote of parseRemoteSessions(result)) {
|
|
3094
|
-
const agentSessionId = remote.id;
|
|
3095
|
-
// Skip conversations the user explicitly forgot — don't resurrect them.
|
|
3096
|
-
if (this.deletedAgentSessionIds.has(agentSessionId)) continue;
|
|
3097
|
-
const existingId = this.conversationByAgentSessionId.get(agentSessionId);
|
|
3098
|
-
const now = Date.now();
|
|
3099
|
-
const conversationId = existingId ?? makeAgentV2RemoteConversationId(provider, agentSessionId);
|
|
3100
|
-
const existing = this.conversations.get(conversationId);
|
|
3101
|
-
const cwd = remote.cwd ?? existing?.cwd ?? this.input.cwd;
|
|
3102
|
-
const model = remote.model ?? existing?.model;
|
|
3103
|
-
// Usage read straight from the on-disk transcript (decoupled from any
|
|
3104
|
-
// live LinkShell turn) — so history + externally-started sessions all
|
|
3105
|
-
// carry a context meter. Falls back to any existing live-turn usage.
|
|
3106
|
-
const usageFields = normalizeUsageFields(remote.usage, model);
|
|
3107
|
-
const usage: AgentConversationUsage | undefined = usageFields
|
|
3108
|
-
? { ...usageFields, updatedAt: remote.lastActivityAt ?? now }
|
|
3109
|
-
: existing?.usage;
|
|
3110
|
-
// Status precedence. While LinkShell is driving a turn for this
|
|
3111
|
-
// conversation, the in-memory status is authoritative — the transcript
|
|
3112
|
-
// lags (the SDK hasn't flushed the in-flight turn to disk), so trusting
|
|
3113
|
-
// it would flicker a live "running" turn back to idle. Otherwise the
|
|
3114
|
-
// transcript-derived running signal is authoritative (Claude reports it
|
|
3115
|
-
// via remote.status; Codex via the on-disk rollout set): surface
|
|
3116
|
-
// "running" while an external process drives it, and let it clear once
|
|
3117
|
-
// the external turn finishes. We only fall back to the existing status
|
|
3118
|
-
// to keep error/waiting_permission sticky across a re-sync — the
|
|
3119
|
-
// transcript signals never emit those, so they live only in `existing`;
|
|
3120
|
-
// defaulting everything else to idle avoids a purely-external session
|
|
3121
|
-
// getting stuck at "running" forever.
|
|
3122
|
-
const remoteRunning = remote.status === "running" || (codexRunning?.has(agentSessionId) ?? false);
|
|
3123
|
-
const linkshellDriving = this.currentTurnIds.has(conversationId);
|
|
3124
|
-
const status: AgentStatus = linkshellDriving
|
|
3125
|
-
? existing?.status ?? "running"
|
|
3126
|
-
: remoteRunning
|
|
3127
|
-
? "running"
|
|
3128
|
-
: remote.status === "error"
|
|
3129
|
-
? "error"
|
|
3130
|
-
: existing?.status === "error" || existing?.status === "waiting_permission"
|
|
3131
|
-
? existing.status
|
|
3132
|
-
: "idle";
|
|
3133
|
-
const conversation: AgentConversation = {
|
|
3134
|
-
id: conversationId,
|
|
3135
|
-
agentSessionId,
|
|
3136
|
-
provider,
|
|
3137
|
-
cwd,
|
|
3138
|
-
title: remote.title ?? existing?.title ?? titleFromCwd(cwd),
|
|
3139
|
-
model,
|
|
3140
|
-
reasoningEffort: existing?.reasoningEffort,
|
|
3141
|
-
permissionMode: existing?.permissionMode,
|
|
3142
|
-
collaborationMode: existing?.collaborationMode,
|
|
3143
|
-
status,
|
|
3144
|
-
archived: existing?.archived ?? false,
|
|
3145
|
-
lastMessagePreview: existing?.lastMessagePreview,
|
|
3146
|
-
lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
|
|
3147
|
-
createdAt: remote.createdAt ?? existing?.createdAt ?? now,
|
|
3148
|
-
usage,
|
|
3149
|
-
group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
|
|
3150
|
-
workspaceRoots: existing?.workspaceRoots,
|
|
3151
|
-
};
|
|
3152
|
-
this.conversations.set(conversation.id, conversation);
|
|
3153
|
-
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
3154
|
-
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
3155
|
-
}
|
|
3156
|
-
} catch (error) {
|
|
3274
|
+
private listDiskSessions(provider: AgentProvider): RemoteSessionRecord[] {
|
|
3275
|
+
if (provider === "codex") return listCodexSessionsFromDisk(AgentWorkspaceProxy.LIST_HARD_CAP);
|
|
3276
|
+
if (provider === "claude") return listClaudeSessionsFromDisk(AgentWorkspaceProxy.LIST_HARD_CAP);
|
|
3277
|
+
return [];
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
private async listClientSessions(provider: AgentProvider): Promise<RemoteSessionRecord[]> {
|
|
3281
|
+
const client = this.clients.get(provider);
|
|
3282
|
+
if (!client) return [];
|
|
3283
|
+
try {
|
|
3284
|
+
return parseRemoteSessions(await client.listSessions());
|
|
3285
|
+
} catch (error) {
|
|
3286
|
+
const recovered = await this.recycleProviderClient(provider, error);
|
|
3287
|
+
if (!recovered) {
|
|
3157
3288
|
if (this.input.verbose) {
|
|
3158
3289
|
process.stderr.write(`[agent:v2] session list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
3159
3290
|
}
|
|
3291
|
+
return [];
|
|
3292
|
+
}
|
|
3293
|
+
try {
|
|
3294
|
+
return parseRemoteSessions(await recovered.listSessions());
|
|
3295
|
+
} catch (retryError) {
|
|
3296
|
+
if (this.input.verbose) {
|
|
3297
|
+
process.stderr.write(`[agent:v2] session list failed for ${provider}: ${retryError instanceof Error ? retryError.message : String(retryError)}\n`);
|
|
3298
|
+
}
|
|
3299
|
+
return [];
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
private upsertRemoteSession(
|
|
3305
|
+
provider: AgentProvider,
|
|
3306
|
+
remote: RemoteSessionRecord,
|
|
3307
|
+
now: number,
|
|
3308
|
+
runningIds?: Set<string>,
|
|
3309
|
+
): void {
|
|
3310
|
+
const agentSessionId = remote.id;
|
|
3311
|
+
if (this.deletedAgentSessionIds.has(agentSessionId)) return;
|
|
3312
|
+
const existingId = this.conversationByAgentSessionId.get(agentSessionId);
|
|
3313
|
+
const conversationId = existingId ?? makeAgentV2RemoteConversationId(provider, agentSessionId);
|
|
3314
|
+
const existing = this.conversations.get(conversationId);
|
|
3315
|
+
const cwd = remote.cwd ?? existing?.cwd ?? this.input.cwd;
|
|
3316
|
+
const model = remote.model ?? existing?.model;
|
|
3317
|
+
const usageFields = normalizeUsageFields(remote.usage, model);
|
|
3318
|
+
const usage: AgentConversationUsage | undefined = usageFields
|
|
3319
|
+
? { ...usageFields, updatedAt: remote.lastActivityAt ?? now }
|
|
3320
|
+
: existing?.usage;
|
|
3321
|
+
const remoteRunning = remote.status === "running" || (runningIds?.has(agentSessionId) ?? false);
|
|
3322
|
+
const linkshellDriving = this.currentTurnIds.has(conversationId);
|
|
3323
|
+
const owned = Boolean(agentSessionId && this.ownedCodexSessionIds.has(agentSessionId));
|
|
3324
|
+
const attached = !owned && (
|
|
3325
|
+
existing?.control === "attached" ||
|
|
3326
|
+
this.attachedExternalCodex.has(conversationId) ||
|
|
3327
|
+
remoteRunning
|
|
3328
|
+
);
|
|
3329
|
+
if (attached) this.attachedExternalCodex.add(conversationId);
|
|
3330
|
+
const status: AgentStatus = linkshellDriving
|
|
3331
|
+
? existing?.status ?? "running"
|
|
3332
|
+
: remoteRunning
|
|
3333
|
+
? "running"
|
|
3334
|
+
: remote.status === "error"
|
|
3335
|
+
? "error"
|
|
3336
|
+
: existing?.status === "error" || existing?.status === "waiting_permission"
|
|
3337
|
+
? existing.status
|
|
3338
|
+
: "idle";
|
|
3339
|
+
const conversation: AgentConversation = {
|
|
3340
|
+
id: conversationId,
|
|
3341
|
+
agentSessionId,
|
|
3342
|
+
provider,
|
|
3343
|
+
cwd,
|
|
3344
|
+
title: remote.title ?? existing?.title ?? titleFromCwd(cwd),
|
|
3345
|
+
model,
|
|
3346
|
+
reasoningEffort: existing?.reasoningEffort,
|
|
3347
|
+
permissionMode: existing?.permissionMode,
|
|
3348
|
+
collaborationMode: existing?.collaborationMode,
|
|
3349
|
+
control: owned ? "owned" : attached ? "attached" : existing?.control,
|
|
3350
|
+
status,
|
|
3351
|
+
archived: existing?.archived ?? false,
|
|
3352
|
+
lastMessagePreview: existing?.lastMessagePreview,
|
|
3353
|
+
lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
|
|
3354
|
+
createdAt: remote.createdAt ?? existing?.createdAt ?? now,
|
|
3355
|
+
usage,
|
|
3356
|
+
group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
|
|
3357
|
+
workspaceRoots: existing?.workspaceRoots,
|
|
3358
|
+
};
|
|
3359
|
+
this.conversations.set(conversation.id, conversation);
|
|
3360
|
+
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
3361
|
+
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
private async syncProviderSessions(): Promise<void> {
|
|
3365
|
+
await this.initialize();
|
|
3366
|
+
const now = Date.now();
|
|
3367
|
+
const codexRunning = runningCodexSessionIds(now);
|
|
3368
|
+
const providers = new Set<AgentProvider>([
|
|
3369
|
+
...this.input.availableProviders,
|
|
3370
|
+
...this.clients.keys(),
|
|
3371
|
+
]);
|
|
3372
|
+
for (const provider of providers) {
|
|
3373
|
+
const byId = new Map<string, RemoteSessionRecord>();
|
|
3374
|
+
for (const remote of this.listDiskSessions(provider)) byId.set(remote.id, remote);
|
|
3375
|
+
for (const remote of await this.listClientSessions(provider)) {
|
|
3376
|
+
const prev = byId.get(remote.id);
|
|
3377
|
+
byId.set(remote.id, prev ? { ...prev, ...remote } : remote);
|
|
3378
|
+
}
|
|
3379
|
+
const runningIds = provider === "codex" ? codexRunning : undefined;
|
|
3380
|
+
for (const remote of this.pickListedRemotes([...byId.values()], provider, runningIds)) {
|
|
3381
|
+
this.upsertRemoteSession(provider, remote, now, runningIds);
|
|
3160
3382
|
}
|
|
3161
3383
|
}
|
|
3162
3384
|
}
|
|
@@ -3395,26 +3617,40 @@ export class AgentWorkspaceProxy {
|
|
|
3395
3617
|
...this.lastDiscoveredProviders,
|
|
3396
3618
|
])];
|
|
3397
3619
|
const providers = catalog.map((provider) => {
|
|
3620
|
+
const resolved = resolveAgentCommand({
|
|
3621
|
+
provider,
|
|
3622
|
+
command: this.input.command,
|
|
3623
|
+
});
|
|
3398
3624
|
const client = this.clients.get(provider);
|
|
3399
|
-
const protocol = this.agentProtocols.get(provider);
|
|
3625
|
+
const protocol = this.agentProtocols.get(provider) ?? resolved?.protocol;
|
|
3400
3626
|
const runtimeCapabilities = this.providerCapabilities.get(provider);
|
|
3401
3627
|
const advertised = runtimeCapabilities?.advertised
|
|
3402
3628
|
?? (client instanceof AcpClient ? client.advertised : undefined);
|
|
3403
|
-
const
|
|
3629
|
+
const canStart = Boolean(resolved) && (
|
|
3630
|
+
this.input.availableProviders.includes(provider) || Boolean(this.input.command?.trim())
|
|
3631
|
+
);
|
|
3632
|
+
const enabled = Boolean(client) || canStart;
|
|
3404
3633
|
const isAcp = protocol === "acp";
|
|
3405
|
-
const supportsImages =
|
|
3634
|
+
const supportsImages = Boolean(client) && protocolSupportsImages(protocol, advertised);
|
|
3406
3635
|
const isClaudeFallback = protocol === "claude-stream-json";
|
|
3407
|
-
const supportsPermission =
|
|
3408
|
-
const supportsReasoningEffort =
|
|
3409
|
-
const
|
|
3410
|
-
const
|
|
3411
|
-
|
|
3412
|
-
|
|
3636
|
+
const supportsPermission = Boolean(client) && !isClaudeFallback;
|
|
3637
|
+
const supportsReasoningEffort = (Boolean(client) || (canStart && protocol === "codex-app-server")) && !isClaudeFallback;
|
|
3638
|
+
const diskListable = provider === "codex" || provider === "claude";
|
|
3639
|
+
const supportsSessionList = Boolean(client)
|
|
3640
|
+
? (isAcp ? Boolean(advertised?.listSession) : !isClaudeFallback)
|
|
3641
|
+
: canStart && diskListable;
|
|
3642
|
+
const supportsSessionLoad = Boolean(client)
|
|
3643
|
+
? (isAcp ? Boolean(advertised?.loadSession) : !isClaudeFallback)
|
|
3644
|
+
: canStart;
|
|
3645
|
+
const supportsCancel = Boolean(client)
|
|
3646
|
+
? (isAcp ? Boolean(advertised?.cancel) : true)
|
|
3647
|
+
: canStart && !isAcp;
|
|
3648
|
+
const supportsFork = Boolean(client) && (isAcp
|
|
3413
3649
|
? Boolean(advertised?.forkSession)
|
|
3414
3650
|
: protocol === "codex-app-server" || protocol === "claude-agent-sdk");
|
|
3415
|
-
const supportsSetModel =
|
|
3416
|
-
? Boolean(advertised?.setModel)
|
|
3417
|
-
: protocol === "codex-app-server"
|
|
3651
|
+
const supportsSetModel = Boolean(client)
|
|
3652
|
+
? (isAcp ? Boolean(advertised?.setModel) : protocol === "codex-app-server")
|
|
3653
|
+
: canStart && protocol === "codex-app-server";
|
|
3418
3654
|
const protocolBacked = isProtocolAgentProvider(provider);
|
|
3419
3655
|
const commands = mergeCommands(
|
|
3420
3656
|
defaultProviderCommands(provider, this.input.cwd, enabled),
|
|
@@ -3434,8 +3670,8 @@ export class AgentWorkspaceProxy {
|
|
|
3434
3670
|
supportsPermission,
|
|
3435
3671
|
supportsPlan: enabled && (isAcp ? Boolean(advertised?.embeddedContext) : true),
|
|
3436
3672
|
supportsCancel,
|
|
3437
|
-
models: runtimeCapabilities?.models ?? [{ id: "default", label: "默认模型" }],
|
|
3438
|
-
defaultModel: runtimeCapabilities?.defaultModel ?? "default",
|
|
3673
|
+
models: runtimeCapabilities?.models ?? (provider === "codex" ? [...CODEX_FALLBACK_MODELS] : [{ id: "default", label: "默认模型" }]),
|
|
3674
|
+
defaultModel: runtimeCapabilities?.defaultModel ?? (provider === "codex" ? CODEX_FALLBACK_DEFAULT_MODEL : "default"),
|
|
3439
3675
|
reasoningEfforts: supportsReasoningEffort
|
|
3440
3676
|
? runtimeCapabilities?.reasoningEfforts ?? [...ALL_REASONING_EFFORTS]
|
|
3441
3677
|
: [],
|
|
@@ -3488,6 +3724,44 @@ export class AgentWorkspaceProxy {
|
|
|
3488
3724
|
}));
|
|
3489
3725
|
}
|
|
3490
3726
|
|
|
3727
|
+
private shouldOpenAsAttachedView(conversation: AgentConversation): boolean {
|
|
3728
|
+
if (conversation.control === "attached") return true;
|
|
3729
|
+
if (this.attachedExternalCodex.has(conversation.id)) return true;
|
|
3730
|
+
if (isLiveConversationId(conversation.id)) return true;
|
|
3731
|
+
if (
|
|
3732
|
+
conversation.provider === "codex" &&
|
|
3733
|
+
conversation.agentSessionId &&
|
|
3734
|
+
!this.ownedCodexSessionIds.has(conversation.agentSessionId) &&
|
|
3735
|
+
conversation.status === "running"
|
|
3736
|
+
) {
|
|
3737
|
+
return true;
|
|
3738
|
+
}
|
|
3739
|
+
return false;
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
private openAttachedView(
|
|
3743
|
+
conversation: AgentConversation,
|
|
3744
|
+
requestedConversationId?: string,
|
|
3745
|
+
): AgentConversation {
|
|
3746
|
+
conversation.control = "attached";
|
|
3747
|
+
this.attachedExternalCodex.add(conversation.id);
|
|
3748
|
+
this.watchedConversationIds.add(conversation.id);
|
|
3749
|
+
if (conversation.provider === "codex") {
|
|
3750
|
+
this.followCodexRolloutDisk(conversation, false);
|
|
3751
|
+
}
|
|
3752
|
+
this.activeConversationId = conversation.id;
|
|
3753
|
+
this.input.send(createEnvelope({
|
|
3754
|
+
type: "agent.v2.conversation.opened",
|
|
3755
|
+
sessionId: this.input.sessionId,
|
|
3756
|
+
payload: {
|
|
3757
|
+
conversation,
|
|
3758
|
+
snapshot: this.timelines.get(conversation.id) ?? [],
|
|
3759
|
+
requestedConversationId,
|
|
3760
|
+
},
|
|
3761
|
+
}));
|
|
3762
|
+
return conversation;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3491
3765
|
private async openConversation(payload: {
|
|
3492
3766
|
conversationId?: string;
|
|
3493
3767
|
agentSessionId?: string;
|
|
@@ -3501,6 +3775,7 @@ export class AgentWorkspaceProxy {
|
|
|
3501
3775
|
forkFromConversationId?: string;
|
|
3502
3776
|
forkFromTurnId?: string;
|
|
3503
3777
|
}): Promise<AgentConversation | undefined> {
|
|
3778
|
+
await this.initialize();
|
|
3504
3779
|
const provider = payload.provider ?? this.input.availableProviders[0];
|
|
3505
3780
|
if (!provider) {
|
|
3506
3781
|
return this.openFailure(payload, "没有可用的 Agent provider。");
|
|
@@ -3545,6 +3820,18 @@ export class AgentWorkspaceProxy {
|
|
|
3545
3820
|
}
|
|
3546
3821
|
if (!payload.cwd && existingConversation?.cwd) cwd = existingConversation.cwd;
|
|
3547
3822
|
|
|
3823
|
+
if (existingConversation && this.shouldOpenAsAttachedView(existingConversation)) {
|
|
3824
|
+
return this.openAttachedView(existingConversation, payload.conversationId);
|
|
3825
|
+
}
|
|
3826
|
+
if (
|
|
3827
|
+
existingConversation?.provider === "codex" &&
|
|
3828
|
+
existingConversation.agentSessionId &&
|
|
3829
|
+
!this.ownedCodexSessionIds.has(existingConversation.agentSessionId) &&
|
|
3830
|
+
runningCodexSessionIds(Date.now()).has(existingConversation.agentSessionId)
|
|
3831
|
+
) {
|
|
3832
|
+
return this.openAttachedView(existingConversation, payload.conversationId);
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3548
3835
|
let client = await this.ensureProviderClient(provider);
|
|
3549
3836
|
if (!client) {
|
|
3550
3837
|
const message = this.providerErrors.get(provider) ?? `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`;
|
|
@@ -3773,6 +4060,7 @@ export class AgentWorkspaceProxy {
|
|
|
3773
4060
|
this.conversations.set(conversation.id, conversation);
|
|
3774
4061
|
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
3775
4062
|
this.activeConversationId = conversation.id;
|
|
4063
|
+
this.watchedConversationIds.add(conversation.id);
|
|
3776
4064
|
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
3777
4065
|
await this.hydrateConversationFromProvider(conversation, result);
|
|
3778
4066
|
this.input.send(createEnvelope({
|
|
@@ -5747,10 +6035,16 @@ export class AgentWorkspaceProxy {
|
|
|
5747
6035
|
}
|
|
5748
6036
|
|
|
5749
6037
|
private sendSnapshot(conversationId?: string): void {
|
|
5750
|
-
const conversations =
|
|
6038
|
+
const conversations = this.listedConversations(
|
|
6039
|
+
[...this.conversations.values()].filter((conversation) => !conversation.archived),
|
|
6040
|
+
);
|
|
6041
|
+
if (conversationId && !conversations.some((conversation) => conversation.id === conversationId)) {
|
|
6042
|
+
const extra = this.conversations.get(conversationId);
|
|
6043
|
+
if (extra) conversations.push(extra);
|
|
6044
|
+
}
|
|
5751
6045
|
const items = conversationId
|
|
5752
6046
|
? this.timelines.get(conversationId) ?? []
|
|
5753
|
-
: [...this.
|
|
6047
|
+
: [...this.watchedConversationIds].flatMap((id) => this.timelines.get(id) ?? []);
|
|
5754
6048
|
this.input.send(createEnvelope({
|
|
5755
6049
|
type: "agent.v2.snapshot",
|
|
5756
6050
|
sessionId: this.input.sessionId,
|