Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
eb939e1
feat: add core agent loop with concurrent tool execution
reno Aug 27, 2026
e731043
feat: add loop guards for turns, tokens, wall-clock, and repetition
reno Aug 27, 2026
41d7480
feat: recover panics and bound tool calls by timeout/cancellation
reno Aug 27, 2026
66ceac2
refactor: cut excess comments from the agent loop
reno Aug 27, 2026
1583b1f
feat: add workspace sandbox for filesystem tools
reno Aug 27, 2026
74e3fbb
feat: add read_file and list_dir tools
reno Aug 27, 2026
dfa6e94
feat: add run_command tool with loop-owned approval
reno Aug 27, 2026
275105f
feat: add write_file tool with diff preview and --allow-write flag
reno Aug 27, 2026
b9c0359
feat: add streaming Event model and channel-lifecycle helper
reno Aug 27, 2026
bd3065a
feat: add Anthropic SSE streaming
reno Aug 27, 2026
422a733
feat: add Ollama NDJSON streaming
reno Aug 27, 2026
659d7cf
feat: add cross-provider conformance suite
reno Aug 27, 2026
5690f1a
feat: add ui.Renderer and a plain, ANSI-free writer
reno Aug 27, 2026
b17a6f7
feat: add bubbletea TUI and wire cmd/pico chat for real
reno Aug 27, 2026
0a7d5e5
feat: track per-turn and cumulative token usage, expose /usage
reno Aug 27, 2026
c25423d
feat: add history compaction and wire it into the agent loop
reno Aug 27, 2026
65e7494
feat: add session persistence with --session and /new, /save, /load
reno Aug 27, 2026
394ce75
feat: add RECORD=1 live-record harness and a fixture secret scan
reno Aug 27, 2026
7aa3949
feat: wire structured logging with request/response redaction
reno Aug 27, 2026
0ee07dd
ci: add GitHub Actions workflow
reno Aug 27, 2026
dbe673d
docs: add README
reno Aug 27, 2026
00c696d
feat: wire --tools and --stream into cmd/pico
reno Aug 27, 2026
0863842
feat: wire run_command into cmd/pico behind --allow-commands
reno Aug 27, 2026
6045036
feat: wire slash commands into the TUI
reno Aug 27, 2026
f9e57b5
feat: wire the compaction context window into config
reno Aug 27, 2026
c533bf3
feat: add the skull home screen banner
reno Aug 28, 2026
c6bcc0a
fix: stop copying a live strings.Builder across Model.Update calls
reno Aug 29, 2026
827064a
feat: add the turn status line's timer, token counter, and rotating word
reno Aug 29, 2026
4f09ae0
fix: stop leaking log noise and stray escape bytes into the TUI
reno Aug 29, 2026
95c44ff
fix: don't mistake a non-json fenced answer for a failed tool call
reno Aug 29, 2026
93c0af5
fix: recover a native-mode tool call narrated as plain JSON text
reno Aug 29, 2026
3ed4e24
chore: update development command
reno Aug 29, 2026
69c19e3
feat: default --tools to prompted for --provider=ollama
reno Aug 29, 2026
e7511d7
feat: replace slash-command switch with a table and add /help
reno Aug 29, 2026
bddcb75
feat: add /clear, /compact, and /exit chat commands
reno Aug 29, 2026
2f33a05
feat: add /cd and /model chat commands
reno Aug 29, 2026
7bed87f
fix: echo submitted user messages into the TUI transcript
reno Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: CI

on:
push:
branches: [main, develop]
pull_request:

permissions:
contents: read

jobs:
lint-test:
name: lint & test (Go ${{ matrix.go-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.26", "1.27"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}

- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest

- name: make lint
run: make lint

- name: make test
run: make test

# make test doesn't pass -race (CLAUDE.md: offline, no network, no
# keys is the only hard requirement on the default target); CI runs
# the race detector as a separate, still fully offline step.
- name: go test -race
run: go test ./... -race

build:
name: build (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.27"

- name: go build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: go build -o bin/pico ./cmd/pico
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ lint:
golangci-lint run

run:
go run ./cmd/pico chat --provider=ollama
go run ./cmd/pico chat --provider=ollama --model=qwen2.5-coder:7b --tui
208 changes: 208 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# pico code

A small CLI AI agent written in Go. One agent loop, two LLM backends β€”
[Anthropic](https://docs.anthropic.com/) (cloud) and [Ollama](https://ollama.com/)
(local) β€” and a handful of sandboxed tools (read a file, list a directory, run
an allowlisted command, write a file behind a flag).

## Quickstart

Requires Go 1.26+.

```bash
git clone https://github.com/reno/pico-code.git
cd pico-code
make build # -> bin/pico
```

Pick a backend and talk to it:

```bash
# Local, no account needed β€” see "Ollama" below for the one-time setup.
./bin/pico chat --provider=ollama --model=qwen3:8b

# Cloud β€” needs ANTHROPIC_API_KEY set first.
./bin/pico chat --provider=anthropic --model=claude-sonnet-4-5
```

Either command drops you into a `> ` prompt. Ctrl+D (EOF) exits cleanly;
Ctrl+C interrupts the process the way it would any CLI tool β€” one to stop,
a second if the first doesn't. `--tui` behaves differently on purpose: see
[While chatting](#while-chatting).

## The two backends

### Anthropic

```bash
export ANTHROPIC_API_KEY=sk-ant-...
./bin/pico chat --provider=anthropic --model=<a Claude model ID>
```

Nothing else to install. Retries on 429/5xx happen inside the SDK; a bad key
or an unreachable network surfaces as a clear error rather than a hang.

### Ollama

```bash
# once: install Ollama (https://ollama.com/download), make sure it's
# running (the desktop app does this for you; on Linux, `ollama serve`),
# and pull a model
ollama pull qwen3:8b

./bin/pico chat --provider=ollama --model=qwen3:8b
```

`OLLAMA_HOST` overrides the default `http://localhost:11434`. Ollama
silently truncates context if its `num_ctx` option is left unset β€” pico
code always sets it explicitly (`--num-ctx`, default 4096) and logs the
effective value at startup.

Not every local model supports native tool calling, and some that advertise
support still narrate a tool call in prose instead of using it. So
`--provider=ollama` defaults `--tools` to `prompted` unless you pass
`--tools` explicitly; pass `--tools=native` yourself if your model handles
it reliably. (`--provider=anthropic` keeps defaulting to `native`.)
`--tools=prompted` injects tool schemas into the system prompt and parses a
fenced JSON block back out of the reply instead of relying on the model's
native tool-calling β€” it can't stream, so it also forces `--stream=false`
for that turn (a log line says so), and it isn't available at all under
`--tui`, which always streams; combine `--tui` with `--provider=ollama` and
you'll need to add `--tools=native` explicitly.

## Flags

| Flag | Default | Meaning |
| ----------------- | ------------ | -------------------------------------------------------------- |
| `--provider` | `anthropic` | `anthropic` or `ollama` |
| `--model` | (none) | model ID for the selected provider |
| `--max-turns` | `25` | agent loop turns before it stops on its own |
| `--token-budget` | `100000` | cumulative tokens before it stops on its own |
| `--workspace` | `.` | root directory filesystem tools are confined to |
| `--yes` | `false` | skip interactive approval prompts |
| `--tools` | `native` (`prompted` for `--provider=ollama`) | `native` or `prompted`; prompted mode ignores `--tui` and forces non-streaming |
| `--stream` | `true` | stream a reply as it arrives, plain mode only (the TUI always streams) |
| `--tui` | `false` | bubbletea TUI instead of the plain read-eval-print loop |
| `--log-level` | `info` | `debug`, `info`, `warn`, or `error` |
| `--num-ctx` | `4096` | Ollama's context window (`num_ctx`); ignored by Anthropic |
| `--allow-write` | `false` | register the `write_file` tool |
| `--session` | (none) | name a session to resume or start; saved after every turn |
| `--allow-commands`| (none) | comma-separated binary allowlist; registers `run_command` only if non-empty |
| `--context-window`| `200000` | context window compaction measures usage against; ignored by Ollama, which uses `--num-ctx` |

`PICO_CODE_PROVIDER` is an environment fallback for `--provider`, checked
only when the flag isn't explicitly set. `ANTHROPIC_API_KEY` and
`OLLAMA_HOST` are read from the environment only β€” there's no flag for
either, so a credential never ends up in shell history.

## While chatting

Both the plain REPL and the TUI understand the same commands:

- `/usage` β€” cumulative and last-turn token counts
- `/new` β€” start a fresh, unsaved conversation
- `/save [name]` β€” save the current conversation as a session
- `/load <name>` β€” replace the current conversation with a saved session

`--session <name>` does the same save/resume automatically, after every
turn, in both β€” kill the process mid-conversation and `--session <name>`
again picks up where it left off.

`--tui` swaps the REPL for a full-screen interface (scrollback, a spinner
while the model thinks, tool calls shown with a running/βœ“/βœ— status,
markdown rendering for finished replies, an approval modal for tools that
need sign-off). A command's output appears in the scrollback prefixed with
the command line itself, since the TUI otherwise never echoes what you
typed. Its Ctrl+C/Ctrl+D also differ from the plain REPL's on purpose:
Ctrl+C there cancels only the in-flight turn and returns to the prompt,
and Ctrl+D is the one that exits β€” a per-turn cancellation the plain REPL
doesn't have, since its context is shared process-wide rather than derived
fresh per turn.

## Safety

- Filesystem tools are confined to `--workspace`, resolved through
`EvalSymlinks` so a symlink can't point a read or write outside it. Reads
of `.env`, `.git/config`, `*.pem`, and `id_*` are denied outright.
- `run_command` (registered only when `--allow-commands` names at least one
binary) executes only that allowlisted binary, directly (`exec.Command`,
never a shell β€” no `|`, `&&`, `$()`, or similar can do anything), with a
timeout and truncated output.
- `write_file` and `run_command` both need approval unless `--yes` is
passed; the plain REPL prompts on the terminal, the TUI shows a modal.
- Tool output is truncated (head + tail, with an elision marker) before it
ever reaches the model's context, so one large file can't blow the
context window.

## How the agent loop works

```mermaid
flowchart TD
U[user turn] --> C[call the provider]
C --> T{reply has tool calls}
T -- no --> Done[return the reply]
T -- yes --> R[run every tool call concurrently]
R --> A[append results in call order]
A --> G{guard tripped}
G -- yes --> Stop[stop with an explanation appended]
G -- no --> C
```

A guard trips on max turns, the token budget, a wall-clock timeout, or the
same tool call repeating three times in a row.

Every tool call the model issues is answered by exactly one tool result, in
the same message, in the same order β€” even if the tool panics, times out,
or the run is cancelled mid-call. That invariant is asserted in the history
package itself (`history.Validate`), not just hoped for, and it's what makes
compaction and session persistence safe to build on top of: a saved or
compacted history is always in a state the model can resume from cleanly.

## Development

```bash
make build # bin/pico
make test # go test ./... β€” offline, no network, no API keys
make lint # gofmt -l . && go vet ./... && golangci-lint run
make run # go run ./cmd/pico chat --provider=ollama
```

`make test` never touches the network. Provider adapters replay recorded
HTTP exchanges from `testdata/golden/` through an `httptest.Server`; running
a test with `RECORD=1` (and real credentials, for the adapters that need
them) re-records against the live API instead, scrubbing any credential
before the fixture is written back to disk.

## What I learned / what breaks with small models

Small local models are the reason most of the guard rails in this codebase
exist:

- **They invent tool names.** The registry rejects an unknown name with an
error result β€” handed back to the model as data, not a crash β€” and the
model usually corrects itself on the next turn.
- **They repeat themselves.** A model stuck in a loop calling the same tool
with the same arguments gets cut off after three identical calls in a
row, with an explanation appended to the transcript instead of spinning
forever.
- **They emit arguments that don't match the schema**, or send them as a
JSON-encoded *string* instead of an object (Ollama does this often enough
that the adapter normalizes both shapes before decoding). Validation
failures go back to the model as an error result with the actual message,
which is usually enough for a capable-but-small model to fix on retry.
- **Some don't call tools at all β€” they narrate the call in prose.** That's
what `--tools=prompted` is for: schemas injected into the system prompt,
a fenced JSON block parsed back out of the reply instead of relying on
native tool-calling.
- **Streaming exposed a real gap in `encoding/json`.** A JSON string chunk
containing a truncated multi-byte UTF-8 sequence gets silently replaced
with U+FFFD by Go's own decoder β€” which mattered for testing "a tool
argument split mid-character" honestly: splitting raw UTF-8 bytes across
SSE chunks is untestable for this exact reason, so the fixture instead
splits a `\uXXXX` escape sequence, which is always well-formed at the
outer JSON-encoding level regardless of where it's cut.

More generally: almost everything CLAUDE.md calls a "provider gotcha" was
discovered by actually running a small model against this loop, not
anticipated up front. The cloud model mostly behaves; the local one is what
taught this codebase to be defensive.
Loading
Loading