hotmilk 0.1.13 → 0.1.15
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 +12 -10
- package/README.md +237 -53
- package/agents/README.md +3 -3
- package/agents/assistant.md +1 -1
- package/agents/coach.md +26 -4
- package/agents/designer.md +1 -1
- package/agents/planner.md +2 -2
- package/hotmilk.json +11 -0
- package/package.json +43 -24
- package/prompts/prompt-eval.md +241 -0
- package/prompts/tidy.md +3 -1
- package/prompts/translate.md +3 -1
- package/skills/make-docs/SKILL.md +43 -0
- package/skills/pioneer/SKILL.md +135 -131
- package/skills/pioneer/references/autoresearch-routing.md +114 -0
- package/skills/pioneer/references/chat-plan.md +50 -0
- package/skills/pioneer/references/goal-gate.md +54 -0
- package/skills/pioneer/references/graph-recon-gate.md +50 -0
- package/skills/pioneer/references/observational-memory-routing.md +90 -0
- package/skills/pioneer/references/openspec-routing.md +50 -0
- package/skills/pioneer/references/plannotator-routing.md +83 -0
- package/skills/pioneer/references/prompt-eval-gate.md +38 -0
- package/skills/pioneer/references/shazam-routing.md +58 -0
- package/skills/recommend-research/SKILL.md +94 -6
- package/skills/recommend-research/references/example-narrow.md +53 -0
- package/skills/recommend-research/references/scope-gate.md +46 -0
- package/skills/recommend-research/references/shortlist-gate.md +69 -0
- package/skills/recommend-research/references/verify-gate.md +73 -0
- package/skills/update-docs/SKILL.md +86 -0
- package/skills/update-docs/references/doc-inventory.md +69 -0
- package/skills/update-docs/references/drift-verification.md +59 -0
- package/src/bootstrap/btw.ts +49 -0
- package/src/bootstrap/context-stack.ts +71 -14
- package/src/bootstrap/dashboard-settings.ts +16 -2
- package/src/bootstrap/dashboard.ts +85 -10
- package/src/bootstrap/defaults.ts +26 -0
- package/src/bootstrap/extensions.ts +10 -1
- package/src/bootstrap/global-extension-sources.ts +43 -4
- package/src/bootstrap/graph.ts +12 -0
- package/src/bootstrap/project-trust.ts +55 -0
- package/src/bootstrap/resolve-bundled.ts +28 -4
- package/src/bootstrap/session.ts +61 -1
- package/src/bootstrap/subagents-doctor.ts +12 -0
- package/src/config/bundled-extensions.ts +42 -0
- package/src/config/bundled-package-registry.ts +3 -0
- package/src/config/hotmilk.ts +76 -1
- package/src/config/mcp.ts +45 -12
- package/src/config/resolve.ts +21 -0
- package/src/config/runtime.ts +6 -0
- package/src/controller/input.ts +15 -0
- package/src/controller/mode.ts +32 -0
- package/src/index.ts +5 -0
- package/src/ui/footer.ts +63 -1
- package/src/ui/github-user.ts +52 -4
- package/src/ui/session-logo.ts +31 -3
- package/themes/monokai.json +1 -1
- package/skills/empirical-prompt-tuning/SKILL.md +0 -232
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard warm-start and config migration.
|
|
3
|
+
*
|
|
4
|
+
* Prepares the bundled agent-dashboard extension by migrating the upstream
|
|
5
|
+
* default port, disabling zrok when unavailable, and launching the server so
|
|
6
|
+
* the bridge extension sees a healthy dashboard on session start.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import { execSync } from "node:child_process";
|
|
2
10
|
import { createRequire } from "node:module";
|
|
3
11
|
import fs from "node:fs";
|
|
@@ -35,28 +43,49 @@ const WARM_START_PROBE_OPTS = {
|
|
|
35
43
|
|
|
36
44
|
const WARM_START_HEALTH_TIMEOUT_MS = 60_000;
|
|
37
45
|
|
|
46
|
+
/** @returns the path to the dashboard server log file under `~/.pi/dashboard`. */
|
|
38
47
|
export function dashboardServerLogPath(): string {
|
|
39
48
|
return path.join(os.homedir(), ".pi", "dashboard", "server.log");
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the bundled `@blackbelt-technology/pi-agent-dashboard` server CLI.
|
|
53
|
+
*
|
|
54
|
+
* @returns absolute path to `pi-dashboard.mjs`.
|
|
55
|
+
*/
|
|
43
56
|
export function resolvePiDashboardCli(): string {
|
|
44
57
|
const pkgJson = require.resolve("@blackbelt-technology/pi-agent-dashboard/package.json");
|
|
45
58
|
return path.join(path.dirname(pkgJson), "packages/server/bin/pi-dashboard.mjs");
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the dashboard server entry script used by {@link launchDashboardServer}.
|
|
63
|
+
*
|
|
64
|
+
* @returns absolute path to the server CLI source.
|
|
65
|
+
*/
|
|
49
66
|
export function resolveDashboardServerCliPath(): string {
|
|
50
67
|
const pkgJson = require.resolve("@blackbelt-technology/pi-dashboard-server/package.json");
|
|
51
68
|
return path.join(path.dirname(pkgJson), "src", "cli.ts");
|
|
52
69
|
}
|
|
53
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Build command-line args for a warm-start launch.
|
|
73
|
+
*
|
|
74
|
+
* @param config - dashboard config (port + pi gateway port)
|
|
75
|
+
* @returns CLI args like `["--port", "8102", "--pi-port", "9999"]`
|
|
76
|
+
*/
|
|
54
77
|
export function buildWarmStartLaunchArgs(
|
|
55
78
|
config: Pick<DashboardConfig, "port" | "piPort">,
|
|
56
79
|
): string[] {
|
|
57
80
|
return ["--port", String(config.port), "--pi-port", String(config.piPort)];
|
|
58
81
|
}
|
|
59
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Probe whether a TCP port is currently free.
|
|
85
|
+
*
|
|
86
|
+
* @param port - port to test
|
|
87
|
+
* @param host - interface to bind (default: 127.0.0.1)
|
|
88
|
+
*/
|
|
60
89
|
export function isTcpPortFree(port: number, host = "127.0.0.1"): Promise<boolean> {
|
|
61
90
|
return new Promise((resolve) => {
|
|
62
91
|
const server = net.createServer();
|
|
@@ -68,7 +97,14 @@ export function isTcpPortFree(port: number, host = "127.0.0.1"): Promise<boolean
|
|
|
68
97
|
});
|
|
69
98
|
}
|
|
70
99
|
|
|
71
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Pick a free pi gateway port descending from a preferred value.
|
|
102
|
+
*
|
|
103
|
+
* @param preferred - starting port
|
|
104
|
+
* @param isFree - port probe function (default: {@link isTcpPortFree})
|
|
105
|
+
* @param maxAttempts - how many ports to try
|
|
106
|
+
* @returns the first free port, or `preferred` if none are found
|
|
107
|
+
*/
|
|
72
108
|
export async function resolveAvailablePiPort(
|
|
73
109
|
preferred: number,
|
|
74
110
|
isFree: (port: number) => Promise<boolean> = isTcpPortFree,
|
|
@@ -86,6 +122,12 @@ export async function resolveAvailablePiPort(
|
|
|
86
122
|
return preferred;
|
|
87
123
|
}
|
|
88
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Persist the chosen pi gateway port to dashboard config.
|
|
127
|
+
*
|
|
128
|
+
* @param piPort - port to save
|
|
129
|
+
* @returns whether the file was actually changed
|
|
130
|
+
*/
|
|
89
131
|
export function persistDashboardPiPort(piPort: number): boolean {
|
|
90
132
|
ensureConfig();
|
|
91
133
|
const raw = readDashboardConfigRaw();
|
|
@@ -97,6 +139,7 @@ export function persistDashboardPiPort(piPort: number): boolean {
|
|
|
97
139
|
return true;
|
|
98
140
|
}
|
|
99
141
|
|
|
142
|
+
/** Decision made by {@link resolveDashboardWarmStartDecision}. */
|
|
100
143
|
export type DashboardWarmStartDecision = "skip-running" | "skip-conflict" | "launch";
|
|
101
144
|
|
|
102
145
|
/** When TCP is bound but /api/health is not ready yet (cold boot). */
|
|
@@ -112,6 +155,17 @@ function sleep(ms: number): Promise<void> {
|
|
|
112
155
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
156
|
}
|
|
114
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Decide whether the dashboard needs a warm-start launch.
|
|
160
|
+
*
|
|
161
|
+
* Skips when already running, waits briefly for a cold boot to become ready,
|
|
162
|
+
* and treats persistent occupancy as a conflict.
|
|
163
|
+
*
|
|
164
|
+
* @param port - dashboard HTTP port
|
|
165
|
+
* @param probe - running-state probe (default: `isDashboardRunning`)
|
|
166
|
+
* @param isFree - TCP port probe (default: {@link isTcpPortFree})
|
|
167
|
+
* @param opts - wait/poll timing overrides
|
|
168
|
+
*/
|
|
115
169
|
export async function resolveDashboardWarmStartDecision(
|
|
116
170
|
port: number,
|
|
117
171
|
probe: typeof isDashboardRunning = isDashboardRunning,
|
|
@@ -153,6 +207,7 @@ export async function resolveDashboardWarmStartDecision(
|
|
|
153
207
|
return "launch";
|
|
154
208
|
}
|
|
155
209
|
|
|
210
|
+
/** @returns whether the `zrok` binary is available on `$PATH`. */
|
|
156
211
|
export function isZrokOnPath(): boolean {
|
|
157
212
|
try {
|
|
158
213
|
execSync("command -v zrok", { stdio: "ignore" });
|
|
@@ -174,6 +229,7 @@ function writeDashboardConfigRaw(raw: Record<string, unknown>): void {
|
|
|
174
229
|
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
|
|
175
230
|
}
|
|
176
231
|
|
|
232
|
+
/** Result of {@link applyHotmilkDashboardDefaults}. */
|
|
177
233
|
export type ApplyHotmilkDashboardDefaultsResult = {
|
|
178
234
|
updated: boolean;
|
|
179
235
|
path: string;
|
|
@@ -181,7 +237,13 @@ export type ApplyHotmilkDashboardDefaultsResult = {
|
|
|
181
237
|
|
|
182
238
|
/**
|
|
183
239
|
* Apply hotmilk-specific dashboard defaults to a parsed config object.
|
|
184
|
-
*
|
|
240
|
+
*
|
|
241
|
+
* Preserves custom ports; only migrates upstream default `8000` to
|
|
242
|
+
* {@link HOTMILK_DASHBOARD_PORT}. Disables the zrok tunnel when zrok is missing.
|
|
243
|
+
*
|
|
244
|
+
* @param raw - parsed dashboard config
|
|
245
|
+
* @param opts - optional zrok availability override
|
|
246
|
+
* @returns whether any changes were made
|
|
185
247
|
*/
|
|
186
248
|
export function applyHotmilkDashboardDefaultsToRaw(
|
|
187
249
|
raw: Record<string, unknown>,
|
|
@@ -209,8 +271,9 @@ export function applyHotmilkDashboardDefaultsToRaw(
|
|
|
209
271
|
}
|
|
210
272
|
|
|
211
273
|
/**
|
|
212
|
-
* Seed hotmilk dashboard defaults into
|
|
213
|
-
*
|
|
274
|
+
* Seed hotmilk dashboard defaults into `~/.pi/dashboard/config.json`.
|
|
275
|
+
*
|
|
276
|
+
* @returns whether the file was updated
|
|
214
277
|
*/
|
|
215
278
|
export function applyHotmilkDashboardDefaults(): ApplyHotmilkDashboardDefaultsResult {
|
|
216
279
|
ensureConfig();
|
|
@@ -227,11 +290,12 @@ export function applyHotmilkDashboardDefaults(): ApplyHotmilkDashboardDefaultsRe
|
|
|
227
290
|
/** Dedupes concurrent warm-start only; each session_start/reload re-probes. */
|
|
228
291
|
let warmStartInFlight: Promise<DashboardWarmStartResult> | undefined;
|
|
229
292
|
|
|
230
|
-
/**
|
|
293
|
+
/** Reset the in-flight warm-start promise so tests can re-launch. */
|
|
231
294
|
export function resetDashboardWarmStartForTests(): void {
|
|
232
295
|
warmStartInFlight = undefined;
|
|
233
296
|
}
|
|
234
297
|
|
|
298
|
+
/** Result returned by {@link ensureDashboardWarmStarted}. */
|
|
235
299
|
export type DashboardWarmStartResult = {
|
|
236
300
|
status: "running" | "started" | "skipped-conflict" | "failed";
|
|
237
301
|
port: number;
|
|
@@ -249,8 +313,12 @@ function warmStartResult(
|
|
|
249
313
|
}
|
|
250
314
|
|
|
251
315
|
/**
|
|
252
|
-
* Start the dashboard via shared launcher
|
|
253
|
-
*
|
|
316
|
+
* Start the dashboard via shared launcher and wait until it is ready.
|
|
317
|
+
*
|
|
318
|
+
* Concurrent calls are deduplicated. The bridge extension then sees a healthy
|
|
319
|
+
* server during its short auto-start probe.
|
|
320
|
+
*
|
|
321
|
+
* @returns warm-start outcome
|
|
254
322
|
*/
|
|
255
323
|
export async function ensureDashboardWarmStarted(): Promise<DashboardWarmStartResult> {
|
|
256
324
|
if (warmStartInFlight) {
|
|
@@ -318,7 +386,14 @@ async function runDashboardWarmStart(): Promise<DashboardWarmStartResult> {
|
|
|
318
386
|
}
|
|
319
387
|
}
|
|
320
388
|
|
|
321
|
-
/**
|
|
389
|
+
/**
|
|
390
|
+
* Warn when the dashboard doctor may flag hotmilk-managed warm-start.
|
|
391
|
+
*
|
|
392
|
+
* Hotmilk uses bundled jiti, so legacy `~/.pi-dashboard` warnings can be ignored
|
|
393
|
+
* when `/api/health` is healthy.
|
|
394
|
+
*
|
|
395
|
+
* @param result - warm-start result
|
|
396
|
+
*/
|
|
322
397
|
export function logHotmilkDashboardDoctorHint(result: DashboardWarmStartResult): void {
|
|
323
398
|
if (result.status !== "running" && result.status !== "started") {
|
|
324
399
|
return;
|
|
@@ -1,11 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persona / language defaults bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Seeds a project-level Gentle AI persona file from hotmilk config and injects
|
|
5
|
+
* a language hint into the system prompt when `defaults.language` is set.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
9
|
import { dirname, join } from "node:path";
|
|
3
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
11
|
import type { ResolvedDefaults } from "../config/hotmilk.ts";
|
|
5
12
|
|
|
13
|
+
/** Warning shown when caveman terse-mode clashes with Japanese language default. */
|
|
6
14
|
export const CAVEMAN_JA_CONFLICT_MESSAGE =
|
|
7
15
|
"caveman is on while defaults.language is ja — caveman adds English terse rules that fight the Japanese language hint. Turn off caveman (/mode), clear defaults.language, or use /caveman off.";
|
|
8
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Detect the caveman + Japanese language conflict.
|
|
19
|
+
*
|
|
20
|
+
* @param cavemanEnabled - whether caveman toggle is on
|
|
21
|
+
* @param language - configured language hint
|
|
22
|
+
*/
|
|
9
23
|
export function shouldWarnCavemanJaConflict(
|
|
10
24
|
cavemanEnabled: boolean,
|
|
11
25
|
language: string | undefined,
|
|
@@ -13,6 +27,12 @@ export function shouldWarnCavemanJaConflict(
|
|
|
13
27
|
return cavemanEnabled && language?.trim().toLowerCase() === "ja";
|
|
14
28
|
}
|
|
15
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Write `.pi/gentle-ai/persona.json` from resolved defaults when absent.
|
|
32
|
+
*
|
|
33
|
+
* @param cwd - project root
|
|
34
|
+
* @param defaults - resolved defaults containing persona mode
|
|
35
|
+
*/
|
|
16
36
|
export function seedPersonaFromDefaults(cwd: string, defaults: ResolvedDefaults): void {
|
|
17
37
|
const path = join(cwd, ".pi", "gentle-ai", "persona.json");
|
|
18
38
|
if (existsSync(path)) {
|
|
@@ -22,6 +42,12 @@ export function seedPersonaFromDefaults(cwd: string, defaults: ResolvedDefaults)
|
|
|
22
42
|
writeFileSync(path, `${JSON.stringify({ mode: defaults.persona }, null, 2)}\n`, "utf8");
|
|
23
43
|
}
|
|
24
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Register handlers that inject the configured language into system prompts.
|
|
47
|
+
*
|
|
48
|
+
* @param pi - Pi extension API
|
|
49
|
+
* @param defaults - resolved defaults
|
|
50
|
+
*/
|
|
25
51
|
export function registerDefaultsHandlers(pi: ExtensionAPI, defaults: ResolvedDefaults): void {
|
|
26
52
|
if (!defaults.language) {
|
|
27
53
|
return;
|
|
@@ -32,23 +32,32 @@ async function registerOne(pi: ExtensionAPI, id: BundledExtensionId): Promise<vo
|
|
|
32
32
|
await (mod.default as ExtensionFactory)(pi);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Options controlling how bundled extensions are registered. */
|
|
35
36
|
export type RegisterBundledExtensionsOptions = {
|
|
36
37
|
cwd?: string;
|
|
37
38
|
/** Precomputed skips (tests); defaults to scanning Pi settings. */
|
|
38
39
|
globalSkips?: GlobalBundledExtensionSkip[];
|
|
40
|
+
/** When false, only global Pi settings are scanned (trust-safe startup). */
|
|
41
|
+
includeProjectSettings?: boolean;
|
|
39
42
|
};
|
|
40
43
|
|
|
44
|
+
/** Result of bundled-extension registration, including skipped ids. */
|
|
41
45
|
export type RegisterBundledExtensionsResult = {
|
|
42
46
|
globalSkips: GlobalBundledExtensionSkip[];
|
|
43
47
|
};
|
|
44
48
|
|
|
49
|
+
/** Register all enabled bundled extensions with the Pi extension API. */
|
|
45
50
|
export async function registerBundledExtensions(
|
|
46
51
|
pi: ExtensionAPI,
|
|
47
52
|
enabled: Record<BundledExtensionId, boolean>,
|
|
48
53
|
options: RegisterBundledExtensionsOptions = {},
|
|
49
54
|
): Promise<RegisterBundledExtensionsResult> {
|
|
50
55
|
const globalSkips =
|
|
51
|
-
options.globalSkips ??
|
|
56
|
+
options.globalSkips ??
|
|
57
|
+
detectGlobalBundledExtensionSkips({
|
|
58
|
+
cwd: options.cwd,
|
|
59
|
+
includeProjectSettings: options.includeProjectSettings ?? true,
|
|
60
|
+
});
|
|
52
61
|
const skipById = new Map(globalSkips.map((skip) => [skip.id, skip] as const));
|
|
53
62
|
|
|
54
63
|
const enabledIds = new Set<BundledExtensionId>();
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect bundled extensions that the user already installed globally or locally.
|
|
3
|
+
*
|
|
4
|
+
* Skips registering those extensions a second time through hotmilk.
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import fs from "node:fs";
|
|
2
8
|
import os from "node:os";
|
|
3
9
|
import path from "node:path";
|
|
@@ -9,14 +15,20 @@ import { BUNDLED_EXTENSION_IDS, type BundledExtensionId } from "../config/hotmil
|
|
|
9
15
|
|
|
10
16
|
const PI_PROJECT_CONFIG_DIR = ".pi";
|
|
11
17
|
|
|
18
|
+
/** A bundled extension that should be skipped because it is already installed. */
|
|
12
19
|
export type GlobalBundledExtensionSkip = {
|
|
13
20
|
id: BundledExtensionId;
|
|
14
21
|
packageName: string;
|
|
15
22
|
};
|
|
16
23
|
|
|
24
|
+
/** Options for reading Pi settings files. */
|
|
17
25
|
export type CollectGlobalExtensionSourcesOptions = {
|
|
26
|
+
/** Project root for resolving local package paths. */
|
|
18
27
|
cwd?: string;
|
|
28
|
+
/** Override `$HOME` resolution. */
|
|
19
29
|
homedir?: string;
|
|
30
|
+
/** When false, skip project `.pi/settings.json` (Pi project trust gate). */
|
|
31
|
+
includeProjectSettings?: boolean;
|
|
20
32
|
};
|
|
21
33
|
|
|
22
34
|
function isLocalPackageEntry(entry: string): boolean {
|
|
@@ -62,7 +74,14 @@ function resolvePackageNameFromLocalPath(entry: string, baseDir: string): string
|
|
|
62
74
|
}
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Extract the package name from a Pi `npm:` settings entry.
|
|
79
|
+
*
|
|
80
|
+
* @example `npm:@scope/pkg@1.2.3` → `@scope/pkg`
|
|
81
|
+
* @example `npm:pkg` → `pkg`
|
|
82
|
+
*
|
|
83
|
+
* @param entry - raw settings entry
|
|
84
|
+
*/
|
|
66
85
|
export function parseNpmPackageName(entry: string): string | null {
|
|
67
86
|
const trimmed = entry.trim();
|
|
68
87
|
if (!trimmed.startsWith("npm:")) {
|
|
@@ -99,12 +118,20 @@ function readSettingsEntries(settingsPath: string): string[] {
|
|
|
99
118
|
}
|
|
100
119
|
}
|
|
101
120
|
|
|
102
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Collect npm package names referenced by Pi settings (global + project).
|
|
123
|
+
*
|
|
124
|
+
* Resolves local package paths to their `package.json` `name`.
|
|
125
|
+
*
|
|
126
|
+
* @param options - settings search options
|
|
127
|
+
* @returns set of installed package names, excluding hotmilk itself
|
|
128
|
+
*/
|
|
103
129
|
export function collectInstalledPackageNamesFromPiSettings(
|
|
104
130
|
options: CollectGlobalExtensionSourcesOptions = {},
|
|
105
131
|
): Set<string> {
|
|
106
132
|
const home = options.homedir ?? process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
|
|
107
133
|
const cwd = options.cwd ?? process.cwd();
|
|
134
|
+
const includeProjectSettings = options.includeProjectSettings ?? true;
|
|
108
135
|
const names = new Set<string>();
|
|
109
136
|
|
|
110
137
|
const sources: Array<{ settingsPath: string; baseDir: string }> = [
|
|
@@ -112,9 +139,15 @@ export function collectInstalledPackageNamesFromPiSettings(
|
|
|
112
139
|
settingsPath: path.join(home, ".pi", "agent", "settings.json"),
|
|
113
140
|
baseDir: path.join(home, ".pi", "agent"),
|
|
114
141
|
},
|
|
115
|
-
{ settingsPath: path.join(cwd, PI_PROJECT_CONFIG_DIR, "settings.json"), baseDir: cwd },
|
|
116
142
|
];
|
|
117
143
|
|
|
144
|
+
if (includeProjectSettings) {
|
|
145
|
+
sources.push({
|
|
146
|
+
settingsPath: path.join(cwd, PI_PROJECT_CONFIG_DIR, "settings.json"),
|
|
147
|
+
baseDir: cwd,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
118
151
|
for (const { settingsPath, baseDir } of sources) {
|
|
119
152
|
for (const entry of readSettingsEntries(settingsPath)) {
|
|
120
153
|
const npmName = parseNpmPackageName(entry);
|
|
@@ -136,7 +169,13 @@ export function collectInstalledPackageNamesFromPiSettings(
|
|
|
136
169
|
return names;
|
|
137
170
|
}
|
|
138
171
|
|
|
139
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Find bundled ids that should not register because Pi settings already
|
|
174
|
+
* provide the same package.
|
|
175
|
+
*
|
|
176
|
+
* @param options - settings search options
|
|
177
|
+
* @returns list of bundled ids to skip and the package that replaces them
|
|
178
|
+
*/
|
|
140
179
|
export function detectGlobalBundledExtensionSkips(
|
|
141
180
|
options: CollectGlobalExtensionSourcesOptions = {},
|
|
142
181
|
): GlobalBundledExtensionSkip[] {
|
package/src/bootstrap/graph.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graphify stale-graph warnings.
|
|
3
|
+
*
|
|
4
|
+
* Warns the user at session start when `graphify-out/needs_update` exists.
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import { existsSync } from "node:fs";
|
|
2
8
|
import { join } from "node:path";
|
|
3
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
10
|
import type { ResolvedGraphSettings } from "../config/hotmilk.ts";
|
|
5
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Register the session_start handler that warns about stale graphify output.
|
|
14
|
+
*
|
|
15
|
+
* @param pi - Pi extension API
|
|
16
|
+
* @param settings - resolved graph settings
|
|
17
|
+
*/
|
|
6
18
|
export function registerGraphHandlers(pi: ExtensionAPI, settings: ResolvedGraphSettings): void {
|
|
7
19
|
if (!settings.warnOnStale) {
|
|
8
20
|
return;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ProjectTrustContext,
|
|
4
|
+
ProjectTrustEventResult,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ResolvedProjectTrust } from "../config/hotmilk.ts";
|
|
7
|
+
|
|
8
|
+
const HOTMILK_TRUST_PROMPT = (cwd: string): string =>
|
|
9
|
+
`Trust this project for hotmilk?\n\n${cwd}\n\nEnables: .pi/settings.json, project extensions, .agents/skills, gentle-ai files under .pi/. Decline keeps global hotmilk bundles only.`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the trust decision for a project based on hotmilk project-trust settings.
|
|
13
|
+
*
|
|
14
|
+
* @param settings - resolved project trust settings
|
|
15
|
+
* @param ctx - project trust context
|
|
16
|
+
* @param cwd - current working directory
|
|
17
|
+
* @returns promise resolving to trust decision result
|
|
18
|
+
*/
|
|
19
|
+
export function resolveProjectTrustDecision(
|
|
20
|
+
settings: ResolvedProjectTrust,
|
|
21
|
+
ctx: ProjectTrustContext,
|
|
22
|
+
cwd: string,
|
|
23
|
+
): Promise<ProjectTrustEventResult> {
|
|
24
|
+
switch (settings.mode) {
|
|
25
|
+
case "always":
|
|
26
|
+
return Promise.resolve({ trusted: "yes", remember: settings.remember });
|
|
27
|
+
case "never":
|
|
28
|
+
return Promise.resolve({ trusted: "no", remember: settings.remember });
|
|
29
|
+
case "prompt":
|
|
30
|
+
if (!ctx.hasUI) {
|
|
31
|
+
return Promise.resolve({ trusted: "undecided" });
|
|
32
|
+
}
|
|
33
|
+
return ctx.ui.confirm(HOTMILK_TRUST_PROMPT(cwd), "Trust project").then((trusted) => ({
|
|
34
|
+
trusted: trusted ? "yes" : "no",
|
|
35
|
+
remember: trusted ? settings.remember : false,
|
|
36
|
+
}));
|
|
37
|
+
default:
|
|
38
|
+
return Promise.resolve({ trusted: "undecided" });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register the Pi project-trust event handler.
|
|
44
|
+
*
|
|
45
|
+
* @param pi - Pi extension API
|
|
46
|
+
* @param settings - resolved project trust settings
|
|
47
|
+
*/
|
|
48
|
+
export function registerProjectTrustHandlers(
|
|
49
|
+
pi: ExtensionAPI,
|
|
50
|
+
settings: ResolvedProjectTrust,
|
|
51
|
+
): void {
|
|
52
|
+
pi.on("project_trust", async (event, ctx): Promise<ProjectTrustEventResult> => {
|
|
53
|
+
return resolveProjectTrustDecision(settings, ctx, event.cwd);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution helpers for bundled extension modules.
|
|
3
|
+
*
|
|
4
|
+
* Bundled deps may be nested under hotmilk or hoisted next to it; these helpers
|
|
5
|
+
* walk the package tree to find them without depending on npm internals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
9
|
import { basename, dirname, join } from "node:path";
|
|
3
10
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -30,7 +37,13 @@ function findHotmilkPackageRoot(fromModuleUrl: string): string {
|
|
|
30
37
|
throw new Error("Cannot locate hotmilk package root");
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Split a bundled module specifier into package name and subpath.
|
|
42
|
+
*
|
|
43
|
+
* Supports scoped packages (`@scope/name/...`) and the `hotmilk/` prefix.
|
|
44
|
+
*
|
|
45
|
+
* @param relativePath - specifier like `pkg/subpath` or `@scope/pkg/subpath`
|
|
46
|
+
*/
|
|
34
47
|
export function parseBundledModulePath(relativePath: string): { pkgName: string; subpath: string } {
|
|
35
48
|
if (relativePath.startsWith(HOTMILK_MODULE_PREFIX)) {
|
|
36
49
|
return {
|
|
@@ -50,8 +63,15 @@ export function parseBundledModulePath(relativePath: string): { pkgName: string;
|
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
/**
|
|
53
|
-
* Resolve a bundled dependency file
|
|
54
|
-
*
|
|
66
|
+
* Resolve a bundled dependency file on disk.
|
|
67
|
+
*
|
|
68
|
+
* Handles both npm-nested (`.../hotmilk/node_modules/pkg/...`) and hoisted
|
|
69
|
+
* (`.../node_modules/pkg/...`) layouts.
|
|
70
|
+
*
|
|
71
|
+
* @param relativePath - bundled module specifier
|
|
72
|
+
* @param fromModuleUrl - module URL to start the search from (default: `import.meta.url`)
|
|
73
|
+
* @returns absolute path to the resolved file
|
|
74
|
+
* @throws when the module cannot be located
|
|
55
75
|
*/
|
|
56
76
|
export function resolveBundledModule(
|
|
57
77
|
relativePath: string,
|
|
@@ -100,7 +120,11 @@ export function resolveBundledModule(
|
|
|
100
120
|
throw new Error(`Cannot resolve bundled module "${relativePath}" from hotmilk`);
|
|
101
121
|
}
|
|
102
122
|
|
|
103
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Build a `file://` URL for a bundled module suitable for dynamic `import()`.
|
|
125
|
+
*
|
|
126
|
+
* @param relativePath - bundled module specifier
|
|
127
|
+
*/
|
|
104
128
|
export function bundledImportUrl(relativePath: string): string {
|
|
105
129
|
return pathToFileURL(resolveBundledModule(relativePath)).href;
|
|
106
130
|
}
|
package/src/bootstrap/session.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { applyContextStackOnSessionStart } from "./context-stack.ts";
|
|
3
|
+
import { detectGlobalBundledExtensionSkips } from "./global-extension-sources.ts";
|
|
3
4
|
import { seedAgentMcpJsonIfMissing } from "../config/mcp.ts";
|
|
4
5
|
import { AGENT_HOTMILK_CONFIG_LABEL, seedHotmilkConfigIfMissing } from "../config/hotmilk.ts";
|
|
5
6
|
import type { HotmilkRuntime } from "../config/runtime.ts";
|
|
@@ -10,12 +11,61 @@ import {
|
|
|
10
11
|
shouldWarnCavemanJaConflict,
|
|
11
12
|
} from "./defaults.ts";
|
|
12
13
|
|
|
14
|
+
/** Message shown when hotmilk config is seeded. */
|
|
13
15
|
const HOTMILK_SEEDED_MESSAGE = `Created ${AGENT_HOTMILK_CONFIG_LABEL} (toggle bundled extensions with /mode, then /reload).`;
|
|
16
|
+
/**
|
|
17
|
+
* Format error message for hotmilk config parse failures.
|
|
18
|
+
*
|
|
19
|
+
* @param path - config file path
|
|
20
|
+
* @param error - error message
|
|
21
|
+
* @returns formatted error message
|
|
22
|
+
*/
|
|
14
23
|
const HOTMILK_PARSE_ERROR_MESSAGE = (path: string, error: string): string =>
|
|
15
24
|
`Failed to parse ${path}: ${error}. Using default extension toggles.`;
|
|
25
|
+
/**
|
|
26
|
+
* Message shown when MCP config is seeded.
|
|
27
|
+
*
|
|
28
|
+
* @param path - seeded MCP config path
|
|
29
|
+
* @returns formatted message
|
|
30
|
+
*/
|
|
16
31
|
const MCP_SEEDED_MESSAGE = (path: string): string =>
|
|
17
32
|
`Created ${path} from hotmilk MCP template (add servers for pi-mcp-adapter; context-mode uses the extension bridge).`;
|
|
18
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Format message for project trust skips.
|
|
36
|
+
*
|
|
37
|
+
* @param skips - global extension skips
|
|
38
|
+
* @returns formatted message or undefined if no skips
|
|
39
|
+
*/
|
|
40
|
+
function formatProjectTrustSkipMessage(
|
|
41
|
+
skips: HotmilkRuntime["globalExtensionSkips"],
|
|
42
|
+
): string | undefined {
|
|
43
|
+
if (skips.length === 0) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const rows = skips.map((skip) => `${skip.id}: project ${skip.packageName}`).join("\n");
|
|
47
|
+
return `Project settings also provide bundled packages (run /reload to dedupe):\n${rows}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detect bundled extensions that are provided only by project settings.
|
|
52
|
+
*
|
|
53
|
+
* @param cwd - current working directory
|
|
54
|
+
* @returns project-only bundled extension skips
|
|
55
|
+
*/
|
|
56
|
+
function detectProjectOnlyBundledSkips(cwd: string): HotmilkRuntime["globalExtensionSkips"] {
|
|
57
|
+
const globalOnly = detectGlobalBundledExtensionSkips({ cwd, includeProjectSettings: false });
|
|
58
|
+
const withProject = detectGlobalBundledExtensionSkips({ cwd, includeProjectSettings: true });
|
|
59
|
+
const globalIds = new Set(globalOnly.map((skip) => skip.id));
|
|
60
|
+
return withProject.filter((skip) => !globalIds.has(skip.id));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format message for global extension skips.
|
|
65
|
+
*
|
|
66
|
+
* @param skips - global extension skips
|
|
67
|
+
* @returns formatted message or undefined if no skips
|
|
68
|
+
*/
|
|
19
69
|
function formatGlobalExtensionSkipsMessage(
|
|
20
70
|
skips: HotmilkRuntime["globalExtensionSkips"],
|
|
21
71
|
): string | undefined {
|
|
@@ -26,6 +76,7 @@ function formatGlobalExtensionSkipsMessage(
|
|
|
26
76
|
return `Bundled extensions skipped (Pi settings already provide the package):\n${rows}`;
|
|
27
77
|
}
|
|
28
78
|
|
|
79
|
+
/** Register hotmilk session-start handlers (seed config, footer, context stack, MCP). */
|
|
29
80
|
export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntime): void {
|
|
30
81
|
const termProgram = process.env.TERM_PROGRAM ?? "none";
|
|
31
82
|
|
|
@@ -43,7 +94,7 @@ export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntim
|
|
|
43
94
|
uiNotify(HOTMILK_PARSE_ERROR_MESSAGE(runtime.configPath, runtime.configError), "warning");
|
|
44
95
|
}
|
|
45
96
|
|
|
46
|
-
if (runtime.extensionToggles["gentle-ai"]) {
|
|
97
|
+
if (runtime.extensionToggles["gentle-ai"] && ctx.isProjectTrusted()) {
|
|
47
98
|
seedPersonaFromDefaults(ctx.cwd, runtime.defaults);
|
|
48
99
|
}
|
|
49
100
|
|
|
@@ -54,6 +105,15 @@ export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntim
|
|
|
54
105
|
uiNotify(globalSkipMessage, "info");
|
|
55
106
|
}
|
|
56
107
|
|
|
108
|
+
if (ctx.isProjectTrusted()) {
|
|
109
|
+
const projectSkipMessage = formatProjectTrustSkipMessage(
|
|
110
|
+
detectProjectOnlyBundledSkips(ctx.cwd),
|
|
111
|
+
);
|
|
112
|
+
if (projectSkipMessage) {
|
|
113
|
+
uiNotify(projectSkipMessage, "warning");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
57
117
|
if (shouldWarnCavemanJaConflict(runtime.extensionToggles.caveman, runtime.defaults.language)) {
|
|
58
118
|
uiNotify(CAVEMAN_JA_CONFLICT_MESSAGE, "warning");
|
|
59
119
|
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct `/subagents-doctor` command registration.
|
|
3
|
+
*
|
|
4
|
+
* Bypasses the upstream slash-event bridge so the doctor report works even
|
|
5
|
+
* when `state.lastUiContext` is unset after `/reload`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import * as fs from "node:fs";
|
|
2
9
|
import * as os from "node:os";
|
|
3
10
|
import * as path from "node:path";
|
|
@@ -77,6 +84,11 @@ function buildDirectDoctorReport(
|
|
|
77
84
|
* reads `state.lastUiContext` instead of the command handler's `ctx`. After `/reload`
|
|
78
85
|
* that context can be unset while the command still runs — override with a direct report.
|
|
79
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* Register the `/subagents-doctor` Pi command with a direct report builder.
|
|
89
|
+
*
|
|
90
|
+
* @param pi - Pi extension API
|
|
91
|
+
*/
|
|
80
92
|
export async function registerSubagentsDoctorCommand(pi: ExtensionAPI): Promise<void> {
|
|
81
93
|
await ensureChainRunsDir();
|
|
82
94
|
|