handmux 0.17.7 → 0.18.0
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 +18 -2
- package/README.zh-CN.md +18 -2
- package/bin/handmux.js +25 -1
- package/package.json +6 -5
- package/public/assets/index-CvNuQ-bC.js +330 -0
- package/public/assets/index-DjJf1FX9.css +32 -0
- package/public/index.html +2 -2
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +1 -0
- package/src/agents/scanUtils.js +3 -4
- package/src/claudeEvents.js +23 -6
- package/src/cli/i18n/en.js +87 -2
- package/src/cli/i18n/zh.js +87 -2
- package/src/cli/options.js +23 -5
- package/src/cli/prompt.js +1 -1
- package/src/cli/setupModel.js +11 -2
- package/src/cli/setupWizard.js +19 -18
- package/src/cli/shortcutEditor.js +288 -0
- package/src/cli/supervisor.js +1 -0
- package/src/cli/workspaceCmd.js +300 -0
- package/src/config.js +3 -0
- package/src/httpApi.js +6 -1
- package/src/internalPreviewGateway.js +487 -0
- package/src/keyNames.js +10 -0
- package/src/orphans.js +3 -2
- package/src/routes/sessions.js +13 -1
- package/src/routes/system.js +13 -2
- package/src/routes/terminal.js +3 -13
- package/src/routes/workspace.js +267 -0
- package/src/server.js +46 -5
- package/src/shortcutConfig.js +75 -0
- package/src/tmux/commands.js +17 -17
- package/src/tmux/format.js +45 -0
- package/src/workspace/agentRunner.js +185 -0
- package/src/workspace/atomicJson.js +32 -0
- package/src/workspace/capture.js +87 -0
- package/src/workspace/checkpointer.js +179 -0
- package/src/workspace/environment.js +75 -0
- package/src/workspace/lock.js +128 -0
- package/src/workspace/mapping.js +124 -0
- package/src/workspace/operations.js +252 -0
- package/src/workspace/paths.js +16 -0
- package/src/workspace/planner.js +247 -0
- package/src/workspace/restore.js +279 -0
- package/src/workspace/runtime.js +314 -0
- package/src/workspace/schema.js +136 -0
- package/src/workspace/store.js +382 -0
- package/src/workspace/tmuxAdapter.js +554 -0
- package/public/assets/index-CiaHwIWj.css +0 -32
- package/public/assets/index-cjqG9l7v.js +0 -330
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { validateRecoveryMapping } from '../workspace/mapping.js';
|
|
3
|
+
|
|
4
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
5
|
+
const RESTORE_FIELDS = new Set(['checkpointId', 'sessions']);
|
|
6
|
+
const CHANGE_REASONS = new Set(['boot-changed', 'tmux-changed']);
|
|
7
|
+
const OPERATION_STATUSES = new Set(['pending', 'running', 'succeeded', 'partial', 'failed', 'interrupted']);
|
|
8
|
+
const RESULT_STATUSES = new Set(['restored', 'already-present', 'failed']);
|
|
9
|
+
const RESULT_STAGES = new Set(['plan', 'topology', 'agent', 'restore', 'reconcile']);
|
|
10
|
+
const WARNING_CODES = new Set(['cwd-fallback', 'layout-fallback', 'agent-warning', 'live-reconcile-failed', 'workspace-unavailable', 'restore-warning']);
|
|
11
|
+
const ERROR_MESSAGES = {
|
|
12
|
+
'restore-interrupted': 'restore was interrupted; retry the restore',
|
|
13
|
+
'checkpoint-not-found': 'checkpoint is unavailable; choose another checkpoint',
|
|
14
|
+
'storage-full': 'workspace storage is full; free disk space and retry',
|
|
15
|
+
'permission-denied': 'workspace storage is not writable; check permissions and retry',
|
|
16
|
+
'plan-failed': 'restore plan is no longer usable; refresh and retry',
|
|
17
|
+
'agent-unavailable': 'agent resume failed; open the restored shell and retry manually',
|
|
18
|
+
'tmux-unavailable': 'tmux is unavailable; retry the restore',
|
|
19
|
+
'restore-failed': 'workspace restore failed; retry the restore',
|
|
20
|
+
};
|
|
21
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
|
|
23
|
+
function isSafeId(value) {
|
|
24
|
+
return typeof value === 'string' && SAFE_ID.test(value) && value !== '.' && value !== '..';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isCheckpointMissing(error) {
|
|
28
|
+
if (error?.code === 'WORKSPACE_CHECKPOINT_NOT_FOUND' || error?.status === 404) return true;
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
return /(?:checkpoint.*(?:missing|not found)|no valid checkpoint)/i.test(message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sendFailure(res, error, { checkpoint = false } = {}) {
|
|
34
|
+
if (checkpoint && isCheckpointMissing(error)) {
|
|
35
|
+
return res.status(404).json({ error: 'checkpoint not found' });
|
|
36
|
+
}
|
|
37
|
+
return res.status(500).json({ error: 'workspace unavailable' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function asyncHandler(handler, options) {
|
|
41
|
+
return async (req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
await handler(req, res);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
sendFailure(res, error, options);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkpointId(value, fallback) {
|
|
51
|
+
const id = value === undefined ? fallback : value;
|
|
52
|
+
return isSafeId(id) ? id : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function timestamp(value) {
|
|
56
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function count(value) {
|
|
60
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function displayName(value) {
|
|
64
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256
|
|
65
|
+
&& !/[\x00-\x1f\x7f]/.test(value) ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function projectCheckpoint(row) {
|
|
69
|
+
const id = isSafeId(row?.id) ? row.id : null;
|
|
70
|
+
if (row?.status !== 'ok' || !row.value || typeof row.value !== 'object' || Array.isArray(row.value)) {
|
|
71
|
+
const corrupt = row?.status === 'corrupt';
|
|
72
|
+
return {
|
|
73
|
+
status: corrupt ? 'corrupt' : 'unavailable',
|
|
74
|
+
id,
|
|
75
|
+
capturedAt: null,
|
|
76
|
+
archivedAt: null,
|
|
77
|
+
sessionCount: 0,
|
|
78
|
+
windowCount: 0,
|
|
79
|
+
paneCount: 0,
|
|
80
|
+
agentCount: 0,
|
|
81
|
+
endedReason: null,
|
|
82
|
+
errorCode: corrupt ? 'checkpoint-corrupt' : 'checkpoint-unavailable',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const sessions = Array.isArray(row.value.sessions) ? row.value.sessions : [];
|
|
86
|
+
const windows = Array.isArray(row.value.windows) ? row.value.windows : [];
|
|
87
|
+
const panes = windows.flatMap((window) => Array.isArray(window?.panes) ? window.panes : []);
|
|
88
|
+
return {
|
|
89
|
+
status: 'ok',
|
|
90
|
+
id,
|
|
91
|
+
capturedAt: timestamp(row.value.capturedAt),
|
|
92
|
+
archivedAt: timestamp(row.value.archivedAt),
|
|
93
|
+
sessionCount: sessions.length,
|
|
94
|
+
windowCount: windows.length,
|
|
95
|
+
paneCount: panes.length,
|
|
96
|
+
agentCount: panes.filter((pane) => pane?.agent && typeof pane.agent === 'object' && !Array.isArray(pane.agent)).length,
|
|
97
|
+
endedReason: CHANGE_REASONS.has(row.value.environment?.endedReason) ? row.value.environment.endedReason : null,
|
|
98
|
+
errorCode: null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function errorText(error) {
|
|
103
|
+
if (typeof error === 'string') return error.toLowerCase();
|
|
104
|
+
if (error instanceof Error) return error.message.toLowerCase();
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function errorCode(error, { stage = null, status = null } = {}) {
|
|
109
|
+
const text = errorText(error);
|
|
110
|
+
if (status === 'interrupted' || /interrupt|process restart/.test(text)) return 'restore-interrupted';
|
|
111
|
+
if (/enospc|disk full|no space/.test(text)) return 'storage-full';
|
|
112
|
+
if (/eacces|eperm|permission|not writable/.test(text)) return 'permission-denied';
|
|
113
|
+
if (/checkpoint.*(?:missing|not found|unavailable)/.test(text)) return 'checkpoint-not-found';
|
|
114
|
+
if (stage === 'plan' || /restore plan|planning/.test(text)) return 'plan-failed';
|
|
115
|
+
if (stage === 'agent' || /\b(?:agent|claude|codex)\b|command not found|cli unavailable/.test(text)) return 'agent-unavailable';
|
|
116
|
+
if (stage === 'topology' || /\btmux\b|topology|server disappeared/.test(text)) return 'tmux-unavailable';
|
|
117
|
+
if (text || status === 'failed') return 'restore-failed';
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function warningCode(warning) {
|
|
122
|
+
const text = errorText(warning);
|
|
123
|
+
if (/live reconcile|reconcile.*(?:fail|unavailable)/.test(text)) return 'live-reconcile-failed';
|
|
124
|
+
if (/\blayout\b/.test(text)) return 'layout-fallback';
|
|
125
|
+
if (/\b(?:agent|claude|codex)\b|command not found|cli unavailable/.test(text)) return 'agent-warning';
|
|
126
|
+
if (/\bcwd\b|\bdirectory\b|fallback.*home|missing.*path/.test(text)) return 'cwd-fallback';
|
|
127
|
+
if (/enospc|disk full|no space|eacces|eperm|permission|not writable/.test(text)) return 'workspace-unavailable';
|
|
128
|
+
return 'restore-warning';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function projectWarningCodes(value, existing) {
|
|
132
|
+
const codes = [];
|
|
133
|
+
if (Array.isArray(existing)) {
|
|
134
|
+
for (const code of existing) if (WARNING_CODES.has(code)) codes.push(code);
|
|
135
|
+
}
|
|
136
|
+
if (Array.isArray(value)) for (const warning of value) codes.push(warningCode(warning));
|
|
137
|
+
return [...new Set(codes)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function projectMapping(value, id) {
|
|
141
|
+
if (!value || !id) return null;
|
|
142
|
+
try {
|
|
143
|
+
const mapping = validateRecoveryMapping(value, id);
|
|
144
|
+
const record = (entries) => Object.fromEntries(Object.entries(entries));
|
|
145
|
+
return {
|
|
146
|
+
id: mapping.id,
|
|
147
|
+
checkpointId: mapping.checkpointId,
|
|
148
|
+
restoredAt: mapping.restoredAt,
|
|
149
|
+
names: record(mapping.names),
|
|
150
|
+
runtime: {
|
|
151
|
+
sessions: record(mapping.runtime.sessions),
|
|
152
|
+
windows: record(mapping.runtime.windows),
|
|
153
|
+
panes: record(mapping.runtime.panes),
|
|
154
|
+
},
|
|
155
|
+
logical: {
|
|
156
|
+
sessions: record(mapping.logical.sessions),
|
|
157
|
+
windows: record(mapping.logical.windows),
|
|
158
|
+
panes: record(mapping.logical.panes),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function projectResult(result) {
|
|
167
|
+
const status = RESULT_STATUSES.has(result?.status) ? result.status : 'failed';
|
|
168
|
+
const stage = RESULT_STAGES.has(result?.stage) ? result.stage : null;
|
|
169
|
+
const code = errorCode(result?.error, { stage, status });
|
|
170
|
+
return {
|
|
171
|
+
logicalId: typeof result?.logicalId === 'string' && UUID.test(result.logicalId) ? result.logicalId : null,
|
|
172
|
+
sourceName: displayName(result?.sourceName),
|
|
173
|
+
targetName: displayName(result?.targetName),
|
|
174
|
+
status,
|
|
175
|
+
stage,
|
|
176
|
+
errorCode: code,
|
|
177
|
+
errorMessage: code ? ERROR_MESSAGES[code] : null,
|
|
178
|
+
warningCodes: projectWarningCodes(result?.warnings, result?.warningCodes),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function projectOperation(operation) {
|
|
183
|
+
const request = operation?.request && typeof operation.request === 'object' && !Array.isArray(operation.request)
|
|
184
|
+
? operation.request : {};
|
|
185
|
+
const id = checkpointId(request.checkpointId, null);
|
|
186
|
+
const status = OPERATION_STATUSES.has(operation?.status) ? operation.status : 'failed';
|
|
187
|
+
const code = errorCode(operation?.error, { status });
|
|
188
|
+
return {
|
|
189
|
+
id: isSafeId(operation?.id) ? operation.id : null,
|
|
190
|
+
status,
|
|
191
|
+
request: {
|
|
192
|
+
checkpointId: id,
|
|
193
|
+
sessions: Array.isArray(request.sessions) ? request.sessions.map(displayName).filter(Boolean) : [],
|
|
194
|
+
historical: request.historical === true,
|
|
195
|
+
},
|
|
196
|
+
createdAt: timestamp(operation?.createdAt),
|
|
197
|
+
updatedAt: timestamp(operation?.updatedAt),
|
|
198
|
+
startedAt: timestamp(operation?.startedAt),
|
|
199
|
+
completedAt: timestamp(operation?.completedAt),
|
|
200
|
+
progress: {
|
|
201
|
+
completed: count(operation?.progress?.completed),
|
|
202
|
+
total: count(operation?.progress?.total),
|
|
203
|
+
},
|
|
204
|
+
summary: {
|
|
205
|
+
sessions: count(operation?.summary?.sessions),
|
|
206
|
+
windows: count(operation?.summary?.windows),
|
|
207
|
+
panes: count(operation?.summary?.panes),
|
|
208
|
+
},
|
|
209
|
+
results: Array.isArray(operation?.results) ? operation.results.map(projectResult) : [],
|
|
210
|
+
errorCode: code,
|
|
211
|
+
errorMessage: code ? ERROR_MESSAGES[code] : null,
|
|
212
|
+
warningCodes: projectWarningCodes(operation?.warnings, operation?.warningCodes),
|
|
213
|
+
mapping: projectMapping(operation?.mapping, id),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseRestoreRequest(body) {
|
|
218
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
|
219
|
+
if (Object.keys(body).some((key) => !RESTORE_FIELDS.has(key))) return null;
|
|
220
|
+
const id = checkpointId(body.checkpointId, 'latest');
|
|
221
|
+
if (!id) return null;
|
|
222
|
+
const request = { checkpointId: id };
|
|
223
|
+
if (body.sessions !== undefined) {
|
|
224
|
+
if (!Array.isArray(body.sessions)
|
|
225
|
+
|| body.sessions.some((name) => typeof name !== 'string' || !name || name.length > 256 || /[\x00-\x1f\x7f]/.test(name))) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
request.sessions = body.sessions;
|
|
229
|
+
}
|
|
230
|
+
return request;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function workspaceRoutes({ workspace }) {
|
|
234
|
+
const r = express.Router();
|
|
235
|
+
|
|
236
|
+
r.get('/workspace/status', asyncHandler(async (_req, res) => {
|
|
237
|
+
res.json(await workspace.getProtectionStatus());
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
r.get('/workspace/checkpoints', asyncHandler(async (_req, res) => {
|
|
241
|
+
const checkpoints = await workspace.listCheckpoints();
|
|
242
|
+
res.json(Array.isArray(checkpoints) ? checkpoints.map(projectCheckpoint) : []);
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
r.get('/workspace/restore-plan', asyncHandler(async (req, res) => {
|
|
246
|
+
const id = checkpointId(req.query.checkpoint, 'latest');
|
|
247
|
+
if (!id) return res.status(400).json({ error: 'bad checkpoint id' });
|
|
248
|
+
// serverNow, promptEligible, pending recovery state, and mapping are runtime-authored. The route
|
|
249
|
+
// deliberately passes them through without interpreting expiresAt using a client-supplied clock.
|
|
250
|
+
res.json(await workspace.getRestorePlan({ checkpointId: id }));
|
|
251
|
+
}, { checkpoint: true }));
|
|
252
|
+
|
|
253
|
+
r.post('/workspace/restore', asyncHandler(async (req, res) => {
|
|
254
|
+
const restoreRequest = parseRestoreRequest(req.body);
|
|
255
|
+
if (!restoreRequest) return res.status(400).json({ error: 'bad request' });
|
|
256
|
+
res.status(202).json(await workspace.startRestore(restoreRequest));
|
|
257
|
+
}, { checkpoint: true }));
|
|
258
|
+
|
|
259
|
+
r.get('/workspace/restore/:operationId', asyncHandler(async (req, res) => {
|
|
260
|
+
if (!isSafeId(req.params.operationId)) return res.status(400).json({ error: 'bad operation id' });
|
|
261
|
+
const operation = await workspace.getOperation(req.params.operationId);
|
|
262
|
+
if (!operation) return res.status(404).json({ error: 'operation not found' });
|
|
263
|
+
res.json(projectOperation(operation));
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
return r;
|
|
267
|
+
}
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,12 @@ import { applyAppName, applyManifestName } from './appName.js';
|
|
|
16
16
|
import { homedir } from 'node:os';
|
|
17
17
|
import { createPreviews } from './previews.js';
|
|
18
18
|
import { createPreview } from './previewServer.js';
|
|
19
|
+
import { createWorkspaceStore } from './workspace/store.js';
|
|
20
|
+
import { createWorkspaceTmux } from './workspace/tmuxAdapter.js';
|
|
21
|
+
import { createEnvironmentProvider } from './workspace/environment.js';
|
|
22
|
+
import { createWorkspaceLock } from './workspace/lock.js';
|
|
23
|
+
import { createGracefulShutdown, createWorkspaceBackground } from './workspace/checkpointer.js';
|
|
24
|
+
import { createWorkspaceRuntime } from './workspace/runtime.js';
|
|
19
25
|
|
|
20
26
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
27
|
|
|
@@ -27,21 +33,51 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
27
33
|
const cfg = loadConfig();
|
|
28
34
|
const token = loadToken();
|
|
29
35
|
const uploadExts = loadUploadExts();
|
|
36
|
+
const home = homedir();
|
|
37
|
+
|
|
38
|
+
// One writer set is shared by background capture today and the restore runtime added on top of it. The
|
|
39
|
+
// lock is filesystem-backed because the CLI may restore while this daemon is alive in another process.
|
|
40
|
+
const workspaceStore = createWorkspaceStore({ home });
|
|
41
|
+
const workspaceTmux = createWorkspaceTmux({ run: commands.runTmux });
|
|
42
|
+
const workspaceLock = createWorkspaceLock({ dir: workspaceStore.paths.lockDir });
|
|
43
|
+
const observeEnvironment = createEnvironmentProvider({
|
|
44
|
+
tmuxServerIdProvider: async () => {
|
|
45
|
+
const observed = await workspaceTmux.observeEnvironment();
|
|
46
|
+
if (observed.status === 'unknown') throw new Error('tmux environment unavailable');
|
|
47
|
+
return observed.tmuxServerId;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(home);
|
|
51
|
+
const workspaceBackground = createWorkspaceBackground({
|
|
52
|
+
store: workspaceStore,
|
|
53
|
+
tmux: workspaceTmux,
|
|
54
|
+
observeEnvironment,
|
|
55
|
+
lock: workspaceLock,
|
|
56
|
+
stateFile,
|
|
57
|
+
});
|
|
58
|
+
const workspace = createWorkspaceRuntime({
|
|
59
|
+
store: workspaceStore,
|
|
60
|
+
tmux: workspaceTmux,
|
|
61
|
+
lock: workspaceLock,
|
|
62
|
+
checkpointer: workspaceBackground,
|
|
63
|
+
home,
|
|
64
|
+
});
|
|
30
65
|
|
|
31
66
|
// The inbox is driven by a JSON state file the Claude hooks maintain (server/hooks/handmux-notify.sh →
|
|
32
67
|
// handmux-write.js). We only READ it. start() watches it so idle/permission push fires even when no
|
|
33
68
|
// client is polling; getStates reads it fresh on each /states.
|
|
34
|
-
const events = createClaudeEvents({ commands, push });
|
|
69
|
+
const events = createClaudeEvents({ commands, push, file: stateFile, onStateChange: workspace.requestReconcile });
|
|
35
70
|
events.start();
|
|
71
|
+
workspace.start().catch(() => {});
|
|
36
72
|
|
|
37
73
|
// Keep an already-opted-in user's Claude hooks in step with this handmux version on restart: newly-added
|
|
38
74
|
// lifecycle events (e.g. SessionStart, which rebinds the 对话 lens after /clear) and a refreshed
|
|
39
75
|
// handmux-write.cjs land via `./deploy.sh` alone — no phone re-enable. A strict no-op unless our hooks are
|
|
40
76
|
// already installed; best-effort and must never block or crash startup (pure fs, no subprocess).
|
|
41
77
|
try {
|
|
42
|
-
syncHooks(
|
|
78
|
+
syncHooks(home, {
|
|
43
79
|
srcDir: path.resolve(here, '../hooks'),
|
|
44
|
-
stateFile
|
|
80
|
+
stateFile,
|
|
45
81
|
});
|
|
46
82
|
} catch { /* best effort — hook sync never fails startup */ }
|
|
47
83
|
|
|
@@ -49,14 +85,14 @@ try {
|
|
|
49
85
|
// base domain, e.g. preview.example.com); unset → static only. One registry instance is shared by the
|
|
50
86
|
// API (register/list/remove), the /preview static layer, and the Host-based dynamic proxy.
|
|
51
87
|
const previewDomain = process.env.HANDMUX_PREVIEW_DOMAIN || null;
|
|
52
|
-
const previews = createPreviews({ home
|
|
88
|
+
const previews = createPreviews({ home, dynamicEnabled: !!previewDomain });
|
|
53
89
|
const preview = createPreview({ previews, token, domain: previewDomain });
|
|
54
90
|
|
|
55
91
|
const app = express();
|
|
56
92
|
// Host-based dispatch FIRST: a request to <name>.<domain> is reverse-proxied to its dynamic preview;
|
|
57
93
|
// every other Host falls straight through (next()) to the app below, unaffected.
|
|
58
94
|
app.use(preview.dynamicProxy);
|
|
59
|
-
app.use('/api', createApiRouter({ token, events, uploadExts, previews, previewDomain }));
|
|
95
|
+
app.use('/api', createApiRouter({ token, events, uploadExts, previews, previewDomain, shortcuts: cfg.shortcuts, workspace }));
|
|
60
96
|
app.use('/preview', preview.router);
|
|
61
97
|
app.use(preview.refererFallback);
|
|
62
98
|
|
|
@@ -108,3 +144,8 @@ const server = app.listen(cfg.port, cfg.host, () => {
|
|
|
108
144
|
});
|
|
109
145
|
// WebSocket/HMR for dynamic previews: route raw Upgrade by Host to the right loopback port.
|
|
110
146
|
server.on('upgrade', preview.onUpgrade);
|
|
147
|
+
|
|
148
|
+
const shutdown = createGracefulShutdown({ events, workspace, server });
|
|
149
|
+
const handleSignal = () => { shutdown().catch(() => {}); };
|
|
150
|
+
process.on('SIGINT', handleSignal);
|
|
151
|
+
process.on('SIGTERM', handleSignal);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { isAllowedKey } from './keyNames.js';
|
|
2
|
+
|
|
3
|
+
const CTRL_C = Object.freeze({ type: 'key', key: 'C-c', label: 'Ctrl+C' });
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SHORTCUTS = Object.freeze({
|
|
6
|
+
command: Object.freeze([CTRL_C]),
|
|
7
|
+
chat: Object.freeze([
|
|
8
|
+
CTRL_C,
|
|
9
|
+
Object.freeze({ type: 'key', key: 'Escape', label: 'Esc' }),
|
|
10
|
+
Object.freeze({ type: 'key', key: 'Tab', label: 'Tab' }),
|
|
11
|
+
Object.freeze({ type: 'key', key: 'BSpace', label: '⌫' }),
|
|
12
|
+
Object.freeze({ type: 'text', text: 'ok', enter: true }),
|
|
13
|
+
Object.freeze({ type: 'text', text: 'go on', enter: true }),
|
|
14
|
+
Object.freeze({ type: 'text', text: '1', enter: true }),
|
|
15
|
+
Object.freeze({ type: 'text', text: '2', enter: true }),
|
|
16
|
+
Object.freeze({ type: 'text', text: '3', enter: true }),
|
|
17
|
+
Object.freeze({ type: 'text', text: '/compact', enter: true }),
|
|
18
|
+
Object.freeze({ type: 'text', text: '/clear', enter: true }),
|
|
19
|
+
Object.freeze({ type: 'text', text: '/model', enter: true }),
|
|
20
|
+
]),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const cloneItems = (items) => items.map((item) => ({ ...item }));
|
|
24
|
+
const fail = (path, message) => { throw new Error(`${path}: ${message}`); };
|
|
25
|
+
const isSingleLine = (value, max) => (
|
|
26
|
+
typeof value === 'string' && value.length > 0 && value.length <= max
|
|
27
|
+
&& [...value].every((char) => {
|
|
28
|
+
const code = char.charCodeAt(0);
|
|
29
|
+
return code >= 0x20 && code !== 0x7f;
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export function shortcutIdentity(item) {
|
|
34
|
+
return item.type === 'key' ? `key:${item.key}` : `text:${item.enter ? 1 : 0}:${item.text}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeMode(raw, mode) {
|
|
38
|
+
const path = `shortcuts.${mode}`;
|
|
39
|
+
if (!Array.isArray(raw)) fail(path, 'expected an array');
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
return raw.map((item, index) => {
|
|
42
|
+
const itemPath = `${path}[${index}]`;
|
|
43
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) fail(itemPath, 'expected an object');
|
|
44
|
+
let normalized;
|
|
45
|
+
if (item.type === 'key') {
|
|
46
|
+
if (!isAllowedKey(item.key)) fail(`${itemPath}.key`, `unsupported key ${JSON.stringify(item.key)}`);
|
|
47
|
+
if (item.label !== undefined && !isSingleLine(item.label, 80)) {
|
|
48
|
+
fail(`${itemPath}.label`, 'expected non-empty single-line text');
|
|
49
|
+
}
|
|
50
|
+
if (item.enter !== undefined) fail(`${itemPath}.enter`, 'not valid for a key shortcut');
|
|
51
|
+
normalized = { type: 'key', key: item.key, label: item.label ?? item.key };
|
|
52
|
+
} else if (item.type === 'text') {
|
|
53
|
+
if (!isSingleLine(item.text, 500)) fail(`${itemPath}.text`, 'expected non-empty single-line text');
|
|
54
|
+
if (typeof item.enter !== 'boolean') fail(`${itemPath}.enter`, 'expected a boolean');
|
|
55
|
+
normalized = { type: 'text', text: item.text, enter: item.enter };
|
|
56
|
+
} else {
|
|
57
|
+
fail(`${itemPath}.type`, 'expected "key" or "text"');
|
|
58
|
+
}
|
|
59
|
+
const identity = shortcutIdentity(normalized);
|
|
60
|
+
if (seen.has(identity)) fail(itemPath, 'duplicate shortcut');
|
|
61
|
+
seen.add(identity);
|
|
62
|
+
return normalized;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizeShortcuts(raw) {
|
|
67
|
+
if (raw === undefined) {
|
|
68
|
+
return { command: cloneItems(DEFAULT_SHORTCUTS.command), chat: cloneItems(DEFAULT_SHORTCUTS.chat) };
|
|
69
|
+
}
|
|
70
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('shortcuts', 'expected an object');
|
|
71
|
+
return {
|
|
72
|
+
command: raw.command === undefined ? cloneItems(DEFAULT_SHORTCUTS.command) : normalizeMode(raw.command, 'command'),
|
|
73
|
+
chat: raw.chat === undefined ? cloneItems(DEFAULT_SHORTCUTS.chat) : normalizeMode(raw.chat, 'chat'),
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/tmux/commands.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import { parseTmuxRows, tmuxFormat } from './format.js';
|
|
3
4
|
|
|
4
5
|
export const isPaneId = (s) => typeof s === 'string' && /^%\d+$/.test(s);
|
|
5
6
|
export const isWindowId = (s) => typeof s === 'string' && /^@\d+$/.test(s);
|
|
@@ -32,8 +33,8 @@ const lines = (out) => out.split('\n').filter((l) => l.length > 0);
|
|
|
32
33
|
|
|
33
34
|
export async function listSessions() {
|
|
34
35
|
try {
|
|
35
|
-
const out = await runTmux(['list-sessions', '-F', '
|
|
36
|
-
return
|
|
36
|
+
const out = await runTmux(['list-sessions', '-F', tmuxFormat(['session_id', 'session_name'])]);
|
|
37
|
+
return parseTmuxRows(out, 2, 'session').map(([id, name]) => ({ id, name }));
|
|
37
38
|
} catch (e) {
|
|
38
39
|
// tmux exits non-zero with "no server running" or "no sessions" when nothing is up yet.
|
|
39
40
|
const msg = e.message || '';
|
|
@@ -43,17 +44,17 @@ export async function listSessions() {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export async function listWindows(sessionId) {
|
|
46
|
-
const out = await runTmux(['list-windows', '-t', sessionId, '-F',
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
const out = await runTmux(['list-windows', '-t', sessionId, '-F', tmuxFormat([
|
|
48
|
+
'window_id', 'window_name', 'window_active', 'window_panes', 'window_width', 'window_height',
|
|
49
|
+
])]);
|
|
50
|
+
return parseTmuxRows(out, 6, 'window').map(([id, name, active, panes, width, height]) => {
|
|
51
|
+
return { id, name, active: active === '1', panes: Number(panes), width: Number(width), height: Number(height) };
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export async function listPanes(windowId) {
|
|
54
|
-
const out = await runTmux(['list-panes', '-t', windowId, '-F', '
|
|
55
|
-
return
|
|
56
|
-
const [id, active, width, height, command, cwd, left, top] = l.split('\t');
|
|
56
|
+
const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top'])]);
|
|
57
|
+
return parseTmuxRows(out, 8, 'pane').map(([id, active, width, height, command, cwd, left, top]) => {
|
|
57
58
|
return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top) };
|
|
58
59
|
});
|
|
59
60
|
}
|
|
@@ -77,10 +78,9 @@ export async function listPaneIds(target) {
|
|
|
77
78
|
// still EXISTS) and (b) resolve each recorded pane to its session/window for the inbox roster without a
|
|
78
79
|
// per-pane display-message. The hook only records the pane id; location comes from here, always fresh.
|
|
79
80
|
export async function listLivePanes() {
|
|
80
|
-
return
|
|
81
|
-
'
|
|
82
|
-
.map((
|
|
83
|
-
const [id, cmd, tty, session, window, windowName] = l.split('\t');
|
|
81
|
+
return parseTmuxRows(await runTmux(['list-panes', '-a', '-F',
|
|
82
|
+
tmuxFormat(['pane_id', 'pane_current_command', 'pane_tty', 'session_name', 'window_id', 'window_name'])]), 6, 'live pane')
|
|
83
|
+
.map(([id, cmd, tty, session, window, windowName]) => {
|
|
84
84
|
return { id, cmd, tty, session, window, windowName };
|
|
85
85
|
});
|
|
86
86
|
}
|
|
@@ -110,8 +110,8 @@ export async function capturePlain(paneId) {
|
|
|
110
110
|
// client translate a swipe into wheel events the app scrolls on (sendWheel) instead of a dead swipe.
|
|
111
111
|
export async function paneInfo(paneId) {
|
|
112
112
|
const out = await runTmux(['display-message', '-p', '-t', paneId,
|
|
113
|
-
'
|
|
114
|
-
const [width, height, cx, cy, cflag, alt, mAny, mSgr] = out
|
|
113
|
+
tmuxFormat(['pane_width', 'pane_height', 'cursor_x', 'cursor_y', 'cursor_flag', 'alternate_on', 'mouse_any_flag', 'mouse_sgr_flag'])]);
|
|
114
|
+
const [[width, height, cx, cy, cflag, alt, mAny, mSgr] = []] = parseTmuxRows(out, 8, 'pane info');
|
|
115
115
|
return {
|
|
116
116
|
width: Number(width), height: Number(height),
|
|
117
117
|
cursorX: Number(cx), cursorY: Number(cy), cursorVisible: cflag === '1',
|
|
@@ -148,8 +148,8 @@ export function wheelSeq(dir, count, { sgr = true, col = 1, row = 1 } = {}) {
|
|
|
148
148
|
// gives us only $TMUX_PANE; this turns "%263" into the session/window the phone navigates by.
|
|
149
149
|
export async function paneLocation(paneId) {
|
|
150
150
|
const out = await runTmux(['display-message', '-p', '-t', paneId,
|
|
151
|
-
'
|
|
152
|
-
const [session, windowId, windowName] = out
|
|
151
|
+
tmuxFormat(['session_name', 'window_id', 'window_name'])]);
|
|
152
|
+
const [[session, windowId, windowName] = []] = parseTmuxRows(out, 3, 'pane location');
|
|
153
153
|
return { session, window: windowId, windowName };
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const Q_ESCAPED = new Set([
|
|
2
|
+
'|', '&', ';', '<', '>', '(', ')', '$', '`', '\\', '"', "'", '*', '?', '[', '#', ' ', '=', '%',
|
|
3
|
+
]);
|
|
4
|
+
|
|
5
|
+
export function tmuxFormat(fields) {
|
|
6
|
+
if (!Array.isArray(fields) || fields.length === 0) throw new Error('tmux format fields are required');
|
|
7
|
+
return fields.map((field) => {
|
|
8
|
+
if (typeof field !== 'string' || !/^@?[A-Za-z0-9_]+$/.test(field)) {
|
|
9
|
+
throw new Error(`invalid tmux format field: ${String(field)}`);
|
|
10
|
+
}
|
|
11
|
+
return `#{q:${field}}`;
|
|
12
|
+
}).join('|');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseTmuxFields(line, columns, label = 'tmux') {
|
|
16
|
+
const value = String(line);
|
|
17
|
+
const fields = [];
|
|
18
|
+
let field = '';
|
|
19
|
+
for (let i = 0; i < value.length; i++) {
|
|
20
|
+
const char = value[i];
|
|
21
|
+
if (char === '|') {
|
|
22
|
+
fields.push(field);
|
|
23
|
+
field = '';
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (char !== '\\') {
|
|
27
|
+
field += char;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (i + 1 >= value.length) throw new Error(`invalid ${label} format: dangling escape`);
|
|
31
|
+
const escaped = value[++i];
|
|
32
|
+
if (!Q_ESCAPED.has(escaped)) throw new Error(`invalid ${label} format: unsupported escape`);
|
|
33
|
+
field += escaped;
|
|
34
|
+
}
|
|
35
|
+
fields.push(field);
|
|
36
|
+
if (fields.length !== columns) throw new Error(`invalid ${label} format: expected ${columns} fields`);
|
|
37
|
+
return fields;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseTmuxRows(output, columns, label = 'tmux') {
|
|
41
|
+
const value = String(output ?? '');
|
|
42
|
+
if (value === '' || value === '\n' || value === '\r\n') return [];
|
|
43
|
+
return value.replace(/\r?\n$/, '').split(/\r?\n/)
|
|
44
|
+
.map((line) => parseTmuxFields(line, columns, label));
|
|
45
|
+
}
|