@germanescobar/anita 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -200,6 +200,39 @@ Example stream:
200
200
  {"type":"run.completed","sessionId":"9e6f8a7d-7ff1-4c2c-b3d8-9c3ed5a1d4b7","status":"completed","stopReason":"end_turn","timestamp":"2026-04-09T15:00:01.000Z"}
201
201
  ```
202
202
 
203
+ #### Approvals in `--stream-json` mode
204
+
205
+ When a tool call requires approval (policy returns `ask`), the CLI emits two
206
+ extra events around the gate so consumers can audit decisions. They fire in
207
+ every mode — including `--auto-approve` and human TTY mode — so the stream
208
+ shape is uniform regardless of how the answer was produced.
209
+
210
+ ```json
211
+ {"type":"approval.request","id":"toolu_123","tool":"run_command","input":{"command":"npm test"},"timestamp":"…"}
212
+ {"type":"approval.resolved","id":"toolu_123","approved":true,"reason":"user","timestamp":"…"}
213
+ ```
214
+
215
+ `id` is the model's tool call id and matches the surrounding `tool.call` /
216
+ `tool.result` lifecycle. `input` is the structured object, never a pre-rendered
217
+ string. `reason` is one of:
218
+
219
+ - `user` — the responder answered.
220
+ - `aborted` — the run's `AbortSignal` fired while waiting.
221
+ - `eof` — the consumer closed stdin before answering.
222
+ - `error` — the approval callback itself threw.
223
+
224
+ When stdin is a pipe, the CLI never writes a prompt to stdout or stderr. The
225
+ consumer answers by writing a single JSON line to stdin:
226
+
227
+ ```json
228
+ {"type":"approval.response","id":"toolu_123","approved":true}
229
+ ```
230
+
231
+ The resolver waits silently on stdin. Mismatched `id`s, malformed JSON, and
232
+ unknown message types are discarded (logged to stderr) so a stale response
233
+ can't poison the run. There is exactly one approval pending at a time — the
234
+ executor runs tool calls sequentially.
235
+
203
236
  Without `--stream-json`, the CLI uses the normal human-readable terminal output.
204
237
 
205
238
  When using an OpenAI-compatible backend that exposes reasoning traces, Anita will also store them in the `assistant_response` event payload as `reasoning` and emit an `assistant.reasoning` stream event.
@@ -1,6 +1,4 @@
1
1
  import type { ApprovalMode, PolicyContext } from "./policies.js";
2
- import type { ConversationItem } from "../types/conversation.js";
3
- import type { Message } from "../types/messages.js";
4
2
  import type { Skill } from "../skills/skills.js";
5
3
  export type NetworkAccess = "available" | "unavailable" | "unknown";
6
4
  export interface RuntimeContextOptions {
@@ -19,11 +17,10 @@ export declare class ContextBuilder {
19
17
  private runtimeContext;
20
18
  constructor(workingDirectory: string, runtimeContext?: RuntimeContextOptions);
21
19
  buildSystemPrompt(): string;
20
+ appendRuntimeContext(baseSystemPrompt: string): Promise<string>;
22
21
  private buildUserSystemPromptSection;
23
22
  private buildEnvironmentFooter;
24
23
  buildDynamicContext(): Promise<string>;
25
- buildMessagesWithDynamicContext(messages: Message[]): Promise<Message[]>;
26
- buildItemsWithDynamicContext(items: ConversationItem[]): Promise<ConversationItem[]>;
27
24
  private buildEnvironmentContext;
28
25
  private buildPolicyContext;
29
26
  private describeApprovalMode;
@@ -1,7 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { formatSkillsForPrompt } from "../skills/skills.js";
3
3
  import { formatAgentInstructionsForPrompt, loadAgentInstructions, } from "./agents.js";
4
- const STATIC_SYSTEM_PROMPT = `You are Anita, a coding agent.
4
+ const STATIC_SYSTEM_PROMPT = `You are Anita, an extremely capable coding agent.
5
5
 
6
6
  You have these tools available:
7
7
  - read_file: Read file contents
@@ -11,13 +11,14 @@ You have these tools available:
11
11
  - run_command: Run a shell command
12
12
 
13
13
  Instructions:
14
- - Use tools to explore and understand the codebase before making changes
15
- - Prefer rg for repository text searches when available; fall back to grep only when rg is unavailable
16
- - Always read a file before editing it
17
- - Run tests or checks after making changes when appropriate
18
- - Follow the instructions of the user without jumping ahead.
19
- - Do not print or publish secrets, credentials, tokens, private keys, environment values, or other sensitive data
20
- - Explain what you are doing briefly`;
14
+ - Use tools to explore and understand the codebase before making changes.
15
+ - Prefer rg for repository text searches when available; fall back to grep only when rg is unavailable.
16
+ - Always read a file before editing it.
17
+ - Run tests or checks after making changes when appropriate.
18
+ - Do not print or publish secrets, credentials, tokens, private keys, environment values, or other sensitive data.
19
+ - Explain what you are doing briefly.
20
+ - Sometimes the most simple solution is the correct one. Try it first and ask the user before jumping ahead or going deeper. Don't spiral into rabbit holes.
21
+ - Always verify, don't guess.`;
21
22
  export class ContextBuilder {
22
23
  workingDirectory;
23
24
  runtimeContext;
@@ -33,11 +34,30 @@ export class ContextBuilder {
33
34
  repositoryRoot: this.runtimeContext.agentsRepositoryRoot,
34
35
  }));
35
36
  return (STATIC_SYSTEM_PROMPT +
37
+ skillsSection +
36
38
  agentInstructionsSection +
37
39
  this.buildUserSystemPromptSection() +
38
- skillsSection +
39
40
  this.buildEnvironmentFooter());
40
41
  }
42
+ /*
43
+ * Appends the dynamic runtime context to an already-built base system prompt.
44
+ *
45
+ * Runtime context (environment, policy, git state) belongs in the system
46
+ * prompt rather than as a separate user turn. Injecting it as a user message
47
+ * placed it adjacent to the real request, and weaker models conflated the two
48
+ * and treated the actual request as context.
49
+ *
50
+ * The caller passes the base prompt so it can be built once per run: rebuilding
51
+ * it each turn would reload AGENTS.md from disk and let a tool that writes
52
+ * AGENTS.md inject generated text as system instructions mid-run. Only the
53
+ * runtime context is refreshed here so git state stays current.
54
+ */
55
+ async appendRuntimeContext(baseSystemPrompt) {
56
+ const dynamicContext = await this.buildDynamicContext();
57
+ if (!dynamicContext)
58
+ return baseSystemPrompt;
59
+ return `${baseSystemPrompt}\n\n${dynamicContext}`;
60
+ }
41
61
  buildUserSystemPromptSection() {
42
62
  const systemPrompt = this.runtimeContext.systemPrompt?.trim();
43
63
  if (!systemPrompt)
@@ -63,7 +83,7 @@ export class ContextBuilder {
63
83
  const environmentContext = this.buildEnvironmentContext();
64
84
  const policyContext = this.buildPolicyContext();
65
85
  return [
66
- "Runtime context for the assistant. This is not a user request:",
86
+ "Runtime context (current environment state, refreshed each turn; not a user request):",
67
87
  environmentContext,
68
88
  policyContext,
69
89
  gitContext,
@@ -71,31 +91,6 @@ export class ContextBuilder {
71
91
  .filter(Boolean)
72
92
  .join("\n\n");
73
93
  }
74
- async buildMessagesWithDynamicContext(messages) {
75
- const dynamicContext = await this.buildDynamicContext();
76
- if (!dynamicContext)
77
- return messages;
78
- return [
79
- {
80
- role: "user",
81
- content: [{ type: "text", text: dynamicContext }],
82
- },
83
- ...messages,
84
- ];
85
- }
86
- async buildItemsWithDynamicContext(items) {
87
- const dynamicContext = await this.buildDynamicContext();
88
- if (!dynamicContext)
89
- return items;
90
- return [
91
- {
92
- type: "message",
93
- role: "user",
94
- content: dynamicContext,
95
- },
96
- ...items,
97
- ];
98
- }
99
94
  buildEnvironmentContext() {
100
95
  const shell = this.runtimeContext.shell ?? process.env.SHELL ?? "unknown";
101
96
  const approvalMode = this.runtimeContext.approvalMode ?? "prompt";
@@ -2,12 +2,31 @@ import type { ToolCall, ToolExecuteOptions, ToolResult } from "../types/tools.js
2
2
  import type { ToolRegistry } from "../tools/registry.js";
3
3
  import type { EventStore } from "../storage/event-store.js";
4
4
  import type { PolicyEngine } from "./policies.js";
5
- export type ApprovalCallback = (toolName: string, input: Record<string, unknown>) => Promise<boolean>;
5
+ import type { ApprovalAnswer, ApprovalCallback, ApprovalNotifier, ApprovalRequest } from "../types/approval.js";
6
+ export type { ApprovalAnswer, ApprovalCallback, ApprovalNotifier, ApprovalRequest, };
6
7
  export declare class Executor {
7
8
  private registry;
8
9
  private policyEngine;
9
10
  private eventStore;
10
11
  private approvalCallback;
11
- constructor(registry: ToolRegistry, policyEngine: PolicyEngine, eventStore: EventStore, approvalCallback: ApprovalCallback);
12
+ /**
13
+ * Optional notifier used to emit structured `approval.request` /
14
+ * `approval.resolved` stream events. Kept as an injected seam so the
15
+ * Executor does not depend on AgentLoop or stream-json mode.
16
+ */
17
+ private approvalNotifier?;
18
+ constructor(registry: ToolRegistry, policyEngine: PolicyEngine, eventStore: EventStore, approvalCallback: ApprovalCallback,
19
+ /**
20
+ * Optional notifier used to emit structured `approval.request` /
21
+ * `approval.resolved` stream events. Kept as an injected seam so the
22
+ * Executor does not depend on AgentLoop or stream-json mode.
23
+ */
24
+ approvalNotifier?: ApprovalNotifier | undefined);
25
+ /**
26
+ * Install (or replace) the approval notifier after construction. Used by
27
+ * `AgentLoop` to wire the executor into its own stream-json emitter without
28
+ * changing the public constructor order.
29
+ */
30
+ setApprovalNotifier(notifier: ApprovalNotifier | undefined): void;
12
31
  executeTool(sessionId: string, toolCall: ToolCall, options?: ToolExecuteOptions): Promise<ToolResult>;
13
32
  }
@@ -3,11 +3,27 @@ export class Executor {
3
3
  policyEngine;
4
4
  eventStore;
5
5
  approvalCallback;
6
- constructor(registry, policyEngine, eventStore, approvalCallback) {
6
+ approvalNotifier;
7
+ constructor(registry, policyEngine, eventStore, approvalCallback,
8
+ /**
9
+ * Optional notifier used to emit structured `approval.request` /
10
+ * `approval.resolved` stream events. Kept as an injected seam so the
11
+ * Executor does not depend on AgentLoop or stream-json mode.
12
+ */
13
+ approvalNotifier) {
7
14
  this.registry = registry;
8
15
  this.policyEngine = policyEngine;
9
16
  this.eventStore = eventStore;
10
17
  this.approvalCallback = approvalCallback;
18
+ this.approvalNotifier = approvalNotifier;
19
+ }
20
+ /**
21
+ * Install (or replace) the approval notifier after construction. Used by
22
+ * `AgentLoop` to wire the executor into its own stream-json emitter without
23
+ * changing the public constructor order.
24
+ */
25
+ setApprovalNotifier(notifier) {
26
+ this.approvalNotifier = notifier;
11
27
  }
12
28
  async executeTool(sessionId, toolCall, options) {
13
29
  const validationError = this.registry.validateInput(toolCall.name, toolCall.input);
@@ -39,7 +55,41 @@ export class Executor {
39
55
  };
40
56
  }
41
57
  if (decision === "ask") {
42
- const approved = await this.approvalCallback(toolCall.name, toolCall.input);
58
+ const request = {
59
+ toolCallId: toolCall.id,
60
+ toolName: toolCall.name,
61
+ input: toolCall.input,
62
+ };
63
+ // Emit the request before awaiting the responder so consumers see
64
+ // approval.request ahead of any tool.call/tool.result events for the
65
+ // same toolCallId.
66
+ this.approvalNotifier?.notifyApprovalRequest(request);
67
+ let answer;
68
+ try {
69
+ answer = await this.approvalCallback(request, options?.signal);
70
+ }
71
+ catch (err) {
72
+ this.approvalNotifier?.notifyApprovalResolved({
73
+ ...request,
74
+ approved: false,
75
+ reason: "error",
76
+ error: err instanceof Error ? err.message : String(err),
77
+ });
78
+ throw err;
79
+ }
80
+ const approved = typeof answer === "boolean" ? answer : answer.approved;
81
+ // Resolver may supply a reason (e.g. stdin EOF). Otherwise fall back
82
+ // to "aborted" when the surrounding signal is already aborted, else
83
+ // "user".
84
+ const responderReason = typeof answer === "boolean" ? undefined : answer.reason;
85
+ const reason = options?.signal?.aborted
86
+ ? "aborted"
87
+ : (responderReason ?? "user");
88
+ this.approvalNotifier?.notifyApprovalResolved({
89
+ ...request,
90
+ approved,
91
+ reason,
92
+ });
43
93
  if (!approved) {
44
94
  return {
45
95
  content: `Tool "${toolCall.name}" was denied by user.`,
@@ -6,6 +6,7 @@ import type { SessionState } from "../types/agent.js";
6
6
  import type { AttachmentContentBlock } from "../types/messages.js";
7
7
  import { ContextBuilder } from "./context-builder.js";
8
8
  import { Executor } from "./executor.js";
9
+ import type { ApprovalRequest, ApprovalResolvedEvent } from "../types/approval.js";
9
10
  export declare const DEFAULT_CONTEXT_BUDGET: ContextBudgetOptions;
10
11
  export interface ContextBudgetOptions {
11
12
  compactAtRatio: number;
@@ -25,6 +26,13 @@ export declare class AgentLoop {
25
26
  private contextBudget;
26
27
  private pendingTerminalDelta;
27
28
  constructor(provider: ModelProvider, executor: Executor, contextBuilder: ContextBuilder, registry: ToolRegistry, eventStore: EventStore, sessionStore: SessionStore, streamJson?: boolean, contextBudget?: ContextBudgetOptions);
29
+ /**
30
+ * ApprovalNotifier implementation: emits structured stream events around
31
+ * every approval gate so consumers can correlate the request with the
32
+ * matching tool.call / tool.result lifecycle.
33
+ */
34
+ notifyApprovalRequest(request: ApprovalRequest): void;
35
+ notifyApprovalResolved(event: ApprovalResolvedEvent): void;
28
36
  run(session: SessionState, userMessage: string, attachments?: AttachmentContentBlock[], signal?: AbortSignal): Promise<void>;
29
37
  /**
30
38
  * Record a coherent cancellation: persist the session at its last consistent
@@ -34,6 +34,36 @@ export class AgentLoop {
34
34
  this.sessionStore = sessionStore;
35
35
  this.streamJson = streamJson;
36
36
  this.contextBudget = contextBudget;
37
+ // Route executor approval events through this loop's stream emitter.
38
+ // Executor stays unaware of AgentLoop or stream-json mode via the small
39
+ // ApprovalNotifier seam. Tests that stub the executor may omit the
40
+ // setter; skip silently in that case.
41
+ if (typeof this.executor.setApprovalNotifier === "function") {
42
+ this.executor.setApprovalNotifier(this);
43
+ }
44
+ }
45
+ /**
46
+ * ApprovalNotifier implementation: emits structured stream events around
47
+ * every approval gate so consumers can correlate the request with the
48
+ * matching tool.call / tool.result lifecycle.
49
+ */
50
+ notifyApprovalRequest(request) {
51
+ this.emit({
52
+ type: "approval.request",
53
+ id: request.toolCallId,
54
+ tool: request.toolName,
55
+ input: request.input,
56
+ timestamp: new Date().toISOString(),
57
+ });
58
+ }
59
+ notifyApprovalResolved(event) {
60
+ this.emit({
61
+ type: "approval.resolved",
62
+ id: event.toolCallId,
63
+ approved: event.approved,
64
+ reason: event.reason,
65
+ timestamp: new Date().toISOString(),
66
+ });
37
67
  }
38
68
  async run(session, userMessage, attachments = [], signal) {
39
69
  try {
@@ -66,10 +96,17 @@ export class AgentLoop {
66
96
  })),
67
97
  });
68
98
  await this.saveSession(session);
69
- const systemPrompt = this.contextBuilder.buildSystemPrompt();
70
99
  const tools = this.registry.toSchemas();
100
+ // Build the base system prompt once per run. It loads AGENTS.md from disk,
101
+ // so rebuilding it each turn would let a tool that writes AGENTS.md inject
102
+ // freshly generated text as system instructions mid-run. Only the runtime
103
+ // context is refreshed per turn.
104
+ const baseSystemPrompt = this.contextBuilder.buildSystemPrompt();
71
105
  let finalStopReason = "max_iterations";
72
106
  let status = "max_iterations";
107
+ // Track the last logged system prompt so the model_request event is only
108
+ // written when the assembled prompt (including runtime context) changes.
109
+ let lastLoggedSystemPrompt;
73
110
  this.emit({
74
111
  type: "run.started",
75
112
  sessionId: session.id,
@@ -83,12 +120,19 @@ export class AgentLoop {
83
120
  return;
84
121
  }
85
122
  const modelContextItems = await this.buildModelContextItems(session);
86
- const conversationItems = await this.contextBuilder.buildItemsWithDynamicContext(modelContextItems);
123
+ const systemPrompt = await this.contextBuilder.appendRuntimeContext(baseSystemPrompt);
124
+ if (systemPrompt !== lastLoggedSystemPrompt) {
125
+ await this.eventStore.append(session.id, "model_request", {
126
+ systemPrompt,
127
+ messageCount: modelContextItems.length,
128
+ });
129
+ lastLoggedSystemPrompt = systemPrompt;
130
+ }
87
131
  const response = await this.getModelResponse({
88
132
  systemPrompt,
89
133
  sessionId: session.id,
90
- conversationItems,
91
- messages: conversationItemsToMessages(conversationItems),
134
+ conversationItems: modelContextItems,
135
+ messages: conversationItemsToMessages(modelContextItems),
92
136
  tools,
93
137
  signal,
94
138
  });
@@ -526,6 +570,24 @@ export class AgentLoop {
526
570
  console.log(event.isError ? chalk.red(` ✗ ${preview}`) : chalk.gray(` ${preview}`));
527
571
  return;
528
572
  }
573
+ case "approval.request": {
574
+ // The readline responder in `askApprovalOn` prints the actual prompt;
575
+ // emitting one here would show it twice in human mode. The structured
576
+ // `approval.resolved` event still prints the audit decision below so
577
+ // the user sees the outcome of every gate.
578
+ this.finishPendingTerminalDelta();
579
+ return;
580
+ }
581
+ case "approval.resolved": {
582
+ this.finishPendingTerminalDelta();
583
+ if (event.approved) {
584
+ console.log(chalk.gray(" approved"));
585
+ }
586
+ else {
587
+ console.log(chalk.red(` denied (${event.reason})`));
588
+ }
589
+ return;
590
+ }
529
591
  }
530
592
  }
531
593
  finishPendingTerminalDelta() {
@@ -1,5 +1,53 @@
1
+ import readline from "node:readline";
1
2
  import { Command } from "commander";
3
+ import type { ApprovalAnswer, ApprovalRequest } from "../types/approval.js";
2
4
  import { type ModelOption, type ModelOptionsResult } from "../models/resolve.js";
3
5
  export declare function formatModelOptions(options?: readonly ModelOption[]): string;
4
6
  export declare function formatModelOptionsJson(result: ModelOptionsResult): string;
7
+ /**
8
+ * Builds an approval responder suited to the current CLI mode.
9
+ *
10
+ * Selection rules (matches the issue #75 spec):
11
+ * - `--auto-approve` (with or without `--stream-json`): never prompts the
12
+ * user. Always resolves true so the Executor emits the audit events but
13
+ * the user is never interrupted.
14
+ * - `--stream-json` with a piped stdin (no TTY): the stdin line protocol
15
+ * responder. It reads JSON lines like
16
+ * `{"type":"approval.response","id":"<toolCallId>","approved":<bool>}`
17
+ * from stdin and never writes a prompt to stdout/stderr.
18
+ * - Otherwise (no flags or `--stream-json` with a TTY stdin): the readline
19
+ * prompt, identical to today's behavior.
20
+ */
21
+ export declare function createApprovalResponder(options?: {
22
+ autoApprove?: boolean;
23
+ streamJson?: boolean;
24
+ }): (request: ApprovalRequest, signal?: AbortSignal) => Promise<ApprovalAnswer>;
25
+ export declare function askApprovalOn(streams: {
26
+ input: NodeJS.ReadableStream;
27
+ output: NodeJS.WritableStream;
28
+ }, request: ApprovalRequest, signal?: AbortSignal, hooks?: {
29
+ onReadline?: (rl: readline.Interface) => void;
30
+ }): Promise<boolean>;
31
+ /**
32
+ * Stdin line protocol responder used when `--stream-json` is active and
33
+ * stdin is not a TTY. Reads JSON lines from stdin and resolves the pending
34
+ * approval when a matching response arrives. See issue #75 §4 for the
35
+ * protocol and the mismatch / malformed / EOF / abort cases.
36
+ *
37
+ * Invariant: at most one approval is pending at a time (Executor runs tool
38
+ * calls sequentially). The responder therefore buffers exactly one pending
39
+ * request and discards responses received while no request is in flight.
40
+ */
41
+ export declare function createStdinApprovalResponder(streams: {
42
+ input: NodeJS.ReadableStream;
43
+ stderr?: NodeJS.WritableStream;
44
+ }): ((request: ApprovalRequest, signal?: AbortSignal) => Promise<ApprovalAnswer>) & {
45
+ /**
46
+ * Detach the readline interface from stdin. The CLI calls this in a
47
+ * `finally` after the run completes so a long-lived orchestrator piping
48
+ * JSON events on stdout and approval responses on stdin does not hang.
49
+ * Idempotent and safe to call multiple times.
50
+ */
51
+ close(): void;
52
+ };
5
53
  export declare function createCLI(): Command;
package/dist/cli/index.js CHANGED
@@ -90,17 +90,170 @@ async function createAgentsFile(filePath, force) {
90
90
  function isAlreadyExistsError(err) {
91
91
  return err instanceof Error && "code" in err && err.code === "EEXIST";
92
92
  }
93
- async function askApproval(toolName, input) {
93
+ function askApproval(request, signal) {
94
+ return askApprovalOn({ input: process.stdin, output: process.stdout }, request, signal);
95
+ }
96
+ /**
97
+ * Builds an approval responder suited to the current CLI mode.
98
+ *
99
+ * Selection rules (matches the issue #75 spec):
100
+ * - `--auto-approve` (with or without `--stream-json`): never prompts the
101
+ * user. Always resolves true so the Executor emits the audit events but
102
+ * the user is never interrupted.
103
+ * - `--stream-json` with a piped stdin (no TTY): the stdin line protocol
104
+ * responder. It reads JSON lines like
105
+ * `{"type":"approval.response","id":"<toolCallId>","approved":<bool>}`
106
+ * from stdin and never writes a prompt to stdout/stderr.
107
+ * - Otherwise (no flags or `--stream-json` with a TTY stdin): the readline
108
+ * prompt, identical to today's behavior.
109
+ */
110
+ export function createApprovalResponder(options = {}) {
111
+ if (options.autoApprove) {
112
+ return async () => true;
113
+ }
114
+ if (options.streamJson && !process.stdin.isTTY) {
115
+ return createStdinApprovalResponder({
116
+ input: process.stdin,
117
+ stderr: process.stderr,
118
+ });
119
+ }
120
+ return askApproval;
121
+ }
122
+ export async function askApprovalOn(streams, request, signal, hooks) {
94
123
  const rl = readline.createInterface({
95
- input: process.stdin,
96
- output: process.stdout,
124
+ input: streams.input,
125
+ output: streams.output,
97
126
  });
98
- const summary = toolName === "run_command" ? input.command : JSON.stringify(input);
99
- const answer = await new Promise((resolve) => {
100
- rl.question(chalk.yellow(`Allow ${toolName}: ${summary}? [y/n] `), resolve);
127
+ hooks?.onReadline?.(rl);
128
+ const summary = request.toolName === "run_command"
129
+ ? request.input.command ?? JSON.stringify(request.input)
130
+ : JSON.stringify(request.input);
131
+ return new Promise((resolve) => {
132
+ let settled = false;
133
+ const settle = (value) => {
134
+ if (settled)
135
+ return;
136
+ settled = true;
137
+ signal?.removeEventListener("abort", onAbort);
138
+ rl.removeListener("SIGINT", onRlSigint);
139
+ rl.removeListener("close", onRlClose);
140
+ rl.close();
141
+ resolve(value);
142
+ };
143
+ const onAbort = () => settle(false);
144
+ // Readline captures Ctrl-C on a TTY and pauses stdin instead of letting
145
+ // the process-level SIGINT handler fire. Listen for the interface's own
146
+ // SIGINT event so a first Ctrl-C still cancels the prompt.
147
+ const onRlSigint = () => settle(false);
148
+ const onRlClose = () => settle(false);
149
+ if (signal?.aborted) {
150
+ settle(false);
151
+ return;
152
+ }
153
+ signal?.addEventListener("abort", onAbort, { once: true });
154
+ rl.on("SIGINT", onRlSigint);
155
+ rl.on("close", onRlClose);
156
+ rl.question(chalk.yellow(`Allow ${request.toolName}: ${summary}? [y/n] `), (answer) => settle(answer.toLowerCase().startsWith("y")));
101
157
  });
102
- rl.close();
103
- return answer.toLowerCase().startsWith("y");
158
+ }
159
+ /**
160
+ * Stdin line protocol responder used when `--stream-json` is active and
161
+ * stdin is not a TTY. Reads JSON lines from stdin and resolves the pending
162
+ * approval when a matching response arrives. See issue #75 §4 for the
163
+ * protocol and the mismatch / malformed / EOF / abort cases.
164
+ *
165
+ * Invariant: at most one approval is pending at a time (Executor runs tool
166
+ * calls sequentially). The responder therefore buffers exactly one pending
167
+ * request and discards responses received while no request is in flight.
168
+ */
169
+ export function createStdinApprovalResponder(streams) {
170
+ let pending;
171
+ let closed = false;
172
+ const onLine = (line) => {
173
+ const trimmed = line.trim();
174
+ if (!trimmed)
175
+ return;
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(trimmed);
179
+ }
180
+ catch (err) {
181
+ // Malformed JSON: log to stderr, discard, do not resolve.
182
+ streams.stderr?.write(`anita: discarded malformed approval.response line (${err.message})\n`);
183
+ return;
184
+ }
185
+ if (!parsed ||
186
+ typeof parsed !== "object" ||
187
+ parsed.type !== "approval.response") {
188
+ return;
189
+ }
190
+ const { id, approved } = parsed;
191
+ if (typeof id !== "string" || typeof approved !== "boolean") {
192
+ streams.stderr?.write("anita: discarded approval.response with invalid id/approved\n");
193
+ return;
194
+ }
195
+ if (!pending) {
196
+ streams.stderr?.write("anita: discarded approval.response with no pending request\n");
197
+ return;
198
+ }
199
+ if (pending.request.toolCallId !== id) {
200
+ // Desync recovery: don't poison the run with a stale response.
201
+ streams.stderr?.write(`anita: discarded approval.response for unknown id ${id} (waiting on ${pending.request.toolCallId})\n`);
202
+ return;
203
+ }
204
+ const current = pending;
205
+ pending = undefined;
206
+ current.resolve(approved);
207
+ };
208
+ const onEnd = () => {
209
+ closed = true;
210
+ if (pending) {
211
+ const current = pending;
212
+ pending = undefined;
213
+ // EOF while a request is in flight must surface as `reason: "eof"` so
214
+ // consumers can distinguish "user said no" from "consumer closed the
215
+ // pipe mid-run" (issue #75 §4).
216
+ current.resolve({ approved: false, reason: "eof" });
217
+ }
218
+ };
219
+ const rl = readline.createInterface({ input: streams.input, crlfDelay: Infinity });
220
+ rl.on("line", onLine);
221
+ rl.on("close", onEnd);
222
+ // Returns a function that disposes the stdin reader. The chat command calls
223
+ // this from a `finally` block so a long-lived orchestrator piping JSON
224
+ // events on stdout and approval responses on stdin does not hang after
225
+ // `loop.run` returns. The responder is single-use: after close, further
226
+ // calls short-circuit to a structured EOF answer.
227
+ function close() {
228
+ if (closed)
229
+ return;
230
+ closed = true;
231
+ rl.close();
232
+ if (pending) {
233
+ const current = pending;
234
+ pending = undefined;
235
+ current.resolve({ approved: false, reason: "eof" });
236
+ }
237
+ }
238
+ const answer = async (request, signal) => {
239
+ if (closed)
240
+ return { approved: false, reason: "eof" };
241
+ return new Promise((resolve) => {
242
+ pending = { request, resolve };
243
+ const settle = (value) => {
244
+ if (pending && pending.request.toolCallId === request.toolCallId) {
245
+ pending = undefined;
246
+ resolve(value);
247
+ }
248
+ };
249
+ if (signal?.aborted) {
250
+ settle({ approved: false, reason: "eof" });
251
+ return;
252
+ }
253
+ signal?.addEventListener("abort", () => settle(false), { once: true });
254
+ });
255
+ };
256
+ return Object.assign(answer, { close });
104
257
  }
105
258
  export function createCLI() {
106
259
  const program = new Command();
@@ -217,7 +370,10 @@ export function createCLI() {
217
370
  skills,
218
371
  systemPrompt,
219
372
  });
220
- const approvalFn = autoApprove ? async () => true : askApproval;
373
+ const approvalFn = createApprovalResponder({
374
+ autoApprove,
375
+ streamJson,
376
+ });
221
377
  const executor = new Executor(registry, policyEngine, eventStore, approvalFn);
222
378
  const loop = new AgentLoop(provider, executor, contextBuilder, registry, eventStore, sessionStore, streamJson);
223
379
  if (!streamJson) {
@@ -262,6 +418,14 @@ export function createCLI() {
262
418
  }
263
419
  finally {
264
420
  process.off("SIGINT", onSigint);
421
+ // Detach any stdin readline the responder opened so the process can
422
+ // exit naturally instead of waiting for stdin to close. The
423
+ // auto-approve and readline responders return plain async functions
424
+ // without a `close` method; only the stdin line-protocol responder
425
+ // exposes one.
426
+ const close = approvalFn.close;
427
+ if (typeof close === "function")
428
+ close();
265
429
  }
266
430
  if (interrupted) {
267
431
  process.exit(130);
@@ -1,5 +1,5 @@
1
1
  import type { ModelProvider } from "./provider.js";
2
- export declare const OLLAMA_CLOUD_MODELS: readonly ["glm-5.2", "minimax-m3", "deepseek-v4-pro", "kimi-k2.7-code"];
2
+ export declare const OLLAMA_CLOUD_MODELS: string[];
3
3
  export type ModelOptionGroupName = "Ollama Local" | "Ollama Cloud" | "OpenRouter";
4
4
  export interface ModelOption {
5
5
  label: string;
@@ -12,64 +12,72 @@ const DEEPSEEK_V4_PRO_CONTEXT_WINDOW_TOKENS = 1_000_000;
12
12
  const KIMI_K2_CONTEXT_WINDOW_TOKENS = 256_000;
13
13
  const MINIMAX_M3_OLLAMA_CONTEXT_WINDOW_TOKENS = 512_000;
14
14
  const MINIMAX_M3_OPENROUTER_CONTEXT_WINDOW_TOKENS = 1_000_000;
15
- export const OLLAMA_CLOUD_MODELS = [
16
- "glm-5.2",
17
- "minimax-m3",
18
- "deepseek-v4-pro",
19
- "kimi-k2.7-code",
15
+ const OLLAMA_CLOUD_MODEL_SPECS = [
16
+ {
17
+ slug: "glm-5.2",
18
+ label: "GLM 5.2",
19
+ contextWindowTokens: GLM_5_2_CONTEXT_WINDOW_TOKENS,
20
+ },
21
+ {
22
+ slug: "minimax-m3",
23
+ label: "MiniMax M3",
24
+ contextWindowTokens: MINIMAX_M3_OLLAMA_CONTEXT_WINDOW_TOKENS,
25
+ capabilities: { attachments: { images: true, files: true } },
26
+ },
27
+ {
28
+ slug: "deepseek-v4-pro",
29
+ label: "DeepSeek V4 Pro",
30
+ contextWindowTokens: DEEPSEEK_V4_PRO_CONTEXT_WINDOW_TOKENS,
31
+ },
32
+ {
33
+ slug: "kimi-k2.7-code",
34
+ label: "Kimi K2.7 Code",
35
+ contextWindowTokens: KIMI_K2_CONTEXT_WINDOW_TOKENS,
36
+ capabilities: { attachments: { images: true, files: false } },
37
+ },
20
38
  ];
21
- const OLLAMA_CLOUD_CONTEXT_WINDOWS = {
22
- "glm-5.2": GLM_5_2_CONTEXT_WINDOW_TOKENS,
23
- "minimax-m3": MINIMAX_M3_OLLAMA_CONTEXT_WINDOW_TOKENS,
24
- "deepseek-v4-pro": DEEPSEEK_V4_PRO_CONTEXT_WINDOW_TOKENS,
25
- "kimi-k2.7-code": KIMI_K2_CONTEXT_WINDOW_TOKENS,
26
- };
39
+ export const OLLAMA_CLOUD_MODELS = OLLAMA_CLOUD_MODEL_SPECS.map((spec) => spec.slug);
27
40
  export const MODEL_OPTIONS = [
28
41
  {
29
- label: "GLM 4.7 Flash (local)",
42
+ label: "GLM 4.7 Flash",
30
43
  value: "ollama/glm-4.7-flash:latest",
31
44
  group: "Ollama Local",
32
45
  contextWindowTokens: GLM_CONTEXT_WINDOW_TOKENS,
33
46
  },
34
47
  {
35
- label: "GLM 5.1 (OpenRouter)",
48
+ label: "GLM 5.1",
36
49
  value: "openrouter/z-ai/glm-5.1",
37
50
  group: "OpenRouter",
38
51
  contextWindowTokens: GLM_CONTEXT_WINDOW_TOKENS,
39
52
  capabilities: { attachments: { images: false, files: true } },
40
53
  },
41
54
  {
42
- label: "MiniMax M3 (OpenRouter)",
55
+ label: "MiniMax M3",
43
56
  value: "openrouter/minimax/minimax-m3",
44
57
  group: "OpenRouter",
45
58
  contextWindowTokens: MINIMAX_M3_OPENROUTER_CONTEXT_WINDOW_TOKENS,
46
59
  capabilities: { attachments: { images: true, files: true } },
47
60
  },
48
61
  {
49
- label: "DeepSeek V4 Pro (OpenRouter)",
62
+ label: "DeepSeek V4 Pro",
50
63
  value: "openrouter/deepseek/deepseek-v4-pro",
51
64
  group: "OpenRouter",
52
65
  contextWindowTokens: DEEPSEEK_V4_PRO_CONTEXT_WINDOW_TOKENS,
53
66
  capabilities: { attachments: { images: false, files: true } },
54
67
  },
55
68
  {
56
- label: "Kimi K2.6 (OpenRouter)",
69
+ label: "Kimi K2.6",
57
70
  value: "openrouter/moonshotai/kimi-k2.6",
58
71
  group: "OpenRouter",
59
72
  contextWindowTokens: KIMI_K2_CONTEXT_WINDOW_TOKENS,
60
73
  capabilities: { attachments: { images: true, files: true } },
61
74
  },
62
- ...OLLAMA_CLOUD_MODELS.map((model) => ({
63
- label: `${model} (cloud)`,
64
- value: `ollama-cloud/${model}`,
75
+ ...OLLAMA_CLOUD_MODEL_SPECS.map((spec) => ({
76
+ label: spec.label,
77
+ value: `ollama-cloud/${spec.slug}`,
65
78
  group: "Ollama Cloud",
66
- contextWindowTokens: OLLAMA_CLOUD_CONTEXT_WINDOWS[model],
67
- ...(model === "minimax-m3"
68
- ? { capabilities: { attachments: { images: true, files: true } } }
69
- : {}),
70
- ...(model === "kimi-k2.7-code"
71
- ? { capabilities: { attachments: { images: true, files: false } } }
72
- : {}),
79
+ contextWindowTokens: spec.contextWindowTokens,
80
+ ...(spec.capabilities ? { capabilities: spec.capabilities } : {}),
73
81
  })),
74
82
  ];
75
83
  export function parseModelString(modelString) {
@@ -112,7 +120,7 @@ export async function discoverLocalOllamaModelOptions(fetchImpl = fetch) {
112
120
  return undefined;
113
121
  const payload = await response.json();
114
122
  return parseOllamaModelNames(payload).map((name) => ({
115
- label: `${name} (local)`,
123
+ label: name,
116
124
  value: `ollama/${name}`,
117
125
  group: "Ollama Local",
118
126
  contextWindowTokens: UNKNOWN_MODEL_CONTEXT_WINDOW_TOKENS,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared types for the approval seam between Executor and consumers.
3
+ *
4
+ * The `Executor` exposes a single approval callback. To make stream-json +
5
+ * approvals work, the callback now receives a structured request (with the
6
+ * model's tool call id) and a small notifier seam emits structured events
7
+ * around every approval gate so consumers can audit decisions.
8
+ */
9
+ export interface ApprovalRequest {
10
+ /** The model's tool call id; matches `tool.call.id` / `tool.result.id`. */
11
+ toolCallId: string;
12
+ toolName: string;
13
+ input: Record<string, unknown>;
14
+ }
15
+ /**
16
+ * Reason a pending approval was settled. Echoed in `approval.resolved`.
17
+ *
18
+ * - `user`: the user/consumer answered the prompt.
19
+ * - `aborted`: the run's AbortSignal fired while waiting for the answer.
20
+ * - `eof`: the consumer closed stdin before responding (stdin line protocol).
21
+ * - `error`: the approval callback itself threw.
22
+ */
23
+ export type ApprovalResolvedReason = "user" | "aborted" | "eof" | "error";
24
+ /**
25
+ * What a resolver hands back to the executor. A plain boolean still works
26
+ * (treated as `{ approved: <value>, reason: "user" }`) for callers that
27
+ * don't care about distinguishing EOF/aborted; responders that need to
28
+ * signal "stdin closed mid-run" return `{ approved: false, reason: "eof" }`.
29
+ */
30
+ export type ApprovalAnswer = boolean | {
31
+ approved: boolean;
32
+ reason?: "user" | "eof";
33
+ };
34
+ export interface ApprovalResolvedEvent {
35
+ toolCallId: string;
36
+ toolName: string;
37
+ approved: boolean;
38
+ reason: ApprovalResolvedReason;
39
+ /** Optional human-readable detail for the `error` reason. */
40
+ error?: string;
41
+ }
42
+ /**
43
+ * Resolves the pending approval request. Implementations live in the CLI
44
+ * (readline, stdin line protocol, or always-true under --auto-approve).
45
+ */
46
+ export type ApprovalCallback = (request: ApprovalRequest, signal?: AbortSignal) => Promise<ApprovalAnswer>;
47
+ /**
48
+ * Small injected seam so `Executor` can emit stream-json approval events
49
+ * without depending on `AgentLoop`. The CLI's `AgentLoop` implements this by
50
+ * calling its own `emit(...)` with `approval.request` / `approval.resolved`.
51
+ */
52
+ export interface ApprovalNotifier {
53
+ notifyApprovalRequest(request: ApprovalRequest): void;
54
+ notifyApprovalResolved(event: ApprovalResolvedEvent): void;
55
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Shared types for the approval seam between Executor and consumers.
3
+ *
4
+ * The `Executor` exposes a single approval callback. To make stream-json +
5
+ * approvals work, the callback now receives a structured request (with the
6
+ * model's tool call id) and a small notifier seam emits structured events
7
+ * around every approval gate so consumers can audit decisions.
8
+ */
9
+ export {};
@@ -1,4 +1,4 @@
1
- export type EventType = "session_start" | "user_message" | "assistant_reasoning" | "assistant_response" | "conversation_compaction" | "tool_call" | "tool_result" | "policy_decision" | "error" | "run_cancelled" | "session_end" | "session_archived" | "skills_loaded";
1
+ export type EventType = "session_start" | "user_message" | "model_request" | "assistant_reasoning" | "assistant_response" | "conversation_compaction" | "tool_call" | "tool_result" | "policy_decision" | "error" | "run_cancelled" | "session_end" | "session_archived" | "skills_loaded";
2
2
  export interface AgentEvent {
3
3
  id: string;
4
4
  sessionId: string;
@@ -36,6 +36,18 @@ export type StreamEvent = {
36
36
  content: string;
37
37
  isError: boolean;
38
38
  metadata?: Record<string, ToolResultMetadata>;
39
+ } | {
40
+ type: "approval.request";
41
+ id: string;
42
+ tool: string;
43
+ input: Record<string, unknown>;
44
+ timestamp: string;
45
+ } | {
46
+ type: "approval.resolved";
47
+ id: string;
48
+ approved: boolean;
49
+ reason: "user" | "aborted" | "eof" | "error";
50
+ timestamp: string;
39
51
  } | {
40
52
  type: "run.completed";
41
53
  sessionId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@germanescobar/anita",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "An intelligent AI-powered coding agent that helps you write, edit, and run code with conversational access to AI models and your file system.",
5
5
  "type": "module",
6
6
  "files": [