linkshell-cli 0.10.0 → 0.10.2

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,13 +1,43 @@
1
+ import { statSync } from "node:fs";
1
2
  import { ABANDON, RpcError } from "@linkshell/wire";
2
3
  import { parseCodexLoginStatus, runStatusCommand } from "../auth.js";
3
- import { CodexAppServer, detectCodex } from "./app-server.js";
4
- import { APPROVAL_METHODS, QUESTION_METHODS, mapApprovalRequest, mapQuestionRequest, mapNotification, threadStateOf, threadToDiscovered, spawnedThreads, threadToHistory, toCodexInput, } from "./mapper.js";
4
+ import { CodexAppServer, CodexSharedServer, detectCodex, sharedServerSocket } from "./app-server.js";
5
+ import { APPROVAL_METHODS, QUESTION_METHODS, mapApprovalRequest, mapQuestionRequest, mapNotification, threadStateOf, threadToDiscovered, spawnedThreads, threadToHistory, toCodexInput, toolStart, turnUnderWay, } from "./mapper.js";
5
6
  import { nestHistory, nestUnder } from "../nesting.js";
6
7
  import { configOptions, effective, settingsFrom, turnOverrides } from "./settings.js";
7
8
  import { COMMANDS, INIT_PROMPT, commandOf } from "./commands.js";
9
+ import { desktopBusSocket, interruptThroughDesktop } from "./desktop-ipc.js";
10
+ /** Codex's refusal to load a thread that another process has loaded. */
11
+ function heldElsewhere(error) {
12
+ return error instanceof Error && /already has an active writer/i.test(error.message);
13
+ }
14
+ function fileStamp(path) {
15
+ try {
16
+ const stat = statSync(path);
17
+ return `${stat.mtimeMs}:${stat.size}`;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ /** A request id from Codex's background server: both servers count theirs from the same numbers. */
24
+ const sharedRequestId = (id) => `shared-${id}`;
25
+ // (Shown as one small line in the conversation: short enough to read at a glance.)
26
+ const HELD_NOTICE = { title: "会话开在电脑的另一个 Codex 里 · 消息会排队,等这一轮结束后发送" };
27
+ const HELD_SEND = "会话开在电脑的另一个 Codex 里,这条消息没能排进它的队列。在那边关掉这个会话后再发。";
28
+ const HELD_STOP = "这一轮跑在电脑的另一个 Codex(桌面 App 或 IDE 插件)里,手机停不了它:请在电脑上停止。";
29
+ const HELD_NOW = "这一轮跑在电脑的另一个 Codex(桌面 App 或 IDE 插件)里,手机插不进去。它结束后这条消息会自动发送;想现在就插入,在电脑上对这条排队消息点 Steer。";
30
+ const HELD_COMMAND = "会话开在电脑的另一个 Codex 里,要在那边关掉它之后才能从手机做。";
8
31
  /**
9
32
  * Codex, tier multi_client: LinkShell and the desktop TUI (`codex --remote`)
10
33
  * are both clients of one app-server, so every thread is live on both sides.
34
+ *
35
+ * Codex lets one process have a thread loaded at a time. A thread loaded in
36
+ * Codex's own background server (a plain `codex` in a terminal) is joined
37
+ * there, as one more client. A thread in a process that takes no other
38
+ * clients (the desktop app, an IDE extension) is read from disk as that
39
+ * process writes it; a message for it goes into Codex's own queue, which that
40
+ * process starts when it is idle; and its turn is stopped by asking the app.
11
41
  */
12
42
  export class CodexDriver {
13
43
  options;
@@ -48,9 +78,35 @@ export class CodexDriver {
48
78
  restartTimer;
49
79
  restartDelay;
50
80
  stopped = false;
81
+ shared;
82
+ /** Threads loaded in Codex's background server: announced by it, or joined there. */
83
+ inShared = new Set();
84
+ observed = new Map();
85
+ takingUp = new Map();
86
+ /** Messages in Codex's own queue for threads another process holds: it starts them when it is idle or its turn ends. */
87
+ waiting = new Map();
88
+ /** Notifications for a thread being joined, held until its history has been read. */
89
+ joining = new Map();
90
+ /** Threads a device has open. */
91
+ watching = new Set();
92
+ /** Threads another client of this host's app-server opened (`linkshell codex`): followed for as long as the host runs. */
93
+ kept = new Set();
94
+ /** Threads started here that have had no turn: nothing on disk yet to come back to. */
95
+ fresh = new Set();
96
+ releases = new Map();
97
+ /** What was last announced per thread (settings, commands): a thread joined again says it only if it changed. */
98
+ announced = new Map();
99
+ observeIntervalMs;
100
+ releaseDelayMs;
101
+ sharedSocketPath;
102
+ desktopBusPath;
51
103
  constructor(options) {
52
104
  this.options = options;
53
105
  this.restartDelay = options.restartDelayMs ?? 1000;
106
+ this.desktopBusPath = options.desktopBusPath === false ? undefined : (options.desktopBusPath ?? desktopBusSocket(options.env));
107
+ this.sharedSocketPath = options.sharedSocketPath === false ? undefined : (options.sharedSocketPath ?? sharedServerSocket(options.env));
108
+ this.observeIntervalMs = options.observeIntervalMs ?? 2000;
109
+ this.releaseDelayMs = options.releaseDelayMs ?? 30_000;
54
110
  }
55
111
  status() {
56
112
  return this.current;
@@ -77,21 +133,43 @@ export class CodexDriver {
77
133
  for (const timer of this.followRetries.values())
78
134
  clearTimeout(timer);
79
135
  this.followRetries.clear();
136
+ for (const timer of this.releases.values())
137
+ clearTimeout(timer);
138
+ this.releases.clear();
139
+ for (const threadId of [...this.observed.keys()])
140
+ this.stopObserving(threadId);
80
141
  for (const pending of this.approvals.values())
81
142
  pending.abandon();
82
143
  this.approvals.clear();
144
+ this.shared?.close();
145
+ this.shared = undefined;
83
146
  await this.server?.stop();
84
147
  this.server = undefined;
85
148
  }
86
149
  async listSessions(limit) {
87
150
  const result = await this.rpc("thread/list", { limit, archived: false });
88
- return result.data.map(threadToDiscovered);
151
+ return result.data.map((thread) => {
152
+ const discovered = threadToDiscovered(thread);
153
+ // This host's app-server only knows the state of the threads loaded in it.
154
+ const state = this.stateElsewhere(thread.id);
155
+ return state ? { ...discovered, state } : discovered;
156
+ });
157
+ }
158
+ stateElsewhere(threadId) {
159
+ const watch = this.observed.get(threadId);
160
+ if (watch)
161
+ return watch.running ? "running" : "idle";
162
+ if (!this.inShared.has(threadId) || !this.attached.has(threadId))
163
+ return undefined;
164
+ if (this.hasApprovals(threadId))
165
+ return "waiting";
166
+ return this.stateOf(threadId).activeTurnId ? "running" : "idle";
89
167
  }
90
168
  /** Codex's own fork: a new thread with this one's turns, all or through `lastTurnId`, in `cwd`. */
91
169
  async fork(nativeId, options) {
92
170
  let lastTurnId;
93
171
  if (options.upTo) {
94
- const { thread } = await this.rpc("thread/read", { threadId: nativeId, includeTurns: true });
172
+ const { thread } = await this.rpcFor(nativeId, "thread/read", { threadId: nativeId, includeTurns: true });
95
173
  const turns = thread.turns ?? [];
96
174
  const picked = options.upTo;
97
175
  const holding = turns.find((turn) => turn.items.some((item) => item.id === picked.itemId));
@@ -116,6 +194,7 @@ export class CodexDriver {
116
194
  model: options.model ?? null,
117
195
  }).then((result) => {
118
196
  this.startedHere.add(result.thread.id);
197
+ this.fresh.add(result.thread.id);
119
198
  this.cwds.set(result.thread.id, result.thread.cwd ?? options.cwd);
120
199
  this.settings.set(result.thread.id, settingsFrom(result));
121
200
  return result;
@@ -142,16 +221,9 @@ export class CodexDriver {
142
221
  void this.announceCommands(nativeId);
143
222
  return [];
144
223
  }
145
- let thread;
224
+ let joined;
146
225
  try {
147
- // On a running thread, thread/resume rejoins it; otherwise it loads it from disk.
148
- const resumed = await this.rpc("thread/resume", { threadId: nativeId });
149
- thread = resumed.thread;
150
- this.settings.set(nativeId, settingsFrom(resumed));
151
- if (thread.cwd)
152
- this.cwds.set(nativeId, thread.cwd);
153
- void this.announceConfig(nativeId);
154
- void this.announceCommands(nativeId);
226
+ joined = await this.subscribe(nativeId);
155
227
  }
156
228
  catch (error) {
157
229
  this.attached.delete(nativeId);
@@ -160,12 +232,399 @@ export class CodexDriver {
160
232
  // still be started on it; after that it can be joined.
161
233
  throw RpcError.app("not_ready", "this Codex session has no messages yet");
162
234
  }
235
+ if (!heldElsewhere(error))
236
+ throw error;
237
+ // A Codex process that takes no other clients has it: what it writes can still be read.
238
+ return this.observe(nativeId);
239
+ }
240
+ this.stopObserving(nativeId);
241
+ const history = await this.withSubAgents(nativeId, joined.thread, threadToHistory(joined.thread));
242
+ this.goLive(nativeId, joined);
243
+ this.reportHeld(nativeId, false);
244
+ void this.announceConfig(nativeId);
245
+ void this.announceCommands(nativeId);
246
+ if (this.waiting.has(nativeId))
247
+ void this.syncWaiting(nativeId);
248
+ this.considerRelease(nativeId);
249
+ return history;
250
+ }
251
+ /**
252
+ * Subscribes to a thread where it can be driven: this host's app-server, or
253
+ * Codex's background server when the thread is loaded there. What the
254
+ * server sends from then on is held until `goLive`.
255
+ */
256
+ async subscribe(threadId) {
257
+ this.joining.set(threadId, []);
258
+ try {
259
+ // On a running thread, thread/resume rejoins it; otherwise it loads it from disk.
260
+ const resumed = await this.resume(threadId);
261
+ let thread = resumed.thread;
262
+ const running = thread.turns?.find((turn) => turn.status === "inProgress");
263
+ if (running) {
264
+ // The response numbers a running turn's messages itself (item-1,
265
+ // item-2…): imported like that, they would come a second time under
266
+ // their real ids. Read from disk they have the ids they keep, and
267
+ // only what is finished is there.
268
+ const read = await this.rpcFor(threadId, "thread/read", { threadId, includeTurns: true }).catch(async (error) => {
269
+ await this.rpcFor(threadId, "thread/unsubscribe", { threadId }).catch(() => { });
270
+ throw error;
271
+ });
272
+ thread = read.thread;
273
+ }
274
+ this.settings.set(threadId, settingsFrom(resumed));
275
+ if (thread.cwd)
276
+ this.cwds.set(threadId, thread.cwd);
277
+ return { thread, underWay: running?.items.filter((item) => item.status === "inProgress") ?? [] };
278
+ }
279
+ catch (error) {
280
+ this.joining.delete(threadId);
163
281
  throw error;
164
282
  }
283
+ }
284
+ /** thread/resume where the thread can be loaded: here, or in Codex's background server if it is loaded there. */
285
+ async resume(threadId) {
286
+ const places = this.inShared.has(threadId) ? ["shared", "own"] : ["own", "shared"];
287
+ let refused;
288
+ for (const home of places) {
289
+ if (home === "shared" && !(await this.connectShared()))
290
+ continue;
291
+ try {
292
+ const server = home === "shared" ? this.shared : this.server;
293
+ if (!server)
294
+ throw RpcError.app("agent_unavailable", "Codex is not running");
295
+ const resumed = await server.request("thread/resume", { threadId });
296
+ if (home === "shared")
297
+ this.inShared.add(threadId);
298
+ else
299
+ this.inShared.delete(threadId);
300
+ return resumed;
301
+ }
302
+ catch (error) {
303
+ // Loaded in another process: the other place may be that process. Anything else is the answer.
304
+ if (refused === undefined && !heldElsewhere(error))
305
+ throw error;
306
+ refused ??= error;
307
+ }
308
+ }
309
+ throw refused;
310
+ }
311
+ /** Says where the joined thread stands, then lets through what arrived while it was being read. */
312
+ goLive(threadId, { thread, underWay }) {
313
+ const state = this.stateOf(threadId);
165
314
  const active = thread.turns?.find((turn) => turn.status === "inProgress");
166
- this.stateOf(nativeId).activeTurnId = active?.id;
167
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: threadStateOf(thread.status) });
168
- return this.withSubAgents(nativeId, thread, threadToHistory(thread));
315
+ state.activeTurnId = active?.id;
316
+ state.midTurn = active ? true : undefined;
317
+ state.begun = undefined;
318
+ // A turn already running: the session shows as working, and its end has a start to close.
319
+ if (active)
320
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_turn", state: "started", turnId: active.id });
321
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_status", state: threadStateOf(thread.status) });
322
+ const read = new Set((thread.turns ?? []).flatMap((turn) => turn.items.map((item) => item.id)));
323
+ for (const item of underWay) {
324
+ // A command still running: its card. (Its output so far was missed, so the whole of it comes when it finishes.)
325
+ const start = read.has(item.id) ? undefined : toolStart(item);
326
+ if (start)
327
+ this.host?.update(this.id, threadId, start);
328
+ }
329
+ const held = this.joining.get(threadId) ?? [];
330
+ this.joining.delete(threadId);
331
+ for (const { method, params, from } of held) {
332
+ const record = params;
333
+ const itemId = record?.itemId ?? record?.item?.id;
334
+ // Already in what was read: the item, or the start and end of turns it shows.
335
+ if (typeof itemId === "string" && read.has(itemId))
336
+ continue;
337
+ if (method === "turn/started" && record?.turn?.id === active?.id)
338
+ continue;
339
+ if (method === "turn/completed" && record?.turn?.id !== active?.id)
340
+ continue;
341
+ this.onNotification(method, params, from);
342
+ }
343
+ }
344
+ // ── a thread held by a Codex that can't be joined ──────────────────
345
+ /** Reads the thread from disk, and keeps reading while a device has it open. */
346
+ async observe(threadId) {
347
+ const { thread } = await this.rpc("thread/read", { threadId, includeTurns: true });
348
+ const history = await this.withSubAgents(threadId, thread, threadToHistory(thread));
349
+ this.stopObserving(threadId);
350
+ const timer = setInterval(() => void this.refresh(threadId), this.observeIntervalMs);
351
+ timer.unref?.();
352
+ const last = thread.turns?.at(-1);
353
+ const running = turnUnderWay(last);
354
+ // (No stamp yet: the file may have grown since it was read, so the first look reads it again.)
355
+ this.observed.set(threadId, {
356
+ path: thread.path ?? undefined,
357
+ seen: new Set(history.map((item) => item.itemId)),
358
+ running,
359
+ turnId: running ? last?.id : undefined,
360
+ timer,
361
+ });
362
+ if (thread.cwd)
363
+ this.cwds.set(threadId, thread.cwd);
364
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_status", state: running ? "running" : "idle" });
365
+ this.reportHeld(threadId, true);
366
+ // What was queued for it before this host last started is still waiting.
367
+ void this.syncWaiting(threadId);
368
+ this.considerRelease(threadId);
369
+ return history;
370
+ }
371
+ /** Reports what the other process has finished since the last look. An item still being written isn't on disk. */
372
+ async refresh(threadId) {
373
+ const watch = this.observed.get(threadId);
374
+ if (!watch || watch.reading)
375
+ return;
376
+ if (this.waiting.has(threadId))
377
+ void this.syncWaiting(threadId);
378
+ const stamp = watch.path ? fileStamp(watch.path) : undefined;
379
+ if (stamp !== undefined && stamp === watch.stamp)
380
+ return;
381
+ watch.reading = true;
382
+ try {
383
+ const { thread } = await this.rpc("thread/read", { threadId, includeTurns: true });
384
+ const history = await this.withSubAgents(threadId, thread, threadToHistory(thread));
385
+ // Joined, or let go, while it was being read.
386
+ if (this.observed.get(threadId) !== watch)
387
+ return;
388
+ watch.stamp = stamp;
389
+ watch.path = thread.path ?? undefined;
390
+ for (const item of history) {
391
+ if (watch.seen.has(item.itemId))
392
+ continue;
393
+ watch.seen.add(item.itemId);
394
+ this.report(threadId, item);
395
+ }
396
+ const last = thread.turns?.at(-1);
397
+ const running = turnUnderWay(last);
398
+ watch.turnId = running ? last?.id : undefined;
399
+ if (running !== watch.running) {
400
+ watch.running = running;
401
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_status", state: running ? "running" : "idle" });
402
+ }
403
+ }
404
+ catch (error) {
405
+ // Gone, or unreadable: opening it again says what is wrong.
406
+ this.host?.log(`[codex] couldn't read ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
407
+ if (this.observed.get(threadId) === watch) {
408
+ this.stopObserving(threadId);
409
+ this.host?.detached(this.id, threadId);
410
+ }
411
+ }
412
+ finally {
413
+ watch.reading = false;
414
+ }
415
+ }
416
+ report(threadId, item) {
417
+ item.updates.forEach((update, index) => this.host?.update(this.id, threadId, update, index === item.updates.length - 1 ? item.itemId : undefined));
418
+ }
419
+ stopObserving(threadId) {
420
+ const watch = this.observed.get(threadId);
421
+ if (!watch)
422
+ return;
423
+ clearInterval(watch.timer);
424
+ this.observed.delete(threadId);
425
+ }
426
+ /**
427
+ * Before writing to a thread that could only be read: joins it if whoever
428
+ * had it has let go, with what they wrote since the last look. Still held,
429
+ * it stays as it is.
430
+ */
431
+ takeUp(threadId) {
432
+ const watch = this.observed.get(threadId);
433
+ if (!watch)
434
+ return Promise.resolve();
435
+ let taking = this.takingUp.get(threadId);
436
+ if (!taking) {
437
+ taking = (async () => {
438
+ this.attached.add(threadId);
439
+ let joined;
440
+ try {
441
+ joined = await this.subscribe(threadId);
442
+ }
443
+ catch (error) {
444
+ this.attached.delete(threadId);
445
+ if (heldElsewhere(error))
446
+ return;
447
+ throw error;
448
+ }
449
+ this.stopObserving(threadId);
450
+ for (const item of await this.withSubAgents(threadId, joined.thread, threadToHistory(joined.thread))) {
451
+ if (!watch.seen.has(item.itemId))
452
+ this.report(threadId, item);
453
+ }
454
+ this.goLive(threadId, joined);
455
+ this.reportHeld(threadId, false);
456
+ void this.announceConfig(threadId);
457
+ void this.announceCommands(threadId);
458
+ // What still waits in Codex's queue is started by this host's app-server now.
459
+ void this.syncWaiting(threadId);
460
+ })().finally(() => this.takingUp.delete(threadId));
461
+ this.takingUp.set(threadId, taking);
462
+ }
463
+ return taking;
464
+ }
465
+ /**
466
+ * Leaves a message in Codex's own queue for a thread another process holds.
467
+ * That process starts it when it is idle, or right after the turn it is running.
468
+ */
469
+ async enqueue(threadId, input, clientMessageId) {
470
+ try {
471
+ await this.rpc("thread/queue/add", { threadId, input, clientUserMessageId: clientMessageId });
472
+ }
473
+ catch (error) {
474
+ this.host?.log(`[codex] couldn't queue a message for ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
475
+ throw this.refused(threadId, "消息没发出去 · 在电脑的 Codex 里关掉这个会话后再发", HELD_SEND);
476
+ }
477
+ await this.syncWaiting(threadId);
478
+ return "queued";
479
+ }
480
+ /** What waits in Codex's queue for the thread, as the session's queue on the devices. */
481
+ async syncWaiting(threadId) {
482
+ const listed = await this.rpc("thread/queue/list", {
483
+ threadId,
484
+ }).catch(() => undefined);
485
+ if (!listed)
486
+ return;
487
+ this.setWaiting(threadId, listed.data.map((entry) => ({
488
+ clientMessageId: entry.clientUserMessageId,
489
+ submissionId: entry.id,
490
+ text: entry.input.map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : "")).join("").trim(),
491
+ images: entry.input.filter((part) => part.type === "image" || part.type === "localImage").length,
492
+ })));
493
+ }
494
+ setWaiting(threadId, waiting) {
495
+ const before = this.waiting.get(threadId) ?? [];
496
+ if (JSON.stringify(before) === JSON.stringify(waiting))
497
+ return;
498
+ if (waiting.length > 0)
499
+ this.waiting.set(threadId, waiting);
500
+ else
501
+ this.waiting.delete(threadId);
502
+ this.host?.queue(this.id, threadId, waiting.map(({ clientMessageId, text, images }) => ({ clientMessageId, text, images })));
503
+ }
504
+ unqueue(nativeId, clientMessageId) {
505
+ const waiting = this.waiting.get(nativeId) ?? [];
506
+ const entry = waiting.find((candidate) => candidate.clientMessageId === clientMessageId);
507
+ if (!entry)
508
+ return false;
509
+ this.setWaiting(nativeId, waiting.filter((candidate) => candidate !== entry));
510
+ // (Already started over there: then it runs, and shows as any message does.)
511
+ void this.rpc("thread/queue/delete", { threadId: nativeId, queuedSubmissionId: entry.submissionId }).catch(() => { });
512
+ return true;
513
+ }
514
+ /** A queued message can't be put into the turn another Codex is running: that turn is stopped, and the queue goes on. */
515
+ async sendQueuedNow(nativeId) {
516
+ if (!this.waiting.has(nativeId))
517
+ return;
518
+ await this.stopElsewhere(nativeId).catch(() => {
519
+ throw RpcError.app("busy", HELD_NOW);
520
+ });
521
+ }
522
+ /**
523
+ * Stops the turn a Codex that can't be joined is running. Only its own app
524
+ * can: it is asked over the bus its windows share.
525
+ */
526
+ async stopElsewhere(threadId) {
527
+ const watch = this.observed.get(threadId);
528
+ if (!watch?.running)
529
+ return;
530
+ try {
531
+ if (!this.desktopBusPath)
532
+ throw new Error("not asked");
533
+ await interruptThroughDesktop(this.desktopBusPath, threadId, watch.turnId);
534
+ }
535
+ catch (error) {
536
+ this.host?.log(`[codex] couldn't stop ${threadId} through the Codex desktop app: ${error instanceof Error ? error.message : String(error)}`);
537
+ throw RpcError.app("busy", HELD_STOP);
538
+ }
539
+ void this.refresh(threadId);
540
+ }
541
+ /**
542
+ * What a message for a thread held elsewhere is refused with. The apps show
543
+ * a failed message without its reason, so the reason goes into the session too.
544
+ */
545
+ refused(threadId, notice, message) {
546
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_notice", level: "warning", title: notice });
547
+ return RpcError.app("busy", message);
548
+ }
549
+ /** Says, when it becomes so and when it is over, that the session is in a Codex this host can't join. */
550
+ reportHeld(threadId, held) {
551
+ const memory = this.host?.state(this.id, threadId);
552
+ if ((memory?.get("held") === "1") === held)
553
+ return;
554
+ memory?.set("held", held ? "1" : "");
555
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_driver", driver: held ? "desktop" : "none" });
556
+ if (held)
557
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_notice", level: "info", ...HELD_NOTICE });
558
+ }
559
+ // ── letting go ─────────────────────────────────────────────────────
560
+ /** Whether a device has the session open. */
561
+ watched(nativeId, open) {
562
+ if (open) {
563
+ this.watching.add(nativeId);
564
+ this.cancelRelease(nativeId);
565
+ }
566
+ else {
567
+ this.watching.delete(nativeId);
568
+ this.considerRelease(nativeId);
569
+ }
570
+ }
571
+ cancelRelease(threadId) {
572
+ clearTimeout(this.releases.get(threadId));
573
+ this.releases.delete(threadId);
574
+ }
575
+ /**
576
+ * A thread stays loaded, and closed to every other Codex program, for as
577
+ * long as this host is subscribed to it. One that no device has open and
578
+ * that isn't working is let go after a while, so the desktop app or a
579
+ * `codex` in a terminal can open it; opening it on a device joins it again.
580
+ */
581
+ considerRelease(threadId) {
582
+ this.cancelRelease(threadId);
583
+ if (this.watching.has(threadId) || this.kept.has(threadId) || this.fresh.has(threadId))
584
+ return;
585
+ if (!this.attached.has(threadId) && !this.observed.has(threadId))
586
+ return;
587
+ const timer = setTimeout(() => {
588
+ this.releases.delete(threadId);
589
+ void this.release(threadId);
590
+ }, this.releaseDelayMs);
591
+ timer.unref?.();
592
+ this.releases.set(threadId, timer);
593
+ }
594
+ async release(threadId) {
595
+ if (this.watching.has(threadId) || this.kept.has(threadId) || this.fresh.has(threadId))
596
+ return;
597
+ if (this.observed.has(threadId)) {
598
+ this.stopObserving(threadId);
599
+ this.host?.detached(this.id, threadId);
600
+ return;
601
+ }
602
+ // Working: its turn's end asks again.
603
+ const busy = () => !this.attached.has(threadId) || this.stateOf(threadId).activeTurnId !== undefined || this.hasApprovals(threadId);
604
+ if (busy())
605
+ return;
606
+ // A command the agent left running (a dev server, say) ends when its thread
607
+ // is unloaded: the thread stays, and is looked at again later. So does one
608
+ // in a Codex that can't say what it has running.
609
+ let background;
610
+ try {
611
+ background = (await this.rpcFor(threadId, "thread/backgroundTerminals/list", { threadId, limit: 1 })).data;
612
+ }
613
+ catch {
614
+ return;
615
+ }
616
+ if (background.length > 0)
617
+ return this.considerRelease(threadId);
618
+ if (busy() || this.watching.has(threadId) || this.kept.has(threadId))
619
+ return;
620
+ this.host?.detached(this.id, threadId);
621
+ await this.detach(threadId);
622
+ }
623
+ hasApprovals(threadId) {
624
+ for (const pending of this.approvals.values())
625
+ if (pending.threadId === threadId)
626
+ return true;
627
+ return false;
169
628
  }
170
629
  /** Nests each sub-agent's own history right after the call that spawned it. */
171
630
  async withSubAgents(threadId, thread, history) {
@@ -185,7 +644,7 @@ export class CodexDriver {
185
644
  return history;
186
645
  const nested = new Map();
187
646
  await Promise.all([...spawns].map(async ([callId, ids]) => {
188
- const parts = await Promise.all(ids.map((child) => this.rpc("thread/read", { threadId: child, includeTurns: true })
647
+ const parts = await Promise.all(ids.map((child) => this.rpcFor(threadId, "thread/read", { threadId: child, includeTurns: true })
189
648
  .then((result) => nestHistory(threadToHistory(result.thread), callId))
190
649
  .catch(() => [])));
191
650
  nested.set(callId, parts.flat());
@@ -197,11 +656,14 @@ export class CodexDriver {
197
656
  });
198
657
  }
199
658
  async detach(nativeId) {
659
+ this.cancelRelease(nativeId);
660
+ this.stopObserving(nativeId);
200
661
  if (!this.attached.delete(nativeId))
201
662
  return;
202
- await this.rpc("thread/unsubscribe", { threadId: nativeId }).catch(() => { });
663
+ await this.rpcFor(nativeId, "thread/unsubscribe", { threadId: nativeId }).catch(() => { });
203
664
  }
204
665
  async prompt(nativeId, content, clientMessageId) {
666
+ await this.takeUp(nativeId);
205
667
  const command = commandOf(content);
206
668
  if (command) {
207
669
  const ran = await this.runCommand(nativeId, command, clientMessageId);
@@ -217,14 +679,17 @@ export class CodexDriver {
217
679
  async runCommand(nativeId, command, clientMessageId) {
218
680
  const echo = () => this.host?.update(this.id, nativeId, { sessionUpdate: "user_message_chunk", messageId: `local-${clientMessageId}`, content: { type: "text", text: command.text } }, `command:${clientMessageId}`);
219
681
  if (command.name === "compact" || command.name === "review") {
682
+ // Not something a queue can carry: they run in the app-server that has the thread.
683
+ if (this.observed.has(nativeId))
684
+ throw this.refused(nativeId, `会话开在电脑的另一个 Codex 里 · 这里不能${command.name === "compact" ? "压缩上下文" : "开始审查"}`, HELD_COMMAND);
220
685
  if (this.stateOf(nativeId).activeTurnId) {
221
686
  throw RpcError.app("busy", command.name === "compact" ? "等这一轮结束后再压缩上下文" : "等这一轮结束后再开始审查");
222
687
  }
223
688
  if (command.name === "compact") {
224
- await this.rpc("thread/compact/start", { threadId: nativeId });
689
+ await this.rpcFor(nativeId, "thread/compact/start", { threadId: nativeId });
225
690
  }
226
691
  else {
227
- await this.rpc("review/start", {
692
+ await this.rpcFor(nativeId, "review/start", {
228
693
  threadId: nativeId,
229
694
  target: command.args ? { type: "custom", instructions: command.args } : { type: "uncommittedChanges" },
230
695
  delivery: "inline",
@@ -249,10 +714,12 @@ export class CodexDriver {
249
714
  async send(nativeId, input, clientMessageId) {
250
715
  if (input.length === 0)
251
716
  throw RpcError.app("invalid_params", "nothing to send");
717
+ if (this.observed.has(nativeId))
718
+ return this.enqueue(nativeId, input, clientMessageId);
252
719
  const activeTurnId = this.stateOf(nativeId).activeTurnId;
253
720
  if (activeTurnId) {
254
721
  try {
255
- await this.rpc("turn/steer", {
722
+ await this.rpcFor(nativeId, "turn/steer", {
256
723
  threadId: nativeId,
257
724
  expectedTurnId: activeTurnId,
258
725
  clientUserMessageId: clientMessageId,
@@ -266,7 +733,7 @@ export class CodexDriver {
266
733
  }
267
734
  }
268
735
  const overrides = this.overrides.get(nativeId) ?? {};
269
- await this.rpc("turn/start", {
736
+ await this.rpcFor(nativeId, "turn/start", {
270
737
  threadId: nativeId,
271
738
  clientUserMessageId: clientMessageId,
272
739
  input,
@@ -324,9 +791,16 @@ export class CodexDriver {
324
791
  async announceConfig(nativeId) {
325
792
  const models = await this.loadModels();
326
793
  const options = configOptions(this.settings.get(nativeId) ?? {}, this.overrides.get(nativeId) ?? {}, models);
327
- if (options.length > 0)
794
+ if (options.length > 0 && this.isNews(`config:${nativeId}`, options))
328
795
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_config", options });
329
796
  }
797
+ isNews(key, value) {
798
+ const text = JSON.stringify(value);
799
+ if (this.announced.get(key) === text)
800
+ return false;
801
+ this.announced.set(key, text);
802
+ return true;
803
+ }
330
804
  loadSkills(cwd) {
331
805
  const key = cwd ?? "";
332
806
  let loading = this.skills.get(key);
@@ -346,33 +820,38 @@ export class CodexDriver {
346
820
  async announceCommands(nativeId) {
347
821
  const skills = await this.loadSkills(this.cwds.get(nativeId));
348
822
  const taken = new Set(COMMANDS.map((command) => command.name));
349
- this.host?.update(this.id, nativeId, {
350
- sessionUpdate: "available_commands_update",
351
- availableCommands: [
352
- ...COMMANDS,
353
- ...skills
354
- .filter((skill) => !taken.has(skill.name))
355
- .map((skill) => ({
356
- name: skill.name,
357
- description: (skill.interface?.shortDescription ?? skill.shortDescription ?? skill.description ?? "").slice(0, 160),
358
- })),
359
- ],
360
- });
823
+ const availableCommands = [
824
+ ...COMMANDS,
825
+ ...skills
826
+ .filter((skill) => !taken.has(skill.name))
827
+ .map((skill) => ({
828
+ name: skill.name,
829
+ description: (skill.interface?.shortDescription ?? skill.shortDescription ?? skill.description ?? "").slice(0, 160),
830
+ })),
831
+ ];
832
+ if (this.isNews(`commands:${nativeId}`, availableCommands))
833
+ this.host?.update(this.id, nativeId, { sessionUpdate: "available_commands_update", availableCommands });
361
834
  }
362
835
  async archive(nativeId, archived) {
363
- await this.rpc(archived ? "thread/archive" : "thread/unarchive", { threadId: nativeId });
836
+ await this.rpcFor(nativeId, archived ? "thread/archive" : "thread/unarchive", { threadId: nativeId });
364
837
  }
365
838
  async rename(nativeId, title) {
366
- await this.rpc("thread/name/set", { threadId: nativeId, name: title });
839
+ await this.rpcFor(nativeId, "thread/name/set", { threadId: nativeId, name: title });
367
840
  }
368
841
  async delete(nativeId) {
369
- await this.rpc("thread/delete", { threadId: nativeId });
842
+ await this.rpcFor(nativeId, "thread/delete", { threadId: nativeId });
370
843
  }
371
844
  async cancel(nativeId) {
845
+ if (this.observed.has(nativeId)) {
846
+ // Stopping stops what's waiting too; the apps put queued text back in the composer.
847
+ for (const entry of this.waiting.get(nativeId) ?? [])
848
+ this.unqueue(nativeId, entry.clientMessageId);
849
+ return this.stopElsewhere(nativeId);
850
+ }
372
851
  const turnId = this.stateOf(nativeId).activeTurnId;
373
852
  if (!turnId)
374
853
  return;
375
- await this.rpc("turn/interrupt", { threadId: nativeId, turnId });
854
+ await this.rpcFor(nativeId, "turn/interrupt", { threadId: nativeId, turnId });
376
855
  }
377
856
  async respondPermission(nativeId, requestId, optionId) {
378
857
  const pending = this.approvals.get(requestId);
@@ -394,9 +873,19 @@ export class CodexDriver {
394
873
  pending.answer(pending.request.answer(answers));
395
874
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_permission_resolved", requestId, optionId: "answered", answers });
396
875
  }
397
- desktopLaunch(args, nativeId) {
398
- // Multi-client: no lease to take, the TUI just joins the shared server.
399
- const remote = ["--remote", `unix://${this.options.socketPath}`];
876
+ async desktopLaunch(args, nativeId) {
877
+ // Multi-client: no lease to take, the TUI just joins the server the thread is in.
878
+ let socketPath = this.options.socketPath;
879
+ if (nativeId) {
880
+ // Joined first, which finds where the thread is loaded: the TUI has to go to the same server.
881
+ await this.host?.follow(this.id, nativeId).catch(() => { });
882
+ if (this.attached.has(nativeId) && this.inShared.has(nativeId) && this.sharedSocketPath)
883
+ socketPath = this.sharedSocketPath;
884
+ // In this host's app-server it is followed from now on, like a thread the TUI starts.
885
+ else
886
+ this.kept.add(nativeId);
887
+ }
888
+ const remote = ["--remote", `unix://${socketPath}`];
400
889
  return {
401
890
  command: this.options.command ?? "codex",
402
891
  args: nativeId ? [...remote, "resume", nativeId, ...args] : [...remote, ...args],
@@ -410,8 +899,8 @@ export class CodexDriver {
410
899
  env: this.options.env,
411
900
  clientVersion: this.options.hostVersion,
412
901
  log: (message) => this.host?.log(message),
413
- onNotification: (method, params) => this.onNotification(method, params),
414
- onRequest: (method, params, id) => this.onServerRequest(method, params, id),
902
+ onNotification: (method, params) => this.onNotification(method, params, "own"),
903
+ onRequest: (method, params, id) => this.onServerRequest(method, params, id, "own"),
415
904
  onDown: (reason) => this.onDown(reason),
416
905
  });
417
906
  this.server = server;
@@ -433,8 +922,10 @@ export class CodexDriver {
433
922
  /** Follows every thread already live in the app-server (e.g. opened in the TUI). */
434
923
  async reattachLoaded() {
435
924
  const loaded = await this.rpc("thread/loaded/list", {}).catch(() => ({ data: [] }));
436
- for (const threadId of loaded.data)
925
+ for (const threadId of loaded.data) {
926
+ this.kept.add(threadId);
437
927
  this.follow(threadId);
928
+ }
438
929
  }
439
930
  /**
440
931
  * Asks the host to follow a thread another client opened. A brand-new thread
@@ -469,16 +960,24 @@ export class CodexDriver {
469
960
  if (this.stopped)
470
961
  return;
471
962
  this.host?.log(`[codex] ${reason}`);
472
- for (const pending of this.approvals.values())
473
- pending.abandon();
474
- this.approvals.clear();
475
- for (const threadId of this.attached) {
963
+ this.abandonApprovals("own");
964
+ for (const threadId of [...this.attached]) {
965
+ // (What is joined in Codex's background server is still there.)
966
+ if (this.inShared.has(threadId))
967
+ continue;
968
+ this.attached.delete(threadId);
476
969
  this.threads.delete(threadId);
477
970
  this.host?.update(this.id, threadId, { sessionUpdate: "ls_status", state: "offline" });
478
971
  this.host?.detached(this.id, threadId);
479
972
  }
480
- this.attached.clear();
973
+ // Read through the app-server that just went away.
974
+ for (const threadId of [...this.observed.keys()]) {
975
+ this.stopObserving(threadId);
976
+ this.host?.detached(this.id, threadId);
977
+ }
481
978
  this.startedHere.clear();
979
+ this.fresh.clear();
980
+ this.kept.clear();
482
981
  this.current = { installed: true, version: this.current.version, problem: "Codex app-server restarting" };
483
982
  this.scheduleRestart();
484
983
  }
@@ -503,7 +1002,7 @@ export class CodexDriver {
503
1002
  }
504
1003
  return state;
505
1004
  }
506
- onNotification(method, params) {
1005
+ onNotification(method, params, from) {
507
1006
  if (method === "thread/deleted") {
508
1007
  // Deleted in the TUI or another client.
509
1008
  const threadId = params?.threadId;
@@ -521,29 +1020,46 @@ export class CodexDriver {
521
1020
  return;
522
1021
  }
523
1022
  this.host?.sessionSeen(this.id, threadToDiscovered(thread));
1023
+ if (from === "shared") {
1024
+ // A plain `codex` opened it in Codex's background server. It is joined
1025
+ // there when a device opens it: until then this host keeps nothing loaded.
1026
+ this.inShared.add(thread.id);
1027
+ return;
1028
+ }
524
1029
  // The broadcast can beat our own thread/start response; decide whether
525
1030
  // the thread was opened elsewhere (the desktop TUI) once those settle.
526
1031
  void Promise.allSettled([...this.startsInFlight]).then(() => {
527
- if (!this.attached.has(thread.id) && !this.startedHere.has(thread.id))
528
- this.follow(thread.id);
1032
+ if (this.attached.has(thread.id) || this.startedHere.has(thread.id))
1033
+ return;
1034
+ this.kept.add(thread.id);
1035
+ this.follow(thread.id);
529
1036
  });
530
1037
  return;
531
1038
  }
1039
+ const threadId = params?.threadId;
1040
+ const held = typeof threadId === "string" ? this.joining.get(threadId) : undefined;
1041
+ if (held) {
1042
+ held.push({ method, params, from });
1043
+ return;
1044
+ }
532
1045
  this.noteSpawns(params);
533
- for (const mapped of mapNotification(method, params, (threadId) => this.stateOf(threadId))) {
534
- if (mapped.update.sessionUpdate === "tool_call")
535
- this.rememberTitle(mapped.update.toolCallId, mapped.update.title);
536
- if (mapped.update.sessionUpdate === "ls_permission_resolved") {
537
- const pending = this.approvals.get(mapped.update.requestId);
1046
+ for (const mapped of mapNotification(method, params, (id) => this.stateOf(id))) {
1047
+ const update = from === "shared" && mapped.update.sessionUpdate === "ls_permission_resolved"
1048
+ ? { ...mapped.update, requestId: sharedRequestId(mapped.update.requestId) }
1049
+ : mapped.update;
1050
+ if (update.sessionUpdate === "tool_call")
1051
+ this.rememberTitle(update.toolCallId, update.title);
1052
+ if (update.sessionUpdate === "ls_permission_resolved") {
1053
+ const pending = this.approvals.get(update.requestId);
538
1054
  if (pending) {
539
1055
  // Another client (the TUI) answered first.
540
- this.approvals.delete(mapped.update.requestId);
1056
+ this.approvals.delete(update.requestId);
541
1057
  pending.abandon();
542
1058
  }
543
1059
  }
544
1060
  const parent = this.children.get(mapped.threadId);
545
1061
  if (parent) {
546
- const nested = nestUnder(mapped.update, parent.toolCallId);
1062
+ const nested = nestUnder(update, parent.toolCallId);
547
1063
  if (nested && this.attached.has(parent.threadId)) {
548
1064
  this.host?.update(this.id, parent.threadId, nested, mapped.itemId && `sub:${parent.toolCallId}:${mapped.itemId}`);
549
1065
  }
@@ -551,7 +1067,16 @@ export class CodexDriver {
551
1067
  }
552
1068
  if (!this.attached.has(mapped.threadId))
553
1069
  continue;
554
- this.host?.update(this.id, mapped.threadId, mapped.update, mapped.itemId);
1070
+ this.host?.update(this.id, mapped.threadId, update, mapped.itemId);
1071
+ // A message that waited in Codex's queue has started.
1072
+ if (update.sessionUpdate === "user_message_chunk" && this.waiting.has(mapped.threadId))
1073
+ void this.syncWaiting(mapped.threadId);
1074
+ if (update.sessionUpdate !== "ls_turn")
1075
+ continue;
1076
+ if (update.state === "started")
1077
+ this.fresh.delete(mapped.threadId);
1078
+ else
1079
+ this.considerRelease(mapped.threadId);
555
1080
  }
556
1081
  }
557
1082
  /** Remembers which spawnAgent call each new sub-agent thread belongs to. */
@@ -566,10 +1091,10 @@ export class CodexDriver {
566
1091
  for (const child of spawnedThreads(record?.item))
567
1092
  this.children.set(child, { threadId: root, toolCallId: callId });
568
1093
  }
569
- onServerRequest(method, params, id) {
1094
+ onServerRequest(method, params, id, from) {
570
1095
  if (!APPROVAL_METHODS.has(method) && !QUESTION_METHODS.has(method))
571
1096
  return ABANDON;
572
- const requestId = String(id);
1097
+ const requestId = from === "shared" ? sharedRequestId(String(id)) : String(id);
573
1098
  const mapped = QUESTION_METHODS.has(method) ? mapQuestionRequest(method, params, requestId) : mapApprovalRequest(method, params, requestId);
574
1099
  // A sub-agent asking for permission asks in its parent session.
575
1100
  const request = mapped && this.children.has(mapped.threadId) ? { ...mapped, threadId: this.children.get(mapped.threadId).threadId } : mapped;
@@ -600,5 +1125,48 @@ export class CodexDriver {
600
1125
  return Promise.reject(RpcError.app("agent_unavailable", "Codex is not running"));
601
1126
  return this.server.request(method, params);
602
1127
  }
1128
+ /** A request about a thread, to the server it is loaded in. */
1129
+ rpcFor(threadId, method, params) {
1130
+ return this.inShared.has(threadId) && this.shared ? this.shared.request(method, params) : this.rpc(method, params);
1131
+ }
1132
+ /** Connects to Codex's background server, if it is running. */
1133
+ connectShared() {
1134
+ if (!this.sharedSocketPath || this.stopped)
1135
+ return Promise.resolve(false);
1136
+ this.shared ??= new CodexSharedServer({
1137
+ socketPath: this.sharedSocketPath,
1138
+ clientVersion: this.options.hostVersion,
1139
+ onNotification: (method, params) => this.onNotification(method, params, "shared"),
1140
+ onRequest: (method, params, id) => this.onServerRequest(method, params, id, "shared"),
1141
+ onDown: () => this.onSharedDown(),
1142
+ });
1143
+ return this.shared.connect();
1144
+ }
1145
+ /** Codex's background server went away (stopped, or restarted by its updater): the threads joined there are joined again wherever they are now. */
1146
+ onSharedDown() {
1147
+ if (this.stopped)
1148
+ return;
1149
+ this.host?.log("[codex] lost Codex's background server");
1150
+ this.abandonApprovals("shared");
1151
+ const joined = [...this.inShared].filter((threadId) => this.attached.delete(threadId));
1152
+ this.inShared.clear();
1153
+ for (const threadId of joined) {
1154
+ const turnId = this.stateOf(threadId).activeTurnId;
1155
+ this.threads.delete(threadId);
1156
+ // Whether the turn goes on is told again when the thread is joined again.
1157
+ if (turnId)
1158
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_turn", state: "ended", turnId });
1159
+ this.host?.detached(this.id, threadId);
1160
+ this.follow(threadId);
1161
+ }
1162
+ }
1163
+ abandonApprovals(from) {
1164
+ for (const [requestId, pending] of this.approvals) {
1165
+ if (requestId.startsWith(sharedRequestId("")) !== (from === "shared"))
1166
+ continue;
1167
+ this.approvals.delete(requestId);
1168
+ pending.abandon();
1169
+ }
1170
+ }
603
1171
  }
604
1172
  //# sourceMappingURL=driver.js.map