super-dsh 0.3.1 → 0.3.2-e

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 (48) hide show
  1. package/client/index.js +135 -14
  2. package/package.json +1 -1
  3. package/remote/helper-bin/MANIFEST.json +35 -0
  4. package/remote/helper-bin/aarch64-apple-darwin/BUILT-VER +1 -0
  5. package/remote/helper-bin/aarch64-apple-darwin/superd-run +0 -0
  6. package/remote/helper-bin/aarch64-unknown-linux-musl/BUILT-VER +1 -0
  7. package/remote/helper-bin/aarch64-unknown-linux-musl/superd-run +0 -0
  8. package/remote/helper-bin/x86_64-pc-windows-gnu/BUILT-VER +1 -0
  9. package/remote/helper-bin/x86_64-pc-windows-gnu/superd-run.exe +0 -0
  10. package/remote/helper-bin/x86_64-unknown-linux-musl/BUILT-VER +1 -0
  11. package/remote/helper-bin/x86_64-unknown-linux-musl/superd-run +0 -0
  12. package/remote/hub-pty-pump.mjs +97 -0
  13. package/remote/plugin.d.ts +523 -199
  14. package/remote/plugin.js +2530 -768
  15. package/remote/provenance.json +36 -21
  16. package/superd-hub/dist/a2a-host.d.ts +11 -3
  17. package/superd-hub/dist/a2a-host.js +7 -7
  18. package/superd-hub/dist/a2a-row.js +7 -1
  19. package/superd-hub/dist/carrier.d.ts +9 -7
  20. package/superd-hub/dist/carrier.js +3 -3
  21. package/superd-hub/dist/client/pill-geometry.d.ts +70 -0
  22. package/superd-hub/dist/client/pill-geometry.js +49 -0
  23. package/superd-hub/dist/client/switch.d.ts +15 -0
  24. package/superd-hub/dist/client/switch.js +54 -1
  25. package/superd-hub/dist/companions.js +8 -6
  26. package/superd-hub/dist/exec.d.ts +3 -2
  27. package/superd-hub/dist/exec.js +6 -5
  28. package/superd-hub/dist/fusion-transport.d.ts +18 -1
  29. package/superd-hub/dist/fusion-transport.js +44 -5
  30. package/superd-hub/dist/gateway.js +8 -1
  31. package/superd-hub/dist/hub-route.d.ts +5 -2
  32. package/superd-hub/dist/hub-route.js +6 -6
  33. package/superd-hub/dist/hub-tool.d.ts +1 -1
  34. package/superd-hub/dist/hub-tool.js +6 -6
  35. package/superd-hub/dist/remote-provider.js +70 -36
  36. package/superd-hub/dist/rpc.d.ts +5 -0
  37. package/superd-hub/dist/rpc.js +20 -1
  38. package/superd-hub/dist/switch-core.d.ts +41 -0
  39. package/superd-hub/dist/switch-core.js +40 -0
  40. package/superd-hub/dist/targets.d.ts +7 -0
  41. package/superd-hub/dist/world-lifecycle.js +1 -1
  42. package/superd-hub/lib/client/index.js +135 -14
  43. package/superd-hub/dist/exec-pty-channel.d.ts +0 -58
  44. package/superd-hub/dist/exec-pty-channel.js +0 -162
  45. package/superd-hub/dist/exec-pty-framing.d.ts +0 -34
  46. package/superd-hub/dist/exec-pty-framing.js +0 -89
  47. package/superd-hub/dist/exec-pty-session.d.ts +0 -78
  48. package/superd-hub/dist/exec-pty-session.js +0 -228
@@ -898,6 +898,16 @@ function machineLabelsFrom(list) {
898
898
  * roster-probe failure → revert to native + a visible toast, never a page
899
899
  * lock).
900
900
  *
901
+ * Live-install staleness (2026-10-18): the fusion flag global and the
902
+ * transport script are baked into the SERVED index, so a page that booted
903
+ * before super-dsh was installed runs the freshly hot-loaded client half
904
+ * against a stale page — `fusionPage()` reads false and every switch
905
+ * affordance stays hidden until a manual restart. {@link fusionReloadDecision}
906
+ * is the pure decision behind the client half's heal: one roster answer later,
907
+ * reload the page once. The transport must precede app boot (its upload hook
908
+ * `__DSH_FILE_UPLOAD__` is read at construction), so a client-side flag flip
909
+ * can never arm a stale page honestly — the page must be re-served.
910
+ *
901
911
  * Host-half on purpose: plain TS with every effect injected through
902
912
  * {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
903
913
  * imports it from `dist/` like every other host module. The client half
@@ -1343,6 +1353,22 @@ function switchAffordance(fusion) {
1343
1353
  hint: FUSION_OFF_HINT
1344
1354
  };
1345
1355
  }
1356
+ /** The sessionStorage marker guarding the stale-page reload: one per tab session. */
1357
+ const FUSION_RELOAD_MARKER = "superd.fusion-armed.reload";
1358
+ /**
1359
+ * The live-install staleness decision (host-half pure so the node tests pin
1360
+ * the table from `dist/`). The host's `hub.fusion` field is the ONLY thing
1361
+ * that distinguishes a page-booted-before-install from a deliberately
1362
+ * flag-off deployment — without it the reload would loop forever on every
1363
+ * `enabled: false` composition (the client half loads there too).
1364
+ */
1365
+ function fusionReloadDecision(input) {
1366
+ if (input.flagPresent) return "armed";
1367
+ if (input.markerPresent) return "already-reloaded";
1368
+ if (input.hubFusion === true) return "reload";
1369
+ if (input.hubFusion === false) return "row-off";
1370
+ return "host-silent";
1371
+ }
1346
1372
  /**
1347
1373
  * The active agent's display name for the Hub row's right-edge selector: the
1348
1374
  * roster label of the atom's `activeAgent` key — the name the selector's
@@ -1621,6 +1647,53 @@ function createReadinessWaiter(faces, options = {}) {
1621
1647
  function fusionPage() {
1622
1648
  return globalThis.__SUPERD_UI_FUSION__ === true;
1623
1649
  }
1650
+ /**
1651
+ * The sessionStorage marker read (undefined-safe: a storage failure is
1652
+ * `false` — the probe then reloads without the loop guard, which only
1653
+ * matters for a host that reports fusion but never bakes the flag).
1654
+ */
1655
+ function staleReloadMarker() {
1656
+ try {
1657
+ return globalThis.sessionStorage?.getItem(FUSION_RELOAD_MARKER) !== null;
1658
+ } catch {
1659
+ return false;
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Live-install staleness probe (2026-10-18): the ONE call the client entry
1664
+ * makes when the page does not carry the fusion flag. A flag-absent page is
1665
+ * either a pre-install page (the client half hot-loaded into HTML served
1666
+ * before the row mounted — the fresh-install case where the Hub row renders
1667
+ * but every switch affordance stays hidden until a manual restart) or a
1668
+ * deliberate flag-off deployment (T-E). The roster's additive `hub.fusion`
1669
+ * answers which; on an armed host the page re-boots ONCE — the marker keeps
1670
+ * a misbehaving host from ever looping the tab. Every failure mode (fetch
1671
+ * error, non-OK, old host without the field) degrades to no action: the
1672
+ * flag-off shapes below are the honest rendering of an unanswered probe.
1673
+ *
1674
+ * Fire-and-forget by contract — never throws, never blocks the apply.
1675
+ */
1676
+ function probeStaleFusionPage() {
1677
+ (async () => {
1678
+ let hubFusion;
1679
+ try {
1680
+ const response = await fetch("/api/agent-runtime", { credentials: "same-origin" });
1681
+ if (!response.ok) return;
1682
+ hubFusion = (await response.json()).hub?.fusion;
1683
+ } catch {
1684
+ return;
1685
+ }
1686
+ if (fusionReloadDecision({
1687
+ flagPresent: fusionPage(),
1688
+ markerPresent: staleReloadMarker(),
1689
+ hubFusion
1690
+ }) !== "reload") return;
1691
+ try {
1692
+ globalThis.sessionStorage?.setItem(FUSION_RELOAD_MARKER, "1");
1693
+ } catch {}
1694
+ globalThis.location?.reload();
1695
+ })();
1696
+ }
1624
1697
  /** The page atom, when the page carries one (it always does on a fusion page). */
1625
1698
  function uiState() {
1626
1699
  const state = globalThis.__SUPERD_UI_STATE__;
@@ -3880,6 +3953,38 @@ function HubPage() {
3880
3953
  });
3881
3954
  }
3882
3955
 
3956
+ //#endregion
3957
+ //#region src/client/pill-geometry.ts
3958
+ /**
3959
+ * Derive the scroll-invariant offsets from one same-frame measurement.
3960
+ * Measuring the DELTAS (not absolute coordinates) is the whole point: they
3961
+ * survive any scroll of any ancestor, so nothing needs to re-run while the
3962
+ * sidebar scrolls.
3963
+ */
3964
+ function measurePillBox(input) {
3965
+ return {
3966
+ dx: input.row.right - input.wrap.left,
3967
+ cy: (input.row.top + input.row.bottom) / 2 - input.wrap.top
3968
+ };
3969
+ }
3970
+ /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
3971
+ const PILL_ROW_INSET = 8;
3972
+ /**
3973
+ * The pill's placement, wrapper-relative: its right edge sits `inset` px
3974
+ * inside the row's right edge, vertically centered on the row
3975
+ * (`translateY(-50%)`). `selectorWidth` is the render-time pill width (the
3976
+ * mark is present only while an agent is active), so it stays a render
3977
+ * concern; the measured box is not.
3978
+ */
3979
+ function pillStyle(box, selectorWidth, inset = PILL_ROW_INSET) {
3980
+ return {
3981
+ position: "absolute",
3982
+ left: box.dx - inset - selectorWidth,
3983
+ top: box.cy,
3984
+ transform: "translateY(-50%)"
3985
+ };
3986
+ }
3987
+
3883
3988
  //#endregion
3884
3989
  //#region src/client/HubPanelIcon.tsx
3885
3990
  /** Runtime-mark edge inside the selector, and the chevron's own default edge. */
@@ -3893,8 +3998,6 @@ const CHEVRON_SIZE = 14;
3893
3998
  const SELECTOR_HEIGHT = 28;
3894
3999
  const SELECTOR_PAD = 8;
3895
4000
  const SELECTOR_GAP = 4;
3896
- /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
3897
- const ROW_INSET = 8;
3898
4001
  function HubPanelIcon({ size }) {
3899
4002
  const wide = size <= 16;
3900
4003
  const rootRef = react.useRef(null);
@@ -3917,16 +4020,25 @@ function HubPanelIcon({ size }) {
3917
4020
  }, [fusion]);
3918
4021
  const measure = react.useCallback(() => {
3919
4022
  const row = rootRef.current?.closest("button") ?? null;
3920
- if (row === null) {
4023
+ const wrap = selectorRef.current?.parentElement ?? null;
4024
+ if (row === null || wrap === null) {
3921
4025
  setBox(null);
3922
4026
  return;
3923
4027
  }
3924
- const rect = row.getBoundingClientRect();
3925
- setBox((prev) => prev !== null && prev.top === rect.top && prev.bottom === rect.bottom && prev.right === rect.right ? prev : {
3926
- top: rect.top,
3927
- bottom: rect.bottom,
3928
- right: rect.right
4028
+ const rowRect = row.getBoundingClientRect();
4029
+ const wrapRect = wrap.getBoundingClientRect();
4030
+ const next = measurePillBox({
4031
+ row: {
4032
+ top: rowRect.top,
4033
+ bottom: rowRect.bottom,
4034
+ right: rowRect.right
4035
+ },
4036
+ wrap: {
4037
+ left: wrapRect.left,
4038
+ top: wrapRect.top
4039
+ }
3929
4040
  });
4041
+ setBox((prev) => prev !== null && prev.dx === next.dx && prev.cy === next.cy ? prev : next);
3930
4042
  }, []);
3931
4043
  react.useLayoutEffect(() => {
3932
4044
  if (!wide || !switchUi.available) return;
@@ -4036,7 +4148,7 @@ function HubPanelIcon({ size }) {
4036
4148
  children: [
4037
4149
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HubGlyph, { size }),
4038
4150
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SuperdToastHost, {}),
4039
- wide && box !== null && switchUi.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
4151
+ wide && switchUi.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
4040
4152
  open,
4041
4153
  onClose: () => {
4042
4154
  setOpen(false);
@@ -4057,10 +4169,11 @@ function HubPanelIcon({ size }) {
4057
4169
  "aria-expanded": open,
4058
4170
  title: activeLabel === void 0 ? "Agent selector" : `Agent selector: ${activeLabel}`,
4059
4171
  style: {
4060
- position: "fixed",
4061
- left: box.right - ROW_INSET - selectorWidth,
4062
- top: box.top + (box.bottom - box.top) / 2,
4063
- transform: "translateY(-50%)",
4172
+ ...pillStyle(box ?? {
4173
+ dx: 0,
4174
+ cy: 0
4175
+ }, selectorWidth),
4176
+ visibility: box === null ? "hidden" : "visible",
4064
4177
  display: "inline-flex",
4065
4178
  alignItems: "center",
4066
4179
  justifyContent: "center",
@@ -4105,9 +4218,17 @@ const inject = ["slots"];
4105
4218
  * absent (a composition without the sidebar renders nothing — every seat
4106
4219
  * here is an optional occupant by contract).
4107
4220
  *
4108
- * @param ctx - client root context.
4221
+ * Live-install staleness: the fusion flag is baked into the SERVED index, so
4222
+ * a page that booted before super-dsh was installed carries no flag even
4223
+ * though this very apply is the hot-loaded client half — the Hub row would
4224
+ * render with every switch affordance hidden until a manual restart. When
4225
+ * the flag is absent the entry probes the roster's `hub.fusion` once and
4226
+ * re-boots the page on an armed host (switch.ts `probeStaleFusionPage`; the
4227
+ * sessionStorage marker bounds it to one reload per tab session). A flag-off
4228
+ * deployment answers `false` and keeps the T-E inert shape with no reload.
4109
4229
  */
4110
4230
  function apply(ctx) {
4231
+ if (!fusionPage()) probeStaleFusionPage();
4111
4232
  ctx.inject(["slots"], (raw) => {
4112
4233
  const scope = raw;
4113
4234
  scope.effect(() => scope.slots.inject("main", () => scope.slots.register({
@@ -1,58 +0,0 @@
1
- /** The transport seam the ported PtySession drives (spec §8.2: {write, onData, kill}). */
2
- export interface PtyChannel {
3
- /** Open the transport; resolves when bytes can flow (session readiness itself = the init nonce frame). */
4
- open(onData: (chunk: string) => void): Promise<void>;
5
- write(s: string): void;
6
- /** Polite close (dispose path: 'exit\n' already sent by the session). */
7
- close(): void;
8
- /** Hard kill: the transport AND any process group behind it (Ruling 8 fallback). */
9
- kill(): void;
10
- /** Register for unexpected transport death (socket cut / process exit). */
11
- onDead(callback: () => void): void;
12
- /** Diagnostics tail (transport stderr / socket errors) for init-failure messages. */
13
- readonly diag: string;
14
- }
15
- /** Local target: `script -qfec bash /dev/null` — host-side PTY, no node-pty (spec §8.2 / D12). */
16
- export declare class LocalScriptChannel implements PtyChannel {
17
- private readonly argv;
18
- diag: string;
19
- private proc;
20
- private deadCb;
21
- constructor(argv?: string[]);
22
- open(onData: (chunk: string) => void): Promise<void>;
23
- write(s: string): void;
24
- close(): void;
25
- kill(): void;
26
- onDead(callback: () => void): void;
27
- }
28
- /** Structural view of the `ws` client socket (runtime peer — mesh-gateway precedent). */
29
- interface WsSocketLike {
30
- send(data: unknown): void;
31
- terminate(): void;
32
- close(): void;
33
- on(event: 'open', listener: () => void): unknown;
34
- on(event: 'error', listener: (error: unknown) => void): unknown;
35
- on(event: 'message', listener: (data: unknown) => void): unknown;
36
- on(event: 'close', listener: () => void): unknown;
37
- }
38
- type WsConstructorLike = new (url: string, options: {
39
- headers: Record<string, string>;
40
- }) => WsSocketLike;
41
- /** Remote target: a WebSocket to the peer hub's dumb PTY executor, pairing Cookie riding the upgrade. */
42
- export declare class WsPtyChannel implements PtyChannel {
43
- private readonly url;
44
- private readonly headers;
45
- private readonly wsCtor;
46
- diag: string;
47
- private socket;
48
- private deadCb;
49
- private readonly pending;
50
- private opened;
51
- constructor(url: string, headers?: Record<string, string>, wsCtor?: WsConstructorLike | undefined);
52
- open(onData: (chunk: string) => void): Promise<void>;
53
- write(s: string): void;
54
- close(): void;
55
- kill(): void;
56
- onDead(callback: () => void): void;
57
- }
58
- export {};
@@ -1,162 +0,0 @@
1
- // PTY channels — the ONE seam spec §8.2 carves out of better-dsh's
2
- // PtySession: the session machinery (nonce injection, framing, two-stage
3
- // interrupt, idle TTL) is transport-agnostic; only {open, write, kill} differ
4
- // between "a local `script -qfec` subprocess" (this machine as target) and
5
- // "a WebSocket to the peer's dumb PTY executor" (a paired machine as target).
6
- //
7
- // The WS client follows mesh-gateway's crash-proofing lesson: an 'error'
8
- // listener is ALWAYS registered (ECONNREFUSED / a rejected 401 upgrade would
9
- // otherwise be an uncaught exception), and sends before 'open' queue in
10
- // order while post-close sends are swallowed (the session's death path owns
11
- // the teardown).
12
- import { spawn } from 'node:child_process';
13
- import { createRequire } from 'node:module';
14
- /** Local target: `script -qfec bash /dev/null` — host-side PTY, no node-pty (spec §8.2 / D12). */
15
- export class LocalScriptChannel {
16
- argv;
17
- diag = '';
18
- proc = null;
19
- deadCb;
20
- constructor(argv = ['script', '-qfec', 'bash', '/dev/null']) {
21
- this.argv = argv;
22
- }
23
- async open(onData) {
24
- const proc = spawn(this.argv[0], this.argv.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], detached: true });
25
- this.proc = proc;
26
- proc.stdout.setEncoding('utf8');
27
- proc.stdout.on('data', (d) => onData(d));
28
- proc.stderr.setEncoding('utf8').on('data', (d) => { this.diag = (this.diag + d).slice(-2_000); });
29
- proc.once('close', () => this.deadCb?.());
30
- proc.once('error', (err) => { this.diag = (this.diag + `\n[pty: spawn failed: ${err.message}]`).slice(-2_000); this.deadCb?.(); });
31
- }
32
- write(s) {
33
- if (this.proc?.stdin?.writable !== true)
34
- return;
35
- try {
36
- this.proc.stdin.write(s);
37
- }
38
- catch { /* dead: the death callback settles the session */ }
39
- }
40
- close() {
41
- this.write('exit\n');
42
- const pid = this.proc?.pid;
43
- this.proc = null;
44
- if (pid === undefined)
45
- return;
46
- setTimeout(() => { try {
47
- process.kill(-pid, 'SIGKILL');
48
- }
49
- catch { /* already gone */ } }, 1_000).unref();
50
- }
51
- kill() {
52
- const pid = this.proc?.pid;
53
- this.proc = null;
54
- if (pid === undefined)
55
- return;
56
- try {
57
- process.kill(-pid, 'SIGTERM');
58
- }
59
- catch { /* already gone */ }
60
- setTimeout(() => { try {
61
- process.kill(-pid, 'SIGKILL');
62
- }
63
- catch { /* already gone */ } }, 2_000).unref();
64
- }
65
- onDead(callback) { this.deadCb = callback; }
66
- }
67
- const WS_MODULE = 'ws';
68
- let wsConstructor;
69
- function loadWs() {
70
- if (wsConstructor !== undefined)
71
- return wsConstructor;
72
- const require = createRequire(import.meta.url);
73
- const mod = require(WS_MODULE);
74
- const boxed = mod;
75
- wsConstructor = (typeof mod === 'function' ? mod : boxed.default);
76
- return wsConstructor;
77
- }
78
- /** Remote target: a WebSocket to the peer hub's dumb PTY executor, pairing Cookie riding the upgrade. */
79
- export class WsPtyChannel {
80
- url;
81
- headers;
82
- wsCtor;
83
- diag = '';
84
- socket = null;
85
- deadCb;
86
- pending = [];
87
- opened = false;
88
- constructor(url, headers = {}, wsCtor = undefined) {
89
- this.url = url;
90
- this.headers = headers;
91
- this.wsCtor = wsCtor;
92
- }
93
- open(onData) {
94
- return new Promise((resolve, reject) => {
95
- let settled = false;
96
- let socket;
97
- try {
98
- socket = new (this.wsCtor ?? loadWs())(this.url, { headers: this.headers });
99
- }
100
- catch (err) {
101
- reject(err instanceof Error ? err : new Error(String(err)));
102
- return;
103
- }
104
- this.socket = socket;
105
- // crash-proof by construction (mesh-gateway fix-round-1 lesson): 'error'
106
- // ALWAYS has a listener; 'close' is guaranteed to follow and the dead
107
- // path owns the teardown.
108
- socket.on('error', (err) => { this.diag = (this.diag + `\n[pty-ws: ${err instanceof Error ? err.message : String(err)}]`).slice(-2_000); });
109
- socket.on('open', () => {
110
- if (settled)
111
- return;
112
- settled = true;
113
- this.opened = true;
114
- for (const chunk of this.pending.splice(0))
115
- socket.send(chunk);
116
- resolve();
117
- });
118
- socket.on('message', (data) => {
119
- const text = typeof data === 'string'
120
- ? data
121
- : Array.isArray(data)
122
- ? Buffer.concat(data).toString('utf8')
123
- : Buffer.from(data).toString('utf8');
124
- onData(text);
125
- });
126
- socket.on('close', () => {
127
- if (!settled) {
128
- settled = true;
129
- reject(new Error(`pty-ws: socket closed before opening${this.diag !== '' ? `; ${this.diag}` : ''}`));
130
- return;
131
- }
132
- this.opened = false;
133
- this.deadCb?.();
134
- });
135
- });
136
- }
137
- write(s) {
138
- if (this.socket === null)
139
- return;
140
- if (!this.opened) {
141
- this.pending.push(s);
142
- return;
143
- }
144
- try {
145
- this.socket.send(s);
146
- }
147
- catch { /* dead: the close path settles the session */ }
148
- }
149
- close() {
150
- try {
151
- this.socket?.close();
152
- }
153
- catch { /* already closing */ }
154
- }
155
- kill() {
156
- try {
157
- this.socket?.terminate();
158
- }
159
- catch { /* already gone */ }
160
- }
161
- onDead(callback) { this.deadCb = callback; }
162
- }
@@ -1,34 +0,0 @@
1
- import type { OutputStats } from '../../remote/plugin.js';
2
- /** 收帧帧体:退出码 + 只读回传的 cwd(Non-Goal ②:报告不重建)。 */
3
- export interface PtyFrame {
4
- exit: number;
5
- cwd: string | null;
6
- }
7
- /** 8 字节随机串的 hex 形(spec §五.2:每次下发前生成)。 */
8
- export declare function genNonce(bytes?: number): string;
9
- /** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
10
- export declare function wrapPtyCommand(cmd: string, nonce: string): string;
11
- /** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
12
- export declare function buildInitCommand(nonce: string): string;
13
- export declare function stripAnsi(s: string): string;
14
- export declare function normalizePtyText(s: string): string;
15
- export declare function tailWindow(text: string, cap: number, stats?: OutputStats): {
16
- text: string;
17
- truncated?: number;
18
- stats?: OutputStats;
19
- };
20
- export interface FrameParserEvents {
21
- /** 输出文本:feed 路径经 normalize(剥 ANSI/规整换行);flush 路径为**裸放出**(残余字节原样,勿假设已剥洗)。 */
22
- onOutput?(text: string): void;
23
- onFrame(frame: PtyFrame): void;
24
- }
25
- export interface NonceFrameParser {
26
- feed(chunk: string): void;
27
- flush(): void;
28
- }
29
- /**
30
- * 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
31
- * 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
32
- * 缓冲尾若可能是 marker 前缀则扣留,flush() 时作为输出放出。
33
- */
34
- export declare function createNonceFrameParser(nonce: string, events: FrameParserEvents): NonceFrameParser;
@@ -1,89 +0,0 @@
1
- // PTY nonce framing — PORTED VERBATIM from better-dsh src/remote/nonce-framing.ts
2
- // (spec §8.2 / D12 "原样照抄"; only the import specifiers were adapted to this
3
- // package's ESM convention). Command wrap = brace compound + OSC-133;D nonce
4
- // tail carrying exit + cwd; parser holds back possible marker prefixes and
5
- // flushes leftovers raw.
6
- import { randomBytes } from 'node:crypto';
7
- /** 8 字节随机串的 hex 形(spec §五.2:每次下发前生成)。 */
8
- export function genNonce(bytes = 8) {
9
- return randomBytes(bytes).toString('hex');
10
- }
11
- /** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
12
- export function wrapPtyCommand(cmd, nonce) {
13
- return `{\n${cmd}\n} ; printf '\\033]133;D;${nonce};%s;%s\\007' "$?" "$PWD"\n`;
14
- }
15
- /** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
16
- export function buildInitCommand(nonce) {
17
- return `stty -echo 2>/dev/null ; stty rows 40 cols 120 2>/dev/null ; PS1='' ; PS2='' ; export TERM=dumb ; printf '\\033]133;D;${nonce};0;%s\\007' "$PWD"\n`;
18
- }
19
- const ANSI_RE = /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|\[[0-9;?]*[ -/]*[@-~]|[@-Z\\-_])/g;
20
- export function stripAnsi(s) {
21
- return s.replace(ANSI_RE, '');
22
- }
23
- export function normalizePtyText(s) {
24
- return stripAnsi(s).replace(/\r\n/g, '\n').replace(/\r/g, '');
25
- }
26
- export function tailWindow(text, cap, stats) {
27
- let tail = text.slice(-cap);
28
- if (tail.charCodeAt(0) >= 0xdc00 && tail.charCodeAt(0) <= 0xdfff)
29
- tail = tail.slice(1);
30
- const returned = stats?.unit === 'bytes' ? Buffer.byteLength(tail) : tail.length;
31
- const total = stats?.total ?? text.length;
32
- const updated = stats === undefined ? {} : { stats: { ...stats, returned } };
33
- if (total <= returned)
34
- return { text: tail, ...updated };
35
- const unit = stats?.unit === 'bytes' ? 'bytes' : 'chars';
36
- const eviction = stats !== undefined && stats.dropped > 0 ? `; retained ${stats.retained}, dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === 'bytes' ? 'bytes' : 'UTF-16 code units'}` : '';
37
- return { text: `[truncated: showing last ${returned} of ${total} ${unit}${eviction}]\n${tail}`, truncated: total, ...updated };
38
- }
39
- const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/; // cwd 是尾字段:合法路径可含 `;`,只有 BEL/ESC 终止它
40
- /**
41
- * 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
42
- * 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
43
- * 缓冲尾若可能是 marker 前缀则扣留,flush() 时作为输出放出。
44
- */
45
- export function createNonceFrameParser(nonce, events) {
46
- const prefix = `\x1b]133;D;${nonce};`;
47
- let buf = '';
48
- let done = false;
49
- const emit = (raw) => {
50
- const text = normalizePtyText(raw);
51
- if (text.length > 0)
52
- events.onOutput?.(text);
53
- };
54
- const holdback = () => {
55
- const max = Math.min(buf.length, prefix.length - 1);
56
- for (let k = max; k > 0; k--)
57
- if (buf.endsWith(prefix.slice(0, k)))
58
- return k;
59
- return 0;
60
- };
61
- const consume = () => {
62
- if (done)
63
- return;
64
- const i = buf.indexOf(prefix);
65
- if (i < 0) {
66
- const keep = holdback();
67
- emit(buf.slice(0, buf.length - keep));
68
- buf = buf.slice(buf.length - keep);
69
- return;
70
- }
71
- emit(buf.slice(0, i));
72
- const rest = buf.slice(i + prefix.length);
73
- const m = FRAME_BODY_RE.exec(rest);
74
- if (m === null) {
75
- buf = buf.slice(i);
76
- return;
77
- } // 帧体未到齐,整段扣留
78
- events.onFrame({ exit: Number(m[1]), cwd: m[2].length > 0 ? m[2] : null });
79
- done = true; // 帧后字节一律丢弃(下一轮是新解析器)
80
- buf = '';
81
- };
82
- return {
83
- feed(chunk) { buf += chunk; consume(); },
84
- // 流结束 = 裸放出扣留的残余(含未完成的 marker 前缀):不经过 ANSI 剥洗——
85
- // stripAnsi 会把未闭合 OSC 整段吃掉,扣留字节就此蒸发,违背 flush 语义。
86
- flush() { if (buf.length > 0)
87
- events.onOutput?.(buf); buf = ''; },
88
- };
89
- }
@@ -1,78 +0,0 @@
1
- import { type StreamOutputStats } from '../../remote/plugin.js';
2
- import type { PtyChannel } from './exec-pty-channel.js';
3
- /** spec §五.2 逐字。 */
4
- export declare const RECONNECT_NOTICE = "[remote: session reconnected to fresh shell; cwd reset to default]";
5
- export interface PtySessionOptions {
6
- key: string;
7
- channel: PtyChannel;
8
- idleTtlSec: number;
9
- initTimeoutSec?: number;
10
- /** 池回调:会话彻底出池(dispose)时触发;进程意外死亡不出池(Ruling 7)。 */
11
- onDead?: () => void;
12
- }
13
- export interface PtyDispatchOptions {
14
- stdin?: string;
15
- timeoutSec?: number;
16
- }
17
- export interface PtyDispatchResult {
18
- output: string;
19
- outputStats?: StreamOutputStats;
20
- exit: number | null;
21
- cwd: string | null;
22
- timedOut: boolean;
23
- reconnected: boolean;
24
- durationMs: number;
25
- }
26
- export type PtyState = 'cold' | 'starting' | 'ready' | 'dead' | 'disposed';
27
- /** 会话运行时快照(status 面消费,Ruling 16 词汇源)。 */
28
- export interface SessionSnapshot {
29
- state: PtyState;
30
- busy: boolean;
31
- idleMs: number | null;
32
- }
33
- export declare class PtySession {
34
- private readonly opts;
35
- private state;
36
- private startPromise;
37
- private queue;
38
- private idleTimer;
39
- private onChunk;
40
- private deathWaiters;
41
- private inFlight;
42
- private lastSettleAt;
43
- constructor(opts: PtySessionOptions);
44
- get sessionState(): PtyState;
45
- /** status 面快照:state + busy(有命令在跑)+ idleMs(上次 settle 至今)。 */
46
- get snapshot(): SessionSnapshot;
47
- /** 同一会话命令严格串行(FIFO);死亡/冷态先重启。 */
48
- dispatch(cmd: string, dOpts?: PtyDispatchOptions): Promise<PtyDispatchResult>;
49
- /** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
50
- dispose(): Promise<void>;
51
- private write;
52
- private start;
53
- private doStart;
54
- private diag;
55
- private markDead;
56
- private resetIdleTimer;
57
- private clearIdleTimer;
58
- }
59
- export interface PtyPoolDefaults {
60
- idleTtlSec: number;
61
- initTimeoutSec?: number;
62
- }
63
- /** 会话池:键 → 会话。意外死亡的会话留在池内(下次 dispatch 带 reconnect 提示)。 */
64
- export declare class PtyPool {
65
- private readonly defaults;
66
- private readonly sessions;
67
- constructor(defaults: PtyPoolDefaults);
68
- get size(): number;
69
- /** status 面:键上的会话快照(无会话 = undefined → "none — dials on first exec")。 */
70
- inspect(key: string): SessionSnapshot | undefined;
71
- /** roster 面:池内全部会话。 */
72
- list(): Array<{
73
- key: string;
74
- snapshot: SessionSnapshot;
75
- }>;
76
- getOrCreate(key: string, channelOf: () => PtyChannel): PtySession;
77
- disposeAll(): Promise<void>;
78
- }