@akanjs/devkit 3.0.0-beta.18 → 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.
Files changed (63) hide show
  1. package/abstractDoc.ts +4 -1
  2. package/akanApp/akanApp.host.ts +23 -9
  3. package/akanConfig/akanConfig.test.ts +55 -30
  4. package/akanConfig/akanConfig.ts +29 -17
  5. package/akanConfig/types.ts +1 -0
  6. package/applicationBuildRunner.test.ts +5 -3
  7. package/applicationBuildRunner.ts +29 -3
  8. package/artifact/routeSeedIndex.test.ts +7 -6
  9. package/capacitorApp.test.ts +2 -2
  10. package/cloud/constants.ts +23 -0
  11. package/cloud/globalConfig.ts +13 -0
  12. package/codeAgent/agent/AkanCodeServices.ts +22 -3
  13. package/codeAgent/agent/CodeAgent.ts +33 -18
  14. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  15. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  16. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  17. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  18. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  19. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  20. package/codeAgent/agent/akanCodeModel.ts +1 -1
  21. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  22. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  23. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  24. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  25. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  26. package/codeAgent/tools/mcpOAuth.test.ts +6 -2
  27. package/dependencyScanner.ts +4 -2
  28. package/devkitUtils.test.ts +1 -1
  29. package/executors.linkFallback.test.ts +103 -0
  30. package/executors.test.ts +8 -0
  31. package/executors.ts +31 -8
  32. package/frontendBuild/autoImportSync.test.ts +13 -0
  33. package/frontendBuild/autoImportSync.ts +19 -1
  34. package/frontendBuild/buildRouteClient.test.ts +52 -1
  35. package/frontendBuild/cssImportResolver.ts +1 -1
  36. package/frontendBuild/fontOptimizer.test.ts +1 -1
  37. package/frontendBuild/frontendBuild.test.ts +19 -17
  38. package/frontendBuild/hmrWatcher.test.ts +1 -1
  39. package/frontendBuild/routeClientBuilder.ts +27 -10
  40. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  41. package/getDirname.test.ts +4 -2
  42. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  43. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  44. package/integration/devStabilityHarness.test.ts +7 -2
  45. package/lint/gritRules.test.ts +2 -1
  46. package/lint/no-deep-internal-import.grit +2 -2
  47. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  48. package/linter.ts +4 -1
  49. package/package.json +2 -2
  50. package/platformTest/BunTestReport.test.ts +60 -0
  51. package/platformTest/BunTestReport.ts +65 -0
  52. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  53. package/platformTest/IdleWatchedProcess.ts +77 -0
  54. package/platformTest/LinuxDockerTarget.ts +120 -0
  55. package/platformTest/PlatformTestRun.test.ts +68 -0
  56. package/platformTest/PlatformTestRun.ts +141 -0
  57. package/platformTest/PlatformTestTarget.ts +120 -0
  58. package/platformTest/TestSnapshot.test.ts +80 -0
  59. package/platformTest/TestSnapshot.ts +107 -0
  60. package/platformTest/WindowsSshTarget.ts +167 -0
  61. package/subspace.test.ts +5 -2
  62. package/subspace.ts +19 -8
  63. package/workflow/rolloutGate.test.ts +1 -1
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  type AgentSession,
3
3
  createAgentSession,
4
- DEFAULT_COMPACTION_SETTINGS,
5
4
  DefaultResourceLoader,
6
5
  type ExtensionAPI,
7
6
  type InlineExtension,
@@ -37,6 +36,8 @@ import { akanCodePaths } from "./akanCodePaths";
37
36
  import { CodeAgentAsks } from "./CodeAgentAsks";
38
37
  import { CodeAgentEventMapper } from "./CodeAgentEventMapper";
39
38
  import { CodeAgentGate } from "./CodeAgentGate";
39
+ import { CodeAgentProxy } from "./CodeAgentProxy";
40
+ import { CodeAgentSuspended } from "./CodeAgentSuspended";
40
41
  import { CodeAgentUi } from "./CodeAgentUi";
41
42
  import { CodeMailbox } from "./CodeMailbox";
42
43
  import { CodeSessionFork } from "./CodeSessionFork";
@@ -77,6 +78,7 @@ export class CodeAgent {
77
78
  readonly #gate: CodeAgentGate;
78
79
  readonly #mapper = new CodeAgentEventMapper();
79
80
  readonly #asks = new CodeAgentAsks();
81
+ #suspended = new CodeAgentSuspended(null);
80
82
  readonly #listeners = new Set<(event: CodeAgentEvent) => void>();
81
83
  #session: AgentSession | undefined;
82
84
  #disposePlugins: (() => void) | undefined;
@@ -106,9 +108,9 @@ export class CodeAgent {
106
108
  const cwd = options.cwd ?? workspaceRoot;
107
109
  const agent = new CodeAgent(options.profile);
108
110
  const settingsManager = AkanCodeServices.settings();
109
- const modelRuntime = await AkanCodeServices.runtime(workspaceRoot);
111
+ const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, CodeAgentProxy.fromEnv(options.profile));
110
112
  const model = await akanCodeModel(modelRuntime, options.model);
111
- const compaction = DEFAULT_COMPACTION_SETTINGS;
113
+ const compaction = settingsManager.getCompactionSettings(model);
112
114
  const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
113
115
 
114
116
  const akan = await AkanCodePlugins.build({
@@ -153,9 +155,11 @@ export class CodeAgent {
153
155
  agent.#disposePlugins = akan.dispose;
154
156
  agent.#mcp = akan.mcp;
155
157
  agent.#workspaceRoot = workspaceRoot;
156
- if (options.profile.session.store === "file") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
158
+ if (options.profile.session.store !== "memory") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
157
159
  await agent.#attach(session, options.mode ?? "print");
158
- if (agent.#sessionDir) agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
160
+ agent.#suspended = new CodeAgentSuspended(CodeAgentSuspended.fileOf(agent.#sessionDir, agent.sessionId));
161
+ if (agent.#sessionDir && options.profile.session.store === "file")
162
+ agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
159
163
  for (const message of modelWarnings) agent.#emit({ type: "notice", level: "warning", message });
160
164
  return agent;
161
165
  }
@@ -262,6 +266,10 @@ export class CodeAgent {
262
266
  announce() {
263
267
  this.#emit({ type: "session", info: this.info });
264
268
  this.#restore();
269
+ //* A resumed worker says again what it is still waiting on, or a host that reattached has no card to answer.
270
+ const { question, approvals } = this.#suspended;
271
+ if (question) this.#emit({ type: "question", question });
272
+ for (const request of approvals) this.#emit({ type: "approval", request });
265
273
  }
266
274
 
267
275
  /**
@@ -346,10 +354,13 @@ export class CodeAgent {
346
354
  return true;
347
355
  }
348
356
  if (this.#profile.interaction.question !== "suspend") return false;
349
- this.#emit({ type: "question_resolved", questionId, answer, rendered });
357
+ const suspended = this.#suspended.takeQuestion(questionId);
358
+ if (!suspended) return false;
359
+ const suspendedRendered = codeAgentRenderAnswer(suspended, answer);
360
+ this.#emit({ type: "question_resolved", questionId, answer, rendered: suspendedRendered });
350
361
  // The answer has to reach the model as prose: compaction and the next turn read message content only, so
351
362
  // an answer that exists solely as structure is one the agent will not remember being given.
352
- await this.prompt(`The user answered: ${rendered}\nContinue the task.`);
363
+ await this.prompt(`The user answered: ${suspendedRendered}\nContinue the task.`);
353
364
  return true;
354
365
  }
355
366
 
@@ -359,6 +370,7 @@ export class CodeAgent {
359
370
  return true;
360
371
  }
361
372
  if (this.#profile.interaction.approval !== "suspend") return false;
373
+ if (!this.#suspended.takeApproval(approvalId)) return false;
362
374
  this.#emit({ type: "approval_resolved", approvalId, approved });
363
375
  if (approved) await this.prompt("The user approved the pending action. Retry it and continue.");
364
376
  return true;
@@ -561,17 +573,16 @@ export class CodeAgent {
561
573
  // Nobody to ask means nobody to refuse: a pod would otherwise deny every write it was created to make.
562
574
  if (!this.#profile.ui.canPrompt) return true;
563
575
  const approvalId = this.#asks.nextId("a");
564
- this.#emit({
565
- type: "approval",
566
- request: {
567
- approvalId,
568
- toolCallId,
569
- name,
570
- summary: codeAgentClip(summary, codeAgentLabelChars),
571
- policy: this.#profile.approval,
572
- },
573
- });
576
+ const request = {
577
+ approvalId,
578
+ toolCallId,
579
+ name,
580
+ summary: codeAgentClip(summary, codeAgentLabelChars),
581
+ policy: this.#profile.approval,
582
+ };
583
+ this.#emit({ type: "approval", request });
574
584
  if (this.#profile.interaction.approval === "suspend") {
585
+ this.#suspended.addApproval(request);
575
586
  this.#mapper.noteOutcome("awaiting");
576
587
  return false;
577
588
  }
@@ -586,10 +597,14 @@ export class CodeAgent {
586
597
  * between them. An empty answer is a skip, which is a real answer and not a failure.
587
598
  */
588
599
  async ask(spec: Omit<CodeAgentQuestion, "questionId">) {
589
- if (!this.#profile.ui.canPrompt) return undefined;
590
600
  const question: CodeAgentQuestion = { questionId: this.#asks.nextId("q"), ...spec };
601
+ if (!this.#profile.ui.canPrompt) {
602
+ this.#emit({ type: "question_skipped", question, reason: "no-host" });
603
+ return undefined;
604
+ }
591
605
  this.#emit({ type: "question", question });
592
606
  if (this.#profile.interaction.question === "suspend") {
607
+ this.#suspended.setQuestion(question);
593
608
  this.#mapper.noteOutcome("awaiting");
594
609
  return undefined;
595
610
  }
@@ -19,6 +19,8 @@ export class CodeAgentAsks {
19
19
  #question: { question: CodeAgentQuestion; deferred: Deferred<string> } | undefined;
20
20
  readonly #approvals = new Map<string, Deferred<boolean>>();
21
21
  #nextId = 0;
22
+ //* Ids outlive the process on a suspending session, so a resumed worker must not hand out `q1` a second time.
23
+ readonly #epoch = Date.now().toString(36);
22
24
 
23
25
  get pendingQuestionId() {
24
26
  return this.#question?.question.questionId;
@@ -35,7 +37,7 @@ export class CodeAgentAsks {
35
37
 
36
38
  nextId(prefix: string) {
37
39
  this.#nextId += 1;
38
- return `${prefix}${this.#nextId}`;
40
+ return `${prefix}${this.#epoch}.${this.#nextId}`;
39
41
  }
40
42
 
41
43
  openQuestion(question: CodeAgentQuestion) {
@@ -79,7 +79,15 @@ export class CodeAgentEventMapper {
79
79
  case "compaction_start":
80
80
  return [{ type: "compaction", phase: "start", reason: event.reason }];
81
81
  case "compaction_end":
82
- return [{ type: "compaction", phase: "end", reason: event.reason }];
82
+ return [
83
+ {
84
+ type: "compaction",
85
+ phase: "end",
86
+ reason: event.reason,
87
+ ...(event.errorMessage ? { error: event.errorMessage } : {}),
88
+ ...(event.aborted ? { aborted: true } : {}),
89
+ },
90
+ ];
83
91
  case "auto_retry_start":
84
92
  return [
85
93
  {
@@ -0,0 +1,91 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import type { CodeAgentProfile } from "akanjs/common";
4
+ import { akanCodeDefaultModel } from "./akanCodeModel";
5
+
6
+ interface PreparedRequest {
7
+ model: { provider: string; baseUrl?: string };
8
+ options: { apiKey?: string };
9
+ }
10
+
11
+ type PrepareRequest = (model: unknown, options?: Record<string, unknown>) => Promise<PreparedRequest>;
12
+
13
+ export interface CodeAgentProxyOptions {
14
+ baseUrl: string;
15
+ providers: string[];
16
+ tokenFile?: string;
17
+ token?: string;
18
+ }
19
+
20
+ /**
21
+ * Routes every model request through a host's LLM proxy, so a pod never holds a provider key: the host mints a
22
+ * short-lived token, writes it to a file, and rotates it there while the worker keeps running.
23
+ *
24
+ * `baseUrl` may carry `{provider}` for a proxy that fronts several providers under one host.
25
+ */
26
+ export class CodeAgentProxy {
27
+ static readonly placeholderKey = "akan-code-proxy";
28
+
29
+ readonly baseUrl: string;
30
+ readonly providers: string[];
31
+ readonly #tokenFile?: string;
32
+ readonly #token?: string;
33
+
34
+ constructor({ baseUrl, providers, tokenFile, token }: CodeAgentProxyOptions) {
35
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
36
+ this.providers = providers;
37
+ this.#tokenFile = tokenFile;
38
+ this.#token = token;
39
+ if (!tokenFile && !token)
40
+ throw new Error("AKAN_CODE_PROXY_URL needs AKAN_CODE_PROXY_TOKEN_FILE or AKAN_CODE_PROXY_TOKEN");
41
+ }
42
+
43
+ static fromEnv(profile?: CodeAgentProfile): CodeAgentProxy | null {
44
+ const baseUrl = process.env.AKAN_CODE_PROXY_URL?.trim() || profile?.network.proxyBaseUrl;
45
+ if (!baseUrl) return null;
46
+ const providers = (process.env.AKAN_CODE_PROXY_PROVIDERS ?? akanCodeDefaultModel.provider)
47
+ .split(",")
48
+ .map((provider) => provider.trim())
49
+ .filter(Boolean);
50
+ return new CodeAgentProxy({
51
+ baseUrl,
52
+ providers,
53
+ tokenFile: process.env.AKAN_CODE_PROXY_TOKEN_FILE?.trim() || undefined,
54
+ token: process.env.AKAN_CODE_PROXY_TOKEN?.trim() || undefined,
55
+ });
56
+ }
57
+
58
+ baseUrlFor(provider: string) {
59
+ return this.baseUrl.replaceAll("{provider}", provider);
60
+ }
61
+
62
+ //* Read per request, not cached: the host rotates a token that outlives no single turn, and a worker holding the
63
+ //* first one would start failing half an hour into a session it is still running.
64
+ async token() {
65
+ if (!this.#tokenFile) return this.#token ?? "";
66
+ const token = (await readFile(this.#tokenFile, "utf8")).trim();
67
+ if (!token) throw new Error(`${this.#tokenFile} is empty`);
68
+ return token;
69
+ }
70
+
71
+ /**
72
+ * `prepareRequest` is the one path every stream, completion, compaction and cache-warm request takes, which is
73
+ * why it is wrapped rather than the public stream methods. It is private in the engine's typings, so its
74
+ * absence after an engine upgrade fails here at boot instead of sending a request with no key.
75
+ */
76
+ async apply(runtime: ModelRuntime) {
77
+ const target = runtime as unknown as { prepareRequest?: PrepareRequest };
78
+ const original = target.prepareRequest?.bind(runtime);
79
+ if (!original) throw new Error("The model runtime no longer exposes prepareRequest; update CodeAgentProxy");
80
+ for (const provider of this.providers) await runtime.setRuntimeApiKey(provider, CodeAgentProxy.placeholderKey);
81
+ target.prepareRequest = async (model, options = {}) => {
82
+ const apiKey = await this.token();
83
+ const prepared = await original(model, { ...options, apiKey });
84
+ return {
85
+ ...prepared,
86
+ model: { ...prepared.model, baseUrl: this.baseUrlFor(prepared.model.provider) },
87
+ options: { ...prepared.options, apiKey },
88
+ };
89
+ };
90
+ }
91
+ }
@@ -2,7 +2,7 @@ import type { CodeAgentCommand, CodeAgentEvent, CodeAgentReply, CodeAgentRequest
2
2
  import type { CodeAgent } from "./CodeAgent";
3
3
 
4
4
  /**
5
- * Serves one code agent over stdio as newline-delimited akan wire frames.
5
+ * Serves one code agent as newline-delimited akan wire frames, over stdio or whatever transport attaches.
6
6
  *
7
7
  * The protocol is **ours**, not the engine's: a host — the web relay, a pod runner, a test harness — reads the
8
8
  * same events the in-process API emits, so an engine upgrade stops at the core instead of reaching every
@@ -13,23 +13,41 @@ import type { CodeAgent } from "./CodeAgent";
13
13
  */
14
14
  export class CodeAgentRpcHost {
15
15
  readonly #agent: CodeAgent;
16
- readonly #write: (line: string) => void;
16
+ #write: ((line: string) => void) | null;
17
17
  #buffer = "";
18
+ #started = false;
19
+ #resolveShutdown: () => void = () => {};
20
+ readonly shutdown = new Promise<void>((resolve) => {
21
+ this.#resolveShutdown = resolve;
22
+ });
18
23
 
19
- constructor(agent: CodeAgent, write: (line: string) => void = (line) => process.stdout.write(line)) {
24
+ constructor(agent: CodeAgent, write: ((line: string) => void) | null = (line) => process.stdout.write(line)) {
20
25
  this.#agent = agent;
21
26
  this.#write = write;
22
27
  }
23
28
 
24
29
  async serve(input: AsyncIterable<Uint8Array> = process.stdin) {
25
- this.#agent.on((event) => this.#emit(event));
26
- this.#agent.announce();
30
+ this.start();
27
31
  const decoder = new TextDecoder();
28
- for await (const chunk of input) await this.#push(decoder.decode(chunk, { stream: true }));
32
+ for await (const chunk of input) await this.feed(decoder.decode(chunk, { stream: true }));
29
33
  await this.#agent.abort().catch(() => {});
30
34
  }
31
35
 
32
- async #push(text: string) {
36
+ start() {
37
+ if (this.#started) return;
38
+ this.#started = true;
39
+ this.#agent.on((event) => this.#emit(event));
40
+ this.#agent.announce();
41
+ }
42
+
43
+ //* Frames emitted while no client is attached are dropped here and kept in the agent's replay buffer; a client
44
+ //* that attaches catches up with `get_state { sinceSeq }` instead of receiving a burst it did not ask for.
45
+ attach(write: ((line: string) => void) | null) {
46
+ this.#write = write;
47
+ this.#buffer = "";
48
+ }
49
+
50
+ async feed(text: string) {
33
51
  this.#buffer += text;
34
52
  let index = this.#buffer.indexOf("\n");
35
53
  while (index >= 0) {
@@ -52,6 +70,8 @@ export class CodeAgentRpcHost {
52
70
  } catch (error) {
53
71
  this.#reply({ type: "reply", id: request.id, ok: false, error: String(error) });
54
72
  }
73
+ //* After the reply, so a socket transport that closes on shutdown has already written the acknowledgement.
74
+ if (request.command?.type === "shutdown") this.#resolveShutdown();
55
75
  }
56
76
 
57
77
  /**
@@ -87,10 +107,10 @@ export class CodeAgentRpcHost {
87
107
  }
88
108
 
89
109
  #emit(event: CodeAgentEvent) {
90
- this.#write(`${JSON.stringify({ type: "event", event })}\n`);
110
+ this.#write?.(`${JSON.stringify({ type: "event", event })}\n`);
91
111
  }
92
112
 
93
113
  #reply(reply: CodeAgentReply) {
94
- this.#write(`${JSON.stringify(reply)}\n`);
114
+ this.#write?.(`${JSON.stringify(reply)}\n`);
95
115
  }
96
116
  }
@@ -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-v4-flash" };
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.
@@ -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) => files.map((file) => path.relative(root, file.path));
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 });
@@ -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
+ });