taskchef 7.28.0 → 7.29.0
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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +16 -1
- package/docs/dashboard-lifecycle.md +7 -0
- package/docs/spec.md +9 -0
- package/package.json +1 -1
- package/scripts/roles/resolve_roles.py +128 -7
- package/skills/taskchef-delegate/SKILL.md +1 -1
- package/skills/taskchef-delegate/references/model-roles.md +12 -9
- package/src/dashboard/dist/app.js +50 -35
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.test.tsx +10 -1
- package/src/dashboard/react/App.tsx +8 -4
- package/src/dashboard/react/api.test.ts +17 -1
- package/src/dashboard/react/api.ts +8 -0
- package/src/dashboard/react/components/ModelSettings.test.tsx +71 -10
- package/src/dashboard/react/components/ModelSettings.tsx +68 -25
- package/src/dashboard/react/styles.css +15 -0
- package/src/dashboard.js +62 -8
- package/src/mcp.js +0 -1
- package/src/model-roles.js +34 -8
- package/src/usage-tracker.js +192 -23
- package/src/usage.js +40 -12
- package/src/workspace.js +0 -3
|
@@ -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,
|
|
@@ -169,6 +171,13 @@ test("places the filtered task count below the toolbar and updates it from live
|
|
|
169
171
|
initialTasks={tasks}
|
|
170
172
|
/>,
|
|
171
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");
|
|
172
181
|
const projectFilter = screen.getByRole("combobox", { name: "Project" });
|
|
173
182
|
const dateFilter = screen.getByRole("combobox", { name: "Updated" });
|
|
174
183
|
const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
useComputedColorScheme,
|
|
21
21
|
useMantineColorScheme,
|
|
22
22
|
} from "@mantine/core";
|
|
23
|
-
import { IconAlertTriangle, IconCircleFilled, IconMoon, IconSun } from "@tabler/icons-react";
|
|
23
|
+
import { IconArrowLeft, IconSettings, IconAlertTriangle, IconCircleFilled, IconMoon, IconSun } from "@tabler/icons-react";
|
|
24
24
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
25
25
|
import {
|
|
26
26
|
dismissNotification,
|
|
@@ -315,14 +315,17 @@ export function DashboardApp({
|
|
|
315
315
|
<IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
|
|
316
316
|
<Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
|
|
317
317
|
</Group>
|
|
318
|
-
<Group
|
|
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>
|
|
319
322
|
</Stack>
|
|
320
323
|
</Group>
|
|
321
324
|
</Container>
|
|
322
325
|
|
|
323
326
|
<AppShell.Main>
|
|
324
327
|
<Container className="taskchef-main" pb={80} size={1080}>
|
|
325
|
-
{settings ? <
|
|
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> : <>
|
|
326
329
|
<Paper className="taskchef-toolbar" p="sm" radius="md" withBorder>
|
|
327
330
|
<Stack gap="sm">
|
|
328
331
|
<Group align="end" gap="sm">
|
|
@@ -447,12 +450,13 @@ function ThemeToggle() {
|
|
|
447
450
|
<Tooltip label={`Use ${next} theme`}>
|
|
448
451
|
<ActionIcon
|
|
449
452
|
aria-label={`Use ${next} theme`}
|
|
453
|
+
className="taskchef-icon-button"
|
|
450
454
|
color="gray"
|
|
451
455
|
onClick={() => setColorScheme(next)}
|
|
452
456
|
size="lg"
|
|
453
457
|
variant="subtle"
|
|
454
458
|
>
|
|
455
|
-
{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} />}
|
|
456
460
|
</ActionIcon>
|
|
457
461
|
</Tooltip>
|
|
458
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
|
}
|
|
@@ -3,18 +3,35 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
|
|
3
3
|
import { afterEach, expect, test, vi } from 'vitest';
|
|
4
4
|
import { ModelSettings } from './ModelSettings';
|
|
5
5
|
|
|
6
|
+
const apiMocks = vi.hoisted(() => ({ updateModelRole: vi.fn() }));
|
|
7
|
+
vi.mock('../api', () => apiMocks);
|
|
8
|
+
|
|
6
9
|
vi.mock('@mantine/core', async () => {
|
|
7
10
|
const { createElement } = await import('react');
|
|
8
11
|
const block = ({ children }: { children?: import('react').ReactNode }) => createElement('div', null, children);
|
|
9
12
|
return {
|
|
10
|
-
MantineProvider: block, Badge: block, Group: block, Paper: block, Stack: block, Text: block, Title: block,
|
|
11
|
-
Select: () => null,
|
|
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))]),
|
|
12
15
|
Alert: ({ children }: { children?: import('react').ReactNode }) => createElement('div', { role: 'alert' }, children),
|
|
13
|
-
|
|
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),
|
|
14
17
|
};
|
|
15
18
|
});
|
|
16
19
|
|
|
17
|
-
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
|
20
|
+
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
|
|
21
|
+
|
|
22
|
+
test('shows the version reported by the active usage provider', async () => {
|
|
23
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
24
|
+
ok: true,
|
|
25
|
+
json: async () => ({
|
|
26
|
+
profiles: [],
|
|
27
|
+
usageProvider: { provider: 'ccusage', status: 'available', version: '20.0.21' },
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
31
|
+
expect(await screen.findByText('ccusage 20.0.21')).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByText('Usage provider')).toBeInTheDocument();
|
|
33
|
+
expect(screen.getByText(/Version reported by the executable/)).toBeInTheDocument();
|
|
34
|
+
});
|
|
18
35
|
|
|
19
36
|
test('refresh shows configuration failures and does not label them honored', async () => {
|
|
20
37
|
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,11 +41,11 @@ test('refresh shows configuration failures and does not label them honored', asy
|
|
|
24
41
|
vi.stubGlobal('fetch', fetchMock);
|
|
25
42
|
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
26
43
|
expect(await screen.findByText('configured')).toBeInTheDocument();
|
|
27
|
-
const refresh = screen.
|
|
44
|
+
const refresh = screen.getByRole('button', { name: 'Refresh' });
|
|
45
|
+
expect(refresh).toHaveClass('taskchef-icon-button');
|
|
28
46
|
await waitFor(() => expect(refresh).toBeEnabled());
|
|
29
47
|
await act(async () => { fireEvent.click(refresh); });
|
|
30
48
|
expect(await screen.findByText('Unsupported effort')).toBeInTheDocument();
|
|
31
|
-
expect(screen.getByText(/Overrides blocked/)).toBeInTheDocument();
|
|
32
49
|
expect(screen.queryByText(/Requested effective settings/)).not.toBeInTheDocument();
|
|
33
50
|
});
|
|
34
51
|
|
|
@@ -36,7 +53,7 @@ test('request failure remains visible and can be retried', async () => {
|
|
|
36
53
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false }).mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [] }) }));
|
|
37
54
|
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
38
55
|
expect(await screen.findByRole('alert')).toHaveTextContent('could not be loaded');
|
|
39
|
-
const refresh = screen.
|
|
56
|
+
const refresh = screen.getByRole('button', { name: 'Refresh' });
|
|
40
57
|
await waitFor(() => expect(refresh).toBeEnabled());
|
|
41
58
|
await act(async () => { fireEvent.click(refresh); });
|
|
42
59
|
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
|
|
@@ -47,7 +64,51 @@ test('non-string TOML values show diagnostics without crashing settings', async
|
|
|
47
64
|
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'] };
|
|
48
65
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [{ id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null }] }) }));
|
|
49
66
|
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
50
|
-
expect(await screen.findByText(
|
|
51
|
-
expect(screen.
|
|
52
|
-
expect(screen.
|
|
67
|
+
expect(await screen.findByText('model must be a nonempty string')).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByRole('combobox', { name: 'reviewer model' })).toHaveValue('');
|
|
69
|
+
expect(screen.getByRole('combobox', { name: 'reviewer reasoning effort' })).toBeDisabled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('saves dropdown changes and displays a home-relative agent source', async () => {
|
|
73
|
+
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[] };
|
|
74
|
+
const profile = { id: 'personal', roles: [role], problems: [], modelOptions: [
|
|
75
|
+
{ value: 'gpt-one', label: 'GPT One', efforts: ['low', 'high'] },
|
|
76
|
+
{ value: 'gpt-two', label: 'GPT Two', efforts: ['medium'] },
|
|
77
|
+
] };
|
|
78
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
|
|
79
|
+
apiMocks.updateModelRole.mockResolvedValue({ profile: { ...profile, roles: [{ ...role, model: 'gpt-two', effort: 'medium' }] } });
|
|
80
|
+
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
81
|
+
const model = await screen.findByRole('combobox', { name: 'planner model' });
|
|
82
|
+
fireEvent.change(model, { target: { value: 'gpt-two' } });
|
|
83
|
+
expect(model).toHaveValue('gpt-two');
|
|
84
|
+
expect(model).toHaveAttribute('aria-readonly', 'true');
|
|
85
|
+
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
|
86
|
+
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium'));
|
|
87
|
+
expect(await screen.findByText('Source: ~/.codex/agents/planner.toml')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('serializes role saves so an older response cannot replace a newer selection', async () => {
|
|
91
|
+
const planner = { role: 'planner', source: null, model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
|
|
92
|
+
const reviewer = { ...planner, role: 'reviewer' };
|
|
93
|
+
const profile = { id: 'personal', roles: [planner, reviewer], problems: [], modelOptions: [
|
|
94
|
+
{ value: 'gpt-one', label: 'GPT One', efforts: ['low'] },
|
|
95
|
+
{ value: 'gpt-two', label: 'GPT Two', efforts: ['medium'] },
|
|
96
|
+
] };
|
|
97
|
+
let resolveFirst!: (value: { profile: typeof profile }) => void;
|
|
98
|
+
apiMocks.updateModelRole.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve; }));
|
|
99
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
|
|
100
|
+
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
101
|
+
const plannerModel = await screen.findByRole('combobox', { name: 'planner model' });
|
|
102
|
+
const reviewerModel = screen.getByRole('combobox', { name: 'reviewer model' });
|
|
103
|
+
fireEvent.change(plannerModel, { target: { value: 'gpt-two' } });
|
|
104
|
+
fireEvent.change(reviewerModel, { target: { value: 'gpt-two' } });
|
|
105
|
+
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
106
|
+
expect(apiMocks.updateModelRole).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium');
|
|
108
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
109
|
+
await act(async () => resolveFirst({ profile: { ...profile, roles: [{ ...planner, model: 'gpt-two', effort: 'medium' }, reviewer] } }));
|
|
110
|
+
await waitFor(() => expect(reviewerModel).toHaveAttribute('aria-readonly', 'false'));
|
|
111
|
+
apiMocks.updateModelRole.mockResolvedValueOnce({ profile: { ...profile, roles: [{ ...planner, model: 'gpt-two', effort: 'medium' }, { ...reviewer, model: 'gpt-two', effort: 'medium' }] } });
|
|
112
|
+
fireEvent.change(reviewerModel, { target: { value: 'gpt-two' } });
|
|
113
|
+
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledTimes(2));
|
|
53
114
|
});
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import { Alert, Badge,
|
|
1
|
+
import { ActionIcon, Alert, Badge, Group, Paper, Select, SimpleGrid, Stack, Text, Title } from '@mantine/core';
|
|
2
|
+
import { IconRefresh } from '@tabler/icons-react';
|
|
2
3
|
import { useEffect, useState } from 'react';
|
|
4
|
+
import { updateModelRole } from '../api';
|
|
3
5
|
|
|
4
|
-
type
|
|
5
|
-
type
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return typeof value === 'string' ? value : value == null ? `${name} omitted` : `Invalid ${name.toLowerCase()} value`;
|
|
9
|
-
}
|
|
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
|
+
type UsageProvider = { provider: string; status: 'available' | 'unavailable'; version: string | null };
|
|
10
10
|
|
|
11
11
|
export function ModelSettings() {
|
|
12
12
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
13
|
-
const [
|
|
13
|
+
const [usageProvider, setUsageProvider] = useState<UsageProvider | null>(null);
|
|
14
14
|
const [error, setError] = useState<string | null>(null);
|
|
15
15
|
const [loading, setLoading] = useState(true);
|
|
16
|
+
const [saving, setSaving] = useState<string | null>(null);
|
|
17
|
+
const [showSaving, setShowSaving] = useState<string | null>(null);
|
|
16
18
|
const [revision, setRevision] = useState(0);
|
|
17
19
|
useEffect(() => {
|
|
18
20
|
const controller = new AbortController();
|
|
@@ -20,42 +22,83 @@ export function ModelSettings() {
|
|
|
20
22
|
setError(null);
|
|
21
23
|
fetch('/api/settings', { signal: controller.signal }).then(async (response) => {
|
|
22
24
|
if (!response.ok) throw new Error('Settings preview could not be loaded.');
|
|
23
|
-
const data = await response.json() as { profiles: Profile[] };
|
|
25
|
+
const data = await response.json() as { profiles: Profile[]; usageProvider?: UsageProvider };
|
|
24
26
|
setProfiles(data.profiles);
|
|
27
|
+
setUsageProvider(data.usageProvider ?? null);
|
|
25
28
|
}).catch((failure: Error) => {
|
|
26
29
|
if (!controller.signal.aborted) setError(failure.message);
|
|
27
30
|
}).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
|
28
31
|
return () => controller.abort();
|
|
29
32
|
}, [revision]);
|
|
30
|
-
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setShowSaving(null);
|
|
35
|
+
if (!saving) return;
|
|
36
|
+
const timeout = window.setTimeout(() => setShowSaving(saving), 400);
|
|
37
|
+
return () => window.clearTimeout(timeout);
|
|
38
|
+
}, [saving]);
|
|
39
|
+
const profile = profiles.find((item) => item.id === 'personal');
|
|
40
|
+
const save = async (role: Role, model: string, effort: string) => {
|
|
41
|
+
setSaving(role.role);
|
|
42
|
+
setError(null);
|
|
43
|
+
setProfiles((current) => current.map((item) => item.id !== 'personal' ? item : {
|
|
44
|
+
...item,
|
|
45
|
+
roles: item.roles.map((candidate) => candidate.role === role.role ? { ...candidate, model, effort } : candidate),
|
|
46
|
+
}));
|
|
47
|
+
try {
|
|
48
|
+
const result = await updateModelRole(role.role, model, effort) as { profile: Profile };
|
|
49
|
+
setProfiles([result.profile]);
|
|
50
|
+
} catch (failure) {
|
|
51
|
+
setProfiles((current) => current.map((item) => item.id !== 'personal' ? item : {
|
|
52
|
+
...item,
|
|
53
|
+
roles: item.roles.map((candidate) => candidate.role === role.role ? role : candidate),
|
|
54
|
+
}));
|
|
55
|
+
setError(failure instanceof Error ? failure.message : 'Model role could not be saved.');
|
|
56
|
+
} finally {
|
|
57
|
+
setSaving(null);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
31
60
|
return <Stack gap="md" aria-label="Model settings">
|
|
32
61
|
<Group justify="space-between">
|
|
33
62
|
<Title order={2} size="h3">Model roles</Title>
|
|
34
|
-
<
|
|
63
|
+
<ActionIcon aria-label="Refresh" aria-busy={loading} aria-disabled={loading || saving !== null} className="taskchef-icon-button" color="gray" variant="subtle" size="lg" onClick={() => {
|
|
64
|
+
if (loading || saving) return;
|
|
65
|
+
setLoading(true);
|
|
66
|
+
setRevision((value) => value + 1);
|
|
67
|
+
}}><IconRefresh className={loading ? 'taskchef-refresh-spinning' : undefined} size={18} stroke={1.8} /></ActionIcon>
|
|
35
68
|
</Group>
|
|
36
|
-
<Text c="dimmed" size="sm">
|
|
69
|
+
<Text c="dimmed" size="sm">Your models for planning, implementation, and review.</Text>
|
|
37
70
|
{error && <Alert color="red" role="alert">{error}</Alert>}
|
|
38
|
-
{loading && <Text role="status">Loading model preferences…</Text>}
|
|
39
71
|
{profile && <>
|
|
40
|
-
<Select label="Profile scope" data={profiles.map((item) => ({ value: item.id, label: item.project }))} value={profile.id} onChange={(value) => setSelection(value ?? 'personal')} />
|
|
41
72
|
{profile.problems.map((problem) => <Alert color="yellow" key={problem}>{problem}</Alert>)}
|
|
42
73
|
{profile.roles.map((role) => <Paper key={role.role} withBorder p="md">
|
|
43
74
|
<Stack gap="xs">
|
|
44
75
|
<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>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
|
77
|
+
<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) => {
|
|
78
|
+
if (loading || saving || !model) return;
|
|
79
|
+
const option = profile.modelOptions?.find((candidate) => candidate.value === model);
|
|
80
|
+
const effort = typeof role.effort === 'string' && option?.efforts.includes(role.effort) ? role.effort : option?.efforts[0];
|
|
81
|
+
if (effort) void save(role, model, effort);
|
|
82
|
+
}} />
|
|
83
|
+
<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) => {
|
|
84
|
+
if (!loading && !saving && effort && typeof role.model === 'string') void save(role, role.model, effort);
|
|
85
|
+
}} />
|
|
86
|
+
</SimpleGrid>
|
|
87
|
+
<Text size="sm" style={{ overflowWrap: 'anywhere' }}>Source: {role.displaySource ?? role.source ?? 'No role file'}</Text>
|
|
88
|
+
{showSaving === role.role && <Text c="dimmed" size="sm" role="status">Saving…</Text>}
|
|
50
89
|
{role.problems.map((problem) => <Alert key={problem} color="red">{problem}</Alert>)}
|
|
51
90
|
</Stack>
|
|
52
91
|
</Paper>)}
|
|
53
|
-
<Paper withBorder p="md"><Stack gap="xs">
|
|
54
|
-
<Text fw={600}>Precedence and validation</Text>
|
|
55
|
-
<Text size="sm">{profile.precedence}</Text>
|
|
56
|
-
<Text size="sm">{profile.scope}</Text>
|
|
57
|
-
<Text c="dimmed" size="sm" style={{ overflowWrap: 'anywhere' }}>Catalog: {profile.catalogSource ?? 'Unavailable'}. This preview does not prove that a future run will honor the profile. The current native interface validates model availability.</Text>
|
|
58
|
-
</Stack></Paper>
|
|
59
92
|
</>}
|
|
93
|
+
{usageProvider && <Paper withBorder p="md">
|
|
94
|
+
<Stack gap="xs">
|
|
95
|
+
<Group justify="space-between">
|
|
96
|
+
<Title order={3} size="h4">Usage provider</Title>
|
|
97
|
+
<Badge color={usageProvider.status === 'available' ? 'teal' : 'red'}>{usageProvider.status}</Badge>
|
|
98
|
+
</Group>
|
|
99
|
+
<Text>{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
|
|
100
|
+
<Text c="dimmed" size="sm">Version reported by the executable TaskChef uses for token and cost estimates.</Text>
|
|
101
|
+
</Stack>
|
|
102
|
+
</Paper>}
|
|
60
103
|
</Stack>;
|
|
61
104
|
}
|
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
import { resolveModelRoles } from "./model-roles.js";
|
|
1
|
+
import { resolveModelRoles, updateModelRole } from "./model-roles.js";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { constants, watch } from "node:fs";
|
|
@@ -27,7 +27,7 @@ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
|
27
27
|
import { taskGitHubProjection } from "./dashboard/github-links.js";
|
|
28
28
|
import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
|
|
29
29
|
import { createUsageTracker } from "./usage-tracker.js";
|
|
30
|
-
import { readUsageStore, usageStorePath } from "./usage.js";
|
|
30
|
+
import { readCcusageRuntimeInfo, readUsageStore, usageStorePath } from "./usage.js";
|
|
31
31
|
import { reportedWorkSummary } from "./reported-work.js";
|
|
32
32
|
import {
|
|
33
33
|
MAX_DASHBOARD_SESSION_PIDS,
|
|
@@ -804,6 +804,10 @@ export async function createDashboardServer({
|
|
|
804
804
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
805
805
|
usageTracker = null,
|
|
806
806
|
usageSummaryMonitor = null,
|
|
807
|
+
usagePreloadIntervalMs = 30_000,
|
|
808
|
+
resolveRoles = resolveModelRoles,
|
|
809
|
+
resolveUsageProvider = readCcusageRuntimeInfo,
|
|
810
|
+
updateRole = updateModelRole,
|
|
807
811
|
control = null,
|
|
808
812
|
controlReplayCache = createDashboardControlReplayCache(),
|
|
809
813
|
} = {}) {
|
|
@@ -816,6 +820,9 @@ export async function createDashboardServer({
|
|
|
816
820
|
if (!Number.isInteger(maxEventClients) || maxEventClients < 0) {
|
|
817
821
|
throw new Error("dashboard event-client limit must be a non-negative integer");
|
|
818
822
|
}
|
|
823
|
+
if (!Number.isFinite(usagePreloadIntervalMs) || usagePreloadIntervalMs < 1_000) {
|
|
824
|
+
throw new Error("dashboard usage preload interval must be at least one second");
|
|
825
|
+
}
|
|
819
826
|
if (!new Set(["mcp", "session", "standalone"]).has(launcher)) {
|
|
820
827
|
throw new Error("dashboard launcher must be mcp, session, or standalone");
|
|
821
828
|
}
|
|
@@ -849,11 +856,13 @@ export async function createDashboardServer({
|
|
|
849
856
|
}
|
|
850
857
|
} catch (error) {
|
|
851
858
|
taskUsageSummaryMonitor?.close();
|
|
859
|
+
taskUsageTracker?.close?.();
|
|
852
860
|
monitor.close();
|
|
853
861
|
throw error;
|
|
854
862
|
}
|
|
855
863
|
const clients = new Set();
|
|
856
864
|
const archiveRequests = new Set();
|
|
865
|
+
let usageProviderPromise = null;
|
|
857
866
|
let controlHandoff = null;
|
|
858
867
|
let controlHandoffTimer = null;
|
|
859
868
|
let allowedAuthority;
|
|
@@ -873,12 +882,21 @@ export async function createDashboardServer({
|
|
|
873
882
|
})),
|
|
874
883
|
};
|
|
875
884
|
};
|
|
876
|
-
const
|
|
885
|
+
const preloadUsage = () => {
|
|
886
|
+
void Promise.resolve(taskUsageTracker.preload?.(monitor.tasks)).catch(() => {});
|
|
887
|
+
};
|
|
888
|
+
const snapshotListener = () => {
|
|
889
|
+
preloadUsage();
|
|
890
|
+
broadcast("snapshot", dashboardSnapshot());
|
|
891
|
+
};
|
|
877
892
|
const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
|
|
878
893
|
const errorListener = () => broadcast("dashboard-error", publicMonitorError());
|
|
879
894
|
monitor.on("snapshot", snapshotListener);
|
|
880
895
|
monitor.on("monitorError", errorListener);
|
|
881
896
|
taskUsageSummaryMonitor.on("change", usageSummaryListener);
|
|
897
|
+
preloadUsage();
|
|
898
|
+
const usagePreloadTimer = setInterval(preloadUsage, usagePreloadIntervalMs);
|
|
899
|
+
usagePreloadTimer.unref?.();
|
|
882
900
|
|
|
883
901
|
const handleRequest = async (request, response) => {
|
|
884
902
|
const method = request.method ?? "GET";
|
|
@@ -1180,12 +1198,44 @@ export async function createDashboardServer({
|
|
|
1180
1198
|
}
|
|
1181
1199
|
|
|
1182
1200
|
if (url.pathname === "/api/settings" && method === "GET") {
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1201
|
+
const profiles = [{ id: "personal", project: "Personal", ...await resolveRoles({ includeCatalog: true }) }];
|
|
1202
|
+
usageProviderPromise ??= Promise.resolve(resolveUsageProvider()).catch(() => ({
|
|
1203
|
+
provider: "ccusage",
|
|
1204
|
+
status: "unavailable",
|
|
1205
|
+
version: null,
|
|
1206
|
+
}));
|
|
1207
|
+
sendJson(response, 200, { profiles, usageProvider: await usageProviderPromise });
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const settingsMatch = url.pathname.match(/^\/api\/settings\/(planner|implementer|reviewer)$/);
|
|
1212
|
+
if (settingsMatch && method === "POST") {
|
|
1213
|
+
if (request.headers.origin !== allowedOrigin) {
|
|
1214
|
+
sendJson(response, 403, { code: "invalid_origin", message: "Dashboard origin validation failed." });
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
const body = await readBoundedJsonBody(request);
|
|
1219
|
+
const fields = Object.keys(body);
|
|
1220
|
+
if (fields.length !== 3 || body.schemaVersion !== 1
|
|
1221
|
+
|| typeof body.model !== "string" || typeof body.effort !== "string") {
|
|
1222
|
+
const error = new Error("Model role update has an invalid shape.");
|
|
1223
|
+
error.code = "invalid_request";
|
|
1224
|
+
throw error;
|
|
1225
|
+
}
|
|
1226
|
+
const profile = await updateRole(settingsMatch[1], body.model, body.effort);
|
|
1227
|
+
sendJson(response, 200, { profile: { id: "personal", project: "Personal", ...profile } });
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
const status = error?.code === "invalid_request" ? 400
|
|
1230
|
+
: error?.code === "unsupported_media_type" ? 415
|
|
1231
|
+
: error?.code === "body_too_large" ? 413 : 409;
|
|
1232
|
+
sendJson(response, status, {
|
|
1233
|
+
code: error?.code ?? "role_update_failed",
|
|
1234
|
+
message: error?.code === "invalid_request"
|
|
1235
|
+
? "Model role update is invalid."
|
|
1236
|
+
: "Model role could not be saved. Review the profile and try again.",
|
|
1237
|
+
});
|
|
1187
1238
|
}
|
|
1188
|
-
sendJson(response, 200, { profiles });
|
|
1189
1239
|
return;
|
|
1190
1240
|
}
|
|
1191
1241
|
|
|
@@ -1440,8 +1490,10 @@ export async function createDashboardServer({
|
|
|
1440
1490
|
server.once("error", reject);
|
|
1441
1491
|
server.listen(port, host, resolve);
|
|
1442
1492
|
}).catch((error) => {
|
|
1493
|
+
clearInterval(usagePreloadTimer);
|
|
1443
1494
|
monitor.close();
|
|
1444
1495
|
taskUsageSummaryMonitor.close();
|
|
1496
|
+
taskUsageTracker.close?.();
|
|
1445
1497
|
throw error;
|
|
1446
1498
|
});
|
|
1447
1499
|
|
|
@@ -1464,6 +1516,8 @@ export async function createDashboardServer({
|
|
|
1464
1516
|
taskUsageSummaryMonitor.off("change", usageSummaryListener);
|
|
1465
1517
|
monitor.close();
|
|
1466
1518
|
taskUsageSummaryMonitor.close();
|
|
1519
|
+
taskUsageTracker.close?.();
|
|
1520
|
+
clearInterval(usagePreloadTimer);
|
|
1467
1521
|
for (const client of clients) client.close();
|
|
1468
1522
|
const closed = new Promise((resolve, reject) => server.close((error) =>
|
|
1469
1523
|
error ? reject(error) : resolve()));
|
package/src/mcp.js
CHANGED
|
@@ -103,7 +103,6 @@ const preparationSchema = z.object({
|
|
|
103
103
|
preparedAt: z.string(),
|
|
104
104
|
marker: z.string(),
|
|
105
105
|
modelRoles: z.record(z.string(), z.unknown()).optional(),
|
|
106
|
-
projectModelRoles: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
107
106
|
projectCount: z.number(),
|
|
108
107
|
projects: z.array(projectSchema),
|
|
109
108
|
});
|
package/src/model-roles.js
CHANGED
|
@@ -21,24 +21,50 @@ function missingRoles() {
|
|
|
21
21
|
taskOverrides: {}, subagentOverrides: {},
|
|
22
22
|
fallback: role === 'reviewer' ? 'inherit parent settings' : 'native new-task default (not guaranteed dispatcher inheritance)',
|
|
23
23
|
})),
|
|
24
|
-
problems: [], catalogSource: null,
|
|
25
|
-
precedence: 'explicit user model (and its explicit effort) >
|
|
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
26
|
scope: 'Model and effort only; agent instructions, tools, permissions, and other TOML keys are not applied by this adapter.',
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export async function resolveModelRoles(
|
|
30
|
+
export async function resolveModelRoles({ run = execFile, env = process.env, includeCatalog = false } = {}) {
|
|
31
31
|
try {
|
|
32
32
|
const home = env.HOME || os.homedir();
|
|
33
33
|
const codexHome = (env.CODEX_HOME || path.join(home, '.codex')).replace(/^~(?=\/|$)/, home);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const { stdout } = await run('python3', [resolver, ...(project ? ['--project', project] : [])], {
|
|
34
|
+
const agentsDirectory = path.join(codexHome, 'agents');
|
|
35
|
+
if (!await hasAgentFiles(agentsDirectory) && !includeCatalog) return missingRoles();
|
|
36
|
+
const { stdout } = await run('python3', [resolver], {
|
|
38
37
|
env, timeout: 5000, maxBuffer: 128 * 1024,
|
|
39
38
|
});
|
|
40
|
-
|
|
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;
|
|
41
47
|
} catch {
|
|
42
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 };
|
|
43
49
|
}
|
|
44
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
|
+
}
|