hotmilk 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -6,14 +6,14 @@ User-facing setup: [README.md](README.md).
6
6
 
7
7
  ## Commands (this repo)
8
8
 
9
- | Command | Purpose |
10
- | --------------- | -------------------------------- |
11
- | `bun install` | Install deps (`bun.lock`) |
12
- | `bun test` | Vitest via vite-plus |
13
- | `bun run lint` | `vp lint` |
14
- | `bun run check` | lint + format + test |
15
-
16
- Requires **Node.js 22+**. Targets **Pi 0.77** (`@earendil-works/pi-coding-agent` peers).
9
+ | Command | Purpose |
10
+ | --------------- | ------------------------- |
11
+ | `bun install` | Install deps (`bun.lock`) |
12
+ | `bun test` | Vitest via vite-plus |
13
+ | `bun run lint` | `vp lint` |
14
+ | `bun run check` | lint + format + test |
15
+
16
+ Requires **Node.js 22+**. Targets **Pi 0.78** (`@earendil-works/pi-coding-agent` peers).
17
17
 
18
18
  ## Pi essentials
19
19
 
package/README.md CHANGED
@@ -50,11 +50,11 @@ pi install -l npm:hotmilk
50
50
  2. On first session, hotmilk creates `~/.pi/agent/hotmilk.json` if missing (defaults match the bundled template).
51
51
  3. After config changes, run `/reload`.
52
52
 
53
- ### Pi 0.77 and npm peers
53
+ ### Pi 0.78 and npm peers
54
54
 
55
- hotmilk targets **Pi 0.77** (`@earendil-works/pi-coding-agent` and peers). Several bundled dependencies still declare **0.74.x** peer ranges (`pi-simplify`, `pi-btw`, `pi-docparser`, `@blackbelt-technology/pi-flows`, and others). npm may report `ERESOLVE` until those packages publish 0.77-compatible peers.
55
+ hotmilk targets **Pi 0.78** (`@earendil-works/pi-coding-agent` and peers). Several bundled dependencies still declare **0.74.x** peer ranges (`pi-simplify`, `pi-btw`, `pi-docparser`, `@blackbelt-technology/pi-flows`, and others). npm may report `ERESOLVE` until those packages publish 0.78-compatible peers.
56
56
 
57
- This repo ships **`.npmrc`** with `legacy-peer-deps=true` so `npm install` and `npm ci` succeed. Copy from `.npmrc.example` if you clone without `.npmrc`. Treat upstream extensions as **best-effort on 0.77** until their maintainers widen peer ranges.
57
+ This repo ships **`.npmrc`** with `legacy-peer-deps=true` so `npm install` and `npm ci` succeed. Copy from `.npmrc.example` if you clone without `.npmrc`. Treat upstream extensions as **best-effort on 0.78** until their maintainers widen peer ranges.
58
58
 
59
59
  Heavy optional stacks (`agent-dashboard`, `pi-flows`) stay **off by default**; enable in `/mode` only when you need them and accept extra peer / startup cost.
60
60
 
@@ -98,7 +98,7 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
98
98
  "cursor-provider": true,
99
99
  "btw": true,
100
100
  "simplify": true,
101
- "rtk-optimizer": true,
101
+ "rtk-optimizer": false,
102
102
  "mcp-adapter": false,
103
103
  "planning-with-files": false,
104
104
  "caveman": false,
@@ -121,20 +121,22 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
121
121
  }
122
122
  ```
123
123
 
124
- | Key / area | Behavior |
125
- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
126
- | `extensions.*` | Set to `false` to skip registering that bundled extension |
127
- | `extensions.subagents` | Default `false`. When `true`, imports pi-subagents (~10s). Use with `gentle-ai` for delegation |
128
- | `extensions.goal` `mcp-adapter` | Integration / perf extensions (formerly always loaded via `pi.extensions`; now toggled like other bundled deps) |
129
- | Enabled extensions | Loaded **in parallel** on session start (faster than sequential import when many toggles are on) |
130
- | `graph.warnOnStale` | Notify when `graphify-out/needs_update` exists |
131
- | `graph.autoSuggestUpdate` | Append `graphify update .` to that notification |
132
- | `defaults.persona` | Seeds `.pi/gentle-ai/persona.json` when missing (`gentleman` \| `neutral`) |
133
- | `defaults.language` | Appends a project language hint to the system prompt each turn |
134
- | `mcp.seedOnStart` | Copy `mcp.json` template into `~/.pi/agent/mcp.json` when missing (empty template; for pi-mcp-adapter) |
135
- | `extensions.mcp-adapter` | Default `false`. Enable only when you want MCP servers from `~/.pi/agent/mcp.json` (do not duplicate context-mode) |
136
-
137
- **MCP (default):** `context-mode` extension registers `ctx_*` tools via its built-in bridge. Do **not** run `context-mode` as an MCP server at the same time. Hotmilk removes a legacy `context-mode` entry from `~/.pi/agent/mcp.json` on session start when the extension is on and mcp-adapter is off.
124
+ | Key / area | Behavior |
125
+ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | `extensions.*` | Set to `false` to skip registering that bundled extension |
127
+ | `extensions.subagents` | Default `false`. When `true`, imports pi-subagents (~10s). Use with `gentle-ai` for delegation |
128
+ | `extensions.context-mode` | Default `true`. Prefer `ctx_*` for large outputs (see project context-window rules) |
129
+ | `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`) |
130
+ | `extensions.goal` `mcp-adapter` | Integration / perf extensions (formerly always loaded via `pi.extensions`; now toggled like other bundled deps) |
131
+ | Enabled extensions | Loaded **in parallel** on session start (faster than sequential import when many toggles are on) |
132
+ | `graph.warnOnStale` | Notify when `graphify-out/needs_update` exists |
133
+ | `graph.autoSuggestUpdate` | Append `graphify update .` to that notification |
134
+ | `defaults.persona` | Seeds `.pi/gentle-ai/persona.json` when missing (`gentleman` \| `neutral`) |
135
+ | `defaults.language` | Appends a project language hint to the system prompt each turn |
136
+ | `mcp.seedOnStart` | Copy `mcp.json` template into `~/.pi/agent/mcp.json` when missing (empty template; for pi-mcp-adapter) |
137
+ | `extensions.mcp-adapter` | Default `false`. Enable only when you want MCP servers from `~/.pi/agent/mcp.json` (do not duplicate context-mode) |
138
+
139
+ **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.
138
140
 
139
141
  ### Optional extensions (off by default)
140
142
 
@@ -144,9 +146,9 @@ Enable in `/mode` or set the key to `true` in `hotmilk.json`, then `/reload`.
144
146
  | ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
145
147
  | `caveman` | [pi-caveman](https://www.npmjs.com/package/pi-caveman) | Terse English (`/caveman`). Conflicts with `defaults.language: ja` |
146
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` |
147
- | `agent-dashboard` | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) | Warm-starts via `pi-dashboard start` (~30s). Peers **0.74**; test on 0.77 before relying on it. Node.js ≥ 22.18 |
149
+ | `agent-dashboard` | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) | Warm-starts via `pi-dashboard start` (~30s). Peers **0.74**; test on 0.78 before relying on it. Node.js ≥ 22.18 |
148
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` |
149
- | `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.77 stacks |
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 |
150
152
 
151
153
  **Agent dashboard troubleshooting**
152
154
 
@@ -170,7 +172,7 @@ Older **tabako** users: replace `npm:tabako` with `npm:hotmilk`; let hotmilk see
170
172
 
171
173
  ## Development
172
174
 
173
- Requires **Node.js 22+** (or **Bun 1.3+**), **Bun** for installs in this repo, and Pi **0.77** peers in the environment.
175
+ Requires **Node.js 22+** (or **Bun 1.3+**), **Bun** for installs in this repo, and Pi **0.78** peers in the environment.
174
176
 
175
177
  ```bash
176
178
  bun install # commit bun.lock; peers resolved by Bun
@@ -186,7 +188,7 @@ bun run check # lint + format + test
186
188
  On push to `main`, GitHub Actions runs **lint + test**, then a **`publish` job** (`needs: test`) when `package.json` version is **newer than npm**. No separate workflow or tag push is required to start publish.
187
189
 
188
190
  ```text
189
- push main → test → publish (bun publish) → git tag v<version>
191
+ push main → test → publish (npm publish --provenance) → git tag v<version>
190
192
  ```
191
193
 
192
194
  Bump `version` in `package.json` before pushing to `main`.
@@ -197,20 +199,20 @@ Bump `version` in `package.json` before pushing to `main`.
197
199
  2. Scope: publish to **`hotmilk`** (or classic `publish` on the account)
198
200
  3. Repository → **Settings → Secrets → Actions** → name **`NPM_TOKEN`**
199
201
 
200
- CI runs `bun publish --access public` with `NPM_TOKEN` (same secret name as [npm’s CI/CD doc](https://docs.npmjs.com/using-private-packages-in-a-ci-cd-workflow)). Dependencies are **not** bundled into the tarball (`bundleDependencies` removed — npm rejected the 162 MB hard-linked bundle with `E415`).
202
+ CI uses [npm’s CI/CD workflow](https://docs.npmjs.com/using-private-packages-in-a-ci-cd-workflow): `actions/setup-node` with `registry-url`, then `npm publish --provenance --access public` with **`NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`**. The secret is named `NPM_TOKEN`; `setup-node` reads **`NODE_AUTH_TOKEN`** for auth. Dependencies are **not** bundled into the tarball (`bundleDependencies` removed — npm rejected the 162 MB hard-linked bundle with `E415`).
201
203
 
202
- Trusted Publisher on npm can stay configured or be removed; CI no longer depends on it.
204
+ Trusted Publisher on npm can stay configured or be removed; CI uses the token path above.
203
205
 
204
206
  GitHub Release is optional — npm publish does not require it.
205
207
 
206
- Local publish: run `npm login` once, **or** add the token to **`~/.npmrc`** (not the repo `.npmrc`):
208
+ Local publish: `npm login` once, then `npm publish --access public`. Or add the token to **`~/.npmrc`** (not the repo `.npmrc`):
207
209
 
208
210
  ```bash
209
211
  echo "//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN" >> ~/.npmrc
210
- bun publish --access public
212
+ npm publish --access public
211
213
  ```
212
214
 
213
- `bun publish` does not read the `NPM_TOKEN` environment variable by itself — it uses `~/.npmrc` auth (same as npm).
215
+ `bun publish` also works locally if `~/.npmrc` has a token; it does not read the `NPM_TOKEN` environment variable by itself.
214
216
 
215
217
  ### Layout
216
218
 
package/hotmilk.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "cursor-provider": true,
14
14
  "btw": true,
15
15
  "simplify": true,
16
- "rtk-optimizer": true,
16
+ "rtk-optimizer": false,
17
17
  "mcp-adapter": false,
18
18
  "planning-with-files": false,
19
19
  "caveman": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotmilk",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
@@ -60,21 +60,24 @@
60
60
  "pi-web-access": "^0.10.7"
61
61
  },
62
62
  "devDependencies": {
63
- "@earendil-works/pi-agent-core": "^0.77.0",
64
- "@earendil-works/pi-ai": "^0.77.0",
65
- "@earendil-works/pi-coding-agent": "^0.77.0",
66
- "@earendil-works/pi-tui": "^0.77.0",
67
- "@types/node": "^22.15.0",
63
+ "@earendil-works/pi-agent-core": "^0.78.0",
64
+ "@earendil-works/pi-ai": "^0.78.0",
65
+ "@earendil-works/pi-coding-agent": "^0.78.0",
66
+ "@earendil-works/pi-tui": "^0.78.0",
67
+ "@types/node": "^22.19.19",
68
68
  "typebox": "^1.1.39",
69
69
  "vite-plus": "^0.1.23"
70
70
  },
71
71
  "peerDependencies": {
72
- "@earendil-works/pi-agent-core": "^0.77.0",
73
- "@earendil-works/pi-ai": "^0.77.0",
74
- "@earendil-works/pi-coding-agent": "^0.77.0",
75
- "@earendil-works/pi-tui": "^0.77.0",
72
+ "@earendil-works/pi-agent-core": "^0.78.0",
73
+ "@earendil-works/pi-ai": "^0.78.0",
74
+ "@earendil-works/pi-coding-agent": "^0.78.0",
75
+ "@earendil-works/pi-tui": "^0.78.0",
76
76
  "typebox": "^1.1.39"
77
77
  },
78
+ "engines": {
79
+ "node": "22"
80
+ },
78
81
  "pi": {
79
82
  "extensions": [
80
83
  "./src/index.ts"
@@ -100,8 +103,5 @@
100
103
  "themes": [
101
104
  "./themes"
102
105
  ]
103
- },
104
- "engines": {
105
- "node": "22"
106
106
  }
107
107
  }
@@ -0,0 +1,167 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { pruneContextModeMcpServerFromAgentConfig } from "../config/mcp.ts";
5
+ import type { HotmilkRuntime } from "../config/runtime.ts";
6
+ import type { BundledExtensionId } from "../config/hotmilk.ts";
7
+
8
+ const RTK_EXTENSION_DIR = "pi-rtk-optimizer";
9
+ const RTK_CONFIG_FILENAME = "config.json";
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
+ export function getRtkOptimizerConfigPath(): string {
21
+ return `${getAgentDir()}/extensions/${RTK_EXTENSION_DIR}/${RTK_CONFIG_FILENAME}`;
22
+ }
23
+
24
+ export function expectedRtkMode(contextModeEnabled: boolean): "suggest" | "rewrite" {
25
+ return contextModeEnabled ? "suggest" : "rewrite";
26
+ }
27
+
28
+ /** Defaults when hotmilk seeds pi-rtk-optimizer config for the first time. */
29
+ export function buildHotmilkRtkConfig(contextModeEnabled: boolean): Record<string, unknown> {
30
+ return {
31
+ enabled: true,
32
+ mode: expectedRtkMode(contextModeEnabled),
33
+ guardWhenRtkMissing: true,
34
+ showRewriteNotifications: false,
35
+ outputCompaction: {
36
+ enabled: true,
37
+ stripAnsi: true,
38
+ readCompaction: { enabled: false },
39
+ truncate: { enabled: true, maxChars: 12_000 },
40
+ sourceCodeFilteringEnabled: false,
41
+ preserveExactSkillReads: true,
42
+ sourceCodeFiltering: "none",
43
+ smartTruncate: { enabled: false, maxLines: 220 },
44
+ aggregateTestOutput: true,
45
+ filterBuildOutput: true,
46
+ compactGitOutput: true,
47
+ aggregateLinterOutput: true,
48
+ groupSearchOutput: true,
49
+ trackSavings: true,
50
+ },
51
+ };
52
+ }
53
+
54
+ type RtkConfigRecord = Record<string, unknown> & {
55
+ mode?: string;
56
+ outputCompaction?: {
57
+ readCompaction?: { enabled?: boolean };
58
+ };
59
+ };
60
+
61
+ function writeRtkConfig(configPath: string, config: unknown): void {
62
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
63
+ }
64
+
65
+ export function seedRtkConfigIfMissing(
66
+ contextModeEnabled: boolean,
67
+ configPath = getRtkOptimizerConfigPath(),
68
+ ): { seeded: boolean; path: string } {
69
+ if (existsSync(configPath)) {
70
+ return { seeded: false, path: configPath };
71
+ }
72
+
73
+ mkdirSync(dirname(configPath), { recursive: true });
74
+ writeRtkConfig(configPath, buildHotmilkRtkConfig(contextModeEnabled));
75
+ return { seeded: true, path: configPath };
76
+ }
77
+
78
+ /** Hotmilk-managed fields only — does not touch Pi auto-compaction (settings.json). */
79
+ function alignRtkConfigWithContextMode(
80
+ config: RtkConfigRecord,
81
+ contextModeEnabled: boolean,
82
+ ): boolean {
83
+ let changed = false;
84
+ const mode = expectedRtkMode(contextModeEnabled);
85
+ if (config.mode !== mode) {
86
+ config.mode = mode;
87
+ changed = true;
88
+ }
89
+
90
+ if (contextModeEnabled && config.outputCompaction?.readCompaction?.enabled !== false) {
91
+ config.outputCompaction ??= {};
92
+ config.outputCompaction.readCompaction = { enabled: false };
93
+ changed = true;
94
+ }
95
+
96
+ return changed;
97
+ }
98
+
99
+ export function syncRtkConfigForContextStack(
100
+ contextModeEnabled: boolean,
101
+ rtkEnabled: boolean,
102
+ configPath = getRtkOptimizerConfigPath(),
103
+ ): { updated: boolean; seeded: boolean; path: string } {
104
+ if (!rtkEnabled) {
105
+ return { updated: false, seeded: false, path: configPath };
106
+ }
107
+
108
+ if (!existsSync(configPath)) {
109
+ seedRtkConfigIfMissing(contextModeEnabled, configPath);
110
+ return { updated: true, seeded: true, path: configPath };
111
+ }
112
+
113
+ const config = JSON.parse(readFileSync(configPath, "utf8")) as RtkConfigRecord;
114
+ const changed = alignRtkConfigWithContextMode(config, contextModeEnabled);
115
+ if (changed) {
116
+ writeRtkConfig(configPath, config);
117
+ }
118
+
119
+ return { updated: changed, seeded: false, path: configPath };
120
+ }
121
+
122
+ /** Run before bundled extensions register (rtk seed/sync when that toggle is on). */
123
+ export function prepareContextStack(extensionToggles: Record<BundledExtensionId, boolean>): void {
124
+ if (extensionToggles["rtk-optimizer"]) {
125
+ syncRtkConfigForContextStack(extensionToggles["context-mode"], true);
126
+ }
127
+ }
128
+
129
+ const MCP_PRUNED_MESSAGE = (path: string): string =>
130
+ `Removed duplicate context-mode entry from ${path}. ctx_* tools use the extension bridge only.`;
131
+
132
+ const MCP_ADAPTER_DUPLICATE_WARNING =
133
+ "context-mode runs on the extension bridge (ctx_*). Do not add a context-mode server to mcp.json — use mcp-adapter only for other MCP servers.";
134
+
135
+ const RTK_SYNC_MESSAGE =
136
+ "Adjusted pi-rtk-optimizer for context-mode coexistence (mode/readCompaction). Pi auto-compaction unchanged.";
137
+
138
+ /**
139
+ * Option A: context-mode extension owns ctx_* via built-in MCP bridge.
140
+ * Prune legacy `context-mode` MCP server entries whenever the extension is enabled.
141
+ */
142
+ export function applyContextStackOnSessionStart(
143
+ runtime: HotmilkRuntime,
144
+ notify: (message: string, level: "info" | "warning") => void,
145
+ ): void {
146
+ const { extensionToggles } = runtime;
147
+
148
+ if (extensionToggles["rtk-optimizer"]) {
149
+ const sync = syncRtkConfigForContextStack(extensionToggles["context-mode"], true);
150
+ if (sync.updated && !sync.seeded) {
151
+ notify(RTK_SYNC_MESSAGE, "info");
152
+ }
153
+ }
154
+
155
+ if (!extensionToggles["context-mode"]) {
156
+ return;
157
+ }
158
+
159
+ const mcpPrune = pruneContextModeMcpServerFromAgentConfig();
160
+ if (mcpPrune.pruned) {
161
+ notify(MCP_PRUNED_MESSAGE(mcpPrune.path), "info");
162
+ }
163
+
164
+ if (extensionToggles["mcp-adapter"]) {
165
+ notify(MCP_ADAPTER_DUPLICATE_WARNING, "warning");
166
+ }
167
+ }
@@ -3,6 +3,16 @@ import { dirname, join } from "node:path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import type { ResolvedDefaults } from "../config/hotmilk.ts";
5
5
 
6
+ export const CAVEMAN_JA_CONFLICT_MESSAGE =
7
+ "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
+
9
+ export function shouldWarnCavemanJaConflict(
10
+ cavemanEnabled: boolean,
11
+ language: string | undefined,
12
+ ): boolean {
13
+ return cavemanEnabled && language?.trim().toLowerCase() === "ja";
14
+ }
15
+
6
16
  export function seedPersonaFromDefaults(cwd: string, defaults: ResolvedDefaults): void {
7
17
  const path = join(cwd, ".pi", "gentle-ai", "persona.json");
8
18
  if (existsSync(path)) {
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { CONTEXT_STACK_EXTENSION_IDS } from "./context-stack.ts";
3
+ import { bundledImportUrl } from "./resolve-bundled.ts";
2
4
  import { BUNDLED_EXTENSION_IDS, type BundledExtensionId } from "../config/hotmilk.ts";
3
5
 
4
6
  type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
@@ -6,45 +8,59 @@ type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
6
8
  /** Bundled deps may type against @mariozechner/pi-coding-agent; hotmilk uses @earendil-works. */
7
9
  type ExtensionModule = { default: unknown };
8
10
 
11
+ function loadBundled(relativePath: string): () => Promise<ExtensionModule> {
12
+ return () => import(bundledImportUrl(relativePath));
13
+ }
14
+
9
15
  /**
10
16
  * Load bundled extensions on demand so disabled toggles do not pay import cost.
11
- * Registrations run in parallel extensions must not depend on each other's init order.
17
+ * Context stack registers sequentially; other enabled extensions load in parallel.
12
18
  */
13
19
  const BUNDLED_EXTENSION_LOADERS: Record<BundledExtensionId, () => Promise<ExtensionModule>> = {
14
- "skill-registry": () => import("../../node_modules/gentle-pi/extensions/skill-registry.ts"),
15
- "sdd-init": () => import("../../node_modules/gentle-pi/extensions/sdd-init.ts"),
16
- "gentle-ai": () => import("../../node_modules/gentle-pi/extensions/gentle-ai.ts"),
17
- "context-mode": () => import("../../node_modules/context-mode/build/adapters/pi/extension.js"),
18
- "ask-user": () => import("pi-ask-user"),
19
- graphify: () => import("../../node_modules/graphify-pi/extensions/graphify.ts"),
20
- subagents: () => import("../../node_modules/pi-subagents/src/extension/index.ts"),
21
- goal: () => import("../../node_modules/pi-goal/.pi/extensions/pi-goal/index.ts"),
22
- docparser: () => import("../../node_modules/pi-docparser/extensions/docparser/index.ts"),
23
- obsidian: () => import("../../node_modules/@haispeed/pi-obsidian/extensions/obsidian-cli.ts"),
24
- "cursor-provider": () => import("../../node_modules/@netandreus/pi-cursor-provider/index.ts"),
25
- btw: () => import("../../node_modules/pi-btw/extensions/btw.ts"),
26
- simplify: () => import("../../node_modules/pi-simplify/dist/index.js"),
27
- "rtk-optimizer": () => import("../../node_modules/pi-rtk-optimizer/index.ts"),
28
- "mcp-adapter": () => import("../../node_modules/pi-mcp-adapter/index.ts"),
29
- "planning-with-files": () =>
30
- import("../../node_modules/@tomxprime/planning-with-files/extensions/planning-with-files/index.ts"),
31
- caveman: () => import("../../node_modules/pi-caveman/extensions/caveman.ts"),
32
- "red-green": () => import("../../node_modules/pi-red-green/dist/index.js"),
33
- "agent-dashboard": () =>
34
- import("../../node_modules/@blackbelt-technology/pi-agent-dashboard/packages/extension/src/bridge.ts"),
35
- "web-access": () => import("../../node_modules/pi-web-access/index.ts"),
36
- "pi-flows": () => import("../../node_modules/@blackbelt-technology/pi-flows/extensions/index.ts"),
20
+ "skill-registry": loadBundled("gentle-pi/extensions/skill-registry.ts"),
21
+ "sdd-init": loadBundled("gentle-pi/extensions/sdd-init.ts"),
22
+ "gentle-ai": loadBundled("gentle-pi/extensions/gentle-ai.ts"),
23
+ "context-mode": loadBundled("context-mode/build/adapters/pi/extension.js"),
24
+ "ask-user": loadBundled("pi-ask-user/index.ts"),
25
+ graphify: loadBundled("graphify-pi/extensions/graphify.ts"),
26
+ subagents: loadBundled("pi-subagents/src/extension/index.ts"),
27
+ goal: loadBundled("pi-goal/.pi/extensions/pi-goal/index.ts"),
28
+ docparser: loadBundled("pi-docparser/extensions/docparser/index.ts"),
29
+ obsidian: loadBundled("@haispeed/pi-obsidian/extensions/obsidian-cli.ts"),
30
+ "cursor-provider": loadBundled("@netandreus/pi-cursor-provider/index.ts"),
31
+ btw: loadBundled("pi-btw/extensions/btw.ts"),
32
+ simplify: loadBundled("pi-simplify/dist/index.js"),
33
+ "rtk-optimizer": loadBundled("pi-rtk-optimizer/index.ts"),
34
+ "mcp-adapter": loadBundled("pi-mcp-adapter/index.ts"),
35
+ "planning-with-files": loadBundled(
36
+ "@tomxprime/planning-with-files/extensions/planning-with-files/index.ts",
37
+ ),
38
+ caveman: loadBundled("pi-caveman/extensions/caveman.ts"),
39
+ "red-green": loadBundled("pi-red-green/dist/index.js"),
40
+ "agent-dashboard": loadBundled(
41
+ "@blackbelt-technology/pi-agent-dashboard/packages/extension/src/bridge.ts",
42
+ ),
43
+ "web-access": loadBundled("pi-web-access/index.ts"),
44
+ "pi-flows": loadBundled("@blackbelt-technology/pi-flows/extensions/index.ts"),
37
45
  };
38
46
 
47
+ async function registerOne(pi: ExtensionAPI, id: BundledExtensionId): Promise<void> {
48
+ const mod = await BUNDLED_EXTENSION_LOADERS[id]();
49
+ await (mod.default as ExtensionFactory)(pi);
50
+ }
51
+
39
52
  export async function registerBundledExtensions(
40
53
  pi: ExtensionAPI,
41
54
  enabled: Record<BundledExtensionId, boolean>,
42
55
  ): Promise<void> {
43
- const enabledIds = BUNDLED_EXTENSION_IDS.filter((id) => enabled[id]);
44
- await Promise.all(
45
- enabledIds.map(async (id) => {
46
- const mod = await BUNDLED_EXTENSION_LOADERS[id]();
47
- await (mod.default as ExtensionFactory)(pi);
48
- }),
49
- );
56
+ const enabledIds = new Set(BUNDLED_EXTENSION_IDS.filter((id) => enabled[id]));
57
+
58
+ for (const id of CONTEXT_STACK_EXTENSION_IDS) {
59
+ if (enabledIds.has(id)) {
60
+ await registerOne(pi, id);
61
+ enabledIds.delete(id);
62
+ }
63
+ }
64
+
65
+ await Promise.all([...enabledIds].map((id) => registerOne(pi, id)));
50
66
  }
@@ -0,0 +1,55 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ /** Split `pkg/subpath` — supports scoped packages (`@scope/name/...`). */
6
+ export function parseBundledModulePath(relativePath: string): { pkgName: string; subpath: string } {
7
+ if (relativePath.startsWith("@")) {
8
+ const parts = relativePath.split("/");
9
+ return { pkgName: `${parts[0]}/${parts[1]}`, subpath: parts.slice(2).join("/") };
10
+ }
11
+ const slash = relativePath.indexOf("/");
12
+ if (slash === -1) {
13
+ return { pkgName: relativePath, subpath: "index.ts" };
14
+ }
15
+ return { pkgName: relativePath.slice(0, slash), subpath: relativePath.slice(slash + 1) };
16
+ }
17
+
18
+ /**
19
+ * Resolve a bundled dependency file whether npm nested it under hotmilk or hoisted
20
+ * it next to hotmilk (e.g. `~/.pi/npm/node_modules/context-mode`).
21
+ */
22
+ export function resolveBundledModule(
23
+ relativePath: string,
24
+ fromModuleUrl = import.meta.url,
25
+ ): string {
26
+ const { pkgName, subpath } = parseBundledModulePath(relativePath);
27
+ let dir = dirname(fileURLToPath(fromModuleUrl));
28
+
29
+ while (true) {
30
+ const nested = join(dir, "node_modules", pkgName, subpath);
31
+ if (existsSync(nested)) {
32
+ return nested;
33
+ }
34
+
35
+ if (basename(dir) === "node_modules") {
36
+ const sibling = join(dir, pkgName, subpath);
37
+ if (existsSync(sibling)) {
38
+ return sibling;
39
+ }
40
+ }
41
+
42
+ const parent = dirname(dir);
43
+ if (parent === dir) {
44
+ break;
45
+ }
46
+ dir = parent;
47
+ }
48
+
49
+ throw new Error(`Cannot resolve bundled module "${relativePath}" from hotmilk`);
50
+ }
51
+
52
+ /** Dynamic import URL for a bundled extension entry (works with nested and hoisted installs). */
53
+ export function bundledImportUrl(relativePath: string): string {
54
+ return pathToFileURL(resolveBundledModule(relativePath)).href;
55
+ }
@@ -0,0 +1,6 @@
1
+ /** @deprecated Import from `./context-stack.ts` instead. */
2
+ export {
3
+ buildHotmilkRtkConfig,
4
+ getRtkOptimizerConfigPath,
5
+ seedRtkConfigIfMissing,
6
+ } from "./context-stack.ts";
@@ -1,58 +1,52 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import {
3
- pruneContextModeMcpServerFromAgentConfig,
4
- seedAgentMcpJsonIfMissing,
5
- } from "../config/mcp.ts";
2
+ import { applyContextStackOnSessionStart } from "./context-stack.ts";
3
+ import { seedAgentMcpJsonIfMissing } from "../config/mcp.ts";
6
4
  import { AGENT_HOTMILK_CONFIG_LABEL, seedHotmilkConfigIfMissing } from "../config/hotmilk.ts";
7
5
  import type { HotmilkRuntime } from "../config/runtime.ts";
8
6
  import { setupHotmilkFooter } from "../ui/footer.ts";
9
- import { HOTMILK_LOGO } from "../ui/logo.ts";
10
- import { seedPersonaFromDefaults } from "./defaults.ts";
7
+ import {
8
+ CAVEMAN_JA_CONFLICT_MESSAGE,
9
+ seedPersonaFromDefaults,
10
+ shouldWarnCavemanJaConflict,
11
+ } from "./defaults.ts";
11
12
 
12
13
  const HOTMILK_SEEDED_MESSAGE = `Created ${AGENT_HOTMILK_CONFIG_LABEL} (toggle bundled extensions with /mode, then /reload).`;
13
14
  const HOTMILK_PARSE_ERROR_MESSAGE = (path: string, error: string): string =>
14
15
  `Failed to parse ${path}: ${error}. Using default extension toggles.`;
15
16
  const MCP_SEEDED_MESSAGE = (path: string): string =>
16
17
  `Created ${path} from hotmilk MCP template (add servers for pi-mcp-adapter; context-mode uses the extension bridge).`;
17
- const MCP_PRUNED_CONTEXT_MODE_MESSAGE = (path: string): string =>
18
- `Removed duplicate context-mode entry from ${path}. Use context-mode extension tools (ctx_*), not MCP adapter, unless mcp-adapter is enabled.`;
19
18
 
20
19
  export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntime): void {
21
20
  const termProgram = process.env.TERM_PROGRAM ?? "none";
22
21
 
23
22
  pi.on("session_start", (_event, ctx) => {
23
+ const uiNotify = (message: string, level: "info" | "warning") => ctx.ui.notify(message, level);
24
+
24
25
  setupHotmilkFooter(ctx, termProgram);
25
26
 
26
- ctx.ui.notify(`\`\`\`text\n${HOTMILK_LOGO}\n\`\`\``, "info");
27
27
  const hotmilkSeed = seedHotmilkConfigIfMissing();
28
28
  if (hotmilkSeed.seeded) {
29
- ctx.ui.notify(HOTMILK_SEEDED_MESSAGE, "info");
29
+ uiNotify(HOTMILK_SEEDED_MESSAGE, "info");
30
30
  }
31
31
 
32
32
  if (runtime.configError) {
33
- ctx.ui.notify(
34
- HOTMILK_PARSE_ERROR_MESSAGE(runtime.configPath, runtime.configError),
35
- "warning",
36
- );
33
+ uiNotify(HOTMILK_PARSE_ERROR_MESSAGE(runtime.configPath, runtime.configError), "warning");
37
34
  }
38
35
 
39
36
  if (runtime.extensionToggles["gentle-ai"]) {
40
37
  seedPersonaFromDefaults(ctx.cwd, runtime.defaults);
41
38
  }
42
39
 
43
- const useContextModeExtension =
44
- runtime.extensionToggles["context-mode"] && !runtime.extensionToggles["mcp-adapter"];
45
- if (useContextModeExtension) {
46
- const mcpPrune = pruneContextModeMcpServerFromAgentConfig();
47
- if (mcpPrune.pruned) {
48
- ctx.ui.notify(MCP_PRUNED_CONTEXT_MODE_MESSAGE(mcpPrune.path), "info");
49
- }
40
+ applyContextStackOnSessionStart(runtime, uiNotify);
41
+
42
+ if (shouldWarnCavemanJaConflict(runtime.extensionToggles.caveman, runtime.defaults.language)) {
43
+ uiNotify(CAVEMAN_JA_CONFLICT_MESSAGE, "warning");
50
44
  }
51
45
 
52
46
  if (runtime.mcp.seedOnStart) {
53
47
  const mcpSeed = seedAgentMcpJsonIfMissing();
54
48
  if (mcpSeed.seeded) {
55
- ctx.ui.notify(MCP_SEEDED_MESSAGE(mcpSeed.path), "info");
49
+ uiNotify(MCP_SEEDED_MESSAGE(mcpSeed.path), "info");
56
50
  }
57
51
  }
58
52
  });
@@ -10,21 +10,16 @@ export const BUNDLED_EXTENSION_GROUPS: BundledExtensionGroup[] = [
10
10
  { label: "Harness", ids: ["skill-registry", "sdd-init", "gentle-ai"] },
11
11
  {
12
12
  label: "Agent tools",
13
- ids: [
14
- "context-mode",
15
- "ask-user",
16
- "graphify",
17
- "subagents",
18
- "agent-dashboard",
19
- "web-access",
20
- "pi-flows",
21
- ],
13
+ ids: ["ask-user", "graphify", "subagents", "agent-dashboard", "web-access", "pi-flows"],
14
+ },
15
+ {
16
+ label: "Context & performance",
17
+ ids: ["context-mode", "rtk-optimizer", "simplify"],
22
18
  },
23
19
  {
24
20
  label: "Integrations",
25
21
  ids: ["goal", "docparser", "obsidian", "cursor-provider", "btw", "mcp-adapter"],
26
22
  },
27
- { label: "Performance", ids: ["simplify", "rtk-optimizer"] },
28
23
  { label: "Workflow", ids: ["planning-with-files", "red-green"] },
29
24
  { label: "Output", ids: ["caveman"] },
30
25
  ];
package/src/index.ts CHANGED
@@ -6,18 +6,25 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { createHotmilkRuntime } from "./config/runtime.ts";
7
7
  import { registerDefaultsHandlers } from "./bootstrap/defaults.ts";
8
8
  import { registerBundledExtensions } from "./bootstrap/extensions.ts";
9
+ import { prepareContextStack } from "./bootstrap/context-stack.ts";
9
10
  import { registerGraphHandlers } from "./bootstrap/graph.ts";
10
11
  import { registerSessionHandlers } from "./bootstrap/session.ts";
11
12
  import { registerInputCommands, routeInputCommand } from "./controller/input.ts";
13
+ import { registerHotmilkSessionLogo } from "./ui/session-logo.ts";
12
14
 
13
15
  export default async function registerHotmilk(pi: ExtensionAPI): Promise<void> {
14
16
  const runtime = createHotmilkRuntime();
15
17
 
18
+ // Register before bundled imports so session_start handlers exist when bindExtensions emits.
19
+ registerHotmilkSessionLogo(pi);
20
+
16
21
  if (runtime.extensionToggles["agent-dashboard"]) {
17
22
  const { scheduleDashboardWarmStart } = await import("./bootstrap/dashboard.ts");
18
23
  scheduleDashboardWarmStart();
19
24
  }
20
25
 
26
+ prepareContextStack(runtime.extensionToggles);
27
+
21
28
  await registerBundledExtensions(pi, runtime.extensionToggles);
22
29
  registerGraphHandlers(pi, runtime.graph);
23
30
  registerDefaultsHandlers(pi, runtime.defaults);
package/src/ui/footer.ts CHANGED
@@ -4,10 +4,51 @@ import {
4
4
  type ReadonlyFooterDataProvider,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
7
- import { truncateToWidth } from "@earendil-works/pi-tui";
7
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
8
  import type { ExtensionContext } from "../controller/context.ts";
9
9
  import { formatFooterTime } from "./footer-time.ts";
10
10
 
11
+ function sanitizeStatusText(text: string): string {
12
+ return text
13
+ .replace(/[\r\n\t]/g, " ")
14
+ .replace(/ +/g, " ")
15
+ .trim();
16
+ }
17
+
18
+ function extensionStatusLines(
19
+ footerData: ReadonlyFooterDataProvider,
20
+ width: number,
21
+ dim: (text: string) => string,
22
+ ellipsis: string,
23
+ ): string[] {
24
+ const extensionStatuses = footerData.getExtensionStatuses();
25
+ if (extensionStatuses.size === 0) {
26
+ return [];
27
+ }
28
+
29
+ return [...extensionStatuses.entries()]
30
+ .sort(([a], [b]) => a.localeCompare(b))
31
+ .map(([, text]) => sanitizeStatusText(text))
32
+ .filter((text) => text.length > 0)
33
+ .map((text) => truncateToWidth(dim(text), width, ellipsis));
34
+ }
35
+
36
+ function appendMetaToLastLine(lines: string[], meta: string, width: number): string[] {
37
+ if (lines.length === 0) {
38
+ return [meta];
39
+ }
40
+
41
+ const lastIndex = lines.length - 1;
42
+ const lastLine = lines[lastIndex] ?? "";
43
+ const gap = 2;
44
+ if (visibleWidth(lastLine) + gap + visibleWidth(meta) <= width) {
45
+ const padding = " ".repeat(width - visibleWidth(lastLine) - visibleWidth(meta));
46
+ return [...lines.slice(0, lastIndex), lastLine + padding + meta];
47
+ }
48
+
49
+ return [...lines, meta];
50
+ }
51
+
11
52
  const FOOTER_TIME_REFRESH_MS = 30_000;
12
53
 
13
54
  function latestThinkingLevel(sessionManager: ExtensionContext["sessionManager"]): ThinkingLevel {
@@ -55,9 +96,17 @@ export function setupHotmilkFooter(ctx: ExtensionContext, termProgram: string):
55
96
  base.invalidate();
56
97
  },
57
98
  render(width: number): string[] {
58
- const lines = base.render(width);
59
- const meta = theme.fg("dim", `${formatFooterTime(new Date())} ${termProgram}`);
60
- return [...lines, "", truncateToWidth(meta, width, theme.fg("dim", "..."))];
99
+ const baseLines = base.render(width);
100
+ const coreLines = baseLines.slice(0, 2);
101
+ const dim = (text: string) => theme.fg("dim", text);
102
+ const ellipsis = theme.fg("dim", "...");
103
+ const statusLines = extensionStatusLines(footerData, width, dim, ellipsis);
104
+ const meta = truncateToWidth(
105
+ dim(`${formatFooterTime(new Date())} ${termProgram}`),
106
+ width,
107
+ ellipsis,
108
+ );
109
+ return appendMetaToLastLine([...coreLines, ...statusLines], meta, width);
61
110
  },
62
111
  };
63
112
  });
@@ -0,0 +1,73 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import type { ExtensionContext } from "../controller/context.ts";
4
+ import { HOTMILK_LOGO } from "./logo.ts";
5
+
6
+ const LOGO_LINES = HOTMILK_LOGO.split("\n");
7
+ /** Same lead time as gentle-pi `startup-banner.ts`. */
8
+ const LOGO_SHOW_DELAY_MS = 50;
9
+
10
+ let showTimeout: ReturnType<typeof setTimeout> | undefined;
11
+ let activeTui: TUI | undefined;
12
+
13
+ /** Center fixed-width ASCII lines within the terminal width. */
14
+ export function centerAsciiLines(lines: readonly string[], width: number): string[] {
15
+ const contentWidth = Math.max(...lines.map((line) => line.length), 1);
16
+ const pad = Math.max(0, Math.floor((width - contentWidth) / 2));
17
+ const prefix = " ".repeat(pad);
18
+ return lines.map((line) => prefix + line.padEnd(contentWidth));
19
+ }
20
+
21
+ /** Skip only `/reload`; show on fresh launch and session resume (gentle-pi shows every session_start). */
22
+ export function shouldShowSessionLogo(reason: string): boolean {
23
+ return reason !== "reload";
24
+ }
25
+
26
+ function clearLogoTimers(): void {
27
+ if (showTimeout) {
28
+ clearTimeout(showTimeout);
29
+ showTimeout = undefined;
30
+ }
31
+ }
32
+
33
+ function dismissHotmilkLogo(ctx: ExtensionContext): void {
34
+ if (!ctx.hasUI) return;
35
+ ctx.ui.setHeader(undefined);
36
+ activeTui?.requestRender();
37
+ activeTui = undefined;
38
+ }
39
+
40
+ function showHotmilkSessionLogo(ctx: ExtensionContext): void {
41
+ if (!ctx.hasUI) return;
42
+
43
+ clearLogoTimers();
44
+ showTimeout = setTimeout(() => {
45
+ showTimeout = undefined;
46
+ if (!ctx.hasUI) return;
47
+
48
+ ctx.ui.setHeader((tui, theme) => {
49
+ activeTui = tui;
50
+ return {
51
+ render(width: number): string[] {
52
+ return centerAsciiLines(LOGO_LINES, width).map((line) => theme.fg("accent", line));
53
+ },
54
+ invalidate() {
55
+ clearLogoTimers();
56
+ },
57
+ };
58
+ });
59
+ }, LOGO_SHOW_DELAY_MS);
60
+ }
61
+
62
+ /** Register persistent header logo on session start; clear on shutdown only. */
63
+ export function registerHotmilkSessionLogo(pi: ExtensionAPI): void {
64
+ pi.on("session_start", (event, ctx) => {
65
+ if (!shouldShowSessionLogo(event.reason)) return;
66
+ showHotmilkSessionLogo(ctx);
67
+ });
68
+
69
+ pi.on("session_shutdown", (_event, ctx) => {
70
+ clearLogoTimers();
71
+ dismissHotmilkLogo(ctx);
72
+ });
73
+ }