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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa

## [Unreleased]

### Added
- Quick replies: one-tap messages above the chat's message box (`continue`, `yes`, `no`,
`commit and push`, `retry` to start with). Each is sent as if typed: queued while the agent works,
taken as the answer when a question is open, and a draft in the box stays. **Settings → Quick
replies** edits the list on each device; the row is hidden until the ⚡ button beside the paperclip
shows it.
- The chat's status line says how much context is left: `74% left` from the window Codex records,
or for Claude once a request has run past 200k (the 1M window); `68k used` where the transcript
names no window (omp, omo, gjc, and Claude below 200k). It turns red at 20% left. Hovering shows
the token counts.
- A tool call that failed says so: its row reads "failed" in red, its output is headed Error, and
the folded "Worked for…" header counts the failures. Claude and omp record the failure; for Codex
it is read from the output (a command that exited non-zero, a script or patch that failed).
- A file a tool call names (an edit, a read, a patch's files) opens in the viewer on a tap.
- A Codex patch reads as a diff: each file as a header, then its lines in red and green, and the row
is summed up by the files it touches, also when an `exec` script applies it.
- While reading further up the chat, a ↓ button takes you back to the end, new messages or not.
- Images you sent show above your message: ones pasted into Claude's prompt, fetched only when
shown, and ones attached here (an `@…png` mention). A tap opens them.
- An edit reads as one diff: unchanged lines once, removed and added lines in place between them,
instead of the whole old block and then the whole new one. Several edits to one file each get
theirs.
- Where a compaction folded a Claude conversation, a divider says so, and opens to its summary.
- Headings down to `######` render as headings.
- A tool output cut for the chat (past 4,000 characters) has a "Show the whole output" button that
fetches the rest, up to 2 MB, into a box of its own.
- The `/` menu lists Claude's skills (yours and the project's) and the skills and commands of the
plugins turned on in its settings, as `/<plugin>:<name>`. In a Codex pane it lists your saved
prompts as `/prompts:<name>`, and `$` opens Codex's skills.
- On a phone, the terminal lens has an input line above the key bar. A phone keyboard rewrites
what it typed (dictation revising a phrase, a Korean syllable being composed, autocorrect), and
the terminal could not take back keys it had sent, so every revision arrived as more text. The
line is written with the keyboard's own editing and goes to the pane whole, then Enter (typed
like the keyboard, into an agent's open menu too); an empty line's button presses Enter alone.
Tapping the terminal no longer raises the keyboard; the ⌨ key on the key bar switches to typing
straight into it, remembered per device. Desktops are unchanged.

### Fixed
- An agent's chat said "No conversation yet" until its first answer arrived, seconds on a remote PC.
It now says it is loading.

### Changed
- Alerts wait before they go out, and a change of the pane meanwhile calls them off: a question
answered at the PC within 10 seconds, or a finish followed by the next prompt within a minute,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

- **Nothing in between.** Claude Code, Codex, omp, omo, gjc and the rest keep running in herdr exactly as you start them. There is no wrapper command, no second daemon and no account. herdr owns the sessions; this app reads herdr's socket and attaches to its terminals.
- **Chat and terminal, one pane.** Read the agent's own transcript as a chat, with its work folded per turn and its todo list pinned below. Flip to the real terminal for full-screen TUIs and raw keys.
- **Answer from anywhere.** Approval, question and plan menus show up as cards you answer with one tap. Push alerts tell you when an agent needs you or finishes, and stay quiet when you already answered at the PC.
- **Answer from anywhere.** Approval, question and plan menus show up as cards you answer with one tap, and your own quick replies (`continue`, `commit and push`, …) sit above the message box. Push alerts tell you when an agent needs you or finishes, and stay quiet when you already answered at the PC.
- **Built for the phone.** It installs as an app, with a terminal key bar, touch scrolling of herdr's history, file and image attachments, and agent panes that open in the chat.
- **Every machine, one sidebar.** Add Linux and macOS PCs over SSH. Their agents join the list with the same chat, terminal and alerts.
- **Yours only.** It runs on your machine and listens on `127.0.0.1` by default. It sends nothing anywhere except GitHub (for its own updates) and your browser's push service.
Expand Down
62 changes: 62 additions & 0 deletions scripts/ui-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,30 @@ try {
await page.locator(".composer-queue-text").waitFor({ state: "hidden" });
console.log("PASS queue held through status changes and explicitly sent to its owner");

// a quick reply goes out as typed, and leaves a draft in the box alone; the row is hidden until asked for
assert.equal(await page.locator(".composer-quick").count(), 0);
await page.getByRole("button", { name: "Show quick replies", exact: true }).click();
await composer.fill("draft stays");
const quickCount = inputs.length;
await page.getByRole("group", { name: "Quick replies", exact: true }).getByRole("button", { name: "continue", exact: true }).click();
await until(() => inputs.length > quickCount, "quick reply send");
assert.equal(inputs.at(-1)?.pane_id, paneA);
assert.match(inputs.at(-1)!.text, /^continue/);
assert.equal(await composer.inputValue(), "draft stays");
// mid-turn it is held like a typed message
await report("working");
await page.locator('.composer-status[data-status="working"]').waitFor();
await page.getByRole("group", { name: "Quick replies", exact: true }).getByRole("button", { name: "retry", exact: true }).click();
assert.equal(await page.locator(".composer-queue-text").inputValue(), "retry");
await page.getByRole("button", { name: "Discard", exact: true }).click();
await page.getByRole("button", { name: "Hide quick replies", exact: true }).click();
assert.equal(await page.locator(".composer-quick").count(), 0);
await composer.fill("");
await report("idle");
// idle after work reads DONE (server/completion.ts)
await page.locator('.composer-status:not([data-status="working"])').waitFor();
console.log("PASS quick replies send as typed, queue mid-turn, and leave the draft");

const selectPane = async (paneId: string) => {
await page.locator(`.pane-select[title^="${paneId} —"]`).click();
await page.getByRole("log", { name: `conversation of ${paneId}`, exact: true }).waitFor();
Expand Down Expand Up @@ -215,6 +239,44 @@ try {
assert.equal(await mobilePage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
assert.deepEqual(errors, []);
console.log("PASS mobile composer with unavailable storage and no horizontal overflow");

// the terminal lens on a touch screen: an input line sends whole lines; the grid raises no keyboard
const touch = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
const touchPage = await touch.newPage();
touchPage.on("pageerror", (error) => errors.push(error.message));
const touchSent: Array<Record<string, unknown>> = [];
touchPage.on("websocket", (socket) => socket.on("framesent", ({ payload }) => {
const message = JSON.parse(String(payload)) as Record<string, unknown>;
if (message.type === "input" || message.type === "submit") touchSent.push(message);
}));
await touchPage.goto(`${origin}/?pane=${encodeURIComponent(paneB)}`);
await touchPage.locator(".conn-live").waitFor();
await touchPage.getByTitle("Live terminal (⌘⇧J)", { exact: true }).click();
const line = touchPage.getByRole("textbox", { name: "Terminal input line", exact: true });
await line.waitFor();
assert.equal(await touchPage.locator(".xterm-helper-textarea").getAttribute("inputmode"), "none");
await line.fill("printf 'line-ok\\n'");
await line.press("Enter");
await until(() => touchSent.some((message) => message.type === "submit" && message.typed === true), "input line submit");
const submitted = touchSent.find((message) => message.type === "submit")!;
assert.equal(submitted.pane_id, paneB);
assert.equal(submitted.text, "printf 'line-ok\\n'");
// the line clears once the pane confirmed it
await until(async () => (await line.inputValue()) === "", "input line cleared after send");
// an empty line's button is Enter alone
const enters = touchSent.length;
await touchPage.getByRole("button", { name: "Press Enter in the terminal", exact: true }).click();
await until(() => touchSent.length > enters && touchSent.at(-1)?.type === "input" && touchSent.at(-1)?.text === "\r", "enter from the empty line");
// typing straight into the grid is one tap away, and gives the keyboard back to it
await touchPage.getByRole("button", { name: "Type straight into the terminal", exact: true }).click();
assert.equal(await touchPage.locator(".terminal-input").count(), 0);
assert.equal(await touchPage.locator(".xterm-helper-textarea").getAttribute("inputmode"), null);
await touchPage.getByRole("button", { name: "Type straight into the terminal", exact: true }).click();
await line.waitFor();
assert.equal(await touchPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
assert.deepEqual(errors, []);
await touch.close();
console.log("PASS touch terminal input line sends whole lines, Enter alone, and yields to direct typing");
} finally {
for (const release of releases) release();
await browser?.close();
Expand Down
26 changes: 25 additions & 1 deletion server/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test";
import { appendFileSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { codexHistoryTail, codexRolloutPath, forgetHistoryChains, matchCodexTranscript, parseCodexTranscript, resumedThread, unansweredCodexQuestions } from "./codex.ts";
import { codexCallFailed, codexHistoryTail, codexRolloutPath, forgetHistoryChains, matchCodexTranscript, parseCodexTranscript, resumedThread, unansweredCodexQuestions } from "./codex.ts";
import { splitTurn } from "../src/lib/workBlocks.ts";

const ts = "2026-09-22T01:00:00.000Z";
Expand Down Expand Up @@ -317,3 +317,27 @@ describe("Codex rollout resolution", () => {
expect(matchCodexTranscript(`${answer}\nOpenAI Codex (v1.0)\nNew session`, [{ path: "old", text: jsonl(message("assistant", answer)) }])).toBeNull();
});
});

describe("Codex tool calls that failed, and patches", () => {
it("reads a failure from the output Codex records, judging a completed script as a whole", () => {
expect(codexCallFailed("Chunk ID: 1\nWall time: 0.0 seconds\nProcess exited with code 1\nOutput:\n")).toBe(true);
expect(codexCallFailed("Process exited with code 0\nOutput:\nok")).toBe(false);
expect(codexCallFailed('{"output":"boom","metadata":{"exit_code":2}}')).toBe(true);
expect(codexCallFailed("Script failed\nError: x")).toBe(true);
expect(codexCallFailed("apply_patch verification failed: Failed to find expected lines in /x")).toBe(true);
expect(codexCallFailed("Script completed\nProcess exited with code 1")).toBe(false);
expect(codexCallFailed("{\"accepted\":true}")).toBe(false);
});

it("marks the failed call and sums a patch up by its files", () => {
const patch = "*** Begin Patch\n*** Update File: src/a.ts\n@@\n-x\n+y\n*** End Patch\n";
const turns = parseCodexTranscript(jsonl(
{ type: "response_item", payload: { type: "custom_tool_call", call_id: "p", name: "apply_patch", input: patch } },
{ type: "response_item", payload: { type: "custom_tool_call_output", call_id: "p", output: "Success. Updated the following files:\nM src/a.ts" } },
{ type: "response_item", payload: { type: "function_call", call_id: "c", name: "exec_command", arguments: JSON.stringify({ cmd: "false" }) } },
{ type: "response_item", payload: { type: "function_call_output", call_id: "c", output: "Process exited with code 1\nOutput:\n" } },
));
const tools = turns.flatMap((turn) => turn.parts).filter((part) => part.kind === "tool");
expect(tools.map((tool) => [tool.summary, tool.error === true])).toEqual([["src/a.ts", false], ["false", true]]);
});
});
29 changes: 27 additions & 2 deletions server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { closeSync, openSync, readdirSync, readFileSync, readlinkSync, readSync,
import { homedir } from "node:os";
import { isAbsolute, join, relative, sep } from "node:path";
import type { ConversationPart, ConversationTurn, HerdrPane } from "../shared/protocol.ts";
import { patchFiles, patchText } from "../shared/patch.ts";
import { trimOutput } from "./tool-output.ts";
import { herdrRpc, paneRead, sessionSnapshot } from "./herdr/client.ts";

type RecordValue = Record<string, unknown>;
Expand Down Expand Up @@ -43,6 +45,11 @@ function questionTitles(args: RecordValue): string[] {
: [];
}

/** A tool call's output as Codex recorded it, as text. */
export function codexOutputText(value: unknown): string {
return contentText(value);
}

function contentText(value: unknown, user = false): string {
if (typeof value === "string") return user && contextOnly(value) ? "" : value;
if (!Array.isArray(value)) return "";
Expand All @@ -59,6 +66,20 @@ function entries(text: string): RecordValue[] {
});
}


/**
* Whether a Codex tool output says the call failed. Codex records no flag: its command
* runner writes the exit code into the output, and a script or patch its own verdict. A
* script that completed is judged as a whole, however its commands went.
*/
export function codexCallFailed(output: string): boolean {
const head = output.slice(0, 2000);
if (/^Script completed\b/m.test(head)) return false;
if (/^Script failed\b/m.test(head) || /apply_patch verification failed/.test(head)) return true;
const code = /^(?:Process exited with code|Exit code:) (\d+)$/m.exec(head) ?? /"exit_code":\s*(\d+)/.exec(head);
return code !== null && code[1] !== "0";
}

export function parseCodexTranscript(text: string, maxTurns = 100): ConversationTurn[] {
const turns: ConversationTurn[] = [];
const tools = new Map<string, Extract<ConversationPart, { kind: "tool" }>>();
Expand Down Expand Up @@ -130,7 +151,10 @@ export function parseCodexTranscript(text: string, maxTurns = 100): Conversation
const raw = payload.type === "function_call" ? payload.arguments : payload.input;
let args = record(raw);
if (typeof raw === "string") { try { args = record(JSON.parse(raw)); } catch { /* Freeform tool input. */ } }
const summary = /^request_user_input/.test(name) && questionTitles(args).length > 0
// a patch, bare or in an exec script, is summed up by the files it touches
const patch = typeof raw === "string" ? patchText(raw) : null;
const summary = patch !== null && patchFiles(patch).length > 0 ? patchFiles(patch).join(", ")
: /^request_user_input/.test(name) && questionTitles(args).length > 0
? questionTitles(args).join(" · ")
: [args.cmd, args.command, args.file_path, args.path, args.pattern, args.description, args.url].find((v) => typeof v === "string");
const part: Extract<ConversationPart, { kind: "tool" }> = {
Expand All @@ -143,7 +167,8 @@ export function parseCodexTranscript(text: string, maxTurns = 100): Conversation
const tool = tools.get(string(payload.call_id));
if (!tool) continue;
const output = contentText(payload.output);
tool.output = output.length > 4000 ? `${output.slice(0, 4000)}\n… trimmed` : output;
trimOutput(tool, output, string(payload.call_id));
if (codexCallFailed(output)) tool.error = true;
tools.delete(string(payload.call_id));
const turn = turns.at(-1);
if (turn?.role === "assistant" && ts) turn.end_ts = ts;
Expand Down
37 changes: 37 additions & 0 deletions server/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,40 @@ describe("paneCommands", () => {
expect(commands).toContainEqual({ name: "team:review", description: "Review this project thoroughly", source: "project" });
});
});

describe("skills and plugins", () => {
const write = (path: string, text: string) => { mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, text); };

it("offers Claude's skills, user and project, and the enabled plugins' skills and commands", () => {
const home = temp("skills-home-");
const cwd = temp("skills-cwd-");
write(join(home, ".claude", "skills", "patina", "SKILL.md"), "---\nname: patina\ndescription: Rewrite AI prose\n---\n");
write(join(home, ".claude", "skills", "notes", "README.md"), "no SKILL.md: not a skill");
write(join(cwd, ".claude", "skills", "deploy", "SKILL.md"), "---\ndescription: Ship it\n---\n");
const plugin = join(home, ".claude", "plugins", "cache", "market", "hud", "1.0.0");
write(join(plugin, "skills", "setup", "SKILL.md"), "---\nname: setup\ndescription: Configure the HUD\n---\n");
write(join(plugin, "commands", "configure.md"), "---\ndescription: Configure options\n---\n");
const off = join(home, ".claude", "plugins", "cache", "market", "off", "1.0.0");
write(join(off, "skills", "hidden", "SKILL.md"), "---\nname: hidden\n---\n");
write(join(home, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({ plugins: { "hud@market": [{ installPath: plugin }], "off@market": [{ installPath: off }] } }));
write(join(home, ".claude", "settings.json"), JSON.stringify({ enabledPlugins: { "hud@market": true, "off@market": false } }));
const extra = paneCommands("claude", cwd, home).filter((command) => command.source === "skill" || command.source === "plugin");
expect(extra).toEqual([
{ name: "deploy", description: "Ship it", source: "skill" },
{ name: "hud:configure", description: "Configure options", source: "plugin" },
{ name: "hud:setup", description: "Configure the HUD", source: "plugin" },
{ name: "patina", description: "Rewrite AI prose", source: "skill" },
]);
});

it("offers Codex's saved prompts as /prompts:<name> and its skills with $", () => {
const home = temp("codex-home-");
write(join(home, ".codex", "prompts", "review.md"), "Review the diff\n");
write(join(home, ".codex", "skills", "deepinit", "SKILL.md"), "---\nname: deepinit\ndescription: Deep codebase initialization\n---\n");
const extra = paneCommands("codex", null, home).filter((command) => command.source !== "builtin");
expect(extra).toEqual([
{ name: "deepinit", description: "Deep codebase initialization", source: "skill", trigger: "$" },
{ name: "prompts:review", description: "Review the diff", source: "user" },
]);
});
});
Loading