@linkshell/host 0.4.68 → 0.4.70

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.
Files changed (82) hide show
  1. package/dist/host/src/drivers/acp/antigravity.d.ts +13 -0
  2. package/dist/host/src/drivers/acp/antigravity.js +13 -0
  3. package/dist/host/src/drivers/acp/antigravity.js.map +1 -1
  4. package/dist/host/src/drivers/acp/driver.d.ts +23 -2
  5. package/dist/host/src/drivers/acp/driver.js +89 -5
  6. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  7. package/dist/host/src/drivers/acp/mapper.d.ts +2 -0
  8. package/dist/host/src/drivers/acp/mapper.js +6 -1
  9. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  10. package/dist/host/src/drivers/claude/app-sessions.d.ts +58 -0
  11. package/dist/host/src/drivers/claude/app-sessions.js +183 -0
  12. package/dist/host/src/drivers/claude/app-sessions.js.map +1 -0
  13. package/dist/host/src/drivers/claude/driver.d.ts +37 -1
  14. package/dist/host/src/drivers/claude/driver.js +199 -9
  15. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  16. package/dist/host/src/drivers/claude/holders.d.ts +3 -1
  17. package/dist/host/src/drivers/claude/holders.js +4 -0
  18. package/dist/host/src/drivers/claude/holders.js.map +1 -1
  19. package/dist/host/src/drivers/claude/routing.d.ts +19 -0
  20. package/dist/host/src/drivers/claude/routing.js +145 -0
  21. package/dist/host/src/drivers/claude/routing.js.map +1 -0
  22. package/dist/host/src/drivers/claude/transcript.d.ts +10 -0
  23. package/dist/host/src/drivers/claude/transcript.js +97 -22
  24. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  25. package/dist/host/src/drivers/codex/driver.d.ts +45 -2
  26. package/dist/host/src/drivers/codex/driver.js +288 -18
  27. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  28. package/dist/host/src/drivers/codex/mapper.d.ts +41 -7
  29. package/dist/host/src/drivers/codex/mapper.js +167 -26
  30. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  31. package/dist/host/src/drivers/codex/mcp-apps.d.ts +69 -0
  32. package/dist/host/src/drivers/codex/mcp-apps.js +340 -0
  33. package/dist/host/src/drivers/codex/mcp-apps.js.map +1 -0
  34. package/dist/host/src/drivers/codex/settings.d.ts +13 -0
  35. package/dist/host/src/drivers/codex/settings.js +22 -6
  36. package/dist/host/src/drivers/codex/settings.js.map +1 -1
  37. package/dist/host/src/drivers/types.d.ts +82 -1
  38. package/dist/host/src/host.js +2 -1
  39. package/dist/host/src/host.js.map +1 -1
  40. package/dist/host/src/hub.d.ts +48 -1
  41. package/dist/host/src/hub.js +235 -13
  42. package/dist/host/src/hub.js.map +1 -1
  43. package/dist/host/src/rpc/mcp.d.ts +8 -0
  44. package/dist/host/src/rpc/mcp.js +33 -0
  45. package/dist/host/src/rpc/mcp.js.map +1 -0
  46. package/dist/host/src/rpc/server.js +27 -3
  47. package/dist/host/src/rpc/server.js.map +1 -1
  48. package/dist/host/src/side.d.ts +28 -0
  49. package/dist/host/src/side.js +142 -0
  50. package/dist/host/src/side.js.map +1 -0
  51. package/dist/host/src/store.d.ts +7 -0
  52. package/dist/host/src/store.js +27 -3
  53. package/dist/host/src/store.js.map +1 -1
  54. package/dist/host/src/terminals.d.ts +6 -0
  55. package/dist/host/src/terminals.js +29 -2
  56. package/dist/host/src/terminals.js.map +1 -1
  57. package/dist/host/src/usage.d.ts +5 -0
  58. package/dist/host/src/usage.js +10 -2
  59. package/dist/host/src/usage.js.map +1 -1
  60. package/dist/host/tsconfig.tsbuildinfo +1 -1
  61. package/dist/wire/src/envelopes.d.ts +53 -0
  62. package/dist/wire/src/envelopes.js +251 -0
  63. package/dist/wire/src/envelopes.js.map +1 -0
  64. package/dist/wire/src/index.d.ts +3 -0
  65. package/dist/wire/src/index.js +3 -0
  66. package/dist/wire/src/index.js.map +1 -1
  67. package/dist/wire/src/model.d.ts +54 -0
  68. package/dist/wire/src/model.js +28 -0
  69. package/dist/wire/src/model.js.map +1 -1
  70. package/dist/wire/src/rpc-mcp.d.ts +199 -0
  71. package/dist/wire/src/rpc-mcp.js +78 -0
  72. package/dist/wire/src/rpc-mcp.js.map +1 -0
  73. package/dist/wire/src/rpc.d.ts +823 -6
  74. package/dist/wire/src/rpc.js +64 -1
  75. package/dist/wire/src/rpc.js.map +1 -1
  76. package/dist/wire/src/side.d.ts +83 -0
  77. package/dist/wire/src/side.js +26 -0
  78. package/dist/wire/src/side.js.map +1 -0
  79. package/dist/wire/src/updates.d.ts +211 -0
  80. package/dist/wire/src/updates.js +14 -1
  81. package/dist/wire/src/updates.js.map +1 -1
  82. package/package.json +3 -3
@@ -1,7 +1,8 @@
1
1
  import { type GoalChange, type SessionGoal, type ContentBlock, type QuestionAnswer } from "@linkshell/wire";
2
2
  import type { AgentAuth } from "@linkshell/wire";
3
- import type { AgentDriver, DiscoveredSession, DriverHost, DriverStatus, ForkOptions, HistoryItem, LaunchSpec } from "../types.js";
3
+ import type { AgentDriver, DiscoveredSession, DriverHost, DriverStatus, ForkOptions, HistoryItem, LaunchSpec, SideOutput, SideRequest } from "../types.js";
4
4
  import { type DriverBehavior } from "../behavior.js";
5
+ import { CodexMcpApps } from "./mcp-apps.js";
5
6
  export interface CodexDriverOptions {
6
7
  socketPath: string;
7
8
  command?: string;
@@ -56,6 +57,7 @@ export declare class CodexDriver implements AgentDriver {
56
57
  images: boolean;
57
58
  models: boolean;
58
59
  modes: boolean;
60
+ side: boolean;
59
61
  };
60
62
  private host?;
61
63
  private server?;
@@ -71,7 +73,13 @@ export declare class CodexDriver implements AgentDriver {
71
73
  /** Recent tool titles by item, so a file-change approval can name its files. */
72
74
  private readonly toolTitles;
73
75
  private readonly settings;
76
+ /** What a device chose for each thread; kept in the host's state so a restart before the next turn doesn't lose it. */
74
77
  private readonly overrides;
78
+ /** Side conversations (`sideAsk`), by `<parent>\n<sideId>`, and their threads. */
79
+ private readonly sides;
80
+ private readonly sideThreads;
81
+ /** Turns that run under the full access a device chose, started with it or chosen while they ran. */
82
+ private readonly fullAccessTurns;
75
83
  /** Sub-agent thread → the parent thread and the spawnAgent call its work nests under. */
76
84
  private readonly children;
77
85
  /** Threads Codex reported as sub-agents (parentThreadId set); never shown as sessions. */
@@ -114,6 +122,7 @@ export declare class CodexDriver implements AgentDriver {
114
122
  private readonly confirmStartMs;
115
123
  private readonly sharedSocketPath?;
116
124
  private readonly desktopBusPath?;
125
+ readonly mcpApps: CodexMcpApps;
117
126
  constructor(options: CodexDriverOptions);
118
127
  status(): DriverStatus;
119
128
  start(host: DriverHost): Promise<DriverStatus>;
@@ -225,8 +234,40 @@ export declare class CodexDriver implements AgentDriver {
225
234
  private send;
226
235
  goal(nativeId: string, change: GoalChange): Promise<SessionGoal | null>;
227
236
  private announceGoal;
228
- /** Model, reasoning effort and permissions apply from the next turn on. */
237
+ /** Model, reasoning effort and permissions apply from the next turn on (full access at once, see autoApproval). */
229
238
  setConfig(nativeId: string, optionId: string, value: string): Promise<void>;
239
+ private overridesOf;
240
+ private setOverrides;
241
+ /**
242
+ * With full access chosen, what Codex still asks in the thread (the turn
243
+ * under way started with the old approvals) is approved here, as it would
244
+ * not have been asked from the next turn on. Questions still go to the user.
245
+ */
246
+ private autoApproval;
247
+ /**
248
+ * Codex's `/side`: an ephemeral fork of the thread (never written to disk),
249
+ * read-only, told where the inherited history ends. Each question is a turn
250
+ * in it; what it writes goes to `out`, not into the session.
251
+ */
252
+ sideAsk(nativeId: string, request: SideRequest, out: SideOutput, signal: AbortSignal): Promise<void>;
253
+ sideClose(nativeId: string, sideId: string): Promise<void>;
254
+ private openSide;
255
+ /** Their server went away, and an ephemeral thread with it: the next question opens a new one. */
256
+ private loseSides;
257
+ private sideUpdate;
258
+ private markFullAccess;
259
+ private approvePending;
260
+ /** From the app-server the driver keeps running: its model list (cached) and the configuration a thread started in `cwd` reads. */
261
+ listModels(cwd?: string): Promise<{
262
+ models: {
263
+ value: string;
264
+ name: string;
265
+ description?: string;
266
+ }[];
267
+ current?: string;
268
+ }>;
269
+ /** The model config.toml (with the project's own layers, for `cwd`) gives a new thread; undefined: Codex's default. */
270
+ private configuredModel;
230
271
  private loadModels;
231
272
  private announceConfig;
232
273
  private isNews;
@@ -256,6 +297,8 @@ export declare class CodexDriver implements AgentDriver {
256
297
  /** Remembers which spawnAgent call each new sub-agent thread belongs to. */
257
298
  private noteSpawns;
258
299
  private onServerRequest;
300
+ /** A tool call's page, found only once its tool had been looked up: to the card, wherever the call's thread shows. */
301
+ private lateDetail;
259
302
  private rememberTitle;
260
303
  private rpc;
261
304
  /** A request about a thread, to the server it is loaded in. */
@@ -10,9 +10,10 @@ import { behaviorCapabilities } from "../behavior.js";
10
10
  import { CodexAppServer, CodexSharedServer, findCodex, sharedServerSocket } from "./app-server.js";
11
11
  import { APPROVAL_METHODS, QUESTION_METHODS, mapApprovalRequest, mapQuestionRequest, mapNotification, threadStateOf, threadToDiscovered, spawnedThreads, subagentHistory, threadToHistory, toCodexInput, toolStart, turnUnderWay, } from "./mapper.js";
12
12
  import { nestHistory, nestUnder } from "../nesting.js";
13
- import { configOptions, effective, settingsFrom, settingsFromTurnContext, turnOverrides } from "./settings.js";
13
+ import { configOptions, effective, newThreadModels, settingsFrom, settingsFromTurnContext, turnOverrides } from "./settings.js";
14
14
  import { COMMANDS, INIT_PROMPT, commandOf } from "./commands.js";
15
15
  import { DesktopUnconfirmed, desktopBusSocket, interruptThroughDesktop, startThroughDesktop, steerThroughDesktop, updateSettingsThroughDesktop } from "./desktop-ipc.js";
16
+ import { CodexMcpApps } from "./mcp-apps.js";
16
17
  /** Codex's refusal to load a thread that another process has loaded. */
17
18
  function heldElsewhere(error) {
18
19
  return error instanceof Error && /already has an active writer/i.test(error.message);
@@ -88,6 +89,17 @@ const HELD_SEND = "会话开在电脑的另一个 Codex 里,这条消息没能
88
89
  const HELD_STOP = "这一轮跑在电脑的 Codex 桌面 App(或 IDE 插件)里,这次没能从手机停下它。请在电脑上停止;一直这样的话,重启一下 Codex 桌面 App。";
89
90
  const HELD_NOW = "这一轮跑在电脑的 Codex 桌面 App(或 IDE 插件)里,这次没能从手机插进去。它结束后这条消息会自动发送;想现在就插入,在电脑上对这条排队消息点 Steer。一直这样的话,重启一下 Codex 桌面 App。";
90
91
  const HELD_COMMAND = "会话开在电脑的另一个 Codex 里,要在那边关掉它之后才能从手机做。";
92
+ /**
93
+ * What a side conversation's thread is told, as Codex's own `/side` tells it:
94
+ * the history it was forked with is for reference, and it changes nothing.
95
+ */
96
+ const SIDE_INSTRUCTIONS = [
97
+ "You are in a side conversation forked from the user's main thread, not in the main thread itself.",
98
+ "The history before the side-conversation boundary is reference only: do not continue, carry out or complete any task, plan, tool call, approval or edit from it. Only messages after the boundary are instructions for you.",
99
+ "Answer questions and explore without changing anything: you may read and search files and run commands that change nothing, but do not modify files, git state, configuration or the workspace, do not ask for broader permissions, and do not start or talk to sub-agents.",
100
+ "Keep answers short; the user is reading them on a phone.",
101
+ ].join("\n\n");
102
+ const SIDE_BOUNDARY = "Side conversation boundary. Everything before this point is the main thread's history, for reference only. What the user asks from here on is the side conversation.";
91
103
  /**
92
104
  * Codex, tier multi_client: LinkShell and the desktop TUI (`codex --remote`)
93
105
  * are both clients of one app-server, so every thread is live on both sides.
@@ -118,6 +130,7 @@ export class CodexDriver {
118
130
  images: true,
119
131
  models: true,
120
132
  modes: true,
133
+ side: true,
121
134
  ...behaviorCapabilities(this.behavior),
122
135
  };
123
136
  host;
@@ -134,7 +147,13 @@ export class CodexDriver {
134
147
  /** Recent tool titles by item, so a file-change approval can name its files. */
135
148
  toolTitles = new Map();
136
149
  settings = new Map();
150
+ /** What a device chose for each thread; kept in the host's state so a restart before the next turn doesn't lose it. */
137
151
  overrides = new Map();
152
+ /** Side conversations (`sideAsk`), by `<parent>\n<sideId>`, and their threads. */
153
+ sides = new Map();
154
+ sideThreads = new Map();
155
+ /** Turns that run under the full access a device chose, started with it or chosen while they ran. */
156
+ fullAccessTurns = new Set();
138
157
  /** Sub-agent thread → the parent thread and the spawnAgent call its work nests under. */
139
158
  children = new Map();
140
159
  /** Threads Codex reported as sub-agents (parentThreadId set); never shown as sessions. */
@@ -177,6 +196,12 @@ export class CodexDriver {
177
196
  confirmStartMs;
178
197
  sharedSocketPath;
179
198
  desktopBusPath;
199
+ mcpApps = new CodexMcpApps({
200
+ request: (method, params, threadId) => (threadId ? this.rpcFor(threadId, method, params) : this.rpc(method, params)),
201
+ reach: (threadId) => (this.observed.has(threadId) ? "held" : this.attached.has(threadId) ? "joined" : "unloaded"),
202
+ lateDetail: (threadId, toolCallId, detail) => this.lateDetail(threadId, toolCallId, detail),
203
+ log: (message) => this.host?.log(message),
204
+ });
180
205
  constructor(options) {
181
206
  this.options = options;
182
207
  this.restartDelay = options.restartDelayMs ?? 1000;
@@ -217,6 +242,7 @@ export class CodexDriver {
217
242
  return parseCodexLoginStatus(await runStatusCommand(this.command, ["login", "status"], this.options.env));
218
243
  }
219
244
  async stop() {
245
+ this.mcpApps.stop();
220
246
  if (this.attentionTimer)
221
247
  clearInterval(this.attentionTimer);
222
248
  this.attentionTimer = undefined;
@@ -374,7 +400,7 @@ export class CodexDriver {
374
400
  // Archives remain readable; opening history must not unarchive or resume the agent.
375
401
  const { thread } = await this.rpc("thread/read", { threadId: nativeId, includeTurns: true });
376
402
  this.host?.sessionSeen(this.id, { ...threadToDiscovered(thread), archived: true, state: "idle", asyncQuestions: [] });
377
- const history = await this.withSubAgents(nativeId, thread, threadToHistory(thread));
403
+ const history = await this.withSubAgents(nativeId, thread, threadToHistory(thread, this.mcpApps.lookup(nativeId)));
378
404
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "idle" });
379
405
  return history;
380
406
  }
@@ -389,7 +415,7 @@ export class CodexDriver {
389
415
  return this.observe(nativeId);
390
416
  }
391
417
  this.stopObserving(nativeId);
392
- const history = await this.withSubAgents(nativeId, joined.thread, threadToHistory(joined.thread));
418
+ const history = await this.withSubAgents(nativeId, joined.thread, threadToHistory(joined.thread, this.mcpApps.lookup(nativeId)));
393
419
  this.goLive(nativeId, joined);
394
420
  this.reportHeld(nativeId, false);
395
421
  void this.announceConfig(nativeId);
@@ -476,7 +502,7 @@ export class CodexDriver {
476
502
  const read = new Set((thread.turns ?? []).flatMap((turn) => turn.items.map((item) => item.id)));
477
503
  for (const item of underWay) {
478
504
  // A command still running: its card. (Its output so far was missed, so the whole of it comes when it finishes.)
479
- const start = read.has(item.id) ? undefined : toolStart(item);
505
+ const start = read.has(item.id) ? undefined : toolStart(item, this.mcpApps.lookup(threadId));
480
506
  if (start)
481
507
  this.host?.update(this.id, threadId, start);
482
508
  }
@@ -499,7 +525,7 @@ export class CodexDriver {
499
525
  /** Reads the thread from disk, and keeps reading while a device has it open. */
500
526
  async observe(threadId) {
501
527
  const { thread } = await this.rpc("thread/read", { threadId, includeTurns: true });
502
- const history = await this.withSubAgents(threadId, thread, threadToHistory(thread));
528
+ const history = await this.withSubAgents(threadId, thread, threadToHistory(thread, this.mcpApps.lookup(threadId)));
503
529
  this.stopObserving(threadId);
504
530
  const timer = setInterval(() => void this.refresh(threadId), this.observeIntervalMs);
505
531
  timer.unref?.();
@@ -548,7 +574,7 @@ export class CodexDriver {
548
574
  watch.reading = true;
549
575
  try {
550
576
  const { thread } = await this.rpc("thread/read", { threadId, includeTurns: true });
551
- const history = await this.withSubAgents(threadId, thread, threadToHistory(thread));
577
+ const history = await this.withSubAgents(threadId, thread, threadToHistory(thread, this.mcpApps.lookup(threadId)));
552
578
  // Joined, or let go, while it was being read.
553
579
  if (this.observed.get(threadId) !== watch)
554
580
  return;
@@ -642,7 +668,7 @@ export class CodexDriver {
642
668
  throw error;
643
669
  }
644
670
  this.stopObserving(threadId);
645
- for (const item of await this.withSubAgents(threadId, joined.thread, threadToHistory(joined.thread))) {
671
+ for (const item of await this.withSubAgents(threadId, joined.thread, threadToHistory(joined.thread, this.mcpApps.lookup(threadId)))) {
646
672
  if (!watch.seen.has(item.itemId))
647
673
  this.report(threadId, item);
648
674
  }
@@ -786,7 +812,7 @@ export class CodexDriver {
786
812
  }
787
813
  desktopMessage(threadId, input, clientMessageId) {
788
814
  const current = this.settings.get(threadId) ?? {};
789
- const overrides = this.overrides.get(threadId) ?? {};
815
+ const overrides = this.overridesOf(threadId);
790
816
  return {
791
817
  input,
792
818
  text: textOf(input),
@@ -1019,7 +1045,7 @@ export class CodexDriver {
1019
1045
  const result = await this.rpcFor(threadId, "thread/read", { threadId: child, includeTurns: true });
1020
1046
  known.path = result.thread.path ?? undefined;
1021
1047
  known.stamp = stamp;
1022
- return nestHistory(subagentHistory(result.thread), callId);
1048
+ return nestHistory(subagentHistory(result.thread, this.mcpApps.lookup(child)), callId);
1023
1049
  }
1024
1050
  catch {
1025
1051
  // A just-started child may not have written its rollout yet.
@@ -1088,7 +1114,7 @@ export class CodexDriver {
1088
1114
  const lines = [];
1089
1115
  if (command.name === "status") {
1090
1116
  const { thread } = await this.rpcFor(nativeId, "thread/read", { threadId: nativeId, includeTurns: false });
1091
- const current = effective(this.settings.get(nativeId) ?? {}, this.overrides.get(nativeId) ?? {}, await this.loadModels());
1117
+ const current = effective(this.settings.get(nativeId) ?? {}, this.overridesOf(nativeId), await this.loadModels());
1092
1118
  const running = this.observed.get(nativeId)?.running ?? Boolean(this.stateOf(nativeId).activeTurnId);
1093
1119
  lines.push(`目录:${thread.cwd}`, `模型:${current.model ?? "默认"}`, `推理强度:${current.effort ?? "默认"}`, `状态:${running ? "运行中" : "空闲"}`);
1094
1120
  }
@@ -1184,13 +1210,15 @@ export class CodexDriver {
1184
1210
  this.host?.log(`[codex] steer failed, starting a new turn: ${error instanceof Error ? error.message : String(error)}`);
1185
1211
  }
1186
1212
  }
1187
- const overrides = this.overrides.get(nativeId) ?? {};
1188
- await this.rpcFor(nativeId, "turn/start", {
1213
+ const overrides = this.overridesOf(nativeId);
1214
+ const started = await this.rpcFor(nativeId, "turn/start", {
1189
1215
  threadId: nativeId,
1190
1216
  clientUserMessageId: clientMessageId,
1191
1217
  input,
1192
1218
  ...turnOverrides(overrides, overrides.plan === undefined ? {} : effective(this.settings.get(nativeId) ?? {}, overrides, await this.loadModels())),
1193
1219
  });
1220
+ if (overrides.permissions === "full-access" && typeof started?.turn?.id === "string")
1221
+ this.markFullAccess(started.turn.id);
1194
1222
  return "started";
1195
1223
  }
1196
1224
  async goal(nativeId, change) {
@@ -1228,12 +1256,12 @@ export class CodexDriver {
1228
1256
  this.host?.log(`[codex] goal unavailable: ${error instanceof Error ? error.message : String(error)}`);
1229
1257
  }
1230
1258
  }
1231
- /** Model, reasoning effort and permissions apply from the next turn on. */
1259
+ /** Model, reasoning effort and permissions apply from the next turn on (full access at once, see autoApproval). */
1232
1260
  async setConfig(nativeId, optionId, value) {
1233
1261
  const models = await this.loadModels();
1234
1262
  const base = this.settings.get(nativeId) ?? {};
1235
- const current = effective(base, this.overrides.get(nativeId) ?? {}, models);
1236
- const next = { ...this.overrides.get(nativeId) };
1263
+ const current = effective(base, this.overridesOf(nativeId), models);
1264
+ const next = { ...this.overridesOf(nativeId) };
1237
1265
  if (optionId === "model") {
1238
1266
  if (!models.some((m) => m.model === value) && value !== current.model) {
1239
1267
  throw RpcError.app("invalid_params", `unknown model ${value}`);
@@ -1263,16 +1291,217 @@ export class CodexDriver {
1263
1291
  else {
1264
1292
  throw RpcError.app("not_supported", `Codex has no setting ${optionId}`);
1265
1293
  }
1266
- this.overrides.set(nativeId, next);
1294
+ this.setOverrides(nativeId, next);
1295
+ // Codex fixes approvals when a turn starts, so the turn under way would go on asking.
1296
+ const running = this.stateOf(nativeId).activeTurnId ?? this.observed.get(nativeId)?.turnId;
1297
+ if (optionId === "permissions" && running) {
1298
+ if (value === "full-access") {
1299
+ this.markFullAccess(running);
1300
+ this.approvePending(nativeId);
1301
+ }
1302
+ else
1303
+ this.fullAccessTurns.delete(running);
1304
+ }
1267
1305
  if (this.observed.has(nativeId) && this.desktopBusPath) {
1268
1306
  const updated = effective(base, next, models);
1269
1307
  const threadSettings = turnOverrides(next, { model: updated.model, effort: updated.effort });
1270
1308
  await updateSettingsThroughDesktop(this.desktopBusPath, nativeId, threadSettings, optionId === "permissions" ? (this.observed.get(nativeId)?.turnId ?? null) : null).catch((error) => {
1271
1309
  this.host?.log(`[codex] desktop settings sync for ${nativeId}: ${error instanceof Error ? error.message : String(error)}`);
1310
+ // The window keeps its own settings until then; saying nothing would look like the choice took.
1311
+ this.host?.update(this.id, nativeId, {
1312
+ sessionUpdate: "ls_notice",
1313
+ level: "warning",
1314
+ title: "Codex 桌面 App 没有接受这项设置",
1315
+ detail: "电脑上那个窗口仍按原来的设置运行;从这里发下一条消息时会按新设置开始。",
1316
+ });
1272
1317
  });
1273
1318
  }
1274
1319
  await this.announceConfig(nativeId);
1275
1320
  }
1321
+ overridesOf(threadId) {
1322
+ let overrides = this.overrides.get(threadId);
1323
+ if (!overrides) {
1324
+ try {
1325
+ overrides = JSON.parse(this.host?.state(this.id, threadId).get("overrides") ?? "{}");
1326
+ }
1327
+ catch {
1328
+ overrides = {};
1329
+ }
1330
+ this.overrides.set(threadId, overrides);
1331
+ }
1332
+ return overrides;
1333
+ }
1334
+ setOverrides(threadId, next) {
1335
+ this.overrides.set(threadId, next);
1336
+ this.host?.state(this.id, threadId).set("overrides", JSON.stringify(next));
1337
+ }
1338
+ /**
1339
+ * With full access chosen, what Codex still asks in the thread (the turn
1340
+ * under way started with the old approvals) is approved here, as it would
1341
+ * not have been asked from the next turn on. Questions still go to the user.
1342
+ */
1343
+ autoApproval(method, request, turnId) {
1344
+ // Only such turns: a turn someone else started keeps the approvals it was started with.
1345
+ if (!APPROVAL_METHODS.has(method) || !turnId || !this.fullAccessTurns.has(turnId))
1346
+ return undefined;
1347
+ if (this.overridesOf(request.threadId).permissions !== "full-access")
1348
+ return undefined;
1349
+ const options = request.update.options;
1350
+ return (options.find((option) => option.kind === "allow_always") ?? options.find((option) => option.kind === "allow_once"))?.optionId;
1351
+ }
1352
+ // ── side conversations (/side) ────────────────────────────────────
1353
+ /**
1354
+ * Codex's `/side`: an ephemeral fork of the thread (never written to disk),
1355
+ * read-only, told where the inherited history ends. Each question is a turn
1356
+ * in it; what it writes goes to `out`, not into the session.
1357
+ */
1358
+ async sideAsk(nativeId, request, out, signal) {
1359
+ if (signal.aborted)
1360
+ return;
1361
+ const key = `${nativeId}\n${request.sideId}`;
1362
+ const side = this.sides.get(key) ?? (await this.openSide(key, nativeId));
1363
+ if (signal.aborted)
1364
+ return;
1365
+ const done = new Promise((resolve, reject) => {
1366
+ side.out = out;
1367
+ side.finish = (error) => (error ? reject(error) : resolve());
1368
+ });
1369
+ const stop = () => {
1370
+ side.finish?.();
1371
+ if (side.turnId)
1372
+ void this.rpcFor(side.threadId, "turn/interrupt", { threadId: side.threadId, turnId: side.turnId }).catch(() => { });
1373
+ };
1374
+ signal.addEventListener("abort", stop, { once: true });
1375
+ try {
1376
+ const started = await this.rpcFor(side.threadId, "turn/start", {
1377
+ threadId: side.threadId,
1378
+ input: toCodexInput([{ type: "text", text: request.question }]),
1379
+ });
1380
+ if (typeof started?.turn?.id === "string")
1381
+ side.turnId = started.turn.id;
1382
+ if (signal.aborted)
1383
+ stop();
1384
+ await done;
1385
+ }
1386
+ catch (error) {
1387
+ // The fork may be gone (Codex restarted it away): the next question opens a new one.
1388
+ if (!side.turnId) {
1389
+ this.sides.delete(side.key);
1390
+ this.sideThreads.delete(side.threadId);
1391
+ }
1392
+ throw error;
1393
+ }
1394
+ finally {
1395
+ signal.removeEventListener("abort", stop);
1396
+ side.out = undefined;
1397
+ side.finish = undefined;
1398
+ side.turnId = undefined;
1399
+ side.messageId = undefined;
1400
+ }
1401
+ }
1402
+ async sideClose(nativeId, sideId) {
1403
+ const side = this.sides.get(`${nativeId}\n${sideId}`);
1404
+ if (!side)
1405
+ return;
1406
+ this.sides.delete(side.key);
1407
+ this.sideThreads.delete(side.threadId);
1408
+ side.finish?.();
1409
+ // Nobody subscribed, an ephemeral thread is unloaded: nothing of it is left.
1410
+ await this.rpcFor(side.threadId, "thread/unsubscribe", { threadId: side.threadId }).catch(() => { });
1411
+ this.inShared.delete(side.threadId);
1412
+ this.threads.delete(side.threadId);
1413
+ }
1414
+ async openSide(key, parent) {
1415
+ let forked;
1416
+ try {
1417
+ forked = await this.rpcFor(parent, "thread/fork", {
1418
+ threadId: parent,
1419
+ ephemeral: true,
1420
+ excludeTurns: true,
1421
+ approvalPolicy: "never",
1422
+ sandbox: "read-only",
1423
+ developerInstructions: SIDE_INSTRUCTIONS,
1424
+ });
1425
+ }
1426
+ catch (error) {
1427
+ if (error instanceof Error && /at least one turn|no rollout/i.test(error.message))
1428
+ throw RpcError.app("not_ready", "这个 Codex 会话还没有任何消息:先发一条再问");
1429
+ throw error;
1430
+ }
1431
+ const threadId = typeof forked.thread?.id === "string" ? forked.thread.id : undefined;
1432
+ if (!threadId)
1433
+ throw RpcError.app("agent_error", "Codex 没有开出旁支对话");
1434
+ const side = { key, threadId };
1435
+ this.sides.set(key, side);
1436
+ this.sideThreads.set(threadId, side);
1437
+ // The fork lives where its parent is loaded.
1438
+ if (this.inShared.has(parent))
1439
+ this.inShared.add(threadId);
1440
+ await this.rpcFor(threadId, "thread/inject_items", {
1441
+ threadId,
1442
+ items: [{ type: "message", role: "developer", content: [{ type: "input_text", text: SIDE_BOUNDARY }] }],
1443
+ }).catch((error) => this.host?.log(`[codex] side boundary not injected: ${error instanceof Error ? error.message : String(error)}`));
1444
+ return side;
1445
+ }
1446
+ /** Their server went away, and an ephemeral thread with it: the next question opens a new one. */
1447
+ loseSides(from) {
1448
+ for (const side of [...this.sides.values()]) {
1449
+ if (this.inShared.has(side.threadId) !== (from === "shared"))
1450
+ continue;
1451
+ this.sides.delete(side.key);
1452
+ this.sideThreads.delete(side.threadId);
1453
+ this.inShared.delete(side.threadId);
1454
+ side.finish?.(new Error("Codex 重启了,这个回答没有完成:再问一次即可,之前的旁支内容 Codex 已不记得"));
1455
+ }
1456
+ }
1457
+ sideUpdate(side, update) {
1458
+ if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
1459
+ if (side.messageId && side.messageId !== update.messageId)
1460
+ side.out?.text("\n\n");
1461
+ side.messageId = update.messageId;
1462
+ side.out?.text(update.content.text);
1463
+ }
1464
+ else if (update.sessionUpdate === "tool_call")
1465
+ side.out?.activity(update.title);
1466
+ else if (update.sessionUpdate === "ls_error")
1467
+ side.finish?.(new Error(update.message));
1468
+ else if (update.sessionUpdate === "ls_turn" && update.state === "ended" && (!side.turnId || !update.turnId || update.turnId === side.turnId))
1469
+ side.finish?.();
1470
+ }
1471
+ markFullAccess(turnId) {
1472
+ this.fullAccessTurns.add(turnId);
1473
+ if (this.fullAccessTurns.size > 200)
1474
+ this.fullAccessTurns.delete(this.fullAccessTurns.values().next().value);
1475
+ }
1476
+ approvePending(threadId) {
1477
+ for (const [requestId, pending] of this.approvals) {
1478
+ if (pending.threadId !== threadId)
1479
+ continue;
1480
+ const optionId = this.autoApproval(pending.method, pending.request, pending.turnId);
1481
+ if (!optionId)
1482
+ continue;
1483
+ this.approvals.delete(requestId);
1484
+ pending.answer(pending.request.respond(optionId));
1485
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_permission_resolved", requestId, optionId });
1486
+ }
1487
+ }
1488
+ /** From the app-server the driver keeps running: its model list (cached) and the configuration a thread started in `cwd` reads. */
1489
+ async listModels(cwd) {
1490
+ const [models, configured] = await Promise.all([this.loadModels(), this.configuredModel(cwd)]);
1491
+ return newThreadModels(models, configured);
1492
+ }
1493
+ /** The model config.toml (with the project's own layers, for `cwd`) gives a new thread; undefined: Codex's default. */
1494
+ async configuredModel(cwd) {
1495
+ try {
1496
+ const result = await this.rpc("config/read", { includeLayers: false, ...(cwd ? { cwd } : {}) });
1497
+ return result.config?.model ?? undefined;
1498
+ }
1499
+ catch (error) {
1500
+ // Older app-servers have no config/read: the list's default is the best guess.
1501
+ this.host?.log(`[codex] config/read failed: ${error instanceof Error ? error.message : String(error)}`);
1502
+ return undefined;
1503
+ }
1504
+ }
1276
1505
  loadModels() {
1277
1506
  this.models ??= this.rpc("model/list", { includeHidden: false })
1278
1507
  .then((result) => result.data)
@@ -1285,7 +1514,7 @@ export class CodexDriver {
1285
1514
  }
1286
1515
  async announceConfig(nativeId) {
1287
1516
  const models = await this.loadModels();
1288
- const options = configOptions(this.settings.get(nativeId) ?? {}, this.overrides.get(nativeId) ?? {}, models);
1517
+ const options = configOptions(this.settings.get(nativeId) ?? {}, this.overridesOf(nativeId), models);
1289
1518
  if (options.length > 0 && this.isNews(`config:${nativeId}`, options))
1290
1519
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_config", options });
1291
1520
  }
@@ -1485,6 +1714,7 @@ export class CodexDriver {
1485
1714
  return;
1486
1715
  this.host?.log(`[codex] ${reason}`);
1487
1716
  this.abandonApprovals("own");
1717
+ this.loseSides("own");
1488
1718
  for (const threadId of [...this.attached]) {
1489
1719
  // (What is joined in Codex's background server is still there.)
1490
1720
  if (this.inShared.has(threadId))
@@ -1560,6 +1790,9 @@ export class CodexDriver {
1560
1790
  const thread = params?.thread;
1561
1791
  if (!thread?.id)
1562
1792
  return;
1793
+ // A side conversation (ours, or one a TUI opened with /side): gone when it closes, never a session.
1794
+ if (thread.ephemeral)
1795
+ return;
1563
1796
  if (thread.parentThreadId) {
1564
1797
  // A sub-agent: its work shows inside the parent session, not as a session of its own.
1565
1798
  this.subThreads.add(thread.id);
@@ -1583,6 +1816,12 @@ export class CodexDriver {
1583
1816
  return;
1584
1817
  }
1585
1818
  const threadId = params?.threadId;
1819
+ const side = typeof threadId === "string" ? this.sideThreads.get(threadId) : undefined;
1820
+ if (side) {
1821
+ for (const mapped of mapNotification(method, params, (id) => this.stateOf(id)))
1822
+ this.sideUpdate(side, mapped.update);
1823
+ return;
1824
+ }
1586
1825
  const held = typeof threadId === "string" ? this.joining.get(threadId) : undefined;
1587
1826
  if (held) {
1588
1827
  held.push({ method, params, from });
@@ -1603,7 +1842,7 @@ export class CodexDriver {
1603
1842
  if (frame)
1604
1843
  this.host?.preview?.(this.id, threadId, frame);
1605
1844
  }
1606
- for (const mapped of mapNotification(method, params, (id) => this.stateOf(id))) {
1845
+ for (const mapped of mapNotification(method, params, (id) => this.stateOf(id), (id) => this.mcpApps.lookup(id))) {
1607
1846
  const update = from === "shared" && mapped.update.sessionUpdate === "ls_permission_resolved"
1608
1847
  ? { ...mapped.update, requestId: sharedRequestId(mapped.update.requestId) }
1609
1848
  : mapped.update;
@@ -1660,6 +1899,14 @@ export class CodexDriver {
1660
1899
  return ABANDON;
1661
1900
  const requestId = from === "shared" ? sharedRequestId(String(id)) : String(id);
1662
1901
  const mapped = QUESTION_METHODS.has(method) ? mapQuestionRequest(method, params, requestId) : mapApprovalRequest(method, params, requestId);
1902
+ const side = mapped && this.sideThreads.get(mapped.threadId);
1903
+ if (side && mapped) {
1904
+ // A side conversation changes nothing (it runs read-only and is told so): what it would need asking for is declined.
1905
+ const options = mapped.update.options;
1906
+ const decline = (options.find((option) => option.kind === "reject_once") ?? options[0]).optionId;
1907
+ side.out?.activity(`已拒绝:${mapped.update.title}(旁支对话只读)`);
1908
+ return mapped.answer ? mapped.answer([]) : mapped.respond(decline);
1909
+ }
1663
1910
  // A sub-agent asking for permission asks in its parent session.
1664
1911
  const request = mapped && this.children.has(mapped.threadId) ? { ...mapped, threadId: this.children.get(mapped.threadId).threadId } : mapped;
1665
1912
  if (!request || !this.attached.has(request.threadId))
@@ -1668,9 +1915,18 @@ export class CodexDriver {
1668
1915
  const toolTitle = method === "item/fileChange/requestApproval" && request.update.toolCallId && this.toolTitles.get(request.update.toolCallId);
1669
1916
  if (toolTitle)
1670
1917
  request.update = { ...request.update, title: toolTitle };
1918
+ const asking = params?.turnId;
1919
+ const turnId = mapped && mapped.threadId !== request.threadId ? this.stateOf(request.threadId).activeTurnId : typeof asking === "string" ? asking : undefined;
1920
+ const approved = this.autoApproval(method, request, turnId);
1921
+ if (approved) {
1922
+ this.host?.log(`[codex] ${method} in ${request.threadId} approved under the full access a device chose`);
1923
+ return request.respond(approved);
1924
+ }
1671
1925
  return new Promise((resolve) => {
1672
1926
  this.approvals.set(requestId, {
1673
1927
  threadId: request.threadId,
1928
+ method,
1929
+ turnId,
1674
1930
  request,
1675
1931
  answer: resolve,
1676
1932
  abandon: () => resolve(ABANDON),
@@ -1678,6 +1934,19 @@ export class CodexDriver {
1678
1934
  this.host?.update(this.id, request.threadId, request.update);
1679
1935
  });
1680
1936
  }
1937
+ /** A tool call's page, found only once its tool had been looked up: to the card, wherever the call's thread shows. */
1938
+ lateDetail(threadId, toolCallId, detail) {
1939
+ const update = { sessionUpdate: "tool_call_update", toolCallId, detail };
1940
+ const parent = this.children.get(threadId);
1941
+ if (parent) {
1942
+ const nested = nestUnder(update, parent.toolCallId);
1943
+ if (nested && this.attached.has(parent.threadId))
1944
+ this.host?.update(this.id, parent.threadId, nested);
1945
+ }
1946
+ else if (this.attached.has(threadId)) {
1947
+ this.host?.update(this.id, threadId, update);
1948
+ }
1949
+ }
1681
1950
  rememberTitle(toolCallId, title) {
1682
1951
  this.toolTitles.delete(toolCallId);
1683
1952
  this.toolTitles.set(toolCallId, title);
@@ -1712,6 +1981,7 @@ export class CodexDriver {
1712
1981
  return;
1713
1982
  this.host?.log("[codex] lost Codex's background server");
1714
1983
  this.abandonApprovals("shared");
1984
+ this.loseSides("shared");
1715
1985
  const joined = [...this.inShared].filter((threadId) => this.attached.delete(threadId));
1716
1986
  this.inShared.clear();
1717
1987
  for (const threadId of joined) {