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,14 +1,19 @@
1
+ import { ConversationActivationError } from '../agent-runtime/conversationActivation.js';
1
2
  import { serializePaneInput } from '../paneInput.js';
2
- import { codex, codexExitSessionId } from './codex.js';
3
+ import { codex } from './codex.js';
4
+ import { sameCodexOpenSession } from './codexOpenSession.js';
3
5
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
6
  const SHELLS = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'tcsh']);
7
+ const REMOTE_ARG_RE = /(?:^|\s)--remote(?:=|\s|$)/;
5
8
  function throwIfAborted(signal) {
6
9
  if (signal.aborted)
7
10
  throw signal.reason instanceof Error ? signal.reason : new Error('Activation cancelled');
8
11
  }
9
- export function createCodexConversationActivationController({ panes, process, commands, wait = pause, }) {
10
- if (!panes || !process || !commands || typeof commands.sendKey !== 'function'
11
- || typeof commands.capturePlain !== 'function' || typeof commands.runPaneCommand !== 'function') {
12
+ export function createCodexConversationActivationController({ app, panes, process, commands, receipts, wait = pause, }) {
13
+ if (!app || typeof app.discover !== 'function' || !panes || !process || !commands
14
+ || typeof commands.openOutputCapture !== 'function'
15
+ || typeof commands.inspectOpenSession !== 'function'
16
+ || typeof commands.runPaneCommand !== 'function') {
12
17
  throw new TypeError('Codex Conversation activation requires pane control');
13
18
  }
14
19
  const pane = async (paneId) => (await panes.list()).find((item) => item.paneId === paneId) ?? null;
@@ -22,6 +27,22 @@ export function createCodexConversationActivationController({ panes, process, co
22
27
  return await codex.process.verify(current, { inspectForeground: async () => identity })
23
28
  ? identity : null;
24
29
  };
30
+ const nativeCodexTakeoverIdentity = async (paneId) => {
31
+ const identity = await codexIdentity(paneId);
32
+ if (!identity)
33
+ return null;
34
+ const ownership = await app.discover(paneId);
35
+ if (ownership?.managed === true && ownership.threadId) {
36
+ receipts?.clearManaged(paneId, ownership.threadId);
37
+ }
38
+ if (identity.commandLine && REMOTE_ARG_RE.test(identity.commandLine))
39
+ return null;
40
+ if (ownership?.managed === false)
41
+ return identity;
42
+ // A dead App Server can leave its socket file behind. Only an explicit unknown ownership result
43
+ // plus a complete native command line is authoritative enough to treat that process as native.
44
+ return ownership?.managed === null && identity.commandLine ? identity : null;
45
+ };
25
46
  const shellIdentity = async (paneId) => {
26
47
  const current = await pane(paneId);
27
48
  if (!current)
@@ -30,91 +51,270 @@ export function createCodexConversationActivationController({ panes, process, co
30
51
  if (!identity)
31
52
  return null;
32
53
  const executable = identity.executable?.split('/').pop()?.toLowerCase();
33
- return SHELLS.has(current.currentCommand.toLowerCase()) || (executable && SHELLS.has(executable))
34
- ? identity : null;
54
+ return executable && SHELLS.has(executable) ? identity : null;
55
+ };
56
+ const processAfterInterrupt = async (paneId) => {
57
+ const current = await pane(paneId);
58
+ if (!current)
59
+ return null;
60
+ const identity = await process.inspectForeground(current);
61
+ if (!identity)
62
+ return { kind: 'unknown', identity: null };
63
+ // During Codex shutdown, tmux/ps can briefly expose the old pane command without an executable.
64
+ // Only this bounded post-interrupt wait treats that incomplete snapshot as transient.
65
+ if (!identity.executable)
66
+ return { kind: 'unknown', identity };
67
+ const executable = identity.executable.split('/').pop()?.toLowerCase();
68
+ if (executable && SHELLS.has(executable))
69
+ return { kind: 'shell', identity };
70
+ const isCodex = await codex.process.verify(current, { inspectForeground: async () => identity });
71
+ return { kind: isCodex ? 'codex' : 'other', identity };
35
72
  };
36
73
  const sameProcess = (first, second) => (first.pid === second.pid
37
- && (first.startedAt === undefined || second.startedAt === first.startedAt)
38
- && (first.tty === undefined || second.tty === first.tty));
74
+ && first.startedAt !== undefined && second.startedAt === first.startedAt
75
+ && first.tty !== undefined && second.tty === first.tty
76
+ && first.executable !== undefined && second.executable === first.executable);
77
+ const sameRunProcess = (locked, current) => (locked.pid === current.pid
78
+ && locked.startedAt !== undefined && current.startedAt === locked.startedAt
79
+ && locked.tty !== undefined && current.tty === locked.tty);
80
+ const samePane = (first, second) => (first.paneId === second.paneId && first.sessionName === second.sessionName
81
+ && first.windowId === second.windowId && first.tty === second.tty);
82
+ const receiptRecovery = (receipt) => ({
83
+ kind: 'codex_resume',
84
+ sessionId: receipt.sessionId,
85
+ command: receipt.command,
86
+ });
87
+ const recoveryReceipt = async (paneId, expectedOperationId) => {
88
+ const receipt = expectedOperationId
89
+ ? receipts?.get(expectedOperationId) : receipts?.latestForPane(paneId);
90
+ if (!receipt || receipt.pane.paneId !== paneId)
91
+ return null;
92
+ const ownership = await app.discover(paneId);
93
+ if (ownership?.managed === true && ownership.threadId === receipt.sessionId) {
94
+ receipts?.clearManaged(paneId, receipt.sessionId);
95
+ return null;
96
+ }
97
+ const currentPane = await pane(paneId);
98
+ const currentEpoch = await commands.paneEpoch?.(paneId);
99
+ const current = !!currentPane
100
+ && currentPane.paneId === receipt.pane.paneId
101
+ && currentPane.sessionName === receipt.pane.sessionName
102
+ && currentPane.windowId === receipt.pane.windowId
103
+ && !!currentEpoch && currentEpoch === receipt.pane.tmuxEpoch;
104
+ let canResume = false;
105
+ if (current && receipt.phase !== 'resuming') {
106
+ const identity = await shellIdentity(paneId);
107
+ canResume = identity !== null;
108
+ }
109
+ return {
110
+ receipt,
111
+ view: {
112
+ operationId: receipt.operationId,
113
+ recovery: receiptRecovery(receipt),
114
+ phase: receipt.phase,
115
+ state: current ? 'current' : 'stale',
116
+ canResume,
117
+ },
118
+ };
119
+ };
39
120
  return {
40
121
  apiVersion: 1,
122
+ async recovery(paneId) {
123
+ return (await recoveryReceipt(paneId))?.view ?? null;
124
+ },
125
+ async recover(paneId, operationId, signal, progress) {
126
+ return await serializePaneInput(paneId, async () => {
127
+ throwIfAborted(signal);
128
+ const resolved = await recoveryReceipt(paneId, operationId);
129
+ if (!resolved)
130
+ throw new ConversationActivationError('Conversation recovery is no longer available', 'unavailable');
131
+ const recovery = receiptRecovery(resolved.receipt);
132
+ progress.recovery(recovery);
133
+ if (resolved.view.state !== 'current' || !resolved.view.canResume) {
134
+ if (resolved.receipt.phase === 'resuming')
135
+ return { recovery };
136
+ throw new ConversationActivationError('The original pane is no longer safe to resume; copy the recovery command instead', 'unavailable', recovery);
137
+ }
138
+ const claimed = receipts?.transition(operationId, ['prepared', 'interrupted'], 'resuming');
139
+ if (!claimed)
140
+ throw new ConversationActivationError('Conversation recovery is unavailable', 'unavailable', recovery);
141
+ throwIfAborted(signal);
142
+ const rechecked = await recoveryReceipt(paneId, operationId);
143
+ if (!rechecked || rechecked.view.state !== 'current') {
144
+ throw new ConversationActivationError('The pane changed before Conversation recovery; copy the recovery command instead', 'unavailable', recovery);
145
+ }
146
+ const shell = await shellIdentity(paneId);
147
+ if (!shell)
148
+ throw new ConversationActivationError('The pane shell changed before Conversation recovery; copy the recovery command instead', 'unavailable', recovery);
149
+ // The service can time out while either asynchronous recheck above is still pending.
150
+ // Revalidate cancellation at the side-effect boundary, not only before the rechecks.
151
+ throwIfAborted(signal);
152
+ await commands.runPaneCommand(paneId, recovery.command);
153
+ return { recovery };
154
+ });
155
+ },
41
156
  async describe(run) {
42
- if (run.ref.sessionId || run.signal.aborted || !await codexIdentity(run.ref.paneId))
157
+ if (run.ref.sessionId || run.signal.aborted)
158
+ return null;
159
+ const lockedProcess = run.process;
160
+ if (!lockedProcess || lockedProcess.startedAt === undefined || !lockedProcess.tty)
161
+ return null;
162
+ let processIdentity;
163
+ try {
164
+ processIdentity = await nativeCodexTakeoverIdentity(run.ref.paneId);
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ if (!processIdentity || !processIdentity.executable
170
+ || !sameRunProcess(lockedProcess, processIdentity))
171
+ return null;
172
+ if (!await commands.inspectOpenSession(processIdentity.pid))
43
173
  return null;
44
174
  return { effect: 'replace-process-preserve-session' };
45
175
  },
46
- async activate(run, signal) {
47
- await serializePaneInput(run.ref.paneId, async () => {
176
+ async activate(run, signal, progress) {
177
+ return await serializePaneInput(run.ref.paneId, async () => {
48
178
  const paneId = run.ref.paneId;
49
179
  throwIfAborted(signal);
180
+ const originalPane = await pane(paneId);
181
+ if (!originalPane)
182
+ throw new Error('The pane closed before Conversation activation');
183
+ const lockedProcess = run.process;
184
+ if (!lockedProcess || lockedProcess.startedAt === undefined || !lockedProcess.tty) {
185
+ throw new Error('The Agent run identity is incomplete; continue in the terminal');
186
+ }
50
187
  const original = !run.ref.sessionId && !run.signal.aborted
51
- ? await codexIdentity(paneId) : null;
188
+ ? await nativeCodexTakeoverIdentity(paneId) : null;
52
189
  throwIfAborted(signal);
53
- if (!original)
190
+ if (!original || !sameRunProcess(lockedProcess, original)) {
54
191
  throw new Error('The Agent run changed before Conversation activation');
55
- // Authorization is bound to the verified run above. Exiting that exact process intentionally revokes
56
- // its lease, so subsequent checks bind recovery to the shell that replaced it.
57
- let exited = false;
58
- let shell = null;
59
- for (let press = 0; press < 2 && !exited; press += 1) {
60
- throwIfAborted(signal);
61
- const beforePress = await codexIdentity(paneId);
62
- if (!beforePress || !sameProcess(original, beforePress)) {
63
- throw new Error('The Agent process changed before Conversation activation');
64
- }
192
+ }
193
+ if (original.startedAt === undefined || !original.tty || !original.executable) {
194
+ throw new Error('The Agent process identity is incomplete; continue in the terminal');
195
+ }
196
+ const lockedSession = await commands.inspectOpenSession(original.pid);
197
+ throwIfAborted(signal);
198
+ if (!lockedSession) {
199
+ throw new Error('Codex current session could not be identified safely; continue in the terminal');
200
+ }
201
+ const tmuxEpoch = receipts ? await commands.paneEpoch?.(paneId) : null;
202
+ if (receipts && (!tmuxEpoch || original.startedAt === undefined || !original.tty
203
+ || !original.executable)) {
204
+ throw new Error('The pane identity is incomplete; continue in the terminal');
205
+ }
206
+ const sessionId = lockedSession.sessionId;
207
+ const resume = codex.sessions.managedResumeCmd?.(sessionId);
208
+ if (!resume)
209
+ throw new Error('Managed Conversation activation is unavailable');
210
+ const recovery = { kind: 'codex_resume', sessionId, command: resume };
211
+ let durable = null;
212
+ if (receipts && tmuxEpoch && original.startedAt !== undefined && original.tty && original.executable) {
213
+ durable = receipts.prepare({
214
+ pane: {
215
+ paneId,
216
+ sessionName: originalPane.sessionName,
217
+ windowId: originalPane.windowId,
218
+ tmuxEpoch,
219
+ },
220
+ process: {
221
+ pid: original.pid,
222
+ startedAt: original.startedAt,
223
+ tty: original.tty,
224
+ executable: original.executable,
225
+ },
226
+ sessionId,
227
+ command: resume,
228
+ });
229
+ }
230
+ progress.recovery(recovery);
231
+ const outputCapture = await commands.openOutputCapture(paneId);
232
+ const abortCapture = () => outputCapture.close();
233
+ signal.addEventListener('abort', abortCapture, { once: true });
234
+ try {
65
235
  throwIfAborted(signal);
66
- await commands.sendKey(paneId, 'C-c');
67
- for (let attempt = 0; attempt < 10; attempt += 1) {
68
- await wait(500);
236
+ // Authorization is bound to the verified run above. Exiting that exact process intentionally revokes
237
+ // its lease, so subsequent checks bind recovery to the shell that replaced it.
238
+ let exited = false;
239
+ let shell = null;
240
+ for (let press = 0; press < 2 && !exited; press += 1) {
69
241
  throwIfAborted(signal);
70
- if (!await pane(paneId))
71
- throw new Error('The pane closed during Conversation activation');
72
- const currentIdentity = await codexIdentity(paneId);
73
- if (!currentIdentity) {
74
- shell = await shellIdentity(paneId);
75
- if (!shell)
76
- throw new Error('The pane did not return to the expected shell');
77
- exited = true;
78
- break;
242
+ const beforePane = await pane(paneId);
243
+ if (!beforePane || !samePane(originalPane, beforePane)) {
244
+ throw new Error('The pane changed before Conversation activation');
79
245
  }
80
- if (!sameProcess(original, currentIdentity)) {
81
- throw new Error('The Agent process changed during Conversation activation');
246
+ if (tmuxEpoch && await commands.paneEpoch?.(paneId) !== tmuxEpoch) {
247
+ throw new Error('The tmux server changed before Conversation activation');
82
248
  }
249
+ // A previous activation may have returned before Runtime revoked this sessionless lease.
250
+ // Re-read provider ownership at the irreversible boundary so a second client can never
251
+ // interrupt the newly managed replacement through the still-resolvable old run.
252
+ const beforePress = await nativeCodexTakeoverIdentity(paneId);
253
+ if (!beforePress || !sameProcess(original, beforePress)) {
254
+ throw new Error('The Agent process changed before Conversation activation');
255
+ }
256
+ const stillOpen = await commands.inspectOpenSession(original.pid);
257
+ if (!stillOpen || !sameCodexOpenSession(lockedSession, stillOpen)) {
258
+ throw new Error('The Codex session changed before Conversation activation');
259
+ }
260
+ throwIfAborted(signal);
261
+ // This exact control-mode connection sends C-c and arms collection synchronously at that
262
+ // command's %end. Output rendered before the interrupt can never satisfy freshness.
263
+ await outputCapture.sendKey('C-c');
264
+ if (durable) {
265
+ durable = receipts?.transition(durable.operationId, ['prepared', 'interrupted'], 'interrupted') ?? durable;
266
+ }
267
+ let originalStillVerified = true;
268
+ for (let attempt = 0; attempt < 10; attempt += 1) {
269
+ await wait(500);
270
+ throwIfAborted(signal);
271
+ const current = await processAfterInterrupt(paneId);
272
+ if (!current)
273
+ throw new Error('The pane closed during Conversation activation');
274
+ if (current.kind === 'unknown') {
275
+ originalStillVerified = false;
276
+ continue;
277
+ }
278
+ if (current.kind === 'shell') {
279
+ shell = current.identity;
280
+ exited = true;
281
+ break;
282
+ }
283
+ if (current.kind === 'other' || !current.identity
284
+ || !sameProcess(original, current.identity)) {
285
+ throw new Error('The Agent process changed during Conversation activation');
286
+ }
287
+ originalStillVerified = true;
288
+ }
289
+ // Never send another interrupt unless the last complete snapshot still proves the original Codex.
290
+ if (!originalStillVerified)
291
+ break;
83
292
  }
84
- }
85
- if (!exited || !shell)
86
- throw new Error('Codex did not exit; close any open panel in the terminal and try again');
87
- let sessionId = null;
88
- for (let attempt = 0; attempt < 20; attempt += 1) {
89
- throwIfAborted(signal);
90
- const beforeCapture = await shellIdentity(paneId);
91
- if (!beforeCapture || !sameProcess(shell, beforeCapture)) {
92
- throw new Error('The pane shell changed during Conversation activation');
93
- }
293
+ if (!exited || !shell)
294
+ throw new Error('Codex did not exit; close any open panel in the terminal and try again');
94
295
  try {
95
- const candidate = codexExitSessionId(await commands.capturePlain(paneId));
96
- if (codex.sessions.isId(candidate)) {
97
- sessionId = candidate;
98
- break;
296
+ throwIfAborted(signal);
297
+ const beforeResume = await shellIdentity(paneId);
298
+ if (!beforeResume || !sameProcess(shell, beforeResume)) {
299
+ throw new Error('The pane shell changed before Conversation activation');
99
300
  }
301
+ throwIfAborted(signal);
302
+ if (durable) {
303
+ durable = receipts?.transition(durable.operationId, ['interrupted', 'resuming'], 'resuming') ?? durable;
304
+ }
305
+ await commands.runPaneCommand(paneId, resume);
100
306
  }
101
- catch {
102
- throw new Error('The pane closed during Conversation activation');
307
+ catch (error) {
308
+ if (error instanceof ConversationActivationError)
309
+ throw error;
310
+ throw new ConversationActivationError('Conversation activation could not finish; use the recovery command or continue in the terminal', 'unavailable', recovery);
103
311
  }
104
- await wait(100);
312
+ return { recovery };
105
313
  }
106
- if (!sessionId)
107
- throw new Error('Codex did not expose a resumable session; continue in the terminal');
108
- const resume = codex.sessions.managedResumeCmd?.(sessionId);
109
- if (!resume)
110
- throw new Error('Managed Conversation activation is unavailable');
111
- throwIfAborted(signal);
112
- const beforeResume = await shellIdentity(paneId);
113
- if (!beforeResume || !sameProcess(shell, beforeResume)) {
114
- throw new Error('The pane shell changed before Conversation activation');
314
+ finally {
315
+ signal.removeEventListener('abort', abortCapture);
316
+ outputCapture.close();
115
317
  }
116
- throwIfAborted(signal);
117
- await commands.runPaneCommand(paneId, resume);
118
318
  });
119
319
  },
120
320
  };
@@ -0,0 +1,164 @@
1
+ import path from 'node:path';
2
+ import { promises as fsp } from 'node:fs';
3
+ import { defaultRun, isSessionUuid } from './scanUtils.js';
4
+ import { rolloutSessionId } from './codex.js';
5
+ const MAX_SESSION_META_BYTES = 1024 * 1024;
6
+ function isRecord(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+ function lsofOpenFiles(value) {
10
+ const files = [];
11
+ let fd = '';
12
+ for (const line of String(value || '').split('\n')) {
13
+ if (line[0] === 'f') {
14
+ fd = line.slice(1).trim();
15
+ continue;
16
+ }
17
+ if (line[0] === 'n' && fd)
18
+ files.push({ fd, file: line.slice(1) });
19
+ }
20
+ return files;
21
+ }
22
+ async function procOpenFiles(pid, fs) {
23
+ const directory = `/proc/${pid}/fd`;
24
+ let names;
25
+ try {
26
+ names = await fs.readdir(directory);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ const files = [];
32
+ for (const fd of names) {
33
+ if (!/^\d+$/.test(fd))
34
+ continue;
35
+ try {
36
+ const file = await fs.readlink(path.join(directory, fd));
37
+ // Linux appends this marker after the original path once the directory entry is gone. A deleted
38
+ // rollout cannot be resumed by id, so it is never a takeover candidate.
39
+ if (file && !file.endsWith(' (deleted)'))
40
+ files.push({ fd, file });
41
+ }
42
+ catch { /* the descriptor closed while enumerating */ }
43
+ }
44
+ return files;
45
+ }
46
+ async function readFirstLine(file, fs) {
47
+ let handle;
48
+ try {
49
+ handle = await fs.open(file, 'r');
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ try {
55
+ const buffer = Buffer.alloc(MAX_SESSION_META_BYTES + 1);
56
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
57
+ if (bytesRead === 0)
58
+ return null;
59
+ const newline = buffer.subarray(0, bytesRead).indexOf(0x0a);
60
+ if (newline < 0 && bytesRead > MAX_SESSION_META_BYTES)
61
+ return null;
62
+ return buffer.subarray(0, newline < 0 ? bytesRead : newline).toString('utf8').replace(/\r$/, '');
63
+ }
64
+ finally {
65
+ await handle.close();
66
+ }
67
+ }
68
+ function rootSessionMeta(line, sessionId) {
69
+ if (!line)
70
+ return null;
71
+ let value;
72
+ try {
73
+ value = JSON.parse(line);
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ if (!isRecord(value) || value.type !== 'session_meta' || !isRecord(value.payload))
79
+ return null;
80
+ const meta = value.payload;
81
+ if (meta.id !== sessionId || meta.session_id !== sessionId
82
+ || (meta.parent_thread_id !== undefined && meta.parent_thread_id !== null)
83
+ || meta.thread_source !== 'user'
84
+ || typeof meta.cwd !== 'string' || !meta.cwd)
85
+ return null;
86
+ return { cwd: meta.cwd };
87
+ }
88
+ function inside(root, file) {
89
+ const relative = path.relative(root, file);
90
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`)
91
+ && !path.isAbsolute(relative);
92
+ }
93
+ // Resolve the exact root conversation owned by one already-verified foreground Codex process. The process
94
+ // may also hold many subagent rollouts; metadata, not cwd or mtime, distinguishes the single TUI root.
95
+ // Every uncertainty fails closed before activation can interrupt the process.
96
+ export async function inspectCodexOpenRootSession(pid, sessionsRoot, { platform = process.platform, run = defaultRun, fs = fsp, } = {}) {
97
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !path.isAbsolute(sessionsRoot))
98
+ return null;
99
+ let canonicalRoot;
100
+ try {
101
+ canonicalRoot = await fs.realpath(sessionsRoot);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ let openFiles;
107
+ if (platform === 'linux') {
108
+ openFiles = await procOpenFiles(pid, fs);
109
+ }
110
+ else {
111
+ let output = '';
112
+ try {
113
+ output = await run('lsof', ['-a', '-p', String(pid), '-Fn']);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ openFiles = lsofOpenFiles(output);
119
+ }
120
+ if (!openFiles.length)
121
+ return null;
122
+ const candidates = new Map();
123
+ for (const open of openFiles) {
124
+ let canonicalFile;
125
+ try {
126
+ canonicalFile = await fs.realpath(open.file);
127
+ }
128
+ catch {
129
+ continue;
130
+ }
131
+ if (!inside(canonicalRoot, canonicalFile))
132
+ continue;
133
+ const sessionId = rolloutSessionId(path.basename(canonicalFile));
134
+ if (!sessionId || !isSessionUuid(sessionId))
135
+ continue;
136
+ let stat;
137
+ try {
138
+ stat = await fs.stat(canonicalFile);
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ if (!stat.isFile())
144
+ continue;
145
+ const meta = rootSessionMeta(await readFirstLine(canonicalFile, fs), sessionId);
146
+ if (!meta)
147
+ continue;
148
+ const candidate = {
149
+ sessionId: sessionId.toLowerCase(),
150
+ file: canonicalFile,
151
+ cwd: meta.cwd,
152
+ fd: open.fd,
153
+ device: String(stat.dev),
154
+ inode: String(stat.ino),
155
+ };
156
+ const key = `${candidate.device}:${candidate.inode}`;
157
+ candidates.set(key, candidate);
158
+ }
159
+ return candidates.size === 1 ? [...candidates.values()][0] ?? null : null;
160
+ }
161
+ export function sameCodexOpenSession(first, second) {
162
+ return first.sessionId === second.sessionId && first.file === second.file
163
+ && first.fd === second.fd && first.device === second.device && first.inode === second.inode;
164
+ }