@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.19
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 +5 -4
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +33 -6
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +28 -2
- package/capacitorApp.ts +25 -4
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +38 -12
- package/codeAgent/agent/CodeAgent.ts +94 -30
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- 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 +19 -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 +69 -34
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +16 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +324 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +18 -46
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/index.ts +0 -1
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- 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/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/executor.ts +52 -0
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +19 -13
- package/guideline.ts +0 -19
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import type { Socket, SocketListener } from "bun";
|
|
3
|
+
import type { CodeAgentRpcHost } from "./CodeAgentRpcHost";
|
|
4
|
+
|
|
5
|
+
export type CodeAgentRpcAddress = { unix: string } | { hostname: string; port: number };
|
|
6
|
+
|
|
7
|
+
interface ClientState {
|
|
8
|
+
pending: Buffer | null;
|
|
9
|
+
decoder: TextDecoder;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `akan code --rpc-listen <unix:PATH | tcp:[HOST:]PORT>` — the RPC host on a socket instead of stdio, so the agent
|
|
14
|
+
* outlives the process driving it. A control plane that restarts reconnects and resumes with
|
|
15
|
+
* `get_state { sinceSeq }`; the turn it was watching kept running in the meantime.
|
|
16
|
+
*
|
|
17
|
+
* One client at a time: a new connection replaces the old one, which is what a reconnecting control plane whose
|
|
18
|
+
* previous socket has not timed out yet needs. TCP binds 127.0.0.1 unless a host is named, because the wire has
|
|
19
|
+
* no authentication of its own — reach a pod through its unix socket and whatever the orchestrator already
|
|
20
|
+
* authenticates.
|
|
21
|
+
*/
|
|
22
|
+
export class CodeAgentRpcListener {
|
|
23
|
+
readonly #host: CodeAgentRpcHost;
|
|
24
|
+
readonly #address: CodeAgentRpcAddress;
|
|
25
|
+
#listener: SocketListener<ClientState> | null = null;
|
|
26
|
+
#client: Socket<ClientState> | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(host: CodeAgentRpcHost, address: CodeAgentRpcAddress) {
|
|
29
|
+
this.#host = host;
|
|
30
|
+
this.#address = address;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static parse(spec: string): CodeAgentRpcAddress {
|
|
34
|
+
if (spec.startsWith("unix:")) return { unix: spec.slice("unix:".length) };
|
|
35
|
+
const rest = spec.startsWith("tcp:") ? spec.slice("tcp:".length) : spec;
|
|
36
|
+
const separator = rest.lastIndexOf(":");
|
|
37
|
+
const hostname = separator >= 0 ? rest.slice(0, separator) : "127.0.0.1";
|
|
38
|
+
const port = Number(separator >= 0 ? rest.slice(separator + 1) : rest);
|
|
39
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
40
|
+
throw new Error(`--rpc-listen takes unix:<path> or tcp:[host:]<port>, not "${spec}"`);
|
|
41
|
+
return { hostname: hostname || "127.0.0.1", port };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get port() {
|
|
45
|
+
return this.#listener && "port" in this.#listener ? this.#listener.port : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
listen() {
|
|
49
|
+
if ("unix" in this.#address && existsSync(this.#address.unix)) rmSync(this.#address.unix);
|
|
50
|
+
const socket = {
|
|
51
|
+
open: (client: Socket<ClientState>) => this.#open(client),
|
|
52
|
+
data: (client: Socket<ClientState>, data: Buffer) => this.#data(client, data),
|
|
53
|
+
drain: (client: Socket<ClientState>) => this.#drain(client),
|
|
54
|
+
close: (client: Socket<ClientState>) => this.#close(client),
|
|
55
|
+
error: (client: Socket<ClientState>) => this.#close(client),
|
|
56
|
+
};
|
|
57
|
+
this.#listener =
|
|
58
|
+
"unix" in this.#address
|
|
59
|
+
? Bun.listen<ClientState>({ unix: this.#address.unix, socket })
|
|
60
|
+
: Bun.listen<ClientState>({ hostname: this.#address.hostname, port: this.#address.port, socket });
|
|
61
|
+
this.#host.start();
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async serve() {
|
|
66
|
+
if (!this.#listener) this.listen();
|
|
67
|
+
await this.#host.shutdown;
|
|
68
|
+
this.stop();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stop() {
|
|
72
|
+
this.#client?.end();
|
|
73
|
+
this.#client = null;
|
|
74
|
+
this.#host.attach(null);
|
|
75
|
+
this.#listener?.stop(true);
|
|
76
|
+
this.#listener = null;
|
|
77
|
+
if ("unix" in this.#address && existsSync(this.#address.unix)) rmSync(this.#address.unix);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#open(client: Socket<ClientState>) {
|
|
81
|
+
client.data = { pending: null, decoder: new TextDecoder() };
|
|
82
|
+
const previous = this.#client;
|
|
83
|
+
this.#client = client;
|
|
84
|
+
previous?.end();
|
|
85
|
+
this.#host.attach((line) => this.#write(client, line));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#data(client: Socket<ClientState>, data: Buffer) {
|
|
89
|
+
if (client !== this.#client) return;
|
|
90
|
+
void this.#host.feed(client.data.decoder.decode(data, { stream: true }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//* A socket write may take only part of a frame; the rest waits for `drain`, and frames behind it queue in order.
|
|
94
|
+
#write(client: Socket<ClientState>, line: string) {
|
|
95
|
+
if (client !== this.#client) return;
|
|
96
|
+
const bytes = Buffer.from(line);
|
|
97
|
+
if (client.data.pending) {
|
|
98
|
+
client.data.pending = Buffer.concat([client.data.pending, bytes]);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const written = client.write(bytes);
|
|
102
|
+
if (written < bytes.length) client.data.pending = bytes.subarray(Math.max(0, written));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#drain(client: Socket<ClientState>) {
|
|
106
|
+
const pending = client.data.pending;
|
|
107
|
+
if (!pending) return;
|
|
108
|
+
const written = client.write(pending);
|
|
109
|
+
client.data.pending = written < pending.length ? pending.subarray(Math.max(0, written)) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#close(client: Socket<ClientState>) {
|
|
113
|
+
if (client !== this.#client) return;
|
|
114
|
+
this.#client = null;
|
|
115
|
+
this.#host.attach(null);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { CodeAgentApprovalRequest, CodeAgentQuestion } from "akanjs/common";
|
|
4
|
+
|
|
5
|
+
interface SuspendedState {
|
|
6
|
+
question?: CodeAgentQuestion;
|
|
7
|
+
approvals: CodeAgentApprovalRequest[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a suspending profile is waiting on after its turn ended — the question it asked and the approvals it
|
|
12
|
+
* parked. The turn is gone, so nothing in memory holds them; this does, and on a file-backed session it writes
|
|
13
|
+
* them beside the session so a worker that restarts and resumes still knows what it asked.
|
|
14
|
+
*
|
|
15
|
+
* Taking an ask removes it, which is what makes an answer idempotent: a second tab or a retried frame finds
|
|
16
|
+
* nothing to take and is refused instead of opening a second turn.
|
|
17
|
+
*/
|
|
18
|
+
export class CodeAgentSuspended {
|
|
19
|
+
readonly #file: string | null;
|
|
20
|
+
#state: SuspendedState;
|
|
21
|
+
|
|
22
|
+
constructor(file: string | null) {
|
|
23
|
+
this.#file = file;
|
|
24
|
+
this.#state = CodeAgentSuspended.#read(file);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static fileOf(sessionDir: string | undefined, sessionId: string) {
|
|
28
|
+
return sessionDir ? path.join(sessionDir, `${sessionId}.suspended.json`) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get question() {
|
|
32
|
+
return this.#state.question;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get approvals(): readonly CodeAgentApprovalRequest[] {
|
|
36
|
+
return this.#state.approvals;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//* A new question replaces the old one: asking ends the turn, so the earlier question has nobody left to answer it.
|
|
40
|
+
setQuestion(question: CodeAgentQuestion) {
|
|
41
|
+
this.#state = { ...this.#state, question };
|
|
42
|
+
this.#save();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addApproval(request: CodeAgentApprovalRequest) {
|
|
46
|
+
this.#state = { ...this.#state, approvals: [...this.#state.approvals, request] };
|
|
47
|
+
this.#save();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
takeQuestion(questionId: string) {
|
|
51
|
+
const question = this.#state.question;
|
|
52
|
+
if (question?.questionId !== questionId) return undefined;
|
|
53
|
+
this.#state = { approvals: this.#state.approvals };
|
|
54
|
+
this.#save();
|
|
55
|
+
return question;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
takeApproval(approvalId: string) {
|
|
59
|
+
const request = this.#state.approvals.find((approval) => approval.approvalId === approvalId);
|
|
60
|
+
if (!request) return undefined;
|
|
61
|
+
this.#state = { ...this.#state, approvals: this.#state.approvals.filter((approval) => approval !== request) };
|
|
62
|
+
this.#save();
|
|
63
|
+
return request;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//* A prompt the user typed instead of answering moves the conversation on; the parked asks belong to a turn it replaced.
|
|
67
|
+
clear() {
|
|
68
|
+
if (!this.#state.question && !this.#state.approvals.length) return;
|
|
69
|
+
this.#state = { approvals: [] };
|
|
70
|
+
this.#save();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#save() {
|
|
74
|
+
if (!this.#file) return;
|
|
75
|
+
if (!this.#state.question && !this.#state.approvals.length) {
|
|
76
|
+
rmSync(this.#file, { force: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
mkdirSync(path.dirname(this.#file), { recursive: true });
|
|
80
|
+
writeFileSync(this.#file, `${JSON.stringify(this.#state)}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static #read(file: string | null): SuspendedState {
|
|
84
|
+
if (!file || !existsSync(file)) return { approvals: [] };
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<SuspendedState>;
|
|
87
|
+
return { ...(parsed.question ? { question: parsed.question } : {}), approvals: parsed.approvals ?? [] };
|
|
88
|
+
} catch {
|
|
89
|
+
return { approvals: [] };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -13,7 +13,7 @@ export interface CodeAgentModelRef {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
|
|
16
|
-
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-
|
|
16
|
+
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Models the catalogue declares text-only that take a picture anyway.
|
|
@@ -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;
|
|
@@ -22,7 +22,8 @@ const loaded = (root: string, name = "AGENTS.md"): AkanContextFile[] => [
|
|
|
22
22
|
{ path: path.join(root, name), content: "the workspace guide" },
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
-
const namesOf = (files: AkanContextFile[], root: string) =>
|
|
25
|
+
const namesOf = (files: AkanContextFile[], root: string) =>
|
|
26
|
+
files.map((file) => path.relative(root, file.path).split(path.sep).join("/"));
|
|
26
27
|
|
|
27
28
|
afterEach(() => {
|
|
28
29
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
@@ -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.`,
|
|
@@ -292,6 +292,23 @@ describe("CodeAgentEventMapper", () => {
|
|
|
292
292
|
const frames = mapper.map({ type: "agent_end", messages: [{ stopReason: "aborted" }], willRetry: false } as never);
|
|
293
293
|
expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
test("a compaction that failed or was cancelled carries that, not just its reason", () => {
|
|
297
|
+
const mapper = new CodeAgentEventMapper();
|
|
298
|
+
const end = (fields: object) =>
|
|
299
|
+
mapper.map({
|
|
300
|
+
type: "compaction_end",
|
|
301
|
+
reason: "threshold",
|
|
302
|
+
result: undefined,
|
|
303
|
+
willRetry: false,
|
|
304
|
+
...fields,
|
|
305
|
+
} as never);
|
|
306
|
+
expect(end({ aborted: false, errorMessage: "Auto-compaction failed: 529" })).toEqual([
|
|
307
|
+
{ type: "compaction", phase: "end", reason: "threshold", error: "Auto-compaction failed: 529" },
|
|
308
|
+
]);
|
|
309
|
+
expect(end({ aborted: true })).toEqual([{ type: "compaction", phase: "end", reason: "threshold", aborted: true }]);
|
|
310
|
+
expect(end({ aborted: false })).toEqual([{ type: "compaction", phase: "end", reason: "threshold" }]);
|
|
311
|
+
});
|
|
295
312
|
});
|
|
296
313
|
|
|
297
314
|
describe("CodeAgentStreamPrinter", () => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AkanCodeServices } from "./AkanCodeServices";
|
|
6
|
+
import { akanCodeDefaultModel, akanCodeModel } from "./akanCodeModel";
|
|
7
|
+
import { CodeAgentProxy } from "./CodeAgentProxy";
|
|
8
|
+
|
|
9
|
+
const seen: { path: string; auth: string | null }[] = [];
|
|
10
|
+
let proxy: ReturnType<typeof Bun.serve>;
|
|
11
|
+
let home = "";
|
|
12
|
+
let realHome: string | undefined;
|
|
13
|
+
let realKey: string | undefined;
|
|
14
|
+
|
|
15
|
+
const chunk = (delta: object, finish: string | null = null) =>
|
|
16
|
+
`data: ${JSON.stringify({
|
|
17
|
+
id: "c1",
|
|
18
|
+
object: "chat.completion.chunk",
|
|
19
|
+
created: 0,
|
|
20
|
+
model: akanCodeDefaultModel.id,
|
|
21
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
22
|
+
})}\n\n`;
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
home = mkdtempSync(path.join(tmpdir(), "akan-code-proxy-test-"));
|
|
26
|
+
realHome = process.env.AKAN_CODE_HOME;
|
|
27
|
+
realKey = process.env.DEEPSEEK_API_KEY;
|
|
28
|
+
process.env.AKAN_CODE_HOME = home;
|
|
29
|
+
process.env.DEEPSEEK_API_KEY = "real-provider-key-must-not-leave";
|
|
30
|
+
proxy = Bun.serve({
|
|
31
|
+
port: 0,
|
|
32
|
+
fetch: (req) => {
|
|
33
|
+
seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") });
|
|
34
|
+
const body = `${chunk({ role: "assistant", content: "pong" })}${chunk({}, "stop")}data: [DONE]\n\n`;
|
|
35
|
+
return new Response(body, { headers: { "content-type": "text/event-stream" } });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
proxy.stop(true);
|
|
42
|
+
rmSync(home, { recursive: true, force: true });
|
|
43
|
+
if (realHome === undefined) delete process.env.AKAN_CODE_HOME;
|
|
44
|
+
else process.env.AKAN_CODE_HOME = realHome;
|
|
45
|
+
if (realKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
46
|
+
else process.env.DEEPSEEK_API_KEY = realKey;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("CodeAgentProxy", () => {
|
|
50
|
+
test("sends every request to the proxy with the token read from its file at that moment", async () => {
|
|
51
|
+
const tokenFile = path.join(home, "token");
|
|
52
|
+
writeFileSync(tokenFile, "session-token-1\n");
|
|
53
|
+
const codeProxy = new CodeAgentProxy({
|
|
54
|
+
baseUrl: `http://127.0.0.1:${proxy.port}/llm/{provider}/v1`,
|
|
55
|
+
providers: [akanCodeDefaultModel.provider],
|
|
56
|
+
tokenFile,
|
|
57
|
+
});
|
|
58
|
+
const runtime = await AkanCodeServices.runtime(home, codeProxy);
|
|
59
|
+
const model = await akanCodeModel(runtime);
|
|
60
|
+
expect(model?.provider).toBe("deepseek");
|
|
61
|
+
const context = { messages: [{ role: "user" as const, content: "ping", timestamp: Date.now() }] };
|
|
62
|
+
if (!model) throw new Error("no model");
|
|
63
|
+
const first = await runtime.completeSimple(model, context);
|
|
64
|
+
writeFileSync(tokenFile, "session-token-2\n");
|
|
65
|
+
await runtime.completeSimple(model, context);
|
|
66
|
+
expect(first.content).toEqual([expect.objectContaining({ type: "text", text: "pong" })]);
|
|
67
|
+
expect(seen.map((request) => request.auth)).toEqual(["Bearer session-token-1", "Bearer session-token-2"]);
|
|
68
|
+
expect(seen.every((request) => request.path.startsWith("/llm/deepseek/v1/"))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("refuses to start with a proxy URL and no token source", () => {
|
|
72
|
+
expect(() => new CodeAgentProxy({ baseUrl: "https://proxy", providers: ["deepseek"] })).toThrow("TOKEN");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { CodeAgentEvent } from "akanjs/common";
|
|
6
|
+
import type { CodeAgent } from "./CodeAgent";
|
|
7
|
+
import { CodeAgentRpcHost } from "./CodeAgentRpcHost";
|
|
8
|
+
import { CodeAgentRpcListener } from "./CodeAgentRpcListener";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-rpc-"));
|
|
11
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
12
|
+
|
|
13
|
+
class FakeAgent {
|
|
14
|
+
seq = 0;
|
|
15
|
+
frames: CodeAgentEvent[] = [];
|
|
16
|
+
disposed = false;
|
|
17
|
+
aborted = false;
|
|
18
|
+
#listener: (event: CodeAgentEvent) => void = () => {};
|
|
19
|
+
on(listener: (event: CodeAgentEvent) => void) {
|
|
20
|
+
this.#listener = listener;
|
|
21
|
+
return () => {};
|
|
22
|
+
}
|
|
23
|
+
announce() {
|
|
24
|
+
this.emit();
|
|
25
|
+
}
|
|
26
|
+
emit() {
|
|
27
|
+
const event = { type: "idle", seq: ++this.seq } as CodeAgentEvent;
|
|
28
|
+
this.frames.push(event);
|
|
29
|
+
this.#listener(event);
|
|
30
|
+
}
|
|
31
|
+
async prompt() {
|
|
32
|
+
this.emit();
|
|
33
|
+
}
|
|
34
|
+
async abort() {
|
|
35
|
+
this.aborted = true;
|
|
36
|
+
}
|
|
37
|
+
async state({ sinceSeq }: { sinceSeq?: number } = {}) {
|
|
38
|
+
return { replayFrom: 0, frames: this.frames.filter((event) => event.seq > (sinceSeq ?? Number.MAX_SAFE_INTEGER)) };
|
|
39
|
+
}
|
|
40
|
+
dispose() {
|
|
41
|
+
this.disposed = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class Client {
|
|
46
|
+
lines: unknown[] = [];
|
|
47
|
+
#buffer = "";
|
|
48
|
+
#socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
49
|
+
static async connect(unix: string) {
|
|
50
|
+
const client = new Client();
|
|
51
|
+
client.#socket = await Bun.connect({
|
|
52
|
+
unix,
|
|
53
|
+
socket: {
|
|
54
|
+
data: (_socket, data) => {
|
|
55
|
+
client.#buffer += data.toString();
|
|
56
|
+
const parts = client.#buffer.split("\n");
|
|
57
|
+
client.#buffer = parts.pop() ?? "";
|
|
58
|
+
for (const part of parts) if (part) client.lines.push(JSON.parse(part));
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return client;
|
|
63
|
+
}
|
|
64
|
+
send(id: string, command: object) {
|
|
65
|
+
this.#socket?.write(`${JSON.stringify({ id, command })}\n`);
|
|
66
|
+
}
|
|
67
|
+
async reply(id: string) {
|
|
68
|
+
for (let idx = 0; idx < 200; idx++) {
|
|
69
|
+
const found = this.lines.find((line) => (line as { id?: string }).id === id);
|
|
70
|
+
if (found) return found as { ok: boolean; data: { frames?: CodeAgentEvent[] } };
|
|
71
|
+
await Bun.sleep(5);
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`no reply ${id}`);
|
|
74
|
+
}
|
|
75
|
+
close() {
|
|
76
|
+
this.#socket?.end();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("CodeAgentRpcListener", () => {
|
|
81
|
+
test("parses unix and tcp addresses, defaulting tcp to loopback", () => {
|
|
82
|
+
expect(CodeAgentRpcListener.parse("unix:/tmp/a.sock")).toEqual({ unix: "/tmp/a.sock" });
|
|
83
|
+
expect(CodeAgentRpcListener.parse("tcp:7000")).toEqual({ hostname: "127.0.0.1", port: 7000 });
|
|
84
|
+
expect(CodeAgentRpcListener.parse("0.0.0.0:7000")).toEqual({ hostname: "0.0.0.0", port: 7000 });
|
|
85
|
+
expect(() => CodeAgentRpcListener.parse("tcp:nope")).toThrow("--rpc-listen");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the agent outlives a dropped client, and the next one catches up by seq", async () => {
|
|
89
|
+
const agent = new FakeAgent();
|
|
90
|
+
const unix = path.join(dir, "agent.sock");
|
|
91
|
+
const listener = new CodeAgentRpcListener(new CodeAgentRpcHost(agent as unknown as CodeAgent, null), { unix });
|
|
92
|
+
listener.listen();
|
|
93
|
+
const served = listener.serve();
|
|
94
|
+
|
|
95
|
+
const first = await Client.connect(unix);
|
|
96
|
+
first.send("1", { type: "prompt", message: "hi" });
|
|
97
|
+
expect((await first.reply("1")).ok).toBe(true);
|
|
98
|
+
first.close();
|
|
99
|
+
await Bun.sleep(20);
|
|
100
|
+
agent.emit();
|
|
101
|
+
expect(agent.aborted).toBe(false);
|
|
102
|
+
|
|
103
|
+
const second = await Client.connect(unix);
|
|
104
|
+
second.send("2", { type: "get_state", sinceSeq: 2 });
|
|
105
|
+
const state = await second.reply("2");
|
|
106
|
+
expect(state.data.frames?.map((frame) => frame.seq)).toEqual([3]);
|
|
107
|
+
|
|
108
|
+
second.send("3", { type: "shutdown" });
|
|
109
|
+
await second.reply("3");
|
|
110
|
+
await served;
|
|
111
|
+
expect(agent.disposed).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CodeAgentSuspended } from "./CodeAgentSuspended";
|
|
6
|
+
|
|
7
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-suspended-"));
|
|
8
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
9
|
+
|
|
10
|
+
const question = {
|
|
11
|
+
questionId: "qx.1",
|
|
12
|
+
prompt: "Which app?",
|
|
13
|
+
kind: "select" as const,
|
|
14
|
+
options: [{ key: "a", label: "akan" }],
|
|
15
|
+
};
|
|
16
|
+
const approval = {
|
|
17
|
+
approvalId: "ax.2",
|
|
18
|
+
toolCallId: "c1",
|
|
19
|
+
name: "write",
|
|
20
|
+
summary: "write a.ts",
|
|
21
|
+
policy: "writes" as const,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("CodeAgentSuspended", () => {
|
|
25
|
+
test("a restarted worker reads back what it was waiting on, and each ask is taken once", () => {
|
|
26
|
+
const file = CodeAgentSuspended.fileOf(dir, "s1");
|
|
27
|
+
const before = new CodeAgentSuspended(file);
|
|
28
|
+
before.setQuestion(question);
|
|
29
|
+
before.addApproval(approval);
|
|
30
|
+
|
|
31
|
+
const after = new CodeAgentSuspended(file);
|
|
32
|
+
expect(after.question).toEqual(question);
|
|
33
|
+
expect(after.approvals).toEqual([approval]);
|
|
34
|
+
expect(after.takeQuestion("qx.1")).toEqual(question);
|
|
35
|
+
expect(after.takeQuestion("qx.1")).toBeUndefined();
|
|
36
|
+
expect(after.takeApproval("ax.2")).toEqual(approval);
|
|
37
|
+
expect(after.takeApproval("ax.2")).toBeUndefined();
|
|
38
|
+
expect(existsSync(file ?? "")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a memory session keeps its asks without touching disk", () => {
|
|
42
|
+
const memory = new CodeAgentSuspended(CodeAgentSuspended.fileOf(undefined, "s2"));
|
|
43
|
+
memory.setQuestion(question);
|
|
44
|
+
expect(memory.takeQuestion("other")).toBeUndefined();
|
|
45
|
+
expect(memory.takeQuestion("qx.1")).toEqual(question);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -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`.
|