diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 831894b7..d46ba45e 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -137,26 +137,27 @@ The `initialize` response of an AIR client carries the agent side of the extensi } ``` -The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename` and `sessionListSubscribe`, only for a client that declared `sessionIndex`. -An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename` and `sessionListSubscribe`. +The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`. +An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`. The response to a client that is not AIR has no `_meta.jetbrains` key. ### Capabilities -| Capability | Advertised | What the adapter does when the client declares it | Section | -| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | -| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) | -| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) | -| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) | -| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) | -| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) | -| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) | -| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) | -| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) | -| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) | -| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) | -| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) | -| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) | +| Capability | Advertised | What the adapter does when the client declares it | Section | +| ------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) | +| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) | +| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) | +| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) | +| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) | +| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) | +| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) | +| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) | +| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) | +| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) | +| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) | +| `sessionWorktrees` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that rows carry `agentWorktree` for a worktree Claude Code made. | [List response](#list-response) | +| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) | The adapter ignores `planContentDelta`. Claude does not stream a plan, so the adapter never sends `contentDelta` (see [Plan file](#plan-file)). @@ -1222,8 +1223,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`. Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2. -Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`) and `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`). -The client does not declare these three; declaring them without `sessionIndex` enables nothing. +Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)). +The client does not declare these four; declaring them without `sessionIndex` enables nothing. Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise. A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`. It gets no new notification, and the adapter starts no watcher for it. @@ -1291,6 +1292,7 @@ Each row carries `_meta.jetbrains.air` with flat row fields of the RFDs. `archiv "lastPromptAt": "2026-10-06T09:57:40.000Z", "model": "claude-opus-5-5", "forkedFrom": "8f0c1d2e-0000-4000-8000-000000000000", + "agentWorktree": true, "state": "idle", "lastTurnEndedAt": "2026-10-06T09:58:10.000Z", "cost": { "amount": 1.23, "currency": "USD" } @@ -1300,6 +1302,14 @@ Each row carries `_meta.jetbrains.air` with flat row fields of the RFDs. `archiv - `lastPromptAt` is the time of the last real user prompt in the transcript tail: prompt text (the predicate of the SDK title extractor) or an image or a document, but not a tool result, a meta or compact summary record, a slash command or an interrupt. When a long answer or tool output follows the last prompt, the tail window grows (256 KB, 1 MB, 4 MB) to find it; it is omitted only when the last 4 MB hold no prompt. - `model` is the model of the last assistant message in the tail; messages the CLI makes up (``) do not count. - `forkedFrom` is the parent session id that the SDK `forkSession` and the CLI fork write on every copied record (`forkedFrom.sessionId`). Sessions started any other way have none. +- `agentWorktree` is `true` when the session runs in a linked git worktree that Claude Code made for it (`claude --worktree`, or the worktree tools), and omitted otherwise, never `false`. The transcript rule is the one of AIR's native Claude provider; the adapter adds the git check of the row's `cwd`: + - the transcript holds `worktree-state` records, `{"type":"worktree-state","worktreeSession":{originalCwd, worktreePath, worktreeName, worktreeBranch, originalBranch, originalHeadCommit, sessionId, hookBased?, enteredExisting?}}`, which the CLI writes when the session enters or leaves a worktree; + - the last line that parses as a `worktree-state` record decides, whatever it holds: the session is marked only when its `worktreeSession` is an object without `enteredExisting: true` (`enteredExisting` means a worktree that existed before, so not made for the session). Any other `worktreeSession` (`null`, which the CLI writes when the session leaves the worktree, a missing key, or a value that is no object) means not marked, and an older record does not count then. Only lines that are not JSON, such as the cut first line of a window, are skipped; + - the row's `cwd` must lie in a linked worktree: the adapter reads the git files, without running git. It takes the nearest `.git` above `cwd`, follows a `.git` file to its git dir, reads `commondir`, and requires the git dir to be `/worktrees/`. This is checked on every `session/list` and on every row a subscription reads, not cached. The main checkout, a submodule and a directory outside a repository never get the field, and neither does a worktree the user made (it has no record). The adapter does not use `environment.isWorktree` or `git worktree list`; + - `hookBased` is not read: a worktree a `WorktreeCreate` hook made is marked only when its directory is a linked git worktree as above, so a hook that makes a plain directory or a checkout of another VCS gives no field. The repository is the nearest one to `cwd`: a session in a repository nested in a linked worktree is not marked, and one in a linked worktree of a nested repository is; + - the record is read from the 64 KB tail, else from the 64 KB head, and from the wider tail when the window grows to find the last prompt (see `lastPromptAt`). `claude --worktree` writes it at the start of the session, and the CLI writes it again with the other session metadata at the end of the transcript (on exit and as the transcript grows), so one of these windows almost always holds the latest one. A session that entered or left a worktree in the middle of a long transcript, with no metadata written since, keeps its old value until the CLI writes the metadata again; + - a session that made a worktree mid-session (EnterWorktree) is marked too: the CLI moves its transcript to the worktree (a `relocated` record), so the row's `cwd` is the worktree path, not the directory the session started in. The row's `cwd` is always the session's own directory, so the worktree path; + - a subscription does not send a row again when only git metadata changes, such as a manual `git worktree remove` or `git worktree move`: the field is checked again only when the transcript changes, or when the rescan finds that the worktrees of the scope changed and resolves the rows again. `session/list` checks it on every read. - `state` is `running`, `idle`, `requires_action`, or `error`, never `unknown`. `error` means the session is not running and its last turn ended with an error; a user cancel or interrupt is no error, and a new turn clears it: - a session this connection runs: the SDK `session_state_changed` state; `error` instead of `idle` when its last turn failed (an error result, `is_error` or an `error_*` subtype, that was not a cancel). A session whose query failed here (not a cancel) is reported as any other session, but `error` until its transcript shows a prompt or a turn end after the failure, unless a busy or waiting interactive CLI holds it; - a session no live Claude Code process holds: `idle`, or `error` when its last turn ended with an API error (an assistant record with `isApiErrorMessage: true`, also when the CLI's turn-end records follow it, with no prompt after it); @@ -1395,7 +1405,7 @@ From then on the agent sends `_session/list/changes` notifications for the sessi - `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored. - The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list. -- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included. +- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included. - After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change. - `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id. - Archive and unarchive are row changes (`archived`): their title records are a transcript change. The agent applies no archive filter: the client filters. @@ -1442,21 +1452,22 @@ It is idempotent, and an unknown id also returns `{}`. The session index is the `_meta` form of two ACP RFDs: "Session list extensions: limit, order, row metadata and change hints" and "Session Archive and Unarchive" (#2161). Names and semantics follow them; the transport differs, and so do the `archived` list filter and the archive of a running session (see [Relation to ACP RFD #2161](#relation-to-acp-rfd-2161)): -| Extension | RFD | -| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` | -| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) | -| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) | -| `_meta.jetbrains.air.list.limit` | `session/list` `limit` | -| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` | -| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there | -| row `_meta.jetbrains.air.lastPromptAt`, `model`, `forkedFrom`, `state`, `lastTurnEndedAt`, `cost` | `SessionInfo` fields of the same names; the RFD `state` has no `error` | -| row `_meta.jetbrains.air.archived` | `SessionInfo.archived` (#2161) | -| `session_info_update` `_meta.jetbrains.air.archived` | `SessionInfoUpdate.archived` (#2161) | -| capability `sessionListSubscribe`, `_session/list/subscribe`, `_session/list/unsubscribe` | none | -| `_session/list/changes { subscriptionId, sessions, removed }` | none | -| `_session/archive`, `_session/unarchive` | `session/archive`, `session/unarchive` (#2161) | -| `_session/rename` | `session/set_title` (#1987) | +| Extension | RFD | +| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` | +| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) | +| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) | +| `_meta.jetbrains.air.list.limit` | `session/list` `limit` | +| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` | +| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there | +| row `_meta.jetbrains.air.lastPromptAt`, `model`, `forkedFrom`, `state`, `lastTurnEndedAt`, `cost` | `SessionInfo` fields of the same names; the RFD `state` has no `error` | +| row `_meta.jetbrains.air.archived` | `SessionInfo.archived` (#2161) | +| `session_info_update` `_meta.jetbrains.air.archived` | `SessionInfoUpdate.archived` (#2161) | +| capability `sessionWorktrees`, row `_meta.jetbrains.air.agentWorktree` | none yet: proposed in the session list extensions RFD (agentclientprotocol/agent-client-protocol#2323) as `SessionInfo.agentWorktree`, with the capability named `agentWorktrees` there, not `sessionWorktrees` | +| capability `sessionListSubscribe`, `_session/list/subscribe`, `_session/list/unsubscribe` | none | +| `_session/list/changes { subscriptionId, sessions, removed }` | none | +| `_session/archive`, `_session/unarchive` | `session/archive`, `session/unarchive` (#2161) | +| `_session/rename` | `session/set_title` (#1987) | ### Relation to ACP RFD #2161 diff --git a/src/air-extension.ts b/src/air-extension.ts index c4bc7418..ef8c741f 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -34,6 +34,12 @@ export const AIR_SESSION_RENAME_CAPABILITY = "sessionRename"; * {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it. */ export const AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY = "sessionListSubscribe"; +/** + * Session index rows carry `agentWorktree: true` when the session runs in a + * linked git worktree that Claude Code made for it. Advertised exactly when + * {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it. + */ +export const AIR_SESSION_WORKTREES_CAPABILITY = "sessionWorktrees"; /** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */ export const AIR_COMMAND_TITLE_KEY = "commandTitle"; diff --git a/src/session-index/connection.ts b/src/session-index/connection.ts index ee25b8e8..a641a920 100644 --- a/src/session-index/connection.ts +++ b/src/session-index/connection.ts @@ -23,6 +23,7 @@ import { AIR_SESSION_INDEX_CAPABILITY, AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY, AIR_SESSION_RENAME_CAPABILITY, + AIR_SESSION_WORKTREES_CAPABILITY, clientSupportsAirCapability, withAirMeta, } from "../air-extension.js"; @@ -143,8 +144,8 @@ export class SessionIndexConnection { } /** The AIR capabilities the agent advertises for the index: the index - * itself, archive, rename and the list subscription, all only to a client - * that declared `sessionIndex`. */ + * itself, archive, rename, the list subscription and the `agentWorktree` + * row field, all only to a client that declared `sessionIndex`. */ capabilities(): string[] { return this.service ? [ @@ -152,6 +153,7 @@ export class SessionIndexConnection { AIR_SESSION_ARCHIVE_CAPABILITY, AIR_SESSION_RENAME_CAPABILITY, AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY, + AIR_SESSION_WORKTREES_CAPABILITY, ] : []; } diff --git a/src/session-index/list-subscriptions.ts b/src/session-index/list-subscriptions.ts index 7240daa1..a95cdd73 100644 --- a/src/session-index/list-subscriptions.ts +++ b/src/session-index/list-subscriptions.ts @@ -1199,7 +1199,10 @@ export class ListSubscriptions { if (!watch.rows.has(id)) candidates.push(...(priorFiles.get(id) ?? [])); } if (candidates.length === 0) return new Set(); - const { rows, unknown } = await this.deps.index.cachedRowsOf(paths, candidates); + // Only the ids count: no worktree check. + const { rows, unknown } = await this.deps.index.cachedRowsOf(paths, candidates, { + mark: false, + }); for (const row of rows) unknown.add(row.sessionId); return unknown; } diff --git a/src/session-index/session-index.ts b/src/session-index/session-index.ts index 83b55762..8add7518 100644 --- a/src/session-index/session-index.ts +++ b/src/session-index/session-index.ts @@ -53,7 +53,7 @@ import { type TranscriptFacts, } from "./transcript-scan.js"; import { DirListings, statFiles } from "./dir-listing.js"; -import { worktreeCounterparts } from "./worktrees.js"; +import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js"; export const DEFAULT_LIST_LIMIT = 50; export const MAX_LIST_LIMIT = 200; @@ -125,6 +125,10 @@ export type IndexRow = { facts: TranscriptFacts; mtimeMs: number; archived: boolean; + /** The session runs in a linked git worktree that Claude Code made for + * it: its transcript says so (`facts.agentWorktree`) and `cwd` lies in + * a linked worktree. See {@link markAgentWorktrees}. */ + agentWorktree?: true; }; /** A keyset position: the order key and the session id of the last row. */ @@ -249,6 +253,22 @@ function toRow({ candidate, metadata }: Resolved, cwd: string): IndexRow { }; } +/** + * Sets `agentWorktree` on the rows whose transcript says Claude Code made + * their worktree and whose cwd lies in a linked worktree now. Only those rows + * are checked; the check is not cached, so a removed worktree shows at once. + */ +async function markAgentWorktrees(rows: IndexRow[]): Promise { + await Promise.all( + rows.map(async (row) => { + if (row.facts.agentWorktree === true && (await isInLinkedWorktree(row.cwd))) { + row.agentWorktree = true; + } + }), + ); + return rows; +} + function isAfter(row: { orderAtMs: number; sessionId: string }, cursor: ListCursor): boolean { return compareRows(row, cursor) > 0; } @@ -686,11 +706,14 @@ export class SessionIndex { * alone gives, read from it at once, and the ids of the sessions it gives * no answer for: a transcript of it has nothing cached at its stat, no cwd * known, or is continued in another session (whether the successor hides - * it depends on the successor now). Reads no transcript. + * it depends on the successor now). Reads no transcript. The rows carry + * `agentWorktree` ({@link markAgentWorktrees}, which reads git files) + * unless `mark` is false, for a caller that uses only their ids. */ async cachedRowsOf( paths: readonly string[], candidates: readonly TranscriptCandidate[], + { mark = true }: { mark?: boolean } = {}, ): Promise<{ rows: IndexRow[]; unknown: Set }> { const unknown = new Set(); const resolved: { item: Resolved; cwd: string }[] = []; @@ -721,7 +744,8 @@ export class SessionIndex { if (previous && previous.mtimeMs >= item.candidate.mtimeMs) continue; newest.set(id, { row: toRow(item, cwd), mtimeMs: item.candidate.mtimeMs }); } - return { rows: [...newest.values()].map(({ row }) => row), unknown }; + const rows = [...newest.values()].map(({ row }) => row); + return { rows: mark ? await markAgentWorktrees(rows) : rows, unknown }; } /** @@ -831,7 +855,7 @@ export class SessionIndex { ); await settlePending(); } - return rows(); + return markAgentWorktrees(rows()); } /** Whether the cached metadata of `candidate`, still current, places it diff --git a/src/session-index/session-info.ts b/src/session-index/session-info.ts index ede3eda2..e5af5704 100644 --- a/src/session-index/session-info.ts +++ b/src/session-index/session-info.ts @@ -38,6 +38,7 @@ export function sessionInfoOf( lastPromptAt: iso(facts.lastPromptAt), model: facts.model, forkedFrom: facts.forkedFrom, + agentWorktree: row.agentWorktree, state: activity?.state, lastTurnEndedAt: activity?.lastTurnEndedAt, cost: cost === undefined ? undefined : { amount: cost, currency: "USD" }, diff --git a/src/session-index/transcript-scan.ts b/src/session-index/transcript-scan.ts index fe6d795a..44f335ec 100644 --- a/src/session-index/transcript-scan.ts +++ b/src/session-index/transcript-scan.ts @@ -51,6 +51,11 @@ export type TranscriptFacts = { /** The session this one was forked from: the `forkedFrom.sessionId` that * the SDK and CLI fork write on every copied record. */ forkedFrom?: string; + /** What the last `worktree-state` record says: true when its + * `worktreeSession` is set and not `enteredExisting` (Claude Code made the + * worktree and the session is in it), false when it says otherwise. + * Undefined when no such record was read. See {@link agentWorktreeOf}. */ + agentWorktree?: boolean; /** Whether the head or the tail holds a user or an assistant message. A * transcript without one is a metadata-only stub. */ hasMessages: boolean; @@ -67,7 +72,7 @@ export type HeadTail = { }; /** Facts that an earlier scan may have found before the tail window. */ -const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt"] as const; +const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt", "agentWorktree"] as const; /** What an earlier scan of the same file found, at `size`. */ export type PreviousScan = { size: number } & Pick< @@ -152,6 +157,12 @@ export async function scanTranscriptFile( } } } + // No worktree record in the tail (or before it, by an earlier scan): the + // one `claude --worktree` writes at the start may still be in the head. + if (facts.agentWorktree === undefined) { + const agentWorktree = agentWorktreeOf(read.head); + if (agentWorktree !== undefined) facts.agentWorktree = agentWorktree; + } if (facts.lastPromptAt !== undefined || size <= CHUNK_SIZE) { facts.promptSearched = true; } else if (grownWithinTail && (previous.lastPromptAt !== undefined || previous.promptSearched)) { @@ -199,6 +210,11 @@ export async function scanTranscriptFile( ...wider, ...(facts.model !== undefined && { model: facts.model }), ...(facts.tailCwd !== undefined && { tailCwd: facts.tailCwd }), + // The wider tail holds the narrow one, so a record it has is the + // latest; else the narrow scan's (inherited or from the head) stands. + ...((wider.agentWorktree ?? facts.agentWorktree) !== undefined && { + agentWorktree: wider.agentWorktree ?? facts.agentWorktree, + }), // A turn end the wider tail lacks may lie before it: the // narrow scan inherited it from an earlier scan of this file. lastTurnEndedAt: wider.lastTurnEndedAt ?? facts.lastTurnEndedAt, @@ -238,6 +254,43 @@ export function relocatedCwd(tail: string): string | undefined { return undefined; } +const WORKTREE_STATE_MARKER = '"worktree-state"'; + +/** + * Whether the last `worktree-state` record of `text` says the session is in + * a worktree that Claude Code made for it, as AIR's native Claude provider + * reads it: a `worktreeSession` that is an object without + * `enteredExisting: true`. The last line that parses as a `worktree-state` + * record decides; any other `worktreeSession` (`null`, which the CLI writes + * when the session leaves the worktree, missing, or no object) means false. + * Only lines that are not JSON (such as the cut first line of a window) are + * skipped. Undefined when `text` holds no such record. + * + * The CLI writes the record when it enters or leaves a worktree (for + * `claude --worktree`, at the start of the session, so in the head), and + * writes it again with the other session metadata at the end of the + * transcript (on exit and as the transcript grows), so the tail usually + * holds the latest one. Only the head and the tail are read: a session that + * entered a worktree midway through a long transcript, with no metadata + * written after it yet, is not seen until the CLI writes the metadata again. + */ +export function agentWorktreeOf(text: string): boolean | undefined { + if (!text.includes(WORKTREE_STATE_MARKER)) return undefined; + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]!; + if (!line.includes(WORKTREE_STATE_MARKER)) continue; + const entry = parseLine(line); + // Only a line that is no JSON record is skipped: the latest record that + // parses decides, even when its `worktreeSession` is missing or no object. + if (entry?.type !== "worktree-state") continue; + const session = entry.worktreeSession; + if (!session || typeof session !== "object" || Array.isArray(session)) return false; + return (session as { enteredExisting?: unknown }).enteredExisting !== true; + } + return undefined; +} + /** The cwd a transcript belongs to, as the SDK reads it: the last * `relocated` record of the tail, else the first cwd of the head. */ export function transcriptProjectCwd({ head, tail }: HeadTail): string | undefined { @@ -344,6 +397,10 @@ export function scanTranscript( if (headCwd !== undefined) facts.headCwd = decodeJsonString(headCwd); const forkedFrom = FORKED_FROM_PATTERN.exec(head)?.[1]; if (forkedFrom !== undefined) facts.forkedFrom = forkedFrom; + // The tail only: scanTranscriptFile falls back to the head after it + // inherits what an earlier scan found before the tail. + const agentWorktree = agentWorktreeOf(tail); + if (agentWorktree !== undefined) facts.agentWorktree = agentWorktree; const lines = tail.split("\n"); let turnEndFound = false; diff --git a/src/session-index/worktrees.ts b/src/session-index/worktrees.ts index d71b4c8a..4a994d58 100644 --- a/src/session-index/worktrees.ts +++ b/src/session-index/worktrees.ts @@ -115,6 +115,18 @@ export async function worktreeCounterparts(cwd: string): Promise { return [...new Set(existing.filter((value): value is string => value !== undefined))]; } +/** + * Whether `cwd` lies in a linked worktree: its git dir is + * `/worktrees/`. False in the main worktree, in a + * submodule and outside a repository. + */ +export async function isInLinkedWorktree(cwd: string): Promise { + const gitDir = await findGitDir(cwd); + if (!gitDir) return false; + const commonDir = await commonDirOf(gitDir); + return path.resolve(path.dirname(gitDir)) === path.resolve(commonDir, "worktrees"); +} + /** * Every existing worktree of the repository that contains `cwd`: the main * worktree and the linked ones. Empty outside a repository. diff --git a/src/tests/session-index-units.test.ts b/src/tests/session-index-units.test.ts index addd13e2..cc9ccfc0 100644 --- a/src/tests/session-index-units.test.ts +++ b/src/tests/session-index-units.test.ts @@ -25,6 +25,7 @@ import { visibleTitle, } from "../session-index/archive-title.js"; import { + agentWorktreeOf, continuedInSessionId, readHeadTail, scanTranscript, @@ -35,7 +36,8 @@ import { } from "../session-index/transcript-scan.js"; import { firstPrompt } from "../session-index/first-prompt.js"; import { DirListings, statFiles } from "../session-index/dir-listing.js"; -import { SessionIndex } from "../session-index/session-index.js"; +import { SessionIndex, type IndexRow } from "../session-index/session-index.js"; +import { changeSignature, sessionInfoOf } from "../session-index/session-info.js"; const SESSION = "11111111-1111-4111-8111-111111111111"; const lines = (...entries: object[]) => entries.map((entry) => JSON.stringify(entry)).join("\n"); @@ -211,6 +213,58 @@ describe("transcript scan", () => { }); }); +describe("worktree-state records", () => { + const made = { worktreePath: "/wt", sessionId: SESSION }; + const record = (worktreeSession: unknown) => + JSON.stringify({ type: "worktree-state", worktreeSession, sessionId: SESSION }); + const marked = record(made); + + it("reads the last record: an object without enteredExisting marks", () => { + expect(agentWorktreeOf(lines(user("hi", "2026-01-01T00:00:00Z")))).toBeUndefined(); + expect(agentWorktreeOf(marked)).toBe(true); + expect(agentWorktreeOf(record({ ...made, enteredExisting: false }))).toBe(true); + expect(agentWorktreeOf(record({ ...made, enteredExisting: true }))).toBe(false); + expect(agentWorktreeOf(`${marked}\n${record(null)}`)).toBe(false); + expect(agentWorktreeOf(`${record(null)}\n${marked}`)).toBe(true); + }); + + it("decides on a malformed latest record, and skips only lines that are no JSON", () => { + const missing = JSON.stringify({ type: "worktree-state", sessionId: SESSION }); + for (const latest of [missing, record(null), record("wt"), record(1), record([made])]) { + expect(agentWorktreeOf(`${marked}\n${latest}`)).toBe(false); + } + // A cut line (the first of a window) or a broken one is no record. + const cut = record({ ...made, enteredExisting: true }).slice(8); + expect(cut.startsWith('"worktree-state"')).toBe(true); + expect(agentWorktreeOf(`${marked}\n${cut}`)).toBe(true); + expect(agentWorktreeOf(`${marked}\n{"type":"worktree-state",`)).toBe(true); + }); +}); + +describe("change signature", () => { + it("is the same for an unmarked row as without the agentWorktree field", () => { + const row: IndexRow = { + sessionId: SESSION, + cwd: "/repo", + title: "Fix it", + updatedAtMs: Date.parse("2026-01-01T00:00:00Z"), + orderAtMs: Date.parse("2026-01-01T00:00:00Z"), + facts: { hasMessages: true, model: "claude-model-x", agentWorktree: false }, + mtimeMs: Date.parse("2026-01-01T00:00:00Z"), + archived: false, + }; + const now = Date.parse("2026-01-01T01:00:00Z"); + const info = sessionInfoOf(row, undefined, undefined, now); + expect((info._meta as any).jetbrains.air).not.toHaveProperty("agentWorktree"); + expect(changeSignature(info)).toBe( + JSON.stringify([SESSION, "/repo", "Fix it", info._meta ?? null]), + ); + const markedInfo = sessionInfoOf({ ...row, agentWorktree: true }, undefined, undefined, now); + expect((markedInfo._meta as any).jetbrains.air.agentWorktree).toBe(true); + expect(changeSignature(markedInfo)).not.toBe(changeSignature(info)); + }); +}); + describe("activity", () => { const now = Date.parse("2026-01-01T01:00:00Z"); const facts = (overrides: Partial = {}): TranscriptFacts => ({ diff --git a/src/tests/session-index.test.ts b/src/tests/session-index.test.ts index a255fa7d..75dc14e5 100644 --- a/src/tests/session-index.test.ts +++ b/src/tests/session-index.test.ts @@ -180,6 +180,43 @@ const titleRecords = (sessionId: string, title: string) => [ { type: "agent-name", agentName: title, sessionId }, ]; +/** A `worktree-state` record, as the CLI writes it when a session enters + * (`worktree` set) or leaves (null) a worktree. */ +const worktreeState = ( + sessionId: string, + worktree: { path: string; enteredExisting?: boolean } | null, +) => ({ + type: "worktree-state", + worktreeSession: worktree && { + originalCwd: "/repo", + worktreePath: worktree.path, + worktreeName: path.basename(worktree.path), + worktreeBranch: `worktree-${path.basename(worktree.path)}`, + originalBranch: "main", + originalHeadCommit: "0123456789abcdef0123456789abcdef01234567", + sessionId, + ...(worktree.enteredExisting !== undefined && { enteredExisting: worktree.enteredExisting }), + }, + sessionId, +}); + +/** A repository at `repo` with linked worktrees at `linked`, as git lays + * them out. */ +async function linkWorktrees(repo: string, ...linked: string[]): Promise { + await fs.mkdir(path.join(repo, ".git"), { recursive: true }); + for (const worktree of linked) { + const meta = path.join(repo, ".git", "worktrees", path.basename(worktree)); + await fs.mkdir(meta, { recursive: true }); + await fs.mkdir(worktree, { recursive: true }); + await fs.writeFile(path.join(meta, "gitdir"), `${path.join(worktree, ".git")}\n`); + await fs.writeFile(path.join(meta, "commondir"), "../..\n"); + await fs.writeFile(path.join(worktree, ".git"), `gitdir: ${meta}\n`); + } +} + +const agentWorktreeOfRow = (row: SessionInfo | undefined) => + (row?._meta as any)?.jetbrains?.air?.agentWorktree; + /** The last `count` records of a transcript. */ async function lastRecords(file: string, count = 2): Promise { const lines = (await fs.readFile(file, "utf8")).trim().split("\n"); @@ -223,6 +260,7 @@ describe("sessionIndex negotiation", () => { "sessionArchive", "sessionRename", "sessionListSubscribe", + "sessionWorktrees", ]; const baseline = [ "sessionFailure", @@ -516,6 +554,116 @@ describe("session/list of a sessionIndex client", () => { ).rejects.toMatchObject({ code: -32602 }); }); + it("marks the rows of sessions in a worktree Claude Code made (agentWorktree)", async () => { + const repo = path.join(workspace, "repo"); + const made = path.join(workspace, "made"); + const entered = path.join(workspace, "entered"); + const user = path.join(workspace, "user"); + const left = path.join(workspace, "left"); + await linkWorktrees(repo, made, entered, user, left); + const madeId = randomUUID(); + const enteredId = randomUUID(); + const leftId = randomUUID(); + const mainId = randomUUID(); + const inMade = await writeTranscript({ + sessionId: madeId, + cwd: made, + lastMessageAt: base, + trailer: [worktreeState(madeId, { path: made })], + }); + const inEntered = await writeTranscript({ + sessionId: enteredId, + cwd: entered, + lastMessageAt: base - 1000, + trailer: [worktreeState(enteredId, { path: entered, enteredExisting: true })], + }); + // A worktree the user made: no record. + const inUser = await writeTranscript({ cwd: user, lastMessageAt: base - 2000 }); + // Left the worktree: the later null record wins. + const inLeft = await writeTranscript({ + sessionId: leftId, + cwd: left, + lastMessageAt: base - 3000, + trailer: [worktreeState(leftId, { path: left }), worktreeState(leftId, null)], + }); + // The main checkout, with a record that says otherwise: not in a linked + // worktree, so no field. + const inMain = await writeTranscript({ + sessionId: mainId, + cwd: repo, + lastMessageAt: base - 4000, + trailer: [worktreeState(mainId, { path: made })], + }); + const { agent } = await indexAgent(); + + const page = await agent.listSessions({ + cwd: repo, + _meta: listMeta({ includeWorktrees: true }), + }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd, agentWorktreeOfRow(s)])).toEqual([ + [inMade.id, made, true], + [inEntered.id, entered, undefined], + [inUser.id, user, undefined], + [inLeft.id, left, undefined], + [inMain.id, repo, undefined], + ]); + // The field is omitted, not false. + for (const row of page.sessions.slice(1)) { + expect((row._meta as any)?.jetbrains?.air ?? {}).not.toHaveProperty("agentWorktree"); + } + // The worktree's own list carries it too. + const own = await agent.listSessions({ cwd: made }); + expect(own.sessions.map((s) => [s.sessionId, agentWorktreeOfRow(s)])).toEqual([ + [inMade.id, true], + ]); + }); + + it("marks a session that made its worktree mid-session, and not after a malformed record", async () => { + const repo = path.join(workspace, "repo"); + const made = path.join(workspace, "made"); + const other = path.join(workspace, "other"); + await linkWorktrees(repo, made, other); + // Started in the main checkout, then EnterWorktree made a worktree: the + // CLI moved the transcript there, so the row's cwd is the worktree. + const movedId = randomUUID(); + const moved = await writeTranscript({ + sessionId: movedId, + cwd: repo, + dirName: encodeProjectPath(made), + lastMessageAt: base, + trailer: [ + worktreeState(movedId, { path: made }), + { type: "relocated", sessionId: movedId, relocatedCwd: made }, + ], + }); + // The latest record has no worktreeSession object: it decides, so the + // older marked one does not count. + const malformedId = randomUUID(); + const malformed = await writeTranscript({ + sessionId: malformedId, + cwd: other, + lastMessageAt: base - 1000, + trailer: [ + worktreeState(malformedId, { path: other }), + { type: "worktree-state", worktreeSession: "other", sessionId: malformedId }, + ], + }); + const { agent } = await indexAgent(); + + const page = await agent.listSessions({ + cwd: repo, + _meta: listMeta({ includeWorktrees: true }), + }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd, agentWorktreeOfRow(s)])).toEqual([ + [moved.id, made, true], + [malformed.id, other, undefined], + ]); + const own = await agent.listSessions({ cwd: made }); + expect(own.sessions.map((s) => [s.sessionId, s.cwd, agentWorktreeOfRow(s)])).toEqual([ + [moved.id, made, true], + ]); + }); + it("recovers the cwd of a session whose head has none", async () => { // A first prompt beyond 64 KB pushes the first cwd out of the head; the // tail cwd is a subdirectory of the project. @@ -1408,6 +1556,39 @@ describe("_session/list/subscribe", () => { await agent.dispose(); }); + it("marks the change rows of sessions in a worktree Claude Code made", async () => { + const repo = path.join(workspace, "repo"); + const made = path.join(workspace, "made"); + const user = path.join(workspace, "user"); + await linkWorktrees(repo, made, user); + // The worktrees' project directories exist before the subscription. + await writeTranscript({ cwd: made }); + await writeTranscript({ cwd: user }); + const { rowsOf, agent } = await subscribedAgent(repo); + const madeId = randomUUID(); + const inMade = await writeTranscript({ + sessionId: madeId, + cwd: made, + trailer: [worktreeState(madeId, { path: made })], + }); + const inUser = await writeTranscript({ cwd: user }); + expect(await waitFor(() => rowsOf(inMade.id).length > 0 && rowsOf(inUser.id).length > 0)).toBe( + true, + ); + expect(agentWorktreeOfRow(rowsOf(inMade.id).at(-1))).toBe(true); + expect(agentWorktreeOfRow(rowsOf(inUser.id).at(-1))).toBeUndefined(); + // The session leaves the worktree: the row changes and loses the field. + await fs.appendFile(inMade.file, JSON.stringify(worktreeState(madeId, null)) + "\n"); + expect( + await waitFor( + () => + rowsOf(inMade.id).length > 1 && + agentWorktreeOfRow(rowsOf(inMade.id).at(-1)) === undefined, + ), + ).toBe(true); + await agent.dispose(); + }); + /** A subscription manager over a real index whose first read of rows is slow. */ function slowFirstRows( delayMs: number, @@ -2598,6 +2779,63 @@ describe("tail growth", () => { expect((await scanTranscriptFile(file, size)).lastMessageAt).toBe(at); }); + it("reads the worktree record of the tail, else of an earlier scan, else of the head", async () => { + const id = randomUUID(); + const at = Date.parse("2026-07-01T00:00:00Z"); + const record = (worktree: { path: string } | null) => + JSON.stringify(worktreeState(id, worktree)); + // `claude --worktree` writes the record at the start; 200 KB of + // messages push it out of the tail. + const messages = Array.from({ length: 20 }, (_, i) => big(id, at + i, 10_000)).join("\n"); + const inHead = path.join(workspace, `${id}.jsonl`); + const headContent = `${record({ path: "/wt" })}\n${messages}\n`; + await fs.writeFile(inHead, headContent); + expect((await scanTranscriptFile(inHead, Buffer.byteLength(headContent))).agentWorktree).toBe( + true, + ); + // A later record in the tail wins over the head. + const nullRecord = `${record(null)}\n`; + await fs.appendFile(inHead, nullRecord); + expect( + (await scanTranscriptFile(inHead, Buffer.byteLength(headContent + nullRecord))).agentWorktree, + ).toBe(false); + // The record an earlier scan found stands when the file grew within the + // tail and the new tail lost it, not the older one of the head. + const grown = path.join(workspace, `${randomUUID()}.jsonl`); + const before = `${record({ path: "/wt" })}\n${messages}\n${record(null)}\n${big(id, at + 50, 10_000)}\n`; + await fs.writeFile(grown, before); + const first = await scanTranscriptFile(grown, Buffer.byteLength(before)); + expect(first.agentWorktree).toBe(false); + const appended = `${big(id, at + 100, 60_000)}\n`; + await fs.appendFile(grown, appended); + const second = await scanTranscriptFile( + grown, + Buffer.byteLength(before + appended), + undefined, + { + ...first, + size: Buffer.byteLength(before), + }, + ); + expect(second.agentWorktree).toBe(false); + }); + + it("takes the worktree record of a wider tail over the head's", async () => { + const id = randomUUID(); + const at = Date.parse("2026-07-01T00:00:00Z"); + const record = (worktree: { path: string } | null) => + JSON.stringify(worktreeState(id, worktree)); + const messages = (from: number, count: number) => + Array.from({ length: count }, (_, i) => big(id, at + from + i, 10_000)).join("\n"); + // No prompt, so the tail grows; the null record lies beyond the 64 KB + // tail and the 64 KB head, within the 256 KB window. + const file = path.join(workspace, `${id}.jsonl`); + const content = `${record({ path: "/wt" })}\n${messages(0, 10)}\n${record(null)}\n${messages(100, 10)}\n`; + await fs.writeFile(file, content); + const facts = await scanTranscriptFile(file, Buffer.byteLength(content)); + expect(facts.agentWorktree).toBe(false); + }); + it("does not search a transcript of the same size again", async () => { const id = randomUUID(); const file = path.join(workspace, `${id}.jsonl`);