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.
@@ -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 (eager
2229
- // initialize() racing conversation.open) share one subprocess spawn.
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 = 400;
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 = [...this.conversations.values()].filter((c) => !c.archived);
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
- // External sessions (status running / waiting_permission but LinkShell is
2580
- // not driving the turn) have no live event stream on this app-server.
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 conversation of conversations) {
2584
- if (conversation.status !== "running" && conversation.status !== "waiting_permission") continue;
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
- await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
2591
- if (this.followCodexRolloutDisk(conversation, true) > 0) {
2592
- this.sendSnapshot(conversation.id);
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 = [...this.conversations.values()].filter((item) => !item.archived);
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: eager initialize() and conversation.open can
2837
- // race here before this.clients is populated (it's only set AFTER the
2838
- // client finishes initializing), which would spawn the provider subprocess
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
- await client.initialize();
2898
- return client;
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 async syncProviderSessions(): Promise<void> {
3075
- await this.initialize();
3076
- for (const [provider, client] of this.clients) {
3077
- try {
3078
- let activeClient = client;
3079
- let result: unknown;
3080
- try {
3081
- result = await activeClient.listSessions();
3082
- } catch (error) {
3083
- const recovered = await this.recycleProviderClient(provider, error);
3084
- if (!recovered) throw error;
3085
- activeClient = recovered;
3086
- result = await activeClient.listSessions();
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 enabled = Boolean(client);
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 = enabled && protocolSupportsImages(protocol, advertised);
3634
+ const supportsImages = Boolean(client) && protocolSupportsImages(protocol, advertised);
3406
3635
  const isClaudeFallback = protocol === "claude-stream-json";
3407
- const supportsPermission = enabled && !isClaudeFallback;
3408
- const supportsReasoningEffort = enabled && !isClaudeFallback;
3409
- const supportsSessionList = enabled && (isAcp ? Boolean(advertised?.listSession) : !isClaudeFallback);
3410
- const supportsSessionLoad = enabled && (isAcp ? Boolean(advertised?.loadSession) : !isClaudeFallback);
3411
- const supportsCancel = enabled && (isAcp ? Boolean(advertised?.cancel) : true);
3412
- const supportsFork = enabled && (isAcp
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 = enabled && (isAcp
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 = [...this.conversations.values()];
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.timelines.values()].flat();
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,