handmux 0.17.8 → 0.19.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 +21 -4
- package/README.zh-CN.md +20 -3
- package/bin/handmux.js +25 -1
- package/hooks/handmux-codex-usage.cjs +141 -0
- package/hooks/handmux-statusline.cjs +16 -3
- package/hooks/handmux-write.cjs +9 -0
- package/package.json +7 -5
- package/public/assets/index-CIlbBw_U.js +330 -0
- package/public/assets/index-erQcVC-q.css +32 -0
- package/public/index.html +5 -6
- 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/codexHooks.js +10 -0
- package/src/cli/hookScaffold.js +3 -3
- 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 +17 -7
- package/src/routes/terminal.js +26 -13
- package/src/routes/workspace.js +267 -0
- package/src/server.js +55 -5
- package/src/shortcutConfig.js +75 -0
- package/src/staticCompression.js +10 -0
- package/src/tmux/commands.js +22 -17
- package/src/tmux/format.js +45 -0
- package/src/usage.js +214 -60
- 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-BVccqWdO.css +0 -32
- package/public/assets/index-zXIn4X1S.js +0 -330
package/src/routes/terminal.js
CHANGED
|
@@ -5,6 +5,9 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { gzipSync } from 'node:zlib';
|
|
6
6
|
import { isPaneId, isWindowId } from '../tmux/commands.js';
|
|
7
7
|
import { capTrailingBlankRows } from '../trimCapture.js';
|
|
8
|
+
import { isAllowedKey } from '../keyNames.js';
|
|
9
|
+
|
|
10
|
+
export { isAllowedKey } from '../keyNames.js';
|
|
8
11
|
|
|
9
12
|
// Keys the mobile keyboard may send via /keys. A controlled vocabulary of named tmux keys, PLUS
|
|
10
13
|
// live-modifier combinations (Ctrl/Alt + a single letter or digit) so the keyboard's Ctrl modifier
|
|
@@ -12,24 +15,20 @@ import { capTrailingBlankRows } from '../trimCapture.js';
|
|
|
12
15
|
// one here. tmux send-keys key names are themselves a closed set, so this stays a strict allowlist
|
|
13
16
|
// (never a passthrough): a key either names an approved token or matches the modifier shape, or it's
|
|
14
17
|
// rejected. The old fixed C-c/C-d/C-z/C-l/C-r/C-o/C-e all still match `C-[a-z0-9]`.
|
|
15
|
-
const NAMED = 'Up|Down|Left|Right|Space|Enter|Escape|Tab|BTab|BSpace|Home|End|PageUp|PageDown';
|
|
16
|
-
// A named tmux key with any (optional) modifier prefixes, in the canonical C- M- S- order buildChord emits.
|
|
17
|
-
// Covers the plain keys (Up, Tab, …) AND modifier combos on them — C-Up, M-Up, S-Left, C-Tab, C-S-Up, … —
|
|
18
|
-
// so the 按键 editor can bind e.g. Ctrl+Arrow or Ctrl+Tab, not just Ctrl + a letter.
|
|
19
|
-
const NAMED_KEY = new RegExp(`^(?:C-)?(?:M-)?(?:S-)?(?:${NAMED})$`);
|
|
20
|
-
// Ctrl and/or Alt + one letter or digit — C-r, C-a, M-b, C-M-k. Must carry a modifier (never a bare char;
|
|
21
|
-
// Shift+letter folds to the uppercase character, so no S- here).
|
|
22
|
-
const CHAR_KEY = /^(?:C-)?(?:M-)?[a-z0-9]$/;
|
|
23
|
-
export function isAllowedKey(k) {
|
|
24
|
-
if (typeof k !== 'string') return false;
|
|
25
|
-
return NAMED_KEY.test(k) || (CHAR_KEY.test(k) && k.includes('-'));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
18
|
// Pause between typing the text and pressing Enter on a /send. A TUI like Claude Code needs a
|
|
29
19
|
// beat to ingest the pasted line; without it, the Enter can fold into the input as a newline
|
|
30
20
|
// instead of submitting. 120ms is imperceptible but enough to settle.
|
|
31
21
|
const SUBMIT_GAP_MS = 120;
|
|
22
|
+
const RAW_INPUT_MAX_BYTES = 16 * 1024;
|
|
32
23
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
const validHexInput = (hex) => typeof hex === 'string'
|
|
25
|
+
&& hex.length >= 2
|
|
26
|
+
&& hex.length <= RAW_INPUT_MAX_BYTES * 2
|
|
27
|
+
&& hex.length % 2 === 0
|
|
28
|
+
&& /^[0-9a-f]+$/i.test(hex);
|
|
29
|
+
const isMissingPane = (error) =>
|
|
30
|
+
/(?:can't find|cannot find|no such|unknown|not found).*\bpane\b|\bpane\b.*(?:can't find|cannot find|no such|unknown|not found)/i
|
|
31
|
+
.test(error instanceof Error ? error.message : String(error));
|
|
33
32
|
|
|
34
33
|
export function terminalRoutes({ commands }) {
|
|
35
34
|
const r = express.Router();
|
|
@@ -97,6 +96,20 @@ export function terminalRoutes({ commands }) {
|
|
|
97
96
|
} catch (e) { next(e); }
|
|
98
97
|
});
|
|
99
98
|
|
|
99
|
+
r.post('/input', async (req, res, next) => {
|
|
100
|
+
const { pane, hex } = req.body || {};
|
|
101
|
+
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
102
|
+
if (!validHexInput(hex)) return res.status(400).json({ error: 'bad input bytes' });
|
|
103
|
+
try {
|
|
104
|
+
await commands.exitCopyModeIfActive(pane);
|
|
105
|
+
await commands.sendHexInput(pane, hex);
|
|
106
|
+
res.json({ ok: true });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (isMissingPane(error)) return res.status(404).json({ error: 'pane not found' });
|
|
109
|
+
next(error);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
100
113
|
// Resize the tmux window so it reflows to the phone (auto:false), or hand sizing back to
|
|
101
114
|
// attached clients (auto:true). NOTE: this mutates the shared window — the PC's view of it
|
|
102
115
|
// changes too.
|
|
@@ -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
|
@@ -8,14 +8,22 @@ import { createApiRouter } from './httpApi.js';
|
|
|
8
8
|
import { loadUploadExts } from './uploadTypes.js';
|
|
9
9
|
import { createClaudeEvents } from './claudeEvents.js';
|
|
10
10
|
import { syncHooks } from './cli/claudeHooks.js';
|
|
11
|
+
import { syncCodexHooks } from './cli/codexHooks.js';
|
|
11
12
|
import { claudeStatePath } from './cli/state.js';
|
|
12
13
|
import * as commands from './tmux/commands.js';
|
|
13
14
|
import * as push from './push.js';
|
|
14
15
|
import { cacheControlFor } from './staticCache.js';
|
|
16
|
+
import { compressStaticAssets } from './staticCompression.js';
|
|
15
17
|
import { applyAppName, applyManifestName } from './appName.js';
|
|
16
18
|
import { homedir } from 'node:os';
|
|
17
19
|
import { createPreviews } from './previews.js';
|
|
18
20
|
import { createPreview } from './previewServer.js';
|
|
21
|
+
import { createWorkspaceStore } from './workspace/store.js';
|
|
22
|
+
import { createWorkspaceTmux } from './workspace/tmuxAdapter.js';
|
|
23
|
+
import { createEnvironmentProvider } from './workspace/environment.js';
|
|
24
|
+
import { createWorkspaceLock } from './workspace/lock.js';
|
|
25
|
+
import { createGracefulShutdown, createWorkspaceBackground } from './workspace/checkpointer.js';
|
|
26
|
+
import { createWorkspaceRuntime } from './workspace/runtime.js';
|
|
19
27
|
|
|
20
28
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
29
|
|
|
@@ -27,21 +35,57 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
27
35
|
const cfg = loadConfig();
|
|
28
36
|
const token = loadToken();
|
|
29
37
|
const uploadExts = loadUploadExts();
|
|
38
|
+
const home = homedir();
|
|
39
|
+
|
|
40
|
+
// One writer set is shared by background capture today and the restore runtime added on top of it. The
|
|
41
|
+
// lock is filesystem-backed because the CLI may restore while this daemon is alive in another process.
|
|
42
|
+
const workspaceStore = createWorkspaceStore({ home });
|
|
43
|
+
const workspaceTmux = createWorkspaceTmux({ run: commands.runTmux });
|
|
44
|
+
const workspaceLock = createWorkspaceLock({ dir: workspaceStore.paths.lockDir });
|
|
45
|
+
const observeEnvironment = createEnvironmentProvider({
|
|
46
|
+
tmuxServerIdProvider: async () => {
|
|
47
|
+
const observed = await workspaceTmux.observeEnvironment();
|
|
48
|
+
if (observed.status === 'unknown') throw new Error('tmux environment unavailable');
|
|
49
|
+
return observed.tmuxServerId;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(home);
|
|
53
|
+
const workspaceBackground = createWorkspaceBackground({
|
|
54
|
+
store: workspaceStore,
|
|
55
|
+
tmux: workspaceTmux,
|
|
56
|
+
observeEnvironment,
|
|
57
|
+
lock: workspaceLock,
|
|
58
|
+
stateFile,
|
|
59
|
+
});
|
|
60
|
+
const workspace = createWorkspaceRuntime({
|
|
61
|
+
store: workspaceStore,
|
|
62
|
+
tmux: workspaceTmux,
|
|
63
|
+
lock: workspaceLock,
|
|
64
|
+
checkpointer: workspaceBackground,
|
|
65
|
+
home,
|
|
66
|
+
});
|
|
30
67
|
|
|
31
68
|
// The inbox is driven by a JSON state file the Claude hooks maintain (server/hooks/handmux-notify.sh →
|
|
32
69
|
// handmux-write.js). We only READ it. start() watches it so idle/permission push fires even when no
|
|
33
70
|
// client is polling; getStates reads it fresh on each /states.
|
|
34
|
-
const events = createClaudeEvents({ commands, push });
|
|
71
|
+
const events = createClaudeEvents({ commands, push, file: stateFile, onStateChange: workspace.requestReconcile });
|
|
35
72
|
events.start();
|
|
73
|
+
workspace.start().catch(() => {});
|
|
36
74
|
|
|
37
75
|
// Keep an already-opted-in user's Claude hooks in step with this handmux version on restart: newly-added
|
|
38
76
|
// lifecycle events (e.g. SessionStart, which rebinds the 对话 lens after /clear) and a refreshed
|
|
39
77
|
// handmux-write.cjs land via `./deploy.sh` alone — no phone re-enable. A strict no-op unless our hooks are
|
|
40
78
|
// already installed; best-effort and must never block or crash startup (pure fs, no subprocess).
|
|
41
79
|
try {
|
|
42
|
-
syncHooks(
|
|
80
|
+
syncHooks(home, {
|
|
43
81
|
srcDir: path.resolve(here, '../hooks'),
|
|
44
|
-
stateFile
|
|
82
|
+
stateFile,
|
|
83
|
+
});
|
|
84
|
+
} catch { /* best effort — hook sync never fails startup */ }
|
|
85
|
+
try {
|
|
86
|
+
syncCodexHooks(home, {
|
|
87
|
+
srcDir: path.resolve(here, '../hooks'),
|
|
88
|
+
stateFile,
|
|
45
89
|
});
|
|
46
90
|
} catch { /* best effort — hook sync never fails startup */ }
|
|
47
91
|
|
|
@@ -49,14 +93,14 @@ try {
|
|
|
49
93
|
// base domain, e.g. preview.example.com); unset → static only. One registry instance is shared by the
|
|
50
94
|
// API (register/list/remove), the /preview static layer, and the Host-based dynamic proxy.
|
|
51
95
|
const previewDomain = process.env.HANDMUX_PREVIEW_DOMAIN || null;
|
|
52
|
-
const previews = createPreviews({ home
|
|
96
|
+
const previews = createPreviews({ home, dynamicEnabled: !!previewDomain });
|
|
53
97
|
const preview = createPreview({ previews, token, domain: previewDomain });
|
|
54
98
|
|
|
55
99
|
const app = express();
|
|
56
100
|
// Host-based dispatch FIRST: a request to <name>.<domain> is reverse-proxied to its dynamic preview;
|
|
57
101
|
// every other Host falls straight through (next()) to the app below, unaffected.
|
|
58
102
|
app.use(preview.dynamicProxy);
|
|
59
|
-
app.use('/api', createApiRouter({ token, events, uploadExts, previews, previewDomain }));
|
|
103
|
+
app.use('/api', createApiRouter({ token, events, uploadExts, previews, previewDomain, shortcuts: cfg.shortcuts, workspace }));
|
|
60
104
|
app.use('/preview', preview.router);
|
|
61
105
|
app.use(preview.refererFallback);
|
|
62
106
|
|
|
@@ -83,6 +127,7 @@ if (appName) {
|
|
|
83
127
|
}
|
|
84
128
|
|
|
85
129
|
// index:false so the renamed shell below owns "/" too (otherwise static would serve the generic one).
|
|
130
|
+
app.use(compressStaticAssets);
|
|
86
131
|
app.use(express.static(staticDir, {
|
|
87
132
|
index: false,
|
|
88
133
|
// Cache-Control policy lives in staticCache.js (unit-tested): index.html + sw.js are never cached
|
|
@@ -108,3 +153,8 @@ const server = app.listen(cfg.port, cfg.host, () => {
|
|
|
108
153
|
});
|
|
109
154
|
// WebSocket/HMR for dynamic previews: route raw Upgrade by Host to the right loopback port.
|
|
110
155
|
server.on('upgrade', preview.onUpgrade);
|
|
156
|
+
|
|
157
|
+
const shutdown = createGracefulShutdown({ events, workspace, server });
|
|
158
|
+
const handleSignal = () => { shutdown().catch(() => {}); };
|
|
159
|
+
process.on('SIGINT', handleSignal);
|
|
160
|
+
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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import compression from 'compression';
|
|
2
|
+
|
|
3
|
+
const compress = compression({ threshold: 1024 });
|
|
4
|
+
|
|
5
|
+
// Only compress Vite's content-hashed frontend assets. API responses keep their existing
|
|
6
|
+
// latency/streaming behavior, while the large JS/CSS needed for first paint becomes much smaller.
|
|
7
|
+
export function compressStaticAssets(req, res, next) {
|
|
8
|
+
if (!req.path.startsWith('/assets/')) return next();
|
|
9
|
+
return compress(req, res, next);
|
|
10
|
+
}
|
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', 'pane_id',
|
|
49
|
+
])]);
|
|
50
|
+
return parseTmuxRows(out, 7, 'window').map(([id, name, active, panes, width, height, activePaneId]) => {
|
|
51
|
+
return { id, name, active: active === '1', panes: Number(panes), width: Number(width), height: Number(height), activePaneId };
|
|
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
|
|
|
@@ -164,6 +164,11 @@ export async function sendText(paneId, text) {
|
|
|
164
164
|
await runTmux(['send-keys', '-t', paneId, '-l', '--', text]);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
export async function sendHexInput(paneId, hex) {
|
|
168
|
+
const bytes = hex.match(/../g) || [];
|
|
169
|
+
await runTmux(['send-keys', '-t', paneId, '-H', ...bytes]);
|
|
170
|
+
}
|
|
171
|
+
|
|
167
172
|
export async function sendEnter(paneId) {
|
|
168
173
|
await runTmux(['send-keys', '-t', paneId, 'Enter']);
|
|
169
174
|
}
|