@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
@@ -1,27 +1,13 @@
1
- // The "Code review" section every task's PR body carries (the task brief
2
- // asks for it, after /do-code-review). Merges refuse a PR without one for
3
- // its head commit. No imports: CI runs this file on its own
4
- // (scripts/check-code-review.ts).
5
-
6
- export interface CodeReviewSection {
7
- // The commit the review covered, as written in the section.
8
- sha: string | null;
9
- // A "Restacked: <from> -> <to>" line AgentOS wrote after rebasing the
10
- // branch itself. Only CI reads it: AgentOS's own gates go by what it
11
- // recorded, never by what a body says.
12
- restacked?: Restacked;
13
- // What kind of AI attribution the body carries, when it does: a fixed
14
- // label, never the body's own text, which goes into gate messages.
15
- attribution?: AttributionKind;
16
- }
1
+ // The code-review gate, the same for every PR: a passing independent
2
+ // review (lib/orchestrator/review) of its exact head, and a body with no AI
3
+ // attribution. Tasks don't write a review section in the body any more: the
4
+ // orchestrator's review is the one review.
17
5
 
18
6
  // Matched one line at a time, never across lines: the body is written by
19
7
  // the task, and a regex that backtracks over it would block the server.
20
8
  // Only what renders counts: fenced code and HTML comments are skipped, so an
21
- // example of the section isn't read as the section.
9
+ // example isn't read as the real thing.
22
10
  const MAX_BODY = 65_536;
23
- const HEADING = /^ {0,3}(#{1,6}) *code review *:?$/i;
24
- const ANY_HEADING = /^ {0,3}(#{1,6}) +\S/;
25
11
  const FENCE = /^ {0,3}(`{3,}|~{3,})/;
26
12
  // AI attribution ("Co-Authored-By: Claude", "Generated with [Claude Code]").
27
13
  // The same rule as the commit-msg hook's ATTRIBUTION_ERE in
@@ -45,10 +31,6 @@ function attributionKind(line: string): AttributionKind | undefined {
45
31
  if (/^claude-session:/i.test(line)) return "a Claude-Session link";
46
32
  return 'a "Generated with" AI footer';
47
33
  }
48
- const REVIEWED =
49
- /^[>*_ -]*reviewed(?: (?:commit|sha|at))?[*_ ]*:[*_` ]*([0-9a-f]{12,40})\b/i;
50
- const RESTACKED =
51
- /^[>*_ -]*restacked[*_ ]*:[*_` ]*([0-9a-f]{40})[*_` ]*(?:->|→)[*_` ]*([0-9a-f]{40})\b/i;
52
34
 
53
35
  export function renderedLines(body: string): string[] {
54
36
  let text = body.slice(0, MAX_BODY);
@@ -70,45 +52,7 @@ export function renderedLines(body: string): string[] {
70
52
  return lines;
71
53
  }
72
54
 
73
- // The last Code review section that names a commit (or the last one, when
74
- // none does); null when the body has none. A section runs to the next
75
- // heading of its level or higher, so its own subheadings stay inside it.
76
- export function parseCodeReview(
77
- body: string | null | undefined
78
- ): CodeReviewSection | null {
79
- if (!body) return null;
80
- const sections: CodeReviewSection[] = [];
81
- let level = 0;
82
- let attribution: AttributionKind | undefined;
83
- for (const line of renderedLines(body)) {
84
- attribution ??= attributionKind(line.trimStart());
85
- const heading = HEADING.exec(line);
86
- if (heading) {
87
- level = heading[1].length;
88
- sections.push({ sha: null });
89
- continue;
90
- }
91
- const other = ANY_HEADING.exec(line);
92
- if (other && other[1].length <= level) level = 0;
93
- const current = sections.at(-1);
94
- if (!level || !current) continue;
95
- if (current.sha) {
96
- const moved = RESTACKED.exec(line);
97
- if (moved)
98
- current.restacked = {
99
- from: moved[1].toLowerCase(),
100
- to: moved[2].toLowerCase(),
101
- };
102
- continue;
103
- }
104
- const sha = REVIEWED.exec(line)?.[1];
105
- if (sha) current.sha = sha.toLowerCase();
106
- }
107
- const found = sections.findLast((s) => s.sha) ?? sections.at(-1) ?? null;
108
- return found && attribution ? { ...found, attribution } : found;
109
- }
110
-
111
- // The AI attribution a body carries anywhere, Code review section or not.
55
+ // The AI attribution a body carries anywhere.
112
56
  export function attributionIn(
113
57
  body: string | null | undefined
114
58
  ): AttributionKind | undefined {
@@ -119,57 +63,75 @@ export function attributionIn(
119
63
  }
120
64
  }
121
65
 
122
- // A branch AgentOS rebased itself (a stack restack): the head the task
123
- // pushed and reviewed, and the head the restack left.
66
+ // A branch AgentOS rebased itself (a stack restack, or the automatic
67
+ // rebase onto its base): the head the task pushed, and the head AgentOS
68
+ // left.
124
69
  export interface Restacked {
125
70
  from: string | null;
126
71
  to: string | null;
127
72
  }
128
73
 
129
- // Why a PR can't merge on its code review, or null when it can. Fails
130
- // closed: an unread body or an unknown head refuses. A review of the head
131
- // the task pushed still counts after AgentOS restacked it, but only while
132
- // the head is exactly the one the restack left: a push by the task after
133
- // that needs a new review.
134
- export function codeReviewRefusal(
135
- section: CodeReviewSection | null | undefined,
136
- head: string | undefined,
137
- restacked?: Restacked | null,
138
- fix = "the task has to run /do-code-review and put a Code review section naming the reviewed commit in the PR body"
139
- ): string | null {
140
- if (section === undefined) return `the PR body couldn't be read: ${fix}`;
141
- if (!head) return `the PR's head commit is unknown: ${fix}`;
142
- if (!section) return `the PR body has no Code review section: ${fix}`;
143
- if (section.attribution)
144
- return `the PR body carries AI attribution (${section.attribution}): remove it with \`gh pr edit --body-file\``;
145
- if (!section.sha)
146
- return `the PR's Code review section doesn't name the reviewed commit ("Reviewed: <sha>"): ${fix}`;
147
- const covers = (sha: string | null | undefined) =>
148
- !!sha && sha.toLowerCase().startsWith(section.sha!);
149
- if (covers(head)) return null;
150
- if (restacked?.to === head && covers(restacked.from)) return null;
151
- return `the PR's code review covers ${section.sha.slice(0, 7)}, not its head ${head.slice(0, 7)}: ${fix}`;
74
+ export interface ReviewState {
75
+ sha: string;
76
+ status: "running" | "pass" | "block" | "error";
77
+ }
78
+
79
+ export interface CodeReviewGate {
80
+ // Fails the gate: counts against the task.
81
+ refusal: string | null;
82
+ // Not covered yet, but a review will cover it.
83
+ wait: string | null;
84
+ // A review covering the head finished, passing or not. Saad's approval
85
+ // of the commit overrides what it found, never whether one ran.
86
+ ran?: boolean;
152
87
  }
153
88
 
154
- // The body with its Code review section saying AgentOS rebased the
155
- // reviewed head `from` to `to`: a "Restacked:" line right after the
156
- // section's "Reviewed:" line, replacing one already there. Null when the
157
- // body has no section, or its review isn't of `from`.
158
- export function withRestackedLine(
159
- body: string,
160
- from: string,
161
- to: string
89
+ const short = (sha: string) => sha.slice(0, 7);
90
+
91
+ // Fails closed: an unread body or an unknown head refuses. A review of the
92
+ // head the task pushed still counts after AgentOS restacked or rebased it
93
+ // cleanly, but only while the head is exactly the one AgentOS left: a push
94
+ // by the task after that needs a new review.
95
+ export function codeReviewGate(opts: {
96
+ head: string | undefined;
97
+ // The AI attribution in the PR body (attributionIn): null for none,
98
+ // undefined when the body couldn't be read.
99
+ attribution: AttributionKind | null | undefined;
100
+ review: (sha: string) => ReviewState | null;
101
+ restacked?: Restacked | null;
102
+ }): CodeReviewGate {
103
+ const { head, restacked } = opts;
104
+ const no = (refusal: string) => ({ refusal, wait: null });
105
+ const { attribution } = opts;
106
+ if (attribution === undefined) return no("the PR body couldn't be read");
107
+ if (!head) return no("the PR's head commit is unknown");
108
+ if (attribution)
109
+ return no(
110
+ `the PR body carries AI attribution (${attribution}): remove it with \`gh pr edit --body-file\``
111
+ );
112
+ // The reviews that cover the head: its own, and the one of the head the
113
+ // task pushed while the head is the one AgentOS left.
114
+ const covering = [
115
+ opts.review(head),
116
+ restacked?.to === head && restacked.from
117
+ ? opts.review(restacked.from)
118
+ : null,
119
+ ];
120
+ if (covering.some((r) => r?.status === "pass"))
121
+ return { refusal: null, wait: null, ran: true };
122
+ const now = covering[0];
123
+ return {
124
+ refusal: null,
125
+ wait: `no passing independent review of ${short(head)}${now?.status === "running" ? " yet (it's running)" : now?.status === "block" ? " (it has blocking findings)" : ": call review"}`,
126
+ ran: covering.some((r) => r?.status === "block"),
127
+ };
128
+ }
129
+
130
+ // The gate as one reason, for paths that only merge or refuse (a stack
131
+ // landing, an approved merge): a wait refuses there too.
132
+ export function codeReviewRefusal(
133
+ opts: Parameters<typeof codeReviewGate>[0]
162
134
  ): string | null {
163
- const section = parseCodeReview(body);
164
- if (!section?.sha || !from.toLowerCase().startsWith(section.sha)) return null;
165
- const lines = body.split("\n");
166
- const at = lines.findLastIndex(
167
- (l) =>
168
- REVIEWED.exec(l.replace(/\t/g, " "))?.[1]?.toLowerCase() === section.sha
169
- );
170
- if (at < 0) return null;
171
- const line = `Restacked: ${from} -> ${to} (AgentOS rebased the reviewed commit onto its base; the PR's own changes are the ones reviewed)`;
172
- const next = lines[at + 1]?.replace(/\t/g, " ") ?? "";
173
- lines.splice(at + 1, RESTACKED.test(next) ? 1 : 0, line);
174
- return lines.join("\n");
135
+ const gate = codeReviewGate(opts);
136
+ return gate.refusal ?? gate.wait;
175
137
  }
package/lib/tasks/gh.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { git } from "../git-exec";
4
- import { parseCodeReview } from "./code-review";
4
+ import { attributionIn } from "./code-review";
5
5
  import { parseScopeChange } from "./scope-change";
6
6
  import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
7
7
  import {
@@ -114,7 +114,8 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
114
114
  failing: failingCheck(rollup),
115
115
  failingUrl: failingUrl(rollup),
116
116
  checkCount: (pr.statusCheckRollup ?? []).length,
117
- codeReview: pr.body === undefined ? undefined : parseCodeReview(pr.body),
117
+ attribution:
118
+ pr.body === undefined ? undefined : (attributionIn(pr.body) ?? null),
118
119
  scopeChange: parseScopeChange(pr.body),
119
120
  ...(pr.mergeable && { mergeable: pr.mergeable }),
120
121
  };
@@ -4,6 +4,14 @@
4
4
  * drops it.
5
5
  */
6
6
 
7
+ import {
8
+ parseOverride,
9
+ resolveRole,
10
+ sessionModelRef,
11
+ type ModelOverride,
12
+ } from "../models/resolve";
13
+ import { effectiveModels } from "../models/config";
14
+ import { ANTHROPIC } from "../models/roles";
7
15
  import { processTable, runsSomething } from "../process-table";
8
16
  import { randomUUID } from "crypto";
9
17
  import { db, queries, type Session } from "../db";
@@ -19,7 +27,6 @@ import {
19
27
  isBranchName,
20
28
  slugify,
21
29
  } from "../git";
22
- import { resolveModelForAgent } from "../model-catalog";
23
30
  import { getProvider } from "../providers";
24
31
  import { statusDetector } from "../status-detector";
25
32
  import { buildTaskBrief, type StackedOn } from "./brief";
@@ -49,7 +56,7 @@ import { chatStateNow } from "../chat/runner";
49
56
  import { saidSinceLastMessage } from "../chat/store";
50
57
 
51
58
  export * from "./state";
52
- export { codeReviewRefusal, parseCodeReview } from "./code-review";
59
+ export { codeReviewGate, codeReviewRefusal } from "./code-review";
53
60
  export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
54
61
  export { prFor as taskPR } from "./session";
55
62
  export { moveTask } from "./move-flow";
@@ -85,6 +92,10 @@ export interface TaskView {
85
92
  hostError: string | null;
86
93
  // Merges by itself once every gate passes (orchestrator/armed.ts).
87
94
  armed?: boolean;
95
+ // What it runs on now, "provider/model", and whether it's still in its
96
+ // plan step (lib/models).
97
+ model?: string;
98
+ planning?: boolean;
88
99
  }
89
100
 
90
101
  export async function createTask(opts: {
@@ -92,7 +103,12 @@ export async function createTask(opts: {
92
103
  prompt: string;
93
104
  // Its name; generated from the prompt when absent.
94
105
  name?: string;
95
- model?: string;
106
+ // Its own model: "provider/model" (or an Anthropic alias) for all its
107
+ // roles, or a map of role to one (lib/models). Its workspace's and
108
+ // Settings' role models otherwise.
109
+ model?: ModelOverride;
110
+ // Plan first on the planning model (Settings' default otherwise).
111
+ plan?: boolean;
96
112
  // Started from this LumifyHub card, which the task then moves.
97
113
  cardId?: string;
98
114
  // Stacked: cut from another task's pushed branch, at this exact commit.
@@ -169,7 +185,7 @@ async function startTask(
169
185
  id,
170
186
  prompt,
171
187
  name: opts.name,
172
- model: opts.model,
188
+ model: typeof opts.model === "string" ? opts.model : undefined,
173
189
  baseBranch: opts.baseBranch,
174
190
  view,
175
191
  });
@@ -179,6 +195,12 @@ async function startTask(
179
195
 
180
196
  if (opts.baseBranch !== undefined && !isBranchName(opts.baseBranch))
181
197
  throw new Error(`"${opts.baseBranch}" isn't a branch name`);
198
+ const routing = taskRouting(
199
+ project.workspace_id,
200
+ view,
201
+ opts.model,
202
+ opts.plan
203
+ );
182
204
  const projectPath = expandHome(project.working_directory);
183
205
  const naming = await nameFor(prompt, projectPath, opts.name);
184
206
  const feature = `${slugify(naming.name.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
@@ -198,10 +220,7 @@ async function startTask(
198
220
  });
199
221
 
200
222
  const provider = getProvider("claude");
201
- const model = resolveModelForAgent(
202
- "claude",
203
- opts.model || project.default_model
204
- );
223
+ const model = routing.first.model;
205
224
  const tmuxName = `${provider.id}-${id}`;
206
225
 
207
226
  queries
@@ -225,7 +244,8 @@ async function startTask(
225
244
  .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
226
245
  // Everything the launch needs is on the row, so a restart can resume it.
227
246
  db.prepare(
228
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ? WHERE id = ?`
247
+ `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
248
+ model_provider = ?, model_overrides = ?, plan_phase = ? WHERE id = ?`
229
249
  ).run(
230
250
  prompt,
231
251
  buildTaskBrief({
@@ -235,6 +255,9 @@ async function startTask(
235
255
  }),
236
256
  naming.source,
237
257
  view,
258
+ routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
259
+ routing.override ? JSON.stringify(routing.override) : null,
260
+ routing.plan ? "planning" : null,
238
261
  id
239
262
  );
240
263
  opts.onCreated?.(id);
@@ -340,6 +363,8 @@ export async function taskView(session: Session): Promise<TaskView> {
340
363
  hostName: null,
341
364
  hostError: null,
342
365
  armed: !!session.merge_armed_at,
366
+ model: sessionModelRef(session),
367
+ planning: session.plan_phase === "planning",
343
368
  };
344
369
  }
345
370
 
@@ -365,3 +390,29 @@ export async function listTasks(): Promise<TaskView[]> {
365
390
  b.createdAt.localeCompare(a.createdAt)
366
391
  );
367
392
  }
393
+
394
+ // The model a task starts on, and whether it plans first: only as a chat,
395
+ // where the handoff can follow the end of its plan turn, and only when
396
+ // planning and implementing are on different models. Both roles resolve
397
+ // now, so a bad override or a provider that's off fails the start.
398
+ export function taskRouting(
399
+ workspaceId: string | null,
400
+ view: StartView,
401
+ model: unknown,
402
+ plan?: boolean
403
+ ) {
404
+ const override = parseOverride(model);
405
+ const models = effectiveModels();
406
+ const opts = { workspaceId, override };
407
+ const implement = resolveRole("implement", opts, models);
408
+ const planning = resolveRole("plan", opts, models);
409
+ const planStep =
410
+ view === "chat" &&
411
+ (plan ?? models.planStep) &&
412
+ planning.ref !== implement.ref;
413
+ return {
414
+ override,
415
+ plan: planStep,
416
+ first: planStep ? planning : implement,
417
+ };
418
+ }
@@ -11,6 +11,7 @@
11
11
  * is deleted: a started or removed row stays as history.
12
12
  */
13
13
 
14
+ import type { ModelOverride } from "../models/resolve";
14
15
  import { randomUUID } from "crypto";
15
16
  import { db, type Session } from "../db";
16
17
  import { getProject } from "../projects";
@@ -41,6 +42,8 @@ export interface QueueRow {
41
42
  prompt: string;
42
43
  name: string | null;
43
44
  model: string | null;
45
+ // A per-role model map (lib/models), when that's what it was given.
46
+ model_overrides: string | null;
44
47
  view: "chat" | "terminal" | null;
45
48
  base_branch: string | null;
46
49
  host_id: string | null;
@@ -200,7 +203,7 @@ export interface QueueRequest {
200
203
  projectId: string;
201
204
  prompt: string;
202
205
  name?: string;
203
- model?: string;
206
+ model?: ModelOverride;
204
207
  view?: "chat" | "terminal";
205
208
  baseBranch?: string;
206
209
  hostId?: string;
@@ -247,9 +250,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
247
250
  if (!req.prompt.trim()) throw new Error("Describe the task");
248
251
  const id = randomUUID();
249
252
  db.prepare(
250
- `INSERT INTO task_queue (id, project_id, prompt, name, model, view, base_branch,
253
+ `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, view, base_branch,
251
254
  host_id, after_task, after_any, origin_workspace_id, position)
252
- VALUES (@id, @project_id, @prompt, @name, @model, @view, @base_branch,
255
+ VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @view, @base_branch,
253
256
  @host_id, @after_task, @after_any, @origin,
254
257
  (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
255
258
  ).run({
@@ -257,7 +260,11 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
257
260
  project_id: project.id,
258
261
  prompt: req.prompt.trim(),
259
262
  name: req.name?.trim() || null,
260
- model: req.model || null,
263
+ model: typeof req.model === "string" ? req.model || null : null,
264
+ model_overrides:
265
+ req.model && typeof req.model === "object"
266
+ ? JSON.stringify(req.model)
267
+ : null,
261
268
  view: req.view ?? null,
262
269
  base_branch: req.baseBranch || null,
263
270
  host_id: req.hostId || null,
@@ -429,7 +436,9 @@ async function startRow(
429
436
  projectId: r.project_id,
430
437
  prompt: r.prompt,
431
438
  name: r.name ?? undefined,
432
- model: r.model ?? undefined,
439
+ model: r.model_overrides
440
+ ? (JSON.parse(r.model_overrides) as ModelOverride)
441
+ : (r.model ?? undefined),
433
442
  view: r.view ?? undefined,
434
443
  baseBranch: r.base_branch ?? undefined,
435
444
  hostId: r.host_id ?? undefined,
@@ -23,6 +23,7 @@ import { deleteQueued, enqueue, listQueue } from "../chat/queued";
23
23
  import { sendQueuedNow } from "../chat/runner";
24
24
  import { hasItem } from "../chat/store";
25
25
  import { firstMessageId, launchPending } from "./launch-gate";
26
+ import { planPrompt, preparePlanDir } from "../models/plan-step";
26
27
 
27
28
  export { firstMessageId, launchPending };
28
29
  import { expandHome } from "./session";
@@ -136,7 +137,10 @@ export async function finishTaskStart(
136
137
  ms: setup.ms,
137
138
  error: "Not launched: the task was closed during setup",
138
139
  });
139
- const prompt = (session.task_prompt ?? "") + setupNote(setup);
140
+ const task = (session.task_prompt ?? "") + setupNote(setup);
141
+ const planning = session.plan_phase === "planning";
142
+ if (planning) preparePlanDir(session.worktree_path);
143
+ const prompt = planning ? planPrompt(task) : task;
140
144
  try {
141
145
  if (session.view === "chat") await launchChat(sessionId, prompt);
142
146
  else {
@@ -148,6 +152,7 @@ export async function finishTaskStart(
148
152
  tmuxName: session.tmux_name,
149
153
  cwd: session.worktree_path,
150
154
  model: session.model,
155
+ provider: session.model_provider,
151
156
  prompt,
152
157
  brief: session.task_brief ?? undefined,
153
158
  });
@@ -190,7 +195,7 @@ function earlierSetup(row: SetupColumns): TaskSetup | undefined {
190
195
  return undefined;
191
196
  }
192
197
 
193
- const killTmux = (name: string) =>
198
+ export const killTmux = (name: string) =>
194
199
  execFileAsync("tmux", ["kill-session", "-t", `=${name}`], {
195
200
  timeout: 5000,
196
201
  }).catch(() => undefined);
@@ -1,4 +1,4 @@
1
- import type { CodeReviewSection } from "./code-review";
1
+ import type { AttributionKind } from "./code-review";
2
2
 
3
3
  export type TaskStatus =
4
4
  | "running"
@@ -22,9 +22,9 @@ export interface TaskPR {
22
22
  failingUrl?: string | null;
23
23
  // How many checks are registered on the head.
24
24
  checkCount?: number;
25
- // The body's Code review section: null when it has none, undefined
26
- // when the body wasn't read.
27
- codeReview?: CodeReviewSection | null;
25
+ // The AI attribution the body carries (tasks/code-review.ts): null for
26
+ // none, undefined when the body wasn't read.
27
+ attribution?: AttributionKind | null;
28
28
  // The body's "Scope change" note (tasks/scope-change.ts), when it has one.
29
29
  scopeChange?: string | null;
30
30
  // GitHub's word on merging it into its base: CONFLICTING once the base
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Spend by model and by role over a window: tokens of each kind, turns,
3
+ * and the estimated cost from the price table (lib/usage/cost).
4
+ */
5
+
6
+ import { estimateCost, rowModel, UNRECORDED, type PricedRow } from "./cost";
7
+ import type { ModelPrice } from "../models/providers";
8
+
9
+ export interface ModelTurnRow extends PricedRow {
10
+ at: number;
11
+ role: string | null;
12
+ }
13
+
14
+ export interface TokenSpend {
15
+ inputTokens: number;
16
+ outputTokens: number;
17
+ cacheReadTokens: number;
18
+ cacheWriteTokens: number;
19
+ turns: number;
20
+ costUsd: number;
21
+ // Some of its turns are on a model the price table doesn't list.
22
+ unpriced: boolean;
23
+ }
24
+
25
+ export interface ModelSpend extends TokenSpend {
26
+ provider: string;
27
+ model: string;
28
+ }
29
+
30
+ export interface RoleSpend extends TokenSpend {
31
+ role: string;
32
+ }
33
+
34
+ export interface SpendByModel {
35
+ since: number;
36
+ total: TokenSpend;
37
+ models: ModelSpend[];
38
+ roles: RoleSpend[];
39
+ }
40
+
41
+ export const emptySpend = (): TokenSpend => ({
42
+ inputTokens: 0,
43
+ outputTokens: 0,
44
+ cacheReadTokens: 0,
45
+ cacheWriteTokens: 0,
46
+ turns: 0,
47
+ costUsd: 0,
48
+ unpriced: false,
49
+ });
50
+
51
+ export function addRow(
52
+ s: TokenSpend,
53
+ r: PricedRow,
54
+ prices: Record<string, ModelPrice>
55
+ ): void {
56
+ const cost = estimateCost(r, prices);
57
+ s.inputTokens += r.input_tokens;
58
+ s.outputTokens += r.output_tokens;
59
+ s.cacheReadTokens += r.cache_read_tokens;
60
+ s.cacheWriteTokens += r.cache_write_tokens;
61
+ s.turns += 1;
62
+ s.costUsd += cost.usd;
63
+ s.unpriced ||= !cost.priced;
64
+ }
65
+
66
+ export function spendByModel(
67
+ rows: ModelTurnRow[],
68
+ since: number,
69
+ prices: Record<string, ModelPrice>
70
+ ): SpendByModel {
71
+ const total = emptySpend();
72
+ const models = new Map<string, ModelSpend>();
73
+ const roles = new Map<string, RoleSpend>();
74
+ for (const r of rows) {
75
+ if (r.at < since) continue;
76
+ const { provider, model } = rowModel(r);
77
+ const mk = `${provider}/${model}`;
78
+ if (!models.has(mk)) models.set(mk, { ...emptySpend(), provider, model });
79
+ const role = r.role ?? UNRECORDED;
80
+ if (!roles.has(role)) roles.set(role, { ...emptySpend(), role });
81
+ for (const s of [total, models.get(mk)!, roles.get(role)!])
82
+ addRow(s, r, prices);
83
+ }
84
+ const byCost = (a: TokenSpend, b: TokenSpend) => b.costUsd - a.costUsd;
85
+ return {
86
+ since,
87
+ total,
88
+ models: [...models.values()].sort(byCost),
89
+ roles: [...roles.values()].sort(byCost),
90
+ };
91
+ }
92
+
93
+ export function sumSpend(xs: TokenSpend[]): TokenSpend {
94
+ const t = emptySpend();
95
+ for (const x of xs) {
96
+ t.inputTokens += x.inputTokens;
97
+ t.outputTokens += x.outputTokens;
98
+ t.cacheReadTokens += x.cacheReadTokens;
99
+ t.cacheWriteTokens += x.cacheWriteTokens;
100
+ t.turns += x.turns;
101
+ t.costUsd += x.costUsd;
102
+ t.unpriced ||= x.unpriced;
103
+ }
104
+ return t;
105
+ }