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.
Files changed (50) hide show
  1. package/README.md +18 -2
  2. package/README.zh-CN.md +18 -2
  3. package/bin/handmux.js +25 -1
  4. package/package.json +6 -5
  5. package/public/assets/index-CvNuQ-bC.js +330 -0
  6. package/public/assets/index-DjJf1FX9.css +32 -0
  7. package/public/index.html +2 -2
  8. package/src/agents/claude.js +1 -0
  9. package/src/agents/codex.js +1 -0
  10. package/src/agents/scanUtils.js +3 -4
  11. package/src/claudeEvents.js +23 -6
  12. package/src/cli/i18n/en.js +87 -2
  13. package/src/cli/i18n/zh.js +87 -2
  14. package/src/cli/options.js +23 -5
  15. package/src/cli/prompt.js +1 -1
  16. package/src/cli/setupModel.js +11 -2
  17. package/src/cli/setupWizard.js +19 -18
  18. package/src/cli/shortcutEditor.js +288 -0
  19. package/src/cli/supervisor.js +1 -0
  20. package/src/cli/workspaceCmd.js +300 -0
  21. package/src/config.js +3 -0
  22. package/src/httpApi.js +6 -1
  23. package/src/internalPreviewGateway.js +487 -0
  24. package/src/keyNames.js +10 -0
  25. package/src/orphans.js +3 -2
  26. package/src/routes/sessions.js +13 -1
  27. package/src/routes/system.js +13 -2
  28. package/src/routes/terminal.js +3 -13
  29. package/src/routes/workspace.js +267 -0
  30. package/src/server.js +46 -5
  31. package/src/shortcutConfig.js +75 -0
  32. package/src/tmux/commands.js +17 -17
  33. package/src/tmux/format.js +45 -0
  34. package/src/workspace/agentRunner.js +185 -0
  35. package/src/workspace/atomicJson.js +32 -0
  36. package/src/workspace/capture.js +87 -0
  37. package/src/workspace/checkpointer.js +179 -0
  38. package/src/workspace/environment.js +75 -0
  39. package/src/workspace/lock.js +128 -0
  40. package/src/workspace/mapping.js +124 -0
  41. package/src/workspace/operations.js +252 -0
  42. package/src/workspace/paths.js +16 -0
  43. package/src/workspace/planner.js +247 -0
  44. package/src/workspace/restore.js +279 -0
  45. package/src/workspace/runtime.js +314 -0
  46. package/src/workspace/schema.js +136 -0
  47. package/src/workspace/store.js +382 -0
  48. package/src/workspace/tmuxAdapter.js +554 -0
  49. package/public/assets/index-CiaHwIWj.css +0 -32
  50. package/public/assets/index-cjqG9l7v.js +0 -330
@@ -0,0 +1,300 @@
1
+ import { homedir } from 'node:os';
2
+ import { runTmux as defaultRunTmux } from '../tmux/commands.js';
3
+ import { createWorkspaceStore } from '../workspace/store.js';
4
+ import { createWorkspaceTmux } from '../workspace/tmuxAdapter.js';
5
+ import { createWorkspaceLock } from '../workspace/lock.js';
6
+ import { createWorkspaceBackground } from '../workspace/checkpointer.js';
7
+ import { createEnvironmentProvider } from '../workspace/environment.js';
8
+ import { createWorkspaceRuntime } from '../workspace/runtime.js';
9
+ import { claudeStatePath } from './state.js';
10
+ import { ask, select, CANCELLED } from './prompt.js';
11
+ import { t } from './i18n/index.js';
12
+
13
+ const RESTORE_FLAGS = new Set(['list', 'dryRun', 'checkpoint', 'session', 'lang', 'config']);
14
+ const SAFE_CHECKPOINT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
15
+ const INVALID_TOPOLOGY_REASONS = new Set([
16
+ 'invalid-session', 'invalid-session-id', 'invalid-session-name', 'invalid-active-window-id',
17
+ 'invalid-window-links', 'missing-window-links', 'invalid-window-link', 'invalid-window-link-index',
18
+ 'duplicate-window-link', 'duplicate-window-link-index', 'dangling-window-link', 'dangling-active-window',
19
+ 'missing-window-panes', 'invalid-pane', 'duplicate-pane', 'dangling-active-pane',
20
+ ]);
21
+
22
+ function line(stream, value = '') {
23
+ if (typeof stream === 'function') stream(value);
24
+ else stream.write(`${value}\n`);
25
+ }
26
+
27
+ function countAgents(checkpoint) {
28
+ return (checkpoint?.windows || []).flatMap((window) => window?.panes || []).filter((pane) => pane?.agent).length;
29
+ }
30
+
31
+ function checkpointCounts(checkpoint) {
32
+ const windows = checkpoint?.windows || [];
33
+ return {
34
+ sessions: (checkpoint?.sessions || []).length,
35
+ windows: windows.length,
36
+ panes: windows.reduce((sum, window) => sum + (window?.panes || []).length, 0),
37
+ agents: countAgents(checkpoint),
38
+ };
39
+ }
40
+
41
+ function unsupportedReason(reason) {
42
+ if (reason === 'linked-windows-unsupported') return t('restore.reason.linkedWindows');
43
+ if (INVALID_TOPOLOGY_REASONS.has(reason)) return t('restore.reason.invalidTopology');
44
+ return t('restore.reason.unknown', { reason: reason || 'unknown' });
45
+ }
46
+
47
+ function posixQuote(value) {
48
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
49
+ }
50
+
51
+ function restoreCommand(checkpoint, sessions = []) {
52
+ const selectedSessions = Array.isArray(sessions) ? sessions : [sessions];
53
+ return selectedSessions.reduce(
54
+ (command, session) => `${command} --session ${posixQuote(session)}`,
55
+ `handmux restore --checkpoint ${posixQuote(checkpoint)}`,
56
+ );
57
+ }
58
+
59
+ function manualSessionCommand(session) {
60
+ return `tmux new-session -s ${posixQuote(session)}`;
61
+ }
62
+
63
+ function writeManualRecovery(stream, session) {
64
+ line(stream, t('restore.manualRecovery', { command: manualSessionCommand(session) }));
65
+ }
66
+
67
+ function normalizeFlags(flags) {
68
+ const unknown = Object.keys(flags).find((key) => !RESTORE_FLAGS.has(key));
69
+ if (unknown) return { error: t('restore.badFlag', { flag: `--${unknown}` }) };
70
+ if (flags.list !== undefined && typeof flags.list !== 'boolean') return { error: t('restore.flagBoolean', { flag: '--list' }) };
71
+ if (flags.dryRun !== undefined && typeof flags.dryRun !== 'boolean') return { error: t('restore.flagBoolean', { flag: '--dry-run' }) };
72
+ if (flags.checkpoint !== undefined && (typeof flags.checkpoint !== 'string' || !flags.checkpoint.trim())) {
73
+ return { error: t('restore.checkpointValue') };
74
+ }
75
+ if (typeof flags.checkpoint === 'string'
76
+ && (!SAFE_CHECKPOINT_ID.test(flags.checkpoint.trim()) || flags.checkpoint.trim() === '.' || flags.checkpoint.trim() === '..')) {
77
+ return { error: t('restore.checkpointId') };
78
+ }
79
+ const rawSessions = flags.session === undefined ? [] : (Array.isArray(flags.session) ? flags.session : [flags.session]);
80
+ if (rawSessions.some((name) => typeof name !== 'string' || !name.trim())) return { error: t('restore.sessionValue') };
81
+ if (flags.list && (flags.dryRun || flags.checkpoint !== undefined || flags.session !== undefined)) {
82
+ return { error: t('restore.listExclusive') };
83
+ }
84
+ return {
85
+ list: flags.list === true,
86
+ dryRun: flags.dryRun === true,
87
+ checkpoint: flags.checkpoint?.trim(),
88
+ sessions: rawSessions.map((name) => name.trim()),
89
+ };
90
+ }
91
+
92
+ function validRows(rows) {
93
+ return rows.filter((row) => row?.status === 'ok' && row.value);
94
+ }
95
+
96
+ function writeCheckpointRow(stdout, row) {
97
+ if (row.status !== 'ok') {
98
+ line(stdout, t('restore.listUnavailable', { id: row.id || '?', error: row.error || row.status || 'unknown' }));
99
+ return;
100
+ }
101
+ const counts = checkpointCounts(row.value);
102
+ line(stdout, t('restore.listRow', {
103
+ id: row.id,
104
+ time: row.value.archivedAt || row.value.capturedAt || '?',
105
+ ...counts,
106
+ }));
107
+ }
108
+
109
+ async function defaultSelectCheckpoint(rows) {
110
+ return ask(select({
111
+ message: t('restore.selectCheckpoint'),
112
+ options: rows.map((row) => {
113
+ const counts = checkpointCounts(row.value);
114
+ return {
115
+ value: row.id,
116
+ label: row.id,
117
+ hint: t('restore.selectHint', { time: row.value.archivedAt || row.value.capturedAt || '?', ...counts }),
118
+ };
119
+ }),
120
+ initialValue: rows[0]?.id,
121
+ }));
122
+ }
123
+
124
+ function writePlan(stdout, plan, dryRun, continuationCommand) {
125
+ line(stdout, t('restore.planCheckpoint', {
126
+ id: plan.checkpointId,
127
+ time: plan.capturedAt || plan.archivedAt || '?',
128
+ sessions: plan.summary?.sessions ?? 0,
129
+ windows: plan.summary?.windows ?? 0,
130
+ panes: plan.summary?.panes ?? 0,
131
+ }));
132
+ line(stdout, t('restore.planCurrent', { sessions: plan.preExistingRuntimeIds?.sessions?.length ?? 0 }));
133
+ line(stdout);
134
+ for (const item of plan.sessions || []) {
135
+ if (item.action === 'create') line(stdout, t('restore.planCreate', { session: item.sourceName }));
136
+ else if (item.action === 'create-renamed') line(stdout, t('restore.planRenamed', { session: item.sourceName, target: item.targetName }));
137
+ else if (item.action === 'already-present') line(stdout, t('restore.planAlready', { session: item.sourceName }));
138
+ else {
139
+ const session = item.sourceName || item.logicalId || '?';
140
+ line(stdout, t('restore.planUnavailable', { session, reason: unsupportedReason(item.reason) }));
141
+ writeManualRecovery(stdout, session);
142
+ }
143
+ }
144
+ for (const warning of plan.warnings || []) line(stdout, t('restore.warning', { warning }));
145
+ line(stdout);
146
+ line(stdout, t('restore.nonDestructive'));
147
+ if (dryRun && !(plan.sessions || []).some((item) => item.action === 'unsupported')) {
148
+ line(stdout, t('restore.dryRunHint', { command: continuationCommand }));
149
+ }
150
+ }
151
+
152
+ function writeResult(stdout, stderr, checkpointId, result) {
153
+ for (const item of result.results || []) {
154
+ const session = item.sourceName || item.logicalId || '?';
155
+ if (item.status === 'restored') {
156
+ line(stdout, item.targetName && item.targetName !== session
157
+ ? t('restore.resultRenamed', { session, target: item.targetName })
158
+ : t('restore.resultRestored', { session }));
159
+ for (const warning of item.warnings || []) line(stdout, t('restore.sessionWarning', { session, warning }));
160
+ } else if (item.status === 'already-present') {
161
+ line(stdout, t('restore.resultAlready', { session }));
162
+ } else {
163
+ const stage = item.stage || 'restore';
164
+ line(stderr, t('restore.sessionFailed', {
165
+ checkpoint: checkpointId,
166
+ session,
167
+ stage,
168
+ error: stage === 'plan' ? unsupportedReason(item.error) : (item.error || 'unknown error'),
169
+ }));
170
+ if (stage === 'plan') writeManualRecovery(stderr, session);
171
+ else line(stderr, t('restore.retrySession', { command: restoreCommand(checkpointId, session) }));
172
+ }
173
+ }
174
+ if (result.status !== 'succeeded' && result.error && !(result.results || []).some((item) => item.status === 'failed')) {
175
+ line(stderr, t('restore.operationFailed', { checkpoint: checkpointId, stage: 'restore', error: result.error }));
176
+ line(stderr, t('restore.retry', { command: restoreCommand(checkpointId) }));
177
+ }
178
+ for (const warning of result.warnings || []) line(stdout, t('restore.warning', { warning }));
179
+ line(stdout);
180
+ line(stdout, t('restore.resultSummary', {
181
+ restored: result.restored ?? 0,
182
+ already: result.alreadyPresent ?? 0,
183
+ failed: result.failed ?? 0,
184
+ }));
185
+ line(stdout, t('restore.nonDestructivePast'));
186
+ }
187
+
188
+ export function createStandaloneWorkspaceRuntime({
189
+ home = homedir(),
190
+ runTmux = defaultRunTmux,
191
+ stateFile = claudeStatePath(home),
192
+ createStore = createWorkspaceStore,
193
+ createTmux = createWorkspaceTmux,
194
+ createLock = createWorkspaceLock,
195
+ createCheckpointer = createWorkspaceBackground,
196
+ createRuntime = createWorkspaceRuntime,
197
+ observeEnvironment,
198
+ readOnly = false,
199
+ } = {}) {
200
+ const store = createStore({ home });
201
+ const tmux = createTmux({ run: runTmux, readOnly });
202
+ const lock = createLock({ dir: store.paths.lockDir });
203
+ const observe = observeEnvironment || createEnvironmentProvider({
204
+ tmuxServerIdProvider: async () => {
205
+ const observed = await tmux.observeEnvironment();
206
+ if (observed.status === 'unknown') throw new Error('tmux environment unavailable');
207
+ return observed.tmuxServerId;
208
+ },
209
+ });
210
+ const checkpointer = createCheckpointer({ store, tmux, observeEnvironment: observe, lock, stateFile });
211
+ return createRuntime({ store, tmux, lock, checkpointer, home });
212
+ }
213
+
214
+ export async function runWorkspaceCommand({
215
+ flags = {},
216
+ positionals = [],
217
+ unknownShortFlags = [],
218
+ home = homedir(),
219
+ runtime,
220
+ inputIsTTY = Boolean(process.stdin.isTTY),
221
+ outputIsTTY = Boolean(process.stdout.isTTY),
222
+ selectCheckpoint = defaultSelectCheckpoint,
223
+ stdout = process.stdout,
224
+ stderr = process.stderr,
225
+ createRuntime = createStandaloneWorkspaceRuntime,
226
+ } = {}) {
227
+ if (positionals.length > 0 || unknownShortFlags.length > 0) {
228
+ const error = positionals.length > 0
229
+ ? t('restore.unexpectedArgument', { value: positionals[0] })
230
+ : t('restore.unknownShortFlag', { flag: unknownShortFlags[0] });
231
+ line(stderr, error);
232
+ line(stderr, t('restore.usage'));
233
+ return 2;
234
+ }
235
+ const parsed = normalizeFlags(flags);
236
+ if (parsed.error) {
237
+ line(stderr, parsed.error);
238
+ line(stderr, t('restore.usage'));
239
+ return 2;
240
+ }
241
+ const workspace = runtime || createRuntime({ home, readOnly: parsed.dryRun });
242
+ let resolvedCheckpoint = parsed.checkpoint || 'latest';
243
+
244
+ try {
245
+ if (parsed.list) {
246
+ const rows = await workspace.listCheckpoints();
247
+ if (rows.length === 0) line(stdout, t('restore.listEmpty'));
248
+ else rows.forEach((row) => writeCheckpointRow(stdout, row));
249
+ return 0;
250
+ }
251
+
252
+ let checkpointId;
253
+ let historical = false;
254
+ if (parsed.checkpoint && parsed.checkpoint !== 'latest') {
255
+ checkpointId = parsed.checkpoint;
256
+ historical = true;
257
+ } else {
258
+ const rows = validRows(await workspace.listCheckpoints());
259
+ if (rows.length === 0) {
260
+ line(stderr, t('restore.noCheckpoint'));
261
+ return 1;
262
+ }
263
+ if (parsed.checkpoint === 'latest') {
264
+ checkpointId = rows[0].id;
265
+ historical = true;
266
+ } else if (rows.length === 1) {
267
+ checkpointId = rows[0].id;
268
+ } else if (inputIsTTY && outputIsTTY) {
269
+ checkpointId = await selectCheckpoint(rows);
270
+ historical = true;
271
+ } else {
272
+ checkpointId = rows[0].id;
273
+ }
274
+ }
275
+
276
+ if (typeof checkpointId !== 'string' || !checkpointId) {
277
+ line(stderr, t('restore.selectionCancelled', { command: 'handmux restore' }));
278
+ return 1;
279
+ }
280
+ resolvedCheckpoint = checkpointId;
281
+ const request = { checkpointId, sessions: parsed.sessions, historical: historical || parsed.dryRun };
282
+ if (parsed.dryRun) {
283
+ const restorePlan = await workspace.getRestorePlan(request);
284
+ writePlan(stdout, restorePlan, true, restoreCommand(checkpointId, parsed.sessions));
285
+ return restorePlan.planSummary?.unsupported > 0 ? 1 : 0;
286
+ }
287
+
288
+ const result = await workspace.restoreNow(request);
289
+ writeResult(stdout, stderr, checkpointId, result);
290
+ return result.status === 'succeeded' ? 0 : 1;
291
+ } catch (error) {
292
+ if (error === CANCELLED) {
293
+ line(stderr, t('restore.selectionCancelled', { command: 'handmux restore' }));
294
+ return 1;
295
+ }
296
+ line(stderr, t('restore.error', { checkpoint: resolvedCheckpoint, error: error?.message || String(error) }));
297
+ line(stderr, t('restore.retry', { command: restoreCommand(resolvedCheckpoint) }));
298
+ return 1;
299
+ }
300
+ }
package/src/config.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { normalizeShortcuts } from './shortcutConfig.js';
2
+
1
3
  export function loadConfig(env = process.env) {
2
4
  return {
3
5
  host: env.HANDMUX_HOST || '0.0.0.0',
4
6
  port: Number(env.HANDMUX_PORT) || 4000,
7
+ shortcuts: normalizeShortcuts(env.HANDMUX_SHORTCUTS === undefined ? undefined : JSON.parse(env.HANDMUX_SHORTCUTS)),
5
8
  };
6
9
  }
package/src/httpApi.js CHANGED
@@ -22,6 +22,8 @@ import { systemRoutes } from './routes/system.js';
22
22
  import { previewRoutes } from './routes/previews.js';
23
23
  import { notificationRoutes } from './routes/notifications.js';
24
24
  import { transcriptRoutes } from './routes/transcript.js';
25
+ import { DEFAULT_SHORTCUTS } from './shortcutConfig.js';
26
+ import { workspaceRoutes } from './routes/workspace.js';
25
27
 
26
28
  // Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
27
29
  export { isAllowedKey } from './routes/terminal.js';
@@ -30,6 +32,8 @@ export function createApiRouter({
30
32
  token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events,
31
33
  uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES,
32
34
  asrEnv = process.env, previews, previewDomain = null,
35
+ shortcuts = DEFAULT_SHORTCUTS,
36
+ workspace,
33
37
  home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()),
34
38
  } = {}) {
35
39
  const r = express.Router();
@@ -39,7 +43,7 @@ export function createApiRouter({
39
43
 
40
44
  const deps = {
41
45
  token, commands, docs, git, push, notifications, claudeEvents,
42
- uploadExts, maxUploadBytes, asrEnv, previews, previewDomain, home, stateFile,
46
+ uploadExts, maxUploadBytes, asrEnv, previews, previewDomain, shortcuts, home, stateFile, workspace,
43
47
  };
44
48
 
45
49
  r.use(sessionRoutes(deps));
@@ -51,6 +55,7 @@ export function createApiRouter({
51
55
  r.use(systemRoutes(deps));
52
56
  r.use(previewRoutes(deps));
53
57
  r.use(transcriptRoutes(deps));
58
+ if (workspace) r.use(workspaceRoutes(deps));
54
59
 
55
60
  return r;
56
61
  }