@deepseek-insight/dsi 0.10.2 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -5
- package/bin/dsi-chassis.mjs +371 -0
- package/bin/dsi.mjs +43 -15
- package/bin/lib/plugin-chain.mjs +6 -5
- package/build/client/_app/immutable/assets/0.Bjw4vcZl.css +2 -0
- package/build/client/_app/immutable/chunks/{D17XAauH.js → CgYTrsc7.js} +2 -2
- package/build/client/_app/immutable/chunks/DAVs74W8.js +1 -0
- package/build/client/_app/immutable/chunks/{BodLd2is.js → DxK-AwFF.js} +1 -1
- package/build/client/_app/immutable/chunks/EyZgnsOk.js +1 -0
- package/build/client/_app/immutable/entry/{app.ClaEF2lH.js → app.BrbZICAs.js} +2 -2
- package/build/client/_app/immutable/entry/start.D0ra5p5U.js +1 -0
- package/build/client/_app/immutable/nodes/{1.CSTMQTZ7.js → 1.BpeytF2X.js} +1 -1
- package/build/client/_app/immutable/nodes/{2.CdbJFHUF.js → 2.BeOldRUA.js} +3 -3
- package/build/client/_app/version.json +1 -1
- package/build/handler.js +4 -4
- package/build/index.js +4 -4
- package/build/server/chunks/chunks/{internal2.js-XxZ8wzRE.js → internal2.js-BmeTE2qk.js} +2 -2
- package/build/server/chunks/chunks/{registry.js-ClhrzR6O.js → registry.js-Cnmd1vW3.js} +196 -21
- package/build/server/chunks/chunks/{state.js-D5zsYmjz.js → state.js-D-A2xekV.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/activate/{_server.ts.js-B-Ns5pyR.js → _server.ts.js-DlbbO3RF.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/close/{_server.ts.js-CzXe-sTr.js → _server.ts.js-B85bLw4w.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/closetab/{_server.ts.js-Ct465_rt.js → _server.ts.js-Dt5Bxv2-.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/element/{_server.ts.js-6mdytkvM.js → _server.ts.js-uJ5BnGUA.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/history/{_server.ts.js-BwUPVQGx.js → _server.ts.js-CVCoWk2n.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/input/{_server.ts.js-8Nq3JxSo.js → _server.ts.js-CmhQ7zGO.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/mcp/{_server.ts.js-B9eKtj1-.js → _server.ts.js-CFtpmSCd.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/navigate/{_server.ts.js-CHJs9Qbl.js → _server.ts.js-BH8MNt9W.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/opentab/{_server.ts.js-B32Ow0Y2.js → _server.ts.js-uIrK0T8h.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/page/{_server.ts.js-Cpv4BolN.js → _server.ts.js-Bg5yHS9F.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/stream/{_server.ts.js-DJqJImSI.js → _server.ts.js-DG6xvUsm.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/tabs/{_server.ts.js-Bm6IAZF6.js → _server.ts.js-RiXP45mV.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/viewport/{_server.ts.js-B1VVzfCk.js → _server.ts.js-w9MNQddQ.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/_id_/zoom/{_server.ts.js-CoUpqHeq.js → _server.ts.js-BoXDoshV.js} +2 -2
- package/build/server/chunks/entries/endpoints/api/browser/{_server.ts.js-D9hK0CG8.js → _server.ts.js-pQmJm_Io.js} +2 -2
- package/build/server/chunks/entries/pages/{_error.svelte.js-TrOSxb0e.js → _error.svelte.js-CL7qPUQ1.js} +3 -3
- package/build/server/chunks/entries/pages/{_page.svelte.js-BUuXoaje.js → _page.svelte.js-nXReJwZQ.js} +4 -4
- package/build/server/chunks/{handler-BOqY82LA.js → handler-DiVCAgmr.js} +3 -3
- package/build/server/chunks/{index.js-M1GginY2.js → index.js-DW6Zd84U.js} +2 -2
- package/build/server/chunks/{manifest.js-BEmwisii.js → manifest.js-DXLiUOif.js} +20 -20
- package/build/server/chunks/nodes/{0.js-Do4ZKaKe.js → 0.js-B8ciwPSu.js} +3 -3
- package/build/server/chunks/nodes/1.js-BX_MW_nG.js +9 -0
- package/build/server/chunks/nodes/{2.js-BFX_zPfJ.js → 2.js-darOh5z6.js} +3 -3
- package/package.json +1 -1
- package/skills/dsi-host-drive/SKILL.md +96 -0
- package/template/.dsi/prompts.sqlite +0 -0
- package/template/.dsi/prompts.sqlite-shm +0 -0
- package/template/.dsi/prompts.sqlite-wal +0 -0
- package/build/client/_app/immutable/assets/0.B2rul4Mk.css +0 -2
- package/build/client/_app/immutable/chunks/Cy4hfhN-.js +0 -1
- package/build/client/_app/immutable/chunks/FY34R_78.js +0 -1
- package/build/client/_app/immutable/entry/start.CU6iOT39.js +0 -1
- package/build/server/chunks/nodes/1.js-CRbdbfof.js +0 -9
- /package/build/client/_app/immutable/nodes/{0.CYP0vr1_.js → 0.DopnsZUT.js} +0 -0
package/README.md
CHANGED
|
@@ -79,20 +79,64 @@ One "floor" is a pair: **DSH** (the harness host) and **DSI** (the page you watc
|
|
|
79
79
|
|
|
80
80
|
| What | Default | How to move it | Plain meaning |
|
|
81
81
|
|---|---|---|---|
|
|
82
|
-
| DSH host | `3080` | `--port <n>` | the harness brain of the floor |
|
|
83
|
-
| DSI page | `5174` | `
|
|
84
|
-
| Browser brain | `9515` | `
|
|
82
|
+
| DSH host | `3080` | `--port <n>` or `DSH_PORT=<n>` env var (flag wins) | the harness brain of the floor |
|
|
83
|
+
| DSI page | `5174` | `DSI_PORT=<n>` env var | the page you open in the browser |
|
|
84
|
+
| Browser brain | `9515` | `GLASS_PORT=<n>` env var | the MCP endpoint agents use to drive the glass browser |
|
|
85
|
+
| Glass chassis | *(this machine)* | `GLASS_CHASSIS_URL=<url>` env var | where Shift+click births its visible window — unset means right here |
|
|
85
86
|
|
|
86
87
|
**The browser brain.** By default the glass browser's assistant (the MCP brain that lets an agent click and type in the browser) runs as its **own separate helper process** — if it fails, you lose only the glass panel. Adding `--playwright-mcp` moves that brain **inside the DSI page server**: one less background process and a faster start, but a brain failure can now touch the page server itself. DSI prints a banner saying so whenever you use the flag — drop the flag to go back to the helper process.
|
|
87
88
|
|
|
88
89
|
**Example — a second, self-contained floor beside a live one** (nothing on the running floor is touched):
|
|
89
90
|
|
|
90
91
|
```bash
|
|
91
|
-
|
|
92
|
+
DSI_PORT=5176 DSH_PORT=3081 GLASS_PORT=19515 npx @deepseek-insight/dsi@latest dsh --sync --playwright-mcp
|
|
92
93
|
# Page: http://localhost:5176 · host: 3081 · brain: http://127.0.0.1:19515/sse
|
|
93
94
|
```
|
|
94
95
|
|
|
95
|
-
**
|
|
96
|
+
**DSI in a VM, Glass on the host.** Run DSI/DSH inside OrbStack (or any VM) while
|
|
97
|
+
the Glass brain stays on your Mac — that is where the headed glass can be seen
|
|
98
|
+
(Shift+Click opens a visible Playwright window, and only the host display can
|
|
99
|
+
show one). The VM floor boots **brain-less** (no brain flag — never spawn a
|
|
100
|
+
second, invisible brain) and exports `GLASS_BRAIN_URL` pointing at the host;
|
|
101
|
+
OrbStack exposes the Mac to its VMs at `host.internal`:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# inside the VM — floor lives here, brain dials the host
|
|
105
|
+
DSI_PORT=5171 DSH_PORT=3081 GLASS_BRAIN_URL=http://host.internal:9515/sse npx @deepseek-insight/dsi@latest dsh --sync
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```mermaid
|
|
109
|
+
sequenceDiagram
|
|
110
|
+
participant M as host Mac
|
|
111
|
+
participant V as VM floor (OrbStack)
|
|
112
|
+
participant G as Glass brain (host 9515)
|
|
113
|
+
M->>M: dsi dsh --sync with the brain flag — Glass lives here
|
|
114
|
+
V->>V: dsi dsh --sync brain-less with GLASS_BRAIN_URL exported
|
|
115
|
+
V->>G: preset dials the host stamp
|
|
116
|
+
G->>G: Shift+Click opens a HEADED window on the host display
|
|
117
|
+
V->>G: tabs, panels, viewport — driven over the wire
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```mermaid
|
|
121
|
+
flowchart TD
|
|
122
|
+
A[floor boots] --> B{GLASS_BRAIN_URL exported?}
|
|
123
|
+
B -- yes --> C[dial that address — VM floor, host brain]
|
|
124
|
+
B -- no --> D[dial 127.0.0.1:GLASS_PORT — same machine]
|
|
125
|
+
C --> E[headed glass opens where the brain runs]
|
|
126
|
+
D --> E
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Second floors are fully drivable.** At boot, a floor stamps its own addresses (`DSI_UI`, `GLASS_BRAIN_URL`) into everything it starts — so agents in *that* floor's conversations drive *that* floor's glass automatically, and the preset brain entry follows the stamped port. Nothing on the primary floor changes: without a stamp (a bare `dsh web` boot), the brain falls back to `9515` and the drivers probe `5175` → `5174` exactly as before. One conversation = one glass owner; the driver refuses to touch another conversation's glass, and a session busy in the live floor shows `writer-held` if you try to prompt it from the second window (watch it there, prompt it on its own floor).
|
|
130
|
+
|
|
131
|
+
**Two glass addresses: brain vs chassis.** The glass has two halves and each has its own stamp. The **brain** (`GLASS_BRAIN_URL`) is where *agents* dial in — the MCP endpoint. The **chassis** (`GLASS_CHASSIS_URL`) is where a Shift+click's *visible window* is born. By default both are "this machine" and nothing changes. When DSI runs on a machine with no screen (a VM) but your screens live on the host, stamp the chassis at the host and the window opens where you are looking:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# in the VM — the window is born on the host, the panel mirrors it
|
|
135
|
+
node bin/dsi-chassis.mjs --bind 192.168.1.9 --port 9525 # on the HOST: the chassis companion
|
|
136
|
+
DSI_PORT=5171 DSH_PORT=3081 GLASS_CHASSIS_URL=http://192.168.1.9:9525 pnpm dsi dsh --sync
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The host-side **companion** (`dsi-chassis`) births each Shift+click glass on a fresh disposable profile — never your real Chrome profile — enforces the same one-conversation-one-glass rule on its side, binds only the address you name (never all interfaces), and bridges CDP back to the floor. When the stamp is set and the companion is down, the panel says so honestly instead of silently falling back to a screenless local launch; unset the stamp and every floor behaves exactly as before.
|
|
96
140
|
|
|
97
141
|
## License
|
|
98
142
|
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsi-chassis — the host companion (ADR-0054 D3/D4, spec 2026-11-08 Glass
|
|
4
|
+
* Chassis Remote CDP task 1.1).
|
|
5
|
+
*
|
|
6
|
+
* A small helper that runs on the machine the operator's SCREENS are on.
|
|
7
|
+
* A floor whose launcher stamped GLASS_CHASSIS_URL at this process asks it
|
|
8
|
+
* to birth the headed glass THERE, by the house rules:
|
|
9
|
+
* - disposable profiles only (the Borrowed Chrome posture — the host's
|
|
10
|
+
* real Chrome profile is never mounted, read, or deletable);
|
|
11
|
+
* - one conversation = one glass, enforced HERE (ownership survives the
|
|
12
|
+
* machine boundary; a busy owner is refused with the writer-held
|
|
13
|
+
* contract);
|
|
14
|
+
* - it binds ONLY the interface address given via the required --bind
|
|
15
|
+
* flag, never 0.0.0.0 (D4);
|
|
16
|
+
* - it owns Chrome's debugging port and speaks clean CDP to the floor —
|
|
17
|
+
* HTTP endpoints are proxied with the Host header rewritten, JSON
|
|
18
|
+
* responses have their webSocketDebuggerUrl rewritten to route back
|
|
19
|
+
* through the companion, and websocket upgrades are raw-piped (this
|
|
20
|
+
* is the loopback/Host-header hop, ADR fact 7).
|
|
21
|
+
*
|
|
22
|
+
* The birth rules (profile naming, owner marker, pid-of-port) come from
|
|
23
|
+
* src/lib/server/browser/chassis-shared.ts — the SAME module the floor's
|
|
24
|
+
* registry imports; plain node strips the types natively (Node >= 23.6).
|
|
25
|
+
*
|
|
26
|
+
* Wire:
|
|
27
|
+
* POST /open { width, height, ownerSessionId } -> { id, cdpTarget, pid, profileDir }
|
|
28
|
+
* 409 { error: 'BUSY' } when ownerSessionId already holds a glass
|
|
29
|
+
* POST /close { id } (or { pid } / { profileDir }) -> { ok }
|
|
30
|
+
* GET /status -> { bind, port, glasses: [...] }
|
|
31
|
+
* /cdp/<id>/... CDP bridge (HTTP + websocket upgrade) to that glass
|
|
32
|
+
*
|
|
33
|
+
* Run: node bin/dsi-chassis.mjs --bind 192.168.1.9 [--port 9560]
|
|
34
|
+
*/
|
|
35
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
36
|
+
import { connect as netConnect } from 'node:net';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
40
|
+
import { pathToFileURL } from 'node:url';
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
disposableProfilePath,
|
|
44
|
+
pidOfPort,
|
|
45
|
+
writeOwnerMarker
|
|
46
|
+
} from '../src/lib/server/browser/chassis-shared.ts';
|
|
47
|
+
|
|
48
|
+
/** The companion's disposable root — the same default the floor config
|
|
49
|
+
* ships (insight-config). The companion runs on the host and answers to
|
|
50
|
+
* the Borrowed Chrome posture, so deletes are GUARDED to this root. */
|
|
51
|
+
const PROFILE_ROOT = join(homedir(), '.dsi', 'browser-profiles');
|
|
52
|
+
|
|
53
|
+
/** D4 — the bind verdict. Pure: `--bind` is REQUIRED and 0.0.0.0 (and the
|
|
54
|
+
* IPv6 all-interfaces '::') are refused. The stamp already names the
|
|
55
|
+
* exact interface; bind-all buys nothing and invites every device. */
|
|
56
|
+
export function resolveBind(bind) {
|
|
57
|
+
if (bind === undefined || bind === '') {
|
|
58
|
+
return { ok: false, error: 'dsi-chassis: --bind <addr> is required (the private interface from the GLASS_CHASSIS_URL stamp); refusing to guess' };
|
|
59
|
+
}
|
|
60
|
+
if (bind === '0.0.0.0' || bind === '::' || bind === '[::]') {
|
|
61
|
+
return { ok: false, error: 'dsi-chassis: refusing 0.0.0.0 — bind the ONE private interface from the stamp (ADR-0054 D4)' };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, addr: bind };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** CLI arg parsing — pure, so the pins drive it directly. */
|
|
67
|
+
export function parseArgs(argv) {
|
|
68
|
+
const out = { bind: undefined, port: undefined };
|
|
69
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
70
|
+
const a = argv[i];
|
|
71
|
+
if (a === '--bind') { out.bind = argv[i + 1]; i += 1; }
|
|
72
|
+
else if (a.startsWith('--bind=')) out.bind = a.slice('--bind='.length);
|
|
73
|
+
else if (a === '--port') { out.port = Number(argv[i + 1]); i += 1; }
|
|
74
|
+
else if (a.startsWith('--port=')) out.port = Number(a.slice('--port='.length));
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A free port for Chrome's debugging listener — net probe, no lsof
|
|
80
|
+
* dependency, same behavior on macOS and Linux hosts. */
|
|
81
|
+
async function freeChassisPort(preferred) {
|
|
82
|
+
const { createServer: probeServer } = await import('node:net');
|
|
83
|
+
const tryBind = (port) =>
|
|
84
|
+
new Promise((resolve) => {
|
|
85
|
+
const s = probeServer();
|
|
86
|
+
s.once('error', () => resolve(false));
|
|
87
|
+
s.once('listening', () => s.close(() => resolve(true)));
|
|
88
|
+
s.listen(port, '127.0.0.1');
|
|
89
|
+
});
|
|
90
|
+
if (preferred && (await tryBind(preferred))) return preferred;
|
|
91
|
+
for (let p = 9520; p < 9620; p += 1) if (await tryBind(p)) return p;
|
|
92
|
+
throw new Error('dsi-chassis: no free debugging port in 9520..9619');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The REAL launcher: headed Chromium on a disposable profile via the
|
|
96
|
+
* repo's pinned playwright-core, channel 'chrome' with the bundled
|
|
97
|
+
* chromium fallback — the same posture registry.open() births with. */
|
|
98
|
+
async function defaultLaunch({ profileDir, args, headless, width, height }) {
|
|
99
|
+
const { chromium } = await import('playwright-core');
|
|
100
|
+
const ignoreDefaultArgs = ['--enable-automation', '--mute-audio', '--hide-scrollbars'];
|
|
101
|
+
// headless honors the floor's plain-click contract (no OS window); only
|
|
102
|
+
// the Shift+click headed open passes false (ADR-0054 D2 — the chassis
|
|
103
|
+
// exists so a HEADED glass can be born where the screens are).
|
|
104
|
+
// Anti-bot parity (registry's PLAIN_IDENTITY posture): a headless birth
|
|
105
|
+
// must not advertise HeadlessChrome — Google captcha'd the first VM-floor
|
|
106
|
+
// plain click on 2026-11-08 while the host floor's spoofed glass passed.
|
|
107
|
+
const identity = headless === true
|
|
108
|
+
? { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' }
|
|
109
|
+
: {};
|
|
110
|
+
// Viewport parity (ADR-0041's local contract, carried across the machine
|
|
111
|
+
// boundary): HEADED is window-driven — viewport null so the page tracks
|
|
112
|
+
// Browser.setWindowBounds. HEADLESS has no OS window: pin the viewport to
|
|
113
|
+
// the requested box (no +6/+70 chassis padding — that pads OS title bars,
|
|
114
|
+
// and a headless glass has none) or the mirror is cut off at the bottom
|
|
115
|
+
// (live witness 2026-11-08: VM plain click vs host panel box).
|
|
116
|
+
const viewport = headless === true
|
|
117
|
+
? { defaultViewport: { width: Number(width) || 730, height: Number(height) || 830 } }
|
|
118
|
+
: { defaultViewport: null };
|
|
119
|
+
let context;
|
|
120
|
+
try {
|
|
121
|
+
context = await chromium.launchPersistentContext(profileDir, { channel: 'chrome', headless: headless === true, ignoreDefaultArgs, ...identity, ...viewport, args });
|
|
122
|
+
} catch {
|
|
123
|
+
context = await chromium.launchPersistentContext(profileDir, { headless: headless === true, ignoreDefaultArgs, ...identity, ...viewport, args });
|
|
124
|
+
}
|
|
125
|
+
const port = Number(args.find((a) => a.startsWith('--remote-debugging-port='))?.split('=')[1]);
|
|
126
|
+
const pid = pidOfPort(port);
|
|
127
|
+
return {
|
|
128
|
+
pid,
|
|
129
|
+
port,
|
|
130
|
+
close: async () => { await context.close(); }
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Start the companion. `launch` is injectable so the pins can drive the
|
|
136
|
+
* whole open/close/ownership/bridge contract without a real Chromium.
|
|
137
|
+
* Returns { server, port, glasses, stop }.
|
|
138
|
+
*/
|
|
139
|
+
export async function startChassisCompanion(opts = {}) {
|
|
140
|
+
const bindVerdict = resolveBind(opts.bind);
|
|
141
|
+
if (!bindVerdict.ok) throw new Error(bindVerdict.error);
|
|
142
|
+
const bind = bindVerdict.ok ? bindVerdict.addr : undefined;
|
|
143
|
+
const launch = opts.launch ?? defaultLaunch;
|
|
144
|
+
const portFor = opts.portFor ?? freeChassisPort;
|
|
145
|
+
// Injectable for the pins (and a VM host may relocate the root); the
|
|
146
|
+
// default stays the Borrowed Chrome root. Deletes are GUARDED to THIS
|
|
147
|
+
// root — the guard is a PATH check, never a flag (the borrowed-wipe RCA).
|
|
148
|
+
const profileRoot = opts.profileRoot ?? PROFILE_ROOT;
|
|
149
|
+
|
|
150
|
+
// The ownership registry lives IN this process: one conversation = one
|
|
151
|
+
// glass, keyed by the requesting floor's ownerSessionId (karpathy edge
|
|
152
|
+
// case: two floors stamping the same companion each carry their own id).
|
|
153
|
+
const glasses = new Map(); // id -> glass
|
|
154
|
+
const byOwner = new Map(); // ownerSessionId -> id
|
|
155
|
+
|
|
156
|
+
const server = createServer(async (req, res) => {
|
|
157
|
+
try {
|
|
158
|
+
const url = new URL(req.url ?? '/', 'http://' + (req.headers.host ?? bind));
|
|
159
|
+
// --- the CDP bridge: /cdp/<id>/... proxies to that glass's port ---
|
|
160
|
+
const cdp = url.pathname.match(/^\/cdp\/([^/]+)(\/.*)$/);
|
|
161
|
+
if (cdp) {
|
|
162
|
+
const glass = glasses.get(cdp[1]);
|
|
163
|
+
if (!glass) { res.statusCode = 404; res.end('unknown glass'); return; }
|
|
164
|
+
const headers = { ...req.headers, host: '127.0.0.1:' + glass.debugPort };
|
|
165
|
+
const preq = httpRequest({ host: '127.0.0.1', port: glass.debugPort, path: cdp[2] + url.search, method: req.method, headers }, (pres) => {
|
|
166
|
+
const ctype = String(pres.headers['content-type'] ?? '');
|
|
167
|
+
if (ctype.includes('json')) {
|
|
168
|
+
const chunks = [];
|
|
169
|
+
pres.on('data', (c) => chunks.push(c));
|
|
170
|
+
pres.on('end', () => {
|
|
171
|
+
// Rewrite the advertised debugger urls to route back
|
|
172
|
+
// through THIS companion — the floor never needs to know
|
|
173
|
+
// Chrome's loopback port (ADR fact 7, the Host hop).
|
|
174
|
+
const body = Buffer.concat(chunks).toString('utf8')
|
|
175
|
+
.split('ws://127.0.0.1:' + glass.debugPort).join('ws://' + (req.headers.host ?? bind + ':' + glass.boundPort) + '/cdp/' + glass.id);
|
|
176
|
+
const headers = { ...pres.headers };
|
|
177
|
+
delete headers['transfer-encoding'];
|
|
178
|
+
res.writeHead(pres.statusCode ?? 200, { ...headers, 'content-length': Buffer.byteLength(body) });
|
|
179
|
+
res.end(body);
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
res.writeHead(pres.statusCode ?? 200, pres.headers);
|
|
184
|
+
pres.pipe(res);
|
|
185
|
+
});
|
|
186
|
+
preq.on('error', () => { try { res.statusCode = 502; res.end('cdp bridge down'); } catch { /* gone */ } });
|
|
187
|
+
req.pipe(preq);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
191
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
192
|
+
res.end(JSON.stringify({
|
|
193
|
+
bind,
|
|
194
|
+
port: glassPort(server),
|
|
195
|
+
glasses: [...glasses.values()].map((g) => ({ id: g.id, ownerSessionId: g.ownerSessionId, pid: g.pid, profileDir: g.profileDir }))
|
|
196
|
+
}));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (req.method === 'POST' && url.pathname === '/open') {
|
|
200
|
+
const chunks = [];
|
|
201
|
+
for await (const c of req) chunks.push(c);
|
|
202
|
+
let body;
|
|
203
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
204
|
+
const ownerSessionId = typeof body.ownerSessionId === 'string' ? body.ownerSessionId : '';
|
|
205
|
+
if (!ownerSessionId) {
|
|
206
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
207
|
+
res.end(JSON.stringify({ error: 'BAD_REQUEST', message: 'ownerSessionId is required — ownership is keyed by the requesting conversation' }));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Writer-held, enforced HERE (D3): a busy owner is refused, and
|
|
211
|
+
// the refusal is HONEST — the floor surfaces it, no fallback.
|
|
212
|
+
if (byOwner.has(ownerSessionId)) {
|
|
213
|
+
res.writeHead(409, { 'content-type': 'application/json' });
|
|
214
|
+
res.end(JSON.stringify({ error: 'BUSY', message: 'writer-held: owner session ' + ownerSessionId + ' already holds a glass on this chassis' }));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const id = crypto.randomUUID();
|
|
218
|
+
const profileDir = disposableProfilePath(profileRoot, id);
|
|
219
|
+
mkdirSync(profileDir, { recursive: true });
|
|
220
|
+
writeOwnerMarker(profileDir); // shared birth rule — the sweep contract holds on the host too
|
|
221
|
+
const debugPort = await portFor(opts.debugPort);
|
|
222
|
+
const width = Math.max(200, Math.min(1920, Number(body.width) || 730));
|
|
223
|
+
const height = Math.max(120, Math.min(1080, Number(body.height) || 830));
|
|
224
|
+
const args = [
|
|
225
|
+
'--no-first-run',
|
|
226
|
+
'--remote-debugging-port=' + debugPort,
|
|
227
|
+
'--disable-dev-shm-usage',
|
|
228
|
+
'--disable-renderer-backgrounding',
|
|
229
|
+
'--disable-background-timer-throttling',
|
|
230
|
+
'--disable-backgrounding-occluded-windows',
|
|
231
|
+
'--disable-blink-features=AutomationControlled',
|
|
232
|
+
'--autoplay-policy=no-user-gesture-required',
|
|
233
|
+
'--window-size=' + (width + 6) + ',' + (height + 70)
|
|
234
|
+
];
|
|
235
|
+
const born = await launch({ profileDir, args, headless: body.headless === true, width, height }).catch((e) => {
|
|
236
|
+
// a failed birth must not leave a profile dir behind (and the
|
|
237
|
+
// error rides the honest 500 to the floor's panel)
|
|
238
|
+
try { if (profileDir.startsWith(profileRoot)) rmSync(profileDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
239
|
+
throw e;
|
|
240
|
+
});
|
|
241
|
+
const glass = {
|
|
242
|
+
id,
|
|
243
|
+
ownerSessionId,
|
|
244
|
+
debugPort,
|
|
245
|
+
pid: born.pid ?? null,
|
|
246
|
+
profileDir,
|
|
247
|
+
close: born.close
|
|
248
|
+
};
|
|
249
|
+
glasses.set(id, glass);
|
|
250
|
+
byOwner.set(ownerSessionId, id);
|
|
251
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
252
|
+
// The cdpTarget routes THROUGH this companion (the bridge above) —
|
|
253
|
+
// the floor attaches its relay to this URL, never to Chrome raw.
|
|
254
|
+
// Root it at the address the floor DIALED (the request's Host
|
|
255
|
+
// header — same idiom as the ws rewrite in the bridge): `bind`
|
|
256
|
+
// is often 127.0.0.1, which only means "this machine" to the
|
|
257
|
+
// HOST — to a remote floor (VM over the OrbStack bridge) it is
|
|
258
|
+
// the wrong machine and connectOverCDP dials itself (witness
|
|
259
|
+
// 2026-11-08: ECONNREFUSED 127.0.0.1:9525 from the VM floor).
|
|
260
|
+
const dialHost = req.headers.host ?? (bind + ':' + glassPort(server));
|
|
261
|
+
res.end(JSON.stringify({ id, cdpTarget: 'http://' + dialHost + '/cdp/' + id, pid: glass.pid, profileDir }));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (req.method === 'POST' && url.pathname === '/close') {
|
|
265
|
+
const chunks = [];
|
|
266
|
+
for await (const c of req) chunks.push(c);
|
|
267
|
+
let body;
|
|
268
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
269
|
+
// Karpathy edge case: the companion may have restarted, so a
|
|
270
|
+
// glass is closable by id, pid, or profileDir — not id alone.
|
|
271
|
+
const glass = glasses.get(String(body.id ?? ''))
|
|
272
|
+
?? [...glasses.values()].find((g) => (body.pid !== undefined && g.pid === body.pid) || (body.profileDir !== undefined && g.profileDir === body.profileDir));
|
|
273
|
+
if (!glass) {
|
|
274
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
275
|
+
res.end(JSON.stringify({ error: 'NO_GLASS', message: 'no such glass on this chassis' }));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
await releaseGlass(glass);
|
|
279
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
280
|
+
res.end(JSON.stringify({ ok: true }));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
res.statusCode = 404;
|
|
284
|
+
res.end();
|
|
285
|
+
} catch (e) {
|
|
286
|
+
// Honest failure: a birth or close that throws is a 500 with the
|
|
287
|
+
// reason — the floor's panel surfaces it verbatim (no fallback).
|
|
288
|
+
try {
|
|
289
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ error: 'CHASSIS_FAILED', message: e instanceof Error ? e.message : String(e) }));
|
|
291
|
+
} catch { /* client gone */ }
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
function releaseGlass(glass) {
|
|
296
|
+
glasses.delete(glass.id);
|
|
297
|
+
if (byOwner.get(glass.ownerSessionId) === glass.id) byOwner.delete(glass.ownerSessionId);
|
|
298
|
+
return (async () => {
|
|
299
|
+
try { await glass.close(); } catch { /* already dead */ }
|
|
300
|
+
try { if (glass.pid) process.kill(glass.pid, 'SIGKILL'); } catch { /* already dead */ }
|
|
301
|
+
// Borrowed Chrome: deletes are GUARDED to the disposable root.
|
|
302
|
+
if (glass.profileDir.startsWith(profileRoot)) rmSync(glass.profileDir, { recursive: true, force: true });
|
|
303
|
+
})();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const upgrade = (req, socket, head) => {
|
|
307
|
+
const m = (req.url ?? '').match(/^\/cdp\/([^/]+)(\/devtools\/.*)$/);
|
|
308
|
+
if (!m) { socket.destroy(); return; }
|
|
309
|
+
const glass = glasses.get(m[1]);
|
|
310
|
+
if (!glass) { socket.destroy(); return; }
|
|
311
|
+
// Raw pipe to Chrome's loopback port; the Host header is rewritten —
|
|
312
|
+
// recent Chrome validates it (ADR fact 7) and the companion is the hop.
|
|
313
|
+
const headers = [];
|
|
314
|
+
const raw = req.rawHeaders;
|
|
315
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
316
|
+
headers.push(raw[i] + ': ' + (raw[i].toLowerCase() === 'host' ? '127.0.0.1:' + glass.debugPort : raw[i + 1]));
|
|
317
|
+
}
|
|
318
|
+
const upstream = netConnect(glass.debugPort, '127.0.0.1', () => {
|
|
319
|
+
upstream.write(req.method + ' ' + m[2] + ' HTTP/1.1\r\n' + headers.join('\r\n') + '\r\n\r\n');
|
|
320
|
+
if (head?.length) upstream.write(head);
|
|
321
|
+
socket.pipe(upstream);
|
|
322
|
+
upstream.pipe(socket);
|
|
323
|
+
});
|
|
324
|
+
const fail = () => { socket.destroy(); upstream.destroy(); };
|
|
325
|
+
upstream.on('error', fail);
|
|
326
|
+
socket.on('error', fail);
|
|
327
|
+
};
|
|
328
|
+
server.on('upgrade', upgrade);
|
|
329
|
+
|
|
330
|
+
const wanted = opts.port ?? 9560;
|
|
331
|
+
const boundPort = await new Promise((res, rej) => {
|
|
332
|
+
server.once('error', rej);
|
|
333
|
+
server.listen(wanted, bind, () => {
|
|
334
|
+
const a = server.address();
|
|
335
|
+
res(typeof a === 'object' && a ? a.port : wanted);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
server,
|
|
340
|
+
port: boundPort,
|
|
341
|
+
glasses,
|
|
342
|
+
stop: async () => {
|
|
343
|
+
server.removeAllListeners('upgrade');
|
|
344
|
+
for (const g of [...glasses.values()]) await releaseGlass(g);
|
|
345
|
+
await new Promise((res) => server.close(() => res()));
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function glassPort(server) {
|
|
351
|
+
const a = server.address();
|
|
352
|
+
return typeof a === 'object' && a ? a.port : 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---- direct run: parse CLI, resolve bind, start, stay up --------------------
|
|
356
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
357
|
+
const { bind, port } = parseArgs(process.argv.slice(2));
|
|
358
|
+
const verdict = resolveBind(bind);
|
|
359
|
+
if (!verdict.ok) {
|
|
360
|
+
console.error(verdict.error);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
startChassisCompanion({ bind: verdict.addr, port })
|
|
364
|
+
.then((c) => {
|
|
365
|
+
console.log('dsi-chassis: listening on ' + verdict.addr + ':' + c.port + ' (stamp me as GLASS_CHASSIS_URL=http://' + verdict.addr + ':' + c.port + ')');
|
|
366
|
+
})
|
|
367
|
+
.catch((e) => {
|
|
368
|
+
console.error('dsi-chassis: ' + (e instanceof Error ? e.message : String(e)));
|
|
369
|
+
process.exit(1);
|
|
370
|
+
});
|
|
371
|
+
}
|
package/bin/dsi.mjs
CHANGED
|
@@ -83,15 +83,21 @@ const USAGE = [
|
|
|
83
83
|
'',
|
|
84
84
|
' Ports: `dsi dsh --sync` serves the DSI page on 5174 and opens your',
|
|
85
85
|
' browser there. With --dev the Vite dev server takes 5175 instead',
|
|
86
|
-
' and the browser follows. Override with
|
|
86
|
+
' and the browser follows. Override with DSI_PORT=<n>. The HOST half',
|
|
87
87
|
' takes 3080 by default; when that port is held by a dsh web you',
|
|
88
88
|
' must keep running (another conversation floor), forward',
|
|
89
|
-
' --port <n> — it goes to `dsh web`
|
|
89
|
+
' --port <n> (or DSH_PORT=<n>; the flag wins) — it goes to `dsh web`',
|
|
90
|
+
' and DSI follows it automatically. GLASS_PORT=<n> moves the brain',
|
|
91
|
+
' (the glass browser MCP, default 9515). A pre-set GLASS_BRAIN_URL',
|
|
92
|
+
' overrides the computed brain address byte-for-byte. On a VM floor,',
|
|
93
|
+
' stamp GLASS_CHASSIS_URL=http://<host>:<port> (run `node bin/dsi-chassis.mjs --bind <host>`',
|
|
94
|
+
' there) and Shift+click births the visible glass window on THAT',
|
|
95
|
+
' machine; unset, the window is born locally exactly as always.',
|
|
90
96
|
'',
|
|
91
97
|
' All commands (flags — see above):',
|
|
92
98
|
'',
|
|
93
99
|
' dsi web [--zai] [--ov] [--dev] [--token <token>]',
|
|
94
|
-
' serve the DSI page alone (
|
|
100
|
+
' serve the DSI page alone (DSI_PORT env default 5174,',
|
|
95
101
|
' HOST env default 127.0.0.1)',
|
|
96
102
|
' dsi dsh --token <token>',
|
|
97
103
|
' store the dsh launch token in ~/.dsi/settings.yaml',
|
|
@@ -387,13 +393,26 @@ function setDshAuthToken(token) {
|
|
|
387
393
|
/** The Stamped Floor (ADR 2026-11-05, D1): every floor announces its own
|
|
388
394
|
* addresses into the env it gives its children — the UI base (the Floor
|
|
389
395
|
* Seam D1a seam, finally with a writer) and the brain URL (the preset's
|
|
390
|
-
* !!js expression reads
|
|
391
|
-
* launcher is the only party that knows both ports at spawn time.
|
|
396
|
+
* !!js expression reads GLASS_BRAIN_URL with a 9515 bare-boot fallback).
|
|
397
|
+
* The launcher is the only party that knows both ports at spawn time.
|
|
398
|
+
* D2 (ADR 2026-11-08): an operator pre-set GLASS_BRAIN_URL passes through
|
|
399
|
+
* byte-for-byte — no validation, no trim; the computed URL is only the
|
|
400
|
+
* default.
|
|
401
|
+
* GLASS_CHASSIS_URL (ADR-0054, 2026-11-08): the SECOND stamp — where the
|
|
402
|
+
* glass WINDOW is born. Same ladder: an operator-exported value passes
|
|
403
|
+
* through byte-for-byte; unset stamps nothing and the registry keeps its
|
|
404
|
+
* local launch exactly as today (D5). It points at the host companion
|
|
405
|
+
* (`node bin/dsi-chassis.mjs --bind <addr>`), not at raw Chrome. */
|
|
392
406
|
function stampedFloorEnv(uiPort) {
|
|
393
|
-
|
|
407
|
+
const env = {
|
|
394
408
|
DSI_UI: 'http://127.0.0.1:' + uiPort,
|
|
395
|
-
|
|
409
|
+
GLASS_BRAIN_URL: process.env.GLASS_BRAIN_URL ?? 'http://127.0.0.1:' + (process.env.GLASS_PORT ?? 9515) + '/sse'
|
|
396
410
|
};
|
|
411
|
+
// Unset means NOTHING is stamped (D5): byte-for-byte today's env shape.
|
|
412
|
+
if (process.env.GLASS_CHASSIS_URL !== undefined) {
|
|
413
|
+
env.GLASS_CHASSIS_URL = process.env.GLASS_CHASSIS_URL;
|
|
414
|
+
}
|
|
415
|
+
return env;
|
|
397
416
|
}
|
|
398
417
|
|
|
399
418
|
function dshWebVersion() {
|
|
@@ -489,7 +508,7 @@ async function runSyncedPair(extraArgs, dev = 'build', execArgv = [], opts = {})
|
|
|
489
508
|
// takes 5175 instead (vite.config.ts pins 5174 strictPort, so pair mode
|
|
490
509
|
// passes 5175 explicitly), giving the two-port dev shape. in-process is
|
|
491
510
|
// also a vite floor: same 5175.
|
|
492
|
-
const uiPort = process.env.
|
|
511
|
+
const uiPort = process.env.DSI_PORT ?? (mode === 'build' ? '5174' : '5175');
|
|
493
512
|
// The host half takes --port <n> when forwarded (a 3080 held by a
|
|
494
513
|
// dsh web this CLI does not own — e.g. another live conversation
|
|
495
514
|
// floor — is exactly the case the preflight must not kill). The
|
|
@@ -506,7 +525,16 @@ async function runSyncedPair(extraArgs, dev = 'build', execArgv = [], opts = {})
|
|
|
506
525
|
// bug 2026-10-11: DSH_HOST_PORT=3477 booted the host there while the
|
|
507
526
|
// page dialed 3080 — the production floor — → "Failed to fetch".)
|
|
508
527
|
const pluginPort = inProcess && process.env.DSH_HOST_PORT ? parseInt(process.env.DSH_HOST_PORT, 10) : undefined;
|
|
509
|
-
|
|
528
|
+
// DSH_PORT (2026-11-08 rename): the env form of the forwarded --port flag —
|
|
529
|
+
// the CLI flag wins when both are set (explicit intent beats ambient env).
|
|
530
|
+
const envHostPort = /^\d+$/.test(process.env.DSH_PORT ?? '') ? Number(process.env.DSH_PORT) : undefined;
|
|
531
|
+
const flaggedHostPort = hostPortFromArgs(extraArgs);
|
|
532
|
+
const hostPort = flaggedHostPort ?? pluginPort ?? envHostPort ?? 3080;
|
|
533
|
+
// No forwarded flag but DSH_PORT set: forward it to `dsh web` as the flag
|
|
534
|
+
// it already understands — the host never learns the env name.
|
|
535
|
+
const hostSpawnArgs = !inProcess && flaggedHostPort === null && envHostPort !== undefined
|
|
536
|
+
? [...extraArgs, '--port', String(envHostPort)]
|
|
537
|
+
: extraArgs;
|
|
510
538
|
const dshBase = hostPort === 3080 ? undefined : `http://127.0.0.1:${hostPort}`;
|
|
511
539
|
if (inProcess) {
|
|
512
540
|
await preflightPort(uiPort, 'DSI page');
|
|
@@ -516,7 +544,7 @@ async function runSyncedPair(extraArgs, dev = 'build', execArgv = [], opts = {})
|
|
|
516
544
|
}
|
|
517
545
|
// IN-PROCESS: no sync script, no host child — the vite plugin owns the
|
|
518
546
|
// host lifecycle (vite close hook stops it; one Ctrl-C, no orphan).
|
|
519
|
-
const host = inProcess ? null : spawn('/bin/sh', [script, ...
|
|
547
|
+
const host = inProcess ? null : spawn('/bin/sh', [script, ...hostSpawnArgs], {
|
|
520
548
|
stdio: 'inherit',
|
|
521
549
|
env: { ...process.env, DSH_WEB_VERSION: dshWebVersion(), ...stampedFloorEnv(uiPort) }
|
|
522
550
|
});
|
|
@@ -766,7 +794,7 @@ function startDsiDevServer(defaultPort = '5175', dshBase = undefined, mode = 'pa
|
|
|
766
794
|
}
|
|
767
795
|
// --host matches the built server's 127.0.0.1 bind: vite's default
|
|
768
796
|
// 'localhost' can resolve ::1-only and strand a 127.0.0.1 client.
|
|
769
|
-
return spawn(process.execPath, [...execArgv, viteBin, 'dev', '--host', process.env.HOST ?? '127.0.0.1', '--port', process.env.
|
|
797
|
+
return spawn(process.execPath, [...execArgv, viteBin, 'dev', '--host', process.env.HOST ?? '127.0.0.1', '--port', process.env.DSI_PORT ?? defaultPort], {
|
|
770
798
|
// D7: inspector flags lead the child argv — node consumes options before
|
|
771
799
|
// the script path, so `node --inspect vite.js dev` debugs THIS process
|
|
772
800
|
// (vite + the embedded DSH host share one heap). The spawn execArgv
|
|
@@ -785,7 +813,7 @@ function startDsiDevServer(defaultPort = '5175', dshBase = undefined, mode = 'pa
|
|
|
785
813
|
DSI_DEV: '1',
|
|
786
814
|
// D4/D6: the mode rides as env, never past the CLI to the host.
|
|
787
815
|
...devModeEnv(mode, { playwrightMcp: opts.playwrightMcp === true }),
|
|
788
|
-
...stampedFloorEnv(process.env.
|
|
816
|
+
...stampedFloorEnv(process.env.DSI_PORT ?? defaultPort),
|
|
789
817
|
// The Faithful Bounce (ADR 2026-10-13, D1): the inspector flags are
|
|
790
818
|
// part of the launch record — stamped so every restorer (the bounce
|
|
791
819
|
// boot via ps -E, the delayed re-serve via inherited env) can put
|
|
@@ -806,7 +834,7 @@ function startDsiServer(defaultPort = '5174', dshBase = undefined, opts = {}) {
|
|
|
806
834
|
stdio: 'inherit',
|
|
807
835
|
env: {
|
|
808
836
|
...process.env,
|
|
809
|
-
PORT: process.env.
|
|
837
|
+
PORT: process.env.DSI_PORT ?? defaultPort,
|
|
810
838
|
HOST: process.env.HOST ?? '127.0.0.1',
|
|
811
839
|
// First-start bootstrap seam: the server clones template/.dsi to ~/.dsi
|
|
812
840
|
// when the operator home is absent (src/lib/server/settings-document.ts).
|
|
@@ -815,7 +843,7 @@ function startDsiServer(defaultPort = '5174', dshBase = undefined, opts = {}) {
|
|
|
815
843
|
// vite child got it via startDsiDevServer's devModeEnv spread; the
|
|
816
844
|
// built server was silently dropping it (caught by the live gate).
|
|
817
845
|
...devModeEnv('build', { playwrightMcp: opts.playwrightMcp === true }),
|
|
818
|
-
...stampedFloorEnv(process.env.
|
|
846
|
+
...stampedFloorEnv(process.env.DSI_PORT ?? defaultPort),
|
|
819
847
|
...(dshBase !== undefined ? { DSH_BASE_URL: dshBase } : {})
|
|
820
848
|
}
|
|
821
849
|
});
|
|
@@ -909,7 +937,7 @@ if (command === 'web') {
|
|
|
909
937
|
// Same browser-follows-the-UI contract as the pair (runSyncedPair): open
|
|
910
938
|
// the default browser at the serving port — --dev's Vite server takes
|
|
911
939
|
// 5175, the built server 5174; PORT/HOST overrides apply.
|
|
912
|
-
openBrowser(`http://${process.env.HOST ?? '127.0.0.1'}:${process.env.
|
|
940
|
+
openBrowser(`http://${process.env.HOST ?? '127.0.0.1'}:${process.env.DSI_PORT ?? (withDev ? '5175' : '5174')}/`);
|
|
913
941
|
|
|
914
942
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
915
943
|
process.on(signal, () => {
|
package/bin/lib/plugin-chain.mjs
CHANGED
|
@@ -158,16 +158,17 @@ export function parseDevFloorPids(psOutput) {
|
|
|
158
158
|
/** The port the floor's DSI UI answers on (ADR-0025 Wave 3 gate finding,
|
|
159
159
|
* 2026-10-14): the host-bounce route is a DSI (vite) route, so the bounce
|
|
160
160
|
* chain must POST the VITE port - the DSH host port only knows the fence
|
|
161
|
-
* (401) or 404. Resolution order:
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* command), so unit tests pin
|
|
161
|
+
* (401) or 404. Resolution order: DSI_PORT= (the 2026-11-08 rename) then
|
|
162
|
+
* legacy PORT= in the floor env dump, then --port N on the vite command
|
|
163
|
+
* line, then the 5175 default the launcher pins. Pure: both inputs are
|
|
164
|
+
* strings read by dsi.mjs (ps -E and ps -o command), so unit tests pin
|
|
165
|
+
* all arms.
|
|
165
166
|
*
|
|
166
167
|
* @param {{ floorEnv?: string, command?: string }} params
|
|
167
168
|
* @returns {number} the UI port
|
|
168
169
|
*/
|
|
169
170
|
export function floorUiPort({ floorEnv = '', command = '' } = {}) {
|
|
170
|
-
const envPort = /(?:^|\s)PORT=(\d+)/.exec(floorEnv || '');
|
|
171
|
+
const envPort = /(?:^|\s)DSI_PORT=(\d+)/.exec(floorEnv || '') || /(?:^|\s)PORT=(\d+)/.exec(floorEnv || '');
|
|
171
172
|
if (envPort) return Number(envPort[1]);
|
|
172
173
|
const argPort = /--port\s+(\d+)/.exec(command || '');
|
|
173
174
|
if (argPort) return Number(argPort[1]);
|