@deepseek-insight/dsi 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.agents/skills/glass-dsi/scripts/_connect.mjs +49 -9
  2. package/.agents/skills/glass-dsi/scripts/glass.mjs +6 -5
  3. package/.agents/skills/glass-url/scripts/_connect.mjs +56 -9
  4. package/.agents/skills/glass-url/scripts/glass-url.mjs +5 -4
  5. package/README.md +21 -0
  6. package/bin/dsi.mjs +44 -7
  7. package/bin/lib/dev-mode.mjs +8 -5
  8. package/bin/lib/glass-browser-bootstrap.mjs +115 -0
  9. package/bin/lib/mcp-banner.mjs +26 -0
  10. package/build/client/_app/immutable/assets/0.CxUWwepa.css +2 -0
  11. package/build/client/_app/immutable/assets/{2.D0QOh44D.css → 2.CSFboNRO.css} +1 -1
  12. package/build/client/_app/immutable/chunks/C4M9oU_3.js +1 -0
  13. package/build/client/_app/immutable/chunks/CNT-CapI.js +1 -0
  14. package/build/client/_app/immutable/chunks/DttyJZuo.js +2 -0
  15. package/build/client/_app/immutable/chunks/{Bu2owQS4.js → UcxQDPMg.js} +1 -1
  16. package/build/client/_app/immutable/entry/{app.DjLAWmJH.js → app.GxH1nznH.js} +2 -2
  17. package/build/client/_app/immutable/entry/start.P-LmBYpD.js +1 -0
  18. package/build/client/_app/immutable/nodes/{1.w6Hz6-dJ.js → 1.BVvBJtMj.js} +1 -1
  19. package/build/client/_app/immutable/nodes/{2.CFmqEODJ.js → 2.D0JzdegJ.js} +107 -107
  20. package/build/client/_app/version.json +1 -1
  21. package/build/handler.js +4 -4
  22. package/build/index.js +4 -4
  23. package/build/server/chunks/chunks/{internal2.js-DRFhzPda.js → internal2.js-BRzR_ZGp.js} +2 -2
  24. package/build/server/chunks/chunks/{registry.js-CWnRQ4vx.js → registry.js-6St5Biiy.js} +96 -10
  25. package/build/server/chunks/chunks/{state.js-kZHonU4N.js → state.js-Ct6h8rMl.js} +2 -2
  26. package/build/server/chunks/entries/endpoints/api/browser/_id_/activate/{_server.ts.js-sXqf-okw.js → _server.ts.js-DYvCel9L.js} +2 -2
  27. package/build/server/chunks/entries/endpoints/api/browser/_id_/close/{_server.ts.js-Byj9ApJh.js → _server.ts.js-CDNbpOqu.js} +2 -2
  28. package/build/server/chunks/entries/endpoints/api/browser/_id_/closetab/{_server.ts.js-DOIsrzvg.js → _server.ts.js-C8UZMTcb.js} +2 -2
  29. package/build/server/chunks/entries/endpoints/api/browser/_id_/element/{_server.ts.js-Cq3gmRfE.js → _server.ts.js-CERwttQS.js} +2 -2
  30. package/build/server/chunks/entries/endpoints/api/browser/_id_/history/{_server.ts.js-BGSLIV0t.js → _server.ts.js-DtVkS-mH.js} +2 -2
  31. package/build/server/chunks/entries/endpoints/api/browser/_id_/input/{_server.ts.js-8slca75-.js → _server.ts.js-C85umKKr.js} +2 -2
  32. package/build/server/chunks/entries/endpoints/api/browser/_id_/mcp/{_server.ts.js-CpbdTYds.js → _server.ts.js-CrTtygPQ.js} +2 -2
  33. package/build/server/chunks/entries/endpoints/api/browser/_id_/navigate/{_server.ts.js-Bls9LtLY.js → _server.ts.js-DgJ6DXVr.js} +2 -2
  34. package/build/server/chunks/entries/endpoints/api/browser/_id_/opentab/{_server.ts.js-DqNi_iW_.js → _server.ts.js-Bj9bKEEv.js} +2 -2
  35. package/build/server/chunks/entries/endpoints/api/browser/_id_/page/{_server.ts.js-DuXWwo7i.js → _server.ts.js--1WTGEfT.js} +2 -2
  36. package/build/server/chunks/entries/endpoints/api/browser/_id_/stream/{_server.ts.js-DXfqBDEw.js → _server.ts.js-D8hQe810.js} +2 -2
  37. package/build/server/chunks/entries/endpoints/api/browser/_id_/tabs/{_server.ts.js-Bgnl8lYs.js → _server.ts.js-CSYYFlm2.js} +2 -2
  38. package/build/server/chunks/entries/endpoints/api/browser/_id_/viewport/{_server.ts.js-FHmQVbrv.js → _server.ts.js-KJjT8zXH.js} +2 -2
  39. package/build/server/chunks/entries/endpoints/api/browser/_id_/zoom/{_server.ts.js-BV4cBtSA.js → _server.ts.js-DQtzgPeG.js} +2 -2
  40. package/build/server/chunks/entries/endpoints/api/browser/{_server.ts.js-4vgkwh7v.js → _server.ts.js-DSkCBGRA.js} +6 -3
  41. package/build/server/chunks/entries/pages/{_error.svelte.js-BKhpJygh.js → _error.svelte.js-BXD7Pthk.js} +3 -3
  42. package/build/server/chunks/entries/pages/{_page.svelte.js-BlbGdGej.js → _page.svelte.js-DaZfwP76.js} +234 -24
  43. package/build/server/chunks/{handler-BtG6aVza.js → handler-C5_-AjlT.js} +3 -3
  44. package/build/server/chunks/{index.js-rGfUdWOY.js → index.js-BECscw6k.js} +2 -2
  45. package/build/server/chunks/{manifest.js-DwQ7BEt1.js → manifest.js-aksIjogg.js} +20 -20
  46. package/build/server/chunks/nodes/{0.js-Cum-Y-us.js → 0.js-xX4GZsu5.js} +3 -3
  47. package/build/server/chunks/nodes/1.js-BaNGyV1A.js +9 -0
  48. package/build/server/chunks/nodes/{2.js-ButkUErW.js → 2.js-CBdFoBFK.js} +4 -4
  49. package/package.json +1 -1
  50. package/template/.dsi/prompts.sqlite-shm +0 -0
  51. package/template/.dsi/prompts.sqlite-wal +0 -0
  52. package/build/client/_app/immutable/assets/0.uhYTRapi.css +0 -2
  53. package/build/client/_app/immutable/chunks/0Uk2rkmL.js +0 -1
  54. package/build/client/_app/immutable/chunks/BaH6C3xD.js +0 -2
  55. package/build/client/_app/immutable/chunks/DXba8Dom.js +0 -1
  56. package/build/client/_app/immutable/entry/start.D-t6KuoK.js +0 -1
  57. package/build/server/chunks/nodes/1.js-D0EwPqp_.js +0 -9
  58. /package/build/client/_app/immutable/nodes/{0.DeDuCaDD.js → 0.CfpMgVS-.js} +0 -0
@@ -26,7 +26,52 @@
26
26
  */
27
27
  import { chromium } from 'playwright-core';
28
28
 
29
- const DSI = 'http://127.0.0.1:5175';
29
+ // ── The Floor Seam (ADR "The Floor Seam" 2026-11-05, D1-D3) ────────────────
30
+ // DSI_UI env verbatim when set; else parallel probe of both known floor
31
+ // bases (5175 dev, 5174 build) with the D7 identity tiebreak. Tab identity
32
+ // is an origin compare against the RESOLVED base — never a port literal.
33
+
34
+ const DEFAULT_FLOOR_BASES = ['http://127.0.0.1:5175', 'http://127.0.0.1:5174'];
35
+
36
+ /** Candidate bases: env seam wins; else dev first, build second. */
37
+ export function floorCandidates(env = process.env) {
38
+ const ui = env.DSI_UI;
39
+ if (ui && /^http:\/\//.test(ui)) return [ui.replace(/\/$/, '')];
40
+ return [...DEFAULT_FLOOR_BASES];
41
+ }
42
+
43
+ /** Resolve the live floor — one answer wins; both → pickSession per floor,
44
+ * my-glass floor wins, double-claim dies loud; none → die naming both. */
45
+ export async function resolveDsiBase({ env = process.env, fetchImpl = fetch, identity = undefined, pickSessionFn = pickSession, dieFn = die, timeoutMs = 3000 } = {}) {
46
+ const candidates = floorCandidates(env);
47
+ const probes = await Promise.all(candidates.map(async (base) => {
48
+ try {
49
+ const res = await fetchImpl(base + '/api/browser', { signal: AbortSignal.timeout(timeoutMs) });
50
+ if (!res.ok) return { base, reg: null };
51
+ return { base, reg: await res.json() };
52
+ } catch { return { base, reg: null }; }
53
+ }));
54
+ const answered = probes.filter((p) => p.reg?.sessions);
55
+ if (answered.length === 0) {
56
+ dieFn('no DSI floor answers — probed ' + candidates.join(', ') +
57
+ ' (dev: dsi dsh --sync --dev --playwright-mcp · build: dsi dsh --sync · or set DSI_UI=<floor url>)');
58
+ }
59
+ if (answered.length === 1) return answered[0];
60
+ const claims = answered.map((p) => ({ ...p, verdict: pickSessionFn(p.reg.sessions, identity) }));
61
+ const mine = claims.filter((c) => c.verdict.session);
62
+ if (mine.length === 1) return mine[0];
63
+ if (mine.length > 1) {
64
+ dieFn('a glass owned by you lives on BOTH floors (' + mine.map((m) => m.base).join(', ') +
65
+ ') — set DSI_UI=<floor url> to name the floor; refusing to guess (D7)');
66
+ }
67
+ dieFn('both floors answer but neither holds YOUR glass (' + answered.map((m) => m.base).join(', ') +
68
+ ') — click the Browser chip in THIS conversation');
69
+ }
70
+
71
+ /** True when a tab url's ORIGIN is the resolved floor's origin (D2). */
72
+ export function isFloorTab(url, base) {
73
+ try { return new URL(url).origin === new URL(base).origin; } catch { return false; }
74
+ }
30
75
 
31
76
  export function die(msg) {
32
77
  console.error(JSON.stringify({ error: msg, fallback: 'SKILL.md Step 1 — ask the OPERATOR to click the Browser chip IN THE RIGHT CONVERSATION, then catch the session via GET /api/browser. If DSH_SESSION_ID is absent from env, pass --owner-session <id> (the session id you already know from context).' }));
@@ -62,20 +107,15 @@ export function pickSession(sessions, envId) {
62
107
  }
63
108
 
64
109
  export async function connect() {
65
- let reg;
66
- try {
67
- reg = await (await fetch(DSI + '/api/browser')).json();
68
- } catch {
69
- die('DSI dev server not reachable on 5175 — floor not up (dsi dsh --sync --dev --playwright-mcp)');
70
- }
110
+ const { base, reg } = await resolveDsiBase({ env: process.env, identity: identityFrom(process.argv) });
71
111
  const { session, degraded, note, error } = pickSession(reg.sessions, identityFrom(process.argv));
72
112
  if (!session) die(error ?? 'no glass session resolved — operator must click the Browser chip');
73
113
  if (degraded || note) console.error(JSON.stringify({ warn: note ?? 'degraded', sessionId: session.id }));
74
114
  const browser = await chromium.connectOverCDP('http://127.0.0.1:' + session.port).catch(() => null);
75
115
  if (!browser) die('CDP connect failed on port ' + session.port);
76
- return { session, browser, ctx: browser.contexts()[0] };
116
+ return { session, browser, ctx: browser.contexts()[0], base };
77
117
  }
78
118
 
79
119
  export function pick(ctx, substr) {
80
120
  return ctx.pages().find((p) => p.url().includes(substr));
81
- }
121
+ }
@@ -20,18 +20,19 @@
20
20
  * On any failure it prints the manual fallback hint (SKILL.md Step 1 —
21
21
  * the operator clicks the Browser chip; agents never open sessions).
22
22
  */
23
- import { connect, die } from './_connect.mjs';
23
+ import { connect, die, isFloorTab } from './_connect.mjs';
24
24
 
25
- const DSI = 'http://127.0.0.1:5175';
26
25
  const [cmd, ...args] = process.argv.slice(2);
27
26
 
28
- const { session, browser, ctx } = await connect();
27
+ // The Floor Seam (ADR 2026-11-05): connect resolves the live floor (DSI_UI
28
+ // env or probe); the DSI tab is its own ORIGIN — never a port literal.
29
+ const { session, browser, ctx, base } = await connect();
29
30
 
30
- let page = ctx.pages().find((p) => p.url().includes('5175'));
31
+ let page = ctx.pages().find((p) => isFloorTab(p.url(), base));
31
32
  if (cmd === 'open') {
32
33
  if (!page) {
33
34
  page = await ctx.newPage();
34
- await page.goto(args[0] ?? DSI, { waitUntil: 'domcontentloaded' }).catch(() => {});
35
+ await page.goto(args[0] ?? base, { waitUntil: 'domcontentloaded' }).catch(() => {});
35
36
  } else if (args[0] && args[0] !== page.url()) {
36
37
  await page.goto(args[0], { waitUntil: 'domcontentloaded' }).catch(() => {});
37
38
  }
@@ -39,7 +39,59 @@ import { chromium } from 'playwright-core';
39
39
  * @property {string} [error]
40
40
  */
41
41
 
42
- const DSI = 'http://127.0.0.1:5175';
42
+ // ── The Floor Seam (ADR "The Floor Seam" 2026-11-05, D1-D3) ────────────────
43
+ // The drivers dial the floor they SEE: DSI_UI env verbatim when set, else a
44
+ // parallel probe of the two known floor bases (5175 dev, 5174 build) with the
45
+ // D7 identity resolver as tiebreak. No port literal may survive in tab
46
+ // filtering — isFloorTab compares origins against the RESOLVED base (D2).
47
+
48
+ const DEFAULT_FLOOR_BASES = ['http://127.0.0.1:5175', 'http://127.0.0.1:5174'];
49
+
50
+ /** Candidate bases: the env seam wins verbatim (validated http), else both
51
+ * known floor bases — dev first so a dev-only machine keeps today's shape. */
52
+ export function floorCandidates(env = process.env) {
53
+ const ui = env.DSI_UI;
54
+ if (ui && /^http:\/\//.test(ui)) return [ui.replace(/\/$/, '')];
55
+ return [...DEFAULT_FLOOR_BASES];
56
+ }
57
+
58
+ /** Resolve the live floor: fetch /api/browser on every candidate in
59
+ * PARALLEL (3 s timeout each), then — one answer wins; both answer →
60
+ * pickSession per floor with the caller's identity, the floor holding the
61
+ * caller's OWN glass wins, a double-claim dies loud (D7 stands); none
62
+ * answer → die naming BOTH ports and BOTH launch commands. */
63
+ export async function resolveDsiBase({ env = process.env, fetchImpl = fetch, identity = undefined, pickSessionFn = pickSession, dieFn = die, timeoutMs = 3000 } = {}) {
64
+ const candidates = floorCandidates(env);
65
+ const probes = await Promise.all(candidates.map(async (base) => {
66
+ try {
67
+ const res = await fetchImpl(base + '/api/browser', { signal: AbortSignal.timeout(timeoutMs) });
68
+ if (!res.ok) return { base, reg: null };
69
+ return { base, reg: await res.json() };
70
+ } catch { return { base, reg: null }; }
71
+ }));
72
+ const answered = probes.filter((p) => p.reg?.sessions);
73
+ if (answered.length === 0) {
74
+ dieFn('no DSI floor answers — probed ' + candidates.join(', ') +
75
+ ' (dev: dsi dsh --sync --dev --playwright-mcp · build: dsi dsh --sync · or set DSI_UI=<floor url>)');
76
+ }
77
+ if (answered.length === 1) return answered[0];
78
+ // Both floors live — the caller's own glass picks the floor (D7 tiebreak).
79
+ const claims = answered.map((p) => ({ ...p, verdict: pickSessionFn(p.reg.sessions, identity) }));
80
+ const mine = claims.filter((c) => c.verdict.session);
81
+ if (mine.length === 1) return mine[0];
82
+ if (mine.length > 1) {
83
+ dieFn('a glass owned by you lives on BOTH floors (' + mine.map((m) => m.base).join(', ') +
84
+ ') — set DSI_UI=<floor url> to name the floor; refusing to guess (D7)');
85
+ }
86
+ dieFn('both floors answer but neither holds YOUR glass (' + answered.map((m) => m.base).join(', ') +
87
+ ') — click the Browser chip in THIS conversation');
88
+ }
89
+
90
+ /** True when a tab url's ORIGIN is the resolved floor's origin (D2): the
91
+ * DSI tab is the floor's own page, whatever port it landed on. */
92
+ export function isFloorTab(url, base) {
93
+ try { return new URL(url).origin === new URL(base).origin; } catch { return false; }
94
+ }
43
95
 
44
96
  /** @param {string} msg @returns {never} */
45
97
  export function die(msg) {
@@ -79,21 +131,16 @@ export function pickSession(sessions, envId) {
79
131
 
80
132
  /** @returns {Promise<{ session: GlassSession, browser: import('playwright-core').Browser, ctx: import('playwright-core').BrowserContext }>} */
81
133
  export async function connect() {
82
- let reg;
83
- try {
84
- reg = await (await fetch(DSI + '/api/browser')).json();
85
- } catch {
86
- die('DSI dev server not reachable on 5175 — floor not up (dsi dsh --sync --dev --playwright-mcp)');
87
- }
134
+ const { base, reg } = await resolveDsiBase({ env: process.env, identity: identityFrom(process.argv) });
88
135
  const { session, degraded, note, error } = pickSession(reg.sessions, identityFrom(process.argv));
89
136
  if (!session) die(error ?? 'no glass session resolved — operator must click the Browser chip');
90
137
  if (degraded || note) console.error(JSON.stringify({ warn: note ?? 'degraded', sessionId: session.id }));
91
138
  const browser = await chromium.connectOverCDP('http://127.0.0.1:' + session.port).catch(() => null);
92
139
  if (!browser) die('CDP connect failed on port ' + session.port);
93
- return { session, browser, ctx: browser.contexts()[0] };
140
+ return { session, browser, ctx: browser.contexts()[0], base };
94
141
  }
95
142
 
96
143
  /** @param {import('playwright-core').BrowserContext} ctx @param {string} substr @returns {import('playwright-core').Page|undefined} */
97
144
  export function pick(ctx, substr) {
98
145
  return ctx.pages().find((p) => p.url().includes(substr));
99
- }
146
+ }
@@ -23,22 +23,23 @@
23
23
  * EXIT DISCIPLINE: this script always process.exit()s — a driver that
24
24
  * played media once hung its caller for a full timeout (RCA trap 5).
25
25
  */
26
- import { connect, die, pick } from './_connect.mjs';
26
+ import { connect, die, pick, isFloorTab } from './_connect.mjs';
27
27
  import { norm, parseEvalArgs, matchAllTerms } from './_args.mjs';
28
28
 
29
29
  const argv = process.argv.slice(2);
30
- const { session, browser, ctx } = await connect();
30
+ const { session, browser, ctx, base } = await connect();
31
31
  const out = (v) => { console.log(JSON.stringify(v, null, 1)); return process.exit(0); };
32
32
 
33
33
  // target tab: url-substring when given; else last http(s) page that is
34
- // neither the glass placeholder nor :5175 (contract, not position — D2).
34
+ // neither the glass placeholder nor the FLOOR's own page — origin compare
35
+ // against the resolved base (The Floor Seam ADR D2; never a port literal).
35
36
  const target = (substr) => {
36
37
  if (substr) {
37
38
  const p = pick(ctx, substr);
38
39
  if (!p) die('no tab matching "' + substr + '" — run: pages');
39
40
  return p;
40
41
  }
41
- const pages = ctx.pages().filter((p) => p.url().startsWith('http') && !p.url().includes(':5175'));
42
+ const pages = ctx.pages().filter((p) => p.url().startsWith('http') && !isFloorTab(p.url(), base));
42
43
  if (!pages.length) die('no non-DSI tab to drive — run: glass-url.mjs open <url>');
43
44
  return pages[pages.length - 1];
44
45
  };
package/README.md CHANGED
@@ -65,6 +65,27 @@ npx @deepseek-insight/dsi@latest dsh --sync --zai
65
65
 
66
66
  When it is up, your browser opens the page by itself (default `http://127.0.0.1:5174`).
67
67
 
68
+ ### Ports & the browser brain, in plain words
69
+
70
+ One "floor" is a pair: **DSH** (the harness host) and **DSI** (the page you watch). Each sits on its own port, and each port can be moved — handy when you want a second floor beside one that is already running.
71
+
72
+ | What | Default | How to move it | Plain meaning |
73
+ |---|---|---|---|
74
+ | DSH host | `3080` | `--port <n>` | the harness brain of the floor |
75
+ | DSI page | `5174` | `PORT=<n>` env var | the page you open in the browser |
76
+ | Browser brain | `9515` | `DSI_MCP_PORT=<n>` env var | the MCP endpoint agents use to drive the glass browser |
77
+
78
+ **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.
79
+
80
+ **Example — a second, self-contained floor beside a live one** (nothing on the running floor is touched):
81
+
82
+ ```bash
83
+ PORT=5176 DSI_MCP_PORT=19515 npx @deepseek-insight/dsi@latest dsh --sync --playwright-mcp --port 3081
84
+ # Page: http://localhost:5176 · host: 3081 · brain: http://127.0.0.1:19515/sse
85
+ ```
86
+
87
+ **Second floors are fully drivable.** At boot, a floor stamps its own addresses (`DSI_UI`, `DSI_MCP_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).
88
+
68
89
  ## License
69
90
 
70
91
  [Apache-2.0](LICENSE) — Copyright © 2026 [Widi Harsojo](https://www.linkedin.com/in/wharsojo/)
package/bin/dsi.mjs CHANGED
@@ -33,7 +33,9 @@ import { parse, stringify } from 'yaml';
33
33
  import { mirrorSkills } from './lib/mirror-skills.mjs';
34
34
  import { dshLauncherCmd, parsePluginArgs, takeoverFloorPorts, hostBounceDecision, floorUiPort, parseDevFloorPids, launchUrl, tokenFromSettings } from './lib/plugin-chain.mjs';
35
35
  import { parseDevArg, devModeEnv, modeFromBoolean, resolveBounceMode, extractInspectArgs, parseInspectArgsFromEnv } from './lib/dev-mode.mjs';
36
+ import { brainBannerLines } from './lib/mcp-banner.mjs';
36
37
  import { addZaiProvider as addZaiPatch, zaiPatchPath } from './lib/zai-patch.mjs';
38
+ import { ensureGlassBrowser } from './lib/glass-browser-bootstrap.mjs';
37
39
 
38
40
  // Layman-facing help: wrapped near 76 columns, continuation lines indented,
39
41
  // the recommended one-liner first. --zai is optional on purpose — no GLM
@@ -177,15 +179,32 @@ if (command === 'dsh') {
177
179
  const rest = process.argv.slice(4);
178
180
  const withZai = rest.includes('--zai');
179
181
  const withOv = rest.includes('--ov');
180
- // The Glass Browser (ADR-0029 D7): --playwright-mcp opts the DEV floor
181
- // into the in-process MCP brain — a dsi-side flag, stripped before the
182
- // host spawn (dsh web would refuse it) and carried to vite as an env
183
- // marker, the exact idiom of DSI_DEV_IN_PROCESS.
182
+ // The Glass Browser (ADR-0029 D7, amendment A6 D7c): --playwright-mcp
183
+ // opts ANY floor into the in-process MCP brain — a dsi-side flag,
184
+ // stripped before the host spawn (dsh web would refuse it) and carried
185
+ // to the server as an env marker (DSI_MCP_IN_PROCESS, dev-mode.mjs).
184
186
  const withPlaywrightMcp = rest.includes('--playwright-mcp');
185
187
  const restNoMcp = withPlaywrightMcp ? rest.filter((a) => a !== '--playwright-mcp') : rest;
188
+ // A6 D7d: say out loud what the flag chose, in plain language, BEFORE
189
+ // any child spawns — an unannounced behavior change is a silent trap.
190
+ if (withPlaywrightMcp) {
191
+ const { mode: bannerMode } = parseDevArg(restNoMcp);
192
+ for (const line of brainBannerLines(bannerMode !== 'build')) console.log(line);
193
+ }
186
194
  if (withZai) addZaiProvider();
187
195
  if (withOv) runOvPlugin();
188
196
  runSkillsMirror();
197
+ // The Glass Bootstrap (ADR 2026-11-05, D1): probe the glass browser
198
+ // BEFORE the pair spawns — system Chrome first, cached chromium second,
199
+ // a one-time pinned fetch last (soft failure; DSI_SKIP_BROWSER_INSTALL=1
200
+ // skips). The bounce chain never fetches — only this first-run branch.
201
+ const glass = ensureGlassBrowser({
202
+ packageRoot,
203
+ spawnSync,
204
+ env: process.env,
205
+ log: (line) => console.log(line)
206
+ });
207
+ if (glass.action === 'fetched') console.log(`dsi dsh: chromium ${glass.reason} — the glass browser is ready`);
189
208
  // --dev parse (D4): 'in-process' | 'pair' consumed as a value; bare
190
209
  // --dev stays 'pair'. dsi-side flags stripped before the host spawn.
191
210
  const { mode, rest: hostArgs0 } = parseDevArg(restNoMcp);
@@ -365,6 +384,18 @@ function setDshAuthToken(token) {
365
384
  * falls back to the registry's latest tag). An exact pin, not a range: a
366
385
  * range drifts to whatever the registry resolves, and the pin exists
367
386
  * precisely because latest can change the API contract under DSI. */
387
+ /** The Stamped Floor (ADR 2026-11-05, D1): every floor announces its own
388
+ * addresses into the env it gives its children — the UI base (the Floor
389
+ * Seam D1a seam, finally with a writer) and the brain URL (the preset's
390
+ * !!js expression reads DSI_MCP_URL with a 9515 bare-boot fallback). The
391
+ * launcher is the only party that knows both ports at spawn time. */
392
+ function stampedFloorEnv(uiPort) {
393
+ return {
394
+ DSI_UI: 'http://127.0.0.1:' + uiPort,
395
+ DSI_MCP_URL: 'http://127.0.0.1:' + (process.env.DSI_MCP_PORT ?? 9515) + '/sse'
396
+ };
397
+ }
398
+
368
399
  function dshWebVersion() {
369
400
  const doc = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
370
401
  const version = doc?.dsh?.webVersion;
@@ -487,9 +518,9 @@ async function runSyncedPair(extraArgs, dev = 'build', execArgv = [], opts = {})
487
518
  // host lifecycle (vite close hook stops it; one Ctrl-C, no orphan).
488
519
  const host = inProcess ? null : spawn('/bin/sh', [script, ...extraArgs], {
489
520
  stdio: 'inherit',
490
- env: { ...process.env, DSH_WEB_VERSION: dshWebVersion() }
521
+ env: { ...process.env, DSH_WEB_VERSION: dshWebVersion(), ...stampedFloorEnv(uiPort) }
491
522
  });
492
- const ui = mode === 'build' ? startDsiServer(uiPort, dshBase) : startDsiDevServer(uiPort, dshBase, mode, execArgv, opts);
523
+ const ui = mode === 'build' ? startDsiServer(uiPort, dshBase, opts) : startDsiDevServer(uiPort, dshBase, mode, execArgv, opts);
493
524
  // The browser follows the UI: the built page (5174) — or, in --dev mode,
494
525
  // the dev server (5175). Gated on the HOST actually listening (bug
495
526
  // 2026-10-17): the fixed 2s timer lost every race the host booted slower
@@ -754,6 +785,7 @@ function startDsiDevServer(defaultPort = '5175', dshBase = undefined, mode = 'pa
754
785
  DSI_DEV: '1',
755
786
  // D4/D6: the mode rides as env, never past the CLI to the host.
756
787
  ...devModeEnv(mode, { playwrightMcp: opts.playwrightMcp === true }),
788
+ ...stampedFloorEnv(process.env.PORT ?? defaultPort),
757
789
  // The Faithful Bounce (ADR 2026-10-13, D1): the inspector flags are
758
790
  // part of the launch record — stamped so every restorer (the bounce
759
791
  // boot via ps -E, the delayed re-serve via inherited env) can put
@@ -764,7 +796,7 @@ function startDsiDevServer(defaultPort = '5175', dshBase = undefined, mode = 'pa
764
796
  });
765
797
  }
766
798
 
767
- function startDsiServer(defaultPort = '5174', dshBase = undefined) {
799
+ function startDsiServer(defaultPort = '5174', dshBase = undefined, opts = {}) {
768
800
  const serverEntry = path.join(packageRoot, 'build', 'index.js');
769
801
  if (!existsSync(serverEntry)) {
770
802
  console.error(`dsi: no build found at ${serverEntry} — run \`pnpm run build\` first`);
@@ -779,6 +811,11 @@ function startDsiServer(defaultPort = '5174', dshBase = undefined) {
779
811
  // First-start bootstrap seam: the server clones template/.dsi to ~/.dsi
780
812
  // when the operator home is absent (src/lib/server/settings-document.ts).
781
813
  DSI_TEMPLATE_PATH: process.env.DSI_TEMPLATE_PATH ?? path.join(packageRoot, 'template', '.dsi'),
814
+ // A6 D7c: the brain marker rides EVERY floor, build included — the
815
+ // vite child got it via startDsiDevServer's devModeEnv spread; the
816
+ // built server was silently dropping it (caught by the live gate).
817
+ ...devModeEnv('build', { playwrightMcp: opts.playwrightMcp === true }),
818
+ ...stampedFloorEnv(process.env.PORT ?? defaultPort),
782
819
  ...(dshBase !== undefined ? { DSH_BASE_URL: dshBase } : {})
783
820
  }
784
821
  });
@@ -54,9 +54,15 @@ export function parseDevArg(rest) {
54
54
  * @returns {Record<string, string>}
55
55
  */
56
56
  export function devModeEnv(mode, opts = {}) {
57
- if (mode === 'build') return {};
58
57
  /** @type {Record<string, string>} */
59
- const env = { DSI_DEV: '1', DSI_DEV_MODE: mode };
58
+ const env = {};
59
+ // ADR-0029 amendment A6 D7c: the glass brain's placement is the operator's
60
+ // choice on EVERY floor — build included. DSI_DEV/DSI_DEV_MODE stay
61
+ // strictly dev-only (the bounce chain probes them as dev-floor markers).
62
+ if (opts.playwrightMcp) env.DSI_MCP_IN_PROCESS = '1';
63
+ if (mode === 'build') return env;
64
+ env.DSI_DEV = '1';
65
+ env.DSI_DEV_MODE = mode;
60
66
  if (mode === 'in-process') {
61
67
  env.DSI_DEV_IN_PROCESS = '1';
62
68
  // ADR-0025 Wave 3 (real-floor gate finding, 2026-10-14): the bounce
@@ -67,9 +73,6 @@ export function devModeEnv(mode, opts = {}) {
67
73
  // that carries the handle, so in-process floors default to it.
68
74
  env.DSH_HOST_INTROSPECT = '1';
69
75
  }
70
- // ADR-0029 D7: the glass browser's MCP brain runs in-process (opt-in,
71
- // dev floor only — the tandem/production floors keep the stdio child)
72
- if (opts.playwrightMcp) env.DSI_MCP_IN_PROCESS = '1';
73
76
  return env;
74
77
  }
75
78
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Glass browser bootstrap (ADR "The Glass Bootstrap" 2026-11-05, D1-D4):
3
+ * decide whether the floor's glass browser has a binary it can launch, and
4
+ * fetch the pinned chromium once when it does not. Pure decision logic —
5
+ * every effect (spawnSync, env, log, platform) is injected, so the unit
6
+ * pins exercise the full decision tree without a network or a real cache.
7
+ *
8
+ * Probe order IS the launch order (D2): registry.ts tries system Google
9
+ * Chrome (channel: 'chrome') FIRST and falls back to the bundled chromium,
10
+ * so a machine with Chrome must never pay for a chromium download.
11
+ *
12
+ * Soft failure is the contract (D4): a failed or skipped fetch warns and
13
+ * returns — the floor boots; only the glass is affected, and the launch
14
+ * site's honest error (D5) names the cure.
15
+ */
16
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ /** Chrome candidates probed on PATH (Linux shape). macOS has no google-chrome
21
+ * on PATH — the .app bundle is the truth (checked separately). */
22
+ const CHROME_PATH_CANDIDATES = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'];
23
+
24
+ /** The pinned playwright family, derived from this package's own
25
+ * playwright-core dependency (D3: no second hardcoded pin may exist).
26
+ * Returns null when the pin cannot be read — the caller then warns and
27
+ * skips the fetch; a null pin never spawns npx. */
28
+ export function pinnedPlaywrightVersion(packageRoot) {
29
+ try {
30
+ const doc = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
31
+ const version = doc?.dependencies?.['playwright-core'];
32
+ return typeof version === 'string' && /^\d+\.\d+\.\d+/.test(version) ? version : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** True when a system Google/Chromium browser is resolvable: PATH probes on
39
+ * Linux, the .app bundle on macOS (where PATH probing is always blind). */
40
+ export function findSystemChrome(spawnSync, platform = process.platform, exists = existsSync) {
41
+ if (platform === 'darwin') {
42
+ return exists('/Applications/Google Chrome.app');
43
+ }
44
+ for (const name of CHROME_PATH_CANDIDATES) {
45
+ try {
46
+ if (spawnSync('which', [name], { stdio: 'pipe' }).status === 0) return true;
47
+ } catch {
48
+ // no which (or no sh) — the next candidate still probes
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /** True when ANY chromium-family build sits in the playwright cache (either
55
+ * platform root). The npx fetch is the revision authority — this probe is
56
+ * the cheap hint that avoids re-downloading what is already present. */
57
+ export function playwrightCacheHasChromium(env = process.env, readdir = readdirSync) {
58
+ const home = env.HOME ?? homedir();
59
+ const roots = [
60
+ path.join(home, 'Library', 'Caches', 'ms-playwright'),
61
+ path.join(home, '.cache', 'ms-playwright')
62
+ ];
63
+ for (const root of roots) {
64
+ try {
65
+ if (readdir(root).some((entry) => entry.startsWith('chromium'))) return true;
66
+ } catch {
67
+ // unreadable/absent root — the next root still probes
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * The D2 decision tree. Sync by construction: every step is a stat, a
75
+ * readdir, or one spawnSync child. Returns a verdict the launcher logs:
76
+ * { action: 'skip', reason } — a browser exists (or the hatch fired)
77
+ * { action: 'fetched', reason } — the pinned chromium landed in the cache
78
+ * { action: 'warned', reason } — no browser and the fetch failed/skipped;
79
+ * the floor still boots (D4)
80
+ */
81
+ export function ensureGlassBrowser({ packageRoot, spawnSync, env = process.env, log = () => {}, platform = process.platform }) {
82
+ if (env.DSI_SKIP_BROWSER_INSTALL === '1') {
83
+ log('dsi dsh: glass browser check skipped (DSI_SKIP_BROWSER_INSTALL=1)');
84
+ return { action: 'skip', reason: 'env-escape-hatch' };
85
+ }
86
+ if (findSystemChrome(spawnSync, platform)) {
87
+ return { action: 'skip', reason: 'system-chrome' };
88
+ }
89
+ if (playwrightCacheHasChromium(env)) {
90
+ return { action: 'skip', reason: 'cached-chromium' };
91
+ }
92
+ const pin = pinnedPlaywrightVersion(packageRoot);
93
+ if (pin === null) {
94
+ log('dsi dsh: glass browser — no playwright-core pin readable in package.json, skipping the chromium fetch');
95
+ return { action: 'warned', reason: 'no-pin' };
96
+ }
97
+ // The loud line comes BEFORE the fetch child (D1): the silent-minutes
98
+ // bug (2026-11-06) must never repeat on this stage.
99
+ log(`dsi dsh: glass browser missing — downloading chromium (~150 MB, one-time, playwright@${pin})…`);
100
+ let result;
101
+ try {
102
+ result = spawnSync('npx', ['--yes', `playwright@${pin}`, 'install', 'chromium'], {
103
+ stdio: 'inherit',
104
+ timeout: 300000
105
+ });
106
+ } catch (error) {
107
+ log(`dsi dsh: chromium fetch failed (${error?.message ?? error}) — the floor still boots; the glass needs: npx playwright@${pin} install chromium`);
108
+ return { action: 'warned', reason: 'fetch-error' };
109
+ }
110
+ if ((result?.status ?? 1) === 0) {
111
+ return { action: 'fetched', reason: `playwright@${pin}` };
112
+ }
113
+ log(`dsi dsh: chromium fetch failed (exit ${result?.status}) — the floor still boots; the glass needs: npx playwright@${pin} install chromium`);
114
+ return { action: 'warned', reason: 'fetch-failed' };
115
+ }
@@ -0,0 +1,26 @@
1
+ // mcp-banner — A6 D7d: when --playwright-mcp is present, say out loud what
2
+ // the operator just chose, in plain language, BEFORE any child spawns.
3
+ // Extracted as a module so the exact text is unit-pinned (T3-T).
4
+
5
+ /** @param {boolean} devFloor - true when a --dev mode was chosen
6
+ * @returns {string[]} banner lines (never empty; the caller prints each) */
7
+ export function brainBannerLines(devFloor) {
8
+ const blast = devFloor
9
+ ? 'a brain failure can now touch this dev floor server.'
10
+ : 'a brain failure can now touch this page server — not just the glass.';
11
+ return [
12
+ '',
13
+ '────────── playwright-mcp: in-process browser brain ──────────',
14
+ 'You asked for the browser-assist brain to run INSIDE the DSI page',
15
+ 'server instead of as its own separate background helper.',
16
+ '',
17
+ 'What that means:',
18
+ ' + one less background process on your machine, faster start',
19
+ ' - ' + blast,
20
+ ' Before, it could only lose the glass panel, never the server.',
21
+ '',
22
+ 'Drop the --playwright-mcp flag to go back to the old helper process.',
23
+ '───────────────────────────────────────────────────────────────',
24
+ ''
25
+ ];
26
+ }