@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
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import { Fragment } from "react";
4
- import { ArrowDown, ArrowUp, Play, Trash2 } from "lucide-react";
4
+ import { ArrowDown, ArrowUp, Flag, Play, Trash2 } from "lucide-react";
5
5
  import { toast } from "sonner";
6
6
  import * as DM from "@/components/ui/dropdown-menu";
7
7
  import * as CM from "@/components/ui/context-menu";
8
- import { useQueueAction, type QueueAction } from "@/data/tasks";
8
+ import { useQueueAction, useSetPriority, type QueueAction } from "@/data/tasks";
9
9
  import {
10
+ priorityMenu,
10
11
  queueMenu,
11
12
  queuedSubtitle,
12
13
  type QueuedRow as Row,
@@ -22,6 +23,18 @@ const SEPARATOR = {
22
23
  context: CM.ContextMenuSeparator,
23
24
  };
24
25
  const icon = "mr-2 h-3.5 w-3.5";
26
+ const SUB = {
27
+ dropdown: {
28
+ Sub: DM.DropdownMenuSub,
29
+ Trigger: DM.DropdownMenuSubTrigger,
30
+ Content: DM.DropdownMenuSubContent,
31
+ },
32
+ context: {
33
+ Sub: CM.ContextMenuSub,
34
+ Trigger: CM.ContextMenuSubTrigger,
35
+ Content: CM.ContextMenuSubContent,
36
+ },
37
+ };
25
38
  const ICONS = { start: Play, up: ArrowUp, down: ArrowDown, remove: Trash2 };
26
39
 
27
40
  const DONE: Record<QueueAction, string> = {
@@ -55,16 +68,47 @@ export function QueuedRow({ row }: { row: Row }) {
55
68
  );
56
69
  const failed = item.status === "failed";
57
70
  const busy = action.isPending;
71
+ const setPriority = useSetPriority();
72
+ const priorities = priorityMenu(item);
58
73
 
59
74
  const items = queueMenu(row);
60
75
  const menu = items.length
61
76
  ? (kind: RowMenuKind) => {
62
77
  const Item = ITEM[kind];
63
78
  const Separator = SEPARATOR[kind];
79
+ const { Sub, Trigger, Content } = SUB[kind];
64
80
  return items.map(({ action: a, label, disabled }) => {
65
81
  const Icon = ICONS[a];
66
82
  return (
67
83
  <Fragment key={a}>
84
+ {a === "remove" && priorities.length > 0 && (
85
+ <Sub>
86
+ <Trigger>
87
+ <Flag className={icon} />
88
+ Priority
89
+ </Trigger>
90
+ <Content>
91
+ {priorities.map((p) => (
92
+ <Item
93
+ key={p.priority}
94
+ onClick={() =>
95
+ setPriority.mutate(
96
+ { id: item.id, priority: p.priority },
97
+ { onError: (e) => toast.error(e.message) }
98
+ )
99
+ }
100
+ >
101
+ {p.label}
102
+ {p.current && (
103
+ <span className="text-muted-foreground ml-3 text-xs">
104
+ current
105
+ </span>
106
+ )}
107
+ </Item>
108
+ ))}
109
+ </Content>
110
+ </Sub>
111
+ )}
68
112
  {a === "remove" && items.length > 1 && <Separator />}
69
113
  <Item
70
114
  disabled={disabled || busy}
@@ -9,6 +9,7 @@ import { headerParts } from "@/lib/orchestrator/header-line";
9
9
  import { cn } from "@/lib/utils";
10
10
  import { usageLabel, useSessionUsage } from "@/data/load";
11
11
  import { useRowContext } from "./RowContext";
12
+ import { pauseSubtitle } from "@/lib/sidebar/paused";
12
13
 
13
14
  const ATTENTION = (part: string) => part === "paused" || /\basks?$/.test(part);
14
15
 
@@ -56,9 +57,21 @@ export function RowSubtitle({
56
57
  }
57
58
  const project = ctx.projectNames.get(session.project_id ?? "");
58
59
  const group = rowGroup(session);
60
+ const pause = session.pause_state;
59
61
  return (
60
62
  <span className="flex min-w-0 items-center gap-1.5">
61
- <LiveLine {...live} />
63
+ {pause ? (
64
+ <span className="min-w-0 truncate text-xs text-amber-600 dark:text-amber-400">
65
+ {pauseSubtitle({
66
+ state: pause,
67
+ until: session.paused_until
68
+ ? Date.parse(`${session.paused_until.replace(" ", "T")}Z`)
69
+ : null,
70
+ })}
71
+ </span>
72
+ ) : (
73
+ <LiveLine {...live} />
74
+ )}
62
75
  {group && (
63
76
  <span
64
77
  className="text-muted-foreground/70 hidden min-w-0 shrink items-center gap-1 truncate text-xs @[14rem]:flex"
@@ -72,9 +85,14 @@ export function RowSubtitle({
72
85
  <span className="truncate">{group.text}</span>
73
86
  </span>
74
87
  )}
75
- {(project || detail || usage) && (
88
+ {(project || detail || usage || session.placed_note) && (
76
89
  <span className="text-muted-foreground/70 min-w-0 truncate text-xs">
77
- {[usage && usageLabel(usage), detail?.slice(0, 200), project]
90
+ {[
91
+ usage && usageLabel(usage),
92
+ detail?.slice(0, 200),
93
+ session.placed_note,
94
+ project,
95
+ ]
78
96
  .filter(Boolean)
79
97
  .join(" · ")}
80
98
  </span>
@@ -17,6 +17,7 @@ import {
17
17
  Loader2,
18
18
  Pencil,
19
19
  Pin,
20
+ Pause,
20
21
  Play,
21
22
  Repeat,
22
23
  ChefHat,
@@ -35,7 +36,13 @@ import { rowGroup } from "@/lib/recipes/chip";
35
36
  import { finishActions } from "@/lib/sidebar/row-actions";
36
37
  import { useArchiveAction, useDoneAction } from "./useDoneAction";
37
38
  import { useShelfActions } from "./useShelfActions";
38
- import { SNOOZE_PRESETS, snoozePreset, wakeLabel } from "@/lib/sidebar/snooze";
39
+ import {
40
+ PAUSE_PRESETS,
41
+ SNOOZE_PRESETS,
42
+ pauseUntil,
43
+ snoozePreset,
44
+ wakeLabel,
45
+ } from "@/lib/sidebar/snooze";
39
46
  import type { SidebarRow } from "@/lib/sidebar/shelves";
40
47
  import { useRowContext } from "./RowContext";
41
48
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
@@ -399,6 +406,47 @@ export function SessionRowMenu({
399
406
  </Sub>
400
407
  )
401
408
  )}
409
+ {session.task_status || session.view === "chat" ? (
410
+ session.pause_state === "paused" ||
411
+ session.pause_state === "pausing" ? (
412
+ <Item onClick={() => shelf.resume(session)}>
413
+ <Play className={icon} />
414
+ {session.pause_state === "paused"
415
+ ? "Resume now"
416
+ : "Keep running"}
417
+ </Item>
418
+ ) : session.pause_state ? null : (
419
+ <Sub>
420
+ <SubTrigger>
421
+ <Pause className={icon} />
422
+ Pause
423
+ </SubTrigger>
424
+ <SubContent>
425
+ {PAUSE_PRESETS.map((p) => {
426
+ const at = pauseUntil(p.key);
427
+ return (
428
+ <Item
429
+ key={p.key}
430
+ onClick={() => shelf.pause(session, p.key)}
431
+ >
432
+ {p.label}
433
+ {at && (
434
+ <span className="text-muted-foreground ml-3 text-xs">
435
+ {wakeLabel(at.getTime())}
436
+ </span>
437
+ )}
438
+ </Item>
439
+ );
440
+ })}
441
+ <Item
442
+ onClick={() => shelf.pause(session, "until-resumed", true)}
443
+ >
444
+ Pause now (interrupt its step)
445
+ </Item>
446
+ </SubContent>
447
+ </Sub>
448
+ )
449
+ ) : null}
402
450
  {plain &&
403
451
  (row.settled ? (
404
452
  <Item onClick={() => shelf.reopen(session)}>
@@ -1,16 +1,25 @@
1
1
  import { toast } from "sonner";
2
2
  import type { Session } from "@/lib/db";
3
- import { useSettleSession, useSnoozeSession } from "@/data/sessions/shelf";
4
3
  import {
4
+ usePauseSession,
5
+ useResumeSession,
6
+ useSettleSession,
7
+ useSnoozeSession,
8
+ } from "@/data/sessions/shelf";
9
+ import {
10
+ pauseUntil,
5
11
  snoozePreset,
6
12
  wakeLabel,
13
+ type PausePreset,
7
14
  type SnoozePreset,
8
15
  } from "@/lib/sidebar/snooze";
9
16
 
10
- // A row menu's Snooze, Unsnooze, Mark done and Reopen, each with an Undo.
17
+ // A row menu's Snooze, Unsnooze, Pause, Resume, Mark done and Reopen.
11
18
  export function useShelfActions() {
12
19
  const snooze = useSnoozeSession();
13
20
  const settle = useSettleSession();
21
+ const pause = usePauseSession();
22
+ const resume = useResumeSession();
14
23
  const unsnooze = (session: Session) =>
15
24
  snooze.mutate({ id: session.id, until: null });
16
25
  return {
@@ -28,6 +37,34 @@ export function useShelfActions() {
28
37
  );
29
38
  },
30
39
  unsnooze,
40
+ // Stops its agent at the end of the step (or now) and starts it again at
41
+ // the time. A refusal (it is waiting on you, merging) is the toast.
42
+ pause: (session: Session, preset: PausePreset, now = false) => {
43
+ const until = pauseUntil(preset);
44
+ pause.mutate(
45
+ { id: session.id, until, mode: now ? "now" : "turn" },
46
+ {
47
+ onSuccess: () =>
48
+ toast.success(
49
+ until
50
+ ? `Pausing until ${wakeLabel(until.getTime())}`
51
+ : "Pausing until you resume",
52
+ {
53
+ action: {
54
+ label: "Keep running",
55
+ onClick: () => resume.mutate({ id: session.id }),
56
+ },
57
+ }
58
+ ),
59
+ onError: (e) => toast.error(e.message),
60
+ }
61
+ );
62
+ },
63
+ resume: (session: Session) =>
64
+ resume.mutate(
65
+ { id: session.id },
66
+ { onError: (e) => toast.error(e.message) }
67
+ ),
31
68
  settle: (session: Session) =>
32
69
  settle.mutate(
33
70
  { id: session.id, settled: true },
@@ -1,19 +1,19 @@
1
+ import type { Tone } from "@/lib/config/client-config-types";
1
2
  import type { TaskState } from "@/lib/tasks/state";
3
+ import { TASK_STATE_LABELS } from "@/lib/tasks/state-labels";
2
4
 
3
- // How a task's state reads, and its tone, wherever a task is shown.
4
- export const TASK_STATE: Record<TaskState, { label: string; tone: string }> = {
5
- working: { label: "Working", tone: "text-muted-foreground" },
6
- "needs-input": {
7
- label: "Needs input",
8
- tone: "text-amber-600 dark:text-amber-400",
9
- },
10
- blocked: { label: "Blocked", tone: "text-amber-600 dark:text-amber-400" },
11
- "plan-ready": { label: "Plan ready", tone: "text-primary" },
12
- review: { label: "Ready for review", tone: "text-primary" },
13
- "checks-failing": { label: "Checks failing", tone: "text-destructive" },
14
- exited: { label: "Agent exited", tone: "text-amber-600 dark:text-amber-400" },
15
- merged: { label: "Merged", tone: "text-emerald-600 dark:text-emerald-400" },
16
- dropped: { label: "Dropped", tone: "text-muted-foreground/60" },
17
- done: { label: "Done", tone: "text-emerald-600 dark:text-emerald-400" },
18
- moving: { label: "Moving", tone: "text-amber-600 dark:text-amber-400" },
5
+ export const TONE_TEXT: Record<Tone, string> = {
6
+ muted: "text-muted-foreground",
7
+ warning: "text-amber-600 dark:text-amber-400",
8
+ primary: "text-primary",
9
+ destructive: "text-destructive",
10
+ success: "text-emerald-600 dark:text-emerald-400",
19
11
  };
12
+
13
+ // How a task's state reads, and its tone class, wherever a task is shown.
14
+ export const TASK_STATE = Object.fromEntries(
15
+ Object.entries(TASK_STATE_LABELS).map(([k, v]) => [
16
+ k,
17
+ { label: v.label, tone: TONE_TEXT[v.tone] },
18
+ ])
19
+ ) as Record<TaskState, { label: string; tone: string }>;
@@ -146,6 +146,7 @@ export function useChat(
146
146
  const [commands, setCommands] = useState<ChatCommand[]>([]);
147
147
  const [models, setModels] = useState<ChatModel[]>([]);
148
148
  const [model, setModelState] = useState("");
149
+ const [modelLabel, setModelLabel] = useState<string | undefined>();
149
150
  const [refreshingCommands, setRefreshingCommands] = useState(false);
150
151
  const refreshTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
151
152
  const [access, setAccessState] = useState<ChatAccess>("full");
@@ -251,6 +252,7 @@ export function useChat(
251
252
  setCommands(m.commands);
252
253
  setModels(m.models);
253
254
  setModelState(m.model);
255
+ setModelLabel(m.modelLabel);
254
256
  setAccessState(m.access);
255
257
  setPlanState(m.plan);
256
258
  setCanBackground(!!m.background);
@@ -519,6 +521,7 @@ export function useChat(
519
521
  refreshingCommands,
520
522
  models,
521
523
  model,
524
+ modelLabel,
522
525
  send,
523
526
  interrupt,
524
527
  setModel,
@@ -83,3 +83,53 @@ export function useSettleSession() {
83
83
  : { settled_at: null, active_at: now }
84
84
  );
85
85
  }
86
+
87
+ async function send(method: string, url: string, body?: object) {
88
+ const res = await fetch(url, {
89
+ method,
90
+ headers: { "Content-Type": "application/json" },
91
+ body: body ? JSON.stringify(body) : undefined,
92
+ });
93
+ if (!res.ok)
94
+ throw new Error(
95
+ (await res.json().catch(() => null))?.error ?? "Request failed"
96
+ );
97
+ }
98
+
99
+ // until null: until resumed. The row moves to "pausing" at once; the server
100
+ // stops the agent at the end of its step (or now) and the push settles it.
101
+ export function usePauseSession() {
102
+ return useShelfMutation<{
103
+ id: string;
104
+ until: Date | null;
105
+ mode: "turn" | "now";
106
+ }>(
107
+ ({ id, until, mode }) =>
108
+ send("POST", `/api/sessions/${id}/pause`, {
109
+ until: until?.toISOString() ?? null,
110
+ mode,
111
+ }),
112
+ ({ until }) => ({
113
+ pause_state: "pausing",
114
+ paused_until: until ? toSqliteTime(until) : null,
115
+ })
116
+ );
117
+ }
118
+
119
+ // Resume now, or keep running while it is still pausing.
120
+ export function useResumeSession() {
121
+ return useShelfMutation<{ id: string }>(
122
+ ({ id }) => send("DELETE", `/api/sessions/${id}/pause`),
123
+ () => ({ pause_state: "resuming", paused_until: null })
124
+ );
125
+ }
126
+
127
+ export function useChangeResumeTime() {
128
+ return useShelfMutation<{ id: string; until: Date | null }>(
129
+ ({ id, until }) =>
130
+ send("PATCH", `/api/sessions/${id}/pause`, {
131
+ until: until?.toISOString() ?? null,
132
+ }),
133
+ ({ until }) => ({ paused_until: until ? toSqliteTime(until) : null })
134
+ );
135
+ }
@@ -1,6 +1,7 @@
1
1
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
2
  import type { TaskView } from "@/lib/tasks";
3
3
  import type { QueuedTaskView } from "@/lib/tasks/queue";
4
+ import type { Priority } from "@/lib/tasks/schedule-types";
4
5
  import { sessionKeys } from "../sessions/keys";
5
6
  import { taskKeys } from "./keys";
6
7
  import { usePollWhenOffline } from "../push/connection";
@@ -43,6 +44,7 @@ export function useCreateTask() {
43
44
  projectId: string;
44
45
  prompt: string;
45
46
  model?: string;
47
+ priority?: Priority;
46
48
  baseBranch?: string;
47
49
  hostId?: string;
48
50
  view?: "chat" | "terminal";
@@ -1,6 +1,11 @@
1
1
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import type { QueuedTaskView, StartOutcome } from "@/lib/tasks/queue";
3
- import { queueRequest, type QueueMenuAction } from "@/lib/sidebar/queued";
3
+ import {
4
+ priorityRequest,
5
+ queueRequest,
6
+ type QueueMenuAction,
7
+ } from "@/lib/sidebar/queued";
8
+ import type { Priority } from "@/lib/tasks/schedule-types";
4
9
  import { sessionKeys } from "../sessions/keys";
5
10
  import { taskKeys } from "./keys";
6
11
  import { usePollWhenOffline } from "../push/connection";
@@ -40,3 +45,35 @@ export function useQueueAction() {
40
45
  },
41
46
  });
42
47
  }
48
+
49
+ // Changes where a waiting task stands; the line re-ranks from the push.
50
+ export function useSetPriority() {
51
+ const queryClient = useQueryClient();
52
+ return useMutation({
53
+ mutationFn: async ({
54
+ id,
55
+ priority,
56
+ }: {
57
+ id: string;
58
+ priority: Priority;
59
+ }) => {
60
+ const { url, init } = priorityRequest(id, priority);
61
+ return json<unknown>(await fetch(url, init));
62
+ },
63
+ onMutate: async ({ id, priority }) => {
64
+ await queryClient.cancelQueries({ queryKey: taskKeys.queue() });
65
+ const previous = queryClient.getQueryData<QueuedTaskView[]>(
66
+ taskKeys.queue()
67
+ );
68
+ queryClient.setQueryData<QueuedTaskView[]>(taskKeys.queue(), (old) =>
69
+ old?.map((q) => (q.id === id ? { ...q, priority } : q))
70
+ );
71
+ return { previous };
72
+ },
73
+ onError: (_e, _v, context) => {
74
+ if (context?.previous)
75
+ queryClient.setQueryData(taskKeys.queue(), context.previous);
76
+ },
77
+ onSettled: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }),
78
+ });
79
+ }
package/lib/bus/index.ts CHANGED
@@ -15,6 +15,7 @@ import { deliverToPane, type Delivery } from "./delivery";
15
15
  import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
17
  import { hostLink } from "../hosts/remote-api";
18
+ import { pauseHoldOf, pausedNote } from "../tasks/pause-hold";
18
19
  import { relayToHeldTask } from "../tasks/held-remote";
19
20
  import { heldOrchestrator, heldTarget } from "./held-reply";
20
21
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
@@ -215,6 +216,16 @@ export async function deliver(
215
216
  return { state: "failed", why };
216
217
  }
217
218
  }
219
+ // A paused terminal has no agent: the message waits for the resume prompt.
220
+ const paused = pauseHoldOf(to.id);
221
+ if (paused) {
222
+ if (from.messageId)
223
+ db.prepare(`UPDATE bus_messages SET held_pause_id = ? WHERE id = ?`).run(
224
+ paused.pauseId,
225
+ from.messageId
226
+ );
227
+ return { state: "queued" };
228
+ }
218
229
  if (!statusDetector.sessionExists(to.tmux_name))
219
230
  return { state: "failed", why: "its terminal isn't running" };
220
231
  try {
@@ -298,7 +309,10 @@ export async function sendMessage(opts: {
298
309
  : opts.origin,
299
310
  }
300
311
  );
301
- if (delivery.state !== "failed") {
312
+ // A message held for a paused terminal isn't delivered until it resumes.
313
+ const hold = pauseHoldOf(to.id);
314
+ const parked = !!hold && to.view !== "chat";
315
+ if (delivery.state !== "failed" && !parked) {
302
316
  db.prepare(
303
317
  `UPDATE bus_messages SET delivered_at = datetime('now') WHERE id = ?`
304
318
  ).run(id);
@@ -308,7 +322,12 @@ export async function sendMessage(opts: {
308
322
  `UPDATE bus_messages SET read_at = datetime('now') WHERE id = ?`
309
323
  ).run(id);
310
324
  const message = toView(db.prepare(`${SELECT} WHERE m.id = ?`).get(id) as Row);
311
- return { message, delivery, note };
325
+ const heldNote = hold ? pausedNote(to.name, hold) : null;
326
+ return {
327
+ message,
328
+ delivery,
329
+ note: [note, heldNote].filter(Boolean).join("; ") || undefined,
330
+ };
312
331
  }
313
332
 
314
333
  function storeForHolder(
@@ -0,0 +1,24 @@
1
+ // What each chat access level is called and does, for every client. Synced to
2
+ // the native app, so it may only import synced files.
3
+ import type { ChatAccess } from "./events";
4
+
5
+ export const ACCESS_LEVELS: Record<
6
+ ChatAccess,
7
+ { label: string; description: string }
8
+ > = {
9
+ ask: {
10
+ label: "Ask first",
11
+ description: "Asks before edits and commands it isn't already allowed",
12
+ },
13
+ edits: {
14
+ label: "Accept edits",
15
+ description: "Edits files on its own, asks before other commands",
16
+ },
17
+ full: {
18
+ label: "Full access",
19
+ description: "Does everything without asking",
20
+ },
21
+ };
22
+
23
+ // The access a new chat starts with when the client doesn't pick one.
24
+ export const DEFAULT_NEW_CHAT_ACCESS: ChatAccess = "edits";
@@ -359,6 +359,8 @@ export type ChatServerMessage =
359
359
  commands: ChatCommand[];
360
360
  models: ChatModel[];
361
361
  model: string;
362
+ // A readable name for `model` when it is not one of `models`.
363
+ modelLabel?: string;
362
364
  access: ChatAccess;
363
365
  // Plan mode: on, or null when the session can't use it.
364
366
  plan: boolean | null;
package/lib/chat/hold.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Holding a chat still while something outside it works on its task: a
3
3
  * move to another machine (its row is 'moving'), or a Land of the stack it
4
- * sits in (the stack is 'landing'). Both are facts in SQLite, so the worker
4
+ * sits in (the stack is 'landing'), or a timed pause (lib/tasks/pause.ts).
5
+ * All are facts in SQLite, so the worker
5
6
  * process sees the hold too: it neither starts what's queued (queued.ts) nor
6
7
  * a turn for a message sent meanwhile, which waits in the queue instead.
7
8
  *
@@ -17,6 +18,8 @@ export const HELD_SQL = `(
17
18
  EXISTS (SELECT 1 FROM sessions WHERE id = @sid AND task_status = 'moving')
18
19
  OR EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
19
20
  WHERE i.session_id = @sid AND s.status = 'landing')
21
+ OR EXISTS (SELECT 1 FROM sessions WHERE id = @sid
22
+ AND pause_state IN ('pausing', 'paused'))
20
23
  )`;
21
24
 
22
25
  export const isHeld = (sessionId: string): boolean =>
@@ -45,12 +48,14 @@ interface HoldRow {
45
48
  moved_to: string | null;
46
49
  quick_of: string | null;
47
50
  landing: number;
51
+ pause_state: string | null;
52
+ paused_until: string | null;
48
53
  }
49
54
 
50
55
  const holdRow = (sessionId: string) =>
51
56
  db
52
57
  .prepare(
53
- `SELECT task_status, moved_to, quick_of,
58
+ `SELECT task_status, moved_to, quick_of, pause_state, paused_until,
54
59
  EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
55
60
  WHERE i.session_id = @sid AND s.status = 'landing') AS landing
56
61
  FROM sessions WHERE id = @sid`
@@ -63,6 +68,10 @@ export function chatHold(sessionId: string): string | null {
63
68
  if (row?.task_status === "moving" && collecting.has(sessionId))
64
69
  return "It's sent once the move to another machine finishes";
65
70
  if (row?.landing) return "It's sent once its stack has landed";
71
+ if (row?.pause_state === "pausing" || row?.pause_state === "paused")
72
+ return row.paused_until
73
+ ? "It's sent when the session resumes, with anything else held meanwhile"
74
+ : "It's sent when the session is resumed, with anything else held meanwhile";
66
75
  return null;
67
76
  }
68
77
 
@@ -7,7 +7,7 @@ import { effectiveChatAccess, noApprovalsOn } from "../approvals/no-approvals";
7
7
  import os from "os";
8
8
  import { db, type Session } from "../db";
9
9
  import { agentEnv } from "../agents/launch";
10
- import { resolveModelForAgent } from "../model-catalog";
10
+ import { modelDisplayName, resolveModelForAgent } from "../model-catalog";
11
11
  import { chatDriverFor } from "./drivers";
12
12
  import {
13
13
  CHAT_ACCESS,
@@ -69,11 +69,15 @@ function visibleCommands(caps: Capabilities): ChatCommand[] {
69
69
  export function emitCapabilities(session: Session, listener?: Listener): void {
70
70
  const caps = registry.caps.get(capsKey(session));
71
71
  if (!caps) return;
72
+ const model = chatModel(session);
72
73
  const m: ChatServerMessage = {
73
74
  type: "capabilities",
74
75
  commands: visibleCommands(caps),
75
76
  models: caps.models,
76
- model: chatModel(session),
77
+ model,
78
+ modelLabel:
79
+ caps.models.find((o) => o.value === model)?.label ??
80
+ modelDisplayName(model),
77
81
  access: effectiveChatAccess(session.chat_access),
78
82
  plan: canPlan(session) ? !!session.chat_plan && !noApprovalsOn() : null,
79
83
  background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
@@ -0,0 +1,29 @@
1
+ // Read-only config a client renders with. Additive: a new field never bumps
2
+ // `v`; only a change that breaks an old reader does, and then the old field
3
+ // keeps being served beside the new one for a release. Synced to the native
4
+ // app, so it must stay pure: no imports.
5
+ export const CLIENT_CONFIG_VERSION = 1;
6
+
7
+ export type Tone = "muted" | "warning" | "primary" | "destructive" | "success";
8
+
9
+ export interface LabelTone {
10
+ label: string;
11
+ tone: Tone;
12
+ }
13
+
14
+ export interface AccessLevel {
15
+ value: string;
16
+ label: string;
17
+ description: string;
18
+ }
19
+
20
+ // Keys are strings on purpose, so an older app can accept a state it doesn't
21
+ // know yet; each client narrows them through its own typed lookups.
22
+ export interface ClientConfig {
23
+ v: number;
24
+ server: { version: string | null; startedAt: string };
25
+ access: { levels: AccessLevel[]; default: string };
26
+ taskStates: Record<string, LabelTone>;
27
+ runStatuses: Record<string, LabelTone>;
28
+ runTriggers: Record<string, string>;
29
+ }
@@ -0,0 +1,28 @@
1
+ import { ACCESS_LEVELS, DEFAULT_NEW_CHAT_ACCESS } from "../chat/access-levels";
2
+ import { CHAT_ACCESS } from "../chat/events";
3
+ import {
4
+ RUN_STATUS_LABELS,
5
+ RUN_TRIGGER_LABELS,
6
+ } from "../recipes/status-labels";
7
+ import { TASK_STATE_LABELS } from "../tasks/state-labels";
8
+ import { cachedCheck } from "../updates/check";
9
+ import {
10
+ CLIENT_CONFIG_VERSION,
11
+ type ClientConfig,
12
+ } from "./client-config-types";
13
+
14
+ const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
15
+
16
+ export function clientConfig(): ClientConfig {
17
+ return {
18
+ v: CLIENT_CONFIG_VERSION,
19
+ server: { version: cachedCheck()?.current?.version ?? null, startedAt },
20
+ access: {
21
+ levels: CHAT_ACCESS.map((value) => ({ value, ...ACCESS_LEVELS[value] })),
22
+ default: DEFAULT_NEW_CHAT_ACCESS,
23
+ },
24
+ taskStates: TASK_STATE_LABELS,
25
+ runStatuses: RUN_STATUS_LABELS,
26
+ runTriggers: RUN_TRIGGER_LABELS,
27
+ };
28
+ }