diff --git a/packages/api/src/info.ts b/packages/api/src/info.ts
index 375666fc..baeb27da 100644
--- a/packages/api/src/info.ts
+++ b/packages/api/src/info.ts
@@ -35,4 +35,8 @@ export interface LiveStatusResponse {
lastPollAt: string | null;
/** Requests an agent has taken and not answered yet; it counts as listening while it works on them. */
working: number;
+ /** When diffity last fired the user's routine for this session, while that call is still expected to arrive. */
+ calledAt: string | null;
+ /** Diffity has a routine to call for this user when no agent is listening. */
+ routine: boolean;
}
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 854c7f32..775ba01f 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -11,13 +11,14 @@ import type { Config } from './config.js';
import { WEB_SESSION_MAX_AGE_SECONDS, type Users, type User, type WebSessions } from './users.js';
import { hashPresentedClientSecret, type OAuthProvider } from './oauth.js';
import type { AutomationTokens } from './automation.js';
-import { ServiceError, describeSession, type ReviewRequests, type ReviewService } from './service.js';
+import { ServiceError, describeSession, type ReviewRequestRow, type ReviewService } from './service.js';
import type { DevLoginProvider, IapLogin } from './login.js';
import { GitHubConnectError, type GitHubAppAccess } from './github-app.js';
import { createMcpServer } from './mcp.js';
import { MAX_WAIT_SECONDS, type Live } from './live.js';
+import type { Routine } from './routine.js';
import { uiApiRouter } from './ui-api.js';
-import { escapeHtml, page } from './html.js';
+import { SECTIONS_SCRIPT, escapeHtml, page } from './html.js';
import { randomToken } from './crypto.js';
import {
SESSION_COOKIE,
@@ -37,6 +38,7 @@ export interface AppDeps {
automationTokens: AutomationTokens;
service: ReviewService;
live: Live;
+ routine: Routine;
devLogin: DevLoginProvider | null;
/** When set, identity comes from IAP's header on every request, and there is no login form. */
iap: IapLogin | null;
@@ -91,7 +93,7 @@ function formTargetFor(redirectUri: string): string {
}
export function createApp(deps: AppDeps): express.Express {
- const { config, users, webSessions, oauth, service, live } = deps;
+ const { config, users, webSessions, oauth, service, live, routine } = deps;
const publicUrl = config.publicUrl;
const mcpUrl = new URL('/mcp', publicUrl);
const secureCookies = publicUrl.protocol === 'https:';
@@ -118,9 +120,9 @@ export function createApp(deps: AppDeps): express.Express {
return userId ? users.get(userId) : null;
};
- const sendPage = (res: Response, status: number, title: string, body: string, user: User | null, formTargets: string[] = []) => {
+ const sendPage = (res: Response, status: number, title: string, body: string, user: User | null, formTargets: string[] = [], selfScripts = false) => {
res.status(status)
- .set('Content-Security-Policy', pageContentSecurityPolicy(formTargets))
+ .set('Content-Security-Policy', pageContentSecurityPolicy(formTargets, selfScripts))
.set('Cache-Control', 'no-store')
.type('html')
.send(page({ title, body, user }));
@@ -145,6 +147,10 @@ export function createApp(deps: AppDeps): express.Express {
res.json({ ok: true, version: deps.version });
});
+ app.get('/sections.js', (_req, res) => {
+ res.type('text/javascript').set('Cache-Control', 'no-cache').send(SECTIONS_SCRIPT);
+ });
+
if (deps.uiDir) {
// Build artifacts, the same for everyone; nothing about a review is in them.
app.use('/assets', express.static(join(deps.uiDir, 'assets'), { index: false, immutable: true, maxAge: '365d' }));
@@ -209,17 +215,27 @@ export function createApp(deps: AppDeps): express.Express {
verifier: {
verifyAccessToken: async token => {
const automation = await deps.automationTokens.verify(token);
- if (!automation) {
- return oauth.verifyAccessToken(token);
+ if (automation) {
+ return {
+ token,
+ clientId: `automation:${automation.name}`,
+ scopes: [],
+ // The SDK requires an expiry; an automation token lasts until it is revoked.
+ expiresAt: Number.MAX_SAFE_INTEGER,
+ extra: { userId: automation.userId, automation: true, automationName: automation.name },
+ };
+ }
+ const scoped = await live.verifySessionToken(token);
+ if (scoped) {
+ return {
+ token,
+ clientId: 'session',
+ scopes: [],
+ expiresAt: scoped.expiresAt,
+ extra: { userId: scoped.userId, sessionScope: scoped.sessionId },
+ };
}
- return {
- token,
- clientId: `automation:${automation.name}`,
- scopes: [],
- // The SDK requires an expiry; an automation token lasts until it is revoked.
- expiresAt: Number.MAX_SAFE_INTEGER,
- extra: { userId: automation.userId, automation: true, automationName: automation.name },
- };
+ return oauth.verifyAccessToken(token);
},
},
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
@@ -232,14 +248,16 @@ export function createApp(deps: AppDeps): express.Express {
return;
}
const automationName = req.auth!.extra!.automation === true ? String(req.auth!.extra!.automationName) : null;
+ const sessionScope = typeof req.auth!.extra!.sessionScope === 'string' ? req.auth!.extra!.sessionScope : undefined;
const server = createMcpServer({
service,
live,
automationTokens: deps.automationTokens,
userId,
automation: automationName !== null,
- agentName: automationName ?? (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude',
+ agentName: sessionScope ? 'Attendant' : automationName ?? (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude',
version: deps.version,
+ sessionScope,
});
// Stateless: every request is complete in itself, so nothing is held between them.
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
@@ -435,6 +453,7 @@ export function createApp(deps: AppDeps): express.Express {
return;
}
const settings = await users.settings(user.id);
+ const routineSettings = await users.routineSettings(user.id);
sendPage(res, 200, 'Settings', `
Settings
GitHub access
@@ -448,6 +467,18 @@ export function createApp(deps: AppDeps): express.Express {
List drafts too
Save
+ Call an agent
+ When you ask for a review from this page, or ask a question while no agent is listening, diffity fires this Claude
+ routine with the session and a token that works on that session only.
+
+ ${routineSettings.url || routineSettings.keySet
+ ? ''
+ : ''}
Connect an agent
Add this server to Claude Code as an MCP connector; it signs you in through this page the first time.
Its review prompt holds the review method.
@@ -483,6 +514,27 @@ export function createApp(deps: AppDeps): express.Express {
res.redirect(303, '/settings');
});
+ app.post('/settings/routine', sameOrigin, forms, async (req, res) => {
+ const user = await userFor(req);
+ if (!user) {
+ notSignedIn(res, '/settings');
+ return;
+ }
+ if (req.body?.clear === '1') {
+ await users.setRoutine(user.id, null, null);
+ res.redirect(303, '/settings');
+ return;
+ }
+ const url = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
+ if (!url.startsWith('https://')) {
+ sendPage(res, 400, 'Settings', 'The fire URL must start with https://.
Settings
', user);
+ return;
+ }
+ const key = typeof req.body?.key === 'string' ? req.body.key.trim() : '';
+ await users.setRoutine(user.id, url, key || undefined);
+ res.redirect(303, '/settings');
+ });
+
app.get('/github/connect', async (req, res) => {
const user = await userFor(req);
if (!user) {
@@ -528,47 +580,54 @@ export function createApp(deps: AppDeps): express.Express {
res.redirect(303, '/settings');
});
- const reviewRequestsSection = async (user: User, listening: boolean): Promise => {
- let found: ReviewRequests | null;
+ const section = (heading: string, content: string): string =>
+ `${heading} ${content} `;
+
+ const pullsSection = async (
+ heading: string,
+ connectFor: string,
+ empty: string,
+ list: () => Promise<{ rows: ReviewRequestRow[]; hidden: number } | null>,
+ reachable: boolean,
+ ): Promise => {
+ let found: { rows: ReviewRequestRow[]; hidden: number } | null;
try {
- found = await service.reviewRequests(user.id);
+ found = await list();
} catch (err) {
- return `Awaiting your review
- GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}
`;
+ return section(heading, `GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}
`);
}
if (!found) {
- return `Awaiting your review
- Connect GitHub on Settings to see pull requests waiting for your review.
`;
+ return section(heading, `Connect GitHub on Settings to see ${connectFor}.
`);
}
const hidden = found.hidden > 0 ? ` (${found.hidden} hidden by your filters ) ` : '';
- if (found.requests.length === 0) {
- return `Awaiting your review Nothing is waiting for your review.${hidden}
`;
+ if (found.rows.length === 0) {
+ return section(heading, `${empty}${hidden}
`);
}
- const rows = found.requests.map(request => {
+ const rows = found.rows.map(request => {
const slug = `${request.owner}/${request.repo}`;
const action = request.session
- ? `open · ${escapeHtml(request.session.headSha.slice(0, 7))} ${listening
+ ? `open · ${escapeHtml(request.session.headSha.slice(0, 7))} ${reachable
? ` `
: ''}`
- : ``;
return `
${escapeHtml(`${slug}#${request.number}`)}
- ${escapeHtml(request.title)}
+ ${escapeHtml(request.title)}${request.draft ? ' draft ' : ''}
${escapeHtml(request.author)}
${escapeHtml(request.updatedAt.slice(0, 16).replace('T', ' '))}
${action}
`;
}).join('');
- return `Awaiting your review
+ return section(heading, `
Pull request Title Author Updated ${rows}
- ${hidden ? `${hidden}
` : ''}`;
+ ${hidden ? `${hidden}
` : ''}`);
};
app.get('/', async (req, res) => {
@@ -579,10 +638,21 @@ export function createApp(deps: AppDeps): express.Express {
}
const status = await live.userStatus(user.id);
const listening = status.listening;
- const [requests, sessions] = await Promise.all([reviewRequestsSection(user, listening), reviews.listSessions(user.id)]);
+ const reachable = listening || (await routine.configured(user.id));
+ const [requests, own, sessions] = await Promise.all([
+ pullsSection('Awaiting your review', 'pull requests waiting for your review', 'Nothing is waiting for your review.', async () => {
+ const found = await service.reviewRequests(user.id);
+ return found && { rows: found.requests, hidden: found.hidden };
+ }, reachable),
+ pullsSection('Your open pull requests', 'your open pull requests', 'You have no open pull requests.', async () => {
+ const rows = await service.ownPulls(user.id);
+ return rows && { rows, hidden: 0 };
+ }, reachable),
+ reviews.listSessions(user.id),
+ ]);
const rows = sessions.map(session => `
- ${escapeHtml(`${session.owner}/${session.repo}`)}
+ ${escapeHtml(`${session.owner}/${session.repo}`)}
${escapeHtml(describeSession(session))}
${escapeHtml(session.createdAt.slice(0, 16).replace('T', ' '))}
`).join('');
@@ -591,10 +661,10 @@ export function createApp(deps: AppDeps): express.Express {
: listening
? 'Agent listening · Create and review hands a pull request to it
'
: '';
- sendPage(res, 200, 'Sessions', agent + requests + (sessions.length > 0
- ? `Your review sessions Repository Change Created ${rows}
`
- : `No review sessions yet Ask your agent to create_session through the diffity MCP connector.
- See settings for how to add it.
`), user);
+ sendPage(res, 200, 'Sessions', agent + requests + own + (sessions.length > 0
+ ? section('Your review sessions', `Repository Change Created ${rows}
`)
+ : section('No review sessions yet', `Ask your agent to create_session through the diffity MCP connector.
+ See settings for how to add it.
`)) + '', user, [], true);
});
app.post('/sessions', sameOrigin, forms, async (req, res) => {
@@ -608,6 +678,7 @@ export function createApp(deps: AppDeps): express.Express {
const { session } = await service.createSession(user.id, { repo, pr: Number(req.body?.pr) });
if (req.body?.review === '1') {
await live.queueReview(user.id, session.id);
+ await routine.fire(user.id, session, 'review');
}
res.redirect(303, `/s/${session.id}/`);
} catch (err) {
@@ -631,6 +702,7 @@ export function createApp(deps: AppDeps): express.Express {
return;
}
await live.queueReview(user.id, session.id);
+ await routine.fire(user.id, session, 'review');
res.redirect(303, '/');
});
@@ -641,14 +713,14 @@ export function createApp(deps: AppDeps): express.Express {
res.status(401).json({ error: 'Sign in first' });
return;
}
- res.json((await live.userStatus(user.id)) satisfies LiveStatusResponse);
+ res.json({ ...(await live.userStatus(user.id)), routine: await routine.configured(user.id) } satisfies LiveStatusResponse);
});
app.use('/s/:sid/api', requireSameOrigin(publicUrl), (_req, res, next) => {
res.set('Content-Security-Policy', UI_CONTENT_SECURITY_POLICY);
res.set('Cache-Control', 'no-store');
next();
- }, uiApiRouter({ service, live, userFor }));
+ }, uiApiRouter({ service, live, routine, userFor }));
app.get('/s/:sid{/*rest}', async (req, res) => {
const user = await userFor(req);
diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts
index 80d2a262..4808d678 100644
--- a/packages/server/src/db.ts
+++ b/packages/server/src/db.ts
@@ -232,6 +232,17 @@ export const MIGRATIONS: Migration[] = [
);
`,
},
+ {
+ version: 6,
+ sql: `
+ ALTER TABLE live_tokens ADD COLUMN scope TEXT NOT NULL DEFAULT 'wait';
+ ALTER TABLE live_tokens ADD CONSTRAINT live_tokens_scope_check
+ CHECK (scope = 'wait' OR (scope = 'session' AND session_id IS NOT NULL));
+ ALTER TABLE users ADD COLUMN routine_url TEXT;
+ ALTER TABLE users ADD COLUMN routine_key TEXT;
+ ALTER TABLE sessions ADD COLUMN routine_fired_at BIGINT;
+ `,
+ },
];
/** Any constant; two instances starting together take turns migrating. */
diff --git a/packages/server/src/github.ts b/packages/server/src/github.ts
index cb9e5660..55c4564e 100644
--- a/packages/server/src/github.ts
+++ b/packages/server/src/github.ts
@@ -97,6 +97,7 @@ const PAGE_SIZE = 100;
const MAX_PAGES = 30;
const REVIEW_REQUESTS_QUERY = 'is:pr is:open archived:false review-requested:@me';
+const OWN_PULLS_QUERY = 'is:pr is:open archived:false author:@me';
const REVIEW_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
repository(owner:$owner,name:$repo){
@@ -290,15 +291,24 @@ export class GitHubApi {
}
/** Open pull requests waiting for the token's own review, most recently updated first. */
- async reviewRequests(token: string): Promise {
- const query = new URLSearchParams({ q: REVIEW_REQUESTS_QUERY, sort: 'updated', order: 'desc', per_page: '50' });
+ reviewRequests(token: string): Promise {
+ return this.searchPulls(token, REVIEW_REQUESTS_QUERY);
+ }
+
+ /** The token's own open pull requests, most recently updated first. */
+ ownPulls(token: string): Promise {
+ return this.searchPulls(token, OWN_PULLS_QUERY);
+ }
+
+ private async searchPulls(token: string, q: string): Promise {
+ const query = new URLSearchParams({ q, sort: 'updated', order: 'desc', per_page: '50' });
let result: { items?: RawSearchIssue[] };
try {
result = await this.json(token, `/search/issues?${query.toString()}`);
} catch (err) {
// A search GitHub refuses (rate limit, an App without access) leaves the list empty, not the page broken.
if (err instanceof GitHubApiError && (err.status === 422 || err.status === 403)) {
- console.warn(`Listing review requests failed: ${err.message}`);
+ console.warn(`Searching pull requests failed: ${err.message}`);
return [];
}
throw err;
diff --git a/packages/server/src/html.ts b/packages/server/src/html.ts
index 702ed9fa..44df093a 100644
--- a/packages/server/src/html.ts
+++ b/packages/server/src/html.ts
@@ -23,10 +23,24 @@ const STYLE = `
textarea { font: inherit; padding: 6px 8px; max-width: 100%; }
button { font: inherit; padding: 6px 12px; cursor: pointer; }
form.inline { display: inline; }
+ summary { cursor: pointer; }
+ summary > h2 { display: inline-block; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
pre { padding: 8px; border: 1px solid var(--line); overflow-x: auto; }
`;
+/** Keeps each collapsible section of a page open or closed as the user last left it. */
+export const SECTIONS_SCRIPT = `for (const details of document.querySelectorAll('details')) {
+ const key = 'diffity.section:' + details.querySelector('summary').textContent.trim();
+ try {
+ if (localStorage.getItem(key) === 'closed') details.open = false;
+ } catch {}
+ details.addEventListener('toggle', () => {
+ try { localStorage.setItem(key, details.open ? 'open' : 'closed'); } catch {}
+ });
+}
+`;
+
export function page(input: { title: string; body: string; user?: { email: string } | null }): string {
const nav = input.user
? `Sessions Settings
diff --git a/packages/server/src/live.ts b/packages/server/src/live.ts
index f23a7e0c..3bc6fae0 100644
--- a/packages/server/src/live.ts
+++ b/packages/server/src/live.ts
@@ -1,8 +1,10 @@
import { randomUUID } from 'node:crypto';
import type { Db } from './db.js';
import { randomToken, sha256 } from './crypto.js';
+import { ROUTINE_COOLDOWN_MS } from './routine.js';
export const LIVE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
+export const SESSION_TOKEN_TTL_MS = 48 * 60 * 60 * 1000;
/** A request whose agent went quiet for this long is handed out again. */
export const CLAIM_TTL_MS = 10 * 60 * 1000;
/** Under the load balancer's 30 s backend timeout. */
@@ -79,15 +81,17 @@ export interface LiveStatus {
listening: boolean;
lastPollAt: string | null;
working: number;
+ calledAt: string | null;
}
/** An agent that holds a request has stopped polling to work on it, and still counts as listening. */
-function liveStatus(polls: (number | null | undefined)[], working: number, now: number): LiveStatus {
+function liveStatus(polls: (number | null | undefined)[], working: number, now: number, calledAt: string | null = null): LiveStatus {
const latest = Math.max(...polls.map(poll => poll ?? -Infinity));
return {
listening: now - latest < LISTENING_WINDOW_MS || working > 0,
lastPollAt: Number.isFinite(latest) ? new Date(latest).toISOString() : null,
working,
+ calledAt,
};
}
@@ -108,17 +112,34 @@ export class Live {
* user's. Only its hash is kept.
*/
async issueToken(userId: string, sessionId: string | null, now = Date.now()): Promise<{ token: string; expiresAt: number }> {
+ return this.insertToken(userId, sessionId, 'wait', now + LIVE_TOKEN_TTL_MS);
+ }
+
+ /** A token for a routine diffity called: the review tools on this one session, and waiting on it. */
+ async issueSessionToken(userId: string, sessionId: string, now = Date.now()): Promise<{ token: string; expiresAt: number }> {
+ return this.insertToken(userId, sessionId, 'session', now + SESSION_TOKEN_TTL_MS);
+ }
+
+ private async insertToken(userId: string, sessionId: string | null, scope: 'wait' | 'session', expiresAt: number) {
const token = randomToken();
- const expiresAt = now + LIVE_TOKEN_TTL_MS;
- await this.db.query('INSERT INTO live_tokens (token_hash, user_id, session_id, expires_at) VALUES ($1, $2, $3, $4)', [
+ await this.db.query('INSERT INTO live_tokens (token_hash, user_id, session_id, scope, expires_at) VALUES ($1, $2, $3, $4, $5)', [
sha256(token),
userId,
sessionId,
+ scope,
expiresAt,
]);
return { token, expiresAt };
}
+ async verifySessionToken(token: string, now = Date.now()): Promise<{ userId: string; sessionId: string; expiresAt: number } | null> {
+ const row = await this.db.one<{ user_id: string; session_id: string; expires_at: number }>(
+ "SELECT user_id, session_id, expires_at FROM live_tokens WHERE token_hash = $1 AND scope = 'session'",
+ [sha256(token)],
+ );
+ return row && row.expires_at > now ? { userId: row.user_id, sessionId: row.session_id, expiresAt: row.expires_at } : null;
+ }
+
/**
* Who may wait where with this token. Without a session only a user token qualifies; with one,
* that session's token, or a user token when the session is the user's.
@@ -268,12 +289,14 @@ export class Live {
/** An agent listens on a session when it waits on that session or on everything of the user's. */
async status(userId: string, sessionId: string, now = Date.now()): Promise {
- const row = await this.db.one<{ session_poll: number | null; user_poll: number | null }>(
- `SELECT s.live_polled_at AS session_poll, u.live_polled_at AS user_poll
+ const row = await this.db.one<{ session_poll: number | null; user_poll: number | null; fired: number | null }>(
+ `SELECT s.live_polled_at AS session_poll, u.live_polled_at AS user_poll, s.routine_fired_at AS fired
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = $1 AND s.user_id = $2`,
[sessionId, userId],
);
- return liveStatus([row?.session_poll, row?.user_poll], row ? await this.working(userId, sessionId, now) : 0, now);
+ const fired = row?.fired ?? null;
+ const calledAt = fired !== null && now - fired < ROUTINE_COOLDOWN_MS ? new Date(fired).toISOString() : null;
+ return liveStatus([row?.session_poll, row?.user_poll], row ? await this.working(userId, sessionId, now) : 0, now, calledAt);
}
/** Whether an agent waits on the user's whole queue, which is what a new session's review needs. */
diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts
index ceab062b..294312b3 100644
--- a/packages/server/src/mcp.ts
+++ b/packages/server/src/mcp.ts
@@ -19,6 +19,8 @@ export interface McpContext {
/** Who the comments are from, as the connecting client named itself when it registered. */
agentName: string;
version: string;
+ /** The one session a session token works on. */
+ sessionScope?: string;
}
function json(value: unknown): CallToolResult {
@@ -94,9 +96,15 @@ export function createMcpServer(ctx: McpContext): McpServer {
const agent: CommentAuthor = { name: ctx.agentName, type: 'agent' };
const server = new McpServer({ name: 'diffity', version: ctx.version });
- const session = (id: string): Promise => service.requireSession(userId, id);
+ const session = async (id: string): Promise => {
+ const record = await service.requireSession(userId, id);
+ if (ctx.sessionScope !== undefined && record.id !== ctx.sessionScope) {
+ throw new ServiceError(`This token is for session ${ctx.sessionScope.slice(0, 8)} only`);
+ }
+ return record;
+ };
- const thread = async (id: string, sessionId?: string): Promise => {
+ const thread = async (id: string, sessionId = ctx.sessionScope): Promise => {
const found = await reviews.getThread(userId, id);
if (!found) {
throw new ServiceError(`No thread matches ${id}`);
@@ -112,6 +120,9 @@ export function createMcpServer(ctx: McpContext): McpServer {
if (!found) {
throw new ServiceError(`No walkthrough matches ${id}`);
}
+ if (ctx.sessionScope !== undefined) {
+ await session(found.sessionId);
+ }
return found;
};
@@ -128,112 +139,170 @@ export function createMcpServer(ctx: McpContext): McpServer {
createdAt: record.createdAt,
});
- server.registerTool(
- 'create_session',
- {
- description:
- 'Open a review session on pushed code. Give the repository and exactly one of: `pr` (a pull request number); '
- + '`base` and `head` (full commit shas); or `base` and `patch` (a unified diff applied to base, for work that is not pushed). '
- + 'Returns the session id, the URL the user opens to read the review, and the changed files. '
- + 'With `review: true` the session is queued for the agent waiting on the user\'s queue (live_token {}) to review. '
- + 'The `review` prompt of this server holds the method for reviewing through these tools.',
- inputSchema: {
- repo: z.string().describe('"owner/name" on GitHub'),
- pr: z.number().int().positive().optional(),
- base: z.string().optional().describe('Full 40-character commit sha'),
- head: z.string().optional().describe('Full 40-character commit sha'),
- patch: z.string().optional().describe('Unified diff (git diff output) against base'),
- review: z.boolean().optional().describe('Queue the session for the agent waiting on your queue to review'),
+ // A session token's run works on its one session and reads the code from its own checkout.
+ if (!ctx.sessionScope) {
+ server.registerTool(
+ 'create_session',
+ {
+ description:
+ 'Open a review session on pushed code. Give the repository and exactly one of: `pr` (a pull request number); '
+ + '`base` and `head` (full commit shas); or `base` and `patch` (a unified diff applied to base, for work that is not pushed). '
+ + 'Returns the session id, the URL the user opens to read the review, and the changed files. '
+ + 'With `review: true` the session is queued for the agent waiting on the user\'s queue (live_token {}) to review. '
+ + 'The `review` prompt of this server holds the method for reviewing through these tools.',
+ inputSchema: {
+ repo: z.string().describe('"owner/name" on GitHub'),
+ pr: z.number().int().positive().optional(),
+ base: z.string().optional().describe('Full 40-character commit sha'),
+ head: z.string().optional().describe('Full 40-character commit sha'),
+ patch: z.string().optional().describe('Unified diff (git diff output) against base'),
+ review: z.boolean().optional().describe('Queue the session for the agent waiting on your queue to review'),
+ },
},
- },
- guarded(async ({ review, ...input }) => {
- const created = await service.createSession(userId, input);
- const queued = review ? await ctx.live.queueReview(userId, created.session.id) : false;
- return json({
- ...describe(created.session),
- ...(review ? { review: queued ? 'queued' : 'already queued' } : {}),
- created: created.created,
- carriedThreads: created.carried,
- files: created.files.map(file => ({
- status: file.status,
- path: file.newPath,
- ...(file.oldPath !== file.newPath ? { oldPath: file.oldPath } : {}),
- })),
- });
- }),
- );
-
- server.registerTool(
- 'list_sessions',
- {
- description: 'List your review sessions, newest first.',
- inputSchema: { repo: z.string().optional().describe('Only sessions of this "owner/name"') },
- annotations: { readOnlyHint: true },
- },
- guarded(async ({ repo }) => {
- const [owner, name] = repo?.split('/') ?? [];
- return json((await reviews.listSessions(userId, { owner, repo: name })).map(describe));
- }),
- );
+ guarded(async ({ review, ...input }) => {
+ const created = await service.createSession(userId, input);
+ const queued = review ? await ctx.live.queueReview(userId, created.session.id) : false;
+ return json({
+ ...describe(created.session),
+ ...(review ? { review: queued ? 'queued' : 'already queued' } : {}),
+ created: created.created,
+ carriedThreads: created.carried,
+ files: created.files.map(file => ({
+ status: file.status,
+ path: file.newPath,
+ ...(file.oldPath !== file.newPath ? { oldPath: file.oldPath } : {}),
+ })),
+ });
+ }),
+ );
+
+ server.registerTool(
+ 'list_sessions',
+ {
+ description: 'List your review sessions, newest first.',
+ inputSchema: { repo: z.string().optional().describe('Only sessions of this "owner/name"') },
+ annotations: { readOnlyHint: true },
+ },
+ guarded(async ({ repo }) => {
+ const [owner, name] = repo?.split('/') ?? [];
+ return json((await reviews.listSessions(userId, { owner, repo: name })).map(describe));
+ }),
+ );
+
+ server.registerTool(
+ 'list_review_requests',
+ {
+ description:
+ 'List the open pull requests waiting for your review on GitHub, after your filters on the Settings page, '
+ + 'each with your newest session on it, if any. Review one with create_session { repo, pr }.',
+ inputSchema: {},
+ annotations: { readOnlyHint: true },
+ },
+ guarded(async () => {
+ const found = await service.reviewRequests(userId);
+ if (!found) {
+ throw new ServiceError(`Connect GitHub first, at ${service.settingsUrl()}`);
+ }
+ return json({
+ requests: found.requests.map(request => ({
+ repo: `${request.owner}/${request.repo}`,
+ pr: request.number,
+ title: request.title,
+ author: request.author,
+ draft: request.draft,
+ updatedAt: request.updatedAt,
+ url: request.url,
+ session: request.session ? { ...request.session, url: service.sessionUrl(request.session.id) } : null,
+ })),
+ hidden: found.hidden,
+ });
+ }),
+ );
+
+ server.registerTool(
+ 'get_diff',
+ {
+ description: "The session's unified diff; line numbers are in the @@ hunk headers.",
+ inputSchema: { session: sessionArg, file: z.string().optional().describe('Only this file') },
+ annotations: { readOnlyHint: true },
+ },
+ guarded(async args => {
+ const raw = await service.diffText(await session(args.session), { path: args.file });
+ return text(raw.trim() ? raw : 'No changes.');
+ }),
+ );
+
+ server.registerTool(
+ 'get_file',
+ {
+ description: 'A file as it is on one side of the diff: `new` is the head, `old` the base.',
+ inputSchema: { session: sessionArg, path: z.string().min(1), side: z.enum(['old', 'new']).default('new') },
+ annotations: { readOnlyHint: true },
+ },
+ guarded(async args => {
+ const content = await service.readFile(await session(args.session), args.side, args.path);
+ if (content === null) {
+ throw new ServiceError(`${args.path} does not exist on the ${args.side} side`);
+ }
+ return text(content);
+ }),
+ );
- server.registerTool(
- 'list_review_requests',
- {
- description:
- 'List the open pull requests waiting for your review on GitHub, after your filters on the Settings page, '
- + 'each with your newest session on it, if any. Review one with create_session { repo, pr }.',
- inputSchema: {},
- annotations: { readOnlyHint: true },
- },
- guarded(async () => {
- const found = await service.reviewRequests(userId);
- if (!found) {
- throw new ServiceError(`Connect GitHub first, at ${service.settingsUrl()}`);
+ const requireSignedIn = () => {
+ if (ctx.automation) {
+ throw new ServiceError('Automation tokens are managed by a signed-in agent, not by an automation token');
}
- return json({
- requests: found.requests.map(request => ({
- repo: `${request.owner}/${request.repo}`,
- pr: request.number,
- title: request.title,
- author: request.author,
- draft: request.draft,
- updatedAt: request.updatedAt,
- url: request.url,
- session: request.session ? { ...request.session, url: service.sessionUrl(request.session.id) } : null,
- })),
- hidden: found.hidden,
- });
- }),
- );
+ };
- server.registerTool(
- 'get_diff',
- {
- description: "The session's unified diff; line numbers are in the @@ hunk headers.",
- inputSchema: { session: sessionArg, file: z.string().optional().describe('Only this file') },
- annotations: { readOnlyHint: true },
- },
- guarded(async args => {
- const raw = await service.diffText(await session(args.session), { path: args.file });
- return text(raw.trim() ? raw : 'No changes.');
- }),
- );
+ const tokenName = z.string().trim().min(1).max(100).describe('What the token is for, e.g. "pr-review routine"');
- server.registerTool(
- 'get_file',
- {
- description: 'A file as it is on one side of the diff: `new` is the head, `old` the base.',
- inputSchema: { session: sessionArg, path: z.string().min(1), side: z.enum(['old', 'new']).default('new') },
- annotations: { readOnlyHint: true },
- },
- guarded(async args => {
- const content = await service.readFile(await session(args.session), args.side, args.path);
- if (content === null) {
- throw new ServiceError(`${args.path} does not exist on the ${args.side} side`);
- }
- return text(content);
- }),
- );
+ server.registerTool(
+ 'automation_token',
+ {
+ description:
+ 'Mint a bearer token for an agent that cannot sign in, such as a scheduled routine: it reaches this MCP endpoint '
+ + 'as you, with every tool, until revoked. Shown once; store it as a secret.',
+ inputSchema: { name: tokenName },
+ },
+ guarded(async args => {
+ requireSignedIn();
+ const token = await ctx.automationTokens.mint(userId, args.name);
+ return json({
+ name: args.name,
+ token,
+ use: `Send it as "Authorization: Bearer " to ${new URL('/mcp', service.publicUrl).href}. It is not shown again.`,
+ });
+ }),
+ );
+
+ server.registerTool(
+ 'automation_tokens',
+ {
+ description: 'List your automation tokens, without the tokens themselves.',
+ inputSchema: {},
+ annotations: { readOnlyHint: true },
+ },
+ guarded(async () => {
+ requireSignedIn();
+ return json(await ctx.automationTokens.list(userId));
+ }),
+ );
+
+ server.registerTool(
+ 'automation_token_revoke',
+ {
+ description: 'Revoke one of your automation tokens by name.',
+ inputSchema: { name: tokenName },
+ },
+ guarded(async args => {
+ requireSignedIn();
+ if (!(await ctx.automationTokens.revoke(userId, args.name))) {
+ throw new ServiceError(`You have no automation token named "${args.name}"`);
+ }
+ return text(`Revoked automation token "${args.name}"`);
+ }),
+ );
+ }
server.registerTool(
'get_standards',
@@ -371,7 +440,8 @@ export function createMcpServer(ctx: McpContext): McpServer {
inputSchema: { id: z.string().min(1), body, session: sessionArg.optional() },
},
guarded(async args => {
- const scoped = args.session === undefined ? undefined : (await session(args.session)).id;
+ const asked = args.session ?? ctx.sessionScope;
+ const scoped = asked === undefined ? undefined : (await session(asked)).id;
const comment = await reviews.findComment(userId, args.id);
let commentId: string;
let sessionId: string;
@@ -516,7 +586,8 @@ export function createMcpServer(ctx: McpContext): McpServer {
inputSchema: { session: sessionArg.optional() },
},
guarded(async args => {
- const record = args.session === undefined ? null : await session(args.session);
+ const scoped = ctx.sessionScope ?? args.session;
+ const record = scoped === undefined ? null : await session(scoped);
const { token, expiresAt } = await ctx.live.issueToken(userId, record?.id ?? null);
const query = record ? `session=${record.id}&wait=${MAX_WAIT_SECONDS}` : `wait=${MAX_WAIT_SECONDS}`;
const pollUrl = new URL(`/live/await?${query}`, service.publicUrl).href;
@@ -560,61 +631,6 @@ export function createMcpServer(ctx: McpContext): McpServer {
}),
);
- const requireSignedIn = () => {
- if (ctx.automation) {
- throw new ServiceError('Automation tokens are managed by a signed-in agent, not by an automation token');
- }
- };
-
- const tokenName = z.string().trim().min(1).max(100).describe('What the token is for, e.g. "pr-review routine"');
-
- server.registerTool(
- 'automation_token',
- {
- description:
- 'Mint a bearer token for an agent that cannot sign in, such as a scheduled routine: it reaches this MCP endpoint '
- + 'as you, with every tool, until revoked. Shown once; store it as a secret.',
- inputSchema: { name: tokenName },
- },
- guarded(async args => {
- requireSignedIn();
- const token = await ctx.automationTokens.mint(userId, args.name);
- return json({
- name: args.name,
- token,
- use: `Send it as "Authorization: Bearer " to ${new URL('/mcp', service.publicUrl).href}. It is not shown again.`,
- });
- }),
- );
-
- server.registerTool(
- 'automation_tokens',
- {
- description: 'List your automation tokens, without the tokens themselves.',
- inputSchema: {},
- annotations: { readOnlyHint: true },
- },
- guarded(async () => {
- requireSignedIn();
- return json(await ctx.automationTokens.list(userId));
- }),
- );
-
- server.registerTool(
- 'automation_token_revoke',
- {
- description: 'Revoke one of your automation tokens by name.',
- inputSchema: { name: tokenName },
- },
- guarded(async args => {
- requireSignedIn();
- if (!(await ctx.automationTokens.revoke(userId, args.name))) {
- throw new ServiceError(`You have no automation token named "${args.name}"`);
- }
- return text(`Revoked automation token "${args.name}"`);
- }),
- );
-
server.registerPrompt(
'review',
{
diff --git a/packages/server/src/routine.ts b/packages/server/src/routine.ts
new file mode 100644
index 00000000..d2d230b7
--- /dev/null
+++ b/packages/server/src/routine.ts
@@ -0,0 +1,85 @@
+import type { Db } from './db.js';
+import type { Live } from './live.js';
+import type { ReviewSessionRecord } from './reviews.js';
+import type { Users } from './users.js';
+
+export const ROUTINE_COOLDOWN_MS = 10 * 60 * 1000;
+const FIRE_TIMEOUT_MS = 10_000;
+
+export type RoutineFireKind = 'review' | 'ask';
+export type RoutineFireResult = 'fired' | 'cooldown' | 'unconfigured' | 'failed';
+
+/** Calls the user's Claude routine (an API trigger) to attend a session; the run itself happens elsewhere. */
+export class Routine {
+ constructor(
+ private readonly db: Db,
+ private readonly users: Users,
+ private readonly live: Live,
+ private readonly publicUrl: URL,
+ private readonly fetchImpl: typeof fetch = fetch,
+ ) {}
+
+ async configured(userId: string): Promise {
+ const settings = await this.users.routineSettings(userId);
+ return !!settings.url && settings.keySet;
+ }
+
+ /** Fires the user's routine for the session unless none is configured or one fired within the cooldown; never throws. */
+ async fire(userId: string, session: ReviewSessionRecord, kind: RoutineFireKind, now = Date.now()): Promise {
+ try {
+ const routine = await this.users.routine(userId);
+ if (!routine) {
+ return 'unconfigured';
+ }
+ const claimed = await this.db.one(
+ `UPDATE sessions SET routine_fired_at = $3 WHERE id = $1 AND user_id = $2
+ AND (routine_fired_at IS NULL OR routine_fired_at <= $4) RETURNING id`,
+ [session.id, userId, now, now - ROUTINE_COOLDOWN_MS],
+ );
+ if (!claimed) {
+ return 'cooldown';
+ }
+ const failure = await this.post(routine, userId, session, kind);
+ if (failure === null) {
+ return 'fired';
+ }
+ console.error(`routine fire failed for session ${session.id}: ${failure}`);
+ await this.db.query('UPDATE sessions SET routine_fired_at = NULL WHERE id = $1 AND routine_fired_at = $2', [session.id, now]);
+ return 'failed';
+ } catch (err) {
+ console.error(`routine fire failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
+ return 'failed';
+ }
+ }
+
+ /** Null when the routine took the call, else why not. */
+ private async post(routine: { url: string; key: string }, userId: string, session: ReviewSessionRecord, kind: RoutineFireKind): Promise {
+ const { token } = await this.live.issueSessionToken(userId, session.id);
+ const text = [
+ 'diffity session', session.id,
+ 'repo', `${session.owner}/${session.repo}`,
+ 'pr', session.prNumber ?? '-',
+ 'base', session.baseSha,
+ 'head', session.headSha,
+ 'kind', kind,
+ 'url', new URL(`/s/${session.id}/`, this.publicUrl).href,
+ 'token', token,
+ ].join(' ');
+ try {
+ const res = await this.fetchImpl(routine.url, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${routine.key}`,
+ 'anthropic-beta': 'experimental-cc-routine-2026-04-01',
+ 'anthropic-version': '2023-06-01',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ text }),
+ signal: AbortSignal.timeout(FIRE_TIMEOUT_MS),
+ });
+ return res.ok ? null : String(res.status);
+ } catch (err) {
+ return err instanceof Error ? err.message : String(err);
+ }
+ }
+}
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index 2b714d40..e18d4185 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -12,6 +12,7 @@ import { AutomationTokens } from './automation.js';
import { ReviewService } from './service.js';
import { DevLoginProvider, IapLogin } from './login.js';
import { Live } from './live.js';
+import { Routine } from './routine.js';
import { createApp } from './app.js';
export interface ServerOverrides {
@@ -23,6 +24,8 @@ export interface ServerOverrides {
uiDir?: string | null;
rateLimit?: boolean;
version?: string;
+ /** What fires the users' routines, instead of the network. */
+ routineFetch?: typeof fetch;
}
export interface RunningServer {
@@ -63,6 +66,7 @@ export async function startServer(
const access = overrides.gitHubAccess ?? githubApp ?? new StoredTokenAccess(users, config.devGitHubToken);
const service = new ReviewService(reviews, mirrors, api, access, config.publicUrl, users, bot);
const live = new Live(db);
+ const routine = new Routine(db, users, live, config.publicUrl, overrides.routineFetch);
const app = createApp({
config,
@@ -72,6 +76,7 @@ export async function startServer(
automationTokens: new AutomationTokens(db),
service,
live,
+ routine,
devLogin: config.devLogin ? new DevLoginProvider(config) : null,
iap: config.iapAudience ? new IapLogin({ ...config, iapAudience: config.iapAudience }, overrides.iapKeys) : null,
githubApp,
diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts
index a6a996ef..7d554536 100644
--- a/packages/server/src/service.ts
+++ b/packages/server/src/service.ts
@@ -168,6 +168,26 @@ export class ReviewService {
/** Null when the user has no GitHub access to ask with. */
async reviewRequests(userId: string, now = Date.now()): Promise {
+ const all = await this.searchPulls(userId, 'review', token => this.github.reviewRequests(token), now);
+ if (!all) {
+ return null;
+ }
+ const shown = filterReviewRequests(all, await this.users.settings(userId));
+ return { requests: await this.withSessions(userId, shown), hidden: all.length - shown.length };
+ }
+
+ /** Null when the user has no GitHub access to ask with. */
+ async ownPulls(userId: string, now = Date.now()): Promise {
+ const all = await this.searchPulls(userId, 'own', token => this.github.ownPulls(token), now);
+ return all && this.withSessions(userId, all);
+ }
+
+ private async searchPulls(
+ userId: string,
+ kind: 'review' | 'own',
+ search: (token: string) => Promise,
+ now: number,
+ ): Promise {
const token = await this.access.tokenFor(userId);
if (!token) {
return null;
@@ -177,24 +197,24 @@ export class ReviewService {
this.reviewRequestCache.delete(key);
}
}
- let cached = this.reviewRequestCache.get(userId);
+ const key = `${kind}:${userId}`;
+ let cached = this.reviewRequestCache.get(key);
if (!cached || cached.token !== token) {
- const entry = { token, at: now, requests: this.github.reviewRequests(token) };
- this.reviewRequestCache.set(userId, entry);
+ const entry = { token, at: now, requests: search(token) };
+ this.reviewRequestCache.set(key, entry);
entry.requests.catch(() => {
- if (this.reviewRequestCache.get(userId) === entry) {
- this.reviewRequestCache.delete(userId);
+ if (this.reviewRequestCache.get(key) === entry) {
+ this.reviewRequestCache.delete(key);
}
});
cached = entry;
}
- const all = await cached.requests;
- const shown = filterReviewRequests(all, await this.users.settings(userId));
- const sessions = await this.reviews.latestPrSessions(userId, shown);
- return {
- requests: shown.map(request => ({ ...request, session: sessions.get(prKey(request.owner, request.repo, request.number)) ?? null })),
- hidden: all.length - shown.length,
- };
+ return cached.requests;
+ }
+
+ private async withSessions(userId: string, requests: ReviewRequest[]): Promise {
+ const sessions = await this.reviews.latestPrSessions(userId, requests);
+ return requests.map(request => ({ ...request, session: sessions.get(prKey(request.owner, request.repo, request.number)) ?? null }));
}
async requireSession(userId: string, idOrPrefix: string): Promise {
diff --git a/packages/server/src/ui-api.ts b/packages/server/src/ui-api.ts
index a309f5c9..8a761e44 100644
--- a/packages/server/src/ui-api.ts
+++ b/packages/server/src/ui-api.ts
@@ -21,10 +21,12 @@ import { describeSession, ServiceError, type ReviewService } from './service.js'
import { splitLines } from './anchor.js';
import type { User } from './users.js';
import type { Live } from './live.js';
+import type { Routine } from './routine.js';
export interface UiApiDeps {
service: ReviewService;
live: Live;
+ routine: Routine;
userFor: (req: Request) => Promise;
}
@@ -47,7 +49,7 @@ function parsed(res: Response, result: ParseResult): T | null {
/** The `/api/*` subset the review UI needs, scoped to one session of the signed-in user. */
export function uiApiRouter(deps: UiApiDeps): Router {
- const { service, live } = deps;
+ const { service, live, routine } = deps;
const reviews = service.reviews;
const router = express.Router({ mergeParams: true });
router.use(express.json({ limit: '2mb' }));
@@ -83,6 +85,13 @@ export function uiApiRouter(deps: UiApiDeps): Router {
const author = (user: User): CommentAuthor => ({ name: user.name, type: 'user' });
+ const ask = async ({ user, session }: Scoped, threadId: string, commentId: string): Promise => {
+ await live.ask(user.id, session.id, threadId, commentId);
+ if (!(await live.status(user.id, session.id)).listening) {
+ await routine.fire(user.id, session, 'ask');
+ }
+ };
+
const sessionMatches = (res: Response, session: ReviewSessionRecord, asked: unknown): boolean => {
if (asked !== undefined && asked !== null && asked !== '' && asked !== session.id) {
fail(res, 400, 'session does not match this review');
@@ -148,7 +157,8 @@ export function uiApiRouter(deps: UiApiDeps): Router {
res.json(await reviews.threadsForSession(user.id, session.id, status));
}));
- router.post('/threads', handle(async ({ user, session }, req, res) => {
+ router.post('/threads', handle(async (scoped, req, res) => {
+ const { user, session } = scoped;
const body = parsed(res, parseCreateThreadRequest(req.body));
if (!body || !sessionMatches(res, session, body.sessionId)) {
return;
@@ -166,7 +176,7 @@ export function uiApiRouter(deps: UiApiDeps): Router {
kind: body.ask ? 'aside' : body.kind ?? 'review',
});
if (body.ask) {
- await live.ask(user.id, session.id, thread.id, thread.comments[0].id);
+ await ask(scoped, thread.id, thread.comments[0].id);
}
res.json(body.ask ? await reviews.getThread(user.id, thread.id) : thread);
}));
@@ -189,7 +199,7 @@ export function uiApiRouter(deps: UiApiDeps): Router {
const kind = body.ask ? 'aside' : body.kind ?? 'review';
const reply = await reviews.addReply(scoped.user.id, thread.id, body.body, author(scoped.user), kind);
if (body.ask) {
- await live.ask(scoped.user.id, scoped.session.id, thread.id, reply.id);
+ await ask(scoped, thread.id, reply.id);
}
res.json(body.ask ? { ...reply, ask: 'pending' } : reply);
}));
@@ -246,11 +256,12 @@ export function uiApiRouter(deps: UiApiDeps): Router {
}));
router.get('/live/status', handle(async ({ user, session }, _req, res) => {
- res.json((await live.status(user.id, session.id)) satisfies LiveStatusResponse);
+ res.json({ ...(await live.status(user.id, session.id)), routine: await routine.configured(user.id) } satisfies LiveStatusResponse);
}));
router.post('/review-request', handle(async ({ user, session }, _req, res) => {
await live.queueReview(user.id, session.id);
+ await routine.fire(user.id, session, 'review');
res.json((await reviews.getSession(user.id, session.id))!.review satisfies ReviewRun);
}));
diff --git a/packages/server/src/users.ts b/packages/server/src/users.ts
index f0153ec1..2b3c7945 100644
--- a/packages/server/src/users.ts
+++ b/packages/server/src/users.ts
@@ -74,6 +74,42 @@ export class Users {
const row = await this.db.one<{ github_token: string | null }>('SELECT github_token FROM users WHERE id = $1', [userId]);
return row?.github_token ? decrypt(this.secretKey, row.github_token) : null;
}
+
+ /** The Claude routine diffity fires to attend the user's sessions; null as well when its key no longer decrypts. */
+ async routine(userId: string): Promise<{ url: string; key: string } | null> {
+ const row = await this.db.one<{ routine_url: string | null; routine_key: string | null }>(
+ 'SELECT routine_url, routine_key FROM users WHERE id = $1',
+ [userId],
+ );
+ const key = row?.routine_key ? decrypt(this.secretKey, row.routine_key) : null;
+ return row?.routine_url && key ? { url: row.routine_url, key } : null;
+ }
+
+ /** A null URL clears the routine; an undefined key keeps the stored one. */
+ async setRoutine(userId: string, url: string | null, key: string | null | undefined): Promise {
+ if (url === null) {
+ await this.db.query('UPDATE users SET routine_url = NULL, routine_key = NULL WHERE id = $1', [userId]);
+ return;
+ }
+ if (key === undefined) {
+ await this.db.query('UPDATE users SET routine_url = $1 WHERE id = $2', [url, userId]);
+ return;
+ }
+ await this.db.query('UPDATE users SET routine_url = $1, routine_key = $2 WHERE id = $3', [
+ url,
+ key === null ? null : encrypt(this.secretKey, key),
+ userId,
+ ]);
+ }
+
+ /** What the settings page shows of the routine: never the key. */
+ async routineSettings(userId: string): Promise<{ url: string | null; keySet: boolean }> {
+ const row = await this.db.one<{ routine_url: string | null; routine_key: string | null }>(
+ 'SELECT routine_url, routine_key FROM users WHERE id = $1',
+ [userId],
+ );
+ return { url: row?.routine_url ?? null, keySet: !!row?.routine_key };
+ }
}
export const WEB_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
diff --git a/packages/server/src/web.ts b/packages/server/src/web.ts
index 72e7e08b..0b1497ee 100644
--- a/packages/server/src/web.ts
+++ b/packages/server/src/web.ts
@@ -17,8 +17,8 @@ export const UI_CONTENT_SECURITY_POLICY = [
"connect-src 'self'",
].join('; ');
-/** The server's own pages: no script at all, forms only to this origin unless widened for one. */
-export function pageContentSecurityPolicy(extraFormTargets: string[] = []): string {
+/** The server's own pages: no script unless the page loads one of this origin's, forms only to this origin unless widened for one. */
+export function pageContentSecurityPolicy(extraFormTargets: string[] = [], selfScripts = false): string {
return [
"default-src 'none'",
"base-uri 'none'",
@@ -26,6 +26,7 @@ export function pageContentSecurityPolicy(extraFormTargets: string[] = []): stri
`form-action 'self'${extraFormTargets.map(target => ` ${target}`).join('')}`,
"img-src 'self'",
"style-src 'unsafe-inline'",
+ ...selfScripts ? ["script-src 'self'"] : [],
].join('; ');
}
diff --git a/packages/server/tests/helpers.ts b/packages/server/tests/helpers.ts
index 89e72ffc..8df2cdfa 100644
--- a/packages/server/tests/helpers.ts
+++ b/packages/server/tests/helpers.ts
@@ -166,6 +166,8 @@ export interface FakeGitHub {
failReviewWith: number | null;
/** What `GET /search/issues` finds waiting for each token's review. */
reviewRequests: Record;
+ /** What `GET /search/issues` finds authored by each token's own account. */
+ ownPulls: Record;
/** The `q` of every search. */
searches: string[];
/** Makes `GET /search/issues` answer this status instead. */
@@ -197,6 +199,7 @@ export async function startFakeGitHub(repos: FakeRepo[]): Promise {
tokenEndpoint: () => ({ error: 'bad_verification_code' }),
failReviewWith: null,
reviewRequests: {},
+ ownPulls: {},
searches: [],
failSearchWith: null,
installations: {},
@@ -238,7 +241,8 @@ export async function startFakeGitHub(repos: FakeRepo[]): Promise {
send(fake.failSearchWith, { message: 'Validation Failed' });
return;
}
- const items = (token ? fake.reviewRequests[token] ?? [] : []).map(pr => ({
+ const found = (url.searchParams.get('q') ?? '').includes('author:@me') ? fake.ownPulls : fake.reviewRequests;
+ const items = (token ? found[token] ?? [] : []).map(pr => ({
number: pr.number,
title: pr.title,
html_url: `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`,
diff --git a/packages/server/tests/http.test.ts b/packages/server/tests/http.test.ts
index 6f6fb2c4..8a011b83 100644
--- a/packages/server/tests/http.test.ts
+++ b/packages/server/tests/http.test.ts
@@ -159,6 +159,36 @@ describe('web pages', () => {
expect(await server.users.gitHubToken(aliceId)).toBeNull();
});
+ it('keeps the routine’s key encrypted and never shows it, keeps it on a save without one, and removes both on request', async () => {
+ const save = (body: string) => api('/settings/routine', {
+ method: 'POST',
+ cookie: aliceCookie,
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+ expect((await api('/settings', { cookie: aliceCookie })).body).toContain('No key yet');
+ expect((await save('url=http%3A%2F%2Finsecure.example%2Ffire&key=sk-routine')).status).toBe(400);
+ expect(await server.users.routine(aliceId)).toBeNull();
+
+ const saved = await save('url=+https%3A%2F%2Fapi.example%2Ffire+&key=sk-routine');
+ expect(saved.status).toBe(303);
+ expect(saved.headers.get('location')).toBe('/settings');
+ expect(await server.users.routine(aliceId)).toEqual({ url: 'https://api.example/fire', key: 'sk-routine' });
+ expect(JSON.stringify(await server.db.query('SELECT routine_key FROM users'))).not.toContain('sk-routine');
+ const settings = (await api('/settings', { cookie: aliceCookie })).body as string;
+ expect(settings).toContain('Key is set');
+ expect(settings).toContain('value="https://api.example/fire"');
+ expect(settings).not.toContain('sk-routine');
+ expect(await server.users.routine(bobId)).toBeNull();
+
+ await save('url=https%3A%2F%2Fapi.example%2Fother&key=');
+ expect(await server.users.routine(aliceId)).toEqual({ url: 'https://api.example/other', key: 'sk-routine' });
+
+ await save('clear=1');
+ expect(await server.users.routine(aliceId)).toBeNull();
+ expect(await server.users.routineSettings(aliceId)).toEqual({ url: null, keySet: false });
+ });
+
it('signs out', async () => {
const cookie = await login(server.base, 'carol@example.com');
expect((await api('/', { cookie })).status).toBe(200);
@@ -194,9 +224,9 @@ describe('pull requests awaiting review', () => {
try {
const page = (await api('/', { cookie: aliceCookie })).body as string;
expect(page).toContain('acme/widgets#1 ');
- expect(page).toContain(`open · ${fixture.head1.slice(0, 7)} `);
+ expect(page).toContain(`open · ${fixture.head1.slice(0, 7)} `);
expect(page).toContain('2026-10-03 09:15');
- expect(page).toMatch(/action="\/sessions">\s* \s* /);
+ expect(page).toMatch(/action="\/sessions" target="_blank">\s* \s* /);
expect(page).not.toContain('Release 1.2');
expect(page).toContain('(1 hidden by your');
@@ -209,6 +239,92 @@ describe('pull requests awaiting review', () => {
}
});
+ it('offers to hand a pull request to the agent when a routine is configured, with nobody listening', async () => {
+ github.reviewRequests['alice-token-routine'] = [
+ { owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'octocat', updatedAt: '2026-10-03T09:15:00Z' },
+ { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' },
+ ];
+ await server.users.setGitHubToken(aliceId, 'alice-token-routine');
+ const userStatus = async () => (await api('/api/live/status', { cookie: aliceCookie })).body;
+ const sessionStatus = async () => (await api(`/s/${session.id}/api/live/status`, { cookie: aliceCookie })).body;
+ try {
+ const without = (await api('/', { cookie: aliceCookie })).body as string;
+ expect(without).not.toContain('Create and review');
+ expect(without).not.toContain('review ');
+ expect(without).toContain('Create session ');
+ expect(await userStatus()).toMatchObject({ listening: false, routine: false });
+ expect(await sessionStatus()).toMatchObject({ listening: false, routine: false });
+
+ await server.users.setRoutine(aliceId, 'https://api.example/fire', 'sk-routine');
+ const reachable = (await api('/', { cookie: aliceCookie })).body as string;
+ expect(reachable).not.toContain('Agent listening');
+ expect(reachable).toMatch(/name="pr" value="2">\s*Create and review<\/button>/);
+ expect(reachable).toContain(`action="/sessions/${prSession.id}/review-request">review `);
+ expect(await userStatus()).toMatchObject({ listening: false, routine: true });
+ expect(await sessionStatus()).toMatchObject({ listening: false, routine: true });
+ expect((await api('/api/live/status', { cookie: bobCookie })).body.routine).toBe(false);
+ } finally {
+ await server.users.setRoutine(aliceId, null, null);
+ await server.users.setGitHubToken(aliceId, null);
+ }
+ });
+
+ it('lists the user’s own open pull requests, drafts marked, apart from the review queue', async () => {
+ github.reviewRequests['alice-token-own'] = [
+ { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' },
+ ];
+ github.ownPulls['alice-token-own'] = [
+ { owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'alice', updatedAt: '2026-10-03T09:15:00Z' },
+ { owner: 'acme', repo: 'widgets', number: 5, title: 'Work in progress', author: 'alice', draft: true, updatedAt: '2026-10-04T09:15:00Z' },
+ ];
+ await server.users.setGitHubToken(aliceId, 'alice-token-own');
+ try {
+ const page = (await api('/', { cookie: aliceCookie })).body as string;
+ const review = page.slice(page.indexOf('Awaiting your review'), page.indexOf('Your open pull requests'));
+ const own = page.slice(page.indexOf('Your open pull requests'), page.indexOf('Your review sessions'));
+ expect(review).toContain('acme/widgets#2');
+ expect(review).not.toContain('acme/widgets#1');
+ expect(review).not.toContain('Work in progress');
+ expect(own).toContain('acme/widgets#1');
+ expect(own).toContain('Work in progress draft ');
+ expect(own).toContain(`open · ${fixture.head1.slice(0, 7)} `);
+ expect(own).not.toContain('acme/widgets#2');
+ expect(github.searches).toContain('is:pr is:open archived:false author:@me');
+
+ github.ownPulls['alice-token-own-none'] = [];
+ await server.users.setGitHubToken(aliceId, 'alice-token-own-none');
+ expect((await api('/', { cookie: aliceCookie })).body).toContain('You have no open pull requests.');
+ } finally {
+ await server.users.setGitHubToken(aliceId, null);
+ }
+ expect((await api('/', { cookie: aliceCookie })).body).toContain('Connect GitHub on Settings to see your open pull requests.');
+ });
+
+ it('opens sessions in a new tab, folds each section, and allows only its own script', async () => {
+ github.reviewRequests['alice-token-tabs'] = [
+ { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' },
+ ];
+ await server.users.setGitHubToken(aliceId, 'alice-token-tabs');
+ try {
+ const res = await api('/', { cookie: aliceCookie });
+ const page = res.body as string;
+ expect(page).toContain(``);
+ expect(page).not.toMatch(/ /);
+ expect(page).toContain('
{onAsk && (
-
+
send(onAsk)}
- disabled={!listening || !body.trim()}
+ disabled={!reachable || !body.trim()}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-accent/40 text-accent hover:bg-accent/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Ask Claude
diff --git a/packages/ui/src/components/diff/diff-page.tsx b/packages/ui/src/components/diff/diff-page.tsx
index aa24170f..5d15c97e 100644
--- a/packages/ui/src/components/diff/diff-page.tsx
+++ b/packages/ui/src/components/diff/diff-page.tsx
@@ -43,7 +43,7 @@ import type { LineSelection } from '../comments/types';
import type { ParsedDiff } from '@diffity/parser';
import { isThreadResolved } from '../comments/types';
import { liveStatusOptions } from '../../queries/live';
-import { AgentListeningContext } from '../../lib/agent-listening';
+import { AgentCalledContext, AgentListeningContext, AgentReachableContext } from '../../lib/agent-listening';
export function DiffPage() {
const { theme: initialTheme, view: initialViewMode } = useLoaderData<{
@@ -87,6 +87,8 @@ export function DiffPage() {
useTabBadge(answerAlerts.unseenWhileHidden);
const liveStatus = useQuery(liveStatusOptions(sessionId)).data;
const agentListening = !!liveStatus?.listening;
+ const agentCalled = !agentListening && !!liveStatus?.calledAt;
+ const agentReachable = agentListening || !!liveStatus?.routine;
const requestReview = useRequestReview();
const commentCountsByFile = useMemo(() => buildThreadCountsByFile(threads), [threads]);
@@ -483,7 +485,7 @@ export function DiffPage() {
}
return (
-
+
requestReview.mutate()}
/>
)}
@@ -574,6 +578,6 @@ export function DiffPage() {
{showHelp && setShowHelp(false)} />}
-
+
);
}
diff --git a/packages/ui/src/components/layout/review-banner.tsx b/packages/ui/src/components/layout/review-banner.tsx
index 6d29765a..ae8280bd 100644
--- a/packages/ui/src/components/layout/review-banner.tsx
+++ b/packages/ui/src/components/layout/review-banner.tsx
@@ -13,6 +13,10 @@ interface ReviewBannerProps {
onReload: () => void;
/** An agent waits on this session or on the user's queue, so a review asked for now gets picked up. */
agentListening?: boolean;
+ /** Diffity called the user's routine for this session and it has not started listening yet. */
+ agentCalled?: boolean;
+ /** Diffity has a routine to call for this user, so a review asked for now gets picked up even with no agent listening. */
+ agentReachable?: boolean;
onRequestReview?: () => void;
}
@@ -23,8 +27,8 @@ const BAR = 'flex items-center gap-2 px-4 py-2 border-b text-xs text-text';
* finished looking" is the difference between approving a change and approving it too early.
*/
export function ReviewBanner(props: ReviewBannerProps) {
- const { review, findings, ready, onReload, agentListening = false, onRequestReview } = props;
- const canRequest = agentListening && !!onRequestReview;
+ const { review, findings, ready, onReload, agentListening = false, agentCalled = false, agentReachable = false, onRequestReview } = props;
+ const canRequest = (agentListening || agentReachable) && !!onRequestReview;
const requestButton = (label: string) => (
- No agent picked this up
+ {agentCalled ? 'Agent called, starting…' : 'No agent picked this up'}
Ask an agent to review it with the diffity review prompt, or to attend your queue.
diff --git a/packages/ui/src/lib/agent-listening.ts b/packages/ui/src/lib/agent-listening.ts
index 7083a6b1..26e4fbe3 100644
--- a/packages/ui/src/lib/agent-listening.ts
+++ b/packages/ui/src/lib/agent-listening.ts
@@ -2,3 +2,9 @@ import { createContext } from 'react';
/** Whether an agent is waiting for questions on this session, which is what "Ask Claude" needs. */
export const AgentListeningContext = createContext(false);
+
+/** Diffity called the user's routine for this session, and no agent is listening yet. */
+export const AgentCalledContext = createContext(false);
+
+/** Diffity has a routine to call for this user, so a question gets answered even while no agent is listening. */
+export const AgentReachableContext = createContext(false);
diff --git a/packages/ui/tests/ask-claude.test.tsx b/packages/ui/tests/ask-claude.test.tsx
index 157c2576..473e101d 100644
--- a/packages/ui/tests/ask-claude.test.tsx
+++ b/packages/ui/tests/ask-claude.test.tsx
@@ -3,7 +3,7 @@ import { render, cleanup, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CommentForm } from '../src/components/comments/comment-form';
import { ThreadCard } from '../src/components/comments/thread-card';
-import { AgentListeningContext } from '../src/lib/agent-listening';
+import { AgentListeningContext, AgentReachableContext } from '../src/lib/agent-listening';
import { makeComment, makeThread } from './helpers/wire';
afterEach(cleanup);
@@ -43,6 +43,22 @@ describe('Ask Claude on the comment box', () => {
expect(ask.parentElement?.getAttribute('title')).toBe('No agent is listening on this review');
});
+ it('asks with nobody listening when diffity has a routine to call', async () => {
+ const user = userEvent.setup({ delay: null });
+ const onAsk = vi.fn();
+ render(
+
+ {}} onAsk={onAsk} />
+ ,
+ );
+
+ await user.type(screen.getByPlaceholderText('Leave a comment'), 'why this?');
+ const ask = screen.getByRole('button', { name: 'Ask Claude' });
+ expect(ask.parentElement?.getAttribute('title')).toBe('Ask; diffity calls your routine to answer');
+ await user.click(ask);
+ expect(onAsk).toHaveBeenCalledWith('why this?');
+ });
+
it('is not offered where nothing can be asked', () => {
form(true);
expect(screen.queryByRole('button', { name: 'Ask Claude' })).toBeNull();
diff --git a/packages/ui/tests/review-banner.test.tsx b/packages/ui/tests/review-banner.test.tsx
index d2f9bcb5..0e7fba45 100644
--- a/packages/ui/tests/review-banner.test.tsx
+++ b/packages/ui/tests/review-banner.test.tsx
@@ -76,6 +76,13 @@ describe('the review banner', () => {
expect(onRequestReview).toHaveBeenCalledTimes(3);
});
+ it('offers a review with nobody listening when diffity has a routine to call', () => {
+ const onRequestReview = vi.fn();
+ render( {}} agentReachable onRequestReview={onRequestReview} />);
+ fireEvent.click(screen.getByRole('button', { name: 'Ask your agent to review' }));
+ expect(onRequestReview).toHaveBeenCalledTimes(1);
+ });
+
it('offers the reload only for a review that finished while the page was open', () => {
banner(run('done'), true);
expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy();