linkshell-cli 0.10.0 → 0.10.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.
- 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 +124 -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 +629 -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,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(
|
|
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.
|
|
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
|
|
225
|
+
let joined;
|
|
146
226
|
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);
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
687
|
+
await this.rpcFor(nativeId, "thread/compact/start", { threadId: nativeId });
|
|
225
688
|
}
|
|
226
689
|
else {
|
|
227
|
-
await this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
834
|
+
await this.rpcFor(nativeId, archived ? "thread/archive" : "thread/unarchive", { threadId: nativeId });
|
|
364
835
|
}
|
|
365
836
|
async rename(nativeId, title) {
|
|
366
|
-
await this.
|
|
837
|
+
await this.rpcFor(nativeId, "thread/name/set", { threadId: nativeId, name: title });
|
|
367
838
|
}
|
|
368
839
|
async delete(nativeId) {
|
|
369
|
-
await this.
|
|
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.
|
|
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
|
|
399
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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 (
|
|
528
|
-
|
|
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, (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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
|