@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.
- package/README.md +139 -36
- package/app/api/fresh-start/route.ts +9 -5
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +8 -3
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +69 -5
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +5 -0
- package/lib/agents/launch.ts +13 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/refresh.ts +31 -13
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +70 -24
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +70 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +5 -4
- package/lib/orchestrator/claude-cli.ts +48 -14
- package/lib/orchestrator/diff.ts +1 -1
- package/lib/orchestrator/external-gates.ts +8 -43
- package/lib/orchestrator/gates.ts +10 -10
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +20 -5
- package/lib/orchestrator/scope.ts +11 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/signoff.ts +13 -8
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +38 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-audit/render.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- package/lib/stacks/land.ts +23 -16
- package/lib/tasks/brief.ts +3 -14
- package/lib/tasks/code-review.ts +69 -107
- package/lib/tasks/gh.ts +3 -2
- package/lib/tasks/index.ts +60 -9
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +170 -0
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +51 -0
- package/lib/usage/turns.ts +107 -18
- package/package.json +1 -1
- package/stores/usageUi.ts +11 -2
- 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),
|
|
21
|
-
|
|
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
|

|
|
37
48
|
|
|
38
49
|

|
|
@@ -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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
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
|
|
957
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
6
|
-
//
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/app/api/usage/route.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
289
|
-
// "Send as-is", for that send only.
|
|
290
|
-
const
|
|
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={
|
|
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:
|
|
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 {
|
|
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
|
|
10
|
-
//
|
|
11
|
-
// re-read all of it at full price.
|
|
12
|
-
// picks "Send as-is", for that one
|
|
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 =
|
|
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.
|
|
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,
|