@deepseek-insight/dsi 0.6.14 → 0.6.16

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 (91) hide show
  1. package/.agents/skills/dsi-ports/SKILL.md +61 -0
  2. package/.agents/skills/dsi-restart/SKILL.md +85 -0
  3. package/bin/dsi.mjs +148 -39
  4. package/bin/lib/dev-mode.mjs +138 -0
  5. package/bin/lib/plugin-chain.mjs +47 -0
  6. package/build/client/_app/immutable/assets/0.KhAZv9mf.css +2 -0
  7. package/build/client/_app/immutable/assets/2.yPxdQ-n0.css +1 -0
  8. package/build/client/_app/immutable/chunks/{CiLhSVIj.js → -izoh3h0.js} +1 -1
  9. package/build/client/_app/immutable/chunks/{Dj4_qcIH.js → B-V7Vlsz.js} +4 -4
  10. package/build/client/_app/immutable/chunks/{CP4wchdA.js → BXGojodM.js} +2 -2
  11. package/build/client/_app/immutable/chunks/C0ZiNuOj.js +1 -0
  12. package/build/client/_app/immutable/chunks/{C_Gd0Wor.js → CbtU_GW3.js} +1 -1
  13. package/build/client/_app/immutable/chunks/{GdM4UPkS.js → Ck2086Sr.js} +1 -1
  14. package/build/client/_app/immutable/chunks/{C8seIiU_.js → D3rUyYDm.js} +1 -1
  15. package/build/client/_app/immutable/chunks/{uEyn1Z7p.js → DOkUpqkd.js} +1 -1
  16. package/build/client/_app/immutable/chunks/b58grRSF.js +1 -0
  17. package/build/client/_app/immutable/chunks/{2KTqoQH-.js → q1Yj7q99.js} +1 -1
  18. package/build/client/_app/immutable/entry/{app.CCuVGpQc.js → app.CfbFUHxw.js} +2 -2
  19. package/build/client/_app/immutable/entry/start.4M41VX7w.js +1 -0
  20. package/build/client/_app/immutable/nodes/{1.CaQ_jAq8.js → 1.C2BfYC2f.js} +1 -1
  21. package/build/client/_app/immutable/nodes/{2.DxOLlTKc.js → 2.UdTbIvwR.js} +220 -220
  22. package/build/client/_app/version.json +1 -1
  23. package/build/handler.js +4 -4
  24. package/build/index.js +4 -4
  25. package/build/server/chunks/chunks/{dsh-connection.js-DRtqPR_j.js → dsh-connection.js-DvncrWM4.js} +5 -2
  26. package/build/server/chunks/chunks/{git-probe.js-Daf6r7Z9.js → git-probe.js-S9w9gGXE.js} +2 -2
  27. package/build/server/chunks/chunks/{internal2.js-DAPn3Vk8.js → internal2.js-DMu68nYR.js} +2 -2
  28. package/build/server/chunks/chunks/restart-chain.js-D-VwGWE2.js +112 -0
  29. package/build/server/chunks/chunks/{state.js-CsIZcr7q.js → state.js-CBD6RE0B.js} +2 -2
  30. package/build/server/chunks/chunks/{watcher.js-CoR53lwR.js → watcher.js-BfZOp5td.js} +2 -2
  31. package/build/server/chunks/entries/endpoints/api/a2a/{_server.ts.js-BKlF4XGH.js → _server.ts.js-D9vpMh8K.js} +3 -3
  32. package/build/server/chunks/entries/endpoints/api/a2a/register/{_server.ts.js-BpEhlidV.js → _server.ts.js-HPJEmfbv.js} +3 -3
  33. package/build/server/chunks/entries/endpoints/api/dsh/directory/{_server.ts.js-Dmh7f-hU.js → _server.ts.js-BaAmRXrU.js} +2 -2
  34. package/build/server/chunks/entries/endpoints/api/dsh/pick-directory/{_server.ts.js-BEci0ZAx.js → _server.ts.js-D-Ftq5ei.js} +2 -2
  35. package/build/server/chunks/entries/endpoints/api/dsh/presets/{_server.ts.js-OXFzXBSp.js → _server.ts.js-C0_j53OW.js} +2 -2
  36. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/attachment/{_server.ts.js-x44GKy4R.js → _server.ts.js-CxMvUVdS.js} +2 -2
  37. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/cancel/{_server.ts.js-Da5ZqeJ7.js → _server.ts.js-CfjRtKMp.js} +2 -2
  38. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/catalog/{_server.ts.js-BACv9hW-.js → _server.ts.js-D7uC5b_j.js} +2 -2
  39. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/changes/{_server.ts.js-BtiUS3iD.js → _server.ts.js-TeoUfrIl.js} +2 -2
  40. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/command/{_server.ts.js-BOOyd1LI.js → _server.ts.js-uqUb-EdM.js} +2 -2
  41. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/events/{_server.ts.js-D0GwQbdF.js → _server.ts.js-C_DF0fAc.js} +2 -2
  42. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/fork/{_server.ts.js-Bd0JYMlf.js → _server.ts.js-BfkqDQXU.js} +2 -2
  43. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/goal/{_server.ts.js-DqAV7OOv.js → _server.ts.js-vCfxCACS.js} +2 -2
  44. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/history/{_server.ts.js-Cz4P-68E.js → _server.ts.js-DJlpW4hl.js} +2 -2
  45. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/models/{_server.ts.js-C4fXXBIM.js → _server.ts.js-Cqn5ZMGh.js} +2 -2
  46. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/permission/{_server.ts.js-BiqNZiiN.js → _server.ts.js-ChB_0ZdM.js} +2 -2
  47. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/prompt/{_server.ts.js-Bdx9V4oY.js → _server.ts.js-BVHypcWF.js} +2 -2
  48. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/rename/{_server.ts.js-CuBqoV3c.js → _server.ts.js-BwDVfVrR.js} +2 -2
  49. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/respond/{_server.ts.js-CXPFI_Pn.js → _server.ts.js-DRmd83Ea.js} +2 -2
  50. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/select-model/{_server.ts.js-CRcaw4Yo.js → _server.ts.js-hc61Nb64.js} +2 -2
  51. package/build/server/chunks/entries/endpoints/api/dsh/sessions/{_server.ts.js-dYrtVHDP.js → _server.ts.js-xkp4IT7y.js} +2 -2
  52. package/build/server/chunks/entries/endpoints/api/dsh/workspace-file/{_server.ts.js-BtlfuMjf.js → _server.ts.js-BvPKBiTg.js} +2 -2
  53. package/build/server/chunks/entries/endpoints/api/dsh/workspace-tree/{_server.ts.js-D12kAP9B.js → _server.ts.js-BSsF01K6.js} +2 -2
  54. package/build/server/chunks/entries/endpoints/api/dsh/workspaces/{_server.ts.js-C9JHp1WU.js → _server.ts.js-DNLxFs1R.js} +2 -2
  55. package/build/server/chunks/entries/endpoints/api/dsh/workspaces/_workspaceId_/delete/{_server.ts.js-bLF4Fbgn.js → _server.ts.js-5jaRPaKn.js} +2 -2
  56. package/build/server/chunks/entries/endpoints/api/dsh/workspaces/_workspaceId_/rename/{_server.ts.js-BSw_zD-0.js → _server.ts.js-A-hvBqyy.js} +2 -2
  57. package/build/server/chunks/entries/endpoints/api/floor/mode/_server.ts.js-CzBvebv2.js +25 -0
  58. package/build/server/chunks/entries/endpoints/api/floor/restart/_server.ts.js-D5wnUph-.js +30 -0
  59. package/build/server/chunks/entries/endpoints/api/plugins/apply/{_server.ts.js-BNEUWbaf.js → _server.ts.js-CCs56XtH.js} +5 -56
  60. package/build/server/chunks/entries/endpoints/api/workspace/file-write/{_server.ts.js-CqyKA9g2.js → _server.ts.js-C0x68iMD.js} +3 -3
  61. package/build/server/chunks/entries/endpoints/api/workspace/git-events/{_server.ts.js-yu695YoE.js → _server.ts.js-DzdGKZdV.js} +3 -3
  62. package/build/server/chunks/entries/endpoints/api/workspace/git-file-head/{_server.ts.js-BhDAvM6y.js → _server.ts.js-BC1jb3zy.js} +3 -3
  63. package/build/server/chunks/entries/endpoints/api/workspace/git-map/{_server.ts.js-BRUgPrJ2.js → _server.ts.js-BE9XJyhN.js} +3 -3
  64. package/build/server/chunks/entries/endpoints/api/workspace/git-status/{_server.ts.js-C7VJ2G4_.js → _server.ts.js-CWlGtOu8.js} +3 -3
  65. package/build/server/chunks/entries/endpoints/api/workspace/write-gate/{_server.ts.js-BH5BRA_d.js → _server.ts.js-CgcggNrH.js} +3 -3
  66. package/build/server/chunks/entries/pages/{_error.svelte.js-B5TEtccE.js → _error.svelte.js-CKrYUsNh.js} +3 -3
  67. package/build/server/chunks/entries/pages/{_page.server.ts.js-CityLNBO.js → _page.server.ts.js-CbzURHkB.js} +2 -2
  68. package/build/server/chunks/entries/pages/{_page.svelte.js-i6KsWK9O.js → _page.svelte.js-Bub68nnw.js} +218 -104
  69. package/build/server/chunks/{handler-XuK3Ihhf.js → handler-29vKRsIm.js} +3 -3
  70. package/build/server/chunks/{index.js-D2FEiD0I.js → index.js-BpxR9RqE.js} +2 -2
  71. package/build/server/chunks/{manifest.js-B_kahcFI.js → manifest.js-BUA2pFJz.js} +52 -38
  72. package/build/server/chunks/nodes/{0.js-C0B2o0le.js → 0.js-zSkx80Fq.js} +3 -3
  73. package/build/server/chunks/nodes/1.js-BFDsDe_G.js +9 -0
  74. package/build/server/chunks/nodes/{2.js-Bh3kLeYq.js → 2.js-B3UD9xCC.js} +6 -6
  75. package/package.json +1 -1
  76. package/template/.dsi/prompts.sqlite +0 -0
  77. package/template/.dsi/prompts.sqlite-shm +0 -0
  78. package/template/.dsi/prompts.sqlite-wal +0 -0
  79. package/.agents/skills/dsi-thin/SKILL.md +0 -140
  80. package/.agents/skills/dsi-thin/references/ADR-0019-snapshot.md +0 -363
  81. package/.agents/skills/dsi-thin/references/authority-adr.md +0 -28
  82. package/.agents/skills/dsi-thin/references/input-contract.md +0 -42
  83. package/.agents/skills/dsi-thin/references/proven-gates.md +0 -61
  84. package/.agents/skills/dsi-thin/references/two-lane-contract.md +0 -53
  85. package/build/client/_app/immutable/assets/0.B-dIufDc.css +0 -2
  86. package/build/client/_app/immutable/assets/2.B70dfiy3.css +0 -1
  87. package/build/client/_app/immutable/chunks/38H6DLwz.js +0 -1
  88. package/build/client/_app/immutable/chunks/DJWLLhNJ.js +0 -1
  89. package/build/client/_app/immutable/entry/start.BtgtnpyB.js +0 -1
  90. package/build/server/chunks/nodes/1.js-C2e7NFqd.js +0 -9
  91. /package/build/client/_app/immutable/nodes/{0.GHtnahSb.js → 0.Jl8y_yU6.js} +0 -0
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: dsi-ports
3
+ description: Fast feedback-loop port inventory — run /dsi-ports (or ask "what's running / which ports are open / what's eating memory") to list every TCP listener with pid, process, memory usage, and its purpose (DSH host, DSI UI, Playwright, inspector, test floors). Backed by the Port Ledger KB.
4
+ ---
5
+
6
+ # dsi-ports — the Port Ledger, live
7
+
8
+ Companion KB: `dev/kb/open-ports/2026-10-12 - The Port Ledger — Who Opens What, Why, and How Test Floors Stay Isolated.md` — read it for WHY each port exists. This skill is the LOOK-path: one command, one table, no judgment.
9
+
10
+ ## Run
11
+
12
+ Execute this single pipeline (macOS; safe, read-only). AGENT HYGIENE: write
13
+ the block to a temp file (`/tmp/dsi-ports.sh`) and run `sh` on it — inlining
14
+ shell `$-expansions` inside a JS template string breaks the tool call
15
+ (observed 2026-10-12); the failure is the caller's, not the pipeline's.
16
+
17
+ ```sh
18
+ for pid_port in $(lsof -nP -iTCP -sTCP:LISTEN | awk 'NR>1 {match($9, /[^:]*$/); print substr($9, RSTART) "/" $2}' | sort -u); do
19
+ port=${pid_port%%/*}; pid=${pid_port##*/}
20
+ rss=$(ps -o rss= -p "$pid" 2>/dev/null | awk '{printf "%.0f MB", $1/1024}')
21
+ cmd=$(ps -o command= -p "$pid" 2>/dev/null | cut -c1-70)
22
+ echo "$port | $pid | ${rss:-?} | $cmd"
23
+ done | sort -n
24
+ ```
25
+
26
+ ## Format the output
27
+
28
+ Present EXACTLY this table (port ascending), then the purpose line:
29
+
30
+ | Port | PID | Process | Memory (RSS) | Purpose |
31
+ |---|---|---|---|---|
32
+ | 3080 | 1234 | node …/vite.js dev | 412 MB | **DSH host** — browser RPC + token announce |
33
+ | 5175 | 1234 | node …/vite.js dev | 388 MB | **DSI dev UI** — the page the operator uses |
34
+
35
+ Purpose lookup (from the Port Ledger KB — first match wins):
36
+
37
+ | Port | Purpose |
38
+ |---|---|
39
+ | 3080 | DSH host — browser RPC, token announce |
40
+ | 5174 | DSI built UI (production page) |
41
+ | 5175 | DSI dev UI (Vite, HMR) |
42
+ | 5176 | Playwright webServer (vite preview, e2e backend) |
43
+ | 9229 | Node inspector (D7 — one debugger for DSI + embedded host) |
44
+ | 1933 | OpenViking memory server |
45
+ | 34xx | test-floor DSH host (isolated) |
46
+ | 51xx (5180+) | test-floor UI (isolated) |
47
+ | anything else | **unknown — name the binary and ask** (never guess a purpose) |
48
+
49
+ ## Reading the table (tell the operator, one line each)
50
+
51
+ - Same PID on TWO ports (e.g. 3080 + 5175) → the **in-process floor** (one process, D7).
52
+ - 5175 up but 3080 down → UI-only floor; the page will show "Failed to fetch".
53
+ - 3080 up but no 5175/5174 → bare host (tandem's other half missing, or runCli host only).
54
+ - Two PIDs on the same port → TWO listeners — list both rows, flag it.
55
+ - Biggest RSS → name it; vite dev floors routinely sit at 300–500 MB (normal).
56
+
57
+ ## Boundaries
58
+
59
+ - Read-only: `lsof`, `ps`, `sort`. Never kill, never open, never edit — remediation is always a separate, confirmed step.
60
+ - IPv6 vs IPv4 duplicates: same port with different PIDs means TWO listeners — list both rows, flag it.
61
+ - The table is a snapshot; ports move between runs. Re-run, don't cache.
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: dsi-restart
3
+ description: Restart the DSI/DSH floor with a before/after report — /dsi-restart snapshots every listener's port+pid+RSS, restarts (or waits for the operator's restart), re-snapshots, and prints the delta table (which ports restarted, memory freed/claimed). Answers "floor feels heavy, restart and show me what changed".
4
+ ---
5
+
6
+ # dsi-restart — restart with receipts
7
+
8
+ Companion: `dsi-ports` skill (the snapshot pipeline) and the Port Ledger KB (`dev/kb/open-ports/`). This skill adds the DELTA: before, restart, after, memory moved.
9
+
10
+ ## 0. Identify how the floor is running (do not skip)
11
+
12
+ **Facts first (2026-10-12, hard rule):** classify launch shape ONLY from a full
13
+ ancestry walk — `ps -o pid,ppid,lstart,command` repeated up the ppid chain
14
+ until you reach pid 1 or an interactive `zsh`/`login`. Record each hop. An
15
+ immediate-parent glance is not detection: the same zsh can own both a dead
16
+ floor's corpse and the replacement the OPERATOR launched by hand (precedent
17
+ 2026-10-12: the assistant claimed a "detached relaunch" that was actually the
18
+ operator re-running the command in their own terminal). **Never narrate a
19
+ relaunch you did not perform and cannot cite a pid + start time for.**
20
+
21
+ | Launch shape | Detection (full ancestry walk) | Who may restart it |
22
+ |---|---|---|
23
+ | Operator's foreground terminal | the `bin/dsi.mjs` chain's top parent is an interactive `zsh`/`login` (walk ppids; lstart proves which launch is which) | **the operator only** — or the Restart Verb with the operator's go (§2) |
24
+ | Detached chain (plugin-rack / verb bounce) | the chain's top parent is pid 1 (launchd) | the skill, via the Restart Verb or `--sync-kills` + relaunch |
25
+
26
+ If detection is ambiguous → **ask the operator** before killing anything (precedent: pid 37084, 2026-10-12 — the assistant killed the operator's live floor during cleanup).
27
+
28
+ **Exit-143 rule (2026-10-12, corrected):** when the floor dies — Ctrl-C, kill,
29
+ or the Restart Verb hitting a foreground-terminal floor — the terminal prints
30
+ `ELIFECYCLE Command failed with exit code 143` and the floor is **DOWN. It
31
+ does not come back by itself.** Only the detached restart chain (the verb's
32
+ spawned one-shot) or a human re-running the command brings it back. Never
33
+ write or say "the floor came back detached" without a pid + `lstart` you just
34
+ read from `ps` — an unverified rebirth story cost the operator a dead floor
35
+ and a wrong incident report (2026-10-12).
36
+
37
+ ## 1. Snapshot BEFORE
38
+
39
+ Run the dsi-ports pipeline (see dsi-ports skill for the script) and save:
40
+
41
+ ```sh
42
+ sh /tmp/dsi-ports.sh > /tmp/floor-before.txt
43
+ ```
44
+
45
+ ## 2. Restart
46
+
47
+ - **PREFERRED, every shape: the Restart Verb (shipped — ADR "The Restart Verb,
48
+ One Button, One Route, Receipts", 2026-10).** With the operator's go, click
49
+ the power button in the sidebar header (or `POST /api/floor/restart`). It
50
+ spawns the same detached chain plugin-apply uses, so the floor re-serves in
51
+ the same mode (`--sync --dev in-process` fidelity) even when the previous
52
+ floor lived in the operator's terminal. Receipts still apply: BEFORE/AFTER
53
+ snapshots are mandatory.
54
+ - **Fallback, detached/agent-owned floor:** `dsi dsh --sync-kills`, then
55
+ relaunch the exact previous command (copy it from `ps` BEFORE killing).
56
+ - **Fallback, operator foreground floor (operator declines the verb):** print
57
+ the restart command and WAIT for the operator to run it (their Ctrl-C, their
58
+ terminal). Remember: once it dies, it stays dead until someone starts it.
59
+
60
+ ## 3. Snapshot AFTER (wait for both listeners first)
61
+
62
+ Poll until the expected ports answer (host + UI), then:
63
+
64
+ ```sh
65
+ sh /tmp/dsi-ports.sh > /tmp/floor-after.txt
66
+ ```
67
+
68
+ ## 4. The delta report (this is the deliverable)
69
+
70
+ Present exactly this table — join BEFORE and AFTER on port purpose (not pid: pids always change):
71
+
72
+ | Port | Purpose | RSS before | RSS after | Delta | PID before → after |
73
+ |---|---|---|---|---|---|
74
+ | 3080 | DSH host | 3262 MB | 1489 MB | **−1773 MB** | 38237 → 41200 |
75
+
76
+ Then one summary line: total floor RSS before → after, and the verdict:
77
+ - large negative delta + all expected ports up → **restart healthy**
78
+ - a port missing after → **name it loud** (that half of the floor did not come back)
79
+ - RSS after ≥ before → say so honestly; restart did not reclaim (possible leak — offer dsi-rca)
80
+
81
+ ## Boundaries
82
+
83
+ - Never kill a process the operator owns without their confirmed go (§0).
84
+ - Always restore the EXACT previous launch command/flags/env — a restart that changes the floor's shape is a config change, not a restart.
85
+ - The report is the point: a restart without before/after receipts is just downtime.
package/bin/dsi.mjs CHANGED
@@ -28,7 +28,8 @@ import { fileURLToPath } from 'node:url';
28
28
  import { parse, stringify } from 'yaml';
29
29
 
30
30
  import { mirrorSkills } from './lib/mirror-skills.mjs';
31
- import { dshLauncherCmd, parsePluginArgs } from './lib/plugin-chain.mjs';
31
+ import { dshLauncherCmd, parsePluginArgs, takeoverFloorPorts } from './lib/plugin-chain.mjs';
32
+ import { parseDevArg, devModeEnv, modeFromBoolean, resolveBounceMode, extractInspectArgs, parseInspectArgsFromEnv } from './lib/dev-mode.mjs';
32
33
 
33
34
  // Layman-facing help: wrapped near 76 columns, continuation lines indented,
34
35
  // the recommended one-liner first. --zai is optional on purpose — no GLM
@@ -56,7 +57,11 @@ const USAGE = [
56
57
  ' store the dsh launch token as dsh.authToken in',
57
58
  ' ~/.dsi/settings.yaml, then serve',
58
59
  ' --dev serve the Vite dev server instead of the built page (takes',
59
- ' port 5175; needs a full checkout: pnpm install)',
60
+ ' port 5175; needs a full checkout: pnpm install). Optional',
61
+ ' value: --dev in-process boots the DSH host INSIDE the dev',
62
+ ' server process (one process, one Ctrl-C; host still on',
63
+ ' port 3080; token via in-process intercept). Bare --dev',
64
+ ' keeps the two-port pair; --dev pair is the same spelled out.',
60
65
  ' --sync-kills',
61
66
  ' kill orphaned dsh/dsi processes left by an earlier run',
62
67
  ' --ov install the OpenViking memory plugin into the active dsh',
@@ -195,15 +200,16 @@ if (command === 'dsh') {
195
200
  // HMR surface as `pnpm dev`, beside the same synced host.
196
201
  const rest = process.argv.slice(4);
197
202
  const withZai = rest.includes('--zai');
198
- const withDev = rest.includes('--dev');
199
203
  const withOv = rest.includes('--ov');
200
204
  if (withZai) addZaiProvider();
201
205
  if (withOv) runOvPlugin();
202
206
  runSkillsMirror();
203
- runSyncedPair(
204
- rest.filter((arg) => arg !== '--zai' && arg !== '--dev' && arg !== '--ov'),
205
- withDev
206
- );
207
+ // --dev parse (D4): 'in-process' | 'pair' consumed as a value; bare
208
+ // --dev stays 'pair'. dsi-side flags stripped before the host spawn.
209
+ const { mode, rest: hostArgs0 } = parseDevArg(rest);
210
+ // D7: inspector flags ride the vite child's execArgv, never the host.
211
+ const { rest: hostArgs, execArgv: inspectExecArgv } = extractInspectArgs(hostArgs0);
212
+ runSyncedPair(hostArgs, mode, inspectExecArgv);
207
213
  } else if (flag === '--plugin' || flag === '--plugin-restart') {
208
214
  // The Plugin Rack ADR (2026-09-27, D3/D5): the dsh CLI is the writer,
209
215
  // dsi owns the bounce. Install/remove delegates to the launcher rule
@@ -240,7 +246,20 @@ if (command === 'dsh') {
240
246
  // before the marker existed, or from plain `pnpm dev`, carries none.
241
247
  // The vite dev process itself is visible to pgrep (unlike the npx
242
248
  // shims — different argv rewriting), so ask the process table.
243
- const dev = bounceDevMode(parsed.dev === true, isDevFloorAlive);
249
+ // D6 (2026-10-11): the boolean is no longer enough — an in-process
250
+ // floor re-served as 'pair' would double-spawn the host. Read the
251
+ // alive vite's DSI_DEV_MODE via ps -E BEFORE the sweep, resolve the
252
+ // enum, restore exactly what was running.
253
+ const floorEnvDump = readFloorEnv(devFloorPid());
254
+ const mode = resolveBounceMode({
255
+ flagDev: parsed.dev === true,
256
+ devFloorAlive: isDevFloorAlive(),
257
+ floorEnv: floorEnvDump
258
+ });
259
+ // The Faithful Bounce (ADR 2026-10-13, D3): the SAME floor env that
260
+ // records the mode records the inspector flags — re-forward them so
261
+ // the bounce boot comes back debuggable when it left that way.
262
+ const bounceExecArgv = parseInspectArgsFromEnv(floorEnvDump);
244
263
  killOrphans();
245
264
  // The pkill sweep cannot see every floor shape (RCA 2026-09-27: the
246
265
  // npx shims — 'npm exec @deepseek-ai/dsh…' and '.bin/dsh web' — are
@@ -248,9 +267,11 @@ if (command === 'dsh') {
248
267
  // 3080 and preflightPort exited 64, killing the detached chain
249
268
  // silently). The bounce chain owns the DSI ports by contract, so
250
269
  // free them by listener, not by name.
251
- freeFloorPorts([3080, 5174, 5175]);
252
- // Restore the SAME UI mode the floor was serving.
253
- runSyncedPair([], dev);
270
+ await freeFloorPorts(floorPortSet());
271
+ // Restore the SAME UI mode the floor was serving. The bounce's
272
+ // boolean signal normalizes to 'pair'/'build' inside runSyncedPair;
273
+ // 3.5 teaches the bounce the full enum via DSI_DEV_MODE.
274
+ runSyncedPair([], mode, bounceExecArgv);
254
275
  } else if (flag === '--sync-kills') {
255
276
  killOrphans();
256
277
  process.exit(0);
@@ -367,56 +388,80 @@ function openBrowser(url, delayMs = 2000) {
367
388
  * the Vite dev server (`pnpm dev`'s surface) — as the UI. The pair shares
368
389
  * one lifecycle — a signal or one child's exit tears down the other, so no
369
390
  * orphan survives a Ctrl-C or a crash. */
370
- async function runSyncedPair(extraArgs, dev = false) {
391
+ async function runSyncedPair(extraArgs, dev = 'build', execArgv = []) {
392
+ // dev accepts the D4 mode enum ('build' | 'pair' | 'in-process'); the
393
+ // legacy boolean true is normalized so older call sites keep working.
394
+ const mode = typeof dev === 'boolean' ? modeFromBoolean(dev) : dev;
371
395
  const script = path.join(packageRoot, 'scripts', 'dsh-web-synced.sh');
372
- if (!existsSync(script)) {
396
+ const inProcess = mode === 'in-process';
397
+ if (!inProcess && !existsSync(script)) {
373
398
  console.error(`dsi dsh: no sync script at ${script}`);
374
399
  process.exit(66);
375
400
  }
376
401
  // UI port: the built server takes 5174 — the same port `dsi web` uses,
377
402
  // so plain `dsi dsh --sync` listens on ONE port. --dev's Vite dev server
378
403
  // takes 5175 instead (vite.config.ts pins 5174 strictPort, so pair mode
379
- // passes 5175 explicitly), giving the two-port dev shape.
380
- const uiPort = process.env.PORT ?? (dev ? '5175' : '5174');
404
+ // passes 5175 explicitly), giving the two-port dev shape. in-process is
405
+ // also a vite floor: same 5175.
406
+ const uiPort = process.env.PORT ?? (mode === 'build' ? '5174' : '5175');
381
407
  // The host half takes --port <n> when forwarded (a 3080 held by a
382
408
  // dsh web this CLI does not own — e.g. another live conversation
383
409
  // floor — is exactly the case the preflight must not kill). The
384
410
  // forwarded flag goes to `dsh web` untouched; DSI follows via
385
411
  // DSH_BASE_URL so the page dials the host wherever it landed.
412
+ // IN-PROCESS: no host child will be spawned, so preflighting 3080 is
413
+ // pointless — the plugin boots the host on 3080 AFTER vite is up, and
414
+ // a stale 3080 from a foreign floor is that floor's business (the
415
+ // embedded boot fails loud in the plugin, killing nothing).
386
416
  warnIfFirstRunDownload();
387
- const hostPort = hostPortFromArgs(extraArgs) ?? 3080;
417
+ // IN-PROCESS: the plugin reads its port from DSH_HOST_PORT (default 3080)
418
+ // — the UI half MUST dial the same port, so DSH_HOST_PORT participates in
419
+ // the dshBase derivation exactly like a forwarded --port flag. (Smoke
420
+ // bug 2026-10-11: DSH_HOST_PORT=3477 booted the host there while the
421
+ // page dialed 3080 — the production floor — → "Failed to fetch".)
422
+ const pluginPort = inProcess && process.env.DSH_HOST_PORT ? parseInt(process.env.DSH_HOST_PORT, 10) : undefined;
423
+ const hostPort = hostPortFromArgs(extraArgs) ?? pluginPort ?? 3080;
388
424
  const dshBase = hostPort === 3080 ? undefined : `http://127.0.0.1:${hostPort}`;
389
- // Preflight BOTH halves: a forgotten previous run holds not just the
390
- // host port but often the UI port too — the host would only crash
391
- // later, and the UI server would crash first with EADDRINUSE.
392
- await preflightPort(hostPort, 'dsh host');
393
- await preflightPort(uiPort, 'DSI page');
394
- const host = spawn('/bin/sh', [script, ...extraArgs], {
425
+ if (inProcess) {
426
+ await preflightPort(uiPort, 'DSI page');
427
+ } else {
428
+ await preflightPort(hostPort, 'dsh host');
429
+ await preflightPort(uiPort, 'DSI page');
430
+ }
431
+ // IN-PROCESS: no sync script, no host child — the vite plugin owns the
432
+ // host lifecycle (vite close hook stops it; one Ctrl-C, no orphan).
433
+ const host = inProcess ? null : spawn('/bin/sh', [script, ...extraArgs], {
395
434
  stdio: 'inherit',
396
435
  env: { ...process.env, DSH_WEB_VERSION: dshWebVersion() }
397
436
  });
398
- const ui = dev ? startDsiDevServer(uiPort, dshBase) : startDsiServer(uiPort, dshBase);
437
+ const ui = mode === 'build' ? startDsiServer(uiPort, dshBase) : startDsiDevServer(uiPort, dshBase, mode, execArgv);
399
438
  // The browser follows the UI: the built page (5174) — or, in --dev mode,
400
439
  // the dev server (5175).
401
440
  openBrowser(`http://localhost:${uiPort}/`);
402
441
  for (const signal of ['SIGINT', 'SIGTERM']) {
403
442
  process.on(signal, () => {
404
- host.kill(signal);
443
+ if (host) host.kill(signal); // in-process: no host child — vite close hook stops it
405
444
  ui.kill(signal);
406
445
  });
407
446
  }
408
447
  const teardown = (code, signal) => {
409
- host.kill('SIGTERM');
448
+ if (host) host.kill('SIGTERM');
410
449
  ui.kill('SIGTERM');
411
450
  process.exit(signal ? 1 : (code ?? 1));
412
451
  };
413
- host.on('exit', teardown);
452
+ if (host) host.on('exit', teardown);
414
453
  ui.on('exit', teardown);
415
454
  }
416
455
 
417
456
  /** Kill orphaned dsh/dsi leftovers — a pair whose parent died before it
418
457
  * could tear down its sibling, or a previous run the terminal lost. */
419
458
  function killOrphans() {
459
+ // Test-floor isolation (Port Ledger 34xx/51xx): a scratch floor's bounce
460
+ // must never pkill the production floor's shapes.
461
+ if (process.env.DSI_SKIP_ORPHAN_SWEEP === '1') {
462
+ console.log('dsi dsh: orphan sweep skipped (DSI_SKIP_ORPHAN_SWEEP=1)');
463
+ return;
464
+ }
420
465
  for (const pattern of ORPHAN_PATTERNS) {
421
466
  try {
422
467
  spawnSync('pkill', ['-f', pattern], { stdio: 'pipe' });
@@ -428,20 +473,49 @@ function killOrphans() {
428
473
  console.log('dsi dsh: orphan sweep done (dsh-web-synced.sh, dsh bin, dsi server)');
429
474
  }
430
475
 
431
- /** SIGTERM whatever LISTENS on these ports: the bounce chain's name-blind
432
- * second sweep (RCA 2026-09-27). pkill -f cannot match every floor shape
433
- * (the npx shim processes are invisible to it on macOS), so a surviving
434
- * dsh host held 3080, preflightPort exited 64, and the detached chain died
435
- * silently. Only the bounce uses this; a manual --sync preflight still
436
- * REFUSES a busy port it does not own. */
437
- function freeFloorPorts(ports) {
438
- for (const port of ports) {
476
+ /** The DSI ports the bounce owns. DSI_FLOOR_PORTS overrides for isolated
477
+ * test floors (Port Ledger: 34xx host, 5180+ UI) so a scratch bounce never
478
+ * touches the production ports. */
479
+ function floorPortSet() {
480
+ const raw = process.env.DSI_FLOOR_PORTS;
481
+ if (!raw) return [3080, 5174, 5175];
482
+ const ports = raw.split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0);
483
+ return ports.length ? ports : [3080, 5174, 5175];
484
+ }
485
+
486
+ /** SIGTERM whatever LISTENS on these ports, then WAIT until they actually
487
+ * free (The Port Takeover ADR 2026-10-12, D2/D3/D5): the fire-and-forget
488
+ * kill of RCA 2026-09-27 raced the successor's bind — the dying in-process
489
+ * host still held 3080 two seconds later and the new floor died on
490
+ * EADDRINUSE (incident: tmp/bounce.log 2026-10-12T03:49:06Z). Every kill
491
+ * and every freed port gets a receipt line; a port still held at the
492
+ * budget exits 64 naming the port and the holder. Only the bounce uses
493
+ * this — the click is the consent (ADR D1); a manual --sync preflight
494
+ * still REFUSES a busy port it does not own. */
495
+ async function freeFloorPorts(ports) {
496
+ const listListeners = (port) => {
439
497
  try {
440
- spawnSync('/bin/sh', ['-c', 'lsof -ti tcp:' + port + ' -sTCP:LISTEN | xargs kill 2>/dev/null || true'], { stdio: 'pipe' });
498
+ const probe = spawnSync('/bin/sh', ['-c', 'lsof -ti tcp:' + port + ' -sTCP:LISTEN 2>/dev/null || true'], { stdio: 'pipe', encoding: 'utf8' });
499
+ return (probe.stdout || '').split('\n').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0);
441
500
  } catch {
442
- // no lsof (or no xargs): the pattern sweep above and the 5s
443
- // preflight wait remain the honest fallbacks.
501
+ // no lsof (or no sh): nothing to signal; the wait loop below will
502
+ // report the port honestly if it never frees.
503
+ return [];
444
504
  }
505
+ };
506
+ const result = await takeoverFloorPorts(ports, {
507
+ listListeners,
508
+ terminate: (pid) => {
509
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone — the wait decides */ }
510
+ },
511
+ portFree: (port) => portInUse(port).then((busy) => !busy),
512
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
513
+ now: () => Date.now(),
514
+ log: (line) => console.log(line)
515
+ });
516
+ if (!result.ok) {
517
+ console.error('dsi dsh: port ' + result.port + ' is still in use by a process the takeover does not own (pid ' + result.pid + ') — free it yourself, then re-run');
518
+ process.exit(64);
445
519
  }
446
520
  }
447
521
 
@@ -455,6 +529,28 @@ function isDevFloorAlive() {
455
529
  return (probe.status ?? 1) === 0;
456
530
  }
457
531
 
532
+ /** The alive dev floor's vite pid (first match), or null. Same match rule
533
+ * as isDevFloorAlive — the pid feeds the D6 mode read (ps -E). */
534
+ function devFloorPid() {
535
+ const probe = spawnSync('pgrep', ['-f', 'deepseek-insight/node_modules/vite/bin/vite.js'], { stdio: 'pipe' });
536
+ if ((probe.status ?? 1) !== 0) return null;
537
+ const first = (probe.stdout?.toString() || '').split('\n').find(Boolean);
538
+ return first ? first.trim() : null;
539
+ }
540
+
541
+ /** The floor's DSI_DEV_MODE via ps -E (macOS: own processes only). Returns
542
+ * '' when the read fails for any reason — resolveBounceMode then falls back
543
+ * to the boolean arms, the pre-D6 behavior, never a wrong restore. */
544
+ function readFloorEnv(pid) {
545
+ if (!pid) return '';
546
+ try {
547
+ const ps = spawnSync('ps', ['-E', '-p', String(pid)], { stdio: 'pipe', timeout: 5000 });
548
+ return ps.status === 0 ? ps.stdout?.toString() || '' : '';
549
+ } catch {
550
+ return '';
551
+ }
552
+ }
553
+
458
554
  /** Add the ZAI provider and default model to ~/.dsh/settings.yaml when
459
555
  * absent. Existing entries always win — an already-configured host is
460
556
  * rewritten only when at least one block was added. Exits non-zero when
@@ -573,7 +669,7 @@ function runSkillsMirror() {
573
669
  * sources and devDependencies exist — a published tarball (bin + build)
574
670
  * refuses loudly instead of half-booting. The --port CLI flag overrides the
575
671
  * config's server.port while strictPort keeps the fail-loud contract. */
576
- function startDsiDevServer(defaultPort = '5175', dshBase = undefined) {
672
+ function startDsiDevServer(defaultPort = '5175', dshBase = undefined, mode = 'pair', execArgv = []) {
577
673
  const viteBin = path.join(packageRoot, 'node_modules', 'vite', 'bin', 'vite.js');
578
674
  if (!existsSync(viteBin)) {
579
675
  console.error(`dsi dsh --dev: no vite at ${viteBin} — --dev needs a full DSI checkout (pnpm install); use the built server instead`);
@@ -581,7 +677,13 @@ function startDsiDevServer(defaultPort = '5175', dshBase = undefined) {
581
677
  }
582
678
  // --host matches the built server's 127.0.0.1 bind: vite's default
583
679
  // 'localhost' can resolve ::1-only and strand a 127.0.0.1 client.
584
- return spawn(process.execPath, [viteBin, 'dev', '--host', process.env.HOST ?? '127.0.0.1', '--port', process.env.PORT ?? defaultPort], {
680
+ return spawn(process.execPath, [...execArgv, viteBin, 'dev', '--host', process.env.HOST ?? '127.0.0.1', '--port', process.env.PORT ?? defaultPort], {
681
+ // D7: inspector flags lead the child argv — node consumes options before
682
+ // the script path, so `node --inspect vite.js dev` debugs THIS process
683
+ // (vite + the embedded DSH host share one heap). The spawn execArgv
684
+ // OPTION was tried first and empirically never activated the inspector
685
+ // (silent no-op, probed 2026-10-11); leading args are the reliable form.
686
+ // NODE_OPTIONS is banned — it leaks to every child and collides on port.
585
687
  stdio: 'inherit',
586
688
  cwd: packageRoot,
587
689
  env: {
@@ -592,6 +694,13 @@ function startDsiDevServer(defaultPort = '5175', dshBase = undefined) {
592
694
  // this from the API server's env to re-serve --dev instead of the
593
695
  // stale build. Nothing else may set it.
594
696
  DSI_DEV: '1',
697
+ // D4/D6: the mode rides as env, never past the CLI to the host.
698
+ ...devModeEnv(mode),
699
+ // The Faithful Bounce (ADR 2026-10-13, D1): the inspector flags are
700
+ // part of the launch record — stamped so every restorer (the bounce
701
+ // boot via ps -E, the delayed re-serve via inherited env) can put
702
+ // them back exactly as launched.
703
+ ...(execArgv.length ? { DSI_INSPECT_ARGS: execArgv.join(' ') } : {}),
595
704
  ...(dshBase !== undefined ? { DSH_BASE_URL: dshBase } : {})
596
705
  }
597
706
  });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * dev-mode — parse the --dev flag's optional value and derive env markers.
3
+ *
4
+ * ADR: dev/architectural-decission/2026-10/2026-10-11 - The In-Process Host
5
+ * (D4: --dev gains an optional value; bare --dev keeps today's meaning;
6
+ * the mode rides to vite via env, never past the CLI to the dsh web host).
7
+ *
8
+ * Modes:
9
+ * 'build' — the built page (no --dev)
10
+ * 'pair' — vite dev server + separate host child (bare --dev / --dev pair)
11
+ * 'in-process' — vite dev server boots the host itself (--dev in-process)
12
+ */
13
+
14
+ /** Tokens the --dev flag may consume as its value. Anything else after
15
+ * --dev belongs to some other flag (or the host) and is left alone. */
16
+ const DEV_VALUES = new Set(['in-process', 'pair']);
17
+
18
+ /**
19
+ * Parse a rest-args array for --dev (and strip dsi-side flags).
20
+ * @param {string[]} rest — args AFTER the subcommand (e.g. after 'dsh --sync')
21
+ * @returns {{ mode: 'build'|'pair'|'in-process', rest: string[] }}
22
+ * rest — args with --dev (and its value) plus other dsi-side flags removed,
23
+ * safe to forward to the dsh web host.
24
+ */
25
+ export function parseDevArg(rest) {
26
+ const out = [];
27
+ let mode = 'build';
28
+ for (let i = 0; i < rest.length; i++) {
29
+ const arg = rest[i];
30
+ if (arg === '--zai' || arg === '--ov') continue; // dsi-side, stripped (pre-existing contract)
31
+ if (arg === '--dev') {
32
+ const next = rest[i + 1];
33
+ if (next && DEV_VALUES.has(next)) {
34
+ mode = next === 'pair' ? 'pair' : 'in-process';
35
+ i++; // consume the value — it never reaches the host
36
+ } else {
37
+ mode = 'pair'; // bare --dev keeps today's meaning (D4)
38
+ }
39
+ continue;
40
+ }
41
+ out.push(arg);
42
+ }
43
+ return { mode, rest: out };
44
+ }
45
+
46
+ /**
47
+ * Env additions for the vite child expressing the chosen mode.
48
+ * DSI_DEV=1 is the pre-existing dev-floor marker (RCA 2026-09-27) — kept for
49
+ * the bounce chain's boolean probe; DSI_DEV_MODE is the D6 mode enum.
50
+ */
51
+ export function devModeEnv(mode) {
52
+ if (mode === 'build') return {};
53
+ const env = { DSI_DEV: '1', DSI_DEV_MODE: mode };
54
+ if (mode === 'in-process') env.DSI_DEV_IN_PROCESS = '1';
55
+ return env;
56
+ }
57
+
58
+ /**
59
+ * Map the legacy boolean dev signal (bounceDevMode's return, older floors)
60
+ * to a mode. Wave 3.5 will teach the bounce the enum; until then the bounce
61
+ * can only ever restore 'pair' or 'build'.
62
+ */
63
+ export function modeFromBoolean(dev) {
64
+ return dev ? 'pair' : 'build';
65
+ }
66
+
67
+ /**
68
+ * Parse ps -E -p <pid> output for the floor's DSI_DEV_MODE marker (D6).
69
+ * Pure: the ps invocation is injected by dsi.mjs.
70
+ * @param {string} psOutput — full environment dump of the vite process
71
+ * @returns {'in-process' | null} null = marker absent (pair/build floor)
72
+ */
73
+ export function parseDevModeFromPsE(psOutput) {
74
+ const m = /(?:^|\s)DSI_DEV_MODE=(\S+)/.exec(psOutput || '');
75
+ return m && m[1] === 'in-process' ? 'in-process' : null;
76
+ }
77
+
78
+ /**
79
+ * Resolve the mode a bounce must RESTORE (D6 — the plugin-apply restart
80
+ * runs in a fresh CLI that cannot see the original argv).
81
+ *
82
+ * Priority:
83
+ * 1. the alive floor's own env says in-process → restore 'in-process'
84
+ * (D6, sharpened 2026-10-12 Port Takeover: the floor's RECORDED mode is
85
+ * authoritative over the bounce's --dev flag — the verb's chain always
86
+ * carries --dev, and letting it win bounced in-process floors into
87
+ * 'pair', which spawned a sync-script host AND left the stale
88
+ * DSI_DEV_IN_PROCESS marker active in the chain env, so two hosts
89
+ * fought for 3080 and the floor died on EADDRINUSE every click)
90
+ * 2. explicit --dev flag on the bounce itself → 'pair' (legacy semantics;
91
+ * the operator asked for a dev re-serve)
92
+ * 3. a dev floor is alive (boolean probe) → 'pair'
93
+ * 4. nothing alive → 'build'
94
+ * @param {{ flagDev?: boolean, devFloorAlive?: boolean, floorEnv?: string }} p
95
+ */
96
+ export function resolveBounceMode(p = {}) {
97
+ if (p.floorEnv && parseDevModeFromPsE(p.floorEnv) === 'in-process') return 'in-process';
98
+ if (p.flagDev) return 'pair';
99
+ if (p.devFloorAlive) return 'pair';
100
+ return 'build';
101
+ }
102
+
103
+ /**
104
+ * Parse a ps -E environment dump for DSI_INSPECT_ARGS (ADR 2026-10-13
105
+ * "The Faithful Bounce", D3): the space-joined inspector flags the launcher
106
+ * stamped beside the dev-mode markers. Pure: the dump string is injected.
107
+ * @param {string} psOutput — full environment dump of the vite process
108
+ * @returns {string[]} inspector argv ([] = none recorded / pre-stamp floor)
109
+ */
110
+ export function parseInspectArgsFromEnv(psOutput) {
111
+ // ps -E is ONE line of KEY=VALUE tokens — the value ends at the next
112
+ // KEY= token (identifier-shaped, so '--inspect=...' never trips it),
113
+ // at a newline, or at end-of-dump. Lazy capture + lookahead.
114
+ const m = /(?:^|\s)DSI_INSPECT_ARGS=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|\s*$|\n|\r)/.exec(psOutput || '');
115
+ if (!m) return [];
116
+ return m[1].trim().split(/\s+/).filter(Boolean);
117
+ }
118
+
119
+ /**
120
+ * Pull Node inspector flags out of the rest-args: --inspect, --inspect-brk,
121
+ * --inspect=<host:port>. Forwarded to the vite child's execArgv (D7) —
122
+ * NEVER via NODE_OPTIONS, which would leak the inspector to every child
123
+ * dsi.mjs spawns and collide on the default port.
124
+ * @param {string[]} rest
125
+ * @returns {{ rest: string[], execArgv: string[] }}
126
+ */
127
+ export function extractInspectArgs(rest) {
128
+ const out = [];
129
+ const execArgv = [];
130
+ for (const arg of rest) {
131
+ if (arg === '--inspect' || arg === '--inspect-brk' || arg.startsWith('--inspect=')) {
132
+ execArgv.push(arg);
133
+ continue;
134
+ }
135
+ out.push(arg);
136
+ }
137
+ return { rest: out, execArgv };
138
+ }
@@ -70,6 +70,53 @@ export function bounceDevMode(flagDev, devFloorAlive) {
70
70
  }
71
71
  }
72
72
 
73
+ /** The takeover's bounded-wait budget and poll cadence (The Port Takeover
74
+ * ADR 2026-10-12, D3): after the SIGTERM sweep the chain waits until every
75
+ * DSI port actually frees — at most this budget — then re-serves. A port
76
+ * still held at the deadline is an honest exit 64, never a half-boot. */
77
+ export const TAKEOVER_BUDGET_MS = 10_000;
78
+ export const TAKEOVER_POLL_MS = 250;
79
+
80
+ /**
81
+ * The takeover sweep (The Port Takeover ADR 2026-10-12, D2/D3/D5): SIGTERM
82
+ * whatever LISTENS on the given ports, then WAIT until each port frees,
83
+ * bounded. Pure orchestration — every effect is injected, so unit tests run
84
+ * it against fakes and the real half stays in dsi.mjs.
85
+ *
86
+ * @param {number[]} ports
87
+ * @param {{ listListeners: (port: number) => number[], terminate: (pid: number) => void,
88
+ * portFree: (port: number) => boolean | Promise<boolean>, sleep: (ms: number) => Promise<void>,
89
+ * now: () => number, log: (line: string) => void }} effects
90
+ * @returns {Promise<{ ok: boolean, port?: number, pid?: number | string }>} ok:false names the port and holder (exit 64 is the caller's)
91
+ */
92
+ export async function takeoverFloorPorts(ports, effects) {
93
+ const { listListeners, terminate, portFree, sleep, now, log } = effects;
94
+ const start = now();
95
+ for (const port of ports) {
96
+ for (const pid of listListeners(port)) {
97
+ terminate(pid);
98
+ log('takeover: SIGTERM pid ' + pid + ' on port ' + port);
99
+ }
100
+ }
101
+ for (const port of ports) {
102
+ let freed = null;
103
+ while (now() - start <= TAKEOVER_BUDGET_MS) {
104
+ if (await portFree(port)) {
105
+ freed = now() - start;
106
+ break;
107
+ }
108
+ await sleep(TAKEOVER_POLL_MS);
109
+ }
110
+ if (freed === null) {
111
+ const holder = listListeners(port)[0] ?? 'unknown';
112
+ log('takeover: port ' + port + ' still held by pid ' + holder + ' after ' + TAKEOVER_BUDGET_MS + 'ms — giving up');
113
+ return { ok: false, port, pid: holder };
114
+ }
115
+ log('takeover: port ' + port + ' free after ' + freed + 'ms');
116
+ }
117
+ return { ok: true };
118
+ }
119
+
73
120
  /**
74
121
  * The dsh launcher rule (runOvPlugin's, generalized): DSH_WEB_CMD override,
75
122
  * else the pinned published dsh via npx. The webVersion pin comes from the