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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,18 +355,22 @@ On first run it writes `~/.diffity/inbox/config.json`:
| `worktreesDir` | Where each pull request gets its worktree. |
| `filter` | Your own words on what does and doesn't need your attention, handed to the agent — it answers with a skip instead of reviewing when a PR matches (e.g. "Skip payments-focused PRs"). Editable from the page's Settings panel. |
| `alertWhen` | Your own words on what needs you *now*. The agent judges each prepared review against them and flags the ones that match; the page notifies for those only — empty means every prepared review. Editable from the page's Settings panel. |
| `alertPaths` | Globs against the pull request's changed paths, one per line in the page — `packages/shared/src/model/**`, `**/dbref/**`. A changed file matching one marks the review as needing you now, whatever the agent made of `alertWhen`. Editable from the page's Settings panel. |
| `agent.model` | `--model` for the review agent; `null` leaves its own default. Editable from the page. |
| `agent.effort` | `--effort`: `low`, `medium`, `high`, `xhigh` or `max`; `null` leaves its own default. Editable from the page. |
| `agent.mcpAllow` | The exact MCP tool names the agent may call, e.g. `mcp__claude_ai_Atlassian__getJiraIssue`. Empty (the default) means no MCP servers at all. Editable from the page. |
| `agent.extraArgs` | Appended verbatim to the built command, for a flag this table does not cover. |
| `agent.maxBudgetUsd` | `--max-budget-usd` for one run; `null` leaves it uncapped. A run that hits it is a failed attempt with that reason. Editable from the page. |
| `waitForCi` | Whether a pull request waits for green CI before an agent is spent on it (default false). Editable from the page. |
| `prepareTimeoutMinutes` | How long one preparation may take before it's abandoned. Editable from the page. |
| `maxPrepared` | How many prepared reviews may wait for you at once (default 5). Each preparation is an agent run; the rest of the queue waits until a prepared review is posted or dismissed. Editable from the page. |
| `live` | Whether opening a prepared review also parks a live agent on it (default true). Questions asked in the page — the Ask button on a finding — each run the agent once to answer; the agent may answer and amend findings, never edit code, and never reaches GitHub. Editable from the page. |
| `liveTimeoutMinutes` | How long one answer may take before the agent is stopped (default 10). Editable from the page. |

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.

What CI made of the head goes into the prompt: every check that reported, with its verdict, a count of the ones a workflow skipped, and the instruction not to install, build, typecheck, lint or test — CI has done that, and the checkout is the author's code — so the agent reasons from the source and says in its summary when a check failed or is still running. Each card carries the same verdict as a dot before its title: green for passing, red for failing, amber while checks are still going, and no dot at all when nothing has decided — no checks, or only skipped and cancelled ones. With `waitForCi` on, a pull request whose checks are still running waits in the queue ("waiting: CI running (3 checks)") and one whose checks failed is set aside ("CI failed: check-job, pr-mgmt-job") — both re-decided at every poll, so passing checks bring one back to the queue on their own, unlike a filter skip. ↑ prepares it whatever CI says, and a review already prepared for an older head stays openable while its refresh waits.

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.
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.23",
"version": "0.10.24",
"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.23",
"version": "0.10.24",
"description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop",
"type": "module",
"bin": {
Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/inbox/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ export interface InboxConfig {
* empty means every prepared review is worth a notification.
*/
alertWhen: string;
/**
* Globs against the pull request's changed paths. A pull request touching one of them is marked
* as needing the reviewer now, alongside whatever the agent made of `alertWhen`.
*/
alertPaths: string[];
agent: AgentConfig;
/** Whether a pull request waits for its CI to pass before an agent is spent on it. */
waitForCi: boolean;
prepareTimeoutMinutes: number;
/**
* How many prepared reviews may wait for the reviewer at once. Each preparation spends an agent
Expand All @@ -66,7 +73,9 @@ export const DEFAULT_INBOX_CONFIG: InboxConfig = {
worktreesDir: '~/.diffity/inbox/worktrees',
filter: '',
alertWhen: '',
alertPaths: [],
agent: { model: null, effort: null, mcpAllow: [], extraArgs: [], maxBudgetUsd: null },
waitForCi: false,
prepareTimeoutMinutes: 30,
maxPrepared: 5,
live: true,
Expand Down Expand Up @@ -98,7 +107,7 @@ export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxCo
throw new Error(`${source} must be a JSON object`);
}
const obj = raw as Record<string, unknown>;
const config: InboxConfig = { ...DEFAULT_INBOX_CONFIG, agent: defaultAgent() };
const config: InboxConfig = { ...DEFAULT_INBOX_CONFIG, alertPaths: [], agent: defaultAgent() };

if (obj.prepare !== undefined) {
throw new Error(`${source}: "prepare" was replaced by the "agent" block — delete it (the built-in command applies) and put extra flags in agent.extraArgs`);
Expand Down Expand Up @@ -127,9 +136,21 @@ export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxCo
}
config.alertWhen = obj.alertWhen;
}
if (obj.alertPaths !== undefined) {
if (!Array.isArray(obj.alertPaths) || !obj.alertPaths.every(glob => typeof glob === 'string' && glob.trim() !== '')) {
throw new Error(`${source}: alertPaths must be an array of non-empty globs`);
}
config.alertPaths = (obj.alertPaths as string[]).map(glob => glob.trim());
}
if (obj.agent !== undefined) {
config.agent = parseAgentConfig(obj.agent, source);
}
if (obj.waitForCi !== undefined) {
if (typeof obj.waitForCi !== 'boolean') {
throw new Error(`${source}: waitForCi must be true or false`);
}
config.waitForCi = obj.waitForCi;
}
if (obj.prepareTimeoutMinutes !== undefined) {
config.prepareTimeoutMinutes = positive(obj.prepareTimeoutMinutes, 'prepareTimeoutMinutes', source);
}
Expand Down Expand Up @@ -193,7 +214,7 @@ function parseAgentConfig(raw: unknown, source: string): AgentConfig {
}

/** The settings the inbox page edits, kept in the config file beside the keys only the file holds. */
export type InboxSettings = Pick<InboxConfig, 'filter' | 'alertWhen' | 'maxPrepared' | 'pollMinutes' | 'live' | 'liveTimeoutMinutes' | 'prepareTimeoutMinutes' | 'agent'>;
export type InboxSettings = Pick<InboxConfig, 'filter' | 'alertWhen' | 'alertPaths' | 'maxPrepared' | 'pollMinutes' | 'live' | 'liveTimeoutMinutes' | 'prepareTimeoutMinutes' | 'waitForCi' | 'agent'>;

/**
* Writes the page-editable settings into the config file, leaving every other key as the reviewer
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/inbox/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ export interface SettingsHost {
export function settingsHost(config: InboxConfig, configPath: string | undefined, onPollChanged: () => void = () => {}): SettingsHost {
return {
get: () => ({
filter: config.filter, alertWhen: config.alertWhen, maxPrepared: config.maxPrepared, pollMinutes: config.pollMinutes,
filter: config.filter, alertWhen: config.alertWhen, alertPaths: config.alertPaths,
maxPrepared: config.maxPrepared, pollMinutes: config.pollMinutes,
live: config.live, liveTimeoutMinutes: config.liveTimeoutMinutes, prepareTimeoutMinutes: config.prepareTimeoutMinutes,
agent: config.agent,
waitForCi: config.waitForCi, agent: config.agent,
}),
update: settings => {
// The config object is the one the tick, the prepares and the opens read from, so the change
Expand Down Expand Up @@ -131,6 +132,8 @@ 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 waitForCi() { return config.waitForCi; },
get alertPaths() { return config.alertPaths; },
get agentModel() { return config.agent.model; },
pauseUntil: (until: string) => {
store.pauseUntil(until);
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/inbox/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export function inboxPage(): string {
.open-hint { color: var(--accent); font-size: 12px; font-weight: 600; white-space: nowrap; }
.empty { color: var(--muted); padding: 12px 2px; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ready); flex: none; }
.ci { flex: none; font-size: 9px; line-height: 1; }
.ci-passing { color: var(--ready); }
.ci-failing { color: var(--bad); }
.ci-running { color: var(--stale); }
.foot { color: var(--muted); font-size: 11.5px; margin-top: 22px; }
a { color: inherit; text-decoration: none; }
</style>
Expand Down Expand Up @@ -119,6 +123,9 @@ export function inboxPage(): string {
<label>Notify me if:
<textarea id="alertWhen" rows="3" placeholder="e.g. there is a P1, or the change touches authentication (empty: every prepared review)"></textarea>
</label>
<label>Alert me if a changed file matches (one glob per line):
<textarea id="alertPaths" rows="3" placeholder="e.g. packages/shared/src/model/** or **/dbref/**"></textarea>
</label>
<label>MCP tools the review agent may use, one per line:
<textarea id="agentMcpAllow" rows="3" placeholder="e.g. mcp__claude_ai_Atlassian__getJiraIssue (empty: no MCP servers at all)"></textarea>
</label>
Expand All @@ -138,6 +145,7 @@ export function inboxPage(): string {
</select></label>
<label>Budget per run ($)<input id="agentMaxBudgetUsd" type="number" min="0.5" step="0.5" placeholder="uncapped"></label>
<label class="check"><input id="live" type="checkbox"> Park a live agent on opened reviews</label>
<label class="check"><input id="waitForCi" type="checkbox"> Hold a pull request until its CI has passed</label>
</div>
<div class="settings-row">
<button id="save" type="button">Save</button>
Expand All @@ -161,6 +169,14 @@ export function inboxPage(): string {
return Math.round(hours / 24) + ' d ago';
}

/** What CI made of the head, as one glyph; nothing when nothing has reported. */
function ciDot(r) {
const labels = { passing: 'CI passing', failing: 'CI failing', running: 'CI running' };
return labels[r.ciState]
? '<span class="ci ci-' + r.ciState + '" title="' + labels[r.ciState] + '">&#9679;</span>'
: '';
}

function metaLine(parts, hover) {
const text = parts.filter(Boolean).join(' \\u00b7 ');
// The line is cut to the card; the full text, wrapped, is a hover away.
Expand Down Expand Up @@ -203,6 +219,7 @@ export function inboxPage(): string {
row.innerHTML =
'<span class="dot"></span>' +
'<span class="size">' + sizeLabel(r) + '</span>' +
ciDot(r) +
'<span class="title"><div><span class="repo">' + esc(r.repo) + '#' + r.number + '</span> ' +
'<span class="name">' + esc(r.title) + '</span></div>' +
metaLine(['by ' + esc(r.author), r.changedFiles + ' file(s)', esc(r.summary || ''), spendLabel(r), times(r)],
Expand All @@ -218,6 +235,7 @@ export function inboxPage(): string {
row.className = 'row';
row.innerHTML =
'<span class="size">' + sizeLabel(r) + '</span>' +
ciDot(r) +
'<span class="title"><div><span class="repo">' + esc(r.repo) + '#' + r.number + '</span> ' +
'<span class="name">' + esc(r.title) + '</span></div>' +
metaLine([esc(r.statusReason || ''), times(r)], r.statusReason || '') + '</span>' +
Expand Down Expand Up @@ -338,8 +356,10 @@ export function inboxPage(): string {
settings = await res.json();
el('filter').value = settings.filter;
el('alertWhen').value = settings.alertWhen;
el('alertPaths').value = (settings.alertPaths || []).join('\\n');
for (const key of ['maxPrepared', 'pollMinutes', 'prepareTimeoutMinutes', 'liveTimeoutMinutes']) el(key).value = settings[key];
el('live').checked = settings.live;
el('waitForCi').checked = settings.waitForCi;
const agent = settings.agent || {};
el('agentModel').value = agent.model || '';
el('agentEffort').value = agent.effort || '';
Expand All @@ -355,11 +375,13 @@ export function inboxPage(): string {
const next = {
filter: el('filter').value,
alertWhen: el('alertWhen').value,
alertPaths: el('alertPaths').value.split('\\n').map(line => line.trim()).filter(Boolean),
maxPrepared: Number(el('maxPrepared').value),
pollMinutes: Number(el('pollMinutes').value),
prepareTimeoutMinutes: Number(el('prepareTimeoutMinutes').value),
liveTimeoutMinutes: Number(el('liveTimeoutMinutes').value),
live: el('live').checked,
waitForCi: el('waitForCi').checked,
agent: {
model: el('agentModel').value.trim() || null,
effort: el('agentEffort').value || null,
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/inbox/paths-alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* The reviewer's own paths: a pull request touching one of them needs them now, whatever the agent
* made of the review. Where `alertWhen` asks an agent to judge, this is a fact about the diff and
* cannot be misjudged.
*/
export function alertForPaths(files: { path: string }[], globs: string[]): string | null {
if (globs.length === 0 || files.length === 0) {
return null;
}
const patterns = globs.map(globToRegExp);
const matched = files.filter(file => patterns.some(pattern => pattern.test(file.path)));
if (matched.length === 0) {
return null;
}
return `touches ${matched[0].path}${matched.length > 1 ? ` and ${matched.length - 1} more` : ''}`;
}

/**
* The glob dialect a reviewer would expect of a path list: `**` spans directories, `*` and `?` stay
* within one segment, everything else is literal. `node:path`'s own `matchesGlob` would do this,
* but it still warns as experimental on the Node versions this runs on.
*/
function globToRegExp(glob: string): RegExp {
let source = '';
for (let i = 0; i < glob.length; i++) {
const char = glob[i];
if (char === '*' && glob[i + 1] === '*') {
i++;
if (glob[i + 1] === '/') {
// Any number of directories, the pattern's own next segment included at any depth.
i++;
source += '(?:[^/]*/)*';
} else if (source.endsWith('/')) {
// A trailing `dir/**`: the directory itself and everything under it.
source = `${source.slice(0, -1)}(?:/.*)?`;
} else {
source += '.*';
}
continue;
}
if (char === '*') {
source += '[^/]*';
continue;
}
if (char === '?') {
source += '[^/]';
continue;
}
source += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
}
return new RegExp(`^${source}$`);
}
Loading
Loading