handmux 0.23.0 → 0.25.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 +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +154 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +3436 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +167 -0
- package/dist/src/paneInput.js +42 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +363 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/claudeHooks.js +0 -206
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/statusLine.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/server.js +0 -186
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Resolve the natapp / cpolar client binaries, mirroring cloudflared.js. The two providers differ in one
|
|
2
|
+
// hard way: cpolar publishes stable per-OS/arch zips, so we can AUTO-DOWNLOAD it (PATH → ~/.handmux/bin →
|
|
3
|
+
// fetch+unzip); natapp gates its downloads behind a login, so there's no stable URL to fetch — we resolve an
|
|
4
|
+
// already-installed binary and otherwise throw a clear "download it yourself into ~/.handmux/bin" message.
|
|
5
|
+
// Pure mappers (assetForCpolar) are unit-tested offline; `which`/`fetchImpl` are injectable.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { pocketHome } from './state.js';
|
|
10
|
+
import { onPath, drain, defaultProgress } from './cloudflared.js';
|
|
11
|
+
import { t } from './i18n/index.js';
|
|
12
|
+
// cpolar release asset for this OS/arch. Linux/macOS ship a bare binary inside a .zip; Windows ships a
|
|
13
|
+
// `-setup.zip` INSTALLER (not a bare binary), so we don't auto-extract it — Windows users install manually.
|
|
14
|
+
const CPOLAR_VERSION = '3.3.12';
|
|
15
|
+
export function assetForCpolar(platform = process.platform, arch = process.arch) {
|
|
16
|
+
const a = { x64: 'amd64', arm64: 'arm64', arm: 'arm', ia32: '386' }[arch] || arch;
|
|
17
|
+
const base = `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}`;
|
|
18
|
+
if (platform === 'win32')
|
|
19
|
+
return { bin: 'cpolar.exe', url: null }; // installer-only → manual
|
|
20
|
+
const file = `cpolar-stable-${platform === 'darwin' ? 'darwin' : 'linux'}-${a}.zip`;
|
|
21
|
+
return { bin: 'cpolar', file, url: `${base}/${file}` };
|
|
22
|
+
}
|
|
23
|
+
// PATH → ~/.handmux/bin/cpolar → download+unzip. Any download/extract failure surfaces the friendly manual
|
|
24
|
+
// hint (t('client.cpolarManual')) so the worst case is "install it yourself", never a silent dead child.
|
|
25
|
+
export async function resolveCpolar(home, { which = onPath, fetchImpl, log = console, progress } = {}) {
|
|
26
|
+
const found = which('cpolar');
|
|
27
|
+
if (found)
|
|
28
|
+
return found;
|
|
29
|
+
const dir = path.join(pocketHome(home), 'bin');
|
|
30
|
+
const asset = assetForCpolar();
|
|
31
|
+
const dest = path.join(dir, asset.bin);
|
|
32
|
+
if (fs.existsSync(dest))
|
|
33
|
+
return dest;
|
|
34
|
+
if (!asset.url)
|
|
35
|
+
throw new Error(t('client.cpolarManual')); // windows / unknown platform
|
|
36
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
37
|
+
if (!doFetch)
|
|
38
|
+
throw new Error(t('client.cpolarManual'));
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
log.log?.(t('client.downloading', { name: 'cpolar', file: asset.file }));
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await doFetch(asset.url, { redirect: 'follow' });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error(t('client.cpolarManual'));
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
throw new Error(t('client.cpolarManual'));
|
|
50
|
+
const buf = await drain(res, progress || defaultProgress(process.stdout, 'cpolar'));
|
|
51
|
+
const tmp = path.join(dir, asset.file);
|
|
52
|
+
fs.writeFileSync(tmp, buf);
|
|
53
|
+
const r = spawnSync('unzip', ['-o', tmp, '-d', dir], { encoding: 'utf8' });
|
|
54
|
+
try {
|
|
55
|
+
fs.unlinkSync(tmp);
|
|
56
|
+
}
|
|
57
|
+
catch { /* best-effort cleanup */ }
|
|
58
|
+
if (r.status !== 0 || !fs.existsSync(dest))
|
|
59
|
+
throw new Error(t('client.cpolarManual'));
|
|
60
|
+
fs.chmodSync(dest, 0o755);
|
|
61
|
+
return dest;
|
|
62
|
+
}
|
|
63
|
+
// natapp: PATH → ~/.handmux/bin/natapp, else a clear manual-download hint (no auto-download — login-gated).
|
|
64
|
+
export function resolveNatapp(home, { which = onPath } = {}) {
|
|
65
|
+
const found = which('natapp');
|
|
66
|
+
if (found)
|
|
67
|
+
return found;
|
|
68
|
+
const dest = path.join(pocketHome(home), 'bin', process.platform === 'win32' ? 'natapp.exe' : 'natapp');
|
|
69
|
+
if (fs.existsSync(dest))
|
|
70
|
+
return dest;
|
|
71
|
+
throw new Error(t('client.natappManual', { dir: path.join(pocketHome(home), 'bin') }));
|
|
72
|
+
}
|
|
@@ -7,127 +7,156 @@
|
|
|
7
7
|
// 2. The version query goes through the user's own npm (`npm view handmux version`), so it honours a
|
|
8
8
|
// configured China mirror / private registry instead of hard-coding registry.npmjs.org. Any failure
|
|
9
9
|
// (offline, blocked, npm missing) is swallowed — the notifier is best-effort, never an error.
|
|
10
|
-
import fs from 'node:fs';
|
|
11
10
|
import path from 'node:path';
|
|
12
11
|
import { spawn, spawnSync } from 'node:child_process';
|
|
13
12
|
import { pocketHome } from './state.js';
|
|
14
13
|
import { t } from './i18n/index.js';
|
|
15
|
-
|
|
14
|
+
import { PrivateStateStore } from '../privateStateStore.js';
|
|
16
15
|
export const PKG_NAME = 'handmux';
|
|
17
16
|
export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // refresh the cached "latest" at most once an hour
|
|
18
|
-
|
|
17
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
18
|
// A Homebrew-installed handmux lives under the Cellar (…/Cellar/handmux/<ver>/…, symlinked from the brew
|
|
20
19
|
// prefix's bin), or under a linuxbrew prefix. For such installs `handmux update` must NOT `npm i -g` over
|
|
21
20
|
// itself — that plants a second, conflicting copy that brew can't see or upgrade — and the "how to upgrade"
|
|
22
21
|
// hint must point at `brew upgrade` instead. Detect the source from the (real, symlink-resolved) entry path.
|
|
23
|
-
export function isBrewInstall(selfPath = '') { return /\/(Cellar|homebrew)\//.test(selfPath); }
|
|
24
|
-
|
|
22
|
+
export function isBrewInstall(selfPath = '') { return /\/(Cellar|homebrew)\//.test(String(selfPath)); }
|
|
25
23
|
export function updateCachePath(home) { return path.join(pocketHome(home), 'update-check.json'); }
|
|
26
|
-
|
|
24
|
+
function parseHighlights(value) {
|
|
25
|
+
if (!Array.isArray(value))
|
|
26
|
+
return null;
|
|
27
|
+
return value.flatMap((item) => {
|
|
28
|
+
if (!isRecord(item)
|
|
29
|
+
|| typeof item.version !== 'string'
|
|
30
|
+
|| typeof item.date !== 'string'
|
|
31
|
+
|| typeof item.zh !== 'string'
|
|
32
|
+
|| typeof item.en !== 'string')
|
|
33
|
+
return [];
|
|
34
|
+
return [{ version: item.version, date: item.date, zh: item.zh, en: item.en }];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
27
37
|
export function readCache(home) {
|
|
28
|
-
|
|
38
|
+
const value = new PrivateStateStore(updateCachePath(home)).read();
|
|
39
|
+
if (!isRecord(value))
|
|
40
|
+
return null;
|
|
41
|
+
const cache = {};
|
|
42
|
+
if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt))
|
|
43
|
+
cache.checkedAt = value.checkedAt;
|
|
44
|
+
if (typeof value.latest === 'string' && parts(value.latest))
|
|
45
|
+
cache.latest = value.latest;
|
|
46
|
+
else if (value.latest === null)
|
|
47
|
+
cache.latest = null;
|
|
48
|
+
if (Array.isArray(value.whatsNew))
|
|
49
|
+
cache.whatsNew = parseHighlights(value.whatsNew);
|
|
50
|
+
else if (value.whatsNew === null)
|
|
51
|
+
cache.whatsNew = null;
|
|
52
|
+
return cache;
|
|
29
53
|
}
|
|
30
|
-
|
|
31
54
|
export function writeCache(home, obj) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
55
|
+
try {
|
|
56
|
+
new PrivateStateStore(updateCachePath(home)).write(obj);
|
|
57
|
+
}
|
|
58
|
+
catch { /* best effort — a missing cache just means we re-check next time */ }
|
|
36
59
|
}
|
|
37
|
-
|
|
38
60
|
// "1.2.3" → [1,2,3]; a prerelease/build tail (`-rc.1`, `+meta`) is ignored. null if unparseable.
|
|
39
61
|
function parts(v) {
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
63
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
42
64
|
}
|
|
43
|
-
|
|
44
65
|
// -1 / 0 / 1 by numeric major.minor.patch. Unparseable inputs compare equal (→ no false "upgrade").
|
|
45
66
|
export function compareVersions(a, b) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
const pa = parts(a), pb = parts(b);
|
|
68
|
+
if (!pa || !pb)
|
|
69
|
+
return 0;
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
const left = pa[i] ?? 0;
|
|
72
|
+
const right = pb[i] ?? 0;
|
|
73
|
+
if (left !== right)
|
|
74
|
+
return left < right ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
50
77
|
}
|
|
51
|
-
|
|
52
78
|
export function isNewer(latest, current) { return compareVersions(latest, current) > 0; }
|
|
53
|
-
|
|
54
79
|
export function shouldRefresh(cache, now = Date.now(), interval = CHECK_INTERVAL_MS) {
|
|
55
|
-
|
|
80
|
+
return !isRecord(cache) || typeof cache.checkedAt !== 'number' || (now - cache.checkedAt) > interval;
|
|
56
81
|
}
|
|
57
|
-
|
|
58
82
|
// ONE npm call fetches both the latest version AND its concise `whatsNew` highlights (the published
|
|
59
83
|
// package.json field release.sh mirrors from the changelog), so the phone can show "what's new" BEFORE
|
|
60
84
|
// upgrading — sourced through the user's own npm, so it stays China-mirror-friendly. `@latest` + `--json`
|
|
61
85
|
// makes npm return an object keyed by field: {"version":"0.9.1","whatsNew":[{version,date,zh,en},…]}.
|
|
62
86
|
export const VIEW_ARGS = ['view', `${PKG_NAME}@latest`, 'version', 'whatsNew', '--json'];
|
|
63
|
-
|
|
64
87
|
// Parse that JSON into { latest, whatsNew }. `latest` is null unless it's a real version; `whatsNew` is
|
|
65
88
|
// null unless it's an array (old published versions predate the field). Never throws.
|
|
66
89
|
export function parseView(stdout) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(String(stdout));
|
|
92
|
+
if (!isRecord(value))
|
|
93
|
+
return { latest: null, whatsNew: null };
|
|
94
|
+
const version = typeof value.version === 'string' ? value.version.trim() : '';
|
|
95
|
+
return { latest: parts(version) ? version : null, whatsNew: parseHighlights(value.whatsNew) };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { latest: null, whatsNew: null };
|
|
99
|
+
}
|
|
72
100
|
}
|
|
73
|
-
|
|
74
101
|
// Query the latest version + highlights via the user's own npm (honours their registry/mirror). Hard
|
|
75
102
|
// timeout; any non-zero exit, empty/garbled output, or thrown error → { latest:null, whatsNew:null }.
|
|
76
103
|
export function fetchLatest({ timeoutMs = 4000, run = spawnSync } = {}) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
try {
|
|
105
|
+
const r = run('npm', VIEW_ARGS, { timeout: timeoutMs, encoding: 'utf8' });
|
|
106
|
+
if (!r || r.status !== 0 || !r.stdout)
|
|
107
|
+
return { latest: null, whatsNew: null };
|
|
108
|
+
return parseView(r.stdout);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { latest: null, whatsNew: null };
|
|
112
|
+
}
|
|
82
113
|
}
|
|
83
|
-
|
|
84
114
|
// Merge a fetch result over the prior cache: on a failed field keep the previously-known value, so a flaky
|
|
85
115
|
// network never blanks out a good `latest`/`whatsNew`. Always stamps `checkedAt`.
|
|
86
116
|
function mergedCache(home, now, { latest, whatsNew }) {
|
|
87
|
-
|
|
88
|
-
|
|
117
|
+
const prev = readCache(home);
|
|
118
|
+
return { checkedAt: now, latest: latest ?? prev?.latest ?? null, whatsNew: whatsNew ?? prev?.whatsNew ?? null };
|
|
89
119
|
}
|
|
90
|
-
|
|
91
120
|
// Non-blocking refresh for the long-running server: query npm asynchronously (never stalls the event loop
|
|
92
121
|
// the way the CLI's spawnSync path would) and persist the same cache the CLI reads. The /api/version route
|
|
93
122
|
// calls this when the cache is stale, so the phone opening the app keeps `latest`/`whatsNew` current without
|
|
94
123
|
// the user re-running the CLI. Best-effort: npm missing/offline/blocked leaves the prior values.
|
|
95
|
-
export function refreshLatestAsync(home, { now = Date.now(), spawnFn = spawn, timeoutMs = 4000 } = {}) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
124
|
+
export function refreshLatestAsync(home, { now = Date.now(), spawnFn = spawn, timeoutMs = 4000, } = {}) {
|
|
125
|
+
try {
|
|
126
|
+
const child = spawnFn('npm', VIEW_ARGS, { timeout: timeoutMs });
|
|
127
|
+
let out = '';
|
|
128
|
+
child.stdout?.on('data', (data) => { out += String(data); });
|
|
129
|
+
child.on('close', (code) => {
|
|
130
|
+
writeCache(home, mergedCache(home, now, code === 0 ? parseView(out) : { latest: null, whatsNew: null }));
|
|
131
|
+
});
|
|
132
|
+
child.on('error', () => { });
|
|
133
|
+
}
|
|
134
|
+
catch { /* best effort */ }
|
|
105
135
|
}
|
|
106
|
-
|
|
107
136
|
// The hidden `__update-check` worker (runs detached, prints nothing): refresh the cache. On a failed fetch
|
|
108
137
|
// keep the previously-known values but still stamp checkedAt, so a flaky network doesn't re-spawn every run.
|
|
109
138
|
export function runUpdateCheck(home, { now = Date.now(), ...opts } = {}) {
|
|
110
|
-
|
|
139
|
+
writeCache(home, mergedCache(home, now, fetchLatest(opts)));
|
|
111
140
|
}
|
|
112
|
-
|
|
113
141
|
// Fire-and-forget notifier for a foreground command. Prints an upgrade line straight from the cache (no
|
|
114
142
|
// network on this path), then — if the cache is stale — kicks off a detached refresh so the NEXT run is
|
|
115
143
|
// current. Returns true if a notice was printed (handy for tests). `selfPath` is the CLI entry so the
|
|
116
144
|
// background worker re-invokes this same binary.
|
|
117
|
-
export function notifyUpdate(home, { version, selfPath, now = Date.now(), log = console.log, spawnFn = spawn } = {}) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
145
|
+
export function notifyUpdate(home, { version, selfPath, now = Date.now(), log = console.log, spawnFn = spawn, } = {}) {
|
|
146
|
+
const cache = readCache(home);
|
|
147
|
+
let shown = false;
|
|
148
|
+
if (cache && cache.latest && isNewer(cache.latest, version)) {
|
|
149
|
+
log('');
|
|
150
|
+
log(t('update.available', { current: version, latest: cache.latest }));
|
|
151
|
+
log(t(isBrewInstall(selfPath) ? 'update.howBrew' : 'update.how'));
|
|
152
|
+
shown = true;
|
|
153
|
+
}
|
|
154
|
+
if (selfPath && shouldRefresh(cache, now)) {
|
|
155
|
+
try {
|
|
156
|
+
const child = spawnFn(process.execPath, [selfPath, '__update-check'], { detached: true, stdio: 'ignore' });
|
|
157
|
+
child.unref?.();
|
|
158
|
+
}
|
|
159
|
+
catch { /* best effort */ }
|
|
160
|
+
}
|
|
161
|
+
return shown;
|
|
133
162
|
}
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
// host up front, so instead of scraping a random URL we gate readiness on the tunnel process echoing that
|
|
3
3
|
// exact host in its own log — and cpolar derives its -subdomain/-hostname flag from the same host. Pure.
|
|
4
4
|
export function hostOf(url) {
|
|
5
|
-
|
|
5
|
+
try {
|
|
6
|
+
return new URL(String(url)).host;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
6
11
|
}
|
|
7
|
-
|
|
8
12
|
// Has the process output mentioned this URL's host yet? (i.e. the named tunnel is up on its known domain.)
|
|
9
13
|
export function hostIn(text, url) {
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
const h = hostOf(url);
|
|
15
|
+
return !!h && String(text || '').includes(h);
|
|
12
16
|
}
|
|
@@ -0,0 +1,347 @@
|
|
|
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
|
+
const RESTORE_FLAGS = new Set(['list', 'dryRun', 'checkpoint', 'session', 'lang', 'config']);
|
|
13
|
+
const SAFE_CHECKPOINT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
14
|
+
const INVALID_TOPOLOGY_REASONS = new Set([
|
|
15
|
+
'invalid-session', 'invalid-session-id', 'invalid-session-name', 'invalid-active-window-id',
|
|
16
|
+
'invalid-window-links', 'missing-window-links', 'invalid-window-link', 'invalid-window-link-index',
|
|
17
|
+
'duplicate-window-link', 'duplicate-window-link-index', 'dangling-window-link', 'dangling-active-window',
|
|
18
|
+
'missing-window-panes', 'invalid-pane', 'duplicate-pane', 'dangling-active-pane',
|
|
19
|
+
]);
|
|
20
|
+
const recordOf = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
21
|
+
? value : null;
|
|
22
|
+
const arrayOfRecords = (value) => Array.isArray(value) ? value.flatMap((item) => {
|
|
23
|
+
const record = recordOf(item);
|
|
24
|
+
return record ? [record] : [];
|
|
25
|
+
}) : [];
|
|
26
|
+
const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
27
|
+
function line(stream, value = '') {
|
|
28
|
+
if (typeof stream === 'function')
|
|
29
|
+
stream(value);
|
|
30
|
+
else
|
|
31
|
+
stream.write(`${value}\n`);
|
|
32
|
+
}
|
|
33
|
+
function countAgents(checkpoint) {
|
|
34
|
+
return arrayOfRecords(checkpoint.windows)
|
|
35
|
+
.flatMap((window) => arrayOfRecords(window.panes))
|
|
36
|
+
.filter((pane) => Boolean(pane.agent)).length;
|
|
37
|
+
}
|
|
38
|
+
function checkpointCounts(checkpoint) {
|
|
39
|
+
const windows = arrayOfRecords(checkpoint.windows);
|
|
40
|
+
return {
|
|
41
|
+
sessions: Array.isArray(checkpoint.sessions) ? checkpoint.sessions.length : 0,
|
|
42
|
+
windows: windows.length,
|
|
43
|
+
panes: windows.reduce((sum, window) => sum + (Array.isArray(window.panes) ? window.panes.length : 0), 0),
|
|
44
|
+
agents: countAgents(checkpoint),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function unsupportedReason(reason) {
|
|
48
|
+
if (reason === 'linked-windows-unsupported')
|
|
49
|
+
return t('restore.reason.linkedWindows');
|
|
50
|
+
if (typeof reason === 'string' && INVALID_TOPOLOGY_REASONS.has(reason))
|
|
51
|
+
return t('restore.reason.invalidTopology');
|
|
52
|
+
return t('restore.reason.unknown', { reason: reason || 'unknown' });
|
|
53
|
+
}
|
|
54
|
+
function posixQuote(value) {
|
|
55
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
56
|
+
}
|
|
57
|
+
function restoreCommand(checkpoint, sessions = []) {
|
|
58
|
+
const selectedSessions = Array.isArray(sessions) ? sessions : [sessions];
|
|
59
|
+
return selectedSessions.reduce((command, session) => `${command} --session ${posixQuote(session)}`, `handmux restore --checkpoint ${posixQuote(checkpoint)}`);
|
|
60
|
+
}
|
|
61
|
+
function manualSessionCommand(session) {
|
|
62
|
+
return `tmux new-session -s ${posixQuote(session)}`;
|
|
63
|
+
}
|
|
64
|
+
function writeManualRecovery(stream, session) {
|
|
65
|
+
line(stream, t('restore.manualRecovery', { command: manualSessionCommand(session) }));
|
|
66
|
+
}
|
|
67
|
+
function normalizeFlags(flags) {
|
|
68
|
+
const unknown = Object.keys(flags).find((key) => !RESTORE_FLAGS.has(key));
|
|
69
|
+
if (unknown)
|
|
70
|
+
return { error: t('restore.badFlag', { flag: `--${unknown}` }) };
|
|
71
|
+
if (flags.list !== undefined && typeof flags.list !== 'boolean')
|
|
72
|
+
return { error: t('restore.flagBoolean', { flag: '--list' }) };
|
|
73
|
+
if (flags.dryRun !== undefined && typeof flags.dryRun !== 'boolean')
|
|
74
|
+
return { error: t('restore.flagBoolean', { flag: '--dry-run' }) };
|
|
75
|
+
if (flags.checkpoint !== undefined && (typeof flags.checkpoint !== 'string' || !flags.checkpoint.trim())) {
|
|
76
|
+
return { error: t('restore.checkpointValue') };
|
|
77
|
+
}
|
|
78
|
+
if (typeof flags.checkpoint === 'string'
|
|
79
|
+
&& (!SAFE_CHECKPOINT_ID.test(flags.checkpoint.trim()) || flags.checkpoint.trim() === '.' || flags.checkpoint.trim() === '..')) {
|
|
80
|
+
return { error: t('restore.checkpointId') };
|
|
81
|
+
}
|
|
82
|
+
const rawSessions = flags.session === undefined ? [] : (Array.isArray(flags.session) ? flags.session : [flags.session]);
|
|
83
|
+
if (rawSessions.some((name) => typeof name !== 'string' || !name.trim()))
|
|
84
|
+
return { error: t('restore.sessionValue') };
|
|
85
|
+
if (flags.list && (flags.dryRun || flags.checkpoint !== undefined || flags.session !== undefined)) {
|
|
86
|
+
return { error: t('restore.listExclusive') };
|
|
87
|
+
}
|
|
88
|
+
const checkpoint = typeof flags.checkpoint === 'string' ? flags.checkpoint.trim() : undefined;
|
|
89
|
+
return {
|
|
90
|
+
list: flags.list === true,
|
|
91
|
+
dryRun: flags.dryRun === true,
|
|
92
|
+
...(checkpoint ? { checkpoint } : {}),
|
|
93
|
+
sessions: rawSessions.map((name) => name.trim()),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function checkpointRows(value) {
|
|
97
|
+
if (!Array.isArray(value))
|
|
98
|
+
throw new Error('invalid checkpoint list');
|
|
99
|
+
return value.map((candidate) => {
|
|
100
|
+
const row = recordOf(candidate);
|
|
101
|
+
if (row?.status === 'ok' && typeof row.id === 'string' && row.id && recordOf(row.value)) {
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
if (typeof row?.status === 'string' && typeof row.id === 'string' && row.id
|
|
105
|
+
&& typeof row.error === 'string') {
|
|
106
|
+
return candidate;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
status: typeof row?.status === 'string' ? row.status : 'invalid',
|
|
110
|
+
id: typeof row?.id === 'string' && row.id ? row.id : '?',
|
|
111
|
+
error: typeof row?.error === 'string' ? row.error : 'invalid checkpoint record',
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function validRows(rows) {
|
|
116
|
+
return rows.filter((row) => 'value' in row);
|
|
117
|
+
}
|
|
118
|
+
function writeCheckpointRow(stdout, row) {
|
|
119
|
+
if (!('value' in row)) {
|
|
120
|
+
line(stdout, t('restore.listUnavailable', { id: row.id || '?', error: row.error || row.status || 'unknown' }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const counts = checkpointCounts(row.value);
|
|
124
|
+
line(stdout, t('restore.listRow', {
|
|
125
|
+
id: row.id,
|
|
126
|
+
time: row.value.archivedAt || row.value.capturedAt || '?',
|
|
127
|
+
...counts,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
async function defaultSelectCheckpoint(rows) {
|
|
131
|
+
const selected = await ask(select({
|
|
132
|
+
message: t('restore.selectCheckpoint'),
|
|
133
|
+
options: rows.map((row) => {
|
|
134
|
+
const counts = checkpointCounts(row.value);
|
|
135
|
+
return {
|
|
136
|
+
value: row.id,
|
|
137
|
+
label: row.id,
|
|
138
|
+
hint: t('restore.selectHint', { time: row.value.archivedAt || row.value.capturedAt || '?', ...counts }),
|
|
139
|
+
};
|
|
140
|
+
}),
|
|
141
|
+
initialValue: rows[0]?.id,
|
|
142
|
+
}));
|
|
143
|
+
if (typeof selected !== 'string')
|
|
144
|
+
throw new Error('invalid checkpoint selection');
|
|
145
|
+
return selected;
|
|
146
|
+
}
|
|
147
|
+
function writePlan(stdout, planInput, dryRun, continuationCommand) {
|
|
148
|
+
const plan = recordOf(planInput);
|
|
149
|
+
if (!plan || typeof plan.checkpointId !== 'string')
|
|
150
|
+
throw new Error('invalid restore plan');
|
|
151
|
+
const summary = recordOf(plan.summary);
|
|
152
|
+
const preExisting = recordOf(plan.preExistingRuntimeIds);
|
|
153
|
+
const planSessions = arrayOfRecords(plan.sessions);
|
|
154
|
+
line(stdout, t('restore.planCheckpoint', {
|
|
155
|
+
id: plan.checkpointId,
|
|
156
|
+
time: plan.capturedAt || plan.archivedAt || '?',
|
|
157
|
+
sessions: summary?.sessions ?? 0,
|
|
158
|
+
windows: summary?.windows ?? 0,
|
|
159
|
+
panes: summary?.panes ?? 0,
|
|
160
|
+
}));
|
|
161
|
+
line(stdout, t('restore.planCurrent', {
|
|
162
|
+
sessions: Array.isArray(preExisting?.sessions) ? preExisting.sessions.length : 0,
|
|
163
|
+
}));
|
|
164
|
+
line(stdout);
|
|
165
|
+
for (const item of planSessions) {
|
|
166
|
+
if (item.action === 'create')
|
|
167
|
+
line(stdout, t('restore.planCreate', { session: item.sourceName }));
|
|
168
|
+
else if (item.action === 'create-renamed')
|
|
169
|
+
line(stdout, t('restore.planRenamed', { session: item.sourceName, target: item.targetName }));
|
|
170
|
+
else if (item.action === 'already-present')
|
|
171
|
+
line(stdout, t('restore.planAlready', { session: item.sourceName }));
|
|
172
|
+
else {
|
|
173
|
+
const session = item.sourceName || item.logicalId || '?';
|
|
174
|
+
line(stdout, t('restore.planUnavailable', { session, reason: unsupportedReason(item.reason) }));
|
|
175
|
+
writeManualRecovery(stdout, session);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const warning of strings(plan.warnings))
|
|
179
|
+
line(stdout, t('restore.warning', { warning }));
|
|
180
|
+
line(stdout);
|
|
181
|
+
line(stdout, t('restore.nonDestructive'));
|
|
182
|
+
if (dryRun && !planSessions.some((item) => item.action === 'unsupported')) {
|
|
183
|
+
line(stdout, t('restore.dryRunHint', { command: continuationCommand }));
|
|
184
|
+
}
|
|
185
|
+
const unsupported = recordOf(plan.planSummary)?.unsupported;
|
|
186
|
+
return typeof unsupported === 'number' && Number.isFinite(unsupported) ? unsupported : 0;
|
|
187
|
+
}
|
|
188
|
+
function writeResult(stdout, stderr, checkpointId, resultInput) {
|
|
189
|
+
const result = recordOf(resultInput);
|
|
190
|
+
if (!result || typeof result.status !== 'string' || !Array.isArray(result.results)) {
|
|
191
|
+
throw new Error('invalid restore result');
|
|
192
|
+
}
|
|
193
|
+
const resultRows = arrayOfRecords(result.results);
|
|
194
|
+
for (const item of resultRows) {
|
|
195
|
+
const session = item.sourceName || item.logicalId || '?';
|
|
196
|
+
if (item.status === 'restored') {
|
|
197
|
+
line(stdout, item.targetName && item.targetName !== session
|
|
198
|
+
? t('restore.resultRenamed', { session, target: item.targetName })
|
|
199
|
+
: t('restore.resultRestored', { session }));
|
|
200
|
+
for (const warning of strings(item.warnings))
|
|
201
|
+
line(stdout, t('restore.sessionWarning', { session, warning }));
|
|
202
|
+
}
|
|
203
|
+
else if (item.status === 'already-present') {
|
|
204
|
+
line(stdout, t('restore.resultAlready', { session }));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const stage = item.stage || 'restore';
|
|
208
|
+
line(stderr, t('restore.sessionFailed', {
|
|
209
|
+
checkpoint: checkpointId,
|
|
210
|
+
session,
|
|
211
|
+
stage,
|
|
212
|
+
error: stage === 'plan' ? unsupportedReason(item.error) : (item.error || 'unknown error'),
|
|
213
|
+
}));
|
|
214
|
+
if (stage === 'plan')
|
|
215
|
+
writeManualRecovery(stderr, session);
|
|
216
|
+
else
|
|
217
|
+
line(stderr, t('restore.retrySession', { command: restoreCommand(checkpointId, session) }));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (result.status !== 'succeeded' && result.error && !resultRows.some((item) => item.status === 'failed')) {
|
|
221
|
+
line(stderr, t('restore.operationFailed', { checkpoint: checkpointId, stage: 'restore', error: result.error }));
|
|
222
|
+
line(stderr, t('restore.retry', { command: restoreCommand(checkpointId) }));
|
|
223
|
+
}
|
|
224
|
+
for (const warning of strings(result.warnings))
|
|
225
|
+
line(stdout, t('restore.warning', { warning }));
|
|
226
|
+
line(stdout);
|
|
227
|
+
line(stdout, t('restore.resultSummary', {
|
|
228
|
+
restored: result.restored ?? 0,
|
|
229
|
+
already: result.alreadyPresent ?? 0,
|
|
230
|
+
failed: result.failed ?? 0,
|
|
231
|
+
}));
|
|
232
|
+
line(stdout, t('restore.nonDestructivePast'));
|
|
233
|
+
return result.status;
|
|
234
|
+
}
|
|
235
|
+
export function createStandaloneWorkspaceRuntime(options = {}) {
|
|
236
|
+
const home = options.home ?? homedir();
|
|
237
|
+
const runTmux = options.runTmux ?? defaultRunTmux;
|
|
238
|
+
const stateFile = options.stateFile ?? claudeStatePath(home);
|
|
239
|
+
const readOnly = options.readOnly ?? false;
|
|
240
|
+
const createStore = (options.createStore ?? createWorkspaceStore);
|
|
241
|
+
const createTmux = (options.createTmux ?? createWorkspaceTmux);
|
|
242
|
+
const createLock = (options.createLock ?? createWorkspaceLock);
|
|
243
|
+
const createCheckpointer = (options.createCheckpointer ?? createWorkspaceBackground);
|
|
244
|
+
const createRuntime = (options.createRuntime ?? createWorkspaceRuntime);
|
|
245
|
+
const store = createStore({ home });
|
|
246
|
+
const tmux = createTmux({ run: runTmux, readOnly });
|
|
247
|
+
const lock = createLock({ dir: store.paths.lockDir });
|
|
248
|
+
const observe = options.observeEnvironment || createEnvironmentProvider({
|
|
249
|
+
tmuxServerIdProvider: async () => {
|
|
250
|
+
const observeTmux = recordOf(tmux)?.observeEnvironment;
|
|
251
|
+
if (typeof observeTmux !== 'function')
|
|
252
|
+
throw new Error('tmux environment observer unavailable');
|
|
253
|
+
const observedInput = await observeTmux.call(tmux);
|
|
254
|
+
const observed = recordOf(observedInput);
|
|
255
|
+
if (!observed)
|
|
256
|
+
throw new Error('tmux environment unavailable');
|
|
257
|
+
if (observed.status === 'unknown')
|
|
258
|
+
throw new Error('tmux environment unavailable');
|
|
259
|
+
return observed.tmuxServerId ?? null;
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const checkpointer = createCheckpointer({ store, tmux, observeEnvironment: observe, lock, stateFile });
|
|
263
|
+
return createRuntime({ store, tmux, lock, checkpointer, home });
|
|
264
|
+
}
|
|
265
|
+
export async function runWorkspaceCommand({ flags = {}, positionals = [], unknownShortFlags = [], home = homedir(), runtime, inputIsTTY = Boolean(process.stdin.isTTY), outputIsTTY = Boolean(process.stdout.isTTY), selectCheckpoint = defaultSelectCheckpoint, stdout = process.stdout, stderr = process.stderr, createRuntime = createStandaloneWorkspaceRuntime, } = {}) {
|
|
266
|
+
if (positionals.length > 0 || unknownShortFlags.length > 0) {
|
|
267
|
+
const error = positionals.length > 0
|
|
268
|
+
? t('restore.unexpectedArgument', { value: positionals[0] })
|
|
269
|
+
: t('restore.unknownShortFlag', { flag: unknownShortFlags[0] });
|
|
270
|
+
line(stderr, error);
|
|
271
|
+
line(stderr, t('restore.usage'));
|
|
272
|
+
return 2;
|
|
273
|
+
}
|
|
274
|
+
const parsed = normalizeFlags(flags);
|
|
275
|
+
if ('error' in parsed) {
|
|
276
|
+
line(stderr, parsed.error);
|
|
277
|
+
line(stderr, t('restore.usage'));
|
|
278
|
+
return 2;
|
|
279
|
+
}
|
|
280
|
+
const workspace = runtime || createRuntime({ home, readOnly: parsed.dryRun });
|
|
281
|
+
let resolvedCheckpoint = parsed.checkpoint || 'latest';
|
|
282
|
+
try {
|
|
283
|
+
if (parsed.list) {
|
|
284
|
+
const rows = checkpointRows(await workspace.listCheckpoints());
|
|
285
|
+
if (rows.length === 0)
|
|
286
|
+
line(stdout, t('restore.listEmpty'));
|
|
287
|
+
else
|
|
288
|
+
rows.forEach((row) => writeCheckpointRow(stdout, row));
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
let checkpointId;
|
|
292
|
+
let historical = false;
|
|
293
|
+
if (parsed.checkpoint && parsed.checkpoint !== 'latest') {
|
|
294
|
+
checkpointId = parsed.checkpoint;
|
|
295
|
+
historical = true;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const rows = validRows(checkpointRows(await workspace.listCheckpoints()));
|
|
299
|
+
if (rows.length === 0) {
|
|
300
|
+
line(stderr, t('restore.noCheckpoint'));
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
const firstRow = rows[0];
|
|
304
|
+
if (!firstRow)
|
|
305
|
+
return 1;
|
|
306
|
+
if (parsed.checkpoint === 'latest') {
|
|
307
|
+
checkpointId = firstRow.id;
|
|
308
|
+
historical = true;
|
|
309
|
+
}
|
|
310
|
+
else if (rows.length === 1) {
|
|
311
|
+
checkpointId = firstRow.id;
|
|
312
|
+
}
|
|
313
|
+
else if (inputIsTTY && outputIsTTY) {
|
|
314
|
+
checkpointId = await selectCheckpoint(rows);
|
|
315
|
+
historical = true;
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
checkpointId = firstRow.id;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (typeof checkpointId !== 'string' || !checkpointId) {
|
|
322
|
+
line(stderr, t('restore.selectionCancelled', { command: 'handmux restore' }));
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
resolvedCheckpoint = checkpointId;
|
|
326
|
+
const request = { checkpointId, sessions: parsed.sessions, historical: historical || parsed.dryRun };
|
|
327
|
+
if (parsed.dryRun) {
|
|
328
|
+
const restorePlan = await workspace.getRestorePlan(request);
|
|
329
|
+
const unsupported = writePlan(stdout, restorePlan, true, restoreCommand(checkpointId, parsed.sessions));
|
|
330
|
+
return unsupported > 0 ? 1 : 0;
|
|
331
|
+
}
|
|
332
|
+
const result = await workspace.restoreNow(request);
|
|
333
|
+
return writeResult(stdout, stderr, checkpointId, result) === 'succeeded' ? 0 : 1;
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (error === CANCELLED) {
|
|
337
|
+
line(stderr, t('restore.selectionCancelled', { command: 'handmux restore' }));
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
line(stderr, t('restore.error', {
|
|
341
|
+
checkpoint: resolvedCheckpoint,
|
|
342
|
+
error: error instanceof Error ? error.message : String(error),
|
|
343
|
+
}));
|
|
344
|
+
line(stderr, t('restore.retry', { command: restoreCommand(resolvedCheckpoint) }));
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
}
|