taskchef 7.27.2 → 7.28.1

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.
@@ -1,4 +1,4 @@
1
- import { act, cleanup, render, screen } from "@testing-library/react";
1
+ import { act, cleanup, render, screen, within } from "@testing-library/react";
2
2
  import type { ElementType, ReactNode } from "react";
3
3
  import { afterEach, beforeEach, expect, test, vi } from "vitest";
4
4
  import { DashboardApp } from "./App";
@@ -21,6 +21,8 @@ vi.mock("@tabler/icons-react", async () => {
21
21
  const { createElement } = await vi.importActual<typeof import("react")>("react");
22
22
  const Icon = (props: Record<string, unknown>) => createElement("svg", props);
23
23
  return {
24
+ IconArrowLeft: Icon,
25
+ IconSettings: Icon,
24
26
  IconAlertTriangle: Icon,
25
27
  IconCircleFilled: Icon,
26
28
  IconMoon: Icon,
@@ -112,6 +114,7 @@ vi.mock("@mantine/core", async () => {
112
114
  Alert: primitive("div"),
113
115
  AppShell,
114
116
  Box: primitive("div"),
117
+ Button: primitive("button"),
115
118
  CloseButton: primitive("button"),
116
119
  Container: primitive("div"),
117
120
  Group: primitive("div"),
@@ -168,6 +171,13 @@ test("places the filtered task count below the toolbar and updates it from live
168
171
  initialTasks={tasks}
169
172
  />,
170
173
  );
174
+ const headerActions = document.querySelector<HTMLElement>(".taskchef-header-actions")!;
175
+ const headerIconRow = within(headerActions).getByRole("link", { name: "Settings" }).parentElement?.parentElement;
176
+ expect(headerActions.firstElementChild).toBe(within(headerActions).getByRole("status"));
177
+ expect(headerActions.lastElementChild).toHaveClass("taskchef-header-icon-row");
178
+ expect(headerIconRow).toBe(headerActions.lastElementChild);
179
+ expect(within(headerActions).getByRole("link", { name: "Settings" })).toHaveClass("taskchef-icon-button");
180
+ expect(within(headerActions).getByRole("button", { name: "Use dark theme" })).toHaveClass("taskchef-icon-button");
171
181
  const projectFilter = screen.getByRole("combobox", { name: "Project" });
172
182
  const dateFilter = screen.getByRole("combobox", { name: "Updated" });
173
183
  const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
@@ -1,8 +1,10 @@
1
+ import { ModelSettings } from "./components/ModelSettings";
1
2
  import {
2
3
  Alert,
3
4
  ActionIcon,
4
5
  AppShell,
5
6
  Box,
7
+ Button,
6
8
  CloseButton,
7
9
  Container,
8
10
  Group,
@@ -18,7 +20,7 @@ import {
18
20
  useComputedColorScheme,
19
21
  useMantineColorScheme,
20
22
  } from "@mantine/core";
21
- import { IconAlertTriangle, IconCircleFilled, IconMoon, IconSun } from "@tabler/icons-react";
23
+ import { IconArrowLeft, IconSettings, IconAlertTriangle, IconCircleFilled, IconMoon, IconSun } from "@tabler/icons-react";
22
24
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
25
  import {
24
26
  dismissNotification,
@@ -100,6 +102,12 @@ export function DashboardApp({
100
102
  initialFilters?: { date?: string; project?: string; status?: string };
101
103
  initialTasks?: Task[];
102
104
  }) {
105
+ const [settings, setSettings] = useState(() => window.location.hash === '#settings');
106
+ useEffect(() => {
107
+ const update = () => setSettings(window.location.hash === '#settings');
108
+ window.addEventListener('hashchange', update);
109
+ return () => window.removeEventListener('hashchange', update);
110
+ }, []);
103
111
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
104
112
  const [connected, setConnected] = useState(false);
105
113
  const [message, setMessage] = useState<string | null>(null);
@@ -307,13 +315,17 @@ export function DashboardApp({
307
315
  <IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
308
316
  <Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
309
317
  </Group>
310
- <ThemeToggle />
318
+ <Group className="taskchef-header-icon-row" gap={2} justify="flex-end" wrap="nowrap">
319
+ <Tooltip label="Settings"><ActionIcon className="taskchef-icon-button" component="a" href="#settings" aria-label="Settings" aria-current={settings ? 'page' : undefined} variant="subtle" color="gray" size="lg"><IconSettings size={18} stroke={1.8} /></ActionIcon></Tooltip>
320
+ <ThemeToggle />
321
+ </Group>
311
322
  </Stack>
312
323
  </Group>
313
324
  </Container>
314
325
 
315
326
  <AppShell.Main>
316
327
  <Container className="taskchef-main" pb={80} size={1080}>
328
+ {settings ? <Stack gap="md"><Button component="a" href="#" variant="subtle" size="sm" leftSection={<IconArrowLeft size={16} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
317
329
  <Paper className="taskchef-toolbar" p="sm" radius="md" withBorder>
318
330
  <Stack gap="sm">
319
331
  <Group align="end" gap="sm">
@@ -356,6 +368,7 @@ export function DashboardApp({
356
368
  </Paper>
357
369
  )}
358
370
  </Stack>
371
+ </>}
359
372
  </Container>
360
373
  </AppShell.Main>
361
374
  </AppShell>
@@ -437,12 +450,13 @@ function ThemeToggle() {
437
450
  <Tooltip label={`Use ${next} theme`}>
438
451
  <ActionIcon
439
452
  aria-label={`Use ${next} theme`}
453
+ className="taskchef-icon-button"
440
454
  color="gray"
441
455
  onClick={() => setColorScheme(next)}
442
456
  size="lg"
443
457
  variant="subtle"
444
458
  >
445
- {colorScheme === "dark" ? <IconSun aria-hidden size={18} /> : <IconMoon aria-hidden size={18} />}
459
+ {colorScheme === "dark" ? <IconSun aria-hidden size={18} stroke={1.8} /> : <IconMoon aria-hidden size={18} stroke={1.8} />}
446
460
  </ActionIcon>
447
461
  </Tooltip>
448
462
  );
@@ -1,6 +1,6 @@
1
1
  import { afterEach, expect, test, vi } from "vitest";
2
2
  import { fixtureTask } from "./fixtures";
3
- import { manualTransition } from "./api";
3
+ import { manualTransition, updateModelRole } from "./api";
4
4
 
5
5
  afterEach(() => {
6
6
  vi.useRealTimers();
@@ -22,3 +22,19 @@ test("times out a stalled manual transition and returns a retryable error", asyn
22
22
  message: "Task state change timed out. Try again.",
23
23
  });
24
24
  });
25
+
26
+ test("updates a personal model role with an exact JSON request", async () => {
27
+ const fetchMock = vi.fn().mockResolvedValue({
28
+ ok: true,
29
+ json: async () => ({ profile: { id: "personal" } }),
30
+ });
31
+ vi.stubGlobal("fetch", fetchMock);
32
+ await expect(updateModelRole("planner", "gpt-fixture", "high")).resolves.toEqual({
33
+ profile: { id: "personal" },
34
+ });
35
+ expect(fetchMock).toHaveBeenCalledWith("/api/settings/planner", {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ schemaVersion: 1, model: "gpt-fixture", effort: "high" }),
39
+ });
40
+ });
@@ -39,6 +39,14 @@ export async function dashboardVersion(): Promise<string> {
39
39
  return health.taskchefVersion;
40
40
  }
41
41
 
42
+ export async function updateModelRole(role: string, model: string, effort: string) {
43
+ return jsonRequest<{ profile: unknown }>(`/api/settings/${encodeURIComponent(role)}`, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ schemaVersion: 1, model, effort }),
47
+ });
48
+ }
49
+
42
50
  export async function taskDetail(taskId: string): Promise<Task> {
43
51
  return (await jsonRequest<{ task: Task }>(`/api/tasks/${encodeURIComponent(taskId)}`)).task;
44
52
  }
@@ -0,0 +1,100 @@
1
+ import { MantineProvider } from '@mantine/core';
2
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { afterEach, expect, test, vi } from 'vitest';
4
+ import { ModelSettings } from './ModelSettings';
5
+
6
+ const apiMocks = vi.hoisted(() => ({ updateModelRole: vi.fn() }));
7
+ vi.mock('../api', () => apiMocks);
8
+
9
+ vi.mock('@mantine/core', async () => {
10
+ const { createElement } = await import('react');
11
+ const block = ({ children }: { children?: import('react').ReactNode }) => createElement('div', null, children);
12
+ return {
13
+ MantineProvider: block, Tooltip: block, Badge: block, Group: block, Paper: block, SimpleGrid: block, Stack: block, Text: block, Title: block,
14
+ Select: ({ 'aria-busy': busy, 'aria-label': label, data = [], disabled, onChange, readOnly, value }: { 'aria-busy'?: boolean; 'aria-label': string; data?: Array<string | { value: string; label: string }>; disabled?: boolean; onChange: (value: string | null) => void; readOnly?: boolean; value: string | null }) => createElement('select', { 'aria-busy': busy, 'aria-label': label, disabled, 'aria-readonly': readOnly, value: value ?? '', onChange: (event: { currentTarget: { value: string } }) => onChange(event.currentTarget.value) }, [createElement('option', { key: '', value: '' }), ...data.map((item) => typeof item === 'string' ? createElement('option', { key: item, value: item }, item) : createElement('option', { key: item.value, value: item.value }, item.label))]),
15
+ Alert: ({ children }: { children?: import('react').ReactNode }) => createElement('div', { role: 'alert' }, children),
16
+ ActionIcon: ({ children, loading, onClick, 'aria-label': label, ...props }: { children?: import('react').ReactNode; loading?: boolean; onClick: () => void; 'aria-label': string; className?: string }) => createElement('button', { ...props, disabled: loading, onClick, 'aria-label': label }, children),
17
+ };
18
+ });
19
+
20
+ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
21
+
22
+ test('refresh shows configuration failures and does not label them honored', async () => {
23
+ const role = { role: 'reviewer', source: '/fixture/reviewer.toml', effectiveSource: '/fixture/reviewer.toml', model: 'fixture', effort: 'low', status: 'configured', availability: 'not checked', fallback: 'inherit parent settings', problems: [] as string[] };
24
+ const profile = { id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null };
25
+ const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [profile] }) })
26
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [{ ...profile, roles: [{ ...role, status: 'invalid', problems: ['Unsupported effort'] }] }] }) });
27
+ vi.stubGlobal('fetch', fetchMock);
28
+ render(<MantineProvider><ModelSettings /></MantineProvider>);
29
+ expect(await screen.findByText('configured')).toBeInTheDocument();
30
+ const refresh = screen.getByRole('button', { name: 'Refresh' });
31
+ expect(refresh).toHaveClass('taskchef-icon-button');
32
+ await waitFor(() => expect(refresh).toBeEnabled());
33
+ await act(async () => { fireEvent.click(refresh); });
34
+ expect(await screen.findByText('Unsupported effort')).toBeInTheDocument();
35
+ expect(screen.queryByText(/Requested effective settings/)).not.toBeInTheDocument();
36
+ });
37
+
38
+ test('request failure remains visible and can be retried', async () => {
39
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false }).mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [] }) }));
40
+ render(<MantineProvider><ModelSettings /></MantineProvider>);
41
+ expect(await screen.findByRole('alert')).toHaveTextContent('could not be loaded');
42
+ const refresh = screen.getByRole('button', { name: 'Refresh' });
43
+ await waitFor(() => expect(refresh).toBeEnabled());
44
+ await act(async () => { fireEvent.click(refresh); });
45
+ await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
46
+ });
47
+
48
+
49
+ test('non-string TOML values show diagnostics without crashing settings', async () => {
50
+ const role = { role: 'reviewer', source: '/fixture/reviewer.toml', model: { slug: 'bad' }, effort: ['bad'], status: 'invalid', availability: 'not checked', fallback: 'inherit parent settings', problems: ['model must be a nonempty string'] };
51
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [{ id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null }] }) }));
52
+ render(<MantineProvider><ModelSettings /></MantineProvider>);
53
+ expect(await screen.findByText('model must be a nonempty string')).toBeInTheDocument();
54
+ expect(screen.getByRole('combobox', { name: 'reviewer model' })).toHaveValue('');
55
+ expect(screen.getByRole('combobox', { name: 'reviewer reasoning effort' })).toBeDisabled();
56
+ });
57
+
58
+ test('saves dropdown changes and displays a home-relative agent source', async () => {
59
+ const role = { role: 'planner', source: '/Users/example/.codex/agents/planner.toml', displaySource: '~/.codex/agents/planner.toml', model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
60
+ const profile = { id: 'personal', roles: [role], problems: [], modelOptions: [
61
+ { value: 'gpt-one', label: 'GPT One', efforts: ['low', 'high'] },
62
+ { value: 'gpt-two', label: 'GPT Two', efforts: ['medium'] },
63
+ ] };
64
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
65
+ apiMocks.updateModelRole.mockResolvedValue({ profile: { ...profile, roles: [{ ...role, model: 'gpt-two', effort: 'medium' }] } });
66
+ render(<MantineProvider><ModelSettings /></MantineProvider>);
67
+ const model = await screen.findByRole('combobox', { name: 'planner model' });
68
+ fireEvent.change(model, { target: { value: 'gpt-two' } });
69
+ expect(model).toHaveValue('gpt-two');
70
+ expect(model).toHaveAttribute('aria-readonly', 'true');
71
+ expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
72
+ await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium'));
73
+ expect(await screen.findByText('Source: ~/.codex/agents/planner.toml')).toBeInTheDocument();
74
+ });
75
+
76
+ test('serializes role saves so an older response cannot replace a newer selection', async () => {
77
+ const planner = { role: 'planner', source: null, model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
78
+ const reviewer = { ...planner, role: 'reviewer' };
79
+ const profile = { id: 'personal', roles: [planner, reviewer], problems: [], modelOptions: [
80
+ { value: 'gpt-one', label: 'GPT One', efforts: ['low'] },
81
+ { value: 'gpt-two', label: 'GPT Two', efforts: ['medium'] },
82
+ ] };
83
+ let resolveFirst!: (value: { profile: typeof profile }) => void;
84
+ apiMocks.updateModelRole.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve; }));
85
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
86
+ render(<MantineProvider><ModelSettings /></MantineProvider>);
87
+ const plannerModel = await screen.findByRole('combobox', { name: 'planner model' });
88
+ const reviewerModel = screen.getByRole('combobox', { name: 'reviewer model' });
89
+ fireEvent.change(plannerModel, { target: { value: 'gpt-two' } });
90
+ fireEvent.change(reviewerModel, { target: { value: 'gpt-two' } });
91
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
92
+ expect(apiMocks.updateModelRole).toHaveBeenCalledTimes(1);
93
+ expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium');
94
+ expect(fetch).toHaveBeenCalledTimes(1);
95
+ await act(async () => resolveFirst({ profile: { ...profile, roles: [{ ...planner, model: 'gpt-two', effort: 'medium' }, reviewer] } }));
96
+ await waitFor(() => expect(reviewerModel).toHaveAttribute('aria-readonly', 'false'));
97
+ apiMocks.updateModelRole.mockResolvedValueOnce({ profile: { ...profile, roles: [{ ...planner, model: 'gpt-two', effort: 'medium' }, { ...reviewer, model: 'gpt-two', effort: 'medium' }] } });
98
+ fireEvent.change(reviewerModel, { target: { value: 'gpt-two' } });
99
+ await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledTimes(2));
100
+ });
@@ -0,0 +1,91 @@
1
+ import { ActionIcon, Alert, Badge, Group, Paper, Select, SimpleGrid, Stack, Text, Title } from '@mantine/core';
2
+ import { IconRefresh } from '@tabler/icons-react';
3
+ import { useEffect, useState } from 'react';
4
+ import { updateModelRole } from '../api';
5
+
6
+ type ModelOption = { value: string; label: string; efforts: string[] };
7
+ type Role = { role: string; source: string | null; displaySource?: string | null; model: unknown; effort: unknown; status: string; problems: string[] };
8
+ type Profile = { id: string; roles: Role[]; problems: string[]; modelOptions?: ModelOption[] };
9
+
10
+ export function ModelSettings() {
11
+ const [profiles, setProfiles] = useState<Profile[]>([]);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [loading, setLoading] = useState(true);
14
+ const [saving, setSaving] = useState<string | null>(null);
15
+ const [showSaving, setShowSaving] = useState<string | null>(null);
16
+ const [revision, setRevision] = useState(0);
17
+ useEffect(() => {
18
+ const controller = new AbortController();
19
+ setLoading(true);
20
+ setError(null);
21
+ fetch('/api/settings', { signal: controller.signal }).then(async (response) => {
22
+ if (!response.ok) throw new Error('Settings preview could not be loaded.');
23
+ const data = await response.json() as { profiles: Profile[] };
24
+ setProfiles(data.profiles);
25
+ }).catch((failure: Error) => {
26
+ if (!controller.signal.aborted) setError(failure.message);
27
+ }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
28
+ return () => controller.abort();
29
+ }, [revision]);
30
+ useEffect(() => {
31
+ setShowSaving(null);
32
+ if (!saving) return;
33
+ const timeout = window.setTimeout(() => setShowSaving(saving), 400);
34
+ return () => window.clearTimeout(timeout);
35
+ }, [saving]);
36
+ const profile = profiles.find((item) => item.id === 'personal');
37
+ const save = async (role: Role, model: string, effort: string) => {
38
+ setSaving(role.role);
39
+ setError(null);
40
+ setProfiles((current) => current.map((item) => item.id !== 'personal' ? item : {
41
+ ...item,
42
+ roles: item.roles.map((candidate) => candidate.role === role.role ? { ...candidate, model, effort } : candidate),
43
+ }));
44
+ try {
45
+ const result = await updateModelRole(role.role, model, effort) as { profile: Profile };
46
+ setProfiles([result.profile]);
47
+ } catch (failure) {
48
+ setProfiles((current) => current.map((item) => item.id !== 'personal' ? item : {
49
+ ...item,
50
+ roles: item.roles.map((candidate) => candidate.role === role.role ? role : candidate),
51
+ }));
52
+ setError(failure instanceof Error ? failure.message : 'Model role could not be saved.');
53
+ } finally {
54
+ setSaving(null);
55
+ }
56
+ };
57
+ return <Stack gap="md" aria-label="Model settings">
58
+ <Group justify="space-between">
59
+ <Title order={2} size="h3">Model roles</Title>
60
+ <ActionIcon aria-label="Refresh" aria-busy={loading} aria-disabled={loading || saving !== null} className="taskchef-icon-button" color="gray" variant="subtle" size="lg" onClick={() => {
61
+ if (loading || saving) return;
62
+ setLoading(true);
63
+ setRevision((value) => value + 1);
64
+ }}><IconRefresh className={loading ? 'taskchef-refresh-spinning' : undefined} size={18} stroke={1.8} /></ActionIcon>
65
+ </Group>
66
+ <Text c="dimmed" size="sm">Your models for planning, implementation, and review.</Text>
67
+ {error && <Alert color="red" role="alert">{error}</Alert>}
68
+ {profile && <>
69
+ {profile.problems.map((problem) => <Alert color="yellow" key={problem}>{problem}</Alert>)}
70
+ {profile.roles.map((role) => <Paper key={role.role} withBorder p="md">
71
+ <Stack gap="xs">
72
+ <Group justify="space-between"><Title order={3} size="h4" tt="capitalize">{role.role}</Title><Badge color={['invalid', 'unavailable'].includes(role.status) ? 'red' : role.status === 'missing' ? 'gray' : 'teal'}>{role.status}</Badge></Group>
73
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>
74
+ <Select label="Model" aria-label={`${role.role} model`} aria-busy={loading || saving !== null} data={profile.modelOptions ?? []} value={typeof role.model === 'string' ? role.model : null} readOnly={loading || saving !== null} allowDeselect={false} searchable onChange={(model) => {
75
+ if (loading || saving || !model) return;
76
+ const option = profile.modelOptions?.find((candidate) => candidate.value === model);
77
+ const effort = typeof role.effort === 'string' && option?.efforts.includes(role.effort) ? role.effort : option?.efforts[0];
78
+ if (effort) void save(role, model, effort);
79
+ }} />
80
+ <Select label="Reasoning effort" aria-label={`${role.role} reasoning effort`} aria-busy={loading || saving !== null} data={(profile.modelOptions?.find((option) => option.value === role.model)?.efforts ?? []).map((value) => ({ value, label: value }))} value={typeof role.effort === 'string' ? role.effort : null} disabled={typeof role.model !== 'string'} readOnly={loading || saving !== null} allowDeselect={false} onChange={(effort) => {
81
+ if (!loading && !saving && effort && typeof role.model === 'string') void save(role, role.model, effort);
82
+ }} />
83
+ </SimpleGrid>
84
+ <Text size="sm" style={{ overflowWrap: 'anywhere' }}>Source: {role.displaySource ?? role.source ?? 'No role file'}</Text>
85
+ {showSaving === role.role && <Text c="dimmed" size="sm" role="status">Saving…</Text>}
86
+ {role.problems.map((problem) => <Alert key={problem} color="red">{problem}</Alert>)}
87
+ </Stack>
88
+ </Paper>)}
89
+ </>}
90
+ </Stack>;
91
+ }
@@ -21,6 +21,8 @@ body {
21
21
  .taskchef-header-copy { min-width: 0; }
22
22
  .taskchef-header-actions { align-self: stretch; flex: none; }
23
23
  .taskchef-connection { align-self: flex-end; }
24
+ .taskchef-header-icon-row { justify-content: flex-end; }
25
+ .taskchef-icon-button { flex: none; }
24
26
  .taskchef-version { margin-inline-start: 0.45rem; color: var(--taskchef-muted); font-weight: 600; letter-spacing: 0; text-transform: none; }
25
27
  .taskchef-brand-icon, .taskchef-brand-icon img { display: block; width: 42px; height: 42px; }
26
28
  .taskchef-main { padding-top: var(--mantine-spacing-md); }
@@ -140,3 +142,16 @@ body {
140
142
  .taskchef-task-usage { text-align: left; }
141
143
  .taskchef-card-footer { align-items: flex-start; flex-direction: column; }
142
144
  }
145
+
146
+ .taskchef-refresh-spinning {
147
+ animation: taskchef-refresh-spin 800ms linear infinite;
148
+ transform-origin: center;
149
+ }
150
+
151
+ @keyframes taskchef-refresh-spin {
152
+ to { transform: rotate(360deg); }
153
+ }
154
+
155
+ @media (prefers-reduced-motion: reduce) {
156
+ .taskchef-refresh-spinning { animation: none; }
157
+ }
package/src/dashboard.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveModelRoles, updateModelRole } from "./model-roles.js";
1
2
  import { EventEmitter } from "node:events";
2
3
  import { randomBytes } from "node:crypto";
3
4
  import { constants, watch } from "node:fs";
@@ -803,6 +804,8 @@ export async function createDashboardServer({
803
804
  serverVersion = DASHBOARD_SERVER_VERSION,
804
805
  usageTracker = null,
805
806
  usageSummaryMonitor = null,
807
+ resolveRoles = resolveModelRoles,
808
+ updateRole = updateModelRole,
806
809
  control = null,
807
810
  controlReplayCache = createDashboardControlReplayCache(),
808
811
  } = {}) {
@@ -1178,6 +1181,43 @@ export async function createDashboardServer({
1178
1181
  return;
1179
1182
  }
1180
1183
 
1184
+ if (url.pathname === "/api/settings" && method === "GET") {
1185
+ const profiles = [{ id: "personal", project: "Personal", ...await resolveRoles({ includeCatalog: true }) }];
1186
+ sendJson(response, 200, { profiles });
1187
+ return;
1188
+ }
1189
+
1190
+ const settingsMatch = url.pathname.match(/^\/api\/settings\/(planner|implementer|reviewer)$/);
1191
+ if (settingsMatch && method === "POST") {
1192
+ if (request.headers.origin !== allowedOrigin) {
1193
+ sendJson(response, 403, { code: "invalid_origin", message: "Dashboard origin validation failed." });
1194
+ return;
1195
+ }
1196
+ try {
1197
+ const body = await readBoundedJsonBody(request);
1198
+ const fields = Object.keys(body);
1199
+ if (fields.length !== 3 || body.schemaVersion !== 1
1200
+ || typeof body.model !== "string" || typeof body.effort !== "string") {
1201
+ const error = new Error("Model role update has an invalid shape.");
1202
+ error.code = "invalid_request";
1203
+ throw error;
1204
+ }
1205
+ const profile = await updateRole(settingsMatch[1], body.model, body.effort);
1206
+ sendJson(response, 200, { profile: { id: "personal", project: "Personal", ...profile } });
1207
+ } catch (error) {
1208
+ const status = error?.code === "invalid_request" ? 400
1209
+ : error?.code === "unsupported_media_type" ? 415
1210
+ : error?.code === "body_too_large" ? 413 : 409;
1211
+ sendJson(response, status, {
1212
+ code: error?.code ?? "role_update_failed",
1213
+ message: error?.code === "invalid_request"
1214
+ ? "Model role update is invalid."
1215
+ : "Model role could not be saved. Review the profile and try again.",
1216
+ });
1217
+ }
1218
+ return;
1219
+ }
1220
+
1181
1221
  if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
1182
1222
  if (method === "HEAD") {
1183
1223
  response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
package/src/mcp.js CHANGED
@@ -102,6 +102,7 @@ const preparationSchema = z.object({
102
102
  taskId: z.string(),
103
103
  preparedAt: z.string(),
104
104
  marker: z.string(),
105
+ modelRoles: z.record(z.string(), z.unknown()).optional(),
105
106
  projectCount: z.number(),
106
107
  projects: z.array(projectSchema),
107
108
  });
@@ -0,0 +1,70 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { readdir } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { promisify } from 'node:util';
7
+
8
+ const execFile = promisify(execFileCallback);
9
+ const resolver = fileURLToPath(new URL('../scripts/roles/resolve_roles.py', import.meta.url));
10
+
11
+ async function hasAgentFiles(directory) {
12
+ try { return (await readdir(directory)).some((name) => name.endsWith('.toml')); }
13
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
14
+ }
15
+
16
+ function missingRoles() {
17
+ return {
18
+ roles: ['planner', 'implementer', 'reviewer'].map((role) => ({
19
+ role, source: null, effectiveSource: null, model: null, effort: null,
20
+ status: 'missing', availability: 'No role files', problems: [],
21
+ taskOverrides: {}, subagentOverrides: {},
22
+ fallback: role === 'reviewer' ? 'inherit parent settings' : 'native new-task default (not guaranteed dispatcher inheritance)',
23
+ })),
24
+ problems: [], catalogSource: null, modelOptions: [],
25
+ precedence: 'explicit user model (and its explicit effort) > personal role > native defaults; explicit effort alone overrides role effort',
26
+ scope: 'Model and effort only; agent instructions, tools, permissions, and other TOML keys are not applied by this adapter.',
27
+ };
28
+ }
29
+
30
+ export async function resolveModelRoles({ run = execFile, env = process.env, includeCatalog = false } = {}) {
31
+ try {
32
+ const home = env.HOME || os.homedir();
33
+ const codexHome = (env.CODEX_HOME || path.join(home, '.codex')).replace(/^~(?=\/|$)/, home);
34
+ const agentsDirectory = path.join(codexHome, 'agents');
35
+ if (!await hasAgentFiles(agentsDirectory) && !includeCatalog) return missingRoles();
36
+ const { stdout } = await run('python3', [resolver], {
37
+ env, timeout: 5000, maxBuffer: 128 * 1024,
38
+ });
39
+ const result = JSON.parse(stdout);
40
+ result.roles = result.roles.map((role) => ({
41
+ ...role,
42
+ displaySource: typeof role.source === 'string'
43
+ ? `~/.codex/agents/${path.basename(role.source)}`
44
+ : role.source,
45
+ }));
46
+ return result;
47
+ } catch {
48
+ return { roles: [], problems: ['Model role preview unavailable. Python 3.11+ and readable agent configuration are required when agent files exist; configured roles have not been honored.'], catalogSource: null };
49
+ }
50
+ }
51
+
52
+ export async function updateModelRole(role, model, effort, { run = execFile, env = process.env } = {}) {
53
+ const allowedRoles = new Set(['planner', 'implementer', 'reviewer']);
54
+ if (!allowedRoles.has(role) || typeof model !== 'string' || typeof effort !== 'string') {
55
+ const error = new Error('Invalid model role update.');
56
+ error.code = 'invalid_request';
57
+ throw error;
58
+ }
59
+ try {
60
+ await run('python3', [resolver, '--update', '--role', role, '--model', model, '--effort', effort], {
61
+ env, timeout: 5000, maxBuffer: 128 * 1024,
62
+ });
63
+ return resolveModelRoles({ run, env, includeCatalog: true });
64
+ } catch (error) {
65
+ if (error.code === 'invalid_request') throw error;
66
+ const failure = new Error('Model role could not be saved.');
67
+ failure.code = 'role_update_failed';
68
+ throw failure;
69
+ }
70
+ }
package/src/workspace.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveModelRoles } from "./model-roles.js";
1
2
  import { execFile as execFileCallback } from "node:child_process";
2
3
  import {
3
4
  access,
@@ -704,6 +705,7 @@ export async function prepareDispatch(workspaceRoot, {
704
705
  marker,
705
706
  projectCount: projects.length,
706
707
  projects,
708
+ modelRoles: await resolveModelRoles(),
707
709
  };
708
710
  }
709
711