Skip to content

feat(resume): carry a session across models and machines - #80

Merged
AetherAI3 merged 2 commits into
mainfrom
feat/growth-release
Aug 19, 2026
Merged

feat(resume): carry a session across models and machines#80
AetherAI3 merged 2 commits into
mainfrom
feat/growth-release

Conversation

@AetherAI3

Copy link
Copy Markdown
Owner

The one capability to remember

Start a task on one model. Finish it on another, on another machine. Your tests decide when it's done.

aether resume could already 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 PR makes the resume path mean what it says.

What's in it

A handoff (src/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 <id> 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 <file>] 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 <file> continues there. A file reference is deliberately not workspace-scoped; a session id still is.
  • It 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 bugs this needed, each real on its own

aether agent --local "<task>" was dead out of the box It spawned the separately-installed Python brain unconditionally, so a plain npm i -g aether-agents could only answer spawn python ENOENT. The one-shot offline path now drives the Ollama brain that ships in the package — the same one the REPL's --local turns already used. AETHER_LOCAL_BRAIN=python opts back in; the choice is a pure decision beside chooseBackend.
Session logs redacted every file path The 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, pat_token are still redacted.
A run that never reached your tests claimed a failing one The Ollama brain reported a placeholder remaining: 1, so an unreachable-Ollama turn printed 1 test failing when nothing had run. The count now comes only from the host's own verify run.

Proof

npm run demo:handoff     # ~5s, no account, no model download

scripts/handoff-demo.ts 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 absolute path, deletes the first checkout and its session logs, then finishes the job on model B with --resume <file> and no restated task.

The model — and only the model — is a scripted local HTTP server speaking Ollama's OpenAI-compatible endpoint, which is what makes the run deterministic enough to be a gate. Everything else is real: the CLI, the git repos, the file edits, the permission gate, the handoff file, node --test, and the verify gate. It asserts three things independently:

  1. session B's first prompt contained the continuation brief, naming model A and src/slug.js;
  2. node --test is green when run by the script, outside the agent;
  3. the CLI's own verify gate exited 0.

AETHER_DEMO_REAL=1 runs the identical script against real Ollama models. Documented in docs/demo/handoff.md, including the 20–45 second recording sequence.

Sample run:

── The handoff ───────────────────────────────────────────────────────
⇄ handoff written .../handoff.json
  session 2026-08-19T…-local-22556 · incomplete · 1 file(s) · 2 step(s)
   carried: model aether-demo-a, status incomplete, files ["src/slug.js"],
            repo https://github.com/aether-demo/slugify.git

── Machine B — aether-demo-b ─────────────────────────────────────────
   no task restated; the handoff file is the only context
  ✓ ok · 1 file changed · tests green · 1s

── PASSED ────────────────────────────────────────────────────────────
   One model started it. Another finished it, elsewhere, with no re-pasted context.
   The tests decided when it was done.

Verification

  • npm run typecheck — clean
  • 65 unit tests across handoff, resume_cmd, backend_select, session_log, session_resume, cli_registry, command_docs_parity, brain_ollama, ui, version — green locally
  • npm run demo:handoff — PASSED
  • npm run smoke — 3 pass / 4 skip / 0 fail (skips need Ollama, network, or sign-in)
  • npm pack --dry-run — clean; the production verifier's pack-install-launch test is green
  • Failure paths exercised by hand: missing Ollama, a corrupt handoff, a missing handoff file, and an unknown session id all fail with an actionable line

Full-suite CI is the gate here — one tool_executor test is pathologically slow on the authoring machine and was not run locally.

Notes for review

`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 <id>` 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 <file>]` 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 <file>` 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 "<task>"` 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 <file>` 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.
…log once

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).
@AetherAI3
AetherAI3 force-pushed the feat/growth-release branch from a9fea93 to 0b115cb Compare August 19, 2026 12:11
@AetherAI3
AetherAI3 merged commit 2507db0 into main Aug 19, 2026
5 checks passed
@AetherAI3
AetherAI3 deleted the feat/growth-release branch August 19, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant