@saadnvd1/agent-os 0.3.570 → 0.3.572

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/app/api/bus/spawn/route.ts +21 -3
  3. package/app/api/client-config/route.test.ts +11 -0
  4. package/app/api/client-config/route.ts +10 -0
  5. package/app/api/load/route.test.ts +32 -0
  6. package/app/api/load/route.ts +20 -0
  7. package/app/api/sessions/[id]/pause/route.ts +97 -0
  8. package/app/api/tasks/[id]/priority/route.ts +29 -0
  9. package/app/api/tasks/route.ts +17 -2
  10. package/components/Chat/AccessPicker.tsx +10 -18
  11. package/components/Chat/ChatPanel.tsx +4 -0
  12. package/components/Chat/ModelPicker.tsx +9 -3
  13. package/components/Chat/PausedBanner.tsx +54 -0
  14. package/components/Chat/composer/ComposerBody.tsx +3 -0
  15. package/components/Chat/composer/Toolbar.tsx +3 -0
  16. package/components/Draft/DraftChips.tsx +25 -0
  17. package/components/Draft/DraftPanel.tsx +1 -0
  18. package/components/Recipes/format.ts +19 -30
  19. package/components/SessionList/QueuedRow.tsx +46 -2
  20. package/components/SessionList/RowSubtitle.tsx +21 -3
  21. package/components/SessionList/SessionRowMenu.tsx +49 -1
  22. package/components/SessionList/useShelfActions.ts +39 -2
  23. package/components/Tasks/task-state-labels.ts +16 -16
  24. package/data/chat/useChat.ts +3 -0
  25. package/data/sessions/shelf.ts +50 -0
  26. package/data/tasks/index.ts +2 -0
  27. package/data/tasks/queue.ts +38 -1
  28. package/lib/bus/index.ts +21 -2
  29. package/lib/chat/access-levels.ts +24 -0
  30. package/lib/chat/events.ts +2 -0
  31. package/lib/chat/hold.ts +11 -2
  32. package/lib/chat/settings.ts +6 -2
  33. package/lib/config/client-config-types.ts +29 -0
  34. package/lib/config/client-config.ts +28 -0
  35. package/lib/db/migrations.ts +56 -1
  36. package/lib/db/types.ts +30 -0
  37. package/lib/done/index.ts +2 -0
  38. package/lib/drafts.ts +3 -0
  39. package/lib/hosts/peer-load.ts +120 -0
  40. package/lib/load/machines.ts +124 -0
  41. package/lib/load/monitor.ts +17 -2
  42. package/lib/load/start-gate.ts +95 -0
  43. package/lib/model-catalog.ts +11 -0
  44. package/lib/orchestrator/act.ts +78 -0
  45. package/lib/orchestrator/brakes.ts +2 -0
  46. package/lib/orchestrator/brief.ts +4 -1
  47. package/lib/orchestrator/digest.ts +15 -4
  48. package/lib/orchestrator/serve.ts +18 -0
  49. package/lib/orchestrator/tool-names.ts +3 -0
  50. package/lib/orchestrator/tool-schemas.ts +33 -0
  51. package/lib/orchestrator/tools.ts +7 -1
  52. package/lib/project-config/agentos.schema.json +13 -0
  53. package/lib/project-config/schema.ts +9 -0
  54. package/lib/recipes/status-labels.ts +27 -0
  55. package/lib/sidebar/paused.ts +13 -0
  56. package/lib/sidebar/queued.ts +34 -1
  57. package/lib/sidebar/shelves.ts +21 -1
  58. package/lib/sidebar/snooze.ts +26 -0
  59. package/lib/tasks/finish.ts +7 -0
  60. package/lib/tasks/move-carry.ts +1 -0
  61. package/lib/tasks/move-flow.ts +6 -0
  62. package/lib/tasks/pause-hold.ts +70 -0
  63. package/lib/tasks/pause-policy.ts +130 -0
  64. package/lib/tasks/pause.ts +718 -0
  65. package/lib/tasks/placement.ts +219 -0
  66. package/lib/tasks/queue.ts +522 -64
  67. package/lib/tasks/schedule-types.ts +64 -0
  68. package/lib/tasks/schedule.ts +271 -0
  69. package/lib/tasks/state-labels.ts +21 -0
  70. package/package.json +1 -1
  71. package/server.ts +2 -1
package/README.md CHANGED
@@ -13,7 +13,7 @@ and check on all of it from your phone.
13
13
  ## What it does
14
14
 
15
15
  - **An orchestrator for each workspace.** A chat that starts tasks, answers blocked agents, and merges pull requests only through gates. Anything that is yours to decide waits as an ask. [More](docs/reference/orchestrator.md)
16
- - **Tasks that end in a pull request.** Each gets its own worktree, branch and ports, runs the project's code review, and waits for your sign-off. [More](docs/guides/tasks.md)
16
+ - **Tasks that end in a pull request.** Each gets its own worktree, branch and ports, runs the project's code review, and waits for your sign-off. A queue ranks waiting tasks by priority and machine load, can start one on a linked machine when this one is busy, and a running task can be paused for a set time and resumes by itself. [More](docs/guides/tasks.md)
17
17
  - **Each kind of work on the right model.** Planning, review, implementation and titles each run on their own model, set in Settings → Models. [More](docs/reference/model-routing.md)
18
18
  - **Nothing starts until you send.** ⌥N opens a draft with project, machine, agent and branch as chips. [More](docs/guides/sessions.md)
19
19
  - **Other machines in the same list.** Link another machine that runs AgentOS and start or move work there. [More](docs/reference/machines.md)
@@ -1,7 +1,8 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { spawnSession, findProject } from "@/lib/agents/spawn";
3
3
  import { createTask } from "@/lib/tasks";
4
- import { startOrQueue } from "@/lib/tasks/queue";
4
+ import { startOrQueue, stampPriority } from "@/lib/tasks/queue";
5
+ import { isPriority } from "@/lib/tasks/schedule-types";
5
6
  import { hostIdNamed } from "@/lib/hosts";
6
7
  import { startView } from "@/lib/sessions/launch";
7
8
  import { db } from "@/lib/db";
@@ -30,6 +31,8 @@ export async function POST(request: NextRequest) {
30
31
  after,
31
32
  view,
32
33
  planOnly,
34
+ priority,
35
+ machine,
33
36
  from,
34
37
  } = await request.json();
35
38
  const startedBy = orchestratorFrom(from);
@@ -38,6 +41,12 @@ export async function POST(request: NextRequest) {
38
41
  throw new Error("--after works for tasks only");
39
42
  if (planOnly !== undefined && mode !== "task")
40
43
  throw new Error("--plan-only works for tasks only");
44
+ if ((priority !== undefined || machine !== undefined) && mode !== "task")
45
+ throw new Error("--priority and --on-mac work for tasks only");
46
+ if (priority !== undefined && !isPriority(priority))
47
+ throw new Error("--priority is urgent, blocking, normal or background");
48
+ if (machine !== undefined && machine !== "mac" && machine !== "any")
49
+ throw new Error('machine is "mac" or "any"');
41
50
  if (mode === "task") {
42
51
  const task = {
43
52
  projectId: findProject(String(project ?? "")).id,
@@ -50,8 +59,17 @@ export async function POST(request: NextRequest) {
50
59
  startedBy,
51
60
  };
52
61
  const out = await startOrQueue(
53
- { ...task, after: typeof after === "string" ? after : undefined },
54
- () => createTask(task)
62
+ {
63
+ ...task,
64
+ after: typeof after === "string" ? after : undefined,
65
+ priority,
66
+ machine,
67
+ },
68
+ async () => {
69
+ const session = await createTask(task);
70
+ stampPriority(session.id, priority);
71
+ return session;
72
+ }
55
73
  );
56
74
  return "queued" in out
57
75
  ? NextResponse.json({ queued: out.queued }, { status: 202 })
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const facts = vi.hoisted(() => ({ level: null as string | null }));
4
+ vi.mock("@/lib/load/machines", () => ({
5
+ localFacts: () => ({
6
+ level: facts.level,
7
+ load1: 1,
8
+ cores: 8,
9
+ memUsedPct: 40,
10
+ booted: 0,
11
+ capabilities: ["linux"],
12
+ }),
13
+ }));
14
+
15
+ import { GET } from "./route";
16
+ import { parseReading } from "@/lib/hosts/peer-load";
17
+
18
+ describe("GET /api/load", () => {
19
+ it("reports no level before the first sample, and the asker reads that as no reading", async () => {
20
+ facts.level = null;
21
+ const body = await GET().json();
22
+ expect(body.level).toBeNull();
23
+ expect(parseReading(body, 1)).toBeNull();
24
+ });
25
+
26
+ it("reports the level once it has one", async () => {
27
+ facts.level = "amber";
28
+ const body = await GET().json();
29
+ expect(body).toMatchObject({ level: "amber", cores: 8 });
30
+ expect(parseReading(body, 1)).toMatchObject({ level: "amber" });
31
+ });
32
+ });
@@ -0,0 +1,20 @@
1
+ import { NextResponse } from "next/server";
2
+ import { localFacts } from "@/lib/load/machines";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ // This machine's load as the scheduler reads it, for another machine's
7
+ // AgentOS deciding whether to start a task here. No session names or paths.
8
+ // Before the monitor's first sample the level is null, which the asker
9
+ // reads as no reading (held), never as green.
10
+ export function GET() {
11
+ const f = localFacts();
12
+ return NextResponse.json({
13
+ level: f.level,
14
+ load1: f.load1,
15
+ cores: f.cores,
16
+ memUsedPct: f.memUsedPct,
17
+ booted: f.booted,
18
+ capabilities: f.capabilities,
19
+ });
20
+ }
@@ -0,0 +1,97 @@
1
+ import { NextResponse } from "next/server";
2
+ import {
3
+ PauseRefused,
4
+ pauseViewOf,
5
+ requestPause,
6
+ resumeNow,
7
+ setResumeTime,
8
+ } from "@/lib/tasks/pause";
9
+
10
+ const MAX_AHEAD_MS = 60 * 24 * 3600_000;
11
+
12
+ function parseUntil(v: unknown): Date | null | "bad" {
13
+ if (v === null) return null;
14
+ const at = typeof v === "string" ? new Date(v) : new Date(NaN);
15
+ const ahead = at.getTime() - Date.now();
16
+ return Number.isNaN(ahead) || ahead < 0 || ahead > MAX_AHEAD_MS ? "bad" : at;
17
+ }
18
+
19
+ const fail = (error: unknown) =>
20
+ NextResponse.json(
21
+ { error: error instanceof Error ? error.message : String(error) },
22
+ { status: error instanceof PauseRefused ? 409 : 500 }
23
+ );
24
+
25
+ // POST /api/sessions/:id/pause { until, mode } - pauses it: until an ISO time
26
+ // (the reader's own 9am, worked out on their device), or null for until it is
27
+ // resumed. mode "turn" (the default) stops it at the end of its step; "now"
28
+ // interrupts the step. Refused with 409 and the reason when it can't be.
29
+ export async function POST(
30
+ request: Request,
31
+ { params }: { params: Promise<{ id: string }> }
32
+ ) {
33
+ const { id } = await params;
34
+ const body = (await request.json().catch(() => null)) as {
35
+ until?: unknown;
36
+ mode?: unknown;
37
+ } | null;
38
+ if (body?.until === undefined)
39
+ return NextResponse.json({ error: "until is required" }, { status: 400 });
40
+ const until = parseUntil(body.until);
41
+ if (until === "bad")
42
+ return NextResponse.json(
43
+ { error: "until must be a time within the next 60 days" },
44
+ { status: 400 }
45
+ );
46
+ if (body.mode !== undefined && body.mode !== "turn" && body.mode !== "now")
47
+ return NextResponse.json(
48
+ { error: 'mode is "turn" or "now"' },
49
+ { status: 400 }
50
+ );
51
+ try {
52
+ requestPause(id, { until, mode: body.mode ?? "turn", by: "person" });
53
+ return NextResponse.json({ pause: pauseViewOf(id) }, { status: 202 });
54
+ } catch (error) {
55
+ return fail(error);
56
+ }
57
+ }
58
+
59
+ // PATCH /api/sessions/:id/pause { until } - changes when it resumes.
60
+ export async function PATCH(
61
+ request: Request,
62
+ { params }: { params: Promise<{ id: string }> }
63
+ ) {
64
+ const { id } = await params;
65
+ const body = (await request.json().catch(() => null)) as {
66
+ until?: unknown;
67
+ } | null;
68
+ if (body?.until === undefined)
69
+ return NextResponse.json({ error: "until is required" }, { status: 400 });
70
+ const until = parseUntil(body.until);
71
+ if (until === "bad")
72
+ return NextResponse.json(
73
+ { error: "until must be a time within the next 60 days" },
74
+ { status: 400 }
75
+ );
76
+ try {
77
+ setResumeTime(id, until);
78
+ return NextResponse.json({ pause: pauseViewOf(id) });
79
+ } catch (error) {
80
+ return fail(error);
81
+ }
82
+ }
83
+
84
+ // DELETE /api/sessions/:id/pause - resume now (past the task limit), or keep
85
+ // running when it is still pausing.
86
+ export async function DELETE(
87
+ _request: Request,
88
+ { params }: { params: Promise<{ id: string }> }
89
+ ) {
90
+ const { id } = await params;
91
+ try {
92
+ await resumeNow(id, "person");
93
+ return NextResponse.json({ pause: pauseViewOf(id) });
94
+ } catch (error) {
95
+ return fail(error);
96
+ }
97
+ }
@@ -0,0 +1,29 @@
1
+ import { NextResponse } from "next/server";
2
+ import { setPriority } from "@/lib/tasks/queue";
3
+ import { isPriority } from "@/lib/tasks/schedule-types";
4
+
5
+ // PATCH /api/tasks/:id/priority { priority } - urgent, blocking, normal or
6
+ // background, for a queued task or a running one. A queued task keeps its age.
7
+ export async function PATCH(
8
+ request: Request,
9
+ { params }: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const { id } = await params;
12
+ const body = (await request.json().catch(() => null)) as {
13
+ priority?: unknown;
14
+ } | null;
15
+ if (!isPriority(body?.priority))
16
+ return NextResponse.json(
17
+ { error: "priority is one of: urgent, blocking, normal, background" },
18
+ { status: 400 }
19
+ );
20
+ try {
21
+ setPriority(id, body.priority);
22
+ } catch (error) {
23
+ return NextResponse.json(
24
+ { error: error instanceof Error ? error.message : String(error) },
25
+ { status: 404 }
26
+ );
27
+ }
28
+ return NextResponse.json({ ok: true, priority: body.priority });
29
+ }
@@ -1,7 +1,8 @@
1
1
  import { parseOverride } from "@/lib/models/resolve";
2
2
  import { NextRequest, NextResponse } from "next/server";
3
3
  import { createTask, listTasks, movedTasks } from "@/lib/tasks";
4
- import { startOrQueue } from "@/lib/tasks/queue";
4
+ import { stampPriority, startOrQueue } from "@/lib/tasks/queue";
5
+ import { isPriority } from "@/lib/tasks/schedule-types";
5
6
  import { TASK_CAPABILITIES } from "@/lib/tasks/remote";
6
7
  import { ensureProject, type ProjectRef } from "@/lib/tasks/project-ref";
7
8
  import { isSessionId, statusFor } from "@/lib/tasks/move-bundle";
@@ -40,12 +41,20 @@ export async function POST(request: NextRequest) {
40
41
  plan,
41
42
  plan_reason,
42
43
  plan_only,
44
+ priority,
45
+ machine,
43
46
  } = await request.json();
44
47
  if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
45
48
  const pid =
46
49
  project && typeof project === "object"
47
50
  ? (await ensureProject(project as ProjectRef)).id
48
51
  : projectId;
52
+ if (priority !== undefined && !isPriority(priority))
53
+ throw new Error(
54
+ "Priority is one of: urgent, blocking, normal, background"
55
+ );
56
+ if (machine !== undefined && machine !== "any" && machine !== "mac")
57
+ throw new Error('Machine is "any" or "mac"');
49
58
  if (agent !== undefined && !isValidAgentType(agent))
50
59
  throw new Error("Unknown agent");
51
60
  if (agent && agent !== "claude" && model != null && model !== "")
@@ -76,12 +85,18 @@ export async function POST(request: NextRequest) {
76
85
  return NextResponse.json({ session }, { status: 201 });
77
86
  }
78
87
  const out = await startOrQueue(
79
- { ...opts, after: typeof after === "string" ? after : undefined },
88
+ {
89
+ ...opts,
90
+ after: typeof after === "string" ? after : undefined,
91
+ priority,
92
+ machine,
93
+ },
80
94
  () => createTask(opts)
81
95
  );
82
96
  if ("queued" in out)
83
97
  return NextResponse.json({ queued: out.queued }, { status: 202 });
84
98
  const session = out.started;
99
+ stampPriority(session.id, priority);
85
100
  return NextResponse.json({ session }, { status: 201 });
86
101
  } catch (error) {
87
102
  const message = error instanceof Error ? error.message : String(error);
@@ -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({
@@ -12,6 +12,7 @@ import {
12
12
  import { ArrowDown, Loader2 } from "lucide-react";
13
13
  import type { ClipboardEvent } from "react";
14
14
  import { useChat } from "@/data/chat/useChat";
15
+ import { PausedBanner } from "./PausedBanner";
15
16
  import {
16
17
  groupTimeline,
17
18
  sameBlock,
@@ -265,6 +266,7 @@ export function ChatPanel({
265
266
  refreshingCommands,
266
267
  models,
267
268
  model,
269
+ modelLabel,
268
270
  send,
269
271
  interrupt,
270
272
  setModel,
@@ -715,6 +717,7 @@ export function ChatPanel({
715
717
  setInsert({ text: quoteMarkdown(text), at: Date.now() })
716
718
  }
717
719
  />
720
+ <PausedBanner sessionId={sessionId} />
718
721
  <Queue
719
722
  queue={queue}
720
723
  running={running}
@@ -770,6 +773,7 @@ export function ChatPanel({
770
773
  refreshingCommands={refreshingCommands}
771
774
  models={models}
772
775
  model={model}
776
+ modelLabel={modelLabel}
773
777
  onSetModel={setModel}
774
778
  access={access}
775
779
  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>
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { Pause } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import { useSessionsQuery } from "@/data/sessions";
6
+ import { useResumeSession } from "@/data/sessions/shelf";
7
+ import { pauseSubtitle } from "@/lib/sidebar/paused";
8
+ import { Button } from "@/components/ui/button";
9
+
10
+ // Above the composer while the session is paused: when it resumes, and a
11
+ // button to resume it now. What is sent meanwhile waits and goes with it.
12
+ export function PausedBanner({ sessionId }: { sessionId: string }) {
13
+ const { data } = useSessionsQuery();
14
+ const resume = useResumeSession();
15
+ const session = data?.sessions.find((s) => s.id === sessionId);
16
+ const state = session?.pause_state;
17
+ if (!session || !state) return null;
18
+ const until = session.paused_until
19
+ ? Date.parse(`${session.paused_until.replace(" ", "T")}Z`)
20
+ : null;
21
+ return (
22
+ <section
23
+ aria-label="Paused"
24
+ className="bg-card popover-surface mb-2 flex min-h-11 items-center gap-2 rounded-2xl px-3 py-1.5"
25
+ >
26
+ <Pause
27
+ aria-hidden
28
+ className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400"
29
+ />
30
+ <p className="min-w-0 flex-1 text-sm">
31
+ {pauseSubtitle({ state, until })}
32
+ <span className="text-muted-foreground block text-xs">
33
+ Messages you send now are held and delivered together when it resumes.
34
+ </span>
35
+ </p>
36
+ {(state === "paused" || state === "pausing") && (
37
+ <Button
38
+ size="sm"
39
+ variant="secondary"
40
+ className="min-h-11 shrink-0"
41
+ disabled={resume.isPending}
42
+ onClick={() =>
43
+ resume.mutate(
44
+ { id: sessionId },
45
+ { onError: (e) => toast.error(e.message) }
46
+ )
47
+ }
48
+ >
49
+ {state === "paused" ? "Resume now" : "Keep running"}
50
+ </Button>
51
+ )}
52
+ </section>
53
+ );
54
+ }
@@ -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}
@@ -6,6 +6,7 @@ import {
6
6
  Cpu,
7
7
  FolderGit2,
8
8
  GitBranch,
9
+ Flag,
9
10
  GitPullRequest,
10
11
  Monitor,
11
12
  SquareTerminal,
@@ -21,6 +22,12 @@ import { useProjectMachinesQuery } from "@/data/projects";
21
22
  import { modelChoices, useModelsQuery } from "@/data/models";
22
23
  import type { AgentProbe } from "@/lib/agents/probe";
23
24
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
25
+ import {
26
+ DEFAULT_PRIORITY,
27
+ PRIORITIES,
28
+ PRIORITY_HINT,
29
+ PRIORITY_LABEL,
30
+ } from "@/lib/tasks/schedule-types";
24
31
 
25
32
  // What stands between an agent and its first prompt, if anything.
26
33
  export function agentBlocker(probe?: AgentProbe): string | null {
@@ -260,6 +267,24 @@ export function DraftChips({
260
267
  ))}
261
268
  </ChipMenu>
262
269
  )}
270
+ {project && draft.openPr && (
271
+ <ChipMenu
272
+ icon={Flag}
273
+ name="Priority"
274
+ label={PRIORITY_LABEL[draft.taskPriority ?? DEFAULT_PRIORITY]}
275
+ >
276
+ {PRIORITIES.map((p) => (
277
+ <ChipItem
278
+ key={p}
279
+ checked={p === (draft.taskPriority ?? DEFAULT_PRIORITY)}
280
+ onSelect={() => onChange({ taskPriority: p })}
281
+ hint={PRIORITY_HINT[p]}
282
+ >
283
+ {PRIORITY_LABEL[p]}
284
+ </ChipItem>
285
+ ))}
286
+ </ChipMenu>
287
+ )}
263
288
  {project && draft.openPr && (
264
289
  <ChipToggle
265
290
  icon={SquareTerminal}
@@ -66,6 +66,7 @@ export function DraftPanel({
66
66
  projectId: project.id,
67
67
  prompt: text,
68
68
  model: draft.taskModel ?? undefined,
69
+ priority: draft.taskPriority,
69
70
  baseBranch: draft.baseBranch ?? undefined,
70
71
  hostId: draft.hostId !== project.host_id ? draft.hostId : undefined,
71
72
  view: draft.terminal ? "terminal" : undefined,
@@ -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];