From 726fba51ed068a1a4a88b5a541b9f25cad22f586 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:27:09 -0400 Subject: [PATCH 1/5] feat(resume): carry a session across models and machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aether resume` could replay a prior session's transcript to the screen, but the brain never saw a byte of it: continuing meant re-typing the story so far, and only ever on the machine where the work started. This makes the resume path mean what it says. A handoff (core/handoff.ts) is the machine-facing half of a session log — a small JSON document distilled from one run: the task, the model that ran it, the verify gate's verdict, the failing-test count, the files it changed, the verification command, and the repository identity (origin remote, branch, HEAD). - `aether agent --resume ` now prepends a continuation brief built from that record to the task the brain receives, so a different model picks the thread up with the project context in hand. With no new task, the run continues the original one (it used to fail with "nothing to do"). - `aether resume export [id] [--out ]` writes the handoff to a file. Nothing in it is keyed to an absolute path, so it can be copied to another checkout, machine, or OS, and `aether agent --resume ` continues there. A file reference is deliberately not workspace-scoped; a session id still is. - The handoff is a summary, not a transcript: no file contents, no shell commands, no credential-shaped values. Untrusted files are validated field by field on the way back in, and one written by a newer Agent is refused with an upgrade hint rather than half-read. Three fixes the above needed, each a bug in its own right: - `aether agent --local ""` spawned the separately-installed Python brain unconditionally, so a plain `npm i -g aether-agents` could only ever answer `spawn python ENOENT`. The one-shot offline path now drives the Ollama brain that ships inside the package — the same one the REPL's `--local` turns already used. `AETHER_LOCAL_BRAIN=python` opts back in, and the choice is a pure decision in core/backend.ts beside chooseBackend. - The session log's credential filter matched `pat` as a substring, so `path` (and PATH, patch, pattern) was stored as "[REDACTED]". Every log could say a file changed but not which one. `pat` is now anchored to a whole segment; pat, gh_pat and pat_token are still redacted. - The Ollama brain reported a placeholder `remaining: 1` on any unsuccessful run, so an unreachable-Ollama turn printed "1 test failing" when no test had run. The failing count comes only from the host's own verify run. `npm run demo:handoff` (scripts/handoff-demo.ts, docs/demo/handoff.md) is the end-to-end proof and takes about five seconds: it builds a throwaway git repo with two genuinely failing tests, runs the real CLI on model A until half the work is done, exports the handoff, creates a second checkout at a different path, deletes the first checkout AND its logs, then finishes the job on model B with `--resume ` and no restated task. The model — and only the model — is a scripted local stub so the run is deterministic and needs no download or account; AETHER_DEMO_REAL=1 runs the identical script against real Ollama models. It asserts that session B's prompt carried the brief, that `node --test` is green when run independently of the agent, and that the verify gate exited 0, so it works as a CI gate as well as a demo. --- COMMANDS.md | 38 +++- docs/demo/handoff.md | 106 +++++++++++ package.json | 4 +- scripts/handoff-demo.ts | 359 +++++++++++++++++++++++++++++++++++ src/commands/cli_registry.ts | 2 +- src/commands/code.ts | 74 ++++++-- src/commands/resume.ts | 66 ++++++- src/core/backend.ts | 21 ++ src/core/brain_ollama.ts | 5 +- src/core/handoff.ts | 319 +++++++++++++++++++++++++++++++ src/core/session_log.ts | 12 +- src/core/session_resume.ts | 4 + src/main.ts | 3 +- test/backend_select.test.ts | 27 ++- test/handoff.test.ts | 261 +++++++++++++++++++++++++ test/resume_cmd.test.ts | 156 ++++++++++++++- test/session_log.test.ts | 27 +++ 17 files changed, 1448 insertions(+), 36 deletions(-) create mode 100644 docs/demo/handoff.md create mode 100644 scripts/handoff-demo.ts create mode 100644 src/core/handoff.ts create mode 100644 test/handoff.test.ts diff --git a/COMMANDS.md b/COMMANDS.md index 46ab6e7..b75eca3 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -65,14 +65,15 @@ suggests `aether auth` and exits `2` instead of spending a turn on "auht". ### `aether code ""` — autonomous coding agent One host loop drives a pluggable brain: cloud (UVT-metered) by default, -`--local` for the Python/Ollama brain. The host renders every event, executes +`--local` for the built-in Ollama brain. The host renders every event, executes every tool call locally, and verifies the result itself — the final status is derived from your test command's exit code, never the brain's self-report. Every run ends with a verdict line: `✓ ok · 4 files changed · tests green · 3m12s`. | Flag | Meaning | |---|---| -| `--local` | Use the local brain (Python/Ollama) instead of the cloud. | +| `--local` | Use the built-in offline Ollama brain instead of the cloud. | +| `--resume ` | Continue a prior session id, or a handoff file from another machine. | | `--pool ` | Context pool size in GB (status-bar reach = pool × 233M tokens). | | `--effort ` | Effort tier: `LOW` \| `MED` \| `HIGH` \| `MAX` \| `ULTRA` \| `CODEPRO` (overrides the saved `/effort` dial). | | `--test-cmd ` | Command the verification gate runs (unverified without it). | @@ -90,16 +91,35 @@ aether run kronus "audit this service for race conditions and fix them" ``` > Orchestrators are gated to paid tiers. Neo is available on Solo+; Kronus on Pro+. -### `aether resume [id]` — replay a session +### `aether resume [id | export [id]]` — replay or carry a session Replays a prior local coding session's transcript from `~/.aether-agent/logs/`. -With no id, resumes the most recent session. +With no id, uses the most recent session in this workspace. ```bash -aether resume # the latest session -aether resume # a specific session -aether agent --resume "" # resume, then continue working +aether resume # replay the latest session +aether resume # replay a specific one +aether resume export # write ./aether-handoff.json +aether resume export --out h.json # …from a specific session, to a path ``` +`export` writes a **handoff**: one portable JSON file carrying the task, the +model that ran it, the verify gate's verdict, the failing-test count, the files +the run changed, the verification command, and the repository identity (origin +remote, branch, HEAD). It carries no file contents, no shell commands, and no +absolute paths, so it can be copied to another checkout, machine, or OS. + +Continue from either form: +```bash +aether agent --resume "" # same machine +aether agent --resume ./handoff.json # anywhere else +aether agent --resume ./handoff.json --model # …on another model +``` +With no new task, the run continues the **original** task. Either way the prior +context is summarized into a continuation brief that the brain reads before its +instruction — you never re-paste the conversation. See +[`docs/demo/handoff.md`](docs/demo/handoff.md) for a runnable end-to-end proof. + > Local-first: sessions are read from disk, so resume works offline. When you stop > a coding run with Ctrl-C, the exact `aether agent --resume ` command is printed. +> A session id is workspace-scoped; a handoff file deliberately is not. ### `aether models [use ]` — list / pick a model - `aether models` — list every model **and** orchestrator visible to your tier. @@ -381,6 +401,10 @@ Requires an active orchestrator — switch with `/agent neo` or `/agent kronus` | `AETHER_LOGIN_URL` | `https://aethersystems.net/platform` | Page `aether auth login` opens. | | `AETHER_TOKEN` | *(unset)* | Inject a session token (CI / headless / embedding). | | `AETHER_CONFIG_DIR` | `~/.config/aether` | Config + token + REPL-history directory. | +| `AETHER_LOG_DIR` | `~/.aether-agent/logs` | Where session logs (and therefore `aether resume`) live. | +| `AETHER_BACKEND` | `auto` | `local` \| `cloud` \| `auto` — overrides the config `backend`. | +| `AETHER_LOCAL_BRAIN` | *(unset)* | `python` runs the separately-installed Unlimited-Context brain instead of the built-in Ollama one. | +| `OLLAMA_HOST` | `http://localhost:11434` | Where the offline brain looks for Ollama. | | `AETHER_STREAM_TIMEOUT_MS` | `120000` | Stream open/idle timeout (ms). `0` disables it. | | `AETHER_NO_ANIM` | *(unset)* | `1` disables all animated status lines and the thinking pulse. | | `NO_COLOR` | *(unset)* | Any value disables ANSI colors (https://no-color.org). | diff --git a/docs/demo/handoff.md b/docs/demo/handoff.md new file mode 100644 index 0000000..e36a8a6 --- /dev/null +++ b/docs/demo/handoff.md @@ -0,0 +1,106 @@ +# The handoff demo + +> Start a task on one model. Finish it on another, on another machine. +> Your tests decide when it's done. + +```bash +npm run demo:handoff +``` + +This is the reproducible proof behind that sentence, and the script a screen +recording should follow. It runs in about five seconds and needs nothing but a +built checkout — no account, no model download, no network. + +## What it does + +1. **Machine A.** Builds a throwaway git repo (`slugify`, with an `origin` + remote) containing two genuinely failing tests, and runs the real CLI over it + on model A with `--test-cmd`. The session gets half the job done: lowercasing + and hyphenation land, the whitespace case stays red. The verify gate re-runs + the tests itself and marks the run `incomplete`; the process exits non-zero. +2. **The handoff.** `aether resume export --out handoff.json` distils the + session log into one portable file — the task, the model that ran it, the + verdict, the files that changed, the verification command, and the repository + identity. +3. **Moving machines.** A second checkout is created at a different absolute + path, and machine A's checkout **and its session logs are deleted**. Nothing + the next step does can be quietly reading them, because they no longer exist. +4. **Machine B.** The CLI runs in the second checkout on model B with + `--resume handoff.json` and **no restated task**. The handoff is the only + context it is given. It finishes the job. +5. **Proof.** Three independent checks, all of which must hold: + - the scripted model records session B's first prompt, and it must contain + the continuation brief naming model A and `src/slug.js`; + - `node --test` is run directly by the demo script, outside the agent, and + must be green; + - the CLI's own verify gate must have exited 0. + +Any failure prints `FAILED` with the reasons and exits non-zero, so the script +works as a CI gate as well as a demo. + +## What is real and what is stubbed + +Real: the `aether` CLI, the git repositories, the file edits, the tool +permission gate, the session log, the handoff file, `node --test`, and the +verify gate. + +Stubbed by default: **the model, and only the model**. A local HTTP server +speaks Ollama's OpenAI-compatible chat endpoint with scripted tool calls. That +is what makes the run byte-deterministic — a 4B model asked to fix a bug does +something slightly different every time, which is fine for a product and useless +for a gate. + +To run the identical script against real models: + +```bash +# needs `ollama serve` and both tags pulled +AETHER_DEMO_REAL=1 npm run demo:handoff + +AETHER_DEMO_MODEL_A=qwen2.5-coder:7b \ +AETHER_DEMO_MODEL_B=qwen3:4b \ +AETHER_DEMO_REAL=1 npm run demo:handoff +``` + +In real mode the models decide what to do, so the transcript varies and the run +can legitimately fail — that is the honest shape of a small local model on a +real task. The verify gate still has the last word either way. + +The demo never touches your real configuration: it points `AETHER_CONFIG_DIR` +and `AETHER_LOG_DIR` at a temporary directory, so your token, config, and +session history are untouched, and everything it created is removed on exit. + +## Recording it + +The sequence below is the 20–45 second version, readable with the sound off. +Nothing here is staged: every frame is the script's own output. + +| Beat | Seconds | On screen | +|---|---|---| +| 1. The task | 0–6 | `aether agent --model "make the slugify tests pass"` — the agent reads, edits, runs the tests | +| 2. Not done | 6–12 | the red verdict line: `✗ incomplete · tests failing` | +| 3. The handoff | 12–18 | `aether resume export --out handoff.json` and the `⇄ handoff written` line | +| 4. Moving | 18–24 | `cd` into the second checkout; `rm -rf` the first one | +| 5. Continue | 24–36 | `aether agent --model --resume handoff.json` — no task typed, the agent picks up where A stopped | +| 6. Done | 36–45 | the green verdict line: `✓ ok · tests green` | + +Capture: + +```bash +# 1. build, so the run is instant on camera +npm ci && npm run build + +# 2. set the terminal to 100x30 and record +asciinema rec handoff.cast -c "npm run demo:handoff" + +# 3. or, for a GIF +# (agg is asciinema's own renderer: https://github.com/asciinema/agg) +agg --font-size 18 --theme dracula handoff.cast handoff.gif +``` + +`AETHER_NO_ANIM=1` is set inside the demo for the child processes, so the output +is stable text rather than a repainting status line — which is what you want for +a GIF. For a live-feel recording of the product itself, run the two `aether` +commands by hand instead, with animation on. + +Do not re-time or re-cut the verdict lines. The whole point of the last beat is +that a test run, not a model, decided it. diff --git a/package.json b/package.json index e3d9e20..5d95ac9 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "smoke": "npm run build && node -e \"import('./dist/src/core/smoke.js').then(m=>m.smokeMain()).then(c=>process.exit(c)).catch(e=>{console.error(e);process.exit(1)})\"", "verify:production": "npm run build && node dist/scripts/verify-production.js", "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", - "prepack": "npm run build" + "prepack": "npm run build", + "demo:handoff": "npm run build && node dist/scripts/handoff-demo.js" }, "keywords": [ "aether", @@ -72,4 +73,3 @@ "typescript": "^7.0.2" } } - diff --git a/scripts/handoff-demo.ts b/scripts/handoff-demo.ts new file mode 100644 index 0000000..46ddc2c --- /dev/null +++ b/scripts/handoff-demo.ts @@ -0,0 +1,359 @@ +// scripts/handoff-demo.ts — the hero demo, end to end and reproducible. +// +// npm run demo:handoff +// +// It proves one sentence: **start on one model, continue on another, on another +// machine, and let the tests decide when it is done.** +// +// The demo builds a throwaway git repo with a genuinely failing test, runs the +// real `aether` CLI over it on model A, exports a handoff file, sets up a SECOND +// checkout at a different absolute path (machine B), deletes the first one, and +// runs the CLI there on model B with `--resume ` and no restated +// context. The host's verify gate — not the model — decides the final verdict. +// +// What is real: the CLI, the git repos, the file edits, the tool permission +// gate, the handoff file, `node --test`, and the verify gate. +// +// What is stubbed BY DEFAULT: the model. A tiny local HTTP server speaks the +// Ollama OpenAI-compatible API with scripted replies, so the run is +// byte-deterministic and needs no download, no GPU, and no account — which is +// what makes it usable as a CI gate. The stub also ASSERTS what it was asked: +// session B's first request must contain the continuation brief, naming session +// A's model and the file it touched. That assertion is the actual proof; if the +// context did not cross, the demo fails. +// +// To run the identical script against real models instead (this is what a +// screen recording should show): +// +// AETHER_DEMO_REAL=1 npm run demo:handoff +// AETHER_DEMO_MODEL_A=qwen2.5-coder:7b AETHER_DEMO_MODEL_B=qwen3:4b \ +// AETHER_DEMO_REAL=1 npm run demo:handoff +// +// Real mode needs Ollama running with those tags pulled. The models then decide +// what to do, so the transcript varies — the verify gate still has the last word. + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { spawn, spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "..", ".."); +const cli = join(repoRoot, "dist", "src", "main.js"); + +const REAL = process.env["AETHER_DEMO_REAL"] === "1"; +const MODEL_A = process.env["AETHER_DEMO_MODEL_A"] ?? (REAL ? "qwen2.5-coder:7b" : "aether-demo-a"); +const MODEL_B = process.env["AETHER_DEMO_MODEL_B"] ?? (REAL ? "qwen3:4b" : "aether-demo-b"); +const TEST_CMD = "node --test test/slug.test.js"; + +// ── the throwaway project ─────────────────────────────────────────────────── +// Two assertions, both red at the start. The point of splitting them is that a +// session can legitimately land half-done — which is exactly the state a handoff +// has to carry. + +const BROKEN_SOURCE = `export function slugify(input) { + return input; +} +`; + +const HALF_FIXED_SOURCE = `export function slugify(input) { + return input.toLowerCase().replace(/\\s+/g, "-"); +} +`; + +const FIXED_SOURCE = `export function slugify(input) { + return input.trim().toLowerCase().replace(/\\s+/g, "-"); +} +`; + +const TEST_SOURCE = `import test from "node:test"; +import assert from "node:assert/strict"; +import { slugify } from "../src/slug.js"; + +test("lowercases and hyphenates", () => { + assert.equal(slugify("Hello World"), "hello-world"); +}); + +test("trims surrounding whitespace", () => { + assert.equal(slugify(" Release Notes "), "release-notes"); +}); +`; + +const TASK = "make the slugify tests pass"; + +function git(cwd: string, args: string[]): void { + const r = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr || r.stdout}`); +} + +/** A fresh checkout of the demo project, with both tests red. */ +function makeProject(dir: string): void { + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, "test"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "slugify-demo", type: "module", private: true }, null, 2) + "\n"); + writeFileSync(join(dir, "src", "slug.js"), BROKEN_SOURCE); + writeFileSync(join(dir, "test", "slug.test.js"), TEST_SOURCE); + git(dir, ["init", "-q", "-b", "main"]); + git(dir, ["config", "user.email", "demo@aethersystems.net"]); + git(dir, ["config", "user.name", "Aether Demo"]); + git(dir, ["remote", "add", "origin", "https://github.com/aether-demo/slugify.git"]); + git(dir, ["add", "-A"]); + git(dir, ["commit", "-q", "-m", "slugify: both cases red"]); +} + +// ── the scripted model ────────────────────────────────────────────────────── + +interface ScriptedTurn { + /** Emit this tool call... */ + tool?: { name: string; args: Record }; + /** ...or this final answer (no tool call ends the turn). */ + content?: string; +} + +const SCRIPTS: Record = { + [MODEL_A]: [ + { tool: { name: "read_file", args: { path: "src/slug.js" } } }, + { tool: { name: "write_file", args: { path: "src/slug.js", content: HALF_FIXED_SOURCE } } }, + { tool: { name: "run_tests", args: { command: TEST_CMD } } }, + { content: "Lowercasing and hyphenation are in. The surrounding-whitespace case is still red." }, + ], + [MODEL_B]: [ + { tool: { name: "read_file", args: { path: "src/slug.js" } } }, + { tool: { name: "write_file", args: { path: "src/slug.js", content: FIXED_SOURCE } } }, + { tool: { name: "run_tests", args: { command: TEST_CMD } } }, + { content: "Trimmed the input before slugifying. Both cases pass." }, + ], +}; + +interface StubState { + /** Turns served, per model. */ + turns: Map; + /** The first user message each model was given — the continuity evidence. */ + firstPrompt: Map; + failures: string[]; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((res, rej) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => res(body)); + req.on("error", rej); + }); +} + +/** A minimal Ollama-compatible endpoint that replays SCRIPTS. */ +function startStub(state: StubState): Promise<{ server: Server; host: string }> { + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + void (async () => { + const body = JSON.parse((await readBody(req)) || "{}") as { + model?: string; + messages?: Array<{ role: string; content: string }>; + }; + const model = body.model ?? ""; + const script = SCRIPTS[model]; + if (!script) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: `no script for model ${model}` })); + return; + } + if (!state.firstPrompt.has(model)) { + const user = (body.messages ?? []).find((m) => m.role === "user"); + state.firstPrompt.set(model, user?.content ?? ""); + } + const n = state.turns.get(model) ?? 0; + state.turns.set(model, n + 1); + const turn = script[Math.min(n, script.length - 1)]!; + const message = turn.tool + ? { + role: "assistant", + content: "", + tool_calls: [ + { + id: `call-${model}-${n}`, + type: "function", + function: { name: turn.tool.name, arguments: JSON.stringify(turn.tool.args) }, + }, + ], + } + : { role: "assistant", content: turn.content ?? "done" }; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ choices: [{ message }] })); + })().catch((err: unknown) => { + state.failures.push(String(err)); + res.writeHead(500, { "content-type": "application/json" }); + res.end("{}"); + }); + }); + return new Promise((res) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + res({ server, host: `http://127.0.0.1:${port}` }); + }); + }); +} + +// ── running the real CLI ──────────────────────────────────────────────────── + +interface RunResult { + status: number; + stdout: string; + stderr: string; +} + +/** Run the CLI ASYNCHRONOUSLY. This must not be spawnSync: the scripted model + * is served by this same process, and a synchronous spawn blocks the event + * loop, so the agent's very first request would never be answered. */ +function runCli(args: string[], cwd: string, env: Record): Promise { + return new Promise((res, rej) => { + const child = spawn(process.execPath, [cli, ...args], { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (c: string) => (stdout += c)); + child.stderr.on("data", (c: string) => (stderr += c)); + child.on("error", rej); + child.on("close", (status) => res({ status: status ?? 1, stdout, stderr })); + }); +} + +function testsPass(cwd: string): boolean { + return spawnSync(process.execPath, ["--test", "test/slug.test.js"], { cwd, encoding: "utf8" }).status === 0; +} + +const banner = (text: string): void => { + process.stdout.write(`\n\x1b[36m── ${text} ${"─".repeat(Math.max(0, 66 - text.length))}\x1b[0m\n`); +}; +const say = (text: string): void => { + process.stdout.write(` ${text}\n`); +}; + +async function main(): Promise { + const root = mkdtempSync(join(tmpdir(), "aether-handoff-demo-")); + const machineA = join(root, "machine-a", "slugify"); + const machineB = join(root, "machine-b", "slugify"); + const logs = join(root, "logs"); + const config = join(root, "config"); + mkdirSync(logs, { recursive: true }); + mkdirSync(config, { recursive: true }); + // permissionMode "skip" keeps the demo non-interactive without --yes, which + // would also trigger the interactive repo gate. A private AETHER_CONFIG_DIR + // means the demo never reads or writes the operator's real config or token. + writeFileSync(join(config, "config.json"), JSON.stringify({ permissionMode: "skip", backend: "local" }, null, 2) + "\n"); + + const state: StubState = { turns: new Map(), firstPrompt: new Map(), failures: [] }; + let server: Server | null = null; + let ollamaHost = process.env["OLLAMA_HOST"] ?? "http://localhost:11434"; + if (!REAL) { + const stub = await startStub(state); + server = stub.server; + ollamaHost = stub.host; + } + + const env = (): Record => ({ + AETHER_CONFIG_DIR: config, + AETHER_LOG_DIR: logs, + AETHER_BACKEND: "local", + AETHER_NO_ANIM: "1", + AETHER_NO_HISTORY: "1", + OLLAMA_HOST: ollamaHost, + }); + + const problems: string[] = []; + try { + banner(`Machine A — ${MODEL_A}${REAL ? "" : " (scripted)"}`); + makeProject(machineA); + say(`repo: ${machineA}`); + say(`task: ${TASK}`); + const a = await runCli( + ["agent", "--local", "--model", MODEL_A, "--quiet", "--test-cmd", TEST_CMD, TASK], + machineA, + env(), + ); + process.stdout.write(a.stdout); + process.stdout.write(a.stderr); + if (!REAL && a.status === 0) problems.push("session A was expected to end RED (half the work done), but exited 0"); + + banner("The handoff"); + const handoffFile = join(root, "handoff.json"); + const exported = await runCli(["resume", "export", "--out", handoffFile], machineA, env()); + process.stdout.write(exported.stdout); + process.stdout.write(exported.stderr); + if (exported.status !== 0) problems.push("`aether resume export` failed"); + const handoff = JSON.parse(readFileSync(handoffFile, "utf8")) as { + model?: string; + finalStatus?: string; + filesTouched?: string[]; + repo?: { remote?: string }; + }; + say(`carried: model ${handoff.model}, status ${handoff.finalStatus}, ` + + `files ${JSON.stringify(handoff.filesTouched)}, repo ${handoff.repo?.remote ?? "(none)"}`); + + banner("Moving machines"); + // A different absolute path, from the same origin — the shape a second + // machine actually has. Machine A is then destroyed: nothing the second run + // does can be reading its logs, because they are gone. + mkdirSync(dirname(machineB), { recursive: true }); + cpSync(machineA, machineB, { recursive: true }); + rmSync(machineA, { recursive: true, force: true }); + rmSync(logs, { recursive: true, force: true }); + mkdirSync(logs, { recursive: true }); + say(`repo: ${machineB}`); + say("machine A's checkout and session logs: deleted"); + + banner(`Machine B — ${MODEL_B}${REAL ? "" : " (scripted)"}`); + say("no task restated; the handoff file is the only context"); + const b = await runCli( + ["agent", "--local", "--model", MODEL_B, "--quiet", "--test-cmd", TEST_CMD, "--resume", handoffFile], + machineB, + env(), + ); + process.stdout.write(b.stdout); + process.stdout.write(b.stderr); + + banner("Proof"); + // 1. The context crossed — session B's prompt carried the brief. + const bPrompt = REAL ? "" : (state.firstPrompt.get(MODEL_B) ?? ""); + if (!REAL) { + for (const needle of ["Continuing a prior Aether Agent session", MODEL_A, "src/slug.js", TASK]) { + if (!bPrompt.includes(needle)) problems.push(`session B's prompt never mentioned ${JSON.stringify(needle)}`); + } + say(`session B's prompt carried the brief (${bPrompt.length} chars), naming ${MODEL_A} and src/slug.js`); + } + // 2. The work is actually done — checked here, independently of the agent. + const green = testsPass(machineB); + say(`independent test run in ${machineB}: ${green ? "green" : "RED"}`); + if (!green) problems.push("the demo project's tests are still failing after session B"); + // 3. The CLI's own verify gate agrees. + if (b.status !== 0) problems.push(`session B exited ${b.status}; the verify gate did not call it done`); + else say("the verify gate called it done (exit 0)"); + problems.push(...state.failures); + } finally { + server?.close(); + rmSync(root, { recursive: true, force: true }); + } + + banner(problems.length ? "FAILED" : "PASSED"); + for (const p of problems) process.stdout.write(` ✗ ${p}\n`); + if (!problems.length) { + process.stdout.write(" One model started it. Another finished it, elsewhere, with no re-pasted context.\n"); + process.stdout.write(" The tests decided when it was done.\n"); + } + return problems.length ? 1 : 0; +} + +main().then( + (code) => process.exit(code), + (err: unknown) => { + process.stderr.write(String(err instanceof Error ? (err.stack ?? err.message) : err) + "\n"); + process.exit(1); + }, +); diff --git a/src/commands/cli_registry.ts b/src/commands/cli_registry.ts index dcda2c3..2614f55 100644 --- a/src/commands/cli_registry.ts +++ b/src/commands/cli_registry.ts @@ -5,7 +5,7 @@ export const CLI_COMMANDS: CommandSpec[] = [ { name: "help", args: "[command]", summary: "show grouped help or command detail", section: "Start" }, { name: "agent", aliases: ["code"], args: "[task]", summary: "run the coding agent or open its REPL", section: "Start" }, { name: "chat", args: "[prompt]", summary: "start chat or send one prompt", section: "Start" }, - { name: "resume", args: "[session-id]", summary: "resume a scoped local session", section: "Start" }, + { name: "resume", args: "[session-id|export [id] --out ]", summary: "replay a local session, or export it as a portable handoff", section: "Start" }, { name: "run", args: " ", summary: "stream an orchestrator run", section: "Start" }, { name: "models", args: "[use ]", summary: "list models or set the default", section: "Start" }, { name: "agents", summary: "list available orchestrators", section: "Start" }, diff --git a/src/commands/code.ts b/src/commands/code.ts index 11382d7..b106d4c 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -12,6 +12,7 @@ import type { Brain, TaskCommand } from "../core/brain.js"; import type { BrainEvent } from "../core/brain_protocol.js"; import type { ToolResult } from "../core/tool_executor.js"; import { LocalBrain } from "../core/brain_local.js"; +import { OllamaBrain } from "../core/brain_ollama.js"; import { CloudBrain } from "../core/brain_cloud.js"; import { ToolExecutor } from "../core/tool_executor.js"; import { stdioPrompt } from "../ui/interact.js"; @@ -35,10 +36,11 @@ import { writeDiffLines, } from "./code_support.js"; import { loadSession, replayLines } from "../core/session_resume.js"; +import { continuationTask, isHandoffPath, resolveHandoff, type Handoff } from "../core/handoff.js"; import { resumeHint } from "./resume.js"; import { createWorktree, mergeHint, type Worktree } from "../core/worktree.js"; import { parseRepoSpec, ensureLocalClone, prCreateHint, type RepoSpec } from "../core/repo.js"; -import { chooseBackend } from "../core/backend.js"; +import { chooseBackend, chooseLocalBrain } from "../core/backend.js"; import { decideGate } from "../core/autonomy.js"; export { prepareWorkspace } from "./code_support.js"; @@ -63,7 +65,9 @@ export interface CodeOpts { noLog?: boolean; /** Number of swarm workers (gated — see the swarm guard below). */ swarm?: number; - /** Resume a prior local session id: replay its transcript before this run. */ + /** Continue a prior session: a local session id, or the path to a handoff + * file exported from another machine. The prior context is summarized into + * the brief the brain reads (core/handoff.ts), not just replayed on screen. */ resume?: string; /** Isolate the run in a fresh git worktree on an auto-named branch. */ worktree?: boolean; @@ -88,11 +92,19 @@ export function applyEventToStatus( } } -/** Replay a prior local session's transcript into the active surface. Fail-soft: - * a missing/unreadable session prints a note and does not abort the new run. */ -function replaySession(id: string, cwd: string, emit: (line: string) => void): void { +/** Show the human what is being continued. A local session id replays its full + * transcript; a handoff FILE has no transcript to replay (that is the point — + * it is a summary that survived the trip), so its highlights stand in. + * Fail-soft: an unreadable session prints a note and never aborts the new run. */ +function replaySession(ref: string, cwd: string, handoff: Handoff | null, emit: (line: string) => void): void { + if (isHandoffPath(ref)) { + if (!handoff) return; + emit(`⇄ continuing ${handoff.sessionId} (${handoff.finalStatus}) from ${ref}`); + for (const line of handoff.highlights) emit(" " + line); + return; + } try { - const prior = loadSession(id, logsRoot(), cwd); + const prior = loadSession(ref, logsRoot(), cwd); for (const line of replayLines(prior.events)) emit(line); } catch (err) { process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); @@ -100,10 +112,24 @@ function replaySession(id: string, cwd: string, emit: (line: string) => void): v } export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Promise { - if (!task.trim()) { + // --resume carries the prior session's context forward, so it is also a task + // of its own: with no new instruction the run continues the ORIGINAL task. + let handoff: Handoff | null = null; + if (opts.resume) { + try { + handoff = resolveHandoff(opts.resume, ctx.flags.cwd); + } catch (err) { + process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + return 1; + } + } + if (!task.trim() && !handoff) { process.stderr.write('✗ nothing to do — try: aether agent "fix the failing tests"\n'); return 1; } + // What the run is CALLED (worktree branch, session manifest, summary) stays + // the human-sized instruction; the brief below is what the brain reads. + const label = task.trim() || handoff!.task; // Swarm is GATED on purpose: never swarm an unproven loop — N agents multiply // the #1 failure (tool-call emission fraying). It is also LOCAL-ONLY (the cloud // path has its own orchestration). Stays gated until the single-agent loop is @@ -152,7 +178,7 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr } } try { - worktree = createWorktree(repoRoot, task); + worktree = createWorktree(repoRoot, label); process.stderr.write(`⌥ worktree ${worktree.branch}\n ${worktree.dir}\n`); } catch (err) { process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); @@ -160,7 +186,7 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr } cwd = worktree.dir; } else { - const ws = await prepareWorkspace(ctx, task, io, defaultRunner()); + const ws = await prepareWorkspace(ctx, label, io, defaultRunner()); if (!ws.proceed) return 0; cwd = ws.cwd; } @@ -176,7 +202,17 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr } const brainKind: "local" | "cloud" = goLocal ? "local" : "cloud"; - const brain: Brain = goLocal ? new LocalBrain() : new CloudBrain(ctx.api); + // The offline path drives the SAME Ollama brain the REPL's `--local` turns + // already use (commands/chat.ts runLocalTurn) — pure TypeScript, shipped in + // the npm package, no extra runtime. The headless Python brain is a separate + // install, so it is opt-in through AETHER_LOCAL_BRAIN=python; before this the + // one-shot form spawned it unconditionally and a plain npm install could only + // ever answer `spawn python ENOENT`. + const brain: Brain = goLocal + ? chooseLocalBrain(process.env["AETHER_LOCAL_BRAIN"]) === "python" + ? new LocalBrain() + : new OllamaBrain() + : new CloudBrain(ctx.api); const exec = new ToolExecutor(cwd, opts.testCmd); // Scope the session manifest to the ORIGINAL launch directory (ctx.flags.cwd), // not the possibly-substituted `cwd` (an auto-created worktree, or a manually @@ -186,7 +222,14 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr const log = opts.noLog ? null : new SessionLog( - { task, model: ctx.flags.model ?? "", poolGb, brain: brainKind, cwd: ctx.flags.cwd }, + { + task: label, + model: ctx.flags.model ?? "", + poolGb, + brain: brainKind, + cwd: ctx.flags.cwd, + ...(opts.testCmd ? { testCmd: opts.testCmd } : {}), + }, nowIso(), ); @@ -201,7 +244,10 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr const taskCmd: TaskCommand = { type: "task", - text: task, + // On a resume the brain reads the prior session's continuation brief FIRST, + // then the new instruction — that, and not the on-screen replay, is what + // lets a different model (or a different machine) pick the thread up. + text: handoff ? continuationTask(handoff, task) : task, cwd, poolGb, // --effort wins; otherwise the /effort dial saved in the Aether config @@ -275,7 +321,7 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr if (animated) { const sr = new StatusRenderer({ mode: brainKind === "local" ? "local" : "api" }); sr.start(); - if (opts.resume) replaySession(opts.resume, ctx.flags.cwd, (line) => sr.log(line)); + if (opts.resume) replaySession(opts.resume, ctx.flags.cwd, handoff, (line) => sr.log(line)); const anim = new AnimationController({ onFrame: (_stage, art) => sr.setAnim(art), onProgress: (used, c) => sr.setProgress(used, c), @@ -332,7 +378,7 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr }; } else { const renderer = new HostRenderer({ poolGb, quiet: opts.quiet, json: ctx.flags.json }); - if (opts.resume) replaySession(opts.resume, ctx.flags.cwd, (line) => process.stdout.write(line + "\n")); + if (opts.resume) replaySession(opts.resume, ctx.flags.cwd, handoff, (line) => process.stdout.write(line + "\n")); onEvent = async (ev: BrainEvent): Promise => { applyToLedger(ledger, ev); trackWrites(ev); diff --git a/src/commands/resume.ts b/src/commands/resume.ts index c00536c..be196fb 100644 --- a/src/commands/resume.ts +++ b/src/commands/resume.ts @@ -1,9 +1,16 @@ // `aether resume [id]` — replay a prior local session's transcript and show how // to continue it. With no id, resumes the most recent session. +// +// `aether resume export [id] [--out ]` writes the same session out as a +// portable handoff (core/handoff.ts): the file you copy to another machine so +// `aether agent --resume ` can carry the project context across. +import { join } from "node:path"; import type { AppContext } from "../core/context.js"; -import { loadSession, latestSession, replayLines } from "../core/session_resume.js"; +import { loadSession, latestSession, replayLines, type LoadedSession } from "../core/session_resume.js"; import { logsRoot } from "../core/session_log.js"; +import { buildHandoff, readRepoIdentity, writeHandoff } from "../core/handoff.js"; +import { defaultRunner } from "../core/worktree.js"; import { theme } from "../ui/theme.js"; /** The exact command a paused session can be re-entered with. */ @@ -11,24 +18,69 @@ export function resumeHint(sessionId: string): string { return `session paused — resume with: aether agent --resume ${sessionId}`; } -export async function cmdResume(ctx: AppContext, id: string): Promise { - let s; +/** Default filename for `aether resume export` when --out is not given. */ +export const DEFAULT_HANDOFF_FILE = "aether-handoff.json"; + +/** Load by id, or the newest session in this workspace when no id is given. */ +function pick(ctx: AppContext, id: string): LoadedSession | null { + return id ? loadSession(id, logsRoot(), ctx.flags.cwd) : latestSession(ctx.flags.cwd); +} + +function noSessions(): number { + process.stderr.write('no sessions to resume (run `aether agent ""` first)\n'); + return 1; +} + +/** `aether resume export [id] [--out ]`. */ +function cmdResumeExport(ctx: AppContext, id: string, out: string | undefined): number { + let s: LoadedSession | null; try { - s = id ? loadSession(id, logsRoot(), ctx.flags.cwd) : latestSession(ctx.flags.cwd); + s = pick(ctx, id); } catch (err) { process.stderr.write(String(err instanceof Error ? err.message : err) + "\n"); return 1; } - if (!s) { - process.stderr.write('no sessions to resume (run `aether agent ""` first)\n'); + if (!s) return noSessions(); + const target = out?.trim() ? out.trim() : join(ctx.flags.cwd, DEFAULT_HANDOFF_FILE); + const handoff = buildHandoff(s, { repo: readRepoIdentity(ctx.flags.cwd, defaultRunner()) }); + try { + writeHandoff(target, handoff); + } catch (err) { + process.stderr.write(`✗ could not write ${target}: ${err instanceof Error ? err.message : err}\n`); + return 1; + } + process.stdout.write( + `${theme.cyan("⇄ handoff written")} ${theme.bold(target)}\n` + + theme.dim( + ` session ${handoff.sessionId} · ${handoff.finalStatus} · ` + + `${handoff.filesTouched.length} file(s) · ${handoff.highlights.length} step(s)\n`, + ) + + theme.dim(" continue anywhere with: ") + + `aether agent --resume ${target} ""\n`, + ); + return 0; +} + +export async function cmdResume(ctx: AppContext, id: string, out?: string): Promise { + if (id === "export") return cmdResumeExport(ctx, "", out); + if (id.startsWith("export ")) return cmdResumeExport(ctx, id.slice("export ".length).trim(), out); + let s; + try { + s = pick(ctx, id); + } catch (err) { + process.stderr.write(String(err instanceof Error ? err.message : err) + "\n"); return 1; } + if (!s) return noSessions(); process.stdout.write(theme.dim(`▸ ${s.manifest.sessionId} · ${s.manifest.task}\n\n`)); for (const line of replayLines(s.events)) process.stdout.write(line + "\n"); process.stdout.write( "\n" + theme.dim(`status: ${s.manifest.finalStatus ?? "running"} · continue with: `) + - `aether agent --resume ${s.manifest.sessionId}\n`, + `aether agent --resume ${s.manifest.sessionId}\n` + + theme.dim(" moving machines? ") + + `aether resume export ${s.manifest.sessionId}\n`, ); return 0; } + diff --git a/src/core/backend.ts b/src/core/backend.ts index 28affb6..95f6dfe 100644 --- a/src/core/backend.ts +++ b/src/core/backend.ts @@ -21,3 +21,24 @@ export function chooseBackend(backend: string, authed: boolean): BackendPath { // 'auto' and any garbage value: route on auth state (cloud when signed in). return authed ? "cloud" : "local"; } + +export type LocalBrainKind = "ollama" | "python"; + +/** + * Resolve WHICH local brain drives an offline run. + * + * Two local brains exist. The Ollama brain (core/brain_ollama.ts) is pure + * TypeScript, ships inside the npm package, and needs nothing but Node and a + * running Ollama — it is what `aether agent --local` has always used in the + * REPL. The headless Python brain (core/brain_local.ts) spawns + * `python -m aether_agent.headless`, which is a SEPARATE install that the npm + * package does not carry; asking for it when it is absent fails with + * "spawn python ENOENT". + * + * So the shipped brain is the default and Python is opt-in, via + * AETHER_LOCAL_BRAIN=python (any other value, including unset, means ollama). + * Pure so the matrix is unit-testable. + */ +export function chooseLocalBrain(pref: string | undefined): LocalBrainKind { + return (pref ?? "").trim().toLowerCase() === "python" ? "python" : "ollama"; +} diff --git a/src/core/brain_ollama.ts b/src/core/brain_ollama.ts index a1c2187..1449065 100644 --- a/src/core/brain_ollama.ts +++ b/src/core/brain_ollama.ts @@ -181,7 +181,10 @@ export class OllamaBrain implements Brain { type: "done", ok, result: result || (ok ? "done" : "stopped"), - remaining: ok ? 0 : 1, + // The brain does not count failing tests — the host's verify gate does, + // from a real test run. Reporting a placeholder 1 here made an + // unreachable-Ollama run print "1 test failing" when no test had run. + remaining: 0, reason, }); this.queue.end(); diff --git a/src/core/handoff.ts b/src/core/handoff.ts new file mode 100644 index 0000000..fbf110a --- /dev/null +++ b/src/core/handoff.ts @@ -0,0 +1,319 @@ +// src/core/handoff.ts — the portable continuation record. +// +// A session log (session_log.ts) is the human's record of one run: append-only +// JSONL plus a rendered monologue, keyed to one absolute working directory on +// one machine. `aether resume` could already REPLAY it to the screen, but the +// brain never saw a byte of it — resuming meant re-typing the story so far. +// +// A handoff is the machine-facing half of that record: a small, self-contained +// JSON document distilled from a session (what the task was, which model ran +// it, what it touched, whether the tests were green, what is still failing) +// plus the repository identity it belongs to. Two things follow: +// +// 1. `aether agent --resume ""` prepends the CONTINUATION +// BRIEF built from it, so a different model picks the thread up with the +// project context already in hand. +// 2. `aether resume export` writes it to a file. Copy that file anywhere — +// another checkout, another machine, another OS — and +// `aether agent --resume ` continues there. Nothing in it is keyed +// to an absolute path, so the receiving side needs no matching layout. +// +// Deliberately NOT a transcript: full chat history is large, leaks file +// contents and shell commands, and is exactly what the session log already +// redacts. The brief is a summary a human could have written, which is what +// makes it safe to move between machines. + +import { readFileSync, writeFileSync } from "node:fs"; +import type { BrainEvent } from "./brain_protocol.js"; +import { decodeEvent } from "./brain_protocol.js"; +import { loadSession, type LoadedSession } from "./session_resume.js"; +import { logsRoot } from "./session_log.js"; +import type { RunResult, Runner } from "./worktree.js"; + +export const HANDOFF_SCHEMA_VERSION = 1; +export const HANDOFF_KIND = "aether-agent-handoff"; + +/** Where the work lives, expressed so it survives the trip to another machine. */ +export interface HandoffRepo { + /** `git remote get-url origin`, when there is one. */ + remote?: string; + /** Branch the prior run ended on. */ + branch?: string; + /** HEAD sha at export time — lets the receiving side spot a diverged tree. */ + head?: string; +} + +export interface Handoff { + schemaVersion: number; + kind: typeof HANDOFF_KIND; + sessionId: string; + /** The task the prior run was given. */ + task: string; + /** Model id the prior run used ("" when it ran on the account default). */ + model: string; + brain: "local" | "cloud"; + started: string; + ended: string | null; + /** The prior run's verify-gate verdict — "ok" only if its tests were green. */ + finalStatus: string; + /** Failing tests the prior run left behind, when it left any. */ + remaining?: number; + repo?: HandoffRepo; + /** Compacted narration of what the prior run actually did. */ + highlights: string[]; + /** Files the prior run wrote. */ + filesTouched: string[]; + /** The command the verify gate ran, when the prior run named one. */ + testCmd?: string; +} + +/** Highlights are a summary, not a transcript — these bounds keep it one. */ +const MAX_HIGHLIGHTS = 40; +const MAX_HIGHLIGHT_CHARS = 300; +const MAX_FILES = 60; + +function clip(text: string, max = MAX_HIGHLIGHT_CHARS): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > max ? flat.slice(0, max - 1) + "…" : flat; +} + +/** The narration worth carrying forward: stages entered, what the model said it + * was doing, checkpoints, and the terminal result. Tool spam is dropped — the + * next model will re-read the files itself, and it is the DECISIONS that do not + * survive a fresh context. */ +export function summarizeEvents(events: Array>): { + highlights: string[]; + filesTouched: string[]; +} { + const highlights: string[] = []; + const files: string[] = []; + for (const raw of events) { + const ev: BrainEvent | null = decodeEvent(raw); + if (!ev) continue; + if (ev.type === "tool_call") { + const path = ev.args["path"]; + if (ev.name === "write_file" && typeof path === "string") { + if (!files.includes(path) && files.length < MAX_FILES) files.push(path); + } + continue; + } + if (ev.type === "stage") highlights.push(`stage: ${clip(ev.name)}`); + else if (ev.type === "monologue" && ev.text.trim()) highlights.push(clip(ev.text)); + else if (ev.type === "checkpoint") highlights.push(`checkpoint ${clip(ev.gitSha, 40)}`); + else if (ev.type === "done") { + // The final answer usually arrives twice — once as the closing monologue, + // once inside `done`. Say it once. + const said = `${ev.ok ? "finished" : "stopped"}: ${clip(ev.result)}`; + if (highlights[highlights.length - 1] !== clip(ev.result)) highlights.push(said); + } + else if (ev.type === "error") highlights.push(`error: ${clip(ev.msg)}`); + } + // Keep the tail: the END of a run is what the next one has to build on. + return { highlights: highlights.slice(-MAX_HIGHLIGHTS), filesTouched: files }; +} + +/** Read the repository identity of `cwd`. Every probe is best-effort — a plain + * directory with no git in it yields an empty record, never an error. */ +export function readRepoIdentity(cwd: string, run: Runner): HandoffRepo | undefined { + const value = (args: string[]): string | undefined => { + let r: RunResult; + try { + r = run("git", args, cwd); + } catch { + return undefined; + } + const out = r.stdout.trim(); + return r.status === 0 && out ? out : undefined; + }; + const remote = value(["remote", "get-url", "origin"]); + const branch = value(["rev-parse", "--abbrev-ref", "HEAD"]); + const head = value(["rev-parse", "HEAD"]); + if (!remote && !branch && !head) return undefined; + return { ...(remote && { remote }), ...(branch && { branch }), ...(head && { head }) }; +} + +export interface BuildHandoffOptions { + repo?: HandoffRepo | undefined; + testCmd?: string | undefined; +} + +/** Distil one loaded session into a handoff. Pure — the caller supplies the + * repo identity so this stays testable without a git checkout. */ +export function buildHandoff(session: LoadedSession, opts: BuildHandoffOptions = {}): Handoff { + const m = session.manifest; + const { highlights, filesTouched } = summarizeEvents(session.events); + const remaining = m.remaining; + const testCmd = opts.testCmd ?? m.testCmd; + return { + schemaVersion: HANDOFF_SCHEMA_VERSION, + kind: HANDOFF_KIND, + sessionId: m.sessionId, + task: m.task, + model: m.model ?? "", + brain: m.brain, + started: m.started, + ended: m.ended ?? null, + finalStatus: m.finalStatus ?? "running", + ...(typeof remaining === "number" && remaining > 0 ? { remaining } : {}), + ...(opts.repo ? { repo: opts.repo } : {}), + highlights, + filesTouched, + ...(testCmd ? { testCmd } : {}), + }; +} + +/** Validate an untrusted handoff document. Handoffs travel between machines, so + * a file that is merely JSON is not enough — every field the brief renders is + * checked, and anything unrecognized is rejected rather than half-used. */ +export function parseHandoff(value: unknown): Handoff { + const body = value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + if (!body) throw new Error("handoff file is not a JSON object"); + if (body["kind"] !== HANDOFF_KIND) throw new Error("not an Aether Agent handoff file"); + const version = body["schemaVersion"]; + if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { + throw new Error("handoff file has no usable schemaVersion"); + } + if (version > HANDOFF_SCHEMA_VERSION) { + throw new Error( + `handoff was written by a newer Aether Agent (schema ${version}); upgrade with: npm i -g aether-agents`, + ); + } + const str = (key: string): string => (typeof body[key] === "string" ? (body[key] as string) : ""); + const strings = (key: string): string[] => + Array.isArray(body[key]) ? (body[key] as unknown[]).filter((v): v is string => typeof v === "string") : []; + const brain = body["brain"] === "cloud" ? "cloud" : "local"; + const sessionId = str("sessionId"); + const task = str("task"); + if (!sessionId || !task) throw new Error("handoff file is missing its session id or task"); + const repoRaw = body["repo"]; + const repo = repoRaw != null && typeof repoRaw === "object" && !Array.isArray(repoRaw) + ? (repoRaw as Record) + : undefined; + const repoStr = (key: string): string | undefined => + repo && typeof repo[key] === "string" && repo[key] ? (repo[key] as string) : undefined; + const remoteId = repoStr("remote"); + const branchId = repoStr("branch"); + const headId = repoStr("head"); + const remaining = body["remaining"]; + const ended = body["ended"]; + const testCmd = str("testCmd"); + return { + schemaVersion: version, + kind: HANDOFF_KIND, + sessionId, + task, + model: str("model"), + brain, + started: str("started"), + ended: typeof ended === "string" ? ended : null, + finalStatus: str("finalStatus") || "unknown", + ...(typeof remaining === "number" && remaining > 0 ? { remaining } : {}), + ...(remoteId || branchId || headId + ? { repo: { ...(remoteId && { remote: remoteId }), ...(branchId && { branch: branchId }), ...(headId && { head: headId }) } } + : {}), + highlights: strings("highlights").map((h) => clip(h)).slice(-MAX_HIGHLIGHTS), + filesTouched: strings("filesTouched").slice(0, MAX_FILES), + ...(testCmd ? { testCmd } : {}), + }; +} + +export function writeHandoff(path: string, handoff: Handoff): void { + writeFileSync(path, JSON.stringify(handoff, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); +} + +export function readHandoff(path: string): Handoff { + return parseHandoff(JSON.parse(readFileSync(path, "utf8"))); +} + +/** + * Render the brief the NEXT brain reads before its own task. + * + * Written as plain prose on purpose: it is prepended to the task text, so it + * has to be legible to every brain on every path — a hosted frontier model, a + * 4B local model, and the human reading the log a week later — without any of + * them having to parse a format. + */ +export function continuationBrief(h: Handoff): string { + const lines: string[] = []; + lines.push("## Continuing a prior Aether Agent session"); + lines.push(""); + lines.push(`Prior session: ${h.sessionId}`); + lines.push(`Ran on: ${h.model || "the account default model"} (${h.brain} brain)`); + lines.push(`Original task: ${h.task}`); + lines.push( + `Where it left off: ${h.finalStatus}` + + (h.remaining ? ` — ${h.remaining} test${h.remaining === 1 ? "" : "s"} still failing` : ""), + ); + if (h.testCmd) lines.push(`Verification command: ${h.testCmd}`); + if (h.repo?.remote || h.repo?.branch) { + lines.push(`Repository: ${h.repo.remote ?? "(local)"}${h.repo.branch ? ` on ${h.repo.branch}` : ""}`); + } + if (h.filesTouched.length) { + lines.push(""); + lines.push("Files the prior session changed:"); + for (const f of h.filesTouched) lines.push(`- ${f}`); + } + if (h.highlights.length) { + lines.push(""); + lines.push("What it did, in order:"); + for (const line of h.highlights) lines.push(`- ${line}`); + } + lines.push(""); + lines.push( + "You are continuing this work in the same repository. Read the files above " + + "before changing them — the summary is what happened, not what the code " + + "says now. Do not redo finished work.", + ); + return lines.join("\n"); +} + +/** Compose the brief and the next instruction into the task the brain receives. */ +export function continuationTask(h: Handoff, nextTask: string): string { + const next = nextTask.trim() || h.task; + return `${continuationBrief(h)}\n\n## Your task now\n\n${next}\n`; +} + +/** + * Resolve a `--resume` value into a handoff. + * + * Two shapes, distinguished without guessing: a session id is an opaque + * directory name under the logs root (no separators, no extension), so anything + * carrying a path separator or ending in .json is read as a handoff FILE — the + * form that came from another machine. Everything else is a local session id + * and is distilled on the spot. + * + * A file is never workspace-scoped: importing one is an explicit act by the + * person holding it, and its whole purpose is to land in a checkout whose + * absolute path does not match where the work started. + */ +export function resolveHandoff( + value: string, + cwd: string, + load: (id: string, root: string, scope: string) => LoadedSession = defaultLoadSession, + root?: string, +): Handoff { + const ref = value.trim(); + if (!ref) throw new Error("--resume needs a session id or a handoff file"); + if (isHandoffPath(ref)) { + try { + return readHandoff(ref); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new Error(`no handoff file at ${ref} — write one with: aether resume export --out ${ref}`); + } + const why = err instanceof Error ? err.message : String(err); + throw new Error(`cannot read handoff ${ref}: ${why}`); + } + } + return buildHandoff(load(ref, root ?? logsRoot(), cwd)); +} + +/** A --resume value that names a file on disk rather than a local session id. */ +export function isHandoffPath(value: string): boolean { + return /[\/]/.test(value) || value.toLowerCase().endsWith(".json"); +} + +const defaultLoadSession = (id: string, root: string, scope: string): LoadedSession => + loadSession(id, root, scope); diff --git a/src/core/session_log.ts b/src/core/session_log.ts index aea6c61..d17fd4e 100644 --- a/src/core/session_log.ts +++ b/src/core/session_log.ts @@ -22,7 +22,13 @@ export function logsRoot(): string { -const SENSITIVE_KEY = /token|secret|password|authorization|api[_-]?key|private[_-]?key|credential|pat/i; +// "pat" is anchored to a whole word/segment on purpose. Bare /pat/ also matches +// PATH, path, patch, and pattern — so every `write_file {path}` in every session +// log was stored as "[REDACTED]", which is not redaction, it is data loss: the +// record could no longer say which files a run changed. Credential-shaped keys +// (pat, gh_pat, pat-token) still match. +const SENSITIVE_KEY = + /token|secret|password|authorization|api[_-]?key|private[_-]?key|credential|(?:^|[_-])pat(?:$|[_-])/i; function redactInline(value: string): string { return value @@ -101,6 +107,9 @@ export interface SessionMeta { poolGb: number; brain: "local" | "cloud"; cwd: string; + /** The command the verify gate runs for this session. Recorded so a handoff + * can tell the next machine how this work is checked. */ + testCmd?: string; } export class SessionLog { @@ -222,6 +231,7 @@ export class SessionLog { poolGb: this.meta.poolGb, brain: this.meta.brain, cwd: normalizeWorkspace(this.meta.cwd), + ...(this.meta.testCmd ? { testCmd: redactInline(this.meta.testCmd) } : {}), started: this.started, ended: end?.ended ?? null, finalStatus: end?.finalStatus ?? "running", diff --git a/src/core/session_resume.ts b/src/core/session_resume.ts index dd92e92..f285dce 100644 --- a/src/core/session_resume.ts +++ b/src/core/session_resume.ts @@ -17,6 +17,10 @@ export interface SessionManifest { ended?: string | null; finalStatus?: string; cwd?: string; + /** Failing tests the run left behind, when it left any. */ + remaining?: number; + /** The command this session's verify gate ran, when one was named. */ + testCmd?: string; } export interface LoadedSession { diff --git a/src/main.ts b/src/main.ts index fbe7bca..14cf04f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -81,6 +81,7 @@ async function main(argv: string[]): Promise { repo: { type: "string" }, swarm: { type: "string" }, resume: { type: "string" }, + out: { type: "string" }, }, }); @@ -209,7 +210,7 @@ async function main(argv: string[]): Promise { } case "resume": { const { cmdResume } = await import("./commands/resume.js"); - return cmdResume(ctx, rest[0] ?? ""); + return cmdResume(ctx, rest.join(" "), sf(values["out"])); } case "chat": return cmdChat(ctx, rest.join(" ")); diff --git a/test/backend_select.test.ts b/test/backend_select.test.ts index ffdca62..c808300 100644 --- a/test/backend_select.test.ts +++ b/test/backend_select.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { chooseBackend } from "../src/core/backend.js"; +import { chooseBackend, chooseLocalBrain } from "../src/core/backend.js"; // chooseBackend is the ONE pure decision: given the resolved config backend and // whether the user is authed, return the concrete path. 'auto' picks cloud when @@ -31,3 +31,28 @@ test("an unknown/garbage backend value is treated as auto", () => { assert.equal(chooseBackend("", true), "cloud"); assert.equal(chooseBackend("", false), "local"); }); + +// chooseLocalBrain is the second pure decision: WHICH local brain runs offline. +// The Ollama brain ships inside the npm package; the headless Python brain is a +// separate install, so asking for it by accident is the difference between a +// working offline run and "spawn python ENOENT". + +test("the shipped Ollama brain is the default local brain", () => { + assert.equal(chooseLocalBrain(undefined), "ollama"); + assert.equal(chooseLocalBrain(""), "ollama"); + assert.equal(chooseLocalBrain(" "), "ollama"); +}); + +test("the Python brain is opt-in, case- and whitespace-insensitively", () => { + assert.equal(chooseLocalBrain("python"), "python"); + assert.equal(chooseLocalBrain(" Python "), "python"); + assert.equal(chooseLocalBrain("PYTHON"), "python"); +}); + +test("an unknown local-brain value falls back to the shipped brain", () => { + // A stray env value must never route an offline run to an interpreter the + // npm package does not install. + assert.equal(chooseLocalBrain("ollama"), "ollama"); + assert.equal(chooseLocalBrain("py"), "ollama"); + assert.equal(chooseLocalBrain("nonsense"), "ollama"); +}); diff --git a/test/handoff.test.ts b/test/handoff.test.ts new file mode 100644 index 0000000..0093c33 --- /dev/null +++ b/test/handoff.test.ts @@ -0,0 +1,261 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + HANDOFF_KIND, + HANDOFF_SCHEMA_VERSION, + buildHandoff, + continuationBrief, + continuationTask, + isHandoffPath, + parseHandoff, + readHandoff, + readRepoIdentity, + resolveHandoff, + summarizeEvents, + writeHandoff, + type Handoff, +} from "../src/core/handoff.js"; +import type { LoadedSession } from "../src/core/session_resume.js"; + +// A handoff is what makes `--resume` mean "the next brain knows what happened" +// rather than "the human sees the old transcript scroll past". These tests pin +// the three things that must hold for that: the distillation keeps decisions and +// drops noise, the file survives a trip between machines (validated on the way +// back in), and the brief the brain reads carries the prior verdict. + +function session(events: Array>, manifest: Record = {}): LoadedSession { + return { + dir: "/logs/s1", + manifest: { + sessionId: "s1", + task: "make the parser accept trailing commas", + model: "qwen3:4b", + brain: "local", + started: "2026-08-19T10:00:00.000Z", + ended: "2026-08-19T10:04:00.000Z", + finalStatus: "incomplete", + ...manifest, + } as LoadedSession["manifest"], + events, + }; +} + +test("summarizeEvents keeps the decisions and drops the tool noise", () => { + const { highlights, filesTouched } = summarizeEvents([ + { type: "stage", name: "scan", face: "" }, + { type: "monologue", text: "the tokenizer rejects a comma before ]", depth: 0 }, + { type: "tool_call", id: "1", name: "read_file", args: { path: "src/parse.ts" } }, + { type: "tool_call", id: "2", name: "write_file", args: { path: "src/parse.ts" } }, + { type: "tool_call", id: "3", name: "write_file", args: { path: "src/parse.ts" } }, + { type: "tool_call", id: "4", name: "run_tests", args: { command: "npm test" } }, + { type: "done", ok: false, result: "one case still red", remaining: 1, reason: "" }, + ]); + // read_file is not a change, and the same file written twice is one file. + assert.deepEqual(filesTouched, ["src/parse.ts"]); + assert.ok(highlights.some((h) => h.includes("stage: scan"))); + assert.ok(highlights.some((h) => h.includes("tokenizer rejects"))); + assert.ok(highlights.some((h) => h.startsWith("stopped: one case still red"))); + assert.ok(!highlights.some((h) => h.includes("read_file"))); +}); + +test("summarizeEvents keeps the TAIL when a run is long", () => { + const events = Array.from({ length: 200 }, (_, i) => ({ + type: "monologue", + text: `step ${i}`, + depth: 0, + })); + const { highlights } = summarizeEvents(events); + assert.ok(highlights.length <= 40); + // The end of a run is what the next one builds on, so the tail survives. + assert.equal(highlights[highlights.length - 1], "step 199"); +}); + +test("buildHandoff carries the prior verdict, model and failing count", () => { + const h = buildHandoff( + session([{ type: "done", ok: false, result: "1 failing", remaining: 1, reason: "" }], { + remaining: 1, + testCmd: "npm test", + }), + { repo: { remote: "https://github.com/acme/parser.git", branch: "main" } }, + ); + assert.equal(h.kind, HANDOFF_KIND); + assert.equal(h.schemaVersion, HANDOFF_SCHEMA_VERSION); + assert.equal(h.model, "qwen3:4b"); + assert.equal(h.finalStatus, "incomplete"); + assert.equal(h.remaining, 1); + assert.equal(h.testCmd, "npm test"); + assert.equal(h.repo?.branch, "main"); +}); + +test("buildHandoff omits `remaining` when the prior run was green", () => { + const h = buildHandoff(session([], { finalStatus: "ok" })); + assert.equal(h.finalStatus, "ok"); + assert.equal(h.remaining, undefined); +}); + +test("a handoff round-trips through a file", () => { + const dir = mkdtempSync(join(tmpdir(), "aether-handoff-")); + try { + const path = join(dir, "aether-handoff.json"); + const original = buildHandoff(session([{ type: "monologue", text: "found it", depth: 0 }])); + writeHandoff(path, original); + assert.deepEqual(readHandoff(path), original); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("parseHandoff refuses anything that is not a handoff", () => { + assert.throws(() => parseHandoff(null), /not a JSON object/); + assert.throws(() => parseHandoff([1, 2]), /not a JSON object/); + assert.throws(() => parseHandoff({ kind: "something-else" }), /not an Aether Agent handoff/); + assert.throws( + () => parseHandoff({ kind: HANDOFF_KIND, schemaVersion: "1" }), + /no usable schemaVersion/, + ); + assert.throws( + () => parseHandoff({ kind: HANDOFF_KIND, schemaVersion: 1 }), + /missing its session id or task/, + ); +}); + +test("a handoff from a NEWER Aether Agent says how to read it, rather than half-reading it", () => { + assert.throws( + () => parseHandoff({ kind: HANDOFF_KIND, schemaVersion: 99, sessionId: "s", task: "t" }), + /npm i -g aether-agents/, + ); +}); + +test("parseHandoff drops junk inside the arrays instead of trusting them", () => { + const h = parseHandoff({ + kind: HANDOFF_KIND, + schemaVersion: 1, + sessionId: "s1", + task: "t", + highlights: ["real", 7, null, { nope: true }], + filesTouched: ["src/a.ts", 9], + repo: "not-an-object", + }); + assert.deepEqual(h.highlights, ["real"]); + assert.deepEqual(h.filesTouched, ["src/a.ts"]); + assert.equal(h.repo, undefined); +}); + +test("the brief names the prior model, the verdict and the files", () => { + const brief = continuationBrief( + buildHandoff( + session([{ type: "tool_call", id: "1", name: "write_file", args: { path: "src/parse.ts" } }], { + remaining: 2, + testCmd: "npm test", + }), + ), + ); + assert.match(brief, /Prior session: s1/); + assert.match(brief, /qwen3:4b/); + assert.match(brief, /incomplete — 2 tests still failing/); + assert.match(brief, /Verification command: npm test/); + assert.match(brief, /- src\/parse\.ts/); +}); + +test("continuationTask falls back to the ORIGINAL task when no new one is given", () => { + const h = buildHandoff(session([])); + const text = continuationTask(h, " "); + assert.match(text, /## Your task now/); + assert.match(text, /make the parser accept trailing commas/); +}); + +test("continuationTask puts the new instruction after the brief", () => { + const h = buildHandoff(session([])); + const text = continuationTask(h, "now delete the dead branch"); + assert.ok(text.indexOf("Prior session") < text.indexOf("now delete the dead branch")); +}); + +test("isHandoffPath separates a file from an opaque session id", () => { + assert.equal(isHandoffPath("./aether-handoff.json"), true); + assert.equal(isHandoffPath("C:\\work\\handoff.json"), true); + assert.equal(isHandoffPath("handoff.json"), true); + assert.equal(isHandoffPath("2026-08-19T10-00-00-000Z-local-4242"), false); +}); + +test("resolveHandoff reads a FILE without any workspace check", () => { + // The whole point of the file form: it lands in a checkout whose absolute + // path does not match where the work started. + const dir = mkdtempSync(join(tmpdir(), "aether-handoff-")); + try { + const path = join(dir, "handoff.json"); + writeHandoff(path, buildHandoff(session([]))); + const h: Handoff = resolveHandoff(path, "C:\\somewhere\\entirely\\else"); + assert.equal(h.sessionId, "s1"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveHandoff explains an unreadable handoff file by name", () => { + const dir = mkdtempSync(join(tmpdir(), "aether-handoff-")); + try { + const path = join(dir, "broken.json"); + writeFileSync(path, "{ not json", "utf8"); + assert.throws(() => resolveHandoff(path, dir), /cannot read handoff/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveHandoff distils a local session id through the injected loader", () => { + const loaded = session([{ type: "monologue", text: "did a thing", depth: 0 }]); + const h = resolveHandoff("s1", "/work", (id, _root, scope) => { + assert.equal(id, "s1"); + assert.equal(scope, "/work"); + return loaded; + }); + assert.equal(h.sessionId, "s1"); + assert.ok(h.highlights.includes("did a thing")); +}); + +test("resolveHandoff rejects an empty reference", () => { + assert.throws(() => resolveHandoff(" ", "/work"), /needs a session id or a handoff file/); +}); + +test("readRepoIdentity is best-effort: a non-repo yields nothing, not an error", () => { + const identity = readRepoIdentity("/nowhere", () => ({ status: 128, stdout: "", stderr: "not a git repo" })); + assert.equal(identity, undefined); +}); + +test("readRepoIdentity collects remote, branch and head", () => { + const identity = readRepoIdentity("/work", (_cmd, args) => { + const key = args.join(" "); + if (key === "remote get-url origin") return { status: 0, stdout: "git@github.com:acme/parser.git\n", stderr: "" }; + if (key === "rev-parse --abbrev-ref HEAD") return { status: 0, stdout: "feat/commas\n", stderr: "" }; + return { status: 0, stdout: "abc123\n", stderr: "" }; + }); + assert.deepEqual(identity, { + remote: "git@github.com:acme/parser.git", + branch: "feat/commas", + head: "abc123", + }); +}); + +test("readRepoIdentity survives a runner that throws", () => { + assert.equal( + readRepoIdentity("/work", () => { + throw new Error("git is not installed"); + }), + undefined, + ); +}); + +test("a missing handoff file says how to make one", () => { + const dir = mkdtempSync(join(tmpdir(), "aether-handoff-")); + try { + assert.throws( + () => resolveHandoff(join(dir, "absent.json"), dir), + /no handoff file at .*absent\.json — write one with: aether resume export/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/resume_cmd.test.ts b/test/resume_cmd.test.ts index 9fa430d..5dc8849 100644 --- a/test/resume_cmd.test.ts +++ b/test/resume_cmd.test.ts @@ -1,6 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { resumeHint } from "../src/commands/resume.js"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cmdResume, DEFAULT_HANDOFF_FILE, resumeHint } from "../src/commands/resume.js"; +import type { AppContext } from "../src/core/context.js"; test("resumeHint quotes the exact re-entry command", () => { assert.equal( @@ -8,3 +12,153 @@ test("resumeHint quotes the exact re-entry command", () => { "session paused — resume with: aether agent --resume 2026-06-08T12-00-00-000Z-cloud", ); }); + +// `aether resume export` is the machine-to-machine half of resume: it turns the +// local session log into one file you can carry. These tests drive the real +// command against a seeded log root, capturing stdout/stderr. + +function seedSession(root: string, id: string, cwd: string): void { + const dir = join(root, id); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "manifest.json"), + JSON.stringify({ + sessionId: id, + task: "make the parser accept trailing commas", + model: "qwen3:4b", + brain: "local", + cwd, + started: "2026-08-19T10:00:00.000Z", + ended: "2026-08-19T10:04:00.000Z", + finalStatus: "incomplete", + remaining: 1, + testCmd: "npm test", + }), + ); + writeFileSync( + join(dir, "events.jsonl"), + JSON.stringify({ ts: "t", type: "monologue", text: "the tokenizer rejects it", depth: 0 }) + + "\n" + + JSON.stringify({ ts: "t", type: "tool_call", id: "1", name: "write_file", args: { path: "src/parse.ts" } }) + + "\n", + ); +} + +function fakeContext(cwd: string): AppContext { + return { + cfg: {}, + api: {}, + tokens: {}, + flags: { json: false, audit: false, yes: true, cwd }, + confirm: async () => true, + } as unknown as AppContext; +} + +/** Run one command with stdout/stderr captured. */ +async function capture(run: () => Promise): Promise<{ code: number; out: string; err: string }> { + const realOut = process.stdout.write.bind(process.stdout); + const realErr = process.stderr.write.bind(process.stderr); + let out = ""; + let err = ""; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdout as any).write = (chunk: string): boolean => ((out += chunk), true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stderr as any).write = (chunk: string): boolean => ((err += chunk), true); + try { + const code = await run(); + return { code, out, err }; + } finally { + process.stdout.write = realOut; + process.stderr.write = realErr; + } +} + +test("`aether resume export` writes a handoff next to the work by default", async () => { + const root = mkdtempSync(join(tmpdir(), "aether-resume-")); + const logs = join(root, "logs"); + const work = join(root, "work"); + mkdirSync(work, { recursive: true }); + const previous = process.env["AETHER_LOG_DIR"]; + process.env["AETHER_LOG_DIR"] = logs; + try { + seedSession(logs, "s1", work); + const { code, out } = await capture(() => cmdResume(fakeContext(work), "export")); + assert.equal(code, 0); + const written = join(work, DEFAULT_HANDOFF_FILE); + assert.ok(existsSync(written), "handoff file was written"); + const handoff = JSON.parse(readFileSync(written, "utf8")); + assert.equal(handoff.kind, "aether-agent-handoff"); + assert.equal(handoff.sessionId, "s1"); + assert.equal(handoff.model, "qwen3:4b"); + assert.equal(handoff.remaining, 1); + assert.equal(handoff.testCmd, "npm test"); + assert.deepEqual(handoff.filesTouched, ["src/parse.ts"]); + // The command has to tell the user how to spend what it just made. + assert.match(out, /aether agent --resume/); + } finally { + if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; + else process.env["AETHER_LOG_DIR"] = previous; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("`aether resume export --out ` honours the destination", async () => { + const root = mkdtempSync(join(tmpdir(), "aether-resume-")); + const logs = join(root, "logs"); + const work = join(root, "work"); + mkdirSync(work, { recursive: true }); + const previous = process.env["AETHER_LOG_DIR"]; + process.env["AETHER_LOG_DIR"] = logs; + try { + seedSession(logs, "s1", work); + const target = join(root, "carried.json"); + const { code } = await capture(() => cmdResume(fakeContext(work), "export s1", target)); + assert.equal(code, 0); + assert.equal(JSON.parse(readFileSync(target, "utf8")).sessionId, "s1"); + } finally { + if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; + else process.env["AETHER_LOG_DIR"] = previous; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("`aether resume export` with no sessions fails loudly rather than writing an empty file", async () => { + const root = mkdtempSync(join(tmpdir(), "aether-resume-")); + const logs = join(root, "logs"); + const work = join(root, "work"); + mkdirSync(work, { recursive: true }); + mkdirSync(logs, { recursive: true }); + const previous = process.env["AETHER_LOG_DIR"]; + process.env["AETHER_LOG_DIR"] = logs; + try { + const { code, err } = await capture(() => cmdResume(fakeContext(work), "export")); + assert.equal(code, 1); + assert.match(err, /no sessions to resume/); + assert.equal(existsSync(join(work, DEFAULT_HANDOFF_FILE)), false); + } finally { + if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; + else process.env["AETHER_LOG_DIR"] = previous; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("`aether resume` replay points at both re-entry routes", async () => { + const root = mkdtempSync(join(tmpdir(), "aether-resume-")); + const logs = join(root, "logs"); + const work = join(root, "work"); + mkdirSync(work, { recursive: true }); + const previous = process.env["AETHER_LOG_DIR"]; + process.env["AETHER_LOG_DIR"] = logs; + try { + seedSession(logs, "s1", work); + const { code, out } = await capture(() => cmdResume(fakeContext(work), "")); + assert.equal(code, 0); + assert.match(out, /the tokenizer rejects it/); + assert.match(out, /aether agent --resume s1/); + assert.match(out, /aether resume export s1/); + } finally { + if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; + else process.env["AETHER_LOG_DIR"] = previous; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/session_log.test.ts b/test/session_log.test.ts index 70de2fa..196c401 100644 --- a/test/session_log.test.ts +++ b/test/session_log.test.ts @@ -85,3 +85,30 @@ test("SessionLog redacts credentials and omits prompt, command, and memory conte rmSync(root, { recursive: true, force: true }); } }); + +test("SessionLog keeps file paths readable — 'pat' must not swallow 'path'", () => { + // Bare /pat/ matched path/PATH/patch/pattern, so every edited file in the + // record read "[REDACTED]" and a session log could no longer say what a run + // changed. Credential-shaped keys must still be redacted. + const root = mkdtempSync(join(tmpdir(), "aether-log-path-")); + try { + const log = new SessionLog( + { task: "t", model: "test", poolGb: 1, brain: "local", cwd: root }, + TS, + root, + ); + log.event({ type: "tool_call", id: "c1", name: "write_file", args: { + path: "src/parse.ts", + pattern: "^export", + pat: "ghp_super_secret_value", + gh_pat: "ghp_other_secret_value", + } }, TS); + log.close("ok", TS); + const raw = readFileSync(join(log.dir, "events.jsonl"), "utf8"); + assert.match(raw, /src\/parse\.ts/); + assert.match(raw, /\^export/); + assert.doesNotMatch(raw, /ghp_super_secret_value|ghp_other_secret_value/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 0b115cb7e1fefc8b4b1718b29923af69c0f1bcc4 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:53:05 -0400 Subject: [PATCH 2/5] refactor(handoff): reuse what the codebase already has, and read the log once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the handoff feature. No intended behaviour change except where a reused helper is strictly better than the hand-rolled version — those are called out below. Reuse: - `writeHandoff` uses `atomicWriteFile` (core/durable_store.ts), the same write-then-rename every other durable file in this CLI already uses. An interrupted `resume export` can no longer destroy a good handoff or leave half a JSON document, and `--out reports/handoff.json` now creates the missing parent instead of failing with ENOENT. - `readHandoff` uses `readJsonFile`, which already distinguishes missing from unreadable from corrupt — so the hand-rolled ENOENT check is gone and an empty file reads as "corrupt", the codebase's word for it, rather than "Unexpected end of JSON input". - Untrusted strings go through `sanitizeTerm` (ui/text.ts) and `clipCodePoints` (ui/theme.ts) instead of a local clipper. A handoff arrives from another machine and its strings are BOTH printed and prepended to the brain's prompt, so it was the only untrusted-input surface in the CLI not passing through the terminal sanitizer. `clipCodePoints` also stops a truncation from cutting a surrogate pair in half. - `wroteFile()` is now the one definition of "the run changed a file", called by both a handoff's `filesTouched` and cmdCode's live blast-radius set, which had the predicate written out twice. `isHandoffPath` had a real defect: its separator class held only a forward slash, so `--resume C:\work\handoff` (no .json) was classified as a session id and died as "invalid session id". It now inverts `requireOpaqueId` — the same rule `loadSession` enforces a moment later — so the two branches provably partition the input, and Windows separators, `..`, and `~/` fall out for free. Read the session log once. `resolveHandoff` loaded the session, distilled it, and threw it away; `replaySession` then re-derived the file-vs-id decision and loaded the same session again to render it. `resolveResume` returns `{handoff, session}` and `resumeReplayLines` renders from it — one read, one decision, and `replaySession`'s `ref`/`cwd` parameters disappear. On a 2000-event log that is 2000 fewer JSON parses and decodes per resume. `aether resume` dispatches on argv like every other subcommand-bearing command in main.ts, instead of joining `rest` into a string and re-splitting it by prefix. `cmdResumeExport` is its own exported function. Side effect: `aether resume abc def` reports `no such session: abc` rather than looking up the id `"abc def"`. Smaller: `summarizeEvents` bounds `highlights` as it goes rather than growing to N and slicing to 40, and uses a Set for `filesTouched`; `parseHandoff` bounds arrays before clipping them, so a hostile file with 10k highlights costs 40 clips rather than 10k; one `repoFrom` and one `asObject` replace two spellings each; the demo's two model scripts are one shape with two substitutions; the demo header no longer restates docs/demo/handoff.md. Tests: the four resume-command cases share one `withLogRoot` fixture instead of pasting the same 10-line prologue and env restore four times, and there are new cases for the Windows path classification, the sanitizer, the shared write predicate, and `--out` into a missing directory. Deliberately not done: collapsing readRepoIdentity's three git spawns into two (one saved spawn, subtler parsing); `latestSession`'s full-log scan (real, but pre-existing and outside this diff); rebuilding `summarizeEvents` on top of `monologueLine` (its tree-rendering shape is not a summary's). --- scripts/handoff-demo.ts | 73 +++++------- src/commands/code.ts | 47 +++----- src/commands/resume.ts | 56 ++++----- src/core/handoff.ts | 252 ++++++++++++++++++++++++---------------- src/main.ts | 6 +- test/handoff.test.ts | 71 ++++++++--- test/resume_cmd.test.ts | 197 ++++++++++++++++--------------- 7 files changed, 390 insertions(+), 312 deletions(-) diff --git a/scripts/handoff-demo.ts b/scripts/handoff-demo.ts index 46ddc2c..a74ac10 100644 --- a/scripts/handoff-demo.ts +++ b/scripts/handoff-demo.ts @@ -5,32 +5,18 @@ // It proves one sentence: **start on one model, continue on another, on another // machine, and let the tests decide when it is done.** // -// The demo builds a throwaway git repo with a genuinely failing test, runs the -// real `aether` CLI over it on model A, exports a handoff file, sets up a SECOND -// checkout at a different absolute path (machine B), deletes the first one, and -// runs the CLI there on model B with `--resume ` and no restated -// context. The host's verify gate — not the model — decides the final verdict. +// What the demo does, what is real, what is stubbed, and how to record it are +// documented once in docs/demo/handoff.md — read that, not a second copy here. +// Two things about the CODE that the doc has no reason to mention: // -// What is real: the CLI, the git repos, the file edits, the tool permission -// gate, the handoff file, `node --test`, and the verify gate. +// - runCli MUST NOT use spawnSync. The scripted model is served by this same +// process, so a synchronous spawn blocks the event loop and the agent's very +// first request is never answered. +// - the stub keys its script on the model NAME, which is how session B's +// prompt gets captured and asserted separately from session A's. // -// What is stubbed BY DEFAULT: the model. A tiny local HTTP server speaks the -// Ollama OpenAI-compatible API with scripted replies, so the run is -// byte-deterministic and needs no download, no GPU, and no account — which is -// what makes it usable as a CI gate. The stub also ASSERTS what it was asked: -// session B's first request must contain the continuation brief, naming session -// A's model and the file it touched. That assertion is the actual proof; if the -// context did not cross, the demo fails. -// -// To run the identical script against real models instead (this is what a -// screen recording should show): -// -// AETHER_DEMO_REAL=1 npm run demo:handoff -// AETHER_DEMO_MODEL_A=qwen2.5-coder:7b AETHER_DEMO_MODEL_B=qwen3:4b \ -// AETHER_DEMO_REAL=1 npm run demo:handoff -// -// Real mode needs Ollama running with those tags pulled. The models then decide -// what to do, so the transcript varies — the verify gate still has the last word. +// AETHER_DEMO_REAL=1 (optionally with AETHER_DEMO_MODEL_A / _B) runs the +// identical script against real Ollama models instead. import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { spawn, spawnSync } from "node:child_process"; @@ -112,19 +98,22 @@ interface ScriptedTurn { content?: string; } +/** Both sessions run the same four beats — read, rewrite, test, report — so the + * script is one shape with two substitutions rather than two blocks to keep + * aligned. The determinism of the demo depends on them staying identical. */ +const session = (source: string, report: string): ScriptedTurn[] => [ + { tool: { name: "read_file", args: { path: "src/slug.js" } } }, + { tool: { name: "write_file", args: { path: "src/slug.js", content: source } } }, + { tool: { name: "run_tests", args: { command: TEST_CMD } } }, + { content: report }, +]; + const SCRIPTS: Record = { - [MODEL_A]: [ - { tool: { name: "read_file", args: { path: "src/slug.js" } } }, - { tool: { name: "write_file", args: { path: "src/slug.js", content: HALF_FIXED_SOURCE } } }, - { tool: { name: "run_tests", args: { command: TEST_CMD } } }, - { content: "Lowercasing and hyphenation are in. The surrounding-whitespace case is still red." }, - ], - [MODEL_B]: [ - { tool: { name: "read_file", args: { path: "src/slug.js" } } }, - { tool: { name: "write_file", args: { path: "src/slug.js", content: FIXED_SOURCE } } }, - { tool: { name: "run_tests", args: { command: TEST_CMD } } }, - { content: "Trimmed the input before slugifying. Both cases pass." }, - ], + [MODEL_A]: session( + HALF_FIXED_SOURCE, + "Lowercasing and hyphenation are in. The surrounding-whitespace case is still red.", + ), + [MODEL_B]: session(FIXED_SOURCE, "Trimmed the input before slugifying. Both cases pass."), }; interface StubState { @@ -258,14 +247,16 @@ async function main(): Promise { ollamaHost = stub.host; } - const env = (): Record => ({ + // Nothing below varies per call — the CLI child env is fixed once the stub + // (or the real Ollama host) is known. + const env: Record = { AETHER_CONFIG_DIR: config, AETHER_LOG_DIR: logs, AETHER_BACKEND: "local", AETHER_NO_ANIM: "1", AETHER_NO_HISTORY: "1", OLLAMA_HOST: ollamaHost, - }); + }; const problems: string[] = []; try { @@ -276,7 +267,7 @@ async function main(): Promise { const a = await runCli( ["agent", "--local", "--model", MODEL_A, "--quiet", "--test-cmd", TEST_CMD, TASK], machineA, - env(), + env, ); process.stdout.write(a.stdout); process.stdout.write(a.stderr); @@ -284,7 +275,7 @@ async function main(): Promise { banner("The handoff"); const handoffFile = join(root, "handoff.json"); - const exported = await runCli(["resume", "export", "--out", handoffFile], machineA, env()); + const exported = await runCli(["resume", "export", "--out", handoffFile], machineA, env); process.stdout.write(exported.stdout); process.stdout.write(exported.stderr); if (exported.status !== 0) problems.push("`aether resume export` failed"); @@ -314,7 +305,7 @@ async function main(): Promise { const b = await runCli( ["agent", "--local", "--model", MODEL_B, "--quiet", "--test-cmd", TEST_CMD, "--resume", handoffFile], machineB, - env(), + env, ); process.stdout.write(b.stdout); process.stdout.write(b.stderr); diff --git a/src/commands/code.ts b/src/commands/code.ts index b106d4c..976f1db 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -18,7 +18,7 @@ import { ToolExecutor } from "../core/tool_executor.js"; import { stdioPrompt } from "../ui/interact.js"; import { defaultRunner } from "../core/worktree.js"; import { HostRenderer } from "../ui/host_render.js"; -import { SessionLog, logsRoot } from "../core/session_log.js"; +import { SessionLog } from "../core/session_log.js"; import { finalVerify, type BrainDone } from "../core/verify_gate.js"; import { StatusRenderer } from "../ui/status_renderer.js"; import { AnimationController } from "../ui/animations.js"; @@ -35,8 +35,7 @@ import { stageGate, writeDiffLines, } from "./code_support.js"; -import { loadSession, replayLines } from "../core/session_resume.js"; -import { continuationTask, isHandoffPath, resolveHandoff, type Handoff } from "../core/handoff.js"; +import { continuationTask, resolveResume, resumeReplayLines, wroteFile, type ResolvedResume } from "../core/handoff.js"; import { resumeHint } from "./resume.js"; import { createWorktree, mergeHint, type Worktree } from "../core/worktree.js"; import { parseRepoSpec, ensureLocalClone, prCreateHint, type RepoSpec } from "../core/repo.js"; @@ -92,37 +91,26 @@ export function applyEventToStatus( } } -/** Show the human what is being continued. A local session id replays its full - * transcript; a handoff FILE has no transcript to replay (that is the point — - * it is a summary that survived the trip), so its highlights stand in. - * Fail-soft: an unreadable session prints a note and never aborts the new run. */ -function replaySession(ref: string, cwd: string, handoff: Handoff | null, emit: (line: string) => void): void { - if (isHandoffPath(ref)) { - if (!handoff) return; - emit(`⇄ continuing ${handoff.sessionId} (${handoff.finalStatus}) from ${ref}`); - for (const line of handoff.highlights) emit(" " + line); - return; - } - try { - const prior = loadSession(ref, logsRoot(), cwd); - for (const line of replayLines(prior.events)) emit(line); - } catch (err) { - process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Promise { // --resume carries the prior session's context forward, so it is also a task // of its own: with no new instruction the run continues the ORIGINAL task. - let handoff: Handoff | null = null; + // Resolved ONCE — the handoff the brain reads and the lines the human sees + // come from the same read, so a session log is never parsed twice and the + // file-vs-id decision is made in exactly one place. + let resumed: ResolvedResume | null = null; if (opts.resume) { try { - handoff = resolveHandoff(opts.resume, ctx.flags.cwd); + resumed = resolveResume(opts.resume, ctx.flags.cwd); } catch (err) { process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); return 1; } } + const handoff = resumed?.handoff ?? null; + const replay = (emit: (line: string) => void): void => { + if (!resumed || !opts.resume) return; + for (const line of resumeReplayLines(resumed, opts.resume)) emit(line); + }; if (!task.trim() && !handoff) { process.stderr.write('✗ nothing to do — try: aether agent "fix the failing tests"\n'); return 1; @@ -298,10 +286,11 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr const cols = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80; // Blast radius for the end-of-run summary: every file the brain wrote. const touched = new Set(); + // Same predicate a handoff uses for `filesTouched`, so the live blast radius + // and the exported record can never disagree about what "wrote a file" means. const trackWrites = (ev: BrainEvent): void => { - if (ev.type === "tool_call" && ev.name === "write_file" && typeof ev.args["path"] === "string") { - touched.add(ev.args["path"] as string); - } + const written = wroteFile(ev); + if (written) touched.add(written); }; let onEvent: (ev: BrainEvent) => void | Promise; @@ -321,7 +310,7 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr if (animated) { const sr = new StatusRenderer({ mode: brainKind === "local" ? "local" : "api" }); sr.start(); - if (opts.resume) replaySession(opts.resume, ctx.flags.cwd, handoff, (line) => sr.log(line)); + replay((line) => sr.log(line)); const anim = new AnimationController({ onFrame: (_stage, art) => sr.setAnim(art), onProgress: (used, c) => sr.setProgress(used, c), @@ -378,7 +367,7 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr }; } else { const renderer = new HostRenderer({ poolGb, quiet: opts.quiet, json: ctx.flags.json }); - if (opts.resume) replaySession(opts.resume, ctx.flags.cwd, handoff, (line) => process.stdout.write(line + "\n")); + replay((line) => process.stdout.write(line + "\n")); onEvent = async (ev: BrainEvent): Promise => { applyToLedger(ledger, ev); trackWrites(ev); diff --git a/src/commands/resume.ts b/src/commands/resume.ts index be196fb..508b378 100644 --- a/src/commands/resume.ts +++ b/src/commands/resume.ts @@ -21,28 +21,27 @@ export function resumeHint(sessionId: string): string { /** Default filename for `aether resume export` when --out is not given. */ export const DEFAULT_HANDOFF_FILE = "aether-handoff.json"; -/** Load by id, or the newest session in this workspace when no id is given. */ +/** Load by id, or the newest session in this workspace when no id is given. + * Reports the failure itself and returns null, so both entry points share one + * error path instead of two copies that must be kept in step. */ function pick(ctx: AppContext, id: string): LoadedSession | null { - return id ? loadSession(id, logsRoot(), ctx.flags.cwd) : latestSession(ctx.flags.cwd); -} - -function noSessions(): number { - process.stderr.write('no sessions to resume (run `aether agent ""` first)\n'); - return 1; -} - -/** `aether resume export [id] [--out ]`. */ -function cmdResumeExport(ctx: AppContext, id: string, out: string | undefined): number { - let s: LoadedSession | null; + let session: LoadedSession | null; try { - s = pick(ctx, id); + session = id ? loadSession(id, logsRoot(), ctx.flags.cwd) : latestSession(ctx.flags.cwd); } catch (err) { process.stderr.write(String(err instanceof Error ? err.message : err) + "\n"); - return 1; + return null; } - if (!s) return noSessions(); + if (!session) process.stderr.write('no sessions to resume (run `aether agent ""` first)\n'); + return session; +} + +/** `aether resume export [id] [--out ]`. */ +export function cmdResumeExport(ctx: AppContext, id: string, out?: string): number { + const session = pick(ctx, id); + if (!session) return 1; const target = out?.trim() ? out.trim() : join(ctx.flags.cwd, DEFAULT_HANDOFF_FILE); - const handoff = buildHandoff(s, { repo: readRepoIdentity(ctx.flags.cwd, defaultRunner()) }); + const handoff = buildHandoff(session, { repo: readRepoIdentity(ctx.flags.cwd, defaultRunner()) }); try { writeHandoff(target, handoff); } catch (err) { @@ -61,26 +60,17 @@ function cmdResumeExport(ctx: AppContext, id: string, out: string | undefined): return 0; } -export async function cmdResume(ctx: AppContext, id: string, out?: string): Promise { - if (id === "export") return cmdResumeExport(ctx, "", out); - if (id.startsWith("export ")) return cmdResumeExport(ctx, id.slice("export ".length).trim(), out); - let s; - try { - s = pick(ctx, id); - } catch (err) { - process.stderr.write(String(err instanceof Error ? err.message : err) + "\n"); - return 1; - } - if (!s) return noSessions(); - process.stdout.write(theme.dim(`▸ ${s.manifest.sessionId} · ${s.manifest.task}\n\n`)); - for (const line of replayLines(s.events)) process.stdout.write(line + "\n"); +export function cmdResume(ctx: AppContext, id: string): number { + const session = pick(ctx, id); + if (!session) return 1; + process.stdout.write(theme.dim(`▸ ${session.manifest.sessionId} · ${session.manifest.task}\n\n`)); + for (const line of replayLines(session.events)) process.stdout.write(line + "\n"); process.stdout.write( "\n" + - theme.dim(`status: ${s.manifest.finalStatus ?? "running"} · continue with: `) + - `aether agent --resume ${s.manifest.sessionId}\n` + + theme.dim(`status: ${session.manifest.finalStatus ?? "running"} · continue with: `) + + `aether agent --resume ${session.manifest.sessionId}\n` + theme.dim(" moving machines? ") + - `aether resume export ${s.manifest.sessionId}\n`, + `aether resume export ${session.manifest.sessionId}\n`, ); return 0; } - diff --git a/src/core/handoff.ts b/src/core/handoff.ts index fbf110a..a6bfb1a 100644 --- a/src/core/handoff.ts +++ b/src/core/handoff.ts @@ -22,13 +22,21 @@ // contents and shell commands, and is exactly what the session log already // redacts. The brief is a summary a human could have written, which is what // makes it safe to move between machines. +// +// A handoff read back in is UNTRUSTED input — it arrived from another machine. +// Every field is validated on the way in, and every string is run through the +// terminal sanitizer, because these strings are both printed and prepended to +// the brain's prompt. -import { readFileSync, writeFileSync } from "node:fs"; import type { BrainEvent } from "./brain_protocol.js"; import { decodeEvent } from "./brain_protocol.js"; -import { loadSession, type LoadedSession } from "./session_resume.js"; +import { atomicWriteFile, readJsonFile } from "./durable_store.js"; +import { loadSession, replayLines, type LoadedSession } from "./session_resume.js"; import { logsRoot } from "./session_log.js"; +import { requireOpaqueId } from "./workspace_scope.js"; import type { RunResult, Runner } from "./worktree.js"; +import { clipCodePoints } from "../ui/theme.js"; +import { sanitizeTerm } from "../ui/text.js"; export const HANDOFF_SCHEMA_VERSION = 1; export const HANDOFF_KIND = "aether-agent-handoff"; @@ -39,7 +47,7 @@ export interface HandoffRepo { remote?: string; /** Branch the prior run ended on. */ branch?: string; - /** HEAD sha at export time — lets the receiving side spot a diverged tree. */ + /** HEAD sha at export time — recorded for provenance; nothing reads it yet. */ head?: string; } @@ -72,9 +80,17 @@ const MAX_HIGHLIGHTS = 40; const MAX_HIGHLIGHT_CHARS = 300; const MAX_FILES = 60; +/** Flatten, sanitize, and bound one line of untrusted narration. */ function clip(text: string, max = MAX_HIGHLIGHT_CHARS): string { - const flat = text.replace(/\s+/g, " ").trim(); - return flat.length > max ? flat.slice(0, max - 1) + "…" : flat; + return clipCodePoints(sanitizeTerm(text).replace(/\s+/g, " ").trim(), max); +} + +/** True when this event is the host writing a file — the ONE definition of + * "the run changed something", shared with cmdCode's live blast-radius set. */ +export function wroteFile(ev: BrainEvent): string | null { + if (ev.type !== "tool_call" || ev.name !== "write_file") return null; + const path = ev.args["path"]; + return typeof path === "string" && path ? path : null; } /** The narration worth carrying forward: stages entered, what the model said it @@ -86,34 +102,47 @@ export function summarizeEvents(events: Array>): { filesTouched: string[]; } { const highlights: string[] = []; - const files: string[] = []; + // Keep the TAIL: the end of a run is what the next one builds on. Bounding as + // we go means a 10k-event session never holds 10k clipped strings at once. + const remember = (line: string): void => { + highlights.push(line); + if (highlights.length > MAX_HIGHLIGHTS) highlights.shift(); + }; + const files = new Set(); for (const raw of events) { const ev: BrainEvent | null = decodeEvent(raw); if (!ev) continue; - if (ev.type === "tool_call") { - const path = ev.args["path"]; - if (ev.name === "write_file" && typeof path === "string") { - if (!files.includes(path) && files.length < MAX_FILES) files.push(path); - } + const written = wroteFile(ev); + if (written) { + if (files.size < MAX_FILES) files.add(written); continue; } - if (ev.type === "stage") highlights.push(`stage: ${clip(ev.name)}`); - else if (ev.type === "monologue" && ev.text.trim()) highlights.push(clip(ev.text)); - else if (ev.type === "checkpoint") highlights.push(`checkpoint ${clip(ev.gitSha, 40)}`); + if (ev.type === "tool_call") continue; + if (ev.type === "stage") remember(`stage: ${clip(ev.name)}`); + else if (ev.type === "monologue" && ev.text.trim()) remember(clip(ev.text)); + else if (ev.type === "checkpoint") remember(`checkpoint ${clip(ev.gitSha, 40)}`); else if (ev.type === "done") { // The final answer usually arrives twice — once as the closing monologue, // once inside `done`. Say it once. - const said = `${ev.ok ? "finished" : "stopped"}: ${clip(ev.result)}`; - if (highlights[highlights.length - 1] !== clip(ev.result)) highlights.push(said); - } - else if (ev.type === "error") highlights.push(`error: ${clip(ev.msg)}`); + const result = clip(ev.result); + if (highlights[highlights.length - 1] !== result) { + remember(`${ev.ok ? "finished" : "stopped"}: ${result}`); + } + } else if (ev.type === "error") remember(`error: ${clip(ev.msg)}`); } - // Keep the tail: the END of a run is what the next one has to build on. - return { highlights: highlights.slice(-MAX_HIGHLIGHTS), filesTouched: files }; + return { highlights, filesTouched: [...files] }; +} + +/** Assemble a repo record, dropping empty fields. `undefined` when nothing is + * known — the shape is built in two places (probe and parse), so it is one + * rule here rather than two spellings that can drift. */ +function repoFrom(remote?: string, branch?: string, head?: string): HandoffRepo | undefined { + if (!remote && !branch && !head) return undefined; + return { ...(remote && { remote }), ...(branch && { branch }), ...(head && { head }) }; } /** Read the repository identity of `cwd`. Every probe is best-effort — a plain - * directory with no git in it yields an empty record, never an error. */ + * directory with no git in it yields nothing, never an error. */ export function readRepoIdentity(cwd: string, run: Runner): HandoffRepo | undefined { const value = (args: string[]): string | undefined => { let r: RunResult; @@ -125,11 +154,11 @@ export function readRepoIdentity(cwd: string, run: Runner): HandoffRepo | undefi const out = r.stdout.trim(); return r.status === 0 && out ? out : undefined; }; - const remote = value(["remote", "get-url", "origin"]); - const branch = value(["rev-parse", "--abbrev-ref", "HEAD"]); - const head = value(["rev-parse", "HEAD"]); - if (!remote && !branch && !head) return undefined; - return { ...(remote && { remote }), ...(branch && { branch }), ...(head && { head }) }; + return repoFrom( + value(["remote", "get-url", "origin"]), + value(["rev-parse", "--abbrev-ref", "HEAD"]), + value(["rev-parse", "HEAD"]), + ); } export interface BuildHandoffOptions { @@ -142,7 +171,6 @@ export interface BuildHandoffOptions { export function buildHandoff(session: LoadedSession, opts: BuildHandoffOptions = {}): Handoff { const m = session.manifest; const { highlights, filesTouched } = summarizeEvents(session.events); - const remaining = m.remaining; const testCmd = opts.testCmd ?? m.testCmd; return { schemaVersion: HANDOFF_SCHEMA_VERSION, @@ -154,7 +182,7 @@ export function buildHandoff(session: LoadedSession, opts: BuildHandoffOptions = started: m.started, ended: m.ended ?? null, finalStatus: m.finalStatus ?? "running", - ...(typeof remaining === "number" && remaining > 0 ? { remaining } : {}), + ...(typeof m.remaining === "number" && m.remaining > 0 ? { remaining: m.remaining } : {}), ...(opts.repo ? { repo: opts.repo } : {}), highlights, filesTouched, @@ -162,69 +190,127 @@ export function buildHandoff(session: LoadedSession, opts: BuildHandoffOptions = }; } +/** A plain JSON object, or undefined for null/array/primitive. */ +function asObject(value: unknown): Record | undefined { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + /** Validate an untrusted handoff document. Handoffs travel between machines, so * a file that is merely JSON is not enough — every field the brief renders is - * checked, and anything unrecognized is rejected rather than half-used. */ + * checked and sanitized, and anything unrecognized is dropped rather than + * half-used. */ export function parseHandoff(value: unknown): Handoff { - const body = value != null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; + const body = asObject(value); if (!body) throw new Error("handoff file is not a JSON object"); if (body["kind"] !== HANDOFF_KIND) throw new Error("not an Aether Agent handoff file"); const version = body["schemaVersion"]; - if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { - throw new Error("handoff file has no usable schemaVersion"); - } - if (version > HANDOFF_SCHEMA_VERSION) { + // Number.isInteger already rejects every non-number, so this one check covers + // both "not a number" and "not a whole version". + if (!Number.isInteger(version)) throw new Error("handoff file has no usable schemaVersion"); + const schemaVersion = version as number; + if (schemaVersion < 1) throw new Error("handoff file has no usable schemaVersion"); + if (schemaVersion > HANDOFF_SCHEMA_VERSION) { throw new Error( - `handoff was written by a newer Aether Agent (schema ${version}); upgrade with: npm i -g aether-agents`, + `handoff was written by a newer Aether Agent (schema ${schemaVersion}); upgrade with: npm i -g aether-agents`, ); } - const str = (key: string): string => (typeof body[key] === "string" ? (body[key] as string) : ""); + const field = (source: Record | undefined, key: string): string => + typeof source?.[key] === "string" ? clip(source[key] as string) : ""; + const str = (key: string): string => field(body, key); const strings = (key: string): string[] => Array.isArray(body[key]) ? (body[key] as unknown[]).filter((v): v is string => typeof v === "string") : []; - const brain = body["brain"] === "cloud" ? "cloud" : "local"; const sessionId = str("sessionId"); const task = str("task"); if (!sessionId || !task) throw new Error("handoff file is missing its session id or task"); - const repoRaw = body["repo"]; - const repo = repoRaw != null && typeof repoRaw === "object" && !Array.isArray(repoRaw) - ? (repoRaw as Record) - : undefined; - const repoStr = (key: string): string | undefined => - repo && typeof repo[key] === "string" && repo[key] ? (repo[key] as string) : undefined; - const remoteId = repoStr("remote"); - const branchId = repoStr("branch"); - const headId = repoStr("head"); + const repo = asObject(body["repo"]); + const identity = repoFrom(field(repo, "remote"), field(repo, "branch"), field(repo, "head")); const remaining = body["remaining"]; - const ended = body["ended"]; const testCmd = str("testCmd"); return { - schemaVersion: version, + schemaVersion, kind: HANDOFF_KIND, sessionId, task, model: str("model"), - brain, + brain: body["brain"] === "cloud" ? "cloud" : "local", started: str("started"), - ended: typeof ended === "string" ? ended : null, + ended: str("ended") || null, finalStatus: str("finalStatus") || "unknown", ...(typeof remaining === "number" && remaining > 0 ? { remaining } : {}), - ...(remoteId || branchId || headId - ? { repo: { ...(remoteId && { remote: remoteId }), ...(branchId && { branch: branchId }), ...(headId && { head: headId }) } } - : {}), - highlights: strings("highlights").map((h) => clip(h)).slice(-MAX_HIGHLIGHTS), - filesTouched: strings("filesTouched").slice(0, MAX_FILES), + ...(identity ? { repo: identity } : {}), + // Bound BEFORE sanitizing: a hostile file with 10k highlights should cost 40 + // clips, not 10k. + highlights: strings("highlights").slice(-MAX_HIGHLIGHTS).map((h) => clip(h)), + filesTouched: strings("filesTouched").slice(0, MAX_FILES).map((f) => clip(f)), ...(testCmd ? { testCmd } : {}), }; } export function writeHandoff(path: string, handoff: Handoff): void { - writeFileSync(path, JSON.stringify(handoff, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + // Atomic like every other durable file this CLI owns (config, goals, history, + // mcp store): an interrupted export must not destroy a good handoff or leave + // half a JSON document that `--resume` will refuse. Creates missing parents, + // so `--out reports/handoff.json` works. + atomicWriteFile(path, JSON.stringify(handoff, null, 2) + "\n"); } export function readHandoff(path: string): Handoff { - return parseHandoff(JSON.parse(readFileSync(path, "utf8"))); + const read = readJsonFile(path); + if (!read.ok) { + if (read.reason === "missing") { + throw new Error(`no handoff file at ${path} — write one with: aether resume export --out ${path}`); + } + throw new Error(`cannot read handoff ${path}: ${read.detail}`); + } + return parseHandoff(read.value); +} + +/** A `--resume` value that names a FILE rather than a local session id. + * + * The two branches must partition the input against the SAME rule the loader + * enforces, or a value can fall through both: `requireOpaqueId` (the canonical + * session-id shape, and what `loadSession` calls a moment later) is the rule, + * so anything it rejects — a Windows or POSIX separator, `..`, `~/`, a dotted + * name — is a path. The `.json` shortcut keeps an ordinary bare filename on + * the file side even though it is a legal id. */ +export function isHandoffPath(value: string): boolean { + if (value.toLowerCase().endsWith(".json")) return true; + try { + requireOpaqueId(value, "session id"); + return false; + } catch { + return true; + } +} + +/** What a `--resume` reference resolved to: always a handoff, plus the loaded + * session when the reference was a local id (the file form has no transcript — + * that is the point of it). Returned together so the caller reads the log once + * and the file-vs-id decision is made in exactly one place. */ +export interface ResolvedResume { + handoff: Handoff; + session: LoadedSession | null; +} + +/** + * Resolve a `--resume` value. + * + * A file is never workspace-scoped: importing one is an explicit act by the + * person holding it, and its whole purpose is to land in a checkout whose + * absolute path does not match where the work started. A session id still is. + */ +export function resolveResume( + value: string, + cwd: string, + load: typeof loadSession = loadSession, +): ResolvedResume { + const ref = value.trim(); + if (!ref) throw new Error("--resume needs a session id or a handoff file"); + if (isHandoffPath(ref)) return { handoff: readHandoff(ref), session: null }; + const session = load(ref, logsRoot(), cwd); + return { handoff: buildHandoff(session), session }; } /** @@ -275,45 +361,11 @@ export function continuationTask(h: Handoff, nextTask: string): string { return `${continuationBrief(h)}\n\n## Your task now\n\n${next}\n`; } -/** - * Resolve a `--resume` value into a handoff. - * - * Two shapes, distinguished without guessing: a session id is an opaque - * directory name under the logs root (no separators, no extension), so anything - * carrying a path separator or ending in .json is read as a handoff FILE — the - * form that came from another machine. Everything else is a local session id - * and is distilled on the spot. - * - * A file is never workspace-scoped: importing one is an explicit act by the - * person holding it, and its whole purpose is to land in a checkout whose - * absolute path does not match where the work started. - */ -export function resolveHandoff( - value: string, - cwd: string, - load: (id: string, root: string, scope: string) => LoadedSession = defaultLoadSession, - root?: string, -): Handoff { - const ref = value.trim(); - if (!ref) throw new Error("--resume needs a session id or a handoff file"); - if (isHandoffPath(ref)) { - try { - return readHandoff(ref); - } catch (err) { - if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { - throw new Error(`no handoff file at ${ref} — write one with: aether resume export --out ${ref}`); - } - const why = err instanceof Error ? err.message : String(err); - throw new Error(`cannot read handoff ${ref}: ${why}`); - } - } - return buildHandoff(load(ref, root ?? logsRoot(), cwd)); -} - -/** A --resume value that names a file on disk rather than a local session id. */ -export function isHandoffPath(value: string): boolean { - return /[\/]/.test(value) || value.toLowerCase().endsWith(".json"); +/** The lines shown to the HUMAN for what is being continued: a local session + * replays its whole transcript, a handoff file has only its highlights (it + * never carried a transcript — that is the point of it). */ +export function resumeReplayLines(resolved: ResolvedResume, ref: string): string[] { + if (resolved.session) return replayLines(resolved.session.events); + const h = resolved.handoff; + return [`⇄ continuing ${h.sessionId} (${h.finalStatus}) from ${ref}`, ...h.highlights.map((l) => " " + l)]; } - -const defaultLoadSession = (id: string, root: string, scope: string): LoadedSession => - loadSession(id, root, scope); diff --git a/src/main.ts b/src/main.ts index 14cf04f..89b8884 100644 --- a/src/main.ts +++ b/src/main.ts @@ -209,8 +209,10 @@ async function main(argv: string[]): Promise { }); } case "resume": { - const { cmdResume } = await import("./commands/resume.js"); - return cmdResume(ctx, rest.join(" "), sf(values["out"])); + const { cmdResume, cmdResumeExport } = await import("./commands/resume.js"); + return rest[0] === "export" + ? cmdResumeExport(ctx, rest[1] ?? "", sf(values["out"])) + : cmdResume(ctx, rest[0] ?? ""); } case "chat": return cmdChat(ctx, rest.join(" ")); diff --git a/test/handoff.test.ts b/test/handoff.test.ts index 0093c33..71c5ea3 100644 --- a/test/handoff.test.ts +++ b/test/handoff.test.ts @@ -13,10 +13,11 @@ import { parseHandoff, readHandoff, readRepoIdentity, - resolveHandoff, + resolveResume, summarizeEvents, + wroteFile, writeHandoff, - type Handoff, + type ResolvedResume, } from "../src/core/handoff.js"; import type { LoadedSession } from "../src/core/session_resume.js"; @@ -173,51 +174,63 @@ test("continuationTask puts the new instruction after the brief", () => { assert.ok(text.indexOf("Prior session") < text.indexOf("now delete the dead branch")); }); -test("isHandoffPath separates a file from an opaque session id", () => { +test("isHandoffPath partitions the input against the session-id rule", () => { + // The two branches must agree with requireOpaqueId — the rule loadSession + // itself enforces — or a value falls through both. A hand-rolled separator + // class is exactly how that goes wrong: these Windows cases used to be + // classified as session ids and die as "invalid session id". assert.equal(isHandoffPath("./aether-handoff.json"), true); assert.equal(isHandoffPath("C:\\work\\handoff.json"), true); assert.equal(isHandoffPath("handoff.json"), true); + assert.equal(isHandoffPath("C:\\work\\handoff"), true, "a Windows path without .json is still a path"); + assert.equal(isHandoffPath("..\\out\\handoff"), true); + assert.equal(isHandoffPath("../out/handoff"), true); + assert.equal(isHandoffPath("~/handoff"), true); + assert.equal(isHandoffPath(".."), true); assert.equal(isHandoffPath("2026-08-19T10-00-00-000Z-local-4242"), false); }); -test("resolveHandoff reads a FILE without any workspace check", () => { +test("resolveResume reads a FILE without any workspace check", () => { // The whole point of the file form: it lands in a checkout whose absolute // path does not match where the work started. const dir = mkdtempSync(join(tmpdir(), "aether-handoff-")); try { const path = join(dir, "handoff.json"); writeHandoff(path, buildHandoff(session([]))); - const h: Handoff = resolveHandoff(path, "C:\\somewhere\\entirely\\else"); - assert.equal(h.sessionId, "s1"); + const resolved: ResolvedResume = resolveResume(path, "C:\\somewhere\\entirely\\else"); + assert.equal(resolved.handoff.sessionId, "s1"); + assert.equal(resolved.session, null, "the file form carries no transcript — that is the point"); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("resolveHandoff explains an unreadable handoff file by name", () => { +test("resolveResume explains an unreadable handoff file by name", () => { const dir = mkdtempSync(join(tmpdir(), "aether-handoff-")); try { const path = join(dir, "broken.json"); writeFileSync(path, "{ not json", "utf8"); - assert.throws(() => resolveHandoff(path, dir), /cannot read handoff/); + assert.throws(() => resolveResume(path, dir), /cannot read handoff/); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("resolveHandoff distils a local session id through the injected loader", () => { +test("resolveResume distils a local session id through the injected loader", () => { const loaded = session([{ type: "monologue", text: "did a thing", depth: 0 }]); - const h = resolveHandoff("s1", "/work", (id, _root, scope) => { + const r = resolveResume("s1", "/work", (id, _root, scope) => { assert.equal(id, "s1"); assert.equal(scope, "/work"); return loaded; }); - assert.equal(h.sessionId, "s1"); - assert.ok(h.highlights.includes("did a thing")); + assert.equal(r.handoff.sessionId, "s1"); + assert.ok(r.handoff.highlights.includes("did a thing")); + // The session comes back with the handoff, so the caller never re-reads it. + assert.equal(r.session, loaded); }); -test("resolveHandoff rejects an empty reference", () => { - assert.throws(() => resolveHandoff(" ", "/work"), /needs a session id or a handoff file/); +test("resolveResume rejects an empty reference", () => { + assert.throws(() => resolveResume(" ", "/work"), /needs a session id or a handoff file/); }); test("readRepoIdentity is best-effort: a non-repo yields nothing, not an error", () => { @@ -252,10 +265,38 @@ test("a missing handoff file says how to make one", () => { const dir = mkdtempSync(join(tmpdir(), "aether-handoff-")); try { assert.throws( - () => resolveHandoff(join(dir, "absent.json"), dir), + () => resolveResume(join(dir, "absent.json"), dir), /no handoff file at .*absent\.json — write one with: aether resume export/, ); } finally { rmSync(dir, { recursive: true, force: true }); } }); + +test("wroteFile is the ONE definition of 'the run changed a file'", () => { + // cmdCode's live blast-radius set and a handoff's filesTouched both call this, + // so they cannot disagree about what counts as a write. + assert.equal(wroteFile({ type: "tool_call", id: "1", name: "write_file", args: { path: "a.ts" } }), "a.ts"); + assert.equal(wroteFile({ type: "tool_call", id: "2", name: "read_file", args: { path: "a.ts" } }), null); + assert.equal(wroteFile({ type: "tool_call", id: "3", name: "write_file", args: {} }), null); + assert.equal(wroteFile({ type: "tool_call", id: "4", name: "write_file", args: { path: "" } }), null); + assert.equal(wroteFile({ type: "monologue", text: "write_file", depth: 0 }), null); +}); + +test("a handoff cannot smuggle terminal escapes into the brief", () => { + // Handoff files arrive from another machine, and their strings are both + // printed to the terminal and prepended to the brain's prompt. + const esc = "\x1b"; + const h = parseHandoff({ + kind: HANDOFF_KIND, + schemaVersion: 1, + sessionId: "s1", + task: `clean ${esc}[31mup${esc}[0m the parser`, + highlights: [`${esc}]0;pwned${esc}\did a thing`], + filesTouched: [`src/${esc}[2Ka.ts`], + }); + const brief = continuationBrief(h); + assert.equal(brief.includes(esc), false, "no escape byte survives into the brief"); + assert.match(brief, /clean up the parser/); + assert.match(brief, /did a thing/); +}); diff --git a/test/resume_cmd.test.ts b/test/resume_cmd.test.ts index 5dc8849..9649eb7 100644 --- a/test/resume_cmd.test.ts +++ b/test/resume_cmd.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { cmdResume, DEFAULT_HANDOFF_FILE, resumeHint } from "../src/commands/resume.js"; +import { cmdResume, cmdResumeExport, DEFAULT_HANDOFF_FILE, resumeHint } from "../src/commands/resume.js"; import type { AppContext } from "../src/core/context.js"; test("resumeHint quotes the exact re-entry command", () => { @@ -15,7 +15,7 @@ test("resumeHint quotes the exact re-entry command", () => { // `aether resume export` is the machine-to-machine half of resume: it turns the // local session log into one file you can carry. These tests drive the real -// command against a seeded log root, capturing stdout/stderr. +// commands against a seeded log root, capturing stdout/stderr. function seedSession(root: string, id: string, cwd: string): void { const dir = join(root, id); @@ -54,111 +54,124 @@ function fakeContext(cwd: string): AppContext { } as unknown as AppContext; } -/** Run one command with stdout/stderr captured. */ -async function capture(run: () => Promise): Promise<{ code: number; out: string; err: string }> { - const realOut = process.stdout.write.bind(process.stdout); - const realErr = process.stderr.write.bind(process.stderr); - let out = ""; - let err = ""; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (process.stdout as any).write = (chunk: string): boolean => ((out += chunk), true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (process.stderr as any).write = (chunk: string): boolean => ((err += chunk), true); - try { - const code = await run(); - return { code, out, err }; - } finally { - process.stdout.write = realOut; - process.stderr.write = realErr; - } +interface Fixture { + /** Directory the session log root lives in (AETHER_LOG_DIR). */ + logs: string; + /** The "workspace" the session belongs to, and the command's cwd. */ + work: string; + /** Scratch root, for --out targets outside the workspace. */ + root: string; + ctx: AppContext; } -test("`aether resume export` writes a handoff next to the work by default", async () => { +/** Run one case against an isolated log root, with stdout/stderr captured. + * Everything — temp dirs, the AETHER_LOG_DIR override, both streams — is + * restored on the way out, so a failing assertion cannot leak the log root + * into every later test in the process. */ +function withLogRoot( + body: (f: Fixture) => number, + seed: (f: Fixture) => void = () => {}, +): { code: number; out: string; err: string } { const root = mkdtempSync(join(tmpdir(), "aether-resume-")); const logs = join(root, "logs"); const work = join(root, "work"); mkdirSync(work, { recursive: true }); - const previous = process.env["AETHER_LOG_DIR"]; + mkdirSync(logs, { recursive: true }); + const previousLogDir = process.env["AETHER_LOG_DIR"]; + const realOut = process.stdout.write.bind(process.stdout); + const realErr = process.stderr.write.bind(process.stderr); + let out = ""; + let err = ""; process.env["AETHER_LOG_DIR"] = logs; + const fixture: Fixture = { logs, work, root, ctx: fakeContext(work) }; try { - seedSession(logs, "s1", work); - const { code, out } = await capture(() => cmdResume(fakeContext(work), "export")); - assert.equal(code, 0); - const written = join(work, DEFAULT_HANDOFF_FILE); - assert.ok(existsSync(written), "handoff file was written"); - const handoff = JSON.parse(readFileSync(written, "utf8")); - assert.equal(handoff.kind, "aether-agent-handoff"); - assert.equal(handoff.sessionId, "s1"); - assert.equal(handoff.model, "qwen3:4b"); - assert.equal(handoff.remaining, 1); - assert.equal(handoff.testCmd, "npm test"); - assert.deepEqual(handoff.filesTouched, ["src/parse.ts"]); - // The command has to tell the user how to spend what it just made. - assert.match(out, /aether agent --resume/); + seed(fixture); + (process.stdout as { write: unknown }).write = (chunk: string): boolean => ((out += chunk), true); + (process.stderr as { write: unknown }).write = (chunk: string): boolean => ((err += chunk), true); + try { + return { code: body(fixture), out, err }; + } finally { + process.stdout.write = realOut; + process.stderr.write = realErr; + } } finally { - if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; - else process.env["AETHER_LOG_DIR"] = previous; + if (previousLogDir === undefined) delete process.env["AETHER_LOG_DIR"]; + else process.env["AETHER_LOG_DIR"] = previousLogDir; rmSync(root, { recursive: true, force: true }); } +} + +const seedOne = (f: Fixture): void => seedSession(f.logs, "s1", f.work); + +test("`aether resume export` writes a handoff next to the work by default", () => { + let written = ""; + const { code, out } = withLogRoot((f) => { + written = join(f.work, DEFAULT_HANDOFF_FILE); + const result = cmdResumeExport(f.ctx, ""); + // Read inside the fixture — the temp tree is removed on the way out. + if (existsSync(written)) written = readFileSync(written, "utf8"); + return result; + }, seedOne); + assert.equal(code, 0); + const handoff = JSON.parse(written); + assert.equal(handoff.kind, "aether-agent-handoff"); + assert.equal(handoff.sessionId, "s1"); + assert.equal(handoff.model, "qwen3:4b"); + assert.equal(handoff.remaining, 1); + assert.equal(handoff.testCmd, "npm test"); + assert.deepEqual(handoff.filesTouched, ["src/parse.ts"]); + // The command has to tell the user how to spend what it just made. + assert.match(out, /aether agent --resume/); }); -test("`aether resume export --out ` honours the destination", async () => { - const root = mkdtempSync(join(tmpdir(), "aether-resume-")); - const logs = join(root, "logs"); - const work = join(root, "work"); - mkdirSync(work, { recursive: true }); - const previous = process.env["AETHER_LOG_DIR"]; - process.env["AETHER_LOG_DIR"] = logs; - try { - seedSession(logs, "s1", work); - const target = join(root, "carried.json"); - const { code } = await capture(() => cmdResume(fakeContext(work), "export s1", target)); - assert.equal(code, 0); - assert.equal(JSON.parse(readFileSync(target, "utf8")).sessionId, "s1"); - } finally { - if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; - else process.env["AETHER_LOG_DIR"] = previous; - rmSync(root, { recursive: true, force: true }); - } +test("`aether resume export --out ` honours the destination", () => { + let written = ""; + const { code } = withLogRoot((f) => { + const target = join(f.root, "carried.json"); + const result = cmdResumeExport(f.ctx, "s1", target); + if (existsSync(target)) written = readFileSync(target, "utf8"); + return result; + }, seedOne); + assert.equal(code, 0); + assert.equal(JSON.parse(written).sessionId, "s1"); }); -test("`aether resume export` with no sessions fails loudly rather than writing an empty file", async () => { - const root = mkdtempSync(join(tmpdir(), "aether-resume-")); - const logs = join(root, "logs"); - const work = join(root, "work"); - mkdirSync(work, { recursive: true }); - mkdirSync(logs, { recursive: true }); - const previous = process.env["AETHER_LOG_DIR"]; - process.env["AETHER_LOG_DIR"] = logs; - try { - const { code, err } = await capture(() => cmdResume(fakeContext(work), "export")); - assert.equal(code, 1); - assert.match(err, /no sessions to resume/); - assert.equal(existsSync(join(work, DEFAULT_HANDOFF_FILE)), false); - } finally { - if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; - else process.env["AETHER_LOG_DIR"] = previous; - rmSync(root, { recursive: true, force: true }); - } +test("`aether resume export --out` creates a missing parent directory", () => { + // Atomic writes carry mkdir -p, so `--out reports/handoff.json` works from a + // clean checkout instead of failing with ENOENT. + let existed = false; + const { code } = withLogRoot((f) => { + const target = join(f.root, "reports", "nested", "carried.json"); + const result = cmdResumeExport(f.ctx, "s1", target); + existed = existsSync(target); + return result; + }, seedOne); + assert.equal(code, 0); + assert.equal(existed, true); }); -test("`aether resume` replay points at both re-entry routes", async () => { - const root = mkdtempSync(join(tmpdir(), "aether-resume-")); - const logs = join(root, "logs"); - const work = join(root, "work"); - mkdirSync(work, { recursive: true }); - const previous = process.env["AETHER_LOG_DIR"]; - process.env["AETHER_LOG_DIR"] = logs; - try { - seedSession(logs, "s1", work); - const { code, out } = await capture(() => cmdResume(fakeContext(work), "")); - assert.equal(code, 0); - assert.match(out, /the tokenizer rejects it/); - assert.match(out, /aether agent --resume s1/); - assert.match(out, /aether resume export s1/); - } finally { - if (previous === undefined) delete process.env["AETHER_LOG_DIR"]; - else process.env["AETHER_LOG_DIR"] = previous; - rmSync(root, { recursive: true, force: true }); - } +test("`aether resume export` with no sessions fails loudly rather than writing an empty file", () => { + let leftBehind = true; + const { code, err } = withLogRoot((f) => { + const result = cmdResumeExport(f.ctx, ""); + leftBehind = existsSync(join(f.work, DEFAULT_HANDOFF_FILE)); + return result; + }); + assert.equal(code, 1); + assert.match(err, /no sessions to resume/); + assert.equal(leftBehind, false); +}); + +test("`aether resume` replay points at both re-entry routes", () => { + const { code, out } = withLogRoot((f) => cmdResume(f.ctx, ""), seedOne); + assert.equal(code, 0); + assert.match(out, /the tokenizer rejects it/); + assert.match(out, /aether agent --resume s1/); + assert.match(out, /aether resume export s1/); +}); + +test("an unknown session id is reported, not swallowed", () => { + const { code, err } = withLogRoot((f) => cmdResume(f.ctx, "no-such-session"), seedOne); + assert.equal(code, 1); + assert.match(err, /no such session/); }); From e78bbe7868d0ae3561ed3c39f0233e8d5e953c83 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:29:00 -0400 Subject: [PATCH 3/5] =?UTF-8?q?release:=20v0.2.0=20=E2=80=94=20the=20work?= =?UTF-8?q?=20outlives=20the=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's first screen led with the category ("a coding agent for your terminal"), which is true of every tool on the shelf. It now leads with the two things that are specific to this one and reproducible from a clean checkout: the verify gate decides when a run is done, and a run's context can leave the machine it started on. - README hero: "Start a task on one model. Finish it on another, on another machine. Your tests decide when it's done." — followed by the install command, a runnable handoff example, and `npm run demo:handoff`. - Two claims corrected rather than restated. The local brain was credited to the Unlimited Context engine, which lives in a separate Python package the npm tarball does not carry; the shipped offline brain talks to Ollama directly, and the Python one is now described as the opt-in it is. `--test-cmd` was documented as defaulting to `pytest -q`; it has no default, and a run without one ends `unverified`, never `ok`. - The version bumps to 0.2.0. npm still serves 0.1.0 from June, so every `npm i -g aether-agents` today predates agent dev sessions, the media history, doctor v2, and everything in this release. Publishing is release-triggered (.github/workflows/release.yml), so cutting the tag is what ships it. - The production verifier's test read the expected version from a literal "v0.1.0". It reads package.json now: a version bump is a release step, not a reason for the release gate to go red. Release notes in RELEASE_NOTES.md and docs/releases/2026-08-19.md. --- README.md | 68 +++++++++++++++++++++++++++---- RELEASE_NOTES.md | 35 ++++++++++++++++ docs/releases/2026-08-19.md | 65 +++++++++++++++++++++++++++++ docs/releases/README.md | 1 + install.sh | 2 +- package.json | 2 +- src/version.ts | 2 +- test/production_hardening.test.ts | 7 +++- 8 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 docs/releases/2026-08-19.md diff --git a/README.md b/README.md index 5cd3c9b..b0fc003 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ # Aether Agent -**A coding agent for your terminal — runs on hosted frontier models or fully offline on your own machine.** +**Start a task on one model. Finish it on another, on another machine. +Your tests decide when it's done.** [![CI](https://github.com/AetherAI3/aether-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/AetherAI3/aether-agent/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-06b6d4)](LICENSE) [![Node](https://img.shields.io/badge/node-%E2%89%A524-14b8a6)](https://nodejs.org) [![TypeScript](https://img.shields.io/badge/TypeScript-7-3178c6)](https://www.typescriptlang.org/) [![Release notes](https://img.shields.io/badge/release-notes-7c3aed)](RELEASE_NOTES.md) -**Aether Agent is in beta.** Updates are shipping quickly. ```bash -npm i -g aether-agents --ignore-scripts # or run once: npx --ignore-scripts aether-agents +npm i -g aether-agents --ignore-scripts # Node ≥ 24 · no account needed to start +aether agent "make the failing tests pass" ``` -[Install](#install-in-three-moves) · [Models & pricing](#models--pricing) · [Commands](#commands) · [Security](#security) · [Platform](#part-of-the-aether-platform) · [Release notes](RELEASE_NOTES.md) +[Carry the work](#carry-the-work-across-models-and-machines) · [Install](#install-in-three-moves) · [Models & pricing](#models--pricing) · [Commands](#commands) · [Security](#security) · [Platform](#part-of-the-aether-platform) · [Release notes](RELEASE_NOTES.md) Aether Agent — terminal coding session @@ -19,7 +20,50 @@ npm i -g aether-agents --ignore-scripts # or run once: npx --ignore-scripts -It scans, plans, edits, and runs your tests — in your repo, on your terms. Verification is ground truth: the agent re-runs your test command and reads the exit code itself, so "done" is never the model's word. And with **QOPC memory** it learns from what you accept, revise, or discard — measurably better the more you use it, no config, no fine-tuning. +Aether Agent scans, plans, edits, and runs your tests — in your repo, on your terms. +Two things make it different from the rest of the terminal-agent shelf: + +- **Verification is ground truth.** The agent re-runs *your* test command at the end + and reads the exit code itself. A model that says "done" over a red tree gets + marked `incomplete`, and the process exits non-zero. "Done" is never the model's word. +- **The work outlives the session.** Every run leaves a local record. `aether resume + export` turns that record into a single portable file — copy it to another checkout, + another machine, another OS, and `aether agent --resume ` picks the thread up + on whatever model you want, with no chat history to re-paste. + +**Aether Agent is in beta.** Updates are shipping quickly. + +## Carry the work across models and machines + +```bash +# machine A — start it on a hosted frontier model +aether agent --model opus5 "make the slugify tests pass" +aether resume export --out handoff.json # ⇄ one file: task, verdict, files, repo + +# machine B (or the same one, offline) — continue on a different brain +aether agent --local --model qwen2.5-coder:7b --resume handoff.json +``` + +The handoff is a summary, not a transcript: the task, which model ran it, the verify +gate's verdict, how many tests were still failing, the files that changed, and the +repository it belongs to. Nothing is keyed to an absolute path, so the receiving +checkout does not have to live where the work started — and no file contents, shell +commands, or credential-shaped values ride along. The next model reads it as a brief +and continues; you never re-paste the conversation. + +Run the whole thing yourself, end to end, in about five seconds: + +```bash +npm run demo:handoff # two sessions, two models, two checkouts, one verify gate +``` + +The demo builds a throwaway git repo with a real failing test, runs the real CLI on +model A, exports the handoff, **deletes machine A's checkout and logs**, and finishes +the job in a second checkout on model B. By default the model is a scripted local stub +so the run is deterministic and needs no download or account; `AETHER_DEMO_REAL=1` +runs the identical script against real Ollama models. Either way the last word belongs +to `node --test`, run independently of the agent. See +[`docs/demo/handoff.md`](docs/demo/handoff.md). ## Install in three moves @@ -39,9 +83,12 @@ ollama pull qwen2.5-coder:7b # 03 — or go offline: no account, no network aether agent --local # …same terminal, nothing leaves the machine ``` -`aether agent` opens the REPL — chat with the model, slash-commands at hand, the agent edits files and runs your tests **in the same session**. Both brains run through the same host loop, render, tools, and commands — switching just swaps the transport. On the hosted path your code stays local and only the prompt + context you send leaves; on `--local`, nothing leaves at all. The local brain runs on **[Unlimited Context](https://github.com/AetherAI3/Unlimited-Context-LLM)** — Aether's open-source (Apache-2.0) memory engine that gives any Ollama model a billion-token working memory. -> Prefer the installer UI? Download [`install.sh`](install.sh) or [`install.ps1`](install.ps1), inspect it, then run it locally. Set `AETHER_VERSION=0.1.0` (shell) or `-Version 0.1.0` (PowerShell) to pin an exact release. The canonical npm command above verifies registry integrity and disables lifecycle scripts; there are no native or runtime dependencies and no daemon. +`aether agent` opens the REPL — chat with the model, slash-commands at hand, the agent edits files and runs your tests **in the same session**. Both brains run through the same host loop, render, tools, and commands — switching just swaps the transport. On the hosted path your code stays local and only the prompt + context you send leaves; on `--local`, nothing leaves at all. The offline brain is built into the package: it talks straight to Ollama over its OpenAI-compatible endpoint, with the same eight tools and the same permission gate, so `--local` needs nothing beyond Node and `ollama serve`. + +> Running the separate Python brain instead — Aether's open-source (Apache-2.0) **[Unlimited Context](https://github.com/AetherAI3/Unlimited-Context-LLM)** engine, which gives an Ollama model a billion-token working memory — is opt-in with `AETHER_LOCAL_BRAIN=python` once you have installed it. It is not bundled with the npm package. + +> Prefer the installer UI? Download [`install.sh`](install.sh) or [`install.ps1`](install.ps1), inspect it, then run it locally. Set `AETHER_VERSION=0.2.0` (shell) or `-Version 0.2.0` (PowerShell) to pin an exact release. The canonical npm command above verifies registry integrity and disables lifecycle scripts; there are no native or runtime dependencies and no daemon. ## Models & pricing @@ -98,7 +145,9 @@ Inside the REPL, `/` commands control the whole session — type `/help` to see aether agent # the main thing — open the REPL and chat aether agent --local # same REPL on a local Ollama brain (offline) aether models # list models + orchestrators -aether resume # replay / continue the last session +aether resume # replay the last session in this workspace +aether resume export # write a portable handoff for another machine +aether agent --resume # continue it — on any model, with the context ``` Flags you can set when launching the REPL (or pass with an inline task `aether agent ""` for one-shot autonomous mode): @@ -108,7 +157,8 @@ Flags you can set when launching the REPL (or pass with an inline task `aether a | `--local` | Local Ollama brain instead of the hosted API. | | `--model ` | Force a model by key (`--model opus5`, `--model gpt56_terra`, or an Ollama tag with `--local`). | | `--effort ` | Budget ceiling: `LOW` · `MED` · `MAX` · `ULTRA` · `CODEPRO`. | -| `--test-cmd ` | Command the verification gate runs (default `pytest -q`). | +| `--test-cmd ` | Command the verification gate runs. With none, a run ends `unverified` — never `ok`. | +| `--resume ` | Continue a prior session, or a handoff file from another machine. | | `--worktree` | Fresh git worktree on an auto-named branch (isolated). | | `--repo ` | Clone a GitHub repo via your own `gh`/`git` auth, work it in a worktree. | | `-y`, `--yes` | Auto-confirm prompts (non-interactive). | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9979c4c..b521887 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -39,6 +39,41 @@ history migrates on first read. Full detail in --- +# Aether Agent v0.2.0 — the work outlives the session + +**August 19, 2026** + +Start a task on one model. Finish it on another, on another machine. Your tests +decide when it's done. + +- **Handoffs** — `aether resume export` writes one portable file: the task, the + model that ran it, the verify gate's verdict, how many tests were still + failing, the files that changed, the verification command, and the repository + it belongs to. Copy it anywhere and continue with `aether agent --resume + `, on whatever model you want. Nothing in it is keyed to an absolute + path, so the receiving checkout does not have to live where the work started — + and no file contents, shell commands, or credential-shaped values ride along. +- **`--resume` now reaches the brain** — the prior session is summarized into a + continuation brief the model reads before its own instruction, rather than + being replayed only for the human. With no new task, the run continues the + original one. No re-pasted chat history. +- **`aether agent --local ""` works after a plain npm install** — the + one-shot offline form used to spawn the separately-installed Python brain and + die with `spawn python ENOENT`. It now drives the Ollama brain that ships in + the package. `AETHER_LOCAL_BRAIN=python` opts back in. +- **Session logs stopped redacting your file paths** — the credential filter + matched `pat` inside `path`, so every edited file in every log read + `[REDACTED]`. Real credential keys are still redacted. +- **A run that never reached your tests no longer reports a failing one.** +- **`npm run demo:handoff`** — a five-second deterministic proof of all of it: + two sessions, two models, two checkouts, one verify gate, no account and no + model download. See [`docs/demo/handoff.md`](docs/demo/handoff.md). + +Upgrade with `npm i -g aether-agents --ignore-scripts`. No configuration +changes, no migration; 0.1.x session logs are read unchanged. + +--- + # Aether Agent — the API brain goes bidirectional **August 12, 2026** diff --git a/docs/releases/2026-08-19.md b/docs/releases/2026-08-19.md new file mode 100644 index 0000000..9342a8d --- /dev/null +++ b/docs/releases/2026-08-19.md @@ -0,0 +1,65 @@ +# Release notes — 2026-08-19 + +**v0.2.0 — the work outlives the session.** + +A coding session used to end at the edge of your terminal. `aether resume` could +replay a transcript to the screen, but the next model never saw a byte of it, so +continuing meant re-pasting the story so far — and only ever on the machine the +work started on. This release closes that. + +## Highlights + +- **Handoffs.** `aether resume export` writes one portable file carrying the + task, the model that ran it, the verify gate's verdict, the failing-test + count, the files the run changed, the verification command, and the repository + identity. Copy it to another checkout, machine, or OS and continue there with + `aether agent --resume ` — on any model. Nothing in it is keyed to an + absolute path, and no file contents, shell commands, or credential-shaped + values ride along. +- **`--resume` now reaches the brain.** The prior session is summarized into a + continuation brief the model reads before its own instruction, instead of + being replayed only for the human. With no new task, the run continues the + original one. +- **`aether agent --local ""` works out of the box.** The one-shot offline + form spawned the separately-installed Python brain unconditionally, so a plain + `npm i -g aether-agents` could only ever answer `spawn python ENOENT`. It now + drives the Ollama brain that ships inside the package — the same one the REPL's + `--local` turns have always used. `AETHER_LOCAL_BRAIN=python` opts back in to + the Unlimited-Context brain when you have it installed. +- **Session logs stopped redacting your file paths.** The credential filter + matched `pat` as a substring, so `path` — and `PATH`, `patch`, `pattern` — + were stored as `[REDACTED]`. Every session log could say a file changed but + not which one. Credential-shaped keys (`pat`, `gh_pat`, `pat_token`) are still + redacted. +- **A run that never reached your tests no longer claims a failing one.** The + offline brain reported a placeholder `remaining: 1`, so an unreachable-Ollama + run printed `1 test failing` when nothing had been run. The failing count now + comes only from the host's own test run. +- **`npm run demo:handoff`.** A five-second, deterministic, end-to-end proof of + all of the above: two sessions, two models, two checkouts, one verify gate, no + account and no model download. Documented in + [`docs/demo/handoff.md`](../demo/handoff.md). + +## Documentation corrections + +- The README credited the local brain to the Unlimited Context engine. That + engine lives in a separate Python package that the npm tarball does not carry; + the shipped offline brain talks to Ollama directly. Both are now described for + what they are. +- `--test-cmd` was documented as defaulting to `pytest -q`. It has no default: + without one a run ends `unverified`, and never `ok`. + +## Upgrading + +```bash +npm i -g aether-agents --ignore-scripts +``` + +No configuration changes and no migration. Session logs written by 0.1.x are +read unchanged; handoffs exported from them simply omit the verification command, +which older logs did not record. Handoff files carry a `schemaVersion`, and a +file written by a newer Agent is refused with an upgrade hint rather than being +half-read. + +If you were relying on `aether agent --local ""` to spawn the Python brain, +set `AETHER_LOCAL_BRAIN=python`. diff --git a/docs/releases/README.md b/docs/releases/README.md index 6922f6a..b621ee7 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -8,5 +8,6 @@ For the live command reference, see [COMMANDS.md](../COMMANDS.md). ## Index +- [2026-08-19](2026-08-19.md) — **v0.2.0**: portable handoffs, `--resume` reaches the brain, `--local ""` works out of the box. - [2026-08-14](2026-08-14.md) — Durable media output history, one safe opener, and `aether doctor` v2 (fast / `--live` / `--fix`). - [2026-06-09](2026-06-09.md) — Aether Agent rebrand + slash-command console (PRs #4–#16). diff --git a/install.sh b/install.sh index d449246..28f37a7 100644 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ set -eu -# Pin a specific release with AETHER_VERSION=0.1.0. The default follows npm's +# Pin a specific release with AETHER_VERSION=0.2.0. The default follows npm's # latest dist-tag, while the quoted package spec prevents shell interpretation. AETHER_VERSION="${AETHER_VERSION:-latest}" case "$AETHER_VERSION" in diff --git a/package.json b/package.json index 5d95ac9..1c38d6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aether-agents", - "version": "0.1.0", + "version": "0.2.0", "description": "Open-source terminal coding agent — runs on hosted frontier models (Claude, GPT, DeepSeek, Kimi, Gemma) or fully offline via Ollama. Edits your code, runs your tests, and verifies the result.", "type": "module", "bin": { diff --git a/src/version.ts b/src/version.ts index 8bb26cc..f26929c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Single source of the CLI version (kept in lockstep with package.json). -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0"; diff --git a/test/production_hardening.test.ts b/test/production_hardening.test.ts index b51fa55..c815bac 100644 --- a/test/production_hardening.test.ts +++ b/test/production_hardening.test.ts @@ -105,9 +105,12 @@ test("operator-facing API defaults match the production transport path", () => { }); test("production verifier installs and launches the exact packed CLI", { timeout: 60_000 }, () => { - const result = verifyProduction(process.cwd(), "v0.1.0"); + // Read the expected release from package.json rather than pinning a literal: + // a version bump is a release step, not a reason for this gate to go red. + const expected = (JSON.parse(readFileSync("package.json", "utf8")) as { version: string }).version; + const result = verifyProduction(process.cwd(), `v${expected}`); assert.equal(result.package, "aether-agents"); - assert.equal(result.version, "0.1.0"); + assert.equal(result.version, expected); assert.equal(result.workflows, 3); assert.ok(result.packedFiles > 0); assert.ok(result.packedBytes > 0); From 66f7e059e0fbcbbe0a8a8c5769fd4800a9d9a686 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:31:43 -0400 Subject: [PATCH 4/5] docs(release): note that an unreadable --resume reference now stops the run --- docs/releases/2026-08-19.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releases/2026-08-19.md b/docs/releases/2026-08-19.md index 9342a8d..bbd2c99 100644 --- a/docs/releases/2026-08-19.md +++ b/docs/releases/2026-08-19.md @@ -63,3 +63,9 @@ half-read. If you were relying on `aether agent --local ""` to spawn the Python brain, set `AETHER_LOCAL_BRAIN=python`. + +One behaviour change worth knowing: `--resume` with a reference that cannot be +read — an unknown session id, a session belonging to another workspace, a +missing or corrupt handoff file — now stops the run with an explanatory line. +It used to print the error and carry on without the context you asked for, +which is the one outcome nobody wants from a resume. From 26067569dc4a75d81c8c07c740f93ca3da991c09 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:32:48 -0400 Subject: [PATCH 5/5] =?UTF-8?q?docs(readme):=20say=20what=20the=20first=20?= =?UTF-8?q?screen=20actually=20requires=20=E2=80=94=20an=20account=20or=20?= =?UTF-8?q?your=20own=20Ollama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b0fc003..9132689 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Your tests decide when it's done.** [![CI](https://github.com/AetherAI3/aether-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/AetherAI3/aether-agent/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-06b6d4)](LICENSE) [![Node](https://img.shields.io/badge/node-%E2%89%A524-14b8a6)](https://nodejs.org) [![TypeScript](https://img.shields.io/badge/TypeScript-7-3178c6)](https://www.typescriptlang.org/) [![Release notes](https://img.shields.io/badge/release-notes-7c3aed)](RELEASE_NOTES.md) ```bash -npm i -g aether-agents --ignore-scripts # Node ≥ 24 · no account needed to start +npm i -g aether-agents --ignore-scripts # Node ≥ 24 · zero runtime dependencies +aether auth login # …or skip it and run on your own Ollama aether agent "make the failing tests pass" ```