@akanjs/devkit 3.0.0-beta.14 → 3.0.0-beta.16

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 (59) hide show
  1. package/README.md +1 -0
  2. package/codeAgent/agent/AkanAuthStorageBackend.ts +105 -0
  3. package/codeAgent/agent/AkanCodeServices.ts +104 -0
  4. package/codeAgent/agent/CodeAgent.ts +543 -0
  5. package/codeAgent/agent/CodeAgentAsks.ts +87 -0
  6. package/codeAgent/agent/CodeAgentEventMapper.ts +260 -0
  7. package/codeAgent/agent/CodeAgentGate.ts +119 -0
  8. package/codeAgent/agent/CodeAgentRpcHost.ts +96 -0
  9. package/codeAgent/agent/CodeAgentStreamPrinter.ts +93 -0
  10. package/codeAgent/agent/CodeAgentUi.ts +52 -0
  11. package/codeAgent/agent/CodeMailbox.ts +175 -0
  12. package/codeAgent/agent/CodeSessionFork.ts +71 -0
  13. package/codeAgent/agent/CodeSessionIndex.ts +104 -0
  14. package/codeAgent/agent/SubagentPool.ts +173 -0
  15. package/codeAgent/agent/akanCodeModel.ts +81 -0
  16. package/codeAgent/agent/akanCodePaths.ts +31 -0
  17. package/codeAgent/agent/akanSystemPrompt.ts +49 -0
  18. package/codeAgent/agent/codeAgentContract.test.ts +529 -0
  19. package/codeAgent/agent/codeMailbox.test.ts +80 -0
  20. package/codeAgent/agent/codeSessionFork.test.ts +60 -0
  21. package/codeAgent/agent/codeSessionIndex.test.ts +72 -0
  22. package/codeAgent/agent/consoleToStderr.ts +18 -0
  23. package/codeAgent/feedback/DevLogFeedback.ts +97 -0
  24. package/codeAgent/feedback/PreviewChrome.ts +106 -0
  25. package/codeAgent/feedback/PreviewFeedback.ts +53 -0
  26. package/codeAgent/feedback/PreviewView.ts +107 -0
  27. package/codeAgent/feedback/TurnFeedback.ts +113 -0
  28. package/codeAgent/feedback/VerifyFeedback.ts +46 -0
  29. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  30. package/codeAgent/index.ts +43 -0
  31. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  32. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  33. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  34. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  35. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  36. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  37. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  38. package/codeAgent/skills/akan-store/SKILL.md +63 -0
  39. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  40. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  41. package/codeAgent/tools/AkanCli.ts +62 -0
  42. package/codeAgent/tools/AkanCodePlugins.ts +135 -0
  43. package/codeAgent/tools/AkanEditScope.ts +58 -0
  44. package/codeAgent/tools/AkanEnvKeys.ts +56 -0
  45. package/codeAgent/tools/AkanToolPack.ts +114 -0
  46. package/codeAgent/tools/AkanVerifier.ts +55 -0
  47. package/codeAgent/tools/MailToolPack.ts +85 -0
  48. package/codeAgent/tools/McpClient.ts +162 -0
  49. package/codeAgent/tools/McpToolPack.ts +134 -0
  50. package/codeAgent/tools/SessionToolPack.ts +101 -0
  51. package/codeAgent/tools/WebToolPack.ts +153 -0
  52. package/codeAgent/tools/akanCodePlugins.test.ts +476 -0
  53. package/executors.ts +5 -0
  54. package/index.ts +2 -0
  55. package/package.json +8 -2
  56. package/piDependencyPins.test.ts +55 -0
  57. package/stripAnsi.ts +3 -0
  58. package/testHelpers.ts +179 -0
  59. package/types.ts +1 -0
@@ -0,0 +1,260 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type CodeAgentEventBody,
4
+ type CodeAgentStopReason,
5
+ type CodeAgentToolOp,
6
+ type CodeAgentToolSummary,
7
+ codeAgentClip,
8
+ codeAgentLabelChars,
9
+ codeAgentOutputChars,
10
+ } from "akanjs/common";
11
+
12
+ const toolOps: { [key: string]: CodeAgentToolOp } = {
13
+ read: "read",
14
+ write: "write",
15
+ edit: "write",
16
+ ls: "list",
17
+ find: "search",
18
+ grep: "search",
19
+ bash: "execute",
20
+ };
21
+
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
+ */
29
+ export class CodeAgentEventMapper {
30
+ #turn = 0;
31
+ #turnId = "";
32
+ #outcome: CodeAgentStopReason | undefined;
33
+ readonly #openTools = new Map<string, CodeAgentToolSummary>();
34
+ readonly #blocked = new Map<string, string>();
35
+
36
+ get turnId() {
37
+ return this.#turnId;
38
+ }
39
+
40
+ map(event: AgentSessionEvent): CodeAgentEventBody[] {
41
+ switch (event.type) {
42
+ case "agent_start":
43
+ this.#turn += 1;
44
+ this.#turnId = `t${this.#turn}`;
45
+ // A hint left by an abort that hit an idle session must not colour the turn after it.
46
+ this.#outcome = undefined;
47
+ return [{ type: "turn_start", turnId: this.#turnId }];
48
+ 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.
51
+ return [
52
+ ...this.flushBlocked(),
53
+ { type: "turn_end", turnId: this.#turnId, stopReason: this.#stopReason(event) },
54
+ ];
55
+ case "agent_settled":
56
+ return [{ type: "idle" }];
57
+ case "message_update":
58
+ return this.#mapDelta(event);
59
+ case "message_end":
60
+ return this.#mapMessage(event);
61
+ case "tool_execution_start": {
62
+ const tool = this.#summary(event.toolCallId, event.toolName, event.args);
63
+ this.#openTools.set(event.toolCallId, tool);
64
+ return [{ type: "tool_start", turnId: this.#turnId, tool }];
65
+ }
66
+ case "tool_execution_update":
67
+ return [
68
+ {
69
+ type: "tool_progress",
70
+ turnId: this.#turnId,
71
+ toolCallId: event.toolCallId,
72
+ text: codeAgentClip(CodeAgentEventMapper.#renderToolText(event.partialResult), codeAgentLabelChars),
73
+ },
74
+ ];
75
+ case "tool_execution_end":
76
+ return [this.#mapToolEnd(event)];
77
+ case "queue_update":
78
+ return [{ type: "queue", steering: [...event.steering], followUp: [...event.followUp] }];
79
+ case "compaction_start":
80
+ return [{ type: "compaction", phase: "start", reason: event.reason }];
81
+ case "compaction_end":
82
+ return [{ type: "compaction", phase: "end", reason: event.reason }];
83
+ case "auto_retry_start":
84
+ return [
85
+ {
86
+ type: "retry",
87
+ attempt: event.attempt,
88
+ maxAttempts: event.maxAttempts,
89
+ delayMs: event.delayMs,
90
+ message: event.errorMessage,
91
+ },
92
+ ];
93
+ default:
94
+ return [];
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Records that a call was refused before it ran.
100
+ *
101
+ * The refusal arrives in the `tool_call` hook, which fires **before** `tool_execution_start` — measured: a
102
+ * blocked call produces no start event at all, and then a `tool_execution_end` carrying `isError`. So the
103
+ * start frame is synthesised here and the engine's own end frame is relabelled `blocked`, rather than
104
+ * synthesising a terminal frame that would arrive twice.
105
+ */
106
+ markBlocked(toolCallId: string, toolName: string, args: unknown, reason: string): CodeAgentEventBody[] {
107
+ this.#blocked.set(toolCallId, reason);
108
+ if (this.#openTools.has(toolCallId)) return [];
109
+ const tool = this.#summary(toolCallId, toolName, args);
110
+ this.#openTools.set(toolCallId, tool);
111
+ return [{ type: "tool_start", turnId: this.#turnId, tool }];
112
+ }
113
+
114
+ /** A refusal the engine never closed would leave a row open forever, so the turn boundary sweeps them. */
115
+ flushBlocked(): CodeAgentEventBody[] {
116
+ const frames: CodeAgentEventBody[] = [];
117
+ for (const [toolCallId, reason] of this.#blocked) {
118
+ const tool = this.#openTools.get(toolCallId);
119
+ if (!tool) continue;
120
+ this.#openTools.delete(toolCallId);
121
+ frames.push({
122
+ type: "tool_end",
123
+ turnId: this.#turnId,
124
+ tool,
125
+ outcome: "blocked",
126
+ output: reason,
127
+ truncated: false,
128
+ });
129
+ }
130
+ this.#blocked.clear();
131
+ return frames;
132
+ }
133
+
134
+ #mapToolEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): CodeAgentEventBody {
135
+ const tool = this.#openTools.get(event.toolCallId) ?? this.#summary(event.toolCallId, event.toolName, undefined);
136
+ this.#openTools.delete(event.toolCallId);
137
+ const reason = this.#blocked.get(event.toolCallId);
138
+ this.#blocked.delete(event.toolCallId);
139
+ const full = reason ?? CodeAgentEventMapper.#renderToolText(event.result);
140
+ return {
141
+ type: "tool_end",
142
+ turnId: this.#turnId,
143
+ tool,
144
+ outcome: reason ? "blocked" : event.isError ? "error" : "ok",
145
+ output: codeAgentClip(full, codeAgentOutputChars),
146
+ truncated: full.length > codeAgentOutputChars,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Records how this turn is ending, from the side that actually knows.
152
+ *
153
+ * Consumed once, by the next `agent_end`. See {@link CodeAgentEventMapper.#stopReason} for why the engine's
154
+ * own report cannot be trusted for this.
155
+ */
156
+ noteOutcome(reason: CodeAgentStopReason) {
157
+ this.#outcome = reason;
158
+ }
159
+
160
+ /**
161
+ * **The engine does not report an abort.** Measured: interrupting a streaming turn finalizes the partial
162
+ * assistant message with `stopReason: "stop"` — the provider stream was cut, and from the engine's side that
163
+ * is an ordinary stop. Trusting it reports `done` for every interrupt, and a host then has no way to tell a
164
+ * finished answer from a truncated one.
165
+ *
166
+ * So the core's own record wins: it is the party that called `abort()`, and the party that suspended the
167
+ * turn to ask a question. The engine's report is only the fallback.
168
+ */
169
+ #stopReason(event: Extract<AgentSessionEvent, { type: "agent_end" }>): CodeAgentStopReason {
170
+ const noted = this.#outcome;
171
+ this.#outcome = undefined;
172
+ if (noted) return noted;
173
+ if (event.willRetry) return "error";
174
+ const last = event.messages.at(-1) as { stopReason?: string } | undefined;
175
+ if (last?.stopReason === "aborted") return "aborted";
176
+ if (last?.stopReason === "error") return "error";
177
+ // The engine's vocabulary is `stop | length | toolUse | error | aborted`. Branching on only the two that
178
+ // read as failures lets `length` — the model hitting its output cap — leave as `done`, and a half-written
179
+ // answer is then stored, and replayed to the next turn, as a finished one.
180
+ if (last?.stopReason === "length") return "truncated";
181
+ return "done";
182
+ }
183
+
184
+ #summary(toolCallId: string, name: string, args: unknown): CodeAgentToolSummary {
185
+ const path = CodeAgentEventMapper.#pathOf(args);
186
+ return {
187
+ toolCallId,
188
+ name,
189
+ op: toolOps[name] ?? "other",
190
+ ...(path ? { path } : {}),
191
+ title: codeAgentClip(`${name}${CodeAgentEventMapper.#renderArgs(args)}`, codeAgentLabelChars),
192
+ };
193
+ }
194
+
195
+ #mapDelta(event: Extract<AgentSessionEvent, { type: "message_update" }>): CodeAgentEventBody[] {
196
+ const inner = event.assistantMessageEvent;
197
+ if (inner.type === "text_delta") return [{ type: "text_delta", turnId: this.#turnId, text: inner.delta }];
198
+ if (inner.type === "thinking_delta") return [{ type: "thinking_delta", turnId: this.#turnId, text: inner.delta }];
199
+ return [];
200
+ }
201
+
202
+ #mapMessage(event: Extract<AgentSessionEvent, { type: "message_end" }>): CodeAgentEventBody[] {
203
+ const message = event.message as { role?: string; content?: unknown };
204
+ if (message.role !== "assistant" && message.role !== "user") return [];
205
+ const text = CodeAgentEventMapper.#renderContent(message.content);
206
+ if (!text) return [];
207
+ return [{ type: "message", turnId: this.#turnId, role: message.role, text }];
208
+ }
209
+
210
+ static #pathOf(args: unknown) {
211
+ if (!args || typeof args !== "object") return undefined;
212
+ const value = (args as { path?: unknown }).path;
213
+ return typeof value === "string" && value ? value : undefined;
214
+ }
215
+
216
+ /**
217
+ * A one-line rendering, never the values themselves: a `write` call's argument is the whole new file body and
218
+ * an `edit` call's is every replacement in it.
219
+ */
220
+ static #renderArgs(args: unknown) {
221
+ if (!args || typeof args !== "object") return "";
222
+ const entries = Object.entries(args as Record<string, unknown>).filter(([, value]) => value !== undefined);
223
+ if (!entries.length) return "";
224
+ const rendered = entries.map(([key, value]) => `${key}=${CodeAgentEventMapper.#renderArgValue(value)}`).join(" ");
225
+ return `(${codeAgentClip(rendered, 120)})`;
226
+ }
227
+
228
+ /**
229
+ * A structured argument is described by its shape, not stringified.
230
+ *
231
+ * `String({})` is `"[object Object]"` and an array of them is that repeated — which is where an `edit` call's
232
+ * replacement list ends up, so the row says nothing about a call that changed three places in a file.
233
+ */
234
+ static #renderArgValue(value: unknown) {
235
+ if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
236
+ if (value && typeof value === "object") {
237
+ const keys = Object.keys(value as Record<string, unknown>).length;
238
+ return `{${keys} key${keys === 1 ? "" : "s"}}`;
239
+ }
240
+ return codeAgentClip(String(value), 60);
241
+ }
242
+
243
+ static #renderContent(content: unknown): string {
244
+ if (typeof content === "string") return content;
245
+ if (!Array.isArray(content)) return "";
246
+ return content
247
+ .filter(
248
+ (part): part is { type: string; text: string } => !!part && typeof part === "object" && part.type === "text",
249
+ )
250
+ .map((part) => part.text)
251
+ .join("");
252
+ }
253
+
254
+ static #renderToolText(value: unknown): string {
255
+ if (typeof value === "string") return value;
256
+ if (value && typeof value === "object" && "content" in value)
257
+ return CodeAgentEventMapper.#renderContent((value as { content: unknown }).content);
258
+ return value === undefined ? "" : JSON.stringify(value);
259
+ }
260
+ }
@@ -0,0 +1,119 @@
1
+ import path from "node:path";
2
+ import type { CodeAgentProfile } from "akanjs/common";
3
+
4
+ const writeTools = new Set(["write", "edit"]);
5
+ const commandTools = new Set(["bash"]);
6
+
7
+ export interface GateVerdict {
8
+ /** `undefined` means the call may run without asking. */
9
+ block?: string;
10
+ approval?: string;
11
+ }
12
+
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
+ */
20
+ export class CodeAgentGate {
21
+ readonly #profile: CodeAgentProfile;
22
+ readonly #deny: Bun.Glob[];
23
+ readonly #allow: Bun.Glob[];
24
+ readonly #deniedFragments: string[];
25
+
26
+ constructor(profile: CodeAgentProfile) {
27
+ this.#profile = profile;
28
+ this.#deny = (profile.paths.deny ?? []).map((pattern) => new Bun.Glob(pattern));
29
+ this.#allow = (profile.paths.allow ?? []).map((pattern) => new Bun.Glob(pattern));
30
+ this.#deniedFragments = [...new Set((profile.paths.deny ?? []).map(CodeAgentGate.#fragmentOf).filter(Boolean))];
31
+ }
32
+
33
+ verdict(toolName: string, args: unknown): GateVerdict {
34
+ const target = CodeAgentGate.#pathOf(args);
35
+ if (target) {
36
+ const denied = this.#deniedReason(target);
37
+ if (denied) return { block: denied };
38
+ }
39
+ const command = CodeAgentGate.#commandOf(args);
40
+ if (command) {
41
+ const denied = this.#deniedCommandReason(command);
42
+ if (denied) return { block: denied };
43
+ }
44
+ if (!this.#needsApproval(toolName)) return {};
45
+ return { approval: CodeAgentGate.#summarize(toolName, args) };
46
+ }
47
+
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
+ */
57
+ #deniedCommandReason(command: string) {
58
+ const hit = this.#deniedFragments.find((fragment) => command.includes(fragment));
59
+ if (!hit) return undefined;
60
+ return `The command names "${hit}", which the profile's deny list covers. Ask the user for the value instead of reading the file.`;
61
+ }
62
+
63
+ #deniedReason(target: string) {
64
+ const absolute = path.isAbsolute(target) ? target : path.resolve(this.#profile.paths.root, target);
65
+ const relative = path.relative(this.#profile.paths.root, absolute);
66
+ if (relative.startsWith("..") || path.isAbsolute(relative))
67
+ return `Path is outside the agent root (${this.#profile.paths.root}).`;
68
+ if (this.#deny.some((glob) => glob.match(absolute) || glob.match(relative)))
69
+ return "Path is on the profile's deny list (secrets and environment files are never readable).";
70
+ if (this.#allow.length && !this.#allow.some((glob) => glob.match(absolute) || glob.match(relative)))
71
+ return "Path is outside the profile's allow list.";
72
+ return undefined;
73
+ }
74
+
75
+ #needsApproval(toolName: string) {
76
+ switch (this.#profile.approval) {
77
+ case "never":
78
+ return false;
79
+ case "writes":
80
+ return writeTools.has(toolName);
81
+ case "commands":
82
+ return commandTools.has(toolName);
83
+ case "all":
84
+ return true;
85
+ default:
86
+ return false;
87
+ }
88
+ }
89
+
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.
93
+ static #fragmentOf(pattern: string) {
94
+ const trimmed = pattern
95
+ .replace(/^\*\*\//, "")
96
+ .replace(/\/\*\*$/, "/")
97
+ .replace(/^\*+/, "");
98
+ return trimmed.includes("*") ? "" : trimmed;
99
+ }
100
+
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
+ static #summarize(toolName: string, args: unknown) {
114
+ const record = (args ?? {}) as Record<string, unknown>;
115
+ if (toolName === "bash") return `run: ${String(record.command ?? "").slice(0, 200)}`;
116
+ const target = CodeAgentGate.#pathOf(args);
117
+ return target ? `${toolName} ${target}` : toolName;
118
+ }
119
+ }
@@ -0,0 +1,96 @@
1
+ import type { CodeAgentCommand, CodeAgentEvent, CodeAgentReply, CodeAgentRequest } from "akanjs/common";
2
+ import type { CodeAgent } from "./CodeAgent";
3
+
4
+ /**
5
+ * Serves one code agent over stdio as newline-delimited akan wire frames.
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
+ */
14
+ export class CodeAgentRpcHost {
15
+ readonly #agent: CodeAgent;
16
+ readonly #write: (line: string) => void;
17
+ #buffer = "";
18
+
19
+ constructor(agent: CodeAgent, write: (line: string) => void = (line) => process.stdout.write(line)) {
20
+ this.#agent = agent;
21
+ this.#write = write;
22
+ }
23
+
24
+ async serve(input: AsyncIterable<Uint8Array> = process.stdin) {
25
+ this.#agent.on((event) => this.#emit(event));
26
+ this.#agent.announce();
27
+ const decoder = new TextDecoder();
28
+ for await (const chunk of input) await this.#push(decoder.decode(chunk, { stream: true }));
29
+ await this.#agent.abort().catch(() => {});
30
+ }
31
+
32
+ async #push(text: string) {
33
+ this.#buffer += text;
34
+ let index = this.#buffer.indexOf("\n");
35
+ while (index >= 0) {
36
+ const line = this.#buffer.slice(0, index).trim();
37
+ this.#buffer = this.#buffer.slice(index + 1);
38
+ if (line) await this.#handle(line);
39
+ index = this.#buffer.indexOf("\n");
40
+ }
41
+ }
42
+
43
+ async #handle(line: string) {
44
+ let request: CodeAgentRequest;
45
+ try {
46
+ request = JSON.parse(line) as CodeAgentRequest;
47
+ } catch {
48
+ return this.#emit({ type: "error", message: "malformed command frame", fatal: false, seq: -1 });
49
+ }
50
+ try {
51
+ this.#reply({ type: "reply", id: request.id, ok: true, data: await this.#run(request.command) });
52
+ } catch (error) {
53
+ this.#reply({ type: "reply", id: request.id, ok: false, error: String(error) });
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `prompt` answers as soon as the turn is accepted, not when it finishes — a client that had to wait for the
59
+ * reply could not send `abort`, which is the one command that matters while a turn runs.
60
+ */
61
+ async #run(command: CodeAgentCommand): Promise<unknown> {
62
+ switch (command.type) {
63
+ case "prompt":
64
+ void this.#agent.prompt(command.message, command.images);
65
+ return { accepted: true };
66
+ case "answer":
67
+ return { handled: await this.#agent.answer(command.questionId, command.answer) };
68
+ case "approve":
69
+ return { handled: await this.#agent.approve(command.approvalId, command.approved) };
70
+ case "abort":
71
+ await this.#agent.abort();
72
+ return { aborted: true };
73
+ case "compact":
74
+ await this.#agent.compact(command.instructions);
75
+ return { compacted: true };
76
+ case "set_model":
77
+ await this.#agent.setModel({ provider: command.provider, id: command.modelId });
78
+ return await this.#agent.state();
79
+ case "get_state":
80
+ return await this.#agent.state(command.sinceSeq === undefined ? {} : { sinceSeq: command.sinceSeq });
81
+ case "shutdown":
82
+ this.#agent.dispose();
83
+ return { shutdown: true };
84
+ default:
85
+ throw new Error(`Unsupported command: ${command.type}`);
86
+ }
87
+ }
88
+
89
+ #emit(event: CodeAgentEvent) {
90
+ this.#write(`${JSON.stringify({ type: "event", event })}\n`);
91
+ }
92
+
93
+ #reply(reply: CodeAgentReply) {
94
+ this.#write(`${JSON.stringify(reply)}\n`);
95
+ }
96
+ }
@@ -0,0 +1,93 @@
1
+ import { type CodeAgentEvent, codeAgentEventLabel } from "akanjs/common";
2
+ import chalk from "chalk";
3
+
4
+ export interface CodeAgentStreamPrinterOptions {
5
+ /** One JSON object per line instead of prose, for a pipe that parses. */
6
+ json?: boolean;
7
+ thinking?: boolean;
8
+ write?: (text: string) => void;
9
+ }
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
+ export class CodeAgentStreamPrinter {
18
+ readonly #options: CodeAgentStreamPrinterOptions;
19
+ readonly #write: (text: string) => void;
20
+ #streaming = false;
21
+
22
+ constructor(options: CodeAgentStreamPrinterOptions = {}) {
23
+ this.#options = options;
24
+ this.#write = options.write ?? ((text) => process.stdout.write(text));
25
+ }
26
+
27
+ print(event: CodeAgentEvent) {
28
+ if (this.#options.json) return this.#write(`${JSON.stringify(event)}\n`);
29
+ switch (event.type) {
30
+ case "text_delta":
31
+ this.#streaming = true;
32
+ return this.#write(event.text);
33
+ case "thinking_delta":
34
+ if (!this.#options.thinking) return;
35
+ this.#streaming = true;
36
+ return this.#write(chalk.dim(event.text));
37
+ case "message":
38
+ return;
39
+ case "tool_start":
40
+ return this.#line(chalk.cyan(`→ ${event.tool.title}`));
41
+ case "tool_progress":
42
+ return;
43
+ case "tool_end":
44
+ return this.#line(CodeAgentStreamPrinter.#toolEnd(event));
45
+ case "session":
46
+ return this.#line(chalk.dim(codeAgentEventLabel(event)));
47
+ 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.
50
+ if (event.stopReason === "truncated") return this.#line(chalk.yellow(codeAgentEventLabel(event)));
51
+ return;
52
+ case "turn_start":
53
+ case "idle":
54
+ case "queue":
55
+ case "context":
56
+ return;
57
+ case "question":
58
+ return this.#line(chalk.yellow(`? ${event.question.prompt}`));
59
+ case "approval":
60
+ return this.#line(chalk.yellow(`approve? ${event.request.summary}`));
61
+ case "notice":
62
+ return this.#line(CodeAgentStreamPrinter.#notice(event.level, event.message));
63
+ case "error":
64
+ return this.#line(chalk.red(`error: ${event.message}`));
65
+ default:
66
+ return this.#line(chalk.dim(codeAgentEventLabel(event)));
67
+ }
68
+ }
69
+
70
+ /** Closes a half-written streamed line so the exit code and the shell prompt do not land mid-sentence. */
71
+ finish() {
72
+ if (this.#streaming) this.#write("\n");
73
+ this.#streaming = false;
74
+ }
75
+
76
+ #line(text: string) {
77
+ if (this.#streaming) this.#write("\n");
78
+ this.#streaming = false;
79
+ this.#write(`${text}\n`);
80
+ }
81
+
82
+ static #toolEnd(event: Extract<CodeAgentEvent, { type: "tool_end" }>) {
83
+ if (event.outcome === "ok") return chalk.green(`✓ ${event.tool.title}`);
84
+ if (event.outcome === "blocked") return chalk.yellow(`⦸ ${event.tool.title} — ${event.output}`);
85
+ return chalk.red(`✗ ${event.tool.title} — ${event.output.split("\n")[0] ?? ""}`);
86
+ }
87
+
88
+ static #notice(level: "info" | "warning" | "error", message: string) {
89
+ if (level === "error") return chalk.red(message);
90
+ if (level === "warning") return chalk.yellow(message);
91
+ return chalk.dim(message);
92
+ }
93
+ }
@@ -0,0 +1,52 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface CodeAgentUiHandlers {
4
+ ask(prompt: string, kind: "text" | "select" | "confirm", choices?: string[]): Promise<string | undefined>;
5
+ notify(level: "info" | "warning" | "error", message: string): void;
6
+ }
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
+ */
15
+ export class CodeAgentUi {
16
+ readonly #handlers: CodeAgentUiHandlers;
17
+
18
+ constructor(handlers: CodeAgentUiHandlers) {
19
+ this.#handlers = handlers;
20
+ }
21
+
22
+ context(): ExtensionUIContext {
23
+ const noop = () => {};
24
+ const ui = {
25
+ select: async (title: string, options: string[]) => await this.#handlers.ask(title, "select", options),
26
+ confirm: async (title: string, message: string) => {
27
+ const answer = await this.#handlers.ask(message ? `${title}: ${message}` : title, "confirm");
28
+ return answer === "yes" || answer === "true";
29
+ },
30
+ input: async (title: string) => await this.#handlers.ask(title, "text"),
31
+ notify: (message: string, type: "info" | "warning" | "error" = "info") => this.#handlers.notify(type, message),
32
+ editor: async (title: string, prefill?: string) => (await this.#handlers.ask(title, "text")) ?? prefill,
33
+ onTerminalInput: () => noop,
34
+ setStatus: noop,
35
+ setWorkingMessage: noop,
36
+ setWorkingVisible: noop,
37
+ setWorkingIndicator: noop,
38
+ setHiddenThinkingLabel: noop,
39
+ setWidget: noop,
40
+ setFooter: noop,
41
+ setHeader: noop,
42
+ setTitle: noop,
43
+ custom: async () => undefined,
44
+ pasteToEditor: noop,
45
+ setEditorText: noop,
46
+ getEditorText: () => "",
47
+ addAutocompleteProvider: noop,
48
+ setCustomEditorComponent: noop,
49
+ };
50
+ return ui as unknown as ExtensionUIContext;
51
+ }
52
+ }