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
package/src/orphans.js
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
// Detect "orphan" coding-agent sessions: a `claude`/`codex`/… process running on this host that is NOT
|
|
2
|
-
// inside a tmux pane, so handmux can't see or steer it. We can't migrate a live process into tmux (reptyr
|
|
3
|
-
// needs Linux ptrace+/proc — out on macOS — and breaks on multithreaded Node + child processes), so instead
|
|
4
|
-
// we surface these in the Inbox and offer a "takeover": spawn the agent's `resume` command in a fresh tmux
|
|
5
|
-
// pane (the agent's own persistence continues the conversation), then optionally kill the original.
|
|
6
|
-
//
|
|
7
|
-
// Detection is process-based, NOT a scan of the agents' session history (which is unbounded and can't tell
|
|
8
|
-
// a live session from a dead one). Cost scales with the number of LIVE agent processes only.
|
|
9
|
-
//
|
|
10
|
-
// tmux membership is decided by TTY/PPID match against `tmux list-panes`, NOT by reading the process
|
|
11
|
-
// environment: on macOS `ps eww` can't read another process's env (SIP), so `$TMUX` is a false signal.
|
|
12
|
-
// A proc whose controlling tty is one of tmux's pane ttys (or whose parent is a pane's shell) is in tmux;
|
|
13
|
-
// anything else with a real tty is an orphan.
|
|
14
|
-
//
|
|
15
|
-
// This module is now the agent-AGNOSTIC engine: which processes count, where a cwd's session lives, and how
|
|
16
|
-
// to resume it all come from the driver descriptors in ./agents (parseAgentProcs tags each proc with its
|
|
17
|
-
// agent; scan/takeover dispatch through getAgent). The pure parse/file helpers live in ./agents/scanUtils.
|
|
18
|
-
import os from 'node:os';
|
|
19
|
-
import path from 'node:path';
|
|
20
|
-
import { AGENTS, getAgent } from './agents/index.js';
|
|
21
|
-
import { isSessionId } from './tmux/commands.js';
|
|
22
|
-
import { tmuxFormat } from './tmux/format.js';
|
|
23
|
-
import {
|
|
24
|
-
defaultRun, parseAgentProcs, parsePaneMembership, findOrphans,
|
|
25
|
-
takeoverSessionName, sanitizeSessionName, freeSessionName, isShell, lsofCwd, isSessionUuid,
|
|
26
|
-
} from './agents/scanUtils.js';
|
|
27
|
-
|
|
28
|
-
// Back-compat re-exports: these were originally defined here and are imported by tests and callers by this
|
|
29
|
-
// path. They're all agent-agnostic (or Claude's, kept as the default) and now live in scanUtils / claude.
|
|
30
|
-
export {
|
|
31
|
-
parsePaneMembership, findOrphans, encodeProjectDir, isSessionUuid,
|
|
32
|
-
lastUserSnippet, etimeToMs, takeoverSessionName,
|
|
33
|
-
resolveEncodedDirSession as resolveSession,
|
|
34
|
-
} from './agents/scanUtils.js';
|
|
35
|
-
export { projectsDir as defaultProjectsDir } from './agents/claude.js';
|
|
36
|
-
|
|
37
|
-
// Parse `ps …` to LIVE Claude CLI procs only — the original Claude-specific helper, kept for the tests.
|
|
38
|
-
// The general engine uses parseAgentProcs(psOut, AGENTS) directly.
|
|
39
|
-
export function parseClaudeProcs(psOut) {
|
|
40
|
-
return parseAgentProcs(psOut, [getAgent('claude')]);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Take over an orphan: spawn the agent's `resume <sessionId>` in a fresh tmux session (target.mode 'new')
|
|
44
|
-
// or a new window of an existing session ('window'), so handmux can steer the continued conversation.
|
|
45
|
-
// Everything is re-verified server-side — the client's pid/sessionId are inputs to a fresh scan, never
|
|
46
|
-
// trusted directly. The original process is SIGTERM'd only AFTER the resumed agent is confirmed up
|
|
47
|
-
// (foreground command is no longer the shell) AND re-confirmed still the same orphan (guards pid reuse):
|
|
48
|
-
// `<agent> resume` appends to the SAME session file with no OS lock (verified for Claude), so two live
|
|
49
|
-
// writers corrupt history — killing guarantees a single writer. Injectable deps make it unit-testable.
|
|
50
|
-
export async function takeoverOrphan(
|
|
51
|
-
{
|
|
52
|
-
commands, scanFn = scanOrphans, scanOpts = {},
|
|
53
|
-
killProc = (pid, sig) => process.kill(pid, sig),
|
|
54
|
-
delay = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
55
|
-
pollTries = 16, pollMs = 400,
|
|
56
|
-
},
|
|
57
|
-
{ pid, sessionId, target = { mode: 'new' }, kill = true, name: wantName } = {},
|
|
58
|
-
) {
|
|
59
|
-
if (!Number.isInteger(pid) || pid <= 0) return { error: 'bad pid', status: 400 };
|
|
60
|
-
// Both agents use UUID session ids; validate up front (takeover types the id into a shell via send-keys).
|
|
61
|
-
if (!isSessionUuid(sessionId)) return { error: 'bad session id', status: 400 };
|
|
62
|
-
|
|
63
|
-
const o = (await scanFn(scanOpts)).find((x) => x.pid === pid);
|
|
64
|
-
if (!o) return { error: 'gone', status: 409 }; // no longer a live orphan
|
|
65
|
-
if (o.sessionId !== sessionId) return { error: 'session changed', status: 409 };
|
|
66
|
-
if (!o.cwd) return { error: 'no cwd', status: 409 };
|
|
67
|
-
const agent = getAgent(o.agent);
|
|
68
|
-
const cmd = agent.sessions.managedResumeCmd
|
|
69
|
-
? agent.sessions.managedResumeCmd(sessionId)
|
|
70
|
-
: agent.sessions.resumeArgs(sessionId).join(' ');
|
|
71
|
-
|
|
72
|
-
let sid;
|
|
73
|
-
let wid;
|
|
74
|
-
let name; // the target session NAME — returned so the client can bind it into its session list
|
|
75
|
-
if (target.mode === 'window') {
|
|
76
|
-
if (!isSessionId(target.session)) return { error: 'bad target session', status: 400 };
|
|
77
|
-
sid = target.session;
|
|
78
|
-
name = (await commands.listSessions()).find((s) => s.id === sid)?.name || null;
|
|
79
|
-
wid = await commands.newWindow(sid, o.cwd, agent.procName, cmd);
|
|
80
|
-
} else {
|
|
81
|
-
const existing = new Set((await commands.listSessions()).map((s) => s.name));
|
|
82
|
-
// Honour a user-typed name (sanitized to tmux rules, deduped with a numeric suffix); fall back to the
|
|
83
|
-
// generated `<prefix>-<dir>-<n>` when the client sent nothing.
|
|
84
|
-
const wanted = sanitizeSessionName(wantName);
|
|
85
|
-
if (wanted) {
|
|
86
|
-
name = freeSessionName(wanted, existing);
|
|
87
|
-
} else {
|
|
88
|
-
for (let i = 1; i < 1000 && !name; i++) {
|
|
89
|
-
const cand = takeoverSessionName(o.cwdLabel, i, agent.takeoverPrefix);
|
|
90
|
-
if (!existing.has(cand)) name = cand;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (!name) return { error: 'no free session name', status: 409 };
|
|
94
|
-
sid = await commands.newSession(name, o.cwd, cmd);
|
|
95
|
-
wid = (await commands.listWindows(sid))[0]?.id;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
let up = false;
|
|
99
|
-
let pane = null;
|
|
100
|
-
for (let i = 0; i < pollTries && !up; i++) {
|
|
101
|
-
await delay(pollMs);
|
|
102
|
-
try {
|
|
103
|
-
const p = (await commands.listPanes(wid))[0];
|
|
104
|
-
if (p) { pane = p.id; if (!isShell(p.command)) up = true; }
|
|
105
|
-
} catch { /* window/pane not ready yet */ }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let killed = false;
|
|
109
|
-
if (kill && up) {
|
|
110
|
-
const still = (await scanFn(scanOpts)).find((x) => x.pid === pid && x.sessionId === sessionId);
|
|
111
|
-
if (still) { try { killProc(pid, 'SIGTERM'); killed = true; } catch { /* already exited */ } }
|
|
112
|
-
}
|
|
113
|
-
// claudeUp kept in the response for back-compat with the existing web client; agentUp is the neutral name.
|
|
114
|
-
return { session: sid, name, window: wid, pane, agentUp: up, claudeUp: up, killed, agent: agent.id };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Scan the host for orphan agent sessions across every registered driver. Best-effort: any failing
|
|
118
|
-
// sub-command degrades to fewer/no results rather than throwing. `projectsDir`/`sessionsDir` override a
|
|
119
|
-
// specific agent's session dir (each driver declares which option key it reads — used by tests and the
|
|
120
|
-
// server, which pin the dir off the resolved $HOME).
|
|
121
|
-
export async function scanOrphans({
|
|
122
|
-
run = defaultRun, home = os.homedir(), busyMs = 8000, now = Date.now, agents = AGENTS, ...dirOverrides
|
|
123
|
-
} = {}) {
|
|
124
|
-
const [psOut, tmuxOut] = await Promise.all([
|
|
125
|
-
run('ps', ['-Ao', 'pid=,ppid=,stat=,etime=,tty=,args=']),
|
|
126
|
-
run('tmux', ['list-panes', '-a', '-F', tmuxFormat(['pane_tty', 'pane_pid'])]),
|
|
127
|
-
]);
|
|
128
|
-
const orphans = findOrphans(parseAgentProcs(psOut, agents), parsePaneMembership(tmuxOut));
|
|
129
|
-
const results = [];
|
|
130
|
-
for (const o of orphans) {
|
|
131
|
-
const agent = getAgent(o.agent);
|
|
132
|
-
const cwd = await lsofCwd(run, o.pid);
|
|
133
|
-
const dir = dirOverrides[agent.sessions.dirOptKey] || agent.sessions.dir(home);
|
|
134
|
-
const meta = cwd ? await agent.sessions.resolve(dir, cwd, { busyMs, now }) : {};
|
|
135
|
-
const cwdLabel = cwd ? path.basename(cwd) : '';
|
|
136
|
-
results.push({
|
|
137
|
-
pid: o.pid,
|
|
138
|
-
agent: agent.id,
|
|
139
|
-
agentLabel: agent.label,
|
|
140
|
-
cwd: cwd || '',
|
|
141
|
-
cwdLabel,
|
|
142
|
-
// Default name the takeover sheet prefills (the same `<prefix>-<dir>-1` the server would auto-pick).
|
|
143
|
-
suggestedName: takeoverSessionName(cwdLabel, 1, agent.takeoverPrefix),
|
|
144
|
-
sessionId: meta.sessionId || null,
|
|
145
|
-
state: meta.state || 'unknown',
|
|
146
|
-
snippet: meta.snippet || '',
|
|
147
|
-
lastActivity: meta.lastActivity || 0,
|
|
148
|
-
startedAt: o.etimeMs ? Math.round(now() - o.etimeMs) : 0,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
return results;
|
|
152
|
-
}
|
package/src/pendingPrompt.js
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
// Parse a pending interactive PROMPT (an AskUserQuestion menu — single OR multi-question — or a tool-
|
|
2
|
-
// permission menu) out of a Claude Code pane's `capture-pane` text. This is deliberately screen-scraping: a
|
|
3
|
-
// pending prompt's options exist ONLY in the rendered TUI — they are NOT written to the session .jsonl until
|
|
4
|
-
// AFTER the user answers (verified live), so the transcript can't be the source. Same approach amux/
|
|
5
|
-
// VibeTunnel/Conductor use for the "attach to a running TUI" model; there is no structured channel short of
|
|
6
|
-
// relaunching Claude via the SDK.
|
|
7
|
-
//
|
|
8
|
-
// Verified against a live Claude Code (2026-07-17):
|
|
9
|
-
//
|
|
10
|
-
// SINGLE question / permission menu: MULTI-question (tabbed): REVIEW / submit screen:
|
|
11
|
-
// ☐ 颜色 ← header (optional) ← ☒ 水果 ☐ 颜色 ✔ Submit → ← ☒ 水果 ☒ 颜色 ✔ Submit →
|
|
12
|
-
// 你喜欢哪个? ← question 选个颜色? ← current tab Review your answers
|
|
13
|
-
// ❯ 1. 红色 ← ❯ marks the cursor ❯ 1. 红 ❯ 1. Submit answers
|
|
14
|
-
// 热情、醒目 ← description (optional) 2. 蓝 2. Cancel
|
|
15
|
-
// 2. 蓝色 Enter to select · Tab/… · Esc (NO footer line)
|
|
16
|
-
// 4. Type something. ← built-in meta (dropped)
|
|
17
|
-
// Enter to select · ↑/↓ to navigate · Esc to cancel
|
|
18
|
-
//
|
|
19
|
-
// Driving (verified live): sending the literal DIGIT of an option selects it. For a SINGLE question it
|
|
20
|
-
// selects AND submits immediately; for MULTI it selects AND auto-advances to the next tab; on the review
|
|
21
|
-
// screen digit 1 = "Submit answers". So the caller drives the whole flow by sending `String(option.n)` and
|
|
22
|
-
// re-polling — each screen (Q1 → Q2 → review) is itself a parseable menu. Escape cancels.
|
|
23
|
-
//
|
|
24
|
-
// The parser ANCHORS on the ❯ cursor line, NOT a footer: the review screen has options but no footer.
|
|
25
|
-
|
|
26
|
-
// Cursor-selected option line: ❯ (or ›»>) then "N.".
|
|
27
|
-
const CURSOR_RE = /^\s*[❯›»>]\s*\d+\.\s/;
|
|
28
|
-
// Any option row: optional cursor, then "N. label".
|
|
29
|
-
const OPTION_RE = /^\s*[❯›»>]?\s*(\d+)\.\s+(.+?)\s*$/;
|
|
30
|
-
// A description row: indented, not itself an option.
|
|
31
|
-
const isDesc = (l) => /^\s{3,}\S/.test(l) && !OPTION_RE.test(l);
|
|
32
|
-
// A horizontal rule Claude draws inside/around the card (kept within an option block so meta rows past it
|
|
33
|
-
// are still seen and dropped).
|
|
34
|
-
const RULE_RE = /^[\s─━—–\-_=·⎯]{8,}$/;
|
|
35
|
-
// The multi-question tab strip: "← ☒ 水果 ☐ 颜色 ✔ Submit →".
|
|
36
|
-
const TAB_BAR_RE = /✔\s*Submit|[☐☒].*[☐☒]/;
|
|
37
|
-
// Claude's built-in trailing meta-options — not real answers.
|
|
38
|
-
const META_LABELS = new Set(['type something', 'type something.', 'chat about this', 'chat about this.']);
|
|
39
|
-
// Leading decoration on the header line.
|
|
40
|
-
const HEADER_DECOR_RE = /^[\s☐☑☒◯●○·•\-*]+/;
|
|
41
|
-
// A menu footer (single-question / permission), used only as a title boundary — NOT required to detect a menu.
|
|
42
|
-
const FOOTER_RE = /enter to select|esc to (cancel|reject)/i;
|
|
43
|
-
// A Claude activity/spinner line — the top edge of the card, above which is prior transcript.
|
|
44
|
-
const ACTIVITY_RE = /^\s*[✻✳✶✽⏺]\s|worked for|cogitated|crafting|cooked for|crunched for|esc to interrupt/i;
|
|
45
|
-
// The input cursor ❯ leading the user's echoed prompt (above the card).
|
|
46
|
-
const PROMPT_ECHO_RE = /^\s*[❯›»>]\s*\D/; // ❯ NOT followed by a digit (that would be an option)
|
|
47
|
-
const isTitleBoundary = (l) =>
|
|
48
|
-
ACTIVITY_RE.test(l) || RULE_RE.test(l) || TAB_BAR_RE.test(l) || FOOTER_RE.test(l) || PROMPT_ECHO_RE.test(l);
|
|
49
|
-
|
|
50
|
-
const stripRight = (s) => String(s == null ? '' : s).replace(/\s+$/, '');
|
|
51
|
-
// Strip ANSI/OSC escapes so a capture taken WITH `-e` (SGR) still parses — belt-and-suspenders even though
|
|
52
|
-
// the endpoint captures plain.
|
|
53
|
-
const stripAnsi = (s) => String(s || '').replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '').replace(/\x1b[[0-9;?]*[ -/]*[@-~]/g, '');
|
|
54
|
-
|
|
55
|
-
// The assistant text immediately PRECEDING the menu, scraped because the turn (text + AskUserQuestion) is NOT
|
|
56
|
-
// flushed to the session jsonl until AFTER the user answers (verified live: probe + out-of-order line
|
|
57
|
-
// timestamps) — so the 对话 lens's transcript view structurally can't show it while the gate is up. The title
|
|
58
|
-
// walk below stops at the first boundary (activity spinner / box rule / prompt echo), which is exactly where
|
|
59
|
-
// the lead-in text usually sits. Walk on upward: skip boundary chrome, take the nearest content block, keep
|
|
60
|
-
// its LAST lines (the freshest context, "问题的最后一行"). `fromIdx` = the first line NOT consumed by the
|
|
61
|
-
// title walk, so adjacent lead-in the title already absorbed is never duplicated here.
|
|
62
|
-
const LEADIN_MAX = 2;
|
|
63
|
-
const MSG_MARK_RE = /^\s*⏺/; // ⏺ opens an assistant message in the scrollback — include it, stop there
|
|
64
|
-
function extractLeadIn(lines, fromIdx) {
|
|
65
|
-
const block = [];
|
|
66
|
-
let i = fromIdx;
|
|
67
|
-
// Skip the chrome between the menu and the preceding text (spinner / rules / tab bar / stale footer /
|
|
68
|
-
// blanks) — but never past a ❯ prompt echo (above it is the PREVIOUS exchange, stale context) nor past a
|
|
69
|
-
// ⏺ message head (it IS the text's first line — the collect loop includes it).
|
|
70
|
-
while (i >= 0 && (!lines[i].trim() || (isTitleBoundary(lines[i]) && !PROMPT_ECHO_RE.test(lines[i]) && !MSG_MARK_RE.test(lines[i])))) i--;
|
|
71
|
-
for (; i >= 0 && block.length < 12; i--) {
|
|
72
|
-
const l = lines[i];
|
|
73
|
-
if (!l.trim()) break; // blank = the block's top (last paragraph)
|
|
74
|
-
if (MSG_MARK_RE.test(l)) { block.unshift(l.trim()); break; } // the ⏺ message head: include & stop
|
|
75
|
-
if (isTitleBoundary(l)) break; // hard chrome (incl. ❯ echo): stop
|
|
76
|
-
block.unshift(l.trim());
|
|
77
|
-
}
|
|
78
|
-
const tail = block.slice(-LEADIN_MAX).map((l) => l.replace(/^⏺\s*/, '').trim()).filter(Boolean);
|
|
79
|
-
return tail.length ? tail.join(' ') : null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Parse the multi-question tab strip → [{ label, answered }]. ☒ = answered, ☐ = not. "✔ Submit" is excluded.
|
|
83
|
-
function parseTabs(line) {
|
|
84
|
-
const tabs = [];
|
|
85
|
-
const re = /([☐☒])\s*(\S+)/g;
|
|
86
|
-
let m;
|
|
87
|
-
while ((m = re.exec(line))) tabs.push({ label: m[2], answered: m[1] === '☒' });
|
|
88
|
-
return tabs;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// capture-pane text → a normalized pending prompt, or null when no interactive menu is on screen.
|
|
92
|
-
// { kind:'question'|'permission', title, options:[{n,label,description}], cursor,
|
|
93
|
-
// multi, step, total, submit }
|
|
94
|
-
// `n` is the digit to send to pick that option. For multi, `step`/`total` drive the "第 i/N 题" progress and
|
|
95
|
-
// `submit` marks the final review screen (options are Submit answers / Cancel).
|
|
96
|
-
export function parsePendingPrompt(text) {
|
|
97
|
-
const lines = stripAnsi(text).split('\n').map(stripRight);
|
|
98
|
-
|
|
99
|
-
// Anchor on the cursor-selected option (present in every menu: question, permission, review). Take the
|
|
100
|
-
// LAST one so stale menus higher in the scrollback are ignored — the live menu is at the bottom.
|
|
101
|
-
const anchor = lines.findLastIndex((l) => CURSOR_RE.test(l));
|
|
102
|
-
if (anchor < 0) return null;
|
|
103
|
-
|
|
104
|
-
// The option block = the contiguous run of option / description / rule lines around the anchor.
|
|
105
|
-
const inBlock = (l) => OPTION_RE.test(l) || isDesc(l) || RULE_RE.test(l);
|
|
106
|
-
let top = anchor;
|
|
107
|
-
let bot = anchor;
|
|
108
|
-
while (top - 1 >= 0 && inBlock(lines[top - 1])) top--;
|
|
109
|
-
while (bot + 1 < lines.length && inBlock(lines[bot + 1])) bot++;
|
|
110
|
-
|
|
111
|
-
const options = [];
|
|
112
|
-
let cursor = null;
|
|
113
|
-
for (let i = top; i <= bot; i++) {
|
|
114
|
-
const m = lines[i].match(OPTION_RE);
|
|
115
|
-
if (!m) continue;
|
|
116
|
-
const n = Number(m[1]);
|
|
117
|
-
const label = m[2].trim();
|
|
118
|
-
if (CURSOR_RE.test(lines[i])) cursor = n;
|
|
119
|
-
let description = '';
|
|
120
|
-
for (let j = i + 1; j <= bot; j++) {
|
|
121
|
-
if (OPTION_RE.test(lines[j])) break;
|
|
122
|
-
if (!isDesc(lines[j])) break;
|
|
123
|
-
description += (description ? ' ' : '') + lines[j].trim();
|
|
124
|
-
}
|
|
125
|
-
if (META_LABELS.has(label.toLowerCase())) continue; // drop Claude's built-in meta-options
|
|
126
|
-
options.push({ n, label, description });
|
|
127
|
-
}
|
|
128
|
-
if (!options.length) return null;
|
|
129
|
-
|
|
130
|
-
// Title = header/question text above the block, skipping in-card blanks, stopping at the card's top edge.
|
|
131
|
-
const head = [];
|
|
132
|
-
let headStop = top - 1;
|
|
133
|
-
for (let i = top - 1; i >= 0 && head.length < 5; i--) {
|
|
134
|
-
if (!lines[i].trim()) { headStop = i - 1; continue; }
|
|
135
|
-
if (isTitleBoundary(lines[i])) { headStop = i; break; }
|
|
136
|
-
head.unshift(lines[i]);
|
|
137
|
-
headStop = i - 1;
|
|
138
|
-
}
|
|
139
|
-
const title = head.map((l) => l.replace(HEADER_DECOR_RE, '').trim()).filter(Boolean).join(' — ') || '需要你选择';
|
|
140
|
-
|
|
141
|
-
// Multi-question: the tab strip drives progress. `answered` ☒ tabs → current step is the next unanswered.
|
|
142
|
-
const tabLine = lines.find((l) => TAB_BAR_RE.test(l));
|
|
143
|
-
const tabs = tabLine ? parseTabs(tabLine) : [];
|
|
144
|
-
const multi = tabs.length > 1;
|
|
145
|
-
const answered = tabs.filter((t) => t.answered).length;
|
|
146
|
-
const submit = /^submit answers?$/i.test(options[0].label) || (multi && answered >= tabs.length);
|
|
147
|
-
|
|
148
|
-
const kind = /do you want to proceed/i.test(title) || /^yes\b/i.test(options[0].label)
|
|
149
|
-
? 'permission' : 'question';
|
|
150
|
-
|
|
151
|
-
const out = { kind, title, options, cursor };
|
|
152
|
-
// Lead-in context the title walk didn't absorb (it stopped at a boundary or its 5-line cap) — the
|
|
153
|
-
// assistant's last line(s) before the question, shown above the gate so the phone isn't asked blind.
|
|
154
|
-
const leadIn = extractLeadIn(lines, headStop);
|
|
155
|
-
if (leadIn) out.leadIn = leadIn;
|
|
156
|
-
if (multi) {
|
|
157
|
-
out.multi = true;
|
|
158
|
-
out.total = tabs.length;
|
|
159
|
-
out.step = Math.min(answered + 1, tabs.length);
|
|
160
|
-
out.submit = submit;
|
|
161
|
-
}
|
|
162
|
-
return out;
|
|
163
|
-
}
|
package/src/previewServer.js
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// server/src/previewServer.js
|
|
2
|
-
// Serves registered static-preview directories under a per-registration capability path. The main
|
|
3
|
-
// Handmux token is never a preview credential. Absolute-rooted assets (/assets/...) are served from the
|
|
4
|
-
// right preview dir via an authenticated Referer fallback (design's "方案 A").
|
|
5
|
-
// Dynamic ports and arbitrary websites are handled by the built-in browser instead.
|
|
6
|
-
import express from 'express';
|
|
7
|
-
import { promises as fs } from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import { tokenEquals } from './auth.js';
|
|
10
|
-
function credentialOk(provided, token) {
|
|
11
|
-
if (!provided) return false;
|
|
12
|
-
try { return tokenEquals(provided, token); } catch { return false; }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
|
|
16
|
-
function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
|
|
17
|
-
|
|
18
|
-
function previewHeaders(res) {
|
|
19
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
20
|
-
res.setHeader('Referrer-Policy', 'same-origin');
|
|
21
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
22
|
-
// Keep preview code isolated even if its capability URL is opened outside the in-app iframe.
|
|
23
|
-
res.setHeader('Content-Security-Policy', 'sandbox allow-scripts allow-forms allow-downloads allow-modals allow-popups');
|
|
24
|
-
// A sandboxed static document intentionally has an opaque origin. Its capability path needs to remain
|
|
25
|
-
// readable by relative fetch(), module scripts, and fonts without restoring same-origin parent access.
|
|
26
|
-
res.setHeader('Access-Control-Allow-Origin', 'null');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function prefixedRoot(url, prefix) {
|
|
30
|
-
return url.startsWith('/') && !url.startsWith('//') ? `${prefix}${url.slice(1)}` : url;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function rewriteCssRoots(source, prefix) {
|
|
34
|
-
return source
|
|
35
|
-
.replace(/(url\(\s*["']?)\/(?!\/)/gi, `$1${prefix}`)
|
|
36
|
-
.replace(/(@import\s+["'])\/(?!\/)/gi, `$1${prefix}`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function rewriteModuleRoots(source, prefix) {
|
|
40
|
-
return source.replace(
|
|
41
|
-
/(\b(?:from|import)\s*(?:\(\s*)?)(["'])\/(?!\/)/g,
|
|
42
|
-
`$1$2${prefix}`,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function rewritePreviewText(source, extension, prefix) {
|
|
47
|
-
if (extension === '.css') return rewriteCssRoots(source, prefix);
|
|
48
|
-
if (extension === '.js' || extension === '.mjs') return rewriteModuleRoots(source, prefix);
|
|
49
|
-
if (extension !== '.html' && extension !== '.htm') return source;
|
|
50
|
-
let output = source.replace(
|
|
51
|
-
/(\b(?:src|href|action|poster|data|formaction)\s*=\s*)(["'])(\/[^"']*)\2/gi,
|
|
52
|
-
(_match, start, quote, url) => `${start}${quote}${prefixedRoot(url, prefix)}${quote}`,
|
|
53
|
-
);
|
|
54
|
-
output = output.replace(
|
|
55
|
-
/(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi,
|
|
56
|
-
(_match, start, quote, value) => {
|
|
57
|
-
const rewritten = value.split(',').map((candidate) => {
|
|
58
|
-
const match = /^(\s*)(\S+)(.*)$/.exec(candidate);
|
|
59
|
-
return match ? `${match[1]}${prefixedRoot(match[2], prefix)}${match[3]}` : candidate;
|
|
60
|
-
}).join(',');
|
|
61
|
-
return `${start}${quote}${rewritten}${quote}`;
|
|
62
|
-
},
|
|
63
|
-
);
|
|
64
|
-
output = output.replace(
|
|
65
|
-
/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi,
|
|
66
|
-
(_match, open, css, close) => `${open}${rewriteCssRoots(css, prefix)}${close}`,
|
|
67
|
-
);
|
|
68
|
-
output = output.replace(
|
|
69
|
-
/(\bstyle\s*=\s*)(["'])([^"']*)\2/gi,
|
|
70
|
-
(_match, start, quote, css) => `${start}${quote}${rewriteCssRoots(css, prefix)}${quote}`,
|
|
71
|
-
);
|
|
72
|
-
return output.replace(
|
|
73
|
-
/(<script\b[^>]*\btype\s*=\s*["']module["'][^>]*>)([\s\S]*?)(<\/script>)/gi,
|
|
74
|
-
(_match, open, script, close) => `${open}${rewriteModuleRoots(script, prefix)}${close}`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function createPreview({
|
|
79
|
-
previews,
|
|
80
|
-
}) {
|
|
81
|
-
const router = express.Router();
|
|
82
|
-
|
|
83
|
-
function resolveEntry(name, accessToken, res) {
|
|
84
|
-
const { state, entry } = previews.get(name);
|
|
85
|
-
if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
|
|
86
|
-
if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
|
|
87
|
-
if (!credentialOk(accessToken, entry.accessToken)) return res.status(401).send('unauthorized');
|
|
88
|
-
return entry;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function serve(entry, rest, prefix, res) {
|
|
92
|
-
previewHeaders(res);
|
|
93
|
-
const requested = fileFor(rest);
|
|
94
|
-
const segments = requested.split('/');
|
|
95
|
-
const target = path.resolve(entry.dir, requested);
|
|
96
|
-
const relative = path.relative(entry.dir, target);
|
|
97
|
-
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)
|
|
98
|
-
|| segments.some((segment) => segment.startsWith('.'))) {
|
|
99
|
-
if (!relative && requested === 'index.html') {
|
|
100
|
-
// The root index is the normal preview entry point.
|
|
101
|
-
} else {
|
|
102
|
-
res.status(404).end();
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
const extension = path.extname(target).toLowerCase();
|
|
107
|
-
if (['.html', '.htm', '.css', '.js', '.mjs'].includes(extension)) {
|
|
108
|
-
try {
|
|
109
|
-
const source = await fs.readFile(target, 'utf8');
|
|
110
|
-
res.type(extension).send(rewritePreviewText(source, extension, prefix));
|
|
111
|
-
} catch (error) {
|
|
112
|
-
if (!res.headersSent) res.status(error?.code === 'ENOENT' ? 404 : 500).end();
|
|
113
|
-
}
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
res.sendFile(requested, { root: entry.dir, dotfiles: 'deny' }, (err) => {
|
|
117
|
-
if (err && !res.headersSent) res.status(err.statusCode || 404).end();
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// A name without its independent capability is never accepted, including the old ?token=<app-token>
|
|
122
|
-
// shape. Keeping this explicit makes the failure an authentication result instead of an SPA fallback.
|
|
123
|
-
router.get('/:name', (_req, res) => res.status(401).send('unauthorized'));
|
|
124
|
-
router.get('/:name/:accessToken', (req, res) => {
|
|
125
|
-
const entry = resolveEntry(req.params.name, req.params.accessToken, res);
|
|
126
|
-
if (!entry || res.headersSent) return;
|
|
127
|
-
const prefix = `/preview/${encodeURIComponent(req.params.name)}/${encodeURIComponent(req.params.accessToken)}/`;
|
|
128
|
-
if (!req.url.split('?')[0].endsWith('/')) {
|
|
129
|
-
return res.redirect(301, prefix);
|
|
130
|
-
}
|
|
131
|
-
return void serve(entry, '', prefix, res);
|
|
132
|
-
});
|
|
133
|
-
router.get('/:name/:accessToken/*', (req, res) => {
|
|
134
|
-
const entry = resolveEntry(req.params.name, req.params.accessToken, res);
|
|
135
|
-
if (!entry || res.headersSent) return;
|
|
136
|
-
const prefix = `/preview/${encodeURIComponent(req.params.name)}/${encodeURIComponent(req.params.accessToken)}/`;
|
|
137
|
-
void serve(entry, req.params[0], prefix, res);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// Referer fallback (mount AFTER /preview, BEFORE express.static): an absolute /assets/... request
|
|
141
|
-
// whose Referer is a preview page is served from that preview's dir. Reuses credOk so it can never
|
|
142
|
-
// read a dir without a valid token/cookie. Misses fall through to the normal static/SPA layer.
|
|
143
|
-
function refererFallback(req, res, next) {
|
|
144
|
-
if (req.method !== 'GET') return next();
|
|
145
|
-
if (req.path.startsWith('/api') || req.path.startsWith('/preview')) return next();
|
|
146
|
-
const ref = req.headers.referer;
|
|
147
|
-
if (!ref) return next();
|
|
148
|
-
let refPath;
|
|
149
|
-
try { refPath = new URL(ref).pathname; } catch { return next(); }
|
|
150
|
-
const m = /^\/preview\/([^/]+)\/([^/]+)\//.exec(refPath);
|
|
151
|
-
if (!m) return next();
|
|
152
|
-
let name;
|
|
153
|
-
let accessToken;
|
|
154
|
-
try {
|
|
155
|
-
name = decodeURIComponent(m[1]);
|
|
156
|
-
accessToken = decodeURIComponent(m[2]);
|
|
157
|
-
} catch { return next(); }
|
|
158
|
-
const { state, entry } = previews.get(name);
|
|
159
|
-
if (state !== 'active') return next();
|
|
160
|
-
if (!credentialOk(accessToken, entry.accessToken)) return next();
|
|
161
|
-
previewHeaders(res);
|
|
162
|
-
res.sendFile(req.path, { root: entry.dir, dotfiles: 'deny' }, (err) => {
|
|
163
|
-
if (err && !res.headersSent) next();
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return { router, refererFallback };
|
|
168
|
-
}
|
package/src/previews.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
// server/src/previews.js
|
|
2
|
-
// Preview registry. Maps a safe single-segment name to an on-disk directory under $HOME with a lease.
|
|
3
|
-
// in-memory registry (loaded once at construction, flushed atomically on each mutation) — the previous
|
|
4
|
-
// reload-and-write-back on every op was an unguarded read-modify-write that could lose an entry when a
|
|
5
|
-
// GET's lease update raced a concurrent register(). Pure-ish: home/now/store/ttl are injected for tests.
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import { homedir } from 'node:os';
|
|
9
|
-
import { randomBytes } from 'node:crypto';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { isUnder } from './docPath.js';
|
|
12
|
-
import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
|
|
13
|
-
|
|
14
|
-
export function safePreviewName(raw) {
|
|
15
|
-
if (typeof raw !== 'string') return null;
|
|
16
|
-
if (!/^[A-Za-z0-9._-]+$/.test(raw)) return null;
|
|
17
|
-
if (raw === '.' || raw === '..' || raw[0] === '.') return null;
|
|
18
|
-
// Keep lookups stable across clients that may normalize user-provided names differently.
|
|
19
|
-
return raw.toLowerCase();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function createPreviews({
|
|
23
|
-
home = homedir(),
|
|
24
|
-
store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'),
|
|
25
|
-
now = () => Date.now(),
|
|
26
|
-
ttlMs = 2 * 60 * 60_000,
|
|
27
|
-
randomToken = () => randomBytes(24).toString('base64url'),
|
|
28
|
-
} = {}) {
|
|
29
|
-
let realHome;
|
|
30
|
-
try { realHome = fs.realpathSync(home); } catch { realHome = home; }
|
|
31
|
-
|
|
32
|
-
// Loaded ONCE — this in-memory array is the source of truth; every op mutates it and flushes atomically.
|
|
33
|
-
let entries = readJsonArray(store);
|
|
34
|
-
let flushedExpiries = new Map(entries.map((entry) => [entry?.name, entry?.expiresAt]));
|
|
35
|
-
const flush = () => {
|
|
36
|
-
// Access tokens are runtime capabilities. Open device tabs re-register after a restart and receive
|
|
37
|
-
// a fresh token, so a stale registry file can never resurrect an old preview URL.
|
|
38
|
-
writeJsonAtomic(store, entries.map(({ accessToken: _accessToken, ...entry }) => entry));
|
|
39
|
-
flushedExpiries = new Map(entries.map((entry) => [entry?.name, entry?.expiresAt]));
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const resultFor = (entry) => ({
|
|
43
|
-
name: entry.name,
|
|
44
|
-
kind: entry.kind,
|
|
45
|
-
accessToken: entry.accessToken,
|
|
46
|
-
expiresAt: entry.expiresAt,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
// Re-registering the same active directory is a lease renewal. Preserve its capability so a
|
|
50
|
-
// foreground check does not change the iframe URL and reload an already-mounted page. A changed
|
|
51
|
-
// directory, expired row, or process-restored row without a runtime token receives a fresh one.
|
|
52
|
-
const upsert = (fields) => {
|
|
53
|
-
const ts = now();
|
|
54
|
-
const current = entries.find((entry) => entry && entry.name === fields.name);
|
|
55
|
-
if (current?.kind === fields.kind
|
|
56
|
-
&& current.dir === fields.dir
|
|
57
|
-
&& current.expiresAt > ts
|
|
58
|
-
&& typeof current.accessToken === 'string'
|
|
59
|
-
&& current.accessToken) {
|
|
60
|
-
current.expiresAt = ts + ttlMs;
|
|
61
|
-
flush();
|
|
62
|
-
return resultFor(current);
|
|
63
|
-
}
|
|
64
|
-
entries = entries.filter((e) => e && e.name !== fields.name);
|
|
65
|
-
const entry = {
|
|
66
|
-
...fields,
|
|
67
|
-
accessToken: randomToken(),
|
|
68
|
-
createdAt: ts,
|
|
69
|
-
expiresAt: ts + ttlMs,
|
|
70
|
-
};
|
|
71
|
-
entries.push(entry);
|
|
72
|
-
flush();
|
|
73
|
-
return resultFor(entry);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
async function register({ name, dir, port }) {
|
|
77
|
-
const nm = safePreviewName(name);
|
|
78
|
-
if (!nm) return { error: 'bad name', status: 400 };
|
|
79
|
-
if (port !== undefined && port !== null && port !== '') return { error: 'bad request', status: 400 };
|
|
80
|
-
if (typeof dir !== 'string' || dir[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
81
|
-
let real;
|
|
82
|
-
try { real = fs.realpathSync(dir); } catch { return { error: 'not found', status: 404 }; }
|
|
83
|
-
if (!isUnder(real, realHome)) return { error: 'outside home', status: 400 };
|
|
84
|
-
let st;
|
|
85
|
-
try { st = fs.statSync(real); } catch { return { error: 'not accessible', status: 404 }; }
|
|
86
|
-
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
87
|
-
return upsert({ name: nm, kind: 'static', dir: real });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function get(name) {
|
|
91
|
-
const entry = entries.find((e) => e && e.name === name);
|
|
92
|
-
if (!entry) return { state: 'missing' };
|
|
93
|
-
const ts = now();
|
|
94
|
-
if (entry.expiresAt <= ts) { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'expired' }; }
|
|
95
|
-
if (entry.kind === 'dynamic') { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'missing' }; }
|
|
96
|
-
// Match proxy leases: actual page/resource traffic renews the lease. Throttle persistence so a page
|
|
97
|
-
// with many assets does not rewrite the registry once per request.
|
|
98
|
-
const nextExpiry = ts + ttlMs;
|
|
99
|
-
entry.expiresAt = nextExpiry;
|
|
100
|
-
if (nextExpiry - (flushedExpiries.get(entry.name) || 0) >= 60_000) flush();
|
|
101
|
-
return { state: 'active', entry: { kind: 'static', ...entry } }; // legacy rows (no kind) → static
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function list() {
|
|
105
|
-
const active = entries.filter((e) => e && e.kind !== 'dynamic' && e.expiresAt > now());
|
|
106
|
-
if (active.length !== entries.length) { entries = active; flush(); }
|
|
107
|
-
return active.map((e) => ({ name: e.name, kind: 'static', dir: e.dir, expiresAt: e.expiresAt }));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function remove(name) {
|
|
111
|
-
const next = entries.filter((e) => e && e.name !== name);
|
|
112
|
-
if (next.length !== entries.length) { entries = next; flush(); }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return { register, get, list, remove };
|
|
116
|
-
}
|