tmux-ide 2.6.0 → 2.6.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/bin/cli.js +13 -5
- package/bin/cli.ts +1 -1
- package/bunfig.toml +4 -0
- package/package.json +11 -3
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/src/agent-explain.ts +270 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +336 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +195 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +428 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +113 -0
- package/packages/daemon/src/tui/detect/classify.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
- package/packages/daemon/src/tui/detect/manifest.ts +184 -0
- package/packages/daemon/src/tui/detect/manifests.ts +226 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +70 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +11 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +73 -0
- package/packages/daemon/src/tui/team/sessions.ts +344 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/postinstall.js +26 -2
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { defaultNodePtyAdapter, NodePtyAdapter } from "../terminal/NodePtyAdapter.ts";
|
|
4
|
+
import type { PtyAdapter, PtyProcess, PtySpawnInput } from "../terminal/PtyAdapter.ts";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RING_BUFFER_BYTES = 256 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Test-only spawn signature that historic tests injected before T087
|
|
10
|
+
* (see `options.pty?.spawn`). We keep it as a compat shim — anything more
|
|
11
|
+
* intrusive (e.g. a full `pty.IPty` stub) should switch to
|
|
12
|
+
* `options.ptyAdapter` (a `PtyAdapter` from `terminal/MockPtyAdapter.ts`).
|
|
13
|
+
*/
|
|
14
|
+
type LegacyPtySpawn = (
|
|
15
|
+
shell: string,
|
|
16
|
+
args: string[],
|
|
17
|
+
opts: {
|
|
18
|
+
name?: string;
|
|
19
|
+
cols: number;
|
|
20
|
+
rows: number;
|
|
21
|
+
cwd: string;
|
|
22
|
+
env: Record<string, string>;
|
|
23
|
+
encoding: null | "utf8";
|
|
24
|
+
},
|
|
25
|
+
) => {
|
|
26
|
+
pid: number;
|
|
27
|
+
cols?: number;
|
|
28
|
+
rows?: number;
|
|
29
|
+
onData(cb: (data: unknown) => void): { dispose(): void };
|
|
30
|
+
onExit(cb: (evt: { exitCode: number; signal?: number | null }) => void): { dispose(): void };
|
|
31
|
+
write(data: string | Buffer): void;
|
|
32
|
+
resize(cols: number, rows: number): void;
|
|
33
|
+
kill(signal?: NodeJS.Signals | string): void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface PtyExit {
|
|
37
|
+
code: number;
|
|
38
|
+
signal: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type TerminalCwdErrorReason = "notFound" | "notDirectory" | "statFailed";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Typed error thrown when a requested terminal cwd cannot be used to
|
|
45
|
+
* spawn a PTY. Carries the offending cwd and a discriminator so the
|
|
46
|
+
* WebSocket layer can surface a structured error frame to the client.
|
|
47
|
+
*/
|
|
48
|
+
export class TerminalCwdError extends Error {
|
|
49
|
+
readonly cwd: string;
|
|
50
|
+
readonly reason: TerminalCwdErrorReason;
|
|
51
|
+
|
|
52
|
+
constructor(args: { cwd: string; reason: TerminalCwdErrorReason; cause?: unknown }) {
|
|
53
|
+
const message = TerminalCwdError.formatMessage(args.cwd, args.reason);
|
|
54
|
+
super(message, args.cause !== undefined ? { cause: args.cause } : undefined);
|
|
55
|
+
this.name = "TerminalCwdError";
|
|
56
|
+
this.cwd = args.cwd;
|
|
57
|
+
this.reason = args.reason;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private static formatMessage(cwd: string, reason: TerminalCwdErrorReason): string {
|
|
61
|
+
switch (reason) {
|
|
62
|
+
case "notFound":
|
|
63
|
+
return `cwd does not exist: ${cwd}`;
|
|
64
|
+
case "notDirectory":
|
|
65
|
+
return `cwd is not a directory: ${cwd}`;
|
|
66
|
+
case "statFailed":
|
|
67
|
+
return `cwd stat failed: ${cwd}`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Typed error thrown when caller-supplied PTY input is structurally
|
|
74
|
+
* invalid (e.g. empty cwd for the project default terminal). Distinct
|
|
75
|
+
* from {@link TerminalCwdError} which describes filesystem state.
|
|
76
|
+
*/
|
|
77
|
+
export class TerminalSpawnInputError extends Error {
|
|
78
|
+
readonly field: string;
|
|
79
|
+
|
|
80
|
+
constructor(args: { field: string; message: string }) {
|
|
81
|
+
super(args.message);
|
|
82
|
+
this.name = "TerminalSpawnInputError";
|
|
83
|
+
this.field = args.field;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PtyBridgeOptions {
|
|
88
|
+
id?: string;
|
|
89
|
+
shell?: string;
|
|
90
|
+
args?: string[];
|
|
91
|
+
cwd?: string;
|
|
92
|
+
env?: Record<string, string | undefined>;
|
|
93
|
+
name?: string;
|
|
94
|
+
coalesceMs?: number;
|
|
95
|
+
ringBufferBytes?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Inject a `PtyAdapter` (T087). When omitted, the bridge uses the
|
|
98
|
+
* `defaultNodePtyAdapter`. Tests should pass a `MockPtyAdapter` from
|
|
99
|
+
* `terminal/__tests__/MockPtyAdapter.ts` so no native PTY is spawned.
|
|
100
|
+
*/
|
|
101
|
+
ptyAdapter?: PtyAdapter;
|
|
102
|
+
/**
|
|
103
|
+
* Legacy injection point retained for back-compat with pre-T087 tests
|
|
104
|
+
* that hand-roll a `pty.spawn` stub. The bridge wraps the supplied
|
|
105
|
+
* function in an internal `NodePtyAdapter` so the rest of the code path
|
|
106
|
+
* stays uniform. New tests should prefer `ptyAdapter`.
|
|
107
|
+
*/
|
|
108
|
+
pty?: {
|
|
109
|
+
spawn: LegacyPtySpawn;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Override for cwd validation. Defaults to a real `fs.statSync` check.
|
|
113
|
+
* Tests inject a stub to exercise the typed-error branches without
|
|
114
|
+
* touching the filesystem.
|
|
115
|
+
*/
|
|
116
|
+
statCwd?: (cwd: string) => fs.Stats;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface PtySpawnOptions {
|
|
120
|
+
cwd?: string;
|
|
121
|
+
cmd?: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validate that the supplied cwd exists and is a directory. Throws a
|
|
126
|
+
* {@link TerminalCwdError} with a typed reason on any failure so the
|
|
127
|
+
* caller can surface a structured error frame instead of leaking
|
|
128
|
+
* stringly-typed errno checks.
|
|
129
|
+
*/
|
|
130
|
+
export function assertValidCwd(
|
|
131
|
+
cwd: string,
|
|
132
|
+
statCwd: (cwd: string) => fs.Stats = fs.statSync,
|
|
133
|
+
): void {
|
|
134
|
+
let stats: fs.Stats;
|
|
135
|
+
try {
|
|
136
|
+
stats = statCwd(cwd);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const errno = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
139
|
+
if (errno === "ENOENT") {
|
|
140
|
+
throw new TerminalCwdError({ cwd, reason: "notFound", cause: err });
|
|
141
|
+
}
|
|
142
|
+
throw new TerminalCwdError({ cwd, reason: "statFailed", cause: err });
|
|
143
|
+
}
|
|
144
|
+
if (!stats.isDirectory()) {
|
|
145
|
+
throw new TerminalCwdError({ cwd, reason: "notDirectory" });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function cleanEnv(): Record<string, string> {
|
|
150
|
+
const env: Record<string, string> = {};
|
|
151
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
152
|
+
if (value !== undefined) env[key] = value;
|
|
153
|
+
}
|
|
154
|
+
return env;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function outputToBuffer(data: unknown): Buffer {
|
|
158
|
+
if (Buffer.isBuffer(data)) return data;
|
|
159
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
160
|
+
if (ArrayBuffer.isView(data)) {
|
|
161
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
162
|
+
}
|
|
163
|
+
return Buffer.from(String(data), "utf8");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function assertPositiveDimension(name: string, value: number): void {
|
|
167
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
168
|
+
throw new Error(`${name} must be a positive integer`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readPositiveIntEnv(name: string, fallback: number): number {
|
|
173
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
174
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class PtyBridge extends EventEmitter {
|
|
178
|
+
private ptyProcess: PtyProcess | null = null;
|
|
179
|
+
private dataDispose: (() => void) | null = null;
|
|
180
|
+
private exitDispose: (() => void) | null = null;
|
|
181
|
+
private exitPoll: ReturnType<typeof setInterval> | null = null;
|
|
182
|
+
private outputTimer: ReturnType<typeof setTimeout> | null = null;
|
|
183
|
+
private outputChunks: Buffer[] = [];
|
|
184
|
+
private pausedOutputChunks: Buffer[] = [];
|
|
185
|
+
private outputPaused = false;
|
|
186
|
+
private replayChunks: Buffer[] = [];
|
|
187
|
+
private replayBytes = 0;
|
|
188
|
+
private lastCwd: string | null = null;
|
|
189
|
+
private readonly options: PtyBridgeOptions;
|
|
190
|
+
private readonly adapter: PtyAdapter;
|
|
191
|
+
private readonly ringBufferBytes: number;
|
|
192
|
+
private readonly statCwd: (cwd: string) => fs.Stats;
|
|
193
|
+
|
|
194
|
+
constructor(options: PtyBridgeOptions = {}) {
|
|
195
|
+
super();
|
|
196
|
+
this.options = options;
|
|
197
|
+
// Resolution order (per T087):
|
|
198
|
+
// 1. explicit `ptyAdapter` (preferred — pass a MockPtyAdapter in tests)
|
|
199
|
+
// 2. legacy `options.pty?.spawn` (wraps the hand-rolled stub in a
|
|
200
|
+
// NodePtyAdapter so pre-T087 tests still work)
|
|
201
|
+
// 3. defaultNodePtyAdapter — the production singleton.
|
|
202
|
+
if (options.ptyAdapter) {
|
|
203
|
+
this.adapter = options.ptyAdapter;
|
|
204
|
+
} else if (options.pty?.spawn) {
|
|
205
|
+
this.adapter = new NodePtyAdapter({
|
|
206
|
+
spawnPty: options.pty.spawn as unknown as NodePtyAdapter["spawnPty"],
|
|
207
|
+
statCwd: options.statCwd,
|
|
208
|
+
skipHelperEnsure: true,
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
this.adapter = defaultNodePtyAdapter;
|
|
212
|
+
}
|
|
213
|
+
this.ringBufferBytes =
|
|
214
|
+
options.ringBufferBytes ??
|
|
215
|
+
readPositiveIntEnv("TMUX_IDE_PTY_RING_BUFFER_BYTES", DEFAULT_RING_BUFFER_BYTES);
|
|
216
|
+
this.statCwd = options.statCwd ?? fs.statSync;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Returns the cwd used by the most recent spawn (or restart). `null`
|
|
221
|
+
* if the bridge has never been spawned. Used by ws-route to detect
|
|
222
|
+
* stale-cwd reuse and trigger a respawn.
|
|
223
|
+
*/
|
|
224
|
+
getCwd(): string | null {
|
|
225
|
+
return this.lastCwd;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get pid(): number | null {
|
|
229
|
+
return this.ptyProcess?.pid ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// `cols`/`rows` aren't on the canonical PtyProcess shape — we mirror the
|
|
233
|
+
// most recent value the bridge handed to the adapter so external readers
|
|
234
|
+
// (status views) can still see the size without rummaging in the child.
|
|
235
|
+
private lastCols: number | null = null;
|
|
236
|
+
private lastRows: number | null = null;
|
|
237
|
+
get cols(): number | null {
|
|
238
|
+
return this.lastCols;
|
|
239
|
+
}
|
|
240
|
+
get rows(): number | null {
|
|
241
|
+
return this.lastRows;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
get running(): boolean {
|
|
245
|
+
return this.ptyProcess !== null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
getReplayBuffer(): Buffer {
|
|
249
|
+
return Buffer.concat(this.replayChunks, this.replayBytes);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
flushReplayBuffer(): void {
|
|
253
|
+
this.replayChunks = [];
|
|
254
|
+
this.replayBytes = 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
spawn(cols: number, rows: number, spawnOptions: PtySpawnOptions = {}): void {
|
|
258
|
+
if (this.running) {
|
|
259
|
+
throw new Error("PTY already spawned");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
assertPositiveDimension("cols", cols);
|
|
263
|
+
assertPositiveDimension("rows", rows);
|
|
264
|
+
|
|
265
|
+
// When the client doesn't specify cmd, fall back to the user's actual
|
|
266
|
+
// login shell from $SHELL so .zshrc / .zprofile / etc. load. Defaulting
|
|
267
|
+
// to "bash" silently downgrades zsh users to a non-zsh experience.
|
|
268
|
+
const defaultShell = this.options.shell ?? process.env.SHELL ?? "bash";
|
|
269
|
+
let executable = spawnOptions.cmd?.[0] ?? defaultShell;
|
|
270
|
+
let args = spawnOptions.cmd ? spawnOptions.cmd.slice(1) : (this.options.args ?? ["-l"]);
|
|
271
|
+
|
|
272
|
+
// `__login_shell__` sentinel: wrap the rest of cmd in `$SHELL -l -c
|
|
273
|
+
// "exec <cmd...>"`. The user's login shell sources profile/rc files
|
|
274
|
+
// (PATH, nvm, brew, …) before exec'ing the requested command, so the
|
|
275
|
+
// spawned program inherits the fully-loaded env. Used by the dashboard
|
|
276
|
+
// to give tmux-ide the user's real shell environment.
|
|
277
|
+
if (executable === "__login_shell__" && args.length > 0) {
|
|
278
|
+
const innerCmd = args.map((part) => `'${part.replace(/'/g, "'\\''")}'`).join(" ");
|
|
279
|
+
executable = defaultShell;
|
|
280
|
+
args = ["-l", "-c", `exec ${innerCmd}`];
|
|
281
|
+
}
|
|
282
|
+
const cwd = spawnOptions.cwd ?? this.options.cwd ?? process.env.HOME ?? "/";
|
|
283
|
+
assertValidCwd(cwd, this.statCwd);
|
|
284
|
+
const env = this.options.env ?? cleanEnv();
|
|
285
|
+
|
|
286
|
+
const spawnInput: PtySpawnInput = {
|
|
287
|
+
shell: executable,
|
|
288
|
+
args,
|
|
289
|
+
cwd,
|
|
290
|
+
cols,
|
|
291
|
+
rows,
|
|
292
|
+
env: env as NodeJS.ProcessEnv,
|
|
293
|
+
name: this.options.name ?? "xterm-256color",
|
|
294
|
+
encoding: null,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
let child: PtyProcess;
|
|
298
|
+
try {
|
|
299
|
+
child = this.adapter.spawnSync(spawnInput);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT" && executable === "tmux-ide") {
|
|
302
|
+
throw new Error("tmux-ide not found in PATH", { cause: err });
|
|
303
|
+
}
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
this.ptyProcess = child;
|
|
308
|
+
this.lastCwd = cwd;
|
|
309
|
+
this.lastCols = cols;
|
|
310
|
+
this.lastRows = rows;
|
|
311
|
+
this.exitDispose = child.onExit(({ exitCode, signal }) => {
|
|
312
|
+
this.emitExit({ code: exitCode, signal: signal ?? null });
|
|
313
|
+
});
|
|
314
|
+
this.dataDispose = child.onData((data) => {
|
|
315
|
+
this.enqueueOutput(outputToBuffer(data));
|
|
316
|
+
});
|
|
317
|
+
this.startExitPoll(child);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Stop the currently-running PTY process synchronously. Drops listeners
|
|
322
|
+
* and clears replay so a follow-up spawn starts from a clean slate.
|
|
323
|
+
* Idempotent — no-op when no process is running.
|
|
324
|
+
*
|
|
325
|
+
* Used by {@link restartWith} to swap out a sticky bridge whose cwd no
|
|
326
|
+
* longer matches the client request.
|
|
327
|
+
*/
|
|
328
|
+
stopProcess(signal: NodeJS.Signals = "SIGTERM"): void {
|
|
329
|
+
if (!this.ptyProcess) return;
|
|
330
|
+
const child = this.ptyProcess;
|
|
331
|
+
// Drop listeners first so the impending kill does not re-emit `exit`
|
|
332
|
+
// to the WS client (which would close the socket mid-restart).
|
|
333
|
+
this.disposeListeners();
|
|
334
|
+
this.ptyProcess = null;
|
|
335
|
+
this.lastCwd = null;
|
|
336
|
+
this.flushReplayBuffer();
|
|
337
|
+
try {
|
|
338
|
+
child.kill(signal);
|
|
339
|
+
} catch {
|
|
340
|
+
// Process may have already exited between the running check and
|
|
341
|
+
// the kill syscall.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Stop the running process (if any) and spawn a new one with the
|
|
347
|
+
* supplied options. Preserves bridge identity (id, registry slot) but
|
|
348
|
+
* resets the replay buffer — the prior process is gone, there is no
|
|
349
|
+
* meaningful output to replay. Used when a reconnect requests a new
|
|
350
|
+
* cwd; modeled on t3code's `stopProcess + spawn` pattern.
|
|
351
|
+
*/
|
|
352
|
+
restartWith(cols: number, rows: number, spawnOptions: PtySpawnOptions = {}): void {
|
|
353
|
+
this.stopProcess("SIGTERM");
|
|
354
|
+
this.spawn(cols, rows, spawnOptions);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
pause(): void {
|
|
358
|
+
this.outputPaused = true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
resume(): void {
|
|
362
|
+
if (!this.outputPaused) return;
|
|
363
|
+
this.flushCoalescedOutput();
|
|
364
|
+
this.outputPaused = false;
|
|
365
|
+
this.flushPausedOutput();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
write(bytes: Buffer | Uint8Array | string): void {
|
|
369
|
+
if (!this.ptyProcess) {
|
|
370
|
+
throw new Error("PTY is not running");
|
|
371
|
+
}
|
|
372
|
+
this.ptyProcess.write(typeof bytes === "string" ? bytes : Buffer.from(bytes));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
resize(cols: number, rows: number): void {
|
|
376
|
+
assertPositiveDimension("cols", cols);
|
|
377
|
+
assertPositiveDimension("rows", rows);
|
|
378
|
+
|
|
379
|
+
if (!this.ptyProcess) {
|
|
380
|
+
throw new Error("PTY is not running");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.ptyProcess.resize(cols, rows);
|
|
384
|
+
this.lastCols = cols;
|
|
385
|
+
this.lastRows = rows;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
kill(signal: NodeJS.Signals = "SIGTERM"): void {
|
|
389
|
+
if (!this.ptyProcess) return;
|
|
390
|
+
try {
|
|
391
|
+
this.ptyProcess.kill(signal);
|
|
392
|
+
} catch {
|
|
393
|
+
// The process may already have exited between the running check and kill.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
dispose(): void {
|
|
398
|
+
this.disposeListeners();
|
|
399
|
+
this.kill("SIGTERM");
|
|
400
|
+
this.flushReplayBuffer();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private disposeListeners(): void {
|
|
404
|
+
this.flushAllOutput();
|
|
405
|
+
this.dataDispose?.();
|
|
406
|
+
this.exitDispose?.();
|
|
407
|
+
if (this.exitPoll) {
|
|
408
|
+
clearInterval(this.exitPoll);
|
|
409
|
+
this.exitPoll = null;
|
|
410
|
+
}
|
|
411
|
+
this.dataDispose = null;
|
|
412
|
+
this.exitDispose = null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private enqueueOutput(bytes: Buffer): void {
|
|
416
|
+
if (bytes.byteLength === 0) return;
|
|
417
|
+
|
|
418
|
+
const coalesceMs = this.options.coalesceMs ?? 8;
|
|
419
|
+
if (coalesceMs <= 0) {
|
|
420
|
+
this.deliverOutput(bytes);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this.outputChunks.push(bytes);
|
|
425
|
+
if (this.outputTimer) return;
|
|
426
|
+
|
|
427
|
+
this.outputTimer = setTimeout(() => {
|
|
428
|
+
this.outputTimer = null;
|
|
429
|
+
this.flushCoalescedOutput();
|
|
430
|
+
}, coalesceMs);
|
|
431
|
+
this.outputTimer.unref?.();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private flushCoalescedOutput(): void {
|
|
435
|
+
if (this.outputTimer) {
|
|
436
|
+
clearTimeout(this.outputTimer);
|
|
437
|
+
this.outputTimer = null;
|
|
438
|
+
}
|
|
439
|
+
if (this.outputChunks.length === 0) return;
|
|
440
|
+
|
|
441
|
+
const chunks = this.outputChunks;
|
|
442
|
+
this.outputChunks = [];
|
|
443
|
+
this.deliverOutput(Buffer.concat(chunks));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private deliverOutput(bytes: Buffer): void {
|
|
447
|
+
this.appendReplay(bytes);
|
|
448
|
+
if (this.outputPaused) {
|
|
449
|
+
this.pausedOutputChunks.push(bytes);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.emit("output", bytes);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private flushPausedOutput(): void {
|
|
456
|
+
if (this.pausedOutputChunks.length === 0) return;
|
|
457
|
+
|
|
458
|
+
const chunks = this.pausedOutputChunks;
|
|
459
|
+
this.pausedOutputChunks = [];
|
|
460
|
+
this.emit("output", Buffer.concat(chunks));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private flushAllOutput(): void {
|
|
464
|
+
if (this.outputTimer) {
|
|
465
|
+
clearTimeout(this.outputTimer);
|
|
466
|
+
this.outputTimer = null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (this.outputChunks.length > 0) this.appendReplay(Buffer.concat(this.outputChunks));
|
|
470
|
+
const chunks = [...this.pausedOutputChunks, ...this.outputChunks];
|
|
471
|
+
this.pausedOutputChunks = [];
|
|
472
|
+
this.outputChunks = [];
|
|
473
|
+
if (chunks.length > 0) {
|
|
474
|
+
const bytes = Buffer.concat(chunks);
|
|
475
|
+
this.emit("output", bytes);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private appendReplay(bytes: Buffer): void {
|
|
480
|
+
if (this.ringBufferBytes <= 0 || bytes.byteLength === 0) return;
|
|
481
|
+
|
|
482
|
+
if (bytes.byteLength >= this.ringBufferBytes) {
|
|
483
|
+
const tail = bytes.subarray(bytes.byteLength - this.ringBufferBytes);
|
|
484
|
+
this.replayChunks = [Buffer.from(tail)];
|
|
485
|
+
this.replayBytes = tail.byteLength;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
this.replayChunks.push(Buffer.from(bytes));
|
|
490
|
+
this.replayBytes += bytes.byteLength;
|
|
491
|
+
|
|
492
|
+
while (this.replayBytes > this.ringBufferBytes && this.replayChunks.length > 0) {
|
|
493
|
+
const first = this.replayChunks[0]!;
|
|
494
|
+
const overflow = this.replayBytes - this.ringBufferBytes;
|
|
495
|
+
if (first.byteLength <= overflow) {
|
|
496
|
+
this.replayChunks.shift();
|
|
497
|
+
this.replayBytes -= first.byteLength;
|
|
498
|
+
} else {
|
|
499
|
+
this.replayChunks[0] = first.subarray(overflow);
|
|
500
|
+
this.replayBytes -= overflow;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private startExitPoll(child: PtyProcess): void {
|
|
506
|
+
this.exitPoll = setInterval(() => {
|
|
507
|
+
if (this.ptyProcess !== child) {
|
|
508
|
+
this.disposeListeners();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
process.kill(child.pid, 0);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
516
|
+
this.emitExit({ code: 0, signal: null });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}, 100);
|
|
520
|
+
this.exitPoll.unref?.();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private emitExit(exit: PtyExit): void {
|
|
524
|
+
if (!this.ptyProcess) return;
|
|
525
|
+
this.flushAllOutput();
|
|
526
|
+
this.disposeListeners();
|
|
527
|
+
this.ptyProcess = null;
|
|
528
|
+
this.lastCwd = null;
|
|
529
|
+
this.flushReplayBuffer();
|
|
530
|
+
this.emit("exit", exit);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { start } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
const { values } = parseArgs({
|
|
5
|
+
options: {
|
|
6
|
+
port: { type: "string" },
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const running = await start(values.port ? Number.parseInt(values.port, 10) : undefined);
|
|
11
|
+
|
|
12
|
+
const shutdown = async () => {
|
|
13
|
+
await running.close();
|
|
14
|
+
process.exit(0);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
process.on("SIGINT", () => void shutdown());
|
|
18
|
+
process.on("SIGTERM", () => void shutdown());
|