handmux 0.26.0 → 0.27.2

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 (48) hide show
  1. package/README.md +20 -93
  2. package/README.zh-CN.md +20 -93
  3. package/dist/package.json +19 -19
  4. package/dist/public/assets/index-B4pKf3IW.js +356 -0
  5. package/dist/public/assets/index-Cdrh2CUp.css +32 -0
  6. package/dist/public/assets/pcm-worklet-NzlBQ6-t.js +1 -0
  7. package/dist/public/index.html +2 -2
  8. package/dist/src/agent-runtime/adapter.js +2 -0
  9. package/dist/src/agent-runtime/builtinRuntime.js +3 -1
  10. package/dist/src/agent-runtime/conversationActivation.js +136 -4
  11. package/dist/src/agent-runtime/run.js +23 -2
  12. package/dist/src/agent-runtime/runtime.js +174 -15
  13. package/dist/src/agent-runtime/tmuxRuntime.js +3 -7
  14. package/dist/src/agents/codex.js +78 -10
  15. package/dist/src/agents/codexActivationReceipt.js +149 -0
  16. package/dist/src/agents/codexConversation.js +59 -20
  17. package/dist/src/agents/codexConversationActivation.js +265 -65
  18. package/dist/src/agents/codexOpenSession.js +164 -0
  19. package/dist/src/agents/nativeInbox.js +24 -56
  20. package/dist/src/agents/scanUtils.js +7 -1
  21. package/dist/src/apiErrors.js +10 -1
  22. package/dist/src/asr/config.js +29 -0
  23. package/dist/src/asr/providerRegistry.js +19 -0
  24. package/dist/src/asr/providers/tencent.js +135 -0
  25. package/dist/src/asr/providers/xfyun.js +73 -0
  26. package/dist/src/asr/tencentSentence.js +111 -0
  27. package/dist/src/asr/tencentSign.js +20 -0
  28. package/dist/src/asr/verify.js +80 -0
  29. package/dist/src/cli/codexManaged.js +13 -2
  30. package/dist/src/cli/i18n/en.js +16 -1
  31. package/dist/src/cli/i18n/zh.js +16 -1
  32. package/dist/src/cli/options.js +29 -2
  33. package/dist/src/cli/setupModel.js +49 -5
  34. package/dist/src/cli/setupWizard.js +149 -29
  35. package/dist/src/cli/supervisor.js +24 -8
  36. package/dist/src/cli/supervisorLaunch.js +26 -0
  37. package/dist/src/codexAppServer.js +41 -4
  38. package/dist/src/routes/agents.js +51 -14
  39. package/dist/src/routes/system.js +109 -10
  40. package/dist/src/server.js +11 -2
  41. package/dist/src/terminalStream.js +8 -30
  42. package/dist/src/tmux/commands.js +14 -0
  43. package/dist/src/tmux/controlProtocol.js +26 -0
  44. package/dist/src/tmux/paneOutputCapture.js +180 -0
  45. package/package.json +5 -5
  46. package/dist/public/assets/index-BVFKs5dN.css +0 -32
  47. package/dist/public/assets/index-KDa5PCoi.js +0 -356
  48. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +0 -1
@@ -1,8 +1,37 @@
1
+ const CODEX_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
+ function validRecovery(value) {
3
+ if (!value || typeof value !== 'object' || Array.isArray(value))
4
+ return false;
5
+ const recovery = value;
6
+ return recovery.kind === 'codex_resume'
7
+ && typeof recovery.sessionId === 'string'
8
+ && CODEX_SESSION_ID_RE.test(recovery.sessionId)
9
+ && recovery.command === `handmux codex resume ${recovery.sessionId}`;
10
+ }
11
+ function sameRecovery(first, second) {
12
+ return first.kind === second.kind
13
+ && first.sessionId === second.sessionId
14
+ && first.command === second.command;
15
+ }
16
+ function validRecoveryReceipt(value) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value))
18
+ return false;
19
+ const receipt = value;
20
+ return typeof receipt.operationId === 'string'
21
+ && /^[0-9a-f]{64}$/.test(receipt.operationId)
22
+ && validRecovery(receipt.recovery)
23
+ && (receipt.phase === 'prepared' || receipt.phase === 'interrupted' || receipt.phase === 'resuming')
24
+ && (receipt.state === 'current' || receipt.state === 'stale')
25
+ && typeof receipt.canResume === 'boolean'
26
+ && (!receipt.canResume || (receipt.state === 'current' && receipt.phase !== 'resuming'));
27
+ }
1
28
  export class ConversationActivationError extends Error {
2
29
  code;
3
- constructor(message, code) {
30
+ recovery;
31
+ constructor(message, code, recovery) {
4
32
  super(message);
5
33
  this.code = code;
34
+ this.recovery = recovery;
6
35
  this.name = 'ConversationActivationError';
7
36
  }
8
37
  }
@@ -47,6 +76,19 @@ export class AgentConversationActivationService {
47
76
  throw new ConversationActivationError('Conversation activation is already in progress', 'in_progress');
48
77
  }
49
78
  this.#active.add(key);
79
+ let reportedRecovery;
80
+ const progress = {
81
+ recovery: (value) => {
82
+ if (!validRecovery(value)) {
83
+ throw new ConversationActivationError('Invalid Conversation activation recovery', 'contract_violation');
84
+ }
85
+ const next = structuredClone(value);
86
+ if (reportedRecovery && !sameRecovery(reportedRecovery, next)) {
87
+ throw new ConversationActivationError('Conversation activation recovery changed', 'contract_violation');
88
+ }
89
+ reportedRecovery = next;
90
+ },
91
+ };
50
92
  const operation = new AbortController();
51
93
  const cancel = () => operation.abort(signal?.reason ?? new Error('Activation request cancelled'));
52
94
  signal?.addEventListener('abort', cancel, { once: true });
@@ -59,16 +101,106 @@ export class AgentConversationActivationService {
59
101
  const aborted = new Promise((_resolve, reject) => {
60
102
  operation.signal.addEventListener('abort', () => reject(operation.signal.reason), { once: true });
61
103
  });
62
- await Promise.race([controller.activate(run, operation.signal), aborted]);
104
+ const result = await Promise.race([controller.activate(run, operation.signal, progress), aborted]);
105
+ if (!result)
106
+ return reportedRecovery ? { recovery: reportedRecovery } : undefined;
107
+ if (!validRecovery(result.recovery)
108
+ || (reportedRecovery && !sameRecovery(reportedRecovery, result.recovery))) {
109
+ throw new ConversationActivationError('Invalid Conversation activation recovery', 'contract_violation');
110
+ }
111
+ return { recovery: structuredClone(result.recovery) };
112
+ }
113
+ catch (error) {
114
+ if (error instanceof ConversationActivationError) {
115
+ if (error.recovery !== undefined && (!validRecovery(error.recovery)
116
+ || (reportedRecovery !== undefined && !sameRecovery(reportedRecovery, error.recovery)))) {
117
+ throw new ConversationActivationError('Invalid Conversation activation recovery', 'contract_violation');
118
+ }
119
+ if (error.recovery !== undefined || reportedRecovery === undefined)
120
+ throw error;
121
+ throw new ConversationActivationError(error.message, error.code, reportedRecovery);
122
+ }
123
+ throw new ConversationActivationError('Conversation activation could not finish; continue in the terminal or try again', 'unavailable', reportedRecovery);
124
+ }
125
+ finally {
126
+ clearTimeout(timer);
127
+ signal?.removeEventListener('abort', cancel);
128
+ this.#active.delete(key);
129
+ }
130
+ }
131
+ async recovery(paneId) {
132
+ if (!/^%\d+$/.test(paneId)) {
133
+ throw new ConversationActivationError('Invalid Conversation activation pane', 'contract_violation');
134
+ }
135
+ for (const controller of this.#controllers.values()) {
136
+ if (!controller.recovery)
137
+ continue;
138
+ try {
139
+ const receipt = await controller.recovery(paneId);
140
+ if (receipt === null)
141
+ continue;
142
+ if (!validRecoveryReceipt(receipt)) {
143
+ throw new ConversationActivationError('Invalid Conversation recovery receipt', 'contract_violation');
144
+ }
145
+ return structuredClone(receipt);
146
+ }
147
+ catch (error) {
148
+ if (error instanceof ConversationActivationError)
149
+ throw error;
150
+ throw new ConversationActivationError('Conversation recovery is temporarily unavailable', 'unavailable');
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ async recover(paneId, operationId) {
156
+ if (!/^%\d+$/.test(paneId) || !/^[0-9a-f]{64}$/.test(operationId)) {
157
+ throw new ConversationActivationError('Invalid Conversation recovery request', 'contract_violation');
158
+ }
159
+ const controller = [...this.#controllers.values()].find((candidate) => candidate.recover);
160
+ if (!controller?.recover) {
161
+ throw new ConversationActivationError('Conversation recovery unsupported', 'unsupported');
162
+ }
163
+ const key = `recovery\0${operationId}`;
164
+ if (this.#active.has(key)) {
165
+ throw new ConversationActivationError('Conversation recovery is already in progress', 'in_progress');
166
+ }
167
+ this.#active.add(key);
168
+ let reportedRecovery;
169
+ const progress = { recovery: (value) => {
170
+ if (!validRecovery(value)) {
171
+ throw new ConversationActivationError('Invalid Conversation recovery', 'contract_violation');
172
+ }
173
+ const next = structuredClone(value);
174
+ if (reportedRecovery && !sameRecovery(reportedRecovery, next)) {
175
+ throw new ConversationActivationError('Conversation recovery changed', 'contract_violation');
176
+ }
177
+ reportedRecovery = next;
178
+ } };
179
+ const operation = new AbortController();
180
+ const timer = setTimeout(() => operation.abort(new Error('Conversation recovery timed out')), this.#timeoutMs);
181
+ try {
182
+ const aborted = new Promise((_resolve, reject) => {
183
+ operation.signal.addEventListener('abort', () => reject(operation.signal.reason), { once: true });
184
+ });
185
+ const result = await Promise.race([
186
+ controller.recover(paneId, operationId, operation.signal, progress),
187
+ aborted,
188
+ ]);
189
+ if (!result)
190
+ return reportedRecovery ? { recovery: reportedRecovery } : undefined;
191
+ if (!validRecovery(result.recovery)
192
+ || (reportedRecovery && !sameRecovery(reportedRecovery, result.recovery))) {
193
+ throw new ConversationActivationError('Invalid Conversation recovery', 'contract_violation');
194
+ }
195
+ return { recovery: structuredClone(result.recovery) };
63
196
  }
64
197
  catch (error) {
65
198
  if (error instanceof ConversationActivationError)
66
199
  throw error;
67
- throw new ConversationActivationError('Conversation activation could not finish; continue in the terminal or try again', 'unavailable');
200
+ throw new ConversationActivationError('Conversation recovery could not finish; copy the command or continue in the terminal', 'unavailable', reportedRecovery);
68
201
  }
69
202
  finally {
70
203
  clearTimeout(timer);
71
- signal?.removeEventListener('abort', cancel);
72
204
  this.#active.delete(key);
73
205
  }
74
206
  }
@@ -108,7 +108,8 @@ export class AgentRunRuntime {
108
108
  }
109
109
  return Object.freeze({
110
110
  attach: (candidate) => this.#attach(agentId, verify, candidate),
111
- associateSession: (lease, sessionId) => (this.#associateSession(agentId, lease, sessionId)),
111
+ associateSession: (lease, sessionId) => (this.#associateSession(agentId, verify, lease, sessionId)),
112
+ replaceSession: (lease, sessionId) => (this.#replaceSession(agentId, verify, lease, sessionId)),
112
113
  replace: (current, candidate, reason) => this.#replace(agentId, verify, current, candidate, reason),
113
114
  revoke: (lease, reason) => this.#revoke(agentId, lease, reason),
114
115
  });
@@ -206,6 +207,7 @@ export class AgentRunRuntime {
206
207
  record.lease = Object.freeze({
207
208
  get ref() { return record.ref; },
208
209
  signal: abort.signal,
210
+ process: record.process,
209
211
  });
210
212
  this.#records.set(record.lease, record);
211
213
  return record;
@@ -274,7 +276,7 @@ export class AgentRunRuntime {
274
276
  return this.#replaceInstalled(paneOwner, created, 'process_exit');
275
277
  });
276
278
  }
277
- async #associateSession(agentId, lease, sessionId) {
279
+ async #associateSession(agentId, verify, lease, sessionId) {
278
280
  if (!validText(sessionId, 1024)) {
279
281
  throw new AgentRunError('invalid-candidate', 'sessionId must be a non-empty bounded string');
280
282
  }
@@ -284,6 +286,14 @@ export class AgentRunRuntime {
284
286
  if (this.#byRunId.get(record.ref.runId) !== record || record.abort.signal.aborted) {
285
287
  throw new AgentRunError('stale-lease', 'Cannot associate a session with a stale Agent run');
286
288
  }
289
+ await this.#verify(verify, {
290
+ paneId: record.paneId,
291
+ attachmentId: record.attachmentId,
292
+ ...(record.ref.sessionId === undefined ? {} : { sessionId: record.ref.sessionId }),
293
+ ...(record.ref.implementationVersion === undefined
294
+ ? {} : { implementationVersion: record.ref.implementationVersion }),
295
+ process: { ...record.process },
296
+ });
287
297
  if (record.ref.sessionId === sessionId)
288
298
  return record.ref;
289
299
  if (record.ref.sessionId !== undefined) {
@@ -317,6 +327,17 @@ export class AgentRunRuntime {
317
327
  return this.#replaceInstalled(record, created, reason);
318
328
  });
319
329
  }
330
+ async #replaceSession(agentId, verify, current, sessionId) {
331
+ const record = this.#recordFor(agentId, current);
332
+ return this.#replace(agentId, verify, current, {
333
+ paneId: record.paneId,
334
+ attachmentId: record.attachmentId,
335
+ sessionId,
336
+ ...(record.ref.implementationVersion === undefined
337
+ ? {} : { implementationVersion: record.ref.implementationVersion }),
338
+ process: { ...record.process },
339
+ }, 'session_replaced');
340
+ }
320
341
  async #revoke(agentId, lease, reason) {
321
342
  const known = this.#recordFor(agentId, lease);
322
343
  await this.#withPane(known.paneId, async () => {
@@ -58,16 +58,43 @@ export function validSubscriptionUsageAdapter(value) {
58
58
  return adapter !== null && typeof adapter === 'object' && adapter.apiVersion === 1
59
59
  && typeof adapter.snapshot === 'function';
60
60
  }
61
- function sameProcess(candidate, pane, foreground) {
61
+ function compareProcess(candidate, pane, foreground) {
62
62
  if (foreground.pid !== candidate.process.pid)
63
- return false;
64
- if (pane.foregroundPid !== undefined && pane.foregroundPid !== candidate.process.pid)
65
- return false;
63
+ return 'invalid';
64
+ if (pane.foregroundPid !== undefined
65
+ && pane.foregroundPid !== candidate.process.pid)
66
+ return 'invalid';
66
67
  if (candidate.process.startedAt !== undefined
68
+ && foreground.startedAt !== undefined
67
69
  && foreground.startedAt !== candidate.process.startedAt)
68
- return false;
70
+ return 'invalid';
69
71
  const observedTty = foreground.tty ?? pane.tty;
70
- return candidate.process.tty === undefined || observedTty === candidate.process.tty;
72
+ if (candidate.process.tty !== undefined && observedTty
73
+ && observedTty !== candidate.process.tty)
74
+ return 'invalid';
75
+ if ((candidate.process.startedAt !== undefined && foreground.startedAt === undefined)
76
+ || (candidate.process.tty !== undefined && !observedTty))
77
+ return 'unknown';
78
+ return 'valid';
79
+ }
80
+ function processAttachmentCandidate(adapter, pane, foreground) {
81
+ const process = {
82
+ pid: foreground.pid,
83
+ ...(foreground.startedAt === undefined ? {} : { startedAt: foreground.startedAt }),
84
+ ...(foreground.tty === undefined
85
+ ? (pane.tty === undefined ? {} : { tty: pane.tty })
86
+ : { tty: foreground.tty }),
87
+ };
88
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify({
89
+ agentId: adapter.id,
90
+ paneId: pane.paneId,
91
+ process,
92
+ })).digest('hex');
93
+ return {
94
+ paneId: pane.paneId,
95
+ attachmentId: `runtime.process:${fingerprint}`,
96
+ process,
97
+ };
71
98
  }
72
99
  function recoverCorruptState({ file, kind, logger, create, isContractError, }) {
73
100
  try {
@@ -154,7 +181,8 @@ export class AgentRuntime {
154
181
  #health = new Map();
155
182
  #transport;
156
183
  #unsubscribePanes;
157
- #reconcileTail = Promise.resolve();
184
+ #pendingReconcile;
185
+ #reconcileDrain;
158
186
  #startPromise;
159
187
  #started = false;
160
188
  #closed = false;
@@ -567,14 +595,20 @@ export class AgentRuntime {
567
595
  if (this.#closed)
568
596
  throw new Error('AgentRuntime is closed');
569
597
  unsubscribe = this.#panes.subscribe((snapshot) => {
570
- const operation = this.#reconcileTail.then(() => this.#reconcile(snapshot));
571
- this.#reconcileTail = operation.catch((error) => {
572
- this.#logger.warn('Agent pane reconciliation failed', {
573
- error: error instanceof Error ? error.message : String(error),
574
- });
575
- });
598
+ this.#offerReconcile(snapshot);
576
599
  });
577
600
  this.#unsubscribePanes = unsubscribe;
601
+ // Establish Runtime-owned process runs before capability coordinators consume provider rows.
602
+ // The subscription remains the retry path when this best-effort initial read is unavailable.
603
+ try {
604
+ const initial = await this.#panes.list();
605
+ await this.#waitForReconcile(initial);
606
+ }
607
+ catch (error) {
608
+ this.#logger.warn('Initial Agent pane reconciliation failed', {
609
+ error: error instanceof Error ? error.message : String(error),
610
+ });
611
+ }
578
612
  for (const adapter of this.adapters) {
579
613
  if (this.#closed)
580
614
  throw new Error('AgentRuntime is closed');
@@ -609,10 +643,11 @@ export class AgentRuntime {
609
643
  if (this.#closed)
610
644
  return;
611
645
  this.#closed = true;
646
+ this.#cancelPendingReconcile(new Error('AgentRuntime is closed'));
612
647
  await this.#startPromise?.catch(() => { });
613
648
  this.#unsubscribePanes?.();
614
649
  this.#unsubscribePanes = undefined;
615
- await this.#reconcileTail.catch(() => { });
650
+ await this.#reconcileDrain?.catch(() => { });
616
651
  await this.#transport.close();
617
652
  await this.runs.shutdown();
618
653
  await this.interaction?.shutdown();
@@ -680,6 +715,7 @@ export class AgentRuntime {
680
715
  runControl: this.#controllers.get(adapter.id),
681
716
  panes: this.#panes,
682
717
  process: this.#process,
718
+ currentRunForPane: (paneId) => this.runs.currentForPane(paneId),
683
719
  bridge: this.bridge.hostFor(adapter.id),
684
720
  resources: this.resources.forAdapter(adapter.id),
685
721
  logger: this.#logger,
@@ -698,6 +734,79 @@ export class AgentRuntime {
698
734
  const key = `${adapterId}\0${update.capability ?? ''}`;
699
735
  this.#health.set(key, { adapterId, ...structuredClone(update) });
700
736
  }
737
+ #offerReconcile(snapshot) {
738
+ if (this.#closed)
739
+ return;
740
+ if (this.#pendingReconcile)
741
+ this.#pendingReconcile.snapshot = structuredClone(snapshot);
742
+ else
743
+ this.#pendingReconcile = { snapshot: structuredClone(snapshot), waiters: [] };
744
+ this.#ensureReconcileDrain();
745
+ }
746
+ #waitForReconcile(snapshot) {
747
+ if (this.#closed)
748
+ return Promise.reject(new Error('AgentRuntime is closed'));
749
+ const operation = new Promise((resolve, reject) => {
750
+ const waiter = { resolve, reject };
751
+ if (this.#pendingReconcile) {
752
+ this.#pendingReconcile.snapshot = structuredClone(snapshot);
753
+ this.#pendingReconcile.waiters.push(waiter);
754
+ }
755
+ else {
756
+ this.#pendingReconcile = { snapshot: structuredClone(snapshot), waiters: [waiter] };
757
+ }
758
+ });
759
+ this.#ensureReconcileDrain();
760
+ return operation;
761
+ }
762
+ #ensureReconcileDrain() {
763
+ if (this.#closed || this.#reconcileDrain || !this.#pendingReconcile)
764
+ return;
765
+ const drain = this.#drainReconciles();
766
+ this.#reconcileDrain = drain;
767
+ const finish = () => {
768
+ if (this.#reconcileDrain === drain)
769
+ this.#reconcileDrain = undefined;
770
+ if (!this.#closed && this.#pendingReconcile)
771
+ this.#ensureReconcileDrain();
772
+ };
773
+ void drain.then(finish, (error) => {
774
+ try {
775
+ this.#logger.warn('Agent pane reconciliation drain failed', {
776
+ error: error instanceof Error ? error.message : String(error),
777
+ });
778
+ }
779
+ catch { /* diagnostics must not create an unhandled rejection */ }
780
+ finish();
781
+ });
782
+ }
783
+ async #drainReconciles() {
784
+ while (!this.#closed) {
785
+ const pending = this.#pendingReconcile;
786
+ if (!pending)
787
+ return;
788
+ this.#pendingReconcile = undefined;
789
+ try {
790
+ await this.#reconcile(pending.snapshot);
791
+ pending.waiters.forEach((waiter) => waiter.resolve());
792
+ }
793
+ catch (error) {
794
+ pending.waiters.forEach((waiter) => waiter.reject(error));
795
+ try {
796
+ this.#logger.warn('Agent pane reconciliation failed', {
797
+ error: error instanceof Error ? error.message : String(error),
798
+ });
799
+ }
800
+ catch { /* diagnostics must not strand reconciliation waiters */ }
801
+ }
802
+ }
803
+ this.#cancelPendingReconcile(new Error('AgentRuntime is closed'));
804
+ }
805
+ #cancelPendingReconcile(error) {
806
+ const pending = this.#pendingReconcile;
807
+ this.#pendingReconcile = undefined;
808
+ pending?.waiters.forEach((waiter) => waiter.reject(error));
809
+ }
701
810
  #trackedController(adapter, raw) {
702
811
  return Object.freeze({
703
812
  attach: async (candidate) => (this.#track(adapter, await raw.attach(candidate), candidate)),
@@ -708,6 +817,14 @@ export class AgentRuntime {
708
817
  tracked.candidate.sessionId = sessionId;
709
818
  return ref;
710
819
  },
820
+ replaceSession: async (lease, sessionId) => {
821
+ const tracked = this.#tracked.get(lease.ref.runId);
822
+ if (!tracked || tracked.adapter.id !== adapter.id || tracked.lease !== lease) {
823
+ throw new Error('Agent run lease is not tracked by Runtime');
824
+ }
825
+ const candidate = { ...cloneCandidate(tracked.candidate), sessionId };
826
+ return this.#track(adapter, await raw.replaceSession(lease, sessionId), candidate);
827
+ },
711
828
  replace: async (current, candidate, reason) => (this.#track(adapter, await raw.replace(current, candidate, reason), candidate)),
712
829
  revoke: (lease, reason) => raw.revoke(lease, reason),
713
830
  });
@@ -809,7 +926,9 @@ export class AgentRuntime {
809
926
  const foreground = await context.inspectForeground(pane);
810
927
  if (foreground === null)
811
928
  return 'unknown';
812
- return sameProcess(candidate, pane, foreground) ? 'valid' : 'invalid';
929
+ // A partial best-effort probe cannot prove that the process generation changed. Preserve the
930
+ // complete lease and retry only when every field that is present still agrees.
931
+ return compareProcess(candidate, pane, foreground);
813
932
  })(), this.#verifyTimeoutMs, 'Agent process verification');
814
933
  }
815
934
  catch {
@@ -831,5 +950,45 @@ export class AgentRuntime {
831
950
  await this.runs.revokePane(tracked.lease.ref.paneId, pane ? 'process_exit' : 'pane_detached');
832
951
  }
833
952
  }
953
+ const activeAdapters = this.adapters.filter((adapter) => (!this.#lifecycles.get(adapter.id)?.abort.signal.aborted));
954
+ for (const pane of panes.values()) {
955
+ if (this.#closed || this.runs.currentForPane(pane.paneId))
956
+ continue;
957
+ let inspected;
958
+ const context = {
959
+ inspectForeground: () => {
960
+ inspected ??= this.#process.inspectForeground(pane);
961
+ return inspected;
962
+ },
963
+ };
964
+ const identity = await resolveAgentIdentity(pane, activeAdapters, context, {
965
+ verifyTimeoutMs: this.#verifyTimeoutMs,
966
+ });
967
+ if (identity.kind !== 'matched' || identity.adapter.process.runtimeAttach !== true)
968
+ continue;
969
+ let foreground;
970
+ try {
971
+ // Exact command matches skip Adapter verification, so bound this probe at its own use site.
972
+ // A timed-out result must not attach later or block other panes and subsequent snapshots.
973
+ foreground = await lifecycleWithin(context.inspectForeground(pane), this.#verifyTimeoutMs, 'Agent process discovery');
974
+ }
975
+ catch {
976
+ continue;
977
+ }
978
+ // A PID without its start time is not a process generation. Do not publish a lease that
979
+ // destructive capabilities must permanently reject; the next pane poll retries the probe.
980
+ if (!foreground || foreground.startedAt === undefined || !(foreground.tty ?? pane.tty))
981
+ continue;
982
+ try {
983
+ await this.#controllers.get(identity.adapter.id).attach(processAttachmentCandidate(identity.adapter, pane, foreground));
984
+ }
985
+ catch (error) {
986
+ this.#logger.warn('Agent process attachment failed', {
987
+ adapterId: identity.adapter.id,
988
+ paneId: pane.paneId,
989
+ error: error instanceof Error ? error.message : String(error),
990
+ });
991
+ }
992
+ }
834
993
  }
835
994
  }
@@ -10,14 +10,14 @@ function pane(value) {
10
10
  ...(value.tty ? { tty: value.tty } : {}),
11
11
  };
12
12
  }
13
- // Tmux has no lifecycle subscription. One shared, non-overlapping poller publishes complete snapshots;
14
- // an unavailable tmux command preserves the last trusted snapshot instead of fabricating process exits.
13
+ // Tmux has no lifecycle subscription. One shared, non-overlapping poller publishes every successful
14
+ // complete snapshot: unchanged pane metadata can still hide a replaced foreground PID. An unavailable
15
+ // tmux command preserves the last trusted snapshot instead of fabricating process exits.
15
16
  export class TmuxAgentPaneSource {
16
17
  #commands;
17
18
  #pollMs;
18
19
  #listeners = new Set();
19
20
  #timer;
20
- #signature = '';
21
21
  #polling = false;
22
22
  constructor({ commands, pollMs = 1_000 }) {
23
23
  if (!commands || typeof commands.listLivePanes !== 'function'
@@ -59,10 +59,6 @@ export class TmuxAgentPaneSource {
59
59
  catch {
60
60
  return;
61
61
  }
62
- const signature = JSON.stringify(snapshot);
63
- if (signature === this.#signature)
64
- return;
65
- this.#signature = signature;
66
62
  for (const listener of this.#listeners)
67
63
  listener(structuredClone(snapshot));
68
64
  })().finally(() => {
@@ -14,20 +14,76 @@ export function rolloutSessionId(name) {
14
14
  const m = String(name).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
15
15
  return m?.[1] || null;
16
16
  }
17
- const UUID_PARTS = '([0-9a-f]{8})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{12})';
18
- function lastUuidAfter(text, prefix) {
19
- const matches = [...String(text || '').matchAll(new RegExp(`${prefix}\\s*${UUID_PARTS}`, 'gi'))];
20
- const match = matches.at(-1);
21
- if (!match)
22
- return null;
23
- const parts = match.slice(1, 6);
24
- return parts.length === 5 && parts.every(Boolean) ? parts.join('-').toLowerCase() : null;
25
- }
17
+ const CODEX_EXIT_PREFIX = 'To continue this session, run\\s+codex\\s+resume';
18
+ const UUID_VALUE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
19
+ const CODEX_DIRECT_EXIT = new RegExp(`^\\s*${CODEX_EXIT_PREFIX}\\s+(${UUID_VALUE})\\s*$`, 'i');
20
+ const CODEX_PICKER_EXIT = new RegExp(`^\\s*${CODEX_EXIT_PREFIX}\\s*,\\s*then\\s+select\\s+.+\\(\\s*(${UUID_VALUE})\\s*\\)\\s*$`, 'i');
26
21
  // A normal Codex exit prints the exact command for the session that just exited. Keep this deliberately
27
22
  // stricter than a generic `codex resume` search so conversation text or shell history is not mistaken for
28
23
  // the current exit notice.
29
24
  export function codexExitSessionId(text) {
30
- return lastUuidAfter(text, 'To continue this session, run\\s+codex\\s+resume');
25
+ let sessionId = null;
26
+ for (const line of String(text || '').split(/\r?\n/)) {
27
+ const candidate = line.match(CODEX_DIRECT_EXIT)?.[1] ?? line.match(CODEX_PICKER_EXIT)?.[1];
28
+ if (candidate && isSessionUuid(candidate))
29
+ sessionId = candidate.toLowerCase();
30
+ }
31
+ return sessionId;
32
+ }
33
+ const OSC_SEQUENCE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
34
+ const CSI_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
35
+ const MAX_CODEX_EXIT_FRAMES = 4_096;
36
+ const MAX_CODEX_EXIT_LINE_BYTES = 8 * 1_024;
37
+ function collectCodexExitOutputCandidates(output, candidates) {
38
+ const plain = String(output || '').replace(OSC_SEQUENCE, '').replace(CSI_SEQUENCE, '');
39
+ for (const line of plain.split(/\r?\n/)) {
40
+ const candidate = codexExitSessionId(line);
41
+ if (candidate)
42
+ candidates.add(candidate);
43
+ }
44
+ }
45
+ export function codexExitOutputSessionId(output) {
46
+ const candidates = new Set();
47
+ collectCodexExitOutputCandidates(output, candidates);
48
+ return candidates.size === 1 ? [...candidates][0] ?? null : null;
49
+ }
50
+ // A tmux control-mode frame is also a trustworthy output boundary. Codex can emit its exit notice in a
51
+ // fresh frame without first writing CR/LF after the preceding TUI status, and tmux can split that notice
52
+ // across later frames. Test only the first bounded logical line from each real frame boundary, plus normal
53
+ // CR/LF-delimited lines from the full stream; never scan for the prefix at an arbitrary text offset.
54
+ export function codexExitOutputFramesSessionId(frames) {
55
+ if (!frames.length || frames.length > MAX_CODEX_EXIT_FRAMES)
56
+ return null;
57
+ const candidates = new Set();
58
+ collectCodexExitOutputCandidates(Buffer.concat(frames), candidates);
59
+ for (let start = 0; start < frames.length; start += 1) {
60
+ const chunks = [];
61
+ let bytes = 0;
62
+ let complete = false;
63
+ for (let index = start; index < frames.length && bytes < MAX_CODEX_EXIT_LINE_BYTES; index += 1) {
64
+ const frame = frames[index];
65
+ if (!frame)
66
+ continue;
67
+ const newline = frame.indexOf(0x0a);
68
+ const available = newline < 0 ? frame.length : newline + 1;
69
+ const take = Math.min(available, MAX_CODEX_EXIT_LINE_BYTES - bytes);
70
+ if (take > 0) {
71
+ chunks.push(frame.subarray(0, take));
72
+ bytes += take;
73
+ }
74
+ if (newline >= 0 && take === available) {
75
+ complete = true;
76
+ break;
77
+ }
78
+ if (take < available)
79
+ break;
80
+ if (index === frames.length - 1)
81
+ complete = true;
82
+ }
83
+ if (complete)
84
+ collectCodexExitOutputCandidates(Buffer.concat(chunks, bytes), candidates);
85
+ }
86
+ return candidates.size === 1 ? [...candidates][0] ?? null : null;
31
87
  }
32
88
  // Last user turn out of a Codex rollout tail, for a recognizable one-line label. Codex records turns as
33
89
  // response_item messages: {payload:{type:'message',role:'user',content:[{type:'input_text',text}]}} (and a
@@ -188,6 +244,17 @@ export async function resolveCodexRollout(dir, sessionId) {
188
244
  }
189
245
  return found;
190
246
  }
247
+ export async function codexSessionCwd(sessionId, dir = sessionsDir()) {
248
+ const file = await resolveCodexRollout(dir, sessionId);
249
+ if (!file)
250
+ return null;
251
+ try {
252
+ return firstCwd(await readHead(file)) || null;
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }
191
258
  // Resolve a live orphan's cwd to its Codex session: the newest rollout whose recorded cwd matches. Same
192
259
  // shape as Claude's resolver ({ sessionId, state, snippet, lastActivity }) so the orphan engine is agnostic.
193
260
  export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.now } = {}) {
@@ -222,6 +289,7 @@ export const codex = {
222
289
  process: {
223
290
  commands: ['codex'],
224
291
  ambiguousCommands: ['node'],
292
+ runtimeAttach: true,
225
293
  verify: async (pane, context) => {
226
294
  const foreground = await context.inspectForeground(pane);
227
295
  const executable = foreground?.executable;