gitspace 0.2.0-rc.1
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/.claude/settings.local.json +21 -0
- package/.gitspace/bundle.json +50 -0
- package/.gitspace/select/01-status.sh +40 -0
- package/.gitspace/setup/01-install-deps.sh +12 -0
- package/.gitspace/setup/02-typecheck.sh +16 -0
- package/AGENTS.md +439 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +25 -0
- package/README.md +607 -0
- package/bin/gssh +62 -0
- package/bun.lock +647 -0
- package/docs/CONNECTION.md +623 -0
- package/docs/GATEWAY-WORKER.md +319 -0
- package/docs/GETTING-STARTED.md +448 -0
- package/docs/GITSPACE-PLATFORM.md +1819 -0
- package/docs/INFRASTRUCTURE.md +1347 -0
- package/docs/PROTOCOL.md +619 -0
- package/docs/QUICKSTART.md +174 -0
- package/docs/RELAY.md +327 -0
- package/docs/REMOTE-DESIGN.md +549 -0
- package/docs/ROADMAP.md +564 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
- package/docs/STACK-DESIGN.md +588 -0
- package/docs/UNIFIED_ARCHITECTURE.md +292 -0
- package/experiments/pty-benchmark.ts +148 -0
- package/experiments/pty-latency.ts +100 -0
- package/experiments/router/client.ts +199 -0
- package/experiments/router/protocol.ts +74 -0
- package/experiments/router/router.ts +217 -0
- package/experiments/router/session.ts +180 -0
- package/experiments/router/test.ts +133 -0
- package/experiments/socket-bandwidth.ts +77 -0
- package/homebrew/gitspace.rb +45 -0
- package/landing-page/ATTRIBUTIONS.md +3 -0
- package/landing-page/README.md +11 -0
- package/landing-page/bun.lock +801 -0
- package/landing-page/guidelines/Guidelines.md +61 -0
- package/landing-page/index.html +37 -0
- package/landing-page/package.json +90 -0
- package/landing-page/postcss.config.mjs +15 -0
- package/landing-page/public/_redirects +1 -0
- package/landing-page/public/favicon.png +0 -0
- package/landing-page/src/app/App.tsx +53 -0
- package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
- package/landing-page/src/app/components/ui/accordion.tsx +66 -0
- package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
- package/landing-page/src/app/components/ui/alert.tsx +66 -0
- package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
- package/landing-page/src/app/components/ui/avatar.tsx +53 -0
- package/landing-page/src/app/components/ui/badge.tsx +46 -0
- package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
- package/landing-page/src/app/components/ui/button.tsx +57 -0
- package/landing-page/src/app/components/ui/calendar.tsx +75 -0
- package/landing-page/src/app/components/ui/card.tsx +92 -0
- package/landing-page/src/app/components/ui/carousel.tsx +241 -0
- package/landing-page/src/app/components/ui/chart.tsx +353 -0
- package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
- package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
- package/landing-page/src/app/components/ui/command.tsx +177 -0
- package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
- package/landing-page/src/app/components/ui/dialog.tsx +135 -0
- package/landing-page/src/app/components/ui/drawer.tsx +132 -0
- package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/landing-page/src/app/components/ui/form.tsx +168 -0
- package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
- package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
- package/landing-page/src/app/components/ui/input.tsx +21 -0
- package/landing-page/src/app/components/ui/label.tsx +24 -0
- package/landing-page/src/app/components/ui/menubar.tsx +276 -0
- package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
- package/landing-page/src/app/components/ui/pagination.tsx +127 -0
- package/landing-page/src/app/components/ui/popover.tsx +48 -0
- package/landing-page/src/app/components/ui/progress.tsx +31 -0
- package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
- package/landing-page/src/app/components/ui/resizable.tsx +56 -0
- package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
- package/landing-page/src/app/components/ui/select.tsx +189 -0
- package/landing-page/src/app/components/ui/separator.tsx +28 -0
- package/landing-page/src/app/components/ui/sheet.tsx +139 -0
- package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
- package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
- package/landing-page/src/app/components/ui/slider.tsx +63 -0
- package/landing-page/src/app/components/ui/sonner.tsx +25 -0
- package/landing-page/src/app/components/ui/switch.tsx +31 -0
- package/landing-page/src/app/components/ui/table.tsx +116 -0
- package/landing-page/src/app/components/ui/tabs.tsx +66 -0
- package/landing-page/src/app/components/ui/textarea.tsx +18 -0
- package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
- package/landing-page/src/app/components/ui/toggle.tsx +47 -0
- package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
- package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
- package/landing-page/src/app/components/ui/utils.ts +6 -0
- package/landing-page/src/components/docs/DocsContent.tsx +718 -0
- package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
- package/landing-page/src/components/landing/CTA.tsx +59 -0
- package/landing-page/src/components/landing/Comparison.tsx +84 -0
- package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
- package/landing-page/src/components/landing/Features.tsx +201 -0
- package/landing-page/src/components/landing/Hero.tsx +142 -0
- package/landing-page/src/components/landing/Pricing.tsx +140 -0
- package/landing-page/src/components/landing/Roadmap.tsx +86 -0
- package/landing-page/src/components/landing/Security.tsx +81 -0
- package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
- package/landing-page/src/components/landing/UseCases.tsx +55 -0
- package/landing-page/src/components/landing/Workflow.tsx +101 -0
- package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
- package/landing-page/src/components/layout/Footer.tsx +55 -0
- package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
- package/landing-page/src/components/ui/badge.tsx +39 -0
- package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
- package/landing-page/src/components/ui/button.tsx +57 -0
- package/landing-page/src/components/ui/card.tsx +79 -0
- package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
- package/landing-page/src/components/ui/separator.tsx +28 -0
- package/landing-page/src/lib/utils.ts +6 -0
- package/landing-page/src/main.tsx +10 -0
- package/landing-page/src/pages/Dashboard.tsx +133 -0
- package/landing-page/src/pages/DocsPage.tsx +79 -0
- package/landing-page/src/pages/LandingPage.tsx +31 -0
- package/landing-page/src/pages/TerminalView.tsx +106 -0
- package/landing-page/src/styles/fonts.css +0 -0
- package/landing-page/src/styles/index.css +3 -0
- package/landing-page/src/styles/tailwind.css +4 -0
- package/landing-page/src/styles/theme.css +181 -0
- package/landing-page/vite.config.ts +19 -0
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +20 -0
- package/package.json +74 -0
- package/scripts/build.ts +284 -0
- package/scripts/release.ts +140 -0
- package/src/__tests__/test-utils.ts +298 -0
- package/src/commands/__tests__/serve-messages.test.ts +190 -0
- package/src/commands/access.ts +298 -0
- package/src/commands/add.ts +452 -0
- package/src/commands/auth.ts +364 -0
- package/src/commands/connect.ts +287 -0
- package/src/commands/directory.ts +16 -0
- package/src/commands/host.ts +396 -0
- package/src/commands/identity.ts +184 -0
- package/src/commands/list.ts +200 -0
- package/src/commands/relay.ts +315 -0
- package/src/commands/remove.ts +241 -0
- package/src/commands/serve.ts +1493 -0
- package/src/commands/share.ts +456 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/switch.ts +353 -0
- package/src/commands/tmux.ts +317 -0
- package/src/core/__tests__/access.test.ts +240 -0
- package/src/core/access.ts +277 -0
- package/src/core/bundle.ts +342 -0
- package/src/core/config.ts +510 -0
- package/src/core/git.ts +317 -0
- package/src/core/github.ts +151 -0
- package/src/core/identity.ts +631 -0
- package/src/core/linear.ts +225 -0
- package/src/core/shell.ts +161 -0
- package/src/core/trusted-relays.ts +315 -0
- package/src/index.ts +821 -0
- package/src/lib/remote-session/index.ts +7 -0
- package/src/lib/remote-session/protocol.ts +267 -0
- package/src/lib/remote-session/session-handler.ts +581 -0
- package/src/lib/remote-session/workspace-scanner.ts +167 -0
- package/src/lib/tmux-lite/README.md +81 -0
- package/src/lib/tmux-lite/cli.ts +796 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
- package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
- package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
- package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
- package/src/lib/tmux-lite/crypto/frames.ts +141 -0
- package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
- package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
- package/src/lib/tmux-lite/crypto/identity.ts +286 -0
- package/src/lib/tmux-lite/crypto/index.ts +51 -0
- package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
- package/src/lib/tmux-lite/crypto/invites.ts +215 -0
- package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
- package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
- package/src/lib/tmux-lite/crypto/keys.ts +47 -0
- package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
- package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
- package/src/lib/tmux-lite/handshake-handler.ts +451 -0
- package/src/lib/tmux-lite/protocol.test.ts +307 -0
- package/src/lib/tmux-lite/protocol.ts +266 -0
- package/src/lib/tmux-lite/relay-client.ts +506 -0
- package/src/lib/tmux-lite/server.ts +1250 -0
- package/src/lib/tmux-lite/shell-integration.sh +37 -0
- package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
- package/src/lib/tmux-lite/terminal-queries.ts +49 -0
- package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
- package/src/relay/__tests__/helpers/auth.ts +354 -0
- package/src/relay/__tests__/helpers/ports.ts +51 -0
- package/src/relay/__tests__/protocol-validation.test.ts +265 -0
- package/src/relay/authorization.ts +303 -0
- package/src/relay/embedded-assets.generated.d.ts +15 -0
- package/src/relay/identity.ts +352 -0
- package/src/relay/index.ts +57 -0
- package/src/relay/pipes.test.ts +427 -0
- package/src/relay/pipes.ts +195 -0
- package/src/relay/protocol.ts +804 -0
- package/src/relay/registries.test.ts +437 -0
- package/src/relay/registries.ts +593 -0
- package/src/relay/server.test.ts +1323 -0
- package/src/relay/server.ts +1092 -0
- package/src/relay/signing.ts +238 -0
- package/src/relay/types.ts +69 -0
- package/src/serve/client-session-manager.ts +622 -0
- package/src/serve/daemon.ts +497 -0
- package/src/serve/pty-session.ts +236 -0
- package/src/serve/types.ts +169 -0
- package/src/shared/components/Flow.tsx +453 -0
- package/src/shared/components/Flow.tui.tsx +343 -0
- package/src/shared/components/Flow.web.tsx +442 -0
- package/src/shared/components/Inbox.tsx +446 -0
- package/src/shared/components/Inbox.tui.tsx +262 -0
- package/src/shared/components/Inbox.web.tsx +329 -0
- package/src/shared/components/MachineList.tsx +187 -0
- package/src/shared/components/MachineList.tui.tsx +161 -0
- package/src/shared/components/MachineList.web.tsx +210 -0
- package/src/shared/components/ProjectList.tsx +176 -0
- package/src/shared/components/ProjectList.tui.tsx +109 -0
- package/src/shared/components/ProjectList.web.tsx +143 -0
- package/src/shared/components/SpacesBrowser.tsx +332 -0
- package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
- package/src/shared/components/SpacesBrowser.web.tsx +221 -0
- package/src/shared/components/index.ts +103 -0
- package/src/shared/hooks/index.ts +16 -0
- package/src/shared/hooks/useNavigation.ts +226 -0
- package/src/shared/index.ts +122 -0
- package/src/shared/providers/LocalMachineProvider.ts +425 -0
- package/src/shared/providers/MachineProvider.ts +165 -0
- package/src/shared/providers/RemoteMachineProvider.ts +444 -0
- package/src/shared/providers/index.ts +26 -0
- package/src/shared/types.ts +145 -0
- package/src/tui/adapters.ts +120 -0
- package/src/tui/app.tsx +1816 -0
- package/src/tui/components/Terminal.tsx +580 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useAppState.ts +314 -0
- package/src/tui/hooks/useDaemonStatus.ts +174 -0
- package/src/tui/hooks/useInboxTUI.ts +113 -0
- package/src/tui/hooks/useRemoteMachines.ts +209 -0
- package/src/tui/index.ts +24 -0
- package/src/tui/state.ts +299 -0
- package/src/tui/terminal-bracketed-paste.test.ts +45 -0
- package/src/tui/terminal-bracketed-paste.ts +47 -0
- package/src/types/bundle.ts +112 -0
- package/src/types/config.ts +89 -0
- package/src/types/errors.ts +206 -0
- package/src/types/identity.ts +284 -0
- package/src/types/workspace-fuzzy.ts +49 -0
- package/src/types/workspace.ts +151 -0
- package/src/utils/bun-socket-writer.ts +80 -0
- package/src/utils/deps.ts +127 -0
- package/src/utils/fuzzy-match.ts +125 -0
- package/src/utils/logger.ts +127 -0
- package/src/utils/markdown.ts +254 -0
- package/src/utils/onboarding.ts +229 -0
- package/src/utils/prompts.ts +114 -0
- package/src/utils/run-commands.ts +112 -0
- package/src/utils/run-scripts.ts +142 -0
- package/src/utils/sanitize.ts +98 -0
- package/src/utils/secrets.ts +122 -0
- package/src/utils/shell-escape.ts +40 -0
- package/src/utils/utf8.ts +79 -0
- package/src/utils/workspace-state.ts +47 -0
- package/src/web/README.md +73 -0
- package/src/web/bun.lock +575 -0
- package/src/web/eslint.config.js +23 -0
- package/src/web/index.html +16 -0
- package/src/web/package.json +37 -0
- package/src/web/public/vite.svg +1 -0
- package/src/web/src/App.tsx +604 -0
- package/src/web/src/assets/react.svg +1 -0
- package/src/web/src/components/Terminal.tsx +207 -0
- package/src/web/src/hooks/useRelayConnection.ts +224 -0
- package/src/web/src/hooks/useTerminal.ts +699 -0
- package/src/web/src/index.css +55 -0
- package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
- package/src/web/src/lib/crypto/frames.ts +205 -0
- package/src/web/src/lib/crypto/handshake.ts +396 -0
- package/src/web/src/lib/crypto/identity.ts +128 -0
- package/src/web/src/lib/crypto/keyexchange.ts +246 -0
- package/src/web/src/lib/crypto/relay-signing.ts +53 -0
- package/src/web/src/lib/invite.ts +58 -0
- package/src/web/src/lib/storage/identity-store.ts +94 -0
- package/src/web/src/main.tsx +10 -0
- package/src/web/src/types/identity.ts +45 -0
- package/src/web/tsconfig.app.json +28 -0
- package/src/web/tsconfig.json +7 -0
- package/src/web/tsconfig.node.json +26 -0
- package/src/web/vite.config.ts +31 -0
- package/todo-security.md +92 -0
- package/tsconfig.json +23 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
- package/worker/bun.lock +237 -0
- package/worker/package.json +22 -0
- package/worker/schema.sql +96 -0
- package/worker/src/handlers/auth.ts +451 -0
- package/worker/src/handlers/subdomains.ts +376 -0
- package/worker/src/handlers/user.ts +98 -0
- package/worker/src/index.ts +70 -0
- package/worker/src/middleware/auth.ts +152 -0
- package/worker/src/services/cloudflare.ts +609 -0
- package/worker/src/types.ts +96 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @ts-nocheck - Uses Bun-specific APIs (Bun.Terminal, etc.)
|
|
3
|
+
/**
|
|
4
|
+
* tmux-lite server - manages all sessions in a single process
|
|
5
|
+
* Uses xterm-headless for proper terminal state tracking
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
|
|
9
|
+
import { dirname } from "path";
|
|
10
|
+
import { Terminal as XTerminal } from "@xterm/headless";
|
|
11
|
+
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
12
|
+
import { createBufferedSocketWriter } from "../../utils/bun-socket-writer";
|
|
13
|
+
import { installDsrCprResponder } from "./terminal-queries";
|
|
14
|
+
import {
|
|
15
|
+
getRouterSocket,
|
|
16
|
+
getSessionSocketPath,
|
|
17
|
+
getPidFile,
|
|
18
|
+
PROTOCOL_VERSION,
|
|
19
|
+
PACKAGE_VERSION,
|
|
20
|
+
type Command,
|
|
21
|
+
type Response,
|
|
22
|
+
type Session,
|
|
23
|
+
type SessionCtrl,
|
|
24
|
+
type InboxItem,
|
|
25
|
+
encodeRouterMessage,
|
|
26
|
+
decodeRouterMessages,
|
|
27
|
+
encodePTY,
|
|
28
|
+
encodeControl,
|
|
29
|
+
parseFrames,
|
|
30
|
+
decodeControl,
|
|
31
|
+
FrameType,
|
|
32
|
+
MAX_FRAME_SIZE,
|
|
33
|
+
} from "./protocol";
|
|
34
|
+
|
|
35
|
+
// Chunk size for large PTY data (leave room for frame header overhead)
|
|
36
|
+
// Using 512KB to be well under the 1MB limit
|
|
37
|
+
const PTY_CHUNK_SIZE = 512 * 1024;
|
|
38
|
+
|
|
39
|
+
// Max scrollback lines to include in serialized state during attach
|
|
40
|
+
// This is a limit - if less scrollback exists, we'll send what's available
|
|
41
|
+
const SERIALIZE_SCROLLBACK_LINES = 1_000;
|
|
42
|
+
|
|
43
|
+
const rawArgs = process.argv.slice(2);
|
|
44
|
+
if (rawArgs.includes("--test")) {
|
|
45
|
+
process.env.TMUX_LITE_SOCKET = "/tmp/tmux-lite-test.sock";
|
|
46
|
+
process.env.TMUX_LITE_SESSION_DIR = "/tmp/tmux-lite-test";
|
|
47
|
+
process.env.TMUX_LITE_PID_FILE = "/tmp/tmux-lite-test.pid";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ROUTER_SOCKET = getRouterSocket();
|
|
51
|
+
const PID_FILE = getPidFile();
|
|
52
|
+
const SERVER_START_TIME = Date.now();
|
|
53
|
+
|
|
54
|
+
// Clean up old socket
|
|
55
|
+
try { unlinkSync(ROUTER_SOCKET); } catch {}
|
|
56
|
+
|
|
57
|
+
// Write PID file
|
|
58
|
+
writeFileSync(PID_FILE, String(process.pid));
|
|
59
|
+
|
|
60
|
+
interface SessionData {
|
|
61
|
+
info: Session;
|
|
62
|
+
ptyTerminal: Bun.Terminal;
|
|
63
|
+
xterm: XTerminal;
|
|
64
|
+
serialize: SerializeAddon;
|
|
65
|
+
proc: Bun.Subprocess;
|
|
66
|
+
client: any;
|
|
67
|
+
clientWriter: any;
|
|
68
|
+
ctrlBuffer: Buffer;
|
|
69
|
+
pendingWrites: number; // Track pending xterm writes
|
|
70
|
+
attaching: boolean;
|
|
71
|
+
attachBuffer: Buffer[];
|
|
72
|
+
attachPending: boolean;
|
|
73
|
+
attachTimer: any;
|
|
74
|
+
processTitle: string; // Title set by running process (via OSC 0)
|
|
75
|
+
lastInteraction: number; // Timestamp of last user input
|
|
76
|
+
lastDetached: number; // Timestamp of last detach (for grace period)
|
|
77
|
+
lastAttached: number; // Timestamp of last attach (for grace period)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sessions = new Map<string, SessionData>();
|
|
81
|
+
const inbox: InboxItem[] = [];
|
|
82
|
+
|
|
83
|
+
function writeToClient(session: SessionData, data: Buffer): void {
|
|
84
|
+
if (!session.client) return;
|
|
85
|
+
if (session.clientWriter) {
|
|
86
|
+
session.clientWriter.write(data);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
session.client.write(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function flushClient(session: SessionData): void {
|
|
93
|
+
if (session.clientWriter) session.clientWriter.flush();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Socket State Management
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Type-safe socket state manager using WeakMap.
|
|
102
|
+
* This avoids mutating socket objects with `as any` casts.
|
|
103
|
+
*/
|
|
104
|
+
interface RouterSocketState {
|
|
105
|
+
buffer: Buffer;
|
|
106
|
+
writer: any;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const routerSocketStates = new WeakMap<object, RouterSocketState>();
|
|
110
|
+
|
|
111
|
+
function getRouterSocketState(socket: object): RouterSocketState {
|
|
112
|
+
let state = routerSocketStates.get(socket);
|
|
113
|
+
if (!state) {
|
|
114
|
+
state = { buffer: Buffer.alloc(0), writer: null };
|
|
115
|
+
routerSocketStates.set(socket, state);
|
|
116
|
+
}
|
|
117
|
+
return state;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clearRouterSocketState(socket: object): void {
|
|
121
|
+
routerSocketStates.delete(socket);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// How long after last interaction before we consider the user "inactive"
|
|
125
|
+
const INTERACTION_TIMEOUT_MS = 30000; // 30 seconds
|
|
126
|
+
// Grace period after attach/detach - don't notify immediately
|
|
127
|
+
const ATTACH_GRACE_MS = 5000; // 5 seconds after attach
|
|
128
|
+
const DETACH_GRACE_MS = 5000; // 5 seconds after detach
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// OSC Pattern Registry
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Registry of OSC (Operating System Command) patterns for terminal notifications.
|
|
136
|
+
* Each pattern matches specific escape sequences and extracts relevant data.
|
|
137
|
+
*/
|
|
138
|
+
interface OscPattern {
|
|
139
|
+
name: string;
|
|
140
|
+
pattern: RegExp;
|
|
141
|
+
/** Extract notification data from a match. Returns null to skip notification. */
|
|
142
|
+
extract: (match: RegExpMatchArray, context: OscMatchContext) => OscNotificationData | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface OscMatchContext {
|
|
146
|
+
sessionId: string;
|
|
147
|
+
sessionName: string;
|
|
148
|
+
processTitle: string;
|
|
149
|
+
xterm: XTerminal;
|
|
150
|
+
now: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface OscNotificationData {
|
|
154
|
+
type: InboxItem['type'];
|
|
155
|
+
context: string;
|
|
156
|
+
exitCode?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const OSC_PATTERNS: OscPattern[] = [
|
|
160
|
+
{
|
|
161
|
+
// Custom exit code: ESC ] 777 ; exit : <code> BEL
|
|
162
|
+
name: 'exit',
|
|
163
|
+
pattern: /\x1b\]777;exit:(-?\d+)\x07/g,
|
|
164
|
+
extract: (match, ctx) => ({
|
|
165
|
+
type: 'exit',
|
|
166
|
+
exitCode: parseInt(match[1], 10),
|
|
167
|
+
context: getCurrentLine(ctx.xterm) || `Exit code: ${match[1]}`,
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
// iTerm2/Growl notification: ESC ] 9 ; message BEL
|
|
172
|
+
name: 'osc9',
|
|
173
|
+
pattern: /\x1b\]9;([^\x07]*)\x07/g,
|
|
174
|
+
extract: (match) => match[1] ? { type: 'bell', context: match[1] } : null,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
// Kitty notification: ESC ] 99 ; i=id:d=0; body BEL (simplified)
|
|
178
|
+
name: 'osc99',
|
|
179
|
+
pattern: /\x1b\]99;[^;]*;([^\x07]*)\x07/g,
|
|
180
|
+
extract: (match) => match[1] ? { type: 'bell', context: match[1] } : null,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
// rxvt notification: ESC ] 777 ; notify ; title ; body BEL
|
|
184
|
+
name: 'osc777notify',
|
|
185
|
+
pattern: /\x1b\]777;notify;([^;]*);([^\x07]*)\x07/g,
|
|
186
|
+
extract: (match) => ({
|
|
187
|
+
type: 'bell',
|
|
188
|
+
context: match[2] || match[1] || 'Notification',
|
|
189
|
+
}),
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
// Semantic shell integration patterns (OSC 133) - handled separately due to state tracking
|
|
194
|
+
const OSC_133_DONE_PATTERN = /\x1b\]133;D(?:;(\d+))?\x07/g;
|
|
195
|
+
const OSC_133_CMD_START = /\x1b\]133;C\x07/g;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Process OSC patterns in terminal output and create inbox notifications.
|
|
199
|
+
*/
|
|
200
|
+
function processOscPatterns(
|
|
201
|
+
str: string,
|
|
202
|
+
ctx: OscMatchContext,
|
|
203
|
+
addNotification: (data: OscNotificationData) => void
|
|
204
|
+
): void {
|
|
205
|
+
for (const { name, pattern, extract } of OSC_PATTERNS) {
|
|
206
|
+
// Reset lastIndex for global patterns
|
|
207
|
+
pattern.lastIndex = 0;
|
|
208
|
+
const matches = [...str.matchAll(pattern)];
|
|
209
|
+
for (const match of matches) {
|
|
210
|
+
const data = extract(match, ctx);
|
|
211
|
+
if (data) {
|
|
212
|
+
addNotification(data);
|
|
213
|
+
console.log(`[${ctx.sessionName}] ${name} notification: ${data.context.substring(0, 50)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Inbox Notification Helpers
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Creates an inbox item with common fields populated.
|
|
225
|
+
*/
|
|
226
|
+
function createInboxNotification(
|
|
227
|
+
sessionId: string,
|
|
228
|
+
sessionName: string,
|
|
229
|
+
type: InboxItem['type'],
|
|
230
|
+
context: string,
|
|
231
|
+
processTitle?: string,
|
|
232
|
+
exitCode?: number
|
|
233
|
+
): Omit<InboxItem, 'id' | 'read'> {
|
|
234
|
+
return {
|
|
235
|
+
sessionId,
|
|
236
|
+
sessionName,
|
|
237
|
+
type,
|
|
238
|
+
timestamp: Date.now(),
|
|
239
|
+
context,
|
|
240
|
+
processTitle,
|
|
241
|
+
exitCode,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Check if user is actively using the session or recently attached/detached
|
|
246
|
+
// Returns true if we should SUPPRESS notifications
|
|
247
|
+
function isActivelyUsing(session: SessionData | undefined): boolean {
|
|
248
|
+
if (!session) return false;
|
|
249
|
+
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
|
|
252
|
+
// If recently detached, still suppress notifications (grace period)
|
|
253
|
+
if (session.lastDetached > 0) {
|
|
254
|
+
const timeSinceDetach = now - session.lastDetached;
|
|
255
|
+
if (timeSinceDetach < DETACH_GRACE_MS) {
|
|
256
|
+
return true; // Suppress - just detached
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// If not attached, don't suppress (unless in grace period above)
|
|
261
|
+
if (!session.info.attached) return false;
|
|
262
|
+
|
|
263
|
+
// If recently attached, suppress notifications (startup grace period)
|
|
264
|
+
if (session.lastAttached > 0) {
|
|
265
|
+
const timeSinceAttach = now - session.lastAttached;
|
|
266
|
+
if (timeSinceAttach < ATTACH_GRACE_MS) {
|
|
267
|
+
return true; // Suppress - just attached
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// If attached but never interacted AND past the attach grace period, don't suppress
|
|
272
|
+
if (session.lastInteraction === 0) return false;
|
|
273
|
+
|
|
274
|
+
// If attached and recently interacted, suppress
|
|
275
|
+
const timeSinceInteraction = now - session.lastInteraction;
|
|
276
|
+
return timeSinceInteraction < INTERACTION_TIMEOUT_MS;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let sessionCounter = 0;
|
|
280
|
+
let inboxCounter = 0;
|
|
281
|
+
|
|
282
|
+
function genId(): string {
|
|
283
|
+
return String(sessionCounter++);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function genInboxId(): string {
|
|
287
|
+
return String(inboxCounter++);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function addInboxItem(item: Omit<InboxItem, 'id' | 'read'>): void {
|
|
291
|
+
inbox.push({
|
|
292
|
+
...item,
|
|
293
|
+
id: genInboxId(),
|
|
294
|
+
read: false,
|
|
295
|
+
});
|
|
296
|
+
console.log(`[inbox] ${item.type}: ${item.sessionName} - ${item.context.substring(0, 50)}`);
|
|
297
|
+
|
|
298
|
+
// Update titles for all attached sessions to show new inbox count
|
|
299
|
+
broadcastTitleUpdate();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getLastLines(xterm: XTerminal, count: number): string {
|
|
303
|
+
const buffer = xterm.buffer.active;
|
|
304
|
+
const lines: string[] = [];
|
|
305
|
+
const startRow = Math.max(0, buffer.cursorY - count + 1);
|
|
306
|
+
|
|
307
|
+
for (let i = startRow; i <= buffer.cursorY; i++) {
|
|
308
|
+
const line = buffer.getLine(i)?.translateToString(true);
|
|
309
|
+
if (line) lines.push(line);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return lines.join('\n').trim();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getCurrentLine(xterm: XTerminal): string {
|
|
316
|
+
const buffer = xterm.buffer.active;
|
|
317
|
+
return buffer.getLine(buffer.cursorY)?.translateToString(true)?.trim() || '';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getUnreadInboxCount(): number {
|
|
321
|
+
return inbox.filter(i => !i.read).length;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildTitle(sessionName: string, processTitle?: string): string {
|
|
325
|
+
const unread = getUnreadInboxCount();
|
|
326
|
+
let title = `tl: ${sessionName}`;
|
|
327
|
+
|
|
328
|
+
if (processTitle) {
|
|
329
|
+
title += ` | ${processTitle}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (unread > 0) {
|
|
333
|
+
title += ` (${unread} 🔔)`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return title;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sendTitle(session: SessionData, sessionName: string, processTitle?: string): void {
|
|
340
|
+
if (!session.client) return;
|
|
341
|
+
const title = buildTitle(sessionName, processTitle);
|
|
342
|
+
// OSC 0 sets both icon and window title - must be framed!
|
|
343
|
+
writeToClient(session, encodePTY(Buffer.from(`\x1b]0;${title}\x07`)));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function broadcastTitleUpdate(): void {
|
|
347
|
+
// Update title for all attached sessions
|
|
348
|
+
for (const [id, session] of sessions) {
|
|
349
|
+
if (session.client) {
|
|
350
|
+
sendTitle(session, session.info.name, session.processTitle);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// RIS (Reset to Initial State) - the nuclear option that resets everything
|
|
356
|
+
const TERM_RESET = Buffer.from("\x1bc");
|
|
357
|
+
|
|
358
|
+
// ============================================================================
|
|
359
|
+
// Session Helper Functions
|
|
360
|
+
// ============================================================================
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Configuration for idle detection in a session.
|
|
364
|
+
*/
|
|
365
|
+
interface IdleDetectionState {
|
|
366
|
+
lastOutputTime: number;
|
|
367
|
+
outputSinceIdle: number;
|
|
368
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const IDLE_THRESHOLD_MS = 10000; // 10 seconds of quiet after output
|
|
372
|
+
const MIN_OUTPUT_FOR_IDLE = 500; // Need at least 500 bytes of output to consider "activity"
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Creates the idle detection check function for a session.
|
|
376
|
+
*/
|
|
377
|
+
function createIdleChecker(
|
|
378
|
+
id: string,
|
|
379
|
+
sessionName: string,
|
|
380
|
+
xterm: XTerminal,
|
|
381
|
+
idleState: IdleDetectionState,
|
|
382
|
+
getProcessTitle: () => string
|
|
383
|
+
): () => void {
|
|
384
|
+
return () => {
|
|
385
|
+
const session = sessions.get(id);
|
|
386
|
+
// Only notify if: not actively using, had significant output, and now idle
|
|
387
|
+
if (!isActivelyUsing(session) && idleState.outputSinceIdle >= MIN_OUTPUT_FOR_IDLE) {
|
|
388
|
+
const context = getLastLines(xterm, 3) || '(idle)';
|
|
389
|
+
addInboxItem(createInboxNotification(
|
|
390
|
+
id,
|
|
391
|
+
sessionName,
|
|
392
|
+
'idle',
|
|
393
|
+
context,
|
|
394
|
+
session?.processTitle || getProcessTitle()
|
|
395
|
+
));
|
|
396
|
+
console.log(`[${sessionName}] idle notification after ${idleState.outputSinceIdle} bytes output`);
|
|
397
|
+
}
|
|
398
|
+
idleState.outputSinceIdle = 0;
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Sets up xterm event handlers for bell and title change notifications.
|
|
404
|
+
*/
|
|
405
|
+
function setupXtermEventHandlers(
|
|
406
|
+
id: string,
|
|
407
|
+
sessionName: string,
|
|
408
|
+
xterm: XTerminal
|
|
409
|
+
): { getProcessTitle: () => string; setProcessTitle: (title: string) => void } {
|
|
410
|
+
let processTitle = '';
|
|
411
|
+
let lastBellTime = 0;
|
|
412
|
+
let lastTitleNotification = 0;
|
|
413
|
+
|
|
414
|
+
// Track bells for inbox notifications (with debounce)
|
|
415
|
+
xterm.onBell(() => {
|
|
416
|
+
const session = sessions.get(id);
|
|
417
|
+
// Don't notify if user is actively using the session
|
|
418
|
+
if (isActivelyUsing(session)) return;
|
|
419
|
+
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
// Debounce: ignore bells within 500ms of each other
|
|
422
|
+
if (now - lastBellTime < 500) return;
|
|
423
|
+
lastBellTime = now;
|
|
424
|
+
|
|
425
|
+
// Get last few lines for context (not just current line)
|
|
426
|
+
const context = getLastLines(xterm, 3) || getCurrentLine(xterm) || '(bell)';
|
|
427
|
+
addInboxItem(createInboxNotification(
|
|
428
|
+
id,
|
|
429
|
+
sessionName,
|
|
430
|
+
'bell',
|
|
431
|
+
context,
|
|
432
|
+
session?.processTitle
|
|
433
|
+
));
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Track title changes from running processes
|
|
437
|
+
xterm.onTitleChange((title) => {
|
|
438
|
+
console.log(`[${sessionName}] title changed: "${title}"`);
|
|
439
|
+
const previousTitle = processTitle;
|
|
440
|
+
processTitle = title;
|
|
441
|
+
const session = sessions.get(id);
|
|
442
|
+
if (session) {
|
|
443
|
+
session.processTitle = title;
|
|
444
|
+
// Update client's terminal title if attached
|
|
445
|
+
if (session.client) {
|
|
446
|
+
sendTitle(session.client, sessionName, title);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Create inbox notification for ANY title change when not actively using
|
|
450
|
+
// This helps track when background processes change state
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
if (!isActivelyUsing(session) && title && title !== previousTitle) {
|
|
453
|
+
// Debounce: don't notify more than once per 3 seconds
|
|
454
|
+
if (now - lastTitleNotification > 3000) {
|
|
455
|
+
lastTitleNotification = now;
|
|
456
|
+
addInboxItem(createInboxNotification(
|
|
457
|
+
id,
|
|
458
|
+
sessionName,
|
|
459
|
+
'title',
|
|
460
|
+
title,
|
|
461
|
+
title
|
|
462
|
+
));
|
|
463
|
+
console.log(`[${sessionName}] title change: ${previousTitle} -> ${title}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
getProcessTitle: () => processTitle,
|
|
471
|
+
setProcessTitle: (title: string) => { processTitle = title; },
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* State for tracking OSC 133 shell integration commands.
|
|
477
|
+
*/
|
|
478
|
+
interface Osc133State {
|
|
479
|
+
commandRunning: boolean;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Creates the PTY data handler that processes terminal output.
|
|
484
|
+
*/
|
|
485
|
+
function createPtyDataHandler(
|
|
486
|
+
id: string,
|
|
487
|
+
sessionName: string,
|
|
488
|
+
xterm: XTerminal,
|
|
489
|
+
idleState: IdleDetectionState,
|
|
490
|
+
osc133State: Osc133State,
|
|
491
|
+
checkIdle: () => void,
|
|
492
|
+
getProcessTitle: () => string
|
|
493
|
+
): (term: Bun.Terminal, data: Buffer) => void {
|
|
494
|
+
return (term, data) => {
|
|
495
|
+
// Track output for idle detection
|
|
496
|
+
idleState.lastOutputTime = Date.now();
|
|
497
|
+
idleState.outputSinceIdle += data.length;
|
|
498
|
+
|
|
499
|
+
// Reset idle timer
|
|
500
|
+
if (idleState.idleTimer) clearTimeout(idleState.idleTimer);
|
|
501
|
+
idleState.idleTimer = setTimeout(checkIdle, IDLE_THRESHOLD_MS);
|
|
502
|
+
|
|
503
|
+
const session = sessions.get(id);
|
|
504
|
+
if (!session) return;
|
|
505
|
+
|
|
506
|
+
const str = data.toString();
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
|
|
509
|
+
// Only create inbox notifications if user is not actively using the session
|
|
510
|
+
const activelyUsing = session.attaching || isActivelyUsing(session);
|
|
511
|
+
const currentProcessTitle = session.processTitle || getProcessTitle();
|
|
512
|
+
|
|
513
|
+
// Process OSC patterns for notifications (only if not actively using)
|
|
514
|
+
if (!activelyUsing) {
|
|
515
|
+
const oscContext: OscMatchContext = {
|
|
516
|
+
sessionId: id,
|
|
517
|
+
sessionName,
|
|
518
|
+
processTitle: currentProcessTitle,
|
|
519
|
+
xterm,
|
|
520
|
+
now,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
processOscPatterns(str, oscContext, (notifData) => {
|
|
524
|
+
addInboxItem(createInboxNotification(
|
|
525
|
+
id,
|
|
526
|
+
sessionName,
|
|
527
|
+
notifData.type,
|
|
528
|
+
notifData.context,
|
|
529
|
+
currentProcessTitle,
|
|
530
|
+
notifData.exitCode
|
|
531
|
+
));
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Check for semantic shell integration (OSC 133)
|
|
536
|
+
// Command start
|
|
537
|
+
if (OSC_133_CMD_START.test(str)) {
|
|
538
|
+
osc133State.commandRunning = true;
|
|
539
|
+
OSC_133_CMD_START.lastIndex = 0; // Reset regex state
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Command done - only notify if not actively using and command was running
|
|
543
|
+
OSC_133_DONE_PATTERN.lastIndex = 0;
|
|
544
|
+
const osc133DoneMatches = [...str.matchAll(OSC_133_DONE_PATTERN)];
|
|
545
|
+
for (const match of osc133DoneMatches) {
|
|
546
|
+
const exitCode = match[1] ? parseInt(match[1], 10) : 0;
|
|
547
|
+
// Only notify for background sessions with non-zero exit or if command was tracked
|
|
548
|
+
if (!activelyUsing && (exitCode !== 0 || osc133State.commandRunning)) {
|
|
549
|
+
const context = getLastLines(xterm, 2) || `Command finished (exit ${exitCode})`;
|
|
550
|
+
addInboxItem(createInboxNotification(
|
|
551
|
+
id,
|
|
552
|
+
sessionName,
|
|
553
|
+
exitCode !== 0 ? 'exit' : 'idle',
|
|
554
|
+
context,
|
|
555
|
+
currentProcessTitle,
|
|
556
|
+
exitCode !== 0 ? exitCode : undefined
|
|
557
|
+
));
|
|
558
|
+
console.log(`[${sessionName}] OSC 133 command done: exit ${exitCode}`);
|
|
559
|
+
}
|
|
560
|
+
osc133State.commandRunning = false;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Pass original data through unchanged to preserve all escape sequences
|
|
564
|
+
// Our custom OSC 777 exit sequences are harmless - terminals ignore unknown OSC
|
|
565
|
+
// Converting to string and back was corrupting cursor movement/screen control sequences
|
|
566
|
+
|
|
567
|
+
if (session.attaching) {
|
|
568
|
+
session.attachBuffer.push(Buffer.from(data));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Feed data to xterm-headless for state tracking
|
|
573
|
+
session.pendingWrites++;
|
|
574
|
+
xterm.write(data, () => {
|
|
575
|
+
session.pendingWrites--;
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Send to client (buffered - avoid framed protocol desync on backpressure)
|
|
579
|
+
writeToClient(session, encodePTY(data));
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Handles process exit and cleanup for a session.
|
|
585
|
+
*/
|
|
586
|
+
function handleProcessExit(
|
|
587
|
+
id: string,
|
|
588
|
+
sessionName: string,
|
|
589
|
+
xterm: XTerminal,
|
|
590
|
+
socketPath: string,
|
|
591
|
+
disposeDsr: () => void,
|
|
592
|
+
getProcessTitle: () => string
|
|
593
|
+
): (code: number) => void {
|
|
594
|
+
return (code) => {
|
|
595
|
+
const session = sessions.get(id);
|
|
596
|
+
|
|
597
|
+
// Clean up parser hooks
|
|
598
|
+
try { disposeDsr(); } catch {}
|
|
599
|
+
|
|
600
|
+
// Capture last lines for inbox before disposing xterm
|
|
601
|
+
const context = getLastLines(xterm, 3);
|
|
602
|
+
addInboxItem(createInboxNotification(
|
|
603
|
+
id,
|
|
604
|
+
sessionName,
|
|
605
|
+
'exit',
|
|
606
|
+
context || `Session ended (exit ${code})`,
|
|
607
|
+
session?.processTitle || getProcessTitle(),
|
|
608
|
+
code
|
|
609
|
+
));
|
|
610
|
+
|
|
611
|
+
// Update session info with exit code
|
|
612
|
+
if (session) {
|
|
613
|
+
session.info.exitCode = code;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (session?.client) {
|
|
617
|
+
writeToClient(session, encodeControl({ type: "exited", code }));
|
|
618
|
+
session.client.end();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
xterm.dispose();
|
|
622
|
+
try { unlinkSync(socketPath); } catch {}
|
|
623
|
+
sessions.delete(id);
|
|
624
|
+
console.log(`[${sessionName}] exited (${code})`);
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Sends cursor visibility and style state to the client.
|
|
630
|
+
*/
|
|
631
|
+
function sendCursorState(session: SessionData): void {
|
|
632
|
+
// Access xterm internal API for cursor hidden state
|
|
633
|
+
// Note: _core is not part of the public API but is stable
|
|
634
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
635
|
+
const xtermInternal = session.xterm as { _core?: { coreService?: { isCursorHidden?: boolean } } };
|
|
636
|
+
const isCursorHidden = xtermInternal._core?.coreService?.isCursorHidden;
|
|
637
|
+
if (typeof isCursorHidden === "boolean") {
|
|
638
|
+
writeToClient(session, encodePTY(Buffer.from(isCursorHidden ? "\x1b[?25l" : "\x1b[?25h")));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const cursorStyle = session.xterm.options.cursorStyle;
|
|
642
|
+
const cursorBlink = session.xterm.options.cursorBlink;
|
|
643
|
+
let cursorStyleParam: number | null = null;
|
|
644
|
+
if (cursorStyle === "block") {
|
|
645
|
+
cursorStyleParam = cursorBlink ? 2 : 1;
|
|
646
|
+
} else if (cursorStyle === "underline") {
|
|
647
|
+
cursorStyleParam = cursorBlink ? 4 : 3;
|
|
648
|
+
} else if (cursorStyle === "bar") {
|
|
649
|
+
cursorStyleParam = cursorBlink ? 6 : 5;
|
|
650
|
+
}
|
|
651
|
+
if (cursorStyleParam !== null) {
|
|
652
|
+
writeToClient(session, encodePTY(Buffer.from(`\x1b[${cursorStyleParam} q`)));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Clears the attach timer for a session.
|
|
658
|
+
*/
|
|
659
|
+
function clearAttachTimer(session: SessionData): void {
|
|
660
|
+
if (session.attachTimer) {
|
|
661
|
+
clearTimeout(session.attachTimer);
|
|
662
|
+
session.attachTimer = null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Sends serialized terminal state to client during attach.
|
|
668
|
+
*/
|
|
669
|
+
function sendSerializedState(session: SessionData, sessionName: string): void {
|
|
670
|
+
// Debug mode: skip xterm serialization to test if it's the issue
|
|
671
|
+
const skipSerialize = process.env.TMUX_LITE_SKIP_SERIALIZE === '1';
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
// Send reset first to clear any bad modes
|
|
675
|
+
console.log(`[${sessionName}] sending TERM_RESET`);
|
|
676
|
+
writeToClient(session, encodePTY(TERM_RESET));
|
|
677
|
+
writeToClient(session, encodePTY(Buffer.from("\x1b[2J\x1b[H"))); // clear + home
|
|
678
|
+
|
|
679
|
+
if (!skipSerialize) {
|
|
680
|
+
// Get serialized terminal state (including modes) for consistent redraws
|
|
681
|
+
// Limit scrollback to prevent oversized payloads
|
|
682
|
+
const serialized = session.serialize.serialize({
|
|
683
|
+
scrollback: SERIALIZE_SCROLLBACK_LINES,
|
|
684
|
+
});
|
|
685
|
+
const serializedBytes = Buffer.from(serialized);
|
|
686
|
+
|
|
687
|
+
// Log size for debugging
|
|
688
|
+
const sizeKB = Math.round(serializedBytes.length / 1024);
|
|
689
|
+
if (serializedBytes.length > PTY_CHUNK_SIZE) {
|
|
690
|
+
console.log(`[${sessionName}] serialized ${serializedBytes.length} bytes (${sizeKB}KB) - will send in chunks`);
|
|
691
|
+
} else {
|
|
692
|
+
console.log(`[${sessionName}] serialized ${serializedBytes.length} bytes (${sizeKB}KB)`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Send in chunks if too large for a single frame
|
|
696
|
+
if (serializedBytes.length > PTY_CHUNK_SIZE) {
|
|
697
|
+
let offset = 0;
|
|
698
|
+
let chunkNum = 0;
|
|
699
|
+
while (offset < serializedBytes.length) {
|
|
700
|
+
const chunkEnd = Math.min(offset + PTY_CHUNK_SIZE, serializedBytes.length);
|
|
701
|
+
const chunk = serializedBytes.subarray(offset, chunkEnd);
|
|
702
|
+
writeToClient(session, encodePTY(chunk));
|
|
703
|
+
chunkNum++;
|
|
704
|
+
offset = chunkEnd;
|
|
705
|
+
}
|
|
706
|
+
console.log(`[${sessionName}] attached (restored ${serializedBytes.length} bytes in ${chunkNum} chunks)`);
|
|
707
|
+
} else {
|
|
708
|
+
writeToClient(session, encodePTY(serializedBytes));
|
|
709
|
+
console.log(`[${sessionName}] attached (restored ${serializedBytes.length} bytes)`);
|
|
710
|
+
}
|
|
711
|
+
} else {
|
|
712
|
+
console.log(`[${sessionName}] attached (serialization skipped for debugging)`);
|
|
713
|
+
}
|
|
714
|
+
} catch (e) {
|
|
715
|
+
console.log(`[${sessionName}] serialize error:`, e);
|
|
716
|
+
// Fallback: just send a reset
|
|
717
|
+
writeToClient(session, encodePTY(TERM_RESET));
|
|
718
|
+
writeToClient(session, encodePTY(Buffer.from("\x1b[2J\x1b[H")));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Creates the startAttach function that handles the attach process.
|
|
724
|
+
*/
|
|
725
|
+
function createStartAttach(sessionName: string): (session: SessionData) => void {
|
|
726
|
+
return (session: SessionData) => {
|
|
727
|
+
if (!session.attachPending || !session.client) return;
|
|
728
|
+
session.attachPending = false;
|
|
729
|
+
clearAttachTimer(session);
|
|
730
|
+
|
|
731
|
+
// Wait for any pending xterm writes to complete
|
|
732
|
+
const sendState = () => {
|
|
733
|
+
if (session.pendingWrites > 0) {
|
|
734
|
+
setTimeout(sendState, 10);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
sendSerializedState(session, sessionName);
|
|
739
|
+
sendCursorState(session);
|
|
740
|
+
|
|
741
|
+
writeToClient(session, encodeControl({ type: "attach-ready", cols: session.xterm.cols, rows: session.xterm.rows }));
|
|
742
|
+
|
|
743
|
+
const drainAttachBuffer = () => {
|
|
744
|
+
const buffered = session.attachBuffer;
|
|
745
|
+
session.attachBuffer = [];
|
|
746
|
+
for (const chunk of buffered) {
|
|
747
|
+
session.pendingWrites++;
|
|
748
|
+
session.xterm.write(chunk, () => {
|
|
749
|
+
session.pendingWrites--;
|
|
750
|
+
});
|
|
751
|
+
writeToClient(session, encodePTY(chunk));
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const attachStart = Date.now();
|
|
756
|
+
const finalizeAttach = () => {
|
|
757
|
+
if (session.attachBuffer.length > 0) {
|
|
758
|
+
drainAttachBuffer();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if ((session.pendingWrites > 0 || session.attachBuffer.length > 0) &&
|
|
762
|
+
Date.now() - attachStart < 200) {
|
|
763
|
+
setTimeout(finalizeAttach, 10);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
session.attaching = false;
|
|
768
|
+
|
|
769
|
+
writeToClient(session, encodeControl({ type: "attached" }));
|
|
770
|
+
|
|
771
|
+
// Set terminal title
|
|
772
|
+
sendTitle(session, sessionName, session.processTitle);
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
finalizeAttach();
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
sendState();
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Creates socket handlers for a session.
|
|
784
|
+
*/
|
|
785
|
+
function createSessionSocketHandlers(
|
|
786
|
+
id: string,
|
|
787
|
+
sessionName: string,
|
|
788
|
+
proc: Bun.Subprocess,
|
|
789
|
+
startAttach: (session: SessionData) => void
|
|
790
|
+
): {
|
|
791
|
+
open: (socket: any) => void;
|
|
792
|
+
data: (socket: any, data: Buffer) => void;
|
|
793
|
+
drain: (socket: any) => void;
|
|
794
|
+
close: (socket: any) => void;
|
|
795
|
+
} {
|
|
796
|
+
return {
|
|
797
|
+
open(socket) {
|
|
798
|
+
const session = sessions.get(id);
|
|
799
|
+
if (!session) return socket.end();
|
|
800
|
+
|
|
801
|
+
// Kick existing client
|
|
802
|
+
if (session.client) {
|
|
803
|
+
writeToClient(session, encodeControl({ type: "kicked" }));
|
|
804
|
+
session.client.end();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
session.attaching = true;
|
|
808
|
+
session.attachPending = true;
|
|
809
|
+
session.attachBuffer = [];
|
|
810
|
+
session.client = socket;
|
|
811
|
+
session.clientWriter = createBufferedSocketWriter(socket);
|
|
812
|
+
session.info.attached = true;
|
|
813
|
+
session.lastAttached = Date.now(); // Record attach time for grace period
|
|
814
|
+
session.ctrlBuffer = Buffer.alloc(0);
|
|
815
|
+
clearAttachTimer(session);
|
|
816
|
+
// Fallback timeout - client should send attach-init immediately, but just in case
|
|
817
|
+
session.attachTimer = setTimeout(() => {
|
|
818
|
+
if (session.attachPending) {
|
|
819
|
+
console.log(`[${sessionName}] WARN: attach-init not received after 5s, starting attach anyway`);
|
|
820
|
+
startAttach(session);
|
|
821
|
+
}
|
|
822
|
+
}, 5000);
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
data(socket, data) {
|
|
826
|
+
const session = sessions.get(id);
|
|
827
|
+
if (!session) return;
|
|
828
|
+
|
|
829
|
+
const applyResize = (cols: number, rows: number) => {
|
|
830
|
+
try {
|
|
831
|
+
session.ptyTerminal.resize(cols, rows);
|
|
832
|
+
session.xterm.resize(cols, rows);
|
|
833
|
+
// Send SIGWINCH to process group so children (vim, etc.) get it
|
|
834
|
+
try {
|
|
835
|
+
process.kill(-proc.pid, "SIGWINCH");
|
|
836
|
+
} catch {
|
|
837
|
+
try {
|
|
838
|
+
process.kill(proc.pid, "SIGWINCH");
|
|
839
|
+
} catch {}
|
|
840
|
+
}
|
|
841
|
+
} catch {}
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
let buf = Buffer.from(data);
|
|
845
|
+
|
|
846
|
+
// Prepend any buffered data
|
|
847
|
+
if (session.ctrlBuffer.length > 0) {
|
|
848
|
+
buf = Buffer.concat([session.ctrlBuffer, buf]);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Parse frames using the new framed protocol
|
|
852
|
+
let frames;
|
|
853
|
+
let remaining;
|
|
854
|
+
try {
|
|
855
|
+
const result = parseFrames(buf);
|
|
856
|
+
frames = result.frames;
|
|
857
|
+
remaining = result.remaining;
|
|
858
|
+
} catch (err) {
|
|
859
|
+
// Protocol error - likely desync or corrupted data
|
|
860
|
+
const msg = err instanceof Error ? err.message : 'Frame parse error';
|
|
861
|
+
console.error(`[${sessionName}] Frame parse error: ${msg}`);
|
|
862
|
+
// Close the client connection on protocol error
|
|
863
|
+
socket.end();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
// Copy remaining bytes - subarray references can become invalid when Bun reuses buffers
|
|
867
|
+
session.ctrlBuffer = Buffer.from(remaining);
|
|
868
|
+
|
|
869
|
+
for (const frame of frames) {
|
|
870
|
+
if (frame.type === FrameType.CONTROL) {
|
|
871
|
+
const ctrl = decodeControl(frame.payload) as SessionCtrl;
|
|
872
|
+
if (ctrl.type === "resize" || ctrl.type === "attach-init") {
|
|
873
|
+
applyResize(ctrl.cols, ctrl.rows);
|
|
874
|
+
if (session.attaching && session.attachPending) {
|
|
875
|
+
startAttach(session);
|
|
876
|
+
}
|
|
877
|
+
} else if (ctrl.type === "detach") {
|
|
878
|
+
// Send reset before detaching to clean up client terminal
|
|
879
|
+
writeToClient(session, encodePTY(TERM_RESET));
|
|
880
|
+
session.client = null;
|
|
881
|
+
session.clientWriter = null;
|
|
882
|
+
session.info.attached = false;
|
|
883
|
+
session.attaching = false;
|
|
884
|
+
session.attachPending = false;
|
|
885
|
+
clearAttachTimer(session);
|
|
886
|
+
session.attachBuffer = [];
|
|
887
|
+
session.lastDetached = Date.now(); // Record detach time for grace period
|
|
888
|
+
socket.end();
|
|
889
|
+
console.log(`[${sessionName}] detached`);
|
|
890
|
+
}
|
|
891
|
+
} else if (frame.type === FrameType.PTY) {
|
|
892
|
+
// Raw PTY input - write to terminal
|
|
893
|
+
session.ptyTerminal.write(frame.payload);
|
|
894
|
+
// Track last interaction time
|
|
895
|
+
session.lastInteraction = Date.now();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
drain(socket) {
|
|
901
|
+
const session = sessions.get(id);
|
|
902
|
+
if (session && session.client === socket) {
|
|
903
|
+
flushClient(session);
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
close(socket) {
|
|
908
|
+
const session = sessions.get(id);
|
|
909
|
+
if (session && session.client === socket) {
|
|
910
|
+
session.client = null;
|
|
911
|
+
session.clientWriter = null;
|
|
912
|
+
session.info.attached = false;
|
|
913
|
+
session.attaching = false;
|
|
914
|
+
session.attachPending = false;
|
|
915
|
+
clearAttachTimer(session);
|
|
916
|
+
session.attachBuffer = [];
|
|
917
|
+
console.log(`[${sessionName}] disconnected`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Builds the shell environment with integration hooks.
|
|
925
|
+
*/
|
|
926
|
+
function buildShellEnvironment(id: string, shell: string): Record<string, string> {
|
|
927
|
+
// Shell integration: report non-zero exit codes via OSC 777
|
|
928
|
+
// This creates inbox notifications for failed commands
|
|
929
|
+
const exitReporter = '__tl_report() { local e=$?; [[ $e -ne 0 ]] && printf "\\033]777;exit:%d\\007" "$e"; return $e; }';
|
|
930
|
+
|
|
931
|
+
const shellEnv: Record<string, string> = {
|
|
932
|
+
...process.env as Record<string, string>,
|
|
933
|
+
TMUX_LITE: id,
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
// Add PROMPT_COMMAND for bash
|
|
937
|
+
if (shell.endsWith('/bash') || shell.endsWith('/sh')) {
|
|
938
|
+
const existingPrompt = process.env.PROMPT_COMMAND || '';
|
|
939
|
+
shellEnv.PROMPT_COMMAND = `${exitReporter}; __tl_report${existingPrompt ? '; ' + existingPrompt : ''}`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return shellEnv;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ============================================================================
|
|
946
|
+
// Main Session Creation
|
|
947
|
+
// ============================================================================
|
|
948
|
+
|
|
949
|
+
function createSession(name: string | undefined, cwd: string): Session {
|
|
950
|
+
const id = genId();
|
|
951
|
+
const sessionName = name || `session-${id}`;
|
|
952
|
+
const socketPath = getSessionSocketPath(id);
|
|
953
|
+
const socketDir = dirname(socketPath);
|
|
954
|
+
if (!existsSync(socketDir)) {
|
|
955
|
+
mkdirSync(socketDir, { recursive: true });
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const cols = process.stdout.columns || 80;
|
|
959
|
+
const rows = process.stdout.rows || 24;
|
|
960
|
+
|
|
961
|
+
// Create xterm-headless for proper terminal state tracking
|
|
962
|
+
const xterm = new XTerminal({
|
|
963
|
+
cols,
|
|
964
|
+
rows,
|
|
965
|
+
// Keep stored scrollback bounded to avoid slow attach+render on large sessions.
|
|
966
|
+
// Note: attach serialization is additionally capped by SERIALIZE_SCROLLBACK_LINES.
|
|
967
|
+
scrollback: 2_000,
|
|
968
|
+
allowProposedApi: true,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
const serialize = new SerializeAddon();
|
|
972
|
+
xterm.loadAddon(serialize);
|
|
973
|
+
|
|
974
|
+
// Set up xterm event handlers for notifications (bell, title changes)
|
|
975
|
+
const { getProcessTitle } = setupXtermEventHandlers(id, sessionName, xterm);
|
|
976
|
+
|
|
977
|
+
// Initialize idle detection state
|
|
978
|
+
const idleState: IdleDetectionState = {
|
|
979
|
+
lastOutputTime: 0,
|
|
980
|
+
outputSinceIdle: 0,
|
|
981
|
+
idleTimer: null,
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// Initialize OSC 133 state for shell integration
|
|
985
|
+
const osc133State: Osc133State = {
|
|
986
|
+
commandRunning: false,
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
// Create the idle checker function
|
|
990
|
+
const checkIdle = createIdleChecker(id, sessionName, xterm, idleState, getProcessTitle);
|
|
991
|
+
|
|
992
|
+
// Create PTY terminal with data handler
|
|
993
|
+
const ptyDataHandler = createPtyDataHandler(
|
|
994
|
+
id,
|
|
995
|
+
sessionName,
|
|
996
|
+
xterm,
|
|
997
|
+
idleState,
|
|
998
|
+
osc133State,
|
|
999
|
+
checkIdle,
|
|
1000
|
+
getProcessTitle
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
const ptyTerminal = new Bun.Terminal({
|
|
1004
|
+
cols,
|
|
1005
|
+
rows,
|
|
1006
|
+
data: ptyDataHandler,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Terminal query support (server-side): respond to DSR (CSI 6 n) with CPR.
|
|
1010
|
+
const disposeDsr = installDsrCprResponder(xterm, (data) => {
|
|
1011
|
+
try { ptyTerminal.write(data); } catch {}
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
// Spawn shell process
|
|
1015
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
1016
|
+
const shellEnv = buildShellEnvironment(id, shell);
|
|
1017
|
+
|
|
1018
|
+
const proc = Bun.spawn([shell], {
|
|
1019
|
+
terminal: ptyTerminal,
|
|
1020
|
+
cwd,
|
|
1021
|
+
env: shellEnv,
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
// Handle process exit
|
|
1025
|
+
proc.exited.then(handleProcessExit(id, sessionName, xterm, socketPath, disposeDsr, getProcessTitle));
|
|
1026
|
+
|
|
1027
|
+
// Create session info
|
|
1028
|
+
const info: Session = {
|
|
1029
|
+
id,
|
|
1030
|
+
name: sessionName,
|
|
1031
|
+
socketPath,
|
|
1032
|
+
pid: proc.pid,
|
|
1033
|
+
attached: false,
|
|
1034
|
+
cwd,
|
|
1035
|
+
createdAt: Date.now(),
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
// Create attach handler
|
|
1039
|
+
const startAttach = createStartAttach(sessionName);
|
|
1040
|
+
|
|
1041
|
+
// Create and bind socket handlers
|
|
1042
|
+
const socketHandlers = createSessionSocketHandlers(id, sessionName, proc, startAttach);
|
|
1043
|
+
|
|
1044
|
+
// Create session socket
|
|
1045
|
+
Bun.listen({
|
|
1046
|
+
unix: socketPath,
|
|
1047
|
+
socket: socketHandlers,
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
// Store session data
|
|
1051
|
+
sessions.set(id, {
|
|
1052
|
+
info,
|
|
1053
|
+
ptyTerminal,
|
|
1054
|
+
xterm,
|
|
1055
|
+
serialize,
|
|
1056
|
+
proc,
|
|
1057
|
+
client: null,
|
|
1058
|
+
clientWriter: null,
|
|
1059
|
+
ctrlBuffer: Buffer.alloc(0),
|
|
1060
|
+
pendingWrites: 0,
|
|
1061
|
+
attaching: false,
|
|
1062
|
+
attachBuffer: [],
|
|
1063
|
+
attachPending: false,
|
|
1064
|
+
attachTimer: null,
|
|
1065
|
+
processTitle: '',
|
|
1066
|
+
lastInteraction: 0, // No interaction yet
|
|
1067
|
+
lastDetached: 0, // Never detached yet
|
|
1068
|
+
lastAttached: 0, // Never attached yet (will be set on first attach)
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
console.log(`[${sessionName}] created (pid ${proc.pid})`);
|
|
1072
|
+
return info;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Router server
|
|
1076
|
+
Bun.listen({
|
|
1077
|
+
unix: ROUTER_SOCKET,
|
|
1078
|
+
socket: {
|
|
1079
|
+
open(socket) {
|
|
1080
|
+
const socketState = getRouterSocketState(socket);
|
|
1081
|
+
socketState.writer = createBufferedSocketWriter(socket as any);
|
|
1082
|
+
},
|
|
1083
|
+
data(socket, data) {
|
|
1084
|
+
const socketState = getRouterSocketState(socket);
|
|
1085
|
+
const combined = Buffer.concat([socketState.buffer, Buffer.from(data)]);
|
|
1086
|
+
let decoded;
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
decoded = decodeRouterMessages(combined);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
const message = err instanceof Error ? err.message : "Invalid request";
|
|
1092
|
+
if (socketState.writer) socketState.writer.write(encodeRouterMessage({ type: "error", message }));
|
|
1093
|
+
else socket.write(encodeRouterMessage({ type: "error", message }));
|
|
1094
|
+
socketState.buffer = Buffer.alloc(0);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
socketState.buffer = decoded.remaining;
|
|
1099
|
+
|
|
1100
|
+
for (const message of decoded.messages) {
|
|
1101
|
+
const cmd = message as Command;
|
|
1102
|
+
let res: Response;
|
|
1103
|
+
|
|
1104
|
+
// Helper to get session info with current processTitle
|
|
1105
|
+
const getSessionInfo = (s: SessionData): Session => ({
|
|
1106
|
+
...s.info,
|
|
1107
|
+
processTitle: s.processTitle || undefined,
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
switch (cmd.type) {
|
|
1111
|
+
case "list":
|
|
1112
|
+
res = {
|
|
1113
|
+
type: "sessions",
|
|
1114
|
+
sessions: Array.from(sessions.values()).map(getSessionInfo)
|
|
1115
|
+
};
|
|
1116
|
+
break;
|
|
1117
|
+
|
|
1118
|
+
case "new":
|
|
1119
|
+
try {
|
|
1120
|
+
const session = createSession(cmd.name, cmd.cwd);
|
|
1121
|
+
res = { type: "session", session };
|
|
1122
|
+
} catch (e) {
|
|
1123
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
1124
|
+
console.error(`[server] createSession failed: ${errMsg}`);
|
|
1125
|
+
res = { type: "error", message: `Failed to create session: ${errMsg}` };
|
|
1126
|
+
}
|
|
1127
|
+
break;
|
|
1128
|
+
|
|
1129
|
+
case "attach": {
|
|
1130
|
+
const s = sessions.get(cmd.id);
|
|
1131
|
+
if (!s) {
|
|
1132
|
+
res = { type: "error", message: `Session ${cmd.id} not found` };
|
|
1133
|
+
} else if (s.info.attached && !cmd.force) {
|
|
1134
|
+
res = { type: "already-attached", session: getSessionInfo(s) };
|
|
1135
|
+
} else {
|
|
1136
|
+
res = { type: "session", session: getSessionInfo(s) };
|
|
1137
|
+
}
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
case "kill": {
|
|
1142
|
+
const s = sessions.get(cmd.id);
|
|
1143
|
+
if (!s) {
|
|
1144
|
+
res = { type: "error", message: `Session ${cmd.id} not found` };
|
|
1145
|
+
} else {
|
|
1146
|
+
// Use SIGKILL to forcefully terminate - SIGTERM is often ignored by shells
|
|
1147
|
+
s.proc.kill(9);
|
|
1148
|
+
res = { type: "ok" };
|
|
1149
|
+
}
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
case "kill-server":
|
|
1154
|
+
console.log("Shutting down...");
|
|
1155
|
+
for (const [id, s] of sessions) {
|
|
1156
|
+
s.xterm.dispose();
|
|
1157
|
+
s.proc.kill(9); // Use SIGKILL - shells ignore SIGTERM
|
|
1158
|
+
}
|
|
1159
|
+
// Clean up PID file
|
|
1160
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
1161
|
+
res = { type: "ok" };
|
|
1162
|
+
if (socketState.writer) socketState.writer.write(encodeRouterMessage(res));
|
|
1163
|
+
else socket.write(encodeRouterMessage(res));
|
|
1164
|
+
setTimeout(() => process.exit(0), 100);
|
|
1165
|
+
return;
|
|
1166
|
+
|
|
1167
|
+
case "inbox":
|
|
1168
|
+
res = { type: "inbox", items: [...inbox] };
|
|
1169
|
+
break;
|
|
1170
|
+
|
|
1171
|
+
case "inbox-clear":
|
|
1172
|
+
if (cmd.id) {
|
|
1173
|
+
const idx = inbox.findIndex(i => i.id === cmd.id);
|
|
1174
|
+
if (idx !== -1) inbox.splice(idx, 1);
|
|
1175
|
+
} else {
|
|
1176
|
+
inbox.length = 0;
|
|
1177
|
+
}
|
|
1178
|
+
broadcastTitleUpdate();
|
|
1179
|
+
res = { type: "ok" };
|
|
1180
|
+
break;
|
|
1181
|
+
|
|
1182
|
+
case "inbox-read": {
|
|
1183
|
+
const item = inbox.find(i => i.id === cmd.id);
|
|
1184
|
+
if (item) item.read = true;
|
|
1185
|
+
broadcastTitleUpdate();
|
|
1186
|
+
res = { type: "ok" };
|
|
1187
|
+
break;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
case "version":
|
|
1191
|
+
res = {
|
|
1192
|
+
type: "version",
|
|
1193
|
+
version: PACKAGE_VERSION,
|
|
1194
|
+
protocol: PROTOCOL_VERSION,
|
|
1195
|
+
};
|
|
1196
|
+
break;
|
|
1197
|
+
|
|
1198
|
+
case "status": {
|
|
1199
|
+
const sessionList = Array.from(sessions.values());
|
|
1200
|
+
const attachedCount = sessionList.filter(s => s.info.attached).length;
|
|
1201
|
+
res = {
|
|
1202
|
+
type: "status",
|
|
1203
|
+
version: PACKAGE_VERSION,
|
|
1204
|
+
protocol: PROTOCOL_VERSION,
|
|
1205
|
+
pid: process.pid,
|
|
1206
|
+
uptime: Math.floor((Date.now() - SERVER_START_TIME) / 1000),
|
|
1207
|
+
sessions: sessionList.length,
|
|
1208
|
+
attached: attachedCount,
|
|
1209
|
+
};
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
default:
|
|
1214
|
+
res = { type: "error", message: "Unknown command" };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (socketState.writer) socketState.writer.write(encodeRouterMessage(res));
|
|
1218
|
+
else socket.write(encodeRouterMessage(res));
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
drain(socket) {
|
|
1222
|
+
const socketState = getRouterSocketState(socket);
|
|
1223
|
+
socketState.writer?.flush?.();
|
|
1224
|
+
},
|
|
1225
|
+
close(socket) {
|
|
1226
|
+
clearRouterSocketState(socket);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// Handle unexpected termination - kill all session processes
|
|
1232
|
+
function cleanupAndExit(signal: string) {
|
|
1233
|
+
console.log(`\nReceived ${signal}, cleaning up sessions...`);
|
|
1234
|
+
for (const [id, s] of sessions) {
|
|
1235
|
+
try {
|
|
1236
|
+
s.xterm.dispose();
|
|
1237
|
+
s.proc.kill(9);
|
|
1238
|
+
} catch {}
|
|
1239
|
+
}
|
|
1240
|
+
// Clean up PID file
|
|
1241
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
1242
|
+
process.exit(0);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
process.on('SIGTERM', () => cleanupAndExit('SIGTERM'));
|
|
1246
|
+
process.on('SIGINT', () => cleanupAndExit('SIGINT'));
|
|
1247
|
+
process.on('SIGHUP', () => cleanupAndExit('SIGHUP'));
|
|
1248
|
+
|
|
1249
|
+
console.log("tmux-lite server running (xterm-headless)");
|
|
1250
|
+
console.log(`Socket: ${ROUTER_SOCKET}\n`);
|