@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
@@ -8,11 +8,10 @@
8
8
  * only while its agent is idle with nothing uncommitted, and pushed to the
9
9
  * task's own branch with a lease on the commit fetched (the restack's code,
10
10
  * stacks/git.ts). The base itself and every other branch are never written.
11
- * The heads are recorded so the task's Code review section still covers
12
- * the new head (tasks/code-review.ts), and the section gets a "Restacked:"
13
- * line for CI. When the PR's own patch is unchanged (same `git patch-id`
14
- * against the base before and after), the orchestrator's review of the old
15
- * head carries over; otherwise a fresh one starts.
11
+ * The heads are recorded, and when the PR's own patch is unchanged (same
12
+ * `git patch-id` against the base before and after), the orchestrator's
13
+ * review of the old head carries over to the new one (tasks/code-review.ts);
14
+ * otherwise a fresh one starts.
16
15
  *
17
16
  * Not clean, or not rebasable (uncommitted work, commits only on origin):
18
17
  * the task is asked once, per head, to rebase by hand. The orchestrator
@@ -28,7 +27,6 @@ import { hostLink } from "../hosts/remote-api";
28
27
  import type { TaskPR } from "../tasks/state";
29
28
  import { run } from "../tasks/gh";
30
29
  import { git as runGit, gitFetch, revParse } from "../git-exec";
31
- import { withRestackedLine } from "../tasks/code-review";
32
30
  import { outputOf, type Runner } from "../stacks/git";
33
31
  import { restackedHeads, stackNotice } from "../stacks/restack";
34
32
  import { getCheck, putCheck } from "./checks";
@@ -53,37 +51,6 @@ export interface RebaseDeps {
53
51
  pr: TaskPR,
54
52
  sha: string
55
53
  ) => Promise<unknown>;
56
- // Add the Restacked line to the PR body's Code review section.
57
- editBody: (
58
- repo: string,
59
- pr: number,
60
- from: string,
61
- to: string
62
- ) => Promise<void>;
63
- }
64
-
65
- async function editBody(repo: string, pr: number, from: string, to: string) {
66
- const body = await run(
67
- "gh",
68
- ["pr", "view", String(pr), "--json", "body", "--jq", ".body"],
69
- repo,
70
- 15000
71
- );
72
- const next = withRestackedLine(body.replace(/\n$/, ""), from, to);
73
- if (!next) return;
74
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-body-"));
75
- const file = path.join(dir, "body.md");
76
- try {
77
- fs.writeFileSync(file, next);
78
- await run(
79
- "gh",
80
- ["pr", "edit", String(pr), "--body-file", file],
81
- repo,
82
- 30000
83
- );
84
- } finally {
85
- fs.rmSync(dir, { recursive: true, force: true });
86
- }
87
54
  }
88
55
 
89
56
  const defaults: RebaseDeps = {
@@ -94,7 +61,6 @@ const defaults: RebaseDeps = {
94
61
  idle: async (task) => (await statusOf(task)).status === "idle",
95
62
  review: (w, task, pr, sha) =>
96
63
  reviewAt(w, taskReviewTarget(task, { ...pr, head: sha }), sha),
97
- editBody,
98
64
  };
99
65
 
100
66
  // The (head, base tip) pairs already rebased or found not to need it, so a
@@ -263,11 +229,6 @@ async function rebaseOne(
263
229
  deps
264
230
  );
265
231
  }
266
- if (heads.restacked_from && heads.restacked_to)
267
- await deps
268
- .editBody(repo, pr.number, heads.restacked_from, heads.restacked_to)
269
- .catch(() => {});
270
-
271
232
  // The task's worktree follows while it's still idle and untouched;
272
233
  // otherwise the task is told how to catch up.
273
234
  const follows =
@@ -277,7 +238,7 @@ async function rebaseOne(
277
238
  if (!follows)
278
239
  await deps.send(
279
240
  task.id,
280
- `AgentOS rebased your branch ${task.branch_name} onto ${branch} on origin (now ${short(to)}), because PR #${pr.number} conflicted with it. Your worktree is still on the old head: before changing anything, run \`git fetch origin && git reset --keep origin/${task.branch_name}\` (commit your work first). Your Code review section still covers it.`
241
+ `AgentOS rebased your branch ${task.branch_name} onto ${branch} on origin (now ${short(to)}), because PR #${pr.number} conflicted with it. Your worktree is still on the old head: before changing anything, run \`git fetch origin && git reset --keep origin/${task.branch_name}\` (commit your work first). Its review carries over when its own patch didn't change.`
281
242
  );
282
243
  const reviewed = await carryReview(workspaceId, task, pr, onto, to, deps);
283
244
  queueEvent(
@@ -422,6 +383,6 @@ async function askToRebase(
422
383
  );
423
384
  await deps.send(
424
385
  task.id,
425
- `Your PR #${pr.number} conflicts with ${base}, and AgentOS couldn't rebase it for you: ${why.slice(0, 300)}. At your next stopping point: commit your work, run \`git fetch origin && git rebase origin/${base}\`, resolve the conflicts, re-run your checks, push with \`git push --force-with-lease\`, then run /do-code-review again and update the Code review section for the new head.`
386
+ `Your PR #${pr.number} conflicts with ${base}, and AgentOS couldn't rebase it for you: ${why.slice(0, 300)}. At your next stopping point: commit your work, run \`git fetch origin && git rebase origin/${base}\`, resolve the conflicts, re-run your checks, push with \`git push --force-with-lease\`. The orchestrator reviews the new head.`
426
387
  );
427
388
  }
@@ -38,6 +38,11 @@ import {
38
38
  toVerdict,
39
39
  } from "./review-prompt";
40
40
  import { checkScope } from "./scope";
41
+ import {
42
+ checkRunRecorder,
43
+ checkRunTotals,
44
+ describeCheckRuns,
45
+ } from "../usage/check-runs";
41
46
  import { workspaceTask } from "./targets";
42
47
  import { untrusted } from "./untrusted";
43
48
 
@@ -52,10 +57,8 @@ export function describeReview(row: CheckRow): string {
52
57
  if (row.status === "running") return `The review of ${at} is still running.`;
53
58
  if (row.status === "error")
54
59
  return `The review of ${at} couldn't run: ${row.detail}`;
55
- const head =
56
- row.status === "pass"
57
- ? `Review of ${at}: pass.`
58
- : `Review of ${at}: blocking findings.`;
60
+ const cost = checkRunTotals(row.session_id, row.sha);
61
+ const head = `${row.status === "pass" ? `Review of ${at}: pass.` : `Review of ${at}: blocking findings.`}${cost ? ` (${describeCheckRuns(cost)})` : ""}`;
59
62
  return row.detail ? `${head}\n${untrusted("reviewer", row.detail)}` : head;
60
63
  }
61
64
 
@@ -115,6 +118,14 @@ async function reviewJob(
115
118
  const amendments = task ? amendmentsOf(task.id).map((a) => a.text) : [];
116
119
  const scope = task ? { amendments, scopeClaim: pr.scopeChange ?? null } : {};
117
120
  const dir = await checkout(repo, sha);
121
+ const onUsage = checkRunRecorder({
122
+ workspaceId,
123
+ sessionId: target.id,
124
+ label: target.label,
125
+ prNumber: pr.number,
126
+ sha,
127
+ kind: "review",
128
+ });
118
129
  try {
119
130
  const verdicts = [];
120
131
  for (const [i, part] of parts.entries()) {
@@ -143,6 +154,9 @@ async function reviewJob(
143
154
  schema: REVIEW_SCHEMA,
144
155
  tools: ["Read", "Grep", "Glob"],
145
156
  allow: [`Read(/${dir}/**)`, `Grep(/${dir}/**)`, `Glob(/${dir}/**)`],
157
+ onUsage,
158
+ role: "review",
159
+ workspaceId,
146
160
  });
147
161
  verdicts.push(toVerdict(answer));
148
162
  }
@@ -246,11 +260,12 @@ export async function reviewAt(
246
260
  : done.status === "block"
247
261
  ? "found blocking issues"
248
262
  : "couldn't run";
263
+ const cost = checkRunTotals(target.id, sha);
249
264
  queueEvent(
250
265
  workspaceId,
251
266
  `review:${target.id}:${sha}`,
252
267
  target.id,
253
- `${target.label}: review of ${short(sha)} ${said}${countVerdicts(workspaceId, target, sha)}`
268
+ `${target.label}: review of ${short(sha)} ${said}${cost ? ` (${describeCheckRuns(cost)})` : ""}${countVerdicts(workspaceId, target, sha)}`
254
269
  );
255
270
  return done;
256
271
  });
@@ -8,6 +8,7 @@ import { connectedClient } from "../lumifyhub/connection";
8
8
  import { promptFromCard } from "../lumifyhub/task-cards";
9
9
  import { putCheck, type CheckRow } from "./checks";
10
10
  import type { ClaudeRunner } from "./claude-cli";
11
+ import { checkRunRecorder } from "../usage/check-runs";
11
12
  import type { ChangedFile } from "./diff";
12
13
  import { fence } from "./review-prompt";
13
14
 
@@ -78,6 +79,16 @@ export async function checkScope(input: {
78
79
  prompt: `The card, as data:\n${fence("card", card)}${amended}\n\nFiles changed:\n${list}\n\n${label}\n${fence("diff", part)}`,
79
80
  schema: SCOPE_SCHEMA,
80
81
  tools: [],
82
+ onUsage: checkRunRecorder({
83
+ workspaceId,
84
+ sessionId: task.id,
85
+ label: `task ${task.name}`,
86
+ prNumber: task.pr_number ?? null,
87
+ sha,
88
+ kind: "scope",
89
+ }),
90
+ role: "scope",
91
+ workspaceId,
81
92
  })) as { within?: boolean; reason?: string };
82
93
  if (answer.within !== true) break;
83
94
  }
@@ -1,3 +1,5 @@
1
+ import { resumeOnModel } from "../models/resume";
2
+ import { workspaceTask } from "./targets";
1
3
  import { getWorkspace } from "../workspaces";
2
4
  import {
3
5
  done,
@@ -33,6 +35,7 @@ const ACTING = new Set<ToolName>([
33
35
  "send",
34
36
  "message_orchestrator",
35
37
  "start_task",
38
+ "set_task_model",
36
39
  "start_session",
37
40
  "stack",
38
41
  "land",
@@ -97,8 +100,18 @@ export async function runTool(
97
100
  name: a.name,
98
101
  view: a.view,
99
102
  after: a.after,
103
+ model: a.model,
104
+ plan: a.plan,
100
105
  });
101
106
  }
107
+ case "set_task_model": {
108
+ const a = p(tool, raw);
109
+ const task = workspaceTask(w, a.task);
110
+ const out = await resumeOnModel(task.id, a.model, a.message);
111
+ return out.resumed
112
+ ? `Resumed ${task.name} on ${out.ref}: same conversation and worktree.`
113
+ : `${task.name} hasn't launched yet; it starts on ${out.ref}.`;
114
+ }
102
115
  case "start_session": {
103
116
  const a = p(tool, raw);
104
117
  return startSession(w, a.project, a.prompt, {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
11
11
  import { prFor } from "../tasks/session";
12
- import { codeReviewRefusal, signOffTask } from "../tasks";
12
+ import { codeReviewGate, signOffTask } from "../tasks";
13
13
  import { signOffRefusal } from "../stacks/guard";
14
14
  import { getStack, landInBackground } from "../stacks";
15
15
  import { LAND_DEFAULTS } from "../stacks/land";
@@ -91,11 +91,12 @@ export async function judge(
91
91
  fromCard: !!task.lh_card_id,
92
92
  scope: getCheck(task.id, sha, "scope"),
93
93
  stackRefusal: landing ? null : signOffRefusal(task.id),
94
- codeReviewRefusal: codeReviewRefusal(
95
- pr.codeReview,
96
- sha,
97
- restackedOf(task)
98
- ),
94
+ codeReview: codeReviewGate({
95
+ head: sha,
96
+ attribution: pr.attribution,
97
+ review: (at) => getCheck(task.id, at, "review"),
98
+ restacked: restackedOf(task),
99
+ }),
99
100
  }),
100
101
  opts
101
102
  );
@@ -129,7 +130,8 @@ export async function gateVerdict(
129
130
  const held = holdingEscalations(work.id);
130
131
  // Saad approved merging this exact commit. On an ask the orchestrator
131
132
  // raised (ask_saad with its task and sha), held or not, it merges once
132
- // whatever the gates say, but only with a code review of that commit and
133
+ // whatever the gates say, but only once an independent review of that
134
+ // commit finished (its findings are his to override, not that it ran) and
133
135
  // its stack parent merged: the orchestrator wrote that ask, and no answer
134
136
  // to it stands in for those. An approval of the gates' own ask on a held
135
137
  // task merges as it always has (heldVerdict).
@@ -139,7 +141,10 @@ export async function gateVerdict(
139
141
  (approval.raised_by === "orchestrator" || !held.length)
140
142
  ) {
141
143
  const i = await inputs();
142
- const refusal = i.codeReviewRefusal ?? i.stackRefusal;
144
+ const refusal =
145
+ i.codeReview.refusal ??
146
+ (i.codeReview.ran ? null : i.codeReview.wait) ??
147
+ i.stackRefusal;
143
148
  if (refusal)
144
149
  return no(
145
150
  `${work.name} (PR #${pr.number} at ${short(sha)}): Saad approved this commit, but ${refusal}`
@@ -14,6 +14,7 @@ export const TOOL_NAMES = {
14
14
  orchestrators: name("orchestrators"),
15
15
  message_orchestrator: name("message_orchestrator"),
16
16
  start_task: name("start_task"),
17
+ set_task_model: name("set_task_model"),
17
18
  start_session: name("start_session"),
18
19
  stack: name("stack"),
19
20
  stack_status: name("stack_status"),
@@ -6,6 +6,7 @@ import { BRANCH_NAME } from "../git";
6
6
  import { RAISED_KINDS } from "./asks";
7
7
  import { MAX_RULE_CHARS } from "./rules";
8
8
  import { PROJECT_NAME } from "../project-add/name";
9
+ import { TASK_ROLES, type ModelRole } from "../models/roles";
9
10
 
10
11
  const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
11
12
  const session = ref("Session name, project/name, or id from sessions");
@@ -19,6 +20,23 @@ const view = z
19
20
  "How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
20
21
  );
21
22
 
23
+ const modelRef = z
24
+ .string()
25
+ .trim()
26
+ .regex(/^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i, 'not "provider/model"');
27
+ const modelOverride = z
28
+ .union([
29
+ modelRef,
30
+ z.partialRecord(
31
+ z.enum(TASK_ROLES as [ModelRole, ...ModelRole[]]),
32
+ modelRef
33
+ ),
34
+ ])
35
+ .optional()
36
+ .describe(
37
+ "Its own model, as \"provider/model\" (anthropic/claude-sonnet-5-5, deepseek/deepseek-v4-pro) for all its roles, or a map of role (plan, implement, review, scope) to one. Default: the workspace's and Settings' role models; another provider only when asked"
38
+ );
39
+
22
40
  export const TOOL_SHAPES = {
23
41
  sessions: {},
24
42
  read: {
@@ -79,6 +97,26 @@ export const TOOL_SHAPES = {
79
97
  .describe(
80
98
  'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first'
81
99
  ),
100
+ model: modelOverride,
101
+ plan: z
102
+ .boolean()
103
+ .optional()
104
+ .describe(
105
+ "Plan first on the planning model, then implement on the implementation model (default: Settings)"
106
+ ),
107
+ },
108
+ set_task_model: {
109
+ task,
110
+ model: modelOverride.unwrap(),
111
+ message: z
112
+ .string()
113
+ .trim()
114
+ .min(1)
115
+ .max(8000)
116
+ .optional()
117
+ .describe(
118
+ "What it's told on resuming (default: carry on where it left off)"
119
+ ),
82
120
  },
83
121
  start_session: {
84
122
  project,
@@ -61,7 +61,9 @@ const DESCRIPTIONS: Record<ToolName, string> = {
61
61
  message_orchestrator:
62
62
  "Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
63
63
  start_task:
64
- "Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Refused while a brake holds.",
64
+ "Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Refused while a brake holds.",
65
+ set_task_model:
66
+ 'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
65
67
  start_session:
66
68
  "Start an interactive agent session in a project with a prompt. It runs as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.",
67
69
  stack:
@@ -1,3 +1,4 @@
1
+ import { routedRun } from "./models/launch";
1
2
  import { execFile } from "child_process";
2
3
  import { git as runGit } from "./git-exec";
3
4
  import { run } from "./tasks/gh";
@@ -154,13 +155,15 @@ async function generateWithClaude(
154
155
 
155
156
  // `claude --print` with the prompt on stdin, off the event loop and killed
156
157
  // at the ceiling.
157
- function claudePrint(cwd: string, prompt: string): Promise<string> {
158
+ async function claudePrint(cwd: string, prompt: string): Promise<string> {
159
+ const routed = await routedRun("helper");
158
160
  return new Promise((resolve, reject) => {
159
161
  const child = execFile(
160
162
  "claude",
161
- ["--print"],
163
+ ["--print", "--model", routed.model],
162
164
  {
163
165
  cwd,
166
+ env: { ...process.env, ...routed.env },
164
167
  encoding: "utf-8",
165
168
  timeout: 30_000,
166
169
  killSignal: "SIGKILL",
@@ -73,7 +73,7 @@ export const PROVIDERS: ProviderDefinition[] = [
73
73
  supportsFork: true,
74
74
  resumeArgs: (id) => ["--resume", id],
75
75
  forkArgs: (id) => ["--resume", id, "--fork-session"],
76
- modelFlag: undefined, // Claude doesn't expose model flag
76
+ modelFlag: "--model",
77
77
  initialPromptFlag: "", // Positional argument
78
78
  },
79
79
  {
@@ -427,7 +427,7 @@ export function renderSummary(
427
427
  )
428
428
  );
429
429
  out.push(
430
- "\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript; reviews are counted from the checks above and from do-code-review subagents."
430
+ "\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript; each run's tokens are recorded in `check_runs` and shown in Usage."
431
431
  );
432
432
  return out.join("\n") + "\n";
433
433
  }
@@ -174,7 +174,7 @@ export async function generateTitle(
174
174
  prompt: input,
175
175
  schema: SCHEMA,
176
176
  tools: [],
177
- model: process.env.AGENTOS_TITLE_MODEL || "haiku",
177
+ role: "helper",
178
178
  timeoutMs: 30_000,
179
179
  })) as { title?: unknown };
180
180
  return cleanTitle(out?.title);
@@ -11,6 +11,8 @@ import { db, queries, type Session } from "../db";
11
11
  import type { AgentType } from "../providers";
12
12
  import type { ChatAccess, ChatImage } from "../chat/events";
13
13
  import { resolveModelForAgent } from "../model-catalog";
14
+ import { resolveRole, setSessionModel } from "../models/resolve";
15
+ import { ANTHROPIC } from "../models/roles";
14
16
  import { getProject } from "../projects";
15
17
  import { getHost, isRemoteHost } from "../hosts";
16
18
  import { hostLink } from "../hosts/remote-api";
@@ -214,14 +216,26 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
214
216
  await fs.promises.mkdir(SCRATCH_DIR, { recursive: true });
215
217
 
216
218
  const agentType = input.agentType;
217
- const model = resolveModelForAgent(
218
- agentType,
219
- input.model?.trim() || project?.default_model
220
- );
221
219
  const view =
222
220
  input.view === "terminal" || !supportsChat(agentType) || remote
223
221
  ? "terminal"
224
222
  : DEFAULT_START_VIEW;
223
+ // Claude with no model picked runs on the interactive role's, here; on
224
+ // another provider only as a chat, whose worker carries its endpoint.
225
+ const routed =
226
+ agentType === "claude" && !input.model?.trim() && !remote
227
+ ? resolveRole("interactive", { workspaceId: project?.workspace_id })
228
+ : null;
229
+ const interactive =
230
+ routed && (routed.provider.id === ANTHROPIC || view === "chat")
231
+ ? routed
232
+ : null;
233
+ const model = interactive
234
+ ? interactive.model
235
+ : resolveModelForAgent(
236
+ agentType,
237
+ input.model?.trim() || project?.default_model
238
+ );
225
239
  const text = input.prompt?.trim() ?? "";
226
240
  const naming: Naming = text
227
241
  ? await nameFor(text, projectPath, input.name)
@@ -252,6 +266,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
252
266
  db.prepare(
253
267
  `UPDATE sessions SET view = ?, name_source = ?, setup_status = ? WHERE id = ?`
254
268
  ).run(view, naming.source, useWorktree ? "running" : null, id);
269
+ if (interactive) setSessionModel(id, interactive);
255
270
  if (input.access)
256
271
  db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
257
272
  input.access,
@@ -7,6 +7,7 @@ export const SETTINGS_PARAM = "settings";
7
7
  export const SETTINGS_SECTIONS = [
8
8
  "merging",
9
9
  "workspaces",
10
+ "models",
10
11
  "devices",
11
12
  "notifications",
12
13
  "schedules",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Land a stack: merge every PR bottom-up, ported from dispatch's `land`.
3
- * A preflight checks EVERY PR before anything merges: CI, and a Code review
4
- * section in its body for its head commit. Each sign-off restacks
3
+ * A preflight checks EVERY PR before anything merges: CI, and a passing
4
+ * independent review of its head commit (tasks/code-review.ts). Each sign-off restacks
5
5
  * what sits on it, so a restacked PR's checks are waited out again (its green
6
6
  * was against the parent's branch). It stops at the first failure and says
7
7
  * what landed. A chat item's agent is stopped between turns before anything
@@ -17,6 +17,8 @@ import {
17
17
  codeReviewRefusal,
18
18
  type TaskPR,
19
19
  } from "../tasks";
20
+ import type { ReviewState } from "../tasks/code-review";
21
+ import { getCheck } from "../orchestrator/checks";
20
22
  import { itemName } from "./guard";
21
23
  import { refreshItems } from "./tick";
22
24
  import { outputOf } from "./git";
@@ -34,6 +36,8 @@ export interface LandDeps {
34
36
  signOff: (sessionId: string, head?: string) => Promise<void>;
35
37
  beforeMerge?: MergeGate;
36
38
  prOf: (session: Session) => Promise<TaskPR | null>;
39
+ // The independent review of a task at a commit.
40
+ reviewOf: (sessionId: string, sha: string) => ReviewState | null;
37
41
  sleep: (ms: number) => Promise<void>;
38
42
  refresh: (stackId: string) => Promise<void>;
39
43
  waitMs: number;
@@ -52,6 +56,7 @@ export const LAND_DEFAULTS: LandDeps = {
52
56
  await signOffTask(id, { wait: true, head });
53
57
  },
54
58
  prOf: (s) => taskPR(s, true),
59
+ reviewOf: (id, sha) => getCheck(id, sha, "review"),
55
60
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
56
61
  refresh: refreshItems,
57
62
  waitMs: 45 * 60_000,
@@ -90,8 +95,20 @@ async function waitGreen(
90
95
  }
91
96
  }
92
97
 
98
+ // Why an item can't merge on its review: none passed of its head, or of
99
+ // the head the task pushed before a restack moved it.
100
+ function unreviewed(item: StackItemRow, pr: TaskPR, deps: LandDeps) {
101
+ return codeReviewRefusal({
102
+ head: pr.head,
103
+ attribution: pr.attribution,
104
+ review: (sha) =>
105
+ item.session_id ? deps.reviewOf(item.session_id, sha) : null,
106
+ restacked: { from: item.restacked_from, to: item.restacked_to },
107
+ });
108
+ }
109
+
93
110
  // Without the orchestrator's gate: the item's head right before merging,
94
- // only if its Code review section covers that commit. A push since the
111
+ // only if a passing review covers that commit. A push since the
95
112
  // preflight needs a new review; the merge is pinned to this head.
96
113
  async function reviewedHead(
97
114
  item: StackItemRow,
@@ -100,12 +117,7 @@ async function reviewedHead(
100
117
  const now = q.item(db, item.id) ?? item;
101
118
  const session = sessionOf(now.session_id);
102
119
  const pr = session ? await deps.prOf(session) : null;
103
- const refusal = pr
104
- ? codeReviewRefusal(pr.codeReview, pr.head, {
105
- from: now.restacked_from,
106
- to: now.restacked_to,
107
- })
108
- : "its PR is gone";
120
+ const refusal = pr ? unreviewed(now, pr, deps) : "its PR is gone";
109
121
  if (refusal) throw new Stop(`stopped at ${itemName(now)}: ${refusal}`);
110
122
  return pr!.head!;
111
123
  }
@@ -157,14 +169,9 @@ export async function landStack(
157
169
  const pr =
158
170
  session && item.status === "pr" ? await deps.prOf(session) : null;
159
171
  const verdict = canSignOff(pr);
160
- const unreviewed =
161
- pr &&
162
- codeReviewRefusal(pr.codeReview, pr.head, {
163
- from: item.restacked_from,
164
- to: item.restacked_to,
165
- });
172
+ const refusal = pr && unreviewed(item, pr, deps);
166
173
  if (!verdict.ok) missing.push(`${itemName(item)}: ${verdict.reason}`);
167
- else if (unreviewed) missing.push(`${itemName(item)}: ${unreviewed}`);
174
+ else if (refusal) missing.push(`${itemName(item)}: ${refusal}`);
168
175
  else if (item.error) missing.push(`${itemName(item)}: ${item.error}`);
169
176
  hadChecks.set(item.id, pr?.checks !== "none");
170
177
  }
@@ -16,18 +16,9 @@ function stackedLines(baseBranch: string, stack: StackedOn): string {
16
16
  return `
17
17
  - This task is STACKED on ${stack.name} (${on}), which has not merged. Its commits are already in your branch and are not yours: don't change or revert them.
18
18
  - Open your pull request against ${baseBranch} (\`gh pr create --base ${baseBranch}\`) so its diff is only your commits, and start the body with the line "Stacked on ${on} (${stack.name})".
19
- - Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. Your code review still counts for the head AgentOS's restack left; if you push after that, review again and update the section.${also}`;
19
+ - Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. AgentOS reviews your PR itself; a review of your head still counts for the head its restack left, and a push after that gets a new one.${also}`;
20
20
  }
21
21
 
22
- // What the PR body's review section looks like. lib/tasks/code-review.ts
23
- // parses it, and merges refuse a PR without one for its head commit.
24
- export const CODE_REVIEW_SECTION = `
25
- ## Code review
26
- Reviewed: <the full sha of the commit you reviewed, which is the PR's head>
27
- Agents: <the review agents that ran>
28
- Fixed: <each finding fixed, one line each, or "none">
29
- Deferred: <each finding not fixed and why, or "none">`;
30
-
31
22
  // The system brief every task agent runs under. It finishes by opening a PR
32
23
  // and stopping: merging is the human's sign-off, never the agent's.
33
24
  export function buildTaskBrief(opts: {
@@ -39,10 +30,8 @@ export function buildTaskBrief(opts: {
39
30
 
40
31
  - Branch: ${opts.branch} (based on ${opts.baseBranch}). Stay in this directory and on this branch.
41
32
  - Do the task fully. Run the project's own checks (typecheck, tests, lint) when they exist and keep them passing.
42
- - When the work is done, commit with a conventional commit message, then review it before any pull request: run \`/do-code-review\` (the project's skill in .claude/skills/do-code-review; if the project has none, \`/code-review\`). Fix every Blocking and High finding, commit, and review again until none are left (two rounds at most; past that, list what's still open in the PR body).
43
- - Then run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it, and ends with this section:
44
- ${CODE_REVIEW_SECTION}
45
- Merges are refused without it, and when its "Reviewed:" commit is not the PR's head: if you push again, review again and update the body (\`gh pr edit --body-file\`). Then stop and wait.
33
+ - When the work is done, commit with a conventional commit message, run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it. Then stop and wait.
34
+ - You don't need to review it yourself: AgentOS runs an independent review of the PR's exact head commit before it can merge, and the orchestrator sends you any blocking findings to fix. For a risky change you may still run \`/do-code-review\` (or \`/code-review\`) before the PR.
46
35
  - Never merge the pull request, never push to ${opts.baseBranch}, and never delete branches or worktrees. A human reviews and merges.${opts.stack ? stackedLines(opts.baseBranch, opts.stack) : ""}
47
36
  - Write scratch files (PR bodies, review notes, logs, test output) outside the worktree, in your scratchpad or \$TMPDIR. Anything uncommitted left in the worktree keeps it from being removed when the task is done.
48
37
  - Do not add any AI or assistant attribution to commits or the pull request.