@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
package/README.md CHANGED
@@ -17,8 +17,8 @@ and check on all of it from your phone.
17
17
  Every workspace has one orchestrator, a chat pinned at the top of its list.
18
18
  It starts tasks, answers agents that are blocked, and reviews and merges
19
19
  pull requests through gates: CI green and settled, an independent review of
20
- the exact commit (big diffs are reviewed in parts), the PR's Code review
21
- section, nothing blocked, and the diff in scope. Anything that is yours to
20
+ the exact commit (big diffs are reviewed in parts), nothing blocked, and the
21
+ diff in scope. Anything that is yours to
22
22
  decide (publishing, money, something irreversible, credentials) waits as an
23
23
  ask with Approve, Decline and Reply. Turn on **Require my approval** and
24
24
  sensitive or large PRs wait for you too. [More](#orchestrator)
@@ -33,6 +33,17 @@ sign-off. A workspace's running task limit (Settings → Workspaces) caps how ma
33
33
  wait under **Queued** with their place in line and start on their own, and
34
34
  `aos task --after` holds one until another finishes. [More](#tasks)
35
35
 
36
+ ### Each kind of work on the right model
37
+
38
+ Planning and gate reviews run on Opus, implementation, the orchestrator and
39
+ scope checks on Sonnet, titles and summaries on Haiku, each editable in
40
+ Settings → Models and per workspace. A task plans on one model and
41
+ implements on another in the same conversation, can be resumed on any
42
+ model, and can run on another provider (DeepSeek, GLM, any
43
+ Anthropic-compatible endpoint) whose key stays a reference.
44
+ **Usage → Compare** shows Claude-only and routed tasks side by side.
45
+ [More](#model-routing)
46
+
36
47
  ![A task's chat: the edit, the tests, the code review and the PR it opened](screenshots/task-chat.png)
37
48
 
38
49
  ![Two queued tasks in the sidebar, one in line and one waiting on another task, with its menu open](screenshots/queued.png)
@@ -312,25 +323,34 @@ The ring in the tab bar (in the composer on a phone) is how full the
312
323
  conversation's context is, measured against the room before auto-compact:
313
324
  amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
314
325
 
315
- A message to a chat idle past its prompt cache (5 minutes or an hour, read
316
- from the agent's own usage) re-reads the whole conversation at the
317
- cache-write price. When the context is also above a threshold (150k tokens by
318
- default, Settings → Workspaces → Compact idle chats; off sets it to never), a
319
- chip above the composer says "Idle 47m · 412k context · Compact first?": Send
320
- compacts and then sends, **Send as-is** skips it for that one message.
321
- Messages nobody is typing (the orchestrator's `send`, AgentOS events and
322
- wakes, `aos send`, schedules) compact first on their own, only between turns,
323
- and the chat shows one quiet line with the context before and after. An
324
- orchestrator isn't compacted (a summary would lose the fence around what it
325
- read from tasks and CI): it starts a fresh conversation from its brief and
326
- the current state instead, as at its fresh-start threshold.
326
+ Chats and tasks compact at 150k tokens of context by default (Settings →
327
+ Workspaces → Compact; off sets it to never), not near the model's window:
328
+ AgentOS launches Claude Code with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to
329
+ that size (its floor is 100k), so the agent compacts itself mid-turn. Between
330
+ turns AgentOS catches what it didn't: a chat over the threshold is compacted
331
+ before the next message, and so is one idle past its prompt cache (5 minutes
332
+ or an hour, read from the agent's own usage), whose next message would
333
+ re-read the whole conversation at the cache-write price. A chip above the
334
+ composer says "Idle 47m · 412k context · Compact first?": Send compacts and
335
+ then sends, **Send as-is** skips it for that one message. Messages nobody is
336
+ typing (the orchestrator's `send`, AgentOS events and wakes, `aos send`,
337
+ schedules) compact first on their own, only between turns, and the chat
338
+ shows one quiet line with the context before and after. An orchestrator
339
+ isn't compacted (a summary would lose the fence around what it read from
340
+ tasks and CI): it starts a fresh conversation from its brief and the current
341
+ state at its fresh-start threshold, or earlier once idle past its cache.
327
342
  On a desktop browser the screen button attaches a screenshot of a screen or
328
343
  window you pick; on a phone, attach a screenshot as a photo.
329
344
 
330
345
  **Usage** (the sidebar's ⋯ menu, or the palette) shows the account's 5-hour
331
346
  and weekly windows, and what chats cost today, over 7 or 30 days, by day,
332
347
  workspace and session. Costs are the agent's own estimates, recorded as each
333
- turn ends.
348
+ turn ends. **Gate reviews** below it shows what the orchestrator's
349
+ independent reviewer and scope check cost, by PR: they run as `claude -p`
350
+ and leave no transcript, so each run's tokens, model and duration are read
351
+ from its JSON result into `check_runs`, and the review's verdict says them
352
+ too. Every compaction is kept in `chat_compactions` (who ran it, and the
353
+ context before and after), so cost per task can be compared by date.
334
354
 
335
355
  **Esc** stops a running turn, as it does in Claude Code's terminal, unless a
336
356
  menu, dialog or the command list is open (those close first). On a phone the
@@ -428,6 +448,95 @@ and the same actions in each row's menu; a row becomes the task's own row
428
448
  when it starts. Why it works
429
449
  this way: [docs/decisions/2026-10-10-queued-tasks.md](docs/decisions/2026-10-10-queued-tasks.md).
430
450
 
451
+ ## Model routing
452
+
453
+ Each kind of work AgentOS launches an agent for runs on its own model, set in
454
+ **Settings → Models** (⌘K → Model settings). A model is named
455
+ `provider/model`:
456
+
457
+ | Role | Default |
458
+ | --------------------------------------------------- | ------------------------------------- |
459
+ | Task planning (a task's plan step) | `anthropic/claude-opus-5-5` |
460
+ | Gate review | `anthropic/claude-opus-5-5` |
461
+ | Task implementation | `anthropic/claude-sonnet-5-5` |
462
+ | Orchestrator | `anthropic/claude-sonnet-5-5` |
463
+ | Scope check | `anthropic/claude-sonnet-5-5` |
464
+ | Interactive sessions | `anthropic/claude-opus-5-5` |
465
+ | Chat & summary helpers (titles, summaries, PR text) | `anthropic/claude-haiku-4-5-20251001` |
466
+
467
+ The most specific setting wins: a task's own model, then its workspace's
468
+ (**Settings → Workspaces → Models**), then Settings, then the default.
469
+ `AGENTOS_REVIEW_MODEL` (gate review and scope check) and
470
+ `AGENTOS_TITLE_MODEL` (helpers) in the server's environment still replace a
471
+ role's default where Settings names none. A
472
+ Claude session started without a model (the orchestrator's `start_session`,
473
+ the API) runs on the interactive role's; one you pick a model for keeps it.
474
+
475
+ **Plan, then implement.** A chat task whose planning and implementation
476
+ models differ starts with a plan step: its first turn runs on the planning
477
+ model and writes the plan to `.agentos/plan.md` in its worktree (ignored by
478
+ git), changing nothing else. When that turn ends, the same conversation
479
+ switches to the implementation model (a live model switch, or a resume with
480
+ `--model` when the provider changes) and is told to follow the plan. Turn it
481
+ off in Settings, or per task with `plan: false`. Terminal tasks skip it.
482
+
483
+ **Per task.** The task draft has a **Model** chip ("Routed by role", or one
484
+ model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
485
+ `"provider/model"` for the task's own roles or a map such as
486
+ `{"implement": "zai/glm-5.3"}`, and `plan`. Its gate review and scope check
487
+ never follow a task's model, only Settings and its workspace, so work can't
488
+ pick its own reviewer. **Resume on model…** on a task's row
489
+ (or `POST /api/tasks/<id>/model`, or the orchestrator's `set_task_model`)
490
+ moves a paused task to another model: same conversation, same worktree, and
491
+ it keeps that model from then on. Each turn records the model, provider and
492
+ role it ran as.
493
+
494
+ **Providers.** `anthropic` is your Claude login and needs no key. Others are
495
+ Anthropic-compatible endpoints (OpenAI-compatible ones can be defined and
496
+ tested, but Claude Code can't run on them): a base URL, where the key lives,
497
+ the models, and Claude Code's opus/sonnet/haiku tiers mapped to them so
498
+ subagents and background calls stay on that provider. DeepSeek
499
+ (`https://api.deepseek.com/anthropic`) and Z.ai GLM
500
+ (`https://api.z.ai/api/anthropic`) ship as presets, off until given a key.
501
+ Claude stays the default: another provider runs only where a role or a task
502
+ picks it. If a role's provider is turned off or its key can't be read,
503
+ helper runs, reviews and the orchestrator fall back to the role's default
504
+ rather than fail. **Test provider** sends one tiny request and says whether it
505
+ answered. Changing models, providers or the test runs only from this machine
506
+ or over Tailscale, never from a paired device: a provider's URL and key
507
+ reference decide where a key is sent.
508
+
509
+ The key is never stored by AgentOS. A provider names where to read it: a
510
+ tokenvault entry (`tv get <project> <desc>`), a macOS Keychain item
511
+ (`security find-generic-password -s <service> -w`) or an environment
512
+ variable named `*_API_KEY` (not `ANTHROPIC_`, `CLAUDE_` or `AGENTOS_`). It's read when an agent launches and goes only into that agent's
513
+ environment (`ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` or
514
+ `ANTHROPIC_API_KEY`), never its command line, the database, a prompt or a
515
+ log; a terminal task's shell reads it from the reference itself. Errors that
516
+ might echo a key are scrubbed of it.
517
+
518
+ **Usage by model.** **Usage → By model** shows tokens and estimated cost per
519
+ role and per model in the plan's 5-hour window and the last 24 hours, from
520
+ the price table in Settings → Models (dollars per million input, cache
521
+ write, cache read and output tokens, editable).
522
+
523
+ **How to read the comparison.** **Usage → Compare** (or ⌘K → Model
524
+ comparison, or the sidebar menu) puts Claude-only tasks (the baseline,
525
+ including everything from before routing) beside routed ones, per provider,
526
+ over 7, 30 or 90 days, with a total column. A task counts under the provider
527
+ its own planning and implementation turns ran on; its reviews and checks add
528
+ to its cost wherever they ran. For each column: tasks, turns, tokens,
529
+ estimated dollars (list prices; for Claude, what the API would charge),
530
+ Claude window points per task (how far each turn moved the plan's 5-hour
531
+ window, an estimate), agent time, CI green on the first push, review passed
532
+ on the first try, gate failures and rework commits per task (heads checked
533
+ after the first review), and merged or dropped. Outcomes come from the
534
+ orchestrator's checks, so tasks it didn't run have cost but no outcome. A
535
+ column with fewer than 10 tasks is marked a small sample: a hint, not a
536
+ result. Parity means the routed column's outcome rows match the baseline's
537
+ while its window points per task drop. Why it works this way:
538
+ [docs/decisions/2026-10-11-model-routing.md](docs/decisions/2026-10-11-model-routing.md).
539
+
431
540
  ## New sessions
432
541
 
433
542
  ⌥N (the **+** on a phone) opens a draft: a composer in the current project, or
@@ -465,17 +574,15 @@ Starting a task returns at once and shows **Setting up** until the agent
465
574
  launches; a restart in the middle resumes it. A failed setup shows on the task
466
575
  and is in the agent's first prompt (redacted, as data).
467
576
 
468
- Before opening its PR, every task runs `/do-code-review` (the project's
469
- `.claude/skills/do-code-review`, or Claude Code's `/code-review` where a
470
- project has none), fixes the Blocking and High findings, and ends the PR body
471
- with a **Code review** section: the commit it reviewed, the agents that ran,
472
- what it fixed and what it deferred and why. The orchestrator's `sign_off` and
473
- a stack's **Land** refuse a PR without that section for its head commit, and
474
- this repository's CI fails one too. A head AgentOS rebased itself is
475
- covered by the review of the head it rebased: its gates go by what they
476
- recorded, and CI by the section's `Restacked:` line. The review agents are in
477
- [.claude/agents](.claude/agents) and the skill in
478
- [.claude/skills](.claude/skills/README.md).
577
+ Tasks don't review their own PRs. The orchestrator's independent review of
578
+ the PR's exact head commit is the one review: `sign_off` and a stack's
579
+ **Land** refuse a PR without a passing one, and a PR body with AI
580
+ attribution. A head AgentOS rebased itself is covered by the review of the
581
+ head it rebased while the PR's own patch is unchanged. A task may still run
582
+ `/do-code-review` (the project's `.claude/skills/do-code-review`, or Claude
583
+ Code's `/code-review`) on a risky change before its PR; it's no longer
584
+ required. The review agents are in [.claude/agents](.claude/agents) and the
585
+ skill in [.claude/skills](.claude/skills/README.md).
479
586
 
480
587
  Requires the GitHub CLI (`gh`) signed in, and a project with a GitHub remote.
481
588
 
@@ -934,7 +1041,7 @@ it may do on its own, and the lines that always come back to you as asks.
934
1041
  it (2 minutes with no new check), its review passed, nothing is
935
1042
  `BLOCKED:` or waiting, the diff stays in scope (no secrets, not only
936
1043
  lockfiles, nothing outside the repo, within the card) and its stack parent
937
- has merged, and the PR body's Code review section names that commit. `land` judges each item again at its own head right before
1044
+ has merged, and the PR body carries no AI attribution. `land` judges each item again at its own head right before
938
1045
  merging it. A gate failing again on a different commit, or a repo with no
939
1046
  CI, goes to you as an ask, and the orchestrator stops merging that task.
940
1047
  A failure counts once per task, gate and head commit, however many times
@@ -953,17 +1060,15 @@ it may do on its own, and the lines that always come back to you as asks.
953
1060
  conflicts (read in the PR poll AgentOS already makes), `git merge-tree`
954
1061
  checks it against the base. Clean, and with the task's agent idle and
955
1062
  nothing uncommitted, AgentOS rebases it in the task's worktree and pushes
956
- only its branch with `--force-with-lease`; the Code review section gets a
957
- `Restacked: <reviewed> -> <new>` line so the task needn't review again,
958
- and the orchestrator's review carries over when the PR's own patch is
1063
+ only its branch with `--force-with-lease`, and the orchestrator's review
1064
+ carries over when the PR's own patch is
959
1065
  unchanged (same `git patch-id`; a fresh review starts otherwise). Not
960
1066
  clean: the task is asked once to rebase. Either way, one event. Main and
961
1067
  other tasks' branches are never written.
962
1068
  - **Tasks woken on CI failures and reviews:** a check failing on a task's
963
1069
  PR head messages the task's own agent once per head, with the check, the
964
1070
  failing step and about 60 lines of `gh run view --log-failed` around the
965
- error, telling it to fix the root cause, push and update its Reviewed:
966
- line. A review or comment from a collaborator (not the account gh runs
1071
+ error, telling it to fix the root cause and push. A review or comment from a collaborator (not the account gh runs
967
1072
  as, which is the task's own) wakes it once per review or comment; one
968
1073
  GraphQL query per repository a minute fingerprints the open task PRs, and
969
1074
  only a PR whose fingerprint moved has its reviews read. Comment-only wakes
@@ -976,8 +1081,7 @@ it may do on its own, and the lines that always come back to you as asks.
976
1081
  `owner/repo#N` or URL, as long as its repository is one of the workspace's
977
1082
  projects; anything else is refused. The same gates apply, except that a
978
1083
  draft waits and a "blocked", "do not merge" or "wip" label holds it (there's
979
- no session to read), and a body with no Code review section is covered by
980
- the orchestrator's own review of that head. `sessions` also lists tmux
1084
+ no session to read). `sessions` also lists tmux
981
1085
  sessions AgentOS didn't start that work in a workspace repository or a
982
1086
  worktree of it, here or on another machine, with their branch's PR; `read`
983
1087
  reads them, and nothing else acts on them.
@@ -1333,8 +1437,7 @@ until `npm install` runs in them. Typecheck, lint and tests are left to CI.
1333
1437
  In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
1334
1438
  which also holds the app's first-load JavaScript to a budget
1335
1439
  (`scripts/check-bundle.mjs`; `npx next experimental-analyze` shows what's in
1336
- it), and fails a pull request whose body has no Code review section for its head
1337
- commit (run `/do-code-review` first; see [Tasks](#tasks)).
1440
+ it).
1338
1441
 
1339
1442
  ### Session audits
1340
1443
 
@@ -2,17 +2,21 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
3
3
 
4
4
  // When a chat's conversation starts over (lib/chat/refresh): context
5
- // tokens for orchestrators and for tasks, and above which a stale chat is
6
- // compacted before a message (lib/chat/stale); 0 for never.
5
+ // tokens for orchestrators and for tasks, and at which chats and tasks
6
+ // compact (lib/chat/stale); 0 for never.
7
7
  export async function GET() {
8
8
  return NextResponse.json(refreshSettings());
9
9
  }
10
10
 
11
11
  export async function PUT(request: NextRequest) {
12
12
  try {
13
- const { orchestratorTokens, taskTokens, staleCompactTokens } =
14
- await request.json();
15
- setRefreshSettings({ orchestratorTokens, taskTokens, staleCompactTokens });
13
+ const body = await request.json();
14
+ setRefreshSettings({
15
+ orchestratorTokens: body.orchestratorTokens,
16
+ taskTokens: body.taskTokens,
17
+ // Its name before it also set the agents' own auto-compact.
18
+ compactTokens: body.compactTokens ?? body.staleCompactTokens,
19
+ });
16
20
  return NextResponse.json(refreshSettings());
17
21
  } catch (error) {
18
22
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,34 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ effectiveModels,
4
+ savedModelSettings,
5
+ setModelSettings,
6
+ } from "@/lib/models/config";
7
+ import { DEFAULT_ROLE_MODELS } from "@/lib/models/roles";
8
+ import { requireLocalTrust } from "@/lib/security/route-guard";
9
+
10
+ // GET /api/models - model routing: what's saved, what's in force, and the
11
+ // role defaults. Providers carry key references only, never a key.
12
+ export async function GET() {
13
+ return NextResponse.json({
14
+ saved: savedModelSettings(),
15
+ effective: effectiveModels(),
16
+ defaults: DEFAULT_ROLE_MODELS,
17
+ });
18
+ }
19
+
20
+ // PUT /api/models {roles?, providers?, prices?, planStep?} - replaces the
21
+ // saved settings. Only from this machine or the tailnet: a provider's base
22
+ // URL and key reference decide where a key is sent, so a stolen device
23
+ // token mustn't be able to point them anywhere.
24
+ export async function PUT(request: NextRequest) {
25
+ const refused = requireLocalTrust(request);
26
+ if (refused) return refused;
27
+ try {
28
+ setModelSettings(await request.json());
29
+ return GET();
30
+ } catch (error) {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ return NextResponse.json({ error: message }, { status: 400 });
33
+ }
34
+ }
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { testProvider } from "@/lib/models/test-provider";
3
+ import { requireLocalTrust } from "@/lib/security/route-guard";
4
+
5
+ // POST /api/models/test {provider, model?} - one tiny request to the
6
+ // provider with its key; says whether it answered, never the key. It sends
7
+ // a key, so only from this machine or the tailnet.
8
+ export async function POST(request: NextRequest) {
9
+ const refused = requireLocalTrust(request);
10
+ if (refused) return refused;
11
+ const { provider, model } = await request.json().catch(() => ({}));
12
+ if (typeof provider !== "string" || !provider)
13
+ return NextResponse.json({ error: "Name a provider" }, { status: 400 });
14
+ return NextResponse.json(
15
+ await testProvider(provider, typeof model === "string" ? model : undefined)
16
+ );
17
+ }
@@ -1,3 +1,4 @@
1
+ import { routedRun } from "@/lib/models/launch";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { isRemoteHost } from "@/lib/hosts";
3
4
  import { spawn } from "child_process";
@@ -130,8 +131,10 @@ async function getTmuxCwd(sessionName: string): Promise<string | null> {
130
131
  async function generateSummary(conversation: string): Promise<string> {
131
132
  const prompt = `Summarize this Claude Code conversation in under 300 words. Focus on: what was built, key files changed, current state, and any pending work. Be specific.`;
132
133
 
134
+ const routed = await routedRun("helper");
133
135
  return new Promise((resolve, reject) => {
134
- const claude = spawn("claude", ["-p", prompt], {
136
+ const claude = spawn("claude", ["-p", prompt, "--model", routed.model], {
137
+ env: { ...process.env, ...routed.env },
135
138
  stdio: ["pipe", "pipe", "pipe"],
136
139
  });
137
140
 
@@ -0,0 +1,23 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { resumeOnModel } from "@/lib/models/resume";
3
+
4
+ // POST /api/tasks/:id/model {model, message?} - resume the task on another
5
+ // model ("provider/model" or a map of role to one), keeping its
6
+ // conversation and worktree.
7
+ export async function POST(
8
+ request: NextRequest,
9
+ { params }: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const { model, message } = await request.json().catch(() => ({}));
12
+ try {
13
+ const result = await resumeOnModel(
14
+ (await params).id,
15
+ model,
16
+ typeof message === "string" && message.trim() ? message : undefined
17
+ );
18
+ return NextResponse.json(result);
19
+ } catch (error) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ return NextResponse.json({ error: message }, { status: 409 });
22
+ }
23
+ }
@@ -1,3 +1,4 @@
1
+ import { parseOverride } from "@/lib/models/resolve";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { createTask, listTasks, movedTasks } from "@/lib/tasks";
3
4
  import { startOrQueue } from "@/lib/tasks/queue";
@@ -33,6 +34,7 @@ export async function POST(request: NextRequest) {
33
34
  baseBranch,
34
35
  after,
35
36
  view,
37
+ plan,
36
38
  } = await request.json();
37
39
  if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
38
40
  const pid =
@@ -42,7 +44,9 @@ export async function POST(request: NextRequest) {
42
44
  const opts = {
43
45
  projectId: pid,
44
46
  prompt: String(prompt ?? ""),
45
- model,
47
+ // Checked now, so a queued task can't fail on it later.
48
+ model: parseOverride(model) ?? undefined,
49
+ plan: typeof plan === "boolean" ? plan : undefined,
46
50
  name: typeof name === "string" ? name : undefined,
47
51
  hostId: typeof hostId === "string" && hostId ? hostId : undefined,
48
52
  baseBranch: typeof baseBranch === "string" ? baseBranch : undefined,
@@ -0,0 +1,21 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { comparisonReport } from "@/lib/usage/compare";
3
+
4
+ const DAY = 24 * 3600 * 1000;
5
+
6
+ // GET /api/usage/compare?days=30 (or from=&to= in ms) - Claude-only tasks
7
+ // against routed ones: cost and outcomes per provider, and spend per role
8
+ // and model.
9
+ export async function GET(request: NextRequest) {
10
+ const q = request.nextUrl.searchParams;
11
+ const now = Date.now();
12
+ const days = Math.min(Math.max(Number(q.get("days")) || 30, 1), 365);
13
+ const to = Number(q.get("to")) || now;
14
+ const from = Number(q.get("from")) || to - days * DAY;
15
+ if (from >= to)
16
+ return NextResponse.json(
17
+ { error: "from must be before to" },
18
+ { status: 400 }
19
+ );
20
+ return NextResponse.json(comparisonReport(from, to));
21
+ }
@@ -1,15 +1,20 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { USAGE_RANGES, type UsageRange } from "@/lib/usage/aggregate";
3
- import { usageReport } from "@/lib/usage/report";
3
+ import { checkRunReport, modelUsage, usageReport } from "@/lib/usage/report";
4
4
  import { usageWindows } from "@/lib/usage/windows";
5
5
 
6
6
  // GET /api/usage?range=today|7d|30d - chat spend by day, session and
7
- // workspace, and the account's rolling windows
7
+ // workspace, what the orchestrator's reviews cost by PR, the account's
8
+ // rolling windows, and spend by model and role in the 5-hour window and the
9
+ // last 24 hours
8
10
  export async function GET(request: NextRequest) {
9
11
  const asked = request.nextUrl.searchParams.get("range") as UsageRange | null;
10
12
  const range = asked && USAGE_RANGES.includes(asked) ? asked : "7d";
13
+ const windows = await usageWindows();
11
14
  return NextResponse.json({
12
15
  report: usageReport(range),
13
- windows: await usageWindows(),
16
+ reviews: checkRunReport(range),
17
+ windows,
18
+ models: modelUsage(windows.fiveHour?.resetsIn),
14
19
  });
15
20
  }
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { setWorkspaceModels, workspaceModels } from "@/lib/models/config";
3
+ import { getWorkspace } from "@/lib/workspaces";
4
+ import { requireLocalTrust } from "@/lib/security/route-guard";
5
+
6
+ type RouteParams = { params: Promise<{ id: string }> };
7
+
8
+ // GET/PUT /api/workspaces/:id/models {roles} - the workspace's own role
9
+ // models, over Settings'.
10
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
11
+ return NextResponse.json(workspaceModels((await params).id));
12
+ }
13
+
14
+ // Writes only from this machine or the tailnet, like Settings' own.
15
+ export async function PUT(request: NextRequest, { params }: RouteParams) {
16
+ const refused = requireLocalTrust(request);
17
+ if (refused) return refused;
18
+ const { id } = await params;
19
+ if (!getWorkspace(id))
20
+ return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
21
+ try {
22
+ return NextResponse.json(setWorkspaceModels(id, await request.json()));
23
+ } catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ return NextResponse.json({ error: message }, { status: 400 });
26
+ }
27
+ }
@@ -285,9 +285,9 @@ export function ChatPanel({
285
285
  toEnd();
286
286
  send(text);
287
287
  };
288
- // A stale chat compacts before the next message unless the reader picks
289
- // "Send as-is", for that send only.
290
- const staleCompactTokens = useFreshStartQuery().data?.staleCompactTokens ?? 0;
288
+ // A chat past the compact threshold compacts before the next message
289
+ // unless the reader picks "Send as-is", for that send only.
290
+ const compactTokens = useFreshStartQuery().data?.compactTokens ?? 0;
291
291
  const [asIsFor, setAsIsFor] = useState<string | null>(null);
292
292
  const asIs = asIsFor === sessionId;
293
293
  const setAsIs = (on: boolean) => setAsIsFor(on ? sessionId : null);
@@ -529,7 +529,7 @@ export function ChatPanel({
529
529
  <StaleChip
530
530
  context={context}
531
531
  state={state}
532
- thresholdTokens={staleCompactTokens}
532
+ thresholdTokens={compactTokens}
533
533
  asIs={asIs}
534
534
  onAsIs={setAsIs}
535
535
  />
@@ -547,7 +547,7 @@ export function ChatPanel({
547
547
  toEnd();
548
548
  const compact = composerCompacts(text, context, {
549
549
  state,
550
- thresholdTokens: staleCompactTokens,
550
+ thresholdTokens: compactTokens,
551
551
  asIs,
552
552
  });
553
553
  setAsIs(false);
@@ -2,14 +2,15 @@
2
2
 
3
3
  import { Minimize2 } from "lucide-react";
4
4
  import type { ChatContext, ChatState } from "@/lib/chat/events";
5
- import { staleLabel, staleness } from "@/lib/chat/stale";
5
+ import { compactionDue, staleLabel } from "@/lib/chat/stale";
6
6
  import { cn } from "@/lib/utils";
7
7
  import { useNow } from "./useNow";
8
8
 
9
- // Above the composer while the chat is stale (lib/chat/stale): its prompt
10
- // cache has expired and its context is long, so the next message would
11
- // re-read all of it at full price. Send compacts first unless the reader
12
- // picks "Send as-is", for that one send only.
9
+ // Above the composer while the chat is due a compaction (lib/chat/stale):
10
+ // its context is past the compact threshold, and maybe its prompt cache has
11
+ // expired too, so the next message would re-read all of it at full price.
12
+ // Send compacts first unless the reader picks "Send as-is", for that one
13
+ // send only.
13
14
  export function StaleChip({
14
15
  context,
15
16
  state,
@@ -25,7 +26,7 @@ export function StaleChip({
25
26
  }) {
26
27
  const watching = !!context?.cacheTtlMs && thresholdTokens > 0;
27
28
  const now = useNow(watching);
28
- const stale = staleness(context, { state, thresholdTokens, now });
29
+ const stale = compactionDue(context, { state, thresholdTokens, now });
29
30
  if (!stale) return null;
30
31
  return (
31
32
  <div
@@ -17,6 +17,7 @@ import {
17
17
  Plus,
18
18
  Sun,
19
19
  Workflow,
20
+ Scale,
20
21
  } from "lucide-react";
21
22
  import type { Session } from "@/lib/db";
22
23
  import type { PaletteCommand } from "@/lib/palette/registry";
@@ -147,7 +148,24 @@ export function useAppCommands({
147
148
  group: "Go to",
148
149
  keywords: ["cost", "tokens", "limits", "spend", "window"],
149
150
  icon: Gauge,
150
- run: usageUiActions.open,
151
+ run: () => usageUiActions.open(),
152
+ },
153
+ {
154
+ id: "app.model-comparison",
155
+ title: "Model comparison",
156
+ group: "Go to",
157
+ keywords: [
158
+ "models",
159
+ "routing",
160
+ "providers",
161
+ "before after",
162
+ "quality",
163
+ "deepseek",
164
+ "glm",
165
+ "cost",
166
+ ],
167
+ icon: Scale,
168
+ run: () => usageUiActions.open("compare"),
151
169
  },
152
170
  {
153
171
  id: "app.tasks",
@@ -3,6 +3,7 @@
3
3
  import { useEffect } from "react";
4
4
  import {
5
5
  Bot,
6
+ Cpu,
6
7
  FolderGit2,
7
8
  GitBranch,
8
9
  GitPullRequest,
@@ -17,6 +18,7 @@ import { resolveModelForAgent } from "@/lib/model-catalog";
17
18
  import type { GitCheck } from "@/data/git/queries";
18
19
  import { useAgentStatusQuery } from "@/data/agents";
19
20
  import { useProjectMachinesQuery } from "@/data/projects";
21
+ import { modelChoices, useModelsQuery } from "@/data/models";
20
22
  import type { AgentProbe } from "@/lib/agents/probe";
21
23
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
22
24
 
@@ -64,6 +66,7 @@ export function DraftChips({
64
66
  const { data: localProbes } = useAgentStatusQuery();
65
67
  const probes = local ? localProbes : undefined;
66
68
  const base = draft.baseBranch ?? git?.defaultBranch ?? "main";
69
+ const { data: models } = useModelsQuery(draft.openPr);
67
70
  // A project elsewhere runs where it lives; a scratch chat, a task or a
68
71
  // project here can go to another machine.
69
72
  const canMove = !project || draft.openPr || projectHere;
@@ -232,6 +235,31 @@ export function DraftChips({
232
235
  onChange={(openPr) => onChange({ openPr })}
233
236
  />
234
237
  )}
238
+ {project && draft.openPr && local && (
239
+ <ChipMenu
240
+ icon={Cpu}
241
+ name="Model"
242
+ label={draft.taskModel ?? "Routed by role"}
243
+ >
244
+ <ChipItem
245
+ checked={!draft.taskModel}
246
+ onSelect={() => onChange({ taskModel: null })}
247
+ hint="Plans and implements on the role models in Settings"
248
+ >
249
+ Routed by role
250
+ </ChipItem>
251
+ {modelChoices(models?.effective).map((c) => (
252
+ <ChipItem
253
+ key={c.ref}
254
+ checked={c.ref === draft.taskModel}
255
+ onSelect={() => onChange({ taskModel: c.ref })}
256
+ hint={c.provider}
257
+ >
258
+ {c.label}
259
+ </ChipItem>
260
+ ))}
261
+ </ChipMenu>
262
+ )}
235
263
  {project && draft.openPr && (
236
264
  <ChipToggle
237
265
  icon={SquareTerminal}
@@ -65,7 +65,7 @@ export function DraftPanel({
65
65
  const { session, queued } = await createTask.mutateAsync({
66
66
  projectId: project.id,
67
67
  prompt: text,
68
- model: draft.model,
68
+ model: draft.taskModel ?? undefined,
69
69
  baseBranch: draft.baseBranch ?? undefined,
70
70
  hostId: draft.hostId !== project.host_id ? draft.hostId : undefined,
71
71
  view: draft.terminal ? "terminal" : undefined,