@saadnvd1/agent-os 0.3.572 → 0.3.574

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.
@@ -17,7 +17,7 @@ export async function GET() {
17
17
  });
18
18
  }
19
19
 
20
- // PUT /api/models {roles?, providers?, prices?, planStep?} - replaces the
20
+ // PUT /api/models {roles?, subagents?, providers?, prices?, planStep?} - replaces the
21
21
  // saved settings. Only from this machine or the tailnet: a provider's base
22
22
  // URL and key reference decide where a key is sent, so a stolen device
23
23
  // token mustn't be able to point them anywhere.
@@ -0,0 +1,10 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { subagentModelUsage } from "@/lib/usage/subagents";
3
+
4
+ // GET /api/usage/subagents?days=7 - calls and tokens by subagent type and
5
+ // the model that answered, for the last 1-90 days.
6
+ export async function GET(request: NextRequest) {
7
+ const asked = Number(request.nextUrl.searchParams.get("days"));
8
+ const days = Number.isFinite(asked) && asked >= 1 ? Math.min(asked, 90) : 7;
9
+ return NextResponse.json({ days, rows: subagentModelUsage(days) });
10
+ }
@@ -10,6 +10,7 @@ import {
10
10
  type ModelPrice,
11
11
  type ModelProvider,
12
12
  } from "@/lib/models/providers";
13
+ import { SubagentModels } from "./SubagentModels";
13
14
  import { MODEL_ROLES, ROLE_LABELS } from "@/lib/models/roles";
14
15
  import { ModelSelect } from "./ModelSelect";
15
16
  import { ProviderCard } from "./ProviderCard";
@@ -98,6 +99,12 @@ export function ModelsSection() {
98
99
  />
99
100
  </section>
100
101
 
102
+ <SubagentModels
103
+ saved={saved.subagents}
104
+ disabled={update.isPending}
105
+ onChange={(subagents) => save({ subagents })}
106
+ />
107
+
101
108
  <section className="space-y-2">
102
109
  <h3 className="text-sm font-medium">Providers</h3>
103
110
  <p className="text-muted-foreground text-xs">
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select";
13
+ import {
14
+ DEFAULT_SUBAGENT_MODELS,
15
+ mustNotBeHaiku,
16
+ SUBAGENT_PATTERN,
17
+ SUBAGENT_TIERS,
18
+ TIER_LABELS,
19
+ type SubagentModels as Map,
20
+ type SubagentTier,
21
+ } from "@/lib/models/subagent-tiers";
22
+
23
+ // Which model each kind of subagent runs on (the Agent tool's
24
+ // subagent_type), for Claude sessions. Types that plan, decide or review
25
+ // can't be put on Haiku.
26
+ export function SubagentModels({
27
+ saved,
28
+ disabled,
29
+ onChange,
30
+ }: {
31
+ saved: Map | undefined;
32
+ disabled: boolean;
33
+ onChange: (next: Map) => void;
34
+ }) {
35
+ const [adding, setAdding] = useState("");
36
+ const rows = { ...DEFAULT_SUBAGENT_MODELS, ...saved };
37
+ const valid = SUBAGENT_PATTERN.test(adding) && !(adding in rows);
38
+ const set = (type: string, tier: SubagentTier) =>
39
+ onChange({ ...saved, [type]: tier });
40
+
41
+ return (
42
+ <section className="space-y-2">
43
+ <h3 className="text-sm font-medium">Subagent models</h3>
44
+ <p className="text-muted-foreground text-xs">
45
+ Subagents a Claude session starts run on their own model by type: search
46
+ on Haiku, anything that plans, decides or reviews on Sonnet. A new
47
+ session picks a change up.
48
+ </p>
49
+ <ul className="space-y-2">
50
+ {Object.entries(rows).map(([type, tier]) => {
51
+ const custom = !(type in DEFAULT_SUBAGENT_MODELS);
52
+ return (
53
+ <li
54
+ key={type}
55
+ className="bg-muted/40 flex items-center gap-2 rounded-lg px-3 py-2"
56
+ >
57
+ <span className="min-w-0 flex-1 truncate font-mono text-sm">
58
+ {type}
59
+ </span>
60
+ <Select
61
+ value={tier}
62
+ disabled={disabled}
63
+ onValueChange={(v) => set(type, v as SubagentTier)}
64
+ >
65
+ <SelectTrigger
66
+ aria-label={`Model for ${type}`}
67
+ className="min-h-11 w-44"
68
+ >
69
+ <SelectValue />
70
+ </SelectTrigger>
71
+ <SelectContent>
72
+ {SUBAGENT_TIERS.map((t) => (
73
+ <SelectItem
74
+ key={t}
75
+ value={t}
76
+ disabled={t === "haiku" && mustNotBeHaiku(type)}
77
+ >
78
+ {TIER_LABELS[t]}
79
+ </SelectItem>
80
+ ))}
81
+ </SelectContent>
82
+ </Select>
83
+ {(custom || saved?.[type] !== undefined) && (
84
+ <Button
85
+ variant="ghost"
86
+ className="min-h-11"
87
+ disabled={disabled}
88
+ onClick={() => {
89
+ const next = { ...saved };
90
+ delete next[type];
91
+ onChange(next);
92
+ }}
93
+ >
94
+ {custom ? "Remove" : "Reset"}
95
+ </Button>
96
+ )}
97
+ </li>
98
+ );
99
+ })}
100
+ </ul>
101
+ <div className="flex gap-2">
102
+ <Input
103
+ value={adding}
104
+ onChange={(e) => setAdding(e.target.value.trim())}
105
+ placeholder="Another type, or prefix*"
106
+ aria-label="Subagent type to add"
107
+ className="min-h-11"
108
+ />
109
+ <Button
110
+ variant="secondary"
111
+ className="min-h-11"
112
+ disabled={disabled || !valid}
113
+ onClick={() => {
114
+ set(adding, "inherit");
115
+ setAdding("");
116
+ }}
117
+ >
118
+ Add
119
+ </Button>
120
+ </div>
121
+ </section>
122
+ );
123
+ }
@@ -11,6 +11,7 @@ import { trustPromptKeys } from "../tasks/state";
11
11
  import { BUS_BRIEF } from "./brief";
12
12
  import { selfUrl } from "./self-env";
13
13
  import { autoCompactEnv } from "../chat/refresh";
14
+ import { subagentEnv } from "../models/subagents";
14
15
  import { CLAUDE_STATUS_SETTINGS_FLAG } from "../program-status/claude-flag";
15
16
  import {
16
17
  sessionProjectEnv,
@@ -38,6 +39,7 @@ export function agentEnv(sessionId: string): Record<string, string> {
38
39
  return {
39
40
  ...sessionProjectEnv(sessionId),
40
41
  ...autoCompactEnv(sessionId),
42
+ ...subagentEnv(),
41
43
  AGENTOS_SESSION_ID: sessionId,
42
44
  AGENTOS_URL: selfUrl(),
43
45
  PATH: `${AOS_BIN_DIR}:${process.env.PATH ?? ""}`,
@@ -262,9 +262,15 @@ export function guardHook(guard: ToolGuard): HookCallback {
262
262
  // role's guard (an orchestrator's, on where it writes) sees every call.
263
263
  export function preToolUseHooks(
264
264
  approvals: Approvals,
265
- guard?: ToolGuard
265
+ guard?: ToolGuard,
266
+ subagentModels?: HookCallback | null
266
267
  ): HookCallbackMatcher[] {
267
268
  return [
269
+ // Sets the model of a subagent call by its type. It only fills in the
270
+ // input, so the guard and permissions below still see the call.
271
+ ...(subagentModels
272
+ ? [{ matcher: "Agent|Task", hooks: [subagentModels] }]
273
+ : []),
268
274
  {
269
275
  matcher: "AskUserQuestion",
270
276
  hooks: [approvals.askQuestions],
@@ -25,6 +25,8 @@ import {
25
25
  URL_USERINFO,
26
26
  } from "../../orchestrator/untrusted";
27
27
  import { NO_ATTRIBUTION } from "../../agents/attribution";
28
+ import { SUBAGENTS_ENV } from "../../models/subagent-env";
29
+ import { parseSubagentEnv, subagentModelHook } from "../../models/subagents";
28
30
  import { callFromAssistant } from "../../usage/calls";
29
31
 
30
32
  // Claude Code through the Agent SDK, signed in with the user's own Claude
@@ -196,7 +198,11 @@ export const claudeDriver: ChatDriver = {
196
198
  allowedTools: options.allowedTools,
197
199
  disallowedTools: options.disallowedTools,
198
200
  hooks: {
199
- PreToolUse: preToolUseHooks(approvals, options.toolGuard),
201
+ PreToolUse: preToolUseHooks(
202
+ approvals,
203
+ options.toolGuard,
204
+ subagentHook(options.env)
205
+ ),
200
206
  ...(options.compactContext && {
201
207
  SessionStart: compactedHooks(options.compactContext),
202
208
  }),
@@ -378,6 +384,13 @@ export const claudeDriver: ChatDriver = {
378
384
  },
379
385
  };
380
386
 
387
+ // The hook that sets a subagent call's model, when the session's
388
+ // environment carries a map.
389
+ function subagentHook(env: Record<string, string> | undefined) {
390
+ const map = parseSubagentEnv(env?.[SUBAGENTS_ENV]);
391
+ return map ? subagentModelHook(map) : null;
392
+ }
393
+
381
394
  // The tokens of each response, subagents' too (the mapper drops those), and
382
395
  // the CLI's limit readings, for the usage ledger. `agents` maps a subagent's
383
396
  // parent tool call to its type, learned from the call that started it.
@@ -22,6 +22,13 @@ import {
22
22
  REF_PATTERN,
23
23
  type ModelRole,
24
24
  } from "./roles";
25
+ import {
26
+ mustNotBeHaiku,
27
+ SUBAGENT_PATTERN,
28
+ SUBAGENT_TIERS,
29
+ DEFAULT_SUBAGENT_MODELS,
30
+ type SubagentModels,
31
+ } from "./subagent-tiers";
25
32
 
26
33
  // A name passed to `tv` or `security` as its own argument: no option, no
27
34
  // line break, nothing a shell would read.
@@ -114,8 +121,26 @@ const price = z.strictObject({
114
121
  const roleRef = z.string().trim().regex(REF_PATTERN, "must be provider/model");
115
122
  const roleMap = z.partialRecord(z.enum(MODEL_ROLES), roleRef);
116
123
 
124
+ // Subagent type (or "prefix*") to the model its calls run on. A type that
125
+ // plans, decides or reviews can't be put on Haiku.
126
+ const subagentMap = z
127
+ .record(
128
+ z.string().regex(SUBAGENT_PATTERN, "not a subagent type"),
129
+ z.enum(SUBAGENT_TIERS)
130
+ )
131
+ .refine((m) => Object.keys(m).length <= 40, "at most 40 entries")
132
+ .superRefine((m, ctx) => {
133
+ for (const [pattern, tier] of Object.entries(m))
134
+ if (tier === "haiku" && mustNotBeHaiku(pattern))
135
+ ctx.addIssue({
136
+ code: "custom",
137
+ message: `${pattern} plans, decides or reviews, so it can't run on Haiku`,
138
+ });
139
+ });
140
+
117
141
  export const modelSettingsSchema = z.strictObject({
118
142
  roles: roleMap.optional(),
143
+ subagents: subagentMap.optional(),
119
144
  providers: z.array(providerSchema).optional(),
120
145
  prices: z.record(z.string().regex(REF_PATTERN), price).optional(),
121
146
  planStep: z.boolean().optional(),
@@ -200,6 +225,7 @@ export function setWorkspaceModels(
200
225
  // (by id), saved providers that aren't presets, and every role filled.
201
226
  export interface EffectiveModels {
202
227
  roles: Record<ModelRole, string>;
228
+ subagents: SubagentModels;
203
229
  providers: ModelProvider[];
204
230
  prices: Record<string, ModelPrice>;
205
231
  planStep: boolean;
@@ -212,6 +238,7 @@ export function effectiveModels(
212
238
  for (const p of saved.providers ?? []) byId.set(p.id, p as ModelProvider);
213
239
  return {
214
240
  roles: { ...DEFAULT_ROLE_MODELS, ...saved.roles },
241
+ subagents: { ...DEFAULT_SUBAGENT_MODELS, ...saved.subagents },
215
242
  providers: [...byId.values()],
216
243
  prices: { ...DEFAULT_PRICES, ...saved.prices },
217
244
  planStep: saved.planStep ?? true,
@@ -5,6 +5,7 @@
5
5
  * AGENTOS_TITLE_MODEL, then the default (lib/models/roles).
6
6
  */
7
7
 
8
+ import { SUBAGENTS_ENV } from "./subagent-env";
8
9
  import { db, type Session } from "../db";
9
10
  import { getProject } from "../projects";
10
11
  import {
@@ -227,6 +228,8 @@ function routingEnv(p: ModelProvider, model: string): Record<string, string> {
227
228
  ANTHROPIC_DEFAULT_SONNET_MODEL: p.tiers?.sonnet ?? model,
228
229
  ANTHROPIC_DEFAULT_HAIKU_MODEL: p.tiers?.haiku ?? model,
229
230
  CLAUDE_CODE_SUBAGENT_MODEL: p.tiers?.sonnet ?? model,
231
+ // Per-type subagent routing is for Claude's own models.
232
+ [SUBAGENTS_ENV]: "",
230
233
  };
231
234
  }
232
235
 
@@ -0,0 +1,2 @@
1
+ // The variable a session's subagent model map is passed in.
2
+ export const SUBAGENTS_ENV = "AGENTOS_SUBAGENT_MODELS";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Which model a subagent runs on, by its type. Read-only search (Explore)
3
+ * runs on Haiku; anything that plans, decides or reviews on Sonnet, and
4
+ * never on Haiku. Imported by the browser too, so no Node modules here.
5
+ */
6
+
7
+ export const SUBAGENT_TIERS = ["haiku", "sonnet", "opus", "inherit"] as const;
8
+ export type SubagentTier = (typeof SUBAGENT_TIERS)[number];
9
+
10
+ // Subagent type (or "prefix*") to tier. "inherit" leaves a call on the
11
+ // session's model.
12
+ export type SubagentModels = Record<string, SubagentTier>;
13
+
14
+ export const DEFAULT_SUBAGENT_MODELS: SubagentModels = {
15
+ Explore: "haiku",
16
+ Plan: "sonnet",
17
+ "general-purpose": "sonnet",
18
+ "review-*": "sonnet",
19
+ };
20
+
21
+ // Types that plan, decide or review: Settings can't put these on Haiku.
22
+ export const NEVER_HAIKU = ["Plan", "general-purpose", "review-*"];
23
+
24
+ export const TIER_LABELS: Record<SubagentTier, string> = {
25
+ haiku: "Haiku 4.5",
26
+ sonnet: "Sonnet 5.5",
27
+ opus: "Opus 5.5",
28
+ inherit: "Same as the session",
29
+ };
30
+
31
+ export const SUBAGENT_PATTERN = /^[A-Za-z0-9:_-]{1,80}\*?$/;
32
+
33
+ // Whether a pattern could match a type that must not run on Haiku.
34
+ export function mustNotBeHaiku(pattern: string): boolean {
35
+ const wild = pattern.endsWith("*");
36
+ const p = wild ? pattern.slice(0, -1) : pattern;
37
+ return NEVER_HAIKU.some((n) => {
38
+ const q = n.endsWith("*") ? n.slice(0, -1) : null;
39
+ if (q === null) return wild ? n.startsWith(p) : n === p;
40
+ return wild ? p.startsWith(q) || q.startsWith(p) : p.startsWith(q);
41
+ });
42
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Routing a subagent's model by its type. Claude Code takes a subagent's
3
+ * model from the call, then its definition (the built-in Explore and Plan
4
+ * say "inherit"), then the session's: so a PreToolUse hook sets the call's
5
+ * own model. The map rides in the session's environment; a routed provider
6
+ * gets none (its tiers are mapped in lib/models/resolve instead).
7
+ */
8
+
9
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
10
+ import {
11
+ routeAgentInput,
12
+ tierFor,
13
+ } from "../../scripts/subagent-model-hook.mjs";
14
+ import { effectiveModels } from "./config";
15
+ import { SUBAGENTS_ENV } from "./subagent-env";
16
+ import {
17
+ DEFAULT_SUBAGENT_MODELS,
18
+ SUBAGENT_TIERS,
19
+ type SubagentModels,
20
+ } from "./subagent-tiers";
21
+
22
+ export { routeAgentInput, tierFor };
23
+
24
+ export const effectiveSubagentModels = (
25
+ saved?: SubagentModels
26
+ ): SubagentModels => ({ ...DEFAULT_SUBAGENT_MODELS, ...saved });
27
+
28
+ // The environment that turns routing on for a session. A settings read that
29
+ // fails leaves the session unrouted rather than not started.
30
+ export function subagentEnv(): Record<string, string> {
31
+ try {
32
+ return { [SUBAGENTS_ENV]: JSON.stringify(effectiveModels().subagents) };
33
+ } catch (error) {
34
+ console.warn("[models] subagent routing off:", error);
35
+ return {};
36
+ }
37
+ }
38
+
39
+ // The map from a session's environment, or null when routing is off.
40
+ export function parseSubagentEnv(
41
+ raw: string | undefined
42
+ ): SubagentModels | null {
43
+ if (!raw) return null;
44
+ try {
45
+ const map = JSON.parse(raw) as Record<string, unknown>;
46
+ const valid = Object.entries(map).filter(([, t]) =>
47
+ (SUBAGENT_TIERS as readonly unknown[]).includes(t)
48
+ );
49
+ return valid.length ? (Object.fromEntries(valid) as SubagentModels) : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ // The same rule as the terminal hook script, for chat sessions.
56
+ export function subagentModelHook(map: SubagentModels): HookCallback {
57
+ return async (input) => {
58
+ if (input.hook_event_name !== "PreToolUse") return {};
59
+ if (input.tool_name !== "Agent" && input.tool_name !== "Task") return {};
60
+ const updatedInput = routeAgentInput(
61
+ (input.tool_input ?? {}) as Record<string, unknown>,
62
+ map
63
+ );
64
+ if (!updatedInput) return {};
65
+ return {
66
+ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput },
67
+ };
68
+ };
69
+ }
@@ -6,6 +6,12 @@ import { NO_ATTRIBUTION } from "../agents/attribution";
6
6
  const DIR = path.join(os.homedir(), ".agent-os");
7
7
  const HOOK = path.join(DIR, "bin", "agentos-status");
8
8
  const SETTINGS = path.join(DIR, "claude-status-hooks.json");
9
+ const SUBAGENT_HOOK = path.join(DIR, "bin", "agentos-subagent-model.mjs");
10
+ const SUBAGENT_SOURCE = path.join(
11
+ process.cwd(),
12
+ "scripts",
13
+ "subagent-model-hook.mjs"
14
+ );
9
15
  const SOURCE = path.join(process.cwd(), "scripts", "claude-status-hook.sh");
10
16
 
11
17
  const EVENTS = [
@@ -21,8 +27,14 @@ const EVENTS = [
21
27
  const TOOL_EVENTS = new Set(["PreToolUse", "PermissionRequest", "PostToolUse"]);
22
28
 
23
29
  // The flag settings every terminal Claude AgentOS starts reads: the status
24
- // hooks, and no AI attribution on its commits and PRs.
25
- export function claudeStatusSettings(hook = HOOK) {
30
+ // hooks, the subagent model hook (a no-op unless the session's environment
31
+ // carries a map, lib/models/subagents), and no AI attribution on its
32
+ // commits and PRs.
33
+ export function claudeStatusSettings(
34
+ hook = HOOK,
35
+ subagentHook = SUBAGENT_HOOK,
36
+ node = process.execPath
37
+ ) {
26
38
  return {
27
39
  attribution: NO_ATTRIBUTION,
28
40
  hooks: Object.fromEntries(
@@ -39,6 +51,20 @@ export function claudeStatusSettings(hook = HOOK) {
39
51
  },
40
52
  ],
41
53
  },
54
+ ...(event === "PreToolUse"
55
+ ? [
56
+ {
57
+ matcher: "Agent|Task",
58
+ hooks: [
59
+ {
60
+ type: "command",
61
+ command: `${JSON.stringify(node)} ${JSON.stringify(subagentHook)}`,
62
+ timeout: 5,
63
+ },
64
+ ],
65
+ },
66
+ ]
67
+ : []),
42
68
  ],
43
69
  ])
44
70
  ),
@@ -64,6 +90,7 @@ export function installClaudeStatusHooks(): void {
64
90
  try {
65
91
  fs.mkdirSync(path.dirname(HOOK), { recursive: true });
66
92
  writeAtomic(HOOK, fs.readFileSync(SOURCE), 0o755);
93
+ writeAtomic(SUBAGENT_HOOK, fs.readFileSync(SUBAGENT_SOURCE), 0o755);
67
94
  writeAtomic(
68
95
  SETTINGS,
69
96
  Buffer.from(JSON.stringify(claudeStatusSettings(), null, 2) + "\n")
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Which model each kind of subagent ran on: its calls and tokens by subagent
3
+ * type and the model the response came from, so a routing change shows up
4
+ * in what actually ran.
5
+ */
6
+
7
+ import { db } from "../db";
8
+
9
+ export interface SubagentModelRow {
10
+ agentType: string;
11
+ model: string;
12
+ calls: number;
13
+ tokens: number;
14
+ }
15
+
16
+ export function subagentModelUsage(
17
+ days: number,
18
+ now = Date.now()
19
+ ): SubagentModelRow[] {
20
+ return db
21
+ .prepare(
22
+ `SELECT COALESCE(agent_type, 'unknown') AS agentType, model, COUNT(*) AS calls,
23
+ SUM(input_tokens + cache_read_tokens + cache_write_5m_tokens
24
+ + cache_write_1h_tokens + output_tokens) AS tokens
25
+ FROM usage_calls
26
+ WHERE kind IN ('sub', 'review') AND at >= ?
27
+ GROUP BY agentType, model
28
+ ORDER BY calls DESC, agentType, model`
29
+ )
30
+ .all(now - days * 86_400_000) as SubagentModelRow[];
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.572",
3
+ "version": "0.3.574",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,5 @@
1
+ export function tierFor(type: unknown, map: unknown): string | null;
2
+ export function routeAgentInput(
3
+ toolInput: Record<string, unknown> | undefined,
4
+ map: Record<string, string>
5
+ ): Record<string, unknown> | null;
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // A Claude Code PreToolUse hook for the Agent/Task tool: it sets the model of
3
+ // a subagent call from its type, so read-only searches run on a cheaper
4
+ // model. AgentOS copies it to ~/.agent-os/bin/agentos-subagent-model and
5
+ // lists it in the settings it passes to terminal Claude sessions; chat
6
+ // sessions run the same rule in-process (lib/models/subagents.ts). The map
7
+ // (subagent type or "prefix*" to haiku|sonnet|opus|inherit) is JSON in
8
+ // AGENTOS_SUBAGENT_MODELS. It always exits 0: a hook that fails must never
9
+ // break the session.
10
+
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ // The tier for a subagent type: an exact entry, else the longest "prefix*".
14
+ export function tierFor(type, map) {
15
+ if (typeof type !== "string" || !map || typeof map !== "object") return null;
16
+ if (Object.hasOwn(map, type)) return map[type];
17
+ let best = null;
18
+ for (const pattern of Object.keys(map)) {
19
+ if (!pattern.endsWith("*")) continue;
20
+ const prefix = pattern.slice(0, -1);
21
+ if (
22
+ type.startsWith(prefix) &&
23
+ (best === null || prefix.length > best.length)
24
+ )
25
+ best = prefix;
26
+ }
27
+ return best === null ? null : map[`${best}*`];
28
+ }
29
+
30
+ // The call's input with its model set, or null to leave the call alone. A
31
+ // caller's own choice is kept, except Haiku on a type routed higher: those
32
+ // decide, and never run on Haiku.
33
+ export function routeAgentInput(toolInput, map) {
34
+ const type = toolInput?.subagent_type;
35
+ if (type === "fork") return null;
36
+ const tier = tierFor(type, map);
37
+ if (!tier || tier === "inherit") return null;
38
+ const asked = toolInput.model;
39
+ if (asked === undefined || asked === null || asked === "")
40
+ return { ...toolInput, model: tier };
41
+ if (asked === "haiku" && tier !== "haiku")
42
+ return { ...toolInput, model: tier };
43
+ return null;
44
+ }
45
+
46
+ async function main() {
47
+ let map;
48
+ try {
49
+ map = JSON.parse(process.env.AGENTOS_SUBAGENT_MODELS || "");
50
+ } catch {
51
+ return;
52
+ }
53
+ let input = "";
54
+ for await (const chunk of process.stdin) input += chunk;
55
+ const call = JSON.parse(input);
56
+ if (call.tool_name !== "Agent" && call.tool_name !== "Task") return;
57
+ const updatedInput = routeAgentInput(call.tool_input, map);
58
+ if (updatedInput)
59
+ process.stdout.write(
60
+ JSON.stringify({
61
+ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput },
62
+ })
63
+ );
64
+ }
65
+
66
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href)
67
+ main().catch(() => {});