@saadnvd1/agent-os 0.3.471 → 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 (75) hide show
  1. package/README.md +100 -0
  2. package/app/api/models/route.ts +34 -0
  3. package/app/api/models/test/route.ts +17 -0
  4. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  5. package/app/api/tasks/[id]/model/route.ts +23 -0
  6. package/app/api/tasks/route.ts +5 -1
  7. package/app/api/usage/compare/route.ts +21 -0
  8. package/app/api/usage/route.ts +7 -4
  9. package/app/api/workspaces/[id]/models/route.ts +27 -0
  10. package/components/CommandPalette/useAppCommands.ts +19 -1
  11. package/components/Draft/DraftChips.tsx +28 -0
  12. package/components/Draft/DraftPanel.tsx +1 -1
  13. package/components/Models/ModelSelect.tsx +67 -0
  14. package/components/Models/ModelsSection.tsx +199 -0
  15. package/components/Models/ProviderCard.tsx +271 -0
  16. package/components/SessionList/SidebarHeader.tsx +5 -1
  17. package/components/Settings/SettingsDialog.test.ts +3 -0
  18. package/components/Settings/SettingsDialog.tsx +9 -0
  19. package/components/Settings/commands.ts +19 -0
  20. package/components/Settings/entry-points.test.ts +1 -0
  21. package/components/Tasks/ResumeOnModel.tsx +72 -0
  22. package/components/Tasks/TaskRow.tsx +4 -0
  23. package/components/Usage/CompareView.tsx +211 -0
  24. package/components/Usage/ModelSpend.tsx +133 -0
  25. package/components/Usage/UsageDialog.tsx +38 -3
  26. package/components/Usage/format.ts +9 -0
  27. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  28. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  29. package/data/models/index.ts +125 -0
  30. package/data/usage/index.ts +3 -0
  31. package/lib/agents/launch.ts +11 -2
  32. package/lib/chat/context.ts +1 -1
  33. package/lib/chat/runner.ts +5 -0
  34. package/lib/chat/worker/extras.ts +12 -0
  35. package/lib/chat/worker/host.ts +13 -2
  36. package/lib/chat/worker/main.ts +5 -2
  37. package/lib/db/migrations.ts +28 -0
  38. package/lib/db/types.ts +6 -0
  39. package/lib/drafts.ts +3 -0
  40. package/lib/models/config.ts +218 -0
  41. package/lib/models/launch.ts +93 -0
  42. package/lib/models/plan-step.ts +94 -0
  43. package/lib/models/providers.ts +163 -0
  44. package/lib/models/resolve.ts +246 -0
  45. package/lib/models/resume.ts +99 -0
  46. package/lib/models/roles.ts +98 -0
  47. package/lib/models/secrets.ts +86 -0
  48. package/lib/models/session-model.ts +40 -0
  49. package/lib/models/test-provider.ts +116 -0
  50. package/lib/orchestrator/act.ts +13 -1
  51. package/lib/orchestrator/brief.ts +2 -1
  52. package/lib/orchestrator/claude-cli.ts +44 -15
  53. package/lib/orchestrator/home.ts +2 -2
  54. package/lib/orchestrator/review.ts +2 -0
  55. package/lib/orchestrator/scope.ts +2 -0
  56. package/lib/orchestrator/serve.ts +13 -0
  57. package/lib/orchestrator/tool-names.ts +1 -0
  58. package/lib/orchestrator/tool-schemas.ts +38 -0
  59. package/lib/orchestrator/tools.ts +3 -1
  60. package/lib/pr-generation.ts +5 -2
  61. package/lib/providers/registry.ts +1 -1
  62. package/lib/session-titles.ts +1 -1
  63. package/lib/sessions/launch.ts +19 -4
  64. package/lib/settings-url.ts +1 -0
  65. package/lib/tasks/index.ts +59 -8
  66. package/lib/tasks/queue.ts +14 -5
  67. package/lib/tasks/start.ts +7 -2
  68. package/lib/usage/by-model.ts +105 -0
  69. package/lib/usage/check-runs.ts +5 -2
  70. package/lib/usage/compare.ts +280 -0
  71. package/lib/usage/cost.ts +71 -0
  72. package/lib/usage/report.ts +26 -0
  73. package/lib/usage/turns.ts +79 -18
  74. package/package.json +1 -1
  75. package/stores/usageUi.ts +11 -2
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Where a model runs. A provider is config: Anthropic through the Claude
3
+ * login (no key), or an Anthropic- or OpenAI-compatible endpoint with a base
4
+ * URL, a reference to its key and its model names. The key itself is never
5
+ * here: only where to fetch it from at launch (lib/models/secrets).
6
+ * Imported by the browser too, so no Node modules here.
7
+ */
8
+
9
+ import { ANTHROPIC } from "./roles";
10
+
11
+ // Where a provider's key lives. Never the key.
12
+ export type SecretRef =
13
+ | { kind: "tokenvault"; project: string; desc?: string }
14
+ | { kind: "keychain"; service: string; account?: string }
15
+ | { kind: "env"; name: string };
16
+
17
+ export type ProviderApi = "anthropic" | "openai";
18
+
19
+ export interface ModelTiers {
20
+ opus?: string;
21
+ sonnet?: string;
22
+ haiku?: string;
23
+ }
24
+
25
+ export interface ModelProvider {
26
+ id: string;
27
+ label: string;
28
+ api: ProviderApi;
29
+ // Absent for Anthropic itself, which uses the Claude login.
30
+ baseUrl?: string;
31
+ auth?: SecretRef;
32
+ // How the key is sent: Authorization: Bearer, or x-api-key.
33
+ authHeader?: "bearer" | "x-api-key";
34
+ models: string[];
35
+ // Claude Code's tiers mapped to this provider's models, so subagents and
36
+ // background work (which ask for "haiku" or "sonnet") route here too.
37
+ tiers?: ModelTiers;
38
+ // Plain settings the endpoint wants (a longer timeout, say). Never a key.
39
+ env?: Record<string, string>;
40
+ enabled: boolean;
41
+ }
42
+
43
+ export interface ModelPrice {
44
+ input: number;
45
+ cacheWrite: number;
46
+ cacheRead: number;
47
+ output: number;
48
+ }
49
+
50
+ export const ANTHROPIC_PROVIDER: ModelProvider = {
51
+ id: ANTHROPIC,
52
+ label: "Anthropic (Claude login)",
53
+ api: "anthropic",
54
+ models: [
55
+ "claude-opus-5-5",
56
+ "claude-sonnet-5-5",
57
+ "claude-haiku-4-5-20251001",
58
+ "claude-fable-5-1",
59
+ ],
60
+ enabled: true,
61
+ };
62
+
63
+ // Checked against each provider's docs on 2026-10-11: DeepSeek
64
+ // (api-docs.deepseek.com/guides/anthropic_api) and Z.ai
65
+ // (docs.z.ai/devpack/tool/claude). Off until a key reference is set.
66
+ export const PROVIDER_PRESETS: ModelProvider[] = [
67
+ ANTHROPIC_PROVIDER,
68
+ {
69
+ id: "deepseek",
70
+ label: "DeepSeek",
71
+ api: "anthropic",
72
+ baseUrl: "https://api.deepseek.com/anthropic",
73
+ authHeader: "x-api-key",
74
+ models: ["deepseek-v4-pro", "deepseek-flash"],
75
+ tiers: {
76
+ opus: "deepseek-v4-pro",
77
+ sonnet: "deepseek-flash",
78
+ haiku: "deepseek-flash",
79
+ },
80
+ enabled: false,
81
+ },
82
+ {
83
+ id: "zai",
84
+ label: "Z.ai (GLM)",
85
+ api: "anthropic",
86
+ baseUrl: "https://api.z.ai/api/anthropic",
87
+ authHeader: "bearer",
88
+ models: ["glm-5.3", "glm-5.3-flash"],
89
+ tiers: { opus: "glm-5.3", sonnet: "glm-5.3", haiku: "glm-5.3-flash" },
90
+ env: { API_TIMEOUT_MS: "3000000" },
91
+ enabled: false,
92
+ },
93
+ ];
94
+
95
+ // Dollars per million tokens, from each provider's price page on
96
+ // 2026-10-11. DeepSeek and GLM have no cache-write price (a write costs
97
+ // what input does), and DeepSeek's are its peak prices.
98
+ export const DEFAULT_PRICES: Record<string, ModelPrice> = {
99
+ "anthropic/claude-opus-5-5": {
100
+ input: 4,
101
+ cacheWrite: 5,
102
+ cacheRead: 0.2,
103
+ output: 20,
104
+ },
105
+ "anthropic/claude-sonnet-5-5": {
106
+ input: 2,
107
+ cacheWrite: 2.5,
108
+ cacheRead: 0.1,
109
+ output: 10,
110
+ },
111
+ "anthropic/claude-haiku-4-5-20251001": {
112
+ input: 1,
113
+ cacheWrite: 1.25,
114
+ cacheRead: 0.1,
115
+ output: 5,
116
+ },
117
+ "anthropic/claude-fable-5-1": {
118
+ input: 10,
119
+ cacheWrite: 12.5,
120
+ cacheRead: 0.25,
121
+ output: 50,
122
+ },
123
+ "deepseek/deepseek-v4-pro": {
124
+ input: 1.32,
125
+ cacheWrite: 1.32,
126
+ cacheRead: 0.044,
127
+ output: 3.96,
128
+ },
129
+ "deepseek/deepseek-flash": {
130
+ input: 0.3,
131
+ cacheWrite: 0.3,
132
+ cacheRead: 0.006,
133
+ output: 1.2,
134
+ },
135
+ "zai/glm-5.3": { input: 1.4, cacheWrite: 1.4, cacheRead: 0.26, output: 4.4 },
136
+ "zai/glm-5.3-flash": {
137
+ input: 0.15,
138
+ cacheWrite: 0.15,
139
+ cacheRead: 0.03,
140
+ output: 0.5,
141
+ },
142
+ };
143
+
144
+ // Claude Code's family aliases, priced as the model each stands for.
145
+ export const ANTHROPIC_ALIASES: Record<string, string> = {
146
+ opus: "claude-opus-5-5",
147
+ sonnet: "claude-sonnet-5-5",
148
+ haiku: "claude-haiku-4-5-20251001",
149
+ fable: "claude-fable-5-1",
150
+ };
151
+
152
+ // Anthropic is billed by the plan, not per token: a turn there costs a share
153
+ // of the plan's window, and its dollars are what the API would have charged.
154
+ export const isMetered = (providerId: string) => providerId !== ANTHROPIC;
155
+
156
+ export function describeSecretRef(ref: SecretRef | undefined): string {
157
+ if (!ref) return "no key";
158
+ if (ref.kind === "tokenvault")
159
+ return `tokenvault ${ref.project}${ref.desc ? ` "${ref.desc}"` : ""}`;
160
+ if (ref.kind === "keychain")
161
+ return `Keychain ${ref.service}${ref.account ? ` (${ref.account})` : ""}`;
162
+ return `$${ref.name}`;
163
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Which model a piece of work runs on, and what its agent needs to reach
3
+ * it. The most specific setting wins: the task's own override, then its
4
+ * workspace's, then Settings, then AGENTOS_REVIEW_MODEL or
5
+ * AGENTOS_TITLE_MODEL, then the default (lib/models/roles).
6
+ */
7
+
8
+ import { db, type Session } from "../db";
9
+ import { getProject } from "../projects";
10
+ import {
11
+ effectiveModels,
12
+ roleMapSchema,
13
+ savedModelSettings,
14
+ workspaceModels,
15
+ type EffectiveModels,
16
+ type RoleMap,
17
+ } from "./config";
18
+ import { ANTHROPIC_PROVIDER, type ModelProvider } from "./providers";
19
+ import {
20
+ ANTHROPIC,
21
+ DEFAULT_ROLE_MODELS,
22
+ GATE_ROLES,
23
+ formatModelRef,
24
+ isModelRole,
25
+ parseModelRef,
26
+ sessionRole,
27
+ type ModelRole,
28
+ } from "./roles";
29
+ import {
30
+ resolveSecret,
31
+ secretShellExpansion,
32
+ type SecretCommand,
33
+ } from "./secrets";
34
+
35
+ export type RoleSource = "task" | "workspace" | "settings" | "env" | "default";
36
+
37
+ // The server's own environment can still stand in for a role's default:
38
+ // what AgentOS read before model routing existed.
39
+ const ENV_ROLE_MODELS: Partial<Record<ModelRole, string>> = {
40
+ review: "AGENTOS_REVIEW_MODEL",
41
+ scope: "AGENTOS_REVIEW_MODEL",
42
+ helper: "AGENTOS_TITLE_MODEL",
43
+ };
44
+
45
+ const envModel = (role: ModelRole): string | undefined => {
46
+ const name = ENV_ROLE_MODELS[role];
47
+ return (name && process.env[name]?.trim()) || undefined;
48
+ };
49
+
50
+ export interface RoutedModel {
51
+ role: ModelRole;
52
+ ref: string;
53
+ provider: ModelProvider;
54
+ model: string;
55
+ source: RoleSource;
56
+ }
57
+
58
+ // A task's override: one model for all its roles, or a model per role.
59
+ export type ModelOverride = string | RoleMap;
60
+
61
+ export function parseOverride(value: unknown): ModelOverride | null {
62
+ if (value == null || value === "") return null;
63
+ if (typeof value === "string") {
64
+ const ref = value.trim();
65
+ providerFor(parseModelRef(ref).provider);
66
+ return ref.includes("/") ? ref : formatModelRef(parseModelRef(ref));
67
+ }
68
+ const got = roleMapSchema.safeParse(value);
69
+ const gate = got.success && GATE_ROLES.find((r) => got.data[r]);
70
+ if (gate)
71
+ throw new Error(
72
+ `model: a task can't pick its own ${gate} model; set it in Settings or the workspace`
73
+ );
74
+ if (!got.success)
75
+ throw new Error(
76
+ `model: give "provider/model", or a map of role to "provider/model"`
77
+ );
78
+ for (const ref of Object.values(got.data))
79
+ if (ref) providerFor(parseModelRef(ref).provider);
80
+ return Object.keys(got.data).length ? got.data : null;
81
+ }
82
+
83
+ const overrideFor = (o: ModelOverride | null | undefined, role: ModelRole) =>
84
+ GATE_ROLES.includes(role) ? undefined : typeof o === "string" ? o : o?.[role];
85
+
86
+ // The provider by id, or throws why it can't be used.
87
+ export function providerFor(
88
+ id: string,
89
+ models: EffectiveModels = effectiveModels()
90
+ ): ModelProvider {
91
+ if (id === ANTHROPIC) return ANTHROPIC_PROVIDER;
92
+ const p = models.providers.find((x) => x.id === id);
93
+ if (!p) throw new Error(`No model provider "${id}" (Settings > Models)`);
94
+ if (!p.enabled) throw new Error(`The ${p.label} provider is turned off`);
95
+ if (!p.baseUrl || !p.auth)
96
+ throw new Error(`The ${p.label} provider needs a base URL and a key`);
97
+ if (p.api !== "anthropic")
98
+ throw new Error(
99
+ `${p.label} speaks the OpenAI API; Claude Code needs an Anthropic-compatible endpoint`
100
+ );
101
+ return p;
102
+ }
103
+
104
+ export function resolveRole(
105
+ role: ModelRole,
106
+ opts: { workspaceId?: string | null; override?: ModelOverride | null } = {},
107
+ models: EffectiveModels = effectiveModels()
108
+ ): RoutedModel {
109
+ const task = overrideFor(opts.override, role);
110
+ const ws = opts.workspaceId
111
+ ? workspaceModels(opts.workspaceId).roles?.[role]
112
+ : undefined;
113
+ const saved = models.roles[role];
114
+ // The variable stands in only where Settings names nothing for the role.
115
+ const env =
116
+ saved === DEFAULT_ROLE_MODELS[role] && !savedModelSettings().roles?.[role]
117
+ ? envModel(role)
118
+ : undefined;
119
+ const [ref, source]: [string, RoleSource] = task
120
+ ? [task, "task"]
121
+ : ws
122
+ ? [ws, "workspace"]
123
+ : saved !== DEFAULT_ROLE_MODELS[role]
124
+ ? [saved, "settings"]
125
+ : env
126
+ ? [env, "env"]
127
+ : [saved, "default"];
128
+ const parsed = parseModelRef(ref);
129
+ return {
130
+ role,
131
+ ref: formatModelRef(parsed),
132
+ provider: providerFor(parsed.provider, models),
133
+ model: parsed.model,
134
+ source,
135
+ };
136
+ }
137
+
138
+ // The role's built-in model on the Claude login: what work falls back to
139
+ // when the provider it's routed to can't be used.
140
+ export function defaultRoute(role: ModelRole): RoutedModel {
141
+ const parsed = parseModelRef(DEFAULT_ROLE_MODELS[role]);
142
+ return {
143
+ role,
144
+ ref: formatModelRef(parsed),
145
+ provider: ANTHROPIC_PROVIDER,
146
+ model: parsed.model,
147
+ source: "default",
148
+ };
149
+ }
150
+
151
+ export function resolveRoleOrDefault(
152
+ role: ModelRole,
153
+ opts: { workspaceId?: string | null } = {}
154
+ ): RoutedModel {
155
+ try {
156
+ return resolveRole(role, opts);
157
+ } catch {
158
+ return defaultRoute(role);
159
+ }
160
+ }
161
+
162
+ // A session's workspace: an orchestrator's own, or its project's.
163
+ export function sessionWorkspace(
164
+ session: Pick<Session, "workspace_id" | "project_id">
165
+ ): string | null {
166
+ if (session.workspace_id) return session.workspace_id;
167
+ return session.project_id
168
+ ? (getProject(session.project_id)?.workspace_id ?? null)
169
+ : null;
170
+ }
171
+
172
+ export function taskOverride(
173
+ session: Pick<Session, "model_overrides">
174
+ ): ModelOverride | null {
175
+ if (!session.model_overrides) return null;
176
+ try {
177
+ return parseOverride(JSON.parse(session.model_overrides));
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ // What the agent's environment needs beyond the model name, with the key
184
+ // fetched now. Nothing for Anthropic: the Claude login is the default.
185
+ export async function providerEnv(
186
+ provider: ModelProvider,
187
+ model: string,
188
+ exec?: SecretCommand
189
+ ): Promise<Record<string, string>> {
190
+ if (provider.id === ANTHROPIC) return {};
191
+ const key = await resolveSecret(provider.auth!, exec);
192
+ return { ...routingEnv(provider, model), [authVar(provider)]: key };
193
+ }
194
+
195
+ // The same for a terminal launch: the plain settings, and shell text that
196
+ // sets the key from its reference inside the agent's shell.
197
+ export function providerShellEnv(
198
+ provider: ModelProvider,
199
+ model: string
200
+ ): { env: Record<string, string>; exportKey: string } {
201
+ if (provider.id === ANTHROPIC) return { env: {}, exportKey: "" };
202
+ return {
203
+ env: routingEnv(provider, model),
204
+ exportKey: `export ${authVar(provider)}=${secretShellExpansion(provider.auth!)};`,
205
+ };
206
+ }
207
+
208
+ const authVar = (p: ModelProvider) =>
209
+ p.authHeader === "x-api-key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN";
210
+
211
+ // The endpoint, Claude Code's tiers mapped to its models (so subagents and
212
+ // background calls asking for "haiku" stay on it), and the other key
213
+ // variable emptied so a key in AgentOS's own environment isn't sent there.
214
+ function routingEnv(p: ModelProvider, model: string): Record<string, string> {
215
+ const other =
216
+ authVar(p) === "ANTHROPIC_API_KEY"
217
+ ? "ANTHROPIC_AUTH_TOKEN"
218
+ : "ANTHROPIC_API_KEY";
219
+ return {
220
+ ...p.env,
221
+ ANTHROPIC_BASE_URL: p.baseUrl!,
222
+ [other]: "",
223
+ ANTHROPIC_DEFAULT_OPUS_MODEL: p.tiers?.opus ?? model,
224
+ ANTHROPIC_DEFAULT_SONNET_MODEL: p.tiers?.sonnet ?? model,
225
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: p.tiers?.haiku ?? model,
226
+ CLAUDE_CODE_SUBAGENT_MODEL: p.tiers?.sonnet ?? model,
227
+ };
228
+ }
229
+
230
+ // Records what a session runs on: the model its CLI is given, and its
231
+ // provider (NULL for Anthropic).
232
+ export function setSessionModel(sessionId: string, routed: RoutedModel): void {
233
+ db.prepare(
234
+ `UPDATE sessions SET model = ?, model_provider = ? WHERE id = ?`
235
+ ).run(
236
+ routed.model,
237
+ routed.provider.id === ANTHROPIC ? null : routed.provider.id,
238
+ sessionId
239
+ );
240
+ }
241
+
242
+ export const sessionModelRef = (
243
+ s: Pick<Session, "model" | "model_provider">
244
+ ): string => `${s.model_provider ?? ANTHROPIC}/${s.model}`;
245
+
246
+ export { isModelRole, sessionRole };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Resuming a task on another model: its conversation and worktree carry on,
3
+ * only the model (and maybe the provider) under it changes. The choice is
4
+ * kept as the task's own override, so later turns and restarts stay on it.
5
+ */
6
+
7
+ import { db, type Session } from "../db";
8
+ import { chatState, sendChat } from "../chat/runner";
9
+ import { launchPending } from "../tasks/launch-gate";
10
+ import { killTmux } from "../tasks/start";
11
+ import { launchClaude } from "../agents/launch";
12
+ import { switchSessionModel } from "./session-model";
13
+ import {
14
+ parseOverride,
15
+ resolveRole,
16
+ sessionWorkspace,
17
+ setSessionModel,
18
+ taskOverride,
19
+ type ModelOverride,
20
+ } from "./resolve";
21
+ import { sessionRole } from "./roles";
22
+
23
+ export const RESUME_MESSAGE =
24
+ "You were paused. Carry on where you left off: check the worktree and the conversation above for where things stand, then finish the task.";
25
+
26
+ // A per-role map adds to the one it has; one model replaces it.
27
+ export function mergeOverride(
28
+ before: ModelOverride | null,
29
+ next: ModelOverride
30
+ ): ModelOverride {
31
+ if (typeof next === "string" || !before) return next;
32
+ if (typeof before === "string") return next;
33
+ return { ...before, ...next };
34
+ }
35
+
36
+ export interface ResumeResult {
37
+ ref: string;
38
+ // Whether a message went to its agent now: a task still setting up, or
39
+ // held, starts on the new model by itself.
40
+ resumed: boolean;
41
+ }
42
+
43
+ export async function resumeOnModel(
44
+ sessionId: string,
45
+ model: unknown,
46
+ message = RESUME_MESSAGE
47
+ ): Promise<ResumeResult> {
48
+ const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
49
+ | Session
50
+ | undefined;
51
+ if (!s?.task_status) throw new Error("No such task");
52
+ if (s.task_status !== "running")
53
+ throw new Error(
54
+ `That task is ${s.task_status}; only a running one resumes`
55
+ );
56
+ const next = parseOverride(model);
57
+ if (!next) throw new Error("Pick a model");
58
+ const override = mergeOverride(taskOverride(s), next);
59
+ const routed = resolveRole(sessionRole(s), {
60
+ workspaceId: sessionWorkspace(s),
61
+ override,
62
+ });
63
+ const state = s.view === "chat" ? chatState(sessionId) : null;
64
+ if (state === "running" || state === "waiting")
65
+ throw new Error(
66
+ "It's mid-turn: stop it first, or wait for the turn to end"
67
+ );
68
+ db.prepare(`UPDATE sessions SET model_overrides = ? WHERE id = ?`).run(
69
+ JSON.stringify(override),
70
+ sessionId
71
+ );
72
+ if (launchPending(sessionId)) {
73
+ setSessionModel(sessionId, routed);
74
+ return { ref: routed.ref, resumed: false };
75
+ }
76
+ if (s.view === "chat") {
77
+ await switchSessionModel(sessionId, routed);
78
+ await sendChat(sessionId, {
79
+ text: message,
80
+ origin: { kind: "decision", label: `Resumed on ${routed.ref}` },
81
+ });
82
+ return { ref: routed.ref, resumed: true };
83
+ }
84
+ if (!s.claude_session_id || !s.worktree_path)
85
+ throw new Error("Its terminal conversation can't be resumed");
86
+ setSessionModel(sessionId, routed);
87
+ await killTmux(s.tmux_name);
88
+ await launchClaude({
89
+ sessionId,
90
+ tmuxName: s.tmux_name,
91
+ cwd: s.worktree_path,
92
+ model: routed.model,
93
+ provider: routed.provider.id === "anthropic" ? null : routed.provider.id,
94
+ prompt: message,
95
+ brief: s.task_brief ?? undefined,
96
+ resume: s.claude_session_id,
97
+ });
98
+ return { ref: routed.ref, resumed: true };
99
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The kinds of work AgentOS launches agents for, and the model each runs on
3
+ * unless Settings, a workspace or the task says otherwise. A model is named
4
+ * by a reference, "provider/model": the provider is one of lib/models/
5
+ * providers, and the model is that provider's own id. Imported by the
6
+ * browser too, so no Node modules here.
7
+ */
8
+
9
+ export const MODEL_ROLES = [
10
+ "plan",
11
+ "implement",
12
+ "orchestrator",
13
+ "review",
14
+ "scope",
15
+ "interactive",
16
+ "helper",
17
+ ] as const;
18
+
19
+ export type ModelRole = (typeof MODEL_ROLES)[number];
20
+
21
+ export const ROLE_LABELS: Record<ModelRole, { label: string; hint: string }> = {
22
+ plan: {
23
+ label: "Task planning",
24
+ hint: "A task's plan step, written to a file before any code changes",
25
+ },
26
+ implement: {
27
+ label: "Task implementation",
28
+ hint: "A task's agent, following the plan in its worktree",
29
+ },
30
+ orchestrator: { label: "Orchestrator", hint: "A workspace's orchestrator" },
31
+ review: {
32
+ label: "Gate review",
33
+ hint: "The independent review a PR passes before it merges",
34
+ },
35
+ scope: { label: "Scope check", hint: "Whether a diff stays in its card" },
36
+ interactive: {
37
+ label: "Interactive sessions",
38
+ hint: "Sessions you start and talk to yourself",
39
+ },
40
+ helper: {
41
+ label: "Chat & summary helpers",
42
+ hint: "Titles, summaries and digests",
43
+ },
44
+ };
45
+
46
+ export const ANTHROPIC = "anthropic";
47
+ const OPUS = `${ANTHROPIC}/claude-opus-5-5`;
48
+ const SONNET = `${ANTHROPIC}/claude-sonnet-5-5`;
49
+ const HAIKU = `${ANTHROPIC}/claude-haiku-4-5-20251001`;
50
+
51
+ // Claude stays the default for every role: another provider is opt-in.
52
+ export const DEFAULT_ROLE_MODELS: Record<ModelRole, string> = {
53
+ plan: OPUS,
54
+ review: OPUS,
55
+ implement: SONNET,
56
+ orchestrator: SONNET,
57
+ scope: SONNET,
58
+ interactive: OPUS,
59
+ helper: HAIKU,
60
+ };
61
+
62
+ // The roles that gate a task's merge: only Settings and its workspace pick
63
+ // their model, never the task (or the orchestrator that started it), so
64
+ // work can't choose its own reviewer.
65
+ export const GATE_ROLES: readonly ModelRole[] = ["review", "scope"];
66
+ export const TASK_ROLES = MODEL_ROLES.filter((r) => !GATE_ROLES.includes(r));
67
+
68
+ export const isModelRole = (v: unknown): v is ModelRole =>
69
+ typeof v === "string" && (MODEL_ROLES as readonly string[]).includes(v);
70
+
71
+ export interface ModelRef {
72
+ provider: string;
73
+ model: string;
74
+ }
75
+
76
+ // "provider/model", split at the first slash (a model id may hold more).
77
+ // A bare model ("opus", "claude-sonnet-5-5") is Anthropic's.
78
+ export function parseModelRef(ref: string): ModelRef {
79
+ const s = ref.trim();
80
+ const at = s.indexOf("/");
81
+ if (at <= 0) return { provider: ANTHROPIC, model: s };
82
+ return { provider: s.slice(0, at), model: s.slice(at + 1) };
83
+ }
84
+
85
+ export const formatModelRef = (r: ModelRef) => `${r.provider}/${r.model}`;
86
+
87
+ export const REF_PATTERN = /^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i;
88
+
89
+ // The role a session's next turn runs as.
90
+ export function sessionRole(session: {
91
+ role?: string | null;
92
+ task_status?: string | null;
93
+ plan_phase?: string | null;
94
+ }): ModelRole {
95
+ if (session.role === "orchestrator") return "orchestrator";
96
+ if (!session.task_status) return "interactive";
97
+ return session.plan_phase === "planning" ? "plan" : "implement";
98
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A provider's key, fetched from where its reference points at the moment
3
+ * an agent launches, and handed straight to that agent's environment. It is
4
+ * never written anywhere: not the database, not a log, not a prompt. Every
5
+ * key fetched is remembered in memory only so redact() can scrub it from
6
+ * any text on its way to a log or an error.
7
+ */
8
+
9
+ import { execFile } from "child_process";
10
+ import { shellQuote } from "../hosts/ssh";
11
+ import type { SecretRef } from "./providers";
12
+
13
+ export type SecretCommand = (cmd: string, args: string[]) => Promise<string>;
14
+
15
+ const run: SecretCommand = (cmd, args) =>
16
+ new Promise((resolve, reject) =>
17
+ execFile(cmd, args, { timeout: 15_000 }, (err, stdout) =>
18
+ // Its stderr isn't passed on: a tool may echo what it was asked.
19
+ err ? reject(new Error(`${cmd} failed`)) : resolve(stdout)
20
+ )
21
+ );
22
+
23
+ // An environment variable a key may be read from: one named for an API key,
24
+ // never AgentOS's own nor its Claude login's, so a reference can't lift an
25
+ // unrelated secret out of the server's environment.
26
+ export const KEY_ENV_NAME =
27
+ /^(?!(?:ANTHROPIC|CLAUDE|AGENTOS)_)[A-Z][A-Z0-9_]*_API_KEY$/;
28
+
29
+ // The command that prints the key, as argv; null for an environment
30
+ // variable, which is read directly.
31
+ export function secretCommand(ref: SecretRef): string[] | null {
32
+ if (ref.kind === "tokenvault")
33
+ return ["tv", "get", ref.project, ...(ref.desc ? [ref.desc] : [])];
34
+ if (ref.kind === "keychain")
35
+ return [
36
+ "security",
37
+ "find-generic-password",
38
+ "-s",
39
+ ref.service,
40
+ ...(ref.account ? ["-a", ref.account] : []),
41
+ "-w",
42
+ ];
43
+ return null;
44
+ }
45
+
46
+ // The same, as shell text that expands to the key inside the agent's own
47
+ // shell (a terminal launch), so AgentOS never holds it at all.
48
+ export function secretShellExpansion(ref: SecretRef): string {
49
+ const argv = secretCommand(ref);
50
+ if (!argv) return `"\${${ref.kind === "env" ? envName(ref.name) : ""}}"`;
51
+ return `"$(${argv.map(shellQuote).join(" ")})"`;
52
+ }
53
+
54
+ function envName(name: string): string {
55
+ if (!KEY_ENV_NAME.test(name))
56
+ throw new Error(
57
+ `A key can't be read from ${name}: name a *_API_KEY variable`
58
+ );
59
+ return name;
60
+ }
61
+
62
+ const seen = new Set<string>();
63
+
64
+ export async function resolveSecret(
65
+ ref: SecretRef,
66
+ exec: SecretCommand = run
67
+ ): Promise<string> {
68
+ const argv = secretCommand(ref);
69
+ let value: string | undefined;
70
+ if (argv) {
71
+ const out = await exec(argv[0], argv.slice(1)).catch(() => {
72
+ throw new Error(`Couldn't read the key (${argv[0]} failed)`);
73
+ });
74
+ value = out.trim();
75
+ } else if (ref.kind === "env") value = process.env[envName(ref.name)]?.trim();
76
+ if (!value) throw new Error("The key reference resolved to nothing");
77
+ if (value.length >= 8) seen.add(value);
78
+ return value;
79
+ }
80
+
81
+ // Scrubs every key fetched so far out of the text.
82
+ export function redact(text: string): string {
83
+ let out = text;
84
+ for (const s of seen) out = out.split(s).join("[redacted]");
85
+ return out;
86
+ }