diff --git a/.gitignore b/.gitignore
index 45138eb..a5642e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
bin/
graphify-out/
dist/
+Tui-design-ins/
\ No newline at end of file
diff --git a/README.md b/README.md
index ef8a2b8..d596592 100644
--- a/README.md
+++ b/README.md
@@ -1,91 +1,211 @@
-# atlas-cli
-
-A headless Atlas for servers: a Go binary that signs in like the Atlas desktop
-app, binds a folder to an organisation and a cloud Project, and gives every
-coding agent run in that folder **session capture** and **shared memory**,
-byte-compatible with the desktop's on-disk formats. The agents keep their own
-UIs: Atlas plugs into their hooks (ADR-0013).
+
+
+
+
+atlas
+
+
+ Shared memory and session capture for coding agents, from the terminal.
+ Claude Code, Codex, Cursor, Copilot, OpenCode, Droid, Antigravity and Pi keep their own UIs;
+ Atlas records every session and gives every agent the same memory of the project.
+
+
+
+
+
+
+
+
+
+
+ Install ·
+ Quick start ·
+ Shared memory ·
+ Session capture ·
+ Agents ·
+ Commands
+
+
+---
+
+`atlas` is the headless Atlas: one Go binary that signs in like the Atlas
+desktop app, binds a folder to an organisation and a cloud Project, and plugs
+into the hooks of the agents you already use. It writes the same files the app
+writes, byte for byte, so a folder works the same whether it is open in the app,
+in a terminal on a server, or both.
## Install
-Linux (amd64, arm64) and macOS (Apple silicon, Intel):
+macOS (Apple silicon, Intel) and Linux (amd64, arm64):
```sh
curl -fsSL https://github.com/tryatlas/atlas-cli/releases/latest/download/install.sh | sh
```
-It verifies the archive's sha256 and installs `atlas` to `~/.local/bin`
-(`ATLAS_INSTALL_DIR` to change it, `ATLAS_VERSION=v1.2.3` to pin). From
-source: `make build` (pure Go, no cgo).
+The installer verifies the archive's sha256 and puts `atlas` in `~/.local/bin`
+(`ATLAS_INSTALL_DIR` changes it, `ATLAS_VERSION=v0.4.0` pins it). Later,
+`atlas update` does the same in place. From source: `make build` (pure Go, no
+cgo).
-## Use
+## Quick start
-```
-atlas login # device grant against the Atlas auth server
+```sh
+atlas login # sign in (the same account as the desktop app)
cd ~/work/backend
-atlas init # org + cloud Project, writes .atlas/ (--local: no cloud)
-atlas agents add claude-code # the agents you use here (atlas agents list shows all eight)
-atlas agents add codex cursor
-claude # …or codex, cursor-agent, opencode: work as usual
-atlas status # sessions captured, commits linked, sync
+atlas init # organisation → Project → agents → background sync
+claude # …or codex, cursor-agent, opencode, copilot: work as usual
+atlas status # what was captured, what synced, what agents remember
```
-`atlas agents add` writes two things into the agent's own config in the
-Project: its **hook**, which runs `atlas hooks ` at session
-start, each prompt, each turn end and session end; and the **`atlas_memory`
-MCP server**, so the agent can read and write shared memory. It also installs a
-git `post-commit` hook that links sessions to the commits that carry their
-work. Nothing is wrapped and no environment variable changes; remove it all with
-`atlas agents remove `.
-
-Agents run their project hooks only for a project you trust: Codex, Copilot
-and Pi each ask once in their own UI. Keep `atlas` current on PATH, including
-`~/.local/bin`, which some agents put first.
-
-At each turn end the hook reads the agent's transcript from where the turn
-started, diffs `git status`, and records the prompt, the reply, the tool calls
-with the files they wrote, token usage and the model. The hook writes to the
-store itself, or hands the turn to a running `atlas serve`, or spools it when
-the desktop app has the folder open. A hook never fails the agent: errors go to
-`.atlas/cli-hooks.log` and `atlas status`.
+`atlas init` is a guided flow on a terminal:
+
+| step | asks |
+|---|---|
+| 1 · Organisation | which organisation the folder belongs to (skipped when you have one) |
+| 2 · Project | the cloud Project: the one matching this repository first, the others, or a new one |
+| 3 · Agents | which agents found on this machine to hook, with an **All agents** row |
+| 4 · Background sync | keep a listener running so every event reaches the cloud at once |
+| 5 · Memory search | download the local search model (87 MB, once) when it is missing |
+
+`--local` binds without a cloud. In scripts, flags answer instead: `--org`,
+`--project | --create`, `--agents claude-code,codex | --all-agents`,
+`--sync on|off`.
+
+`atlas settings` changes any of it later on one screen: account, capture and
+memory, each agent, background sync, the local model, updates.
+
+## Shared memory
+
+Every agent in the project reads and writes one memory, through the
+`atlas_memory` MCP server that `atlas agents add` registers in the agent's own
+config. Nothing is ever injected into a prompt: agents pull what they need.
+
+| tool | what an agent gets |
+|---|---|
+| `memory_briefing` | at session start: the active **plan**, recent file changes, decisions, facts, failures, architecture, conventions, and the previous session's handoff |
+| `memory_search` | memories ranked by **meaning and words**, plus the project's **documents** (CLAUDE.md, AGENTS.md, notes, captured sessions) |
+| `memory_remember` | save a decision, fact, failure, architecture note or preference; near-duplicates merge |
+| `memory_changes` | what other sessions recorded since this one last looked |
+| `memory_get` · `memory_history` · `memory_why` | one memory, its earlier wordings, and the sessions behind a file or commit |
+| `memory_forget` · `memory_feedback` · `memory_list` | remove, rate and list |
+
+- **Search by meaning.** A small local model (MiniLM, the one the app uses) runs
+ in pure Go on your machine; nothing is sent anywhere. Ask "how do we rotate
+ tokens" and find "signing keys rotate every week". Without the model, search
+ falls back to words.
+- **Plans are captured, not typed.** When an agent writes its to-do list
+ (`TodoWrite`, `update_plan`, `todowrite`, Claude's tasks and plan mode, …),
+ it becomes the project's plan, and the next session, or another agent, sees
+ it in its briefing.
+- **Memories write themselves.** Decisions an agent states in its reply, and
+ what the extractor distils from long sessions, land as memories; at session
+ end a handoff note tells the next session where things stand.
+- **One record with the app.** `.atlas/memory/` holds the app's exact files:
+ `memory.sqlite` (with its vectors), `corpus.sqlite`, `memory.snapshot.sqlite`
+ (a daily backup, restored automatically if the record is damaged) and
+ `extract-state/`.
+
+```sh
+atlas memory browse # entries by kind, search, history
+atlas memory index # embed every memory, index the project's documents
+```
+
+## Session capture
+
+Capture happens per event, as the agent works:
+
+1. **The prompt** opens the turn the moment you submit it.
+2. **Each tool call** lands as the agent's post-tool hook fires, with the files it
+ wrote, including files a shell command changed.
+3. **The reply** closes the turn at the agent's stop hook, with token usage and
+ the model, read from the transcript the agent names.
+4. **Commits** an agent makes get their checkpoint: a git `post-commit` hook
+ links each commit to the session and turn that wrote it.
+
+Every event reaches the cloud Project within seconds: the hook writes the store
+and syncs, or hands the event to `atlas serve`, or spools it while the desktop
+app has the folder open. A hook never fails the agent; problems go to
+`atlas status`.
+
+```sh
+atlas sync on # a background listener your agents' hooks keep alive
+atlas serve # the same in the foreground, with a live feed of hook events
+```
+
+## Agents
+
+`atlas agents add ` writes the hook and the memory server into the
+agent's own config in the project; `atlas agents remove` takes them out.
+Nothing is wrapped and no environment variable changes.
| agent | hook written to | memory server written to |
|---|---|---|
-| `claude-code` Claude Code | `.claude/settings.local.json` | `.mcp.json` (+ approved in the local settings) |
-| `codex` Codex | `.codex/hooks.json`: the first interactive `codex` here asks "Hooks need review"; choose **Trust all and continue** (`codex exec` cannot ask) | `.codex/config.toml` (trusted projects) |
-| `cursor` Cursor CLI and IDE | `.cursor/hooks.json` (`cursor-agent -p` fires no turn hooks; such a session is captured at its end) | `.cursor/mcp.json` (Cursor asks to approve it once) |
-| `copilot` GitHub Copilot CLI | `.github/hooks/atlas.json`: loaded once the folder is trusted (the first interactive `copilot` asks; `copilot -p` does not) | `.github/mcp.json` (same trust) |
+| `claude-code` Claude Code | `.claude/settings.local.json` | `.mcp.json`, approved in the local settings |
+| `codex` Codex | `.codex/hooks.json`: choose **Trust all and continue** once | `.codex/config.toml` |
+| `cursor` Cursor CLI and IDE | `.cursor/hooks.json` | `.cursor/mcp.json`, approved once in Cursor |
+| `copilot` GitHub Copilot CLI | `.github/hooks/atlas.json`, once the folder is trusted | `.github/mcp.json` |
| `opencode` OpenCode | `.opencode/plugins/atlas.ts` | `opencode.json` |
| `droid` Factory Droid | `.factory/settings.json` | `.factory/mcp.json` |
-| `antigravity` Antigravity (`agy`) | `.agents/hooks.json` (run `agy` with an absolute `--add-dir`) | `.agents/mcp_config.json` |
-| `pi` Pi | `.pi/extensions/atlas/index.ts`: loaded once the project is trusted in Pi (`pi --approve` for one run) | `.pi/mcp.json` (same trust) |
+| `antigravity` Antigravity (`agy`) | `.agents/hooks.json` | `.agents/mcp_config.json` |
+| `pi` Pi | `.pi/extensions/atlas/index.ts`, once the project is trusted | `.pi/mcp.json` |
+
+Keep `atlas` current on `PATH`, including `~/.local/bin`, which some agents put
+first.
+
+## Commands
+
+Every command draws its own screen on a terminal (the Atlas palette: monochrome,
+colour only for status) and prints plain text to pipes and scripts.
| command | what |
|---|---|
-| `atlas agents add \| remove \| list \| sessions` | install or remove the hook and memory entry per agent; list agents and the sessions seen |
-| `atlas serve [--daemon \| --stop]` | the Project's listener: memory server, sync drain, receiver of hook turns while it holds the store |
-| `atlas status` | sign-in, Project, gates, capture health, commits linked, sync, memory, agents and hook errors |
-| `atlas memory browse \| list \| verify \| export \| import` | shared memory (this machine only) |
-| `atlas capture sync \| retry \| link` | cloud sync now, retry failed rows, link sessions to commits by hand |
-| `atlas mcp-bridge --project --agent ` | the stdio MCP entry the agents' configs hold (serves memory with or without `atlas serve`) |
+| `atlas` | the overview: logo, commands by group |
+| `atlas login` · `logout` · `whoami` | sign in with the device grant; the session file is the only secret kept (mode 0600) |
+| `atlas org list \| use \| create` | organisations |
+| `atlas init` | bind the folder, hook the agents, choose background sync and memory search |
+| `atlas settings` | everything on one screen |
+| `atlas status` | sign-in, Project, capture health, commits linked, sync, memory, agents |
+| `atlas agents add \| remove \| list \| sessions` | the hooks and memory entry per agent; the sessions seen |
+| `atlas sync [on \| off]` | background sync |
+| `atlas serve` | the Project's listener: memory server, sync drain, receiver of hook events |
+| `atlas memory browse \| list \| index \| verify \| export \| import` | shared memory |
+| `atlas memory enable \| disable` | the shared-memory gate for this repository |
+| `atlas capture enable \| disable \| sync \| retry \| link` | the capture gate; sync now, retry failed rows, link commits |
+| `atlas update [--check] [--version vX.Y.Z]` | replace this binary with a release, checksum verified |
+
+## What lives where
+
+| path | what |
+|---|---|
+| `.atlas/` | the app's per-project files only: `sessions.db`, `blobs/`, `memory/`, `project.json` |
+| `~/.local/state/atlas-cli/projects//` | the CLI's own: hook state, `hooks.log`, the listener's run file, preferences |
+| `~/Library/Application Support/dev.atlas.ide/models/` | the local search model, shared with the app (`~/.local/share/…` on Linux) |
-The ACP path of v0.1 (`atlas run ` with Atlas's own chat screen, the
-registry installs) is parked under the hidden `atlas acp` group (ADR-0013 §8).
+## Development
-## Where things are
+```sh
+make build # ./bin/atlas
+make test # go test ./...
+make lint # golangci-lint
+```
-| path | what |
+| doc | what |
|---|---|
-| `docs/atlas-reference/README.md` | index of the hand-off folder copied from the desktop repo: read first |
-| `docs/atlas-reference/SPEC.md` | the specification; §1.2 holds the locked decisions D1–D20 |
-| `docs/atlas-reference/evidence-*.md` | desktop-side facts with `file:line` citations into the desktop repo |
-| `docs/research/entire-cli.md` | how the Entire CLI captures agents through hooks; the per-agent facts this CLI's integrations follow |
-| `docs/adr/` | this repo's own ADRs; `0013` is the hook-based capture of native agent UIs and supersedes `0009`/`0012` as the product |
-| `docs/RELEASE.md` | how to release, and the acceptance checklists |
| `docs/progress.md` | what was built, what was verified live, what was not |
+| `docs/adr/` | decisions; `0013` is capture through agents' hooks |
+| `docs/research/entire-cli.md` | the per-agent hook facts the integrations follow |
+| `docs/atlas-reference/` | the desktop app's specification and file formats |
+| `docs/RELEASE.md` | how to release, and the acceptance checklists |
+
+Ground rules: a hook reads only the transcript file the agent's own payload
+names, from the turn's offset, while the hook runs, and never scans an agent's
+folders; memory is never injected into prompts; an `Authorization` header is
+never logged; every stored text passes the redactor; no secret persists except
+the session token file.
+
+The ACP path of v0.1 (`atlas run `) is parked under the hidden
+`atlas acp` group.
+
+## License
-Ground rules that are not negotiable: a hook reads only the transcript file
-the agent's own payload names, from the turn's offset, while the hook runs
-(never a scan of an agent's directories); never inject memory into prompts;
-never log an `Authorization` header; every stored text passes the redactor
-first; and no secret persists except the session token file (mode 0600).
+[Apache-2.0](LICENSE)
diff --git a/cmd/atlas/agents_cmds.go b/cmd/atlas/agents_cmds.go
index 31c69a0..fa90d05 100644
--- a/cmd/atlas/agents_cmds.go
+++ b/cmd/atlas/agents_cmds.go
@@ -16,6 +16,7 @@ import (
"github.com/tryatlas/atlas-cli/internal/hooks"
"github.com/tryatlas/atlas-cli/internal/hooks/mcpconfig"
"github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui"
)
// atlas agents: the agents that run in their own UIs and report to Atlas
@@ -85,13 +86,34 @@ func runAgentsAdd(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
- if changed, err := hooks.InstallGitHooks(ctx, p.Root); err != nil {
+ w := out
+ if interactive() {
+ tui.Header(out, "agents", "add")
+ fmt.Fprintln(out)
+ w = agentLines(out)
+ }
+ if err := installAgents(ctx, w, p.Root, agents, force, noMem); err != nil {
+ return err
+ }
+ if interactive() {
+ fmt.Fprintln(out)
+ tui.Note(out, "Use the agents as usual in "+p.Root+"; sessions are captured as they run. atlas status shows them.")
+ } else {
+ fmt.Fprintf(out, "Use the agents as usual in %s; sessions are captured as they run. atlas status shows them.\n", p.Root)
+ }
+ return nil
+}
+
+// installAgents writes the git hook, then each agent's hook and memory entry,
+// one line per agent.
+func installAgents(ctx context.Context, out io.Writer, root string, agents []hooks.Agent, force, noMem bool) error {
+ if changed, err := hooks.InstallGitHooks(ctx, root); err != nil {
return fmt.Errorf("git hooks: %w", err)
} else if changed {
fmt.Fprintln(out, "Installed the git post-commit hook (links sessions to commits).")
}
for _, a := range agents {
- changed, err := a.Install(p.Root, force)
+ changed, err := a.Install(root, force)
if err != nil {
return fmt.Errorf("%s: %w", a.ID(), err)
}
@@ -101,12 +123,12 @@ func runAgentsAdd(cmd *cobra.Command, args []string) error {
}
line := fmt.Sprintf("%s: hook %s", a.Name(), what)
if w, ok := mcpconfig.For(a.ID()); ok && !noMem {
- mchanged, err := w.Write(p.Root)
+ mchanged, err := w.Write(root)
if err != nil {
return fmt.Errorf("%s memory config: %w", a.ID(), err)
}
if mchanged {
- line += ", memory server added to " + strings.TrimPrefix(w.Path(p.Root), p.Root+"/")
+ line += ", memory server added to " + strings.TrimPrefix(w.Path(root), root+"/")
} else {
line += ", memory server present"
}
@@ -121,7 +143,6 @@ func runAgentsAdd(cmd *cobra.Command, args []string) error {
fmt.Fprintf(out, " note: %s\n", n.AddNote())
}
}
- fmt.Fprintf(out, "Use the agents as usual in %s; sessions are captured as they run. atlas status shows them.\n", p.Root)
return nil
}
@@ -137,12 +158,38 @@ func runAgentsRemove(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
+ if interactive() {
+ tui.Header(out, "agents", "remove")
+ fmt.Fprintln(out)
+ out = agentLines(out)
+ }
+ return removeAgents(ctx, out, p.Root, agents)
+}
+
+// agentLines styles installAgents' and removeAgents' lines on a terminal:
+// "Claude Code: hook installed, …" becomes "✓ Claude Code hook installed, …",
+// notes go faint under it.
+func agentLines(w io.Writer) io.Writer {
+ return tui.Lines(w, func(l string) string {
+ if n, ok := strings.CutPrefix(l, " note: "); ok {
+ return " " + tui.Default.Faint.Render("↳ "+n)
+ }
+ if name, rest, ok := strings.Cut(l, ": "); ok && !strings.Contains(name, " the ") {
+ return tui.Default.Success.Render("✓") + " " + tui.Default.Bold.Render(fmt.Sprintf("%-20s", name)) + " " + tui.Default.Faint.Render(rest)
+ }
+ return tui.Default.Success.Render("✓") + " " + l
+ })
+}
+
+// removeAgents takes the Atlas entries out of each agent's config, and the
+// git hook once no agent is left.
+func removeAgents(ctx context.Context, out io.Writer, root string, agents []hooks.Agent) error {
for _, a := range agents {
- if err := a.Uninstall(p.Root); err != nil {
+ if err := a.Uninstall(root); err != nil {
return fmt.Errorf("%s: %w", a.ID(), err)
}
if w, ok := mcpconfig.For(a.ID()); ok {
- if err := w.Remove(p.Root); err != nil {
+ if err := w.Remove(root); err != nil {
return fmt.Errorf("%s memory config: %w", a.ID(), err)
}
}
@@ -150,12 +197,12 @@ func runAgentsRemove(cmd *cobra.Command, args []string) error {
}
left := false
for _, a := range hooks.All() {
- if a.State(p.Root) != hooks.Absent {
+ if a.State(root) != hooks.Absent {
left = true
}
}
if !left {
- if err := hooks.UninstallGitHooks(ctx, p.Root); err != nil {
+ if err := hooks.UninstallGitHooks(ctx, root); err != nil {
return err
}
fmt.Fprintln(out, "No agent left: the git post-commit hook was removed too.")
@@ -169,6 +216,9 @@ func runAgentsList(cmd *cobra.Command, _ []string) error {
if p, err := agentRoot(ctx); err == nil {
root = p.Root
}
+ if interactive() {
+ return agentsTable(cmd.OutOrStdout(), root)
+ }
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "AGENT\tNAME\tBINARY\tHOOK\tMEMORY")
for _, a := range hooks.All() {
@@ -196,6 +246,44 @@ func runAgentsList(cmd *cobra.Command, _ []string) error {
return nil
}
+// agentsTable is atlas agents list on a terminal.
+func agentsTable(out io.Writer, root string) error {
+ tui.Header(out, "agents")
+ fmt.Fprintln(out)
+ state := func(st hooks.InstallState) string {
+ switch st {
+ case hooks.Current:
+ return tui.Dot(tui.Good, "installed")
+ case hooks.Outdated:
+ return tui.Dot(tui.Warn, "outdated")
+ }
+ return tui.Dot(tui.Idle, "off")
+ }
+ var rows [][]string
+ for _, a := range hooks.All() {
+ bin := tui.Default.Faint.Render("not on PATH")
+ if _, err := exec.LookPath(a.Binary()); err == nil {
+ bin = a.Binary()
+ }
+ hook, mem := tui.Default.Faint.Render("-"), tui.Default.Faint.Render("-")
+ if root != "" {
+ hook = state(a.State(root))
+ if mw, ok := mcpconfig.For(a.ID()); ok {
+ mem = state(mw.State(root))
+ }
+ }
+ rows = append(rows, []string{a.Name(), tui.Default.Faint.Render(a.ID()), bin, hook, mem})
+ }
+ fmt.Fprint(out, tui.Table([]string{"agent", "id", "binary", "hook", "memory"}, rows))
+ fmt.Fprintln(out)
+ if root == "" {
+ tui.Note(out, "Not in an Atlas Project: run atlas init, then atlas agents add .")
+ } else {
+ tui.Note(out, "atlas agents add · atlas agents add --all · atlas settings")
+ }
+ return nil
+}
+
func runAgentsSessions(cmd *cobra.Command, _ []string) error {
p, err := agentRoot(cmd.Context())
if err != nil {
@@ -209,6 +297,28 @@ func runAgentsSessions(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(cmd.OutOrStdout(), "No session seen by the hooks yet.")
return nil
}
+ if interactive() {
+ out := cmd.OutOrStdout()
+ tui.Header(out, "agents", "sessions")
+ fmt.Fprintln(out)
+ var rows [][]string
+ for _, s := range states {
+ st := tui.Dot(tui.Good, "idle")
+ switch {
+ case s.Ended:
+ st = tui.Dot(tui.Idle, "ended")
+ case s.InTurn:
+ st = tui.Dot(tui.Warn, "in a turn")
+ }
+ resume := ""
+ if a, ok := hooks.Get(s.Agent); ok {
+ resume = a.ResumeCommand(s.SessionID)
+ }
+ rows = append(rows, []string{s.Agent, tui.Fit(s.SessionID, 28), fmt.Sprint(s.Turn), st, ago(s.UpdatedAt), tui.Default.Faint.Render(resume)})
+ }
+ fmt.Fprint(out, tui.Table([]string{"agent", "session", "turns", "state", "last", "resume"}, rows))
+ return nil
+ }
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "AGENT\tSESSION\tTURNS\tSTATE\tLAST\tRESUME")
for _, s := range states {
diff --git a/cmd/atlas/auth_cmds.go b/cmd/atlas/auth_cmds.go
index 720ece4..d50fc8f 100644
--- a/cmd/atlas/auth_cmds.go
+++ b/cmd/atlas/auth_cmds.go
@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/tryatlas/atlas-cli/internal/auth"
+ "github.com/tryatlas/atlas-cli/internal/tui"
)
// newAuth is replaced in tests.
@@ -26,6 +27,29 @@ func runLogin(cmd *cobra.Command, _ []string) error {
}
noBrowser, _ := cmd.Flags().GetBool("no-browser")
out := cmd.OutOrStdout()
+ if interactive() {
+ var s *auth.Session
+ err := tui.DeviceLogin(func(prompt func(tui.DevicePrompt)) (string, error) {
+ var err error
+ s, err = a.Login(cmd.Context(), func(p auth.Prompt) {
+ opened := !noBrowser && canOpenBrowser() && openBrowser(p.VerificationURI) == nil
+ prompt(tui.DevicePrompt{URL: p.VerificationURI, Code: p.UserCode, Expires: p.ExpiresAt, Opened: opened})
+ })
+ if err != nil {
+ return "", err
+ }
+ if s.Identity != nil {
+ return fmt.Sprintf("%s <%s>", s.Identity.Name, s.Identity.Email), nil
+ }
+ return "you", nil
+ })
+ if err != nil {
+ return err
+ }
+ fmt.Fprintln(out)
+ printIdentity(out, s, a.Store.Path())
+ return nil
+ }
s, err := a.Login(cmd.Context(), func(p auth.Prompt) {
fmt.Fprintf(out, "To sign in, open %s and enter the code:\n\n %s\n\n", p.VerificationURI, p.UserCode)
if !noBrowser && canOpenBrowser() {
@@ -48,20 +72,18 @@ func runLogout(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
- confirmed, err := a.Logout(cmd.Context())
- if errors.Is(err, auth.ErrSignedOut) {
- fmt.Fprintln(cmd.OutOrStdout(), "Not signed in.")
- return nil
- }
- if err != nil {
- return err
- }
- if confirmed {
- fmt.Fprintln(cmd.OutOrStdout(), "Signed out.")
- } else {
- fmt.Fprintln(cmd.OutOrStdout(), "Signed out on this machine. Atlas did not confirm the server session ended; it expires on its own.")
- }
- return nil
+ return tui.Work(cmd.OutOrStdout(), "Signing out", func(tui.StepFunc) (string, error) {
+ confirmed, err := a.Logout(cmd.Context())
+ switch {
+ case errors.Is(err, auth.ErrSignedOut):
+ return "Not signed in.", nil
+ case err != nil:
+ return "", err
+ case confirmed:
+ return "Signed out.", nil
+ }
+ return "Signed out on this machine. Atlas did not confirm the server session ended; it expires on its own.", nil
+ })
}
func runWhoami(cmd *cobra.Command, _ []string) error {
@@ -75,7 +97,12 @@ func runWhoami(cmd *cobra.Command, _ []string) error {
return err
}
if refresh {
- r, rerr := a.Refresh(cmd.Context())
+ var r *auth.Session
+ var rerr error
+ _ = tui.Work(io.Discard, "Refreshing your profile", func(tui.StepFunc) (string, error) {
+ r, rerr = a.Refresh(cmd.Context())
+ return "", nil
+ })
if errors.Is(rerr, auth.ErrSignedOut) {
return rerr
}
@@ -95,7 +122,11 @@ func runOrgList(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
- s, err := a.Refresh(cmd.Context())
+ var s *auth.Session
+ _ = tui.Work(io.Discard, "Fetching your organisations", func(tui.StepFunc) (string, error) {
+ s, err = a.Refresh(cmd.Context())
+ return "", nil
+ })
if errors.Is(err, auth.ErrSignedOut) {
return err
}
@@ -124,13 +155,13 @@ func runOrgUse(cmd *cobra.Command, args []string) error {
}
out := cmd.OutOrStdout()
if id == "" {
- fmt.Fprintln(out, "No organisation pinned.")
+ tui.Done(out, "No organisation pinned.")
return nil
}
if s.ActiveOrgID() != id {
fmt.Fprintf(cmd.ErrOrStderr(), "atlas: %s is not in your organisation list; pinned anyway. Run atlas org list to refresh it.\n", id)
}
- fmt.Fprintf(out, "Active organisation: %s\n", orgLabel(s, id))
+ tui.Done(out, "Active organisation: "+orgLabel(s, id))
return nil
}
@@ -152,26 +183,30 @@ func runOrgCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot make a handle from %q; pass --slug", name)
}
ctx := cmd.Context()
- free, err := a.Client.CheckSlug(ctx, s.SessionToken, slug)
- if err != nil {
- return err
- }
- if !free {
- return fmt.Errorf("the handle %q is taken; pass another with --slug", slug)
- }
- created, err := a.Client.CreateOrg(ctx, s.SessionToken, name, slug)
- if err != nil {
- var f *auth.Failure
- if errors.As(err, &f) && f.Kind == auth.Denied {
- return errors.New("couldn't create it: that organisation name or handle may already be taken")
+ return tui.Work(cmd.OutOrStdout(), "Creating "+name, func(step tui.StepFunc) (string, error) {
+ step("checking the handle " + slug)
+ free, err := a.Client.CheckSlug(ctx, s.SessionToken, slug)
+ if err != nil {
+ return "", err
}
- return err
- }
- if _, err := a.Refresh(ctx); err != nil && !errors.Is(err, auth.ErrSignedOut) {
- fmt.Fprintln(cmd.ErrOrStderr(), "atlas: created, but could not refresh the list:", err)
- }
- fmt.Fprintf(cmd.OutOrStdout(), "Created %s (%s). Run atlas org use %s to make it active.\n", created.Name, created.ID, created.ID)
- return nil
+ if !free {
+ return "", fmt.Errorf("the handle %q is taken; pass another with --slug", slug)
+ }
+ step("creating the organisation")
+ created, err := a.Client.CreateOrg(ctx, s.SessionToken, name, slug)
+ if err != nil {
+ var f *auth.Failure
+ if errors.As(err, &f) && f.Kind == auth.Denied {
+ return "", errors.New("couldn't create it: that organisation name or handle may already be taken")
+ }
+ return "", err
+ }
+ step("refreshing your organisations")
+ if _, err := a.Refresh(ctx); err != nil && !errors.Is(err, auth.ErrSignedOut) {
+ fmt.Fprintln(cmd.ErrOrStderr(), "atlas: created, but could not refresh the list:", err)
+ }
+ return fmt.Sprintf("Created %s (%s). Run atlas org use %s to make it active.", created.Name, created.ID, created.ID), nil
+ })
}
var slugJunk = regexp.MustCompile(`[^a-z0-9]+`)
@@ -184,6 +219,21 @@ func printIdentity(w io.Writer, s *auth.Session, path string) {
if s == nil {
return
}
+ if interactive() {
+ tui.Header(w, "account")
+ tui.Section(w, "Signed in")
+ if id := s.Identity; id != nil {
+ tui.Field(w, "Name", tui.Default.Bold.Render(id.Name))
+ tui.Field(w, "Email", id.Email)
+ tui.Field(w, "User id", tui.Default.Faint.Render(id.ID))
+ } else {
+ tui.Field(w, "Profile", "not fetched yet (atlas whoami --refresh)")
+ }
+ printOrgs(w, s)
+ tui.Section(w, "On this machine")
+ tui.Field(w, "Session file", tui.Default.Faint.Render(path))
+ return
+ }
if id := s.Identity; id != nil {
fmt.Fprintf(w, "Signed in as %s <%s> (%s)\n", id.Name, id.Email, id.ID)
} else {
@@ -195,6 +245,23 @@ func printIdentity(w io.Writer, s *auth.Session, path string) {
func printOrgs(w io.Writer, s *auth.Session) {
id := s.Identity
+ if interactive() && id != nil && id.Orgs != nil && len(*id.Orgs) > 0 {
+ tui.Section(w, "Organisations")
+ var rows [][]string
+ for _, o := range *id.Orgs {
+ role := ""
+ if o.Role != nil {
+ role = string(*o.Role)
+ }
+ state := tui.Default.Off.Render("○")
+ if o.ID == s.ActiveOrgID() {
+ state = tui.Default.Success.Render("●") + " active"
+ }
+ rows = append(rows, []string{o.Name, tui.Default.Faint.Render(o.ID), role, state})
+ }
+ fmt.Fprint(w, tui.Table([]string{"name", "id", "role", ""}, rows))
+ return
+ }
switch {
case id == nil || id.Orgs == nil:
fmt.Fprintln(w, "Organisations: not known yet")
diff --git a/cmd/atlas/capture_cmds.go b/cmd/atlas/capture_cmds.go
index 6a18ffb..ed77f1a 100644
--- a/cmd/atlas/capture_cmds.go
+++ b/cmd/atlas/capture_cmds.go
@@ -10,6 +10,7 @@ import (
gitlink "github.com/tryatlas/atlas-cli/internal/capture/git"
"github.com/tryatlas/atlas-cli/internal/capture/store"
"github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui"
)
// runCaptureLink walks new commits and links them to captured sessions.
@@ -35,14 +36,16 @@ func runCaptureLink(cmd *cobra.Command, _ []string) error {
return err
}
defer st.Close()
- res, err := gitlink.Link(cmd.Context(), st, p.Root, now)
- if err != nil {
- return err
- }
- fmt.Fprintf(cmd.OutOrStdout(), "Checked %d commit(s); %d checkpoint(s) written.\n", res.Commits, res.Checkpoints)
- if rw := res.Rewrites; rw.Relinked+rw.Orphaned+rw.Recovered+rw.Failed > 0 {
- fmt.Fprintf(cmd.OutOrStdout(), "Rewritten history: %d relinked, %d orphaned, %d recovered, %d not checked.\n",
- rw.Relinked, rw.Orphaned, rw.Recovered, rw.Failed)
- }
- return nil
+ return tui.Work(cmd.OutOrStdout(), "Linking sessions to commits", func(tui.StepFunc) (string, error) {
+ res, err := gitlink.Link(cmd.Context(), st, p.Root, now)
+ if err != nil {
+ return "", err
+ }
+ msg := fmt.Sprintf("Checked %d commit(s); %d checkpoint(s) written.", res.Commits, res.Checkpoints)
+ if rw := res.Rewrites; rw.Relinked+rw.Orphaned+rw.Recovered+rw.Failed > 0 {
+ msg += fmt.Sprintf("\nRewritten history: %d relinked, %d orphaned, %d recovered, %d not checked.",
+ rw.Relinked, rw.Orphaned, rw.Recovered, rw.Failed)
+ }
+ return msg, nil
+ })
}
diff --git a/cmd/atlas/commands.go b/cmd/atlas/commands.go
index 4265f9a..b9648b8 100644
--- a/cmd/atlas/commands.go
+++ b/cmd/atlas/commands.go
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/spf13/cobra"
+
+ "github.com/tryatlas/atlas-cli/internal/tui"
)
// notImplementedError is returned by every command that has not been built yet.
@@ -26,6 +28,14 @@ func newRootCmd() *cobra.Command {
SilenceErrors: true,
}
root.SetVersionTemplate("atlas {{.Version}}\n")
+ root.SetHelpFunc(styledHelp(root.HelpFunc()))
+ root.RunE = func(cmd *cobra.Command, _ []string) error {
+ if !interactive() {
+ return cmd.Help()
+ }
+ splash(cmd)
+ return nil
+ }
root.AddCommand(
newLoginCmd(),
@@ -36,6 +46,9 @@ func newRootCmd() *cobra.Command {
newMemoryCmd(),
newCaptureCmd(),
newServeCmd(),
+ newSyncCmd(),
+ newSettingsCmd(),
+ newUpdateCmd(),
withRun(leaf("status", "Show login, organisation, Project, gates, capture health and agent integrations", cobra.NoArgs), runStatus),
newAgentsCmd(),
newHooksCmd(),
@@ -45,6 +58,32 @@ func newRootCmd() *cobra.Command {
return root
}
+// splashGroups orders the splash's command list.
+var splashGroups = []struct {
+ title string
+ cmds []string
+}{
+ {"Start", []string{"login", "init", "status", "settings"}},
+ {"Agents", []string{"agents", "sync"}},
+ {"Memory and capture", []string{"memory", "capture"}},
+ {"Account", []string{"whoami", "org", "logout", "update"}},
+}
+
+// splash is `atlas` alone on a terminal.
+func splash(cmd *cobra.Command) {
+ groups := map[string][]tui.Command{}
+ var order []string
+ for _, g := range splashGroups {
+ order = append(order, g.title)
+ for _, name := range g.cmds {
+ if c, _, err := cmd.Find([]string{name}); err == nil && c != cmd {
+ groups[g.title] = append(groups[g.title], tui.Command{Name: name, Short: c.Short})
+ }
+ }
+ }
+ tui.Splash(cmd.OutOrStdout(), version, groups, order)
+}
+
// leaf builds a stub command with the given argument rule.
func leaf(use, short string, args cobra.PositionalArgs) *cobra.Command {
return &cobra.Command{Use: use, Short: short, Args: args, RunE: stub}
@@ -95,6 +134,10 @@ func newInitCmd() *cobra.Command {
c.Flags().String("visibility", "org", "who sees a new Project: org or restricted")
c.Flags().Bool("local", false, "bind locally only; nothing is synced")
c.Flags().Bool("yes", false, "do not prompt for confirmation")
+ c.Flags().StringSlice("agents", nil, "hook these agents (comma-separated ids, atlas agents list); skips the agent step")
+ c.Flags().Bool("all-agents", false, "hook every agent detected on this machine; skips the agent step")
+ c.Flags().String("sync", "", "background sync: on or off (atlas sync); skips the sync step")
+ c.MarkFlagsMutuallyExclusive("agents", "all-agents")
c.MarkFlagsMutuallyExclusive("project", "create")
c.MarkFlagsMutuallyExclusive("local", "project")
c.MarkFlagsMutuallyExclusive("local", "create")
@@ -107,6 +150,7 @@ func newMemoryCmd() *cobra.Command {
withRun(leaf("disable", "Turn shared memory off for this repository", cobra.NoArgs), runMemoryGate(false)),
withRun(leaf("verify", "Verify the memory revision hash chain", cobra.NoArgs), runMemoryVerify),
withRun(leaf("browse", "Browse shared memory: entries by kind, search, history", cobra.NoArgs), runMemoryBrowse),
+ newMemoryIndexCmd(),
newMemoryListCmd(),
withRun(leaf("export ", "Export durable memories as an Agent Memory Repo (MEMORY.md and one file per kind)", cobra.ExactArgs(1)), runMemoryExport),
withRun(leaf("import ", "Import Agent Memory Repo files as candidate memories", cobra.ExactArgs(1)), runMemoryImport),
@@ -126,7 +170,7 @@ func newCaptureCmd() *cobra.Command {
func newServeCmd() *cobra.Command {
c := withRun(leaf("serve", "Run the Project's listener: memory server, sync drain, and the receiver of agent hooks", cobra.NoArgs), runServe)
c.Flags().Bool("daemon", false, "start the listener detached")
- c.Flags().Bool("stop", false, "stop a detached listener")
+ c.Flags().Bool("stop", false, "stop a detached listener (atlas sync off also keeps hooks from restarting it)")
c.Flags().Duration("import-every", 0, "ACP import of native sessions this often (parked; 0: never)")
_ = c.Flags().MarkHidden("import-every")
c.MarkFlagsMutuallyExclusive("daemon", "stop")
@@ -161,6 +205,7 @@ func newMemoryListCmd() *cobra.Command {
c := withRun(leaf("list", "Print shared memory entries (for scripts; atlas memory browse on a terminal)", cobra.NoArgs), runMemoryList)
c.Flags().String("kind", "", "only this kind: plan, file_changed, decision, fact, failure, architecture, preference")
c.Flags().String("search", "", "search instead of listing")
+ c.Flags().Bool("all", false, "include candidates (unconfirmed memories), as the app shows them")
return c
}
diff --git a/cmd/atlas/help_view.go b/cmd/atlas/help_view.go
new file mode 100644
index 0000000..d72aa9e
--- /dev/null
+++ b/cmd/atlas/help_view.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/tryatlas/atlas-cli/internal/tui"
+)
+
+// styledHelp is --help on a terminal: the wordmark and path, what the
+// command does, then usage, subcommands and flags under upper-case labels.
+// Pipes keep cobra's plain help.
+func styledHelp(plain func(*cobra.Command, []string)) func(*cobra.Command, []string) {
+ return func(cmd *cobra.Command, args []string) {
+ if !interactive() {
+ plain(cmd, args)
+ return
+ }
+ w := cmd.OutOrStdout()
+ path := strings.Fields(cmd.CommandPath())
+ tui.Header(w, path[1:]...)
+ fmt.Fprintln(w)
+ desc := cmd.Long
+ if desc == "" {
+ desc = cmd.Short
+ }
+ for _, l := range tui.Wrap(desc, min(tui.Width()-4, 96)) {
+ fmt.Fprintln(w, " "+l)
+ }
+ tui.Section(w, "Usage")
+ fmt.Fprintln(w, " "+tui.Default.Bold.Render(cmd.UseLine()))
+ var rows [][]string
+ for _, c := range cmd.Commands() {
+ if c.IsAvailableCommand() {
+ rows = append(rows, []string{c.Name(), tui.Default.Faint.Render(tui.Fit(c.Short, max(tui.Width()-30, 20)))})
+ }
+ }
+ if len(rows) > 0 {
+ tui.Section(w, "Commands")
+ for _, r := range rows {
+ fmt.Fprintln(w, " "+tui.Default.Bold.Render(fmt.Sprintf("%-12s", r[0]))+" "+r[1])
+ }
+ }
+ if f := cmd.LocalFlags().FlagUsages(); f != "" {
+ tui.Section(w, "Flags")
+ for _, l := range strings.Split(strings.TrimRight(f, "\n"), "\n") {
+ name, desc := splitFlag(l)
+ fmt.Fprintln(w, " "+tui.Default.Bold.Render(fmt.Sprintf("%-28s", name))+" "+tui.Default.Faint.Render(desc))
+ }
+ }
+ fmt.Fprintln(w)
+ if len(rows) > 0 {
+ tui.Note(w, cmd.CommandPath()+" --help for more")
+ }
+ }
+}
+
+// splitFlag splits a pflag usage line into the flag and its description.
+func splitFlag(l string) (string, string) {
+ l = strings.TrimSpace(l)
+ if i := strings.Index(l, " "); i > 0 {
+ return strings.TrimSpace(l[:i]), strings.TrimSpace(l[i:])
+ }
+ return l, ""
+}
diff --git a/cmd/atlas/hooks_cmds.go b/cmd/atlas/hooks_cmds.go
index 22d1983..9b65542 100644
--- a/cmd/atlas/hooks_cmds.go
+++ b/cmd/atlas/hooks_cmds.go
@@ -7,14 +7,19 @@ import (
"fmt"
"io"
"os"
+ "os/exec"
+ "syscall"
"time"
"github.com/spf13/cobra"
gitlink "github.com/tryatlas/atlas-cli/internal/capture/git"
"github.com/tryatlas/atlas-cli/internal/capture/store"
+ capsync "github.com/tryatlas/atlas-cli/internal/capture/sync"
"github.com/tryatlas/atlas-cli/internal/hooks"
+ "github.com/tryatlas/atlas-cli/internal/hosts"
"github.com/tryatlas/atlas-cli/internal/listener"
+ "github.com/tryatlas/atlas-cli/internal/memory/extract"
"github.com/tryatlas/atlas-cli/internal/project"
// The agent integrations register themselves.
@@ -31,7 +36,8 @@ import (
// atlas hooks is what the installed agent hooks run; atlas
// hooks git is what the git hooks run. Both are hidden, read their
// payload on stdin, never block the agent, and never exit non-zero: an error
-// goes to .atlas/cli-hooks.log and shows in atlas status (ADR-0013 §4).
+// goes to hooks.log in the CLI state directory and shows in atlas status
+// (ADR-0013 §4).
func newHooksCmd() *cobra.Command {
root := &cobra.Command{Use: "hooks", Short: "Entry points for agent and git hooks (installed by atlas agents add)", Hidden: true, Args: cobra.NoArgs}
for _, a := range hooks.All() {
@@ -41,6 +47,12 @@ func newHooksCmd() *cobra.Command {
}
root.AddCommand(ag)
}
+ extract := &cobra.Command{Use: "extract", Short: "Distil memories from a captured session (run detached by the hooks)", Hidden: true, Args: cobra.NoArgs, RunE: runHookExtract}
+ extract.Flags().String("root", "", "the Project root")
+ extract.Flags().String("agent", "", "the desktop's agent id")
+ extract.Flags().String("session", "", "the agent's session id")
+ extract.Flags().Bool("final", false, "the session ended")
+ root.AddCommand(extract)
git := &cobra.Command{Use: "git", Short: "git hooks", Hidden: true, Args: cobra.NoArgs}
git.AddCommand(&cobra.Command{Use: "post-commit", Hidden: true, Args: cobra.ArbitraryArgs, RunE: runGitPostCommit})
root.AddCommand(git)
@@ -77,7 +89,7 @@ func runAgentHook(a hooks.Agent, verb string) func(*cobra.Command, []string) err
if !hooks.Enabled(ctx, p.Root) {
return nil
}
- d := &hooks.Dispatcher{Root: p.Root, Version: version, Now: time.Now, Deliver: hooks.Deliver(version), Banner: banner(ctx, p.Root)}
+ d := &hooks.Dispatcher{Root: p.Root, Version: version, Now: time.Now, Deliver: hooks.Deliver(version, syncAfterHook), Banner: banner(ctx, p.Root)}
out, err := d.Run(ctx, a, ev)
if err != nil {
hooks.LogError(p.Root, a.ID(), verb, err)
@@ -85,8 +97,96 @@ func runAgentHook(a hooks.Agent, verb string) func(*cobra.Command, []string) err
if len(out) > 0 {
_, _ = cmd.OutOrStdout().Write(out)
}
+ if err == nil && (ev.Kind == hooks.TurnEnd || ev.Kind == hooks.SessionEnd) {
+ spawnExtract(p.Root, a.ID(), ev.SessionID, ev.Kind == hooks.SessionEnd)
+ }
+ if err == nil && ev.Kind == hooks.SessionEnd {
+ spawnIndex(p.Root) // the session's document and any new memories
+ }
+ if ev.Kind == hooks.SessionStart || ev.Kind == hooks.TurnStart {
+ ensureListener(p.Root) // after the delivery, so nothing is spooled behind a starting listener
+ }
+ return nil
+ }
+}
+
+// spawnExtract starts `atlas hooks extract` detached: the model call can take
+// a minute, and the agent must not wait on it.
+func spawnExtract(root, agent, session string, final bool) {
+ self, err := os.Executable()
+ if err != nil {
+ return
+ }
+ args := []string{"hooks", "extract", "--root", root, "--agent", agent, "--session", session}
+ if final {
+ args = append(args, "--final")
+ }
+ c := exec.Command(self, args...) //nolint:gosec // G204: this binary
+ c.Dir = root
+ c.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+ if c.Start() == nil {
+ _ = c.Process.Release()
+ }
+}
+
+// runHookExtract runs one extraction pass; gates that say "not yet" are
+// silent, real failures go to the hooks log.
+func runHookExtract(cmd *cobra.Command, _ []string) error {
+ ctx, cancel := context.WithTimeout(cmd.Context(), 2*extract.Timeout)
+ defer cancel()
+ root, _ := cmd.Flags().GetString("root")
+ agent, _ := cmd.Flags().GetString("agent")
+ session, _ := cmd.Flags().GetString("session")
+ final, _ := cmd.Flags().GetBool("final")
+ if root == "" || session == "" || !hooks.ValidSessionID(session) {
return nil
}
+ a, err := newAuth()
+ if err != nil || a == nil {
+ return nil //nolint:nilerr // signed out: the desktop's extractor also needs a sign-in
+ }
+ s, err := a.Session()
+ if err != nil {
+ return nil //nolint:nilerr // signed out
+ }
+ n, err := extract.Run(ctx, extract.Options{Root: root, Agent: agent, Session: session, Final: final,
+ Gateway: hosts.Gateway(),
+ Auth: func(ctx context.Context) (string, string, error) {
+ tok, err := a.Tokens().Token(ctx)
+ return tok, s.ActiveOrgID(), err
+ }})
+ switch {
+ case errors.Is(err, extract.ErrSkipped):
+ case err != nil:
+ hooks.LogError(root, agent, "extract", err)
+ case n > 0:
+ hooks.ClearLastError(root)
+ // New memories: their vectors and documents, as the app reindexes
+ // after an extraction. This process is already detached.
+ if p, perr := project.Discover(ctx, root); perr == nil {
+ _, _ = indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {})
+ }
+ }
+ return nil
+}
+
+// syncAfterHook sends what a direct hook write just recorded to the cloud,
+// within the drain's short budget, so a turn reaches the Project without
+// waiting for atlas serve or atlas capture sync. Offline or signed out it
+// does nothing; the rows stay pending for the next drain.
+func syncAfterHook(ctx context.Context, st *store.Store, d hooks.Delivery) {
+ b, err := st.Binding(ctx)
+ if err != nil || b == nil {
+ return
+ }
+ if _, ok := capsync.TargetOf(b); !ok || b.DrainState == store.DrainNotAuthorized {
+ return
+ }
+ budget := capsync.Budget
+ if d.Tool != nil {
+ budget = time.Second // the agent waits on a post-tool hook: keep it short
+ }
+ _, _ = capsync.Drain(ctx, syncClient(), st, budget, time.Now)
}
// banner is the SessionStart message: what is on, and which Project.
diff --git a/cmd/atlas/index_cmds.go b/cmd/atlas/index_cmds.go
new file mode 100644
index 0000000..b7af372
--- /dev/null
+++ b/cmd/atlas/index_cmds.go
@@ -0,0 +1,142 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "syscall"
+
+ "github.com/spf13/cobra"
+ "golang.org/x/sys/unix"
+
+ "github.com/tryatlas/atlas-cli/internal/clistate"
+ "github.com/tryatlas/atlas-cli/internal/embed"
+ "github.com/tryatlas/atlas-cli/internal/memory/corpus"
+ "github.com/tryatlas/atlas-cli/internal/memory/record"
+ "github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui"
+)
+
+// atlas memory index: what the app's memory indexer does for a Project:
+// the MiniLM model (downloaded once, shared with the app), a vector for
+// every durable memory, and the corpus of documents memory_search returns.
+func newMemoryIndexCmd() *cobra.Command {
+ c := withRun(leaf("index", "Build the memory search index: memory vectors and the project's documents (downloads the model once)", cobra.NoArgs), runMemoryIndex)
+ c.Flags().Bool("no-download", false, "do not download the model; index keywords only when it is missing")
+ c.Flags().Bool("background", false, "run quietly, never download, skip when another pass runs (used by hooks)")
+ _ = c.Flags().MarkHidden("background")
+ return c
+}
+
+func runMemoryIndex(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ cwd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ p, err := project.Discover(ctx, cwd)
+ if errors.Is(err, project.ErrNotInitialised) {
+ return errNoProject
+ }
+ if err != nil {
+ return err
+ }
+ bg, _ := cmd.Flags().GetBool("background")
+ noDL, _ := cmd.Flags().GetBool("no-download")
+ if bg {
+ _, err := indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {})
+ if err != nil && !errors.Is(err, errIndexBusy) {
+ // The hooks log is where background work reports.
+ fmt.Fprintln(cmd.ErrOrStderr(), "atlas: memory index:", err)
+ }
+ return nil
+ }
+ return tui.WorkProgress(cmd.OutOrStdout(), "Indexing memory", func(step tui.StepFunc, frac tui.FracFunc) (string, error) {
+ return indexMemory(ctx, p.Root, p.ScopeRoot, !noDL, step, frac)
+ })
+}
+
+var errIndexBusy = errors.New("another index pass is running")
+
+// indexMemory is one pass: model (download when allowed), memory vectors,
+// corpus. One pass per Project at a time.
+func indexMemory(ctx context.Context, root, scope string, download bool, step tui.StepFunc, frac tui.FracFunc) (string, error) {
+ if !project.MemorySharing(scope) {
+ return "Shared memory is off here (atlas memory enable); nothing to index.", nil
+ }
+ lock, err := os.OpenFile(clistate.Path(root, "index.lock"), os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return "", err
+ }
+ defer lock.Close()
+ if unix.Flock(int(lock.Fd()), unix.LOCK_EX|unix.LOCK_NB) != nil {
+ return "", errIndexBusy
+ }
+ defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }()
+
+ if !embed.Available() && download {
+ step("Downloading the memory search model (" + embed.Size + ", shared with the Atlas app)")
+ if err := embed.Download(ctx, func(done, total int64) {
+ if total > 0 {
+ frac(0.8 * float64(done) / float64(total))
+ }
+ }); err != nil {
+ return "", fmt.Errorf("download the model: %w", err)
+ }
+ }
+ vectors := record.Vectors()
+ embedded := 0
+ if st, err := record.Open(scope, true); err == nil {
+ if vectors != nil {
+ step("Embedding memories")
+ embedded, err = st.SyncVectors(ctx)
+ if err != nil && !errors.Is(err, record.ErrNoVectors) {
+ _ = st.Close()
+ return "", err
+ }
+ }
+ _ = st.Close()
+ } else {
+ return "", err
+ }
+ frac(0.85)
+ step("Reading the project's documents")
+ docs, err := corpus.Collect(ctx, root, scope)
+ if err != nil {
+ return "", err
+ }
+ ix, err := corpus.Open(root, true)
+ if err != nil {
+ return "", err
+ }
+ defer ix.Close()
+ step(fmt.Sprintf("Indexing %d documents", len(docs)))
+ stats, err := ix.Update(ctx, docs, corpus.Owned, vectors)
+ if err != nil {
+ return "", err
+ }
+ frac(1)
+ msg := fmt.Sprintf("Memory index ready: %d documents (%d new, %d changed, %d removed); %d memories embedded.",
+ len(docs), stats.Added, stats.Updated, stats.Deleted, embedded)
+ if vectors == nil {
+ msg += " No model on this machine: keyword search only (atlas memory index downloads it)."
+ }
+ return msg, nil
+}
+
+// spawnIndex runs a background index pass detached, as hooks do after a
+// session (the app indexes when a turn finishes and after extraction).
+func spawnIndex(root string) {
+ self, err := os.Executable()
+ if err != nil {
+ return
+ }
+ c := exec.Command(self, "memory", "index", "--background") //nolint:gosec // G204: this binary
+ c.Dir = root
+ c.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+ if c.Start() == nil {
+ _ = c.Process.Release()
+ }
+}
diff --git a/cmd/atlas/init_wizard.go b/cmd/atlas/init_wizard.go
new file mode 100644
index 0000000..2c178f5
--- /dev/null
+++ b/cmd/atlas/init_wizard.go
@@ -0,0 +1,219 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/tryatlas/atlas-cli/internal/clistate"
+ "github.com/tryatlas/atlas-cli/internal/embed"
+ "github.com/tryatlas/atlas-cli/internal/hooks"
+ "github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui"
+)
+
+// initStepper numbers atlas init's steps: Organisation, Project, Agents and
+// Background sync for a cloud Project; Agents only for a local one.
+type initStepper []string
+
+func initSteps(local bool) initStepper {
+ s := initStepper{"Organisation", "Project", "Agents", "Background sync"}
+ if local {
+ s = initStepper{"Agents"}
+ }
+ if !embed.Available() {
+ s = append(s, "Memory search")
+ }
+ return s
+}
+
+func (s initStepper) step(title string) tui.Step {
+ for i, t := range s {
+ if t == title {
+ return tui.Step{N: i + 1, Total: len(s), Title: title}
+ }
+ }
+ return tui.Step{Title: title}
+}
+
+// finishInit runs the steps after the binding: which agents to hook, and
+// whether to keep syncing in the background.
+func finishInit(cmd *cobra.Command, root string, local, guided bool, steps initStepper) error {
+ out := cmd.OutOrStdout()
+ agents, err := pickAgents(cmd, root, guided, steps.step("Agents"))
+ if err != nil {
+ return err
+ }
+ on, ask := false, false
+ if !local {
+ if on, ask, err = pickSync(cmd, root, guided, steps.step("Background sync")); err != nil {
+ return err
+ }
+ }
+ model := false
+ if guided && !embed.Available() && project.MemorySharing(project.ScopeRoot(cmd.Context(), root)) {
+ i, err := chooseStep(steps.step("Memory search"), "Download the memory search model? Agents then find memories by meaning, not only by words.", []tui.Choice{
+ {Label: "Download", Detail: embed.Size + ", once; the same model the Atlas app uses"},
+ {Label: "Not now", Detail: "keyword search; atlas memory index downloads it later"},
+ }, 0)
+ if err != nil {
+ return err
+ }
+ model = i == 0
+ }
+ if !guided {
+ if len(agents) > 0 {
+ if err := installAgents(cmd.Context(), out, root, agents, false, false); err != nil {
+ return err
+ }
+ }
+ if ask {
+ msg, err := setBackgroundSync(root, on)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintln(out, msg)
+ }
+ return nil
+ }
+ // On a terminal the work runs under the progress bar, each line a step.
+ if model {
+ fmt.Fprintln(out)
+ scope := project.ScopeRoot(cmd.Context(), root)
+ if err := tui.WorkProgress(out, "Setting up memory search", func(step tui.StepFunc, frac tui.FracFunc) (string, error) {
+ return indexMemory(cmd.Context(), root, scope, true, step, frac)
+ }); err != nil {
+ return err
+ }
+ } else if embed.Available() {
+ spawnIndex(root) // the index, in the background
+ }
+ if len(agents) > 0 || ask {
+ fmt.Fprintln(out)
+ err := tui.Work(out, "Initializing Atlas in "+root, func(step tui.StepFunc) (string, error) {
+ w := tui.Lines(io.Discard, func(l string) string {
+ if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "note:") {
+ step(l)
+ }
+ return l
+ })
+ if len(agents) > 0 {
+ if err := installAgents(cmd.Context(), w, root, agents, false, false); err != nil {
+ return "", err
+ }
+ }
+ if ask {
+ msg, err := setBackgroundSync(root, on)
+ if err != nil {
+ return "", err
+ }
+ step(msg)
+ }
+ return "Atlas is ready in " + root, nil
+ })
+ if err != nil {
+ return err
+ }
+ for _, a := range agents {
+ if n, ok := a.(hooks.Noter); ok {
+ tui.Note(out, "↳ "+n.AddNote())
+ }
+ }
+ } else {
+ tui.Ready(out, "Atlas is ready in "+root)
+ }
+ fmt.Fprintln(out)
+ tui.Note(out, "Use your agents as usual; sessions and memory are captured as they run.")
+ tui.Note(out, "atlas status shows what was captured · atlas settings changes any of this")
+ return nil
+}
+
+// detail says what the machine has of an agent.
+func agentDetail(a hooks.Agent, root string) string {
+ d := "configured here"
+ if _, err := exec.LookPath(a.Binary()); err == nil {
+ d = a.Binary() + " on PATH"
+ }
+ switch a.State(root) {
+ case hooks.Current:
+ d += " · already hooked"
+ case hooks.Outdated:
+ d += " · hook outdated"
+ }
+ return d
+}
+
+// pickAgents is step 3: --agents or --all-agents, else a checklist of the
+// agents detected here with a row that selects them all.
+func pickAgents(cmd *cobra.Command, root string, guided bool, step tui.Step) ([]hooks.Agent, error) {
+ flags := cmd.Flags()
+ ids, _ := flags.GetStringSlice("agents")
+ all, _ := flags.GetBool("all-agents")
+ var detected []hooks.Agent
+ for _, a := range hooks.All() {
+ if a.Detect(root) {
+ detected = append(detected, a)
+ }
+ }
+ switch {
+ case len(ids) > 0:
+ return resolveAgents(ids, false, nil)
+ case all:
+ if len(detected) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No supported agent was found on this machine (atlas agents list).")
+ }
+ return detected, nil
+ case !guided:
+ return nil, nil
+ case len(detected) == 0:
+ tui.StepDone(cmd.OutOrStdout(), step, "none found on this machine (atlas agents add later)")
+ return nil, nil
+ }
+ choices := make([]tui.Choice, len(detected))
+ checked := make([]bool, len(detected))
+ for i, a := range detected {
+ choices[i] = tui.Choice{Label: a.Name(), Detail: agentDetail(a, root)}
+ checked[i] = true
+ }
+ picked, err := chooseMany(step, "Which agents should Atlas capture and give shared memory?", "All agents", choices, checked)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]hooks.Agent, len(picked))
+ for i, n := range picked {
+ out[i] = detected[n]
+ }
+ return out, nil
+}
+
+// pickSync is step 4: background sync on or off. ask is false when nothing
+// was asked and no flag given, so the current choice stays.
+func pickSync(cmd *cobra.Command, root string, guided bool, step tui.Step) (on, ask bool, err error) {
+ switch v, _ := cmd.Flags().GetString("sync"); v {
+ case "on":
+ return true, true, nil
+ case "off":
+ return false, true, nil
+ case "":
+ default:
+ return false, false, fmt.Errorf("--sync must be on or off, not %q", v)
+ }
+ if !guided {
+ return false, false, nil
+ }
+ initial := 1
+ if clistate.LoadPrefs(root).BackgroundSync {
+ initial = 0
+ }
+ i, err := chooseStep(step, "Keep syncing in the background? (atlas sync on|off changes it later)", []tui.Choice{
+ {Label: "On", Detail: "a background atlas serve sends prompts, tool calls and replies as they happen"},
+ {Label: "Off", Detail: "each agent hook syncs on its own; nothing keeps running"},
+ }, initial)
+ if err != nil {
+ return false, false, err
+ }
+ return i == 0, true, nil
+}
diff --git a/cmd/atlas/main.go b/cmd/atlas/main.go
index 2f0b6ae..dd85c19 100644
--- a/cmd/atlas/main.go
+++ b/cmd/atlas/main.go
@@ -10,6 +10,8 @@ import (
"os"
"os/signal"
"syscall"
+
+ "github.com/tryatlas/atlas-cli/internal/tui"
)
// version is set at build time with -ldflags "-X main.version=…".
@@ -24,6 +26,10 @@ func main() {
// run executes the command tree and returns the process exit code.
func run(args []string, stdout, stderr io.Writer) int {
+ if interactive() {
+ tui.Detect() // light or dark palette, before anything draws
+ stdout = tui.Out(stdout)
+ }
root := newRootCmd()
root.SetArgs(args)
root.SetIn(stdin)
@@ -37,7 +43,9 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, err)
return 2
}
- fmt.Fprintln(stderr, "atlas:", err)
+ if !errors.Is(err, tui.ErrCancelled) && !tui.Shown(err) { // the screen already said so
+ fmt.Fprintln(stderr, "atlas:", err)
+ }
return 1
}
return 0
diff --git a/cmd/atlas/main_test.go b/cmd/atlas/main_test.go
index db10ead..58c6559 100644
--- a/cmd/atlas/main_test.go
+++ b/cmd/atlas/main_test.go
@@ -20,6 +20,9 @@ func TestMain(m *testing.M) {
_ = os.Setenv("ATLAS_INGEST_URL", "http://127.0.0.1:1") // unreachable unless a test installs a fake
data, _ := os.MkdirTemp("", "atlas-data-") // never the user's agents
_ = os.Setenv("XDG_DATA_HOME", data)
+ state, _ := os.MkdirTemp("", "atlas-state-") // never the user's CLI state
+ _ = os.Setenv("XDG_STATE_HOME", state)
+ _ = os.Setenv("ATLAS_MODELS_DIR", state) // no embedding model: deterministic
os.Exit(m.Run())
}
@@ -44,6 +47,7 @@ var wantCommands = []string{
"capture link",
"capture retry",
"capture sync",
+ "hooks extract",
"hooks git post-commit",
"init",
"login",
@@ -54,13 +58,17 @@ var wantCommands = []string{
"memory enable",
"memory export",
"memory import",
+ "memory index",
"memory list",
"memory verify",
"org create",
"org list",
"org use",
"serve",
+ "settings",
"status",
+ "sync",
+ "update",
"whoami",
}
@@ -104,8 +112,9 @@ var implemented = map[string]bool{
"memory verify": true, "memory export": true, "memory import": true, "mcp-bridge": true, "serve": true,
"capture link": true, "capture sync": true, "acp import": true, "capture retry": true, "acp run": true, "memory browse": true, "memory list": true,
"acp check": true, "acp detect": true, "acp install": true, "acp list": true, "acp sessions": true, "acp logout": true, "acp uninstall": true,
+ "settings": true, "sync": true, "update": true, "memory index": true,
"agents add": true, "agents list": true, "agents remove": true, "agents sessions": true,
- "hooks git post-commit": true,
+ "hooks extract": true, "hooks git post-commit": true,
}
func TestStubsReportNotImplemented(t *testing.T) {
diff --git a/cmd/atlas/memory_cmds.go b/cmd/atlas/memory_cmds.go
index 906cbe2..d37c3fd 100644
--- a/cmd/atlas/memory_cmds.go
+++ b/cmd/atlas/memory_cmds.go
@@ -14,6 +14,7 @@ import (
"github.com/tryatlas/atlas-cli/internal/memory/record"
"github.com/tryatlas/atlas-cli/internal/memory/server"
"github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui"
"github.com/tryatlas/atlas-cli/internal/tui/memview"
)
@@ -37,15 +38,16 @@ func runMemoryVerify(cmd *cobra.Command, _ []string) error {
return err
}
defer st.Close()
- rep, err := st.VerifyChain()
- if err != nil {
- return err
- }
- if !rep.OK() {
- return fmt.Errorf("the revision chain is broken at revision %d (%d revisions checked): history was edited outside Atlas", rep.FirstBad, rep.Revisions)
- }
- fmt.Fprintf(cmd.OutOrStdout(), "Revision chain intact: %d revisions in %s\n", rep.Revisions, record.Path(st.ScopeRoot))
- return nil
+ return tui.Work(cmd.OutOrStdout(), "Verifying the memory revision chain", func(tui.StepFunc) (string, error) {
+ rep, err := st.VerifyChain()
+ if err != nil {
+ return "", err
+ }
+ if !rep.OK() {
+ return "", fmt.Errorf("the revision chain is broken at revision %d (%d revisions checked): history was edited outside Atlas", rep.FirstBad, rep.Revisions)
+ }
+ return fmt.Sprintf("Revision chain intact: %d revisions in %s", rep.Revisions, record.Path(st.ScopeRoot)), nil
+ })
}
func runMemoryExport(cmd *cobra.Command, args []string) error {
@@ -54,12 +56,13 @@ func runMemoryExport(cmd *cobra.Command, args []string) error {
return err
}
defer st.Close()
- n, err := st.Export(cmd.Context(), args[0])
- if err != nil {
- return err
- }
- fmt.Fprintf(cmd.OutOrStdout(), "Exported %d memories to %s (MEMORY.md, decisions.md, architecture.md, facts.md, failures.md).\n", n, args[0])
- return nil
+ return tui.Work(cmd.OutOrStdout(), "Exporting shared memory", func(tui.StepFunc) (string, error) {
+ n, err := st.Export(cmd.Context(), args[0])
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("Exported %d memories to %s (MEMORY.md, decisions.md, architecture.md, facts.md, failures.md).", n, args[0]), nil
+ })
}
func runMemoryImport(cmd *cobra.Command, args []string) error {
@@ -75,12 +78,13 @@ func runMemoryImport(cmd *cobra.Command, args []string) error {
return err
}
defer st.Close()
- res, err := st.Import(cmd.Context(), args[0], now().UnixMilli())
- if err != nil {
- return err
- }
- fmt.Fprintf(cmd.OutOrStdout(), "Imported %d memories as candidates (%d already held). Agents see them as unconfirmed until one restates them.\n", res.Imported, res.Skipped)
- return nil
+ return tui.Work(cmd.OutOrStdout(), "Importing memories", func(tui.StepFunc) (string, error) {
+ res, err := st.Import(cmd.Context(), args[0], now().UnixMilli())
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("Imported %d memories as candidates (%d already held). Agents see them as unconfirmed until one restates them.", res.Imported, res.Skipped), nil
+ })
}
func gateRoot(cmd *cobra.Command, cwd string) string {
@@ -167,9 +171,35 @@ func runMemoryList(cmd *cobra.Command, _ []string) error {
for _, h := range hits {
es = append(es, h.Entry)
}
+ } else if all, _ := cmd.Flags().GetBool("all"); all {
+ // Candidates too, as the app's memory panel shows them.
+ if es, err = st.List(ctx, kind, 10000); err != nil {
+ return err
+ }
} else if es, err = st.Active(ctx, kinds...); err != nil {
return err
}
+ if interactive() {
+ out := cmd.OutOrStdout()
+ tui.Header(out, "memory", "list")
+ fmt.Fprintln(out)
+ if len(es) == 0 {
+ tui.Note(out, "No memories here yet.")
+ return nil
+ }
+ var rows [][]string
+ for _, e := range es {
+ state := tui.Dot(tui.Good, e.State)
+ if e.State == record.StateCandidate {
+ state = tui.Dot(tui.Idle, e.State)
+ }
+ rows = append(rows, []string{fmt.Sprint(e.ID), strings.ReplaceAll(e.Kind, "_", " "), state,
+ tui.Fit(strings.Join(strings.Fields(e.Content), " "), max(tui.Width()-50, 20))})
+ }
+ fmt.Fprint(out, tui.Table([]string{"id", "kind", "state", "memory"}, rows))
+ tui.Note(out, fmt.Sprintf("%d memories · atlas memory browse to read them", len(es)))
+ return nil
+ }
for _, e := range es {
fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\t%s\t%s\n", e.ID, e.Kind, e.State, strings.Join(strings.Fields(e.Content), " "))
}
diff --git a/cmd/atlas/project_cmds.go b/cmd/atlas/project_cmds.go
index 20f6cc5..2cc773a 100644
--- a/cmd/atlas/project_cmds.go
+++ b/cmd/atlas/project_cmds.go
@@ -2,6 +2,7 @@ package main
import (
"bufio"
+ "bytes"
"context"
"errors"
"fmt"
@@ -11,6 +12,7 @@ import (
"strings"
"time"
+ "charm.land/lipgloss/v2"
"github.com/spf13/cobra"
"github.com/tryatlas/atlas-cli/internal/auth"
@@ -25,7 +27,8 @@ import (
// Seams replaced in tests.
var (
interactive = tui.Interactive
- choose = tui.Select
+ chooseStep = tui.StepSelect
+ chooseMany = tui.StepMulti
now = time.Now
)
@@ -34,10 +37,11 @@ const lockedHelp = "another Atlas window is recording this Project; close the fo
func runInit(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
- out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr()
+ errOut := cmd.ErrOrStderr()
flags := cmd.Flags()
local, _ := flags.GetBool("local")
yes, _ := flags.GetBool("yes")
+ guided := interactive() && !yes
cwd, err := os.Getwd()
if err != nil {
@@ -48,6 +52,12 @@ func runInit(cmd *cobra.Command, _ []string) error {
return err
}
scope := project.ScopeRoot(ctx, launch)
+ if guided {
+ tui.Animate(cmd.OutOrStdout(), func(t float64) string {
+ return lipgloss.JoinHorizontal(lipgloss.Center, tui.Logo(t), " ",
+ tui.Default.Bold.Render("Set up Atlas")+"\n"+tui.Default.Faint.Render(launch)) + "\n"
+ })
+ }
if launch != scope {
fmt.Fprintf(errOut, "Note: %s is inside the repository %s.\n"+
@@ -64,13 +74,46 @@ func runInit(cmd *cobra.Command, _ []string) error {
if a, err = newAuth(); err != nil {
return err
}
- if sess, err = a.Refresh(ctx); errors.Is(err, auth.ErrSignedOut) {
- return err
- } else if sess == nil {
+ sess, err = a.Refresh(ctx)
+ if errors.Is(err, auth.ErrSignedOut) && guided {
+ i, cerr := chooseStep(tui.Step{Title: "Account"}, "You are not signed in to Atlas.", []tui.Choice{
+ {Label: "Sign in", Detail: "opens the browser; sessions and memory sync to a cloud Project"},
+ {Label: "Set up locally", Detail: "nothing leaves this machine"},
+ }, 0)
+ if cerr != nil {
+ return cerr
+ }
+ if i == 1 {
+ local = true
+ } else {
+ if err := runLogin(cmd, nil); err != nil {
+ return err
+ }
+ sess, err = a.Refresh(ctx)
+ }
+ }
+ if !local && sess == nil {
return err
}
}
+ steps := initSteps(local)
+
+ if err := bindFolder(cmd, a, sess, local, launch, scope, steps); err != nil {
+ return err
+ }
+ ensureMemoryRecord(scope)
+ return finishInit(cmd, launch, local, guided, steps)
+}
+// bindFolder writes the binding under the writer lock and lets it go.
+func bindFolder(cmd *cobra.Command, a *auth.Auth, sess *auth.Session, local bool, launch, scope string, steps initStepper) error {
+ ctx := cmd.Context()
+ out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr()
+ yes, _ := cmd.Flags().GetBool("yes")
+ guided := interactive() && !yes
+ if guided {
+ out = tui.Indent(out) // inside the steps' tree
+ }
lock, err := store.Lock(launch)
if errors.Is(err, store.ErrLocked) {
return errors.New(lockedHelp)
@@ -105,16 +148,20 @@ func runInit(cmd *cobra.Command, _ []string) error {
if err := st.PutBinding(ctx, b, now()); err != nil {
return err
}
- fmt.Fprintf(out, "Bound %s locally. Nothing is synced.\n", launch)
+ if guided {
+ tui.StepDone(cmd.OutOrStdout(), tui.Step{Title: "Project"}, "local · nothing is synced")
+ } else {
+ fmt.Fprintf(out, "Bound %s locally. Nothing is synced.\n", launch)
+ }
return nil
}
- org, err := pickOrg(cmd, sess)
+ org, err := pickOrg(cmd, sess, steps.step("Organisation"))
if err != nil {
return err
}
ic := ingest.New(hosts.Ingest(), a.Tokens())
- ws, how, err := pickProject(cmd, ic, org, launch, fp)
+ ws, how, err := pickProject(cmd, ic, org, launch, fp, steps.step("Project"))
if err != nil {
return err
}
@@ -128,13 +175,27 @@ func runInit(cmd *cobra.Command, _ []string) error {
if err := st.BindCloud(ctx, b, now()); err != nil {
return err
}
- fmt.Fprintf(out, "Bound %s to %s / %s (%s).\n", launch, org.Name, ws.Label(), how)
+ if !guided { // on a terminal the Project step's line says it
+ fmt.Fprintf(out, "Bound %s to %s / %s (%s).\n", launch, org.Name, ws.Label(), how)
+ }
if launch != scope {
fmt.Fprintf(out, "Shared memory for this folder lives at %s.\n", filepath.Join(scope, ".atlas", "memory"))
}
return nil
}
+// ensureMemoryRecord creates /.atlas/memory/memory.sqlite when shared
+// memory is on, so a bound folder has its record from the start (the app
+// creates it on the first session; an empty record changes nothing).
+func ensureMemoryRecord(scope string) {
+ if !project.MemorySharing(scope) {
+ return
+ }
+ if st, err := record.Open(scope, true); err == nil {
+ _ = st.Close()
+ }
+}
+
// confirm asks a yes/no question; without a terminal, --yes is required.
func confirm(cmd *cobra.Command, yes bool, question string) error {
if yes {
@@ -151,7 +212,7 @@ func confirm(cmd *cobra.Command, yes bool, question string) error {
return tui.ErrCancelled
}
-func pickOrg(cmd *cobra.Command, s *auth.Session) (auth.Org, error) {
+func pickOrg(cmd *cobra.Command, s *auth.Session, step tui.Step) (auth.Org, error) {
want, _ := cmd.Flags().GetString("org")
var orgs []auth.Org
if s.Identity != nil && s.Identity.Orgs != nil {
@@ -163,35 +224,46 @@ func pickOrg(cmd *cobra.Command, s *auth.Session) (auth.Org, error) {
if want != "" {
for _, o := range orgs {
if o.ID == want {
+ stepDone(cmd, step, o.Name)
return o, nil
}
}
return auth.Org{}, fmt.Errorf("%s is not one of your organisations (atlas org list)", want)
}
if len(orgs) == 1 {
+ stepDone(cmd, step, orgs[0].Name+" (your only organisation)")
return orgs[0], nil
}
if !interactive() {
return auth.Org{}, errors.New("you belong to several organisations; pass --org (atlas org list)")
}
- labels := make([]string, len(orgs))
+ choices := make([]tui.Choice, len(orgs))
initial := 0
for i, o := range orgs {
- labels[i] = fmt.Sprintf("%s (%s)", o.Name, o.ID)
+ choices[i] = tui.Choice{Label: o.Name, Detail: o.ID}
if o.ID == s.ActiveOrgID() {
initial = i
+ choices[i].Detail += " · active"
}
}
- i, err := choose("Which organisation?", labels, initial)
+ i, err := chooseStep(step, "Which organisation should this folder belong to?", choices, initial)
if err != nil {
return auth.Org{}, err
}
return orgs[i], nil
}
+// stepDone prints an answered step on a terminal; scripts get the summary
+// line only.
+func stepDone(cmd *cobra.Command, step tui.Step, answer string) {
+ if interactive() {
+ tui.StepDone(cmd.OutOrStdout(), step, answer)
+ }
+}
+
// pickProject chooses the cloud Project: an explicit --project, a new one with
// --create, else the server's fingerprint match, else a choice from the list.
-func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch string, fp project.GitFingerprint) (ingest.Workspace, string, error) {
+func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch string, fp project.GitFingerprint, step tui.Step) (ingest.Workspace, string, error) {
ctx := cmd.Context()
flags := cmd.Flags()
explicit, _ := flags.GetString("project")
@@ -214,10 +286,19 @@ func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch str
switch {
case explicit != "":
- return connect(explicit, nil)
+ ws, how, err := connect(explicit, nil)
+ if err == nil {
+ stepDone(cmd, step, ws.Label())
+ }
+ return ws, how, err
case create:
- return createProject(cmd, ic, org, launch, fp)
+ ws, how, err := createProject(cmd, ic, org, launch, fp)
+ if err == nil {
+ stepDone(cmd, step, ws.Label()+" (new)")
+ }
+ return ws, how, err
}
+ newLabel := tui.Choice{Label: "+ Create a new Project", Detail: "“" + defaultSlug(cmd, launch) + "”"}
if fp.RootCommitSHA != nil || fp.GitURL != nil {
res, err := ic.Connect(ctx, ingest.ConnectRequest{OrgID: org.ID, RootCommitSHA: fp.RootCommitSHA, GitURL: fp.GitURL})
@@ -226,12 +307,37 @@ func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch str
}
switch res.Status {
case "connected":
- return ingest.Workspace{ID: res.WorkspaceID, Slug: deref(res.Slug)}, "matched by " + res.Reason, nil
+ matched := ingest.Workspace{ID: res.WorkspaceID, Slug: deref(res.Slug)}
+ if !interactive() {
+ return matched, "matched by " + res.Reason, nil
+ }
+ // Offer the match first, the org's other Projects after it.
+ list, _ := ic.Workspaces(ctx, org.ID)
+ opts := []ingest.Workspace{matched}
+ for _, w := range list {
+ if w.ID == matched.ID {
+ opts[0] = w
+ } else {
+ opts = append(opts, w)
+ }
+ }
+ choices := workspaceChoices(opts)
+ choices[0].Detail = "matches this repository (" + res.Reason + ")"
+ i, err := chooseStep(step, "Which Project in "+org.Name+"?", append(choices, newLabel), 0)
+ switch {
+ case err != nil:
+ return ingest.Workspace{}, "", err
+ case i == 0:
+ return opts[0], "matched by " + res.Reason, nil
+ case i == len(opts):
+ return createProject(cmd, ic, org, launch, fp)
+ }
+ return connect(opts[i].ID, &opts[i].Slug)
case "ambiguous":
if !interactive() {
return ingest.Workspace{}, "", fmt.Errorf("several Projects match this repository (%s); pass --project ", workspaceList(res.Candidates))
}
- i, err := choose("Several Projects match this repository. Which one?", workspaceLabels(res.Candidates), 0)
+ i, err := chooseStep(step, "Several Projects in "+org.Name+" match this repository. Which one?", workspaceChoices(res.Candidates), 0)
if err != nil {
return ingest.Workspace{}, "", err
}
@@ -250,8 +356,7 @@ func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch str
}
return ingest.Workspace{}, "", fmt.Errorf("no Project matches this repository; pass --project (%s) or --create", workspaceList(list))
}
- newLabel := fmt.Sprintf("Create a new Project “%s”", defaultSlug(cmd, launch))
- i, err := choose("Which Project in "+org.Name+"?", append(workspaceLabels(list), newLabel), len(list))
+ i, err := chooseStep(step, "Which Project in "+org.Name+"?", append(workspaceChoices(list), newLabel), len(list))
if err != nil {
return ingest.Workspace{}, "", err
}
@@ -300,6 +405,14 @@ func workspaceLabels(ws []ingest.Workspace) []string {
return out
}
+func workspaceChoices(ws []ingest.Workspace) []tui.Choice {
+ out := make([]tui.Choice, len(ws))
+ for i, w := range ws {
+ out[i] = tui.Choice{Label: w.Label(), Detail: w.ID}
+ }
+ return out
+}
+
func workspaceList(ws []ingest.Workspace) string { return strings.Join(workspaceLabels(ws), ", ") }
func deref(p *string) string {
@@ -361,7 +474,7 @@ func runMemoryGate(enabled bool) func(*cobra.Command, []string) error {
if enabled {
state = "on"
}
- fmt.Fprintf(cmd.OutOrStdout(), "Shared memory is %s for %s.\n", state, dir)
+ tui.Done(cmd.OutOrStdout(), fmt.Sprintf("Shared memory is %s for %s.", state, dir))
return nil
}
}
@@ -393,9 +506,9 @@ func runCaptureGate(enabled bool) func(*cobra.Command, []string) error {
return err
}
if enabled {
- fmt.Fprintf(cmd.OutOrStdout(), "Capture is on for %s.\n", p.Root)
+ tui.Done(cmd.OutOrStdout(), fmt.Sprintf("Capture is on for %s.", p.Root))
} else {
- fmt.Fprintf(cmd.OutOrStdout(), "Capture is paused for %s. What was recorded still syncs.\n", p.Root)
+ tui.Done(cmd.OutOrStdout(), fmt.Sprintf("Capture is paused for %s. What was recorded still syncs.", p.Root))
}
return nil
}
@@ -404,8 +517,19 @@ func runCaptureGate(enabled bool) func(*cobra.Command, []string) error {
// ── Status ───────────────────────────────────────────────────────────────────
func runStatus(cmd *cobra.Command, _ []string) error {
+ if !interactive() {
+ return statusLines(cmd, cmd.OutOrStdout())
+ }
+ var buf bytes.Buffer
+ err := statusLines(cmd, &buf)
+ renderStatus(cmd.OutOrStdout(), buf.String())
+ return err
+}
+
+// statusLines prints "Label: value" lines, one fact each; on a terminal
+// renderStatus draws them as a card.
+func statusLines(cmd *cobra.Command, w io.Writer) error {
ctx := cmd.Context()
- w := cmd.OutOrStdout()
var sess *auth.Session
if a, err := newAuth(); err == nil {
if s, err := a.Session(); err == nil {
@@ -461,6 +585,7 @@ func runStatus(cmd *cobra.Command, _ []string) error {
}
printCaptureHealth(ctx, w, st, p.Root, b)
printMemoryHealth(ctx, w, p.ScopeRoot)
+ printSync(w, p.Root)
printAgents(ctx, w, p.Root)
return nil
}
diff --git a/cmd/atlas/serve_cmds.go b/cmd/atlas/serve_cmds.go
index 2b437c2..173c972 100644
--- a/cmd/atlas/serve_cmds.go
+++ b/cmd/atlas/serve_cmds.go
@@ -11,7 +11,7 @@ import (
"os"
"os/exec"
"os/signal"
- "path/filepath"
+ "strings"
"syscall"
"time"
@@ -21,12 +21,14 @@ import (
gitlink "github.com/tryatlas/atlas-cli/internal/capture/git"
"github.com/tryatlas/atlas-cli/internal/capture/store"
capsync "github.com/tryatlas/atlas-cli/internal/capture/sync"
+ "github.com/tryatlas/atlas-cli/internal/clistate"
"github.com/tryatlas/atlas-cli/internal/hooks"
"github.com/tryatlas/atlas-cli/internal/hosts"
"github.com/tryatlas/atlas-cli/internal/ingest"
"github.com/tryatlas/atlas-cli/internal/listener"
"github.com/tryatlas/atlas-cli/internal/project"
atlasrun "github.com/tryatlas/atlas-cli/internal/run"
+ "github.com/tryatlas/atlas-cli/internal/tui"
)
// ── atlas capture sync / retry ───────────────────────────────────────────────
@@ -59,9 +61,9 @@ func runCaptureSync(retry bool) func(*cobra.Command, []string) error {
return fmt.Errorf("the running listener did not answer: %w", err)
}
if retry {
- fmt.Fprintf(out, "Requeued %d failed row(s).\n", ans.Requeued)
+ tui.Done(out, fmt.Sprintf("Requeued %d failed row(s).", ans.Requeued))
}
- fmt.Fprintln(out, "Asked the running listener to sync now.")
+ tui.Done(out, "Asked the running listener to sync now.")
return nil
}
if err != nil {
@@ -73,56 +75,64 @@ func runCaptureSync(retry bool) func(*cobra.Command, []string) error {
return err
}
defer st.Close()
+ title := "Syncing to the cloud Project"
if retry {
- n, err := st.RetryFailed(ctx)
- if err != nil {
- return err
- }
- fmt.Fprintf(out, "Requeued %d failed row(s).\n", n)
- }
- b, err := st.Binding(ctx)
- if err != nil {
- return err
- }
- if _, ok := capsync.TargetOf(b); !ok {
- fmt.Fprintln(out, "This Project is local: nothing syncs. Bind it to a cloud Project with atlas init.")
- return nil
+ title = "Retrying failed rows"
}
- if b.DrainState == store.DrainNotAuthorized {
- // A human action re-checks access (the desktop re-registers).
- if err := st.SetDrainState(ctx, store.DrainOK, time.Now()); err != nil {
- return err
+ return tui.Work(out, title, func(step tui.StepFunc) (string, error) {
+ var lines []string
+ if retry {
+ n, err := st.RetryFailed(ctx)
+ if err != nil {
+ return "", err
+ }
+ lines = append(lines, fmt.Sprintf("Requeued %d failed row(s).", n))
+ step(lines[0])
}
- }
- total := capsync.Report{}
- c := syncClient()
- for {
- rep, err := capsync.Drain(ctx, c, st, capsync.Budget, time.Now)
+ b, err := st.Binding(ctx)
if err != nil {
- return err
+ return "", err
}
- total.Sent += rep.Sent
- total.Failed += rep.Failed
- total.Retired += rep.Retired
- total.Outcome = rep.Outcome
- if rep.Outcome != capsync.Done || rep.Sent+rep.Failed == 0 {
- break
+ if _, ok := capsync.TargetOf(b); !ok {
+ return strings.Join(append(lines, "This Project is local: nothing syncs. Bind it to a cloud Project with atlas init."), "\n"), nil
}
- }
- counts, _ := st.OutboxCounts(ctx)
- fmt.Fprintf(out, "Sent %d row(s); %d failed. Outbox: %d pending, %d failed.\n",
- total.Sent, total.Failed+int(total.Retired), counts[store.SyncPending], counts[store.SyncFailed])
- switch total.Outcome {
- case capsync.Offline:
- return errors.New("the ingest server is unreachable; the listener retries with backoff")
- case capsync.RateLimited:
- return errors.New("the ingest server asked to slow down; try again later")
- case capsync.NotAuthorized:
- return errors.New("the ingest server refused this Project (403); check your access with atlas status")
- case capsync.SignedOut:
- return errors.New("not signed in; run atlas login")
- }
- return nil
+ if b.DrainState == store.DrainNotAuthorized {
+ // A human action re-checks access (the desktop re-registers).
+ if err := st.SetDrainState(ctx, store.DrainOK, time.Now()); err != nil {
+ return "", err
+ }
+ }
+ total := capsync.Report{}
+ c := syncClient()
+ for {
+ rep, err := capsync.Drain(ctx, c, st, capsync.Budget, time.Now)
+ if err != nil {
+ return "", err
+ }
+ total.Sent += rep.Sent
+ total.Failed += rep.Failed
+ total.Retired += rep.Retired
+ total.Outcome = rep.Outcome
+ step(fmt.Sprintf("%d row(s) sent", total.Sent))
+ if rep.Outcome != capsync.Done || rep.Sent+rep.Failed == 0 {
+ break
+ }
+ }
+ counts, _ := st.OutboxCounts(ctx)
+ summary := fmt.Sprintf("Sent %d row(s); %d failed. Outbox: %d pending, %d failed.",
+ total.Sent, total.Failed+int(total.Retired), counts[store.SyncPending], counts[store.SyncFailed])
+ switch total.Outcome {
+ case capsync.Offline:
+ return "", fmt.Errorf("%s The ingest server is unreachable; the listener retries with backoff", summary)
+ case capsync.RateLimited:
+ return "", fmt.Errorf("%s The ingest server asked to slow down; try again later", summary)
+ case capsync.NotAuthorized:
+ return "", fmt.Errorf("%s The ingest server refused this Project (403); check your access with atlas status", summary)
+ case capsync.SignedOut:
+ return "", fmt.Errorf("%s Not signed in; run atlas login", summary)
+ }
+ return strings.Join(append(lines, summary), "\n"), nil
+ })
}
}
@@ -156,7 +166,7 @@ func postSync(ctx context.Context, sock string, retry bool) (listener.SyncAnswer
const daemonEnv = "ATLAS_SERVE_DAEMON"
// serveLog is where a detached listener's stderr goes.
-func serveLog(root string) string { return filepath.Join(root, store.DirName, "cli-serve.log") }
+func serveLog(root string) string { return clistate.Path(root, "serve.log") }
// runServe runs the listener until interrupted; --daemon detaches it and
// --stop ends a detached one.
@@ -181,6 +191,15 @@ func runServe(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
+ var live *tui.Live
+ if os.Getenv(daemonEnv) == "" && interactive() {
+ live = tui.StartLive([]string{"serve"}, [][2]string{
+ {"Project", p.Root},
+ {"Memory", h.Listener.MCPURL()},
+ {"Sync", tui.Dot(tui.Good, "drain running")},
+ {"Hooks", tui.Dot(tui.Good, "delivered here")},
+ })
+ }
if h.Capturing() {
h.Listener.SetHookSink(func(ctx context.Context, body []byte) error {
var d hooks.Delivery
@@ -188,7 +207,20 @@ func runServe(cmd *cobra.Command, _ []string) error {
return err
}
d.Root = p.Root
- return hooks.Apply(ctx, h.Recorder, h.Listener.Memory, []hooks.Delivery{d})
+ if h.Stale() {
+ return errStaleStore // the hook writes to the new store itself
+ }
+ err := hooks.Apply(ctx, h.Recorder, h.Listener.Memory, []hooks.Delivery{d})
+ if live != nil {
+ live.Event(deliveryEvent(d, err))
+ }
+ if err != nil {
+ return err
+ }
+ if h.Sync != nil && !d.End {
+ h.Sync.Force() // the prompt, call or reply goes to the cloud now, not at the next tick
+ }
+ return nil
})
h.Listener.SetLinker(func(ctx context.Context) error {
_, err := gitlink.Link(ctx, h.Store, p.Root, time.Now)
@@ -209,44 +241,137 @@ func runServe(cmd *cobra.Command, _ []string) error {
logImport(cmd.ErrOrStderr(), res, err, false)
})
}
- if os.Getenv(daemonEnv) == "" {
+ if os.Getenv(daemonEnv) == "" && live == nil {
fmt.Fprintf(out, "Serving %s: memory on %s, sync drain running, agent hooks delivered here. Ctrl-C to stop.\n", p.Root, h.Listener.MCPURL())
if im != nil {
fmt.Fprintf(out, "Importing sessions from the agents' own UIs every %s.\n", every)
}
}
+ // The memory index follows the record and the sessions, as the app's
+ // indexer does: now, then every 15 minutes (never downloading).
+ go func() {
+ t := time.NewTicker(15 * time.Minute)
+ defer t.Stop()
+ for {
+ if project.MemorySharing(p.ScopeRoot) {
+ _, _ = indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {})
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+ }()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
- select {
- case <-sig:
- case <-ctx.Done():
+ // A store deleted or re-created under the listener: stop rather than
+ // write into a file no one can see. Hooks then write directly, and the
+ // next one restarts a listener when background sync is on.
+ stale := make(chan struct{})
+ go func() {
+ t := time.NewTicker(2 * time.Second)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ if h.Stale() {
+ fmt.Fprintln(cmd.ErrOrStderr(), "atlas: this Project's .atlas was replaced; stopping the listener")
+ close(stale)
+ return
+ }
+ }
+ }
+ }()
+ if live != nil {
+ ended := make(chan struct{})
+ go func() { _ = live.Run(); close(ended) }()
+ select {
+ case <-ended: // q, esc or ctrl+c on the screen
+ case <-sig:
+ live.Stop()
+ <-ended
+ case <-stale:
+ live.Stop()
+ <-ended
+ case <-ctx.Done():
+ live.Stop()
+ <-ended
+ }
+ } else {
+ select {
+ case <-sig:
+ case <-stale:
+ case <-ctx.Done():
+ }
}
if im != nil {
im.Close()
}
res, err := h.Close()
if res.Checkpoints > 0 && os.Getenv(daemonEnv) == "" {
- fmt.Fprintf(out, "Linked %d session commit(s).\n", res.Checkpoints)
+ tui.Done(out, fmt.Sprintf("Linked %d session commit(s).", res.Checkpoints))
+ }
+ if live != nil && err == nil {
+ tui.Done(out, "Listener stopped.")
}
return err
}
+// errStaleStore refuses a hook delivery to a listener whose store was
+// replaced; the hook falls back to writing the store itself.
+var errStaleStore = errors.New("the listener's store was replaced")
+
+// deliveryEvent is a hook delivery as one line of serve's live feed.
+func deliveryEvent(d hooks.Delivery, err error) tui.LiveEvent {
+ e := tui.LiveEvent{At: time.Now(), Who: d.Session.Agent, Bad: err != nil}
+ switch {
+ case err != nil:
+ e.What = "failed: " + err.Error()
+ case d.Start:
+ e.What = "session started"
+ case d.Open != nil:
+ e.What = "prompt " + tui.Default.Faint.Render(strings.Join(strings.Fields(d.Open.Prompt), " "))
+ case d.Tool != nil:
+ e.What = "tool " + tui.Default.Faint.Render(d.Tool.Name+" "+strings.Join(d.Changed, ", "))
+ case d.Turn != nil:
+ e.What = "reply " + tui.Default.Faint.Render("turn closed")
+ case d.End:
+ e.What = "session ended"
+ default:
+ e.What = "event"
+ }
+ if d.LinkFrom != "" && err == nil {
+ e.What += tui.Default.Success.Render(" ✓ commit linked")
+ }
+ return e
+}
+
// startDaemon re-runs `atlas serve` detached in its own session and waits for
// its run file.
func startDaemon(cmd *cobra.Command, root string) error {
- if rf := listener.ReadRunFile(root); rf != nil && alive(rf.PID) {
- return fmt.Errorf("a listener is already running for this Project (pid %d)", rf.PID)
+ every, _ := cmd.Flags().GetDuration("import-every")
+ pid, port, err := launchListener(root, every)
+ if err != nil {
+ return err
}
+ fmt.Fprintf(cmd.OutOrStdout(), "Listener started (pid %d) on 127.0.0.1:%d. Stop it with atlas sync off.\n", pid, port)
+ return nil
+}
+
+// spawnListener starts the detached listener without waiting for it.
+func spawnListener(root string, every time.Duration) (*exec.Cmd, error) {
self, err := os.Executable()
if err != nil {
- return err
+ return nil, err
}
logf, err := os.OpenFile(serveLog(root), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
- return err
+ return nil, err
}
defer logf.Close()
- every, _ := cmd.Flags().GetDuration("import-every")
args := []string{"serve"}
if every > 0 {
args = append(args, "--import-every", every.String())
@@ -257,46 +382,69 @@ func startDaemon(cmd *cobra.Command, root string) error {
child.Stdout, child.Stderr = logf, logf
child.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := child.Start(); err != nil {
- return err
+ return nil, err
+ }
+ return child, nil
+}
+
+// launchListener starts the detached listener and waits for its run file.
+func launchListener(root string, every time.Duration) (pid, port int, err error) {
+ if rf := listener.ReadRunFile(root); rf != nil && alive(rf.PID) {
+ return 0, 0, fmt.Errorf("a listener is already running for this Project (pid %d)", rf.PID)
}
- pid := child.Process.Pid
+ child, err := spawnListener(root, every)
+ if err != nil {
+ return 0, 0, err
+ }
+ pid = child.Process.Pid
_ = child.Process.Release()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if rf := listener.ReadRunFile(root); rf != nil && rf.PID == pid {
- fmt.Fprintf(cmd.OutOrStdout(), "Listener started (pid %d) on 127.0.0.1:%d. Stop it with atlas serve --stop.\n", pid, rf.Port)
- return nil
+ return pid, rf.Port, nil
}
if !alive(pid) {
break
}
time.Sleep(100 * time.Millisecond)
}
- return fmt.Errorf("the listener did not start; see %s", serveLog(root))
+ return 0, 0, fmt.Errorf("the listener did not start; see %s", serveLog(root))
}
// stopDaemon signals the listener named in the run file and waits for it.
func stopDaemon(out interface{ Write([]byte) (int, error) }, root string) error {
- rf := listener.ReadRunFile(root)
- if rf == nil || !alive(rf.PID) {
+ pid, err := haltListener(root)
+ if err != nil {
+ return err
+ }
+ if pid == 0 {
fmt.Fprintln(out, "No listener is running for this Project.")
return nil
}
+ fmt.Fprintf(out, "Stopped the listener (pid %d).\n", pid)
+ return nil
+}
+
+// haltListener stops the detached listener; pid 0 means none ran.
+func haltListener(root string) (int, error) {
+ rf := listener.ReadRunFile(root)
+ if rf == nil || !alive(rf.PID) {
+ return 0, nil
+ }
if !rf.Daemon {
- return fmt.Errorf("the listener (pid %d) belongs to atlas run or a foreground atlas serve; exit that instead", rf.PID)
+ return 0, fmt.Errorf("the listener (pid %d) belongs to atlas run or a foreground atlas serve; exit that instead", rf.PID)
}
if err := syscall.Kill(rf.PID, syscall.SIGTERM); err != nil {
- return err
+ return 0, err
}
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if !alive(rf.PID) {
- fmt.Fprintf(out, "Stopped the listener (pid %d).\n", rf.PID)
- return nil
+ return rf.PID, nil
}
time.Sleep(100 * time.Millisecond)
}
- return fmt.Errorf("the listener (pid %d) did not stop", rf.PID)
+ return 0, fmt.Errorf("the listener (pid %d) did not stop", rf.PID)
}
func alive(pid int) bool {
diff --git a/cmd/atlas/settings_cmds.go b/cmd/atlas/settings_cmds.go
new file mode 100644
index 0000000..15fb2e6
--- /dev/null
+++ b/cmd/atlas/settings_cmds.go
@@ -0,0 +1,399 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/spf13/cobra"
+
+ "github.com/tryatlas/atlas-cli/internal/auth"
+ "github.com/tryatlas/atlas-cli/internal/capture/store"
+ "github.com/tryatlas/atlas-cli/internal/clistate"
+ "github.com/tryatlas/atlas-cli/internal/embed"
+ "github.com/tryatlas/atlas-cli/internal/hooks"
+ "github.com/tryatlas/atlas-cli/internal/hooks/mcpconfig"
+ "github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui/settings"
+)
+
+// atlas settings: account, Project gates, agents and background sync on
+// one screen. Without a terminal it prints the same rows.
+func newSettingsCmd() *cobra.Command {
+ return withRun(leaf("settings", "Change this Project's capture, shared memory, agents and background sync on one screen", cobra.NoArgs), runSettings)
+}
+
+func runSettings(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ cwd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ be := &settingsBackend{cwd: cwd}
+ title := cwd
+ if p, err := project.Discover(ctx, cwd); err == nil {
+ title = p.Root
+ }
+ if !interactive() {
+ secs, err := be.Load(ctx)
+ if err != nil {
+ return err
+ }
+ printSettings(cmd.OutOrStdout(), secs)
+ return nil
+ }
+ _, err = tea.NewProgram(settings.New(ctx, be, title), tea.WithContext(ctx)).Run()
+ return err
+}
+
+// printSettings is the screen as text, for pipes and scripts.
+func printSettings(w io.Writer, secs []settings.Section) {
+ for i, s := range secs {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+ fmt.Fprintln(w, s.Title)
+ for _, it := range s.Items {
+ v := it.Value
+ switch it.Kind {
+ case settings.Toggle:
+ st := "off"
+ if it.On {
+ st = "on"
+ }
+ v = strings.TrimSpace(st + " " + it.Value)
+ case settings.Action:
+ continue
+ }
+ fmt.Fprintf(w, " %-22s %s\n", it.Label, v)
+ }
+ }
+}
+
+// settingsBackend reads the state the commands read, and changes it through
+// the commands themselves, so the screen and the CLI cannot disagree.
+type settingsBackend struct{ cwd string }
+
+func (b *settingsBackend) project(ctx context.Context) *project.Project {
+ p, err := project.Discover(ctx, b.cwd)
+ if err != nil {
+ return nil
+ }
+ return p
+}
+
+const needInit = "Run atlas init in this folder first."
+
+// Load builds the sections.
+func (b *settingsBackend) Load(ctx context.Context) ([]settings.Section, error) {
+ p := b.project(ctx)
+ secs := []settings.Section{b.account(), b.projectSection(ctx, p), b.agents(p), b.sync(ctx, p), b.model(p), b.about(p)}
+ return secs, nil
+}
+
+func (b *settingsBackend) account() settings.Section {
+ sec := settings.Section{Title: "Account", Icon: "◉"}
+ a, err := newAuth()
+ var s *auth.Session
+ if err == nil {
+ s, _ = a.Session()
+ }
+ if s == nil {
+ sec.Items = []settings.Item{{Label: "Signed in as", Value: "not signed in",
+ Detail: "Run atlas login to sync sessions and memory to a cloud Project."}}
+ return sec
+ }
+ who := "signed in"
+ if s.Identity != nil {
+ who = fmt.Sprintf("%s <%s>", s.Identity.Name, s.Identity.Email)
+ }
+ org := "none chosen"
+ if id := s.ActiveOrgID(); id != "" {
+ org = orgLabel(s, id)
+ }
+ n := 0
+ if s.Identity != nil && s.Identity.Orgs != nil {
+ n = len(*s.Identity.Orgs)
+ }
+ sec.Items = []settings.Item{
+ {Label: "Signed in as", Value: who, Detail: "atlas logout signs out and deletes the local session file."},
+ {Label: "Active organisation", Value: org, Detail: "atlas org use changes it; atlas org list shows them."},
+ {Label: "Organisations", Value: fmt.Sprint(n)},
+ {Label: "Session file", Value: a.Store.Path(), Detail: "The only secret Atlas keeps on disk (mode 0600)."},
+ }
+ return sec
+}
+
+func (b *settingsBackend) projectSection(ctx context.Context, p *project.Project) settings.Section {
+ sec := settings.Section{Title: "Project", Icon: "▣"}
+ var dir string
+ if p == nil {
+ dir, _ = project.Canonical(b.cwd)
+ sec.Items = append(sec.Items, settings.Item{Label: "Folder", Value: dir + " (not initialised)", Detail: needInit})
+ } else {
+ dir = p.Root
+ sec.Items = append(sec.Items, settings.Item{Label: "Folder", Value: p.Root})
+ }
+ var bind *store.Binding
+ if p != nil {
+ if st, err := store.Open(p.Root, false); err == nil {
+ bind, _ = st.Binding(ctx)
+ _ = st.Close()
+ }
+ var sess *auth.Session
+ if a, err := newAuth(); err == nil {
+ sess, _ = a.Session()
+ }
+ if bind != nil {
+ sec.Items = append(sec.Items, settings.Item{Label: "Bound to", Value: describeBinding(bind, sess),
+ Detail: "atlas init binds the folder again (another organisation or Project)."})
+ }
+ if p.ScopeRoot != p.Root {
+ sec.Items = append(sec.Items, settings.Item{Label: "Memory lives at", Value: p.ScopeRoot})
+ }
+ }
+ capture := settings.Item{Key: "capture", Label: "Session capture", Kind: settings.Toggle, On: bind != nil && bind.Enabled,
+ Detail: "Record prompts, replies, tool calls and file changes of every agent session here. Pausing keeps what was recorded and still syncs it."}
+ if bind == nil {
+ capture.Disabled = needInit
+ }
+ sec.Items = append(sec.Items, capture,
+ settings.Item{Key: "memory", Label: "Shared memory", Kind: settings.Toggle, On: project.MemorySharing(dir),
+ Detail: "Agents read and write what every session learned in this repository, through the atlas_memory MCP server. Nothing is injected into prompts."})
+ return sec
+}
+
+func (b *settingsBackend) agents(p *project.Project) settings.Section {
+ sec := settings.Section{Title: "Agents", Icon: "◆"}
+ for _, a := range hooks.All() {
+ it := settings.Item{Key: "agent:" + a.ID(), Label: a.Name(), Kind: settings.Toggle}
+ var notes []string
+ if _, err := exec.LookPath(a.Binary()); err == nil {
+ notes = append(notes, a.Binary()+" is on PATH.")
+ } else {
+ notes = append(notes, a.Binary()+" is not on PATH here.")
+ }
+ if p == nil {
+ it.Disabled = needInit
+ } else {
+ switch a.State(p.Root) {
+ case hooks.Current:
+ it.On = true
+ case hooks.Outdated:
+ it.On, it.Value = true, "hook outdated: toggle off and on"
+ default:
+ if !a.Detect(p.Root) {
+ it.Value = "not detected"
+ }
+ }
+ if mw, ok := mcpconfig.For(a.ID()); ok && it.On {
+ if mw.State(p.Root) == hooks.Current {
+ notes = append(notes, "Memory server entry in "+strings.TrimPrefix(mw.Path(p.Root), p.Root+"/")+".")
+ } else if it.Value == "" {
+ it.Value = "no memory entry"
+ }
+ }
+ }
+ if n, ok := a.(hooks.Noter); ok {
+ notes = append(notes, n.AddNote())
+ }
+ it.Detail = "Capture " + a.Name() + " sessions run in its own UI, and give it shared memory. " + strings.Join(notes, " ")
+ sec.Items = append(sec.Items, it)
+ }
+ all := settings.Item{Key: "agents:all", Label: "Add all detected agents", Kind: settings.Action,
+ Detail: "Hook every agent found on this machine or configured in this folder."}
+ if p == nil {
+ all.Disabled = needInit
+ }
+ sec.Items = append(sec.Items, all)
+ return sec
+}
+
+func (b *settingsBackend) sync(ctx context.Context, p *project.Project) settings.Section {
+ sec := settings.Section{Title: "Sync", Icon: "⇅"}
+ if p == nil {
+ sec.Items = []settings.Item{{Key: "sync", Label: "Background sync", Kind: settings.Toggle, Disabled: needInit}}
+ return sec
+ }
+ on, line := syncState(p.Root)
+ sec.Items = append(sec.Items, settings.Item{Key: "sync", Label: "Background sync", Kind: settings.Toggle, On: on, Value: line,
+ Detail: "Keeps atlas serve running for this Project: hooks hand it each prompt, tool call and reply, and it sends them to the cloud at once. Your agents' hooks restart it after a reboot. Same as atlas sync on|off."})
+ if st, err := store.Open(p.Root, false); err == nil {
+ if c, err := st.OutboxCounts(ctx); err == nil {
+ sec.Items = append(sec.Items, settings.Item{Label: "Outbox", Value: fmt.Sprintf("%d pending, %d failed", c[store.SyncPending], c[store.SyncFailed])})
+ }
+ _ = st.Close()
+ }
+ sec.Items = append(sec.Items,
+ settings.Item{Key: "sync:now", Label: "Sync now", Kind: settings.Action, Detail: "Send what is pending to the cloud Project now (atlas capture sync)."},
+ settings.Item{Key: "sync:retry", Label: "Retry failed", Kind: settings.Action, Detail: "Re-queue rows that failed to sync, then sync (atlas capture retry)."},
+ )
+ if e := strings.TrimSpace(hooks.LastError(p.Root)); e != "" {
+ sec.Items = append(sec.Items, settings.Item{Label: "Last hook error", Value: e, Detail: e})
+ }
+ return sec
+}
+
+// model is the local MiniLM model memory search runs on.
+func (b *settingsBackend) model(p *project.Project) settings.Section {
+ sec := settings.Section{Title: "Model"}
+ have := embed.Available()
+ state, search := "not downloaded", "keywords only"
+ if have {
+ state = fmt.Sprintf("downloaded · %.0f MB", float64(embed.DiskSize())/1e6)
+ search = "meaning and keywords"
+ }
+ sec.Items = []settings.Item{
+ {Label: "Memory model", Value: embed.ModelID + " · " + state,
+ Detail: "A small local model (MiniLM, 384 dimensions) that turns memories and documents into vectors, so memory_search finds them by meaning. It runs on this machine; nothing is sent anywhere."},
+ {Label: "Memory search", Value: search},
+ {Label: "Location", Value: embed.ModelDir(), Detail: "The Atlas app keeps its models here too: one download serves both."},
+ }
+ dl := settings.Item{Key: "model:download", Label: "Download model", Kind: settings.Action, Value: embed.Size,
+ Detail: "Download " + embed.Repo + " (" + embed.Size + ", from Hugging Face, once), then embed this Project's memories and index its documents."}
+ if have {
+ dl.Disabled = "The model is already downloaded."
+ }
+ sec.Items = append(sec.Items, dl)
+ idx := settings.Item{Key: "memory:index", Label: "Build memory index", Kind: settings.Action,
+ Detail: "Embed every memory and index the project's documents now (atlas memory index). Hooks and atlas serve also keep it current."}
+ if p == nil {
+ idx.Disabled = needInit
+ }
+ sec.Items = append(sec.Items, idx)
+ if have {
+ sec.Items = append(sec.Items, settings.Item{Key: "model:remove", Label: "Remove model", Kind: settings.Action,
+ Confirm: "This deletes the model files the Atlas app also uses.",
+ Detail: "Delete the model files to free " + embed.Size + ". Memory search falls back to keywords. The Atlas app uses the same files and would download them again."})
+ }
+ return sec
+}
+
+func (b *settingsBackend) about(p *project.Project) settings.Section {
+ sec := settings.Section{Title: "About", Icon: "◇", Items: []settings.Item{
+ {Label: "Version", Value: version},
+ {Key: "update:check", Label: "Check for updates", Kind: settings.Action, Detail: "Ask GitHub for the latest release (atlas update --check)."},
+ {Key: "update", Label: "Update atlas", Kind: settings.Action,
+ Detail: "Download the latest release for this machine, verify it against the release's checksums.txt, and replace this binary (atlas update). A background listener is restarted on the new version."},
+ }}
+ if p != nil {
+ sec.Items = append(sec.Items,
+ settings.Item{Label: "CLI state", Value: clistate.Dir(p.Root), Detail: "Hook state, logs and the listener's run file; .atlas/ holds only what the app writes."},
+ settings.Item{Label: "Hooks log", Value: hooks.LogPath(p.Root)},
+ )
+ }
+ return sec
+}
+
+// ApplyProgress runs the long actions with a progress report: the model
+// download and the index; everything else is Apply.
+func (b *settingsBackend) ApplyProgress(ctx context.Context, key string, on bool, report func(float64, string)) (string, error) {
+ p := b.project(ctx)
+ switch key {
+ case "model:download":
+ if err := embed.Download(ctx, func(done, total int64) {
+ if total > 0 {
+ report(0.9*float64(done)/float64(total), fmt.Sprintf("%.0f of %.0f MB", float64(done)/1e6, float64(total)/1e6))
+ }
+ }); err != nil {
+ return "", fmt.Errorf("download the model: %w", err)
+ }
+ msg := "Model downloaded: memory search now uses meaning and keywords."
+ if p != nil {
+ report(0.95, "embedding memories")
+ if m, err := indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {}); err == nil {
+ msg += " " + m
+ }
+ }
+ report(1, "")
+ return msg, nil
+ case "memory:index":
+ if p == nil {
+ return "", errNoProject
+ }
+ return indexMemory(ctx, p.Root, p.ScopeRoot, true, func(s string) { report(-1, s) }, func(f float64) { report(f, "") })
+ }
+ return b.Apply(ctx, key, on)
+}
+
+// Apply runs the command behind a row and returns its last line.
+func (b *settingsBackend) Apply(ctx context.Context, key string, on bool) (string, error) {
+ p := b.project(ctx)
+ switch {
+ case key == "model:remove":
+ if err := embed.Remove(); err != nil {
+ return "", err
+ }
+ return "Model removed: memory search uses keywords until it is downloaded again.", nil
+ case key == "update:check":
+ return applyUpdate(ctx, func(string, string) {}, "", true)
+ case key == "update":
+ return applyUpdate(ctx, func(string, string) {}, "", false)
+ case key == "capture":
+ return quietly(ctx, runCaptureGate(on))
+ case key == "memory":
+ return quietly(ctx, runMemoryGate(on))
+ case p == nil:
+ return "", errNoProject
+ case key == "sync":
+ return setBackgroundSync(p.Root, on)
+ case key == "memory:index":
+ return indexMemory(ctx, p.Root, p.ScopeRoot, true, func(string) {}, func(float64) {})
+ case key == "sync:now":
+ return quietly(ctx, runCaptureSync(false))
+ case key == "sync:retry":
+ return quietly(ctx, runCaptureSync(true))
+ case key == "agents:all":
+ var detected []hooks.Agent
+ for _, a := range hooks.All() {
+ if a.Detect(p.Root) {
+ detected = append(detected, a)
+ }
+ }
+ if len(detected) == 0 {
+ return "No supported agent was found on this machine.", nil
+ }
+ return quietly(ctx, func(c *cobra.Command, _ []string) error {
+ if err := installAgents(ctx, c.OutOrStdout(), p.Root, detected, false, false); err != nil {
+ return err
+ }
+ fmt.Fprintf(c.OutOrStdout(), "Hooked %d agent(s).\n", len(detected))
+ return nil
+ })
+ case strings.HasPrefix(key, "agent:"):
+ a, ok := hooks.Get(strings.TrimPrefix(key, "agent:"))
+ if !ok {
+ return "", fmt.Errorf("unknown agent %s", key)
+ }
+ return quietly(ctx, func(c *cobra.Command, _ []string) error {
+ if on {
+ return installAgents(ctx, c.OutOrStdout(), p.Root, []hooks.Agent{a}, true, false)
+ }
+ return removeAgents(ctx, c.OutOrStdout(), p.Root, []hooks.Agent{a})
+ })
+ }
+ return "", fmt.Errorf("unknown setting %s", key)
+}
+
+// quietly runs a command's body with its output captured, and returns the
+// most telling line: the first one that is not a note.
+func quietly(ctx context.Context, run func(*cobra.Command, []string) error) (string, error) {
+ var buf bytes.Buffer
+ c := &cobra.Command{}
+ c.SetOut(&buf)
+ c.SetErr(&buf)
+ c.SetContext(ctx)
+ err := run(c, nil)
+ var last string
+ for _, l := range strings.Split(buf.String(), "\n") {
+ if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "note:") {
+ last = l
+ }
+ }
+ return last, err
+}
diff --git a/cmd/atlas/settings_cmds_test.go b/cmd/atlas/settings_cmds_test.go
new file mode 100644
index 0000000..176645e
--- /dev/null
+++ b/cmd/atlas/settings_cmds_test.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/tryatlas/atlas-cli/internal/clistate"
+ "github.com/tryatlas/atlas-cli/internal/hooks"
+)
+
+func TestInitHooksTheAgentsGiven(t *testing.T) {
+ root := gitRepo(t)
+ code, out, e := runCmd(t, "init", "--local", "--agents", "claude-code,codex")
+ if code != 0 {
+ t.Fatalf("exit %d: %s", code, e)
+ }
+ for _, id := range []string{"claude-code", "codex"} {
+ a, _ := hooks.Get(id)
+ if a.State(root) != hooks.Current {
+ t.Fatalf("%s not hooked:\n%s", id, out)
+ }
+ }
+ if a, _ := hooks.Get("cursor"); a.State(root) != hooks.Absent {
+ t.Fatal("cursor was not asked for")
+ }
+ if _, err := os.Stat(filepath.Join(root, ".mcp.json")); err != nil {
+ t.Fatal("memory entry missing")
+ }
+ if code, _, e := runCmd(t, "init", "--local", "--yes", "--agents", "nope"); code != 1 || !strings.Contains(e, "nope is not a supported agent") {
+ t.Fatalf("exit %d: %s", code, e)
+ }
+}
+
+func TestInitWithoutAgentFlagsHooksNothingWhenNotInteractive(t *testing.T) {
+ root := gitRepo(t)
+ if code, _, e := runCmd(t, "init", "--local"); code != 0 {
+ t.Fatal(e)
+ }
+ for _, a := range hooks.All() {
+ if a.State(root) != hooks.Absent {
+ t.Fatalf("%s hooked without being asked", a.ID())
+ }
+ }
+}
+
+func TestSyncOffAndStatus(t *testing.T) {
+ root := gitRepo(t)
+ if code, out, e := runCmd(t, "sync"); code != 1 || !strings.Contains(e+out, "init") {
+ t.Fatalf("exit %d: %s %s", code, out, e)
+ }
+ _, _, _ = runCmd(t, "init", "--local")
+ code, out, e := runCmd(t, "sync")
+ if code != 0 || !strings.Contains(out, "background sync off") {
+ t.Fatalf("exit %d: %s %s", code, out, e)
+ }
+ _ = clistate.SavePrefs(root, clistate.Prefs{BackgroundSync: true})
+ if _, out, _ := runCmd(t, "sync", "status"); !strings.Contains(out, "on (not running now") {
+ t.Fatal(out)
+ }
+ if code, out, _ := runCmd(t, "sync", "off"); code != 0 || !strings.Contains(out, "Background sync is off.") {
+ t.Fatal(out)
+ }
+ if clistate.LoadPrefs(root).BackgroundSync {
+ t.Fatal("off must be remembered")
+ }
+ if code, _, _ := runCmd(t, "sync", "maybe"); code != 1 {
+ t.Fatal("only on, off and status")
+ }
+}
+
+func TestSettingsPrintsWithoutATerminal(t *testing.T) {
+ gitRepo(t)
+ _, _, _ = runCmd(t, "init", "--local", "--agents", "pi")
+ code, out, e := runCmd(t, "settings")
+ if code != 0 {
+ t.Fatalf("exit %d: %s", code, e)
+ }
+ for _, want := range []string{"Account", "Project", "Session capture", "on", "Shared memory", "Agents", "Pi ", "Sync", "Background sync", "About", "Version"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing %q:\n%s", want, out)
+ }
+ }
+}
+
+func TestSettingsBackendAppliesThroughTheCommands(t *testing.T) {
+ root := gitRepo(t)
+ _, _, _ = runCmd(t, "init", "--local")
+ be := &settingsBackend{cwd: root}
+ ctx := t.Context()
+ if msg, err := be.Apply(ctx, "agent:opencode", true); err != nil || !strings.Contains(msg, "OpenCode") {
+ t.Fatalf("%q %v", msg, err)
+ }
+ if a, _ := hooks.Get("opencode"); a.State(root) != hooks.Current {
+ t.Fatal("not installed")
+ }
+ if msg, err := be.Apply(ctx, "agent:opencode", false); err != nil || !strings.Contains(msg, "removed") {
+ t.Fatalf("%q %v", msg, err)
+ }
+ if msg, err := be.Apply(ctx, "capture", false); err != nil || !strings.Contains(msg, "paused") {
+ t.Fatalf("%q %v", msg, err)
+ }
+ if msg, err := be.Apply(ctx, "memory", false); err != nil || !strings.Contains(msg, "off") {
+ t.Fatalf("%q %v", msg, err)
+ }
+ secs, _ := be.Load(ctx)
+ for _, s := range secs {
+ for _, it := range s.Items {
+ if (it.Key == "capture" || it.Key == "memory") && it.On {
+ t.Fatalf("%s still on", it.Key)
+ }
+ }
+ }
+}
+
+func TestInitCreatesTheMemoryRecord(t *testing.T) {
+ root := gitRepo(t)
+ if code, _, e := runCmd(t, "init", "--local"); code != 0 {
+ t.Fatal(e)
+ }
+ if _, err := os.Stat(filepath.Join(root, ".atlas", "memory", "memory.sqlite")); err != nil {
+ t.Fatalf("no memory record after init: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, ".atlas", ".gitignore")); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/cmd/atlas/status_view.go b/cmd/atlas/status_view.go
new file mode 100644
index 0000000..3356e73
--- /dev/null
+++ b/cmd/atlas/status_view.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/tryatlas/atlas-cli/internal/tui"
+)
+
+// statusGroups puts atlas status's lines under the card's sections.
+var statusGroups = []struct {
+ title string
+ labels []string
+}{
+ {"Account", []string{"Account", "Org"}},
+ {"Project", []string{"Project", "Binding", "Memory at", "Memory", "Capture", "Recorder"}},
+ {"Capture and sync", []string{"Sessions", "Commits", "Sync", "Record"}},
+ {"Agents", []string{"Listener", "Agents", "Git hook", "Hooks", "Hook err"}},
+}
+
+// statusOf reads a value's first words for its dot.
+func statusOf(label, value string) tui.State {
+ v := strings.ToLower(value)
+ for _, zero := range []string{"0 failed", "0 pending", "0 need attention"} {
+ v = strings.ReplaceAll(v, " "+zero, "")
+ }
+ has := func(words ...string) bool {
+ for _, w := range words {
+ if strings.Contains(v, w) {
+ return true
+ }
+ }
+ return false
+ }
+ switch {
+ case label == "Hook err" || has("failed", "broken", "refused", "unreadable", "error"):
+ return tui.Bad
+ case has("need attention", "outdated", "spooled", "not installed", "left open", "pending"):
+ return tui.Warn
+ case strings.HasPrefix(v, "off") || strings.HasPrefix(v, "paused") || strings.HasPrefix(v, "none") || strings.HasPrefix(v, "not ") || has("not signed in", "not initialised", "sync off"):
+ return tui.Idle
+ case strings.HasPrefix(v, "on") || strings.HasPrefix(v, "ok") || strings.HasPrefix(v, "idle") || has("intact", "linked up to", "sync on"):
+ return tui.Good
+ }
+ return tui.Neutral
+}
+
+// renderStatus draws status lines as a card: the header, then a section per
+// group, each value with its dot.
+func renderStatus(w io.Writer, lines string) {
+ values := map[string]string{}
+ var order []string
+ for _, l := range strings.Split(strings.TrimRight(lines, "\n"), "\n") {
+ label, value, ok := strings.Cut(l, ":")
+ if !ok || strings.HasPrefix(l, " ") {
+ if len(order) > 0 { // a continuation of the previous value
+ values[order[len(order)-1]] += " " + strings.TrimSpace(l)
+ }
+ continue
+ }
+ label = strings.TrimSpace(label)
+ values[label] = strings.TrimSpace(value)
+ order = append(order, label)
+ }
+ tui.Header(w, "status")
+ seen := map[string]bool{}
+ for _, g := range statusGroups {
+ var has bool
+ for _, l := range g.labels {
+ if _, ok := values[l]; ok {
+ has = true
+ }
+ }
+ if !has {
+ continue
+ }
+ tui.Section(w, g.title)
+ for _, l := range g.labels {
+ v, ok := values[l]
+ if !ok {
+ continue
+ }
+ seen[l] = true
+ tui.Field(w, statusLabel(l), tui.Dot(statusOf(l, v), v))
+ }
+ }
+ for _, l := range order {
+ if !seen[l] {
+ tui.Field(w, statusLabel(l), values[l])
+ }
+ }
+ fmt.Fprintln(w)
+}
+
+func statusLabel(l string) string {
+ switch l {
+ case "Org":
+ return "Organisation"
+ case "Hook err":
+ return "Last hook error"
+ }
+ return l
+}
diff --git a/cmd/atlas/status_view_test.go b/cmd/atlas/status_view_test.go
new file mode 100644
index 0000000..8b042ac
--- /dev/null
+++ b/cmd/atlas/status_view_test.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/tryatlas/atlas-cli/internal/tui"
+)
+
+func TestStatusDots(t *testing.T) {
+ for _, c := range []struct {
+ label, value string
+ want tui.State
+ }{
+ {"Sync", "ok · 1183 sent, 0 pending, 0 failed", tui.Good},
+ {"Sync", "ok · 10 sent, 3 pending, 0 failed", tui.Warn},
+ {"Sync", "offline · 2 failed", tui.Bad},
+ {"Listener", "background sync off (sessions sync as each hook fires)", tui.Idle},
+ {"Listener", "background sync on (listener pid 1)", tui.Good},
+ {"Capture", "paused", tui.Idle},
+ {"Sessions", "3 session(s), 1 turn(s) left open", tui.Warn},
+ {"Commits", "linked up to eaa9ab0", tui.Good},
+ {"Project", "/Users/x/repo", tui.Neutral},
+ } {
+ if got := statusOf(c.label, c.value); got != c.want {
+ t.Errorf("%s %q = %v, want %v", c.label, c.value, got, c.want)
+ }
+ }
+}
diff --git a/cmd/atlas/sync_cmds.go b/cmd/atlas/sync_cmds.go
new file mode 100644
index 0000000..ad2829f
--- /dev/null
+++ b/cmd/atlas/sync_cmds.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/tryatlas/atlas-cli/internal/clistate"
+ "github.com/tryatlas/atlas-cli/internal/listener"
+ "github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui"
+)
+
+// atlas sync [on|off]: background sync is a running atlas serve for the
+// Project. on starts it and keeps it wanted, so an agent's next hook
+// restarts it after a reboot; off stops it and forgets it.
+func newSyncCmd() *cobra.Command {
+ return withRun(&cobra.Command{
+ Use: "sync [on|off]",
+ Short: "Turn background sync on or off for this Project (a detached atlas serve), or show it",
+ Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs),
+ ValidArgs: []string{"on", "off", "status"},
+ }, runSync)
+}
+
+func runSync(cmd *cobra.Command, args []string) error {
+ out := cmd.OutOrStdout()
+ cwd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ p, err := project.Discover(cmd.Context(), cwd)
+ if errors.Is(err, project.ErrNotInitialised) {
+ return errNoProject
+ }
+ if err != nil {
+ return err
+ }
+ switch {
+ case len(args) == 0 || args[0] == "status":
+ if interactive() {
+ on, note := syncState(p.Root)
+ tui.Header(out, "sync")
+ tui.Section(out, "Background sync")
+ if on {
+ tui.Field(out, "State", tui.Dot(tui.Good, "on"))
+ } else {
+ tui.Field(out, "State", tui.Dot(tui.Idle, "off"))
+ }
+ tui.Field(out, "Listener", note)
+ fmt.Fprintln(out)
+ tui.Note(out, "atlas sync on · atlas sync off")
+ return nil
+ }
+ printSync(out, p.Root)
+ return nil
+ case args[0] == "on":
+ return tui.Work(out, "Starting background sync", func(tui.StepFunc) (string, error) {
+ return setBackgroundSync(p.Root, true)
+ })
+ default:
+ return tui.Work(out, "Stopping background sync", func(tui.StepFunc) (string, error) {
+ return setBackgroundSync(p.Root, false)
+ })
+ }
+}
+
+// setBackgroundSync records the choice and starts or stops the listener.
+func setBackgroundSync(root string, on bool) (string, error) {
+ prefs := clistate.LoadPrefs(root)
+ prefs.BackgroundSync = on
+ if err := clistate.SavePrefs(root, prefs); err != nil {
+ return "", err
+ }
+ if !on {
+ pid, err := haltListener(root)
+ if err != nil {
+ return "", err
+ }
+ if pid == 0 {
+ return "Background sync is off.", nil
+ }
+ return fmt.Sprintf("Background sync is off (stopped pid %d).", pid), nil
+ }
+ if rf := listener.ReadRunFile(root); rf != nil {
+ return fmt.Sprintf("Background sync is on (already running, pid %d).", rf.PID), nil
+ }
+ pid, port, err := launchListener(root, 0)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("Background sync is on: listener pid %d on 127.0.0.1:%d.", pid, port), nil
+}
+
+// syncState says whether background sync is on, and a note on the listener.
+func syncState(root string) (on bool, note string) {
+ want := clistate.LoadPrefs(root).BackgroundSync
+ rf := listener.ReadRunFile(root)
+ switch {
+ case rf != nil && rf.Daemon:
+ return true, fmt.Sprintf("listener pid %d on 127.0.0.1:%d since %s", rf.PID, rf.Port, rf.StartedAt)
+ case rf != nil:
+ return want, fmt.Sprintf("a foreground listener runs (pid %d)", rf.PID)
+ case want:
+ return true, "not running now; the next agent hook starts it"
+ }
+ return false, "sessions sync as each hook fires"
+}
+
+func printSync(w io.Writer, root string) {
+ on, note := syncState(root)
+ state := "off"
+ if on {
+ state = "on"
+ }
+ fmt.Fprintf(w, "Listener: background sync %s (%s)\n", state, note)
+}
+
+// listenerRetry spaces the hooks' attempts to restart a wanted listener
+// (the desktop may hold the store, and then serve cannot start).
+const listenerRetry = time.Minute
+
+// ensureListener restarts the background listener from an agent hook when
+// the user turned background sync on and none runs. It never waits.
+func ensureListener(root string) {
+ if !clistate.LoadPrefs(root).BackgroundSync || listener.ReadRunFile(root) != nil {
+ return
+ }
+ mark := clistate.Path(root, "serve.spawn")
+ if fi, err := os.Stat(mark); err == nil && time.Since(fi.ModTime()) < listenerRetry {
+ return
+ }
+ _ = os.WriteFile(mark, nil, 0o600)
+ now := time.Now()
+ _ = os.Chtimes(mark, now, now)
+ if c, err := spawnListener(root, 0); err == nil {
+ _ = c.Process.Release()
+ }
+}
diff --git a/cmd/atlas/update_cmds.go b/cmd/atlas/update_cmds.go
new file mode 100644
index 0000000..769e5d1
--- /dev/null
+++ b/cmd/atlas/update_cmds.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+
+ "github.com/spf13/cobra"
+
+ "github.com/tryatlas/atlas-cli/internal/listener"
+ "github.com/tryatlas/atlas-cli/internal/project"
+ "github.com/tryatlas/atlas-cli/internal/tui"
+ "github.com/tryatlas/atlas-cli/internal/update"
+)
+
+// Seams replaced in tests.
+var (
+ updateSource = update.GitHub
+ updateTarget = update.Executable
+)
+
+// atlas update: replace this binary with the latest release (or --version),
+// verified against the release's checksums.txt like the installer does.
+func newUpdateCmd() *cobra.Command {
+ c := withRun(leaf("update", "Update atlas to the latest release (verified against the release checksums)", cobra.NoArgs), runUpdate)
+ c.Flags().Bool("check", false, "only say whether a newer release exists")
+ c.Flags().String("version", "", "install this release tag instead of the latest (e.g. v0.3.4)")
+ return c
+}
+
+func runUpdate(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ out := cmd.OutOrStdout()
+ check, _ := cmd.Flags().GetBool("check")
+ want, _ := cmd.Flags().GetString("version")
+ if want != "" && want[0] != 'v' {
+ want = "v" + want
+ }
+ if !interactive() {
+ msg, err := applyUpdate(ctx, func(label, value string) { fmt.Fprintf(out, "%-10s %s\n", label+":", value) }, want, check)
+ if err == nil {
+ fmt.Fprintln(out, msg)
+ }
+ return err
+ }
+ title := "Updating atlas"
+ if check {
+ title = "Checking for a newer atlas"
+ }
+ return tui.Work(out, title, func(step tui.StepFunc) (string, error) {
+ return applyUpdate(ctx, func(label, value string) { step(label + " " + value) }, want, check)
+ })
+}
+
+// applyUpdate does the work and returns the closing line; each step is
+// reported to step as a label and a value.
+func applyUpdate(ctx context.Context, step func(label, value string), want string, check bool) (string, error) {
+ src := updateSource()
+ tag := want
+ if tag == "" {
+ step("Checking", "the latest release on GitHub")
+ latest, err := src.Latest(ctx)
+ if err != nil {
+ return "", fmt.Errorf("could not read the latest release: %w", err)
+ }
+ tag = latest
+ }
+ step("Installed", version)
+ step("Release", tag)
+ if want == "" && !update.Newer(version, tag) {
+ return fmt.Sprintf("atlas %s is up to date.", version), nil
+ }
+ if check {
+ return fmt.Sprintf("atlas %s is available; run atlas update.", tag), nil
+ }
+ exe, err := updateTarget()
+ if err != nil {
+ return "", err
+ }
+ step("Downloading", update.Archive(tag, runtime.GOOS, runtime.GOARCH))
+ bin, err := src.Download(ctx, tag)
+ if err != nil {
+ return "", err
+ }
+ step("Verified", update.Archive(tag, runtime.GOOS, runtime.GOARCH)+" (sha256)")
+ if err := update.Replace(exe, bin); err != nil {
+ return "", fmt.Errorf("%w (reinstall with the installer, or run atlas update with write access to %s)", err, exe)
+ }
+ step("Replaced", exe)
+ if note := restartListener(ctx); note != "" {
+ step("Listener", note)
+ }
+ return fmt.Sprintf("Updated atlas %s → %s.", version, tag), nil
+}
+
+// restartListener puts this Project's background listener on the new
+// binary; a foreground one is left to its owner.
+func restartListener(ctx context.Context) string {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return ""
+ }
+ p, err := project.Discover(ctx, cwd)
+ if err != nil {
+ return ""
+ }
+ rf := listener.ReadRunFile(p.Root)
+ if rf == nil {
+ return ""
+ }
+ if !rf.Daemon {
+ return "a foreground atlas serve still runs the old version; restart it"
+ }
+ if _, err := haltListener(p.Root); err != nil {
+ return "could not stop the old listener: " + err.Error()
+ }
+ pid, _, err := launchListener(p.Root, 0)
+ if err != nil {
+ return "restart failed: " + err.Error()
+ }
+ return fmt.Sprintf("restarted on the new version (pid %d)", pid)
+}
diff --git a/cmd/atlas/update_cmds_test.go b/cmd/atlas/update_cmds_test.go
new file mode 100644
index 0000000..8dfd1f3
--- /dev/null
+++ b/cmd/atlas/update_cmds_test.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/tryatlas/atlas-cli/internal/update"
+)
+
+func fakeRelease(t *testing.T, tag string, bin []byte) {
+ t.Helper()
+ var arch bytes.Buffer
+ zw := gzip.NewWriter(&arch)
+ tw := tar.NewWriter(zw)
+ _ = tw.WriteHeader(&tar.Header{Name: "atlas", Mode: 0o755, Size: int64(len(bin)), Typeflag: tar.TypeReg})
+ _, _ = tw.Write(bin)
+ _ = tw.Close()
+ _ = zw.Close()
+ sum := sha256.Sum256(arch.Bytes())
+ name := update.Archive(tag, runtime.GOOS, runtime.GOARCH)
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintf(w, `{"tag_name":%q}`, tag) })
+ mux.HandleFunc("/dl/", func(w http.ResponseWriter, r *http.Request) {
+ switch filepath.Base(r.URL.Path) {
+ case "checksums.txt":
+ fmt.Fprintf(w, "%s %s\n", hex.EncodeToString(sum[:]), name)
+ case name:
+ _, _ = w.Write(arch.Bytes())
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ old := updateSource
+ updateSource = func() update.Source {
+ return update.Source{API: srv.URL + "/api", Base: srv.URL + "/dl", HTTP: srv.Client()}
+ }
+ t.Cleanup(func() { updateSource = old })
+}
+
+func TestUpdate(t *testing.T) {
+ fakeRelease(t, "v9.0.0", []byte("atlas 9"))
+ exe := filepath.Join(t.TempDir(), "atlas")
+ _ = os.WriteFile(exe, []byte("atlas old"), 0o755)
+ oldTarget, oldVersion := updateTarget, version
+ updateTarget = func() (string, error) { return exe, nil }
+ t.Cleanup(func() { updateTarget, version = oldTarget, oldVersion })
+ version = "0.4.0"
+
+ code, out, e := runCmd(t, "update", "--check")
+ if code != 0 || !strings.Contains(out, "atlas v9.0.0 is available") {
+ t.Fatalf("exit %d: %s %s", code, out, e)
+ }
+ if b, _ := os.ReadFile(exe); string(b) != "atlas old" {
+ t.Fatal("--check must not replace")
+ }
+ code, out, e = runCmd(t, "update")
+ if code != 0 || !strings.Contains(out, "Updated atlas 0.4.0 → v9.0.0.") || !strings.Contains(out, "Verified:") {
+ t.Fatalf("exit %d: %s %s", code, out, e)
+ }
+ if b, _ := os.ReadFile(exe); string(b) != "atlas 9" {
+ t.Fatalf("binary = %q", b)
+ }
+ version = "9.0.0"
+ if code, out, _ := runCmd(t, "update"); code != 0 || !strings.Contains(out, "atlas 9.0.0 is up to date.") {
+ t.Fatal(out)
+ }
+ if code, _, e := runCmd(t, "update", "--version", "8.0.0"); code != 1 || !strings.Contains(e, "atlas_8.0.0_") {
+ t.Fatalf("a missing release must fail: %d %s", code, e)
+ }
+}
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index e47ce3d..c9d1f98 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -55,7 +55,7 @@ Run them before tagging. Record the results in `docs/progress.md`.
| 4 | `atlas agents list` | The eight agents; hook and memory "absent"; binaries on PATH named |
| 5 | `atlas agents add claude-code` | "hook installed, memory server added to .mcp.json"; the git post-commit hook installed; `.claude/settings.local.json` holds six Atlas hooks |
| 6 | `claude -p "Create hello.md with one line"` | The reply; `hello.md` exists; `atlas status` shows 1 session; `atlas agents sessions` lists it with `claude -r ` |
-| 7 | `claude` (its own UI): a prompt that edits a file, then exit | The SessionStart banner "Atlas: capture on · memory on · …"; a second turn in the store with the tool call and the file touch |
+| 7 | `claude` (its own UI): a prompt that edits a file and runs a command, then exit | The SessionStart banner "Atlas: capture on · memory on · …"; while the agent still works, `sqlite3 .atlas/sessions.db "select tool_name from tool_call"` already lists the calls (per-event); after Stop one completed turn with the prompt first, one row per call, the reply last, no `changes-N` call |
| 8 | `atlas memory list` | `file_changed` entries from both turns |
| 9 | Commit the file | The post-commit hook links it: `atlas status` says "Commits: linked up to " without running `atlas capture link` |
| 10 | `git commit --amend -m reworded`, then `atlas capture link` | "Rewritten history: 1 relinked" |
diff --git a/docs/adr/0013-hooks-capture-native-agent-uis.md b/docs/adr/0013-hooks-capture-native-agent-uis.md
index 152a408..0f8ccdf 100644
--- a/docs/adr/0013-hooks-capture-native-agent-uis.md
+++ b/docs/adr/0013-hooks-capture-native-agent-uis.md
@@ -51,7 +51,7 @@ the product.
Commits are linked by the existing walk (`atlas capture link`), now also
run from a `post-commit` git hook. No git refs, no commit trailers.
4. **Hooks never fail the agent.** Every hook exits 0; errors go to
- `.atlas/cli-hooks.log` and the last one shows in `atlas status`. Stdout
+ `hooks.log` in the CLI state directory and the last one shows in `atlas status`. Stdout
carries only the agent's own response shape (the SessionStart banner).
Every hook runs under a deadline. The installed command is wrapped in
`sh -c 'if ! command -v atlas >/dev/null 2>&1; then exit 0; fi; exec atlas
@@ -59,7 +59,7 @@ the product.
5. **Delivery.** A hook writes to the store itself when it can take the
writer lock; when `atlas serve` holds it, the hook posts the turn to the
listener's owner-only Unix socket; when the desktop app holds it, the turn
- is spooled under `.atlas/hooks/spool/` and drained by the next writer.
+ is spooled in the CLI state directory (`hooks/spool/`) and drained by the next writer.
6. **Shared memory reaches native UIs through MCP config.** `atlas agents
add` also writes the `atlas_memory` server entry into the agent's MCP
config as a stdio entry `atlas mcp-bridge --project --agent `
@@ -75,9 +75,21 @@ the product.
group until their removal is agreed, and are no longer documented as the
product.
+9. **Per event, not per turn** (added 2026-10-12). The prompt opens the turn
+ in the store as it is submitted; each tool call is recorded as the
+ agent's post-tool hook fires (Claude Code, Codex, Copilot, Droid and
+ Antigravity have a generic one; Cursor's file-edit and shell hooks,
+ OpenCode's plugin and Pi's extension carry the same information); the
+ turn closes at Stop with the transcript's text and usage, and transcript
+ tool items that duplicate a delivered call are dropped (same id,
+ arguments, shell command or written paths). Each delivery syncs. Token
+ streaming is out of reach for hooks.
+
## Consequences
-- Native-UI sessions get file touches and checkpoints again, live.
+- Native-UI sessions get file touches and checkpoints again, live: the
+ prompt and each tool call reach the cloud within seconds; the reply at
+ turn end.
- One integration per agent is the price; the external-agent protocol is
a later addition.
- A hook-captured turn's file changes are attributed to the tool that wrote
diff --git a/docs/assets/banner.png b/docs/assets/banner.png
new file mode 100644
index 0000000..4f289c4
Binary files /dev/null and b/docs/assets/banner.png differ
diff --git a/docs/progress.md b/docs/progress.md
index 30a5787..af57922 100644
--- a/docs/progress.md
+++ b/docs/progress.md
@@ -1183,3 +1183,395 @@ https://github.com/tryatlas/atlas-cli/releases/tag/v0.2.0, four archives,
`checksums.txt`, `install.sh`. CI and the release workflow were green on
all four native runners. The anonymous installer delivers a build with the
hooks commands.
+
+## v0.2.1: a turn reaches the cloud right after its hook
+
+A hook that writes the store directly now drains the new rows to the cloud
+Project within the drain's 3 s budget (signed in, cloud-bound; offline or
+rate-limited rows stay pending for the next drain). A turn delivered to
+`atlas serve` forces a drain instead of waiting for the 30 s tick. Before,
+without `atlas serve` nothing synced until `atlas capture sync`.
+
+## Per-event capture (ADR-0013 §9, 2026-10-12)
+
+The prompt opens the turn in the store as it is submitted; each tool call is
+recorded as the agent's post-tool hook fires (with the file it wrote); Stop
+closes the turn with the transcript's text runs and usage. Transcript tool
+items that duplicate a delivered call are dropped (same id, canonical name +
+arguments hash, shell command text, or every written path), and the files a
+delivered call wrote count as explained, so no synthetic "changes" call is
+made for them. Every delivery syncs: `atlas serve` forces a drain per event;
+a direct write drains within the sync budget (1 s for a post-tool hook, 3 s
+otherwise). A post-tool hook costs 60–180 ms locally.
+
+Verified live with Claude Code: `claude -p` with Write + Bash, directly and
+through `atlas serve`: one completed turn, the prompt row first, one tool
+row per call in order, the reply last, one file touch, no duplicates.
+
+Found by the per-agent live re-tests and fixed in the core:
+
+- Hooks of one session can fire concurrently (Pi runs six tool handlers at
+ once): the dispatcher now holds a per-session flock for the hook's
+ lifetime and writes state through a per-process temp name.
+- Delivered calls are tracked as key groups, each matching at most one
+ transcript item; when an agent's hook ids match its transcript ids (Claude,
+ Pi, OpenCode) ids decide alone, so two identical `sleep 4` calls stay two
+ calls; otherwise arguments (never empty `{}`), command text (string or
+ argv) or every written path decide (Codex, Copilot, Cursor).
+- A shell call's hook diffs git status right then: the files the command
+ changed are its touches, delivered with the call, and the baseline moves
+ on; no synthetic "changes" call for them.
+- A delivery carries the hook's own time, so a spooled one keeps it.
+
+Per-agent per-event results: Claude Code, Codex (code-mode `exec` scripts
+unwrapped to the inner `apply_patch`/`exec_command` so hook and rollout
+match), Copilot (no call ids in its payload; a derived id, matching by
+arguments, command or path), OpenCode (plugin `tool.execute.after`, ids
+equal the export's `callID`), Pi (`tool_result`, ids equal the session
+file's), Droid (PostToolUse added; untested live), Cursor (`afterFileEdit`
+and `afterShellExecution` become Write/Edit and Shell calls, matched by
+written path and command text; the TUI asks before MCP tools and
+non-allowlisted commands), Antigravity (PostToolUse carries no output and
+the id is derived from the planner step; the prompt is read from the
+transcript's pending USER_INPUT at PreInvocation so the turn opens). A
+transcript item matched by id completes the delivered row's result and
+status without sampling its writes again. Ordering within a turn is prompt,
+tool calls as they ran, then the reply's text runs at the end: the text is
+known only at Stop.
+
+## v0.3.0
+
+Per-event capture for all eight agents (above), the per-session lock, the
+dedupe by key groups, shell changes on the shell call, hook times kept.
+
+## v0.3.1: commits made by an agent get their checkpoint
+
+Reported: agents commit but no checkpoint appears. Two causes, both fixed:
+
+- A file written and committed by one shell command never shows in `git
+ status`, so the turn had no touch for it. The dispatcher now remembers
+ HEAD at turn start (and after each shell call); files changed by commits
+ made during the turn count as the turn's changes, on the shell call that
+ committed them.
+- The post-commit hook links only touches that already exist; touches that
+ land later (at Stop, or a shell call's) missed the commit, and the cursor
+ had moved past it. A delivery now carries the turn's starting HEAD, and the
+ writer links the commits made since then (`gitlink.LinkRange`), consuming
+ the late touches so they cannot attach to a later commit.
+
+Verified live: `claude -p` running only `echo … > c4.txt && git add && git
+commit` → checkpoint `["c4.txt"]` linked, the touch consumed by that commit;
+`codex exec` the same → checkpoint `["c5.txt"]` linked.
+
+## v0.3.2: `.atlas/` holds only what the desktop app writes
+
+Reported: the CLI's `.atlas/` differed from the app's. Compared against
+app-made folders on this machine (`Delta`, `acp-ref`, `Portfolio-Clean`):
+the shared files (`.gitignore`, `project.json`, `sessions.db`,
+`sessions.lock`, `memory/memory.sqlite`) were already identical; the CLI also
+wrote its own `hooks/` (session state, spool, OpenCode export cache, locks),
+`cli-hooks.log`, `cli-run.json`, `cli-serve.log` and `cli-import.json`. Those
+now live in a per-user state directory, `$XDG_STATE_HOME/atlas-cli/projects/
+<16 hex of sha256(root)>/` (default `~/.local/state/…`, 0700, with a `root`
+file naming the Project): `hooks/`, `hooks.log`, `run.json` (0600),
+`serve.log`, `import.json`. Older layouts are moved there on first use. The
+app's own UI and index files (`editor-state.json`, `logs.jsonl`,
+`codebase-index/`, `memory/extract-state`, …) are the app's to create when it
+opens the folder. Every test package now points `XDG_STATE_HOME` at a temp
+directory.
+
+Verified: `test-cli` migrated in place (history kept); a fresh Project after
+a hooked Claude turn and an `atlas serve` start/stop has `.atlas/` =
+`.gitignore memory project.json sessions.db sessions.lock`.
+
+## v0.3.3: terminal sessions feed memory like app sessions
+
+Reported: app memory and terminal memory differ. Both use the same file
+(`.atlas/memory/memory.sqlite`, schema v6) and in this repo `atlas memory
+list` matched the app's active entries; the difference was in what each
+side writes for a session. Fixed:
+
+- **Agent ids.** (Reverted in 0.3.4 at the user's request: agents keep
+ their own names.)
+- **Capture candidates.** Lines of the agent's reply carrying the app's
+ markers (`decided to`, `decision:`, `gotcha:`, `doesn't work`, `note:`,
+ `architecture:`, …) become candidates (source `capture`, 0.3).
+- **Handoff note.** At session end the episode is written in the app's
+ HandoffNote shape from the session's active memories, and rewritten after
+ the extractor's final pass.
+- **Extractor.** After a turn (≥ 20 turns and ≥ 3 tool calls since the last
+ pass) and at session end, a detached `atlas hooks extract` asks the Atlas
+ gateway's default model (`/v1/models` `default: true`, glm-5.3-flash today)
+ to distil decisions, facts, failures, architecture and preferences; entries
+ land as source `extractor`. Signed in only; the summariser's `local` mode
+ sends nothing; one pass per session at a time. The excerpt comes from the
+ capture store, which is already redacted.
+- `atlas memory list --all` includes candidates, as the app's panel does.
+
+Verified live (`claude -p` in a fresh Project): `file_changed`, two capture
+candidates, three extractor memories ~40 s later, the handoff with the
+distilled decision and fact, everything as `claude-acp`; in this repo
+`atlas memory list --all` = 202 = the app's store.
+
+## v0.3.4: agents keep their own names
+
+At the user's request the agent renaming of v0.3.3 is reverted: hook
+sessions, memory entries, handoff notes and the MCP bridge use the
+integration's own id (`claude-code`, `codex`, `cursor`, `copilot`,
+`opencode`, `droid`, `antigravity`, `pi`). Candidates, handoff notes and the
+extractor stay. Sessions recorded by 0.3.3 keep the names they were written
+with.
+
+## v0.4.0: guided init, `atlas sync`, `atlas settings`
+
+At the user's request ("init with an all-selection option: org, project,
+agents with an add-all option, sync daemon on/off as `atlas sync`; a properly
+designed Bubble Tea UI and an `atlas settings` screen"):
+
+- **`atlas init` is a four-step flow** on a terminal (`tui.StepSelect`,
+ `tui.StepMulti`): Organisation, Project (the fingerprint match first and
+ marked, the org's other Projects, "+ Create a new Project"), Agents (a
+ checklist of the agents detected here, all pre-checked, with an
+ **All agents** row; `a`/`n` select all or none), Background sync (On/Off).
+ Each answered step collapses to a `✓ n. Step answer` line; esc leaves a
+ `✗`. Long lists scroll. The binding is written and the store released
+ before step 3, so the listener can take it. `--local` asks step 3 only.
+ New flags: `--agents`, `--all-agents`, `--sync on|off`; without a
+ terminal and without them, init hooks nothing (as before). A signed-out
+ `atlas init` on a terminal offers "Sign in" or "Set up locally".
+- **`atlas sync [on|off|status]`**: on starts a detached `atlas serve` and
+ records `backgroundSync` in `prefs.json` in the CLI state directory; off
+ stops it and clears it. While it is on, a SessionStart or TurnStart hook
+ restarts a missing listener (after its own delivery, at most once a
+ minute: the desktop may hold the store). `atlas status` shows a
+ `Listener:` line. `atlas serve --daemon/--stop` stay as the low-level
+ form.
+- **`atlas settings`**: a full-screen Bubble Tea screen with a sidebar
+ (Account, Project, Agents, Sync, About) and a detail pane. Toggles:
+ session capture, shared memory, each agent (install/remove hook and
+ memory entry), background sync; actions: add all detected agents, sync
+ now, retry failed. Changes run through the same code as the commands, off
+ the UI loop, with a spinner and a status line. Without a terminal it
+ prints the rows.
+- `installAgents` / `removeAgents` are shared by `atlas agents`, init and
+ settings; `launchListener` / `haltListener` by serve, sync and settings.
+- Verified live in a pty: init steps 1–3 against the real account
+ (cancelled before connecting a Project), the agent checklist installing
+ only the picked agents, the settings screen toggling an agent, and
+ `atlas sync on` → kill -9 → a Claude SessionStart hook restarting the
+ listener.
+- **Fix: a commit of a file the agent had read got no checkpoint.** The
+ dispatcher treats the files a tool call names as "explained", so git's
+ view of the shell call that follows does not count them twice. Read and
+ Search calls name files too (OpenCode's read metadata, Claude's
+ `file_path`), so after "read greet.js, then git add && git commit" the
+ commit's change was dropped as explained: no touch, no checkpoint. Now
+ only calls that write (Edit, Write, Delete, Move, or carrying a diff)
+ explain a path (`writtenPaths`). Test:
+ `TestCommittingAFileWrittenBeforeTheTurnGetsItsCheckpoint` (fails before
+ the fix). Verified live with OpenCode and Claude Code: read, then commit,
+ gives a linked checkpoint with the file.
+- **TUI redesign after `Tui-design-ins/`** (the user's references: a
+ breadcrumb over a rule and a `[KEY] label` footer; Oxide's tree of
+ answered steps with green answers and a block progress bar; Cursor's
+ ☒ ▣ ☐ checklist and ⬢ status; a mail client's titled panels, `` key
+ badges and coloured tags; a highlight bar on the selected row). Shared
+ pieces in `internal/tui/frame.go` (`Crumb`, `Rule`, `Keys`, `Badge`,
+ `Tag`, `Bar`, `TreeLine`, `Upper`, `Panel`, `Fit`, `Wrap`, `Indent`),
+ colours in the theme (ANSI indices, so the terminal's palette decides).
+ - `atlas init`: each step is `atlas / setup / `, `STEP n OF 4`
+ with a ▮▯ bar, the question, options on the highlight bar, the key
+ footer; an answered step becomes `├ Step answer` and the run ends
+ `└ ⬢ Atlas is ready in …`; install output stays on the tree's stem
+ (wrapped at 76). Lists fit the terminal and scroll. A cancelled step
+ ends `└ Step cancelled` with no second error line.
+ - `atlas settings`: Sections, rows and Details panels (two panels under
+ 110 columns), `<1>`–`<5>` badges with icons, ON/OFF/RUN tags, a
+ spinner while a change applies, `⬢ result` or `✗ error` in the footer.
+ - `atlas memory browse`: the same frame, kind tabs on the highlight bar,
+ Entries and Entry panels, upper-case field labels.
+ Checked by rendering the real screens to PNG (pyte + Pillow) at 100 and
+ 120 columns.
+- **`atlas update [--check] [--version vX.Y.Z]`** (`internal/update`): reads
+ the latest release from the GitHub API (or takes the tag given), downloads
+ `atlas___.tar.gz` and `checksums.txt` as install.sh does,
+ refuses a checksum mismatch, extracts `atlas`, and renames it over the
+ running binary (resolved through symlinks; a temp file next to it, mode
+ kept). A development build (`0.4.0-dev`) counts as older than its
+ release. A background listener for the Project in the cwd is restarted
+ on the new binary. `ATLAS_UPDATE_BASE_URL` replaces the download base.
+ Settings → About has "Check for updates" and "Update atlas". Verified
+ live: a 0.3.0 build updated itself to v0.3.4 from GitHub.
+- **Atlas look across every command** (the user: "Bubble Tea animations,
+ but no gradients; our Atlas design colours; our logo; every command with
+ a TUI"). From the desktop app (`crates/atlas-theme/themes/atlas.toml`,
+ `src/assets/atlas-icon.svg`):
+ - **Palette** (`tui/theme.go`): the app's hex colours, dark and light;
+ monochrome first, the selection an inversion (white bar, black text),
+ colour only for status (success #9ecf8a, warning #ffff00, error
+ #f44747, info #6796e6), the logo cream #FFFFE2. No gradients: every
+ animation switches between two tones. Light or dark from
+ `ATLAS_THEME=light|dark`, else `COLORFGBG`, else dark (the terminal is
+ not queried: one that does not answer would hang). Colours are cut
+ down to the terminal's profile (`tui.Out`).
+ - **Logo** (`tui/logo.go`): the mark's exact dot grid (three stripes, 4
+ dots wide, 14 tall, one column right every two rows): `LogoSmall` a 5-row `●●●` miniature (braille was dropped: some fonts
+ draw its empty dots, which turned the logo into noise), `Mark`
+ `▟▛ ▟▛ ▟▛` (block quadrants, a slanted bar in any font), `Wordmark`. Reveal animation lights the stripes one
+ by one from the bottom; the working spinner pulses the three stripes.
+ - **Every command on a terminal**: `atlas` alone shows the splash (logo
+ lighting up, commands by group); `--help` is styled; `login` is a
+ screen with the code boxed, the URL and a live expiry countdown;
+ `whoami`, `org list`, `agents list|sessions`, `memory list` are
+ sections and tables with status dots; `status` is a card grouped
+ Account / Project / Capture and sync / Agents with dots; `logout`,
+ `org create`, `capture sync|retry|link`, `memory verify|export|import`,
+ `sync on|off`, `update` run under the pulsing mark with live steps and
+ end `✓ …` or `✗ …`; `serve` in the foreground is a live screen with a
+ feed of hook events (prompt, tool, reply, commit linked); `init` opens
+ with the logo; settings carries the logo in its sidebar. Pipes and
+ scripts get the same plain text as before (all tests unchanged).
+ `ATLAS_NO_ANIMATION=1` skips the reveal.
+- **The logo is Array Bold's `///`** (Fontshare's Array, as the user pointed out; checked against the font's own "/" glyph: round dots, 4 wide, 14 tall, one column right every two rows). `tui.Logo` keeps every dot column and puts two dot rows in each text row, so the slope and the 1.7:1 proportions hold: 7 rows × 24 columns, three stripes 3 columns apart. It is the only logo (splash, `init` intro, settings sidebar); the 5-row miniature and the braille version are gone.
+- Headers read `Atlas settings / account` (no mark); the settings sidebar has no logo. The logo is on the splash and the `init` intro only.
+- **Tree lists and a real progress bar** (the user's references: Oxide's
+ `├ Title Colossal Cave Adventure` tree and its green `▮▮▮▯▯▯` bar).
+ Lists (init's options, settings sections and rows) are trees: a gold
+ `├`/`└` (the app's terminal yellow #e5c07b), the label, the value in
+ green; the selected row is `▸` on the app's selection grey (#303030), not
+ a white bar; badges and icons are gone. Values read `on` (green), `off`
+ (dim), `run ↵` (gold). Waiting is a progress bar (green `▮` over `▯`)
+ under a bold title with the elapsed time: it eases toward 90% while the
+ work runs, steps on as each step reports (shown as tree lines), and fills
+ before `✓ …`. Init's last phase runs as "Initializing Atlas in "
+ under it, each agent installed a step.
+
+## Memory parity with the app's .atlas/memory (and the "lost memory" bug)
+
+- **Bug: a listener kept writing into a deleted store.** In test-cli the user
+ deleted `.atlas/` and ran `atlas init` again while a background `atlas
+ serve` (started earlier) still ran. It held the deleted `sessions.db`,
+ `sessions.lock` and `memory.sqlite` open, so every hook delivered into
+ files no one could see: no sessions, no `memory/` folder. Now the host
+ records the store it opened and `Host.Stale()` compares it with what is
+ on disk (`os.SameFile`); the listener checks every 2 s and stops, and
+ refuses deliveries meanwhile, so hooks write the new store directly and
+ the next one restarts the listener (background sync on). The memory
+ server reopens a record whose file was replaced (`record.Store.Stale`),
+ which also covers a long-lived `mcp-bridge`. Verified live: re-init under
+ a running listener, then a Claude session: the session and its memory
+ land in the new store, a new listener starts.
+- `atlas init` creates `.atlas/memory/memory.sqlite` when shared memory is
+ on (the app creates it on the first session; the folder shows from the
+ start).
+- Extractor progress is the desktop's file and format:
+ `.atlas/memory/extract-state/.json` with
+ `last_extracted_turn_index`, `tool_calls_since_last`, `extraction_count`
+ (written atomically; the CLI's old state file is read once and removed;
+ the pass lock stays in the CLI state dir). A session not in the store is
+ skipped, not logged as an error (the "sql: no rows" hook errors).
+- `memory.snapshot.sqlite`: the desktop's daily backup (`VACUUM INTO`, a
+ `snapshot_meta` 'at' stamp, rename), refreshed at session start at most
+ once a day. A damaged record (quick_check, malformed) is quarantined as
+ `memory.sqlite.corrupt-`, the snapshot copied in, and `restored.json`
+ written, as `RecordStore::open` does; a record declined for its schema
+ version is never quarantined.
+- Not done, by design or pending a decision: `corpus.sqlite` and its
+ `.usearch` (the "documents" leg of memory_search, MiniLM embeddings via
+ candle in the app), the record's `embed_cache` (dense search), the
+ `memory-index/policy.json` cache (the app's Policy view only), and
+ `code-index/` (the atlas_code tools).
+
+## Memory search like the app: embeddings, corpus, hybrid memory_search
+
+The user: "those are important for shared memory" (the corpus, the vectors).
+Everything below is byte-compatible with the desktop, so either side keeps
+what the other wrote.
+
+- **`internal/embed`: MiniLM in pure Go** (no cgo). BERT WordPiece tokenizer
+ from `tokenizer.json` (BertNormalizer: clean text, CJK spacing, accent
+ strip, lowercase; BertPreTokenizer; WordPiece `##`, [UNK], 100 chars; added
+ tokens matched first; truncation 512 with [CLS]/[SEP], as atlas-embed
+ overrides it), a 6-layer encoder over `model.safetensors` (float32, erf
+ GELU, LayerNorm 1e-12), mean pooling, L2 norm. It reads the model the app
+ downloads (`/models/all-MiniLM-L6-v2/`; `ATLAS_MODELS_DIR`
+ overrides) and downloads it there the app's way (`.part`, rename) when
+ asked. Checked against the app: 54 entries of the atlas-cli record hit
+ the app's own cache keys, every Go vector at cosine 1.000000 to the app's.
+ ~55 ms a memory on this machine.
+- **Record vectors** (`memory.sqlite` `embed_cache`): key blake3(model ‖ 0 ‖
+ text), model `all-MiniLM-L6-v2-384`, f16 LE (768 bytes). A durable
+ `Remember` is embedded before its transaction; a write that matches no key
+ or text merges into the closest same-kind memory at cosine ≥ 0.92 unless
+ both are keyed or the texts contradict (numbers, negation:
+ `ContradictsText`, the app's consolidate rule). `SyncVectors` backfills
+ and drops garbage keys; `Forget` purges the vectors of every text the
+ entry had.
+- **Hybrid search** (`record.Search`, the app's search_explained): BM25 leg
+ and meaning leg (cosine ≥ 0.35, 100 deep, exact over the cache) fused by
+ RRF k 60, priors trust 0.5 and recency 0.3 over max(last use, update);
+ `why` reports `bm25` and `dense` ranks. No model: keyword only.
+- **Corpus** (`internal/memory/corpus`, `.atlas/memory/corpus.sqlite`): the
+ app's schema; docs from the project's CLAUDE.md and AGENTS.md (mirrored
+ block and injected context stripped), `.atlas/knowledge` notes, active
+ durable memories with a log position (caps 50/30/30/50), and captured
+ sessions of agents without a dedicated reader (12 KiB of role lines).
+ Content hash sha256(title\n\ntext), vkey from blake3(id), cache key
+ blake3(model ‖ 0 ‖ hash hex), title/body split, user/repo scope; changed
+ docs are re-embedded, `vectors_dirty:` set so the app rebuilds its
+ usearch file. The CLI never reads agent folders (Claude's memory dir,
+ ~/.claude, ~/.codex) and never deletes the documents the app reads from
+ them (`Owned`). Checked against the app: all 9 documents both produce for
+ atlas-cli have the app's content hash.
+- **memory_search documents**: when no kinds are given, up to 6 (limit,
+ max 20) from the corpus: dense (cosine ≥ 0.30) and BM25, pool max(4·limit,
+ 20), RRF, Jaccard ≥ 0.8 dedup, a forgotten memory's doc filtered out; the
+ query embedded untrimmed. `memory_forget` evicts the doc at once.
+- **When it runs**: `atlas memory index` (downloads the model unless
+ `--no-download`, progress bar), after a session ends (detached, never
+ downloads), after an extraction stores memories, every 15 min in
+ `atlas serve`, and `atlas init` asks to download the model (a "Memory
+ search" step) when it is missing. Settings shows "Memory search: meaning
+ and keywords" and a "Build memory index" action.
+- Verified live: a Claude session asked `memory_search` "how do we say hi to
+ people" and got both greeting memories by meaning (`why: {dense: 1}`,
+ `{dense: 2}`) and the CLAUDE.md rule and the shared decision as documents.
+- Still the app's alone: `memory-index/policy.json` (the Policy view's
+ cache), `code-index/` (atlas_code tools), the global memory blend.
+- **Settings ▸ Model**: the local MiniLM model's state (downloaded and its
+ size, or not), what memory search uses (meaning and keywords, or keywords
+ only), where the files live (shared with the app), and actions: Download
+ model (87 MB from Hugging Face, the bar and "n of 87 MB" in the row, then
+ the Project's memories embedded and documents indexed), Build memory
+ index, Remove model (asks for a second enter: the app uses the same
+ files). Settings actions can now report progress (`settings.Progresser`)
+ and ask for confirmation (`Item.Confirm`). The embedder notices a model
+ downloaded in the same process. Verified live into a scratch
+ ATLAS_MODELS_DIR: the files match the app's byte for byte in size.
+- **Plans from agents run in their own UIs.** Only the parked ACP path
+ folded plans (`plan_set`); hook capture never did, so the Plan tab stayed
+ empty. Now the agents' plan tools are read as they complete and at turn
+ end: Claude Code's and Droid's `TodoWrite`, Codex's `update_plan`,
+ OpenCode's `todowrite`, Cursor's `todo_write`, Copilot's `update_todo`
+ (a list or a Markdown checklist), Claude Code's `ExitPlanMode` (the plan
+ as Markdown), and Claude Code's incremental `TaskCreate` / `TaskUpdate`
+ (the list kept in the hook session state, each call applied once by its
+ id, replayed from the transcript when its hook did not deliver it). The
+ plan is the ACP text ("- [status] item", 600 chars) and is `done` once
+ every item is completed; each new plan supersedes the last.
+ `memory_briefing` gives it to the next session as the active plan.
+ Verified live with OpenCode (`todowrite` → the plan entry).
+
+## v0.4.0 release
+
+PR #3 (`feat/tui-memory-search` → main) carries everything since #2 was
+merged at v0.2.0: v0.3.x and the v0.4.0 work above. Before tagging: tests,
+lint and `go mod tidy -diff` clean; `make release-check` and `make snapshot`
+built the four archives and `checksums.txt`, and install.sh installed the
+snapshot from them. README redesigned with the banner
+(`docs/assets/banner.png`).
+- The first v0.4.0 tag's release run failed on Linux: ext4 gave a
+ re-created `sessions.db` the deleted file's inode number, so the inode
+ comparison missed a replaced store. The host and the memory record now
+ also hold a handle on the file they opened and treat a link count of 0
+ (unlinked) as stale. The tag was moved to the fixed commit.
diff --git a/go.mod b/go.mod
index 287555d..0274bf3 100644
--- a/go.mod
+++ b/go.mod
@@ -6,19 +6,20 @@ require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6
+ github.com/charmbracelet/colorprofile v0.4.3
+ github.com/charmbracelet/x/ansi v0.11.9
+ github.com/charmbracelet/x/term v0.2.2
github.com/pelletier/go-toml/v2 v2.4.3
github.com/spf13/cobra v1.10.2
github.com/zeebo/blake3 v0.2.4
golang.org/x/sys v0.49.0
+ golang.org/x/text v0.42.0
modernc.org/sqlite v1.60.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
- github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
- github.com/charmbracelet/x/ansi v0.11.9 // indirect
- github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
diff --git a/go.sum b/go.sum
index 3083341..9d6b8e8 100644
--- a/go.sum
+++ b/go.sum
@@ -79,6 +79,8 @@ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/agents/acp/state_main_test.go b/internal/agents/acp/state_main_test.go
new file mode 100644
index 0000000..d91d467
--- /dev/null
+++ b/internal/agents/acp/state_main_test.go
@@ -0,0 +1,15 @@
+package acp
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/auth/state_main_test.go b/internal/auth/state_main_test.go
new file mode 100644
index 0000000..3528e37
--- /dev/null
+++ b/internal/auth/state_main_test.go
@@ -0,0 +1,15 @@
+package auth
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/capture/git/link.go b/internal/capture/git/link.go
index 1246bc5..86fd8a5 100644
--- a/internal/capture/git/link.go
+++ b/internal/capture/git/link.go
@@ -74,7 +74,7 @@ func Link(ctx context.Context, st *store.Store, root string, now func() time.Tim
return res, err
}
for _, sha := range strings.Fields(list) {
- n, err := evaluate(ctx, g, st, sha, touches, now)
+ n, err := evaluate(ctx, g, st, sha, touches, now, false)
if err != nil {
return res, err
}
@@ -99,8 +99,9 @@ type commit struct {
}
// evaluate links one first-parent commit; a merge creates no checkpoint and
-// its side commits are evaluated instead.
-func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touches []store.Touch, now func() time.Time) (int, error) {
+// its side commits are evaluated instead. late says the touches may have been
+// recorded after the commit (LinkRange): they are consumed all the same.
+func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touches []store.Touch, now func() time.Time, late bool) (int, error) {
c, err := g.commit(ctx, sha)
if err != nil {
return 0, err
@@ -112,7 +113,7 @@ func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touche
}
total := 0
for _, s := range strings.Fields(side) {
- n, err := evaluate(ctx, g, st, s, touches, now)
+ n, err := evaluate(ctx, g, st, s, touches, now, late)
if err != nil {
return total, err
}
@@ -161,8 +162,12 @@ func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touche
if err := st.UpsertCheckpoint(ctx, cp, now()); err != nil {
return written, err
}
+ cutoff := c.time
+ if late {
+ cutoff = now()
+ }
for _, p := range touchPaths {
- if err := st.ConsumeTouches(ctx, sid, p, sha, c.time); err != nil {
+ if err := st.ConsumeTouches(ctx, sid, p, sha, cutoff); err != nil {
return written, err
}
}
@@ -356,3 +361,37 @@ func (g runner) branch(ctx context.Context) *string {
}
return &b
}
+
+// LinkRange links the first-parent commits after from up to HEAD to the
+// touches recorded so far, whatever the cursor says: an agent hook calls it
+// when touches arrive after a commit the post-commit hook already walked (a
+// file written and committed in one shell command, or a turn delivered at
+// its end). Checkpoints are upserted, so a commit linked twice stays one.
+func LinkRange(ctx context.Context, st *store.Store, root, from string, now func() time.Time) (int, error) {
+ g := runner{dir: root}
+ if from == "" || !g.isCommit(ctx, from) || g.midOperation(ctx) {
+ return 0, nil
+ }
+ list, err := g.out(ctx, "rev-list", "--first-parent", "--reverse", "--max-count", strconv.Itoa(scanLimit), from+"..HEAD")
+ if err != nil || list == "" {
+ return 0, err
+ }
+ touches, err := st.LiveTouches(ctx, root)
+ if err != nil || len(touches) == 0 {
+ return 0, err
+ }
+ total := 0
+ for _, sha := range strings.Fields(list) {
+ n, err := evaluate(ctx, g, st, sha, touches, now, true)
+ if err != nil {
+ return total, err
+ }
+ total += n
+ if n > 0 {
+ if touches, err = st.LiveTouches(ctx, root); err != nil {
+ return total, err
+ }
+ }
+ }
+ return total, nil
+}
diff --git a/internal/capture/git/state_main_test.go b/internal/capture/git/state_main_test.go
new file mode 100644
index 0000000..ef609f6
--- /dev/null
+++ b/internal/capture/git/state_main_test.go
@@ -0,0 +1,15 @@
+package git
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/capture/record/recorder.go b/internal/capture/record/recorder.go
index 07f84f2..ac98b38 100644
--- a/internal/capture/record/recorder.go
+++ b/internal/capture/record/recorder.go
@@ -221,6 +221,9 @@ type Tool struct {
Binary []byte
Locations []string
Diffs []Diff // ACP diff content blocks
+ // NoSample says the call's writes were already sampled (a hook delivered
+ // it): this sighting only completes the row (result, status).
+ NoSample bool `json:"no_sample,omitempty"`
}
// ToolCall upserts the call; on its first completed sighting it samples the
diff --git a/internal/capture/record/state_main_test.go b/internal/capture/record/state_main_test.go
new file mode 100644
index 0000000..de38293
--- /dev/null
+++ b/internal/capture/record/state_main_test.go
@@ -0,0 +1,15 @@
+package record
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/capture/record/turn.go b/internal/capture/record/turn.go
index 34963fe..eff22d0 100644
--- a/internal/capture/record/turn.go
+++ b/internal/capture/record/turn.go
@@ -22,7 +22,11 @@ import (
type CapturedTurn struct {
Prompt string `json:"prompt"`
Items []CapturedItem `json:"items,omitempty"`
- Model string `json:"model,omitempty"`
+ // Open says the turn was opened earlier by OpenTurn (and may already
+ // hold tool calls from RecordTool): complete it instead of starting a
+ // new one.
+ Open bool `json:"open,omitempty"`
+ Model string `json:"model,omitempty"`
// Usage is the turn's token usage when the transcript or the hook gave
// one; nil records nothing.
Usage *store.TokenTotals `json:"usage,omitempty"`
@@ -58,9 +62,11 @@ func (r *Recorder) RecordTurn(ctx context.Context, s Session, t CapturedTurn) (i
if at.IsZero() {
at = r.Now()
}
- l.turn++
- if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil {
- return 0, err
+ if !t.Open || l.turn == 0 {
+ l.turn++
+ if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil {
+ return 0, err
+ }
}
stripped := StripUser(t.Prompt)
if strings.TrimSpace(stripped) != "" {
@@ -132,25 +138,8 @@ func (r *Recorder) RecordTurn(ctx context.Context, s Session, t CapturedTurn) (i
}
call = id
}
- inHead := headFiles(r.Root)
- for _, p := range t.Changed {
- ft := store.FileTouch{ToolCallID: call, SessionID: l.id, TurnSeq: l.turn, Path: p, ExistedBefore: inHead[p]}
- if filepath.IsAbs(p) {
- ft.OutOfRepo = true
- }
- abs := p
- if !ft.OutOfRepo {
- abs = filepath.Join(r.Root, filepath.FromSlash(p))
- }
- if data, err := os.ReadFile(abs); err == nil { //nolint:gosec // G304: a path git reported changed
- h := store.SHA256Hex(data)
- ft.SHA256After, ft.SketchAfter = &h, Sketch(data)
- } else {
- ft.Deleted = true
- }
- if err := r.Store.AddFileTouch(ctx, ft, at); err != nil {
- return l.turn, err
- }
+ if err := r.touchPaths(ctx, l, call, t.Changed, at); err != nil {
+ return l.turn, err
}
}
if t.Usage != nil {
@@ -222,7 +211,7 @@ func (r *Recorder) recordCapturedTool(ctx context.Context, l *live, t Tool, at t
if err != nil {
return "", name, err
}
- if status == "completed" && (WritesFiles(name) || len(t.Diffs) > 0) && !l.touched[t.NativeID] {
+ if status == "completed" && !t.NoSample && (WritesFiles(name) || len(t.Diffs) > 0) && !l.touched[t.NativeID] {
l.touched[t.NativeID] = true
if err := r.sampleWrites(ctx, l, id, t.NativeID, WritePaths(t.Arguments, t.Locations, t.Diffs...), t.Arguments, t.Diffs, at); err != nil {
return id, name, err
@@ -230,3 +219,96 @@ func (r *Recorder) recordCapturedTool(ctx context.Context, l *live, t Tool, at t
}
return id, name, nil
}
+
+// OpenTurn starts a turn as the prompt is submitted (the per-event path): the
+// turn row, the title on the first turn, and the user message. The rest of
+// the turn arrives through RecordTool and a CapturedTurn with Open set.
+func (r *Recorder) OpenTurn(ctx context.Context, s Session, prompt string, at time.Time) (int64, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if !r.enabled(ctx) {
+ return 0, nil
+ }
+ l, err := r.session(ctx, s)
+ if err != nil {
+ return 0, err
+ }
+ if at.IsZero() {
+ at = r.Now()
+ }
+ l.turn++
+ if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil {
+ return 0, err
+ }
+ stripped := StripUser(prompt)
+ if strings.TrimSpace(stripped) == "" {
+ return l.turn, nil
+ }
+ if flat, err := redact.Flat(stripped); err == nil {
+ if title := Title(flat.Text); title != "" {
+ _ = r.Store.SetTitleIfAbsent(ctx, l.id, title)
+ }
+ }
+ body, ok := r.redactAuto(ctx, l, stripped)
+ if !ok {
+ return l.turn, nil
+ }
+ _, err = r.Store.RecordMessage(ctx, store.Message{SessionID: l.id, TurnSeq: l.turn,
+ NativeID: fmt.Sprintf("prompt-%d-%s", l.turn, store.SHA256Hex([]byte(prompt))),
+ Role: "user", Mode: "text", Body: body, CreatedAt: at})
+ return l.turn, err
+}
+
+// RecordTool upserts one tool call into the session's open turn as the
+// agent's post-tool hook reports it, sampling the files it wrote; changed
+// are the Project paths git saw change while a shell call ran, recorded as
+// its touches. With no open turn it opens one.
+func (r *Recorder) RecordTool(ctx context.Context, s Session, t Tool, changed []string, at time.Time) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if !r.enabled(ctx) {
+ return nil
+ }
+ l, err := r.session(ctx, s)
+ if err != nil {
+ return err
+ }
+ if at.IsZero() {
+ at = r.Now()
+ }
+ if l.turn == 0 {
+ l.turn++
+ if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil {
+ return err
+ }
+ }
+ id, _, err := r.recordCapturedTool(ctx, l, t, at)
+ if err != nil || id == "" || len(changed) == 0 {
+ return err
+ }
+ return r.touchPaths(ctx, l, id, changed, at)
+}
+
+// touchPaths records file touches for paths changed outside a known write.
+func (r *Recorder) touchPaths(ctx context.Context, l *live, callID string, paths []string, at time.Time) error {
+ inHead := headFiles(r.Root)
+ for _, p := range paths {
+ ft := store.FileTouch{ToolCallID: callID, SessionID: l.id, TurnSeq: l.turn, Path: p, ExistedBefore: inHead[p]}
+ abs := p
+ if filepath.IsAbs(p) {
+ ft.OutOfRepo = true
+ } else {
+ abs = filepath.Join(r.Root, filepath.FromSlash(p))
+ }
+ if data, err := os.ReadFile(abs); err == nil { //nolint:gosec // G304: a path git reported changed
+ h := store.SHA256Hex(data)
+ ft.SHA256After, ft.SketchAfter = &h, Sketch(data)
+ } else {
+ ft.Deleted = true
+ }
+ if err := r.Store.AddFileTouch(ctx, ft, at); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/capture/store/state_main_test.go b/internal/capture/store/state_main_test.go
new file mode 100644
index 0000000..add354c
--- /dev/null
+++ b/internal/capture/store/state_main_test.go
@@ -0,0 +1,15 @@
+package store
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/capture/sync/state_main_test.go b/internal/capture/sync/state_main_test.go
new file mode 100644
index 0000000..d243224
--- /dev/null
+++ b/internal/capture/sync/state_main_test.go
@@ -0,0 +1,15 @@
+package sync
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/clistate/clistate.go b/internal/clistate/clistate.go
new file mode 100644
index 0000000..23835c4
--- /dev/null
+++ b/internal/clistate/clistate.go
@@ -0,0 +1,80 @@
+// Package clistate is where the CLI keeps its own per-Project files: hook
+// session state, the spool, the listener's run file and logs. They live
+// outside /.atlas/ so that folder holds exactly what the desktop app
+// writes: $XDG_STATE_HOME/atlas-cli/projects/<16 hex of sha256(root)>/
+// (default ~/.local/state/…), with a "root" file naming the Project.
+package clistate
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+// Dir is the CLI's state directory for a Project root.
+func Dir(root string) string {
+ base := os.Getenv("XDG_STATE_HOME")
+ if !filepath.IsAbs(base) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = os.TempDir()
+ }
+ base = filepath.Join(home, ".local", "state")
+ }
+ sum := sha256.Sum256([]byte(root))
+ return filepath.Join(base, "atlas-cli", "projects", hex.EncodeToString(sum[:])[:16])
+}
+
+// Ensure creates the directory (0700: it holds typed prompts) and its root
+// file, and moves what older CLIs left in /.atlas/.
+func Ensure(root string) (string, error) {
+ d := Dir(root)
+ if err := os.MkdirAll(d, 0o700); err != nil {
+ return "", err
+ }
+ if _, err := os.Stat(filepath.Join(d, "root")); errors.Is(err, fs.ErrNotExist) {
+ _ = os.WriteFile(filepath.Join(d, "root"), []byte(root+"\n"), 0o600)
+ }
+ migrate(root, d)
+ return d, nil
+}
+
+// Path is a file in the state directory (created on demand).
+func Path(root, name string) string {
+ d, err := Ensure(root)
+ if err != nil {
+ return filepath.Join(Dir(root), name)
+ }
+ return filepath.Join(d, name)
+}
+
+// legacy maps what CLIs up to 0.3.1 wrote into .atlas/ to its new name.
+var legacy = map[string]string{
+ "hooks": "hooks",
+ "cli-hooks.log": "hooks.log",
+ "cli-serve.log": "serve.log",
+ "cli-import.json": "import.json",
+}
+
+func migrate(root, d string) {
+ old := filepath.Join(root, ".atlas")
+ for from, to := range legacy {
+ src := filepath.Join(old, from)
+ if _, err := os.Lstat(src); err != nil {
+ continue
+ }
+ dst := filepath.Join(d, to)
+ if _, err := os.Lstat(dst); err == nil {
+ _ = os.RemoveAll(src) // the new copy wins
+ continue
+ }
+ if os.Rename(src, dst) != nil {
+ _ = os.RemoveAll(src)
+ }
+ }
+ // A run file of a listener from an older CLI is stale by now.
+ _ = os.Remove(filepath.Join(old, "cli-run.json"))
+}
diff --git a/internal/clistate/prefs.go b/internal/clistate/prefs.go
new file mode 100644
index 0000000..fa210b6
--- /dev/null
+++ b/internal/clistate/prefs.go
@@ -0,0 +1,41 @@
+package clistate
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+)
+
+// Prefs are the CLI's per-Project choices that are not the desktop's
+// (.atlas/ stays the app's).
+type Prefs struct {
+ // BackgroundSync keeps atlas serve running for the Project: atlas sync on
+ // starts it, and an agent hook restarts it when it is gone.
+ BackgroundSync bool `json:"backgroundSync"`
+}
+
+const prefsFile = "prefs.json"
+
+// LoadPrefs reads the Project's prefs; a missing or unreadable file is the
+// zero value.
+func LoadPrefs(root string) Prefs {
+ var p Prefs
+ if b, err := os.ReadFile(Path(root, prefsFile)); err == nil {
+ _ = json.Unmarshal(b, &p)
+ }
+ return p
+}
+
+// SavePrefs writes them.
+func SavePrefs(root string, p Prefs) error {
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return err
+ }
+ path := Path(root, prefsFile)
+ tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
+ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, path)
+}
diff --git a/internal/embed/app_compat_test.go b/internal/embed/app_compat_test.go
new file mode 100644
index 0000000..18e75c9
--- /dev/null
+++ b/internal/embed/app_compat_test.go
@@ -0,0 +1,74 @@
+package embed
+
+import (
+ "database/sql"
+ "os"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// TestMatchesTheAppsVectors embeds entries of a real app-written record and
+// compares them with the vectors the desktop cached for the same text. It
+// runs only where both exist (ATLAS_COMPAT_RECORD names the memory.sqlite).
+func TestMatchesTheAppsVectors(t *testing.T) {
+ path := os.Getenv("ATLAS_COMPAT_RECORD")
+ if path == "" || !Available() {
+ t.Skip("set ATLAS_COMPAT_RECORD to an app-written memory.sqlite; needs the model")
+ }
+ e, err := Default()
+ if err != nil {
+ t.Fatal(err)
+ }
+ db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ rows, err := db.Query(`SELECT content FROM entries ORDER BY id DESC LIMIT 60`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var contents []string
+ for rows.Next() {
+ var c string
+ _ = rows.Scan(&c)
+ contents = append(contents, c)
+ }
+ _ = rows.Close()
+ matched, worst := 0, float32(1)
+ start := time.Now()
+ for _, c := range contents {
+ var vec []byte
+ if db.QueryRow(`SELECT vec FROM embed_cache WHERE key = ?`, CacheKey(ModelName, c)).Scan(&vec) != nil {
+ continue
+ }
+ matched++
+ cos := Cosine(e.Embed(c), Decode(vec))
+ worst = min(worst, cos)
+ if cos < 0.999 {
+ t.Errorf("cosine %.5f for %q", cos, c)
+ }
+ // Our f16 bytes should be nearly identical to the app's.
+ if b := Encode(e.Embed(c)); len(b) != len(vec) {
+ t.Fatalf("encoded %d bytes, app %d", len(b), len(vec))
+ }
+ }
+ if matched == 0 {
+ t.Fatal("no entry's key matched the app's cache: the key derivation differs")
+ }
+ t.Logf("%d entries matched the app's cache keys; worst cosine %.6f; %s", matched, worst, time.Since(start))
+}
+
+func TestF16RoundTrip(t *testing.T) {
+ for _, x := range []float32{0, 1, -1, 0.5, 0.1, -0.333, 65504, 1e-5, 6e-8, 3.14159} {
+ got := f16to32(f32to16(x))
+ if d := got - x; d > 0.001*max(1, x) || d < -0.001*max(1, -x) {
+ t.Errorf("%v → %v", x, got)
+ }
+ }
+ if f32to16(1) != 0x3c00 || f32to16(-2) != 0xc000 || f32to16(0.1) != 0x2e66 {
+ t.Fatalf("%x %x %x", f32to16(1), f32to16(-2), f32to16(0.1))
+ }
+}
diff --git a/internal/embed/download.go b/internal/embed/download.go
new file mode 100644
index 0000000..fcbcf31
--- /dev/null
+++ b/internal/embed/download.go
@@ -0,0 +1,89 @@
+package embed
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// BaseURL is where the files come from, as the app downloads them
+// (models.rs: https://huggingface.co/{repo}/resolve/main/{file}).
+var BaseURL = "https://huggingface.co/" + Repo + "/resolve/main/"
+
+// Download fetches the files that are missing into ModelDir, each written as
+// .part and renamed, as the app does; progress reports bytes done of
+// the total known so far. Files already there are kept.
+func Download(ctx context.Context, progress func(done, total int64)) error {
+ dir := ModelDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ client := &http.Client{Timeout: 30 * time.Minute}
+ var done, total int64
+ for _, f := range Files {
+ dst := filepath.Join(dir, f)
+ if fi, err := os.Stat(dst); err == nil && fi.Size() > 0 {
+ continue
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, BaseURL+f, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "atlas-cli")
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ _ = resp.Body.Close()
+ return fmt.Errorf("download %s: %s", f, resp.Status)
+ }
+ if resp.ContentLength > 0 {
+ total += resp.ContentLength
+ }
+ part := dst + ".part"
+ out, err := os.Create(part) //nolint:gosec // G304: our own model directory
+ if err != nil {
+ _ = resp.Body.Close()
+ return err
+ }
+ buf := make([]byte, 256<<10)
+ for {
+ n, rerr := resp.Body.Read(buf)
+ if n > 0 {
+ if _, err := out.Write(buf[:n]); err != nil {
+ _ = out.Close()
+ _ = resp.Body.Close()
+ return err
+ }
+ done += int64(n)
+ if progress != nil {
+ progress(done, total)
+ }
+ }
+ if rerr == io.EOF {
+ break
+ }
+ if rerr != nil {
+ _ = out.Close()
+ _ = resp.Body.Close()
+ return rerr
+ }
+ }
+ _ = resp.Body.Close()
+ if err := out.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(part, dst); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Size is the model's download size, for prompts.
+const Size = "87 MB"
diff --git a/internal/embed/embed.go b/internal/embed/embed.go
new file mode 100644
index 0000000..648bc47
--- /dev/null
+++ b/internal/embed/embed.go
@@ -0,0 +1,263 @@
+// Package embed computes the desktop's memory embeddings in pure Go:
+// sentence-transformers/all-MiniLM-L6-v2 (BERT, 6 layers, 384 dimensions),
+// mean-pooled over the tokens and L2-normalised, as crates/atlas-embed runs
+// it with candle. It reads the model files the app downloads
+// (/models/all-MiniLM-L6-v2/{config.json,tokenizer.json,
+// model.safetensors}); vectors are cached as the app caches them, f16
+// little-endian under blake3(model ‖ 0 ‖ text), so either side reuses the
+// other's.
+package embed
+
+import (
+ "encoding/binary"
+ "errors"
+ "math"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync"
+
+ "github.com/zeebo/blake3"
+)
+
+// The memory model, as the desktop names it.
+const (
+ ModelID = "all-MiniLM-L6-v2"
+ Repo = "sentence-transformers/all-MiniLM-L6-v2"
+ Dim = 384
+ ModelName = "all-MiniLM-L6-v2-384" // provider name: "-"
+)
+
+// Files are the model files, in download order.
+var Files = []string{"config.json", "tokenizer.json", "model.safetensors"}
+
+// ErrNoModel says the model files are not on this machine.
+var ErrNoModel = errors.New("the memory search model is not downloaded (atlas memory index downloads it)")
+
+// AppDataDir is the desktop's app data directory (Tauri app_data_dir):
+// macOS ~/Library/Application Support/dev.atlas.ide, Linux
+// $XDG_DATA_HOME or ~/.local/share, then dev.atlas.ide.
+func AppDataDir() string {
+ home, _ := os.UserHomeDir()
+ switch runtime.GOOS {
+ case "darwin":
+ return filepath.Join(home, "Library", "Application Support", "dev.atlas.ide")
+ default:
+ if x := os.Getenv("XDG_DATA_HOME"); filepath.IsAbs(x) {
+ return filepath.Join(x, "dev.atlas.ide")
+ }
+ return filepath.Join(home, ".local", "share", "dev.atlas.ide")
+ }
+}
+
+// ModelDir is where the model's files live; ATLAS_MODELS_DIR replaces the
+// models directory.
+func ModelDir() string {
+ if d := os.Getenv("ATLAS_MODELS_DIR"); d != "" {
+ return filepath.Join(d, ModelID)
+ }
+ return filepath.Join(AppDataDir(), "models", ModelID)
+}
+
+// Available reports that every model file is present.
+func Available() bool {
+ for _, f := range Files {
+ if fi, err := os.Stat(filepath.Join(ModelDir(), f)); err != nil || fi.Size() == 0 {
+ return false
+ }
+ }
+ return true
+}
+
+// Embedder turns text into unit vectors.
+type Embedder struct {
+ tok *Tokenizer
+ model *Model
+ mu sync.Mutex
+}
+
+var (
+ shared *Embedder
+ sharedMu sync.Mutex
+)
+
+// Default loads the model once per process; ErrNoModel while it is missing
+// (checked again on each call, so a download in this process is picked up).
+func Default() (*Embedder, error) {
+ sharedMu.Lock()
+ defer sharedMu.Unlock()
+ if shared != nil {
+ return shared, nil
+ }
+ if !Available() {
+ return nil, ErrNoModel
+ }
+ e, err := Load(ModelDir())
+ if err != nil {
+ return nil, err
+ }
+ shared = e
+ return shared, nil
+}
+
+// Forget drops the loaded model (after its files were removed).
+func Forget() {
+ sharedMu.Lock()
+ shared = nil
+ sharedMu.Unlock()
+}
+
+// DiskSize is the bytes the model's files take, 0 when absent.
+func DiskSize() int64 {
+ var n int64
+ for _, f := range Files {
+ if fi, err := os.Stat(filepath.Join(ModelDir(), f)); err == nil {
+ n += fi.Size()
+ }
+ }
+ return n
+}
+
+// Remove deletes the model's files (the app uses the same ones).
+func Remove() error {
+ Forget()
+ for _, f := range append(append([]string(nil), Files...), ".metal_incompatible") {
+ if err := os.Remove(filepath.Join(ModelDir(), f)); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ return nil
+}
+
+// Load reads a model directory.
+func Load(dir string) (*Embedder, error) {
+ tok, err := LoadTokenizer(filepath.Join(dir, "tokenizer.json"))
+ if err != nil {
+ return nil, err
+ }
+ m, err := LoadModel(filepath.Join(dir, "config.json"), filepath.Join(dir, "model.safetensors"))
+ if err != nil {
+ return nil, err
+ }
+ return &Embedder{tok: tok, model: m}, nil
+}
+
+// Embed returns text's unit vector; an empty text is the zero vector.
+func (e *Embedder) Embed(text string) []float32 {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ ids := e.tok.Encode(text)
+ if len(ids) <= 2 {
+ return make([]float32, e.model.Dim())
+ }
+ return e.model.Forward(ids)
+}
+
+// CacheKey is blake3(model ‖ 0x00 ‖ text) (atlas-retrieval codec.rs).
+func CacheKey(model, text string) []byte {
+ h := blake3.New()
+ _, _ = h.Write([]byte(model))
+ _, _ = h.Write([]byte{0})
+ _, _ = h.Write([]byte(text))
+ return h.Sum(nil)
+}
+
+// VKey is a document's vector key: the first 8 bytes of blake3(id),
+// little-endian, as a non-negative int64 (corpus.rs doc_key).
+func VKey(id string) int64 {
+ sum := blake3.Sum256([]byte(id))
+ return int64(binary.LittleEndian.Uint64(sum[:8]) & math.MaxInt64)
+}
+
+// Encode packs a vector as f16 little-endian, as the desktop stores it.
+func Encode(v []float32) []byte {
+ out := make([]byte, 2*len(v))
+ for i, x := range v {
+ binary.LittleEndian.PutUint16(out[2*i:], f32to16(x))
+ }
+ return out
+}
+
+// Decode unpacks an f16 little-endian vector.
+func Decode(b []byte) []float32 {
+ out := make([]float32, len(b)/2)
+ for i := range out {
+ out[i] = f16to32(binary.LittleEndian.Uint16(b[2*i:]))
+ }
+ return out
+}
+
+// Cosine of two vectors (unit vectors: their dot product).
+func Cosine(a, b []float32) float32 {
+ if len(a) != len(b) {
+ return 0
+ }
+ var dot, na, nb float64
+ for i := range a {
+ dot += float64(a[i]) * float64(b[i])
+ na += float64(a[i]) * float64(a[i])
+ nb += float64(b[i]) * float64(b[i])
+ }
+ if na == 0 || nb == 0 {
+ return 0
+ }
+ return float32(dot / math.Sqrt(na*nb))
+}
+
+// f32to16 rounds to nearest even, as half::f16::from_f32.
+func f32to16(f float32) uint16 {
+ b := math.Float32bits(f)
+ sign := uint16(b>>16) & 0x8000
+ exp := int((b >> 23) & 0xff)
+ man := b & 0x7fffff
+ switch {
+ case exp == 0xff: // inf, NaN
+ if man != 0 {
+ return sign | 0x7e00
+ }
+ return sign | 0x7c00
+ case exp > 142: // overflow
+ return sign | 0x7c00
+ case exp < 103: // underflow to zero
+ return sign
+ case exp < 113: // subnormal
+ man |= 0x800000
+ shift := uint(126 - exp)
+ half := uint32(1) << (shift - 1)
+ r := man >> shift
+ rem := man & ((1 << shift) - 1)
+ if rem > half || rem == half && r&1 == 1 {
+ r++
+ }
+ return sign | uint16(r) //nolint:gosec // G115: r < 0x400 here
+ }
+ h := uint32(exp-112)<<10 | man>>13
+ rem := man & 0x1fff
+ if rem > 0x1000 || rem == 0x1000 && h&1 == 1 {
+ h++
+ }
+ return sign | uint16(h) //nolint:gosec // G115: h ≤ 0x7c00 (exp ≤ 142)
+}
+
+func f16to32(h uint16) float32 {
+ sign := uint32(h&0x8000) << 16
+ exp := uint32(h>>10) & 0x1f
+ man := uint32(h & 0x3ff)
+ switch exp {
+ case 0:
+ if man == 0 {
+ return math.Float32frombits(sign)
+ }
+ // subnormal: normalise
+ e := uint32(113)
+ for man&0x400 == 0 {
+ man <<= 1
+ e--
+ }
+ man &= 0x3ff
+ return math.Float32frombits(sign | e<<23 | man<<13)
+ case 0x1f:
+ return math.Float32frombits(sign | 0x7f800000 | man<<13)
+ }
+ return math.Float32frombits(sign | (exp+112)<<23 | man<<13)
+}
diff --git a/internal/embed/model.go b/internal/embed/model.go
new file mode 100644
index 0000000..eb3a908
--- /dev/null
+++ b/internal/embed/model.go
@@ -0,0 +1,310 @@
+package embed
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os"
+ "runtime"
+ "sync"
+)
+
+// Config is the part of config.json the forward pass needs.
+type Config struct {
+ Hidden int `json:"hidden_size"`
+ Layers int `json:"num_hidden_layers"`
+ Heads int `json:"num_attention_heads"`
+ Intermediate int `json:"intermediate_size"`
+ MaxPositions int `json:"max_position_embeddings"`
+ Vocab int `json:"vocab_size"`
+ Eps float64 `json:"layer_norm_eps"`
+ Act string `json:"hidden_act"`
+}
+
+type linear struct {
+ w []float32 // out × in, row-major (PyTorch's layout)
+ b []float32
+ in int
+ out int
+}
+
+type layerNorm struct{ w, b []float32 }
+
+type layer struct {
+ q, k, v, o linear
+ attnNorm layerNorm
+ inter, output linear
+ outNorm layerNorm
+}
+
+// Model is a BERT encoder in float32 (as candle runs it on the CPU).
+type Model struct {
+ cfg Config
+ word, pos, typ []float32
+ embNorm layerNorm
+ layers []layer
+}
+
+// tensors reads a safetensors file: an 8-byte header length, a JSON header
+// of name → {dtype, shape, data_offsets}, then the data.
+func tensors(path string) (map[string][]float32, error) {
+ b, err := os.ReadFile(path) //nolint:gosec // G304: the model's own file
+ if err != nil {
+ return nil, err
+ }
+ if len(b) < 8 {
+ return nil, fmt.Errorf("%s: too short", path)
+ }
+ n := binary.LittleEndian.Uint64(b[:8])
+ if n > uint64(len(b)-8) { //nolint:gosec // G115: len(b) ≥ 8 here
+ return nil, fmt.Errorf("%s: bad header", path)
+ }
+ var hdr map[string]json.RawMessage
+ if err := json.Unmarshal(b[8:8+n], &hdr); err != nil {
+ return nil, err
+ }
+ data := b[8+n:]
+ out := map[string][]float32{}
+ for name, raw := range hdr {
+ if name == "__metadata__" {
+ continue
+ }
+ var t struct {
+ Dtype string `json:"dtype"`
+ Offsets [2]int64 `json:"data_offsets"`
+ }
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, err
+ }
+ if t.Dtype != "F32" {
+ continue // position_ids (I64) and the like
+ }
+ lo, hi := t.Offsets[0], t.Offsets[1]
+ if lo < 0 || hi > int64(len(data)) || lo > hi || (hi-lo)%4 != 0 {
+ return nil, fmt.Errorf("%s: bad offsets for %s", path, name)
+ }
+ f := make([]float32, (hi-lo)/4)
+ for i := range f {
+ f[i] = math.Float32frombits(binary.LittleEndian.Uint32(data[lo+int64(i)*4:]))
+ }
+ out[name] = f
+ }
+ return out, nil
+}
+
+// LoadModel reads config.json and model.safetensors.
+func LoadModel(configPath, weightsPath string) (*Model, error) {
+ cb, err := os.ReadFile(configPath) //nolint:gosec // G304: the model's own file
+ if err != nil {
+ return nil, err
+ }
+ var cfg Config
+ if err := json.Unmarshal(cb, &cfg); err != nil {
+ return nil, err
+ }
+ if cfg.Eps == 0 {
+ cfg.Eps = 1e-12
+ }
+ if cfg.Hidden == 0 || cfg.Heads == 0 || cfg.Hidden%cfg.Heads != 0 {
+ return nil, fmt.Errorf("config.json: bad sizes")
+ }
+ ts, err := tensors(weightsPath)
+ if err != nil {
+ return nil, err
+ }
+ var missing string
+ get := func(name string, size int) []float32 {
+ for _, n := range []string{name, "bert." + name} {
+ if t, ok := ts[n]; ok && len(t) == size {
+ return t
+ }
+ }
+ if missing == "" {
+ missing = name
+ }
+ return make([]float32, size)
+ }
+ h, in := cfg.Hidden, cfg.Intermediate
+ lin := func(p string, inDim, outDim int) linear {
+ return linear{w: get(p+".weight", inDim*outDim), b: get(p+".bias", outDim), in: inDim, out: outDim}
+ }
+ ln := func(p string) layerNorm { return layerNorm{w: get(p+".weight", h), b: get(p+".bias", h)} }
+ m := &Model{cfg: cfg,
+ word: get("embeddings.word_embeddings.weight", cfg.Vocab*h),
+ pos: get("embeddings.position_embeddings.weight", cfg.MaxPositions*h),
+ typ: get("embeddings.token_type_embeddings.weight", 2*h),
+ embNorm: ln("embeddings.LayerNorm"),
+ }
+ for i := range cfg.Layers {
+ p := fmt.Sprintf("encoder.layer.%d.", i)
+ m.layers = append(m.layers, layer{
+ q: lin(p+"attention.self.query", h, h), k: lin(p+"attention.self.key", h, h), v: lin(p+"attention.self.value", h, h),
+ o: lin(p+"attention.output.dense", h, h), attnNorm: ln(p + "attention.output.LayerNorm"),
+ inter: lin(p+"intermediate.dense", h, in), output: lin(p+"output.dense", in, h), outNorm: ln(p + "output.LayerNorm"),
+ })
+ }
+ if missing != "" {
+ return nil, fmt.Errorf("model.safetensors: no tensor %s", missing)
+ }
+ return m, nil
+}
+
+// Dim is the embedding size.
+func (m *Model) Dim() int { return m.cfg.Hidden }
+
+// apply computes y = x·Wᵀ + b for n rows, the rows split across cores.
+func (l linear) apply(x []float32, n int) []float32 {
+ y := make([]float32, n*l.out)
+ workers := min(runtime.GOMAXPROCS(0), max(l.out/32, 1))
+ var wg sync.WaitGroup
+ chunk := (l.out + workers - 1) / workers
+ for w := range workers {
+ lo, hi := w*chunk, min((w+1)*chunk, l.out)
+ if lo >= hi {
+ continue
+ }
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for o := lo; o < hi; o++ {
+ wr := l.w[o*l.in : (o+1)*l.in]
+ bias := l.b[o]
+ for r := range n {
+ y[r*l.out+o] = dot(x[r*l.in:(r+1)*l.in], wr) + bias
+ }
+ }
+ }()
+ }
+ wg.Wait()
+ return y
+}
+
+func dot(a, b []float32) float32 {
+ var s0, s1, s2, s3 float32
+ i := 0
+ for ; i+4 <= len(a); i += 4 {
+ s0 += a[i] * b[i]
+ s1 += a[i+1] * b[i+1]
+ s2 += a[i+2] * b[i+2]
+ s3 += a[i+3] * b[i+3]
+ }
+ for ; i < len(a); i++ {
+ s0 += a[i] * b[i]
+ }
+ return s0 + s1 + s2 + s3
+}
+
+func (ln layerNorm) apply(x []float32, n, h int, eps float64) {
+ for r := range n {
+ row := x[r*h : (r+1)*h]
+ var mean float64
+ for _, v := range row {
+ mean += float64(v)
+ }
+ mean /= float64(h)
+ var vr float64
+ for _, v := range row {
+ d := float64(v) - mean
+ vr += d * d
+ }
+ inv := 1 / math.Sqrt(vr/float64(h)+eps)
+ for i, v := range row {
+ row[i] = float32((float64(v)-mean)*inv)*ln.w[i] + ln.b[i]
+ }
+ }
+}
+
+func gelu(x float32) float32 {
+ return float32(0.5 * float64(x) * (1 + math.Erf(float64(x)/math.Sqrt2)))
+}
+
+// Forward runs the encoder on one sequence and returns the mean of its
+// token states (every token is real: one text, no padding), L2-normalised.
+func (m *Model) Forward(ids []int32) []float32 {
+ h, n := m.cfg.Hidden, len(ids)
+ if n == 0 {
+ return make([]float32, h)
+ }
+ x := make([]float32, n*h)
+ for t, id := range ids {
+ if int(id) >= m.cfg.Vocab || t >= m.cfg.MaxPositions {
+ continue
+ }
+ row := x[t*h : (t+1)*h]
+ for i := range h {
+ row[i] = m.word[int(id)*h+i] + m.pos[t*h+i] + m.typ[i]
+ }
+ }
+ eps := m.cfg.Eps
+ m.embNorm.apply(x, n, h, eps)
+ heads := m.cfg.Heads
+ hd := h / heads
+ scale := float32(1 / math.Sqrt(float64(hd)))
+ for _, L := range m.layers {
+ q, k, v := L.q.apply(x, n), L.k.apply(x, n), L.v.apply(x, n)
+ ctx := make([]float32, n*h)
+ var wg sync.WaitGroup
+ for hh := range heads {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ scores := make([]float32, n)
+ for i := range n {
+ qi := q[i*h+hh*hd : i*h+hh*hd+hd]
+ maxS := float32(math.Inf(-1))
+ for j := range n {
+ s := dot(qi, k[j*h+hh*hd:j*h+hh*hd+hd]) * scale
+ scores[j] = s
+ maxS = max(maxS, s)
+ }
+ var sum float32
+ for j := range n {
+ scores[j] = float32(math.Exp(float64(scores[j] - maxS)))
+ sum += scores[j]
+ }
+ out := ctx[i*h+hh*hd : i*h+hh*hd+hd]
+ for j := range n {
+ p := scores[j] / sum
+ vj := v[j*h+hh*hd : j*h+hh*hd+hd]
+ for d := range hd {
+ out[d] += p * vj[d]
+ }
+ }
+ }
+ }()
+ }
+ wg.Wait()
+ a := L.o.apply(ctx, n)
+ for i := range a {
+ a[i] += x[i]
+ }
+ L.attnNorm.apply(a, n, h, eps)
+ inter := L.inter.apply(a, n)
+ for i := range inter {
+ inter[i] = gelu(inter[i])
+ }
+ out := L.output.apply(inter, n)
+ for i := range out {
+ out[i] += a[i]
+ }
+ L.outNorm.apply(out, n, h, eps)
+ x = out
+ }
+ pooled := make([]float32, h)
+ for t := range n {
+ for i := range h {
+ pooled[i] += x[t*h+i]
+ }
+ }
+ var norm float64
+ for i := range pooled {
+ pooled[i] /= float32(n)
+ norm += float64(pooled[i]) * float64(pooled[i])
+ }
+ norm = math.Max(math.Sqrt(norm), 1e-12)
+ for i := range pooled {
+ pooled[i] = float32(float64(pooled[i]) / norm)
+ }
+ return pooled
+}
diff --git a/internal/embed/tokenizer.go b/internal/embed/tokenizer.go
new file mode 100644
index 0000000..098e2db
--- /dev/null
+++ b/internal/embed/tokenizer.go
@@ -0,0 +1,252 @@
+package embed
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "unicode"
+
+ "golang.org/x/text/unicode/norm"
+)
+
+// Tokenizer is the BERT WordPiece tokenizer of a tokenizer.json, as the
+// Hugging Face tokenizers crate runs it for all-MiniLM-L6-v2:
+// BertNormalizer (clean text, space out CJK, strip accents, lowercase),
+// BertPreTokenizer (whitespace and punctuation), WordPiece ("##", [UNK],
+// 100 chars a word), then [CLS] … [SEP], truncated to MaxTokens.
+type Tokenizer struct {
+ vocab map[string]int32
+ unk int32
+ cls, sep int32
+ special []string // added tokens, matched on the raw text first
+ maxWord int
+ prefix string
+}
+
+// MaxTokens is the desktop's truncation (atlas-embed spec max_tokens),
+// [CLS] and [SEP] included; tokenizer.json's own 128 is overridden.
+const MaxTokens = 512
+
+// LoadTokenizer reads tokenizer.json.
+func LoadTokenizer(path string) (*Tokenizer, error) {
+ b, err := os.ReadFile(path) //nolint:gosec // G304: the model's own file
+ if err != nil {
+ return nil, err
+ }
+ var tj struct {
+ Model struct {
+ Type string `json:"type"`
+ Vocab map[string]int32 `json:"vocab"`
+ Unk string `json:"unk_token"`
+ Prefix string `json:"continuing_subword_prefix"`
+ MaxWord int `json:"max_input_chars_per_word"`
+ } `json:"model"`
+ Added []struct {
+ Content string `json:"content"`
+ } `json:"added_tokens"`
+ }
+ if err := json.Unmarshal(b, &tj); err != nil {
+ return nil, fmt.Errorf("tokenizer.json: %w", err)
+ }
+ if tj.Model.Type != "WordPiece" || len(tj.Model.Vocab) == 0 {
+ return nil, fmt.Errorf("tokenizer.json: not a WordPiece tokenizer")
+ }
+ t := &Tokenizer{vocab: tj.Model.Vocab, prefix: tj.Model.Prefix, maxWord: tj.Model.MaxWord}
+ if t.prefix == "" {
+ t.prefix = "##"
+ }
+ if t.maxWord == 0 {
+ t.maxWord = 100
+ }
+ var ok [3]bool
+ t.unk, ok[0] = t.vocab[orDefault(tj.Model.Unk, "[UNK]")]
+ t.cls, ok[1] = t.vocab["[CLS]"]
+ t.sep, ok[2] = t.vocab["[SEP]"]
+ if !ok[0] || !ok[1] || !ok[2] {
+ return nil, fmt.Errorf("tokenizer.json: missing [UNK], [CLS] or [SEP]")
+ }
+ for _, a := range tj.Added {
+ t.special = append(t.special, a.Content)
+ }
+ return t, nil
+}
+
+func orDefault(s, d string) string {
+ if s == "" {
+ return d
+ }
+ return s
+}
+
+// Encode returns the token ids of one text: [CLS] tokens [SEP].
+func (t *Tokenizer) Encode(text string) []int32 {
+ ids := []int32{t.cls}
+ for _, seg := range t.splitSpecial(text) {
+ if seg.special {
+ if id, ok := t.vocab[seg.text]; ok {
+ ids = append(ids, id)
+ }
+ continue
+ }
+ for _, w := range preTokenize(normalize(seg.text)) {
+ ids = append(ids, t.wordPiece(w)...)
+ }
+ }
+ if len(ids) > MaxTokens-1 {
+ ids = ids[:MaxTokens-1]
+ }
+ return append(ids, t.sep)
+}
+
+type segment struct {
+ text string
+ special bool
+}
+
+// splitSpecial cuts out the added tokens ([CLS], [SEP], …) written in the
+// text itself; tokenizers matches them before normalizing.
+func (t *Tokenizer) splitSpecial(text string) []segment {
+ var out []segment
+ for len(text) > 0 {
+ at, which := -1, ""
+ for _, s := range t.special {
+ if i := strings.Index(text, s); i >= 0 && (at < 0 || i < at || i == at && len(s) > len(which)) {
+ at, which = i, s
+ }
+ }
+ if at < 0 {
+ out = append(out, segment{text: text})
+ break
+ }
+ if at > 0 {
+ out = append(out, segment{text: text[:at]})
+ }
+ out = append(out, segment{text: which, special: true})
+ text = text[at+len(which):]
+ }
+ return out
+}
+
+// normalize is BertNormalizer with clean_text, handle_chinese_chars,
+// strip_accents (following lowercase) and lowercase.
+func normalize(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ switch {
+ case r == 0 || r == 0xFFFD || isControl(r):
+ continue
+ case isWhitespace(r):
+ b.WriteByte(' ')
+ case isChinese(r):
+ b.WriteByte(' ')
+ b.WriteRune(r)
+ b.WriteByte(' ')
+ default:
+ b.WriteRune(r)
+ }
+ }
+ // Strip accents: NFD, then drop nonspacing marks.
+ var c strings.Builder
+ for _, r := range norm.NFD.String(b.String()) {
+ if !unicode.Is(unicode.Mn, r) {
+ c.WriteRune(r)
+ }
+ }
+ return strings.ToLower(c.String())
+}
+
+// isWhitespace is the normalizer's: tab, newline, return, and Unicode
+// White_Space (checked after control characters were dropped).
+func isWhitespace(r rune) bool {
+ switch r {
+ case '\t', '\n', '\r':
+ return true
+ }
+ return unicode.IsSpace(r)
+}
+
+// isControl is tokenizers' is_control: every "other" category except tab,
+// newline and carriage return (which are whitespace).
+func isControl(r rune) bool {
+ switch r {
+ case '\t', '\n', '\r':
+ return false
+ }
+ if unicode.In(r, unicode.Cc, unicode.Cf, unicode.Co, unicode.Cs) {
+ return true
+ }
+ // Cn, unassigned: in no other category.
+ return !unicode.In(r, unicode.L, unicode.M, unicode.N, unicode.P, unicode.S, unicode.Z)
+}
+
+func isChinese(r rune) bool {
+ return r >= 0x4E00 && r <= 0x9FFF || r >= 0x3400 && r <= 0x4DBF || r >= 0x20000 && r <= 0x2A6DF ||
+ r >= 0x2A700 && r <= 0x2B73F || r >= 0x2B740 && r <= 0x2B81F || r >= 0x2B920 && r <= 0x2CEAF ||
+ r >= 0xF900 && r <= 0xFAFF || r >= 0x2F800 && r <= 0x2FA1F
+}
+
+// isPunct is BERT's punctuation: ASCII punctuation, or Unicode P*.
+func isPunct(r rune) bool {
+ if r < 128 {
+ return r >= 33 && r <= 47 || r >= 58 && r <= 64 || r >= 91 && r <= 96 || r >= 123 && r <= 126
+ }
+ return unicode.Is(unicode.P, r)
+}
+
+// preTokenize splits on whitespace (dropped) and punctuation (kept, one
+// character a word).
+func preTokenize(s string) []string {
+ var out []string
+ var cur strings.Builder
+ flush := func() {
+ if cur.Len() > 0 {
+ out = append(out, cur.String())
+ cur.Reset()
+ }
+ }
+ for _, r := range s {
+ switch {
+ case unicode.IsSpace(r):
+ flush()
+ case isPunct(r):
+ flush()
+ out = append(out, string(r))
+ default:
+ cur.WriteRune(r)
+ }
+ }
+ flush()
+ return out
+}
+
+// wordPiece is greedy longest-match-first; a word with a piece not in the
+// vocabulary, or longer than maxWord characters, is [UNK].
+func (t *Tokenizer) wordPiece(word string) []int32 {
+ rs := []rune(word)
+ if len(rs) > t.maxWord {
+ return []int32{t.unk}
+ }
+ var ids []int32
+ for start := 0; start < len(rs); {
+ end := len(rs)
+ found := int32(-1)
+ for end > start {
+ sub := string(rs[start:end])
+ if start > 0 {
+ sub = t.prefix + sub
+ }
+ if id, ok := t.vocab[sub]; ok {
+ found = id
+ break
+ }
+ end--
+ }
+ if found < 0 {
+ return []int32{t.unk}
+ }
+ ids = append(ids, found)
+ start = end
+ }
+ return ids
+}
diff --git a/internal/hooks/antigravity/antigravity.go b/internal/hooks/antigravity/antigravity.go
index ee09ebb..fd04848 100644
--- a/internal/hooks/antigravity/antigravity.go
+++ b/internal/hooks/antigravity/antigravity.go
@@ -7,8 +7,11 @@
// names the model (modelName); the transcript is written step by step, the
// USER_INPUT step before PreInvocation fires and the last PLANNER_RESPONSE
// before Stop (older releases wrote it after Stop, which Settle still
-// tolerates); and each PLANNER_RESPONSE step carries input_tokens,
-// output_tokens and cache_read_tokens.
+// tolerates); each PLANNER_RESPONSE step carries input_tokens,
+// output_tokens and cache_read_tokens; and PreToolUse/PostToolUse carry the
+// call (toolCall) and stepIdx, the index of the step its output will be, one
+// after the PLANNER_RESPONSE that made it, which is not yet on disk when
+// PostToolUse fires, so no hook sees the tool's output.
package antigravity
import (
@@ -18,7 +21,9 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strconv"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -37,6 +42,7 @@ type Agent struct{}
const (
VerbPreInvocation = "pre-invocation"
VerbPreToolUse = "pre-tool-use"
+ VerbPostToolUse = "post-tool-use"
VerbStop = "stop"
)
@@ -55,7 +61,9 @@ func (Agent) Name() string { return "Antigravity" }
func (Agent) Binary() string { return "agy" }
// Verbs are the hook verbs the installed config invokes.
-func (Agent) Verbs() []string { return []string{VerbPreInvocation, VerbPreToolUse, VerbStop} }
+func (Agent) Verbs() []string {
+ return []string{VerbPreInvocation, VerbPreToolUse, VerbPostToolUse, VerbStop}
+}
// HooksPath is the workspace hooks file agy loads for a trusted workspace.
func HooksPath(root string) string { return filepath.Join(root, ".agents", "hooks.json") }
@@ -89,6 +97,9 @@ func render(disabled bool) entry {
PreToolUse: []toolHandler{{Matcher: "*", Hooks: []hooks.CommandHook{
{Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbPreToolUse))},
}}},
+ PostToolUse: []toolHandler{{Matcher: "*", Hooks: []hooks.CommandHook{
+ {Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbPostToolUse))},
+ }}},
PreInvocation: []plainHandler{{Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbPreInvocation))}},
Stop: []plainHandler{{Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbStop)), Timeout: stopTimeout}},
}
@@ -181,9 +192,11 @@ func (Agent) Response(hooks.Kind, string) []byte { return nil }
func (Agent) ResumeCommand(id string) string { return "agy --conversation " + id }
// payload is the union of the fields read from agy's hook stdin. agy also
-// sends artifactDirectoryPath, stepIdx, initialNumSteps, executionNum,
-// terminationReason and error; only the ones below are used. modelName
-// (e.g. "gemini-3.1-pro-low") is in every hook's payload on agy 1.3.3.
+// sends artifactDirectoryPath, initialNumSteps, executionNum and
+// terminationReason; only the ones below are used. modelName (e.g.
+// "gemini-3.1-pro-low") is in every hook's payload on agy 1.3.3. stepIdx
+// (Pre/PostToolUse) is the index of the step the tool's output will be
+// written as; error (PostToolUse, Stop) is "" unless the tool or run failed.
type payload struct {
ConversationID string `json:"conversationId"`
TranscriptPath string `json:"transcriptPath"`
@@ -191,19 +204,23 @@ type payload struct {
InvocationNum int `json:"invocationNum"`
ModelName string `json:"modelName"`
FullyIdle bool `json:"fullyIdle"`
- ToolCall struct {
- Name string `json:"name"`
- Args json.RawMessage `json:"args"`
- } `json:"toolCall"`
+ StepIdx *int `json:"stepIdx"`
+ Error string `json:"error"`
+ ToolCall toolCall `json:"toolCall"`
}
// Parse maps a hook verb and payload to an event:
//
-// - pre-invocation → TurnStart with no prompt (the dispatcher reads it
-// from the transcript at turn end); agy fires one per model call, and a
-// follow-up call within an open turn must not restart it;
+// - pre-invocation → TurnStart. On the first model call of a turn the
+// prompt is already in the transcript the payload names and no model
+// step follows it, so it is read from there and opens the turn at once;
+// a follow-up call within the turn (the transcript ends with model
+// steps) carries no prompt, and the dispatcher keeps the turn open;
// - pre-tool-use → ToolUse with the target file of a write tool, nothing
// for any other tool;
+// - post-tool-use → ToolUse with the completed call, keyed like the
+// transcript reader keys it so the turn-end read does not record it
+// twice; the payload has no output, only an error when the call failed;
// - stop → TurnEnd when fullyIdle is true, nothing while background work
// continues.
func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
@@ -220,13 +237,26 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
switch verb {
case VerbPreInvocation:
ev.Kind = hooks.TurnStart
+ if p.TranscriptPath != "" {
+ ev.Prompt = pendingPrompt(p.TranscriptPath)
+ }
case VerbPreToolUse:
- files := writeTargets(p.ToolCall.Name, p.ToolCall.Args)
+ files := p.ToolCall.writeTargets()
if len(files) == 0 {
return nil, nil
}
ev.Kind = hooks.ToolUse
ev.Files = files
+ case VerbPostToolUse:
+ if p.ToolCall.Name == "" || p.StepIdx == nil || *p.StepIdx < 1 {
+ return nil, nil
+ }
+ ev.Kind = hooks.ToolUse
+ ev.Tool = p.ToolCall.tool(callID(p.TranscriptPath, *p.StepIdx, p.ToolCall))
+ if p.Error != "" {
+ msg := p.Error
+ ev.Tool.Status, ev.Tool.Result = "failed", &msg
+ }
case VerbStop:
if !p.FullyIdle {
return nil, nil
@@ -269,21 +299,63 @@ var toolKinds = map[string]string{
}
// writeTargets is the file a write tool call names, or nothing.
-func writeTargets(name string, args json.RawMessage) []string {
- if !writeTools[name] {
- return nil
- }
- var m map[string]json.RawMessage
- if json.Unmarshal(args, &m) != nil {
+func (tc toolCall) writeTargets() []string {
+ if !writeTools[tc.Name] {
return nil
}
- target, _ := decodeString(m["TargetFile"])
+ target, _ := decodeString(tc.Args["TargetFile"])
if target == "" {
return nil
}
return []string{target}
}
+// callID is the id the transcript reader gives the call whose output step
+// will be stepIdx: the PLANNER_RESPONSE step that made it (the last one
+// before stepIdx, on disk before the tool runs) and the call's index in it.
+// A planner step may hold several calls; their outputs follow it one step
+// each in call order, so the index is the distance from the planner step.
+// With no readable transcript or no step that fits, the call is taken as
+// the only one of the step before.
+func callID(path string, stepIdx int, tc toolCall) string {
+ fallback := strconv.Itoa(stepIdx-1) + "-0"
+ if path == "" {
+ return fallback
+ }
+ var planner *step
+ err := eachNonBlankLine(path, func(_ int, raw []byte) {
+ var s step
+ if json.Unmarshal(raw, &s) != nil || s.Type != "PLANNER_RESPONSE" || s.StepIndex >= stepIdx {
+ return
+ }
+ planner = &s
+ })
+ if err != nil || planner == nil {
+ return fallback
+ }
+ i := stepIdx - planner.StepIndex - 1
+ if i < 0 || i >= len(planner.ToolCalls) || planner.ToolCalls[i].Name != tc.Name {
+ return fallback
+ }
+ return strconv.Itoa(planner.StepIndex) + "-" + strconv.Itoa(i)
+}
+
+// tool is the call as the recorder takes it, under id: the ACP kind the
+// alias table cannot derive from agy's snake_case names, the arguments with
+// their double encoding undone, and the target file of a write tool as its
+// location. The hook payload and the transcript describe one call the same
+// way, so the dispatcher's keys (id, name + arguments, written path) match.
+func (tc toolCall) tool(id string) *record.Tool {
+ return &record.Tool{
+ NativeID: id,
+ Name: tc.Name,
+ Kind: toolKinds[tc.Name],
+ Arguments: decodeArgs(tc.Args),
+ Locations: tc.writeTargets(),
+ Status: "completed",
+ }
+}
+
// decodeString reads a string arg in either of agy's shapes: a plain JSON
// string, or (its wire format) a JSON string holding a JSON-encoded string.
// ok is false when the value is not a string at all.
diff --git a/internal/hooks/antigravity/antigravity_test.go b/internal/hooks/antigravity/antigravity_test.go
index e52c654..e23a157 100644
--- a/internal/hooks/antigravity/antigravity_test.go
+++ b/internal/hooks/antigravity/antigravity_test.go
@@ -1,12 +1,15 @@
package antigravity
import (
+ "bytes"
"encoding/json"
+ "fmt"
"os"
"path/filepath"
"strings"
"testing"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -63,12 +66,14 @@ func TestInstallIntoEmpty(t *testing.T) {
if len(file) != 1 {
t.Fatalf("file has %d keys: %v", len(file), file)
}
+ type group struct {
+ Matcher string `json:"matcher"`
+ Hooks []hooks.CommandHook `json:"hooks"`
+ }
var e struct {
- Enabled *bool `json:"enabled"`
- PreToolUse []struct {
- Matcher string `json:"matcher"`
- Hooks []hooks.CommandHook `json:"hooks"`
- } `json:"PreToolUse"`
+ Enabled *bool `json:"enabled"`
+ PreToolUse []group `json:"PreToolUse"`
+ PostToolUse []group `json:"PostToolUse"`
PreInvocation []hooks.CommandHook `json:"PreInvocation"`
Stop []hooks.CommandHook `json:"Stop"`
}
@@ -83,13 +88,18 @@ func TestInstallIntoEmpty(t *testing.T) {
e.PreToolUse[0].Hooks[0].Type != "command" {
t.Fatalf("PreToolUse = %+v", e.PreToolUse)
}
+ if len(e.PostToolUse) != 1 || e.PostToolUse[0].Matcher != "*" || len(e.PostToolUse[0].Hooks) != 1 ||
+ e.PostToolUse[0].Hooks[0].Command != hooks.Wrap("atlas hooks antigravity post-tool-use") ||
+ e.PostToolUse[0].Hooks[0].Type != "command" {
+ t.Fatalf("PostToolUse = %+v", e.PostToolUse)
+ }
if len(e.PreInvocation) != 1 || e.PreInvocation[0].Command != hooks.Wrap("atlas hooks antigravity pre-invocation") {
t.Fatalf("PreInvocation = %+v", e.PreInvocation)
}
if len(e.Stop) != 1 || e.Stop[0].Command != hooks.Wrap("atlas hooks antigravity stop") || e.Stop[0].Timeout != 300 {
t.Fatalf("Stop = %+v", e.Stop)
}
- for _, h := range []string{e.PreToolUse[0].Hooks[0].Command, e.PreInvocation[0].Command, e.Stop[0].Command} {
+ for _, h := range []string{e.PreToolUse[0].Hooks[0].Command, e.PostToolUse[0].Hooks[0].Command, e.PreInvocation[0].Command, e.Stop[0].Command} {
if !hooks.Owned(h) {
t.Fatalf("not owned: %q", h)
}
@@ -133,7 +143,7 @@ func TestInstallKeepsOtherEntriesAndDisabled(t *testing.T) {
if len(e.Stop) != 1 || e.Stop[0].Command != hooks.Wrap("atlas hooks antigravity stop") {
t.Fatalf("old Stop hook survived: %+v", e.Stop)
}
- if len(e.PreToolUse) != 1 || len(e.PreInvocation) != 1 {
+ if len(e.PreToolUse) != 1 || len(e.PostToolUse) != 1 || len(e.PreInvocation) != 1 {
t.Fatalf("entry = %+v", e)
}
if a.State(root) != hooks.Current {
@@ -217,7 +227,9 @@ func TestParse(t *testing.T) {
{"write without target", VerbPreToolUse, `{"toolCall":{"name":"write_to_file","args":{"Overwrite":"true"}},` + common + `}`, 0, nil},
{"stop busy", VerbStop, `{"executionNum":1,"terminationReason":"model_stop","error":"","fullyIdle":false,` + common + `}`, 0, nil},
{"stop idle", VerbStop, `{"executionNum":1,"terminationReason":"model_stop","error":"","fullyIdle":true,` + common + `}`, hooks.TurnEnd, nil},
- {"unknown verb", "post-tool-use", `{` + common + `}`, 0, nil},
+ {"post without a step", VerbPostToolUse, `{"toolCall":{"name":"run_command","args":{"CommandLine":"ls"}},"error":"",` + common + `}`, 0, nil},
+ {"post without a call", VerbPostToolUse, `{"stepIdx":2,"error":"",` + common + `}`, 0, nil},
+ {"unknown verb", "post-invocation", `{"invocationNum":0,` + common + `}`, 0, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -255,3 +267,155 @@ func TestParse(t *testing.T) {
t.Fatalf("empty payload = %+v, %v", ev, err)
}
}
+
+// postToolUse is the payload agy 1.3.3 sends after a tool ran (captured
+// 2026-10-12): the call, stepIdx (the index of the step its output will be
+// written as), an empty error, and no output.
+const postToolUse = `{"artifactDirectoryPath":"/home/u/.gemini/antigravity-cli/brain/ec33ebf9","error":"","stepIdx":%d,"toolCall":{"args":%s,"name":"%s"},` + common + `}`
+
+func TestParsePostToolUse(t *testing.T) {
+ a := Agent{}
+ writeArgs := `{"Append":false,"CodeContent":"probe","Description":"Create probe.txt","Overwrite":false,"TargetFile":"/workspace/project/probe.txt","toolAction":"Creating probe.txt","toolSummary":"File creation"}`
+ ev, err := a.Parse(VerbPostToolUse, []byte(fmt.Sprintf(postToolUse, 2, writeArgs, "write_to_file")))
+ if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.Tool == nil {
+ t.Fatalf("event = %+v, %v", ev, err)
+ }
+ if ev.SessionID != "ec33ebf9-0cba-4100-8142-c61503f6c587" || ev.Cwd != "/workspace/project" || ev.Model != "gemini-3.1-pro-low" || ev.Prompt != "" || len(ev.Files) != 0 {
+ t.Fatalf("event = %+v", ev)
+ }
+ w := ev.Tool
+ if w.NativeID != "1-0" || w.Name != "write_to_file" || w.Kind != "edit" || w.Status != "completed" || w.Result != nil ||
+ len(w.Locations) != 1 || w.Locations[0] != "/workspace/project/probe.txt" {
+ t.Fatalf("tool = %+v", w)
+ }
+ var args map[string]any
+ if err := json.Unmarshal(w.Arguments, &args); err != nil {
+ t.Fatal(err)
+ }
+ if args["Append"] != false || args["CodeContent"] != "probe" || args["TargetFile"] != "/workspace/project/probe.txt" {
+ t.Fatalf("args = %s", w.Arguments)
+ }
+ if name := record.CanonicalToolName(w.Name, w.Title, w.Kind, w.Arguments); name != "Write" {
+ t.Fatalf("canonical name = %q, want Write", name)
+ }
+
+ // The same call as the transcript records it: same id, arguments and
+ // location, so the dispatcher's keys match and it is not recorded twice.
+ path := filepath.Join(t.TempDir(), "transcript_full.jsonl")
+ line := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nmake it\n"}` + "\n" +
+ `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"write_to_file","args":` + writeArgs + `}]}` + "\n" +
+ `{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","content":"Created"}` + "\n"
+ if err := os.WriteFile(path, []byte(line), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ turn, _, err := reader{}.Read(path, 0)
+ if err != nil || len(turn.Items) != 1 || turn.Items[0].Tool == nil {
+ t.Fatalf("Read = %+v, %v", turn, err)
+ }
+ r := turn.Items[0].Tool
+ if r.NativeID != w.NativeID || r.Name != w.Name || r.Kind != w.Kind || !bytes.Equal(r.Arguments, w.Arguments) ||
+ strings.Join(r.Locations, ",") != strings.Join(w.Locations, ",") {
+ t.Fatalf("hook tool %+v differs from transcript tool %+v", w, r)
+ }
+
+ // Two calls in one planner step (seen live: write_to_file and
+ // run_command together in step 1, outputs at steps 2 and 3): the hook's
+ // stepIdx names the output step, and the id follows the reader's.
+ two := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nboth\n"}` + "\n" +
+ `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"write_to_file","args":` + writeArgs + `},{"name":"run_command","args":{"CommandLine":"cat probe.txt","Cwd":"/workspace/project"}}]}` + "\n"
+ if err := os.WriteFile(path, []byte(two), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ withPath := strings.Replace(postToolUse, `"transcriptPath":"/home/u/.gemini/antigravity-cli/brain/ec33ebf9/.system_generated/logs/transcript_full.jsonl"`, `"transcriptPath":%q`, 1)
+ for _, c := range []struct {
+ stepIdx int
+ name, args string
+ id string
+ }{
+ {2, "write_to_file", writeArgs, "1-0"},
+ {3, "run_command", `{"CommandLine":"cat probe.txt","Cwd":"/workspace/project"}`, "1-1"},
+ {4, "run_command", `{"CommandLine":"ls"}`, "3-0"}, // no call at that index: the step before
+ {3, "view_file", `{"AbsolutePath":"/x"}`, "2-0"}, // a different call there: the step before
+ } {
+ ev, err := a.Parse(VerbPostToolUse, []byte(fmt.Sprintf(withPath, c.stepIdx, c.args, c.name, path)))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.NativeID != c.id {
+ t.Fatalf("stepIdx %d %s: event = %+v, %v; want id %s", c.stepIdx, c.name, ev, err, c.id)
+ }
+ }
+ turn, _, err = reader{}.Read(path, 0)
+ if err != nil || len(turn.Items) != 2 || turn.Items[0].Tool.NativeID != "1-0" || turn.Items[1].Tool.NativeID != "1-1" {
+ t.Fatalf("reader ids = %+v, %v", turn, err)
+ }
+
+ // A shell call: execute kind, wire-shape (double encoded) args undone,
+ // and a failure reported in the payload's error.
+ raw := fmt.Sprintf(postToolUse, 4, `{"CommandLine":"\"cat probe.txt\"","Cwd":"/workspace/project","IsDaemon":false,"WaitMsBeforeAsync":5000}`, "run_command")
+ raw = strings.Replace(raw, `"error":""`, `"error":"exit status 1"`, 1)
+ ev, err = a.Parse(VerbPostToolUse, []byte(raw))
+ if err != nil || ev == nil || ev.Tool == nil {
+ t.Fatalf("event = %+v, %v", ev, err)
+ }
+ run := ev.Tool
+ if run.NativeID != "3-0" || run.Kind != "execute" || run.Status != "failed" || run.Result == nil || *run.Result != "exit status 1" || run.Locations != nil {
+ t.Fatalf("tool = %+v", run)
+ }
+ if err := json.Unmarshal(run.Arguments, &args); err != nil {
+ t.Fatal(err)
+ }
+ if args["CommandLine"] != "cat probe.txt" || args["WaitMsBeforeAsync"] != float64(5000) {
+ t.Fatalf("args = %s", run.Arguments)
+ }
+ if name := record.CanonicalToolName(run.Name, run.Title, run.Kind, run.Arguments); name != "Bash" {
+ t.Fatalf("canonical name = %q, want Bash", name)
+ }
+}
+
+// TestParsePreInvocationPrompt: the first invocation of a turn finds the
+// prompt step already in the transcript and opens the turn with it; a
+// follow-up invocation, whose transcript ends with the model's steps, does
+// not; a transcript that is not there yet gives no prompt either.
+func TestParsePreInvocationPrompt(t *testing.T) {
+ a := Agent{}
+ path := filepath.Join(t.TempDir(), "transcript_full.jsonl")
+ payload := func(n int) []byte {
+ return []byte(fmt.Sprintf(`{"invocationNum":%d,"initialNumSteps":1,"conversationId":"c1","modelName":"m","workspacePaths":["/w"],"transcriptPath":%q}`, n, path))
+ }
+ ev, err := a.Parse(VerbPreInvocation, payload(0))
+ if err != nil || ev == nil || ev.Kind != hooks.TurnStart || ev.Prompt != "" {
+ t.Fatalf("no transcript yet: %+v, %v", ev, err)
+ }
+
+ prompt := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nCreate rt.txt, then run: cat rt.txt\n\n\nnow\n"}` + "\n"
+ if err := os.WriteFile(path, []byte(prompt), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ ev, err = a.Parse(VerbPreInvocation, payload(0))
+ if err != nil || ev == nil || ev.Kind != hooks.TurnStart || ev.Prompt != "Create rt.txt, then run: cat rt.txt" {
+ t.Fatalf("first invocation = %+v, %v", ev, err)
+ }
+ if ev.SessionID != "c1" || ev.Transcript != path || ev.Cwd != "/w" || ev.Model != "m" {
+ t.Fatalf("event = %+v", ev)
+ }
+
+ model := `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"run_command","args":{"CommandLine":"cat rt.txt"}}]}` + "\n" +
+ `{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","content":"rt"}` + "\n"
+ if err := os.WriteFile(path, []byte(prompt+model), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ ev, err = a.Parse(VerbPreInvocation, payload(1))
+ if err != nil || ev == nil || ev.Kind != hooks.TurnStart || ev.Prompt != "" {
+ t.Fatalf("follow-up invocation = %+v, %v", ev, err)
+ }
+
+ // The next turn's prompt, after a finished one (and agy's bookkeeping
+ // step after it), is the pending one.
+ next := prompt + model + `{"step_index":3,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nagain\n"}` + "\n" +
+ `{"step_index":4,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE"}` + "\n"
+ if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ ev, err = a.Parse(VerbPreInvocation, payload(0))
+ if err != nil || ev == nil || ev.Prompt != "again" {
+ t.Fatalf("second turn = %+v, %v", ev, err)
+ }
+}
diff --git a/internal/hooks/antigravity/state_main_test.go b/internal/hooks/antigravity/state_main_test.go
new file mode 100644
index 0000000..f6879e2
--- /dev/null
+++ b/internal/hooks/antigravity/state_main_test.go
@@ -0,0 +1,15 @@
+package antigravity
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/antigravity/transcript.go b/internal/hooks/antigravity/transcript.go
index b8a25d3..4d5cebc 100644
--- a/internal/hooks/antigravity/transcript.go
+++ b/internal/hooks/antigravity/transcript.go
@@ -13,7 +13,6 @@ import (
"strings"
"time"
- "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -119,6 +118,31 @@ func prompt(content string) string {
return strings.TrimSpace(content)
}
+// pendingPrompt is the prompt of a USER_INPUT step that no model step
+// follows: the turn agy is about to start answering when PreInvocation
+// fires, since agy writes the prompt step before the hook. A transcript
+// whose last steps are the model's (a follow-up invocation within a turn),
+// or none at all, gives "".
+func pendingPrompt(path string) string {
+ var pending string
+ err := eachNonBlankLine(path, func(_ int, raw []byte) {
+ var s step
+ if json.Unmarshal(raw, &s) != nil {
+ return
+ }
+ switch {
+ case s.Type == "USER_INPUT":
+ pending = prompt(s.Content)
+ case s.Source == "MODEL":
+ pending = ""
+ }
+ })
+ if err != nil {
+ return ""
+ }
+ return pending
+}
+
// Read parses the steps from a position into a turn. A step agy marks as
// truncated is still read for what it holds. Usage is the sum of the steps'
// token fields when any step has them. No model: the transcript names none
@@ -154,20 +178,8 @@ func (reader) Read(path string, from int) (*hooks.Turn, int, error) {
if tc.Name == "" {
continue
}
- tool := &record.Tool{
- NativeID: id + "-" + strconv.Itoa(i),
- Name: tc.Name,
- Kind: toolKinds[tc.Name],
- Arguments: decodeArgs(tc.Args),
- Status: "completed",
- }
- if writeTools[tc.Name] {
- if target, _ := decodeString(tc.Args["TargetFile"]); target != "" {
- tool.Locations = []string{target}
- }
- }
open = append(open, len(t.Items))
- t.Items = append(t.Items, hooks.Item{Tool: tool})
+ t.Items = append(t.Items, hooks.Item{Tool: tc.tool(id + "-" + strconv.Itoa(i))})
}
case "CONVERSATION_HISTORY", "SYSTEM_MESSAGE":
// agy's own bookkeeping and injected notices.
diff --git a/internal/hooks/antigravity/transcript_test.go b/internal/hooks/antigravity/transcript_test.go
index 31d28dd..d37209c 100644
--- a/internal/hooks/antigravity/transcript_test.go
+++ b/internal/hooks/antigravity/transcript_test.go
@@ -249,6 +249,35 @@ func TestReadFromOffset(t *testing.T) {
}
}
+func TestPendingPrompt(t *testing.T) {
+ if got := pendingPrompt(filepath.Join(t.TempDir(), "missing.jsonl")); got != "" {
+ t.Fatalf("missing transcript = %q", got)
+ }
+ // Both fixture turns are answered: nothing pending.
+ path := writeFixture(t)
+ if got := pendingPrompt(path); got != "" {
+ t.Fatalf("answered transcript = %q", got)
+ }
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = f.WriteString(`{"step_index":12,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nnext\n"}` + "\n" +
+ `{"step_index":13,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE"}` + "\n")
+ _ = f.Close()
+ if got := pendingPrompt(path); got != "next" {
+ t.Fatalf("pending = %q, want next", got)
+ }
+ // The live shape, cut after the prompt step as PreInvocation sees it.
+ live1 := filepath.Join(t.TempDir(), "transcript_full.jsonl")
+ if err := os.WriteFile(live1, []byte(strings.SplitAfter(live, "\n")[0]), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if got := pendingPrompt(live1); got != "Create a file named note.txt containing the word hello" {
+ t.Fatalf("live pending = %q", got)
+ }
+}
+
func TestPromptUnwrap(t *testing.T) {
if got := prompt("no wrapper here "); got != "no wrapper here" {
t.Fatalf("prompt = %q", got)
diff --git a/internal/hooks/claude/claude.go b/internal/hooks/claude/claude.go
index 3a524fb..466dac1 100644
--- a/internal/hooks/claude/claude.go
+++ b/internal/hooks/claude/claude.go
@@ -3,11 +3,13 @@
package claude
import (
+ "bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -27,6 +29,7 @@ const (
VerbStopFailure = "stop-failure"
VerbPreCompact = "pre-compact"
VerbSessionEnd = "session-end"
+ VerbPostToolUse = "post-tool-use"
)
// ID is the registry key.
@@ -40,7 +43,7 @@ func (Agent) Binary() string { return "claude" }
// Verbs are the hook verbs the installed config invokes.
func (Agent) Verbs() []string {
- return []string{VerbSessionStart, VerbPromptSubmit, VerbStop, VerbStopFailure, VerbPreCompact, VerbSessionEnd}
+ return []string{VerbSessionStart, VerbPromptSubmit, VerbPostToolUse, VerbStop, VerbStopFailure, VerbPreCompact, VerbSessionEnd}
}
// SettingsPath is the local (per-machine, uncommitted) Claude settings
@@ -53,6 +56,7 @@ func (a Agent) shaped(root string) hooks.ClaudeShaped {
return hooks.ClaudeShaped{Path: SettingsPath(root), Types: []hooks.ClaudeHook{
{Type: "SessionStart", Command: hooks.WrapWithMessage(hooks.Command(ID, VerbSessionStart), hooks.MissingMessage)},
{Type: "UserPromptSubmit", Command: hooks.Wrap(hooks.Command(ID, VerbPromptSubmit))},
+ {Type: "PostToolUse", Command: hooks.Wrap(hooks.Command(ID, VerbPostToolUse))},
{Type: "Stop", Command: hooks.Wrap(hooks.Command(ID, VerbStop))},
{Type: "StopFailure", Command: hooks.Wrap(hooks.Command(ID, VerbStopFailure))},
{Type: "PreCompact", Command: hooks.Wrap(hooks.Command(ID, VerbPreCompact))},
@@ -95,12 +99,33 @@ func (Agent) ResumeCommand(id string) string { return "claude -r " + id }
// payload is the common shape of Claude Code hook input.
type payload struct {
- SessionID string `json:"session_id"`
- TranscriptPath string `json:"transcript_path"`
- Cwd string `json:"cwd"`
- Model string `json:"model"`
- Prompt string `json:"prompt"`
- Source string `json:"source"`
+ SessionID string `json:"session_id"`
+ TranscriptPath string `json:"transcript_path"`
+ Cwd string `json:"cwd"`
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ Source string `json:"source"`
+ ToolName string `json:"tool_name"`
+ ToolUseID string `json:"tool_use_id"`
+ ToolInput json.RawMessage `json:"tool_input"`
+ ToolResponse json.RawMessage `json:"tool_response"`
+}
+
+// toolOf is the completed call a PostToolUse payload describes. The
+// response is a string or an object; either is kept as text.
+func (p payload) toolOf() *record.Tool {
+ if p.ToolUseID == "" || p.ToolName == "" {
+ return nil
+ }
+ t := &record.Tool{NativeID: p.ToolUseID, Name: p.ToolName, Arguments: p.ToolInput, Status: "completed"}
+ if r := bytes.TrimSpace(p.ToolResponse); len(r) > 0 && string(r) != "null" {
+ var str string
+ if json.Unmarshal(r, &str) != nil {
+ str = string(r)
+ }
+ t.Result = &str
+ }
+ return t
}
// Parse maps a hook verb and payload to an event.
@@ -123,6 +148,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
case VerbStopFailure:
ev.Kind = hooks.TurnEnd
ev.TurnEndFailed = true
+ case VerbPostToolUse:
+ ev.Kind = hooks.ToolUse
+ ev.Tool = p.toolOf()
+ if ev.Tool == nil {
+ return nil, nil
+ }
case VerbPreCompact:
ev.Kind = hooks.Compaction
case VerbSessionEnd:
diff --git a/internal/hooks/claude/claude_test.go b/internal/hooks/claude/claude_test.go
index f57651e..bf5c8f1 100644
--- a/internal/hooks/claude/claude_test.go
+++ b/internal/hooks/claude/claude_test.go
@@ -28,6 +28,15 @@ func TestParse(t *testing.T) {
t.Fatal("stop-failure must mark the turn failed")
}
}
+ post := `{"session_id":"abc","transcript_path":"/t/abc.jsonl","cwd":"/w","hook_event_name":"PostToolUse","tool_name":"Write","tool_use_id":"toolu_01","tool_input":{"file_path":"/w/x.txt","content":"hi"},"tool_response":{"filePath":"/w/x.txt","success":true}}`
+ ev, err = a.Parse(VerbPostToolUse, []byte(post))
+ if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil || ev.Tool.NativeID != "toolu_01" || ev.Tool.Name != "Write" ||
+ string(ev.Tool.Arguments) != `{"file_path":"/w/x.txt","content":"hi"}` || ev.Tool.Result == nil || !strings.Contains(*ev.Tool.Result, "success") {
+ t.Fatalf("post-tool-use → %+v %v", ev, err)
+ }
+ if ev, _ := a.Parse(VerbPostToolUse, []byte(`{"session_id":"abc"}`)); ev != nil {
+ t.Fatal("a tool payload without a call must be nil")
+ }
if ev, err := a.Parse("unknown", []byte(payload)); ev != nil || err != nil {
t.Fatal("unknown verb must be nil, nil")
}
@@ -67,7 +76,7 @@ func TestInstallWritesTheLocalSettings(t *testing.T) {
if err := json.Unmarshal(b, &obj); err != nil {
t.Fatal(err)
}
- for _, typ := range []string{"SessionStart", "UserPromptSubmit", "Stop", "StopFailure", "PreCompact", "SessionEnd"} {
+ for _, typ := range []string{"SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "StopFailure", "PreCompact", "SessionEnd"} {
g := obj.Hooks[typ]
if len(g) != 1 || len(g[0].Hooks) != 1 || g[0].Hooks[0].Type != "command" || !hooks.Owned(g[0].Hooks[0].Command) {
t.Fatalf("%s: %+v", typ, g)
diff --git a/internal/hooks/claude/state_main_test.go b/internal/hooks/claude/state_main_test.go
new file mode 100644
index 0000000..c027a41
--- /dev/null
+++ b/internal/hooks/claude/state_main_test.go
@@ -0,0 +1,15 @@
+package claude
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/codex/codex.go b/internal/hooks/codex/codex.go
index 4b3ad66..e291f5a 100644
--- a/internal/hooks/codex/codex.go
+++ b/internal/hooks/codex/codex.go
@@ -3,6 +3,7 @@
package codex
import (
+ "bytes"
"encoding/json"
"os"
"os/exec"
@@ -12,6 +13,7 @@ import (
"unicode/utf16"
"unicode/utf8"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -111,6 +113,7 @@ type payload struct {
Prompt string `json:"prompt"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
+ ToolResponse json.RawMessage `json:"tool_response"`
ToolUseID string `json:"tool_use_id"`
}
@@ -134,14 +137,13 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
case VerbSessionEnd:
ev.Kind = hooks.SessionEnd
case VerbPostToolUse:
- // Only the file-writing tool is a lifecycle event; shell and MCP
- // calls are left to the transcript and the git status diff.
- files := toolPatchFiles(p.ToolName, p.ToolInput)
- if len(files) == 0 {
+ tool, files := p.toolOf()
+ if tool == nil && len(files) == 0 {
return nil, nil
}
ev.Kind = hooks.ToolUse
ev.ToolUseID = p.ToolUseID
+ ev.Tool = tool
for _, f := range files {
if !filepath.IsAbs(f) && p.Cwd != "" {
f = filepath.Join(p.Cwd, f)
@@ -154,6 +156,89 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
return ev, nil
}
+// toolOf is the completed call a PostToolUse payload describes, shaped so
+// the dispatcher recognises the same call when it reads the rollout at turn
+// end (toolKeys in dispatch.go), plus the files an apply_patch wrote for the
+// git attribution.
+//
+// Codex 0.153 reports each tool the model used, in code mode the inner call
+// of the exec script: apply_patch with the envelope under command, and Bash
+// with the shell command under command. Its tool_use_id (exec-) is not
+// the rollout's call_id (call_), so the id never matches and the other
+// keys must:
+//
+// - apply_patch (also under its Write and Edit aliases, and a code-mode
+// exec script that only calls tools.apply_patch once): the shape of
+// transcript.go's patchTool, name "apply_patch", arguments {"patch": …}
+// and the envelope's paths as locations, so both the arguments hash and
+// every written path match;
+// - Bash {"command": …}: the command text, which transcript.go reads out
+// of a lone tools.exec_command({"cmd": …}) script (CodeModeCommand) or a
+// shell call's command, matches as the shell key;
+// - a code-mode exec script that does more: name "exec" with arguments
+// {"input": script}, the rollout's custom_tool_call shape, so the
+// arguments hash matches; the patches it holds still name their files;
+// - anything else: the payload's name and arguments, re-encoded like the
+// rollout's (canonJSON), so the arguments hash matches a function_call
+// with the same arguments.
+//
+// The response is a string or an object; either is kept as text.
+func (p payload) toolOf() (*record.Tool, []string) {
+ var tool *record.Tool
+ if p.ToolUseID != "" && p.ToolName != "" {
+ tool = &record.Tool{NativeID: p.ToolUseID, Name: p.ToolName, Arguments: canonJSON(p.ToolInput), Status: "completed"}
+ if r := bytes.TrimSpace(p.ToolResponse); len(r) > 0 && string(r) != "null" {
+ var str string
+ if json.Unmarshal(r, &str) != nil {
+ str = string(r)
+ }
+ tool.Result = &str
+ }
+ }
+ var files []string
+ switch {
+ case isApplyPatch(p.ToolName):
+ patch := patchText(p.ToolInput)
+ files = PatchFiles(patch)
+ if tool != nil && len(files) > 0 {
+ tool.Name, tool.Arguments, tool.Locations = patchArgs(patch, files)
+ }
+ case p.ToolName == "exec":
+ script := patchText(p.ToolInput)
+ patches, others := CodeModePatches(script)
+ files = PatchFiles(strings.Join(patches, "\n"))
+ if tool == nil || script == "" {
+ break
+ }
+ if len(patches) == 1 && !others {
+ tool.Name, tool.Arguments, tool.Locations = patchArgs(patches[0], files)
+ } else {
+ tool.Arguments = hooks.Raw(map[string]string{"input": script})
+ }
+ }
+ return tool, files
+}
+
+// patchArgs is the apply_patch shape shared with transcript.go's patchTool.
+func patchArgs(patch string, files []string) (string, json.RawMessage, []string) {
+ return "apply_patch", hooks.Raw(map[string]string{"patch": patch}), files
+}
+
+// canonJSON re-encodes a JSON value with Go's encoder (sorted object keys,
+// no HTML escaping) so the same arguments hash the same whether they came
+// from a hook payload or the rollout; anything that is not JSON is kept.
+func canonJSON(raw json.RawMessage) json.RawMessage {
+ raw = bytes.TrimSpace(raw)
+ if len(raw) == 0 {
+ return nil
+ }
+ var v any
+ if err := json.Unmarshal(raw, &v); err != nil {
+ return raw
+ }
+ return hooks.Raw(v)
+}
+
// isApplyPatch recognises Codex's file tool: apply_patch, plus the Write and
// Edit matcher aliases Codex registers for Claude-style hook configs.
func isApplyPatch(name string) bool {
@@ -164,19 +249,6 @@ func isApplyPatch(name string) bool {
return false
}
-// toolPatchFiles lists the files a PostToolUse touched: an apply_patch call
-// (also under the Write and Edit aliases), or a code-mode exec script that
-// calls tools.apply_patch. Anything else is left to the transcript and the
-// git status diff.
-func toolPatchFiles(name string, input json.RawMessage) []string {
- text := patchText(input)
- if isApplyPatch(name) {
- return PatchFiles(text)
- }
- patches, _ := CodeModePatches(text)
- return PatchFiles(strings.Join(patches, "\n"))
-}
-
// codeModeCall is a tool call in a code-mode script: tools.(.
var codeModeCall = regexp.MustCompile(`\btools\.([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*`)
@@ -202,6 +274,25 @@ func CodeModePatches(script string) (patches []string, others bool) {
return patches, others
}
+// CodeModeCommand is the argument object of a code-mode script that calls
+// tools.exec_command({…}) exactly once and no other tool, when that object
+// is a JSON literal with a cmd string; the object is re-encoded with sorted
+// keys. Anything else is not a lone command.
+func CodeModeCommand(script string) (json.RawMessage, bool) {
+ calls := codeModeCall.FindAllStringSubmatchIndex(script, -1)
+ if len(calls) != 1 || script[calls[0][2]:calls[0][3]] != "exec_command" {
+ return nil, false
+ }
+ var obj map[string]any
+ if err := json.NewDecoder(strings.NewReader(script[calls[0][1]:])).Decode(&obj); err != nil {
+ return nil, false
+ }
+ if cmd, ok := obj["cmd"].(string); !ok || strings.TrimSpace(cmd) == "" {
+ return nil, false
+ }
+ return hooks.Raw(obj), true
+}
+
// jsString decodes the JavaScript string literal that starts at s[i] with a
// ", ' or ` quote. A template literal holding ${…} and an unterminated or
// malformed literal fail.
diff --git a/internal/hooks/codex/codex_test.go b/internal/hooks/codex/codex_test.go
index 4c78e5e..d03f5bb 100644
--- a/internal/hooks/codex/codex_test.go
+++ b/internal/hooks/codex/codex_test.go
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -170,29 +171,77 @@ func TestParse(t *testing.T) {
t.Fatalf("%+v %v", ev, err)
}
+ // apply_patch: the call is shaped like the rollout's (transcript.go
+ // patchTool) so the dispatcher's dedupe matches by arguments and by the
+ // written paths; Files are absolute for the git attribution.
patch := "*** Begin Patch\n*** Add File: docs/new.md\n+hi\n*** Update File: main.go\n@@\n-a\n+b\n*** Delete File: /abs/old.txt\n*** End Patch"
- ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_use_id":"call_1","tool_input":{"command":`+string(hooks.Raw(patch))+`}}`))
- if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.ToolUseID != "call_1" {
+ ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_use_id":"exec-1","tool_input":{"command":`+string(hooks.Raw(patch))+`},"tool_response":"Exit code: 0\nOutput:\nSuccess."}`))
+ if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.ToolUseID != "exec-1" || ev.Tool == nil {
t.Fatalf("%+v %v", ev, err)
}
if want := []string{"/repo/docs/new.md", "/repo/main.go", "/abs/old.txt"}; strings.Join(ev.Files, ",") != strings.Join(want, ",") {
t.Fatalf("files %v", ev.Files)
}
+ want := patchTool("exec-1", patch)
+ if ev.Tool.NativeID != want.NativeID || ev.Tool.Name != want.Name || string(ev.Tool.Arguments) != string(want.Arguments) || strings.Join(ev.Tool.Locations, ",") != strings.Join(want.Locations, ",") || ev.Tool.Status != "completed" {
+ t.Fatalf("got %+v\nwant %+v", ev.Tool, want)
+ }
+ if ev.Tool.Result == nil || *ev.Tool.Result != "Exit code: 0\nOutput:\nSuccess." {
+ t.Fatalf("result %v", ev.Tool.Result)
+ }
+ if record.CanonicalToolName(ev.Tool.Name, "", "", ev.Tool.Arguments) != "Edit" {
+ t.Fatal("apply_patch should canonicalise to Edit")
+ }
- // The Write and Edit aliases are the same tool.
+ // The Write and Edit aliases are the same tool; without a tool_use_id
+ // there is no call to deliver, but the files still count.
ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"Edit","tool_input":{"patch":"*** Begin Patch\n*** Update File: a.go\n*** End Patch"}}`))
- if err != nil || ev == nil || ev.Kind != hooks.ToolUse || len(ev.Files) != 1 || ev.Files[0] != "/repo/a.go" {
+ if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.Tool != nil || len(ev.Files) != 1 || ev.Files[0] != "/repo/a.go" {
t.Fatalf("%+v %v", ev, err)
}
- // A shell call is not a lifecycle event.
- ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"shell","tool_input":{"command":["ls"]}}`))
- if err != nil || ev != nil {
+ // A shell call (Codex reports a code-mode exec_command as Bash with the
+ // command text): the call is delivered with its arguments re-encoded
+ // and an object response kept as text; no files.
+ ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"Bash","tool_use_id":"exec-2","tool_input":{"workdir":"/repo","command":"cat rt.txt"},"tool_response":{"output":"realtime\n","exit_code":0}}`))
+ if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.Tool == nil || len(ev.Files) != 0 {
+ t.Fatalf("%+v %v", ev, err)
+ }
+ if ev.Tool.NativeID != "exec-2" || ev.Tool.Name != "Bash" || string(ev.Tool.Arguments) != `{"command":"cat rt.txt","workdir":"/repo"}` || ev.Tool.Status != "completed" {
+ t.Fatalf("%+v", ev.Tool)
+ }
+ if ev.Tool.Result == nil || *ev.Tool.Result != `{"output":"realtime\n","exit_code":0}` {
+ t.Fatalf("result %v", ev.Tool.Result)
+ }
+ // The same command in the rollout's code-mode script yields the same
+ // command text, which is the dispatcher's shell key.
+ args, ok := CodeModeCommand(`const r = await tools.exec_command({"cmd":"cat rt.txt","workdir":"/repo","yield_time_ms":10000});` + "\ntext(r.output);\n")
+ if !ok {
+ t.Fatal("expected a lone exec_command")
+ }
+ var hookArgs, rolloutArgs struct {
+ Command string `json:"command"`
+ Cmd string `json:"cmd"`
+ }
+ if json.Unmarshal(ev.Tool.Arguments, &hookArgs) != nil || json.Unmarshal(args, &rolloutArgs) != nil || hookArgs.Command+hookArgs.Cmd != rolloutArgs.Command+rolloutArgs.Cmd {
+ t.Fatalf("hook %s, rollout %s", ev.Tool.Arguments, args)
+ }
+
+ // An MCP or other tool: delivered as named, a null response is no result.
+ ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"mcp__atlas_memory__memory_search","tool_use_id":"exec-3","tool_input":{"query":"hooks"},"tool_response":null}`))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "mcp__atlas_memory__memory_search" || string(ev.Tool.Arguments) != `{"query":"hooks"}` || ev.Tool.Result != nil || len(ev.Files) != 0 {
t.Fatalf("%+v %v", ev, err)
}
- // Nor is an apply_patch whose envelope names no file.
- ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_input":{"command":"garbage"}}`))
+ // An apply_patch whose envelope names no file is still a call, just not
+ // an edit.
+ ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_use_id":"exec-4","tool_input":{"command":"garbage"}}`))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "apply_patch" || string(ev.Tool.Arguments) != `{"command":"garbage"}` || len(ev.Tool.Locations) != 0 || len(ev.Files) != 0 {
+ t.Fatalf("%+v %v", ev, err)
+ }
+
+ // Nothing to deliver: no id and no files.
+ ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"shell","tool_input":{"command":["ls"]}}`))
if err != nil || ev != nil {
t.Fatalf("%+v %v", ev, err)
}
@@ -302,16 +351,62 @@ func TestCodeModePatches(t *testing.T) {
}
func TestParseCodeModeExec(t *testing.T) {
- // A PostToolUse for the exec tool whose script edits a file still names
- // the file; a script that only runs a shell command does not.
+ // A PostToolUse for the exec tool itself, carrying the code-mode script.
common := `"session_id":"s1","transcript_path":"/t/rollout.jsonl","cwd":"/repo","model":"gpt-5-codex"`
- script := `await tools.apply_patch("*** Begin Patch\n*** Add File: note.txt\n+hi\n*** End Patch");`
+ patch := "*** Begin Patch\n*** Add File: note.txt\n+hi\n*** End Patch"
+ script := `await tools.apply_patch(` + string(hooks.Raw(patch)) + `);`
+
+ // A script with one patch and nothing else is that apply_patch call,
+ // shaped like the rollout's.
ev, err := Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_use_id":"exec-1","tool_input":{"input":`+string(hooks.Raw(script))+`}}`))
if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.ToolUseID != "exec-1" || len(ev.Files) != 1 || ev.Files[0] != "/repo/note.txt" {
t.Fatalf("%+v %v", ev, err)
}
+ want := patchTool("exec-1", patch)
+ if ev.Tool == nil || ev.Tool.NativeID != "exec-1" || ev.Tool.Name != "apply_patch" || string(ev.Tool.Arguments) != string(want.Arguments) || strings.Join(ev.Tool.Locations, ",") != "note.txt" {
+ t.Fatalf("got %+v\nwant %+v", ev.Tool, want)
+ }
+
+ // A script that also runs a command stays the exec call, in the
+ // rollout's {"input": script} shape, and still names the patched files.
+ mixed := `await tools.shell({command: 'ls'}); ` + script + ` await tools.apply_patch("*** Begin Patch\n*** Delete File: old.txt\n*** End Patch");`
+ ev, err = Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_use_id":"exec-2","tool_input":{"input":`+string(hooks.Raw(mixed))+`}}`))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "exec" || string(ev.Tool.Arguments) != string(hooks.Raw(map[string]string{"input": mixed})) {
+ t.Fatalf("%+v %v", ev, err)
+ }
+ if strings.Join(ev.Files, ",") != "/repo/note.txt,/repo/old.txt" {
+ t.Fatalf("files %v", ev.Files)
+ }
+
+ // A script that only runs a shell command is the exec call with no files.
+ ev, err = Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_use_id":"exec-3","tool_input":{"input":"await tools.shell({command: 'ls'})"}}`))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "exec" || len(ev.Files) != 0 {
+ t.Fatalf("%+v %v", ev, err)
+ }
+ // Without a tool_use_id a shell-only script is nothing.
ev, err = Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_input":{"input":"await tools.shell({command: 'ls'})"}}`))
if err != nil || ev != nil {
t.Fatalf("%+v %v", ev, err)
}
}
+
+func TestCodeModeCommand(t *testing.T) {
+ args, ok := CodeModeCommand(`const r = await tools.exec_command({"cmd":"cat rt.txt","workdir":"/repo","yield_time_ms":10000,"max_output_tokens":2000});` + "\ntext(r.output);\n")
+ if !ok || string(args) != `{"cmd":"cat rt.txt","max_output_tokens":2000,"workdir":"/repo","yield_time_ms":10000}` {
+ t.Fatalf("%s %v", args, ok)
+ }
+ for _, bad := range []string{
+ `tools.exec_command({cmd: 'ls'})`, // a JS literal, not JSON
+ `tools.exec_command({"cmd":""})`,
+ `tools.exec_command({"workdir":"/repo"})`,
+ `tools.exec_command(args)`,
+ `tools.exec_command({"cmd":"ls"}); tools.exec_command({"cmd":"pwd"})`,
+ `tools.shell({"command":"ls"})`,
+ `tools.exec_command({"cmd":"ls"}); tools.apply_patch("*** Begin Patch\n*** Add File: a\n*** End Patch")`,
+ "",
+ } {
+ if args, ok := CodeModeCommand(bad); ok {
+ t.Fatalf("%s: %s", bad, args)
+ }
+ }
+}
diff --git a/internal/hooks/codex/state_main_test.go b/internal/hooks/codex/state_main_test.go
new file mode 100644
index 0000000..e78e875
--- /dev/null
+++ b/internal/hooks/codex/state_main_test.go
@@ -0,0 +1,15 @@
+package codex
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/codex/transcript.go b/internal/hooks/codex/transcript.go
index 9124de3..c379da5 100644
--- a/internal/hooks/codex/transcript.go
+++ b/internal/hooks/codex/transcript.go
@@ -180,11 +180,20 @@ func readItem(t *hooks.Turn, tools map[string]int, it item, idx int) {
return
}
// Code mode (Codex 0.153): the model edits files from a JavaScript
- // script run by the exec tool, as tools.apply_patch("…"). Each patch
+ // script run by the exec tool, as tools.apply_patch("…"), and runs
+ // shell commands as tools.exec_command({"cmd": …}). Each patch
// becomes its own apply_patch call so the edit and its files are
- // attributed; the script stays only when it also called other
- // tools, and then it keeps the call id and the output.
+ // attributed; a script that only runs one command becomes that
+ // exec_command call, whose cmd is what the PostToolUse hook reported
+ // as the command (codex.go toolOf); the script itself stays only
+ // when it did more, and then it keeps the call id and the output.
patches, others := CodeModePatches(it.Input)
+ if len(patches) == 0 {
+ if args, ok := CodeModeCommand(it.Input); ok {
+ addTool(t, tools, &record.Tool{NativeID: it.CallID, Name: "exec_command", Arguments: args, Status: "completed"})
+ return
+ }
+ }
if len(patches) == 0 || others {
addTool(t, tools, &record.Tool{NativeID: it.CallID, Name: it.Name, Status: "completed", Arguments: hooks.Raw(map[string]string{"input": it.Input})})
}
diff --git a/internal/hooks/codex/transcript_test.go b/internal/hooks/codex/transcript_test.go
index cf4d57d..730f6f5 100644
--- a/internal/hooks/codex/transcript_test.go
+++ b/internal/hooks/codex/transcript_test.go
@@ -302,6 +302,8 @@ func TestReadCodeMode(t *testing.T) {
`{"timestamp":"2026-10-12T02:39:28.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_Sn9","output":[{"type":"input_text","text":"Script completed\nWall time 0.6 seconds\nOutput:\n"},{"type":"input_text","text":"{}"}]}}`,
`{"timestamp":"2026-10-12T02:39:29.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_mix","name":"exec","input":"await tools.shell({command: 'go test'}); await tools.apply_patch(\"*** Begin Patch\\n*** Update File: a.go\\n*** End Patch\");"}}`,
`{"timestamp":"2026-10-12T02:39:30.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_mix","output":"ok"}}`,
+ `{"timestamp":"2026-10-12T02:39:31.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_cmd","name":"exec","input":"const r = await tools.exec_command({\"cmd\":\"cat rt.txt\",\"workdir\":\"/private/tmp/hooks-codex\",\"yield_time_ms\":10000,\"max_output_tokens\":2000});\ntext(r.output);\n"}}`,
+ `{"timestamp":"2026-10-12T02:39:32.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_cmd","output":[{"type":"input_text","text":"Script completed\nWall time 0.2 seconds\nOutput:\n"},{"type":"input_text","text":"realtime\n"}]}}`,
}
p := filepath.Join(t.TempDir(), "rollout.jsonl")
if err := os.WriteFile(p, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
@@ -311,7 +313,7 @@ func TestReadCodeMode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(turn.Items) != 3 {
+ if len(turn.Items) != 4 {
t.Fatalf("items %d: %+v", len(turn.Items), turn.Items)
}
// A script that only edits is the edit, with the script's output.
@@ -333,4 +335,13 @@ func TestReadCodeMode(t *testing.T) {
if ed == nil || ed.Name != "apply_patch" || ed.NativeID != "call_mix#patch1" || len(ed.Locations) != 1 || ed.Locations[0] != "a.go" || ed.Result != nil {
t.Fatalf("patch %+v", ed)
}
+ // A script that only runs one command is that command, with cmd at the
+ // top level (the hook reports the same text as command) and the output.
+ cmd := turn.Items[3].Tool
+ if cmd == nil || cmd.Name != "exec_command" || cmd.NativeID != "call_cmd" || !strings.Contains(string(cmd.Arguments), `"cmd":"cat rt.txt"`) || cmd.Result == nil || !strings.HasSuffix(*cmd.Result, "realtime\n") {
+ t.Fatalf("command %+v", cmd)
+ }
+ if record.CanonicalToolName(cmd.Name, "", "", cmd.Arguments) != "Bash" {
+ t.Fatal("exec_command should canonicalise to Bash")
+ }
}
diff --git a/internal/hooks/copilot/copilot.go b/internal/hooks/copilot/copilot.go
index 7877994..4ddf077 100644
--- a/internal/hooks/copilot/copilot.go
+++ b/internal/hooks/copilot/copilot.go
@@ -36,6 +36,7 @@ type Agent struct{}
const (
VerbSessionStart = "session-start"
VerbPromptSubmitted = "user-prompt-submitted"
+ VerbPostToolUse = "post-tool-use"
VerbAgentStop = "agent-stop"
VerbSessionEnd = "session-end"
)
@@ -44,6 +45,7 @@ const (
const (
eventSessionStart = "sessionStart"
eventUserPromptSubmitted = "userPromptSubmitted"
+ eventPostToolUse = "postToolUse"
eventAgentStop = "agentStop"
eventSessionEnd = "sessionEnd"
)
@@ -59,7 +61,7 @@ func (Agent) Binary() string { return "copilot" }
// Verbs are the hook verbs the installed config invokes.
func (Agent) Verbs() []string {
- return []string{VerbSessionStart, VerbPromptSubmitted, VerbAgentStop, VerbSessionEnd}
+ return []string{VerbSessionStart, VerbPromptSubmitted, VerbPostToolUse, VerbAgentStop, VerbSessionEnd}
}
// HooksPath is the hook file Atlas owns. Copilot auto-discovers every JSON
@@ -90,6 +92,7 @@ func wanted() hookFile {
return hookFile{Version: 1, Hooks: map[string][]hookEntry{
eventSessionStart: entry(VerbSessionStart),
eventUserPromptSubmitted: entry(VerbPromptSubmitted),
+ eventPostToolUse: entry(VerbPostToolUse),
eventAgentStop: entry(VerbAgentStop),
eventSessionEnd: entry(VerbSessionEnd),
}}
@@ -191,7 +194,10 @@ func TranscriptPath(sessionID string) string {
// Parse maps a hook verb and payload to an event. Copilot's keys are
// camelCase (sessionId, transcriptPath, cwd, prompt); the snake_case forms
-// VS Code's hook host sends are accepted too.
+// VS Code's hook host sends are accepted too. A postToolUse payload becomes
+// a ToolUse event carrying the completed call (tool.go), which the dispatcher
+// records into the open turn as it fires; a payload naming no tool is
+// nothing to do.
func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
m, err := hooks.Decode(raw)
if err != nil {
@@ -208,6 +214,11 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
case VerbPromptSubmitted:
ev.Kind = hooks.TurnStart
ev.Prompt = hooks.Str(m, "prompt")
+ case VerbPostToolUse:
+ ev.Kind = hooks.ToolUse
+ if ev.Tool = toolOf(raw, ev.Cwd); ev.Tool == nil {
+ return nil, nil
+ }
case VerbAgentStop:
ev.Kind = hooks.TurnEnd
case VerbSessionEnd:
diff --git a/internal/hooks/copilot/copilot_test.go b/internal/hooks/copilot/copilot_test.go
index 02a39e7..2b0a317 100644
--- a/internal/hooks/copilot/copilot_test.go
+++ b/internal/hooks/copilot/copilot_test.go
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -24,7 +25,7 @@ func TestRegisteredAndNamed(t *testing.T) {
if a.ResumeCommand("abc") != "copilot --resume abc" {
t.Fatal(a.ResumeCommand("abc"))
}
- if len(a.Verbs()) != 4 {
+ if len(a.Verbs()) != 5 {
t.Fatal(a.Verbs())
}
n, ok := a.(hooks.Noter)
@@ -58,12 +59,12 @@ func TestInstallStateUninstall(t *testing.T) {
if err := json.Unmarshal(b, &file); err != nil {
t.Fatal(err)
}
- if file.Version != 1 || len(file.Hooks) != 4 {
+ if file.Version != 1 || len(file.Hooks) != 5 {
t.Fatalf("%s", b)
}
for ev, verb := range map[string]string{
"sessionStart": "session-start", "userPromptSubmitted": "user-prompt-submitted",
- "agentStop": "agent-stop", "sessionEnd": "session-end",
+ "postToolUse": "post-tool-use", "agentStop": "agent-stop", "sessionEnd": "session-end",
} {
list := file.Hooks[ev]
if len(list) != 1 || list[0].Type != "command" {
@@ -211,3 +212,106 @@ func TestTranscriptPathDefaultsToTheHomeDirectory(t *testing.T) {
t.Fatalf("%q != %q", got, want)
}
}
+
+// The postToolUse payloads below are what Copilot CLI 1.0.95 sent to an echo
+// hook: no tool call id; toolArgs an object for bash and MCP tools, the bare
+// patch string for apply_patch; toolResult {resultType, textResultForLlm}.
+func TestParsePostToolUse(t *testing.T) {
+ t.Setenv("COPILOT_HOME", "/copilot-home")
+ a := Agent{}
+ const id = "c90a3853-49f2-4828-9293-c24163db8c36"
+ derived := filepath.Join("/copilot-home", "session-state", id, "events.jsonl")
+
+ shell := `{"sessionId":"` + id + `","timestamp":1791759613789,"cwd":"/private/tmp/hooks-copilot","toolName":"bash","toolArgs":{"command":"cat echo2.txt","description":"Read echo2.txt"},"toolResult":{"resultType":"success","textResultForLlm":"hello\n"}}`
+ ev, err := a.Parse(VerbPostToolUse, []byte(shell))
+ if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.SessionID != id || ev.Cwd != "/private/tmp/hooks-copilot" || ev.Transcript != derived {
+ t.Fatalf("%+v %v", ev, err)
+ }
+ tool := ev.Tool
+ if tool == nil || tool.Name != "bash" || tool.Status != "completed" || len(tool.Locations) != 0 ||
+ string(tool.Arguments) != `{"command":"cat echo2.txt","description":"Read echo2.txt"}` ||
+ tool.Result == nil || *tool.Result != "hello\n" {
+ t.Fatalf("%+v", tool)
+ }
+ if !strings.HasPrefix(tool.NativeID, "hook-") || len(tool.NativeID) != len("hook-")+24 {
+ t.Fatalf("the payload has no call id; a derived one is expected: %q", tool.NativeID)
+ }
+ if record.CanonicalToolName(tool.Name, tool.Title, tool.Kind, tool.Arguments) != "Bash" {
+ t.Fatal("bash must record as Bash")
+ }
+ // The same payload derives the same id; another timestamp another one.
+ again, _ := a.Parse(VerbPostToolUse, []byte(shell))
+ if again.Tool.NativeID != tool.NativeID {
+ t.Fatalf("%q != %q", again.Tool.NativeID, tool.NativeID)
+ }
+ later, _ := a.Parse(VerbPostToolUse, []byte(strings.Replace(shell, "1791759613789", "1791759613790", 1)))
+ if later.Tool.NativeID == tool.NativeID {
+ t.Fatal("two calls at different times must not share an id")
+ }
+
+ patch := `{"sessionId":"` + id + `","timestamp":1791759612107,"cwd":"/private/tmp/hooks-copilot","toolName":"apply_patch","toolArgs":"*** Begin Patch\n*** Add File: echo2.txt\n+hello\n*** End Patch\n","toolResult":{"resultType":"success","textResultForLlm":"Added 1 file(s): /private/tmp/hooks-copilot/echo2.txt"}}`
+ ev, err = a.Parse(VerbPostToolUse, []byte(patch))
+ if err != nil || ev == nil || ev.Tool == nil {
+ t.Fatalf("%+v %v", ev, err)
+ }
+ tool = ev.Tool
+ if tool.Name != "apply_patch" || tool.Status != "completed" ||
+ string(tool.Arguments) != `{"patch":"*** Begin Patch\n*** Add File: echo2.txt\n+hello\n*** End Patch\n"}` ||
+ tool.Result == nil || *tool.Result != "Added 1 file(s): /private/tmp/hooks-copilot/echo2.txt" {
+ t.Fatalf("%+v", tool)
+ }
+ if len(tool.Locations) != 1 || tool.Locations[0] != "/private/tmp/hooks-copilot/echo2.txt" {
+ t.Fatalf("the patch's file, absolute like the transcript's fileEdits: %v", tool.Locations)
+ }
+ if record.CanonicalToolName(tool.Name, tool.Title, tool.Kind, tool.Arguments) != "Edit" {
+ t.Fatal("apply_patch must record as Edit")
+ }
+ if paths := record.WritePaths(tool.Arguments, tool.Locations); len(paths) != 1 || paths[0] != "/private/tmp/hooks-copilot/echo2.txt" {
+ t.Fatalf("the recorder must sample the patched file: %v", paths)
+ }
+ if record.EditPatch(tool.Arguments, tool.Locations[0]) != "*** Begin Patch\n*** Add File: echo2.txt\n+hello\n*** End Patch\n" {
+ t.Fatal("the patch must be kept as the edit's patch")
+ }
+
+ mcp := `{"sessionId":"` + id + `","timestamp":1791759578328,"cwd":"/private/tmp/hooks-copilot","toolName":"atlas_memory-memory_briefing","toolArgs":{},"toolResult":{"resultType":"success","textResultForLlm":"{\"filesChanged\":[]}"}}`
+ ev, err = a.Parse(VerbPostToolUse, []byte(mcp))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "atlas_memory-memory_briefing" || string(ev.Tool.Arguments) != `{}` ||
+ ev.Tool.Result == nil || *ev.Tool.Result != `{"filesChanged":[]}` || len(ev.Tool.Locations) != 0 {
+ t.Fatalf("%+v %v", ev, err)
+ }
+
+ // A result that is not a success fails the call; a string or absent
+ // result is kept as is; a string argument that is no patch is wrapped.
+ ev, _ = a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":1,"toolName":"bash","toolArgs":{"command":"rm -rf /"},"toolResult":{"resultType":"denied","textResultForLlm":"The user denied this tool call."}}`))
+ if ev.Tool.Status != "failed" || *ev.Tool.Result != "The user denied this tool call." {
+ t.Fatalf("%+v", ev.Tool)
+ }
+ ev, _ = a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":2,"toolName":"view","toolArgs":"x.txt","toolResult":"hello"}`))
+ if ev.Tool.Status != "completed" || *ev.Tool.Result != "hello" || string(ev.Tool.Arguments) != `{"input":"x.txt"}` || ev.Tool.Locations != nil {
+ t.Fatalf("%+v", ev.Tool)
+ }
+ ev, _ = a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":3,"toolName":"think"}`))
+ if ev.Tool.Result != nil || ev.Tool.Arguments != nil || ev.Tool.Status != "completed" {
+ t.Fatalf("%+v", ev.Tool)
+ }
+
+ // No tool named: nothing to do.
+ if ev, err := a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":4}`)); err != nil || ev != nil {
+ t.Fatalf("%+v %v", ev, err)
+ }
+}
+
+func TestPatchPaths(t *testing.T) {
+ patch, _ := json.Marshal("*** Begin Patch\n*** Update File: src/a.go\n*** Move to: src/b.go\n@@\n-x\n+y\n*** Delete File: old.txt\n*** Add File: /abs/new.txt\n+n\n*** Update File: src/a.go\n*** End Patch\n")
+ got := patchPaths(patch, "/repo")
+ want := []string{"/repo/src/a.go", "/repo/src/b.go", "/repo/old.txt", "/abs/new.txt"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("%v", got)
+ }
+ if got := patchPaths(patch, ""); got[0] != "src/a.go" {
+ t.Fatalf("no cwd keeps the path relative: %v", got)
+ }
+ if patchPaths([]byte(`{"path":"x"}`), "/repo") != nil || patchPaths([]byte(`"not a patch"`), "/repo") != nil {
+ t.Fatal("only a patch string names files")
+ }
+}
diff --git a/internal/hooks/copilot/state_main_test.go b/internal/hooks/copilot/state_main_test.go
new file mode 100644
index 0000000..4c35518
--- /dev/null
+++ b/internal/hooks/copilot/state_main_test.go
@@ -0,0 +1,15 @@
+package copilot
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/copilot/tool.go b/internal/hooks/copilot/tool.go
new file mode 100644
index 0000000..456de18
--- /dev/null
+++ b/internal/hooks/copilot/tool.go
@@ -0,0 +1,147 @@
+package copilot
+
+import (
+ "bytes"
+ "encoding/json"
+ "path/filepath"
+ "strings"
+
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
+ "github.com/tryatlas/atlas-cli/internal/capture/store"
+)
+
+// toolPayload is Copilot's postToolUse input (1.0.95): {sessionId,
+// timestamp, cwd, toolName, toolArgs, toolResult}. toolArgs is the tool's
+// arguments object ({"command":…,"description":…} for bash, {} for an MCP
+// tool), or apply_patch's bare patch string. toolResult is
+// {"resultType":"success"|…,"textResultForLlm":…}; a shell command that
+// exited non-zero is still a "success" (the tool ran), as in the transcript.
+// The payload carries no tool call id: the transcript's toolCallId is not in
+// it.
+type toolPayload struct {
+ SessionID string `json:"sessionId"`
+ Timestamp json.RawMessage `json:"timestamp"`
+ ToolName string `json:"toolName"`
+ ToolArgs json.RawMessage `json:"toolArgs"`
+ ToolResult json.RawMessage `json:"toolResult"`
+}
+
+type toolResult struct {
+ ResultType string `json:"resultType"`
+ Text string `json:"textResultForLlm"`
+}
+
+// toolOf is the completed call a postToolUse payload describes, or nil when
+// it names no tool. cwd resolves the relative paths of a patch.
+func toolOf(raw []byte, cwd string) *record.Tool {
+ var p toolPayload
+ if json.Unmarshal(raw, &p) != nil || p.ToolName == "" {
+ return nil
+ }
+ args := toolArguments(p.ToolArgs)
+ t := &record.Tool{
+ NativeID: hookCallID(p.SessionID, string(bytes.TrimSpace(p.Timestamp)), p.ToolName, args),
+ Name: p.ToolName,
+ Arguments: args,
+ Locations: patchPaths(p.ToolArgs, cwd),
+ Status: "completed",
+ }
+ res := bytes.TrimSpace(p.ToolResult)
+ if !isSet(res) {
+ return t
+ }
+ var r toolResult
+ if res[0] == '{' && json.Unmarshal(res, &r) == nil {
+ if r.ResultType != "" && r.ResultType != "success" {
+ t.Status = "failed"
+ }
+ text := r.Text
+ if text == "" {
+ text = string(res)
+ }
+ t.Result = &text
+ } else if text, ok := resultText(res); ok {
+ t.Result = &text
+ }
+ return t
+}
+
+// hookCallID stands in for the call id the payload lacks: a digest of the
+// session, the payload's timestamp, the tool and its arguments, so a
+// re-delivered payload upserts the same row and two calls of one tool in the
+// same millisecond stay apart. It never equals the transcript's toolCallId,
+// so the dispatcher's dedupe at turn end goes by the arguments hash, the
+// shell command text and the written paths, all of which this package shapes
+// the same way on both sides.
+func hookCallID(session, timestamp, name string, args json.RawMessage) string {
+ var compact bytes.Buffer
+ _ = json.Compact(&compact, args)
+ sum := store.SHA256Hex([]byte(session + "\x00" + timestamp + "\x00" + name + "\x00" + compact.String()))
+ return "hook-" + sum[:24]
+}
+
+// toolArguments normalises a call's arguments to the JSON object the
+// recorder and the dispatcher's dedupe keys read. An object stays as it is.
+// apply_patch's bare patch string becomes {"patch": …}, which
+// record.EditPatch keeps as the edit's patch; any other string becomes
+// {"input": …}. Nothing stays nothing. The transcript reader applies the
+// same shape, so a hook-delivered call and its transcript item hash alike.
+func toolArguments(raw json.RawMessage) json.RawMessage {
+ raw = bytes.TrimSpace(raw)
+ if !isSet(raw) {
+ return nil
+ }
+ if raw[0] != '"' {
+ return raw
+ }
+ var s string
+ if json.Unmarshal(raw, &s) != nil {
+ return raw
+ }
+ key := "input"
+ if isPatch(s) {
+ key = "patch"
+ }
+ b, _ := json.Marshal(map[string]string{key: s})
+ return b
+}
+
+// isPatch recognises the apply_patch format.
+func isPatch(s string) bool { return strings.HasPrefix(strings.TrimSpace(s), "*** Begin Patch") }
+
+// patchHeaders introduce the files a patch touches.
+var patchHeaders = []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "}
+
+// patchPaths are the files an apply_patch call names, made absolute against
+// cwd so they equal the transcript's fileEdits paths, which the dispatcher
+// dedupes written paths by. Non-patch arguments yield nil: record.WritePaths
+// reads their path keys instead.
+func patchPaths(raw json.RawMessage, cwd string) []string {
+ var s string
+ if json.Unmarshal(raw, &s) != nil || !isPatch(s) {
+ return nil
+ }
+ var out []string
+ seen := map[string]bool{}
+ for _, line := range strings.Split(s, "\n") {
+ var p string
+ for _, h := range patchHeaders {
+ if rest, ok := strings.CutPrefix(line, h); ok {
+ p = strings.TrimSpace(rest)
+ break
+ }
+ }
+ if p == "" {
+ continue
+ }
+ if cwd != "" && !filepath.IsAbs(p) {
+ p = filepath.Join(cwd, p)
+ }
+ p = filepath.Clean(p)
+ if !seen[p] {
+ seen[p] = true
+ out = append(out, p)
+ }
+ }
+ return out
+}
diff --git a/internal/hooks/copilot/transcript.go b/internal/hooks/copilot/transcript.go
index d0c63d3..08c7149 100644
--- a/internal/hooks/copilot/transcript.go
+++ b/internal/hooks/copilot/transcript.go
@@ -20,6 +20,12 @@ import (
// message it paid for, so a turn's usage is their sum. Older releases put
// outputTokens on assistant.message instead; that is read when no
// usage_record was seen.
+//
+// Since the postToolUse hook delivers each call as it completes, the items
+// read here are mostly the dispatcher's dedupe material at turn end: their
+// arguments take the same shape as the hook's (toolArguments) so the two
+// hash alike, and fileEdits give the written paths the hook's patch paths
+// are matched against. Calls the hook did not report stay as items.
type reader struct{}
// Position is the line count.
@@ -146,7 +152,7 @@ func (reader) Read(path string, from int) (*hooks.Turn, int, error) {
}
tools[id] = len(t.Items)
t.Items = append(t.Items, hooks.Item{Tool: &record.Tool{
- NativeID: id, Name: te.ToolName, Arguments: te.Arguments, Status: "running",
+ NativeID: id, Name: te.ToolName, Arguments: toolArguments(te.Arguments), Status: "running",
}})
case "tool.execution_complete":
var te toolEvent
@@ -163,7 +169,7 @@ func (reader) Read(path string, from int) (*hooks.Turn, int, error) {
// the call still counts, with what the completion says.
i = len(t.Items)
tools[id] = i
- t.Items = append(t.Items, hooks.Item{Tool: &record.Tool{NativeID: id, Name: te.ToolName, Arguments: te.Arguments}})
+ t.Items = append(t.Items, hooks.Item{Tool: &record.Tool{NativeID: id, Name: te.ToolName, Arguments: toolArguments(te.Arguments)}})
}
tool := t.Items[i].Tool
tool.Status = "completed"
diff --git a/internal/hooks/copilot/transcript_test.go b/internal/hooks/copilot/transcript_test.go
index 6524bd8..73f3ce5 100644
--- a/internal/hooks/copilot/transcript_test.go
+++ b/internal/hooks/copilot/transcript_test.go
@@ -144,6 +144,9 @@ func TestReadCurrentReleaseShape(t *testing.T) {
tool.Result == nil || !strings.HasPrefix(*tool.Result, `{"content":"Added 1 file(s)`) {
t.Fatalf("%+v", tool)
}
+ if string(tool.Arguments) != `{"patch":"*** Begin Patch\n*** Add File: note.txt\n+hello\n*** End Patch\n"}` {
+ t.Fatalf("the bare patch string takes the hook payload's shape: %s", tool.Arguments)
+ }
if turn.Items[1].Tool != nil || turn.Items[1].Body != "Created note.txt." {
t.Fatalf("an empty-content message is no item; the final one is: %+v", turn.Items[1])
}
diff --git a/internal/hooks/cursor/cursor.go b/internal/hooks/cursor/cursor.go
index 0ec12b1..5412f5e 100644
--- a/internal/hooks/cursor/cursor.go
+++ b/internal/hooks/cursor/cursor.go
@@ -3,12 +3,15 @@
// under ~/.cursor/projects (docs/research/entire-cli.md §3).
//
// Observed live against cursor-agent 2026.10.01: the interactive TUI fires
-// sessionStart (at the first prompt, not at launch), beforeSubmitPrompt,
-// stop and sessionEnd, so turns are captured as they end. Print mode
-// (cursor-agent -p) fires only sessionStart, the tool hooks and sessionEnd:
-// no beforeSubmitPrompt and no stop, so a -p run yields a session with no
-// turns. The TUI keeps a hook's stdin open; ReadPayload returns after one
-// JSON value, so Atlas never blocks it.
+// sessionStart (at the first prompt, not at launch), beforeSubmitPrompt, the
+// tool hooks afterFileEdit and afterShellExecution as each call completes,
+// stop and sessionEnd. The prompt opens the turn, each tool hook records its
+// call into the open turn, and stop closes it with the transcript's text.
+// Print mode (cursor-agent -p) fires only sessionStart, the tool hooks and
+// sessionEnd: no beforeSubmitPrompt and no stop, so a -p run yields a
+// session with no turns until sessionEnd captures the whole transcript as
+// one. The TUI keeps a hook's stdin open; ReadPayload returns after one JSON
+// value, so Atlas never blocks it.
package cursor
import (
@@ -23,6 +26,7 @@ import (
"regexp"
"strings"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/capture/store"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -37,11 +41,13 @@ type Agent struct{}
// Hook verbs, as the installed commands name them.
const (
- VerbSessionStart = "session-start"
- VerbBeforePrompt = "before-submit-prompt"
- VerbStop = "stop"
- VerbSessionEnd = "session-end"
- VerbPreCompact = "pre-compact"
+ VerbSessionStart = "session-start"
+ VerbBeforePrompt = "before-submit-prompt"
+ VerbAfterFileEdit = "after-file-edit"
+ VerbAfterShell = "after-shell-execution"
+ VerbStop = "stop"
+ VerbSessionEnd = "session-end"
+ VerbPreCompact = "pre-compact"
)
// hook is one Cursor hook event and the verb Atlas runs for it.
@@ -51,10 +57,15 @@ type hook struct {
}
// installed are the hooks Atlas writes, in lifecycle order. Cursor hooks
-// take no matcher; the stdin payload is the same shape for every event.
+// take no matcher; the stdin payload shares its common fields across events.
+// Cursor has no generic post-tool hook: afterFileEdit and afterShellExecution
+// are the two that name a completed call (afterMCPExecution is left to the
+// transcript).
var installed = []hook{
{"sessionStart", VerbSessionStart},
{"beforeSubmitPrompt", VerbBeforePrompt},
+ {"afterFileEdit", VerbAfterFileEdit},
+ {"afterShellExecution", VerbAfterShell},
{"stop", VerbStop},
{"sessionEnd", VerbSessionEnd},
{"preCompact", VerbPreCompact},
@@ -294,6 +305,7 @@ func (Agent) ResumeCommand(id string) string { return "cursor-agent --resume " +
// the first tool hook on; the IDE names it throughout.
type payload struct {
ConversationID string `json:"conversation_id"`
+ GenerationID string `json:"generation_id"` // changes with each user message
Model string `json:"model"`
WorkspaceRoots []string `json:"workspace_roots"`
TranscriptPath string `json:"transcript_path"`
@@ -306,6 +318,72 @@ type payload struct {
OutputTokens *json.Number `json:"output_tokens"`
CacheReadTokens *json.Number `json:"cache_read_tokens"`
CacheWriteTokens *json.Number `json:"cache_write_tokens"`
+ // afterFileEdit: the absolute path and the replacements applied to it. A
+ // new file arrives as one edit with an empty old_string.
+ FilePath string `json:"file_path"`
+ Edits []edit `json:"edits"`
+ // afterShellExecution: the command and, when Cursor captured it, its
+ // output.
+ Command string `json:"command"`
+ Output *string `json:"output"`
+}
+
+// edit is one replacement of an afterFileEdit payload.
+type edit struct {
+ OldString string `json:"old_string"`
+ NewString string `json:"new_string"`
+}
+
+// nativeID names a hook-reported call: Cursor's tool hooks carry no call id
+// (and its transcript gives tool_use blocks none either), so the id is the
+// generation plus a hash of what the call did. The dispatcher matches the
+// transcript's copy by written path or command text, not by this id.
+func (p payload) nativeID(kind string, parts ...string) string {
+ h := store.SHA256Hex([]byte(strings.Join(parts, "\x00")))[:16]
+ if p.GenerationID == "" {
+ return kind + "-" + h
+ }
+ return p.GenerationID + "-" + kind + "-" + h
+}
+
+// editTool is the completed call an afterFileEdit payload describes, in the
+// shape Cursor's transcript gives the same call so the dispatcher's keys
+// line up: a new file is Write {path, contents}; anything else is Edit
+// {path, edits} with the single replacement also flattened to old_string and
+// new_string so the recorder can synthesise its patch.
+func (p payload) editTool() *record.Tool {
+ if p.FilePath == "" {
+ return nil
+ }
+ edits, _ := json.Marshal(p.Edits)
+ t := &record.Tool{NativeID: p.nativeID("edit", p.FilePath, string(edits)), Status: "completed"}
+ if len(p.Edits) == 1 && p.Edits[0].OldString == "" {
+ t.Name = "Write"
+ t.Arguments = hooks.Raw(map[string]any{"path": p.FilePath, "contents": p.Edits[0].NewString})
+ return t
+ }
+ t.Name = "Edit"
+ args := map[string]any{"path": p.FilePath, "edits": p.Edits}
+ if len(p.Edits) == 1 {
+ args["old_string"], args["new_string"] = p.Edits[0].OldString, p.Edits[0].NewString
+ }
+ t.Arguments = hooks.Raw(args)
+ return t
+}
+
+// shellTool is the completed call an afterShellExecution payload describes:
+// Shell {command}, as the transcript names it, with the output as its result.
+func (p payload) shellTool() *record.Tool {
+ if strings.TrimSpace(p.Command) == "" {
+ return nil
+ }
+ out := ""
+ if p.Output != nil {
+ out = *p.Output
+ }
+ t := &record.Tool{NativeID: p.nativeID("shell", p.Command, out), Name: "Shell", Status: "completed",
+ Arguments: hooks.Raw(map[string]string{"command": p.Command}), Result: p.Output}
+ return t
}
func (p payload) cwd() string {
@@ -373,6 +451,16 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
case VerbBeforePrompt:
ev.Kind = hooks.TurnStart
ev.Prompt = p.Prompt
+ case VerbAfterFileEdit:
+ ev.Kind = hooks.ToolUse
+ if ev.Tool = p.editTool(); ev.Tool == nil {
+ return nil, nil
+ }
+ case VerbAfterShell:
+ ev.Kind = hooks.ToolUse
+ if ev.Tool = p.shellTool(); ev.Tool == nil {
+ return nil, nil
+ }
case VerbStop:
ev.Kind = hooks.TurnEnd
ev.Usage = p.usage()
diff --git a/internal/hooks/cursor/cursor_test.go b/internal/hooks/cursor/cursor_test.go
index aa6c46a..35d85cd 100644
--- a/internal/hooks/cursor/cursor_test.go
+++ b/internal/hooks/cursor/cursor_test.go
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -43,7 +44,7 @@ func TestInstallIntoEmptyProject(t *testing.T) {
if string(obj["version"]) != "1" {
t.Fatalf("version %s", obj["version"])
}
- if len(hs) != 5 {
+ if len(hs) != 7 {
t.Fatalf("events %v", hs)
}
for _, h := range installed {
@@ -105,8 +106,9 @@ func TestInstallKeepsUserHooksAndPrunesStale(t *testing.T) {
if ss[1]["command"] != command(VerbSessionStart) {
t.Fatalf("stale entry not replaced: %v", ss[1])
}
- if len(hs["afterFileEdit"]) != 1 || hs["afterFileEdit"][0]["command"] != "./lint.sh" {
- t.Fatalf("user event lost: %v", hs["afterFileEdit"])
+ // Atlas installs into afterFileEdit too: the user's entry stays first.
+ if fe := hs["afterFileEdit"]; len(fe) != 2 || fe[0]["command"] != "./lint.sh" || fe[1]["command"] != command(VerbAfterFileEdit) {
+ t.Fatalf("user event lost or not appended to: %v", hs["afterFileEdit"])
}
if _, ok := hs["preToolUse"]; ok {
t.Fatalf("stale Atlas-only event kept: %v", hs["preToolUse"])
@@ -135,7 +137,7 @@ func TestInstallKeepsUserHooksAndPrunesStale(t *testing.T) {
t.Fatal(err)
}
obj, hs = readHooks(t, root)
- if len(hs) != 2 || len(hs["sessionStart"]) != 1 || hs["sessionStart"][0]["command"] != "echo mine" || len(hs["afterFileEdit"]) != 1 {
+ if len(hs) != 2 || len(hs["sessionStart"]) != 1 || hs["sessionStart"][0]["command"] != "echo mine" || len(hs["afterFileEdit"]) != 1 || hs["afterFileEdit"][0]["command"] != "./lint.sh" {
t.Fatalf("after uninstall %v", hs)
}
if string(obj["version"]) != "1" {
@@ -261,6 +263,47 @@ func TestParseLiveCLIPayloads(t *testing.T) {
t.Fatalf("beforeSubmitPrompt %+v %v", ev, err)
}
+ // The tool hooks, recorded from the same CLI: a new file, a replacement
+ // in it, and a shell command with its output.
+ ev, err = a.Parse(VerbAfterFileEdit, []byte(`{"conversation_id":"`+id+`","generation_id":"9c3d906a-6fae-4aca-8800-640b0373b6c2","model":"default","file_path":"/private/tmp/hooks-cursor/probe.txt","edits":[{"old_string":"","new_string":"alpha\n"}],`+tail+`,"hook_event_name":"afterFileEdit","transcript_path":"`+named+`"}`))
+ if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil || ev.Transcript != named || ev.SessionID != id {
+ t.Fatalf("afterFileEdit %+v %v", ev, err)
+ }
+ w := ev.Tool
+ if w.Name != "Write" || w.Status != "completed" || string(w.Arguments) != `{"contents":"alpha\n","path":"/private/tmp/hooks-cursor/probe.txt"}` || !strings.HasPrefix(w.NativeID, "9c3d906a-6fae-4aca-8800-640b0373b6c2-edit-") {
+ t.Fatalf("new file %+v %s", w, w.Arguments)
+ }
+ if got := record.WritePaths(w.Arguments, nil); len(got) != 1 || got[0] != "/private/tmp/hooks-cursor/probe.txt" {
+ t.Fatalf("write paths %q", got)
+ }
+ ev, err = a.Parse(VerbAfterFileEdit, []byte(`{"conversation_id":"`+id+`","generation_id":"9c3d906a-6fae-4aca-8800-640b0373b6c2","model":"default","file_path":"/private/tmp/hooks-cursor/probe.txt","edits":[{"old_string":"alpha","new_string":"beta"}],`+tail+`,"hook_event_name":"afterFileEdit","transcript_path":"`+named+`"}`))
+ if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil {
+ t.Fatalf("afterFileEdit (edit) %+v %v", ev, err)
+ }
+ if e := ev.Tool; e.Name != "Edit" || string(e.Arguments) != `{"edits":[{"old_string":"alpha","new_string":"beta"}],"new_string":"beta","old_string":"alpha","path":"/private/tmp/hooks-cursor/probe.txt"}` {
+ t.Fatalf("edit %+v %s", e, e.Arguments)
+ }
+ if record.CanonicalToolName(ev.Tool.Name, "", "", ev.Tool.Arguments) != "Edit" || record.EditPatch(ev.Tool.Arguments, "") == "" {
+ t.Fatalf("edit should canonicalise to Edit with a patch: %s", ev.Tool.Arguments)
+ }
+ ev, err = a.Parse(VerbAfterShell, []byte(`{"conversation_id":"`+id+`","generation_id":"9c3d906a-6fae-4aca-8800-640b0373b6c2","model":"default","command":"cat probe.txt","output":"beta\n","duration":13261.171,"sandbox":false,`+tail+`,"hook_event_name":"afterShellExecution","transcript_path":"`+named+`"}`))
+ if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil {
+ t.Fatalf("afterShellExecution %+v %v", ev, err)
+ }
+ if sh := ev.Tool; sh.Name != "Shell" || string(sh.Arguments) != `{"command":"cat probe.txt"}` || sh.Result == nil || *sh.Result != "beta\n" || sh.Status != "completed" || !strings.HasPrefix(sh.NativeID, "9c3d906a-6fae-4aca-8800-640b0373b6c2-shell-") {
+ t.Fatalf("shell %+v %s", sh, sh.Arguments)
+ }
+ if record.CanonicalToolName(ev.Tool.Name, "", "", ev.Tool.Arguments) != "Bash" {
+ t.Fatal("shell should canonicalise to Bash")
+ }
+ // A tool hook without its call (nothing edited, nothing run) is no event.
+ if ev, err := a.Parse(VerbAfterFileEdit, []byte(`{"conversation_id":"`+id+`",`+tail+`}`)); ev != nil || err != nil {
+ t.Fatalf("empty afterFileEdit %+v %v", ev, err)
+ }
+ if ev, err := a.Parse(VerbAfterShell, []byte(`{"conversation_id":"`+id+`","command":"",`+tail+`}`)); ev != nil || err != nil {
+ t.Fatalf("empty afterShellExecution %+v %v", ev, err)
+ }
+
ev, err = a.Parse(VerbStop, []byte(`{"conversation_id":"`+id+`","generation_id":"055d3bc7","model":"default","status":"completed","loop_count":0,"input_tokens":31219,"output_tokens":230,"cache_read_tokens":15488,"cache_write_tokens":0,`+tail+`,"hook_event_name":"stop","transcript_path":"`+named+`"}`))
if err != nil || ev.Kind != hooks.TurnEnd || ev.TurnEndFailed || ev.Transcript != named || ev.Usage == nil {
t.Fatalf("stop %+v %v", ev, err)
@@ -329,7 +372,7 @@ func TestIdentity(t *testing.T) {
if a.Response(hooks.SessionStart, "hello") != nil {
t.Fatal("Cursor shows no banner")
}
- if len(a.Verbs()) != 5 {
+ if len(a.Verbs()) != 7 {
t.Fatal(a.Verbs())
}
if got, ok := hooks.Get(ID); !ok || got.ID() != ID {
diff --git a/internal/hooks/cursor/perevent_test.go b/internal/hooks/cursor/perevent_test.go
new file mode 100644
index 0000000..af0f7c0
--- /dev/null
+++ b/internal/hooks/cursor/perevent_test.go
@@ -0,0 +1,148 @@
+package cursor
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/tryatlas/atlas-cli/internal/hooks"
+)
+
+// TestPerEventToolDelivery drives the dispatcher through the hooks Cursor's
+// TUI fires for one turn: the prompt opens the turn, afterFileEdit and
+// afterShellExecution deliver their calls as they complete, and stop closes
+// the turn from the transcript without recording those calls a second time.
+// The transcript's tool_use blocks carry no id, so the match is by written
+// path (Write, StrReplace) and by command text (Shell, whose transcript
+// arguments carry extra fields).
+func TestPerEventToolDelivery(t *testing.T) {
+ root := t.TempDir()
+ transcript := filepath.Join(t.TempDir(), "c.jsonl")
+ const id = "e00128c9-9da5-4ab7-8ad9-2743f63d3734"
+ common := `"conversation_id":"` + id + `","generation_id":"c2eb53ba","model":"default","workspace_roots":["` + root + `"],"transcript_path":"` + transcript + `"`
+ file := filepath.Join(root, "probe.txt")
+
+ var deliveries []hooks.Delivery
+ d := &hooks.Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: func(_ context.Context, dl hooks.Delivery) error {
+ deliveries = append(deliveries, dl)
+ return nil
+ }}
+ a := Agent{}
+ run := func(verb, body string) {
+ t.Helper()
+ ev, err := a.Parse(verb, []byte(`{`+common+`,`+body+`}`))
+ if err != nil {
+ t.Fatalf("%s: %v", verb, err)
+ }
+ if _, err := d.Run(context.Background(), a, ev); err != nil {
+ t.Fatalf("%s: %v", verb, err)
+ }
+ }
+
+ if err := os.WriteFile(transcript, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ run(VerbSessionStart, `"hook_event_name":"sessionStart"`)
+ run(VerbBeforePrompt, `"prompt":"make probe.txt"`)
+ // The agent works: Cursor's Write, then its StrReplace, then a Shell.
+ _ = os.WriteFile(file, []byte("alpha\n"), 0o644)
+ run(VerbAfterFileEdit, `"file_path":"`+file+`","edits":[{"old_string":"","new_string":"alpha\n"}]`)
+ _ = os.WriteFile(file, []byte("beta\n"), 0o644)
+ run(VerbAfterFileEdit, `"file_path":"`+file+`","edits":[{"old_string":"alpha","new_string":"beta"}]`)
+ run(VerbAfterShell, `"command":"cat probe.txt","output":"beta\n","duration":12,"sandbox":false`)
+ // A command the hooks did not report stays with the transcript.
+ lines := []string{
+ `{"role":"user","message":{"content":[{"type":"text","text":"make probe.txt"}]}}`,
+ `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"` + file + `","contents":"alpha\n"}}]}}`,
+ `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"` + file + `","old_string":"alpha","new_string":"beta"}}]}}`,
+ `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cat probe.txt","working_directory":"` + root + `","description":"Show it"}}]}}`,
+ `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"echo unreported"}}]}}`,
+ `{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}`,
+ }
+ if err := os.WriteFile(transcript, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ run(VerbStop, `"status":"completed","loop_count":0,"input_tokens":1000,"output_tokens":20,"cache_read_tokens":600,"cache_write_tokens":0`)
+
+ var kinds []string
+ var tools []string
+ for _, dl := range deliveries {
+ switch {
+ case dl.Start:
+ kinds = append(kinds, "start")
+ case dl.Open != nil:
+ kinds = append(kinds, "open:"+dl.Open.Prompt)
+ case dl.Tool != nil:
+ kinds = append(kinds, "tool:"+dl.Tool.Name)
+ tools = append(tools, dl.Tool.NativeID)
+ case dl.Turn != nil:
+ kinds = append(kinds, "turn")
+ }
+ }
+ want := "start|open:make probe.txt|tool:Write|tool:Edit|tool:Shell|turn"
+ if got := strings.Join(kinds, "|"); got != want {
+ t.Fatalf("deliveries %s, want %s", got, want)
+ }
+ for i, id := range tools {
+ if !strings.HasPrefix(id, "c2eb53ba-") {
+ t.Fatalf("tool %d id %q lacks the generation", i, id)
+ }
+ for _, other := range tools[i+1:] {
+ if other == id {
+ t.Fatalf("two calls share id %q", id)
+ }
+ }
+ }
+ turn := deliveries[len(deliveries)-1].Turn
+ if !turn.Open || turn.Prompt != "make probe.txt" || turn.Usage == nil || turn.Usage.Output != 20 || turn.Usage.CacheRead != 600 {
+ t.Fatalf("turn %+v", turn)
+ }
+ var left []string
+ for _, it := range turn.Items {
+ if it.Tool != nil {
+ left = append(left, it.Tool.Name+":"+string(it.Tool.Arguments))
+ } else {
+ left = append(left, it.Mode+":"+it.Body)
+ }
+ }
+ if len(left) != 2 || left[0] != `Shell:{"command":"echo unreported"}` || left[1] != "text:done" {
+ t.Fatalf("transcript items left after dedupe: %q", left)
+ }
+ if len(turn.Changed) != 0 {
+ t.Fatalf("the delivered Write explains probe.txt; changed = %q", turn.Changed)
+ }
+}
+
+func TestEditAndShellToolShapes(t *testing.T) {
+ p := payload{GenerationID: "g1", FilePath: "/w/a.txt", Edits: []edit{{OldString: "", NewString: "x\n"}}}
+ w := p.editTool()
+ if w.Name != "Write" || string(w.Arguments) != `{"contents":"x\n","path":"/w/a.txt"}` || w.Status != "completed" {
+ t.Fatalf("write %+v %s", w, w.Arguments)
+ }
+ p.Edits = []edit{{OldString: "x", NewString: "y"}}
+ e := p.editTool()
+ if e.Name != "Edit" || !strings.Contains(string(e.Arguments), `"old_string":"x"`) || !strings.Contains(string(e.Arguments), `"edits":[{"old_string":"x","new_string":"y"}]`) {
+ t.Fatalf("edit %+v %s", e, e.Arguments)
+ }
+ if e.NativeID == w.NativeID || !strings.HasPrefix(e.NativeID, "g1-edit-") {
+ t.Fatalf("ids %q %q", w.NativeID, e.NativeID)
+ }
+ p.Edits = []edit{{OldString: "a", NewString: "b"}, {OldString: "c", NewString: "d"}}
+ if m := p.editTool(); m.Name != "Edit" || strings.Count(string(m.Arguments), `"old_string"`) != 2 {
+ t.Fatalf("multi-edit flattened: %s", m.Arguments)
+ }
+ if (payload{}).editTool() != nil || (payload{Command: " "}).shellTool() != nil {
+ t.Fatal("empty payloads should give no tool")
+ }
+ out := "ok\n"
+ s := payload{GenerationID: "g1", Command: "ls", Output: &out}.shellTool()
+ if s.Name != "Shell" || string(s.Arguments) != `{"command":"ls"}` || s.Result == nil || *s.Result != "ok\n" || !strings.HasPrefix(s.NativeID, "g1-shell-") {
+ t.Fatalf("shell %+v", s)
+ }
+ if n := (payload{Command: "ls"}).shellTool(); n.Result != nil || !strings.HasPrefix(n.NativeID, "shell-") {
+ t.Fatalf("no output, no generation: %+v", n)
+ }
+}
diff --git a/internal/hooks/cursor/state_main_test.go b/internal/hooks/cursor/state_main_test.go
new file mode 100644
index 0000000..68818f7
--- /dev/null
+++ b/internal/hooks/cursor/state_main_test.go
@@ -0,0 +1,15 @@
+package cursor
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/deliver.go b/internal/hooks/deliver.go
index 681eb71..641d103 100644
--- a/internal/hooks/deliver.go
+++ b/internal/hooks/deliver.go
@@ -16,6 +16,7 @@ import (
"sort"
"time"
+ gitlink "github.com/tryatlas/atlas-cli/internal/capture/git"
"github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/capture/store"
"github.com/tryatlas/atlas-cli/internal/listener"
@@ -34,8 +35,10 @@ var ErrSpooled = errors.New("spooled")
// SpoolDir holds deliveries waiting for a writer.
func SpoolDir(root string) string { return filepath.Join(Dir(root), "spool") }
-// Deliver is the default Dispatcher.Deliver.
-func Deliver(version string) func(ctx context.Context, d Delivery) error {
+// Deliver is the default Dispatcher.Deliver. afterDirect, when set, runs
+// after a direct write with the store still open: the caller syncs the new
+// rows to the cloud there, so a turn does not wait for the next atlas serve.
+func Deliver(version string, afterDirect func(ctx context.Context, st *store.Store, d Delivery)) func(ctx context.Context, d Delivery) error {
return func(ctx context.Context, d Delivery) error {
if rf := listener.ReadRunFile(d.Root); rf != nil {
if err := postHook(ctx, rf.Socket, d); err == nil {
@@ -44,7 +47,7 @@ func Deliver(version string) func(ctx context.Context, d Delivery) error {
// The listener is there but did not answer: fall through to a
// direct write, which fails on its lock and spools.
}
- err := applyDirect(ctx, version, d.Root, []Delivery{d})
+ err := applyDirect(ctx, version, d.Root, []Delivery{d}, afterDirect)
if errors.Is(err, store.ErrLocked) {
if serr := spool(d); serr != nil {
return serr
@@ -59,6 +62,22 @@ func Deliver(version string) func(ctx context.Context, d Delivery) error {
// listener and the direct path share it.
func Apply(ctx context.Context, rec *record.Recorder, mem *server.Server, ds []Delivery) error {
for _, d := range ds {
+ if err := apply1(ctx, rec, mem, d); err != nil {
+ return err
+ }
+ if d.LinkFrom != "" {
+ // Touches that landed after a commit the post-commit hook
+ // already walked: link the turn's commits now.
+ if _, err := gitlink.LinkRange(ctx, rec.Store, d.Root, d.LinkFrom, time.Now); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func apply1(ctx context.Context, rec *record.Recorder, mem *server.Server, d Delivery) error {
+ {
g := server.Grant{SessionID: d.Session.NativeID, Agent: d.Session.Agent, Cwd: d.Root}
switch {
case d.Start:
@@ -69,12 +88,37 @@ func Apply(ctx context.Context, rec *record.Recorder, mem *server.Server, ds []D
if mem != nil {
mem.SessionEnded(ctx, g)
}
+ case d.Open != nil:
+ if _, err := rec.OpenTurn(ctx, d.Session, d.Open.Prompt, d.Open.At); err != nil {
+ return err
+ }
+ case d.Tool != nil:
+ if err := rec.RecordTool(ctx, d.Session, *d.Tool, d.Changed, d.At); err != nil {
+ return err
+ }
+ if mem != nil {
+ one := []record.CapturedItem{{Tool: d.Tool}}
+ foldChanges(ctx, mem, g, d.Root, &record.CapturedTurn{Items: one, Changed: d.Changed})
+ foldPlans(ctx, mem, g, one)
+ if d.Plan != nil {
+ _ = mem.Fold(ctx, g, memrecord.Event{Kind: "plan_set", Text: d.Plan.Text, Status: d.Plan.Status})
+ }
+ }
case d.Turn != nil:
if _, err := rec.RecordTurn(ctx, d.Session, *d.Turn); err != nil {
return err
}
if mem != nil {
foldChanges(ctx, mem, g, d.Root, d.Turn)
+ foldPlans(ctx, mem, g, d.Turn.Items)
+ if d.Plan != nil {
+ _ = mem.Fold(ctx, g, memrecord.Event{Kind: "plan_set", Text: d.Plan.Text, Status: d.Plan.Status})
+ }
+ for _, it := range d.Turn.Items {
+ if it.Tool == nil && it.Mode != "thinking" {
+ mem.Capture(ctx, g, it.Body)
+ }
+ }
}
}
}
@@ -114,7 +158,7 @@ func foldChanges(ctx context.Context, mem *server.Server, g server.Grant, root s
// applyDirect takes the writer lock for the duration of the write, drains
// the spool first, then applies ds.
-func applyDirect(ctx context.Context, version, root string, ds []Delivery) error {
+func applyDirect(ctx context.Context, version, root string, ds []Delivery, after func(ctx context.Context, st *store.Store, d Delivery)) error {
lock, err := store.Lock(root)
if err != nil {
return err
@@ -135,6 +179,11 @@ func applyDirect(ctx context.Context, version, root string, ds []Delivery) error
return err
}
ClearLastError(root)
+ if after != nil {
+ for _, d := range ds {
+ after(ctx, st, d)
+ }
+ }
return nil
}
diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go
index 802e339..fdf6c6d 100644
--- a/internal/hooks/dispatch.go
+++ b/internal/hooks/dispatch.go
@@ -1,7 +1,9 @@
package hooks
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
"io/fs"
@@ -25,14 +27,34 @@ type Dispatcher struct {
Banner string
}
-// Delivery is what a hook hands to the capture side: a session start, a
-// turn, or a session end for one native session.
+// Delivery is what a hook hands to the capture side for one native session:
+// a session start, an opening prompt, one completed tool call, the turn's
+// close (or a whole turn), or a session end.
type Delivery struct {
Root string `json:"root"`
Session record.Session `json:"session"`
Start bool `json:"start,omitempty"`
+ Open *OpenTurn `json:"open,omitempty"`
+ Tool *record.Tool `json:"tool,omitempty"`
Turn *record.CapturedTurn `json:"turn,omitempty"`
End bool `json:"end,omitempty"`
+ // Changed are Project-relative paths git saw change while Tool (a
+ // shell call) ran; they are recorded as its file touches.
+ Changed []string `json:"changed,omitempty"`
+ // At is when the hook fired, so a spooled delivery keeps its time.
+ At time.Time `json:"at,omitempty"`
+ // LinkFrom, when set, asks the writer to link the commits after it to
+ // the touches just recorded (commits made during the turn).
+ LinkFrom string `json:"link_from,omitempty"`
+ // Plan is the session's whole plan after an incremental task tool
+ // changed it, folded as plan_set.
+ Plan *PlanFold `json:"plan,omitempty"`
+}
+
+// OpenTurn is the prompt that opens a turn as it is submitted.
+type OpenTurn struct {
+ Prompt string `json:"prompt"`
+ At time.Time `json:"at"`
}
// Run handles one event. Every error is logged and swallowed by the caller;
@@ -44,6 +66,13 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error
if !ValidSessionID(ev.SessionID) {
return nil, ErrBadSessionID
}
+ // Hooks of one session can fire concurrently (parallel tool calls):
+ // one at a time per session, so the state's read-modify-write holds.
+ unlock, err := lockSession(d.Root, a.ID(), ev.SessionID)
+ if err != nil {
+ return nil, err
+ }
+ defer unlock()
now := d.Now()
st, err := LoadState(d.Root, a.ID(), ev.SessionID)
if err != nil {
@@ -66,7 +95,7 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error
if err := st.Save(d.Root); err != nil {
return nil, err
}
- _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Start: true})
+ _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Start: true, At: now})
}
switch ev.Kind {
@@ -90,19 +119,77 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error
}
st.InTurn = true
st.Prompt = ev.Prompt
- st.ToolFiles = nil
+ st.ToolFiles, st.Delivered, st.Tools, st.Open = nil, nil, nil, false
st.Status = gitStatus(ctx, d.Root)
+ st.Head = gitHead(ctx, d.Root)
+ st.TurnHead = st.Head
if r := a.Reader(); r != nil && st.Transcript != "" {
if pos, err := r.Position(st.Transcript); err == nil {
st.Offset = pos
}
}
+ if ev.Prompt != "" {
+ // The prompt opens the turn now; tool calls follow as they
+ // complete, and the reply closes it (per-event capture).
+ st.Open = true
+ if err := st.Save(d.Root); err != nil {
+ return nil, err
+ }
+ return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Open: &OpenTurn{Prompt: ev.Prompt, At: now}, At: now})
+ }
return nil, st.Save(d.Root)
case ToolUse:
for _, f := range ev.Files {
st.ToolFiles = append(st.ToolFiles, relPath(d.Root, f))
}
+ if ev.Tool != nil && ev.Tool.NativeID != "" {
+ if !st.Open {
+ st.Tools = append(st.Tools, *ev.Tool)
+ _ = trackTasks(st, *ev.Tool) // the turn-end delivery folds the plan
+ return nil, st.Save(d.Root)
+ }
+ st.Delivered = append(st.Delivered, toolKeys(*ev.Tool))
+ for _, p := range writtenPaths(*ev.Tool) {
+ rel, _ := record.ProjectPath(d.Root, p)
+ st.Delivered = append(st.Delivered, []string{"path:" + rel}) // explained: not a shell change
+ }
+ dl := Delivery{Root: d.Root, Session: sess, Tool: ev.Tool, At: now}
+ dl.Plan = trackTasks(st, *ev.Tool)
+ if record.CanonicalToolName(ev.Tool.Name, ev.Tool.Title, ev.Tool.Kind, ev.Tool.Arguments) == "Bash" && st.Status != nil {
+ // What the shell command changed is known now: its touches,
+ // and the baseline for the rest of the turn moves past them.
+ after := gitStatus(ctx, d.Root)
+ head := gitHead(ctx, d.Root)
+ explained := explainedPaths(st.Delivered)
+ changed := changedSince(d.Root, st.Status, after)
+ if head != "" && head != st.Head {
+ // The command committed: what it committed is no longer
+ // in git status but is its work all the same.
+ changed = append(changed, committedSince(ctx, d.Root, st.Head)...)
+ }
+ for _, p := range changed {
+ if !explained[p] {
+ dl.Changed = append(dl.Changed, p)
+ st.Delivered = append(st.Delivered, []string{"path:" + p})
+ explained[p] = true
+ }
+ }
+ if after != nil {
+ st.Status = after
+ }
+ if head != "" {
+ st.Head = head
+ }
+ }
+ if st.TurnHead != "" && gitHead(ctx, d.Root) != st.TurnHead {
+ dl.LinkFrom = st.TurnHead
+ }
+ if err := st.Save(d.Root); err != nil {
+ return nil, err
+ }
+ return nil, d.deliver(ctx, st, dl)
+ }
return nil, st.Save(d.Root)
case TurnEnd:
@@ -112,15 +199,34 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error
return nil, err
}
st.InTurn, st.Prompt, st.ToolFiles, st.Status = false, "", nil, nil
+ st.Open, st.Delivered, st.Tools = false, nil, nil
if turn == nil {
return nil, st.Save(d.Root)
}
st.Turn++
+ linkFrom := ""
+ if st.TurnHead != "" && gitHead(ctx, d.Root) != st.TurnHead {
+ linkFrom = st.TurnHead // commits made during the turn
+ }
+ st.Head, st.TurnHead = "", ""
+ // Task tool calls the hooks did not deliver (or that came before
+ // the turn opened) still build the plan, once each.
+ var plan *PlanFold
+ for _, it := range turn.Items {
+ if it.Tool != nil {
+ if p := trackTasks(st, *it.Tool); p != nil {
+ plan = p
+ }
+ }
+ }
+ if plan == nil && len(st.Tasks) > 0 && hadTaskCalls(turn.Items) {
+ plan = planFromTasks(st.Tasks)
+ }
if err := st.Save(d.Root); err != nil {
return nil, err
}
sess.Model = st.Model
- return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn})
+ return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn, At: now, LinkFrom: linkFrom, Plan: plan})
case Compaction:
return nil, st.Save(d.Root)
@@ -133,7 +239,7 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error
if turn, err := d.turnEnd(ctx, a, st, ev); err == nil && turn != nil {
st.Turn = 1
sess.Model = st.Model
- _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn})
+ _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn, At: now})
}
}
// The state stays (atlas agents sessions lists it with its resume
@@ -142,7 +248,7 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error
if err := st.Save(d.Root); err != nil {
return nil, err
}
- return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, End: true})
+ return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, End: true, At: now})
case SubagentStart, SubagentEnd:
return nil, st.Save(d.Root)
@@ -185,14 +291,24 @@ func (d *Dispatcher) turnEnd(ctx context.Context, a Agent, st *State, ev *Event)
if ev.Model != "" {
turn.Model = ev.Model
}
+ // Tool calls already delivered during the open turn are not recorded
+ // again from the transcript; calls the hooks saw while no turn was open
+ // join now.
+ if st.Open {
+ turn.Open = true
+ turn.Items = dropDelivered(turn.Items, st.Delivered)
+ }
+ for i := range st.Tools {
+ turn.Items = append(turn.Items, record.CapturedItem{Tool: &st.Tools[i]})
+ }
// Files the agent's tool hooks named, then whatever git says changed
- // during the turn that no tool call explains.
- explained := map[string]bool{}
+ // during the turn that no tool call explains (delivered ones included).
+ explained := explainedPaths(st.Delivered)
for _, it := range turn.Items {
if it.Tool == nil {
continue
}
- for _, p := range record.WritePaths(it.Tool.Arguments, it.Tool.Locations, it.Tool.Diffs...) {
+ for _, p := range writtenPaths(*it.Tool) {
rel, _ := record.ProjectPath(d.Root, p)
explained[rel] = true
}
@@ -204,18 +320,179 @@ func (d *Dispatcher) turnEnd(ctx context.Context, a Agent, st *State, ev *Event)
}
}
if st.Status != nil {
- for _, p := range changedSince(d.Root, st.Status, gitStatus(ctx, d.Root)) {
+ changed := changedSince(d.Root, st.Status, gitStatus(ctx, d.Root))
+ if head := gitHead(ctx, d.Root); head != "" && st.Head != "" && head != st.Head {
+ changed = append(changed, committedSince(ctx, d.Root, st.Head)...)
+ }
+ for _, p := range changed {
if !explained[p] {
turn.Changed = append(turn.Changed, p)
+ explained[p] = true
}
}
}
- if turn.Prompt == "" && len(turn.Items) == 0 && len(turn.Changed) == 0 && !ev.TurnEndFailed {
+ if turn.Prompt == "" && len(turn.Items) == 0 && len(turn.Changed) == 0 && !ev.TurnEndFailed && !turn.Open {
return nil, nil // nothing happened (a turn that only read)
}
return turn, nil
}
+// toolKeys identifies a tool call across the hook payload and the
+// transcript, whose ids and argument shapes may differ: the agent's call
+// id; the canonical name with a hash of the arguments (unless they are
+// empty); for a shell call its command text (a string, or an argv array
+// joined); for a call that writes files, each written path.
+func toolKeys(t record.Tool) []string {
+ keys := []string{"id:" + t.NativeID}
+ name := record.CanonicalToolName(t.Name, t.Title, t.Kind, t.Arguments)
+ if len(bytes.TrimSpace(t.Arguments)) > 0 {
+ var compact bytes.Buffer
+ if json.Compact(&compact, t.Arguments) == nil && compact.String() != "{}" && compact.String() != "null" {
+ keys = append(keys, "args:"+strings.ToLower(name)+":"+store.SHA256Hex(compact.Bytes()))
+ }
+ var m struct {
+ Command json.RawMessage `json:"command"`
+ Cmd json.RawMessage `json:"cmd"`
+ }
+ if json.Unmarshal(t.Arguments, &m) == nil {
+ if c := commandText(m.Command) + commandText(m.Cmd); c != "" {
+ keys = append(keys, "cmd:"+store.SHA256Hex([]byte(c)))
+ }
+ }
+ }
+ if record.WritesFiles(name) || len(t.Diffs) > 0 {
+ for _, p := range record.WritePaths(t.Arguments, t.Locations, t.Diffs...) {
+ keys = append(keys, "write:"+filepath.ToSlash(filepath.Clean(p)))
+ }
+ }
+ return keys
+}
+
+// commandText flattens a command argument: a string, or an argv array.
+func commandText(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var s string
+ if json.Unmarshal(raw, &s) == nil {
+ return strings.TrimSpace(s)
+ }
+ var argv []string
+ if json.Unmarshal(raw, &argv) == nil {
+ return strings.TrimSpace(strings.Join(argv, " "))
+ }
+ return ""
+}
+
+// explainedPaths are the Project paths delivered tool calls wrote.
+func explainedPaths(delivered [][]string) map[string]bool {
+ out := map[string]bool{}
+ for _, g := range delivered {
+ for _, k := range g {
+ if rel, ok := strings.CutPrefix(k, "path:"); ok {
+ out[rel] = true
+ }
+ }
+ }
+ return out
+}
+
+// dropDelivered removes the transcript's tool items already recorded from a
+// hook, each delivered call matching at most one item. When the agent's hook
+// ids match its transcript ids (any item's id was delivered) ids decide
+// alone, so two distinct calls with the same command stay distinct; an item
+// matched by id is kept, marked NoSample, so its result completes the row.
+// Otherwise an item matches a delivered call by arguments, by command text,
+// or by every path it wrote, and is dropped.
+func dropDelivered(items []record.CapturedItem, delivered [][]string) []record.CapturedItem {
+ groups := make([][]string, 0, len(delivered))
+ for _, g := range delivered {
+ if len(g) > 0 && !strings.HasPrefix(g[0], "path:") {
+ groups = append(groups, g)
+ }
+ }
+ has := func(g []string, key string) bool {
+ for _, k := range g {
+ if k == key {
+ return true
+ }
+ }
+ return false
+ }
+ idsMatch := false
+ for _, it := range items {
+ if it.Tool == nil {
+ continue
+ }
+ for _, g := range groups {
+ if has(g, "id:"+it.Tool.NativeID) {
+ idsMatch = true
+ }
+ }
+ }
+ match := func(t record.Tool) int {
+ keys := toolKeys(t)
+ for i, g := range groups {
+ if has(g, keys[0]) {
+ return i
+ }
+ }
+ if idsMatch {
+ return -1
+ }
+ for i, g := range groups {
+ for _, k := range keys[1:] {
+ if !strings.HasPrefix(k, "write:") && has(g, k) {
+ return i
+ }
+ }
+ }
+ // Every written path delivered (possibly by several calls): a
+ // duplicate too; the first writing group is consumed.
+ writes, first := 0, -1
+ for _, k := range keys {
+ if !strings.HasPrefix(k, "write:") {
+ continue
+ }
+ found := -1
+ for i, g := range groups {
+ if has(g, k) {
+ found = i
+ break
+ }
+ }
+ if found < 0 {
+ return -1
+ }
+ writes++
+ if first < 0 {
+ first = found
+ }
+ }
+ if writes > 0 {
+ return first
+ }
+ return -1
+ }
+ kept := items[:0]
+ for _, it := range items {
+ if it.Tool != nil {
+ if i := match(*it.Tool); i >= 0 {
+ byID := has(groups[i], "id:"+it.Tool.NativeID)
+ groups = append(groups[:i], groups[i+1:]...)
+ if !byID {
+ continue
+ }
+ // Same row: let the transcript complete it (result, status)
+ // without sampling its writes again.
+ it.Tool.NoSample = true
+ }
+ }
+ kept = append(kept, it)
+ }
+ return kept
+}
+
// deliver hands off to the store; a failure spools (deliver.go) and is
// reported once in the hooks log.
func (d *Dispatcher) deliver(ctx context.Context, st *State, dl Delivery) error {
@@ -245,3 +522,14 @@ func Enabled(ctx context.Context, root string) bool {
b, err := st.Binding(ctx)
return err == nil && b != nil && b.Enabled
}
+
+// writtenPaths are the files a tool call wrote. A call that only read or
+// searched names files too (its locations), but explains no change: a file
+// the agent read and then committed is the commit's change.
+func writtenPaths(t record.Tool) []string {
+ name := record.CanonicalToolName(t.Name, t.Title, t.Kind, t.Arguments)
+ if !record.WritesFiles(name) && len(t.Diffs) == 0 {
+ return nil
+ }
+ return record.WritePaths(t.Arguments, t.Locations, t.Diffs...)
+}
diff --git a/internal/hooks/droid/droid.go b/internal/hooks/droid/droid.go
index 3a1917c..6427ff6 100644
--- a/internal/hooks/droid/droid.go
+++ b/internal/hooks/droid/droid.go
@@ -4,12 +4,15 @@
package droid
import (
+ "bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
+ "github.com/tryatlas/atlas-cli/internal/capture/store"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -28,6 +31,7 @@ const (
VerbStop = "stop"
VerbPreCompact = "pre-compact"
VerbSessionEnd = "session-end"
+ VerbPostToolUse = "post-tool-use"
)
// MissingMessage is what Droid shows when the hook is installed but atlas is
@@ -45,7 +49,7 @@ func (Agent) Binary() string { return "droid" }
// Verbs are the hook verbs the installed config invokes.
func (Agent) Verbs() []string {
- return []string{VerbSessionStart, VerbPromptSubmit, VerbStop, VerbPreCompact, VerbSessionEnd}
+ return []string{VerbSessionStart, VerbPromptSubmit, VerbPostToolUse, VerbStop, VerbPreCompact, VerbSessionEnd}
}
// SettingsPath is Droid's project settings file, which holds its hooks. It
@@ -63,6 +67,9 @@ func (Agent) shaped(root string) hooks.ClaudeShaped {
{Type: "SessionStart", Command: hooks.WrapWithMessage(hooks.Command(ID, VerbSessionStart), MissingMessage)},
{Type: "SessionStart", Command: promptSubmit},
{Type: "UserPromptSubmit", Command: promptSubmit},
+ // Matcher "" is every tool: each completed call is recorded into the
+ // open turn as it happens (per-event delivery).
+ {Type: "PostToolUse", Command: hooks.Wrap(hooks.Command(ID, VerbPostToolUse))},
{Type: "Stop", Command: hooks.Wrap(hooks.Command(ID, VerbStop))},
{Type: "PreCompact", Command: hooks.Wrap(hooks.Command(ID, VerbPreCompact))},
{Type: "SessionEnd", Command: hooks.Wrap(hooks.Command(ID, VerbSessionEnd))},
@@ -102,13 +109,47 @@ func (Agent) Response(k hooks.Kind, message string) []byte {
func (Agent) ResumeCommand(id string) string { return "droid --session-id " + id }
// payload is the common shape of Droid hook input. Stop omits model, and
-// the SessionStart-fired user-prompt-submit omits prompt.
+// the SessionStart-fired user-prompt-submit omits prompt. PostToolUse
+// carries the Claude-style tool fields (Entire's factoryaidroid/types.go
+// postToolHookInputRaw).
type payload struct {
- SessionID string `json:"session_id"`
- TranscriptPath string `json:"transcript_path"`
- Cwd string `json:"cwd"`
- Model string `json:"model"`
- Prompt string `json:"prompt"`
+ SessionID string `json:"session_id"`
+ TranscriptPath string `json:"transcript_path"`
+ Cwd string `json:"cwd"`
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ ToolName string `json:"tool_name"`
+ ToolUseID string `json:"tool_use_id"`
+ ToolInput json.RawMessage `json:"tool_input"`
+ ToolResponse json.RawMessage `json:"tool_response"`
+}
+
+// toolOf is the completed call a PostToolUse payload describes, keyed by
+// the tool_use_id Droid shares with its transcript's tool_use block. Nil
+// without an id or a name: Droid has been seen to omit tool_use_id (Entire
+// keeps a fingerprint fallback for it), and such a call is left to the
+// transcript at Stop. The response is a string or an object; either is
+// kept as text.
+func (p payload) toolOf() *record.Tool {
+ if p.ToolName == "" {
+ return nil
+ }
+ id := p.ToolUseID
+ if id == "" {
+ // Droid has shipped tool payloads without an id: derive a stable one
+ // from the call; the dispatcher still matches the transcript's call
+ // by arguments or written path.
+ id = "droid-" + store.SHA256Hex(append([]byte(p.ToolName+"\x00"), p.ToolInput...))[:16]
+ }
+ t := &record.Tool{NativeID: id, Name: p.ToolName, Arguments: p.ToolInput, Status: "completed"}
+ if r := bytes.TrimSpace(p.ToolResponse); len(r) > 0 && string(r) != "null" {
+ var str string
+ if json.Unmarshal(r, &str) != nil {
+ str = string(r)
+ }
+ t.Result = &str
+ }
+ return t
}
// Parse maps a hook verb and payload to an event.
@@ -126,6 +167,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
case VerbPromptSubmit:
ev.Kind = hooks.TurnStart
ev.Prompt = strings.TrimSpace(p.Prompt)
+ case VerbPostToolUse:
+ ev.Kind = hooks.ToolUse
+ ev.Tool = p.toolOf()
+ if ev.Tool == nil {
+ return nil, nil
+ }
case VerbStop:
ev.Kind = hooks.TurnEnd
if ev.Model == "" {
diff --git a/internal/hooks/droid/droid_test.go b/internal/hooks/droid/droid_test.go
index 75c996c..76fd015 100644
--- a/internal/hooks/droid/droid_test.go
+++ b/internal/hooks/droid/droid_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "slices"
"strings"
"testing"
@@ -79,11 +80,14 @@ func TestInstallUninstall(t *testing.T) {
if got := commands(hk["Stop"]); len(got) != 1 || got[0] != hooks.Wrap(hooks.Command(ID, VerbStop)) {
t.Fatalf("Stop should hold only the current hook (stale pruned): %q", got)
}
- for typ, verb := range map[string]string{"UserPromptSubmit": VerbPromptSubmit, "PreCompact": VerbPreCompact, "SessionEnd": VerbSessionEnd} {
+ for typ, verb := range map[string]string{"UserPromptSubmit": VerbPromptSubmit, "PostToolUse": VerbPostToolUse, "PreCompact": VerbPreCompact, "SessionEnd": VerbSessionEnd} {
if got := commands(hk[typ]); len(got) != 1 || got[0] != hooks.Wrap(hooks.Command(ID, verb)) {
t.Fatalf("%s: %q", typ, got)
}
}
+ if post := hk["PostToolUse"]; len(post) != 1 || post[0].Matcher == nil || *post[0].Matcher != "" {
+ t.Fatalf("PostToolUse should match every tool with matcher \"\": %+v", post)
+ }
if a.State(root) != hooks.Current {
t.Fatalf("state after install: %v", a.State(root))
}
@@ -171,11 +175,65 @@ func TestParse(t *testing.T) {
if ev, err := a.Parse("notification", []byte(`{`+base+`}`)); err != nil || ev != nil {
t.Fatalf("unknown verb: %v %v", ev, err)
}
+ if !slices.Contains(a.Verbs(), VerbPostToolUse) {
+ t.Fatalf("verbs should include post-tool-use: %q", a.Verbs())
+ }
if _, err := a.Parse(VerbStop, []byte(`nope`)); err == nil {
t.Fatal("bad JSON should error")
}
}
+func TestParsePostToolUse(t *testing.T) {
+ a := Agent{}
+ base := `"session_id":"s-1","transcript_path":"/h/.factory/sessions/repo/s-1.jsonl","cwd":"/w","hook_event_name":"PostToolUse"`
+ // An object response is kept as its JSON text.
+ post := `{` + base + `,"tool_name":"Edit","tool_use_id":"toolu_01","tool_input":{"file_path":"/w/main.go","old_string":"a","new_string":"b"},"tool_response":{"filePath":"/w/main.go","success":true}}`
+ ev, err := a.Parse(VerbPostToolUse, []byte(post))
+ if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.SessionID != "s-1" || ev.Transcript != "/h/.factory/sessions/repo/s-1.jsonl" || ev.Cwd != "/w" {
+ t.Fatalf("post-tool-use: %+v %v", ev, err)
+ }
+ tool := ev.Tool
+ if tool == nil || tool.NativeID != "toolu_01" || tool.Name != "Edit" || tool.Status != "completed" ||
+ string(tool.Arguments) != `{"file_path":"/w/main.go","old_string":"a","new_string":"b"}` ||
+ tool.Result == nil || *tool.Result != `{"filePath":"/w/main.go","success":true}` {
+ t.Fatalf("tool: %+v", tool)
+ }
+ // A string response is kept verbatim.
+ ev, err = a.Parse(VerbPostToolUse, []byte(`{`+base+`,"tool_name":"Execute","tool_use_id":"toolu_02","tool_input":{"command":"go test ./..."},"tool_response":"ok\n"}`))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Result == nil || *ev.Tool.Result != "ok\n" || ev.Tool.NativeID != "toolu_02" {
+ t.Fatalf("string response: %+v %v", ev, err)
+ }
+ // No response at all, or a null one, leaves Result nil.
+ for _, tail := range []string{``, `,"tool_response":null`} {
+ ev, err = a.Parse(VerbPostToolUse, []byte(`{`+base+`,"tool_name":"Read","tool_use_id":"toolu_03","tool_input":{"file_path":"/w/x"}`+tail+`}`))
+ if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Result != nil {
+ t.Fatalf("no response (%q): %+v %v", tail, ev, err)
+ }
+ }
+ // Without a tool id (Droid has been seen to omit it) a stable id is
+ // derived from the call, so it still lands per event; the dispatcher
+ // matches the transcript's call by arguments or written path.
+ noID := `{` + base + `,"tool_name":"Edit","tool_input":{"file_path":"/w/x"},"tool_response":"ok"}`
+ ev, err = a.Parse(VerbPostToolUse, []byte(noID))
+ if err != nil || ev == nil || ev.Tool == nil || !strings.HasPrefix(ev.Tool.NativeID, "droid-") || len(ev.Tool.NativeID) != 22 {
+ t.Fatalf("payload without an id: %+v %v", ev, err)
+ }
+ again, _ := a.Parse(VerbPostToolUse, []byte(noID))
+ if again.Tool.NativeID != ev.Tool.NativeID {
+ t.Fatal("the derived id must be stable")
+ }
+ // Without a name there is nothing to record: nil, and the transcript
+ // supplies the call at Stop.
+ for _, raw := range []string{
+ `{` + base + `,"tool_use_id":"toolu_04","tool_input":{}}`,
+ `{` + base + `}`,
+ } {
+ if ev, err := a.Parse(VerbPostToolUse, []byte(raw)); err != nil || ev != nil {
+ t.Fatalf("payload without a call should be nil: %s → %+v %v", raw, ev, err)
+ }
+ }
+}
+
func TestStopModelFromSettings(t *testing.T) {
dir := t.TempDir()
transcript := filepath.Join(dir, "abc.jsonl")
diff --git a/internal/hooks/droid/state_main_test.go b/internal/hooks/droid/state_main_test.go
new file mode 100644
index 0000000..be94557
--- /dev/null
+++ b/internal/hooks/droid/state_main_test.go
@@ -0,0 +1,15 @@
+package droid
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/event.go b/internal/hooks/event.go
index c14e61b..302243b 100644
--- a/internal/hooks/event.go
+++ b/internal/hooks/event.go
@@ -1,7 +1,7 @@
// Package hooks captures sessions run in an agent's own UI through the
// agent's lifecycle hooks (ADR-0013). Each agent integration translates its
// native hook payload into one Event; the dispatcher does the rest: session
-// state under .atlas/hooks/, the transcript read from the offset recorded at
+// state in the CLI state directory, the transcript read from the offset recorded at
// turn start, the git status diff, and delivery to the capture store.
package hooks
@@ -13,6 +13,7 @@ import (
"os"
"regexp"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/capture/store"
)
@@ -63,13 +64,14 @@ type Event struct {
Model string
Usage *store.TokenTotals // a hook-provided usage total for the turn
Files []string // ToolUse: files the tool changed
+ Tool *record.Tool // ToolUse: the completed call, when the payload carries it
ToolUseID string
// TurnEndFailed marks a turn that ended on an API error.
TurnEndFailed bool
}
// sessionIDPattern is what a session id may look like before it becomes a
-// file name under .atlas/hooks/.
+// file name in the hooks state directory.
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$`)
// ErrBadSessionID is a session id that cannot be a file name.
diff --git a/internal/hooks/gitstatus.go b/internal/hooks/gitstatus.go
index 3604f06..d0e4d13 100644
--- a/internal/hooks/gitstatus.go
+++ b/internal/hooks/gitstatus.go
@@ -76,3 +76,39 @@ func relPath(root, p string) string {
}
return p
}
+
+// gitHead is HEAD's commit, or "" (unborn, not a repository).
+func gitHead(ctx context.Context, root string) string {
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "-q", "--verify", "HEAD") //nolint:gosec // G204: fixed read-only git subcommand
+ cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "LC_ALL=C")
+ b, err := cmd.Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(b))
+}
+
+// committedSince are the paths changed by the commits from..HEAD: files the
+// turn committed, which git status no longer shows.
+func committedSince(ctx context.Context, root, from string) []string {
+ if from == "" {
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "git", "-C", root, "diff", "--name-only", "-z", "--no-renames", from, "HEAD") //nolint:gosec // G204: fixed read-only git subcommand
+ cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "LC_ALL=C")
+ b, err := cmd.Output()
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, p := range strings.Split(string(b), "\x00") {
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go
index 7ec4ebe..3487338 100644
--- a/internal/hooks/hooks_test.go
+++ b/internal/hooks/hooks_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ gitlink "github.com/tryatlas/atlas-cli/internal/capture/git"
"github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/capture/store"
)
@@ -46,9 +47,10 @@ func (fakeAgent) Parse(verb string, payload []byte) (*Event, error) {
var p struct {
Session, Transcript, Prompt string
Files []string
+ Tool *record.Tool
}
_ = json.Unmarshal(payload, &p)
- ev := &Event{SessionID: p.Session, Transcript: p.Transcript, Prompt: p.Prompt, Files: p.Files}
+ ev := &Event{SessionID: p.Session, Transcript: p.Transcript, Prompt: p.Prompt, Files: p.Files, Tool: p.Tool}
switch verb {
case "start":
ev.Kind = SessionStart
@@ -213,7 +215,7 @@ func TestDispatcherRecordsATurnDirectly(t *testing.T) {
st := enabledStore(t, root)
transcript := filepath.Join(t.TempDir(), "s.jsonl")
_ = os.WriteFile(transcript, []byte(`{"kind":"prompt","body":"earlier turn"}`+"\n"), 0o600)
- d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test"), Banner: "Atlas: on"}
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil), Banner: "Atlas: on"}
sess := map[string]any{"Session": "s-1", "Transcript": transcript}
if out := runHook(t, d, "start", sess); string(out) != "Atlas: on" {
@@ -291,12 +293,74 @@ func TestDispatcherRecordsATurnDirectly(t *testing.T) {
}
}
+func TestPerEventDeliveryOpensAndClosesOneTurn(t *testing.T) {
+ root := gitRepo(t)
+ st := enabledStore(t, root)
+ transcript := filepath.Join(t.TempDir(), "s.jsonl")
+ _ = os.WriteFile(transcript, nil, 0o600)
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)}
+ sess := map[string]any{"Session": "s-4", "Transcript": transcript}
+ runHook(t, d, "start", sess)
+ runHook(t, d, "prompt", map[string]any{"Session": "s-4", "Transcript": transcript, "Prompt": "make a file"})
+ db := st.DB()
+ var n int
+ _ = db.QueryRow(`SELECT count(*) FROM turn WHERE state='open'`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("open turns after the prompt = %d, want 1", n)
+ }
+ _ = db.QueryRow(`SELECT count(*) FROM agent_message WHERE role='user'`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("prompt rows = %d", n)
+ }
+ // The post-tool hook reports the write as it completes.
+ _ = os.WriteFile(filepath.Join(root, "made.txt"), []byte("x\n"), 0o644)
+ args, _ := json.Marshal(map[string]string{"file_path": filepath.Join(root, "made.txt"), "content": "x\n"})
+ tool := &record.Tool{NativeID: "tc", Name: "Write", Arguments: args, Status: "completed"}
+ runHook(t, d, "tool", map[string]any{"Session": "s-4", "Transcript": transcript, "Tool": tool})
+ var status string
+ if err := db.QueryRow(`SELECT status FROM tool_call WHERE native_call_id='tc'`).Scan(&status); err != nil || status != "completed" {
+ t.Fatalf("tool after the hook: %q %v", status, err)
+ }
+ _ = db.QueryRow(`SELECT count(*) FROM file_touch WHERE path='made.txt'`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("touches after the hook = %d", n)
+ }
+ // The transcript names the same call again, plus the reply; the close
+ // must not duplicate the call.
+ f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600)
+ _, _ = f.WriteString(`{"kind":"prompt","body":"make a file"}` + "\n" +
+ `{"kind":"tool","path":"` + filepath.Join(root, "made.txt") + `","body":"x\n"}` + "\n" +
+ `{"kind":"text","body":"Made it."}` + "\n")
+ _ = f.Close()
+ runHook(t, d, "stop", sess)
+ _ = db.QueryRow(`SELECT count(*) FROM turn`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("turns = %d, want 1", n)
+ }
+ _ = db.QueryRow(`SELECT count(*) FROM turn WHERE state='completed'`).Scan(&n)
+ if n != 1 {
+ t.Fatal("the turn must be completed")
+ }
+ _ = db.QueryRow(`SELECT count(*) FROM tool_call`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("tool calls = %d, want 1 (no duplicate from the transcript)", n)
+ }
+ _ = db.QueryRow(`SELECT count(*) FROM file_touch`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("touches = %d, want 1", n)
+ }
+ _ = db.QueryRow(`SELECT count(*) FROM agent_message`).Scan(&n)
+ if n != 2 {
+ t.Fatalf("messages = %d, want the prompt and the reply", n)
+ }
+}
+
func TestATurnThatOnlyReadRecordsNothing(t *testing.T) {
root := gitRepo(t)
st := enabledStore(t, root)
transcript := filepath.Join(t.TempDir(), "s.jsonl")
_ = os.WriteFile(transcript, nil, 0o600)
- d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test")}
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)}
sess := map[string]any{"Session": "s-2", "Transcript": transcript}
runHook(t, d, "start", sess)
runHook(t, d, "prompt", sess)
@@ -317,7 +381,7 @@ func TestSpoolWhenTheStoreIsLockedAndDrainLater(t *testing.T) {
}
transcript := filepath.Join(t.TempDir(), "s.jsonl")
_ = os.WriteFile(transcript, []byte(`{"kind":"prompt","body":"hi"}`+"\n"+`{"kind":"text","body":"hello"}`+"\n"), 0o600)
- d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test")}
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)}
sess := map[string]any{"Session": "s-3", "Transcript": transcript}
runHook(t, d, "start", sess)
runHook(t, d, "prompt", map[string]any{"Session": "s-3", "Transcript": transcript, "Prompt": "hi"})
@@ -328,11 +392,11 @@ func TestSpoolWhenTheStoreIsLockedAndDrainLater(t *testing.T) {
_ = stt.Save(root)
runHook(t, d, "stop", sess)
stt, _ = LoadState(root, "fake", "s-3")
- if stt.Pending != 2 { // the session start and the turn
- t.Fatalf("pending = %d, want 2 (spooled)", stt.Pending)
+ if stt.Pending != 3 { // the session start, the opening prompt and the turn
+ t.Fatalf("pending = %d, want 3 (spooled)", stt.Pending)
}
ents, _ := os.ReadDir(SpoolDir(root))
- if len(ents) != 2 { // the start and the turn
+ if len(ents) != 3 { // the start, the prompt and the turn
t.Fatalf("spool entries = %d", len(ents))
}
_ = lock.Release()
@@ -409,3 +473,152 @@ func TestGitHooksInstallChainsAndRestores(t *testing.T) {
t.Fatalf("restored = %q", b)
}
}
+
+func tool(id, name, args string) record.CapturedItem {
+ return record.CapturedItem{Tool: &record.Tool{NativeID: id, Name: name, Arguments: json.RawMessage(args), Status: "completed"}}
+}
+
+func TestDropDeliveredPrefersIDsAndKeepsDistinctCommands(t *testing.T) {
+ // Ids match between hook and transcript: two distinct `sleep 4` calls,
+ // one delivered, one whose hook failed; the second must survive.
+ delivered := [][]string{toolKeys(*tool("c1", "Bash", `{"command":"sleep 4"}`).Tool)}
+ items := []record.CapturedItem{tool("c1", "Bash", `{"command":"sleep 4"}`), tool("c2", "Bash", `{"command":"sleep 4"}`), {ID: "m", Mode: "text", Body: "ok"}}
+ kept := dropDelivered(items, delivered)
+ if len(kept) != 3 || !kept[0].Tool.NoSample || kept[1].Tool == nil || kept[1].Tool.NativeID != "c2" || kept[1].Tool.NoSample || kept[2].Tool != nil {
+ t.Fatalf("kept = %+v", kept)
+ }
+ // Ids differ (another agent): match by arguments even when the transcript
+ // pretty-prints them, each delivered command once.
+ delivered = [][]string{toolKeys(*tool("h1", "bash", `{"command":"ls"}`).Tool), toolKeys(*tool("h2", "write", `{"path":"a.txt","contents":"x"}`).Tool)}
+ items = []record.CapturedItem{tool("t1", "Shell", `{ "command": "ls" }`), tool("t2", "Shell", `{"command":"ls"}`), tool("t3", "Write", `{"path": "a.txt", "contents": "x"}`)}
+ kept = dropDelivered(items, delivered)
+ if len(kept) != 1 || kept[0].Tool.NativeID != "t2" {
+ t.Fatalf("kept = %+v", kept)
+ }
+ // Empty arguments never match by arguments; an argv command matches a
+ // string one.
+ delivered = [][]string{toolKeys(*tool("h3", "mcp__x__briefing", `{}`).Tool), toolKeys(*tool("h4", "shell", `{"command":["bash","-lc","cat a"]}`).Tool)}
+ items = []record.CapturedItem{tool("t4", "mcp__y__other", `{}`), tool("t5", "Bash", `{"command":"bash -lc cat a"}`)}
+ kept = dropDelivered(items, delivered)
+ if len(kept) != 1 || kept[0].Tool.NativeID != "t4" {
+ t.Fatalf("kept = %+v", kept)
+ }
+}
+
+func TestShellChangesLandOnTheShellCall(t *testing.T) {
+ root := gitRepo(t)
+ st := enabledStore(t, root)
+ transcript := filepath.Join(t.TempDir(), "s.jsonl")
+ _ = os.WriteFile(transcript, nil, 0o600)
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)}
+ sess := map[string]any{"Session": "s-5", "Transcript": transcript}
+ runHook(t, d, "start", sess)
+ runHook(t, d, "prompt", map[string]any{"Session": "s-5", "Transcript": transcript, "Prompt": "touch a file with the shell"})
+ _ = os.WriteFile(filepath.Join(root, "by-shell.txt"), []byte("x\n"), 0o644)
+ args, _ := json.Marshal(map[string]string{"command": "echo x > by-shell.txt"})
+ runHook(t, d, "tool", map[string]any{"Session": "s-5", "Transcript": transcript,
+ "Tool": &record.Tool{NativeID: "sh1", Name: "Bash", Arguments: args, Status: "completed"}})
+ db := st.DB()
+ var path, callID string
+ if err := db.QueryRow(`SELECT f.path, c.native_call_id FROM file_touch f JOIN tool_call c ON c.id = f.tool_call_id`).Scan(&path, &callID); err != nil || path != "by-shell.txt" || callID != "sh1" {
+ t.Fatalf("touch after the shell hook: %q %q %v", path, callID, err)
+ }
+ f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600)
+ _, _ = f.WriteString(`{"kind":"text","body":"Done."}` + "\n")
+ _ = f.Close()
+ runHook(t, d, "stop", sess)
+ var n int
+ _ = db.QueryRow(`SELECT count(*) FROM tool_call`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("tool calls = %d, want only the shell call (no synthetic changes call)", n)
+ }
+ _ = db.QueryRow(`SELECT count(*) FROM file_touch`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("touches = %d, want 1", n)
+ }
+}
+
+func gitIn(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ c := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=T", "-c", "user.email=t@e", "-c", "commit.gpgsign=false"}, args...)...)
+ c.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null")
+ if out, err := c.CombinedOutput(); err != nil {
+ t.Fatalf("%v %s", err, out)
+ }
+}
+
+func TestACommitMadeDuringTheTurnGetsItsCheckpoint(t *testing.T) {
+ for _, perEvent := range []bool{true, false} {
+ root := gitRepo(t)
+ st := enabledStore(t, root)
+ transcript := filepath.Join(t.TempDir(), "s.jsonl")
+ _ = os.WriteFile(transcript, nil, 0o600)
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)}
+ sess := map[string]any{"Session": "s-c", "Transcript": transcript}
+ runHook(t, d, "start", sess)
+ runHook(t, d, "prompt", map[string]any{"Session": "s-c", "Transcript": transcript, "Prompt": "write and commit"})
+ // One shell command writes the file and commits it; the
+ // post-commit hook would run here and find no touch yet.
+ _ = os.WriteFile(filepath.Join(root, "c2.txt"), []byte("two\n"), 0o644)
+ gitIn(t, root, "add", "c2.txt")
+ gitIn(t, root, "commit", "-q", "-m", "agent commit")
+ if perEvent {
+ args, _ := json.Marshal(map[string]string{"command": "echo two > c2.txt && git add c2.txt && git commit -m 'agent commit'"})
+ runHook(t, d, "tool", map[string]any{"Session": "s-c", "Transcript": transcript,
+ "Tool": &record.Tool{NativeID: "sh", Name: "Bash", Arguments: args, Status: "completed"}})
+ }
+ f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600)
+ _, _ = f.WriteString(`{"kind":"text","body":"Committed."}` + "\n")
+ _ = f.Close()
+ runHook(t, d, "stop", sess)
+ var files string
+ if err := st.DB().QueryRow(`SELECT files_touched FROM checkpoint`).Scan(&files); err != nil || !strings.Contains(files, "c2.txt") {
+ t.Fatalf("perEvent=%v: checkpoint files = %q %v", perEvent, files, err)
+ }
+ var live int
+ _ = st.DB().QueryRow(`SELECT count(*) FROM file_touch WHERE path = 'c2.txt' AND consumed_by_commit IS NULL`).Scan(&live)
+ if live != 0 {
+ t.Fatalf("perEvent=%v: the late touch must be consumed by the commit it linked to", perEvent)
+ }
+ }
+}
+
+func TestCommittingAFileWrittenBeforeTheTurnGetsItsCheckpoint(t *testing.T) {
+ root := gitRepo(t)
+ st := enabledStore(t, root)
+ transcript := filepath.Join(t.TempDir(), "s.jsonl")
+ _ = os.WriteFile(transcript, nil, 0o600)
+ // greet.js was written earlier (another session, or by hand) and is
+ // untracked when the turn starts; the agent only stages and commits it.
+ _ = os.WriteFile(filepath.Join(root, "greet.js"), []byte("hi\n"), 0o644)
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)}
+ sess := map[string]any{"Session": "s-g", "Transcript": transcript}
+ runHook(t, d, "start", sess)
+ runHook(t, d, "prompt", map[string]any{"Session": "s-g", "Transcript": transcript, "Prompt": "commit greet.js"})
+ shell := func(id, cmd string) {
+ args, _ := json.Marshal(map[string]string{"command": cmd})
+ runHook(t, d, "tool", map[string]any{"Session": "s-g", "Transcript": transcript,
+ "Tool": &record.Tool{NativeID: id, Name: "Bash", Arguments: args, Status: "completed"}})
+ }
+ // The agent reads the file first: a read explains no change.
+ readArgs, _ := json.Marshal(map[string]string{"filePath": filepath.Join(root, "greet.js")})
+ runHook(t, d, "tool", map[string]any{"Session": "s-g", "Transcript": transcript,
+ "Tool": &record.Tool{NativeID: "r", Name: "read", Arguments: readArgs, Locations: []string{filepath.Join(root, "greet.js")}, Status: "completed"}})
+ gitIn(t, root, "add", "greet.js")
+ shell("a", "git add greet.js")
+ gitIn(t, root, "commit", "-q", "-m", "feat: greet")
+ // git's post-commit hook walks the commit before the agent's tool hook
+ // reports the command.
+ if _, err := gitlink.Link(context.Background(), st, root, time.Now); err != nil {
+ t.Fatal(err)
+ }
+ shell("c", `git commit -m "feat: greet"`)
+ f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600)
+ _, _ = f.WriteString(`{"kind":"text","body":"Committed."}` + "\n")
+ _ = f.Close()
+ runHook(t, d, "stop", sess)
+ var files string
+ if err := st.DB().QueryRow(`SELECT files_touched FROM checkpoint`).Scan(&files); err != nil || !strings.Contains(files, "greet.js") {
+ t.Fatalf("checkpoint files = %q %v", files, err)
+ }
+}
diff --git a/internal/hooks/mcpconfig/state_main_test.go b/internal/hooks/mcpconfig/state_main_test.go
new file mode 100644
index 0000000..1a56c3f
--- /dev/null
+++ b/internal/hooks/mcpconfig/state_main_test.go
@@ -0,0 +1,15 @@
+package mcpconfig
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/opencode/export.go b/internal/hooks/opencode/export.go
index 2097482..21ed4aa 100644
--- a/internal/hooks/opencode/export.go
+++ b/internal/hooks/opencode/export.go
@@ -292,10 +292,13 @@ func turnOf(msgs []message) *hooks.Turn {
return t
}
-// toolOf translates a tool part.
+// toolOf translates a tool part. Its id is the callID, which the plugin's
+// tool.execute.after hook also receives (opencode.go toolOf): one id for one
+// call, however it arrives. The part id (prt_…) stands in when a part has no
+// callID.
func toolOf(p part, fallbackID string) *record.Tool {
tool := &record.Tool{NativeID: p.CallID, Name: p.Tool, Status: "completed"}
- if p.ID != "" {
+ if tool.NativeID == "" {
tool.NativeID = p.ID
}
if tool.NativeID == "" {
diff --git a/internal/hooks/opencode/export_test.go b/internal/hooks/opencode/export_test.go
index 1f37a4c..1c7507e 100644
--- a/internal/hooks/opencode/export_test.go
+++ b/internal/hooks/opencode/export_test.go
@@ -75,8 +75,8 @@ func TestTurnOf(t *testing.T) {
if write == nil || write.Name != "write" || write.Locations != nil || !strings.Contains(string(write.Arguments), `"filePath": "/x/hello.txt"`) {
t.Fatalf("write %+v", write)
}
- patch := turn.Items[3].Tool
- if patch == nil || patch.NativeID != "prt_ap" || strings.Join(patch.Locations, ",") != "/x/a.go,/x/b.go" {
+ patch := turn.Items[3].Tool // the callID wins over the part id: it is what the tool hook sends too
+ if patch == nil || patch.NativeID != "call_p" || strings.Join(patch.Locations, ",") != "/x/a.go,/x/b.go" {
t.Fatalf("apply_patch %+v", patch)
}
bash := turn.Items[4].Tool
diff --git a/internal/hooks/opencode/opencode.go b/internal/hooks/opencode/opencode.go
index d34b1a4..422a8f9 100644
--- a/internal/hooks/opencode/opencode.go
+++ b/internal/hooks/opencode/opencode.go
@@ -16,6 +16,7 @@ import (
"path/filepath"
"time"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
"github.com/tryatlas/atlas-cli/internal/project"
)
@@ -35,6 +36,7 @@ const (
VerbTurnEnd = "turn-end"
VerbCompaction = "compaction"
VerbSessionEnd = "session-end"
+ VerbToolUse = "tool-use"
)
// Plugin is the plugin OpenCode loads from .opencode/plugins/atlas.ts.
@@ -57,7 +59,7 @@ func (Agent) Binary() string { return "opencode" }
// Verbs are the hook verbs the plugin invokes.
func (Agent) Verbs() []string {
- return []string{VerbSessionStart, VerbTurnStart, VerbTurnEnd, VerbCompaction, VerbSessionEnd}
+ return []string{VerbSessionStart, VerbTurnStart, VerbToolUse, VerbTurnEnd, VerbCompaction, VerbSessionEnd}
}
// PluginPath is the plugin file under root. OpenCode loads every file in
@@ -133,11 +135,39 @@ func (Agent) Response(hooks.Kind, string) []byte { return nil }
func (Agent) ResumeCommand(id string) string { return "opencode -s " + id }
// payload is what the plugin sends: session_id always, prompt and model at
-// turn start, model at turn end.
+// turn start, model at turn end, and at tool-use the completed call from
+// OpenCode's tool.execute.after hook: the tool's wire name, its call id (the
+// callID the export's tool part carries too), the title and output OpenCode
+// shows, the tool's metadata, and the arguments (from the hook's input, or
+// remembered from tool.execute.before).
type payload struct {
- SessionID string `json:"session_id"`
- Prompt string `json:"prompt"`
- Model string `json:"model"`
+ SessionID string `json:"session_id"`
+ Prompt string `json:"prompt"`
+ Model string `json:"model"`
+ Tool string `json:"tool"`
+ CallID string `json:"call_id"`
+ Title string `json:"title"`
+ Output *string `json:"output"`
+ Metadata json.RawMessage `json:"metadata"`
+ Args json.RawMessage `json:"args"`
+}
+
+// toolOf is the completed call a tool-use payload describes, in the shape
+// the export reader gives the same call (export.go toolOf): the same
+// NativeID, so the turn-end read drops it as already delivered.
+func (p payload) toolOf() *record.Tool {
+ if p.CallID == "" || p.Tool == "" {
+ return nil
+ }
+ t := &record.Tool{NativeID: p.CallID, Name: p.Tool, Title: p.Title, Status: "completed"}
+ if a := bytes.TrimSpace(p.Args); len(a) > 0 && string(a) != "null" {
+ t.Arguments = a
+ }
+ if p.Output != nil {
+ t.Result = p.Output
+ }
+ t.Locations = (&toolState{Metadata: p.Metadata}).files()
+ return t
}
// Parse maps a hook verb and payload to an event. The payload names no
@@ -158,6 +188,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
case VerbTurnStart:
ev.Kind = hooks.TurnStart
ev.Prompt = StripReminders(p.Prompt)
+ case VerbToolUse:
+ ev.Kind = hooks.ToolUse
+ ev.Tool = p.toolOf()
+ if ev.Tool == nil {
+ return nil, nil
+ }
case VerbTurnEnd:
ev.Kind = hooks.TurnEnd
case VerbCompaction:
diff --git a/internal/hooks/opencode/opencode_test.go b/internal/hooks/opencode/opencode_test.go
index c07329c..1847e70 100644
--- a/internal/hooks/opencode/opencode_test.go
+++ b/internal/hooks/opencode/opencode_test.go
@@ -32,6 +32,11 @@ func TestPluginInstallStateUninstall(t *testing.T) {
t.Fatalf("plugin does not send %s", verb)
}
}
+ for _, hook := range []string{`"tool.execute.before"`, `"tool.execute.after"`, `sendSync("tool-use"`} {
+ if !strings.Contains(string(b), hook) {
+ t.Fatalf("plugin lacks %s", hook)
+ }
+ }
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o644 {
t.Fatalf("mode %v", fi.Mode().Perm())
}
@@ -124,13 +129,40 @@ func TestParse(t *testing.T) {
t.Fatalf("%s: transcript %q", c.verb, ev.Transcript)
}
}
+ // tool-use: OpenCode's tool.execute.after as the plugin forwards it, with
+ // the id the export's tool part carries as callID.
+ toolUse := `{"session_id":"` + id + `","tool":"write","call_id":"ZdNFvSAofdecladc","title":"rt.txt","output":"Wrote file successfully.",` +
+ `"metadata":{"diagnostics":{},"filepath":"/w/rt.txt","exists":false},"args":{"filePath":"/w/rt.txt","content":"realtime\n"}}`
+ ev, err := a.Parse(VerbToolUse, []byte(toolUse))
+ if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.SessionID != id || ev.Tool == nil {
+ t.Fatalf("tool-use: %+v %v", ev, err)
+ }
+ if tool := ev.Tool; tool.NativeID != "ZdNFvSAofdecladc" || tool.Name != "write" || tool.Title != "rt.txt" || tool.Status != "completed" ||
+ string(tool.Arguments) != `{"filePath":"/w/rt.txt","content":"realtime\n"}` || tool.Result == nil || *tool.Result != "Wrote file successfully." || tool.Locations != nil {
+ t.Fatalf("tool-use tool: %+v", tool)
+ }
+ if !strings.HasSuffix(ev.Transcript, filepath.Join("hooks", ID, "export", id+".json")) {
+ t.Fatalf("tool-use names no export cache: %q", ev.Transcript)
+ }
+ // apply_patch's metadata lists the files it wrote; null args and a
+ // missing output are nothing, not "null".
+ ev, err = a.Parse(VerbToolUse, []byte(`{"session_id":"`+id+`","tool":"apply_patch","call_id":"call_p","metadata":{"files":[{"filePath":"/w/a.go"},{"filePath":"/w/b.go"}]},"args":null}`))
+ if err != nil || ev == nil || ev.Tool == nil || strings.Join(ev.Tool.Locations, ",") != "/w/a.go,/w/b.go" || ev.Tool.Arguments != nil || ev.Tool.Result != nil {
+ t.Fatalf("apply_patch tool-use: %+v %v", ev.Tool, err)
+ }
+ // Without a call id or a tool name there is no call to record.
+ for _, raw := range []string{`{"session_id":"` + id + `","tool":"bash"}`, `{"session_id":"` + id + `","call_id":"c1"}`} {
+ if ev, err := a.Parse(VerbToolUse, []byte(raw)); ev != nil || err != nil {
+ t.Fatalf("tool-use without a call: %+v %v", ev, err)
+ }
+ }
if ev, err := a.Parse("something-else", []byte(`{"session_id":"x"}`)); ev != nil || err != nil {
t.Fatalf("unknown verb: %v %v", ev, err)
}
if _, err := a.Parse(VerbTurnEnd, []byte(`not json`)); err == nil {
t.Fatal("bad payload accepted")
}
- ev, err := a.Parse(VerbTurnEnd, []byte(`{"session_id":"../../etc"}`))
+ ev, err = a.Parse(VerbTurnEnd, []byte(`{"session_id":"../../etc"}`))
if err != nil || ev.Transcript != "" {
t.Fatalf("an unusable id named a cache: %+v %v", ev, err)
}
diff --git a/internal/hooks/opencode/plugin.ts b/internal/hooks/opencode/plugin.ts
index fe24ab0..ee5a812 100644
--- a/internal/hooks/opencode/plugin.ts
+++ b/internal/hooks/opencode/plugin.ts
@@ -1,7 +1,7 @@
// Atlas plugin for OpenCode — written by atlas agents add; do not edit (rewritten on the next add).
//
-// Forwards OpenCode's session lifecycle to `atlas hooks opencode ` with
-// one JSON payload on stdin. node:child_process runs under Bun (the CLI and
+// Forwards OpenCode's session lifecycle, and each completed tool call, to
+// `atlas hooks opencode ` with one JSON payload on stdin. node:child_process runs under Bun (the CLI and
// TUI) and Node (the desktop sidecar). A machine without atlas on PATH runs a
// silent no-op, and nothing here throws: a failing hook never fails OpenCode.
import { spawn, spawnSync } from "node:child_process"
@@ -16,6 +16,8 @@ export const AtlasPlugin: Plugin = async ({ directory }) => {
const roles = new Map() // message id → role
const early = new Map() // user text seen before its message.updated
const started = new Set() // user message ids whose turn-start fired
+ const toolArgs = new Map() // callID → args, from tool.execute.before
+ const MAX_OUTPUT = 64 * 1024 // bytes of tool output (and of metadata) a payload carries
function argv(verb: string): string[] {
return ["sh", "-c", `if ! command -v atlas >/dev/null 2>&1; then exit 0; fi; exec atlas hooks opencode ${verb}`]
@@ -54,6 +56,29 @@ export const AtlasPlugin: Plugin = async ({ directory }) => {
roles.clear()
early.clear()
started.clear()
+ toolArgs.clear()
+ }
+
+ // clip bounds a string to MAX_OUTPUT UTF-16 units (at most 2× the bytes),
+ // marking the cut.
+ function clip(text: unknown): string {
+ const s = typeof text === "string" ? text : text === undefined || text === null ? "" : String(text)
+ if (s.length <= MAX_OUTPUT) return s
+ return s.slice(0, MAX_OUTPUT) + `\n… [truncated ${s.length - MAX_OUTPUT} chars]`
+ }
+
+ // smallMetadata keeps the tool's metadata when it is small; a large one
+ // (read's display text, a big diff) is cut down to the files it names,
+ // which is what the hook reads from it.
+ function smallMetadata(meta: unknown): unknown {
+ if (meta === undefined || meta === null || typeof meta !== "object") return meta ?? null
+ try {
+ if (JSON.stringify(meta).length <= MAX_OUTPUT) return meta
+ const files = (meta as any).files
+ return Array.isArray(files) ? { files, truncated: true } : { truncated: true }
+ } catch {
+ return null
+ }
}
// follow makes id the tracked session; it reports whether that is new.
@@ -73,6 +98,44 @@ export const AtlasPlugin: Plugin = async ({ directory }) => {
}
return {
+ // The arguments as the tool is about to run; the after hook carries them
+ // too in recent OpenCode, and this copy covers versions whose after
+ // hook does not.
+ "tool.execute.before": async (input, output) => {
+ try {
+ if (!input?.callID) return
+ if (toolArgs.size > 256) toolArgs.clear()
+ toolArgs.set(input.callID, output?.args)
+ } catch {
+ // never fail OpenCode
+ }
+ },
+
+ // One completed tool call, delivered into the open turn now (before the
+ // reply closes it); the turn-end export then skips it by callID. Waited
+ // for, so a short task's rows land before the same tick's idle event
+ // runs turn-end.
+ "tool.execute.after": async (input, output) => {
+ try {
+ if (!input?.callID || !input.tool) return
+ const sessionID = input.sessionID ?? session
+ const args = (input as any).args !== undefined ? (input as any).args : toolArgs.get(input.callID)
+ toolArgs.delete(input.callID)
+ if (!sessionID || children.has(sessionID)) return
+ sendSync("tool-use", {
+ session_id: sessionID,
+ tool: input.tool,
+ call_id: input.callID,
+ title: typeof output?.title === "string" ? output.title : "",
+ output: clip(output?.output),
+ metadata: smallMetadata(output?.metadata),
+ args: args === undefined ? null : args,
+ })
+ } catch {
+ // never fail OpenCode
+ }
+ },
+
event: async ({ event }) => {
try {
const props = (event as any).properties
diff --git a/internal/hooks/opencode/state_main_test.go b/internal/hooks/opencode/state_main_test.go
new file mode 100644
index 0000000..4e7c1ef
--- /dev/null
+++ b/internal/hooks/opencode/state_main_test.go
@@ -0,0 +1,15 @@
+package opencode
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/pi/extension.ts b/internal/hooks/pi/extension.ts
index 50b2177..f38a6fe 100644
--- a/internal/hooks/pi/extension.ts
+++ b/internal/hooks/pi/extension.ts
@@ -9,6 +9,8 @@ import { execFile } from "node:child_process";
const HOOK_TIMEOUT_MS = 10_000;
const MAX_OUTPUT = 4 * 1024 * 1024;
+// A tool result is forwarded as text, cut here; the session file keeps it whole.
+const MAX_RESULT = 64 * 1024;
// A nested pi (a subagent spawns one per task, inside the project, where Pi
// finds this same extension) must not report its lifecycle as the user's
@@ -20,7 +22,7 @@ function hookCommand(verb: string): string {
return `if ! command -v atlas >/dev/null 2>&1; then exit 0; fi; exec atlas hooks pi ${verb}`;
}
-function fire(verb: string, payload: Record): Promise {
+function run(verb: string, payload: Record): Promise {
return new Promise((resolve) => {
try {
const child = execFile(
@@ -37,6 +39,33 @@ function fire(verb: string, payload: Record): Promise {
});
}
+// Hooks run one at a time, in the order they were fired. Pi executes
+// parallel tool calls concurrently and fires their tool_result events
+// together; concurrent hook processes would race on the session's state
+// file and the store's writer lock, and a tool result could land after the
+// agent_end that closes its turn.
+let queue: Promise = Promise.resolve();
+
+function fire(verb: string, payload: Record): Promise {
+ const next = queue.then(() => run(verb, payload));
+ queue = next.catch(() => {});
+ return next;
+}
+
+// resultText flattens a tool result's content to its text parts, truncated.
+function resultText(content: unknown): string {
+ if (typeof content === "string") return content.slice(0, MAX_RESULT);
+ if (!Array.isArray(content)) return "";
+ const parts: string[] = [];
+ for (const c of content) {
+ if (c && typeof c === "object" && (c as { type?: unknown }).type === "text") {
+ const text = (c as { text?: unknown }).text;
+ if (typeof text === "string" && text !== "") parts.push(text);
+ }
+ }
+ return parts.join("\n").slice(0, MAX_RESULT);
+}
+
export default function (pi: ExtensionAPI) {
const nested = Boolean(process.env[NESTED_ENV]);
process.env[NESTED_ENV] = "1";
@@ -59,6 +88,25 @@ export default function (pi: ExtensionAPI) {
});
});
+ // Each completed tool call is delivered as it finishes, so the capture
+ // holds the turn's calls before the reply lands. The call id is the one
+ // the session file's toolCall carries, which is how the turn-end read
+ // knows not to record the call twice. A nested call (one tool running
+ // another) is not in the transcript and is left to its parent.
+ pi.on("tool_result", async (event, ctx) => {
+ if (event.parentToolCallId) return;
+ await fire("tool-result", {
+ type: "tool_result",
+ cwd: ctx.cwd,
+ session_file: ctx.sessionManager.getSessionFile(),
+ tool_call_id: event.toolCallId,
+ tool_name: event.toolName,
+ args: event.input ?? {},
+ result: resultText(event.content),
+ is_error: Boolean(event.isError),
+ });
+ });
+
pi.on("agent_end", async (_event, ctx) => {
await fire("agent-end", {
type: "agent_end",
diff --git a/internal/hooks/pi/pi.go b/internal/hooks/pi/pi.go
index f5c4a29..3d6a726 100644
--- a/internal/hooks/pi/pi.go
+++ b/internal/hooks/pi/pi.go
@@ -14,6 +14,7 @@ import (
"path/filepath"
"strings"
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
"github.com/tryatlas/atlas-cli/internal/hooks"
)
@@ -29,6 +30,7 @@ type Agent struct{}
const (
VerbSessionStart = "session-start"
VerbBeforeAgentStart = "before-agent-start"
+ VerbToolResult = "tool-result"
VerbAgentEnd = "agent-end"
)
@@ -50,7 +52,7 @@ func (Agent) Binary() string { return "pi" }
// Verbs are the hook verbs the extension invokes.
func (Agent) Verbs() []string {
- return []string{VerbSessionStart, VerbBeforeAgentStart, VerbAgentEnd}
+ return []string{VerbSessionStart, VerbBeforeAgentStart, VerbToolResult, VerbAgentEnd}
}
// ExtensionDir is the directory Pi loads the Atlas extension from; Pi
@@ -145,6 +147,25 @@ type payload struct {
SessionFile string `json:"session_file"`
SessionID string `json:"session_id"`
Prompt string `json:"prompt"`
+ // tool-result: the completed call, with its result as text.
+ ToolCallID string `json:"tool_call_id"`
+ ToolName string `json:"tool_name"`
+ Args json.RawMessage `json:"args"`
+ Result *string `json:"result"`
+ IsError bool `json:"is_error"`
+}
+
+// toolOf is the completed call a tool-result payload describes; nil when
+// the payload names no call.
+func (p payload) toolOf() *record.Tool {
+ if p.ToolCallID == "" || p.ToolName == "" {
+ return nil
+ }
+ t := &record.Tool{NativeID: p.ToolCallID, Name: p.ToolName, Arguments: p.Args, Result: p.Result, Status: "completed"}
+ if p.IsError {
+ t.Status = "failed"
+ }
+ return t
}
// Parse maps a hook verb and payload to an event. A payload naming no
@@ -170,6 +191,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) {
case VerbBeforeAgentStart:
ev.Kind = hooks.TurnStart
ev.Prompt = p.Prompt
+ case VerbToolResult:
+ ev.Kind = hooks.ToolUse
+ ev.Tool = p.toolOf()
+ if ev.Tool == nil {
+ return nil, nil
+ }
case VerbAgentEnd:
ev.Kind = hooks.TurnEnd
default:
diff --git a/internal/hooks/pi/pi_test.go b/internal/hooks/pi/pi_test.go
index 8ca152d..9a74ae9 100644
--- a/internal/hooks/pi/pi_test.go
+++ b/internal/hooks/pi/pi_test.go
@@ -26,7 +26,7 @@ func TestInstallStateUninstall(t *testing.T) {
if !strings.HasPrefix(string(b), "// Atlas extension for Pi — written by atlas agents add; do not edit (rewritten on the next add).\n") {
t.Fatalf("header missing:\n%s", b[:120])
}
- for _, want := range []string{"exec atlas hooks pi ${verb}", `"session-start"`, `"before-agent-start"`, `"agent-end"`, "ATLAS_PI_NESTED", "command -v atlas", "10_000"} {
+ for _, want := range []string{"exec atlas hooks pi ${verb}", `"session-start"`, `"before-agent-start"`, `"tool-result"`, `"agent-end"`, `pi.on("tool_result"`, "parentToolCallId", "64 * 1024", "let queue", "ATLAS_PI_NESTED", "command -v atlas", "10_000"} {
if !strings.Contains(string(b), want) {
t.Errorf("extension lacks %q", want)
}
@@ -124,6 +124,30 @@ func TestParse(t *testing.T) {
if ev.Kind != hooks.TurnEnd || ev.SessionID != "explicit" {
t.Fatalf("agent-end = %+v", ev)
}
+ ev, err = a.Parse(VerbToolResult, []byte(`{"type":"tool_result","cwd":"/repo","session_file":"`+file+`","tool_call_id":"call_1","tool_name":"write","args":{"path":"rt.txt","content":"realtime\n"},"result":"Successfully wrote 9 bytes to rt.txt","is_error":false}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ev.Kind != hooks.ToolUse || ev.Tool == nil || ev.SessionID != "3f2b1c4d-aaaa-bbbb-cccc-0123456789ab" {
+ t.Fatalf("tool-result = %+v", ev)
+ }
+ if ev.Tool.NativeID != "call_1" || ev.Tool.Name != "write" || ev.Tool.Status != "completed" || string(ev.Tool.Arguments) != `{"path":"rt.txt","content":"realtime\n"}` {
+ t.Fatalf("tool = %+v", ev.Tool)
+ }
+ if ev.Tool.Result == nil || *ev.Tool.Result != "Successfully wrote 9 bytes to rt.txt" {
+ t.Fatalf("tool result = %v", ev.Tool.Result)
+ }
+ ev, err = a.Parse(VerbToolResult, []byte(`{"type":"tool_result","session_file":"`+file+`","tool_call_id":"call_2","tool_name":"bash","args":{"command":"cat missing"},"result":"cat: missing: No such file","is_error":true}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ev.Tool == nil || ev.Tool.Status != "failed" || ev.Tool.NativeID != "call_2" {
+ t.Fatalf("failed tool-result = %+v", ev.Tool)
+ }
+ // A result payload with no call to speak of is dropped.
+ if ev, err := a.Parse(VerbToolResult, []byte(`{"type":"tool_result","session_file":"`+file+`","tool_name":"bash"}`)); err != nil || ev != nil {
+ t.Fatalf("callless tool-result = %+v, %v", ev, err)
+ }
// No session: nothing to track (pi --no-session).
if ev, err := a.Parse(VerbAgentEnd, []byte(`{"type":"agent_end","cwd":"/repo"}`)); err != nil || ev != nil {
t.Fatalf("sessionless = %+v, %v", ev, err)
diff --git a/internal/hooks/pi/state_main_test.go b/internal/hooks/pi/state_main_test.go
new file mode 100644
index 0000000..4b56897
--- /dev/null
+++ b/internal/hooks/pi/state_main_test.go
@@ -0,0 +1,15 @@
+package pi
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hooks/plans.go b/internal/hooks/plans.go
new file mode 100644
index 0000000..56b56c1
--- /dev/null
+++ b/internal/hooks/plans.go
@@ -0,0 +1,271 @@
+package hooks
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "slices"
+ "strings"
+
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
+ memrecord "github.com/tryatlas/atlas-cli/internal/memory/record"
+ "github.com/tryatlas/atlas-cli/internal/memory/server"
+)
+
+// planMaxRunes caps a plan as the ACP session does.
+const planMaxRunes = 600
+
+// planOf reads a plan out of an agent's plan tool call: Claude Code's and
+// Factory Droid's TodoWrite, Codex's update_plan, OpenCode's todowrite,
+// Cursor's todo_write, Copilot's update_todo (a list, or a Markdown
+// checklist), and Claude Code's ExitPlanMode (the plan as Markdown). The
+// text is the ACP session's: "- [status] item" lines, at most 600
+// characters; status is done once every item is completed.
+func planOf(t record.Tool) (text, status string, ok bool) {
+ name := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(t.Name))
+ var args map[string]json.RawMessage
+ if json.Unmarshal(t.Arguments, &args) != nil {
+ return "", "", false
+ }
+ switch {
+ case name == "exitplanmode":
+ var md string
+ if json.Unmarshal(args["plan"], &md) != nil || strings.TrimSpace(md) == "" {
+ return "", "", false
+ }
+ return clipPlan(strings.TrimSpace(md)), "active", true
+ case strings.Contains(name, "todo") || name == "updateplan" || name == "plan":
+ default:
+ return "", "", false
+ }
+ for _, k := range []string{"todos", "plan", "items", "tasks"} {
+ raw, has := args[k]
+ if !has {
+ continue
+ }
+ var md string
+ if json.Unmarshal(raw, &md) == nil {
+ if strings.TrimSpace(md) == "" {
+ return "", "", false
+ }
+ return clipPlan(strings.TrimSpace(md)), checklistStatus(md), true
+ }
+ var items []map[string]any
+ if json.Unmarshal(raw, &items) != nil || len(items) == 0 {
+ continue
+ }
+ var lines []string
+ all := true
+ for _, it := range items {
+ content := firstString(it, "content", "step", "text", "title", "description", "task")
+ if content == "" {
+ continue
+ }
+ st := itemStatus(it)
+ if st != "completed" {
+ all = false
+ }
+ lines = append(lines, fmt.Sprintf("- [%s] %s", st, strings.Join(strings.Fields(content), " ")))
+ }
+ if len(lines) == 0 {
+ return "", "", false
+ }
+ status := "active"
+ if all {
+ status = "done"
+ }
+ return clipPlan(strings.Join(lines, "\n")), status, true
+ }
+ return "", "", false
+}
+
+func firstString(m map[string]any, keys ...string) string {
+ for _, k := range keys {
+ if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+// itemStatus is ACP's vocabulary: pending, in_progress, completed (and
+// cancelled, kept as said).
+func itemStatus(it map[string]any) string {
+ if b, ok := it["completed"].(bool); ok && b {
+ return "completed"
+ }
+ if b, ok := it["done"].(bool); ok && b {
+ return "completed"
+ }
+ s, _ := it["status"].(string)
+ if s == "" {
+ s, _ = it["state"].(string)
+ }
+ switch strings.ToLower(strings.ReplaceAll(s, "-", "_")) {
+ case "completed", "complete", "done", "finished":
+ return "completed"
+ case "in_progress", "inprogress", "active", "running", "doing":
+ return "in_progress"
+ case "cancelled", "canceled":
+ return "cancelled"
+ }
+ return "pending"
+}
+
+// checklistStatus is done when a Markdown checklist has items and all are
+// ticked.
+func checklistStatus(md string) string {
+ open, done := 0, 0
+ for _, l := range strings.Split(md, "\n") {
+ l = strings.TrimSpace(l)
+ switch {
+ case strings.HasPrefix(l, "- [ ]") || strings.HasPrefix(l, "* [ ]"):
+ open++
+ case strings.HasPrefix(strings.ToLower(l), "- [x]") || strings.HasPrefix(strings.ToLower(l), "* [x]"):
+ done++
+ }
+ }
+ if done > 0 && open == 0 {
+ return "done"
+ }
+ return "active"
+}
+
+func clipPlan(s string) string {
+ if r := []rune(s); len(r) > planMaxRunes {
+ return string(r[:planMaxRunes])
+ }
+ return s
+}
+
+// foldPlans records the latest plan among items as the session's plan
+// (plan_set: it supersedes the one before, as the app's working memory).
+func foldPlans(ctx context.Context, mem *server.Server, g server.Grant, items []record.CapturedItem) {
+ var text, status string
+ for _, it := range items {
+ if it.Tool == nil {
+ continue
+ }
+ if t, s, ok := planOf(*it.Tool); ok {
+ text, status = t, s
+ }
+ }
+ if text != "" {
+ _ = mem.Fold(ctx, g, memrecord.Event{Kind: "plan_set", Text: text, Status: status})
+ }
+}
+
+// PlanTask is one task of an incremental task list.
+type PlanTask struct {
+ ID string `json:"id"`
+ Subject string `json:"subject"`
+ Status string `json:"status"`
+}
+
+// PlanFold is a plan to record.
+type PlanFold struct {
+ Text string `json:"text"`
+ Status string `json:"status"`
+}
+
+var taskNumber = regexp.MustCompile(`#(\d+)`)
+
+// trackTasks follows Claude Code's task tools: TaskCreate adds a task (its
+// number is in the result, "Task #3 created"), TaskUpdate changes one
+// (status, subject; "deleted" removes it). It returns the whole list as a
+// plan when the call changed it.
+func trackTasks(st *State, t record.Tool) *PlanFold {
+ name := strings.ToLower(t.Name)
+ if name != "taskcreate" && name != "taskupdate" {
+ return nil
+ }
+ if t.NativeID != "" {
+ if slices.Contains(st.TaskCalls, t.NativeID) {
+ return nil
+ }
+ st.TaskCalls = append(st.TaskCalls, t.NativeID)
+ }
+ var args map[string]any
+ if json.Unmarshal(t.Arguments, &args) != nil {
+ return nil
+ }
+ switch name {
+ case "taskcreate":
+ subject := firstString(args, "subject", "title", "content", "description")
+ if subject == "" {
+ return nil
+ }
+ id := ""
+ if t.Result != nil {
+ if m := taskNumber.FindStringSubmatch(*t.Result); m != nil {
+ id = m[1]
+ }
+ }
+ if id == "" {
+ id = fmt.Sprint(len(st.Tasks) + 1)
+ }
+ st.Tasks = append(st.Tasks, PlanTask{ID: id, Subject: strings.Join(strings.Fields(subject), " "), Status: "pending"})
+ case "taskupdate":
+ id := fmt.Sprint(args["taskId"])
+ if s, ok := args["taskId"].(string); ok {
+ id = s
+ } else if f, ok := args["taskId"].(float64); ok {
+ id = fmt.Sprint(int64(f))
+ }
+ i := -1
+ for j, task := range st.Tasks {
+ if task.ID == id {
+ i = j
+ }
+ }
+ if i < 0 {
+ return nil
+ }
+ if s, _ := args["status"].(string); s != "" {
+ if strings.EqualFold(s, "deleted") {
+ st.Tasks = append(st.Tasks[:i], st.Tasks[i+1:]...)
+ break
+ }
+ st.Tasks[i].Status = itemStatus(map[string]any{"status": s})
+ }
+ if s := firstString(args, "subject"); s != "" {
+ st.Tasks[i].Subject = strings.Join(strings.Fields(s), " ")
+ }
+ default:
+ return nil
+ }
+ if len(st.Tasks) == 0 {
+ return nil
+ }
+ return planFromTasks(st.Tasks)
+}
+
+// hadTaskCalls reports a task tool among items.
+func hadTaskCalls(items []record.CapturedItem) bool {
+ for _, it := range items {
+ if it.Tool != nil {
+ if n := strings.ToLower(it.Tool.Name); n == "taskcreate" || n == "taskupdate" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// planFromTasks is the task list as plan text.
+func planFromTasks(tasks []PlanTask) *PlanFold {
+ var lines []string
+ all := true
+ for _, task := range tasks {
+ lines = append(lines, fmt.Sprintf("- [%s] %s", task.Status, task.Subject))
+ if task.Status != "completed" {
+ all = false
+ }
+ }
+ status := "active"
+ if all {
+ status = "done"
+ }
+ return &PlanFold{Text: clipPlan(strings.Join(lines, "\n")), Status: status}
+}
diff --git a/internal/hooks/plans_test.go b/internal/hooks/plans_test.go
new file mode 100644
index 0000000..2526a4b
--- /dev/null
+++ b/internal/hooks/plans_test.go
@@ -0,0 +1,99 @@
+package hooks
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
+ memrecord "github.com/tryatlas/atlas-cli/internal/memory/record"
+)
+
+func TestPlanOfEachAgentsPlanTool(t *testing.T) {
+ for _, c := range []struct {
+ name, tool, args string
+ text, status string
+ }{
+ {"claude todos", "TodoWrite", `{"todos":[{"content":"Read the code","status":"completed","activeForm":"Reading"},{"content":"Fix the bug","status":"in_progress"},{"content":"Run tests","status":"pending"}]}`,
+ "- [completed] Read the code\n- [in_progress] Fix the bug\n- [pending] Run tests", "active"},
+ {"codex update_plan", "update_plan", `{"explanation":"x","plan":[{"step":"Write it","status":"completed"},{"step":"Ship it","status":"completed"}]}`,
+ "- [completed] Write it\n- [completed] Ship it", "done"},
+ {"opencode todowrite", "todowrite", `{"todos":[{"id":"1","content":"Add greet","status":"in_progress","priority":"high"}]}`,
+ "- [in_progress] Add greet", "active"},
+ {"cursor todo_write", "todo_write", `{"merge":false,"todos":[{"id":"a","content":"Lint","status":"cancelled"}]}`,
+ "- [cancelled] Lint", "active"},
+ {"copilot checklist", "update_todo", `{"todos":"- [x] One\n- [x] Two"}`, "- [x] One\n- [x] Two", "done"},
+ {"claude plan mode", "ExitPlanMode", `{"plan":"## Plan\n1. Do it"}`, "## Plan\n1. Do it", "active"},
+ } {
+ text, status, ok := planOf(record.Tool{Name: c.tool, Arguments: []byte(c.args)})
+ if !ok || text != c.text || status != c.status {
+ t.Errorf("%s: %v %q %q", c.name, ok, text, status)
+ }
+ }
+ if _, _, ok := planOf(record.Tool{Name: "Bash", Arguments: []byte(`{"command":"ls"}`)}); ok {
+ t.Fatal("a shell call is not a plan")
+ }
+ long := `{"todos":[{"content":"` + strings.Repeat("x", 800) + `","status":"pending"}]}`
+ if text, _, _ := planOf(record.Tool{Name: "TodoWrite", Arguments: []byte(long)}); len([]rune(text)) != planMaxRunes {
+ t.Fatalf("not capped: %d", len([]rune(text)))
+ }
+}
+
+func TestTrackTasksBuildsThePlan(t *testing.T) {
+ st := &State{}
+ res := func(s string) *string { return &s }
+ call := func(name, args string, result *string) *PlanFold {
+ return trackTasks(st, record.Tool{Name: name, Arguments: []byte(args), Result: result})
+ }
+ call("TaskCreate", `{"subject":"Write farewell()","activeForm":"Writing"}`, res("Task #1 created successfully: Write farewell()"))
+ call("TaskCreate", `{"subject":"Add a test"}`, res("Task #2 created successfully: Add a test"))
+ p := call("TaskUpdate", `{"taskId":"1","status":"in_progress"}`, res("Updated task #1 status"))
+ if p == nil || p.Text != "- [in_progress] Write farewell()\n- [pending] Add a test" || p.Status != "active" {
+ t.Fatalf("%+v", p)
+ }
+ call("TaskUpdate", `{"taskId":"1","status":"completed"}`, nil)
+ p = call("TaskUpdate", `{"taskId":2,"status":"completed"}`, nil)
+ if p == nil || p.Status != "done" {
+ t.Fatalf("%+v", p)
+ }
+ if p := call("TaskUpdate", `{"taskId":"9","status":"completed"}`, nil); p != nil {
+ t.Fatal("an unknown task changes nothing")
+ }
+ p = call("TaskUpdate", `{"taskId":"2","status":"deleted"}`, nil)
+ if p == nil || strings.Contains(p.Text, "Add a test") {
+ t.Fatalf("%+v", p)
+ }
+ if call("Read", `{"file_path":"x"}`, nil) != nil {
+ t.Fatal("not a task tool")
+ }
+}
+
+func TestTaskCallsBecomeThePlanAtTurnEnd(t *testing.T) {
+ root := gitRepo(t)
+ _ = enabledStore(t, root)
+ transcript := filepath.Join(t.TempDir(), "s.jsonl")
+ _ = os.WriteFile(transcript, nil, 0o600)
+ d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)}
+ sess := map[string]any{"Session": "s-plan", "Transcript": transcript}
+ runHook(t, d, "start", sess)
+ res := "Task #1 created successfully: Write farewell()"
+ // No prompt was delivered: the call is held for the turn's end.
+ runHook(t, d, "tool", map[string]any{"Session": "s-plan", "Transcript": transcript,
+ "Tool": &record.Tool{NativeID: "tc1", Name: "TaskCreate", Arguments: []byte(`{"subject":"Write farewell()"}`), Result: &res, Status: "completed"}})
+ f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600)
+ _, _ = f.WriteString(`{"kind":"text","body":"OK."}` + "\n")
+ _ = f.Close()
+ runHook(t, d, "stop", sess)
+ mem, err := memrecord.Open(root, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer mem.Close()
+ es, _ := mem.Active(context.Background(), memrecord.KindPlan)
+ if len(es) != 1 || es[0].Content != "- [pending] Write farewell()" {
+ t.Fatalf("plan = %+v", es)
+ }
+}
diff --git a/internal/hooks/state.go b/internal/hooks/state.go
index 3e7fec1..a580ec3 100644
--- a/internal/hooks/state.go
+++ b/internal/hooks/state.go
@@ -10,18 +10,22 @@ import (
"sort"
"time"
- "github.com/tryatlas/atlas-cli/internal/capture/store"
+ "golang.org/x/sys/unix"
+
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
+ "github.com/tryatlas/atlas-cli/internal/clistate"
)
-// Session state lives under /.atlas/hooks//.json:
-// what the dispatcher needs between two hook invocations of one session.
-// .atlas/ ignores itself in git.
+// Session state lives in the CLI's own state directory (clistate), under
+// hooks//.json: what the dispatcher needs between two hook
+// invocations of one session. Nothing of it goes into /.atlas/, which
+// holds only what the desktop app writes.
// Dir is the hooks state directory of a Project.
-func Dir(root string) string { return filepath.Join(root, store.DirName, "hooks") }
+func Dir(root string) string { return clistate.Path(root, "hooks") }
// LogPath is where hook errors go.
-func LogPath(root string) string { return filepath.Join(root, store.DirName, "cli-hooks.log") }
+func LogPath(root string) string { return clistate.Path(root, "hooks.log") }
// State is one native session as the hooks have seen it.
type State struct {
@@ -33,17 +37,40 @@ type State struct {
UpdatedAt time.Time `json:"updated_at"`
// Turn is the number of turns delivered so far.
Turn int `json:"turn"`
+ // Tasks is the session's task list as an agent with incremental task
+ // tools (Claude Code's TaskCreate / TaskUpdate) built it.
+ Tasks []PlanTask `json:"tasks,omitempty"`
+ // TaskCalls are the task tool calls already applied (by call id), so a
+ // call seen by its hook and again in the transcript counts once.
+ TaskCalls []string `json:"task_calls,omitempty"`
// InTurn is set between TurnStart and TurnEnd.
InTurn bool `json:"in_turn,omitempty"`
// Offset is the transcript position at the last turn boundary.
Offset int `json:"offset"`
// Prompt is the typed prompt of the open turn.
Prompt string `json:"prompt,omitempty"`
+ // Head is HEAD at the baseline (turn start, then each shell call):
+ // commits after it were made during the turn.
+ Head string `json:"head,omitempty"`
+ // TurnHead is HEAD when the turn started: commits after it are linked
+ // once the turn's touches have landed.
+ TurnHead string `json:"turn_head,omitempty"`
// Status is git status at turn start: path → true; nil when no snapshot
// was taken (an empty map is a clean tree, so no omitempty).
Status map[string]bool `json:"status"`
// ToolFiles are files the agent's tool hooks reported during the turn.
ToolFiles []string `json:"tool_files,omitempty"`
+ // Open says the turn was opened in the store at TurnStart (the prompt
+ // was delivered), so tool calls are delivered as they complete.
+ Open bool `json:"open,omitempty"`
+ // Delivered holds, per tool call delivered during the open turn, the
+ // keys that identify it (id, name+arguments hash, command, written
+ // paths), so the transcript read at turn end does not record it twice;
+ // a one-element group "path:" marks a path a delivered call wrote.
+ Delivered [][]string `json:"delivered,omitempty"`
+ // Tools are tool calls reported by hooks while no turn was open; they
+ // join the turn when it closes.
+ Tools []record.Tool `json:"tools,omitempty"`
// Started is set once the session start was delivered (some agents
// fire their first prompt hook before their session-start hook).
Started bool `json:"started,omitempty"`
@@ -89,7 +116,7 @@ func (s *State) Save(root string) error {
if err != nil {
return err
}
- tmp := p + ".tmp"
+ tmp := fmt.Sprintf("%s.%d.tmp", p, os.Getpid())
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
@@ -206,3 +233,27 @@ func CurrentSession(root, agent string) string {
}
return ""
}
+
+// lockSession takes an advisory lock on the session's state for the hook's
+// lifetime; the lock file stays (an inode-bound lock must not be unlinked).
+func lockSession(root, agent, id string) (func(), error) {
+ if !ValidSessionID(id) {
+ return nil, ErrBadSessionID
+ }
+ p := statePath(root, agent, id) + ".lock"
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return nil, err
+ }
+ f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // G304: the Project's own lock file
+ if err != nil {
+ return nil, err
+ }
+ if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
+ _ = f.Close()
+ return nil, err
+ }
+ return func() {
+ _ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
+ _ = f.Close()
+ }, nil
+}
diff --git a/internal/hooks/state_main_test.go b/internal/hooks/state_main_test.go
new file mode 100644
index 0000000..0a48392
--- /dev/null
+++ b/internal/hooks/state_main_test.go
@@ -0,0 +1,16 @@
+package hooks
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: keyword-only and deterministic
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/hosts/state_main_test.go b/internal/hosts/state_main_test.go
new file mode 100644
index 0000000..5a4a8ca
--- /dev/null
+++ b/internal/hosts/state_main_test.go
@@ -0,0 +1,15 @@
+package hosts
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/ingest/state_main_test.go b/internal/ingest/state_main_test.go
new file mode 100644
index 0000000..52fbaae
--- /dev/null
+++ b/internal/ingest/state_main_test.go
@@ -0,0 +1,15 @@
+package ingest
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/listener/listener.go b/internal/listener/listener.go
index 1e28f0a..ee74e3a 100644
--- a/internal/listener/listener.go
+++ b/internal/listener/listener.go
@@ -4,7 +4,7 @@
// owner-only Unix socket that carries the control endpoints: /sync for
// atlas capture sync and retry, /hook for turns delivered by agent hooks, and
// /mcp for the memory bridge of agents run in their own UIs (ADR-0013). Both
-// are published in .atlas/cli-run.json.
+// are published in the CLI's state directory (clistate) as run.json.
package listener
import (
@@ -15,17 +15,18 @@ import (
"net"
"net/http"
"os"
- "path/filepath"
"strconv"
"sync/atomic"
"syscall"
"time"
+ "github.com/tryatlas/atlas-cli/internal/clistate"
"github.com/tryatlas/atlas-cli/internal/memory/server"
"github.com/tryatlas/atlas-cli/internal/timefmt"
)
-// RunFile is /.atlas/cli-run.json while a listener runs.
+// RunFile is the Project's run.json in the CLI state directory while a
+// listener runs.
type RunFile struct {
PID int `json:"pid"`
Port int `json:"port"`
@@ -35,7 +36,7 @@ type RunFile struct {
}
// RunFilePath is where a Project's run file lives.
-func RunFilePath(root string) string { return filepath.Join(root, ".atlas", "cli-run.json") }
+func RunFilePath(root string) string { return clistate.Path(root, "run.json") }
// ReadRunFile returns the running listener's file, or nil when none runs.
func ReadRunFile(root string) *RunFile {
@@ -161,11 +162,7 @@ func Start(root, version string, opt Options) (*Listener, error) {
rf := RunFile{PID: os.Getpid(), Port: ln.Port, StartedAt: timefmt.RFC3339(time.Now()), Socket: sock, Daemon: opt.Daemon}
b, _ := json.Marshal(rf)
- if err := os.MkdirAll(filepath.Join(root, ".atlas"), 0o755); err != nil {
- _ = ln.Stop()
- return nil, err
- }
- if err := os.WriteFile(RunFilePath(root), b, 0o644); err != nil { //nolint:gosec // G306: holds no secret
+ if err := os.WriteFile(RunFilePath(root), b, 0o600); err != nil {
_ = ln.Stop()
return nil, err
}
diff --git a/internal/listener/state_main_test.go b/internal/listener/state_main_test.go
new file mode 100644
index 0000000..a24d24f
--- /dev/null
+++ b/internal/listener/state_main_test.go
@@ -0,0 +1,15 @@
+package listener
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/memory/corpus/corpus.go b/internal/memory/corpus/corpus.go
new file mode 100644
index 0000000..b9f6ad2
--- /dev/null
+++ b/internal/memory/corpus/corpus.go
@@ -0,0 +1,431 @@
+// Package corpus is the memory retrieval index the desktop keeps in
+// /.atlas/memory/corpus.sqlite (crates/atlas-memory/src/corpus.rs):
+// the project's documents (its CLAUDE.md and AGENTS.md, knowledge notes,
+// durable shared memories, captured sessions), each with a BM25 row and a
+// MiniLM vector, searched by memory_search as "documents". The schema, ids,
+// keys and vectors are the app's, so either side keeps the index the other
+// built. The app's usearch file is a projection it rebuilds from
+// embed_cache; the CLI flags it dirty after a change and searches the cache
+// itself.
+package corpus
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "unicode"
+
+ "github.com/zeebo/blake3"
+
+ "github.com/tryatlas/atlas-cli/internal/embed"
+ memrecord "github.com/tryatlas/atlas-cli/internal/memory/record"
+)
+
+// File is the index's database inside .atlas/memory/.
+const File = "corpus.sqlite"
+
+const schema = `
+CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
+CREATE TABLE IF NOT EXISTS docs (id TEXT PRIMARY KEY, vkey INTEGER NOT NULL, content_hash TEXT NOT NULL,
+ corpus TEXT NOT NULL, title TEXT NOT NULL, source TEXT NOT NULL, text TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'repo');
+CREATE INDEX IF NOT EXISTS docs_vkey ON docs(vkey);
+CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(title, text, content='', contentless_delete=1,
+ tokenize='porter unicode61 remove_diacritics 2');
+CREATE TABLE IF NOT EXISTS embed_cache (key BLOB PRIMARY KEY, model TEXT NOT NULL, dims INTEGER NOT NULL, vec BLOB NOT NULL);
+`
+
+// Index is an open corpus.sqlite.
+type Index struct {
+ db *sql.DB
+ Path string
+}
+
+// Path is the project's corpus file.
+func Path(root string) string { return filepath.Join(root, ".atlas", "memory", File) }
+
+// Open opens (with create, creates) the project's index.
+func Open(root string, create bool) (*Index, error) {
+ p := Path(root)
+ if _, err := os.Stat(p); err != nil {
+ if !create {
+ return nil, err
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return nil, err
+ }
+ }
+ esc := strings.NewReplacer("%", "%25", "?", "%3f", "#", "%23").Replace(p)
+ db, err := sql.Open("sqlite", "file:"+esc+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_txlock=immediate")
+ if err != nil {
+ return nil, err
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec(schema); err != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("corpus schema: %w", err)
+ }
+ return &Index{db: db, Path: p}, nil
+}
+
+// Close closes it.
+func (x *Index) Close() error { return x.db.Close() }
+
+// Doc is one document as a source yields it.
+type Doc struct {
+ ID, Title, Text string
+ Source string // the corpus tag: shared, claude, codex, note, or an agent id
+}
+
+// embedText is what is embedded and hashed: the title, then the text
+// (memory_indexer.rs embed_text).
+func (d Doc) embedText() string {
+ if strings.TrimSpace(d.Text) == "" {
+ return d.Title
+ }
+ return d.Title + "\n\n" + d.Text
+}
+
+func hashText(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:])
+}
+
+// cacheKey is blake3(model ‖ 0 ‖ content hash hex) (corpus.rs doc_cache_key).
+func cacheKey(model, contentHash string) []byte {
+ h := blake3.New()
+ _, _ = h.Write([]byte(model))
+ _, _ = h.Write([]byte{0})
+ _, _ = h.Write([]byte(contentHash))
+ return h.Sum(nil)
+}
+
+// splitEmbedded is the stored title and body: cut at the first blank line,
+// both trimmed (docstore.rs split_embedded).
+func splitEmbedded(s string) (string, string) {
+ title, body, _ := strings.Cut(s, "\n\n")
+ return strings.TrimSpace(title), strings.TrimSpace(body)
+}
+
+// Stats counts what a pass did.
+type Stats struct {
+ Added, Updated, Deleted, Unchanged, Embedded int
+}
+
+// Update brings the index in line with docs: unchanged documents (same hash,
+// vector cached) are left alone, changed or new ones written (and embedded
+// when emb is not nil and the vector is not cached), and stored documents
+// owned reports as ours but that are no longer in docs deleted. Documents
+// another writer owns (the app's readers of agent folders) are never
+// touched.
+func (x *Index) Update(ctx context.Context, docs []Doc, owned func(id string) bool, emb memrecord.VectorSource) (Stats, error) {
+ var st Stats
+ model := embed.ModelName
+ stored := map[string]string{} // id → content_hash
+ rows, err := x.db.QueryContext(ctx, `SELECT id, content_hash FROM docs`)
+ if err != nil {
+ return st, err
+ }
+ for rows.Next() {
+ var id, h string
+ if err := rows.Scan(&id, &h); err != nil {
+ _ = rows.Close()
+ return st, err
+ }
+ stored[id] = h
+ }
+ _ = rows.Close()
+ hasVec := func(h string) bool {
+ var one int
+ return x.db.QueryRowContext(ctx, `SELECT 1 FROM embed_cache WHERE key = ?`, cacheKey(model, h)).Scan(&one) == nil
+ }
+ type plan struct {
+ doc Doc
+ text string
+ hash string
+ vec []float32
+ }
+ var upserts []plan
+ current := map[string]bool{}
+ for _, d := range docs {
+ if current[d.ID] {
+ continue
+ }
+ current[d.ID] = true
+ text := d.embedText()
+ h := hashText(text)
+ cached := hasVec(h)
+ if old, ok := stored[d.ID]; ok && old == h && (cached || emb == nil) {
+ st.Unchanged++
+ continue
+ }
+ p := plan{doc: d, text: text, hash: h}
+ if !cached && emb != nil {
+ p.vec = emb.Embed(text)
+ st.Embedded++
+ }
+ upserts = append(upserts, p)
+ }
+ var deletes []string
+ for id := range stored {
+ if !current[id] && owned(id) {
+ deletes = append(deletes, id)
+ }
+ }
+ sort.Strings(deletes)
+ if len(upserts) == 0 && len(deletes) == 0 {
+ return st, nil
+ }
+ tx, err := x.db.BeginTx(ctx, nil)
+ if err != nil {
+ return st, err
+ }
+ defer func() { _ = tx.Rollback() }()
+ changedVectors := false
+ for _, p := range upserts {
+ var rowid int64
+ switch err := tx.QueryRowContext(ctx, `SELECT rowid FROM docs WHERE id = ?`, p.doc.ID).Scan(&rowid); {
+ case err == nil:
+ if _, err := tx.ExecContext(ctx, `DELETE FROM docs_fts WHERE rowid = ?`, rowid); err != nil {
+ return st, err
+ }
+ st.Updated++
+ case errors.Is(err, sql.ErrNoRows):
+ st.Added++
+ default:
+ return st, err
+ }
+ title, body := splitEmbedded(p.text)
+ scope := "repo"
+ if strings.HasSuffix(p.doc.ID, "@global") {
+ scope = "user"
+ }
+ if _, err := tx.ExecContext(ctx, `INSERT INTO docs (id, vkey, content_hash, corpus, title, source, text, scope)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?4, ?6, ?7)
+ ON CONFLICT(id) DO UPDATE SET content_hash = excluded.content_hash, corpus = excluded.corpus,
+ title = excluded.title, source = excluded.source, text = excluded.text, scope = excluded.scope`,
+ p.doc.ID, embed.VKey(p.doc.ID), p.hash, p.doc.Source, title, body, scope); err != nil {
+ return st, err
+ }
+ if _, err := tx.ExecContext(ctx, `INSERT INTO docs_fts (rowid, title, text) SELECT rowid, title, text FROM docs WHERE id = ?`, p.doc.ID); err != nil {
+ return st, err
+ }
+ if p.vec != nil {
+ if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO embed_cache (key, model, dims, vec) VALUES (?, ?, ?, ?)`,
+ cacheKey(model, p.hash), model, len(p.vec), embed.Encode(p.vec)); err != nil {
+ return st, err
+ }
+ }
+ changedVectors = true
+ }
+ for _, id := range deletes {
+ var rowid int64
+ if tx.QueryRowContext(ctx, `SELECT rowid FROM docs WHERE id = ?`, id).Scan(&rowid) == nil {
+ if _, err := tx.ExecContext(ctx, `DELETE FROM docs_fts WHERE rowid = ?`, rowid); err != nil {
+ return st, err
+ }
+ }
+ if _, err := tx.ExecContext(ctx, `DELETE FROM docs WHERE id = ?`, id); err != nil {
+ return st, err
+ }
+ st.Deleted++
+ }
+ if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO meta (k, v) VALUES ('model', ?), ('dims', ?)`, model, fmt.Sprint(embed.Dim)); err != nil {
+ return st, err
+ }
+ if changedVectors || len(deletes) > 0 {
+ // The app's usearch file no longer matches: it rebuilds it from
+ // embed_cache on its next open (corpus.rs heal).
+ if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO meta (k, v) VALUES (?, '1')`, "vectors_dirty:"+model); err != nil {
+ return st, err
+ }
+ }
+ return st, tx.Commit()
+}
+
+// Retrieval constants (retrieve.rs).
+const (
+ minCosine = 0.30
+ rrfK = 60
+ jaccardDedup = 0.8
+ minQueryLen = 4
+)
+
+// Result is one document memory_search returns.
+type Result struct {
+ ID, Title, Source, Text string
+}
+
+// Retrieve is the documents leg of memory_search: dense (cosine over the
+// cached vectors, at least 0.30) and BM25 over docs_fts, pool max(4·limit,
+// 20) each, fused by reciprocal rank (k 60), near-duplicates (Jaccard ≥ 0.8
+// over words) dropped, at most limit. keep filters ids (live shared
+// memories). qvec is the query's vector, nil without a model (then BM25
+// only).
+func (x *Index) Retrieve(ctx context.Context, query string, qvec []float32, limit int, keep func(id string) bool) ([]Result, error) {
+ if len(strings.TrimSpace(query)) < minQueryLen || limit <= 0 {
+ return nil, nil
+ }
+ pool := max(limit*4, 20)
+ var dense []string
+ if qvec != nil {
+ type scored struct {
+ id string
+ sim float32
+ }
+ var hits []scored
+ drows, err := x.db.QueryContext(ctx, `SELECT id, content_hash FROM docs`)
+ if err != nil {
+ return nil, err
+ }
+ type idHash struct{ id, hash string }
+ var all []idHash
+ for drows.Next() {
+ var ih idHash
+ if err := drows.Scan(&ih.id, &ih.hash); err != nil {
+ _ = drows.Close()
+ return nil, err
+ }
+ all = append(all, ih)
+ }
+ _ = drows.Close()
+ for _, ih := range all {
+ var vec []byte
+ if x.db.QueryRowContext(ctx, `SELECT vec FROM embed_cache WHERE key = ?`, cacheKey(embed.ModelName, ih.hash)).Scan(&vec) != nil {
+ continue
+ }
+ if sim := embed.Cosine(qvec, embed.Decode(vec)); sim >= minCosine {
+ hits = append(hits, scored{ih.id, sim})
+ }
+ }
+ sort.SliceStable(hits, func(i, j int) bool { return hits[i].sim > hits[j].sim })
+ for i, h := range hits {
+ if i >= pool {
+ break
+ }
+ dense = append(dense, h.id)
+ }
+ }
+ var bm []string
+ if fq := memrecord.FTSQuery(query); fq != "" {
+ rows, err := x.db.QueryContext(ctx, `SELECT d.id FROM docs_fts f JOIN docs d ON d.rowid = f.rowid
+ WHERE docs_fts MATCH ? ORDER BY f.rank LIMIT ?`, fq, pool)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ _ = rows.Close()
+ return nil, err
+ }
+ bm = append(bm, id)
+ }
+ _ = rows.Close()
+ }
+ score := map[string]float64{}
+ var order []string
+ add := func(list []string) {
+ for r, id := range list {
+ if _, ok := score[id]; !ok {
+ order = append(order, id)
+ }
+ score[id] += 1.0 / float64(rrfK+r+1)
+ }
+ }
+ add(dense)
+ add(bm)
+ sort.SliceStable(order, func(i, j int) bool { return score[order[i]] > score[order[j]] })
+ var out []Result
+ var kept [][]string
+ for _, id := range order {
+ if keep != nil && !keep(id) {
+ continue
+ }
+ var r Result
+ if x.db.QueryRowContext(ctx, `SELECT id, title, source, text FROM docs WHERE id = ?`, id).Scan(&r.ID, &r.Title, &r.Source, &r.Text) != nil {
+ continue
+ }
+ words := tokens(r.Title + " " + r.Text)
+ dup := false
+ for _, k := range kept {
+ if jaccard(words, k) >= jaccardDedup {
+ dup = true
+ break
+ }
+ }
+ if dup {
+ continue
+ }
+ kept = append(kept, words)
+ r.Text = strings.TrimSpace(r.Text)
+ out = append(out, r)
+ if len(out) >= limit {
+ break
+ }
+ }
+ return out, nil
+}
+
+func tokens(s string) []string {
+ var out []string
+ for _, w := range strings.Fields(s) {
+ w = strings.ToLower(strings.TrimFunc(w, func(r rune) bool { return !isAlnum(r) }))
+ if len(w) >= 2 {
+ out = append(out, w)
+ }
+ }
+ return out
+}
+
+func jaccard(a, b []string) float64 {
+ sa, sb := map[string]bool{}, map[string]bool{}
+ for _, w := range a {
+ sa[w] = true
+ }
+ for _, w := range b {
+ sb[w] = true
+ }
+ inter := 0
+ for w := range sa {
+ if sb[w] {
+ inter++
+ }
+ }
+ union := len(sa) + len(sb) - inter
+ if union == 0 {
+ return 0
+ }
+ return float64(inter) / float64(union)
+}
+
+func isAlnum(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) }
+
+// Delete drops one document now (memory_forget evicts its shared doc).
+func (x *Index) Delete(ctx context.Context, id string) error {
+ tx, err := x.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+ var rowid int64
+ if tx.QueryRowContext(ctx, `SELECT rowid FROM docs WHERE id = ?`, id).Scan(&rowid) != nil {
+ return nil
+ }
+ if _, err := tx.ExecContext(ctx, `DELETE FROM docs_fts WHERE rowid = ?`, rowid); err != nil {
+ return err
+ }
+ if _, err := tx.ExecContext(ctx, `DELETE FROM docs WHERE id = ?`, id); err != nil {
+ return err
+ }
+ if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO meta (k, v) VALUES (?, '1')`, "vectors_dirty:"+embed.ModelName); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
diff --git a/internal/memory/corpus/corpus_test.go b/internal/memory/corpus/corpus_test.go
new file mode 100644
index 0000000..424d6ba
--- /dev/null
+++ b/internal/memory/corpus/corpus_test.go
@@ -0,0 +1,174 @@
+package corpus
+
+import (
+ "context"
+ "database/sql"
+ "hash/fnv"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ memrecord "github.com/tryatlas/atlas-cli/internal/memory/record"
+)
+
+type bag struct{}
+
+func (bag) Embed(text string) []float32 {
+ v := make([]float32, 64)
+ for _, w := range strings.Fields(strings.ToLower(text)) {
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(strings.Trim(w, ".,:[]")))
+ v[h.Sum32()%64]++
+ }
+ var n float64
+ for _, x := range v {
+ n += float64(x * x)
+ }
+ for i := range v {
+ if n > 0 {
+ v[i] = float32(float64(v[i]) / math.Sqrt(n))
+ }
+ }
+ return v
+}
+
+func TestUpdateAndRetrieve(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ ix, err := Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ix.Close()
+ docs := []Doc{
+ {ID: "shared:decision:1", Title: "Use Postgres for the ledger", Text: "[pi] Use Postgres for the ledger", Source: "shared"},
+ {ID: "kb:deploy", Title: "deploy", Text: "Deploys go out on Fridays after the smoke tests.", Source: "note"},
+ {ID: "claude:MEMORY.md", Title: "Memory Index", Text: "app-owned", Source: "claude"},
+ }
+ st, err := ix.Update(ctx, docs, Owned, bag{})
+ if err != nil || st.Added != 3 || st.Embedded != 3 {
+ t.Fatalf("%+v %v", st, err)
+ }
+ // Unchanged: nothing written.
+ if st, _ := ix.Update(ctx, docs, Owned, bag{}); st.Unchanged != 3 || st.Added+st.Updated+st.Embedded != 0 {
+ t.Fatalf("second pass %+v", st)
+ }
+ // A pass without the app-owned doc keeps it; a removed owned doc goes.
+ st, err = ix.Update(ctx, docs[:1], Owned, bag{})
+ if err != nil || st.Deleted != 1 {
+ t.Fatalf("%+v %v", st, err)
+ }
+ var n int
+ _ = ix.db.QueryRow(`SELECT count(*) FROM docs WHERE id = 'claude:MEMORY.md'`).Scan(&n)
+ if n != 1 {
+ t.Fatal("an app-owned document must never be deleted by the CLI")
+ }
+ var dirty string
+ _ = ix.db.QueryRow(`SELECT v FROM meta WHERE k = 'vectors_dirty:all-MiniLM-L6-v2-384'`).Scan(&dirty)
+ if dirty != "1" {
+ t.Fatal("the app's vector file must be flagged for a rebuild")
+ }
+ res, err := ix.Retrieve(ctx, "ledger postgres", bag{}.Embed("ledger postgres"), 6, nil)
+ if err != nil || len(res) == 0 || res[0].ID != "shared:decision:1" || res[0].Source != "shared" {
+ t.Fatalf("%+v %v", res, err)
+ }
+ if res, _ := ix.Retrieve(ctx, "ab", nil, 6, nil); res != nil {
+ t.Fatal("a query under 4 bytes returns nothing")
+ }
+ if res, _ := ix.Retrieve(ctx, "ledger postgres", nil, 6, func(string) bool { return false }); len(res) != 0 {
+ t.Fatal("keep filters")
+ }
+ if err := ix.Delete(ctx, "shared:decision:1"); err != nil {
+ t.Fatal(err)
+ }
+ if res, _ := ix.Retrieve(ctx, "ledger postgres", nil, 6, nil); len(res) != 0 {
+ t.Fatalf("deleted doc returned: %+v", res)
+ }
+}
+
+func TestHelpersMatchTheApp(t *testing.T) {
+ if got := shortTitle("first line\n---\nrest"); got != "first line" {
+ t.Fatal(got)
+ }
+ if got := shortTitle(strings.Repeat("ab ", 40)); !strings.HasSuffix(got, "…") || len([]rune(got)) != 81 {
+ t.Fatal(got)
+ }
+ in := "keep\n\nsecret\n\n--- SHARED MEMORY — UPDATES ---\nx\n--- END SHARED MEMORY ---\nalso keep"
+ if got := StripInjected(in); got != "keep\nalso keep" {
+ t.Fatalf("%q", got)
+ }
+ if t1, b1 := splitEmbedded("Title\n\n body \n\nmore"); t1 != "Title" || b1 != "body \n\nmore" {
+ t.Fatalf("%q %q", t1, b1)
+ }
+ if !Owned("opencode:ses_1") || Owned("claude:MEMORY.md") || Owned("codex:019a") || !Owned("claude:CLAUDE.md") || Owned("claude-code:x") {
+ t.Fatal("ownership")
+ }
+}
+
+func TestSharedAndCaptureDocs(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ rec, err := memrecord.Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, _ = rec.Remember(ctx, memrecord.Write{Kind: "decision", Content: " Ship on Fridays ", Source: "agent", Agent: "pi", Session: "s", Confidence: 0.8, At: time.Now().UnixMilli(), LogEvent: true})
+ _ = rec.Close()
+ _ = os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("Run make lint.\n"), 0o644)
+ docs, err := Collect(ctx, root, root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var shared, agents *Doc
+ for i := range docs {
+ switch {
+ case strings.HasPrefix(docs[i].ID, "shared:decision:"):
+ shared = &docs[i]
+ case docs[i].ID == "codex:AGENTS.md":
+ agents = &docs[i]
+ }
+ }
+ if shared == nil || shared.Text != "[pi] Ship on Fridays" || shared.Title != "Ship on Fridays" || agents == nil || agents.Text != "Run make lint." {
+ t.Fatalf("%+v", docs)
+ }
+}
+
+// TestCollectMatchesTheAppsIndex runs Collect on a Project the app indexed
+// (ATLAS_COMPAT_PROJECT) and checks every document both produce has the
+// same content hash: the same text, byte for byte.
+func TestCollectMatchesTheAppsIndex(t *testing.T) {
+ root := os.Getenv("ATLAS_COMPAT_PROJECT")
+ if root == "" {
+ t.Skip("set ATLAS_COMPAT_PROJECT to a Project the app has indexed")
+ }
+ ctx := context.Background()
+ docs, err := Collect(ctx, root, root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ db, err := sql.Open("sqlite", "file:"+Path(root)+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ same, differ := 0, 0
+ for _, d := range docs {
+ var h string
+ if db.QueryRow(`SELECT content_hash FROM docs WHERE id = ?`, d.ID).Scan(&h) != nil {
+ continue
+ }
+ if h == hashText(d.embedText()) {
+ same++
+ } else {
+ differ++
+ t.Logf("differs: %s", d.ID)
+ }
+ }
+ t.Logf("%d documents match the app's index, %d differ", same, differ)
+ if same == 0 {
+ t.Fatal("no document matched")
+ }
+}
diff --git a/internal/memory/corpus/sources.go b/internal/memory/corpus/sources.go
new file mode 100644
index 0000000..f065961
--- /dev/null
+++ b/internal/memory/corpus/sources.go
@@ -0,0 +1,303 @@
+package corpus
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ capstore "github.com/tryatlas/atlas-cli/internal/capture/store"
+ memrecord "github.com/tryatlas/atlas-cli/internal/memory/record"
+)
+
+// The desktop's caps on durable memories per kind (shared_memory.rs
+// durable_entries); preferences are not documents.
+var sharedCaps = []struct {
+ kind string
+ cap int
+}{{"decision", 50}, {"failure", 30}, {"architecture", 30}, {"fact", 50}}
+
+// textCap bounds a captured session's text (agent_memory.rs TEXT_CAP).
+const textCap = 12 * 1024
+
+// Collect reads the documents the CLI indexes for the project at root
+// (shared memory lives at scope): the project's CLAUDE.md and AGENTS.md,
+// .atlas/knowledge notes, durable shared memories, and captured sessions of
+// agents the app has no dedicated reader for, as the desktop's
+// collect_corpus builds them. It never reads an agent's own folders (Claude
+// Code's memory directory, ~/.claude, ~/.codex): the app indexes those into
+// the same file, and Owned leaves them alone.
+func Collect(ctx context.Context, root, scope string) ([]Doc, error) {
+ var docs []Doc
+ if t := projectFile(filepath.Join(root, "CLAUDE.md")); t != "" {
+ docs = append(docs, Doc{ID: "claude:CLAUDE.md", Title: "CLAUDE.md", Text: t, Source: "claude"})
+ }
+ if t := projectFile(filepath.Join(root, "AGENTS.md")); t != "" {
+ docs = append(docs, Doc{ID: "codex:AGENTS.md", Title: "AGENTS.md", Text: t, Source: "codex"})
+ }
+ docs = append(docs, knowledge(root)...)
+ shared, err := sharedDocs(ctx, scope)
+ if err != nil {
+ return nil, err
+ }
+ docs = append(docs, shared...)
+ captured, err := captureDocs(ctx, root)
+ if err != nil {
+ return nil, err
+ }
+ return append(docs, captured...), nil
+}
+
+// Owned reports the documents Collect produces, which a pass may delete when
+// they disappear; documents the app reads from agent folders (Claude memory
+// files, global CLAUDE.md, Codex threads) are not the CLI's.
+func Owned(id string) bool {
+ switch {
+ case id == "claude:CLAUDE.md" || id == "codex:AGENTS.md":
+ return true
+ case strings.HasPrefix(id, "shared:") || strings.HasPrefix(id, "kb:"):
+ return true
+ }
+ agent, _, ok := strings.Cut(id, ":")
+ return ok && !capturedElsewhere(agent) && agent != "claude" && agent != "codex"
+}
+
+// capturedElsewhere: Claude Code and Codex sessions come from their own
+// readers in the app, never from capture (agent_memory.rs
+// capture_covered_agent).
+func capturedElsewhere(agent string) bool {
+ return strings.HasPrefix(agent, "claude") || agent == "codex"
+}
+
+// projectFile reads a project instruction file without Atlas's mirrored
+// block or injected memory context.
+func projectFile(path string) string {
+ b, err := os.ReadFile(path) //nolint:gosec // G304: the project's own instruction file
+ if err != nil {
+ return ""
+ }
+ return StripInjected(removeBlock(string(b)))
+}
+
+// The block atlas-instruction-sync mirrors into AGENTS.md.
+const (
+ blockStart = ""
+ blockEnd = ""
+)
+
+func removeBlock(s string) string {
+ lines := strings.SplitAfter(s, "\n")
+ start, end := -1, -1
+ for i, l := range lines {
+ switch strings.TrimRight(l, "\r\n") {
+ case blockStart:
+ if start >= 0 {
+ return s // not one well-formed pair
+ }
+ start = i
+ case blockEnd:
+ if start < 0 || end >= 0 {
+ return s
+ }
+ end = i
+ }
+ }
+ if start < 0 || end < 0 {
+ return s
+ }
+ before := strings.Join(lines[:start], "")
+ after := strings.Join(lines[end+1:], "")
+ if before == "" {
+ after = strings.TrimPrefix(strings.TrimPrefix(after, "\r\n"), "\n")
+ } else if after == "" || strings.HasPrefix(after, "\n") || strings.HasPrefix(after, "\r\n") {
+ before = strings.TrimSuffix(strings.TrimSuffix(before, "\n"), "\r")
+ if !strings.HasSuffix(before, "\n") {
+ before += "\n"
+ }
+ }
+ return before + after
+}
+
+// Context blocks Atlas injects into prompts, dropped from indexed text
+// (atlas-agent-transcript strip_injected_context).
+var injectedCores = []string{"SHARED MEMORY", "RELEVANT PROJECT MEMORY", "PROJECT MEMORY", "RECENT SESSION"}
+
+// StripInjected drops envelopes and "--- SHARED MEMORY ---" …
+// "--- END SHARED MEMORY ---" style blocks, then trims.
+func StripInjected(text string) string {
+ var out []string
+ skipUntil := ""
+ inEnvelope := false
+ for _, line := range strings.Split(text, "\n") {
+ l := strings.TrimSpace(line)
+ switch {
+ case inEnvelope:
+ if l == "" {
+ inEnvelope = false
+ }
+ continue
+ case skipUntil != "":
+ if l == skipUntil {
+ skipUntil = ""
+ }
+ continue
+ case l == "":
+ inEnvelope = true
+ continue
+ }
+ if strings.HasPrefix(l, "--- ") && strings.HasSuffix(l, "---") && !strings.HasPrefix(l, "--- END") {
+ inner := strings.TrimLeft(strings.TrimPrefix(l, "--- "), "")
+ for _, c := range injectedCores {
+ if strings.HasPrefix(inner, c) {
+ skipUntil = "--- END " + c + " ---"
+ break
+ }
+ }
+ if skipUntil != "" {
+ continue
+ }
+ }
+ out = append(out, strings.TrimSuffix(line, "\r"))
+ }
+ return strings.TrimSpace(strings.Join(out, "\n"))
+}
+
+// shortTitle is the first part before a "---" rule, whitespace collapsed,
+// at most 80 characters and an ellipsis (agent_memory.rs short_title).
+func shortTitle(s string) string {
+ cut, _, _ := strings.Cut(s, "\n---\n")
+ collapsed := strings.Join(strings.Fields(cut), " ")
+ if r := []rune(collapsed); len(r) > 80 {
+ return string(r[:80]) + "…"
+ }
+ return collapsed
+}
+
+// knowledge is .atlas/knowledge/**/*.md: id kb:, the
+// file name as title, source "note".
+func knowledge(root string) []Doc {
+ base := filepath.Join(root, ".atlas", "knowledge")
+ var out []Doc
+ _ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { //nolint:gosec // G703: the project's own .atlas/knowledge
+ if err != nil || d.IsDir() || !strings.HasSuffix(p, ".md") {
+ return nil //nolint:nilerr // an unreadable note is skipped
+ }
+ b, err := os.ReadFile(p) //nolint:gosec // G304: the project's own notes
+ if err != nil || strings.TrimSpace(string(b)) == "" {
+ return nil //nolint:nilerr // an unreadable note is skipped
+ }
+ rel, _ := filepath.Rel(base, p)
+ rel = strings.TrimSuffix(filepath.ToSlash(rel), ".md")
+ out = append(out, Doc{ID: "kb:" + rel, Title: strings.TrimSuffix(d.Name(), ".md"), Text: string(b), Source: "note"})
+ return nil
+ })
+ return out
+}
+
+// sharedDocs are the active durable memories with a log position, newest
+// cap per kind, oldest first: id shared::, text
+// "[] ".
+func sharedDocs(ctx context.Context, scope string) ([]Doc, error) {
+ st, err := memrecord.Open(scope, false)
+ if err != nil {
+ return nil, nil //nolint:nilerr // no record yet: no shared documents
+ }
+ defer st.Close()
+ var out []Doc
+ for _, k := range sharedCaps {
+ es, err := st.DurableForCorpus(ctx, k.kind, k.cap)
+ if err != nil {
+ return nil, err
+ }
+ for _, e := range es {
+ t := strings.TrimSpace(e.Content)
+ if t == "" {
+ continue
+ }
+ out = append(out, Doc{ID: fmt.Sprintf("shared:%s:%d", e.Kind, e.ID), Title: shortTitle(t), Text: "[" + e.Agent + "] " + t, Source: "shared"})
+ }
+ }
+ return out, nil
+}
+
+// captureDocs are captured sessions of agents without a dedicated reader:
+// "role: preview" lines (injection stripped) up to 12 KiB, id
+// :, titled by the session's title or first
+// prompt.
+func captureDocs(ctx context.Context, root string) ([]Doc, error) {
+ if _, err := os.Stat(capstore.Path(root)); err != nil { //nolint:gosec // G703: the project's own store
+ return nil, nil //nolint:nilerr // capture never enabled here
+ }
+ db, err := sql.Open("sqlite", "file:"+capstore.Path(root)+"?mode=ro&_pragma=busy_timeout(5000)")
+ if err != nil {
+ return nil, err
+ }
+ defer db.Close()
+ rows, err := db.QueryContext(ctx, `SELECT id, agent, native_session_id, COALESCE(title, '') FROM agent_session
+ WHERE workspace_id = ? ORDER BY started_at`, root)
+ if err != nil {
+ return nil, err
+ }
+ type sess struct{ id, agent, native, title string }
+ var ss []sess
+ for rows.Next() {
+ var s sess
+ var agent, native sql.NullString
+ if err := rows.Scan(&s.id, &agent, &native, &s.title); err != nil {
+ _ = rows.Close()
+ return nil, err
+ }
+ s.agent, s.native = agent.String, native.String
+ if s.agent != "" && !capturedElsewhere(s.agent) {
+ ss = append(ss, s)
+ }
+ }
+ _ = rows.Close()
+ var out []Doc
+ for _, s := range ss {
+ mrows, err := db.QueryContext(ctx, `SELECT role, COALESCE(preview, '') FROM agent_message WHERE session_id = ? ORDER BY seq`, s.id)
+ if err != nil {
+ return nil, err
+ }
+ var text strings.Builder
+ firstUser := ""
+ for mrows.Next() {
+ var role, preview string
+ if err := mrows.Scan(&role, &preview); err != nil {
+ _ = mrows.Close()
+ return nil, err
+ }
+ clean := strings.TrimSpace(StripInjected(preview))
+ if clean == "" {
+ continue
+ }
+ if firstUser == "" && role == "user" {
+ firstUser = clean
+ }
+ if text.Len() < textCap {
+ text.WriteString(role + ": ")
+ room := textCap - min(text.Len(), textCap)
+ r := []rune(clean)
+ text.WriteString(string(r[:min(len(r), room)]))
+ text.WriteByte('\n')
+ }
+ }
+ _ = mrows.Close()
+ titleSrc := strings.TrimSpace(StripInjected(s.title))
+ if titleSrc == "" {
+ titleSrc = firstUser
+ }
+ body := text.String()
+ if strings.TrimSpace(titleSrc) == "" && strings.TrimSpace(body) == "" {
+ continue
+ }
+ if strings.TrimSpace(body) == "" {
+ body = titleSrc
+ }
+ out = append(out, Doc{ID: s.agent + ":" + s.native, Title: shortTitle(titleSrc), Text: body, Source: s.agent})
+ }
+ return out, nil
+}
diff --git a/internal/memory/extract/extract.go b/internal/memory/extract/extract.go
new file mode 100644
index 0000000..f3bc2bc
--- /dev/null
+++ b/internal/memory/extract/extract.go
@@ -0,0 +1,418 @@
+// Package extract distils durable memories from a captured session through
+// the Atlas AI gateway, as the desktop's extractor does (evidence-memory.md
+// §4.1): after at least 20 turns and 3 tool calls since the last pass, and
+// once at session end; only with sharing on and the summariser not set to
+// "local"; results land as entries with source "extractor", through the
+// record's redactor. Unlike the desktop, the excerpt sent is the capture
+// store's text, which was redacted when it was recorded.
+package extract
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/tryatlas/atlas-cli/internal/capture/store"
+ "github.com/tryatlas/atlas-cli/internal/clistate"
+ "github.com/tryatlas/atlas-cli/internal/memory/record"
+ "github.com/tryatlas/atlas-cli/internal/project"
+)
+
+// Gates and limits (the desktop's).
+const (
+ MinTurns = 20
+ MinToolCalls = 3
+ ExcerptChars = 6000
+ MaxEntries = 8
+ Timeout = 60 * time.Second
+)
+
+// Options name one session and how to reach the gateway.
+type Options struct {
+ Root string // the Project root (capture store)
+ Agent string // the desktop's agent id
+ Session string // the agent's own session id
+ Final bool // the session ended: extract whatever is new
+ Gateway string // base URL, e.g. https://ai.tryatlas.cc/v1
+ // Auth returns the bearer token and the active org id (may be "").
+ Auth func(ctx context.Context) (token, org string, err error)
+ HTTP *http.Client
+ Now func() time.Time
+}
+
+// ErrSkipped says a gate kept the pass from running.
+var ErrSkipped = errors.New("extraction skipped")
+
+// state is the extractor's progress for one session, in the desktop's file
+// and format (crates/atlas-memory/src/extract.rs ExtractState):
+// /.atlas/memory/extract-state/.json.
+type state struct {
+ LastTurn int64 `json:"last_extracted_turn_index"`
+ ToolCallsSince int `json:"tool_calls_since_last"`
+ ExtractionCount int `json:"extraction_count"`
+}
+
+// sanitize keeps a session id filesystem-safe, as the desktop does.
+func sanitize(s string) string {
+ return strings.Map(func(r rune) rune {
+ if r < 128 && (r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
+ return r
+ }
+ return '_'
+ }, s)
+}
+
+func statePath(scope, session string) string {
+ return filepath.Join(filepath.Dir(record.Path(scope)), "extract-state", sanitize(session)+".json")
+}
+
+// legacyStatePath is where CLIs up to 0.4 kept it.
+func legacyStatePath(root, session string) string {
+ return filepath.Join(clistate.Dir(root), "extract", session+".json")
+}
+
+func loadState(scope, root, session string) state {
+ var s state
+ if b, err := os.ReadFile(statePath(scope, session)); err == nil {
+ _ = json.Unmarshal(b, &s)
+ return s
+ }
+ if b, err := os.ReadFile(legacyStatePath(root, session)); err == nil {
+ var old struct {
+ LastTurn int64 `json:"last_extracted_turn"`
+ ExtractionCount int `json:"extraction_count"`
+ }
+ if json.Unmarshal(b, &old) == nil {
+ s.LastTurn, s.ExtractionCount = old.LastTurn, old.ExtractionCount
+ }
+ }
+ return s
+}
+
+// save writes atomically (temp file, rename), as the desktop does.
+func (s state) save(scope, root, session string) error {
+ p := statePath(scope, session)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ b, _ := json.MarshalIndent(s, "", " ")
+ tmp := fmt.Sprintf("%s.%d.tmp", p, os.Getpid())
+ if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { //nolint:gosec // G306: the app reads it; no secret
+ return err
+ }
+ if err := os.Rename(tmp, p); err != nil {
+ return err
+ }
+ _ = os.Remove(legacyStatePath(root, session))
+ return nil
+}
+
+// summariserLocal reads /.atlas/memory-summarizer.json: mode "local"
+// sends nothing anywhere; "provider" (a BYOK model) is the desktop's alone.
+func summariserMode(root string) string {
+ b, err := os.ReadFile(filepath.Join(root, store.DirName, "memory-summarizer.json")) //nolint:gosec // G304: the Project's own setting
+ if err != nil {
+ return ""
+ }
+ var v struct {
+ Mode string `json:"mode"`
+ }
+ _ = json.Unmarshal(b, &v)
+ return v.Mode
+}
+
+// Run does one pass and returns how many entries it wrote.
+func Run(ctx context.Context, o Options) (int, error) {
+ if o.Now == nil {
+ o.Now = time.Now
+ }
+ if o.HTTP == nil {
+ o.HTTP = &http.Client{Timeout: Timeout}
+ }
+ if !project.MemorySharing(o.Root) {
+ return 0, ErrSkipped
+ }
+ if m := summariserMode(o.Root); m == "local" || m == "provider" {
+ return 0, ErrSkipped
+ }
+ // One pass per session at a time: a pass that finds another running
+ // leaves the work to it.
+ unlock, ok := tryLock(o.Root, o.Session)
+ if !ok {
+ return 0, ErrSkipped
+ }
+ defer unlock()
+ scope := project.ScopeRoot(ctx, o.Root)
+ st := loadState(scope, o.Root, o.Session)
+ excerpt, last, tools, err := conversation(ctx, o.Root, o.Session, st.LastTurn)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, ErrSkipped // the session is not in this store (yet)
+ }
+ if err != nil {
+ return 0, err
+ }
+ turns := last - st.LastTurn
+ switch {
+ case turns <= 0 || strings.TrimSpace(excerpt) == "":
+ return 0, ErrSkipped
+ case !o.Final && (turns < MinTurns || tools < MinToolCalls):
+ if tools != st.ToolCallsSince {
+ st.ToolCallsSince = tools
+ _ = st.save(scope, o.Root, o.Session)
+ }
+ return 0, ErrSkipped
+ }
+ token, org, err := o.Auth(ctx)
+ if err != nil {
+ return 0, err
+ }
+ entries, err := ask(ctx, o, token, org, excerpt)
+ if err != nil {
+ return 0, err
+ }
+ mem, err := record.Open(scope, true)
+ if err != nil {
+ return 0, err
+ }
+ defer mem.Close()
+ n := 0
+ at := o.Now().UnixMilli()
+ for _, e := range entries {
+ if n >= MaxEntries || !record.IsDurable(e.Kind) || strings.TrimSpace(e.Content) == "" {
+ continue
+ }
+ c := e.Confidence
+ if c <= 0 || c > 1 {
+ c = 0.5
+ }
+ if _, _, err := mem.Remember(ctx, record.Write{Kind: e.Kind, Content: e.Content, Source: "extractor",
+ Agent: o.Agent, Session: o.Session, Confidence: c, At: at, LogEvent: true}); err != nil {
+ return n, err
+ }
+ n++
+ }
+ st.LastTurn = last
+ st.ToolCallsSince = 0
+ st.ExtractionCount++
+ if o.Final && n > 0 {
+ // The handoff was written at session end, before this pass: rewrite
+ // it so the next session sees what was distilled.
+ _ = mem.WriteHandoff(ctx, o.Session, o.Agent, scope, o.Now().UnixMilli())
+ }
+ return n, st.save(scope, o.Root, o.Session)
+}
+
+var injected = regexp.MustCompile(`(?s)<(system-reminder|local-command-caveat|local-command-stdout|command-message|task-notification)[^>]*>.*?(system-reminder|local-command-caveat|local-command-stdout|command-message|task-notification)>`)
+
+// conversation is the session's text after a turn, from the capture store:
+// prompts, replies and the tools called, the last ExcerptChars of it, plus
+// the session's last turn and the number of tool calls since.
+func conversation(ctx context.Context, root, native string, after int64) (string, int64, int, error) {
+ st, err := store.Open(root, false)
+ if err != nil {
+ return "", 0, 0, err
+ }
+ defer st.Close()
+ db := st.DB()
+ var id string
+ if err := db.QueryRowContext(ctx, `SELECT id FROM agent_session WHERE workspace_id = ? AND native_session_id = ?
+ ORDER BY updated_at DESC LIMIT 1`, root, native).Scan(&id); err != nil {
+ return "", 0, 0, err
+ }
+ var last int64
+ _ = db.QueryRowContext(ctx, `SELECT COALESCE(MAX(turn_seq), 0) FROM turn WHERE session_id = ?`, id).Scan(&last)
+ var tools int
+ _ = db.QueryRowContext(ctx, `SELECT count(*) FROM tool_call WHERE session_id = ? AND turn_seq > ?`, id, after).Scan(&tools)
+ rows, err := db.QueryContext(ctx, `SELECT 'm', role, COALESCE(body, ''), seq FROM agent_message
+ WHERE session_id = ? AND turn_seq > ? AND mode = 'text'
+ UNION ALL SELECT 't', tool_name, COALESCE(title, ''), seq FROM tool_call WHERE session_id = ? AND turn_seq > ?
+ ORDER BY 4`, id, after, id, after)
+ if err != nil {
+ return "", 0, 0, err
+ }
+ defer rows.Close()
+ var b strings.Builder
+ for rows.Next() {
+ var typ, who, text string
+ var seq int64
+ if rows.Scan(&typ, &who, &text, &seq) != nil {
+ continue
+ }
+ text = strings.TrimSpace(injected.ReplaceAllString(text, ""))
+ if typ == "t" {
+ fmt.Fprintf(&b, "[tool %s] %s\n", who, firstLine(text))
+ continue
+ }
+ if text == "" {
+ continue
+ }
+ label := "Assistant"
+ if who == "user" {
+ label = "User"
+ }
+ fmt.Fprintf(&b, "%s: %s\n\n", label, text)
+ }
+ s := b.String()
+ if r := []rune(s); len(r) > ExcerptChars {
+ s = string(r[len(r)-ExcerptChars:])
+ }
+ return s, last, tools, rows.Err()
+}
+
+func firstLine(s string) string {
+ s, _, _ = strings.Cut(s, "\n")
+ if len(s) > 160 {
+ s = s[:160]
+ }
+ return s
+}
+
+// Entry is one memory the model proposes.
+type Entry struct {
+ Kind string `json:"kind"`
+ Content string `json:"content"`
+ Confidence float64 `json:"confidence"`
+}
+
+// Prompt is what the model is asked; the excerpt follows it.
+const Prompt = `You distil durable project memory from a coding-agent session in a software repository.
+Read the session excerpt below and return only a JSON object, no prose:
+{"entries":[{"kind":"decision|fact|failure|architecture|preference","content":"...","confidence":0.0}]}
+
+Keep only what a future session in this repository needs and could not cheaply rediscover from the code:
+- decision: a choice that was made and why
+- fact: a durable truth about the project, its tools or its environment
+- failure: a dead end or mistake, with the rule to follow next time
+- architecture: how parts of the system fit together
+- preference: something the user asked to be done a certain way from now on
+Each content is one or two self-contained sentences. No session narration, no task status, no file lists, no secrets.
+confidence is how sure you are the memory is true and durable (0 to 1).
+Return {"entries":[]} when nothing qualifies. At most 8 entries.
+
+Session excerpt:
+`
+
+func ask(ctx context.Context, o Options, token, org, excerpt string) ([]Entry, error) {
+ model, err := defaultModel(ctx, o, token, org)
+ if err != nil {
+ return nil, err
+ }
+ body, _ := json.Marshal(map[string]any{"model": model, "stream": false,
+ "messages": []map[string]string{{"role": "user", "content": Prompt + excerpt}}})
+ var out struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+ if err := call(ctx, o, token, org, http.MethodPost, "/chat/completions", body, &out); err != nil {
+ return nil, err
+ }
+ if len(out.Choices) == 0 {
+ return nil, errors.New("the gateway returned no choice")
+ }
+ return parseEntries(out.Choices[0].Message.Content)
+}
+
+// parseEntries reads {"entries":[…]} out of a reply that may wrap it in a
+// code fence or prose.
+func parseEntries(text string) ([]Entry, error) {
+ i, j := strings.Index(text, "{"), strings.LastIndex(text, "}")
+ if i < 0 || j <= i {
+ return nil, fmt.Errorf("no JSON object in the reply")
+ }
+ var v struct {
+ Entries []Entry `json:"entries"`
+ }
+ if err := json.Unmarshal([]byte(text[i:j+1]), &v); err != nil {
+ return nil, err
+ }
+ return v.Entries, nil
+}
+
+// defaultModel is the gateway's model flagged default (the desktop's
+// "gateway default model").
+func defaultModel(ctx context.Context, o Options, token, org string) (string, error) {
+ var out struct {
+ Data []struct {
+ ID string `json:"id"`
+ Default bool `json:"default"`
+ } `json:"data"`
+ }
+ if err := call(ctx, o, token, org, http.MethodGet, "/models", nil, &out); err != nil {
+ return "", err
+ }
+ for _, m := range out.Data {
+ if m.Default {
+ return m.ID, nil
+ }
+ }
+ if len(out.Data) > 0 {
+ return out.Data[0].ID, nil
+ }
+ return "", errors.New("the gateway lists no model")
+}
+
+func call(ctx context.Context, o Options, token, org, method, path string, body []byte, out any) error {
+ var r io.Reader
+ if body != nil {
+ r = bytes.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(o.Gateway, "/")+path, r)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ if org != "" {
+ req.Header.Set("atlas-org", org)
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := o.HTTP.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("gateway %s %s: %d", method, path, resp.StatusCode)
+ }
+ return json.Unmarshal(b, out)
+}
+
+// tryLock takes the session's extraction lock without waiting.
+func tryLock(root, session string) (func(), bool) {
+ p := legacyStatePath(root, session) + ".lock" // the CLI's own: .atlas/ holds only the app's files
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
+ return nil, false
+ }
+ f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // G304: the CLI's own lock file
+ if err != nil {
+ return nil, false
+ }
+ if unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) != nil {
+ _ = f.Close()
+ return nil, false
+ }
+ return func() {
+ _ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
+ _ = f.Close()
+ }, true
+}
diff --git a/internal/memory/extract/extract_test.go b/internal/memory/extract/extract_test.go
new file mode 100644
index 0000000..d4be306
--- /dev/null
+++ b/internal/memory/extract/extract_test.go
@@ -0,0 +1,140 @@
+package extract
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/tryatlas/atlas-cli/internal/capture/record"
+ "github.com/tryatlas/atlas-cli/internal/capture/store"
+ memrecord "github.com/tryatlas/atlas-cli/internal/memory/record"
+)
+
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: deterministic
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
+
+// gateway answers /models and /chat/completions like the Atlas gateway.
+func gateway(t *testing.T, seen *string) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer tok" || r.Header.Get("atlas-org") != "org_1" {
+ http.Error(w, "auth", http.StatusUnauthorized)
+ return
+ }
+ switch r.URL.Path {
+ case "/v1/models":
+ fmt.Fprint(w, `{"data":[{"id":"big","default":false},{"id":"small","default":true}]}`)
+ case "/v1/chat/completions":
+ var req struct {
+ Model string `json:"model"`
+ Messages []struct{ Content string } `json:"messages"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ *seen = req.Model + "\n" + req.Messages[0].Content
+ fmt.Fprint(w, `{"choices":[{"message":{"content":"`+"```json\\n"+`{\"entries\":[{\"kind\":\"decision\",\"content\":\"Use the streaming parser for large files.\",\"confidence\":0.8},{\"kind\":\"bogus\",\"content\":\"x\"}]}`+"\\n```"+`"}}]}`)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+}
+
+func TestExtractGatesAndWrites(t *testing.T) {
+ ctx := context.Background()
+ root, _ := os.MkdirTemp("", "atlas-x-")
+ defer os.RemoveAll(root)
+ st, err := store.Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = st.PutBinding(ctx, store.Binding{WorkspaceID: root, Root: root, Mode: store.ModeLocal, Enabled: true}, time.Now())
+ rec := record.New(st, root)
+ sess := record.Session{Agent: "claude-acp", NativeID: "s-x", Cwd: root}
+ for i := 0; i < 3; i++ {
+ args, _ := json.Marshal(map[string]string{"command": "go test"})
+ if _, err := rec.RecordTurn(ctx, sess, record.CapturedTurn{Prompt: fmt.Sprintf("step %d", i),
+ Items: []record.CapturedItem{{Mode: "text", Body: "ran the tests hidden"},
+ {Tool: &record.Tool{NativeID: fmt.Sprintf("t%d", i), Name: "Bash", Arguments: args, Status: "completed"}}}}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _ = st.Close()
+ var seen string
+ gw := gateway(t, &seen)
+ defer gw.Close()
+ o := Options{Root: root, Agent: "claude-acp", Session: "s-x", Gateway: gw.URL + "/v1",
+ Auth: func(context.Context) (string, string, error) { return "tok", "org_1", nil }}
+
+ // 3 turns: under the mid-session gate.
+ if _, err := Run(ctx, o); !errors.Is(err, ErrSkipped) {
+ t.Fatalf("mid-session pass = %v, want skipped", err)
+ }
+ // Session end: extract whatever is new.
+ o.Final = true
+ n, err := Run(ctx, o)
+ if err != nil || n != 1 {
+ t.Fatalf("final pass = %d %v", n, err)
+ }
+ if !strings.HasPrefix(seen, "small\n") || !strings.Contains(seen, "User: step 2") || !strings.Contains(seen, "[tool Bash]") || strings.Contains(seen, "hidden") {
+ t.Fatalf("request = %q", seen)
+ }
+ mem, err := memrecord.Open(root, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer mem.Close()
+ es, _ := mem.Active(ctx, "decision")
+ if len(es) != 1 || es[0].Source != "extractor" || es[0].Agent != "claude-acp" || es[0].Session != "s-x" {
+ t.Fatalf("entries = %+v", es)
+ }
+ // Nothing new since: skipped.
+ if _, err := Run(ctx, o); !errors.Is(err, ErrSkipped) {
+ t.Fatalf("repeat = %v", err)
+ }
+ // Progress is kept where and how the desktop keeps it.
+ b, err := os.ReadFile(filepath.Join(root, ".atlas", "memory", "extract-state", "s-x.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got map[string]any
+ _ = json.Unmarshal(b, &got)
+ if got["last_extracted_turn_index"] != float64(3) || got["tool_calls_since_last"] != float64(0) || got["extraction_count"] != float64(1) {
+ t.Fatalf("state = %s", b)
+ }
+ // An unknown session is skipped, not an error.
+ o.Session = "not-captured"
+ if _, err := Run(ctx, o); !errors.Is(err, ErrSkipped) {
+ t.Fatalf("unknown session = %v", err)
+ }
+}
+
+func TestSanitizeMatchesTheDesktop(t *testing.T) {
+ if got := sanitize("ses_ab/c:d.é-1"); got != "ses_ab_c_d__-1" {
+ t.Fatal(got)
+ }
+}
+
+func TestLocalSummariserSendsNothing(t *testing.T) {
+ root := t.TempDir()
+ _ = os.MkdirAll(root+"/.atlas", 0o755)
+ _ = os.WriteFile(root+"/.atlas/memory-summarizer.json", []byte(`{"mode":"local"}`), 0o644)
+ called := false
+ o := Options{Root: root, Session: "s", Final: true, Gateway: "http://127.0.0.1:1",
+ Auth: func(context.Context) (string, string, error) { called = true; return "", "", nil }}
+ if _, err := Run(context.Background(), o); !errors.Is(err, ErrSkipped) || called {
+ t.Fatalf("local mode: %v called=%v", err, called)
+ }
+}
diff --git a/internal/memory/record/handoff.go b/internal/memory/record/handoff.go
new file mode 100644
index 0000000..3763194
--- /dev/null
+++ b/internal/memory/record/handoff.go
@@ -0,0 +1,125 @@
+package record
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// ── Handoff notes and capture candidates (the desktop's session lifecycle) ──
+
+// HandoffNote is what a finished session leaves for the next one: the
+// episodes row's note, camelCase like the desktop's.
+type HandoffNote struct {
+ Session string `json:"session"`
+ Agent string `json:"agent"`
+ StartedAt *int64 `json:"startedAt"`
+ EndedAt int64 `json:"endedAt"`
+ Plan *string `json:"plan"`
+ OpenItems []string `json:"openItems"`
+ Decisions []string `json:"decisions"`
+ Failures []string `json:"failures"`
+ Facts []string `json:"facts"`
+ Architecture []string `json:"architecture"`
+ Files []string `json:"files"`
+}
+
+// handoffCap bounds each list of a note.
+const handoffCap = 10
+
+// WriteHandoff builds the session's handoff note from the entries it wrote
+// and stores it as its episode (replacing an earlier one). A session that
+// wrote nothing leaves no note.
+func (s *Store) WriteHandoff(ctx context.Context, session, agent, root string, endedAt int64) error {
+ entries, err := s.EntriesBySessions(ctx, []string{session}, 400)
+ if err != nil || len(entries) == 0 {
+ return err
+ }
+ n := HandoffNote{Session: session, Agent: agent, EndedAt: endedAt,
+ OpenItems: []string{}, Decisions: []string{}, Failures: []string{}, Facts: []string{}, Architecture: []string{}, Files: []string{}}
+ var started int64
+ if s.db.QueryRowContext(ctx, `SELECT started_at FROM sessions WHERE session_id = ?`, session).Scan(&started) == nil && started > 0 {
+ n.StartedAt = &started
+ }
+ add := func(list *[]string, v string) {
+ if v = strings.TrimSpace(v); v != "" && len(*list) < handoffCap {
+ *list = append(*list, v)
+ }
+ }
+ for _, e := range entries {
+ if e.State != StateActive {
+ continue // candidates are not the session's word yet
+ }
+ switch e.Kind {
+ case KindPlan:
+ if n.Plan == nil {
+ c := e.Content
+ n.Plan = &c
+ }
+ case "decision":
+ add(&n.Decisions, e.Content)
+ case "failure":
+ add(&n.Failures, e.Content)
+ case "fact":
+ add(&n.Facts, e.Content)
+ case "architecture":
+ add(&n.Architecture, e.Content)
+ case KindFileChanged:
+ p := e.Key
+ if p != "" && !filepath.IsAbs(p) && root != "" {
+ p = filepath.Join(root, filepath.FromSlash(p))
+ }
+ add(&n.Files, p)
+ }
+ }
+ b, err := json.Marshal(n)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx, `INSERT INTO episodes (session, agent, started_at, ended_at, note) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(session) DO UPDATE SET agent = excluded.agent, ended_at = excluded.ended_at, note = excluded.note`,
+ session, agent, n.StartedAt, endedAt, string(b))
+ return err
+}
+
+// markers are the phrases in an agent's own reply that the desktop records
+// as candidate memories (memory_delta.rs): never active until confirmed.
+var markers = []struct {
+ kind string
+ re *regexp.Regexp
+}{
+ {"decision", regexp.MustCompile(`(?i)\b(decided to\b|decision:|we will use\b|let's use\b|going with\b)`)},
+ {"fact", regexp.MustCompile(`(?i)\b(note:|remember:|convention:)`)},
+ {"failure", regexp.MustCompile(`(?i)\b(failed:|doesn't work\b|does not work\b|anti-pattern\b|gotcha:)`)},
+ {"architecture", regexp.MustCompile(`(?i)\b(architecture:|structured as\b|the system uses\b)`)},
+}
+
+// candidateMax bounds one captured line.
+const candidateMax = 500
+
+// CaptureCandidates records each line of an agent's reply that carries a
+// marker as a candidate of the marker's kind (source capture, confidence
+// 0.3). It returns how many were written.
+func (s *Store) CaptureCandidates(ctx context.Context, text, agent, session string, at int64) (int, error) {
+ n := 0
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*>#0123456789. "))
+ if len(line) < 12 || len(line) > candidateMax {
+ continue
+ }
+ for _, m := range markers {
+ if !m.re.MatchString(line) {
+ continue
+ }
+ if _, _, err := s.Remember(ctx, Write{Kind: m.kind, Content: line, Source: "capture", Agent: agent,
+ Session: session, Confidence: Candidate, At: at}); err != nil {
+ return n, err
+ }
+ n++
+ break
+ }
+ }
+ return n, nil
+}
diff --git a/internal/memory/record/handoff_test.go b/internal/memory/record/handoff_test.go
new file mode 100644
index 0000000..d67fc1a
--- /dev/null
+++ b/internal/memory/record/handoff_test.go
@@ -0,0 +1,55 @@
+package record
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+)
+
+func TestCaptureCandidatesAndHandoff(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ st, err := Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ _ = st.SessionStart(ctx, "s1", "claude-acp", 1000)
+ reply := "Done.\n- We decided to keep the parser streaming.\nGotcha: the cache key must include the locale.\nDecision: ship the CLI for Intel Macs too.\nnothing to see here at all\n"
+ n, err := st.CaptureCandidates(ctx, reply, "claude-acp", "s1", 2000)
+ if err != nil || n != 3 {
+ t.Fatalf("candidates = %d %v", n, err)
+ }
+ es, _ := st.List(ctx, "", 50)
+ kinds := map[string]string{}
+ for _, e := range es {
+ kinds[e.Kind] = e.State
+ if e.Source != "capture" || e.Agent != "claude-acp" {
+ t.Fatalf("entry %+v", e)
+ }
+ }
+ if kinds["decision"] != StateCandidate || kinds["failure"] != StateCandidate {
+ t.Fatalf("kinds = %v (candidates must never be active)", kinds)
+ }
+ if _, err := st.Fold(ctx, Event{Kind: KindFileChanged, Key: "a.go", Text: "Edit", Agent: "claude-acp", Session: "s1", At: 2500}); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.WriteHandoff(ctx, "s1", "claude-acp", "/repo", 3000); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := st.LatestHandoff(ctx, "s2")
+ if err != nil || raw == nil {
+ t.Fatalf("handoff = %s %v", raw, err)
+ }
+ var note HandoffNote
+ if err := json.Unmarshal(raw, ¬e); err != nil {
+ t.Fatal(err)
+ }
+ if note.Session != "s1" || note.Agent != "claude-acp" || note.StartedAt == nil || *note.StartedAt != 1000 || note.EndedAt != 3000 ||
+ len(note.Decisions) != 0 || len(note.Failures) != 0 || len(note.Files) != 1 || note.Files[0] != "/repo/a.go" || note.OpenItems == nil {
+ t.Fatalf("note = %+v", note)
+ }
+ if raw, _ := st.LatestHandoff(ctx, "s1"); raw != nil {
+ t.Fatal("a session never sees its own handoff")
+ }
+}
diff --git a/internal/memory/record/read.go b/internal/memory/record/read.go
index b3a0100..2f15f15 100644
--- a/internal/memory/record/read.go
+++ b/internal/memory/record/read.go
@@ -183,20 +183,24 @@ func FTSQuery(q string) string {
// Hit is a search result with its BM25 rank (1-based; 0 when not from BM25).
type Hit struct {
Entry *Entry
- BM25 int
+ BM25 int // 1-based rank in the keyword leg; 0 when it did not find it
+ Dense int // 1-based rank in the meaning leg
}
// rrfK is the reciprocal-rank-fusion constant.
const rrfK = 60
-// Search ranks entries: the BM25 leg (depth 100) fused with two priors that
-// only reorder what was found — trust (active before candidate, then
-// confidence, then id; weight 0.5) and recency (weight 0.3). Archived entries
-// are excluded. An empty query returns the newest entries.
+// Search is the app's hybrid search (record.rs search_explained): the
+// keyword leg (FTS5 BM25, 100 deep) and the meaning leg (cosine over cached
+// vectors, 100 deep, at least 0.35) fused by reciprocal rank (k 60, weight 1
+// each), then priors that only reorder what was found: trust (active before
+// candidate, then confidence, then id; weight 0.5) and recency (the later of
+// last use and update; weight 0.3). Archived entries are excluded. Without a
+// model the meaning leg is empty. An empty query returns the newest entries.
func (s *Store) Search(ctx context.Context, query string, kinds []string, limit int) ([]Hit, error) {
kq, kargs := kindFilter(kinds)
fq := FTSQuery(query)
- if fq == "" {
+ if strings.TrimSpace(query) == "" {
es, err := s.queryEntries(ctx, `WHERE state <> 'archived'`+kq+` ORDER BY updated_at DESC, id DESC LIMIT ?`, append(kargs, limit)...)
hits := make([]Hit, len(es))
for i, e := range es {
@@ -204,20 +208,41 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit
}
return hits, err
}
- rows, err := s.db.QueryContext(ctx, `SELECT rowid FROM entries_fts WHERE entries_fts MATCH ? ORDER BY rank LIMIT 100`, fq)
- if err != nil {
- return nil, err
+ var bmIDs []int64
+ if fq != "" {
+ rows, err := s.db.QueryContext(ctx, `SELECT rowid FROM entries_fts WHERE entries_fts MATCH ? ORDER BY rank LIMIT ?`, fq, legDepth)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ _ = rows.Close()
+ return nil, err
+ }
+ bmIDs = append(bmIDs, id)
+ }
+ _ = rows.Close()
}
- var ids []int64
- for rows.Next() {
- var id int64
- if err := rows.Scan(&id); err != nil {
- _ = rows.Close()
+ var denseIDs []int64
+ if v := vectorFor(ctx, s.db, strings.TrimSpace(query)); v != nil {
+ var err error
+ if denseIDs, err = s.denseIDs(ctx, v); err != nil {
return nil, err
}
+ }
+ bm, dn := map[int64]int{}, map[int64]int{}
+ var ids []int64
+ for i, id := range bmIDs {
+ bm[id] = i + 1
ids = append(ids, id)
}
- _ = rows.Close()
+ for i, id := range denseIDs {
+ dn[id] = i + 1
+ if bm[id] == 0 {
+ ids = append(ids, id)
+ }
+ }
if len(ids) == 0 {
return nil, nil
}
@@ -230,19 +255,20 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit
if err != nil {
return nil, err
}
- bm := map[int64]int{}
- for i, id := range ids {
- bm[id] = i + 1
- }
score := map[int64]float64{}
for _, e := range found {
- score[e.ID] += 1.0 / float64(rrfK+bm[e.ID])
+ if r := bm[e.ID]; r > 0 {
+ score[e.ID] += 1.0 / float64(rrfK+r)
+ }
+ if r := dn[e.ID]; r > 0 {
+ score[e.ID] += 1.0 / float64(rrfK+r)
+ }
}
trust := append([]*Entry(nil), found...)
sort.SliceStable(trust, func(i, j int) bool {
a, b := trust[i], trust[j]
- if (a.State == StateActive) != (b.State == StateActive) {
- return a.State == StateActive
+ if (a.State == StateCandidate) != (b.State == StateCandidate) {
+ return b.State == StateCandidate
}
if a.Confidence != b.Confidence {
return a.Confidence > b.Confidence
@@ -252,8 +278,20 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit
for i, e := range trust {
score[e.ID] += 0.5 / float64(rrfK+i+1)
}
+ lastTouch := func(e *Entry) int64 {
+ t := e.UpdatedAt
+ if e.LastUsedAt != nil && *e.LastUsedAt > t {
+ t = *e.LastUsedAt
+ }
+ return t
+ }
recent := append([]*Entry(nil), found...)
- sort.SliceStable(recent, func(i, j int) bool { return recent[i].UpdatedAt > recent[j].UpdatedAt })
+ sort.SliceStable(recent, func(i, j int) bool {
+ if lastTouch(recent[i]) != lastTouch(recent[j]) {
+ return lastTouch(recent[i]) > lastTouch(recent[j])
+ }
+ return recent[i].ID < recent[j].ID
+ })
for i, e := range recent {
score[e.ID] += 0.3 / float64(rrfK+i+1)
}
@@ -263,9 +301,12 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit
}
return found[i].ID < found[j].ID
})
+ if limit > 0 && len(found) > limit {
+ found = found[:limit]
+ }
hits := make([]Hit, len(found))
for i, e := range found {
- hits[i] = Hit{Entry: e, BM25: bm[e.ID]}
+ hits[i] = Hit{Entry: e, BM25: bm[e.ID], Dense: dn[e.ID]}
}
return hits, nil
}
diff --git a/internal/memory/record/snapshot.go b/internal/memory/record/snapshot.go
new file mode 100644
index 0000000..784c97d
--- /dev/null
+++ b/internal/memory/record/snapshot.go
@@ -0,0 +1,61 @@
+package record
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+)
+
+// SnapshotIfDue refreshes memory.snapshot.sqlite when the last one is a day
+// old or missing, as the desktop's health pass does (crates/atlas-memory/
+// src/health.rs snapshot_if_due): VACUUM INTO a temp file, stamp it with a
+// snapshot_meta 'at' row (the age is read from there, not the mtime), then
+// rename it into place. A damaged record is restored from it on open.
+func (s *Store) SnapshotIfDue(now int64) (bool, error) {
+ dir := filepath.Dir(Path(s.ScopeRoot))
+ path := filepath.Join(dir, SnapshotFile)
+ if at, ok := snapshotAt(path); ok && now-at < SnapshotEvery.Milliseconds() {
+ return false, nil
+ }
+ tmp := path + ".tmp"
+ _ = os.Remove(tmp)
+ if _, err := s.db.Exec(`VACUUM INTO ?`, tmp); err != nil {
+ return false, err
+ }
+ snap, err := sql.Open("sqlite", "file:"+tmp)
+ if err != nil {
+ return false, err
+ }
+ _, err = snap.Exec(`CREATE TABLE IF NOT EXISTS snapshot_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)`)
+ if err == nil {
+ _, err = snap.Exec(`INSERT OR REPLACE INTO snapshot_meta (k, v) VALUES ('at', ?)`, strconv.FormatInt(now, 10))
+ }
+ if cerr := snap.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ _ = os.Remove(tmp)
+ return false, fmt.Errorf("stamp snapshot: %w", err)
+ }
+ return true, os.Rename(tmp, path)
+}
+
+// snapshotAt reads the snapshot's 'at' stamp.
+func snapshotAt(path string) (int64, bool) {
+ if _, err := os.Stat(path); err != nil {
+ return 0, false
+ }
+ db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
+ if err != nil {
+ return 0, false
+ }
+ defer db.Close()
+ var v string
+ if db.QueryRow(`SELECT v FROM snapshot_meta WHERE k = 'at'`).Scan(&v) != nil {
+ return 0, false
+ }
+ at, err := strconv.ParseInt(v, 10, 64)
+ return at, err == nil
+}
diff --git a/internal/memory/record/snapshot_test.go b/internal/memory/record/snapshot_test.go
new file mode 100644
index 0000000..59f2434
--- /dev/null
+++ b/internal/memory/record/snapshot_test.go
@@ -0,0 +1,62 @@
+package record
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestSnapshotAndRestore(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ st, err := Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UnixMilli()
+ if _, _, err := st.Remember(ctx, Write{Kind: "decision", Content: "Keep the record restorable", Source: "agent", Agent: "pi", Session: "s1", Confidence: 0.9, At: now}); err != nil {
+ t.Fatal(err)
+ }
+ if did, err := st.SnapshotIfDue(now); err != nil || !did {
+ t.Fatalf("first snapshot: %v %v", did, err)
+ }
+ if did, _ := st.SnapshotIfDue(now + time.Hour.Milliseconds()); did {
+ t.Fatal("a snapshot is due once a day")
+ }
+ if at, ok := snapshotAt(filepath.Join(root, ".atlas", "memory", SnapshotFile)); !ok || at != now {
+ t.Fatalf("stamp = %d %v", at, ok)
+ }
+ _ = st.Close()
+
+ // Damage the record: the next open quarantines it and restores the snapshot.
+ dir := filepath.Join(root, ".atlas", "memory")
+ for _, f := range []string{"-wal", "-shm"} {
+ _ = os.Remove(filepath.Join(dir, DBFile+f))
+ }
+ if err := os.WriteFile(filepath.Join(dir, DBFile), []byte("this is not a database, not at all, definitely not one"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ st, err = Open(root, true)
+ if err != nil {
+ t.Fatalf("open after damage: %v", err)
+ }
+ defer st.Close()
+ es, _ := st.Active(ctx, "decision")
+ if len(es) != 1 || es[0].Content != "Keep the record restorable" {
+ t.Fatalf("restored = %+v", es)
+ }
+ if b, err := os.ReadFile(filepath.Join(dir, "restored.json")); err != nil || string(b) == "" {
+ t.Fatal("no restored.json marker")
+ }
+ if m, _ := filepath.Glob(filepath.Join(dir, DBFile+".corrupt-*")); len(m) != 1 {
+ t.Fatalf("quarantined = %v", m)
+ }
+}
+
+func TestAnOldSchemaIsNotQuarantined(t *testing.T) {
+ if damaged(ErrSchemaTooOld) || damaged(ErrNoRecord) {
+ t.Fatal("a declined record is not damaged")
+ }
+}
diff --git a/internal/memory/record/stale_test.go b/internal/memory/record/stale_test.go
new file mode 100644
index 0000000..df52ce6
--- /dev/null
+++ b/internal/memory/record/stale_test.go
@@ -0,0 +1,33 @@
+package record
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestStaleAfterTheRecordIsRecreated(t *testing.T) {
+ root := t.TempDir()
+ st, err := Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ if st.Stale() {
+ t.Fatal("fresh store is not stale")
+ }
+ if err := os.RemoveAll(filepath.Join(root, ".atlas")); err != nil {
+ t.Fatal(err)
+ }
+ if !st.Stale() {
+ t.Fatal("a deleted record must be stale")
+ }
+ again, err := Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer again.Close()
+ if !st.Stale() || again.Stale() {
+ t.Fatal("the old handle is stale, the new one is not")
+ }
+}
diff --git a/internal/memory/record/state_main_test.go b/internal/memory/record/state_main_test.go
new file mode 100644
index 0000000..f116ef1
--- /dev/null
+++ b/internal/memory/record/state_main_test.go
@@ -0,0 +1,16 @@
+package record
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: keyword-only and deterministic
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/memory/record/store.go b/internal/memory/record/store.go
index e82723a..db6c138 100644
--- a/internal/memory/record/store.go
+++ b/internal/memory/record/store.go
@@ -11,6 +11,8 @@ import (
"os"
"path/filepath"
"strings"
+ "syscall"
+ "time"
_ "modernc.org/sqlite" // pure-Go SQLite with FTS5
)
@@ -93,8 +95,40 @@ const (
type Store struct {
ScopeRoot string
db *sql.DB
+ opened os.FileInfo // memory.sqlite as opened
+ held *os.File // a handle on it: unlinked means .atlas/ went away
}
+// Stale reports that memory.sqlite on disk is no longer the file this store
+// opened (.atlas/ deleted or re-created): a long-lived holder (the listener,
+// an mcp-bridge) must reopen it, or its writes go to a file no one sees.
+func (s *Store) Stale() bool {
+ now, err := os.Stat(Path(s.ScopeRoot))
+ return err != nil || s.opened == nil || unlinked(s.held) || !os.SameFile(s.opened, now)
+}
+
+// unlinked: the open file's link count is 0. A re-created file can reuse
+// the inode number (ext4 does), so the inode comparison alone misses it.
+func unlinked(f *os.File) bool {
+ if f == nil {
+ return false
+ }
+ fi, err := f.Stat()
+ if err != nil {
+ return true
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ return ok && st.Nlink == 0
+}
+
+// File names in /.atlas/memory/, as the desktop names them.
+const (
+ DBFile = "memory.sqlite"
+ SnapshotFile = "memory.snapshot.sqlite"
+ // SnapshotEvery is how old the snapshot may get (health.rs SNAPSHOT_EVERY_MS).
+ SnapshotEvery = 24 * time.Hour
+)
+
// Path is /.atlas/memory/memory.sqlite.
func Path(scopeRoot string) string {
return filepath.Join(scopeRoot, ".atlas", "memory", "memory.sqlite")
@@ -104,6 +138,58 @@ func Path(scopeRoot string) string {
// created at schema v6. A record below v6 was made by an older desktop and is
// refused rather than overwritten.
func Open(scopeRoot string, create bool) (*Store, error) {
+ st, err := openChecked(scopeRoot, create)
+ if err == nil || !damaged(err) {
+ return st, err
+ }
+ // A damaged record is quarantined and the last snapshot copied in its
+ // place, with a restored.json marker for the app, as the desktop does
+ // (crates/atlas-memory/src/record.rs RecordStore::open).
+ if qerr := quarantine(scopeRoot, time.Now().UnixMilli()); qerr != nil {
+ return nil, fmt.Errorf("%w (and quarantining it failed: %w)", err, qerr)
+ }
+ return openChecked(scopeRoot, create)
+}
+
+// damaged tells a damaged file from a record the CLI declines (wrong schema
+// version, not there).
+func damaged(err error) bool {
+ if errors.Is(err, ErrNoRecord) || errors.Is(err, ErrSchemaTooNew) || errors.Is(err, ErrSchemaTooOld) {
+ return false
+ }
+ m := strings.ToLower(err.Error())
+ for _, w := range []string{"malformed", "not a database", "quick_check", "corrupt", "disk image"} {
+ if strings.Contains(m, w) {
+ return true
+ }
+ }
+ return false
+}
+
+func quarantine(scopeRoot string, now int64) error {
+ dir := filepath.Dir(Path(scopeRoot))
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ from := filepath.Join(dir, DBFile+suffix)
+ if _, err := os.Stat(from); err == nil {
+ if err := os.Rename(from, filepath.Join(dir, fmt.Sprintf("%s%s.corrupt-%d", DBFile, suffix, now))); err != nil {
+ return err
+ }
+ }
+ }
+ fromSnapshot := copyFile(filepath.Join(dir, SnapshotFile), filepath.Join(dir, DBFile)) == nil
+ b, _ := json.Marshal(map[string]any{"at": now, "from_snapshot": fromSnapshot})
+ return os.WriteFile(filepath.Join(dir, "restored.json"), b, 0o644) //nolint:gosec // G306: a marker the app reads
+}
+
+func copyFile(from, to string) error {
+ b, err := os.ReadFile(from) //nolint:gosec // G304: the record's own snapshot
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(to, b, 0o644) //nolint:gosec // G306: the record, as the app writes it
+}
+
+func openChecked(scopeRoot string, create bool) (*Store, error) {
path := Path(scopeRoot)
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
if !create {
@@ -124,6 +210,16 @@ func Open(scopeRoot string, create bool) (*Store, error) {
_ = db.Close()
return nil, err
}
+ var quick string
+ if err := db.QueryRow(`PRAGMA quick_check`).Scan(&quick); err != nil || quick != "ok" {
+ _ = db.Close()
+ if err == nil {
+ err = fmt.Errorf("quick_check: %s", quick)
+ }
+ return nil, err
+ }
+ s.opened, _ = os.Stat(path)
+ s.held, _ = os.Open(path) //nolint:gosec // G304: the record's own file
return s, nil
}
@@ -162,7 +258,12 @@ func (s *Store) migrate() error {
}
// Close closes the record.
-func (s *Store) Close() error { return s.db.Close() }
+func (s *Store) Close() error {
+ if s.held != nil {
+ _ = s.held.Close()
+ }
+ return s.db.Close()
+}
// VerifyChain recomputes the revision hash chain.
func (s *Store) VerifyChain() (ChainReport, error) { return verifyChain(s.db) }
diff --git a/internal/memory/record/vectors.go b/internal/memory/record/vectors.go
new file mode 100644
index 0000000..47cf672
--- /dev/null
+++ b/internal/memory/record/vectors.go
@@ -0,0 +1,312 @@
+package record
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "sort"
+ "strings"
+ "unicode"
+
+ "github.com/tryatlas/atlas-cli/internal/embed"
+)
+
+// VectorSource embeds text; the record caches what it returns.
+type VectorSource interface {
+ Embed(text string) []float32
+}
+
+// Vectors returns the embedder, or nil when the model is not on this
+// machine: then search is keyword-only and dedup is key-or-hash only, as
+// in the app. Tests replace it.
+var Vectors = func() VectorSource {
+ e, err := embed.Default()
+ if err != nil {
+ return nil
+ }
+ return e
+}
+
+// VectorModel is the name vectors are cached under.
+const VectorModel = embed.ModelName
+
+// NearDuplicate is the cosine at which a new durable memory merges into a
+// stored one of its kind (record.rs NEAR_DUPLICATE).
+const NearDuplicate = 0.92
+
+// Search legs (record.rs LEG_DEPTH, MIN_SIMILARITY).
+const (
+ legDepth = 100
+ minSimilarity = 0.35
+)
+
+type vecDB interface {
+ QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+ QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+}
+
+// cachedVector reads text's vector from embed_cache.
+func cachedVector(ctx context.Context, q vecDB, text string) []float32 {
+ var vec []byte
+ if q.QueryRowContext(ctx, `SELECT vec FROM embed_cache WHERE key = ?`, embed.CacheKey(VectorModel, text)).Scan(&vec) != nil {
+ return nil
+ }
+ return embed.Decode(vec)
+}
+
+// putVector caches text's vector, as the app's put_cache.
+func putVector(ctx context.Context, q vecDB, text string, v []float32) error {
+ _, err := q.ExecContext(ctx, `INSERT OR REPLACE INTO embed_cache (key, model, dims, vec) VALUES (?, ?, ?, ?)`,
+ embed.CacheKey(VectorModel, text), VectorModel, len(v), embed.Encode(v))
+ return err
+}
+
+// vectorFor is text's vector: cached, else embedded (nil without a model,
+// or for a text with nothing to embed).
+func vectorFor(ctx context.Context, q vecDB, text string) []float32 {
+ if v := cachedVector(ctx, q, text); v != nil {
+ return v
+ }
+ src := Vectors()
+ if src == nil {
+ return nil
+ }
+ v := src.Embed(text)
+ for _, x := range v {
+ if x != 0 {
+ return v
+ }
+ }
+ return nil
+}
+
+// nearDuplicate is the stored entry of w's kind most like v at or above
+// NearDuplicate; keyed entries only merge with keyless ones, and a text
+// that contradicts (another number, one side negated) never merges.
+func nearDuplicate(ctx context.Context, tx *sql.Tx, w Write, v []float32) (*Entry, error) {
+ rows, err := tx.QueryContext(ctx, `SELECT id, key, content FROM entries WHERE kind = ? AND state <> 'archived'`, w.Kind)
+ if err != nil {
+ return nil, err
+ }
+ type cand struct {
+ id int64
+ key, content string
+ }
+ var cs []cand
+ for rows.Next() {
+ var c cand
+ if err := rows.Scan(&c.id, &c.key, &c.content); err != nil {
+ _ = rows.Close()
+ return nil, err
+ }
+ cs = append(cs, c)
+ }
+ _ = rows.Close()
+ var best int64
+ bestSim := float32(0)
+ for _, c := range cs {
+ if w.Key != "" && c.key != "" || ContradictsText(w.Content, c.content) {
+ continue
+ }
+ stored := cachedVector(ctx, tx, c.content)
+ if stored == nil {
+ continue
+ }
+ if sim := embed.Cosine(v, stored); sim >= NearDuplicate && sim > bestSim {
+ best, bestSim = c.id, sim
+ }
+ }
+ if best == 0 {
+ return nil, nil
+ }
+ return scanOne(tx.QueryRowContext(ctx, `SELECT `+entryColumns+` FROM entries WHERE id = ?`, best))
+}
+
+var negations = []string{"not", "never", "no longer", "don't", "doesn't", "isn't", "instead of"}
+
+// ContradictsText is the app's conflict signal (consolidate.rs
+// contradicts_text): the texts differ in their numbers or one is negated.
+func ContradictsText(a, b string) bool {
+ na, nb := numbers(a), numbers(b)
+ if strings.Join(na, "\x00") != strings.Join(nb, "\x00") {
+ return true
+ }
+ return negated(a) != negated(b)
+}
+
+func numbers(s string) []string {
+ parts := strings.FieldsFunc(s, func(r rune) bool { return (r < '0' || r > '9') && r != '.' })
+ var out []string
+ for _, p := range parts {
+ if strings.ContainsAny(p, "0123456789") {
+ out = append(out, strings.Trim(p, "."))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func negated(s string) bool {
+ var b strings.Builder
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case r == '\'' || r == '‘' || r == '’':
+ b.WriteRune('\'')
+ case isAlnum(r):
+ b.WriteRune(r)
+ default:
+ b.WriteByte(' ')
+ }
+ }
+ padded := " " + strings.Join(strings.Fields(b.String()), " ") + " "
+ for _, n := range negations {
+ if strings.Contains(padded, " "+n+" ") {
+ return true
+ }
+ }
+ return false
+}
+
+// denseIDs are the entries whose cached vector is within minSimilarity of
+// v, best first, at most legDepth (the app searches an HNSW built from the
+// same cache; this reads it exactly).
+func (s *Store) denseIDs(ctx context.Context, v []float32) ([]int64, error) {
+ rows, err := s.db.QueryContext(ctx, `SELECT id, content FROM entries WHERE state <> 'archived'`)
+ if err != nil {
+ return nil, err
+ }
+ type idText struct {
+ id int64
+ text string
+ }
+ var all []idText
+ for rows.Next() {
+ var x idText
+ if err := rows.Scan(&x.id, &x.text); err != nil {
+ _ = rows.Close()
+ return nil, err
+ }
+ all = append(all, x)
+ }
+ _ = rows.Close()
+ type scored struct {
+ id int64
+ sim float32
+ }
+ var hits []scored
+ for _, x := range all {
+ stored := cachedVector(ctx, s.db, x.text)
+ if stored == nil {
+ continue
+ }
+ if sim := embed.Cosine(v, stored); sim >= minSimilarity {
+ hits = append(hits, scored{x.id, sim})
+ }
+ }
+ sort.SliceStable(hits, func(i, j int) bool {
+ if hits[i].sim != hits[j].sim {
+ return hits[i].sim > hits[j].sim
+ }
+ return hits[i].id < hits[j].id
+ })
+ if len(hits) > legDepth {
+ hits = hits[:legDepth]
+ }
+ ids := make([]int64, len(hits))
+ for i, h := range hits {
+ ids[i] = h.id
+ }
+ return ids, nil
+}
+
+// SyncVectors embeds every durable entry without a cached vector (the app's
+// sync_vectors backfill) and drops cached vectors no entry uses any more
+// (garbage_keys). It returns how many it embedded; ErrNoVectors without a
+// model.
+func (s *Store) SyncVectors(ctx context.Context) (int, error) {
+ src := Vectors()
+ if src == nil {
+ return 0, ErrNoVectors
+ }
+ rows, err := s.db.QueryContext(ctx, `SELECT kind, content FROM entries`)
+ if err != nil {
+ return 0, err
+ }
+ live := map[string]bool{}
+ var todo []string
+ for rows.Next() {
+ var kind, content string
+ if err := rows.Scan(&kind, &content); err != nil {
+ _ = rows.Close()
+ return 0, err
+ }
+ key := string(embed.CacheKey(VectorModel, content))
+ if live[key] {
+ continue
+ }
+ live[key] = true
+ if IsDurable(kind) {
+ todo = append(todo, content)
+ }
+ }
+ _ = rows.Close()
+ n := 0
+ for _, text := range todo {
+ if cachedVector(ctx, s.db, text) != nil {
+ continue
+ }
+ v := src.Embed(strings.TrimSpace(text))
+ if v == nil {
+ continue
+ }
+ if err := putVector(ctx, s.db, text, v); err != nil {
+ return n, err
+ }
+ n++
+ }
+ // Garbage: cache keys of this model that no entry's text produces.
+ krows, err := s.db.QueryContext(ctx, `SELECT key FROM embed_cache WHERE model = ?`, VectorModel)
+ if err != nil {
+ return n, err
+ }
+ var dead [][]byte
+ for krows.Next() {
+ var k []byte
+ if err := krows.Scan(&k); err != nil {
+ _ = krows.Close()
+ return n, err
+ }
+ if len(k) != 32 || !live[string(k)] {
+ dead = append(dead, k)
+ }
+ }
+ _ = krows.Close()
+ for _, k := range dead {
+ if _, err := s.db.ExecContext(ctx, `DELETE FROM embed_cache WHERE key = ?`, k); err != nil {
+ return n, err
+ }
+ }
+ return n, nil
+}
+
+// ErrNoVectors says there is no model to embed with.
+var ErrNoVectors = errors.New("no embedding model on this machine")
+
+func isAlnum(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) }
+
+// DurableForCorpus is one kind's active memories with a log position, the
+// newest n, oldest first (the desktop's durable_entries for the corpus).
+func (s *Store) DurableForCorpus(ctx context.Context, kind string, n int) ([]*Entry, error) {
+ es, err := s.queryEntries(ctx, `WHERE kind = ? AND seq IS NOT NULL ORDER BY seq DESC LIMIT ?`, kind, n)
+ if err != nil {
+ return nil, err
+ }
+ var out []*Entry
+ for i := len(es) - 1; i >= 0; i-- {
+ if es[i].State == StateActive {
+ out = append(out, es[i])
+ }
+ }
+ return out, nil
+}
diff --git a/internal/memory/record/vectors_test.go b/internal/memory/record/vectors_test.go
new file mode 100644
index 0000000..8bd1b6f
--- /dev/null
+++ b/internal/memory/record/vectors_test.go
@@ -0,0 +1,162 @@
+package record
+
+import (
+ "context"
+ "hash/fnv"
+ "math"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/tryatlas/atlas-cli/internal/embed"
+)
+
+// bagOfWords is a deterministic stand-in for the model: a hashed bag of
+// lowercase words, unit length. Same words, same vector.
+type bagOfWords struct{}
+
+func (bagOfWords) Embed(text string) []float32 {
+ v := make([]float32, 64)
+ for _, w := range strings.Fields(strings.ToLower(text)) {
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(strings.Trim(w, ".,")))
+ v[h.Sum32()%64]++
+ }
+ var n float64
+ for _, x := range v {
+ n += float64(x * x)
+ }
+ if n == 0 {
+ return v
+ }
+ for i := range v {
+ v[i] = float32(float64(v[i]) / math.Sqrt(n))
+ }
+ return v
+}
+
+func withVectors(t *testing.T, src VectorSource) {
+ old := Vectors
+ Vectors = func() VectorSource { return src }
+ t.Cleanup(func() { Vectors = old })
+}
+
+func TestRememberCachesVectorsAndMergesNearDuplicates(t *testing.T) {
+ withVectors(t, bagOfWords{})
+ ctx := context.Background()
+ st, err := Open(t.TempDir(), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ at := time.Now().UnixMilli()
+ w := Write{Kind: "decision", Content: "Use Postgres for the ledger service", Source: "agent", Agent: "pi", Session: "s1", Confidence: 0.8, At: at}
+ if o, _, err := st.Remember(ctx, w); err != nil || o != Inserted {
+ t.Fatalf("%s %v", o, err)
+ }
+ if cachedVector(ctx, st.db, w.Content) == nil {
+ t.Fatal("a durable write caches its vector")
+ }
+ // The same words in another order: a near-duplicate, merged.
+ w2 := w
+ w2.Content, w2.Session, w2.At = "For the ledger service use Postgres", "s2", at+1
+ o, e, err := st.Remember(ctx, w2)
+ if err != nil || o != Merged || e.Uses != 1 {
+ t.Fatalf("near-duplicate: %s %+v %v", o, e, err)
+ }
+ // A contradicting text is stored, never merged.
+ w3 := w
+ w3.Content, w3.Session, w3.At = "Do not use Postgres for the ledger service", "s3", at+2
+ if o, _, _ := st.Remember(ctx, w3); o != Inserted {
+ t.Fatalf("contradiction merged: %s", o)
+ }
+ // Plans and file changes are not embedded.
+ if _, _, err := st.Remember(ctx, Write{Kind: "plan", Content: "Step one", Source: "agent", Agent: "pi", Session: "s1", At: at}); err != nil {
+ t.Fatal(err)
+ }
+ if cachedVector(ctx, st.db, "Step one") != nil {
+ t.Fatal("a plan has no vector")
+ }
+}
+
+func TestSearchFusesTheMeaningLeg(t *testing.T) {
+ withVectors(t, bagOfWords{})
+ ctx := context.Background()
+ st, err := Open(t.TempDir(), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ at := time.Now().UnixMilli()
+ for i, c := range []string{"Ledger writes go through Postgres", "The cache is Redis", "Deploys run on Fridays"} {
+ if _, _, err := st.Remember(ctx, Write{Kind: "fact", Content: c, Source: "agent", Agent: "pi", Session: "s", Confidence: 0.7, At: at + int64(i)}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ hits, err := st.Search(ctx, "ledger postgres", nil, 10)
+ if err != nil || len(hits) == 0 || hits[0].Entry.Content != "Ledger writes go through Postgres" || hits[0].BM25 != 1 || hits[0].Dense != 1 {
+ t.Fatalf("%+v %v", hits, err)
+ }
+ // Without a model: keyword only, same top hit, no dense rank.
+ withVectors(t, nil)
+ hits, _ = st.Search(ctx, "ledger postgres", nil, 10)
+ if len(hits) == 0 || hits[0].Dense != 0 {
+ t.Fatalf("%+v", hits)
+ }
+}
+
+func TestSyncVectorsBackfillsAndCollectsGarbage(t *testing.T) {
+ withVectors(t, nil)
+ ctx := context.Background()
+ st, _ := Open(t.TempDir(), true)
+ defer st.Close()
+ _, _, _ = st.Remember(ctx, Write{Kind: "fact", Content: "Builds use Go 1.26", Source: "agent", Agent: "pi", Session: "s", At: 1})
+ _ = putVector(ctx, st.db, "a text no entry has", []float32{1, 0})
+ withVectors(t, bagOfWords{})
+ n, err := st.SyncVectors(ctx)
+ if err != nil || n != 1 {
+ t.Fatalf("%d %v", n, err)
+ }
+ if cachedVector(ctx, st.db, "Builds use Go 1.26") == nil || cachedVector(ctx, st.db, "a text no entry has") != nil {
+ t.Fatal("backfill and garbage")
+ }
+}
+
+func TestContradictsText(t *testing.T) {
+ for _, c := range []struct {
+ a, b string
+ want bool
+ }{
+ {"Timeout is 30s", "Timeout is 60s", true},
+ {"We use JWT", "We don't use JWT", true},
+ {"We use JWT", "JWT is what we use", false},
+ {"Retry 3 times", "Retry 3 times.", false},
+ } {
+ if got := ContradictsText(c.a, c.b); got != c.want {
+ t.Errorf("%q vs %q = %v", c.a, c.b, got)
+ }
+ }
+}
+
+// TestRealModelFindsMeaning runs only where the app downloaded the model.
+func TestRealModelFindsMeaning(t *testing.T) {
+ e, err := embed.Load(filepath.Join(embed.AppDataDir(), "models", embed.ModelID))
+ if err != nil {
+ t.Skip("the model is not on this machine")
+ }
+ withVectors(t, e)
+ ctx := context.Background()
+ st, _ := Open(t.TempDir(), true)
+ defer st.Close()
+ at := time.Now().UnixMilli()
+ for i, c := range []string{"Signing keys for access tokens rotate every week", "The UI uses a monochrome palette", "CI runs golangci-lint before tests"} {
+ if _, _, err := st.Remember(ctx, Write{Kind: "decision", Content: c, Source: "agent", Agent: "pi", Session: "s", Confidence: 0.8, At: at + int64(i)}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ hits, err := st.Search(ctx, "how often do we change JWT secrets", nil, 3)
+ if err != nil || len(hits) == 0 || !strings.HasPrefix(hits[0].Entry.Content, "Signing keys") || hits[0].Dense != 1 {
+ t.Fatalf("%+v %v", hits, err)
+ }
+}
diff --git a/internal/memory/record/write.go b/internal/memory/record/write.go
index de68f61..6b673b5 100644
--- a/internal/memory/record/write.go
+++ b/internal/memory/record/write.go
@@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"strings"
+
+ "github.com/tryatlas/atlas-cli/internal/embed"
)
// Revision ops.
@@ -85,6 +87,12 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) {
return "", nil, errors.New("nothing to remember: content is empty")
}
hash := ContentHash(w.Content)
+ // A durable write is embedded before the transaction (as the app does,
+ // outside its lock); without a model it is stored all the same.
+ var vec []float32
+ if IsDurable(w.Kind) {
+ vec = vectorFor(ctx, s.db, strings.TrimSpace(w.Content))
+ }
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -106,6 +114,15 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) {
if err != nil {
return "", nil, err
}
+ nearDup := false
+ if cur == nil && vec != nil && !w.OnlyNew {
+ // No key or text match: a stored memory of the kind that says the
+ // same thing takes the write as a merge.
+ if cur, err = nearDuplicate(ctx, tx, w, vec); err != nil {
+ return "", nil, err
+ }
+ nearDup = cur != nil
+ }
var outcome string
var id int64
@@ -125,7 +142,7 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) {
case w.OnlyNew:
return "", cur, nil
- case cur.ContentHash == hash:
+ case cur.ContentHash == hash || nearDup:
if isRetry(cur, w) {
return Merged, cur, nil
}
@@ -166,6 +183,11 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) {
return "", nil, err
}
}
+ if vec != nil && (outcome == Inserted || outcome == Replaced) {
+ if err := putVector(ctx, tx, w.Content, vec); err != nil {
+ return "", nil, err
+ }
+ }
op := w.Op
if op == "" {
op = map[string]string{Inserted: OpInsert, Replaced: OpReplace, Merged: OpMerge}[outcome]
@@ -424,9 +446,49 @@ func (s *Store) Forget(ctx context.Context, id int64, session string, at int64)
if _, err := tx.ExecContext(ctx, `DELETE FROM entries WHERE id = ?`, e.ID); err != nil {
return false, err
}
+ // Its vectors go too, for every text it ever had (record.rs purge).
+ if err := dropVectors(ctx, tx, e); err != nil {
+ return false, err
+ }
return true, tx.Commit()
}
+// dropVectors deletes the cached vectors of an entry's current and earlier
+// texts, under every model the cache holds.
+func dropVectors(ctx context.Context, tx *sql.Tx, e *Entry) error {
+ texts := map[string]bool{e.Content: true}
+ rows, err := tx.QueryContext(ctx, `SELECT content FROM revisions WHERE entry_id = ?`, e.ID)
+ if err == nil {
+ for rows.Next() {
+ var c string
+ if rows.Scan(&c) == nil {
+ texts[c] = true
+ }
+ }
+ _ = rows.Close()
+ }
+ mrows, err := tx.QueryContext(ctx, `SELECT DISTINCT model FROM embed_cache`)
+ if err != nil {
+ return err
+ }
+ var models []string
+ for mrows.Next() {
+ var m string
+ if mrows.Scan(&m) == nil {
+ models = append(models, m)
+ }
+ }
+ _ = mrows.Close()
+ for _, m := range models {
+ for t := range texts {
+ if _, err := tx.ExecContext(ctx, `DELETE FROM embed_cache WHERE key = ?`, embed.CacheKey(m, t)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
// carryingEvents are the entry's own event and the events of its kind with
// its key (keyed) or its content hash (keyless).
func carryingEvents(ctx context.Context, tx *sql.Tx, e *Entry) ([]int64, error) {
diff --git a/internal/memory/server/server.go b/internal/memory/server/server.go
index d592460..b9ba4a1 100644
--- a/internal/memory/server/server.go
+++ b/internal/memory/server/server.go
@@ -87,7 +87,11 @@ func (s *Server) store(ctx context.Context, cwd string) (*record.Store, error) {
s.mu.Lock()
defer s.mu.Unlock()
if st, ok := s.stores[scope]; ok {
- return st, nil
+ if !st.Stale() {
+ return st, nil
+ }
+ _ = st.Close() // .atlas/ was deleted or re-created: open the new record
+ delete(s.stores, scope)
}
st, err := record.Open(scope, true)
if err != nil {
@@ -114,6 +118,7 @@ func (s *Server) SessionStarted(ctx context.Context, g Grant) {
}
if st, err := s.store(ctx, g.Cwd); err == nil {
_ = st.SessionStart(ctx, g.SessionID, g.Agent, s.now())
+ _, _ = st.SnapshotIfDue(s.now()) // the desktop's daily backup, at most once a day
}
}
@@ -146,7 +151,22 @@ func (s *Server) SessionEnded(ctx context.Context, g Grant) {
return
}
if st, err := s.store(ctx, g.Cwd); err == nil {
- _ = st.SessionEnd(ctx, g.SessionID, g.Agent, s.now())
+ now := s.now()
+ _ = st.SessionEnd(ctx, g.SessionID, g.Agent, now)
+ // The handoff note the next session's briefing shows, as the
+ // desktop's lifecycle writes it.
+ _ = st.WriteHandoff(ctx, g.SessionID, g.Agent, project.ScopeRoot(ctx, g.Cwd), now)
+ }
+}
+
+// Capture records the marked lines of an agent's reply as candidate
+// memories when sharing is on (the desktop's memory_delta).
+func (s *Server) Capture(ctx context.Context, g Grant, reply string) {
+ if g.SessionID == "" || strings.TrimSpace(reply) == "" || !sharingOn(ctx, g.Cwd) {
+ return
+ }
+ if st, err := s.store(ctx, g.Cwd); err == nil {
+ _, _ = st.CaptureCandidates(ctx, reply, g.Agent, g.SessionID, s.now())
}
}
diff --git a/internal/memory/server/state_main_test.go b/internal/memory/server/state_main_test.go
new file mode 100644
index 0000000..dd47a42
--- /dev/null
+++ b/internal/memory/server/state_main_test.go
@@ -0,0 +1,16 @@
+package server
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: keyword-only and deterministic
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/memory/server/tools.go b/internal/memory/server/tools.go
index 450ddf2..7494ece 100644
--- a/internal/memory/server/tools.go
+++ b/internal/memory/server/tools.go
@@ -4,12 +4,16 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"math"
"path/filepath"
"regexp"
"sort"
+ "strconv"
"strings"
+ "time"
+ "github.com/tryatlas/atlas-cli/internal/memory/corpus"
"github.com/tryatlas/atlas-cli/internal/memory/record"
"github.com/tryatlas/atlas-cli/internal/project"
)
@@ -361,12 +365,71 @@ func (t *call) search(args json.RawMessage) (any, error) {
entries := []any{}
for _, x := range list {
m := t.entryJSON(x.h.Entry, srcs[x.h.Entry.ID], x.ev)
+ why := map[string]any{}
if x.h.BM25 > 0 {
- m["why"] = map[string]any{"bm25": x.h.BM25}
+ why["bm25"] = x.h.BM25
+ }
+ if x.h.Dense > 0 {
+ why["dense"] = x.h.Dense
+ }
+ if len(why) > 0 {
+ m["why"] = why
}
entries = append(entries, m)
}
- return map[string]any{"entries": entries, "documents": []any{}}, nil
+ documents := []any{}
+ if len(a.Kinds) == 0 {
+ docLimit := indexDefault
+ if a.Limit != nil {
+ docLimit = min(max(*a.Limit, 1), indexMax)
+ }
+ for _, d := range t.documents(a.Query, docLimit) {
+ documents = append(documents, map[string]any{"title": d.Title, "source": d.Source, "text": d.Text})
+ }
+ }
+ return map[string]any{"entries": entries, "documents": documents}, nil
+}
+
+// How many indexed documents memory_search adds (tools.rs INDEX_*_LIMIT).
+const (
+ indexDefault = 6
+ indexMax = 20
+)
+
+// documents is the index leg of memory_search: the project's corpus, best
+// effort (no index, no model: BM25 only or nothing). A shared memory's
+// document is returned only while its entry still exists.
+func (t *call) documents(query string, limit int) []corpus.Result {
+ root := t.g.Cwd
+ if p, err := project.Discover(t.ctx, t.g.Cwd); err == nil {
+ root = p.Root
+ }
+ ix, err := corpus.Open(root, false)
+ if err != nil {
+ return nil
+ }
+ defer ix.Close()
+ var qvec []float32
+ if src := record.Vectors(); src != nil {
+ qvec = src.Embed(query) // untrimmed, as the app's retrieve
+ }
+ live := func(id string) bool {
+ rest, ok := strings.CutPrefix(id, "shared:")
+ if !ok {
+ return true
+ }
+ _, num, _ := strings.Cut(rest, ":")
+ n, err := strconv.ParseInt(num, 10, 64)
+ if err != nil {
+ return false
+ }
+ e, err := t.st.Get(t.ctx, n)
+ return err == nil && e != nil
+ }
+ ctx, cancel := context.WithTimeout(t.ctx, 6*time.Second)
+ defer cancel()
+ res, _ := ix.Retrieve(ctx, query, qvec, limit, live)
+ return res
}
// ── memory_get, memory_list, memory_history ──────────────────────────────────
@@ -663,9 +726,21 @@ func (t *call) forget(args json.RawMessage) (any, error) {
if err != nil {
return nil, err
}
+ before, _ := t.st.Get(t.ctx, id)
ok, err := t.st.Forget(t.ctx, id, t.g.SessionID, t.s.now())
if err != nil {
return nil, err
}
+ if ok && before != nil {
+ // Its document leaves the index now, not at the next pass.
+ root := t.g.Cwd
+ if p, err := project.Discover(t.ctx, t.g.Cwd); err == nil {
+ root = p.Root
+ }
+ if ix, err := corpus.Open(root, false); err == nil {
+ _ = ix.Delete(t.ctx, fmt.Sprintf("shared:%s:%d", before.Kind, id))
+ _ = ix.Close()
+ }
+ }
return map[string]any{"forgotten": ok, "id": id}, nil
}
diff --git a/internal/project/state_main_test.go b/internal/project/state_main_test.go
new file mode 100644
index 0000000..4ea28e6
--- /dev/null
+++ b/internal/project/state_main_test.go
@@ -0,0 +1,15 @@
+package project
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/redact/state_main_test.go b/internal/redact/state_main_test.go
new file mode 100644
index 0000000..9c2f42f
--- /dev/null
+++ b/internal/redact/state_main_test.go
@@ -0,0 +1,15 @@
+package redact
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/run/gone.go b/internal/run/gone.go
new file mode 100644
index 0000000..c5e2b50
--- /dev/null
+++ b/internal/run/gone.go
@@ -0,0 +1,21 @@
+package run
+
+import (
+ "os"
+ "syscall"
+)
+
+// deleted reports that an open file was unlinked: its link count is 0. A
+// re-created file can reuse the inode number (ext4 does), so comparing
+// inodes alone misses a replaced store; the open handle does not.
+func deleted(f *os.File) bool {
+ if f == nil {
+ return false
+ }
+ fi, err := f.Stat()
+ if err != nil {
+ return true
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ return ok && st.Nlink == 0
+}
diff --git a/internal/run/host.go b/internal/run/host.go
index 4454a1f..9eb7666 100644
--- a/internal/run/host.go
+++ b/internal/run/host.go
@@ -7,6 +7,7 @@ package run
import (
"context"
"errors"
+ "os"
"time"
gitlink "github.com/tryatlas/atlas-cli/internal/capture/git"
@@ -43,12 +44,33 @@ type Host struct {
lock *store.WriterLock
cancel context.CancelFunc
done chan struct{}
+ opened os.FileInfo // sessions.db as the host opened it; nil when there was none
+ held *os.File // a handle on that file: unlinked means .atlas/ went away
+}
+
+// Stale reports that the Project's store is no longer the one this host
+// opened: .atlas/ was deleted or re-created (atlas init again, or by hand),
+// or a store appeared where there was none. A stale host must stop; it
+// would otherwise keep writing into a deleted file.
+func (h *Host) Stale() bool {
+ now, err := os.Stat(store.Path(h.Root))
+ switch {
+ case h.opened == nil:
+ return err == nil
+ case err != nil || deleted(h.held):
+ return true
+ }
+ return !os.SameFile(h.opened, now)
}
// StartHost takes the writer lock, opens the capture store, and starts the
// listener and the drain.
func StartHost(root string, opt HostOptions) (*Host, error) {
h := &Host{Root: root, done: make(chan struct{})}
+ if fi, err := os.Stat(store.Path(root)); err == nil {
+ h.opened = fi
+ h.held, _ = os.Open(store.Path(root))
+ }
lock, err := store.Lock(root)
switch {
case errors.Is(err, store.ErrLocked) && opt.CaptureOptional:
@@ -94,6 +116,9 @@ func StartHost(root string, opt HostOptions) (*Host, error) {
func (h *Host) Capturing() bool { return h.Recorder != nil }
func (h *Host) release() {
+ if h.held != nil {
+ _ = h.held.Close()
+ }
if h.Store != nil {
_ = h.Store.Close()
}
diff --git a/internal/run/import.go b/internal/run/import.go
index 3898096..7f5dd4b 100644
--- a/internal/run/import.go
+++ b/internal/run/import.go
@@ -14,6 +14,7 @@ import (
"github.com/tryatlas/atlas-cli/internal/agents/acp"
"github.com/tryatlas/atlas-cli/internal/capture/record"
+ "github.com/tryatlas/atlas-cli/internal/clistate"
)
// Importing sessions run in an agent's own UI (ADR-0012): each installed
@@ -24,7 +25,7 @@ import (
// ImportStatePath is the CLI's own record of what was imported:
// {"": {"": ""}}.
-func ImportStatePath(root string) string { return filepath.Join(root, ".atlas", "cli-import.json") }
+func ImportStatePath(root string) string { return clistate.Path(root, "import.json") }
type importState map[string]map[string]string
diff --git a/internal/run/session_test.go b/internal/run/session_test.go
index b136c79..882dfeb 100644
--- a/internal/run/session_test.go
+++ b/internal/run/session_test.go
@@ -19,6 +19,8 @@ import (
)
func TestMain(m *testing.M) {
+ state, _ := os.MkdirTemp("", "atlas-state-") // never the user's CLI state
+ _ = os.Setenv("XDG_STATE_HOME", state)
acptest.Main()
os.Exit(m.Run())
}
diff --git a/internal/run/stale_test.go b/internal/run/stale_test.go
new file mode 100644
index 0000000..b00c646
--- /dev/null
+++ b/internal/run/stale_test.go
@@ -0,0 +1,37 @@
+package run
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/tryatlas/atlas-cli/internal/capture/store"
+)
+
+func TestHostStaleWhenTheStoreIsReplaced(t *testing.T) {
+ root := t.TempDir()
+ st, err := store.Open(root, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = st.Close()
+ fi, _ := os.Stat(store.Path(root))
+ held, _ := os.Open(store.Path(root))
+ defer held.Close()
+ h := &Host{Root: root, opened: fi, held: held}
+ if h.Stale() {
+ t.Fatal("same store")
+ }
+ _ = os.RemoveAll(filepath.Join(root, ".atlas"))
+ if !h.Stale() {
+ t.Fatal("deleted store")
+ }
+ st, _ = store.Open(root, true)
+ _ = st.Close()
+ if !h.Stale() {
+ t.Fatal("re-created store")
+ }
+ if !(&Host{Root: root}).Stale() {
+ t.Fatal("a store that appeared after start")
+ }
+}
diff --git a/internal/timefmt/state_main_test.go b/internal/timefmt/state_main_test.go
new file mode 100644
index 0000000..0eb7904
--- /dev/null
+++ b/internal/timefmt/state_main_test.go
@@ -0,0 +1,15 @@
+package timefmt
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/tui/card.go b/internal/tui/card.go
new file mode 100644
index 0000000..5fc955c
--- /dev/null
+++ b/internal/tui/card.go
@@ -0,0 +1,150 @@
+package tui
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/colorprofile"
+ "github.com/charmbracelet/x/term"
+)
+
+// Out wraps a terminal writer so colours are cut down to what the terminal
+// shows (256 or 16 colours, or none).
+func Out(w io.Writer) io.Writer { return colorprofile.NewWriter(w, os.Environ()) }
+
+// Header is a command's first line: the mark, the name, and where we are.
+func Header(w io.Writer, crumb ...string) {
+ line := Wordmark()
+ if len(crumb) > 0 {
+ line += " " + Crumb(crumb...)
+ }
+ fmt.Fprintln(w, line)
+}
+
+// Section starts a group of fields: an upper-case label, as the app's
+// eyebrows.
+func Section(w io.Writer, title string) {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, Upper(title))
+}
+
+// fieldWidth is the label column.
+const fieldWidth = 14
+
+// Field is one "label value" line of a section.
+func Field(w io.Writer, label, value string) {
+ fmt.Fprintln(w, " "+Default.Faint.Render(fmt.Sprintf("%-*s", fieldWidth, label))+" "+value)
+}
+
+// State is how a value reads at a glance.
+type State int
+
+// States.
+const (
+ Neutral State = iota
+ Good
+ Warn
+ Bad
+ Idle
+)
+
+// Dot is a status dot and the value: ● in the status colour.
+func Dot(s State, value string) string {
+ dot := map[State]lipgloss.Style{Good: Default.Success, Warn: Default.Warning, Bad: Default.Error, Idle: Default.Off}[s]
+ if s == Neutral {
+ return " " + Default.Option.Render(value)
+ }
+ return dot.Render("●") + " " + value
+}
+
+// Done prints "✓ msg" on a terminal, msg elsewhere.
+func Done(w io.Writer, msg string) {
+ if Interactive() {
+ fmt.Fprintln(w, Default.Success.Render("✓")+" "+Default.Bold.Render(msg))
+ return
+ }
+ fmt.Fprintln(w, msg)
+}
+
+// Note prints a secondary line: indented and faint on a terminal.
+func Note(w io.Writer, msg string) {
+ if Interactive() {
+ fmt.Fprintln(w, " "+Default.Faint.Render(msg))
+ return
+ }
+ fmt.Fprintln(w, msg)
+}
+
+// Table renders rows under upper-case headers with a rule between; the
+// first column is bold.
+func Table(headers []string, rows [][]string) string {
+ widths := make([]int, len(headers))
+ for i, h := range headers {
+ widths[i] = lipgloss.Width(h)
+ }
+ for _, r := range rows {
+ for i := range min(len(r), len(widths)) {
+ widths[i] = max(widths[i], lipgloss.Width(r[i]))
+ }
+ }
+ var b strings.Builder
+ cells := make([]string, len(headers))
+ total := 0
+ for i, h := range headers {
+ cells[i] = Upper(Fit(h, widths[i]))
+ total += widths[i] + 3
+ }
+ b.WriteString(" " + strings.Join(cells, " ") + "\n")
+ b.WriteString(" " + Rule(total-3) + "\n")
+ for _, r := range rows {
+ for i := range cells {
+ v := ""
+ if i < len(r) {
+ v = r[i]
+ }
+ if i == 0 {
+ v = Default.Bold.Render(v)
+ }
+ cells[i] = Fit(v, widths[i])
+ }
+ b.WriteString(" " + strings.TrimRight(strings.Join(cells, " "), " ") + "\n")
+ }
+ return b.String()
+}
+
+// Width is the terminal's width, 100 when it cannot be read.
+func Width() int {
+ if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 {
+ return w
+ }
+ return 100
+}
+
+// lineWriter restyles each complete line written through it.
+type lineWriter struct {
+ w io.Writer
+ fn func(string) string
+ part string
+}
+
+// Lines returns a writer that passes each line through fn before writing
+// it to w; plain output from shared code gets a screen's look this way.
+func Lines(w io.Writer, fn func(line string) string) io.Writer { return &lineWriter{w: w, fn: fn} }
+
+func (lw *lineWriter) Write(p []byte) (int, error) {
+ lw.part += string(p)
+ for {
+ i := strings.IndexByte(lw.part, '\n')
+ if i < 0 {
+ return len(p), nil
+ }
+ line := lw.part[:i]
+ lw.part = lw.part[i+1:]
+ if _, err := io.WriteString(lw.w, lw.fn(line)+"\n"); err != nil {
+ return 0, err
+ }
+ }
+}
diff --git a/internal/tui/chat/state_main_test.go b/internal/tui/chat/state_main_test.go
new file mode 100644
index 0000000..a1964b6
--- /dev/null
+++ b/internal/tui/chat/state_main_test.go
@@ -0,0 +1,15 @@
+package chat
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/tui/frame.go b/internal/tui/frame.go
new file mode 100644
index 0000000..ab29165
--- /dev/null
+++ b/internal/tui/frame.go
@@ -0,0 +1,166 @@
+package tui
+
+import (
+ "io"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// The frame every screen shares: a breadcrumb over a rule, a key line under
+// a rule, titled panels, tags, tree lines and a block progress bar.
+
+// Crumb is "atlas / settings / agents": the path faint, the last part bright.
+func Crumb(parts ...string) string {
+ if len(parts) == 0 {
+ return ""
+ }
+ out := make([]string, len(parts))
+ for i, p := range parts {
+ if i == len(parts)-1 {
+ out[i] = Default.CrumbHere.Render(p)
+ } else {
+ out[i] = Default.Crumb.Render(p)
+ }
+ }
+ return strings.Join(out, Default.Crumb.Render(" / "))
+}
+
+// Rule is a horizontal line w cells wide.
+func Rule(w int) string { return Default.Rule.Render(strings.Repeat("─", max(w, 1))) }
+
+// Keys renders key hints, "[ENTER] select · [ESC] back", from key, label pairs.
+func Keys(pairs ...string) string {
+ var out []string
+ for i := 0; i+1 < len(pairs); i += 2 {
+ out = append(out, Default.Key.Render("["+pairs[i]+"]")+" "+Default.KeyLabel.Render(pairs[i+1]))
+ }
+ return strings.Join(out, Default.KeyLabel.Render(" · "))
+}
+
+// Badge is a key shown inside a list, "<1>".
+func Badge(key string) string { return Default.KeyBadge.Render("<" + key + ">") }
+
+// Tag is a short label on a coloured ground, " ON ".
+func Tag(text string, style lipgloss.Style) string { return style.Render(" " + text + " ") }
+
+// Bar is a block progress bar of width cells, done of total full.
+func Bar(done, total, width int) string {
+ if total <= 0 || width <= 0 {
+ return ""
+ }
+ full := min(width*done/total, width)
+ return Default.BarFull.Render(strings.Repeat("▮", full)) + Default.BarEmpty.Render(strings.Repeat("▯", width-full))
+}
+
+// TreeLine is one answered step: "├ Project atlas-cli", or └ for
+// the last one.
+func TreeLine(last bool, label, answer string) string {
+ glyph := "├"
+ if last {
+ glyph = "└"
+ }
+ return Default.Tree.Render(glyph) + " " + lipgloss.NewStyle().Width(16).Render(label) + " " + answer
+}
+
+// Upper is a small heading: "STEP 2 OF 4".
+func Upper(s string) string { return Default.Label.Render(strings.ToUpper(s)) }
+
+// Fit cuts a styled line to w cells and pads it to exactly w.
+func Fit(s string, w int) string {
+ if w <= 0 {
+ return ""
+ }
+ if lipgloss.Width(s) > w {
+ s = ansi.Truncate(s, w, "…")
+ }
+ return s + strings.Repeat(" ", max(w-lipgloss.Width(s), 0))
+}
+
+// Panel draws body in a rounded box w×h cells (borders included) with the
+// title set into the top edge, "╭─ Agents ──╮". A focused panel is
+// coloured.
+func Panel(title, body string, w, h int, focused bool) string {
+ if w < 4 || h < 2 {
+ return ""
+ }
+ edge, ts := Default.PanelEdge, Default.PanelTitle
+ if focused {
+ edge, ts = Default.Panel, Default.PanelTitleFocused
+ }
+ inner := w - 2
+ top := edge.Render("╭" + strings.Repeat("─", inner) + "╮")
+ if title != "" {
+ t := ansi.Truncate(title, max(inner-4, 1), "…")
+ top = edge.Render("╭─ ") + ts.Render(t) + edge.Render(" "+strings.Repeat("─", max(inner-3-lipgloss.Width(t), 0))+"╮")
+ }
+ var b strings.Builder
+ b.WriteString(top + "\n")
+ lines := strings.Split(strings.TrimRight(body, "\n"), "\n")
+ for i := range h - 2 {
+ line := ""
+ if i < len(lines) {
+ line = lines[i]
+ }
+ b.WriteString(edge.Render("│") + " " + Fit(line, inner-2) + " " + edge.Render("│") + "\n")
+ }
+ b.WriteString(edge.Render("╰" + strings.Repeat("─", inner) + "╯"))
+ return b.String()
+}
+
+// Wrap breaks plain text into lines of at most w cells.
+func Wrap(s string, w int) []string {
+ if w < 8 {
+ return []string{s}
+ }
+ var out []string
+ for _, para := range strings.Split(s, "\n") {
+ out = append(out, strings.Split(ansi.Wordwrap(para, w, ""), "\n")...)
+ }
+ return out
+}
+
+// indentWidth is where Indent wraps a line.
+const indentWidth = 76
+
+// indentWriter prefixes each line it writes.
+type indentWriter struct {
+ w io.Writer
+ prefix string
+ mid bool
+}
+
+// Indent returns a writer that prefixes every line with the tree's stem,
+// "│ ", so output printed between steps stays inside the tree.
+func Indent(w io.Writer) io.Writer {
+ return &indentWriter{w: w, prefix: Default.Tree.Render("│") + " "}
+}
+
+func (iw *indentWriter) Write(p []byte) (int, error) {
+ var b strings.Builder
+ parts := strings.SplitAfter(string(p), "\n")
+ for _, part := range parts {
+ if part == "" {
+ continue
+ }
+ if !iw.mid {
+ b.WriteString(iw.prefix)
+ }
+ text, nl := strings.CutSuffix(part, "\n")
+ for i, l := range Wrap(text, indentWidth) {
+ if i > 0 {
+ b.WriteString("\n" + iw.prefix + " ") // a wrapped line stays on the stem
+ }
+ b.WriteString(Default.Faint.Render(l))
+ }
+ if nl {
+ b.WriteString("\n")
+ }
+ iw.mid = !nl
+ }
+ if _, err := io.WriteString(iw.w, b.String()); err != nil {
+ return 0, err
+ }
+ return len(p), nil
+}
diff --git a/internal/tui/live.go b/internal/tui/live.go
new file mode 100644
index 0000000..1405558
--- /dev/null
+++ b/internal/tui/live.go
@@ -0,0 +1,110 @@
+package tui
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// LiveEvent is one line of a live screen's feed.
+type LiveEvent struct {
+ At time.Time
+ Who, What string
+ Bad bool
+}
+
+type liveStop struct{}
+
+type liveModel struct {
+ title []string
+ fields [][2]string
+ events []LiveEvent
+ count int
+ frame int
+ start time.Time
+ height int
+}
+
+func (m liveModel) Init() tea.Cmd { return workTicker() }
+
+func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.height = msg.Height
+ case workTick:
+ m.frame++
+ return m, workTicker()
+ case LiveEvent:
+ m.count++
+ m.events = append(m.events, msg)
+ if len(m.events) > 200 {
+ m.events = m.events[len(m.events)-200:]
+ }
+ case liveStop:
+ return m, tea.Quit
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "ctrl+c", "q", "esc":
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+}
+
+func (m liveModel) View() tea.View {
+ var b strings.Builder
+ b.WriteString(Header2(m.title...) + "\n")
+ for _, f := range m.fields {
+ b.WriteString(" " + Default.Faint.Render(fmt.Sprintf("%-*s", fieldWidth, f[0])) + " " + f[1] + "\n")
+ }
+ up := time.Since(m.start).Round(time.Second)
+ b.WriteString("\n" + pulse(m.frame) + " " + Default.Bold.Render("Listening") + " " +
+ Default.Faint.Render(fmt.Sprintf("%d event(s) · up %s", m.count, up)) + "\n\n")
+ b.WriteString(Upper("activity") + "\n")
+ room := 8
+ if m.height > 0 {
+ room = max(m.height-len(m.fields)-10, 3)
+ }
+ ev := m.events
+ if len(ev) > room {
+ ev = ev[len(ev)-room:]
+ }
+ if len(ev) == 0 {
+ b.WriteString(" " + Default.Faint.Render("waiting for your agents' hooks…") + "\n")
+ }
+ w := Width()
+ for _, e := range ev {
+ dot := Default.Success.Render("●")
+ if e.Bad {
+ dot = Default.Error.Render("●")
+ }
+ line := fmt.Sprintf(" %s %s %s %s", Default.Faint.Render(e.At.Format("15:04:05")), dot,
+ Default.Bold.Render(fmt.Sprintf("%-12s", e.Who)), e.What)
+ b.WriteString(Fit(line, w-1) + "\n")
+ }
+ b.WriteString("\n" + Keys("Q", "stop") + "\n")
+ return tea.NewView(b.String())
+}
+
+// Live is a running live screen.
+type Live struct{ p *tea.Program }
+
+// StartLive shows a live screen: a header, fields, a pulsing "Listening"
+// line and a feed of events. Wait returns when the user quits (q, esc,
+// ctrl+c) or Stop is called.
+func StartLive(title []string, fields [][2]string) *Live {
+ p := tea.NewProgram(liveModel{title: title, fields: fields, start: time.Now()}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin))
+ return &Live{p: p}
+}
+
+// Run blocks until the screen ends.
+func (l *Live) Run() error { _, err := l.p.Run(); return err }
+
+// Event adds a line to the feed.
+func (l *Live) Event(e LiveEvent) { l.p.Send(e) }
+
+// Stop ends the screen.
+func (l *Live) Stop() { l.p.Send(liveStop{}) }
diff --git a/internal/tui/login.go b/internal/tui/login.go
new file mode 100644
index 0000000..07f39df
--- /dev/null
+++ b/internal/tui/login.go
@@ -0,0 +1,116 @@
+package tui
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+)
+
+// DevicePrompt is what the user needs to approve a device sign-in.
+type DevicePrompt struct {
+ URL, Code string
+ Expires time.Time
+ Opened bool // the browser was opened
+}
+
+type (
+ loginPrompt DevicePrompt
+ loginDone struct {
+ who string
+ err error
+ }
+)
+
+type loginModel struct {
+ prompt *DevicePrompt
+ frame int
+ done bool
+ who string
+ err error
+}
+
+func (m loginModel) Init() tea.Cmd { return workTicker() }
+
+func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case workTick:
+ if m.done {
+ return m, nil
+ }
+ m.frame++
+ return m, workTicker()
+ case loginPrompt:
+ p := DevicePrompt(msg)
+ m.prompt = &p
+ case loginDone:
+ m.done, m.who, m.err = true, msg.who, msg.err
+ return m, tea.Quit
+ case tea.KeyPressMsg:
+ if msg.String() == "ctrl+c" || msg.String() == "esc" {
+ m.done, m.err = true, ErrCancelled
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+}
+
+// spaced writes a code with room between its characters: "W D J B - M J H T".
+func spaced(code string) string { return strings.Join(strings.Split(code, ""), " ") }
+
+func (m loginModel) View() tea.View {
+ var b strings.Builder
+ if m.done {
+ switch {
+ case errors.Is(m.err, ErrCancelled):
+ b.WriteString(Default.Error.Render("✗") + " " + Default.Bold.Render("Sign-in cancelled") + "\n")
+ case m.err != nil:
+ b.WriteString(Default.Error.Render("✗") + " " + Default.Bold.Render("Sign-in failed") + " " + Default.Error.Render(m.err.Error()) + "\n")
+ default:
+ b.WriteString(Default.Success.Render("✓") + " " + Default.Bold.Render("Signed in as "+m.who) + "\n")
+ }
+ return tea.NewView(b.String())
+ }
+ b.WriteString(Header2("sign in") + "\n\n")
+ if m.prompt == nil {
+ b.WriteString(pulse(m.frame) + " " + Default.Faint.Render("Asking Atlas for a sign-in code…") + "\n")
+ return tea.NewView(b.String())
+ }
+ code := Default.Logo.Render(spaced(m.prompt.Code))
+ box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(Default.Rule.GetForeground()).Padding(1, 4).Render(code)
+ b.WriteString(Upper("your code") + "\n" + box + "\n\n")
+ b.WriteString(Upper("open") + "\n" + Default.Info.Render(m.prompt.URL) + "\n")
+ if m.prompt.Opened {
+ b.WriteString(Default.Faint.Render("opened in your browser") + "\n")
+ }
+ left := time.Until(m.prompt.Expires).Round(time.Second)
+ b.WriteString("\n" + pulse(m.frame) + " " + Default.Bold.Render("Waiting for approval") + " " +
+ Default.Faint.Render(fmt.Sprintf("code expires in %s", max(left, 0))) + "\n\n")
+ b.WriteString(Keys("ESC", "cancel") + "\n")
+ return tea.NewView(b.String())
+}
+
+// Header2 is a screen's first line as a string.
+func Header2(crumb ...string) string { return Wordmark() + " " + Crumb(crumb...) }
+
+// DeviceLogin runs a device sign-in on screen: run calls prompt once it has
+// the code, then blocks until the user approves; it returns who signed in.
+func DeviceLogin(run func(prompt func(DevicePrompt)) (string, error)) error {
+ p := tea.NewProgram(loginModel{}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin))
+ go func() {
+ who, err := run(func(dp DevicePrompt) { p.Send(loginPrompt(dp)) })
+ p.Send(loginDone{who, err})
+ }()
+ final, err := p.Run()
+ if err != nil {
+ return err
+ }
+ if m := final.(loginModel); m.err != nil {
+ return ShownError{m.err}
+ }
+ return nil
+}
diff --git a/internal/tui/logo.go b/internal/tui/logo.go
new file mode 100644
index 0000000..b4ff4bf
--- /dev/null
+++ b/internal/tui/logo.go
@@ -0,0 +1,80 @@
+package tui
+
+import (
+ "strings"
+)
+
+// The Atlas mark is "///" set in Array Bold (Fontshare), as in the desktop
+// app's src/assets/atlas-icon.svg. Array's "/" is a stripe of round dots 4
+// wide and 14 tall, moving one column right every two rows; the logo sets
+// three of them 3 dot columns apart. Here every dot column is kept and two
+// dot rows share a text row (a terminal cell is about twice as tall as it
+// is wide), which keeps the glyph's slope and the logo's proportions. Drawn
+// in the logo's cream; animations only switch dots between the cream and a
+// dim grey, never through a gradient.
+
+const (
+ glyphRows = 14 // dot rows of Array Bold's "/"
+ glyphWidth = 4 // dots across one stripe
+ glyphGap = 3 // dot columns between stripes in the logo
+ logoCols = 3*glyphWidth + 2*glyphGap + glyphRows/2 - 1
+)
+
+// Logo is the mark, 7 rows by 24 columns of dots. Stripes light one by one,
+// each from the bottom up, as t goes from 0 to 1 (1: all lit).
+func Logo(t float64) string {
+ const rows = glyphRows / 2
+ var b strings.Builder
+ for r := range rows {
+ off := rows - 1 - r // one column right per text row (two dot rows)
+ line := []rune(strings.Repeat(" ", logoCols))
+ lit := make([]bool, logoCols)
+ for s := range 3 {
+ x := off + s*(glyphWidth+glyphGap)
+ on := (float64(s)+float64(rows-1-r)/float64(rows))/3 < t
+ for i := range glyphWidth {
+ line[x+i] = '●'
+ lit[x+i] = on
+ }
+ }
+ for i, c := range line {
+ switch {
+ case c == ' ':
+ b.WriteByte(' ')
+ case lit[i]:
+ b.WriteString(Default.Logo.Render("●"))
+ default:
+ b.WriteString(Default.LogoDim.Render("·"))
+ }
+ }
+ b.WriteByte('\n')
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
+
+// stripe is one stripe of the working pulse: two block quadrants that read
+// as a slanted bar in any font.
+const stripe = "▟▛"
+
+// Wordmark is the name that heads every screen; the logo itself is kept
+// for the splash and the init intro, where it has room.
+func Wordmark() string { return Default.Bold.Render("Atlas") }
+
+// pulse is a frame of the working mark: one stripe lit, the one behind it
+// half lit, the rest dim. Monochrome: cream, grey, dark grey.
+func pulse(frame int) string {
+ const n = 5 // three stripes, then two dark beats
+ lead := frame % n
+ parts := make([]string, 3)
+ for s := range 3 {
+ switch lead - s {
+ case 0:
+ parts[s] = Default.Logo.Render(stripe)
+ case 1:
+ parts[s] = Default.Secondary.Render(stripe)
+ default:
+ parts[s] = Default.LogoDim.Render(stripe)
+ }
+ }
+ return strings.Join(parts, " ")
+}
diff --git a/internal/tui/logo_test.go b/internal/tui/logo_test.go
new file mode 100644
index 0000000..b7847b9
--- /dev/null
+++ b/internal/tui/logo_test.go
@@ -0,0 +1,70 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+func TestLogoShapes(t *testing.T) {
+ if s := ansiRE.ReplaceAllString(Logo(0), ""); strings.Contains(s, "●") {
+ t.Fatal("nothing lit at 0")
+ }
+ // Array Bold's "/" three times: 4 dots wide, 3 apart, one column right
+ // per row, 7 rows of two dot rows each.
+ logo := strings.Split(ansiRE.ReplaceAllString(Logo(1), ""), "\n")
+ if len(logo) != 7 || logo[0] != " ●●●● ●●●● ●●●●" || logo[6] != "●●●● ●●●● ●●●● " {
+ t.Fatalf("%q", logo)
+ }
+ if got := ansiRE.ReplaceAllString(pulse(0), ""); got != "▟▛ ▟▛ ▟▛" {
+ t.Fatal(got)
+ }
+}
+
+func TestLightDetection(t *testing.T) {
+ for _, c := range []struct {
+ theme, fgbg string
+ want bool
+ }{{"", "", false}, {"light", "", true}, {"dark", "0;15", false}, {"", "0;15", true}, {"", "15;0", false}, {"", "0;7", true}} {
+ if got := light(c.theme, c.fgbg); got != c.want {
+ t.Errorf("light(%q, %q) = %v", c.theme, c.fgbg, got)
+ }
+ }
+}
+
+func TestLoginScreen(t *testing.T) {
+ var m tea.Model = loginModel{}
+ if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "Asking Atlas for a sign-in code") {
+ t.Fatal(s)
+ }
+ m, _ = m.Update(loginPrompt{URL: "https://auth.example/device", Code: "WDJB-MJHT", Expires: time.Now().Add(10 * time.Minute)})
+ s := ansiRE.ReplaceAllString(m.View().Content, "")
+ for _, want := range []string{"Atlas", "sign in", "YOUR CODE", "W D J B - M J H T", "https://auth.example/device", "Waiting for approval", "code expires in"} {
+ if !strings.Contains(s, want) {
+ t.Fatalf("missing %q:\n%s", want, s)
+ }
+ }
+ m, _ = m.Update(loginDone{who: "Ada "})
+ if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "✓ Signed in as Ada ") {
+ t.Fatal(s)
+ }
+}
+
+func TestWorkScreen(t *testing.T) {
+ var m tea.Model = workModel{title: "Syncing", start: time.Now()}
+ m, _ = m.Update(workStep("10 row(s) sent"))
+ s := ansiRE.ReplaceAllString(m.View().Content, "")
+ if !strings.Contains(s, "Syncing") || !strings.Contains(s, "└ 10 row(s) sent") || !strings.Contains(s, "▮") || !strings.Contains(s, "▯") {
+ t.Fatal(s)
+ }
+ m, _ = m.Update(workDone{msg: "Sent 10 row(s)."})
+ if s := ansiRE.ReplaceAllString(m.View().Content, ""); strings.Contains(s, "▯") {
+ t.Fatalf("the bar must show full before the result:\n%s", s)
+ }
+ m, _ = m.Update(workShown{})
+ if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "├ 10 row(s) sent") || !strings.Contains(s, "✓ Sent 10 row(s).") {
+ t.Fatal(s)
+ }
+}
diff --git a/internal/tui/memview/memview.go b/internal/tui/memview/memview.go
index dae4f31..d3aa4b3 100644
--- a/internal/tui/memview/memview.go
+++ b/internal/tui/memview/memview.go
@@ -161,57 +161,72 @@ func clip(s string, w int) string {
return string(r[:w-1]) + "…"
}
-// View draws the list and the selected entry side by side.
-func (m *Model) View() tea.View {
- if m.width == 0 {
- return tea.NewView("")
- }
- var tabs []string
+// tabs are the kinds, the current one on the highlight bar.
+func (m *Model) tabs() string {
+ var out []string
for i, k := range Kinds {
if i == m.kind {
- tabs = append(tabs, th.Selected.Render("["+label(k)+"]"))
+ out = append(out, th.Highlight.Render(" "+label(k)+" "))
} else {
- tabs = append(tabs, th.Faint.Render(label(k)))
+ out = append(out, th.Faint.Render(" "+label(k)+" "))
}
}
- head := th.Title.Render("Shared memory · "+m.title) + "\n" + strings.Join(tabs, " ")
+ return strings.Join(out, " ")
+}
+
+// View draws a breadcrumb and the kind tabs, the entries and the selected
+// entry in two panels, and the keys.
+func (m *Model) View() tea.View {
+ if m.width == 0 {
+ return tea.NewView("")
+ }
+ W := m.width
+ left := tui.Header2("memory", label(Kinds[m.kind]))
+ right := th.Faint.Render(clip(m.title, W/2))
+ head := left + strings.Repeat(" ", max(W-lipgloss.Width(left)-lipgloss.Width(right), 1)) + right
+ head += "\n" + tui.Rule(W) + "\n" + m.tabs()
if m.searching || m.query != "" {
- head += "\n" + th.BarKey.Render("search ") + m.query
+ q := m.query
if m.searching {
- head += "▏"
+ q += "▏"
}
+ head += " " + tui.Upper("search") + " " + q
}
- listW := m.width * 2 / 5
- bodyH := m.height - lipgloss.Height(head) - 2
+ bodyH := max(m.height-lipgloss.Height(head)-2, 6)
+ listW := W * 2 / 5
+ rows := bodyH - 2
if m.cursor < m.top {
m.top = m.cursor
}
- if m.cursor >= m.top+bodyH {
- m.top = m.cursor - bodyH + 1
+ if m.cursor >= m.top+rows {
+ m.top = m.cursor - rows + 1
}
var list []string
- for i := m.top; i < len(m.entries) && i < m.top+bodyH; i++ {
+ for i := m.top; i < len(m.entries) && i < m.top+rows; i++ {
e := m.entries[i]
- line := fmt.Sprintf("%-12s %s", label(e.Kind), clip(e.Content, listW-16))
+ kind := th.Accent.Render(fmt.Sprintf("%-12s", label(e.Kind)))
+ text := clip(e.Content, listW-20)
if e.State == record.StateCandidate {
- line = th.Faint.Render(line)
+ text = th.Faint.Render(text)
}
if i == m.cursor {
- list = append(list, th.Selected.Render("› "+line))
+ list = append(list, th.Tree.Render("▸")+kind+th.Highlight.Render(" "+text+" "))
} else {
- list = append(list, " "+line)
+ list = append(list, " "+kind+" "+text)
}
}
if len(m.entries) == 0 {
- list = append(list, th.Faint.Render(" nothing here"))
+ list = append(list, th.Faint.Render("nothing here"))
}
- left := lipgloss.NewStyle().Width(listW).Height(bodyH).Render(strings.Join(list, "\n"))
- right := lipgloss.NewStyle().Width(m.width - listW - 2).Height(bodyH).PaddingLeft(2).Render(m.detail(m.width - listW - 4))
- foot := th.Hint.Render("↑/↓ move · tab kind · / search · h history · q quit")
+ count := fmt.Sprintf("Entries · %d", len(m.entries))
+ panels := lipgloss.JoinHorizontal(lipgloss.Top,
+ tui.Panel(count, strings.Join(list, "\n"), listW, bodyH, true), " ",
+ tui.Panel("Entry", m.detail(W-listW-5), W-listW-1, bodyH, false))
+ foot := tui.Keys("↑↓", "navigate", "TAB", "kind", "/", "search", "H", "history", "Q", "quit")
if m.err != nil {
- foot = th.Error.Render(m.err.Error())
+ foot = th.Error.Render("✗ " + m.err.Error())
}
- v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, head, lipgloss.JoinHorizontal(lipgloss.Top, left, right), foot))
+ v := tea.NewView(head + "\n" + panels + "\n" + tui.Rule(W) + "\n" + foot)
v.AltScreen = true
return v
}
@@ -223,7 +238,7 @@ func (m *Model) detail(w int) string {
}
wrap := lipgloss.NewStyle().Width(w)
var b strings.Builder
- b.WriteString(th.Bold.Render(label(e.Kind)) + " " + th.Faint.Render(fmt.Sprintf("#%d · rev %d · %s", e.ID, e.Rev, strings.TrimSpace(e.State+" "+e.Status))) + "\n\n")
+ b.WriteString(tui.Tag(strings.ToUpper(label(e.Kind)), th.TagRun) + " " + th.Faint.Render(fmt.Sprintf("#%d · rev %d · %s", e.ID, e.Rev, strings.TrimSpace(e.State+" "+e.Status))) + "\n\n")
b.WriteString(wrap.Render(e.Content) + "\n\n")
meta := [][2]string{
{"by", e.By()}, {"session", e.Session}, {"key", e.Key}, {"confidence", fmt.Sprintf("%.2f", e.Confidence)},
@@ -231,7 +246,7 @@ func (m *Model) detail(w int) string {
}
for _, kv := range meta {
if kv[1] != "" {
- b.WriteString(th.BarKey.Render(fmt.Sprintf("%-11s", kv[0])) + kv[1] + "\n")
+ b.WriteString(tui.Upper(fmt.Sprintf("%-11s", kv[0])) + kv[1] + "\n")
}
}
for _, c := range e.Evidence {
@@ -239,10 +254,10 @@ func (m *Model) detail(w int) string {
if c.Symbol != nil {
ev += " " + *c.Symbol
}
- b.WriteString(th.Location.Render("evidence "+ev) + "\n")
+ b.WriteString(tui.Upper("evidence ") + th.Location.Render(ev) + "\n")
}
if m.showHist {
- b.WriteString("\n" + th.Bold.Render("History") + "\n")
+ b.WriteString("\n" + tui.Upper("history") + "\n")
for _, r := range m.history {
b.WriteString(th.Faint.Render(fmt.Sprintf("rev %d · %s · %s · %s", r.Rev, r.Op, r.By(), when(r.At))) + "\n")
b.WriteString(wrap.Render(" "+clip(r.Content, w*3)) + "\n")
diff --git a/internal/tui/memview/memview_test.go b/internal/tui/memview/memview_test.go
index c5e9e05..e156558 100644
--- a/internal/tui/memview/memview_test.go
+++ b/internal/tui/memview/memview_test.go
@@ -51,13 +51,13 @@ func TestBrowser(t *testing.T) {
m := New(context.Background(), f, "/repo")
_, _ = m.Update(tea.WindowSizeMsg{Width: 140, Height: 30})
s := ansi.ReplaceAllString(m.View().Content, "")
- for _, want := range []string{"Shared memory · /repo", "[all]", "decision Use Postgres", "db/ledger.go:3-9", "claude-acp"} {
+ for _, want := range []string{"Atlas memory / all", "/repo", "Entries · 2", "decision Use Postgres", "db/ledger.go:3-9", "claude-acp", "[TAB] kind"} {
if !strings.Contains(s, want) {
t.Errorf("missing %q\n%s", want, s)
}
}
key(m, "h", 'h')
- if s := ansi.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "rev 1 · insert · codex-acp") || !strings.Contains(s, "Use MySQL") {
+ if s := ansi.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "HISTORY") || !strings.Contains(s, "rev 1 · insert · codex-acp") || !strings.Contains(s, "Use MySQL") {
t.Fatal(s)
}
_, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyTab})
diff --git a/internal/tui/memview/state_main_test.go b/internal/tui/memview/state_main_test.go
new file mode 100644
index 0000000..688e40b
--- /dev/null
+++ b/internal/tui/memview/state_main_test.go
@@ -0,0 +1,15 @@
+package memview
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/tui/settings/settings.go b/internal/tui/settings/settings.go
new file mode 100644
index 0000000..4861f2e
--- /dev/null
+++ b/internal/tui/settings/settings.go
@@ -0,0 +1,466 @@
+// Package settings is the settings screen (atlas settings): account,
+// Project gates, agent integrations and background sync, in one place. The
+// screen only draws and routes keys; a Backend reads and changes the state.
+package settings
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/tryatlas/atlas-cli/internal/tui"
+)
+
+var th = tui.Default
+
+// Kind is how a row behaves.
+type Kind int
+
+// Row kinds.
+const (
+ Info Kind = iota // a value to read
+ Toggle // on or off; space flips it
+ Action // enter runs it
+)
+
+// Item is one row of a section.
+type Item struct {
+ Key string // what Apply receives
+ Label string
+ Kind Kind
+ On bool // Toggle: the current state
+ Value string // Info: the value; Toggle and Action: a short note after the state
+ Detail string // shown under the list when the row is selected
+ // Disabled says why the row cannot change now; empty when it can.
+ Disabled string
+ // Confirm, on an action, is asked first: the action runs on a second
+ // enter.
+ Confirm string
+}
+
+// Section is one entry of the sidebar.
+type Section struct {
+ Title string
+ Icon string // one cell, before the title
+ Items []Item
+}
+
+// Backend reads and changes the settings.
+type Backend interface {
+ Load(ctx context.Context) ([]Section, error)
+ // Apply flips a toggle to on, or runs an action (on is then true). The
+ // message is shown in the status line.
+ Apply(ctx context.Context, key string, on bool) (string, error)
+}
+
+type loadedMsg struct {
+ sections []Section
+ err error
+}
+
+type appliedMsg struct {
+ msg string
+ err error
+}
+
+// Progresser is a Backend whose long changes report how far they are (a
+// model download); the row shows a bar while one runs.
+type Progresser interface {
+ ApplyProgress(ctx context.Context, key string, on bool, report func(frac float64, note string)) (string, error)
+}
+
+type progressMsg struct {
+ frac float64
+ note string
+ ch chan tea.Msg
+}
+
+// listen waits for the next message of a running change.
+func listen(ch chan tea.Msg) tea.Cmd {
+ return func() tea.Msg { return <-ch }
+}
+
+type tickMsg struct{}
+
+var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
+
+// Model is the screen.
+type Model struct {
+ ctx context.Context
+ be Backend
+ title string
+ sections []Section
+ sec int
+ row int
+ focus int // 0: sidebar, 1: rows
+ busy string
+ confirm string // the action key waiting for its second enter
+ frame int
+ prog float64 // the running change's progress, -1 when unknown
+ progNote string
+ status string
+ err error
+ width int
+ height int
+}
+
+// New opens the screen; title names the Project (or the folder).
+func New(ctx context.Context, be Backend, title string) *Model {
+ m := &Model{ctx: ctx, be: be, title: title, focus: 1}
+ m.sections, m.err = be.Load(ctx)
+ return m
+}
+
+// Init does nothing.
+func (m *Model) Init() tea.Cmd { return nil }
+
+func (m *Model) items() []Item {
+ if m.sec < len(m.sections) {
+ return m.sections[m.sec].Items
+ }
+ return nil
+}
+
+func (m *Model) current() *Item {
+ if it := m.items(); m.row < len(it) {
+ return &it[m.row]
+ }
+ return nil
+}
+
+func (m *Model) reload() tea.Cmd {
+ return func() tea.Msg {
+ s, err := m.be.Load(m.ctx)
+ return loadedMsg{s, err}
+ }
+}
+
+func tick() tea.Cmd {
+ return tea.Tick(90*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} })
+}
+
+// apply runs the change off the UI loop: starting a listener takes seconds.
+func (m *Model) apply(it *Item, on bool) tea.Cmd {
+ if it.Disabled != "" {
+ m.status, m.err = it.Disabled, nil
+ return nil
+ }
+ key := it.Key
+ m.busy, m.status, m.err = it.Label, "", nil
+ m.prog, m.progNote = -1, ""
+ if pb, ok := m.be.(Progresser); ok {
+ ch := make(chan tea.Msg, 64)
+ go func() {
+ msg, err := pb.ApplyProgress(m.ctx, key, on, func(f float64, note string) {
+ select {
+ case ch <- progressMsg{frac: f, note: note, ch: ch}:
+ default: // the screen is behind: drop a frame, not the download
+ }
+ })
+ ch <- appliedMsg{msg, err}
+ }()
+ return tea.Batch(tick(), listen(ch))
+ }
+ return tea.Batch(tick(), func() tea.Msg {
+ msg, err := m.be.Apply(m.ctx, key, on)
+ return appliedMsg{msg, err}
+ })
+}
+
+// Update handles keys and results.
+func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width, m.height = max(msg.Width, 64), max(msg.Height, 16)
+ case tickMsg:
+ if m.busy != "" {
+ m.frame++
+ return m, tick()
+ }
+ case progressMsg:
+ if msg.frac >= 0 {
+ m.prog = msg.frac
+ }
+ if msg.note != "" || msg.frac >= 0 {
+ m.progNote = msg.note
+ }
+ return m, listen(msg.ch)
+ case appliedMsg:
+ m.busy, m.status, m.err = "", msg.msg, msg.err
+ return m, m.reload()
+ case loadedMsg:
+ if msg.err != nil {
+ m.err = msg.err
+ } else {
+ m.sections = msg.sections
+ }
+ m.sec = min(m.sec, max(len(m.sections)-1, 0))
+ m.row = min(m.row, max(len(m.items())-1, 0))
+ case tea.KeyPressMsg:
+ return m.key(msg.String())
+ }
+ return m, nil
+}
+
+func (m *Model) key(k string) (tea.Model, tea.Cmd) {
+ switch k {
+ case "q", "esc", "ctrl+c":
+ return m, tea.Quit
+ }
+ if m.busy != "" {
+ return m, nil // one change at a time
+ }
+ switch k {
+ case "r":
+ m.status, m.err = "Refreshed.", nil
+ return m, m.reload()
+ case "tab":
+ m.focus = 1 - m.focus
+ case "left", "h":
+ m.focus = 0
+ case "right", "l":
+ if len(m.items()) > 0 {
+ m.focus = 1
+ }
+ case "up", "k":
+ m.move(-1)
+ m.confirm = ""
+ case "down", "j":
+ m.move(1)
+ m.confirm = ""
+ case "space", "enter", "x":
+ if m.focus == 0 {
+ m.focus = 1
+ return m, nil
+ }
+ if it := m.current(); it != nil {
+ switch {
+ case it.Kind == Toggle:
+ return m, m.apply(it, !it.On)
+ case it.Kind == Action && k != "space":
+ if it.Confirm != "" && m.confirm != it.Key && it.Disabled == "" {
+ m.confirm, m.status, m.err = it.Key, it.Confirm+" Press enter again to confirm.", nil
+ return m, nil
+ }
+ m.confirm = ""
+ return m, m.apply(it, true)
+ }
+ }
+ default:
+ // A digit jumps to that section.
+ if len(k) == 1 && k[0] >= '1' && k[0] <= '9' && int(k[0]-'1') < len(m.sections) {
+ m.sec, m.row, m.focus = int(k[0]-'1'), 0, 1
+ }
+ }
+ return m, nil
+}
+
+func (m *Model) move(d int) {
+ if m.focus == 0 {
+ if n := len(m.sections); n > 0 {
+ m.sec = (m.sec + d + n) % n
+ m.row = 0
+ }
+ return
+ }
+ if n := len(m.items()); n > 0 {
+ m.row = (m.row + d + n) % n
+ }
+}
+
+// ── View ─────────────────────────────────────────────────────────────────────
+
+// state is a row's value: on in green, off dim, run in gold, information
+// in green, as the tree's answers.
+func (m *Model) state(it Item) string {
+ if m.busy != "" && m.busy == it.Label && it.Kind != Info {
+ if m.prog >= 0 {
+ s := tui.Bar(int(m.prog*1000), 1000, 16) + " " + th.Bold.Render(fmt.Sprintf("%3.0f%%", m.prog*100))
+ if m.progNote != "" {
+ s += " " + th.Faint.Render(m.progNote)
+ }
+ return s
+ }
+ return th.Badge.Render(spinner[m.frame%len(spinner)] + " applying…")
+ }
+ note := ""
+ if it.Value != "" && it.Kind != Info {
+ note = " " + th.Faint.Render(it.Value)
+ }
+ switch it.Kind {
+ case Toggle:
+ if it.On {
+ return th.Answer.Render("on") + note
+ }
+ return th.Off.Render("off") + note
+ case Action:
+ return th.Tree.Render("run ↵") + note
+ }
+ return th.Answer.Render(it.Value)
+}
+
+// glyph is a tree line's stem: ├, or └ for the last line.
+func glyph(last bool) string {
+ if last {
+ return th.Tree.Render("└")
+ }
+ return th.Tree.Render("├")
+}
+
+func (m *Model) navBody(w int) string {
+ var b strings.Builder
+ b.WriteString("\n")
+ for i, s := range m.sections {
+ title := fmt.Sprintf("%-*s", max(w-4, 1), s.Title)
+ last := i == len(m.sections)-1
+ switch {
+ case i == m.sec && m.focus == 0:
+ b.WriteString(th.Tree.Render("▸") + " " + th.Highlight.Render(" "+title))
+ case i == m.sec:
+ b.WriteString(glyph(last) + " " + th.Bold.Render(title))
+ default:
+ b.WriteString(glyph(last) + " " + th.Faint.Render(title))
+ }
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+func (m *Model) rowsBody(w int) string {
+ items := m.items()
+ lw := 0
+ for _, it := range items {
+ lw = max(lw, lipgloss.Width(it.Label))
+ }
+ lw = min(lw, w/2)
+ var b strings.Builder
+ b.WriteString("\n")
+ for i, it := range items {
+ label := tui.Fit(it.Label, lw)
+ st := m.state(it)
+ if it.Disabled != "" && it.Kind != Info {
+ st = th.Faint.Render(ansi.Strip(st))
+ }
+ if i == m.row && m.focus == 1 {
+ b.WriteString(th.Tree.Render("▸") + " " + th.Highlight.Render(" "+label+" ") + " " + st + "\n")
+ } else {
+ b.WriteString(glyph(i == len(items)-1) + " " + th.Option.Render(label) + " " + st + "\n")
+ }
+ }
+ if len(items) == 0 {
+ b.WriteString(th.Faint.Render("nothing here"))
+ }
+ return b.String()
+}
+
+func (m *Model) detailBody(w int) string {
+ it := m.current()
+ if it == nil {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString("\n" + th.Bold.Render(it.Label) + "\n\n")
+ switch it.Kind {
+ case Info:
+ b.WriteString(tui.Upper("value") + "\n")
+ for _, l := range tui.Wrap(it.Value, w) {
+ b.WriteString(th.Answer.Render(l) + "\n")
+ }
+ case Toggle:
+ b.WriteString(tui.Upper("state") + "\n" + m.state(Item{Kind: Toggle, On: it.On}) + "\n")
+ if it.Value != "" {
+ b.WriteString("\n" + tui.Upper("note") + "\n")
+ for _, l := range tui.Wrap(it.Value, w) {
+ b.WriteString(l + "\n")
+ }
+ }
+ case Action:
+ b.WriteString(tui.Keys("ENTER", "run this") + "\n")
+ }
+ if it.Detail != "" {
+ b.WriteString("\n")
+ for _, l := range tui.Wrap(it.Detail, w) {
+ b.WriteString(th.Faint.Render(l) + "\n")
+ }
+ }
+ if it.Disabled != "" {
+ b.WriteString("\n" + tui.Tag("!", th.TagWarn) + " ")
+ for i, l := range tui.Wrap(it.Disabled, w-4) {
+ if i > 0 {
+ b.WriteString(" ")
+ }
+ b.WriteString(th.Badge.Render(l) + "\n")
+ }
+ }
+ return b.String()
+}
+
+// View draws a breadcrumb over a rule, the sections, rows and details
+// panels, and the keys under a rule with the last result.
+func (m *Model) View() tea.View {
+ if m.width == 0 {
+ return tea.NewView("")
+ }
+ W, H := m.width, m.height
+ here := "settings"
+ if m.sec < len(m.sections) {
+ here = strings.ToLower(m.sections[m.sec].Title)
+ }
+ left := tui.Header2("settings", here)
+ right := th.Faint.Render(ansi.Truncate(m.title, W/2, "…"))
+ head := left + strings.Repeat(" ", max(W-lipgloss.Width(left)-lipgloss.Width(right), 1)) + right
+
+ bodyH := max(H-4, 6)
+ navW := 12
+ for _, s := range m.sections {
+ navW = max(navW, lipgloss.Width(s.Title)+10)
+ }
+ rest := W - navW - 1
+ var panels string
+ nav := tui.Panel("Sections", m.navBody(navW-4), navW, bodyH, m.focus == 0)
+ title := ""
+ if m.sec < len(m.sections) {
+ title = m.sections[m.sec].Title
+ }
+ if W >= 110 {
+ rowsW := rest * 11 / 20
+ detW := rest - rowsW - 1
+ rows := tui.Panel(title, m.rowsBody(rowsW-4), rowsW, bodyH, m.focus == 1)
+ det := tui.Panel("Details", m.detailBody(detW-4), detW, bodyH, false)
+ panels = lipgloss.JoinHorizontal(lipgloss.Top, nav, " ", rows, " ", det)
+ } else {
+ body := m.rowsBody(rest - 4)
+ if m.focus == 1 {
+ body += "\n" + th.Rule.Render(strings.Repeat("─", rest-4)) + "\n" + m.detailBody(rest-4)
+ }
+ panels = lipgloss.JoinHorizontal(lipgloss.Top, nav, " ", tui.Panel(title, body, rest, bodyH, m.focus == 1))
+ }
+
+ keys := tui.Keys("↑↓", "navigate", "→", "open", "1-9", "section", "Q", "quit")
+ if m.focus == 1 {
+ keys = tui.Keys("↑↓", "navigate", "SPACE", "toggle", "ENTER", "run", "←", "sections", "R", "refresh", "Q", "quit")
+ }
+ status := ""
+ switch {
+ case m.err != nil:
+ status = th.Error.Render("✗ " + m.err.Error())
+ case m.status != "":
+ status = th.Hex.Render("⬢ ") + m.status
+ }
+ if status != "" {
+ room := W - lipgloss.Width(keys) - 3
+ if room < 12 {
+ keys, room = "", W
+ }
+ status = tui.Fit(status, room)
+ keys += " " + status
+ }
+ v := tea.NewView(head + "\n" + tui.Rule(W) + "\n" + panels + "\n" + tui.Rule(W) + "\n" + keys)
+ v.AltScreen = true
+ return v
+}
diff --git a/internal/tui/settings/settings_test.go b/internal/tui/settings/settings_test.go
new file mode 100644
index 0000000..8c917b0
--- /dev/null
+++ b/internal/tui/settings/settings_test.go
@@ -0,0 +1,142 @@
+package settings
+
+import (
+ "context"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+type fake struct {
+ on map[string]bool
+ applied []string
+}
+
+func (f *fake) Load(context.Context) ([]Section, error) {
+ return []Section{
+ {Title: "Account", Items: []Item{{Label: "Signed in as", Value: "Ada "}}},
+ {Title: "Agents", Items: []Item{
+ {Key: "agent:codex", Label: "Codex", Kind: Toggle, On: f.on["agent:codex"], Detail: "Capture Codex sessions"},
+ {Key: "agent:pi", Label: "Pi", Kind: Toggle, Disabled: "Run atlas init in this folder first."},
+ {Key: "agents:all", Label: "Add all detected agents", Kind: Action},
+ }},
+ }, nil
+}
+
+func (f *fake) Apply(_ context.Context, key string, on bool) (string, error) {
+ f.applied = append(f.applied, key)
+ f.on[key] = on
+ return "done " + key, nil
+}
+
+var ansiRE = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
+
+// run feeds a command's message back, as the program would.
+func run(m *Model, cmd tea.Cmd) {
+ for cmd != nil {
+ msg := cmd()
+ if b, ok := msg.(tea.BatchMsg); ok {
+ for _, c := range b {
+ if r := c(); r != nil {
+ if _, ok := r.(tickMsg); !ok {
+ _, cmd = m.Update(r)
+ run(m, cmd)
+ }
+ }
+ }
+ return
+ }
+ _, cmd = m.Update(msg)
+ }
+}
+
+func key(m *Model, code rune, text string) {
+ _, cmd := m.Update(tea.KeyPressMsg{Code: code, Text: text})
+ run(m, cmd)
+}
+
+func TestSettingsScreen(t *testing.T) {
+ f := &fake{on: map[string]bool{}}
+ m := New(context.Background(), f, "/repo")
+ _, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
+ s := ansiRE.ReplaceAllString(m.View().Content, "")
+ for _, want := range []string{"Atlas settings / account", "/repo", "├ Account", "└ Agents", "╭─ Sections", "╭─ Details", "Ada "} {
+ if !strings.Contains(s, want) {
+ t.Fatalf("missing %q\n%s", want, s)
+ }
+ }
+ key(m, '2', "2")
+ s = ansiRE.ReplaceAllString(m.View().Content, "")
+ if !strings.Contains(s, "▸ Codex ") || !strings.Contains(s, " off") || !strings.Contains(s, "Capture Codex sessions") || !strings.Contains(s, "Atlas settings / agents") {
+ t.Fatal(s)
+ }
+ key(m, tea.KeySpace, " ") // toggle Codex on
+ if len(f.applied) != 1 || f.applied[0] != "agent:codex" || !f.on["agent:codex"] {
+ t.Fatalf("%v", f.applied)
+ }
+ s = ansiRE.ReplaceAllString(m.View().Content, "")
+ if !strings.Contains(s, " on") || !strings.Contains(s, "⬢ done agent:codex") {
+ t.Fatal(s)
+ }
+ key(m, tea.KeyDown, "")
+ key(m, tea.KeySpace, " ") // disabled: explains, applies nothing
+ if len(f.applied) != 1 || !strings.Contains(ansiRE.ReplaceAllString(m.View().Content, ""), "atlas init") {
+ t.Fatalf("%v", f.applied)
+ }
+ key(m, tea.KeyDown, "")
+ key(m, tea.KeySpace, " ") // space does not run an action
+ key(m, tea.KeyEnter, "")
+ if len(f.applied) != 2 || f.applied[1] != "agents:all" {
+ t.Fatalf("%v", f.applied)
+ }
+ key(m, tea.KeyLeft, "")
+ key(m, tea.KeyUp, "")
+ if m.sec != 0 || m.focus != 0 {
+ t.Fatalf("sec %d focus %d", m.sec, m.focus)
+ }
+}
+
+type progressFake struct{ fake }
+
+func (p *progressFake) ApplyProgress(_ context.Context, key string, on bool, report func(float64, string)) (string, error) {
+ report(0.5, "40 of 87 MB")
+ return p.Apply(context.Background(), key, on)
+}
+
+func TestProgressAndConfirm(t *testing.T) {
+ f := &progressFake{fake{on: map[string]bool{}}}
+ m := New(context.Background(), f, "/repo")
+ _, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
+ m.sections = []Section{{Title: "Model", Items: []Item{
+ {Key: "model:download", Label: "Download model", Kind: Action},
+ {Key: "model:remove", Label: "Remove model", Kind: Action, Confirm: "This deletes the files."},
+ }}}
+ m.sec, m.row, m.focus = 0, 0, 1
+ // The running change shows its progress in the row.
+ _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if cmd == nil || m.busy != "Download model" {
+ t.Fatal("download did not start")
+ }
+ _, _ = m.Update(progressMsg{frac: 0.5, note: "40 of 87 MB", ch: make(chan tea.Msg, 1)})
+ if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "50%") || !strings.Contains(s, "40 of 87 MB") {
+ t.Fatal(s)
+ }
+ _, _ = m.Update(appliedMsg{msg: "Model downloaded."})
+ m.sections = []Section{{Title: "Model", Items: []Item{
+ {Key: "model:download", Label: "Download model", Kind: Action},
+ {Key: "model:remove", Label: "Remove model", Kind: Action, Confirm: "This deletes the files."},
+ }}}
+ m.row = 1
+ // A confirmed action asks first.
+ key(m, tea.KeyEnter, "")
+ if slices.Contains(f.applied, "model:remove") || !strings.Contains(m.status, "Press enter again") {
+ t.Fatalf("%v %q", f.applied, m.status)
+ }
+ _, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if cmd == nil || m.busy != "Remove model" {
+ t.Fatal("second enter must run it")
+ }
+}
diff --git a/internal/tui/settings/state_main_test.go b/internal/tui/settings/state_main_test.go
new file mode 100644
index 0000000..7fbb0aa
--- /dev/null
+++ b/internal/tui/settings/state_main_test.go
@@ -0,0 +1,15 @@
+package settings
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/tui/splash.go b/internal/tui/splash.go
new file mode 100644
index 0000000..180af64
--- /dev/null
+++ b/internal/tui/splash.go
@@ -0,0 +1,87 @@
+package tui
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+)
+
+type introTick struct{}
+
+// intro plays a render function from t=0 to t=1, then leaves the last frame.
+type intro struct {
+ render func(t float64) string
+ frame int
+ frames int
+}
+
+func (m intro) Init() tea.Cmd { return introNext() }
+
+func introNext() tea.Cmd {
+ return tea.Tick(28*time.Millisecond, func(time.Time) tea.Msg { return introTick{} })
+}
+
+func (m intro) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg.(type) {
+ case introTick:
+ m.frame++
+ if m.frame >= m.frames {
+ return m, tea.Quit
+ }
+ return m, introNext()
+ case tea.KeyPressMsg:
+ m.frame = m.frames // any key skips
+ return m, tea.Quit
+ }
+ return m, nil
+}
+
+func (m intro) View() tea.View {
+ t := min(float64(m.frame)/float64(max(m.frames-1, 1)), 1)
+ return tea.NewView(m.render(t) + "\n")
+}
+
+// Animate plays render from 0 to 1 (about half a second) on a terminal and
+// leaves the final frame; elsewhere it prints only the final frame.
+func Animate(w io.Writer, render func(t float64) string) {
+ if !Interactive() || os.Getenv("ATLAS_NO_ANIMATION") != "" {
+ fmt.Fprintln(w, render(1))
+ return
+ }
+ _, _ = tea.NewProgram(intro{render: render, frames: 20}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin)).Run()
+}
+
+// Command is one line of the splash's command list.
+type Command struct{ Name, Short string }
+
+// Splash is `atlas` with no command: the mark lighting up stripe by stripe,
+// the name and version beside it, then the commands by group.
+func Splash(w io.Writer, version string, groups map[string][]Command, order []string) {
+ Animate(w, func(t float64) string {
+ logo := Logo(t)
+ side := Default.Bold.Render("Atlas") + " " + Default.Faint.Render(version) + "\n" +
+ Default.Faint.Render("Shared memory and session capture") + "\n" + Default.Faint.Render("for coding agents.")
+ return "\n" + lipgloss.JoinHorizontal(lipgloss.Center, " "+strings.ReplaceAll(logo, "\n", "\n "), " ", side) + "\n"
+ })
+ nameW := 0
+ for _, g := range order {
+ for _, c := range groups[g] {
+ nameW = max(nameW, len(c.Name))
+ }
+ }
+ for _, g := range order {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, " "+Upper(g))
+ for _, c := range groups[g] {
+ fmt.Fprintln(w, " "+Default.Bold.Render(fmt.Sprintf("atlas %-*s", nameW, c.Name))+" "+Default.Faint.Render(Fit(c.Short, Width()-nameW-12)))
+ }
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, " "+Keys("atlas --help", "details", "atlas init", "start here"))
+ fmt.Fprintln(w)
+}
diff --git a/internal/tui/state_main_test.go b/internal/tui/state_main_test.go
new file mode 100644
index 0000000..17d7b81
--- /dev/null
+++ b/internal/tui/state_main_test.go
@@ -0,0 +1,15 @@
+package tui
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMain keeps the CLI state directory (clistate) out of the real home.
+func TestMain(m *testing.M) {
+ dir, _ := os.MkdirTemp("", "atlas-state-")
+ _ = os.Setenv("XDG_STATE_HOME", dir)
+ code := m.Run()
+ _ = os.RemoveAll(dir)
+ os.Exit(code)
+}
diff --git a/internal/tui/theme.go b/internal/tui/theme.go
index 0456bdf..c664f3d 100644
--- a/internal/tui/theme.go
+++ b/internal/tui/theme.go
@@ -1,9 +1,20 @@
package tui
-import "charm.land/lipgloss/v2"
+import (
+ "image/color"
+ "os"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+)
// Theme is every colour and style the CLI draws with. No other file names a
// colour: screens take their styles from here.
+//
+// The palette is the Atlas desktop app's (crates/atlas-theme/themes/
+// atlas.toml): monochrome first, black and white with greys between; the
+// action colour is an inversion of the page, not a hue; colour is kept for
+// status (success, warning, error, info). No gradients. The logo is cream.
type Theme struct {
Title, Cursor, Hint, Faint, Bold lipgloss.Style
@@ -20,60 +31,176 @@ type Theme struct {
Overlay, OverlayTitle lipgloss.Style
Selected, Option, Input lipgloss.Style
Border lipgloss.Style
+
+ // Wizard and settings.
+ Brand, Accent, Check, Off, Badge lipgloss.Style
+ Pane, PaneFocused, Sidebar lipgloss.Style
+
+ // Frame: breadcrumb, rules, key hints, panels, tags (frame.go).
+ Crumb, CrumbHere, Rule lipgloss.Style
+ Key, KeyLabel, KeyBadge lipgloss.Style
+ Tree, Answer, Label lipgloss.Style
+ Highlight, PanelEdge, Panel lipgloss.Style
+ PanelTitle, PanelTitleFocused lipgloss.Style
+ TagOn, TagOff, TagRun, TagWarn lipgloss.Style
+ BarFull, BarEmpty, Hex, HexIdle lipgloss.Style
+
+ // Logo and status.
+ Logo, LogoDim lipgloss.Style
+ Success, Warning, Info, Secondary lipgloss.Style
}
-// ANSI palette indices, so the user's terminal theme decides the shades.
-var (
- cBlue = lipgloss.Color("12")
- cCyan = lipgloss.Color("6")
- cGreen = lipgloss.Color("2")
- cRed = lipgloss.Color("1")
- cYellow = lipgloss.Color("3")
- cMagenta = lipgloss.Color("5")
- cGrey = lipgloss.Color("8")
-)
+// Palette is one theme's colours.
+type Palette struct {
+ Bg, Panel, Fill, Popover color.Color
+ Border, BorderStrong color.Color
+ Fg, Text, Secondary, Muted, Dim color.Color
+ Logo color.Color
+ Success, Warning, Error, Info color.Color
+ SuccessBg color.Color
+ Gold, Selection color.Color
+}
+
+// Dark is atlas.toml [dark].
+var Dark = Palette{
+ Bg: lipgloss.Color("#000000"), Panel: lipgloss.Color("#0a0a0a"), Fill: lipgloss.Color("#171717"), Popover: lipgloss.Color("#1c1c1c"),
+ Border: lipgloss.Color("#1e1e1e"), BorderStrong: lipgloss.Color("#3d3d3d"),
+ Fg: lipgloss.Color("#ffffff"), Text: lipgloss.Color("#d4d4d4"), Secondary: lipgloss.Color("#aaaaaa"),
+ Muted: lipgloss.Color("#8f8f8f"), Dim: lipgloss.Color("#666666"),
+ Logo: lipgloss.Color("#FFFFE2"),
+ Success: lipgloss.Color("#9ecf8a"), Warning: lipgloss.Color("#ffff00"), Error: lipgloss.Color("#f44747"), Info: lipgloss.Color("#6796e6"),
+ SuccessBg: lipgloss.Color("#0d2211"),
+ Gold: lipgloss.Color("#e5c07b"), // the app's terminal yellow (keys.toml)
+ Selection: lipgloss.Color("#303030"), // editor.selection.background
+}
+
+// Light is atlas.toml [light].
+var Light = Palette{
+ Bg: lipgloss.Color("#ffffff"), Panel: lipgloss.Color("#fafafa"), Fill: lipgloss.Color("#f2f2f2"), Popover: lipgloss.Color("#f0f0f0"),
+ Border: lipgloss.Color("#e0e0e0"), BorderStrong: lipgloss.Color("#bdbdbd"),
+ Fg: lipgloss.Color("#000000"), Text: lipgloss.Color("#2b2b2b"), Secondary: lipgloss.Color("#4f4f4f"),
+ Muted: lipgloss.Color("#737373"), Dim: lipgloss.Color("#8a8a8a"),
+ Logo: lipgloss.Color("#000000"),
+ Success: lipgloss.Color("#3f7f2f"), Warning: lipgloss.Color("#8f7a00"), Error: lipgloss.Color("#c62828"), Info: lipgloss.Color("#1f5fb0"),
+ SuccessBg: lipgloss.Color("#e3f1dc"),
+ Gold: lipgloss.Color("#8f7a00"),
+ Selection: lipgloss.Color("#d0d0d0"),
+}
+
+// NewTheme builds the styles from a palette.
+func NewTheme(p Palette) Theme {
+ s := func() lipgloss.Style { return lipgloss.NewStyle() }
+ fg := func(c color.Color) lipgloss.Style { return s().Foreground(c) }
+ return Theme{
+ Title: fg(p.Fg).Bold(true),
+ Cursor: fg(p.Fg).Bold(true),
+ Hint: fg(p.Dim),
+ Faint: fg(p.Muted),
+ Bold: fg(p.Fg).Bold(true),
+
+ User: fg(p.Fg).Bold(true),
+ Assistant: fg(p.Text),
+ Thinking: fg(p.Muted).Italic(true),
+ Notice: fg(p.Muted),
+ Error: fg(p.Error).Bold(true),
+
+ ToolTitle: fg(p.Fg).Bold(true),
+ ToolMeta: fg(p.Muted),
+ Location: fg(p.Info),
+
+ StatusPending: fg(p.Dim),
+ StatusRunning: fg(p.Warning),
+ StatusDone: fg(p.Success),
+ StatusFailed: fg(p.Error),
+
+ DiffAdd: fg(p.Success),
+ DiffDel: fg(p.Error),
+ DiffHunk: fg(p.Info),
+ Terminal: fg(p.Muted),
+
+ PlanDone: fg(p.Success),
+ PlanActive: fg(p.Fg).Bold(true),
+ PlanPending: fg(p.Dim),
+
+ Bar: fg(p.Muted),
+ BarKey: fg(p.Secondary),
+ BarValue: fg(p.Fg).Bold(true),
+ Usage: fg(p.Muted),
+
+ Overlay: s().Border(lipgloss.RoundedBorder()).BorderForeground(p.BorderStrong).Padding(0, 1),
+ OverlayTitle: fg(p.Fg).Bold(true),
+ Selected: fg(p.Fg).Bold(true),
+ Option: fg(p.Text),
+ Input: s().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(p.BorderStrong),
+ Border: fg(p.BorderStrong),
+
+ Brand: fg(p.Logo).Bold(true),
+ Accent: fg(p.Secondary),
+ Check: fg(p.Success).Bold(true),
+ Off: fg(p.Dim),
+ Badge: fg(p.Secondary),
+ Pane: s().Border(lipgloss.RoundedBorder()).BorderForeground(p.BorderStrong).Padding(0, 1),
+ PaneFocused: s().Border(lipgloss.RoundedBorder()).BorderForeground(p.Fg).Padding(0, 1),
+ Sidebar: s().Padding(0, 1),
+
+ Crumb: fg(p.Dim),
+ CrumbHere: fg(p.Fg).Bold(true),
+ Rule: fg(p.BorderStrong),
+ Key: fg(p.Fg).Bold(true),
+ KeyLabel: fg(p.Dim),
+ KeyBadge: fg(p.Secondary).Background(p.Fill),
+ Tree: fg(p.Gold),
+ Answer: fg(p.Success),
+ Label: fg(p.Dim).Bold(true),
+ Highlight: fg(p.Fg).Background(p.Selection).Bold(true),
+
+ PanelEdge: fg(p.BorderStrong),
+ Panel: fg(p.Fg),
+ PanelTitle: fg(p.Dim),
+ PanelTitleFocused: fg(p.Fg).Bold(true),
+
+ TagOn: fg(p.Success).Background(p.SuccessBg).Bold(true),
+ TagOff: fg(p.Dim).Background(p.Fill),
+ TagRun: fg(p.Fg).Background(p.Popover).Bold(true),
+ TagWarn: fg(p.Warning).Background(p.Fill).Bold(true),
+
+ BarFull: fg(p.Success),
+ BarEmpty: fg(p.BorderStrong),
+ Hex: fg(p.Success),
+ HexIdle: fg(p.Dim),
+
+ Logo: fg(p.Logo).Bold(true),
+ LogoDim: fg(p.BorderStrong),
+ Success: fg(p.Success),
+ Warning: fg(p.Warning),
+ Info: fg(p.Info),
+ Secondary: fg(p.Secondary),
+ }
+}
+
+// Default is the theme in use; screens hold the pointer, so Detect can
+// switch it to the light palette before anything draws.
+var Default = func() *Theme { t := NewTheme(Dark); return &t }()
+
+// Detect picks the light or dark palette: ATLAS_THEME=light|dark, else the
+// terminal's COLORFGBG ("15;0": light text on black), else dark, the app's
+// default. The terminal is not queried: one that does not answer would hang.
+func Detect() {
+ if light(os.Getenv("ATLAS_THEME"), os.Getenv("COLORFGBG")) {
+ *Default = NewTheme(Light)
+ }
+}
-// Default is the theme.
-var Default = Theme{
- Title: lipgloss.NewStyle().Bold(true),
- Cursor: lipgloss.NewStyle().Foreground(cBlue).Bold(true),
- Hint: lipgloss.NewStyle().Faint(true),
- Faint: lipgloss.NewStyle().Faint(true),
- Bold: lipgloss.NewStyle().Bold(true),
-
- User: lipgloss.NewStyle().Foreground(cBlue).Bold(true),
- Assistant: lipgloss.NewStyle(),
- Thinking: lipgloss.NewStyle().Faint(true).Italic(true),
- Notice: lipgloss.NewStyle().Foreground(cGrey),
- Error: lipgloss.NewStyle().Foreground(cRed).Bold(true),
-
- ToolTitle: lipgloss.NewStyle().Bold(true),
- ToolMeta: lipgloss.NewStyle().Foreground(cGrey),
- Location: lipgloss.NewStyle().Foreground(cCyan),
-
- StatusPending: lipgloss.NewStyle().Foreground(cGrey),
- StatusRunning: lipgloss.NewStyle().Foreground(cYellow),
- StatusDone: lipgloss.NewStyle().Foreground(cGreen),
- StatusFailed: lipgloss.NewStyle().Foreground(cRed),
-
- DiffAdd: lipgloss.NewStyle().Foreground(cGreen),
- DiffDel: lipgloss.NewStyle().Foreground(cRed),
- DiffHunk: lipgloss.NewStyle().Foreground(cCyan),
- Terminal: lipgloss.NewStyle().Foreground(cGrey),
-
- PlanDone: lipgloss.NewStyle().Foreground(cGreen),
- PlanActive: lipgloss.NewStyle().Foreground(cYellow),
- PlanPending: lipgloss.NewStyle().Foreground(cGrey),
-
- Bar: lipgloss.NewStyle().Foreground(cGrey),
- BarKey: lipgloss.NewStyle().Foreground(cMagenta),
- BarValue: lipgloss.NewStyle().Bold(true),
- Usage: lipgloss.NewStyle().Foreground(cGrey),
-
- Overlay: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cBlue).Padding(0, 1),
- OverlayTitle: lipgloss.NewStyle().Bold(true).Foreground(cBlue),
- Selected: lipgloss.NewStyle().Foreground(cBlue).Bold(true),
- Option: lipgloss.NewStyle(),
- Input: lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(cGrey),
- Border: lipgloss.NewStyle().Foreground(cGrey),
+func light(theme, fgbg string) bool {
+ switch strings.ToLower(theme) {
+ case "light":
+ return true
+ case "dark":
+ return false
+ }
+ if i := strings.LastIndexByte(fgbg, ';'); i >= 0 {
+ bg := fgbg[i+1:]
+ return bg == "7" || bg == "15"
+ }
+ return false
}
diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go
new file mode 100644
index 0000000..03f0b3c
--- /dev/null
+++ b/internal/tui/wizard.go
@@ -0,0 +1,358 @@
+package tui
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+)
+
+// Step places one question in a numbered flow (atlas init): the header
+// shows where the user is, and the answered step collapses to one line.
+type Step struct {
+ N, Total int
+ Title string
+}
+
+// Choice is one option of a step: a label and a faint detail after it.
+type Choice struct {
+ Label, Detail string
+}
+
+// wizardWidth is the frame's width before the terminal reports its own.
+const wizardWidth = 72
+
+func frameWidth(term int) int {
+ if term <= 0 {
+ return wizardWidth
+ }
+ return max(min(term-2, 96), 40)
+}
+
+// header is the breadcrumb over a rule, then "STEP 2 OF 4 ▮▮▮▮▯▯▯▯".
+func (s Step) header(w int) string {
+ h := Header2("setup", strings.ToLower(s.Title)) + "\n" + Rule(w) + "\n"
+ if s.Total > 0 {
+ h += Upper(fmt.Sprintf("step %d of %d", s.N, s.Total)) + " " + Bar(s.N, s.Total, 4*s.Total) + "\n"
+ }
+ return h
+}
+
+// footer is a rule over the key hints.
+func footer(w int, keys ...string) string {
+ return Rule(w) + "\n" + Keys(keys...) + "\n"
+}
+
+// summary is the tree line an answered step leaves behind; Ready closes
+// the tree.
+func (s Step) summary(answer string) string {
+ return TreeLine(false, s.Title, Default.Answer.Render(answer))
+}
+
+// cancelled is the line a step left with esc leaves behind.
+func (s Step) cancelled() string {
+ return TreeLine(true, s.Title, Default.Error.Render("cancelled"))
+}
+
+// StepDone prints the line of a step answered without a question (one
+// organisation, a flag on the command line).
+func StepDone(w io.Writer, s Step, answer string) {
+ fmt.Fprintln(w, s.summary(answer))
+}
+
+// Ready closes a flow's tree: a green hexagon and the message.
+func Ready(w io.Writer, msg string, notes ...string) {
+ fmt.Fprintln(w, Default.Tree.Render("└")+" "+Default.Hex.Render("⬢")+" "+Default.Bold.Render(msg))
+ for _, n := range notes {
+ fmt.Fprintln(w, " "+Default.Faint.Render(n))
+ }
+}
+
+// row renders one option as a tree line: ├ (└ for the last), the label,
+// and its detail; the cursor's row is ▸ with the label on the selection
+// background and the detail in green.
+func row(cursor, last bool, mark, label, detail string) string {
+ if mark != "" {
+ mark += " "
+ }
+ if cursor {
+ if detail != "" {
+ detail = " " + Default.Answer.Render(detail)
+ }
+ return Default.Tree.Render("▸") + " " + mark + Default.Highlight.Render(" "+label+" ") + detail + "\n"
+ }
+ if detail != "" {
+ detail = " " + Default.Faint.Render(detail)
+ }
+ glyph := "├"
+ if last {
+ glyph = "└"
+ }
+ return Default.Tree.Render(glyph) + " " + mark + " " + Default.Option.Render(label) + " " + detail + "\n"
+}
+
+func labelWidth(choices []Choice) int {
+ w := 0
+ for _, c := range choices {
+ w = max(w, lipgloss.Width(c.Label))
+ }
+ return w
+}
+
+// ── Single choice ────────────────────────────────────────────────────────────
+
+type stepSelect struct {
+ step Step
+ question string
+ choices []Choice
+ cursor int
+ chosen int
+ done bool
+ height int // the terminal's, once known; long lists scroll
+ width int
+}
+
+// chrome is the lines a step draws around its list: breadcrumb, rule,
+// step line, question, the "more" markers, the footer and the blank lines.
+const chrome = 14
+
+// window is the slice of rows [from, to) that fits with the cursor in it.
+func window(n, cursor, height int) (from, to int) {
+ rows := n
+ if height > 0 {
+ rows = max(height-chrome, 4)
+ }
+ if n <= rows {
+ return 0, n
+ }
+ from = min(max(cursor-rows/2, 0), n-rows)
+ return from, from + rows
+}
+
+func (m stepSelect) Init() tea.Cmd { return nil }
+
+func (m stepSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if ws, ok := msg.(tea.WindowSizeMsg); ok {
+ m.height, m.width = ws.Height, ws.Width
+ return m, nil
+ }
+ key, ok := msg.(tea.KeyPressMsg)
+ if !ok {
+ return m, nil
+ }
+ switch key.String() {
+ case "up", "k", "shift+tab":
+ m.cursor = (m.cursor - 1 + len(m.choices)) % len(m.choices)
+ case "down", "j", "tab":
+ m.cursor = (m.cursor + 1) % len(m.choices)
+ case "home", "g":
+ m.cursor = 0
+ case "end", "G":
+ m.cursor = len(m.choices) - 1
+ case "enter", "space":
+ m.chosen, m.done = m.cursor, true
+ return m, tea.Quit
+ case "esc", "q", "ctrl+c":
+ m.done = true
+ return m, tea.Quit
+ default:
+ // A digit picks that option directly.
+ if r := key.String(); len(r) == 1 && r[0] >= '1' && r[0] <= '9' && int(r[0]-'1') < len(m.choices) {
+ m.cursor = int(r[0] - '1')
+ }
+ }
+ return m, nil
+}
+
+func (m stepSelect) View() tea.View {
+ if m.done {
+ if m.chosen < 0 {
+ return tea.NewView(m.step.cancelled() + "\n")
+ }
+ return tea.NewView(m.step.summary(m.choices[m.chosen].Label) + "\n")
+ }
+ w := frameWidth(m.width)
+ var b strings.Builder
+ b.WriteString(m.step.header(w) + "\n")
+ b.WriteString(Default.Bold.Render(m.question) + "\n\n")
+ lw := labelWidth(m.choices)
+ from, to := window(len(m.choices), m.cursor, m.height)
+ if from > 0 {
+ b.WriteString(Default.Tree.Render("│") + " " + Default.Faint.Render(fmt.Sprintf("↑ %d more", from)) + "\n")
+ }
+ for i := from; i < to; i++ {
+ c := m.choices[i]
+ b.WriteString(row(i == m.cursor, i == len(m.choices)-1, "", fmt.Sprintf("%-*s", lw, c.Label), c.Detail))
+ }
+ if to < len(m.choices) {
+ b.WriteString(Default.Tree.Render("│") + " " + Default.Faint.Render(fmt.Sprintf("↓ %d more", len(m.choices)-to)) + "\n")
+ }
+ b.WriteString("\n" + footer(w, "↑↓", "navigate", "ENTER", "select", "ESC", "cancel"))
+ return tea.NewView(b.String())
+}
+
+// StepSelect asks one question of a flow and returns the chosen index, or
+// ErrCancelled.
+func StepSelect(s Step, question string, choices []Choice, initial int) (int, error) {
+ return stepSelectWith(s, question, choices, initial, os.Stdin, os.Stdout)
+}
+
+func stepSelectWith(s Step, question string, choices []Choice, initial int, in io.Reader, out io.Writer) (int, error) {
+ if len(choices) == 0 {
+ return -1, fmt.Errorf("%s: nothing to choose from", s.Title)
+ }
+ if initial < 0 || initial >= len(choices) {
+ initial = 0
+ }
+ m := stepSelect{step: s, question: question, choices: choices, cursor: initial, chosen: -1}
+ final, err := tea.NewProgram(m, tea.WithInput(in), tea.WithOutput(out)).Run()
+ if err != nil {
+ return -1, err
+ }
+ if fm := final.(stepSelect); fm.chosen >= 0 {
+ return fm.chosen, nil
+ }
+ return -1, ErrCancelled
+}
+
+// ── Several choices ──────────────────────────────────────────────────────────
+
+// stepMulti is a checklist whose first row selects or clears every option.
+type stepMulti struct {
+ step Step
+ question string
+ allLabel string
+ choices []Choice
+ checked []bool
+ cursor int // 0 is the "all" row; i+1 is choices[i]
+ done bool
+ ok bool
+ width int
+}
+
+func (m stepMulti) allChecked() bool {
+ for _, c := range m.checked {
+ if !c {
+ return false
+ }
+ }
+ return len(m.checked) > 0
+}
+
+func (m stepMulti) setAll(on bool) stepMulti {
+ for i := range m.checked {
+ m.checked[i] = on
+ }
+ return m
+}
+
+func (m stepMulti) Init() tea.Cmd { return nil }
+
+func (m stepMulti) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if ws, ok := msg.(tea.WindowSizeMsg); ok {
+ m.width = ws.Width
+ return m, nil
+ }
+ key, ok := msg.(tea.KeyPressMsg)
+ if !ok {
+ return m, nil
+ }
+ rows := len(m.choices) + 1
+ switch key.String() {
+ case "up", "k", "shift+tab":
+ m.cursor = (m.cursor - 1 + rows) % rows
+ case "down", "j", "tab":
+ m.cursor = (m.cursor + 1) % rows
+ case "space", "x":
+ if m.cursor == 0 {
+ m = m.setAll(!m.allChecked())
+ } else {
+ m.checked[m.cursor-1] = !m.checked[m.cursor-1]
+ }
+ case "a":
+ m = m.setAll(!m.allChecked())
+ case "n":
+ m = m.setAll(false)
+ case "enter":
+ m.done, m.ok = true, true
+ return m, tea.Quit
+ case "esc", "q", "ctrl+c":
+ m.done = true
+ return m, tea.Quit
+ }
+ return m, nil
+}
+
+func (m stepMulti) picked() []int {
+ var out []int
+ for i, c := range m.checked {
+ if c {
+ out = append(out, i)
+ }
+ }
+ return out
+}
+
+// box is a checkbox: ☒ chosen, ☐ not, ▣ for the "all" row when some are.
+func box(on bool) string {
+ if on {
+ return Default.Check.Render("☒")
+ }
+ return Default.Off.Render("☐")
+}
+
+func (m stepMulti) View() tea.View {
+ if m.done {
+ if !m.ok {
+ return tea.NewView(m.step.cancelled() + "\n")
+ }
+ var names []string
+ for _, i := range m.picked() {
+ names = append(names, m.choices[i].Label)
+ }
+ answer := strings.Join(names, ", ")
+ if answer == "" {
+ answer = "none"
+ }
+ return tea.NewView(m.step.summary(answer) + "\n")
+ }
+ w := frameWidth(m.width)
+ var b strings.Builder
+ b.WriteString(m.step.header(w) + "\n")
+ b.WriteString(Default.Bold.Render(m.question) + "\n\n")
+ lw := max(labelWidth(m.choices), lipgloss.Width(m.allLabel))
+ n := len(m.picked())
+ all := box(m.allChecked())
+ if n > 0 && !m.allChecked() {
+ all = Default.Accent.Render("▣")
+ }
+ b.WriteString(row(m.cursor == 0, false, all, fmt.Sprintf("%-*s", lw, m.allLabel), fmt.Sprintf("%d of %d selected", n, len(m.choices))))
+ for i, c := range m.choices {
+ b.WriteString(row(m.cursor == i+1, i == len(m.choices)-1, box(m.checked[i]), fmt.Sprintf("%-*s", lw, c.Label), c.Detail))
+ }
+ b.WriteString("\n" + footer(w, "↑↓", "navigate", "SPACE", "toggle", "A", "all", "N", "none", "ENTER", "confirm", "ESC", "cancel"))
+ return tea.NewView(b.String())
+}
+
+// StepMulti asks for any number of options, with a first row that selects
+// them all; it returns the chosen indexes (possibly none), or ErrCancelled.
+func StepMulti(s Step, question, allLabel string, choices []Choice, checked []bool) ([]int, error) {
+ return stepMultiWith(s, question, allLabel, choices, checked, os.Stdin, os.Stdout)
+}
+
+func stepMultiWith(s Step, question, allLabel string, choices []Choice, checked []bool, in io.Reader, out io.Writer) ([]int, error) {
+ m := stepMulti{step: s, question: question, allLabel: allLabel, choices: choices, checked: make([]bool, len(choices))}
+ copy(m.checked, checked)
+ final, err := tea.NewProgram(m, tea.WithInput(in), tea.WithOutput(out)).Run()
+ if err != nil {
+ return nil, err
+ }
+ fm := final.(stepMulti)
+ if !fm.ok {
+ return nil, ErrCancelled
+ }
+ return fm.picked(), nil
+}
diff --git a/internal/tui/wizard_test.go b/internal/tui/wizard_test.go
new file mode 100644
index 0000000..9f05d7f
--- /dev/null
+++ b/internal/tui/wizard_test.go
@@ -0,0 +1,103 @@
+package tui
+
+import (
+ "io"
+ "regexp"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+var ansiRE = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
+
+func plain(v tea.View) string { return ansiRE.ReplaceAllString(v.Content, "") }
+
+func hit(m tea.Model, code rune, text string) tea.Model {
+ m, _ = m.Update(tea.KeyPressMsg{Code: code, Text: text})
+ return m
+}
+
+func TestStepSelect(t *testing.T) {
+ var m tea.Model = stepSelect{step: Step{N: 2, Total: 4, Title: "Project"}, question: "Which Project?",
+ choices: []Choice{{Label: "backend", Detail: "matches"}, {Label: "+ Create"}}, chosen: -1}
+ s := plain(m.View())
+ for _, want := range []string{"Atlas setup / project", "STEP 2 OF 4", "▮▮▮▮▮▮▮▮▯▯▯▯▯▯▯▯", "Which Project?", "▸ backend", "matches", "[ENTER] select"} {
+ if !strings.Contains(s, want) {
+ t.Fatalf("missing %q:\n%s", want, s)
+ }
+ }
+ m = hit(m, tea.KeyDown, "")
+ m = hit(m, tea.KeyEnter, "")
+ if fm := m.(stepSelect); fm.chosen != 1 || !fm.done {
+ t.Fatalf("%+v", fm)
+ }
+ if s := plain(m.View()); !strings.Contains(s, "├ Project") || !strings.Contains(s, "+ Create") {
+ t.Fatalf("summary: %s", s)
+ }
+}
+
+func TestStepMultiAllRow(t *testing.T) {
+ var m tea.Model = stepMulti{step: Step{N: 3, Total: 4, Title: "Agents"}, allLabel: "All agents",
+ choices: []Choice{{Label: "Claude Code"}, {Label: "Codex"}, {Label: "Cursor"}}, checked: []bool{true, false, false}}
+ if s := plain(m.View()); !strings.Contains(s, "1 of 3 selected") {
+ t.Fatal(s)
+ }
+ m = hit(m, tea.KeySpace, " ") // on the "all" row: select every agent
+ if got := m.(stepMulti).picked(); len(got) != 3 {
+ t.Fatalf("all: %v", got)
+ }
+ m = hit(m, 'a', "a") // a again clears
+ if got := m.(stepMulti).picked(); len(got) != 0 {
+ t.Fatalf("none: %v", got)
+ }
+ m = hit(m, tea.KeyDown, "")
+ m = hit(m, tea.KeyDown, "")
+ m = hit(m, tea.KeySpace, " ") // Codex
+ m = hit(m, tea.KeyEnter, "")
+ fm := m.(stepMulti)
+ if got := fm.picked(); !fm.ok || len(got) != 1 || got[0] != 1 {
+ t.Fatalf("%v %v", fm.ok, got)
+ }
+ if s := plain(m.View()); !strings.Contains(s, "├ Agents") || !strings.Contains(s, "Codex") {
+ t.Fatal(s)
+ }
+ m = hit(stepMulti{choices: []Choice{{Label: "x"}}, checked: []bool{true}}, tea.KeyEscape, "")
+ if m.(stepMulti).ok {
+ t.Fatal("esc must cancel")
+ }
+}
+
+func TestStepSelectScrollsALongList(t *testing.T) {
+ choices := make([]Choice, 30)
+ for i := range choices {
+ choices[i] = Choice{Label: string(rune('a' + i%26))}
+ }
+ var m tea.Model = stepSelect{step: Step{Title: "Project"}, choices: choices, chosen: -1}
+ m, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+ s := plain(m.View())
+ if !strings.Contains(s, "↓ 24 more") || strings.Count(s, " more") != 1 {
+ t.Fatal(s)
+ }
+ for range 29 {
+ m = hit(m, tea.KeyDown, "")
+ }
+ if s := plain(m.View()); !strings.Contains(s, "↑ 24 more") || strings.Count(s, " more") != 1 {
+ t.Fatal(s)
+ }
+}
+
+func TestIndentKeepsWrappedLinesOnTheStem(t *testing.T) {
+ var b strings.Builder
+ w := Indent(&b)
+ _, _ = io.WriteString(w, "short\n"+strings.Repeat("word ", 30)+"\n")
+ lines := strings.Split(strings.TrimRight(ansiRE.ReplaceAllString(b.String(), ""), "\n"), "\n")
+ if len(lines) < 3 {
+ t.Fatalf("%q", lines)
+ }
+ for _, l := range lines {
+ if !strings.HasPrefix(l, "│ ") || len([]rune(l)) > indentWidth+5 {
+ t.Fatalf("%q", l)
+ }
+ }
+}
diff --git a/internal/tui/work.go b/internal/tui/work.go
new file mode 100644
index 0000000..a66ad83
--- /dev/null
+++ b/internal/tui/work.go
@@ -0,0 +1,182 @@
+package tui
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// ShownError is an error a screen already showed the user; main does not
+// print it again.
+type ShownError struct{ Err error }
+
+func (e ShownError) Error() string { return e.Err.Error() }
+func (e ShownError) Unwrap() error { return e.Err }
+
+// Shown reports whether err was already shown.
+func Shown(err error) bool {
+ var s ShownError
+ return errors.As(err, &s)
+}
+
+// StepFunc reports progress from inside a Work function: the line under the
+// title changes to msg.
+type StepFunc func(msg string)
+
+// FracFunc sets a Work bar to a known fraction, 0..1 (a download).
+type FracFunc func(f float64)
+
+type (
+ workTick struct{}
+ workFrac float64
+ workStep string
+ workDone struct {
+ msg string
+ err error
+ }
+)
+
+type workModel struct {
+ title string
+ step string
+ frame int
+ start time.Time
+ done bool
+ msg string
+ err error
+ output []string // finished steps, kept on screen
+ prog float64 // the bar, 0..1
+ full bool // finished: the bar shows full a moment before the result
+ known bool // the bar follows reported fractions, not time
+}
+
+type workShown struct{}
+
+// advance moves the bar: it eases toward 90% with time and jumps forward
+// with each step, never back.
+func (m *workModel) advance(step bool) {
+ if m.known {
+ return
+ }
+ el := time.Since(m.start).Seconds()
+ m.prog = max(m.prog, 0.9*(1-math.Exp(-el/2.5)))
+ if step {
+ m.prog += (0.95 - m.prog) * 0.3
+ }
+}
+
+func (m workModel) Init() tea.Cmd { return workTicker() }
+
+func workTicker() tea.Cmd {
+ return tea.Tick(110*time.Millisecond, func(time.Time) tea.Msg { return workTick{} })
+}
+
+func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case workTick:
+ if m.done || m.full {
+ return m, nil
+ }
+ m.frame++
+ m.advance(false)
+ return m, workTicker()
+ case workFrac:
+ m.known = true
+ m.prog = max(m.prog, min(float64(msg), 1))
+ case workStep:
+ if m.step != "" {
+ m.output = append(m.output, m.step)
+ }
+ m.step = string(msg)
+ m.advance(true)
+ case workDone:
+ m.msg, m.err = msg.msg, msg.err
+ if m.step != "" {
+ m.output = append(m.output, m.step)
+ m.step = ""
+ }
+ if msg.err != nil {
+ m.done = true
+ return m, tea.Quit
+ }
+ m.full, m.prog = true, 1
+ return m, tea.Tick(220*time.Millisecond, func(time.Time) tea.Msg { return workShown{} })
+ case workShown:
+ m.done = true
+ return m, tea.Quit
+ case tea.KeyPressMsg:
+ if msg.String() == "ctrl+c" {
+ m.done, m.err = true, errors.New("interrupted")
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+}
+
+// barWidth is the progress bar's width on this terminal.
+func barWidth() int { return max(min(Width()-4, 56), 16) }
+
+func (m workModel) View() tea.View {
+ var b []byte
+ if m.done {
+ for _, o := range m.output {
+ b = fmt.Appendf(b, "%s %s\n", Default.Tree.Render("├"), Default.Faint.Render(o))
+ }
+ switch {
+ case m.err != nil:
+ b = fmt.Appendf(b, "%s %s %s\n", Default.Error.Render("✗"), Default.Bold.Render(m.title), Default.Error.Render(m.err.Error()))
+ case m.msg != "":
+ b = fmt.Appendf(b, "%s %s\n", Default.Success.Render("✓"), Default.Bold.Render(m.msg))
+ }
+ return tea.NewView(string(b))
+ }
+ el := time.Since(m.start).Round(100 * time.Millisecond)
+ b = fmt.Appendf(b, "%s %s\n", Default.Bold.Render(m.title), Default.Faint.Render(el.String()))
+ b = fmt.Appendf(b, "%s\n", Bar(int(m.prog*1000), 1000, barWidth()))
+ for _, o := range m.output {
+ b = fmt.Appendf(b, "%s %s\n", Default.Tree.Render("├"), Default.Faint.Render(o))
+ }
+ if m.step != "" {
+ b = fmt.Appendf(b, "%s %s\n", Default.Tree.Render("└"), Default.Faint.Render(m.step))
+ }
+ return tea.NewView(string(b))
+}
+
+// Work runs fn while a pulsing mark and the title show that something is
+// happening; steps fn reports appear under it. On a terminal it ends with
+// "✓ " or "✗ " (the error comes back as a ShownError);
+// elsewhere fn runs silently and msg is printed plain to w.
+func Work(w io.Writer, title string, fn func(step StepFunc) (string, error)) error {
+ return WorkProgress(w, title, func(step StepFunc, _ FracFunc) (string, error) { return fn(step) })
+}
+
+// WorkProgress is Work for a job that knows how far it is: frac drives the
+// bar (a download), steps still show under it.
+func WorkProgress(w io.Writer, title string, fn func(step StepFunc, frac FracFunc) (string, error)) error {
+ if !Interactive() {
+ msg, err := fn(func(string) {}, func(float64) {})
+ if err == nil && msg != "" {
+ fmt.Fprintln(w, msg)
+ }
+ return err
+ }
+ p := tea.NewProgram(workModel{title: title, start: time.Now()}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin))
+ go func() {
+ msg, err := fn(func(s string) { p.Send(workStep(s)) }, func(f float64) { p.Send(workFrac(f)) })
+ p.Send(workDone{msg, err})
+ }()
+ final, err := p.Run()
+ if err != nil {
+ return err
+ }
+ m := final.(workModel)
+ if m.err != nil {
+ return ShownError{m.err}
+ }
+ return nil
+}
diff --git a/internal/update/update.go b/internal/update/update.go
new file mode 100644
index 0000000..48cdf05
--- /dev/null
+++ b/internal/update/update.go
@@ -0,0 +1,216 @@
+// Package update replaces the running atlas binary with a release from
+// GitHub: the same archive and checksums.txt the installer (install.sh)
+// downloads, verified the same way, swapped in with a rename.
+package update
+
+import (
+ "archive/tar"
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Repo is where releases are published.
+const Repo = "tryatlas/atlas-cli"
+
+// maxArchive bounds a download.
+const maxArchive = 128 << 20
+
+// Source is where releases are read from; tests point it at a fake.
+type Source struct {
+ API string // https://api.github.com/repos/
+ Base string // https://github.com//releases/download
+ HTTP *http.Client
+}
+
+// GitHub is the real source. ATLAS_UPDATE_BASE_URL replaces the download
+// base, as ATLAS_BASE_URL does for the installer.
+func GitHub() Source {
+ s := Source{API: "https://api.github.com/repos/" + Repo, Base: "https://github.com/" + Repo + "/releases/download",
+ HTTP: &http.Client{Timeout: 2 * time.Minute}}
+ if b := os.Getenv("ATLAS_UPDATE_BASE_URL"); b != "" {
+ s.Base = strings.TrimRight(b, "/")
+ }
+ return s
+}
+
+func (s Source) get(ctx context.Context, url string, limit int64) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "atlas-cli-update")
+ resp, err := s.HTTP.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s: %s", url, resp.Status)
+ }
+ b, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(b)) > limit {
+ return nil, fmt.Errorf("%s: larger than %d bytes", url, limit)
+ }
+ return b, nil
+}
+
+// Latest is the tag of the latest published release, "v0.4.0".
+func (s Source) Latest(ctx context.Context) (string, error) {
+ b, err := s.get(ctx, s.API+"/releases/latest", 1<<20)
+ if err != nil {
+ return "", err
+ }
+ var r struct {
+ Tag string `json:"tag_name"`
+ }
+ if err := json.Unmarshal(b, &r); err != nil || r.Tag == "" {
+ return "", errors.New("the latest release names no tag")
+ }
+ return r.Tag, nil
+}
+
+// Archive is the release archive for this platform.
+func Archive(tag, goos, goarch string) string {
+ return fmt.Sprintf("atlas_%s_%s_%s.tar.gz", strings.TrimPrefix(tag, "v"), goos, goarch)
+}
+
+// Download fetches this platform's archive for tag, checks it against the
+// release's checksums.txt, and returns the atlas binary inside it.
+func (s Source) Download(ctx context.Context, tag string) ([]byte, error) {
+ name := Archive(tag, runtime.GOOS, runtime.GOARCH)
+ sums, err := s.get(ctx, s.Base+"/"+tag+"/checksums.txt", 1<<20)
+ if err != nil {
+ return nil, err
+ }
+ want := ""
+ sc := bufio.NewScanner(bytes.NewReader(sums))
+ for sc.Scan() {
+ if f := strings.Fields(sc.Text()); len(f) == 2 && f[1] == name {
+ want = f[0]
+ }
+ }
+ if want == "" {
+ return nil, fmt.Errorf("%s is not in the release's checksums.txt (no build for %s/%s?)", name, runtime.GOOS, runtime.GOARCH)
+ }
+ arch, err := s.get(ctx, s.Base+"/"+tag+"/"+name, maxArchive)
+ if err != nil {
+ return nil, err
+ }
+ sum := sha256.Sum256(arch)
+ if hex.EncodeToString(sum[:]) != want {
+ return nil, fmt.Errorf("%s does not match its checksum; nothing was changed", name)
+ }
+ return binaryFrom(arch)
+}
+
+// binaryFrom extracts the "atlas" file from a tar.gz.
+func binaryFrom(archive []byte) ([]byte, error) {
+ zr, err := gzip.NewReader(bytes.NewReader(archive))
+ if err != nil {
+ return nil, err
+ }
+ tr := tar.NewReader(zr)
+ for {
+ h, err := tr.Next()
+ if errors.Is(err, io.EOF) {
+ return nil, errors.New("the archive holds no atlas binary")
+ }
+ if err != nil {
+ return nil, err
+ }
+ if h.Typeflag == tar.TypeReg && filepath.Base(h.Name) == "atlas" {
+ return io.ReadAll(io.LimitReader(tr, maxArchive))
+ }
+ }
+}
+
+// Replace swaps the binary at path for bin: written next to it, then
+// renamed over it, so a running atlas keeps its open file and a failure
+// leaves the old one in place.
+func Replace(path string, bin []byte) error {
+ mode := os.FileMode(0o755)
+ if fi, err := os.Stat(path); err == nil {
+ mode = fi.Mode().Perm() | 0o111
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".atlas-update-*")
+ if err != nil {
+ return fmt.Errorf("cannot write next to %s: %w", path, err)
+ }
+ defer func() { _ = os.Remove(tmp.Name()) }()
+ if _, err := tmp.Write(bin); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Chmod(tmp.Name(), mode); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), path)
+}
+
+// Executable is the running binary's real path.
+func Executable() (string, error) {
+ p, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ return filepath.EvalSymlinks(p)
+}
+
+// Newer reports that release tag is newer than the running version. A
+// development build ("dev", "0.4.0-dev") is older than its release.
+func Newer(current, tag string) bool {
+ c, cpre := parse(current)
+ t, tpre := parse(tag)
+ if c == nil {
+ return true
+ }
+ if t == nil {
+ return false
+ }
+ for i := range 3 {
+ if t[i] != c[i] {
+ return t[i] > c[i]
+ }
+ }
+ return cpre && !tpre
+}
+
+// parse reads "v1.2.3[-pre]"; nil when it is not a version.
+func parse(v string) ([]int, bool) {
+ v = strings.TrimPrefix(strings.TrimSpace(v), "v")
+ core, pre, _ := strings.Cut(v, "-")
+ parts := strings.Split(core, ".")
+ if len(parts) != 3 {
+ return nil, false
+ }
+ out := make([]int, 3)
+ for i, p := range parts {
+ n, err := strconv.Atoi(p)
+ if err != nil {
+ return nil, false
+ }
+ out[i] = n
+ }
+ return out, pre != ""
+}
diff --git a/internal/update/update_test.go b/internal/update/update_test.go
new file mode 100644
index 0000000..42837ea
--- /dev/null
+++ b/internal/update/update_test.go
@@ -0,0 +1,101 @@
+package update
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// Fake builds a release server for tag holding bin; corrupt breaks the
+// checksum.
+func Fake(t *testing.T, tag string, bin []byte, corrupt bool) Source {
+ t.Helper()
+ var arch bytes.Buffer
+ zw := gzip.NewWriter(&arch)
+ tw := tar.NewWriter(zw)
+ for name, body := range map[string][]byte{"README.md": []byte("readme"), "atlas": bin} {
+ _ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg})
+ _, _ = tw.Write(body)
+ }
+ _ = tw.Close()
+ _ = zw.Close()
+ sum := sha256.Sum256(arch.Bytes())
+ hexsum := hex.EncodeToString(sum[:])
+ if corrupt {
+ hexsum = strings.Repeat("0", 64)
+ }
+ name := Archive(tag, runtime.GOOS, runtime.GOARCH)
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
+ fmt.Fprintf(w, `{"tag_name":%q}`, tag)
+ })
+ mux.HandleFunc("/dl/"+tag+"/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
+ fmt.Fprintf(w, "%s %s\n%s atlas_x_plan9_mips.tar.gz\n", hexsum, name, strings.Repeat("1", 64))
+ })
+ mux.HandleFunc("/dl/"+tag+"/"+name, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(arch.Bytes()) })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return Source{API: srv.URL + "/api", Base: srv.URL + "/dl", HTTP: srv.Client()}
+}
+
+func TestDownloadVerifiesAndExtracts(t *testing.T) {
+ ctx := context.Background()
+ src := Fake(t, "v9.9.9", []byte("new binary"), false)
+ tag, err := src.Latest(ctx)
+ if err != nil || tag != "v9.9.9" {
+ t.Fatalf("%q %v", tag, err)
+ }
+ bin, err := src.Download(ctx, tag)
+ if err != nil || string(bin) != "new binary" {
+ t.Fatalf("%q %v", bin, err)
+ }
+ if _, err := Fake(t, "v9.9.9", []byte("x"), true).Download(ctx, "v9.9.9"); err == nil || !strings.Contains(err.Error(), "checksum") {
+ t.Fatalf("a bad checksum must refuse: %v", err)
+ }
+ if _, err := src.Download(ctx, "v1.0.0"); err == nil {
+ t.Fatal("an unknown tag must fail")
+ }
+}
+
+func TestReplaceKeepsTheModeAndSwapsAtomically(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "atlas")
+ _ = os.WriteFile(p, []byte("old"), 0o700)
+ if err := Replace(p, []byte("new")); err != nil {
+ t.Fatal(err)
+ }
+ b, _ := os.ReadFile(p)
+ fi, _ := os.Stat(p)
+ if string(b) != "new" || fi.Mode().Perm() != 0o711 {
+ t.Fatalf("%q %v", b, fi.Mode())
+ }
+ left, _ := filepath.Glob(filepath.Join(filepath.Dir(p), ".atlas-update-*"))
+ if len(left) != 0 {
+ t.Fatalf("temp files left: %v", left)
+ }
+}
+
+func TestNewer(t *testing.T) {
+ for _, c := range []struct {
+ cur, tag string
+ want bool
+ }{
+ {"0.3.4", "v0.4.0", true}, {"0.4.0", "v0.4.0", false}, {"0.4.1", "v0.4.0", false},
+ {"0.4.0-dev", "v0.4.0", true}, {"dev", "v0.1.0", true}, {"0.10.0", "v0.9.9", false},
+ {"1.0.0", "v1.0.1", true}, {"0.4.0", "nonsense", false},
+ } {
+ if got := Newer(c.cur, c.tag); got != c.want {
+ t.Errorf("Newer(%q, %q) = %v", c.cur, c.tag, got)
+ }
+ }
+}