@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.
- package/codeAgent/agent/AkanCodeServices.ts +27 -18
- package/codeAgent/agent/AkanContextFiles.ts +117 -0
- package/codeAgent/agent/CodeAgent.ts +25 -6
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +55 -4
- package/codeAgent/agent/akanCodeModel.ts +8 -8
- package/codeAgent/agent/akanContextFiles.test.ts +115 -0
- package/codeAgent/index.ts +0 -1
- package/codeAgent/tools/AkanCodePlugins.ts +8 -1
- package/codeAgent/tools/AkanEnvKeys.ts +5 -3
- package/codeAgent/tools/McpServerConfig.ts +113 -0
- package/codeAgent/tools/McpToolPack.ts +27 -47
- package/codeAgent/tools/akanCodePlugins.test.ts +36 -3
- package/codeAgent/tools/mcpServerConfig.test.ts +111 -0
- package/executors.ts +5 -0
- package/package.json +8 -3
- package/piDependencyPins.test.ts +57 -0
- package/types.ts +1 -0
- package/codeAgent/agent/AkanAuthStorageBackend.ts +0 -105
|
@@ -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 {
|
|
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
|
|
28
|
-
* measured: with all
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
106
|
-
const
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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<
|
|
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 (
|
|
45
|
+
export const akanCodeModel = async (runtime: ModelRuntime, ref?: CodeAgentModelRef) => {
|
|
46
46
|
if (ref) {
|
|
47
|
-
const requested =
|
|
47
|
+
const requested = runtime.getModel(ref.provider, ref.id);
|
|
48
48
|
if (!requested) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
|
|
49
|
-
if (!
|
|
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 =
|
|
54
|
-
if (fallback &&
|
|
55
|
-
return correctInputs(
|
|
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
|
+
});
|
package/codeAgent/index.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 [...
|
|
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 = {
|
|
235
|
-
|
|
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).
|
|
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.
|
|
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/
|
|
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.
|
|
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
|
-
}
|