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
@@ -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, deliveryHistorySafe: true, deliveryProofClientIds: new Set(),
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
- const observed = await observe(pane);
2999
- if (!observed)
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
- return { managed: true, threadId: observed.threadId };
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({ error: error.message, code: error.code });
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
- const operation = new AbortController();
197
- const cancel = () => {
198
- if (!res.writableEnded)
199
- operation.abort(new Error('Activation request disconnected'));
200
- };
201
- req.once('aborted', cancel);
202
- res.once('close', cancel);
203
- try {
204
- await runtime.conversationActivation.activate(lease, operation.signal);
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
- finally {
207
- req.removeListener('aborted', cancel);
208
- res.removeListener('close', cancel);
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.status(202).json({ accepted: true });
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 { buildIatSignedUrl } from '../asr/iflySign.js';
10
- import { asrConfig, isAsrConfigured } from '../asr/iflyConfig.js';
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: isAsrConfigured(asrEnv),
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: iFlytek IAT signed-URL handoff -------------------------------------------
135
- // The browser connects to iFlytek directly; we only mint a short-lived signed wss URL so the
136
- // apiSecret never reaches the phone. 503 if creds aren't configured (front-end hides the mic).
137
- r.get('/asr/sign', (_req, res) => {
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
- const { appId, apiKey, apiSecret } = asrConfig(asrEnv);
141
- return res.json(buildIatSignedUrl({ appId, apiKey, apiSecret, date: new Date().toUTCString() }));
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.
@@ -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
- sendKey: (pane, key) => commands.sendKey(pane, key),
155
- capturePlain: (pane) => commands.capturePlain(pane),
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(4001, 'authentication timeout');
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
- || !tokenEquals(message.token ?? '', token)
388
- || !isPaneId(message.pane)) {
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
+ }