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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/abstractDoc.test.ts +6 -15
  2. package/abstractDoc.ts +1 -4
  3. package/agentsIndex.test.ts +4 -7
  4. package/agentsIndex.ts +6 -22
  5. package/akanApp/BackendImportGraph.test.ts +8 -22
  6. package/akanApp/BackendImportGraph.ts +1 -20
  7. package/akanApp/akanApp.host.test.ts +1 -3
  8. package/akanApp/akanApp.host.ts +64 -250
  9. package/akanApp/devHostPolicy.test.ts +10 -34
  10. package/akanApp/devHostPolicy.ts +12 -98
  11. package/akanConfig/akanConfig.test.ts +30 -39
  12. package/akanConfig/akanConfig.ts +40 -136
  13. package/akanContext.ts +106 -159
  14. package/akanMcpContract.ts +28 -73
  15. package/appSelectionMemory.ts +1 -4
  16. package/applicationBuildReporter.ts +37 -5
  17. package/applicationBuildRunner.ts +25 -59
  18. package/applicationReleasePackager.ts +24 -44
  19. package/applicationTestPreload.ts +3 -12
  20. package/artifact/implicitRootLayout.test.ts +3 -19
  21. package/artifact/implicitRootLayout.ts +34 -91
  22. package/artifact/routeSeedIndex.ts +11 -44
  23. package/biomeBase.ts +2 -5
  24. package/capacitorApp.test.ts +4 -14
  25. package/capacitorApp.ts +86 -235
  26. package/cloud/cloudApi.test.ts +202 -0
  27. package/cloud/cloudApi.ts +33 -57
  28. package/cloud/configLock.test.ts +64 -0
  29. package/cloud/configLock.ts +96 -0
  30. package/cloud/constants.ts +3 -2
  31. package/cloud/globalConfig.ts +64 -31
  32. package/codeAgent/agent/AkanCodeServices.ts +15 -57
  33. package/codeAgent/agent/AkanContextFiles.ts +5 -28
  34. package/codeAgent/agent/CodeAgent.ts +33 -108
  35. package/codeAgent/agent/CodeAgentAsks.ts +7 -30
  36. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
  37. package/codeAgent/agent/CodeAgentGate.ts +8 -34
  38. package/codeAgent/agent/CodeAgentProxy.ts +4 -13
  39. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
  40. package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
  41. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
  42. package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
  43. package/codeAgent/agent/CodeAgentUi.ts +2 -7
  44. package/codeAgent/agent/CodeMailbox.ts +7 -34
  45. package/codeAgent/agent/CodeSessionFork.ts +4 -19
  46. package/codeAgent/agent/CodeSessionIndex.ts +5 -31
  47. package/codeAgent/agent/SubagentPool.ts +48 -58
  48. package/codeAgent/agent/akanCodeModel.ts +5 -39
  49. package/codeAgent/agent/akanCodePaths.ts +4 -24
  50. package/codeAgent/agent/akanContextFiles.test.ts +0 -2
  51. package/codeAgent/agent/akanSystemPrompt.ts +1 -9
  52. package/codeAgent/agent/codeAgent.test.ts +178 -0
  53. package/codeAgent/agent/codeAgentContract.test.ts +5 -28
  54. package/codeAgent/agent/codeMailbox.test.ts +0 -4
  55. package/codeAgent/agent/codeSessionFork.test.ts +0 -2
  56. package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
  57. package/codeAgent/agent/consoleToStderr.ts +2 -12
  58. package/codeAgent/agent/jsonLines.ts +29 -0
  59. package/codeAgent/agent/stringArg.ts +5 -0
  60. package/codeAgent/feedback/DevLogFeedback.ts +3 -18
  61. package/codeAgent/feedback/PreviewChrome.ts +5 -29
  62. package/codeAgent/feedback/PreviewFeedback.ts +2 -9
  63. package/codeAgent/feedback/PreviewView.ts +3 -15
  64. package/codeAgent/feedback/TurnFeedback.ts +2 -17
  65. package/codeAgent/feedback/VerifyFeedback.ts +1 -7
  66. package/codeAgent/feedback/previewChrome.test.ts +1 -1
  67. package/codeAgent/index.ts +2 -10
  68. package/codeAgent/skills/akan-code/SKILL.md +12 -4
  69. package/codeAgent/tools/AkanCli.ts +4 -16
  70. package/codeAgent/tools/AkanCodePlugins.ts +21 -43
  71. package/codeAgent/tools/AkanEditScope.ts +2 -12
  72. package/codeAgent/tools/AkanEnvKeys.ts +3 -11
  73. package/codeAgent/tools/AkanToolPack.ts +6 -28
  74. package/codeAgent/tools/AkanVerifier.ts +1 -8
  75. package/codeAgent/tools/AskToolPack.ts +3 -15
  76. package/codeAgent/tools/MailToolPack.ts +0 -9
  77. package/codeAgent/tools/McpClient.ts +3 -24
  78. package/codeAgent/tools/McpOAuth.ts +6 -41
  79. package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
  80. package/codeAgent/tools/McpServerConfig.ts +7 -26
  81. package/codeAgent/tools/McpSignIn.ts +46 -49
  82. package/codeAgent/tools/McpTokenStore.ts +6 -17
  83. package/codeAgent/tools/McpToolPack.ts +107 -35
  84. package/codeAgent/tools/SessionToolPack.ts +1 -11
  85. package/codeAgent/tools/WebToolPack.ts +1 -7
  86. package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
  87. package/codeAgent/tools/askToolPack.test.ts +0 -3
  88. package/codeAgent/tools/mcpOAuth.test.ts +315 -27
  89. package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
  90. package/codegenLock.test.ts +4 -13
  91. package/codegenLock.ts +3 -17
  92. package/commandDecorators/argMeta.ts +1 -5
  93. package/commandDecorators/camelToKebabCase.ts +1 -0
  94. package/commandDecorators/command.ts +44 -102
  95. package/commandDecorators/commandBuilder.ts +30 -61
  96. package/commandDecorators/commandDecorators.test.ts +1 -9
  97. package/commandDecorators/dependencyBuilder.ts +10 -1
  98. package/commandDecorators/helpFormatter.ts +66 -144
  99. package/commandDecorators/index.ts +0 -2
  100. package/commandDecorators/targetMeta.ts +9 -7
  101. package/createTunnel.ts +7 -14
  102. package/dependencyScanner.test.ts +4 -16
  103. package/dependencyScanner.ts +27 -78
  104. package/devkitUtils.test.ts +47 -19
  105. package/executors.test.ts +114 -60
  106. package/executors.ts +165 -484
  107. package/fileEditor.ts +9 -37
  108. package/{getDirname.test.ts → fileSys.test.ts} +1 -1
  109. package/fileSys.ts +6 -8
  110. package/formSetterScanner.ts +3 -16
  111. package/frontendBuild/allRoutesBuilder.ts +7 -21
  112. package/frontendBuild/autoImportSync.test.ts +14 -38
  113. package/frontendBuild/autoImportSync.ts +15 -67
  114. package/frontendBuild/buildRouteClient.test.ts +10 -36
  115. package/frontendBuild/bundleDefine.ts +12 -0
  116. package/frontendBuild/clientBuildTypes.ts +5 -36
  117. package/frontendBuild/clientEntriesBundler.ts +9 -40
  118. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  119. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  120. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  121. package/frontendBuild/cssCandidateCache.ts +6 -41
  122. package/frontendBuild/cssCompiler.ts +29 -78
  123. package/frontendBuild/cssImportResolver.ts +5 -18
  124. package/frontendBuild/devChangePlanner.ts +7 -10
  125. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  126. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  127. package/frontendBuild/fontOptimizer.test.ts +5 -13
  128. package/frontendBuild/fontOptimizer.ts +15 -72
  129. package/frontendBuild/fontPruner.test.ts +5 -10
  130. package/frontendBuild/fontPruner.ts +5 -20
  131. package/frontendBuild/frontendBuild.test.ts +27 -52
  132. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  133. package/frontendBuild/hmrWatcher.test.ts +7 -31
  134. package/frontendBuild/hmrWatcher.ts +14 -71
  135. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  136. package/frontendBuild/precompressArtifacts.ts +5 -18
  137. package/frontendBuild/routeClientBuilder.ts +7 -47
  138. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  139. package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
  140. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  141. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  142. package/frontendBuild/styleContract.ts +1 -8
  143. package/frontendBuild/themeValidator.ts +4 -26
  144. package/frontendBuild/watchRootResolver.test.ts +23 -42
  145. package/frontendBuild/watchRootResolver.ts +5 -27
  146. package/getRelatedCnsts.ts +12 -92
  147. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  148. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  149. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  150. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  151. package/incrementalBuilder/builderChannel.test.ts +24 -49
  152. package/incrementalBuilder/builderChannel.ts +6 -37
  153. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  154. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  155. package/incrementalBuilder/devWatchBatch.test.ts +14 -18
  156. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  157. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  158. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  159. package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
  160. package/index.ts +3 -15
  161. package/integration/devResourceProbe.ts +50 -56
  162. package/integration/devStability.integration.test.ts +47 -194
  163. package/integration/devStabilityHarness.test.ts +9 -29
  164. package/integration/devStabilityHarness.ts +65 -330
  165. package/integration/ssrMemoryProbe.ts +41 -167
  166. package/libSource.test.ts +4 -15
  167. package/libSource.ts +19 -31
  168. package/lint/gritRules.test.ts +6 -23
  169. package/linter.test.ts +1 -2
  170. package/linter.ts +22 -115
  171. package/mobile/mobileTarget.ts +14 -13
  172. package/package.json +2 -2
  173. package/packageExports.test.ts +3 -18
  174. package/packageExportsMap.ts +3 -19
  175. package/piDependencyPins.test.ts +3 -14
  176. package/platformTest/IdleWatchedProcess.test.ts +5 -13
  177. package/platformTest/IdleWatchedProcess.ts +1 -5
  178. package/platformTest/LinuxDockerTarget.ts +20 -35
  179. package/platformTest/PlatformTestRun.test.ts +5 -9
  180. package/platformTest/PlatformTestTarget.ts +24 -2
  181. package/platformTest/TestSnapshot.test.ts +6 -14
  182. package/platformTest/TestSnapshot.ts +1 -5
  183. package/platformTest/WindowsSshTarget.ts +6 -22
  184. package/prompter.ts +3 -6
  185. package/qualityScanner.test.ts +6 -24
  186. package/qualityScanner.ts +196 -284
  187. package/recipeScanner.test.ts +7 -12
  188. package/recipeScanner.ts +9 -38
  189. package/repoIdentity.ts +2 -9
  190. package/routeSourceValidator.ts +7 -30
  191. package/scanInfo.ts +82 -214
  192. package/selectModel.ts +1 -2
  193. package/semver.ts +3 -5
  194. package/slicePlanner.test.ts +6 -24
  195. package/slicePlanner.ts +11 -32
  196. package/spinner.test.ts +3 -8
  197. package/spinner.ts +4 -16
  198. package/sshTunnel.ts +1 -6
  199. package/ssrScanner.test.ts +1 -6
  200. package/ssrScanner.ts +103 -113
  201. package/subspace.test.ts +6 -25
  202. package/subspace.ts +31 -109
  203. package/subspaceConfig.ts +3 -11
  204. package/testHelpers.ts +49 -34
  205. package/transforms/asyncDefaultExportDetector.ts +3 -11
  206. package/transforms/barrelAnalyzer.ts +43 -135
  207. package/transforms/barrelImportsPlugin.ts +38 -175
  208. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  209. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  210. package/transforms/moduleSyntax.ts +32 -0
  211. package/transforms/rscUseClientTransform.ts +35 -20
  212. package/transforms/transforms.test.ts +153 -44
  213. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  214. package/transforms/useClientBundlePlugin.ts +2 -22
  215. package/typeChecker.ts +7 -63
  216. package/uploadRelease.ts +10 -25
  217. package/workflow/artifacts.ts +18 -0
  218. package/workflow/executor.test.ts +1 -12
  219. package/workflow/executor.ts +85 -146
  220. package/workflow/index.ts +0 -1
  221. package/workflow/moduleIndex.test.ts +4 -15
  222. package/workflow/moduleIndex.ts +9 -49
  223. package/workflow/plan.ts +18 -25
  224. package/workflow/render.ts +80 -96
  225. package/workflow/source.ts +33 -56
  226. package/workflow/sourceAst.ts +50 -0
  227. package/workspaceLayout.ts +4 -19
  228. package/commandDecorators/commandMeta.ts +0 -7
  229. package/commandDecorators/types.ts +0 -10
  230. package/getDirname.ts +0 -4
  231. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  232. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  233. package/workflow/primitive.ts +0 -59
@@ -8,6 +8,7 @@ import {
8
8
  codeAgentLabelChars,
9
9
  codeAgentOutputChars,
10
10
  } from "akanjs/common";
11
+ import { stringArg } from "./stringArg";
11
12
 
12
13
  const toolOps: { [key: string]: CodeAgentToolOp } = {
13
14
  read: "read",
@@ -19,13 +20,7 @@ const toolOps: { [key: string]: CodeAgentToolOp } = {
19
20
  bash: "execute",
20
21
  };
21
22
 
22
- /**
23
- * Narrows the engine's event stream into the akan wire.
24
- *
25
- * One turn here is one user prompt, not one LLM round trip: the engine's `turn_start` fires once per round trip
26
- * inside a run, which is an engine detail no host should have to fold. `agent_start`/`agent_end` are the
27
- * boundaries a person perceives, so they become our `turn_start`/`turn_end`.
28
- */
23
+ // A turn is one prompt: the engine's `turn_start` fires per LLM round trip, so `agent_start`/`agent_end` bound it.
29
24
  export class CodeAgentEventMapper {
30
25
  #turn = 0;
31
26
  #turnId = "";
@@ -46,8 +41,7 @@ export class CodeAgentEventMapper {
46
41
  this.#outcome = undefined;
47
42
  return [{ type: "turn_start", turnId: this.#turnId }];
48
43
  case "agent_end":
49
- // `turn_end` is last by contract. The engine finalizes the assistant message before the run ends even
50
- // when the run was aborted, so a host that closes the turn on this frame has already seen it.
44
+ // `turn_end` is last by contract; the engine finalizes the message before the run ends, even on abort.
51
45
  return [
52
46
  ...this.flushBlocked(),
53
47
  { type: "turn_end", turnId: this.#turnId, stopReason: this.#stopReason(event) },
@@ -103,14 +97,7 @@ export class CodeAgentEventMapper {
103
97
  }
104
98
  }
105
99
 
106
- /**
107
- * Records that a call was refused before it ran.
108
- *
109
- * The refusal arrives in the `tool_call` hook, which fires **before** `tool_execution_start` — measured: a
110
- * blocked call produces no start event at all, and then a `tool_execution_end` carrying `isError`. So the
111
- * start frame is synthesised here and the engine's own end frame is relabelled `blocked`, rather than
112
- * synthesising a terminal frame that would arrive twice.
113
- */
100
+ // The engine sends a blocked call no start event, only an `isError` end: synthesise the start, relabel the end.
114
101
  markBlocked(toolCallId: string, toolName: string, args: unknown, reason: string): CodeAgentEventBody[] {
115
102
  this.#blocked.set(toolCallId, reason);
116
103
  if (this.#openTools.has(toolCallId)) return [];
@@ -155,25 +142,12 @@ export class CodeAgentEventMapper {
155
142
  };
156
143
  }
157
144
 
158
- /**
159
- * Records how this turn is ending, from the side that actually knows.
160
- *
161
- * Consumed once, by the next `agent_end`. See {@link CodeAgentEventMapper.#stopReason} for why the engine's
162
- * own report cannot be trusted for this.
163
- */
145
+ /** Consumed once, by the next `agent_end`, and wins over the engine's own stop reason. */
164
146
  noteOutcome(reason: CodeAgentStopReason) {
165
147
  this.#outcome = reason;
166
148
  }
167
149
 
168
- /**
169
- * **The engine does not report an abort.** Measured: interrupting a streaming turn finalizes the partial
170
- * assistant message with `stopReason: "stop"` — the provider stream was cut, and from the engine's side that
171
- * is an ordinary stop. Trusting it reports `done` for every interrupt, and a host then has no way to tell a
172
- * finished answer from a truncated one.
173
- *
174
- * So the core's own record wins: it is the party that called `abort()`, and the party that suspended the
175
- * turn to ask a question. The engine's report is only the fallback.
176
- */
150
+ // The engine reports an interrupted stream as `stop`, so the core's own note wins and the engine's is a fallback.
177
151
  #stopReason(event: Extract<AgentSessionEvent, { type: "agent_end" }>): CodeAgentStopReason {
178
152
  const noted = this.#outcome;
179
153
  this.#outcome = undefined;
@@ -182,15 +156,13 @@ export class CodeAgentEventMapper {
182
156
  const last = event.messages.at(-1) as { stopReason?: string } | undefined;
183
157
  if (last?.stopReason === "aborted") return "aborted";
184
158
  if (last?.stopReason === "error") return "error";
185
- // The engine's vocabulary is `stop | length | toolUse | error | aborted`. Branching on only the two that
186
- // read as failures lets `length` — the model hitting its output cap — leave as `done`, and a half-written
187
- // answer is then stored, and replayed to the next turn, as a finished one.
159
+ // `length` is the model hitting its output cap: a half-written answer must not be stored as `done`.
188
160
  if (last?.stopReason === "length") return "truncated";
189
161
  return "done";
190
162
  }
191
163
 
192
164
  #summary(toolCallId: string, name: string, args: unknown): CodeAgentToolSummary {
193
- const path = CodeAgentEventMapper.#pathOf(args);
165
+ const path = stringArg(args, "path");
194
166
  return {
195
167
  toolCallId,
196
168
  name,
@@ -215,16 +187,6 @@ export class CodeAgentEventMapper {
215
187
  return [{ type: "message", turnId: this.#turnId, role: message.role, text }];
216
188
  }
217
189
 
218
- static #pathOf(args: unknown) {
219
- if (!args || typeof args !== "object") return undefined;
220
- const value = (args as { path?: unknown }).path;
221
- return typeof value === "string" && value ? value : undefined;
222
- }
223
-
224
- /**
225
- * A one-line rendering, never the values themselves: a `write` call's argument is the whole new file body and
226
- * an `edit` call's is every replacement in it.
227
- */
228
190
  static #renderArgs(args: unknown) {
229
191
  if (!args || typeof args !== "object") return "";
230
192
  const entries = Object.entries(args as Record<string, unknown>).filter(([, value]) => value !== undefined);
@@ -233,12 +195,7 @@ export class CodeAgentEventMapper {
233
195
  return `(${codeAgentClip(rendered, 120)})`;
234
196
  }
235
197
 
236
- /**
237
- * A structured argument is described by its shape, not stringified.
238
- *
239
- * `String({})` is `"[object Object]"` and an array of them is that repeated — which is where an `edit` call's
240
- * replacement list ends up, so the row says nothing about a call that changed three places in a file.
241
- */
198
+ // By shape, not `String()`, which renders an `edit` call's replacement list as `[object Object]`s.
242
199
  static #renderArgValue(value: unknown) {
243
200
  if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
244
201
  if (value && typeof value === "object") {
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import type { CodeAgentProfile } from "akanjs/common";
3
+ import { stringArg } from "./stringArg";
3
4
 
4
5
  const writeTools = new Set(["write", "edit"]);
5
6
  const commandTools = new Set(["bash"]);
@@ -10,13 +11,7 @@ export interface GateVerdict {
10
11
  approval?: string;
11
12
  }
12
13
 
13
- /**
14
- * Decides, per tool call, whether the profile lets it through, needs a human, or is refused outright.
15
- *
16
- * Paths are checked here rather than inside a replacement for the engine's file operations because a refusal
17
- * has to reach the model as a reason it can act on. An operations port that throws produces a stack trace the
18
- * model reads as a bug in the tool, and it retries.
19
- */
14
+ // Gated here, not in the engine's file-ops port: a throw there reads to the model as a tool bug, and it retries.
20
15
  export class CodeAgentGate {
21
16
  readonly #profile: CodeAgentProfile;
22
17
  readonly #deny: Bun.Glob[];
@@ -31,12 +26,12 @@ export class CodeAgentGate {
31
26
  }
32
27
 
33
28
  verdict(toolName: string, args: unknown): GateVerdict {
34
- const target = CodeAgentGate.#pathOf(args);
29
+ const target = stringArg(args, "path");
35
30
  if (target) {
36
31
  const denied = this.#deniedReason(target);
37
32
  if (denied) return { block: denied };
38
33
  }
39
- const command = CodeAgentGate.#commandOf(args);
34
+ const command = stringArg(args, "command");
40
35
  if (command) {
41
36
  const denied = this.#deniedCommandReason(command);
42
37
  if (denied) return { block: denied };
@@ -45,15 +40,8 @@ export class CodeAgentGate {
45
40
  return { approval: CodeAgentGate.#summarize(toolName, args) };
46
41
  }
47
42
 
48
- /**
49
- * A shell command naming a denied file is refused on the literal text.
50
- *
51
- * ⚠️ **This is a speed bump, not a boundary.** A shell can reach any path the process can — through a
52
- * variable, a glob, a subshell, `base64`, anything — and gating it properly would mean interpreting the
53
- * command. The real boundary is the container the `pod` profile runs in. What this does buy is that the
54
- * obvious accident, `cat .env`, does not put a live key into a transcript that is then persisted and
55
- * replayed on every later turn.
56
- */
43
+ // A speed bump, not a boundary: a shell reaches any path; the real boundary is the `pod` profile's container.
44
+ // It stops the obvious accident (`cat .env`) from persisting a live key into the transcript.
57
45
  #deniedCommandReason(command: string) {
58
46
  const hit = this.#deniedFragments.find((fragment) => command.includes(fragment));
59
47
  if (!hit) return undefined;
@@ -87,9 +75,7 @@ export class CodeAgentGate {
87
75
  }
88
76
  }
89
77
 
90
- // A recursive secrets glob is only useful as the literal `secrets/`; a glob cannot be matched against a shell
91
- // line. A pattern with a wildcard left in the middle yields nothing matchable, so it is dropped rather than
92
- // guessed at.
78
+ // A glob cannot match a shell line, so a deny pattern is reduced to a literal; a mid-pattern wildcard is dropped.
93
79
  static #fragmentOf(pattern: string) {
94
80
  const trimmed = pattern
95
81
  .replace(/^\*\*\//, "")
@@ -98,22 +84,10 @@ export class CodeAgentGate {
98
84
  return trimmed.includes("*") ? "" : trimmed;
99
85
  }
100
86
 
101
- static #commandOf(args: unknown) {
102
- if (!args || typeof args !== "object") return undefined;
103
- const value = (args as { command?: unknown }).command;
104
- return typeof value === "string" && value ? value : undefined;
105
- }
106
-
107
- static #pathOf(args: unknown) {
108
- if (!args || typeof args !== "object") return undefined;
109
- const value = (args as { path?: unknown }).path;
110
- return typeof value === "string" && value ? value : undefined;
111
- }
112
-
113
87
  static #summarize(toolName: string, args: unknown) {
114
88
  const record = (args ?? {}) as Record<string, unknown>;
115
89
  if (toolName === "bash") return `run: ${String(record.command ?? "").slice(0, 200)}`;
116
- const target = CodeAgentGate.#pathOf(args);
90
+ const target = stringArg(args, "path");
117
91
  return target ? `${toolName} ${target}` : toolName;
118
92
  }
119
93
  }
@@ -17,12 +17,7 @@ export interface CodeAgentProxyOptions {
17
17
  token?: string;
18
18
  }
19
19
 
20
- /**
21
- * Routes every model request through a host's LLM proxy, so a pod never holds a provider key: the host mints a
22
- * short-lived token, writes it to a file, and rotates it there while the worker keeps running.
23
- *
24
- * `baseUrl` may carry `{provider}` for a proxy that fronts several providers under one host.
25
- */
20
+ /** `baseUrl` may carry `{provider}` for a proxy that fronts several providers under one host. */
26
21
  export class CodeAgentProxy {
27
22
  static readonly placeholderKey = "akan-code-proxy";
28
23
 
@@ -59,8 +54,7 @@ export class CodeAgentProxy {
59
54
  return this.baseUrl.replaceAll("{provider}", provider);
60
55
  }
61
56
 
62
- //* Read per request, not cached: the host rotates a token that outlives no single turn, and a worker holding the
63
- //* first one would start failing half an hour into a session it is still running.
57
+ //* Read per request, not cached: the host rotates the token while a session is still running.
64
58
  async token() {
65
59
  if (!this.#tokenFile) return this.#token ?? "";
66
60
  const token = (await readFile(this.#tokenFile, "utf8")).trim();
@@ -68,11 +62,8 @@ export class CodeAgentProxy {
68
62
  return token;
69
63
  }
70
64
 
71
- /**
72
- * `prepareRequest` is the one path every stream, completion, compaction and cache-warm request takes, which is
73
- * why it is wrapped rather than the public stream methods. It is private in the engine's typings, so its
74
- * absence after an engine upgrade fails here at boot instead of sending a request with no key.
75
- */
65
+ // `prepareRequest` is the one path every request takes. It is engine-private, so an upgrade that drops it must
66
+ // fail here at boot rather than send a request with no key.
76
67
  async apply(runtime: ModelRuntime) {
77
68
  const target = runtime as unknown as { prepareRequest?: PrepareRequest };
78
69
  const original = target.prepareRequest?.bind(runtime);
@@ -1,16 +1,7 @@
1
1
  import type { CodeAgentCommand, CodeAgentEvent, CodeAgentReply, CodeAgentRequest } from "akanjs/common";
2
2
  import type { CodeAgent } from "./CodeAgent";
3
3
 
4
- /**
5
- * Serves one code agent as newline-delimited akan wire frames, over stdio or whatever transport attaches.
6
- *
7
- * The protocol is **ours**, not the engine's: a host — the web relay, a pod runner, a test harness — reads the
8
- * same events the in-process API emits, so an engine upgrade stops at the core instead of reaching every
9
- * consumer.
10
- *
11
- * stdout carries frames and nothing else. That only holds because `consoleToStderr` was imported before the
12
- * engine: under Bun the engine's own stdout guard misses `console.log`, which writes to fd 1 natively.
13
- */
4
+ // stdout carries only frames because `consoleToStderr` was imported before the engine (Bun's console bypasses it).
14
5
  export class CodeAgentRpcHost {
15
6
  readonly #agent: CodeAgent;
16
7
  #write: ((line: string) => void) | null;
@@ -40,8 +31,7 @@ export class CodeAgentRpcHost {
40
31
  this.#agent.announce();
41
32
  }
42
33
 
43
- //* Frames emitted while no client is attached are dropped here and kept in the agent's replay buffer; a client
44
- //* that attaches catches up with `get_state { sinceSeq }` instead of receiving a burst it did not ask for.
34
+ //* Frames with no client attached are dropped; the next client catches up with `get_state { sinceSeq }`.
45
35
  attach(write: ((line: string) => void) | null) {
46
36
  this.#write = write;
47
37
  this.#buffer = "";
@@ -74,10 +64,7 @@ export class CodeAgentRpcHost {
74
64
  if (request.command?.type === "shutdown") this.#resolveShutdown();
75
65
  }
76
66
 
77
- /**
78
- * `prompt` answers as soon as the turn is accepted, not when it finishes — a client that had to wait for the
79
- * reply could not send `abort`, which is the one command that matters while a turn runs.
80
- */
67
+ // `prompt` replies on acceptance, not completion, so the client can still send `abort` while the turn runs.
81
68
  async #run(command: CodeAgentCommand): Promise<unknown> {
82
69
  switch (command.type) {
83
70
  case "prompt":
@@ -9,16 +9,8 @@ interface ClientState {
9
9
  decoder: TextDecoder;
10
10
  }
11
11
 
12
- /**
13
- * `akan code --rpc-listen <unix:PATH | tcp:[HOST:]PORT>` — the RPC host on a socket instead of stdio, so the agent
14
- * outlives the process driving it. A control plane that restarts reconnects and resumes with
15
- * `get_state { sinceSeq }`; the turn it was watching kept running in the meantime.
16
- *
17
- * One client at a time: a new connection replaces the old one, which is what a reconnecting control plane whose
18
- * previous socket has not timed out yet needs. TCP binds 127.0.0.1 unless a host is named, because the wire has
19
- * no authentication of its own — reach a pod through its unix socket and whatever the orchestrator already
20
- * authenticates.
21
- */
12
+ // One client at a time: a reconnecting control plane replaces a socket that has not timed out yet.
13
+ // TCP binds 127.0.0.1 unless a host is named, because the wire has no authentication of its own.
22
14
  export class CodeAgentRpcListener {
23
15
  readonly #host: CodeAgentRpcHost;
24
16
  readonly #address: CodeAgentRpcAddress;
@@ -8,12 +8,6 @@ export interface CodeAgentStreamPrinterOptions {
8
8
  write?: (text: string) => void;
9
9
  }
10
10
 
11
- /**
12
- * Prints the akan wire to a terminal, reading nothing but the contract.
13
- *
14
- * That restriction is the point: the TUI and the web host read the same events, so anything this printer cannot
15
- * render is a hole in the contract rather than a missing feature of one host.
16
- */
17
11
  export class CodeAgentStreamPrinter {
18
12
  readonly #options: CodeAgentStreamPrinterOptions;
19
13
  readonly #write: (text: string) => void;
@@ -34,28 +28,23 @@ export class CodeAgentStreamPrinter {
34
28
  if (!this.#options.thinking) return;
35
29
  this.#streaming = true;
36
30
  return this.#write(chalk.dim(event.text));
37
- case "message":
38
- return;
39
31
  case "tool_start":
40
32
  return this.#line(chalk.cyan(`→ ${event.tool.title}`));
41
- case "tool_progress":
42
- return;
43
33
  case "tool_end":
44
34
  return this.#line(CodeAgentStreamPrinter.#toolEnd(event));
45
35
  case "session":
46
36
  return this.#line(chalk.dim(codeAgentEventLabel(event)));
47
37
  case "turn_end":
48
- // Every other reason is one the operator already knows: they typed the prompt, or pressed the key.
49
- // A truncated answer is the one that looks exactly like a finished one unless it is said out loud.
38
+ // Only `truncated` is said: a cut answer looks exactly like a finished one.
50
39
  if (event.stopReason === "truncated") return this.#line(chalk.yellow(codeAgentEventLabel(event)));
51
40
  return;
41
+ case "message":
42
+ case "tool_progress":
52
43
  case "turn_start":
53
44
  case "idle":
54
45
  case "queue":
55
46
  case "context":
56
- return;
57
- // The rail is a live status for a screen that redraws. A stream has no row to update, and the pool
58
- // already says on the transcript when a child starts and what it spent when it stops.
47
+ // A stream has no rail to redraw; the pool already reports a child's start and cost as notices.
59
48
  case "subagent":
60
49
  return;
61
50
  case "question":
@@ -7,14 +7,7 @@ interface SuspendedState {
7
7
  approvals: CodeAgentApprovalRequest[];
8
8
  }
9
9
 
10
- /**
11
- * What a suspending profile is waiting on after its turn ended — the question it asked and the approvals it
12
- * parked. The turn is gone, so nothing in memory holds them; this does, and on a file-backed session it writes
13
- * them beside the session so a worker that restarts and resumes still knows what it asked.
14
- *
15
- * Taking an ask removes it, which is what makes an answer idempotent: a second tab or a retried frame finds
16
- * nothing to take and is refused instead of opening a second turn.
17
- */
10
+ // Taking an ask removes it, which makes an answer idempotent: a second tab or a retried frame finds nothing.
18
11
  export class CodeAgentSuspended {
19
12
  readonly #file: string | null;
20
13
  #state: SuspendedState;
@@ -5,13 +5,8 @@ export interface CodeAgentUiHandlers {
5
5
  notify(level: "info" | "warning" | "error", message: string): void;
6
6
  }
7
7
 
8
- /**
9
- * The engine's UI port, answered by the akan wire instead of by a terminal.
10
- *
11
- * Only the four dialog calls and `notify` mean anything off a TUI; the rest of the port paints widgets, footers
12
- * and editors that exist solely inside the engine's own interactive shell. They are no-ops here rather than
13
- * throws — an extension that sets a status line should not take the turn down with it.
14
- */
8
+ // Only the dialogs and `notify` mean anything off the engine's TUI; the rest are no-ops, not throws, so an
9
+ // extension that sets a status line does not take the turn down with it.
15
10
  export class CodeAgentUi {
16
11
  readonly #handlers: CodeAgentUiHandlers;
17
12
 
@@ -9,6 +9,7 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
  import path from "node:path";
12
+ import { parseJsonLine, readJsonLines } from "./jsonLines";
12
13
 
13
14
  export interface CodePeer {
14
15
  id: string;
@@ -26,18 +27,9 @@ export interface CodeMail {
26
27
  text: string;
27
28
  }
28
29
 
29
- /**
30
- * How two `akan code` sessions in one workspace reach each other.
31
- *
32
- * A directory, not a socket or a daemon: the sessions already share a checkout and already write their
33
- * transcripts there, and a mailbox on disk survives one end restarting, needs nothing listening to accept a
34
- * message, and is inspectable with `cat` when a message does not arrive.
35
- *
36
- * Presence is a heartbeat file rather than a registration, because the interesting failure is a session that
37
- * died without saying so — a registry would keep listing it forever, while a stale mtime says it is gone.
38
- */
30
+ // A directory, not a socket or daemon: it survives either end restarting and needs nothing listening.
31
+ // Presence is a heartbeat file, not a registration, so a session that died silently goes stale on its own.
39
32
  export class CodeMailbox {
40
- /** A session older than this said nothing for four heartbeats, so it is not there to answer. */
41
33
  static readonly staleMs = 20_000;
42
34
  static readonly beatMs = 5_000;
43
35
  static readonly pollMs = 1_500;
@@ -66,9 +58,8 @@ export class CodeMailbox {
66
58
  open(cwd: string, onMail: (mail: CodeMail) => void) {
67
59
  mkdirSync(path.join(this.#dir, "live"), { recursive: true });
68
60
  mkdirSync(path.join(this.#dir, "inbox"), { recursive: true });
69
- // Whatever was already in the inbox belongs to an earlier run of this session, which has read it or is
70
- // never going to; delivering it now would replay a conversation that already happened.
71
- this.#read = CodeMailbox.#lines(this.inbox).length;
61
+ // What is already in the inbox belongs to an earlier run; delivering it would replay a past conversation.
62
+ this.#read = readJsonLines(this.inbox).length;
72
63
  this.beat(cwd);
73
64
  this.#beat = setInterval(() => this.beat(cwd), CodeMailbox.beatMs);
74
65
  this.#poll = setInterval(() => {
@@ -137,12 +128,12 @@ export class CodeMailbox {
137
128
  }
138
129
 
139
130
  #drain(): CodeMail[] {
140
- const lines = CodeMailbox.#lines(this.inbox);
131
+ const lines = readJsonLines(this.inbox);
141
132
  if (lines.length <= this.#read) return [];
142
133
  const fresh = lines.slice(this.#read);
143
134
  this.#read = lines.length;
144
135
  return fresh
145
- .map((line) => CodeMailbox.#parse(line))
136
+ .map((line) => parseJsonLine<CodeMail>(line))
146
137
  .filter((mail): mail is CodeMail => !!mail?.text && typeof mail.from === "string");
147
138
  }
148
139
 
@@ -154,22 +145,4 @@ export class CodeMailbox {
154
145
  return undefined;
155
146
  }
156
147
  }
157
-
158
- static #lines(file: string) {
159
- try {
160
- return readFileSync(file, "utf8")
161
- .split("\n")
162
- .filter((line) => !!line.trim());
163
- } catch {
164
- return [];
165
- }
166
- }
167
-
168
- static #parse(line: string) {
169
- try {
170
- return JSON.parse(line) as CodeMail;
171
- } catch {
172
- return undefined;
173
- }
174
- }
175
148
  }
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { codeAgentSessionName } from "akanjs/common";
5
5
  import { CodeSessionIndex } from "./CodeSessionIndex";
6
+ import { parseJsonLine } from "./jsonLines";
6
7
 
7
8
  interface CodeSessionRecord {
8
9
  [key: string]: unknown;
@@ -10,14 +11,7 @@ interface CodeSessionRecord {
10
11
  id?: string;
11
12
  }
12
13
 
13
- /**
14
- * Copies a session's file so one conversation can continue two ways.
15
- *
16
- * A fork is a file copy because that is exactly what a session is: an append-only log the engine replays. The
17
- * copy is rewritten in one place — the `session` record's id — and then the two files know nothing about each
18
- * other, which is the property that matters. Sharing an id instead would give two live agents one inbox, one
19
- * presence entry and one resume target, and the second one to write would win silently.
20
- */
14
+ // Only the `session` record's id is rewritten: a shared id would give two agents one inbox and one resume target.
21
15
  export class CodeSessionFork {
22
16
  static fork(dir: string, id: string, name?: string) {
23
17
  const source = CodeSessionIndex.fileOf(dir, id);
@@ -29,15 +23,14 @@ export class CodeSessionFork {
29
23
  const at = new Date();
30
24
  const forked = Bun.randomUUIDv7();
31
25
  const records = lines
32
- .map((line) => CodeSessionFork.#parse(line))
26
+ .map((line) => parseJsonLine<CodeSessionRecord>(line))
33
27
  .filter((record): record is CodeSessionRecord => !!record);
34
28
  const first = records.find((record) => record.type === "session");
35
29
  if (!first) throw new Error(`Session ${id} carries no session record`);
36
30
  first.id = forked;
37
31
  first.timestamp = at.toISOString();
38
32
  records.push(CodeSessionFork.#named(records, name ?? CodeSessionFork.#nameOf(records), at));
39
- // The name is the timestamp the file was created plus the id, which is the shape the engine writes and
40
- // what `CodeSessionIndex` matches on when a resume names one.
33
+ // The engine's own file-name shape, which `CodeSessionIndex.fileOf` matches a resume against.
41
34
  const file = path.join(dir, `${at.toISOString().replace(/[:.]/g, "-")}_${forked}.jsonl`);
42
35
  writeFileSync(file, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
43
36
  return { id: forked, file };
@@ -60,12 +53,4 @@ export class CodeSessionFork {
60
53
  const current = typeof named?.name === "string" ? named.name : "";
61
54
  return codeAgentSessionName(current ? `${current} fork` : "fork");
62
55
  }
63
-
64
- static #parse(line: string) {
65
- try {
66
- return JSON.parse(line) as CodeSessionRecord;
67
- } catch {
68
- return undefined;
69
- }
70
- }
71
56
  }
@@ -1,5 +1,6 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { parseJsonLine, readJsonLines } from "./jsonLines";
3
4
 
4
5
  export interface CodeSessionEntry {
5
6
  id: string;
@@ -12,15 +13,8 @@ export interface CodeSessionEntry {
12
13
  turns: number;
13
14
  }
14
15
 
15
- /**
16
- * The sessions on disk, read as files rather than through the engine.
17
- *
18
- * The engine's session manager addresses one session at a time — it can open a file and append to it, and has
19
- * nothing that enumerates the directory. The format is self-describing JSONL, so the listing is a read of
20
- * lines we already own the shape of, and it costs no engine instance to produce.
21
- */
16
+ // Read as files: the engine's session manager has nothing that enumerates the directory.
22
17
  export class CodeSessionIndex {
23
- /** Past this, a file is a session long enough that its opening lines are far from the interesting part. */
24
18
  static readonly readLimit = 512 * 1024;
25
19
 
26
20
  static list(dir: string, limit = 30): CodeSessionEntry[] {
@@ -52,11 +46,11 @@ export class CodeSessionIndex {
52
46
  }
53
47
 
54
48
  static #read(file: string, updatedAt: number): CodeSessionEntry | undefined {
55
- const lines = CodeSessionIndex.#lines(file);
49
+ const lines = readJsonLines(file, CodeSessionIndex.readLimit);
56
50
  if (!lines.length) return undefined;
57
51
  const entry: CodeSessionEntry = { id: "", file, name: undefined, opening: "", updatedAt, turns: 0 };
58
52
  for (const line of lines) {
59
- const record = CodeSessionIndex.#parse(line);
53
+ const record = parseJsonLine<{ [key: string]: unknown; type?: string }>(line);
60
54
  if (!record) continue;
61
55
  if (record.type === "session" && typeof record.id === "string") entry.id = record.id;
62
56
  // A rename appends a second entry rather than editing the first, so the last one on the file wins.
@@ -70,26 +64,6 @@ export class CodeSessionIndex {
70
64
  return entry.id ? entry : undefined;
71
65
  }
72
66
 
73
- static #lines(file: string) {
74
- try {
75
- if (statSync(file).size > CodeSessionIndex.readLimit) return [];
76
- return readFileSync(file, "utf8")
77
- .split("\n")
78
- .filter((line) => !!line.trim());
79
- } catch {
80
- return [];
81
- }
82
- }
83
-
84
- static #parse(line: string): { [key: string]: unknown; type?: string } | undefined {
85
- try {
86
- return JSON.parse(line) as { type?: string };
87
- } catch {
88
- return undefined;
89
- }
90
- }
91
-
92
- /** One line, always: the opening ask is a label in a list, and a pasted stack trace is not a label. */
93
67
  static #text(content: unknown) {
94
68
  const text =
95
69
  typeof content === "string"