linkshell-cli 0.10.73 → 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.
Files changed (38) hide show
  1. package/dist/cli/src/commands/desktop.d.ts +2 -0
  2. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  3. package/dist/gateway/src/relay.d.ts +1 -1
  4. package/dist/gateway/src/relay.js +8 -1
  5. package/dist/gateway/src/relay.js.map +1 -1
  6. package/dist/host/src/drivers/acp/driver.d.ts +9 -1
  7. package/dist/host/src/drivers/acp/driver.js +44 -2
  8. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  9. package/dist/host/src/drivers/claude/driver.d.ts +16 -1
  10. package/dist/host/src/drivers/claude/driver.js +106 -1
  11. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  12. package/dist/host/src/drivers/codex/driver.d.ts +30 -2
  13. package/dist/host/src/drivers/codex/driver.js +241 -9
  14. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  15. package/dist/host/src/drivers/types.d.ts +32 -0
  16. package/dist/host/src/host.js +2 -1
  17. package/dist/host/src/host.js.map +1 -1
  18. package/dist/host/src/hub.d.ts +16 -1
  19. package/dist/host/src/hub.js +78 -0
  20. package/dist/host/src/hub.js.map +1 -1
  21. package/dist/host/src/rpc/server.js +11 -1
  22. package/dist/host/src/rpc/server.js.map +1 -1
  23. package/dist/host/src/side.d.ts +28 -0
  24. package/dist/host/src/side.js +142 -0
  25. package/dist/host/src/side.js.map +1 -0
  26. package/dist/wire/src/index.d.ts +1 -0
  27. package/dist/wire/src/index.js +1 -0
  28. package/dist/wire/src/index.js.map +1 -1
  29. package/dist/wire/src/model.d.ts +22 -0
  30. package/dist/wire/src/model.js +4 -0
  31. package/dist/wire/src/model.js.map +1 -1
  32. package/dist/wire/src/rpc.d.ts +330 -3
  33. package/dist/wire/src/rpc.js +35 -0
  34. package/dist/wire/src/rpc.js.map +1 -1
  35. package/dist/wire/src/side.d.ts +83 -0
  36. package/dist/wire/src/side.js +26 -0
  37. package/dist/wire/src/side.js.map +1 -0
  38. 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.overrides.get(threadId) ?? {};
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.overrides.get(nativeId) ?? {}, await this.loadModels());
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.overrides.get(nativeId) ?? {};
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.overrides.get(nativeId) ?? {}, models);
1236
- const next = { ...this.overrides.get(nativeId) };
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.overrides.set(nativeId, next);
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.overrides.get(nativeId) ?? {}, models);
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) {