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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ 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.

### 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
24 changes: 24 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
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]]);
});
});
21 changes: 20 additions & 1 deletion server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { herdrRpc, paneRead, sessionSnapshot } from "./herdr/client.ts";

type RecordValue = Record<string, unknown>;
Expand Down Expand Up @@ -59,6 +60,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 +145,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 @@ -144,6 +162,7 @@ export function parseCodexTranscript(text: string, maxTurns = 100): Conversation
if (!tool) continue;
const output = contentText(payload.output);
tool.output = output.length > 4000 ? `${output.slice(0, 4000)}\n… trimmed` : output;
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
30 changes: 30 additions & 0 deletions server/conversation-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,33 @@ describe("recorded conversation model settings", () => {
expect(parseConversationMetadata("", "scrollback")).toEqual({ model: null, reasoning_effort: null });
});
});

describe("context use", () => {
const assistant = (usage: Record<string, number>, model = "claude-opus-5-5", extra: Record<string, unknown> = {}) =>
({ type: "assistant", ...extra, message: { role: "assistant", model, usage } });

it("reads Codex's last request against the window it states", () => {
const tokens = (total: number) => ({ type: "event_msg", payload: { type: "token_count", info: { last_token_usage: { total_tokens: total }, model_context_window: 258_400 } } });
expect(parseConversationMetadata(jsonl(tokens(10_000), tokens(67_723), { type: "event_msg", payload: { type: "token_count", info: null } }), "codex-transcript").context)
.toEqual({ used: 67_723, window: 258_400 });
});

it("adds up a Claude request's input and cache, skipping subagents, and knows the 1M window once past 200k", () => {
const small = parseConversationMetadata(jsonl(assistant({ input_tokens: 2, cache_creation_input_tokens: 700, cache_read_input_tokens: 40_000, output_tokens: 900 })), "claude-transcript");
expect(small.context).toEqual({ used: 40_702, window: null });
const long = parseConversationMetadata(jsonl(
assistant({ input_tokens: 5, cache_read_input_tokens: 420_000 }),
assistant({ input_tokens: 1, cache_read_input_tokens: 5_000 }, "claude-opus-5-5", { isSidechain: true }),
// compacted: the use shrinks, the window stays
assistant({ input_tokens: 3, cache_read_input_tokens: 30_000 }),
assistant({ input_tokens: 0, output_tokens: 0 }, "<synthetic>"),
), "claude-transcript");
expect(long.context).toEqual({ used: 30_003, window: 1_000_000 });
});

it("reads omp's usage shape, with no window to go by", () => {
const entry = { type: "message", message: { role: "assistant", model: "gpt-6", usage: { input: 2, output: 7_000, cacheRead: 10_000, cacheWrite: 22_000 } } };
expect(parseConversationMetadata(jsonl(entry), "omo-transcript").context).toEqual({ used: 32_002, window: null });
expect(parseConversationMetadata(jsonl({ type: "message", message: { role: "user" } }), "omp-transcript").context).toBeUndefined();
});
});
34 changes: 34 additions & 0 deletions server/conversation-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import type { ConversationMetadata, ConversationResponse } from "../shared/proto
type RecordValue = Record<string, unknown>;
const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
const label = (value: unknown): string | null => typeof value === "string" && value.trim() && !value.startsWith("<") ? value.trim() : null;
const count = (value: unknown): number => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;

/** Claude's standard window; a request past it can only have run in the 1M one. */
const CLAUDE_STANDARD_WINDOW = 200_000;
const CLAUDE_LONG_WINDOW = 1_000_000;

/**
* The context a request filled, and the window when the transcript leaves no doubt. Only
* Codex records its window; a Claude model is known to run in the 1M one once a request
* went past 200k (and keeps it: a compaction shrinks the use, not the window).
*/
function contextOf(used: number, model: string | null, window: number | null, before: ConversationMetadata["context"]): ConversationMetadata["context"] {
if (used === 0) return before;
if (window !== null) return { used, window };
const long = model?.startsWith("claude") && (used > CLAUDE_STANDARD_WINDOW || before?.window === CLAUDE_LONG_WINDOW);
return { used, window: long ? CLAUDE_LONG_WINDOW : null };
}

/** Read recorded settings, never infer effort from the presence of thinking text.
* Codex turn_context is a snapshot; omp/omo setting changes are separate events. */
Expand All @@ -13,6 +30,14 @@ export function parseConversationMetadata(text: string, source: ConversationResp
let entry: RecordValue;
try { entry = record(JSON.parse(line)); } catch { continue; }
if (source === "codex-transcript") {
const event = record(entry.payload);
if (entry.type === "event_msg" && event.type === "token_count") {
// what the last request filled, against the window Codex itself states
const info = record(event.info);
const window = count(info.model_context_window);
metadata.context = contextOf(count(record(info.last_token_usage).total_tokens), metadata.model, window || null, metadata.context);
continue;
}
if (entry.type !== "turn_context" && entry.type !== "session_meta") continue;
const payload = record(entry.payload);
const settings = record(record(payload.collaboration_mode).settings);
Expand All @@ -27,9 +52,18 @@ export function parseConversationMetadata(text: string, source: ConversationResp
if (entry.type === "thinking_level_change") metadata.reasoning_effort = label(entry.thinkingLevel);
const message = record(entry.message);
if (entry.type === "message" && message.role === "assistant" && label(message.model)) metadata.model = label(message.model);
if (entry.type === "message" && message.role === "assistant") {
const usage = record(message.usage);
metadata.context = contextOf(count(usage.input) + count(usage.cacheRead) + count(usage.cacheWrite), metadata.model, null, metadata.context);
}
} else if (source === "claude-transcript") {
const message = record(entry.message);
if (entry.type === "assistant" && label(message.model)) metadata.model = label(message.model);
// a subagent's request fills its own context, not this one's
if (entry.type === "assistant" && entry.isSidechain !== true && label(message.model)) {
const usage = record(message.usage);
metadata.context = contextOf(count(usage.input_tokens) + count(usage.cache_creation_input_tokens) + count(usage.cache_read_input_tokens), metadata.model, null, metadata.context);
}
// Claude Code records the effort each response ran at; versions before it record none
if (entry.type === "assistant" && "effort" in entry) metadata.reasoning_effort = label(entry.effort);
}
Expand Down
Loading