@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20

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 (233) hide show
  1. package/abstractDoc.test.ts +6 -15
  2. package/abstractDoc.ts +1 -4
  3. package/agentsIndex.test.ts +4 -7
  4. package/agentsIndex.ts +6 -22
  5. package/akanApp/BackendImportGraph.test.ts +8 -22
  6. package/akanApp/BackendImportGraph.ts +1 -20
  7. package/akanApp/akanApp.host.test.ts +1 -3
  8. package/akanApp/akanApp.host.ts +64 -250
  9. package/akanApp/devHostPolicy.test.ts +10 -34
  10. package/akanApp/devHostPolicy.ts +12 -98
  11. package/akanConfig/akanConfig.test.ts +30 -39
  12. package/akanConfig/akanConfig.ts +40 -136
  13. package/akanContext.ts +106 -159
  14. package/akanMcpContract.ts +28 -73
  15. package/appSelectionMemory.ts +1 -4
  16. package/applicationBuildReporter.ts +37 -5
  17. package/applicationBuildRunner.ts +25 -59
  18. package/applicationReleasePackager.ts +24 -44
  19. package/applicationTestPreload.ts +3 -12
  20. package/artifact/implicitRootLayout.test.ts +3 -19
  21. package/artifact/implicitRootLayout.ts +34 -91
  22. package/artifact/routeSeedIndex.ts +11 -44
  23. package/biomeBase.ts +2 -5
  24. package/capacitorApp.test.ts +4 -14
  25. package/capacitorApp.ts +86 -235
  26. package/cloud/cloudApi.test.ts +202 -0
  27. package/cloud/cloudApi.ts +33 -57
  28. package/cloud/configLock.test.ts +64 -0
  29. package/cloud/configLock.ts +96 -0
  30. package/cloud/constants.ts +3 -2
  31. package/cloud/globalConfig.ts +64 -31
  32. package/codeAgent/agent/AkanCodeServices.ts +15 -57
  33. package/codeAgent/agent/AkanContextFiles.ts +5 -28
  34. package/codeAgent/agent/CodeAgent.ts +33 -108
  35. package/codeAgent/agent/CodeAgentAsks.ts +7 -30
  36. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
  37. package/codeAgent/agent/CodeAgentGate.ts +8 -34
  38. package/codeAgent/agent/CodeAgentProxy.ts +4 -13
  39. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
  40. package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
  41. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
  42. package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
  43. package/codeAgent/agent/CodeAgentUi.ts +2 -7
  44. package/codeAgent/agent/CodeMailbox.ts +7 -34
  45. package/codeAgent/agent/CodeSessionFork.ts +4 -19
  46. package/codeAgent/agent/CodeSessionIndex.ts +5 -31
  47. package/codeAgent/agent/SubagentPool.ts +48 -58
  48. package/codeAgent/agent/akanCodeModel.ts +5 -39
  49. package/codeAgent/agent/akanCodePaths.ts +4 -24
  50. package/codeAgent/agent/akanContextFiles.test.ts +0 -2
  51. package/codeAgent/agent/akanSystemPrompt.ts +1 -9
  52. package/codeAgent/agent/codeAgent.test.ts +178 -0
  53. package/codeAgent/agent/codeAgentContract.test.ts +5 -28
  54. package/codeAgent/agent/codeMailbox.test.ts +0 -4
  55. package/codeAgent/agent/codeSessionFork.test.ts +0 -2
  56. package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
  57. package/codeAgent/agent/consoleToStderr.ts +2 -12
  58. package/codeAgent/agent/jsonLines.ts +29 -0
  59. package/codeAgent/agent/stringArg.ts +5 -0
  60. package/codeAgent/feedback/DevLogFeedback.ts +3 -18
  61. package/codeAgent/feedback/PreviewChrome.ts +5 -29
  62. package/codeAgent/feedback/PreviewFeedback.ts +2 -9
  63. package/codeAgent/feedback/PreviewView.ts +3 -15
  64. package/codeAgent/feedback/TurnFeedback.ts +2 -17
  65. package/codeAgent/feedback/VerifyFeedback.ts +1 -7
  66. package/codeAgent/feedback/previewChrome.test.ts +1 -1
  67. package/codeAgent/index.ts +2 -10
  68. package/codeAgent/skills/akan-code/SKILL.md +12 -4
  69. package/codeAgent/tools/AkanCli.ts +4 -16
  70. package/codeAgent/tools/AkanCodePlugins.ts +21 -43
  71. package/codeAgent/tools/AkanEditScope.ts +2 -12
  72. package/codeAgent/tools/AkanEnvKeys.ts +3 -11
  73. package/codeAgent/tools/AkanToolPack.ts +6 -28
  74. package/codeAgent/tools/AkanVerifier.ts +1 -8
  75. package/codeAgent/tools/AskToolPack.ts +3 -15
  76. package/codeAgent/tools/MailToolPack.ts +0 -9
  77. package/codeAgent/tools/McpClient.ts +3 -24
  78. package/codeAgent/tools/McpOAuth.ts +6 -41
  79. package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
  80. package/codeAgent/tools/McpServerConfig.ts +7 -26
  81. package/codeAgent/tools/McpSignIn.ts +46 -49
  82. package/codeAgent/tools/McpTokenStore.ts +6 -17
  83. package/codeAgent/tools/McpToolPack.ts +107 -35
  84. package/codeAgent/tools/SessionToolPack.ts +1 -11
  85. package/codeAgent/tools/WebToolPack.ts +1 -7
  86. package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
  87. package/codeAgent/tools/askToolPack.test.ts +0 -3
  88. package/codeAgent/tools/mcpOAuth.test.ts +315 -27
  89. package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
  90. package/codegenLock.test.ts +4 -13
  91. package/codegenLock.ts +3 -17
  92. package/commandDecorators/argMeta.ts +1 -5
  93. package/commandDecorators/camelToKebabCase.ts +1 -0
  94. package/commandDecorators/command.ts +44 -102
  95. package/commandDecorators/commandBuilder.ts +30 -61
  96. package/commandDecorators/commandDecorators.test.ts +1 -9
  97. package/commandDecorators/dependencyBuilder.ts +10 -1
  98. package/commandDecorators/helpFormatter.ts +66 -144
  99. package/commandDecorators/index.ts +0 -2
  100. package/commandDecorators/targetMeta.ts +9 -7
  101. package/createTunnel.ts +7 -14
  102. package/dependencyScanner.test.ts +4 -16
  103. package/dependencyScanner.ts +27 -78
  104. package/devkitUtils.test.ts +47 -19
  105. package/executors.test.ts +114 -60
  106. package/executors.ts +165 -484
  107. package/fileEditor.ts +9 -37
  108. package/{getDirname.test.ts → fileSys.test.ts} +1 -1
  109. package/fileSys.ts +6 -8
  110. package/formSetterScanner.ts +3 -16
  111. package/frontendBuild/allRoutesBuilder.ts +7 -21
  112. package/frontendBuild/autoImportSync.test.ts +14 -38
  113. package/frontendBuild/autoImportSync.ts +15 -67
  114. package/frontendBuild/buildRouteClient.test.ts +10 -36
  115. package/frontendBuild/bundleDefine.ts +12 -0
  116. package/frontendBuild/clientBuildTypes.ts +5 -36
  117. package/frontendBuild/clientEntriesBundler.ts +9 -40
  118. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  119. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  120. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  121. package/frontendBuild/cssCandidateCache.ts +6 -41
  122. package/frontendBuild/cssCompiler.ts +29 -78
  123. package/frontendBuild/cssImportResolver.ts +5 -18
  124. package/frontendBuild/devChangePlanner.ts +7 -10
  125. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  126. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  127. package/frontendBuild/fontOptimizer.test.ts +5 -13
  128. package/frontendBuild/fontOptimizer.ts +15 -72
  129. package/frontendBuild/fontPruner.test.ts +5 -10
  130. package/frontendBuild/fontPruner.ts +5 -20
  131. package/frontendBuild/frontendBuild.test.ts +27 -52
  132. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  133. package/frontendBuild/hmrWatcher.test.ts +7 -31
  134. package/frontendBuild/hmrWatcher.ts +14 -71
  135. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  136. package/frontendBuild/precompressArtifacts.ts +5 -18
  137. package/frontendBuild/routeClientBuilder.ts +7 -47
  138. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  139. package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
  140. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  141. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  142. package/frontendBuild/styleContract.ts +1 -8
  143. package/frontendBuild/themeValidator.ts +4 -26
  144. package/frontendBuild/watchRootResolver.test.ts +23 -42
  145. package/frontendBuild/watchRootResolver.ts +5 -27
  146. package/getRelatedCnsts.ts +12 -92
  147. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  148. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  149. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  150. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  151. package/incrementalBuilder/builderChannel.test.ts +24 -49
  152. package/incrementalBuilder/builderChannel.ts +6 -37
  153. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  154. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  155. package/incrementalBuilder/devWatchBatch.test.ts +14 -18
  156. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  157. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  158. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  159. package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
  160. package/index.ts +3 -15
  161. package/integration/devResourceProbe.ts +50 -56
  162. package/integration/devStability.integration.test.ts +47 -194
  163. package/integration/devStabilityHarness.test.ts +9 -29
  164. package/integration/devStabilityHarness.ts +65 -330
  165. package/integration/ssrMemoryProbe.ts +41 -167
  166. package/libSource.test.ts +4 -15
  167. package/libSource.ts +19 -31
  168. package/lint/gritRules.test.ts +6 -23
  169. package/linter.test.ts +1 -2
  170. package/linter.ts +22 -115
  171. package/mobile/mobileTarget.ts +14 -13
  172. package/package.json +2 -2
  173. package/packageExports.test.ts +3 -18
  174. package/packageExportsMap.ts +3 -19
  175. package/piDependencyPins.test.ts +3 -14
  176. package/platformTest/IdleWatchedProcess.test.ts +5 -13
  177. package/platformTest/IdleWatchedProcess.ts +1 -5
  178. package/platformTest/LinuxDockerTarget.ts +20 -35
  179. package/platformTest/PlatformTestRun.test.ts +5 -9
  180. package/platformTest/PlatformTestTarget.ts +24 -2
  181. package/platformTest/TestSnapshot.test.ts +6 -14
  182. package/platformTest/TestSnapshot.ts +1 -5
  183. package/platformTest/WindowsSshTarget.ts +6 -22
  184. package/prompter.ts +3 -6
  185. package/qualityScanner.test.ts +6 -24
  186. package/qualityScanner.ts +196 -284
  187. package/recipeScanner.test.ts +7 -12
  188. package/recipeScanner.ts +9 -38
  189. package/repoIdentity.ts +2 -9
  190. package/routeSourceValidator.ts +7 -30
  191. package/scanInfo.ts +82 -214
  192. package/selectModel.ts +1 -2
  193. package/semver.ts +3 -5
  194. package/slicePlanner.test.ts +6 -24
  195. package/slicePlanner.ts +11 -32
  196. package/spinner.test.ts +3 -8
  197. package/spinner.ts +4 -16
  198. package/sshTunnel.ts +1 -6
  199. package/ssrScanner.test.ts +1 -6
  200. package/ssrScanner.ts +103 -113
  201. package/subspace.test.ts +6 -25
  202. package/subspace.ts +31 -109
  203. package/subspaceConfig.ts +3 -11
  204. package/testHelpers.ts +49 -34
  205. package/transforms/asyncDefaultExportDetector.ts +3 -11
  206. package/transforms/barrelAnalyzer.ts +43 -135
  207. package/transforms/barrelImportsPlugin.ts +38 -175
  208. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  209. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  210. package/transforms/moduleSyntax.ts +32 -0
  211. package/transforms/rscUseClientTransform.ts +35 -20
  212. package/transforms/transforms.test.ts +153 -44
  213. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  214. package/transforms/useClientBundlePlugin.ts +2 -22
  215. package/typeChecker.ts +7 -63
  216. package/uploadRelease.ts +10 -25
  217. package/workflow/artifacts.ts +18 -0
  218. package/workflow/executor.test.ts +1 -12
  219. package/workflow/executor.ts +85 -146
  220. package/workflow/index.ts +0 -1
  221. package/workflow/moduleIndex.test.ts +4 -15
  222. package/workflow/moduleIndex.ts +9 -49
  223. package/workflow/plan.ts +18 -25
  224. package/workflow/render.ts +80 -96
  225. package/workflow/source.ts +33 -56
  226. package/workflow/sourceAst.ts +50 -0
  227. package/workspaceLayout.ts +4 -19
  228. package/commandDecorators/commandMeta.ts +0 -7
  229. package/commandDecorators/types.ts +0 -10
  230. package/getDirname.ts +0 -4
  231. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  232. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  233. package/workflow/primitive.ts +0 -59
@@ -8,42 +8,29 @@ export interface SubagentPoolOptions {
8
8
  cwd: string;
9
9
  parent: CodeAgentProfile;
10
10
  depth: number;
11
- /** Progress and refusals reach the parent's host through this rather than through a new event kind. */
11
+ /** What the tree above this pool has spent and has running; a root pool starts its own. */
12
+ spend?: SubagentSpend;
12
13
  onNotice?: (message: string) => void;
13
14
  /** The running children, whole, for a host that draws them as a rail beside the prompt. */
14
15
  onAgents?: (agents: CodeAgentSubagent[]) => void;
15
16
  }
16
17
 
18
+ /** One object shared down a sub-agent tree, so a single token budget and a single concurrency limit bound it. */
19
+ export interface SubagentSpend {
20
+ tokens: number;
21
+ running: number;
22
+ }
23
+
17
24
  const maxResultChars = 8_000;
18
25
 
19
- /**
20
- * How often the running children are re-reported while none of them starts or finishes.
21
- *
22
- * A child spends tokens for minutes between the two frames its lifetime would otherwise produce, and the
23
- * number that decides whether to let it keep going is the one climbing in between. One frame a second for at
24
- * most a handful of rows is far below what a single streaming turn already puts on the wire.
25
- */
26
+ // Children are re-reported every second: their token counts climb between start and finish.
26
27
  const tickMs = 1_000;
27
28
 
28
29
  export type SubagentKind = keyof typeof SubagentPool.kinds;
29
30
 
30
- /**
31
- * The `task` tool: run a narrowed copy of the agent on one question and bring back a summary.
32
- *
33
- * **Only the summary crosses back.** A subagent's whole transcript in the parent's context is the fastest way
34
- * to spend a window, and the reason to spawn one is precisely that its reading should not become the parent's
35
- * reading.
36
- *
37
- * **The budget is not optional.** Depth, concurrency and token ceilings are checked before a child exists,
38
- * because a recursive `task` with no ceiling is a fork bomb that bills per token.
39
- */
31
+ // Only a child's answer crosses back, never its transcript. Depth, concurrency and token ceilings are checked
32
+ // before a child exists, because an unbounded recursive `task` is a fork bomb that bills per token.
40
33
  export class SubagentPool {
41
- /**
42
- * What a sub-agent is allowed to be, and the only axis that matters: whether it can change the tree.
43
- *
44
- * A reader can be spawned for anything — the worst it costs is tokens. A writer works unwatched on the same
45
- * checkout as its parent, so it is the one the parent has to ask for by name rather than get by default.
46
- */
47
34
  static readonly kinds = {
48
35
  explore: {
49
36
  readOnly: true,
@@ -55,20 +42,20 @@ export class SubagentPool {
55
42
  },
56
43
  } as const;
57
44
 
45
+ // A writer works unwatched on the parent's checkout, so it has to be asked for by name, never defaulted.
58
46
  static readonly defaultKind: SubagentKind = "explore";
59
47
 
60
48
  readonly #options: SubagentPoolOptions;
61
- /** Keyed by the `task` call that opened each child. `agent` lands once it exists, and reports live tokens. */
62
49
  readonly #children = new Map<
63
50
  string,
64
51
  { kind: SubagentKind; description: string; startedAt: number; agent?: { tokensUsed: number } }
65
52
  >();
66
- #running = 0;
67
- #spent = 0;
53
+ readonly #spend: SubagentSpend;
68
54
  #ticker: ReturnType<typeof setInterval> | null = null;
69
55
 
70
56
  constructor(options: SubagentPoolOptions) {
71
57
  this.#options = options;
58
+ this.#spend = options.spend ?? { tokens: 0, running: 0 };
72
59
  }
73
60
 
74
61
  static extensionFor(options: SubagentPoolOptions): InlineExtension | undefined {
@@ -103,17 +90,16 @@ export class SubagentPool {
103
90
  const kind = (params.type ?? SubagentPool.defaultKind) as SubagentKind;
104
91
  const refusal = this.#refuse();
105
92
  if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
106
- this.#running += 1;
93
+ this.#spend.running += 1;
107
94
  this.#children.set(id, { kind, description: params.description, startedAt: Date.now() });
108
95
  this.#report();
109
- // Said before it starts, not only when it ends: a turn that goes quiet for a minute while a child
110
- // reads the repo is otherwise indistinguishable from a turn that has stopped responding.
96
+ // Announced at start: a turn quiet for a minute while a child reads looks like one that stopped responding.
111
97
  this.#options.onNotice?.(`${kind} sub-agent · ${params.description}`);
112
98
  try {
113
99
  const text = await this.#run(id, kind, params.prompt, signal);
114
100
  return { content: [{ type: "text", text }], details: undefined };
115
101
  } finally {
116
- this.#running -= 1;
102
+ this.#spend.running -= 1;
117
103
  this.#children.delete(id);
118
104
  this.#report();
119
105
  }
@@ -124,20 +110,15 @@ export class SubagentPool {
124
110
  #refuse() {
125
111
  const budget = this.#options.parent.tools.subagent;
126
112
  if (!budget) return "Sub-agents are disabled by this profile.";
127
- if (this.#running >= budget.maxConcurrent)
128
- return `Too many sub-agents are already running (limit ${budget.maxConcurrent}). Wait for one to finish.`;
129
- if (this.#spent >= budget.budget)
113
+ // Refused, never queued: every ancestor keeps its slot while it waits on its child, so a queued child deadlocks.
114
+ if (this.#spend.running >= budget.maxConcurrent)
115
+ return `Too many sub-agents are already running (limit ${budget.maxConcurrent} at once, counted across the whole task tree). ${this.#children.size ? "Wait for one of yours to finish, or do" : "Do"} this part yourself.`;
116
+ if (this.#spend.tokens >= budget.budget)
130
117
  return `The sub-agent token budget (${budget.budget}) is spent. Do the rest of this work yourself.`;
131
118
  return undefined;
132
119
  }
133
120
 
134
- /**
135
- * The child is the parent profile **narrowed** and never widened: in-memory sessions so nothing it does
136
- * shows up in the workspace's session tree, no `AGENTS.md` — a sub-agent is asked one narrow question and
137
- * the guide is 26k tokens of its budget — and no ability to ask a human, there being nobody on the other
138
- * end of a nested turn. The skills stay: they are descriptions until one is needed, and the child writes
139
- * code too.
140
- */
121
+ /** The parent profile narrowed, never widened: no session file, no `AGENTS.md`, nobody to ask; skills stay. */
141
122
  static childOf(parent: CodeAgentProfile, kind: SubagentKind): CodeAgentProfile {
142
123
  const budget = parent.tools.subagent;
143
124
  return {
@@ -145,8 +126,7 @@ export class SubagentPool {
145
126
  name: `${parent.name}:${kind}`,
146
127
  tools: {
147
128
  ...parent.tools,
148
- // Filtered rather than replaced, so a kind can only ever take tools away from the parent: a child of
149
- // a read-only session must not become the one thing in the tree that can write.
129
+ // Filtered, not replaced: a child of a read-only session must never gain a write tool.
150
130
  builtin: SubagentPool.kinds[kind].readOnly
151
131
  ? parent.tools.builtin.filter((tool) => codeAgentReadOnlyBuiltins.includes(tool))
152
132
  : parent.tools.builtin,
@@ -158,13 +138,7 @@ export class SubagentPool {
158
138
  };
159
139
  }
160
140
 
161
- /**
162
- * The running children as a list, and the ticker that keeps it moving.
163
- *
164
- * The ticker is owned by the report rather than by the tool call: two children overlapping must not each
165
- * start one, and the last one to finish is what stops it. It is unref'd because a pool with a child still
166
- * registered is a bug that should end the process, not hold it open.
167
- */
141
+ // One ticker per pool, unref'd: a child left registered is a bug that must not hold the process open.
168
142
  #report() {
169
143
  const onAgents = this.#options.onAgents;
170
144
  if (!onAgents) return;
@@ -196,28 +170,44 @@ export class SubagentPool {
196
170
  profile: child,
197
171
  apps: [],
198
172
  depth: this.#options.depth + 1,
173
+ subagentSpend: this.#spend,
199
174
  });
200
175
  const running = this.#children.get(id);
201
176
  if (running) running.agent = agent;
202
- const abort = () => void agent.abort();
177
+ let stopped = false;
178
+ const abort = () => {
179
+ stopped = true;
180
+ void agent.abort();
181
+ };
203
182
  signal?.addEventListener("abort", abort, { once: true });
183
+ // A listener added to an already-aborted signal never fires: this child was stopped while it was being created.
184
+ if (signal?.aborted) stopped = true;
185
+ let finished = false;
204
186
  try {
205
187
  let answer = "";
206
188
  agent.on((event) => {
207
189
  if (event.type === "message" && event.role === "assistant") answer = event.text;
190
+ // The engine drops an abort that lands before its run is live, so a stopped child is stopped again there.
191
+ if (event.type === "turn_start" && stopped) void agent.abort();
208
192
  });
209
- await agent.prompt(prompt);
210
- await agent.waitForIdle();
211
- this.#spent += agent.tokensUsed;
212
- const ceiling = budget ? budget.budget : 0;
213
- this.#options.onNotice?.(
214
- `${kind} sub-agent finished, ${agent.tokensUsed} tokens (${this.#spent} of ${ceiling} spent)`,
215
- );
193
+ if (!stopped) {
194
+ await agent.prompt(prompt);
195
+ await agent.waitForIdle();
196
+ }
197
+ finished = true;
216
198
  return answer.length > maxResultChars
217
199
  ? `${answer.slice(0, maxResultChars)}\n…(truncated)`
218
200
  : answer || "(no answer)";
219
201
  } finally {
220
202
  signal?.removeEventListener("abort", abort);
203
+ // Whatever the outcome: a failed or stopped child was billed too, and its own children are counted by its pool.
204
+ const tokens = agent.tokensUsed;
205
+ this.#spend.tokens += tokens;
206
+ const outcome = stopped ? "stopped" : finished ? "finished" : "failed";
207
+ const ceiling = budget ? budget.budget : 0;
208
+ this.#options.onNotice?.(
209
+ `${kind} sub-agent ${outcome}, ${tokens} tokens (${this.#spend.tokens} of ${ceiling} spent)`,
210
+ );
221
211
  agent.dispose();
222
212
  }
223
213
  }
@@ -1,10 +1,6 @@
1
1
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
 
3
- /**
4
- * The engine's `Model` type lives in `@earendil-works/pi-ai` and is not re-exported from the package we depend
5
- * on, so it is recovered from the one public function that returns it rather than by adding a dependency on a
6
- * transitive package whose version we do not control.
7
- */
3
+ // The engine's `Model` type (from `pi-ai`) is not re-exported, so it is recovered from `getModel`.
8
4
  export type CodeAgentModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
9
5
 
10
6
  export interface CodeAgentModelRef {
@@ -15,18 +11,8 @@ export interface CodeAgentModelRef {
15
11
  /** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
16
12
  export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
17
13
 
18
- /**
19
- * Models the catalogue declares text-only that take a picture anyway.
20
- *
21
- * `input` is the catalogue's claim, and a stale one is expensive in one direction only: with `image` missing
22
- * the `read` tool refuses to hand the model a picture and a paste is refused before it is ever sent, so
23
- * nothing ever finds out. Measured 2026-09-20 against the provider — `deepseek-v4-flash` answered "A red
24
- * star." to `apps/akan/public/icon-192x192.png` and "Red" to the colour, over two requests.
25
- *
26
- * ⚠️ `deepseek-v4-pro` is **not** on this list. It answers 200 to the same request and then says it cannot
27
- * view the image, which is not the same evidence, and guessing the family from one member is how the
28
- * catalogue got it wrong in the first place.
29
- */
14
+ // Declared text-only by the catalogue, yet verified against the provider to see images.
15
+ // `deepseek-v4-pro` is deliberately absent: it accepts an image and then says it cannot view it.
30
16
  const catalogueMissesImages = new Set(["deepseek/deepseek-v4-flash"]);
31
17
 
32
18
  const correctInputs = (model: CodeAgentModel | undefined) => {
@@ -35,13 +21,7 @@ const correctInputs = (model: CodeAgentModel | undefined) => {
35
21
  return { ...model, input: [...model.input, "image" as const] };
36
22
  };
37
23
 
38
- /**
39
- * Resolves the model to run with, preferring an explicit request, then the default, then anything the user has
40
- * credentials for.
41
- *
42
- * An empty API key produces a request that succeeds with no content rather than an error, so a provider with no
43
- * configured auth is skipped here instead of answering with nothing three seconds later.
44
- */
24
+ // An empty API key yields a successful empty response rather than an error, so unauthorized providers are skipped.
45
25
  export const akanCodeModel = async (runtime: ModelRuntime, ref?: CodeAgentModelRef) => {
46
26
  if (ref) {
47
27
  const requested = runtime.getModel(ref.provider, ref.id);
@@ -55,23 +35,9 @@ export const akanCodeModel = async (runtime: ModelRuntime, ref?: CodeAgentModelR
55
35
  return correctInputs((await runtime.getAvailable())[0]);
56
36
  };
57
37
 
58
- /** A screenshot is worth attaching only to a model that can see it — after the catalogue has been corrected. */
59
38
  export const akanCodeModelSupportsImages = (model: CodeAgentModel | undefined) => !!model?.input.includes("image");
60
39
 
61
- /**
62
- * Says so when the chosen model's window is too small for the compaction policy to leave room for a
63
- * conversation.
64
- *
65
- * This is deliberately the **only** descriptor check. Two more obvious ones are wrong here: `maxTokens >=
66
- * contextWindow` matches 153 of the engine's own 1,057 catalogue entries (`gpt-4` is declared 8192/8192, and
67
- * several qwen models 262000/262000), and `reserveTokens > maxTokens` matches 145 — every model with a small
68
- * output cap, which is normal, because the reserve is context headroom and the cap is output length. Both
69
- * would cry wolf on one model in seven.
70
- *
71
- * ⚠️ **A wrong descriptor that is internally consistent cannot be detected here at all.** A custom model
72
- * declaring a 65k window for a provider that serves 1M is coherent and simply false, and the only thing that
73
- * catches it is a person seeing the number — which is why the session line prints it.
74
- */
40
+ // Deliberately the only check: `maxTokens >= contextWindow` or `reserveTokens > maxTokens` flag 1 model in 7.
75
41
  export const akanCodeModelWarnings = (model: CodeAgentModel | undefined, compactionFloor: number) => {
76
42
  if (!model) return [];
77
43
  if (model.contextWindow >= compactionFloor) return [];
@@ -2,30 +2,16 @@ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- /**
6
- * Where the shipped akan skills sit, which differs between the source tree and the bundle: the CLI's entry
7
- * chunks are flattened into the package root, so `skills/` is a sibling of the caller there and one level up
8
- * here, beside this folder. Same two-candidate shape the guidelines lookup uses.
9
- */
5
+ // Two candidates: the CLI bundle flattens entry chunks into the package root, where `skills/` is a sibling.
10
6
  const builtinSkillsDir = () => {
11
7
  const candidates = [path.join(import.meta.dir, "skills"), path.join(import.meta.dir, "..", "skills")];
12
8
  return candidates.find((candidate) => existsSync(candidate));
13
9
  };
14
10
 
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
- */
11
+ // One override for the whole set, so a `models.json` and the `auth.json` it names never drift apart.
22
12
  const globalDir = () => process.env.AKAN_CODE_HOME ?? path.join(homedir(), ".akan", "code");
23
13
 
24
- /**
25
- * Sessions live in the workspace — they are project history, and a second checkout of the same repo is a
26
- * different project. Credentials live in the home directory instead: a repo directory is the one place a key
27
- * must never be, because it is the place that gets committed, zipped and shared.
28
- */
14
+ // Credentials live under the home directory, never the repo: a repo is what gets committed, zipped and shared.
29
15
  export const akanCodePaths = {
30
16
  workspaceDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code"),
31
17
  sessionsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "sessions"),
@@ -34,13 +20,7 @@ export const akanCodePaths = {
34
20
  mailDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mail"),
35
21
  builtinSkillsDir,
36
22
  mcpFile: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mcp.json"),
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
- */
23
+ /** Person-wide MCP servers; the workspace `mcpFile` wins on a name they share. */
44
24
  globalMcpFile: () => path.join(globalDir(), "mcp.json"),
45
25
  globalSkillsDir: () => path.join(globalDir(), "skills"),
46
26
  globalDir,
@@ -17,7 +17,6 @@ const workspace = (files: Record<string, string>) => {
17
17
  return root;
18
18
  };
19
19
 
20
- /** What the engine hands over: the one context file it took for that directory. */
21
20
  const loaded = (root: string, name = "AGENTS.md"): AkanContextFile[] => [
22
21
  { path: path.join(root, name), content: "the workspace guide" },
23
22
  ];
@@ -60,7 +59,6 @@ describe("workspace context files", () => {
60
59
  expect(files[1]?.content).toBe("never use any\n");
61
60
  });
62
61
 
63
- /** Cursor's other three kinds are conditional; applying one always is applying a rule its author scoped. */
64
62
  test("a scoped cursor rule is left to cursor", () => {
65
63
  const root = workspace({
66
64
  ".cursor/rules/ui.mdc": "---\nglobs: ui/**/*.tsx\n---\nno inline colours\n",
@@ -1,12 +1,6 @@
1
1
  import type { CodeAgentProfile } from "akanjs/common";
2
2
 
3
- /**
4
- * What the agent has to know that `AGENTS.md` does not say.
5
- *
6
- * The workspace guide is already loaded whole — 104KB, roughly 26k tokens, on every turn — so this stays short
7
- * and carries only what is about *this* runtime: which tools exist, what the profile refuses, and the one rule
8
- * the guide states for editors but not for an agent that can run commands itself.
9
- */
3
+ // Only what `AGENTS.md` does not say: the guide is already loaded whole on every turn.
10
4
  export const akanSystemPrompt = (profile: CodeAgentProfile) => {
11
5
  const lines = [
12
6
  "You are the akan coding agent, running inside an Akan.js workspace.",
@@ -40,8 +34,6 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
40
34
  lines.push(
41
35
  "- Secrets are refused at the tool boundary. If you need a value from `.env`, ask the user for it rather than looking for another way to read the file.",
42
36
  "",
43
- // The answer lands in a terminal pane, not a document: a preamble scrolls the thing being read off the
44
- // screen, and a summary of tool calls repeats rows the host already drew above the answer.
45
37
  "## How to answer",
46
38
  "",
47
39
  "- Answer in as few words as carry the answer. A short question gets one sentence with no heading.",
@@ -0,0 +1,178 @@
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
5
+ import { type CodeAgentEvent, type CodeAgentProfile, codeAgentPresets } from "akanjs/common";
6
+ import { WorkspaceExecutor } from "../../executors";
7
+ import { isolateEnv, tempDirs } from "../../testHelpers";
8
+ import { AkanCodePlugins } from "../tools/AkanCodePlugins";
9
+ import { akanCodeDefaultModel } from "./akanCodeModel";
10
+ import { akanCodePaths } from "./akanCodePaths";
11
+ import { CodeAgent, type CodeAgentOptions } from "./CodeAgent";
12
+ import { CodeAgentSuspended } from "./CodeAgentSuspended";
13
+ import { SubagentPool } from "./SubagentPool";
14
+
15
+ isolateEnv();
16
+ const makeTempRoot = tempDirs("akan-code-agent-");
17
+
18
+ const chunk = (delta: object, finish: string | null = null) =>
19
+ `data: ${JSON.stringify({
20
+ id: "c1",
21
+ object: "chat.completion.chunk",
22
+ created: 0,
23
+ model: akanCodeDefaultModel.id,
24
+ choices: [{ index: 0, delta, finish_reason: finish }],
25
+ })}\n\n`;
26
+
27
+ let llm: ReturnType<typeof Bun.serve>;
28
+ let llmCalls = 0;
29
+ beforeAll(() => {
30
+ llm = Bun.serve({
31
+ port: 0,
32
+ fetch: () => {
33
+ llmCalls += 1;
34
+ return new Response(`${chunk({ role: "assistant", content: "ok" })}${chunk({}, "stop")}data: [DONE]\n\n`, {
35
+ headers: { "content-type": "text/event-stream" },
36
+ });
37
+ },
38
+ });
39
+ });
40
+ afterAll(() => llm.stop(true));
41
+
42
+ const useProxy = (root: string) => {
43
+ process.env.AKAN_CODE_HOME = path.join(root, "home");
44
+ process.env.AKAN_CODE_PROXY_URL = `http://127.0.0.1:${llm.port}/llm/{provider}/v1`;
45
+ process.env.AKAN_CODE_PROXY_TOKEN = "test-token";
46
+ };
47
+
48
+ const quietProfile = (root: string): CodeAgentProfile => {
49
+ const web = codeAgentPresets.web(root);
50
+ return {
51
+ ...web,
52
+ tools: { builtin: [], akan: false, mcp: "off", subagent: false, web: { fetch: false, search: false } },
53
+ context: { projectFiles: false, skills: false },
54
+ };
55
+ };
56
+
57
+ const createAgent = async (root: string, options: Partial<CodeAgentOptions> = {}) => {
58
+ useProxy(root);
59
+ return await CodeAgent.create({
60
+ workspace: new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }),
61
+ profile: quietProfile(root),
62
+ ...options,
63
+ });
64
+ };
65
+
66
+ describe("CodeAgent", () => {
67
+ test("a prompt typed instead of an answer drops the parked asks", async () => {
68
+ const root = await makeTempRoot();
69
+ const agent = await createAgent(root);
70
+ try {
71
+ await agent.ask({ prompt: "Which app?", kind: "select", options: [{ key: "a", label: "akan" }] });
72
+ const suspendedFile = CodeAgentSuspended.fileOf(akanCodePaths.sessionsDir(root), agent.sessionId) ?? "";
73
+ expect(existsSync(suspendedFile)).toBe(true);
74
+
75
+ await agent.prompt("Do something else instead.");
76
+
77
+ const events: CodeAgentEvent[] = [];
78
+ agent.on((event) => events.push(event));
79
+ agent.announce();
80
+ expect(events.filter((event) => event.type === "question")).toEqual([]);
81
+ expect(existsSync(suspendedFile)).toBe(false);
82
+ } finally {
83
+ agent.dispose();
84
+ }
85
+ });
86
+
87
+ test("a sub-agent hands its plugins the token tally of the tree that opened it", async () => {
88
+ const root = await makeTempRoot();
89
+ const build = AkanCodePlugins.build;
90
+ const seen: unknown[] = [];
91
+ const spy = spyOn(AkanCodePlugins, "build").mockImplementation(async (options) => {
92
+ seen.push(options.subagentSpend);
93
+ return await build.call(AkanCodePlugins, options);
94
+ });
95
+ const subagentSpend = { tokens: 7, running: 0 };
96
+ try {
97
+ const agent = await createAgent(root, { subagentSpend, depth: 1 });
98
+ agent.dispose();
99
+ expect(seen).toHaveLength(1);
100
+ expect(seen[0]).toBe(subagentSpend);
101
+ } finally {
102
+ spy.mockRestore();
103
+ }
104
+ });
105
+ });
106
+
107
+ describe("a sub-agent its parent stops before the sub-agent's run is live", () => {
108
+ type ToolExecute = (
109
+ id: string,
110
+ params: never,
111
+ signal?: AbortSignal,
112
+ ) => Promise<{ content: { text: string }[] } | undefined>;
113
+
114
+ const runTask = async (root: string, parent: AbortController, notices: string[]) => {
115
+ useProxy(root);
116
+ const profile = quietProfile(root);
117
+ const pool = SubagentPool.extensionFor({
118
+ workspace: new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }),
119
+ cwd: root,
120
+ parent: { ...profile, tools: { ...profile.tools, subagent: { maxDepth: 2, maxConcurrent: 3, budget: 1_000 } } },
121
+ depth: 0,
122
+ onNotice: (message) => notices.push(message),
123
+ });
124
+ let execute: ToolExecute | undefined;
125
+ if (pool && "factory" in pool)
126
+ pool.factory({
127
+ registerTool: (tool: { execute: ToolExecute }) => {
128
+ execute = tool.execute;
129
+ },
130
+ } as never);
131
+ const result = await execute?.("c1", { description: "d", prompt: "p" } as never, parent.signal);
132
+ return result?.content[0]?.text;
133
+ };
134
+
135
+ test("stopped while it is being created, it never reaches the model and says it stopped", async () => {
136
+ const root = await makeTempRoot();
137
+ const parent = new AbortController();
138
+ const create = CodeAgent.create;
139
+ const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
140
+ const agent = await create.call(CodeAgent, options);
141
+ parent.abort();
142
+ return agent;
143
+ });
144
+ const notices: string[] = [];
145
+ try {
146
+ const calls = llmCalls;
147
+ expect(await runTask(root, parent, notices)).toBe("(no answer)");
148
+ expect(llmCalls).toBe(calls);
149
+ expect(notices.at(-1)).toBe("explore sub-agent stopped, 0 tokens (0 of 1000 spent)");
150
+ } finally {
151
+ spy.mockRestore();
152
+ }
153
+ });
154
+
155
+ test("stopped after it exists but before its run is live, it is stopped as soon as the run starts", async () => {
156
+ const root = await makeTempRoot();
157
+ const parent = new AbortController();
158
+ const stopBeforeTheRun: InlineExtension = {
159
+ name: "stop-before-the-run",
160
+ factory: (pi: ExtensionAPI) => {
161
+ pi.on("before_agent_start", () => {
162
+ parent.abort();
163
+ });
164
+ },
165
+ };
166
+ const create = CodeAgent.create;
167
+ const spy = spyOn(CodeAgent, "create").mockImplementation(
168
+ async (options) => await create.call(CodeAgent, { ...options, extensions: [stopBeforeTheRun] }),
169
+ );
170
+ const notices: string[] = [];
171
+ try {
172
+ expect(await runTask(root, parent, notices)).toBe("(no answer)");
173
+ expect(notices.at(-1)).toMatch(/^explore sub-agent stopped, /);
174
+ } finally {
175
+ spy.mockRestore();
176
+ }
177
+ });
178
+ });
@@ -34,6 +34,8 @@ class FakeTransport implements CodeAgentTransport {
34
34
  }
35
35
 
36
36
  const event = (seq: number, body: Partial<CodeAgentEvent> = {}) => ({ type: "idle", seq, ...body }) as CodeAgentEvent;
37
+ const start = { type: "agent_start" } as never;
38
+ const finished = (stopReason: string) => ({ type: "agent_end", messages: [{ stopReason }], willRetry: false }) as never;
37
39
 
38
40
  describe("CodeAgentClient", () => {
39
41
  test("correlates a reply with the command that asked for it", async () => {
@@ -207,8 +209,6 @@ describe("CodeAgentAsks", () => {
207
209
  });
208
210
 
209
211
  describe("CodeAgentEventMapper", () => {
210
- const start = { type: "agent_start" } as never;
211
-
212
212
  test("a run becomes one turn, not one turn per round trip", () => {
213
213
  const mapper = new CodeAgentEventMapper();
214
214
  expect(mapper.map(start)).toEqual([{ type: "turn_start", turnId: "t1" }]);
@@ -231,10 +231,6 @@ describe("CodeAgentEventMapper", () => {
231
231
  expect(think).toEqual([{ type: "thinking_delta", turnId: "t1", text: "hmm" }]);
232
232
  });
233
233
 
234
- /**
235
- * `String({})` is `"[object Object]"`, and an `edit` call's argument is an array of them — so a row that
236
- * should read "three replacements" read as two identical placeholders instead.
237
- */
238
234
  test("a structured argument is described by its shape, not stringified", () => {
239
235
  const mapper = new CodeAgentEventMapper();
240
236
  mapper.map(start);
@@ -248,7 +244,6 @@ describe("CodeAgentEventMapper", () => {
248
244
  expect(title).not.toContain("[object Object]");
249
245
  expect(title).toContain("edits=2 items");
250
246
  expect(title).toContain("options={1 key}");
251
- // A path still reads as itself: it is the one argument worth showing verbatim.
252
247
  expect(title).toContain("path=/repo/a.ts");
253
248
  });
254
249
 
@@ -289,8 +284,7 @@ describe("CodeAgentEventMapper", () => {
289
284
  test("reports an aborted run as aborted rather than done", () => {
290
285
  const mapper = new CodeAgentEventMapper();
291
286
  mapper.map(start);
292
- const frames = mapper.map({ type: "agent_end", messages: [{ stopReason: "aborted" }], willRetry: false } as never);
293
- expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
287
+ expect(mapper.map(finished("aborted"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
294
288
  });
295
289
 
296
290
  test("a compaction that failed or was cancelled carries that, not just its reason", () => {
@@ -435,10 +429,6 @@ describe("codeAgentShouldPersist", () => {
435
429
  });
436
430
 
437
431
  describe("turn outcome", () => {
438
- const start = { type: "agent_start" } as never;
439
- const finished = (stopReason: string) =>
440
- ({ type: "agent_end", messages: [{ stopReason }], willRetry: false }) as never;
441
-
442
432
  test("an abort is reported even though the engine calls it a normal stop", () => {
443
433
  const mapper = new CodeAgentEventMapper();
444
434
  mapper.map(start);
@@ -477,25 +467,17 @@ describe("turn outcome", () => {
477
467
  });
478
468
 
479
469
  describe("truncation", () => {
480
- const start = { type: "agent_start" } as never;
481
-
482
470
  test("the engine's `length` is a partial success, not a completed turn", () => {
483
471
  const mapper = new CodeAgentEventMapper();
484
472
  mapper.map(start);
485
- const frames = mapper.map({
486
- type: "agent_end",
487
- messages: [{ stopReason: "length" }],
488
- willRetry: false,
489
- } as never);
490
- expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "truncated" }]);
473
+ expect(mapper.map(finished("length"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "truncated" }]);
491
474
  });
492
475
 
493
476
  test("an abort still wins over the engine's report", () => {
494
477
  const mapper = new CodeAgentEventMapper();
495
478
  mapper.map(start);
496
479
  mapper.noteOutcome("aborted");
497
- const frames = mapper.map({ type: "agent_end", messages: [{ stopReason: "length" }], willRetry: false } as never);
498
- expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
480
+ expect(mapper.map(finished("length"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
499
481
  });
500
482
 
501
483
  test("the terminal says so, because a cut answer looks like a finished one", () => {
@@ -526,17 +508,12 @@ describe("akanCodeModelWarnings", () => {
526
508
  });
527
509
 
528
510
  describe("wire coverage", () => {
529
- /**
530
- * The audit that found `context` declared, rendered, and never emitted. A contract entry with no producer
531
- * costs every host a branch for a frame that cannot arrive.
532
- */
533
511
  test("every event type either has a producer in the core or is documented as host-supplied", async () => {
534
512
  const dir = import.meta.dir;
535
513
  const sources = await Promise.all(
536
514
  ["CodeAgent.ts", "CodeAgentEventMapper.ts"].map(async (file) => await Bun.file(`${dir}/${file}`).text()),
537
515
  );
538
516
  const core = sources.join("\n");
539
- // `host` is the one frame the core never makes: it is the slot a host fills with its own vocabulary.
540
517
  const hostSupplied = new Set(["host"]);
541
518
  const missing = Object.keys(codeAgentEventPersistence).filter(
542
519
  (type) => !hostSupplied.has(type) && !core.includes(`type: "${type}"`),