Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
`grok-1.0.50-linux-x86_64` on Linux; other builds, macOS and Windows show the terminal view.
See [setup and limits](docs/grok-chat.md).
([#777](https://github.com/devswha/herdr-web-ui/pull/777) by @infowolfe)
- **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
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
Expand Down Expand Up @@ -71,6 +75,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
([#673](https://github.com/devswha/herdr-web-ui/pull/673) by @WOULDU-pres)

### 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.
([#789](https://github.com/devswha/herdr-web-ui/pull/789) by @infowolfe)
- When a chat message is not sent because Claude Code's input box in the terminal is not empty,
the note now says what the box holds, and a one-line draft there can be cleared from the chat
with Clear it and send. The chat did not show that text, so Send seemed to do nothing until the
Expand Down
54 changes: 54 additions & 0 deletions docs/agy-chat.md
Original file line number Diff line number Diff line change
@@ -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 `<USER_REQUEST>` 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.
Binary file added docs/agy-chat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c
| **Devin CLI** | Native SQLite session, showing the active branch when herdr or the live process explicitly identifies the session; otherwise terminal text | — use Terminal |
| **Grok** | Linux native update log with exact process/session evidence, on the `grok-1.0.50-linux-x86_64` build only; other builds use Terminal. See [Grok setup and limits](grok-chat.md). | — use Terminal |
| **Hermes** | Native SQLite session store | — 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
Expand Down
83 changes: 82 additions & 1 deletion scripts/chat-history-browser-qa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from "node:path";
import { chromium } from "playwright-core";
import { VOICE_DEFAULTS, type VoiceStatus } from "../shared/voice.ts";
import type {} from "./chat-history-fixture.tsx";
import { parseAgyTranscript } from "../server/agy.ts";

const root = mkdtempSync(join(tmpdir(), "herdr-history-browser-"));
let server: ReturnType<typeof Bun.serve> | undefined;
Expand All @@ -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<string>(), failing = new Set<string>();
const pending: Array<{ key: string; answer: (status: number) => Promise<void> }> = [];
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<void>((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" },
Expand All @@ -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}:`)) {
Expand Down Expand Up @@ -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<void>((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<void>((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;
Expand Down Expand Up @@ -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");
Expand Down
87 changes: 87 additions & 0 deletions server/agy-store.ts
Original file line number Diff line number Diff line change
@@ -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<AgyStore, "root" | "session" | "path"> | null {
const locks = new Set<string>();
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<string[]> {
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<AgyStore | null> {
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;
}
Loading
Loading