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.
- package/README.md +20 -93
- package/README.zh-CN.md +20 -93
- package/dist/package.json +19 -19
- package/dist/public/assets/index-B4pKf3IW.js +356 -0
- package/dist/public/assets/index-Cdrh2CUp.css +32 -0
- package/dist/public/assets/pcm-worklet-NzlBQ6-t.js +1 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +2 -0
- package/dist/src/agent-runtime/builtinRuntime.js +3 -1
- package/dist/src/agent-runtime/conversationActivation.js +136 -4
- package/dist/src/agent-runtime/run.js +23 -2
- package/dist/src/agent-runtime/runtime.js +174 -15
- package/dist/src/agent-runtime/tmuxRuntime.js +3 -7
- package/dist/src/agents/codex.js +78 -10
- package/dist/src/agents/codexActivationReceipt.js +149 -0
- package/dist/src/agents/codexConversation.js +59 -20
- package/dist/src/agents/codexConversationActivation.js +265 -65
- package/dist/src/agents/codexOpenSession.js +164 -0
- package/dist/src/agents/nativeInbox.js +24 -56
- package/dist/src/agents/scanUtils.js +7 -1
- package/dist/src/apiErrors.js +10 -1
- package/dist/src/asr/config.js +29 -0
- package/dist/src/asr/providerRegistry.js +19 -0
- package/dist/src/asr/providers/tencent.js +135 -0
- package/dist/src/asr/providers/xfyun.js +73 -0
- package/dist/src/asr/tencentSentence.js +111 -0
- package/dist/src/asr/tencentSign.js +20 -0
- package/dist/src/asr/verify.js +80 -0
- package/dist/src/cli/codexManaged.js +13 -2
- package/dist/src/cli/i18n/en.js +16 -1
- package/dist/src/cli/i18n/zh.js +16 -1
- package/dist/src/cli/options.js +29 -2
- package/dist/src/cli/setupModel.js +49 -5
- package/dist/src/cli/setupWizard.js +149 -29
- package/dist/src/cli/supervisor.js +24 -8
- package/dist/src/cli/supervisorLaunch.js +26 -0
- package/dist/src/codexAppServer.js +41 -4
- package/dist/src/routes/agents.js +51 -14
- package/dist/src/routes/system.js +109 -10
- package/dist/src/server.js +11 -2
- package/dist/src/terminalStream.js +8 -30
- package/dist/src/tmux/commands.js +14 -0
- package/dist/src/tmux/controlProtocol.js +26 -0
- package/dist/src/tmux/paneOutputCapture.js +180 -0
- package/package.json +5 -5
- package/dist/public/assets/index-BVFKs5dN.css +0 -32
- package/dist/public/assets/index-KDa5PCoi.js +0 -356
- 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
|
-
|
|
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
|
|
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
|
|
61
|
+
function compareProcess(candidate, pane, foreground) {
|
|
62
62
|
if (foreground.pid !== candidate.process.pid)
|
|
63
|
-
return
|
|
64
|
-
if (pane.foregroundPid !== undefined
|
|
65
|
-
|
|
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
|
|
70
|
+
return 'invalid';
|
|
69
71
|
const observedTty = foreground.tty ?? pane.tty;
|
|
70
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
14
|
-
//
|
|
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(() => {
|
package/dist/src/agents/codex.js
CHANGED
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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;
|