feat: mark session list rows of worktrees Claude Code made (agentWorktree) (#1352)

## What

Marks `session/list` and `_session/list/changes` rows whose session runs
in a linked Git worktree that Claude Code itself created (`claude
--worktree`, the `EnterWorktree` tool), as
`_meta.jetbrains.air.agentWorktree: true`. A new agent capability
`sessionWorktrees`, advertised together with `sessionIndex`, tells the
client that rows carry the field, so a missing field means "not made by
the agent" rather than "unknown".

## Why

With `includeWorktrees`, a client gets sessions from every linked
worktree with nothing to tell them apart. A worktree Claude Code made
for a session belongs to the project the user started it from; a
worktree the user made by hand is a separate checkout. Only the agent
knows which is which.

## How

- The rule reads the transcript's `worktree-state` records: the latest
one wins; the session counts only when `worktreeSession` is non-null and
`enteredExisting` is not `true`. `environment.isWorktree` is not used,
since plain git detection is also true for user-made worktrees.
- The record is parsed in the existing head/tail transcript scan, so no
extra file reads. The row is marked only when its `cwd` is in a linked
worktree (`<common-dir>/worktrees/<name>`), never for the main checkout
or a submodule.
- The row's `cwd` stays the session's own directory. The field is `true`
or absent.

Proposed as `SessionInfo.agentWorktree` plus a `agentWorktrees`
capability marker in the session list extensions RFD:
agentclientprotocol/agent-client-protocol#2323. Docs:
`docs/air-extensions.md`.

## Tests

`src/tests/session-index.test.ts`: marked worktree, `enteredExisting`,
no record (user worktree), a later `null` record, main checkout,
`_session/list/changes` rows, capability negotiation, and scan windows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nikita-ashihmin
2026-10-15 12:15:52 +04:00
committed by GitHub
parent 0c8de1380f
commit 1a1f1a0e80
10 changed files with 451 additions and 43 deletions

View File

@@ -137,14 +137,14 @@ 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) |
@@ -156,6 +156,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key.
| `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`.
@@ -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 (`<synthetic>`) 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 `<git-common-dir>/worktrees/<name>`. 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.
@@ -1443,7 +1453,7 @@ The session index is the `_meta` form of two ACP RFDs: "Session list extensions:
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) |
@@ -1453,6 +1463,7 @@ Names and semantics follow them; the transport differs, and so do the `archived`
| 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) |

View File

@@ -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";

View File

@@ -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,
]
: [];
}

View File

@@ -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;
}

View File

@@ -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<IndexRow[]> {
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<string> }> {
const unknown = new Set<string>();
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

View File

@@ -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" },

View File

@@ -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;

View File

@@ -115,6 +115,18 @@ export async function worktreeCounterparts(cwd: string): Promise<string[]> {
return [...new Set(existing.filter((value): value is string => value !== undefined))];
}
/**
* Whether `cwd` lies in a linked worktree: its git dir is
* `<common-dir>/worktrees/<name>`. False in the main worktree, in a
* submodule and outside a repository.
*/
export async function isInLinkedWorktree(cwd: string): Promise<boolean> {
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.

View File

@@ -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> = {}): TranscriptFacts => ({

View File

@@ -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<void> {
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<unknown[]> {
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`);