@drewpayment/mink 0.12.0-beta.8 → 0.13.0-beta.1

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.
Files changed (48) hide show
  1. package/README.md +20 -4
  2. package/dashboard/out/404.html +1 -1
  3. package/dashboard/out/action-log.html +1 -1
  4. package/dashboard/out/action-log.txt +1 -1
  5. package/dashboard/out/activity.html +1 -1
  6. package/dashboard/out/activity.txt +1 -1
  7. package/dashboard/out/bugs.html +1 -1
  8. package/dashboard/out/bugs.txt +1 -1
  9. package/dashboard/out/capture.html +1 -1
  10. package/dashboard/out/capture.txt +1 -1
  11. package/dashboard/out/config.html +1 -1
  12. package/dashboard/out/config.txt +1 -1
  13. package/dashboard/out/daemon.html +1 -1
  14. package/dashboard/out/daemon.txt +1 -1
  15. package/dashboard/out/design.html +1 -1
  16. package/dashboard/out/design.txt +1 -1
  17. package/dashboard/out/discord.html +1 -1
  18. package/dashboard/out/discord.txt +1 -1
  19. package/dashboard/out/file-index.html +1 -1
  20. package/dashboard/out/file-index.txt +1 -1
  21. package/dashboard/out/index.html +1 -1
  22. package/dashboard/out/index.txt +1 -1
  23. package/dashboard/out/insights.html +1 -1
  24. package/dashboard/out/insights.txt +1 -1
  25. package/dashboard/out/learning.html +1 -1
  26. package/dashboard/out/learning.txt +1 -1
  27. package/dashboard/out/overview.html +1 -1
  28. package/dashboard/out/overview.txt +1 -1
  29. package/dashboard/out/scheduler.html +1 -1
  30. package/dashboard/out/scheduler.txt +1 -1
  31. package/dashboard/out/sync.html +1 -1
  32. package/dashboard/out/sync.txt +1 -1
  33. package/dashboard/out/tokens.html +1 -1
  34. package/dashboard/out/tokens.txt +1 -1
  35. package/dashboard/out/waste.html +1 -1
  36. package/dashboard/out/waste.txt +1 -1
  37. package/dashboard/out/wiki.html +1 -1
  38. package/dashboard/out/wiki.txt +1 -1
  39. package/dist/cli.bun.js +894 -484
  40. package/dist/cli.node.js +912 -499
  41. package/package.json +1 -1
  42. package/src/cli.ts +20 -3
  43. package/src/commands/init.ts +122 -9
  44. package/src/core/agent-detect.ts +88 -0
  45. package/src/core/agent-pi.ts +314 -0
  46. package/src/core/prompt.ts +27 -0
  47. /package/dashboard/out/_next/static/{JMh4Nbbot9RESlGvYp6_4 → UWfkbJY4zr9fSt7O-CAge}/_buildManifest.js +0 -0
  48. /package/dashboard/out/_next/static/{JMh4Nbbot9RESlGvYp6_4 → UWfkbJY4zr9fSt7O-CAge}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drewpayment/mink",
3
- "version": "0.12.0-beta.8",
3
+ "version": "0.13.0-beta.1",
4
4
  "description": "A hidden presence that moves alongside the developer — token efficiency and cross-project wiki for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -16,8 +16,24 @@ switch (command) {
16
16
  break;
17
17
 
18
18
  case "init": {
19
- const { init } = await import("./commands/init");
20
- await init(cwd);
19
+ const { init, resolveTargetsFromFlag } = await import("./commands/init");
20
+ const args = process.argv.slice(3);
21
+ const agentFlagIndex = args.findIndex(
22
+ (a) => a === "--agent" || a.startsWith("--agent=")
23
+ );
24
+ let agentValue: string | undefined;
25
+ if (agentFlagIndex !== -1) {
26
+ const a = args[agentFlagIndex];
27
+ agentValue = a.includes("=") ? a.split("=").slice(1).join("=") : args[agentFlagIndex + 1];
28
+ }
29
+ const yes = args.includes("--yes") || args.includes("-y");
30
+ const targets = agentValue ? resolveTargetsFromFlag(agentValue) : undefined;
31
+ if (agentValue && (!targets || targets.length === 0)) {
32
+ console.error(`[mink] unknown --agent value: ${agentValue}`);
33
+ console.error(" Valid: claude, pi, all (or a comma-separated list)");
34
+ process.exit(1);
35
+ }
36
+ await init(cwd, { targets, interactive: !yes });
21
37
  break;
22
38
  }
23
39
 
@@ -221,7 +237,8 @@ switch (command) {
221
237
  console.log("Usage: mink <command> [options]");
222
238
  console.log();
223
239
  console.log("Commands:");
224
- console.log(" init Initialize Mink in the current project");
240
+ console.log(" init [--agent X] [--yes] Initialize Mink in the current project");
241
+ console.log(" --agent claude|pi|all (default: detect & prompt)");
225
242
  console.log(" status Display project health at a glance");
226
243
  console.log(" scan [--check] Force a full file index rescan");
227
244
  console.log(" config [key] [value] Manage global user settings");
@@ -12,6 +12,23 @@ import {
12
12
  isInsideVault,
13
13
  vaultProjects,
14
14
  } from "../core/vault";
15
+ import {
16
+ type AgentId,
17
+ AGENTS,
18
+ detectAgents,
19
+ resolveTargetsFromFlag,
20
+ } from "../core/agent-detect";
21
+ import { installPi } from "../core/agent-pi";
22
+ import { ask, stdinIsInteractive } from "../core/prompt";
23
+
24
+ export { resolveTargetsFromFlag };
25
+
26
+ export interface InitOptions {
27
+ /** Explicit set of hosts to wire. When omitted, Mink detects and/or prompts. */
28
+ targets?: AgentId[];
29
+ /** Allow an interactive agent-selection prompt when stdin is a TTY. */
30
+ interactive?: boolean;
31
+ }
15
32
 
16
33
  interface HookCommand {
17
34
  type: "command";
@@ -168,17 +185,80 @@ export function mergeHooksIntoSettings(
168
185
  atomicWriteJson(settingsPath, existing);
169
186
  }
170
187
 
188
+ /** Wire Mink into Claude Code: settings.json hooks + the project rule file. */
189
+ export function installClaude(
190
+ cwd: string,
191
+ cliPath: string
192
+ ): { settingsPath: string; rulePath: string } {
193
+ const settingsPath = resolve(cwd, ".claude", "settings.json");
194
+ mergeHooksIntoSettings(settingsPath, buildHooksConfig(cliPath));
195
+ const rulePath = writeMinkRule(cwd);
196
+ return { settingsPath, rulePath };
197
+ }
198
+
199
+ /**
200
+ * Decide which hosts to wire. Explicit `targets` win; otherwise Mink detects
201
+ * installed hosts and — only when running interactively at a TTY — asks the
202
+ * user to confirm. Non-interactive runs fall back to the detected set, and to
203
+ * Claude Code when nothing is detected (preserving Mink's original behavior).
204
+ */
205
+ export async function resolveTargets(
206
+ cwd: string,
207
+ opts: InitOptions
208
+ ): Promise<AgentId[]> {
209
+ if (opts.targets && opts.targets.length > 0) return opts.targets;
210
+
211
+ const detected = detectAgents(cwd);
212
+ const detectedIds = detected.filter((a) => a.detected).map((a) => a.id);
213
+
214
+ if (opts.interactive && stdinIsInteractive()) {
215
+ return promptForAgents(detected, detectedIds);
216
+ }
217
+
218
+ return detectedIds.length > 0 ? detectedIds : ["claude"];
219
+ }
220
+
221
+ async function promptForAgents(
222
+ detected: ReturnType<typeof detectAgents>,
223
+ defaults: AgentId[]
224
+ ): Promise<AgentId[]> {
225
+ const fallback: AgentId[] = defaults.length > 0 ? defaults : ["claude"];
226
+
227
+ console.log("Which assistant(s) should Mink work with?");
228
+ detected.forEach((a, i) => {
229
+ const tag = a.detected ? ` (detected — ${a.signals.join(", ")})` : "";
230
+ console.log(` ${i + 1}) ${a.label}${tag}`);
231
+ });
232
+
233
+ const answer = (
234
+ await ask(
235
+ `Enter numbers (comma-separated), 'a' for all [default: ${fallback.join(", ")}]: `
236
+ )
237
+ )
238
+ .trim()
239
+ .toLowerCase();
240
+
241
+ if (answer === "") return fallback;
242
+ if (answer === "a" || answer === "all") return AGENTS.map((a) => a.id);
243
+
244
+ const picked = answer
245
+ .split(",")
246
+ .map((s) => parseInt(s.trim(), 10))
247
+ .filter((n) => Number.isInteger(n) && n >= 1 && n <= detected.length)
248
+ .map((n) => detected[n - 1].id);
249
+
250
+ return picked.length > 0 ? picked : fallback;
251
+ }
252
+
171
253
  function isExistingInstallation(cwd: string): boolean {
172
254
  const dir = projectDir(cwd);
173
255
  if (!existsSync(dir)) return false;
174
256
  return existsSync(join(dir, "file-index.json"));
175
257
  }
176
258
 
177
- export async function init(cwd: string): Promise<void> {
259
+ export async function init(cwd: string, opts: InitOptions = {}): Promise<void> {
178
260
  const runtime = detectRuntime();
179
261
  const cliPath = resolveCliPath();
180
- const hooks = buildHooksConfig(cliPath);
181
- const settingsPath = resolve(cwd, ".claude", "settings.json");
182
262
  const dir = projectDir(cwd);
183
263
  const upgrading = isExistingInstallation(cwd);
184
264
 
@@ -189,8 +269,25 @@ export async function init(cwd: string): Promise<void> {
189
269
  console.log(` backup: ${backupName}`);
190
270
  }
191
271
 
192
- mergeHooksIntoSettings(settingsPath, hooks);
193
- const rulePath = writeMinkRule(cwd);
272
+ const targets = await resolveTargets(cwd, opts);
273
+
274
+ // Wire each selected host. Each installer is idempotent — re-running replaces
275
+ // Mink's prior entries rather than duplicating them — and touches only that
276
+ // host's configuration.
277
+ const wired: Record<string, string[]> = {};
278
+ for (const target of targets) {
279
+ if (target === "claude") {
280
+ const { settingsPath, rulePath } = installClaude(cwd, cliPath);
281
+ wired.claude = [`hooks: ${settingsPath}`, `rule: ${rulePath}`];
282
+ } else if (target === "pi") {
283
+ const r = installPi(cwd, cliPath);
284
+ wired.pi = [
285
+ `extension: ${r.extensionPath}`,
286
+ `guidance: ${r.guidancePath}`,
287
+ ...(r.notePath ? [`note skill: ${r.notePath}`] : []),
288
+ ];
289
+ }
290
+ }
194
291
 
195
292
  mkdirSync(dir, { recursive: true });
196
293
 
@@ -213,6 +310,13 @@ export async function init(cwd: string): Promise<void> {
213
310
  !Array.isArray(existingMeta.pathsByDevice)
214
311
  ? (existingMeta.pathsByDevice as Record<string, string>)
215
312
  : {};
313
+ // Record the set of wired hosts as the authoritative source of truth, unioned
314
+ // with any previously wired host so a single-target re-init never silently
315
+ // unwires the other.
316
+ const priorAgents = Array.isArray(existingMeta?.agents)
317
+ ? (existingMeta!.agents as string[])
318
+ : [];
319
+ const agents = Array.from(new Set([...priorAgents, ...targets]));
216
320
  atomicWriteJson(metaPath, {
217
321
  ...(existingMeta ?? {}),
218
322
  cwd,
@@ -220,21 +324,30 @@ export async function init(cwd: string): Promise<void> {
220
324
  initTimestamp: existingMeta?.initTimestamp ?? new Date().toISOString(),
221
325
  version: "0.1.0",
222
326
  pathsByDevice: { ...existingPathsByDevice, [deviceId]: cwd },
327
+ agents,
223
328
  ...(isNotesProject ? { projectType: "notes" } : {}),
224
329
  });
225
330
 
331
+ const printWiring = () => {
332
+ for (const id of Object.keys(wired)) {
333
+ const label = AGENTS.find((a) => a.id === id)?.label ?? id;
334
+ console.log(` ${label}:`);
335
+ for (const line of wired[id]) console.log(` ${line}`);
336
+ }
337
+ };
338
+
226
339
  if (upgrading) {
227
340
  console.log(`[mink] upgrade complete`);
228
341
  console.log(` project: ${projectId}`);
229
- console.log(` hooks: ${settingsPath}`);
230
- console.log(` rule: ${rulePath}`);
342
+ console.log(` agents: ${agents.join(", ")}`);
343
+ printWiring();
231
344
  } else {
232
345
  console.log(`[mink] initialized`);
233
346
  console.log(` project: ${projectId} (${identity.source})`);
234
347
  console.log(` state: ${dir}`);
235
348
  console.log(` runtime: ${runtime}`);
236
- console.log(` hooks: ${settingsPath}`);
237
- console.log(` rule: ${rulePath}`);
349
+ console.log(` agents: ${agents.join(", ")}`);
350
+ printWiring();
238
351
  }
239
352
 
240
353
  // Surface a one-time hint when the project is in a git repo with no remote
@@ -0,0 +1,88 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+
6
+ // Supported host coding assistants Mink can attach to. Adding a new host is a
7
+ // matter of appending an entry here plus an installer in init.ts.
8
+ export type AgentId = "claude" | "pi";
9
+
10
+ export interface AgentMeta {
11
+ id: AgentId;
12
+ label: string;
13
+ /** Project-local config directory that signals the host is used here. */
14
+ projectDir: string;
15
+ /** Per-user global config directory that signals the host is installed. */
16
+ globalDir: string;
17
+ /** Executable name to probe on PATH. */
18
+ bin: string;
19
+ }
20
+
21
+ export const AGENTS: AgentMeta[] = [
22
+ {
23
+ id: "claude",
24
+ label: "Claude Code",
25
+ projectDir: ".claude",
26
+ globalDir: join(homedir(), ".claude"),
27
+ bin: "claude",
28
+ },
29
+ {
30
+ id: "pi",
31
+ label: "Pi",
32
+ projectDir: ".pi",
33
+ globalDir: join(homedir(), ".pi"),
34
+ bin: "pi",
35
+ },
36
+ ];
37
+
38
+ export interface AgentInfo extends AgentMeta {
39
+ detected: boolean;
40
+ /** Human-readable reasons the host was (or was not) detected. */
41
+ signals: string[];
42
+ }
43
+
44
+ function commandExists(bin: string): boolean {
45
+ try {
46
+ const probe = process.platform === "win32" ? `where ${bin}` : `command -v ${bin}`;
47
+ execSync(probe, { stdio: "ignore" });
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Inspect a single host's footprint relative to `cwd`. Detection is best-effort
56
+ * and layered, strongest signal first: a project-local config directory is the
57
+ * clearest sign the host is actually used here; a global config directory or a
58
+ * binary on PATH only prove the host is installed somewhere.
59
+ */
60
+ export function detectAgent(meta: AgentMeta, cwd: string): AgentInfo {
61
+ const signals: string[] = [];
62
+ if (existsSync(join(cwd, meta.projectDir))) {
63
+ signals.push(`project config (${meta.projectDir}/)`);
64
+ }
65
+ if (existsSync(meta.globalDir)) {
66
+ signals.push("global config");
67
+ }
68
+ if (commandExists(meta.bin)) {
69
+ signals.push("on PATH");
70
+ }
71
+ return { ...meta, detected: signals.length > 0, signals };
72
+ }
73
+
74
+ export function detectAgents(cwd: string): AgentInfo[] {
75
+ return AGENTS.map((m) => detectAgent(m, cwd));
76
+ }
77
+
78
+ export function resolveTargetsFromFlag(flag: string): AgentId[] {
79
+ const normalized = flag.trim().toLowerCase();
80
+ if (normalized === "all") return AGENTS.map((a) => a.id);
81
+ const ids = normalized
82
+ .split(",")
83
+ .map((s) => s.trim())
84
+ .filter(Boolean);
85
+ const valid = AGENTS.map((a) => a.id) as string[];
86
+ const resolved = ids.filter((id): id is AgentId => valid.includes(id));
87
+ return resolved;
88
+ }
@@ -0,0 +1,314 @@
1
+ import { join, resolve, dirname } from "path";
2
+ import { existsSync, mkdirSync, copyFileSync, rmSync } from "fs";
3
+ import { atomicWriteText } from "./fs-utils";
4
+
5
+ // ── Paths ───────────────────────────────────────────────────────────────────
6
+ // Pi auto-discovers extensions from `.pi/extensions/*.ts` and skills from
7
+ // `.pi/skills/*/SKILL.md`, so simply writing these files wires Mink in — no
8
+ // `.pi/settings.json` edit required, which keeps us from clobbering the user's
9
+ // own Pi configuration.
10
+
11
+ export function piExtensionPath(cwd: string): string {
12
+ return join(cwd, ".pi", "extensions", "mink.ts");
13
+ }
14
+
15
+ export function piGuidanceSkillPath(cwd: string): string {
16
+ return join(cwd, ".pi", "skills", "mink", "SKILL.md");
17
+ }
18
+
19
+ export function piNoteSkillPath(cwd: string): string {
20
+ return join(cwd, ".pi", "skills", "mink-note", "SKILL.md");
21
+ }
22
+
23
+ // ── Extension source ─────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Generate the Pi adapter extension. Like the Claude hook wiring, the `mink`
27
+ * invocation is templated: an installed package resolves the portable `mink`
28
+ * bin shim (so a committed `.pi/` works across machines), while source-dev mode
29
+ * falls back to `bun run <abs cli.ts>`.
30
+ *
31
+ * The adapter shells out to the same `mink` lifecycle commands every other host
32
+ * uses, translating Pi's event/tool shapes into Mink's canonical payload. It is
33
+ * deliberately defensive: it never throws into Pi, never blocks a tool, and
34
+ * times out fast — matching the safety contract of the Claude hooks.
35
+ */
36
+ export function buildPiExtension(cliPath: string): string {
37
+ const isTsSource = cliPath.endsWith(".ts");
38
+ const cmd = isTsSource ? "bun" : "mink";
39
+ const baseArgs = isTsSource ? JSON.stringify(["run", cliPath]) : "[]";
40
+
41
+ return `// AUTO-GENERATED by \`mink init\`. Do not edit — re-run \`mink init\` to refresh.
42
+ //
43
+ // Mink adapter for the Pi coding agent. Routes Pi lifecycle and tool events
44
+ // into the \`mink\` CLI so Pi shares the same ~/.mink state, file index, ledger,
45
+ // and wiki as every other assistant wired to this project.
46
+ //
47
+ // Field shapes confirmed against pi source (earendil-works/pi,
48
+ // packages/coding-agent): read params {path, offset, limit}; write {path,
49
+ // content}; edit {path, edits:[{oldText,newText}]} (with legacy file_path /
50
+ // top-level oldText,newText). tool_call/tool_result events expose toolName,
51
+ // toolCallId, input; tool_result also exposes content (a content-block array),
52
+ // details, isError. Advisories are surfaced by returning a modified result from
53
+ // the tool_result handler — the documented mechanism. pi.exec has no stdin, so
54
+ // the canonical payload is piped to \`mink\` via a spawned child process. Every
55
+ // host-API access is defensive: the adapter never throws into Pi or blocks a
56
+ // tool — a failure degrades to "no advisory".
57
+
58
+ import { spawn } from "node:child_process";
59
+
60
+ const MINK_CMD = ${JSON.stringify(cmd)};
61
+ const MINK_BASE_ARGS = ${baseArgs};
62
+ const TIMEOUT_MS = 5000;
63
+
64
+ function runMink(sub, payload, cwd) {
65
+ return new Promise((res) => {
66
+ let done = false;
67
+ const finish = (out) => {
68
+ if (done) return;
69
+ done = true;
70
+ res(out);
71
+ };
72
+ try {
73
+ const child = spawn(MINK_CMD, [...MINK_BASE_ARGS, sub], {
74
+ cwd,
75
+ stdio: ["pipe", "ignore", "pipe"],
76
+ });
77
+ let stderr = "";
78
+ child.stderr?.on("data", (d) => {
79
+ stderr += d.toString();
80
+ });
81
+ child.on("error", () => finish(""));
82
+ child.on("close", () => finish(stderr.trim()));
83
+ const timer = setTimeout(() => {
84
+ try {
85
+ child.kill();
86
+ } catch {}
87
+ finish("");
88
+ }, TIMEOUT_MS);
89
+ timer.unref?.();
90
+ try {
91
+ child.stdin?.end(payload ? JSON.stringify(payload) : "");
92
+ } catch {}
93
+ } catch {
94
+ finish("");
95
+ }
96
+ });
97
+ }
98
+
99
+ // Pi's edit tool takes an array of { oldText, newText } replacements (legacy
100
+ // inputs may put oldText/newText/new_string at the top level). Concatenate the
101
+ // replacement text so Mink's write-enforcement sees everything being written.
102
+ function editNewText(input) {
103
+ if (Array.isArray(input.edits)) {
104
+ return input.edits
105
+ .map((e) => e?.newText ?? e?.new_string ?? "")
106
+ .filter(Boolean)
107
+ .join("\\n");
108
+ }
109
+ return input.newText ?? input.new_string ?? input.replacement ?? "";
110
+ }
111
+
112
+ // Resolve Pi's tool name + arguments to Mink's canonical operation. Only the
113
+ // three file operations matter; anything else returns null and is ignored.
114
+ function toolInfo(event) {
115
+ const name = String(event?.toolName ?? event?.tool ?? event?.name ?? "").toLowerCase();
116
+ const input = event?.input ?? event?.arguments ?? {};
117
+ const filePath = input.path ?? input.file_path ?? input.filePath;
118
+ if (!filePath) return null;
119
+ if (name === "read") return { op: "read", filePath };
120
+ if (name === "write") return { op: "write", filePath, content: input.content ?? input.text ?? "" };
121
+ if (name === "edit") return { op: "edit", filePath, newString: editNewText(input) };
122
+ return null;
123
+ }
124
+
125
+ // A tool_result's content is an array of content blocks ({ type, text }); pull
126
+ // the text out so Mink's post-read can estimate tokens from real content.
127
+ function resultContent(event) {
128
+ const c = event?.content;
129
+ if (typeof c === "string") return c;
130
+ if (Array.isArray(c)) {
131
+ const text = c
132
+ .map((b) => (typeof b === "string" ? b : b?.text ?? ""))
133
+ .filter(Boolean)
134
+ .join("\\n");
135
+ return text || null;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function prePayload(info) {
141
+ if (info.op === "read")
142
+ return { sub: "pre-read", payload: { tool_name: "Read", tool_input: { file_path: info.filePath } } };
143
+ if (info.op === "write")
144
+ return {
145
+ sub: "pre-write",
146
+ payload: { tool_name: "Write", tool_input: { file_path: info.filePath, content: info.content } },
147
+ };
148
+ return {
149
+ sub: "pre-write",
150
+ payload: { tool_name: "Edit", tool_input: { file_path: info.filePath, new_string: info.newString } },
151
+ };
152
+ }
153
+
154
+ function postPayload(info, content) {
155
+ if (info.op === "read")
156
+ return {
157
+ sub: "post-read",
158
+ payload: {
159
+ tool_name: "Read",
160
+ tool_input: { file_path: info.filePath },
161
+ tool_output: content == null ? undefined : { content },
162
+ },
163
+ };
164
+ if (info.op === "write")
165
+ return {
166
+ sub: "post-write",
167
+ payload: { tool_name: "Write", tool_input: { file_path: info.filePath, content: info.content } },
168
+ };
169
+ return {
170
+ sub: "post-write",
171
+ payload: { tool_name: "Edit", tool_input: { file_path: info.filePath, new_string: info.newString } },
172
+ };
173
+ }
174
+
175
+ export default function (pi) {
176
+ const cwd = pi?.ctx?.cwd || process.cwd();
177
+ const pending = new Map();
178
+ const keyOf = (event) => event?.toolCallId ?? event?.id ?? event?.callId ?? "";
179
+
180
+ pi.on?.("session_start", (event) => {
181
+ // Skip hot-reloads so an extension reload mid-task doesn't reset the
182
+ // ephemeral session state; every genuinely new/resumed session starts fresh.
183
+ if (event?.reason === "reload") return;
184
+ void runMink("session-start", null, cwd);
185
+ });
186
+ pi.on?.("agent_end", () => {
187
+ void runMink("session-stop", null, cwd);
188
+ });
189
+ pi.on?.("session_shutdown", () => {
190
+ void runMink("session-stop", null, cwd);
191
+ });
192
+
193
+ pi.on?.("tool_call", async (event) => {
194
+ const info = toolInfo(event);
195
+ if (!info) return;
196
+ const { sub, payload } = prePayload(info);
197
+ const advisory = await runMink(sub, payload, cwd);
198
+ if (advisory) pending.set(keyOf(event), advisory);
199
+ });
200
+
201
+ pi.on?.("tool_result", async (event) => {
202
+ const info = toolInfo(event);
203
+ if (!info) return;
204
+ const { sub, payload } = postPayload(info, resultContent(event));
205
+ const post = await runMink(sub, payload, cwd);
206
+ const pre = pending.get(keyOf(event)) ?? "";
207
+ pending.delete(keyOf(event));
208
+ const advisory = [pre, post].filter(Boolean).join("\\n");
209
+ if (!advisory) return;
210
+
211
+ // Surface Mink's advisory into the model's context by appending a text
212
+ // block to the tool result — the parity of Claude feeding hook stderr back.
213
+ const base = Array.isArray(event.content)
214
+ ? event.content
215
+ : typeof event.content === "string"
216
+ ? [{ type: "text", text: event.content }]
217
+ : [];
218
+ return {
219
+ content: [...base, { type: "text", text: advisory }],
220
+ details: event.details,
221
+ isError: event.isError,
222
+ };
223
+ });
224
+ }
225
+ `;
226
+ }
227
+
228
+ // ── Guidance skill ───────────────────────────────────────────────────────────
229
+ // Pi has no automatic project-rules file (CLAUDE.md / AGENTS.md equivalent), so
230
+ // the guidance Mink gives the assistant is delivered as a Pi skill instead.
231
+
232
+ const PI_GUIDANCE_SKILL = `---
233
+ name: mink
234
+ description: Mink context management is active in this project. Read this to understand how Mink memory, write enforcement, and note capture work under Pi.
235
+ ---
236
+
237
+ # Mink — context management for this project
238
+
239
+ This project uses **Mink** (\`@drewpayment/mink\`) for cross-session context management.
240
+
241
+ ## How it works
242
+ - Mink runs automatically through a Pi extension at \`.pi/extensions/mink.ts\` that hooks session start/stop and every read/edit/write tool call.
243
+ - All state lives in \`~/.mink/\` on the user's machine — **not** in this repository. Do not create or write to any in-repo state directory (no \`.wolf/\`, \`.mink/\`, etc.).
244
+ - Read intelligence, write enforcement, bug memory, and the token ledger are handled by the extension. You do not need to manually read or update any state files.
245
+ - Mink shares one \`~/.mink/\` state across every assistant wired to this project, so history is unified whether the user runs Pi or another assistant.
246
+
247
+ ## When to act on Mink
248
+ - If the user asks to "save a note", "remember this", "log this to my wiki", or similar, use the \`mink-note\` skill (\`/skill:mink-note\`) — it captures into the user's \`~/.mink/\` vault.
249
+ - If the extension surfaces a learning, past bug, or repeat-read warning in context, treat that as authoritative project memory and follow it.
250
+ - The \`mink dashboard\` and \`mink agent\` commands are user tools — do not invoke them on the user's behalf.
251
+ `;
252
+
253
+ function resolveSkillsSourceDir(): string {
254
+ // Walk up until we find a package root that contains skills/ — works from
255
+ // src/core/agent-pi.ts (dev) and dist/cli.js (installed), which sit at
256
+ // different depths relative to the package root.
257
+ let dir = dirname(new URL(import.meta.url).pathname);
258
+ while (true) {
259
+ if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "skills"))) {
260
+ return join(dir, "skills");
261
+ }
262
+ const parent = dirname(dir);
263
+ if (parent === dir) break;
264
+ dir = parent;
265
+ }
266
+ return resolve(dirname(new URL(import.meta.url).pathname), "../../skills");
267
+ }
268
+
269
+ export interface PiInstallResult {
270
+ extensionPath: string;
271
+ guidancePath: string;
272
+ notePath: string | null;
273
+ }
274
+
275
+ export function installPi(cwd: string, cliPath: string): PiInstallResult {
276
+ const extensionPath = piExtensionPath(cwd);
277
+ mkdirSync(dirname(extensionPath), { recursive: true });
278
+ atomicWriteText(extensionPath, buildPiExtension(cliPath));
279
+
280
+ const guidancePath = piGuidanceSkillPath(cwd);
281
+ mkdirSync(dirname(guidancePath), { recursive: true });
282
+ atomicWriteText(guidancePath, PI_GUIDANCE_SKILL);
283
+
284
+ // Mirror the note-capture skill into Pi's skill directory so the single
285
+ // source of truth (skills/mink-note) is reused rather than duplicated.
286
+ let notePath: string | null = null;
287
+ try {
288
+ const src = join(resolveSkillsSourceDir(), "mink-note", "SKILL.md");
289
+ if (existsSync(src)) {
290
+ notePath = piNoteSkillPath(cwd);
291
+ mkdirSync(dirname(notePath), { recursive: true });
292
+ copyFileSync(src, notePath);
293
+ }
294
+ } catch {
295
+ // Note skill is non-critical — the extension and guidance still work.
296
+ notePath = null;
297
+ }
298
+
299
+ return { extensionPath, guidancePath, notePath };
300
+ }
301
+
302
+ export function removePi(cwd: string): void {
303
+ for (const p of [
304
+ piExtensionPath(cwd),
305
+ join(cwd, ".pi", "skills", "mink"),
306
+ join(cwd, ".pi", "skills", "mink-note"),
307
+ ]) {
308
+ try {
309
+ rmSync(p, { recursive: true, force: true });
310
+ } catch {
311
+ // best-effort
312
+ }
313
+ }
314
+ }
@@ -0,0 +1,27 @@
1
+ import { createInterface } from "readline";
2
+
3
+ /**
4
+ * Whether the current process can safely prompt the user. We require a real
5
+ * interactive TTY on both stdin and stdout and honor the usual escape hatches
6
+ * (CI, an explicit opt-out) so `mink init` never blocks a script or pipeline.
7
+ */
8
+ export function stdinIsInteractive(): boolean {
9
+ const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
10
+ const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean };
11
+ return (
12
+ Boolean(stdin.isTTY) &&
13
+ Boolean(stdout.isTTY) &&
14
+ process.env.MINK_NO_PROMPT !== "1" &&
15
+ !process.env.CI
16
+ );
17
+ }
18
+
19
+ export function ask(question: string): Promise<string> {
20
+ return new Promise((resolve) => {
21
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22
+ rl.question(question, (answer) => {
23
+ rl.close();
24
+ resolve(answer);
25
+ });
26
+ });
27
+ }