@saadnvd1/agent-os 0.3.571 → 0.3.573

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 (54) hide show
  1. package/README.md +1 -1
  2. package/app/api/bus/spawn/route.ts +21 -3
  3. package/app/api/load/route.test.ts +32 -0
  4. package/app/api/load/route.ts +20 -0
  5. package/app/api/sessions/[id]/pause/route.ts +97 -0
  6. package/app/api/tasks/[id]/priority/route.ts +29 -0
  7. package/app/api/tasks/route.ts +17 -2
  8. package/components/Chat/ChatPanel.tsx +2 -0
  9. package/components/Chat/PausedBanner.tsx +54 -0
  10. package/components/Draft/DraftChips.tsx +25 -0
  11. package/components/Draft/DraftPanel.tsx +1 -0
  12. package/components/SessionList/QueuedRow.tsx +46 -2
  13. package/components/SessionList/RowSubtitle.tsx +21 -3
  14. package/components/SessionList/SessionRowMenu.tsx +49 -1
  15. package/components/SessionList/useShelfActions.ts +39 -2
  16. package/data/sessions/shelf.ts +50 -0
  17. package/data/tasks/index.ts +2 -0
  18. package/data/tasks/queue.ts +38 -1
  19. package/lib/bus/index.ts +21 -2
  20. package/lib/chat/hold.ts +11 -2
  21. package/lib/db/migrations.ts +56 -1
  22. package/lib/db/types.ts +30 -0
  23. package/lib/done/index.ts +2 -0
  24. package/lib/drafts.ts +3 -0
  25. package/lib/hosts/peer-load.ts +120 -0
  26. package/lib/load/machines.ts +124 -0
  27. package/lib/load/monitor.ts +17 -2
  28. package/lib/load/start-gate.ts +95 -0
  29. package/lib/orchestrator/act.ts +78 -0
  30. package/lib/orchestrator/brakes.ts +2 -0
  31. package/lib/orchestrator/brief.ts +4 -1
  32. package/lib/orchestrator/digest.ts +15 -4
  33. package/lib/orchestrator/serve.ts +18 -0
  34. package/lib/orchestrator/tool-names.ts +3 -0
  35. package/lib/orchestrator/tool-schemas.ts +33 -0
  36. package/lib/orchestrator/tools.ts +7 -1
  37. package/lib/project-config/agentos.schema.json +13 -0
  38. package/lib/project-config/schema.ts +9 -0
  39. package/lib/sidebar/paused.ts +13 -0
  40. package/lib/sidebar/queued.ts +34 -1
  41. package/lib/sidebar/shelves.ts +21 -1
  42. package/lib/sidebar/snooze.ts +26 -0
  43. package/lib/tasks/finish.ts +7 -0
  44. package/lib/tasks/move-carry.ts +1 -0
  45. package/lib/tasks/move-flow.ts +6 -0
  46. package/lib/tasks/pause-hold.ts +70 -0
  47. package/lib/tasks/pause-policy.ts +130 -0
  48. package/lib/tasks/pause.ts +718 -0
  49. package/lib/tasks/placement.ts +219 -0
  50. package/lib/tasks/queue.ts +522 -64
  51. package/lib/tasks/schedule-types.ts +64 -0
  52. package/lib/tasks/schedule.ts +271 -0
  53. package/package.json +1 -1
  54. 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,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);
@@ -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,
@@ -716,6 +717,7 @@ export function ChatPanel({
716
717
  setInsert({ text: quoteMarkdown(text), at: Date.now() })
717
718
  }
718
719
  />
720
+ <PausedBanner sessionId={sessionId} />
719
721
  <Queue
720
722
  queue={queue}
721
723
  running={running}
@@ -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
+ }
@@ -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,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)}>