@saadnvd1/agent-os 0.3.698 → 0.3.715

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 (226) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/send/route.ts +11 -1
  4. package/app/api/bus/spawn/route.ts +12 -1
  5. package/app/api/bus/wait/route.ts +22 -0
  6. package/app/api/claims/[id]/route.ts +20 -0
  7. package/app/api/claims/check/route.ts +23 -0
  8. package/app/api/claims/check.test.ts +113 -0
  9. package/app/api/claims/exchange/route.ts +42 -0
  10. package/app/api/claims/outside.test.ts +90 -0
  11. package/app/api/claims/route.test.ts +193 -0
  12. package/app/api/claims/route.ts +73 -0
  13. package/app/api/hosts/[id]/route.ts +9 -1
  14. package/app/api/peer/hello/reveal/route.ts +6 -0
  15. package/app/api/peer/hello/route.ts +9 -0
  16. package/app/api/peer/hello/status/route.ts +6 -0
  17. package/app/api/peer/unlink/route.ts +17 -0
  18. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  19. package/app/api/peers/[hostId]/route.ts +20 -0
  20. package/app/api/peers/account/route.ts +9 -0
  21. package/app/api/peers/gate.test.ts +174 -0
  22. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  23. package/app/api/peers/link/[id]/route.ts +20 -0
  24. package/app/api/peers/link/route.ts +23 -0
  25. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  26. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  27. package/app/api/peers/pending/route.test.ts +37 -0
  28. package/app/api/peers/pending/route.ts +14 -0
  29. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  30. package/app/api/session-groups/[id]/route.ts +25 -0
  31. package/app/api/session-groups/order/route.ts +15 -0
  32. package/app/api/session-groups/route.ts +21 -0
  33. package/app/api/sessions/[id]/fork/route.ts +3 -0
  34. package/app/api/sessions/[id]/group/route.ts +18 -0
  35. package/app/api/sessions/[id]/route.ts +14 -4
  36. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  37. package/app/api/sessions/claims.test.ts +122 -0
  38. package/app/api/sessions/group/route.ts +17 -0
  39. package/app/api/sessions/route.ts +31 -13
  40. package/app/page.tsx +7 -0
  41. package/components/Claims/ClaimFiles.tsx +38 -0
  42. package/components/Claims/ClaimRow.tsx +82 -0
  43. package/components/Claims/ClaimsSheet.tsx +176 -0
  44. package/components/Claims/OverlapPill.tsx +31 -0
  45. package/components/Claims/StartClaimsNotice.tsx +70 -0
  46. package/components/Claims/claims-ui.test.ts +161 -0
  47. package/components/Claims/format.ts +57 -0
  48. package/components/Claims/index.ts +3 -0
  49. package/components/Draft/DraftPanel.tsx +40 -2
  50. package/components/Hosts/AccountMachines.tsx +194 -0
  51. package/components/Hosts/HostRow.tsx +195 -45
  52. package/components/Hosts/HostsDialog.tsx +49 -37
  53. package/components/Hosts/IncomingLinkSheet.tsx +246 -0
  54. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  55. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  56. package/components/Hosts/sheet.ts +3 -0
  57. package/components/SessionList/GroupShelves.tsx +195 -0
  58. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  59. package/components/SessionList/RowSubtitle.tsx +18 -2
  60. package/components/SessionList/SelectionToolbar.tsx +52 -1
  61. package/components/SessionList/SessionRow.tsx +1 -0
  62. package/components/SessionList/SessionRowMenu.tsx +17 -0
  63. package/components/SessionList/SessionShelves.tsx +5 -0
  64. package/components/SessionList/SidebarBody.tsx +6 -0
  65. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  66. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  67. package/components/SessionList/SidebarHeader.tsx +5 -0
  68. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  69. package/components/SessionList/index.tsx +18 -14
  70. package/components/SessionList/useSidebarData.ts +10 -0
  71. package/components/Welcome/ConnectBlock.tsx +1 -1
  72. package/components/ui/notice.tsx +46 -0
  73. package/components/ui/status-pill.tsx +39 -0
  74. package/data/claims/index.ts +2 -0
  75. package/data/claims/keys.ts +5 -0
  76. package/data/claims/queries.ts +47 -0
  77. package/data/peers/index.ts +2 -0
  78. package/data/peers/keys.ts +7 -0
  79. package/data/peers/queries.ts +172 -0
  80. package/data/push/topics.ts +4 -0
  81. package/data/session-groups/index.ts +156 -0
  82. package/data/sessions/index.ts +0 -1
  83. package/data/sessions/pin.ts +2 -6
  84. package/data/sessions/queries.ts +10 -29
  85. package/data/sessions/shelf.ts +2 -6
  86. package/data/tasks/index.ts +2 -0
  87. package/hooks/useSessions.ts +0 -12
  88. package/lib/__fixtures__/peer-rpc.ts +65 -33
  89. package/lib/agents/brief.ts +3 -1
  90. package/lib/agents/spawn.ts +4 -0
  91. package/lib/bus/format.ts +25 -4
  92. package/lib/bus/index.ts +121 -106
  93. package/lib/bus/resolve.ts +16 -2
  94. package/lib/bus/targets.ts +122 -0
  95. package/lib/bus/waits/kick.ts +12 -0
  96. package/lib/bus/waits/lines.ts +69 -0
  97. package/lib/bus/waits/register.ts +83 -0
  98. package/lib/bus/waits/store.ts +196 -0
  99. package/lib/bus/waits/tick.ts +192 -0
  100. package/lib/claims/collectLocal.ts +121 -0
  101. package/lib/claims/describe.ts +41 -0
  102. package/lib/claims/exchange.ts +91 -0
  103. package/lib/claims/goal.ts +45 -0
  104. package/lib/claims/index.ts +10 -0
  105. package/lib/claims/loop.ts +51 -0
  106. package/lib/claims/machine.ts +21 -0
  107. package/lib/claims/mine.ts +43 -0
  108. package/lib/claims/notifyOverlaps.ts +101 -0
  109. package/lib/claims/onMerged.ts +85 -0
  110. package/lib/claims/outside.ts +36 -0
  111. package/lib/claims/overlap.ts +87 -0
  112. package/lib/claims/overlapCache.ts +86 -0
  113. package/lib/claims/overlapText.ts +36 -0
  114. package/lib/claims/peers.ts +56 -0
  115. package/lib/claims/startCheck.ts +100 -0
  116. package/lib/claims/startWarning.ts +29 -0
  117. package/lib/claims/store.ts +366 -0
  118. package/lib/claims/types.ts +102 -0
  119. package/lib/claims/view.ts +80 -0
  120. package/lib/claims/yield.ts +24 -0
  121. package/lib/connect/control-api.ts +108 -2
  122. package/lib/connect/heartbeat.ts +11 -3
  123. package/lib/connect/serve.ts +2 -0
  124. package/lib/db/changes.ts +6 -0
  125. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  126. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  127. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  128. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  129. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  130. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  131. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  132. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  133. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  134. package/lib/db/queries.ts +0 -40
  135. package/lib/db/session-parts/columns.ts +1 -0
  136. package/lib/db/types.ts +20 -8
  137. package/lib/doctor/checks.ts +64 -1
  138. package/lib/done/archive.ts +2 -0
  139. package/lib/git-remote.ts +43 -0
  140. package/lib/hosts/index.ts +10 -1
  141. package/lib/orchestration.ts +2 -0
  142. package/lib/orchestrator/act.ts +54 -4
  143. package/lib/orchestrator/brief.ts +2 -1
  144. package/lib/orchestrator/claim-wait.ts +71 -0
  145. package/lib/orchestrator/conditions.ts +28 -1
  146. package/lib/orchestrator/describe.ts +19 -1
  147. package/lib/orchestrator/facts.ts +22 -0
  148. package/lib/orchestrator/serve.ts +37 -2
  149. package/lib/orchestrator/tool-names.ts +1 -0
  150. package/lib/orchestrator/tool-schemas.ts +31 -1
  151. package/lib/orchestrator/tools.ts +3 -1
  152. package/lib/orchestrator/watcher.ts +19 -1
  153. package/lib/peer/account.ts +270 -0
  154. package/lib/peer/allowlist.ts +1 -1
  155. package/lib/peer/cli.ts +24 -0
  156. package/lib/peer/client.ts +26 -1
  157. package/lib/peer/code.ts +3 -0
  158. package/lib/peer/hello-route.ts +32 -0
  159. package/lib/peer/hello.ts +346 -0
  160. package/lib/peer/link-cli.ts +248 -0
  161. package/lib/peer/links.ts +117 -5
  162. package/lib/peer/machines.ts +84 -0
  163. package/lib/peer/name.ts +32 -0
  164. package/lib/peer/notify.ts +32 -0
  165. package/lib/peer/pairing-out.ts +382 -0
  166. package/lib/peer/pairing.ts +432 -0
  167. package/lib/peer/pin.ts +62 -0
  168. package/lib/peer/reasons.ts +7 -0
  169. package/lib/peer/revoke.ts +75 -0
  170. package/lib/peer/rpc-intake.ts +37 -8
  171. package/lib/peer/rpc-server.ts +72 -6
  172. package/lib/peer/unlink.ts +62 -0
  173. package/lib/peer/unlinked-proof.ts +44 -0
  174. package/lib/projects.ts +5 -2
  175. package/lib/schedules/when.ts +1 -1
  176. package/lib/security/auth.ts +10 -2
  177. package/lib/security/demo.ts +7 -0
  178. package/lib/security/gate.ts +3 -0
  179. package/lib/session-groups/assignSessions.ts +81 -0
  180. package/lib/session-groups/cli.ts +61 -0
  181. package/lib/session-groups/createGroup.ts +28 -0
  182. package/lib/session-groups/index.ts +21 -0
  183. package/lib/session-groups/removeGroup.ts +36 -0
  184. package/lib/session-groups/reorderGroups.ts +31 -0
  185. package/lib/session-groups/resolveGroup.ts +19 -0
  186. package/lib/session-groups/route.ts +32 -0
  187. package/lib/session-groups/store.ts +85 -0
  188. package/lib/session-groups/types.ts +25 -0
  189. package/lib/session-groups/updateGroup.ts +35 -0
  190. package/lib/session-groups/workspace.ts +21 -0
  191. package/lib/sessions/end.ts +3 -0
  192. package/lib/sessions/launch.ts +5 -2
  193. package/lib/sidebar/rollup.ts +1 -0
  194. package/lib/sidebar/row-menu.ts +2 -0
  195. package/lib/sidebar/shelves.ts +90 -15
  196. package/lib/sidebar/visible-order.ts +5 -0
  197. package/lib/tasks/hold-checks.ts +3 -1
  198. package/lib/tasks/holds.ts +3 -0
  199. package/lib/tasks/index.ts +5 -0
  200. package/lib/tasks/queue-rows.ts +8 -1
  201. package/lib/tasks/queue-start.ts +1 -0
  202. package/lib/tasks/queue-types.ts +9 -1
  203. package/lib/tasks/queue.ts +15 -5
  204. package/lib/tasks/schedule-types.ts +1 -0
  205. package/lib/test-temp.ts +19 -3
  206. package/lib/workspaces.ts +11 -3
  207. package/lib/worktree-sweep/testTemp.ts +23 -106
  208. package/package.json +1 -1
  209. package/scripts/agent-os +3 -1
  210. package/scripts/autodeploy +2 -0
  211. package/scripts/autodeploy.test.ts +31 -1
  212. package/scripts/claim-hook.sh +31 -0
  213. package/scripts/claim-hook.test.ts +230 -0
  214. package/scripts/lib/cli-links.sh +36 -0
  215. package/scripts/lib/commands.sh +5 -0
  216. package/scripts/lib/machine.sh +27 -2
  217. package/scripts/lib/machine.test.ts +25 -3
  218. package/scripts/sync-cli-links +7 -0
  219. package/scripts/sync-cli-links.test.ts +69 -0
  220. package/server.ts +6 -0
  221. package/stores/claimsUi.ts +22 -0
  222. package/app/api/groups/[...path]/route.ts +0 -136
  223. package/app/api/groups/route.ts +0 -93
  224. package/data/groups/index.ts +0 -1
  225. package/data/groups/mutations.ts +0 -95
  226. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,32 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { z } from "zod";
3
+ import { GROUP_STATUS, GroupError } from "./types";
4
+
5
+ // Runs a group action for a route: a body that fails its schema is a 400, a
6
+ // GroupError becomes its status code.
7
+ export async function groupRoute<S extends z.ZodType>(
8
+ request: Request,
9
+ schema: S,
10
+ act: (body: z.infer<S>) => unknown
11
+ ): Promise<NextResponse> {
12
+ const parsed = schema.safeParse(await request.json().catch(() => null));
13
+ if (!parsed.success)
14
+ return NextResponse.json(
15
+ { error: parsed.error.issues[0]?.message ?? "Bad request" },
16
+ { status: 400 }
17
+ );
18
+ return run(() => act(parsed.data));
19
+ }
20
+
21
+ export function run(act: () => unknown): NextResponse {
22
+ try {
23
+ return NextResponse.json(act());
24
+ } catch (err) {
25
+ if (err instanceof GroupError)
26
+ return NextResponse.json(
27
+ { error: err.message, code: err.code },
28
+ { status: GROUP_STATUS[err.code] }
29
+ );
30
+ throw err;
31
+ }
32
+ }
@@ -0,0 +1,85 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { GroupError, NAME_MAX, type SessionGroup } from "./types";
3
+
4
+ interface GroupRow extends Omit<SessionGroup, "collapsed"> {
5
+ collapsed: number;
6
+ deleted_at: string | null;
7
+ }
8
+
9
+ const COLUMNS = `id, workspace_id, name, sort_order, collapsed, created_at,
10
+ updated_at, deleted_at`;
11
+
12
+ const toGroup = ({ deleted_at: _, ...row }: GroupRow): SessionGroup => ({
13
+ ...row,
14
+ collapsed: !!row.collapsed,
15
+ });
16
+
17
+ export function cleanName(name: unknown): string {
18
+ const trimmed =
19
+ typeof name === "string" ? name.trim().replace(/\s+/g, " ") : "";
20
+ if (!trimmed) throw new GroupError("invalid", "A group needs a name");
21
+ if (trimmed.length > NAME_MAX)
22
+ throw new GroupError(
23
+ "invalid",
24
+ `A group name is at most ${NAME_MAX} characters`
25
+ );
26
+ return trimmed;
27
+ }
28
+
29
+ // Live groups (not deleted), in the order to show them within a workspace.
30
+ export function listGroups(workspaceId?: string | null): SessionGroup[] {
31
+ const rows = (
32
+ workspaceId === undefined
33
+ ? getDb()
34
+ .prepare(
35
+ `SELECT ${COLUMNS} FROM session_groups WHERE deleted_at IS NULL
36
+ ORDER BY sort_order, created_at`
37
+ )
38
+ .all()
39
+ : getDb()
40
+ .prepare(
41
+ `SELECT ${COLUMNS} FROM session_groups WHERE deleted_at IS NULL
42
+ AND COALESCE(workspace_id, '') = COALESCE(?, '')
43
+ ORDER BY sort_order, created_at`
44
+ )
45
+ .all(workspaceId)
46
+ ) as GroupRow[];
47
+ return rows.map(toGroup);
48
+ }
49
+
50
+ // Live groups' names by id, for labelling.
51
+ export function groupNames(): Map<string, string> {
52
+ return new Map(listGroups().map((g) => [g.id, g.name]));
53
+ }
54
+
55
+ // Any group by id, deleted or not, with whether it is deleted.
56
+ export function findGroup(
57
+ id: string
58
+ ): (SessionGroup & { deleted: boolean }) | null {
59
+ const row = getDb()
60
+ .prepare(`SELECT ${COLUMNS} FROM session_groups WHERE id = ?`)
61
+ .get(id) as GroupRow | undefined;
62
+ return row ? { ...toGroup(row), deleted: !!row.deleted_at } : null;
63
+ }
64
+
65
+ // A live group by id, or the error a caller should surface.
66
+ export function liveGroup(id: unknown): SessionGroup {
67
+ const group = typeof id === "string" ? findGroup(id) : null;
68
+ if (!group) throw new GroupError("not-found", "Group not found");
69
+ if (group.deleted) throw new GroupError("deleted", "That group was deleted");
70
+ return group;
71
+ }
72
+
73
+ export function groupByName(
74
+ workspaceId: string | null,
75
+ name: string
76
+ ): SessionGroup | null {
77
+ const row = getDb()
78
+ .prepare(
79
+ `SELECT ${COLUMNS} FROM session_groups
80
+ WHERE deleted_at IS NULL AND lower(name) = lower(?)
81
+ AND COALESCE(workspace_id, '') = COALESCE(?, '')`
82
+ )
83
+ .get(name.trim(), workspaceId) as GroupRow | undefined;
84
+ return row ? toGroup(row) : null;
85
+ }
@@ -0,0 +1,25 @@
1
+ export type { SessionGroup } from "@/lib/sidebar/shelves";
2
+
3
+ export type GroupErrorCode =
4
+ "not-found" | "duplicate" | "workspace" | "invalid" | "deleted";
5
+
6
+ // Thrown for anything the caller got wrong; routes map the code to a status.
7
+ export class GroupError extends Error {
8
+ constructor(
9
+ readonly code: GroupErrorCode,
10
+ message: string
11
+ ) {
12
+ super(message);
13
+ this.name = "GroupError";
14
+ }
15
+ }
16
+
17
+ export const GROUP_STATUS: Record<GroupErrorCode, number> = {
18
+ "not-found": 404,
19
+ duplicate: 409,
20
+ workspace: 409,
21
+ invalid: 400,
22
+ deleted: 409,
23
+ };
24
+
25
+ export const NAME_MAX = 60;
@@ -0,0 +1,35 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { cleanName, groupByName, liveGroup } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Rename and fold. Folding is stored here so every device agrees.
6
+ export function updateGroup(
7
+ id: string,
8
+ patch: { name?: unknown; collapsed?: unknown }
9
+ ): SessionGroup {
10
+ const group = liveGroup(id);
11
+ const sets: string[] = [];
12
+ const args: (string | number)[] = [];
13
+ if (patch.name !== undefined) {
14
+ const clean = cleanName(patch.name);
15
+ const clash = groupByName(group.workspace_id, clean);
16
+ if (clash && clash.id !== id)
17
+ throw new GroupError("duplicate", `There is already a group "${clean}"`);
18
+ sets.push("name = ?");
19
+ args.push(clean);
20
+ }
21
+ if (patch.collapsed !== undefined) {
22
+ if (typeof patch.collapsed !== "boolean")
23
+ throw new GroupError("invalid", "collapsed must be a boolean");
24
+ sets.push("collapsed = ?");
25
+ args.push(patch.collapsed ? 1 : 0);
26
+ }
27
+ if (sets.length)
28
+ getDb()
29
+ .prepare(
30
+ `UPDATE session_groups SET ${sets.join(", ")},
31
+ updated_at = datetime('now') WHERE id = ?`
32
+ )
33
+ .run(...args, id);
34
+ return liveGroup(id);
35
+ }
@@ -0,0 +1,21 @@
1
+ import { getDb } from "@/lib/db";
2
+
3
+ // The workspace a session belongs to: its project's, else the one kept on the
4
+ // session (an orchestrator, or a scratch session in no project). Same rule as
5
+ // inWorkspace in lib/sidebar/shelves, read from the database.
6
+ export function sessionWorkspaceId(
7
+ sessionId: string
8
+ ): string | null | undefined {
9
+ const row = getDb()
10
+ .prepare(
11
+ `SELECT s.role AS role, s.workspace_id AS own, p.workspace_id AS byProject
12
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
13
+ WHERE s.id = ?`
14
+ )
15
+ .get(sessionId) as
16
+ | { role: string | null; own: string | null; byProject: string | null }
17
+ | undefined;
18
+ if (!row) return undefined;
19
+ if (row.role === "orchestrator") return row.own;
20
+ return row.byProject ?? row.own;
21
+ }
@@ -4,6 +4,7 @@ import { waitForExit } from "../chat/worker/client";
4
4
  import { endTmuxSession } from "../tmux/end";
5
5
  import { releaseOnEnd, shutTaskSimulators } from "../simulator";
6
6
  import { forgetPlanWait } from "../models/plan-step";
7
+ import { kickWaits } from "../bus/waits/kick";
7
8
 
8
9
  type Agent = Pick<Session, "id" | "host_id" | "tmux_name"> &
9
10
  Partial<Pick<Session, "worktree_path" | "branch_name">>;
@@ -20,4 +21,6 @@ export async function endSessionAgent(s: Agent): Promise<void> {
20
21
  shutTaskSimulators(s);
21
22
  void waitForExit(s.id).catch(() => {});
22
23
  await endTmuxSession(s.host_id, s.tmux_name);
24
+ // Whoever waits on it is told now, not at the next sweep.
25
+ kickWaits();
23
26
  }
@@ -3,6 +3,7 @@
3
3
  * then (for a worktree) the setup in the background, then the first message.
4
4
  */
5
5
 
6
+ import { fileInGroup } from "../session-groups/assignSessions";
6
7
  import { agentosHome } from "../agentos-home";
7
8
  import type { StartVia } from "../tasks/starter";
8
9
  import fs from "fs";
@@ -82,8 +83,9 @@ export interface LaunchInput {
82
83
  // The workspace selected when it was started. A session in a project
83
84
  // follows the project; one in none keeps this (see inWorkspace).
84
85
  workspaceId?: string | null;
85
- groupPath?: string;
86
86
  systemPrompt?: string | null;
87
+ // The user's group it starts in (lib/session-groups).
88
+ groupId?: string;
87
89
  }
88
90
 
89
91
  export interface Launched {
@@ -317,7 +319,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
317
319
  input.parentSessionId ?? null,
318
320
  model,
319
321
  input.systemPrompt ?? null,
320
- input.groupPath ?? "sessions",
322
+ "sessions",
321
323
  agentType,
322
324
  input.autoApprove || input.access === "full" ? 1 : 0,
323
325
  project?.id ?? "uncategorized",
@@ -335,6 +337,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
335
337
  id
336
338
  );
337
339
  setHome(id, home);
340
+ if (input.groupId) fileInGroup(id, input.groupId);
338
341
  if (interactive) setSessionModel(id, interactive);
339
342
  if (input.access)
340
343
  db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
@@ -28,6 +28,7 @@ export function rollups(
28
28
  const rows = flatten([
29
29
  ...shelves.orchestrators,
30
30
  ...shelves.pinned,
31
+ ...shelves.groups.flatMap((g) => g.rows),
31
32
  ...shelves.needsYou,
32
33
  ...shelves.working,
33
34
  ...shelves.inReview,
@@ -21,6 +21,7 @@ export type RowMenuItem =
21
21
  | "new-conversation"
22
22
  | "move-machine"
23
23
  | "move-project"
24
+ | "move-group"
24
25
  | "check-ins"
25
26
  | "save-recipe"
26
27
  | "run-again"
@@ -133,6 +134,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
133
134
  ...group("move", [
134
135
  input.machineTargets > 0 && "move-machine",
135
136
  agentRow && input.otherProjects > 0 && live && "move-project",
137
+ agentRow && "move-group",
136
138
  ]),
137
139
  ...group("automate", [
138
140
  input.hasWorkspace &&
@@ -1,6 +1,6 @@
1
1
  // The sidebar's one flat list, sorted onto shelves: the workspace's pinned
2
- // orchestrator, Pinned, Needs you, Working, In review, Ongoing, Snoozed, then
3
- // Done. Client-safe: no database.
2
+ // orchestrator, Pinned, the user's groups, Needs you, Working, In review,
3
+ // Ongoing, Snoozed, then Done. Client-safe: no database.
4
4
  import type { Session } from "@/lib/db/types";
5
5
  import { fromSqliteTime } from "@/lib/session-meta";
6
6
  import type { PrStage } from "@/lib/tasks/pr-stage";
@@ -42,6 +42,25 @@ export interface RowStatus {
42
42
  detail?: string | null;
43
43
  }
44
44
 
45
+ // A named shelf the user files sessions into (lib/session-groups). Native
46
+ // reads the same shape from GET /api/sessions.
47
+ export interface SessionGroup {
48
+ id: string;
49
+ workspace_id: string | null;
50
+ name: string;
51
+ sort_order: number;
52
+ collapsed: boolean;
53
+ created_at: string;
54
+ updated_at: string;
55
+ }
56
+
57
+ export interface GroupShelf {
58
+ group: SessionGroup;
59
+ rows: SidebarRow[];
60
+ // What a folded header still has to say.
61
+ rollup: { needsYou: number };
62
+ }
63
+
45
64
  export interface SidebarRow {
46
65
  session: Session;
47
66
  status: RowStatus | undefined;
@@ -60,8 +79,11 @@ export interface SidebarRow {
60
79
  activeAt: number;
61
80
  // While snoozed: when it wakes (ms).
62
81
  wakesAt?: number;
63
- // A plain session in Done: marked by hand, or settled by itself.
82
+ // A plain session in Done: marked by hand, or settled by itself. In a
83
+ // group, "auto" only mutes the row: it stays where it was put.
64
84
  settled?: "manual" | "auto";
85
+ // The group it belongs to, even while Done holds it (Reopen goes back).
86
+ groupName?: string;
65
87
  // Its agent is being stopped, is stopped, or is starting again
66
88
  // (lib/tasks/pause): until is when it resumes by itself (ms), null: when
67
89
  // someone resumes it.
@@ -76,6 +98,8 @@ export interface Shelves {
76
98
  // Each shown workspace's current orchestrator, while it's pinned.
77
99
  orchestrators: SidebarRow[];
78
100
  pinned: SidebarRow[];
101
+ // The shown groups in order, empty ones too.
102
+ groups: GroupShelf[];
79
103
  needsYou: SidebarRow[];
80
104
  working: SidebarRow[];
81
105
  inReview: SidebarRow[];
@@ -145,6 +169,9 @@ export interface ShelfInput {
145
169
  currentOrchestrators?: ReadonlySet<string>;
146
170
  // Only the sessions this orchestrator session started.
147
171
  startedBy?: string | null;
172
+ // The live groups to show, in the order to draw them. A session in any
173
+ // other group id is filed by status as if it had none.
174
+ groups?: readonly SessionGroup[];
148
175
  // The clock (ms): wakes and 3-day settles are read from it.
149
176
  now?: number;
150
177
  }
@@ -274,6 +301,24 @@ function toRow(input: ShelfInput, session: Session, now: number): SidebarRow {
274
301
  };
275
302
  }
276
303
 
304
+ // Where a row goes when nothing the user did holds it: what it needs, what
305
+ // it's doing, then how finished it is. Sets settled on a plain session in Done.
306
+ function statusShelf(
307
+ row: SidebarRow,
308
+ now: number
309
+ ): "needsYou" | "working" | "inReview" | "ongoing" | "snoozed" | "done" {
310
+ if (row.need) return "needsYou";
311
+ // A stopped agent is not working, whatever its last status said.
312
+ if (row.paused?.state === "paused") return "snoozed";
313
+ if (row.working) return "working";
314
+ if (row.wakesAt) return "snoozed";
315
+ if (row.task || row.session.task_status) return quietShelf(row.task);
316
+ const settled = settledHow(row.session, now);
317
+ if (!settled) return "ongoing";
318
+ row.settled = settled;
319
+ return "done";
320
+ }
321
+
277
322
  export function buildShelves(input: ShelfInput): Shelves {
278
323
  const now = input.now ?? Date.now();
279
324
  const rows = input.sessions
@@ -314,6 +359,7 @@ export function buildShelves(input: ShelfInput): Shelves {
314
359
  const shelves: Shelves = {
315
360
  orchestrators: [],
316
361
  pinned: [],
362
+ groups: [],
317
363
  needsYou: [],
318
364
  working: [],
319
365
  inReview: [],
@@ -321,26 +367,36 @@ export function buildShelves(input: ShelfInput): Shelves {
321
367
  snoozed: [],
322
368
  done: [],
323
369
  };
370
+ const filtered = !!input.query?.trim() || !!input.projectId;
371
+ const grouped = new Map<string, GroupShelf>(
372
+ (input.groups ?? []).map((group) => [
373
+ group.id,
374
+ { group, rows: [], rollup: { needsYou: 0 } },
375
+ ])
376
+ );
324
377
  for (const row of top) {
325
378
  const { pinned, role, id } = row.session;
326
379
  const current = !!input.currentOrchestrators?.has(id);
380
+ const mine = grouped.get(row.session.session_group_id ?? "");
381
+ // Named on a row outside its group (Pinned, Done); inside, the header says it.
382
+ if (mine) row.groupName = mine.group.name;
327
383
  if (pinned && current) shelves.orchestrators.push(row);
328
384
  else if (pinned && role !== "orchestrator") shelves.pinned.push(row);
329
- else if (row.need) shelves.needsYou.push(row);
330
- // A stopped agent is not working, whatever its last status said.
331
- else if (row.paused?.state === "paused") shelves.snoozed.push(row);
332
- else if (row.working) shelves.working.push(row);
333
- else if (row.wakesAt) shelves.snoozed.push(row);
334
- else if (row.task || row.session.task_status)
335
- shelves[quietShelf(row.task)].push(row);
336
385
  else {
337
- const settled = settledHow(row.session, now);
338
- if (settled) {
339
- row.settled = settled;
340
- shelves.done.push(row);
341
- } else shelves.ongoing.push(row);
386
+ const shelf = statusShelf(row, now);
387
+ // Membership beats status. Only finished work goes on to Done (it keeps
388
+ // its group); a session that merely went quiet stays, muted.
389
+ if (mine && (shelf !== "done" || row.settled === "auto")) {
390
+ delete row.groupName;
391
+ mine.rows.push(row);
392
+ if (row.need) mine.rollup.needsYou++;
393
+ } else shelves[shelf].push(row);
342
394
  }
343
395
  }
396
+ for (const shelf of grouped.values()) {
397
+ shelf.rows.sort((a, b) => b.activeAt - a.activeAt);
398
+ if (!filtered || shelf.rows.length > 0) shelves.groups.push(shelf);
399
+ }
344
400
  shelves.orchestrators.sort(oldestFirst);
345
401
  shelves.pinned.sort(oldestFirst);
346
402
  shelves.needsYou.sort(newestFirst);
@@ -357,6 +413,24 @@ export function buildShelves(input: ShelfInput): Shelves {
357
413
  return shelves;
358
414
  }
359
415
 
416
+ // The groups to draw, in the order to draw them: by workspace (in the order
417
+ // given; groups of no workspace last), then by their own order.
418
+ export function orderGroups(
419
+ groups: readonly SessionGroup[],
420
+ workspaceOrder: readonly string[]
421
+ ): SessionGroup[] {
422
+ const rank = (g: SessionGroup) => {
423
+ const at = g.workspace_id ? workspaceOrder.indexOf(g.workspace_id) : -1;
424
+ return at < 0 ? workspaceOrder.length : at;
425
+ };
426
+ return [...groups].sort(
427
+ (a, b) =>
428
+ rank(a) - rank(b) ||
429
+ a.sort_order - b.sort_order ||
430
+ a.created_at.localeCompare(b.created_at)
431
+ );
432
+ }
433
+
360
434
  // Done shows 10 at first, then 25 more per "Show more".
361
435
  export const doneLimit = (pages: number) =>
362
436
  DONE_FIRST_PAGE + Math.max(0, pages) * DONE_PAGE;
@@ -424,6 +498,7 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
424
498
  a.paused?.until === b.paused?.until &&
425
499
  a.paused?.note === b.paused?.note &&
426
500
  a.settled === b.settled &&
501
+ a.groupName === b.groupName &&
427
502
  a.workers.length === b.workers.length &&
428
503
  a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
429
504
  (a.quick?.length ?? 0) === (b.quick?.length ?? 0) &&
@@ -11,6 +11,10 @@ export interface ShelfFolds {
11
11
  // to the pages shown. SessionShelves renders from this too, so the two agree.
12
12
  export function shownShelves(shelves: Shelves, folds: ShelfFolds) {
13
13
  return {
14
+ groups: shelves.groups.map((g) => ({
15
+ ...g,
16
+ rows: g.group.collapsed ? [] : g.rows,
17
+ })),
14
18
  snoozed: folds.snoozedCollapsed ? [] : shelves.snoozed,
15
19
  done: folds.doneCollapsed
16
20
  ? []
@@ -32,6 +36,7 @@ export function visibleSessionIds(
32
36
  return flatten([
33
37
  ...shelves.orchestrators,
34
38
  ...shelves.pinned,
39
+ ...shown.groups.flatMap((g) => g.rows),
35
40
  ...shelves.needsYou,
36
41
  ...shelves.working,
37
42
  ...shelves.inReview,
@@ -16,7 +16,7 @@ import { DEFAULT_PRIORITY, type Priority } from "./schedule-types";
16
16
  import { machineHolds, holdReason, type Hold } from "./holds";
17
17
  import { heavyNeeds, meetsNeeds, needsOf, type Need } from "./placement";
18
18
  import { expandHome } from "./session";
19
- import { afterLabel, hasFinished } from "./queue-rows";
19
+ import { afterLabel, afterMet, hasFinished } from "./queue-rows";
20
20
  import type { QueueRow } from "./queue-types";
21
21
  import type { PauseBy } from "./schedule-types";
22
22
 
@@ -46,6 +46,8 @@ export const autoHold = (
46
46
 
47
47
  // `after X`: the task it waits on hasn't finished.
48
48
  export function afterHold(r: QueueRow): Hold | null {
49
+ if (r.after_claim && !afterMet(r))
50
+ return { code: "after_claim", text: `After ${afterLabel(r)}` };
49
51
  if (!r.after_task || hasFinished(r.after_task)) return null;
50
52
  return {
51
53
  code: "after",
@@ -64,6 +64,7 @@ export type MachineCode =
64
64
  export type Hold =
65
65
  | { code: "after"; text: string }
66
66
  | { code: "after_any"; text: string }
67
+ | { code: "after_claim"; text: string }
67
68
  | { code: "orch_paused"; text: string }
68
69
  | { code: "window"; text: string }
69
70
  | {
@@ -80,6 +81,7 @@ export type Hold =
80
81
  export const HOLD_ORDER: readonly WaitCode[] = [
81
82
  "after",
82
83
  "after_any",
84
+ "after_claim",
83
85
  "orch_paused",
84
86
  "window",
85
87
  "needs",
@@ -95,6 +97,7 @@ export const HOLD_ORDER: readonly WaitCode[] = [
95
97
  const CLEARS_BY_ITSELF: Record<WaitCode, boolean> = {
96
98
  after: true,
97
99
  after_any: true,
100
+ after_claim: true,
98
101
  orch_paused: false,
99
102
  window: true,
100
103
  needs: false,
@@ -47,6 +47,7 @@ import {
47
47
  type TaskState,
48
48
  } from "./state";
49
49
  import { expandHome, prFor, storedPR, taskSessions } from "./session";
50
+ import { fileInGroup } from "../session-groups/assignSessions";
50
51
  import { InProgressError, type OrchestratedBundle } from "./move-bundle";
51
52
  import { holdFor, mayHold } from "./held";
52
53
  import { orchestratorOf, recordStart, type StartVia } from "./starter";
@@ -169,6 +170,8 @@ export async function createTask(opts: {
169
170
  // How its agent runs: as a chat (the default, DEFAULT_START_VIEW) or in
170
171
  // a terminal.
171
172
  view?: StartView;
173
+ // The user's group it joins as it starts (lib/session-groups).
174
+ groupId?: string;
172
175
  // Started by another machine's orchestrator, which this one holds it for
173
176
  // (held.ts): that machine's paired device and its description of the task.
174
177
  held?: { device: string; orchestrated: OrchestratedBundle };
@@ -292,6 +295,7 @@ async function startTask(
292
295
  startedByDevice: opts.held?.device ?? opts.startedByDevice,
293
296
  });
294
297
  if (opts.pieceId) claimPiece(opts.pieceId, session.id);
298
+ if (opts.groupId) fileInGroup(session.id, opts.groupId);
295
299
  opts.onCreated?.(session.id);
296
300
  return session;
297
301
  }
@@ -416,6 +420,7 @@ async function startTask(
416
420
  throw error;
417
421
  }
418
422
  if (opts.held) holdFor(id, opts.held.device, opts.held.orchestrated);
423
+ if (opts.groupId) fileInGroup(id, opts.groupId);
419
424
  opts.onCreated?.(id);
420
425
  void naming.refine?.(id);
421
426
 
@@ -6,6 +6,7 @@
6
6
  import { db, type Session } from "../db";
7
7
  import { getProject } from "../projects";
8
8
  import { getWorkspace } from "../workspaces";
9
+ import { liveClaim } from "../claims/store";
9
10
  import { queuedLabel } from "./queue-held";
10
11
  import type { Group } from "./schedule";
11
12
  import type { QueueRow } from "./queue-types";
@@ -106,6 +107,8 @@ export function stillWorking(taskId: string): boolean {
106
107
  }
107
108
 
108
109
  export function afterMet(r: QueueRow): boolean {
110
+ // A claim is over when it is gone, released or expired.
111
+ if (r.after_claim && liveClaim(r.after_claim)) return false;
109
112
  if (r.after_task && !hasFinished(r.after_task)) return false;
110
113
  if (r.after_any && !r.after_any_used) {
111
114
  const ids = JSON.parse(r.after_any) as string[];
@@ -171,7 +174,7 @@ export const waitingIn = (workspaceId: string) =>
171
174
  .prepare(
172
175
  `SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
173
176
  WHERE q.status = 'queued' AND p.workspace_id = ?
174
- AND q.after_task IS NULL AND q.after_any IS NULL
177
+ AND q.after_task IS NULL AND q.after_any IS NULL AND q.after_claim IS NULL
175
178
  AND q.plan_only IS NULL`
176
179
  )
177
180
  .get(workspaceId) as { n: number }
@@ -180,6 +183,10 @@ export const waitingIn = (workspaceId: string) =>
180
183
  export const label = queuedLabel;
181
184
 
182
185
  export function afterLabel(r: QueueRow): string | null {
186
+ if (r.after_claim) {
187
+ const c = liveClaim(r.after_claim);
188
+ return c ? `${c.goal} on ${c.originName}` : "a work claim";
189
+ }
183
190
  if (r.after_task) {
184
191
  const s = sessionOf(r.after_task);
185
192
  const q = s ? null : row(r.after_task);
@@ -138,6 +138,7 @@ export async function startRow(
138
138
  startedVia: (r.started_via ??
139
139
  (r.started_by ? "session" : "person")) as StartVia,
140
140
  view: r.view ?? undefined,
141
+ groupId: r.session_group_id ?? undefined,
141
142
  agent: (r.agent_type as AgentType | null) ?? undefined,
142
143
  baseBranch: r.base_branch ?? undefined,
143
144
  hostId: placed?.hostId ?? r.host_id ?? undefined,
@@ -26,6 +26,8 @@ export interface QueueRow {
26
26
  started_by_device: string | null;
27
27
  started_via: string | null;
28
28
  view: "chat" | "terminal" | null;
29
+ // The user's group it joins when it starts (lib/session-groups).
30
+ session_group_id: string | null;
29
31
  agent_type: string | null;
30
32
  base_branch: string | null;
31
33
  host_id: string | null;
@@ -37,6 +39,9 @@ export interface QueueRow {
37
39
  // The finish (a task id) this "after any" waiter took; one finish lets one
38
40
  // waiter go (a unique index keeps it so).
39
41
  after_any_used: string | null;
42
+ // A work claim (lib/claims) it waits on, by claim id, until the claim is
43
+ // released or expires.
44
+ after_claim: string | null;
40
45
  priority: Priority;
41
46
  // What it needs from a machine, as JSON, when the starter said.
42
47
  machine_needs: string | null;
@@ -102,10 +107,13 @@ export interface QueueRequest {
102
107
  // The waiting plan piece it builds, claimed for its queue id.
103
108
  pieceId?: number;
104
109
  view?: "chat" | "terminal";
110
+ // The user's group it joins when it starts: a live group of its workspace.
111
+ groupId?: string;
105
112
  agent?: AgentType;
106
113
  baseBranch?: string;
107
114
  hostId?: string;
108
- // A task's id or name, or "any" for whichever running task finishes first.
115
+ // A task's id or name, "any" for whichever running task finishes first, or
116
+ // "claim:<id>" for a work claim on a linked machine.
109
117
  after?: string;
110
118
  // Set when the orchestrator starts it.
111
119
  originWorkspaceId?: string;