@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
@@ -119,7 +119,7 @@ Reading:
119
119
  Acting:
120
120
  - \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
121
121
  - \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
122
- - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
122
+ - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
123
123
  - \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
124
124
  - \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
125
125
  - \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
@@ -134,6 +134,9 @@ Acting:
134
134
  - \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
135
135
  - \`${TOOL_NAMES.move}\` (task, machine): carry a running task to a linked machine (heavy test or build work), or back with machine "here". It keeps its branch, conversation and plan, and everything you do with a task works the same there: its events come to you, read, send, stop, done and drop reach it, and the gates judge its PR at its head. The machine it lands on holds it for you and can't merge it. It runs in the background; an event says when it lands. Refused while paused, and for a task waiting on Saad.
136
136
  - \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
137
+ - \`${TOOL_NAMES.set_priority}\` (task, priority): re-rank a queued task (urgent, blocking, normal, background), or tag a running one. Urgent is a bug Saad hit; background starts last.
138
+ - \`${TOOL_NAMES.pause_task}\` (task, for, now?, reason): pause a running task for 1h, 2h, until tomorrow 9 AM or until resumed. Its agent ends at the end of its step (now interrupts the step); its worktree and conversation stay and its slot frees for the next queued task. It resumes by itself, ahead of new arrivals, with what was held (CI and review notes, messages) in one prompt. Use it to make room for urgent work or to wait out a loaded machine. Refused for a task that is merging, moving, setting up or waiting on Saad.
139
+ - \`${TOOL_NAMES.resume_task}\` (task): resume a pause you set now. A pause Saad set is his to lift.
137
140
  - \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
138
141
  - \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
139
142
  - \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
@@ -56,6 +56,9 @@ interface TaskRow {
56
56
  pr_status: string | null;
57
57
  view: string;
58
58
  armed_at: string | null;
59
+ pause_state: string | null;
60
+ paused_until: string | null;
61
+ placed_note: string | null;
59
62
  }
60
63
 
61
64
  export interface CheckRow {
@@ -162,7 +165,7 @@ function tasksSection(
162
165
  const tasks = db
163
166
  .prepare(
164
167
  `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_status, s.view,
165
- s.merge_armed_at AS armed_at
168
+ s.merge_armed_at AS armed_at, s.pause_state, s.paused_until, s.placed_note
166
169
  FROM sessions s JOIN projects p ON p.id = s.project_id
167
170
  WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
168
171
  AND s.task_status = 'running'
@@ -177,7 +180,11 @@ function tasksSection(
177
180
  : "no PR yet";
178
181
  const gates = gateState(checks.get(t.id), failures.get(t.id));
179
182
  const arm = t.armed_at ? " | armed to merge" : "";
180
- return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${gates ? ` | ${gates}` : ""}`;
183
+ const paused = t.pause_state
184
+ ? ` | ${t.pause_state}${t.paused_until ? ` until ${t.paused_until} UTC` : t.pause_state === "paused" ? " until resumed" : ""}`
185
+ : "";
186
+ const placed = t.placed_note ? ` | ${t.placed_note}` : "";
187
+ return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${paused}${placed}${gates ? ` | ${gates}` : ""}`;
181
188
  }),
182
189
  empty: "- None.",
183
190
  more: "the sessions tool",
@@ -210,7 +217,7 @@ function externalSection(
210
217
  function queueSection(workspaceId: string): Section {
211
218
  const rows = db
212
219
  .prepare(
213
- `SELECT q.id, q.name, q.prompt, q.status, q.after_task, q.after_any,
220
+ `SELECT q.id, q.name, q.prompt, q.status, q.after_task, q.after_any, q.priority, q.note,
214
221
  p.name AS project, a.name AS after_name
215
222
  FROM task_queue q JOIN projects p ON p.id = q.project_id
216
223
  LEFT JOIN sessions a ON a.id = q.after_task
@@ -226,6 +233,8 @@ function queueSection(workspaceId: string): Section {
226
233
  after_any: string | null;
227
234
  project: string;
228
235
  after_name: string | null;
236
+ priority: string;
237
+ note: string | null;
229
238
  }[];
230
239
  return {
231
240
  title: "Queued tasks, in line order",
@@ -236,7 +245,9 @@ function queueSection(workspaceId: string): Section {
236
245
  ? " | after any running task"
237
246
  : "";
238
247
  const status = r.status === "queued" ? "" : ` | ${r.status}`;
239
- return `- ${i + 1}. ${clip(r.name ?? r.prompt, 80)} (${r.project}, id ${shortId(r.id)})${after}${status}`;
248
+ const priority = r.priority === "normal" ? "" : ` | ${r.priority}`;
249
+ const why = r.note ? ` | ${clip(r.note, 100)}` : "";
250
+ return `- ${i + 1}. ${clip(r.name ?? r.prompt, 80)} (${r.project}, id ${shortId(r.id)})${after}${priority}${status}${why}`;
240
251
  }),
241
252
  more: "the sessions tool",
242
253
  };
@@ -12,6 +12,9 @@ import {
12
12
  stackStatus,
13
13
  startSession,
14
14
  startTask,
15
+ setTaskPriority,
16
+ pauseTask,
17
+ resumeTask,
15
18
  stop,
16
19
  } from "./act";
17
20
  import { readCards } from "./cards";
@@ -63,6 +66,9 @@ const ACTING = new Set<ToolName>([
63
66
  "move",
64
67
  "approve_plan",
65
68
  "plan_pieces",
69
+ "set_priority",
70
+ "pause_task",
71
+ "resume_task",
66
72
  "stop",
67
73
  "done",
68
74
  "review",
@@ -131,8 +137,20 @@ export async function runTool(
131
137
  plan: a.plan,
132
138
  planOnly: a.plan_only,
133
139
  planReason: a.plan_reason,
140
+ priority: a.priority,
141
+ machine: a.machine,
134
142
  });
135
143
  }
144
+ case "set_priority": {
145
+ const a = p(tool, raw);
146
+ return setTaskPriority(w, a.task, a.priority);
147
+ }
148
+ case "pause_task": {
149
+ const a = p(tool, raw);
150
+ return pauseTask(w, a.task, a.for, a.now ?? false, a.reason);
151
+ }
152
+ case "resume_task":
153
+ return resumeTask(w, p(tool, raw).task);
136
154
  case "approve_plan": {
137
155
  const a = p(tool, raw);
138
156
  const task = workspaceTask(w, a.task);
@@ -29,6 +29,9 @@ export const TOOL_NAMES = {
29
29
  plan_pieces: name("plan_pieces"),
30
30
  drop: name("drop"),
31
31
  move: name("move"),
32
+ set_priority: name("set_priority"),
33
+ pause_task: name("pause_task"),
34
+ resume_task: name("resume_task"),
32
35
  stop: name("stop"),
33
36
  done: name("done"),
34
37
  archive: name("archive"),
@@ -7,6 +7,7 @@ import { RAISED_KINDS } from "./asks";
7
7
  import { MAX_RULE_CHARS } from "./rules";
8
8
  import { PROJECT_NAME } from "../project-add/name";
9
9
  import { TASK_ROLES, type ModelRole } from "../models/roles";
10
+ import { PRIORITIES } from "../tasks/schedule-types";
10
11
 
11
12
  const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
12
13
  const session = ref("Session name, project/name, or id from sessions");
@@ -115,6 +116,18 @@ export const TOOL_SHAPES = {
115
116
  .describe(
116
117
  'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first'
117
118
  ),
119
+ priority: z
120
+ .enum(PRIORITIES)
121
+ .optional()
122
+ .describe(
123
+ "Where it stands in the queue: urgent (a bug Saad hit), blocking (something waits on it), normal (the default), background"
124
+ ),
125
+ machine: z
126
+ .enum(["mac", "any"])
127
+ .optional()
128
+ .describe(
129
+ 'Where it may run: "mac" for work that needs this Mac (Xcode, a simulator); "any" if no machine need applies. Unset: the project\'s setting, else decided from its files'
130
+ ),
118
131
  model: modelOverride,
119
132
  plan: z
120
133
  .boolean()
@@ -233,6 +246,26 @@ export const TOOL_SHAPES = {
233
246
  'A linked machine\'s name (see Machines), or "here" to bring the task back'
234
247
  ),
235
248
  },
249
+ set_priority: {
250
+ task: ref("A queued or running task: name, project/name or id"),
251
+ priority: z.enum(PRIORITIES),
252
+ },
253
+ pause_task: {
254
+ task,
255
+ for: z
256
+ .enum(["1h", "2h", "tomorrow", "until_resumed"])
257
+ .describe(
258
+ "How long: 1h, 2h, tomorrow (9 AM, Saad's time) or until_resumed (until you or Saad resume it)"
259
+ ),
260
+ now: z
261
+ .boolean()
262
+ .optional()
263
+ .describe(
264
+ "Interrupt its step now. Default: let the step finish, then pause"
265
+ ),
266
+ reason: z.string().trim().min(1).max(300),
267
+ },
268
+ resume_task: { task },
236
269
  stop: { session },
237
270
  done: { session },
238
271
  archive: { session },
@@ -62,7 +62,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
62
62
  message_orchestrator:
63
63
  "Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
64
64
  start_task:
65
- "Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.",
65
+ 'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.',
66
66
  set_task_model:
67
67
  'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
68
68
  start_session:
@@ -81,6 +81,12 @@ const DESCRIPTIONS: Record<ToolName, string> = {
81
81
  "A task whose plan split into pieces: each piece, its repo, what it waits on and its state. With merge or drop (position numbers) it edits the pieces still waiting: a merged piece's slice goes to the piece before it, a dropped one is left out and anything waiting on it is held. It can't add pieces or change reasons.",
82
82
  drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
83
83
  move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
84
+ set_priority:
85
+ "Change the priority of a queued task (it re-ranks the line at once) or a running one (it only matters if it is paused and waits to resume). urgent starts first even on a busy machine; background starts last.",
86
+ pause_task:
87
+ "Pause a running task for a set time: its agent ends at the end of its current step (now true interrupts the step), its worktree and conversation stay, and it frees its slot so the next queued task can start. It resumes by itself at the time, ahead of new arrivals of the same priority, with whatever was held (CI and review notes, messages) in one continue prompt. Refused for a task that is merging, moving, setting up or waiting on Saad. Give a reason; it is shown with the pause.",
88
+ resume_task:
89
+ "Resume a task you paused (or the scheduler did) now. A pause Saad set is his to lift: this refuses and says so.",
84
90
  stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
85
91
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it lands on the sidebar's Done shelf; it no longer archives (use archive for that). Not a rejection.",
86
92
  archive:
@@ -151,6 +151,19 @@
151
151
  "required": ["start"],
152
152
  "additionalProperties": false
153
153
  },
154
+ "machine": {
155
+ "type": "object",
156
+ "properties": {
157
+ "needs": {
158
+ "type": "array",
159
+ "items": {
160
+ "type": "string",
161
+ "enum": ["mac", "xcode", "simulator"]
162
+ }
163
+ }
164
+ },
165
+ "additionalProperties": false
166
+ },
154
167
  "database": {
155
168
  "type": "object",
156
169
  "properties": {
@@ -69,6 +69,14 @@ const simulator = z.strictObject({
69
69
  device: z.string().min(1).optional(),
70
70
  });
71
71
 
72
+ // What a project's tasks need from a machine (lib/tasks/placement.ts): `mac`,
73
+ // `xcode`, `simulator`. A task whose needs a machine can't meet never starts
74
+ // there; with none listed, repo files decide (an Xcode project implies all).
75
+ export const MACHINE_NEEDS = ["mac", "xcode", "simulator"] as const;
76
+ const machine = z.strictObject({
77
+ needs: z.array(z.enum(MACHINE_NEEDS)).optional(),
78
+ });
79
+
72
80
  const database = z.strictObject({
73
81
  from: z.string().min(1),
74
82
  env: projectEnvName.optional(),
@@ -119,6 +127,7 @@ const shape = {
119
127
  notes: notes.optional(),
120
128
  browse: browse.optional(),
121
129
  simulator: simulator.optional(),
130
+ machine: machine.optional(),
122
131
  database: database.optional(),
123
132
  mcp: z.array(z.string().min(1)).optional(),
124
133
  model: z.string().min(1).optional(),
@@ -0,0 +1,27 @@
1
+ // How a recipe run's status and trigger read, for every client. Synced to the
2
+ // native app, so it may only import synced files.
3
+ import type { Tone } from "../config/client-config-types";
4
+ import type { RunStatus, RunTriggerKind } from "./types";
5
+
6
+ export const RUN_STATUS_LABELS: Record<
7
+ RunStatus,
8
+ { label: string; tone: Tone }
9
+ > = {
10
+ queued: { label: "Queued", tone: "muted" },
11
+ starting: { label: "Starting", tone: "primary" },
12
+ running: { label: "Running", tone: "primary" },
13
+ succeeded: { label: "Succeeded", tone: "success" },
14
+ failed: { label: "Failed", tone: "destructive" },
15
+ blocked: { label: "Needs you", tone: "warning" },
16
+ timed_out: { label: "Timed out", tone: "destructive" },
17
+ cancelled: { label: "Cancelled", tone: "muted" },
18
+ skipped: { label: "Skipped", tone: "warning" },
19
+ };
20
+
21
+ export const RUN_TRIGGER_LABELS: Record<RunTriggerKind, string> = {
22
+ manual: "by hand",
23
+ tool: "by the orchestrator",
24
+ cli: "by an agent",
25
+ timer: "on its timer",
26
+ "catch-up": "on its timer, late",
27
+ };
@@ -0,0 +1,13 @@
1
+ import { wakeLabel } from "./snooze";
2
+
3
+ // What a paused row says under its title.
4
+ export function pauseSubtitle(
5
+ paused: { state: "pausing" | "paused" | "resuming"; until: number | null },
6
+ now: number = Date.now(),
7
+ timeZone?: string
8
+ ): string {
9
+ if (paused.state === "pausing") return "Pausing · finishing its step";
10
+ if (paused.state === "resuming") return "Resuming · waiting for a slot";
11
+ if (paused.until === null) return "Paused until you resume";
12
+ return `Paused · resumes ${wakeLabel(paused.until, now, timeZone)}`;
13
+ }
@@ -1,4 +1,9 @@
1
1
  import type { QueuedTaskView } from "@/lib/tasks/queue";
2
+ import {
3
+ PRIORITIES,
4
+ PRIORITY_LABEL,
5
+ type Priority,
6
+ } from "@/lib/tasks/schedule-types";
2
7
 
3
8
  export interface QueuedRow {
4
9
  item: QueuedTaskView;
@@ -71,7 +76,35 @@ export function queuedSubtitle(item: QueuedTaskView): {
71
76
  // Why a start it tried is held: the orchestrator paused, a brake.
72
77
  if (item.note) return { lead: item.note, after: null };
73
78
  if (item.after) return { lead: "After", after: item.after };
74
- return { lead: `#${item.position} in line`, after: null };
79
+ const priority =
80
+ item.priority === "normal" ? "" : ` · ${PRIORITY_LABEL[item.priority]}`;
81
+ return { lead: `#${item.rank} of ${item.of}${priority}`, after: null };
82
+ }
83
+
84
+ // The priority choices for a waiting task's menu, the current one marked.
85
+ export function priorityMenu(
86
+ item: Pick<QueuedTaskView, "priority" | "status">
87
+ ): { priority: Priority; label: string; current: boolean }[] {
88
+ if (item.status !== "queued") return [];
89
+ return PRIORITIES.map((priority) => ({
90
+ priority,
91
+ label: PRIORITY_LABEL[priority],
92
+ current: priority === item.priority,
93
+ }));
94
+ }
95
+
96
+ export function priorityRequest(
97
+ id: string,
98
+ priority: Priority
99
+ ): { url: string; init: RequestInit } {
100
+ return {
101
+ url: `/api/tasks/${encodeURIComponent(id)}/priority`,
102
+ init: {
103
+ method: "PATCH",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ priority }),
106
+ },
107
+ };
75
108
  }
76
109
 
77
110
  export type QueueMenuAction = "start" | "up" | "down" | "remove";
@@ -67,6 +67,10 @@ export interface SidebarRow {
67
67
  wakesAt?: number;
68
68
  // A plain session in Done: marked by hand, or settled by itself.
69
69
  settled?: "manual" | "auto";
70
+ // Its agent is being stopped, is stopped, or is starting again
71
+ // (lib/tasks/pause): until is when it resumes by itself (ms), null: when
72
+ // someone resumes it.
73
+ paused?: { state: "pausing" | "paused" | "resuming"; until: number | null };
70
74
  }
71
75
 
72
76
  export interface Shelves {
@@ -234,6 +238,7 @@ export function recipeRunOpen(session: Pick<Session, "recipe_run">): boolean {
234
238
  function toRow(input: ShelfInput, session: Session, now: number): SidebarRow {
235
239
  const status = input.statuses[session.id];
236
240
  const wakesAt = snoozedUntil(session, now);
241
+ const pauseState = session.pause_state;
237
242
  return {
238
243
  session,
239
244
  status,
@@ -241,6 +246,12 @@ function toRow(input: ShelfInput, session: Session, now: number): SidebarRow {
241
246
  unread: !!status?.unread || wokeUnread(session, now),
242
247
  activeAt: activeMs(session, now),
243
248
  ...(wakesAt && { wakesAt }),
249
+ ...(pauseState && {
250
+ paused: {
251
+ state: pauseState,
252
+ until: session.paused_until ? ms(session.paused_until) : null,
253
+ },
254
+ }),
244
255
  working:
245
256
  status?.status === "running" ||
246
257
  (status?.background ?? 0) > 0 ||
@@ -305,6 +316,8 @@ export function buildShelves(input: ShelfInput): Shelves {
305
316
  if (pinned && current) shelves.orchestrators.push(row);
306
317
  else if (pinned && role !== "orchestrator") shelves.pinned.push(row);
307
318
  else if (row.need) shelves.needsYou.push(row);
319
+ // A stopped agent is not working, whatever its last status said.
320
+ else if (row.paused?.state === "paused") shelves.snoozed.push(row);
308
321
  else if (row.working) shelves.working.push(row);
309
322
  else if (row.wakesAt) shelves.snoozed.push(row);
310
323
  else if (row.task || row.session.task_status)
@@ -323,7 +336,12 @@ export function buildShelves(input: ShelfInput): Shelves {
323
336
  shelves.working.sort(newestFirst);
324
337
  shelves.inReview.sort(newestFirst);
325
338
  shelves.ongoing.sort((a, b) => b.activeAt - a.activeAt);
326
- shelves.snoozed.sort((a, b) => a.wakesAt! - b.wakesAt!);
339
+ // By when each comes back; a pause with no end goes last.
340
+ const comesBack = (r: SidebarRow) =>
341
+ r.paused?.state === "paused"
342
+ ? (r.paused.until ?? Number.POSITIVE_INFINITY)
343
+ : r.wakesAt!;
344
+ shelves.snoozed.sort((a, b) => comesBack(a) - comesBack(b));
327
345
  shelves.done.sort((a, b) => doneAtMs(b) - doneAtMs(a));
328
346
  return shelves;
329
347
  }
@@ -390,6 +408,8 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
390
408
  a.task === b.task &&
391
409
  a.activeAt === b.activeAt &&
392
410
  a.wakesAt === b.wakesAt &&
411
+ a.paused?.state === b.paused?.state &&
412
+ a.paused?.until === b.paused?.until &&
393
413
  a.settled === b.settled &&
394
414
  a.workers.length === b.workers.length &&
395
415
  a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
@@ -100,3 +100,29 @@ export function wakeLabel(
100
100
  return `${date.toLocaleDateString("en-US", { timeZone, weekday: "short" })} ${time}`;
101
101
  return `${date.toLocaleDateString("en-US", { timeZone, month: "short", day: "numeric" })}, ${time}`;
102
102
  }
103
+
104
+ // How long a paused session stays paused: a set time, or until someone
105
+ // resumes it. "Tomorrow" is the same 9am as Snooze's.
106
+ export type PausePreset = "1h" | "2h" | "tomorrow" | "until-resumed";
107
+
108
+ export const PAUSE_PRESETS: { key: PausePreset; label: string }[] = [
109
+ { key: "1h", label: "For 1 hour" },
110
+ { key: "2h", label: "For 2 hours" },
111
+ { key: "tomorrow", label: "Until tomorrow 9 AM" },
112
+ { key: "until-resumed", label: "Until I resume" },
113
+ ];
114
+
115
+ // The instant a pause ends; null: until resumed.
116
+ export function pauseUntil(
117
+ preset: PausePreset,
118
+ now: Date = new Date(),
119
+ timeZone: string = localZone()
120
+ ): Date | null {
121
+ if (preset === "1h") return new Date(now.getTime() + 3_600_000);
122
+ if (preset === "2h") return new Date(now.getTime() + 7_200_000);
123
+ if (preset === "tomorrow") return snoozePreset("tomorrow", now, timeZone);
124
+ return null;
125
+ }
126
+
127
+ export const isPausePreset = (v: unknown): v is PausePreset =>
128
+ PAUSE_PRESETS.some((p) => p.key === v);
@@ -1,4 +1,5 @@
1
1
  import { db, type Session } from "../db";
2
+ import { endPauseOnFinish } from "./pause-hold";
2
3
  import { isMirror, remoteTaskAction } from "./remote";
3
4
  import { deleteWorktree } from "../worktrees";
4
5
  import { settleWorktree, type MergedAs } from "../done/worktree";
@@ -154,6 +155,7 @@ export async function signOffTask(
154
155
  db.prepare(
155
156
  `UPDATE sessions SET task_status = 'merged', pr_status = 'merged' WHERE id = ?`
156
157
  ).run(id);
158
+ endPauseOnFinish(id);
157
159
  db.prepare(
158
160
  `UPDATE stack_items SET status = 'merged', error = NULL WHERE session_id = ?`
159
161
  ).run(id);
@@ -182,6 +184,10 @@ export async function signOffTask(
182
184
  return policy.method;
183
185
  }
184
186
 
187
+ // Between `gh pr merge` and the end of the tidying after it.
188
+ export const isMerging = (id: string): boolean =>
189
+ signingOff.has(id) || afterMerge.has(id);
190
+
185
191
  export function mergeSettled(id: string): Promise<void> {
186
192
  return afterMerge.get(id) ?? Promise.resolve();
187
193
  }
@@ -212,6 +218,7 @@ export async function dropTask(id: string): Promise<void> {
212
218
  db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
213
219
  id
214
220
  );
221
+ endPauseOnFinish(id);
215
222
  db.prepare(
216
223
  `UPDATE stack_items SET status = 'dropped' WHERE session_id = ?`
217
224
  ).run(id);
@@ -31,6 +31,7 @@ export const REKEYED = [
31
31
  export const KEPT = [
32
32
  ["orchestrator_event_log", "subject", "history is never rewritten"],
33
33
  ["stack_items", "session_id", "stacked tasks don't move"],
34
+ ["task_pauses", "session_id", "a paused task can't be moved"],
34
35
  [
35
36
  "task_queue",
36
37
  "after_any",
@@ -42,6 +42,12 @@ export function assertMovable(id: string, toHostId: string): Session {
42
42
  const session = getTaskSession(id);
43
43
  if (!LIVE.has(session.task_status ?? ""))
44
44
  throw new Error(`Task is already ${session.task_status}`);
45
+ if (session.pause_state)
46
+ throw new Error(
47
+ session.pause_state === "paused"
48
+ ? "It's paused: resume it first"
49
+ : `It's ${session.pause_state}: let that finish first`
50
+ );
45
51
  const fromRemote = isRemoteHost(session.host_id);
46
52
  const toRemote = isRemoteHost(toHostId);
47
53
  if (fromRemote === toRemote) {
@@ -0,0 +1,70 @@
1
+ // Whether a session is paused, from the database alone, so the message bus
2
+ // can hold a message for it without loading the pause machinery.
3
+
4
+ import { db, type TaskPause } from "../db";
5
+
6
+ export interface PauseHold {
7
+ pauseId: number;
8
+ until: string | null;
9
+ }
10
+
11
+ export function pauseHoldOf(sessionId: string): PauseHold | null {
12
+ const row = db
13
+ .prepare(
14
+ `SELECT pause_id, paused_until FROM sessions
15
+ WHERE id = ? AND pause_state IN ('pausing', 'paused') AND pause_id IS NOT NULL`
16
+ )
17
+ .get(sessionId) as
18
+ | { pause_id: number; paused_until: string | null }
19
+ | undefined;
20
+ return row ? { pauseId: row.pause_id, until: row.paused_until } : null;
21
+ }
22
+
23
+ // A stored time (UTC, no zone) as this server's clock reads it.
24
+ export function formatUntil(stored: string): string {
25
+ const t = Date.parse(`${stored.replace(" ", "T")}Z`);
26
+ return new Date(t).toLocaleString("en-US", {
27
+ month: "short",
28
+ day: "numeric",
29
+ hour: "numeric",
30
+ minute: "2-digit",
31
+ });
32
+ }
33
+
34
+ // What a sender is told when their message is held.
35
+ export function pausedNote(name: string, hold: PauseHold): string {
36
+ return `${name} is paused${hold.until ? ` until ${formatUntil(hold.until)}` : ""}; it gets this when it resumes`;
37
+ }
38
+
39
+ export function endPause(
40
+ row: Pick<TaskPause, "id" | "session_id">,
41
+ reason: "resumed" | "cancelled" | "finished" | "failed"
42
+ ): void {
43
+ db.transaction(() => {
44
+ db.prepare(
45
+ `UPDATE task_pauses SET state = 'ended', ended_at = datetime('now'), end_reason = ?
46
+ WHERE id = ? AND state != 'ended'`
47
+ ).run(reason, row.id);
48
+ db.prepare(
49
+ `UPDATE sessions SET pause_state = NULL, paused_until = NULL, pause_id = NULL
50
+ WHERE id = ? AND pause_id = ?`
51
+ ).run(row.session_id, row.id);
52
+ })();
53
+ }
54
+
55
+ // A task that merged, was dropped or was marked done is no longer paused.
56
+ // What was held stays as history, read.
57
+ export function endPauseOnFinish(sessionId: string): void {
58
+ const row = db
59
+ .prepare(
60
+ `SELECT id, session_id FROM task_pauses WHERE session_id = ? AND state != 'ended'
61
+ ORDER BY id DESC LIMIT 1`
62
+ )
63
+ .get(sessionId) as { id: number; session_id: string } | undefined;
64
+ if (!row) return;
65
+ endPause(row, "finished");
66
+ db.prepare(
67
+ `UPDATE bus_messages SET read_at = COALESCE(read_at, datetime('now'))
68
+ WHERE held_pause_id = ?`
69
+ ).run(row.id);
70
+ }