hotmilk 0.1.10 → 0.1.11

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/AGENTS.md CHANGED
@@ -20,7 +20,7 @@ Requires **Node.js 22+**. Targets **Pi 0.78** (`@earendil-works/pi-coding-agent`
20
20
  - **Runtime**: [Pi](https://github.com/badlogic/pi-mono) with `@earendil-works/pi-coding-agent` — install hotmilk via `pi install npm:hotmilk`.
21
21
  - **Single extension entry**: only `./src/index.ts` is listed under `package.json` → `pi.extensions`. All other bundled packages load via **dynamic `import()`** when their toggle is `true`.
22
22
  - **Config**: `~/.pi/agent/hotmilk.json` — change with `/mode`, apply with `/reload`.
23
- - **Do not** add packages to `pi.extensions` for toggled deps; add `BUNDLED_EXTENSION_IDS` + a loader in `src/bootstrap/extensions.ts`.
23
+ - **Do not** add packages to `pi.extensions` for toggled deps; add one row to `BUNDLED_EXTENSION_DEFINITIONS` in `src/config/bundled-extensions.ts`.
24
24
 
25
25
  Details: [docs/pi.md](docs/pi.md).
26
26
 
package/README.md CHANGED
@@ -6,19 +6,19 @@ Use it when you want a practical engineering workstation without hand-picking a
6
6
 
7
7
  ## What you get
8
8
 
9
- | Layer | Packages / assets |
10
- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | **Orchestration** | [gentle-pi](https://www.npmjs.com/package/gentle-pi) (SDD, skill registry, `sdd-init`) |
12
- | **Context** | [context-mode](https://www.npmjs.com/package/context-mode) |
13
- | **Codebase graph** | [graphify-pi](https://www.npmjs.com/package/graphify-pi) |
14
- | **Subagents** | [pi-subagents](https://www.npmjs.com/package/pi-subagents), [pi-ask-user](https://www.npmjs.com/package/pi-ask-user) |
15
- | **Goals & docs** | [pi-goal](https://www.npmjs.com/package/pi-goal), [pi-docparser](https://www.npmjs.com/package/pi-docparser) |
16
- | **File-based planning** | [@tomxprime/planning-with-files](https://www.npmjs.com/package/@tomxprime/planning-with-files) |
17
- | **Integrations** | [pi-mcp-adapter](https://www.npmjs.com/package/pi-mcp-adapter), [pi-btw](https://www.npmjs.com/package/pi-btw), [@haispeed/pi-obsidian](https://www.npmjs.com/package/@haispeed/pi-obsidian), [@netandreus/pi-cursor-provider](https://www.npmjs.com/package/@netandreus/pi-cursor-provider) |
18
- | **Dashboard** | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) |
19
- | **Web tools** | [pi-web-access](https://www.npmjs.com/package/pi-web-access) |
20
- | **Flows** | [@blackbelt-technology/pi-flows](https://www.npmjs.com/package/@blackbelt-technology/pi-flows) |
21
- | **Local assets** | `./prompts`, `./skills`, `./themes`, `mcp.json` template |
9
+ | Layer | Packages / assets |
10
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | **Orchestration** | [gentle-pi](https://www.npmjs.com/package/gentle-pi) (SDD, skill registry, `sdd-init`) |
12
+ | **Context** | [context-mode](https://www.npmjs.com/package/context-mode) |
13
+ | **Codebase graph** | [graphify-pi](https://www.npmjs.com/package/graphify-pi) |
14
+ | **Subagents** | [pi-subagents](https://www.npmjs.com/package/pi-subagents), [pi-ask-user](https://www.npmjs.com/package/pi-ask-user) |
15
+ | **Goals & docs** | [pi-goal](https://www.npmjs.com/package/pi-goal), [pi-docparser](https://www.npmjs.com/package/pi-docparser) |
16
+ | **File-based planning** | [@tomxprime/planning-with-files](https://www.npmjs.com/package/@tomxprime/planning-with-files) |
17
+ | **Integrations** | [pi-mcp-adapter](https://www.npmjs.com/package/pi-mcp-adapter), [pi-btw](https://www.npmjs.com/package/pi-btw), [@haispeed/pi-obsidian](https://www.npmjs.com/package/@haispeed/pi-obsidian) |
18
+ | **Dashboard** | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) |
19
+ | **Web tools** | [pi-web-access](https://www.npmjs.com/package/pi-web-access) |
20
+ | **Flows** | [@blackbelt-technology/pi-flows](https://www.npmjs.com/package/@blackbelt-technology/pi-flows) |
21
+ | **Local assets** | `./prompts`, `./skills`, `./themes`, `mcp.json` template |
22
22
 
23
23
  Bundled extension **on/off** is controlled in `hotmilk.json` (via `/mode`), then `/reload`. Only `src/index.ts` is listed in `package.json` → `pi.extensions`; every other bundled package is loaded dynamically when its toggle is `true`.
24
24
 
@@ -95,7 +95,6 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
95
95
  "goal": true,
96
96
  "docparser": true,
97
97
  "obsidian": true,
98
- "cursor-provider": true,
99
98
  "btw": true,
100
99
  "simplify": true,
101
100
  "rtk-optimizer": false,
@@ -105,7 +104,9 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
105
104
  "red-green": false,
106
105
  "agent-dashboard": false,
107
106
  "web-access": false,
108
- "pi-flows": false
107
+ "pi-flows": false,
108
+ "kanagawa": false,
109
+ "tetris": false
109
110
  },
110
111
  "graph": {
111
112
  "warnOnStale": true,
@@ -142,20 +143,34 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
142
143
 
143
144
  Enable in `/mode` or set the key to `true` in `hotmilk.json`, then `/reload`.
144
145
 
145
- | Toggle | Package | Notes |
146
- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
147
- | `caveman` | [pi-caveman](https://www.npmjs.com/package/pi-caveman) | Terse English (`/caveman`). Conflicts with `defaults.language: ja` |
148
- | `red-green` | [pi-red-green](https://www.npmjs.com/package/pi-red-green) | TDD via `/tdd`, `/tdd-status`. Config: `~/.pi/red-green/config.json` |
149
- | `agent-dashboard` | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) | Warm-starts via `pi-dashboard start` (~30s). Peers **0.74**; test on 0.78 before relying on it. Node.js ≥ 22.18 |
150
- | `web-access` | [pi-web-access](https://www.npmjs.com/package/pi-web-access) | `web_search`, fetch, GitHub clone, PDF/video. Optional keys: `~/.pi/web-search.json` |
151
- | `pi-flows` | [@blackbelt-technology/pi-flows](https://www.npmjs.com/package/@blackbelt-technology/pi-flows) | YAML DAG workflows (`/flows`). Peers **0.74** + `@sinclair/typebox`; off by default on 0.78 stacks |
146
+ | Toggle | Package | Notes |
147
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
148
+ | `caveman` | [pi-caveman](https://www.npmjs.com/package/pi-caveman) | Terse English (`/caveman`). Conflicts with `defaults.language: ja` |
149
+ | `red-green` | [pi-red-green](https://www.npmjs.com/package/pi-red-green) | TDD via `/tdd`, `/tdd-status`. Config: `~/.pi/red-green/config.json` |
150
+ | `agent-dashboard` | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) | Warm-starts before the bridge loads (cold boot up to ~60s). Peers **0.74**; test on 0.78 before relying on it. Node.js ≥ 22.18 |
151
+ | `web-access` | [pi-web-access](https://www.npmjs.com/package/pi-web-access) | `web_search`, fetch, GitHub clone, PDF/video. Optional keys: `~/.pi/web-search.json` |
152
+ | `pi-flows` | [@blackbelt-technology/pi-flows](https://www.npmjs.com/package/@blackbelt-technology/pi-flows) | YAML DAG workflows (`/flows`). Peers **0.74** + `@sinclair/typebox`; off by default on 0.78 stacks |
153
+ | `kanagawa` | [pi-kanagawa](https://www.npmjs.com/package/pi-kanagawa) | Kanagawa theme (also in `/theme` via shipped assets), wave animation, `/thinking`. **Replaces hotmilk footer** when on |
154
+ | `tetris` | [pi-tetris](https://www.npmjs.com/package/pi-tetris) | Play Tetris with `/tetris`. Lightweight; default off |
152
155
 
153
156
  **Agent dashboard troubleshooting**
154
157
 
155
158
  - Run **one** dashboard process: either hotmilk warm-start (`agent-dashboard: true`) **or** manual `npm run dashboard:start`, not both.
156
159
  - Keep only `"hotmilk"` in `~/.pi/agent/settings.json` `packages[]` (not a standalone dashboard extension path). Hotmilk prunes duplicate dashboard paths on session start when `agent-dashboard` is enabled.
157
- - `EADDRINUSE` on `8000` or `9999`: `npm run dashboard:stop`, then `lsof -i :8000` / `:9999` and kill the stale PID before starting again.
160
+ - Hotmilk seeds dashboard HTTP port **8102** (when config still has upstream default `8000`) to avoid common port conflicts. Custom ports are preserved.
161
+ - `EADDRINUSE` on `8102` (or `9999` for pi bridge): stop the stale dashboard (`pi-dashboard stop` or `lsof -i :8102` / `:9999`), then restart Pi.
158
162
  - Without `zrok` on PATH, hotmilk sets `tunnel.enabled` to `false`.
163
+ - **`ERR TypeScript loader` / jiti or tsx not found** (paths under `~/.pi-dashboard/node_modules/`): the bridge doctor checks the legacy managed dashboard dir. That directory is often empty when you only use hotmilk. Enable `agent-dashboard`, restart Pi (warm-start uses hotmilk’s bundled `pi-dashboard-server`, which ships jiti), or run `bun run dashboard:start` from a hotmilk checkout. If the error persists, install globally: `pi install npm:@blackbelt-technology/pi-agent-dashboard` and complete **Help → Setup**, or `npm install jiti` under `~/.pi-dashboard` after `pi-dashboard` setup creates that tree.
164
+
165
+ ### Cursor models (optional, not bundled)
166
+
167
+ hotmilk does not ship [@netandreus/pi-cursor-provider](https://www.npmjs.com/package/@netandreus/pi-cursor-provider). Install when you route Pi through the Cursor Agent CLI:
168
+
169
+ ```bash
170
+ pi install npm:@netandreus/pi-cursor-provider
171
+ agent login
172
+ # then in Pi: /model cursor/auto
173
+ ```
159
174
 
160
175
  ### Migrating from `pi-ninja`
161
176
 
package/hotmilk.json CHANGED
@@ -10,7 +10,6 @@
10
10
  "goal": true,
11
11
  "docparser": true,
12
12
  "obsidian": true,
13
- "cursor-provider": true,
14
13
  "btw": true,
15
14
  "simplify": true,
16
15
  "rtk-optimizer": false,
@@ -20,7 +19,9 @@
20
19
  "red-green": false,
21
20
  "agent-dashboard": false,
22
21
  "web-access": false,
23
- "pi-flows": false
22
+ "pi-flows": false,
23
+ "kanagawa": false,
24
+ "tetris": false
24
25
  },
25
26
  "graph": {
26
27
  "warnOnStale": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotmilk",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "hotmilk — Pi package bundling gentle-pi, context-mode, graphify, subagents, and bundled extension toggles via hotmilk.json",
5
5
  "keywords": [
6
6
  "hotmilk",
@@ -42,7 +42,6 @@
42
42
  "@blackbelt-technology/pi-agent-dashboard": "^0.5.4",
43
43
  "@blackbelt-technology/pi-flows": "^0.2.1",
44
44
  "@haispeed/pi-obsidian": "^0.1.1",
45
- "@netandreus/pi-cursor-provider": "^0.1.4",
46
45
  "@tomxprime/planning-with-files": "^1.1.0",
47
46
  "context-mode": "^1.0.151",
48
47
  "gentle-pi": "^0.3.10",
@@ -52,11 +51,13 @@
52
51
  "pi-caveman": "^1.0.7",
53
52
  "pi-docparser": "^3.0.1",
54
53
  "pi-goal": "^0.1.5",
54
+ "pi-kanagawa": "^1.0.0",
55
55
  "pi-mcp-adapter": "^2.8.0",
56
56
  "pi-red-green": "^0.2.2",
57
57
  "pi-rtk-optimizer": "^0.8.1",
58
58
  "pi-simplify": "^0.2.2",
59
59
  "pi-subagents": "^0.25.0",
60
+ "pi-tetris": "^1.3.1",
60
61
  "pi-web-access": "^0.10.7"
61
62
  },
62
63
  "devDependencies": {
@@ -101,7 +102,8 @@
101
102
  "node_modules/@tomxprime/planning-with-files"
102
103
  ],
103
104
  "themes": [
104
- "./themes"
105
+ "./themes",
106
+ "node_modules/pi-kanagawa/themes"
105
107
  ]
106
108
  }
107
109
  }
@@ -8,15 +8,6 @@ import type { BundledExtensionId } from "../config/hotmilk.ts";
8
8
  const RTK_EXTENSION_DIR = "pi-rtk-optimizer";
9
9
  const RTK_CONFIG_FILENAME = "config.json";
10
10
 
11
- /**
12
- * Register in this order so context-mode hooks run before rtk-optimizer compacts tool output.
13
- * Loader paths match upstream npm `pi.extensions` (not `.pi/extensions/` shims in the git repo).
14
- */
15
- export const CONTEXT_STACK_EXTENSION_IDS = [
16
- "context-mode",
17
- "rtk-optimizer",
18
- ] as const satisfies readonly BundledExtensionId[];
19
-
20
11
  export function getRtkOptimizerConfigPath(): string {
21
12
  return `${getAgentDir()}/extensions/${RTK_EXTENSION_DIR}/${RTK_CONFIG_FILENAME}`;
22
13
  }
@@ -144,15 +135,16 @@ export function applyContextStackOnSessionStart(
144
135
  notify: (message: string, level: "info" | "warning") => void,
145
136
  ): void {
146
137
  const { extensionToggles } = runtime;
138
+ const contextModeEnabled = extensionToggles["context-mode"];
147
139
 
148
140
  if (extensionToggles["rtk-optimizer"]) {
149
- const sync = syncRtkConfigForContextStack(extensionToggles["context-mode"], true);
141
+ const sync = syncRtkConfigForContextStack(contextModeEnabled, true);
150
142
  if (sync.updated && !sync.seeded) {
151
143
  notify(RTK_SYNC_MESSAGE, "info");
152
144
  }
153
145
  }
154
146
 
155
- if (!extensionToggles["context-mode"]) {
147
+ if (!contextModeEnabled) {
156
148
  return;
157
149
  }
158
150
 
@@ -1,23 +1,118 @@
1
- import { execSync, spawn } from "node:child_process";
1
+ import { execSync } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
3
  import fs from "node:fs";
4
+ import net from "node:net";
5
+ import os from "node:os";
4
6
  import path from "node:path";
5
- import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
6
- import { pruneRedundantDashboardPackage } from "./dashboard-settings.ts";
7
+ import type { DashboardConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
7
8
  import {
8
9
  CONFIG_FILE,
9
10
  ensureConfig,
10
11
  loadConfig,
11
12
  } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
+ import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
14
+ import {
15
+ EarlyExitError,
16
+ launchDashboardServer,
17
+ PortConflictError,
18
+ } from "@blackbelt-technology/pi-dashboard-shared/server-launcher.js";
19
+ import { pruneRedundantDashboardPackage } from "./dashboard-settings.ts";
12
20
 
13
21
  const require = createRequire(import.meta.url);
14
22
 
15
- /** Bridge auto-start only waits 2s; warm-start via CLI uses 30s readiness. */
23
+ /** hotmilk default dashboard HTTP port (avoids common 8000 conflicts). */
24
+ export const HOTMILK_DASHBOARD_PORT = 8102;
25
+
26
+ /** Upstream pi-agent-dashboard default before hotmilk applies its seed. */
27
+ export const UPSTREAM_DASHBOARD_DEFAULT_PORT = 8000;
28
+
29
+ /** Cold dashboard bootstrap (jiti + session scan) can exceed bridge's 2s probe. */
30
+ const WARM_START_PROBE_OPTS = {
31
+ timeoutMs: 8_000,
32
+ retries: 3,
33
+ retryDelayMs: 500,
34
+ } as const;
35
+
36
+ const WARM_START_HEALTH_TIMEOUT_MS = 60_000;
37
+
38
+ export function dashboardServerLogPath(): string {
39
+ return path.join(os.homedir(), ".pi", "dashboard", "server.log");
40
+ }
41
+
42
+ /** Bridge auto-start only waits 2s; warm-start uses 30s readiness. */
16
43
  export function resolvePiDashboardCli(): string {
17
44
  const pkgJson = require.resolve("@blackbelt-technology/pi-agent-dashboard/package.json");
18
45
  return path.join(path.dirname(pkgJson), "packages/server/bin/pi-dashboard.mjs");
19
46
  }
20
47
 
48
+ /** Entry script for {@link launchDashboardServer} (server CLI, not the mjs wrapper). */
49
+ export function resolveDashboardServerCliPath(): string {
50
+ const pkgJson = require.resolve("@blackbelt-technology/pi-dashboard-server/package.json");
51
+ return path.join(path.dirname(pkgJson), "src", "cli.ts");
52
+ }
53
+
54
+ export function buildWarmStartLaunchArgs(
55
+ config: Pick<DashboardConfig, "port" | "piPort">,
56
+ ): string[] {
57
+ return ["--port", String(config.port), "--pi-port", String(config.piPort)];
58
+ }
59
+
60
+ export function isTcpPortFree(port: number, host = "127.0.0.1"): Promise<boolean> {
61
+ return new Promise((resolve) => {
62
+ const server = net.createServer();
63
+ server.once("error", () => resolve(false));
64
+ server.once("listening", () => {
65
+ server.close(() => resolve(true));
66
+ });
67
+ server.listen(port, host);
68
+ });
69
+ }
70
+
71
+ /** Pick a free pi gateway port when the configured default is already taken. */
72
+ export async function resolveAvailablePiPort(
73
+ preferred: number,
74
+ isFree: (port: number) => Promise<boolean> = isTcpPortFree,
75
+ maxAttempts = 20,
76
+ ): Promise<number> {
77
+ for (let offset = 0; offset < maxAttempts; offset += 1) {
78
+ const candidate = preferred - offset;
79
+ if (candidate <= 0) {
80
+ break;
81
+ }
82
+ if (await isFree(candidate)) {
83
+ return candidate;
84
+ }
85
+ }
86
+ return preferred;
87
+ }
88
+
89
+ export function persistDashboardPiPort(piPort: number): boolean {
90
+ ensureConfig();
91
+ const raw = readDashboardConfigRaw();
92
+ if (!raw || raw.piPort === piPort) {
93
+ return false;
94
+ }
95
+ raw.piPort = piPort;
96
+ writeDashboardConfigRaw(raw);
97
+ return true;
98
+ }
99
+
100
+ export type DashboardWarmStartDecision = "skip-running" | "skip-conflict" | "launch";
101
+
102
+ export async function resolveDashboardWarmStartDecision(
103
+ port: number,
104
+ probe: typeof isDashboardRunning = isDashboardRunning,
105
+ ): Promise<DashboardWarmStartDecision> {
106
+ const status = await probe(port, "localhost", WARM_START_PROBE_OPTS);
107
+ if (status.running) {
108
+ return "skip-running";
109
+ }
110
+ if (status.portConflict) {
111
+ return "skip-conflict";
112
+ }
113
+ return "launch";
114
+ }
115
+
21
116
  export function isZrokOnPath(): boolean {
22
117
  try {
23
118
  execSync("command -v zrok", { stdio: "ignore" });
@@ -27,66 +122,150 @@ export function isZrokOnPath(): boolean {
27
122
  }
28
123
  }
29
124
 
125
+ function readDashboardConfigRaw(): Record<string, unknown> | undefined {
126
+ try {
127
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")) as Record<string, unknown>;
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ function writeDashboardConfigRaw(raw: Record<string, unknown>): void {
134
+ fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
135
+ }
136
+
137
+ export type ApplyHotmilkDashboardDefaultsResult = {
138
+ updated: boolean;
139
+ path: string;
140
+ };
141
+
30
142
  /**
31
- * Disable zrok tunnel when the binary is missing — avoids slow/failed tunnel
32
- * setup and matches local-only hotmilk usage.
143
+ * Apply hotmilk-specific dashboard defaults to a parsed config object.
144
+ * Preserves custom ports; only migrates upstream default 8000 → {@link HOTMILK_DASHBOARD_PORT}.
33
145
  */
34
- export function applyHotmilkDashboardDefaults(): { updated: boolean; path: string } {
35
- ensureConfig();
36
- if (isZrokOnPath()) {
37
- return { updated: false, path: CONFIG_FILE };
146
+ export function applyHotmilkDashboardDefaultsToRaw(
147
+ raw: Record<string, unknown>,
148
+ opts: { zrokAvailable?: boolean } = {},
149
+ ): boolean {
150
+ const zrokAvailable = opts.zrokAvailable ?? isZrokOnPath();
151
+ let updated = false;
152
+
153
+ const port = raw.port;
154
+ if (port === undefined || port === UPSTREAM_DASHBOARD_DEFAULT_PORT) {
155
+ raw.port = HOTMILK_DASHBOARD_PORT;
156
+ updated = true;
38
157
  }
39
158
 
40
- let raw: Record<string, unknown>;
41
- try {
42
- raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")) as Record<string, unknown>;
43
- } catch {
44
- return { updated: false, path: CONFIG_FILE };
159
+ if (!zrokAvailable) {
160
+ const tunnel =
161
+ raw.tunnel && typeof raw.tunnel === "object" ? (raw.tunnel as Record<string, unknown>) : {};
162
+ if (tunnel.enabled !== false) {
163
+ raw.tunnel = { ...tunnel, enabled: false };
164
+ updated = true;
165
+ }
45
166
  }
46
167
 
47
- const tunnel =
48
- raw.tunnel && typeof raw.tunnel === "object" ? (raw.tunnel as Record<string, unknown>) : {};
49
- if (tunnel.enabled === false) {
168
+ return updated;
169
+ }
170
+
171
+ /**
172
+ * Seed hotmilk dashboard defaults into ~/.pi/dashboard/config.json:
173
+ * port 8102 (when still on upstream 8000) and disable zrok when the binary is missing.
174
+ */
175
+ export function applyHotmilkDashboardDefaults(): ApplyHotmilkDashboardDefaultsResult {
176
+ ensureConfig();
177
+
178
+ const raw = readDashboardConfigRaw();
179
+ if (!raw || !applyHotmilkDashboardDefaultsToRaw(raw)) {
50
180
  return { updated: false, path: CONFIG_FILE };
51
181
  }
52
182
 
53
- raw.tunnel = { ...tunnel, enabled: false };
54
- fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
183
+ writeDashboardConfigRaw(raw);
55
184
  return { updated: true, path: CONFIG_FILE };
56
185
  }
57
186
 
58
- let warmStartScheduled = false;
187
+ let warmStartPromise: Promise<DashboardWarmStartResult> | undefined;
59
188
 
60
- async function runDashboardWarmStart(): Promise<void> {
189
+ export type DashboardWarmStartResult = {
190
+ status: "running" | "started" | "skipped-conflict" | "failed";
191
+ port: number;
192
+ piPort: number;
193
+ message?: string;
194
+ };
195
+
196
+ function warmStartResult(
197
+ status: DashboardWarmStartResult["status"],
198
+ port: number,
199
+ piPort: number,
200
+ message?: string,
201
+ ): DashboardWarmStartResult {
202
+ return { status, port, piPort, message };
203
+ }
204
+
205
+ /**
206
+ * Start the dashboard via shared launcher (60s readiness) and wait until
207
+ * the bridge extension loads so its 2s auto-start sees a healthy server.
208
+ */
209
+ export async function ensureDashboardWarmStarted(): Promise<DashboardWarmStartResult> {
210
+ if (warmStartPromise) {
211
+ return warmStartPromise;
212
+ }
213
+
214
+ warmStartPromise = runDashboardWarmStart().catch((err) => {
215
+ warmStartPromise = undefined;
216
+ throw err;
217
+ });
218
+ return warmStartPromise;
219
+ }
220
+
221
+ async function runDashboardWarmStart(): Promise<DashboardWarmStartResult> {
61
222
  pruneRedundantDashboardPackage();
62
223
  applyHotmilkDashboardDefaults();
63
224
  const config = loadConfig();
64
- const status = await isDashboardRunning(config.port, "localhost", {
65
- timeoutMs: 800,
66
- retries: 1,
67
- retryDelayMs: 200,
68
- });
69
- if (status.running) {
70
- return;
225
+ const decision = await resolveDashboardWarmStartDecision(config.port);
226
+ if (decision === "skip-running") {
227
+ return warmStartResult("running", config.port, config.piPort);
228
+ }
229
+ if (decision === "skip-conflict") {
230
+ return warmStartResult(
231
+ "skipped-conflict",
232
+ config.port,
233
+ config.piPort,
234
+ `Port ${config.port} is occupied by a non-dashboard service`,
235
+ );
71
236
  }
72
237
 
73
- const cli = resolvePiDashboardCli();
74
- const child = spawn(process.execPath, [cli, "start"], {
75
- detached: true,
76
- stdio: "ignore",
77
- env: process.env,
78
- });
79
- child.unref();
80
- }
238
+ let piPort = config.piPort;
239
+ if (!(await isTcpPortFree(piPort))) {
240
+ const resolved = await resolveAvailablePiPort(piPort);
241
+ if (resolved !== piPort) {
242
+ persistDashboardPiPort(resolved);
243
+ piPort = resolved;
244
+ }
245
+ }
81
246
 
82
- /**
83
- * Start the dashboard via `pi-dashboard start` (30s readiness) before the
84
- * bridge extension's 2s auto-start runs. Fire-and-forget; idempotent.
85
- */
86
- export function scheduleDashboardWarmStart(): void {
87
- if (warmStartScheduled) {
88
- return;
247
+ const launchConfig = { port: config.port, piPort };
248
+
249
+ try {
250
+ await launchDashboardServer({
251
+ cliPath: resolveDashboardServerCliPath(),
252
+ extraArgs: buildWarmStartLaunchArgs(launchConfig),
253
+ stdio: { logFile: dashboardServerLogPath() },
254
+ starter: "Hotmilk",
255
+ healthTimeoutMs: WARM_START_HEALTH_TIMEOUT_MS,
256
+ port: launchConfig.port,
257
+ });
258
+ return warmStartResult("started", launchConfig.port, launchConfig.piPort);
259
+ } catch (err: unknown) {
260
+ const { port, piPort } = launchConfig;
261
+ const recheck = await isDashboardRunning(port, "localhost", WARM_START_PROBE_OPTS);
262
+ if (recheck.running) {
263
+ return warmStartResult("running", port, piPort);
264
+ }
265
+ if (err instanceof PortConflictError || err instanceof EarlyExitError) {
266
+ return warmStartResult("failed", port, piPort, err.message);
267
+ }
268
+ const detail = err instanceof Error ? err.message : String(err);
269
+ return warmStartResult("failed", port, piPort, `${detail}. See ${dashboardServerLogPath()}`);
89
270
  }
90
- warmStartScheduled = true;
91
- void runDashboardWarmStart();
92
271
  }
@@ -1,5 +1,12 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { CONTEXT_STACK_EXTENSION_IDS } from "./context-stack.ts";
2
+ import {
3
+ BUNDLED_EXTENSION_DEFINITIONS,
4
+ CONTEXT_STACK_EXTENSION_IDS,
5
+ } from "../config/bundled-extensions.ts";
6
+ import {
7
+ detectGlobalBundledExtensionSkips,
8
+ type GlobalBundledExtensionSkip,
9
+ } from "./global-extension-sources.ts";
3
10
  import { bundledImportUrl } from "./resolve-bundled.ts";
4
11
  import { BUNDLED_EXTENSION_IDS, type BundledExtensionId } from "../config/hotmilk.ts";
5
12
 
@@ -12,48 +19,49 @@ function loadBundled(relativePath: string): () => Promise<ExtensionModule> {
12
19
  return () => import(bundledImportUrl(relativePath));
13
20
  }
14
21
 
15
- /**
16
- * Load bundled extensions on demand so disabled toggles do not pay import cost.
17
- * Context stack registers sequentially; other enabled extensions load in parallel.
18
- */
19
- const BUNDLED_EXTENSION_LOADERS: Record<BundledExtensionId, () => Promise<ExtensionModule>> = {
20
- "skill-registry": loadBundled("gentle-pi/extensions/skill-registry.ts"),
21
- "sdd-init": loadBundled("gentle-pi/extensions/sdd-init.ts"),
22
- "gentle-ai": loadBundled("gentle-pi/extensions/gentle-ai.ts"),
23
- "context-mode": loadBundled("context-mode/build/adapters/pi/extension.js"),
24
- "ask-user": loadBundled("pi-ask-user/index.ts"),
25
- graphify: loadBundled("graphify-pi/extensions/graphify.ts"),
26
- subagents: loadBundled("pi-subagents/src/extension/index.ts"),
27
- goal: loadBundled("pi-goal/.pi/extensions/pi-goal/index.ts"),
28
- docparser: loadBundled("pi-docparser/extensions/docparser/index.ts"),
29
- obsidian: loadBundled("@haispeed/pi-obsidian/extensions/obsidian-cli.ts"),
30
- "cursor-provider": loadBundled("@netandreus/pi-cursor-provider/index.ts"),
31
- btw: loadBundled("pi-btw/extensions/btw.ts"),
32
- simplify: loadBundled("pi-simplify/dist/index.js"),
33
- "rtk-optimizer": loadBundled("pi-rtk-optimizer/index.ts"),
34
- "mcp-adapter": loadBundled("pi-mcp-adapter/index.ts"),
35
- "planning-with-files": loadBundled(
36
- "@tomxprime/planning-with-files/extensions/planning-with-files/index.ts",
37
- ),
38
- caveman: loadBundled("pi-caveman/extensions/caveman.ts"),
39
- "red-green": loadBundled("pi-red-green/dist/index.js"),
40
- "agent-dashboard": loadBundled(
41
- "@blackbelt-technology/pi-agent-dashboard/packages/extension/src/bridge.ts",
42
- ),
43
- "web-access": loadBundled("pi-web-access/index.ts"),
44
- "pi-flows": loadBundled("@blackbelt-technology/pi-flows/extensions/index.ts"),
45
- };
22
+ /** Derived from {@link BUNDLED_EXTENSION_DEFINITIONS} — one loader per manifest row. */
23
+ const BUNDLED_EXTENSION_LOADERS = Object.fromEntries(
24
+ BUNDLED_EXTENSION_DEFINITIONS.map((definition) => [
25
+ definition.id,
26
+ loadBundled(definition.module),
27
+ ]),
28
+ ) as Record<BundledExtensionId, () => Promise<ExtensionModule>>;
46
29
 
47
30
  async function registerOne(pi: ExtensionAPI, id: BundledExtensionId): Promise<void> {
48
31
  const mod = await BUNDLED_EXTENSION_LOADERS[id]();
49
32
  await (mod.default as ExtensionFactory)(pi);
50
33
  }
51
34
 
35
+ export type RegisterBundledExtensionsOptions = {
36
+ cwd?: string;
37
+ /** Precomputed skips (tests); defaults to scanning Pi settings. */
38
+ globalSkips?: GlobalBundledExtensionSkip[];
39
+ };
40
+
41
+ export type RegisterBundledExtensionsResult = {
42
+ globalSkips: GlobalBundledExtensionSkip[];
43
+ };
44
+
52
45
  export async function registerBundledExtensions(
53
46
  pi: ExtensionAPI,
54
47
  enabled: Record<BundledExtensionId, boolean>,
55
- ): Promise<void> {
56
- const enabledIds = new Set(BUNDLED_EXTENSION_IDS.filter((id) => enabled[id]));
48
+ options: RegisterBundledExtensionsOptions = {},
49
+ ): Promise<RegisterBundledExtensionsResult> {
50
+ const globalSkips =
51
+ options.globalSkips ?? detectGlobalBundledExtensionSkips({ cwd: options.cwd });
52
+ const skipById = new Map(globalSkips.map((skip) => [skip.id, skip] as const));
53
+
54
+ const enabledIds = new Set<BundledExtensionId>();
55
+ const appliedSkips: GlobalBundledExtensionSkip[] = [];
56
+ for (const id of BUNDLED_EXTENSION_IDS) {
57
+ if (!enabled[id]) continue;
58
+ const skip = skipById.get(id);
59
+ if (skip) {
60
+ appliedSkips.push(skip);
61
+ continue;
62
+ }
63
+ enabledIds.add(id);
64
+ }
57
65
 
58
66
  for (const id of CONTEXT_STACK_EXTENSION_IDS) {
59
67
  if (enabledIds.has(id)) {
@@ -62,5 +70,14 @@ export async function registerBundledExtensions(
62
70
  }
63
71
  }
64
72
 
73
+ if (enabledIds.has("agent-dashboard")) {
74
+ enabledIds.delete("agent-dashboard");
75
+ const { ensureDashboardWarmStarted } = await import("./dashboard.ts");
76
+ await ensureDashboardWarmStarted();
77
+ await registerOne(pi, "agent-dashboard");
78
+ }
79
+
65
80
  await Promise.all([...enabledIds].map((id) => registerOne(pi, id)));
81
+
82
+ return { globalSkips: appliedSkips };
66
83
  }