Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions bridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ session's checkout — see `headlessScratchCwd`. History a CLI offers no switch
skip is redirected per spawn instead (`HeadlessCommand.scratchEnv`, a fresh dir
the runner deletes after), so never list a var that also carries credentials.

**An agent's native session id is not stable across a resume**, so nothing may
read a change of it as "a new conversation started". Measured on Claude Code:
`--resume` copies the transcript into a NEW file and appends under a fresh id,
so the same thread comes back wearing a different name. Every guard against
re-naming a session is otherwise per-run — `SessionNamer` and `TitleAttempts`
both die with the PTY — which is why the winning signal is also written to the
session row as `autoTitleRank`, and why `SessionManager.noteConversationStart`
records AT LAUNCH that a run continues the previous conversation. The first
identity report spends that claim; every rotation after it is a real `/clear`.
Get either half wrong and a stop/start pays another model spawn and renames the
session — and not back to the same title, since the transcript read returns the
LAST few messages, so the new name describes wherever the work had drifted to.

The `agent:tools` advert (and the loopback `tools:list` reply) carries TWO
arrays, and the split is load-bearing. `tools[]` is the PATH probe — what this
machine can actually launch. `agents[]` (`agent-catalog.ts`, projected from the
Expand Down
8 changes: 7 additions & 1 deletion bridge/src/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2589,7 +2589,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
// Policy unit that turns title signals (OSC-2 + injected hook/plugin POSTs)
// into session names, honoring manual-rename precedence via applyAutoName.
namer = new SessionNamer({
applyAutoName: (id, name) => sessions?.applyAutoName(id, name),
applyAutoName: (id, name, rank) => sessions?.applyAutoName(id, name, rank),
});

// agy fires no hook on a `/rename`, so it would not reach the sidebar until
Expand Down Expand Up @@ -2909,6 +2909,12 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
// itself — so the count alone would let the flipped session rename itself
// from whatever the user typed next.
if (namer?.hasFinalTitle(target.terminalId)) return;
// The durable half of the same question, and the one that answers it after a
// restart: the namer is empty by then, the attempt budget below is empty
// too, and only the session row still knows its name is one we generated.
// Without it every stop/start pays another model spawn and renames the
// session from wherever the conversation had since drifted to.
if (sessions?.hasFinalAutoTitle(target.terminalId)) return;

const key = titleAttemptKey(target);
// Tested twice, and the two tests answer different questions. This one is a
Expand Down
85 changes: 82 additions & 3 deletions bridge/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { stripAnsi } from "./handler/context";
import { resumeArgv, sessionResumable } from "./agent-resume";
import { isChatCapableTool } from "./structured/chat-capable";
import { initialPromptArgv } from "./initial-prompt";
import { TITLE_RANKS, titleRankValue, type TitleRank } from "./session-namer";
import type { WorkStatus } from "./protocol";
import type { TerminalManager } from "./terminal-manager";
import type { AbMessage, SessionEntry } from "./protocol";
Expand Down Expand Up @@ -194,6 +195,14 @@ interface PersistedEntry {
// create-time name). Auto-naming from the agent title is suppressed forever
// after — manual always wins. Backfilled from the name pattern on load.
manuallyRenamed: boolean;
// Which signal produced the current `name`, absent for a default name or one
// derived from the terminal's OSC chrome. Durable because SessionNamer's copy
// of the same precedence dies with the PTY: without it every restart lets the
// per-turn first-message read rename the session back to its opening prompt,
// and lets the naming gate spend another model spawn re-titling a session that
// already has a real title. Released when a NEW conversation takes the slot —
// see noteConversationStart and setAgentSession.
autoTitleRank?: TitleRank;
// Last-active agent-native conversation id for this slot (the agent's own
// id-space, distinct from `id`). Persisted-only — never sent on the wire.
// Overwrite-latest: whatever the agent last reported is "where you left off",
Expand Down Expand Up @@ -251,6 +260,7 @@ const PersistedEntrySchema = z
args: z.string().optional().catch(undefined),
mode: z.enum(["terminal", "chat"]).optional().catch(undefined),
manuallyRenamed: z.boolean().optional().catch(undefined),
autoTitleRank: z.enum(TITLE_RANKS).optional().catch(undefined),
agentSessionId: z.string().optional().catch(undefined),
agentTranscriptPath: z.string().optional().catch(undefined),
config: z.record(z.string(), z.string()).optional().catch(undefined),
Expand Down Expand Up @@ -282,6 +292,12 @@ const PersistedEntrySchema = z
// isn't a default "Session N" was user-chosen → treat as manual so
// live-follow never clobbers it. Default names start following.
manuallyRenamed: s.manuallyRenamed ?? !isDefaultSessionName(s.name),
// Deliberately NOT backfilled from the name: a file written before this
// field existed cannot say whether its name was generated or read off the
// opening prompt, and guessing "generated" would freeze every one of them
// against ever being named properly. Absent means "behave as before" —
// the rank starts describing the name from the next title that lands.
autoTitleRank: s.autoTitleRank,
agentSessionId: s.agentSessionId,
agentTranscriptPath: s.agentTranscriptPath,
config: s.config,
Expand Down Expand Up @@ -462,6 +478,11 @@ export class SessionManager {
// something new; a stale `true` is harmless because start() re-runs the real
// pre-flight and falls back to a fresh start.
private resumableCache = new Map<string, { agentSessionId: string; resumable: boolean }>();
/** Sessions launched to CONTINUE their previous conversation, until the agent
* reports the identity it continued under. Per-run and therefore in memory:
* a launch always precedes the report, and a bridge that died between them
* killed the run too. See noteConversationStart. */
private readonly awaitingResumedIdentity = new Set<string>();
/** Live and recovered `worktree.setup` state, keyed by session id. Runtime
* only — it reaches the wire through toWire and never sessions.json, which
* is also why every mutation here emits with notifyObservers() rather than
Expand Down Expand Up @@ -911,6 +932,10 @@ export class SessionManager {
if (!trimmed) throw new Error("name cannot be empty");
entry.name = trimmed;
entry.manuallyRenamed = true;
// The name is the user's now, so it no longer describes any signal. Kept
// honest rather than load-bearing: manuallyRenamed already refuses every
// auto-name, and a rank left behind would outlive the title it described.
entry.autoTitleRank = undefined;
this.changed();
}

Expand All @@ -932,15 +957,54 @@ export class SessionManager {
return !!entry && !entry.manuallyRenamed;
}

applyAutoName(id: string, name: string): void {
/**
* Whether this slot already holds a name no model call should try to improve
* on. The DURABLE twin of SessionNamer.hasFinalTitle, which holds nothing once
* the PTY has exited — so this is the only thing that still knows, after a
* restart, that the name on the row was generated rather than defaulted.
*/
hasFinalAutoTitle(id: string): boolean {
const entry = this.entries.get(id);
return titleRankValue(entry?.autoTitleRank) >= titleRankValue("self");
}

applyAutoName(id: string, name: string, rank?: TitleRank): void {
const entry = this.entries.get(id);
if (!entry || entry.manuallyRenamed) return;
// Precedence, restated here because SessionNamer's copy only spans one run:
// the first-message read repeats the SAME opening prompt every turn and the
// OSC signal is terminal chrome, so after a restart either would rename a
// session we had already titled back to something worse.
if (titleRankValue(rank) < titleRankValue(entry.autoTitleRank)) return;
const trimmed = name.trim();
if (!trimmed || trimmed === entry.name) return;
if (!trimmed) return;
// The rank travels with the name it describes, so an unchanged name still
// writes when the signal behind it strengthened.
if (trimmed === entry.name && rank === entry.autoTitleRank) return;
entry.name = trimmed;
entry.autoTitleRank = rank;
this.changed();
}

/**
* Record whether this launch continues the slot's previous conversation, and
* release the generated title when it does not.
*
* A continuation cannot be recognized from the agent's session id afterwards:
* measured on Claude Code, `--resume` copies the transcript into a new file
* and appends under a FRESH id, so the same thread comes back wearing a
* different name. Only the launch knows, which is why it is recorded here and
* consumed by the first report in setAgentSession.
*/
private noteConversationStart(entry: PersistedEntry, resumed: boolean): void {
if (resumed) {
this.awaitingResumedIdentity.add(entry.id);
return;
}
this.awaitingResumedIdentity.delete(entry.id);
entry.autoTitleRank = undefined;
}

/**
* Record the agent's last-active native conversation id for a slot
* (overwrite-latest). Called from the /session-title pipeline every turn.
Expand All @@ -950,6 +1014,15 @@ export class SessionManager {
setAgentSession(id: string, agentSessionId: string, agentTranscriptPath?: string): void {
const entry = this.entries.get(id);
if (!entry) return;
// A conversation change releases the slot's title — the name describes what
// was being worked on, not the slot. Ordered BEFORE the unchanged early-out
// below so a resume that comes back under the SAME id still consumes the
// claim; left armed, the `/clear` after it would read as that resume.
if (!this.awaitingResumedIdentity.delete(id)
&& entry.agentSessionId !== undefined
&& entry.agentSessionId !== agentSessionId) {
entry.autoTitleRank = undefined;
}
// Keep a previously captured path if this report omits one for the SAME
// session — an agent's SessionStart can fire before the transcript path is
// known, then a later turn-end report supplies it (antigravity's
Expand Down Expand Up @@ -1076,6 +1149,7 @@ export class SessionManager {
this.tm.forget(id);
this.entries.delete(id);
this.resumableCache.delete(id);
this.awaitingResumedIdentity.delete(id);
this.setups.delete(id);
}

Expand Down Expand Up @@ -1787,10 +1861,12 @@ export class SessionManager {
const chatAlreadyRunning = this.runningChat.has(id);
this.runningChat.add(id);
const chatTool = entry.tool ?? "codex";
const resumeId = entry.conversationStart === "fork" ? undefined : this.resumeIdFor(chatTool, entry);
this.noteConversationStart(entry, resumeId !== undefined);
this.opts.onStartChat?.({
sessionId: id,
tool: chatTool,
resumeId: entry.conversationStart === "fork" ? undefined : this.resumeIdFor(chatTool, entry),
resumeId,
config: entry.config,
initialPrompt: this.forkInitialPrompt(entry, initialPrompt),
});
Expand Down Expand Up @@ -1880,6 +1956,9 @@ export class SessionManager {
// than spawning a default shell.
throw new Error("agent.tool or agent.command not configured");
}
// After the throw: a launch that never happened has started no conversation
// and must not release the title of the one still recorded here.
this.noteConversationStart(entry, resumeArgs.length > 0);

// One-shot first prompt, appended LAST (after resume tokens — codex's
// `resume <uuid>` is a subcommand and the positional prompt must follow it).
Expand Down
22 changes: 18 additions & 4 deletions bridge/src/session-namer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { ResolvedTitle } from "./agents/types";

export interface AutoNameSink {
applyAutoName(id: string, name: string): void;
/** The signal the name came from, absent for the OSC fallback. Passed on so
* the sink can apply the same precedence DURABLY: this class holds nothing
* after the PTY exits, and the name outlives it on the session entry. */
applyAutoName(id: string, name: string, rank?: TitleRank): void;
}

const MAX_TITLE_LEN = 60;
Expand Down Expand Up @@ -39,6 +42,17 @@ const RANK_ORDER: Record<TitleRank, number> = {
manual: 2,
};

/** Every rank, for the persisted schema that has to name them. Derived from
* RANK_ORDER rather than restated, because `Record<TitleRank, number>` is what
* makes forgetting a new rank a build error. */
export const TITLE_RANKS = Object.keys(RANK_ORDER) as [TitleRank, ...TitleRank[]];

/** Comparable strength of a title signal. The OSC fallback carries no rank and
* ranks below every structured one — it is terminal chrome, not a title. */
export function titleRankValue(rank?: TitleRank): number {
return rank ? RANK_ORDER[rank] : -1;
}

interface Signals {
/** Sanitized at ingest, so `title` is exactly the name that will be applied.
* Title and rank travel together: a rank stored without the title it
Expand Down Expand Up @@ -82,7 +96,7 @@ export class SessionNamer {
*/
onStructuredTitle(id: string, title: string, rank: TitleRank): void {
const held = this.signals.get(id)?.structured;
if (held && RANK_ORDER[rank] < RANK_ORDER[held.rank]) return;
if (held && titleRankValue(rank) < titleRankValue(held.rank)) return;
// Normalize at ingest, so the stored title IS the name that will be applied
// and `flush` never has to re-derive it. One that sanitizes away applies
// nothing, and latching the slot on it would block every later title on
Expand All @@ -105,7 +119,7 @@ export class SessionNamer {
*/
hasFinalTitle(id: string): boolean {
const held = this.signals.get(id)?.structured;
return !!held && RANK_ORDER[held.rank] >= RANK_ORDER.self;
return titleRankValue(held?.rank) >= titleRankValue("self");
}

onOscTitle(id: string, title: string): void {
Expand All @@ -118,7 +132,7 @@ export class SessionNamer {
const s = this.signals.get(id);
if (!s) continue;
const name = s.structured?.title ?? sanitizeTitle(s.osc ?? "");
if (name) this.sink.applyAutoName(id, name);
if (name) this.sink.applyAutoName(id, name, s.structured?.rank);
}
this.dirty.clear();
}
Expand Down
Loading