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
|
@@ -15,6 +15,7 @@ const MAX_SUBMISSION_RECEIPTS = 256;
|
|
|
15
15
|
const SUBMISSION_RECEIPT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
16
16
|
const MAX_STREAM_EVENTS = 512;
|
|
17
17
|
const MAX_STREAM_MESSAGE_IDS = 100;
|
|
18
|
+
const MAX_COMPACTOR_ITEM_KEYS = 128;
|
|
18
19
|
const MAX_OBSERVATION_SETUP_EVENTS = 512;
|
|
19
20
|
const MAX_GOAL_READ_ATTEMPTS = 3;
|
|
20
21
|
const CONTROL_TURN_LIMIT = 2;
|
|
@@ -41,6 +42,20 @@ const RESUME_SNAPSHOT_INVALIDATING_METHODS = new Set([
|
|
|
41
42
|
'thread/compacted',
|
|
42
43
|
'thread/started',
|
|
43
44
|
]);
|
|
45
|
+
function compactorItemKey(turnId, itemId) {
|
|
46
|
+
return `${turnId}\0${itemId}`;
|
|
47
|
+
}
|
|
48
|
+
function rememberCompactorItem(state, turnId, itemId) {
|
|
49
|
+
const key = compactorItemKey(turnId, itemId);
|
|
50
|
+
state.compactorItemKeys.delete(key);
|
|
51
|
+
state.compactorItemKeys.add(key);
|
|
52
|
+
while (state.compactorItemKeys.size > MAX_COMPACTOR_ITEM_KEYS) {
|
|
53
|
+
const oldest = state.compactorItemKeys.values().next().value;
|
|
54
|
+
if (typeof oldest !== 'string')
|
|
55
|
+
break;
|
|
56
|
+
state.compactorItemKeys.delete(oldest);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
44
59
|
function recordOf(value) {
|
|
45
60
|
return value != null && typeof value === 'object' && !Array.isArray(value)
|
|
46
61
|
? value
|
|
@@ -788,6 +803,9 @@ class CodexAppConnection {
|
|
|
788
803
|
state.compacting = false;
|
|
789
804
|
}
|
|
790
805
|
if (params.item.type === 'agentMessage') {
|
|
806
|
+
if (state.compacting && params.turnId && params.item.id) {
|
|
807
|
+
rememberCompactorItem(state, params.turnId, params.item.id);
|
|
808
|
+
}
|
|
791
809
|
const itemKey = `${params.turnId}\0${params.item.id}`;
|
|
792
810
|
if (message.method === 'item/completed')
|
|
793
811
|
state.completedAgentItemIds.add(itemKey);
|
|
@@ -991,7 +1009,8 @@ class CodexAppConnection {
|
|
|
991
1009
|
activePrompt: '', contextUsage: null, lastTurn: null, loadedOnly: false, liveItemIds: new Map(),
|
|
992
1010
|
completedAgentItemIds: new Set(), plans: new Map(), goal: undefined,
|
|
993
1011
|
goalRevision: 0, goalEvent: null, goalTurnId: null,
|
|
994
|
-
compacting: false,
|
|
1012
|
+
compacting: false, compactorItemKeys: new Set(),
|
|
1013
|
+
deliveryHistorySafe: true, deliveryProofClientIds: new Set(),
|
|
995
1014
|
});
|
|
996
1015
|
return this.threadState.get(threadId);
|
|
997
1016
|
}
|
|
@@ -2995,10 +3014,20 @@ export function createCodexAppServer({ home, connect = connectUnixWebSocket, exi
|
|
|
2995
3014
|
return { client, thread: await client.readThread(threadId) };
|
|
2996
3015
|
},
|
|
2997
3016
|
async discover(pane) {
|
|
2998
|
-
|
|
2999
|
-
|
|
3017
|
+
// Absence of the pane-owned socket is the only authoritative unmanaged verdict. If the
|
|
3018
|
+
// socket exists but its App Server is still starting or reconnecting, ownership is unknown;
|
|
3019
|
+
// callers must not offer to replace that Codex process as though it were native/unmanaged.
|
|
3020
|
+
if (!exists(codexAppSocketPath(pane, home)))
|
|
3000
3021
|
return { managed: false, threadId: null };
|
|
3001
|
-
|
|
3022
|
+
try {
|
|
3023
|
+
const observed = await observe(pane);
|
|
3024
|
+
return observed
|
|
3025
|
+
? { managed: true, threadId: observed.threadId }
|
|
3026
|
+
: { managed: null, threadId: null };
|
|
3027
|
+
}
|
|
3028
|
+
catch {
|
|
3029
|
+
return { managed: null, threadId: null };
|
|
3030
|
+
}
|
|
3002
3031
|
},
|
|
3003
3032
|
async inboxStates(livePanes = []) {
|
|
3004
3033
|
const out = {};
|
|
@@ -3052,6 +3081,14 @@ export function createCodexAppServer({ home, connect = connectUnixWebSocket, exi
|
|
|
3052
3081
|
revision: state.revision,
|
|
3053
3082
|
};
|
|
3054
3083
|
},
|
|
3084
|
+
async compactorItemKeys(pane, threadId) {
|
|
3085
|
+
const client = await connection(pane);
|
|
3086
|
+
if (!client)
|
|
3087
|
+
throw new Error('Codex session is not managed by Handmux');
|
|
3088
|
+
await client.assertCurrentThread(threadId);
|
|
3089
|
+
const state = await client.ensureThread(threadId);
|
|
3090
|
+
return [...state.compactorItemKeys];
|
|
3091
|
+
},
|
|
3055
3092
|
async subscribe(pane, threadId, listener, afterSequence = null) {
|
|
3056
3093
|
const client = await connection(pane);
|
|
3057
3094
|
if (!client)
|
|
@@ -160,7 +160,11 @@ export function agentRoutes({ runtime }) {
|
|
|
160
160
|
const status = error.code === 'unsupported' ? 409
|
|
161
161
|
: error.code === 'in_progress' ? 409
|
|
162
162
|
: error.code === 'contract_violation' ? 502 : 503;
|
|
163
|
-
res.status(status).json({
|
|
163
|
+
res.status(status).json({
|
|
164
|
+
error: error.message,
|
|
165
|
+
code: error.code,
|
|
166
|
+
...(error.recovery === undefined ? {} : { recovery: error.recovery }),
|
|
167
|
+
});
|
|
164
168
|
return true;
|
|
165
169
|
};
|
|
166
170
|
router.get('/agents/conversation-activation', async (req, res, next) => {
|
|
@@ -193,21 +197,54 @@ export function agentRoutes({ runtime }) {
|
|
|
193
197
|
error: ref ? 'stale agent run' : 'invalid agent run',
|
|
194
198
|
...(ref ? { code: 'stale_run' } : {}),
|
|
195
199
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
// The user already confirmed a destructive process replacement. Once accepted, C-c → managed
|
|
201
|
+
// resume is one bounded server operation: a mobile navigation or dropped HTTP connection must not
|
|
202
|
+
// strand the pane at the intermediate shell. AgentConversationActivationService still owns its
|
|
203
|
+
// hard timeout and the controller revalidates the process before every irreversible step.
|
|
204
|
+
const result = await runtime.conversationActivation.activate(lease);
|
|
205
|
+
return res.status(202).json({
|
|
206
|
+
accepted: true,
|
|
207
|
+
...(result?.recovery === undefined ? {} : { recovery: result.recovery }),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (activationError(error, res))
|
|
212
|
+
return;
|
|
213
|
+
return next(error);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
router.get('/agents/conversation-activation-recovery', async (req, res, next) => {
|
|
217
|
+
try {
|
|
218
|
+
if (!runtime.conversationActivation) {
|
|
219
|
+
return res.status(409).json({ error: 'conversation recovery unsupported', code: 'unsupported' });
|
|
205
220
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
res.
|
|
221
|
+
const paneId = typeof req.query.paneId === 'string' ? req.query.paneId : '';
|
|
222
|
+
if (!/^%\d+$/.test(paneId)) {
|
|
223
|
+
return res.status(400).json({ error: 'invalid conversation recovery pane' });
|
|
209
224
|
}
|
|
210
|
-
return res.
|
|
225
|
+
return res.json({ receipt: await runtime.conversationActivation.recovery(paneId) });
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (activationError(error, res))
|
|
229
|
+
return;
|
|
230
|
+
return next(error);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
router.post('/agents/conversation-activation-recovery', async (req, res, next) => {
|
|
234
|
+
try {
|
|
235
|
+
if (!runtime.conversationActivation) {
|
|
236
|
+
return res.status(409).json({ error: 'conversation recovery unsupported', code: 'unsupported' });
|
|
237
|
+
}
|
|
238
|
+
const paneId = typeof req.body?.paneId === 'string' ? req.body.paneId : '';
|
|
239
|
+
const operationId = typeof req.body?.operationId === 'string' ? req.body.operationId : '';
|
|
240
|
+
if (!/^%\d+$/.test(paneId) || !/^[0-9a-f]{64}$/.test(operationId)) {
|
|
241
|
+
return res.status(400).json({ error: 'invalid conversation recovery request' });
|
|
242
|
+
}
|
|
243
|
+
const result = await runtime.conversationActivation.recover(paneId, operationId);
|
|
244
|
+
return res.status(202).json({
|
|
245
|
+
accepted: true,
|
|
246
|
+
...(result?.recovery === undefined ? {} : { recovery: result.recovery }),
|
|
247
|
+
});
|
|
211
248
|
}
|
|
212
249
|
catch (error) {
|
|
213
250
|
if (activationError(error, res))
|
|
@@ -6,20 +6,29 @@ import { dirname, resolve as resolvePath } from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { isSessionId } from '../tmux/commands.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { asrConfig } from '../asr/config.js';
|
|
10
|
+
import { voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
11
11
|
import { hooksStatus, installHooks } from '../cli/claudeHooks.js';
|
|
12
12
|
import { agentIntegrationStatus, defaultAgentIntegrationContext, enableAgentIntegration, } from '../cli/agentIntegration.js';
|
|
13
13
|
import { scanOrphans, takeoverOrphan, defaultProjectsDir } from '../orphans.js';
|
|
14
14
|
import { readCache, isNewer, shouldRefresh, refreshLatestAsync } from '../cli/updateCheck.js';
|
|
15
15
|
import { normalizeShortcuts } from '../shortcutConfig.js';
|
|
16
16
|
import { projectLegacyInboxStates } from '../agent-runtime/legacyInboxProjection.js';
|
|
17
|
+
import { PublicApiError } from '../apiErrors.js';
|
|
18
|
+
export const MAX_SENTENCE_PCM_BYTES = 2 * 1024 * 1024;
|
|
17
19
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
20
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
19
21
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const HOOKS_SRC = resolvePath(here, '../../hooks'); // server/hooks (bundled scripts)
|
|
21
23
|
const PI_ENTRY = resolvePath(here, '../../connectors/pi/index.js');
|
|
22
24
|
const WEB_AGENT_INTEGRATIONS = ['claude', 'pi'];
|
|
25
|
+
const FILLER_FILTER_LEVELS = ['low', 'medium', 'high'];
|
|
26
|
+
const requestFillerFilter = (value) => {
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return 'medium';
|
|
29
|
+
return typeof value === 'string' && FILLER_FILTER_LEVELS.includes(value)
|
|
30
|
+
? value : null;
|
|
31
|
+
};
|
|
23
32
|
// The installed CLI version (server/package.json) — read once. The phone compares this against the cached
|
|
24
33
|
// npm "latest" to surface an update hint ("run `handmux update` on your computer"); see the /version route.
|
|
25
34
|
const PKG_VERSION = (() => {
|
|
@@ -47,8 +56,13 @@ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, sho
|
|
|
47
56
|
// `claudeHooks` is intentionally Claude-only. Codex is always App Server-backed and never affects
|
|
48
57
|
// this setup prompt or endpoint.
|
|
49
58
|
r.get('/config', (_req, res) => {
|
|
59
|
+
const voice = asrConfig(asrEnv);
|
|
60
|
+
const voiceAdapter = voice ? voiceProviderRegistry.get(voice.provider) : undefined;
|
|
50
61
|
return res.json({
|
|
51
|
-
asr:
|
|
62
|
+
asr: voice !== null,
|
|
63
|
+
asrProvider: voice?.provider ?? null,
|
|
64
|
+
asrMode: voice?.mode ?? null,
|
|
65
|
+
asrFillerFilter: voiceAdapter?.capabilities?.fillerFilter === true,
|
|
52
66
|
claudeHooks: hooksStatus(home),
|
|
53
67
|
managedCodex: true,
|
|
54
68
|
shortcuts: activeShortcuts,
|
|
@@ -131,14 +145,99 @@ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, sho
|
|
|
131
145
|
return next(error);
|
|
132
146
|
}
|
|
133
147
|
});
|
|
134
|
-
// --- Voice input:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (!isAsrConfigured(asrEnv))
|
|
148
|
+
// --- Voice input: provider-neutral, short-lived session handoff -----------------------------
|
|
149
|
+
r.get('/asr/session', (req, res) => {
|
|
150
|
+
const config = asrConfig(asrEnv);
|
|
151
|
+
if (!config)
|
|
139
152
|
return res.status(503).json({ error: 'asr not configured' });
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
if (config.mode !== 'streaming') {
|
|
154
|
+
return res.status(503).json({ error: 'streaming asr is not selected' });
|
|
155
|
+
}
|
|
156
|
+
const adapter = voiceProviderRegistry.get(config.provider);
|
|
157
|
+
if (!adapter?.createStreamingSession) {
|
|
158
|
+
return res.status(503).json({ error: 'streaming asr is unsupported by the selected provider' });
|
|
159
|
+
}
|
|
160
|
+
const fillerFilter = requestFillerFilter(req.query.fillerFilter);
|
|
161
|
+
if (!fillerFilter)
|
|
162
|
+
return res.status(400).json({ error: 'invalid filler filter level' });
|
|
163
|
+
return res.json(adapter.createStreamingSession(config, { fillerFilter }));
|
|
164
|
+
});
|
|
165
|
+
const rawSentenceAudio = express.raw({
|
|
166
|
+
type: 'application/octet-stream', limit: MAX_SENTENCE_PCM_BYTES,
|
|
167
|
+
});
|
|
168
|
+
const parseSentenceAudio = (req, res, next) => {
|
|
169
|
+
if (!req.is('application/octet-stream')) {
|
|
170
|
+
res.status(415).json({
|
|
171
|
+
error: 'audio must use application/octet-stream',
|
|
172
|
+
code: 'unsupported_audio_type', requestId: res.locals.requestId,
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
rawSentenceAudio(req, res, (error) => {
|
|
177
|
+
const bodyError = error && typeof error === 'object'
|
|
178
|
+
? error : null;
|
|
179
|
+
if (bodyError?.status === 413 || bodyError?.type === 'entity.too.large') {
|
|
180
|
+
res.status(413).json({
|
|
181
|
+
error: `audio exceeds ${MAX_SENTENCE_PCM_BYTES} bytes`,
|
|
182
|
+
code: 'audio_too_large', requestId: res.locals.requestId,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
next(error);
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
r.post('/asr/sentence', parseSentenceAudio, async (req, res, next) => {
|
|
190
|
+
const config = asrConfig(asrEnv);
|
|
191
|
+
const adapter = config ? voiceProviderRegistry.get(config.provider) : undefined;
|
|
192
|
+
if (!config || config.mode !== 'sentence' || !adapter?.recognizeSentence) {
|
|
193
|
+
return res.status(503).json({
|
|
194
|
+
error: 'sentence ASR is not selected',
|
|
195
|
+
code: 'sentence_asr_not_selected', requestId: res.locals.requestId,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const audio = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
199
|
+
if (audio.byteLength === 0)
|
|
200
|
+
return res.status(400).json({
|
|
201
|
+
error: 'audio is empty', code: 'audio_empty', requestId: res.locals.requestId,
|
|
202
|
+
});
|
|
203
|
+
const fillerFilter = requestFillerFilter(req.query.fillerFilter);
|
|
204
|
+
if (!fillerFilter)
|
|
205
|
+
return res.status(400).json({
|
|
206
|
+
error: 'invalid filler filter level', code: 'invalid_filler_filter', requestId: res.locals.requestId,
|
|
207
|
+
});
|
|
208
|
+
try {
|
|
209
|
+
const result = await adapter.recognizeSentence(config, audio, { fillerFilter });
|
|
210
|
+
return res.json({ text: result.text });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const publicError = adapter.publicError?.(error);
|
|
214
|
+
if (publicError) {
|
|
215
|
+
if (publicError.logFields) {
|
|
216
|
+
return next(new PublicApiError(502, publicError.code, publicError.message, undefined, {
|
|
217
|
+
...(publicError.providerRequestId
|
|
218
|
+
? { providerRequestId: publicError.providerRequestId } : {}),
|
|
219
|
+
logFields: publicError.logFields,
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
return res.status(502).json({
|
|
223
|
+
error: publicError.message,
|
|
224
|
+
code: publicError.code,
|
|
225
|
+
requestId: res.locals.requestId,
|
|
226
|
+
...(publicError.providerRequestId
|
|
227
|
+
? { providerRequestId: publicError.providerRequestId } : {}),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return next(error);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
// Historical endpoint retained for already-loaded Web clients. It can only describe XFYUN's protocol.
|
|
234
|
+
r.get('/asr/sign', (_req, res) => {
|
|
235
|
+
const config = asrConfig(asrEnv);
|
|
236
|
+
if (!config || config.provider !== 'xfyun')
|
|
237
|
+
return res.status(503).json({ error: 'xfyun asr not configured' });
|
|
238
|
+
const session = voiceProviderRegistry.get(config.provider)?.createStreamingSession?.(config, { fillerFilter: 'medium' });
|
|
239
|
+
return session ? res.json({ url: session.url, appId: session.appId })
|
|
240
|
+
: res.status(503).json({ error: 'xfyun asr not configured' });
|
|
142
241
|
});
|
|
143
242
|
// ?sessions=a,b scopes the roster to the session NAMES this device subscribed to (per-device inbox
|
|
144
243
|
// isolation). Omitted → null → all (back-compat); present-but-empty → [] → nothing.
|
package/dist/src/server.js
CHANGED
|
@@ -39,6 +39,9 @@ import { InboxPushProjection } from './agent-runtime/inboxPushProjection.js';
|
|
|
39
39
|
import { defaultGit } from './git.js';
|
|
40
40
|
import { createProjectTaskRuntime } from './projectTask/runtime.js';
|
|
41
41
|
import { clearCodexConversationThroughTui } from './agents/codexTerminalControl.js';
|
|
42
|
+
import { sessionsDir as codexSessionsDir } from './agents/codex.js';
|
|
43
|
+
import { inspectCodexOpenRootSession } from './agents/codexOpenSession.js';
|
|
44
|
+
import { CodexActivationReceiptStore } from './agents/codexActivationReceipt.js';
|
|
42
45
|
import { ApiAccountService, apiAccountsPath } from './apiAccounts.js';
|
|
43
46
|
import { ClaudeHookBridgeConnector } from '../connectors/claude/index.js';
|
|
44
47
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -82,6 +85,7 @@ const observeEnvironment = createEnvironmentProvider({
|
|
|
82
85
|
const stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(home);
|
|
83
86
|
let codexApp = null;
|
|
84
87
|
const agentRuntimeDirectory = agentRuntimeDirectoryPath(home);
|
|
88
|
+
const codexActivationReceipts = new CodexActivationReceiptStore(path.join(agentRuntimeDirectory, 'codex-activation-receipts.json'));
|
|
85
89
|
const legacyCodexOutbox = codexOutboxPath(home);
|
|
86
90
|
let conversationStartupBlockReason;
|
|
87
91
|
try {
|
|
@@ -150,9 +154,14 @@ const agentRuntime = createBuiltinAgentRuntime({
|
|
|
150
154
|
pendingKind: (paneId) => events?.paneKind(paneId) ?? null,
|
|
151
155
|
},
|
|
152
156
|
codexApp,
|
|
157
|
+
codexActivationReceipts,
|
|
153
158
|
codexActivationCommands: {
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
openOutputCapture: (pane) => commands.openPaneOutputCapture(pane),
|
|
160
|
+
paneEpoch: async () => {
|
|
161
|
+
const environment = await workspaceTmux.observeEnvironment();
|
|
162
|
+
return environment.status === 'present' ? environment.tmuxServerId : null;
|
|
163
|
+
},
|
|
164
|
+
inspectOpenSession: (pid) => inspectCodexOpenRootSession(pid, codexSessionsDir(home)),
|
|
156
165
|
runPaneCommand: (pane, command) => commands.runPaneCommand(pane, command),
|
|
157
166
|
},
|
|
158
167
|
codexClear: async (pane, threadId) => {
|
|
@@ -3,6 +3,8 @@ import { WebSocketServer } from 'ws';
|
|
|
3
3
|
import { tokenEquals } from './auth.js';
|
|
4
4
|
import { isPaneId } from './tmux/commands.js';
|
|
5
5
|
import { restoreCaptureBackgrounds } from './captureBackground.js';
|
|
6
|
+
import { decodeControlData } from './tmux/controlProtocol.js';
|
|
7
|
+
export { decodeControlData } from './tmux/controlProtocol.js';
|
|
6
8
|
const MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
7
9
|
const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024;
|
|
8
10
|
const START_TIMEOUT_MS = 5000;
|
|
@@ -23,7 +25,7 @@ export function echoTerminalProbe(ws, message) {
|
|
|
23
25
|
export function startSubscribeDeadline(ws, timeoutMs = SUBSCRIBE_TIMEOUT_MS) {
|
|
24
26
|
const timer = setTimeout(() => {
|
|
25
27
|
if (ws.readyState < 2)
|
|
26
|
-
ws.close(
|
|
28
|
+
ws.close(4000, 'subscribe timeout');
|
|
27
29
|
}, timeoutMs);
|
|
28
30
|
timer.unref?.();
|
|
29
31
|
return () => clearTimeout(timer);
|
|
@@ -36,32 +38,6 @@ function parsePaneInfo(infoLines) {
|
|
|
36
38
|
}
|
|
37
39
|
return values;
|
|
38
40
|
}
|
|
39
|
-
export function decodeControlData(data) {
|
|
40
|
-
const bytes = [];
|
|
41
|
-
for (let i = 0; i < data.length;) {
|
|
42
|
-
const current = data[i];
|
|
43
|
-
if (current === 0x5c && data[i + 1] === 0x5c) {
|
|
44
|
-
bytes.push(0x5c);
|
|
45
|
-
i += 2;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (current === 0x5c && i + 3 < data.length) {
|
|
49
|
-
const a = data[i + 1];
|
|
50
|
-
const b = data[i + 2];
|
|
51
|
-
const c = data[i + 3];
|
|
52
|
-
if (a !== undefined && b !== undefined && c !== undefined
|
|
53
|
-
&& a >= 0x30 && a <= 0x37 && b >= 0x30 && b <= 0x37 && c >= 0x30 && c <= 0x37) {
|
|
54
|
-
bytes.push(((a - 0x30) << 6) | ((b - 0x30) << 3) | (c - 0x30));
|
|
55
|
-
i += 4;
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
if (current !== undefined)
|
|
60
|
-
bytes.push(current);
|
|
61
|
-
i += 1;
|
|
62
|
-
}
|
|
63
|
-
return Buffer.from(bytes);
|
|
64
|
-
}
|
|
65
41
|
export class PaneControlStream {
|
|
66
42
|
ws;
|
|
67
43
|
pane;
|
|
@@ -383,9 +359,11 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
|
|
|
383
359
|
}
|
|
384
360
|
if (authenticating)
|
|
385
361
|
return;
|
|
386
|
-
if (message.type !== 'subscribe'
|
|
387
|
-
|
|
388
|
-
|
|
362
|
+
if (message.type !== 'subscribe' || !isPaneId(message.pane)) {
|
|
363
|
+
ws.close(1003, 'bad subscribe message');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (!tokenEquals(message.token ?? '', token)) {
|
|
389
367
|
ws.close(4001, 'unauthorized');
|
|
390
368
|
return;
|
|
391
369
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { parseTmuxRows, tmuxFormat } from './format.js';
|
|
4
|
+
import { TmuxPaneOutputCapture } from './paneOutputCapture.js';
|
|
4
5
|
export const isPaneId = (value) => typeof value === 'string' && /^%\d+$/.test(value);
|
|
5
6
|
export const isWindowId = (value) => typeof value === 'string' && /^@\d+$/.test(value);
|
|
6
7
|
export const isSessionId = (value) => typeof value === 'string' && /^\$\d+$/.test(value);
|
|
@@ -165,6 +166,19 @@ export async function paneSession(paneId) {
|
|
|
165
166
|
throw new Error('pane session not found');
|
|
166
167
|
return id;
|
|
167
168
|
}
|
|
169
|
+
export async function openPaneOutputCapture(paneId) {
|
|
170
|
+
if (!isPaneId(paneId))
|
|
171
|
+
throw new TypeError('invalid pane id');
|
|
172
|
+
const capture = new TmuxPaneOutputCapture({ pane: paneId, session: await paneSession(paneId) });
|
|
173
|
+
try {
|
|
174
|
+
await capture.start();
|
|
175
|
+
return capture;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
capture.close();
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
168
182
|
// Exit tmux copy/scroll mode if the pane is currently in it. Called before any user input so
|
|
169
183
|
// text and keys reach the shell instead of being swallowed by tmux's mode key-bindings.
|
|
170
184
|
export async function exitCopyModeIfActive(paneId) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function decodeControlData(data) {
|
|
2
|
+
const bytes = [];
|
|
3
|
+
for (let i = 0; i < data.length;) {
|
|
4
|
+
const current = data[i];
|
|
5
|
+
if (current === 0x5c && data[i + 1] === 0x5c) {
|
|
6
|
+
bytes.push(0x5c);
|
|
7
|
+
i += 2;
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
if (current === 0x5c && i + 3 < data.length) {
|
|
11
|
+
const a = data[i + 1];
|
|
12
|
+
const b = data[i + 2];
|
|
13
|
+
const c = data[i + 3];
|
|
14
|
+
if (a !== undefined && b !== undefined && c !== undefined
|
|
15
|
+
&& a >= 0x30 && a <= 0x37 && b >= 0x30 && b <= 0x37 && c >= 0x30 && c <= 0x37) {
|
|
16
|
+
bytes.push(((a - 0x30) << 6) | ((b - 0x30) << 3) | (c - 0x30));
|
|
17
|
+
i += 4;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (current !== undefined)
|
|
22
|
+
bytes.push(current);
|
|
23
|
+
i += 1;
|
|
24
|
+
}
|
|
25
|
+
return Buffer.from(bytes);
|
|
26
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { decodeControlData } from './controlProtocol.js';
|
|
3
|
+
const START_TIMEOUT_MS = 5_000;
|
|
4
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
5
|
+
const defaultSpawnControl = (command, args, options) => (spawn(command, [...args], options));
|
|
6
|
+
export class TmuxPaneOutputCapture {
|
|
7
|
+
#pane;
|
|
8
|
+
#maxBytes;
|
|
9
|
+
#child;
|
|
10
|
+
#attached;
|
|
11
|
+
#startTimer;
|
|
12
|
+
#resolveAttached;
|
|
13
|
+
#rejectAttached;
|
|
14
|
+
#buffer = Buffer.alloc(0);
|
|
15
|
+
#response;
|
|
16
|
+
#pending = null;
|
|
17
|
+
#chunks = [];
|
|
18
|
+
#bytes = 0;
|
|
19
|
+
#armed = false;
|
|
20
|
+
#overflow = false;
|
|
21
|
+
#failed = false;
|
|
22
|
+
#closed = false;
|
|
23
|
+
#lastError = '';
|
|
24
|
+
constructor({ pane, session, maxBytes = DEFAULT_MAX_BYTES, spawnControl = defaultSpawnControl, }) {
|
|
25
|
+
if (!/^%\d+$/.test(pane) || !/^\$\d+$/.test(session)) {
|
|
26
|
+
throw new TypeError('invalid tmux pane output capture target');
|
|
27
|
+
}
|
|
28
|
+
this.#pane = pane;
|
|
29
|
+
this.#maxBytes = Math.max(1, Math.trunc(maxBytes));
|
|
30
|
+
this.#attached = new Promise((resolve, reject) => {
|
|
31
|
+
this.#resolveAttached = resolve;
|
|
32
|
+
this.#rejectAttached = reject;
|
|
33
|
+
});
|
|
34
|
+
this.#startTimer = setTimeout(() => this.#fail(new Error('tmux control mode attach timed out')), START_TIMEOUT_MS);
|
|
35
|
+
this.#startTimer.unref?.();
|
|
36
|
+
this.#child = spawnControl('tmux', ['-C', 'attach-session', '-E', '-t', session], {
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
});
|
|
39
|
+
this.#child.stdout.on('data', (chunk) => this.#onChunk(chunk));
|
|
40
|
+
this.#child.stderr.on('data', (chunk) => { this.#lastError += chunk.toString('utf8'); });
|
|
41
|
+
this.#child.stdin.on('error', (error) => this.#fail(error));
|
|
42
|
+
this.#child.on('error', (error) => this.#fail(error));
|
|
43
|
+
this.#child.on('exit', (code) => {
|
|
44
|
+
if (!this.#closed)
|
|
45
|
+
this.#fail(new Error(this.#lastError || `tmux control mode exited (${code ?? 'unknown'})`));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async start() {
|
|
49
|
+
await this.#attached;
|
|
50
|
+
}
|
|
51
|
+
async sendKey(key) {
|
|
52
|
+
if (!/^[A-Za-z0-9-]+$/.test(key))
|
|
53
|
+
throw new TypeError('invalid tmux key');
|
|
54
|
+
await this.#attached;
|
|
55
|
+
await this.#request(`send-keys -t ${this.#pane} ${key}`);
|
|
56
|
+
}
|
|
57
|
+
output() {
|
|
58
|
+
if (!this.#armed || this.#failed || this.#overflow)
|
|
59
|
+
return null;
|
|
60
|
+
return Buffer.concat(this.#chunks, this.#bytes);
|
|
61
|
+
}
|
|
62
|
+
outputFrames() {
|
|
63
|
+
if (!this.#armed || this.#failed || this.#overflow)
|
|
64
|
+
return null;
|
|
65
|
+
return this.#chunks.map((chunk) => Buffer.from(chunk));
|
|
66
|
+
}
|
|
67
|
+
close() {
|
|
68
|
+
if (this.#closed)
|
|
69
|
+
return;
|
|
70
|
+
this.#closed = true;
|
|
71
|
+
clearTimeout(this.#startTimer);
|
|
72
|
+
const error = new Error('tmux control output capture closed');
|
|
73
|
+
this.#rejectAttached(error);
|
|
74
|
+
this.#pending?.reject(error);
|
|
75
|
+
this.#response?.reject(error);
|
|
76
|
+
this.#pending = null;
|
|
77
|
+
this.#response = undefined;
|
|
78
|
+
try {
|
|
79
|
+
this.#child.kill();
|
|
80
|
+
}
|
|
81
|
+
catch { /* already gone */ }
|
|
82
|
+
}
|
|
83
|
+
#onChunk(chunk) {
|
|
84
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
85
|
+
for (;;) {
|
|
86
|
+
const newline = this.#buffer.indexOf(0x0a);
|
|
87
|
+
if (newline < 0)
|
|
88
|
+
return;
|
|
89
|
+
const line = this.#buffer.subarray(0, newline);
|
|
90
|
+
this.#buffer = this.#buffer.subarray(newline + 1);
|
|
91
|
+
this.#onLine(line.at(-1) === 0x0d ? line.subarray(0, -1) : line);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
#onLine(line) {
|
|
95
|
+
if (line.subarray(0, 8).toString('ascii') === '%output ') {
|
|
96
|
+
const split = line.indexOf(0x20, 8);
|
|
97
|
+
if (!this.#armed || split < 0
|
|
98
|
+
|| line.subarray(8, split).toString('ascii') !== this.#pane)
|
|
99
|
+
return;
|
|
100
|
+
const output = decodeControlData(line.subarray(split + 1));
|
|
101
|
+
if (this.#bytes + output.length > this.#maxBytes) {
|
|
102
|
+
this.#overflow = true;
|
|
103
|
+
this.#chunks = [];
|
|
104
|
+
this.#bytes = 0;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!this.#overflow) {
|
|
108
|
+
this.#chunks.push(output);
|
|
109
|
+
this.#bytes += output.length;
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (line.subarray(0, 7).toString('ascii') === '%begin ') {
|
|
114
|
+
this.#response = this.#pending;
|
|
115
|
+
this.#pending = null;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const ended = line.subarray(0, 5).toString('ascii') === '%end ';
|
|
119
|
+
const errored = line.subarray(0, 7).toString('ascii') === '%error ';
|
|
120
|
+
if (ended || errored) {
|
|
121
|
+
const response = this.#response;
|
|
122
|
+
this.#response = undefined;
|
|
123
|
+
if (response) {
|
|
124
|
+
if (errored)
|
|
125
|
+
response.reject(new Error('tmux control command failed'));
|
|
126
|
+
else {
|
|
127
|
+
// tmux never inserts notifications inside a command output block. Arming synchronously at
|
|
128
|
+
// this %end makes every subsequently parsed %output byte provably newer than this C-c.
|
|
129
|
+
this.#chunks = [];
|
|
130
|
+
this.#bytes = 0;
|
|
131
|
+
this.#overflow = false;
|
|
132
|
+
this.#armed = true;
|
|
133
|
+
response.resolve();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (line.subarray(0, 17).toString('ascii') === '%session-changed ') {
|
|
139
|
+
clearTimeout(this.#startTimer);
|
|
140
|
+
this.#resolveAttached();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (line.subarray(0, 5).toString('ascii') === '%exit') {
|
|
144
|
+
this.#fail(new Error('tmux control mode detached'));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
#fail(error) {
|
|
148
|
+
if (this.#failed || this.#closed)
|
|
149
|
+
return;
|
|
150
|
+
this.#failed = true;
|
|
151
|
+
this.#closed = true;
|
|
152
|
+
clearTimeout(this.#startTimer);
|
|
153
|
+
this.#rejectAttached(error);
|
|
154
|
+
this.#pending?.reject(error);
|
|
155
|
+
this.#response?.reject(error);
|
|
156
|
+
this.#pending = null;
|
|
157
|
+
this.#response = undefined;
|
|
158
|
+
this.#chunks = [];
|
|
159
|
+
this.#bytes = 0;
|
|
160
|
+
try {
|
|
161
|
+
this.#child.kill();
|
|
162
|
+
}
|
|
163
|
+
catch { /* already gone */ }
|
|
164
|
+
}
|
|
165
|
+
#request(command) {
|
|
166
|
+
if (this.#closed || this.#failed) {
|
|
167
|
+
return Promise.reject(new Error('tmux control output capture is unavailable'));
|
|
168
|
+
}
|
|
169
|
+
if (this.#pending || this.#response) {
|
|
170
|
+
return Promise.reject(new Error('tmux control command already pending'));
|
|
171
|
+
}
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
this.#pending = { resolve, reject };
|
|
174
|
+
this.#child.stdin.write(`${command}\n`, (error) => {
|
|
175
|
+
if (error)
|
|
176
|
+
this.#fail(error);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|