@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
@@ -18,60 +18,38 @@ export interface AkanCodeServicesOptions {
18
18
  sessionId?: string;
19
19
  }
20
20
 
21
- /**
22
- * Assembles the engine's injectable services from akan's own storage.
23
- *
24
- * Every one of the four is passed explicitly. Leave any of them out and the engine falls back to `~/.pi/` —
25
- * measured: with all four injected it writes nothing outside the paths named here.
26
- */
21
+ // All four engine services are injected explicitly: any one left out makes the engine fall back to `~/.pi/`.
27
22
  export class AkanCodeServices {
28
23
  static settings() {
29
- // Settings are in memory on purpose: the engine's file settings would be a second configuration surface
30
- // beside `akan.config.ts` with its own precedence, for options akan already decides through the profile.
31
- // `enableInstallTelemetry` defaults to true and attaches pi-branded headers to OpenRouter, NVIDIA and
32
- // Cloudflare requests, so it is turned off here rather than per host — a host that changes provider later
33
- // would otherwise turn it back on without meaning to.
24
+ // In memory on purpose: file settings would be a second configuration surface beside the profile.
25
+ // `enableInstallTelemetry` defaults to true and adds pi-branded headers to OpenRouter/NVIDIA/Cloudflare calls.
34
26
  return SettingsManager.inMemory({
35
27
  enableInstallTelemetry: false,
36
28
  enableAnalytics: false,
37
29
  quietStartup: true,
38
- // Twice the engine's 16,384. The reserve is all the room the response at the threshold gets — a request
39
- // clamps its output to the window less the prompt less 4,096 — and 80% of it caps the summary. At 16k a
40
- // reasoning model stops mid-thought there, and the recovery re-sends a near-full window to retry the turn.
30
+ // Twice the engine's 16,384: at 16k a reasoning model stops mid-thought at the compaction threshold,
31
+ // and the recovery re-sends a near-full window to retry the turn.
41
32
  compaction: { reserveTokens: 32_768 },
42
33
  });
43
34
  }
44
35
 
45
- /**
46
- * The model catalogue and the credentials behind it, both under `~/.akan/`.
47
- *
48
- * `authPath` is the whole of what akan's own credential file used to need a storage backend for: the engine
49
- * locks and rewrites that file itself, so a second `akan code` refreshing an OAuth token at the same moment
50
- * cannot clobber the first. Nothing lands in `~/.pi/`, and a user who removes akan takes their keys with them.
51
- */
36
+ // The engine locks and rewrites `authPath` itself, so two `akan code` refreshing a token at once cannot clobber it.
52
37
  static async runtime(workspaceRoot: string, proxy: CodeAgentProxy | null = null) {
53
38
  mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
54
- if (proxy) {
55
- //* Behind a proxy neither `auth.json` nor an env key may reach a request, so credentials live in a throwaway
56
- //* file and the only key the engine ever sends is the proxy token.
57
- const runtime = await ModelRuntime.create({
58
- authPath: path.join(mkdtempSync(path.join(tmpdir(), "akan-code-proxy-")), "auth.json"),
59
- modelsPath: akanCodePaths.modelsFile(),
60
- });
61
- await proxy.apply(runtime);
62
- return runtime;
63
- }
64
39
  const runtime = await ModelRuntime.create({
65
- authPath: akanCodePaths.authFile(),
40
+ //* Behind a proxy no real key may reach a request, so credentials live in a throwaway file.
41
+ authPath: proxy
42
+ ? path.join(mkdtempSync(path.join(tmpdir(), "akan-code-proxy-")), "auth.json")
43
+ : akanCodePaths.authFile(),
66
44
  modelsPath: akanCodePaths.modelsFile(),
67
45
  });
68
- await AkanEnvKeys.apply(runtime, workspaceRoot);
46
+ if (proxy) await proxy.apply(runtime);
47
+ else await AkanEnvKeys.apply(runtime, workspaceRoot);
69
48
  return runtime;
70
49
  }
71
50
 
72
51
  static sessions(workspaceRoot: string, cwd: string, profile: CodeAgentProfile, resume?: string) {
73
- //* `remote` has no store of its own yet, so it keeps a file session rather than none: a pod's worker is long-lived
74
- //* and its suspended asks and resume need the file more than a laptop does.
52
+ //* `remote` has no store of its own yet, so it keeps a file session: a pod worker's suspended asks need it.
75
53
  if (profile.session.store === "memory") return SessionManager.inMemory(cwd);
76
54
  const dir = akanCodePaths.sessionsDir(workspaceRoot);
77
55
  mkdirSync(dir, { recursive: true });
@@ -84,15 +62,7 @@ export class AkanCodeServices {
84
62
  return manager;
85
63
  }
86
64
 
87
- /**
88
- * The akan skill set, then the person's own, then the workspace's.
89
- *
90
- * `~/.akan/code/skills` is read because a skill somebody wrote for themselves is theirs, not one checkout's,
91
- * and having to copy it into every repo is the same friction that makes people stop writing them. It is
92
- * text the model may read, never code that runs — which is why extensions stay workspace-only below.
93
- *
94
- * A path that does not exist is a reported load error rather than a skip, so each is checked first.
95
- */
65
+ // Skills are text, never code that runs, so unlike extensions they may come from the home directory too.
96
66
  static #skillPaths(profile: CodeAgentProfile, workspaceRoot: string) {
97
67
  if (!profile.context.skills) return [];
98
68
  const dirs = [
@@ -103,28 +73,16 @@ export class AkanCodeServices {
103
73
  return dirs.filter((dir): dir is string => !!dir && existsSync(dir));
104
74
  }
105
75
 
106
- /**
107
- * What the engine's resource loader may discover. Extensions come from the workspace and nowhere else: a
108
- * globally installed extension is code that runs in every checkout, so an agent would behave differently in
109
- * two clones of the same repo for reasons nothing in the repo explains. Skills are text and are read from
110
- * the home directory too — see `#skillPaths`.
111
- */
112
76
  static resourceOptions(options: AkanCodeServicesOptions) {
113
77
  // A path that does not exist is reported as a load error rather than skipped, and these two are optional.
114
78
  const present = (dir: string) => (existsSync(dir) ? [dir] : []);
115
79
  return {
116
80
  noExtensions: true,
117
- // Always on: it turns off the engine's *default* skill locations, which are `~/.pi/skills` and the
118
- // checkout's own `.pi/`. Explicit `additionalSkillPaths` are merged either way, so this is the switch
119
- // that keeps a skill in whoever's home directory from changing how the agent works here.
81
+ // Disables only the engine's default skill locations (`~/.pi/skills`, `.pi/`); `additionalSkillPaths` merge.
120
82
  noSkills: true,
121
83
  noPromptTemplates: true,
122
84
  noThemes: true,
123
85
  noContextFiles: !options.profile.context.projectFiles,
124
- // The engine takes one context file per directory and looks at no editor's own rules, so a repo whose
125
- // conventions live in a `CLAUDE.md` or under `.cursor/rules` hands this agent less than it hands the
126
- // editor beside it — see {@link AkanContextFiles}. Gated on the same flag, so a profile that wants no
127
- // project context still gets none.
128
86
  agentsFilesOverride: ({ agentsFiles }: { agentsFiles: AkanContextFile[] }) => ({
129
87
  agentsFiles: options.profile.context.projectFiles
130
88
  ? AkanContextFiles.extend(agentsFiles, { workspaceRoot: options.workspaceRoot, cwd: options.cwd })
@@ -11,25 +11,15 @@ export interface AkanContextFilesOptions {
11
11
  cwd: string;
12
12
  }
13
13
 
14
- /**
15
- * The workspace-authored rules the engine's own discovery leaves behind.
16
- *
17
- * It walks the ancestors of `cwd` and takes **one** file per directory, first match of
18
- * `AGENTS.override.md · AGENTS.md · AGENTS.MD · CLAUDE.md · CLAUDE.MD` — so a repo carrying both an `AGENTS.md`
19
- * and a `CLAUDE.md` has the second one silently dropped, and a `.cursor/rules` tree is never looked at. Both
20
- * are files a developer wrote expecting an agent to obey them, and an agent that reads one editor's copy and
21
- * not another's follows rules that disagree with the repo it is in.
22
- *
23
- * Added rather than replaced: the base list keeps its order and its precedence, and these land after it.
24
- */
14
+ // The engine takes only the first of `AGENTS.override.md · AGENTS.md · CLAUDE.md` per directory and never reads
15
+ // `.cursor/rules`; this appends what it drops, after the engine's own list.
25
16
  export class AkanContextFiles {
26
17
  /** Cursor's frontmatter block, which is metadata for its own rule picker and not instruction for a model. */
27
18
  static readonly #frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
28
19
 
29
20
  static extend(base: AkanContextFile[], options: AkanContextFilesOptions): AkanContextFile[] {
30
21
  const seen = new Set(base.map((file) => file.path));
31
- // Keyed by content as well as by path, because the usual way to carry both names is to make one a symlink
32
- // to the other or a one-line pointer at it — and the same guide twice is the same guide twice.
22
+ // Keyed by content too: the usual way to carry both names is a symlink or a one-line pointer.
33
23
  const contents = new Set(base.map((file) => file.content.trim()));
34
24
  const added: AkanContextFile[] = [];
35
25
  for (const file of [...AkanContextFiles.#claude(base), ...AkanContextFiles.#cursor(options)]) {
@@ -41,12 +31,7 @@ export class AkanContextFiles {
41
31
  return [...base, ...added];
42
32
  }
43
33
 
44
- /**
45
- * The `CLAUDE.md` beside an `AGENTS.md` the engine already took.
46
- *
47
- * Not beside an `AGENTS.override.md`: that name means "ignore what this directory would otherwise say", so
48
- * reinstating the file it was written to displace is the one case where loading both is wrong.
49
- */
34
+ // Not beside an `AGENTS.override.md`: that file exists to displace the others in its directory.
50
35
  static #claude(base: AkanContextFile[]) {
51
36
  const files: AkanContextFile[] = [];
52
37
  for (const file of base) {
@@ -60,14 +45,7 @@ export class AkanContextFiles {
60
45
  return files;
61
46
  }
62
47
 
63
- /**
64
- * Cursor's always-on rules, from the workspace root and from the session's own directory.
65
- *
66
- * Only `alwaysApply: true` is taken. Cursor's other three kinds are conditional — `globs` attaches a rule to
67
- * the files it names, a bare `description` offers it for the model to ask for, and neither means the rule is
68
- * loaded manually — so putting them in a system prompt applies rules their author scoped away. A file with
69
- * no frontmatter at all is the `.cursorrules` shape, which was never conditional.
70
- */
48
+ // Only `alwaysApply: true` or frontmatter-less rules: Cursor's `globs`/`description`/manual kinds are scoped.
71
49
  static #cursor(options: AkanContextFilesOptions) {
72
50
  const files: AkanContextFile[] = [];
73
51
  const roots = [...new Set([options.workspaceRoot, options.cwd])];
@@ -84,7 +62,6 @@ export class AkanContextFiles {
84
62
  return files;
85
63
  }
86
64
 
87
- /** The rule body when the rule applies unconditionally, and nothing when its author scoped it. */
88
65
  static #applied(raw: string) {
89
66
  const match = AkanContextFiles.#frontmatter.exec(raw);
90
67
  if (!match) return raw;
@@ -42,11 +42,9 @@ import { CodeAgentUi } from "./CodeAgentUi";
42
42
  import { CodeMailbox } from "./CodeMailbox";
43
43
  import { CodeSessionFork } from "./CodeSessionFork";
44
44
  import { CodeSessionIndex } from "./CodeSessionIndex";
45
+ import type { SubagentSpend } from "./SubagentPool";
45
46
 
46
- /**
47
- * What the engine calls a run mode. It is not re-exported from the package entry, so the literal set is
48
- * restated here; it is the whole type, and a mismatch is a type error at the one call site that uses it.
49
- */
47
+ // The engine's run-mode type is not re-exported from its package entry, so the literal set is restated.
50
48
  export type CodeAgentHostMode = "tui" | "rpc" | "json" | "print";
51
49
 
52
50
  export interface CodeAgentOptions {
@@ -56,23 +54,18 @@ export interface CodeAgentOptions {
56
54
  model?: CodeAgentModelRef;
57
55
  /** Apps whose dev server and preview the turn-end feedback loop watches. */
58
56
  apps?: string[];
59
- /** Extra capability packs, layered on top of the profile gate and the akan pack. */
60
57
  extensions?: InlineExtension[];
61
58
  customTools?: ToolDefinition[];
62
59
  /** `print` for a one-shot stream, `rpc` when a host drives this process over stdio. */
63
60
  mode?: CodeAgentHostMode;
64
- /** How many `task` tools deep this agent already is. A sub-agent is created one level down. */
61
+ /** How many `task` tools deep this agent already is. */
65
62
  depth?: number;
63
+ /** The tally (tokens spent, sub-agents running) of the sub-agent tree whose `task` opened this agent. */
64
+ subagentSpend?: SubagentSpend;
66
65
  /** Id of a stored session to continue instead of opening a new one. */
67
66
  resume?: string;
68
67
  }
69
68
 
70
- /**
71
- * The code agent core: one engine session, gated by a profile, emitting the akan wire.
72
- *
73
- * Nothing here knows what a host is. A host receives events, sends commands and injects a profile — which is
74
- * what lets one core serve a terminal, an RPC child and a browser without three copies of the loop.
75
- */
76
69
  export class CodeAgent {
77
70
  readonly #profile: CodeAgentProfile;
78
71
  readonly #gate: CodeAgentGate;
@@ -88,12 +81,7 @@ export class CodeAgent {
88
81
  #mailbox: CodeMailbox | undefined;
89
82
  #mcp: CodeAgentMcpStatus[] = [];
90
83
  #workspaceRoot = "";
91
- /**
92
- * The frames of the turn in flight, so a host that reconnects mid-turn can be handed what it missed.
93
- *
94
- * Bounded and cleared at `idle`: the completed messages of an earlier turn are in the transcript, and the
95
- * only thing that exists nowhere else is the bubble still being written.
96
- */
84
+ // Cleared at `idle`: an earlier turn is in the transcript; only the turn in flight exists nowhere else.
97
85
  readonly #replay: CodeAgentEvent[] = [];
98
86
  #replayFrom = 0;
99
87
  #lastContextTokens: number | undefined;
@@ -120,6 +108,7 @@ export class CodeAgent {
120
108
  apps: options.apps ?? [],
121
109
  canSeeImages: akanCodeModelSupportsImages(model),
122
110
  depth: options.depth ?? 0,
111
+ ...(options.subagentSpend ? { subagentSpend: options.subagentSpend } : {}),
123
112
  currentSessionId: () => agent.sessionId,
124
113
  mailbox: () => agent.#mailbox,
125
114
  onNotice: (message) => agent.#emit({ type: "notice", level: "warning", message }),
@@ -168,7 +157,6 @@ export class CodeAgent {
168
157
  return this.#profile;
169
158
  }
170
159
 
171
- /** Whether the model can be handed a picture at all, which decides whether offering to attach one is honest. */
172
160
  get canSeeImages() {
173
161
  const model = this.#session?.model;
174
162
  return model ? akanCodeModelSupportsImages(model) : false;
@@ -185,10 +173,8 @@ export class CodeAgent {
185
173
  }
186
174
 
187
175
  /**
188
- * The MCP servers this session was built against, and what each one published.
189
- *
190
- * Read from the assembly rather than from the live session: the tool allowlist is fixed when the session is
191
- * created, so a server declared afterwards is not one this agent can reach however the file now reads.
176
+ * As assembled at creation: the tool allowlist is fixed then, so a server declared later is unreachable. A
177
+ * server's `auth` still turns `required` when a call's token cannot be renewed, until a reload.
192
178
  */
193
179
  mcpServers(): CodeAgentMcpStatus[] {
194
180
  return this.#mcp;
@@ -199,12 +185,7 @@ export class CodeAgent {
199
185
  return this.#mailbox?.peers() ?? [];
200
186
  }
201
187
 
202
- /**
203
- * Hands a message to another session, which receives it as a prompt of its own.
204
- *
205
- * It is a prompt rather than a note on its screen because the point of reaching another session is to set
206
- * it working — and it goes in as a follow-up, so a peer in the middle of a turn finishes that turn first.
207
- */
188
+ /** The peer receives the text as a prompt of its own, queued as a follow-up when it is mid-turn. */
208
189
  send(target: string, text: string) {
209
190
  const mailbox = this.#mailbox;
210
191
  if (!mailbox) throw new Error("This profile keeps no shared mailbox, so it has no peers.");
@@ -221,7 +202,7 @@ export class CodeAgent {
221
202
  this.#mailbox.open(cwd, (mail) => {
222
203
  const from = `${mail.fromName} (${mail.from.slice(0, 8)})`;
223
204
  this.#emit({ type: "notice", level: "info", message: `message from ${from}` });
224
- void this.prompt(`Message from another session — ${from}:\n${mail.text}`).catch((error: unknown) =>
205
+ void this.#prompt(`Message from another session — ${from}:\n${mail.text}`).catch((error: unknown) =>
225
206
  this.#emit({ type: "notice", level: "error", message: String(error) }),
226
207
  );
227
208
  });
@@ -235,7 +216,6 @@ export class CodeAgent {
235
216
  return this.#session?.isStreaming ?? false;
236
217
  }
237
218
 
238
- /** Total tokens this session has spent, which is what a sub-agent budget is drawn down against. */
239
219
  get tokensUsed() {
240
220
  return this.#session?.getSessionStats().tokens.total ?? 0;
241
221
  }
@@ -250,19 +230,14 @@ export class CodeAgent {
250
230
  model: model ? { provider: model.provider, id: model.id, name: model.name } : undefined,
251
231
  tools: session.getActiveToolNames(),
252
232
  contextTokens: model?.contextWindow,
253
- // A model that does no reasoning has no level to report, and `off` is a level — one of them is a
254
- // capability and the other is a setting, so they cannot share a spelling.
233
+ // Not `off`: that is a setting of a reasoning model, not the absence of reasoning.
255
234
  effort: session.supportsThinking() ? session.thinkingLevel : undefined,
256
235
  name: session.sessionName,
257
236
  interaction: this.#profile.interaction,
258
237
  };
259
238
  }
260
239
 
261
- /**
262
- * Emits the opening `session` frame through the same counter as everything else.
263
- *
264
- * A frame minted outside it would carry `seq: 0`, which every client drops — their watermark starts there.
265
- */
240
+ /** A `session` frame minted outside this counter would carry `seq: 0`, which every client drops. */
266
241
  announce() {
267
242
  this.#emit({ type: "session", info: this.info });
268
243
  this.#restore();
@@ -272,16 +247,7 @@ export class CodeAgent {
272
247
  for (const request of approvals) this.#emit({ type: "approval", request });
273
248
  }
274
249
 
275
- /**
276
- * Replays a resumed session's conversation onto the wire.
277
- *
278
- * The model gets its context back from the session file; a host gets nothing, because the wire only carries
279
- * what happens live. Without this a resumed session is a blank screen in front of an agent that remembers
280
- * everything — which reads as a resume that failed.
281
- *
282
- * Prose only: the tool traffic of a past turn is in the file, but replaying it would redraw calls nobody is
283
- * waiting on, against a working tree that has moved since.
284
- */
250
+ // Prose only: a past turn's tool calls would redraw rows nobody waits on, against a tree that has moved since.
285
251
  #restore() {
286
252
  const session = this.#session;
287
253
  if (!session) return;
@@ -311,12 +277,7 @@ export class CodeAgent {
311
277
  /** One turn's worth of frames is kept for reconnection; a client further behind reloads the transcript. */
312
278
  static readonly replayLimit = 2_000;
313
279
 
314
- /**
315
- * Current state, and the frames after `sinceSeq` when a reconnecting host asks for them.
316
- *
317
- * `replayFrom` says how far back the buffer reaches. A client behind it cannot be caught up frame by frame
318
- * and has to reload — saying so is better than silently handing it a gap.
319
- */
280
+ /** `frames` only when `sinceSeq` is given; a client behind `replayFrom` has to reload the transcript. */
320
281
  async state({ sinceSeq }: { sinceSeq?: number } = {}): Promise<CodeAgentState> {
321
282
  const base = { info: this.info, streaming: this.isStreaming, replayFrom: this.#replayFrom };
322
283
  if (sinceSeq === undefined) return base;
@@ -324,9 +285,13 @@ export class CodeAgent {
324
285
  }
325
286
 
326
287
  async prompt(message: string, images?: CodeAgentImage[]) {
288
+ this.#suspended.clear();
289
+ await this.#prompt(message, images);
290
+ }
291
+
292
+ async #prompt(message: string, images?: CodeAgentImage[]) {
327
293
  const session = this.#require();
328
- // Named from the first thing asked of it, not from the answer: the name is derived, so waiting for the
329
- // turn to end would only mean the session is nameless for exactly as long as it is interesting to watch.
294
+ // From the first prompt, not the answer, so the session is not nameless while it is worth watching.
330
295
  if (!session.sessionName && message.trim()) this.setName(codeAgentSessionName(message));
331
296
  const attachments = await CodeAgent.#attachments(images);
332
297
  const options = attachments.length ? { images: attachments } : {};
@@ -335,17 +300,13 @@ export class CodeAgent {
335
300
  }
336
301
 
337
302
  async abort() {
338
- // The engine finalizes an interrupted message as an ordinary stop, so the only party that knows this was
339
- // an abort is the one that asked for it.
303
+ // The engine finalizes an interrupted message as an ordinary stop, so the abort is noted here.
340
304
  this.#mapper.noteOutcome("aborted");
341
305
  this.#asks.clear();
342
306
  await this.#require().abort();
343
307
  }
344
308
 
345
- /**
346
- * Answering is not prompting. Routing an answer through `prompt` would open a turn while the question slot is
347
- * still filled, leaving a card on screen that still looks clickable — the library does not stop that.
348
- */
309
+ // Not via `prompt`: that opens a turn with the question slot still filled, which the engine does not stop.
349
310
  async answer(questionId: string, answer: CodeAgentAnswer) {
350
311
  const question = this.#asks.questionOf(questionId);
351
312
  const rendered = question ? codeAgentRenderAnswer(question, answer) : (answer.text ?? "");
@@ -358,9 +319,8 @@ export class CodeAgent {
358
319
  if (!suspended) return false;
359
320
  const suspendedRendered = codeAgentRenderAnswer(suspended, answer);
360
321
  this.#emit({ type: "question_resolved", questionId, answer, rendered: suspendedRendered });
361
- // The answer has to reach the model as prose: compaction and the next turn read message content only, so
362
- // an answer that exists solely as structure is one the agent will not remember being given.
363
- await this.prompt(`The user answered: ${suspendedRendered}\nContinue the task.`);
322
+ // As prose: compaction and the next turn read message content only, so a structured-only answer is lost.
323
+ await this.#prompt(`The user answered: ${suspendedRendered}\nContinue the task.`);
364
324
  return true;
365
325
  }
366
326
 
@@ -372,7 +332,7 @@ export class CodeAgent {
372
332
  if (this.#profile.interaction.approval !== "suspend") return false;
373
333
  if (!this.#suspended.takeApproval(approvalId)) return false;
374
334
  this.#emit({ type: "approval_resolved", approvalId, approved });
375
- if (approved) await this.prompt("The user approved the pending action. Retry it and continue.");
335
+ if (approved) await this.#prompt("The user approved the pending action. Retry it and continue.");
376
336
  return true;
377
337
  }
378
338
 
@@ -389,12 +349,7 @@ export class CodeAgent {
389
349
  this.#emit({ type: "session", info: this.info });
390
350
  }
391
351
 
392
- /**
393
- * Copies this session under a new id, so the conversation can be continued two ways.
394
- *
395
- * The copy is taken off disk rather than out of memory: the file is what a resume replays, so forking it is
396
- * the only version of "the same conversation" that both agents will actually agree on.
397
- */
352
+ /** Copies the session file, not memory, under a new id: the file is what a resume replays. */
398
353
  fork(name?: string) {
399
354
  if (!this.#sessionDir) throw new Error("This profile keeps no sessions on disk, so there is nothing to fork");
400
355
  return CodeSessionFork.fork(this.#sessionDir, this.sessionId, name);
@@ -415,13 +370,7 @@ export class CodeAgent {
415
370
  this.#emit({ type: "session", info: this.info });
416
371
  }
417
372
 
418
- /**
419
- * The model catalogue, as the two questions a person actually asks of it.
420
- *
421
- * Built from the registry rather than `getAvailable()` so it needs no await and can answer about a provider
422
- * nobody has a key for: "what else could I run" is most of why the list is opened, and a list of only what
423
- * is already reachable cannot answer it.
424
- */
373
+ /** Every registered provider, keyed or not — `getAvailable()` would list only what is already reachable. */
425
374
  catalogue(): CodeAgentProviderInfo[] {
426
375
  const runtime = this.#session?.modelRuntime;
427
376
  if (!runtime) return [];
@@ -468,7 +417,8 @@ export class CodeAgent {
468
417
  this.#session = session;
469
418
  session.subscribe((event) => {
470
419
  for (const body of this.#mapper.map(event)) this.#emit(body);
471
- this.#emitContextUsage(event.type);
420
+ // Per turn, not per delta: the count only moves when a provider response lands.
421
+ if (event.type === "agent_end" || event.type === "compaction_end") this.#emitContextNow();
472
422
  });
473
423
  await session.bindExtensions({
474
424
  mode,
@@ -494,29 +444,10 @@ export class CodeAgent {
494
444
  );
495
445
  }
496
446
 
497
- /**
498
- * Reports how full the window is, once per turn and after a compaction.
499
- *
500
- * Per turn rather than per delta: the number moves only when a provider response lands, and a frame per
501
- * token delta would be thousands of frames saying the same thing. `tokens` is null right after a compaction
502
- * and before the next response — that is "unknown", not zero, so nothing is emitted for it.
503
- */
504
- #emitContextUsage(eventType: string) {
505
- if (eventType !== "agent_end" && eventType !== "compaction_end") return;
506
- this.#emitContextNow();
507
- }
508
-
509
- /**
510
- * The same frame, on demand, for the one moment the window is already full and no turn has run.
511
- *
512
- * A resumed session restores a conversation the model is carrying and the host has never seen, so without
513
- * this the status line offers the declared window and no share of it until the next turn ends — a session
514
- * reopened at 80% looks identical to one reopened empty.
515
- */
447
+ // Also on resume, or a session reopened at 80% shows no usage until its next turn ends.
516
448
  #emitContextNow() {
517
449
  const usage = this.#session?.getContextUsage();
518
- // `== null`, not `=== null`: the field is typed `number | null`, and a strict null check sails straight
519
- // past an `undefined` the way the mirror of this bug does — which would emit `used: undefined`.
450
+ // `== null` on purpose: typed `number | null`, but `undefined` must not emit `used: undefined`.
520
451
  if (usage?.tokens == null) return;
521
452
  if (usage.tokens === this.#lastContextTokens) return;
522
453
  this.#lastContextTokens = usage.tokens;
@@ -589,13 +520,7 @@ export class CodeAgent {
589
520
  return await this.#asks.openApproval(approvalId);
590
521
  }
591
522
 
592
- /**
593
- * Puts one question on the wire and waits for its answer, rendered as the labels a person chose.
594
- *
595
- * The single way anything asks: the engine's own UI port and the `ask_user` tool both land here, so the
596
- * suspend path, the profile's refusal to prompt at all, and the id the host answers against cannot disagree
597
- * between them. An empty answer is a skip, which is a real answer and not a failure.
598
- */
523
+ /** The rendered answer; `undefined` when skipped, suspended, or nobody can be prompted. */
599
524
  async ask(spec: Omit<CodeAgentQuestion, "questionId">) {
600
525
  const question: CodeAgentQuestion = { questionId: this.#asks.nextId("q"), ...spec };
601
526
  if (!this.#profile.ui.canPrompt) {
@@ -1,23 +1,10 @@
1
1
  import type { CodeAgentQuestion } from "akanjs/common";
2
2
 
3
- interface Deferred<T> {
4
- resolve: (value: T) => void;
5
- promise: Promise<T>;
6
- }
7
-
8
- /**
9
- * Holds what the agent is waiting on a person for.
10
- *
11
- * A question is a **single slot**: asking one ends the turn, so a second cannot exist. An approval is a
12
- * **queue**: "may I write this file" naturally arrives twice in a row while a turn is still running, and a
13
- * single slot would drop the second silently.
14
- *
15
- * Idempotency is by id. Two browser tabs, or answering after a refresh, both produce a second answer for an id
16
- * that is already resolved — which must be ignored rather than applied to whatever is pending now.
17
- */
3
+ // A question is a single slot (asking ends the turn); approvals queue, since two can arrive within one turn.
4
+ // Idempotent by id: a second tab, or an answer after a refresh, for an already resolved id is ignored.
18
5
  export class CodeAgentAsks {
19
- #question: { question: CodeAgentQuestion; deferred: Deferred<string> } | undefined;
20
- readonly #approvals = new Map<string, Deferred<boolean>>();
6
+ #question: { question: CodeAgentQuestion; deferred: PromiseWithResolvers<string> } | undefined;
7
+ readonly #approvals = new Map<string, PromiseWithResolvers<boolean>>();
21
8
  #nextId = 0;
22
9
  //* Ids outlive the process on a suspending session, so a resumed worker must not hand out `q1` a second time.
23
10
  readonly #epoch = Date.now().toString(36);
@@ -26,7 +13,6 @@ export class CodeAgentAsks {
26
13
  return this.#question?.question.questionId;
27
14
  }
28
15
 
29
- /** The open question, so an answer can be rendered against the options it was asked with. */
30
16
  questionOf(questionId: string) {
31
17
  return this.#question?.question.questionId === questionId ? this.#question.question : undefined;
32
18
  }
@@ -41,10 +27,9 @@ export class CodeAgentAsks {
41
27
  }
42
28
 
43
29
  openQuestion(question: CodeAgentQuestion) {
44
- // A question opened while one is already pending replaces it: the old turn is gone, so nobody is left to
45
- // receive the old answer, and leaving it would strand the caller forever.
30
+ // Replaces a pending question: its turn is gone, and leaving it unresolved would strand its caller.
46
31
  this.#question?.deferred.resolve("");
47
- const deferred = CodeAgentAsks.#defer<string>();
32
+ const deferred = Promise.withResolvers<string>();
48
33
  this.#question = { question, deferred };
49
34
  return deferred.promise;
50
35
  }
@@ -58,7 +43,7 @@ export class CodeAgentAsks {
58
43
  }
59
44
 
60
45
  openApproval(id: string) {
61
- const deferred = CodeAgentAsks.#defer<boolean>();
46
+ const deferred = Promise.withResolvers<boolean>();
62
47
  this.#approvals.set(id, deferred);
63
48
  return deferred.promise;
64
49
  }
@@ -78,12 +63,4 @@ export class CodeAgentAsks {
78
63
  for (const deferred of this.#approvals.values()) deferred.resolve(false);
79
64
  this.#approvals.clear();
80
65
  }
81
-
82
- static #defer<T>(): Deferred<T> {
83
- let resolve: (value: T) => void = () => {};
84
- const promise = new Promise<T>((r) => {
85
- resolve = r;
86
- });
87
- return { resolve, promise };
88
- }
89
66
  }