@nanobpm/nano-coder 0.1.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 ADDED
@@ -0,0 +1,547 @@
1
+ # nano-coder
2
+
3
+ **A 6MB coding agent. Run a fleet on your laptop.**
4
+
5
+ nano-coder is a coding agent for the terminal, written in Rust. It uses about 6MB of resident memory, where Node-based agent CLIs take 150–660MB, so you can run dense fleets of agent workers on one machine. It runs interactively, or headless over ACP as a worker for [nano-workforce](https://github.com/nanobpm/nano-workforce) via c8ctl-nano.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install -g @nanobpm/nano-coder # prebuilt binaries for macOS and Linux (x64, arm64)
11
+ cargo install nano-coder # or build from source
12
+ ```
13
+
14
+ ## Features
15
+
16
+ - **Interactive CLI**: REPL-based interface for conversing with the agent
17
+ - **ACP Protocol**: JSON-RPC 2.0 over stdio for headless orchestration (c8ctl-nano compatible)
18
+ - **Providers**: OpenAI-compatible and Anthropic endpoints (remote or local), selected per model as `provider/model`, with retry/backoff
19
+ - **Tool Calling**: Agent can invoke registered tools during conversation, including a real `bash` tool with timeouts and bounded output
20
+ - **Sessions**: Append-only JSONL session logs with resume and input-ID deduplication
21
+ - **Lifecycle Hooks**: 6 hook events for observing/intercepting agent behavior
22
+ - **Configuration**: TOML-based config file at `~/.config/nano-coder/config.toml`
23
+ - **Commands**: `/help`, `/compact`, `/context`, `/verbosity`, `/settings`, `/tools`, `/exit`
24
+ - **Streaming output**: answers stream in, thinking shows collapsed (Ctrl-O expands it), tool calls show inline
25
+ - **Status line** pinned to the bottom of the terminal, plus manual and automatic context compaction
26
+ - **Task plans**: `plan_*` tools keep a plan with notes outside the conversation, so long tasks survive compaction, resume and a change of worker
27
+ - **Project instructions**: `AGENTS.md` (or `CLAUDE.md`, `.github/copilot-instructions.md`) from the repository is added to the system prompt
28
+
29
+ ## Two Execution Modes
30
+
31
+ ### Interactive CLI Mode (default)
32
+
33
+ ```bash
34
+ cargo run
35
+ ```
36
+
37
+ Starts the interactive REPL where you can chat with the agent and use slash commands.
38
+
39
+ While a turn is running you can type a message and press Enter to **steer** it: the
40
+ message joins the conversation before the next model call (even if the model had
41
+ just produced its final answer, the turn continues with the steer). **Esc Esc** (twice within a second) or **Ctrl-C** cancels
42
+ the running turn, killing any running bash command; a second Ctrl-C at the prompt exits.
43
+ With piped (non-terminal) stdin, lines read during a turn are queued as later prompts.
44
+
45
+ ### ACP Headless Mode (--acp flag)
46
+
47
+ ```bash
48
+ cargo run -- --acp
49
+ ```
50
+
51
+ Speaks the Agent Communication Protocol (ACP) over stdio using newline-delimited JSON-RPC 2.0 messages. Compatible with c8ctl-nano's `spawnCaptureAcp` executor.
52
+
53
+ **Protocol methods supported:**
54
+ - `initialize` → returns protocol version and capabilities (`loadSession` when persistence is on)
55
+ - `session/new` → starts a fresh conversation (and session log), returns sessionId.
56
+ `params.cwd` (absolute, existing directory) becomes the working directory for tools
57
+ - `session/load` → `{ "sessionId": ... }` resumes a persisted session, first replaying the
58
+ conversation as `session/update` notifications (as the ACP spec requires)
59
+ - `session/prompt` → processes a prompt, supports tool calls. One session is active per
60
+ process; a `sessionId` other than the active one is rejected (use `session/load` to switch). An optional `messageId`
61
+ (or `_meta.inputId`) makes it idempotent: redelivering an ID that already completed
62
+ returns the recorded response without calling the model again
63
+ - `session/cancel` → cancels the current turn (normally sent as a notification, which
64
+ gets no reply). The running model call is abandoned, a running bash command is
65
+ killed, remaining tool calls are recorded as cancelled, and the turn's
66
+ `session/prompt` resolves with `stopReason: "cancelled"`
67
+
68
+ **Steering.** A `session/prompt` for the active session that arrives while a turn is
69
+ running is a steer, not a queued prompt. It is added as a user message before the next
70
+ model call (echoed as a `user_message_chunk`) and answered when the turn ends with
71
+ `{ "stopReason": ..., "_meta": { "steered": true, "inputOf": <main prompt id> } }`.
72
+ A steer that arrives too late to join the turn runs as an ordinary prompt, or is
73
+ answered with `stopReason: "cancelled"` if the turn was cancelled. Slash-command prompts
74
+ and other requests that arrive mid-turn are handled after the turn ends. Everything
75
+ handled after the turn, late steers included, runs in the order the client sent it.
76
+ `stopReason` is `end_turn`, `cancelled`, or `max_turn_requests`.
77
+
78
+ **Streaming updates.** During a turn the harness sends `session/update` notifications:
79
+ `agent_message_chunk` (with a `messageId`), `tool_call` (`toolCallId`, `title` = tool name,
80
+ `kind`, `rawInput`) and `tool_call_update` (`completed`/`failed`, `rawOutput`). These are the
81
+ events c8ctl-nano's transcript producer records into the engine's AgentInstance history.
82
+
83
+ **Project instructions.** `session/new` and `session/load` results include
84
+ `_meta.projectInstructions`: the absolute paths of the instruction files that were added to
85
+ the system prompt for the session's `cwd` (see [Project Instructions](#project-instructions)).
86
+
87
+ **Plans.** Each plan change sends a `plan` update: ACP `entries` (`content`, `priority`,
88
+ `status`) plus `_meta.plan`, the full plan with ids, notes and dependencies. To continue a job
89
+ on another worker, pass the last `_meta.plan` as `session/new` `params._meta.plan` (bare ACP
90
+ `entries` are accepted too). The new session starts with that plan, the `session/new` result
91
+ echoes it in `_meta.plan`, and the model is told about it with the first prompt (unless
92
+ `_meta.planInPrompt: true` says the client already put the plan in the prompt). `initialize`
93
+ advertises this as `agentCapabilities._meta.planSeed`. See [Task Plans](#task-plans).
94
+
95
+ **Outcomes.** When the model calls `report_outcome`, the `session/prompt` result carries
96
+ `_meta.outcome`: `{"status": "completed" | "blocked", "summary": "..."}`. A client can use it
97
+ instead of guessing from the stop reason: `blocked` means the model needs help (an
98
+ escalation). Redelivering the input returns the same outcome. See [Outcomes](#outcomes).
99
+
100
+ **Slash commands work via ACP too:**
101
+ - `/compact [focus]` - summarizes the conversation; the result has `compacted`, `before`,
102
+ `after`, `tokensBefore`, `tokensAfter`, `summarized` and `fallback`
103
+ - `/settings` - returns current settings as JSON
104
+ - `/tools` - lists registered tools
105
+ - `/plan` - returns `plan` (JSON) and `text` (the rendered plan)
106
+ - `/providers` - lists providers
107
+ - `/model provider/model` - switches model
108
+
109
+ ## Architecture
110
+
111
+ ```
112
+ src/
113
+ ├── main.rs # Single binary entry point (interactive + ACP modes)
114
+ ├── agent.rs # Agent core: conversation management, tool execution loop
115
+ ├── acp.rs # ACP JSON-RPC protocol handler
116
+ ├── hooks.rs # Lifecycle hook registry and event system
117
+ ├── tools.rs # Tool registration and dispatch system
118
+ ├── llm.rs # Provider-neutral messages and the async LLMClient trait
119
+ ├── providers/ # Provider registry + presets, HTTP transport with retries
120
+ │ ├── openai.rs # OpenAI Chat Completions (and compatible servers)
121
+ │ ├── anthropic.rs # Anthropic Messages API
122
+ │ ├── github_copilot.rs # UNOFFICIAL Copilot-subscription provider
123
+ │ ├── retry.rs # Retry classification and backoff
124
+ │ └── mock.rs # Offline scripted client
125
+ ├── bash.rs # bash tool: timeout, file capture, bounded output
126
+ ├── files.rs # read_file / write_file / edit_file tools
127
+ ├── output.rs # Head/tail output bounding, spilling long output to disk
128
+ ├── session.rs # Versioned append-only JSONL session log
129
+ ├── context.rs # Token accounting, context-window heuristics, overflow detection
130
+ ├── status.rs # Bottom-of-terminal status line
131
+ ├── ui.rs # Verbosity levels and the streaming output renderer
132
+ ├── lineedit.rs # Key-by-key prompt input (Ctrl-O, steering on the status line)
133
+ ├── instructions.rs # AGENTS.md / CLAUDE.md discovery for the system prompt
134
+ ├── plan.rs # Task plan and the plan_add / plan_update / plan_show tools
135
+ ├── goal.rs # report_outcome tool (completed / blocked)
136
+ ├── reminders.rs # <system-reminder> notes appended to tool results
137
+ ├── settings.rs # /settings menu and config-file writer
138
+ └── config.rs # Configuration file loading and management
139
+ ```
140
+
141
+ Mode selection: `--acp` flag enables ACP headless mode; default is interactive CLI.
142
+
143
+ ## Lifecycle Hooks
144
+
145
+ The harness exposes 6 lifecycle hook events:
146
+
147
+ | Hook | When it fires |
148
+ |------|---------------|
149
+ | `before_context_load` | Before processing user input |
150
+ | `after_context_load` | After adding user message to conversation |
151
+ | `before_llm_send` | Before sending messages to LLM |
152
+ | `after_llm_response` | After receiving LLM response |
153
+ | `before_tool_call` | Before executing a tool |
154
+ | `after_tool_call` | After tool execution completes |
155
+
156
+ ## Built-in Tools
157
+
158
+ - `get_time` - Get current date and time
159
+ - `echo` - Echo back input text
160
+ - `bash` - Run `bash -c <command>` (stdin closed, own process group). Arguments:
161
+ `command`, optional `timeout_seconds` (default `bash_timeout_secs`, 600) and
162
+ `max_output_length` (default 40,000, max 1,000,000 characters each for stdout and stderr).
163
+ Returns stdout, then `Stderr:` and `Exit code: N` when relevant, or `(no output)`.
164
+ Long output keeps its head and tail with `...N bytes truncated; complete output in <path>...`;
165
+ the full capture stays in that file.
166
+ - `read_file` - Numbered lines of a text file; `path`, optional `offset` (1-based) and `limit`
167
+ (default 2000 lines). Refuses binary files.
168
+ - `write_file` - Create or overwrite a file (`path`, `content`), creating parent directories.
169
+ - `edit_file` - Replace exact text (`path`, `old_string`, `new_string`, optional `replace_all`).
170
+ Fails unless `old_string` matches exactly once (or `replace_all` is set).
171
+ - `plan_add`, `plan_update`, `plan_show` - The agent's task plan (see [Task Plans](#task-plans)).
172
+ - `report_outcome` - Report the task `completed` or `blocked`, with a `summary`; ends the turn
173
+ (see [Outcomes](#outcomes)).
174
+
175
+ Any other tool's result longer than 40,000 characters is cut the same way as bash output,
176
+ with the whole result saved under the temp directory (`nano-coder-<pid>/tool-<id>-<name>.txt`)
177
+ and its path in the marker. `read_file` pages instead.
178
+
179
+ Relative paths resolve against the working directory (ACP `session/new` `cwd`). Writes are
180
+ atomic (temp file + rename). There is no permission prompt: run workers in a disposable
181
+ workspace.
182
+
183
+ ## Commands
184
+
185
+ - `/help` - Show available commands
186
+ - `/compact [focus]` - Summarize older messages with the current model, keeping the latest
187
+ message. Optional text tells the summary what to focus on. Esc Esc or Ctrl-C cancels
188
+ - `/verbosity [quiet|normal|verbose|debug]` - Show or set how much is printed (see below)
189
+ - `/context` - Show context usage, window, session token totals, auto-compaction state and the loaded instruction files
190
+ - `/settings` - Interactive settings menu:
191
+ - **Model**: pick a provider, then a model from its live model list (or type an ID)
192
+ - **Add or edit a provider**: name, API kind (OpenAI-compatible, Anthropic, Copilot),
193
+ base URL, key source (env var, shell command, or a literal key; the file is then
194
+ written with mode 0600) and default model
195
+ - temperature, max tokens, system prompt
196
+ - **Context**: auto-compaction on/off, threshold, context-window override
197
+ - **Verbosity**
198
+ - **Save to config file**: writes only the keys you changed into the config file
199
+ (`--config` or `~/.config/nano-coder/config.toml`), keeping comments and
200
+ other settings. Leaving with unsaved changes asks whether to save
201
+ - `/tools` - List registered tools
202
+ - `/plan` - Show the agent's task plan with all notes
203
+ - `/model [provider/model]` - Show or switch the model (conversation is kept)
204
+ - `/providers` - List providers, endpoints and whether their API key is available
205
+ - `/session` - Show the session ID and log path
206
+ - `/exit` - Exit the agent
207
+
208
+ ## Building and Running
209
+
210
+ ```bash
211
+ cargo build --release
212
+ ./target/release/nano-coder
213
+ ```
214
+
215
+ Or run directly:
216
+
217
+ ```bash
218
+ cargo run
219
+ cargo run -- --model anthropic/claude-sonnet-4-5
220
+ cargo run -- --model ollama/qwen2.5:1.5b
221
+ cargo run -- --resume sess-20260923T012518-7e7923f8
222
+ ```
223
+
224
+ Flags: `--login github-copilot`, `--list-models PROVIDER`, `--acp`, `--model provider/model` (or `AGENTIC_HARNESS_MODEL`), `--resume SESSION_ID`,
225
+ `--config PATH`, `--verbosity LEVEL` (`-v`).
226
+
227
+ ## Configuration
228
+
229
+ Create `~/.config/nano-coder/config.toml` (every field is optional). Directories from before the rename (`agentic-harness`) are still used if the new ones don't exist:
230
+
231
+ ```toml
232
+ model = "anthropic/claude-sonnet-4-5" # provider/model
233
+ default_provider = "mock" # used when the model has no known provider prefix
234
+ temperature = 0.7
235
+ max_tokens = 4096
236
+ max_iterations = 50 # LLM calls per user input
237
+ system_prompt = "You are a helpful assistant with access to tools."
238
+ bash_timeout_secs = 600
239
+ persist_sessions = true
240
+ # session_dir = "/path/to/sessions" # default: <platform data dir>/nano-coder/sessions
241
+ auto_compact = true # summarize automatically when the context fills up
242
+ auto_compact_threshold = 0.8 # fraction of the context window
243
+ # context_window = 128000 # override the window (providers can set it too)
244
+ verbosity = "normal" # quiet | normal | verbose | debug (or --verbosity)
245
+ project_instructions = true # load AGENTS.md etc. (see Project Instructions)
246
+ project_instruction_files = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"]
247
+ plan_tools = true # offer the plan_* tools (see Task Plans)
248
+ outcome_tool = true # offer report_outcome (see Outcomes)
249
+ reminders = true # append <system-reminder> notes to tool results
250
+ ```
251
+
252
+ The default model is `gpt-4o-mini` on the `mock` provider, so the harness still works offline.
253
+
254
+ ## Providers
255
+
256
+ A model is written as `provider/model`. The first path segment picks the provider if it
257
+ names one; otherwise the whole string is a model on `default_provider`. So
258
+ `openrouter/anthropic/claude-sonnet-4.5` sends `anthropic/claude-sonnet-4.5` to OpenRouter.
259
+
260
+ Built-in presets:
261
+
262
+ | Provider | Kind | Base URL | API key env |
263
+ |---|---|---|---|
264
+ | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
265
+ | `anthropic` | anthropic | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` |
266
+ | `openrouter` | openai | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
267
+ | `fireworks` | openai | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
268
+ | `groq` | openai | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` |
269
+ | `together` | openai | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` |
270
+ | `deepseek` | openai | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` |
271
+ | `mistral` | openai | `https://api.mistral.ai/v1` | `MISTRAL_API_KEY` |
272
+ | `gemini` | openai | `https://generativelanguage.googleapis.com/v1beta/openai` | `GEMINI_API_KEY` |
273
+ | `ollama` | openai | `http://localhost:11434/v1` | — |
274
+ | `llamacpp` | openai | `http://localhost:8080/v1` | — |
275
+ | `github-copilot` | github-copilot | from session token | `GITHUB_COPILOT_OAUTH_TOKEN` or `--login` (unofficial, see below) |
276
+ | `mock` | mock | — | — |
277
+
278
+ `kind = "openai"` means OpenAI Chat Completions, which also covers vLLM, LM Studio,
279
+ llama.cpp, DwarfStar ds4 and similar servers. `kind = "anthropic"` is the Anthropic
280
+ Messages API.
281
+
282
+ A `[providers.<name>]` table can add a new endpoint or override any field of a preset:
283
+
284
+ ```toml
285
+ [providers.ollama] # point the preset at another host
286
+ base_url = "http://merlin.local:11434/v1"
287
+ default_model = "qwen3:8b" # used by `--model ollama`
288
+
289
+ [providers.ds4] # a custom OpenAI-compatible endpoint
290
+ kind = "openai"
291
+ base_url = "http://localhost:8100/v1"
292
+ extra_body = { think = false } # merged into every request body
293
+
294
+ [providers.openai]
295
+ drop_params = ["temperature"] # for models that reject temperature
296
+
297
+ [providers.openrouter]
298
+ headers = { "HTTP-Referer" = "https://example.com", "X-Title" = "nano-coder" }
299
+ extra_body = { provider = { sort = "throughput" } }
300
+
301
+ [providers.work]
302
+ kind = "anthropic"
303
+ base_url = "https://llm-gateway.example.com/anthropic/v1"
304
+ api_key_env = "WORK_GATEWAY_KEY" # or api_key = "..." (prefer the env var)
305
+ # api_key_command = "op read op://vault/gateway/key" # used when the env var is unset
306
+ timeout_secs = 300
307
+ max_retries = 3
308
+ ```
309
+
310
+ Other per-provider fields: `max_tokens_param` (`max_tokens`, or `max_completion_tokens`
311
+ which is the `openai` default), `retry_initial_backoff_ms`, `retry_max_backoff_ms` and
312
+ `retryable_statuses`.
313
+
314
+ The old top-level `api_key` / `base_url` still work. They apply to `default_provider`,
315
+ which becomes `openai` if it was `mock`.
316
+
317
+ List a provider's models with `--list-models <provider>` (OpenAI-compatible endpoints and
318
+ `github-copilot`).
319
+
320
+ ### GitHub Copilot (unofficial)
321
+
322
+ The `github-copilot` provider uses a GitHub Copilot subscription by authenticating **as the
323
+ VS Code Copilot Chat extension**: a device-flow login with VS Code's OAuth client ID, an
324
+ exchange for a short-lived Copilot session token, and Chat Completions calls with VS Code's
325
+ editor headers. This is the approach several open-source agents (e.g. pi) take, but it is
326
+ **not a GitHub-sanctioned integration**. It may breach GitHub's terms or your organisation's
327
+ Copilot policy, it can break without notice, and misuse could get an account flagged. It is
328
+ never used unless you select it.
329
+
330
+ ```bash
331
+ nano-coder --login github-copilot # interactive; saves the OAuth token (0600)
332
+ nano-coder --list-models github-copilot
333
+ nano-coder --model github-copilot/gpt-4.1
334
+ ```
335
+
336
+ Headless workers can't do the device flow; set `GITHUB_COPILOT_OAUTH_TOKEN` to a token from a
337
+ previous login instead (credentials live in `<data dir>/nano-coder/github-copilot.json`).
338
+ Tool follow-ups are sent with `X-Initiator: agent`, so a turn is billed like one VS Code
339
+ request. `GITHUB_COPILOT_DOMAIN` selects a GHE.com host. Models that Copilot serves only
340
+ through its Responses API are not supported.
341
+
342
+ The sanctioned route is the [Copilot SDK](https://github.com/github/copilot-sdk), which
343
+ drives the Copilot CLI's own agent loop rather than exposing the model.
344
+
345
+ ### Retries
346
+
347
+ Retry behaviour comes from unreal-agent's retry logic (MIT). Connection errors and
348
+ HTTP 408/409/425/429/5xx/529 are retried with exponential backoff (1s doubling to
349
+ 30s, minus up to 20% jitter, 5 retries). `Retry-After` headers are honoured, and so is
350
+ "try again in Xs" in rate-limit messages. Overloads (`overloaded_error`,
351
+ `server_is_overloaded`, 529) back off from 10s up to 60s. Errors that can never succeed
352
+ on retry fail immediately: authentication and permission errors, invalid requests,
353
+ `context_length_exceeded`, quota and billing errors, and policy errors.
354
+
355
+ ## Output and Verbosity
356
+
357
+ In the interactive CLI, answers stream in as the model writes them. Output detail is set
358
+ with `/verbosity`, `--verbosity`, or `verbosity` in the config (default `normal`):
359
+
360
+ | Level | Shows |
361
+ |-------|-------|
362
+ | `quiet` | Final answers only |
363
+ | `normal` | Streamed answers, collapsed thinking, one line per tool call and its result |
364
+ | `verbose` | Also the first lines of each tool's output |
365
+ | `debug` | Also lifecycle hook events (`[hook] ...`) |
366
+
367
+ **Thinking.** Reasoning streams as a single line that updates in place
368
+ (`∴ Thinking: ...`) and becomes `∴ Thought for 3.1s · 812 chars` when the answer starts.
369
+ **Ctrl-O** switches to showing thinking in full, including the block that is streaming. At
370
+ the prompt it prints the last thinking in full. Press it again to collapse. Reasoning is read
371
+ from `reasoning_content` / `reasoning` fields (DeepSeek, llama.cpp, vLLM, OpenRouter, Ollama),
372
+ from `<think>...</think>` in the content, and from Anthropic `thinking` blocks. Anthropic
373
+ thinking blocks are kept with the conversation so tool use keeps working when thinking is
374
+ enabled (e.g. `extra_body = { thinking = { type = "enabled", budget_tokens = 4000 } }`).
375
+
376
+ **Tool calls** show as `● bash ls -la`, then `⎿` with the first line of the result (or the
377
+ error).
378
+
379
+ **Input.** On a terminal, input is read key by key. While a turn runs, what you type shows
380
+ on the status line; Enter sends it as a steer, Esc Esc or Ctrl-C cancels the turn. The prompt supports
381
+ Backspace, Ctrl-U (clear), Ctrl-W (delete word) and Ctrl-D (exit on an empty line).
382
+
383
+ Streaming uses server-sent events. Set `stream = false` on a provider whose endpoint doesn't
384
+ support it. ACP mode doesn't stream text, but sends each response's reasoning as an
385
+ `agent_thought_chunk` update.
386
+
387
+ ## Status Line and Compaction
388
+
389
+ In an interactive terminal the bottom row shows the provider/model, context usage
390
+ (`~` marks an estimate; without it the figure is anchored to the provider's reported usage),
391
+ a fill bar, message count, session input/output tokens, the auto-compaction threshold and
392
+ count, and what the agent is doing. It uses a terminal scroll region, follows resizes, and is
393
+ off when stdin/stdout isn't a TTY or `AGENTIC_NO_STATUS` is set.
394
+
395
+ The context window comes from, in order: `context_window` in the config, `context_window`
396
+ on the provider, a built-in table of known models, then 128k. If a provider rejects a
397
+ request as too long, the harness takes the limit from the error, compacts, and retries once.
398
+
399
+ Compaction asks the current model to summarize older messages, keeping the recent tail
400
+ (up to 20k tokens, never starting at a tool result). Auto-compaction runs before a model
401
+ call when usage passes the threshold. It won't run again until the context has grown by
402
+ another 10% of the window, so a context that can't shrink isn't summarized on every call.
403
+ If summarizing fails, the older messages are dropped with a note. The session log records
404
+ the new conversation, so `--resume` continues from it.
405
+
406
+ ## Project Instructions
407
+
408
+ When a session starts, the harness looks for instruction files in every directory from the
409
+ git root (the nearest ancestor containing `.git`) down to the working directory. Outside a
410
+ repository only the working directory is checked. In each directory the first file found from
411
+ `project_instruction_files` is used, so `AGENTS.md` wins over `CLAUDE.md`, which wins over
412
+ `.github/copilot-instructions.md`. The files are appended to the system prompt, root first,
413
+ under a "Repository instructions" heading that tells the model to follow them. So the model
414
+ has them before it makes any change, without having to decide to read them. Each file is
415
+ capped at 32 KiB and the total at 64 KiB.
416
+
417
+ Instruction files deeper in the tree than the working directory, such as `pkg/AGENTS.md`,
418
+ are loaded lazily. The first time `read_file`, `write_file` or `edit_file` touches a path
419
+ under such a directory, its instructions are appended to that tool result, once per
420
+ directory. After a compaction they are attached again the next time they apply. Files that
421
+ the `bash` tool touches don't trigger this.
422
+
423
+ Instructions are read again when a session is resumed, so edits to `AGENTS.md` take effect.
424
+ `/context` lists the loaded files. To turn loading off, set `project_instructions = false`,
425
+ or set `AGENTIC_NO_PROJECT_INSTRUCTIONS`.
426
+
427
+ ## Task Plans
428
+
429
+ The `plan_*` tools give the agent a plan that lives outside the conversation. This helps most
430
+ with small context windows: the model can write down what it has done and learned, then let the
431
+ conversation be summarized without losing track.
432
+
433
+ - `plan_add` - add steps (title strings or `{title, after, note}`), and optionally set the `goal`.
434
+ Items get numeric ids; `after` lists items that must be finished first.
435
+ - `plan_update` - set an item's `status` (`pending`, `in_progress`, `done`, `blocked`,
436
+ `dropped`), rename it, or add a `note`. `updates: [...]` changes several items in one call.
437
+ - `plan_show` - the whole plan with every note.
438
+
439
+ Each change returns a compact checklist ending with what is in progress or ready next. A bad
440
+ call (unknown id, bad status) changes nothing.
441
+
442
+ The harness keeps the plan in front of the model:
443
+ - **Compaction.** The summary message is followed by the full plan with notes (up to 8,000
444
+ characters; notes on finished items go first when it's too long).
445
+ - **Resume.** Every change is written to the session log, and `--resume` / `session/load`
446
+ restore the latest plan.
447
+ - **Another worker.** ACP `session/new` can be seeded with `_meta.plan` (see ACP above). The
448
+ model then gets the plan, and is told to check which "done" work was already committed or
449
+ pushed.
450
+
451
+ In the terminal, normal verbosity shows plan changes as a checklist instead of tool calls
452
+ (verbose shows both). The status line shows `plan 2/5`, and `/context` and `/plan` show more.
453
+ To turn plans off, set `plan_tools = false`.
454
+
455
+ **Reminders.** A tool result can end with a `<system-reminder>` note that the model sees with
456
+ the result:
457
+ - after 12 tool calls with no plan change while items are open, naming the item in progress
458
+ (or the next one) and asking for `plan_update`; again every 12 calls;
459
+ - once per session, after 10 tool calls in one turn with no plan, suggesting `plan_add`.
460
+
461
+ Plan and outcome calls don't count. Set `reminders = false` to turn them off.
462
+
463
+ ## Outcomes
464
+
465
+ `report_outcome` is the model's explicit end-of-task signal: `status` is `completed` (the
466
+ whole task is done and checked; the summary lists PRs or commits) or `blocked` (after three
467
+ or more different failed attempts, or when only a person can unblock it; the summary says
468
+ what is needed). The call ends the turn. Other calls in the same response still run, then the
469
+ summary becomes the final answer (`Blocked: ...` for blocked), and the outcome is returned
470
+ in ACP `_meta.outcome` and recorded in the session log's `turn_end` record. If the harness
471
+ stops after the call but before the turn ends, resuming the input finishes it with the
472
+ recorded outcome without calling the model again. In the terminal the call shows as
473
+ `✔ completed` or `■ blocked` followed by the summary. Set `outcome_tool = false` to leave the
474
+ tool out.
475
+
476
+ ## Sessions
477
+
478
+ When `persist_sessions` is on, each conversation is written to `<session_dir>/<id>.jsonl`.
479
+ The file is an append-only log whose first record is a versioned header, followed by
480
+ `input`, `message`, `turn_end` and `replace` (compaction / system-prompt reset) records.
481
+ Resume a session with `--resume <id>` or ACP `session/load`.
482
+
483
+ - An unsupported format version is an explicit error on resume.
484
+ - Only records ending in a newline count as committed. A half-written last line from a
485
+ crash is discarded and truncated before the next write.
486
+ - Tool calls left without results by a crash get a synthetic error result when the session
487
+ is loaded, so the conversation stays valid for providers.
488
+ - Input IDs are remembered. Redelivering a completed input returns its recorded response.
489
+ Redelivering an input whose turn was interrupted resumes that turn without duplicating
490
+ the user message.
491
+
492
+ ## Formal Verification
493
+
494
+ `tla/` holds TLA+ specifications of ACP turn routing (steer, cancel, deferred
495
+ messages) and of session-log crash recovery, model-checked with TLC. Run
496
+ `tla/check.sh` (needs Java and `tla2tools.jar`); see `tla/README.md` for the
497
+ properties and findings.
498
+
499
+ ## Extending
500
+
501
+ ### Adding Tools
502
+
503
+ ```rust
504
+ let tool_def = ToolDefinition::new(
505
+ "my_tool",
506
+ "Description of what the tool does",
507
+ json!({ "type": "object", "properties": { ... } })
508
+ );
509
+ agent.tools().register(tool_def, Box::new(|args| {
510
+ // Tool implementation
511
+ Ok(json!({ "result": "..." }))
512
+ }));
513
+ ```
514
+
515
+ The model sees a JSON string result as plain text and any other value as serialized JSON.
516
+
517
+ ### Adding Hooks
518
+
519
+ ```rust
520
+ agent.hooks().register(HookEvent::BeforeToolCall, Box::new(|ctx| {
521
+ let tool_name = ctx.data.get("tool_name").and_then(|v| v.as_str());
522
+ println!("About to call tool: {}", tool_name);
523
+ }));
524
+ ```
525
+
526
+ ### Custom LLM Client
527
+
528
+ Implement the async `LLMClient` trait (or add a `ProviderKind` in `src/providers/`):
529
+
530
+ ```rust
531
+ #[async_trait::async_trait]
532
+ impl LLMClient for MyClient {
533
+ async fn chat(&self, request: &ChatRequest<'_>) -> Result<LLMResponse> {
534
+ // request.messages, request.tools, request.temperature, request.max_tokens
535
+ }
536
+ fn model_name(&self) -> &str { "my-model" }
537
+ fn provider_name(&self) -> &str { "mine" }
538
+ }
539
+ ```
540
+
541
+ ## Acknowledgements
542
+
543
+ Retry classification, output bounding, bash result formatting and the session-log design
544
+ are adapted from [unreal-agent](https://github.com/unreallabsai/unreal-agent)
545
+ (MIT, Copyright (c) 2026 Unreal Labs). System reminders and the outcome tool follow ideas in
546
+ [grok-build](https://github.com/xai-org/grok-build)'s `<system-reminder>` notes and
547
+ `update_goal` tool.
package/bin/nano-coder ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Launcher used only when the postinstall step could not replace this file with
3
+ // the native binary (e.g. `npm install --ignore-scripts`). It adds a Node process
4
+ // in front of nano-coder; the native install avoids that overhead.
5
+ 'use strict';
6
+ const { spawnSync } = require('node:child_process');
7
+ const { binaryPath } = require('../lib/platform.js');
8
+
9
+ let bin;
10
+ try {
11
+ bin = binaryPath();
12
+ } catch (err) {
13
+ console.error(`nano-coder: ${err.message}`);
14
+ process.exit(1);
15
+ }
16
+ const r = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
17
+ if (r.error) {
18
+ console.error(`nano-coder: failed to run ${bin}: ${r.error.message}`);
19
+ process.exit(1);
20
+ }
21
+ if (r.signal) process.kill(process.pid, r.signal);
22
+ process.exit(r.status ?? 1);
package/install.js ADDED
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ // Replace the JS launcher with the native binary so `nano-coder` runs without a
3
+ // Node process in front of it. Best-effort: on any failure the launcher stays
4
+ // and still works.
5
+ const fs = require('node:fs');
6
+ const { binaryPath, launcher } = require('./lib/platform.js');
7
+
8
+ try {
9
+ const bin = binaryPath();
10
+ const tmp = `${launcher}.tmp-${process.pid}`;
11
+ fs.copyFileSync(bin, tmp);
12
+ fs.chmodSync(tmp, 0o755);
13
+ fs.renameSync(tmp, launcher);
14
+ } catch (err) {
15
+ console.warn(`nano-coder: keeping the Node launcher (${err.message})`);
16
+ }
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+
4
+ const PLATFORMS = {
5
+ 'darwin-arm64': '@nanobpm/nano-coder-darwin-arm64',
6
+ 'darwin-x64': '@nanobpm/nano-coder-darwin-x64',
7
+ 'linux-arm64': '@nanobpm/nano-coder-linux-arm64',
8
+ 'linux-x64': '@nanobpm/nano-coder-linux-x64',
9
+ };
10
+
11
+ function platformPackage() {
12
+ const key = `${process.platform}-${process.arch}`;
13
+ const pkg = PLATFORMS[key];
14
+ if (!pkg) throw new Error(`no prebuilt binary for ${key} (supported: ${Object.keys(PLATFORMS).join(', ')}); try \`cargo install nano-coder\``);
15
+ return pkg;
16
+ }
17
+
18
+ function binaryPath() {
19
+ const pkg = platformPackage();
20
+ try {
21
+ return require.resolve(`${pkg}/bin/nano-coder`);
22
+ } catch {
23
+ throw new Error(`the platform package ${pkg} is not installed (was it skipped with --no-optional?); reinstall @nanobpm/nano-coder`);
24
+ }
25
+ }
26
+
27
+ module.exports = { PLATFORMS, platformPackage, binaryPath, launcher: path.join(__dirname, '..', 'bin', 'nano-coder') };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@nanobpm/nano-coder",
3
+ "version": "0.1.0",
4
+ "description": "A 6MB coding agent. Run a fleet on your laptop.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/nanobpm/nano-coder.git"
9
+ },
10
+ "homepage": "https://github.com/nanobpm/nano-coder",
11
+ "keywords": [
12
+ "ai",
13
+ "agent",
14
+ "coding-agent",
15
+ "acp",
16
+ "cli"
17
+ ],
18
+ "bin": {
19
+ "nano-coder": "bin/nano-coder"
20
+ },
21
+ "files": [
22
+ "bin/",
23
+ "lib/",
24
+ "install.js",
25
+ "README.md"
26
+ ],
27
+ "scripts": {
28
+ "postinstall": "node install.js"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "optionalDependencies": {
34
+ "@nanobpm/nano-coder-darwin-arm64": "0.1.0",
35
+ "@nanobpm/nano-coder-darwin-x64": "0.1.0",
36
+ "@nanobpm/nano-coder-linux-arm64": "0.1.0",
37
+ "@nanobpm/nano-coder-linux-x64": "0.1.0"
38
+ }
39
+ }