Skip to content
Merged
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
6 changes: 4 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,11 @@ app. Closing this needs the server-side blocking-message capture described above
- **Scrollback comes from the transcript, not the terminal.** An agent's TUI runs on the *alternate
screen* (`ESC[?1049h`), so the emulator keeps no scrollback ring and `pane.read` can never return
more than the visible viewport — the live mirror physically cannot scroll back. Pane history is
therefore read from the agent's **own transcript file** off disk (`bridge/transcript.ts`,
therefore read from the agent's **own transcript file** off disk (`bridge/journal/`,
`/api/pane/:id/history`), a separate source from the mirror with different fidelity: turns and
their text, not a replay of the screen. The client fetches the whole conversation in one request
their text, not a replay of the screen. Each harness writes a different log in a different place,
so this is a **per-agent adapter** (`bridge/journal/registry.ts` maps the pane's `agent` to one);
a harness with no adapter simply has no journal. The client fetches the whole conversation in one request
and renders a window that grows upward, which is what lets find-in-history and jump-to-user-turn
work across turns you haven't scrolled to. Rationale and the measured numbers are commented at the
top of `web/src/routes/history.tsx`.
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to Collie are recorded here. The format follows
`version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by
`scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy.

## [0.19.0] - 2026-07-29

### Added
- **Journal (pane history) is now per-harness, with Codex and pi support.** Reading an agent's own session log is an adapter keyed on the pane's agent (`bridge/journal/`), so a new harness is an adapter rather than a fork of the reader — Codex reads its date-partitioned `rollout-*.jsonl`, pi its per-cwd session log. Raised in #40 by @simonallfrey, who asked where to implement journaling for Codex (7e3b2bd)
- **`scripts/journal-probe.ts`** probes every adapter against the real logs on the host — the format-drift check unit tests can't make. It caught Codex 0.145 adding a `developer` message role the parser would have rendered as operator speech (7e3b2bd)

### Fixed
- **pi could never have had history.** pi reports its session as a kind-`path` ref (an absolute path) and the bridge kept only kind-`id` refs, so a pi pane arrived with no session at all. Both kinds are kept now; a path ref is confined to that harness's root after symlink resolution (7e3b2bd)
- **A pane relaunched as a different agent served the previous agent's session ref.** Herdr keeps reporting the last session announced for a pane — a pane running pi still advertised a `herdr:claude` id. The ref is dropped unless its own `agent` matches the pane's (7e3b2bd)

### Changed
- **A pane's session reference no longer goes to the browser.** `/api/snapshot` sends `hasSession` instead — for pi the reference is a filesystem path, and the History affordance only ever needed "may this pane have history?". It is now also gated on the harness actually having an adapter (7e3b2bd)

## [0.18.0] - 2026-07-28

### Added
Expand Down
15 changes: 15 additions & 0 deletions HERDR_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,21 @@ Two sibling structural ops reorder objects. Both live-verified 2026-07-20 on the

`agent_status` ∈ `idle | working | blocked | done | unknown`. Panes without an agent omit/null `agent`.

> **`agent_session` has TWO kinds, and it can outlive the agent that reported it** (live-verified
> 2026-07-29 against claude, codex and pi panes). Each harness's herdr integration reports through
> `pane.report_agent_session`, and what it reports differs:
> - `kind:"id"` + a uuid — claude (`source:"herdr:claude"`) and codex (`source:"herdr:codex"`, from
> codex's `SessionStart` hook; verified on codex 0.145.0).
> - `kind:"path"` + an **absolute path to the log file** — pi (`source:"herdr:pi"`). pi's integration
> prefers `agent_session_path` over an id whenever its session manager has a file open.
>
> Two consequences. A harness only reports at all once `herdr integration install <agent>` has been
> run — pi's was missing on this host and the pane simply carried no session of its own. And Herdr
> keeps reporting the LAST session announced for a pane, so relaunching a pane's agent as a different
> harness leaves the previous one's ref behind: a pane running `pi` was observed still advertising a
> `herdr:claude` id. The record's own `agent` field is what distinguishes the two — compare it against
> the pane's `agent` before trusting the ref (`bridge/state-engine.ts`).

> **Pane records now carry `scroll`** (new in 0.7.2, live-verified 2026-07-07): `pane.list`,
> `pane.get`, `pane.current`, and `session.snapshot` panes all include
> `scroll: {offset_from_bottom, max_offset_from_bottom, viewport_rows} | null` (all `uint64`;
Expand Down
28 changes: 25 additions & 3 deletions bridge/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const KEYS = [
"COLLIE_READ_LINES",
"COLLIE_TRANSCRIPT",
"COLLIE_TRANSCRIPT_ROOT",
"COLLIE_CODEX_ROOT",
"COLLIE_PI_ROOT",
// Each harness's own home var participates in journal-root resolution, so the suite must own them
// too — otherwise a developer with CODEX_HOME set gets different results than CI.
"CODEX_HOME",
"PI_CODING_AGENT_DIR",
"COLLIE_SUBMIT_KEYS",
"COLLIE_TRUSTED_USER",
"COLLIE_DEVICE_HEADER",
Expand Down Expand Up @@ -60,7 +66,7 @@ describe("loadConfig", () => {
expect(cfg.readLines).toBe(200);
// Transcript history defaults ON — it's the only scrollback a Claude pane can ever have.
expect(cfg.transcript).toBe(true);
expect(cfg.transcriptRoot).toEndWith("/.claude/projects");
expect(cfg.journalRoots.claude).toEndWith("/.claude/projects");
expect(cfg.submitKeys).toEqual(["Enter"]);
expect(cfg.trustedUser).toBe("");
expect(cfg.allowedOrigins).toEqual([]);
Expand Down Expand Up @@ -127,9 +133,25 @@ describe("loadConfig", () => {
expect(loadConfig().transcript).toBe(true);
});

test("COLLIE_TRANSCRIPT_ROOT relocates the transcript root", () => {
// COLLIE_TRANSCRIPT_ROOT predates the per-harness split and meant Claude's root — it keeps meaning
// exactly that, so an existing deployment's env survives the change untouched.
test("COLLIE_TRANSCRIPT_ROOT relocates the CLAUDE journal root", () => {
process.env.COLLIE_TRANSCRIPT_ROOT = "/srv/claude/projects";
expect(loadConfig().transcriptRoot).toBe("/srv/claude/projects");
expect(loadConfig().journalRoots.claude).toBe("/srv/claude/projects");
});

test("each harness's own home var relocates its journal root", () => {
process.env.CODEX_HOME = "/srv/codex";
process.env.PI_CODING_AGENT_DIR = "/srv/pi";
const cfg = loadConfig();
expect(cfg.journalRoots.codex).toBe("/srv/codex/sessions");
expect(cfg.journalRoots.pi).toBe("/srv/pi/sessions");
});

test("an explicit COLLIE_* root beats the harness's home var", () => {
process.env.CODEX_HOME = "/srv/codex";
process.env.COLLIE_CODEX_ROOT = "/elsewhere/rollouts";
expect(loadConfig().journalRoots.codex).toBe("/elsewhere/rollouts");
});

test("reads the per-device auth header and allowlist", () => {
Expand Down
28 changes: 20 additions & 8 deletions bridge/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { homedir } from "node:os";
import { join } from "node:path";

import type { DialMode } from "./dial.ts";
import type { JournalRoots } from "./journal/registry.ts";

// All bridge configuration, resolved once at startup. Env-driven so the systemd unit and the
// plugin launcher can configure it without code changes. Defaults are safe for a single-user,
Expand Down Expand Up @@ -108,17 +109,17 @@ export interface Config {
readLines: number;
/**
* Serve agent conversation history from the agent's own on-disk session log. This is the only
* way to get scrollback for a Claude pane at all — Claude runs on the terminal's alternate
* way to get scrollback for most agent panes at all — they run on the terminal's alternate
* screen, which has no scrollback ring, so Herdr retains nothing behind the viewport (see
* transcript.ts). Off disables the feature and its route wholesale.
* journal/claude.ts). Off disables the feature and its route wholesale, for every harness.
*/
transcript: boolean;
/**
* Root of the agent's session logs — Claude Code's `~/.claude/projects`. Every transcript read is
* confined to this directory (after symlink resolution). Override only to relocate a non-default
* Claude home; it is never derived from a request.
* Where each harness keeps its session logs. Every read is confined to the matching root after
* symlink resolution, so these double as the security boundary for a feature that touches the
* filesystem — override only to relocate a non-default agent home, never from a request.
*/
transcriptRoot: string;
journalRoots: JournalRoots;
/** Key sequence sent to submit a reply after the text (agent-dependent; see HERDR_API.md). */
submitKeys: string[];
/**
Expand Down Expand Up @@ -213,8 +214,19 @@ export function loadConfig(): Config {
notifyDelayMs: envInt("COLLIE_NOTIFY_DELAY_MS", 30_000, { min: 0 }),
readLines: envInt("COLLIE_READ_LINES", 200, { min: 1 }),
transcript: envBool("COLLIE_TRANSCRIPT", true),
transcriptRoot:
process.env.COLLIE_TRANSCRIPT_ROOT ?? join(homedir(), ".claude", "projects"),
journalRoots: {
// COLLIE_TRANSCRIPT_ROOT predates the per-harness split and meant Claude's root, so it keeps
// meaning exactly that — an existing deployment's env keeps working untouched.
claude: process.env.COLLIE_TRANSCRIPT_ROOT ?? join(homedir(), ".claude", "projects"),
// Each harness's own home var is honoured first, so relocating the agent relocates its journal
// without a second Collie setting to keep in sync.
codex:
process.env.COLLIE_CODEX_ROOT ??
join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions"),
pi:
process.env.COLLIE_PI_ROOT ??
join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sessions"),
},
submitKeys: submitKeys.length ? submitKeys : ["Enter"],
trustedUser: process.env.COLLIE_TRUSTED_USER ?? "",
deviceHeader: (process.env.COLLIE_DEVICE_HEADER ?? "").trim(),
Expand Down
Binary file not shown.
Loading
Loading