logancyang_obsidian-copilot/src/agentMode/session/AgentSessionManager.ts
Zero Liu 29254f6f16
feat(agent-mode): per-session token/context meter in the composer (#2654)
* feat(agent-mode): capture per-session token usage (context meter data)

Normalize what each coding-agent backend already reports into one
backend-agnostic SessionUsage, land it on AgentSession, expose it via
getSessionUsage(), and persist the latest snapshot in chat frontmatter so a
resumed session shows it immediately. No UI yet (Phase 2).

- session/types: SessionUsage + usage_update SessionUpdate variant
- sdk translator: emit usage from the Claude SDK result (used = input +
  cache_read + cache_creation + output; window = max modelUsage.contextWindow)
- acp/wireTranslate: map ACP usage_update (size/used/cost) to the domain
- acp/AcpBackendProcess: used-only prompt-result fallback, gated so it never
  clobbers a live occupancy figure (ACP totalTokens is cumulative, not in-context)
- AgentSession: store/notify usage, precedence carries a known window forward,
  seed on load; AgentSessionManager threads persistence load/save
- AgentChatPersistenceManager: round-trip usage as frontmatter JSON

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): context-window meter in the composer

A circular meter beside the composer's context glyph, filled to the % of the
agent session's context window used; accent normally, warning color past 85%.
Click opens a popover with used/total tokens, an input·output·cache breakdown,
and estimated session cost. Falls back to the legacy count-only chip when a
backend reports usage but no window, and renders nothing before the first turn.

- useSessionUsage: subscribe to the backend's live SessionUsage
- AgentContextMeter: SVG ring + popover + TokenCounter fallback; owns its
  leading separator so it hides cleanly; guards non-finite values
- thread an optional usageIndicator slot through ChatInput → ContextControl →
  ChatContextMenu (pure pass-through; legacy simple chat unaffected)
- AgentHome mounts the meter for all agent sessions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): move context meter into the New Chat / History control row

Relocate the meter from the composer's context bar into AgentChatControls,
left of the New Chat button — the agent-mode analog of where the legacy
TokenCounter sat, matching the original intent. The ring trigger is now a
plain icon button (no inline % text; the percentage stays in the popover).

As a result the meter no longer needs the shared composer plumbing: the
optional usageIndicator slot threaded through ChatInput → ContextControl →
ChatContextMenu is fully removed, so those shared components are byte-identical
to before this feature and the legacy simple chat is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): horizontal context-window bar in the meter popover

Revamp the popover to a cleaner, less technical layout: a "Context window"
label with `used / total (percent)` and the estimated cost on the same line
(cost at the right edge), above a horizontal progress bar (reusing the shared
Progress primitive). Tokens now format with one decimal + k/M (e.g.
248.0k / 1.0M). Drops the input/output/cache breakdown. The compact ring stays
as the control-row trigger; the warning color still shows on the ring and the
percentage past 85%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(agent-mode): keep the context-window label on one line

Use the smaller ui font token for the popover header and widen it (w-72 → w-80)
with a non-wrapping label, so "Context window" no longer breaks onto two lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): open the context meter popover on hover

Drive the popover's open state from hover/focus instead of click, with a short
close delay and content-hover retention so moving onto the card doesn't dismiss
it. Auto-focus is suppressed so opening on hover never steals focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent-mode): drive context meter from occupancy, not cumulative usage

The Claude SDK result message's `usage` sums every API call in a turn (tool
loops re-read the whole context from cache each iteration), so dividing it
by the window pegged the meter after tool-heavy turns even when the live
context still fit. Source occupancy from the last top-level assistant
message's own per-call usage instead, and pair it with that model's window —
prefix-matching the bare model id ("claude-opus-4-8") against the suffixed
`modelUsage` key ("claude-opus-4-8[1m]"), never `Math.max` across models.

Also: a windowless snapshot (ACP's cumulative prompt-result fallback) no
longer borrows a prior window to render a bogus percentage ring; it stays
count-only until a live occupancy update supersedes it.

Verified against runtime frame logs: a num_turns:10 Claude result summed
~646k tokens while real per-call occupancy was ~108k; opencode/codex report
occupancy directly via usage_update (this path was already correct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): use Tooltip for the context ring, drop manual popover timer

The ring meter opened its stats card via a Popover with a hand-rolled hover
state and a setTimeout close delay (to bridge trigger→content). Replace it
with the shared Radix Tooltip, which handles hover/focus open-close and
hoverable content natively — removing the open state, close timer, and the
onMouse/onFocus/onBlur handlers. Relies on the TooltipProvider already at the
chat-view root, alongside the sibling control buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): drop session cost from the context meter, show usage only

Remove `costUsd` from `SessionUsage` and everywhere that fed it — the Claude
SDK `total_cost_usd` mapping, the ACP `usage_update` `cost.amount` mapping, and
the meter's cost label + `formatUsd`. The meter now focuses solely on context
occupancy (used / window and the % ring). This also moots the ACP cost-currency
concern (raised in review), since no cost is surfaced at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:36:11 -07:00

3299 lines
146 KiB
TypeScript

import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
import {
agentProjectContextLoadAtom,
type AgentInFlightSource,
type AgentProjectContextLoadState,
type ContextLoadStepCount,
type FailedItem,
} from "@/aiParams";
import {
getSettings,
setSettings,
settingsStore,
subscribeToSettingsChange,
type CopilotSettings,
} from "@/settings/model";
import {
ensureProjectContextMaterialized,
EMPTY_CONTEXT_MATERIALIZATION_RESULT,
materializeProjectContextSource,
type ContextMaterializationResult,
type ContextMaterializeProgress,
} from "@/context/projectContextMaterializer";
import type {
MaterializedSourceType,
MaterializeSourceIdentity,
SourceFailure,
} from "@/context/contextCacheStore";
import { err2String } from "@/utils";
import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
import { fileToHistoryItem, readChatPathProjectId } from "@/utils/chatHistoryUtils";
import { readFrontmatterViaAdapter } from "@/utils/vaultAdapterUtils";
import { App, FileSystemAdapter, Notice, Platform, TFile } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { AgentSession, ATTENTION_TRIGGER_STATUSES, DEFAULT_TITLE_PREFIX } from "./AgentSession";
import type { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
import type { AgentModelPreloader, WarmBackend } from "./AgentModelPreloader";
import { buildNativeChatId, parseNativeChatId } from "@/utils/nativeChatId";
import type { AgentSessionIndex } from "./AgentSessionIndex";
import {
deriveChatTitleFromMessages,
mergeChatHistoryItems,
type MarkdownChatEntry,
} from "./chatHistoryMerge";
import { MethodUnsupportedError } from "./errors";
import { resolveMcpServers } from "./mcpResolver";
import { replayPersistedMode } from "./replayPersistedMode";
import {
FanoutOrchestrator,
type FanoutHost,
type FanoutRunInput,
} from "./fanout/FanoutOrchestrator";
import type { FanoutTurn } from "./fanout/fanoutTypes";
import { backendStateSignature } from "./translateBackendState";
import { GLOBAL_SCOPE, type ProjectScopeId } from "./scope";
import {
OrphanedProjectError,
pickScopeNeighbor,
resolveProjectIdForCwd,
resolveScopeCwd,
} from "./sessionScope";
import {
getCachedProjectRecordById,
getCachedProjectRecords,
subscribeToProjectRecords,
} from "@/projects/state";
import type { ProjectFileRecord } from "@/projects/type";
import {
getProjectContextSignature,
getProjectLandingCaptureSignature,
} from "@/projects/projectContextSignature";
import { ProjectFileManager } from "@/projects/ProjectFileManager";
import { ensureAgentsMirror } from "@/projects/ensureAgentsMirror";
import { getComposedProjectInstructions } from "@/projects/projectSystemPrompt";
import type {
AgentQuestionAnswers,
AskUserQuestionPrompt,
BackendDescriptor,
BackendId,
BackendProcess,
BackendState,
CopilotMode,
EffortOption,
McpServerSpec,
ModeApplySpec,
ModelSelection,
PermissionDecision,
PermissionPrompt,
ProjectProfile,
SessionId,
} from "./types";
const AUTOSAVE_DEBOUNCE_MS = 500;
/**
* Upper bound on the opportunistic `listSessions` sweep that enriches the
* recent-chats list from already-running backends. The session index answers
* the list on its own, so a slow agent must never hold the popover hostage.
*/
const LIST_SESSIONS_TIMEOUT_MS = 1_500;
/** Resolve with `fallback` if `promise` hasn't settled within `ms`. */
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return new Promise<T>((resolve) => {
const timer = window.setTimeout(() => resolve(fallback), ms);
promise.then(
(value) => {
window.clearTimeout(timer);
resolve(value);
},
() => {
window.clearTimeout(timer);
resolve(fallback);
}
);
});
}
/**
* Compare two cwd strings for "same directory" after stripping trailing
* separators. Agents echo back the cwd we handed them, so an exact
* normalized match is the right level of strictness — anything looser
* risks leaking another vault's sessions into this vault's history.
*/
function isSameCwd(a: string, b: string): boolean {
const norm = (p: string) => p.replace(/[/\\]+$/, "");
return norm(a) === norm(b);
}
// Referential-stability constants for "empty" returns — never hand back a fresh
// `[]` (would churn React consumers of these getters).
const EMPTY_SESSIONS = Object.freeze([]) as unknown as AgentSession[];
const EMPTY_HISTORY_ITEMS = Object.freeze([]) as unknown as ChatHistoryItem[];
// DESIGN NOTE — intentionally NOT Object.freeze'd, unlike the array constants above.
// (a) Goal is referential stability: an empty `getRunningChatIds()` /
// `getAttentionChatIds()` always hands back this same instance, so React
// consumers compare by identity (tests assert `toBe`).
// (b) `Object.freeze` is a no-op for a Set's contents — `Object.freeze(new Set()).add(x)`
// still succeeds (freeze guards own properties, not the internal `[[SetData]]` slot),
// so wrapping it would buy nothing the array constants' freeze buys.
// (c) Runtime immutability rests on the `ReadonlySet<string>` type (no `.add`/`.delete` in
// the surface) plus the convention that no caller mutates the result — consumers only
// `.has()`, read `.size`, and iterate.
const EMPTY_RECENT_CHAT_IDS: ReadonlySet<string> = new Set();
// Backends that discover project instructions from a physical `AGENTS.md` in the session cwd.
// (claude instead has `project.md` parsed and injected in-process via setProjectProfileProvider,
// so it needs no file.) Only these require the generated mirror to exist before cwd is read.
const CWD_INSTRUCTION_BACKENDS: ReadonlySet<BackendId> = new Set(["codex", "opencode"]);
/**
* Map a materializer {@link SourceFailure} to the atom's {@link FailedItem}. The
* cache layer's `"file"` kind becomes the UI's `"nonMd"` (markdown is never
* materialized, so an agent failure is only ever web/youtube/nonMd).
*/
function toFailedItem(failure: SourceFailure): FailedItem {
return {
path: failure.source,
type: failure.kind === "file" ? "nonMd" : failure.kind,
error: failure.error,
usedStaleSnapshot: failure.usedStaleSnapshot,
};
}
/** Whether a {@link FailedItem} refers to the same source as a lifecycle event
* (the cache layer's `"file"` kind is the atom's `"nonMd"`). */
function failedItemIsSource(failed: FailedItem, item: MaterializeSourceIdentity): boolean {
if (failed.path !== item.source) return false;
return item.kind === "file" ? failed.type === "nonMd" : failed.type === item.kind;
}
/** Add a source to the live "processing" set (no-op if already present). Returns a
* NEW array on change so the atom publish is referentially distinct. */
function addProcessingSource(
list: AgentInFlightSource[],
item: MaterializeSourceIdentity
): AgentInFlightSource[] {
if (list.some((s) => s.kind === item.kind && s.source === item.source)) return list;
return [...list, { kind: item.kind, source: item.source }];
}
/** Remove a source from the live "processing" set. */
function removeProcessingSource(
list: AgentInFlightSource[],
item: MaterializeSourceIdentity
): AgentInFlightSource[] {
return list.filter((s) => !(s.kind === item.kind && s.source === item.source));
}
/** Append a freshly-settled failure, dropping any prior entry for the same source. */
function upsertFailedItem(list: FailedItem[], failure: FailedItem): FailedItem[] {
return [...list.filter((f) => !(f.path === failure.path && f.type === failure.type)), failure];
}
export type PermissionPrompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
/**
* Session-domain handler for inline multiple-choice questions, the sibling of
* `PermissionPrompter`. Routes a backend's `AskUserQuestion` request to its
* owning session, which surfaces an inline card and resolves with the answers
* (or `{}` when the user cancels / no session owns the request).
*/
export type AskUserQuestionPrompter = (req: AskUserQuestionPrompt) => Promise<AgentQuestionAnswers>;
// Injected by the barrel so `session/` doesn't have to import
// `backends/registry` directly (would breach the layer boundary).
export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefined;
export interface AgentSessionManagerOptions {
permissionPrompter: PermissionPrompter;
/**
* Handler the Claude SDK backend calls for its inline `AskUserQuestion`
* surface. Optional only so legacy callers (tests) can omit it; production
* wiring always supplies one via the barrel in `agentMode/index.ts`. Wired
* onto each backend that advertises `setAskUserQuestionPrompter`.
*/
askUserQuestionPrompter?: AskUserQuestionPrompter;
resolveDescriptor: DescriptorResolver;
modelPreloader: AgentModelPreloader;
/**
* Persistence layer for Agent Mode chats. Optional only so legacy callers
* (tests) can omit it; production wiring always supplies one via the
* barrel in `agentMode/index.ts`.
*/
persistenceManager?: AgentChatPersistenceManager;
/**
* Plugin-local index of resumable backend sessions, the markdown-free half
* of the recent-chats list. Optional only so legacy callers (tests) can
* omit it; production wiring always supplies one via the barrel in
* `agentMode/index.ts`.
*/
sessionIndex?: AgentSessionIndex;
}
/**
* Plugin-scoped coordinator for Agent Mode. Owns one `AcpBackendProcess` per
* registered backend (lazy-spawned on first `createSession(backendId)`) and a
* pool of `AgentSession`s, each tagged with the backend it was created on.
* Tears every backend down on plugin unload via `shutdown()`.
*
* Backend pluggability is handled via `BackendDescriptor`: the manager
* resolves descriptors from `backendRegistry` and calls
* `descriptor.createBackend(plugin)` to construct each `AcpBackend` — it
* never imports a specific backend class. The permission prompter is
* injected so this file stays out of the UI layer.
*/
export class AgentSessionManager {
private backends = new Map<BackendId, BackendProcess>();
private starting = new Map<BackendId, Promise<BackendProcess>>();
private sessions = new Map<string, AgentSession>();
private chatUIStates = new Map<string, AgentChatUIState>();
private activeSessionId: string | null = null;
// The scope the active session belongs to. Invariant:
// `activeSession.projectId === activeProjectId` (active always belongs to the
// current scope). `GLOBAL_SCOPE` is the implicit global workspace.
private activeProjectId: ProjectScopeId = GLOBAL_SCOPE;
// Per-scope most-recently-used session id, so re-entering a scope restores
// the tab the user last looked at there.
private readonly lastActiveByScope = new Map<ProjectScopeId, string>();
// Live sessions hidden from the current tab strip. Re-entering a project
// detaches its prior conversational sessions here so the strip shows only the
// fresh visit's workset — but they stay in `this.sessions` (and on disk), so
// chat history still lists them and their running/attention indicators stay
// live. Re-attached by `setActiveSession` (history open / tab click) and
// pruned whenever a session leaves the pool. Keyed by the globally-unique
// `internalId`, so no per-scope bucketing is needed.
private readonly detachedFromTabIds = new Set<string>();
// Projects whose context sources changed since their last materialization,
// keyed by id → the context signature AT THE TIME the change was observed.
// A dirty project must NOT reuse a pre-existing empty landing on re-entry (its
// captured `<project_context>` is stale) and must NOT keep a stale landing in
// the strip. Cleared once a freshly-created session captures the SAME
// signature — a newer edit's signature won't match, so its dirtiness survives
// (no lost update). Mirrors chat mode's `markdownNeedsReload`.
private readonly contextDirtySignatures = new Map<ProjectScopeId, string>();
// A project landing session captures MORE than materialized context at
// creation: codex/opencode read the AGENTS.md mirror from cwd, and Claude
// captures its project-instructions append. The materialization dirty flag
// above deliberately ignores `systemPrompt`, so reuse decisions need their own
// fingerprint of what the session actually baked in (the landing-capture
// signature). Keyed by internal session id; every project session gets an
// entry, but only landing-shaped ones (idle/empty) are ever consulted for
// reuse. A missing entry means "not reusable as a project landing".
private readonly landingCaptureSignatures = new Map<string, string>();
// Snapshot of project records from the previous change notification, diffed
// against the next to detect context-source edits. Seeded in the constructor.
private previousProjectRecords: ProjectFileRecord[] = [];
private projectRecordsUnsubscriber?: () => void;
// Dedupe only the auto-spawn path, PER scope. Direct `createSession()` calls
// (e.g. `+` clicks) are independent — concurrent ones each spawn their own
// session. Keyed by scope so entering two scopes can't share one in-flight
// spawn; the key is cleared in `finally` so the next enter doesn't reuse a
// settled promise.
private readonly firstSessionPromiseByScope = new Map<ProjectScopeId, Promise<AgentSession>>();
private pendingCreates = 0;
private listeners = new Set<() => void>();
private disposed = false;
private startingBackendId: BackendId | null = null;
private lastError: string | null = null;
private readonly pendingBackendRestarts = new Map<BackendId, string>();
private readonly restartingBackends = new Set<BackendId>();
private readonly preloader: AgentModelPreloader;
/**
* Per-backend preload status. The chat UI gates its first render on the
* *active* backend's status; the model picker shows a "Loading…" or
* "Failed to load" placeholder for any backend that hasn't reached
* `"ready"` yet. A missing entry is treated as `"absent"` (backend not
* installed / never queued) which is render-safe.
*/
private readonly preloadStatus = new Map<BackendId, "pending" | "ready" | "error">();
// Per-session bookkeeping, all keyed by `internalId`. Mixes persistence
// bookkeeping with subscription teardowns — the unifying property is
// "must be cleaned up when the session is detached":
// - `path`: persisted file (set after first successful save)
// - `timer`: pending debounce timer
// - `indexTimer`: pending session-index write-through debounce timer
// - `unsub`: tear-down for the auto-save `session.subscribe()`
// - `signature`: last serialized snapshot, for no-op skipping
// - `modelCacheUnsub`: tear-down for the model-cache mirror subscription
// - `attentionUnsub`: tear-down for the per-session status watcher that both
// flags needs-attention AND notifies on running-membership flips (spinner)
private readonly sessionState = new Map<
string,
{
path?: string;
timer?: number;
indexTimer?: number;
unsub?: () => void;
signature?: string;
modelCacheUnsub?: () => void;
attentionUnsub?: () => void;
}
>();
// Session ids of ephemeral read-only fan-out sub-sessions; the shared permission
// prompter consults this to hard-deny write/exec tools for them.
private readonly readOnlyFanoutSessions = new Set<SessionId>();
// Serializes per-session default-model re-applies so two rapid settings
// changes can't race their setModel/setConfigOption round-trips and leave
// the session on a stale model. Each link re-reads the latest default, so
// the final settings value always wins.
private readonly defaultApplyChains = new Map<string, Promise<void>>();
private readonly fanoutOrchestrator: FanoutOrchestrator;
// Tear-down for the settings subscription that re-applies a changed
// per-backend default model to any live session on that backend.
private readonly settingsUnsub: () => void;
private getSessionState(internalId: string) {
let entry = this.sessionState.get(internalId);
if (!entry) {
entry = {};
this.sessionState.set(internalId, entry);
}
return entry;
}
constructor(
private readonly app: App,
private readonly plugin: CopilotPlugin,
private readonly opts: AgentSessionManagerOptions
) {
if (Platform.isMobile) {
throw new Error("AgentSessionManager is desktop only");
}
this.preloader = opts.modelPreloader;
this.fanoutOrchestrator = new FanoutOrchestrator(this.createFanoutHost());
this.settingsUnsub = subscribeToSettingsChange((prev, next) =>
this.onDefaultSelectionsChanged(prev, next)
);
this.setupProjectRecordChangeMonitor();
}
/**
* React to a per-backend default model change made in settings: re-apply
* it to any live session on that backend so the user's *next* turn uses
* it (an in-flight turn is unaffected — the descriptor's live re-apply
* only takes effect on the following prompt). Routes through
* `descriptor.applySelection` directly rather than `this.applySelection`
* to avoid re-entrancy with the settings write that triggered us, and to
* reach a non-active session on that backend.
*/
private onDefaultSelectionsChanged(prev: CopilotSettings, next: CopilotSettings): void {
const prevBackends = prev.agentMode?.backends as
| Record<string, { defaultModel?: ModelSelection | null } | undefined>
| undefined;
const nextBackends = next.agentMode?.backends as
| Record<string, { defaultModel?: ModelSelection | null } | undefined>
| undefined;
for (const session of this.sessions.values()) {
if (session.getStatus() === "closed") continue;
const backendId = session.backendId;
const before = prevBackends?.[backendId]?.defaultModel ?? null;
const after = nextBackends?.[backendId]?.defaultModel ?? null;
if (before?.baseModelId === after?.baseModelId && before?.effort === after?.effort) continue;
const descriptor = this.opts.resolveDescriptor(backendId);
if (!descriptor) continue;
this.enqueueDefaultApply(session, descriptor);
}
}
/**
* Append a default-model re-apply to the session's serialized chain. The
* apply re-reads the latest default at run time (clearing to "Agent default"
* resolves to the catalog native so the next turn isn't pinned to the old
* explicit model; no probed catalog leaves the session as-is), so when
* several changes land in a burst the final settings value wins instead of
* an out-of-order round-trip. Chaining off `session.ready` also covers a
* session still in its startup window, which has no `backendSessionId` yet
* and would throw on a bare `applySelection`.
*/
private enqueueDefaultApply(session: AgentSession, descriptor: BackendDescriptor): void {
const backendId = session.backendId;
const prior = this.defaultApplyChains.get(session.internalId) ?? Promise.resolve();
const next = prior
.then(() => session.ready)
.then(() => {
if (session.getStatus() === "closed") return;
const target =
this.getDefaultSelection(backendId) ?? this.nativeDefaultSelection(backendId);
if (!target) return;
return descriptor.applySelection(session, target);
})
.catch((e) => logWarn(`[AgentMode] re-applying default model for ${backendId} failed`, e))
.finally(() => {
if (this.defaultApplyChains.get(session.internalId) === next) {
this.defaultApplyChains.delete(session.internalId);
}
});
this.defaultApplyChains.set(session.internalId, next);
}
/**
* The agent's catalog-declared native default as a `ModelSelection`, or
* `null` when no catalog has been probed. Used to revert a live session
* after its explicit default is cleared.
*/
private nativeDefaultSelection(backendId: BackendId): ModelSelection | null {
const baseModelId = this.getDefaultBaseModelId(backendId);
return baseModelId ? { baseModelId, effort: null } : null;
}
/** Whether `backendSessionId` is an ephemeral read-only fan-out sub-session. */
isReadOnlyFanoutSession(backendSessionId: SessionId): boolean {
return this.readOnlyFanoutSessions.has(backendSessionId);
}
/** Run a multi-agent read-only QA turn. Called by `AgentSession.runTurn` when the turn fans out. */
runFanoutTurn(input: FanoutRunInput): Promise<FanoutTurn> {
return this.fanoutOrchestrator.run(input);
}
/** Narrow backend seam the {@link FanoutOrchestrator} drives. */
private createFanoutHost(): FanoutHost {
return {
ensureBackendForFanout: async (backendId) => {
const descriptor = this.resolveDescriptor(backendId);
const { proc } = await this.ensureBackend(backendId, descriptor);
return { proc, descriptor };
},
// Mirror createSession's fallback: a fan-out sub-session spawned on a
// warm/running subprocess inherits the model baked into its spawn-time
// config, so a cleared default must resolve to the catalog native to
// override that stale model rather than no-op.
getDefaultSelection: (backendId) =>
this.getDefaultSelection(backendId) ?? this.nativeDefaultSelection(backendId),
getDisplayName: (backendId) => this.resolveDescriptor(backendId).displayName,
// DESIGN NOTE: fan-out sub-sessions intentionally run at the vault root,
// not the originating session's project folder, and aren't handed the
// project's `projectId` / `additionalDirectories`. This is the seam where
// multi-agent QA (which predates project workspaces) meets project scope.
// It's acceptable because the project's knowledge already reaches every
// answerer: `runTurn` injects the first-turn `<project_context>` block into
// the shared fan-out prompt. What a sub-session lacks is project-scoped
// tool *reach* (cwd + external roots) — tolerable for ephemeral read-only
// QA, whose answers are advisory and whose authoritative, fully-scoped reply
// comes from the main session. Threading project scope into the sub-sessions
// is a deliberate follow-up, not part of the project-workspace landing.
getCwd: () => {
const adapter = this.app.vault.adapter;
return adapter instanceof FileSystemAdapter ? adapter.getBasePath() : null;
},
getMcpServers: (proc): McpServerSpec[] =>
resolveMcpServers(proc, getSettings().agentMode?.mcpServers),
registerReadOnlySession: (sessionId) => {
this.readOnlyFanoutSessions.add(sessionId);
return () => this.readOnlyFanoutSessions.delete(sessionId);
},
excludeSubSessionFromHistory: (backendId, sessionId) => {
void this.opts.sessionIndex?.deleteSession(backendId, sessionId);
},
};
}
/**
* Watch project config edits so a changed context source (URLs / inclusions /
* etc.) invalidates the project's materialized context — mirroring chat mode's
* {@link ProjectManager.setupProjectListChangeMonitor}. Seeds the prior-records
* snapshot up front so the first notification diffs against the real baseline.
*/
private setupProjectRecordChangeMonitor(): void {
this.previousProjectRecords = getCachedProjectRecords();
this.projectRecordsUnsubscriber?.();
this.projectRecordsUnsubscriber = subscribeToProjectRecords((nextRecords) => {
this.handleProjectRecordsChanged(nextRecords);
});
}
/**
* Diff the new project records against the previous snapshot and mark any
* project whose context signature changed as dirty (its materialized context
* is stale). For the ACTIVE project we also warm its off-vault conversion
* cache in the background so the next chat there starts with sources already
* fetched — the
* warm never publishes to {@link agentProjectContextLoadAtom}, so it can never
* gate the composer of a session that won't even consume the new context.
*/
private handleProjectRecordsChanged(nextRecords: ProjectFileRecord[]): void {
if (this.disposed) return;
const prevRecords = this.previousProjectRecords;
this.previousProjectRecords = nextRecords;
// Drop dirty flags for projects that no longer exist (deleted/renamed-away).
const nextIds = new Set(nextRecords.map((r) => r.project.id));
for (const id of this.contextDirtySignatures.keys()) {
if (!nextIds.has(id)) this.contextDirtySignatures.delete(id);
}
for (const nextRecord of nextRecords) {
const prevRecord = prevRecords.find((r) => r.project.id === nextRecord.project.id);
if (!prevRecord) continue; // brand-new project: nothing materialized yet
const nextSignature = getProjectContextSignature(nextRecord);
if (getProjectContextSignature(prevRecord) === nextSignature) continue;
const projectId = nextRecord.project.id;
this.contextDirtySignatures.set(projectId, nextSignature);
if (projectId === this.activeProjectId) this.warmProjectContext(projectId);
}
}
/**
* Fire-and-forget refresh of a project's off-vault conversion-cache snapshots. Unlike
* {@link rematerializeContext} this NEVER touches the context-load atom, so it
* silently warms the disk cache without gating any composer. The materializer
* single-flights and cheap-skips unchanged sources, so a newly-added URL is
* fetched while everything else is a no-op. Skipped off-desktop (no adapter).
*/
private warmProjectContext(projectId: ProjectScopeId): void {
if (this.disposed || projectId === GLOBAL_SCOPE) return;
let cwd: string;
try {
cwd = this.resolveSessionCwd(projectId);
} catch {
return; // off-desktop: no FileSystemAdapter, nothing to warm
}
void ensureProjectContextMaterialized(this.app, projectId, cwd).catch((err) =>
logWarn(`[AgentMode] background context warm failed for ${projectId}`, err)
);
}
/** Whether a project's materialized context is known to be stale. */
private isProjectContextDirty(projectId: ProjectScopeId): boolean {
return this.contextDirtySignatures.has(projectId);
}
/**
* Clear a project's dirty flag once a freshly-created session has captured its
* context — but ONLY if the signature the session actually captured (passed in,
* read synchronously at materialization kickoff) still equals the dirty one. A
* newer edit that landed after this session started materializing bumps the
* dirty signature, so it won't match and the project stays dirty (this session
* captured the OLDER sources). The lost-update guard for edit/create races.
*/
private clearContextDirtyIfCaptured(projectId: ProjectScopeId, capturedSignature: string): void {
if (this.contextDirtySignatures.get(projectId) === capturedSignature) {
this.contextDirtySignatures.delete(projectId);
}
}
/**
* List every known Agent Mode chat as a `ChatHistoryItem`: markdown-saved
* notes (ranked using the plugin's shared in-memory `lastAccessedAt`
* tracker) merged with the session index's native-store entries, de-duped
* on `backendId + sessionId`. Native entries exist independently of the
* `autosaveChat` setting, so the list survives autosave being off. Before
* merging, already-running backends are swept via `listSessions` (bounded
* by {@link LIST_SESSIONS_TIMEOUT_MS}) so chats created outside the plugin
* surface too — a backend is never spawned just to enumerate history.
*/
async getChatHistoryItems(scope?: ProjectScopeId): Promise<ChatHistoryItem[]> {
const persistence = this.opts.persistenceManager;
const index = this.opts.sessionIndex;
if (!persistence && !index) return EMPTY_HISTORY_ITEMS;
// Paths of saved chats backed by a live session that is flagging for
// attention (finished / errored / paused while backgrounded). In-memory and
// app-lifetime only — purely-on-disk chats stay unflagged. Applied in the
// map below so both the global and project views carry the cue.
const liveAttentionPaths = this.collectLiveAttentionPaths();
let files: TFile[] = [];
let markdownEntries: MarkdownChatEntry[] = [];
if (persistence) {
files = await persistence.getAgentChatHistoryFiles();
const tracker = this.plugin.getChatHistoryLastAccessedAtManager();
markdownEntries = await Promise.all(
files.map(async (file) => {
// readSessionRefFromFile falls back to an adapter read for files in
// hidden save folders, which the metadata cache never indexes — a
// cache-only read would leave those rows unmergeable and duplicate
// their native twins.
const ref = await this.readSessionRefFromFile(file.path);
const item = fileToHistoryItem(this.app, file, tracker);
return {
item: liveAttentionPaths.has(item.id) ? { ...item, needsAttention: true } : item,
backendId: ref?.backendId,
sessionId: ref?.sessionId,
};
})
);
}
// Project view: resolve the AUTHORITATIVE projectId per file and keep only
// this scope's chats. The sync `fileToHistoryItem` reads only metadataCache,
// which can lag right after a save or miss hidden-dir files — that would
// default such a chat to global and wrongly drop it from its project list.
// `readChatPathProjectId` hits the cache for indexed files (zero extra IO)
// and falls back to a one-shot adapter read only for unindexed ones.
if (scope && scope !== GLOBAL_SCOPE) {
const resolved = await Promise.all(
files.map(async (file, i) => {
const projectId =
(await readChatPathProjectId(this.app, file.path))?.trim() || GLOBAL_SCOPE;
return projectId === scope ? markdownEntries[i] : null;
})
);
// Drop cross-device-unresumable chats AFTER scope resolution: the resolver
// indexes `markdownEntries[i]` against `files`, so filtering the list before
// it would misalign those indices.
const scopedMarkdown = await this.dropNonLocalMarkdownEntries(
resolved.filter((entry): entry is MarkdownChatEntry => entry !== null)
);
if (!index) {
const items = scopedMarkdown.map((e) => e.item);
return items.length === 0 ? EMPTY_HISTORY_ITEMS : items;
}
// Native entries scope by the index's recorded projectId (written
// through from live sessions, or cwd-attributed by the sweep), so
// autosave-off project chats list here too — same dual-source merge as
// the global view, just filtered to this scope on both sides.
await this.refreshNativeSessionsFromBackends();
const scopedNative = (await index.getEntries()).filter((e) => e.projectId === scope);
const merged = mergeChatHistoryItems(scopedMarkdown, scopedNative);
return merged.length === 0 ? EMPTY_HISTORY_ITEMS : merged;
}
markdownEntries = await this.dropNonLocalMarkdownEntries(markdownEntries);
if (!index) return markdownEntries.map((e) => e.item);
await this.refreshNativeSessionsFromBackends();
const nativeEntries = await index.getEntries();
return mergeChatHistoryItems(markdownEntries, nativeEntries);
}
/**
* Saved-file paths of live sessions currently flagging `needsAttention`. The
* path is keyed off the session's persisted file (set after its first save),
* matching `ChatHistoryItem.id`. Sessions that never saved (no path) or aren't
* flagging are skipped.
*/
private collectLiveAttentionPaths(): Set<string> {
const paths = new Set<string>();
for (const [internalId, session] of this.sessions) {
if (!session.getNeedsAttention()) continue;
const path = this.sessionState.get(internalId)?.path;
if (path) paths.add(path);
}
return paths;
}
/**
* Every recent-list id this session may currently be rendered under. The ids
* match `ChatHistoryItem.id`: the persisted markdown path once saved, the
* `(backendId, sessionId)` native id before that. Deliberately BOTH when both
* exist: the mounted landing list is a snapshot, so right after the first
* autosave re-keys the chat from native id to path, the visible row may still
* carry the old native id — emitting both keeps `.has()` hitting whichever
* the row was rendered under, with no history reload. Harmless overshoot: a
* native id maps to the same chat and its native twin row is de-duped away.
*
* DESIGN NOTE — known limitation, deliberately deferred: a session that has
* neither saved nor reached the native index yet has NO row in the list at
* all, so its spinner/dot has nowhere to hang until the list next reloads.
* That's a missing row, not an id mismatch — dual ids can't help, and forcing
* a history reload from here would couple autosave to row visibility.
*/
private recentChatIdsForSession(internalId: string, session: AgentSession): string[] {
const ids: string[] = [];
const path = this.sessionState.get(internalId)?.path;
if (path) ids.push(path);
const backendSessionId = session.getBackendSessionId();
if (backendSessionId) ids.push(buildNativeChatId(session.backendId, backendSessionId));
return ids;
}
/**
* Recent-list ids of pool sessions whose backend turn is currently
* `"running"`, so the landing rows can swap their relative-time chip for a
* spinner. Only `"running"` counts — `awaiting_permission` is surfaced via
* the needs-attention dot. Returns a shared module constant when empty so
* React consumers don't churn on a fresh `Set`.
*/
getRunningChatIds(): ReadonlySet<string> {
const ids = new Set<string>();
for (const [internalId, session] of this.sessions) {
if (session.getStatus() !== "running") continue;
for (const id of this.recentChatIdsForSession(internalId, session)) ids.add(id);
}
return ids.size === 0 ? EMPTY_RECENT_CHAT_IDS : ids;
}
/**
* Recent-list ids of pool sessions currently flagging needs-attention, so a
* row's done-dot can light up the moment its backgrounded turn finishes —
* the live complement to the `item.needsAttention` snapshot that
* `getChatHistoryItems` bakes in at load time (which goes stale the moment a
* session finishes after the list mounted, and never covers native-only
* rows). Same shape and constant-on-empty contract as `getRunningChatIds`.
*/
getAttentionChatIds(): ReadonlySet<string> {
const ids = new Set<string>();
for (const [internalId, session] of this.sessions) {
if (!session.getNeedsAttention()) continue;
for (const id of this.recentChatIdsForSession(internalId, session)) ids.add(id);
}
return ids.size === 0 ? EMPTY_RECENT_CHAT_IDS : ids;
}
/**
* Update the user-visible title of a saved chat — frontmatter `topic` for
* markdown chats, the index entry (plus the live session's label, when the
* session is open) for native-store entries.
*/
async updateChatTitle(fileId: string, newTitle: string): Promise<void> {
const native = parseNativeChatId(fileId);
if (native) {
const index = this.opts.sessionIndex;
if (!index) throw new Error("Agent session index is not configured.");
await index.setTitle(native.backendId, native.sessionId, newTitle);
// Match the (backendId, sessionId) pair, not the id alone: on a
// cross-backend id collision, renaming by id could relabel the wrong
// backend's live tab (and its index entry via the label autosave).
this.findLiveSession(native.backendId, native.sessionId)?.setLabel(newTitle);
return;
}
const persistence = this.opts.persistenceManager;
if (!persistence) throw new Error("Agent chat persistence is not configured.");
await persistence.updateTopic(fileId, newTitle);
}
/**
* Delete a chat from history. Native-store entries are tombstoned in the
* index (the backend's own session store is left untouched — it's shared
* with the CLI outside Obsidian). Markdown chats are trashed AND their
* backend session is tombstoned, so the native twin doesn't reappear on
* the next merge.
*/
async deleteChatHistory(fileId: string): Promise<void> {
const index = this.opts.sessionIndex;
const native = parseNativeChatId(fileId);
if (native) {
if (!index) throw new Error("Agent session index is not configured.");
this.cancelPendingIndexTouch(native.backendId, native.sessionId);
await index.deleteSession(native.backendId, native.sessionId);
return;
}
const persistence = this.opts.persistenceManager;
if (!persistence) throw new Error("Agent chat persistence is not configured.");
if (index) {
const ref = await this.readSessionRefFromFile(fileId);
if (ref) {
this.cancelPendingIndexTouch(ref.backendId, ref.sessionId);
await index.deleteSession(ref.backendId, ref.sessionId);
}
}
await persistence.deleteFile(fileId);
}
/**
* Drop any debounced index write-through still pending for a live session
* with this (backendId, sessionId). Without this, deleting a chat inside the
* ~500ms debounce window of a recent message/label change lets the already-
* queued `flushIndexTouch` fire after the tombstone is written — its
* `recordSession` clears the tombstone and re-adds the deleted chat to
* Recent Chats. Activity *after* the delete still re-indexes normally (a
* deliberate "the user is using it again" signal); only the pre-delete
* timer is cancelled.
*/
private cancelPendingIndexTouch(backendId: BackendId, sessionId: string): void {
for (const session of this.sessions.values()) {
if (session.backendId !== backendId) continue;
if (session.getBackendSessionId() !== sessionId) continue;
const state = this.sessionState.get(session.internalId);
if (state?.indexTimer) {
window.clearTimeout(state.indexTimer);
state.indexTimer = undefined;
}
}
}
/**
* Read the backend session identity from a saved chat's frontmatter, via
* the metadata cache with an adapter fallback for hidden-directory files.
* Returns null when the file predates session-id persistence.
*/
private async readSessionRefFromFile(
fileId: string
): Promise<{ backendId: BackendId; sessionId: string } | null> {
let fm: Record<string, unknown> | undefined;
const file = this.app.vault.getAbstractFileByPath(fileId);
if (file instanceof TFile) {
fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
}
if (!fm) {
try {
fm = (await readFrontmatterViaAdapter(this.app, fileId)) ?? undefined;
} catch {
return null;
}
}
const backendId = typeof fm?.backendId === "string" ? fm.backendId.trim() : "";
const sessionId = typeof fm?.sessionId === "string" ? fm.sessionId.trim() : "";
if (!backendId || !sessionId) return null;
return { backendId, sessionId };
}
/**
* Sweep already-running backends' native session stores into the index.
* "Running" includes the preloader's warm probe subprocesses — they're
* spawned for every installed backend at plugin load, so sweeping them
* surfaces codex/opencode history on the very first Agent Home open,
* before any chat has started a manager-owned backend. Strictly
* opportunistic: never spawns a backend, swallows per-backend failures,
* and is capped by {@link LIST_SESSIONS_TIMEOUT_MS} so the history
* surface stays responsive when an agent is slow to answer.
*/
private async refreshNativeSessionsFromBackends(): Promise<void> {
const index = this.opts.sessionIndex;
if (!index) return;
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) return;
const vaultBasePath = adapter.getBasePath();
const procs = this.getRunningProcsByBackend();
if (procs.size === 0) return;
const sweeps = Array.from(procs, ([backendId, proc]) =>
this.sweepNativeSessions(backendId, proc, vaultBasePath)
);
await withTimeout(
Promise.allSettled(sweeps).then(() => undefined),
LIST_SESSIONS_TIMEOUT_MS,
undefined
);
}
/**
* Currently-running backend processes, keyed by backend id. "Running"
* includes the preloader's warm probe subprocesses, spawned for every
* installed backend at plugin load, so this is populated on the first Agent
* Home open without spawning anything. Manager-owned procs win over warm
* probes for the same backend id — they're the same subprocess lineage, but
* the manager's entry is the one whose lifecycle we control.
*/
private getRunningProcsByBackend(): Map<BackendId, BackendProcess> {
const procs = new Map<BackendId, BackendProcess>();
for (const { backendId, proc } of this.preloader.getWarmProcs()) {
if (proc.isRunning()) procs.set(backendId, proc);
}
for (const [backendId, proc] of this.backends) {
if (proc.isRunning()) procs.set(backendId, proc);
}
return procs;
}
/**
* Drop markdown chats whose backend session can't be resumed on this device
* — a chat started on another machine syncs its note (with the session id)
* but not the backend's local transcript store, so resuming it dead-ends.
* Resumability is probed against each chat's own scope cwd (its project
* folder, or the vault root for global chats), since a backend may key its
* transcript store by cwd. Only hides a row when a running backend can
* cheaply and definitively say
* the session is absent; an unknown answer (no such capability, backend not
* running, or a probe error) keeps the row so we never hide a local chat.
*
* A dropped chat's `(backendId, sessionId)` is also tombstoned in the index,
* so the native sweep below doesn't resurface it as a markdown-less row that
* dead-ends in `loadNativeSessionFromHistory` — a normal autosaved chat has
* a `flushIndexTouch` index entry twinned with its note.
*/
private async dropNonLocalMarkdownEntries(
entries: MarkdownChatEntry[]
): Promise<MarkdownChatEntry[]> {
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) return entries;
const vaultRoot = adapter.getBasePath();
const procs = this.getRunningProcsByBackend();
const keep = await Promise.all(
entries.map(async (entry) => {
if (!entry.backendId || !entry.sessionId) return true;
const proc = procs.get(entry.backendId);
if (!proc?.sessionExistsLocally) return true;
// Probe with the chat's OWN scope cwd, not the vault root: a project
// chat's transcript lives under its project folder (Claude keys the
// transcript path by cwd), so probing every row with the vault root
// would wrongly report a local project chat as non-resumable and hide
// it. Resolving per entry keeps both the flat global view and a project
// view correct. A scope that no longer resolves (deleted project) falls
// back to keeping the row — never hide a chat on an uncertain answer.
let cwd: string;
try {
const projectId =
(await readChatPathProjectId(this.app, entry.item.id))?.trim() || GLOBAL_SCOPE;
cwd = resolveScopeCwd(vaultRoot, projectId);
} catch {
return true;
}
try {
return await proc.sessionExistsLocally({ sessionId: entry.sessionId, cwd });
} catch {
return true;
}
})
);
const index = this.opts.sessionIndex;
if (index) {
await Promise.all(
entries.map(async (entry, i) => {
if (keep[i] || !entry.backendId || !entry.sessionId) return;
this.cancelPendingIndexTouch(entry.backendId, entry.sessionId);
await index.deleteSession(entry.backendId, entry.sessionId);
})
);
}
return entries.filter((_, i) => keep[i]);
}
/**
* Merge one backend's `listSessions` result into the index. Keeps sessions
* whose cwd is this vault's root (global scope) or a known project folder
* (attributed to that project) — agent-side cwd filtering is not trusted,
* and a stray session from another vault must never leak into this vault's
* history. Skips the preloader's probe session and requires a real title so
* the sweep can't surface empty placeholder sessions.
*/
private async sweepNativeSessions(
backendId: BackendId,
proc: BackendProcess,
vaultBasePath: string
): Promise<void> {
const index = this.opts.sessionIndex;
if (!index) return;
const descriptor = this.opts.resolveDescriptor(backendId);
// Only backends that summarize their own titles contribute trustworthy
// titles to native discovery. For the rest (codex, Claude Code) the agent's
// title is the raw first prompt (which leaks the injected context envelope),
// and the sweep has no transcript to derive a clean one — those sessions are
// indexed via flushIndexTouch with a client-derived title instead.
if (!descriptor?.summarizesSessionTitle) return;
let sessions;
try {
({ sessions } = await proc.listSessions({ cwd: vaultBasePath }));
} catch (err) {
if (!(err instanceof MethodUnsupportedError)) {
logWarn(`[AgentMode] listSessions sweep failed for ${backendId}`, err);
}
return;
}
const probeSessionId = descriptor.getProbeSessionId?.(getSettings());
const now = Date.now();
const discovered = [];
for (const s of sessions) {
const inVaultRoot = isSameCwd(s.cwd, vaultBasePath);
// A session run inside a materialized project folder belongs to that
// project's history; anything matching neither the vault root nor a
// known project folder is another vault's session.
const projectId = inVaultRoot ? undefined : resolveProjectIdForCwd(vaultBasePath, s.cwd);
if (!inVaultRoot && !projectId) continue;
if (probeSessionId && s.sessionId === probeSessionId) continue;
// A live fan-out sub-session is ephemeral: skip it even before its
// async tombstone lands, so a sweep racing the in-flight turn can't
// surface it as a phantom chat.
if (this.readOnlyFanoutSessions.has(s.sessionId)) continue;
const title = s.title?.trim();
if (!title || title.startsWith(DEFAULT_TITLE_PREFIX)) continue;
const updatedAtMs = s.updatedAt ? Date.parse(s.updatedAt) : NaN;
const timestamp = Number.isFinite(updatedAtMs) && updatedAtMs > 0 ? updatedAtMs : now;
discovered.push({
backendId,
sessionId: s.sessionId,
title,
createdAtMs: timestamp,
lastAccessedAtMs: timestamp,
projectId,
});
}
if (discovered.length > 0) await index.mergeDiscoveredSessions(discovered);
}
/**
* Return the active `AgentSession` if one exists, otherwise create one.
* Used by the router to lazily seed the first session on chain switch.
* Subsequent `+` clicks should call `createSession()` directly.
*/
async getOrCreateActiveSession(): Promise<AgentSession> {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
const active = this.getActiveSession();
// Only reuse the active session when it belongs to the current scope —
// after `enterProject` the prior scope's session may still be pointed at by
// `activeSessionId` until this seeds a fresh one for the new scope.
if (active && active.projectId === this.activeProjectId && active.getStatus() !== "closed") {
return active;
}
// Dedupe rapid auto-spawn callers (e.g. the router effect re-running
// before the first create has populated the pool) so we don't seed two
// sessions when one was asked for. Keyed per scope so two scopes spawning
// at once don't collapse into one shared session.
const scope = this.activeProjectId;
const pending = this.firstSessionPromiseByScope.get(scope);
if (pending) return pending;
const promise = this.createSession(undefined, scope);
this.firstSessionPromiseByScope.set(scope, promise);
try {
return await promise;
} finally {
this.firstSessionPromiseByScope.delete(scope);
}
}
/**
* Spawn a fresh `AgentSession`. Lazily starts the requested backend on its
* first call. The new session becomes the active one *when its scope is the
* current one* (a background/restart create stays parked). `backendId`
* defaults to `settings.agentMode.activeBackend` (the model-picker keeps that
* in sync with the user's most recently selected default model). `projectId`
* defaults to the active scope and is bound immutably onto the session.
*
* The new session's initial (model, effort) defaults to the persisted
* default for `backendId` via `getDefaultSelection`. Pass `seedSelection`
* to seed a specific (model, effort) without touching that default — used
* by a cross-backend chat pick, which is transient.
*/
async createSession(
backendId?: BackendId,
projectId: ProjectScopeId = this.activeProjectId,
seedSelection?: ModelSelection
): Promise<AgentSession> {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
const resolvedId = backendId ?? getSettings().agentMode?.activeBackend ?? "opencode";
// Read the live record once: it drives both the AGENTS.md mirror below and
// the landing-capture signature recorded after the session is built, so the
// two agree on the exact config this session bakes in.
const projectRecord =
projectId === GLOBAL_SCOPE ? undefined : getCachedProjectRecordById(projectId);
const landingCaptureSignature = projectRecord
? getProjectLandingCaptureSignature(projectRecord)
: undefined;
// Materialize the project's AGENTS.md mirror from project.md BEFORE resolving cwd, so a
// cwd-instruction backend (codex/opencode) discovers the instruction file on its first
// session. This is the sole correctness guarantee for an old project.md-only project.
// Never throws (degrades gracefully); skipped for GLOBAL_SCOPE and for claude (which gets
// the instruction injected in-process, no file needed).
if (projectRecord && CWD_INSTRUCTION_BACKENDS.has(resolvedId)) {
await ensureAgentsMirror(this.app, projectRecord);
}
// Resolves the scope's cwd (vault root for global, project folder otherwise)
// and validates desktop/orphaned up front, before any pending-create state
// is mutated.
const cwd = this.resolveSessionCwd(projectId);
// Kick off context materialization WITHOUT blocking session creation: the
// session must become visible immediately so the composer's loading card +
// send-gate render while prefetch runs (§3.1.9). The returned promise is
// threaded into the session, which awaits it right before `newSession`.
// Skipped for GLOBAL_SCOPE — no promise, no atom write, byte-identical to a
// context-free create. Never rejects (degrades to empty roots).
const contextReady =
projectId === GLOBAL_SCOPE ? undefined : this.beginContextMaterialization(projectId, cwd);
const descriptor = this.resolveDescriptor(resolvedId);
this.pendingCreates++;
this.startingBackendId = resolvedId;
this.notify();
let backend: BackendProcess;
let warm: WarmBackend | null = null;
try {
// ensureBackend tries the preloader's warm probe before paying a
// fresh spawn. When `warm` is non-null the probe session is
// available for adoption below — skips a second `newSession`.
({ proc: backend, warm } = await this.ensureBackend(resolvedId, descriptor));
} catch (err) {
this.lastError = err2String(err);
this.finishPendingCreate();
throw err;
}
if (this.disposed) {
this.finishPendingCreate();
throw new Error("AgentSessionManager was shut down during session creation");
}
// Falls back to the catalog native default when there's no transient seed
// and no stored default. Otherwise a warm/running subprocess (e.g.
// opencode) keeps serving the model baked into its spawn-time config from
// a since-cleared default, so a brand-new "Agent default" chat would
// silently inherit the stale model. Confirming the native selection here
// pins the new session to native instead. With no probed catalog there's
// no native id to target, so the seed stays undefined and behavior is
// unchanged.
const resolvedSeed =
seedSelection ??
this.getDefaultSelection(resolvedId) ??
this.nativeDefaultSelection(resolvedId) ??
undefined;
// A new chat must always start from a brand-new backend session. When a
// warm preload probe is available we reuse its already-spawned and
// initialize-handshaken subprocess (the expensive part), but never its
// *session*: opencode persists its probe session id and resumes it from
// disk on the next preload, so adopting that session as the chat would
// replay the previous conversation's transcript and auto-title into a
// supposedly fresh chat. `AgentSession.start` runs `newSession` on the
// (warm or cold) proc at the resolved scope cwd, threading the project
// scope + context roots; the probe's state still seeds the picker so it
// doesn't blink while that round-trip is in flight.
const session = AgentSession.start({
backend,
cwd,
internalId: uuidv4(),
backendId: resolvedId,
projectId,
defaultModelSelection: resolvedSeed,
initialCachedState: warm?.state ?? this.preloader.getCachedBackendState(resolvedId),
getDescriptor: () => this.opts.resolveDescriptor(resolvedId),
runFanoutTurn: (input) => this.runFanoutTurn(input),
getDisplayName: (backendId) => this.resolveDescriptor(backendId).displayName,
getApp: () => this.app,
contextReady,
});
if (warm) {
logInfo(
`[AgentMode] session reused warm proc with a fresh session (internal=${session.internalId} backend=${resolvedId})`
);
}
this.sessions.set(session.internalId, session);
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
// Record what this project landing captured so re-entry can tell a current
// landing from one that baked in stale config (incl. a System-Prompt-only
// edit the materialization dirty flag ignores). Global sessions capture no
// project config, so they get no entry (and are never reused as landings).
if (landingCaptureSignature) {
this.landingCaptureSignatures.set(session.internalId, landingCaptureSignature);
} else {
this.landingCaptureSignatures.delete(session.internalId);
}
// A fresh id is never detached; clear defensively so a new session can't
// inherit a stale hidden-from-strip flag.
this.detachedFromTabIds.delete(session.internalId);
// Always the scope's newest MRU. But only steal the global active pointer
// when this session's scope is still the current one — a slow auto-spawn or
// a restart that replaces a *background* tab must not yank the user out of a
// scope they've since switched to (preserves `active.projectId ===
// activeProjectId`).
this.lastActiveByScope.set(projectId, session.internalId);
if (projectId === this.activeProjectId) {
this.activeSessionId = session.internalId;
}
this.attachAutoSave(session);
this.attachModelCacheSync(session);
this.attachAttentionTracking(session);
this.notify();
// Once the ACP session is ready, apply backend-specific persisted state
// (claude's effort, future config-option preferences) and clear the
// "starting" pill. On failure, capture into `lastError` so the status
// surface and retry handler can react. The session itself transitions to
// status "error" inside its own `initialize`. For warm-adopted sessions
// `ready` is already resolved, so the chain runs on the next microtask.
void session.ready
.then(async () => {
// Reaching here means the session is fully ready — it captured its context
// and opened its backend session (a failed startup rejects into `.catch`
// and never runs this). A fresh session's `ready` resolves only after
// `initialize()` has already awaited `contextReady`, so the await below is
// an already-settled microtask, not a fresh network wait — it can't stall
// `finishPendingCreate`. Clear the project's dirty flag FIRST, before any
// optional backend config that could hang and stall it. Clear only for the
// source revision the materializer actually captured (`contextSignature`):
// a newer edit mid-flight bumped the dirty signature, so it won't match and
// stays dirty (lost-update guard); a failed materialize carries no signature.
if (contextReady) {
const result = await contextReady;
if (!this.disposed && result.contextSignature !== undefined) {
this.clearContextDirtyIfCaptured(projectId, result.contextSignature);
}
}
if (descriptor.applyInitialSessionConfig) {
try {
await descriptor.applyInitialSessionConfig(session, getSettings(), resolvedSeed);
} catch (e) {
logWarn(
`[AgentMode] applyInitialSessionConfig failed for ${resolvedId}; continuing`,
e
);
}
}
this.lastError = null;
logInfo(
`[AgentMode] session ready (internal=${session.internalId} backend-id=${session.getBackendSessionId()} backend=${resolvedId}); pool size=${this.sessions.size}`
);
// Seed the user's sticky mode preference so a new conversation reopens
// in the mode they last chose (e.g. `auto`). Generic across backends;
// `replayPersistedMode` swallows its own errors so it never blocks ready.
// Runs last so it can't perturb the lastError/ready ordering above.
await replayPersistedMode(session, this.getDefaultMode(resolvedId));
})
.catch((err) => {
this.lastError = err2String(err);
})
.finally(() => this.finishPendingCreate());
return session;
}
private finishPendingCreate(): void {
this.pendingCreates--;
if (this.pendingCreates === 0) this.startingBackendId = null;
this.notify();
}
private resolveDescriptor(backendId: BackendId): BackendDescriptor {
const descriptor = this.opts.resolveDescriptor(backendId);
if (!descriptor) {
throw new Error(`Unknown backend "${backendId}". Did you forget to register it?`);
}
return descriptor;
}
/**
* Absolute cwd for a session in `projectId`'s scope. Single source of truth
* for every session-construction site. Throws on a non-desktop adapter or an
* orphaned project (missing record) — the latter must never silently fall
* back to the vault root.
*/
private resolveSessionCwd(projectId: ProjectScopeId): string {
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
}
return resolveScopeCwd(adapter.getBasePath(), projectId);
}
/**
* Start (but do NOT await) materializing a project's context, returning a
* promise of the extra searchable roots. Caller has already done config
* migration + cwd resolution, so the materializer sees the post-migration
* record and resolved cwd. Only ever invoked for a non-global scope.
*
* Publishes the blocking load state SYNCHRONOUSLY (before the session even
* appears) so the composer gates send the instant the tab renders, then flips
* to `done`/`error` and stores the result for {@link getProjectProfile} once
* the materializer settles. NEVER rejects — failure degrades to an empty
* result so the session's `newSession` (which awaits this) is never blocked.
*
* Resolves the full {@link ContextMaterializationResult} (searchable roots +
* the optional inline `<project_context>` block); the session captures both
* before opening. Progress/counts are NOT carried here — they publish
* separately via {@link agentProjectContextLoadAtom}.
*/
private beginContextMaterialization(
projectId: ProjectScopeId,
cwd: string,
forceRetryFailed?: boolean
): Promise<ContextMaterializationResult> {
// Accumulate counts so every publish carries the latest of all three steps
// (resolve / prefetch / parse), not just the one that fired last.
const counts: {
resolved?: number;
prefetch?: ContextLoadStepCount;
parsed?: ContextLoadStepCount;
} = {};
// Per-source state mirroring the legacy CAG tracker: `processingSources` is
// the live "in flight" set, `failedSources` accrues settled failures. Both
// publish incrementally so the popover renders a true queue; the materializer
// sends a final `failures` list that reconciles `failedSources` at the end.
let failedSources: FailedItem[] = [];
let processingSources: AgentInFlightSource[] = [];
// The latest step phase, so item-lifecycle publishes (which carry no phase of
// their own) keep the loading card on the current step.
let stepPhase: "resolve" | "prefetch" | "parse" = "resolve";
// Own the atom only when no concurrent run is already driving it. The
// materializer single-flights, so a second caller would otherwise (a) seed a
// count-less "resolve" over the owner's live counts and (b) publish a
// count-less terminal "done" during the owner's linger window. Letting only
// the flight owner publish keeps the projectId-keyed atom coherent.
const prior = settingsStore.get(agentProjectContextLoadAtom)[projectId];
const ownsPublish = !prior?.blocking;
// Seed the blocking state synchronously (before the session even appears) so
// the composer gates send the instant the tab renders.
if (ownsPublish) {
this.setContextLoadState(projectId, { phase: "resolve", blocking: true });
}
// Re-emit the running state with the current counts + per-source sets. Empty
// `processingSources` publishes as `undefined` (the adapter falls back to a
// frozen empty), matching the `retryingSources` referential-stability pattern.
const publishProgress = () => {
this.setContextLoadState(projectId, {
phase: stepPhase,
blocking: true,
...counts,
failedSources,
processingSources: processingSources.length > 0 ? processingSources : undefined,
});
};
const onProgress = ownsPublish
? (progress: ContextMaterializeProgress) => {
switch (progress.phase) {
case "failures":
// Final reconciliation of the run's failures (data-only; the
// terminal `done` publish carries the authoritative list).
failedSources = progress.failures.map(toFailedItem);
return;
case "itemStart":
processingSources = addProcessingSource(processingSources, progress.item);
failedSources = failedSources.filter((f) => !failedItemIsSource(f, progress.item));
publishProgress();
return;
case "itemFailed":
processingSources = removeProcessingSource(processingSources, progress.item);
failedSources = upsertFailedItem(failedSources, toFailedItem(progress.failure));
publishProgress();
return;
case "itemSettled":
processingSources = removeProcessingSource(processingSources, progress.item);
publishProgress();
return;
case "resolve":
counts.resolved = progress.resolved;
stepPhase = "resolve";
publishProgress();
return;
case "prefetch":
counts.prefetch = { done: progress.done, total: progress.total };
stepPhase = "prefetch";
publishProgress();
return;
case "parse":
counts.parsed = { done: progress.done, total: progress.total };
stepPhase = "parse";
publishProgress();
return;
}
}
: undefined;
return ensureProjectContextMaterialized(this.app, projectId, cwd, onProgress, forceRetryFailed)
.then((ctx) => {
if (ownsPublish) {
// Always carry `failedSources` (empty when clean) so a prior run's
// failures never linger; the run is done, so nothing is processing.
this.setContextLoadState(projectId, {
phase: "done",
blocking: false,
...counts,
failedSources,
});
}
return ctx;
})
.catch((err) => {
// The materializer's contract is never-reject; this guards a contract
// breach. Publish as a completed run with a single synthetic failure
// (not a distinct error phase — there is no "whole context" failure state).
logWarn(`[AgentMode] project context materialize failed for ${projectId}; continuing`, err);
if (ownsPublish) {
this.setContextLoadState(projectId, {
phase: "done",
blocking: false,
failedSources: [{ path: "Project context", type: "nonMd", error: err2String(err) }],
});
}
return EMPTY_CONTEXT_MATERIALIZATION_RESULT;
});
}
/** Publish a project's context-load state to the projectId-keyed atom. */
private setContextLoadState(projectId: string, state: AgentProjectContextLoadState): void {
settingsStore.set(agentProjectContextLoadAtom, (prev) => ({ ...prev, [projectId]: state }));
}
/**
* Re-run a project's context materialization on demand — the status popover's
* "Retry" action. Fire-and-forget: progress/failures republish through
* {@link agentProjectContextLoadAtom} exactly like a session-create run. Passes
* `forceRetryFailed` so known-bad sources are re-fetched instead of honoring
* their persisted failure markers (the automatic path cheap-skips them). A
* no-op for the global scope (no per-project context).
*
* Early-exits while a run already owns the load atom (`blocking`), mirroring
* {@link rematerializeSource}: joining the in-flight session-create run would
* let its cheap-skip swallow the force, so the user's Retry would do nothing.
* Returns whether a real forced run started (so the caller can defer the
* post-retry landing refresh).
*/
rematerializeContext(projectId: ProjectScopeId): boolean {
if (projectId === GLOBAL_SCOPE) return false;
// A session-create run owns the atom while blocking; don't fold the force in.
if (settingsStore.get(agentProjectContextLoadAtom)[projectId]?.blocking) return false;
let cwd: string;
try {
cwd = this.resolveSessionCwd(projectId);
} catch (err) {
// Off-desktop (no FileSystemAdapter): surface as a completed run with a
// single synthetic failure, mirroring the materializer's fatal path.
this.setContextLoadState(projectId, {
phase: "done",
blocking: false,
failedSources: [{ path: "Project context", type: "nonMd", error: err2String(err) }],
});
return false;
}
// Start the forced pass SYNCHRONOUSLY so it claims the materializer's
// single-flight slot before this returns: a background warm (a source edit)
// runs through that guard without owning the blocking atom, so the check
// above can't see it — but the forced run supersedes it (see
// {@link ensureProjectContextMaterialized}), and any landing refresh the
// returned `true` triggers then joins THIS forced run, not the stale warm.
void this.beginContextMaterialization(projectId, cwd, true);
// Reason: report whether a real run started so the caller can defer the
// post-retry landing refresh (skipped for the no-op scopes above).
return true;
}
/**
* Re-materialize a SINGLE source on demand — the per-row "Retry" in the
* Content Conversion panel. Ignored while a full run is in flight (the UI hides
* Retry then, and the running pass already re-attempts every source). On
* settle, drops this source's stale failure from the load atom — re-adding it
* only if the retry failed again — so the panel reflects the new outcome
* without a whole-project re-run. A no-op for the global scope.
*
* Returns whether a retry actually ran to completion — `false` when it was
* skipped (global scope, a full run owns the atom, or this source is already
* retrying), so the caller can avoid a premature post-retry refresh.
*/
async rematerializeSource(
projectId: ProjectScopeId,
item: { kind: MaterializedSourceType; source: string }
): Promise<boolean> {
if (projectId === GLOBAL_SCOPE) return false;
const before = settingsStore.get(agentProjectContextLoadAtom)[projectId];
// A full run owns the atom while blocking; don't race it.
if (before?.blocking) return false;
const matchesItem = (f: FailedItem) =>
f.path === item.source && (item.kind === "file" ? f.type === "nonMd" : f.type === item.kind);
const sameSource = (r: { kind: MaterializedSourceType; source: string }) =>
r.kind === item.kind && r.source === item.source;
// Single-flight per source: if this source's retry is already in flight, a
// second click would fire a duplicate materialize and let the first finisher
// clear the spinner early — so bail rather than re-fire.
const beforeRetrying = before?.retryingSources ?? [];
if (beforeRetrying.some(sameSource)) return false;
// Optimistically mark this source as retrying so its row flips to a spinner
// immediately — the click has visible feedback even if the retry fails again.
// Drop its stale failure now; re-add below only if the retry fails.
this.setContextLoadState(projectId, {
...(before ?? { phase: "done" as const }),
blocking: false,
failedSources: (before?.failedSources ?? []).filter((f) => !matchesItem(f)),
retryingSources: [...beforeRetrying, item],
});
// The single-source retry serializes with any in-flight full run on the
// shared per-artifact lock (keyed by snapshot file name), and shared snapshots
// are never reconciled — so a concurrent warm can no longer reap the snapshot
// this Retry writes. No pre-join is needed.
const failures = await materializeProjectContextSource(this.app, projectId, item);
const prev = settingsStore.get(agentProjectContextLoadAtom)[projectId];
// A full run may have started during our await — it now owns the atom. Bail
// so we don't clobber its `blocking` send-gate / progress with a stale write.
if (prev?.blocking) return false;
const others = (prev?.failedSources ?? []).filter((f) => !matchesItem(f));
const remainingRetrying = (prev?.retryingSources ?? []).filter((r) => !sameSource(r));
this.setContextLoadState(projectId, {
...(prev ?? { phase: "done" as const }),
blocking: false,
retryingSources: remainingRetrying.length > 0 ? remainingRetrying : undefined,
failedSources: [...others, ...failures.map(toFailedItem)],
});
return true;
}
/** The scope the active session belongs to ({@link GLOBAL_SCOPE} or a project id). */
getActiveProjectId(): ProjectScopeId {
return this.activeProjectId;
}
/**
* Sessions belonging to `projectId`, in tab order. Feeds the scoped tab strip.
* Distinct from {@link getSessions} (which stays full-set for draft-prune /
* auto-spawn). Returns a shared frozen empty array when the scope has none.
*/
getSessionsForScope(projectId: ProjectScopeId): AgentSession[] {
const scoped: AgentSession[] = [];
for (const session of this.sessions.values()) {
if (session.projectId !== projectId) continue;
if (this.detachedFromTabIds.has(session.internalId)) continue;
scoped.push(session);
}
return scoped.length === 0 ? EMPTY_SESSIONS : scoped;
}
/** Session ids belonging to `projectId`, in tab order. */
private getSessionIdsForScope(projectId: ProjectScopeId): string[] {
const ids: string[] = [];
for (const session of this.sessions.values()) {
if (session.projectId !== projectId) continue;
if (this.detachedFromTabIds.has(session.internalId)) continue;
ids.push(session.internalId);
}
return ids;
}
/**
* Switch the active scope to `projectId` and surface one of its sessions:
* its MRU (if still alive) → any existing session in the scope → a freshly
* auto-spawned one. Orphaned ids (project deleted) show a Notice and are
* rejected without touching the active scope or cwd (no recovery UI yet).
* `exitProject` is just re-entering {@link GLOBAL_SCOPE}.
*/
async enterProject(projectId: ProjectScopeId): Promise<void> {
if (this.disposed) return;
if (projectId !== GLOBAL_SCOPE && !getCachedProjectRecordById(projectId)) {
new Notice("This project no longer exists. Restore it to open its chats.");
return;
}
// Already here with a live in-scope session — nothing to restore.
const current = this.getActiveSession();
if (
projectId === this.activeProjectId &&
current &&
current.projectId === projectId &&
current.getStatus() !== "closed"
) {
return;
}
// Snapshot the scope this entry replaces BEFORE the optimistic switch, so a
// rejected auto-spawn below can restore it (see `spawnEnteredScopeOrRollback`).
const previousActiveProjectId = this.activeProjectId;
const previousActiveSessionId = this.activeSessionId;
this.parkActiveScope();
this.activeProjectId = projectId;
// A project scope opens as a FRESH visit: its prior conversational chats
// move to chat history (detached from the tab strip — not closed, so a
// backgrounded turn keeps running and stays visible via the history row's
// spinner/done-dot). A never-used empty landing tab is reused instead of
// stacking a new blank tab on every visit. The global workspace keeps its
// restore-the-last-tab behavior (it's the implicit scope `exitProject`
// returns to, not a project the user deliberately re-opens).
if (projectId !== GLOBAL_SCOPE) {
// Reuse an empty landing only when it's safe: not dirty (its
// `<project_context>` would be stale — materialization-only, see
// `contextDirtySignatures`) AND its creation-time capture still matches the
// live project config (`pickReusableLandingSession` checks the
// landing-capture signature, which also covers a System-Prompt-only edit the
// dirty flag ignores). When we instead spawn fresh, detach the project's
// empty landings too so a stale blank one can't linger in the strip; when we
// reuse, leave them so the adopted tab survives.
const dirty = this.isProjectContextDirty(projectId);
const reusable = dirty ? null : this.pickReusableLandingSession(projectId);
this.detachSessionsForProjectEntry(projectId, { includeEmpty: !reusable });
if (reusable) {
this.detachedFromTabIds.delete(reusable.internalId);
this.activeSessionId = reusable.internalId;
this.lastActiveByScope.set(projectId, reusable.internalId);
reusable.clearNeedsAttention();
this.notify();
this.touchProjectUsage(projectId);
return;
}
this.activeSessionId = null;
this.notify();
await this.spawnEnteredScopeOrRollback(
projectId,
previousActiveProjectId,
previousActiveSessionId
);
this.touchProjectUsage(projectId);
return;
}
const restored = this.restoreScopeActiveSession(projectId);
if (restored) {
this.activeSessionId = restored.internalId;
this.lastActiveByScope.set(projectId, restored.internalId);
restored.clearNeedsAttention();
this.notify();
this.touchProjectUsage(projectId);
return;
}
// No reusable session in this scope — drop the stale cross-scope pointer
// (keeps the `active.projectId === activeProjectId` invariant) and spawn.
this.activeSessionId = null;
this.notify();
await this.getOrCreateActiveSession();
// Reason: count as a "use" only after the spawn resolves — a failed spawn
// (await throws) must not bump MRU.
this.touchProjectUsage(projectId);
}
/**
* Detach a project scope's prior tabs on re-entry — they stay live in the pool
* and listed in chat history. Conversational chats (with user-visible
* messages) always detach. Empty landing tabs detach only when
* `includeEmpty` is set (the caller is spawning a fresh session, so any
* existing empty landing is stale/unusable and must not linger in the strip);
* otherwise they're left attached so the reused one stays the visit's tab (see
* {@link pickReusableLandingSession}).
*
* DESIGN NOTE — detached sessions are deliberately kept ALIVE (a backgrounded
* turn keeps running; the history row shows its spinner/done-dot) and are NOT
* evicted here, so repeated project re-entry accumulates idle detached sessions
* for the app's lifetime. Bounding the idle-detached class — an LRU cap that
* never evicts running/awaiting sessions — needs a per-session backend close
* primitive the backends don't expose yet, so it is out of scope for the
* project-workspace landing. Known limitation, deliberately deferred
* (obsidian-copilot-preview#164; the missing close primitive is
* obsidian-copilot-preview#178). If a future review flags this again, point
* them here.
*/
private detachSessionsForProjectEntry(
projectId: ProjectScopeId,
opts: { includeEmpty: boolean }
): void {
for (const session of this.sessions.values()) {
if (session.projectId !== projectId) continue;
if (session.getStatus() === "closed") continue;
if (!opts.includeEmpty && !session.hasUserVisibleMessages()) continue;
this.detachedFromTabIds.add(session.internalId);
}
}
/**
* The scope's reusable empty landing tab — a live, never-messaged session that
* a fresh visit can adopt instead of spawning a new blank one. A candidate must
* also have captured the CURRENT project config (matching landing-capture
* signature); one that baked in stale config is skipped. Prefers the scope's
* MRU; falls back to the most recent matching session. `null` means the caller
* should spawn fresh.
*/
private pickReusableLandingSession(projectId: ProjectScopeId): AgentSession | null {
const expected = this.currentLandingCaptureSignature(projectId);
if (!expected) return null;
const isReusable = (session: AgentSession): boolean =>
this.isReusableLandingShell(session, projectId) &&
this.landingCaptureSignatures.get(session.internalId) === expected;
const mruId = this.lastActiveByScope.get(projectId);
const mru = mruId ? this.sessions.get(mruId) : undefined;
if (mru && isReusable(mru)) return mru;
let fallback: AgentSession | null = null;
for (const session of this.sessions.values()) {
if (isReusable(session)) fallback = session;
}
return fallback;
}
/**
* Whether a session is a clean slate to adopt as a landing — idle/starting and
* never messaged. An "error" landing (backend never opened) or one mid-turn
* ("running"/"awaiting_permission") is not, so the caller should spawn fresh.
* This is the shell check ONLY; capture-freshness is gated separately.
*/
private isReusableLandingShell(session: AgentSession, projectId: ProjectScopeId): boolean {
return (
session.projectId === projectId &&
(session.getStatus() === "idle" || session.getStatus() === "starting") &&
!session.hasUserVisibleMessages()
);
}
/** The live project record's landing-capture signature, or null off-project. */
private currentLandingCaptureSignature(projectId: ProjectScopeId): string | null {
if (projectId === GLOBAL_SCOPE) return null;
const record = getCachedProjectRecordById(projectId);
return record ? getProjectLandingCaptureSignature(record) : null;
}
/**
* Record a successful enter of `projectId` as its most-recent use, mirroring
* chat-mode {@link ProjectManager.switchProject}. Skips {@link GLOBAL_SCOPE}
* (the implicit workspace `exitProject` returns to) and any scope that is no
* longer current — the spawn-path caller touches after an `await`, so a
* concurrent `enterProject` may have moved the active scope on in the meantime.
*/
private touchProjectUsage(projectId: ProjectScopeId): void {
// Reason: the spawn path touches after `await getOrCreateActiveSession()`;
// if the user switched scopes (or we were disposed) during that await, the
// project they LEFT must not be bumped to MRU top. Only credit the scope
// we're actually still in. The restored path passes trivially — there
// `activeProjectId` already equals `projectId` when this is called.
if (this.disposed || projectId === GLOBAL_SCOPE || this.activeProjectId !== projectId) return;
// Reason: MRU feedback only — fire-and-forget so the throttled frontmatter
// write never blocks the scope switch; touchProjectLastUsed logs+swallows
// its own failures.
void ProjectFileManager.getInstance(this.app).touchProjectLastUsed(projectId);
}
/** Leave the current project and return to the global workspace. */
async exitProject(): Promise<void> {
await this.enterProject(GLOBAL_SCOPE);
}
/** Record the current active session as its scope's MRU before a scope switch. */
private parkActiveScope(): void {
const active = this.getActiveSession();
if (active && active.getStatus() !== "closed") {
this.lastActiveByScope.set(active.projectId, active.internalId);
}
}
/**
* Pick the session to surface when entering `projectId`: its recorded MRU if
* still alive, else the last existing session in the scope. `null` means the
* scope is empty and the caller should auto-spawn.
*/
private restoreScopeActiveSession(projectId: ProjectScopeId): AgentSession | null {
const mruId = this.lastActiveByScope.get(projectId);
if (mruId) {
const mru = this.sessions.get(mruId);
if (mru && mru.getStatus() !== "closed") return mru;
}
const scoped = this.getSessionsForScope(projectId);
return scoped.length > 0 ? scoped[scoped.length - 1] : null;
}
/**
* Park the current scope and point `activeProjectId` at `projectId` without
* restoring/spawning a session — used by history load, which creates the
* specific saved session itself right after.
*/
private setActiveScope(projectId: ProjectScopeId): void {
if (projectId === this.activeProjectId) return;
this.parkActiveScope();
this.activeProjectId = projectId;
}
/**
* Undo the optimistic scope switch a history load performs before it has a
* session, when the resume/create that follows rejects. A history load calls
* `setActiveScope` to point `activeProjectId` at the chat's scope (so the new
* session activates there) while `activeSessionId` still references the
* previous scope's session; a failed load would otherwise strand the manager
* with `getActiveSession().projectId !== activeProjectId` until the user
* manually switches scopes. Only roll back if we're still parked in the scope
* we switched to — a concurrent scope switch during the awaited backend spawn
* means the user has moved on, and forcing them back would reintroduce the
* very race `createSession`'s activation guard already avoids.
*/
private rollbackHistoryLoadScope(
attemptedProjectId: ProjectScopeId,
previousProjectId: ProjectScopeId
): void {
if (this.activeProjectId === attemptedProjectId) {
this.activeProjectId = previousProjectId;
}
}
/**
* Auto-spawn a just-entered project scope's first session, undoing the
* optimistic scope switch if the spawn rejects (e.g. a missing backend binary).
* By this point `enterProject` has parked the prior scope, pointed
* `activeProjectId` at the new scope, detached the new scope's tabs, and nulled
* `activeSessionId`. The callers only surface a Notice, so without this a
* rejected spawn would strand the manager in a project scope with no active
* chat. Restores the previous scope's `activeProjectId` + active-session pointer
* and re-notifies, then rethrows so the caller still reports the failure.
*
* Guarded on still being parked in the attempted scope: a concurrent
* `enterProject` during the awaited spawn means the user moved on, and forcing
* them back would clobber that newer switch (mirrors
* {@link rollbackHistoryLoadScope}). The detached tabs are intentionally left
* detached — they belong to the project we failed to enter, are invisible from
* the restored scope, and a later successful entry re-detaches them anyway as a
* fresh visit.
*/
private async spawnEnteredScopeOrRollback(
attemptedProjectId: ProjectScopeId,
previousProjectId: ProjectScopeId,
previousActiveSessionId: string | null
): Promise<void> {
try {
await this.getOrCreateActiveSession();
} catch (err) {
if (this.activeProjectId === attemptedProjectId) {
this.activeProjectId = previousProjectId;
this.activeSessionId = previousActiveSessionId;
this.notify();
}
throw err;
}
}
setDefaultBackend(backendId: BackendId): void {
if (getSettings().agentMode?.activeBackend === backendId) return;
setSettings((cur) => ({
agentMode: { ...cur.agentMode, activeBackend: backendId },
}));
this.notify();
}
/** Read the user's sticky model preference for `backendId`, or `null` if none. */
getDefaultSelection(backendId: BackendId): ModelSelection | null {
const backends = getSettings().agentMode?.backends as
| Record<string, { defaultModel?: ModelSelection | null } | undefined>
| undefined;
return backends?.[backendId]?.defaultModel ?? null;
}
/** Persist a sticky model preference for `backendId`. Pass `null` to clear. */
async persistDefaultSelection(
backendId: BackendId,
selection: ModelSelection | null
): Promise<void> {
setSettings((cur) => {
const existing = (cur.agentMode.backends as Record<string, unknown> | undefined)?.[
backendId
] as Record<string, unknown> | undefined;
return {
agentMode: {
...cur.agentMode,
backends: {
...cur.agentMode.backends,
[backendId]: { ...(existing ?? {}), defaultModel: selection },
},
},
};
});
}
/**
* Apply a (baseModelId, effort) selection to the active session. Both
* fields are optional patches against the current selection:
* - `baseModelId` omitted → keep current
* - `effort` omitted → keep current
* - `effort: null` → explicit "default" (no-op for descriptor-style
* backends, encoded as the bare model id for suffix-style backends)
*
* `opts.expectBackendId`, when provided, makes this a silent no-op if
* the active session is on a different backend. Used by the effort
* sibling, which captures the backend id at picker-build time and
* might fire after a session swap.
*
* A chat-side model switch is transient: it mutates only the active
* session. The durable per-backend default is written exclusively by the
* settings picker (`persistDefaultSelection`), so a one-off chat pick no
* longer drifts the default.
*/
async applySelection(
patch: { baseModelId?: string; effort?: string | null },
opts?: { expectBackendId?: BackendId }
): Promise<void> {
const session = this.getActiveSession();
if (!session) return;
if (opts?.expectBackendId && session.backendId !== opts.expectBackendId) return;
const current = session.getState()?.model?.current;
if (!current) return;
const descriptor = this.resolveDescriptor(session.backendId);
const resolved: ModelSelection = {
baseModelId: patch.baseModelId ?? current.baseModelId,
effort: patch.effort !== undefined ? patch.effort : current.effort,
};
await descriptor.applySelection(session, resolved);
}
/**
* Apply a canonical mode change against the active session. `spec` carries
* the native dispatch info (which ACP RPC + payload); `mode` is the canonical
* id to persist. After a successful apply, `mode` becomes the backend's sticky
* default so the next new conversation reopens in it — symmetric with
* `applySelection`. If the apply throws, no persistence occurs.
*/
async applyMode(backendId: BackendId, mode: CopilotMode, spec: ModeApplySpec): Promise<void> {
const session = this.getActiveSession();
if (!session || session.backendId !== backendId) return;
if (spec.kind === "setMode") {
await session.setMode(spec.nativeId);
} else {
await session.setConfigOption(spec.configId, spec.value);
}
await this.persistDefaultMode(backendId, mode);
}
/** Read the user's sticky mode preference for `backendId`, or `null` if none. */
getDefaultMode(backendId: BackendId): CopilotMode | null {
const backends = getSettings().agentMode?.backends as
| Record<string, { defaultMode?: CopilotMode | null } | undefined>
| undefined;
return backends?.[backendId]?.defaultMode ?? null;
}
/** Persist a sticky mode preference for `backendId`. Pass `null` to clear. */
async persistDefaultMode(backendId: BackendId, mode: CopilotMode | null): Promise<void> {
setSettings((cur) => {
const existing = (cur.agentMode.backends as Record<string, unknown> | undefined)?.[
backendId
] as Record<string, unknown> | undefined;
return {
agentMode: {
...cur.agentMode,
backends: {
...cur.agentMode.backends,
[backendId]: { ...(existing ?? {}), defaultMode: mode },
},
},
};
});
}
getBackendProcess(backendId: BackendId): BackendProcess | null {
return this.backends.get(backendId) ?? null;
}
/**
* Restart a backend process so spawn-time configuration, including native
* skill discovery and deny rules, is rebuilt from current settings. If a
* session on that backend is busy, the restart is deferred until it is idle.
*
* When the manager owns no process yet, a warm probe held by the preloader
* may still carry the pre-change spawn config; we refresh it instead (see
* `refreshWarmProbe`) so the first chat-open doesn't adopt a stale proc.
*
* Returns `true` when a running backend was restarted, a restart was
* scheduled, or a warm probe was refreshed; `false` when nothing exists yet.
*/
async restartBackend(backendId: BackendId, reason: string): Promise<boolean> {
if (this.disposed) return false;
const inflight = this.starting.get(backendId);
if (inflight) {
await inflight.catch(() => undefined);
}
const backend = this.backends.get(backendId);
if (!backend) return this.refreshWarmProbe(backendId, reason);
if (this.hasBusySession(backendId)) {
const prev = this.pendingBackendRestarts.get(backendId);
this.pendingBackendRestarts.set(backendId, prev ? `${prev}; ${reason}` : reason);
logInfo(`[AgentMode] deferred ${backendId} backend restart: ${reason}`);
return true;
}
await this.restartBackendNow(backendId, reason);
return true;
}
/**
* React to a backend's install state changing at runtime — a binary path
* applied/cleared, or a binary installed/updated from the Configure dialog.
* Without this, the change only re-renders the settings status line: the
* running or warm process keeps the old binary, and a backend installed
* after plugin load is never spawned, so the user has to reload the plugin.
*
* Three transitions, all reusing the existing restart/refresh coalescing so
* a burst of edits folds into one re-probe:
* - now uninstalled → tear down any live proc and drop the warm probe so
* the picker stops offering a backend that can no longer spawn;
* - installed with a live/warm process → restart/refresh it against the
* new binary;
* - installed but never probed (freshly installed) → kick a first preload
* (`restartBackend` returns `false` here, since nothing is warm yet).
*
* The fresh-preload case is unique to this signal: the load-time preload
* loop skips backends that aren't installed yet, so install-state is the
* only event that can flip a backend from "never preloaded" into "should
* preload". The provider/system-prompt restart subscriptions always act on
* an already-installed (already-preloaded) backend, so they only ever need
* `restartBackend`'s restart/refresh — never a first preload.
*/
async onInstallStateChanged(backendId: BackendId): Promise<void> {
if (this.disposed) return;
if (!this.isBackendInstalled(backendId)) {
// Tears down a live proc (the install guard in `restartBackendNow` keeps
// it from respawning); `clearCached` then drops any warm probe.
await this.restartBackend(backendId, "binary no longer available");
this.preloader.clearCached(backendId);
return;
}
const refreshed = await this.restartBackend(backendId, "binary path changed");
if (!refreshed) {
this.registerPreload(backendId, this.preloader.preload(backendId));
}
}
/**
* Refresh a preloader warm probe when the manager owns no process yet.
* Spawn-time config (provider keys, enabled models, native skills, system
* prompt) is baked into the warm probe when it spawns, so a config change
* made before the first chat-open would otherwise never reach it — the
* first `createSession` adopts the stale warm proc and the picker flags
* freshly-enabled models as "not offered by agent" until a full reload.
*
* Delegates the re-probe to `preloader.refresh`, which coalesces the burst of
* writes a single BYOK save produces (provider row → key → enabled models)
* into one re-probe against the settled settings. No-op (returns `false`)
* when nothing is warm/in-flight for this backend, or it isn't installed.
*/
private refreshWarmProbe(backendId: BackendId, reason: string): boolean {
if (this.disposed) return false;
if (!this.isBackendInstalled(backendId)) return false;
const probe = this.preloader.refresh(backendId);
if (!probe) return false;
logInfo(`[AgentMode] refreshing warm ${backendId} probe: ${reason}`);
this.registerPreload(backendId, probe);
return true;
}
/** Whether `backendId`'s descriptor reports its binary/runtime as installed. */
private isBackendInstalled(backendId: BackendId): boolean {
const descriptor = this.opts.resolveDescriptor(backendId);
return descriptor?.getInstallState(getSettings())?.kind === "ready";
}
/** Cached unified backend state for `backendId`, populated by the model preloader. */
getCachedBackendState(backendId: BackendId): BackendState | null {
return this.preloader.getCachedBackendState(backendId);
}
/**
* Per-model effort options (baseModelId → options) discovered by the
* preloader's post-catalog prefetch, or `null`. The picker reads this to show
* effort steppers for models that aren't currently active.
*/
getEffortCatalog(backendId: BackendId): Record<string, EffortOption[]> | null {
return this.preloader.getEffortCatalog(backendId);
}
/**
* The agent's catalog-declared default base model id for `backendId`.
* Trusts `availableModels` ordering (agents put their recommended model
* first). Returns `null` when the catalog hasn't been probed yet.
*/
getDefaultBaseModelId(backendId: BackendId): string | null {
const state = this.preloader.getCachedBackendState(backendId);
return state?.model?.availableModels[0]?.baseModelId ?? null;
}
/** Subscribe to preloader cache updates. Used by the picker hook. */
subscribeModelCache(listener: () => void): () => void {
return this.preloader.subscribe(listener);
}
/**
* Stable string that changes whenever anything a model picker reads for
* `backendId` changes: preload status, the cached backend state, and the
* prefetched effort catalog. A `useSyncExternalStore` snapshot built only
* from `getPreloadStatus` would miss the post-`"ready"` effort-catalog
* prefetch (the snapshot stays `"ready"`, so React skips the rerender and
* the Default effort dropdown never appears).
*/
getModelCacheSignature(backendId: BackendId): string {
const status = this.getPreloadStatus(backendId);
const state = backendStateSignature(this.getCachedBackendState(backendId));
const effort = this.getEffortCatalog(backendId);
const effortSig = effort
? Object.keys(effort)
.sort()
.map((id) => `${id}:${effort[id].map((o) => o.value ?? "").join(",")}`)
.join("|")
: "";
return `${status}#${state}#${effortSig}`;
}
/** Kick off a (best-effort) model probe for `backendId`. */
preloadModels(backendId: BackendId): Promise<void> {
return this.preloader.preload(backendId);
}
/**
* Register a backend's plugin-load preload promise. While the promise is
* pending, `getPreloadStatus(backendId)` returns `"pending"`; on settle it
* transitions to `"ready"` (the preload itself is best-effort, so even
* rejected promises flip to `"ready"` here — the picker reads the cached
* state alongside this status and renders empty-but-error vs loading).
* On programmatic error (rejected promise) the entry becomes `"error"` so
* the picker can offer a retry affordance.
*/
registerPreload(backendId: BackendId, promise: Promise<void>): void {
this.preloadStatus.set(backendId, "pending");
this.notify();
promise.then(
() => {
if (this.disposed) return;
this.preloadStatus.set(backendId, "ready");
this.notify();
},
() => {
if (this.disposed) return;
this.preloadStatus.set(backendId, "error");
this.notify();
}
);
}
/**
* Synchronous check, suitable for React render gates. Defaults to the
* active backend; pass `backendId` to query a specific backend (the
* picker reads each backend's status). Backends that were never queued
* (not installed / disabled) are treated as ready so the chat UI doesn't
* stall waiting on a preload that will never run.
*/
isPreloadReady(backendId?: BackendId): boolean {
const id = backendId ?? getSettings().agentMode?.activeBackend;
if (!id) return true;
const status = this.preloadStatus.get(id);
return status === undefined || status === "ready" || status === "error";
}
/**
* Read a backend's preload status for the picker. `"absent"` indicates
* the backend was never queued (uninstalled or disabled) — the picker
* uses today's behavior in that case (omit the section silently).
*/
getPreloadStatus(backendId: BackendId): "pending" | "ready" | "error" | "absent" {
return this.preloadStatus.get(backendId) ?? "absent";
}
/**
* Cancel any in-flight turn, dispose the session, and remove it from the
* pool. If the closed session was active, picks the right neighbor (or the
* last remaining session) as the new active — `null` when none remain.
* Backend stays up.
*/
async closeSession(id: string): Promise<void> {
const session = this.sessions.get(id);
if (!session) return;
const closedScope = session.projectId;
// Capture the closed tab's index WITHIN ITS SCOPE before delete so the
// neighbour pick stays in-scope (never jumps the user to another project).
const scopeIdsBefore = this.getSessionIdsForScope(closedScope);
const closedIdx = scopeIdsBefore.indexOf(id);
try {
await session.cancel();
} catch (e) {
logWarn(`[AgentMode] cancel during closeSession failed`, e);
}
// Drain any pending debounced auto-save before tearing the session
// down — otherwise the last few tokens of a fast turn never reach disk.
await this.drainAutoSave(session);
try {
await session.dispose();
} catch (e) {
logWarn(`[AgentMode] dispose during closeSession failed`, e);
}
this.detachAutoSave(id);
this.sessions.delete(id);
this.chatUIStates.delete(id);
this.landingCaptureSignatures.delete(id);
this.detachedFromTabIds.delete(id);
// Drop the closed session from its scope's MRU so a later enter can't
// resurrect a dead id.
if (this.lastActiveByScope.get(closedScope) === id) {
this.lastActiveByScope.delete(closedScope);
}
if (this.activeSessionId === id) {
const scopeIdsAfter = this.getSessionIdsForScope(closedScope);
const nextId = pickScopeNeighbor(
scopeIdsAfter,
closedIdx,
this.lastActiveByScope.get(closedScope)
);
this.activeSessionId = nextId;
if (nextId) this.lastActiveByScope.set(closedScope, nextId);
// `activeProjectId` stays `closedScope` even when it's now empty — never
// silently jump the active scope on close.
}
this.notify();
}
/**
* Move the active pointer to `id`. No-op if `id` is unknown. When the target
* lives in a different scope, the active scope follows it (cross-scope
* auto-switch) so the `active.projectId === activeProjectId` invariant holds.
* This is NOT a cancel path — the previously-active turn keeps running in the
* background (D6 auto-park).
*/
setActiveSession(id: string): void {
const session = this.sessions.get(id);
if (!session) return;
if (this.activeSessionId === id) return;
if (session.projectId !== this.activeProjectId) {
this.parkActiveScope();
this.activeProjectId = session.projectId;
}
// Surfacing a session (history open / tab click) re-attaches it to its
// scope's tab strip if a prior project re-entry had detached it.
this.detachedFromTabIds.delete(id);
this.activeSessionId = id;
this.lastActiveByScope.set(session.projectId, id);
session.clearNeedsAttention();
this.notify();
}
/**
* Spawn a fresh session at `oldId`'s tab-strip position and close `oldId`
* in the background. Used by the in-tab "New Chat" button so the
* replacement chat takes the same slot the user was looking at instead of
* appearing at the end of the strip (which made it look like focus had
* jumped to a sibling tab). `backendId` defaults to the same fallback as
* `createSession`.
*/
async replaceSessionInPlace(oldId: string, backendId?: BackendId): Promise<AgentSession> {
const oldIdx = Array.from(this.sessions.keys()).indexOf(oldId);
// The replacement inherits the REPLACED session's scope, not the active
// scope (they can differ if the old tab wasn't the active one).
const replacedProjectId = this.sessions.get(oldId)?.projectId ?? this.activeProjectId;
const created = await this.createSession(backendId, replacedProjectId);
if (oldIdx >= 0) {
this.moveMapEntry(this.sessions, created.internalId, oldIdx);
this.moveMapEntry(this.chatUIStates, created.internalId, oldIdx);
this.notify();
}
void this.closeSession(oldId).catch((e) =>
logWarn(`[AgentMode] closeSession during replaceSessionInPlace failed`, e)
);
return created;
}
// Maps preserve insertion order, so reordering means rebuilding the map.
// Used to land a freshly-created session at a specific tab-strip index.
private moveMapEntry<V>(map: Map<string, V>, key: string, targetIdx: number): void {
if (!map.has(key)) return;
const entries = Array.from(map.entries());
const fromIdx = entries.findIndex(([k]) => k === key);
if (fromIdx === -1 || fromIdx === targetIdx) return;
const [entry] = entries.splice(fromIdx, 1);
entries.splice(targetIdx, 0, entry);
map.clear();
for (const [k, v] of entries) map.set(k, v);
}
/** Update a session's user-visible label. No-op if `id` is unknown. */
renameSession(id: string, label: string | null): void {
const session = this.sessions.get(id);
if (!session) return;
session.setLabel(label);
this.notify();
}
getIsStarting(): boolean {
return this.startingBackendId !== null;
}
/** Backend id currently being booted, or null when no create is in flight. */
getStartingBackendId(): BackendId | null {
return this.startingBackendId;
}
getLastError(): string | null {
return this.lastError;
}
getSession(id: string): AgentSession | null {
return this.sessions.get(id) ?? null;
}
/**
* Find a session by its backend `sessionId` (the agent-side identifier
* embedded in `requestPermission` / `session/update` notifications).
* Distinct from the internal id keying our own pool. Returns null while
* the session is still starting (no backend id yet) or when no session
* matches.
*/
getSessionByBackendId(backendSessionId: string): AgentSession | null {
for (const session of this.sessions.values()) {
if (session.getBackendSessionId() === backendSessionId) return session;
}
return null;
}
/**
* Live (non-closed) session matching BOTH `backendId` and the backend
* `sessionId`. Matching the full identity at once — rather than finding the
* first session by `sessionId` and checking the backend after — keeps an
* (effectively impossible, UUID) cross-backend id collision from hiding the
* correct already-open tab. Used by the native-history open/rename paths.
*/
private findLiveSession(backendId: BackendId, sessionId: string): AgentSession | null {
for (const session of this.sessions.values()) {
if (session.backendId !== backendId) continue;
if (session.getBackendSessionId() !== sessionId) continue;
if (session.getStatus() === "closed") continue;
return session;
}
return null;
}
getChatUIState(id: string): AgentChatUIState | null {
return this.chatUIStates.get(id) ?? null;
}
getActiveSession(): AgentSession | null {
return this.activeSessionId ? (this.sessions.get(this.activeSessionId) ?? null) : null;
}
getActiveChatUIState(): AgentChatUIState | null {
return this.activeSessionId ? (this.chatUIStates.get(this.activeSessionId) ?? null) : null;
}
/** All sessions in creation order (Map iteration order). */
getSessions(): AgentSession[] {
return Array.from(this.sessions.values());
}
/**
* Subscribe to lifecycle changes (session created/closed/active changed/
* label changed, backend exit, isStarting/lastError flips). Also fires when a
* session enters or leaves `running` (so the recent-list spinner can follow).
* Returns an unsubscribe function. Listeners must not throw.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const l of this.listeners) {
try {
l();
} catch (e) {
logError("[AgentMode] manager listener threw", e);
}
}
}
/** Cancel any in-flight turn on the active session. Backend stays up. */
async cancel(): Promise<void> {
await this.getActiveSession()?.cancel();
}
/**
* Tear down every session and every spawned backend subprocess. Safe to
* call when nothing was started; safe to call multiple times.
*/
async shutdown(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.settingsUnsub();
// Unsubscribe SYNCHRONOUSLY up front: the teardown below awaits, and a
// project-record change landing in that window must not re-enter the handler
// on a half-disposed manager.
this.projectRecordsUnsubscriber?.();
this.projectRecordsUnsubscriber = undefined;
logInfo(
`[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})`
);
const allSessions = Array.from(this.sessions.values());
// Drain pending auto-saves for every session before disposing — same
// reasoning as `closeSession`. Done before the per-session unsubscribe so
// the timers don't fire with a half-disposed session.
await Promise.allSettled(allSessions.map((s) => this.drainAutoSave(s)));
for (const id of Array.from(this.sessionState.keys())) {
this.detachAutoSave(id);
}
await Promise.allSettled(
allSessions.map(async (session) => {
try {
await session.cancel();
} catch (e) {
logError("[AgentMode] cancel during shutdown failed", e);
}
try {
await session.dispose();
} catch (e) {
logError("[AgentMode] dispose during shutdown failed", e);
}
})
);
this.sessions.clear();
this.chatUIStates.clear();
this.landingCaptureSignatures.clear();
this.activeSessionId = null;
this.activeProjectId = GLOBAL_SCOPE;
this.lastActiveByScope.clear();
this.detachedFromTabIds.clear();
this.contextDirtySignatures.clear();
this.firstSessionPromiseByScope.clear();
const allBackends = Array.from(this.backends.values());
await Promise.allSettled(
allBackends.map(async (proc) => {
try {
await proc.shutdown();
} catch (e) {
logError("[AgentMode] backend shutdown failed", e);
}
})
);
this.backends.clear();
this.starting.clear();
this.startingBackendId = null;
this.listeners.clear();
this.preloadStatus.clear();
this.preloader.shutdown();
// Push any debounced index write to disk before the plugin unloads.
await this.opts.sessionIndex?.flush();
}
/**
* Open a previously-saved Agent Mode chat. If a live session is already
* bound to that file (because the user opened it earlier this run), focus
* its tab instead of spawning a duplicate. Otherwise, if the saved file
* carries a backend `sessionId` and the backend supports resume, rehydrate
* the prior backend session so the agent retains conversation context on
* the next turn. Falls back to a fresh session (UI-only history) when no
* sessionId was saved or the backend can't resume.
*/
async loadSessionFromHistory(file: TFile): Promise<AgentSession> {
if (!this.opts.persistenceManager) {
throw new Error("Agent chat persistence is not configured.");
}
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
for (const [internalId, state] of this.sessionState.entries()) {
if (state.path !== file.path) continue;
const existing = this.sessions.get(internalId);
if (existing && existing.getStatus() !== "closed") {
this.setActiveSession(internalId);
return existing;
}
state.path = undefined;
}
// Captured before we create the loaded session (which becomes active) so
// we can replace an empty landing tab in place instead of leaving it.
const previousActiveId = this.activeSessionId;
const loaded = await this.opts.persistenceManager.loadFile(file);
// `loaded.projectId` is authoritative (GLOBAL_SCOPE for legacy chats with no
// frontmatter). Read scope FIRST so the resumed session gets the right cwd;
// an orphaned project (deleted) shows a Notice and aborts rather than
// downgrading to the vault root (no recovery menu yet).
const projectId = loaded.projectId;
if (projectId !== GLOBAL_SCOPE && !getCachedProjectRecordById(projectId)) {
new Notice("This chat belongs to a project that no longer exists.");
throw new OrphanedProjectError(projectId);
}
// Point the active scope at the chat's scope before constructing its
// session (which then activates within that scope). No restore/spawn here —
// we create the specific saved session next. Snapshot the scope this call
// actually replaces right here, AFTER the awaits above — capturing earlier
// would let a scope switch raced in during `loadFile` make rollback restore
// a stale scope.
const previousActiveProjectId = this.activeProjectId;
this.setActiveScope(projectId);
// Resume or create the saved session. Either await can reject (e.g. a
// missing backend binary fails to spawn); on failure, undo the scope switch
// above so a failed open doesn't leave the active scope ahead of the active
// session. The throw points are all before a session activates, so no
// already-active session in the target scope can be wrongly rolled back.
let session: AgentSession;
try {
const resumed = loaded.sessionId
? await this.tryResumeSessionFromHistory(loaded.backendId, loaded.sessionId, projectId)
: null;
session = resumed ?? (await this.createSession(loaded.backendId, projectId));
} catch (err) {
this.rollbackHistoryLoadScope(projectId, previousActiveProjectId);
throw err;
}
session.loadDisplayMessages(loaded.messages);
session.seedSessionUsage(loaded.usage);
if (loaded.label) session.setLabel(loaded.label);
this.getSessionState(session.internalId).path = file.path;
if (loaded.sessionId) {
// Keep the native twin's recency in step with the markdown side so the
// merged history ranks this chat correctly after a reopen.
void this.opts.sessionIndex?.touch(loaded.backendId, loaded.sessionId);
}
this.lastActiveByScope.set(projectId, session.internalId);
this.absorbIntoEmptyActiveTab(session, previousActiveId);
this.notify();
return session;
}
/**
* When a history item is opened while the active tab is an empty landing
* (no user-visible messages), give the loaded session that tab's strip
* position and close the empty one — so opening a chat doesn't leave a
* stray blank tab behind. A tab with a real conversation is never
* clobbered; the loaded chat opens as a new tab in that case.
*/
private absorbIntoEmptyActiveTab(loaded: AgentSession, previousActiveId: string | null): void {
if (!previousActiveId || previousActiveId === loaded.internalId) return;
const previous = this.sessions.get(previousActiveId);
if (!previous || previous.hasUserVisibleMessages()) return;
const oldIdx = Array.from(this.sessions.keys()).indexOf(previousActiveId);
if (oldIdx >= 0) {
this.moveMapEntry(this.sessions, loaded.internalId, oldIdx);
this.moveMapEntry(this.chatUIStates, loaded.internalId, oldIdx);
}
// Background close: the loaded session is already active, so closing the
// empty one won't reassign the active pointer.
void this.closeSession(previousActiveId).catch((e) =>
logWarn(`[AgentMode] closing empty tab during history load failed`, e)
);
}
/**
* Open a chat that exists only in a backend's native session store (no
* markdown note). If a live session is already bound to that backend
* session id, focus it; otherwise resume through the same path markdown
* history uses. Unlike `loadSessionFromHistory` there is no fresh-session
* fallback — silently opening an empty chat would misread as data loss, so
* the failure surfaces to the caller instead.
*
* The transcript is not rebuilt from the backend store (the resume path
* restores agent-side context only), so the chat may open visually empty
* while the agent still remembers the conversation on the next turn.
*/
async loadNativeSessionFromHistory(
backendId: BackendId,
sessionId: SessionId
): Promise<AgentSession> {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
// Identity is the (backendId, sessionId) pair — searched together so an
// id collision across backends can't hide the correct already-open tab.
const existing = this.findLiveSession(backendId, sessionId);
if (existing) {
this.setActiveSession(existing.internalId);
return existing;
}
// Captured before the resumed session becomes active so we can replace an
// empty landing tab in place rather than spawning a new one.
const previousActiveId = this.activeSessionId;
const index = this.opts.sessionIndex;
const entry = index ? await index.getEntry(backendId, sessionId) : null;
// Scope from the index entry (write-through / sweep attribution); absent ≙
// global. Mirrors loadSessionFromHistory's frontmatter handling: orphan
// guard first (never downgrade a project chat to the vault root), then
// point the active scope at the chat's scope before constructing it.
const projectId: ProjectScopeId = entry?.projectId ?? GLOBAL_SCOPE;
if (projectId !== GLOBAL_SCOPE && !getCachedProjectRecordById(projectId)) {
new Notice("This chat belongs to a project that no longer exists.");
throw new OrphanedProjectError(projectId);
}
// Snapshot the scope this call actually replaces right here, AFTER the
// `getEntry` await — capturing earlier would let a scope switch raced in
// during the await make rollback restore a stale scope.
const previousActiveProjectId = this.activeProjectId;
this.setActiveScope(projectId);
// Unlike markdown history there is no fresh-session fallback — a failed
// resume rejects, so undo the scope switch above before propagating it.
let session: AgentSession;
try {
const resumed = await this.tryResumeSessionFromHistory(backendId, sessionId, projectId);
if (!resumed) {
throw new Error(
`Could not resume session ${sessionId} from the ${backendId} session store.`
);
}
session = resumed;
} catch (err) {
this.rollbackHistoryLoadScope(projectId, previousActiveProjectId);
throw err;
}
// Rebuild the visible transcript for backends that resume without
// replaying it (Claude SDK reads its on-disk session jsonl). ACP backends
// replay through `loadSession`, so they don't implement this and the
// session already has its messages. Best-effort: an empty result leaves
// the resumed-but-blank session as-is rather than failing the open.
await this.hydrateResumedTranscript(session, backendId, sessionId);
// Reapply with the recorded source: a user rename stays sticky, but an
// agent/derived title is agent-sourced so a resumed opencode/codex
// session can still refresh its title from later agent updates.
if (entry?.title) {
session.restoreLabel(entry.title, entry.titleSource === "user" ? "user" : "agent");
}
if (index) await index.touch(backendId, sessionId);
this.lastActiveByScope.set(projectId, session.internalId);
this.absorbIntoEmptyActiveTab(session, previousActiveId);
this.notify();
return session;
}
/**
* Load a resumed session's display transcript from the backend's on-disk
* store when the backend supports it and the session came back empty.
* No-op for backends that replay via `loadSession` (they have no
* `readPersistedTranscript`) or when the store can't be reached.
*/
private async hydrateResumedTranscript(
session: AgentSession,
backendId: BackendId,
sessionId: SessionId
): Promise<void> {
const proc = this.backends.get(backendId);
if (!proc?.readPersistedTranscript) return;
if (session.store.getDisplayMessages().length > 0) return;
try {
// The store is keyed by the cwd the session RAN in (Claude encodes the
// transcript path from it), so a project chat must hydrate from its
// project folder — the vault root would look in the wrong directory.
// Resolved inside the try: hydration is best-effort, and an orphaned
// scope just skips it like any other store miss.
const transcript = await proc.readPersistedTranscript({
sessionId,
cwd: this.resolveSessionCwd(session.projectId),
});
if (transcript.length > 0) session.loadDisplayMessages(transcript);
} catch (e) {
logWarn(`[AgentMode] could not hydrate transcript for ${sessionId}`, e);
}
}
/**
* Spin up an `AgentSession` bound to an existing backend session id. Prefers
* `loadSession` (ACP replays the transcript through the backend) over
* `resumeSession` (Claude SDK reads its own on-disk transcript). Returns
* `null` when the backend supports neither — the caller falls back to a
* fresh session.
*/
private async tryResumeSessionFromHistory(
backendId: BackendId,
sessionId: SessionId,
projectId: ProjectScopeId
): Promise<AgentSession | null> {
// Same AGENTS.md mirror ensure as `createSession` — resume rehydrates an existing
// session, but its cwd still derives from the project record, so a cwd-instruction
// backend needs the mirror materialized before cwd is read. Never throws; skipped for
// GLOBAL_SCOPE and for claude.
if (projectId !== GLOBAL_SCOPE && CWD_INSTRUCTION_BACKENDS.has(backendId)) {
const record = getCachedProjectRecordById(projectId);
if (record) await ensureAgentsMirror(this.app, record);
}
const cwd = this.resolveSessionCwd(projectId);
// Kick off materialization in parallel (publishes the blocking load state up
// front), overlapping with `ensureBackend`. Unlike a fresh create, a resumed
// session can't appear before the backend rehydrates it, so we await the
// roots just before `loadSession`. Never rejects → resume is never blocked.
const contextReady =
projectId === GLOBAL_SCOPE ? undefined : this.beginContextMaterialization(projectId, cwd);
const descriptor = this.resolveDescriptor(backendId);
this.pendingCreates++;
this.startingBackendId = backendId;
this.notify();
let backend: BackendProcess;
try {
// Discard the optional warm probe-session info: we're rehydrating a
// specific saved session, not opening a fresh chat. The probe
// session sits unused on the proc until shutdown — harmless.
({ proc: backend } = await this.ensureBackend(backendId, descriptor));
} catch (err) {
this.lastError = err2String(err);
this.finishPendingCreate();
return null;
}
if (this.disposed) {
this.finishPendingCreate();
return null;
}
const mcpServers = resolveMcpServers(backend, getSettings().agentMode?.mcpServers);
// Await the roots now (materialize has been running alongside ensureBackend).
// GLOBAL has no contextReady → undefined, identical to a context-free resume.
// The inline `<project_context>` block is for fresh first prompts only, so a
// resumed session uses just the searchable roots here.
const additionalDirectories = contextReady
? (await contextReady).additionalDirectories
: undefined;
// The await above is a new suspension point — re-check disposal before
// touching the (possibly tearing-down) backend, mirroring the fresh-create
// guard in AgentSession.initialize. Same cleanup as the path's other returns.
if (this.disposed) {
this.finishPendingCreate();
return null;
}
let resumeResult: { sessionId: SessionId; state: BackendState } | null = null;
try {
resumeResult = await backend.loadSession({
sessionId,
cwd,
mcpServers,
projectId,
additionalDirectories,
});
} catch (err) {
if (!(err instanceof MethodUnsupportedError)) {
logWarn(`[AgentMode] loadSession failed for ${sessionId}`, err);
this.finishPendingCreate();
return null;
}
}
if (!resumeResult) {
try {
resumeResult = await backend.resumeSession({
sessionId,
cwd,
mcpServers,
projectId,
additionalDirectories,
});
} catch (err) {
if (err instanceof MethodUnsupportedError) {
logInfo(
`[AgentMode] backend ${backendId} does not support session resume; falling back to fresh session`
);
} else {
logWarn(`[AgentMode] resumeSession failed for ${sessionId}`, err);
}
this.finishPendingCreate();
return null;
}
}
if (this.disposed) {
this.finishPendingCreate();
return null;
}
const session = new AgentSession({
backend,
backendSessionId: resumeResult.sessionId,
internalId: uuidv4(),
backendId,
projectId,
initialState: resumeResult.state,
cwd,
getDescriptor: () => this.opts.resolveDescriptor(backendId),
runFanoutTurn: (input) => this.runFanoutTurn(input),
getDisplayName: (id) => this.resolveDescriptor(id).displayName,
getApp: () => this.app,
});
this.sessions.set(session.internalId, session);
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
this.detachedFromTabIds.delete(session.internalId);
this.activeSessionId = session.internalId;
this.activeProjectId = projectId;
this.lastActiveByScope.set(projectId, session.internalId);
this.attachAutoSave(session);
this.attachModelCacheSync(session);
this.attachAttentionTracking(session);
this.lastError = null;
this.finishPendingCreate();
logInfo(
`[AgentMode] resumed session (internal=${session.internalId} backend-id=${sessionId} backend=${backendId})`
);
return session;
}
private attachAutoSave(session: AgentSession): void {
const persistence = this.opts.persistenceManager;
const index = this.opts.sessionIndex;
if (!persistence && !index) return;
// The markdown auto-save is gated on `settings.autosaveChat` inside
// `scheduleAutoSave`; the index write-through is not — history must keep
// tracking the session even when the user opted out of markdown notes.
const trigger = () => {
this.scheduleAutoSave(session);
this.scheduleIndexTouch(session);
};
const unsubscribe = session.subscribe({
onMessagesChanged: trigger,
onStatusChanged: () => {},
onLabelChanged: trigger,
});
this.getSessionState(session.internalId).unsub = unsubscribe;
}
private scheduleAutoSave(session: AgentSession): void {
if (!getSettings().autosaveChat) return;
const state = this.getSessionState(session.internalId);
if (state.timer) window.clearTimeout(state.timer);
state.timer = window.setTimeout(() => {
state.timer = undefined;
this.flushAutoSave(session).catch((e) =>
logWarn(`[AgentMode] auto-save failed for ${session.internalId}`, e)
);
}, AUTOSAVE_DEBOUNCE_MS);
}
private scheduleIndexTouch(session: AgentSession): void {
if (!this.opts.sessionIndex) return;
const state = this.getSessionState(session.internalId);
if (state.indexTimer) window.clearTimeout(state.indexTimer);
state.indexTimer = window.setTimeout(() => {
state.indexTimer = undefined;
this.flushIndexTouch(session).catch((e) =>
logWarn(`[AgentMode] session-index update failed for ${session.internalId}`, e)
);
}, AUTOSAVE_DEBOUNCE_MS);
}
/**
* Record this session in the index so it appears in recent chats whether
* or not a markdown note exists. Skips sessions that haven't produced a
* user-visible message yet — a freshly-opened empty tab isn't history.
*/
private async flushIndexTouch(session: AgentSession): Promise<void> {
const index = this.opts.sessionIndex;
if (!index) return;
if (!this.sessions.has(session.internalId)) return;
const sessionId = session.getBackendSessionId();
if (!sessionId) return;
const messages = session.store.getDisplayMessages();
if (messages.length === 0) return;
const now = Date.now();
// Prefer the agent/user label; fall back to a title derived from the
// first user message so chats that have no agent title (every Claude
// Code chat — its SDK has no title API) don't read "Untitled chat".
// The derived title is recorded agent-sourced, so an opencode/codex
// summarizer title still overrides it later, and a user rename always wins.
const label = session.getLabel();
const title = label ?? deriveChatTitleFromMessages(messages);
const titleSource: "user" | "agent" | undefined = !title
? undefined
: label && session.getLabelSource() === "user"
? "user"
: "agent";
await index.recordSession({
backendId: session.backendId,
sessionId,
title,
titleSource,
createdAtMs: messages[0]?.timestamp?.epoch ?? now,
lastAccessedAtMs: now,
// Scope the native entry so project views can list it (markdown chats
// carry the scope in frontmatter; native-only chats have only this).
// GLOBAL_SCOPE maps to the field's "absent" encoding.
projectId: session.projectId === GLOBAL_SCOPE ? undefined : session.projectId,
});
}
/**
* Manual save entry point. Writes the active session via the same code path
* auto-save uses, but ignores `settings.autosaveChat`. Returns the on-disk
* path on success, or `null` when there was nothing to save (no active
* session, no messages, persistence not configured, or signature unchanged).
*/
async saveActiveSession(): Promise<{ path: string } | null> {
const session = this.getActiveSession();
if (!session) return null;
// Drain any pending debounced write first so it doesn't race with us.
const state = this.sessionState.get(session.internalId);
if (state?.timer) {
window.clearTimeout(state.timer);
state.timer = undefined;
}
return this.flushAutoSave(session);
}
private async flushAutoSave(session: AgentSession): Promise<{ path: string } | null> {
const persistence = this.opts.persistenceManager;
if (!persistence) return null;
if (!this.sessions.has(session.internalId)) return null;
const messages = session.store.getDisplayMessages();
if (messages.length === 0) return null;
const label = session.getLabel();
const sessionId = session.getBackendSessionId();
const usage = session.getSessionUsage();
// Skip the write when nothing user-visible has changed since the last
// save. Streaming token updates and idempotent label notifications
// otherwise rewrite the entire file on every debounce tick. Include
// the backend session id in the signature so the first save after the
// session finishes starting (when sessionId flips from null → real)
// always writes through, even if the message list hasn't changed yet.
// For an in-flight fan-out turn `message` stays empty until completion, so
// fold in a fingerprint of the live slots — otherwise mid-stream autosaves
// de-dupe to the first partial snapshot and a crash loses later progress.
const last = messages[messages.length - 1];
const fanoutSig = last?.fanout
? Object.values(last.fanout.answers)
.map((a) => `${a.status}:${a.text.length}`)
.join(",") + `|${last.fanout.summary.status}:${last.fanout.summary.text.length}`
: "";
// Fold in the usage `updatedAt` so a turn that only changed token usage
// (message text/label/sessionId all unchanged) still writes through.
const signature = `${label ?? ""}-${sessionId ?? ""}-${messages.length}-${
last?.message ?? ""
}-${fanoutSig}-${usage?.updatedAt ?? ""}`;
const state = this.getSessionState(session.internalId);
if (state.signature === signature) {
return state.path ? { path: state.path } : null;
}
const result = await persistence.saveSession(messages, session.backendId, {
label,
existingPath: state.path,
sessionId,
// GLOBAL_SCOPE writes no frontmatter (byte-identical to legacy chats);
// a real project id binds the chat to that scope on disk.
projectId: session.projectId,
usage: usage ?? undefined,
});
if (result) {
state.path = result.path;
state.signature = signature;
}
return result;
}
/**
* Cancel any pending debounced auto-save for `session` and run it
* synchronously, so the on-disk file reflects the final state before the
* session is disposed. Safe to call when no save is pending.
*/
private async drainAutoSave(session: AgentSession): Promise<void> {
const state = this.sessionState.get(session.internalId);
if (state?.indexTimer) {
window.clearTimeout(state.indexTimer);
state.indexTimer = undefined;
try {
await this.flushIndexTouch(session);
} catch (e) {
logWarn(`[AgentMode] drain session-index update failed for ${session.internalId}`, e);
}
}
if (!state?.timer) return;
window.clearTimeout(state.timer);
state.timer = undefined;
try {
await this.flushAutoSave(session);
} catch (e) {
logWarn(`[AgentMode] drain auto-save failed for ${session.internalId}`, e);
}
}
private detachAutoSave(internalId: string): void {
const state = this.sessionState.get(internalId);
if (!state) return;
if (state.timer) window.clearTimeout(state.timer);
if (state.indexTimer) window.clearTimeout(state.indexTimer);
state.unsub?.();
state.modelCacheUnsub?.();
state.attentionUnsub?.();
this.sessionState.delete(internalId);
}
/**
* Mirror this session's unified `BackendState` into the preloader cache
* so the picker reflects current state. Skips when the session has no
* usable state yet (during the `"starting"` window) — a naive sync
* would clobber the previous session's cached entries with an empty
* snapshot.
*/
private attachModelCacheSync(session: AgentSession): void {
const sync = (): void => {
const state = session.getState();
if (!state) return;
if (!state.model && !state.mode) return;
this.preloader.setCached(session.backendId, state);
};
sync();
const unsubscribe = session.subscribe({
onMessagesChanged: () => {},
onStatusChanged: () => {},
onModelChanged: () => sync(),
});
this.getSessionState(session.internalId).modelCacheUnsub = unsubscribe;
}
/**
* Watch this session's status transitions and flag `needsAttention` when
* it transitions out of `running` into a state that demands the user's
* eye (turn ended, errored, or paused for permission) while a *different*
* tab is active. The flag is cleared in `setActiveSession` when the user
* clicks back to this tab.
*/
private attachAttentionTracking(session: AgentSession): void {
let prev = session.getStatus();
const unsubscribe = session.subscribe({
onMessagesChanged: () => {},
onStatusChanged: (next) => {
const wasRunning = prev === "running";
const isRunning = next === "running";
prev = next;
void this.flushDeferredBackendRestartIfReady(session.backendId);
// Existing attention marking — unchanged semantics: a backgrounded
// session that leaves `running` for a status that demands the user's eye.
if (
wasRunning &&
ATTENTION_TRIGGER_STATUSES.has(next) &&
this.activeSessionId !== session.internalId
) {
session.markNeedsAttention();
}
// Re-render recent-list rows when this session's running membership
// flips, so the row's spinner appears/disappears in step.
if (wasRunning !== isRunning) this.notify();
},
});
this.getSessionState(session.internalId).attentionUnsub = unsubscribe;
}
/**
* Obtain a running backend process for `backendId`. Tries three sources
* in order:
* 1. An already-running proc owned by the manager (second-and-later
* sessions on the same backend).
* 2. An in-flight spawn — concurrent callers join the first one.
* 3. The preloader's warm probe (skips spawn + `initialize` handshake).
* 4. A fresh `descriptor.createBackendProcess()` + `start()`.
*
* The returned `warm` slot is non-null only on path 3 and carries the
* probe session id + state snapshot so callers that open a fresh chat
* can adopt the probe session as the user's session (avoids a second
* `newSession` round-trip). Callers that need a specific saved session
* (e.g. `tryResumeSessionFromHistory`) can ignore `warm` — the probe
* session simply sits unused on the proc.
*/
/**
* Register the session-domain prompters on a freshly-adopted backend. The
* permission prompter is required; the ask-question prompter is wired only
* when both the manager was configured with one and the backend advertises
* the optional `setAskUserQuestionPrompter` surface (Claude SDK today).
*/
private wirePrompters(proc: BackendProcess): void {
proc.setPermissionPrompter(this.opts.permissionPrompter);
if (this.opts.askUserQuestionPrompter) {
proc.setAskUserQuestionPrompter?.(this.opts.askUserQuestionPrompter);
}
// Lets a backend with its own permission gate (Claude SDK) hard-deny write/exec
// tools for read-only fan-out sub-sessions — see `permissionBridge`.
proc.setReadOnlySessionPredicate?.((sessionId) => this.isReadOnlyFanoutSession(sessionId));
// Inject the project-instruction resolver. Wiring here (the single
// warm-adopt + fresh choke point) covers both backend bring-up paths.
// Backends that discover instructions from cwd (codex/opencode) omit the
// setter and this is a no-op.
proc.setProjectProfileProvider?.((projectId) => this.getProjectProfile(projectId));
}
/**
* Map a scope id to the minimal {@link ProjectProfile} a backend needs to
* inject project instructions. Returns `undefined` for {@link GLOBAL_SCOPE}
* or an unknown project — keeping the `projects/` lookup here so
* `backends/` never imports the projects layer.
*/
private getProjectProfile(projectId: ProjectScopeId): ProjectProfile | undefined {
if (projectId === GLOBAL_SCOPE) return undefined;
const record = getCachedProjectRecordById(projectId);
if (!record) return undefined;
return {
id: record.project.id,
// Layer the built-in project policy ahead of the user's own instruction body, so Claude's
// `<project_instructions>` carries the same composed body codex/opencode get via the mirror.
systemPrompt: getComposedProjectInstructions(record),
};
}
private async ensureBackend(
backendId: BackendId,
descriptor: BackendDescriptor
): Promise<{ proc: BackendProcess; warm: WarmBackend | null }> {
const existing = this.backends.get(backendId);
if (existing && existing.isRunning()) return { proc: existing, warm: null };
const inflight = this.starting.get(backendId);
if (inflight) return { proc: await inflight, warm: null };
const warm = this.preloader.takeWarm(backendId);
if (warm) {
// Probe subprocess is already started + initialize-handshaken —
// wire it into the manager without paying either cost again.
this.wirePrompters(warm.proc);
this.installBackendExitHandler(backendId, warm.proc, descriptor);
this.backends.set(backendId, warm.proc);
return { proc: warm.proc, warm };
}
const proc = descriptor.createBackendProcess({
plugin: this.plugin,
app: this.app,
clientVersion: this.plugin.manifest.version,
descriptor,
});
const startPromise = (async () => {
// ACP backends declare `start()` to spawn the subprocess and run the
// initialize handshake. In-process adapters (Claude SDK) omit it.
if (proc.start) await proc.start();
this.wirePrompters(proc);
this.installBackendExitHandler(backendId, proc, descriptor);
this.backends.set(backendId, proc);
return proc;
})();
this.starting.set(backendId, startPromise);
try {
return { proc: await startPromise, warm: null };
} finally {
this.starting.delete(backendId);
}
}
/**
* Wire the "backend died unexpectedly" cleanup for `proc`: drop every
* session bound to `backendId`, surface a `lastError`, and notify. Same
* shape for both warm-adopted and freshly-spawned procs.
*/
private installBackendExitHandler(
backendId: BackendId,
proc: BackendProcess,
descriptor: BackendDescriptor
): void {
proc.onExit(() => {
// Backend died unexpectedly. Sessions belonging to *this* backend
// are now unusable (their backend session ids are dead) — but other
// backends keep running. Preserving message history across crashes
// is M5.
if (this.backends.get(backendId) === proc) this.backends.delete(backendId);
const dead = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
if (dead.length === 0) return;
for (const s of dead) {
this.detachAutoSave(s.internalId);
this.sessions.delete(s.internalId);
this.chatUIStates.delete(s.internalId);
this.landingCaptureSignatures.delete(s.internalId);
this.detachedFromTabIds.delete(s.internalId);
// Drop the dead session from its scope's MRU so a later enter can't
// resurrect a dead id.
if (this.lastActiveByScope.get(s.projectId) === s.internalId) {
this.lastActiveByScope.delete(s.projectId);
}
s.cancel().catch(() => {});
s.dispose().catch(() => {});
}
if (this.activeSessionId && !this.sessions.has(this.activeSessionId)) {
// Repoint at a STRIP-VISIBLE survivor only. A session detached on a prior
// project re-entry was deliberately parked to history; crash recovery must
// not silently resurrect it into the active tab. If none is visible, leave
// active null — the crash pill shows and the router (which bails on
// lastError) won't auto-respawn behind the user.
let next: AgentSession | undefined;
for (const s of this.sessions.values()) {
if (!this.detachedFromTabIds.has(s.internalId)) {
next = s;
break;
}
}
this.activeSessionId = next?.internalId ?? null;
// Crash repointing can cross scopes; keep `active.projectId ===
// activeProjectId` rather than leaving a stale scope behind.
if (next) this.activeProjectId = next.projectId;
}
// Surface the crash so the empty-state pill shows it and the
// router's auto-spawn effect (which bails on lastError) doesn't
// immediately respawn behind the user's back. The next explicit
// create call clears it.
this.lastError = `${descriptor.displayName} backend exited unexpectedly.`;
this.notify();
});
}
/** Whether any session for `backendId` is not safe to dispose yet. */
private hasBusySession(backendId: BackendId): boolean {
return Array.from(this.sessions.values()).some((session) => {
if (session.backendId !== backendId) return false;
const status = session.getStatus();
return status === "starting" || status === "running" || status === "awaiting_permission";
});
}
/** Execute a pending backend restart once every session for that backend is idle. */
private async flushDeferredBackendRestartIfReady(backendId: BackendId): Promise<void> {
const reason = this.pendingBackendRestarts.get(backendId);
if (!reason) return;
if (this.hasBusySession(backendId)) return;
this.pendingBackendRestarts.delete(backendId);
try {
await this.restartBackendNow(backendId, reason);
} catch (err) {
this.lastError = err2String(err);
logError(`[AgentMode] deferred ${backendId} backend restart failed`, err);
this.notify();
}
}
/** Immediately tear down `backendId` and replace the active affected tab. */
private async restartBackendNow(backendId: BackendId, reason: string): Promise<void> {
// A restart is already running for this backend. Stash the reason so the
// tail of the in-flight restart can re-run with the latest settings —
// otherwise rapid-fire emits (e.g. one BYOK save touching many models)
// would be silently dropped and the running backend would keep stale
// config until something else triggered another restart.
if (this.restartingBackends.has(backendId)) {
const prev = this.pendingBackendRestarts.get(backendId);
this.pendingBackendRestarts.set(backendId, prev ? `${prev}; ${reason}` : reason);
return;
}
const proc = this.backends.get(backendId);
if (!proc) return;
this.restartingBackends.add(backendId);
logInfo(`[AgentMode] restarting ${backendId} backend: ${reason}`);
try {
const affected = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
// Don't respawn a replacement for a backend that is no longer installed
// (e.g. its custom path was just cleared) — the spawn would fail. The
// affected sessions are still closed; the tab falls back to its
// needs-setup state. `restartBackendNow` is otherwise only reached for an
// installed backend, so this is a no-op for the normal restart path.
const replacedSession = affected.find((s) => s.internalId === this.activeSessionId);
const shouldCreateReplacement =
this.isBackendInstalled(backendId) && affected.length > 0 && replacedSession !== undefined;
// The replacement must inherit the REPLACED session's scope, not the
// current active scope — captured before the close loop repoints `active`.
const replacementProjectId = replacedSession?.projectId ?? this.activeProjectId;
for (const session of affected) {
await this.closeSession(session.internalId);
}
await proc.shutdown();
if (this.backends.get(backendId) === proc) {
this.backends.delete(backendId);
}
this.preloader.clearCached(backendId);
new Notice(`${this.resolveDescriptor(backendId).displayName} refreshed.`);
if (!this.disposed && this.isBackendInstalled(backendId)) {
// Spawn config (enabled models, keys, prompt, skills) is rebuilt on
// every restart, and the picker's catalog *and* per-model effort
// catalog both derive from it. A live session mirrors catalog state via
// attachModelCacheSync but NOT the effort catalog, so always re-probe —
// the prefetch repopulates effort for every enabled model. When a tab
// on this backend is active, await the probe and let the replacement
// adopt its warm proc: one spawn, and the per-model switch flicker
// stays on the throwaway probe session instead of the user's.
const probe = this.preloader.preload(backendId);
this.registerPreload(backendId, probe);
if (shouldCreateReplacement && !this.disposed) {
await probe;
// The replacement inherits the REPLACED session's scope (captured
// above as `replacementProjectId`), not the current active scope.
await this.createSession(backendId, replacementProjectId);
}
}
this.notify();
} finally {
this.restartingBackends.delete(backendId);
}
// Drain any restart requests that landed while we were running. Clear the
// entry BEFORE re-invoking so the recursion can't loop forever — a fresh
// request landing during the next restart will repopulate it.
const queued = this.pendingBackendRestarts.get(backendId);
if (queued !== undefined && !this.disposed) {
this.pendingBackendRestarts.delete(backendId);
if (this.hasBusySession(backendId)) {
// A session went busy between requests; fall back to the deferral
// path so we don't tear down mid-turn.
this.pendingBackendRestarts.set(backendId, queued);
return;
}
try {
await this.restartBackendNow(backendId, queued);
} catch (err) {
this.lastError = err2String(err);
logError(`[AgentMode] queued ${backendId} backend restart failed`, err);
this.notify();
}
}
}
}