Просмотр исходного кода

feat(ui): saved trails — a walk you named, kept, and still true after a re-index (CG-60)

Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.

A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.

This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 неделя назад
Родитель
Сommit
47576b392e

+ 7 - 1
CHANGELOG.md

@@ -18,7 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   Run `codegraph ui` in an indexed project, or `codegraph ui /path/to/project` for one indexed elsewhere (`codegraph web` is an alias). It takes port 4747, or the next free one; `--port <n>` pins a specific port and `--no-open` just prints the URL for a headless box or an SSH session. Set `CODEGRAPH_BROWSER=<command>` to choose the browser, or `CODEGRAPH_BROWSER=none` to never open one.
 
-  The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It is read-only: it opens an index that already exists, never creates one, and never writes to your project or your graph. It sends nothing anywhere.
+  The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It opens an index that already exists, never creates one, and never changes your graph or a line of your code — the one thing it writes is a trail you asked it to save (see below), and `--read-only` turns even that off. It sends nothing anywhere.
 
 - **A map of the whole project, in `codegraph ui`.** The Map tab draws your repository at module granularity — one box per directory — with dependencies pointing down, so the top of the picture is what runs first and the bottom is what everything else stands on. Nothing is placed by hand and nothing floats: a module sits one layer above whatever it depends on, line weight is how many calls, imports and type references cross the link, and the same project always draws the same picture. Hover a link for what crosses it, including the busiest symbol pairs behind the weight; click a module to isolate its links, list its dependencies and dependents with counts, and jump straight into one of its files.
 
@@ -80,6 +80,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   On the **Map**, a module nothing depends on now says so in its own count line instead of counting itself — usually your entry points, sometimes something you forgot to delete. Tool-generated files and modules are dimmed wherever they appear: on the map, in its file list, in search results and on the file screen.
 
+- **Keep a walk you want to come back to: saved trails in `codegraph ui`.** Follow a path through the code, press **Save trail** on the trail bar, give it a name, and it's kept. Saved trails are listed on the empty screen and on the Entry points tab, above the suggestions — a walk somebody named beats any ranking — and opening one puts you back at the symbol you left with the whole path in the trail bar. Explaining "how a request is served" to a new teammate is now a link and a name rather than a paragraph.
+
+  A saved trail survives your project changing. Each step is remembered by what it is — its qualified name, its kind, the file it was in — rather than by where it sat, so editing the file above a function doesn't lose it. When something does move, the trail says so on its own row: which step moved to another file, which one was renamed or deleted, and which part of the walk still opens. It never quietly stitches over a missing step, because a trail is a path and a step that skips one would show a call that doesn't exist.
+
+  Trails are plain JSON, one file per trail, under `.codegraph/ui/trails/` — already ignored by git, so they stay yours by default. **Export** hands you the file if you'd rather commit one for the team. This is the only thing the viewer writes: it still never indexes, never changes your graph, and never touches a line of your code. Start it with `codegraph ui --read-only` and it won't write even that — saved trails can still be opened, just not saved or deleted.
+
 ### Fixes
 
 - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.

+ 7 - 5
README.md

@@ -351,6 +351,7 @@ What you get on that screen:
 - **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it.
 - **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
 - **Take the picture with you.** A flow strip or a map can be copied as an image straight into a pull-request comment, or saved as an SVG for a README — always in the light theme, whichever one you are reading in, with a caption saying what the picture is. The SVG is real text, so it stays sharp at any size and the names in it are selectable.
+- **Keep a walk.** Press **Save trail** on the trail bar, name it, and the path is kept — listed on the empty screen and on Entry points, above the suggestions, and reopened at the symbol you left with the whole walk restored. Steps are remembered by what they are, not where they sat, so a saved trail survives editing the code it describes; when something does move it says which step moved, which was renamed away, and how much of the walk still opens. Trails are plain JSON under `.codegraph/ui/trails/` (git already ignores it), and **Export** hands you the file if you would rather commit one.
 - **It keeps up.** Save a file and a banner appears within about a third of a second saying the index hasn't caught up yet — and the screen switches to the file's current source rather than a body sliced at lines it no longer has. When something re-indexes, whatever is on screen refetches itself and says "Index updated · reloaded". A symbol that moved because you added a line above it is followed, not lost. Nothing polls: the viewer watches, and if it loses touch with the server it retries a few times and then says so instead of hammering it.
 
 Options: `--port <n>` to pin a port (without it the viewer takes 4747, or the next free one),
@@ -359,10 +360,11 @@ Options: `--port <n>` to pin a port (without it the viewer takes 4747, or the ne
 `codegraph web` is an alias for the same command.
 
 **Privacy:** the viewer listens on `127.0.0.1` only, so nothing on your network can reach it,
-and requests claiming to come from any other host are refused. It is read-only — it opens an
-index that already exists, never writes to your project or your graph, and never creates an
-index. **It sends nothing anywhere**: no code, no paths, no analytics. There is no account and
-no cloud in this feature at all.
+and requests claiming to come from any other host are refused. It opens an index that already
+exists, never creates one, and never changes your graph or a line of your code. The one thing
+it writes is a trail you asked it to save, into `.codegraph/ui/trails/`; `codegraph ui
+--read-only` refuses even that. **It sends nothing anywhere**: no code, no paths, no analytics.
+There is no account and no cloud in this feature at all.
 
 The viewer reads an index that already exists — it never creates one — so `codegraph init` has
 to have run first. `codegraph ui /path/to/project` points it at a project you indexed elsewhere.
@@ -573,7 +575,7 @@ codegraph uninit [path]           # Remove CodeGraph from a project (--force to
 codegraph index [path]            # Full index (--force to re-index, --quiet for less output)
 codegraph sync [path]             # Incremental update
 codegraph status [path]           # Show statistics
-codegraph ui [path]               # Open the browser viewer for an indexed project (alias: web; --port, --no-open)
+codegraph ui [path]               # Open the browser viewer for an indexed project (alias: web; --port, --no-open, --read-only)
 codegraph unlock [path]           # Remove a stale lock file that's blocking indexing
 codegraph query <search>          # Search symbols (--kind, --limit, --json)
 codegraph explore <query>         # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)

+ 38 - 2
__tests__/ui-package.test.ts

@@ -28,6 +28,7 @@ import {
   FlowStrip,
   SearchPalette,
   SymbolView,
+  SavedTrails,
   TrailBar,
   TypeHierarchy,
   createHttpAdapter,
@@ -406,8 +407,22 @@ function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
         corroborated: true,
         timing: { elapsedMs: 1 },
       }),
-    // Deliberately no `events`: a host without a live channel is the normal
-    // case, and nothing may poll in its absence.
+    trails: () =>
+      seen('trails', {
+        trails: [],
+        // A host with nowhere to keep trails still ANSWERS the question — it
+        // says it is read-only rather than omitting the method, so the screens
+        // show the section explained instead of showing a Save that does
+        // nothing.
+        readOnly: true,
+        readOnlyReason: 'This host does not store trails.',
+        directory: '.codegraph/ui/trails',
+        skipped: 0,
+        bounded: false,
+      }),
+    // Deliberately no `events`, `saveTrail` or `deleteTrail`: a host without a
+    // live channel and without anywhere to write is the normal case, and
+    // nothing may poll or offer to save in their absence.
   };
   return { adapter, calls };
 }
@@ -601,6 +616,27 @@ describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
     expect(host.querySelector('input[role="combobox"]')).not.toBeNull();
   });
 
+  it('offers no Save when the adapter cannot write, and says why in the list', async () => {
+    const { adapter } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
+    await render(TrailBar, {});
+    // The one screen affordance that must never appear against a read-only
+    // host: an adapter with no `saveTrail` has no button, not a button that
+    // fails.
+    expect(host.textContent ?? '').not.toContain('Save trail');
+
+    void unmount(mounted as Record<string, unknown>);
+    mounted = null;
+    host.innerHTML = '';
+
+    await render(SavedTrails, { hideWhenEmpty: false });
+    const text = host.textContent ?? '';
+    expect(text).toContain('Saved trails');
+    expect(text).toContain('This host does not store trails.');
+  });
+
   it('CodegraphUi installs the adapter before its children ask for data', async () => {
     const { adapter, calls } = mockAdapter();
     // NOT installed by hand — the provider is the only thing that installs it.

+ 4 - 1
__tests__/ui-server-api.test.ts

@@ -258,7 +258,10 @@ afterAll(async () => {
 describe('GET /api', () => {
   it('lists the endpoints it answers', async () => {
     const body = await getJson('/api');
-    expect(body.readOnly).toBe(true);
+    // Not a blanket claim any more (CG-60): saved trails are the one thing
+    // this server writes, and it names it rather than implying there is none.
+    expect(body.readOnly).toBe(false);
+    expect(body.writes).toEqual(['POST /api/trails', 'DELETE /api/trails/<id>']);
     const paths = body.endpoints.map((e: any) => e.path);
     expect(paths).toEqual(
       expect.arrayContaining([

+ 33 - 3
__tests__/ui-server.test.ts

@@ -266,14 +266,44 @@ describe('codegraph ui server', () => {
     });
   });
 
-  describe('read-only', () => {
-    it('refuses every method that is not GET or HEAD', async () => {
-      for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
+  describe('methods', () => {
+    it('refuses every method it has never answered', async () => {
+      for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) {
         const res = await request(server.port, '/', { method });
         expect(res.status, method).toBe(405);
+        expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE');
+      }
+    });
+
+    /**
+     * The static side stayed a pure reader when `/api/trails` gained a write
+     * (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the
+     * narrower answer, since nothing under the viewer bundle will ever take
+     * one.
+     */
+    it('refuses a write outside /api/, whatever it carries', async () => {
+      for (const method of ['POST', 'DELETE']) {
+        const res = await request(server.port, '/', {
+          method,
+          headers: { 'X-CodeGraph-UI': '1' },
+        });
+        expect(res.status, method).toBe(405);
         expect(res.headers['allow']).toBe('GET, HEAD');
       }
     });
+
+    /**
+     * Under `/api/` a write is answered as JSON even when refused — the viewer
+     * parses these, and a text/plain body surfaces as a parse error rather than
+     * the refusal it is. No API is mounted on this server, so the refusal is
+     * the boundary's own and not an endpoint's.
+     */
+    it('refuses an unmarked write under /api/ as JSON', async () => {
+      const res = await request(server.port, '/api/trails', { method: 'POST' });
+      expect(res.status).toBe(403);
+      expect(res.headers['content-type']).toContain('application/json');
+      expect(JSON.parse(res.body).code).toBe('refused');
+    });
   });
 
   describe('paths outside the asset root', () => {

+ 179 - 0
__tests__/ui-trails-model.test.ts

@@ -0,0 +1,179 @@
+/**
+ * What a saved trail's row says, without a browser (CG-60).
+ *
+ * The endpoint's own behaviour is pinned in `ui-trails.test.ts` against a real
+ * index; this is the wording layer, and the rule it exists to protect is that
+ * **a trail that has decayed never reads as intact**. A saved trail is somebody's
+ * explanation of a codebase that has since moved underneath it, and a row that
+ * prints "6 hops" while two of them are gone is a lie by omission at exactly the
+ * moment the trail needs fixing.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  hopStatusWord,
+  isOpenable,
+  replacedTrail,
+  trailDecay,
+  trailExport,
+  trailMeta,
+  trailNameProblem,
+  trailOpens,
+  trailTitle,
+} from '../ui/src/lib/trails-model';
+import type { WireTrail, WireTrailHop, WireTrailHopStatus } from '../ui/src/lib/wire';
+
+function hop(
+  name: string,
+  status: WireTrailHopStatus = 'ok',
+  dir: WireTrailHop['dir'] = 'down'
+): WireTrailHop {
+  const alive = status !== 'missing';
+  return {
+    dir,
+    name,
+    qualifiedName: name,
+    kind: 'function',
+    savedFile: 'src/a.ts',
+    savedLine: 10,
+    status,
+    id: alive ? `function:${name}` : null,
+    file: alive ? 'src/a.ts' : null,
+    line: alive ? 10 : null,
+    note: status === 'ok' ? null : `${name} ${status}`,
+  };
+}
+
+function trail(hops: WireTrailHop[], over: Partial<WireTrail> = {}): WireTrail {
+  const resolved = hops.filter((h) => h.id !== null);
+  return {
+    id: 'a-walk',
+    name: 'A walk',
+    note: '',
+    author: 'Ada',
+    createdAt: '2026-08-01T00:00:00.000Z',
+    updatedAt: '2026-08-02T00:00:00.000Z',
+    hops,
+    resolved: resolved.length,
+    intact: hops.every((h) => h.status === 'ok'),
+    encoded: resolved.length > 0 ? resolved.map((h) => `d${h.id}`).join(',') : null,
+    openFrom: 1,
+    openCount: resolved.length,
+    openId: resolved.length > 0 ? (resolved[resolved.length - 1] as WireTrailHop).id : null,
+    ...over,
+  };
+}
+
+describe('trailMeta', () => {
+  it('reports the SAVED length, whatever became of the hops', () => {
+    const decayed = trail([hop('a', 'ok', 'start'), hop('b', 'missing'), hop('c')]);
+    expect(trailMeta(decayed)).toBe('3 hops · Ada');
+  });
+
+  it('drops the author when there is not one', () => {
+    expect(trailMeta(trail([hop('a', 'ok', 'start')], { author: '' }))).toBe('1 hop');
+  });
+});
+
+describe('trailDecay', () => {
+  it('is null for a trail nothing has happened to', () => {
+    expect(trailDecay(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
+  });
+
+  it('warns about hops that are gone, naming them', () => {
+    const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('gone', 'missing')]));
+    expect(decay?.tone).toBe('warn');
+    expect(decay?.text).toContain('1 hop moved or renamed');
+    expect(decay?.text).toContain('gone');
+  });
+
+  it('caps how many it names', () => {
+    const hops = ['a', 'b', 'c', 'd', 'e'].map((n) => hop(n, 'missing'));
+    const decay = trailDecay(trail(hops));
+    expect(decay?.text).toContain('and 2 more');
+  });
+
+  it('notes a move without warning about it — a moved hop still opens', () => {
+    const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'moved')]));
+    expect(decay?.tone).toBe('note');
+    expect(decay?.text).toContain('moved to another file');
+  });
+
+  it('puts a missing hop ahead of a merely moved one', () => {
+    const decay = trailDecay(trail([hop('m', 'moved'), hop('g', 'missing')]));
+    expect(decay?.text).toContain('moved or renamed');
+  });
+
+  it('warns about an ambiguous hop — the trail may no longer mean what it said', () => {
+    const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'ambiguous')]));
+    expect(decay?.tone).toBe('warn');
+    expect(decay?.text).toContain('more than one symbol');
+  });
+});
+
+describe('trailOpens', () => {
+  it('says nothing when the whole trail opens', () => {
+    expect(trailOpens(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
+  });
+
+  it('names the range when only part of it does', () => {
+    const partial = trail([hop('a'), hop('b'), hop('c')], {
+      openFrom: 2,
+      openCount: 2,
+    });
+    expect(trailOpens(partial)).toBe('Opens hops 2–3 of 3.');
+  });
+
+  it('says so plainly when nothing resolves', () => {
+    const dead = trail([hop('a', 'missing')], { encoded: null, openCount: 0, openId: null });
+    expect(trailOpens(dead)).toContain('None of this trail resolves');
+    expect(isOpenable(dead)).toBe(false);
+  });
+});
+
+describe('trailTitle', () => {
+  it('draws the whole walk with its arrows, and when it was saved', () => {
+    const walked = trail([hop('a', 'ok', 'start'), hop('b', 'ok', 'down'), hop('c', 'ok', 'up')]);
+    expect(trailTitle(walked)).toBe('a → b ← c — saved 2026-08-02');
+  });
+});
+
+describe('saving', () => {
+  it('refuses an empty or over-long name before the round-trip', () => {
+    expect(trailNameProblem('   ', 120)).toContain('name');
+    expect(trailNameProblem('x'.repeat(121), 120)).toContain('too long');
+    expect(trailNameProblem('ok', 120)).toBeNull();
+  });
+
+  it('spots the trail a name would replace, whitespace and all', () => {
+    const list = [trail([hop('a', 'ok', 'start')], { name: 'A walk' })];
+    expect(replacedTrail('  A   walk  ', list)?.name).toBe('A walk');
+    expect(replacedTrail('Another walk', list)).toBeNull();
+  });
+});
+
+describe('trailExport', () => {
+  it('exports the SAVED identity of each hop, not today’s resolution', () => {
+    const moved = trail([hop('a', 'ok', 'start'), hop('b', 'moved')]);
+    const raw = JSON.parse(trailExport(moved));
+    expect(raw.version).toBe(1);
+    // `savedFile`, so dropping the file into another checkout re-runs the same
+    // resolution rather than baking this index's answer in.
+    expect(raw.hops[1].file).toBe('src/a.ts');
+    expect(raw.hops[1].qualifiedName).toBe('b');
+    expect(raw.hops.map((h: { dir: string }) => h.dir)).toEqual(['start', 'down']);
+  });
+
+  it('survives a hop with no id at all', () => {
+    const raw = JSON.parse(trailExport(trail([hop('gone', 'missing')])));
+    expect(raw.hops[0].id).toBe('');
+  });
+});
+
+describe('hopStatusWord', () => {
+  it('has a word for every status', () => {
+    for (const status of ['ok', 'moved', 'ambiguous', 'missing'] as const) {
+      expect(hopStatusWord(status)).toBeTruthy();
+    }
+  });
+});

+ 562 - 0
__tests__/ui-trails.test.ts

@@ -0,0 +1,562 @@
+/**
+ * Saved trails (CG-60) — the viewer's only write.
+ *
+ * Two things are worth a real end-to-end fixture rather than a unit test, and
+ * they are the two the feature exists for:
+ *
+ * 1. **A trail survives a re-index.** The suite indexes a project, saves a
+ *    trail, then EDITS the files so every node id changes (a symbol shifts down
+ *    a file, another moves to a different file, a third is deleted), re-indexes,
+ *    and asserts the trail still opens and says what became of each hop. That
+ *    cannot be faked: node ids contain a start line, so the ids really do all
+ *    change.
+ * 2. **The write boundary.** `POST` without the marker header, from a foreign
+ *    `Origin`, or against a `--read-only` server has to be refused — by a real
+ *    loopback server, because the refusals live in the request handler and not
+ *    in the endpoint.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import {
+  encodeResolvedRun,
+  isTrailId,
+  parseTrail,
+  slugify,
+  TRAILS_RELATIVE_DIR,
+  type WireTrailHop,
+} from '../src/ui-server/api';
+
+interface Res {
+  status: number;
+  headers: http.IncomingHttpHeaders;
+  body: any;
+}
+
+let tempDir: string;
+let projectRoot: string;
+let viewerDir: string;
+let api: GraphApi;
+let server: UiServerHandle;
+let readOnlyApi: GraphApi;
+let readOnlyServer: UiServerHandle;
+
+interface CallOptions {
+  method?: string;
+  body?: unknown;
+  /** Send the write marker header. On by default for a write. */
+  marker?: boolean;
+  contentType?: string | null;
+  origin?: string;
+}
+
+/**
+ * One request against a live server.
+ *
+ * `http.request` rather than `fetch` so `Host` is ours to set — undici treats
+ * it as a forbidden header, and the `Host` allowlist is half of what is being
+ * tested here.
+ */
+function callOn(port: number, requestPath: string, opts: CallOptions = {}): Promise<Res> {
+  const method = opts.method ?? 'GET';
+  const isWrite = method === 'POST' || method === 'DELETE';
+  const payload = opts.body === undefined ? null : Buffer.from(JSON.stringify(opts.body), 'utf-8');
+  const headers: Record<string, string> = { Host: `127.0.0.1:${port}` };
+  if (isWrite && (opts.marker ?? true)) headers['X-CodeGraph-UI'] = '1';
+  if (opts.origin) headers['Origin'] = opts.origin;
+  if (payload) {
+    const type = opts.contentType === undefined ? 'application/json' : opts.contentType;
+    if (type !== null) headers['Content-Type'] = type;
+    headers['Content-Length'] = String(payload.length);
+  }
+
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      { host: '127.0.0.1', port, path: requestPath, method, headers, setHost: false },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () => {
+          const text = Buffer.concat(chunks).toString('utf-8');
+          let parsed: unknown = text;
+          try {
+            parsed = JSON.parse(text);
+          } catch {
+            /* a text/plain refusal is a legitimate answer on the static side */
+          }
+          resolve({ status: res.statusCode ?? 0, headers: res.headers, body: parsed });
+        });
+      }
+    );
+    req.on('error', reject);
+    if (payload) req.write(payload);
+    req.end();
+  });
+}
+
+function call(requestPath: string, opts: CallOptions = {}): Promise<Res> {
+  return callOn(server.port, requestPath, opts);
+}
+
+/** The id of a fixture symbol, looked up through the API itself. */
+async function idOf(name: string): Promise<string> {
+  const res = await call(`/api/search?q=${encodeURIComponent(name)}`);
+  const hit = res.body.results.items.find((r: any) => r.name === name);
+  expect(hit, `no symbol named ${name}`).toBeTruthy();
+  return hit.id as string;
+}
+
+function trailsDir(): string {
+  return path.join(projectRoot, TRAILS_RELATIVE_DIR);
+}
+
+/** Re-index in place, the way a `codegraph sync` would after an edit. */
+async function reindex(): Promise<void> {
+  const cg = CodeGraph.openSync(projectRoot);
+  await cg.sync();
+  cg.resolveReferences();
+  cg.close();
+}
+
+const SRC = () => path.join(projectRoot, 'src');
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trails-'));
+  projectRoot = path.join(tempDir, 'project');
+  fs.mkdirSync(SRC(), { recursive: true });
+
+  fs.writeFileSync(
+    path.join(SRC(), 'handler.ts'),
+    `import { load } from './service';
+
+export function handleRequest(key: string): string {
+  return load(key);
+}
+`
+  );
+  fs.writeFileSync(
+    path.join(SRC(), 'service.ts'),
+    `import { read } from './cache';
+
+export function load(key: string): string {
+  return read(key);
+}
+
+export function retired(): string {
+  return 'nothing calls me after the edit';
+}
+`
+  );
+  fs.writeFileSync(
+    path.join(SRC(), 'cache.ts'),
+    `export function read(key: string): string {
+  return key;
+}
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+
+  readOnlyApi = createGraphApi({
+    projectRoot,
+    readOnly: true,
+    readOnlyReason: 'This viewer was started with --read-only, so trails cannot be saved.',
+  });
+  readOnlyServer = await startUiServer({
+    projectRoot,
+    viewerDir,
+    port: 0,
+    api: readOnlyApi.handler,
+  });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  readOnlyApi?.close();
+  await server?.close();
+  await readOnlyServer?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+/* ------------------------------------------------------------- pure bits -- */
+
+describe('trail ids', () => {
+  it('slugs a name into something that is a filename and not a path', () => {
+    expect(slugify('How a request reaches the handler')).toBe(
+      'how-a-request-reaches-the-handler'
+    );
+    expect(slugify('  Spaces   and --- dashes  ')).toBe('spaces-and-dashes');
+    expect(slugify('../../etc/passwd')).toBe('etc-passwd');
+    // A name with no ASCII word characters still has to produce a valid id.
+    expect(slugify('日本語')).toBe('trail');
+    expect(isTrailId(slugify('../../etc/passwd'))).toBe(true);
+  });
+
+  it('refuses anything that is not a slug', () => {
+    for (const bad of ['..', 'a/b', 'A', 'has.dot', '-leading', '', 'a b']) {
+      expect(isTrailId(bad), bad).toBe(false);
+    }
+  });
+});
+
+describe('parseTrail', () => {
+  it('rejects a file that is not a trail rather than half-reading it', () => {
+    expect(parseTrail('x', 'not json')).toBeNull();
+    expect(parseTrail('x', '[]')).toBeNull();
+    expect(parseTrail('x', '{"name":"a"}')).toBeNull();
+    expect(parseTrail('x', '{"name":"a","hops":[]}')).toBeNull();
+    expect(parseTrail('x', '{"name":"","hops":[{"qualifiedName":"a"}]}')).toBeNull();
+  });
+
+  it('takes its id from the FILE, not from the field inside it', () => {
+    const trail = parseTrail('on-disk', '{"name":"a","id":"remembered","hops":[{"name":"f"}]}');
+    expect(trail?.id).toBe('on-disk');
+  });
+});
+
+describe('encodeResolvedRun', () => {
+  const hop = (id: string | null, dir: 'start' | 'down' | 'up' = 'down'): WireTrailHop => ({
+    dir,
+    name: id ?? 'gone',
+    qualifiedName: id ?? 'gone',
+    kind: 'function',
+    savedFile: 'src/a.ts',
+    savedLine: 1,
+    status: id ? 'ok' : 'missing',
+    id,
+    file: id ? 'src/a.ts' : null,
+    line: id ? 1 : null,
+    note: null,
+  });
+
+  it('never stitches across a hole — it takes the longest consecutive run', () => {
+    const run = encodeResolvedRun([hop('a', 'start'), hop(null), hop('c'), hop('d')]);
+    expect(run.encoded).toBe('sc,dd');
+    expect(run.openFrom).toBe(3);
+    expect(run.openCount).toBe(2);
+    expect(run.openId).toBe('d');
+  });
+
+  it('writes the run’s first hop as a start, whatever it was saved as', () => {
+    const run = encodeResolvedRun([hop(null), hop('b', 'up')]);
+    expect(run.encoded).toBe('sb');
+  });
+
+  it('answers nothing when nothing resolves', () => {
+    expect(encodeResolvedRun([hop(null), hop(null)])).toEqual({
+      encoded: null,
+      openFrom: 0,
+      openCount: 0,
+      openId: null,
+    });
+  });
+});
+
+/* ----------------------------------------------------------- the endpoint -- */
+
+describe('GET /api/trails', () => {
+  it('is an empty list, not an error, before anything is saved', async () => {
+    const res = await call('/api/trails');
+    expect(res.status).toBe(200);
+    expect(res.body.trails).toEqual([]);
+    expect(res.body.readOnly).toBe(false);
+    expect(res.body.directory).toBe(TRAILS_RELATIVE_DIR);
+  });
+
+  it('is listed by GET /api', async () => {
+    const res = await call('/api');
+    expect(res.body.endpoints.some((e: any) => e.path === '/api/trails')).toBe(true);
+    // The old blanket claim is gone: the server writes exactly one thing.
+    expect(res.body.readOnly).toBe(false);
+    expect(res.body.writes).toContain('POST /api/trails');
+  });
+});
+
+describe('POST /api/trails', () => {
+  it('saves the walk and answers with the whole list', async () => {
+    const hops = [
+      { dir: 'start', id: await idOf('handleRequest') },
+      { dir: 'down', id: await idOf('load') },
+      { dir: 'down', id: await idOf('read') },
+    ];
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: { name: 'How a request is served', note: 'the whole path', hops },
+    });
+
+    expect(res.status).toBe(200);
+    expect(res.body.saved).toBe('how-a-request-is-served');
+    expect(res.body.replaced).toBe(false);
+    expect(res.body.trails).toHaveLength(1);
+
+    const trail = res.body.trails[0];
+    expect(trail.name).toBe('How a request is served');
+    expect(trail.note).toBe('the whole path');
+    expect(trail.intact).toBe(true);
+    expect(trail.resolved).toBe(3);
+    expect(trail.openCount).toBe(3);
+    expect(trail.hops.map((h: any) => h.name)).toEqual(['handleRequest', 'load', 'read']);
+    // The identity that survives an edit, recorded beside the id hint.
+    expect(trail.hops[1].qualifiedName).toBe('load');
+    expect(trail.hops[1].savedFile).toBe('src/service.ts');
+  });
+
+  it('writes one readable JSON file into .codegraph/ui/trails', () => {
+    const file = path.join(trailsDir(), 'how-a-request-is-served.json');
+    expect(fs.existsSync(file)).toBe(true);
+    const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
+    expect(raw.version).toBe(1);
+    expect(raw.hops).toHaveLength(3);
+    expect(raw.hops[0].qualifiedName).toBe('handleRequest');
+    expect(typeof raw.createdAt).toBe('string');
+    // Nothing but trails lands there — no temp file survives the rename.
+    expect(fs.readdirSync(trailsDir())).toEqual(['how-a-request-is-served.json']);
+  });
+
+  it('replaces a trail saved under the same name, keeping its createdAt', async () => {
+    const before = (await call('/api/trails')).body.trails[0];
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: {
+        name: 'How a request is served',
+        hops: [{ dir: 'start', id: await idOf('handleRequest') }],
+      },
+    });
+    expect(res.body.replaced).toBe(true);
+    expect(res.body.trails).toHaveLength(1);
+    expect(res.body.trails[0].createdAt).toBe(before.createdAt);
+    expect(res.body.trails[0].hops).toHaveLength(1);
+    expect(res.body.trails[0].note).toBe('');
+  });
+
+  it('gives a different name its own file rather than colliding', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: { name: 'How a request is served!', hops: [{ dir: 'start', id: await idOf('load') }] },
+    });
+    expect(res.body.saved).toBe('how-a-request-is-served-2');
+    expect(res.body.trails).toHaveLength(2);
+  });
+
+  it('refuses a hop the index does not hold', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: { name: 'invented', hops: [{ dir: 'start', id: 'function:not-a-real-id' }] },
+    });
+    expect(res.status).toBe(400);
+    expect(res.body.error).toContain('Hop 1 is not in the index');
+  });
+
+  it('refuses a nameless or hopless trail', async () => {
+    const noName = await call('/api/trails', { method: 'POST', body: { name: '  ', hops: [] } });
+    expect(noName.status).toBe(400);
+    const noHops = await call('/api/trails', { method: 'POST', body: { name: 'x', hops: [] } });
+    expect(noHops.status).toBe(400);
+    expect(noHops.body.error).toContain('at least one hop');
+  });
+});
+
+describe('DELETE /api/trails/<id>', () => {
+  it('removes the file and answers with the list that is left', async () => {
+    const res = await call('/api/trails/how-a-request-is-served-2', { method: 'DELETE' });
+    expect(res.status).toBe(200);
+    expect(res.body.deleted).toBe('how-a-request-is-served-2');
+    expect(res.body.trails).toHaveLength(1);
+    expect(fs.existsSync(path.join(trailsDir(), 'how-a-request-is-served-2.json'))).toBe(false);
+  });
+
+  it('is a 404 for a trail that is not there', async () => {
+    const res = await call('/api/trails/never-existed', { method: 'DELETE' });
+    expect(res.status).toBe(404);
+  });
+
+  it('refuses an id shaped like a path before it is joined to anything', async () => {
+    const res = await call('/api/trails/..%2f..%2fetc%2fpasswd', { method: 'DELETE' });
+    // The `..` segments are caught on the RAW url, before WHATWG parsing folds
+    // them away — a traversal attempt is a 404, never the app shell.
+    expect([400, 404]).toContain(res.status);
+    expect(res.headers['content-type']).toContain('application/json');
+  });
+});
+
+/* ------------------------------------------------------- the write boundary */
+
+describe('the write boundary', () => {
+  it('refuses a POST without the marker header', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      marker: false,
+      body: { name: 'forged', hops: [] },
+    });
+    expect(res.status).toBe(403);
+    expect(res.body.code).toBe('refused');
+    expect(String(res.body.error)).toContain('x-codegraph-ui');
+  });
+
+  it('refuses a POST whose body claims to be a form', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      contentType: 'application/x-www-form-urlencoded',
+      body: { name: 'forged', hops: [] },
+    });
+    expect(res.status).toBe(403);
+    expect(String(res.body.error)).toContain('application/json');
+  });
+
+  it('refuses a POST from a foreign origin even with the marker', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      origin: 'https://evil.example',
+      body: { name: 'forged', hops: [] },
+    });
+    expect(res.status).toBe(403);
+  });
+
+  it('refuses a write anywhere but /api/, and still serves the asset on GET', async () => {
+    const post = await call('/index.html', { method: 'POST', body: { a: 1 } });
+    expect(post.status).toBe(405);
+    expect(post.headers.allow).toBe('GET, HEAD');
+    const get = await call('/index.html');
+    expect(get.status).toBe(200);
+  });
+
+  it('still refuses a method it has never answered', async () => {
+    const res = await call('/api/trails', { method: 'PUT' });
+    expect(res.status).toBe(405);
+  });
+
+  it('refuses every write under --read-only, but still lists what is there', async () => {
+    const list = await callOn(readOnlyServer.port, '/api/trails');
+    expect(list.status).toBe(200);
+    expect(list.body.readOnly).toBe(true);
+    expect(list.body.readOnlyReason).toContain('--read-only');
+    expect(list.body.trails.length).toBeGreaterThan(0);
+
+    const save = await callOn(readOnlyServer.port, '/api/trails', {
+      method: 'POST',
+      body: { name: 'nope', hops: [{ dir: 'start', id: 'x' }] },
+    });
+    expect(save.status).toBe(403);
+    expect(save.body.code).toBe('refused');
+
+    const remove = await callOn(readOnlyServer.port, '/api/trails/how-a-request-is-served', {
+      method: 'DELETE',
+    });
+    expect(remove.status).toBe(403);
+  });
+});
+
+/* ------------------------------------------------- surviving a re-index --- */
+
+describe('a saved trail survives a re-index', () => {
+  it('re-resolves hops by qualified name once every node id has changed', async () => {
+    // Save the three-hop walk again, plus a fourth hop that is about to be
+    // deleted outright, so one trail exercises every outcome at once.
+    const saved = await call('/api/trails', {
+      method: 'POST',
+      body: {
+        name: 'The whole walk',
+        hops: [
+          { dir: 'start', id: await idOf('handleRequest') },
+          { dir: 'down', id: await idOf('load') },
+          { dir: 'down', id: await idOf('read') },
+          { dir: 'down', id: await idOf('retired') },
+        ],
+      },
+    });
+    const before = saved.body.trails.find((t: any) => t.id === 'the-whole-walk');
+    expect(before.intact).toBe(true);
+    const idsBefore = before.hops.map((h: any) => h.id);
+
+    // Now move the world underneath it:
+    //  - `handleRequest` shifts down its file (a node id contains its start
+    //    line, so its id changes while it is the same symbol);
+    //  - `read` moves to a different file entirely;
+    //  - `retired` is deleted.
+    fs.writeFileSync(
+      path.join(SRC(), 'handler.ts'),
+      `import { load } from './service';
+
+// A comment inserted above the symbol. This alone renames it.
+// Another line.
+// And another.
+
+export function handleRequest(key: string): string {
+  return load(key);
+}
+`
+    );
+    fs.writeFileSync(
+      path.join(SRC(), 'service.ts'),
+      `import { read } from './store';
+
+export function load(key: string): string {
+  return read(key);
+}
+`
+    );
+    fs.writeFileSync(path.join(SRC(), 'cache.ts'), `export const unused = 1;\n`);
+    fs.writeFileSync(
+      path.join(SRC(), 'store.ts'),
+      `export function read(key: string): string {
+  return key;
+}
+`
+    );
+    await reindex();
+
+    const after = (await call('/api/trails')).body.trails.find(
+      (t: any) => t.id === 'the-whole-walk'
+    );
+
+    // Every id really did change — otherwise this test proves nothing.
+    const idsAfter = after.hops.map((h: any) => h.id);
+    expect(idsAfter[0]).not.toBe(idsBefore[0]);
+    expect(idsAfter[0]).toBeTruthy();
+
+    const [handle, load, read, retired] = after.hops;
+    expect(handle.status).toBe('ok');
+    expect(handle.file).toBe('src/handler.ts');
+    expect(handle.line).toBeGreaterThan(handle.savedLine);
+
+    expect(load.status).toBe('ok');
+
+    // Moved to another file: still resolved, and the row says where from.
+    expect(read.status).toBe('moved');
+    expect(read.savedFile).toBe('src/cache.ts');
+    expect(read.file).toBe('src/store.ts');
+    expect(read.note).toContain('src/cache.ts');
+    expect(read.note).toContain('src/store.ts');
+
+    // Deleted: named honestly, with no invented target.
+    expect(retired.status).toBe('missing');
+    expect(retired.id).toBeNull();
+    expect(retired.note).toContain('moved or renamed');
+
+    // And it still opens — the first three hops, not the fourth.
+    expect(after.intact).toBe(false);
+    expect(after.resolved).toBe(3);
+    expect(after.openFrom).toBe(1);
+    expect(after.openCount).toBe(3);
+    expect(after.openId).toBe(idsAfter[2]);
+    expect(after.encoded?.split(',')).toHaveLength(3);
+    expect(after.encoded?.startsWith('s')).toBe(true);
+  }, 120_000);
+});

+ 41 - 0
docs/design/codegraph-ui-design-spec.md

@@ -377,6 +377,47 @@ of an ancestor's member, names the language calls by itself, vendored directorie
 islands — the Map's job, not this list's), names the resolver failed to resolve somewhere, names shared with a symbol that IS
 referenced, and — the only rule that reads a file — names written more than once in a file that can reach them.
 
+### 3.12 Saved trails (CG-60)
+A **Save trail** button on the trail bar, and a list of what was saved on the empty screen and the entry-points panel.
+The viewer's only write.
+
+**Saving.** `Save trail` sits with `Read as flow` and `Clear` on the right of the trail bar — sans, `4px 8px`,
+`--rule-soft` border, same as its neighbours — and appears only once the trail has a hop and the answering side accepts
+writes. It opens a **one-field inline form** as a second row inside the bar (never a dialog: naming a walk is a thought the
+reader is already having, and anything modal stops the reading to ask about filing). The row is a 12px `--ink-2` sans label,
+a **30px** `--paper` input with a `--rule-soft` border exactly like the search box, `Save`/`Cancel`, and an 11.5px hint that
+says what will happen *before* it happens: `3 hops · saved to .codegraph/ui/trails`, or, in `--amber`,
+`Replaces the saved trail of the same name.` The name is pre-filled with the current symbol's; Escape closes; a failure
+(a read-only checkout, a full disk) prints in `--accent` beside the buttons rather than vanishing. The trail bar's grid row
+is `auto` for this — it keeps its 34px on its own and grows only while the form is open.
+
+**The list.** Rows follow the search-result grid — `18px | 1fr | auto`, kind glyph of the first hop, name 12.5px mono, then
+`N hops · author` 11px mono `--ink-3` — inside a `--rule-soft` box with `--rule-faint` between rows, so the empty screen
+reads as one list rather than two. Two 11px `--rule-soft` actions sit at the right of each row, always drawn and receding to
+`--ink-3` (a control that appears when the pointer arrives is one a keyboard reader has to guess at): `Export`, and `Delete`
+which arms to `Delete?` in `--accent`/`--accent-soft` before it removes anything. The section sits **above** "Where to start":
+a walk somebody named beats any ranking, when there is one. It draws nothing at all on the empty screen when there are no
+trails, and draws itself explained on the entry-points panel, which is where a reader goes looking for one.
+
+**The honesty line is the feature.** A saved trail is somebody's explanation of code that has since moved, so every hop is
+re-resolved against the current index on the way out and each row prints what became of it, in 11.5px under the name:
+`--amber` for *"1 hop moved or renamed since this was saved — parseToken no longer in the index."* or *"…now names more than
+one symbol — showing the closest match."*, `--ink-3` for a hop that merely moved file. Because the trail is a **path**, a hole
+in it cannot be stitched: the row opens the longest run of *consecutive* resolved hops and says so — `Opens hops 2–4 of 6.` —
+and a trail where nothing resolves is drawn `--ink-3` and is not clickable.
+
+**Where it lives.** One JSON file per trail under `.codegraph/ui/trails/<slug>.json`, written atomically (temp + rename),
+newest save first. `.codegraph/.gitignore` already ignores everything, so a trail is local by default; `Export` downloads the
+same file for a reader who wants to commit it somewhere. Each hop is stored as its **qualified name, kind and file** with the
+node id kept only as a fast path — a node id contains its start line, so a trail keyed on ids would break the first time
+anybody edited the code it describes, which is exactly when it matters. Saving under an existing name replaces that trail and
+keeps its `createdAt`.
+
+**What a write has to be.** `POST /api/trails` and `DELETE /api/trails/<id>`, under `/api/` and nowhere else, carrying the
+`X-CodeGraph-UI` header and `Content-Type: application/json` — neither of which a cross-origin form can produce without a
+CORS preflight this server answers none of. `--read-only` refuses both and the screens say so in the answering side's own
+words instead of showing a Save that fails.
+
 ## 4. Libraries and versions
 - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
   hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a

+ 1 - 0
scripts/check-ui-package.mjs

@@ -143,6 +143,7 @@ for (const name of [
   'ArchitectureMap',
   'DeadCodeView',
   'TrailBar',
+  'SavedTrails',
   'SearchPalette',
   'CodegraphUi',
   'setGraphAdapter',

+ 21 - 2
site/src/content/docs/guides/viewer.md

@@ -48,11 +48,27 @@ If the viewer ever loses touch with the server, it retries a handful of times wi
 - **Search** with `/` or Cmd-K: every symbol and file, grouped by kind, with signature and `file:line`. Arrow keys and Enter, no mouse needed.
 - **Entry points** on the opening screen, and in full on the **Entry points** tab (`e`) — see below.
 - **Typing a name also finds entry points.** They come back under their own heading below the symbol matches, so searching `payroll` returns the URL *with* the symbol that serves it, not just the URL.
-- **A trail** records the path you walked, with an arrow per hop showing whether you stepped into a call or up to a caller. Click any hop to jump back to it. The trail lives in the URL, so you can send someone the exact route you took.
+- **A trail** records the path you walked, with an arrow per hop showing whether you stepped into a call or up to a caller. Click any hop to jump back to it. The trail lives in the URL, so you can send someone the exact route you took — or press **Save trail** to keep it (see below).
 - **Keyboard:** arrow keys move within a column, left/right switch columns, Enter follows, Backspace steps back.
 
 Clicking any file path opens the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
 
+## Saved trails
+
+A trail you want to come back to is worth a name. Press **Save trail** on the trail bar, type one, and it is kept — listed on the opening screen and on the **Entry points** tab, above the derived suggestions. Opening one puts you back at the symbol you left with the whole walk restored in the bar. Explaining "how a request is served" to a new teammate becomes a name and a link.
+
+**A saved trail survives your project changing.** Each step is remembered by what it *is* — its qualified name, its kind, the file it was in — rather than by where it sat, so editing the file above a function does not lose it. When something does move, the row says so rather than quietly showing you something else:
+
+- a step that moved to another file still opens, and the row names both files;
+- a step that was renamed or deleted is called out by name, and the row says how much of the walk still opens (`Opens hops 2–4 of 6`);
+- a name now carried by several symbols is marked as a guess.
+
+A gap is never stitched over. The trail is a *path*, so a row opens the longest run of consecutive steps that still resolve — joining step 2 to step 4 would draw a call that does not exist.
+
+**Where they live.** One JSON file per trail under `.codegraph/ui/trails/`, which git already ignores, so trails are yours by default. **Export** on any row hands you the same file if you would rather commit one for the team; drop it back into that directory in another checkout and it re-resolves against *that* index.
+
+This is the only thing the viewer writes. Start it with `codegraph ui --read-only` and it will not write even this — saved trails can still be opened, just not saved or deleted.
+
 ## Entry points
 
 The first screen worth opening on a codebase you have never seen. Four lists, all read out of the graph rather than guessed from filenames:
@@ -124,13 +140,16 @@ An eight-hop strip comes out around half a megabyte, well inside what GitHub acc
 | `codegraph ui [path]` | Read a specific indexed project instead of the current directory |
 | `--port <n>` | Pin a port. Without it the viewer takes 4747, or the next free one |
 | `--no-open` | Print the URL instead of opening a browser (headless boxes, SSH) |
+| `--read-only` | Refuse every write — saved trails can be opened, but not saved or deleted |
 | `CODEGRAPH_BROWSER=<command>` | Choose which browser opens. `CODEGRAPH_BROWSER=none` never opens one |
 
 `codegraph web` is an alias for the same command.
 
 ## Privacy
 
-The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It is read-only: it opens an index that already exists, never creates one, and never writes to your project or your graph.
+The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It opens an index that already exists, never creates one, and never changes your graph or a line of your code.
+
+The one thing it writes is a trail you asked it to save, as JSON under `.codegraph/ui/trails/`. Nothing else it serves has a side effect, no other endpoint accepts a write, and `codegraph ui --read-only` refuses that one too.
 
 It sends nothing anywhere — no code, no paths, no analytics. The page in your browser talks only to the server on your own machine, and that server makes no outbound connections at all. See [Telemetry](https://github.com/colbymchenry/codegraph/blob/main/TELEMETRY.md) for the complete picture.
 

+ 2 - 1
site/src/content/docs/reference/cli.md

@@ -60,8 +60,9 @@ codegraph ui                     # the project you're standing in
 codegraph ui ~/code/my-app       # a project indexed elsewhere
 codegraph ui --port 8080         # pin a port (fails if it's taken)
 codegraph ui --no-open           # just print the URL (headless boxes, SSH)
+codegraph ui --read-only         # refuse every write, including saved trails
 ```
 
 Without `--port` it takes 4747, or the next free port. `CODEGRAPH_BROWSER=<command>` chooses which browser opens; `CODEGRAPH_BROWSER=none` never opens one. `codegraph web` is an alias.
 
-The viewer listens on `127.0.0.1` only and is read-only: it opens an index that already exists, never creates one, never writes to your project or your graph, and sends nothing anywhere.
+The viewer listens on `127.0.0.1` only: it opens an index that already exists, never creates one, never changes your graph or a line of your code, and sends nothing anywhere. The one thing it writes is a trail you asked it to save, under `.codegraph/ui/trails/`; `--read-only` refuses even that.

+ 34 - 12
src/bin/codegraph.ts

@@ -1856,8 +1856,10 @@ function printNoIndexGuidance(projectPath: string): void {
  * codegraph ui [path]  (alias: web)
  *
  * The browser reader: serves the built viewer (`dist/viewer/`) over loopback
- * and opens it. Read-only in every sense — it answers GET, it opens the index
- * for reading, and it never writes to the project or the graph.
+ * and opens it. It opens the index for reading and never writes to it, never
+ * indexes, and never changes a line of the project's code. The single thing it
+ * writes is a trail the reader saved, as JSON under `.codegraph/ui/trails/`;
+ * `--read-only` turns even that off.
  *
  * Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
  * banner tells the user nothing leaves their machine, so it must not be the
@@ -1870,6 +1872,7 @@ program
   .description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
   .option('--port <number>', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
   .option('--no-open', 'Print the URL instead of opening a browser')
+  .option('--read-only', 'Refuse every write — saved trails can be opened but not saved or deleted')
   .addHelpText(
     'after',
     `
@@ -1897,10 +1900,17 @@ The page keeps up with the project while it is open: save a file and it says so
 within about a third of a second, and whatever is on screen re-reads the graph
 when something re-indexes it. It watches for that; it never polls.
 
-The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
-and it is read-only: it opens an index that already exists and never changes
-your project or your graph. Requests from any other host are refused, and
-nothing is sent anywhere: no code, no paths, no analytics.
+Save a walk you want to keep: name the trail and it is written to
+.codegraph/ui/trails/ (already gitignored) as plain JSON, listed on the empty
+screen, and reopened at the symbol you left. Hops are remembered by name rather
+than by position, so a saved trail survives re-indexing and says which hop moved
+when one does. Pass --read-only to refuse every write.
+
+The viewer listens on 127.0.0.1 only, so nothing on your network can reach it.
+It opens an index that already exists, never indexes, and never changes a line
+of your code — the one thing it writes is a trail you asked it to save.
+Requests from any other host are refused, and nothing is sent anywhere: no code,
+no paths, no analytics.
 
 Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
 
@@ -1908,7 +1918,7 @@ Set ${BROWSER_ENV}=<command> to choose which browser opens, or
 ${BROWSER_ENV}=none to never open one.
 `
   )
-  .action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => {
+  .action(async (pathArg: string | undefined, options: { port?: string; open?: boolean; readOnly?: boolean }) => {
     // An explicit --port stays explicit: a scripted `--port 8080` that quietly
     // lands on 8081 is worse than one that says the port is busy. The default
     // port is the only one we're free to walk away from.
@@ -1942,10 +1952,17 @@ ${BROWSER_ENV}=none to never open one.
       '../ui-server'
     );
 
-    // The read-only JSON API the viewer reads its screens from. It opens the
-    // index lazily on the first request, so a slow first paint is the only cost
-    // of mounting it here rather than after the browser connects.
-    const api = createGraphApi({ projectRoot: projectPath });
+    // The JSON API the viewer reads its screens from. It opens the index lazily
+    // on the first request, so a slow first paint is the only cost of mounting
+    // it here rather than after the browser connects.
+    const readOnly = options.readOnly === true;
+    const api = createGraphApi({
+      projectRoot: projectPath,
+      readOnly,
+      readOnlyReason: readOnly
+        ? 'This viewer was started with --read-only, so trails cannot be saved.'
+        : undefined,
+    });
 
     let handle: UiServerHandle;
     try {
@@ -1968,7 +1985,12 @@ ${BROWSER_ENV}=none to never open one.
     console.log('');
     console.log(`  ${chalk.dim('Reading')}  ${projectPath}`);
     console.log(`  ${chalk.dim('URL')}      ${chalk.cyan(handle.url)}`);
-    console.log(`  ${chalk.dim('Access')}   this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`);
+    console.log(
+      `  ${chalk.dim('Access')}   this machine only ${getGlyphs().dash} ` +
+        (readOnly
+          ? 'read-only, nothing leaves your computer'
+          : 'nothing leaves your computer; saved trails are the only thing written')
+    );
     console.log('');
 
     const opened = options.open === false ? false : openBrowser(handle.url);

+ 15 - 0
src/index.ts

@@ -1373,6 +1373,21 @@ export class CodeGraph {
     return this.queries.getNodesByIds(ids);
   }
 
+  /**
+   * Every symbol carrying an exact qualified name.
+   *
+   * The identity that survives a re-index. A node's id contains its start line,
+   * so any edit ABOVE a symbol gives it a different id — anything that has to
+   * name the same symbol across two indexes (a saved trail, a bookmark, a
+   * review comment) has to key on this instead, and then disambiguate the
+   * result by kind and file. Index-backed; unlike
+   * {@link GraphQueryManager.findByQualifiedName} it takes no pattern and scans
+   * nothing.
+   */
+  getNodesByQualifiedName(qualifiedName: string): Node[] {
+    return this.queries.getNodesByQualifiedNameExact(qualifiedName);
+  }
+
   /**
    * Outgoing edges for many source nodes at once — the batch form of
    * {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.

+ 124 - 12
src/ui-server/api/index.ts

@@ -1,12 +1,17 @@
 /**
  * The read-only JSON API the viewer reads its screens from.
  *
- * Twelve endpoints, one per screen, each answering in a single round-trip — the
- * same principle as `codegraph_explore`: return enough that the caller does not
- * have to ask a follow-up question — plus one that does not answer at all and
- * stays open instead (`/api/events`), so a screen learns that its answer went
- * stale rather than waiting to be asked again. Everything here is a *reader* of
- * the existing schema; nothing indexes, resolves, or writes.
+ * Thirteen endpoints, one per screen, each answering in a single round-trip —
+ * the same principle as `codegraph_explore`: return enough that the caller does
+ * not have to ask a follow-up question — plus one that does not answer at all
+ * and stays open instead (`/api/events`), so a screen learns that its answer
+ * went stale rather than waiting to be asked again.
+ *
+ * All but one are *readers* of the existing schema; nothing here indexes or
+ * resolves. The exception is `/api/trails`, which saves the reader's own named
+ * walks as JSON under `.codegraph/ui/trails/` — the only write the viewer makes,
+ * to the only directory it may write to, and refused outright under
+ * `--read-only`. See `./trail-store.ts`.
  *
  * ```
  * GET /api/stats                     what this index is and how much to trust it
@@ -22,20 +27,25 @@
  * GET /api/deadcode                  symbols nothing reaches, and what was excluded
  * GET /api/flow?from=&to=            the flow strip: one card per hop
  * GET /api/events                    the live channel (SSE): drift and refresh
+ * GET /api/trails                    saved trails, re-resolved against the index
+ * POST /api/trails                   save one   (refused under --read-only)
+ * DELETE /api/trails/<id>            remove one (refused under --read-only)
  * ```
  *
  * It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
  * the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
- * CORS headers and the GET/HEAD restriction are already enforced by the time a
- * handler here runs. The one obligation that remains ours is the read
- * chokepoint — `resolveProjectFile` for anything that touches the repository —
- * and it lives in `source.ts`, the only module here that opens a file.
+ * CORS headers and the method restriction are already enforced by the time a
+ * handler here runs — including the extra shape a write has to have. The one
+ * obligation that remains ours is the path chokepoint, `resolveProjectFile` for
+ * anything that touches the repository. Two modules here reach the filesystem
+ * and no others: `source.ts` reads the project's code, and `trail-store.ts`
+ * reads and writes `.codegraph/ui/trails/`.
  */
 
 import type { UiApiHandler, UiRequestContext } from '../index';
 import { PathRefusalError } from '../security';
 import { GraphSession } from './session';
-import { ApiError, badRequest, fail, notFound, ok } from './respond';
+import { ApiError, badRequest, fail, notFound, ok, readJsonBody } from './respond';
 import { buildStats } from './stats';
 import { buildSearch } from './search';
 import { buildNode } from './node';
@@ -48,6 +58,7 @@ import { buildNodeRefs } from './nodes';
 import { buildMap } from './map';
 import { buildDeadCode } from './deadcode';
 import { buildFlow } from './flow';
+import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
 import { EventHub } from './events';
 
 export { GraphSession } from './session';
@@ -99,6 +110,28 @@ export type {
   WireDeadCodeRow,
 } from './deadcode';
 export { MAX_DEAD_CODE_MEMBERS, MAX_DEAD_CODE_ROWS } from './deadcode';
+export type {
+  WireTrail,
+  WireTrailHop,
+  WireTrailHopStatus,
+  WireTrails,
+  SaveTrailRequest,
+  TrailsOptions,
+} from './trails';
+export { buildTrails, encodeResolvedRun, resolveHop, resolveTrail } from './trails';
+export type { StoredHop, StoredTrail } from './trail-store';
+export {
+  MAX_TRAILS,
+  MAX_TRAIL_HOPS,
+  MAX_TRAIL_NAME,
+  MAX_TRAIL_NOTE,
+  TRAILS_RELATIVE_DIR,
+  TRAIL_FORMAT_VERSION,
+  isTrailId,
+  listStoredTrails,
+  parseTrail,
+  slugify,
+} from './trail-store';
 
 /**
  * A mounted API, plus the handle it holds open.
@@ -114,12 +147,29 @@ export interface GraphApi {
 export interface GraphApiOptions {
   /** Absolute path of the indexed project to read. */
   projectRoot: string;
+  /**
+   * Refuse every write, so the viewer is a pure reader again.
+   *
+   * The one thing it would otherwise write is a saved trail into
+   * `.codegraph/ui/trails/`. Turning this on is for a checkout that must not
+   * change (a review sandbox, a read-only mount, a shared machine); the viewer
+   * still lists trails that are already there, and says why Save is gone.
+   */
+  readOnly?: boolean;
+  /** The sentence shown in place of Save. Defaults to a generic one. */
+  readOnlyReason?: string;
 }
 
 /** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
 const API_INDEX = {
   name: 'codegraph ui',
-  readOnly: true,
+  /**
+   * Every endpoint but `/api/trails` is a pure read. Kept as a field rather
+   * than dropped, because it was `true` and something may be reading it; it is
+   * now the honest, narrower claim.
+   */
+  readOnly: false,
+  writes: ['POST /api/trails', 'DELETE /api/trails/<id>'],
   endpoints: [
     { path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
     { path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
@@ -167,6 +217,12 @@ const API_INDEX = {
       description: 'Where to start reading: routes, files that run something, and hubs.',
       params: ['limit'],
     },
+    {
+      path: '/api/trails',
+      description:
+        'Saved trails, each hop re-resolved against the current index. POST saves one, ' +
+        'DELETE /api/trails/<id> removes it. The only endpoint that writes.',
+    },
   ],
 };
 
@@ -175,12 +231,25 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
   // Watches nothing until a browser subscribes, and stops again when the last
   // one goes away — mounting the API costs no watch descriptors.
   const events = new EventHub(options.projectRoot, session);
+  const trails: TrailsOptions = {
+    readOnly: options.readOnly === true,
+    readOnlyReason:
+      options.readOnly === true
+        ? options.readOnlyReason ?? 'This viewer is running read-only, so trails cannot be saved.'
+        : null,
+  };
 
   // Async because `/api/source` highlights: everything else answers straight
   // out of SQLite and resolves on the same tick.
   const handler: UiApiHandler = async (req, res, ctx) => {
     const route = normalize(ctx.pathname);
     try {
+      // Writes first: they are the only requests that carry a body, and
+      // routing them beside the readers would put a `case` that mutates in a
+      // switch every other arm of which is a query.
+      if (ctx.method === 'POST' || ctx.method === 'DELETE') {
+        return await dispatchWrite(route, req, res, ctx, session, trails);
+      }
       switch (route) {
         case '/api':
           return ok(res, API_INDEX, ctx.method);
@@ -196,6 +265,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         case '/api/entrypoints':
           return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
+        case '/api/trails':
+          return ok(res, buildTrails(session.acquire(), ctx.projectRoot, trails), ctx.method);
         case '/api/nodes':
           return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
         case '/api/source':
@@ -231,6 +302,47 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
   };
 }
 
+/**
+ * The write half: `/api/trails` and nothing else.
+ *
+ * Kept to one function so the answer to "what can this server change?" is one
+ * place a reviewer can read in full. Anything else that arrives with a write
+ * method is a 405 naming the endpoint that does accept one — by the time this
+ * runs, `isWriteRequest` has already established the request could not have
+ * been forged from another origin, so an unhelpfully vague refusal here would
+ * only confuse the person poking at their own API.
+ */
+async function dispatchWrite(
+  route: string,
+  req: Parameters<UiApiHandler>[0],
+  res: Parameters<UiApiHandler>[1],
+  ctx: UiRequestContext,
+  session: GraphSession,
+  trails: TrailsOptions
+): Promise<boolean> {
+  if (ctx.method === 'POST' && route === '/api/trails') {
+    const body = await readJsonBody(req);
+    return ok(res, saveTrail(session.acquire(), ctx.projectRoot, body, trails), ctx.method);
+  }
+
+  if (ctx.method === 'DELETE') {
+    const id = suffixAfter(route, '/api/trails/');
+    if (id !== null && id !== '') {
+      return ok(res, removeTrail(session.acquire(), ctx.projectRoot, id, trails), ctx.method);
+    }
+    if (route === '/api/trails') {
+      throw badRequest('Deleting a trail needs its id: DELETE /api/trails/<id>.');
+    }
+  }
+
+  res.setHeader('Allow', 'GET, HEAD');
+  throw new ApiError(
+    'bad-request',
+    `${ctx.method} ${route} is not something this server changes.`,
+    'The only endpoint that writes is /api/trails (POST to save, DELETE /api/trails/<id> to remove).'
+  );
+}
+
 /**
  * The two endpoints that carry their argument in the path.
  *

+ 49 - 1
src/ui-server/api/respond.ts

@@ -8,7 +8,7 @@
  * the CLI and the MCP tools do. What it never does is leak a stack trace.
  */
 
-import type { ServerResponse } from 'http';
+import type { IncomingMessage, ServerResponse } from 'http';
 import { sendJson } from '../static';
 
 /**
@@ -88,6 +88,54 @@ export function fail(res: ServerResponse, err: unknown, method: string): true {
   return true;
 }
 
+// =============================================================================
+// Request bodies
+// =============================================================================
+
+/**
+ * Bytes a request body may carry.
+ *
+ * The only body this server reads is a saved trail: a name and up to 64 node
+ * ids. 64 KB is generous for that and small enough that a runaway client cannot
+ * make the process hold a megabyte per socket.
+ */
+export const MAX_BODY_BYTES = 64 * 1024;
+
+/**
+ * Read a request body as JSON.
+ *
+ * Counts BYTES, not characters, and stops at the cap by destroying the socket
+ * rather than draining a body nobody is going to parse — a `Content-Length`
+ * header is a claim, and the only limit that holds is the one applied to what
+ * actually arrives.
+ *
+ * @throws {ApiError} `bad-request` for a body that is too large or is not JSON.
+ */
+export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
+  const chunks: Buffer[] = [];
+  let size = 0;
+  try {
+    for await (const chunk of req) {
+      const buf = chunk as Buffer;
+      size += buf.length;
+      if (size > MAX_BODY_BYTES) {
+        req.destroy();
+        throw badRequest(`That request body is too large (max ${MAX_BODY_BYTES} bytes).`);
+      }
+      chunks.push(buf);
+    }
+  } catch (err) {
+    if (err instanceof ApiError) throw err;
+    throw badRequest('That request body could not be read.');
+  }
+  if (size === 0) throw badRequest('That request needs a JSON body.');
+  try {
+    return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as unknown;
+  } catch {
+    throw badRequest('That request body is not valid JSON.');
+  }
+}
+
 // =============================================================================
 // Query parameters
 // =============================================================================

+ 332 - 0
src/ui-server/api/trail-store.ts

@@ -0,0 +1,332 @@
+/**
+ * Where saved trails live on disk — the only thing `codegraph ui` ever writes.
+ *
+ * Every other module under `api/` is a reader. This one holds the single write
+ * path in the whole viewer, and it is scoped as narrowly as a write can be: one
+ * directory, `<CODEGRAPH_DIR>/ui/trails/`, inside the project the server was
+ * started on, one JSON file per trail. It never touches source, never touches
+ * the index, and never writes anywhere a `codegraph init` would not already
+ * have created. `.codegraph/.gitignore` ignores everything but itself, so a
+ * saved trail is local by default; exporting one to commit is a copy the reader
+ * makes deliberately.
+ *
+ * Two rules hold it inside the boundary described in `../security.ts`:
+ *
+ * - **The directory is resolved through `resolveProjectFile`**, exactly like a
+ *   source read, so a trail id that tried to be a path is refused by the same
+ *   chokepoint that refuses `?file=../../.ssh/id_rsa`. It is belt and braces on
+ *   top of {@link isTrailId}, which already refuses anything but a slug.
+ * - **A write is atomic.** Temp file in the same directory, then rename. A
+ *   half-written trail read back by the list would look like a corrupt one, and
+ *   the list would then have to decide whether to hide it — which is a decision
+ *   nobody should have to make about a file they saved a second ago.
+ *
+ * The format is deliberately plain: a reader can open one in an editor, and a
+ * hop is described by what it IS (a qualified name in a file) rather than by the
+ * node id it happened to have. Node ids contain a start line, so any edit above
+ * a symbol renames it — a trail keyed on ids would not survive its own project.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { CODEGRAPH_DIR } from '../../directory';
+import { resolveProjectFile } from '../security';
+import { ApiError, badRequest } from './respond';
+
+/** Where trails live, relative to the project root. Forward slashes always. */
+export const TRAILS_RELATIVE_DIR = `${CODEGRAPH_DIR}/ui/trails`;
+
+/** The only `version` this build writes, and the only one it reads. */
+export const TRAIL_FORMAT_VERSION = 1;
+
+/** Trail files read from the directory before the list stops looking. */
+export const MAX_TRAILS = 200;
+
+/** Hops one trail may carry. Past this it is a history, not a tour. */
+export const MAX_TRAIL_HOPS = 64;
+
+/** Characters in a trail's name. */
+export const MAX_TRAIL_NAME = 120;
+
+/** Characters in a trail's note. */
+export const MAX_TRAIL_NOTE = 600;
+
+/** Bytes a single trail file may be before it is skipped as not-ours. */
+export const MAX_TRAIL_FILE_BYTES = 64 * 1024;
+
+/** Characters in a generated slug, before any de-duplicating suffix. */
+const MAX_SLUG = 60;
+
+/** How a reader got from the previous hop to this one. Mirrors the viewer's `HopDirection`. */
+export type StoredHopDirection = 'start' | 'down' | 'up';
+
+/**
+ * One hop, described by what it is rather than by the id it had.
+ *
+ * `id` is kept as a HINT — when the file has not changed it resolves in one
+ * lookup — but `qualifiedName` + `kind` + `file` is what the trail is actually
+ * keyed on, and what lets it survive a re-index.
+ */
+export interface StoredHop {
+  dir: StoredHopDirection;
+  name: string;
+  qualifiedName: string;
+  kind: string;
+  /** Project-relative, forward slashes. */
+  file: string;
+  line: number;
+  /** The node id at save time. A fast path, never the identity. */
+  id: string;
+}
+
+export interface StoredTrail {
+  version: number;
+  /** Slug, and the file's basename. */
+  id: string;
+  name: string;
+  note: string;
+  /** Whoever saved it — git's `user.name`, or the OS user. */
+  author: string;
+  createdAt: string;
+  updatedAt: string;
+  hops: StoredHop[];
+}
+
+/* ------------------------------------------------------------------ paths -- */
+
+/**
+ * Whether a string is a trail id we would have written.
+ *
+ * Lowercase slug characters only: no dot, no separator, no leading dash. This
+ * is what makes `<id>.json` a filename rather than a path expression, and it
+ * runs before the id is ever joined to anything.
+ */
+export function isTrailId(value: string): boolean {
+  return /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
+}
+
+/**
+ * `Read a file with these lines` -> `read-a-file-with-these-lines`.
+ *
+ * Names that carry no ASCII letters or digits at all (a trail named entirely in
+ * Chinese, or in emoji) slug to nothing; they get `trail`, and the collision
+ * handling in {@link saveTrail} keeps them distinct from each other.
+ */
+export function slugify(name: string): string {
+  const slug = name
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, MAX_SLUG)
+    .replace(/-+$/g, '');
+  return slug === '' ? 'trail' : slug;
+}
+
+/** The absolute trails directory, having been through the read chokepoint. */
+export function trailsDirectory(projectRoot: string): string {
+  return resolveProjectFile(projectRoot, TRAILS_RELATIVE_DIR);
+}
+
+/**
+ * The absolute path of one trail file.
+ *
+ * @throws {ApiError} `bad-request` when the id is not a slug we would have
+ *   written — checked before the join, so nothing path-shaped is ever built.
+ */
+export function trailPath(projectRoot: string, id: string): string {
+  if (!isTrailId(id)) {
+    throw badRequest(
+      `"${id}" is not a saved trail id.`,
+      'Trail ids are the lowercase slug in the file name, e.g. "how-a-request-is-served".'
+    );
+  }
+  return resolveProjectFile(projectRoot, `${TRAILS_RELATIVE_DIR}/${id}.json`);
+}
+
+/* ------------------------------------------------------------------- read -- */
+
+/**
+ * Parse a file into a trail, or `null` if it is not one.
+ *
+ * Everything is re-validated rather than trusted: the directory is a place a
+ * user may hand-edit a file, or drop one somebody else exported, and a trail
+ * that half-parsed would draw a row with holes in it. A file that fails is
+ * skipped and counted, never repaired in place.
+ */
+export function parseTrail(id: string, text: string): StoredTrail | null {
+  let raw: unknown;
+  try {
+    raw = JSON.parse(text);
+  } catch {
+    return null;
+  }
+  if (typeof raw !== 'object' || raw === null) return null;
+  const value = raw as Record<string, unknown>;
+  if (typeof value.name !== 'string' || value.name.trim() === '') return null;
+  if (!Array.isArray(value.hops) || value.hops.length === 0) return null;
+
+  const hops: StoredHop[] = [];
+  for (const entry of value.hops.slice(0, MAX_TRAIL_HOPS)) {
+    if (typeof entry !== 'object' || entry === null) return null;
+    const hop = entry as Record<string, unknown>;
+    const qualifiedName = typeof hop.qualifiedName === 'string' ? hop.qualifiedName : '';
+    const name = typeof hop.name === 'string' ? hop.name : '';
+    if (qualifiedName === '' && name === '') return null;
+    hops.push({
+      dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
+      name: name || qualifiedName,
+      qualifiedName: qualifiedName || name,
+      kind: typeof hop.kind === 'string' ? hop.kind : '',
+      file: typeof hop.file === 'string' ? hop.file : '',
+      line: typeof hop.line === 'number' && hop.line > 0 ? Math.floor(hop.line) : 0,
+      id: typeof hop.id === 'string' ? hop.id : '',
+    });
+  }
+
+  const created = typeof value.createdAt === 'string' ? value.createdAt : '';
+  return {
+    version: typeof value.version === 'number' ? value.version : TRAIL_FORMAT_VERSION,
+    // The FILE's name wins over any `id` inside it: the basename is what the
+    // delete route addresses, so a hand-copied file is addressable under the
+    // name it actually has rather than the one it remembers having.
+    id,
+    name: value.name.slice(0, MAX_TRAIL_NAME),
+    note: typeof value.note === 'string' ? value.note.slice(0, MAX_TRAIL_NOTE) : '',
+    author: typeof value.author === 'string' ? value.author.slice(0, 120) : '',
+    createdAt: created,
+    updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : created,
+    hops,
+  };
+}
+
+export interface StoredTrailList {
+  trails: StoredTrail[];
+  /** Files in the directory that were not readable trails. */
+  skipped: number;
+}
+
+/**
+ * Every trail in the project, newest save first.
+ *
+ * A missing directory is the ordinary state of a project nobody has saved a
+ * trail in — an empty list, never an error.
+ */
+export function listStoredTrails(projectRoot: string): StoredTrailList {
+  const dir = trailsDirectory(projectRoot);
+  let names: string[];
+  try {
+    names = fs.readdirSync(dir);
+  } catch {
+    return { trails: [], skipped: 0 };
+  }
+
+  const trails: StoredTrail[] = [];
+  let skipped = 0;
+  for (const name of names.sort()) {
+    if (!name.endsWith('.json')) continue;
+    if (trails.length >= MAX_TRAILS) break;
+    const id = name.slice(0, -'.json'.length);
+    if (!isTrailId(id)) {
+      skipped += 1;
+      continue;
+    }
+    const trail = readTrailFile(path.join(dir, name), id);
+    if (trail) trails.push(trail);
+    else skipped += 1;
+  }
+
+  // Newest save first: a tour written a minute ago is the one being iterated on.
+  trails.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.name.localeCompare(b.name)));
+  return { trails, skipped };
+}
+
+function readTrailFile(absolute: string, id: string): StoredTrail | null {
+  try {
+    const stat = fs.statSync(absolute);
+    // A file too big to be a trail is skipped rather than read: this directory
+    // is inside the project, and something else may one day put a log in it.
+    if (!stat.isFile() || stat.size > MAX_TRAIL_FILE_BYTES) return null;
+    return parseTrail(id, fs.readFileSync(absolute, 'utf-8'));
+  } catch {
+    return null;
+  }
+}
+
+/** One trail by id, or `null` when there is no such file. */
+export function readStoredTrail(projectRoot: string, id: string): StoredTrail | null {
+  return readTrailFile(trailPath(projectRoot, id), id);
+}
+
+/* ------------------------------------------------------------------ write -- */
+
+/**
+ * Write a trail, atomically.
+ *
+ * Temp file beside the target then `rename`, so a reader either sees the
+ * previous trail or the new one and never a partial file. The temp name carries
+ * the pid: two `codegraph ui` processes on one project is unusual but not
+ * forbidden, and two writers sharing a temp name would corrupt each other's.
+ */
+export function writeStoredTrail(projectRoot: string, trail: StoredTrail): void {
+  const dir = trailsDirectory(projectRoot);
+  try {
+    fs.mkdirSync(dir, { recursive: true });
+  } catch (err) {
+    throw writeFailure(err);
+  }
+  const target = trailPath(projectRoot, trail.id);
+  const temp = `${target}.${process.pid}.tmp`;
+  try {
+    fs.writeFileSync(temp, `${JSON.stringify(trail, null, 2)}\n`, 'utf-8');
+    fs.renameSync(temp, target);
+  } catch (err) {
+    try {
+      fs.unlinkSync(temp);
+    } catch {
+      // Nothing to clean up, or nothing we can do about it. The write already
+      // failed; the caller is about to be told so.
+    }
+    throw writeFailure(err);
+  }
+}
+
+/** Remove a trail. Returns false when there was nothing there. */
+export function deleteStoredTrail(projectRoot: string, id: string): boolean {
+  try {
+    fs.unlinkSync(trailPath(projectRoot, id));
+    return true;
+  } catch (err) {
+    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
+    throw writeFailure(err);
+  }
+}
+
+/**
+ * An id nothing in `taken` is using, preferring the plain slug.
+ *
+ * A save under a name that is already there REPLACES it — that is what a reader
+ * pressing Save with the same name means — so the caller passes the ids of
+ * trails carrying a *different* name, and this only steps aside for those.
+ */
+export function uniqueTrailId(base: string, taken: ReadonlySet<string>): string {
+  if (!taken.has(base)) return base;
+  for (let n = 2; n < 1000; n += 1) {
+    const candidate = `${base}-${n}`;
+    if (!taken.has(candidate)) return candidate;
+  }
+  // 999 trails sharing one slug is not a state worth a clever answer.
+  throw new ApiError('bad-request', `Too many saved trails are already named like "${base}".`);
+}
+
+function writeFailure(err: unknown): ApiError {
+  const code = (err as NodeJS.ErrnoException).code;
+  const detail = err instanceof Error ? err.message : String(err);
+  if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
+    return new ApiError(
+      'refused',
+      `Saved trails could not be written: ${detail}`,
+      `The viewer writes only to ${TRAILS_RELATIVE_DIR} inside this project. Check that it is writable.`
+    );
+  }
+  return new ApiError('internal', `Saved trails could not be written: ${detail}`);
+}

+ 477 - 0
src/ui-server/api/trails.ts

@@ -0,0 +1,477 @@
+/**
+ * `GET/POST/DELETE /api/trails` — saved trails, the reader's own tours through
+ * the graph (design spec §3.12).
+ *
+ * A trail is the path of symbols someone walked to explain something: "how a
+ * request is served", "everything the token expiry touches". The viewer already
+ * carries one in the URL; this is the same walk given a name and kept, so the
+ * next person — or the same person next week — starts at the explanation rather
+ * than at the search box.
+ *
+ * ## The one thing this feature has to get right
+ *
+ * **A trail must survive a re-index.** A node's id contains its start line, so
+ * inserting an import at the top of a file renames every symbol below it. A
+ * trail keyed on ids would break the first time anybody edited the code it
+ * describes — which is exactly when it matters. So a hop is stored as what it
+ * *is* — qualified name, kind, file — with the id kept only as a fast path, and
+ * every hop is re-resolved against the current index on the way out:
+ *
+ * - the recorded id still names the same symbol → `ok`
+ * - the qualified name resolves somewhere else → `moved`, and the row says
+ *   where from
+ * - the name is now carried by several symbols and none is in the recorded
+ *   file → `ambiguous`, best guess offered and labelled as one
+ * - nothing answers to it → `missing`, and the row says "moved or renamed"
+ *
+ * Nothing is silently dropped and nothing is silently guessed: a trail that has
+ * decayed says so on its own row, which is the point at which its author can
+ * fix it.
+ *
+ * ## What it opens
+ *
+ * A trail with a hole in it cannot be handed to the viewer whole — the `t`
+ * param is a PATH, and stitching hop 2 to hop 4 would draw an adjacency that
+ * does not exist. So the payload carries the longest run of consecutive
+ * resolved hops, and the row says when that is less than the whole trail.
+ *
+ * Storage — the only write `codegraph ui` makes — is `./trail-store.ts`.
+ */
+
+import { execFileSync } from 'child_process';
+import * as os from 'os';
+import type { CodeGraph } from '../../index';
+import type { Node } from '../../types';
+import { ApiError, badRequest, notFound } from './respond';
+import {
+  MAX_TRAIL_HOPS,
+  MAX_TRAIL_NAME,
+  MAX_TRAIL_NOTE,
+  MAX_TRAILS,
+  TRAILS_RELATIVE_DIR,
+  TRAIL_FORMAT_VERSION,
+  deleteStoredTrail,
+  listStoredTrails,
+  slugify,
+  uniqueTrailId,
+  writeStoredTrail,
+  type StoredHop,
+  type StoredHopDirection,
+  type StoredTrail,
+} from './trail-store';
+import { toNodeRef } from './wire';
+
+/* ------------------------------------------------------------------ wire -- */
+
+/** How a saved hop fared against the current index. */
+export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
+
+export interface WireTrailHop {
+  dir: StoredHopDirection;
+  /** The name as it was when the trail was saved. */
+  name: string;
+  qualifiedName: string;
+  kind: string;
+  /** Where the symbol was when the trail was saved. */
+  savedFile: string;
+  savedLine: number;
+  status: WireTrailHopStatus;
+  /** The symbol's id NOW. Null when nothing answers to it any more. */
+  id: string | null;
+  file: string | null;
+  line: number | null;
+  /** Finished screen wording for a status that is not `ok`; null when it is. */
+  note: string | null;
+}
+
+export interface WireTrail {
+  id: string;
+  name: string;
+  note: string;
+  author: string;
+  createdAt: string;
+  updatedAt: string;
+  hops: WireTrailHop[];
+  /** Hops that still resolve to a symbol in this index. */
+  resolved: number;
+  /** Every hop resolved, and none of them moved. */
+  intact: boolean;
+  /**
+   * The longest run of CONSECUTIVE resolved hops, encoded as the `t` param.
+   * Null when nothing in the trail resolves. Never stitched across a hole: the
+   * trail is a path, and a fabricated adjacency is worse than a short one.
+   */
+  encoded: string | null;
+  /** 1-based index of the first hop `encoded` carries. */
+  openFrom: number;
+  /** How many hops `encoded` carries. */
+  openCount: number;
+  /** The symbol the trail opens at — the last hop of that run. */
+  openId: string | null;
+}
+
+export interface WireTrails {
+  trails: WireTrail[];
+  /** Writes are off. The viewer hides Save and Delete, and says why. */
+  readOnly: boolean;
+  readOnlyReason: string | null;
+  /** Project-relative directory the files live in. The screen names it. */
+  directory: string;
+  /** Files in that directory that were not readable trails. */
+  skipped: number;
+  /** The list stopped at {@link MAX_TRAILS}. */
+  bounded: boolean;
+  /** The id just written, on the answer to a POST. */
+  saved?: string;
+  /** That POST replaced a trail of the same name. */
+  replaced?: boolean;
+  /** The id just removed, on the answer to a DELETE. */
+  deleted?: string;
+}
+
+/* -------------------------------------------------------------- resolution -- */
+
+/**
+ * Re-resolve one saved hop against the index as it is now.
+ *
+ * Order matters: the recorded id first, because in the common case (nothing
+ * above the symbol changed) it is one lookup and exactly right. It is still
+ * verified against the qualified name — an id is a hash of position as well as
+ * identity, and a recycled one pointing at a different symbol would put a
+ * stranger in the middle of somebody's explanation.
+ */
+export function resolveHop(cg: CodeGraph, hop: StoredHop): WireTrailHop {
+  const base = {
+    dir: hop.dir,
+    name: hop.name,
+    qualifiedName: hop.qualifiedName,
+    kind: hop.kind,
+    savedFile: hop.file,
+    savedLine: hop.line,
+  };
+
+  const byId = hop.id ? cg.getNode(hop.id) : null;
+  if (byId && matches(byId, hop)) {
+    return { ...base, status: 'ok', id: byId.id, file: byId.filePath, line: byId.startLine, note: null };
+  }
+
+  const candidates = cg
+    .getNodesByQualifiedName(hop.qualifiedName)
+    .filter((node) => hop.kind === '' || node.kind === hop.kind);
+
+  if (candidates.length === 0) {
+    return {
+      ...base,
+      status: 'missing',
+      id: null,
+      file: null,
+      line: null,
+      note: `no longer in the index — moved or renamed since this trail was saved`,
+    };
+  }
+
+  const sameFile = candidates.filter((node) => node.filePath === hop.file);
+  if (sameFile.length === 1) {
+    const node = sameFile[0] as Node;
+    return { ...base, status: 'ok', id: node.id, file: node.filePath, line: node.startLine, note: null };
+  }
+
+  if (candidates.length === 1) {
+    const node = candidates[0] as Node;
+    return {
+      ...base,
+      status: 'moved',
+      id: node.id,
+      file: node.filePath,
+      line: node.startLine,
+      note: `moved from ${hop.file || 'an unrecorded file'} to ${node.filePath}`,
+    };
+  }
+
+  // Several symbols carry this name and none of them is where it used to be.
+  // The best guess is offered — a row nobody can open is not more honest, it
+  // is just less useful — but it is labelled as a guess.
+  const pick = (sameFile[0] ?? candidates[0]) as Node;
+  return {
+    ...base,
+    status: 'ambiguous',
+    id: pick.id,
+    file: pick.filePath,
+    line: pick.startLine,
+    note: `${candidates.length} symbols now carry this name — showing the one in ${pick.filePath}`,
+  };
+}
+
+function matches(node: Node, hop: StoredHop): boolean {
+  if (hop.kind !== '' && node.kind !== hop.kind) return false;
+  return node.qualifiedName === hop.qualifiedName || node.name === hop.name;
+}
+
+/** The `t` param's own encoding — kept identical to `ui/src/lib/trail-codec.ts`. */
+const DIR_CHAR: Record<StoredHopDirection, string> = { start: 's', down: 'd', up: 'u' };
+
+/**
+ * Turn resolved hops into something the viewer can open.
+ *
+ * The longest CONSECUTIVE run, not every resolved hop: skipping a missing hop
+ * would encode a step from A to C that no edge supports, and the Flow strip
+ * reads a trail as exactly that sequence of edges. The first hop of the run is
+ * always written as `start`, because a run beginning mid-trail arrived from
+ * nothing the viewer can draw.
+ */
+export function encodeResolvedRun(hops: readonly WireTrailHop[]): {
+  encoded: string | null;
+  openFrom: number;
+  openCount: number;
+  openId: string | null;
+} {
+  let bestStart = -1;
+  let bestLength = 0;
+  let start = -1;
+  for (let i = 0; i <= hops.length; i += 1) {
+    const resolved = i < hops.length && (hops[i] as WireTrailHop).id !== null;
+    if (resolved) {
+      if (start < 0) start = i;
+      continue;
+    }
+    if (start >= 0 && i - start > bestLength) {
+      bestStart = start;
+      bestLength = i - start;
+    }
+    start = -1;
+  }
+  if (bestLength === 0) return { encoded: null, openFrom: 0, openCount: 0, openId: null };
+
+  const run = hops.slice(bestStart, bestStart + bestLength);
+  const encoded = run
+    .map((hop, index) => `${index === 0 ? 's' : DIR_CHAR[hop.dir]}${encodeURIComponent(hop.id as string)}`)
+    .join(',');
+  return {
+    encoded,
+    openFrom: bestStart + 1,
+    openCount: bestLength,
+    openId: (run[run.length - 1] as WireTrailHop).id,
+  };
+}
+
+export function resolveTrail(cg: CodeGraph, stored: StoredTrail): WireTrail {
+  const hops = stored.hops.map((hop) => resolveHop(cg, hop));
+  const run = encodeResolvedRun(hops);
+  return {
+    id: stored.id,
+    name: stored.name,
+    note: stored.note,
+    author: stored.author,
+    createdAt: stored.createdAt,
+    updatedAt: stored.updatedAt,
+    hops,
+    resolved: hops.filter((hop) => hop.id !== null).length,
+    intact: hops.every((hop) => hop.status === 'ok'),
+    ...run,
+  };
+}
+
+/* ------------------------------------------------------------------ read -- */
+
+export interface TrailsOptions {
+  /** Writes refused, and the sentence saying why. */
+  readOnly: boolean;
+  readOnlyReason: string | null;
+}
+
+export function buildTrails(
+  cg: CodeGraph,
+  projectRoot: string,
+  options: TrailsOptions
+): WireTrails {
+  const { trails, skipped } = listStoredTrails(projectRoot);
+  return {
+    trails: trails.map((stored) => resolveTrail(cg, stored)),
+    readOnly: options.readOnly,
+    readOnlyReason: options.readOnlyReason,
+    directory: TRAILS_RELATIVE_DIR,
+    skipped,
+    bounded: trails.length >= MAX_TRAILS,
+  };
+}
+
+/* ----------------------------------------------------------------- write -- */
+
+/** What a POST body has to be. Everything else about a hop comes from the graph. */
+export interface SaveTrailRequest {
+  name: string;
+  note?: string;
+  hops: Array<{ dir?: string; id: string }>;
+}
+
+/**
+ * Save a trail.
+ *
+ * The client sends ids and directions and nothing else: the name, kind, file
+ * and line of every hop are read out of the index here. A client that supplied
+ * its own metadata could save a trail describing symbols that are not in the
+ * graph, and the whole value of the feature is that a trail is a claim the
+ * index can re-check.
+ *
+ * A save under a name that already exists REPLACES that trail, keeping its
+ * `createdAt`. That is what pressing Save with the same name means, and the
+ * answer says `replaced` so the screen can too.
+ */
+export function saveTrail(
+  cg: CodeGraph,
+  projectRoot: string,
+  body: unknown,
+  options: TrailsOptions
+): WireTrails {
+  if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
+  const request = parseSaveRequest(body);
+
+  const hops: StoredHop[] = [];
+  request.hops.forEach((hop, index) => {
+    const node = cg.getNode(hop.id);
+    if (!node) {
+      throw badRequest(
+        `Hop ${index + 1} is not in the index: ${hop.id}`,
+        'Trails are saved from symbols the index holds. Reload the page and walk the trail again.'
+      );
+    }
+    const ref = toNodeRef(node);
+    hops.push({
+      dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
+      name: ref.name,
+      qualifiedName: ref.qualifiedName,
+      kind: ref.kind,
+      file: ref.file,
+      line: ref.line,
+      id: ref.id,
+    });
+  });
+  // The first hop is where the walk began, whatever the client called it.
+  if (hops[0]) hops[0].dir = 'start';
+
+  const existing = listStoredTrails(projectRoot).trails;
+  const sameName = existing.find((trail) => trail.name === request.name);
+  const takenByOthers = new Set(
+    existing.filter((trail) => trail.name !== request.name).map((trail) => trail.id)
+  );
+  const id = sameName ? sameName.id : uniqueTrailId(slugify(request.name), takenByOthers);
+  const now = new Date().toISOString();
+
+  writeStoredTrail(projectRoot, {
+    version: TRAIL_FORMAT_VERSION,
+    id,
+    name: request.name,
+    note: request.note,
+    author: trailAuthor(projectRoot),
+    createdAt: sameName?.createdAt || now,
+    updatedAt: now,
+    hops,
+  });
+
+  return { ...buildTrails(cg, projectRoot, options), saved: id, replaced: sameName !== undefined };
+}
+
+export function removeTrail(
+  cg: CodeGraph,
+  projectRoot: string,
+  id: string,
+  options: TrailsOptions
+): WireTrails {
+  if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
+  if (!deleteStoredTrail(projectRoot, id)) {
+    throw notFound(`There is no saved trail called "${id}".`);
+  }
+  return { ...buildTrails(cg, projectRoot, options), deleted: id };
+}
+
+function readOnlyRefusal(reason: string | null): ApiError {
+  return new ApiError(
+    'refused',
+    reason ?? 'This viewer is running read-only, so trails cannot be saved.',
+    `Restart without --read-only to let the viewer write trails into ${TRAILS_RELATIVE_DIR}.`
+  );
+}
+
+function parseSaveRequest(body: unknown): { name: string; note: string; hops: SaveTrailRequest['hops'] } {
+  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
+    throw badRequest('A trail is saved from a JSON object: { name, hops }.');
+  }
+  const value = body as Record<string, unknown>;
+
+  const name = typeof value.name === 'string' ? value.name.trim().replace(/\s+/g, ' ') : '';
+  if (name === '') throw badRequest('A saved trail needs a name.');
+  if (name.length > MAX_TRAIL_NAME) {
+    throw badRequest(`That name is too long (max ${MAX_TRAIL_NAME} characters).`);
+  }
+
+  const note = typeof value.note === 'string' ? value.note.trim() : '';
+  if (note.length > MAX_TRAIL_NOTE) {
+    throw badRequest(`That note is too long (max ${MAX_TRAIL_NOTE} characters).`);
+  }
+
+  if (!Array.isArray(value.hops) || value.hops.length === 0) {
+    throw badRequest('A saved trail needs at least one hop.');
+  }
+  if (value.hops.length > MAX_TRAIL_HOPS) {
+    throw badRequest(`A saved trail can hold at most ${MAX_TRAIL_HOPS} hops.`);
+  }
+
+  const hops: SaveTrailRequest['hops'] = [];
+  for (const entry of value.hops) {
+    if (typeof entry !== 'object' || entry === null) throw badRequest('Each hop is { dir, id }.');
+    const hop = entry as Record<string, unknown>;
+    if (typeof hop.id !== 'string' || hop.id === '') throw badRequest('Each hop needs an id.');
+    hops.push({ id: hop.id, ...(typeof hop.dir === 'string' ? { dir: hop.dir } : {}) });
+  }
+
+  return { name, note, hops };
+}
+
+/* ---------------------------------------------------------------- author -- */
+
+/**
+ * Who to record as the author.
+ *
+ * Git's `user.name` first, because a trail is a thing one person wrote for
+ * others to read and that is the name they already sign work with in this
+ * project; the OS user is the fallback. Read ONCE per process — `git config` is
+ * a subprocess, and a save should not pay for it twice — and never sent
+ * anywhere: it goes into a file inside the user's own `.codegraph/`.
+ */
+let cachedAuthor: string | null = null;
+
+export function trailAuthor(projectRoot: string): string {
+  if (cachedAuthor !== null) return cachedAuthor;
+  cachedAuthor = gitUserName(projectRoot) ?? osUserName() ?? '';
+  return cachedAuthor;
+}
+
+/** Test seam: forget the cached author. */
+export function resetTrailAuthor(): void {
+  cachedAuthor = null;
+}
+
+function gitUserName(projectRoot: string): string | null {
+  try {
+    const out = execFileSync('git', ['config', 'user.name'], {
+      cwd: projectRoot,
+      encoding: 'utf-8',
+      timeout: 2_000,
+      stdio: ['ignore', 'pipe', 'ignore'],
+    });
+    const name = out.trim();
+    return name === '' ? null : name.slice(0, 120);
+  } catch {
+    // No git, no config, not a repository — all ordinary. Fall through.
+    return null;
+  }
+}
+
+function osUserName(): string | null {
+  try {
+    const name = os.userInfo().username.trim();
+    return name === '' ? null : name.slice(0, 120);
+  } catch {
+    return null;
+  }
+}

+ 46 - 10
src/ui-server/index.ts

@@ -1,11 +1,16 @@
 /**
  * The `codegraph ui` server.
  *
- * A loopback-only, read-only `node:http` server that hands the browser the
- * built viewer (`dist/viewer/`) and, through the JSON API mounted on the `api`
- * seam below (`./api`), a read-only view of one indexed project. No framework,
- * no new dependency: it answers GET, serves files, and refuses everything
- * else.
+ * A loopback-only `node:http` server that hands the browser the built viewer
+ * (`dist/viewer/`) and, through the JSON API mounted on the `api` seam below
+ * (`./api`), a view of one indexed project. No framework, no new dependency: it
+ * answers GET, serves files, and refuses everything else.
+ *
+ * It is a reader with one exception, added deliberately and scoped as narrowly
+ * as it could be: `POST`/`DELETE /api/trails` saves and removes the reader's own
+ * named trails, as JSON files under `.codegraph/ui/trails/`. Nothing else it
+ * serves has a side effect, no other path accepts a write, and `--read-only`
+ * turns even that one off. See `security.ts` for what a write has to carry.
  *
  * The interesting part is not the routing, it is the boundary in `security.ts`.
  * Read that first.
@@ -17,9 +22,13 @@ import * as path from 'path';
 import { resolveViewerDir } from './assets';
 import {
   ALLOWED_METHODS,
+  READ_METHODS,
+  WRITE_HEADER,
   isAllowedHost,
   isAllowedOrigin,
   isSafeRequestPath,
+  isWriteMethod,
+  isWriteRequest,
   resolveStaticAsset,
 } from './security';
 import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static';
@@ -35,10 +44,15 @@ export {
 } from './constants';
 export {
   ALLOWED_METHODS,
+  READ_METHODS,
+  WRITE_HEADER,
+  WRITE_METHODS,
   PathRefusalError,
   isAllowedHost,
   isAllowedOrigin,
   isSafeRequestPath,
+  isWriteMethod,
+  isWriteRequest,
   resolveProjectFile,
   resolveStaticAsset,
 } from './security';
@@ -58,7 +72,11 @@ export interface UiRequestContext {
   query: URLSearchParams;
   /** Absolute path of the indexed project this server is reading. */
   projectRoot: string;
-  /** The request method — `GET` or `HEAD`; nothing else reaches a handler. */
+  /**
+   * The request method. `GET` or `HEAD` for every read; `POST` or `DELETE`
+   * only for a request that already passed {@link isWriteRequest}, which is
+   * `/api/trails` and nothing else.
+   */
   method: string;
 }
 
@@ -66,9 +84,9 @@ export interface UiRequestContext {
  * A handler mounted under `/api/`. Returns `true` when it answered the request
  * (i.e. wrote a response), `false` to fall through to a 404.
  *
- * This is the seam the read-only JSON API plugs into. Everything it serves out
- * of the user's repository must go through `resolveProjectFile` — see
- * `security.ts`.
+ * This is the seam the JSON API plugs into. Everything it reads out of — or
+ * writes into — the user's repository must go through `resolveProjectFile`;
+ * see `security.ts`.
  */
 export type UiApiHandler = (
   req: http.IncomingMessage,
@@ -228,7 +246,7 @@ async function handleRequest(
 
   if (!ALLOWED_METHODS.includes(method)) {
     res.setHeader('Allow', ALLOWED_METHODS.join(', '));
-    sendText(res, 405, `codegraph ui is read-only — ${method} is not allowed.`, method);
+    sendText(res, 405, `codegraph ui does not answer ${method}.`, method);
     return;
   }
 
@@ -258,6 +276,24 @@ async function handleRequest(
   // the viewer parses these responses, and a text/plain body here would surface
   // as a parse error instead of the refusal it actually is.
   const jsonNamespace = rawPath === '/api' || rawPath.startsWith('/api/');
+
+  // The one place this server stops being a pure reader. A write has to be
+  // under /api/ and carry the marker header — see `isWriteRequest` for what
+  // that closes that Host and Origin do not.
+  if (isWriteMethod(method)) {
+    const verdict = isWriteRequest(rawPath, {
+      marker: readHeader(req, WRITE_HEADER),
+      contentType: readHeader(req, 'content-type'),
+    });
+    if (!verdict.ok) {
+      if (!jsonNamespace) res.setHeader('Allow', READ_METHODS.join(', '));
+      const body = `Refused: ${verdict.reason}`;
+      if (jsonNamespace) sendJson(res, 403, { error: body, code: 'refused' }, method);
+      else sendText(res, 405, body, method);
+      return;
+    }
+  }
+
   if (!isSafeRequestPath(rawPath)) {
     if (jsonNamespace) {
       sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method);

+ 64 - 4
src/ui-server/security.ts

@@ -20,8 +20,11 @@
  * - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
  *   keeps a cross-origin reader from seeing a response body even if it does
  *   reach us. There is deliberately no way to turn this on.
- * - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side
- *   effect, so there is no state for a forged request to change.
+ * - **GET/HEAD everywhere; POST/DELETE only under `/api/`, and only for a
+ *   request that could not have been forged by a form.** See
+ *   {@link isWriteRequest} below — the viewer went from a pure reader to one
+ *   that saves trails into `.codegraph/ui/`, and that is the entire change to
+ *   this boundary.
  * - **Every path resolves through {@link validatePathWithinRoot}** — the same
  *   chokepoint the MCP read sinks use, which catches `../` traversal AND
  *   in-tree symlinks pointing out of the root (#527).
@@ -40,8 +43,65 @@ export { PathRefusalError };
  */
 const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1']);
 
-/** HTTP methods the viewer server answers. Everything else is 405. */
-export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD'];
+/** Methods that answer anywhere: the viewer's assets and every read endpoint. */
+export const READ_METHODS: readonly string[] = ['GET', 'HEAD'];
+
+/**
+ * Methods that answer under `/api/` only, and only for a request carrying
+ * {@link WRITE_HEADER}. The viewer's one write is a saved trail.
+ */
+export const WRITE_METHODS: readonly string[] = ['POST', 'DELETE'];
+
+/** HTTP methods the viewer server answers at all. Everything else is 405. */
+export const ALLOWED_METHODS: readonly string[] = [...READ_METHODS, ...WRITE_METHODS];
+
+/**
+ * The header a write has to carry.
+ *
+ * Belt and braces behind the `Host` and `Origin` checks, and worth the two
+ * lines because it fails *differently*: a custom request header cannot be sent
+ * cross-origin without a CORS preflight, and this server answers no preflight
+ * and sends no `Access-Control-*` header, so the browser never issues the real
+ * request. That closes the one shape those checks lean on a header for — a
+ * `<form method="post">` submitted from another page, which sends no `Origin`
+ * in some older browsers and cannot set a custom header in any of them.
+ */
+export const WRITE_HEADER = 'x-codegraph-ui';
+
+/** The content type a write body must declare. A form can send none of these. */
+const WRITE_CONTENT_TYPE = 'application/json';
+
+export function isWriteMethod(method: string): boolean {
+  return WRITE_METHODS.includes(method);
+}
+
+/**
+ * Whether a mutating request is one the viewer could have made.
+ *
+ * @param method     the request method, already known to be a write method
+ * @param pathname   the raw request path
+ * @param headers    `x-codegraph-ui` and, for a body-carrying method, `content-type`
+ */
+export function isWriteRequest(
+  pathname: string,
+  headers: { marker: string | undefined; contentType: string | undefined }
+): { ok: true } | { ok: false; reason: string } {
+  // Writes live under /api/ and nowhere else. The static side of this server
+  // serves a built bundle; there is nothing there to POST to.
+  if (pathname !== '/api' && !pathname.startsWith('/api/')) {
+    return { ok: false, reason: 'Only the /api/ endpoints accept writes.' };
+  }
+  if (headers.marker === undefined || headers.marker.trim() === '') {
+    return { ok: false, reason: `A write must carry the ${WRITE_HEADER} header.` };
+  }
+  if (headers.contentType !== undefined) {
+    const type = headers.contentType.split(';')[0]?.trim().toLowerCase();
+    if (type !== '' && type !== WRITE_CONTENT_TYPE) {
+      return { ok: false, reason: `A write body must be ${WRITE_CONTENT_TYPE}.` };
+    }
+  }
+  return { ok: true };
+}
 
 interface HostParts {
   hostname: string;

+ 21 - 5
ui/README.md

@@ -64,8 +64,8 @@ build so that mistake cannot land twice.
 
 Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
 `FileSourceView`, `EntryPointsView`, `DeadCodeView`, `TypeHierarchy`, `TrailBar`,
-`SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`,
-`ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
+`SavedTrails`, `SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`,
+`KindGlyph`, `ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
 built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`,
 `buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter
 answers in.
@@ -91,6 +91,11 @@ interface GraphAdapter {
   routes(request?, signal?): Promise<WireRoutes>;
   entryPoints(request?, signal?): Promise<WireEntryPoints>;
   deadCode(request?, signal?): Promise<WireDeadCode>;
+  trails(signal?): Promise<WireTrails>;
+
+  // The only mutating pair, and the only optional methods besides `events`.
+  saveTrail?(request, signal?): Promise<WireTrails>;
+  deleteTrail?(id, signal?): Promise<WireTrails>;
   events?(handlers): () => void;   // optional: the live channel
 }
 ```
@@ -99,8 +104,8 @@ The shapes are exactly what `src/ui-server/api/` serialises, and they live in
 `src/lib/wire.ts` — no imports, no runtime — so a host can depend on the
 vocabulary without depending on the viewer. The default implementation,
 `createHttpAdapter()`, is the loopback JSON API; a host that already holds the
-index implements the same twelve methods against its own reads and never makes
-an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
+index implements the same thirteen required methods against its own reads and
+never makes an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
 built package but `lib/adapter.js` touches the network, because a screen that
 reached past the adapter would be a screen that ignored the host.
 
@@ -108,6 +113,15 @@ reached past the adapter would be a screen that ignored the host.
 that learns about a sync some other way calls `live.signal('index')` instead,
 which is the same code path the stream uses.
 
+`saveTrail` / `deleteTrail` are optional for a different reason: they are the
+only methods in the interface that CHANGE anything, and a host must be able to
+render the reader without inheriting a write it never asked for. Omit them and
+`TrailBar` grows no Save button and `SavedTrails` says the host does not store
+them — the same thing it does when `trails()` answers `readOnly: true`, which is
+how a host that *can* store them declines a particular project. `trails()` itself
+is required: a host with nowhere to keep them answers an empty read-only list, so
+the screen is explained rather than silently missing.
+
 ### Three things that will bite
 
 1. **Import `theme.css` once.** Every component paints from the design tokens.
@@ -165,6 +179,8 @@ src/
   App.svelte              top bar / trail bar / main, global keys
   lib/router.svelte.ts    hash router: #/s/<id>, #/file/<path>, #/map, #/flow, #/entry
   lib/trail.svelte.ts     the walked path; mirrored into the `t` query param
+  lib/trails.svelte.ts    saved trails: one shared fetch, and the two writes
+  lib/trails-model.ts     what a saved trail's row says, incl. its decay (pure)
   lib/kinds.ts            kind glyph letters
   lib/map-model.ts        the Map's deterministic layered layout (pure)
   lib/flow-model.ts       the Flow strip's card/link geometry + the end cap — a DAG (pure)
@@ -174,7 +190,7 @@ src/
   lib/export-image.ts     rasterising that SVG to PNG, clipboard and download
   lib/live.svelte.ts      /api/events: two counters every screen refreshes from
   lib/toast.svelte.ts     the one transient note ("Index updated · reloaded")
-  components/             TopBar, TrailBar, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
+  components/             TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
   views/                  one component per route
 ```
 

+ 5 - 0
ui/src/App.svelte

@@ -23,6 +23,7 @@
   } from './lib/router.svelte';
   import { palette } from './lib/palette.svelte';
   import { trail, resolveTrailNames } from './lib/trail.svelte';
+  import { trails } from './lib/trails.svelte';
   import { project } from './lib/project.svelte';
   import { live } from './lib/live.svelte';
   import { toast } from './lib/toast.svelte';
@@ -53,6 +54,10 @@
       // kept — so without this the resting palette, the empty screen and the
       // entry-points panel would all keep describing the graph as it was.
       void palette.reloadEntries();
+      // Saved trails are re-resolved by the server against the index that just
+      // moved, so their decay lines are stale the moment it does — a hop that
+      // was "gone" a minute ago may be back, and vice versa.
+      void trails.reload();
       toast.show('Index updated · reloaded');
     });
   });

+ 6 - 2
ui/src/app.css

@@ -78,11 +78,15 @@ h3 {
 /* ---------- app shell ----------
    Design spec §3.1: top bar 48px / trail bar 34px / main. The grid is on
    index.html's mount host, which App.svelte fills directly (no wrapper — a
-   second #app would duplicate the id). */
+   second #app would duplicate the id).
+
+   The trail row is `auto`, not `--trailbar-h`: the bar keeps that height on
+   its own (see TrailBar.svelte) and grows only while the save-trail form is
+   open. Pinning the row instead would clip the form. */
 #app {
   height: 100vh;
   display: grid;
-  grid-template-rows: var(--topbar-h) var(--trailbar-h) 1fr;
+  grid-template-rows: var(--topbar-h) auto 1fr;
 }
 
 /* ---------- cross-view primitives ---------- */

+ 305 - 0
ui/src/components/SavedTrails.svelte

@@ -0,0 +1,305 @@
+<script lang="ts">
+  /**
+   * The trails somebody kept — the fifth answer to "where do I start".
+   *
+   * The other four (routes, executable files, tests, hubs) are derived from the
+   * graph and describe the project. This one is written by hand and describes
+   * what a person thought was worth explaining, which is why it sits above them
+   * on the empty screen: a named walk beats a ranked list every time there is
+   * one.
+   *
+   * Rows follow the search-result grid (18px glyph · name · meta) so the empty
+   * screen reads as one list rather than two lists in one column. What they add
+   * is the honesty line: a trail is a claim about code that has since moved, and
+   * every row says what became of its hops.
+   */
+  import KindGlyph from './KindGlyph.svelte';
+  import { symbolHref, navigate } from '../lib/navigation';
+  import { trails } from '../lib/trails.svelte';
+  import { trail } from '../lib/trail.svelte';
+  import { decodeTrail } from '../lib/trail-codec';
+  import {
+    isOpenable,
+    trailDecay,
+    trailExport,
+    trailMeta,
+    trailOpens,
+    trailTitle,
+  } from '../lib/trails-model';
+  import type { WireTrail } from '../lib/api';
+
+  interface Props {
+    /** Heading text. The empty screen and the entry-points panel word it alike. */
+    title?: string;
+    /** Render nothing at all when there are no saved trails (the empty screen). */
+    hideWhenEmpty?: boolean;
+  }
+  let { title = 'Saved trails', hideWhenEmpty = true }: Props = $props();
+
+  $effect(() => {
+    void trails.ensure();
+  });
+
+  /** Which row is asking to be confirmed before it is deleted. */
+  let confirming = $state<string | null>(null);
+
+  let list = $derived(trails.list);
+
+  /**
+   * Open a trail: adopt its hops, then navigate to the one it ends on.
+   *
+   * The store is primed BEFORE the URL changes so the bar draws named hops
+   * immediately rather than a row of hashes that resolve a moment later — the
+   * encoded trail carries ids and nothing else, and every name is already here.
+   */
+  function open(saved: WireTrail) {
+    if (!isOpenable(saved)) return;
+    const hops = decodeTrail(saved.encoded);
+    trail.clear();
+    const resolved = saved.hops.filter((hop) => hop.id !== null);
+    for (const hop of hops) {
+      const known = resolved.find((h) => h.id === hop.id);
+      trail.push({ id: hop.id, name: known?.name ?? null, kind: known?.kind ?? null, dir: hop.dir });
+    }
+    navigate(symbolHref(saved.openId as string, { trail: saved.encoded as string }));
+  }
+
+  async function remove(saved: WireTrail) {
+    if (confirming !== saved.id) {
+      confirming = saved.id;
+      return;
+    }
+    confirming = null;
+    await trails.remove(saved.id);
+  }
+
+  /**
+   * Hand the trail over as the file it is.
+   *
+   * `.codegraph/` is gitignored wholesale, which is right for a scratch walk
+   * and wrong for a tour worth committing — so exporting is a copy the reader
+   * makes deliberately, and lands wherever their browser puts downloads.
+   */
+  function download(saved: WireTrail) {
+    const blob = new Blob([trailExport(saved)], { type: 'application/json' });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = `${saved.id}.json`;
+    link.click();
+    URL.revokeObjectURL(url);
+  }
+</script>
+
+{#if !(hideWhenEmpty && list.length === 0 && trails.failure === null)}
+  <section class="trails" aria-label={title}>
+    <div class="head">
+      <h3>{title}</h3>
+      {#if trails.directory}
+        <span class="where">{trails.directory}</span>
+      {/if}
+    </div>
+
+    {#if trails.failure}
+      <p class="msg err">{trails.failure}</p>
+    {:else if !trails.settled}
+      <p class="msg">Reading saved trails…</p>
+    {:else if list.length === 0}
+      <p class="msg">
+        No saved trails yet. Walk a path through the code, then press
+        <strong>Save trail</strong> on the trail bar to keep it.
+        {#if trails.readOnlyReason}
+          <br />{trails.readOnlyReason}
+        {/if}
+      </p>
+    {:else}
+      <div class="rows">
+        {#each list as saved (saved.id)}
+          {@const decay = trailDecay(saved)}
+          {@const opens = trailOpens(saved)}
+          <div class="row" class:dead={!isOpenable(saved)}>
+            <button
+              type="button"
+              class="pick"
+              title={trailTitle(saved)}
+              disabled={!isOpenable(saved)}
+              onclick={() => open(saved)}
+            >
+              <KindGlyph kind={saved.hops[0]?.kind ?? null} />
+              <span class="mid">
+                <span class="nm">{saved.name}</span>
+                {#if saved.note}<span class="note">{saved.note}</span>{/if}
+              </span>
+              <span class="meta">{trailMeta(saved)}</span>
+            </button>
+
+            <div class="acts">
+              <button type="button" class="act" onclick={() => download(saved)}>Export</button>
+              {#if trails.canSave}
+                <button
+                  type="button"
+                  class="act"
+                  class:armed={confirming === saved.id}
+                  disabled={trails.busy}
+                  onclick={() => remove(saved)}
+                  onblur={() => (confirming = confirming === saved.id ? null : confirming)}
+                >
+                  {confirming === saved.id ? 'Delete?' : 'Delete'}
+                </button>
+              {/if}
+            </div>
+
+            <!-- The honesty line. A saved trail is a claim about code that has
+                 since moved; this is where the graph gets to say so. -->
+            {#if decay || opens}
+              <p class="decay" class:warn={decay?.tone === 'warn'}>
+                {[decay?.text, opens].filter(Boolean).join(' ')}
+              </p>
+            {/if}
+          </div>
+        {/each}
+      </div>
+      {#if trails.payload?.bounded}
+        <p class="msg">Only the first trails in the directory are listed.</p>
+      {/if}
+    {/if}
+  </section>
+{/if}
+
+<style>
+  .trails {
+    max-width: 720px;
+  }
+
+  .head {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 12px;
+    margin-bottom: 8px;
+  }
+
+  .trails h3 {
+    margin: 0;
+    font-size: 14px;
+    font-weight: 600;
+  }
+
+  .where {
+    color: var(--ink-4);
+    font-family: var(--mono);
+    font-size: 11px;
+  }
+
+  .msg {
+    margin: 0;
+    padding: 8px 0 0;
+    color: var(--ink-3);
+    font-size: 12px;
+  }
+
+  .msg.err {
+    color: var(--accent);
+  }
+
+  .rows {
+    border: 1px solid var(--rule-soft);
+  }
+
+  .row {
+    position: relative;
+    border-bottom: 1px solid var(--rule-faint);
+  }
+
+  .row:last-child {
+    border-bottom: 0;
+  }
+
+  .pick {
+    display: grid;
+    width: 100%;
+    align-items: baseline;
+    padding: 6px 10px;
+    color: var(--ink);
+    gap: 10px;
+    grid-template-columns: 18px 1fr auto;
+    text-align: left;
+  }
+
+  .pick:hover:not(:disabled) {
+    background: var(--press);
+  }
+
+  .pick:disabled {
+    color: var(--ink-3);
+    cursor: default;
+  }
+
+  .mid {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .nm {
+    font-family: var(--mono);
+    font-size: 12.5px;
+  }
+
+  .note {
+    margin-left: 6px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+
+  /* Room for the actions, which overlay the row's right edge. */
+  .meta {
+    padding-right: 96px;
+    color: var(--ink-3);
+    font-family: var(--mono);
+    font-size: 11px;
+    white-space: nowrap;
+  }
+
+  /* Always drawn, never revealed on hover: a control that appears when the
+     pointer arrives is one a keyboard reader has to guess at. It recedes to
+     ink-3 instead, which is the same thing done with ink. */
+  .acts {
+    position: absolute;
+    top: 4px;
+    right: 8px;
+    display: flex;
+    gap: 4px;
+  }
+
+  .act {
+    padding: 2px 6px;
+    color: var(--ink-3);
+    background: var(--paper);
+    border: 1px solid var(--rule-soft);
+    font-family: var(--sans);
+    font-size: 11px;
+  }
+
+  .act:hover:not(:disabled) {
+    color: var(--ink);
+    border-color: var(--ink);
+  }
+
+  .act.armed {
+    color: var(--accent);
+    border-color: var(--accent-line);
+    background: var(--accent-soft);
+  }
+
+  .decay {
+    margin: 0;
+    padding: 0 10px 6px 38px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+
+  .decay.warn {
+    color: var(--amber);
+  }
+</style>

+ 173 - 3
ui/src/components/TrailBar.svelte

@@ -1,10 +1,71 @@
 <script lang="ts">
+  /**
+   * The path the reader walked — and the one place they can keep it.
+   *
+   * "Save trail" is the viewer's only write. It opens a one-field form rather
+   * than a dialog because naming a walk is a thought the reader is already
+   * having; anything modal would stop the reading to ask about filing.
+   */
   import KindGlyph from './KindGlyph.svelte';
   import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
   import { navigate, symbolHref, flowHref } from '../lib/navigation';
+  import { trails } from '../lib/trails.svelte';
+  import { replacedTrail, trailNameProblem } from '../lib/trails-model';
+  import { toast } from '../lib/toast.svelte';
+
+  /** Matches `MAX_TRAIL_NAME` in `src/ui-server/api/trail-store.ts`. */
+  const MAX_NAME = 120;
 
   let hops = $derived(trail.hops);
 
+  let naming = $state(false);
+  let name = $state('');
+  let nameInput: HTMLInputElement | null = $state(null);
+
+  // The list is wanted before Save is pressed, not after: it decides whether
+  // this name would REPLACE something, which the form has to say beforehand.
+  $effect(() => {
+    if (naming) void trails.ensure();
+  });
+
+  let problem = $derived(trailNameProblem(name, MAX_NAME));
+  let replaces = $derived(naming ? replacedTrail(name, trails.list) : null);
+
+  function openForm() {
+    trails.clearFailure();
+    naming = true;
+    // The last hop is the thing the reader is looking at, so it is the most
+    // likely name for the walk that got there — offered, not imposed.
+    name = trail.current?.name ?? '';
+    queueMicrotask(() => {
+      nameInput?.focus();
+      nameInput?.select();
+    });
+  }
+
+  function closeForm() {
+    naming = false;
+    name = '';
+  }
+
+  async function submit(event: Event) {
+    event.preventDefault();
+    if (problem || trails.busy) return;
+    const replacing = replaces !== null;
+    const saved = await trails.save(name, '', hops);
+    if (saved === null) return; // the reason is on `trails.failure`, shown below
+    toast.show(replacing ? `Trail replaced · ${name.trim()}` : `Trail saved · ${name.trim()}`);
+    closeForm();
+  }
+
+  function onkeydown(event: KeyboardEvent) {
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      event.stopPropagation();
+      closeForm();
+    }
+  }
+
   function step(index: number) {
     const hop = hops[index];
     if (!hop) return;
@@ -42,6 +103,10 @@
   }
 </script>
 
+<!-- One root element, always: the save form is a second row inside it rather
+     than a sibling, so a host's layout still sees the trail bar as one box
+     whose height grows only while the form is open. -->
+<div class="trailwrap">
 <div class="trailbar">
   <span class="label">Trail</span>
 
@@ -84,19 +149,66 @@
   {#if hops.length > 1}
     <button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
   {/if}
+  {#if hops.length > 0 && trails.canSave && !naming}
+    <button type="button" class="tb-btn" onclick={openForm}>Save trail</button>
+  {/if}
   {#if hops.length > 0}
     <button type="button" class="tb-btn" onclick={clear}>Clear</button>
   {/if}
 </div>
 
+{#if naming}
+  <form class="saveform" onsubmit={submit}>
+    <label for="trail-name">Name this trail</label>
+    <input
+      bind:this={nameInput}
+      bind:value={name}
+      {onkeydown}
+      id="trail-name"
+      type="text"
+      maxlength={MAX_NAME}
+      autocomplete="off"
+      spellcheck="false"
+      placeholder="How a request reaches the handler"
+    />
+    <button type="submit" class="tb-btn" disabled={problem !== null || trails.busy}>
+      {trails.busy ? 'Saving…' : replaces ? 'Replace' : 'Save'}
+    </button>
+    <button type="button" class="tb-btn" onclick={closeForm}>Cancel</button>
+    <!-- Everything the reader should know BEFORE pressing, in one line: what
+         it will be called, that it will overwrite, and where it lands. -->
+    <span class="hint" class:warn={replaces !== null}>
+      {#if replaces}
+        Replaces the saved trail of the same name.
+      {:else if trails.directory}
+        {hops.length} hop{hops.length === 1 ? '' : 's'} · saved to {trails.directory}
+      {:else}
+        {hops.length} hop{hops.length === 1 ? '' : 's'}
+      {/if}
+    </span>
+    {#if trails.failure}
+      <span class="err">{trails.failure}</span>
+    {/if}
+  </form>
+{/if}
+</div>
+
 <style>
+  .trailwrap {
+    display: flex;
+    min-height: 0;
+    flex-direction: column;
+    background: var(--paper-2);
+    border-bottom: 1px solid var(--rule-soft);
+  }
+
   .trailbar {
     display: flex;
+    height: var(--trailbar-h, 34px);
     align-items: center;
+    flex: 0 0 auto;
     gap: 0;
     padding: 0 18px;
-    background: var(--paper-2);
-    border-bottom: 1px solid var(--rule-soft);
     overflow-x: auto;
     white-space: nowrap;
     font-family: var(--mono);
@@ -158,8 +270,66 @@
     font-family: var(--sans);
   }
 
-  .tb-btn:hover {
+  .tb-btn:hover:not(:disabled) {
     color: var(--ink);
     border-color: var(--ink);
   }
+
+  .tb-btn:disabled {
+    color: var(--ink-4);
+    border-color: var(--rule-faint);
+  }
+
+  /* ---------- the one-field save form ---------- */
+
+  .saveform {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 6px 18px 8px;
+    border-top: 1px solid var(--rule-faint);
+    flex-wrap: wrap;
+  }
+
+  .saveform label {
+    color: var(--ink-2);
+    font-family: var(--sans);
+    font-size: 12px;
+  }
+
+  .saveform input {
+    width: 320px;
+    height: 30px;
+    max-width: 100%;
+    padding: 0 10px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink);
+    font: 13px var(--sans);
+  }
+
+  .saveform input:focus {
+    border-color: var(--ink);
+    outline: none;
+  }
+
+  .saveform input::placeholder {
+    color: var(--ink-4);
+  }
+
+  .hint {
+    color: var(--ink-3);
+    font-family: var(--sans);
+    font-size: 11.5px;
+  }
+
+  .hint.warn {
+    color: var(--amber);
+  }
+
+  .err {
+    color: var(--accent);
+    font-family: var(--sans);
+    font-size: 11.5px;
+  }
 </style>

+ 19 - 1
ui/src/index.ts

@@ -54,6 +54,7 @@ export type {
   LiveHandlers,
   MapRequest,
   RoutesRequest,
+  SaveTrailRequest,
   SearchRequest,
   SourceRequest,
 } from './lib/adapter';
@@ -102,8 +103,10 @@ export { default as DeadCodeView } from './views/DeadCodeView.svelte';
 
 /* -------------------------------------------------------- the furniture -- */
 
-/** The path walked, with its arrows and its "read as flow". */
+/** The path walked, with its arrows, its "read as flow" and its Save. */
 export { default as TrailBar } from './components/TrailBar.svelte';
+/** The trails somebody kept, each hop re-resolved against the current graph. */
+export { default as SavedTrails } from './components/SavedTrails.svelte';
 /** The search box, its keyboard and its results panel — one component. */
 export { default as SearchPalette } from './components/SearchPalette.svelte';
 /** The results panel alone, for a host that owns the input. */
@@ -124,6 +127,7 @@ export { default as TypeHierarchy } from './components/symbol/TypeHierarchy.svel
 export { trail, resolveTrailNames } from './lib/trail.svelte';
 export { encodeTrail, decodeTrail, hopLabel } from './lib/trail-codec';
 export type { HopDirection, TrailHop } from './lib/trail-codec';
+export { trails } from './lib/trails.svelte';
 export { live, liveRefresh, touchesFile } from './lib/live.svelte';
 export type { LiveChanged, LiveHello, LiveIndexEvent, LiveIndexRevision } from './lib/live.svelte';
 export { project } from './lib/project.svelte';
@@ -230,6 +234,20 @@ export {
   DEAD_CODE_CAVEAT,
 } from './lib/deadcode-model';
 
+export {
+  hopStatusWord,
+  isOpenable as isTrailOpenable,
+  replacedTrail,
+  trailDecay,
+  trailExport,
+  trailMeta,
+  trailNameProblem,
+  trailOpens,
+  trailTitle,
+  MAX_NAMED_DECAYED,
+} from './lib/trails-model';
+export type { TrailDecay } from './lib/trails-model';
+
 export { buildEntryPanel, flowPair, matchEntries } from './lib/entry-model';
 export type {
   EntryGroup,

+ 81 - 10
ui/src/lib/adapter.ts

@@ -3,8 +3,8 @@
  * through one {@link GraphAdapter} (task CG-61).
  *
  * The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
- * the read-only JSON API over loopback. A host that already holds the graph —
- * CodeGraph Pro, which opens the index in-process — implements the same twelve
+ * the JSON API over loopback. A host that already holds the graph — CodeGraph
+ * Pro, which opens the index in-process — implements the same thirteen required
  * methods against its own reads and never makes an HTTP request. The components
  * cannot tell the difference, which is the whole point: one implementation of
  * the Symbol view, the Flow strip and the Map, drawn from whichever side of the
@@ -41,6 +41,7 @@ import type {
   WireSource,
   WireStats,
   WireSymbolPayload,
+  WireTrails,
 } from './wire';
 
 /* ---------------------------------------------------------------- errors -- */
@@ -144,6 +145,20 @@ export interface DeadCodeRequest {
   includeGenerated?: boolean;
 }
 
+/**
+ * A trail to save: a name, an optional note, and the walk as ids.
+ *
+ * Ids and directions only. Everything else a saved hop records — the name, the
+ * kind, the file, the line — is read out of the graph by the answering side, so
+ * a saved trail is always a claim the index itself made and can therefore
+ * re-check when it next changes.
+ */
+export interface SaveTrailRequest {
+  name: string;
+  note?: string;
+  hops: ReadonlyArray<{ dir: 'start' | 'down' | 'up'; id: string }>;
+}
+
 /* ----------------------------------------------------------------- live -- */
 
 /**
@@ -171,8 +186,11 @@ export interface LiveHandlers {
  * `source`, `file`, `flow`, `map`, `routes` — and the rest are what the screens
  * around them need: `stats` (the blast bar's denominator and the top bar's
  * counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode` (the
- * whole-file view), `entryPoints` (where a reader starts) and `deadCode` (where
- * nobody goes).
+ * whole-file view), `entryPoints` (where a reader starts), `deadCode` (where
+ * nobody goes) and `trails` (the walks the reader kept).
+ *
+ * Everything here answers a question except `saveTrail`/`deleteTrail`, which
+ * are optional for exactly that reason.
  */
 export interface GraphAdapter {
   /** The index's own facts: counts, thresholds, the blast scale. */
@@ -198,6 +216,26 @@ export interface GraphAdapter {
   entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
   /** Symbols nothing reaches, grouped by file, with every exclusion counted. */
   deadCode(request?: DeadCodeRequest, signal?: AbortSignal): Promise<WireDeadCode>;
+  /**
+   * The reader's saved trails, each hop re-resolved against the current graph.
+   *
+   * A host with nowhere to keep them answers `{ trails: [], readOnly: true, … }`
+   * rather than omitting the method: the screens then show the section as
+   * empty-and-explained instead of showing a Save button that does nothing.
+   */
+  trails(signal?: AbortSignal): Promise<WireTrails>;
+  /**
+   * Save a trail, answering the full list as it now stands.
+   *
+   * OPTIONAL, and the only mutating pair in this interface. An adapter that
+   * refuses to write simply omits {@link saveTrail} and {@link deleteTrail} —
+   * a host must be able to render the reader without inheriting a filesystem
+   * write it never asked for, and the viewer hides Save when they are absent
+   * exactly as it does when the server answers `readOnly`.
+   */
+  saveTrail?(request: SaveTrailRequest, signal?: AbortSignal): Promise<WireTrails>;
+  /** Remove a saved trail by id, answering the list as it now stands. */
+  deleteTrail?(id: string, signal?: AbortSignal): Promise<WireTrails>;
   /**
    * Subscribe to index/disk changes. Optional — a host without a live channel
    * omits it and nothing polls. Returns a function that closes the stream.
@@ -229,7 +267,18 @@ function query(params: URLSearchParams): string {
 }
 
 /**
- * The default adapter: the read-only JSON API `codegraph ui` serves.
+ * The header every write carries.
+ *
+ * The server refuses a `POST`/`DELETE` without it. It is not a secret and is
+ * not trying to be: a custom request header cannot be sent cross-origin without
+ * a CORS preflight, and the viewer's server answers none — so its presence is
+ * proof the request came from a page the server itself served. Must match
+ * `WRITE_HEADER` in `src/ui-server/security.ts`.
+ */
+export const WRITE_HEADER = 'X-CodeGraph-UI';
+
+/**
+ * The default adapter: the JSON API `codegraph ui` serves.
  *
  * Every failure it can describe comes back as an {@link ApiFailure} carrying
  * the server's own sentence. The one it cannot describe — the server was
@@ -241,13 +290,10 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
   const doFetch = options.fetch ?? ((...args: Parameters<typeof globalThis.fetch>) =>
     globalThis.fetch(...args));
 
-  async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
+  async function call<T>(path: string, init: RequestInit, signal?: AbortSignal): Promise<T> {
     let response: Response;
     try {
-      response = await doFetch(`${base}${path}`, {
-        signal,
-        headers: { accept: 'application/json' },
-      });
+      response = await doFetch(`${base}${path}`, { ...init, signal });
     } catch (cause) {
       if (signal?.aborted) throw cause;
       throw new ApiFailure(
@@ -271,6 +317,24 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
     return body as T;
   }
 
+  function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
+    return call<T>(path, { headers: { accept: 'application/json' } }, signal);
+  }
+
+  /** A write: the marker header, and a JSON body when there is one to send. */
+  function write<T>(path: string, method: string, body?: unknown, signal?: AbortSignal): Promise<T> {
+    const headers: Record<string, string> = {
+      accept: 'application/json',
+      [WRITE_HEADER]: '1',
+    };
+    if (body !== undefined) headers['content-type'] = 'application/json';
+    return call<T>(
+      path,
+      { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) },
+      signal
+    );
+  }
+
   return {
     stats: (signal) => getJson<WireStats>('api/stats', signal),
 
@@ -346,6 +410,13 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
       return getJson<WireDeadCode>(`api/deadcode${query(params)}`, signal);
     },
 
+    trails: (signal) => getJson<WireTrails>('api/trails', signal),
+
+    saveTrail: (request, signal) => write<WireTrails>('api/trails', 'POST', request, signal),
+
+    deleteTrail: (id, signal) =>
+      write<WireTrails>(`api/trails/${encodeURIComponent(id)}`, 'DELETE', undefined, signal),
+
     events(handlers) {
       if (typeof EventSource === 'undefined') return () => {};
       const stream = new EventSource(`${base}api/events`);

+ 50 - 1
ui/src/lib/api.ts

@@ -11,7 +11,7 @@
  * shape it answers with.
  */
 
-import { getGraphAdapter } from './adapter';
+import { ApiFailure, getGraphAdapter } from './adapter';
 import type {
   WireDeadCode,
   WireEntryPoints,
@@ -25,7 +25,9 @@ import type {
   WireSource,
   WireStats,
   WireSymbolPayload,
+  WireTrails,
 } from './wire';
+import type { SaveTrailRequest } from './adapter';
 
 export * from './wire';
 export { ApiFailure } from './adapter';
@@ -38,6 +40,7 @@ export type {
   LiveHandlers,
   MapRequest,
   RoutesRequest,
+  SaveTrailRequest,
   SearchRequest,
   SourceRequest,
 } from './adapter';
@@ -158,3 +161,49 @@ export function fetchFlow(
 ): Promise<WireFlowPayload> {
   return getGraphAdapter().flow(spec, signal);
 }
+
+/* ---------------------------------------------------------- saved trails -- */
+
+/**
+ * The reader's saved trails, every hop re-resolved against the current index.
+ *
+ * A trail is stored by qualified name rather than by node id, so this is where
+ * the graph gets to say what became of each hop since it was written: still
+ * there, moved, now ambiguous, or gone.
+ */
+export function fetchTrails(signal?: AbortSignal): Promise<WireTrails> {
+  return getGraphAdapter().trails(signal);
+}
+
+/**
+ * Whether trails can be written at all through the installed adapter.
+ *
+ * Separate from the `readOnly` flag on the payload: that one is the *answering
+ * side* declining, this one is an adapter that never offered. Both hide Save,
+ * and the screens say which it was.
+ */
+export function canWriteTrails(): boolean {
+  const adapter = getGraphAdapter();
+  return typeof adapter.saveTrail === 'function' && typeof adapter.deleteTrail === 'function';
+}
+
+/** Save a trail, answering the whole list as it now stands. */
+export function saveTrail(
+  request: SaveTrailRequest,
+  signal?: AbortSignal
+): Promise<WireTrails> {
+  const adapter = getGraphAdapter();
+  if (!adapter.saveTrail) {
+    return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot save trails.', null));
+  }
+  return adapter.saveTrail(request, signal);
+}
+
+/** Remove a saved trail, answering the whole list as it now stands. */
+export function deleteTrail(id: string, signal?: AbortSignal): Promise<WireTrails> {
+  const adapter = getGraphAdapter();
+  if (!adapter.deleteTrail) {
+    return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot delete trails.', null));
+  }
+  return adapter.deleteTrail(id, signal);
+}

+ 202 - 0
ui/src/lib/trails-model.ts

@@ -0,0 +1,202 @@
+/**
+ * What a saved trail says about itself, without a browser.
+ *
+ * `/api/trails` hands back each trail with every hop already re-resolved
+ * against the current index — so all this module does is turn that into the
+ * sentences the rows print. It is pure for the usual reason (it can be tested,
+ * and a host building its own trail list gets the shipped arithmetic rather
+ * than its own), and because the interesting decisions here are *wording*
+ * decisions, which is exactly the kind of thing that drifts when it is spread
+ * across two components.
+ *
+ * The one rule it keeps: **a trail that has decayed never reads as intact.**
+ * A saved trail is somebody's explanation of a codebase, and the codebase moves
+ * underneath it. Showing "6 hops" for a trail where two hops no longer resolve
+ * would make it a lie by omission at exactly the moment it needs to be fixed.
+ *
+ * Tested in `__tests__/ui-trails-model.test.ts`.
+ */
+
+import type { WireTrail, WireTrailHop, WireTrailHopStatus } from './wire';
+import { plural } from './symbol-model';
+
+/** Hops named in the decay line before it stops naming them. */
+export const MAX_NAMED_DECAYED = 3;
+
+/**
+ * The row's second line: how long the walk is, and who wrote it.
+ *
+ * The hop count is the SAVED length, always — the trail is six hops whatever
+ * became of them. What became of them is {@link trailDecay}'s job, on its own
+ * line, so the two facts cannot be read as one.
+ */
+export function trailMeta(trail: WireTrail): string {
+  const hops = plural(trail.hops.length, 'hop');
+  return trail.author ? `${hops} · ${trail.author}` : hops;
+}
+
+/** The verdict a decayed hop carries, in the words a row uses. */
+export function hopStatusWord(status: WireTrailHopStatus): string {
+  switch (status) {
+    case 'ok':
+      return 'still here';
+    case 'moved':
+      return 'moved';
+    case 'ambiguous':
+      return 'ambiguous';
+    case 'missing':
+      return 'gone';
+  }
+}
+
+export interface TrailDecay {
+  /** `warn` when something is unopenable, `note` when it merely moved. */
+  tone: 'warn' | 'note';
+  text: string;
+}
+
+/**
+ * What has happened to this trail since it was saved, or null when nothing has.
+ *
+ * Two tones, because they call for different things from the reader: a hop that
+ * MOVED still opens and only wants acknowledging, while a hop that is gone (or
+ * that now names several symbols) means the trail no longer says what its author
+ * meant it to say.
+ */
+export function trailDecay(trail: WireTrail): TrailDecay | null {
+  const missing = trail.hops.filter((hop) => hop.status === 'missing');
+  const ambiguous = trail.hops.filter((hop) => hop.status === 'ambiguous');
+  const moved = trail.hops.filter((hop) => hop.status === 'moved');
+
+  if (missing.length > 0) {
+    return {
+      tone: 'warn',
+      text:
+        `${plural(missing.length, 'hop')} moved or renamed since this was saved — ` +
+        `${nameList(missing)} no longer in the index.`,
+    };
+  }
+  if (ambiguous.length > 0) {
+    return {
+      tone: 'warn',
+      text: `${nameList(ambiguous)} now ${ambiguous.length === 1 ? 'names' : 'name'} more than one symbol — showing the closest match.`,
+    };
+  }
+  if (moved.length > 0) {
+    return {
+      tone: 'note',
+      text: `${nameList(moved)} moved to another file since this was saved.`,
+    };
+  }
+  return null;
+}
+
+/**
+ * How much of the trail can actually be opened, or null when all of it can.
+ *
+ * The payload carries the longest run of CONSECUTIVE resolved hops rather than
+ * every resolved hop, because the trail is a path: skipping a broken hop would
+ * encode a step from one symbol to another that nothing joins. When that run is
+ * shorter than the trail, the row has to say so before somebody opens it and
+ * wonders where the first two hops went.
+ */
+export function trailOpens(trail: WireTrail): string | null {
+  if (trail.encoded === null) return 'None of this trail resolves in the current index.';
+  if (trail.openCount === trail.hops.length) return null;
+  const last = trail.openFrom + trail.openCount - 1;
+  const range = trail.openCount === 1 ? `hop ${trail.openFrom}` : `hops ${trail.openFrom}–${last}`;
+  return `Opens ${range} of ${trail.hops.length}.`;
+}
+
+/** Can this row be opened at all? */
+export function isOpenable(trail: WireTrail): boolean {
+  return trail.encoded !== null && trail.openId !== null;
+}
+
+/** Hover text: the whole walk, in order, with its arrows. */
+export function trailTitle(trail: WireTrail): string {
+  const path = trail.hops
+    .map((hop, index) => (index === 0 ? hop.name : `${arrow(hop)} ${hop.name}`))
+    .join(' ');
+  const when = trail.updatedAt ? ` — saved ${trail.updatedAt.slice(0, 10)}` : '';
+  return `${path}${when}`;
+}
+
+function arrow(hop: WireTrailHop): string {
+  return hop.dir === 'up' ? '←' : hop.dir === 'down' ? '→' : '·';
+}
+
+function nameList(hops: readonly WireTrailHop[]): string {
+  const names = hops.slice(0, MAX_NAMED_DECAYED).map((hop) => hop.name);
+  const rest = hops.length - names.length;
+  const listed =
+    names.length === 1
+      ? (names[0] as string)
+      : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
+  return rest > 0 ? `${listed} and ${rest} more` : listed;
+}
+
+/* ------------------------------------------------------------- saving -- */
+
+/**
+ * Why this name cannot be saved, or null when it can.
+ *
+ * Only the two things the server would refuse anyway; everything else about a
+ * name is the reader's business. Checked here as well so the form can disable
+ * its own button rather than teaching by round-trip.
+ */
+export function trailNameProblem(name: string, maxLength: number): string | null {
+  const trimmed = name.trim();
+  if (trimmed === '') return 'Give the trail a name.';
+  if (trimmed.length > maxLength) return `That name is too long (max ${maxLength} characters).`;
+  return null;
+}
+
+/**
+ * The trail this name would replace, or null when it would be a new one.
+ *
+ * Saving under an existing name overwrites it — that is what a reader means by
+ * pressing Save twice — but they should be told before, not after.
+ */
+export function replacedTrail(name: string, trails: readonly WireTrail[]): WireTrail | null {
+  const trimmed = name.trim().replace(/\s+/g, ' ');
+  return trails.find((trail) => trail.name === trimmed) ?? null;
+}
+
+/**
+ * A saved trail as the file it is, ready to be written somewhere a repository
+ * will keep it.
+ *
+ * The trails directory is inside `.codegraph/`, which is gitignored wholesale —
+ * that is the right default for a scratch walk and the wrong one for a tour
+ * worth committing. Exporting is therefore a copy the reader makes on purpose,
+ * and this is the same shape the viewer writes: each hop's saved IDENTITY —
+ * qualified name, kind, the file it was in — so dropping the file into another
+ * checkout re-runs the same resolution rather than baking today's answer in.
+ * Only the id hint is refreshed to whatever the symbol's id is now, since that
+ * is all an id has ever been here.
+ */
+export function trailExport(trail: WireTrail): string {
+  return `${JSON.stringify(
+    {
+      version: 1,
+      id: trail.id,
+      name: trail.name,
+      note: trail.note,
+      author: trail.author,
+      createdAt: trail.createdAt,
+      updatedAt: trail.updatedAt,
+      hops: trail.hops.map((hop) => ({
+        dir: hop.dir,
+        name: hop.name,
+        qualifiedName: hop.qualifiedName,
+        kind: hop.kind,
+        file: hop.savedFile,
+        line: hop.savedLine,
+        id: hop.id ?? '',
+      })),
+    },
+    null,
+    2
+  )}\n`;
+}

+ 164 - 0
ui/src/lib/trails.svelte.ts

@@ -0,0 +1,164 @@
+/**
+ * The saved trails, as live state.
+ *
+ * Everything that decides what a row *says* is in `trails-model.ts`; this owns
+ * the parts that need time — one fetch shared by every screen that lists them,
+ * and the two writes.
+ *
+ * Two things it does deliberately:
+ *
+ * - **A write answers with the whole list, and the whole list is adopted.**
+ *   Saving does not patch one row in place. The server re-resolves every hop of
+ *   every trail on the way out, so a save is also the cheapest moment to learn
+ *   that a trail saved last week has decayed — and patching locally would show
+ *   a screen that had quietly stopped agreeing with the files on disk.
+ * - **Failures are kept, not thrown away.** The one place in the viewer that
+ *   can fail because of the *filesystem* (a read-only checkout, a full disk) is
+ *   here, and "nothing happened" is the worst possible answer to a reader who
+ *   just pressed Save.
+ */
+
+import { canWriteTrails, deleteTrail, fetchTrails, saveTrail, type WireTrail, type WireTrails } from './api';
+import type { TrailHop } from './trail-codec';
+
+let payload = $state<WireTrails | null>(null);
+/** Null until the first attempt settles — the section says "reading" until then. */
+let settled = $state(false);
+let failure = $state<string | null>(null);
+let busy = $state(false);
+
+let inflight: Promise<void> | null = null;
+
+function load(): Promise<void> {
+  if (inflight) return inflight;
+  inflight = fetchTrails()
+    .then((value) => {
+      payload = value;
+      failure = null;
+    })
+    .catch((cause: unknown) => {
+      // A viewer whose trails cannot be listed still works; the section is the
+      // only thing that has to know, and it prints the reason rather than an
+      // empty box that looks like "you have never saved one".
+      payload = null;
+      failure = cause instanceof Error ? cause.message : String(cause);
+    })
+    .finally(() => {
+      settled = true;
+    });
+  return inflight;
+}
+
+function adopt(next: WireTrails): void {
+  payload = next;
+  failure = null;
+  settled = true;
+  // The in-flight promise is the *load*; replacing the payload out from under
+  // it is fine, but a later `ensure()` must not resolve to the stale one.
+  inflight = Promise.resolve();
+}
+
+export const trails = {
+  get list(): readonly WireTrail[] {
+    return payload?.trails ?? [];
+  },
+  get payload(): WireTrails | null {
+    return payload;
+  },
+  /** False until the first fetch settles, however it settled. */
+  get settled(): boolean {
+    return settled;
+  },
+  get failure(): string | null {
+    return failure;
+  },
+  /** A save or a delete is in flight — the form disables itself. */
+  get busy(): boolean {
+    return busy;
+  },
+  /**
+   * Whether the viewer offers to save at all.
+   *
+   * Two independent reasons it might not, and the screens distinguish them:
+   * the adapter never offered a write ({@link canWriteTrails}), or the
+   * answering side declined this one (`readOnly` on the payload). Until the
+   * first fetch settles we assume it can, so the Save button does not flicker
+   * into existence a moment after the trail bar draws.
+   */
+  get canSave(): boolean {
+    if (!canWriteTrails()) return false;
+    return payload === null || !payload.readOnly;
+  },
+  /**
+   * Why saving is off, when it is.
+   *
+   * The answering side's own sentence wins when there is one — it is the more
+   * specific truth, and it is the one that names the flag or the mount that
+   * caused it. The generic line is only for an adapter that never offered a
+   * write at all, which has nothing to say for itself.
+   */
+  get readOnlyReason(): string | null {
+    if (payload?.readOnly) return payload.readOnlyReason ?? 'This viewer is running read-only.';
+    if (!canWriteTrails()) return 'This viewer cannot save trails.';
+    return null;
+  },
+  /** Where the files live, project-relative. Null until known. */
+  get directory(): string | null {
+    return payload?.directory ?? null;
+  },
+
+  /** Load once. Every screen that lists trails calls this. */
+  ensure: load,
+
+  /** Ask again, because the index moved or a file changed underneath us. */
+  reload(): Promise<void> {
+    inflight = null;
+    return load();
+  },
+
+  /**
+   * Save the walk under a name.
+   *
+   * Hops travel as ids and directions only — the answering side reads each
+   * symbol's name, kind and file out of the graph, so a saved trail is always
+   * something the index itself said.
+   *
+   * @returns the id written, or null when the save failed (see `failure`).
+   */
+  async save(name: string, note: string, hops: readonly TrailHop[]): Promise<string | null> {
+    busy = true;
+    try {
+      const answer = await saveTrail({
+        name,
+        note,
+        hops: hops.map((hop) => ({ dir: hop.dir, id: hop.id })),
+      });
+      adopt(answer);
+      return answer.saved ?? null;
+    } catch (cause) {
+      failure = cause instanceof Error ? cause.message : String(cause);
+      return null;
+    } finally {
+      busy = false;
+    }
+  },
+
+  /** Remove a saved trail. Returns whether it went. */
+  async remove(id: string): Promise<boolean> {
+    busy = true;
+    try {
+      adopt(await deleteTrail(id));
+      return true;
+    } catch (cause) {
+      failure = cause instanceof Error ? cause.message : String(cause);
+      return false;
+    } finally {
+      busy = false;
+    }
+  },
+
+  /** Drop the last failure, so a retry starts from a clean screen. */
+  clearFailure(): void {
+    failure = null;
+  },
+};

+ 72 - 0
ui/src/lib/wire.ts

@@ -686,3 +686,75 @@ export interface WireDeadCode {
   corroborated: boolean;
   timing: { elapsedMs: number };
 }
+
+/* ---------------------------------------------------------- saved trails -- */
+
+/**
+ * How a saved hop fared against the index as it is NOW.
+ *
+ * A trail is stored by qualified name rather than by node id (a node id
+ * contains its start line, so any edit above a symbol renames it), and every
+ * hop is re-resolved on the way out. This is what that re-resolution found.
+ */
+export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
+
+export interface WireTrailHop {
+  dir: 'start' | 'down' | 'up';
+  /** The name as it was when the trail was saved. */
+  name: string;
+  qualifiedName: string;
+  kind: string;
+  savedFile: string;
+  savedLine: number;
+  status: WireTrailHopStatus;
+  /** The symbol's id NOW. Null when nothing answers to it any more. */
+  id: string | null;
+  file: string | null;
+  line: number | null;
+  /** Finished screen wording for a status that is not `ok`; null when it is. */
+  note: string | null;
+}
+
+export interface WireTrail {
+  id: string;
+  name: string;
+  note: string;
+  author: string;
+  createdAt: string;
+  updatedAt: string;
+  hops: WireTrailHop[];
+  /** Hops that still resolve to a symbol in this index. */
+  resolved: number;
+  /** Every hop resolved, and none of them moved. */
+  intact: boolean;
+  /**
+   * The longest run of CONSECUTIVE resolved hops, as the `t` param. Null when
+   * nothing in the trail resolves. Never stitched across a hole — the trail is
+   * a path, and a fabricated adjacency is worse than a short one.
+   */
+  encoded: string | null;
+  /** 1-based index of the first hop `encoded` carries. */
+  openFrom: number;
+  /** How many hops `encoded` carries. */
+  openCount: number;
+  /** The symbol the trail opens at — the last hop of that run. */
+  openId: string | null;
+}
+
+export interface WireTrails {
+  trails: WireTrail[];
+  /** Writes are off. Save and Delete are hidden, and the screen says why. */
+  readOnly: boolean;
+  readOnlyReason: string | null;
+  /** Project-relative directory the files live in. */
+  directory: string;
+  /** Files in that directory that were not readable trails. */
+  skipped: number;
+  bounded: boolean;
+  /** The id just written, on the answer to a save. */
+  saved?: string;
+  /** That save replaced a trail of the same name. */
+  replaced?: boolean;
+  /** The id just removed, on the answer to a delete. */
+  deleted?: string;
+}

+ 13 - 0
ui/src/views/EntryView.svelte

@@ -17,6 +17,7 @@
    * at rest, the empty screen and this panel, so all three agree on the order.
    */
   import EntrySection from '../components/entry/EntrySection.svelte';
+  import SavedTrails from '../components/SavedTrails.svelte';
   import { palette } from '../lib/palette.svelte';
   import { buildEntryPanel, flowPair, type EntryRow } from '../lib/entry-model';
   import { flowHref, navigate } from '../lib/navigation';
@@ -120,6 +121,13 @@
     </div>
   {/if}
 
+  <!-- The one list here that a person wrote rather than the graph derived. It
+       is drawn in full (not hidden when empty) because this screen is where a
+       reader comes looking for one. -->
+  <div class="saved">
+    <SavedTrails hideWhenEmpty={false} />
+  </div>
+
   {#if palette.entriesFailure}
     <p class="state">Could not read the entry points — {palette.entriesFailure}</p>
   {:else if !palette.entriesSettled}
@@ -146,6 +154,11 @@
     padding: 26px 40px 6px;
   }
 
+  .saved {
+    max-width: 800px;
+    padding: 14px 40px 0;
+  }
+
   .head h2 {
     margin: 0 0 6px;
     font-size: 20px;

+ 13 - 0
ui/src/views/HomeView.svelte

@@ -14,6 +14,7 @@
    * start a flow, is `#/entry` (`EntryView`); this screen links to it.
    */
   import PaletteRows from '../components/PaletteRows.svelte';
+  import SavedTrails from '../components/SavedTrails.svelte';
   import { palette } from '../lib/palette.svelte';
   import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
   import { entryHref, fileHref, flowHref, navigate } from '../lib/navigation';
@@ -71,6 +72,13 @@
     </p>
   </div>
 
+  <!-- Above the derived lists on purpose: a walk somebody named and kept is a
+       better place to start than any ranking, when there is one. It draws
+       nothing at all when there is not. -->
+  <div class="saved">
+    <SavedTrails />
+  </div>
+
   {#if entries.sections.length > 0}
     <section class="entries" aria-label="Where to start">
       <div class="entries-h">
@@ -96,6 +104,11 @@
     padding-bottom: 8px;
   }
 
+  .saved {
+    max-width: 800px;
+    padding: 8px 40px 0;
+  }
+
   .entries {
     max-width: 720px;
     padding: 8px 40px 48px;