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/2] 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/2] 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/); });