linkshell-cli 0.5.38 → 0.5.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync, watch } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, relative } from "node:path";
4
4
  import { createEnvelope, parseTypedPayload, } from "@linkshell/protocol";
@@ -1445,6 +1445,244 @@ function codexSessionConfig(sessionId) {
1445
1445
  }
1446
1446
  return cfg;
1447
1447
  }
1448
+ const CODEX_ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
1449
+ function walkCodexRollouts(root, onFile) {
1450
+ if (!existsSync(root))
1451
+ return;
1452
+ const walk = (dir) => {
1453
+ let entries;
1454
+ try {
1455
+ entries = readdirSync(dir);
1456
+ }
1457
+ catch {
1458
+ return;
1459
+ }
1460
+ for (const name of entries) {
1461
+ const path = join(dir, name);
1462
+ let stat;
1463
+ try {
1464
+ stat = statSync(path);
1465
+ }
1466
+ catch {
1467
+ continue;
1468
+ }
1469
+ if (stat.isDirectory()) {
1470
+ walk(path);
1471
+ continue;
1472
+ }
1473
+ const match = CODEX_ROLLOUT_NAME.exec(name);
1474
+ if (match?.[1])
1475
+ onFile(path, match[1], stat.mtimeMs);
1476
+ }
1477
+ };
1478
+ walk(root);
1479
+ }
1480
+ function findCodexRolloutPath(sessionId, root = join(homedir(), ".codex", "sessions")) {
1481
+ const byName = [];
1482
+ const recent = [];
1483
+ walkCodexRollouts(root, (path, filenameId, mt) => {
1484
+ recent.push({ path, mt });
1485
+ if (filenameId === sessionId)
1486
+ byName.push({ path, mt });
1487
+ });
1488
+ if (byName.length > 0) {
1489
+ byName.sort((a, b) => b.mt - a.mt);
1490
+ return byName[0]?.path;
1491
+ }
1492
+ recent.sort((a, b) => b.mt - a.mt);
1493
+ for (const candidate of recent.slice(0, 80)) {
1494
+ try {
1495
+ const fdText = readFileSync(candidate.path, "utf8");
1496
+ const first = fdText.slice(0, fdText.indexOf("\n") >= 0 ? fdText.indexOf("\n") : fdText.length);
1497
+ const metaId = firstString(asRecord(asRecord(JSON.parse(first))?.payload), ["session_id", "id"]);
1498
+ if (metaId === sessionId)
1499
+ return candidate.path;
1500
+ }
1501
+ catch { /* skip */ }
1502
+ }
1503
+ return undefined;
1504
+ }
1505
+ function findLatestCodexRolloutForCwd(cwd, root = join(homedir(), ".codex", "sessions")) {
1506
+ const wanted = cwd.replace(/\/+$/, "");
1507
+ const candidates = [];
1508
+ walkCodexRollouts(root, (path, filenameId, mt) => {
1509
+ candidates.push({ path, sessionId: filenameId, mt });
1510
+ });
1511
+ candidates.sort((a, b) => b.mt - a.mt);
1512
+ for (const candidate of candidates.slice(0, 40)) {
1513
+ try {
1514
+ const text = readFileSync(candidate.path, "utf8");
1515
+ const first = text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length);
1516
+ const payload = asRecord(asRecord(JSON.parse(first))?.payload);
1517
+ const fileCwd = firstString(payload, ["cwd"])?.replace(/\/+$/, "");
1518
+ const metaId = firstString(payload, ["session_id", "id"]) ?? candidate.sessionId;
1519
+ if (fileCwd === wanted)
1520
+ return { path: candidate.path, sessionId: metaId };
1521
+ }
1522
+ catch {
1523
+ continue;
1524
+ }
1525
+ }
1526
+ return undefined;
1527
+ }
1528
+ function publicWorkspaceRoots(roots) {
1529
+ if (!Array.isArray(roots))
1530
+ return undefined;
1531
+ const cleaned = roots
1532
+ .filter((root) => typeof root === "string" && root.length > 0)
1533
+ .filter((root) => !root.includes("/.codex/visualizations/"));
1534
+ return cleaned.length > 0 ? cleaned : undefined;
1535
+ }
1536
+ function isInjectedCodexUserText(text) {
1537
+ const trimmed = text.trim();
1538
+ return (trimmed.startsWith("<app-context>") ||
1539
+ trimmed.startsWith("<recommended_plugins>") ||
1540
+ trimmed.startsWith("<skills_instructions>") ||
1541
+ trimmed.startsWith("# AGENTS.md") ||
1542
+ trimmed.startsWith("You are `/root`"));
1543
+ }
1544
+ function textsFromCodexContent(content) {
1545
+ if (typeof content === "string" && content.trim())
1546
+ return [content];
1547
+ if (!Array.isArray(content))
1548
+ return [];
1549
+ const texts = [];
1550
+ for (const block of content) {
1551
+ const rec = asRecord(block);
1552
+ const text = typeof rec?.text === "string" ? rec.text : undefined;
1553
+ if (text?.trim())
1554
+ texts.push(text);
1555
+ }
1556
+ return texts;
1557
+ }
1558
+ /** Incremental Codex jsonl parser so live TUI/desktop sessions can tail the file. */
1559
+ export class CodexRolloutParser {
1560
+ toolNames = new Map();
1561
+ index = 0;
1562
+ workspaceRoots;
1563
+ sessionCwd;
1564
+ consume(text, conversationId) {
1565
+ const items = [];
1566
+ for (const line of text.split(/\r?\n/)) {
1567
+ if (!line.trim())
1568
+ continue;
1569
+ const next = this.consumeLine(line, conversationId);
1570
+ if (next)
1571
+ items.push(next);
1572
+ }
1573
+ return items;
1574
+ }
1575
+ consumeLine(line, conversationId) {
1576
+ let record;
1577
+ try {
1578
+ record = asRecord(JSON.parse(line));
1579
+ }
1580
+ catch {
1581
+ return undefined;
1582
+ }
1583
+ if (!record)
1584
+ return undefined;
1585
+ const payload = asRecord(record.payload) ?? record;
1586
+ const recordType = firstString(record, ["type"]);
1587
+ const payloadType = firstString(payload, ["type"]);
1588
+ const roots = publicWorkspaceRoots(payload.workspace_roots ?? payload.workspaceRoots);
1589
+ if (roots)
1590
+ this.workspaceRoots = roots;
1591
+ const cwd = firstString(payload, ["cwd"]);
1592
+ if (cwd)
1593
+ this.sessionCwd = cwd;
1594
+ const createdAt = parseTimestamp(record.timestamp ?? payload.timestamp) ?? Date.now();
1595
+ if (recordType === "response_item" && payloadType === "message") {
1596
+ const roleRaw = firstString(payload, ["role"]);
1597
+ if (roleRaw !== "user" && roleRaw !== "assistant")
1598
+ return undefined;
1599
+ const texts = textsFromCodexContent(payload.content).filter((text) => roleRaw === "assistant" || !isInjectedCodexUserText(text));
1600
+ if (texts.length === 0)
1601
+ return undefined;
1602
+ const text = texts.join("\n\n");
1603
+ this.index += 1;
1604
+ return {
1605
+ id: firstString(payload, ["id"]) ?? id(`codex-msg-${this.index}`),
1606
+ conversationId,
1607
+ type: "message",
1608
+ kind: "chat",
1609
+ role: roleRaw,
1610
+ text,
1611
+ content: [{ type: "text", text }],
1612
+ createdAt,
1613
+ updatedAt: createdAt,
1614
+ };
1615
+ }
1616
+ if (recordType === "response_item" && payloadType === "reasoning") {
1617
+ const texts = textsFromCodexContent(payload.content ?? payload.summary);
1618
+ if (texts.length === 0)
1619
+ return undefined;
1620
+ this.index += 1;
1621
+ return {
1622
+ id: firstString(payload, ["id"]) ?? id(`codex-think-${this.index}`),
1623
+ conversationId,
1624
+ type: "status",
1625
+ kind: "thinking",
1626
+ text: texts.join("\n"),
1627
+ createdAt,
1628
+ updatedAt: createdAt,
1629
+ };
1630
+ }
1631
+ if (recordType === "response_item" && (payloadType === "custom_tool_call" || payloadType === "function_call")) {
1632
+ const callId = firstString(payload, ["call_id", "id"]) ?? id(`codex-tool-${this.index}`);
1633
+ const name = firstString(payload, ["name"]) ?? "tool";
1634
+ this.toolNames.set(callId, name);
1635
+ const input = firstString(payload, ["input", "arguments"]) ?? stringify(payload.input ?? payload.arguments ?? "");
1636
+ this.index += 1;
1637
+ return {
1638
+ id: `tool:${callId}`,
1639
+ conversationId,
1640
+ type: "tool_call",
1641
+ kind: name === "exec" || name.includes("command") ? "command_execution" : "tool_activity",
1642
+ itemId: callId,
1643
+ toolCall: {
1644
+ id: callId,
1645
+ name,
1646
+ input,
1647
+ createdAt,
1648
+ status: "running",
1649
+ },
1650
+ createdAt,
1651
+ updatedAt: createdAt,
1652
+ };
1653
+ }
1654
+ if (recordType === "response_item" && (payloadType === "custom_tool_call_output" || payloadType === "function_call_output")) {
1655
+ const callId = firstString(payload, ["call_id", "id"]);
1656
+ if (!callId)
1657
+ return undefined;
1658
+ const outputTexts = textsFromCodexContent(payload.output);
1659
+ const output = outputTexts.join("\n") || stringify(payload.output ?? "");
1660
+ this.index += 1;
1661
+ return {
1662
+ id: `tool:${callId}`,
1663
+ conversationId,
1664
+ type: "tool_call",
1665
+ kind: "tool_activity",
1666
+ itemId: callId,
1667
+ toolCall: {
1668
+ id: callId,
1669
+ name: this.toolNames.get(callId) ?? "tool",
1670
+ output,
1671
+ createdAt,
1672
+ status: "completed",
1673
+ },
1674
+ createdAt,
1675
+ updatedAt: createdAt,
1676
+ };
1677
+ }
1678
+ return undefined;
1679
+ }
1680
+ }
1681
+ /** Parse a Codex on-disk rollout into timeline items. App-server thread/read
1682
+ * only sees threads it owns; desktop/TUI sessions live in jsonl. */
1683
+ export function timelineItemsFromCodexRolloutText(text, conversationId) {
1684
+ return new CodexRolloutParser().consume(text, conversationId).slice(-MAX_TIMELINE_ITEMS);
1685
+ }
1448
1686
  function parseRemoteSessions(value) {
1449
1687
  const raw = asRecord(value);
1450
1688
  const sessionsValue = Array.isArray(value) ? value :
@@ -1938,6 +2176,10 @@ export class AgentWorkspaceProxy {
1938
2176
  const conversation = this.conversations.get(payload.conversationId);
1939
2177
  if (!conversation)
1940
2178
  break;
2179
+ if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
2180
+ this.rejectAgentAction(conversation, `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 无法停止它,请在那边操作。`, conversation.status);
2181
+ break;
2182
+ }
1941
2183
  const turnId = this.currentTurnIds.get(payload.conversationId);
1942
2184
  if (this.protocolForProvider(conversation.provider) === "codex-app-server" && !turnId) {
1943
2185
  this.rejectAgentAction(conversation, "无法停止 Codex:当前运行 turn 尚未同步,请重新打开对话后再试。", conversation.status);
@@ -2002,8 +2244,13 @@ export class AgentWorkspaceProxy {
2002
2244
  fsSyncRunning = false;
2003
2245
  lastPushedConversationSignature;
2004
2246
  lastHydrateAt = new Map();
2005
- static FS_SYNC_THROTTLE_MS = 1500;
2006
- static EXTERNAL_HYDRATE_THROTTLE_MS = 2000;
2247
+ /** Session ids this LinkShell process created via app-server `newSession`. */
2248
+ ownedCodexSessionIds = new Set();
2249
+ /** Conversations attached to an external Codex TUI/desktop rollout (same file, not a copy). */
2250
+ attachedExternalCodex = new Set();
2251
+ codexFollow = new Map();
2252
+ static FS_SYNC_THROTTLE_MS = 400;
2253
+ static EXTERNAL_HYDRATE_THROTTLE_MS = 400;
2007
2254
  startSessionWatchers() {
2008
2255
  const roots = [
2009
2256
  join(homedir(), ".claude", "projects"),
@@ -2083,7 +2330,9 @@ export class AgentWorkspaceProxy {
2083
2330
  this.lastHydrateAt.set(conversation.id, now);
2084
2331
  try {
2085
2332
  await this.hydrateConversationFromProvider(conversation, undefined, { rememberActiveTurn: false });
2086
- this.sendSnapshot(conversation.id);
2333
+ if (this.followCodexRolloutDisk(conversation, true) > 0) {
2334
+ this.sendSnapshot(conversation.id);
2335
+ }
2087
2336
  }
2088
2337
  catch (error) {
2089
2338
  if (this.input.verbose) {
@@ -2211,11 +2460,18 @@ export class AgentWorkspaceProxy {
2211
2460
  : `${snapshot.provider}:${snapshot.pid}`;
2212
2461
  seenKeys.add(key);
2213
2462
  this.processMissCounts.set(key, 0);
2214
- const existingId = snapshot.sessionId
2215
- ? this.conversationByAgentSessionId.get(snapshot.sessionId)
2463
+ let sessionId = snapshot.sessionId;
2464
+ if (!sessionId && snapshot.provider === "codex") {
2465
+ const matched = findLatestCodexRolloutForCwd(snapshot.cwd ?? this.input.cwd);
2466
+ if (matched)
2467
+ sessionId = matched.sessionId;
2468
+ }
2469
+ const existingId = sessionId
2470
+ ? this.conversationByAgentSessionId.get(sessionId)
2216
2471
  : undefined;
2217
- const liveId = liveConversationId(snapshot);
2218
- const conversationId = existingId ?? liveId;
2472
+ const liveId = liveConversationId({ ...snapshot, sessionId });
2473
+ const conversationId = existingId
2474
+ ?? (sessionId ? makeAgentV2RemoteConversationId(snapshot.provider, sessionId) : liveId);
2219
2475
  const existing = this.conversations.get(conversationId);
2220
2476
  const cwd = snapshot.cwd ?? existing?.cwd ?? this.input.cwd;
2221
2477
  const driving = this.currentTurnIds.has(conversationId);
@@ -2230,10 +2486,12 @@ export class AgentWorkspaceProxy {
2230
2486
  existing.cwd = cwd;
2231
2487
  if (!existing.title)
2232
2488
  existing.title = agentProviderLabel(snapshot.provider);
2233
- if (snapshot.sessionId && !existing.agentSessionId) {
2234
- existing.agentSessionId = snapshot.sessionId;
2235
- this.conversationByAgentSessionId.set(snapshot.sessionId, existing.id);
2489
+ if (sessionId && !existing.agentSessionId) {
2490
+ existing.agentSessionId = sessionId;
2491
+ this.conversationByAgentSessionId.set(sessionId, existing.id);
2236
2492
  }
2493
+ this.attachedExternalCodex.add(existing.id);
2494
+ existing.control = "attached";
2237
2495
  if (changed) {
2238
2496
  listChanged = true;
2239
2497
  this.emitConversation(existing);
@@ -2243,7 +2501,7 @@ export class AgentWorkspaceProxy {
2243
2501
  const now = Date.now();
2244
2502
  const conversation = {
2245
2503
  id: conversationId,
2246
- agentSessionId: snapshot.sessionId,
2504
+ agentSessionId: sessionId,
2247
2505
  provider: snapshot.provider,
2248
2506
  cwd,
2249
2507
  title: agentProviderLabel(snapshot.provider),
@@ -2252,10 +2510,12 @@ export class AgentWorkspaceProxy {
2252
2510
  lastMessagePreview: "本机终端中的 Agent 进程",
2253
2511
  lastActivityAt: now,
2254
2512
  createdAt: now,
2513
+ control: "attached",
2255
2514
  };
2256
2515
  this.conversations.set(conversation.id, conversation);
2257
- if (snapshot.sessionId)
2258
- this.conversationByAgentSessionId.set(snapshot.sessionId, conversation.id);
2516
+ if (sessionId)
2517
+ this.conversationByAgentSessionId.set(sessionId, conversation.id);
2518
+ this.attachedExternalCodex.add(conversation.id);
2259
2519
  this.emitConversation(conversation);
2260
2520
  listChanged = true;
2261
2521
  }
@@ -2627,6 +2887,8 @@ export class AgentWorkspaceProxy {
2627
2887
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
2628
2888
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
2629
2889
  usage,
2890
+ group: existing?.group ?? (provider === "claude" ? titleFromCwd(cwd) : existing?.group),
2891
+ workspaceRoots: existing?.workspaceRoots,
2630
2892
  };
2631
2893
  this.conversations.set(conversation.id, conversation);
2632
2894
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
@@ -2762,7 +3024,13 @@ export class AgentWorkspaceProxy {
2762
3024
  if (turnCount > 0)
2763
3025
  this.historyCursors.set(conversation.id, String(turnCount));
2764
3026
  }
2765
- const hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
3027
+ let hydratedItems = timelineItemsFromProviderThread(source, conversation.id);
3028
+ if (conversation.provider === "codex") {
3029
+ this.followCodexRolloutDisk(conversation, false);
3030
+ }
3031
+ if (hydratedItems.length === 0) {
3032
+ hydratedItems = this.timelines.get(conversation.id) ?? [];
3033
+ }
2766
3034
  if (hydratedItems.length === 0)
2767
3035
  return;
2768
3036
  const existing = this.timelines.get(conversation.id) ?? [];
@@ -2780,9 +3048,97 @@ export class AgentWorkspaceProxy {
2780
3048
  const lastPreview = [...nextItems].reverse()
2781
3049
  .map((item) => previewText(previewFromTimelineItem(item) ?? ""))
2782
3050
  .find(Boolean);
2783
- if (lastPreview && !conversation.lastMessagePreview) {
3051
+ if (lastPreview)
2784
3052
  conversation.lastMessagePreview = lastPreview;
3053
+ }
3054
+ /** Tail a Codex desktop/TUI rollout. `emit` sends agent.v2.event for new lines
3055
+ * so a phone/web client sees tokens as the jsonl grows, not only on open. */
3056
+ followCodexRolloutDisk(conversation, emit) {
3057
+ if (conversation.provider !== "codex")
3058
+ return 0;
3059
+ let path = conversation.agentSessionId
3060
+ ? findCodexRolloutPath(conversation.agentSessionId)
3061
+ : undefined;
3062
+ if (!path && conversation.cwd) {
3063
+ const matched = findLatestCodexRolloutForCwd(conversation.cwd);
3064
+ if (matched) {
3065
+ path = matched.path;
3066
+ if (!conversation.agentSessionId) {
3067
+ conversation.agentSessionId = matched.sessionId;
3068
+ this.conversationByAgentSessionId.set(matched.sessionId, conversation.id);
3069
+ }
3070
+ }
2785
3071
  }
3072
+ if (!path)
3073
+ return 0;
3074
+ let follow = this.codexFollow.get(conversation.id);
3075
+ if (!follow || follow.path !== path) {
3076
+ follow = { path, offset: 0, carry: "", parser: new CodexRolloutParser() };
3077
+ this.codexFollow.set(conversation.id, follow);
3078
+ }
3079
+ let stat;
3080
+ try {
3081
+ stat = statSync(path);
3082
+ }
3083
+ catch {
3084
+ return 0;
3085
+ }
3086
+ if (stat.size < follow.offset) {
3087
+ follow.offset = 0;
3088
+ follow.carry = "";
3089
+ follow.parser = new CodexRolloutParser();
3090
+ }
3091
+ if (stat.size === follow.offset && !follow.carry)
3092
+ return 0;
3093
+ const length = stat.size - follow.offset;
3094
+ const buf = Buffer.alloc(length);
3095
+ const fd = openSync(path, "r");
3096
+ try {
3097
+ readSync(fd, buf, 0, length, follow.offset);
3098
+ }
3099
+ finally {
3100
+ closeSync(fd);
3101
+ }
3102
+ follow.offset = stat.size;
3103
+ const combined = follow.carry + buf.toString("utf8");
3104
+ const lastNl = combined.lastIndexOf("\n");
3105
+ if (lastNl < 0) {
3106
+ follow.carry = combined;
3107
+ return 0;
3108
+ }
3109
+ const complete = combined.slice(0, lastNl + 1);
3110
+ follow.carry = combined.slice(lastNl + 1);
3111
+ const items = follow.parser.consume(complete, conversation.id);
3112
+ if (items.length === 0)
3113
+ return 0;
3114
+ if (emit) {
3115
+ for (const item of items)
3116
+ this.upsertItem(conversation.id, item);
3117
+ }
3118
+ else {
3119
+ const existing = this.timelines.get(conversation.id) ?? [];
3120
+ const merged = new Map(existing.map((item) => [item.id, item]));
3121
+ for (const item of items)
3122
+ merged.set(item.id, item);
3123
+ const nextItems = [...merged.values()]
3124
+ .sort((a, b) => a.createdAt - b.createdAt)
3125
+ .slice(-MAX_TIMELINE_ITEMS);
3126
+ this.timelines.set(conversation.id, nextItems);
3127
+ for (const item of nextItems)
3128
+ this.rememberItemConversationId(conversation.id, item);
3129
+ }
3130
+ const timeline = this.timelines.get(conversation.id) ?? [];
3131
+ const lastPreview = [...timeline].reverse()
3132
+ .map((item) => previewText(previewFromTimelineItem(item) ?? ""))
3133
+ .find(Boolean);
3134
+ if (lastPreview)
3135
+ conversation.lastMessagePreview = lastPreview;
3136
+ conversation.lastActivityAt = Date.now();
3137
+ if (follow.parser.workspaceRoots?.length)
3138
+ conversation.workspaceRoots = follow.parser.workspaceRoots;
3139
+ if (follow.parser.sessionCwd && !conversation.cwd)
3140
+ conversation.cwd = follow.parser.sessionCwd;
3141
+ return items.length;
2786
3142
  }
2787
3143
  sendCapabilities() {
2788
3144
  const catalog = [...new Set([
@@ -3009,61 +3365,95 @@ export class AgentWorkspaceProxy {
3009
3365
  });
3010
3366
  }
3011
3367
  }
3012
- if (existingConversation && existingConversation.status !== "error" && existingConversation.agentSessionId) {
3013
- const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
3014
- if (payload.conversationId &&
3015
- payload.conversationId === requestedCanonicalId &&
3016
- existingConversation.id !== payload.conversationId) {
3017
- existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
3368
+ if (existingConversation &&
3369
+ !existingConversation.agentSessionId &&
3370
+ provider === "codex") {
3371
+ const matched = findLatestCodexRolloutForCwd(existingConversation.cwd ?? cwd);
3372
+ if (matched) {
3373
+ existingConversation.agentSessionId = matched.sessionId;
3374
+ this.conversationByAgentSessionId.set(matched.sessionId, existingConversation.id);
3018
3375
  }
3019
- // Hydrate the transcript from disk when we have no timeline yet, UNLESS
3020
- // LinkShell is itself driving a live turn for this conversation — in that
3021
- // case the event stream is the source of truth and re-hydrating would be
3022
- // redundant/racy. We key on currentTurnIds (LinkShell-driving), NOT on
3023
- // status === "running": an EXTERNALLY-driven session also reports
3024
- // "running" now (live-status feature) but has no event stream, so gating
3025
- // on status would skip hydration and leave its timeline stuck empty
3026
- // ("加载对话记录…" forever).
3027
- if ((this.timelines.get(existingConversation.id) ?? []).length === 0 &&
3376
+ }
3377
+ if (existingConversation && existingConversation.status !== "error") {
3378
+ const owned = Boolean(existingConversation.agentSessionId &&
3379
+ this.ownedCodexSessionIds.has(existingConversation.agentSessionId));
3380
+ // External Codex (desktop/TUI) is the same session on disk. Attach by
3381
+ // tailing that rollout — never loadSession/newSession (those mint another thread).
3382
+ if (provider === "codex" &&
3383
+ !owned &&
3384
+ this.attachedExternalCodex.has(existingConversation.id) &&
3028
3385
  !this.currentTurnIds.has(existingConversation.id)) {
3029
- try {
3030
- const existingAgentSessionId = existingConversation.agentSessionId;
3031
- let result;
3386
+ existingConversation.control = "attached";
3387
+ this.followCodexRolloutDisk(existingConversation, false);
3388
+ this.activeConversationId = existingConversation.id;
3389
+ this.input.send(createEnvelope({
3390
+ type: "agent.v2.conversation.opened",
3391
+ sessionId: this.input.sessionId,
3392
+ payload: {
3393
+ conversation: existingConversation,
3394
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
3395
+ requestedConversationId: payload.conversationId,
3396
+ },
3397
+ }));
3398
+ return existingConversation;
3399
+ }
3400
+ if (existingConversation.agentSessionId) {
3401
+ const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
3402
+ if (payload.conversationId &&
3403
+ payload.conversationId === requestedCanonicalId &&
3404
+ existingConversation.id !== payload.conversationId) {
3405
+ existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
3406
+ }
3407
+ // Hydrate the transcript from disk when we have no timeline yet, UNLESS
3408
+ // LinkShell is itself driving a live turn for this conversation — in that
3409
+ // case the event stream is the source of truth and re-hydrating would be
3410
+ // redundant/racy. We key on currentTurnIds (LinkShell-driving), NOT on
3411
+ // status === "running": an EXTERNALLY-driven session also reports
3412
+ // "running" now (live-status feature) but has no event stream, so gating
3413
+ // on status would skip hydration and leave its timeline stuck empty
3414
+ // ("加载对话记录…" forever).
3415
+ if ((this.timelines.get(existingConversation.id) ?? []).length === 0 &&
3416
+ !this.currentTurnIds.has(existingConversation.id)) {
3032
3417
  try {
3033
- result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
3418
+ const existingAgentSessionId = existingConversation.agentSessionId;
3419
+ let result;
3420
+ try {
3421
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
3422
+ }
3423
+ catch (error) {
3424
+ const recovered = await this.recycleProviderClient(provider, error);
3425
+ if (!recovered)
3426
+ throw error;
3427
+ client = recovered;
3428
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
3429
+ }
3430
+ const nextAgentSessionId = this.extractSessionId(result);
3431
+ if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
3432
+ this.conversationByAgentSessionId.delete(existingAgentSessionId);
3433
+ existingConversation.agentSessionId = nextAgentSessionId;
3434
+ this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
3435
+ }
3436
+ await this.hydrateConversationFromProvider(existingConversation, result);
3034
3437
  }
3035
3438
  catch (error) {
3036
- const recovered = await this.recycleProviderClient(provider, error);
3037
- if (!recovered)
3038
- throw error;
3039
- client = recovered;
3040
- result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
3041
- }
3042
- const nextAgentSessionId = this.extractSessionId(result);
3043
- if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
3044
- this.conversationByAgentSessionId.delete(existingAgentSessionId);
3045
- existingConversation.agentSessionId = nextAgentSessionId;
3046
- this.conversationByAgentSessionId.set(nextAgentSessionId, existingConversation.id);
3047
- }
3048
- await this.hydrateConversationFromProvider(existingConversation, result);
3049
- }
3050
- catch (error) {
3051
- if (this.input.verbose) {
3052
- process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
3439
+ if (this.input.verbose) {
3440
+ process.stderr.write(`[agent:v2] resume failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
3441
+ }
3442
+ await this.hydrateConversationFromProvider(existingConversation, undefined);
3053
3443
  }
3054
3444
  }
3445
+ this.activeConversationId = existingConversation.id;
3446
+ this.input.send(createEnvelope({
3447
+ type: "agent.v2.conversation.opened",
3448
+ sessionId: this.input.sessionId,
3449
+ payload: {
3450
+ conversation: existingConversation,
3451
+ snapshot: this.timelines.get(existingConversation.id) ?? [],
3452
+ requestedConversationId: payload.conversationId,
3453
+ },
3454
+ }));
3455
+ return existingConversation;
3055
3456
  }
3056
- this.activeConversationId = existingConversation.id;
3057
- this.input.send(createEnvelope({
3058
- type: "agent.v2.conversation.opened",
3059
- sessionId: this.input.sessionId,
3060
- payload: {
3061
- conversation: existingConversation,
3062
- snapshot: this.timelines.get(existingConversation.id) ?? [],
3063
- requestedConversationId: payload.conversationId,
3064
- },
3065
- }));
3066
- return existingConversation;
3067
3457
  }
3068
3458
  try {
3069
3459
  let result;
@@ -3082,6 +3472,8 @@ export class AgentWorkspaceProxy {
3082
3472
  : await client.newSession({ cwd });
3083
3473
  }
3084
3474
  agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
3475
+ if (provider === "codex")
3476
+ this.ownedCodexSessionIds.add(agentSessionId);
3085
3477
  const now = Date.now();
3086
3478
  const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
3087
3479
  const conversation = {
@@ -3091,6 +3483,7 @@ export class AgentWorkspaceProxy {
3091
3483
  provider,
3092
3484
  cwd,
3093
3485
  title: payload.title ?? existingConversation?.title ?? titleFromCwd(cwd),
3486
+ control: "owned",
3094
3487
  model: payload.model ?? existingConversation?.model,
3095
3488
  reasoningEffort: payload.reasoningEffort ?? existingConversation?.reasoningEffort,
3096
3489
  permissionMode: payload.permissionMode ?? existingConversation?.permissionMode,
@@ -3200,9 +3593,13 @@ export class AgentWorkspaceProxy {
3200
3593
  await this.openConversation({ conversationId: payload.conversationId });
3201
3594
  if (!conversation)
3202
3595
  return;
3596
+ if (conversation.control === "attached" || this.attachedExternalCodex.has(conversation.id)) {
3597
+ this.rejectAgentAction(conversation, `${providerLabel(conversation.provider)} 这场会话在本机终端/桌面里运行。Web 只旁观同一份记录,停止和插话请在那边操作。`, conversation.status);
3598
+ return;
3599
+ }
3203
3600
  if (!conversation.agentSessionId) {
3204
3601
  this.rejectAgentAction(conversation, isLiveConversationId(conversation.id) || !isProtocolAgentProvider(conversation.provider)
3205
- ? `${providerLabel(conversation.provider)} 正在本机终端中运行。远程对话协议尚未接入,请打开终端面板继续。`
3602
+ ? `${providerLabel(conversation.provider)} 正在本机终端中运行。请打开终端面板继续。`
3206
3603
  : "Agent session 尚未就绪,消息没有发送。请重新打开对话后再试。");
3207
3604
  return;
3208
3605
  }