Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 6 additions & 5 deletions .saatchi/mod.just
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ serve:
{{ nix_shell }} just build-client
# The one place the server is told to look (justfile:25).
export OLAI_DIST_DIR="$PWD/packages/web/dist"
# AGENT= is the passthrough (`packages/tests/support/serve.sh`'s
# `OLAI_ACP_AGENT="${AGENT:-}"`). Empty is off. Seeding HOME cannot
# produce an agent — OLAI_AGENT_PATH is a binary search path, and
# empty is "look nowhere".
# The one law for every knob that reaches the served server: THE CALL
# ANSWERS, the recipe passes through, EMPTY IS OFF — the recipe itself
# decides nothing. A pi row needs both halves handed in: OLAI_ACP_PI for
# the adapter, the `pi` on OLAI_AGENT_PATH for the probe.
export OLAI_ACP_AGENT="${AGENT:-}"
export OLAI_AGENT_PATH=
export OLAI_ACP_PI="${PI_AGENT:-}"
export OLAI_AGENT_PATH="${OLAI_AGENT_PATH:-}"
exec {{ nix_shell }} bun packages/server/src/main.ts web "$DATA" --port "$PORT"
37 changes: 36 additions & 1 deletion acp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions acp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
"name": "olai-acp",
"version": "0.1.0",
"private": true,
"description": "vendoring shim: pins the Claude Code ACP adapter so nix can build it offline",
"description": "vendoring shim: pins the ACP adapters so nix can build them offline",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.66.0"
"@agentclientprotocol/claude-agent-acp": "0.66.0",
"pi-acp": "0.0.33"
}
}
3 changes: 2 additions & 1 deletion default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ let
makeWrapper ${pkgs.bun}/bin/bun $out/bin/olai \
--add-flags "${base}/packages/server/src/main.ts" \
--set OLAI_DIST_DIR "${olai-client}" \
--set-default OLAI_ACP_AGENT "${acp-agent}/bin/claude-agent-acp"
--set-default OLAI_ACP_AGENT "${acp-agent}/bin/claude-agent-acp" \
--set-default OLAI_ACP_PI "${acp-agent}/bin/pi-acp"
'';
in
{
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions docs/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ What you type sits on the right, in a tinted bubble. What the agent answers sits

## Which agent

The panel speaks [ACP](https://agentclientprotocol.com), and it talks to whichever agents this machine has. It finds them itself: the pinned Claude Code adapter, which comes with olai — `nix run`, the packaged binary and `just serve` all bake it in, so there is nothing to install and nothing to configure — and an **opencode** on the server's own PATH.
The panel speaks [ACP](https://agentclientprotocol.com), and it talks to whichever agents this machine has. It finds them itself: the pinned Claude Code adapter, which comes with olai — `nix run`, the packaged binary and `just serve` all bake it in, so there is nothing to install and nothing to configure — an **opencode** on the server's own PATH, and **pi**, whose adapter is pinned and shipped like the Claude Code one but whose agent is found the way opencode is: a `pi` on the server's agent search path is the machine saying it has one, and without it there is no pi row.

**A conversation is with ONE agent, and you choose it when the chat starts.** Not a setting, and not something a conversation can be moved to afterwards: the way to talk to the other agent is to start a chat with it. What you choose is remembered *for that conversation* and nowhere else, so a new chat asks again — there is no default quietly deciding for you, and no way to find yourself in a conversation with an agent you did not pick.

Expand All @@ -15,9 +15,9 @@ With only ONE agent installed there is nothing to ask, so nothing is asked. That
The list itself:

- **found once, when the server starts.** An agent installed while olai is running is offered by the next start. What decides whether the panel has an agent at all is not a thing to change under somebody who is reading it.
- `OLAI_ACP_AGENT` points at a different ACP agent for the Claude row — that override has always meant *read this the way you read Claude Code*, and it still does.
- `OLAI_ACP_AGENT` points at a different ACP agent for the Claude row — that override has always meant *read this the way you read Claude Code*, and it still does. `OLAI_ACP_PI` is the pi row's half of the same arrangement: which pi-acp adapter the panel spawns is a pin olai bakes in or a person overrides, never whatever `npx -y pi-acp` would have fetched today.
- Setting it to the empty string turns chat off — the whole panel, not one row of it: nothing is looked for, and the panel says there is no agent and how to get one. The outlines are served the same either way.
- `OLAI_AGENT_PATH` is where the probes look, and defaults to `PATH`. It is worth knowing about because **olai's PATH is not your shell's**: run as a systemd user service (the home-manager unit) it inherits neither your profile nor your login shell, so an `opencode` you can run in a terminal is not necessarily one this process can see. Set it and it REPLACES the search path.
- `OLAI_AGENT_PATH` is where the probes look, and defaults to `PATH`. It is worth knowing about because **olai's PATH is not your shell's**: run as a systemd user service (the home-manager unit) it inherits neither your profile nor your login shell, so an `opencode` you can run in a terminal is not necessarily one this process can see. Set it and it REPLACES the search path. For pi it answers a second question too: the `pi` the probe finds there is handed to the pinned adapter as the one it wraps, so the pi the row runs is the pi the probe found rather than one the adapter resolved against its own environment.

With no agent at all the panel still draws, and says which agents olai can talk to and where to get one — because a feature that is silently absent cannot be told apart from one that is broken.

Expand All @@ -27,9 +27,11 @@ The conversation is the agent's own session for that directory: close olai, reop

Anything an agent does not offer simply is not drawn — except where you would expect the behaviour, and then the absence is stated rather than left to be discovered:

- **opencode cannot be INTERRUPTED.** Sending is identical on both agents — the message goes at once and waits its turn at the agent — but the Claude agent also takes a message straight into the turn it is running, on the deliberate gesture, and opencode has no such method. So the `interrupt` control simply is not drawn there ([below](#talking-while-it-works)).
- **opencode's subagents carry no attribution**, so a fan-out is drawn flat — every call in one column — rather than in lanes ([below](#when-the-agent-sends-other-agents)). Nothing here guesses at whose a call was.
- **a tool call's name comes from wherever that agent says it.** Claude Code says it in a field of its own; opencode says it at the head of the call's id (`bash:0`). Either way the row keeps the name it was announced with, and a tool olai cannot name is one you are asked about rather than one that is quietly allowed.
- **opencode cannot be INTERRUPTED.** Sending is identical on both agents — the message goes at once and waits its turn at the agent — but the Claude agent also takes a message straight into the turn it is running, on the deliberate gesture, and opencode has no such method. So the `interrupt` control simply is not drawn there ([below](#talking-while-it-works)). **Neither can pi, and that is a characteristic rather than a gap**: a message sent while pi is mid-turn **QUEUES** — it reaches pi's adapter immediately and is answered in order, the adapter's own chunk saying so ("Queued message (position n).") — and pi's own way of steering is never in play: the ACP verb for it is not one pi-acp's wire carries, and the `/steering` in pi-acp's slash menu is about pi's message delivery, not this one.
- **opencode's subagents carry no attribution**, so a fan-out is drawn flat — every call in one column — rather than in lanes ([below](#when-the-agent-sends-other-agents)). Nothing here guesses at whose a call was. The same is true of pi's: no stamp, no lanes, no strip — and no background-task faces either.
- **a tool call's name comes from wherever that agent says it.** Claude Code says it in a field of its own; opencode says it at the head of the call's id (`bash:0`), and pi saying it the same way is a fact its adapter shares (`edit:1`). Either way the row keeps the name it was announced with, and a tool olai cannot name is one you are asked about rather than one that is quietly allowed.
- **pi is handed olai's own tools and cannot use them — and the roster says so, in its own sentence.** Its adapter accepts the session's MCP servers and wires them to nothing, so the tools were never wired to anything pi can reach and nothing on its wire will ever come back to say otherwise — the roster's rows stand *did not attach* from the conversation's first moment with the reason written under them, rather than *handed* forever, which would be the panel answering the way the model used to: certain, wrong, trustable-looking. The writes the other agents make through the ops layer — the checkbox moving while you watch — pi makes or does not make with its OWN file tools, and any permission it could ask is a person's every time: there is no spelling of *ours* on its wire to match. What you lose on pi, said here rather than discovered missing: no per-server ticks, no counts in the chats list, no usage readout in the header, and its bash output stays in the tool row's detail rather than under it (file edits draw as diffs, fully). And one more that is a size rather than a shape: the chats list shows at most pi's **newest fifty** stored conversations — the adapter answers in pages of that size, newest first, and the page is its answer.
- **pi's own hello is not conversation.** Open a conversation with pi and its adapter publishes a startup banner for editors — pi's version, maybe an update nag, a list of the context and skills it loaded — and then repeats it into the session as an ordinary message, for clients that draw no banner block. This panel leaves the repeat out, matched on the exact text the open's own answer carried, never on a guess at prose. Stated here rather than left to be discovered, because the difference between pi saying nothing and pi having said nothing is something a transcript owes you: a first turn whose only content would have been that banner is a silent turn, and the panel names silent turns — a banner standing where the silence notice belongs would be the one chunk that made it look answered.

## Which conversation you come back to

Expand Down
2 changes: 1 addition & 1 deletion docs/running.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ inputs.olai.url = "github:juspay/olai";

The module fills `package` from the flake for the host platform. The packaged binary already bakes the browser bundle (`OLAI_DIST_DIR`), so the service needs no ambient environment.

**The one thing a user service does NOT inherit is your PATH**, and that is where agents live. Olai looks for the ones it knows when it starts — the pinned Claude Code adapter it ships with, and an `opencode` on its own search path — and a unit started by systemd sees neither your login shell nor your profile. So an `opencode` you can run in a terminal is not necessarily one this process can find, and `OLAI_AGENT_PATH` is how you say where to look:
**The one thing a user service does NOT inherit is your PATH**, and that is where agents live. Olai looks for the ones it knows when it starts — the pinned adapters it ships with (Claude Code's and pi-acp's), and the agents they drive on its own search path: an `opencode`, a `pi` — and a unit started by systemd sees neither your login shell nor your profile. So an `opencode` you can run in a terminal is not necessarily one this process can find, and `OLAI_AGENT_PATH` is how you say where to look:

```nix
systemd.user.services.olai.Environment = [
Expand Down
20 changes: 20 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ serve dir="docs" *args: build-client
# packaged binary does — scripts/acp-agent.sh is the one place that is
# decided, and `OLAI_ACP_AGENT` overrides it (empty disables).
export OLAI_ACP_AGENT="$(sh scripts/acp-agent.sh)"
# The pi row's adapter, the other half of the same pin — a machine with a
# `pi` on the search path gets the row, every other machine gets nothing
# new (scripts/acp-pi.sh says why the roster probes for the agent).
export OLAI_ACP_PI="$(sh scripts/acp-pi.sh)"
# `kill 0` takes the whole process group down together: a stray bundler
# watching a tree nobody is serving is a confusing thing to leave behind.
trap 'kill 0' EXIT INT TERM
Expand All @@ -183,6 +187,7 @@ run dir="docs" *args: build-client
#!/usr/bin/env bash
set -euo pipefail
export OLAI_ACP_AGENT="$(sh scripts/acp-agent.sh)"
export OLAI_ACP_PI="$(sh scripts/acp-pi.sh)"
OLAI_DIST_DIR={{ dist }} OLAI_PORT_FILE={{ dev_url }} \
{{ nix_shell }} bun --watch packages/server/src/main.ts web {{ dir }} {{ args }}

Expand Down Expand Up @@ -222,6 +227,21 @@ nix:
exit 1
fi
echo "packaged default agent: $agent"
# THE OTHER SHIPPED ADAPTER, checked the same way: the pi row is a no-op
# on a machine without `pi`, but on one that has it the row spawns
# whatever this names, so it has to be there and be runnable.
pi=$(sed -n "s|.*OLAI_ACP_PI=\${OLAI_ACP_PI-'\(.*\)'}.*|\1|p" "$out/bin/olai")
if [ -z "$pi" ]; then
echo "the packaged binary does not bake OLAI_ACP_PI into its wrapper," >&2
echo "so the pi row would never be offered. Wrapper:" >&2
cat "$out/bin/olai" >&2
exit 1
fi
if [ ! -x "$pi" ]; then
echo "the wrapper's baked OLAI_ACP_PI is not executable: $pi" >&2
exit 1
fi
echo "packaged pi adapter: $pi"

# The home-manager module evaluates under a sample config (systemd argv on
# Linux, launchd argv on Darwin). Cheap, no home-manager pin, no activation —
Expand Down
26 changes: 21 additions & 5 deletions nix/acp-agent.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@

buildNpmPackage {
pname = "olai-acp-agent";
version = "0.66.0"; # tracks @agentclientprotocol/claude-agent-acp
# One lockfile, TWO adapters. The version names both pins rather than
# claiming the build is the claude one alone: a bump of either line of
# acp/package.json moves the store path's NAME, not only its hash — a
# pi-acp bump that left the path stamped "0.66.0" would be the old claim
# living on after its evidence.
# tracks acp/package.json: @agentclientprotocol/claude-agent-acp + pi-acp
version = "0.66.0+pi-0.0.33";

# ../acp would also pull in whatever else lands in that directory; keep the
# src (and its hash) to just the two files the build actually reads.
Expand All @@ -34,10 +40,10 @@ buildNpmPackage {
filter = path: _type:
baseNameOf path == "package.json" || baseNameOf path == "package-lock.json";
};
npmDepsHash = "sha256-773leTH1zrV0X/VuCzU6ZRiIPzplzeh40BqIfTFauM0=";
npmDepsHash = "sha256-67N6fffjlupkpliEpMkAcwN7map54TPYP68TeV7mwy8=";

# acp/ is a shim around one dependency: nothing to compile, and no package in
# the tree has an install script to run.
# acp/ is a shim around its two pinned dependencies: nothing to compile,
# and no package in the tree has an install script to run.
dontNpmBuild = true;
npmFlags = [ "--ignore-scripts" ];

Expand Down Expand Up @@ -99,14 +105,24 @@ buildNpmPackage {
--set DISABLE_INSTALLATION_CHECKS 1 \
--set USE_BUILTIN_RIPGREP 0 \
--prefix PATH : "${lib.makeBinPath [ ripgrep procps ]}"
# THE SECOND SHIPPED ADAPTER: pi-acp, the bridge that spawns `pi --mode
# rpc` for the pi leg. Pinned at the shim's revision like everything in
# here — a floating `npx -y pi-acp` would be a different build every
# day and the leg's facts are one revision's. No wrapper env of its
# own: the `pi` IT drives is a per-machine find, so the roster names it
# at spawn time (`PI_ACP_PI_COMMAND`) rather than it being baked.
pi_entry="${mods}/pi-acp/dist/index.js"
test -f "$pi_entry"
makeWrapper ${nodejs}/bin/node "$out/bin/pi-acp" \
--add-flags "$pi_entry"
'';

# No meta.license on purpose: the adapter is Apache-2.0 but the `claude`
# binary it drives ships under Anthropic's commercial terms, and declaring
# that unfree would make `nix build` demand allowUnfree from every consumer of
# this flake.
meta = {
description = "Claude Code ACP adapter, pinned for olai";
description = "The ACP adapters olai ships: claude-code-acp and pi-acp, pinned together";
homepage = "https://github.com/zed-industries/claude-code-acp";
mainProgram = "claude-agent-acp";
platforms = lib.platforms.unix;
Expand Down
Loading