@linkshell/host 0.4.0 → 0.4.1

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