Repository navigation
NanoCode Agent
nanocode: an agentic coding CLI for JNode — Anthropic-compatible API client, six filesystem/shell tools, and the JNode-shell execution backend that replaces
bash.
nanocode is JNode's port of an agentic coding REPL. It lives in the distr subproject (distr/src/apps/org/jnode/apps/nanocode/), is packaged into jnode-distr.jar, and is registered as a shell command through the org.jnode.shell.aliases extension point. Added 2026-10-10 in commit f68481092 (distr: add nanocode, an agentic coding CLI for JNode), 19 Java-6 source files, ~3700 lines.
The interesting part is not the LLM protocol — that is a plain HttpURLConnection POST/response — but what had to change to make a REPL work inside JNode: System.in is a dead snapshot inside a shell command, there is no process model so bash must be reimplemented on top of JNode's own shell, the serial console neither echoes nor renders ANSI, the kernel command line is the only way to pass -D properties, and the bundled classlib caps TLS at 1.0.
nanocode is not in all/conf/default-plugin-list.xml — only full-plugin-list.xml. It is therefore absent from the cd-x86-lite / cd-x86_64-lite images and present only in full, test and gui-disabled images.
| Class / File | Role |
|---|---|
org.jnode.apps.nanocode.NanoCode |
extends org.jnode.shell.AbstractCommand; VERSION = "1.0-jnode". Entry point and REPL bootstrap. |
agent/AgentLoop.java |
The turn loop: reads a line, calls the API, dispatches tool_use blocks, feeds tool_result back. |
agent/InputSource.java |
One shared BufferedReader over either System.in or a batch file; also used by Console.confirm. |
api/ApiClient.java |
HttpURLConnection client with retry, Retry-After handling and error-body parsing. |
config/Config.java |
Immutable config with a private Builder; 17 nanocode.* keys. |
platform/Platform.java |
Detects JNode (java.vm.name contains "jnode") and probes for a process model. |
json/Json.java |
Hand-rolled JSON writer and recursive-descent parser (the classlib ships none). |
fs/FileOps.java |
Static file helpers: readLines, readAll, writeFile, isBinary, globToRegex, collectFiles, padLineNumber. |
render/Console.java |
ANSI styling, banner, markdown-lite, tool-result previews, y/N confirmation. |
tool/Tool.java, AbstractTool, ToolRegistry
|
Tool SPI, getInt/getString argument helpers, and schema emission. |
tool/ReadTool, WriteTool, EditTool, GlobTool, GrepTool
|
The five portable file tools. |
tool/BashTool |
Host implementation: ProcessBuilder("/bin/sh","-c",…). |
tool/JNodeShellTool.java |
JNode implementation (584 lines): tokenizer, parser and pipeline runner over CommandShell. |
distr/descriptors/org.jnode.apps.nanocode.xml |
Plugin descriptor; alias + permissions. |
NanoCode extends AbstractCommand and does not parse CLI arguments at all. execute():
Platform.detect()Config.load(platform)- no API key →
Console.printUsage(), return -
printEncodingWarning()whenfile.encoding != "UTF-8" -
printBanner(),ToolRegistry.withDefaults(config),new ApiClient(config, tools) -
buildInput(config)— triesInputSource.fromConfig(config, getInput().getInputStream()), falls back toInputSource.fromConfig(config)(i.e.System.in) console.bindInput(input)new AgentLoop(config, tools, apiClient, input, console).run()
Step 6 is the JNode-specific part. AbstractCommand.getInput() returns ios[0], the shell's live CommandInput. On JNode, System.in handed to a command is a snapshot that stops receiving keystrokes once the shell moves on, so a REPL reading System.in would hang immediately.
Config.lookup(key, default) in precedence order:
System.getProperty("nanocode.<key>")- tokens split out of
System.getProperty("jnode.cmdline")matching-D<key>=— JNode exposes the kernel command line verbatim and has no-Dflag parser, so this is the only way to configure it from the JNode boot prompt - a properties file:
System.getProperty("nanocode.config")or./nanocode.propertiesin the JVM cwd, loaded withProperties.load(standard.properties, fully-qualified keys) - the built-in default
No environment variables are read — System.getenv is never called. Config.fileProps() re-reads the file on every lookup rather than caching it statically, because JNode runs commands in one long-lived VM whose classloader survives across invocations; a static cache would freeze the config seen by the first invocation.
| Key | Type | Default |
|---|---|---|
nanocode.api_key |
String | "" |
nanocode.api_url |
String | inferred (OpenRouter / Anthropic) |
nanocode.model |
String | inferred (anthropic/claude-opus-4.5 / claude-opus-4-5) |
nanocode.system_prompt |
String | built-in 7-line prompt |
nanocode.cwd |
String |
"." (absolute-ised if it is a directory) |
nanocode.max_turns |
int | 40 |
nanocode.max_tokens |
int | 8192 |
nanocode.read_max_lines |
int | 2000 |
nanocode.grep_max |
int | 100 |
nanocode.bash_timeout |
int | 120 |
nanocode.read_only |
bool | false |
nanocode.confirm_writes |
bool | true |
nanocode.color |
bool | !platform.isJNode() |
nanocode.echo_input |
bool | platform.isJNode() |
nanocode.api_retries |
int | 3 |
nanocode.input |
String |
"" = read stdin |
nanocode.config |
String | ./nanocode.properties |
read_max_lines and echo_input are the two platform-sensitive defaults: the serial console has no local echo, so the REPL must echo what it receives.
openRouter is chosen by, in order: an explicit api_url containing "openrouter"; an explicit model containing /; else apiKey.toLowerCase().startsWith("sk-or-"). The provider string is exactly "OpenRouter" or "Anthropic" and controls the auth header:
OpenRouter -> Authorization: Bearer <key>
Anthropic -> x-api-key: <key>
along with anthropic-version: 2023-06-01, Content-Type: application/json, Connection: close.
Built by string concatenation in ApiClient.attempt() and sent as UTF-8:
{"model":"...","max_tokens":8192,"system":"...","messages":[...],"tools":[...]}ToolRegistry.schema() emits per tool:
{"name":"read","description":"...",
"input_schema":{"type":"object",
"properties":{"path":{"type":"string"},"offset":{"type":"number"}},
"required":["path"]}}Property objects carry only type — there is no per-property description. Property order comes from the LinkedHashMap returned by Tool.properties().
line = input.readLine()
"/q" | "exit" -> leave the REPL
"/c" -> messages = new ArrayList()
serial-mux framing marker -> silently continue
empty -> continue
else -> messages.add({"role":"user","content":line})
for turn in 0..max_turns-1:
response = apiClient.sendRequest(messages)
for block in response.content:
"text" -> print console.renderMarkdown(text)
"tool_use" -> find tool; gate on read_only / confirm; execute; collect tool_result
messages.add({"role":"assistant","content":blocks}) // the raw block list, verbatim
if no tool_results: done
else: messages.add({"role":"user","content":toolResults})
Tool failures never propagate as exceptions: they are returned to the model as "error: …" string content in a tool_result block, so the model can self-correct on the next turn. Any exception out of sendRequest is caught, printed in red as * <msg> and ends the turn with done = true — a transport failure never kills the REPL. ThreadDeath is rethrown so job control (Ctrl-C) is never swallowed.
isSerialAgentMarker(line) drops lines starting with echo __JSM_B_, echo __JSM_E_, __JSM_B_, __JSM_E_. Those are the framing echoes emitted by .opencode/skills/jnode-serial/scripts/serial_mux.py when nanocode holds the serial console; without the filter they arrive as REPL input.
ToolRegistry.withDefaults(config) always exposes six tool names — read, write, edit, glob, grep, bash — because BashTool and JNodeShellTool both return "bash" from name(). Only one of the two is registered: BashTool if Platform.isBashAvailable(), otherwise JNodeShellTool.
| Tool | Params (required first) | Mutating | Notes |
|---|---|---|---|
read |
path: string, offset: number, limit: number
|
no | 1-based offset; 6-char right-aligned line-number gutter; appends a [truncated: …] footer on partial coverage |
write |
path: string, content: string
|
yes | refuses if the parent directory does not exist; truncates and overwrites |
edit |
path: string, old: string, new: string, all: boolean
|
yes | exact-substring pass first, then a whitespace-insensitive line-block fallback; refuses an ambiguous old unless all=true
|
glob |
pat: string, path: string
|
no |
* matches within a segment, ** across segments, ? one char; matches both the relative and absolute form; sorted by lastModified() descending |
grep |
pat: string, path: string
|
no |
Matcher.find() per line, capped at nanocode.grep_max; skips files FileOps.isBinary (NUL in the first 4 KiB) |
bash (host) |
cmd: string, timeout: number
|
yes |
ProcessBuilder("/bin/sh","-c",…), live output, kills on timeout |
bash (JNode) |
cmd: string only |
yes | see below; no timeout parameter, so nanocode.bash_timeout is not honoured on JNode |
Reimplements enough shell to run JNode's built-in commands (ls, cat, javac, java, grep, find, …) with pipes, &&/||/; sequencing and </>/>> redirects, using nine JNode-only classes: ShellUtils.getCurrentShell(), CommandShell, CommandLine, CommandThread, ThreadExitListener, ShellException, CommandInput, CommandOutput, NullInputStream.
Pipeline stages: tokenize (NORMAL / SINGLE / DOUBLE modes; single quotes fully literal, # comments, longest-first operator match &&, ||, >>, |, ;, <, >) → splitSequence into Steps of Stages → per-stage argv, from, to, append, next → run.
Four JNode-specific behaviours are load-bearing:
-
invokeAsynchronous, neverinvoke. The synchronous path stashes the command thread inAsyncCommandInvoker.threadProcess/blockingThread, which is exactly how Ctrl-C finds and kills the running command. Routing tool calls through it would overwrite that bookkeeping with finished threads and make nanocode uninterruptible. -
StageExitListener implements ThreadExitListenercloses each stage's streams innotifyThreadExited. That is what delivers EOF downstream; without it the next stage blocks forever. -
A plain DEVNULL marker breaks commands. For a stage with no
<redirect, stdin isnew CommandInput(new NullInputStream()).NullInputStreammust be used explicitly: a bare DEVNULL marker resolves toCommandInputOutputand commands that callgetInput()then hang. -
All stages are wired before any is started. Threads are created, then all started, then
waitFor()in order. Starting them one at a time would deadlock a pipe whose consumer is not yet reading.
Live output is streamed by a daemon poller that diffs capture.size() against printed every 200 ms and prints complete lines as <ESC>[2m| <line>, so a long javac stays visible. Every stage's stderr also goes into capture so the agent sees error text. The capture is decoded ISO-8859-1; empty output reports (no output), a non-zero return appends [exit N].
BashTool needs the same treatment for a different reason: Java 6 has neither Process.poll() nor waitFor(timeout), so it spawns a reader daemon (draining stdout into a LinkedBlockingQueue<String> with a "nanocode-eof" sentinel) and a waiter daemon (publishing the exit code into an int[] exitCodeBox initialised to Integer.MIN_VALUE), and the main thread polls the queue every 250 ms.
-
JNode's classlib caps TLS at 1.0.
ProtocolVersion.MAXisTLS10and no TLS 1.2 is implemented, but the Anthropic API requires TLS 1.2+.ApiClient.decorate()rewrites any transport failure on JNode with that explanation and the workaround: plain HTTP via a local relay, or run on the host.Console.printBanner()pre-warns about anhttps:URL on JNode. -
Tool.properties()carries no per-property descriptions. The schema is{"type": "..."}only, so the model sees parameter names but not per-parameter prose. - Tools run serially in response order even though the system prompt asks the model to batch them. Batching reduces round trips, not tool latency.
-
All JSON numbers parse as
Double.AbstractTool.getIntfunnelsNumberthroughintValue(), so it works formax_tokens/offset/limit, but a large long loses precision. -
One
BufferedReader, shared withConsole.confirm. A second reader over the same stream would swallow the next user line — that is whyInputSourceis passed toConsole.bindInputrather than each constructing their own. -
Console.DIMis used directly byBashTool,JNodeShellToolandInputSource, bypassingisColorEnabled(), so live tool output stays dimmed even withnanocode.color=false. -
No streaming. One request/response per turn;
streamis never set. The read timeout is 180 s, the connect timeout 20 s. -
Retry policy:
api_retries + 1attempts; retryable statuses are exactly408, 429, 500, 502, 503, 504; anIOExceptionwhose message mentions ssl/handshake/tls/certificate/trust is fatal;Retry-Afteris honoured but hard-capped at 5 s; otherwise the backoff ismin(1000 << (attempt-1) + jitter, 8000).Thread.interrupted()aborts the loop immediately. -
messagesis never trimmed and only/cclears it. A long session grows the request without bound. -
Read-only mode exempts
bash.read_onlyblocks every mutating tool exceptbash, whose tool description is what tells the agent the JNode shell cannot glob and has no background jobs. -
Config.getConfigFilePath()always returnsnull— the field is never assigned.getInputFilePath()is. -
With no API key,
Config.loadreturns beforeresolveApi, somodel,max_turnsand the rest are left at Java defaults. -
renderMarkdownonly handles**bold**. No headings, lists or code fences. -
Java 6 throughout: no lambdas, no diamond, no try-with-resources, no
Process.waitFor(timeout), noProcessHandle.replaceAll, arraysubarrayand the whole JSON codec are hand-rolled for that reason.
<plugin id="org.jnode.apps.nanocode" name="JNode nanocode" license-name="lgpl">
<requires><import plugin="org.jnode.shell"/></requires>
<runtime>
<library name="jnode-distr.jar">
<export name="org.jnode.apps.nanocode.*"/>
... one export per subpackage ...
</library>
</runtime>
<extension point="org.jnode.shell.aliases">
<alias name="nanocode" class="org.jnode.apps.nanocode.NanoCode"/>
</extension>
<extension point="org.jnode.security.permissions">
<permission class="java.net.SocketPermission" name="*" actions="resolve,connect"/>
<permission class="java.io.FilePermission" name="<<ALL FILES>>" actions="read,write"/>
<permission class="java.util.PropertyPermission" name="*" actions="read"/>
<permission class="java.lang.RuntimePermission" name="readFileDescriptor"/>
<permission class="java.lang.RuntimePermission" name="writeFileDescriptor"/>
</extension>
</plugin>No createClassLoader, no exitVM, no ReflectPermission. The descriptor is copied by the generic jnode.copy-descriptors target, so no build-file change was needed.
-
Shell-Commands — how
NanoCodeplugs in as anorg.jnode.shell.aliasescommand -
Shell-Interpreter — the
&&/||/;and pipe semanticsJNodeShellToolreimplements - Agent-Issue-Pipeline — the OpenCode agent pipeline that nanocode mirrors in JNode
-
JNode-Serial-Skill — the serial-mux whose framing echoes
AgentLoopfilters out - Plugin-System — descriptor, permissions and packaging rules
- Glossary — nanocode, tool_use, serial-mux framing marker