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