termdock 1.3.1 → 1.4.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 (89) hide show
  1. package/dist/client/assets/{abnfDiagram-VRR7QNED-VDfhcxCs.js → abnfDiagram-VRR7QNED-C7YJqmLC.js} +1 -1
  2. package/dist/client/assets/{arc-CK3Ni_GA.js → arc-B07DueP1.js} +1 -1
  3. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-QKnogVlc.js → architectureDiagram-ZJ3FMSHR-DRKN8EGo.js} +1 -1
  4. package/dist/client/assets/{blockDiagram-677ZJIJ3-B6KYTMUm.js → blockDiagram-677ZJIJ3-BwR-JbpT.js} +1 -1
  5. package/dist/client/assets/{c4Diagram-LMCZKHZV-CempTpbh.js → c4Diagram-LMCZKHZV-DyRja9hI.js} +1 -1
  6. package/dist/client/assets/channel-DMZ6Nll6.js +1 -0
  7. package/dist/client/assets/{chunk-2Q5K7J3B-BIfNQQhi.js → chunk-2Q5K7J3B-Cl0vEDWK.js} +1 -1
  8. package/dist/client/assets/{chunk-32BRIVSS-BjGUsxBo.js → chunk-32BRIVSS-D-cTwhqE.js} +1 -1
  9. package/dist/client/assets/{chunk-5VM5RSS4-YOIRo4TG.js → chunk-5VM5RSS4-D0eRXduK.js} +1 -1
  10. package/dist/client/assets/{chunk-EX3LRPZG-DYok0zQS.js → chunk-EX3LRPZG-J_Nw8oeX.js} +1 -1
  11. package/dist/client/assets/{chunk-JWPE2WC7-jJpFBvnD.js → chunk-JWPE2WC7-D73gFLxs.js} +1 -1
  12. package/dist/client/assets/{chunk-MOJQB5TN-Cfv8l9NT.js → chunk-MOJQB5TN-DZtboEtR.js} +1 -1
  13. package/dist/client/assets/{chunk-RYQCIY6F-DezUuSQG.js → chunk-RYQCIY6F-D7BwVcOs.js} +1 -1
  14. package/dist/client/assets/{chunk-V7JOEXUC-DubthR3B.js → chunk-V7JOEXUC-B63BJcBW.js} +1 -1
  15. package/dist/client/assets/{chunk-VR4S4FIN-Br17FuD9.js → chunk-VR4S4FIN-C502LPuE.js} +1 -1
  16. package/dist/client/assets/{chunk-XXDRQBXY-BEp2DDT5.js → chunk-XXDRQBXY-C9puam3W.js} +1 -1
  17. package/dist/client/assets/classDiagram-OUVF2IWQ-C7oNngQj.js +1 -0
  18. package/dist/client/assets/classDiagram-v2-EOCWNBFH-C7oNngQj.js +1 -0
  19. package/dist/client/assets/{cose-bilkent-JH36ORCC-De1yyf-M.js → cose-bilkent-JH36ORCC-CZ24meVw.js} +1 -1
  20. package/dist/client/assets/{cynefin-VYW2F7L2-DESao676.js → cynefin-VYW2F7L2-Y2fr06hq.js} +1 -1
  21. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHRUq34L.js → cynefinDiagram-TSTJHNR4-OF_nmz5v.js} +1 -1
  22. package/dist/client/assets/{dagre-VKFMJZFB-q7dQXBcZ.js → dagre-VKFMJZFB-svS4Qj1B.js} +1 -1
  23. package/dist/client/assets/{diagram-FQU43EPY-DK8KUQgs.js → diagram-FQU43EPY-Bim50nBZ.js} +1 -1
  24. package/dist/client/assets/{diagram-G47NLZAW-CttMqM35.js → diagram-G47NLZAW-BDGzIPU6.js} +1 -1
  25. package/dist/client/assets/{diagram-NH7WQ7WH-DdPuemYp.js → diagram-NH7WQ7WH-DP8vmwhI.js} +1 -1
  26. package/dist/client/assets/{diagram-OA4YK3LP-BbRCM1am.js → diagram-OA4YK3LP-CVwGpEzy.js} +1 -1
  27. package/dist/client/assets/{diagram-WEI45ONY-Bsx5Tgzq.js → diagram-WEI45ONY-nS0Aj3Pp.js} +1 -1
  28. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B_t6Kh6T.js → ebnfDiagram-CCIWWBDH-BogvM--v.js} +1 -1
  29. package/dist/client/assets/{erDiagram-Q63AITRT-C9R1g3WF.js → erDiagram-Q63AITRT-pBjz5SjG.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-23GEKE2U-Cb9bCkNG.js → flowDiagram-23GEKE2U-BINUCOud.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-NO4QXBWP-BiDqptF0.js → ganttDiagram-NO4QXBWP-DITnKqUR.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Bb5ly04l.js → gitGraphDiagram-IHSO6WYX-C_C74_pU.js} +1 -1
  33. package/dist/client/assets/index-DgG9nafK.css +1 -0
  34. package/dist/client/assets/index-VDGjYWHR.js +408 -0
  35. package/dist/client/assets/{infoDiagram-FWYZ7A6U-CE_9WW2K.js → infoDiagram-FWYZ7A6U-D4WLtM02.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-D6Xq4-fM.js → ishikawaDiagram-FXEZZL3T-tgP7oAl9.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BR9hIye8.js → journeyDiagram-5HDEW3XC-sK0S0EXp.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-CuCbS_Ki.js → kanban-definition-HUTT4EX6-BZVK0aeo.js} +1 -1
  39. package/dist/client/assets/{linear-SWu8Le4S.js → linear-CHubENPf.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-BBUMzzeH.js → mermaid.core-2dLzSZxi.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-CQ0baFfF.js → mindmap-definition-LN4V7U3C-C28O3edf.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-ttatanL5.js → pegDiagram-2B236MQR-DaTkErYV.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-CPfxzHzu.js → pieDiagram-ENE6RG2P-CoUX2zjE.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BXaRswdp.js → quadrantDiagram-ABIIQ3AL-DdidrEKm.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-DrjoYMhE.js → railroadDiagram-RFXS5EU6-B4ua4pek.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-DhVxD_8O.js → requirementDiagram-TGXJPOKE-C7GkSVWe.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-COK6i599.js → sankeyDiagram-HTMAVEWB-D2qKgrSf.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PVFMoTPN.js → sequenceDiagram-DBY2YBRQ-BKpN5Rg1.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-DOVi8Y5x.js → sizeCapture-X5ZJPWSS-CdOyl0Mu.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC-Bn0vvGOe.js → stateDiagram-2N3HPSRC-Pig7diHZ.js} +1 -1
  51. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DaioXNnb.js +1 -0
  52. package/dist/client/assets/{swimlanes-5IMT3BWC-D5QZ4K6L.js → swimlanes-5IMT3BWC-PS6bSiiJ.js} +2 -2
  53. package/dist/client/assets/swimlanesDiagram-G3AALYLV-B9vE1I0i.js +8 -0
  54. package/dist/client/assets/{timeline-definition-FHXFAJF6-DUhdUNzM.js → timeline-definition-FHXFAJF6-BjVebJ90.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-p95xTZY4.js → vennDiagram-L72KCM5P-BtjZHsBe.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-DjIHqD-A.js → wardleyDiagram-EHGQE667-DmmuPtgr.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-DMeL47EZ.js → xychartDiagram-FW5EYKEG-CPh2RG2Y.js} +1 -1
  58. package/dist/client/icons/agents/amp.svg +12 -0
  59. package/dist/client/icons/agents/claude.svg +10 -0
  60. package/dist/client/icons/agents/codex.svg +10 -0
  61. package/dist/client/icons/agents/copilot.svg +12 -0
  62. package/dist/client/icons/agents/cursor.svg +3 -0
  63. package/dist/client/icons/agents/droid.svg +10 -0
  64. package/dist/client/icons/agents/gemini.svg +5 -0
  65. package/dist/client/icons/agents/goose.svg +10 -0
  66. package/dist/client/icons/agents/grok.svg +5 -0
  67. package/dist/client/icons/agents/opencode.svg +3 -0
  68. package/dist/client/index.html +2 -2
  69. package/dist/client/sw.js +1 -1
  70. package/dist/server/agent/installers.js +742 -0
  71. package/dist/server/agent/plugins.js +271 -0
  72. package/dist/server/agent/registry.js +306 -0
  73. package/dist/server/agent/session.js +162 -0
  74. package/dist/server/agentHook.js +174 -0
  75. package/dist/server/cli.js +122 -2
  76. package/dist/server/ptyHost.js +261 -0
  77. package/dist/server/ptyhost/manager.js +396 -0
  78. package/dist/server/ptyhost/protocol.js +80 -0
  79. package/dist/server/routes/filesystem.js +10 -8
  80. package/dist/server/routes/terminal.js +798 -390
  81. package/dist/server/utils/gitStatus.js +114 -0
  82. package/package.json +1 -1
  83. package/dist/client/assets/channel-B_qG4XEr.js +0 -1
  84. package/dist/client/assets/classDiagram-OUVF2IWQ-DsBZBOre.js +0 -1
  85. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DsBZBOre.js +0 -1
  86. package/dist/client/assets/index-BeJrvXOJ.js +0 -394
  87. package/dist/client/assets/index-iK4yVqAA.css +0 -1
  88. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D_c5gxfu.js +0 -1
  89. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DsiBwV8W.js +0 -8
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * termdock pty-host daemon — owns shell-mode PTYs outside the server process,
4
+ * so sessions survive server restarts/crashes/deploys. (架构移植自 tty7 的
5
+ * persistent daemon,Apache-2.0;单进程多 channel 复用版)
6
+ *
7
+ * The server auto-spawns this process detached and talks to it over a
8
+ * Unix-domain socket (see ptyhost/protocol.ts). One host holds *all* of one
9
+ * server instance's shell sessions (channels), keeping a bounded replay ring
10
+ * per channel so a reattaching server rebuilds scrollback + OSC state.
11
+ *
12
+ * Standalone entry: keep imports dependency-light (node-pty + protocol only).
13
+ */
14
+ import net from 'node:net';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { FRAME_CONTROL, FRAME_DATA, FRAME_INPUT, FRAME_REPLAY, PTY_HOST_PROTOCOL_VERSION, FrameDecoder, encodeControl, encodeDataFrame, } from './ptyhost/protocol.js';
18
+ /** Bounded per-channel replay ring, in chars. */
19
+ const RING_CAP_CHARS = 256 * 1024;
20
+ const channels = new Map();
21
+ let serverConn = null;
22
+ function socketPathFromArgs() {
23
+ const idx = process.argv.indexOf('--socket');
24
+ const p = idx >= 0 ? process.argv[idx + 1] : undefined;
25
+ if (!p) {
26
+ console.error('[pty-host] missing --socket <path>');
27
+ process.exit(2);
28
+ }
29
+ return p;
30
+ }
31
+ const SOCKET_PATH = socketPathFromArgs();
32
+ function send(msg) {
33
+ if (serverConn && !serverConn.destroyed) {
34
+ serverConn.write(encodeControl(msg));
35
+ }
36
+ }
37
+ function sendFrame(type, id, data) {
38
+ if (serverConn && !serverConn.destroyed) {
39
+ serverConn.write(encodeDataFrame(type, id, data));
40
+ }
41
+ }
42
+ function channelMeta(ch) {
43
+ return {
44
+ id: ch.id,
45
+ shell: ch.spec.shell,
46
+ cwd: ch.spec.cwd,
47
+ cols: ch.spec.cols,
48
+ rows: ch.spec.rows,
49
+ pid: ch.pty.pid ?? null,
50
+ startedAt: ch.startedAt,
51
+ exited: ch.exited,
52
+ exitCode: ch.exitCode,
53
+ offset: ch.offset,
54
+ };
55
+ }
56
+ function appendToRing(ch, data) {
57
+ const start = ch.offset;
58
+ ch.offset += data.length;
59
+ ch.ring.push({ data, start });
60
+ ch.ringChars += data.length;
61
+ while (ch.ringChars > RING_CAP_CHARS && ch.ring.length > 1) {
62
+ const dropped = ch.ring.shift();
63
+ if (dropped)
64
+ ch.ringChars -= dropped.data.length;
65
+ }
66
+ }
67
+ async function loadNodePty() {
68
+ return await import('node-pty');
69
+ }
70
+ function spawnChannel(id, spec) {
71
+ if (channels.has(id)) {
72
+ send({ op: 'spawned', id, ok: false, error: 'channel id already exists', offset: 0 });
73
+ return;
74
+ }
75
+ void loadNodePty().then((pty) => {
76
+ let proc;
77
+ try {
78
+ proc = pty.spawn(spec.shell, spec.args, {
79
+ name: spec.termName,
80
+ cols: spec.cols,
81
+ rows: spec.rows,
82
+ cwd: spec.cwd,
83
+ env: spec.env,
84
+ });
85
+ }
86
+ catch (error) {
87
+ send({ op: 'spawned', id, ok: false, error: error.message, offset: 0 });
88
+ return;
89
+ }
90
+ const ch = {
91
+ id,
92
+ spec,
93
+ pty: proc,
94
+ ring: [],
95
+ ringChars: 0,
96
+ offset: 0,
97
+ startedAt: Date.now(),
98
+ exited: false,
99
+ exitCode: null,
100
+ exitSignal: null,
101
+ };
102
+ channels.set(id, ch);
103
+ proc.onData((data) => {
104
+ appendToRing(ch, data);
105
+ sendFrame(FRAME_DATA, id, data);
106
+ });
107
+ proc.onExit(({ exitCode, signal }) => {
108
+ ch.exited = true;
109
+ ch.exitCode = exitCode;
110
+ ch.exitSignal = signal ?? null;
111
+ send({ op: 'exit', id, exitCode, signal: signal ?? null, offset: ch.offset });
112
+ // The channel's ring is the scrollback a reattaching server would want;
113
+ // without a server there's no one to reattach, so exit the host when
114
+ // nothing is left running.
115
+ channels.delete(id);
116
+ if (channels.size === 0 && !serverConn)
117
+ shutdown();
118
+ });
119
+ send({ op: 'spawned', id, ok: true, pid: proc.pid, offset: 0 });
120
+ }).catch((error) => {
121
+ send({ op: 'spawned', id, ok: false, error: error.message, offset: 0 });
122
+ });
123
+ }
124
+ function attachChannel(id, since) {
125
+ const ch = channels.get(id);
126
+ if (!ch) {
127
+ send({ op: 'attached', id, ok: false, error: 'no such channel' });
128
+ return;
129
+ }
130
+ send({ op: 'attached', id, ok: true });
131
+ // Replay ring chunks newer than `since` (char offsets, absolute).
132
+ for (const chunk of ch.ring) {
133
+ if (chunk.start + chunk.data.length <= since)
134
+ continue;
135
+ const sliceStart = Math.max(0, since - chunk.start);
136
+ sendFrame(FRAME_REPLAY, id, chunk.data.slice(sliceStart));
137
+ }
138
+ send({ op: 'replayEnd', id, offset: ch.offset });
139
+ }
140
+ function handleControl(msg) {
141
+ switch (msg.op) {
142
+ case 'hello':
143
+ send({ op: 'helloAck', v: PTY_HOST_PROTOCOL_VERSION, channels: [...channels.values()].map(channelMeta) });
144
+ return;
145
+ case 'spawn':
146
+ spawnChannel(msg.id, msg.spec);
147
+ return;
148
+ case 'attach':
149
+ attachChannel(msg.id, msg.since);
150
+ return;
151
+ case 'resize': {
152
+ const ch = channels.get(msg.id);
153
+ if (ch && !ch.exited) {
154
+ ch.spec.cols = msg.cols;
155
+ ch.spec.rows = msg.rows;
156
+ try {
157
+ ch.pty.resize(msg.cols, msg.rows);
158
+ }
159
+ catch { /* pty raced exit */ }
160
+ }
161
+ return;
162
+ }
163
+ case 'kill': {
164
+ const ch = channels.get(msg.id);
165
+ if (ch && !ch.exited) {
166
+ try {
167
+ ch.pty.kill();
168
+ }
169
+ catch { /* already gone */ }
170
+ }
171
+ return;
172
+ }
173
+ case 'pause': {
174
+ const ch = channels.get(msg.id);
175
+ if (ch && !ch.exited) {
176
+ try {
177
+ ch.pty.pause?.();
178
+ }
179
+ catch { /* optional capability */ }
180
+ }
181
+ return;
182
+ }
183
+ case 'resume': {
184
+ const ch = channels.get(msg.id);
185
+ if (ch && !ch.exited) {
186
+ try {
187
+ ch.pty.resume?.();
188
+ }
189
+ catch { /* optional capability */ }
190
+ }
191
+ return;
192
+ }
193
+ default:
194
+ // attached/spawned/exit/replayEnd/pong are host→server; ignore here.
195
+ return;
196
+ }
197
+ }
198
+ function onServerConnection(conn) {
199
+ // Single client (the server). A second connection means the server
200
+ // restarted its socket; drop the old one.
201
+ if (serverConn && !serverConn.destroyed) {
202
+ serverConn.destroy();
203
+ }
204
+ serverConn = conn;
205
+ const decoder = new FrameDecoder();
206
+ conn.on('data', (chunk) => {
207
+ for (const frame of decoder.feed(chunk)) {
208
+ try {
209
+ if (frame.type === FRAME_CONTROL && frame.control) {
210
+ handleControl(frame.control);
211
+ }
212
+ else if (frame.type === FRAME_INPUT && frame.id && frame.data !== null) {
213
+ const ch = channels.get(frame.id);
214
+ if (ch && !ch.exited) {
215
+ try {
216
+ ch.pty.write(frame.data);
217
+ }
218
+ catch { /* pty raced exit */ }
219
+ }
220
+ }
221
+ }
222
+ catch (error) {
223
+ console.error('[pty-host] frame handling error:', error.message);
224
+ }
225
+ }
226
+ });
227
+ conn.on('close', () => {
228
+ if (serverConn === conn)
229
+ serverConn = null;
230
+ });
231
+ conn.on('error', () => { });
232
+ }
233
+ function shutdown() {
234
+ try {
235
+ fs.unlinkSync(SOCKET_PATH);
236
+ }
237
+ catch { /* already gone */ }
238
+ process.exit(0);
239
+ }
240
+ function main() {
241
+ fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true, mode: 0o700 });
242
+ // Stale socket from a crashed host: only our own pid could own it, and we
243
+ // just started — remove and re-listen.
244
+ try {
245
+ fs.unlinkSync(SOCKET_PATH);
246
+ }
247
+ catch { /* not present */ }
248
+ const server = net.createServer(onServerConnection);
249
+ server.listen(SOCKET_PATH, () => {
250
+ try {
251
+ fs.chmodSync(SOCKET_PATH, 0o600);
252
+ }
253
+ catch { /* best effort */ }
254
+ console.log(`[pty-host] listening on ${SOCKET_PATH} (pid ${process.pid})`);
255
+ });
256
+ // The host is meant to run forever; a dropped server connection is normal
257
+ // (server restart) and channels outlive it.
258
+ process.on('SIGTERM', () => shutdown());
259
+ process.on('SIGINT', () => shutdown());
260
+ }
261
+ main();
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Server-side client of the pty-host daemon: connection lifecycle (auto-spawn,
3
+ * reconnect with delta replay) and `PtyHostClient`, a drop-in `PtyProcess`
4
+ * whose PTY lives in the host process. (架构移植自 tty7 spawn.rs + remote.rs)
5
+ *
6
+ * Shell-mode sessions spawned through this manager survive server restarts:
7
+ * the host holds the PTYs and a replay ring; on startup the server adopts the
8
+ * live channels and rebuilds history/OSC state from the ring.
9
+ */
10
+ import net from 'node:net';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+ import { spawn } from 'node:child_process';
15
+ import { FRAME_CONTROL, FRAME_DATA, FRAME_INPUT, FRAME_REPLAY, PTY_HOST_PROTOCOL_VERSION, FrameDecoder, encodeControl, encodeDataFrame, } from './protocol.js';
16
+ /** How long to wait for a freshly spawned host to start listening. */
17
+ const HOST_STARTUP_TIMEOUT_MS = 5_000;
18
+ const HOST_STARTUP_POLL_MS = 50;
19
+ const RECONNECT_DELAY_MS = 500;
20
+ function hostSocketDir() {
21
+ return path.join(os.homedir(), '.termdock', 'pty-hosts');
22
+ }
23
+ /**
24
+ * One host per server instance: dev and prod servers get separate daemons
25
+ * (and separate session sets) by keying the socket on the server port.
26
+ */
27
+ export function ptyHostSocketPath() {
28
+ const key = process.env.TERMDOCK_PTY_HOST_KEY ?? `port-${process.env.PORT ?? '9834'}`;
29
+ return path.join(hostSocketDir(), `${key}.sock`);
30
+ }
31
+ /** The built host entry. Under tsx dev only the .ts source exists — spawn it
32
+ * through tsx in that case. */
33
+ function resolveHostEntry() {
34
+ const here = path.dirname(new URL(import.meta.url).pathname);
35
+ const js = path.resolve(here, '..', 'ptyHost.js');
36
+ if (fs.existsSync(js)) {
37
+ return { command: process.execPath, args: [js, '--socket', ptyHostSocketPath()] };
38
+ }
39
+ const ts = path.resolve(here, '..', 'ptyHost.ts');
40
+ // Dev: run through tsx (available as a devDependency).
41
+ return { command: process.execPath, args: ['--import', 'tsx', ts, '--socket', ptyHostSocketPath()] };
42
+ }
43
+ /**
44
+ * A `PtyProcess` whose PTY lives in the host. Emits live data via onData;
45
+ * the *initial* attach's replay is emitted via onReplay (history rebuild),
46
+ * while reconnect replays (genuinely missed output) go to onData.
47
+ */
48
+ export class PtyHostClient {
49
+ pid;
50
+ /** Char offset of host output seen so far (drives delta re-attach). */
51
+ seenOffset = 0;
52
+ manager;
53
+ channelId;
54
+ dataHandlers = new Set();
55
+ replayHandlers = new Set();
56
+ exitHandlers = new Set();
57
+ hasAttachedOnce = false;
58
+ exited = false;
59
+ constructor(manager, channelId) {
60
+ this.manager = manager;
61
+ this.channelId = channelId;
62
+ }
63
+ get id() {
64
+ return this.channelId;
65
+ }
66
+ onData(handler) {
67
+ this.dataHandlers.add(handler);
68
+ return { dispose: () => this.dataHandlers.delete(handler) };
69
+ }
70
+ onReplay(handler) {
71
+ this.replayHandlers.add(handler);
72
+ return { dispose: () => this.replayHandlers.delete(handler) };
73
+ }
74
+ onExit(handler) {
75
+ this.exitHandlers.add(handler);
76
+ return { dispose: () => this.exitHandlers.delete(handler) };
77
+ }
78
+ write(data) {
79
+ if (this.exited)
80
+ return;
81
+ this.manager.sendRaw(encodeDataFrame(FRAME_INPUT, this.channelId, data));
82
+ }
83
+ resize(cols, rows) {
84
+ if (this.exited)
85
+ return;
86
+ this.manager.send({ op: 'resize', id: this.channelId, cols, rows });
87
+ }
88
+ kill() {
89
+ if (this.exited)
90
+ return;
91
+ this.manager.send({ op: 'kill', id: this.channelId });
92
+ }
93
+ pause() {
94
+ if (this.exited)
95
+ return;
96
+ this.manager.send({ op: 'pause', id: this.channelId });
97
+ }
98
+ resume() {
99
+ if (this.exited)
100
+ return;
101
+ this.manager.send({ op: 'resume', id: this.channelId });
102
+ }
103
+ // -- manager-facing frame handlers --
104
+ handleLiveData(data) {
105
+ this.seenOffset += data.length;
106
+ for (const h of this.dataHandlers)
107
+ h(data);
108
+ }
109
+ handleReplayData(data) {
110
+ this.seenOffset += data.length;
111
+ if (this.hasAttachedOnce) {
112
+ // Reconnect delta: genuinely missed output — normal path.
113
+ for (const h of this.dataHandlers)
114
+ h(data);
115
+ }
116
+ else {
117
+ // Initial adoption replay: history rebuild only.
118
+ for (const h of this.replayHandlers)
119
+ h(data);
120
+ }
121
+ }
122
+ handleReplayEnd(offset) {
123
+ this.seenOffset = Math.max(this.seenOffset, offset);
124
+ this.hasAttachedOnce = true;
125
+ }
126
+ handleExit(exitCode, signal, offset) {
127
+ if (this.exited)
128
+ return;
129
+ this.exited = true;
130
+ this.seenOffset = Math.max(this.seenOffset, offset);
131
+ for (const h of this.exitHandlers)
132
+ h({ exitCode, signal });
133
+ }
134
+ get isExited() {
135
+ return this.exited;
136
+ }
137
+ }
138
+ export class PtyHostManager {
139
+ conn = null;
140
+ decoder = new FrameDecoder();
141
+ clients = new Map();
142
+ connecting = null;
143
+ helloResolve = null;
144
+ pendingSpawns = new Map();
145
+ reconnectTimer = null;
146
+ stopped = false;
147
+ /** Resolve when the host is connected + handshaken; returns live channels. */
148
+ ensureConnected() {
149
+ if (this.connecting)
150
+ return this.connecting;
151
+ this.connecting = this.connectWithSpawnRetry()
152
+ .finally(() => { this.connecting = null; });
153
+ return this.connecting;
154
+ }
155
+ async connectWithSpawnRetry() {
156
+ try {
157
+ return await this.connectOnce();
158
+ }
159
+ catch (error) {
160
+ if (error.code !== 'ENOENT'
161
+ && error.code !== 'ECONNREFUSED') {
162
+ throw error;
163
+ }
164
+ }
165
+ this.spawnHostDaemon();
166
+ const deadline = Date.now() + HOST_STARTUP_TIMEOUT_MS;
167
+ let lastError = null;
168
+ while (Date.now() < deadline) {
169
+ await new Promise((r) => setTimeout(r, HOST_STARTUP_POLL_MS));
170
+ try {
171
+ return await this.connectOnce();
172
+ }
173
+ catch (error) {
174
+ lastError = error;
175
+ }
176
+ }
177
+ throw new Error(`pty-host did not start listening in time: ${lastError?.message ?? lastError}`);
178
+ }
179
+ spawnHostDaemon() {
180
+ const { command, args } = resolveHostEntry();
181
+ fs.mkdirSync(hostSocketDir(), { recursive: true, mode: 0o700 });
182
+ // Detached + own session + ignored stdio + never waited on: the host
183
+ // must not die with (or be reaped by) this server.
184
+ const child = spawn(command, args, {
185
+ detached: true,
186
+ stdio: 'ignore',
187
+ env: process.env,
188
+ });
189
+ child.unref();
190
+ console.log(`[pty-host] spawned host daemon (pid ${child.pid})`);
191
+ }
192
+ connectOnce() {
193
+ return new Promise((resolvePromise, rejectPromise) => {
194
+ const conn = net.createConnection(ptyHostSocketPath());
195
+ const onError = (error) => {
196
+ conn.destroy();
197
+ rejectPromise(error);
198
+ };
199
+ conn.once('error', onError);
200
+ conn.once('connect', () => {
201
+ conn.removeListener('error', onError);
202
+ this.attachConnection(conn);
203
+ this.helloResolve = resolvePromise;
204
+ conn.write(encodeControl({ op: 'hello', v: PTY_HOST_PROTOCOL_VERSION }));
205
+ });
206
+ });
207
+ }
208
+ attachConnection(conn) {
209
+ if (this.conn && !this.conn.destroyed)
210
+ this.conn.destroy();
211
+ this.conn = conn;
212
+ this.decoder = new FrameDecoder();
213
+ conn.on('data', (chunk) => {
214
+ for (const frame of this.decoder.feed(chunk)) {
215
+ try {
216
+ this.handleFrame(frame.type, frame.id, frame.data, frame.control);
217
+ }
218
+ catch (error) {
219
+ console.warn('[pty-host] frame handling error:', error.message);
220
+ }
221
+ }
222
+ });
223
+ conn.on('close', () => {
224
+ if (this.conn !== conn)
225
+ return;
226
+ this.conn = null;
227
+ this.handleDisconnect();
228
+ });
229
+ conn.on('error', () => { });
230
+ }
231
+ handleFrame(type, id, data, control) {
232
+ if (type === FRAME_DATA && id && data !== null) {
233
+ this.clients.get(id)?.handleLiveData(data);
234
+ return;
235
+ }
236
+ if (type === FRAME_REPLAY && id && data !== null) {
237
+ this.clients.get(id)?.handleReplayData(data);
238
+ return;
239
+ }
240
+ if (type !== FRAME_CONTROL || !control)
241
+ return;
242
+ switch (control.op) {
243
+ case 'helloAck': {
244
+ if (control.v !== PTY_HOST_PROTOCOL_VERSION) {
245
+ console.warn(`[pty-host] protocol mismatch: host v${control.v}, server v${PTY_HOST_PROTOCOL_VERSION} — continuing best-effort`);
246
+ }
247
+ this.helloResolve?.(control.channels);
248
+ this.helloResolve = null;
249
+ return;
250
+ }
251
+ case 'spawned': {
252
+ const pending = this.pendingSpawns.get(control.id);
253
+ this.pendingSpawns.delete(control.id);
254
+ if (!pending)
255
+ return;
256
+ if (control.ok) {
257
+ pending.resolve({ pid: control.pid, offset: control.offset });
258
+ }
259
+ else {
260
+ pending.reject(new Error(control.error ?? 'pty-host spawn failed'));
261
+ }
262
+ return;
263
+ }
264
+ case 'replayEnd':
265
+ this.clients.get(control.id)?.handleReplayEnd(control.offset);
266
+ return;
267
+ case 'exit': {
268
+ const client = this.clients.get(control.id);
269
+ if (client) {
270
+ client.handleExit(control.exitCode, control.signal, control.offset);
271
+ }
272
+ return;
273
+ }
274
+ default:
275
+ return;
276
+ }
277
+ }
278
+ handleDisconnect() {
279
+ this.helloResolve?.([]);
280
+ this.helloResolve = null;
281
+ for (const pending of this.pendingSpawns.values()) {
282
+ pending.reject(new Error('pty-host connection dropped'));
283
+ }
284
+ this.pendingSpawns.clear();
285
+ if (this.stopped)
286
+ return;
287
+ if (this.clients.size === 0)
288
+ return; // nothing to lose; reconnect lazily
289
+ // The host may have died (taking every PTY with it) or be mid-restart
290
+ // itself. Probe: quick reconnect; if the socket is gone for good, declare
291
+ // the channels dead so sessions clean up like a PTY exit today.
292
+ const probe = async () => {
293
+ try {
294
+ const channels = await this.ensureConnected();
295
+ const live = new Set(channels.map((c) => c.id));
296
+ for (const [id, client] of this.clients) {
297
+ if (client.isExited)
298
+ continue;
299
+ if (live.has(id)) {
300
+ // Re-attach with delta replay for the gap.
301
+ this.send({ op: 'attach', id, since: client.seenOffset });
302
+ }
303
+ else {
304
+ // Channel vanished with the host: surface as an exit.
305
+ client.handleExit(-1, null, client.seenOffset);
306
+ }
307
+ }
308
+ }
309
+ catch {
310
+ // Host is down for good: all live channels died with it.
311
+ for (const client of this.clients.values()) {
312
+ if (!client.isExited)
313
+ client.handleExit(-1, null, client.seenOffset);
314
+ }
315
+ }
316
+ };
317
+ if (this.reconnectTimer)
318
+ clearTimeout(this.reconnectTimer);
319
+ this.reconnectTimer = setTimeout(() => { void probe(); }, RECONNECT_DELAY_MS);
320
+ }
321
+ send(msg) {
322
+ if (this.conn && !this.conn.destroyed) {
323
+ this.conn.write(encodeControl(msg));
324
+ }
325
+ }
326
+ sendRaw(frame) {
327
+ if (this.conn && !this.conn.destroyed) {
328
+ this.conn.write(frame);
329
+ }
330
+ }
331
+ /**
332
+ * Spawn a new channel and return its client (attached, replay complete).
333
+ * Rejects when the host can't spawn the PTY (bad shell, OOM, …).
334
+ */
335
+ async spawnChannel(id, spec) {
336
+ await this.ensureConnected();
337
+ const client = new PtyHostClient(this, id);
338
+ this.clients.set(id, client);
339
+ const spawned = new Promise((resolvePromise, rejectPromise) => {
340
+ this.pendingSpawns.set(id, { resolve: resolvePromise, reject: rejectPromise });
341
+ });
342
+ this.send({ op: 'spawn', id, spec });
343
+ try {
344
+ const { pid, offset } = await spawned;
345
+ client.pid = pid;
346
+ client.seenOffset = offset;
347
+ // Attach from the spawn offset: a fresh channel has nothing to replay,
348
+ // but the attach registers live delivery on the host side too.
349
+ this.send({ op: 'attach', id, since: offset });
350
+ return client;
351
+ }
352
+ catch (error) {
353
+ this.clients.delete(id);
354
+ throw error;
355
+ }
356
+ }
357
+ /**
358
+ * Adopt every live channel the host reports (server startup). Returns the
359
+ * channel metas with attached clients; replay frames arrive right after
360
+ * (register onReplay handlers synchronously).
361
+ */
362
+ async adoptChannels() {
363
+ // No socket, no survivors — don't spawn an idle host just to learn that.
364
+ if (!fs.existsSync(ptyHostSocketPath()))
365
+ return [];
366
+ let channels;
367
+ try {
368
+ channels = await this.ensureConnected();
369
+ }
370
+ catch (error) {
371
+ console.warn('[pty-host] adoption skipped: cannot reach host:', error.message);
372
+ return [];
373
+ }
374
+ const adopted = [];
375
+ for (const meta of channels) {
376
+ if (meta.exited)
377
+ continue;
378
+ let client = this.clients.get(meta.id);
379
+ if (!client) {
380
+ client = new PtyHostClient(this, meta.id);
381
+ this.clients.set(meta.id, client);
382
+ }
383
+ client.pid = meta.pid ?? undefined;
384
+ adopted.push({ meta, client });
385
+ this.send({ op: 'attach', id: meta.id, since: client.seenOffset });
386
+ }
387
+ return adopted;
388
+ }
389
+ }
390
+ let managerSingleton = null;
391
+ export function getPtyHostManager() {
392
+ if (!managerSingleton) {
393
+ managerSingleton = new PtyHostManager();
394
+ }
395
+ return managerSingleton;
396
+ }