@unlinearity/cli-wechat-bridge 1.2.1 → 1.2.3

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 (54) hide show
  1. package/README.md +146 -73
  2. package/README_EN.md +544 -0
  3. package/bin/telegram-check-update.mjs +10 -0
  4. package/bin/telegram-claude.mjs +10 -0
  5. package/bin/telegram-codex.mjs +10 -0
  6. package/bin/telegram-daemon.mjs +10 -0
  7. package/bin/telegram-opencode.mjs +10 -0
  8. package/bin/telegram-pi.mjs +10 -0
  9. package/bin/telegram-setup.mjs +10 -0
  10. package/dist/bridge/adapter-control.js +19 -12
  11. package/dist/bridge/bridge-adapters.claude.js +219 -115
  12. package/dist/bridge/bridge-adapters.codex.js +532 -99
  13. package/dist/bridge/bridge-adapters.core.js +84 -7
  14. package/dist/bridge/bridge-adapters.opencode.js +202 -55
  15. package/dist/bridge/bridge-adapters.pi.js +23 -18
  16. package/dist/bridge/bridge-adapters.shared.js +9 -9
  17. package/dist/bridge/bridge-session-resume.js +13 -6
  18. package/dist/bridge/bridge-state.js +3 -1
  19. package/dist/bridge/bridge-utils.js +94 -144
  20. package/dist/bridge/claude-hooks.js +60 -3
  21. package/dist/bridge/claude-native-controls.js +19 -12
  22. package/dist/bridge/language-control.js +33 -0
  23. package/dist/bridge/native-terminal-control.js +26 -10
  24. package/dist/bridge/opencode-v2-compat.js +11 -3
  25. package/dist/bridge/wechat-bridge.js +154 -72
  26. package/dist/channels/channel-driver-factory.js +56 -0
  27. package/dist/channels/local/local-channel-port.js +40 -0
  28. package/dist/channels/local/local-driver.js +217 -0
  29. package/dist/channels/telegram/setup-cli.js +12 -0
  30. package/dist/channels/telegram/setup.js +163 -0
  31. package/dist/channels/telegram/telegram-channel-port.js +47 -0
  32. package/dist/channels/telegram/telegram-config.js +66 -0
  33. package/dist/channels/telegram/telegram-driver.js +106 -0
  34. package/dist/channels/telegram/telegram-message.js +41 -0
  35. package/dist/channels/telegram/telegram-transport.js +378 -0
  36. package/dist/channels/wechat/wechat-driver.js +11 -2
  37. package/dist/channels/wecom/wecom-driver.js +8 -1
  38. package/dist/companion/local-companion-start.js +180 -61
  39. package/dist/companion/local-companion.js +27 -7
  40. package/dist/companion/pi-tui-bridge-extension.js +10 -4
  41. package/dist/core/bridge-control.js +3 -0
  42. package/dist/core/bridge-defer.js +3 -2
  43. package/dist/core/bridge-event-forwarder.js +3 -0
  44. package/dist/core/channel-types.js +13 -1
  45. package/dist/daemon/wechat-daemon.js +239 -126
  46. package/dist/i18n/index.js +25 -11
  47. package/dist/i18n/messages-en.js +245 -1
  48. package/dist/i18n/messages-zh.js +245 -1
  49. package/dist/runtime/legacy-adapter-runtime.js +7 -4
  50. package/dist/wechat/channel-config.js +3 -2
  51. package/package.json +14 -4
  52. package/skills/codex-bridge/SKILL.md +35 -0
  53. package/skills/codex-bridge/agents/openai.yaml +4 -0
  54. package/skills/codex-bridge/scripts/install.mjs +28 -0
@@ -5,9 +5,13 @@
5
5
  // must be released under AGPL-3.0-or-later with full source code; see
6
6
  // LICENSE.txt. Network services built on it must offer source to users.
7
7
  import fs from "node:fs";
8
+ import { t } from "../i18n/index.js";
9
+ import net from "node:net";
10
+ import os from "node:os";
8
11
  import path from "node:path";
9
12
  import crypto from "node:crypto";
10
13
  import { spawn as spawnChild, spawnSync } from "node:child_process";
14
+ import { WebSocket as WsPackageWebSocket } from "ws";
11
15
  import { detectCliApproval, } from "./bridge-utils.js";
12
16
  import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
13
17
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
@@ -29,7 +33,87 @@ export function isCodexVersionInCompatibilityRange(version) {
29
33
  }
30
34
  const major = parts[0] ?? -1;
31
35
  const minor = parts[1] ?? -1;
32
- return major === 0 && minor >= 149 && minor <= 157;
36
+ return major === 0 && minor >= 149 && minor <= 161;
37
+ }
38
+ /** Resolve the shared Codex app-server daemon control socket path. */
39
+ export function resolveCodexDaemonControlSocketPath(codexHomeEnv = process.env.CODEX_HOME) {
40
+ const codexHome = codexHomeEnv && codexHomeEnv.trim()
41
+ ? codexHomeEnv
42
+ : path.join(os.homedir(), ".codex");
43
+ return path.join(codexHome, "app-server-control", "app-server-control.sock");
44
+ }
45
+ /**
46
+ * Pick attachable threads from a `thread/list` response: only threads that are
47
+ * live in the shared daemon (`status.type !== "notLoaded"`), belong to the
48
+ * requested cwd, are root threads, accept direct input, and were not started
49
+ * by a bridge (`originator === "wechat-bridge"`). Newest activity wins.
50
+ */
51
+ export function selectCodexDaemonAttachCandidates(response, cwd) {
52
+ if (!isRecord(response) || !Array.isArray(response.data)) {
53
+ return [];
54
+ }
55
+ const candidates = [];
56
+ for (const entry of response.data) {
57
+ if (!isRecord(entry))
58
+ continue;
59
+ const id = typeof entry.id === "string" ? entry.id : null;
60
+ if (!id)
61
+ continue;
62
+ const threadCwd = typeof entry.cwd === "string" ? entry.cwd : null;
63
+ if (!threadCwd || normalizeComparablePath(threadCwd) !== normalizeComparablePath(cwd)) {
64
+ continue;
65
+ }
66
+ if (entry.parentThreadId !== null && entry.parentThreadId !== undefined)
67
+ continue;
68
+ if (entry.canAcceptDirectInput === false)
69
+ continue;
70
+ const status = isRecord(entry.status) ? entry.status.type : undefined;
71
+ if (typeof status !== "string" || status === "notLoaded")
72
+ continue;
73
+ const originator = typeof entry.originator === "string" ? entry.originator : null;
74
+ if (originator === "wechat-bridge")
75
+ continue;
76
+ const updatedAt = typeof entry.updatedAt === "number"
77
+ ? entry.updatedAt
78
+ : typeof entry.lastUpdatedAt === "number"
79
+ ? entry.lastUpdatedAt
80
+ : 0;
81
+ candidates.push({ id, updatedAt, originator });
82
+ }
83
+ return candidates.sort((left, right) => right.updatedAt - left.updatedAt);
84
+ }
85
+ /**
86
+ * Resume params for the bridge's app-server client. In daemon-attach mode the
87
+ * thread already has an owner with its own settings; sending cwd/approval/
88
+ * sandbox overrides would rewrite the shared thread's settings
89
+ * (`thread/settings/updated`), so attach mode resumes without overrides.
90
+ */
91
+ export function buildCodexThreadResumeParams(params) {
92
+ if (params.attachDaemon) {
93
+ return { threadId: params.threadId, excludeTurns: true };
94
+ }
95
+ return {
96
+ threadId: params.threadId,
97
+ cwd: params.cwd,
98
+ approvalPolicy: "on-request",
99
+ approvalsReviewer: "user",
100
+ sandbox: "workspace-write",
101
+ excludeTurns: true,
102
+ };
103
+ }
104
+ /** Turn params; daemon-attach mode inherits the thread's existing settings. */
105
+ export function buildCodexTurnStartParams(params) {
106
+ const input = [{ type: "text", text: params.text }];
107
+ if (params.attachDaemon) {
108
+ return { threadId: params.threadId, input };
109
+ }
110
+ return {
111
+ threadId: params.threadId,
112
+ cwd: params.cwd,
113
+ approvalPolicy: "on-request",
114
+ approvalsReviewer: "user",
115
+ input,
116
+ };
33
117
  }
34
118
  const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_READY_TIMEOUT_MS, CODEX_FINAL_REPLY_SETTLE_DELAY_MS, CODEX_RECENT_SESSION_KEY_LIMIT, CODEX_RPC_CONNECT_RETRY_MS, CODEX_RPC_RECONNECT_TIMEOUT_MS, CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS, CODEX_SESSION_POLL_INTERVAL_MS, CODEX_STARTUP_WARMUP_MS, CODEX_THREAD_SIGNAL_TTL_MS, INTERRUPT_SETTLE_DELAY_MS, appendBoundedLog, buildCodexApprovalRequest, buildCodexCliArgs, buildCodexDynamicToolCallFailureResponse, buildCodexMcpServerElicitationDeclineResponse, buildCodexPermissionsRequestApprovalResponse, buildCodexUserInputRequest, coerceWebSocketMessageData, delay, describeUnknownError, extractCodexFinalTextFromItem, extractCodexThreadFollowIdFromStatusChanged, extractCodexThreadStartedThreadId, extractCodexUserMessageText, findCodexSessionFile, findRecentCodexSessionFileForCwd, getCodexRpcRequestId, getCodexApprovalAutoResponse, getCodexWechatOutboundAttachmentDenyMessage, getNotificationThreadId, getNotificationTurnId, isRecord, isRecentIsoTimestamp, listCodexResumeSessions, normalizeComparablePath, normalizeCodexRpcError, reserveLocalPort, resolveSpawnTarget, shouldAutoCompleteCodexWechatTurnAfterFinalReply, shouldIgnoreCodexSessionReplayEntry, shouldRecoverCodexStaleBusyState, waitForTcpPort, } = shared;
35
119
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
@@ -95,6 +179,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
95
179
  rpcRequestCounter = 0;
96
180
  pendingRpcRequests = new Map();
97
181
  subscribedThreadIds = new Set();
182
+ backgroundThreadIds = new Set();
98
183
  pendingThreadSubscriptions = new Map();
99
184
  sharedThreadId = null;
100
185
  previousCollaborationMode = null;
@@ -114,6 +199,11 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
114
199
  bridgeResumeReplayThreadId = null;
115
200
  bridgeResumeReplayUntilMs = 0;
116
201
  pendingApprovalRequests = [];
202
+ settledApprovalIds = new Set();
203
+ discoveringApprovalIds = new Set();
204
+ approvalGeneration = 0;
205
+ resolvingApprovalIds = new Set();
206
+ backgroundThreadParents = new Map();
117
207
  pendingUserInputRequest = null;
118
208
  pendingUserInputTimer = null;
119
209
  userInputAutoResolutionDelayMs = CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS;
@@ -145,9 +235,16 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
145
235
  localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
146
236
  constructor(options) {
147
237
  super(options);
238
+ this.state.supportsApprovalRequestIds = true;
239
+ this.state.pendingApprovals = [];
148
240
  this.resumeThreadId = options.sessionStartMode === "new"
149
241
  ? null
150
242
  : options.initialSharedSessionId ?? options.initialSharedThreadId ?? null;
243
+ if (options.codexAttachDaemon) {
244
+ // Daemon attach binds an explicit (or discovered) live thread; stale
245
+ // persisted session ids must not leak into the attach target.
246
+ this.resumeThreadId = options.codexAttachThreadId?.trim() || null;
247
+ }
151
248
  if (this.resumeThreadId && options.renderMode !== "panel") {
152
249
  this.state.sharedSessionId = this.resumeThreadId;
153
250
  this.state.sharedThreadId = this.resumeThreadId;
@@ -167,6 +264,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
167
264
  : `outside-supported-range=${this.codexCliVersion}`
168
265
  : "unknown-version";
169
266
  this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
267
+ if (this.options.codexAttachDaemon) {
268
+ await this.startDaemonAttach();
269
+ return;
270
+ }
170
271
  await this.startAppServer();
171
272
  await this.connectRpcClient();
172
273
  try {
@@ -236,23 +337,23 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
236
337
  return;
237
338
  }
238
339
  if (!this.pty) {
239
- throw new Error("codex adapter is not running.");
340
+ throw new Error(t("adapter.codex.notRunning"));
240
341
  }
241
342
  if (this.state.status === "busy") {
242
- throw new Error("codex is still working. Wait for the current reply or use /stop.");
343
+ throw new Error(t("adapter.codex.busy"));
243
344
  }
244
345
  if (this.pendingApproval) {
245
- throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
346
+ throw new Error(t("adapter.codex.approvalPending"));
246
347
  }
247
348
  if (this.startupBlocker) {
248
- throw new Error("Codex is waiting for local terminal input before the session can continue.");
349
+ throw new Error(t("adapter.codex.localInputPending"));
249
350
  }
250
351
  await delay(this.warmupUntilMs - Date.now());
251
352
  if (!this.pty) {
252
- throw new Error("codex adapter is not running.");
353
+ throw new Error(t("adapter.codex.notRunning"));
253
354
  }
254
355
  if (this.startupBlocker) {
255
- throw new Error("Codex is waiting for local terminal input before the session can continue.");
356
+ throw new Error(t("adapter.codex.localInputPending"));
256
357
  }
257
358
  this.clearInterruptTimer();
258
359
  this.hasAcceptedInput = true;
@@ -275,11 +376,59 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
275
376
  if (!this.isHeadlessRuntimeMode()) {
276
377
  throw new Error('WeChat /resume requires the managed "wechat-codex" visible client.');
277
378
  }
379
+ if (this.options.codexAttachDaemon) {
380
+ await this.resumeDaemonAttachedThread(threadId);
381
+ return;
382
+ }
278
383
  await this.resumeVisibleSharedThread(threadId);
279
384
  }
385
+ /**
386
+ * WeChat /resume in daemon-attach mode: rejoin another thread on the shared
387
+ * daemon. Live threads rejoin in place; cold threads are loaded by the
388
+ * daemon (the bridge never becomes the rollout writer for an embedded
389
+ * desktop session — that resume fails with an active-writer error).
390
+ */
391
+ async resumeDaemonAttachedThread(threadId) {
392
+ const trimmedThreadId = threadId.trim();
393
+ if (!trimmedThreadId) {
394
+ throw new Error("A thread id is required to resume a Codex thread.");
395
+ }
396
+ if (this.pendingApproval) {
397
+ throw new Error(t("adapter.codex.approvalPending"));
398
+ }
399
+ if (this.pendingTurnStart ||
400
+ this.activeTurn ||
401
+ this.state.status === "busy" ||
402
+ this.state.status === "awaiting_approval") {
403
+ throw new Error(t("adapter.codex.busy"));
404
+ }
405
+ const response = await this.sendRpcRequest("thread/resume", buildCodexThreadResumeParams({
406
+ threadId: trimmedThreadId,
407
+ cwd: this.options.cwd,
408
+ attachDaemon: true,
409
+ }));
410
+ this.validateCodexResumeThread(response, trimmedThreadId);
411
+ if (isRecord(response) && isRecord(response.thread) && typeof response.thread.path === "string") {
412
+ this.sessionFilePath = response.thread.path;
413
+ }
414
+ const previousThreadId = this.sharedThreadId;
415
+ this.subscribedThreadIds.add(trimmedThreadId);
416
+ this.sessionReadOffset = 0;
417
+ this.sessionPartialLine = "";
418
+ this.sessionFinalText = null;
419
+ this.pendingThreadFollowId = null;
420
+ this.updateSharedThread(trimmedThreadId, {
421
+ source: "wechat",
422
+ reason: "wechat_resume",
423
+ notify: true,
424
+ });
425
+ if (previousThreadId && previousThreadId !== trimmedThreadId) {
426
+ await this.unsubscribeCodexThreadBestEffort(previousThreadId);
427
+ }
428
+ }
280
429
  async listModels() {
281
430
  if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
282
- throw new Error("Codex model selection requires an active app-server thread.");
431
+ throw new Error(t("adapter.codex.modelThreadRequired"));
283
432
  }
284
433
  const response = await this.sendRpcRequest("model/list", { includeHidden: false });
285
434
  const data = isRecord(response) && Array.isArray(response.data) ? response.data : [];
@@ -303,14 +452,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
303
452
  const models = await this.listModels();
304
453
  const selected = models.find((model) => model.id === modelId);
305
454
  if (!selected)
306
- throw new Error(`Codex model ${modelId} is not available.`);
455
+ throw new Error(t("adapter.codex.modelUnavailable", { model: modelId }));
307
456
  await this.sendRpcRequest("thread/settings/update", { threadId: this.sharedThreadId, model: selected.id });
308
457
  return selected;
309
458
  }
310
459
  async setPlanMode(enabled) {
311
460
  this.assertVisibleCodexWorkspace();
312
461
  if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
313
- throw new Error("Codex plan mode requires an active app-server thread.");
462
+ throw new Error(t("adapter.codex.planThreadRequired"));
314
463
  }
315
464
  const settings = await this.readCodexThreadSettings();
316
465
  if (enabled) {
@@ -318,7 +467,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
318
467
  const modes = isRecord(response) && Array.isArray(response.data) ? response.data : [];
319
468
  const plan = modes.find((item) => isRecord(item) && (item.mode === "plan" || item.name?.toString().toLowerCase() === "plan"));
320
469
  if (!isRecord(plan))
321
- throw new Error("Codex plan mode is not available in this app-server.");
470
+ throw new Error(t("adapter.codex.planUnavailable"));
322
471
  this.previousCollaborationMode = isRecord(settings.collaborationMode) ? settings.collaborationMode : null;
323
472
  const mode = typeof plan.mode === "string" ? plan.mode : "plan";
324
473
  const model = typeof plan.model === "string" ? plan.model : settings.model;
@@ -344,7 +493,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
344
493
  const response = await this.sendRpcRequest("thread/read", { threadId: this.sharedThreadId, includeTurns: false });
345
494
  const thread = isRecord(response) && isRecord(response.thread) ? response.thread : null;
346
495
  if (!thread || typeof thread.model !== "string")
347
- throw new Error("Codex did not return the current thread model.");
496
+ throw new Error(t("adapter.codex.modelMissing"));
348
497
  return { model: thread.model, effort: thread.reasoningEffort, collaborationMode: thread.collaborationMode };
349
498
  }
350
499
  async interrupt() {
@@ -362,35 +511,38 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
362
511
  this.armInterruptFallback();
363
512
  return true;
364
513
  }
365
- async resolveApproval(action) {
366
- if (!this.pendingApproval && this.pendingApprovalRequests.length === 0) {
367
- return false;
514
+ async resolveApproval(action, requestId) {
515
+ const pending = requestId === undefined
516
+ ? this.pendingApprovalRequests[0]
517
+ : this.pendingApprovalRequests.find((request) => this.rpcRequestKey(request.requestId) === requestId);
518
+ if (!pending) {
519
+ return this.pendingApprovalRequests.length ? false : await super.resolveApproval(action, requestId);
368
520
  }
369
- if (this.pendingApprovalRequests.length > 0 && this.rpcSocket) {
370
- for (const request of this.pendingApprovalRequests) {
371
- await this.respondToApprovalRequest(request, action);
372
- }
373
- this.clearPendingApprovalState();
374
- this.setStatus("busy");
521
+ const id = this.rpcRequestKey(pending.requestId);
522
+ if (this.resolvingApprovalIds.has(id))
523
+ return false;
524
+ this.resolvingApprovalIds.add(id);
525
+ try {
526
+ await this.respondToApprovalRequest(pending, action);
527
+ this.rememberSettledApproval(pending.requestId);
528
+ this.pendingApprovalRequests = this.pendingApprovalRequests.filter((request) => request !== pending);
529
+ this.syncPendingApprovals();
375
530
  return true;
376
531
  }
377
- return await super.resolveApproval(action);
532
+ finally {
533
+ this.resolvingApprovalIds.delete(id);
534
+ }
378
535
  }
379
536
  async resolveAllApprovals(action) {
380
- if (this.pendingApprovalRequests.length === 0 && !this.pendingApproval) {
381
- return 0;
537
+ if (!this.pendingApprovalRequests.length)
538
+ return await super.resolveAllApprovals(action);
539
+ let count = 0;
540
+ const ids = this.pendingApprovalRequests.map((request) => this.rpcRequestKey(request.requestId));
541
+ for (const id of ids) {
542
+ if (await this.resolveApproval(action, id))
543
+ count++;
382
544
  }
383
- if (this.pendingApprovalRequests.length > 0 && this.rpcSocket) {
384
- const count = this.pendingApprovalRequests.length;
385
- for (const request of this.pendingApprovalRequests) {
386
- await this.respondToApprovalRequest(request, action);
387
- }
388
- this.clearPendingApprovalState();
389
- this.setStatus("busy");
390
- return count;
391
- }
392
- const ok = await super.resolveApproval(action);
393
- return ok ? 1 : 0;
545
+ return count;
394
546
  }
395
547
  async submitUserInput(answers) {
396
548
  if (!this.pendingUserInputRequest) {
@@ -410,7 +562,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
410
562
  },
411
563
  });
412
564
  this.clearPendingUserInputState();
413
- this.setStatus("busy", "Codex user input submitted.");
565
+ this.setStatus(this.pendingApprovalRequests.length ? "awaiting_approval" : "busy", "Codex user input submitted.");
414
566
  return true;
415
567
  }
416
568
  async dispose() {
@@ -499,11 +651,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
499
651
  return;
500
652
  }
501
653
  this.state.lastOutputAt = nowIso();
502
- const approval = detectCliApproval(text);
654
+ const detectedApproval = detectCliApproval(text);
655
+ const approval = detectedApproval ? { ...detectedApproval, requestId: crypto.randomUUID() } : null;
503
656
  if (this.hasAcceptedInput) {
504
657
  if (approval && !this.pendingApproval) {
505
658
  this.pendingApproval = approval;
506
659
  this.state.pendingApproval = approval;
660
+ this.state.pendingApprovals = [approval];
507
661
  this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
508
662
  this.setStatus("awaiting_approval", "Codex approval is required.");
509
663
  this.emit({
@@ -550,6 +704,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
550
704
  }
551
705
  isCodexClientRunning() {
552
706
  if (this.isHeadlessRuntimeMode()) {
707
+ if (this.options.codexAttachDaemon) {
708
+ return Boolean(this.rpcSocket);
709
+ }
553
710
  return Boolean(this.appServer);
554
711
  }
555
712
  return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
@@ -1037,22 +1194,22 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1037
1194
  async sendPanelTurn(text) {
1038
1195
  this.assertVisibleCodexWorkspace();
1039
1196
  if (this.isNativePanelMode() && !this.nativeProcess) {
1040
- throw new Error("codex panel is not running.");
1197
+ throw new Error(t("adapter.codex.panelNotRunning"));
1041
1198
  }
1042
1199
  this.recoverStaleBusyStateIfNeeded();
1043
1200
  this.recoverStaleActiveTurnStateIfNeeded();
1044
1201
  if (this.pendingApproval) {
1045
- throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
1202
+ throw new Error(t("adapter.codex.approvalPending"));
1046
1203
  }
1047
1204
  if (this.pendingUserInputRequest || this.state.pendingUserInput) {
1048
- throw new Error("Codex is waiting for user input. Reply with /answer and your response, or use /stop.");
1205
+ throw new Error(t("adapter.codex.inputPending"));
1049
1206
  }
1050
1207
  if (this.pendingTurnStart || this.activeTurn || this.state.status === "busy") {
1051
1208
  const origin = this.state.activeTurnOrigin;
1052
1209
  if (origin === "local") {
1053
- throw new Error("The local Codex panel is still working. Wait for the current reply or use /stop.");
1210
+ throw new Error(t("adapter.codex.localBusy"));
1054
1211
  }
1055
- throw new Error("codex is still working. Wait for the current reply or use /stop.");
1212
+ throw new Error(t("adapter.codex.busy"));
1056
1213
  }
1057
1214
  this.clearInterruptTimer();
1058
1215
  this.hasAcceptedInput = true;
@@ -1068,18 +1225,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1068
1225
  this.state.activeTurnOrigin = "wechat";
1069
1226
  this.setStatus("busy");
1070
1227
  try {
1071
- const response = await this.sendRpcRequest("turn/start", {
1228
+ const response = await this.sendRpcRequest("turn/start", buildCodexTurnStartParams({
1072
1229
  threadId,
1230
+ text,
1073
1231
  cwd: this.options.cwd,
1074
- approvalPolicy: "on-request",
1075
- approvalsReviewer: "user",
1076
- input: [
1077
- {
1078
- type: "text",
1079
- text,
1080
- },
1081
- ],
1082
- });
1232
+ attachDaemon: Boolean(this.options.codexAttachDaemon),
1233
+ }));
1083
1234
  const turnId = this.extractTurnIdFromResponse(response);
1084
1235
  if (!turnId) {
1085
1236
  throw new Error("Codex did not return a turn id for the requested turn.");
@@ -1133,6 +1284,178 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1133
1284
  this.armInterruptFallback();
1134
1285
  return true;
1135
1286
  }
1287
+ /**
1288
+ * Daemon-attach mode: instead of spawning an owned app-server, join the
1289
+ * shared Codex app-server daemon (codex >= 0.162) as an additional client
1290
+ * and rebind the bridge to a live thread in this workspace. The daemon and
1291
+ * the owning TUI keep their lifecycle; the bridge is a guest connection.
1292
+ */
1293
+ async startDaemonAttach() {
1294
+ this.setStatus("starting", "Attaching to the shared Codex app-server daemon...");
1295
+ await this.connectDaemonRpcClient();
1296
+ try {
1297
+ await this.attachDaemonThread();
1298
+ }
1299
+ catch (error) {
1300
+ await this.disconnectRpcClient();
1301
+ throw error;
1302
+ }
1303
+ this.shuttingDown = false;
1304
+ this.cleanPanelExitInProgress = false;
1305
+ this.hasAcceptedInput = true;
1306
+ this.state.startedAt = nowIso();
1307
+ this.state.pendingApproval = null;
1308
+ this.afterStart();
1309
+ this.setStatus("idle", "Attached to the live Codex daemon session.");
1310
+ }
1311
+ async connectDaemonRpcClient() {
1312
+ if (this.rpcSocket) {
1313
+ return;
1314
+ }
1315
+ const socket = await this.openDaemonRpcSocket();
1316
+ this.attachRpcSocket(socket);
1317
+ await this.initializeRpcClient();
1318
+ }
1319
+ async openDaemonRpcSocket() {
1320
+ const socketPath = resolveCodexDaemonControlSocketPath();
1321
+ if (!fs.existsSync(socketPath)) {
1322
+ throw new Error(`No shared Codex app-server daemon socket at ${socketPath}. Attach mode requires Codex >= 0.162 with its shared background server (the default). Sessions started with --no-daemon, --oss, or a --profile, and desktop-app embedded sessions, cannot be attached.`);
1323
+ }
1324
+ return await new Promise((resolve, reject) => {
1325
+ // ws 8.22 ignores the `agent` option and installs its own TCP
1326
+ // createConnection; passing createConnection at the top level is
1327
+ // forwarded to http.request and takes precedence, which is how the
1328
+ // Unix control socket is dialed.
1329
+ const socket = new WsPackageWebSocket("ws://localhost/", {
1330
+ createConnection: () => net.connect(socketPath),
1331
+ });
1332
+ let settled = false;
1333
+ const timer = setTimeout(() => {
1334
+ if (settled) {
1335
+ return;
1336
+ }
1337
+ settled = true;
1338
+ try {
1339
+ socket.close();
1340
+ }
1341
+ catch {
1342
+ // Best effort cleanup after timeout.
1343
+ }
1344
+ reject(new Error(`Timed out connecting to the Codex daemon socket ${socketPath}.`));
1345
+ }, CODEX_APP_SERVER_READY_TIMEOUT_MS);
1346
+ const cleanup = () => {
1347
+ clearTimeout(timer);
1348
+ };
1349
+ socket.once("open", () => {
1350
+ if (settled) {
1351
+ return;
1352
+ }
1353
+ settled = true;
1354
+ cleanup();
1355
+ resolve(socket);
1356
+ });
1357
+ socket.once("error", (error) => {
1358
+ if (settled) {
1359
+ return;
1360
+ }
1361
+ settled = true;
1362
+ cleanup();
1363
+ reject(new Error(`Failed to connect to the Codex daemon socket ${socketPath}: ${String(error)}`));
1364
+ });
1365
+ });
1366
+ }
1367
+ /**
1368
+ * Bind the bridge to a live daemon thread. An explicit thread id wins;
1369
+ * otherwise the newest live interactive thread in the workspace cwd is
1370
+ * selected. `thread/resume` on a running thread rejoins it (history plus a
1371
+ * live subscription) without taking the rollout writer lock, which stays
1372
+ * with the owning client.
1373
+ */
1374
+ async attachDaemonThread() {
1375
+ const targetThreadId = this.resumeThreadId ?? await this.discoverDaemonAttachThreadId();
1376
+ this.setStatus("starting", `Attaching Codex daemon thread ${targetThreadId.slice(0, 12)}...`);
1377
+ const response = await this.sendRpcRequest("thread/resume", buildCodexThreadResumeParams({
1378
+ threadId: targetThreadId,
1379
+ cwd: this.options.cwd,
1380
+ attachDaemon: true,
1381
+ }));
1382
+ const resumedThreadId = this.extractThreadIdFromResponse(response);
1383
+ if (!resumedThreadId) {
1384
+ throw new Error("Codex daemon did not confirm the attached thread id.");
1385
+ }
1386
+ if (resumedThreadId !== targetThreadId) {
1387
+ throw new Error(`Codex daemon opened ${resumedThreadId}, expected ${targetThreadId}.`);
1388
+ }
1389
+ this.validateCodexThreadWorkspaceIfPresent(response, resumedThreadId);
1390
+ if (isRecord(response) && isRecord(response.thread) && typeof response.thread.path === "string") {
1391
+ this.sessionFilePath = response.thread.path;
1392
+ }
1393
+ this.subscribedThreadIds.add(resumedThreadId);
1394
+ this.resumeThreadId = null;
1395
+ this.updateSharedThread(resumedThreadId, {
1396
+ source: "restore",
1397
+ reason: "startup_restore",
1398
+ notify: true,
1399
+ });
1400
+ }
1401
+ async discoverDaemonAttachThreadId() {
1402
+ const response = await this.sendRpcRequest("thread/list", {
1403
+ limit: 50,
1404
+ sortKey: "updated_at",
1405
+ sortDirection: "desc",
1406
+ sourceKinds: ["cli", "vscode"],
1407
+ archived: false,
1408
+ cwd: this.options.cwd,
1409
+ });
1410
+ const candidates = selectCodexDaemonAttachCandidates(response, this.options.cwd);
1411
+ if (candidates.length === 0) {
1412
+ throw new Error(`No live Codex daemon session found for ${this.options.cwd}. Open codex (>= 0.162, default shared-server mode) in this directory, then retry. Embedded sessions (desktop app, --no-daemon, --oss, --profile) cannot be attached.`);
1413
+ }
1414
+ if (candidates.length > 1) {
1415
+ const listed = candidates
1416
+ .slice(0, 5)
1417
+ .map((candidate) => candidate.id.slice(0, 8))
1418
+ .join(", ");
1419
+ this.setStatus("starting", `Multiple live Codex daemon sessions for ${this.options.cwd}; attaching the newest (${listed}).`);
1420
+ }
1421
+ return candidates[0].id;
1422
+ }
1423
+ /** Reconnect after the daemon socket dropped unexpectedly. */
1424
+ async reconnectDaemonRpcClient() {
1425
+ const reconnectDeadline = Date.now() + CODEX_RPC_RECONNECT_TIMEOUT_MS;
1426
+ let lastError = "Codex daemon connection closed.";
1427
+ while (!this.shuttingDown &&
1428
+ !this.cleanPanelExitInProgress &&
1429
+ Date.now() < reconnectDeadline) {
1430
+ try {
1431
+ await this.connectDaemonRpcClient();
1432
+ // The daemon may have restarted; rejoin the bound thread. Cold threads
1433
+ // are reloaded from disk, so this also recovers after daemon restarts.
1434
+ if (this.state.sharedThreadId) {
1435
+ await this.ensureSharedThreadSubscribed(this.state.sharedThreadId);
1436
+ }
1437
+ return true;
1438
+ }
1439
+ catch (error) {
1440
+ lastError = describeUnknownError(error);
1441
+ await delay(CODEX_RPC_CONNECT_RETRY_MS);
1442
+ }
1443
+ }
1444
+ if (shouldSuppressCodexTransportFatalError({
1445
+ transportShuttingDown: this.rpcShuttingDown,
1446
+ shuttingDown: this.shuttingDown,
1447
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
1448
+ })) {
1449
+ return false;
1450
+ }
1451
+ this.emit({
1452
+ type: "fatal_error",
1453
+ message: `Lost the connection to the shared Codex app-server daemon (${this.lastRpcCloseDetail || lastError}). The attached session may have closed; restart the attach bridge.`,
1454
+ timestamp: nowIso(),
1455
+ });
1456
+ this.terminateCodexClient();
1457
+ return false;
1458
+ }
1136
1459
  async startAppServer() {
1137
1460
  if (this.appServer) {
1138
1461
  return;
@@ -1362,6 +1685,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1362
1685
  cleanPanelExitInProgress: this.cleanPanelExitInProgress,
1363
1686
  });
1364
1687
  this.rpcSocket = null;
1688
+ this.clearPendingApprovalState();
1365
1689
  this.lastRpcCloseDetail = `close_code=${code ?? "unknown"}${reason ? ` reason=${truncatePreview(reason, 240)}` : ""}`;
1366
1690
  this.subscribedThreadIds.clear();
1367
1691
  this.rejectPendingRpcRequests("Codex websocket connection closed.");
@@ -1383,6 +1707,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1383
1707
  })) {
1384
1708
  return false;
1385
1709
  }
1710
+ if (this.options.codexAttachDaemon) {
1711
+ return await this.reconnectDaemonRpcClient();
1712
+ }
1386
1713
  if (!this.appServer || !this.appServerPort) {
1387
1714
  if (shouldSuppressCodexTransportFatalError({
1388
1715
  transportShuttingDown: this.appServerShuttingDown,
@@ -1524,14 +1851,11 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1524
1851
  if (this.subscribedThreadIds.has(threadId)) {
1525
1852
  return;
1526
1853
  }
1527
- const response = await this.sendRpcRequest("thread/resume", {
1854
+ const response = await this.sendRpcRequest("thread/resume", buildCodexThreadResumeParams({
1528
1855
  threadId,
1529
1856
  cwd: this.options.cwd,
1530
- approvalPolicy: "on-request",
1531
- approvalsReviewer: "user",
1532
- sandbox: "workspace-write",
1533
- excludeTurns: true,
1534
- });
1857
+ attachDaemon: Boolean(this.options.codexAttachDaemon),
1858
+ }));
1535
1859
  const resumedThreadId = this.extractThreadIdFromResponse(response);
1536
1860
  if (!resumedThreadId) {
1537
1861
  throw new Error("Codex did not return a thread id while subscribing the bridge client.");
@@ -1624,20 +1948,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1624
1948
  throw new Error("A thread id is required to resume a Codex thread.");
1625
1949
  }
1626
1950
  if (this.pendingVisibleResume) {
1627
- throw new Error("Codex is already switching visible threads.");
1951
+ throw new Error(t("adapter.codex.switching"));
1628
1952
  }
1629
1953
  if (this.pendingApproval || this.pendingApprovalRequests.length > 0) {
1630
- throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
1954
+ throw new Error(t("adapter.codex.approvalPending"));
1631
1955
  }
1632
1956
  if (this.pendingUserInputRequest || this.state.pendingUserInput) {
1633
- throw new Error("Codex is waiting for user input. Reply with /answer or use /stop.");
1957
+ throw new Error(t("adapter.codex.inputPendingShort"));
1634
1958
  }
1635
1959
  if (this.pendingTurnStart ||
1636
1960
  this.activeTurn ||
1637
1961
  this.state.status === "busy" ||
1638
1962
  this.state.status === "awaiting_approval" ||
1639
1963
  this.state.status === "awaiting_input") {
1640
- throw new Error("codex is still working. Wait for the current reply or use /stop.");
1964
+ throw new Error(t("adapter.codex.busy"));
1641
1965
  }
1642
1966
  if (this.state.status !== "idle") {
1643
1967
  throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
@@ -1722,14 +2046,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1722
2046
  throw new Error("A thread id is required to resume a Codex thread.");
1723
2047
  }
1724
2048
  if (this.pendingApproval) {
1725
- throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
2049
+ throw new Error(t("adapter.codex.approvalPending"));
1726
2050
  }
1727
2051
  if (!options.startup &&
1728
2052
  (this.pendingTurnStart ||
1729
2053
  this.activeTurn ||
1730
2054
  this.state.status === "busy" ||
1731
2055
  this.state.status === "awaiting_approval")) {
1732
- throw new Error("codex is still working. Wait for the current reply or use /stop.");
2056
+ throw new Error(t("adapter.codex.busy"));
1733
2057
  }
1734
2058
  const response = await this.sendRpcRequest("thread/resume", {
1735
2059
  threadId: trimmedThreadId,
@@ -1820,7 +2144,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1820
2144
  this.setStatus("idle", "Codex task interrupted.");
1821
2145
  this.emit({
1822
2146
  type: "task_complete",
1823
- summary: "Interrupted",
2147
+ summary: t("task.interrupted"),
1824
2148
  timestamp: nowIso(),
1825
2149
  });
1826
2150
  }, INTERRUPT_SETTLE_DELAY_MS);
@@ -2079,6 +2403,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2079
2403
  }
2080
2404
  }
2081
2405
  trackLocalSharedThread(threadId, options) {
2406
+ if (this.backgroundThreadIds.has(threadId)) {
2407
+ return;
2408
+ }
2082
2409
  const weakSignal = options.signal === "status_changed" ||
2083
2410
  options.signal === "thread_started" ||
2084
2411
  options.signal === "session_fallback";
@@ -2200,10 +2527,33 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2200
2527
  this.bridgeResumeReplayUntilMs = 0;
2201
2528
  }
2202
2529
  clearPendingApprovalState() {
2530
+ this.approvalGeneration++;
2531
+ const hadPending = this.pendingApprovalRequests.length > 0;
2532
+ for (const pending of this.pendingApprovalRequests)
2533
+ this.rememberSettledApproval(pending.requestId);
2203
2534
  this.pendingApprovalRequests = [];
2535
+ this.state.pendingApprovals = [];
2204
2536
  this.pendingApproval = null;
2205
2537
  this.state.pendingApproval = null;
2206
2538
  this.state.pendingApprovalOrigin = undefined;
2539
+ if (hadPending)
2540
+ this.emit({ type: "approval_state_changed", requests: [], timestamp: nowIso() });
2541
+ }
2542
+ syncPendingApprovals() {
2543
+ const previousId = this.pendingApproval?.requestId;
2544
+ const requests = this.pendingApprovalRequests.flatMap((pending) => {
2545
+ const request = buildCodexApprovalRequest(pending.method, pending.params);
2546
+ return request ? [{ ...request, requestId: this.rpcRequestKey(pending.requestId) }] : [];
2547
+ });
2548
+ this.pendingApproval = requests[0] ?? null;
2549
+ this.state.pendingApproval = this.pendingApproval;
2550
+ this.state.pendingApprovals = requests;
2551
+ this.state.pendingApprovalOrigin = this.pendingApprovalRequests[0]?.origin;
2552
+ this.setStatus(requests.length ? "awaiting_approval" : this.state.pendingUserInput ? "awaiting_input" : "busy");
2553
+ this.emit({ type: "approval_state_changed", requests, timestamp: nowIso() });
2554
+ if (requests[0] && requests[0].requestId !== previousId) {
2555
+ this.emit({ type: "approval_required", request: requests[0], timestamp: nowIso() });
2556
+ }
2207
2557
  }
2208
2558
  clearPendingUserInputState() {
2209
2559
  if (this.pendingUserInputTimer) {
@@ -2344,6 +2694,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2344
2694
  if (!isRecord(params)) {
2345
2695
  return;
2346
2696
  }
2697
+ if (method === "serverRequest/resolved") {
2698
+ const requestId = getCodexRpcRequestId(params.requestId);
2699
+ if (requestId !== null)
2700
+ this.rememberSettledApproval(requestId);
2701
+ const pending = this.pendingApprovalRequests.find((request) => request.requestId === requestId
2702
+ && (!getNotificationThreadId(params) || request.threadId === getNotificationThreadId(params))
2703
+ && (!getNotificationTurnId(params) || request.turnId === getNotificationTurnId(params)));
2704
+ if (pending) {
2705
+ this.pendingApprovalRequests = this.pendingApprovalRequests.filter((request) => request !== pending);
2706
+ this.syncPendingApprovals();
2707
+ }
2708
+ }
2347
2709
  if (method === "thread/started") {
2348
2710
  this.handleThreadStarted(params);
2349
2711
  return;
@@ -2386,6 +2748,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2386
2748
  if (!threadId || !turnId) {
2387
2749
  return null;
2388
2750
  }
2751
+ if (this.backgroundThreadIds.has(threadId)) {
2752
+ return null;
2753
+ }
2389
2754
  if (this.hasCompletedTurn(turnId)) {
2390
2755
  return null;
2391
2756
  }
@@ -2470,16 +2835,6 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2470
2835
  }
2471
2836
  case "serverRequest/resolved": {
2472
2837
  const requestId = getCodexRpcRequestId(params.requestId);
2473
- if (requestId !== null &&
2474
- this.pendingApprovalRequests.some((r) => r.requestId === requestId && r.turnId === trackedTurn.turnId)) {
2475
- this.pendingApprovalRequests = this.pendingApprovalRequests.filter((r) => r.requestId !== requestId);
2476
- if (this.pendingApprovalRequests.length === 0) {
2477
- this.clearPendingApprovalState();
2478
- }
2479
- if (this.state.status === "awaiting_approval") {
2480
- this.setStatus("busy", "Codex approval resolved.");
2481
- }
2482
- }
2483
2838
  if (requestId !== null &&
2484
2839
  this.pendingUserInputRequest &&
2485
2840
  requestId === this.pendingUserInputRequest.requestId &&
@@ -2553,9 +2908,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2553
2908
  });
2554
2909
  return;
2555
2910
  }
2556
- const trackedTurn = this.identifyTrackedTurn("server/request", params) ??
2911
+ const trackedTurn = (method !== "item/tool/requestUserInput" ? this.approvalOwnerForBackgroundThread(params) : null) ??
2912
+ this.identifyTrackedTurn("server/request", params) ??
2557
2913
  this.fallbackTrackedTurnForServerRequest(params);
2558
2914
  if (!trackedTurn) {
2915
+ if (method !== "item/tool/requestUserInput" && this.activeTurn && this.rpcSocket) {
2916
+ this.discoverBackgroundApprovalOwner(requestId, method, params);
2917
+ return;
2918
+ }
2559
2919
  this.sendRpcMessage({
2560
2920
  id: requestId,
2561
2921
  error: {
@@ -2582,7 +2942,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2582
2942
  : { decision: "decline" },
2583
2943
  });
2584
2944
  this.state.lastOutputAt = nowIso();
2585
- this.setStatus("busy", `Codex approval auto-denied: ${truncatePreview(denyMessage, 180)}`);
2945
+ this.setStatus(this.pendingApprovalRequests.length ? "awaiting_approval" : "busy", `Codex approval auto-denied: ${truncatePreview(denyMessage, 180)}`);
2586
2946
  return;
2587
2947
  }
2588
2948
  const autoResponse = getCodexApprovalAutoResponse(method, params);
@@ -2592,7 +2952,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2592
2952
  result: autoResponse.result,
2593
2953
  });
2594
2954
  this.state.lastOutputAt = nowIso();
2595
- this.setStatus("busy", `Codex approval auto-approved: ${truncatePreview(autoResponse.reason, 180)}`);
2955
+ this.setStatus(this.pendingApprovalRequests.length ? "awaiting_approval" : "busy", `Codex approval auto-approved: ${truncatePreview(autoResponse.reason, 180)}`);
2596
2956
  return;
2597
2957
  }
2598
2958
  const request = buildCodexApprovalRequest(method, params);
@@ -2606,24 +2966,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2606
2966
  });
2607
2967
  return;
2608
2968
  }
2969
+ if (this.pendingApprovalRequests.some((pending) => pending.requestId === requestId)
2970
+ || this.settledApprovalIds.has(this.rpcRequestKey(requestId)))
2971
+ return;
2609
2972
  this.pendingApprovalRequests.push({
2973
+ ownerTurnId: trackedTurn.turnId,
2610
2974
  requestId,
2611
2975
  method,
2612
- threadId: trackedTurn.threadId,
2613
- turnId: trackedTurn.turnId,
2976
+ threadId: getNotificationThreadId(params) ?? trackedTurn.threadId,
2977
+ turnId: getNotificationTurnId(params) ?? trackedTurn.turnId,
2614
2978
  origin: trackedTurn.origin,
2615
2979
  params,
2616
2980
  });
2617
- this.pendingApproval = request;
2618
- this.state.pendingApproval = request;
2619
- this.state.pendingApprovalOrigin = trackedTurn.origin;
2620
2981
  this.state.lastOutputAt = nowIso();
2621
- this.setStatus("awaiting_approval", `Codex approval is required: ${truncatePreview(request.commandPreview, 180)}`);
2622
- this.emit({
2623
- type: "approval_required",
2624
- request,
2625
- timestamp: nowIso(),
2626
- });
2982
+ this.syncPendingApprovals();
2627
2983
  }
2628
2984
  fallbackTrackedTurnForServerRequest(params) {
2629
2985
  if (this.activeTurn) {
@@ -2631,7 +2987,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2631
2987
  }
2632
2988
  const threadId = getNotificationThreadId(params);
2633
2989
  const turnId = getNotificationTurnId(params);
2634
- if (!threadId || !turnId) {
2990
+ if (!threadId || !turnId || this.backgroundThreadIds.has(threadId)) {
2635
2991
  return null;
2636
2992
  }
2637
2993
  return {
@@ -2663,7 +3019,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2663
3019
  this.state.pendingUserInput = request;
2664
3020
  this.state.pendingUserInputOrigin = trackedTurn.origin;
2665
3021
  this.state.lastOutputAt = nowIso();
2666
- this.setStatus("awaiting_input", "Codex is waiting for user input.");
3022
+ this.setStatus(this.pendingApprovalRequests.length ? "awaiting_approval" : "awaiting_input", "Codex is waiting for user input.");
2667
3023
  this.emit({
2668
3024
  type: "user_input_required",
2669
3025
  request,
@@ -2690,11 +3046,83 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2690
3046
  }, this.userInputAutoResolutionDelayMs);
2691
3047
  this.pendingUserInputTimer.unref?.();
2692
3048
  }
3049
+ rememberSettledApproval(requestId) {
3050
+ this.settledApprovalIds.add(this.rpcRequestKey(requestId));
3051
+ if (this.settledApprovalIds.size > 512)
3052
+ this.settledApprovalIds.delete(this.settledApprovalIds.values().next().value);
3053
+ }
3054
+ discoverBackgroundApprovalOwner(requestId, method, params) {
3055
+ const id = this.rpcRequestKey(requestId);
3056
+ if (this.discoveringApprovalIds.has(id) || this.settledApprovalIds.has(id))
3057
+ return;
3058
+ this.discoveringApprovalIds.add(id);
3059
+ const owner = this.activeTurn;
3060
+ const generation = this.approvalGeneration;
3061
+ void (async () => {
3062
+ let threadId = getNotificationThreadId(params);
3063
+ const seen = new Set();
3064
+ while (threadId && owner && !seen.has(threadId) && seen.size < 32) {
3065
+ if (this.approvalOwnerForBackgroundThread(params))
3066
+ break;
3067
+ seen.add(threadId);
3068
+ const response = await this.sendRpcRequest("thread/read", { threadId, includeTurns: false });
3069
+ if (!isRecord(response) || !isRecord(response.thread))
3070
+ break;
3071
+ const thread = response.thread;
3072
+ if (thread.id !== threadId || typeof thread.cwd !== "string"
3073
+ || normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)
3074
+ || typeof thread.parentThreadId !== "string")
3075
+ break;
3076
+ this.rememberBackgroundThread(thread);
3077
+ threadId = thread.parentThreadId;
3078
+ }
3079
+ if (generation !== this.approvalGeneration || this.activeTurn !== owner || this.settledApprovalIds.has(id))
3080
+ return;
3081
+ const trackedTurn = this.approvalOwnerForBackgroundThread(params);
3082
+ if (!trackedTurn)
3083
+ throw new Error("Codex approval does not belong to the active parent turn.");
3084
+ this.handleTrackedTurnServerRequest(requestId, method, params, trackedTurn);
3085
+ })().catch((error) => {
3086
+ if (generation === this.approvalGeneration && !this.settledApprovalIds.has(id) && this.rpcSocket) {
3087
+ this.sendRpcMessage({ id: requestId, error: { code: -32602, message: describeUnknownError(error) } });
3088
+ }
3089
+ }).finally(() => this.discoveringApprovalIds.delete(id));
3090
+ }
3091
+ approvalOwnerForBackgroundThread(params) {
3092
+ if (!this.activeTurn)
3093
+ return null;
3094
+ let threadId = getNotificationThreadId(params);
3095
+ const seen = new Set();
3096
+ while (threadId && !seen.has(threadId)) {
3097
+ seen.add(threadId);
3098
+ threadId = this.backgroundThreadParents.get(threadId) ?? null;
3099
+ if (threadId === this.activeTurn.threadId)
3100
+ return this.activeTurn;
3101
+ }
3102
+ return null;
3103
+ }
3104
+ rememberBackgroundThread(thread) {
3105
+ if (!isRecord(thread) || typeof thread.id !== "string" ||
3106
+ (typeof thread.parentThreadId !== "string" && thread.ephemeral !== true)) {
3107
+ return false;
3108
+ }
3109
+ // Later notifications may carry only an id. Keep subagent/ephemeral
3110
+ // threads out of local follow and turn ownership for the server lifetime.
3111
+ this.backgroundThreadIds.add(thread.id);
3112
+ if (typeof thread.parentThreadId === "string"
3113
+ && (typeof thread.cwd !== "string" || normalizeComparablePath(thread.cwd) === normalizeComparablePath(this.options.cwd))) {
3114
+ this.backgroundThreadParents.set(thread.id, thread.parentThreadId);
3115
+ }
3116
+ return true;
3117
+ }
2693
3118
  handleThreadStatusChanged(params) {
2694
3119
  const threadId = extractCodexThreadFollowIdFromStatusChanged(params);
2695
3120
  if (!threadId) {
2696
3121
  return;
2697
3122
  }
3123
+ if (this.rememberBackgroundThread(params.thread) || this.backgroundThreadIds.has(threadId)) {
3124
+ return;
3125
+ }
2698
3126
  if (this.pendingVisibleResume || this.visibleThreadProxy) {
2699
3127
  return;
2700
3128
  }
@@ -2742,6 +3170,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2742
3170
  return;
2743
3171
  }
2744
3172
  const thread = response.thread;
3173
+ if (this.rememberBackgroundThread(thread) || this.backgroundThreadIds.has(threadId)) {
3174
+ return;
3175
+ }
2745
3176
  if (thread.id === threadId &&
2746
3177
  typeof thread.cwd === "string" &&
2747
3178
  normalizeComparablePath(thread.cwd) === normalizeComparablePath(this.options.cwd) &&
@@ -2774,6 +3205,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2774
3205
  if (!threadId) {
2775
3206
  return;
2776
3207
  }
3208
+ if (this.rememberBackgroundThread(params.thread) || this.backgroundThreadIds.has(threadId)) {
3209
+ return;
3210
+ }
2777
3211
  if (this.visibleThreadProxy || this.isBridgeResumeReplay(threadId)) {
2778
3212
  return;
2779
3213
  }
@@ -2781,13 +3215,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2781
3215
  return;
2782
3216
  }
2783
3217
  const thread = isRecord(params.thread) ? params.thread : null;
2784
- if (thread) {
2785
- if ((typeof thread.cwd === "string" &&
2786
- normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) ||
2787
- typeof thread.parentThreadId === "string" ||
2788
- thread.ephemeral === true) {
2789
- return;
2790
- }
3218
+ if (typeof thread?.cwd === "string" &&
3219
+ normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
3220
+ return;
2791
3221
  }
2792
3222
  if (!this.activeTurn ||
2793
3223
  this.activeTurn.threadId === threadId ||
@@ -2876,7 +3306,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2876
3306
  ? "Local terminal turn completed."
2877
3307
  : this.currentPreview;
2878
3308
  if (this.pendingApprovalRequests.length > 0 &&
2879
- this.pendingApprovalRequests.some((r) => r.turnId === trackedTurn.turnId)) {
3309
+ this.pendingApprovalRequests.some((r) => (r.ownerTurnId ?? r.turnId) === trackedTurn.turnId)) {
2880
3310
  this.clearPendingApprovalState();
2881
3311
  }
2882
3312
  if (this.pendingUserInputRequest &&
@@ -3055,6 +3485,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
3055
3485
  const visibleThreadProxy = this.visibleThreadProxy;
3056
3486
  this.visibleThreadProxy = null;
3057
3487
  await visibleThreadProxy?.close();
3488
+ this.backgroundThreadIds.clear();
3489
+ this.backgroundThreadParents.clear();
3490
+ this.settledApprovalIds.clear();
3058
3491
  if (!this.appServer) {
3059
3492
  this.appServerPort = null;
3060
3493
  this.appServerShuttingDown = false;