@akanjs/devkit 3.0.0-beta.13 → 3.0.0-beta.15

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 (64) hide show
  1. package/README.md +1 -0
  2. package/cloud/cloudApi.ts +17 -4
  3. package/cloud/constants.ts +0 -3
  4. package/cloud/globalConfig.ts +1 -9
  5. package/codeAgent/agent/AkanAuthStorageBackend.ts +105 -0
  6. package/codeAgent/agent/AkanCodeServices.ts +104 -0
  7. package/codeAgent/agent/CodeAgent.ts +543 -0
  8. package/codeAgent/agent/CodeAgentAsks.ts +87 -0
  9. package/codeAgent/agent/CodeAgentEventMapper.ts +260 -0
  10. package/codeAgent/agent/CodeAgentGate.ts +119 -0
  11. package/codeAgent/agent/CodeAgentRpcHost.ts +96 -0
  12. package/codeAgent/agent/CodeAgentStreamPrinter.ts +93 -0
  13. package/codeAgent/agent/CodeAgentUi.ts +52 -0
  14. package/codeAgent/agent/CodeMailbox.ts +175 -0
  15. package/codeAgent/agent/CodeSessionFork.ts +71 -0
  16. package/codeAgent/agent/CodeSessionIndex.ts +104 -0
  17. package/codeAgent/agent/SubagentPool.ts +173 -0
  18. package/codeAgent/agent/akanCodeModel.ts +81 -0
  19. package/codeAgent/agent/akanCodePaths.ts +31 -0
  20. package/codeAgent/agent/akanSystemPrompt.ts +49 -0
  21. package/codeAgent/agent/codeAgentContract.test.ts +529 -0
  22. package/codeAgent/agent/codeMailbox.test.ts +80 -0
  23. package/codeAgent/agent/codeSessionFork.test.ts +60 -0
  24. package/codeAgent/agent/codeSessionIndex.test.ts +72 -0
  25. package/codeAgent/agent/consoleToStderr.ts +18 -0
  26. package/codeAgent/feedback/DevLogFeedback.ts +97 -0
  27. package/codeAgent/feedback/PreviewChrome.ts +106 -0
  28. package/codeAgent/feedback/PreviewFeedback.ts +53 -0
  29. package/codeAgent/feedback/PreviewView.ts +107 -0
  30. package/codeAgent/feedback/TurnFeedback.ts +113 -0
  31. package/codeAgent/feedback/VerifyFeedback.ts +46 -0
  32. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  33. package/codeAgent/index.ts +43 -0
  34. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  35. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  36. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  37. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  38. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  39. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  40. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  41. package/codeAgent/skills/akan-store/SKILL.md +63 -0
  42. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  43. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  44. package/codeAgent/tools/AkanCli.ts +62 -0
  45. package/codeAgent/tools/AkanCodePlugins.ts +135 -0
  46. package/codeAgent/tools/AkanEditScope.ts +58 -0
  47. package/codeAgent/tools/AkanEnvKeys.ts +56 -0
  48. package/codeAgent/tools/AkanToolPack.ts +114 -0
  49. package/codeAgent/tools/AkanVerifier.ts +55 -0
  50. package/codeAgent/tools/MailToolPack.ts +85 -0
  51. package/codeAgent/tools/McpClient.ts +162 -0
  52. package/codeAgent/tools/McpToolPack.ts +134 -0
  53. package/codeAgent/tools/SessionToolPack.ts +101 -0
  54. package/codeAgent/tools/WebToolPack.ts +153 -0
  55. package/codeAgent/tools/akanCodePlugins.test.ts +476 -0
  56. package/commandDecorators/command.ts +1 -1
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -1
  58. package/index.ts +3 -2
  59. package/package.json +5 -5
  60. package/stripAnsi.ts +3 -0
  61. package/testHelpers.ts +179 -0
  62. package/abstractCompactor.ts +0 -112
  63. package/aiEditor.test.ts +0 -60
  64. package/aiEditor.ts +0 -387
package/README.md CHANGED
@@ -44,6 +44,7 @@ await runner.build();
44
44
  - Command/script decorators used by `@akanjs/cli`.
45
45
  - AI prompt, guideline, and code-generation support utilities.
46
46
  - Capacitor and mobile release helpers.
47
+ - The `akan code` agent engine, the tools it is given, and the shipped akan skills (`@akanjs/devkit/codeAgent`).
47
48
 
48
49
  ## Dev Server Sizing
49
50
 
package/cloud/cloudApi.ts CHANGED
@@ -2,6 +2,11 @@ import type { Workspace } from "../commandDecorators";
2
2
  import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
3
3
  import { GlobalConfig } from "./globalConfig";
4
4
 
5
+ interface HttpRequestOptions {
6
+ headers?: Record<string, string>;
7
+ signal?: AbortSignal;
8
+ }
9
+
5
10
  class HttpClient {
6
11
  readonly baseUrl: string;
7
12
  readonly headers: Record<string, string> = {};
@@ -9,13 +14,14 @@ class HttpClient {
9
14
  this.baseUrl = baseUrl;
10
15
  this.headers = headers;
11
16
  }
12
- async get<T>(url: string, { headers }: { headers?: Record<string, string> } = {}): Promise<T> {
17
+ async get<T>(url: string, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
13
18
  const response = await fetch(`${this.baseUrl}${url}`, {
14
19
  headers: {
15
20
  "Content-Type": "application/json",
16
21
  ...this.headers,
17
22
  ...headers,
18
23
  },
24
+ ...(signal ? { signal } : {}),
19
25
  });
20
26
  return await HttpClient.#body<T>(response, url);
21
27
  }
@@ -26,7 +32,7 @@ class HttpClient {
26
32
  if (!response.ok) throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
27
33
  await Bun.write(localPath, response);
28
34
  }
29
- async post<T>(url: string, data: unknown, { headers }: { headers?: Record<string, string> } = {}): Promise<T> {
35
+ async post<T>(url: string, data: unknown, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
30
36
  const isFormData = data instanceof FormData;
31
37
  const response = await fetch(`${this.baseUrl}${url}`, {
32
38
  method: "POST",
@@ -34,6 +40,7 @@ class HttpClient {
34
40
  headers: isFormData
35
41
  ? { ...this.headers, ...headers }
36
42
  : { "Content-Type": "application/json", ...this.headers, ...headers },
43
+ ...(signal ? { signal } : {}),
37
44
  });
38
45
  return await HttpClient.#body<T>(response, url);
39
46
  }
@@ -153,9 +160,15 @@ export class CloudApi {
153
160
  async tunnelListInSelf(): Promise<TunnelSummary[]> {
154
161
  return await this.#api.get<TunnelSummary[]>(`/tunnel/tunnelListInSelf`);
155
162
  }
156
- /** Hands the grant back. The CLI calls this "stop", which is what the operator is doing, not what it does. */
163
+ /**
164
+ * Hands the grant back. The CLI calls this "stop", which is what the operator is doing, not what it does.
165
+ *
166
+ * Bounded because every caller is on the way out: an unreachable control plane is exactly the state a session
167
+ * ends in, and an unbounded `fetch` there holds the event loop open long after the work is done. Losing the
168
+ * release costs nothing — the share expires on its own TTL.
169
+ */
157
170
  async revokeTunnel(code: string): Promise<boolean> {
158
- return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code });
171
+ return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
159
172
  }
160
173
  async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
161
174
  try {
@@ -1,5 +1,4 @@
1
1
  import type { Dayjs } from "dayjs";
2
- import type { SupportedLlmModel } from "../aiEditor";
3
2
  import { GlobalConfig } from "./globalConfig";
4
3
 
5
4
  export const basePath = `${Bun.env.HOME ?? Bun.env.USERPROFILE}/.akan`;
@@ -28,12 +27,10 @@ export interface RemoteEnvServerConfig {
28
27
  export interface AkanGlobalConfig {
29
28
  cloudHost: { [key: string]: HostConfigDto };
30
29
  remoteEnvServers: Record<string, RemoteEnvServerConfig>;
31
- llm: { model: SupportedLlmModel; apiKey: string } | null;
32
30
  }
33
31
  export const defaultAkanGlobalConfig: AkanGlobalConfig = {
34
32
  cloudHost: {},
35
33
  remoteEnvServers: {},
36
- llm: null,
37
34
  };
38
35
 
39
36
  export interface AccessTokenDto {
@@ -30,7 +30,7 @@ export class GlobalConfig {
30
30
  };
31
31
  }
32
32
  /**
33
- * This file holds the cloud jwt, a refresh token that does not expire, and the LLM api key, so it is
33
+ * This file holds the cloud jwt and a refresh token that does not expire, so it is
34
34
  * written owner-only — the same `0600` the runtime gives its control socket. `Bun.write` takes no mode
35
35
  * and lands on `0666 & ~umask` (0644 on a default shell), so the mode is applied after the write; an
36
36
  * existing world-readable file is tightened by the next write rather than left as it was found.
@@ -49,14 +49,6 @@ export class GlobalConfig {
49
49
  akanConfig.cloudHost[config.host] = GlobalConfig.toHostConfigDto(config);
50
50
  await GlobalConfig.#setAkanGlobalConfig(akanConfig);
51
51
  }
52
- static async getLlmConfig(): Promise<AkanGlobalConfig["llm"]> {
53
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
54
- return akanConfig.llm ?? null;
55
- }
56
- static async setLlmConfig(llmConfig: AkanGlobalConfig["llm"]) {
57
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
58
- await GlobalConfig.#setAkanGlobalConfig({ ...akanConfig, llm: llmConfig });
59
- }
60
52
  static async getRemoteEnvServers(): Promise<AkanGlobalConfig["remoteEnvServers"]> {
61
53
  const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
62
54
  return akanConfig.remoteEnvServers;
@@ -0,0 +1,105 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readFileSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import path from "node:path";
12
+ import type { AuthStorageBackend } from "@earendil-works/pi-coding-agent";
13
+
14
+ /**
15
+ * Credential store for `akan code`, in akan's own file rather than the engine's.
16
+ *
17
+ * The engine only asks for a locked read-modify-write over one opaque string, so satisfying its interface with
18
+ * our own file keeps every key under `~/.akan/` — nothing lands in `~/.pi/` and a user who removes akan takes
19
+ * their credentials with them.
20
+ *
21
+ * The lock is an exclusive-create sentinel rather than an advisory flock: two `akan code` processes refreshing
22
+ * an OAuth token at the same moment would otherwise each write a token the other invalidates.
23
+ */
24
+ export class AkanAuthStorageBackend implements AuthStorageBackend {
25
+ readonly #authPath: string;
26
+ readonly #lockPath: string;
27
+
28
+ constructor(authPath: string) {
29
+ this.#authPath = authPath;
30
+ this.#lockPath = `${authPath}.lock`;
31
+ }
32
+
33
+ withLock<T>(fn: (current: string | undefined) => { result: T; next?: string }) {
34
+ this.#acquire();
35
+ try {
36
+ const { result, next } = fn(this.#read());
37
+ if (next !== undefined) this.#write(next);
38
+ return result;
39
+ } finally {
40
+ this.#release();
41
+ }
42
+ }
43
+
44
+ async withLockAsync<T>(fn: (current: string | undefined) => Promise<{ result: T; next?: string }>) {
45
+ this.#acquire();
46
+ try {
47
+ const { result, next } = await fn(this.#read());
48
+ if (next !== undefined) this.#write(next);
49
+ return result;
50
+ } finally {
51
+ this.#release();
52
+ }
53
+ }
54
+
55
+ #read() {
56
+ if (!existsSync(this.#authPath)) return undefined;
57
+ const text = readFileSync(this.#authPath, "utf8");
58
+ return text.trim() ? text : undefined;
59
+ }
60
+
61
+ #write(next: string) {
62
+ mkdirSync(path.dirname(this.#authPath), { recursive: true, mode: 0o700 });
63
+ const temp = `${this.#authPath}.${process.pid}.tmp`;
64
+ writeFileSync(temp, next, { mode: 0o600 });
65
+ renameSync(temp, this.#authPath);
66
+ }
67
+
68
+ #acquire() {
69
+ mkdirSync(path.dirname(this.#lockPath), { recursive: true, mode: 0o700 });
70
+ const deadline = Date.now() + 5_000;
71
+ for (;;) {
72
+ try {
73
+ closeSync(openSync(this.#lockPath, "wx"));
74
+ return;
75
+ } catch {
76
+ // A lock left behind by a killed process would block every later run, so it expires.
77
+ if (this.#lockIsStale() || Date.now() > deadline) {
78
+ try {
79
+ unlinkSync(this.#lockPath);
80
+ } catch {
81
+ // Another process won the race to clear it; retry the create.
82
+ }
83
+ continue;
84
+ }
85
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
86
+ }
87
+ }
88
+ }
89
+
90
+ #lockIsStale() {
91
+ try {
92
+ return Date.now() - Bun.file(this.#lockPath).lastModified > 30_000;
93
+ } catch {
94
+ return true;
95
+ }
96
+ }
97
+
98
+ #release() {
99
+ try {
100
+ unlinkSync(this.#lockPath);
101
+ } catch {
102
+ // Already gone — a stale-lock sweep by another process.
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import {
3
+ AuthStorage,
4
+ type InlineExtension,
5
+ ModelRegistry,
6
+ SessionManager,
7
+ SettingsManager,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { CodeAgentProfile } from "akanjs/common";
10
+ import { AkanEnvKeys } from "../tools/AkanEnvKeys";
11
+ import { AkanAuthStorageBackend } from "./AkanAuthStorageBackend";
12
+ import { akanCodePaths } from "./akanCodePaths";
13
+ import { akanSystemPrompt } from "./akanSystemPrompt";
14
+ import { CodeSessionIndex } from "./CodeSessionIndex";
15
+
16
+ export interface AkanCodeServicesOptions {
17
+ workspaceRoot: string;
18
+ cwd: string;
19
+ profile: CodeAgentProfile;
20
+ extensions: InlineExtension[];
21
+ sessionId?: string;
22
+ }
23
+
24
+ /**
25
+ * Assembles the engine's injectable services from akan's own storage.
26
+ *
27
+ * Every one of the five is passed explicitly. Leave any of them out and the engine falls back to `~/.pi/` —
28
+ * measured: with all five injected it writes nothing outside the paths named here.
29
+ */
30
+ export class AkanCodeServices {
31
+ static settings() {
32
+ // Settings are in memory on purpose: the engine's file settings would be a second configuration surface
33
+ // beside `akan.config.ts` with its own precedence, for options akan already decides through the profile.
34
+ // `enableInstallTelemetry` defaults to true and attaches pi-branded headers to OpenRouter, NVIDIA and
35
+ // Cloudflare requests, so it is turned off here rather than per host — a host that changes provider later
36
+ // would otherwise turn it back on without meaning to.
37
+ return SettingsManager.inMemory({
38
+ enableInstallTelemetry: false,
39
+ enableAnalytics: false,
40
+ quietStartup: true,
41
+ });
42
+ }
43
+
44
+ static auth(workspaceRoot: string) {
45
+ mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
46
+ const authStorage = AuthStorage.fromStorage(new AkanAuthStorageBackend(akanCodePaths.authFile()));
47
+ AkanEnvKeys.apply(authStorage, workspaceRoot);
48
+ return authStorage;
49
+ }
50
+
51
+ static models(authStorage: AuthStorage) {
52
+ return ModelRegistry.create(authStorage, akanCodePaths.modelsFile());
53
+ }
54
+
55
+ static sessions(workspaceRoot: string, cwd: string, profile: CodeAgentProfile, resume?: string) {
56
+ if (profile.session.store !== "file") return SessionManager.inMemory(cwd);
57
+ const dir = akanCodePaths.sessionsDir(workspaceRoot);
58
+ mkdirSync(dir, { recursive: true });
59
+ const manager = SessionManager.create(cwd, dir);
60
+ if (!resume) return manager;
61
+ const file = CodeSessionIndex.fileOf(dir, resume);
62
+ // A mistyped id that quietly opened a new session would look like a session that lost its history.
63
+ if (!file) throw new Error(`No stored session ${resume} in ${dir}`);
64
+ manager.setSessionFile(file);
65
+ return manager;
66
+ }
67
+
68
+ /**
69
+ * The akan skill set, then the workspace's own.
70
+ *
71
+ * A path that does not exist is a reported load error rather than a skip, so both are checked first.
72
+ */
73
+ static #skillPaths(profile: CodeAgentProfile, workspaceRoot: string) {
74
+ if (!profile.context.skills) return [];
75
+ const workspace = akanCodePaths.skillsDir(workspaceRoot);
76
+ return [akanCodePaths.builtinSkillsDir(), existsSync(workspace) ? workspace : undefined].filter(
77
+ (dir): dir is string => !!dir,
78
+ );
79
+ }
80
+
81
+ /**
82
+ * What the engine's resource loader may discover. Extensions and skills come from the workspace, never from
83
+ * the home directory: a coding agent that picks up a globally installed extension behaves differently in two
84
+ * checkouts of the same repo for reasons nothing in the repo explains.
85
+ */
86
+ static resourceOptions(options: AkanCodeServicesOptions) {
87
+ // A path that does not exist is reported as a load error rather than skipped, and these two are optional.
88
+ const present = (dir: string) => (existsSync(dir) ? [dir] : []);
89
+ return {
90
+ noExtensions: true,
91
+ // Always on: it turns off the engine's *default* skill locations, which are `~/.pi/skills` and the
92
+ // checkout's own `.pi/`. Explicit `additionalSkillPaths` are merged either way, so this is the switch
93
+ // that keeps a skill in whoever's home directory from changing how the agent works here.
94
+ noSkills: true,
95
+ noPromptTemplates: true,
96
+ noThemes: true,
97
+ noContextFiles: !options.profile.context.projectFiles,
98
+ appendSystemPrompt: [akanSystemPrompt(options.profile)],
99
+ additionalExtensionPaths: present(akanCodePaths.extensionsDir(options.workspaceRoot)),
100
+ additionalSkillPaths: AkanCodeServices.#skillPaths(options.profile, options.workspaceRoot),
101
+ extensionFactories: options.extensions,
102
+ };
103
+ }
104
+ }