@akanjs/devkit 3.0.0-beta.16 → 3.0.0-beta.18
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/abstractDoc.ts +1 -3
- package/applicationBuildRunner.ts +4 -3
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +26 -0
- package/capacitorApp.ts +25 -4
- package/codeAgent/agent/AkanCodeServices.ts +43 -27
- package/codeAgent/agent/AkanContextFiles.ts +117 -0
- package/codeAgent/agent/CodeAgent.ts +87 -19
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +55 -4
- package/codeAgent/agent/akanCodeModel.ts +8 -8
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +115 -0
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/index.ts +0 -1
- package/codeAgent/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +26 -3
- package/codeAgent/tools/AkanEnvKeys.ts +5 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +148 -0
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +42 -49
- package/codeAgent/tools/akanCodePlugins.test.ts +36 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +320 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +205 -0
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/executors.test.ts +10 -46
- package/index.ts +0 -1
- package/package.json +8 -6
- package/piDependencyPins.test.ts +4 -2
- package/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/workflow/executor.ts +52 -0
- package/workflow/source.ts +19 -13
- package/codeAgent/agent/AkanAuthStorageBackend.ts +0 -105
- package/guideline.ts +0 -19
|
@@ -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. */
|
|
@@ -12,6 +12,15 @@ const builtinSkillsDir = () => {
|
|
|
12
12
|
return candidates.find((candidate) => existsSync(candidate));
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Where `akan code` keeps what belongs to the person rather than to the repo.
|
|
17
|
+
*
|
|
18
|
+
* `AKAN_CODE_HOME` moves the whole set. A container that mounts no home directory, a CI job that must not
|
|
19
|
+
* write to one, and a second checkout that wants its own credentials all need the same thing, and moving the
|
|
20
|
+
* files apart would let a `models.json` and the `auth.json` it names drift into different directories.
|
|
21
|
+
*/
|
|
22
|
+
const globalDir = () => process.env.AKAN_CODE_HOME ?? path.join(homedir(), ".akan", "code");
|
|
23
|
+
|
|
15
24
|
/**
|
|
16
25
|
* Sessions live in the workspace — they are project history, and a second checkout of the same repo is a
|
|
17
26
|
* different project. Credentials live in the home directory instead: a repo directory is the one place a key
|
|
@@ -25,7 +34,18 @@ export const akanCodePaths = {
|
|
|
25
34
|
mailDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mail"),
|
|
26
35
|
builtinSkillsDir,
|
|
27
36
|
mcpFile: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mcp.json"),
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The MCP servers that belong to the person rather than to one checkout.
|
|
39
|
+
*
|
|
40
|
+
* A server is an integration with an account — the credential for it is already global, in `mcpAuth.json` —
|
|
41
|
+
* so declaring it per repo means declaring it again in every repo and signing in again in each. The
|
|
42
|
+
* workspace file stays, for a server that is genuinely this repo's, and wins on a name they share.
|
|
43
|
+
*/
|
|
44
|
+
globalMcpFile: () => path.join(globalDir(), "mcp.json"),
|
|
45
|
+
globalSkillsDir: () => path.join(globalDir(), "skills"),
|
|
46
|
+
globalDir,
|
|
47
|
+
authFile: () => path.join(globalDir(), "auth.json"),
|
|
48
|
+
/** MCP bearer tokens, beside the provider keys and for the same reason: never in a directory that is shared. */
|
|
49
|
+
mcpAuthFile: () => path.join(globalDir(), "mcpAuth.json"),
|
|
50
|
+
modelsFile: () => path.join(globalDir(), "models.json"),
|
|
31
51
|
} as const;
|
|
@@ -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
|
+
});
|
|
@@ -14,6 +14,7 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
14
14
|
"- Prefer an akan workflow to a direct source edit. `list_workflows` / `explain_workflow` / `plan_workflow`, then `apply_workflow({ planPath })`. Editing source by hand is the fallback, not the default.",
|
|
15
15
|
"- Read context with `inspect_akan_context` before reading source bodies; it answers most structural questions without spending the file.",
|
|
16
16
|
"- Fetch the guideline for an area before a deep pass on it: `get_guideline`.",
|
|
17
|
+
"- A question about `akan code` itself — its slash commands, MCP servers, sessions, models, keys, or where its files live — is answered by the `akan-code` skill. Read it rather than describing the tool from memory.",
|
|
17
18
|
"- Never hand-edit a generated file (`lib/cnst.ts`, `db.ts`, `st.ts`, any `*/index.ts` barrel). Fix the owning source and run `repair_generated` or `akan sync`.",
|
|
18
19
|
];
|
|
19
20
|
if (profile.tools.builtin.includes("write"))
|
|
@@ -28,6 +29,10 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
28
29
|
lines.push(
|
|
29
30
|
'- Hand a self-contained search or read to `task` — `type: "explore"` reads and reports, `type: "code"` makes the change. Only its answer comes back, so give it everything it needs and ask for exactly what you want returned. Do it yourself when the reading is short or when you need the files in front of you.',
|
|
30
31
|
);
|
|
32
|
+
if (profile.ui.canPrompt)
|
|
33
|
+
lines.push(
|
|
34
|
+
"- Ask with `ask_user` when two readings of the request would lead to materially different work, and offer the options you can name. Do not ask for a choice that has an obvious default, and do not ask permission for work you were already asked to do — make the call, say the assumption, and carry on.",
|
|
35
|
+
);
|
|
31
36
|
if (profile.approval !== "never")
|
|
32
37
|
lines.push(
|
|
33
38
|
`- Some tool calls need the user's approval (policy: ${profile.approval}). A refusal is an answer; do not retry it a different way.`,
|
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";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-code
|
|
3
|
+
description: "Answer a question about `akan code` itself — how to add or remove an MCP server, sign one in, switch model, fork or resume a session, what the slash commands and keys are, and where its files live. Read this before telling anyone how to use this tool."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# akan code — the manual
|
|
7
|
+
|
|
8
|
+
This is the tool the user is talking to right now. When they ask how to do something *with it* — rather than
|
|
9
|
+
with their code — answer from here, and quote the exact gesture: the slash command to type, or the file to
|
|
10
|
+
edit. Do not guess a command; everything this tool offers is listed below.
|
|
11
|
+
|
|
12
|
+
You cannot type a slash command yourself. They are the host's, not yours — say which one to type. You *can*
|
|
13
|
+
edit the files named below, which is the useful half of most of these answers: write the change, then say
|
|
14
|
+
which command makes it take effect.
|
|
15
|
+
|
|
16
|
+
## MCP servers
|
|
17
|
+
|
|
18
|
+
Declared in **`.akan/code/mcp.json`**, in the same `mcpServers` shape Claude, Cursor and VS Code already
|
|
19
|
+
write, so a block can be pasted across rather than translated. VS Code's `servers` spelling is read too, and
|
|
20
|
+
whichever key the file already uses is the one a write goes back into.
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] },
|
|
26
|
+
"linear": { "url": "https://mcp.linear.app/sse" },
|
|
27
|
+
"internal": { "url": "https://…/mcp", "headers": { "x-api-key": "…" } },
|
|
28
|
+
"old": { "command": "x", "disabled": true }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A `url` is an http server; anything else is a command with its argv. `env` passes variables to a stdio
|
|
34
|
+
server, `headers` goes on every http request (a server behind a static token needs nothing else), and
|
|
35
|
+
`disabled: true` keeps an entry without connecting it.
|
|
36
|
+
|
|
37
|
+
From the prompt:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
/mcp what is declared, what connected, and how many tools each gave
|
|
41
|
+
/mcp <name> the tools that one server published
|
|
42
|
+
/mcp add <name> <command> [args…]
|
|
43
|
+
/mcp add <name> <https://…>
|
|
44
|
+
/mcp remove <name>
|
|
45
|
+
/mcp login <name> OAuth, in the browser
|
|
46
|
+
/mcp logout <name> forget the token, leaving the server declared
|
|
47
|
+
/mcp reload reopen this session so the file takes effect
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**A server added now is not reachable until the session reloads.** The tool allowlist is fixed when the
|
|
51
|
+
session is created, so `add`, `remove`, `login` and `logout` all reload by themselves — a session that has
|
|
52
|
+
said nothing yet has nothing on disk to reopen, and says so. If you edit `mcp.json` yourself, tell the user to
|
|
53
|
+
run `/mcp reload`.
|
|
54
|
+
|
|
55
|
+
Sign-in is discovered, not configured: the server's `401` names its resource metadata, that names the
|
|
56
|
+
authorization server, and most hosted servers register a client on demand — so a url is usually the whole
|
|
57
|
+
entry. For a provider that issues client ids by hand, add `"oauth": { "clientId": "…", "scope": "…" }`.
|
|
58
|
+
Tokens are stored per server in `~/.akan/code/mcpAuth.json` at mode 0600, never in the repo, and refreshed
|
|
59
|
+
silently; `/mcp` shows `sign-in needed` when a server wants one.
|
|
60
|
+
|
|
61
|
+
## Sessions
|
|
62
|
+
|
|
63
|
+
Stored under `.akan/code/sessions` — project history, so a second checkout of the same repo has its own.
|
|
64
|
+
|
|
65
|
+
- `←` at the start of the prompt lists past sessions; enter reopens the chosen one.
|
|
66
|
+
- `akan code --resume <id>` (`-R`) reopens one from the shell.
|
|
67
|
+
- `/fork [name]` continues the conversation twice: the copy takes over here, and the notice names the command
|
|
68
|
+
that reopens the original.
|
|
69
|
+
- `/name <text>` names this one, which is what the session list is read by.
|
|
70
|
+
- `/peers` lists other `akan code` sessions running in this workspace; `/msg <peer> <text>` hands one a
|
|
71
|
+
message.
|
|
72
|
+
- `/clear` wipes the screen and keeps the conversation; `/compact [notes]` shortens what the model carries.
|
|
73
|
+
|
|
74
|
+
## Model, effort, keys
|
|
75
|
+
|
|
76
|
+
- `/model <provider>/<id>`, e.g. `/model deepseek/deepseek-v4-pro`. `/effort <level>` for how hard it thinks.
|
|
77
|
+
- A provider is authorized by **`<PROVIDER>_API_KEY`** — `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`. The
|
|
78
|
+
workspace's own `.env` is read as well as the environment, and a key found there is used for the run and
|
|
79
|
+
never copied anywhere.
|
|
80
|
+
- Credentials and the model catalogue live in `~/.akan/code/` (`auth.json`, `models.json`). **`AKAN_CODE_HOME`
|
|
81
|
+
moves the whole directory** — for a container with no home, or a checkout that wants its own.
|
|
82
|
+
|
|
83
|
+
## What the agent reads, and how to change it
|
|
84
|
+
|
|
85
|
+
- Every `AGENTS.md` / `CLAUDE.md` from the workspace root down to the working directory, `.cursorrules`, and
|
|
86
|
+
any `.cursor/rules/*.mdc` that applies unconditionally (`alwaysApply: true`, or no frontmatter at all).
|
|
87
|
+
Edit those to change how this agent behaves in this repo — they are the files the editor beside it reads.
|
|
88
|
+
- Skills: drop a `<name>/SKILL.md` into `.akan/code/skills`. Extensions: `.akan/code/extensions`. Neither is
|
|
89
|
+
read from a home directory, so a session behaves the same in two checkouts.
|
|
90
|
+
|
|
91
|
+
## Keys
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
enter send · shift+enter newline · ↑↓ move (history at the edges) · ←→ move
|
|
95
|
+
esc interrupt · pgup/pgdn scroll · ^c quit · tab completes a slash command
|
|
96
|
+
@ completes a file in this repo · wheel scrolls the transcript (hold shift to select text instead)
|
|
97
|
+
cmd+v attaches an image from the clipboard · ^v does too, without the terminal in the way
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Most terminals send the same byte for enter and shift+enter, so the key only arrives where the terminal can
|
|
101
|
+
say which was pressed. In VS Code, bind it with `workbench.action.terminal.sendSequence` and a `text` of
|
|
102
|
+
`"\\\u001b\r"`; iTerm2 calls the same thing "Send Text". Either spelling of that sequence works here, and
|
|
103
|
+
the backslash a shell would read as a line continuation never reaches the prompt.
|
|
104
|
+
|
|
105
|
+
## Starting it
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
akan code open the session
|
|
109
|
+
akan code "<prompt>" run one turn and print the answer
|
|
110
|
+
akan code -i "<prompt>" open the session with the prompt already sent
|
|
111
|
+
--app <name> narrow the agent to one app
|
|
112
|
+
--profile local|pod|review|web what it may do (default local)
|
|
113
|
+
--model <provider>/<id> --thinking, --json, --rpc, --resume <id>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`review` reads and never writes, loads no project context files, and keeps no session on disk. `pod` has
|
|
117
|
+
nobody in front of it: it cannot ask a question. MCP is off in both.
|
|
118
|
+
|
|
119
|
+
## The rest of the commands
|
|
120
|
+
|
|
121
|
+
`/help` prints all of this list · `/tools` what this session can call · `/agents` what a sub-agent may be and
|
|
122
|
+
what one may spend · `/thinking` shows or folds the reasoning · `/abort` stops the running turn · `/quit`.
|
|
@@ -41,5 +41,5 @@ module. Collection-level helpers go `static` on the full model.
|
|
|
41
41
|
- Read the module's `<module>.abstract.md` before changing it, and update it when an invariant or workflow
|
|
42
42
|
changes — not for formatting.
|
|
43
43
|
|
|
44
|
-
Deeper detail per layer is a `get_guideline` call away: `moduleOverview`, `
|
|
45
|
-
`
|
|
44
|
+
Deeper detail per layer is a `get_guideline` call away: `moduleOverview`, `modelConstant`, `modelDocument`,
|
|
45
|
+
`modelService`, `modelSignal`, `modelStore`, `modelDictionary`.
|
|
@@ -37,14 +37,18 @@ Only what the generated set does not cover: extra client state, a multi-step or
|
|
|
37
37
|
calls to the non-CRUD endpoints you added in the signal. Each is an `async` method using `this.set(...)` /
|
|
38
38
|
`this.get()` / `this.pick(...)`.
|
|
39
39
|
|
|
40
|
+
To reach another store, cast `this`: `import type { RootStore } from "../st"`, then
|
|
41
|
+
`(this as unknown as RootStore).logout()` to call its action or `(this as unknown as RootStore).set({ … })` /
|
|
42
|
+
`.get()` to write or read its state. Every store is mixed into one root at runtime, so the cast only tells the
|
|
43
|
+
type what `this` already is. Keep it `import type` — `st.ts` imports every store, so a value import is a cycle.
|
|
44
|
+
|
|
40
45
|
A store that needs none of this stays empty. `store(sig.comment, () => ({}))` with an empty body is the
|
|
41
46
|
correct, complete result — an empty store is not an unfinished one.
|
|
42
47
|
|
|
43
|
-
##
|
|
48
|
+
## The rule the linter enforces
|
|
44
49
|
|
|
45
50
|
- **An action returns nothing.** Every method dispatches through `st.do.<action>()` and is typed `void`, so a
|
|
46
51
|
returned value is unreachable. Write it into state with `this.set({ … })`.
|
|
47
|
-
- **Never `import type { RootStore } from "../st"`** — it crashes `akan build` with a Bun SSR segfault.
|
|
48
52
|
|
|
49
53
|
## From UI
|
|
50
54
|
|
|
@@ -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, CodeAgentQuestion, 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";
|
|
@@ -9,6 +9,7 @@ import { PreviewView } from "../feedback/PreviewView";
|
|
|
9
9
|
import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
|
|
10
10
|
import { VerifyFeedback } from "../feedback/VerifyFeedback";
|
|
11
11
|
import { AkanToolPack } from "./AkanToolPack";
|
|
12
|
+
import { AskToolPack } from "./AskToolPack";
|
|
12
13
|
import { MailToolPack } from "./MailToolPack";
|
|
13
14
|
import { McpToolPack } from "./McpToolPack";
|
|
14
15
|
import { SessionToolPack } from "./SessionToolPack";
|
|
@@ -28,6 +29,9 @@ export interface AkanCodePluginsOptions {
|
|
|
28
29
|
/** Late, because the session this announces to its peers does not exist yet when the pack is built. */
|
|
29
30
|
mailbox: () => CodeMailbox | undefined;
|
|
30
31
|
onNotice?: (message: string) => void;
|
|
32
|
+
onAgents?: (agents: CodeAgentSubagent[]) => void;
|
|
33
|
+
/** How `ask_user` reaches the person. Absent on a host that has nobody to reach. */
|
|
34
|
+
onAsk?: (question: Omit<CodeAgentQuestion, "questionId">) => Promise<string | undefined>;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -40,6 +44,8 @@ export interface AkanCodePluginsResult {
|
|
|
40
44
|
extensions: InlineExtension[];
|
|
41
45
|
/** Names the session's tool allowlist has to carry, or the model is told these tools do not exist. */
|
|
42
46
|
toolNames: string[];
|
|
47
|
+
/** Every declared MCP server and what it published, including the ones that published nothing. */
|
|
48
|
+
mcp: CodeAgentMcpStatus[];
|
|
43
49
|
/** Closes anything the pack holds open — currently the MCP server connections. */
|
|
44
50
|
dispose: () => void;
|
|
45
51
|
}
|
|
@@ -52,6 +58,7 @@ export class AkanCodePlugins {
|
|
|
52
58
|
...(options.onNotice ? { onNotice: options.onNotice } : {}),
|
|
53
59
|
});
|
|
54
60
|
const dispose = () => mcp?.close();
|
|
61
|
+
const mcpStatus = mcp?.status ?? [];
|
|
55
62
|
const web = new WebToolPack({ profile: options.profile });
|
|
56
63
|
const webExtension = web.extension();
|
|
57
64
|
const sessions = new SessionToolPack({
|
|
@@ -63,10 +70,22 @@ export class AkanCodePlugins {
|
|
|
63
70
|
const sessionExtension = sessions.extension();
|
|
64
71
|
const mail = new MailToolPack({ profile: options.profile, mailbox: options.mailbox });
|
|
65
72
|
const mailExtension = mail.extension();
|
|
73
|
+
const onAsk = options.onAsk;
|
|
74
|
+
const ask = onAsk ? new AskToolPack({ profile: options.profile, ask: onAsk }) : undefined;
|
|
75
|
+
const askExtension = ask?.extension();
|
|
66
76
|
if (!options.profile.tools.akan)
|
|
67
77
|
return {
|
|
68
|
-
extensions: [mcp?.extension(), webExtension, sessionExtension, mailExtension].filter(
|
|
69
|
-
|
|
78
|
+
extensions: [mcp?.extension(), webExtension, sessionExtension, mailExtension, askExtension].filter(
|
|
79
|
+
(entry) => !!entry,
|
|
80
|
+
),
|
|
81
|
+
toolNames: [
|
|
82
|
+
...(mcp?.toolNames ?? []),
|
|
83
|
+
...web.names(),
|
|
84
|
+
...sessions.names(),
|
|
85
|
+
...mail.names(),
|
|
86
|
+
...(ask?.names() ?? []),
|
|
87
|
+
],
|
|
88
|
+
mcp: mcpStatus,
|
|
70
89
|
dispose,
|
|
71
90
|
};
|
|
72
91
|
const readOnly = !options.profile.tools.builtin.includes("write");
|
|
@@ -82,6 +101,7 @@ export class AkanCodePlugins {
|
|
|
82
101
|
parent: options.profile,
|
|
83
102
|
depth: options.depth,
|
|
84
103
|
...(options.onNotice ? { onNotice: options.onNotice } : {}),
|
|
104
|
+
...(options.onAgents ? { onAgents: options.onAgents } : {}),
|
|
85
105
|
});
|
|
86
106
|
if (subagent) extensions.push(subagent);
|
|
87
107
|
const sources = await AkanCodePlugins.#sources(options, readOnly);
|
|
@@ -96,6 +116,7 @@ export class AkanCodePlugins {
|
|
|
96
116
|
if (webExtension) extensions.push(webExtension);
|
|
97
117
|
if (sessionExtension) extensions.push(sessionExtension);
|
|
98
118
|
if (mailExtension) extensions.push(mailExtension);
|
|
119
|
+
if (askExtension) extensions.push(askExtension);
|
|
99
120
|
return {
|
|
100
121
|
extensions,
|
|
101
122
|
toolNames: [
|
|
@@ -105,7 +126,9 @@ export class AkanCodePlugins {
|
|
|
105
126
|
...web.names(),
|
|
106
127
|
...sessions.names(),
|
|
107
128
|
...mail.names(),
|
|
129
|
+
...(ask?.names() ?? []),
|
|
108
130
|
],
|
|
131
|
+
mcp: mcpStatus,
|
|
109
132
|
dispose,
|
|
110
133
|
};
|
|
111
134
|
}
|
|
@@ -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;
|