super-dsh 0.3.2-d → 0.3.2

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.
@@ -0,0 +1,228 @@
1
+ import { Utf16TailRing } from '../../remote/plugin.js';
2
+ // PTY session machinery — PORTED from better-dsh src/remote/pty-session.ts
3
+ // (spec §8.2 / D12 "原样照抄"), with the ONE sanctioned adaptation: the
4
+ // subprocess spawn is replaced by the PtyChannel seam (exec-pty-channel.ts) —
5
+ // every state transition (init nonce handshake, FIFO dispatch, P8 two-stage
6
+ // interrupt \x03 + injected 130-marker, 3s grace, group-kill fallback, idle
7
+ // TTL, OUTPUT_HARD_CAP with surrogate-pair-safe slicing, I1 waiter hygiene)
8
+ // is the ported logic, unchanged.
9
+ import { buildInitCommand, createNonceFrameParser, genNonce, wrapPtyCommand } from './exec-pty-framing.js';
10
+ /** spec §五.2 逐字。 */
11
+ export const RECONNECT_NOTICE = '[remote: session reconnected to fresh shell; cwd reset to default]';
12
+ const DEFAULT_INIT_TIMEOUT_SEC = 30;
13
+ const INTERRUPT_GRACE_MS = 3_000;
14
+ /** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
15
+ const OUTPUT_HARD_CAP = 4_000_000;
16
+ export class PtySession {
17
+ opts;
18
+ state = 'cold';
19
+ startPromise = null;
20
+ queue = Promise.resolve();
21
+ idleTimer = null;
22
+ onChunk = null;
23
+ deathWaiters = [];
24
+ inFlight = 0;
25
+ lastSettleAt = 0;
26
+ constructor(opts) {
27
+ this.opts = opts;
28
+ this.opts.channel.onDead(() => this.markDead());
29
+ }
30
+ get sessionState() { return this.state; }
31
+ /** status 面快照:state + busy(有命令在跑)+ idleMs(上次 settle 至今)。 */
32
+ get snapshot() {
33
+ return { state: this.state, busy: this.inFlight > 0, idleMs: this.lastSettleAt > 0 ? Date.now() - this.lastSettleAt : null };
34
+ }
35
+ /** 同一会话命令严格串行(FIFO);死亡/冷态先重启。 */
36
+ dispatch(cmd, dOpts = {}) {
37
+ const attempt = async () => {
38
+ const started = Date.now();
39
+ const reconnected = this.state === 'dead';
40
+ if (this.state !== 'ready')
41
+ await this.start();
42
+ this.inFlight++;
43
+ return await new Promise((resolve) => {
44
+ const nonce = genNonce();
45
+ const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
46
+ let interrupted = false;
47
+ let settled = false;
48
+ let interruptTimer;
49
+ let killTimer;
50
+ const deathWaiter = () => finish({ output: ring.text, exit: null, cwd: null, timedOut: interrupted });
51
+ const cleanup = () => {
52
+ this.onChunk = null;
53
+ // I1(终审):已结算 dispatch 的死亡等待必须摘除——否则闭包钉住整段 output,
54
+ // 长命会话每调用累积一个 waiter(init 路径的 filter 同款形态)
55
+ this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
56
+ if (interruptTimer !== undefined)
57
+ clearTimeout(interruptTimer);
58
+ if (killTimer !== undefined)
59
+ clearTimeout(killTimer);
60
+ };
61
+ const finish = (r) => {
62
+ if (settled)
63
+ return;
64
+ settled = true;
65
+ cleanup();
66
+ this.inFlight--;
67
+ this.lastSettleAt = Date.now();
68
+ this.resetIdleTimer();
69
+ resolve({ ...r, outputStats: { stdout: ring.stats() }, reconnected, durationMs: Date.now() - started });
70
+ };
71
+ const parser = createNonceFrameParser(nonce, {
72
+ onOutput: (t) => {
73
+ ring.push(t);
74
+ },
75
+ onFrame: ({ exit, cwd }) => finish({ output: ring.text, exit, cwd, timedOut: interrupted }),
76
+ });
77
+ this.onChunk = (c) => parser.feed(c);
78
+ this.write(wrapPtyCommand(cmd, nonce));
79
+ if (dOpts.stdin !== undefined)
80
+ this.write(dOpts.stdin); // Ruling 11: REPL 式紧随
81
+ interruptTimer = setTimeout(() => {
82
+ interrupted = true;
83
+ // P8 两段打断:\x03 中断前台作业后,交互 bash 会放弃当前命令行的剩余部分
84
+ // (尾部 marker 不再执行);紧随注入的同 nonce 130-marker 行在 bash 回到
85
+ // 读取态后被执行。无论 bash 弃行还是续行,帧都在宽限内到达且 exit=130,
86
+ // 会话存活;若命令 trap 掉 SIGINT,原 marker 先帧、注入行帧后字节被丢弃。
87
+ this.write('\x03');
88
+ this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
89
+ killTimer = setTimeout(() => {
90
+ // Ruling 8 的 kill 兜底:宽限无帧 = 会话不可恢复,杀整组后收尸
91
+ // (否则 trap-INT 的卡死进程永生,重连后旧组再不可达)
92
+ this.opts.channel.kill();
93
+ this.markDead();
94
+ finish({ output: ring.text, exit: null, cwd: null, timedOut: true });
95
+ }, INTERRUPT_GRACE_MS);
96
+ }, dOpts.timeoutSec === undefined ? 120_000 : dOpts.timeoutSec * 1_000);
97
+ this.deathWaiters.push(deathWaiter);
98
+ });
99
+ };
100
+ const run = this.queue.then(attempt, attempt);
101
+ this.queue = run.then(() => undefined, () => undefined);
102
+ return run;
103
+ }
104
+ /** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
105
+ async dispose() {
106
+ if (this.state === 'disposed')
107
+ return;
108
+ this.clearIdleTimer();
109
+ this.state = 'disposed';
110
+ this.startPromise = null;
111
+ this.opts.channel.close();
112
+ this.opts.onDead?.();
113
+ }
114
+ write(s) {
115
+ this.opts.channel.write(s);
116
+ }
117
+ async start() {
118
+ if (this.state === 'disposed')
119
+ throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.key}' was disposed — the pool replaces disposed sessions; this reference is stale`);
120
+ if (this.startPromise === null) {
121
+ this.state = 'starting';
122
+ this.startPromise = this.doStart().catch((err) => {
123
+ this.startPromise = null;
124
+ this.markDead();
125
+ throw err;
126
+ });
127
+ }
128
+ return this.startPromise;
129
+ }
130
+ async doStart() {
131
+ const initTimeoutSec = this.opts.initTimeoutSec ?? DEFAULT_INIT_TIMEOUT_SEC;
132
+ const initNonce = genNonce();
133
+ await new Promise((resolve, reject) => {
134
+ const timer = setTimeout(() => {
135
+ this.opts.channel.kill();
136
+ reject(new Error(`[E_SESSION_START] remote pty session '${this.opts.key}' did not initialize in ${initTimeoutSec}s` +
137
+ (this.diag().length > 0 ? `; transport stderr tail: ${this.diag()}` : '')));
138
+ }, initTimeoutSec * 1_000);
139
+ const waiter = () => {
140
+ clearTimeout(timer);
141
+ reject(new Error(`[E_SESSION_DIED] remote pty session '${this.opts.key}' died before initializing` +
142
+ (this.diag().length > 0 ? `; transport stderr tail: ${this.diag()}` : '')));
143
+ };
144
+ this.deathWaiters.push(waiter);
145
+ const parser = createNonceFrameParser(initNonce, {
146
+ onFrame: () => {
147
+ clearTimeout(timer);
148
+ this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
149
+ if (this.state === 'disposed') {
150
+ // dispose 竞态 init:不得复活已终态会话(否则重挂 idle timer + 幽灵 ready)
151
+ reject(new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.key}' disposed during init`));
152
+ return;
153
+ }
154
+ this.state = 'ready';
155
+ this.resetIdleTimer();
156
+ resolve();
157
+ },
158
+ });
159
+ this.onChunk = (c) => parser.feed(c);
160
+ this.opts.channel.open((chunk) => this.onChunk?.(chunk))
161
+ .catch((err) => {
162
+ clearTimeout(timer);
163
+ this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
164
+ reject(err instanceof Error ? err : new Error(String(err)));
165
+ });
166
+ this.write(buildInitCommand(initNonce));
167
+ });
168
+ }
169
+ diag() { return this.opts.channel.diag; }
170
+ markDead() {
171
+ if (this.state === 'disposed') {
172
+ // dispose 是终态:不再翻转 state,但在飞 dispatch 的死亡等待仍需立即结算
173
+ // (row teardown 不是超时——不得等 123s 看门狗,也不得错标 timedOut)
174
+ const waiters = this.deathWaiters;
175
+ this.deathWaiters = [];
176
+ for (const w of waiters)
177
+ w();
178
+ return;
179
+ }
180
+ this.state = 'dead';
181
+ this.startPromise = null;
182
+ this.clearIdleTimer();
183
+ const waiters = this.deathWaiters;
184
+ this.deathWaiters = [];
185
+ for (const w of waiters)
186
+ w();
187
+ }
188
+ resetIdleTimer() {
189
+ this.clearIdleTimer();
190
+ this.idleTimer = setTimeout(() => void this.dispose(), this.opts.idleTtlSec * 1_000);
191
+ this.idleTimer.unref();
192
+ }
193
+ clearIdleTimer() {
194
+ if (this.idleTimer !== null) {
195
+ clearTimeout(this.idleTimer);
196
+ this.idleTimer = null;
197
+ }
198
+ }
199
+ }
200
+ /** 会话池:键 → 会话。意外死亡的会话留在池内(下次 dispatch 带 reconnect 提示)。 */
201
+ export class PtyPool {
202
+ defaults;
203
+ sessions = new Map();
204
+ constructor(defaults) {
205
+ this.defaults = defaults;
206
+ }
207
+ get size() { return this.sessions.size; }
208
+ /** status 面:键上的会话快照(无会话 = undefined → "none — dials on first exec")。 */
209
+ inspect(key) {
210
+ return this.sessions.get(key)?.snapshot;
211
+ }
212
+ /** roster 面:池内全部会话。 */
213
+ list() {
214
+ return [...this.sessions.entries()].map(([key, s]) => ({ key, snapshot: s.snapshot }));
215
+ }
216
+ getOrCreate(key, channelOf) {
217
+ let s = this.sessions.get(key);
218
+ if (s === undefined || s.sessionState === 'disposed') {
219
+ s = new PtySession({ key, channel: channelOf(), ...this.defaults, onDead: () => this.sessions.delete(key) });
220
+ this.sessions.set(key, s);
221
+ }
222
+ return s;
223
+ }
224
+ async disposeAll() {
225
+ await Promise.all([...this.sessions.values()].map((s) => s.dispose()));
226
+ this.sessions.clear();
227
+ }
228
+ }
@@ -19,7 +19,7 @@ export interface HubToolExecFace {
19
19
  pty(sessionKey: string, machine: string, cmd: string, opts?: {
20
20
  timeoutSec?: number;
21
21
  stdin?: string;
22
- }): Promise<import('../../remote/plugin.js').PtyDispatchResult>;
22
+ }): Promise<import('./exec-pty-session.js').PtyDispatchResult>;
23
23
  }
24
24
  import type { A2aAddress, A2aInitiator, A2aService } from './a2a-service.js';
25
25
  /** The service face the tool consumes (structural subset of T2/T3's A2aService). */
@@ -37,7 +37,7 @@
37
37
  */
38
38
  import { defineTool } from '@deepseek-ai/dsh-tools';
39
39
  import { parseOneShotExecBody, EXEC_TAIL_WINDOW } from './exec.js';
40
- import { tailWindow } from '../../remote/plugin.js';
40
+ import { tailWindow } from './exec-pty-framing.js';
41
41
  // F2 (0.1.7-n, PC 0.1.7-m first-person): the wire/registry sentinel stays
42
42
  // '[undefined]', but the DISPLAY surface must not show it to a human — D17
43
43
  // already sanctions display-layer beautification.
@@ -1,28 +1,13 @@
1
1
  import { createRemoteFileEndpoint } from './remote-file-rpc.js';
2
2
  import { startRemoteProcess } from './remote-job-client.js';
3
- /** Hub transport implementation. HTTP/WS/pool mechanics stay in the existing exec modules. */
4
- import { PtySession, buildHubPtyArgv, hubPtyAssets } from '../../remote/plugin.js';
5
- // ws 模块绝对路径:在 superd-hub 上下文解析(分叉的 proven 链——闭包/泵子树解析不到,
6
- // 0.3.2-c 定稿:按名解析只在 transport 侧做一次,经 ptyEndpoint/argv 下传)。
7
- const wsModulePath = () => {
8
- try {
9
- return createRequire(import.meta.url).resolve('ws');
10
- }
11
- catch (error) {
12
- throw new Error(`[E_HUB_PTY_SETUP] superd-hub: the ws module could not be resolved from the hub tree — hub PTY spawns a local pump child that opens the peer WebSocket with ws; ${error instanceof Error ? error.message : String(error)}`);
13
- }
14
- };
15
- import { randomUUID } from 'node:crypto';
16
- import { createRequire } from 'node:module';
17
3
  import { ensureSelf, listRemotes } from './machines.js';
18
4
  import { cookieOf } from './mesh-link.js';
19
5
  import { runOneShotExec, postOneShotExec, parseOneShotExecBody } from './exec.js';
6
+ import { PtyPool } from './exec-pty-session.js';
7
+ import { LocalScriptChannel, WsPtyChannel } from './exec-pty-channel.js';
20
8
  import { PTY_EXEC_PATH } from './exec-pty-rpc.js';
21
9
  /** One shared initiator implementation for legacy tool and the optional remote service. */
22
10
  export function createHubExec(home, log) {
23
- // 0.3.2-c(Q2):分叉 exec-pty-session 已删——本 face 与 remote 工具共用 canonical
24
- // PtySession(闭包导出)。per-owner 每机器一个持久 shell(A2A 语义保持:cwd/env 跨调用存活;
25
- // dead → dispatch 内部重连 = 旧 markDead/重启;disposed → 下次调用重建)。
26
11
  const owners = new Map();
27
12
  let closed = false;
28
13
  const assertOpen = () => { if (closed)
@@ -36,16 +21,6 @@ export function createHubExec(home, log) {
36
21
  throw new Error(`exec is disabled for machine "${machine}" on this registry row (enabled/execEnabled=false)`);
37
22
  return row;
38
23
  };
39
- const genCommandId = () => `remote-${randomUUID().replace(/-/g, '').slice(0, 16)}`;
40
- const createPtySession = (machine, row, owner) => new PtySession({
41
- key: `${owner}|hub-pty:${machine}`,
42
- argv: row === undefined
43
- ? ['script', '-qfec', 'bash', '/dev/null']
44
- : buildHubPtyArgv({ url: `${row.origin.replace(/^http/, 'ws')}${PTY_EXEC_PATH}`, cookie: cookieOf(row.id, home) ?? '', wsPath: wsModulePath() }, hubPtyAssets()),
45
- id: `p-${randomUUID().slice(0, 8)}`,
46
- connection: row === undefined ? 'local' : `hub:${machine}`,
47
- idleTtlSec: 900,
48
- });
49
24
  const face = {
50
25
  selfM4: () => ensureSelf(home, 'local').id,
51
26
  run: input => { assertOpen(); return runOneShotExec(input, log); },
@@ -62,34 +37,14 @@ export function createHubExec(home, log) {
62
37
  const row = machine === face.selfM4() ? undefined : remote(machine);
63
38
  let pool = owners.get(owner);
64
39
  if (!pool)
65
- owners.set(owner, pool = new Map());
66
- const attempt = async (session) => {
67
- const r = await session.dispatch(cmd, { stdin: opts.stdin, timeoutSec: opts.timeoutSec, commandId: genCommandId() });
68
- // P4 handoff(超时不杀)对 A2A 面保持旧语义:显式两段 ^C 后等帧结算——分叉自动中断的等价物。
69
- if (r.running === true && r.job !== undefined) {
70
- r.job.interrupt();
71
- const settled = await r.job.done;
72
- return { output: settled.output, outputStats: settled.outputStats, exit: settled.exit, cwd: settled.cwd, timedOut: true, reconnected: r.reconnected, durationMs: r.durationMs, commandId: r.commandId };
73
- }
74
- return r;
75
- };
76
- const settle = (result) => {
77
- log?.('info', 'exec.pty.run', { machine, cmd: cmd.slice(0, 120), exit: result.exit ?? 'none', durationMs: result.durationMs });
78
- return result;
79
- };
80
- const existing = pool.get(machine);
81
- if (existing !== undefined && existing.sessionState !== 'disposed')
82
- return settle(await attempt(existing));
83
- const session = createPtySession(machine, row, owner);
84
- pool.set(machine, session);
85
- try {
86
- return settle(await attempt(session));
87
- }
88
- catch (err) {
89
- if (session.sessionState === 'disposed' && pool.get(machine) === session)
90
- pool.delete(machine); // init 失败/auto-dispose 不留死项
91
- throw err;
92
- }
40
+ owners.set(owner, pool = new PtyPool({ idleTtlSec: 600 }));
41
+ const key = `${machine}|${row?.origin ?? 'local'}`;
42
+ const session = pool.getOrCreate(key, () => row
43
+ ? new WsPtyChannel(`${row.origin.replace(/^http/, 'ws')}${PTY_EXEC_PATH}`, { cookie: cookieOf(row.id, home) })
44
+ : new LocalScriptChannel());
45
+ const result = await session.dispatch(cmd, opts);
46
+ log?.('info', 'exec.pty.run', { machine, cmd: cmd.slice(0, 120), exit: result.exit ?? 'none', durationMs: result.durationMs });
47
+ return result;
93
48
  },
94
49
  };
95
50
  return {
@@ -97,11 +52,12 @@ export function createHubExec(home, log) {
97
52
  async releaseOwner(owner) {
98
53
  const pool = owners.get(owner);
99
54
  if (pool) {
100
- await Promise.all([...pool.values()].map(s => s.dispose()));
101
- owners.delete(owner);
55
+ await pool.disposeAll();
56
+ if (owners.get(owner) === pool)
57
+ owners.delete(owner);
102
58
  }
103
59
  },
104
- async dispose() { closed = true; await Promise.all([...owners.values()].flatMap(p => [...p.values()].map(s => s.dispose()))); owners.clear(); },
60
+ async dispose() { closed = true; await Promise.all([...owners.values()].map(p => p.disposeAll())); owners.clear(); },
105
61
  };
106
62
  }
107
63
  export function createHubRemoteTransport(home, log) {
@@ -111,10 +67,25 @@ export function createHubRemoteTransport(home, log) {
111
67
  const fileAbort = new AbortController();
112
68
  const files = new Set();
113
69
  const processes = new Map();
70
+ const pending = new Map();
114
71
  const requireRemote = (machine) => {
115
72
  if (!machine || machine === exec.face.selfM4())
116
73
  throw new Error('Select a remote machine from the Hub roster; local execution is not available through remote');
117
74
  };
75
+ const track = async (owner, work) => {
76
+ let set = pending.get(owner);
77
+ if (!set)
78
+ pending.set(owner, set = new Set());
79
+ set.add(work);
80
+ try {
81
+ return await work;
82
+ }
83
+ finally {
84
+ set.delete(work);
85
+ if (!set.size)
86
+ pending.delete(owner);
87
+ }
88
+ };
118
89
  return {
119
90
  protocol: 1,
120
91
  listTargets: () => listRemotes(home).filter(r => r.id !== exec.face.selfM4()).map(r => ({ id: r.id, label: r.name, execEnabled: r.enabled && r.execEnabled !== false })),
@@ -139,17 +110,12 @@ export function createHubRemoteTransport(home, log) {
139
110
  processes.delete(owner); });
140
111
  return process;
141
112
  },
142
- ptyEndpoint(machine) {
113
+ async pty(machine, cmd, owner, options) {
143
114
  requireRemote(machine);
144
- if (closed)
145
- throw new Error('Hub remote provider disposed');
146
- const row = listRemotes(home).find(r => r.id === machine);
147
- if (!row)
148
- throw new Error(`unknown machine "${machine}"`);
149
- if (!row.enabled || row.execEnabled === false)
150
- throw new Error(`exec is disabled for machine "${machine}"`);
151
- // P10:创建与每次 dispatch 都重取本端点(driver 侧调用)——pairing 复用不豁免。
152
- return { url: `${row.origin.replace(/^http/, 'ws')}${PTY_EXEC_PATH}`, cookie: cookieOf(row.id, home) ?? '', wsPath: wsModulePath() };
115
+ if (closed || released.has(owner))
116
+ throw new Error('Hub remote owner disposed');
117
+ const { output, ...result } = await track(owner, exec.face.pty(owner, machine, cmd, { timeoutSec: options.timeout, stdin: options.stdin }));
118
+ return { mode: 'pty', target: `hub:${machine}`, ...result, stdout: output };
153
119
  },
154
120
  fileEndpoint(machine) {
155
121
  requireRemote(machine);
@@ -169,7 +135,7 @@ export function createHubRemoteTransport(home, log) {
169
135
  const owned = [...processes.get(owner) ?? []];
170
136
  for (const process of owned)
171
137
  process.cancel('owner released');
172
- await Promise.all([exec.releaseOwner(owner), ...owned.map(p => p.done)]);
138
+ await Promise.all([exec.releaseOwner(owner), ...owned.map(p => p.done), ...pending.get(owner) ?? []]);
173
139
  },
174
140
  async dispose() {
175
141
  closed = true;
@@ -179,7 +145,7 @@ export function createHubRemoteTransport(home, log) {
179
145
  for (const process of owned)
180
146
  process.cancel('provider disposed');
181
147
  await exec.dispose();
182
- await Promise.allSettled([...owned.map(p => p.done)]);
148
+ await Promise.allSettled([...owned.map(p => p.done), ...[...pending.values()].flatMap(s => [...s])]);
183
149
  },
184
150
  };
185
151
  }
@@ -1,97 +0,0 @@
1
- #!/usr/bin/env node
2
- // Hub PTY pump — the DUMB child of the canonical PTY session over a Hub
3
- // machine (0.3.2-c). The session (better-dsh/src/remote/pty-session.ts)
4
- // spawns `script -qfec 'stty -echo; … exec node <this file>' /dev/null`, so
5
- // this process's stdin/stdout ride a host-side PTY; we forward those bytes
6
- // over one WebSocket to the peer hub's dumb PTY executor
7
- // (/api/superd/exec/pty: one WS = one `script -qfec bash /dev/null`, text
8
- // frames = stream bytes, {"op":…} JSON frames = transport control, WS close
9
- // = peer group-kill). All session intelligence (nonce framing, init
10
- // handshake, env E-frames, two-stage interrupt, idle TTL) is in-band bytes
11
- // produced by the SESSION — this pump never parses the stream, only the
12
- // peer's control frames.
13
- //
14
- // Connection facts arrive via environment (never argv — credentials must not
15
- // show up in `ps`):
16
- // SUPERD_HUB_PTY_URL wss://…/api/superd/exec/pty (peer executor)
17
- // SUPERD_HUB_PTY_COOKIE pairing cookie for the upgrade request
18
- // SUPERD_HUB_PTY_WS ABSOLUTE path to a usable `ws` module (resolved by
19
- // the caller before spawning; no resolution gamble
20
- // here — a missing module is a creation-time error)
21
- //
22
- // Verbatim-asset discipline (helper-bin precedent): this file is copied
23
- // byte-for-byte into lib/remote/ and the Super closure; no build step
24
- // transforms it. Crash-proof by construction (mesh-gateway lesson): the
25
- // 'error' listener is registered before anything can reject, and post-close
26
- // sends are swallowed — the session's death path owns the teardown.
27
- const PUMP_ENV_PREFIX = 'SUPERD_HUB_PTY_'
28
- const [url, cookie, wsPath] = ['URL', 'COOKIE', 'WS'].map((k) => process.env[`SUPERD_HUB_PTY_${k}`])
29
-
30
- // cookie may be legitimately empty (an unauthenticated test lane sends no header);
31
- // the endpoint URL and the ws module path are the hard requirements.
32
- if (url === undefined || url === '' || wsPath === undefined || wsPath === '') {
33
- process.stderr.write(`hub-pty-pump: missing environment (${PUMP_ENV_PREFIX}URL/WS)\n`)
34
- process.exit(1)
35
- }
36
-
37
- let WebSocket
38
- try {
39
- const require = (await import('node:module')).createRequire(import.meta.url)
40
- const mod = require(wsPath)
41
- WebSocket = typeof mod === 'function' ? mod : mod.default
42
- } catch (err) {
43
- process.stderr.write(`hub-pty-pump: cannot load ws module at ${wsPath} — ${err instanceof Error ? err.message : String(err)}\n`)
44
- process.exit(1)
45
- }
46
- if (typeof WebSocket !== 'function') {
47
- process.stderr.write('hub-pty-pump: the ws module did not yield a WebSocket constructor\n')
48
- process.exit(1)
49
- }
50
-
51
- const socket = new WebSocket(url, { headers: cookie ? { cookie } : {} })
52
- let opened = false
53
- let forward = true
54
-
55
- socket.on('error', (err) => {
56
- // Always-on listener: an ECONNREFUSED or a rejected 401 upgrade must be a
57
- // reported death, never an uncaught exception.
58
- process.stderr.write(`hub-pty-pump: ${opened ? 'socket error' : 'connect failed'} — ${err instanceof Error ? err.message : String(err)}\n`)
59
- })
60
- socket.on('open', () => { opened = true; pumpStdin() })
61
- socket.on('message', (data) => {
62
- if (!forward) return
63
- const text = typeof data === 'string' ? data : Array.isArray(data) ? Buffer.concat(data).toString('utf8') : Buffer.from(data).toString('utf8')
64
- // Transport control frames (peer: {"op":"exit"} / {"op":"spawn-error"}) are
65
- // addressed to THIS pump, not to the shell stream — the exit frame is
66
- // followed by a socket close, which settles us below.
67
- if (text.startsWith('{"op":')) {
68
- try {
69
- const parsed = JSON.parse(text)
70
- if (typeof parsed.op === 'string') {
71
- if (parsed.op === 'spawn-error' && typeof parsed.error === 'string') process.stderr.write(`hub-pty-pump: peer could not spawn the terminal — ${parsed.error}\n`)
72
- return
73
- }
74
- } catch { /* not JSON after all: fall through and forward */ }
75
- }
76
- process.stdout.write(text)
77
- })
78
- socket.on('close', () => {
79
- forward = false
80
- // Before open this is a failed connection (diag already on stderr); after
81
- // open it is the peer session ending — either way the child exiting is the
82
- // session's dead signal.
83
- process.exit(opened ? 0 : 1)
84
- })
85
-
86
- function pumpStdin() {
87
- process.stdin.setEncoding('utf8')
88
- process.stdin.on('data', (chunk) => {
89
- if (!opened) return
90
- try { socket.send(chunk) } catch { /* closing: the close handler exits us */ }
91
- })
92
- // The session's kill path tears down the process group; a plain stdin end
93
- // (no kill) still means nobody will type more — close politely so the peer
94
- // reaps its side.
95
- process.stdin.on('end', () => { try { socket.close() } catch { /* already closing */ } })
96
- process.stdin.on('error', () => { /* EPIPE on teardown: the close path owns the exit */ })
97
- }