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.
Files changed (209) hide show
  1. package/bin/cli.js +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. package/scripts/postinstall.js +26 -2
@@ -0,0 +1,271 @@
1
+ /**
2
+ * NodePtyAdapter — concrete `PtyAdapter` backed by the `node-pty` native
3
+ * module (T087). The only place in the daemon that imports `node-pty`.
4
+ *
5
+ * One-time setup:
6
+ * - macOS / Linux: node-pty ships a `spawn-helper` binary. The
7
+ * `chmod +x` is occasionally lost during install (npm tarball
8
+ * metadata edge cases). Borrowing t3's
9
+ * `ensureNodePtySpawnHelperExecutable`, we walk the published candidate
10
+ * paths and chmod 0755 once per process. Failure is best-effort —
11
+ * spawning will surface a real error if it actually matters.
12
+ */
13
+
14
+ import { chmodSync, existsSync, statSync, type Stats } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { createRequire } from "node:module";
17
+ import * as pty from "node-pty";
18
+ import {
19
+ PtySpawnError,
20
+ type PtyAdapter,
21
+ type PtyExitEvent,
22
+ type PtyProcess,
23
+ type PtySpawnInput,
24
+ } from "./PtyAdapter.ts";
25
+
26
+ const ADAPTER_ID = "node-pty";
27
+ let helperEnsured = false;
28
+
29
+ function candidateSpawnHelperPaths(): string[] {
30
+ const requireForNodePty = createRequire(import.meta.url);
31
+ let pkgJsonPath: string;
32
+ try {
33
+ pkgJsonPath = requireForNodePty.resolve("node-pty/package.json");
34
+ } catch {
35
+ // node-pty package missing — `pty.spawn` will throw a richer error.
36
+ return [];
37
+ }
38
+ const pkgDir = dirname(pkgJsonPath);
39
+ return [
40
+ join(pkgDir, "build", "Release", "spawn-helper"),
41
+ join(pkgDir, "build", "Debug", "spawn-helper"),
42
+ join(pkgDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
43
+ ];
44
+ }
45
+
46
+ /**
47
+ * Chmod node-pty's `spawn-helper` to 0755 the first time we touch it.
48
+ * No-op on Windows (no helper binary). Idempotent across calls.
49
+ * Exported for the unit test that asserts the chmod side effect.
50
+ */
51
+ export function ensureNodePtySpawnHelperExecutable(
52
+ options: { explicitPath?: string; force?: boolean } = {},
53
+ ): void {
54
+ if (process.platform === "win32") return;
55
+ if (!options.force && !options.explicitPath && helperEnsured) return;
56
+
57
+ const candidates = options.explicitPath ? [options.explicitPath] : candidateSpawnHelperPaths();
58
+
59
+ for (const candidate of candidates) {
60
+ if (!existsSync(candidate)) continue;
61
+ try {
62
+ chmodSync(candidate, 0o755);
63
+ } catch {
64
+ // Best-effort: a hardened filesystem may forbid chmod even when the
65
+ // bit is already set. Continue to the next candidate so we never
66
+ // fail spawning just because the helper was already executable.
67
+ }
68
+ }
69
+
70
+ if (!options.explicitPath) helperEnsured = true;
71
+ }
72
+
73
+ function assertValidCwd(cwd: string, statFn: (cwd: string) => Stats): void {
74
+ let stats: Stats;
75
+ try {
76
+ stats = statFn(cwd);
77
+ } catch (err) {
78
+ throw new PtySpawnError({
79
+ adapter: ADAPTER_ID,
80
+ code: "cwd_invalid",
81
+ message: `cwd does not exist or cannot be stat'd: ${cwd}`,
82
+ cause: err,
83
+ });
84
+ }
85
+ if (!stats.isDirectory()) {
86
+ throw new PtySpawnError({
87
+ adapter: ADAPTER_ID,
88
+ code: "cwd_invalid",
89
+ message: `cwd is not a directory: ${cwd}`,
90
+ });
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Wrap a live `node-pty` IPty with the `PtyProcess` interface. Disposes are
96
+ * idempotent; `kill` emits a synthetic exit if node-pty doesn't fire one
97
+ * (e.g. when the child has already gone away under us).
98
+ */
99
+ class NodePtyProcess implements PtyProcess {
100
+ private exited = false;
101
+ private readonly child: pty.IPty;
102
+ private readonly dataListeners = new Set<(data: Buffer) => void>();
103
+ private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();
104
+
105
+ constructor(child: pty.IPty) {
106
+ this.child = child;
107
+ // node-pty hands us strings by default; we want raw buffers for
108
+ // byte-accurate WS bridging. The encoding:null on spawn opts gives us
109
+ // buffers in the typings of node-pty@1.2.0-beta.12.
110
+ this.child.onData((data) => {
111
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data), "utf8");
112
+ for (const listener of this.dataListeners) listener(buf);
113
+ });
114
+ this.child.onExit((evt) => {
115
+ this.exited = true;
116
+ const event: PtyExitEvent = {
117
+ exitCode: evt.exitCode ?? 0,
118
+ signal: typeof evt.signal === "number" ? evt.signal : null,
119
+ };
120
+ for (const listener of this.exitListeners) listener(event);
121
+ });
122
+ }
123
+
124
+ get pid(): number {
125
+ return this.child.pid;
126
+ }
127
+
128
+ write(data: string | Uint8Array): void {
129
+ if (this.exited) return;
130
+ if (typeof data === "string") this.child.write(data);
131
+ else this.child.write(Buffer.from(data).toString("binary"));
132
+ }
133
+
134
+ resize(cols: number, rows: number): void {
135
+ if (this.exited) return;
136
+ if (!Number.isInteger(cols) || cols <= 0)
137
+ throw new RangeError("cols must be a positive integer");
138
+ if (!Number.isInteger(rows) || rows <= 0)
139
+ throw new RangeError("rows must be a positive integer");
140
+ try {
141
+ this.child.resize(cols, rows);
142
+ } catch {
143
+ // node-pty throws if the underlying fd is gone — treat as no-op so
144
+ // bridge resize handlers don't have to special-case.
145
+ }
146
+ }
147
+
148
+ kill(signal?: NodeJS.Signals | number): void {
149
+ if (this.exited) return;
150
+ try {
151
+ this.child.kill(typeof signal === "number" ? String(signal) : signal);
152
+ } catch {
153
+ // Already gone. Synthesize an exit so listeners detach cleanly.
154
+ this.exited = true;
155
+ for (const listener of this.exitListeners) listener({ exitCode: 0, signal: null });
156
+ }
157
+ }
158
+
159
+ onData(callback: (data: Buffer) => void): () => void {
160
+ this.dataListeners.add(callback);
161
+ return () => {
162
+ this.dataListeners.delete(callback);
163
+ };
164
+ }
165
+
166
+ onExit(callback: (event: PtyExitEvent) => void): () => void {
167
+ if (this.exited) {
168
+ // Adapters MUST guarantee at most one terminal onExit — late
169
+ // subscribers get an inert disposer.
170
+ return () => undefined;
171
+ }
172
+ this.exitListeners.add(callback);
173
+ return () => {
174
+ this.exitListeners.delete(callback);
175
+ };
176
+ }
177
+ }
178
+
179
+ export interface NodePtyAdapterOptions {
180
+ /** Override node-pty's `spawn` for tests. */
181
+ spawnPty?: typeof pty.spawn;
182
+ /** Override `fs.statSync` for cwd validation in tests. */
183
+ statCwd?: (cwd: string) => Stats;
184
+ /** Skip the spawn-helper chmod (test isolation). */
185
+ skipHelperEnsure?: boolean;
186
+ }
187
+
188
+ export class NodePtyAdapter implements PtyAdapter {
189
+ readonly id = ADAPTER_ID;
190
+ private readonly spawnPty: typeof pty.spawn;
191
+ private readonly statCwd: (cwd: string) => Stats;
192
+ private readonly skipHelperEnsure: boolean;
193
+
194
+ constructor(options: NodePtyAdapterOptions = {}) {
195
+ this.spawnPty = options.spawnPty ?? pty.spawn;
196
+ this.statCwd = options.statCwd ?? statSync;
197
+ this.skipHelperEnsure = options.skipHelperEnsure ?? false;
198
+ }
199
+
200
+ async spawn(input: PtySpawnInput): Promise<PtyProcess> {
201
+ if (!this.skipHelperEnsure) ensureNodePtySpawnHelperExecutable();
202
+ return this.spawnSyncInternal(input);
203
+ }
204
+
205
+ spawnSync(input: PtySpawnInput): PtyProcess {
206
+ if (!this.skipHelperEnsure) ensureNodePtySpawnHelperExecutable();
207
+ return this.spawnSyncInternal(input);
208
+ }
209
+
210
+ private spawnSyncInternal(input: PtySpawnInput): PtyProcess {
211
+ assertValidCwd(input.cwd, this.statCwd);
212
+ if (!Number.isInteger(input.cols) || input.cols <= 0) {
213
+ throw new PtySpawnError({
214
+ adapter: ADAPTER_ID,
215
+ code: "unknown",
216
+ message: `cols must be a positive integer (got ${input.cols})`,
217
+ });
218
+ }
219
+ if (!Number.isInteger(input.rows) || input.rows <= 0) {
220
+ throw new PtySpawnError({
221
+ adapter: ADAPTER_ID,
222
+ code: "unknown",
223
+ message: `rows must be a positive integer (got ${input.rows})`,
224
+ });
225
+ }
226
+ // node-pty wants `Record<string, string>` for env — strip undefined values.
227
+ const env: Record<string, string> = {};
228
+ for (const [key, value] of Object.entries(input.env)) {
229
+ if (typeof value === "string") env[key] = value;
230
+ }
231
+ let child: pty.IPty;
232
+ try {
233
+ child = this.spawnPty(input.shell, [...(input.args ?? [])], {
234
+ name: input.name ?? "xterm-256color",
235
+ cols: input.cols,
236
+ rows: input.rows,
237
+ cwd: input.cwd,
238
+ env,
239
+ encoding: input.encoding === "utf8" ? "utf8" : (null as unknown as undefined),
240
+ });
241
+ } catch (err) {
242
+ const errno = (err as NodeJS.ErrnoException | undefined)?.code;
243
+ if (errno === "ENOENT") {
244
+ throw new PtySpawnError({
245
+ adapter: ADAPTER_ID,
246
+ code: "shell_not_found",
247
+ message: `shell not found in PATH: ${input.shell}`,
248
+ cause: err,
249
+ });
250
+ }
251
+ if (errno === "EACCES" || errno === "EPERM") {
252
+ throw new PtySpawnError({
253
+ adapter: ADAPTER_ID,
254
+ code: "permission_denied",
255
+ message: `permission denied spawning ${input.shell}`,
256
+ cause: err,
257
+ });
258
+ }
259
+ throw new PtySpawnError({
260
+ adapter: ADAPTER_ID,
261
+ code: "unknown",
262
+ message: `node-pty spawn failed: ${err instanceof Error ? err.message : String(err)}`,
263
+ cause: err,
264
+ });
265
+ }
266
+ return new NodePtyProcess(child);
267
+ }
268
+ }
269
+
270
+ /** Convenience singleton — most call sites just want `defaultNodePtyAdapter`. */
271
+ export const defaultNodePtyAdapter: PtyAdapter = new NodePtyAdapter();
@@ -0,0 +1,140 @@
1
+ /**
2
+ * PtyAdapter — terminal PTY adapter service contract (T087).
3
+ *
4
+ * Defines the process primitives required by terminal session management
5
+ * without binding to a specific PTY implementation. Mirrors t3's
6
+ * `apps/server/src/terminal/Services/PTY.ts` shape minus the Effect runtime
7
+ * (Effect adoption is G14-T07 territory; we stay on plain TS Promises here).
8
+ *
9
+ * Why:
10
+ * - `node-pty` is a native module whose `onData` callback never fires
11
+ * under Bun (T085 burned half a day on the diagnosis). Routing every
12
+ * daemon-side PTY spawn through this interface lets us swap runtimes
13
+ * without touching consumers and lets tests stub the entire layer with
14
+ * a `MockPtyAdapter` that returns scripted output.
15
+ * - The Electron preload + future remote-PTY transports will eventually
16
+ * ship their own adapters. Keeping the seam thin makes that drop-in.
17
+ *
18
+ * Synchronous vs async:
19
+ * - `spawn(input): Promise<PtyProcess>` is the canonical entry point.
20
+ * Async because some adapters do real setup (e.g. NodePtyAdapter chmods
21
+ * node-pty's `spawn-helper` binary the first time, t3 does the same).
22
+ * - `spawnSync(input): PtyProcess` is a sibling for legacy synchronous
23
+ * callers (today: `PtyBridge.spawn` in `server/pty-bridge.ts`). It MUST
24
+ * have the same observable semantics as `spawn` once any one-shot setup
25
+ * has been done. Adapters that genuinely need async work should throw
26
+ * `PtySpawnError` ("sync_unsupported") from `spawnSync` so the caller
27
+ * can fall back to the async path.
28
+ */
29
+
30
+ export interface PtySpawnInput {
31
+ /** Executable to run. Defaults are adapter-defined when omitted. */
32
+ shell: string;
33
+ /** Argv for the executable (without argv[0]). Optional; adapters may apply defaults. */
34
+ args?: ReadonlyArray<string>;
35
+ /** Working directory for the spawned process. Must exist + be a directory. */
36
+ cwd: string;
37
+ /** Initial cols. Must be a positive integer. */
38
+ cols: number;
39
+ /** Initial rows. Must be a positive integer. */
40
+ rows: number;
41
+ /** Environment vars handed to the child. */
42
+ env: NodeJS.ProcessEnv;
43
+ /**
44
+ * Terminal name (`$TERM`) handed to the child. Defaults to
45
+ * `xterm-256color` when omitted. Some adapters honour it, some pin it.
46
+ */
47
+ name?: string;
48
+ /**
49
+ * Output encoding. `null` (default) yields raw `Buffer` data — required
50
+ * for byte-accurate WebSocket bridging.
51
+ */
52
+ encoding?: "utf8" | null;
53
+ }
54
+
55
+ export interface PtyExitEvent {
56
+ /** Process exit code. `0` is clean shutdown. */
57
+ exitCode: number;
58
+ /** Terminating signal (numeric), or `null` when the process exited cleanly. */
59
+ signal: number | null;
60
+ }
61
+
62
+ /**
63
+ * Handle to a live PTY child returned by `PtyAdapter.spawn`. Modelled on
64
+ * t3's `PtyProcess` and `node-pty`'s `IPty` so wrapping is trivial.
65
+ *
66
+ * Listeners returned by `onData`/`onExit` are disposers — call them to
67
+ * detach. After `kill()` the adapter is expected to emit a single
68
+ * synthetic `onExit` event so the bridge layer can converge on shutdown.
69
+ */
70
+ export interface PtyProcess {
71
+ /** Underlying OS PID (or a synthetic positive integer for mocks). */
72
+ readonly pid: number;
73
+ /** Write raw bytes (string is interpreted as UTF-8) into the child stdin. */
74
+ write(data: string | Uint8Array): void;
75
+ /** Resize the controlling terminal. */
76
+ resize(cols: number, rows: number): void;
77
+ /**
78
+ * Send a signal to the child. Defaults to `SIGTERM` so adapters that
79
+ * can't deliver arbitrary signals (mocks, sandboxed remotes) still
80
+ * honour the common case.
81
+ */
82
+ kill(signal?: NodeJS.Signals | number): void;
83
+ /**
84
+ * Subscribe to data emissions. Returns a disposer; calling it detaches
85
+ * the listener without affecting any sibling subscribers.
86
+ */
87
+ onData(callback: (data: Buffer) => void): () => void;
88
+ /**
89
+ * Subscribe to exit. Returns a disposer. Adapters MUST guarantee at
90
+ * most one terminal `onExit` per process; resubscribing after exit is
91
+ * a no-op that returns an inert disposer.
92
+ */
93
+ onExit(callback: (event: PtyExitEvent) => void): () => void;
94
+ }
95
+
96
+ /** Discriminator codes for `PtySpawnError`. */
97
+ export type PtySpawnErrorCode =
98
+ | "cwd_invalid"
99
+ | "shell_not_found"
100
+ | "permission_denied"
101
+ | "sync_unsupported"
102
+ | "unknown";
103
+
104
+ /**
105
+ * Typed failure for `PtyAdapter.spawn` / `spawnSync`. Adapters MUST throw
106
+ * (or reject with) this class so the WS bridge can translate to a
107
+ * structured error frame without sniffing native error message strings.
108
+ */
109
+ export class PtySpawnError extends Error {
110
+ readonly adapter: string;
111
+ readonly code: PtySpawnErrorCode;
112
+ constructor(args: {
113
+ adapter: string;
114
+ code: PtySpawnErrorCode;
115
+ message: string;
116
+ cause?: unknown;
117
+ }) {
118
+ super(args.message, args.cause !== undefined ? { cause: args.cause } : undefined);
119
+ this.name = "PtySpawnError";
120
+ this.adapter = args.adapter;
121
+ this.code = args.code;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The adapter contract every PTY backend must satisfy. Async is the
127
+ * canonical surface; `spawnSync` is provided for the synchronous callers
128
+ * that exist today (PtyBridge / ws-route). New code should prefer `spawn`.
129
+ */
130
+ export interface PtyAdapter {
131
+ /** Human-readable adapter id, surfaced in `PtySpawnError.adapter`. */
132
+ readonly id: string;
133
+ /** Canonical async spawn. Resolves once the child PID is known. */
134
+ spawn(input: PtySpawnInput): Promise<PtyProcess>;
135
+ /**
136
+ * Synchronous spawn. Adapters that can't satisfy this (because they
137
+ * need async setup) MUST throw `PtySpawnError({ code: "sync_unsupported" })`.
138
+ */
139
+ spawnSync(input: PtySpawnInput): PtyProcess;
140
+ }
@@ -0,0 +1,92 @@
1
+ # `terminal/` — PTY adapter layer (T087)
2
+
3
+ The daemon's PTY surface is layered to keep `node-pty` (a native module
4
+ with sharp runtime edges) confined to one file. Everything above this
5
+ folder talks to the abstract `PtyAdapter` interface and never knows
6
+ whether the bytes came from a real OS PTY, a mock, or — eventually —
7
+ a remote transport.
8
+
9
+ ```
10
+ +------------------+ +------------------------+ +-------------+
11
+ | server/pty-bridge | ---> | PtyAdapter (iface) | ---> | node-pty |
12
+ | (ring buffer, | | | | (NodePty- |
13
+ | replay, WS | | spawn spawnSync | | Adapter) |
14
+ | bridge) | | PtyProcess primitives | +-------------+
15
+ +------------------+ +-------------+-----------+
16
+ |
17
+ v
18
+ +----------------+
19
+ | MockPtyAdapter |
20
+ | (tests) |
21
+ +----------------+
22
+ ```
23
+
24
+ ## Files
25
+
26
+ - `PtyAdapter.ts` — the contract: `PtySpawnInput`, `PtyExitEvent`,
27
+ `PtyProcess`, `PtyAdapter`, and the `PtySpawnError` taxonomy.
28
+ - `NodePtyAdapter.ts` — concrete implementation backed by `node-pty`.
29
+ Ports t3's `ensureNodePtySpawnHelperExecutable` chmod-on-helper trick
30
+ so a fresh install always boots.
31
+ - `__tests__/MockPtyAdapter.ts` — scripted PTY for the test suite. Lives
32
+ under `__tests__/` so production bundling never picks it up.
33
+ - `__tests__/*.test.ts` — contract tests parameterised over every
34
+ adapter, plus adapter-specific units.
35
+
36
+ ## Why this exists
37
+
38
+ `node-pty` is a native module compiled against an underlying runtime ABI.
39
+ T085 burned half a day finding out that under Bun, `node-pty`'s `onData`
40
+ callback never fires — the PTY spawns, the child exits, and zero bytes
41
+ flow back. The fix had to live in two places:
42
+
43
+ 1. **Runtime pinning** (T087 PART 1): every daemon spawn site uses
44
+ `node`/`tsx`, never `bun`. See `daemon-watchdog.ts`,
45
+ `tmux-bridge/src/monitor.ts`, `src/lib/tmux.ts`. The
46
+ `packages/daemon/package.json` `dev`/`start` scripts mirror t3's
47
+ `apps/server/package.json` (`tsx --watch` in dev, `node dist/...` in
48
+ prod).
49
+ 2. **Adapter abstraction** (T087 PART 2): a thin interface so the future
50
+ doesn't paint us into the same corner. New runtimes get their own
51
+ adapter; tests use `MockPtyAdapter` so we never accidentally spawn a
52
+ real PTY in CI.
53
+
54
+ ## When to add a new adapter
55
+
56
+ Anything that produces PTY-shaped bytes is fair game. Concrete examples
57
+ we've considered:
58
+
59
+ - A **remote PTY** adapter that proxies over WebSocket to a daemon
60
+ running on another host. Drops in here without touching `pty-bridge`.
61
+ - A **mock SSH** adapter for integration tests that need a deterministic
62
+ remote shell. Built on `MockPtyAdapter` semantics.
63
+ - An **xterm replay** adapter that pipes a captured `.cast` file back
64
+ through `onData` for demos. Useful for marketing GIFs.
65
+
66
+ Each new adapter MUST satisfy `PtyAdapter.contract.test.ts` — the
67
+ parameterised suite runs every contract assertion against every adapter
68
+ we register there.
69
+
70
+ ## Constraints we honour
71
+
72
+ - `spawn` is async; `spawnSync` exists for legacy callers (today:
73
+ `server/pty-bridge.ts`). Adapters that can't satisfy `spawnSync` throw
74
+ `PtySpawnError({ code: "sync_unsupported" })`.
75
+ - Errors are typed (`PtySpawnError` with a discriminator) so the WS
76
+ bridge can surface a structured error frame instead of grepping native
77
+ messages.
78
+ - `kill()` is idempotent. After it the adapter MUST guarantee at most
79
+ one terminal `onExit` event — subsequent `onExit(...)` subscribers
80
+ receive an inert disposer.
81
+ - No production code outside `NodePtyAdapter.ts` imports `node-pty`.
82
+ This is enforced by `pty-bridge.ts` now consuming `PtyAdapter` and is
83
+ the gate G14-T10 will tighten further.
84
+
85
+ ## Roadmap notes
86
+
87
+ - **G14-T07** introduces Effect runtime. When that lands, this contract
88
+ grows an `Effect.Effect<PtyProcess, PtySpawnError>` shape; the plain-
89
+ TS interface here stays so adapters can implement either side.
90
+ - **G14-T10** moves chat reactor → Effect Stream. PTY bytes will flow
91
+ through the same Stream pipeline; today's `EventEmitter`-based
92
+ `pty-bridge` becomes the bottom of that Stream.