handmux 0.25.2 → 0.27.1
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/connectors/pi/index.js +16 -1
- package/dist/package.json +19 -19
- package/dist/public/assets/index-Cdrh2CUp.css +32 -0
- package/dist/public/assets/index-VT8s4ELL.js +356 -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 +5 -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/claudeConversation.js +2 -2
- package/dist/src/agents/codex.js +78 -10
- package/dist/src/agents/codexActivationReceipt.js +149 -0
- package/dist/src/agents/codexConversation.js +61 -22
- package/dist/src/agents/codexConversationActivation.js +265 -65
- package/dist/src/agents/codexConversationControls.js +4 -1
- package/dist/src/agents/codexOpenSession.js +164 -0
- package/dist/src/agents/conversationProjectionSafety.js +7 -0
- package/dist/src/agents/nativeInbox.js +24 -56
- package/dist/src/agents/pi.js +1 -0
- package/dist/src/agents/piConversationContext.js +49 -0
- 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/piExtension.js +11 -1
- 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 +2 -26
- 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-DH4DnhbV.js +0 -346
- package/dist/public/assets/index-DPBerEuM.css +0 -32
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +0 -1
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
+
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
10
11
|
import { getDriver } from './drivers.js';
|
|
11
12
|
import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
|
|
12
13
|
import { initialSupervisorComponentState, reduceSupervisorComponent, } from './supervisorState.js';
|
|
@@ -114,7 +115,7 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
|
|
|
114
115
|
const startServer = () => {
|
|
115
116
|
// The server reads only process.env (no .env files) — the CLI resolved the one config file and we
|
|
116
117
|
// hand the server everything it needs here. This is the single injection point: config.json fields →
|
|
117
|
-
// the env names the server
|
|
118
|
+
// the env names the server reads. Voice injects only the selected provider's credentials.
|
|
118
119
|
const env = {
|
|
119
120
|
...processRef.env,
|
|
120
121
|
NODE_ENV: 'handmux',
|
|
@@ -142,13 +143,28 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
|
|
|
142
143
|
if (cfg.vapid.subject)
|
|
143
144
|
env.VAPID_SUBJECT = cfg.vapid.subject;
|
|
144
145
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
// Config is authoritative for voice. Always remove inherited ASR state first, including when setup
|
|
147
|
+
// explicitly disabled voice; otherwise stale shell/service credentials silently re-enable XFYUN.
|
|
148
|
+
for (const key of [
|
|
149
|
+
'HANDMUX_ASR_PROVIDER',
|
|
150
|
+
'HANDMUX_ASR_MODE',
|
|
151
|
+
...voiceProviderRegistry.adapters.flatMap((candidate) => (candidate.fields.map((field) => field.env))),
|
|
152
|
+
])
|
|
153
|
+
delete env[key];
|
|
154
|
+
const voice = cfg.voice === null || cfg.voice?.enabled === false ? null
|
|
155
|
+
: cfg.voice ?? (cfg.xfyun ? { provider: 'xfyun', providers: { xfyun: cfg.xfyun } } : null);
|
|
156
|
+
if (voice) {
|
|
157
|
+
const adapter = voiceProviderRegistry.get(voice.provider);
|
|
158
|
+
if (adapter) {
|
|
159
|
+
env.HANDMUX_ASR_PROVIDER = adapter.id;
|
|
160
|
+
env.HANDMUX_ASR_MODE = providerMode(adapter, voice.mode);
|
|
161
|
+
const provider = voice.providers[adapter.id];
|
|
162
|
+
for (const field of adapter.fields) {
|
|
163
|
+
const value = provider?.[field.key] || field.defaultValue;
|
|
164
|
+
if (value)
|
|
165
|
+
env[field.env] = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
152
168
|
}
|
|
153
169
|
const c = spawnChild(processRef.execPath, [SERVER], { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
154
170
|
children.server = c;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { readSupervisorConfig, supervisorConfigPath, writeSupervisorConfig, } from './state.js';
|
|
3
|
+
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
3
4
|
import { TUNNELS } from './options.js';
|
|
4
5
|
import { normalizeShortcuts } from '../shortcutConfig.js';
|
|
5
6
|
const recordOf = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -43,6 +44,28 @@ const stringFields = (value, keys, key) => {
|
|
|
43
44
|
}
|
|
44
45
|
return result;
|
|
45
46
|
};
|
|
47
|
+
const voiceConfig = (value) => {
|
|
48
|
+
if (value === undefined || value === null)
|
|
49
|
+
return value;
|
|
50
|
+
const record = recordOf(value);
|
|
51
|
+
const providers = recordOf(record?.providers);
|
|
52
|
+
const adapter = voiceProviderRegistry.get(record?.provider);
|
|
53
|
+
if (!record || !adapter || !providers) {
|
|
54
|
+
throw new Error('invalid supervisor config: voice must select a known provider');
|
|
55
|
+
}
|
|
56
|
+
const parsed = {};
|
|
57
|
+
for (const candidate of voiceProviderRegistry.adapters) {
|
|
58
|
+
const config = stringFields(providers[candidate.id], candidate.fields.map((field) => field.key), `voice.providers.${candidate.id}`);
|
|
59
|
+
if (config)
|
|
60
|
+
parsed[candidate.id] = config;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
...(record.enabled === false ? { enabled: false } : {}),
|
|
64
|
+
provider: adapter.id,
|
|
65
|
+
mode: providerMode(adapter, record.mode),
|
|
66
|
+
providers: parsed,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
46
69
|
// The persisted file and the legacy base64 argv are untrusted process boundaries. Parse them before the
|
|
47
70
|
// supervisor starts so malformed or stale data fails as one explicit configuration error instead of being
|
|
48
71
|
// used as partially-typed process arguments.
|
|
@@ -87,6 +110,7 @@ export function parseSupervisorConfig(value) {
|
|
|
87
110
|
const cpolarBin = optionalString(record.cpolarBin, 'cpolarBin');
|
|
88
111
|
const cpolarRegion = optionalString(record.cpolarRegion, 'cpolarRegion');
|
|
89
112
|
const vapid = stringFields(record.vapid, ['public', 'private', 'subject'], 'vapid');
|
|
113
|
+
const voice = voiceConfig(record.voice);
|
|
90
114
|
const xfyun = stringFields(record.xfyun, ['appId', 'apiKey', 'apiSecret'], 'xfyun');
|
|
91
115
|
if (name !== undefined)
|
|
92
116
|
config.name = name;
|
|
@@ -120,6 +144,8 @@ export function parseSupervisorConfig(value) {
|
|
|
120
144
|
config.cpolarRegion = cpolarRegion;
|
|
121
145
|
if (vapid !== undefined)
|
|
122
146
|
config.vapid = vapid;
|
|
147
|
+
if (voice !== undefined)
|
|
148
|
+
config.voice = voice;
|
|
123
149
|
if (xfyun !== undefined)
|
|
124
150
|
config.xfyun = xfyun;
|
|
125
151
|
return config;
|
|
@@ -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;
|
|
@@ -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;
|
|
@@ -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
|
+
}
|