hotmilk 0.1.11 → 0.1.12

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/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) (side channel — see below), [@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
 
@@ -91,7 +91,7 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
91
91
  "context-mode": true,
92
92
  "ask-user": true,
93
93
  "graphify": true,
94
- "subagents": false,
94
+ "subagents": true,
95
95
  "goal": true,
96
96
  "docparser": true,
97
97
  "obsidian": true,
@@ -122,23 +122,48 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
122
122
  }
123
123
  ```
124
124
 
125
- | Key / area | Behavior |
126
- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
- | `extensions.*` | Set to `false` to skip registering that bundled extension |
128
- | `extensions.subagents` | Default `false`. When `true`, imports pi-subagents (~10s). Use with `gentle-ai` for delegation |
129
- | `extensions.context-mode` | Default `true`. Prefer `ctx_*` for large outputs (see project context-window rules) |
130
- | `extensions.rtk-optimizer` | Default `false`. Bash/read/grep output compaction; enable with `context-mode` for leftover shell output. Install [`rtk` CLI](https://github.com/rtk-ai/rtk) for command rewrite (`/rtk verify`) |
131
- | `extensions.goal` `mcp-adapter` | Integration / perf extensions (formerly always loaded via `pi.extensions`; now toggled like other bundled deps) |
132
- | Enabled extensions | Loaded **in parallel** on session start (faster than sequential import when many toggles are on) |
133
- | `graph.warnOnStale` | Notify when `graphify-out/needs_update` exists |
134
- | `graph.autoSuggestUpdate` | Append `graphify update .` to that notification |
135
- | `defaults.persona` | Seeds `.pi/gentle-ai/persona.json` when missing (`gentleman` \| `neutral`) |
136
- | `defaults.language` | Appends a project language hint to the system prompt each turn |
137
- | `mcp.seedOnStart` | Copy `mcp.json` template into `~/.pi/agent/mcp.json` when missing (empty template; for pi-mcp-adapter) |
138
- | `extensions.mcp-adapter` | Default `false`. Enable only when you want MCP servers from `~/.pi/agent/mcp.json` (do not duplicate context-mode) |
125
+ | Key / area | Behavior |
126
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
127
+ | `extensions.*` | Set to `false` to skip registering that bundled extension |
128
+ | `extensions.subagents` | Default `true`. Imports pi-subagents (~10s). Use with `gentle-ai` for delegation; set `false` for faster startup without Task tools |
129
+ | `extensions.btw` | Default `true`. Side conversation via `/btw` while main runs. **Delegate implementation to subagents**; use BTW for quick human questions. See [pi-btw coexistence](#pi-btw-with-subagents-default-on) |
130
+ | `extensions.context-mode` | Default `true`. Prefer `ctx_*` for large outputs (see project context-window rules) |
131
+ | `extensions.rtk-optimizer` | Default `false`. Bash/read/grep output compaction; enable with `context-mode` for leftover shell output. Install [`rtk` CLI](https://github.com/rtk-ai/rtk) for command rewrite (`/rtk verify`) |
132
+ | `extensions.goal` `mcp-adapter` | Integration / perf extensions (formerly always loaded via `pi.extensions`; now toggled like other bundled deps) |
133
+ | Enabled extensions | Loaded **in parallel** on session start (faster than sequential import when many toggles are on) |
134
+ | `graph.warnOnStale` | Notify when `graphify-out/needs_update` exists |
135
+ | `graph.autoSuggestUpdate` | Append `graphify update .` to that notification |
136
+ | `defaults.persona` | Seeds `.pi/gentle-ai/persona.json` when missing (`gentleman` \| `neutral`) |
137
+ | `defaults.language` | Appends a project language hint to the system prompt each turn |
138
+ | `mcp.seedOnStart` | Copy `mcp.json` template into `~/.pi/agent/mcp.json` when missing (empty template; for pi-mcp-adapter) |
139
+ | `extensions.mcp-adapter` | Default `false`. Enable only when you want MCP servers from `~/.pi/agent/mcp.json` (do not duplicate context-mode) |
139
140
 
140
141
  **MCP (default):** `context-mode` extension registers `ctx_*` via its built-in bridge (same module as [upstream `.pi/extensions/context-mode`](https://github.com/mksglu/context-mode/tree/main/.pi/extensions/context-mode), loaded from `build/adapters/pi/extension.js`). Hotmilk removes any `context-mode` server from `~/.pi/agent/mcp.json` when the extension is on. Enable `mcp-adapter` only for **other** MCP servers—not a second context-mode entry.
141
142
 
143
+ ### pi-btw with subagents (default on)
144
+
145
+ Both **`subagents`** and **`btw`** default to **on**. They do not share commands or extension IDs; hotmilk loads them in parallel.
146
+
147
+ | Do this | Tool |
148
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------- |
149
+ | Exploration, implementation, review, SDD phases | **subagents** (`Task`, `/run`, `/chain`; use `worktree: true` when running parallel writers) |
150
+ | Ask a quick question while main is working | **`/btw`** or **`/btw:tangent`** (`Alt+/` toggles BTW ↔ main) |
151
+ | Bring BTW results back to the main thread | **`/btw:inject`** |
152
+
153
+ BTW runs a **separate** Pi session. hotmilk wraps upstream pi-btw (`src/extensions/btw.ts`):
154
+
155
+ - **`subagents: true` (default):** read-biased tools only (`read`, `grep`, `find`, `ls`, `bash`) — no main-cwd `edit`/`write`.
156
+ - **`graphify: true` + `graphify-out/graph.json`:** adds **`graphify_query`** (CLI-backed) for architecture questions.
157
+ - **`context-mode: true`:** adds **`ctx_search`** proxy to the main session knowledge base (read-only).
158
+ - Inherited prompts drop main-session harness noise (gentle-ai orchestrator, graphify rules, caveman); project AGENTS.md stays.
159
+ - Still **no** `ctx_execute`, Task, or MCP inside BTW — use main/subagents for heavy ctx work.
160
+
161
+ Global `npm:pi-btw` in Pi settings skips the hotmilk shim (standard dedupe). Prefer bundled hotmilk so BTW gets prompt/tool patches via `createAgentSession` hook.
162
+
163
+ During subagent chains, avoid BTW file edits on the main cwd; use read-only or `:tangent` until workers finish.
164
+
165
+ Set `"btw": false` in `/mode` if you want delegation only with no side channel.
166
+
142
167
  ### Optional extensions (off by default)
143
168
 
144
169
  Enable in `/mode` or set the key to `true` in `hotmilk.json`, then `/reload`.
@@ -160,7 +185,7 @@ Enable in `/mode` or set the key to `true` in `hotmilk.json`, then `/reload`.
160
185
  - Hotmilk seeds dashboard HTTP port **8102** (when config still has upstream default `8000`) to avoid common port conflicts. Custom ports are preserved.
161
186
  - `EADDRINUSE` on `8102` (or `9999` for pi bridge): stop the stale dashboard (`pi-dashboard stop` or `lsof -i :8102` / `:9999`), then restart Pi.
162
187
  - 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.
188
+ - **`ERR TypeScript loader` / jiti or tsx not found** (paths under `~/.pi-dashboard/node_modules/`): the dashboard doctor checks the legacy managed install dir and hardcodes port **8000** in its messages. Hotmilk runs on **8102** with bundled jiti from `pi-dashboard-server`. If `http://localhost:8102/api/health` returns `{"ok":true}`, the doctor error is a false positive hotmilk logs a hint on warm-start. To silence it entirely: run **Help → Setup** once, or `npm install jiti` under `~/.pi-dashboard` after setup creates that tree.
164
189
 
165
190
  ### Cursor models (optional, not bundled)
166
191
 
package/hotmilk.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "context-mode": true,
7
7
  "ask-user": true,
8
8
  "graphify": true,
9
- "subagents": false,
9
+ "subagents": true,
10
10
  "goal": true,
11
11
  "docparser": true,
12
12
  "obsidian": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotmilk",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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",
@@ -96,7 +96,6 @@
96
96
  "node_modules/pi-subagents/skills",
97
97
  "node_modules/pi-docparser/skills",
98
98
  "node_modules/@haispeed/pi-obsidian/skills",
99
- "node_modules/pi-btw/skills",
100
99
  "node_modules/pi-ask-user/skills",
101
100
  "node_modules/pi-web-access/skills",
102
101
  "node_modules/@tomxprime/planning-with-files"
@@ -0,0 +1,395 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Type } from "@sinclair/typebox";
5
+ import * as piSdk from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ createExtensionRuntime,
8
+ defineTool,
9
+ type AgentToolResult,
10
+ type CreateAgentSessionOptions,
11
+ type ExtensionAPI,
12
+ type ResourceLoader,
13
+ type ToolDefinition,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type { BundledExtensionId } from "../config/bundled-extensions.ts";
16
+ import { bundledImportUrl } from "./resolve-bundled.ts";
17
+
18
+ // --- Session config (injected before btw extension loads) ---
19
+
20
+ export type HotmilkBtwConfig = {
21
+ extensionToggles: Readonly<Record<BundledExtensionId, boolean>>;
22
+ };
23
+
24
+ let activeConfig: HotmilkBtwConfig | null = null;
25
+
26
+ export function setHotmilkBtwConfig(config: HotmilkBtwConfig): void {
27
+ activeConfig = config;
28
+ }
29
+
30
+ export function getHotmilkBtwConfig(): HotmilkBtwConfig {
31
+ if (!activeConfig) {
32
+ throw new Error(
33
+ "hotmilk BTW config not initialized — registerBundledExtensions must call setHotmilkBtwConfig first",
34
+ );
35
+ }
36
+ return activeConfig;
37
+ }
38
+
39
+ export function resetHotmilkBtwConfigForTests(): void {
40
+ activeConfig = null;
41
+ }
42
+
43
+ // --- Prompt shaping ---
44
+
45
+ export function stripDynamicSystemPromptFooter(systemPrompt: string): string {
46
+ return systemPrompt
47
+ .replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?$/u, "")
48
+ .replace(/\nCurrent working directory:[^\n]*$/u, "")
49
+ .trim();
50
+ }
51
+
52
+ const MAIN_SESSION_SECTION_MARKERS = [
53
+ /^## graphify\b/im,
54
+ /^## el Gentleman Orchestrator\b/im,
55
+ /^# el Gentleman Identity and Harness\b/im,
56
+ /^<context_window_protection>/im,
57
+ /^IMPORTANT: You are in CAVEMAN MODE\./im,
58
+ /^## SDD Session Preflight\b/im,
59
+ /^<behavioral_directive>/im,
60
+ ] as const;
61
+
62
+ export function stripHotmilkMainSessionHarness(systemPrompt: string): string {
63
+ let next = systemPrompt;
64
+ for (const marker of MAIN_SESSION_SECTION_MARKERS) {
65
+ const match = marker.exec(next);
66
+ if (!match || match.index === undefined) continue;
67
+ next = next.slice(0, match.index).trimEnd();
68
+ }
69
+ return next.trim();
70
+ }
71
+
72
+ export const HOTMILK_BTW_SYSTEM_PROMPT = [
73
+ "You are having an aside conversation with the user, separate from their main working session.",
74
+ "If main session messages are provided, they are for context only — that work is being handled by another agent.",
75
+ "If no main session messages are provided, treat this as a fully contextless tangent thread and rely only on the user's words plus your general instructions.",
76
+ "Focus on answering the user's side questions, helping them think through ideas, or planning next steps.",
77
+ "Do not act as if you need to continue unfinished work from the main session unless the user explicitly asks you to prepare something for injection back to it.",
78
+ "hotmilk routing: multi-file implementation, scout/worker/review, and SDD phases belong on the main line via subagents (Task, /run, /chain) — not in BTW.",
79
+ "While subagents may be writing in worktrees, avoid edit/write on the main cwd unless the user explicitly asks; prefer read-only checks and :tangent for brainstorming.",
80
+ "ctx_search is available as a read-only proxy to the main session knowledge base — use it for timeline recall, prior decisions, and indexed content. Do not use ctx_execute, ctx_batch_execute, or ctx_purge from BTW; loading context-mode here would spawn a second MCP child.",
81
+ ].join(" ");
82
+
83
+ export function buildHotmilkBtwAppendPrompt(options: {
84
+ graphifyEnabled: boolean;
85
+ subagentsEnabled: boolean;
86
+ contextModeEnabled: boolean;
87
+ }): string[] {
88
+ const lines = [HOTMILK_BTW_SYSTEM_PROMPT];
89
+ if (options.contextModeEnabled) {
90
+ lines.push(
91
+ "Prefer ctx_search(queries: [...]) for session memory and indexed corpus recall instead of large bash output or re-reading raw files.",
92
+ );
93
+ }
94
+ if (options.graphifyEnabled) {
95
+ lines.push(
96
+ 'When graphify-out/graph.json exists, prefer the graphify_query tool (or `graphify query "..."` via bash) over broad grep for architecture and cross-module questions.',
97
+ );
98
+ }
99
+ if (options.subagentsEnabled) {
100
+ lines.push(
101
+ "When the user asks for implementation or multi-file exploration, answer briefly in BTW if possible, then suggest /btw:inject plus a main-line Task — do not start parallel file edits here.",
102
+ );
103
+ }
104
+ return lines;
105
+ }
106
+
107
+ const PI_BTW_APPEND_MARKERS = [
108
+ "aside conversation with the user, separate from their main working session",
109
+ "Summarize the side conversation concisely",
110
+ ] as const;
111
+
112
+ /** BTW must never load bundled extensions (context-mode → MCP fork-bomb #516). */
113
+ function createBtwEmptyExtensionsResult() {
114
+ return { extensions: [], errors: [], runtime: createExtensionRuntime() };
115
+ }
116
+
117
+ function isPiBtwResourceLoader(loader: ResourceLoader): boolean {
118
+ const append = loader.getAppendSystemPrompt();
119
+ const text = (Array.isArray(append) ? append : []).join("\n");
120
+ return PI_BTW_APPEND_MARKERS.some((marker) => text.includes(marker));
121
+ }
122
+
123
+ function isBtwSummarizeSession(loader: ResourceLoader): boolean {
124
+ const append = loader.getAppendSystemPrompt();
125
+ const text = (Array.isArray(append) ? append : []).join("\n");
126
+ return text.includes("Summarize the side conversation");
127
+ }
128
+
129
+ export function adaptBtwResourceLoaderForHotmilk(
130
+ loader: ResourceLoader,
131
+ config: HotmilkBtwConfig,
132
+ ): ResourceLoader {
133
+ const emptyExtensions = createBtwEmptyExtensionsResult();
134
+ const sharedLoader = {
135
+ getExtensions: () => emptyExtensions,
136
+ getSkills: () => ({ skills: [], diagnostics: [] }),
137
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
138
+ getThemes: () => ({ themes: [], diagnostics: [] }),
139
+ getAgentsFiles: () => ({ agentsFiles: [] }),
140
+ extendResources: () => {},
141
+ reload: async () => {},
142
+ };
143
+
144
+ if (isBtwSummarizeSession(loader)) {
145
+ return {
146
+ ...sharedLoader,
147
+ getSystemPrompt: () => stripHotmilkMainSessionHarness(loader.getSystemPrompt() ?? ""),
148
+ getAppendSystemPrompt: () => loader.getAppendSystemPrompt(),
149
+ };
150
+ }
151
+
152
+ return {
153
+ ...sharedLoader,
154
+ getSystemPrompt: () => stripHotmilkMainSessionHarness(loader.getSystemPrompt() ?? ""),
155
+ getAppendSystemPrompt: () =>
156
+ buildHotmilkBtwAppendPrompt({
157
+ graphifyEnabled: config.extensionToggles.graphify === true,
158
+ subagentsEnabled: config.extensionToggles.subagents === true,
159
+ contextModeEnabled: config.extensionToggles["context-mode"] === true,
160
+ }),
161
+ };
162
+ }
163
+
164
+ // --- Main-session ctx_search capture (proxy target for BTW) ---
165
+
166
+ type MainCtxSearchTool = {
167
+ description: string;
168
+ parameters: ToolDefinition["parameters"];
169
+ execute: (
170
+ toolCallId: string,
171
+ params: Record<string, unknown>,
172
+ ) => Promise<AgentToolResult<unknown>>;
173
+ };
174
+
175
+ let mainCtxSearchTool: MainCtxSearchTool | null = null;
176
+ let ctxSearchCaptureInstalled = false;
177
+
178
+ export function captureMainCtxSearchTool(tool: {
179
+ name: string;
180
+ description: string;
181
+ parameters: ToolDefinition["parameters"];
182
+ execute: ToolDefinition["execute"];
183
+ }): void {
184
+ if (tool.name !== "ctx_search") return;
185
+ mainCtxSearchTool = {
186
+ description: tool.description,
187
+ parameters: tool.parameters,
188
+ execute: (toolCallId, params) =>
189
+ tool.execute(toolCallId, params as never, undefined, undefined, undefined as never),
190
+ };
191
+ }
192
+
193
+ export function getMainCtxSearchToolForTests(): MainCtxSearchTool | null {
194
+ return mainCtxSearchTool;
195
+ }
196
+
197
+ export function resetMainCtxSearchCaptureForTests(): void {
198
+ mainCtxSearchTool = null;
199
+ ctxSearchCaptureInstalled = false;
200
+ }
201
+
202
+ async function resolveMainCtxSearchTool(): Promise<MainCtxSearchTool | null> {
203
+ if (mainCtxSearchTool) return mainCtxSearchTool;
204
+ try {
205
+ const mod = await import(bundledImportUrl("context-mode/build/adapters/pi/extension.js"));
206
+ await mod._mcpBridgeReady;
207
+ } catch {
208
+ return null;
209
+ }
210
+ return mainCtxSearchTool;
211
+ }
212
+
213
+ /**
214
+ * Capture context-mode's main-session ctx_search registration so BTW can proxy
215
+ * without loading context-mode inside the BTW sub-session (fork-bomb guard #516).
216
+ */
217
+ export function installHotmilkCtxSearchCapture(pi: ExtensionAPI): void {
218
+ if (ctxSearchCaptureInstalled) return;
219
+ ctxSearchCaptureInstalled = true;
220
+
221
+ const original = pi.registerTool.bind(pi);
222
+ pi.registerTool = (tool) => {
223
+ captureMainCtxSearchTool(tool);
224
+ return original(tool);
225
+ };
226
+ }
227
+
228
+ // --- Tools ---
229
+
230
+ const GRAPH_JSON = join("graphify-out", "graph.json");
231
+
232
+ const CTX_SEARCH_FALLBACK_PARAMS = Type.Object({
233
+ queries: Type.Optional(
234
+ Type.Array(
235
+ Type.String({ description: "Search queries — batch all related questions in one call." }),
236
+ ),
237
+ ),
238
+ query: Type.Optional(
239
+ Type.String({ description: "Single search query (legacy alias for queries[0])." }),
240
+ ),
241
+ limit: Type.Optional(Type.Number({ description: "Results per query (default 3)." })),
242
+ source: Type.Optional(
243
+ Type.String({ description: "Filter to an indexed source label (partial match)." }),
244
+ ),
245
+ contentType: Type.Optional(
246
+ Type.Union([Type.Literal("code"), Type.Literal("prose")], {
247
+ description: "Filter by content shape.",
248
+ }),
249
+ ),
250
+ sort: Type.Optional(
251
+ Type.Union([Type.Literal("relevance"), Type.Literal("timeline")], {
252
+ description: "relevance (default) or timeline for session memory.",
253
+ }),
254
+ ),
255
+ });
256
+
257
+ export function graphifyGraphExists(cwd = process.cwd()): boolean {
258
+ return existsSync(join(cwd, GRAPH_JSON));
259
+ }
260
+
261
+ export function resolveHotmilkBtwTools(config: HotmilkBtwConfig): string[] {
262
+ if (config.extensionToggles.subagents === true) {
263
+ return ["read", "grep", "find", "ls", "bash"];
264
+ }
265
+ return ["read", "bash", "edit", "write"];
266
+ }
267
+
268
+ function createCtxSearchProxyTool(mainTool: MainCtxSearchTool | null): ToolDefinition {
269
+ return defineTool({
270
+ name: "ctx_search",
271
+ label: "Search indexed content",
272
+ description:
273
+ mainTool?.description ??
274
+ "Search the main session knowledge base (read-only proxy). Batch related questions in queries: [...].",
275
+ parameters: mainTool?.parameters ?? CTX_SEARCH_FALLBACK_PARAMS,
276
+ execute: async (toolCallId, params) => {
277
+ const main = await resolveMainCtxSearchTool();
278
+ if (!main) {
279
+ return {
280
+ content: [
281
+ {
282
+ type: "text",
283
+ text:
284
+ "ctx_search unavailable — context-mode bridge is not ready on the main session. " +
285
+ "Ensure context-mode is enabled, wait for startup, or use small bounded bash output.",
286
+ },
287
+ ],
288
+ details: { ok: false as const },
289
+ };
290
+ }
291
+ return main.execute(toolCallId, params as Record<string, unknown>);
292
+ },
293
+ });
294
+ }
295
+
296
+ export function createHotmilkBtwCustomTools(config: HotmilkBtwConfig): ToolDefinition[] {
297
+ const tools: ToolDefinition[] = [];
298
+
299
+ if (config.extensionToggles["context-mode"] === true) {
300
+ tools.push(createCtxSearchProxyTool(mainCtxSearchTool));
301
+ }
302
+
303
+ if (config.extensionToggles.graphify !== true || !graphifyGraphExists()) {
304
+ return tools;
305
+ }
306
+
307
+ const graphifyQuery = defineTool({
308
+ name: "graphify_query",
309
+ label: "Graphify query",
310
+ description:
311
+ "Traverse graphify-out/graph.json for architecture and relationship questions. Prefer this over grep for cross-module queries.",
312
+ parameters: Type.Object({
313
+ question: Type.String({ description: "Natural-language question about the codebase graph" }),
314
+ dfs: Type.Optional(
315
+ Type.Boolean({ description: "Use depth-first traversal for dependency chains" }),
316
+ ),
317
+ budget: Type.Optional(
318
+ Type.Number({ description: "Optional token budget cap for the graphify answer" }),
319
+ ),
320
+ }),
321
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
322
+ const args = ["query", params.question, "--graph", GRAPH_JSON];
323
+ if (params.dfs) args.push("--dfs");
324
+ if (params.budget !== undefined) args.push("--budget", String(params.budget));
325
+
326
+ try {
327
+ const output = execFileSync("graphify", args, {
328
+ cwd: ctx.cwd,
329
+ encoding: "utf8",
330
+ maxBuffer: 512 * 1024,
331
+ timeout: 120_000,
332
+ });
333
+ return {
334
+ content: [{ type: "text", text: output.trim() || "(graphify returned no output)" }],
335
+ details: { question: params.question, ok: true as const },
336
+ };
337
+ } catch (error) {
338
+ const message = error instanceof Error ? error.message : String(error);
339
+ return {
340
+ content: [
341
+ {
342
+ type: "text",
343
+ text: `graphify query failed: ${message}. Ensure graphify-out/graph.json exists or run graphify on the main session.`,
344
+ },
345
+ ],
346
+ details: { question: params.question, ok: false as const, error: message },
347
+ };
348
+ }
349
+ },
350
+ });
351
+
352
+ tools.push(graphifyQuery);
353
+ return tools;
354
+ }
355
+
356
+ function patchHotmilkBtwSessionOptions(
357
+ options: CreateAgentSessionOptions,
358
+ ): CreateAgentSessionOptions {
359
+ const loader = options.resourceLoader;
360
+ if (!loader || !isPiBtwResourceLoader(loader)) {
361
+ return options;
362
+ }
363
+
364
+ const config = getHotmilkBtwConfig();
365
+ const summarize = isBtwSummarizeSession(loader);
366
+ const next: CreateAgentSessionOptions = {
367
+ ...options,
368
+ resourceLoader: adaptBtwResourceLoaderForHotmilk(loader, config),
369
+ };
370
+
371
+ if (!summarize) {
372
+ next.tools = resolveHotmilkBtwTools(config);
373
+ next.customTools = createHotmilkBtwCustomTools(config);
374
+ }
375
+
376
+ return next;
377
+ }
378
+
379
+ let btwHookInstalled = false;
380
+
381
+ /**
382
+ * Wrap Pi {@link createAgentSession} so pi-btw sub-sessions get hotmilk prompts/tools
383
+ * without vendoring the upstream extension (~2k lines).
384
+ */
385
+ export function installHotmilkBtwSessionHook(): void {
386
+ if (btwHookInstalled) return;
387
+ btwHookInstalled = true;
388
+
389
+ const original = piSdk.createAgentSession.bind(piSdk);
390
+ const patched = async (options?: CreateAgentSessionOptions) => {
391
+ const resolved = options ? patchHotmilkBtwSessionOptions(options) : options;
392
+ return original(resolved);
393
+ };
394
+ (piSdk as { createAgentSession: typeof patched }).createAgentSession = patched;
395
+ }
@@ -99,17 +99,57 @@ export function persistDashboardPiPort(piPort: number): boolean {
99
99
 
100
100
  export type DashboardWarmStartDecision = "skip-running" | "skip-conflict" | "launch";
101
101
 
102
+ /** When TCP is bound but /api/health is not ready yet (cold boot). */
103
+ const WARM_START_PORT_OCCUPIED_WAIT_MS = 5_000;
104
+ const WARM_START_PORT_OCCUPIED_POLL_MS = 500;
105
+
106
+ export type DashboardWarmStartDecisionOpts = {
107
+ occupiedWaitMs?: number;
108
+ occupiedPollMs?: number;
109
+ };
110
+
111
+ function sleep(ms: number): Promise<void> {
112
+ return new Promise((resolve) => setTimeout(resolve, ms));
113
+ }
114
+
102
115
  export async function resolveDashboardWarmStartDecision(
103
116
  port: number,
104
117
  probe: typeof isDashboardRunning = isDashboardRunning,
118
+ isFree: (port: number) => Promise<boolean> = isTcpPortFree,
119
+ opts: DashboardWarmStartDecisionOpts = {},
105
120
  ): Promise<DashboardWarmStartDecision> {
106
- const status = await probe(port, "localhost", WARM_START_PROBE_OPTS);
121
+ const occupiedWaitMs = opts.occupiedWaitMs ?? WARM_START_PORT_OCCUPIED_WAIT_MS;
122
+ const occupiedPollMs = opts.occupiedPollMs ?? WARM_START_PORT_OCCUPIED_POLL_MS;
123
+ let status = await probe(port, "localhost", WARM_START_PROBE_OPTS);
107
124
  if (status.running) {
108
125
  return "skip-running";
109
126
  }
110
127
  if (status.portConflict) {
111
128
  return "skip-conflict";
112
129
  }
130
+
131
+ if (!(await isFree(port))) {
132
+ const deadline = Date.now() + occupiedWaitMs;
133
+ while (Date.now() < deadline) {
134
+ await sleep(occupiedPollMs);
135
+ status = await probe(port, "localhost", WARM_START_PROBE_OPTS);
136
+ if (status.running) {
137
+ return "skip-running";
138
+ }
139
+ if (status.portConflict) {
140
+ return "skip-conflict";
141
+ }
142
+ if (await isFree(port)) {
143
+ return "launch";
144
+ }
145
+ }
146
+ status = await probe(port, "localhost", WARM_START_PROBE_OPTS);
147
+ if (status.running) {
148
+ return "skip-running";
149
+ }
150
+ return "skip-conflict";
151
+ }
152
+
113
153
  return "launch";
114
154
  }
115
155
 
@@ -184,7 +224,13 @@ export function applyHotmilkDashboardDefaults(): ApplyHotmilkDashboardDefaultsRe
184
224
  return { updated: true, path: CONFIG_FILE };
185
225
  }
186
226
 
187
- let warmStartPromise: Promise<DashboardWarmStartResult> | undefined;
227
+ /** Dedupes concurrent warm-start only; each session_start/reload re-probes. */
228
+ let warmStartInFlight: Promise<DashboardWarmStartResult> | undefined;
229
+
230
+ /** @internal Clears in-flight warm-start dedupe (tests). */
231
+ export function resetDashboardWarmStartForTests(): void {
232
+ warmStartInFlight = undefined;
233
+ }
188
234
 
189
235
  export type DashboardWarmStartResult = {
190
236
  status: "running" | "started" | "skipped-conflict" | "failed";
@@ -207,15 +253,14 @@ function warmStartResult(
207
253
  * the bridge extension loads so its 2s auto-start sees a healthy server.
208
254
  */
209
255
  export async function ensureDashboardWarmStarted(): Promise<DashboardWarmStartResult> {
210
- if (warmStartPromise) {
211
- return warmStartPromise;
256
+ if (warmStartInFlight) {
257
+ return warmStartInFlight;
212
258
  }
213
259
 
214
- warmStartPromise = runDashboardWarmStart().catch((err) => {
215
- warmStartPromise = undefined;
216
- throw err;
260
+ warmStartInFlight = runDashboardWarmStart().finally(() => {
261
+ warmStartInFlight = undefined;
217
262
  });
218
- return warmStartPromise;
263
+ return warmStartInFlight;
219
264
  }
220
265
 
221
266
  async function runDashboardWarmStart(): Promise<DashboardWarmStartResult> {
@@ -246,9 +291,12 @@ async function runDashboardWarmStart(): Promise<DashboardWarmStartResult> {
246
291
 
247
292
  const launchConfig = { port: config.port, piPort };
248
293
 
294
+ const cliPath = resolveDashboardServerCliPath();
295
+
249
296
  try {
250
297
  await launchDashboardServer({
251
- cliPath: resolveDashboardServerCliPath(),
298
+ cliPath,
299
+ anchor: cliPath,
252
300
  extraArgs: buildWarmStartLaunchArgs(launchConfig),
253
301
  stdio: { logFile: dashboardServerLogPath() },
254
302
  starter: "Hotmilk",
@@ -269,3 +317,17 @@ async function runDashboardWarmStart(): Promise<DashboardWarmStartResult> {
269
317
  return warmStartResult("failed", port, piPort, `${detail}. See ${dashboardServerLogPath()}`);
270
318
  }
271
319
  }
320
+
321
+ /** Doctor checks ~/.pi-dashboard; hotmilk warm-start uses bundled jiti instead. */
322
+ export function logHotmilkDashboardDoctorHint(result: DashboardWarmStartResult): void {
323
+ if (result.status !== "running" && result.status !== "started") {
324
+ return;
325
+ }
326
+ const managedDir = path.join(os.homedir(), ".pi-dashboard");
327
+ if (fs.existsSync(managedDir)) {
328
+ return;
329
+ }
330
+ console.warn(
331
+ `[hotmilk] Dashboard doctor may warn about TypeScript loader or ~/.pi-dashboard — hotmilk uses bundled jiti on port ${result.port}. Ignore if http://localhost:${result.port}/api/health is ok.`,
332
+ );
333
+ }
@@ -72,11 +72,25 @@ export async function registerBundledExtensions(
72
72
 
73
73
  if (enabledIds.has("agent-dashboard")) {
74
74
  enabledIds.delete("agent-dashboard");
75
- const { ensureDashboardWarmStarted } = await import("./dashboard.ts");
76
- await ensureDashboardWarmStarted();
75
+ const { ensureDashboardWarmStarted, logHotmilkDashboardDoctorHint } =
76
+ await import("./dashboard.ts");
77
+ const warmStart = await ensureDashboardWarmStarted();
78
+ if (warmStart.status === "failed" || warmStart.status === "skipped-conflict") {
79
+ const detail = warmStart.message ?? `port ${warmStart.port}`;
80
+ console.warn(`[hotmilk] Dashboard warm-start ${warmStart.status}: ${detail}`);
81
+ } else {
82
+ logHotmilkDashboardDoctorHint(warmStart);
83
+ }
77
84
  await registerOne(pi, "agent-dashboard");
78
85
  }
79
86
 
87
+ if (enabledIds.has("btw")) {
88
+ const { setHotmilkBtwConfig } = await import("./btw.ts");
89
+ setHotmilkBtwConfig({ extensionToggles: enabled });
90
+ await registerOne(pi, "btw");
91
+ enabledIds.delete("btw");
92
+ }
93
+
80
94
  await Promise.all([...enabledIds].map((id) => registerOne(pi, id)));
81
95
 
82
96
  return { globalSkips: appliedSkips };
@@ -1,9 +1,43 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
 
5
+ const HOTMILK_MODULE_PREFIX = "hotmilk/";
6
+
7
+ function findHotmilkPackageRoot(fromModuleUrl: string): string {
8
+ let dir = dirname(fileURLToPath(fromModuleUrl));
9
+
10
+ while (true) {
11
+ const pkgJsonPath = join(dir, "package.json");
12
+ if (existsSync(pkgJsonPath)) {
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { name?: string };
15
+ if (pkg.name === "hotmilk") {
16
+ return dir;
17
+ }
18
+ } catch {
19
+ // ignore invalid package.json while walking up
20
+ }
21
+ }
22
+
23
+ const parent = dirname(dir);
24
+ if (parent === dir) {
25
+ break;
26
+ }
27
+ dir = parent;
28
+ }
29
+
30
+ throw new Error("Cannot locate hotmilk package root");
31
+ }
32
+
5
33
  /** Split `pkg/subpath` — supports scoped packages (`@scope/name/...`). */
6
34
  export function parseBundledModulePath(relativePath: string): { pkgName: string; subpath: string } {
35
+ if (relativePath.startsWith(HOTMILK_MODULE_PREFIX)) {
36
+ return {
37
+ pkgName: "hotmilk",
38
+ subpath: relativePath.slice(HOTMILK_MODULE_PREFIX.length),
39
+ };
40
+ }
7
41
  if (relativePath.startsWith("@")) {
8
42
  const parts = relativePath.split("/");
9
43
  return { pkgName: `${parts[0]}/${parts[1]}`, subpath: parts.slice(2).join("/") };
@@ -23,6 +57,16 @@ export function resolveBundledModule(
23
57
  relativePath: string,
24
58
  fromModuleUrl = import.meta.url,
25
59
  ): string {
60
+ if (relativePath.startsWith(HOTMILK_MODULE_PREFIX)) {
61
+ const local = join(
62
+ findHotmilkPackageRoot(fromModuleUrl),
63
+ relativePath.slice(HOTMILK_MODULE_PREFIX.length),
64
+ );
65
+ if (existsSync(local)) {
66
+ return local;
67
+ }
68
+ }
69
+
26
70
  const { pkgName, subpath } = parseBundledModulePath(relativePath);
27
71
  let dir = dirname(fileURLToPath(fromModuleUrl));
28
72
 
@@ -39,6 +83,13 @@ export function resolveBundledModule(
39
83
  }
40
84
  }
41
85
 
86
+ if (pkgName === "hotmilk") {
87
+ const inTree = join(dir, subpath);
88
+ if (existsSync(inTree)) {
89
+ return inTree;
90
+ }
91
+ }
92
+
42
93
  const parent = dirname(dir);
43
94
  if (parent === dir) {
44
95
  break;
@@ -100,7 +100,7 @@ export const BUNDLED_EXTENSION_DEFINITIONS = [
100
100
  {
101
101
  id: "btw",
102
102
  package: { packageName: "pi-btw" },
103
- module: "pi-btw/extensions/btw.ts",
103
+ module: "hotmilk/src/extensions/btw.ts",
104
104
  group: "Integrations",
105
105
  },
106
106
  {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * hotmilk shim for pi-btw: patches createAgentSession before upstream loads so BTW
3
+ * sub-sessions get lighter prompts, read-biased tools when subagents are on, and
4
+ * optional graphify_query — without vendoring pi-btw (~2k lines).
5
+ */
6
+ import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
7
+ import { installHotmilkBtwSessionHook } from "../bootstrap/btw.ts";
8
+ import { bundledImportUrl } from "../bootstrap/resolve-bundled.ts";
9
+
10
+ installHotmilkBtwSessionHook();
11
+
12
+ const upstreamModule = await import(bundledImportUrl("pi-btw/extensions/btw.ts"));
13
+ const upstream = upstreamModule.default as ExtensionFactory;
14
+
15
+ export default async function hotmilkBtw(pi: ExtensionAPI): Promise<void> {
16
+ await upstream(pi);
17
+ }
package/src/index.ts CHANGED
@@ -11,12 +11,14 @@ import { registerGraphHandlers } from "./bootstrap/graph.ts";
11
11
  import { registerSessionHandlers } from "./bootstrap/session.ts";
12
12
  import { registerInputCommands, routeInputCommand } from "./controller/input.ts";
13
13
  import { registerHotmilkSessionLogo } from "./ui/session-logo.ts";
14
+ import { installHotmilkCtxSearchCapture } from "./bootstrap/btw.ts";
14
15
 
15
16
  export default async function registerHotmilk(pi: ExtensionAPI): Promise<void> {
16
17
  const runtime = createHotmilkRuntime();
17
18
 
18
19
  // Register before bundled imports so session_start handlers exist when bindExtensions emits.
19
20
  registerHotmilkSessionLogo(pi);
21
+ installHotmilkCtxSearchCapture(pi);
20
22
 
21
23
  prepareContextStack(runtime.extensionToggles);
22
24
 
@@ -13,7 +13,9 @@ function githubUserFromEnv(env: NodeJS.ProcessEnv): string | undefined {
13
13
  return env.GITHUB_USER?.trim() || env.GH_USER?.trim() || undefined;
14
14
  }
15
15
 
16
- function githubUsernameCommands(cwd: string): Array<{ file: string; args: string[]; cwd?: string }> {
16
+ function githubUsernameCommands(
17
+ cwd: string,
18
+ ): Array<{ file: string; args: string[]; cwd?: string }> {
17
19
  return [
18
20
  { file: "gh", args: ["api", "user", "-q", ".login"] },
19
21
  { file: "git", args: ["config", "--global", "github.user"] },