From 5f86e32e03184cdec35f7727340539d92391fa9b Mon Sep 17 00:00:00 2001 From: "Allen \"Chip\" Parker" Date: Sun, 11 Oct 2026 06:17:39 +0000 Subject: [PATCH 1/3] feat(chat): add Antigravity conversation support --- CHANGELOG.md | 5 + docs/agy-chat.md | 54 ++++++++ docs/agy-chat.png | Bin 0 -> 74069 bytes docs/guide.md | 1 + scripts/chat-history-browser-qa.ts | 83 +++++++++++- server/agy-store.ts | 87 +++++++++++++ server/agy.ts | 202 +++++++++++++++++++++++++++++ server/api.contract.test.ts | 73 ++++++++++- server/conversation-metadata.ts | 2 + server/conversation-monitor.ts | 5 +- server/conversation.test.ts | 168 +++++++++++++++++++++++- server/conversation.ts | 70 ++++++++-- shared/protocol.ts | 7 +- src/components/ChatView.tsx | 11 +- 14 files changed, 746 insertions(+), 22 deletions(-) create mode 100644 docs/agy-chat.md create mode 100644 docs/agy-chat.png create mode 100644 server/agy-store.ts create mode 100644 server/agy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3149f2a1..45160c8fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - On a phone, a two-finger pinch on the terminal changes its font size live (10–22 px), with a badge showing the size; the shared pty resizes once, when a finger lifts. ([#713](https://github.com/devswha/herdr-web-ui/pull/713) by @zerodice0, [#758](https://github.com/devswha/herdr-web-ui/pull/758)) +- Antigravity chat reads native messages and tool activity, follows the running process’s + active conversation, and honors saved rewind boundaries. + See [compatibility and validation](docs/agy-chat.md). - Hermes's clarify questions get an answer card, as Claude Code's and Codex's do: one tap per choice, a typed answer for its "Other" row and for an open question, and ticks for a question that takes several answers. Text already on its answer line is cleared first, so it does not @@ -44,6 +47,8 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#689](https://github.com/devswha/herdr-web-ui/pull/689) by @zerodice0, [#757](https://github.com/devswha/herdr-web-ui/pull/757)) ### Fixed +- Chat hides a rejected conversation immediately, even when reading its terminal fallback + is slow or fails, so removed history does not remain on screen. - A pane in use keeps the lens it shows: with **Panes open in → Chat**, a shell split beside an agent no longer turns into its chat when an agent starts in it, and an agent pane no longer turns into its terminal when the agent exits. A lens picked by hand still wins, and a new diff --git a/docs/agy-chat.md b/docs/agy-chat.md new file mode 100644 index 0000000000..cd39a0a4d8 --- /dev/null +++ b/docs/agy-chat.md @@ -0,0 +1,54 @@ +# Antigravity Chat + +Chat reads the active Agy conversation's `transcript_full.jsonl`. User messages, +assistant text and expandable tool activity use the existing Chat view. Terminal +remains available for native commands and approvals. Reading Chat makes no model call. + +The reader follows the presence lock held by the pane's Agy process, including after +`/new` and `/resume`. It does not choose a session by directory, timestamp or startup +arguments, and needs no hook or status-line configuration. + +The ledger is replayed in order: repeated or backwards step indices replace the +corresponding suffix. A read-only query of the conversation database's step indices +also honors a saved rewind before replacement ledger records arrive. The reader does +not decode database payloads. Changed history invalidates older-page cursors and +expanded output references; ordinary appends preserve them. + +Planner calls and exported tool results have no shared native call ID in the tested +format, so results appear as independent tool-output rows. Hidden context and reasoning +are omitted. Image rendering and child-session expansion are outside this first scope. +The known `` wrapper is removed only when followed by the native +metadata suffix; standalone literal wrapper text is preserved. Error-only records +without text are not rendered. Only completed (`DONE`) exported steps are rendered; an unfinished last row waits, +and an unfinished historical row does not hide later completed turns. + +Linux process/lock discovery and native lifecycle behavior were tested with Agy 1.3.3. +macOS uses `lsof` for the same held-file evidence but has not been tested natively. +Its `ps` process-birth check has one-second resolution; same-second PID reuse is not distinguished. +Windows currently falls back to Terminal because this adapter has no native handle +lookup. A remote machine runs the reader on that machine, beside its agent store. + +Missing or ambiguous identity and unreadable stores use the existing terminal fallback. +An identified idle session with an empty database can show empty Chat before the ledger +exists. Until the ledger exists, discovery uses the monitor’s two-second retry; catalog +change notifications start after resolution. A new process without a conversation lock +still uses Terminal. A cold scan reads +up to 32 MiB per poll and resumes on the next poll; individual records are limited to +16 MiB and active history to 100,000 steps. Malformed complete step records use Terminal; +an incomplete final line waits for its newline. Rebuilding an evicted transcript index +invalidates its old cursors rather than reusing forgotten history state. + +The database and export are written separately. The reader checks active indices around +each read and remembers observed removals, but does not claim an atomic database/export +snapshot or detect a rewind-and-regrow completed entirely before its first observation. +Eviction or rebuilding of the bounded index also forgets observed removals: if the database +has already regained an index but its replacement ledger row has not arrived, old ledger +text can briefly reappear. A new history ID invalidates old cursors but does not remove +that cross-file lag window. No persistent retirement state or database payload decoding +is used. + +Validation covers fictional parser/paging fixtures, the conversation and whole-output +HTTP endpoints on an isolated Herdr server, and replay of an owned native conversation +through the production reader after saved rewind and restart. Native manual checks also +covered `/new`, `/resume`, repeated rewind, graceful restart and one crash while working. +These are bounded checks, not a guarantee about every Agy version or crash timing. diff --git a/docs/agy-chat.png b/docs/agy-chat.png new file mode 100644 index 0000000000000000000000000000000000000000..70ba09cada7e4d623de3fc91f2abd2ab9b427cd8 GIT binary patch literal 74069 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⩔(KY@2e5$<@HIgcigN*Ef;h@=IK=Jq20r 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 | undefined; @@ -32,17 +33,32 @@ try { let epoch = "one", newest = "old newest", before: string | null = "one:100"; let holdOlder = false, olderRequested = false; let product = false; + let unavailable = false, terminalFails = false; + let holdTerminal = false, terminalReads = 0; + let terminalText = "Fictional terminal fallback"; /** product panes ("machine/pane") whose conversation requests wait for the script, and those that fail */ const held = new Set(), failing = new Set(); const pending: Array<{ key: string; answer: (status: number) => Promise }> = []; const user = (text: string) => ({ role: "user", ts: text, parts: [{ kind: "text", text }] }); + await page.route("**/api/**/read?*", async (route) => { + terminalReads += 1; + if (holdTerminal) await new Promise((resolve) => releases.push(resolve)); + await (terminalFails + ? route.fulfill({ status: 503, json: { error: { code: "timeout", message: "fictional terminal read failed" } } }) + : route.fulfill({ json: { read: { text: terminalText, truncated: false } } })); + }); await page.route("**/api/**/conversation?*", async (route) => { const url = new URL(route.request().url()); if (product) { const pane = url.searchParams.get("pane_id") ?? url.searchParams.get("pane") ?? ""; const key = `${/\/api\/machines\/([^/]+)\//.exec(url.pathname)?.[1] ?? "local"}/${pane}`; const answer = (status: number) => status === 200 - ? route.fulfill({ json: key === "local/devin" ? { source: "devin-transcript", history_id: "synthetic-devin-session", cursor: null, turns: [ + ? route.fulfill({ json: key === "local/agy" ? { source: "agy-transcript", history_id: "synthetic-agy-session", cursor: null, turns: parseAgyTranscript([ + { step_index: 0, type: "USER_INPUT", status: "DONE", content: "Summarize the last three fictional commits." }, + { step_index: 1, type: "PLANNER_RESPONSE", status: "DONE", tool_calls: [{ name: "run_command", args: { CommandLine: "git log -3 --oneline" } }] }, + { step_index: 2, type: "GENERIC", status: "DONE", content: "Fictional commit log" }, + { step_index: 3, type: "PLANNER_RESPONSE", status: "DONE", content: "Synthetic Agy answer" }, + ].map((row) => JSON.stringify(row)).join("\n")) } : key === "local/devin" ? { source: "devin-transcript", history_id: "synthetic-devin-session", cursor: null, turns: [ user("Synthetic Devin prompt"), { role: "assistant", ts: null, parts: [ { kind: "tool", name: "synthetic_tool", summary: "synthetic_tool", input: "{}", output: "synthetic result" }, @@ -56,6 +72,10 @@ try { else await answer(failing.has(key) ? 503 : 200); return; } + if (unavailable) { + await route.fulfill({ json: { source: "scrollback", turns: [] } }); + return; + } const captured = epoch; const cursor = url.searchParams.get("before") ?? url.searchParams.get("from"); if (cursor && !cursor.startsWith(`${epoch}:`)) { @@ -157,6 +177,58 @@ try { assert.deepEqual(errors, []); console.log("PASS reused tool ids after clear, machine switch and unmount cancellation; no browser errors"); + // An authoritative refusal must hide stale history even if the second, terminal read fails. + // This is the DB/export mismatch path: the reader returns scrollback, not a transport error. + for (const terminalFailure of [false, true]) { + epoch = `refusal-${terminalFailure}`; newest = `discarded newest ${terminalFailure}`; before = `${epoch}:100`; + await refresh(); + await page.getByText(newest, { exact: true }).waitFor(); + await page.locator(".chat-older").click(); + await page.getByText("old earlier", { exact: true }).waitFor(); + await page.locator(".work-block-head").click(); + await page.locator(".work-row-head").click(); + await page.locator(".chat-tool-more").click(); + const lateOutput = await page.evaluate(() => window.qa.requests.length - 1); + const readsBefore = terminalReads; + terminalFails = terminalFailure; unavailable = true; holdTerminal = true; + await refresh(); + for (const deadline = Date.now() + 10_000; terminalReads === readsBefore;) { + assert.ok(Date.now() < deadline, "missing terminal fallback request"); + await Bun.sleep(20); + } + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + assert.equal(await page.getByText(newest, { exact: true }).count(), 0, "refused history must disappear while terminal fallback is still pending"); + holdTerminal = false; + releases.splice(0).forEach((release) => release()); + if (terminalFailure) await page.locator(".chat-inline-error").filter({ hasText: "fictional terminal read failed" }).waitFor(); + else await page.getByText("Fictional terminal fallback", { exact: true }).waitFor(); + assert.equal(await page.getByText(newest, { exact: true }).count(), 0, "refused latest history must disappear even when terminal fallback fails"); + assert.equal(await page.getByText("old earlier", { exact: true }).count(), 0, "refused older pages must disappear"); + await page.waitForFunction((i) => window.qa.requests[i].signal.aborted, lateOutput); + await page.evaluate((i) => window.qa.requests[i].resolve("REFUSED TOOL OUTPUT"), lateOutput); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + assert.equal(await page.getByText("REFUSED TOOL OUTPUT", { exact: true }).count(), 0); + unavailable = false; terminalFails = false; before = null; + await refresh(); + await page.getByText(newest, { exact: true }).waitFor(); + assert.equal(await page.getByText("old earlier", { exact: true }).count(), 0); + } + console.log("PASS refusal clears latest/older history and late whole output, including failed terminal fallback; recovery reloads"); + + // Keep the existing scrollback change detector: clearing the signature on every fallback + // poll would suppress the New messages chip while the reader is scrolled up. + unavailable = true; + terminalText = Array.from({ length: 100 }, (_, i) => `Fictional terminal line ${i}`).join("\n"); + await refresh(); + await page.getByText(/Fictional terminal line 99/).waitFor(); + await page.getByRole("log").evaluate((node) => { node.scrollTop = 0; node.dispatchEvent(new Event("scroll")); }); + terminalText += "\nFictional terminal appended line"; + await refresh(); + await page.getByText(/Fictional terminal appended line/).waitFor(); + await page.locator(".chat-new-messages").waitFor(); + unavailable = false; + console.log("PASS changing terminal fallback retains the New messages indicator while scrolled up"); + // Exercise the actual product owner. A profiler observes every committed DOM, not just the final frame. const select = async (pane: string, machine: string, agent?: string) => { product = true; @@ -223,6 +295,15 @@ try { assert.deepEqual(await activity.json(), { tasks: [], runs: [] }); assert.equal(await page.getByText("Synthetic Devin answer", { exact: true }).count(), 0); assert.ok((await page.evaluate(() => window.qa.commits)).every((turns) => !turns.some((turn) => turn.includes("Synthetic Devin"))), "switch to Claude cannot commit Devin history"); + await select("agy", "local", "agy"); + await page.getByText("Summarize the last three fictional commits.", { exact: true }).waitFor(); + await page.getByText("Synthetic Agy answer", { exact: true }).waitFor(); + await page.locator(".work-block-head").click(); + assert.equal(await page.locator(".work-row-head").count(), 2, "Agy planner call and independent result remain separate"); + await page.locator(".work-row-head").last().click(); + await page.getByText("Fictional commit log", { exact: true }).waitFor(); + assert.equal(await page.getByText("Synthetic Claude prompt", { exact: true }).count(), 0); + console.log("PASS Agy native parser output renders user/assistant text and independent expandable tool results"); assert.deepEqual(errors, []); console.log("PASS synthetic Devin bubbles and tools render; switching to Claude clears Devin history"); console.log("PASS product pane commits across A→B→A, another PC, and late or failed answers"); diff --git a/server/agy-store.ts b/server/agy-store.ts new file mode 100644 index 0000000000..205689f005 --- /dev/null +++ b/server/agy-store.ts @@ -0,0 +1,87 @@ +/** Agy owns one presence lock for the conversation currently displayed in this process. */ +import { readFileSync, opendirSync, readlinkSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import type { HerdrPane } from "../shared/protocol.ts"; +import { herdrRpc } from "./herdr/client.ts"; + +export interface AgyStore { root: string; session: string; path: string; pid: number; started: string } +const LOCK = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.lock$/i; + +/** Only paths held by the selected process are candidates; directory contents aren't evidence. */ +export function agyStoreFromLocks(paths: readonly string[]): Pick | null { + const locks = new Set(); + for (const path of paths) { + if (basename(dirname(path)) !== "presence" || !LOCK.test(basename(path))) continue; + try { + const canonical = realpathSync(path); + if (basename(dirname(canonical)) !== "presence" || !LOCK.test(basename(canonical)) || !statSync(canonical).isFile()) return null; + locks.add(canonical); + } catch { return null; } + } + if (locks.size !== 1) return null; + const lock = [...locks][0]!; + try { + const root = dirname(dirname(lock)); + const session = basename(lock, ".lock"); + const path = join(root, "brain", session, ".system_generated", "logs", "transcript_full.jsonl"); + return { root, session, path }; + } catch { return null; } +} + +/** Stable process birth identity; elapsed-time estimates drift between reads on macOS. */ +async function processIdentity(pid: number): Promise<{ started: string } | null> { + try { + if (process.platform === "linux") { + const fields = readFileSync(`/proc/${pid}/stat`, "utf8").split(") ").pop()!.split(" "); + const ticks = fields[19]; + if (!ticks || !/^\d+$/.test(ticks)) return null; + return { started: `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}:${ticks}` }; + } + if (process.platform === "darwin") { + const child = Bun.spawn(["/bin/ps", "-o", "lstart=", "-p", String(pid)], { + stdout: "pipe", stderr: "ignore", timeout: 1000, env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, + }); + const started = (await new Response(child.stdout).text()).trim(); + return await child.exited === 0 && started ? { started } : null; + } + } catch { /* gone or unreadable owner */ } + return null; +} + +async function heldPaths(pid: number): Promise { + if (process.platform === "darwin") { + const child = Bun.spawn(["/usr/sbin/lsof", "-nPbw", "-a", "-p", String(pid), "-Fn"], { stdout: "pipe", stderr: "ignore", timeout: 3000 }); + const text = await new Response(child.stdout).text(); + if (await child.exited !== 0 || text.length > 1024 * 1024) return []; + return text.split("\n").filter((line) => line.startsWith("n")).map((line) => line.slice(1)); + } + if (process.platform !== "linux") return []; + const dir = opendirSync(`/proc/${pid}/fd`); + const paths: string[] = []; + try { + let count = 0; + for (let entry = dir.readSync(); entry; entry = dir.readSync()) { + if (++count > 4096) return []; + try { paths.push(readlinkSync(`/proc/${pid}/fd/${entry.name}`)); } catch { /* closed during enumeration */ } + } + } finally { dir.closeSync(); } + return paths; +} + +export async function resolveAgyStore(pane: HerdrPane): Promise { + if (pane.agent !== "agy") return null; + try { + const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid: number; name?: string; argv?: string[] }[] } }>("pane.process_info", { pane_id: pane.pane_id }); + const owners = (info.process_info?.foreground_processes ?? []).filter((p) => p.name === "agy" && Number.isSafeInteger(p.pid) && p.pid > 1); + if (owners.length !== 1) return null; + const pid = owners[0]!.pid; + const before = await processIdentity(pid); + const store = agyStoreFromLocks(await heldPaths(pid)); + const after = await processIdentity(pid); + return before && store && before.started === after?.started ? { ...store, pid, started: before.started } : null; + } catch { return null; } +} + +export function sameAgyStore(a: AgyStore, b: AgyStore | null): boolean { + return b !== null && a.pid === b.pid && a.started === b.started && a.path === b.path; +} diff --git a/server/agy.ts b/server/agy.ts new file mode 100644 index 0000000000..77e70c85f3 --- /dev/null +++ b/server/agy.ts @@ -0,0 +1,202 @@ +/** Agy's append-only step ledger, reduced in physical order before shared paging. */ +import { Database } from "bun:sqlite"; +import { createHash, randomUUID } from "node:crypto"; +import { closeSync, fstatSync, openSync, readSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import type { ConversationPart, ConversationTurn } from "../shared/protocol.ts"; +import { trimOutput } from "./tool-output.ts"; +import { toolSummary } from "./transcript-records.ts"; + +const MAX_ENTRIES = 100_000; +const MAX_LINE = 16 * 1024 * 1024; +const SCAN_BYTES = 32 * 1024 * 1024; +const hash = (value: string): string => createHash("sha256").update(value).digest("base64url"); +const record = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export class AgyUnavailable extends Error { constructor() { super("Agy history is unavailable"); } } +interface Step { index: number; start: number; end: number; type: string; status: string; digest: string } +interface Index { identity: string; size: number; changed: string; scanned: number; revision: string; steps: Step[]; visible: Step[]; retired: Set } +const indexes = new Map(); +export function forgetAgyState(path?: string): void { if (path === undefined) indexes.clear(); else indexes.delete(path); } + +/** The catalog beside this resolved native ledger; also watched for saved rewinds. */ +export function agyDatabasePath(path: string): string { + const sessionDir = dirname(dirname(dirname(path))); + return join(dirname(dirname(sessionDir)), "conversations", `${basename(sessionDir)}.db`); +} + +/** The database supplies only the active indices, never payloads or guessed numeric statuses. */ +export function agyCatalog(path: string): Set { + const dbPath = agyDatabasePath(path); + let db: Database | undefined; + try { + if (realpathSync(dbPath) !== dbPath) throw new AgyUnavailable(); + db = new Database(dbPath, { readonly: true }); + db.exec("PRAGMA busy_timeout = 100"); + const rows = db.query<{ idx: number }, []>(`SELECT idx FROM steps ORDER BY idx LIMIT ${MAX_ENTRIES + 1}`).all(); + if (rows.length > MAX_ENTRIES || rows.some((row) => !Number.isSafeInteger(row.idx) || row.idx < 0)) throw new AgyUnavailable(); + return new Set(rows.map((row) => row.idx)); + } catch { throw new AgyUnavailable(); } + finally { db?.close(); } +} + +function entry(line: string): Record { + let value: Record; + try { value = record(JSON.parse(line)); } catch { throw new AgyUnavailable(); } + if (!Number.isSafeInteger(value.step_index) || (value.step_index as number) < 0 || typeof value.type !== "string") throw new AgyUnavailable(); + return value; +} + +export interface AgyProjection { segments: { start: number; end: number }[]; promptStarts: number[]; revision: string; indices: number[] } +export function agyProjection(path: string, fd: number): AgyProjection { + const stat = fstatSync(fd); + const identity = `${stat.dev}:${stat.ino}`; + const changed = `${stat.mtimeMs}:${stat.ctimeMs}`; + let index = indexes.get(path); + if (!index || index.identity !== identity || stat.size < index.size || (stat.size === index.size && changed !== index.changed)) { + index = { identity, size: stat.size, changed, scanned: 0, revision: randomUUID(), steps: [], visible: [], retired: new Set() }; + } + index.size = stat.size; index.changed = changed; + indexes.delete(path); indexes.set(path, index); + if (indexes.size > 16) indexes.delete(indexes.keys().next().value!); + let position = index.scanned; + let carry: Buffer[] = []; + let carried = 0; + const end = Math.min(stat.size, position + SCAN_BYTES); + while (position < end) { + const chunk = Buffer.alloc(Math.min(64 * 1024, end - position)); + const read = readSync(fd, chunk, 0, chunk.length, position); + if (!read) throw new AgyUnavailable(); + const bytes = chunk.subarray(0, read); + let start = 0; + for (let newline = bytes.indexOf(10); newline !== -1; newline = bytes.indexOf(10, start)) { + if (carried + newline - start > MAX_LINE) throw new AgyUnavailable(); + const line = (carried ? Buffer.concat([...carry, bytes.subarray(start, newline)]) : bytes.subarray(start, newline)).toString("utf8"); + const lineStart = position + start - carried; + const lineEnd = position + newline + 1; + carry = []; carried = 0; + const value = entry(line); + const number = value.step_index as number; + if (index.steps.length && number <= index.steps.at(-1)!.index) { + while (index.steps.length && index.steps.at(-1)!.index >= number) index.steps.pop(); + } + index.steps.push({ index: number, start: lineStart, end: lineEnd, type: value.type as string, status: String(value.status ?? ""), digest: hash(line) }); + if (index.steps.length > MAX_ENTRIES) throw new AgyUnavailable(); + index.scanned = lineEnd; + start = newline + 1; + } + if (start < bytes.length) { carry.push(bytes.subarray(start)); carried += bytes.length - start; } + if (carried > MAX_LINE) throw new AgyUnavailable(); + position += read; + } + if (position < stat.size) throw new AgyUnavailable(); + const catalog = agyCatalog(path); + // Native idle rewind updates the DB before any replacement ledger lines arrive. + // DB-only steps are normal; equality or a strict prefix would reject healthy logs. + const physicalStarts = new Set(index.steps.map((step) => step.start)); + for (const start of index.retired) if (!physicalStarts.has(start)) index.retired.delete(start); + // Once the active DB removed a displayed step, its old bytes cannot return merely + // because the replacement DB row precedes the replacement export. Never-published + // lagging steps remain eligible. The set is bounded by the active physical index. + for (const step of index.visible) if (!catalog.has(step.index) && physicalStarts.has(step.start)) index.retired.add(step.start); + const retained = index.steps.filter((step) => catalog.has(step.index) && !index.retired.has(step.start)); + const visible = retained.at(-1)?.status !== "DONE" ? retained.slice(0, -1) : retained; + // A DB snapshot can lag an exported append. Keep the physical index intact so the + // next poll can admit it. Only changes to already published spans invalidate paging. + if (index.visible.some((step, at) => visible[at]?.start !== step.start || visible[at]?.end !== step.end)) index.revision = randomUUID(); + index.visible = visible; + const segments: AgyProjection["segments"] = []; + const promptStarts: number[] = [], indices: number[] = []; + let length = 0; + for (const step of visible) { + indices.push(step.index); + if (step.type === "USER_INPUT" && step.status === "DONE") promptStarts.push(length); + const previous = segments.at(-1); + if (previous?.end === step.start) previous.end = step.end; + else segments.push({ start: step.start, end: step.end }); + length += step.end - step.start; + } + return { segments, promptStarts, revision: index.revision, indices }; +} + +/** Keep one fd through projection and page reads, and reject a cut during the read. */ +export function withAgyHistory(path: string, read: (projection: AgyProjection, fd: number) => T): T { + const fd = openSync(path, "r"); + try { + if (realpathSync(path) !== path) throw new AgyUnavailable(); + const before = fstatSync(fd); + const projection = agyProjection(path, fd); + const result = read(projection, fd); + const after = statSync(path), pinned = fstatSync(fd); + const current = agyCatalog(path); + if (before.dev !== after.dev || before.ino !== after.ino || pinned.size < before.size + || (pinned.size === before.size && (pinned.mtimeMs !== before.mtimeMs || pinned.ctimeMs !== before.ctimeMs)) + || projection.indices.some((index) => !current.has(index))) throw new AgyUnavailable(); + return result; + } finally { closeSync(fd); } +} + +function userText(text: string): string { + if (!text.startsWith("")) return text; + const end = text.lastIndexOf(""); + if (end < 0) return text; + const suffix = text.slice(end + "".length); + if (!/^\s*<(?:ADDITIONAL_METADATA|USER_SETTINGS_CHANGE)>/.test(suffix)) return text; + return text.slice("".length, end).replace(/^\n|\n$/g, ""); +} +function outputRef(path: string, step: Step): string { + const index = indexes.get(path)!; + return `agy:${hash(JSON.stringify([path, index.identity, index.revision, step.start, step.digest]))}`; +} +export function parseAgyTranscript(text: string, path?: string): ConversationTurn[] { + const turns: ConversationTurn[] = []; + const steps = path ? new Map(indexes.get(path)?.visible.map((step) => [step.index, step])) : undefined; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const value = entry(line); + if (value.status !== "DONE") continue; + const content = typeof value.content === "string" ? value.content : ""; + const ts = typeof value.created_at === "string" ? value.created_at : null; + if (value.type === "USER_INPUT") { turns.push({ role: "user", ts, parts: [{ kind: "text", text: userText(content) }] }); continue; } + if (value.type !== "PLANNER_RESPONSE" && value.type !== "GENERIC") continue; + const parts: ConversationPart[] = []; + if (value.type === "GENERIC") { + if (!content) continue; + // Native exports provide no call ID. Keep this result independent of planner calls. + const part: Extract = { kind: "tool", name: "Tool output", summary: `Step ${value.step_index}`, input: "", output: "" }; + const step = steps?.get(value.step_index as number); + trimOutput(part, content, path && step ? outputRef(path, step) : `agy:${hash(line)}`); + parts.push(part); + } else { + if (content) parts.push({ kind: "text", text: content }); + for (const call of Array.isArray(value.tool_calls) ? value.tool_calls : []) { + const tool = record(call), input = record(tool.args); + if (typeof tool.name !== "string") continue; + const mapped = { ...input, command: input.CommandLine, file_path: input.AbsolutePath ?? input.TargetFile }; + parts.push({ kind: "tool", name: tool.name, summary: toolSummary(tool.name, mapped), input: JSON.stringify(input), output: "" }); + } + } + if (!parts.length) continue; + const previous = turns.at(-1); + if (previous?.role === "assistant") { previous.parts.push(...parts); previous.end_ts = ts ?? undefined; } + else turns.push({ role: "assistant", ts, end_ts: ts ?? undefined, parts }); + } + return turns; +} + +export function agyToolOutput(path: string, ref: string): string | null { + if (!/^agy:[A-Za-z0-9_-]{43}$/.test(ref)) return null; + try { + return withAgyHistory(path, (projection, fd) => { + const visible = new Set(projection.indices); + for (const step of indexes.get(path)!.visible) { + if (step.type !== "GENERIC" || !visible.has(step.index) || outputRef(path, step) !== ref) continue; + const bytes = Buffer.alloc(step.end - step.start); + if (readSync(fd, bytes, 0, bytes.length, step.start) !== bytes.length) return null; + const value = entry(bytes.toString("utf8")); + const text = typeof value.content === "string" ? value.content : ""; + return text.length > 2_000_000 ? `${text.slice(0, 2_000_000)}\n… trimmed` : text; + } + return null; + }); + } catch { return null; } +} diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index a182bf7c25..404fb2befa 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, beforeAll, afterAll } from "bun:test"; import { Database } from "bun:sqlite"; -import { chmodSync, mkdtempSync, existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, chmodSync, mkdtempSync, existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { ConversationMonitor } from "./conversation-monitor.ts"; +import { forgetTranscriptState } from "./conversation.ts"; import { createServer } from "./index.ts"; import type { AgentKind, AgentStatus, ApiError, HealthAuth, IntegrationsResponse, PushKey, RemoteAccess, SessionSnapshot, TabMoved, PaneFindResponse, PaneReadResult, UsageReport, WorkspaceCreated, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../shared/protocol.ts"; import { HerdrUpdater } from "./herdr-update.ts"; @@ -40,6 +42,75 @@ afterAll(() => { const base = () => `http://localhost:${server.port}`; +describe("Agy conversation API", () => { + it.skipIf(process.platform !== "linux")("binds the selected held lock, honors saved rewinds and recovers from an unwritten store", async () => { + const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-agy-contract-")); + const id = "00000000-0000-4000-8000-000000000031"; + const path = join(root, "brain", id, ".system_generated", "logs", "transcript_full.jsonl"); + mkdirSync(join(path, ".."), { recursive: true }); + mkdirSync(join(root, "conversations")); mkdirSync(join(root, "presence")); + const lock = join(root, "presence", `${id}.lock`); + writeFileSync(lock, ""); + // Another unheld lock in the same store must never select a conversation. + writeFileSync(join(root, "presence", "00000000-0000-4000-8000-000000000032.lock"), ""); + const executable = join(root, "agy"); + copyFileSync("/bin/sleep", executable); chmodSync(executable, 0o755); + const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-agy-api" }); + let app: ReturnType | undefined, db: Database | undefined; + let monitor: ConversationMonitor | undefined; + try { + const paneId = created.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: paneId, text: `${executable} 600 3<${lock}\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((pane) => pane.pane_id === paneId)?.agent === "agy") break; + if (Date.now() > deadline) throw new Error("fixture Agy process did not start"); + await Bun.sleep(50); + } + app = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state") }); + const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(paneId)}`; + expect(await (await fetch(url)).json()).toEqual({ source: "scrollback", turns: [] }); + db = new Database(join(root, "conversations", `${id}.db`)); + db.exec("PRAGMA journal_mode=WAL; CREATE TABLE steps(idx INTEGER PRIMARY KEY)"); + const empty = await (await fetch(url)).json(); + expect(empty.source).toBe("agy-transcript"); expect(empty.turns).toEqual([]); + const row = (step_index: number, type: string, content: string) => JSON.stringify({ step_index, type, status: "DONE", content }) + "\n"; + db.exec("INSERT INTO steps VALUES (0), (1)"); + writeFileSync(path, row(0, "USER_INPUT", "fictional prompt") + row(1, "GENERIC", "x".repeat(5000))); + const first = await fetch(url), body = await first.json(); + expect(body.source).toBe("agy-transcript"); + expect(body.turns[0].parts[0].text).toBe("fictional prompt"); + expect((await fetch(url, { headers: { "if-none-match": first.headers.get("etag")! } })).status).toBe(304); + const ref = body.turns[1].parts[0].output_ref; + const outputUrl = `http://127.0.0.1:${app.port}/api/pane/conversation/tool-output?${new URLSearchParams({ pane_id: paneId, ref })}`; + expect(await (await fetch(outputUrl)).text()).toBe("x".repeat(5000)); + const changes: string[] = []; + monitor = new ConversationMonitor((_pane, signature) => changes.push(signature)); + monitor.watch(paneId); + const changed = async (count: number) => { + for (const deadline = Date.now() + 5000; changes.length <= count;) { + if (Date.now() > deadline) throw new Error("Agy catalog change was not pushed"); + await Bun.sleep(20); + } + }; + await changed(0); + const beforeCut = changes.length; + db.exec("DELETE FROM steps WHERE idx >= 1"); + await changed(beforeCut); + expect((await (await fetch(url)).json()).turns).toHaveLength(1); + expect((await fetch(`${url}&from=${encodeURIComponent(`${body.history_id}:0`)}`)).status).toBe(409); + expect((await fetch(outputUrl)).status).toBe(404); + forgetTranscriptState(); + const local = await fetch(url.replace("/api/pane/", "/api/machines/local/pane/")); + expect((await local.json()).turns).toHaveLength(1); + db.exec("DROP TABLE steps"); + expect(await (await fetch(url)).json()).toEqual({ source: "scrollback", turns: [] }); + } finally { + monitor?.stop(); app?.stop(); db?.close(); await workspaceClose(created.workspace.workspace_id); + forgetTranscriptState(); rmSync(root, { recursive: true, force: true }); + } + }, 20_000); +}); + describe("single-pane context lookup", () => { for (const route of ["commands", "files"] as const) { it(`rejects an unknown pane on /api/pane/${route} with the shared error envelope`, async () => { diff --git a/server/conversation-metadata.ts b/server/conversation-metadata.ts index b6e90f2c94..3b18115c3f 100644 --- a/server/conversation-metadata.ts +++ b/server/conversation-metadata.ts @@ -36,6 +36,8 @@ function contextOf(used: number, model: string | null, window: number | null, be export function parseConversationMetadata(text: string, source: ConversationResponse["source"], before?: ConversationMetadata): ConversationMetadata { // `before` is what the text preceding this one said: the fold picks up where it stopped const metadata: ConversationMetadata = before ? { ...before } : { model: null, reasoning_effort: null }; + // Agy's exported rows do not attest the selected model or reasoning effort. + if (source === "agy-transcript") return metadata; // pi states the provider on the same records that state usage, so the window is asked for // from what this line says rather than remembered across sources let piProvider: string | null = null; diff --git a/server/conversation-monitor.ts b/server/conversation-monitor.ts index 15d774a53b..9ebf62d9ab 100644 --- a/server/conversation-monitor.ts +++ b/server/conversation-monitor.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { statSync } from "node:fs"; import type { SessionSnapshot } from "../shared/protocol.ts"; import { ConversationNotStarted, ConversationUnavailable, paneTranscript, type NativeTranscript } from "./conversation.ts"; +import { agyDatabasePath } from "./agy.ts"; import { sessionSnapshot } from "./herdr/client.ts"; const STAT_INTERVAL_MS = 250; @@ -79,7 +80,9 @@ export class ConversationMonitor { const wal = database ? fileIdentity(`${transcript.path}-wal`) : ""; const session = database ? transcript.session : ""; const cwd = transcript.source === "devin-transcript" ? transcript.cwd : ""; - const observed = `${transcript.source}\0${transcript.path}\0${session}\0${cwd}\0${identity}\0${wal}`; + const catalog = transcript.source === "agy-transcript" ? agyDatabasePath(transcript.path) : null; + const catalogIdentity = catalog === null ? "" : `${fileIdentity(catalog)}:${fileIdentity(`${catalog}-wal`)}`; + const observed = `${transcript.source}\0${transcript.path}\0${session}\0${cwd}\0${identity}\0${wal}\0${catalogIdentity}`; // Resolve once immediately on disappearance, not on every missing-file stat. if (identity === "missing" && observed !== state.observed) state.nextResolve = 0; this.publish(paneId, state, observed); diff --git a/server/conversation.test.ts b/server/conversation.test.ts index 92199f792d..fab94173d5 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { Database } from "bun:sqlite"; -import { appendFileSync, copyFileSync, linkSync, mkdirSync, mkdtempSync, renameSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { appendFileSync, copyFileSync, linkSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, win32 } from "node:path"; import type { HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; import { MAX_TURNS, parseOmpTranscript } from "./transcript-records.ts"; import { toolVerb } from "../src/lib/toolVerbs.ts"; import type { ConversationTurn } from "../shared/protocol.ts"; +import { parseAgyTranscript } from "./agy.ts"; +import { agyStoreFromLocks } from "./agy-store.ts"; import * as herdr from "./herdr/client.ts"; import { forgetHistoryChains } from "./codex.ts"; import { DevinHistoryChanged } from "./devin.ts"; @@ -1467,3 +1469,167 @@ it("expands inherited Codex tool output without exposing output discarded by a b expect(transcriptToolOutput("codex-transcript", leaf, "current", home)).toBe("current output"); } finally { rmSync(home, { recursive: true, force: true }); } }); + +describe("Agy native ledger through shared paging", () => { + let root: string, path: string, db: Database; + const row = (step_index: number, type: string, content = "", extra = {}) => JSON.stringify({ step_index, type, status: "DONE", created_at: "2026-01-01T00:00:00Z", content, ...extra }) + "\n"; + const add = (index: number, type: string, content = "", extra = {}) => { + db.query("INSERT OR REPLACE INTO steps VALUES (?)").run(index); + appendFileSync(path, row(index, type, content, extra)); + }; + const page = () => transcriptPage("agy-transcript", path); + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "herdr-agy-unit-")); + const session = "00000000-0000-4000-8000-000000000001"; + path = join(root, "brain", session, ".system_generated", "logs", "transcript_full.jsonl"); + mkdirSync(join(path, ".."), { recursive: true }); + mkdirSync(join(root, "conversations")); + db = new Database(join(root, "conversations", `${session}.db`)); + db.exec("CREATE TABLE steps(idx INTEGER PRIMARY KEY)"); + writeFileSync(path, ""); + forgetTranscriptState(); + }); + afterEach(() => { db.close(); forgetTranscriptState(); rmSync(root, { recursive: true, force: true }); }); + it("renders native messages, multiple planner calls and independent results without guessed pairing", () => { + add(0, "USER_INPUT", "Keep literal tags\nhidden"); + add(1, "EPHEMERAL_MESSAGE", "hidden context"); + add(2, "PLANNER_RESPONSE", "Checking", { tool_calls: [{ name: "run_command", args: { CommandLine: "echo fiction" } }, { name: "view_file", args: { AbsolutePath: "/fiction.txt" } }] }); + add(3, "GENERIC", "independent output"); + add(4, "CHECKPOINT", "not a result"); + add(5, "USER_INPUT", "next"); + const turns = page().turns; + expect(turns[0]!.parts).toEqual([{ kind: "text", text: "Keep literal tags" }]); + expect(turns[1]!.parts.map((p) => p.kind === "tool" ? [p.name, p.summary, p.output] : p.kind === "text" ? p.text : p.kind)).toEqual([ + "Checking", ["run_command", "echo fiction", ""], ["view_file", "/fiction.txt", ""], ["Tool output", "Step 3", "independent output"], + ]); + expect(turns[2]!.role).toBe("user"); + }); + it("keeps paging and refs stable on append, then rejects them on a DB-only idle rewind", () => { + for (let i = 0; i < 80; i++) { add(i * 2, "USER_INPUT", `prompt ${i}`); add(i * 2 + 1, "GENERIC", "x".repeat(5000)); } + const first = page(); + expect(first.cursor).not.toBeNull(); + const older = transcriptPage("agy-transcript", path, { before: first.cursor! }); + const tool = first.turns.flatMap((t) => t.parts).find((p) => p.kind === "tool")!; + if (tool.kind !== "tool") throw new Error("missing result"); + expect(transcriptToolOutput("agy-transcript", path, tool.output_ref!)).toBe("x".repeat(5000)); + add(160, "USER_INPUT", "append"); + expect(page().history_id).toBe(first.history_id); + expect(transcriptPage("agy-transcript", path, { before: first.cursor! })).toEqual(older); + db.exec("DELETE FROM steps WHERE idx >= 4"); + const rewound = page(); + expect(rewound.turns).toHaveLength(4); + expect(rewound.history_id).not.toBe(first.history_id); + expect(() => transcriptPage("agy-transcript", path, { before: first.cursor! })).toThrow(HistoryChanged); + expect(transcriptToolOutput("agy-transcript", path, tool.output_ref!)).toBeNull(); + forgetTranscriptState(); + expect(page().turns).toEqual(rewound.turns.map((turn) => ({ ...turn, parts: turn.parts.map((part) => part.kind === "tool" ? { ...part, output_ref: expect.any(String) } : part) }))); + }); + it("replays repeated/backward indices and allows database-only rows", () => { + add(0, "USER_INPUT", "one"); add(1, "PLANNER_RESPONSE", "old"); + db.query("INSERT INTO steps VALUES (?)").run(2); + add(3, "USER_INPUT", "two"); add(4, "PLANNER_RESPONSE", "discard"); + const before = page(); + add(3, "USER_INPUT", "replacement"); + expect(page().turns.map((t) => t.parts[0])).toEqual([{ kind: "text", text: "one" }, { kind: "text", text: "old" }, { kind: "text", text: "replacement" }]); + const revision = page().history_id; + expect(revision).not.toBe(before.history_id); + add(4, "PLANNER_RESPONSE", "new"); + expect(page().history_id).toBe(revision); + add(3, "USER_INPUT", "equal repeat"); + expect(page().history_id).not.toBe(revision); + const warm = page().turns; + forgetTranscriptState(); + expect(page().turns).toEqual(warm); + }); + it("ignores an incomplete tail and does not settle a running row", () => { + add(0, "USER_INPUT", "one"); + db.query("INSERT INTO steps VALUES (1)").run(); + const text = row(1, "PLANNER_RESPONSE", "complete"); + appendFileSync(path, text.slice(0, -3)); + expect(page().turns).toHaveLength(1); + appendFileSync(path, text.slice(-3)); + expect(page().turns).toHaveLength(2); + add(2, "PLANNER_RESPONSE", "unfinished", { status: "RUNNING" }); + expect(JSON.stringify(page().turns)).not.toContain("unfinished"); + add(2, "PLANNER_RESPONSE", "finished"); + expect(JSON.stringify(page().turns)).toContain("finished"); + }); + it("admits a ledger tail when the DB catches up without changing older cursors", () => { + add(0, "USER_INPUT", "first"); + const initial = page(); + appendFileSync(path, row(1, "PLANNER_RESPONSE", "lagged")); + expect(page().turns).toHaveLength(1); + db.exec("INSERT INTO steps VALUES (1)"); + const caughtUp = page(); + expect(caughtUp.history_id).toBe(initial.history_id); + expect(caughtUp.turns).toHaveLength(2); + add(2, "PLANNER_RESPONSE", "partial", { status: "RUNNING" }); + const running = page(); + add(2, "PLANNER_RESPONSE", "complete"); + expect(page().history_id).toBe(running.history_id); + expect(transcriptPage("agy-transcript", path, { from: `${initial.history_id}:0` }).turns).toHaveLength(2); + }); + it("never resurrects an observed rewind when replacement DB indices precede their ledger rows", () => { + add(0, "USER_INPUT", "kept"); add(1, "GENERIC", "removed".repeat(1000)); + const before = page(); + db.exec("DELETE FROM steps WHERE idx=1"); + const cut = page(); + db.exec("INSERT INTO steps VALUES (1)"); + expect(page().turns).toEqual(cut.turns); + expect(page().history_id).toBe(cut.history_id); + add(1, "GENERIC", "replacement"); + expect(JSON.stringify(page().turns)).toContain("replacement"); + expect(JSON.stringify(page().turns)).not.toContain("removed"); + const part = before.turns[1]!.parts[0]!; + if (part.kind !== "tool") throw new Error("missing tool"); + expect(transcriptToolOutput("agy-transcript", path, part.output_ref!)).toBeNull(); + }); + it("does not hide later turns behind an unfinished or unknown-status historical row", () => { + add(0, "USER_INPUT", "first"); + add(1, "GENERIC", "unfinished", { status: "UNKNOWN" }); + expect(page().turns).toHaveLength(1); + add(2, "USER_INPUT", "later"); add(3, "PLANNER_RESPONSE", "answer"); + expect(page().turns.map(t => t.parts[0])).toEqual([ + { kind: "text", text: "first" }, { kind: "text", text: "later" }, { kind: "text", text: "answer" }, + ]); + }); + it("uses the same assistant boundaries in full and paged replay around a skipped user row", () => { + add(0, "USER_INPUT", "first"); add(1, "PLANNER_RESPONSE", "before"); + add(2, "USER_INPUT", "hidden", { status: "UNKNOWN" }); + add(3, "PLANNER_RESPONSE", "after"); + const full = parseAgyTranscript(row(0, "USER_INPUT", "first") + row(1, "PLANNER_RESPONSE", "before") + + row(2, "USER_INPUT", "hidden", { status: "UNKNOWN" }) + row(3, "PLANNER_RESPONSE", "after")); + expect(page().turns).toEqual(full); + expect(full).toHaveLength(2); + }); + it("continues a bounded cold scan on the next poll", () => { + add(0, "USER_INPUT", "start"); + for (let i=1; i<=34; i++) add(i, "EPHEMERAL_MESSAGE", "x".repeat(1024 * 1024)); + add(35, "USER_INPUT", "end"); + expect(page).toThrow(ConversationUnavailable); + expect(page().turns.at(-1)!.parts[0]).toEqual({ kind: "text", text: "end" }); + }); + it("preserves standalone literal user wrapper text", () => { + add(0, "USER_INPUT", "literal"); + expect(page().turns[0]!.parts[0]).toEqual({ kind: "text", text: "literal" }); + }); + it.skipIf(process.platform === "win32")("canonicalizes held locks and never chooses among sessions or unheld files", () => { + const presence = join(root, "presence"); mkdirSync(presence); + const one = join(presence, "00000000-0000-4000-8000-000000000001.lock"); + const two = join(presence, "00000000-0000-4000-8000-000000000002.lock"); + writeFileSync(one, ""); writeFileSync(two, ""); + expect(agyStoreFromLocks([])).toBeNull(); + expect(agyStoreFromLocks([one, two])).toBeNull(); + expect(agyStoreFromLocks([one])?.path).toBe(path); + const alias = join(root, "alias"); symlinkSync(presence, alias); + // Alias a parent of presence, preserving the native held-path shape. + const link = join(root, "linked-root"); symlinkSync(root, link); + expect(agyStoreFromLocks([join(link, "presence", one.split("/").at(-1)!)])?.path).toBe(path); + }); + it("distinguishes unreadable database from verified empty history", () => { + add(0, "USER_INPUT", "one"); + page(); + db.exec("DROP TABLE steps"); + expect(page).toThrow(ConversationUnavailable); + }); +}); diff --git a/server/conversation.ts b/server/conversation.ts index 347446752a..e2eae39d2b 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -50,6 +50,8 @@ import { forgetOpencodeRead, forgetOpencodeState, OPENCODE_IMAGE_REF, opencodeCo import { piTranscriptInStore, piTranscriptPath, unwrittenSession } from "./pi.ts"; import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchSegments } from "./pi-tree.ts"; import { defaultDevinDbPath, DevinHistoryUnavailable, devinConversation, forgetDevinState } from "./devin.ts"; +import { AgyUnavailable, agyCatalog, agyToolOutput, forgetAgyState, parseAgyTranscript, withAgyHistory, type AgyProjection } from "./agy.ts"; +import { resolveAgyStore, sameAgyStore, type AgyStore } from "./agy-store.ts"; import { trimOutput } from "./tool-output.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; @@ -457,7 +459,7 @@ export class HistoryChanged extends Error { /** What paneConversation resolved: which store the turns came from, and where they start. */ export type RecognizedConversation = { - source: "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript"; + source: "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript"; turns: ConversationTurn[]; metadata: ConversationMetadata; /** the first turn's position, for the page before it; null at the conversation's beginning */ @@ -502,6 +504,8 @@ interface TranscriptStream { files: { path: string; start: number; length: number; offset: number }[]; length: number; floor: number; + promptStarts?: number[]; + fd?: number; } // In-place rewrites keep the inode. Change the cursor generation when observed, @@ -517,13 +521,14 @@ function transcriptGeneration(path: string, stat: { dev: number; ino: number; si return generation ? `-${generation}` : ""; } -function transcriptStream(source: StreamSource, path: string, stat: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }, codexHome: string): TranscriptStream { +function transcriptStream(source: StreamSource, path: string, stat: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }, codexHome: string, agy?: AgyProjection, fd?: number): TranscriptStream { // (a chain whose parent was archived since comes back shorter: codexHistorySegments; // a pi branch skips the side paths a /tree left behind: piBranchSegments) const branch = source === "pi-transcript" ? piBranchSegments(path, stat.size) : null; if (source === "pi-transcript" && branch === null) throw new ConversationUnavailable("branch_unreadable"); const segments: { path: string; start: number; end: number }[] = source === "codex-transcript" ? codexHistorySegments(path, codexHome).map((segment) => ({ path: segment.path, start: 0, end: segment.end })) + : agy ? agy.segments.map((segment) => ({ path, ...segment })) : branch !== null ? branch.map((segment) => ({ path, start: segment.start, end: segment.end })) : [{ path, start: 0, end: stat.size }]; const files: TranscriptStream["files"] = []; @@ -546,7 +551,9 @@ function transcriptStream(source: StreamSource, path: string, stat: { dev: numbe // Two /tree moves away from the same entry share their head ranges and differ only // where their tails begin, so the last range's start is what tells them apart. let chain = ""; - if (branch !== null) { + if (agy) { + chain = `-agy-${agy.revision}`; + } else if (branch !== null) { const layout = files.map((file, at) => (at === files.length - 1 ? `${file.offset}` : `${file.offset}\0${file.length}`)).join("\n"); chain = `-${createHash("sha256").update(layout).digest("base64url").slice(0, 10)}`; } else if (source === "codex-transcript") { @@ -556,7 +563,7 @@ function transcriptStream(source: StreamSource, path: string, stat: { dev: numbe }); chain = earlier.length === 0 ? "" : `-${createHash("sha256").update(earlier.join("\n")).digest("base64url").slice(0, 10)}`; } - return { id: `${stat.dev.toString(36)}-${stat.ino.toString(36)}${chain}${transcriptGeneration(path, stat)}`, files, length: stream, floor: 0 }; + return { id: `${stat.dev.toString(36)}-${stat.ino.toString(36)}${chain}${transcriptGeneration(path, stat)}`, files, length: stream, floor: 0, promptStarts: agy?.promptStarts, fd }; } function readStream(stream: TranscriptStream, from: number, to: number): Buffer { @@ -565,12 +572,12 @@ function readStream(stream: TranscriptStream, from: number, to: number): Buffer const low = Math.max(from, file.start); const high = Math.min(to, file.start + file.length); if (low >= high) continue; - const fd = openSync(file.path, "r"); + const fd = stream.fd ?? openSync(file.path, "r"); try { const buffer = Buffer.alloc(high - low); chunks.push(buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, file.offset + (low - file.start)))); } finally { - closeSync(fd); + if (stream.fd === undefined) closeSync(fd); } } return Buffer.concat(chunks); @@ -584,7 +591,7 @@ function readStream(stream: TranscriptStream, from: number, to: number): Buffer const SETTING_TYPES = ['"model_change"', '"thinking_level_change"']; const clearScans = new Map> }>(); function applyHistoryBoundary(path: string, stream: TranscriptStream, source: RecognizedConversation["source"]): void { - if (source === "codex-transcript") return; + if (source === "codex-transcript" || source === "agy-transcript") return; let scan = clearScans.get(path); if (!scan || scan.id !== stream.id || scan.scanned > stream.length || bytesBefore(stream, scan.scanned) !== scan.tail) { scan = { id: stream.id, scanned: 0, floor: 0, tail: "", settings: {} }; @@ -628,6 +635,7 @@ function applyHistoryBoundary(path: string, stream: TranscriptStream, source: Re /** Bytes that every line opening a turn contains: a cheap filter before JSON.parse. */ const TURN_MARK: Record = { + "agy-transcript": Buffer.from('"USER_INPUT"'), "codex-transcript": Buffer.from('"task_started"'), "claude-transcript": Buffer.from('"user"'), "omp-transcript": Buffer.from('"user"'), @@ -706,7 +714,9 @@ function pageBefore(stream: TranscriptStream, source: StreamSource, to: number, let from = Math.max(floor, to - TRANSCRIPT_WINDOW_BYTES); let bytes = readStream(stream, from, to); for (;;) { - const starts = turnStarts(bytes, source); + const starts = stream.promptStarts + ? stream.promptStarts.filter((offset) => offset >= from && offset < to).map((offset) => offset - from) + : turnStarts(bytes, source); const keep = starts.length > MAX_PAGE_PROMPTS ? starts[starts.length - MAX_PAGE_PROMPTS] : from === floor ? 0 : starts[0]; if (keep !== undefined) return { start: from + keep, bytes: bytes.subarray(keep) }; if (!widen || to - from >= MAX_PAGE_BYTES) { @@ -810,6 +820,11 @@ function rememberPaneDevin(paneId: string, sessionId: string, cwd: string, dbPat /** The newest page's start and every turn start in it (pageBefore without widening), or null when it starts mid-turn. */ function newestPage(path: string, stream: TranscriptStream, source: StreamSource): { start: number; starts: number[] } | null { const from = Math.max(stream.floor, stream.length - TRANSCRIPT_WINDOW_BYTES); + if (stream.promptStarts) { + const starts = stream.promptStarts.filter((offset) => offset >= from); + const start = starts.length > MAX_PAGE_PROMPTS ? starts[starts.length - MAX_PAGE_PROMPTS] : from === stream.floor ? stream.floor : starts[0]; + return start === undefined ? null : { start, starts }; + } let scan = liveScans.get(path); // a window that slid past the scanned bytes starts over at its edge (a line may be cut // there, as in pageBefore, and never counts as a start) @@ -846,6 +861,7 @@ function subagentsOnPage(path: string, text: string): ReadonlyMap, noticed?: Set): ConversationTurn[] { + if (source === "agy-transcript") return parseAgyTranscript(text, path); return source === "codex-transcript" ? parseCodexTranscript(text, Infinity) // only pi keeps a tool's images in the entry as base64; omp, omo and gjc are read the same // way but would carry image refs nothing can answer, so the option stays with pi alone @@ -964,6 +980,7 @@ export function forgetTranscriptState(): void { paneReads.clear(); forgetAllCodexState(); forgetAllPiIndexes(); + forgetAgyState(); forgetDevinState(); } @@ -996,6 +1013,7 @@ export function forgetPaneTranscriptState(paneId: string): void { clearScans.delete(path); forgetCodexStateFor(path); forgetPiIndex(path); + forgetAgyState(path); forgetClaudeSessionFile(path); forgetOpencodeRead(path); } @@ -1154,7 +1172,7 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { /** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ type ResolvedTranscript = - | { source: StreamSource; path: string; codexHome?: string } + | { source: StreamSource; path: string; codexHome?: string; agy?: AgyStore } | { source: "opencode-transcript"; path: string; session: string }; /** A native file or database session, resolved without parsing conversation turns. */ @@ -1193,6 +1211,15 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin // session headers name the one they run in const sessionCwd = typeof pane.foreground_cwd === "string" && pane.foreground_cwd.length > 0 ? pane.foreground_cwd : cwd; try { + if (agent === "agy") { + const found = await resolveAgyStore(pane); + if (!found) throw new ConversationUnavailable("no_session_path"); + if (!statSync(found.path, { throwIfNoEntry: false })) { + if (agyCatalog(found.path).size !== 0 || !sameAgyStore(found, await resolveAgyStore(pane))) throw new ConversationUnavailable("transcript_missing"); + throw new ConversationNotStarted(found.session, "agy-transcript", found.path); + } + return { source: "agy-transcript", path: found.path, agy: found }; + } if (agent === "codex") { // the pane's own store: the rollout, its history and its images are all read from there const home = await paneCodexHome(paneId, codexHome); @@ -1221,6 +1248,7 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin } throw new ConversationUnavailable("no_recognized_transcript"); } catch (error) { + if (error instanceof AgyUnavailable) throw new ConversationUnavailable("transcript_missing"); if (!(error instanceof ConversationUnavailable) || !(await paneRunsOmo(paneId))) throw error; return { source: "omo-transcript", path: await omoTranscriptPath(paneId, cwd, panes) }; } @@ -1356,16 +1384,29 @@ export async function paneConversation(paneId: string, codexHome?: string, page: const key = `opencode\0${resolved.path}\0${resolved.session}\0${page.before ?? ""}\0${page.since ?? ""}\0${page.from ?? ""}`; return { source: resolved.source, ...conversation, version: answerVersion(key, signature) }; } - const identity = resolved.source === "claude-transcript" ? nodePath.basename(resolved.path, ".jsonl") + const identity = resolved.source === "agy-transcript" ? resolved.path + : resolved.source === "claude-transcript" ? nodePath.basename(resolved.path, ".jsonl") : resolved.source === "pi-transcript" || resolved.source === "omp-transcript" ? realpathSync(resolved.path) : null; rememberPaneRead(paneId, resolved.path); const answer = transcriptPage(resolved.source, resolved.path, page, resolved.codexHome ?? codexHome); + if (resolved.agy && !sameAgyStore(resolved.agy, await resolveAgyStore(snapshot.panes.find((pane) => pane.pane_id === paneId)!))) throw new ConversationUnavailable("no_session_path"); if (identity !== null) { writtenSessions.add(`${resolved.source}\0${identity}`); rememberPaneSession(paneId, `${resolved.source}\0${identity}`); } return answer; } /** One page of a resolved transcript (paneConversation's `page`). */ export function transcriptPage(source: StreamSource, path: string, page: ConversationPage = {}, codexHome?: string): RecognizedConversation { + if (source === "agy-transcript") { + try { return withAgyHistory(path, (projection, fd) => transcriptPageRead(source, path, page, codexHome, projection, fd)); } + catch (error) { + if (error instanceof HistoryChanged || error instanceof ConversationUnavailable) throw error; + throw new ConversationUnavailable("transcript_missing"); + } + } + return transcriptPageRead(source, path, page, codexHome); +} + +function transcriptPageRead(source: StreamSource, path: string, page: ConversationPage, codexHome?: string, agy?: AgyProjection, fd?: number): RecognizedConversation { let stat: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }; try { stat = statSync(path); @@ -1374,7 +1415,7 @@ export function transcriptPage(source: StreamSource, path: string, page: Convers } let stream: TranscriptStream; try { - stream = transcriptStream(source, path, stat, codexHome ?? defaultCodexHome()); + stream = transcriptStream(source, path, stat, codexHome ?? defaultCodexHome(), agy, fd); applyHistoryBoundary(path, stream, source); } catch (error) { // a branch that cannot be walked says so; a file that went unreadable mid-read has @@ -1385,7 +1426,7 @@ export function transcriptPage(source: StreamSource, path: string, page: Convers // an older page never changes while its file and the rollouts before it stay the same // (the stream's id names both); the newest one changes with every append const key = page.before !== undefined ? `${path}\0before:${page.before}:${page.since ?? ""}` : `${path}\0from:${page.from ?? ""}`; - const signature = page.before !== undefined ? stream.id : `${stream.id}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; + const signature = page.before !== undefined ? stream.id : `${stream.id}:${stream.length}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; const cached = cache.get(key); // the answer is a function of the page asked for and the file's state, so they name it const version = answerVersion(key, signature); @@ -1512,7 +1553,9 @@ export async function toolOutput(paneId: string, ref: string, codexHome?: string return output !== null && output.length > TOOL_OUTPUT_MAX ? `${output.slice(0, TOOL_OUTPUT_MAX)}\n… trimmed` : output; } rememberPaneRead(paneId, resolved.path); - return transcriptToolOutput(resolved.source, resolved.path, ref, resolved.codexHome ?? codexHome); + const output = transcriptToolOutput(resolved.source, resolved.path, ref, resolved.codexHome ?? codexHome); + if (resolved.agy && !sameAgyStore(resolved.agy, await resolveAgyStore(pane))) return null; + return output; } /** The output a transcript file holds for one tool call id, whole (up to TOOL_OUTPUT_MAX). */ @@ -1530,6 +1573,7 @@ export function transcriptToolOutput(source: StreamSource, path: string, ref: st } return null; } + if (source === "agy-transcript") return agyToolOutput(path, ref); if (source === "pi-transcript") return piToolOutput(path, ref); let text: string; try { text = readFileSync(path, "utf8"); } catch { return null; } diff --git a/shared/protocol.ts b/shared/protocol.ts index 8b01d3b331..438c14d20e 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -106,8 +106,9 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * history, soft-wrapped lines joined; a terminal selection that outlives one screen) * POST /api/pane/input { pane_id, text } -> { ok: true } * GET /api/pane/conversation?pane_id= -> ConversationResponse (structured agent - * transcript turns - claude, codex, omp, omo, gjc, pi or devin; source:"scrollback" when the pane has no - * recognized store) + * transcript turns - claude, codex, omp, omo, gjc, pi, devin, opencode or agy; + * source:"scrollback" when the pane has no recognized, readable store. + * Agy requires live process/session evidence; rewinds invalidate cursors.) * POST /api/pane/close { pane_id } -> { ok: true } (pane.close RPC; the collector's * session-changed broadcast removes it from every client's sidebar) * POST /api/pane/rename { pane_id, label } -> { ok: true } (pane.rename; empty label clears it) @@ -467,7 +468,7 @@ export interface OmoActivity { export interface ConversationResponse { /** Stable across appends; changes on transcript replacement or native context clear. */ history_id?: string; - source: "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "scrollback"; + source: "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "scrollback"; turns: ConversationTurn[]; metadata?: ConversationMetadata; /** diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index db6520c7a6..53d0967aeb 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -43,7 +43,7 @@ import { sendUserFileCall } from "../lib/sendUserFile.ts"; const ChatPaneContext = createContext(null); const ChatHistoryContext = createContext(""); import type { TypedAnswer } from "../lib/promptAnswer.ts"; -import type { AgentStatus, ConversationMetadata, ConversationPart, ConversationTurn, InteractivePrompt, OmoTaskResult } from "../../shared/protocol.ts"; +import type { AgentStatus, ConversationMetadata, ConversationPart, ConversationResponse, ConversationTurn, InteractivePrompt, OmoTaskResult } from "../../shared/protocol.ts"; import { chatIsBlank, type ChatRead } from "../lib/greeting.ts"; import { currentLocale, useT } from "../lib/i18n.ts"; @@ -551,7 +551,7 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio // polls pause while the page is hidden and pick up at once when it is back const visible = usePageVisible(); /** the answer last laid out: an unchanged poll (a 304) hands back this very object */ - const lastAnswer = useRef(null); + const lastAnswer = useRef(null); const [conversationRefresh] = useState(() => new ConversationRefresh()); const onMetadataRef = useRef(onMetadata); onMetadataRef.current = onMetadata; @@ -709,6 +709,13 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio let next: ChatState; if (conversation.source !== "scrollback") next = { source: "conversation", turns: conversation.turns, messages: [], truncated: false }; else { + // The reader has rejected this history. Hide it before the separate terminal + // request, which may wait or fail; a transport error must not revive those turns. + if (lastAnswer.current?.source !== "scrollback") { + setState((previous) => previous.source === "conversation" + ? { source: "scrollback", turns: [], messages: [], truncated: false } : previous); + signature.current = ""; lastAnswer.current = null; + } const result = await fetchPaneTranscript(paneId, TRANSCRIPT_LINES); if (cancelled || generation !== olderGeneration.current) return; next = { source: "scrollback", turns: [], messages: toTranscriptMessages(result.text).filter((message) => message.role !== "status"), truncated: result.truncated === true }; From a0b5b8d9671673e55208e99f73972b3d503e719e Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 16:24:22 +0900 Subject: [PATCH 2/3] fix(chat): fall back when Agy transcript stat fails --- server/conversation.test.ts | 14 +++++++++++++- server/conversation.ts | 5 ++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/server/conversation.test.ts b/server/conversation.test.ts index fab94173d5..4ff15dbbec 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -9,11 +9,12 @@ import { toolVerb } from "../src/lib/toolVerbs.ts"; import type { ConversationTurn } from "../shared/protocol.ts"; import { parseAgyTranscript } from "./agy.ts"; import { agyStoreFromLocks } from "./agy-store.ts"; +import * as agyStore from "./agy-store.ts"; import * as herdr from "./herdr/client.ts"; import { forgetHistoryChains } from "./codex.ts"; import { DevinHistoryChanged } from "./devin.ts"; import * as omo from "./omo.ts"; -import { ConversationUnavailable, devinSessionForPane, forgetPaneTranscriptState, forgetTranscriptState, gjcTranscriptPath, HistoryChanged, isDevinProcess, isOmoProcess, ompSessionStore, paneConversation, parseClaudeTranscript, unwrapPastes, transcriptImage, transcriptPage, transcriptToolOutput } from "./conversation.ts"; +import { ConversationUnavailable, devinSessionForPane, forgetPaneTranscriptState, forgetTranscriptState, gjcTranscriptPath, HistoryChanged, isDevinProcess, isOmoProcess, ompSessionStore, paneConversation, paneTranscript, parseClaudeTranscript, unwrapPastes, transcriptImage, transcriptPage, transcriptToolOutput } from "./conversation.ts"; let mockedPanes: HerdrPane[] = []; let mockedProcesses: { argv?: unknown }[] = []; @@ -1632,4 +1633,15 @@ describe("Agy native ledger through shared paging", () => { db.exec("DROP TABLE steps"); expect(page).toThrow(ConversationUnavailable); }); + it("falls back when the transcript path cannot be inspected", async () => { + const loop = join(root, "loop"); symlinkSync(loop, loop); + const pane = { pane_id: "agy-pane", cwd: root, agent: "agy" } as HerdrPane; + const store = spyOn(agyStore, "resolveAgyStore").mockResolvedValue({ root, session: "test", path: loop, pid: 2, started: "test" }); + const rpc = spyOn(herdr, "herdrRpc").mockResolvedValue({ process_info: { foreground_processes: [] } } as never); + try { + await expect(paneTranscript(pane.pane_id, undefined, { panes: [pane] } as SessionSnapshot)).rejects.toMatchObject({ + name: "ConversationUnavailable", message: "transcript_missing", + }); + } finally { store.mockRestore(); rpc.mockRestore(); } + }); }); diff --git a/server/conversation.ts b/server/conversation.ts index e2eae39d2b..d05ac71328 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -1214,7 +1214,10 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin if (agent === "agy") { const found = await resolveAgyStore(pane); if (!found) throw new ConversationUnavailable("no_session_path"); - if (!statSync(found.path, { throwIfNoEntry: false })) { + let transcriptExists: boolean; + try { transcriptExists = !!statSync(found.path, { throwIfNoEntry: false }); } + catch { throw new ConversationUnavailable("transcript_missing"); } + if (!transcriptExists) { if (agyCatalog(found.path).size !== 0 || !sameAgyStore(found, await resolveAgyStore(pane))) throw new ConversationUnavailable("transcript_missing"); throw new ConversationNotStarted(found.session, "agy-transcript", found.path); } From fdc705ccc9a19eb4c63445b82d4c0b84b50509ce Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 17:04:18 +0900 Subject: [PATCH 3/3] docs(chat): mark Antigravity reader experimental --- CHANGELOG.md | 4 ++-- docs/guide.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52cbd7642e..2226bd1efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,8 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - On a phone, a two-finger pinch on the terminal changes its font size live (10–22 px), with a badge showing the size; the shared pty resizes once, when a finger lifts. ([#713](https://github.com/devswha/herdr-web-ui/pull/713) by @zerodice0, [#758](https://github.com/devswha/herdr-web-ui/pull/758)) -- Antigravity chat reads native messages and tool activity, follows the running process’s - active conversation, and honors saved rewind boundaries. +- **Experimental Antigravity chat** reads native messages and tool activity, follows the + running process’s active conversation, and honors saved rewind boundaries. macOS is untested. See [compatibility and validation](docs/agy-chat.md). ([#789](https://github.com/devswha/herdr-web-ui/pull/789) by @infowolfe) - Hermes's clarify questions get an answer card, as Claude Code's and Codex's do: one tap per diff --git a/docs/guide.md b/docs/guide.md index abdc38885c..8df910aef7 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -151,7 +151,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **pi** | Native session file, resolved through herdr; after `/tree`, the branch in play | ✓ its dialogs: a question, a confirmation, an answer typed in | | **OpenCode** | Native session in OpenCode 2's own database, resolved through herdr: `~/.local/share/opencode/opencode.db` (`%USERPROFILE%\.local\share\opencode\opencode.db` on Windows), or where the server's `XDG_DATA_HOME` or `OPENCODE_DB` puts it | — use Terminal | | **Devin CLI** | Native SQLite session, showing the active branch when herdr or the live process explicitly identifies the session; otherwise terminal text | — use Terminal | -| **Antigravity (agy)** | Native ledger, selected by the running process’s held conversation lock. Linux native-tested; macOS `lsof` untested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal | +| **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal | | **Anything else** | The terminal's text | — use Terminal | Devin sessions are never selected by directory or recency alone. A plain `devin` shows the