@akanjs/devkit 3.0.0-beta.9 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,214 @@
1
+ import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
2
+ import { type CodeAgentProfile, type CodeAgentSubagent, codeAgentReadOnlyBuiltins } from "akanjs/common";
3
+ import { Type } from "typebox";
4
+ import type { Workspace } from "../../commandDecorators";
5
+
6
+ export interface SubagentPoolOptions {
7
+ workspace: Workspace;
8
+ cwd: string;
9
+ parent: CodeAgentProfile;
10
+ depth: number;
11
+ /** What the tree above this pool has spent and has running; a root pool starts its own. */
12
+ spend?: SubagentSpend;
13
+ onNotice?: (message: string) => void;
14
+ /** The running children, whole, for a host that draws them as a rail beside the prompt. */
15
+ onAgents?: (agents: CodeAgentSubagent[]) => void;
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
+
24
+ const maxResultChars = 8_000;
25
+
26
+ // Children are re-reported every second: their token counts climb between start and finish.
27
+ const tickMs = 1_000;
28
+
29
+ export type SubagentKind = keyof typeof SubagentPool.kinds;
30
+
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.
33
+ export class SubagentPool {
34
+ static readonly kinds = {
35
+ explore: {
36
+ readOnly: true,
37
+ desc: "reads, searches and reports back. It cannot write a file or run a command.",
38
+ },
39
+ code: {
40
+ readOnly: false,
41
+ desc: "makes the change itself, with the same tools as this session.",
42
+ },
43
+ } as const;
44
+
45
+ // A writer works unwatched on the parent's checkout, so it has to be asked for by name, never defaulted.
46
+ static readonly defaultKind: SubagentKind = "explore";
47
+
48
+ readonly #options: SubagentPoolOptions;
49
+ readonly #children = new Map<
50
+ string,
51
+ { kind: SubagentKind; description: string; startedAt: number; agent?: { tokensUsed: number } }
52
+ >();
53
+ readonly #spend: SubagentSpend;
54
+ #ticker: ReturnType<typeof setInterval> | null = null;
55
+
56
+ constructor(options: SubagentPoolOptions) {
57
+ this.#options = options;
58
+ this.#spend = options.spend ?? { tokens: 0, running: 0 };
59
+ }
60
+
61
+ static extensionFor(options: SubagentPoolOptions): InlineExtension | undefined {
62
+ if (!options.parent.tools.subagent) return undefined;
63
+ if (options.depth >= options.parent.tools.subagent.maxDepth) return undefined;
64
+ const pool = new SubagentPool(options);
65
+ return { name: "akan-subagent", factory: (pi: ExtensionAPI) => pool.#register(pi) };
66
+ }
67
+
68
+ static readonly toolName = "task";
69
+
70
+ #register(pi: ExtensionAPI) {
71
+ pi.registerTool({
72
+ name: SubagentPool.toolName,
73
+ label: "Task",
74
+ description:
75
+ "Run a focused sub-agent on one self-contained question and get back its answer. Use it for searching and reading that would otherwise fill this conversation — the sub-agent's own reading does not come back, only its answer. Give it everything it needs: it cannot see this conversation.",
76
+ promptSnippet: "task: run a sub-agent on one self-contained question and get back only its answer",
77
+ parameters: Type.Object({
78
+ description: Type.String({ description: "Three to five words naming the task, for the activity log." }),
79
+ prompt: Type.String({
80
+ description: "The complete instruction. The sub-agent sees nothing of this conversation.",
81
+ }),
82
+ type: Type.Optional(
83
+ Type.Union(
84
+ Object.entries(SubagentPool.kinds).map(([kind, spec]) => Type.Literal(kind, { description: spec.desc })),
85
+ { description: `What the sub-agent may do. Defaults to ${SubagentPool.defaultKind}.` },
86
+ ),
87
+ ),
88
+ }),
89
+ execute: async (id, params, signal) => {
90
+ const kind = (params.type ?? SubagentPool.defaultKind) as SubagentKind;
91
+ const refusal = this.#refuse();
92
+ if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
93
+ this.#spend.running += 1;
94
+ this.#children.set(id, { kind, description: params.description, startedAt: Date.now() });
95
+ this.#report();
96
+ // Announced at start: a turn quiet for a minute while a child reads looks like one that stopped responding.
97
+ this.#options.onNotice?.(`${kind} sub-agent · ${params.description}`);
98
+ try {
99
+ const text = await this.#run(id, kind, params.prompt, signal);
100
+ return { content: [{ type: "text", text }], details: undefined };
101
+ } finally {
102
+ this.#spend.running -= 1;
103
+ this.#children.delete(id);
104
+ this.#report();
105
+ }
106
+ },
107
+ });
108
+ }
109
+
110
+ #refuse() {
111
+ const budget = this.#options.parent.tools.subagent;
112
+ if (!budget) return "Sub-agents are disabled by this profile.";
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)
117
+ return `The sub-agent token budget (${budget.budget}) is spent. Do the rest of this work yourself.`;
118
+ return undefined;
119
+ }
120
+
121
+ /** The parent profile narrowed, never widened: no session file, no `AGENTS.md`, nobody to ask; skills stay. */
122
+ static childOf(parent: CodeAgentProfile, kind: SubagentKind): CodeAgentProfile {
123
+ const budget = parent.tools.subagent;
124
+ return {
125
+ ...parent,
126
+ name: `${parent.name}:${kind}`,
127
+ tools: {
128
+ ...parent.tools,
129
+ // Filtered, not replaced: a child of a read-only session must never gain a write tool.
130
+ builtin: SubagentPool.kinds[kind].readOnly
131
+ ? parent.tools.builtin.filter((tool) => codeAgentReadOnlyBuiltins.includes(tool))
132
+ : parent.tools.builtin,
133
+ subagent: budget ? { ...budget, maxDepth: budget.maxDepth } : false,
134
+ },
135
+ session: { store: "memory", crossSession: false },
136
+ ui: { canPrompt: false },
137
+ context: { projectFiles: false, skills: parent.context.skills },
138
+ };
139
+ }
140
+
141
+ // One ticker per pool, unref'd: a child left registered is a bug that must not hold the process open.
142
+ #report() {
143
+ const onAgents = this.#options.onAgents;
144
+ if (!onAgents) return;
145
+ onAgents(
146
+ [...this.#children].map(([id, child]) => ({
147
+ id,
148
+ kind: child.kind,
149
+ description: child.description,
150
+ startedAt: child.startedAt,
151
+ tokens: child.agent?.tokensUsed ?? 0,
152
+ })),
153
+ );
154
+ if (this.#children.size && !this.#ticker) {
155
+ this.#ticker = setInterval(() => this.#report(), tickMs);
156
+ this.#ticker.unref?.();
157
+ } else if (!this.#children.size && this.#ticker) {
158
+ clearInterval(this.#ticker);
159
+ this.#ticker = null;
160
+ }
161
+ }
162
+
163
+ async #run(id: string, kind: SubagentKind, prompt: string, signal: AbortSignal | undefined) {
164
+ const { CodeAgent } = await import("./CodeAgent");
165
+ const budget = this.#options.parent.tools.subagent;
166
+ const child = SubagentPool.childOf(this.#options.parent, kind);
167
+ const agent = await CodeAgent.create({
168
+ workspace: this.#options.workspace,
169
+ cwd: this.#options.cwd,
170
+ profile: child,
171
+ apps: [],
172
+ depth: this.#options.depth + 1,
173
+ subagentSpend: this.#spend,
174
+ });
175
+ const running = this.#children.get(id);
176
+ if (running) running.agent = agent;
177
+ let stopped = false;
178
+ const abort = () => {
179
+ stopped = true;
180
+ void agent.abort();
181
+ };
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;
186
+ try {
187
+ let answer = "";
188
+ agent.on((event) => {
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();
192
+ });
193
+ if (!stopped) {
194
+ await agent.prompt(prompt);
195
+ await agent.waitForIdle();
196
+ }
197
+ finished = true;
198
+ return answer.length > maxResultChars
199
+ ? `${answer.slice(0, maxResultChars)}\n…(truncated)`
200
+ : answer || "(no answer)";
201
+ } finally {
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
+ );
211
+ agent.dispose();
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,47 @@
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+
3
+ // The engine's `Model` type (from `pi-ai`) is not re-exported, so it is recovered from `getModel`.
4
+ export type CodeAgentModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
5
+
6
+ export interface CodeAgentModelRef {
7
+ provider: string;
8
+ id: string;
9
+ }
10
+
11
+ /** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
12
+ export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
13
+
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.
16
+ const catalogueMissesImages = new Set(["deepseek/deepseek-v4-flash"]);
17
+
18
+ const correctInputs = (model: CodeAgentModel | undefined) => {
19
+ if (!model || model.input.includes("image")) return model;
20
+ if (!catalogueMissesImages.has(`${model.provider}/${model.id}`)) return model;
21
+ return { ...model, input: [...model.input, "image" as const] };
22
+ };
23
+
24
+ // An empty API key yields a successful empty response rather than an error, so unauthorized providers are skipped.
25
+ export const akanCodeModel = async (runtime: ModelRuntime, ref?: CodeAgentModelRef) => {
26
+ if (ref) {
27
+ const requested = runtime.getModel(ref.provider, ref.id);
28
+ if (!requested) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
29
+ if (!runtime.hasConfiguredAuth(ref.provider))
30
+ throw new Error(`No API key configured for ${ref.provider}. Set ${ref.provider.toUpperCase()}_API_KEY.`);
31
+ return correctInputs(requested);
32
+ }
33
+ const fallback = runtime.getModel(akanCodeDefaultModel.provider, akanCodeDefaultModel.id);
34
+ if (fallback && runtime.hasConfiguredAuth(fallback.provider)) return correctInputs(fallback);
35
+ return correctInputs((await runtime.getAvailable())[0]);
36
+ };
37
+
38
+ export const akanCodeModelSupportsImages = (model: CodeAgentModel | undefined) => !!model?.input.includes("image");
39
+
40
+ // Deliberately the only check: `maxTokens >= contextWindow` or `reserveTokens > maxTokens` flag 1 model in 7.
41
+ export const akanCodeModelWarnings = (model: CodeAgentModel | undefined, compactionFloor: number) => {
42
+ if (!model) return [];
43
+ if (model.contextWindow >= compactionFloor) return [];
44
+ return [
45
+ `${model.provider}/${model.id} declares a ${model.contextWindow}-token context window, below the ${compactionFloor} the compaction policy needs, so every turn will compact immediately. Check the model entry in ~/.akan/code/models.json.`,
46
+ ];
47
+ };
@@ -0,0 +1,31 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ // Two candidates: the CLI bundle flattens entry chunks into the package root, where `skills/` is a sibling.
6
+ const builtinSkillsDir = () => {
7
+ const candidates = [path.join(import.meta.dir, "skills"), path.join(import.meta.dir, "..", "skills")];
8
+ return candidates.find((candidate) => existsSync(candidate));
9
+ };
10
+
11
+ // One override for the whole set, so a `models.json` and the `auth.json` it names never drift apart.
12
+ const globalDir = () => process.env.AKAN_CODE_HOME ?? path.join(homedir(), ".akan", "code");
13
+
14
+ // Credentials live under the home directory, never the repo: a repo is what gets committed, zipped and shared.
15
+ export const akanCodePaths = {
16
+ workspaceDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code"),
17
+ sessionsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "sessions"),
18
+ extensionsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "extensions"),
19
+ skillsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "skills"),
20
+ mailDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mail"),
21
+ builtinSkillsDir,
22
+ mcpFile: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mcp.json"),
23
+ /** Person-wide MCP servers; the workspace `mcpFile` wins on a name they share. */
24
+ globalMcpFile: () => path.join(globalDir(), "mcp.json"),
25
+ globalSkillsDir: () => path.join(globalDir(), "skills"),
26
+ globalDir,
27
+ authFile: () => path.join(globalDir(), "auth.json"),
28
+ /** MCP bearer tokens, beside the provider keys and for the same reason: never in a directory that is shared. */
29
+ mcpAuthFile: () => path.join(globalDir(), "mcpAuth.json"),
30
+ modelsFile: () => path.join(globalDir(), "models.json"),
31
+ } as const;
@@ -0,0 +1,114 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
6
+
7
+ const roots: string[] = [];
8
+
9
+ const workspace = (files: Record<string, string>) => {
10
+ const root = mkdtempSync(path.join(tmpdir(), "akan-context-"));
11
+ roots.push(root);
12
+ for (const [name, content] of Object.entries(files)) {
13
+ const file = path.join(root, name);
14
+ mkdirSync(path.dirname(file), { recursive: true });
15
+ writeFileSync(file, content);
16
+ }
17
+ return root;
18
+ };
19
+
20
+ const loaded = (root: string, name = "AGENTS.md"): AkanContextFile[] => [
21
+ { path: path.join(root, name), content: "the workspace guide" },
22
+ ];
23
+
24
+ const namesOf = (files: AkanContextFile[], root: string) =>
25
+ files.map((file) => path.relative(root, file.path).split(path.sep).join("/"));
26
+
27
+ afterEach(() => {
28
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
29
+ });
30
+
31
+ describe("workspace context files", () => {
32
+ test("a CLAUDE.md beside the AGENTS.md the engine took is loaded too", () => {
33
+ const root = workspace({ "AGENTS.md": "the workspace guide", "CLAUDE.md": "comments override" });
34
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
35
+ expect(namesOf(files, root)).toEqual(["AGENTS.md", "CLAUDE.md"]);
36
+ expect(files[1]?.content).toBe("comments override");
37
+ });
38
+
39
+ test("an AGENTS.override.md keeps the CLAUDE.md it was written to displace out", () => {
40
+ const root = workspace({ "AGENTS.override.md": "instead of that", "CLAUDE.md": "the old one" });
41
+ const files = AkanContextFiles.extend(loaded(root, "AGENTS.override.md"), { workspaceRoot: root, cwd: root });
42
+ expect(namesOf(files, root)).toEqual(["AGENTS.override.md"]);
43
+ });
44
+
45
+ test("the same guide under two names is loaded once", () => {
46
+ const root = workspace({ "AGENTS.md": "the workspace guide" });
47
+ symlinkSync(path.join(root, "AGENTS.md"), path.join(root, "CLAUDE.md"));
48
+ expect(namesOf(AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root }), root)).toEqual([
49
+ "AGENTS.md",
50
+ ]);
51
+ });
52
+
53
+ test("an always-on cursor rule is loaded without its frontmatter", () => {
54
+ const root = workspace({
55
+ ".cursor/rules/house.mdc": "---\ndescription: house style\nalwaysApply: true\n---\nnever use any\n",
56
+ });
57
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
58
+ expect(namesOf(files, root)).toEqual(["AGENTS.md", ".cursor/rules/house.mdc"]);
59
+ expect(files[1]?.content).toBe("never use any\n");
60
+ });
61
+
62
+ test("a scoped cursor rule is left to cursor", () => {
63
+ const root = workspace({
64
+ ".cursor/rules/ui.mdc": "---\nglobs: ui/**/*.tsx\n---\nno inline colours\n",
65
+ ".cursor/rules/ask.mdc": "---\ndescription: for migrations\n---\nread the plan\n",
66
+ ".cursor/rules/off.mdc": "---\nalwaysApply: false\n---\nnot this one\n",
67
+ });
68
+ expect(namesOf(AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root }), root)).toEqual([
69
+ "AGENTS.md",
70
+ ]);
71
+ });
72
+
73
+ test("a rule with no frontmatter at all is unconditional, and so is .cursorrules", () => {
74
+ const root = workspace({ ".cursor/rules/plain.mdc": "just do it", ".cursorrules": "the legacy file" });
75
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
76
+ expect(namesOf(files, root).sort()).toEqual([".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort());
77
+ });
78
+
79
+ test("rules nested under the rules directory are found, in a stable order", () => {
80
+ const root = workspace({
81
+ ".cursor/rules/b.mdc": "---\nalwaysApply: true\n---\nsecond\n",
82
+ ".cursor/rules/a.mdc": "---\nalwaysApply: true\n---\nfirst\n",
83
+ ".cursor/rules/deep/c.mdc": "---\nalwaysApply: true\n---\nthird\n",
84
+ });
85
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
86
+ expect(namesOf(files, root)).toEqual([
87
+ "AGENTS.md",
88
+ ".cursor/rules/a.mdc",
89
+ ".cursor/rules/b.mdc",
90
+ ".cursor/rules/deep/c.mdc",
91
+ ]);
92
+ });
93
+
94
+ test("the session's own directory carries its rules as well as the workspace root", () => {
95
+ const root = workspace({
96
+ ".cursor/rules/root.mdc": "---\nalwaysApply: true\n---\nworkspace wide\n",
97
+ "apps/akan/.cursor/rules/app.mdc": "---\nalwaysApply: true\n---\napp only\n",
98
+ });
99
+ const cwd = path.join(root, "apps", "akan");
100
+ const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd });
101
+ expect(namesOf(files, root)).toEqual(["AGENTS.md", ".cursor/rules/root.mdc", "apps/akan/.cursor/rules/app.mdc"]);
102
+ });
103
+
104
+ test("an empty rule file adds nothing", () => {
105
+ const root = workspace({ ".cursor/rules/empty.mdc": "---\nalwaysApply: true\n---\n\n" });
106
+ expect(AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root })).toHaveLength(1);
107
+ });
108
+
109
+ test("nothing to add leaves the list exactly as the engine built it", () => {
110
+ const root = workspace({ "AGENTS.md": "the workspace guide" });
111
+ const base = loaded(root);
112
+ expect(AkanContextFiles.extend(base, { workspaceRoot: root, cwd: root })).toEqual(base);
113
+ });
114
+ });
@@ -0,0 +1,46 @@
1
+ import type { CodeAgentProfile } from "akanjs/common";
2
+
3
+ // Only what `AGENTS.md` does not say: the guide is already loaded whole on every turn.
4
+ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
5
+ const lines = [
6
+ "You are the akan coding agent, running inside an Akan.js workspace.",
7
+ "",
8
+ "- Prefer an akan workflow to a direct source edit. `list_workflows` / `explain_workflow` / `plan_workflow`, then `apply_workflow({ planPath })`. Editing source by hand is the fallback, not the default.",
9
+ "- Read context with `inspect_akan_context` before reading source bodies; it answers most structural questions without spending the file.",
10
+ "- Fetch the guideline for an area before a deep pass on it: `get_guideline`.",
11
+ "- A question about `akan code` itself — its slash commands, MCP servers, sessions, models, keys, or where its files live — is answered by the `akan-code` skill. Read it rather than describing the tool from memory.",
12
+ "- Never hand-edit a generated file (`lib/cnst.ts`, `db.ts`, `st.ts`, any `*/index.ts` barrel). Fix the owning source and run `repair_generated` or `akan sync`.",
13
+ ];
14
+ if (profile.tools.builtin.includes("write"))
15
+ lines.push(
16
+ "- After editing, call `akan_verify`. It runs sync, lint, typecheck and the SSR check over what the working tree changed. A turn that edits and does not verify is not finished.",
17
+ );
18
+ else
19
+ lines.push(
20
+ "- This session is read-only: there is no write, edit or bash tool. Report what should change instead of changing it.",
21
+ );
22
+ if (profile.tools.subagent)
23
+ lines.push(
24
+ '- Hand a self-contained search or read to `task` — `type: "explore"` reads and reports, `type: "code"` makes the change. Only its answer comes back, so give it everything it needs and ask for exactly what you want returned. Do it yourself when the reading is short or when you need the files in front of you.',
25
+ );
26
+ if (profile.ui.canPrompt)
27
+ lines.push(
28
+ "- Ask with `ask_user` when two readings of the request would lead to materially different work, and offer the options you can name. Do not ask for a choice that has an obvious default, and do not ask permission for work you were already asked to do — make the call, say the assumption, and carry on.",
29
+ );
30
+ if (profile.approval !== "never")
31
+ lines.push(
32
+ `- Some tool calls need the user's approval (policy: ${profile.approval}). A refusal is an answer; do not retry it a different way.`,
33
+ );
34
+ lines.push(
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.",
36
+ "",
37
+ "## How to answer",
38
+ "",
39
+ "- Answer in as few words as carry the answer. A short question gets one sentence with no heading.",
40
+ "- Do not open with what you are about to do, and do not close with a summary of what you did. The tool calls are already on the user's screen.",
41
+ "- Skip headings, bold labels and bullet lists unless the answer is genuinely a list. Prose is shorter.",
42
+ "- State a file as `path:line`. Do not paste a file back that the user can open.",
43
+ "- Say what you could not do, and why, in one line. Do not offer alternatives nobody asked for.",
44
+ );
45
+ return lines.join("\n");
46
+ };
@@ -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
+ });