@saadnvd1/agent-os 0.3.471 → 0.3.473

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 +43 -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,40 @@
1
+ /**
2
+ * Moving a session to another model without losing its conversation or
3
+ * worktree. On the same provider a live conversation switches in place;
4
+ * across providers the endpoint and key are in the agent's environment, so
5
+ * its worker goes and the next message starts one that resumes the same
6
+ * conversation on the new provider.
7
+ */
8
+
9
+ import { db, type Session } from "../db";
10
+ import { registry } from "../chat/registry";
11
+ import { setChatModel } from "../chat/settings";
12
+ import { setSessionModel, type RoutedModel } from "./resolve";
13
+ import { ANTHROPIC } from "./roles";
14
+
15
+ export async function switchSessionModel(
16
+ sessionId: string,
17
+ routed: RoutedModel
18
+ ): Promise<void> {
19
+ const before = db
20
+ .prepare(`SELECT model_provider FROM sessions WHERE id = ?`)
21
+ .get(sessionId) as Pick<Session, "model_provider"> | undefined;
22
+ setSessionModel(sessionId, routed);
23
+ await applySessionModel(
24
+ sessionId,
25
+ before?.model_provider ?? ANTHROPIC,
26
+ routed
27
+ );
28
+ }
29
+
30
+ // What's live catches up with the row: the model now, or a fresh worker.
31
+ export async function applySessionModel(
32
+ sessionId: string,
33
+ fromProvider: string,
34
+ routed: RoutedModel
35
+ ): Promise<void> {
36
+ if (fromProvider === routed.provider.id)
37
+ return setChatModel(sessionId, routed.model);
38
+ if (registry.live.has(sessionId))
39
+ (await import("../chat/runner")).stopChat(sessionId);
40
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * "Test provider": one tiny request to a provider's endpoint with its key,
3
+ * reporting whether it answered. The key goes only into the request's
4
+ * header; what comes back is scrubbed of it before anyone sees it.
5
+ */
6
+
7
+ import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
8
+ import { effectiveModels } from "./config";
9
+ import type { ModelProvider } from "./providers";
10
+ import { ANTHROPIC } from "./roles";
11
+ import { redact, resolveSecret, type SecretCommand } from "./secrets";
12
+ import os from "os";
13
+
14
+ export interface ProviderTest {
15
+ ok: boolean;
16
+ message: string;
17
+ ms: number;
18
+ }
19
+
20
+ const PROMPT = "Reply with the word OK.";
21
+
22
+ export async function testProvider(
23
+ providerId: string,
24
+ model?: string,
25
+ deps: {
26
+ exec?: SecretCommand;
27
+ fetch?: typeof fetch;
28
+ claude?: ClaudeRunner;
29
+ } = {}
30
+ ): Promise<ProviderTest> {
31
+ const started = Date.now();
32
+ const done = (ok: boolean, message: string): ProviderTest => ({
33
+ ok,
34
+ message: redact(message).slice(0, 300),
35
+ ms: Date.now() - started,
36
+ });
37
+ const p = effectiveModels().providers.find((x) => x.id === providerId);
38
+ if (!p) return done(false, `No provider "${providerId}"`);
39
+ const m = model || p.tiers?.haiku || p.models[0];
40
+ if (!m) return done(false, "It lists no model to try");
41
+ try {
42
+ if (p.id === ANTHROPIC) {
43
+ await (deps.claude ?? runClaude)({
44
+ cwd: os.tmpdir(),
45
+ system: "Answer in the schema.",
46
+ prompt: PROMPT,
47
+ schema: {
48
+ type: "object",
49
+ properties: { ok: { type: "boolean" } },
50
+ required: ["ok"],
51
+ },
52
+ tools: [],
53
+ model: m,
54
+ timeoutMs: 60_000,
55
+ });
56
+ return done(true, `${m} answered through the Claude login`);
57
+ }
58
+ if (!p.baseUrl || !p.auth) return done(false, "Set its base URL and key");
59
+ const key = await resolveSecret(p.auth, deps.exec);
60
+ const res = await (deps.fetch ?? fetch)(...request(p, m, key));
61
+ // Never followed: a redirect would carry the key somewhere else.
62
+ if (res.status >= 300 && res.status < 400)
63
+ return done(
64
+ false,
65
+ `HTTP ${res.status}: the endpoint redirected; set its final URL`
66
+ );
67
+ if (res.ok) return done(true, `${m} answered (HTTP ${res.status})`);
68
+ const body = (await res.text().catch(() => ""))
69
+ .slice(0, 200)
70
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
71
+ return done(false, `HTTP ${res.status}: ${body}`);
72
+ } catch (e) {
73
+ return done(false, e instanceof Error ? e.message : String(e));
74
+ }
75
+ }
76
+
77
+ function request(
78
+ p: ModelProvider,
79
+ model: string,
80
+ key: string
81
+ ): [string, RequestInit] {
82
+ const base = p.baseUrl!.replace(/\/+$/, "");
83
+ const messages = [{ role: "user", content: PROMPT }];
84
+ const signal = AbortSignal.timeout(30_000);
85
+ const redirect = "manual" as const;
86
+ if (p.api === "openai")
87
+ return [
88
+ `${base}/chat/completions`,
89
+ {
90
+ method: "POST",
91
+ redirect,
92
+ signal,
93
+ headers: {
94
+ "content-type": "application/json",
95
+ authorization: `Bearer ${key}`,
96
+ },
97
+ body: JSON.stringify({ model, max_tokens: 8, messages }),
98
+ },
99
+ ];
100
+ return [
101
+ `${base}/v1/messages`,
102
+ {
103
+ method: "POST",
104
+ redirect,
105
+ signal,
106
+ headers: {
107
+ "content-type": "application/json",
108
+ "anthropic-version": "2023-06-01",
109
+ ...(p.authHeader === "x-api-key"
110
+ ? { "x-api-key": key }
111
+ : { authorization: `Bearer ${key}` }),
112
+ },
113
+ body: JSON.stringify({ model, max_tokens: 8, messages }),
114
+ },
115
+ ];
116
+ }
@@ -4,6 +4,7 @@
4
4
  * the brakes.
5
5
  */
6
6
 
7
+ import { parseOverride, type ModelOverride } from "../models/resolve";
7
8
  import { sendMessage } from "../bus";
8
9
  import { chatState, interruptChat } from "../chat/runner";
9
10
  import { hostExec, isRemoteHost } from "../hosts";
@@ -80,9 +81,17 @@ export async function startTask(
80
81
  workspaceId: string,
81
82
  projectRef: string,
82
83
  prompt: string,
83
- opts: { base?: string; name?: string; view?: StartView; after?: string } = {}
84
+ opts: {
85
+ base?: string;
86
+ name?: string;
87
+ view?: StartView;
88
+ after?: string;
89
+ model?: ModelOverride;
90
+ plan?: boolean;
91
+ } = {}
84
92
  ): Promise<string> {
85
93
  const project = workspaceProject(workspaceId, projectRef);
94
+ const model = parseOverride(opts.model) ?? undefined;
86
95
  // Its brakes read this machine's usage, not the other machine's account.
87
96
  if (isRemoteHost(project.host_id))
88
97
  throw new Error(
@@ -97,6 +106,7 @@ export async function startTask(
97
106
  prompt,
98
107
  name: opts.name,
99
108
  view,
109
+ model,
100
110
  baseBranch: opts.base || undefined,
101
111
  after: opts.after,
102
112
  originWorkspaceId: workspaceId,
@@ -112,6 +122,8 @@ export async function startTask(
112
122
  name: opts.name,
113
123
  baseBranch: opts.base || undefined,
114
124
  view,
125
+ model,
126
+ plan: opts.plan,
115
127
  }),
116
128
  (s) => s.id
117
129
  )
@@ -92,7 +92,8 @@ Reading:
92
92
  Acting:
93
93
  - \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
94
94
  - \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
95
- - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does.
95
+ - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); pass model ("provider/model", or a map of role to one) only when Saad asks for one, and another provider than anthropic only then.
96
+ - \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
96
97
  - \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?): an interactive session, a chat unless view is terminal.
97
98
  - \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
98
99
  - \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
@@ -10,6 +10,9 @@
10
10
 
11
11
  import { spawn } from "child_process";
12
12
  import { runUsageOf, type RunUsage } from "../usage/check-runs";
13
+ import { routedRun } from "../models/launch";
14
+ import type { ModelRole } from "../models/roles";
15
+ import { redact } from "../models/secrets";
13
16
 
14
17
  export interface ClaudeRun {
15
18
  cwd: string;
@@ -21,16 +24,18 @@ export interface ClaudeRun {
21
24
  // Permission rules within those, e.g. "Bash(git diff:*)".
22
25
  allow?: string[];
23
26
  timeoutMs?: number;
24
- // A model alias or id; the review model (or the CLI's default) otherwise.
27
+ // A model alias or id on Anthropic; the role's model otherwise.
25
28
  model?: string;
26
29
  // What the run cost, from its result, even when it failed.
27
30
  onUsage?: (usage: RunUsage) => void;
31
+ // The kind of work it is, which picks its model and provider
32
+ // (lib/models): Settings' and the workspace's role models.
33
+ role?: ModelRole;
34
+ workspaceId?: string | null;
28
35
  }
29
36
 
30
37
  export type ClaudeRunner = (run: ClaudeRun) => Promise<unknown>;
31
38
 
32
- const MODEL = process.env.AGENTOS_REVIEW_MODEL;
33
-
34
39
  // All a check's process gets from the server's environment: enough to run
35
40
  // and to sign in to Claude, nothing else.
36
41
  const ENV_ALLOW = [
@@ -57,7 +62,7 @@ export function checkEnv(
57
62
  }
58
63
 
59
64
  export function claudeArgs(run: ClaudeRun): string[] {
60
- const model = run.model ?? MODEL;
65
+ const model = run.model;
61
66
  return [
62
67
  "-p",
63
68
  "--output-format",
@@ -87,12 +92,35 @@ export function claudeArgs(run: ClaudeRun): string[] {
87
92
  ];
88
93
  }
89
94
 
95
+ interface ClaudeResult {
96
+ is_error?: boolean;
97
+ result?: string;
98
+ structured_output?: unknown;
99
+ }
100
+
90
101
  // The structured answer, or throws with what went wrong.
91
- export const runClaude: ClaudeRunner = (run) =>
92
- new Promise((resolve, reject) => {
102
+ export const runClaude: ClaudeRunner = async (run) => {
103
+ const routed =
104
+ run.role && !run.model
105
+ ? await routedRun(run.role, { workspaceId: run.workspaceId })
106
+ : null;
107
+ const result = await execClaude(
108
+ { ...run, model: run.model ?? routed?.model },
109
+ { ...checkEnv(), ...routed?.env },
110
+ routed?.provider ?? "anthropic"
111
+ );
112
+ return result.structured_output;
113
+ };
114
+
115
+ function execClaude(
116
+ run: ClaudeRun,
117
+ env: NodeJS.ProcessEnv,
118
+ provider: string
119
+ ): Promise<ClaudeResult> {
120
+ return new Promise((resolve, reject) => {
93
121
  const child = spawn("claude", claudeArgs(run), {
94
122
  cwd: run.cwd,
95
- env: checkEnv(),
123
+ env,
96
124
  stdio: ["pipe", "pipe", "pipe"],
97
125
  });
98
126
  let out = "";
@@ -113,20 +141,21 @@ export const runClaude: ClaudeRunner = (run) =>
113
141
  child.on("close", () => {
114
142
  clearTimeout(timer);
115
143
  try {
116
- const result = JSON.parse(out) as {
117
- is_error?: boolean;
118
- result?: string;
119
- structured_output?: unknown;
120
- };
144
+ const result = JSON.parse(out) as ClaudeResult;
121
145
  const usage = runUsageOf(result);
122
- if (usage) run.onUsage?.(usage);
146
+ if (usage) run.onUsage?.({ ...usage, provider });
123
147
  if (result.is_error || result.structured_output === undefined)
124
148
  throw new Error(result.result || "No answer");
125
- resolve(result.structured_output);
149
+ resolve(result);
126
150
  } catch (e) {
127
151
  const why = e instanceof Error ? e.message : String(e);
128
- reject(new Error(`claude -p failed: ${why} ${err.slice(-300)}`.trim()));
152
+ reject(
153
+ new Error(
154
+ redact(`claude -p failed: ${why} ${err.slice(-300)}`.trim())
155
+ )
156
+ );
129
157
  }
130
158
  });
131
159
  child.stdin.end(run.prompt);
132
160
  });
161
+ }
@@ -5,12 +5,12 @@
5
5
  * sessions.
6
6
  */
7
7
 
8
+ import { resolveRoleOrDefault } from "../models/resolve";
8
9
  import fs from "fs";
9
10
  import os from "os";
10
11
  import path from "path";
11
12
  import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
12
13
  import { db, type Session } from "../db";
13
- import { resolveModelForAgent } from "../model-catalog";
14
14
  import { getWorkspace } from "../workspaces";
15
15
 
16
16
  // It works through tools, not in a repo: a scratch folder of its own.
@@ -62,7 +62,7 @@ export function ensureOrchestrator(workspaceId: string): Session {
62
62
  `${workspace.name} orchestrator`,
63
63
  `claude-${id}`,
64
64
  dir,
65
- resolveModelForAgent("claude", null),
65
+ resolveRoleOrDefault("orchestrator", { workspaceId }).model,
66
66
  workspaceId
67
67
  );
68
68
  return getOrchestrator(workspaceId)!;
@@ -155,6 +155,8 @@ async function reviewJob(
155
155
  tools: ["Read", "Grep", "Glob"],
156
156
  allow: [`Read(/${dir}/**)`, `Grep(/${dir}/**)`, `Glob(/${dir}/**)`],
157
157
  onUsage,
158
+ role: "review",
159
+ workspaceId,
158
160
  });
159
161
  verdicts.push(toVerdict(answer));
160
162
  }
@@ -87,6 +87,8 @@ export async function checkScope(input: {
87
87
  sha,
88
88
  kind: "scope",
89
89
  }),
90
+ role: "scope",
91
+ workspaceId,
90
92
  })) as { within?: boolean; reason?: string };
91
93
  if (answer.within !== true) break;
92
94
  }
@@ -1,3 +1,5 @@
1
+ import { resumeOnModel } from "../models/resume";
2
+ import { workspaceTask } from "./targets";
1
3
  import { getWorkspace } from "../workspaces";
2
4
  import {
3
5
  done,
@@ -33,6 +35,7 @@ const ACTING = new Set<ToolName>([
33
35
  "send",
34
36
  "message_orchestrator",
35
37
  "start_task",
38
+ "set_task_model",
36
39
  "start_session",
37
40
  "stack",
38
41
  "land",
@@ -97,8 +100,18 @@ export async function runTool(
97
100
  name: a.name,
98
101
  view: a.view,
99
102
  after: a.after,
103
+ model: a.model,
104
+ plan: a.plan,
100
105
  });
101
106
  }
107
+ case "set_task_model": {
108
+ const a = p(tool, raw);
109
+ const task = workspaceTask(w, a.task);
110
+ const out = await resumeOnModel(task.id, a.model, a.message);
111
+ return out.resumed
112
+ ? `Resumed ${task.name} on ${out.ref}: same conversation and worktree.`
113
+ : `${task.name} hasn't launched yet; it starts on ${out.ref}.`;
114
+ }
102
115
  case "start_session": {
103
116
  const a = p(tool, raw);
104
117
  return startSession(w, a.project, a.prompt, {
@@ -14,6 +14,7 @@ export const TOOL_NAMES = {
14
14
  orchestrators: name("orchestrators"),
15
15
  message_orchestrator: name("message_orchestrator"),
16
16
  start_task: name("start_task"),
17
+ set_task_model: name("set_task_model"),
17
18
  start_session: name("start_session"),
18
19
  stack: name("stack"),
19
20
  stack_status: name("stack_status"),
@@ -6,6 +6,7 @@ import { BRANCH_NAME } from "../git";
6
6
  import { RAISED_KINDS } from "./asks";
7
7
  import { MAX_RULE_CHARS } from "./rules";
8
8
  import { PROJECT_NAME } from "../project-add/name";
9
+ import { TASK_ROLES, type ModelRole } from "../models/roles";
9
10
 
10
11
  const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
11
12
  const session = ref("Session name, project/name, or id from sessions");
@@ -19,6 +20,28 @@ const view = z
19
20
  "How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
20
21
  );
21
22
 
23
+ const modelRef = z
24
+ .string()
25
+ .trim()
26
+ .regex(/^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i, 'not "provider/model"');
27
+ const modelOverride = z
28
+ .union([
29
+ modelRef,
30
+ // Named optional keys, not z.partialRecord: its "propertyNames" makes
31
+ // Claude Code drop every tool on this server, not just this one.
32
+ z
33
+ .object(
34
+ Object.fromEntries(
35
+ TASK_ROLES.map((role) => [role, modelRef.optional()])
36
+ ) as Record<ModelRole, z.ZodOptional<typeof modelRef>>
37
+ )
38
+ .strict(),
39
+ ])
40
+ .optional()
41
+ .describe(
42
+ "Its own model, as \"provider/model\" (anthropic/claude-sonnet-5-5, deepseek/deepseek-v4-pro) for all its roles, or a map of role (plan, implement, review, scope) to one. Default: the workspace's and Settings' role models; another provider only when asked"
43
+ );
44
+
22
45
  export const TOOL_SHAPES = {
23
46
  sessions: {},
24
47
  read: {
@@ -79,6 +102,26 @@ export const TOOL_SHAPES = {
79
102
  .describe(
80
103
  'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first'
81
104
  ),
105
+ model: modelOverride,
106
+ plan: z
107
+ .boolean()
108
+ .optional()
109
+ .describe(
110
+ "Plan first on the planning model, then implement on the implementation model (default: Settings)"
111
+ ),
112
+ },
113
+ set_task_model: {
114
+ task,
115
+ model: modelOverride.unwrap(),
116
+ message: z
117
+ .string()
118
+ .trim()
119
+ .min(1)
120
+ .max(8000)
121
+ .optional()
122
+ .describe(
123
+ "What it's told on resuming (default: carry on where it left off)"
124
+ ),
82
125
  },
83
126
  start_session: {
84
127
  project,
@@ -61,7 +61,9 @@ const DESCRIPTIONS: Record<ToolName, string> = {
61
61
  message_orchestrator:
62
62
  "Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
63
63
  start_task:
64
- "Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Refused while a brake holds.",
64
+ "Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Refused while a brake holds.",
65
+ set_task_model:
66
+ 'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
65
67
  start_session:
66
68
  "Start an interactive agent session in a project with a prompt. It runs as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.",
67
69
  stack:
@@ -1,3 +1,4 @@
1
+ import { routedRun } from "./models/launch";
1
2
  import { execFile } from "child_process";
2
3
  import { git as runGit } from "./git-exec";
3
4
  import { run } from "./tasks/gh";
@@ -154,13 +155,15 @@ async function generateWithClaude(
154
155
 
155
156
  // `claude --print` with the prompt on stdin, off the event loop and killed
156
157
  // at the ceiling.
157
- function claudePrint(cwd: string, prompt: string): Promise<string> {
158
+ async function claudePrint(cwd: string, prompt: string): Promise<string> {
159
+ const routed = await routedRun("helper");
158
160
  return new Promise((resolve, reject) => {
159
161
  const child = execFile(
160
162
  "claude",
161
- ["--print"],
163
+ ["--print", "--model", routed.model],
162
164
  {
163
165
  cwd,
166
+ env: { ...process.env, ...routed.env },
164
167
  encoding: "utf-8",
165
168
  timeout: 30_000,
166
169
  killSignal: "SIGKILL",
@@ -73,7 +73,7 @@ export const PROVIDERS: ProviderDefinition[] = [
73
73
  supportsFork: true,
74
74
  resumeArgs: (id) => ["--resume", id],
75
75
  forkArgs: (id) => ["--resume", id, "--fork-session"],
76
- modelFlag: undefined, // Claude doesn't expose model flag
76
+ modelFlag: "--model",
77
77
  initialPromptFlag: "", // Positional argument
78
78
  },
79
79
  {
@@ -174,7 +174,7 @@ export async function generateTitle(
174
174
  prompt: input,
175
175
  schema: SCHEMA,
176
176
  tools: [],
177
- model: process.env.AGENTOS_TITLE_MODEL || "haiku",
177
+ role: "helper",
178
178
  timeoutMs: 30_000,
179
179
  })) as { title?: unknown };
180
180
  return cleanTitle(out?.title);
@@ -11,6 +11,8 @@ import { db, queries, type Session } from "../db";
11
11
  import type { AgentType } from "../providers";
12
12
  import type { ChatAccess, ChatImage } from "../chat/events";
13
13
  import { resolveModelForAgent } from "../model-catalog";
14
+ import { resolveRole, setSessionModel } from "../models/resolve";
15
+ import { ANTHROPIC } from "../models/roles";
14
16
  import { getProject } from "../projects";
15
17
  import { getHost, isRemoteHost } from "../hosts";
16
18
  import { hostLink } from "../hosts/remote-api";
@@ -214,14 +216,26 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
214
216
  await fs.promises.mkdir(SCRATCH_DIR, { recursive: true });
215
217
 
216
218
  const agentType = input.agentType;
217
- const model = resolveModelForAgent(
218
- agentType,
219
- input.model?.trim() || project?.default_model
220
- );
221
219
  const view =
222
220
  input.view === "terminal" || !supportsChat(agentType) || remote
223
221
  ? "terminal"
224
222
  : DEFAULT_START_VIEW;
223
+ // Claude with no model picked runs on the interactive role's, here; on
224
+ // another provider only as a chat, whose worker carries its endpoint.
225
+ const routed =
226
+ agentType === "claude" && !input.model?.trim() && !remote
227
+ ? resolveRole("interactive", { workspaceId: project?.workspace_id })
228
+ : null;
229
+ const interactive =
230
+ routed && (routed.provider.id === ANTHROPIC || view === "chat")
231
+ ? routed
232
+ : null;
233
+ const model = interactive
234
+ ? interactive.model
235
+ : resolveModelForAgent(
236
+ agentType,
237
+ input.model?.trim() || project?.default_model
238
+ );
225
239
  const text = input.prompt?.trim() ?? "";
226
240
  const naming: Naming = text
227
241
  ? await nameFor(text, projectPath, input.name)
@@ -252,6 +266,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
252
266
  db.prepare(
253
267
  `UPDATE sessions SET view = ?, name_source = ?, setup_status = ? WHERE id = ?`
254
268
  ).run(view, naming.source, useWorktree ? "running" : null, id);
269
+ if (interactive) setSessionModel(id, interactive);
255
270
  if (input.access)
256
271
  db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
257
272
  input.access,
@@ -7,6 +7,7 @@ export const SETTINGS_PARAM = "settings";
7
7
  export const SETTINGS_SECTIONS = [
8
8
  "merging",
9
9
  "workspaces",
10
+ "models",
10
11
  "devices",
11
12
  "notifications",
12
13
  "schedules",