Skip to content

Run title is a display overlay on top of the label; identity stays immutable after spawn

Issue #111: telling running agents apart gets hard once an operator has more than a couple of instances open — every one is named from its spawn-time label (AFK #7, debug · 15:30), and that label is often not the name the operator would have picked. A bad auto-generated label, or a manual instance started in a hurry, has no way to be corrected; History fills up with runs that all read as timestamps. The obvious fix — let the operator rename the thing — cannot mean rename the label, because the label is load-bearing identity, not a caption. It seeds the tmux session name <repo>~<label> (the thing tmux's target-pane parser and the chat tailer's transcript-cwd match key on), the worktree path (<repo>-<label>, an on-disk directory a live process has open as its cwd), and for an AFK run the branch itself — branch existence is the claim (ADR-0008, ADR-0010). Re-keying any of those on a live instance means renaming a directory a process is running in and a branch a tracker is treating as a claim; there is no safe way to do that mid-run, and no reason to accept the risk for what is fundamentally a cosmetic ask. So the rename has to be an overlay: a name the UI shows instead of the label, that never gets close to git, tmux, or the filesystem.

The decisions, pinned:

  • Title is a pure display overlay; identity is immutable after spawn. A nullable runs.title column is the entire feature. Nothing derived from the label — session name, branch, worktree path — is ever recomputed from a title. Setting, clearing, or repeatedly changing a title has zero effect on any live process or git ref.
  • Display chain is title || label-title || branch. Everywhere the UI currently renders instanceTitle(label) (chat header, runs rail, History), it now checks runs.title first: a set title wins outright, an unset title falls back to today's label-derived title, and the branch name is the last-resort fallback for a row with neither. One fallback chain, one implementation, shared by every surface that names an instance.
  • PATCH /api/v1/runs/{id} with {"title": ...}, trimmed and capped. The body is trimmed before validation; an empty string or explicit null clears the column back to NULL (falling through the chain); anything over ~120 characters is rejected with a 400. SanitizeLabel is deliberately not applied — a title never reaches a branch name, a tmux argument, or a path component, so the sanitizer's charset restrictions would only make the field less useful for its one job (a human-readable caption) with no corresponding safety gain.
  • One affordance, chat-header-only; empty save is the reset. The only place an operator edits a title is inline in the chat header — pencil-on-hover, click to edit, enter (or the check button) to save, escape/cancel to bail. Saving an empty value clears the title (falls back to the generated one); there is no separate "reset to generated" control, because empty-save already is that action. The runs rail and History render-only: they show whichever title the fallback chain resolves to, with no rename entry point of their own.
  • The raw label stays visible as secondary text once a title is set. When runs.title is non-empty, the chat header still shows the label-derived identity — e.g. afk-83 · <repo>~afk-83 — as secondary text or a tooltip, so the operator can still correlate the friendly name with what tmux attach or a terminal git branch actually shows. A title is a nickname layered on top of a name that keeps working, not a replacement for it.
  • Notification payloads use the title overlay too. Both push builders (done-signal in internal/afk/notify.go, needs-input in internal/chat/notify.go) resolve a run's display name through the shared Run.DisplayName() — the title when set, else the session name (Go never parses labels; the session name already carries <repo>~<label>) — so a renamed run's push reads the name the operator set. PR titles, commit messages, and deep links are untouched — those are forge/git objects, not overlay surface.

Status

Accepted. Amended by issue #120: the chat header's inline secondary text is the project name (sessionRepo(session_name)), not the raw label · session string — that string doubled the label (the session name already embeds it) and out-shrank the display title in the header's flex row. The raw session identity moved to the title button's tooltip, where the tmux/branch/worktree correlation stays reachable without competing with the title for width, and the layout is now one code path for titled and untitled runs alike. The decision itself stands: a title is a nickname beside identity that keeps working, never a replacement for it.

Establishes the title overlay (runs.title, the PATCH endpoint, the shared fallback chain, and the chat-header affordance) as the one way an operator renames how an instance is displayed. Extends ADR-0008's session/worktree identity model rather than conflicting with it: ADR-0008 fixed the label as the immutable key for tmux, branch, and worktree naming, and this ADR adds a display-only layer strictly on top, touching none of that.

Considered options

  • Re-key identity on rename (rename the tmux session, branch, and/or worktree). Rejected: the tmux session name and worktree path are a live process's cwd/pane identity, and the branch is the AFK claim the tracker and reaper key on — renaming any of them out from under a running instance breaks the chat tailer's transcript-cwd match and voids the claim mid-run. Cosmetic naming is not worth that blast radius.
  • Rename affordances in the rail context menu and in History. Rejected: one canonical edit surface (the chat header) is enough, and duplicating it into the rail's context menu and History's row UI multiplies the places validation and the empty-save-as-reset rule have to be reimplemented and kept consistent for no real gain — both already need to render the fallback chain regardless, so they do, and stop there.
  • Apply SanitizeLabel to titles. Rejected: SanitizeLabel's charset restrictions exist because a label is embedded in a tmux session name, a branch name, and a path component. A title reaches none of those — sanitizing it would only strip characters (spaces, punctuation, emoji) an operator would reasonably want in a caption, with no corresponding identity risk to guard against.
  • A separate "reset to generated title" button. Rejected: saving an empty value from the same inline editor already clears runs.title and falls the display back to the generated title. A dedicated reset control would be a second code path doing exactly what an empty save already does.

Consequences

  • migrations/{sqlite,postgres}/0009_run_title.sql: adds nullable runs.title (shipped as 0008; renumbered when it collided with #104's 0008_repo_secrets on main).
  • internal/store/runs.go: a Title field on the run struct plus UpdateRunTitle, the one write path for the column (trim/clear/cap validation lives here or in the handler that calls it).
  • internal/httpapi: the PATCH /api/v1/runs/{id} handler validates and writes the title, then emits run.changed over SSE so every connected view picks up the new title without a poll.
  • web/src/lib/instanceLabel.ts: gains the title || label-title || branch fallback chain, consumed by the chat header, the runs rail, and History alike — one function, three render sites.
  • Chat header: the inline edit affordance (pencil on hover) and the secondary raw-label text (afk-83 · <repo>~afk-83) shown once a title is set.
  • internal/afk/notify.go and internal/chat/notify.go: the push title builders resolve through store.Run.DisplayName() (title when set, else session name), so done-signal and needs-input pushes name a run the way its chat header does.
  • Nothing else moves: PR titles, commit messages, and deep links stay keyed off the label/branch exactly as before; a terminal (tmux attach, git branch, cd into the worktree) still shows the raw identifiers, never the title.