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.
@@ -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 (eager
1982
- // initialize() racing conversation.open) share one subprocess spawn.
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 = 400;
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
- // External sessions (status running / waiting_permission but LinkShell is
2319
- // not driving the turn) have no live event stream on this app-server.
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 conversation of conversations) {
2323
- if (conversation.status !== "running" && conversation.status !== "waiting_permission")
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
- await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
2333
- if (this.followCodexRolloutDisk(conversation, true) > 0) {
2334
- this.sendSnapshot(conversation.id);
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: eager initialize() and conversation.open can
2583
- // race here before this.clients is populated (it's only set AFTER the
2584
- // client finishes initializing), which would spawn the provider subprocess
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
- await client.initialize();
2644
- return client;
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
- async syncProviderSessions() {
2813
- await this.initialize();
2814
- for (const [provider, client] of this.clients) {
2815
- try {
2816
- let activeClient = client;
2817
- let result;
2818
- try {
2819
- result = await activeClient.listSessions();
2820
- }
2821
- catch (error) {
2822
- const recovered = await this.recycleProviderClient(provider, error);
2823
- if (!recovered)
2824
- throw error;
2825
- activeClient = recovered;
2826
- result = await activeClient.listSessions();
2827
- }
2828
- // Codex's thread/list carries no live status and only knows threads
2829
- // THIS app-server owns — so an externally-run `codex` shows idle. Read
2830
- // running state off disk once per sync (CPU-frugal: stats all rollouts,
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
- catch (error) {
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}: ${error instanceof Error ? error.message : String(error)}\n`);
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 enabled = Boolean(client);
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 = enabled && protocolSupportsImages(protocol, advertised);
3382
+ const supportsImages = Boolean(client) && protocolSupportsImages(protocol, advertised);
3157
3383
  const isClaudeFallback = protocol === "claude-stream-json";
3158
- const supportsPermission = enabled && !isClaudeFallback;
3159
- const supportsReasoningEffort = enabled && !isClaudeFallback;
3160
- const supportsSessionList = enabled && (isAcp ? Boolean(advertised?.listSession) : !isClaudeFallback);
3161
- const supportsSessionLoad = enabled && (isAcp ? Boolean(advertised?.loadSession) : !isClaudeFallback);
3162
- const supportsCancel = enabled && (isAcp ? Boolean(advertised?.cancel) : true);
3163
- const supportsFork = enabled && (isAcp
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 = enabled && (isAcp
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.timelines.values()].flat();
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,