@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.
Files changed (96) hide show
  1. package/abstractDoc.ts +5 -4
  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 +33 -6
  8. package/artifact/implicitRootLayout.test.ts +6 -11
  9. package/artifact/implicitRootLayout.ts +0 -15
  10. package/artifact/routeSeedIndex.test.ts +7 -6
  11. package/biome.base.json +6 -1
  12. package/capacitorApp.test.ts +28 -2
  13. package/capacitorApp.ts +25 -4
  14. package/cloud/constants.ts +23 -0
  15. package/cloud/globalConfig.ts +13 -0
  16. package/codeAgent/agent/AkanCodeServices.ts +38 -12
  17. package/codeAgent/agent/CodeAgent.ts +94 -30
  18. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  19. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  20. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  21. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  22. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  23. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  24. package/codeAgent/agent/akanCodeModel.ts +1 -1
  25. package/codeAgent/agent/akanCodePaths.ts +23 -3
  26. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  27. package/codeAgent/agent/akanSystemPrompt.ts +5 -0
  28. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  29. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  30. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  31. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  32. package/codeAgent/skills/akan-code/SKILL.md +122 -0
  33. package/codeAgent/skills/akan-module/SKILL.md +2 -2
  34. package/codeAgent/skills/akan-store/SKILL.md +6 -2
  35. package/codeAgent/tools/AkanCodePlugins.ts +19 -3
  36. package/codeAgent/tools/AskToolPack.ts +111 -0
  37. package/codeAgent/tools/McpClient.ts +51 -5
  38. package/codeAgent/tools/McpOAuth.ts +268 -0
  39. package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
  40. package/codeAgent/tools/McpServerConfig.ts +69 -34
  41. package/codeAgent/tools/McpSignIn.ts +161 -0
  42. package/codeAgent/tools/McpTokenStore.ts +92 -0
  43. package/codeAgent/tools/McpToolPack.ts +16 -3
  44. package/codeAgent/tools/askToolPack.test.ts +119 -0
  45. package/codeAgent/tools/mcpOAuth.test.ts +324 -0
  46. package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
  47. package/commandDecorators/command.ts +2 -0
  48. package/commandDecorators/commandDecorators.test.ts +1 -1
  49. package/commandDecorators/helpFormatter.ts +2 -1
  50. package/dependencyScanner.ts +4 -2
  51. package/devkitUtils.test.ts +1 -1
  52. package/executors.linkFallback.test.ts +103 -0
  53. package/executors.test.ts +18 -46
  54. package/executors.ts +31 -8
  55. package/frontendBuild/autoImportSync.test.ts +13 -0
  56. package/frontendBuild/autoImportSync.ts +19 -1
  57. package/frontendBuild/buildRouteClient.test.ts +52 -1
  58. package/frontendBuild/cssImportResolver.ts +1 -1
  59. package/frontendBuild/fontOptimizer.test.ts +1 -1
  60. package/frontendBuild/frontendBuild.test.ts +19 -17
  61. package/frontendBuild/hmrWatcher.test.ts +1 -1
  62. package/frontendBuild/routeClientBuilder.ts +27 -10
  63. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  64. package/getDirname.test.ts +4 -2
  65. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  66. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  67. package/index.ts +0 -1
  68. package/integration/devStabilityHarness.test.ts +7 -2
  69. package/lint/gritRules.test.ts +2 -1
  70. package/lint/no-deep-internal-import.grit +2 -2
  71. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  72. package/linter.ts +4 -1
  73. package/package.json +2 -2
  74. package/platformTest/BunTestReport.test.ts +60 -0
  75. package/platformTest/BunTestReport.ts +65 -0
  76. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  77. package/platformTest/IdleWatchedProcess.ts +77 -0
  78. package/platformTest/LinuxDockerTarget.ts +120 -0
  79. package/platformTest/PlatformTestRun.test.ts +68 -0
  80. package/platformTest/PlatformTestRun.ts +141 -0
  81. package/platformTest/PlatformTestTarget.ts +120 -0
  82. package/platformTest/TestSnapshot.test.ts +80 -0
  83. package/platformTest/TestSnapshot.ts +107 -0
  84. package/platformTest/WindowsSshTarget.ts +167 -0
  85. package/prompter.ts +0 -7
  86. package/qualityScanner.test.ts +3 -2
  87. package/qualityScanner.ts +1 -1
  88. package/routeSourceValidator.test.ts +0 -1
  89. package/routeSourceValidator.ts +0 -12
  90. package/scanInfo.ts +5 -2
  91. package/subspace.test.ts +5 -2
  92. package/subspace.ts +19 -8
  93. package/workflow/executor.ts +52 -0
  94. package/workflow/rolloutGate.test.ts +1 -1
  95. package/workflow/source.ts +19 -13
  96. package/guideline.ts +0 -19
@@ -24,13 +24,36 @@ export interface RemoteEnvServerConfig {
24
24
  username?: string;
25
25
  port?: number;
26
26
  }
27
+ export type PlatformGatePolicy = "gate" | "warn";
28
+ export interface LinuxTestTargetConfig {
29
+ cpus?: number;
30
+ memory?: string;
31
+ policy?: PlatformGatePolicy;
32
+ }
33
+ export interface WindowsTestTargetConfig {
34
+ host: string;
35
+ user: string;
36
+ identityFile: string;
37
+ knownHostsFile?: string;
38
+ utmVm?: string;
39
+ workRoot?: string;
40
+ tolerateScriptFailures?: string[];
41
+ policy?: PlatformGatePolicy;
42
+ greenStreak?: number;
43
+ }
44
+ export interface TestTargetsConfig {
45
+ linux?: LinuxTestTargetConfig;
46
+ windows?: WindowsTestTargetConfig;
47
+ }
27
48
  export interface AkanGlobalConfig {
28
49
  cloudHost: { [key: string]: HostConfigDto };
29
50
  remoteEnvServers: Record<string, RemoteEnvServerConfig>;
51
+ testTargets: TestTargetsConfig;
30
52
  }
31
53
  export const defaultAkanGlobalConfig: AkanGlobalConfig = {
32
54
  cloudHost: {},
33
55
  remoteEnvServers: {},
56
+ testTargets: {},
34
57
  };
35
58
 
36
59
  export interface AccessTokenDto {
@@ -12,6 +12,7 @@ import {
12
12
  type HostConfig,
13
13
  type HostConfigDto,
14
14
  type RemoteEnvServerConfig,
15
+ type TestTargetsConfig,
15
16
  } from "./constants";
16
17
 
17
18
  export class GlobalConfig {
@@ -27,6 +28,7 @@ export class GlobalConfig {
27
28
  ...akanConfig,
28
29
  cloudHost: akanConfig.cloudHost ?? defaultAkanGlobalConfig.cloudHost,
29
30
  remoteEnvServers: akanConfig.remoteEnvServers ?? defaultAkanGlobalConfig.remoteEnvServers,
31
+ testTargets: akanConfig.testTargets ?? defaultAkanGlobalConfig.testTargets,
30
32
  };
31
33
  }
32
34
  /**
@@ -71,6 +73,17 @@ export class GlobalConfig {
71
73
  remoteEnvServers,
72
74
  });
73
75
  }
76
+ static async getTestTargets(): Promise<TestTargetsConfig> {
77
+ const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
78
+ return akanConfig.testTargets;
79
+ }
80
+ static async setTestTargets(testTargets: TestTargetsConfig) {
81
+ const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
82
+ await GlobalConfig.#setAkanGlobalConfig({
83
+ ...akanConfig,
84
+ testTargets: { ...akanConfig.testTargets, ...testTargets },
85
+ });
86
+ }
74
87
  static needRefreshToken(accessToken: AccessToken): boolean {
75
88
  return !!accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));
76
89
  }
@@ -1,10 +1,13 @@
1
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
2
4
  import { type InlineExtension, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
3
5
  import type { CodeAgentProfile } from "akanjs/common";
4
6
  import { AkanEnvKeys } from "../tools/AkanEnvKeys";
5
7
  import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
6
8
  import { akanCodePaths } from "./akanCodePaths";
7
9
  import { akanSystemPrompt } from "./akanSystemPrompt";
10
+ import type { CodeAgentProxy } from "./CodeAgentProxy";
8
11
  import { CodeSessionIndex } from "./CodeSessionIndex";
9
12
 
10
13
  export interface AkanCodeServicesOptions {
@@ -32,6 +35,10 @@ export class AkanCodeServices {
32
35
  enableInstallTelemetry: false,
33
36
  enableAnalytics: false,
34
37
  quietStartup: true,
38
+ // Twice the engine's 16,384. The reserve is all the room the response at the threshold gets — a request
39
+ // clamps its output to the window less the prompt less 4,096 — and 80% of it caps the summary. At 16k a
40
+ // reasoning model stops mid-thought there, and the recovery re-sends a near-full window to retry the turn.
41
+ compaction: { reserveTokens: 32_768 },
35
42
  });
36
43
  }
37
44
 
@@ -42,8 +49,18 @@ export class AkanCodeServices {
42
49
  * locks and rewrites that file itself, so a second `akan code` refreshing an OAuth token at the same moment
43
50
  * cannot clobber the first. Nothing lands in `~/.pi/`, and a user who removes akan takes their keys with them.
44
51
  */
45
- static async runtime(workspaceRoot: string) {
52
+ static async runtime(workspaceRoot: string, proxy: CodeAgentProxy | null = null) {
46
53
  mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
54
+ if (proxy) {
55
+ //* Behind a proxy neither `auth.json` nor an env key may reach a request, so credentials live in a throwaway
56
+ //* file and the only key the engine ever sends is the proxy token.
57
+ const runtime = await ModelRuntime.create({
58
+ authPath: path.join(mkdtempSync(path.join(tmpdir(), "akan-code-proxy-")), "auth.json"),
59
+ modelsPath: akanCodePaths.modelsFile(),
60
+ });
61
+ await proxy.apply(runtime);
62
+ return runtime;
63
+ }
47
64
  const runtime = await ModelRuntime.create({
48
65
  authPath: akanCodePaths.authFile(),
49
66
  modelsPath: akanCodePaths.modelsFile(),
@@ -53,7 +70,9 @@ export class AkanCodeServices {
53
70
  }
54
71
 
55
72
  static sessions(workspaceRoot: string, cwd: string, profile: CodeAgentProfile, resume?: string) {
56
- if (profile.session.store !== "file") return SessionManager.inMemory(cwd);
73
+ //* `remote` has no store of its own yet, so it keeps a file session rather than none: a pod's worker is long-lived
74
+ //* and its suspended asks and resume need the file more than a laptop does.
75
+ if (profile.session.store === "memory") return SessionManager.inMemory(cwd);
57
76
  const dir = akanCodePaths.sessionsDir(workspaceRoot);
58
77
  mkdirSync(dir, { recursive: true });
59
78
  const manager = SessionManager.create(cwd, dir);
@@ -66,22 +85,29 @@ export class AkanCodeServices {
66
85
  }
67
86
 
68
87
  /**
69
- * The akan skill set, then the workspace's own.
88
+ * The akan skill set, then the person's own, then the workspace's.
70
89
  *
71
- * A path that does not exist is a reported load error rather than a skip, so both are checked first.
90
+ * `~/.akan/code/skills` is read because a skill somebody wrote for themselves is theirs, not one checkout's,
91
+ * and having to copy it into every repo is the same friction that makes people stop writing them. It is
92
+ * text the model may read, never code that runs — which is why extensions stay workspace-only below.
93
+ *
94
+ * A path that does not exist is a reported load error rather than a skip, so each is checked first.
72
95
  */
73
96
  static #skillPaths(profile: CodeAgentProfile, workspaceRoot: string) {
74
97
  if (!profile.context.skills) return [];
75
- const workspace = akanCodePaths.skillsDir(workspaceRoot);
76
- return [akanCodePaths.builtinSkillsDir(), existsSync(workspace) ? workspace : undefined].filter(
77
- (dir): dir is string => !!dir,
78
- );
98
+ const dirs = [
99
+ akanCodePaths.builtinSkillsDir(),
100
+ akanCodePaths.globalSkillsDir(),
101
+ akanCodePaths.skillsDir(workspaceRoot),
102
+ ];
103
+ return dirs.filter((dir): dir is string => !!dir && existsSync(dir));
79
104
  }
80
105
 
81
106
  /**
82
- * What the engine's resource loader may discover. Extensions and skills come from the workspace, never from
83
- * the home directory: a coding agent that picks up a globally installed extension behaves differently in two
84
- * checkouts of the same repo for reasons nothing in the repo explains.
107
+ * What the engine's resource loader may discover. Extensions come from the workspace and nowhere else: a
108
+ * globally installed extension is code that runs in every checkout, so an agent would behave differently in
109
+ * two clones of the same repo for reasons nothing in the repo explains. Skills are text and are read from
110
+ * the home directory too — see `#skillPaths`.
85
111
  */
86
112
  static resourceOptions(options: AkanCodeServicesOptions) {
87
113
  // A path that does not exist is reported as a load error rather than skipped, and these two are optional.
@@ -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,
@@ -15,6 +14,7 @@ import {
15
14
  type CodeAgentImage,
16
15
  type CodeAgentMcpStatus,
17
16
  type CodeAgentProfile,
17
+ type CodeAgentProviderInfo,
18
18
  type CodeAgentQuestion,
19
19
  type CodeAgentSessionInfo,
20
20
  type CodeAgentState,
@@ -36,6 +36,8 @@ import { akanCodePaths } from "./akanCodePaths";
36
36
  import { CodeAgentAsks } from "./CodeAgentAsks";
37
37
  import { CodeAgentEventMapper } from "./CodeAgentEventMapper";
38
38
  import { CodeAgentGate } from "./CodeAgentGate";
39
+ import { CodeAgentProxy } from "./CodeAgentProxy";
40
+ import { CodeAgentSuspended } from "./CodeAgentSuspended";
39
41
  import { CodeAgentUi } from "./CodeAgentUi";
40
42
  import { CodeMailbox } from "./CodeMailbox";
41
43
  import { CodeSessionFork } from "./CodeSessionFork";
@@ -76,6 +78,7 @@ export class CodeAgent {
76
78
  readonly #gate: CodeAgentGate;
77
79
  readonly #mapper = new CodeAgentEventMapper();
78
80
  readonly #asks = new CodeAgentAsks();
81
+ #suspended = new CodeAgentSuspended(null);
79
82
  readonly #listeners = new Set<(event: CodeAgentEvent) => void>();
80
83
  #session: AgentSession | undefined;
81
84
  #disposePlugins: (() => void) | undefined;
@@ -105,9 +108,9 @@ export class CodeAgent {
105
108
  const cwd = options.cwd ?? workspaceRoot;
106
109
  const agent = new CodeAgent(options.profile);
107
110
  const settingsManager = AkanCodeServices.settings();
108
- const modelRuntime = await AkanCodeServices.runtime(workspaceRoot);
111
+ const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, CodeAgentProxy.fromEnv(options.profile));
109
112
  const model = await akanCodeModel(modelRuntime, options.model);
110
- const compaction = DEFAULT_COMPACTION_SETTINGS;
113
+ const compaction = settingsManager.getCompactionSettings(model);
111
114
  const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
112
115
 
113
116
  const akan = await AkanCodePlugins.build({
@@ -121,6 +124,7 @@ export class CodeAgent {
121
124
  mailbox: () => agent.#mailbox,
122
125
  onNotice: (message) => agent.#emit({ type: "notice", level: "warning", message }),
123
126
  onAgents: (agents) => agent.#emit({ type: "subagent", agents }),
127
+ onAsk: (question) => agent.ask(question),
124
128
  });
125
129
 
126
130
  const resourceLoader = new DefaultResourceLoader({
@@ -151,9 +155,11 @@ export class CodeAgent {
151
155
  agent.#disposePlugins = akan.dispose;
152
156
  agent.#mcp = akan.mcp;
153
157
  agent.#workspaceRoot = workspaceRoot;
154
- if (options.profile.session.store === "file") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
158
+ if (options.profile.session.store !== "memory") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
155
159
  await agent.#attach(session, options.mode ?? "print");
156
- 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);
157
163
  for (const message of modelWarnings) agent.#emit({ type: "notice", level: "warning", message });
158
164
  return agent;
159
165
  }
@@ -260,6 +266,10 @@ export class CodeAgent {
260
266
  announce() {
261
267
  this.#emit({ type: "session", info: this.info });
262
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 });
263
273
  }
264
274
 
265
275
  /**
@@ -281,6 +291,7 @@ export class CodeAgent {
281
291
  const text = CodeAgent.#textOf(message.content);
282
292
  if (text) this.#emit({ type: "message", turnId, role: message.role, text });
283
293
  }
294
+ this.#emitContextNow();
284
295
  }
285
296
 
286
297
  static #textOf(content: unknown) {
@@ -343,10 +354,13 @@ export class CodeAgent {
343
354
  return true;
344
355
  }
345
356
  if (this.#profile.interaction.question !== "suspend") return false;
346
- 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 });
347
361
  // The answer has to reach the model as prose: compaction and the next turn read message content only, so
348
362
  // an answer that exists solely as structure is one the agent will not remember being given.
349
- await this.prompt(`The user answered: ${rendered}\nContinue the task.`);
363
+ await this.prompt(`The user answered: ${suspendedRendered}\nContinue the task.`);
350
364
  return true;
351
365
  }
352
366
 
@@ -356,6 +370,7 @@ export class CodeAgent {
356
370
  return true;
357
371
  }
358
372
  if (this.#profile.interaction.approval !== "suspend") return false;
373
+ if (!this.#suspended.takeApproval(approvalId)) return false;
359
374
  this.#emit({ type: "approval_resolved", approvalId, approved });
360
375
  if (approved) await this.prompt("The user approved the pending action. Retry it and continue.");
361
376
  return true;
@@ -400,6 +415,33 @@ export class CodeAgent {
400
415
  this.#emit({ type: "session", info: this.info });
401
416
  }
402
417
 
418
+ /**
419
+ * The model catalogue, as the two questions a person actually asks of it.
420
+ *
421
+ * Built from the registry rather than `getAvailable()` so it needs no await and can answer about a provider
422
+ * nobody has a key for: "what else could I run" is most of why the list is opened, and a list of only what
423
+ * is already reachable cannot answer it.
424
+ */
425
+ catalogue(): CodeAgentProviderInfo[] {
426
+ const runtime = this.#session?.modelRuntime;
427
+ if (!runtime) return [];
428
+ const current = this.#session?.model;
429
+ return runtime
430
+ .getProviders()
431
+ .map((provider) => ({
432
+ id: provider.id,
433
+ name: provider.name ?? provider.id,
434
+ authorized: runtime.hasConfiguredAuth(provider.id),
435
+ models: (runtime.getModels(provider.id) ?? []).map((model) => ({
436
+ id: model.id,
437
+ name: model.name,
438
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
439
+ current: model.provider === current?.provider && model.id === current?.id,
440
+ })),
441
+ }))
442
+ .sort((a, b) => a.id.localeCompare(b.id));
443
+ }
444
+
403
445
  async setModel(ref: CodeAgentModelRef) {
404
446
  const session = this.#require();
405
447
  const model = await akanCodeModel(session.modelRuntime, ref);
@@ -461,6 +503,17 @@ export class CodeAgent {
461
503
  */
462
504
  #emitContextUsage(eventType: string) {
463
505
  if (eventType !== "agent_end" && eventType !== "compaction_end") return;
506
+ this.#emitContextNow();
507
+ }
508
+
509
+ /**
510
+ * The same frame, on demand, for the one moment the window is already full and no turn has run.
511
+ *
512
+ * A resumed session restores a conversation the model is carrying and the host has never seen, so without
513
+ * this the status line offers the declared window and no share of it until the next turn ends — a session
514
+ * reopened at 80% looks identical to one reopened empty.
515
+ */
516
+ #emitContextNow() {
464
517
  const usage = this.#session?.getContextUsage();
465
518
  // `== null`, not `=== null`: the field is typed `number | null`, and a strict null check sails straight
466
519
  // past an `undefined` the way the mirror of this bug does — which would emit `used: undefined`.
@@ -520,42 +573,53 @@ export class CodeAgent {
520
573
  // Nobody to ask means nobody to refuse: a pod would otherwise deny every write it was created to make.
521
574
  if (!this.#profile.ui.canPrompt) return true;
522
575
  const approvalId = this.#asks.nextId("a");
523
- this.#emit({
524
- type: "approval",
525
- request: {
526
- approvalId,
527
- toolCallId,
528
- name,
529
- summary: codeAgentClip(summary, codeAgentLabelChars),
530
- policy: this.#profile.approval,
531
- },
532
- });
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 });
533
584
  if (this.#profile.interaction.approval === "suspend") {
585
+ this.#suspended.addApproval(request);
534
586
  this.#mapper.noteOutcome("awaiting");
535
587
  return false;
536
588
  }
537
589
  return await this.#asks.openApproval(approvalId);
538
590
  }
539
591
 
592
+ /**
593
+ * Puts one question on the wire and waits for its answer, rendered as the labels a person chose.
594
+ *
595
+ * The single way anything asks: the engine's own UI port and the `ask_user` tool both land here, so the
596
+ * suspend path, the profile's refusal to prompt at all, and the id the host answers against cannot disagree
597
+ * between them. An empty answer is a skip, which is a real answer and not a failure.
598
+ */
599
+ async ask(spec: Omit<CodeAgentQuestion, "questionId">) {
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
+ }
605
+ this.#emit({ type: "question", question });
606
+ if (this.#profile.interaction.question === "suspend") {
607
+ this.#suspended.setQuestion(question);
608
+ this.#mapper.noteOutcome("awaiting");
609
+ return undefined;
610
+ }
611
+ return (await this.#asks.openQuestion(question)) || undefined;
612
+ }
613
+
540
614
  #ui() {
541
615
  return new CodeAgentUi({
542
- ask: async (prompt, kind, choices) => {
543
- if (!this.#profile.ui.canPrompt) return undefined;
544
- const questionId = this.#asks.nextId("q");
545
- const question: CodeAgentQuestion = {
546
- questionId,
616
+ ask: async (prompt, kind, choices) =>
617
+ await this.ask({
547
618
  prompt,
548
619
  kind,
549
620
  ...(choices ? { options: choices.map((label) => ({ key: label, label })) } : {}),
550
621
  freeText: kind === "text",
551
- };
552
- this.#emit({ type: "question", question });
553
- if (this.#profile.interaction.question === "suspend") {
554
- this.#mapper.noteOutcome("awaiting");
555
- return undefined;
556
- }
557
- return (await this.#asks.openQuestion(question)) || undefined;
558
- },
622
+ }),
559
623
  notify: (level, message) => this.#emit({ type: "notice", level, message }),
560
624
  });
561
625
  }
@@ -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
  }