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..a74ac10 --- /dev/null +++ b/scripts/handoff-demo.ts @@ -0,0 +1,350 @@ +// 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.** +// +// 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: +// +// - 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. +// +// 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"; +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; +} + +/** 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]: 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 { + /** 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; + } + + // 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 { + 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..976f1db 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -12,12 +12,13 @@ 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"; 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"; @@ -34,11 +35,11 @@ import { stageGate, writeDiffLines, } from "./code_support.js"; -import { loadSession, replayLines } from "../core/session_resume.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"; -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 +64,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,22 +91,33 @@ 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 { - try { - const prior = loadSession(id, 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 { - 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. + // 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 { + 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; } + // 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 +166,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 +174,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 +190,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 +210,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 +232,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 @@ -252,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; @@ -275,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, (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), @@ -332,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, (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 c00536c..508b378 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,59 @@ 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. + * 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 { + let session: LoadedSession | null; try { - s = id ? loadSession(id, logsRoot(), ctx.flags.cwd) : latestSession(ctx.flags.cwd); + 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) { - process.stderr.write('no sessions to resume (run `aether agent ""` first)\n'); + 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(session, { 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.dim(`▸ ${s.manifest.sessionId} · ${s.manifest.task}\n\n`)); - for (const line of replayLines(s.events)) process.stdout.write(line + "\n"); + 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 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 ${session.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..a6bfb1a --- /dev/null +++ b/src/core/handoff.ts @@ -0,0 +1,371 @@ +// 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. +// +// 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 type { BrainEvent } from "./brain_protocol.js"; +import { decodeEvent } from "./brain_protocol.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"; + +/** 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 — recorded for provenance; nothing reads it yet. */ + 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; + +/** Flatten, sanitize, and bound one line of untrusted narration. */ +function clip(text: string, max = MAX_HIGHLIGHT_CHARS): string { + 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 + * 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[] = []; + // 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; + const written = wroteFile(ev); + if (written) { + if (files.size < MAX_FILES) files.add(written); + continue; + } + 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 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)}`); + } + 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 nothing, 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; + }; + return repoFrom( + value(["remote", "get-url", "origin"]), + value(["rev-parse", "--abbrev-ref", "HEAD"]), + value(["rev-parse", "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 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 m.remaining === "number" && m.remaining > 0 ? { remaining: m.remaining } : {}), + ...(opts.repo ? { repo: opts.repo } : {}), + highlights, + filesTouched, + ...(testCmd ? { testCmd } : {}), + }; +} + +/** 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 sanitized, and anything unrecognized is dropped rather than + * half-used. */ +export function parseHandoff(value: unknown): Handoff { + 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"]; + // 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 ${schemaVersion}); upgrade with: npm i -g aether-agents`, + ); + } + 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 sessionId = str("sessionId"); + const task = str("task"); + if (!sessionId || !task) throw new Error("handoff file is missing its session id or task"); + const repo = asObject(body["repo"]); + const identity = repoFrom(field(repo, "remote"), field(repo, "branch"), field(repo, "head")); + const remaining = body["remaining"]; + const testCmd = str("testCmd"); + return { + schemaVersion, + kind: HANDOFF_KIND, + sessionId, + task, + model: str("model"), + brain: body["brain"] === "cloud" ? "cloud" : "local", + started: str("started"), + ended: str("ended") || null, + finalStatus: str("finalStatus") || "unknown", + ...(typeof remaining === "number" && remaining > 0 ? { remaining } : {}), + ...(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 { + // 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 { + 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 }; +} + +/** + * 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`; +} + +/** 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)]; +} 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..89b8884 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" }, }, }); @@ -208,8 +209,10 @@ async function main(argv: string[]): Promise { }); } case "resume": { - const { cmdResume } = await import("./commands/resume.js"); - return cmdResume(ctx, rest[0] ?? ""); + 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/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..71c5ea3 --- /dev/null +++ b/test/handoff.test.ts @@ -0,0 +1,302 @@ +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, + resolveResume, + summarizeEvents, + wroteFile, + writeHandoff, + type ResolvedResume, +} 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 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("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 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("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(() => resolveResume(path, dir), /cannot read handoff/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveResume distils a local session id through the injected loader", () => { + const loaded = session([{ type: "monologue", text: "did a thing", depth: 0 }]); + const r = resolveResume("s1", "/work", (id, _root, scope) => { + assert.equal(id, "s1"); + assert.equal(scope, "/work"); + return loaded; + }); + 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("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", () => { + 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( + () => 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 9fa430d..9649eb7 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, 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", () => { assert.equal( @@ -8,3 +12,166 @@ 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 +// commands 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; +} + +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; +} + +/** 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 }); + 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 { + 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 (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", () => { + 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 --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 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/); +}); 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 }); + } +});