@akanjs/devkit 3.0.0-beta.15 → 3.0.0-beta.17

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.
@@ -1,14 +1,8 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
- import {
3
- AuthStorage,
4
- type InlineExtension,
5
- ModelRegistry,
6
- SessionManager,
7
- SettingsManager,
8
- } from "@earendil-works/pi-coding-agent";
2
+ import { type InlineExtension, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
9
3
  import type { CodeAgentProfile } from "akanjs/common";
10
4
  import { AkanEnvKeys } from "../tools/AkanEnvKeys";
11
- import { AkanAuthStorageBackend } from "./AkanAuthStorageBackend";
5
+ import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
12
6
  import { akanCodePaths } from "./akanCodePaths";
13
7
  import { akanSystemPrompt } from "./akanSystemPrompt";
14
8
  import { CodeSessionIndex } from "./CodeSessionIndex";
@@ -24,8 +18,8 @@ export interface AkanCodeServicesOptions {
24
18
  /**
25
19
  * Assembles the engine's injectable services from akan's own storage.
26
20
  *
27
- * Every one of the five is passed explicitly. Leave any of them out and the engine falls back to `~/.pi/` —
28
- * measured: with all five injected it writes nothing outside the paths named here.
21
+ * Every one of the four is passed explicitly. Leave any of them out and the engine falls back to `~/.pi/` —
22
+ * measured: with all four injected it writes nothing outside the paths named here.
29
23
  */
30
24
  export class AkanCodeServices {
31
25
  static settings() {
@@ -41,15 +35,21 @@ export class AkanCodeServices {
41
35
  });
42
36
  }
43
37
 
44
- static auth(workspaceRoot: string) {
38
+ /**
39
+ * The model catalogue and the credentials behind it, both under `~/.akan/`.
40
+ *
41
+ * `authPath` is the whole of what akan's own credential file used to need a storage backend for: the engine
42
+ * locks and rewrites that file itself, so a second `akan code` refreshing an OAuth token at the same moment
43
+ * cannot clobber the first. Nothing lands in `~/.pi/`, and a user who removes akan takes their keys with them.
44
+ */
45
+ static async runtime(workspaceRoot: string) {
45
46
  mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
46
- const authStorage = AuthStorage.fromStorage(new AkanAuthStorageBackend(akanCodePaths.authFile()));
47
- AkanEnvKeys.apply(authStorage, workspaceRoot);
48
- return authStorage;
49
- }
50
-
51
- static models(authStorage: AuthStorage) {
52
- return ModelRegistry.create(authStorage, akanCodePaths.modelsFile());
47
+ const runtime = await ModelRuntime.create({
48
+ authPath: akanCodePaths.authFile(),
49
+ modelsPath: akanCodePaths.modelsFile(),
50
+ });
51
+ await AkanEnvKeys.apply(runtime, workspaceRoot);
52
+ return runtime;
53
53
  }
54
54
 
55
55
  static sessions(workspaceRoot: string, cwd: string, profile: CodeAgentProfile, resume?: string) {
@@ -95,6 +95,15 @@ export class AkanCodeServices {
95
95
  noPromptTemplates: true,
96
96
  noThemes: true,
97
97
  noContextFiles: !options.profile.context.projectFiles,
98
+ // The engine takes one context file per directory and looks at no editor's own rules, so a repo whose
99
+ // conventions live in a `CLAUDE.md` or under `.cursor/rules` hands this agent less than it hands the
100
+ // editor beside it — see {@link AkanContextFiles}. Gated on the same flag, so a profile that wants no
101
+ // project context still gets none.
102
+ agentsFilesOverride: ({ agentsFiles }: { agentsFiles: AkanContextFile[] }) => ({
103
+ agentsFiles: options.profile.context.projectFiles
104
+ ? AkanContextFiles.extend(agentsFiles, { workspaceRoot: options.workspaceRoot, cwd: options.cwd })
105
+ : agentsFiles,
106
+ }),
98
107
  appendSystemPrompt: [akanSystemPrompt(options.profile)],
99
108
  additionalExtensionPaths: present(akanCodePaths.extensionsDir(options.workspaceRoot)),
100
109
  additionalSkillPaths: AkanCodeServices.#skillPaths(options.profile, options.workspaceRoot),
@@ -0,0 +1,117 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export interface AkanContextFile {
5
+ path: string;
6
+ content: string;
7
+ }
8
+
9
+ export interface AkanContextFilesOptions {
10
+ workspaceRoot: string;
11
+ cwd: string;
12
+ }
13
+
14
+ /**
15
+ * The workspace-authored rules the engine's own discovery leaves behind.
16
+ *
17
+ * It walks the ancestors of `cwd` and takes **one** file per directory, first match of
18
+ * `AGENTS.override.md · AGENTS.md · AGENTS.MD · CLAUDE.md · CLAUDE.MD` — so a repo carrying both an `AGENTS.md`
19
+ * and a `CLAUDE.md` has the second one silently dropped, and a `.cursor/rules` tree is never looked at. Both
20
+ * are files a developer wrote expecting an agent to obey them, and an agent that reads one editor's copy and
21
+ * not another's follows rules that disagree with the repo it is in.
22
+ *
23
+ * Added rather than replaced: the base list keeps its order and its precedence, and these land after it.
24
+ */
25
+ export class AkanContextFiles {
26
+ /** Cursor's frontmatter block, which is metadata for its own rule picker and not instruction for a model. */
27
+ static readonly #frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
28
+
29
+ static extend(base: AkanContextFile[], options: AkanContextFilesOptions): AkanContextFile[] {
30
+ const seen = new Set(base.map((file) => file.path));
31
+ // Keyed by content as well as by path, because the usual way to carry both names is to make one a symlink
32
+ // to the other or a one-line pointer at it — and the same guide twice is the same guide twice.
33
+ const contents = new Set(base.map((file) => file.content.trim()));
34
+ const added: AkanContextFile[] = [];
35
+ for (const file of [...AkanContextFiles.#claude(base), ...AkanContextFiles.#cursor(options)]) {
36
+ if (seen.has(file.path) || contents.has(file.content.trim()) || !file.content.trim()) continue;
37
+ seen.add(file.path);
38
+ contents.add(file.content.trim());
39
+ added.push(file);
40
+ }
41
+ return [...base, ...added];
42
+ }
43
+
44
+ /**
45
+ * The `CLAUDE.md` beside an `AGENTS.md` the engine already took.
46
+ *
47
+ * Not beside an `AGENTS.override.md`: that name means "ignore what this directory would otherwise say", so
48
+ * reinstating the file it was written to displace is the one case where loading both is wrong.
49
+ */
50
+ static #claude(base: AkanContextFile[]) {
51
+ const files: AkanContextFile[] = [];
52
+ for (const file of base) {
53
+ if (!/^AGENTS\.(md|MD)$/.test(path.basename(file.path))) continue;
54
+ for (const name of ["CLAUDE.md", "CLAUDE.MD"]) {
55
+ const candidate = path.join(path.dirname(file.path), name);
56
+ const content = AkanContextFiles.#read(candidate);
57
+ if (content !== undefined) files.push({ path: candidate, content });
58
+ }
59
+ }
60
+ return files;
61
+ }
62
+
63
+ /**
64
+ * Cursor's always-on rules, from the workspace root and from the session's own directory.
65
+ *
66
+ * Only `alwaysApply: true` is taken. Cursor's other three kinds are conditional — `globs` attaches a rule to
67
+ * the files it names, a bare `description` offers it for the model to ask for, and neither means the rule is
68
+ * loaded manually — so putting them in a system prompt applies rules their author scoped away. A file with
69
+ * no frontmatter at all is the `.cursorrules` shape, which was never conditional.
70
+ */
71
+ static #cursor(options: AkanContextFilesOptions) {
72
+ const files: AkanContextFile[] = [];
73
+ const roots = [...new Set([options.workspaceRoot, options.cwd])];
74
+ for (const root of roots) {
75
+ const legacy = AkanContextFiles.#read(path.join(root, ".cursorrules"));
76
+ if (legacy !== undefined) files.push({ path: path.join(root, ".cursorrules"), content: legacy });
77
+ for (const file of AkanContextFiles.#mdcFiles(path.join(root, ".cursor", "rules"))) {
78
+ const raw = AkanContextFiles.#read(file);
79
+ if (raw === undefined) continue;
80
+ const body = AkanContextFiles.#applied(raw);
81
+ if (body !== undefined) files.push({ path: file, content: body });
82
+ }
83
+ }
84
+ return files;
85
+ }
86
+
87
+ /** The rule body when the rule applies unconditionally, and nothing when its author scoped it. */
88
+ static #applied(raw: string) {
89
+ const match = AkanContextFiles.#frontmatter.exec(raw);
90
+ if (!match) return raw;
91
+ if (!/^\s*alwaysApply\s*:\s*true\s*$/m.test(match[1] ?? "")) return undefined;
92
+ return raw.slice(match[0].length);
93
+ }
94
+
95
+ /** Sorted, because the order rules reach the model in is otherwise the order a directory happens to list. */
96
+ static #mdcFiles(dir: string) {
97
+ if (!existsSync(dir)) return [];
98
+ try {
99
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
100
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".mdc"))
101
+ .map((entry) => path.join(entry.parentPath ?? dir, entry.name))
102
+ .sort();
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ static #read(file: string) {
109
+ try {
110
+ if (!existsSync(file) || !statSync(file).isFile()) return undefined;
111
+ return readFileSync(file, "utf8").replace(/^/, "");
112
+ } catch {
113
+ // A rule file that cannot be read costs its own rules. It must not cost the session its start.
114
+ return undefined;
115
+ }
116
+ }
117
+ }
@@ -13,6 +13,7 @@ import {
13
13
  type CodeAgentEvent,
14
14
  type CodeAgentEventBody,
15
15
  type CodeAgentImage,
16
+ type CodeAgentMcpStatus,
16
17
  type CodeAgentProfile,
17
18
  type CodeAgentQuestion,
18
19
  type CodeAgentSessionInfo,
@@ -82,6 +83,8 @@ export class CodeAgent {
82
83
  #disposed = false;
83
84
  #sessionDir: string | undefined;
84
85
  #mailbox: CodeMailbox | undefined;
86
+ #mcp: CodeAgentMcpStatus[] = [];
87
+ #workspaceRoot = "";
85
88
  /**
86
89
  * The frames of the turn in flight, so a host that reconnects mid-turn can be handed what it missed.
87
90
  *
@@ -102,9 +105,8 @@ export class CodeAgent {
102
105
  const cwd = options.cwd ?? workspaceRoot;
103
106
  const agent = new CodeAgent(options.profile);
104
107
  const settingsManager = AkanCodeServices.settings();
105
- const authStorage = AkanCodeServices.auth(workspaceRoot);
106
- const modelRegistry = AkanCodeServices.models(authStorage);
107
- const model = await akanCodeModel(modelRegistry, options.model);
108
+ const modelRuntime = await AkanCodeServices.runtime(workspaceRoot);
109
+ const model = await akanCodeModel(modelRuntime, options.model);
108
110
  const compaction = DEFAULT_COMPACTION_SETTINGS;
109
111
  const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
110
112
 
@@ -118,6 +120,7 @@ export class CodeAgent {
118
120
  currentSessionId: () => agent.sessionId,
119
121
  mailbox: () => agent.#mailbox,
120
122
  onNotice: (message) => agent.#emit({ type: "notice", level: "warning", message }),
123
+ onAgents: (agents) => agent.#emit({ type: "subagent", agents }),
121
124
  });
122
125
 
123
126
  const resourceLoader = new DefaultResourceLoader({
@@ -136,8 +139,7 @@ export class CodeAgent {
136
139
  const { session } = await createAgentSession({
137
140
  cwd,
138
141
  agentDir: akanCodePaths.globalDir(),
139
- authStorage,
140
- modelRegistry,
142
+ modelRuntime,
141
143
  settingsManager,
142
144
  resourceLoader,
143
145
  model,
@@ -147,6 +149,8 @@ export class CodeAgent {
147
149
  customTools: options.customTools,
148
150
  });
149
151
  agent.#disposePlugins = akan.dispose;
152
+ agent.#mcp = akan.mcp;
153
+ agent.#workspaceRoot = workspaceRoot;
150
154
  if (options.profile.session.store === "file") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
151
155
  await agent.#attach(session, options.mode ?? "print");
152
156
  if (agent.#sessionDir) agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
@@ -169,6 +173,21 @@ export class CodeAgent {
169
173
  return this.#sessionDir ? CodeSessionIndex.list(this.#sessionDir) : [];
170
174
  }
171
175
 
176
+ /** The repo, which is not `profile.paths.root` — that one is narrowed to an app by `akan code --app`. */
177
+ get workspaceRoot() {
178
+ return this.#workspaceRoot;
179
+ }
180
+
181
+ /**
182
+ * The MCP servers this session was built against, and what each one published.
183
+ *
184
+ * Read from the assembly rather than from the live session: the tool allowlist is fixed when the session is
185
+ * created, so a server declared afterwards is not one this agent can reach however the file now reads.
186
+ */
187
+ mcpServers(): CodeAgentMcpStatus[] {
188
+ return this.#mcp;
189
+ }
190
+
172
191
  /** The other `akan code` sessions running in this workspace right now. */
173
192
  peers() {
174
193
  return this.#mailbox?.peers() ?? [];
@@ -383,7 +402,7 @@ export class CodeAgent {
383
402
 
384
403
  async setModel(ref: CodeAgentModelRef) {
385
404
  const session = this.#require();
386
- const model = await akanCodeModel(session.modelRegistry, ref);
405
+ const model = await akanCodeModel(session.modelRuntime, ref);
387
406
  if (!model) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
388
407
  await session.setModel(model);
389
408
  this.#emit({ type: "session", info: this.info });
@@ -54,6 +54,10 @@ export class CodeAgentStreamPrinter {
54
54
  case "queue":
55
55
  case "context":
56
56
  return;
57
+ // The rail is a live status for a screen that redraws. A stream has no row to update, and the pool
58
+ // already says on the transcript when a child starts and what it spent when it stops.
59
+ case "subagent":
60
+ return;
57
61
  case "question":
58
62
  return this.#line(chalk.yellow(`? ${event.question.prompt}`));
59
63
  case "approval":
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
2
- import { type CodeAgentProfile, codeAgentReadOnlyBuiltins } from "akanjs/common";
2
+ import { type CodeAgentProfile, type CodeAgentSubagent, codeAgentReadOnlyBuiltins } from "akanjs/common";
3
3
  import { Type } from "typebox";
4
4
  import type { Workspace } from "../../commandDecorators";
5
5
 
@@ -10,10 +10,21 @@ export interface SubagentPoolOptions {
10
10
  depth: number;
11
11
  /** Progress and refusals reach the parent's host through this rather than through a new event kind. */
12
12
  onNotice?: (message: string) => void;
13
+ /** The running children, whole, for a host that draws them as a rail beside the prompt. */
14
+ onAgents?: (agents: CodeAgentSubagent[]) => void;
13
15
  }
14
16
 
15
17
  const maxResultChars = 8_000;
16
18
 
19
+ /**
20
+ * How often the running children are re-reported while none of them starts or finishes.
21
+ *
22
+ * A child spends tokens for minutes between the two frames its lifetime would otherwise produce, and the
23
+ * number that decides whether to let it keep going is the one climbing in between. One frame a second for at
24
+ * most a handful of rows is far below what a single streaming turn already puts on the wire.
25
+ */
26
+ const tickMs = 1_000;
27
+
17
28
  export type SubagentKind = keyof typeof SubagentPool.kinds;
18
29
 
19
30
  /**
@@ -47,8 +58,14 @@ export class SubagentPool {
47
58
  static readonly defaultKind: SubagentKind = "explore";
48
59
 
49
60
  readonly #options: SubagentPoolOptions;
61
+ /** Keyed by the `task` call that opened each child. `agent` lands once it exists, and reports live tokens. */
62
+ readonly #children = new Map<
63
+ string,
64
+ { kind: SubagentKind; description: string; startedAt: number; agent?: { tokensUsed: number } }
65
+ >();
50
66
  #running = 0;
51
67
  #spent = 0;
68
+ #ticker: ReturnType<typeof setInterval> | null = null;
52
69
 
53
70
  constructor(options: SubagentPoolOptions) {
54
71
  this.#options = options;
@@ -82,19 +99,23 @@ export class SubagentPool {
82
99
  ),
83
100
  ),
84
101
  }),
85
- execute: async (_id, params, signal) => {
102
+ execute: async (id, params, signal) => {
86
103
  const kind = (params.type ?? SubagentPool.defaultKind) as SubagentKind;
87
104
  const refusal = this.#refuse();
88
105
  if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
89
106
  this.#running += 1;
107
+ this.#children.set(id, { kind, description: params.description, startedAt: Date.now() });
108
+ this.#report();
90
109
  // Said before it starts, not only when it ends: a turn that goes quiet for a minute while a child
91
110
  // reads the repo is otherwise indistinguishable from a turn that has stopped responding.
92
111
  this.#options.onNotice?.(`${kind} sub-agent · ${params.description}`);
93
112
  try {
94
- const text = await this.#run(kind, params.prompt, signal);
113
+ const text = await this.#run(id, kind, params.prompt, signal);
95
114
  return { content: [{ type: "text", text }], details: undefined };
96
115
  } finally {
97
116
  this.#running -= 1;
117
+ this.#children.delete(id);
118
+ this.#report();
98
119
  }
99
120
  },
100
121
  });
@@ -137,7 +158,35 @@ export class SubagentPool {
137
158
  };
138
159
  }
139
160
 
140
- async #run(kind: SubagentKind, prompt: string, signal: AbortSignal | undefined) {
161
+ /**
162
+ * The running children as a list, and the ticker that keeps it moving.
163
+ *
164
+ * The ticker is owned by the report rather than by the tool call: two children overlapping must not each
165
+ * start one, and the last one to finish is what stops it. It is unref'd because a pool with a child still
166
+ * registered is a bug that should end the process, not hold it open.
167
+ */
168
+ #report() {
169
+ const onAgents = this.#options.onAgents;
170
+ if (!onAgents) return;
171
+ onAgents(
172
+ [...this.#children].map(([id, child]) => ({
173
+ id,
174
+ kind: child.kind,
175
+ description: child.description,
176
+ startedAt: child.startedAt,
177
+ tokens: child.agent?.tokensUsed ?? 0,
178
+ })),
179
+ );
180
+ if (this.#children.size && !this.#ticker) {
181
+ this.#ticker = setInterval(() => this.#report(), tickMs);
182
+ this.#ticker.unref?.();
183
+ } else if (!this.#children.size && this.#ticker) {
184
+ clearInterval(this.#ticker);
185
+ this.#ticker = null;
186
+ }
187
+ }
188
+
189
+ async #run(id: string, kind: SubagentKind, prompt: string, signal: AbortSignal | undefined) {
141
190
  const { CodeAgent } = await import("./CodeAgent");
142
191
  const budget = this.#options.parent.tools.subagent;
143
192
  const child = SubagentPool.childOf(this.#options.parent, kind);
@@ -148,6 +197,8 @@ export class SubagentPool {
148
197
  apps: [],
149
198
  depth: this.#options.depth + 1,
150
199
  });
200
+ const running = this.#children.get(id);
201
+ if (running) running.agent = agent;
151
202
  const abort = () => void agent.abort();
152
203
  signal?.addEventListener("abort", abort, { once: true });
153
204
  try {
@@ -1,11 +1,11 @@
1
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  /**
4
4
  * The engine's `Model` type lives in `@earendil-works/pi-ai` and is not re-exported from the package we depend
5
5
  * on, so it is recovered from the one public function that returns it rather than by adding a dependency on a
6
6
  * transitive package whose version we do not control.
7
7
  */
8
- export type CodeAgentModel = NonNullable<ReturnType<ModelRegistry["find"]>>;
8
+ export type CodeAgentModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
9
9
 
10
10
  export interface CodeAgentModelRef {
11
11
  provider: string;
@@ -42,17 +42,17 @@ const correctInputs = (model: CodeAgentModel | undefined) => {
42
42
  * An empty API key produces a request that succeeds with no content rather than an error, so a provider with no
43
43
  * configured auth is skipped here instead of answering with nothing three seconds later.
44
44
  */
45
- export const akanCodeModel = async (registry: ModelRegistry, ref?: CodeAgentModelRef) => {
45
+ export const akanCodeModel = async (runtime: ModelRuntime, ref?: CodeAgentModelRef) => {
46
46
  if (ref) {
47
- const requested = registry.find(ref.provider, ref.id);
47
+ const requested = runtime.getModel(ref.provider, ref.id);
48
48
  if (!requested) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
49
- if (!registry.hasConfiguredAuth(requested))
49
+ if (!runtime.hasConfiguredAuth(ref.provider))
50
50
  throw new Error(`No API key configured for ${ref.provider}. Set ${ref.provider.toUpperCase()}_API_KEY.`);
51
51
  return correctInputs(requested);
52
52
  }
53
- const fallback = registry.find(akanCodeDefaultModel.provider, akanCodeDefaultModel.id);
54
- if (fallback && registry.hasConfiguredAuth(fallback)) return correctInputs(fallback);
55
- return correctInputs(registry.getAvailable()[0]);
53
+ const fallback = runtime.getModel(akanCodeDefaultModel.provider, akanCodeDefaultModel.id);
54
+ if (fallback && runtime.hasConfiguredAuth(fallback.provider)) return correctInputs(fallback);
55
+ return correctInputs((await runtime.getAvailable())[0]);
56
56
  };
57
57
 
58
58
  /** A screenshot is worth attaching only to a model that can see it — after the catalogue has been corrected. */
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
6
+
7
+ const roots: string[] = [];
8
+
9
+ const workspace = (files: Record<string, string>) => {
10
+ const root = mkdtempSync(path.join(tmpdir(), "akan-context-"));
11
+ roots.push(root);
12
+ for (const [name, content] of Object.entries(files)) {
13
+ const file = path.join(root, name);
14
+ mkdirSync(path.dirname(file), { recursive: true });
15
+ writeFileSync(file, content);
16
+ }
17
+ return root;
18
+ };
19
+
20
+ /** What the engine hands over: the one context file it took for that directory. */
21
+ const loaded = (root: string, name = "AGENTS.md"): AkanContextFile[] => [
22
+ { path: path.join(root, name), content: "the workspace guide" },
23
+ ];
24
+
25
+ const namesOf = (files: AkanContextFile[], root: string) => files.map((file) => path.relative(root, file.path));
26
+
27
+ afterEach(() => {
28
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
29
+ });
30
+
31
+ describe("workspace context files", () => {
32
+ test("a CLAUDE.md beside the AGENTS.md the engine took is loaded too", () => {
33
+ const root = workspace({ "AGENTS.md": "the workspace guide", "CLAUDE.md": "comments override" });
34
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
35
+ expect(namesOf(files, root)).toEqual(["AGENTS.md", "CLAUDE.md"]);
36
+ expect(files[1]?.content).toBe("comments override");
37
+ });
38
+
39
+ test("an AGENTS.override.md keeps the CLAUDE.md it was written to displace out", () => {
40
+ const root = workspace({ "AGENTS.override.md": "instead of that", "CLAUDE.md": "the old one" });
41
+ const files = AkanContextFiles.extend(loaded(root, "AGENTS.override.md"), { workspaceRoot: root, cwd: root });
42
+ expect(namesOf(files, root)).toEqual(["AGENTS.override.md"]);
43
+ });
44
+
45
+ test("the same guide under two names is loaded once", () => {
46
+ const root = workspace({ "AGENTS.md": "the workspace guide" });
47
+ symlinkSync(path.join(root, "AGENTS.md"), path.join(root, "CLAUDE.md"));
48
+ expect(namesOf(AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root }), root)).toEqual([
49
+ "AGENTS.md",
50
+ ]);
51
+ });
52
+
53
+ test("an always-on cursor rule is loaded without its frontmatter", () => {
54
+ const root = workspace({
55
+ ".cursor/rules/house.mdc": "---\ndescription: house style\nalwaysApply: true\n---\nnever use any\n",
56
+ });
57
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
58
+ expect(namesOf(files, root)).toEqual(["AGENTS.md", ".cursor/rules/house.mdc"]);
59
+ expect(files[1]?.content).toBe("never use any\n");
60
+ });
61
+
62
+ /** Cursor's other three kinds are conditional; applying one always is applying a rule its author scoped. */
63
+ test("a scoped cursor rule is left to cursor", () => {
64
+ const root = workspace({
65
+ ".cursor/rules/ui.mdc": "---\nglobs: ui/**/*.tsx\n---\nno inline colours\n",
66
+ ".cursor/rules/ask.mdc": "---\ndescription: for migrations\n---\nread the plan\n",
67
+ ".cursor/rules/off.mdc": "---\nalwaysApply: false\n---\nnot this one\n",
68
+ });
69
+ expect(namesOf(AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root }), root)).toEqual([
70
+ "AGENTS.md",
71
+ ]);
72
+ });
73
+
74
+ test("a rule with no frontmatter at all is unconditional, and so is .cursorrules", () => {
75
+ const root = workspace({ ".cursor/rules/plain.mdc": "just do it", ".cursorrules": "the legacy file" });
76
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
77
+ expect(namesOf(files, root).sort()).toEqual([".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort());
78
+ });
79
+
80
+ test("rules nested under the rules directory are found, in a stable order", () => {
81
+ const root = workspace({
82
+ ".cursor/rules/b.mdc": "---\nalwaysApply: true\n---\nsecond\n",
83
+ ".cursor/rules/a.mdc": "---\nalwaysApply: true\n---\nfirst\n",
84
+ ".cursor/rules/deep/c.mdc": "---\nalwaysApply: true\n---\nthird\n",
85
+ });
86
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
87
+ expect(namesOf(files, root)).toEqual([
88
+ "AGENTS.md",
89
+ ".cursor/rules/a.mdc",
90
+ ".cursor/rules/b.mdc",
91
+ ".cursor/rules/deep/c.mdc",
92
+ ]);
93
+ });
94
+
95
+ test("the session's own directory carries its rules as well as the workspace root", () => {
96
+ const root = workspace({
97
+ ".cursor/rules/root.mdc": "---\nalwaysApply: true\n---\nworkspace wide\n",
98
+ "apps/akan/.cursor/rules/app.mdc": "---\nalwaysApply: true\n---\napp only\n",
99
+ });
100
+ const cwd = path.join(root, "apps", "akan");
101
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd });
102
+ expect(namesOf(files, root)).toEqual(["AGENTS.md", ".cursor/rules/root.mdc", "apps/akan/.cursor/rules/app.mdc"]);
103
+ });
104
+
105
+ test("an empty rule file adds nothing", () => {
106
+ const root = workspace({ ".cursor/rules/empty.mdc": "---\nalwaysApply: true\n---\n\n" });
107
+ expect(AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root })).toHaveLength(1);
108
+ });
109
+
110
+ test("nothing to add leaves the list exactly as the engine built it", () => {
111
+ const root = workspace({ "AGENTS.md": "the workspace guide" });
112
+ const base = loaded(root);
113
+ expect(AkanContextFiles.extend(base, { workspaceRoot: root, cwd: root })).toEqual(base);
114
+ });
115
+ });
@@ -8,7 +8,6 @@
8
8
  * `globalThis.console`, and it has to be the first import of an RPC entry rather than a re-export of this one.
9
9
  */
10
10
 
11
- export * from "./agent/AkanAuthStorageBackend";
12
11
  export * from "./agent/AkanCodeServices";
13
12
  export * from "./agent/akanCodeModel";
14
13
  export * from "./agent/akanCodePaths";
@@ -1,5 +1,5 @@
1
1
  import type { InlineExtension } from "@earendil-works/pi-coding-agent";
2
- import type { CodeAgentProfile } from "akanjs/common";
2
+ import type { CodeAgentMcpStatus, CodeAgentProfile, CodeAgentSubagent } from "akanjs/common";
3
3
  import type { Workspace } from "../../commandDecorators";
4
4
  import type { CodeMailbox } from "../agent/CodeMailbox";
5
5
  import { SubagentPool } from "../agent/SubagentPool";
@@ -28,6 +28,7 @@ export interface AkanCodePluginsOptions {
28
28
  /** Late, because the session this announces to its peers does not exist yet when the pack is built. */
29
29
  mailbox: () => CodeMailbox | undefined;
30
30
  onNotice?: (message: string) => void;
31
+ onAgents?: (agents: CodeAgentSubagent[]) => void;
31
32
  }
32
33
 
33
34
  /**
@@ -40,6 +41,8 @@ export interface AkanCodePluginsResult {
40
41
  extensions: InlineExtension[];
41
42
  /** Names the session's tool allowlist has to carry, or the model is told these tools do not exist. */
42
43
  toolNames: string[];
44
+ /** Every declared MCP server and what it published, including the ones that published nothing. */
45
+ mcp: CodeAgentMcpStatus[];
43
46
  /** Closes anything the pack holds open — currently the MCP server connections. */
44
47
  dispose: () => void;
45
48
  }
@@ -52,6 +55,7 @@ export class AkanCodePlugins {
52
55
  ...(options.onNotice ? { onNotice: options.onNotice } : {}),
53
56
  });
54
57
  const dispose = () => mcp?.close();
58
+ const mcpStatus = mcp?.status ?? [];
55
59
  const web = new WebToolPack({ profile: options.profile });
56
60
  const webExtension = web.extension();
57
61
  const sessions = new SessionToolPack({
@@ -67,6 +71,7 @@ export class AkanCodePlugins {
67
71
  return {
68
72
  extensions: [mcp?.extension(), webExtension, sessionExtension, mailExtension].filter((entry) => !!entry),
69
73
  toolNames: [...(mcp?.toolNames ?? []), ...web.names(), ...sessions.names(), ...mail.names()],
74
+ mcp: mcpStatus,
70
75
  dispose,
71
76
  };
72
77
  const readOnly = !options.profile.tools.builtin.includes("write");
@@ -82,6 +87,7 @@ export class AkanCodePlugins {
82
87
  parent: options.profile,
83
88
  depth: options.depth,
84
89
  ...(options.onNotice ? { onNotice: options.onNotice } : {}),
90
+ ...(options.onAgents ? { onAgents: options.onAgents } : {}),
85
91
  });
86
92
  if (subagent) extensions.push(subagent);
87
93
  const sources = await AkanCodePlugins.#sources(options, readOnly);
@@ -106,6 +112,7 @@ export class AkanCodePlugins {
106
112
  ...sessions.names(),
107
113
  ...mail.names(),
108
114
  ],
115
+ mcp: mcpStatus,
109
116
  dispose,
110
117
  };
111
118
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import type { AuthStorage } from "@earendil-works/pi-coding-agent";
3
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  /**
6
6
  * Loads provider keys from the workspace's `.env` into the session, without persisting them anywhere.
@@ -12,12 +12,14 @@ import type { AuthStorage } from "@earendil-works/pi-coding-agent";
12
12
  * asked for.
13
13
  */
14
14
  export class AkanEnvKeys {
15
- static apply(authStorage: AuthStorage, workspaceRoot: string) {
15
+ static async apply(runtime: ModelRuntime, workspaceRoot: string) {
16
16
  const applied: string[] = [];
17
17
  for (const [name, value] of AkanEnvKeys.#entries(workspaceRoot)) {
18
18
  const provider = AkanEnvKeys.#providerOf(name);
19
19
  if (!provider || !value) continue;
20
- authStorage.setRuntimeApiKey(provider, value);
20
+ // Serially, not through `Promise.all`: the runtime queues credential operations against the same file,
21
+ // and a key written while another is mid-write is the one that goes missing.
22
+ await runtime.setRuntimeApiKey(provider, value);
21
23
  applied.push(provider);
22
24
  }
23
25
  return applied;
@@ -0,0 +1,113 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
4
+ import { akanCodePaths } from "../agent/akanCodePaths";
5
+
6
+ interface ServerFileEntry {
7
+ command?: string;
8
+ args?: string[];
9
+ env?: Record<string, string>;
10
+ url?: string;
11
+ type?: string;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ interface ServerFile {
16
+ mcpServers?: Record<string, ServerFileEntry>;
17
+ servers?: Record<string, ServerFileEntry>;
18
+ }
19
+
20
+ /**
21
+ * The workspace's declared MCP servers, in the shape the editors already write.
22
+ *
23
+ * `.akan/code/mcp.json` holds an `mcpServers` map — the block a developer already has in their Claude, Cursor
24
+ * or VS Code config — so the file can be pasted rather than translated. `servers` is read as well because VS
25
+ * Code spells it that way, and whichever key the file already uses is the one a write goes back into:
26
+ * rewriting it to our preferred spelling would silently break the editor sharing the file.
27
+ */
28
+ export class McpServerConfig {
29
+ static file(workspaceRoot: string) {
30
+ return akanCodePaths.mcpFile(workspaceRoot);
31
+ }
32
+
33
+ static read(workspaceRoot: string): CodeAgentMcpServerRef[] {
34
+ const entries = Object.entries(McpServerConfig.#parse(workspaceRoot).map);
35
+ return entries.filter(([, entry]) => !entry.disabled).map(([name, entry]) => McpServerConfig.#refOf(name, entry));
36
+ }
37
+
38
+ /** Every declared server including the disabled ones, which is what a listing has to show. */
39
+ static declared(workspaceRoot: string) {
40
+ return Object.entries(McpServerConfig.#parse(workspaceRoot).map).map(([name, entry]) => ({
41
+ ref: McpServerConfig.#refOf(name, entry),
42
+ disabled: !!entry.disabled,
43
+ }));
44
+ }
45
+
46
+ /**
47
+ * Declares one server, replacing an entry of the same name.
48
+ *
49
+ * A url is an http server and anything else is a command with its argv, which is the whole of the guess: a
50
+ * transport asked for as a third argument is a thing to get wrong, and the two shapes are already distinct.
51
+ */
52
+ static add(workspaceRoot: string, name: string, target: string[]) {
53
+ const [head, ...rest] = target;
54
+ if (!head) throw new Error("Give the server a command to run, or a url to reach.");
55
+ const entry: ServerFileEntry = /^https?:\/\//.test(head) ? { url: head } : { command: head, args: rest };
56
+ const { file, key, map } = McpServerConfig.#parse(workspaceRoot);
57
+ const next = { ...map, [name]: entry };
58
+ mkdirSync(path.dirname(file), { recursive: true });
59
+ writeFileSync(file, `${JSON.stringify({ [key]: next }, null, 2)}\n`);
60
+ return McpServerConfig.#refOf(name, entry);
61
+ }
62
+
63
+ static remove(workspaceRoot: string, name: string) {
64
+ const { file, key, map } = McpServerConfig.#parse(workspaceRoot);
65
+ if (!(name in map)) return false;
66
+ const next = { ...map };
67
+ delete next[name];
68
+ writeFileSync(file, `${JSON.stringify({ [key]: next }, null, 2)}\n`);
69
+ return true;
70
+ }
71
+
72
+ static #refOf(name: string, entry: ServerFileEntry): CodeAgentMcpServerRef {
73
+ return {
74
+ name,
75
+ transport: entry.url ? "http" : "stdio",
76
+ ...(entry.command ? { command: entry.command } : {}),
77
+ ...(entry.args ? { args: entry.args } : {}),
78
+ ...(entry.url ? { url: entry.url } : {}),
79
+ ...(entry.env ? { env: entry.env } : {}),
80
+ };
81
+ }
82
+
83
+ /** What a server is reached at, as one line: the argv or the url. */
84
+ static targetOf(ref: CodeAgentMcpServerRef) {
85
+ return ref.url ?? [ref.command, ...(ref.args ?? [])].filter(Boolean).join(" ");
86
+ }
87
+
88
+ /** Why the file yielded nothing, when it exists and yielded nothing — a listing has to say which. */
89
+ static problem(workspaceRoot: string) {
90
+ const file = McpServerConfig.file(workspaceRoot);
91
+ if (!existsSync(file)) return undefined;
92
+ try {
93
+ JSON.parse(readFileSync(file, "utf8"));
94
+ return undefined;
95
+ } catch (error) {
96
+ return `${file} is not valid JSON — ${String(error)}`;
97
+ }
98
+ }
99
+
100
+ static #parse(workspaceRoot: string) {
101
+ const file = McpServerConfig.file(workspaceRoot);
102
+ const empty = { file, key: "mcpServers" as const, map: {} as Record<string, ServerFileEntry> };
103
+ if (!existsSync(file)) return empty;
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as ServerFile;
106
+ if (parsed.servers && !parsed.mcpServers) return { file, key: "servers" as const, map: parsed.servers };
107
+ return { ...empty, map: parsed.mcpServers ?? {} };
108
+ } catch {
109
+ // A malformed file costs its servers, not the session; `problem` is what says so on the listing.
110
+ return empty;
111
+ }
112
+ }
113
+ }
@@ -1,9 +1,14 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
3
- import { type CodeAgentMcpServerRef, type CodeAgentProfile, codeAgentClip, codeAgentOutputChars } from "akanjs/common";
2
+ import {
3
+ type CodeAgentMcpServerRef,
4
+ type CodeAgentMcpStatus,
5
+ type CodeAgentProfile,
6
+ codeAgentClip,
7
+ codeAgentOutputChars,
8
+ } from "akanjs/common";
4
9
  import { Type } from "typebox";
5
- import { akanCodePaths } from "../agent/akanCodePaths";
6
10
  import { McpClient, type McpToolInfo } from "./McpClient";
11
+ import { McpServerConfig } from "./McpServerConfig";
7
12
 
8
13
  export interface McpToolPackOptions {
9
14
  workspaceRoot: string;
@@ -11,15 +16,6 @@ export interface McpToolPackOptions {
11
16
  onNotice?: (message: string) => void;
12
17
  }
13
18
 
14
- interface ServerFileEntry {
15
- command?: string;
16
- args?: string[];
17
- env?: Record<string, string>;
18
- url?: string;
19
- type?: string;
20
- disabled?: boolean;
21
- }
22
-
23
19
  /**
24
20
  * Connects the workspace's MCP servers and publishes their tools.
25
21
  *
@@ -30,31 +26,37 @@ interface ServerFileEntry {
30
26
  * and a collision that silently shadows one of them is invisible from the model's side.
31
27
  */
32
28
  export class McpToolPack {
33
- readonly #options: McpToolPackOptions;
34
29
  readonly #clients: { client: McpClient; tools: McpToolInfo[] }[] = [];
30
+ readonly #status: CodeAgentMcpStatus[] = [];
35
31
 
36
- private constructor(options: McpToolPackOptions) {
37
- this.#options = options;
38
- }
32
+ /** Only {@link connect} may build one: a pack whose servers were never reached publishes broken tools. */
33
+ private constructor() {}
39
34
 
40
35
  /**
41
36
  * Connects every declared server before the session exists, because the session's tool allowlist has to name
42
37
  * these tools and their names are only knowable from a live `tools/list`.
38
+ *
39
+ * A pack comes back whenever anything was declared, including when every server failed: what went wrong is
40
+ * the answer `/mcp` exists to give, and a pack that dissolves on failure has nowhere to keep it.
43
41
  */
44
42
  static async connect(options: McpToolPackOptions) {
45
43
  const refs = McpToolPack.#refs(options);
46
44
  if (!refs.length) return undefined;
47
- const pack = new McpToolPack(options);
45
+ const pack = new McpToolPack();
48
46
  for (const ref of refs) {
47
+ const base = { name: ref.name, transport: ref.transport, target: McpServerConfig.targetOf(ref) };
49
48
  try {
50
49
  const client = await new McpClient(ref).connect();
51
- pack.#clients.push({ client, tools: await client.listTools() });
50
+ const tools = await client.listTools();
51
+ pack.#clients.push({ client, tools });
52
+ pack.#status.push({ ...base, tools: tools.map((tool) => McpToolPack.#nameOf(ref.name, tool.name)) });
52
53
  } catch (error) {
53
54
  // One unreachable integration must not cost the agent its turn, so it costs only its own tools.
54
55
  options.onNotice?.(`MCP server "${ref.name}" is unavailable: ${String(error)}`);
56
+ pack.#status.push({ ...base, tools: [], error: String(error) });
55
57
  }
56
58
  }
57
- return pack.#clients.length ? pack : undefined;
59
+ return pack;
58
60
  }
59
61
 
60
62
  get toolNames() {
@@ -63,6 +65,11 @@ export class McpToolPack {
63
65
  );
64
66
  }
65
67
 
68
+ /** What each declared server turned out to be, for a host that offers to show it. */
69
+ get status(): CodeAgentMcpStatus[] {
70
+ return this.#status;
71
+ }
72
+
66
73
  extension(): InlineExtension {
67
74
  return { name: "akan-mcp", factory: (pi: ExtensionAPI) => this.#register(pi) };
68
75
  }
@@ -102,33 +109,6 @@ export class McpToolPack {
102
109
  static #refs(options: McpToolPackOptions): CodeAgentMcpServerRef[] {
103
110
  if (options.profile.tools.mcp === "off") return [];
104
111
  const declared = Array.isArray(options.profile.tools.mcp) ? options.profile.tools.mcp : [];
105
- return [...McpToolPack.#fromFile(options.workspaceRoot), ...declared];
106
- }
107
-
108
- static #fromFile(workspaceRoot: string): CodeAgentMcpServerRef[] {
109
- const file = akanCodePaths.mcpFile(workspaceRoot);
110
- if (!existsSync(file)) return [];
111
- try {
112
- const parsed = JSON.parse(readFileSync(file, "utf8")) as {
113
- mcpServers?: Record<string, ServerFileEntry>;
114
- servers?: Record<string, ServerFileEntry>;
115
- };
116
- const entries = Object.entries(parsed.mcpServers ?? parsed.servers ?? {});
117
- return entries
118
- .filter(([, entry]) => !entry.disabled)
119
- .map(([name, entry]): CodeAgentMcpServerRef => {
120
- const transport = entry.url ? "http" : "stdio";
121
- return {
122
- name,
123
- transport,
124
- ...(entry.command ? { command: entry.command } : {}),
125
- ...(entry.args ? { args: entry.args } : {}),
126
- ...(entry.url ? { url: entry.url } : {}),
127
- ...(entry.env ? { env: entry.env } : {}),
128
- };
129
- });
130
- } catch {
131
- return [];
132
- }
112
+ return [...McpServerConfig.read(options.workspaceRoot), ...declared];
133
113
  }
134
114
  }
@@ -231,8 +231,13 @@ describe("AkanEnvKeys", () => {
231
231
  tempRoots.push(root);
232
232
  await writeText(path.join(root, ".env"), '# comment\nDEEPSEEK_API_KEY="sk-test"\nSOMETHING_ELSE=1\n');
233
233
  const applied: [string, string][] = [];
234
- const fake = { setRuntimeApiKey: (provider: string, key: string) => applied.push([provider, key]) };
235
- const providers = AkanEnvKeys.apply(fake as never, root);
234
+ const fake = {
235
+ setRuntimeApiKey: (provider: string, key: string) => {
236
+ applied.push([provider, key]);
237
+ return Promise.resolve();
238
+ },
239
+ };
240
+ const providers = await AkanEnvKeys.apply(fake as never, root);
236
241
  expect(providers).toContain("deepseek");
237
242
  expect(applied).toContainEqual(["deepseek", "sk-test"]);
238
243
  });
@@ -296,6 +301,29 @@ describe("SubagentPool", () => {
296
301
  expect(reader.tools.builtin).not.toContain("bash");
297
302
  });
298
303
 
304
+ /**
305
+ * A refused call opens no child, so it must leave no row behind either — a rail that shows a sub-agent the
306
+ * pool never started reads as work in flight, and the budget it was refused over says the opposite.
307
+ */
308
+ test("a refusal reports no running sub-agent", async () => {
309
+ const parent = codeAgentPresets.local("/tmp/akan");
310
+ const spent = { ...parent, tools: { ...parent.tools, subagent: { maxConcurrent: 0, maxDepth: 2, budget: 0 } } };
311
+ const frames: unknown[][] = [];
312
+ const extension = SubagentPool.extensionFor({
313
+ workspace,
314
+ cwd: "/tmp/akan",
315
+ parent: spent,
316
+ depth: 0,
317
+ onAgents: (agents) => frames.push(agents),
318
+ });
319
+ const tools: { execute: (id: string, params: never) => Promise<{ isError?: boolean }> }[] = [];
320
+ if (extension && "factory" in extension)
321
+ extension.factory({ registerTool: (spec: never) => tools.push(spec) } as never);
322
+ const result = await tools[0]?.execute("c0", { description: "d", prompt: "p" } as never);
323
+ expect(result?.isError).toBe(true);
324
+ expect(frames).toEqual([]);
325
+ });
326
+
299
327
  /** Nothing a sub-agent does belongs in the workspace's session tree, and nobody is there to answer it. */
300
328
  test("a child keeps no session and cannot ask a human", () => {
301
329
  const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
@@ -367,8 +395,13 @@ for await (const line of console) {
367
395
  profile: codeAgentPresets.local(root),
368
396
  onNotice: (message) => notices.push(message),
369
397
  });
370
- expect(pack).toBeUndefined();
398
+ expect(pack?.toolNames).toEqual([]);
371
399
  expect(notices.join(" ")).toContain("broken");
400
+ // The pack survives the failure so `/mcp` can say what went wrong; a pack that dissolved would leave the
401
+ // server indistinguishable from one nobody declared.
402
+ expect(pack?.status).toEqual([
403
+ { name: "broken", transport: "stdio", target: "/nonexistent/binary", tools: [], error: expect.any(String) },
404
+ ]);
372
405
  });
373
406
 
374
407
  test("declares nothing when the profile turns MCP off", async () => {
@@ -0,0 +1,111 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { McpServerConfig } from "./McpServerConfig";
6
+
7
+ const roots: string[] = [];
8
+
9
+ const workspace = (file?: string) => {
10
+ const root = mkdtempSync(path.join(tmpdir(), "akan-mcp-"));
11
+ roots.push(root);
12
+ if (file === undefined) return root;
13
+ const target = McpServerConfig.file(root);
14
+ mkdirSync(path.dirname(target), { recursive: true });
15
+ writeFileSync(target, file);
16
+ return root;
17
+ };
18
+
19
+ const written = (root: string) =>
20
+ JSON.parse(readFileSync(McpServerConfig.file(root), "utf8")) as Record<string, object>;
21
+
22
+ afterEach(() => {
23
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("MCP server config", () => {
27
+ test("an editor's own block is read as it is written", () => {
28
+ const root = workspace(
29
+ JSON.stringify({
30
+ mcpServers: {
31
+ github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
32
+ linear: { url: "https://mcp.linear.app/sse" },
33
+ },
34
+ }),
35
+ );
36
+ expect(McpServerConfig.read(root)).toEqual([
37
+ { name: "github", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
38
+ { name: "linear", transport: "http", url: "https://mcp.linear.app/sse" },
39
+ ]);
40
+ });
41
+
42
+ test("a disabled server is declared but never connected", () => {
43
+ const root = workspace(JSON.stringify({ mcpServers: { off: { command: "x", disabled: true } } }));
44
+ expect(McpServerConfig.read(root)).toEqual([]);
45
+ expect(McpServerConfig.declared(root)).toEqual([
46
+ { ref: { name: "off", transport: "stdio", command: "x" }, disabled: true },
47
+ ]);
48
+ });
49
+
50
+ test("a url is an http server and anything else is a command with its argv", () => {
51
+ const root = workspace();
52
+ expect(McpServerConfig.add(root, "linear", ["https://mcp.linear.app/sse"])).toEqual({
53
+ name: "linear",
54
+ transport: "http",
55
+ url: "https://mcp.linear.app/sse",
56
+ });
57
+ expect(McpServerConfig.add(root, "github", ["npx", "-y", "server-github"])).toEqual({
58
+ name: "github",
59
+ transport: "stdio",
60
+ command: "npx",
61
+ args: ["-y", "server-github"],
62
+ });
63
+ expect(Object.keys(written(root).mcpServers ?? {})).toEqual(["linear", "github"]);
64
+ });
65
+
66
+ /** The file is shared with whatever editor wrote it, so the key it already uses is the key a write goes into. */
67
+ test("a VS Code file keeps its own spelling through a write", () => {
68
+ const root = workspace(JSON.stringify({ servers: { a: { command: "a" } } }));
69
+ McpServerConfig.add(root, "b", ["b"]);
70
+ const file = written(root);
71
+ expect(Object.keys(file)).toEqual(["servers"]);
72
+ expect(Object.keys(file.servers ?? {})).toEqual(["a", "b"]);
73
+ });
74
+
75
+ test("adding the same name twice replaces the entry rather than doubling it", () => {
76
+ const root = workspace();
77
+ McpServerConfig.add(root, "a", ["old"]);
78
+ McpServerConfig.add(root, "a", ["new", "--flag"]);
79
+ expect(McpServerConfig.read(root)).toEqual([{ name: "a", transport: "stdio", command: "new", args: ["--flag"] }]);
80
+ });
81
+
82
+ test("removing says whether it removed anything", () => {
83
+ const root = workspace(JSON.stringify({ mcpServers: { a: { command: "a" } } }));
84
+ expect(McpServerConfig.remove(root, "nope")).toBe(false);
85
+ expect(McpServerConfig.remove(root, "a")).toBe(true);
86
+ expect(McpServerConfig.read(root)).toEqual([]);
87
+ });
88
+
89
+ test("a server with nothing to reach it by is refused", () => {
90
+ expect(() => McpServerConfig.add(workspace(), "a", [])).toThrow();
91
+ });
92
+
93
+ test("a malformed file costs its servers and is named rather than thrown", () => {
94
+ const root = workspace("{ not json");
95
+ expect(McpServerConfig.read(root)).toEqual([]);
96
+ expect(McpServerConfig.problem(root)).toContain("not valid JSON");
97
+ });
98
+
99
+ test("no file at all is not a problem", () => {
100
+ const root = workspace();
101
+ expect(McpServerConfig.read(root)).toEqual([]);
102
+ expect(McpServerConfig.problem(root)).toBeUndefined();
103
+ });
104
+
105
+ test("a server reads back as the one line it is reached at", () => {
106
+ expect(McpServerConfig.targetOf({ name: "a", transport: "stdio", command: "npx", args: ["-y", "x"] })).toBe(
107
+ "npx -y x",
108
+ );
109
+ expect(McpServerConfig.targetOf({ name: "a", transport: "http", url: "https://x" })).toBe("https://x");
110
+ });
111
+ });
package/executors.ts CHANGED
@@ -1832,6 +1832,11 @@ export class PkgExecutor extends Executor {
1832
1832
  };
1833
1833
  const rootVersion = rootDeps[dep];
1834
1834
  if (rootVersion) return rootVersion;
1835
+ // A transitive dependency the workspace pins rather than imports is only ever written as an override, and
1836
+ // that pin has to reach the published package: neither an override nor a dependency's own shrinkwrap is
1837
+ // applied to a consumer's install, so the version has to be a dependency of what we publish.
1838
+ const overrideVersion = rootPackageJson.overrides?.[dep];
1839
+ if (typeof overrideVersion === "string") return overrideVersion;
1835
1840
 
1836
1841
  try {
1837
1842
  const packageJsonPath = `pkgs/${dep}/package.json`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.15",
3
+ "version": "3.0.0-beta.17",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -39,11 +39,16 @@
39
39
  "./*": "./*.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@earendil-works/pi-coding-agent": "0.80.6",
42
+ "@earendil-works/chord": "0.86.1",
43
+ "@earendil-works/pi-agent-core": "0.86.1",
44
+ "@earendil-works/pi-ai": "0.86.1",
45
+ "@earendil-works/pi-coding-agent": "0.86.1",
46
+ "@earendil-works/pi-telemetry": "0.86.1",
47
+ "@earendil-works/pi-tui": "0.86.1",
43
48
  "@inquirer/prompts": "^8.4.3",
44
49
  "@tailwindcss/node": "^4.3.0",
45
50
  "@trapezedev/project": "^7.1.4",
46
- "akanjs": "3.0.0-beta.15",
51
+ "akanjs": "3.0.0-beta.17",
47
52
  "chalk": "^5.6.2",
48
53
  "commander": "^14.0.3",
49
54
  "dayjs": "^1.11.20",
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import type { PackageJson } from "@akanjs/devkit/types";
4
+
5
+ // Guards the published dependency closure of the code agent, which the monorepo cannot exercise on its own.
6
+ //
7
+ // `@earendil-works/pi-coding-agent` asks for `^0.80.6` of its three sibling packages and ships an
8
+ // `npm-shrinkwrap.json` pinning them to the exact version it was built against. Bun reads neither that file nor
9
+ // this workspace's `overrides` when somebody installs the published CLI, so `^0.80.6` resolved to the newest
10
+ // 0.80.x — 0.80.10, which dropped `getOAuthApiKey` from `pi-ai/oauth` while the pinned coding agent still
11
+ // imports it. A global install died on its first import:
12
+ //
13
+ // Export named 'getOAuthApiKey' not found in module '.../@earendil-works/pi-ai/dist/oauth.js'.
14
+ //
15
+ // `PackageRunner` therefore re-declares pi's own pins as dependencies of what we publish; this keeps the two
16
+ // equal, so bumping the coding agent without moving the pins fails here rather than at a user's first run.
17
+
18
+ const repoRoot = path.resolve(import.meta.dir, "../../..");
19
+ const piName = "@earendil-works/pi-coding-agent";
20
+ const piDir = `${repoRoot}/node_modules/${piName}`;
21
+ const publishedPackages = ["pkgs/@akanjs/devkit/package.json", "pkgs/@akanjs/cli/package.json"];
22
+
23
+ interface Shrinkwrap {
24
+ packages: Record<string, { version?: string }>;
25
+ }
26
+
27
+ const readJson = async <T>(file: string) => (await Bun.file(file).json()) as T;
28
+
29
+ const shrinkwrappedPins = async () => {
30
+ const { packages } = await readJson<Shrinkwrap>(`${piDir}/npm-shrinkwrap.json`);
31
+ // Top-level entries only: a key with a second `node_modules/` in it is a *nested placement* of some other
32
+ // package's dependency, which a consumer's resolver reaches on its own and we have no business pinning.
33
+ const entries = Object.entries(packages)
34
+ .filter(([key]) => /^node_modules\/@earendil-works\/[^/]+$/.test(key))
35
+ .map(([key, entry]) => [key.replace(/^node_modules\//, ""), entry.version ?? ""] as const);
36
+ return Object.fromEntries(entries);
37
+ };
38
+
39
+ describe("pi dependency pins", () => {
40
+ test("the shrinkwrap read here belongs to the coding agent we publish", async () => {
41
+ const installed = await readJson<PackageJson>(`${piDir}/package.json`);
42
+ for (const file of publishedPackages) {
43
+ const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
44
+ expect({ file, [piName]: dependencies[piName] }).toEqual({ file, [piName]: installed.version });
45
+ }
46
+ });
47
+
48
+ test("every sibling pi package is published at the version the coding agent shrinkwrapped", async () => {
49
+ const pins = await shrinkwrappedPins();
50
+ expect(Object.keys(pins).length).toBeGreaterThan(0);
51
+ for (const file of publishedPackages) {
52
+ const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
53
+ const declared = Object.fromEntries(Object.keys(pins).map((name) => [name, dependencies[name]]));
54
+ expect({ file, ...declared }).toEqual({ file, ...pins });
55
+ }
56
+ });
57
+ });
package/types.ts CHANGED
@@ -10,6 +10,7 @@ export interface PackageJson {
10
10
  peerDependencies?: Record<string, string>;
11
11
  peerDependenciesMeta?: Record<string, { optional?: boolean }>;
12
12
  optionalDependencies?: Record<string, string>;
13
+ overrides?: Record<string, string>;
13
14
  engines?: Record<string, string>;
14
15
  exports?: Record<string, string | Record<string, string>>;
15
16
  bun?: {
@@ -1,105 +0,0 @@
1
- import {
2
- closeSync,
3
- existsSync,
4
- mkdirSync,
5
- openSync,
6
- readFileSync,
7
- renameSync,
8
- unlinkSync,
9
- writeFileSync,
10
- } from "node:fs";
11
- import path from "node:path";
12
- import type { AuthStorageBackend } from "@earendil-works/pi-coding-agent";
13
-
14
- /**
15
- * Credential store for `akan code`, in akan's own file rather than the engine's.
16
- *
17
- * The engine only asks for a locked read-modify-write over one opaque string, so satisfying its interface with
18
- * our own file keeps every key under `~/.akan/` — nothing lands in `~/.pi/` and a user who removes akan takes
19
- * their credentials with them.
20
- *
21
- * The lock is an exclusive-create sentinel rather than an advisory flock: two `akan code` processes refreshing
22
- * an OAuth token at the same moment would otherwise each write a token the other invalidates.
23
- */
24
- export class AkanAuthStorageBackend implements AuthStorageBackend {
25
- readonly #authPath: string;
26
- readonly #lockPath: string;
27
-
28
- constructor(authPath: string) {
29
- this.#authPath = authPath;
30
- this.#lockPath = `${authPath}.lock`;
31
- }
32
-
33
- withLock<T>(fn: (current: string | undefined) => { result: T; next?: string }) {
34
- this.#acquire();
35
- try {
36
- const { result, next } = fn(this.#read());
37
- if (next !== undefined) this.#write(next);
38
- return result;
39
- } finally {
40
- this.#release();
41
- }
42
- }
43
-
44
- async withLockAsync<T>(fn: (current: string | undefined) => Promise<{ result: T; next?: string }>) {
45
- this.#acquire();
46
- try {
47
- const { result, next } = await fn(this.#read());
48
- if (next !== undefined) this.#write(next);
49
- return result;
50
- } finally {
51
- this.#release();
52
- }
53
- }
54
-
55
- #read() {
56
- if (!existsSync(this.#authPath)) return undefined;
57
- const text = readFileSync(this.#authPath, "utf8");
58
- return text.trim() ? text : undefined;
59
- }
60
-
61
- #write(next: string) {
62
- mkdirSync(path.dirname(this.#authPath), { recursive: true, mode: 0o700 });
63
- const temp = `${this.#authPath}.${process.pid}.tmp`;
64
- writeFileSync(temp, next, { mode: 0o600 });
65
- renameSync(temp, this.#authPath);
66
- }
67
-
68
- #acquire() {
69
- mkdirSync(path.dirname(this.#lockPath), { recursive: true, mode: 0o700 });
70
- const deadline = Date.now() + 5_000;
71
- for (;;) {
72
- try {
73
- closeSync(openSync(this.#lockPath, "wx"));
74
- return;
75
- } catch {
76
- // A lock left behind by a killed process would block every later run, so it expires.
77
- if (this.#lockIsStale() || Date.now() > deadline) {
78
- try {
79
- unlinkSync(this.#lockPath);
80
- } catch {
81
- // Another process won the race to clear it; retry the create.
82
- }
83
- continue;
84
- }
85
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
86
- }
87
- }
88
- }
89
-
90
- #lockIsStale() {
91
- try {
92
- return Date.now() - Bun.file(this.#lockPath).lastModified > 30_000;
93
- } catch {
94
- return true;
95
- }
96
- }
97
-
98
- #release() {
99
- try {
100
- unlinkSync(this.#lockPath);
101
- } catch {
102
- // Already gone — a stale-lock sweep by another process.
103
- }
104
- }
105
- }