diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.md b/.archgate/adrs/ARCH-004-no-barrel-files.md index db0fd184..75457d56 100644 --- a/.archgate/adrs/ARCH-004-no-barrel-files.md +++ b/.archgate/adrs/ARCH-004-no-barrel-files.md @@ -41,7 +41,7 @@ A **re-export** is any `export { X } from "./other-module"` or `export type { X Files named `index.ts` that contain actual logic are **not** barrel files and are permitted. Examples of permitted `index.ts` files: - `src/commands/adr/index.ts` — defines `registerAdrCommand()` with command group composition logic -- `src/commands/session-context/index.ts` — defines `registerSessionContextCommand()` with subcommand composition logic +- `src/commands/plugin/index.ts` — defines `registerPluginCommand()` with subcommand composition logic ## Do's and Don'ts diff --git a/.archgate/adrs/ARCH-014-prefer-bun-env.md b/.archgate/adrs/ARCH-014-prefer-bun-env.md index 9ee19da6..ca849f7a 100644 --- a/.archgate/adrs/ARCH-014-prefer-bun-env.md +++ b/.archgate/adrs/ARCH-014-prefer-bun-env.md @@ -50,11 +50,13 @@ All environment variable access in `src/` MUST use `Bun.env` instead of `process - **DO** use nullish coalescing for defaults: `Bun.env.NODE_ENV ?? "production"` - **DO** use `Boolean(Bun.env.CI)` for truthy checks on environment flags — but only inline, as one operand of a larger `&&`/`||` expression, or assigned to a `const` first. `Boolean(x)` used as the _sole, direct_ condition of `if (...)`/`cond ? a : b`/`!x` trips `eslint(no-extra-boolean-cast)` ("redundant Boolean call"), since that position is already boolean-coerced — assign to a `const` first (see Implementation Pattern) or use an explicit `!== undefined && !== ""` comparison instead - **DO** keep `process.env` in test files (`tests/`) where test harness compatibility is needed +- **DO** normalize a value through `usableEnv()` (`src/helpers/paths.ts`) before using it as a lookup key, path segment, or identifier — it maps both `""` and the literal string `"undefined"`, which shells and tooling surface for an unset variable, to `null` ### Don't - **DON'T** use `process.env` in any file under `src/` — use `Bun.env` instead -- **DON'T** create wrapper functions around `Bun.env` — access it directly +- **DON'T** create wrapper functions around `Bun.env` — access it directly. `usableEnv()` is not such a wrapper: it validates a value already read from `Bun.env`, and performs no lookup of its own +- **DON'T** default an env value to an empty string (`Bun.env.FOO ?? ""`) when the consumer distinguishes "absent" from "supplied" — `""` reads as absent at the far end, so a rejected value becomes indistinguishable from an unset one and the failure surfaces as silently wrong behavior rather than an error - **DON'T** destructure `Bun.env` (e.g., `const { HOME } = Bun.env`) — the proxy-based implementation may not support it reliably across versions; access properties individually ## Implementation Pattern diff --git a/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.md b/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.md index 8f3a3ce6..66ab5758 100644 --- a/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.md +++ b/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.md @@ -38,7 +38,7 @@ Conversely, every heading whose command path's parent chain consists of command- **Scope:** - **Module-backed subcommands, at every depth.** A group is any directory with an `index.ts`; its subcommands are sibling `.ts` modules and child groups. `src/commands/adr/domain/add.ts` is the command `adr domain add` and needs that heading in `adr.mdx`. -- **In-module subcommands are manual territory.** Subcommands registered inside a single module (e.g. `session-context/claude-code.ts` registering `list`/`show`) are invisible to file-layout discovery: their headings are permitted, not orphan-flagged, and their coverage rests on code review. +- **In-module subcommands are manual territory.** Subcommands registered inside a single module (e.g. `session-context.ts` registering `list`/`show`) are invisible to file-layout discovery: their headings are permitted, not orphan-flagged, and their coverage rests on code review. A heading is orphan-checked only when every ancestor in its command path is a group directory, so a command whose parent is a plain module — or a top-level command with no directory at all — is exempt. - **EN docs only.** The pt-br mirror is enforced by GEN-002. - **Website docs only.** The skill reference (`commands.md` in plugin directories) is in a separate repository and cannot be checked from this project. Its sync is a manual responsibility documented in the Do's section below. @@ -75,7 +75,7 @@ Conversely, every heading whose command path's parent chain consists of command- ### Risks - **Non-standard heading format bypasses the rule.** A heading like `## Import ADRs` instead of `## archgate adr import` goes undetected. **Mitigation:** The Do's section specifies the required format, and the rule's fix suggestion includes the expected heading text. -- **Orphan detection is depth-limited by design.** A heading whose parent chain ends in a leaf module (e.g. `#### archgate session-context claude-code list`) cannot be verified against the file layout and is never orphan-flagged, so a stale in-module subcommand heading survives the rule. **Mitigation:** reviewers check in-module subcommand docs when the registering module changes. +- **Orphan detection is depth-limited by design.** A heading is orphan-flagged only when every ancestor in its command path is a group directory. A heading under a leaf module, or under a top-level command with no directory (e.g. `### archgate session-context list`), cannot be verified against the file layout, so a stale in-module subcommand heading survives the rule. **Mitigation:** reviewers check in-module subcommand docs when the registering module changes. ## Compliance and Enforcement diff --git a/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.rules.ts b/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.rules.ts index 7825e91b..49526f27 100644 --- a/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.rules.ts +++ b/.archgate/adrs/ARCH-016-cli-subcommand-documentation-accuracy.rules.ts @@ -109,8 +109,8 @@ export default { // Docs -> subcommand: a heading is an orphan only when its parent // chain consists of group directories — a heading under a leaf - // module (e.g. "session-context claude-code list") documents an - // in-module subcommand the file layout cannot verify. + // module (e.g. "session-context list") documents an in-module + // subcommand the file layout cannot verify. const lowerPaths = new Set([...cmdPaths].map((p) => p.toLowerCase())); for (const docPath of [...documented].sort()) { if (lowerPaths.has(docPath)) continue; diff --git a/CLAUDE.md b/CLAUDE.md index 77f5626f..288edf93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,11 @@ Editor integrations share the `EditorTarget` union. Adding a new editor requires 5. `src/commands/plugin/install.ts` — extend `.choices([...] as const)` and add a case to `installForEditor` + the manual-instructions `catch` 6. `src/commands/plugin/url.ts` — extend `.choices([...] as const)` and branch before the URL ternary 7. Tests that assert the exact choice list: `tests/commands/plugin/install.test.ts`, `tests/commands/plugin/url.test.ts`, and `tests/helpers/editor-detect.test.ts` (length + id order) +8. To make the editor's transcripts readable, also extend `src/helpers/harness-detect.ts` (`DetectedHarness` union and the `SIGNALS` table), the `listFor`/`readFor` switches in `src/helpers/session-context-auto.ts`, and the `EDITORS` choice list in `src/commands/session-context.ts` + +**A session reader must be verified against a live session of the editor, not against fixtures.** Every harness records a turn where the agent only issued tool calls as a message whose prose content is empty; a reader that maps events to transcript entries without checking emits it as a blank entry. Skip a turn whose content preview is empty after trimming. The same class of gap hides in event shapes: one editor's CLI and desktop distribution can write the same conversation in different shapes, and a reader taught only one of them returns zero turns for the other. Fixtures agree with whatever the reader already assumes, so neither is visible until a real transcript is read. + +**Two detections answer different questions — keep them apart.** `editor-detect.ts` asks whether an editor is _installed_, so a config directory or a binary on PATH is proof. `harness-detect.ts` asks which editor is _running this process_, where only a variable the editor injects into its subprocesses counts; an installed-but-idle editor must never register there. Reaching for `copilotConfigDir()` or a PATH probe in the runtime path would make every user of that editor look like they are inside it. User-scope editors (e.g., opencode) write to a path resolved in `paths.ts` rather than the project tree — `configureEditorSettings` returns that path for the init summary and the real work happens in `tryInstallPlugin`. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index f7c6110d..50037605 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1426,7 +1426,7 @@ The plugin uses Archgate CLI commands directly to read ADRs and run compliance c - **`archgate check --staged`** -- automated rule checking with violation reporting - **`archgate adr show `** -- full text of a specific ADR - **`archgate adr list`** -- inventory of all ADRs in the project with metadata -- **`archgate session-context claude-code`** -- read session transcripts for context recovery +- **`archgate session-context`** -- read session transcripts for context recovery All commands run locally and read directly from your `.archgate/adrs/` directory. No data leaves your machine. @@ -1724,12 +1724,14 @@ For full governance in cloud environments, ensure `archgate` is available on the ## Session transcript access -The `archgate session-context cursor` command reads Cursor agent session transcripts from disk. This allows skills to access the history of the current conversation, which is useful for recovering context that may have been compacted or truncated. +The `archgate session-context` command reads Cursor agent session transcripts from disk. This allows skills to access the history of the current conversation, which is useful for recovering context that may have been compacted or truncated. -The command accepts two optional flags: +When Cursor is the detected editor, no editor needs to be named. Pass `--editor cursor` to read Cursor's transcripts regardless of what was detected — useful when another agent is running inside Cursor and wins detection. The command accepts two options: - `--max-entries ` -- Maximum number of entries to return (default: 200, most recent entries). -- `--session-id ` -- A specific session UUID to read. If omitted, the most recent session is used. +- `--editor ` -- Read another editor's transcripts instead of the detected one. + +Use `archgate session-context list` to discover earlier sessions, and `archgate session-context show ` to read a specific one. ## Tips for effective usage @@ -4817,157 +4819,116 @@ Source: https://cli.archgate.dev/reference/cli/session-context/ Read AI editor session transcripts for the project. Useful for auditing what an AI agent did during a coding session. ```bash -archgate session-context [subcommand] [options] -``` - -Each editor subcommand reads the **current conversation** — the most recent session for the project. Every editor also has two nested subcommands: `list` to discover earlier sessions and `show ` to read a specific one. - -## Subcommands - -### archgate session-context claude-code - -Read the current Claude Code session transcript for the project. - -```bash -archgate session-context claude-code [options] -``` - -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | - -#### archgate session-context claude-code list - -List available Claude Code sessions for the project as JSON (`id` + `updatedAt`), most recent first. - -```bash -archgate session-context claude-code list -``` - -#### archgate session-context claude-code show - -Read a specific session by ID (from `list`). Accepts `--max-entries`. - -```bash -archgate session-context claude-code show -``` - -### archgate session-context copilot - -Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. - -```bash -archgate session-context copilot [options] +archgate session-context [subcommand] [options] ``` -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | +Reads the **current conversation** — by default for the editor running the command, which Archgate detects from the environment. Two subcommands cover the rest: `list` to discover earlier sessions, and `show ` to read a specific one. -#### archgate session-context copilot list - -List available Copilot CLI sessions for the project as JSON, most recent first. - -```bash -archgate session-context copilot list -``` - -#### archgate session-context copilot show - -Read a specific session by UUID (from `list`). Accepts `--max-entries`. +## Options -```bash -archgate session-context copilot show -``` +| Option | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--editor ` | Editor to read: `antigravity`, `claude-code`, `codex`, `copilot`, `cursor`, `opencode`, or `pi`. Defaults to the detected editor. | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--root` | opencode only: resolve a sub-agent child session up to its top-level ancestor | -### archgate session-context cursor +## Automatic editor detection -Read the current Cursor agent session transcript for the project. +Every supported editor sets environment variables on the commands it runs, and Archgate reads them to work out which editor is asking. Pass `--editor` to override the result, or to read a different editor's transcripts. -```bash -archgate session-context cursor [options] -``` +| Editor | Detected by | Pins the exact session via | +| ----------- | ----------------------------- | --------------------------------------------------------------------------------------- | +| Antigravity | `ANTIGRAVITY_AGENT` | `ANTIGRAVITY_CONVERSATION_ID`, or `conversationId` inside `ANTIGRAVITY_SOURCE_METADATA` | +| Claude Code | `CLAUDECODE` | `CLAUDE_CODE_SESSION_ID` | +| Codex | `CODEX_THREAD_ID` | `CODEX_THREAD_ID` | +| Copilot CLI | `COPILOT_CLI` | `COPILOT_AGENT_SESSION_ID` | +| Cursor | `CURSOR_AGENT` | `CURSOR_CONVERSATION_ID` | +| opencode | `OPENCODE`, `OPENCODE_CLIENT` | _(none — falls back to recency)_ | +| Pi | `PI_CODING_AGENT` | `PI_SESSION_ID` | -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | +Editors that publish their own session id get the **exact** conversation rather than the most recent one. This matters when a project has several sessions open at once, where the most recent may not be the conversation you are part of. A published id that matches no session for the project is ignored and recency applies, so a stale id never turns a working command into an error. -#### archgate session-context cursor list +A session id only ever pins the editor that published it. Passing `--editor cursor` from inside Claude Code reads Cursor's transcripts by recency and ignores `CLAUDE_CODE_SESSION_ID`. -List available Cursor agent sessions for the project as JSON, most recent first. +Every command reports what it resolved in a `detection` object: -```bash -archgate session-context cursor list +```json +{ + "detection": { + "editor": "claude-code", + "via": "CLAUDECODE", + "session": "pinned", + "candidates": ["claude-code"] + }, + "sessionFile": "6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6.jsonl", + "totalEntries": 182, + "relevantEntries": 125, + "transcript": [] +} ``` -#### archgate session-context cursor show - -Read a specific session by UUID (from `list`). Accepts `--max-entries`. +`via` is the environment variable that identified the editor, or `--editor` when you named one. `session` is `pinned` when the editor's own session id was used, `recent` when the most recent session was taken, and `explicit` when a session id was passed on the command line. `candidates` lists every editor whose marker was present — more than one appears when an agent runs inside another agent. The winner then comes from a fixed order — Antigravity, Claude Code, Codex, Copilot, Cursor, Pi, then opencode — which ranks the editors that publish a session id ahead of the one that does not. That order applies whatever the ids happen to be: an empty or unusable id changes which session is selected, never which editor. -```bash -archgate session-context cursor show -``` +Detection fails when Archgate runs from a plain shell rather than inside an AI editor. The command then exits 1 and asks for `--editor`. -### archgate session-context opencode +## Subcommands -Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. +### archgate session-context list -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --root` to resolve its top-level ancestor deterministically instead of relying on recency. +List available sessions for the project as JSON (`id`, `updatedAt`, and `title` for editors that store one), most recent first. Accepts `--editor`. ```bash -archgate session-context opencode [options] +archgate session-context list ``` -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | - -#### archgate session-context opencode list +### archgate session-context show -List available top-level opencode sessions for the project as JSON (`id`, `title`, `updatedAt`), most recent first. Sub-agent child sessions are excluded (they can still be read via `show`). +Read a specific session by ID (from `list`). An explicit ID always wins over the one published by the environment. Accepts `--editor`, `--max-entries`, and `--root`. ```bash -archgate session-context opencode list +archgate session-context show ``` -#### archgate session-context opencode show +## How each editor stores sessions -Read a specific session by ID (from `list`), including sub-agent child sessions. Accepts `--max-entries` and `--root` (resolve a sub-agent child session up to its top-level ancestor). - -```bash -archgate session-context opencode show [--root] -``` +- **Antigravity** — both the `agy` CLI and the desktop app write conversations as JSONL under `brain//.system_generated/logs/`, in `~/.gemini/antigravity-cli/` and `~/.gemini/antigravity/` respectively; both are read. The CLI records the workspace in each conversation's own database, the app in a shared summaries index. A conversation the caller is running inside is read even before that index catches up, since the environment names it. +- **Claude Code** — one JSONL transcript per session, keyed by the encoded project path. Session IDs are the transcript filenames. +- **Codex** — rollout files under date shards (`sessions/YYYY/MM/DD/`), shared by the Codex CLI and the desktop app. Sessions are matched by the `cwd` recorded in each rollout's `session_meta` line, and the session ID is the thread ID. The CLI and the desktop app record turns in different event shapes and both are read. Rollouts older than a week are zstd-compressed in place; both forms are read. Honors `CODEX_HOME`. +- **Copilot CLI** — sessions are matched by their workspace `cwd` field. +- **Cursor** — sessions are matched by the encoded project path; IDs are UUIDs. +- **Pi** — sessions live under a directory encoding the working directory, and each file's header `cwd` is verified as well, so a relocated session directory still resolves. Honors `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`. Pi branches a session in place rather than starting a new file, so only the active branch is read — a forked or rewound turn is left out. Pi publishes its session ID only to commands its agent runs, so a manually typed command is detected but selected by recency. +- **opencode** — sessions are matched by comparing the session `directory` field to the project root. Sub-agent runs are recorded as child sessions sharing the parent's directory; these are excluded from `list` and from recency selection, so the most recent top-level session is always the main development session. They can still be read by ID with `show`, and `--root` resolves a child session up to its top-level ancestor — useful when a sub-agent knows its own session ID and needs the conversation it belongs to. ## Examples -Read the current Claude Code session: +Read the current session, whichever editor is running: ```bash -archgate session-context claude-code +archgate session-context ``` -Read the current opencode session: +List sessions for the detected editor: ```bash -archgate session-context opencode +archgate session-context list ``` -List the project's Claude Code sessions: +Read another editor's current session: ```bash -archgate session-context claude-code list +archgate session-context --editor opencode ``` Read a specific earlier session: ```bash -archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context opencode show ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` --- diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index 68725e13..8700c3e0 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -136,7 +136,7 @@ The plugin uses Archgate CLI commands directly to read ADRs and run compliance c - **`archgate check --staged`** -- automated rule checking with violation reporting - **`archgate adr show `** -- full text of a specific ADR - **`archgate adr list`** -- inventory of all ADRs in the project with metadata -- **`archgate session-context claude-code`** -- read session transcripts for context recovery +- **`archgate session-context`** -- read session transcripts for context recovery All commands run locally and read directly from your `.archgate/adrs/` directory. No data leaves your machine. diff --git a/docs/src/content/docs/guides/cursor-integration.mdx b/docs/src/content/docs/guides/cursor-integration.mdx index 6f6554c4..8bca7768 100644 --- a/docs/src/content/docs/guides/cursor-integration.mdx +++ b/docs/src/content/docs/guides/cursor-integration.mdx @@ -147,12 +147,14 @@ For full governance in cloud environments, ensure `archgate` is available on the ## Session transcript access -The `archgate session-context cursor` command reads Cursor agent session transcripts from disk. This allows skills to access the history of the current conversation, which is useful for recovering context that may have been compacted or truncated. +The `archgate session-context` command reads Cursor agent session transcripts from disk. This allows skills to access the history of the current conversation, which is useful for recovering context that may have been compacted or truncated. -The command accepts two optional flags: +When Cursor is the detected editor, no editor needs to be named. Pass `--editor cursor` to read Cursor's transcripts regardless of what was detected — useful when another agent is running inside Cursor and wins detection. The command accepts two options: - `--max-entries ` -- Maximum number of entries to return (default: 200, most recent entries). -- `--session-id ` -- A specific session UUID to read. If omitted, the most recent session is used. +- `--editor ` -- Read another editor's transcripts instead of the detected one. + +Use `archgate session-context list` to discover earlier sessions, and `archgate session-context show ` to read a specific one. ## Tips for effective usage diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index 519a2a03..7e888f65 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -136,7 +136,7 @@ Pluginen bruker Archgate CLI-kommandoer direkte for å lese ADR-er og kjøre sam - **`archgate check --staged`** -- automatisert regelsjekking med bruddrapportering - **`archgate adr show `** -- full tekst av en spesifikk ADR - **`archgate adr list`** -- oversikt over alle ADR-er i prosjektet med metadata -- **`archgate session-context claude-code`** -- les sesjonsutskrifter for kontekstgjenoppretting +- **`archgate session-context`** -- les sesjonsutskrifter for kontekstgjenoppretting Alle kommandoer kjøres lokalt og leser direkte fra `.archgate/adrs/`-katalogen din. Ingen data forlater maskinen din. diff --git a/docs/src/content/docs/nb/guides/cursor-integration.mdx b/docs/src/content/docs/nb/guides/cursor-integration.mdx index c8dd76de..e450e5c6 100644 --- a/docs/src/content/docs/nb/guides/cursor-integration.mdx +++ b/docs/src/content/docs/nb/guides/cursor-integration.mdx @@ -147,12 +147,14 @@ For full styring i skymiljøer, sørg for at `archgate` er tilgjengelig på VM-e ## Tilgang til sesjonsutskrifter -Kommandoen `archgate session-context cursor` leser Cursor-agentens sesjonsutskrifter fra disk. Dette lar ferdigheter få tilgang til historikken til den gjeldende samtalen, noe som er nyttig for å gjenopprette kontekst som kan ha blitt komprimert eller avkortet. +Kommandoen `archgate session-context` leser Cursor-agentens sesjonsutskrifter fra disk. Dette lar ferdigheter få tilgang til historikken til den gjeldende samtalen, noe som er nyttig for å gjenopprette kontekst som kan ha blitt komprimert eller avkortet. -Kommandoen aksepterer to valgfrie flagg: +Når Cursor er den gjenkjente editoren, trenger du ikke oppgi en editor. Bruk `--editor cursor` for å lese Cursors sesjonsutskrifter uansett hva som ble gjenkjent — nyttig når en annen agent kjører inne i Cursor og vinner gjenkjenningen. Kommandoen aksepterer to valg: - `--max-entries ` -- Maksimalt antall oppføringer å returnere (standard: 200, nyeste oppføringer). -- `--session-id ` -- En spesifikk sesjons-UUID å lese. Hvis utelatt, brukes den nyeste sesjonen. +- `--editor ` -- Les en annen editors sesjonsutskrifter i stedet for den gjenkjente. + +Bruk `archgate session-context list` for å oppdage tidligere sesjoner, og `archgate session-context show ` for å lese en bestemt en. ## Tips for effektiv bruk diff --git a/docs/src/content/docs/nb/reference/cli/session-context.mdx b/docs/src/content/docs/nb/reference/cli/session-context.mdx index b83b2142..d02d3cef 100644 --- a/docs/src/content/docs/nb/reference/cli/session-context.mdx +++ b/docs/src/content/docs/nb/reference/cli/session-context.mdx @@ -6,155 +6,114 @@ description: "Les AI-editor-sesjonslogger for prosjektet." Les AI-editor-sesjonslogger for prosjektet. Nyttig for å revidere hva en AI-agent gjorde under en kodeøkt. ```bash -archgate session-context [subcommand] [options] +archgate session-context [subcommand] [options] ``` -Hver editor-underkommando leser den **gjeldende samtalen** — den nyeste sesjonen for prosjektet. Hver editor har også to nestede underkommandoer: `list` for å oppdage tidligere sesjoner og `show ` for å lese en bestemt en. +Leser den **gjeldende samtalen** — som standard for editoren som kjører kommandoen, og som Archgate finner ut fra miljøet. To underkommandoer dekker resten: `list` for å oppdage tidligere sesjoner, og `show ` for å lese en bestemt en. -## Underkommandoer - -### archgate session-context claude-code - -Les den gjeldende Claude Code-sesjonsloggen for prosjektet. - -```bash -archgate session-context claude-code [options] -``` - -| Valg | Beskrivelse | -| ------------------- | -------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | - -#### archgate session-context claude-code list - -List opp tilgjengelige Claude Code-sesjoner for prosjektet som JSON (`id` + `updatedAt`), nyeste først. - -```bash -archgate session-context claude-code list -``` - -#### archgate session-context claude-code show +## Valg -Les en bestemt sesjon etter ID (fra `list`). Godtar `--max-entries`. +| Valg | Beskrivelse | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `--editor ` | Editor som skal leses: `antigravity`, `claude-code`, `codex`, `copilot`, `cursor`, `opencode` eller `pi`. Som standard den gjenkjente editoren. | +| `--max-entries ` | Høyeste antall oppføringer som returneres (standard: 200) | +| `--root` | Kun opencode: løs opp en underagents barnesesjon til forelderen på øverste nivå | -```bash -archgate session-context claude-code show -``` +## Automatisk gjenkjenning av editor -### archgate session-context copilot +Hver støttet editor setter miljøvariabler på kommandoene den kjører, og Archgate leser dem for å finne ut hvilken editor som spør. Bruk `--editor` for å overstyre resultatet, eller for å lese sesjonsloggene til en annen editor. -Les den gjeldende Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches via arbeidsområdets `cwd`-felt. +| Editor | Gjenkjennes av | Låser den nøyaktige sesjonen via | +| ----------- | ----------------------------- | ---------------------------------------------------------------------------------------- | +| Antigravity | `ANTIGRAVITY_AGENT` | `ANTIGRAVITY_CONVERSATION_ID`, eller `conversationId` inni `ANTIGRAVITY_SOURCE_METADATA` | +| Claude Code | `CLAUDECODE` | `CLAUDE_CODE_SESSION_ID` | +| Codex | `CODEX_THREAD_ID` | `CODEX_THREAD_ID` | +| Copilot CLI | `COPILOT_CLI` | `COPILOT_AGENT_SESSION_ID` | +| Cursor | `CURSOR_AGENT` | `CURSOR_CONVERSATION_ID` | +| opencode | `OPENCODE`, `OPENCODE_CLIENT` | _(ingen — faller tilbake på nyeste)_ | +| Pi | `PI_CODING_AGENT` | `PI_SESSION_ID` | -```bash -archgate session-context copilot [options] -``` +Editorer som publiserer sin egen sesjons-ID får den **nøyaktige** samtalen i stedet for den nyeste. Det betyr noe når et prosjekt har flere sesjoner åpne samtidig, der den nyeste kanskje ikke er samtalen du er en del av. En publisert ID som ikke passer til noen sesjon i prosjektet blir ignorert, og den nyeste brukes — slik gjør en utdatert ID aldri en fungerende kommando om til en feil. -| Valg | Beskrivelse | -| ------------------- | -------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +En sesjons-ID låser bare editoren som publiserte den. Bruker du `--editor cursor` inne fra Claude Code, leses Cursors sesjonslogger etter nyeste, og `CLAUDE_CODE_SESSION_ID` blir ignorert. -#### archgate session-context copilot list +Hver kommando rapporterer hva den kom fram til i et `detection`-objekt: -List opp tilgjengelige Copilot CLI-sesjoner for prosjektet som JSON, nyeste først. - -```bash -archgate session-context copilot list +```json +{ + "detection": { + "editor": "claude-code", + "via": "CLAUDECODE", + "session": "pinned", + "candidates": ["claude-code"] + }, + "sessionFile": "6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6.jsonl", + "totalEntries": 182, + "relevantEntries": 125, + "transcript": [] +} ``` -#### archgate session-context copilot show - -Les en bestemt sesjon etter UUID (fra `list`). Godtar `--max-entries`. +`via` er miljøvariabelen som identifiserte editoren, eller `--editor` når du oppga en. `session` er `pinned` når editorens egen sesjons-ID ble brukt, `recent` når den nyeste sesjonen ble tatt, og `explicit` når en sesjons-ID ble oppgitt på kommandolinjen. `candidates` lister hver editor som hadde markøren sin satt — mer enn én dukker opp når en agent kjører inne i en annen agent. Vinneren kommer da fra en fast rekkefølge — Antigravity, Claude Code, Codex, Copilot, Cursor, Pi og deretter opencode — som setter editorene som publiserer en sesjons-ID foran den som ikke gjør det. Rekkefølgen gjelder uansett hvilke ID-er som finnes: en tom eller ubrukelig ID endrer hvilken sesjon som velges, aldri hvilken editor. -```bash -archgate session-context copilot show -``` +Gjenkjenningen mislykkes når Archgate kjøres fra et vanlig skall i stedet for inne i en AI-editor. Kommandoen avslutter da med kode 1 og ber om `--editor`. -### archgate session-context cursor - -Les den gjeldende Cursor-agentsesjonsloggen for prosjektet. - -```bash -archgate session-context cursor [options] -``` - -| Valg | Beskrivelse | -| ------------------- | -------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | - -#### archgate session-context cursor list - -List opp tilgjengelige Cursor-agentsesjoner for prosjektet som JSON, nyeste først. - -```bash -archgate session-context cursor list -``` - -#### archgate session-context cursor show - -Les en bestemt sesjon etter UUID (fra `list`). Godtar `--max-entries`. - -```bash -archgate session-context cursor show -``` - -### archgate session-context opencode +## Underkommandoer -Les den gjeldende opencode-sesjonsloggen for prosjektet. Sesjoner matches ved å sammenligne sesjonens `directory`-felt med prosjektroten. opencode lagrer underagent-kjøringer som barnesesjoner som deler foreldrenes katalog — disse ekskluderes fra utvalget etter nylighet, slik at den nyeste toppnivåsesjonen alltid er hovedutviklingssesjonen. +### archgate session-context list -Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), bruk `show --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. +List tilgjengelige sesjoner for prosjektet som JSON (`id`, `updatedAt`, og `title` for editorer som lagrer en), nyeste først. Godtar `--editor`. ```bash -archgate session-context opencode [options] +archgate session-context list ``` -| Valg | Beskrivelse | -| ------------------- | -------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | - -#### archgate session-context opencode list +### archgate session-context show -List opp tilgjengelige toppnivåsesjoner i opencode for prosjektet som JSON (`id`, `title`, `updatedAt`), nyeste først. Underagent-barnesesjoner ekskluderes (de kan fortsatt leses via `show`). +Les en bestemt sesjon etter ID (fra `list`). En eksplisitt ID vinner alltid over den som miljøet publiserer. Godtar `--editor`, `--max-entries` og `--root`. ```bash -archgate session-context opencode list +archgate session-context show ``` -#### archgate session-context opencode show - -Les en bestemt sesjon etter ID (fra `list`), inkludert underagent-barnesesjoner. Godtar `--max-entries` og `--root` (løs opp en underagent-barnesesjon til dens toppnivåforelder). +## Slik lagrer hver editor sesjoner -```bash -archgate session-context opencode show [--root] -``` +- **Antigravity** — både `agy`-CLI-en og skrivebordsappen skriver samtaler som JSONL under `brain//.system_generated/logs/`, i henholdsvis `~/.gemini/antigravity-cli/` og `~/.gemini/antigravity/`; begge leses. CLI-en lagrer arbeidsområdet i samtalens egen database, appen i en delt sammendragsindeks. En samtale som kalleren kjører inne i, leses selv før den indeksen er oppdatert, siden miljøet navngir den. +- **Claude Code** — én JSONL-sesjonslogg per sesjon, nøklet på den kodede prosjektstien. Sesjons-ID-ene er filnavnene til sesjonsloggene. +- **Codex** — rollout-filer i datomapper (`sessions/YYYY/MM/DD/`), delt av Codex CLI og skrivebordsappen. Sesjoner matches på `cwd` som er lagret i hver rollouts `session_meta`-linje, og sesjons-ID-en er tråd-ID-en. CLI-en og skrivebordsappen lagrer turer i ulike hendelsesformater, og begge leses. Rollouts eldre enn en uke komprimeres med zstd på stedet; begge formene leses. Respekterer `CODEX_HOME`. +- **Copilot CLI** — sesjoner matches på `cwd`-feltet til arbeidsområdet. +- **Cursor** — sesjoner matches på den kodede prosjektstien; ID-ene er UUID-er. +- **Pi** — sesjoner ligger i en mappe som koder arbeidskatalogen, og `cwd` i hver fils header kontrolleres i tillegg, slik at en flyttet sesjonsmappe fortsatt løses opp. Respekterer `PI_CODING_AGENT_DIR` og `PI_CODING_AGENT_SESSION_DIR`. Pi forgrener en sesjon i samme fil i stedet for å lage en ny, så bare den aktive grenen leses — en forgrenet eller tilbakerullet tur utelates. Pi publiserer sesjons-ID-en bare til kommandoer agenten selv kjører, så en manuelt skrevet kommando gjenkjennes, men velges etter nyeste. +- **opencode** — sesjoner matches ved å sammenligne sesjonens `directory`-felt med prosjektroten. Kjøringer av underagenter lagres som barnesesjoner som deler forelderens katalog; disse holdes utenfor `list` og utenfor valget av nyeste, slik at den nyeste sesjonen på øverste nivå alltid er hovedøkten. De kan fortsatt leses etter ID med `show`, og `--root` løser opp en barnesesjon til forelderen på øverste nivå — nyttig når en underagent kjenner sin egen sesjons-ID og trenger samtalen den hører til. ## Eksempler -Les den gjeldende Claude Code-sesjonen: +Les den gjeldende sesjonen, uansett hvilken editor som kjører: ```bash -archgate session-context claude-code +archgate session-context ``` -Les den gjeldende opencode-sesjonen: +List sesjonene til den gjenkjente editoren: ```bash -archgate session-context opencode +archgate session-context list ``` -List opp prosjektets Claude Code-sesjoner: +Les den gjeldende sesjonen til en annen editor: ```bash -archgate session-context claude-code list +archgate session-context --editor opencode ``` Les en bestemt tidligere sesjon: ```bash -archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` -Løs opp en underagent-barnesesjon i opencode til dens toppnivåforelder: +Løs opp en opencode-underagents barnesesjon til forelderen på øverste nivå: ```bash -archgate session-context opencode show ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index 9f674af6..9a126003 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -136,7 +136,7 @@ O plugin usa comandos do CLI Archgate diretamente para ler ADRs e executar verif - **`archgate check --staged`** -- verificação automatizada de regras com relatório de violações - **`archgate adr show `** -- texto completo de um ADR específico - **`archgate adr list`** -- inventário de todos os ADRs do projeto com metadados -- **`archgate session-context claude-code`** -- lê transcrições de sessão para recuperação de contexto +- **`archgate session-context`** -- lê transcrições de sessão para recuperação de contexto Todos os comandos rodam localmente e leem diretamente do seu diretório `.archgate/adrs/`. Nenhum dado sai da sua máquina. diff --git a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx index 127c0047..ce03d24c 100644 --- a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx +++ b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx @@ -147,12 +147,14 @@ Para governança completa em ambientes na nuvem, garanta que `archgate` esteja d ## Acesso à transcrição da sessão -O comando `archgate session-context cursor` lê transcrições de sessão do agente Cursor do disco. Isso permite que skills acessem o histórico da conversa atual, o que é útil para recuperar contexto que pode ter sido compactado ou truncado. +O comando `archgate session-context` lê transcrições de sessão do agente Cursor do disco. Isso permite que skills acessem o histórico da conversa atual, o que é útil para recuperar contexto que pode ter sido compactado ou truncado. -O comando aceita dois flags opcionais: +Quando o Cursor é o editor detectado, não é preciso informar um editor. Use `--editor cursor` para ler as transcrições do Cursor independentemente do que foi detectado — útil quando outro agente executa dentro do Cursor e vence a detecção. O comando aceita duas opções: - `--max-entries ` -- Número máximo de entradas a retornar (padrão: 200, entradas mais recentes). -- `--session-id ` -- Um UUID de sessão específico para ler. Se omitido, a sessão mais recente é usada. +- `--editor ` -- Lê as transcrições de outro editor em vez do detectado. + +Use `archgate session-context list` para descobrir sessões anteriores e `archgate session-context show ` para ler uma sessão específica. ## Dicas para uso eficaz diff --git a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx index 9036e8db..b5fb6f31 100644 --- a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx @@ -6,155 +6,114 @@ description: "Lê transcrições de sessão de editores de IA para o projeto." Lê transcrições de sessão de editores de IA para o projeto. Útil para auditar o que um agente de IA fez durante uma sessão de codificação. ```bash -archgate session-context [subcommand] [options] +archgate session-context [subcommand] [options] ``` -Cada subcomando de editor lê a **conversa atual** — a sessão mais recente do projeto. Cada editor também tem dois subcomandos aninhados: `list` para descobrir sessões anteriores e `show ` para ler uma sessão específica. +Lê a **conversa atual** — por padrão, a do editor que executa o comando, que o Archgate detecta a partir do ambiente. Dois subcomandos cobrem o restante: `list` para descobrir sessões anteriores e `show ` para ler uma sessão específica. -## Subcomandos - -### archgate session-context claude-code - -Lê a transcrição da sessão atual do Claude Code para o projeto. - -```bash -archgate session-context claude-code [options] -``` - -| Opção | Descrição | -| ------------------- | ------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | - -#### archgate session-context claude-code list - -Lista as sessões disponíveis do Claude Code para o projeto como JSON (`id` + `updatedAt`), da mais recente para a mais antiga. - -```bash -archgate session-context claude-code list -``` - -#### archgate session-context claude-code show +## Opções -Lê uma sessão específica por ID (de `list`). Aceita `--max-entries`. +| Opção | Descrição | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `--editor ` | Editor a ler: `antigravity`, `claude-code`, `codex`, `copilot`, `cursor`, `opencode` ou `pi`. Por padrão, o editor detectado. | +| `--max-entries ` | Número máximo de entradas a retornar (padrão: 200) | +| `--root` | Somente opencode: resolve uma sessão-filha de subagente até seu ancestral de nível superior | -```bash -archgate session-context claude-code show -``` +## Detecção automática do editor -### archgate session-context copilot +Todo editor compatível define variáveis de ambiente nos comandos que executa, e o Archgate as lê para descobrir qual editor está perguntando. Passe `--editor` para sobrepor o resultado ou para ler as transcrições de outro editor. -Lê a transcrição da sessão atual do Copilot CLI para o projeto. Sessões são correspondidas pelo campo `cwd` do workspace. +| Editor | Detectado por | Fixa a sessão exata via | +| ----------- | ----------------------------- | ------------------------------------------------------------------------------------------ | +| Antigravity | `ANTIGRAVITY_AGENT` | `ANTIGRAVITY_CONVERSATION_ID`, ou `conversationId` dentro de `ANTIGRAVITY_SOURCE_METADATA` | +| Claude Code | `CLAUDECODE` | `CLAUDE_CODE_SESSION_ID` | +| Codex | `CODEX_THREAD_ID` | `CODEX_THREAD_ID` | +| Copilot CLI | `COPILOT_CLI` | `COPILOT_AGENT_SESSION_ID` | +| Cursor | `CURSOR_AGENT` | `CURSOR_CONVERSATION_ID` | +| opencode | `OPENCODE`, `OPENCODE_CLIENT` | _(nenhuma — recorre à recência)_ | +| Pi | `PI_CODING_AGENT` | `PI_SESSION_ID` | -```bash -archgate session-context copilot [options] -``` +Editores que publicam o próprio identificador de sessão obtêm a conversa **exata**, em vez da mais recente. Isso importa quando um projeto tem várias sessões abertas ao mesmo tempo, situação em que a mais recente pode não ser a conversa da qual você participa. Um identificador publicado que não corresponde a nenhuma sessão do projeto é ignorado e a recência prevalece, de modo que um identificador obsoleto nunca transforma um comando funcional em erro. -| Opção | Descrição | -| ------------------- | ------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +Um identificador de sessão só fixa o editor que o publicou. Passar `--editor cursor` de dentro do Claude Code lê as transcrições do Cursor por recência e ignora `CLAUDE_CODE_SESSION_ID`. -#### archgate session-context copilot list +Todo comando informa o que resolveu em um objeto `detection`: -Lista as sessões disponíveis do Copilot CLI para o projeto como JSON, da mais recente para a mais antiga. - -```bash -archgate session-context copilot list +```json +{ + "detection": { + "editor": "claude-code", + "via": "CLAUDECODE", + "session": "pinned", + "candidates": ["claude-code"] + }, + "sessionFile": "6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6.jsonl", + "totalEntries": 182, + "relevantEntries": 125, + "transcript": [] +} ``` -#### archgate session-context copilot show - -Lê uma sessão específica por UUID (de `list`). Aceita `--max-entries`. +`via` é a variável de ambiente que identificou o editor, ou `--editor` quando você informou um. `session` é `pinned` quando o identificador de sessão do próprio editor foi usado, `recent` quando a sessão mais recente foi tomada e `explicit` quando um identificador de sessão foi passado na linha de comando. `candidates` lista todos os editores cujo marcador estava presente — mais de um aparece quando um agente executa dentro de outro agente. Nesse caso o vencedor vem de uma ordem fixa — Antigravity, Claude Code, Codex, Copilot, Cursor, Pi e então opencode — que coloca os editores que publicam um identificador de sessão à frente daquele que não publica. Essa ordem vale quaisquer que sejam os identificadores: um identificador vazio ou inutilizável muda qual sessão é selecionada, nunca qual editor. -```bash -archgate session-context copilot show -``` +A detecção falha quando o Archgate executa a partir de um shell comum, e não dentro de um editor de IA. O comando então encerra com código 1 e solicita `--editor`. -### archgate session-context cursor - -Lê a transcrição da sessão atual do agente Cursor para o projeto. - -```bash -archgate session-context cursor [options] -``` - -| Opção | Descrição | -| ------------------- | ------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | - -#### archgate session-context cursor list - -Lista as sessões disponíveis do agente Cursor para o projeto como JSON, da mais recente para a mais antiga. - -```bash -archgate session-context cursor list -``` - -#### archgate session-context cursor show - -Lê uma sessão específica por UUID (de `list`). Aceita `--max-entries`. - -```bash -archgate session-context cursor show -``` - -### archgate session-context opencode +## Subcomandos -Lê a transcrição da sessão atual do opencode para o projeto. Sessões são correspondidas comparando o campo `directory` da sessão com a raiz do projeto. O opencode registra execuções de sub-agentes como sessões filhas que compartilham o diretório da sessão pai — elas são excluídas da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. +### archgate session-context list -Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), use `show --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. +Lista as sessões disponíveis do projeto como JSON (`id`, `updatedAt` e `title` para editores que armazenam um), da mais recente para a mais antiga. Aceita `--editor`. ```bash -archgate session-context opencode [options] +archgate session-context list ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | - -#### archgate session-context opencode list +### archgate session-context show -Lista as sessões de nível superior disponíveis do opencode para o projeto como JSON (`id`, `title`, `updatedAt`), da mais recente para a mais antiga. Sessões filhas de sub-agente são excluídas (elas ainda podem ser lidas via `show`). +Lê uma sessão específica por ID (obtido em `list`). Um ID explícito sempre prevalece sobre o publicado pelo ambiente. Aceita `--editor`, `--max-entries` e `--root`. ```bash -archgate session-context opencode list +archgate session-context show ``` -#### archgate session-context opencode show - -Lê uma sessão específica por ID (de `list`), incluindo sessões filhas de sub-agente. Aceita `--max-entries` e `--root` (resolve uma sessão filha de sub-agente até seu ancestral de nível superior). +## Como cada editor armazena as sessões -```bash -archgate session-context opencode show [--root] -``` +- **Antigravity** — tanto o CLI `agy` quanto o aplicativo desktop gravam as conversas como JSONL em `brain//.system_generated/logs/`, em `~/.gemini/antigravity-cli/` e `~/.gemini/antigravity/` respectivamente; ambos são lidos. O CLI registra o workspace no banco da própria conversa; o aplicativo, em um índice de resumos compartilhado. Uma conversa em que o chamador está executando é lida mesmo antes de esse índice ser atualizado, pois o ambiente a identifica. +- **Claude Code** — uma transcrição JSONL por sessão, indexada pelo caminho codificado do projeto. Os IDs de sessão são os nomes dos arquivos de transcrição. +- **Codex** — arquivos de rollout em pastas por data (`sessions/YYYY/MM/DD/`), compartilhados pelo Codex CLI e pelo aplicativo desktop. As sessões são correspondidas pelo `cwd` registrado na linha `session_meta` de cada rollout, e o ID de sessão é o ID da thread. O CLI e o aplicativo desktop registram os turnos em formatos de evento diferentes, e ambos são lidos. Rollouts com mais de uma semana são comprimidos com zstd no lugar; ambas as formas são lidas. Respeita `CODEX_HOME`. +- **Copilot CLI** — as sessões são correspondidas pelo campo `cwd` do workspace. +- **Cursor** — as sessões são correspondidas pelo caminho codificado do projeto; os IDs são UUIDs. +- **Pi** — as sessões ficam em um diretório que codifica o diretório de trabalho, e o `cwd` do cabeçalho de cada arquivo também é verificado, de modo que um diretório de sessões realocado ainda é resolvido. Respeita `PI_CODING_AGENT_DIR` e `PI_CODING_AGENT_SESSION_DIR`. O Pi ramifica uma sessão no próprio arquivo em vez de criar um novo, então apenas o ramo ativo é lido — um turno bifurcado ou desfeito fica de fora. O Pi publica o ID de sessão apenas para comandos executados pelo seu agente, então um comando digitado manualmente é detectado, mas selecionado por recência. +- **opencode** — as sessões são correspondidas comparando o campo `directory` da sessão com a raiz do projeto. Execuções de subagentes são registradas como sessões-filhas que compartilham o diretório do pai; elas ficam fora de `list` e da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. Ainda podem ser lidas por ID com `show`, e `--root` resolve uma sessão-filha até seu ancestral de nível superior — útil quando um subagente conhece o próprio ID de sessão e precisa da conversa à qual ele pertence. ## Exemplos -Ler a sessão atual do Claude Code: +Ler a sessão atual, seja qual for o editor em execução: ```bash -archgate session-context claude-code +archgate session-context ``` -Ler a sessão atual do opencode: +Listar as sessões do editor detectado: ```bash -archgate session-context opencode +archgate session-context list ``` -Listar as sessões do Claude Code do projeto: +Ler a sessão atual de outro editor: ```bash -archgate session-context claude-code list +archgate session-context --editor opencode ``` Ler uma sessão anterior específica: ```bash -archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` -Resolver uma sessão filha de sub-agente do opencode para seu ancestral de nível superior: +Resolver uma sessão-filha de subagente do opencode até seu ancestral de nível superior: ```bash -archgate session-context opencode show ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` diff --git a/docs/src/content/docs/reference/cli/session-context.mdx b/docs/src/content/docs/reference/cli/session-context.mdx index 121ee4e4..de1f74a5 100644 --- a/docs/src/content/docs/reference/cli/session-context.mdx +++ b/docs/src/content/docs/reference/cli/session-context.mdx @@ -6,155 +6,114 @@ description: "Read AI editor session transcripts for the project." Read AI editor session transcripts for the project. Useful for auditing what an AI agent did during a coding session. ```bash -archgate session-context [subcommand] [options] +archgate session-context [subcommand] [options] ``` -Each editor subcommand reads the **current conversation** — the most recent session for the project. Every editor also has two nested subcommands: `list` to discover earlier sessions and `show ` to read a specific one. +Reads the **current conversation** — by default for the editor running the command, which Archgate detects from the environment. Two subcommands cover the rest: `list` to discover earlier sessions, and `show ` to read a specific one. -## Subcommands - -### archgate session-context claude-code - -Read the current Claude Code session transcript for the project. - -```bash -archgate session-context claude-code [options] -``` - -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | - -#### archgate session-context claude-code list - -List available Claude Code sessions for the project as JSON (`id` + `updatedAt`), most recent first. - -```bash -archgate session-context claude-code list -``` - -#### archgate session-context claude-code show +## Options -Read a specific session by ID (from `list`). Accepts `--max-entries`. +| Option | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--editor ` | Editor to read: `antigravity`, `claude-code`, `codex`, `copilot`, `cursor`, `opencode`, or `pi`. Defaults to the detected editor. | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--root` | opencode only: resolve a sub-agent child session up to its top-level ancestor | -```bash -archgate session-context claude-code show -``` +## Automatic editor detection -### archgate session-context copilot +Every supported editor sets environment variables on the commands it runs, and Archgate reads them to work out which editor is asking. Pass `--editor` to override the result, or to read a different editor's transcripts. -Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. +| Editor | Detected by | Pins the exact session via | +| ----------- | ----------------------------- | --------------------------------------------------------------------------------------- | +| Antigravity | `ANTIGRAVITY_AGENT` | `ANTIGRAVITY_CONVERSATION_ID`, or `conversationId` inside `ANTIGRAVITY_SOURCE_METADATA` | +| Claude Code | `CLAUDECODE` | `CLAUDE_CODE_SESSION_ID` | +| Codex | `CODEX_THREAD_ID` | `CODEX_THREAD_ID` | +| Copilot CLI | `COPILOT_CLI` | `COPILOT_AGENT_SESSION_ID` | +| Cursor | `CURSOR_AGENT` | `CURSOR_CONVERSATION_ID` | +| opencode | `OPENCODE`, `OPENCODE_CLIENT` | _(none — falls back to recency)_ | +| Pi | `PI_CODING_AGENT` | `PI_SESSION_ID` | -```bash -archgate session-context copilot [options] -``` +Editors that publish their own session id get the **exact** conversation rather than the most recent one. This matters when a project has several sessions open at once, where the most recent may not be the conversation you are part of. A published id that matches no session for the project is ignored and recency applies, so a stale id never turns a working command into an error. -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | +A session id only ever pins the editor that published it. Passing `--editor cursor` from inside Claude Code reads Cursor's transcripts by recency and ignores `CLAUDE_CODE_SESSION_ID`. -#### archgate session-context copilot list +Every command reports what it resolved in a `detection` object: -List available Copilot CLI sessions for the project as JSON, most recent first. - -```bash -archgate session-context copilot list +```json +{ + "detection": { + "editor": "claude-code", + "via": "CLAUDECODE", + "session": "pinned", + "candidates": ["claude-code"] + }, + "sessionFile": "6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6.jsonl", + "totalEntries": 182, + "relevantEntries": 125, + "transcript": [] +} ``` -#### archgate session-context copilot show - -Read a specific session by UUID (from `list`). Accepts `--max-entries`. +`via` is the environment variable that identified the editor, or `--editor` when you named one. `session` is `pinned` when the editor's own session id was used, `recent` when the most recent session was taken, and `explicit` when a session id was passed on the command line. `candidates` lists every editor whose marker was present — more than one appears when an agent runs inside another agent. The winner then comes from a fixed order — Antigravity, Claude Code, Codex, Copilot, Cursor, Pi, then opencode — which ranks the editors that publish a session id ahead of the one that does not. That order applies whatever the ids happen to be: an empty or unusable id changes which session is selected, never which editor. -```bash -archgate session-context copilot show -``` +Detection fails when Archgate runs from a plain shell rather than inside an AI editor. The command then exits 1 and asks for `--editor`. -### archgate session-context cursor - -Read the current Cursor agent session transcript for the project. - -```bash -archgate session-context cursor [options] -``` - -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | - -#### archgate session-context cursor list - -List available Cursor agent sessions for the project as JSON, most recent first. - -```bash -archgate session-context cursor list -``` - -#### archgate session-context cursor show - -Read a specific session by UUID (from `list`). Accepts `--max-entries`. - -```bash -archgate session-context cursor show -``` - -### archgate session-context opencode +## Subcommands -Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. +### archgate session-context list -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --root` to resolve its top-level ancestor deterministically instead of relying on recency. +List available sessions for the project as JSON (`id`, `updatedAt`, and `title` for editors that store one), most recent first. Accepts `--editor`. ```bash -archgate session-context opencode [options] +archgate session-context list ``` -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | - -#### archgate session-context opencode list +### archgate session-context show -List available top-level opencode sessions for the project as JSON (`id`, `title`, `updatedAt`), most recent first. Sub-agent child sessions are excluded (they can still be read via `show`). +Read a specific session by ID (from `list`). An explicit ID always wins over the one published by the environment. Accepts `--editor`, `--max-entries`, and `--root`. ```bash -archgate session-context opencode list +archgate session-context show ``` -#### archgate session-context opencode show - -Read a specific session by ID (from `list`), including sub-agent child sessions. Accepts `--max-entries` and `--root` (resolve a sub-agent child session up to its top-level ancestor). +## How each editor stores sessions -```bash -archgate session-context opencode show [--root] -``` +- **Antigravity** — both the `agy` CLI and the desktop app write conversations as JSONL under `brain//.system_generated/logs/`, in `~/.gemini/antigravity-cli/` and `~/.gemini/antigravity/` respectively; both are read. The CLI records the workspace in each conversation's own database, the app in a shared summaries index. A conversation the caller is running inside is read even before that index catches up, since the environment names it. +- **Claude Code** — one JSONL transcript per session, keyed by the encoded project path. Session IDs are the transcript filenames. +- **Codex** — rollout files under date shards (`sessions/YYYY/MM/DD/`), shared by the Codex CLI and the desktop app. Sessions are matched by the `cwd` recorded in each rollout's `session_meta` line, and the session ID is the thread ID. The CLI and the desktop app record turns in different event shapes and both are read. Rollouts older than a week are zstd-compressed in place; both forms are read. Honors `CODEX_HOME`. +- **Copilot CLI** — sessions are matched by their workspace `cwd` field. +- **Cursor** — sessions are matched by the encoded project path; IDs are UUIDs. +- **Pi** — sessions live under a directory encoding the working directory, and each file's header `cwd` is verified as well, so a relocated session directory still resolves. Honors `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`. Pi branches a session in place rather than starting a new file, so only the active branch is read — a forked or rewound turn is left out. Pi publishes its session ID only to commands its agent runs, so a manually typed command is detected but selected by recency. +- **opencode** — sessions are matched by comparing the session `directory` field to the project root. Sub-agent runs are recorded as child sessions sharing the parent's directory; these are excluded from `list` and from recency selection, so the most recent top-level session is always the main development session. They can still be read by ID with `show`, and `--root` resolves a child session up to its top-level ancestor — useful when a sub-agent knows its own session ID and needs the conversation it belongs to. ## Examples -Read the current Claude Code session: +Read the current session, whichever editor is running: ```bash -archgate session-context claude-code +archgate session-context ``` -Read the current opencode session: +List sessions for the detected editor: ```bash -archgate session-context opencode +archgate session-context list ``` -List the project's Claude Code sessions: +Read another editor's current session: ```bash -archgate session-context claude-code list +archgate session-context --editor opencode ``` Read a specific earlier session: ```bash -archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context opencode show ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` diff --git a/src/cli.ts b/src/cli.ts index c2077426..52af809d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +17,7 @@ import { registerInitCommand } from "./commands/init"; import { registerLoginCommand } from "./commands/login"; import { registerPluginCommand } from "./commands/plugin/index"; import { registerReviewContextCommand } from "./commands/review-context"; -import { registerSessionContextCommand } from "./commands/session-context/index"; +import { registerSessionContextCommand } from "./commands/session-context"; import { registerTelemetryCommand } from "./commands/telemetry"; import { registerUpgradeCommand } from "./commands/upgrade"; import { cleanupStaleBinary } from "./helpers/binary-upgrade"; diff --git a/src/commands/session-context.ts b/src/commands/session-context.ts new file mode 100644 index 00000000..79ae8b76 --- /dev/null +++ b/src/commands/session-context.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import type { Command } from "@commander-js/extra-typings"; +import { InvalidArgumentError, Option } from "@commander-js/extra-typings"; + +import { exitWith, handleCommandError } from "../helpers/exit"; +import { DETECTED_HARNESSES } from "../helpers/harness-detect"; +import type { DetectedHarness } from "../helpers/harness-detect"; +import { logError } from "../helpers/log"; +import { formatJSON } from "../helpers/output"; +import { findProjectRoot } from "../helpers/paths"; +import { + listAutoSessions, + readAutoSession, + readAutoSessionById, +} from "../helpers/session-context-auto"; + +/** + * Shared `--editor` option. Omitted, the editor is resolved from the + * environment of the AI editor running the command. + * + * Choices come from the detection layer's own list, so `--editor` can neither + * offer an editor detection does not know nor omit one it does. + */ +const makeEditorOption = () => + new Option( + "--editor ", + "editor to read (default: detected from the environment)" + ).choices(DETECTED_HARNESSES); + +/** + * Parse `--max-entries`, rejecting non-numeric or non-positive input — a NaN + * limit would silently disable transcript trimming downstream. + * + * @throws {InvalidArgumentError} When the value is not a positive integer. + */ +export function parseMaxEntries(val: string): number { + const n = Math.trunc(Number(val)); + if (!Number.isFinite(n) || n < 1) { + throw new InvalidArgumentError("must be a positive integer"); + } + return n; +} + +/** Shared `--max-entries` option. */ +const makeMaxEntriesOption = () => + new Option( + "--max-entries ", + "maximum entries to return (default: 200)" + ).argParser(parseMaxEntries); + +interface SharedOptions { + maxEntries?: number; + editor?: DetectedHarness; + root?: boolean; +} + +/** + * Merge an option declared on both `session-context` and its subcommand. + * Commander hoists parent-known options from anywhere on the command line, so + * the flag is often parsed by the parent; the child value wins when present. + * Every option both levels declare must be read through this, or the parent + * silently swallows it and the subcommand sees `undefined`. + */ +function withGlobals( + key: K, + opts: SharedOptions, + command: { optsWithGlobals: () => SharedOptions } +) { + return opts[key] ?? command.optsWithGlobals()[key]; +} + +export function registerSessionContextCommand(program: Command) { + const cmd = program + .command("session-context") + .description( + "Read the current AI editor session transcript for the project" + ) + .addOption(makeEditorOption()) + .addOption(makeMaxEntriesOption()) + .option( + "--root", + "opencode only: resolve a sub-agent child session up to its top-level ancestor" + ) + .action(async (opts) => { + try { + const projectRoot = findProjectRoot(); + const result = await readAutoSession(projectRoot, { + maxEntries: opts.maxEntries, + editor: opts.editor, + root: opts.root, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log( + formatJSON({ detection: result.detection, ...result.data }) + ); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available sessions for the project") + .addOption(makeEditorOption()) + .action(async (opts, command) => { + try { + const projectRoot = findProjectRoot(); + const result = await listAutoSessions(projectRoot, { + editor: withGlobals("editor", opts, command), + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log( + formatJSON({ detection: result.detection, sessions: result.sessions }) + ); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific session by ID") + .argument("", "session ID from `list`") + .addOption(makeEditorOption()) + .addOption(makeMaxEntriesOption()) + .option( + "--root", + "opencode only: resolve a sub-agent child session up to its top-level ancestor" + ) + .action(async (sessionId, opts, command) => { + try { + const projectRoot = findProjectRoot(); + const result = await readAutoSessionById(projectRoot, sessionId, { + maxEntries: withGlobals("maxEntries", opts, command), + editor: withGlobals("editor", opts, command), + root: withGlobals("root", opts, command), + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log( + formatJSON({ detection: result.detection, ...result.data }) + ); + } catch (err) { + await handleCommandError(err); + } + }); +} diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts deleted file mode 100644 index a9bca492..00000000 --- a/src/commands/session-context/claude-code.ts +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import type { Command } from "@commander-js/extra-typings"; -import { InvalidArgumentError, Option } from "@commander-js/extra-typings"; - -import { exitWith, handleCommandError } from "../../helpers/exit"; -import { logError } from "../../helpers/log"; -import { formatJSON } from "../../helpers/output"; -import { findProjectRoot } from "../../helpers/paths"; -import { - listClaudeCodeSessions, - readClaudeCodeSession, -} from "../../helpers/session-context"; - -/** - * Shared `--max-entries` option factory for the session-context commands. - * Exported from this command file (not a helper) per the cross-command I/O - * sharing convention. Rejects non-numeric or non-positive input — a NaN - * limit would silently disable transcript trimming downstream. - */ -export const makeMaxEntriesOption = () => - new Option( - "--max-entries ", - "maximum entries to return (default: 200)" - ).argParser((val) => { - const n = Math.trunc(Number(val)); - if (!Number.isFinite(n) || n < 1) { - throw new InvalidArgumentError("must be a positive integer"); - } - return n; - }); - -/** - * Resolve `--max-entries` for the nested `show` subcommands. The parent - * editor command declares the option too, and commander hoists - * parent-known options from anywhere on the command line — so the flag - * is usually parsed by the parent, not the child. optsWithGlobals() - * merges the ancestor values (child value wins when present). - */ -export function resolveMaxEntries( - opts: { maxEntries?: number }, - command: { optsWithGlobals: () => { maxEntries?: number } } -): number | undefined { - return opts.maxEntries ?? command.optsWithGlobals().maxEntries; -} - -export function registerClaudeCodeSessionContextCommand(parent: Command) { - const cmd = parent - .command("claude-code") - .description( - "Read the current Claude Code session transcript for the project" - ) - .addOption(makeMaxEntriesOption()) - .action(async (opts) => { - try { - const projectRoot = findProjectRoot(); - const result = await readClaudeCodeSession(projectRoot, { - maxEntries: opts.maxEntries, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("list") - .description("List available Claude Code sessions for the project") - .action(async () => { - try { - const projectRoot = findProjectRoot(); - const result = await listClaudeCodeSessions(projectRoot); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("show") - .description("Read a specific Claude Code session by ID") - .argument("", "session ID from `list`") - .addOption(makeMaxEntriesOption()) - .action(async (sessionId, opts, command) => { - try { - const projectRoot = findProjectRoot(); - const result = await readClaudeCodeSession(projectRoot, { - maxEntries: resolveMaxEntries(opts, command), - sessionId, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); -} diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts deleted file mode 100644 index 7cafd595..00000000 --- a/src/commands/session-context/copilot.ts +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import type { Command } from "@commander-js/extra-typings"; - -import { exitWith, handleCommandError } from "../../helpers/exit"; -import { logError } from "../../helpers/log"; -import { formatJSON } from "../../helpers/output"; -import { findProjectRoot } from "../../helpers/paths"; -import { - listCopilotSessions, - readCopilotSession, -} from "../../helpers/session-context-copilot"; -import { makeMaxEntriesOption, resolveMaxEntries } from "./claude-code"; - -export function registerCopilotSessionContextCommand(parent: Command) { - const cmd = parent - .command("copilot") - .description( - "Read the current Copilot CLI session transcript for the project" - ) - .addOption(makeMaxEntriesOption()) - .action(async (opts) => { - try { - const projectRoot = findProjectRoot(); - const result = await readCopilotSession(projectRoot, { - maxEntries: opts.maxEntries, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("list") - .description("List available Copilot CLI sessions for the project") - .action(async () => { - try { - const projectRoot = findProjectRoot(); - const result = await listCopilotSessions(projectRoot); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("show") - .description("Read a specific Copilot CLI session by UUID") - .argument("", "session UUID from `list`") - .addOption(makeMaxEntriesOption()) - .action(async (sessionId, opts, command) => { - try { - const projectRoot = findProjectRoot(); - const result = await readCopilotSession(projectRoot, { - maxEntries: resolveMaxEntries(opts, command), - sessionId, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); -} diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts deleted file mode 100644 index 31e8df3a..00000000 --- a/src/commands/session-context/cursor.ts +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import type { Command } from "@commander-js/extra-typings"; - -import { exitWith, handleCommandError } from "../../helpers/exit"; -import { logError } from "../../helpers/log"; -import { formatJSON } from "../../helpers/output"; -import { findProjectRoot } from "../../helpers/paths"; -import { - listCursorSessions, - readCursorSession, -} from "../../helpers/session-context"; -import { makeMaxEntriesOption, resolveMaxEntries } from "./claude-code"; - -export function registerCursorSessionContextCommand(parent: Command) { - const cmd = parent - .command("cursor") - .description( - "Read the current Cursor agent session transcript for the project" - ) - .addOption(makeMaxEntriesOption()) - .action(async (opts) => { - try { - const projectRoot = findProjectRoot(); - const result = await readCursorSession(projectRoot, { - maxEntries: opts.maxEntries, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("list") - .description("List available Cursor agent sessions for the project") - .action(async () => { - try { - const projectRoot = findProjectRoot(); - const result = await listCursorSessions(projectRoot); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("show") - .description("Read a specific Cursor agent session by UUID") - .argument("", "session UUID from `list`") - .addOption(makeMaxEntriesOption()) - .action(async (sessionId, opts, command) => { - try { - const projectRoot = findProjectRoot(); - const result = await readCursorSession(projectRoot, { - maxEntries: resolveMaxEntries(opts, command), - sessionId, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); -} diff --git a/src/commands/session-context/index.ts b/src/commands/session-context/index.ts deleted file mode 100644 index 66d527ef..00000000 --- a/src/commands/session-context/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import type { Command } from "@commander-js/extra-typings"; - -import { registerClaudeCodeSessionContextCommand } from "./claude-code"; -import { registerCopilotSessionContextCommand } from "./copilot"; -import { registerCursorSessionContextCommand } from "./cursor"; -import { registerOpencodeSessionContextCommand } from "./opencode"; - -export function registerSessionContextCommand(program: Command) { - const sessionContext = program - .command("session-context") - .description("Read AI editor session transcripts"); - - registerClaudeCodeSessionContextCommand(sessionContext); - registerCopilotSessionContextCommand(sessionContext); - registerCursorSessionContextCommand(sessionContext); - registerOpencodeSessionContextCommand(sessionContext); -} diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts deleted file mode 100644 index c0ce6a46..00000000 --- a/src/commands/session-context/opencode.ts +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import type { Command } from "@commander-js/extra-typings"; - -import { exitWith, handleCommandError } from "../../helpers/exit"; -import { logError } from "../../helpers/log"; -import { formatJSON } from "../../helpers/output"; -import { findProjectRoot } from "../../helpers/paths"; -import { - listOpencodeSessions, - readOpencodeSession, -} from "../../helpers/session-context-opencode"; -import { makeMaxEntriesOption, resolveMaxEntries } from "./claude-code"; - -export function registerOpencodeSessionContextCommand(parent: Command) { - const cmd = parent - .command("opencode") - .description("Read the current opencode session transcript for the project") - .addOption(makeMaxEntriesOption()) - .action(async (opts) => { - try { - const projectRoot = findProjectRoot(); - const result = readOpencodeSession(projectRoot, { - maxEntries: opts.maxEntries, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("list") - .description("List available top-level opencode sessions for the project") - .action(async () => { - try { - const projectRoot = findProjectRoot(); - const result = listOpencodeSessions(projectRoot); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); - - cmd - .command("show") - .description("Read a specific opencode session by ID") - .argument("", "session ID from `list`") - .addOption(makeMaxEntriesOption()) - .option( - "--root", - "resolve a sub-agent child session up to its top-level ancestor" - ) - .action(async (sessionId, opts, command) => { - try { - const projectRoot = findProjectRoot(); - const result = readOpencodeSession(projectRoot, { - maxEntries: resolveMaxEntries(opts, command), - sessionId, - root: opts.root, - }); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); -} diff --git a/src/helpers/harness-detect.ts b/src/helpers/harness-detect.ts new file mode 100644 index 00000000..194e66a8 --- /dev/null +++ b/src/helpers/harness-detect.ts @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * harness-detect.ts — Identify which AI editor is running the CLI, from the + * environment variables that harness injects into the processes it spawns. + * + * Distinct from `editor-detect.ts`, which asks whether an editor is + * *installed*; an installed-but-idle editor must never register here. + */ + +import { z } from "zod"; + +import { usableEnv } from "./paths"; + +/** + * Every editor this CLI can read sessions for. The single source of truth: + * {@link DetectedHarness} is derived from it, so a command offering these as + * choices cannot drift from what detection recognizes. + */ +export const DETECTED_HARNESSES = [ + "antigravity", + "claude-code", + "codex", + "copilot", + "cursor", + "opencode", + "pi", +] as const; + +export type DetectedHarness = (typeof DETECTED_HARNESSES)[number]; + +export interface HarnessDetection { + /** Winning harness under {@link SIGNALS} precedence, or null when none matched. */ + editor: DetectedHarness | null; + /** Env var that decided `editor`, for output attribution. */ + via: string | null; + /** Every harness whose marker is present, in precedence order. */ + candidates: DetectedHarness[]; + /** Session id the winning harness published, when it publishes one. */ + envSessionId: string | null; +} + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +interface HarnessSignal { + editor: DetectedHarness; + /** Presence of any one of these marks the harness as running. */ + markers: string[]; + /** Env var carrying this harness's own session id, when it has one. */ + sessionIdVar?: string; + /** + * Env var holding JSON with the session id nested inside, consulted when + * {@link HarnessSignal.sessionIdVar} is unset. + */ + nestedSessionId?: { variable: string; path: string[] }; + /** + * Require a UUID-shaped session id. Set only for Cursor, whose + * `getSafeConversationId` rewrites `%` to `_`: lossless for a UUID, but + * one-way otherwise, where the rewritten value could collide with a + * different real session id and pin the wrong transcript. + */ + requireUuidSessionId?: boolean; +} + +/** + * Detection signals in precedence order, applied when a nested setup sets + * more than one marker (an agent shelling out to another agent). The + * environment carries no nesting order, so the tie is broken by evidence + * strength: harnesses that also publish a session id rank above the one that + * does not. Every match is still reported in `candidates`. + */ +const SIGNALS: HarnessSignal[] = [ + { + // Both the `agy` CLI and the desktop app set the marker. The CLI names + // the conversation in a flat variable; the app only nests it in JSON. + editor: "antigravity", + markers: ["ANTIGRAVITY_AGENT"], + sessionIdVar: "ANTIGRAVITY_CONVERSATION_ID", + nestedSessionId: { + variable: "ANTIGRAVITY_SOURCE_METADATA", + path: ["tool", "conversationId"], + }, + }, + { + editor: "claude-code", + markers: ["CLAUDECODE"], + sessionIdVar: "CLAUDE_CODE_SESSION_ID", + }, + { + // Codex injects the thread id after applying its sandbox env policy, so + // the marker survives filtering. `CODEX_SANDBOX` is macOS-only and would + // miss Linux and Windows entirely. + editor: "codex", + markers: ["CODEX_THREAD_ID"], + sessionIdVar: "CODEX_THREAD_ID", + }, + { + editor: "copilot", + markers: ["COPILOT_CLI"], + sessionIdVar: "COPILOT_AGENT_SESSION_ID", + }, + { + editor: "cursor", + markers: ["CURSOR_AGENT"], + sessionIdVar: "CURSOR_CONVERSATION_ID", + requireUuidSessionId: true, + }, + { + // Pi marks every child process, but publishes the session id only to its + // agent's bash tool — a user-typed `!` command sees the marker alone. + editor: "pi", + markers: ["PI_CODING_AGENT"], + sessionIdVar: "PI_SESSION_ID", + }, + { editor: "opencode", markers: ["OPENCODE", "OPENCODE_CLIENT"] }, +]; + +const JsonObjectSchema = z.record(z.string(), z.unknown()); + +/** A session id is usable only as a non-empty string. */ +const SessionIdSchema = z.string().min(1); + +/** + * Follow `path` into the JSON held by an environment variable, returning the + * string at the end of it. + * + * Antigravity's desktop app leaves its flat id variable unset and names the + * conversation only inside a JSON blob, so without this the app would be + * detected but never pinned. + */ +export function nestedStringFromJsonEnv( + variable: string, + path: string[] +): string | null { + const raw = usableEnv(Bun.env[variable]); + if (raw === null) return null; + + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return null; + } + for (const key of path) { + const object = JsonObjectSchema.safeParse(value); + if (!object.success) return null; + value = object.data[key]; + } + const leaf = SessionIdSchema.safeParse(value); + return leaf.success ? leaf.data : null; +} + +/** Session id nested in a JSON-valued env var, when the signal declares one. */ +function readNestedSessionId(signal: HarnessSignal): string | null { + if (signal.nestedSessionId === undefined) return null; + return nestedStringFromJsonEnv( + signal.nestedSessionId.variable, + signal.nestedSessionId.path + ); +} + +/** The env var that marks `signal` as running, or null when none is set. */ +function matchedMarker(signal: HarnessSignal): string | null { + for (const marker of signal.markers) { + if (usableEnv(Bun.env[marker]) !== null) return marker; + } + return null; +} + +/** + * Read a harness's published session id. + * + * Routed through `usableEnv` so an empty or literal-"undefined" value becomes + * null rather than `""` — the session readers treat `sessionId: ""` exactly + * like `undefined` and silently fall back to recency, which would make an + * unset variable indistinguishable from a rejected one. + */ +function readSessionId(signal: HarnessSignal): string | null { + const flat = + signal.sessionIdVar === undefined + ? null + : usableEnv(Bun.env[signal.sessionIdVar]); + const value = flat ?? readNestedSessionId(signal); + if (value === null) return null; + if (signal.requireUuidSessionId === true && !UUID_PATTERN.test(value)) { + return null; + } + return value; +} + +/** + * Resolve the AI editor running this process from its environment. + * + * @returns The winning harness with the var that identified it and any + * session id it published; `editor` is null when no marker is present (a + * plain shell), leaving the caller to require an explicit editor. + */ +export function detectHarness(): HarnessDetection { + const candidates: DetectedHarness[] = []; + let winner: { signal: HarnessSignal; via: string } | null = null; + + for (const signal of SIGNALS) { + const marker = matchedMarker(signal); + if (marker === null) continue; + candidates.push(signal.editor); + winner ??= { signal, via: marker }; + } + + if (winner === null) { + return { editor: null, via: null, candidates: [], envSessionId: null }; + } + + return { + editor: winner.signal.editor, + via: winner.via, + candidates, + envSessionId: readSessionId(winner.signal), + }; +} diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 00db8fb8..e64667f7 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -8,11 +8,11 @@ import { logDebug } from "./log"; import { UserError } from "./user-error"; /** - * Resolves the user home directory for ~/.archgate paths. - * Ignores empty env and the literal string "undefined" (mis-set env / tooling bugs) - * so path.join does not create a ./undefined/.archgate tree under cwd. + * Resolve the user's home directory, the base for every user-scope path here. + * Ignores empty env and the literal string "undefined" (mis-set env / tooling + * bugs) so path.join does not create a ./undefined tree under cwd. */ -function archgateHomeDir(): string { +function userHomeDir(): string { const fromEnv = Bun.env.HOME ?? Bun.env.USERPROFILE; if ( typeof fromEnv === "string" && @@ -25,17 +25,17 @@ function archgateHomeDir(): string { } export function internalPath(...path: string[]) { - const internalFolder = join(archgateHomeDir(), ".archgate"); + const internalFolder = join(userHomeDir(), ".archgate"); return join(internalFolder, ...path); } /** * Accept an env-var value only when it is a non-empty string that isn't the - * literal "undefined". Mirrors the defensive handling in `archgateHomeDir()` + * literal "undefined". Mirrors the defensive handling in `userHomeDir()` * — shells and tooling sometimes surface an unset variable as the string * "undefined", which would otherwise leak into the resolved path. */ -function usableEnv(value: string | undefined): string | null { +export function usableEnv(value: string | undefined): string | null { if (typeof value !== "string") return null; if (value.length === 0 || value === "undefined") return null; return value; @@ -50,7 +50,7 @@ function usableEnv(value: string | undefined): string | null { */ export function opencodeConfigDir(): string { const xdg = usableEnv(Bun.env.XDG_CONFIG_HOME); - const base = xdg ?? join(archgateHomeDir(), ".config"); + const base = xdg ?? join(userHomeDir(), ".config"); return join(base, "opencode"); } @@ -67,7 +67,7 @@ export function opencodeAgentsDir(): string { export function copilotConfigDir(): string { const override = usableEnv(Bun.env.COPILOT_HOME); if (override !== null) return override; - return join(archgateHomeDir(), ".copilot"); + return join(userHomeDir(), ".copilot"); } /** @@ -89,10 +89,82 @@ export function copilotSessionStateDir(): string { */ export function opencodeDbPath(): string { const xdg = usableEnv(Bun.env.XDG_DATA_HOME); - const base = xdg ?? join(archgateHomeDir(), ".local", "share"); + const base = xdg ?? join(userHomeDir(), ".local", "share"); return join(base, "opencode", "opencode.db"); } +/** + * Resolve the Antigravity CLI (`agy`) data directory, `~/.gemini/antigravity-cli/`. + * + * Distinct from the Antigravity IDE's `~/.gemini/antigravity/`, which stores + * its transcripts encrypted and is therefore unreadable. + */ +export function antigravityCliDir(): string { + return join(userHomeDir(), ".gemini", "antigravity-cli"); +} + +/** Resolve the Antigravity CLI conversation directory. */ +export function antigravityConversationsDir(): string { + return join(antigravityCliDir(), "conversations"); +} + +/** + * Resolve the Antigravity desktop app's data directory, + * `~/.gemini/antigravity/`. + */ +export function antigravityIdeDir(): string { + return join(userHomeDir(), ".gemini", "antigravity"); +} + +/** + * Both Antigravity data directories. The CLI and the desktop app each write + * their own conversations, and both are readable. + */ +export function antigravityDataDirs(): string[] { + return [antigravityCliDir(), antigravityIdeDir()]; +} + +/** + * Resolve the Codex home directory, honoring `CODEX_HOME` and defaulting to + * `~/.codex/`. Shared by the Codex CLI and the desktop/IDE app, which both + * resolve it through the same helper. + */ +export function codexHomeDir(): string { + const override = usableEnv(Bun.env.CODEX_HOME); + if (override !== null) return override; + return join(userHomeDir(), ".codex"); +} + +/** + * Resolve the Codex rollout directory. Sessions live under date shards + * (`sessions/YYYY/MM/DD/`); `archived_sessions/` is a sibling tree that + * `session-context` does not read. + */ +export function codexSessionsDir(): string { + return join(codexHomeDir(), "sessions"); +} + +/** + * Resolve the Pi agent directory, honoring `PI_CODING_AGENT_DIR` and + * defaulting to `~/.pi/agent/`. + */ +export function piAgentDir(): string { + const override = usableEnv(Bun.env.PI_CODING_AGENT_DIR); + if (override !== null) return override; + return join(userHomeDir(), ".pi", "agent"); +} + +/** + * Resolve the Pi session directory. `PI_CODING_AGENT_SESSION_DIR` relocates + * sessions independently of the agent directory, matching Pi's own + * precedence. + */ +export function piSessionsDir(): string { + const override = usableEnv(Bun.env.PI_CODING_AGENT_SESSION_DIR); + if (override !== null) return override; + return join(piAgentDir(), "sessions"); +} + /** * Resolve the Cursor user-scope config directory (`~/.cursor/`). * @@ -103,7 +175,7 @@ export function opencodeDbPath(): string { * Resolved at call time (not cached) so tests can override HOME. */ export function cursorUserDir(): string { - return join(archgateHomeDir(), ".cursor"); + return join(userHomeDir(), ".cursor"); } export const paths = { cacheFolder: internalPath("cache") } as const; diff --git a/src/helpers/session-context-antigravity.ts b/src/helpers/session-context-antigravity.ts new file mode 100644 index 00000000..667395f7 --- /dev/null +++ b/src/helpers/session-context-antigravity.ts @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * session-context-antigravity.ts — Read Antigravity CLI (`agy`) transcripts. + * + * The CLI writes each conversation's turns as JSONL under + * `brain//.system_generated/logs/`, and keeps the workspace it belongs to + * in a SQLite database under `conversations/`. The Antigravity IDE keeps a + * separate store whose transcripts are encrypted at rest and cannot be read. + */ + +import { Database } from "bun:sqlite"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { z } from "zod"; + +import { nestedStringFromJsonEnv } from "./harness-detect"; +import { logDebug } from "./log"; +import { + antigravityCliDir, + antigravityConversationsDir, + antigravityDataDirs, + usableEnv, +} from "./paths"; +import { + type ReadSessionOptions, + type SessionListEntry, + type SessionListResult, + normalizePath, + sessionReadFailure, +} from "./session-context"; + +/** + * Transcript entry kinds that represent a conversation turn. + * + * `PLANNER_RESPONSE` is the agent's own reply; it carries empty content when + * the turn was purely a tool call, so an empty body is skipped rather than + * emitted as a blank turn. Every other kind is a tool call or its result. + */ +const TURN_ROLES = new Map([ + ["USER_INPUT", "user"], + ["PLANNER_RESPONSE", "assistant"], +]); + +/** + * The CLI wraps a user turn's text in ``, and may append + * `` after the closing tag, so the wrapped span is + * extracted rather than the tags stripped from the ends. + */ +const USER_REQUEST_BODY = /\n?([\s\S]*?)\n?<\/USER_REQUEST>/u; + +const MAX_PREVIEW = 300; + +const TranscriptEntrySchema = z.object({ + type: z.string().default(""), + content: z.string().default(""), +}); + +/** Row of `trajectory_metadata_blob`, holding the workspace URI. */ +interface MetadataRow { + data: Uint8Array | null; +} + +/** Row of the shared summaries index. */ +interface SummaryRow { + conversation_id: string; + workspace_uris: string; +} + +interface AntigravitySessionSummary { + sessionId: string; + sessionFile: string; + totalEntries: number; + relevantEntries: number; + transcript: Array<{ role: string; contentPreview: string }>; +} + +interface ReadAntigravitySessionOptions extends ReadSessionOptions { + sessionId?: string; +} + +type AntigravitySessionResult = + | { ok: true; data: AntigravitySessionSummary } + | { ok: false; error: string; path?: string; available?: string[] }; + +/** + * Transcript filenames, most complete first. The CLI writes both; the desktop + * app writes only the truncated one. + */ +const TRANSCRIPT_NAMES = ["transcript_full.jsonl", "transcript.jsonl"]; + +/** Directory holding a conversation's generated logs within a data directory. */ +function logsDir(dataDir: string, conversationId: string): string { + return join(dataDir, "brain", conversationId, ".system_generated", "logs"); +} + +/** + * Locate a conversation's transcript across both data directories. + * + * A conversation belongs to whichever distribution created it, and the two + * write to separate trees, so both are searched. + */ +function transcriptPath(conversationId: string): string | null { + for (const dataDir of antigravityDataDirs()) { + for (const name of TRANSCRIPT_NAMES) { + const candidate = join(logsDir(dataDir, conversationId), name); + if (existsSync(candidate)) return candidate; + } + } + return null; +} + +/** Every conversation with a readable transcript, in either data directory. */ +function conversationsWithTranscripts(): string[] { + const ids = new Set(); + for (const dataDir of antigravityDataDirs()) { + const brain = join(dataDir, "brain"); + let entries: string[]; + try { + entries = readdirSync(brain); + } catch { + continue; + } + for (const id of entries) { + if (transcriptPath(id) !== null) ids.add(id); + } + } + return [...ids]; +} + +/** + * A `file:///` URI, bounded to URI-legal characters. The CLI embeds one in a + * protobuf blob, so a permissive class runs past the string's end into the + * following tag bytes and yields a path that matches nothing. + */ +const FILE_URI = /file:\/\/\/[A-Za-z0-9%._~!$&'()*+,;=:@/-]+/u; + +/** Decode a `file:///` URI into a plain path. */ +function pathFromUri(uri: string): string | null { + try { + return decodeURIComponent(uri.replace(/^file:\/\/\//u, "")); + } catch { + return null; + } +} + +/** Workspace recorded in a CLI conversation's own database. */ +function workspaceFromDb(file: string): string | null { + let db: Database; + try { + db = new Database(file, { readonly: true }); + } catch { + return null; + } + + try { + const row = db + .query("SELECT data FROM trajectory_metadata_blob") + .get(); + if (row?.data === null || row?.data === undefined) return null; + const text = new TextDecoder("utf-8", { fatal: false }).decode(row.data); + const match = FILE_URI.exec(text); + return match === null ? null : pathFromUri(match[0]); + } catch { + return null; + } finally { + db.close(); + } +} + +/** + * Every workspace recorded in the shared summaries index, keyed by + * conversation. The index covers the desktop app's conversations and lags a + * live one, so it is a fallback rather than the primary source. + */ +function summaryWorkspaces(): Map { + const file = join(antigravityCliDir(), "conversation_summaries.db"); + if (!existsSync(file)) return new Map(); + + let db: Database; + try { + db = new Database(file, { readonly: true }); + } catch { + return new Map(); + } + + const workspaces = new Map(); + try { + const rows = db + .query( + "SELECT conversation_id, workspace_uris FROM conversation_summaries" + ) + .all(); + for (const row of rows) { + // The column holds a JSON array of URIs; the first match is its first + // element, so the URI is taken directly rather than parsed out. + const match = FILE_URI.exec(row.workspace_uris); + const path = match === null ? null : pathFromUri(match[0]); + if (path !== null) workspaces.set(row.conversation_id, path); + } + } catch { + return new Map(); + } finally { + db.close(); + } + return workspaces; +} + +/** + * Workspace a conversation belongs to, from whichever store records it. + * + * @param summaries - The shared index, read once for the whole scan; opening + * it per conversation would make discovery cost grow with the history. + */ +function workspaceFor( + conversationId: string, + summaries: Map +): string | null { + const cliDb = join(antigravityConversationsDir(), `${conversationId}.db`); + if (existsSync(cliDb)) { + const fromDb = workspaceFromDb(cliDb); + if (fromDb !== null) return fromDb; + } + return summaries.get(conversationId) ?? null; +} + +/** + * Conversation the caller is running inside. + * + * The CLI names it in a flat variable; the desktop app nests it in JSON. A + * live conversation is not always indexed yet, so it is admitted even when + * its workspace cannot be resolved — it is the caller's own by definition. + */ +function currentConversationId(): string | null { + const flat = usableEnv(Bun.env.ANTIGRAVITY_CONVERSATION_ID); + if (flat !== null) return flat; + + return nestedStringFromJsonEnv("ANTIGRAVITY_SOURCE_METADATA", [ + "tool", + "conversationId", + ]); +} + +interface AntigravityConversation { + id: string; + file: string; + mtime: number; +} + +/** Conversations belonging to a project, most recent first. */ +function findConversations( + projectRoot: string | null +): AntigravityConversation[] | null { + // Either tree counts: `brain` holds transcripts, `conversations` the + // per-conversation databases. One without the other still means Antigravity + // is installed, which is a different answer from having no conversations. + const hasStore = antigravityDataDirs().some( + (d) => existsSync(join(d, "brain")) || existsSync(join(d, "conversations")) + ); + if (!hasStore) return null; + + const target = normalizePath(projectRoot ?? process.cwd()); + const current = currentConversationId(); + const summaries = summaryWorkspaces(); + + const found: AntigravityConversation[] = []; + for (const id of conversationsWithTranscripts()) { + const workspace = workspaceFor(id, summaries); + const matches = workspace !== null && normalizePath(workspace) === target; + if (!matches && id !== current) continue; + const file = transcriptPath(id); + if (file === null) continue; + const stat = statSync(file, { throwIfNoEntry: false }); + if (stat === undefined) continue; + const mtime = stat.mtimeMs; + found.push({ id, file, mtime }); + } + + return found.sort((a, b) => b.mtime - a.mtime); +} + +/** + * List Antigravity CLI conversations for a project, most recent first. + * + * @param projectRoot - Project to read conversations for; `null` falls back + * to cwd. + */ +export function listAntigravitySessions( + projectRoot: string | null +): SessionListResult { + // Both trees are searched, so reporting only one would point troubleshooting + // at a directory the caller's distribution never writes to. + const dir = antigravityDataDirs().join(", "); + const conversations = findConversations(projectRoot); + if (conversations === null) { + return { + ok: false, + error: "No Antigravity conversations directory found", + path: dir, + }; + } + + const sessions: SessionListEntry[] = conversations.map((c) => ({ + id: c.id, + updatedAt: new Date(c.mtime).toISOString(), + })); + return { ok: true, data: { sessions } }; +} + +/** + * Read an Antigravity CLI conversation transcript for a project. + * + * @param projectRoot - Project to read conversations for; `null` falls back + * to cwd. + * @param options - `sessionId` selects a conversation by id; `maxEntries` + * caps returned transcript entries. + */ +export async function readAntigravitySession( + projectRoot: string | null, + options?: ReadAntigravitySessionOptions +): Promise { + const limit = options?.maxEntries ?? 200; + // Both trees are searched, so reporting only one would point troubleshooting + // at a directory the caller's distribution never writes to. + const dir = antigravityDataDirs().join(", "); + const conversations = findConversations(projectRoot); + if (conversations === null) { + return { + ok: false, + error: "No Antigravity conversations directory found", + path: dir, + }; + } + if (conversations.length === 0) { + return { + ok: false, + error: "No Antigravity conversations found for this project", + path: dir, + }; + } + + const requested = options?.sessionId; + const target = + requested !== undefined && requested !== "" + ? conversations.find((c) => c.id === requested) + : conversations[0]; + + if (!target) { + return { + ok: false, + error: `Session not found: ${requested ?? ""}`, + available: conversations.map((c) => c.id), + }; + } + + const file = target.file; + logDebug("Reading Antigravity transcript", file); + const raw = await Bun.file(file) + .text() + .catch(() => null); + if (raw === null) return sessionReadFailure(file); + + // Bun.JSONL.parse drops a trailing partial line, which a conversation being + // appended to right now will have. + const lines = Bun.JSONL.parse(raw); + const transcript: AntigravitySessionSummary["transcript"] = []; + for (const line of lines) { + const entry = TranscriptEntrySchema.safeParse(line); + if (!entry.success) continue; + const role = TURN_ROLES.get(entry.data.type); + if (role === undefined) continue; + const wrapped = USER_REQUEST_BODY.exec(entry.data.content); + const text = (wrapped?.[1] ?? entry.data.content).trim(); + // A planner turn that only made a tool call carries no prose. + if (text === "") continue; + transcript.push({ + role, + contentPreview: + text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW)}...` : text, + }); + } + + const trimmed = + transcript.length > limit ? transcript.slice(-limit) : transcript; + return { + ok: true, + data: { + sessionId: target.id, + sessionFile: basename(file), + totalEntries: lines.length, + relevantEntries: transcript.length, + transcript: trimmed, + }, + }; +} diff --git a/src/helpers/session-context-auto.ts b/src/helpers/session-context-auto.ts new file mode 100644 index 00000000..9aac3aae --- /dev/null +++ b/src/helpers/session-context-auto.ts @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * session-context-auto.ts — Read a session transcript for whichever AI editor + * is running the CLI, resolved by {@link detectHarness}. + * + * Dispatches to the per-editor readers unchanged and adds a `detection` block + * to their payload so the caller can see which editor answered and whether + * the session was pinned or chosen by recency. + */ + +import type { DetectedHarness } from "./harness-detect"; +import { detectHarness } from "./harness-detect"; +import { + listClaudeCodeSessions, + listCursorSessions, + readClaudeCodeSession, + readCursorSession, +} from "./session-context"; +import type { SessionListEntry, SessionListResult } from "./session-context"; +import { + listAntigravitySessions, + readAntigravitySession, +} from "./session-context-antigravity"; +import { listCodexSessions, readCodexSession } from "./session-context-codex"; +import { + listCopilotSessions, + readCopilotSession, +} from "./session-context-copilot"; +import { + listOpencodeSessions, + readOpencodeSession, +} from "./session-context-opencode"; +import { listPiSessions, readPiSession } from "./session-context-pi"; +import { UserError } from "./user-error"; + +/** How the returned session was chosen. */ +export type SessionSelection = "pinned" | "recent" | "explicit"; + +export interface AutoDetectionInfo { + editor: DetectedHarness; + via: string; + session: SessionSelection; + candidates: DetectedHarness[]; +} + +type ReadResult = + | Awaited> + | Awaited> + | Awaited> + | ReturnType; + +type AutoReadResult = + | { ok: true; detection: AutoDetectionInfo; data: object } + | { ok: false; error: string; path?: string; available?: string[] }; + +type AutoListResult = + | { + ok: true; + detection: Omit; + sessions: SessionListEntry[]; + } + | { ok: false; error: string; path?: string }; + +interface ResolvedEditor { + editor: DetectedHarness; + via: string; + candidates: DetectedHarness[]; + envSessionId: string | null; +} + +/** + * Resolve which editor to read, preferring an explicit `--editor` over the + * environment. + * + * @param explicit - Editor named on the command line, when given. + * @throws {UserError} When nothing was named and no harness marker is present + * — archgate was run from a plain shell rather than inside an AI editor. + */ +function requireEditor(explicit?: DetectedHarness): ResolvedEditor { + const detection = detectHarness(); + + if (explicit !== undefined) { + return { + editor: explicit, + via: "--editor", + candidates: detection.candidates, + // A published session id belongs to the harness that published it, so + // it may only pin when the named editor is that same harness. + envSessionId: + detection.editor === explicit ? detection.envSessionId : null, + }; + } + + if (detection.editor === null || detection.via === null) { + throw new UserError( + "Could not detect the AI editor from the environment.", + "Name it with --editor ." + ); + } + + return { + editor: detection.editor, + via: detection.via, + candidates: detection.candidates, + envSessionId: detection.envSessionId, + }; +} + +interface ReadOptions { + maxEntries?: number; + sessionId?: string; + root?: boolean; +} + +/** + * Per-editor readers. Keying by `Record` makes a newly + * added editor a compile error here, so exhaustiveness needs no runtime + * fallback branch. opencode's readers are synchronous, and it is the only + * editor that understands `root` — it alone has a parent/child session graph. + */ +const LISTERS: Record< + DetectedHarness, + (projectRoot: string | null) => SessionListResult | Promise +> = { + antigravity: listAntigravitySessions, + "claude-code": listClaudeCodeSessions, + codex: listCodexSessions, + copilot: listCopilotSessions, + cursor: listCursorSessions, + opencode: listOpencodeSessions, + pi: listPiSessions, +}; + +const READERS: Record< + DetectedHarness, + ( + projectRoot: string | null, + options: ReadOptions + ) => ReadResult | Promise +> = { + "claude-code": async (root, o) => + readClaudeCodeSession(root, { + maxEntries: o.maxEntries, + sessionId: o.sessionId, + }), + copilot: async (root, o) => + readCopilotSession(root, { + maxEntries: o.maxEntries, + sessionId: o.sessionId, + }), + cursor: async (root, o) => + readCursorSession(root, { + maxEntries: o.maxEntries, + sessionId: o.sessionId, + }), + opencode: (root, o) => readOpencodeSession(root, o), + codex: async (root, o) => + readCodexSession(root, { + maxEntries: o.maxEntries, + sessionId: o.sessionId, + }), + pi: async (root, o) => + readPiSession(root, { maxEntries: o.maxEntries, sessionId: o.sessionId }), + antigravity: async (root, o) => + readAntigravitySession(root, { + maxEntries: o.maxEntries, + sessionId: o.sessionId, + }), +}; + +async function listFor( + editor: DetectedHarness, + projectRoot: string | null +): Promise { + return LISTERS[editor](projectRoot); +} + +async function readFor( + editor: DetectedHarness, + projectRoot: string | null, + options: ReadOptions +): Promise { + return READERS[editor](projectRoot, options); +} + +/** + * Reject `--root` for editors without a session graph, rather than accepting + * a flag that would silently do nothing. + * + * @throws {UserError} When `root` is set for any editor but opencode. + */ +function assertRootSupported(editor: DetectedHarness, root?: boolean): void { + if (root !== true || editor === "opencode") return; + throw new UserError( + `--root applies only to opencode, which has parent/child sessions; the ${editor} reader has none.` + ); +} + +/** + * Confirm an environment-supplied session id names a session that exists for + * this project, returning it only then. + * + * Readers hard-fail on an unknown `sessionId` and never fall back on their + * own, so probing the project-scoped list first keeps a stale or unrelated + * id harmless: it degrades to recency. + * + * @returns The id when it matches a listed session, otherwise undefined. + */ +async function resolvePinnedId( + editor: DetectedHarness, + projectRoot: string | null, + envSessionId: string | null +): Promise { + if (envSessionId === null) return undefined; + const listed = await listFor(editor, projectRoot); + if (!listed.ok) return undefined; + return listed.data.sessions.some((s) => s.id === envSessionId) + ? envSessionId + : undefined; +} + +/** + * Read the current session, pinning the exact one when the harness published + * a usable id. + * + * @param projectRoot - Project to read sessions for; `null` falls back to cwd. + * @param options - `editor` overrides detection; `maxEntries` caps returned + * transcript entries; `root` resolves an opencode child session to its + * top-level ancestor. + * @throws {UserError} When no editor was named and none could be detected. + */ +export async function readAutoSession( + projectRoot: string | null, + options?: { maxEntries?: number; editor?: DetectedHarness; root?: boolean } +): Promise { + const harness = requireEditor(options?.editor); + assertRootSupported(harness.editor, options?.root); + const pinned = await resolvePinnedId( + harness.editor, + projectRoot, + harness.envSessionId + ); + + const result = await readFor(harness.editor, projectRoot, { + maxEntries: options?.maxEntries, + sessionId: pinned, + root: options?.root, + }); + + if (!result.ok) return result; + + return { + ok: true, + detection: { + editor: harness.editor, + via: harness.via, + session: pinned === undefined ? "recent" : "pinned", + candidates: harness.candidates, + }, + data: result.data, + }; +} + +/** + * Read a specific session by id. An explicit id always wins over the one + * published by the environment. + * + * @throws {UserError} When no editor was named and none could be detected. + */ +export async function readAutoSessionById( + projectRoot: string | null, + sessionId: string, + options?: { maxEntries?: number; editor?: DetectedHarness; root?: boolean } +): Promise { + const harness = requireEditor(options?.editor); + assertRootSupported(harness.editor, options?.root); + const result = await readFor(harness.editor, projectRoot, { + maxEntries: options?.maxEntries, + sessionId, + root: options?.root, + }); + + if (!result.ok) return result; + + return { + ok: true, + detection: { + editor: harness.editor, + via: harness.via, + session: "explicit", + candidates: harness.candidates, + }, + data: result.data, + }; +} + +/** + * List sessions for the named or detected editor. + * + * @throws {UserError} When no editor was named and none could be detected. + */ +export async function listAutoSessions( + projectRoot: string | null, + options?: { editor?: DetectedHarness } +): Promise { + const harness = requireEditor(options?.editor); + const result = await listFor(harness.editor, projectRoot); + + if (!result.ok) return result; + + return { + ok: true, + detection: { + editor: harness.editor, + via: harness.via, + candidates: harness.candidates, + }, + sessions: result.data.sessions, + }; +} diff --git a/src/helpers/session-context-codex.ts b/src/helpers/session-context-codex.ts new file mode 100644 index 00000000..d2441b33 --- /dev/null +++ b/src/helpers/session-context-codex.ts @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * session-context-codex.ts — Read OpenAI Codex rollout transcripts. + * + * Rollouts live under `~/.codex/sessions/YYYY/MM/DD/`, shared by the Codex + * CLI and the desktop/IDE app. Each line is + * `{timestamp, type, payload}`; a `session_meta` line carries the `cwd` that + * ties the rollout to a project. + */ + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { z } from "zod"; + +import { logDebug } from "./log"; +import { codexSessionsDir } from "./paths"; +import { + type ReadSessionOptions, + type SessionListEntry, + type SessionListResult, + normalizePath, + sessionReadFailure, +} from "./session-context"; + +/** Rollouts older than a week are zstd-compressed in place by Codex. */ +const COMPRESSED_SUFFIX = ".zst"; + +/** `rollout--.jsonl[.zst]` */ +const ROLLOUT_NAME = + /^rollout-\d{4}-\d{2}-\d{2}T[\d-]{8}-(?.+?)\.jsonl(\.zst)?$/u; + +/** Lines scanned for `session_meta` before giving up on a rollout. */ +const META_SCAN_LINES = 50; + +const CodexLineSchema = z.object({ + type: z.string().default(""), + payload: z.record(z.string(), z.unknown()).optional(), +}); + +const SessionMetaPayloadSchema = z.object({ + id: z.string().optional(), + cwd: z.string().default(""), +}); + +const EventMsgPayloadSchema = z.object({ + type: z.string().default(""), + message: z.string().default(""), + item: z + .object({ + type: z.string().default(""), + content: z + .array(z.object({ text: z.string().default("") }).loose()) + .optional(), + }) + .optional(), +}); + +/** + * `event_msg` payload types carrying a turn as a flat `message` string. The + * desktop app records conversations this way. + */ +const ROLE_BY_EVENT = new Map([ + ["user_message", "user"], + ["agent_message", "assistant"], +]); + +/** + * Item kinds carrying a turn inside an `item_completed` event — how the CLI + * records conversations, nesting the text in content blocks. Both shapes live + * under `event_msg`, so one pass reads either without double-counting. + * `response_item` repeats the same turns wrapped in injected environment and + * developer messages, so it stays unread. + */ +const ROLE_BY_ITEM = new Map([ + ["UserMessage", "user"], + ["AgentMessage", "assistant"], +]); + +const MAX_PREVIEW = 300; + +/** + * Conversation turn carried by an `event_msg` payload, in whichever shape the + * writing distribution used, or null when the event is not a turn. + */ +function conversationTurn( + payload: z.infer +): { role: string; text: string } | null { + const flatRole = ROLE_BY_EVENT.get(payload.type); + if (flatRole !== undefined) return { role: flatRole, text: payload.message }; + + const item = payload.item; + if (payload.type !== "item_completed" || item === undefined) return null; + const itemRole = ROLE_BY_ITEM.get(item.type); + if (itemRole === undefined) return null; + const text = (item.content ?? []).map((block) => block.text).join(""); + return text === "" ? null : { role: itemRole, text }; +} + +interface CodexSessionSummary { + sessionId: string; + sessionFile: string; + totalEntries: number; + relevantEntries: number; + transcript: Array<{ role: string; contentPreview: string }>; +} + +interface ReadCodexSessionOptions extends ReadSessionOptions { + sessionId?: string; +} + +type CodexSessionResult = + | { ok: true; data: CodexSessionSummary } + | { ok: false; error: string; path?: string; available?: string[] }; + +interface CodexRollout { + id: string; + file: string; + mtime: number; +} + +/** + * Bytes read from a rollout when only its `session_meta` is wanted. The meta + * line is written at session creation, so the head is enough to classify a + * rollout without paying for its whole transcript. + */ +const HEAD_BYTES = 64 * 1024; + +/** Rollouts inspected at once during discovery. */ +const DISCOVERY_CONCURRENCY = 8; + +/** + * Read a rollout as text, transparently decompressing the `.zst` form. + * + * Codex compresses rollouts older than seven days in place, so a reader that + * handled only `.jsonl` would see nothing beyond the most recent week. + * + * @param headOnly - Read just the leading {@link HEAD_BYTES}. Compressed + * rollouts ignore this: the whole member must be inflated to reach any of it. + */ +async function readRollout( + file: string, + headOnly = false +): Promise { + try { + if (file.endsWith(COMPRESSED_SUFFIX)) { + const bytes = await Bun.file(file).bytes(); + return new TextDecoder().decode(Bun.zstdDecompressSync(bytes)); + } + const handle = Bun.file(file); + return await (headOnly ? handle.slice(0, HEAD_BYTES) : handle).text(); + } catch { + return null; + } +} + +/** + * Map over items with a bounded number in flight. + * + * A sessions directory accumulates indefinitely, and discovery inflates every + * compressed rollout it meets. Reading them all at once would hold each + * inflated transcript in memory simultaneously. + */ +async function mapBounded( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = Array.from({ length: items.length }); + let cursor = 0; + const workers = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + for (let i = cursor++; i < items.length; i = cursor++) { + // Sequential within a worker is the mechanism: parallelism comes from + // running `limit` workers, which is what keeps memory bounded. + // oxlint-disable-next-line eslint/no-await-in-loop + results[i] = await fn(items[i]); + } + } + ); + await Promise.all(workers); + return results; +} + +/** Directory entries, or an empty list when the directory is unreadable. */ +function readDirentsSafe(dir: string) { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +/** Every rollout file under the date-sharded `sessions/` tree. */ +function enumerateRolloutFiles(sessionsDir: string): string[] { + const files: string[] = []; + const walk = (dir: string, depth: number): void => { + const entries = readDirentsSafe(dir); + for (const entry of entries) { + const full = join(dir, entry.name); + // sessions/YYYY/MM/DD — rollouts sit at the third level down. + if (entry.isDirectory() && depth < 3) walk(full, depth + 1); + else if (entry.isFile() && ROLLOUT_NAME.test(entry.name)) + files.push(full); + } + }; + walk(sessionsDir, 0); + return files; +} + +/** + * Extract the thread id and recorded `cwd` from a rollout's `session_meta`. + * + * The meta line is written at session creation and is normally first, but the + * head window is scanned rather than assuming an index, matching Codex's own + * reader. + */ +function parseRolloutMeta( + raw: string +): { id: string | undefined; cwd: string } | null { + const head = raw.split("\n", META_SCAN_LINES).join("\n"); + for (const line of Bun.JSONL.parse(head)) { + const entry = CodexLineSchema.safeParse(line); + if (!entry.success || entry.data.type !== "session_meta") continue; + const meta = SessionMetaPayloadSchema.safeParse(entry.data.payload ?? {}); + if (!meta.success) continue; + return { id: meta.data.id, cwd: meta.data.cwd }; + } + return null; +} + +/** Thread id from a rollout filename, which embeds it verbatim. */ +function idFromFilename(file: string): string { + const match = ROLLOUT_NAME.exec(basename(file)); + return match?.groups?.id ?? basename(file); +} + +/** + * Find rollouts belonging to a project, most recent first. + * + * Codex records the working directory inside the file rather than encoding it + * in the path, so every rollout's `session_meta` is inspected and compared + * against the project root. + */ +async function findCodexRollouts( + projectRoot: string | null +): Promise { + const sessionsDir = codexSessionsDir(); + if (!existsSync(sessionsDir)) return null; + + const target = normalizePath(projectRoot ?? process.cwd()); + const files = enumerateRolloutFiles(sessionsDir); + + const inspected = await mapBounded( + files, + DISCOVERY_CONCURRENCY, + async (file) => { + const raw = await readRollout(file, true); + return { file, meta: raw === null ? null : parseRolloutMeta(raw) }; + } + ); + + const found: CodexRollout[] = []; + for (const { file, meta } of inspected) { + if (meta === null) continue; + if (meta.cwd === "" || normalizePath(meta.cwd) !== target) continue; + const stat = statSync(file, { throwIfNoEntry: false }); + if (stat === undefined) continue; + const mtime = stat.mtimeMs; + found.push({ + id: + meta.id !== undefined && meta.id !== "" + ? meta.id + : idFromFilename(file), + file, + mtime, + }); + } + + return found.sort((a, b) => b.mtime - a.mtime); +} + +/** + * List Codex sessions for a project, most recent first. + * + * @param projectRoot - Project to read sessions for; `null` falls back to cwd. + */ +export async function listCodexSessions( + projectRoot: string | null +): Promise { + const sessionsDir = codexSessionsDir(); + const rollouts = await findCodexRollouts(projectRoot); + if (rollouts === null) { + return { + ok: false, + error: "No Codex sessions directory found", + path: sessionsDir, + }; + } + + const sessions: SessionListEntry[] = rollouts.map((r) => ({ + id: r.id, + updatedAt: new Date(r.mtime).toISOString(), + })); + return { ok: true, data: { sessions } }; +} + +/** + * Read a Codex session transcript for a project. + * + * @param projectRoot - Project to read sessions for; `null` falls back to cwd. + * @param options - `sessionId` selects a specific rollout by thread id; + * `maxEntries` caps returned transcript entries. + */ +export async function readCodexSession( + projectRoot: string | null, + options?: ReadCodexSessionOptions +): Promise { + const limit = options?.maxEntries ?? 200; + const sessionsDir = codexSessionsDir(); + const rollouts = await findCodexRollouts(projectRoot); + if (rollouts === null) { + return { + ok: false, + error: "No Codex sessions directory found", + path: sessionsDir, + }; + } + if (rollouts.length === 0) { + return { + ok: false, + error: "No Codex sessions found for this project", + path: sessionsDir, + }; + } + + const requested = options?.sessionId; + const target = + requested !== undefined && requested !== "" + ? rollouts.find((r) => r.id === requested) + : rollouts[0]; + + if (!target) { + return { + ok: false, + error: `Session not found: ${requested ?? ""}`, + available: rollouts.map((r) => r.id), + }; + } + + logDebug("Reading Codex rollout", target.file); + const raw = await readRollout(target.file); + if (raw === null) return sessionReadFailure(target.file); + + // Bun.JSONL.parse drops a trailing partial line, which a rollout being + // appended to right now will have. + const lines = Bun.JSONL.parse(raw); + const totalEntries = lines.length; + + const transcript: CodexSessionSummary["transcript"] = []; + for (const line of lines) { + const entry = CodexLineSchema.safeParse(line); + if (!entry.success || entry.data.type !== "event_msg") continue; + const event = EventMsgPayloadSchema.safeParse(entry.data.payload ?? {}); + if (!event.success) continue; + + const turn = conversationTurn(event.data); + if (turn === null) continue; + transcript.push({ + role: turn.role, + contentPreview: + turn.text.length > MAX_PREVIEW + ? `${turn.text.slice(0, MAX_PREVIEW)}...` + : turn.text, + }); + } + + const trimmed = + transcript.length > limit ? transcript.slice(-limit) : transcript; + return { + ok: true, + data: { + sessionId: target.id, + sessionFile: basename(target.file), + totalEntries, + relevantEntries: transcript.length, + transcript: trimmed, + }, + }; +} diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index 5b779ac8..050796d0 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -1,19 +1,19 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { readdirSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { z } from "zod"; import { logDebug } from "./log"; import { copilotSessionStateDir } from "./paths"; -import { isWindows } from "./platform"; import { MessageContentSchema, type ReadSessionOptions, type SessionListResult, type TranscriptEntry, getContentPreview, + normalizePath, } from "./session-context"; const WorkspaceMetaSchema = z.object({ @@ -43,16 +43,6 @@ type CopilotSessionResult = | { ok: true; data: CopilotSessionSummary } | { ok: false; error: string; path?: string; available?: string[] }; -/** - * Normalize a file path for cross-platform comparison. - * Lowercases on Windows (case-insensitive FS), normalizes separators to `/`, - * and resolves to an absolute path. - */ -function normalizePath(p: string): string { - const resolved = resolve(p).replaceAll("\\", "/"); - return isWindows() ? resolved.toLowerCase() : resolved; -} - const COPILOT_RELEVANT_TYPES = new Set(["user.message", "assistant.message"]); interface CopilotSessionMatch { @@ -224,7 +214,11 @@ export async function readCopilotSession( role, message: { content }, }; - relevant.push({ role, contentPreview: getContentPreview(normalized) }); + const contentPreview = getContentPreview(normalized); + // An assistant turn that only issued tool calls carries no prose, and + // `toolRequests` holds the calls instead. + if (contentPreview.trim() === "") continue; + relevant.push({ role, contentPreview }); } const trimmed = relevant.length > limit ? relevant.slice(-limit) : relevant; diff --git a/src/helpers/session-context-opencode.ts b/src/helpers/session-context-opencode.ts index fc537fb0..f7fa1ae5 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -2,17 +2,16 @@ // Copyright 2026 Archgate import { Database } from "bun:sqlite"; import { existsSync } from "node:fs"; -import { resolve } from "node:path"; import { logDebug } from "./log"; import { opencodeDbPath } from "./paths"; -import { isWindows } from "./platform"; import { RELEVANT_ROLES, type ReadSessionOptions, type SessionListResult, type TranscriptEntry, getContentPreview, + normalizePath, } from "./session-context"; interface OpencodeSessionSummary { @@ -43,10 +42,6 @@ type OpencodeSessionResult = * Lowercases on Windows (case-insensitive FS), normalizes separators to `/`, * and resolves to an absolute path. */ -function normalizePath(p: string): string { - const resolved = resolve(p).replaceAll("\\", "/"); - return isWindows() ? resolved.toLowerCase() : resolved; -} interface SessionRow { id: string; diff --git a/src/helpers/session-context-pi.ts b/src/helpers/session-context-pi.ts new file mode 100644 index 00000000..74cc49b5 --- /dev/null +++ b/src/helpers/session-context-pi.ts @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * session-context-pi.ts — Read Pi coding-agent session transcripts. + * + * Sessions are JSONL under `~/.pi/agent/sessions/----/`, where the slug + * encodes the working directory. Line 1 is a `session` header carrying `cwd`; + * later `message` entries hold the conversation. + */ + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { z } from "zod"; + +import { logDebug } from "./log"; +import { piSessionsDir } from "./paths"; +import { + MessageContentSchema, + type ReadSessionOptions, + type SessionListEntry, + type SessionListResult, + getContentPreview, + normalizePath, + sessionReadFailure, +} from "./session-context"; + +/** Roles that represent conversation turns; tool results carry their own role. */ +const PI_RELEVANT_ROLES = new Set(["user", "assistant"]); + +const PiHeaderSchema = z.object({ + type: z.literal("session"), + id: z.string().default(""), + cwd: z.string().default(""), +}); + +const PiEntrySchema = z.object({ + type: z.string().default(""), + id: z.string().optional(), + parentId: z.string().optional(), + message: z + .object({ + role: z.string().default(""), + content: MessageContentSchema.optional(), + }) + .optional(), +}); + +type PiEntry = z.infer; + +/** + * Entries on the session's active branch, in file order. + * + * Pi branches in place rather than starting a new file, so `/fork` and + * `/rewind` leave abandoned entries behind, linked by `id`/`parentId`. The + * chain is walked back from the newest entry to skip them. Sessions predating + * the tree format carry no ids and are returned whole. + */ +function activeBranch(entries: PiEntry[]): PiEntry[] { + const leaf = entries.findLast((e) => e.id !== undefined); + if (leaf === undefined) return entries; + + const byId = new Map( + entries.filter((e) => e.id !== undefined).map((e) => [e.id, e]) + ); + const onPath = new Set(); + let cursor: PiEntry | undefined = leaf; + while (cursor?.id !== undefined && !onPath.has(cursor.id)) { + onPath.add(cursor.id); + cursor = + cursor.parentId === undefined ? undefined : byId.get(cursor.parentId); + } + + return entries.filter((e) => e.id === undefined || onPath.has(e.id)); +} + +interface PiSessionSummary { + sessionId: string; + sessionFile: string; + totalEntries: number; + relevantEntries: number; + transcript: Array<{ role: string; contentPreview: string }>; +} + +interface ReadPiSessionOptions extends ReadSessionOptions { + sessionId?: string; +} + +type PiSessionResult = + | { ok: true; data: PiSessionSummary } + | { ok: false; error: string; path?: string; available?: string[] }; + +/** + * Encode a working directory the way Pi names its session shard: drop one + * leading separator, map `/`, `\` and `:` to `-`, and wrap in `--`. + * + * Mirrors `getDefaultSessionDirPath` in Pi's `session-manager`. Runs are not + * collapsed and dots are preserved, so the encoding is exact rather than + * lossy. + */ +export function encodePiProjectDir(projectRoot: string): string { + const slug = projectRoot.replace(/^[/\\]/u, "").replaceAll(/[/\\:]/gu, "-"); + return `--${slug}--`; +} + +/** A session file with the mtime used for recency ordering. */ +interface PiSessionFile { + id: string; + file: string; + mtime: number; +} + +/** Every `.jsonl` directly under `dir`, or an empty list when unreadable. */ +function sessionFilesIn(dir: string): string[] { + try { + return readdirSync(dir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => join(dir, f)); + } catch { + return []; + } +} + +/** + * Enumerate Pi session files for a project, most recent first. + * + * The shard directory encodes the project root, and each file's header `cwd` + * is verified as well. Checking both also covers a relocated session + * directory, whose shard name encodes nothing about the project path. + */ +async function findPiSessions( + projectRoot: string | null +): Promise { + const sessionsDir = piSessionsDir(); + if (!existsSync(sessionsDir)) return null; + + const root = projectRoot ?? process.cwd(); + const target = normalizePath(root); + const shard = join(sessionsDir, encodePiProjectDir(root)); + const searchDirs = existsSync(shard) + ? [shard] + : readdirSync(sessionsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => join(sessionsDir, e.name)); + + const candidates = searchDirs.flatMap((dir) => sessionFilesIn(dir)); + const headers = await Promise.all( + candidates.map(async (file) => ({ file, header: await readPiHeader(file) })) + ); + + const found: PiSessionFile[] = []; + for (const { file, header } of headers) { + if (header === null) continue; + if (header.cwd === "" || normalizePath(header.cwd) !== target) continue; + const stat = statSync(file, { throwIfNoEntry: false }); + if (stat === undefined) continue; + const mtime = stat.mtimeMs; + // The filename is `_`; the header id is authoritative. + found.push({ + id: header.id === "" ? basename(file, ".jsonl") : header.id, + file, + mtime, + }); + } + + return found.sort((a, b) => b.mtime - a.mtime); +} + +/** + * Bytes read when only the header is wanted. It is line 1, so the head is + * enough to classify a session without reading its whole transcript. + */ +const HEADER_BYTES = 64 * 1024; + +/** Parse the leading `session` header, or null when the file is unusable. */ +async function readPiHeader( + file: string +): Promise<{ id: string; cwd: string } | null> { + let firstLine: string; + try { + const head = await Bun.file(file).slice(0, HEADER_BYTES).text(); + firstLine = head.slice(0, head.indexOf("\n") + 1 || undefined).trim(); + } catch { + return null; + } + if (firstLine === "") return null; + const header = PiHeaderSchema.safeParse(Bun.JSONL.parse(firstLine)[0]); + return header.success ? { id: header.data.id, cwd: header.data.cwd } : null; +} + +/** + * List Pi sessions for a project, most recent first. + * + * @param projectRoot - Project to read sessions for; `null` falls back to cwd. + */ +export async function listPiSessions( + projectRoot: string | null +): Promise { + const sessionsDir = piSessionsDir(); + const sessions = await findPiSessions(projectRoot); + if (sessions === null) { + return { + ok: false, + error: "No Pi sessions directory found", + path: sessionsDir, + }; + } + + const entries: SessionListEntry[] = sessions.map((s) => ({ + id: s.id, + updatedAt: new Date(s.mtime).toISOString(), + })); + return { ok: true, data: { sessions: entries } }; +} + +/** + * Read a Pi session transcript for a project. + * + * @param projectRoot - Project to read sessions for; `null` falls back to cwd. + * @param options - `sessionId` selects a specific session; `maxEntries` caps + * returned transcript entries. + */ +export async function readPiSession( + projectRoot: string | null, + options?: ReadPiSessionOptions +): Promise { + const limit = options?.maxEntries ?? 200; + const sessionsDir = piSessionsDir(); + const sessions = await findPiSessions(projectRoot); + if (sessions === null) { + return { + ok: false, + error: "No Pi sessions directory found", + path: sessionsDir, + }; + } + if (sessions.length === 0) { + return { + ok: false, + error: "No Pi sessions found for this project", + path: sessionsDir, + }; + } + + const requested = options?.sessionId; + const target = + requested !== undefined && requested !== "" + ? sessions.find((s) => s.id === requested) + : sessions[0]; + + if (!target) { + return { + ok: false, + error: `Session not found: ${requested ?? ""}`, + available: sessions.map((s) => s.id), + }; + } + + logDebug("Reading Pi session", target.file); + const raw = await Bun.file(target.file) + .text() + .catch(() => null); + if (raw === null) return sessionReadFailure(target.file); + + // Bun.JSONL.parse drops a trailing partial line, which a session being + // appended to right now will have. + const lines = Bun.JSONL.parse(raw); + const totalEntries = lines.length; + + const parsed: PiEntry[] = []; + for (const line of lines) { + const entry = PiEntrySchema.safeParse(line); + if (entry.success) parsed.push(entry.data); + } + + const transcript: PiSessionSummary["transcript"] = []; + for (const entry of activeBranch(parsed)) { + if (entry.type !== "message") continue; + const message = entry.message; + if (message === undefined || !PI_RELEVANT_ROLES.has(message.role)) continue; + const contentPreview = getContentPreview({ + type: "message", + role: message.role, + message: { role: message.role, content: message.content }, + }); + // A turn that only made a tool call or thought carries no prose; emitting + // it would pad the transcript with blank entries. + if (contentPreview.trim() === "") continue; + transcript.push({ role: message.role, contentPreview }); + } + + const trimmed = + transcript.length > limit ? transcript.slice(-limit) : transcript; + return { + ok: true, + data: { + sessionId: target.id, + sessionFile: basename(target.file), + totalEntries, + relevantEntries: transcript.length, + transcript: trimmed, + }, + }; +} diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 1d4a85e7..288fcc08 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -2,19 +2,55 @@ // Copyright 2026 Archgate import { readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; import { z } from "zod"; import type { EditorTarget } from "./init-project"; -import { isWSL, toWindowsPath } from "./platform"; +import { isWindows, isWSL, toWindowsPath } from "./platform"; + +/** + * Normalize a path for cross-platform comparison: resolve to absolute, use + * `/` separators, and lowercase on Windows where the filesystem is + * case-insensitive. Readers compare a session's recorded working directory + * against the project root through this. + */ +export function normalizePath(p: string): string { + const resolved = resolve(p).replaceAll("\\", "/"); + return isWindows() ? resolved.toLowerCase() : resolved; +} + +/** + * Failure for a session file that is discovered but then unreadable, which a + * session removed between discovery and the read produces. + */ +export function sessionReadFailure(file: string) { + return { + ok: false as const, + error: "Failed to read session file", + path: file, + }; +} + +/** + * Slugify a project root the way cursor-agent names its directory under + * `~/.cursor/projects/`: each non-alphanumeric run becomes one dash, and the + * ends are trimmed. Collapsing is what resolves a dot-segment — `\.claude\` + * yields `-claude-`, so a worktree under `.claude/` finds Cursor's directory. + */ +function slugifyCursorPath(raw: string): string { + return raw + .replaceAll(/[^a-zA-Z0-9]/gu, "-") + .replaceAll(/-+/gu, "-") + .replaceAll(/^-+|-+$/gu, ""); +} /** * Encode a project root into the session-directory name under - * `~/.claude/projects/` or `~/.cursor/projects/`: separators (`\`, `/`) and - * dots become dashes; drive-letter colons become dashes for Claude Code - * (`C:\Users\x` → `C--Users-x`) but are stripped by Cursor (`C-Users-x`). - * In WSL, converts to the Windows path first to match the Windows-side editor. + * `~/.claude/projects/` or `~/.cursor/projects/`. Each editor's own encoding + * must be matched exactly: Claude Code keeps every separator it maps to a + * dash (`C:\Users\x` → `C--Users-x`), while Cursor collapses runs and trims + * (`C-Users-x`). In WSL, converts to the Windows path first. */ export async function encodeProjectPath( projectRoot: string, @@ -27,11 +63,11 @@ export async function encodeProjectPath( raw = winPath; } } - const colonReplacement = target === "cursor" ? "" : "-"; + if (target === "cursor") return slugifyCursorPath(raw); return raw .replaceAll("\\", "-") .replaceAll("/", "-") - .replaceAll(":", colonReplacement) + .replaceAll(":", "-") .replaceAll(".", "-"); } diff --git a/tests/commands/session-context-actions.test.ts b/tests/commands/session-context-actions.test.ts new file mode 100644 index 00000000..a4ab60a9 --- /dev/null +++ b/tests/commands/session-context-actions.test.ts @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + spyOn, + test, + type Mock, +} from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerSessionContextCommand } from "../../src/commands/session-context"; +import * as auto from "../../src/helpers/session-context-auto"; +import { UserError } from "../../src/helpers/user-error"; +import { restoreEnv, safeRmSync } from "../test-utils"; + +// Action-handler behaviour lives here; command wiring (options, choices, +// subcommand shape) is asserted in session-context.test.ts. +describe("session-context action handlers", () => { + let tempDir: string; + let originalCwd: string; + let savedCeiling: string | undefined; + let logSpy: Mock; + let errorSpy: Mock; + let exitSpy: Mock; + let readSpy: Mock; + let readByIdSpy: Mock; + let listSpy: Mock; + + const detection = { + editor: "claude-code" as const, + via: "CLAUDECODE", + session: "recent" as const, + candidates: ["claude-code" as const], + }; + + beforeEach(() => { + // realpathSync normalizes the macOS /var → /private/var symlink so the + // path matches what process.cwd() returns after chdir. + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "archgate-sc-action-"))); + originalCwd = process.cwd(); + mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); + savedCeiling = Bun.env.ARCHGATE_PROJECT_CEILING; + Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; + process.chdir(tempDir); + + readSpy = spyOn(auto, "readAutoSession"); + readSpy.mockResolvedValue({ + ok: true, + detection, + data: { totalEntries: 0 }, + }); + readByIdSpy = spyOn(auto, "readAutoSessionById"); + readByIdSpy.mockResolvedValue({ + ok: true, + detection: { ...detection, session: "explicit" }, + data: { totalEntries: 0 }, + }); + listSpy = spyOn(auto, "listAutoSessions"); + listSpy.mockResolvedValue({ + ok: true, + detection: { editor: "claude-code", via: "CLAUDECODE", candidates: [] }, + sessions: [], + }); + + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + restoreEnv("ARCHGATE_PROJECT_CEILING", savedCeiling); + safeRmSync(tempDir); + readSpy.mockRestore(); + readByIdSpy.mockRestore(); + listSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + async function run(...argv: string[]) { + const program = new Command().exitOverride(); + registerSessionContextCommand(program); + return program.parseAsync(["node", "archgate", "session-context", ...argv]); + } + + /** + * Run a command whose action is expected to exit, and let it settle. + * + * The action awaits the reader before reaching `exitWith`, so the spies are + * only meaningful once the returned promise has rejected. Draining it here + * keeps every caller's assertions ordered after the exit. + */ + async function runExpectingExit(...argv: string[]) { + const settled = run(...argv); + expect(settled).rejects.toThrow("process.exit"); + await settled.catch(() => { + // The rejection is the assertion above; draining it just orders the + // caller's spy checks after the exit. + }); + } + + /** Parse whatever the handler printed to stdout. JSON.parse is untyped by nature. */ + function printed(): unknown { + const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); + return JSON.parse(output); + } + + const errorText = () => errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); + + describe("reading the current session", () => { + test("prints the detection block alongside the transcript", async () => { + await run(); + + expect(printed()).toMatchObject({ + detection: { editor: "claude-code", session: "recent" }, + totalEntries: 0, + }); + }); + + test("forwards --max-entries, --editor and --root", async () => { + await run("--editor", "opencode", "--max-entries", "7", "--root"); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: 7, + editor: "opencode", + root: true, + }); + }); + + test("exits 1 when the reader reports a failure", async () => { + readSpy.mockResolvedValue({ ok: false, error: "No session files found" }); + + await runExpectingExit(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorText()).toContain("No session files found"); + }); + + test("exits 1 with guidance when no editor can be resolved", async () => { + readSpy.mockRejectedValue( + new UserError("Could not detect the AI editor from the environment.") + ); + + await runExpectingExit(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorText()).toContain("Could not detect"); + }); + + test("exits 2 on an unexpected error", async () => { + readSpy.mockRejectedValue(new Error("Unexpected disk failure")); + + await runExpectingExit(); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(errorText()).toContain("Unexpected disk failure"); + }); + + test("re-throws ExitPromptError so the entry point exits 130", async () => { + const cancelled = new Error("prompt cancelled"); + cancelled.name = "ExitPromptError"; + readSpy.mockRejectedValue(cancelled); + + expect(run()).rejects.toThrow("prompt cancelled"); + }); + }); + + describe("list", () => { + test("prints the detection block alongside the sessions", async () => { + listSpy.mockResolvedValue({ + ok: true, + detection: { editor: "cursor", via: "--editor", candidates: [] }, + sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00.000Z" }], + }); + + await run("list"); + + expect(printed()).toMatchObject({ + detection: { editor: "cursor" }, + sessions: [{ id: "abc" }], + }); + }); + + test("forwards --editor", async () => { + await run("list", "--editor", "copilot"); + + expect(listSpy).toHaveBeenCalledWith(tempDir, { editor: "copilot" }); + }); + + test("takes --editor from the parent command", async () => { + // Commander hoists a parent-known option from anywhere on the line. + await run("--editor", "cursor", "list"); + + expect(listSpy).toHaveBeenCalledWith(tempDir, { editor: "cursor" }); + }); + + test("exits 1 when listing fails", async () => { + listSpy.mockResolvedValue({ ok: false, error: "No opencode database" }); + + await runExpectingExit("list"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorText()).toContain("No opencode database"); + }); + + test("exits 2 on an unexpected error", async () => { + listSpy.mockRejectedValue(new Error("boom")); + + await runExpectingExit("list"); + expect(exitSpy).toHaveBeenCalledWith(2); + }); + }); + + describe("show", () => { + test("prints the detection block for an explicitly named session", async () => { + await run("show", "sess-1"); + + expect(printed()).toMatchObject({ detection: { session: "explicit" } }); + }); + + test("forwards the session id with --max-entries, --editor and --root", async () => { + await run( + "show", + "sess-1", + "--editor", + "opencode", + "--max-entries", + "3", + "--root" + ); + + expect(readByIdSpy).toHaveBeenCalledWith(tempDir, "sess-1", { + maxEntries: 3, + editor: "opencode", + root: true, + }); + }); + + test("takes --max-entries and --editor from the parent command", async () => { + await run("--max-entries", "9", "--editor", "cursor", "show", "sess-2"); + + expect(readByIdSpy).toHaveBeenCalledWith(tempDir, "sess-2", { + maxEntries: 9, + editor: "cursor", + root: undefined, + }); + }); + + test("exits 1 when the session is not found", async () => { + readByIdSpy.mockResolvedValue({ + ok: false, + error: "Session not found: nope", + }); + + await runExpectingExit("show", "nope"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorText()).toContain("Session not found"); + }); + + test("exits 1 when --root is rejected for the editor", async () => { + readByIdSpy.mockRejectedValue( + new UserError("--root applies only to opencode") + ); + + await runExpectingExit("show", "sess-1", "--root"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorText()).toContain("--root applies only to opencode"); + }); + + test("exits 2 on an unexpected error", async () => { + readByIdSpy.mockRejectedValue(new Error("boom")); + + await runExpectingExit("show", "sess-1"); + expect(exitSpy).toHaveBeenCalledWith(2); + }); + }); +}); diff --git a/tests/commands/session-context.test.ts b/tests/commands/session-context.test.ts index c41b35e5..4bd9cae8 100644 --- a/tests/commands/session-context.test.ts +++ b/tests/commands/session-context.test.ts @@ -4,168 +4,141 @@ import { describe, expect, test } from "bun:test"; import { Command } from "@commander-js/extra-typings"; -import { registerSessionContextCommand } from "../../src/commands/session-context/index"; +import { + parseMaxEntries, + registerSessionContextCommand, +} from "../../src/commands/session-context"; + +/** Build a fresh program and return the registered `session-context` command. */ +function sessionContext() { + const program = new Command(); + registerSessionContextCommand(program); + return program.commands.find((c) => c.name() === "session-context")!; +} + +/** Resolve `session-context` itself, or one of its subcommands by name. */ +function target(subcommand?: string) { + const cmd = sessionContext(); + if (subcommand === undefined) return cmd; + return cmd.commands.find((c) => c.name() === subcommand)!; +} + +const EDITORS = [ + "antigravity", + "claude-code", + "codex", + "copilot", + "cursor", + "opencode", + "pi", +]; describe("registerSessionContextCommand", () => { test("registers 'session-context' as a subcommand", () => { - const program = new Command(); - registerSessionContextCommand(program); - const sub = program.commands.find((c) => c.name() === "session-context"); - expect(sub).toBeDefined(); + expect(sessionContext()).toBeDefined(); }); test("has a description", () => { - const program = new Command(); - registerSessionContextCommand(program); - const sub = program.commands.find((c) => c.name() === "session-context")!; - expect(sub.description()).toBeTruthy(); + expect(sessionContext().description()).toBeTruthy(); }); - test("registers 'claude-code' subcommand", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "claude-code"); - expect(sub).toBeDefined(); + test("has exactly the list and show subcommands", () => { + // Editors are selected with --editor, not with a subcommand each. + expect(sessionContext().commands.map((c) => c.name())).toEqual([ + "list", + "show", + ]); }); - test("registers 'copilot' subcommand", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "copilot"); - expect(sub).toBeDefined(); + test("no longer registers a subcommand per editor", () => { + const names = new Set(sessionContext().commands.map((c) => c.name())); + expect(EDITORS.filter((e) => names.has(e))).toEqual([]); }); - test("registers 'cursor' subcommand", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "cursor"); - expect(sub).toBeDefined(); - }); + describe("--editor", () => { + test.each([ + ["session-context", undefined], + ["list", "list"], + ["show", "show"], + ])("%s accepts --editor", (_label, subcommand) => { + expect(target(subcommand).options.map((o) => o.long)).toContain( + "--editor" + ); + }); - test("registers 'opencode' subcommand", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "opencode"); - expect(sub).toBeDefined(); - }); + test.each([ + ["session-context", undefined], + ["list", "list"], + ["show", "show"], + ])("%s restricts --editor to the known editors", (_label, subcommand) => { + const editor = target(subcommand).options.find( + (o) => o.long === "--editor" + )!; + expect(editor.argChoices).toEqual(EDITORS); + }); - test("claude-code subcommand has only --max-entries (read current conversation)", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "claude-code")!; - const opts = sub.options.map((o) => o.long); - expect(opts).toContain("--max-entries"); - expect(opts).not.toContain("--session-id"); - expect(opts).not.toContain("--list"); - expect(opts).not.toContain("--skip"); + test("--editor takes a value rather than being a boolean flag", () => { + const editor = sessionContext().options.find( + (o) => o.long === "--editor" + )!; + expect(editor.required).toBe(true); + }); }); - test("cursor subcommand has only --max-entries (read current conversation)", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "cursor")!; - const opts = sub.options.map((o) => o.long); - expect(opts).toContain("--max-entries"); - expect(opts).not.toContain("--session-id"); - expect(opts).not.toContain("--list"); - expect(opts).not.toContain("--skip"); - }); + describe("--max-entries", () => { + test.each([ + ["session-context", undefined], + ["show", "show"], + ])("%s accepts --max-entries", (_label, subcommand) => { + expect(target(subcommand).options.map((o) => o.long)).toContain( + "--max-entries" + ); + }); - test("copilot subcommand has only --max-entries (read current conversation)", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "copilot")!; - const opts = sub.options.map((o) => o.long); - expect(opts).toContain("--max-entries"); - expect(opts).not.toContain("--session-id"); - expect(opts).not.toContain("--list"); - expect(opts).not.toContain("--skip"); - }); + test("list does not accept --max-entries", () => { + expect(target("list").options.map((o) => o.long)).not.toContain( + "--max-entries" + ); + }); - test("session-context has exactly the four editor subcommands", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - // list/show are NOT direct children of session-context - expect(parent.commands.map((c) => c.name())).toEqual([ - "claude-code", - "copilot", - "cursor", - "opencode", - ]); + test.each([["0"], ["-1"], ["abc"], [""]])( + "rejects %p as a max-entries value", + (value) => { + expect(() => parseMaxEntries(value)).toThrow(); + } + ); + + test("accepts a positive integer", () => { + expect(parseMaxEntries("25")).toBe(25); + }); + + test("truncates a fractional value", () => { + expect(parseMaxEntries("25.9")).toBe(25); + }); }); - test.each(["claude-code", "copilot", "cursor", "opencode"])( - "%s subcommand has list and show children", - (editor) => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === editor)!; - const children = sub.commands.map((c) => c.name()).sort(); - expect(children).toEqual(["list", "show"]); - } - ); - - test.each([ - ["claude-code", false], - ["copilot", false], - ["cursor", false], - ["opencode", true], - ] as const)("%s show has --root: %p", (editor, hasRoot) => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === editor)!; - const show = sub.commands.find((c) => c.name() === "show")!; - const opts = show.options.map((o) => o.long); - expect(opts).toContain("--max-entries"); - if (hasRoot) { - expect(opts).toContain("--root"); - } else { - expect(opts).not.toContain("--root"); - } + describe("--root", () => { + test.each([ + ["session-context", undefined], + ["show", "show"], + ])("%s accepts --root", (_label, subcommand) => { + expect(target(subcommand).options.map((o) => o.long)).toContain("--root"); + }); + + test("list does not accept --root", () => { + expect(target("list").options.map((o) => o.long)).not.toContain("--root"); + }); }); - test("opencode subcommand has only --max-entries (read current conversation)", () => { - const program = new Command(); - registerSessionContextCommand(program); - const parent = program.commands.find( - (c) => c.name() === "session-context" - )!; - const sub = parent.commands.find((c) => c.name() === "opencode")!; - const opts = sub.options.map((o) => o.long); - expect(opts).toContain("--max-entries"); - expect(opts).not.toContain("--session-id"); - expect(opts).not.toContain("--root"); - expect(opts).not.toContain("--list"); - expect(opts).not.toContain("--skip"); + describe("arguments", () => { + test("show takes a session-id argument", () => { + expect(target("show").registeredArguments.map((a) => a.name())).toEqual([ + "session-id", + ]); + }); + + test("list takes no arguments", () => { + expect(target("list").registeredArguments).toHaveLength(0); + }); }); }); diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts deleted file mode 100644 index d3453132..00000000 --- a/tests/commands/session-context/claude-code.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - spyOn, - test, - type Mock, -} from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Command } from "@commander-js/extra-typings"; - -import { - makeMaxEntriesOption, - registerClaudeCodeSessionContextCommand, -} from "../../../src/commands/session-context/claude-code"; -import * as sessionContextHelpers from "../../../src/helpers/session-context"; -import { runCli } from "../../integration/cli-harness"; -import { safeRmSync } from "../../test-utils"; - -describe("registerClaudeCodeSessionContextCommand", () => { - test("registers 'claude-code' as a subcommand", () => { - const parent = new Command("session-context"); - registerClaudeCodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "claude-code"); - expect(sub).toBeDefined(); - }); - - test("has a description", () => { - const parent = new Command("session-context"); - registerClaudeCodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "claude-code")!; - expect(sub.description()).toBeTruthy(); - }); - - test("accepts --max-entries option", () => { - const parent = new Command("session-context"); - registerClaudeCodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "claude-code")!; - const opt = sub.options.find((o) => o.long === "--max-entries"); - expect(opt).toBeDefined(); - }); - - test("has list and show subcommands", () => { - const parent = new Command("session-context"); - registerClaudeCodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "claude-code")!; - expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); - }); -}); - -describe("makeMaxEntriesOption", () => { - /** Parse `--max-entries ` in isolation, with commander's exit and - * stderr writes neutralized so a rejection surfaces as a thrown error. */ - function parseMaxEntries(value: string): number | undefined { - const program = new Command("probe") - .addOption(makeMaxEntriesOption()) - .exitOverride() - .configureOutput({ - writeErr: () => { - // Commander writes the rejection to stderr; the throw is the signal. - }, - }); - program.parse(["--max-entries", value], { from: "user" }); - return program.opts().maxEntries; - } - - test.each(["0", "-1", "abc", "", "Infinity", "0.5"])( - "rejects %p as a limit", - (value) => { - expect(() => parseMaxEntries(value)).toThrow( - /must be a positive integer/u - ); - } - ); - - test.each<[string, number]>([ - ["1", 1], - ["200", 200], - ["3.9", 3], - ])("accepts %p as %p", (value, expected) => { - expect(parseMaxEntries(value)).toBe(expected); - }); -}); - -describe("claude-code action handler", () => { - let tempDir: string; - let originalCwd: string; - let logSpy: Mock; - let errorSpy: Mock; - let exitSpy: Mock; - let readSpy: Mock; - let listSpy: Mock; - - /** Minimal complete summary for the default happy-path spy. */ - function emptySummary() { - return { - sessionFile: "s.jsonl", - totalEntries: 0, - relevantEntries: 0, - transcript: [], - }; - } - - beforeEach(() => { - // realpathSync normalizes macOS /var → /private/var symlink so the - // path matches what process.cwd() returns after chdir. - tempDir = realpathSync(mkdtempSync(join(tmpdir(), "archgate-cc-test-"))); - originalCwd = process.cwd(); - // Create .archgate/ so findProjectRoot returns this dir - mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); - Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; - process.chdir(tempDir); - - readSpy = spyOn(sessionContextHelpers, "readClaudeCodeSession"); - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); - logSpy = spyOn(console, "log").mockImplementation(() => {}); - errorSpy = spyOn(console, "error").mockImplementation(() => {}); - exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - }); - - afterEach(() => { - process.chdir(originalCwd); - delete Bun.env.ARCHGATE_PROJECT_CEILING; - safeRmSync(tempDir); - readSpy.mockRestore(); - listSpy.mockRestore(); - logSpy.mockRestore(); - errorSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - function makeProgram(): Command { - const parent = new Command("session-context").exitOverride(); - registerClaudeCodeSessionContextCommand(parent); - return parent; - } - - test("prints JSON on successful result", async () => { - // The handler only JSON-serializes `data` verbatim, so a fake shape - // (not the real ClaudeSessionSummary) is enough to exercise passthrough. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - readSpy.mockResolvedValue({ - ok: true, - data: { entries: [{ role: "user", content: "hello" }], total: 1 }, - } as unknown as Awaited< - ReturnType - >); - - await makeProgram().parseAsync(["node", "session-context", "claude-code"]); - - expect(logSpy).toHaveBeenCalled(); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - const parsed: unknown = JSON.parse(output); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - expect((parsed as { total: number }).total).toBe(1); - }); - - test("exits 1 when reader returns error result", () => { - readSpy.mockResolvedValue({ ok: false, error: "No session found" }); - - expect( - makeProgram().parseAsync(["node", "session-context", "claude-code"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("No session found"); - }); - - test("exits 2 when unexpected error is thrown", () => { - readSpy.mockRejectedValue(new Error("Unexpected disk failure")); - - expect( - makeProgram().parseAsync(["node", "session-context", "claude-code"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(2); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("Unexpected disk failure"); - }); - - test("re-throws ExitPromptError", () => { - const exitPromptError = new Error("prompt cancelled"); - exitPromptError.name = "ExitPromptError"; - readSpy.mockRejectedValue(exitPromptError); - - expect( - makeProgram().parseAsync(["node", "session-context", "claude-code"]) - ).rejects.toThrow("prompt cancelled"); - - expect(exitSpy).not.toHaveBeenCalled(); - }); - - test("passes findProjectRoot result to reader", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync(["node", "session-context", "claude-code"]); - - // findProjectRoot found our tempDir (which has .archgate/) - expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); - }); - - test("list subcommand prints sessions", async () => { - listSpy.mockResolvedValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "claude-code", - "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const parsed = JSON.parse(output) as { sessions: Array<{ id: string }> }; - expect(parsed.sessions[0]?.id).toBe("abc"); - }); - - test("list subcommand exits 1 on error result", () => { - listSpy.mockResolvedValue({ ok: false, error: "store missing" }); - - expect( - makeProgram().parseAsync([ - "node", - "session-context", - "claude-code", - "list", - ]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("store missing"); - }); - - test("show subcommand reads the given session id", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "claude-code", - "show", - "abc123", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - sessionId: "abc123", - }); - }); - - test("show subcommand exits 1 on error result", () => { - readSpy.mockResolvedValue({ - ok: false, - error: "Session not found: abc123", - }); - - expect( - makeProgram().parseAsync([ - "node", - "session-context", - "claude-code", - "show", - "abc123", - ]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - test("show subcommand applies --max-entries (hoisted by the parent)", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - // Regression: the parent editor command also declares --max-entries and - // commander hoists parent-known options from anywhere in argv, so the - // child must read the merged value via optsWithGlobals(). - await makeProgram().parseAsync([ - "node", - "session-context", - "claude-code", - "show", - "abc123", - "--max-entries", - "2", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: 2, - sessionId: "abc123", - }); - }); -}); - -describe("claude-code list/show (CLI subprocess)", () => { - // Subprocess tests avoid Bun's process-global mock.module state — this - // file mocks the read helper for the in-process tests above, so the - // nested subcommands are exercised against real stores in a child - // process with HOME/USERPROFILE redirected. - let tempHome: string; - let projectDir: string; - let env: Record; - - function encodeClaude(p: string): string { - return p - .replaceAll("\\", "-") - .replaceAll("/", "-") - .replaceAll(":", "-") - .replaceAll(".", "-"); - } - - beforeEach(() => { - tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-cc-home-"))); - projectDir = join(tempHome, "project"); - mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); - env = { HOME: tempHome, USERPROFILE: tempHome }; - }); - - afterEach(() => { - safeRmSync(tempHome); - }); - - function seedSession(id: string, content: string): void { - const dir = join(tempHome, ".claude", "projects", encodeClaude(projectDir)); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, `${id}.jsonl`), - JSON.stringify({ type: "user", message: { role: "user", content } }) - ); - } - - test("list returns the project's sessions", async () => { - seedSession("abc123", "hi"); - - const { exitCode, stdout } = await runCli( - ["session-context", "claude-code", "list"], - projectDir, - env - ); - - expect(exitCode).toBe(0); - // Real CLI stdout under test; shape is asserted below, not schema-validated. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const parsed = JSON.parse(stdout) as { - sessions: Array<{ id: string; updatedAt: string }>; - }; - expect(parsed.sessions.map((s) => s.id)).toEqual(["abc123"]); - expect(Date.parse(parsed.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); - }); - - test("show reads a specific session by id and applies --max-entries", async () => { - // Two-entry session so --max-entries 1 provably trims - const dir = join(tempHome, ".claude", "projects", encodeClaude(projectDir)); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, "older.jsonl"), - [ - JSON.stringify({ - type: "user", - message: { role: "user", content: "earlier content" }, - }), - JSON.stringify({ - type: "assistant", - message: { role: "assistant", content: "earlier reply" }, - }), - ].join("\n") - ); - seedSession("newer", "current content"); - - const { exitCode, stdout } = await runCli( - ["session-context", "claude-code", "show", "older", "--max-entries", "1"], - projectDir, - env - ); - - expect(exitCode).toBe(0); - // Real CLI stdout under test; shape is asserted below, not schema-validated. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const parsed = JSON.parse(stdout) as { - sessionFile: string; - transcript: Array<{ contentPreview: string }>; - }; - expect(parsed.sessionFile).toBe("older.jsonl"); - // --max-entries keeps the LAST entry — trimming applied through show - expect(parsed.transcript).toHaveLength(1); - expect(parsed.transcript[0]?.contentPreview).toBe("earlier reply"); - }); - - test("show with an unknown id exits 1", async () => { - seedSession("only", "hi"); - - const { exitCode, stderr } = await runCli( - ["session-context", "claude-code", "show", "nope"], - projectDir, - env - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain("Session not found: nope"); - }); -}); diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts deleted file mode 100644 index 943a4b7b..00000000 --- a/tests/commands/session-context/copilot.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - spyOn, - test, - type Mock, -} from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Command } from "@commander-js/extra-typings"; - -import { registerCopilotSessionContextCommand } from "../../../src/commands/session-context/copilot"; -import * as copilotHelpers from "../../../src/helpers/session-context-copilot"; -import { safeRmSync } from "../../test-utils"; - -describe("registerCopilotSessionContextCommand", () => { - test("registers 'copilot' as a subcommand", () => { - const parent = new Command("session-context"); - registerCopilotSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "copilot"); - expect(sub).toBeDefined(); - }); - - test("has a description", () => { - const parent = new Command("session-context"); - registerCopilotSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "copilot")!; - expect(sub.description()).toBeTruthy(); - }); - - test("accepts --max-entries option", () => { - const parent = new Command("session-context"); - registerCopilotSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "copilot")!; - const opt = sub.options.find((o) => o.long === "--max-entries"); - expect(opt).toBeDefined(); - }); - - test("has list and show subcommands", () => { - const parent = new Command("session-context"); - registerCopilotSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "copilot")!; - expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); - }); -}); - -describe("copilot action handler", () => { - let tempDir: string; - let originalCwd: string; - let logSpy: Mock; - let errorSpy: Mock; - let exitSpy: Mock; - let readSpy: Mock; - let listSpy: Mock; - - /** Minimal complete summary for the default happy-path spy. */ - function emptySummary() { - return { - sessionId: "s", - sessionFile: "events.jsonl", - totalEntries: 0, - relevantEntries: 0, - transcript: [], - }; - } - - beforeEach(() => { - // realpathSync normalizes macOS /var → /private/var symlink so the - // path matches what process.cwd() returns after chdir. - tempDir = realpathSync( - mkdtempSync(join(tmpdir(), "archgate-copilot-test-")) - ); - originalCwd = process.cwd(); - mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); - Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; - process.chdir(tempDir); - - readSpy = spyOn(copilotHelpers, "readCopilotSession"); - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - listSpy = spyOn(copilotHelpers, "listCopilotSessions"); - logSpy = spyOn(console, "log").mockImplementation(() => {}); - errorSpy = spyOn(console, "error").mockImplementation(() => {}); - exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - }); - - afterEach(() => { - process.chdir(originalCwd); - delete Bun.env.ARCHGATE_PROJECT_CEILING; - safeRmSync(tempDir); - readSpy.mockRestore(); - listSpy.mockRestore(); - logSpy.mockRestore(); - errorSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - function makeProgram(): Command { - const parent = new Command("session-context").exitOverride(); - registerCopilotSessionContextCommand(parent); - return parent; - } - - test("prints JSON on successful result", async () => { - // The handler only JSON-serializes `data` verbatim, so a fake shape - // (not the real CopilotSessionSummary) is enough to exercise passthrough. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - readSpy.mockResolvedValue({ - ok: true, - data: { entries: [{ role: "assistant", content: "hi" }], total: 1 }, - } as unknown as Awaited< - ReturnType - >); - - await makeProgram().parseAsync(["node", "session-context", "copilot"]); - - expect(logSpy).toHaveBeenCalled(); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - const parsed: unknown = JSON.parse(output); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - expect((parsed as { total: number }).total).toBe(1); - }); - - test("exits 1 when reader returns error result", () => { - readSpy.mockResolvedValue({ ok: false, error: "No copilot session found" }); - - expect( - makeProgram().parseAsync(["node", "session-context", "copilot"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("No copilot session found"); - }); - - test("exits 2 when unexpected error is thrown", () => { - readSpy.mockRejectedValue(new Error("Permission denied")); - - expect( - makeProgram().parseAsync(["node", "session-context", "copilot"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(2); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("Permission denied"); - }); - - test("re-throws ExitPromptError", () => { - const exitPromptError = new Error("prompt cancelled"); - exitPromptError.name = "ExitPromptError"; - readSpy.mockRejectedValue(exitPromptError); - - expect( - makeProgram().parseAsync(["node", "session-context", "copilot"]) - ).rejects.toThrow("prompt cancelled"); - - expect(exitSpy).not.toHaveBeenCalled(); - }); - - test("passes findProjectRoot result to reader", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync(["node", "session-context", "copilot"]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); - }); - - test("list subcommand prints sessions", async () => { - listSpy.mockResolvedValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "copilot", - "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const parsed = JSON.parse(output) as { sessions: Array<{ id: string }> }; - expect(parsed.sessions[0]?.id).toBe("abc"); - }); - - test("list subcommand exits 1 on error result", () => { - listSpy.mockResolvedValue({ ok: false, error: "store missing" }); - - expect( - makeProgram().parseAsync(["node", "session-context", "copilot", "list"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("store missing"); - }); - - test("show subcommand reads the given session id", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "copilot", - "show", - "abc123", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - sessionId: "abc123", - }); - }); - - test("show subcommand exits 1 on error result", () => { - readSpy.mockResolvedValue({ - ok: false, - error: "Session not found: abc123", - }); - - expect( - makeProgram().parseAsync([ - "node", - "session-context", - "copilot", - "show", - "abc123", - ]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - test("show subcommand applies --max-entries (hoisted by the parent)", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - // Regression: the parent editor command also declares --max-entries and - // commander hoists parent-known options from anywhere in argv, so the - // child must read the merged value via optsWithGlobals(). - await makeProgram().parseAsync([ - "node", - "session-context", - "copilot", - "show", - "abc123", - "--max-entries", - "2", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: 2, - sessionId: "abc123", - }); - }); -}); diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts deleted file mode 100644 index e1fb7428..00000000 --- a/tests/commands/session-context/cursor.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - spyOn, - test, - type Mock, -} from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Command } from "@commander-js/extra-typings"; - -import { registerCursorSessionContextCommand } from "../../../src/commands/session-context/cursor"; -import * as sessionContextHelpers from "../../../src/helpers/session-context"; -import { safeRmSync } from "../../test-utils"; - -describe("registerCursorSessionContextCommand", () => { - test("registers 'cursor' as a subcommand", () => { - const parent = new Command("session-context"); - registerCursorSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "cursor"); - expect(sub).toBeDefined(); - }); - - test("has a description", () => { - const parent = new Command("session-context"); - registerCursorSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "cursor")!; - expect(sub.description()).toBeTruthy(); - }); - - test("accepts --max-entries option", () => { - const parent = new Command("session-context"); - registerCursorSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "cursor")!; - const opt = sub.options.find((o) => o.long === "--max-entries"); - expect(opt).toBeDefined(); - }); - - test("has list and show subcommands", () => { - const parent = new Command("session-context"); - registerCursorSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "cursor")!; - expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); - }); -}); - -describe("cursor action handler", () => { - let tempDir: string; - let originalCwd: string; - let logSpy: Mock; - let errorSpy: Mock; - let exitSpy: Mock; - let readSpy: Mock; - let listSpy: Mock; - - /** Minimal complete summary for the default happy-path spy. */ - function emptySummary() { - return { - sessionId: "s", - sessionFile: "s.jsonl", - totalEntries: 0, - relevantEntries: 0, - transcript: [], - }; - } - - beforeEach(() => { - // realpathSync normalizes macOS /var → /private/var symlink so the - // path matches what process.cwd() returns after chdir. - tempDir = realpathSync( - mkdtempSync(join(tmpdir(), "archgate-cursor-test-")) - ); - originalCwd = process.cwd(); - mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); - Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; - process.chdir(tempDir); - - readSpy = spyOn(sessionContextHelpers, "readCursorSession"); - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); - logSpy = spyOn(console, "log").mockImplementation(() => {}); - errorSpy = spyOn(console, "error").mockImplementation(() => {}); - exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - }); - - afterEach(() => { - process.chdir(originalCwd); - delete Bun.env.ARCHGATE_PROJECT_CEILING; - safeRmSync(tempDir); - readSpy.mockRestore(); - listSpy.mockRestore(); - logSpy.mockRestore(); - errorSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - function makeProgram(): Command { - const parent = new Command("session-context").exitOverride(); - registerCursorSessionContextCommand(parent); - return parent; - } - - test("prints JSON on successful result", async () => { - // The handler only JSON-serializes `data` verbatim, so a fake shape - // (not the real CursorSessionSummary) is enough to exercise passthrough. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - readSpy.mockResolvedValue({ - ok: true, - data: { entries: [{ role: "user", content: "test" }], total: 1 }, - } as unknown as Awaited< - ReturnType - >); - - await makeProgram().parseAsync(["node", "session-context", "cursor"]); - - expect(logSpy).toHaveBeenCalled(); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - const parsed: unknown = JSON.parse(output); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - expect((parsed as { total: number }).total).toBe(1); - }); - - test("exits 1 when reader returns error result", () => { - readSpy.mockResolvedValue({ ok: false, error: "No cursor session found" }); - - expect( - makeProgram().parseAsync(["node", "session-context", "cursor"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("No cursor session found"); - }); - - test("exits 2 when unexpected error is thrown", () => { - readSpy.mockRejectedValue(new Error("File system error")); - - expect( - makeProgram().parseAsync(["node", "session-context", "cursor"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(2); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("File system error"); - }); - - test("re-throws ExitPromptError", () => { - const exitPromptError = new Error("prompt cancelled"); - exitPromptError.name = "ExitPromptError"; - readSpy.mockRejectedValue(exitPromptError); - - expect( - makeProgram().parseAsync(["node", "session-context", "cursor"]) - ).rejects.toThrow("prompt cancelled"); - - expect(exitSpy).not.toHaveBeenCalled(); - }); - - test("passes findProjectRoot result to reader", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync(["node", "session-context", "cursor"]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); - }); - - test("list subcommand prints sessions", async () => { - listSpy.mockResolvedValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "cursor", - "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const parsed = JSON.parse(output) as { sessions: Array<{ id: string }> }; - expect(parsed.sessions[0]?.id).toBe("abc"); - }); - - test("list subcommand exits 1 on error result", () => { - listSpy.mockResolvedValue({ ok: false, error: "store missing" }); - - expect( - makeProgram().parseAsync(["node", "session-context", "cursor", "list"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("store missing"); - }); - - test("show subcommand reads the given session id", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "cursor", - "show", - "abc123", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - sessionId: "abc123", - }); - }); - - test("show subcommand exits 1 on error result", () => { - readSpy.mockResolvedValue({ - ok: false, - error: "Session not found: abc123", - }); - - expect( - makeProgram().parseAsync([ - "node", - "session-context", - "cursor", - "show", - "abc123", - ]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - test("show subcommand applies --max-entries (hoisted by the parent)", async () => { - readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); - - // Regression: the parent editor command also declares --max-entries and - // commander hoists parent-known options from anywhere in argv, so the - // child must read the merged value via optsWithGlobals(). - await makeProgram().parseAsync([ - "node", - "session-context", - "cursor", - "show", - "abc123", - "--max-entries", - "2", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: 2, - sessionId: "abc123", - }); - }); -}); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts deleted file mode 100644 index 6f2be728..00000000 --- a/tests/commands/session-context/opencode.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { Database } from "bun:sqlite"; -import { - afterEach, - beforeEach, - describe, - expect, - spyOn, - test, - type Mock, -} from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Command } from "@commander-js/extra-typings"; - -import { registerOpencodeSessionContextCommand } from "../../../src/commands/session-context/opencode"; -import * as opencodeHelpers from "../../../src/helpers/session-context-opencode"; -import { runCli } from "../../integration/cli-harness"; -import { safeRmSync } from "../../test-utils"; - -describe("registerOpencodeSessionContextCommand", () => { - test("registers 'opencode' as a subcommand", () => { - const parent = new Command("session-context"); - registerOpencodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "opencode"); - expect(sub).toBeDefined(); - }); - - test("has a description", () => { - const parent = new Command("session-context"); - registerOpencodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "opencode")!; - expect(sub.description()).toBeTruthy(); - }); - - test("accepts --max-entries option", () => { - const parent = new Command("session-context"); - registerOpencodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "opencode")!; - const opt = sub.options.find((o) => o.long === "--max-entries"); - expect(opt).toBeDefined(); - }); - - test("has list and show subcommands; --root only on show", () => { - const parent = new Command("session-context"); - registerOpencodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "opencode")!; - expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); - const show = sub.commands.find((c) => c.name() === "show")!; - expect(show.options.map((o) => o.long)).toContain("--root"); - expect(sub.options.map((o) => o.long)).not.toContain("--root"); - }); -}); - -describe("opencode action handler", () => { - let tempDir: string; - let originalCwd: string; - let logSpy: Mock; - let errorSpy: Mock; - let exitSpy: Mock; - let readSpy: Mock; - let listSpy: Mock; - - /** Minimal complete summary for the default happy-path spy. */ - function emptySummary() { - return { - sessionId: "s", - totalEntries: 0, - relevantEntries: 0, - transcript: [], - }; - } - - beforeEach(() => { - // realpathSync normalizes macOS /var → /private/var symlink so the - // path matches what process.cwd() returns after chdir. - tempDir = realpathSync( - mkdtempSync(join(tmpdir(), "archgate-opencode-test-")) - ); - originalCwd = process.cwd(); - mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); - Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; - process.chdir(tempDir); - - readSpy = spyOn(opencodeHelpers, "readOpencodeSession"); - readSpy.mockReturnValue({ ok: true, data: emptySummary() }); - listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); - logSpy = spyOn(console, "log").mockImplementation(() => {}); - errorSpy = spyOn(console, "error").mockImplementation(() => {}); - exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - }); - - afterEach(() => { - process.chdir(originalCwd); - delete Bun.env.ARCHGATE_PROJECT_CEILING; - safeRmSync(tempDir); - readSpy.mockRestore(); - listSpy.mockRestore(); - logSpy.mockRestore(); - errorSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - function makeProgram(): Command { - const parent = new Command("session-context").exitOverride(); - registerOpencodeSessionContextCommand(parent); - return parent; - } - - test("prints JSON on successful result", async () => { - // The handler only JSON-serializes `data` verbatim, so a fake shape - // (not the real OpencodeSessionSummary) is enough to exercise passthrough. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - readSpy.mockReturnValue({ - ok: true, - data: { entries: [{ role: "assistant", content: "done" }], total: 1 }, - } as unknown as ReturnType); - - await makeProgram().parseAsync(["node", "session-context", "opencode"]); - - expect(logSpy).toHaveBeenCalled(); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - const parsed: unknown = JSON.parse(output); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - expect((parsed as { total: number }).total).toBe(1); - }); - - test("exits 1 when reader returns error result", () => { - readSpy.mockReturnValue({ ok: false, error: "No opencode session found" }); - - expect( - makeProgram().parseAsync(["node", "session-context", "opencode"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("No opencode session found"); - }); - - test("exits 2 when unexpected error is thrown", () => { - readSpy.mockImplementation(() => { - throw new Error("ENOENT: no such file"); - }); - - expect( - makeProgram().parseAsync(["node", "session-context", "opencode"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(2); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("ENOENT: no such file"); - }); - - test("re-throws ExitPromptError", () => { - const exitPromptError = new Error("prompt cancelled"); - exitPromptError.name = "ExitPromptError"; - readSpy.mockImplementation(() => { - throw exitPromptError; - }); - - expect( - makeProgram().parseAsync(["node", "session-context", "opencode"]) - ).rejects.toThrow("prompt cancelled"); - - expect(exitSpy).not.toHaveBeenCalled(); - }); - - test("passes findProjectRoot result to reader", async () => { - readSpy.mockReturnValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync(["node", "session-context", "opencode"]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); - }); - - test("list subcommand prints sessions", async () => { - listSpy.mockReturnValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls.map((c) => String(c[0])).join(""); - // Shape matches the fixture printed above; JSON.parse is untyped by nature. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const parsed = JSON.parse(output) as { sessions: Array<{ id: string }> }; - expect(parsed.sessions[0]?.id).toBe("abc"); - }); - - test("list subcommand exits 1 on error result", () => { - listSpy.mockReturnValue({ ok: false, error: "store missing" }); - - expect( - makeProgram().parseAsync(["node", "session-context", "opencode", "list"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(errorOutput).toContain("store missing"); - }); - - test("show subcommand reads the given session id", async () => { - readSpy.mockReturnValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "show", - "abc123", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - sessionId: "abc123", - root: undefined, - }); - }); - - test("show subcommand exits 1 on error result", () => { - readSpy.mockReturnValue({ ok: false, error: "Session not found: abc123" }); - - expect( - makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "show", - "abc123", - ]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - test("show subcommand applies --max-entries (hoisted by the parent)", async () => { - readSpy.mockReturnValue({ ok: true, data: emptySummary() }); - - // Regression: the parent editor command also declares --max-entries and - // commander hoists parent-known options from anywhere in argv, so the - // child must read the merged value via optsWithGlobals(). - await makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "show", - "abc123", - "--max-entries", - "2", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: 2, - sessionId: "abc123", - root: undefined, - }); - }); - - test("show subcommand passes --root through", async () => { - readSpy.mockReturnValue({ ok: true, data: emptySummary() }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "show", - "abc123", - "--root", - ]); - - expect(readSpy).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - sessionId: "abc123", - root: true, - }); - }); -}); - -describe("opencode list/show (CLI subprocess)", () => { - // Subprocess tests avoid Bun's process-global mock.module state — this - // file mocks the read helper for the in-process tests above, so the - // nested subcommands are exercised against a real temp opencode DB in a - // child process with XDG_DATA_HOME redirected. - let tempHome: string; - let projectDir: string; - let env: Record; - - beforeEach(() => { - tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-oc-home-"))); - projectDir = join(tempHome, "project"); - mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); - env = { - HOME: tempHome, - USERPROFILE: tempHome, - XDG_DATA_HOME: join(tempHome, "xdg"), - }; - }); - - afterEach(() => { - safeRmSync(tempHome); - }); - - /** Seed an opencode DB: parent session + sub-agent child, each with a message. */ - function seedOpencode(): void { - mkdirSync(join(tempHome, "xdg", "opencode"), { recursive: true }); - const db = new Database(join(tempHome, "xdg", "opencode", "opencode.db")); - db.run("PRAGMA journal_mode = DELETE"); - db.run(` - CREATE TABLE session ( - id TEXT PRIMARY KEY, parent_id TEXT, - directory TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', - time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE message ( - id TEXT PRIMARY KEY, session_id TEXT NOT NULL, - time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, - data TEXT NOT NULL DEFAULT '{}' - ); - CREATE TABLE part ( - id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, - time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, - data TEXT NOT NULL DEFAULT '{}' - ); - `); - const addSession = (id: string, parent: string | null, t: number) => { - db.run( - "INSERT INTO session (id, parent_id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", - [ - id, - parent, - projectDir, - id === "ses_parent" ? "main work" : "sub", - t, - t, - ] - ); - db.run( - "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", - [`msg_${id}`, id, t + 1, JSON.stringify({ role: "user" })] - ); - db.run( - "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)", - [ - `prt_${id}`, - `msg_${id}`, - id, - t + 1, - JSON.stringify({ type: "text", text: `content of ${id}` }), - ] - ); - }; - addSession("ses_parent", null, 1000); - addSession("ses_child", "ses_parent", 2000); - db.close(); - } - - test("list returns top-level sessions only", async () => { - seedOpencode(); - - const { exitCode, stdout } = await runCli( - ["session-context", "opencode", "list"], - projectDir, - env - ); - - expect(exitCode).toBe(0); - // Real CLI stdout under test; shape is asserted below, not schema-validated. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const parsed = JSON.parse(stdout) as { - sessions: Array<{ id: string; title: string; updatedAt: string }>; - }; - expect(parsed.sessions.map((s) => s.id)).toEqual(["ses_parent"]); - expect(parsed.sessions[0]?.title).toBe("main work"); - }); - - test("show reads a specific session; --root resolves to the ancestor", async () => { - seedOpencode(); - - const shown = await runCli( - ["session-context", "opencode", "show", "ses_child"], - projectDir, - env - ); - expect(shown.exitCode).toBe(0); - // Real CLI stdout under test; shape is asserted below, not schema-validated. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const shownParsed = JSON.parse(shown.stdout) as { sessionId: string }; - expect(shownParsed.sessionId).toBe("ses_child"); - - const rooted = await runCli( - ["session-context", "opencode", "show", "ses_child", "--root"], - projectDir, - env - ); - expect(rooted.exitCode).toBe(0); - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const rootedParsed = JSON.parse(rooted.stdout) as { sessionId: string }; - expect(rootedParsed.sessionId).toBe("ses_parent"); - }); - - test("show with an unknown id exits 1", async () => { - seedOpencode(); - - const { exitCode, stderr } = await runCli( - ["session-context", "opencode", "show", "ses_nope"], - projectDir, - env - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain("Session not found: ses_nope"); - }); -}); diff --git a/tests/helpers/harness-detect.test.ts b/tests/helpers/harness-detect.test.ts new file mode 100644 index 00000000..66e5343e --- /dev/null +++ b/tests/helpers/harness-detect.test.ts @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + DETECTED_HARNESSES, + detectHarness, + type DetectedHarness, +} from "../../src/helpers/harness-detect"; +import { restoreEnv } from "../test-utils"; + +// Every variable detectHarness() consults. The suite runs inside a real +// harness (CLAUDECODE is set), so all of them are cleared before each test — +// otherwise the ambient environment leaks into every assertion. +const HARNESS_VARS = [ + "ANTIGRAVITY_AGENT", + "ANTIGRAVITY_CONVERSATION_ID", + "CLAUDECODE", + "CLAUDE_CODE_SESSION_ID", + "CODEX_THREAD_ID", + "COPILOT_CLI", + "COPILOT_AGENT_SESSION_ID", + "CURSOR_AGENT", + "CURSOR_CONVERSATION_ID", + "OPENCODE", + "OPENCODE_CLIENT", + "PI_CODING_AGENT", + "PI_SESSION_ID", +] as const; + +const UUID = "261667f2-f770-40fd-bbfd-c70dc1f0a80c"; + +/** Each editor paired with an env var that identifies it. */ +const MARKER_CASES: Array<[DetectedHarness, string]> = [ + ["antigravity", "ANTIGRAVITY_AGENT"], + ["claude-code", "CLAUDECODE"], + ["codex", "CODEX_THREAD_ID"], + ["copilot", "COPILOT_CLI"], + ["cursor", "CURSOR_AGENT"], + ["opencode", "OPENCODE"], + ["opencode", "OPENCODE_CLIENT"], + ["pi", "PI_CODING_AGENT"], +]; + +describe("detectHarness", () => { + const saved = new Map(); + + beforeEach(() => { + for (const key of HARNESS_VARS) { + saved.set(key, Bun.env[key]); + delete Bun.env[key]; + } + }); + + afterEach(() => { + for (const key of HARNESS_VARS) { + restoreEnv(key, saved.get(key)); + } + saved.clear(); + }); + + test("reports no editor when the environment carries no marker", () => { + const result = detectHarness(); + + expect(result.editor).toBeNull(); + expect(result.via).toBeNull(); + expect(result.candidates).toEqual([]); + expect(result.envSessionId).toBeNull(); + }); + + test.each(MARKER_CASES)("detects %s from %s", (editor, marker) => { + Bun.env[marker] = "1"; + + const result = detectHarness(); + + expect(result.editor).toBe(editor); + expect(result.via).toBe(marker); + expect(result.candidates).toEqual([editor]); + }); + + test("every editor the CLI supports is detectable", () => { + // SIGNALS is a list, so an editor added to DETECTED_HARNESSES without a + // signal would compile and simply never be detected. This turns that + // silent gap into a failure. + const covered = new Set(MARKER_CASES.map(([editor]) => editor)); + expect(DETECTED_HARNESSES.filter((h) => !covered.has(h))).toEqual([]); + }); + + test.each<[string, string, string]>([ + ["antigravity", "ANTIGRAVITY_AGENT", "ANTIGRAVITY_CONVERSATION_ID"], + ["claude-code", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID"], + ["codex", "CODEX_THREAD_ID", "CODEX_THREAD_ID"], + ["copilot", "COPILOT_CLI", "COPILOT_AGENT_SESSION_ID"], + ["cursor", "CURSOR_AGENT", "CURSOR_CONVERSATION_ID"], + ["pi", "PI_CODING_AGENT", "PI_SESSION_ID"], + ])("%s publishes its session id via %s", (_editor, marker, idVar) => { + Bun.env[marker] = "1"; + Bun.env[idVar] = UUID; + + expect(detectHarness().envSessionId).toBe(UUID); + }); + + test("reads a nested session id when the flat variable is unset", () => { + // Antigravity's desktop app names the conversation only inside JSON, so + // the nested source has to be consulted without the flat one present. + Bun.env.ANTIGRAVITY_AGENT = "1"; + Bun.env.ANTIGRAVITY_SOURCE_METADATA = JSON.stringify({ + tool: { conversationId: UUID }, + }); + + expect(detectHarness().envSessionId).toBe(UUID); + }); + + test("prefers the flat session id over the nested one", () => { + Bun.env.ANTIGRAVITY_AGENT = "1"; + Bun.env.ANTIGRAVITY_CONVERSATION_ID = UUID; + Bun.env.ANTIGRAVITY_SOURCE_METADATA = JSON.stringify({ + tool: { conversationId: "nested-should-lose" }, + }); + + expect(detectHarness().envSessionId).toBe(UUID); + }); + + test.each([ + ["malformed JSON", "{not json"], + ["a missing path", JSON.stringify({ tool: {} })], + ["a non-string leaf", JSON.stringify({ tool: { conversationId: 7 } })], + ])("rejects a nested session id from %s", (_label, value) => { + Bun.env.ANTIGRAVITY_AGENT = "1"; + Bun.env.ANTIGRAVITY_SOURCE_METADATA = value; + + expect(detectHarness().envSessionId).toBeNull(); + }); + + test("opencode publishes no session id", () => { + Bun.env.OPENCODE = "1"; + + expect(detectHarness().envSessionId).toBeNull(); + }); + + describe("session id rejection", () => { + // An unusable id must read as null, not "". The session readers treat + // sessionId: "" exactly like undefined and fall back to recency, so a "" + // here would make an unset variable indistinguishable from a rejected one. + test.each([ + ["an empty value", ""], + ["the literal string undefined", "undefined"], + ])("rejects %s", (_label, value) => { + Bun.env.CLAUDECODE = "1"; + Bun.env.CLAUDE_CODE_SESSION_ID = value; + + expect(detectHarness().envSessionId).toBeNull(); + }); + + test("rejects a non-UUID cursor conversation id", () => { + // Cursor's sanitizer rewrites % to _, which is lossless only for a + // UUID; a rewritten value could otherwise collide with a real id. + Bun.env.CURSOR_AGENT = "1"; + Bun.env.CURSOR_CONVERSATION_ID = "conversation_with_underscores"; + + const result = detectHarness(); + + expect(result.editor).toBe("cursor"); + expect(result.envSessionId).toBeNull(); + }); + + test("accepts a non-UUID id from harnesses with a lossless id", () => { + Bun.env.CLAUDECODE = "1"; + Bun.env.CLAUDE_CODE_SESSION_ID = "not-a-uuid"; + + expect(detectHarness().envSessionId).toBe("not-a-uuid"); + }); + }); + + describe("precedence", () => { + test("prefers claude-code over every other harness", () => { + Bun.env.CLAUDECODE = "1"; + Bun.env.COPILOT_CLI = "1"; + Bun.env.CURSOR_AGENT = "1"; + Bun.env.OPENCODE = "1"; + + const result = detectHarness(); + + expect(result.editor).toBe("claude-code"); + expect(result.via).toBe("CLAUDECODE"); + }); + + test("ranks opencode last, since it publishes no session id", () => { + Bun.env.CURSOR_AGENT = "1"; + Bun.env.OPENCODE = "1"; + + expect(detectHarness().editor).toBe("cursor"); + }); + + test("reports every match in precedence order", () => { + Bun.env.OPENCODE = "1"; + Bun.env.COPILOT_CLI = "1"; + Bun.env.CLAUDECODE = "1"; + + expect(detectHarness().candidates).toEqual([ + "claude-code", + "copilot", + "opencode", + ]); + }); + + test("takes the session id of the winner, not of a runner-up", () => { + Bun.env.CLAUDECODE = "1"; + Bun.env.CURSOR_AGENT = "1"; + Bun.env.CURSOR_CONVERSATION_ID = UUID; + + const result = detectHarness(); + + expect(result.editor).toBe("claude-code"); + expect(result.envSessionId).toBeNull(); + }); + }); + + test("ignores a marker set to an empty value", () => { + Bun.env.CLAUDECODE = ""; + + expect(detectHarness().editor).toBeNull(); + }); +}); diff --git a/tests/helpers/session-context-antigravity.test.ts b/tests/helpers/session-context-antigravity.test.ts new file mode 100644 index 00000000..f7b37a27 --- /dev/null +++ b/tests/helpers/session-context-antigravity.test.ts @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + listAntigravitySessions, + readAntigravitySession, +} from "../../src/helpers/session-context-antigravity"; +import { restoreEnv, safeRmSync } from "../test-utils"; + +const PROJECT = join(tmpdir(), "__archgate_agy_project"); +const OTHER_PROJECT = join(tmpdir(), "__archgate_agy_other"); + +/** Environment variables the reader consults, cleared for each test. */ +const ENV_VARS = [ + "ANTIGRAVITY_AGENT", + "ANTIGRAVITY_CONVERSATION_ID", + "ANTIGRAVITY_SOURCE_METADATA", +] as const; + +/** + * A workspace URI embedded the way the CLI stores it: inside a protobuf blob, + * so the URI is followed by tag bytes rather than terminated cleanly. + */ +function metadataBlob(workspace: string): Uint8Array { + const uri = `file:///${workspace.replaceAll("\\", "/")}`; + return new Uint8Array([ + 0x0a, + uri.length, + ...new TextEncoder().encode(uri), + 0x1a, + 0x00, + 0x12, + ]); +} + +/** One transcript line, as either distribution writes it. */ +function entry(type: string, content?: string): string { + const base: Record = { + step_index: 0, + source: "USER_EXPLICIT", + type, + status: "DONE", + created_at: "2026-01-01T00:00:00Z", + }; + if (content !== undefined) base.content = content; + return `${JSON.stringify(base)}\n`; +} + +const userEntry = (text: string) => + entry("USER_INPUT", `\n${text}\n`); +const assistantEntry = (text: string) => entry("PLANNER_RESPONSE", text); +/** A planner turn that only made a tool call carries no prose. */ +const toolOnlyEntry = () => entry("PLANNER_RESPONSE", ""); + +describe("Antigravity session reader", () => { + let tempHome: string; + const saved = new Map(); + let savedHome: string | undefined; + let savedUserProfile: string | undefined; + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "archgate-agy-")); + savedHome = Bun.env.HOME; + savedUserProfile = Bun.env.USERPROFILE; + Bun.env.HOME = tempHome; + Bun.env.USERPROFILE = tempHome; + for (const key of ENV_VARS) { + saved.set(key, Bun.env[key]); + delete Bun.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_VARS) restoreEnv(key, saved.get(key)); + saved.clear(); + restoreEnv("HOME", savedHome); + restoreEnv("USERPROFILE", savedUserProfile); + safeRmSync(tempHome); + }); + + /** `antigravity-cli` is the CLI's data directory, `antigravity` the app's. */ + function dataDir(app: "cli" | "ide"): string { + return join( + tempHome, + ".gemini", + app === "cli" ? "antigravity-cli" : "antigravity" + ); + } + + /** Write a conversation transcript into one distribution's store. */ + function writeTranscript( + app: "cli" | "ide", + id: string, + transcript: string, + name = app === "cli" ? "transcript_full.jsonl" : "transcript.jsonl" + ) { + const logs = join(dataDir(app), "brain", id, ".system_generated", "logs"); + mkdirSync(logs, { recursive: true }); + writeFileSync(join(logs, name), transcript); + } + + /** Record a CLI conversation's workspace in its own database. */ + function writeCliWorkspace(id: string, workspace: string) { + const dir = join(dataDir("cli"), "conversations"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, `${id}.db`), { create: true }); + db.run( + "CREATE TABLE trajectory_metadata_blob (id text DEFAULT 'main', data blob, PRIMARY KEY (id))" + ); + db.run("INSERT INTO trajectory_metadata_blob (id, data) VALUES (?, ?)", [ + "main", + metadataBlob(workspace), + ]); + db.close(); + } + + /** Record a workspace in the shared summaries index the app relies on. */ + function writeSummary(id: string, workspace: string) { + const dir = dataDir("cli"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, "conversation_summaries.db"), { + create: true, + }); + db.run( + "CREATE TABLE IF NOT EXISTS conversation_summaries (conversation_id text, workspace_uris text NOT NULL)" + ); + db.run( + "INSERT INTO conversation_summaries (conversation_id, workspace_uris) VALUES (?, ?)", + [id, JSON.stringify([`file:///${workspace.replaceAll("\\", "/")}`])] + ); + db.close(); + } + + test("reports a missing store", async () => { + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Antigravity conversations directory"); + }); + + test("ignores a conversation directory holding no transcript", () => { + mkdirSync(join(dataDir("cli"), "brain", "no-logs"), { recursive: true }); + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("hi")); + + const result = listAntigravitySessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["c1"]); + }); + + test("falls back to the index when a conversation database cannot open", () => { + writeTranscript("cli", "c1", userEntry("hi")); + // A directory where the database belongs: opening it throws. + mkdirSync(join(dataDir("cli"), "conversations", "c1.db"), { + recursive: true, + }); + writeSummary("c1", PROJECT); + + const result = listAntigravitySessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["c1"]); + }); + + test("falls back to the index when the metadata table is absent", () => { + writeTranscript("cli", "c1", userEntry("hi")); + const dir = join(dataDir("cli"), "conversations"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, "c1.db"), { create: true }); + db.run("CREATE TABLE unrelated (id text)"); + db.close(); + writeSummary("c1", PROJECT); + + const result = listAntigravitySessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["c1"]); + }); + + test("reads a conversation when the summaries index cannot open", () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("hi")); + mkdirSync(join(dataDir("cli"), "conversation_summaries.db"), { + recursive: true, + }); + + const result = listAntigravitySessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["c1"]); + }); + + test("reads a conversation when the summaries table is absent", () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("hi")); + const db = new Database(join(dataDir("cli"), "conversation_summaries.db"), { + create: true, + }); + db.run("CREATE TABLE unrelated (id text)"); + db.close(); + + const result = listAntigravitySessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["c1"]); + }); + + test("ignores a workspace URI that cannot be decoded", async () => { + writeTranscript("ide", "c1", userEntry("hi")); + const dir = dataDir("cli"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, "conversation_summaries.db"), { + create: true, + }); + db.run( + "CREATE TABLE conversation_summaries (conversation_id text, workspace_uris text NOT NULL)" + ); + // A stray percent sign makes the URI undecodable. + db.run( + "INSERT INTO conversation_summaries (conversation_id, workspace_uris) VALUES (?, ?)", + ["c1", JSON.stringify(["file:///bad%zz"])] + ); + db.close(); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Antigravity conversations found"); + }); + + test("reports a transcript that cannot be read", async () => { + // A directory named like the transcript: discovered, then unreadable. + mkdirSync( + join( + dataDir("cli"), + "brain", + "c1", + ".system_generated", + "logs", + "transcript_full.jsonl" + ), + { recursive: true } + ); + writeCliWorkspace("c1", PROJECT); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("Failed to read session file"); + }); + + test("reports when no conversation belongs to the project", async () => { + writeCliWorkspace("c1", OTHER_PROJECT); + writeTranscript("cli", "c1", userEntry("hi")); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Antigravity conversations found"); + }); + + test("reads a CLI conversation", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript( + "cli", + "c1", + userEntry("what does this repo do?") + + assistantEntry("It governs AI agents with ADRs.") + ); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "what does this repo do?" }, + { role: "assistant", contentPreview: "It governs AI agents with ADRs." }, + ]); + }); + + test("reads a desktop app conversation via the summaries index", async () => { + // The app keeps its conversations in a separate tree and records no + // workspace of its own, so the shared index supplies it. + writeSummary("ide1", PROJECT); + writeTranscript("ide", "ide1", userEntry("from the app")); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionId).toBe("ide1"); + expect(result.data.sessionFile).toBe("transcript.jsonl"); + }); + + test("reads the caller's own conversation before it is indexed", async () => { + // A live conversation is not in the summaries index yet. It is the + // caller's by definition, so it is admitted without a workspace match. + Bun.env.ANTIGRAVITY_AGENT = "1"; + Bun.env.ANTIGRAVITY_SOURCE_METADATA = JSON.stringify({ + tool: { conversationId: "live" }, + }); + writeTranscript("ide", "live", userEntry("still running")); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionId).toBe("live"); + }); + + test("prefers the untruncated transcript when both exist", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("full"), "transcript_full.jsonl"); + writeTranscript("cli", "c1", userEntry("truncated"), "transcript.jsonl"); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript[0]?.contentPreview).toBe("full"); + }); + + test("strips the USER_REQUEST wrapper from a user turn", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("plain question")); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript[0]?.contentPreview).toBe("plain question"); + }); + + test("drops metadata appended after the closing USER_REQUEST tag", async () => { + const content = + "\nwhat time is it?\n\n\nThe current local time is: 2026-01-01T00:00:00+00:00.\n"; + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", entry("USER_INPUT", content)); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript[0]?.contentPreview).toBe("what time is it?"); + }); + + test("skips tool calls, results, and prose-less planner turns", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript( + "cli", + "c1", + userEntry("list the files") + + toolOnlyEntry() + + entry("RUN_COMMAND") + + entry("LIST_DIRECTORY") + + assistantEntry("Here they are.") + ); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript.map((t) => t.role)).toEqual([ + "user", + "assistant", + ]); + expect(result.data.totalEntries).toBe(5); + }); + + test("matches the workspace despite trailing bytes after the URI", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("still mine")); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toHaveLength(1); + }); + + test("caps the transcript with maxEntries", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript( + "cli", + "c1", + userEntry("one") + userEntry("two") + userEntry("three") + ); + + const result = await readAntigravitySession(PROJECT, { maxEntries: 1 }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toHaveLength(1); + expect(result.data.relevantEntries).toBe(3); + }); + + test("selects a conversation by id", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("first")); + writeCliWorkspace("c2", PROJECT); + writeTranscript("cli", "c2", userEntry("second")); + + const result = await readAntigravitySession(PROJECT, { sessionId: "c2" }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript[0]?.contentPreview).toBe("second"); + }); + + test("reports an unknown id with the available ids", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript("cli", "c1", userEntry("first")); + + const result = await readAntigravitySession(PROJECT, { sessionId: "nope" }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("Session not found: nope"); + expect(result.available).toEqual(["c1"]); + }); + + test("ignores a conversation with no transcript on disk", async () => { + writeCliWorkspace("c1", PROJECT); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Antigravity conversations found"); + }); + + test("reads a transcript that is still being appended to", async () => { + writeCliWorkspace("c1", PROJECT); + writeTranscript( + "cli", + "c1", + `${userEntry("complete")}{"type":"PLANNER_RESPONSE","cont` + ); + + const result = await readAntigravitySession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "complete" }, + ]); + }); + + test("lists conversations from both stores for the project", async () => { + writeCliWorkspace("fromCli", PROJECT); + writeTranscript("cli", "fromCli", userEntry("cli")); + writeSummary("fromIde", PROJECT); + writeTranscript("ide", "fromIde", userEntry("ide")); + writeCliWorkspace("elsewhere", OTHER_PROJECT); + writeTranscript("cli", "elsewhere", userEntry("theirs")); + + const result = listAntigravitySessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id).sort()).toEqual([ + "fromCli", + "fromIde", + ]); + }); +}); diff --git a/tests/helpers/session-context-auto.test.ts b/tests/helpers/session-context-auto.test.ts new file mode 100644 index 00000000..4a3c51f6 --- /dev/null +++ b/tests/helpers/session-context-auto.test.ts @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + spyOn, + test, + type Mock, +} from "bun:test"; +import { + mkdirSync, + mkdtempSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import * as os from "node:os"; +import { join } from "node:path"; + +import { encodeProjectPath } from "../../src/helpers/session-context"; +import { + listAutoSessions, + readAutoSession, + readAutoSessionById, +} from "../../src/helpers/session-context-auto"; +import { UserError } from "../../src/helpers/user-error"; +import { restoreEnv } from "../test-utils"; + +// XDG_DATA_HOME is managed alongside the harness markers so the opencode +// reader resolves its database under the temp home. `os.homedir()` alone is +// not enough: opencodeDbPath() falls back to Bun.env.HOME, which the spy does +// not redirect, and the suite would otherwise read the real ~/.local/share. +const HARNESS_VARS = [ + // Every detection marker, so an ambient one cannot select an editor these + // tests did not ask for. The suite may itself run inside any of them. + "ANTIGRAVITY_AGENT", + "ANTIGRAVITY_CONVERSATION_ID", + "ANTIGRAVITY_SOURCE_METADATA", + "CLAUDECODE", + "CLAUDE_CODE_SESSION_ID", + "CODEX_THREAD_ID", + "COPILOT_CLI", + "COPILOT_AGENT_SESSION_ID", + "CURSOR_AGENT", + "CURSOR_CONVERSATION_ID", + "OPENCODE", + "OPENCODE_CLIENT", + "PI_CODING_AGENT", + "PI_SESSION_ID", + // Store locations, so the dispatch tests read the temp home rather than the + // developer's real ~/.codex and ~/.pi. + "XDG_DATA_HOME", + "CODEX_HOME", + "PI_CODING_AGENT_DIR", + "PI_CODING_AGENT_SESSION_DIR", + // Antigravity and Copilot resolve their stores from the home directory, + // which the os.homedir() spy does not reach. + "HOME", + "USERPROFILE", +] as const; + +const PROJECT_ROOT = "/__archgate_auto_project"; + +const OLDER_SESSION = "11111111-1111-4111-8111-111111111111"; +const NEWER_SESSION = "22222222-2222-4222-8222-222222222222"; + +/** A JSONL transcript of `entries` user messages. */ +function transcript(marker: string, entries = 1): string { + return Array.from( + { length: entries }, + (_, i) => + `${JSON.stringify({ + type: "user", + message: { role: "user", content: `${marker}-${i}` }, + })}\n` + ).join(""); +} + +/** Entry count in the newest session's fixture, for the trimming assertions. */ +const NEWER_ENTRY_COUNT = 3; + +/** + * Number of transcript entries in a reader payload, or -1 when the field is + * absent or not an array. Narrowed with `in` so the loosely typed `data` + * needs no assertion. + */ +function transcriptLength(data: object): number { + if (!("transcript" in data)) return -1; + const entries = data.transcript; + return Array.isArray(entries) ? entries.length : -1; +} + +describe("session-context auto resolution", () => { + const saved = new Map(); + let tempHome: string; + let homedirSpy: Mock; + + beforeEach(async () => { + for (const key of HARNESS_VARS) { + saved.set(key, Bun.env[key]); + delete Bun.env[key]; + } + + tempHome = mkdtempSync(join(os.tmpdir(), "archgate-auto-session-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(tempHome); + Bun.env.XDG_DATA_HOME = join(tempHome, ".local", "share"); + Bun.env.CODEX_HOME = join(tempHome, ".codex"); + Bun.env.PI_CODING_AGENT_DIR = join(tempHome, ".pi", "agent"); + Bun.env.HOME = tempHome; + Bun.env.USERPROFILE = tempHome; + + // Derived from the encoder, not restated — a hand-rolled copy drifts + // silently. encodeProjectPath's output is asserted in session-context.test.ts. + const encodedProject = await encodeProjectPath(PROJECT_ROOT); + const projectsDir = join(tempHome, ".claude", "projects", encodedProject); + mkdirSync(projectsDir, { recursive: true }); + + // Written oldest-first so the recency order is unambiguous. + writeFileSync( + join(projectsDir, `${OLDER_SESSION}.jsonl`), + transcript("older-session") + ); + const past = new Date(Date.now() - 60_000); + writeFileSync( + join(projectsDir, `${NEWER_SESSION}.jsonl`), + transcript("newer-session", NEWER_ENTRY_COUNT) + ); + // Force OLDER_SESSION to be genuinely older than NEWER_SESSION. + utimesSync(join(projectsDir, `${OLDER_SESSION}.jsonl`), past, past); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + rmSync(tempHome, { recursive: true, force: true }); + for (const key of HARNESS_VARS) { + restoreEnv(key, saved.get(key)); + } + saved.clear(); + }); + + describe("readAutoSession", () => { + test("pins the session the harness published", async () => { + Bun.env.CLAUDECODE = "1"; + Bun.env.CLAUDE_CODE_SESSION_ID = OLDER_SESSION; + + const result = await readAutoSession(PROJECT_ROOT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.session).toBe("pinned"); + expect(result.detection.editor).toBe("claude-code"); + // The pinned session is NOT the most recent one — proving the id was + // honored rather than recency quietly agreeing with it. + expect(result.data).toMatchObject({ + sessionFile: `${OLDER_SESSION}.jsonl`, + }); + }); + + test("falls back to recency when the published id matches nothing", async () => { + // A stale id must degrade, never error: every reader hard-fails on an + // unknown sessionId, so passing it through would break the command. + Bun.env.CLAUDECODE = "1"; + Bun.env.CLAUDE_CODE_SESSION_ID = "99999999-9999-4999-8999-999999999999"; + + const result = await readAutoSession(PROJECT_ROOT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.session).toBe("recent"); + expect(result.data).toMatchObject({ + sessionFile: `${NEWER_SESSION}.jsonl`, + }); + }); + + test("falls back to recency when the harness publishes no id", async () => { + Bun.env.CLAUDECODE = "1"; + + const result = await readAutoSession(PROJECT_ROOT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.session).toBe("recent"); + expect(result.data).toMatchObject({ + sessionFile: `${NEWER_SESSION}.jsonl`, + }); + }); + + test("treats an empty published id as absent, not as a pin", async () => { + Bun.env.CLAUDECODE = "1"; + Bun.env.CLAUDE_CODE_SESSION_ID = ""; + + const result = await readAutoSession(PROJECT_ROOT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.session).toBe("recent"); + }); + + test("reports the reader's failure when no session exists", async () => { + Bun.env.CLAUDECODE = "1"; + + const result = await readAutoSession("/no/such/project"); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No session files found"); + }); + + test("rejects an undetectable environment with actionable guidance", async () => { + expect(readAutoSession(PROJECT_ROOT)).rejects.toThrow(UserError); + }); + + test("points at --editor when detection fails", async () => { + expect(readAutoSession(PROJECT_ROOT)).rejects.toThrow( + /--editor /u + ); + }); + + test("returns every entry when maxEntries is not given", async () => { + Bun.env.CLAUDECODE = "1"; + + const result = await readAutoSession(PROJECT_ROOT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(transcriptLength(result.data)).toBe(NEWER_ENTRY_COUNT); + }); + + test("caps the transcript with maxEntries", async () => { + // The newest fixture holds NEWER_ENTRY_COUNT entries, so a cap of 1 + // must actually trim — otherwise dropping maxEntries would still pass. + Bun.env.CLAUDECODE = "1"; + + const result = await readAutoSession(PROJECT_ROOT, { maxEntries: 1 }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(transcriptLength(result.data)).toBe(1); + // The pre-trim count is reported alongside the trimmed transcript. + expect(result.data).toMatchObject({ relevantEntries: NEWER_ENTRY_COUNT }); + }); + }); + + describe("explicit editor override", () => { + test("reads the named editor when nothing is detected", async () => { + const result = await readAutoSession(PROJECT_ROOT, { + editor: "claude-code", + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.editor).toBe("claude-code"); + expect(result.detection.via).toBe("--editor"); + }); + + test("overrides the detected editor", async () => { + Bun.env.CLAUDECODE = "1"; + + const result = await listAutoSessions(PROJECT_ROOT, { + editor: "opencode", + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("opencode"); + }); + + test("pins when the named editor is the one that published the id", async () => { + Bun.env.CLAUDECODE = "1"; + Bun.env.CLAUDE_CODE_SESSION_ID = OLDER_SESSION; + + const result = await readAutoSession(PROJECT_ROOT, { + editor: "claude-code", + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.session).toBe("pinned"); + }); + + test("ignores a session id published by a different editor", async () => { + // Cursor's conversation id must never pin a Claude Code session, even + // though both are bare UUIDs and could collide by construction. + Bun.env.CURSOR_AGENT = "1"; + Bun.env.CURSOR_CONVERSATION_ID = OLDER_SESSION; + + const result = await readAutoSession(PROJECT_ROOT, { + editor: "claude-code", + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.session).toBe("recent"); + expect(result.data).toMatchObject({ + sessionFile: `${NEWER_SESSION}.jsonl`, + }); + }); + }); + + describe("dispatch", () => { + // Each editor must reach its own reader. The temp home holds only Claude + // Code fixtures, so any other editor answering with its own storage error + // proves the call was routed there rather than to a default. + test.each([ + ["antigravity", "Antigravity"], + ["codex", "Codex"], + ["copilot", "Copilot"], + ["cursor", "Cursor"], + ["opencode", "opencode"], + ["pi", "Pi"], + ] as const)("routes %s reads to its own reader", async (editor, marker) => { + const result = await readAutoSession(PROJECT_ROOT, { editor }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain(marker); + }); + + test.each([ + ["antigravity", "Antigravity"], + ["codex", "Codex"], + ["copilot", "Copilot"], + ["cursor", "Cursor"], + ["opencode", "opencode"], + ["pi", "Pi"], + ] as const)("routes %s lists to its own reader", async (editor, marker) => { + const result = await listAutoSessions(PROJECT_ROOT, { editor }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain(marker); + }); + }); + + describe("--root", () => { + test.each(["claude-code", "copilot", "cursor"] as const)( + "rejects --root for %s, which has no session graph", + async (editor) => { + expect( + readAutoSession(PROJECT_ROOT, { editor, root: true }) + ).rejects.toThrow(UserError); + } + ); + + test("accepts --root for opencode", async () => { + // opencode has no database here, so reaching its reader's own error + // proves the guard let the call through. + const result = await readAutoSession(PROJECT_ROOT, { + editor: "opencode", + root: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("opencode"); + }); + }); + + describe("readAutoSessionById", () => { + test("an explicit id outranks the one the harness published", async () => { + Bun.env.CLAUDECODE = "1"; + Bun.env.CLAUDE_CODE_SESSION_ID = NEWER_SESSION; + + const result = await readAutoSessionById(PROJECT_ROOT, OLDER_SESSION); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.session).toBe("explicit"); + expect(result.data).toMatchObject({ + sessionFile: `${OLDER_SESSION}.jsonl`, + }); + }); + + test("surfaces the reader's error for an unknown id", async () => { + Bun.env.CLAUDECODE = "1"; + + const result = await readAutoSessionById(PROJECT_ROOT, "no-such-id"); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("Session not found"); + }); + + test("rejects an undetectable environment", async () => { + expect(readAutoSessionById(PROJECT_ROOT, OLDER_SESSION)).rejects.toThrow( + UserError + ); + }); + }); + + describe("listAutoSessions", () => { + test("lists the detected editor's sessions, most recent first", async () => { + Bun.env.CLAUDECODE = "1"; + + const result = await listAutoSessions(PROJECT_ROOT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.detection.editor).toBe("claude-code"); + expect(result.sessions.map((s) => s.id)).toEqual([ + NEWER_SESSION, + OLDER_SESSION, + ]); + }); + + test("routes to the detected editor rather than a default", async () => { + // opencode has no database in the temp home, so its own error proves + // the call was dispatched to the opencode reader. + Bun.env.OPENCODE = "1"; + + const result = await listAutoSessions(PROJECT_ROOT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("opencode"); + }); + + test("rejects an undetectable environment", async () => { + expect(listAutoSessions(PROJECT_ROOT)).rejects.toThrow(UserError); + }); + }); +}); diff --git a/tests/helpers/session-context-codex.test.ts b/tests/helpers/session-context-codex.test.ts new file mode 100644 index 00000000..fca2b3a2 --- /dev/null +++ b/tests/helpers/session-context-codex.test.ts @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + listCodexSessions, + readCodexSession, +} from "../../src/helpers/session-context-codex"; +import { restoreEnv, safeRmSync } from "../test-utils"; + +const PROJECT = join(tmpdir(), "__archgate_codex_project"); +const OTHER_PROJECT = join(tmpdir(), "__archgate_codex_other"); + +/** The `session_meta` line, which carries the cwd a rollout belongs to. */ +function meta(id: string, cwd: string): string { + return `${JSON.stringify({ + timestamp: "2026-01-01T00:00:00.000Z", + type: "session_meta", + payload: { session_id: id, id, cwd, originator: "codex_cli_rs" }, + })}\n`; +} + +/** An `event_msg` conversation line, the shape the desktop app writes. */ +function event(type: string, message: string): string { + return `${JSON.stringify({ + timestamp: "2026-01-01T00:00:01.000Z", + type: "event_msg", + payload: { type, message }, + })}\n`; +} + +/** + * An `item_completed` conversation line, the shape the CLI writes: the text + * is nested in content blocks rather than flattened into `message`. + */ +function itemEvent(itemType: string, text: string): string { + return `${JSON.stringify({ + timestamp: "2026-01-01T00:00:01.000Z", + type: "event_msg", + payload: { + type: "item_completed", + thread_id: "t1", + item: { type: itemType, id: "i1", content: [{ type: "text", text }] }, + }, + })}\n`; +} + +describe("Codex session reader", () => { + let codexHome: string; + let savedHome: string | undefined; + + beforeEach(() => { + codexHome = mkdtempSync(join(tmpdir(), "archgate-codex-")); + savedHome = Bun.env.CODEX_HOME; + Bun.env.CODEX_HOME = codexHome; + }); + + afterEach(() => { + restoreEnv("CODEX_HOME", savedHome); + safeRmSync(codexHome); + }); + + /** Write a rollout into the YYYY/MM/DD shard Codex uses. */ + function writeRollout(id: string, cwd: string, body: string, day = "01") { + const dir = join(codexHome, "sessions", "2026", "01", day); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `rollout-2026-01-${day}T00-00-00-${id}.jsonl`), + meta(id, cwd) + body + ); + } + + /** Write a zstd-compressed rollout, as Codex does after seven days. */ + function writeCompressedRollout(id: string, cwd: string, body: string) { + const dir = join(codexHome, "sessions", "2026", "01", "02"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `rollout-2026-01-02T00-00-00-${id}.jsonl.zst`), + Bun.zstdCompressSync(Buffer.from(meta(id, cwd) + body)) + ); + } + + test("reports a missing sessions directory", async () => { + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Codex sessions directory found"); + }); + + test("tolerates a sessions path that is not a directory", async () => { + writeFileSync(join(codexHome, "sessions"), "not a directory"); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Codex sessions found for this project"); + }); + + test("skips a rollout whose compressed body is corrupt", async () => { + const dir = join(codexHome, "sessions", "2026", "01", "02"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "rollout-2026-01-02T00-00-00-id-corrupt.jsonl.zst"), + Buffer.from("this is not a zstd frame") + ); + writeRollout("id-good", PROJECT, event("user_message", "hi")); + + const result = await listCodexSessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["id-good"]); + }); + + test("reports when no rollout belongs to the project", async () => { + writeRollout("id-other", OTHER_PROJECT, event("user_message", "hi")); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Codex sessions found for this project"); + }); + + test("reads user and agent turns", async () => { + writeRollout( + "id-1", + PROJECT, + event("user_message", "hello") + event("agent_message", "hi there") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionId).toBe("id-1"); + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "hello" }, + { role: "assistant", contentPreview: "hi there" }, + ]); + }); + + test("reads turns recorded as item_completed events", async () => { + // The CLI nests turn text in item_completed content blocks, where the + // desktop app flattens it into `message`. + writeRollout( + "id-cli", + PROJECT, + itemEvent("UserMessage", "hello from the CLI") + + itemEvent("AgentMessage", "hi there") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "hello from the CLI" }, + { role: "assistant", contentPreview: "hi there" }, + ]); + }); + + test("skips item_completed events that are not conversation turns", async () => { + writeRollout( + "id-1", + PROJECT, + itemEvent("UserMessage", "keep") + + itemEvent("Reasoning", "internal") + + itemEvent("CommandExecution", "ls") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript.map((t) => t.role)).toEqual(["user"]); + }); + + test("reads both event shapes without double-counting", async () => { + writeRollout( + "id-1", + PROJECT, + event("user_message", "flat") + itemEvent("AgentMessage", "nested") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "flat" }, + { role: "assistant", contentPreview: "nested" }, + ]); + }); + + test("reads a zstd-compressed rollout", async () => { + // Codex compresses rollouts older than seven days in place, so a reader + // that handled only .jsonl would see nothing beyond the last week. + writeCompressedRollout( + "id-old", + PROJECT, + event("user_message", "from the archive") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionFile).toEndWith(".jsonl.zst"); + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "from the archive" }, + ]); + }); + + test("ignores response_item lines that repeat the same turns", async () => { + // Rollouts carry the conversation twice; counting both would duplicate it. + const responseItem = `${JSON.stringify({ + timestamp: "2026-01-01T00:00:01.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "hello" }], + }, + })}\n`; + writeRollout( + "id-1", + PROJECT, + event("user_message", "hello") + responseItem + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toHaveLength(1); + }); + + test("skips non-conversation event types", async () => { + writeRollout( + "id-1", + PROJECT, + event("user_message", "keep") + event("token_count", "drop") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript.map((t) => t.role)).toEqual(["user"]); + }); + + test("caps the transcript with maxEntries", async () => { + writeRollout( + "id-1", + PROJECT, + event("user_message", "one") + + event("user_message", "two") + + event("user_message", "three") + ); + + const result = await readCodexSession(PROJECT, { maxEntries: 1 }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toHaveLength(1); + expect(result.data.relevantEntries).toBe(3); + }); + + test("selects a rollout by thread id", async () => { + writeRollout("id-1", PROJECT, event("user_message", "first")); + writeRollout("id-2", PROJECT, event("user_message", "second"), "03"); + + const result = await readCodexSession(PROJECT, { sessionId: "id-2" }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript[0]?.contentPreview).toBe("second"); + }); + + test("reports an unknown thread id with the available ids", async () => { + writeRollout("id-1", PROJECT, event("user_message", "first")); + + const result = await readCodexSession(PROJECT, { sessionId: "nope" }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("Session not found: nope"); + expect(result.available).toEqual(["id-1"]); + }); + + test("reports a missing sessions directory when listing", async () => { + const result = await listCodexSessions(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Codex sessions directory found"); + }); + + test("falls back to the filename thread id when session_meta omits it", async () => { + const dir = join(codexHome, "sessions", "2026", "01", "01"); + mkdirSync(dir, { recursive: true }); + const line = `${JSON.stringify({ + timestamp: "2026-01-01T00:00:00.000Z", + type: "session_meta", + payload: { cwd: PROJECT }, + })}\n`; + writeFileSync( + join(dir, "rollout-2026-01-01T00-00-00-from-filename.jsonl"), + line + event("user_message", "hi") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionId).toBe("from-filename"); + }); + + test("lists only rollouts for the project", async () => { + writeRollout("id-1", PROJECT, event("user_message", "mine")); + writeRollout("id-other", OTHER_PROJECT, event("user_message", "theirs")); + + const result = await listCodexSessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["id-1"]); + }); + + test("ignores a rollout with no session_meta line", async () => { + const dir = join(codexHome, "sessions", "2026", "01", "01"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "rollout-2026-01-01T00-00-00-id-nometa.jsonl"), + event("user_message", "orphan") + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(false); + }); + + test("reads a rollout that is still being appended to", async () => { + // The live rollout is the common case, and its last line can be a + // half-written record. Everything already flushed must still come back. + writeRollout( + "id-1", + PROJECT, + `${event("user_message", "complete")}{"type":"event_msg","payl` + ); + + const result = await readCodexSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "complete" }, + ]); + }); +}); diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index 8fc771c2..775b93d6 100644 --- a/tests/helpers/session-context-copilot.test.ts +++ b/tests/helpers/session-context-copilot.test.ts @@ -164,6 +164,30 @@ describe("readCopilotSession", () => { expect(result.data.transcript[1]?.contentPreview).toBe("also visible"); }); + test("skips an assistant turn that carries only tool calls", async () => { + // Copilot records a tool-only turn as an assistant.message with empty + // content and the calls in `toolRequests`, which reads as a blank turn. + const sessionId = `copilot-${uniqueId}-toolonly`; + makeSession(sessionId, projectRoot, [ + JSON.stringify({ type: "user.message", data: { content: "run it" } }), + JSON.stringify({ + type: "assistant.message", + data: { content: "", toolRequests: [{ name: "bash" }] }, + }), + JSON.stringify({ type: "assistant.message", data: { content: "done" } }), + ]); + + const result = await readCopilotSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.data.relevantEntries).toBe(2); + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "run it" }, + { role: "assistant", contentPreview: "done" }, + ]); + }); + test("returns error when session has no events.jsonl", async () => { const sessionId = `copilot-${uniqueId}-noevents`; makeSession(sessionId, projectRoot); // no events array diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts index 2d67478a..ceb2d7e8 100644 --- a/tests/helpers/session-context-cursor.test.ts +++ b/tests/helpers/session-context-cursor.test.ts @@ -20,6 +20,7 @@ import * as os from "node:os"; import { join } from "node:path"; import { + encodeProjectPath, listCursorSessions, readCursorSession, } from "../../src/helpers/session-context"; @@ -32,18 +33,17 @@ describe("readCursorSession", () => { // ~/.cursor/projects. A HOME env override does NOT work here — Bun caches // homedir() on Linux — so the implementation is mocked instead (ARCH-005). const projectRoot = "/__archgate_cursor_test_project"; - const encodedProject = projectRoot - .replaceAll("/", "-") - .replaceAll("\\", "-") - .replaceAll(":", "") - .replaceAll(".", "-"); let tempHome: string; let homedirSpy: Mock; let transcriptsDir: string; - beforeEach(() => { + beforeEach(async () => { tempHome = mkdtempSync(join(os.tmpdir(), "archgate-cursor-session-")); homedirSpy = spyOn(os, "homedir").mockReturnValue(tempHome); + // Derived from the encoder rather than restated here: a hand-rolled copy + // drifts silently, and these tests exercise the reader, not the encoding. + // encodeProjectPath's own output is asserted in session-context.test.ts. + const encodedProject = await encodeProjectPath(projectRoot, "cursor"); transcriptsDir = join( tempHome, ".cursor", diff --git a/tests/helpers/session-context-pi.test.ts b/tests/helpers/session-context-pi.test.ts new file mode 100644 index 00000000..bdfa90be --- /dev/null +++ b/tests/helpers/session-context-pi.test.ts @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + encodePiProjectDir, + listPiSessions, + readPiSession, +} from "../../src/helpers/session-context-pi"; +import { restoreEnv, safeRmSync } from "../test-utils"; + +const PROJECT = join(tmpdir(), "__archgate_pi_project"); +const OTHER_PROJECT = join(tmpdir(), "__archgate_pi_other"); + +/** A session header line, as Pi writes it at session creation. */ +function header(id: string, cwd: string): string { + return `${JSON.stringify({ + type: "session", + version: 3, + id, + timestamp: new Date(0).toISOString(), + cwd, + })}\n`; +} + +/** A `message` entry carrying one text block. */ +function message(role: string, text: string): string { + return `${JSON.stringify({ + type: "message", + timestamp: new Date(0).toISOString(), + message: { role, content: [{ type: "text", text }] }, + })}\n`; +} + +describe("encodePiProjectDir", () => { + // Mirrors Pi's own getDefaultSessionDirPath: drop one leading separator, + // map / \ and : to a dash, wrap in double dashes. Runs are not collapsed + // and dots survive, unlike Cursor's slug. + test.each<[string, string]>([ + ["/home/user/project", "--home-user-project--"], + // A drive letter yields two dashes: the colon and the separator after it + // are each replaced, and runs are not collapsed. + ["E:\\archgate\\cli", "--E--archgate-cli--"], + ["E:\\archgate\\cli\\.claude\\wt", "--E--archgate-cli-.claude-wt--"], + ["/a//b", "--a--b--"], + ])("encodes %p -> %p", (input, expected) => { + expect(encodePiProjectDir(input)).toBe(expected); + }); +}); + +describe("Pi session reader", () => { + let tempHome: string; + let savedSessionDir: string | undefined; + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "archgate-pi-")); + savedSessionDir = Bun.env.PI_CODING_AGENT_SESSION_DIR; + Bun.env.PI_CODING_AGENT_SESSION_DIR = join(tempHome, "sessions"); + }); + + afterEach(() => { + restoreEnv("PI_CODING_AGENT_SESSION_DIR", savedSessionDir); + safeRmSync(tempHome); + }); + + /** Write a session file into the shard for `cwd`. */ + function writeSession(name: string, id: string, cwd: string, body: string) { + const dir = join(tempHome, "sessions", encodePiProjectDir(cwd)); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.jsonl`), header(id, cwd) + body); + } + + test("reports a missing sessions directory", async () => { + Bun.env.PI_CODING_AGENT_SESSION_DIR = join(tempHome, "absent"); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Pi sessions directory found"); + }); + + test("tolerates a shard path that is not a directory", async () => { + // The shard name is derived from the project path, so an unrelated file + // can occupy it. Listing its entries fails and the scan yields nothing. + const sessions = join(tempHome, "sessions"); + mkdirSync(sessions, { recursive: true }); + writeFileSync(join(sessions, encodePiProjectDir(PROJECT)), "not a dir"); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Pi sessions found for this project"); + }); + + test("skips a session entry whose header cannot be read", async () => { + writeSession("good", "id-good", PROJECT, message("user", "hi")); + // A directory named like a session file: enumerated, but unreadable. + mkdirSync( + join(tempHome, "sessions", encodePiProjectDir(PROJECT), "broken.jsonl"), + { recursive: true } + ); + + const result = await listPiSessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["id-good"]); + }); + + test("reports when no session belongs to the project", async () => { + writeSession("s1", "id-other", OTHER_PROJECT, message("user", "hi")); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Pi sessions found for this project"); + }); + + test("reads user and assistant turns", async () => { + writeSession( + "s1", + "id-1", + PROJECT, + message("user", "hello") + message("assistant", "hi there") + ); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionId).toBe("id-1"); + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "hello" }, + { role: "assistant", contentPreview: "hi there" }, + ]); + }); + + test("skips tool results and bash executions", async () => { + // Pi gives tool output its own role rather than folding it into `user`, + // so a role filter is enough to keep the transcript conversational. + writeSession( + "s1", + "id-1", + PROJECT, + message("user", "run it") + + message("toolResult", "tool output") + + message("bashExecution", "$ ls") + + message("assistant", "done") + ); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript.map((t) => t.role)).toEqual([ + "user", + "assistant", + ]); + }); + + test("accepts string content as well as block arrays", async () => { + const line = `${JSON.stringify({ + type: "message", + message: { role: "user", content: "plain string" }, + })}\n`; + writeSession("s1", "id-1", PROJECT, line); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "plain string" }, + ]); + }); + + test("ignores a session whose header names another project", async () => { + // The shard encodes the project, but a relocated session dir does not, so + // the header cwd is the authority. + const dir = join(tempHome, "sessions", encodePiProjectDir(PROJECT)); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "impostor.jsonl"), + header("id-x", OTHER_PROJECT) + message("user", "not mine") + ); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(false); + }); + + test("skips a turn that carries no prose", async () => { + // A turn that only made a tool call or thought has empty content; + // emitting it would pad the transcript with blank entries. + const empty = `${JSON.stringify({ + type: "message", + message: { role: "assistant", content: [] }, + })}\n`; + writeSession("s1", "id-1", PROJECT, empty + message("assistant", "real")); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "assistant", contentPreview: "real" }, + ]); + }); + + test("follows the active branch after a fork", async () => { + // Pi branches in place: /fork and /rewind leave the abandoned entries in + // the same file, linked by id/parentId. Reading linearly would interleave + // the abandoned turn with the live conversation. + const linked = (id: string, parentId: string, role: string, text: string) => + `${JSON.stringify({ + type: "message", + id, + parentId, + message: { role, content: [{ type: "text", text }] }, + })}\n`; + + writeSession( + "s1", + "id-1", + PROJECT, + linked("a", "root", "user", "shared question") + + linked("abandoned", "a", "assistant", "discarded answer") + + linked("b", "a", "assistant", "kept answer") + ); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "shared question" }, + { role: "assistant", contentPreview: "kept answer" }, + ]); + }); + + test("caps the transcript with maxEntries", async () => { + writeSession( + "s1", + "id-1", + PROJECT, + message("user", "one") + message("user", "two") + message("user", "three") + ); + + const result = await readPiSession(PROJECT, { maxEntries: 1 }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toHaveLength(1); + expect(result.data.relevantEntries).toBe(3); + }); + + test("selects a session by id", async () => { + writeSession("s1", "id-1", PROJECT, message("user", "first")); + writeSession("s2", "id-2", PROJECT, message("user", "second")); + + const result = await readPiSession(PROJECT, { sessionId: "id-2" }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript[0]?.contentPreview).toBe("second"); + }); + + test("reports an unknown session id with the available ids", async () => { + writeSession("s1", "id-1", PROJECT, message("user", "first")); + + const result = await readPiSession(PROJECT, { sessionId: "nope" }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("Session not found: nope"); + expect(result.available).toEqual(["id-1"]); + }); + + test("reports a missing sessions directory when listing", async () => { + Bun.env.PI_CODING_AGENT_SESSION_DIR = join(tempHome, "absent"); + + const result = await listPiSessions(PROJECT); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("No Pi sessions directory found"); + }); + + test("resolves sessions under PI_CODING_AGENT_DIR", async () => { + // The agent-dir override relocates the whole config tree; sessions sit + // beneath it unless the session-dir override also applies. + const savedSessionDir = Bun.env.PI_CODING_AGENT_SESSION_DIR; + const savedAgentDir = Bun.env.PI_CODING_AGENT_DIR; + delete Bun.env.PI_CODING_AGENT_SESSION_DIR; + Bun.env.PI_CODING_AGENT_DIR = join(tempHome, "agent"); + try { + const dir = join( + tempHome, + "agent", + "sessions", + encodePiProjectDir(PROJECT) + ); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "s1.jsonl"), + header("id-agentdir", PROJECT) + message("user", "via agent dir") + ); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionId).toBe("id-agentdir"); + } finally { + restoreEnv("PI_CODING_AGENT_DIR", savedAgentDir); + restoreEnv("PI_CODING_AGENT_SESSION_DIR", savedSessionDir); + } + }); + + test("falls back to the filename when the header carries no id", async () => { + const dir = join(tempHome, "sessions", encodePiProjectDir(PROJECT)); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "2026-01-01T00-00-00_fallback.jsonl"), + `${JSON.stringify({ type: "session", cwd: PROJECT })}\n${message("user", "x")}` + ); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessionId).toBe("2026-01-01T00-00-00_fallback"); + }); + + test("lists only sessions for the project", async () => { + writeSession("s1", "id-1", PROJECT, message("user", "mine")); + writeSession("s2", "id-other", OTHER_PROJECT, message("user", "theirs")); + + const result = await listPiSessions(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.sessions.map((s) => s.id)).toEqual(["id-1"]); + }); + + test("reads a session that is still being appended to", async () => { + // The live session is the common case, and its last line can be a + // half-written record. Everything already flushed must still come back. + writeSession( + "s1", + "id-1", + PROJECT, + `${message("user", "complete")}{"type":"message","message":{"role":"assis` + ); + + const result = await readPiSession(PROJECT); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.transcript).toEqual([ + { role: "user", contentPreview: "complete" }, + ]); + }); +}); diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index ebf6bcb8..11ad1a0d 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -41,19 +41,35 @@ describe("encodeProjectPath", () => { ], ["C:\\Users\\user\\project", "cursor", "C-Users-user-project"], ["C:\\Users/user\\project", "cursor", "C-Users-user-project"], + // Cursor collapses each separator run to one dash, so a dot-segment + // yields "-claude-", not "--claude-". [ "E:\\archgate\\cli\\.claude\\worktrees\\fancy-prancing-sedgewick", "cursor", - "E-archgate-cli--claude-worktrees-fancy-prancing-sedgewick", + "E-archgate-cli-claude-worktrees-fancy-prancing-sedgewick", ], + ["/home/user/.config/project", "cursor", "home-user-config-project"], + ["/a//b", "cursor", "a-b"], + // Leading and trailing dashes are trimmed. + ["/home/user/project", "cursor", "home-user-project"], + ["/trailing/", "cursor", "trailing"], + ["project", "cursor", "project"], + ["", "cursor", ""], ])("encodes %p (target=%p) -> %p", async (input, target, expected) => { expect(await encodeProjectPath(input, target)).toBe(expected); }); - test("cursor target produces same result as default for Unix paths", async () => { - const unixPath = "/home/user/project"; - expect(await encodeProjectPath(unixPath, "cursor")).toBe( - await encodeProjectPath(unixPath) + test("cursor collapses separator runs the default target preserves", async () => { + // A project under a dot-directory — every git worktree in .claude/ — is + // where the two encodings diverge, and where reusing one for the other + // resolves to a directory that does not exist. + const worktree = "E:\\project\\.claude\\worktrees\\wt"; + + expect(await encodeProjectPath(worktree, "cursor")).toBe( + "E-project-claude-worktrees-wt" + ); + expect(await encodeProjectPath(worktree)).toBe( + "E--project--claude-worktrees-wt" ); }); }); @@ -129,18 +145,16 @@ describe("readClaudeCodeSession", () => { // ~/.claude/projects. A HOME env override does NOT work here — Bun caches // homedir() on Linux — so the implementation is mocked instead (ARCH-005). const projectRoot = "/__archgate_test_project"; - const encodedProject = projectRoot - .replaceAll("/", "-") - .replaceAll("\\", "-") - .replaceAll(":", "-") - .replaceAll(".", "-"); let tempHome: string; let homedirSpy: Mock; let projectsDir: string; - beforeEach(() => { + beforeEach(async () => { tempHome = mkdtempSync(join(os.tmpdir(), "archgate-claude-session-")); homedirSpy = spyOn(os, "homedir").mockReturnValue(tempHome); + // Derived from the encoder, not restated — a hand-rolled copy drifts + // silently. The encoder's own output is asserted above. + const encodedProject = await encodeProjectPath(projectRoot); projectsDir = join(tempHome, ".claude", "projects", encodedProject); mkdirSync(projectsDir, { recursive: true }); });