hotmilk 0.1.9 → 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 +1 -1
- package/README.md +38 -23
- package/hotmilk.json +3 -2
- package/package.json +5 -3
- package/src/bootstrap/context-stack.ts +3 -11
- package/src/bootstrap/dashboard.ts +225 -46
- package/src/bootstrap/extensions.ts +56 -33
- package/src/bootstrap/global-extension-sources.ts +154 -0
- package/src/bootstrap/resolve-bundled.ts +55 -0
- package/src/bootstrap/session.ts +15 -0
- package/src/config/bundled-extensions.ts +235 -0
- package/src/config/bundled-package-registry.ts +28 -0
- package/src/config/hotmilk.ts +2 -25
- package/src/config/runtime.ts +4 -0
- package/src/controller/input.ts +1 -2
- package/src/controller/mode.ts +2 -3
- package/src/index.ts +6 -6
- package/src/ui/footer.ts +83 -12
- package/src/ui/github-user.ts +153 -0
- package/src/ui/session-logo.ts +9 -4
- package/src/bootstrap/rtk-coexist.ts +0 -6
- package/src/config/extension-groups.ts +0 -32
- package/src/controller/context.ts +0 -5
- package/src/ui/footer-time.ts +0 -9
- package/src/ui/logo.ts +0 -7
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
|
|
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)
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
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(
|
|
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 (!
|
|
147
|
+
if (!contextModeEnabled) {
|
|
156
148
|
return;
|
|
157
149
|
}
|
|
158
150
|
|
|
@@ -1,23 +1,118 @@
|
|
|
1
|
-
import { execSync
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
187
|
+
let warmStartPromise: Promise<DashboardWarmStartResult> | undefined;
|
|
59
188
|
|
|
60
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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,13 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
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";
|
|
10
|
+
import { bundledImportUrl } from "./resolve-bundled.ts";
|
|
3
11
|
import { BUNDLED_EXTENSION_IDS, type BundledExtensionId } from "../config/hotmilk.ts";
|
|
4
12
|
|
|
5
13
|
type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
|
|
@@ -7,47 +15,53 @@ type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
|
|
|
7
15
|
/** Bundled deps may type against @mariozechner/pi-coding-agent; hotmilk uses @earendil-works. */
|
|
8
16
|
type ExtensionModule = { default: unknown };
|
|
9
17
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
graphify: () => import("../../node_modules/graphify-pi/extensions/graphify.ts"),
|
|
22
|
-
subagents: () => import("../../node_modules/pi-subagents/src/extension/index.ts"),
|
|
23
|
-
goal: () => import("../../node_modules/pi-goal/.pi/extensions/pi-goal/index.ts"),
|
|
24
|
-
docparser: () => import("../../node_modules/pi-docparser/extensions/docparser/index.ts"),
|
|
25
|
-
obsidian: () => import("../../node_modules/@haispeed/pi-obsidian/extensions/obsidian-cli.ts"),
|
|
26
|
-
"cursor-provider": () => import("../../node_modules/@netandreus/pi-cursor-provider/index.ts"),
|
|
27
|
-
btw: () => import("../../node_modules/pi-btw/extensions/btw.ts"),
|
|
28
|
-
simplify: () => import("../../node_modules/pi-simplify/dist/index.js"),
|
|
29
|
-
"rtk-optimizer": () => import("../../node_modules/pi-rtk-optimizer/index.ts"),
|
|
30
|
-
"mcp-adapter": () => import("../../node_modules/pi-mcp-adapter/index.ts"),
|
|
31
|
-
"planning-with-files": () =>
|
|
32
|
-
import("../../node_modules/@tomxprime/planning-with-files/extensions/planning-with-files/index.ts"),
|
|
33
|
-
caveman: () => import("../../node_modules/pi-caveman/extensions/caveman.ts"),
|
|
34
|
-
"red-green": () => import("../../node_modules/pi-red-green/dist/index.js"),
|
|
35
|
-
"agent-dashboard": () =>
|
|
36
|
-
import("../../node_modules/@blackbelt-technology/pi-agent-dashboard/packages/extension/src/bridge.ts"),
|
|
37
|
-
"web-access": () => import("../../node_modules/pi-web-access/index.ts"),
|
|
38
|
-
"pi-flows": () => import("../../node_modules/@blackbelt-technology/pi-flows/extensions/index.ts"),
|
|
39
|
-
};
|
|
18
|
+
function loadBundled(relativePath: string): () => Promise<ExtensionModule> {
|
|
19
|
+
return () => import(bundledImportUrl(relativePath));
|
|
20
|
+
}
|
|
21
|
+
|
|
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>>;
|
|
40
29
|
|
|
41
30
|
async function registerOne(pi: ExtensionAPI, id: BundledExtensionId): Promise<void> {
|
|
42
31
|
const mod = await BUNDLED_EXTENSION_LOADERS[id]();
|
|
43
32
|
await (mod.default as ExtensionFactory)(pi);
|
|
44
33
|
}
|
|
45
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
|
+
|
|
46
45
|
export async function registerBundledExtensions(
|
|
47
46
|
pi: ExtensionAPI,
|
|
48
47
|
enabled: Record<BundledExtensionId, boolean>,
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
51
65
|
|
|
52
66
|
for (const id of CONTEXT_STACK_EXTENSION_IDS) {
|
|
53
67
|
if (enabledIds.has(id)) {
|
|
@@ -56,5 +70,14 @@ export async function registerBundledExtensions(
|
|
|
56
70
|
}
|
|
57
71
|
}
|
|
58
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
|
+
|
|
59
80
|
await Promise.all([...enabledIds].map((id) => registerOne(pi, id)));
|
|
81
|
+
|
|
82
|
+
return { globalSkips: appliedSkips };
|
|
60
83
|
}
|