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.titlecolumn 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 rendersinstanceTitle(label)(chat header, runs rail, History), it now checksruns.titlefirst: 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 explicitnullclears the column back to NULL (falling through the chain); anything over ~120 characters is rejected with a 400.SanitizeLabelis 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.titleis 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 whattmux attachor a terminalgit branchactually 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 ininternal/chat/notify.go) resolve a run's display name through the sharedRun.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
SanitizeLabelto 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.titleand 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 nullableruns.title(shipped as 0008; renumbered when it collided with #104's0008_repo_secretson main).internal/store/runs.go: aTitlefield on the run struct plusUpdateRunTitle, the one write path for the column (trim/clear/cap validation lives here or in the handler that calls it).internal/httpapi: thePATCH /api/v1/runs/{id}handler validates and writes the title, then emitsrun.changedover SSE so every connected view picks up the new title without a poll.web/src/lib/instanceLabel.ts: gains thetitle || label-title || branchfallback 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.goandinternal/chat/notify.go: the push title builders resolve throughstore.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,cdinto the worktree) still shows the raw identifiers, never the title.