linkshell-cli 0.10.72 → 0.10.74
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/src/commands/desktop.d.ts +2 -0
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/gateway/src/relay.d.ts +1 -1
- package/dist/gateway/src/relay.js +8 -1
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/host/src/drivers/acp/driver.d.ts +9 -1
- package/dist/host/src/drivers/acp/driver.js +44 -2
- package/dist/host/src/drivers/acp/driver.js.map +1 -1
- package/dist/host/src/drivers/claude/driver.d.ts +20 -1
- package/dist/host/src/drivers/claude/driver.js +144 -4
- package/dist/host/src/drivers/claude/driver.js.map +1 -1
- package/dist/host/src/drivers/claude/transcript.d.ts +15 -0
- package/dist/host/src/drivers/claude/transcript.js +86 -1
- package/dist/host/src/drivers/claude/transcript.js.map +1 -1
- package/dist/host/src/drivers/codex/driver.d.ts +30 -2
- package/dist/host/src/drivers/codex/driver.js +241 -9
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/types.d.ts +34 -0
- package/dist/host/src/host.js +2 -1
- package/dist/host/src/host.js.map +1 -1
- package/dist/host/src/hub.d.ts +19 -1
- package/dist/host/src/hub.js +153 -11
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/live-activities.d.ts +2 -1
- package/dist/host/src/live-activities.js +25 -2
- package/dist/host/src/live-activities.js.map +1 -1
- package/dist/host/src/rpc/server.js +11 -1
- 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 +10 -0
- package/dist/host/src/store.js +59 -2
- package/dist/host/src/store.js.map +1 -1
- package/dist/wire/src/index.d.ts +1 -0
- package/dist/wire/src/index.js +1 -0
- package/dist/wire/src/index.js.map +1 -1
- package/dist/wire/src/model.d.ts +22 -0
- package/dist/wire/src/model.js +4 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/rpc.d.ts +330 -3
- package/dist/wire/src/rpc.js +35 -0
- 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/package.json +4 -4
|
@@ -88,6 +88,17 @@ const HELD_SEND = "会话开在电脑的另一个 Codex 里,这条消息没能
|
|
|
88
88
|
const HELD_STOP = "这一轮跑在电脑的 Codex 桌面 App(或 IDE 插件)里,这次没能从手机停下它。请在电脑上停止;一直这样的话,重启一下 Codex 桌面 App。";
|
|
89
89
|
const HELD_NOW = "这一轮跑在电脑的 Codex 桌面 App(或 IDE 插件)里,这次没能从手机插进去。它结束后这条消息会自动发送;想现在就插入,在电脑上对这条排队消息点 Steer。一直这样的话,重启一下 Codex 桌面 App。";
|
|
90
90
|
const HELD_COMMAND = "会话开在电脑的另一个 Codex 里,要在那边关掉它之后才能从手机做。";
|
|
91
|
+
/**
|
|
92
|
+
* What a side conversation's thread is told, as Codex's own `/side` tells it:
|
|
93
|
+
* the history it was forked with is for reference, and it changes nothing.
|
|
94
|
+
*/
|
|
95
|
+
const SIDE_INSTRUCTIONS = [
|
|
96
|
+
"You are in a side conversation forked from the user's main thread, not in the main thread itself.",
|
|
97
|
+
"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.",
|
|
98
|
+
"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.",
|
|
99
|
+
"Keep answers short; the user is reading them on a phone.",
|
|
100
|
+
].join("\n\n");
|
|
101
|
+
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
102
|
/**
|
|
92
103
|
* Codex, tier multi_client: LinkShell and the desktop TUI (`codex --remote`)
|
|
93
104
|
* are both clients of one app-server, so every thread is live on both sides.
|
|
@@ -118,6 +129,7 @@ export class CodexDriver {
|
|
|
118
129
|
images: true,
|
|
119
130
|
models: true,
|
|
120
131
|
modes: true,
|
|
132
|
+
side: true,
|
|
121
133
|
...behaviorCapabilities(this.behavior),
|
|
122
134
|
};
|
|
123
135
|
host;
|
|
@@ -134,7 +146,13 @@ export class CodexDriver {
|
|
|
134
146
|
/** Recent tool titles by item, so a file-change approval can name its files. */
|
|
135
147
|
toolTitles = new Map();
|
|
136
148
|
settings = new Map();
|
|
149
|
+
/** 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
150
|
overrides = new Map();
|
|
151
|
+
/** Side conversations (`sideAsk`), by `<parent>\n<sideId>`, and their threads. */
|
|
152
|
+
sides = new Map();
|
|
153
|
+
sideThreads = new Map();
|
|
154
|
+
/** Turns that run under the full access a device chose, started with it or chosen while they ran. */
|
|
155
|
+
fullAccessTurns = new Set();
|
|
138
156
|
/** Sub-agent thread → the parent thread and the spawnAgent call its work nests under. */
|
|
139
157
|
children = new Map();
|
|
140
158
|
/** Threads Codex reported as sub-agents (parentThreadId set); never shown as sessions. */
|
|
@@ -786,7 +804,7 @@ export class CodexDriver {
|
|
|
786
804
|
}
|
|
787
805
|
desktopMessage(threadId, input, clientMessageId) {
|
|
788
806
|
const current = this.settings.get(threadId) ?? {};
|
|
789
|
-
const overrides = this.
|
|
807
|
+
const overrides = this.overridesOf(threadId);
|
|
790
808
|
return {
|
|
791
809
|
input,
|
|
792
810
|
text: textOf(input),
|
|
@@ -1088,7 +1106,7 @@ export class CodexDriver {
|
|
|
1088
1106
|
const lines = [];
|
|
1089
1107
|
if (command.name === "status") {
|
|
1090
1108
|
const { thread } = await this.rpcFor(nativeId, "thread/read", { threadId: nativeId, includeTurns: false });
|
|
1091
|
-
const current = effective(this.settings.get(nativeId) ?? {}, this.
|
|
1109
|
+
const current = effective(this.settings.get(nativeId) ?? {}, this.overridesOf(nativeId), await this.loadModels());
|
|
1092
1110
|
const running = this.observed.get(nativeId)?.running ?? Boolean(this.stateOf(nativeId).activeTurnId);
|
|
1093
1111
|
lines.push(`目录:${thread.cwd}`, `模型:${current.model ?? "默认"}`, `推理强度:${current.effort ?? "默认"}`, `状态:${running ? "运行中" : "空闲"}`);
|
|
1094
1112
|
}
|
|
@@ -1184,13 +1202,15 @@ export class CodexDriver {
|
|
|
1184
1202
|
this.host?.log(`[codex] steer failed, starting a new turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
1185
1203
|
}
|
|
1186
1204
|
}
|
|
1187
|
-
const overrides = this.
|
|
1188
|
-
await this.rpcFor(nativeId, "turn/start", {
|
|
1205
|
+
const overrides = this.overridesOf(nativeId);
|
|
1206
|
+
const started = await this.rpcFor(nativeId, "turn/start", {
|
|
1189
1207
|
threadId: nativeId,
|
|
1190
1208
|
clientUserMessageId: clientMessageId,
|
|
1191
1209
|
input,
|
|
1192
1210
|
...turnOverrides(overrides, overrides.plan === undefined ? {} : effective(this.settings.get(nativeId) ?? {}, overrides, await this.loadModels())),
|
|
1193
1211
|
});
|
|
1212
|
+
if (overrides.permissions === "full-access" && typeof started?.turn?.id === "string")
|
|
1213
|
+
this.markFullAccess(started.turn.id);
|
|
1194
1214
|
return "started";
|
|
1195
1215
|
}
|
|
1196
1216
|
async goal(nativeId, change) {
|
|
@@ -1228,12 +1248,12 @@ export class CodexDriver {
|
|
|
1228
1248
|
this.host?.log(`[codex] goal unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1229
1249
|
}
|
|
1230
1250
|
}
|
|
1231
|
-
/** Model, reasoning effort and permissions apply from the next turn on. */
|
|
1251
|
+
/** Model, reasoning effort and permissions apply from the next turn on (full access at once, see autoApproval). */
|
|
1232
1252
|
async setConfig(nativeId, optionId, value) {
|
|
1233
1253
|
const models = await this.loadModels();
|
|
1234
1254
|
const base = this.settings.get(nativeId) ?? {};
|
|
1235
|
-
const current = effective(base, this.
|
|
1236
|
-
const next = { ...this.
|
|
1255
|
+
const current = effective(base, this.overridesOf(nativeId), models);
|
|
1256
|
+
const next = { ...this.overridesOf(nativeId) };
|
|
1237
1257
|
if (optionId === "model") {
|
|
1238
1258
|
if (!models.some((m) => m.model === value) && value !== current.model) {
|
|
1239
1259
|
throw RpcError.app("invalid_params", `unknown model ${value}`);
|
|
@@ -1263,16 +1283,200 @@ export class CodexDriver {
|
|
|
1263
1283
|
else {
|
|
1264
1284
|
throw RpcError.app("not_supported", `Codex has no setting ${optionId}`);
|
|
1265
1285
|
}
|
|
1266
|
-
this.
|
|
1286
|
+
this.setOverrides(nativeId, next);
|
|
1287
|
+
// Codex fixes approvals when a turn starts, so the turn under way would go on asking.
|
|
1288
|
+
const running = this.stateOf(nativeId).activeTurnId ?? this.observed.get(nativeId)?.turnId;
|
|
1289
|
+
if (optionId === "permissions" && running) {
|
|
1290
|
+
if (value === "full-access") {
|
|
1291
|
+
this.markFullAccess(running);
|
|
1292
|
+
this.approvePending(nativeId);
|
|
1293
|
+
}
|
|
1294
|
+
else
|
|
1295
|
+
this.fullAccessTurns.delete(running);
|
|
1296
|
+
}
|
|
1267
1297
|
if (this.observed.has(nativeId) && this.desktopBusPath) {
|
|
1268
1298
|
const updated = effective(base, next, models);
|
|
1269
1299
|
const threadSettings = turnOverrides(next, { model: updated.model, effort: updated.effort });
|
|
1270
1300
|
await updateSettingsThroughDesktop(this.desktopBusPath, nativeId, threadSettings, optionId === "permissions" ? (this.observed.get(nativeId)?.turnId ?? null) : null).catch((error) => {
|
|
1271
1301
|
this.host?.log(`[codex] desktop settings sync for ${nativeId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1302
|
+
// The window keeps its own settings until then; saying nothing would look like the choice took.
|
|
1303
|
+
this.host?.update(this.id, nativeId, {
|
|
1304
|
+
sessionUpdate: "ls_notice",
|
|
1305
|
+
level: "warning",
|
|
1306
|
+
title: "Codex 桌面 App 没有接受这项设置",
|
|
1307
|
+
detail: "电脑上那个窗口仍按原来的设置运行;从这里发下一条消息时会按新设置开始。",
|
|
1308
|
+
});
|
|
1272
1309
|
});
|
|
1273
1310
|
}
|
|
1274
1311
|
await this.announceConfig(nativeId);
|
|
1275
1312
|
}
|
|
1313
|
+
overridesOf(threadId) {
|
|
1314
|
+
let overrides = this.overrides.get(threadId);
|
|
1315
|
+
if (!overrides) {
|
|
1316
|
+
try {
|
|
1317
|
+
overrides = JSON.parse(this.host?.state(this.id, threadId).get("overrides") ?? "{}");
|
|
1318
|
+
}
|
|
1319
|
+
catch {
|
|
1320
|
+
overrides = {};
|
|
1321
|
+
}
|
|
1322
|
+
this.overrides.set(threadId, overrides);
|
|
1323
|
+
}
|
|
1324
|
+
return overrides;
|
|
1325
|
+
}
|
|
1326
|
+
setOverrides(threadId, next) {
|
|
1327
|
+
this.overrides.set(threadId, next);
|
|
1328
|
+
this.host?.state(this.id, threadId).set("overrides", JSON.stringify(next));
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* With full access chosen, what Codex still asks in the thread (the turn
|
|
1332
|
+
* under way started with the old approvals) is approved here, as it would
|
|
1333
|
+
* not have been asked from the next turn on. Questions still go to the user.
|
|
1334
|
+
*/
|
|
1335
|
+
autoApproval(method, request, turnId) {
|
|
1336
|
+
// Only such turns: a turn someone else started keeps the approvals it was started with.
|
|
1337
|
+
if (!APPROVAL_METHODS.has(method) || !turnId || !this.fullAccessTurns.has(turnId))
|
|
1338
|
+
return undefined;
|
|
1339
|
+
if (this.overridesOf(request.threadId).permissions !== "full-access")
|
|
1340
|
+
return undefined;
|
|
1341
|
+
const options = request.update.options;
|
|
1342
|
+
return (options.find((option) => option.kind === "allow_always") ?? options.find((option) => option.kind === "allow_once"))?.optionId;
|
|
1343
|
+
}
|
|
1344
|
+
// ── side conversations (/side) ────────────────────────────────────
|
|
1345
|
+
/**
|
|
1346
|
+
* Codex's `/side`: an ephemeral fork of the thread (never written to disk),
|
|
1347
|
+
* read-only, told where the inherited history ends. Each question is a turn
|
|
1348
|
+
* in it; what it writes goes to `out`, not into the session.
|
|
1349
|
+
*/
|
|
1350
|
+
async sideAsk(nativeId, request, out, signal) {
|
|
1351
|
+
if (signal.aborted)
|
|
1352
|
+
return;
|
|
1353
|
+
const key = `${nativeId}\n${request.sideId}`;
|
|
1354
|
+
const side = this.sides.get(key) ?? (await this.openSide(key, nativeId));
|
|
1355
|
+
if (signal.aborted)
|
|
1356
|
+
return;
|
|
1357
|
+
const done = new Promise((resolve, reject) => {
|
|
1358
|
+
side.out = out;
|
|
1359
|
+
side.finish = (error) => (error ? reject(error) : resolve());
|
|
1360
|
+
});
|
|
1361
|
+
const stop = () => {
|
|
1362
|
+
side.finish?.();
|
|
1363
|
+
if (side.turnId)
|
|
1364
|
+
void this.rpcFor(side.threadId, "turn/interrupt", { threadId: side.threadId, turnId: side.turnId }).catch(() => { });
|
|
1365
|
+
};
|
|
1366
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
1367
|
+
try {
|
|
1368
|
+
const started = await this.rpcFor(side.threadId, "turn/start", {
|
|
1369
|
+
threadId: side.threadId,
|
|
1370
|
+
input: toCodexInput([{ type: "text", text: request.question }]),
|
|
1371
|
+
});
|
|
1372
|
+
if (typeof started?.turn?.id === "string")
|
|
1373
|
+
side.turnId = started.turn.id;
|
|
1374
|
+
if (signal.aborted)
|
|
1375
|
+
stop();
|
|
1376
|
+
await done;
|
|
1377
|
+
}
|
|
1378
|
+
catch (error) {
|
|
1379
|
+
// The fork may be gone (Codex restarted it away): the next question opens a new one.
|
|
1380
|
+
if (!side.turnId) {
|
|
1381
|
+
this.sides.delete(side.key);
|
|
1382
|
+
this.sideThreads.delete(side.threadId);
|
|
1383
|
+
}
|
|
1384
|
+
throw error;
|
|
1385
|
+
}
|
|
1386
|
+
finally {
|
|
1387
|
+
signal.removeEventListener("abort", stop);
|
|
1388
|
+
side.out = undefined;
|
|
1389
|
+
side.finish = undefined;
|
|
1390
|
+
side.turnId = undefined;
|
|
1391
|
+
side.messageId = undefined;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
async sideClose(nativeId, sideId) {
|
|
1395
|
+
const side = this.sides.get(`${nativeId}\n${sideId}`);
|
|
1396
|
+
if (!side)
|
|
1397
|
+
return;
|
|
1398
|
+
this.sides.delete(side.key);
|
|
1399
|
+
this.sideThreads.delete(side.threadId);
|
|
1400
|
+
side.finish?.();
|
|
1401
|
+
// Nobody subscribed, an ephemeral thread is unloaded: nothing of it is left.
|
|
1402
|
+
await this.rpcFor(side.threadId, "thread/unsubscribe", { threadId: side.threadId }).catch(() => { });
|
|
1403
|
+
this.inShared.delete(side.threadId);
|
|
1404
|
+
this.threads.delete(side.threadId);
|
|
1405
|
+
}
|
|
1406
|
+
async openSide(key, parent) {
|
|
1407
|
+
let forked;
|
|
1408
|
+
try {
|
|
1409
|
+
forked = await this.rpcFor(parent, "thread/fork", {
|
|
1410
|
+
threadId: parent,
|
|
1411
|
+
ephemeral: true,
|
|
1412
|
+
excludeTurns: true,
|
|
1413
|
+
approvalPolicy: "never",
|
|
1414
|
+
sandbox: "read-only",
|
|
1415
|
+
developerInstructions: SIDE_INSTRUCTIONS,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
catch (error) {
|
|
1419
|
+
if (error instanceof Error && /at least one turn|no rollout/i.test(error.message))
|
|
1420
|
+
throw RpcError.app("not_ready", "这个 Codex 会话还没有任何消息:先发一条再问");
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
1423
|
+
const threadId = typeof forked.thread?.id === "string" ? forked.thread.id : undefined;
|
|
1424
|
+
if (!threadId)
|
|
1425
|
+
throw RpcError.app("agent_error", "Codex 没有开出旁支对话");
|
|
1426
|
+
const side = { key, threadId };
|
|
1427
|
+
this.sides.set(key, side);
|
|
1428
|
+
this.sideThreads.set(threadId, side);
|
|
1429
|
+
// The fork lives where its parent is loaded.
|
|
1430
|
+
if (this.inShared.has(parent))
|
|
1431
|
+
this.inShared.add(threadId);
|
|
1432
|
+
await this.rpcFor(threadId, "thread/inject_items", {
|
|
1433
|
+
threadId,
|
|
1434
|
+
items: [{ type: "message", role: "developer", content: [{ type: "input_text", text: SIDE_BOUNDARY }] }],
|
|
1435
|
+
}).catch((error) => this.host?.log(`[codex] side boundary not injected: ${error instanceof Error ? error.message : String(error)}`));
|
|
1436
|
+
return side;
|
|
1437
|
+
}
|
|
1438
|
+
/** Their server went away, and an ephemeral thread with it: the next question opens a new one. */
|
|
1439
|
+
loseSides(from) {
|
|
1440
|
+
for (const side of [...this.sides.values()]) {
|
|
1441
|
+
if (this.inShared.has(side.threadId) !== (from === "shared"))
|
|
1442
|
+
continue;
|
|
1443
|
+
this.sides.delete(side.key);
|
|
1444
|
+
this.sideThreads.delete(side.threadId);
|
|
1445
|
+
this.inShared.delete(side.threadId);
|
|
1446
|
+
side.finish?.(new Error("Codex 重启了,这个回答没有完成:再问一次即可,之前的旁支内容 Codex 已不记得"));
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
sideUpdate(side, update) {
|
|
1450
|
+
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
|
1451
|
+
if (side.messageId && side.messageId !== update.messageId)
|
|
1452
|
+
side.out?.text("\n\n");
|
|
1453
|
+
side.messageId = update.messageId;
|
|
1454
|
+
side.out?.text(update.content.text);
|
|
1455
|
+
}
|
|
1456
|
+
else if (update.sessionUpdate === "tool_call")
|
|
1457
|
+
side.out?.activity(update.title);
|
|
1458
|
+
else if (update.sessionUpdate === "ls_error")
|
|
1459
|
+
side.finish?.(new Error(update.message));
|
|
1460
|
+
else if (update.sessionUpdate === "ls_turn" && update.state === "ended" && (!side.turnId || !update.turnId || update.turnId === side.turnId))
|
|
1461
|
+
side.finish?.();
|
|
1462
|
+
}
|
|
1463
|
+
markFullAccess(turnId) {
|
|
1464
|
+
this.fullAccessTurns.add(turnId);
|
|
1465
|
+
if (this.fullAccessTurns.size > 200)
|
|
1466
|
+
this.fullAccessTurns.delete(this.fullAccessTurns.values().next().value);
|
|
1467
|
+
}
|
|
1468
|
+
approvePending(threadId) {
|
|
1469
|
+
for (const [requestId, pending] of this.approvals) {
|
|
1470
|
+
if (pending.threadId !== threadId)
|
|
1471
|
+
continue;
|
|
1472
|
+
const optionId = this.autoApproval(pending.method, pending.request, pending.turnId);
|
|
1473
|
+
if (!optionId)
|
|
1474
|
+
continue;
|
|
1475
|
+
this.approvals.delete(requestId);
|
|
1476
|
+
pending.answer(pending.request.respond(optionId));
|
|
1477
|
+
this.host?.update(this.id, threadId, { sessionUpdate: "ls_permission_resolved", requestId, optionId });
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1276
1480
|
loadModels() {
|
|
1277
1481
|
this.models ??= this.rpc("model/list", { includeHidden: false })
|
|
1278
1482
|
.then((result) => result.data)
|
|
@@ -1285,7 +1489,7 @@ export class CodexDriver {
|
|
|
1285
1489
|
}
|
|
1286
1490
|
async announceConfig(nativeId) {
|
|
1287
1491
|
const models = await this.loadModels();
|
|
1288
|
-
const options = configOptions(this.settings.get(nativeId) ?? {}, this.
|
|
1492
|
+
const options = configOptions(this.settings.get(nativeId) ?? {}, this.overridesOf(nativeId), models);
|
|
1289
1493
|
if (options.length > 0 && this.isNews(`config:${nativeId}`, options))
|
|
1290
1494
|
this.host?.update(this.id, nativeId, { sessionUpdate: "ls_config", options });
|
|
1291
1495
|
}
|
|
@@ -1485,6 +1689,7 @@ export class CodexDriver {
|
|
|
1485
1689
|
return;
|
|
1486
1690
|
this.host?.log(`[codex] ${reason}`);
|
|
1487
1691
|
this.abandonApprovals("own");
|
|
1692
|
+
this.loseSides("own");
|
|
1488
1693
|
for (const threadId of [...this.attached]) {
|
|
1489
1694
|
// (What is joined in Codex's background server is still there.)
|
|
1490
1695
|
if (this.inShared.has(threadId))
|
|
@@ -1560,6 +1765,9 @@ export class CodexDriver {
|
|
|
1560
1765
|
const thread = params?.thread;
|
|
1561
1766
|
if (!thread?.id)
|
|
1562
1767
|
return;
|
|
1768
|
+
// A side conversation (ours, or one a TUI opened with /side): gone when it closes, never a session.
|
|
1769
|
+
if (thread.ephemeral)
|
|
1770
|
+
return;
|
|
1563
1771
|
if (thread.parentThreadId) {
|
|
1564
1772
|
// A sub-agent: its work shows inside the parent session, not as a session of its own.
|
|
1565
1773
|
this.subThreads.add(thread.id);
|
|
@@ -1583,6 +1791,12 @@ export class CodexDriver {
|
|
|
1583
1791
|
return;
|
|
1584
1792
|
}
|
|
1585
1793
|
const threadId = params?.threadId;
|
|
1794
|
+
const side = typeof threadId === "string" ? this.sideThreads.get(threadId) : undefined;
|
|
1795
|
+
if (side) {
|
|
1796
|
+
for (const mapped of mapNotification(method, params, (id) => this.stateOf(id)))
|
|
1797
|
+
this.sideUpdate(side, mapped.update);
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1586
1800
|
const held = typeof threadId === "string" ? this.joining.get(threadId) : undefined;
|
|
1587
1801
|
if (held) {
|
|
1588
1802
|
held.push({ method, params, from });
|
|
@@ -1660,6 +1874,14 @@ export class CodexDriver {
|
|
|
1660
1874
|
return ABANDON;
|
|
1661
1875
|
const requestId = from === "shared" ? sharedRequestId(String(id)) : String(id);
|
|
1662
1876
|
const mapped = QUESTION_METHODS.has(method) ? mapQuestionRequest(method, params, requestId) : mapApprovalRequest(method, params, requestId);
|
|
1877
|
+
const side = mapped && this.sideThreads.get(mapped.threadId);
|
|
1878
|
+
if (side && mapped) {
|
|
1879
|
+
// A side conversation changes nothing (it runs read-only and is told so): what it would need asking for is declined.
|
|
1880
|
+
const options = mapped.update.options;
|
|
1881
|
+
const decline = (options.find((option) => option.kind === "reject_once") ?? options[0]).optionId;
|
|
1882
|
+
side.out?.activity(`已拒绝:${mapped.update.title}(旁支对话只读)`);
|
|
1883
|
+
return mapped.answer ? mapped.answer([]) : mapped.respond(decline);
|
|
1884
|
+
}
|
|
1663
1885
|
// A sub-agent asking for permission asks in its parent session.
|
|
1664
1886
|
const request = mapped && this.children.has(mapped.threadId) ? { ...mapped, threadId: this.children.get(mapped.threadId).threadId } : mapped;
|
|
1665
1887
|
if (!request || !this.attached.has(request.threadId))
|
|
@@ -1668,9 +1890,18 @@ export class CodexDriver {
|
|
|
1668
1890
|
const toolTitle = method === "item/fileChange/requestApproval" && request.update.toolCallId && this.toolTitles.get(request.update.toolCallId);
|
|
1669
1891
|
if (toolTitle)
|
|
1670
1892
|
request.update = { ...request.update, title: toolTitle };
|
|
1893
|
+
const asking = params?.turnId;
|
|
1894
|
+
const turnId = mapped && mapped.threadId !== request.threadId ? this.stateOf(request.threadId).activeTurnId : typeof asking === "string" ? asking : undefined;
|
|
1895
|
+
const approved = this.autoApproval(method, request, turnId);
|
|
1896
|
+
if (approved) {
|
|
1897
|
+
this.host?.log(`[codex] ${method} in ${request.threadId} approved under the full access a device chose`);
|
|
1898
|
+
return request.respond(approved);
|
|
1899
|
+
}
|
|
1671
1900
|
return new Promise((resolve) => {
|
|
1672
1901
|
this.approvals.set(requestId, {
|
|
1673
1902
|
threadId: request.threadId,
|
|
1903
|
+
method,
|
|
1904
|
+
turnId,
|
|
1674
1905
|
request,
|
|
1675
1906
|
answer: resolve,
|
|
1676
1907
|
abandon: () => resolve(ABANDON),
|
|
@@ -1712,6 +1943,7 @@ export class CodexDriver {
|
|
|
1712
1943
|
return;
|
|
1713
1944
|
this.host?.log("[codex] lost Codex's background server");
|
|
1714
1945
|
this.abandonApprovals("shared");
|
|
1946
|
+
this.loseSides("shared");
|
|
1715
1947
|
const joined = [...this.inShared].filter((threadId) => this.attached.delete(threadId));
|
|
1716
1948
|
this.inShared.clear();
|
|
1717
1949
|
for (const threadId of joined) {
|