@saadnvd1/agent-os 0.3.570 → 0.3.571

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.
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GET } from "./route";
3
+
4
+ describe("GET /api/client-config", () => {
5
+ it("serves the config uncached", async () => {
6
+ const res = GET();
7
+ expect(res.status).toBe(200);
8
+ expect(res.headers.get("cache-control")).toBe("no-store");
9
+ expect((await res.json()).v).toBe(1);
10
+ });
11
+ });
@@ -0,0 +1,10 @@
1
+ import { NextResponse } from "next/server";
2
+ import { clientConfig } from "@/lib/config/client-config";
3
+
4
+ // GET /api/client-config - the labels and defaults a client renders with.
5
+ // Read-only; the app keeps its own cache, so the server never does.
6
+ export function GET() {
7
+ return NextResponse.json(clientConfig(), {
8
+ headers: { "Cache-Control": "no-store" },
9
+ });
10
+ }
@@ -14,28 +14,20 @@ import {
14
14
  DropdownMenuItem,
15
15
  DropdownMenuTrigger,
16
16
  } from "@/components/ui/dropdown-menu";
17
+ import { ACCESS_LEVELS } from "@/lib/chat/access-levels";
17
18
  import { CHAT_ACCESS, type ChatAccess } from "@/lib/chat/events";
18
19
 
19
- const LEVELS: Record<
20
+ const ICON: Record<ChatAccess, typeof Shield> = {
21
+ ask: ShieldCheck,
22
+ edits: Shield,
23
+ full: ShieldOff,
24
+ };
25
+ const LEVELS = Object.fromEntries(
26
+ CHAT_ACCESS.map((a) => [a, { ...ACCESS_LEVELS[a], icon: ICON[a] }])
27
+ ) as Record<
20
28
  ChatAccess,
21
29
  { label: string; description: string; icon: typeof Shield }
22
- > = {
23
- ask: {
24
- label: "Ask first",
25
- description: "Asks before edits and commands it isn't already allowed",
26
- icon: ShieldCheck,
27
- },
28
- edits: {
29
- label: "Accept edits",
30
- description: "Edits files on its own, asks before other commands",
31
- icon: Shield,
32
- },
33
- full: {
34
- label: "Full access",
35
- description: "Does everything without asking",
36
- icon: ShieldOff,
37
- },
38
- };
30
+ >;
39
31
 
40
32
  // What the agent may do without asking, switchable mid-conversation.
41
33
  export function AccessPicker({
@@ -265,6 +265,7 @@ export function ChatPanel({
265
265
  refreshingCommands,
266
266
  models,
267
267
  model,
268
+ modelLabel,
268
269
  send,
269
270
  interrupt,
270
271
  setModel,
@@ -770,6 +771,7 @@ export function ChatPanel({
770
771
  refreshingCommands={refreshingCommands}
771
772
  models={models}
772
773
  model={model}
774
+ modelLabel={modelLabel}
773
775
  onSetModel={setModel}
774
776
  access={access}
775
777
  onSetAccess={accessLocked ? undefined : setAccess}
@@ -9,20 +9,26 @@ import {
9
9
  } from "@/components/ui/dropdown-menu";
10
10
  import type { ChatModel } from "@/lib/chat/events";
11
11
 
12
- export function modelLabel(models: ChatModel[], value: string): string {
13
- return models.find((m) => m.value === value)?.label ?? value;
12
+ export function modelLabel(
13
+ models: ChatModel[],
14
+ value: string,
15
+ serverLabel?: string
16
+ ): string {
17
+ return models.find((m) => m.value === value)?.label ?? (serverLabel || value);
14
18
  }
15
19
 
16
20
  // The session's model, switchable mid-conversation. Opened by its button or /model.
17
21
  export function ModelPicker({
18
22
  models,
19
23
  model,
24
+ label,
20
25
  open,
21
26
  onOpenChange,
22
27
  onPick,
23
28
  }: {
24
29
  models: ChatModel[];
25
30
  model: string;
31
+ label?: string;
26
32
  open: boolean;
27
33
  onOpenChange: (open: boolean) => void;
28
34
  onPick: (value: string) => void;
@@ -37,7 +43,7 @@ export function ModelPicker({
37
43
  className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] flex h-10 max-w-36 min-w-0 items-center gap-1 rounded-lg px-2 text-xs"
38
44
  >
39
45
  <span className="truncate">
40
- {modelLabel(models, model) || "Model"}
46
+ {modelLabel(models, model, label) || "Model"}
41
47
  </span>
42
48
  <ChevronDown className="h-3 w-3 shrink-0" />
43
49
  </button>
@@ -51,6 +51,7 @@ export interface ComposerProps {
51
51
  refreshingCommands?: boolean;
52
52
  models?: ChatModel[];
53
53
  model?: string;
54
+ modelLabel?: string;
54
55
  onSetModel?: (model: string) => void;
55
56
  access?: ChatAccess;
56
57
  onSetAccess?: (access: ChatAccess) => void;
@@ -88,6 +89,7 @@ export function ComposerBody({
88
89
  refreshingCommands = false,
89
90
  models = [],
90
91
  model = "",
92
+ modelLabel,
91
93
  onSetModel,
92
94
  access,
93
95
  onSetAccess,
@@ -329,6 +331,7 @@ export function ComposerBody({
329
331
  onSetAccess={onSetAccess}
330
332
  models={models}
331
333
  model={model}
334
+ modelLabel={modelLabel}
332
335
  onSetModel={onSetModel}
333
336
  modelOpen={modelOpen}
334
337
  onModelOpenChange={setModelOpen}
@@ -31,6 +31,7 @@ export function Toolbar({
31
31
  onSetAccess,
32
32
  models,
33
33
  model,
34
+ modelLabel,
34
35
  onSetModel,
35
36
  modelOpen,
36
37
  onModelOpenChange,
@@ -51,6 +52,7 @@ export function Toolbar({
51
52
  onSetAccess?: (access: ChatAccess) => void;
52
53
  models: ChatModel[];
53
54
  model: string;
55
+ modelLabel?: string;
54
56
  onSetModel?: (model: string) => void;
55
57
  modelOpen: boolean;
56
58
  onModelOpenChange: (open: boolean) => void;
@@ -135,6 +137,7 @@ export function Toolbar({
135
137
  <ModelPicker
136
138
  models={models}
137
139
  model={model}
140
+ label={modelLabel}
138
141
  open={modelOpen}
139
142
  onOpenChange={onModelOpenChange}
140
143
  onPick={onSetModel}
@@ -1,29 +1,26 @@
1
+ import type { Tone } from "@/lib/config/client-config-types";
2
+ import {
3
+ RUN_STATUS_LABELS,
4
+ RUN_TRIGGER_LABELS,
5
+ } from "@/lib/recipes/status-labels";
1
6
  import type { RecipeRun, RunStatus } from "@/lib/recipes/types";
2
7
 
3
- export const STATUS_LABEL: Record<RunStatus, string> = {
4
- queued: "Queued",
5
- starting: "Starting",
6
- running: "Running",
7
- succeeded: "Succeeded",
8
- failed: "Failed",
9
- blocked: "Needs you",
10
- timed_out: "Timed out",
11
- cancelled: "Cancelled",
12
- skipped: "Skipped",
13
- };
8
+ export const STATUS_LABEL = Object.fromEntries(
9
+ Object.entries(RUN_STATUS_LABELS).map(([k, v]) => [k, v.label])
10
+ ) as Record<RunStatus, string>;
14
11
 
15
- export const STATUS_TONE: Record<RunStatus, string> = {
16
- queued: "bg-foreground/10 text-muted-foreground",
17
- starting: "bg-primary/15 text-primary",
18
- running: "bg-primary/15 text-primary",
19
- succeeded: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
20
- failed: "bg-destructive/15 text-destructive",
21
- blocked: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
22
- timed_out: "bg-destructive/15 text-destructive",
23
- cancelled: "bg-foreground/10 text-muted-foreground",
24
- skipped: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
12
+ const TONE_CHIP: Record<Tone, string> = {
13
+ muted: "bg-foreground/10 text-muted-foreground",
14
+ warning: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
15
+ primary: "bg-primary/15 text-primary",
16
+ destructive: "bg-destructive/15 text-destructive",
17
+ success: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
25
18
  };
26
19
 
20
+ export const STATUS_TONE = Object.fromEntries(
21
+ Object.entries(RUN_STATUS_LABELS).map(([k, v]) => [k, TONE_CHIP[v.tone]])
22
+ ) as Record<RunStatus, string>;
23
+
27
24
  // SQLite's datetime('now') is UTC without a zone marker.
28
25
  export const sqlMs = (t: string | null) =>
29
26
  t ? Date.parse(`${t.replace(" ", "T")}Z`) : 0;
@@ -39,13 +36,5 @@ export function duration(ms: number | null): string {
39
36
  export const money = (n: number | null) =>
40
37
  n == null ? "" : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`;
41
38
 
42
- const TRIGGER_LABEL = {
43
- manual: "by hand",
44
- tool: "by the orchestrator",
45
- cli: "by an agent",
46
- timer: "on its timer",
47
- "catch-up": "on its timer, late",
48
- } as const;
49
-
50
39
  export const triggerLabel = (r: Pick<RecipeRun, "trigger">) =>
51
- TRIGGER_LABEL[r.trigger];
40
+ RUN_TRIGGER_LABELS[r.trigger];
@@ -1,19 +1,19 @@
1
+ import type { Tone } from "@/lib/config/client-config-types";
1
2
  import type { TaskState } from "@/lib/tasks/state";
3
+ import { TASK_STATE_LABELS } from "@/lib/tasks/state-labels";
2
4
 
3
- // How a task's state reads, and its tone, wherever a task is shown.
4
- export const TASK_STATE: Record<TaskState, { label: string; tone: string }> = {
5
- working: { label: "Working", tone: "text-muted-foreground" },
6
- "needs-input": {
7
- label: "Needs input",
8
- tone: "text-amber-600 dark:text-amber-400",
9
- },
10
- blocked: { label: "Blocked", tone: "text-amber-600 dark:text-amber-400" },
11
- "plan-ready": { label: "Plan ready", tone: "text-primary" },
12
- review: { label: "Ready for review", tone: "text-primary" },
13
- "checks-failing": { label: "Checks failing", tone: "text-destructive" },
14
- exited: { label: "Agent exited", tone: "text-amber-600 dark:text-amber-400" },
15
- merged: { label: "Merged", tone: "text-emerald-600 dark:text-emerald-400" },
16
- dropped: { label: "Dropped", tone: "text-muted-foreground/60" },
17
- done: { label: "Done", tone: "text-emerald-600 dark:text-emerald-400" },
18
- moving: { label: "Moving", tone: "text-amber-600 dark:text-amber-400" },
5
+ export const TONE_TEXT: Record<Tone, string> = {
6
+ muted: "text-muted-foreground",
7
+ warning: "text-amber-600 dark:text-amber-400",
8
+ primary: "text-primary",
9
+ destructive: "text-destructive",
10
+ success: "text-emerald-600 dark:text-emerald-400",
19
11
  };
12
+
13
+ // How a task's state reads, and its tone class, wherever a task is shown.
14
+ export const TASK_STATE = Object.fromEntries(
15
+ Object.entries(TASK_STATE_LABELS).map(([k, v]) => [
16
+ k,
17
+ { label: v.label, tone: TONE_TEXT[v.tone] },
18
+ ])
19
+ ) as Record<TaskState, { label: string; tone: string }>;
@@ -146,6 +146,7 @@ export function useChat(
146
146
  const [commands, setCommands] = useState<ChatCommand[]>([]);
147
147
  const [models, setModels] = useState<ChatModel[]>([]);
148
148
  const [model, setModelState] = useState("");
149
+ const [modelLabel, setModelLabel] = useState<string | undefined>();
149
150
  const [refreshingCommands, setRefreshingCommands] = useState(false);
150
151
  const refreshTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
151
152
  const [access, setAccessState] = useState<ChatAccess>("full");
@@ -251,6 +252,7 @@ export function useChat(
251
252
  setCommands(m.commands);
252
253
  setModels(m.models);
253
254
  setModelState(m.model);
255
+ setModelLabel(m.modelLabel);
254
256
  setAccessState(m.access);
255
257
  setPlanState(m.plan);
256
258
  setCanBackground(!!m.background);
@@ -519,6 +521,7 @@ export function useChat(
519
521
  refreshingCommands,
520
522
  models,
521
523
  model,
524
+ modelLabel,
522
525
  send,
523
526
  interrupt,
524
527
  setModel,
@@ -0,0 +1,24 @@
1
+ // What each chat access level is called and does, for every client. Synced to
2
+ // the native app, so it may only import synced files.
3
+ import type { ChatAccess } from "./events";
4
+
5
+ export const ACCESS_LEVELS: Record<
6
+ ChatAccess,
7
+ { label: string; description: string }
8
+ > = {
9
+ ask: {
10
+ label: "Ask first",
11
+ description: "Asks before edits and commands it isn't already allowed",
12
+ },
13
+ edits: {
14
+ label: "Accept edits",
15
+ description: "Edits files on its own, asks before other commands",
16
+ },
17
+ full: {
18
+ label: "Full access",
19
+ description: "Does everything without asking",
20
+ },
21
+ };
22
+
23
+ // The access a new chat starts with when the client doesn't pick one.
24
+ export const DEFAULT_NEW_CHAT_ACCESS: ChatAccess = "edits";
@@ -359,6 +359,8 @@ export type ChatServerMessage =
359
359
  commands: ChatCommand[];
360
360
  models: ChatModel[];
361
361
  model: string;
362
+ // A readable name for `model` when it is not one of `models`.
363
+ modelLabel?: string;
362
364
  access: ChatAccess;
363
365
  // Plan mode: on, or null when the session can't use it.
364
366
  plan: boolean | null;
@@ -7,7 +7,7 @@ import { effectiveChatAccess, noApprovalsOn } from "../approvals/no-approvals";
7
7
  import os from "os";
8
8
  import { db, type Session } from "../db";
9
9
  import { agentEnv } from "../agents/launch";
10
- import { resolveModelForAgent } from "../model-catalog";
10
+ import { modelDisplayName, resolveModelForAgent } from "../model-catalog";
11
11
  import { chatDriverFor } from "./drivers";
12
12
  import {
13
13
  CHAT_ACCESS,
@@ -69,11 +69,15 @@ function visibleCommands(caps: Capabilities): ChatCommand[] {
69
69
  export function emitCapabilities(session: Session, listener?: Listener): void {
70
70
  const caps = registry.caps.get(capsKey(session));
71
71
  if (!caps) return;
72
+ const model = chatModel(session);
72
73
  const m: ChatServerMessage = {
73
74
  type: "capabilities",
74
75
  commands: visibleCommands(caps),
75
76
  models: caps.models,
76
- model: chatModel(session),
77
+ model,
78
+ modelLabel:
79
+ caps.models.find((o) => o.value === model)?.label ??
80
+ modelDisplayName(model),
77
81
  access: effectiveChatAccess(session.chat_access),
78
82
  plan: canPlan(session) ? !!session.chat_plan && !noApprovalsOn() : null,
79
83
  background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
@@ -0,0 +1,29 @@
1
+ // Read-only config a client renders with. Additive: a new field never bumps
2
+ // `v`; only a change that breaks an old reader does, and then the old field
3
+ // keeps being served beside the new one for a release. Synced to the native
4
+ // app, so it must stay pure: no imports.
5
+ export const CLIENT_CONFIG_VERSION = 1;
6
+
7
+ export type Tone = "muted" | "warning" | "primary" | "destructive" | "success";
8
+
9
+ export interface LabelTone {
10
+ label: string;
11
+ tone: Tone;
12
+ }
13
+
14
+ export interface AccessLevel {
15
+ value: string;
16
+ label: string;
17
+ description: string;
18
+ }
19
+
20
+ // Keys are strings on purpose, so an older app can accept a state it doesn't
21
+ // know yet; each client narrows them through its own typed lookups.
22
+ export interface ClientConfig {
23
+ v: number;
24
+ server: { version: string | null; startedAt: string };
25
+ access: { levels: AccessLevel[]; default: string };
26
+ taskStates: Record<string, LabelTone>;
27
+ runStatuses: Record<string, LabelTone>;
28
+ runTriggers: Record<string, string>;
29
+ }
@@ -0,0 +1,28 @@
1
+ import { ACCESS_LEVELS, DEFAULT_NEW_CHAT_ACCESS } from "../chat/access-levels";
2
+ import { CHAT_ACCESS } from "../chat/events";
3
+ import {
4
+ RUN_STATUS_LABELS,
5
+ RUN_TRIGGER_LABELS,
6
+ } from "../recipes/status-labels";
7
+ import { TASK_STATE_LABELS } from "../tasks/state-labels";
8
+ import { cachedCheck } from "../updates/check";
9
+ import {
10
+ CLIENT_CONFIG_VERSION,
11
+ type ClientConfig,
12
+ } from "./client-config-types";
13
+
14
+ const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
15
+
16
+ export function clientConfig(): ClientConfig {
17
+ return {
18
+ v: CLIENT_CONFIG_VERSION,
19
+ server: { version: cachedCheck()?.current?.version ?? null, startedAt },
20
+ access: {
21
+ levels: CHAT_ACCESS.map((value) => ({ value, ...ACCESS_LEVELS[value] })),
22
+ default: DEFAULT_NEW_CHAT_ACCESS,
23
+ },
24
+ taskStates: TASK_STATE_LABELS,
25
+ runStatuses: RUN_STATUS_LABELS,
26
+ runTriggers: RUN_TRIGGER_LABELS,
27
+ };
28
+ }
@@ -84,3 +84,14 @@ export function resolveModelForAgent(
84
84
 
85
85
  return getDefaultModelForAgent(agentType);
86
86
  }
87
+
88
+ // A readable name for a model id: "claude-opus-5-5" is "Opus 5.5", the alias
89
+ // "opus" is "Opus". An id it can't read passes through unchanged.
90
+ export function modelDisplayName(id: string): string {
91
+ const alias = CLAUDE_MODEL_OPTIONS.find((o) => o.value === id);
92
+ if (alias) return alias.label;
93
+ const m = /^claude-([a-z]+)-(\d{1,2}(?:-\d{1,2})*)(?:-\d{8})?$/.exec(id);
94
+ if (!m) return id;
95
+ const family = m[1][0].toUpperCase() + m[1].slice(1);
96
+ return `${family} ${m[2].replace(/-/g, ".")}`;
97
+ }
@@ -0,0 +1,27 @@
1
+ // How a recipe run's status and trigger read, for every client. Synced to the
2
+ // native app, so it may only import synced files.
3
+ import type { Tone } from "../config/client-config-types";
4
+ import type { RunStatus, RunTriggerKind } from "./types";
5
+
6
+ export const RUN_STATUS_LABELS: Record<
7
+ RunStatus,
8
+ { label: string; tone: Tone }
9
+ > = {
10
+ queued: { label: "Queued", tone: "muted" },
11
+ starting: { label: "Starting", tone: "primary" },
12
+ running: { label: "Running", tone: "primary" },
13
+ succeeded: { label: "Succeeded", tone: "success" },
14
+ failed: { label: "Failed", tone: "destructive" },
15
+ blocked: { label: "Needs you", tone: "warning" },
16
+ timed_out: { label: "Timed out", tone: "destructive" },
17
+ cancelled: { label: "Cancelled", tone: "muted" },
18
+ skipped: { label: "Skipped", tone: "warning" },
19
+ };
20
+
21
+ export const RUN_TRIGGER_LABELS: Record<RunTriggerKind, string> = {
22
+ manual: "by hand",
23
+ tool: "by the orchestrator",
24
+ cli: "by an agent",
25
+ timer: "on its timer",
26
+ "catch-up": "on its timer, late",
27
+ };
@@ -0,0 +1,21 @@
1
+ // How a task's state reads, and its tone, wherever a task is shown. Synced to
2
+ // the native app, so it may only import synced files.
3
+ import type { Tone } from "../config/client-config-types";
4
+ import type { TaskState } from "./state";
5
+
6
+ export const TASK_STATE_LABELS: Record<
7
+ TaskState,
8
+ { label: string; tone: Tone }
9
+ > = {
10
+ working: { label: "Working", tone: "muted" },
11
+ "needs-input": { label: "Needs input", tone: "warning" },
12
+ blocked: { label: "Blocked", tone: "warning" },
13
+ "plan-ready": { label: "Plan ready", tone: "primary" },
14
+ review: { label: "Ready for review", tone: "primary" },
15
+ "checks-failing": { label: "Checks failing", tone: "destructive" },
16
+ exited: { label: "Agent exited", tone: "warning" },
17
+ merged: { label: "Merged", tone: "success" },
18
+ dropped: { label: "Dropped", tone: "muted" },
19
+ done: { label: "Done", tone: "success" },
20
+ moving: { label: "Moving", tone: "warning" },
21
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.570",
3
+ "version": "0.3.571",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"