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.
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/host/src/drivers/codex/app-server.d.ts +31 -6
- package/dist/host/src/drivers/codex/app-server.js +99 -24
- package/dist/host/src/drivers/codex/app-server.js.map +1 -1
- package/dist/host/src/drivers/codex/desktop-ipc.d.ts +8 -0
- package/dist/host/src/drivers/codex/desktop-ipc.js +127 -0
- package/dist/host/src/drivers/codex/desktop-ipc.js.map +1 -0
- package/dist/host/src/drivers/codex/driver.d.ts +111 -2
- package/dist/host/src/drivers/codex/driver.js +631 -63
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/codex/mapper.d.ts +21 -0
- package/dist/host/src/drivers/codex/mapper.js +45 -6
- package/dist/host/src/drivers/codex/mapper.js.map +1 -1
- package/dist/host/src/drivers/types.d.ts +6 -0
- package/dist/host/src/hub.js +8 -1
- package/dist/host/src/hub.js.map +1 -1
- package/package.json +2 -2
|
@@ -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(
|
|
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.
|
|
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
|
|
224
|
+
let joined;
|
|
146
225
|
try {
|
|
147
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
689
|
+
await this.rpcFor(nativeId, "thread/compact/start", { threadId: nativeId });
|
|
225
690
|
}
|
|
226
691
|
else {
|
|
227
|
-
await this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
836
|
+
await this.rpcFor(nativeId, archived ? "thread/archive" : "thread/unarchive", { threadId: nativeId });
|
|
364
837
|
}
|
|
365
838
|
async rename(nativeId, title) {
|
|
366
|
-
await this.
|
|
839
|
+
await this.rpcFor(nativeId, "thread/name/set", { threadId: nativeId, name: title });
|
|
367
840
|
}
|
|
368
841
|
async delete(nativeId) {
|
|
369
|
-
await this.
|
|
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.
|
|
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
|
|
399
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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 (
|
|
528
|
-
|
|
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, (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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
|