@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
@@ -1,11 +1,13 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import { codeAgentPresets } from "akanjs/common";
4
- import { cleanupCliTempWorkspace, makeCliTempWorkspace, writeText } from "../../testHelpers";
4
+ import { makeCliTempWorkspace, tempRoots, writeText } from "../../testHelpers";
5
+ import { CodeAgent, type CodeAgentOptions } from "../agent/CodeAgent";
5
6
  import { SubagentPool } from "../agent/SubagentPool";
6
7
  import { DevLogFeedback } from "../feedback/DevLogFeedback";
7
8
  import { PreviewView } from "../feedback/PreviewView";
8
9
  import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
10
+ import { AkanCodePlugins } from "./AkanCodePlugins";
9
11
  import { AkanEditScope } from "./AkanEditScope";
10
12
  import { AkanEnvKeys } from "./AkanEnvKeys";
11
13
  import { AkanVerifier } from "./AkanVerifier";
@@ -14,11 +16,22 @@ import { McpToolPack } from "./McpToolPack";
14
16
  import { SessionToolPack } from "./SessionToolPack";
15
17
  import { WebToolPack } from "./WebToolPack";
16
18
 
17
- const tempRoots: string[] = [];
19
+ const track = tempRoots();
20
+ const tempRoot = async () => track(await makeCliTempWorkspace()).root;
18
21
 
19
- afterEach(async () => {
20
- await Promise.all(tempRoots.splice(0).map((root) => cleanupCliTempWorkspace(root)));
21
- });
22
+ type ToolExecute = (
23
+ id: string,
24
+ params: never,
25
+ signal?: AbortSignal,
26
+ ) => Promise<{ content: { text: string }[]; isError?: boolean }>;
27
+
28
+ const toolsOf = (extension: unknown) => {
29
+ const tools = new Map<string, ToolExecute>();
30
+ (extension as { factory: (pi: unknown) => void }).factory({
31
+ registerTool: (tool: { name: string; execute: ToolExecute }) => tools.set(tool.name, tool.execute),
32
+ });
33
+ return tools;
34
+ };
22
35
 
23
36
  const source = (key: string, findings: (string | undefined)[]): TurnFeedbackSource => {
24
37
  let call = 0;
@@ -52,8 +65,6 @@ describe("TurnFeedback", () => {
52
65
  expect(await feedback.collect()).toContain("still broken");
53
66
  expect(await feedback.collect()).toContain("still broken");
54
67
  expect(await feedback.collect()).toBeUndefined();
55
- // Every reopen is announced: a turn nobody asked for looks, on the transcript, like an agent that will
56
- // not stop — more tool calls after the answer and nothing naming who sent it back to work.
57
68
  expect(notices[0]).toContain("a reopened the turn");
58
69
  expect(notices.at(-1)).toContain("still failing after 2 attempts");
59
70
  });
@@ -136,8 +147,7 @@ describe("AkanVerifier.summarize", () => {
136
147
 
137
148
  describe("AkanEditScope", () => {
138
149
  test("derives apps, libs, sync need and tsx from a git status diff", async () => {
139
- const { root } = await makeCliTempWorkspace();
140
- tempRoots.push(root);
150
+ const root = await tempRoot();
141
151
  await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
142
152
  const baseline = await AkanEditScope.baseline(root);
143
153
  await writeText(path.join(root, "apps", "demo", "lib", "post", "post.service.ts"), "export {};\n");
@@ -150,8 +160,7 @@ describe("AkanEditScope", () => {
150
160
  });
151
161
 
152
162
  test("an unchanged tree implicates no target", async () => {
153
- const { root } = await makeCliTempWorkspace();
154
- tempRoots.push(root);
163
+ const root = await tempRoot();
155
164
  await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
156
165
  const baseline = await AkanEditScope.baseline(root);
157
166
  const scope = await AkanEditScope.since(root, baseline);
@@ -162,10 +171,6 @@ describe("AkanEditScope", () => {
162
171
  describe("DevLogFeedback", () => {
163
172
  const logPath = (root: string, app: string) => path.join(root, "local", "apps", app, "runtime", "dev.log");
164
173
 
165
- /**
166
- * A turn that wrote nothing cannot have broken the running app, so the source is gated on the working tree
167
- * moving — which makes a repo with one edit in it the minimum a test has to set up.
168
- */
169
174
  const armed = async (root: string, feedback: DevLogFeedback) => {
170
175
  Bun.spawnSync(["git", "init", "-q"], { cwd: root });
171
176
  await feedback.begin();
@@ -173,8 +178,7 @@ describe("DevLogFeedback", () => {
173
178
  };
174
179
 
175
180
  test("reports only the errors written after the watermark", async () => {
176
- const { root } = await makeCliTempWorkspace();
177
- tempRoots.push(root);
181
+ const root = await tempRoot();
178
182
  await writeText(logPath(root, "demo"), "[demo] boot ok\n");
179
183
  const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
180
184
  await armed(root, feedback);
@@ -185,8 +189,7 @@ describe("DevLogFeedback", () => {
185
189
  });
186
190
 
187
191
  test("stays quiet when the new lines are only debug noise", async () => {
188
- const { root } = await makeCliTempWorkspace();
189
- tempRoots.push(root);
192
+ const root = await tempRoot();
190
193
  await writeText(logPath(root, "demo"), "start\n");
191
194
  const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
192
195
  await armed(root, feedback);
@@ -195,16 +198,14 @@ describe("DevLogFeedback", () => {
195
198
  });
196
199
 
197
200
  test("no dev server means nothing to say, not an error", async () => {
198
- const { root } = await makeCliTempWorkspace();
199
- tempRoots.push(root);
201
+ const root = await tempRoot();
200
202
  const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
201
203
  await armed(root, feedback);
202
204
  expect(await feedback.observe()).toBeUndefined();
203
205
  });
204
206
 
205
207
  test("a turn that changed nothing is not told about a dev server somebody else is driving", async () => {
206
- const { root } = await makeCliTempWorkspace();
207
- tempRoots.push(root);
208
+ const root = await tempRoot();
208
209
  Bun.spawnSync(["git", "init", "-q"], { cwd: root });
209
210
  await writeText(logPath(root, "demo"), "[demo] boot ok\n");
210
211
  const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
@@ -214,8 +215,7 @@ describe("DevLogFeedback", () => {
214
215
  });
215
216
 
216
217
  test("reads the dev server's allocated URL back out of its own log", async () => {
217
- const { root } = await makeCliTempWorkspace();
218
- tempRoots.push(root);
218
+ const root = await tempRoot();
219
219
  await writeText(
220
220
  logPath(root, "demo"),
221
221
  "[demo] demo ready (pid=1) — http://localhost:3000\n[demo] demo ready (pid=2) — http://localhost:8282\n",
@@ -227,8 +227,7 @@ describe("DevLogFeedback", () => {
227
227
 
228
228
  describe("AkanEnvKeys", () => {
229
229
  test("maps <PROVIDER>_API_KEY onto the engine's provider id without persisting it", async () => {
230
- const { root } = await makeCliTempWorkspace();
231
- tempRoots.push(root);
230
+ const root = await tempRoot();
232
231
  await writeText(path.join(root, ".env"), '# comment\nDEEPSEEK_API_KEY="sk-test"\nSOMETHING_ELSE=1\n');
233
232
  const applied: [string, string][] = [];
234
233
  const fake = {
@@ -295,16 +294,11 @@ describe("SubagentPool", () => {
295
294
  test("a code child keeps the parent's tools, and never more than them", () => {
296
295
  const full = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "code");
297
296
  expect(full.tools.builtin).toContain("write");
298
- // A read-only parent stays read-only through the kind that is allowed to write: narrowing only narrows.
299
297
  const reader = SubagentPool.childOf(codeAgentPresets.review("/tmp/akan"), "code");
300
298
  expect(reader.tools.builtin).not.toContain("write");
301
299
  expect(reader.tools.builtin).not.toContain("bash");
302
300
  });
303
301
 
304
- /**
305
- * A refused call opens no child, so it must leave no row behind either — a rail that shows a sub-agent the
306
- * pool never started reads as work in flight, and the budget it was refused over says the opposite.
307
- */
308
302
  test("a refusal reports no running sub-agent", async () => {
309
303
  const parent = codeAgentPresets.local("/tmp/akan");
310
304
  const spent = { ...parent, tools: { ...parent.tools, subagent: { maxConcurrent: 0, maxDepth: 2, budget: 0 } } };
@@ -324,7 +318,202 @@ describe("SubagentPool", () => {
324
318
  expect(frames).toEqual([]);
325
319
  });
326
320
 
327
- /** Nothing a sub-agent does belongs in the workspace's session tree, and nobody is there to answer it. */
321
+ const budgeted = (budget: number, maxConcurrent = 3) => {
322
+ const parent = codeAgentPresets.local("/tmp/akan");
323
+ return {
324
+ ...parent,
325
+ tools: { ...parent.tools, mcp: "off" as const, subagent: { maxConcurrent, maxDepth: 2, budget } },
326
+ };
327
+ };
328
+ const openFakeAgents = (tokens: number[]) => {
329
+ const opened: CodeAgentOptions[] = [];
330
+ const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
331
+ opened.push(options);
332
+ const tokensUsed = tokens[opened.length - 1] ?? 0;
333
+ return {
334
+ on: () => {},
335
+ prompt: async () => {},
336
+ waitForIdle: async () => {},
337
+ dispose: () => {},
338
+ tokensUsed,
339
+ } as never;
340
+ });
341
+ return { opened, spy };
342
+ };
343
+ const runTask = async (extension: unknown, id: string, signal?: AbortSignal) =>
344
+ await toolsOf(extension).get("task")?.(id, { description: "d", prompt: "p" } as never, signal);
345
+ const until = async (done: () => boolean) => {
346
+ for (let tries = 0; tries < 500 && !done(); tries += 1) await Bun.sleep(2);
347
+ };
348
+ const poolOf = (child: CodeAgentOptions) =>
349
+ SubagentPool.extensionFor({
350
+ workspace,
351
+ cwd: "/tmp/akan",
352
+ parent: child.profile,
353
+ depth: child.depth ?? 0,
354
+ ...(child.subagentSpend ? { spend: child.subagentSpend } : {}),
355
+ });
356
+
357
+ test("every pool of one tree draws on one token budget", async () => {
358
+ const { opened, spy } = openFakeAgents([150, 100]);
359
+ try {
360
+ const root = SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent: budgeted(200), depth: 0 });
361
+ expect((await runTask(root, "c1"))?.isError).toBeUndefined();
362
+ const [child] = opened;
363
+ if (!child) throw new Error("no sub-agent was opened");
364
+ const inner = SubagentPool.extensionFor({
365
+ workspace,
366
+ cwd: "/tmp/akan",
367
+ parent: child.profile,
368
+ depth: child.depth ?? 0,
369
+ ...(child.subagentSpend ? { spend: child.subagentSpend } : {}),
370
+ });
371
+ expect((await runTask(inner, "c2"))?.isError).toBeUndefined();
372
+ for (const pool of [root, inner]) {
373
+ const refused = await runTask(pool, "c3");
374
+ expect(refused?.isError).toBe(true);
375
+ expect(refused?.content[0]?.text).toContain("token budget (200) is spent");
376
+ }
377
+ expect(opened).toHaveLength(2);
378
+ } finally {
379
+ spy.mockRestore();
380
+ }
381
+ });
382
+
383
+ test("the plugins a sub-agent builds hand its pool the tree's tally", async () => {
384
+ const { opened, spy } = openFakeAgents([]);
385
+ try {
386
+ const plugins = await AkanCodePlugins.build({
387
+ workspace,
388
+ cwd: "/tmp/akan",
389
+ profile: budgeted(200),
390
+ apps: [],
391
+ canSeeImages: false,
392
+ depth: 1,
393
+ currentSessionId: () => "",
394
+ mailbox: () => undefined,
395
+ subagentSpend: { tokens: 200, running: 0 },
396
+ });
397
+ const pool = plugins.extensions.find((extension) => extension.name === "akan-subagent");
398
+ const refused = await runTask(pool, "c1");
399
+ expect(refused?.isError).toBe(true);
400
+ expect(opened).toHaveLength(0);
401
+ plugins.dispose();
402
+ } finally {
403
+ spy.mockRestore();
404
+ }
405
+ });
406
+
407
+ test("maxConcurrent counts every running sub-agent of the tree, and a full tree refuses a child's task instead of queueing it", async () => {
408
+ const held = Promise.withResolvers<void>();
409
+ const opened: CodeAgentOptions[] = [];
410
+ const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
411
+ opened.push(options);
412
+ return {
413
+ on: () => {},
414
+ prompt: async () => await held.promise,
415
+ waitForIdle: async () => {},
416
+ dispose: () => {},
417
+ tokensUsed: 0,
418
+ } as never;
419
+ });
420
+ try {
421
+ const root = SubagentPool.extensionFor({
422
+ workspace,
423
+ cwd: "/tmp/akan",
424
+ parent: budgeted(1_000_000, 2),
425
+ depth: 0,
426
+ });
427
+ const running = [runTask(root, "c1"), runTask(root, "c2")];
428
+ await until(() => opened.length === 2);
429
+ const [child] = opened;
430
+ if (!child) throw new Error("no sub-agent was opened");
431
+ const refused = await Promise.race([runTask(poolOf(child), "c3"), Bun.sleep(500).then(() => undefined)]);
432
+ expect(refused?.isError).toBe(true);
433
+ expect(refused?.content[0]?.text).toContain("limit 2 at once");
434
+ expect(refused?.content[0]?.text).not.toContain("Wait");
435
+ expect((await runTask(root, "c4"))?.content[0]?.text).toContain("Wait for one of yours");
436
+ expect(opened).toHaveLength(2);
437
+ held.resolve();
438
+ await Promise.all(running);
439
+ expect((await runTask(poolOf(child), "c5"))?.isError).toBeUndefined();
440
+ expect(opened).toHaveLength(3);
441
+ } finally {
442
+ held.resolve();
443
+ spy.mockRestore();
444
+ }
445
+ });
446
+
447
+ test("a sub-agent that fails still spends from the tree's budget, once, and the notice says it failed", async () => {
448
+ const opened: CodeAgentOptions[] = [];
449
+ const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
450
+ opened.push(options);
451
+ return {
452
+ on: () => {},
453
+ prompt: async () => {
454
+ throw new Error("provider went away");
455
+ },
456
+ waitForIdle: async () => {},
457
+ dispose: () => {},
458
+ tokensUsed: 150,
459
+ } as never;
460
+ });
461
+ const notices: string[] = [];
462
+ try {
463
+ const root = SubagentPool.extensionFor({
464
+ workspace,
465
+ cwd: "/tmp/akan",
466
+ parent: budgeted(100),
467
+ depth: 0,
468
+ onNotice: (message) => notices.push(message),
469
+ });
470
+ await expect(runTask(root, "c1")).rejects.toThrow("provider went away");
471
+ expect(notices.at(-1)).toBe("explore sub-agent failed, 150 tokens (150 of 100 spent)");
472
+ expect((await runTask(root, "c2"))?.content[0]?.text).toContain("token budget (100) is spent");
473
+ expect(opened).toHaveLength(1);
474
+ } finally {
475
+ spy.mockRestore();
476
+ }
477
+ });
478
+
479
+ test("a sub-agent its parent stops is counted once, and the notice says it stopped", async () => {
480
+ const prompted = Promise.withResolvers<void>();
481
+ const stopped = Promise.withResolvers<void>();
482
+ const spy = spyOn(CodeAgent, "create").mockImplementation(
483
+ async () =>
484
+ ({
485
+ on: () => {},
486
+ prompt: async () => {
487
+ prompted.resolve();
488
+ await stopped.promise;
489
+ },
490
+ waitForIdle: async () => {},
491
+ abort: async () => stopped.resolve(),
492
+ dispose: () => {},
493
+ tokensUsed: 120,
494
+ }) as never,
495
+ );
496
+ const notices: string[] = [];
497
+ try {
498
+ const root = SubagentPool.extensionFor({
499
+ workspace,
500
+ cwd: "/tmp/akan",
501
+ parent: budgeted(1_000),
502
+ depth: 0,
503
+ onNotice: (message) => notices.push(message),
504
+ });
505
+ const parent = new AbortController();
506
+ const task = runTask(root, "c1", parent.signal);
507
+ await prompted.promise;
508
+ parent.abort();
509
+ await task;
510
+ expect(notices.at(-1)).toBe("explore sub-agent stopped, 120 tokens (120 of 1000 spent)");
511
+ } finally {
512
+ stopped.resolve();
513
+ spy.mockRestore();
514
+ }
515
+ });
516
+
328
517
  test("a child keeps no session and cannot ask a human", () => {
329
518
  const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
330
519
  expect(child.session).toEqual({ store: "memory", crossSession: false });
@@ -334,7 +523,6 @@ describe("SubagentPool", () => {
334
523
  });
335
524
 
336
525
  describe("McpToolPack", () => {
337
- /** A minimal stdio MCP server, written at test time so the suite carries no extra source file. */
338
526
  const serverSource = `
339
527
  const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n");
340
528
  for await (const line of console) {
@@ -349,8 +537,7 @@ for await (const line of console) {
349
537
  `;
350
538
 
351
539
  const withServer = async () => {
352
- const { root } = await makeCliTempWorkspace();
353
- tempRoots.push(root);
540
+ const root = await tempRoot();
354
541
  const script = path.join(root, "echo-mcp.ts");
355
542
  await writeText(script, serverSource);
356
543
  await writeText(
@@ -383,8 +570,7 @@ for await (const line of console) {
383
570
  });
384
571
 
385
572
  test("a server that cannot start costs its own tools and nothing else", async () => {
386
- const { root } = await makeCliTempWorkspace();
387
- tempRoots.push(root);
573
+ const root = await tempRoot();
388
574
  await writeText(
389
575
  path.join(root, ".akan", "code", "mcp.json"),
390
576
  JSON.stringify({ mcpServers: { broken: { command: "/nonexistent/binary" } } }),
@@ -397,8 +583,6 @@ for await (const line of console) {
397
583
  });
398
584
  expect(pack?.toolNames).toEqual([]);
399
585
  expect(notices.join(" ")).toContain("broken");
400
- // The pack survives the failure so `/mcp` can say what went wrong; a pack that dissolved would leave the
401
- // server indistinguishable from one nobody declared.
402
586
  expect(pack?.status).toEqual([
403
587
  { name: "broken", transport: "stdio", target: "/nonexistent/binary", tools: [], error: expect.any(String) },
404
588
  ]);
@@ -448,28 +632,16 @@ describe("WebToolPack", () => {
448
632
  });
449
633
  try {
450
634
  const profile = codeAgentPresets.local(root);
451
- const pack = new WebToolPack({ profile });
452
- const tools = new Map<
453
- string,
454
- (id: string, params: never) => Promise<{ content: { text: string }[]; isError?: boolean }>
455
- >();
456
- const factory = (pack.extension() as { factory: (pi: unknown) => void }).factory;
457
- factory({ registerTool: (tool: { name: string; execute: never }) => tools.set(tool.name, tool.execute) });
458
- const fetchTool = tools.get("web_fetch");
635
+ const fetchTool = toolsOf(new WebToolPack({ profile }).extension()).get("web_fetch");
459
636
  const ok = await fetchTool?.("1", { url: `http://localhost:${server.port}/` } as never);
460
637
  expect(ok?.content[0]?.text).toContain("Akan");
461
638
  expect(ok?.content[0]?.text).toContain("Hello & welcome");
462
639
  expect(ok?.content[0]?.text).not.toContain("<h1>");
463
640
 
464
641
  const narrowed = new WebToolPack({ profile: { ...profile, network: { allowHosts: ["docs.akanjs.com"] } } });
465
- const narrowedTools = new Map<
466
- string,
467
- (id: string, params: never) => Promise<{ content: { text: string }[]; isError?: boolean }>
468
- >();
469
- (narrowed.extension() as { factory: (pi: unknown) => void }).factory({
470
- registerTool: (tool: { name: string; execute: never }) => narrowedTools.set(tool.name, tool.execute),
471
- });
472
- const refused = await narrowedTools.get("web_fetch")?.("1", { url: `http://localhost:${server.port}/` } as never);
642
+ const refused = await toolsOf(narrowed.extension()).get("web_fetch")?.("1", {
643
+ url: `http://localhost:${server.port}/`,
644
+ } as never);
473
645
  expect(refused?.isError).toBe(true);
474
646
  expect(refused?.content[0]?.text).toContain("docs.akanjs.com");
475
647
  } finally {
@@ -497,12 +669,8 @@ describe("SessionToolPack", () => {
497
669
  });
498
670
 
499
671
  test("says so plainly when nothing earlier matches", async () => {
500
- const { root } = await makeCliTempWorkspace();
501
- tempRoots.push(root);
502
- const tools = new Map<string, (id: string, params: never) => Promise<{ content: { text: string }[] }>>();
503
- (new SessionToolPack(options(root)).extension() as { factory: (pi: unknown) => void }).factory({
504
- registerTool: (tool: { name: string; execute: never }) => tools.set(tool.name, tool.execute),
505
- });
672
+ const root = await tempRoot();
673
+ const tools = toolsOf(new SessionToolPack(options(root)).extension());
506
674
  const result = await tools.get("session_search")?.("1", { query: "tunnel wire" } as never);
507
675
  expect(result?.content[0]?.text).toContain("No earlier session");
508
676
  });
@@ -11,7 +11,6 @@ interface RegisteredTool {
11
11
  ) => Promise<{ content: { text?: string }[]; isError?: boolean }>;
12
12
  }
13
13
 
14
- /** Runs the pack's extension and hands back the one tool it registered, plus the questions it went on to ask. */
15
14
  const mount = (
16
15
  profile: CodeAgentProfile,
17
16
  answer: (question: Omit<CodeAgentQuestion, "questionId">) => string | undefined,
@@ -43,7 +42,6 @@ const one = (patch: Record<string, unknown> = {}) => ({
43
42
  describe("ask_user", () => {
44
43
  test("it is published only where somebody can answer", () => {
45
44
  expect(mount(local, () => "x").pack.names()).toEqual(["ask_user"]);
46
- // A pod and a sub-agent both set `canPrompt` false; the tool is never constructed there.
47
45
  expect(mount(pod, () => "x").pack.names()).toEqual([]);
48
46
  expect(mount(pod, () => "x").tool).toBeUndefined();
49
47
  });
@@ -84,7 +82,6 @@ describe("ask_user", () => {
84
82
  expect(asked).toHaveLength(AskToolPack.maxQuestions);
85
83
  });
86
84
 
87
- /** A blank answer reads to a model as "the user wanted nothing", which is the one thing a skip never means. */
88
85
  test("a skipped question says so, and says what to do about it", async () => {
89
86
  const { tool } = mount(local, () => undefined);
90
87
  const result = await tool?.execute("t", { questions: [one()] });