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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ diffity inbox # run the watcher and a small status server
diffity inbox --once # run a single poll-and-prepare pass, then exit
diffity inbox status # print the current inbox without starting the daemon
diffity inbox status --json
diffity inbox runs # every agent run of the last 7 days, and what it spent
diffity inbox runs --since 30 --json
```

On first run it writes `~/.diffity/inbox/config.json`:
Expand All @@ -365,6 +367,10 @@ On first run it writes `~/.diffity/inbox/config.json`:

The command itself is not configurable: the daemon builds `claude -p --output-format json` with the flags the review depends on. It runs with `--setting-sources ""`, so the agent gets none of your Claude settings — no MCP servers, no memory, no `CLAUDE.md`, none of your installed skills. Listing tools in `agent.mcpAllow` brings your MCP servers back and adds a `PreToolUse` hook (`diffity inbox mcp-gate`) that refuses every MCP call but those, by name; the prompt then tells the agent it may read the ticket or document the pull request refers to, and nothing else outside the checkout. A deny list keeps it off `gh pr review`, `gh pr comment`, `gh pr merge` and `gh api`, and off `pnpm`, `npm`, `npx`, `yarn`, `bun` and `make` — CI has already built and tested this head. The skill shipped with this build goes into the agent's system prompt — `diffity-review` for a preparation, `diffity-live` for an answer — so neither depends on what you have installed.

Every agent run is logged: the pull request and head it was for, which pass it was (`prepare` for a preparation, `answer` for a question asked in the page), the models it actually used, how long it took, its turns, its cost and its tokens, and how it ended (`prepared`, `skipped`, `answered`, `failed`, `timeout`, `rate-limited`). `diffity inbox runs` prints that log with totals — the record of what the inbox costs you. A prepared review's card carries its own share of it, "· 8 min · $1.20", with each run behind that head listed on hover, and the page's footer keeps a running total for today and for the last seven days.

A run that ends on your Claude session limit is not the pull request's fault, so it is waited out rather than retried: the row goes back in the queue as "waiting: Claude session limit until 14:00", no failed attempt is counted against it, and no further preparation starts until the limit lifts. The reset time is read out of the agent's own message ("resets 2pm (Europe/Stockholm)"), or set half an hour ahead when the message names none. Polling and reconciling carry on meanwhile, so the page stays current and says how long the pause has left; the pause is kept with the inbox, so restarting the daemon does not spend another run rediscovering the limit.

If your config still has a `prepare` key from an earlier version, delete it — the daemon refuses to start with it, and the built command takes its place. A flag you were passing belongs in `agent.extraArgs`.

> ⚠️ The agent runs inside a checkout the pull request's author controls, so it can execute their repository code. The daemon runs it without the forge's credentials in its environment, with the deny list above, and — unless you list MCP tools in `agent.mcpAllow`, which loads your settings for the servers behind the gate — with none of your Claude settings. That is defence in depth, not a sandbox.
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@diffity/api",
"version": "0.10.22",
"version": "0.10.23",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@naturalcycles/diffity",
"version": "0.10.22",
"version": "0.10.23",
"description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop",
"type": "module",
"bin": {
Expand Down
66 changes: 65 additions & 1 deletion packages/cli/src/commands/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import pc from 'picocolors';
import { isCliInstalled, isAuthenticated } from '@diffity/github';
import { loadInboxConfig } from '../inbox/config.js';
import { inboxConfigPath, inboxStorePath } from '../inbox/paths.js';
import { InboxStore } from '../inbox/store.js';
import { InboxStore, type RunTotals } from '../inbox/store.js';
import { runDaemon } from '../inbox/daemon.js';
import { allowFromEnv, mcpGateDecision } from '../inbox/mcp-gate.js';
import { buildView } from '../inbox/view.js';
import { localHhMm, localWhen, minutesOf, money, tokensLabel } from '../inbox/runs.js';

export function registerInboxCommand(program: Command): void {
const inbox = program
Expand Down Expand Up @@ -88,6 +89,7 @@ export function registerInboxCommand(program: Command): void {

if (view.ready.length === 0 && view.working.length === 0 && view.other.length === 0 && view.dismissed.length === 0) {
console.log(pc.dim('Nothing in the inbox yet. Run `diffity inbox` to start watching.'));
spent(view);
return;
}

Expand All @@ -103,9 +105,71 @@ export function registerInboxCommand(program: Command): void {
section('Dismissed', view.dismissed.map(row =>
` ${pc.dim('dismissed')} ${row.repo}#${row.number} ${row.title}`,
));

spent(view);
});

inbox
.command('runs')
.description('Print the agent runs the inbox has made, and what they spent')
.option('--json', 'Output as JSON')
.option('--since <days>', 'How far back to look, in days', '7')
.action((opts: { json?: boolean; since?: string }) => {
const days = Number(opts.since ?? 7);
if (!Number.isFinite(days) || days <= 0) {
console.error(pc.red('Error: --since takes a number of days.'));
process.exit(1);
}
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const store = new InboxStore(inboxStorePath());
const runs = store.runs({ since });
const totals = store.runTotals(since);
store.close();

if (opts.json) {
console.log(JSON.stringify({ since, days, runs, totals }, null, 2));
return;
}
if (runs.length === 0) {
console.log(pc.dim(`No agent runs in the last ${days} day(s).`));
return;
}
table([
['when', 'PR', 'phase', 'model', 'turns', 'min', 'cost', 'tokens', 'outcome'],
...runs.map(run => [
localWhen(run.startedAt), run.prId, run.phase, run.model ?? '—',
run.turns === null ? '—' : String(run.turns),
run.durationMs === null ? '—' : minutesOf([run]).toFixed(1),
money(run.costUsd), tokensLabel(run) || '—', run.outcome,
]),
]);
console.log('');
console.log(`${totals.count} run${totals.count === 1 ? '' : 's'} · ${Math.round(totals.minutes)} min · ${money(totals.costUsd)} over the last ${days} day(s)`);
});
}

/** What the agent has spent, and whether it is waiting out a limit, for the foot of the listing. */
function spent(view: { runs: { today: RunTotals; week: RunTotals }; pausedUntil: string | null }): void {
const window = (totals: RunTotals) => `${totals.count} · ${Math.round(totals.minutes)} min · ${money(totals.costUsd)}`;
if (view.runs.week.count > 0) {
console.log('');
console.log(pc.dim(`agent runs today: ${window(view.runs.today)} · 7 days: ${window(view.runs.week)}`));
}
if (view.pausedUntil) {
console.log(pc.yellow(`Preparing paused until ${localHhMm(view.pausedUntil)} — Claude session limit`));
}
}

/** Rows printed as columns, the first row being the header. */
function table(rows: string[][]): void {
const widths = rows[0].map((_, column) => Math.max(...rows.map(row => row[column].length)));
const line = (row: string[]) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ').trimEnd();
console.log(pc.dim(line(rows[0])));
for (const row of rows.slice(1)) {
console.log(line(row));
}
}

/** All of stdin, parsed; unparseable input reads as null, which the gate refuses. */
async function readJsonStdin(): Promise<unknown> {
let raw = '';
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/inbox/agent-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,101 @@ export function parseAgentOutput(stdout: string): { text: string; stats: RunStat
};
}

/**
* Whether the run ended on the reviewer's Claude session limit, and when that limit lifts. The
* message either names a wall-clock time, sometimes with a zone — "resets 2pm (Europe/Stockholm)",
* "resets at 14:30" — read here as the next moment that clock shows it, or names how long is left
* — "resets in 90 minutes" — counted from now. `resetsAt` is null when the text names no time this
* understands, which leaves the caller to pick its own retry.
*/
export function rateLimitOf(text: string, now: Date): { resetsAt: string | null } | null {
if (!/hit your (?:session|usage) limit/i.test(text)) {
return null;
}
return { resetsAt: resetsIn(text, now) ?? resetsAt(text, now) };
}

/** "resets in 3 hours", "resets in 45 minutes", "resets in 1 hour 30 minutes". */
function resetsIn(text: string, now: Date): string | null {
const match = /resets\s+in\s+(?:(\d{1,3})\s*(?:hours|hour|hrs|hr|h)\b)?\s*(?:(\d{1,3})\s*(?:minutes|minute|mins|min|m)\b)?/i.exec(text);
if (!match) {
return null;
}
const hours = match[1] ? Number(match[1]) : 0;
const minutes = match[2] ? Number(match[2]) : 0;
if (hours === 0 && minutes === 0) {
return null;
}
return new Date(now.getTime() + hours * 3_600_000 + minutes * 60_000).toISOString();
}

function resetsAt(text: string, now: Date): string | null {
const match = /resets\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(\s*([A-Za-z0-9_+\-/]+)\s*\))?/i.exec(text);
if (!match) {
return null;
}
const [, rawHour, rawMinute, meridiem, zone] = match;
const minute = rawMinute ? Number(rawMinute) : 0;
let hour = Number(rawHour);
if (meridiem) {
if (hour < 1 || hour > 12) {
return null;
}
hour = (hour % 12) + (meridiem.toLowerCase() === 'pm' ? 12 : 0);
}
if (hour > 23 || minute > 59) {
return null;
}
// Today if that time is still ahead in the zone the message named, tomorrow otherwise.
for (const dayOffset of [0, 1]) {
const at = wallClockInstant(now, zone ?? null, hour, minute, dayOffset);
if (at > now.getTime()) {
return new Date(at).toISOString();
}
}
return null;
}

/** The instant at which a zone's clock reads this hour and minute, `dayOffset` days from now. */
function wallClockInstant(now: Date, zone: string | null, hour: number, minute: number, dayOffset: number): number {
if (zone) {
try {
const [year, month, day] = zonedDate(zone, now);
const wanted = Date.UTC(year, month - 1, day + dayOffset, hour, minute);
// The offset is read at the guessed instant and then at the corrected one, so a reset that
// falls on a daylight-saving change still lands on the clock time the message named.
const once = wanted - zoneOffsetMs(zone, new Date(wanted));
return wanted - zoneOffsetMs(zone, new Date(once));
} catch {
// Not a zone Intl knows (an abbreviation, say): the reviewer's own clock is the better guess.
}
}
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, hour, minute).getTime();
}

function zonedDate(zone: string, at: Date): [number, number, number] {
const parts = zoneParts(zone, at);
return [parts.year, parts.month, parts.day];
}

/** How far ahead of UTC the zone's clock is at that instant. */
function zoneOffsetMs(zone: string, at: Date): number {
const { year, month, day, hour, minute, second } = zoneParts(zone, at);
return Date.UTC(year, month - 1, day, hour, minute, second) - at.getTime();
}

function zoneParts(zone: string, at: Date): { year: number; month: number; day: number; hour: number; minute: number; second: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone, hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(at);
const value = (type: Intl.DateTimeFormatPartTypes): number => Number(parts.find(part => part.type === type)?.value);
return {
year: value('year'), month: value('month'), day: value('day'),
hour: value('hour') % 24, minute: value('minute'), second: value('second'),
};
}

function resultObject(stdout: string): Record<string, unknown> | null {
let parsed: unknown;
try {
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/inbox/attendant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export interface AttendedPr {
url: string;
title: string;
author: string;
/** The head the prepared review is for, so an answer's run is logged against it. */
headSha: string | null;
}

export interface AttendantDeps {
/** Parks on the session once — one `agent await` — and says how it ended. Aborting ends it early. */
awaitRequest(worktree: string, signal: AbortSignal): Promise<AwaitOutcome>;
/** Runs the answering agent for one request; resolves when it has finished, saying if it was cut short. */
answer(worktree: string, prompt: string, signal: AbortSignal): Promise<{ timedOut: boolean }>;
answer(worktree: string, pr: AttendedPr, prompt: string, signal: AbortSignal): Promise<{ timedOut: boolean }>;
/** Closes a request the agent could not answer, with a note in the thread, so it is not asked again. */
giveUp(worktree: string, request: LiveRequest, note: string): Promise<void>;
log(message: string): void;
Expand Down Expand Up @@ -93,7 +95,7 @@ export class Attendants {
this.deps.log(`${pr.id}: the reader asked about ${request.filePath}:${request.startLine}`);
// Not awaited: the wait is re-armed at once, and a second question arriving meanwhile
// queues behind this one on the server rather than finding nobody parked.
void this.deps.answer(worktree, composeLivePrompt(pr, worktree, request), signal)
void this.deps.answer(worktree, pr, composeLivePrompt(pr, worktree, request), signal)
.then(({ timedOut }) => timedOut
? this.deps.giveUp(worktree, request, 'The agent did not finish answering within the time allowed.')
: undefined)
Expand Down
18 changes: 15 additions & 3 deletions packages/cli/src/inbox/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logsDir, preparePr, type PrepareDeps } from './prepare.js';
import { realAttendantDeps, realPrepareDeps, type Inflight } from './runtime.js';
import { Attendants, type AttendedPr } from './attendant.js';
import { removeWorktree, cloneDir } from './worktree.js';
import { localHhMm } from './runs.js';
import { findInstanceForRepo, killInstance } from '../registry.js';
import { repoHash } from './open-session.js';
import { InboxStore } from './store.js';
Expand Down Expand Up @@ -61,6 +62,8 @@ export interface ServerHooks {
export interface DaemonStatus {
ticking: boolean;
lastPollAt: string | null;
/** Until when preparation is held back by the reviewer's Claude limit, or null when it is not. */
pausedUntil: string | null;
}

/** The settings as the page reads and writes them: the running config, persisted when a path is known. */
Expand Down Expand Up @@ -116,6 +119,9 @@ export async function runDaemon(

const inflight: Inflight = {};
const prepareDeps: PrepareDeps = realPrepareDeps(nodePath, entry, inboxDataDir, config, log, inflight);
// The pause outlives this process: a session limit is the reviewer's, not the daemon's, so it is
// kept in the store and a restart does not spend a run rediscovering it.
const pausedUntil = () => store.pausedUntil(new Date().toISOString());
const deps = {
forge: options.forge ?? realForge,
prepare: (snapshot: Parameters<typeof preparePr>[0], opts: { bumped: boolean }) => preparePr(snapshot, config, prepareDeps, opts),
Expand All @@ -125,6 +131,12 @@ export async function runDaemon(
shouldContinue: () => !stopping,
// Read at each tick, not copied: the page can change it while the daemon runs.
get maxPrepared() { return config.maxPrepared; },
get agentModel() { return config.agent.model; },
pauseUntil: (until: string) => {
store.pauseUntil(until);
log(`preparing paused until ${localHhMm(until)} — Claude session limit`);
},
pausedUntil,
};

// A bump arriving mid-tick is served by another tick right after, not by the next poll.
Expand Down Expand Up @@ -164,7 +176,7 @@ export async function runDaemon(
// server's error handler) before it can reclaim and kill the first one's in-flight servers.
const openDeps = options.openDeps ?? realOpenSessionDeps(nodePath, entry);
const attendants: AttendantHost = options.attendants ?? new Attendants(
realAttendantDeps(nodePath, entry, config, worktree => join(logsDir(), `${basename(worktree)}.live.log`), log),
realAttendantDeps(nodePath, entry, config, worktree => join(logsDir(), `${basename(worktree)}.live.log`), log, run => store.recordRun(run)),
);
let timer: NodeJS.Timeout | undefined;
const armPoll = () => {
Expand All @@ -175,7 +187,7 @@ export async function runDaemon(
};
const settings = settingsHost(config, options.configPath, armPoll);
const server = await bindInboxServer(store, config, log, openDeps, {
attendants, onBump: requestTick, onTick: requestTick, settings, status: () => ({ ticking, lastPollAt }),
attendants, onBump: requestTick, onTick: requestTick, settings, status: () => ({ ticking, lastPollAt, pausedUntil: pausedUntil() }),
});
reclaimLeftoverServers(log);
armPoll();
Expand Down Expand Up @@ -363,7 +375,7 @@ async function handleOpen(store: InboxStore, config: InboxConfig, id: string, op
}
const { pr } = resolution;
if (config.live) {
attendants?.ensure(pr.worktreePath!, { id: pr.id, url: pr.url, title: pr.title, author: pr.author });
attendants?.ensure(pr.worktreePath!, { id: pr.id, url: pr.url, title: pr.title, author: pr.author, headSha: pr.preparedHeadSha ?? pr.headSha });
}
res.writeHead(302, { Location: url });
res.end();
Expand Down
Loading
Loading