@saadnvd1/agent-os 0.3.470 → 0.3.472

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +139 -36
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/models/route.ts +34 -0
  4. package/app/api/models/test/route.ts +17 -0
  5. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  6. package/app/api/tasks/[id]/model/route.ts +23 -0
  7. package/app/api/tasks/route.ts +5 -1
  8. package/app/api/usage/compare/route.ts +21 -0
  9. package/app/api/usage/route.ts +8 -3
  10. package/app/api/workspaces/[id]/models/route.ts +27 -0
  11. package/components/Chat/ChatPanel.tsx +5 -5
  12. package/components/Chat/StaleChip.tsx +7 -6
  13. package/components/CommandPalette/useAppCommands.ts +19 -1
  14. package/components/Draft/DraftChips.tsx +28 -0
  15. package/components/Draft/DraftPanel.tsx +1 -1
  16. package/components/Models/ModelSelect.tsx +67 -0
  17. package/components/Models/ModelsSection.tsx +199 -0
  18. package/components/Models/ProviderCard.tsx +271 -0
  19. package/components/SessionList/SidebarHeader.tsx +5 -1
  20. package/components/Settings/SettingsDialog.test.ts +3 -0
  21. package/components/Settings/SettingsDialog.tsx +9 -0
  22. package/components/Settings/commands.ts +19 -0
  23. package/components/Settings/entry-points.test.ts +1 -0
  24. package/components/Tasks/ResumeOnModel.tsx +72 -0
  25. package/components/Tasks/TaskRow.tsx +4 -0
  26. package/components/Usage/CompareView.tsx +211 -0
  27. package/components/Usage/ModelSpend.tsx +133 -0
  28. package/components/Usage/UsageDialog.tsx +69 -5
  29. package/components/Usage/format.ts +9 -0
  30. package/components/Workspaces/FreshStartForm.tsx +13 -13
  31. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  32. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  33. package/data/models/index.ts +125 -0
  34. package/data/usage/index.ts +5 -0
  35. package/lib/agents/launch.ts +13 -2
  36. package/lib/chat/context.ts +1 -1
  37. package/lib/chat/refresh.ts +31 -13
  38. package/lib/chat/runner.ts +5 -0
  39. package/lib/chat/stale.ts +25 -16
  40. package/lib/chat/worker/extras.ts +12 -0
  41. package/lib/chat/worker/host.ts +70 -24
  42. package/lib/chat/worker/main.ts +5 -2
  43. package/lib/db/migrations.ts +70 -0
  44. package/lib/db/types.ts +6 -0
  45. package/lib/drafts.ts +3 -0
  46. package/lib/models/config.ts +218 -0
  47. package/lib/models/launch.ts +93 -0
  48. package/lib/models/plan-step.ts +94 -0
  49. package/lib/models/providers.ts +163 -0
  50. package/lib/models/resolve.ts +246 -0
  51. package/lib/models/resume.ts +99 -0
  52. package/lib/models/roles.ts +98 -0
  53. package/lib/models/secrets.ts +86 -0
  54. package/lib/models/session-model.ts +40 -0
  55. package/lib/models/test-provider.ts +116 -0
  56. package/lib/orchestrator/act.ts +13 -1
  57. package/lib/orchestrator/armed.ts +1 -2
  58. package/lib/orchestrator/brief.ts +5 -4
  59. package/lib/orchestrator/claude-cli.ts +48 -14
  60. package/lib/orchestrator/diff.ts +1 -1
  61. package/lib/orchestrator/external-gates.ts +8 -43
  62. package/lib/orchestrator/gates.ts +10 -10
  63. package/lib/orchestrator/home.ts +2 -2
  64. package/lib/orchestrator/pr-wake.ts +2 -2
  65. package/lib/orchestrator/rebase.ts +6 -45
  66. package/lib/orchestrator/review.ts +20 -5
  67. package/lib/orchestrator/scope.ts +11 -0
  68. package/lib/orchestrator/serve.ts +13 -0
  69. package/lib/orchestrator/signoff.ts +13 -8
  70. package/lib/orchestrator/tool-names.ts +1 -0
  71. package/lib/orchestrator/tool-schemas.ts +38 -0
  72. package/lib/orchestrator/tools.ts +3 -1
  73. package/lib/pr-generation.ts +5 -2
  74. package/lib/providers/registry.ts +1 -1
  75. package/lib/session-audit/render.ts +1 -1
  76. package/lib/session-titles.ts +1 -1
  77. package/lib/sessions/launch.ts +19 -4
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/stacks/land.ts +23 -16
  80. package/lib/tasks/brief.ts +3 -14
  81. package/lib/tasks/code-review.ts +69 -107
  82. package/lib/tasks/gh.ts +3 -2
  83. package/lib/tasks/index.ts +60 -9
  84. package/lib/tasks/queue.ts +14 -5
  85. package/lib/tasks/start.ts +7 -2
  86. package/lib/tasks/state.ts +4 -4
  87. package/lib/usage/by-model.ts +105 -0
  88. package/lib/usage/check-runs.ts +170 -0
  89. package/lib/usage/compare.ts +280 -0
  90. package/lib/usage/cost.ts +71 -0
  91. package/lib/usage/report.ts +51 -0
  92. package/lib/usage/turns.ts +107 -18
  93. package/package.json +1 -1
  94. package/stores/usageUi.ts +11 -2
  95. package/scripts/check-code-review.ts +0 -21
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A provider's key, fetched from where its reference points at the moment
3
+ * an agent launches, and handed straight to that agent's environment. It is
4
+ * never written anywhere: not the database, not a log, not a prompt. Every
5
+ * key fetched is remembered in memory only so redact() can scrub it from
6
+ * any text on its way to a log or an error.
7
+ */
8
+
9
+ import { execFile } from "child_process";
10
+ import { shellQuote } from "../hosts/ssh";
11
+ import type { SecretRef } from "./providers";
12
+
13
+ export type SecretCommand = (cmd: string, args: string[]) => Promise<string>;
14
+
15
+ const run: SecretCommand = (cmd, args) =>
16
+ new Promise((resolve, reject) =>
17
+ execFile(cmd, args, { timeout: 15_000 }, (err, stdout) =>
18
+ // Its stderr isn't passed on: a tool may echo what it was asked.
19
+ err ? reject(new Error(`${cmd} failed`)) : resolve(stdout)
20
+ )
21
+ );
22
+
23
+ // An environment variable a key may be read from: one named for an API key,
24
+ // never AgentOS's own nor its Claude login's, so a reference can't lift an
25
+ // unrelated secret out of the server's environment.
26
+ export const KEY_ENV_NAME =
27
+ /^(?!(?:ANTHROPIC|CLAUDE|AGENTOS)_)[A-Z][A-Z0-9_]*_API_KEY$/;
28
+
29
+ // The command that prints the key, as argv; null for an environment
30
+ // variable, which is read directly.
31
+ export function secretCommand(ref: SecretRef): string[] | null {
32
+ if (ref.kind === "tokenvault")
33
+ return ["tv", "get", ref.project, ...(ref.desc ? [ref.desc] : [])];
34
+ if (ref.kind === "keychain")
35
+ return [
36
+ "security",
37
+ "find-generic-password",
38
+ "-s",
39
+ ref.service,
40
+ ...(ref.account ? ["-a", ref.account] : []),
41
+ "-w",
42
+ ];
43
+ return null;
44
+ }
45
+
46
+ // The same, as shell text that expands to the key inside the agent's own
47
+ // shell (a terminal launch), so AgentOS never holds it at all.
48
+ export function secretShellExpansion(ref: SecretRef): string {
49
+ const argv = secretCommand(ref);
50
+ if (!argv) return `"\${${ref.kind === "env" ? envName(ref.name) : ""}}"`;
51
+ return `"$(${argv.map(shellQuote).join(" ")})"`;
52
+ }
53
+
54
+ function envName(name: string): string {
55
+ if (!KEY_ENV_NAME.test(name))
56
+ throw new Error(
57
+ `A key can't be read from ${name}: name a *_API_KEY variable`
58
+ );
59
+ return name;
60
+ }
61
+
62
+ const seen = new Set<string>();
63
+
64
+ export async function resolveSecret(
65
+ ref: SecretRef,
66
+ exec: SecretCommand = run
67
+ ): Promise<string> {
68
+ const argv = secretCommand(ref);
69
+ let value: string | undefined;
70
+ if (argv) {
71
+ const out = await exec(argv[0], argv.slice(1)).catch(() => {
72
+ throw new Error(`Couldn't read the key (${argv[0]} failed)`);
73
+ });
74
+ value = out.trim();
75
+ } else if (ref.kind === "env") value = process.env[envName(ref.name)]?.trim();
76
+ if (!value) throw new Error("The key reference resolved to nothing");
77
+ if (value.length >= 8) seen.add(value);
78
+ return value;
79
+ }
80
+
81
+ // Scrubs every key fetched so far out of the text.
82
+ export function redact(text: string): string {
83
+ let out = text;
84
+ for (const s of seen) out = out.split(s).join("[redacted]");
85
+ return out;
86
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Moving a session to another model without losing its conversation or
3
+ * worktree. On the same provider a live conversation switches in place;
4
+ * across providers the endpoint and key are in the agent's environment, so
5
+ * its worker goes and the next message starts one that resumes the same
6
+ * conversation on the new provider.
7
+ */
8
+
9
+ import { db, type Session } from "../db";
10
+ import { registry } from "../chat/registry";
11
+ import { setChatModel } from "../chat/settings";
12
+ import { setSessionModel, type RoutedModel } from "./resolve";
13
+ import { ANTHROPIC } from "./roles";
14
+
15
+ export async function switchSessionModel(
16
+ sessionId: string,
17
+ routed: RoutedModel
18
+ ): Promise<void> {
19
+ const before = db
20
+ .prepare(`SELECT model_provider FROM sessions WHERE id = ?`)
21
+ .get(sessionId) as Pick<Session, "model_provider"> | undefined;
22
+ setSessionModel(sessionId, routed);
23
+ await applySessionModel(
24
+ sessionId,
25
+ before?.model_provider ?? ANTHROPIC,
26
+ routed
27
+ );
28
+ }
29
+
30
+ // What's live catches up with the row: the model now, or a fresh worker.
31
+ export async function applySessionModel(
32
+ sessionId: string,
33
+ fromProvider: string,
34
+ routed: RoutedModel
35
+ ): Promise<void> {
36
+ if (fromProvider === routed.provider.id)
37
+ return setChatModel(sessionId, routed.model);
38
+ if (registry.live.has(sessionId))
39
+ (await import("../chat/runner")).stopChat(sessionId);
40
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * "Test provider": one tiny request to a provider's endpoint with its key,
3
+ * reporting whether it answered. The key goes only into the request's
4
+ * header; what comes back is scrubbed of it before anyone sees it.
5
+ */
6
+
7
+ import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
8
+ import { effectiveModels } from "./config";
9
+ import type { ModelProvider } from "./providers";
10
+ import { ANTHROPIC } from "./roles";
11
+ import { redact, resolveSecret, type SecretCommand } from "./secrets";
12
+ import os from "os";
13
+
14
+ export interface ProviderTest {
15
+ ok: boolean;
16
+ message: string;
17
+ ms: number;
18
+ }
19
+
20
+ const PROMPT = "Reply with the word OK.";
21
+
22
+ export async function testProvider(
23
+ providerId: string,
24
+ model?: string,
25
+ deps: {
26
+ exec?: SecretCommand;
27
+ fetch?: typeof fetch;
28
+ claude?: ClaudeRunner;
29
+ } = {}
30
+ ): Promise<ProviderTest> {
31
+ const started = Date.now();
32
+ const done = (ok: boolean, message: string): ProviderTest => ({
33
+ ok,
34
+ message: redact(message).slice(0, 300),
35
+ ms: Date.now() - started,
36
+ });
37
+ const p = effectiveModels().providers.find((x) => x.id === providerId);
38
+ if (!p) return done(false, `No provider "${providerId}"`);
39
+ const m = model || p.tiers?.haiku || p.models[0];
40
+ if (!m) return done(false, "It lists no model to try");
41
+ try {
42
+ if (p.id === ANTHROPIC) {
43
+ await (deps.claude ?? runClaude)({
44
+ cwd: os.tmpdir(),
45
+ system: "Answer in the schema.",
46
+ prompt: PROMPT,
47
+ schema: {
48
+ type: "object",
49
+ properties: { ok: { type: "boolean" } },
50
+ required: ["ok"],
51
+ },
52
+ tools: [],
53
+ model: m,
54
+ timeoutMs: 60_000,
55
+ });
56
+ return done(true, `${m} answered through the Claude login`);
57
+ }
58
+ if (!p.baseUrl || !p.auth) return done(false, "Set its base URL and key");
59
+ const key = await resolveSecret(p.auth, deps.exec);
60
+ const res = await (deps.fetch ?? fetch)(...request(p, m, key));
61
+ // Never followed: a redirect would carry the key somewhere else.
62
+ if (res.status >= 300 && res.status < 400)
63
+ return done(
64
+ false,
65
+ `HTTP ${res.status}: the endpoint redirected; set its final URL`
66
+ );
67
+ if (res.ok) return done(true, `${m} answered (HTTP ${res.status})`);
68
+ const body = (await res.text().catch(() => ""))
69
+ .slice(0, 200)
70
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
71
+ return done(false, `HTTP ${res.status}: ${body}`);
72
+ } catch (e) {
73
+ return done(false, e instanceof Error ? e.message : String(e));
74
+ }
75
+ }
76
+
77
+ function request(
78
+ p: ModelProvider,
79
+ model: string,
80
+ key: string
81
+ ): [string, RequestInit] {
82
+ const base = p.baseUrl!.replace(/\/+$/, "");
83
+ const messages = [{ role: "user", content: PROMPT }];
84
+ const signal = AbortSignal.timeout(30_000);
85
+ const redirect = "manual" as const;
86
+ if (p.api === "openai")
87
+ return [
88
+ `${base}/chat/completions`,
89
+ {
90
+ method: "POST",
91
+ redirect,
92
+ signal,
93
+ headers: {
94
+ "content-type": "application/json",
95
+ authorization: `Bearer ${key}`,
96
+ },
97
+ body: JSON.stringify({ model, max_tokens: 8, messages }),
98
+ },
99
+ ];
100
+ return [
101
+ `${base}/v1/messages`,
102
+ {
103
+ method: "POST",
104
+ redirect,
105
+ signal,
106
+ headers: {
107
+ "content-type": "application/json",
108
+ "anthropic-version": "2023-06-01",
109
+ ...(p.authHeader === "x-api-key"
110
+ ? { "x-api-key": key }
111
+ : { authorization: `Bearer ${key}` }),
112
+ },
113
+ body: JSON.stringify({ model, max_tokens: 8, messages }),
114
+ },
115
+ ];
116
+ }
@@ -4,6 +4,7 @@
4
4
  * the brakes.
5
5
  */
6
6
 
7
+ import { parseOverride, type ModelOverride } from "../models/resolve";
7
8
  import { sendMessage } from "../bus";
8
9
  import { chatState, interruptChat } from "../chat/runner";
9
10
  import { hostExec, isRemoteHost } from "../hosts";
@@ -80,9 +81,17 @@ export async function startTask(
80
81
  workspaceId: string,
81
82
  projectRef: string,
82
83
  prompt: string,
83
- opts: { base?: string; name?: string; view?: StartView; after?: string } = {}
84
+ opts: {
85
+ base?: string;
86
+ name?: string;
87
+ view?: StartView;
88
+ after?: string;
89
+ model?: ModelOverride;
90
+ plan?: boolean;
91
+ } = {}
84
92
  ): Promise<string> {
85
93
  const project = workspaceProject(workspaceId, projectRef);
94
+ const model = parseOverride(opts.model) ?? undefined;
86
95
  // Its brakes read this machine's usage, not the other machine's account.
87
96
  if (isRemoteHost(project.host_id))
88
97
  throw new Error(
@@ -97,6 +106,7 @@ export async function startTask(
97
106
  prompt,
98
107
  name: opts.name,
99
108
  view,
109
+ model,
100
110
  baseBranch: opts.base || undefined,
101
111
  after: opts.after,
102
112
  originWorkspaceId: workspaceId,
@@ -112,6 +122,8 @@ export async function startTask(
112
122
  name: opts.name,
113
123
  baseBranch: opts.base || undefined,
114
124
  view,
125
+ model,
126
+ plan: opts.plan,
115
127
  }),
116
128
  (s) => s.id
117
129
  )
@@ -126,8 +126,7 @@ function fingerprint(
126
126
  (f.task?.ciSettleIn ?? 0) > 0,
127
127
  getCheck(task.id, head, "review")?.status,
128
128
  getCheck(task.id, head, "scope")?.status,
129
- pr.codeReview?.sha,
130
- pr.codeReview?.attribution,
129
+ pr.attribution,
131
130
  f.task?.blocked,
132
131
  f.status,
133
132
  f.needsInput,
@@ -41,16 +41,16 @@ You may act without asking on anything inside this workspace that's additive or
41
41
  Merge only through the gates: \`arm\` or \`sign_off\`. \`sign_off\` merges a task's PR, with the project's merge method (squash unless set otherwise), only if all of these hold, and otherwise refuses naming the gate:
42
42
  - ci: CI is green on the PR's head commit and has settled (the commit is 2 minutes old and no new check has appeared for 2 minutes). A repository with no CI goes to Saad.
43
43
  - review: an independent review of that exact commit passed. Run \`review\` on the task; it starts a fresh read-only reviewer in the background and its verdict reaches you as an event. A new commit needs a new review. The reviewer judges the PR against the task's brief plus every scope change you recorded on it with \`send\` and scope_change; a "Scope change" note in the PR body counts only where one of those backs it. When Saad changes a running task's scope, always tell the task that way.
44
- - code-review: the PR body has a "Code review" section (from the task's own \`/do-code-review\`) whose "Reviewed:" line names that exact commit, or the commit AgentOS restacked or rebased into it while the head is still the one AgentOS left. A task that pushed after its review has to review again and update the body; tell it so.
44
+ - code-review: a passing review (the gate above) covers that exact commit, or the commit AgentOS restacked or rebased into it while the head is still the one AgentOS left, and the PR body carries no AI attribution. Tasks don't review themselves or write a "Code review" section any more: your review is the one review, so don't ask a task for one.
45
45
  - blocked: no BLOCKED: line and no approval or question waiting in the task's chat or terminal.
46
46
  - scope: the diff stays in the task's repository, adds no secrets, isn't only lockfiles, and, for a task from a card, a check against the card (run with the review) says it's within what the card asks.
47
47
  - stack: a stacked task's parent has merged.
48
48
 
49
- PRs AgentOS didn't open (dispatch on another machine, a person, dependabot) go through the same \`sign_off\` and \`review\`: name one by #N, owner/repo#N or its URL. It must be open, in the repository of one of this workspace's projects (anything else is refused), and not from a fork. It's an external PR: there's no task or session behind it, so the gates read it differently. blocked: a draft isn't ready yet, and a label like "blocked", "do not merge" or "wip" holds it. code-review: a body with no "Code review" section is fine once your \`review\` of that exact head passes (dispatch writes its review up its own way); a section it does have must name the head, like a task's. ci, review, scope, Saad's approvals and his hold are the same as for a task, and a stack never applies. A PR that turns out to be a task's is gated as that task.
49
+ PRs AgentOS didn't open (dispatch on another machine, a person, dependabot) go through the same \`sign_off\` and \`review\`: name one by #N, owner/repo#N or its URL. It must be open, in the repository of one of this workspace's projects (anything else is refused), and not from a fork. It's an external PR: there's no task or session behind it, so the gates read it differently. blocked: a draft isn't ready yet, and a label like "blocked", "do not merge" or "wip" holds it. code-review: your \`review\` of that exact head is what counts, as for a task; a "Code review" section in its body is ignored. ci, review, scope, Saad's approvals and his hold are the same as for a task, and a stack never applies. A PR that turns out to be a task's is gated as that task.
50
50
 
51
51
  Arm a task once its work is what you want merged, rather than calling \`sign_off\` and waiting: \`arm\` makes AgentOS merge it the moment every gate passes at its head (it starts the review itself once CI is green), and you hear one event, "merged" or the failure. A failure counts like sign_off's, once per head, and the task stays armed for the fix; one that goes to Saad disarms it, as does an open ask_saad about that task (it merges on his answer). Don't poll an armed task with sign_off. \`disarm\` takes it back.
52
52
 
53
- When the base moves and a task's PR conflicts with it, AgentOS rebases it onto the base by itself if that's clean and its agent is idle with nothing uncommitted, pushes only the task's own branch, and tells you with one event. The task's Code review section still covers the rebased head, and your review of the old head carries over when the PR's own patch is unchanged (a fresh one starts otherwise). If it conflicts, the task is asked once to rebase and you get one event; don't send it the same request.
53
+ When the base moves and a task's PR conflicts with it, AgentOS rebases it onto the base by itself if that's clean and its agent is idle with nothing uncommitted, pushes only the task's own branch, and tells you with one event. Your review of the old head carries over when the PR's own patch is unchanged (a fresh one starts otherwise). If it conflicts, the task is asked once to rebase and you get one event; don't send it the same request.
54
54
 
55
55
  Tasks are woken by AgentOS itself when a check fails on their PR's head (with the failing step and the log around the error) and when a collaborator reviews or comments on their PR, and you get one "task X woken: ..." line for each. Don't message a task about the same failure or review; act only if it doesn't fix it.
56
56
 
@@ -92,7 +92,8 @@ Reading:
92
92
  Acting:
93
93
  - \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
94
94
  - \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
95
- - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does.
95
+ - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); pass model ("provider/model", or a map of role to one) only when Saad asks for one, and another provider than anthropic only then.
96
+ - \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
96
97
  - \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?): an interactive session, a chat unless view is terminal.
97
98
  - \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
98
99
  - \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
@@ -9,6 +9,10 @@
9
9
  */
10
10
 
11
11
  import { spawn } from "child_process";
12
+ import { runUsageOf, type RunUsage } from "../usage/check-runs";
13
+ import { routedRun } from "../models/launch";
14
+ import type { ModelRole } from "../models/roles";
15
+ import { redact } from "../models/secrets";
12
16
 
13
17
  export interface ClaudeRun {
14
18
  cwd: string;
@@ -20,14 +24,18 @@ export interface ClaudeRun {
20
24
  // Permission rules within those, e.g. "Bash(git diff:*)".
21
25
  allow?: string[];
22
26
  timeoutMs?: number;
23
- // A model alias or id; the review model (or the CLI's default) otherwise.
27
+ // A model alias or id on Anthropic; the role's model otherwise.
24
28
  model?: string;
29
+ // What the run cost, from its result, even when it failed.
30
+ onUsage?: (usage: RunUsage) => void;
31
+ // The kind of work it is, which picks its model and provider
32
+ // (lib/models): Settings' and the workspace's role models.
33
+ role?: ModelRole;
34
+ workspaceId?: string | null;
25
35
  }
26
36
 
27
37
  export type ClaudeRunner = (run: ClaudeRun) => Promise<unknown>;
28
38
 
29
- const MODEL = process.env.AGENTOS_REVIEW_MODEL;
30
-
31
39
  // All a check's process gets from the server's environment: enough to run
32
40
  // and to sign in to Claude, nothing else.
33
41
  const ENV_ALLOW = [
@@ -54,7 +62,7 @@ export function checkEnv(
54
62
  }
55
63
 
56
64
  export function claudeArgs(run: ClaudeRun): string[] {
57
- const model = run.model ?? MODEL;
65
+ const model = run.model;
58
66
  return [
59
67
  "-p",
60
68
  "--output-format",
@@ -84,12 +92,35 @@ export function claudeArgs(run: ClaudeRun): string[] {
84
92
  ];
85
93
  }
86
94
 
95
+ interface ClaudeResult {
96
+ is_error?: boolean;
97
+ result?: string;
98
+ structured_output?: unknown;
99
+ }
100
+
87
101
  // The structured answer, or throws with what went wrong.
88
- export const runClaude: ClaudeRunner = (run) =>
89
- new Promise((resolve, reject) => {
102
+ export const runClaude: ClaudeRunner = async (run) => {
103
+ const routed =
104
+ run.role && !run.model
105
+ ? await routedRun(run.role, { workspaceId: run.workspaceId })
106
+ : null;
107
+ const result = await execClaude(
108
+ { ...run, model: run.model ?? routed?.model },
109
+ { ...checkEnv(), ...routed?.env },
110
+ routed?.provider ?? "anthropic"
111
+ );
112
+ return result.structured_output;
113
+ };
114
+
115
+ function execClaude(
116
+ run: ClaudeRun,
117
+ env: NodeJS.ProcessEnv,
118
+ provider: string
119
+ ): Promise<ClaudeResult> {
120
+ return new Promise((resolve, reject) => {
90
121
  const child = spawn("claude", claudeArgs(run), {
91
122
  cwd: run.cwd,
92
- env: checkEnv(),
123
+ env,
93
124
  stdio: ["pipe", "pipe", "pipe"],
94
125
  });
95
126
  let out = "";
@@ -110,18 +141,21 @@ export const runClaude: ClaudeRunner = (run) =>
110
141
  child.on("close", () => {
111
142
  clearTimeout(timer);
112
143
  try {
113
- const result = JSON.parse(out) as {
114
- is_error?: boolean;
115
- result?: string;
116
- structured_output?: unknown;
117
- };
144
+ const result = JSON.parse(out) as ClaudeResult;
145
+ const usage = runUsageOf(result);
146
+ if (usage) run.onUsage?.({ ...usage, provider });
118
147
  if (result.is_error || result.structured_output === undefined)
119
148
  throw new Error(result.result || "No answer");
120
- resolve(result.structured_output);
149
+ resolve(result);
121
150
  } catch (e) {
122
151
  const why = e instanceof Error ? e.message : String(e);
123
- reject(new Error(`claude -p failed: ${why} ${err.slice(-300)}`.trim()));
152
+ reject(
153
+ new Error(
154
+ redact(`claude -p failed: ${why} ${err.slice(-300)}`.trim())
155
+ )
156
+ );
124
157
  }
125
158
  });
126
159
  child.stdin.end(run.prompt);
127
160
  });
161
+ }
@@ -93,7 +93,7 @@ const SENSITIVE: { why: string; test: RegExp }[] = [
93
93
  // What merges check a PR's code review with: a change that weakens it
94
94
  // must not merge through it.
95
95
  why: "the code review gate",
96
- test: /^(lib\/tasks\/code-review\.ts|scripts\/check-code-review\.ts)$/i,
96
+ test: /^lib\/tasks\/code-review\.ts$/i,
97
97
  },
98
98
  {
99
99
  why: "secrets handling",
@@ -12,11 +12,11 @@
12
12
 
13
13
  import { MERGED_AS, mergePolicy } from "../tasks/merge-policy";
14
14
  import { mergePR } from "../tasks/merge-pr";
15
- import { attributionIn, codeReviewRefusal } from "../tasks/code-review";
15
+ import { attributionIn, codeReviewGate } from "../tasks/code-review";
16
16
  import { run } from "../tasks/gh";
17
17
  import { refundApproval, spendApproval } from "./ask-approvals";
18
18
  import { resolveAsks } from "./asks";
19
- import { clearStaleRunning, getCheck, type CheckRow } from "./checks";
19
+ import { clearStaleRunning, getCheck } from "./checks";
20
20
  import { gateTarget, type ExternalPR } from "./external-pr";
21
21
  import { holdingEscalations } from "./gates";
22
22
  import { addNote } from "./notes";
@@ -39,44 +39,6 @@ const refsOf = (ext: ExternalPR) => ({
39
39
  branch_name: ext.branch,
40
40
  });
41
41
 
42
- // The code-review gate: a section in the body must cover the head like a
43
- // task's; with none, the independent review of the head stands in.
44
- export function externalCodeReview(
45
- ext: Pick<ExternalPR, "name" | "body" | "pr">,
46
- sha: string,
47
- review: CheckRow | null
48
- ): { refusal: string | null; wait: string | null } {
49
- const attribution = attributionIn(ext.body);
50
- if (attribution)
51
- return {
52
- refusal: `the PR body carries AI attribution (${attribution}): remove it with \`gh pr edit --body-file\``,
53
- wait: null,
54
- };
55
- const section = ext.pr.codeReview;
56
- if (section !== null)
57
- return {
58
- refusal: codeReviewRefusal(
59
- section,
60
- sha,
61
- null,
62
- `${ext.name}'s "Code review" section has to name ${short(sha)} on a "Reviewed:" line, or come out of the body`
63
- ),
64
- wait: null,
65
- };
66
- const none = `${ext.name}'s body has no "Code review" section; for a PR AgentOS didn't open, the orchestrator's own review of ${short(sha)} stands in for it`;
67
- if (review?.sha === sha && review.status === "pass")
68
- return { refusal: null, wait: null };
69
- return {
70
- refusal: null,
71
- wait:
72
- review?.sha === sha && review.status === "running"
73
- ? `${none}, and it's still running`
74
- : review?.sha === sha && review.status === "block"
75
- ? `${none}, and it has blocking findings`
76
- : `${none}: call review`,
77
- };
78
- }
79
-
80
42
  export async function judgeExternal(
81
43
  workspaceId: string,
82
44
  ext: ExternalPR,
@@ -102,7 +64,6 @@ export async function judgeExternal(
102
64
  },
103
65
  async () => {
104
66
  const review = getCheck(ext.id, sha, "review");
105
- const code = externalCodeReview(ext, sha, review);
106
67
  const label = ext.labels.find((l) => BLOCKED_LABEL.test(l));
107
68
  return {
108
69
  checks: pr.checks,
@@ -124,8 +85,12 @@ export async function judgeExternal(
124
85
  fromCard: false,
125
86
  scope: null,
126
87
  stackRefusal: null,
127
- codeReviewRefusal: code.refusal,
128
- codeReviewWait: code.wait,
88
+ // The same gate as a task's: the review of this exact head.
89
+ codeReview: codeReviewGate({
90
+ head: sha,
91
+ attribution: attributionIn(ext.body) ?? null,
92
+ review: (at) => getCheck(ext.id, at, "review"),
93
+ }),
129
94
  };
130
95
  },
131
96
  opts
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { db } from "../db";
14
+ import type { CodeReviewGate } from "../tasks/code-review";
14
15
  import type { ChecksVerdict } from "../tasks/state";
15
16
  import type { CheckRow } from "./checks";
16
17
  import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
@@ -45,11 +46,9 @@ export interface GateInput {
45
46
  fromCard: boolean;
46
47
  scope: CheckRow | null;
47
48
  stackRefusal: string | null;
48
- // Why the PR body's Code review section doesn't cover this commit.
49
- codeReviewRefusal: string | null;
50
- // Why it isn't covered yet but will be (an external PR whose own review
51
- // of this commit stands in for the section).
52
- codeReviewWait?: string | null;
49
+ // Whether a passing independent review covers this commit, and the body
50
+ // carries no AI attribution (tasks/code-review.ts).
51
+ codeReview: CodeReviewGate;
53
52
  // A PR no task owns has no session to read: a draft isn't ready yet, and
54
53
  // a label saying it's blocked holds it.
55
54
  prDraft?: string | null;
@@ -147,11 +146,12 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
147
146
  const stack: GateOutcome = i.stackRefusal
148
147
  ? { gate: "stack", state: "fail", reason: i.stackRefusal }
149
148
  : { gate: "stack", state: "pass" };
150
- const codeReview: GateOutcome = i.codeReviewRefusal
151
- ? { gate: "code-review", state: "fail", reason: i.codeReviewRefusal }
152
- : i.codeReviewWait
153
- ? { gate: "code-review", state: "wait", reason: i.codeReviewWait }
154
- : { gate: "code-review", state: "pass" };
149
+ // Its wait is the review gate's own (no passing review of this exact
150
+ // head), which already holds the merge: only paths without that gate (an
151
+ // approval, a stack's land) read it.
152
+ const codeReview: GateOutcome = i.codeReview.refusal
153
+ ? { gate: "code-review", state: "fail", reason: i.codeReview.refusal }
154
+ : { gate: "code-review", state: "pass" };
155
155
  return [
156
156
  ciGate(i),
157
157
  checkGate("review", i.review, i.sha),
@@ -5,12 +5,12 @@
5
5
  * sessions.
6
6
  */
7
7
 
8
+ import { resolveRoleOrDefault } from "../models/resolve";
8
9
  import fs from "fs";
9
10
  import os from "os";
10
11
  import path from "path";
11
12
  import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
12
13
  import { db, type Session } from "../db";
13
- import { resolveModelForAgent } from "../model-catalog";
14
14
  import { getWorkspace } from "../workspaces";
15
15
 
16
16
  // It works through tools, not in a repo: a scratch folder of its own.
@@ -62,7 +62,7 @@ export function ensureOrchestrator(workspaceId: string): Session {
62
62
  `${workspace.name} orchestrator`,
63
63
  `claude-${id}`,
64
64
  dir,
65
- resolveModelForAgent("claude", null),
65
+ resolveRoleOrDefault("orchestrator", { workspaceId }).model,
66
66
  workspaceId
67
67
  );
68
68
  return getOrchestrator(workspaceId)!;
@@ -272,7 +272,7 @@ export function checkMessage(
272
272
  : pr.failingUrl
273
273
  ? `\n\nIts details: ${untrusted("CI", pr.failingUrl)}`
274
274
  : "";
275
- return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, push, then run /do-code-review again and update the Reviewed: line in the PR body for the new head.`;
275
+ return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, then push.`;
276
276
  }
277
277
 
278
278
  // True once the PR's activity at this fingerprint has been handled.
@@ -374,7 +374,7 @@ export function reviewMessage(pr: TaskPR, events: PREvent[]): string {
374
374
  : "";
375
375
  return `- ${e.author} ${what}${body}${notes}${more}`;
376
376
  });
377
- return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, push, then run /do-code-review again and update the Reviewed: line for the new head. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
377
+ return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, then push. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
378
378
  }
379
379
 
380
380
  // Tests only.