Skip to content

NanoCode Agent

opencode-agent[bot] edited this page Oct 11, 2026 · 1 revision

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.

Overview

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.

Key Components

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.

How It Works

Entry point

NanoCode extends AbstractCommand and does not parse CLI arguments at all. execute():

  1. Platform.detect()
  2. Config.load(platform)
  3. no API key → Console.printUsage(), return
  4. printEncodingWarning() when file.encoding != "UTF-8"
  5. printBanner(), ToolRegistry.withDefaults(config), new ApiClient(config, tools)
  6. buildInput(config) — tries InputSource.fromConfig(config, getInput().getInputStream()), falls back to InputSource.fromConfig(config) (i.e. System.in)
  7. console.bindInput(input)
  8. 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 resolution

Config.lookup(key, default) in precedence order:

  1. System.getProperty("nanocode.<key>")
  2. tokens split out of System.getProperty("jnode.cmdline") matching -D<key>= — JNode exposes the kernel command line verbatim and has no -D flag parser, so this is the only way to configure it from the JNode boot prompt
  3. a properties file: System.getProperty("nanocode.config") or ./nanocode.properties in the JVM cwd, loaded with Properties.load (standard .properties, fully-qualified keys)
  4. 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.

Provider resolution

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.

The request

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().

The turn loop

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.

Tools

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

JNodeShellTool — JNode's bash

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, never invoke. The synchronous path stashes the command thread in AsyncCommandInvoker.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 ThreadExitListener closes each stage's streams in notifyThreadExited. 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 is new CommandInput(new NullInputStream()). NullInputStream must be used explicitly: a bare DEVNULL marker resolves to CommandInputOutput and commands that call getInput() 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.

Gotchas & Non-Obvious Behavior

  • JNode's classlib caps TLS at 1.0. ProtocolVersion.MAX is TLS10 and 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 an https: 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.getInt funnels Number through intValue(), so it works for max_tokens/offset/limit, but a large long loses precision.
  • One BufferedReader, shared with Console.confirm. A second reader over the same stream would swallow the next user line — that is why InputSource is passed to Console.bindInput rather than each constructing their own.
  • Console.DIM is used directly by BashTool, JNodeShellTool and InputSource, bypassing isColorEnabled(), so live tool output stays dimmed even with nanocode.color=false.
  • No streaming. One request/response per turn; stream is never set. The read timeout is 180 s, the connect timeout 20 s.
  • Retry policy: api_retries + 1 attempts; retryable statuses are exactly 408, 429, 500, 502, 503, 504; an IOException whose message mentions ssl/handshake/tls/certificate/trust is fatal; Retry-After is honoured but hard-capped at 5 s; otherwise the backoff is min(1000 << (attempt-1) + jitter, 8000). Thread.interrupted() aborts the loop immediately.
  • messages is never trimmed and only /c clears it. A long session grows the request without bound.
  • Read-only mode exempts bash. read_only blocks every mutating tool except bash, whose tool description is what tells the agent the JNode shell cannot glob and has no background jobs.
  • Config.getConfigFilePath() always returns null — the field is never assigned. getInputFilePath() is.
  • With no API key, Config.load returns before resolveApi, so model, max_turns and the rest are left at Java defaults.
  • renderMarkdown only handles **bold**. No headings, lists or code fences.
  • Java 6 throughout: no lambdas, no diamond, no try-with-resources, no Process.waitFor(timeout), no ProcessHandle. replaceAll, array subarray and the whole JSON codec are hand-rolled for that reason.

Plugin Descriptor

<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="&lt;&lt;ALL FILES&gt;&gt;" 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.

Related Pages

Clone this wiki locally