@saadnvd1/agent-os 0.3.700 → 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 (185) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/spawn/route.ts +12 -1
  4. package/app/api/claims/[id]/route.ts +20 -0
  5. package/app/api/claims/check/route.ts +23 -0
  6. package/app/api/claims/check.test.ts +113 -0
  7. package/app/api/claims/exchange/route.ts +42 -0
  8. package/app/api/claims/outside.test.ts +90 -0
  9. package/app/api/claims/route.test.ts +193 -0
  10. package/app/api/claims/route.ts +73 -0
  11. package/app/api/hosts/[id]/route.ts +9 -1
  12. package/app/api/peer/unlink/route.ts +17 -0
  13. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  14. package/app/api/peers/[hostId]/route.ts +20 -0
  15. package/app/api/peers/gate.test.ts +64 -1
  16. package/app/api/peers/pending/route.test.ts +37 -0
  17. package/app/api/peers/pending/route.ts +11 -4
  18. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  19. package/app/api/session-groups/[id]/route.ts +25 -0
  20. package/app/api/session-groups/order/route.ts +15 -0
  21. package/app/api/session-groups/route.ts +21 -0
  22. package/app/api/sessions/[id]/fork/route.ts +3 -0
  23. package/app/api/sessions/[id]/group/route.ts +18 -0
  24. package/app/api/sessions/[id]/route.ts +14 -4
  25. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  26. package/app/api/sessions/claims.test.ts +122 -0
  27. package/app/api/sessions/group/route.ts +17 -0
  28. package/app/api/sessions/route.ts +15 -14
  29. package/app/page.tsx +5 -0
  30. package/components/Claims/ClaimFiles.tsx +38 -0
  31. package/components/Claims/ClaimRow.tsx +82 -0
  32. package/components/Claims/ClaimsSheet.tsx +176 -0
  33. package/components/Claims/OverlapPill.tsx +31 -0
  34. package/components/Claims/StartClaimsNotice.tsx +70 -0
  35. package/components/Claims/claims-ui.test.ts +161 -0
  36. package/components/Claims/format.ts +57 -0
  37. package/components/Claims/index.ts +3 -0
  38. package/components/Draft/DraftPanel.tsx +40 -2
  39. package/components/Hosts/AccountMachines.tsx +32 -18
  40. package/components/Hosts/HostRow.tsx +155 -14
  41. package/components/Hosts/IncomingLinkSheet.tsx +28 -10
  42. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  43. package/components/SessionList/GroupShelves.tsx +195 -0
  44. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  45. package/components/SessionList/RowSubtitle.tsx +7 -1
  46. package/components/SessionList/SelectionToolbar.tsx +52 -1
  47. package/components/SessionList/SessionRow.tsx +1 -0
  48. package/components/SessionList/SessionRowMenu.tsx +17 -0
  49. package/components/SessionList/SessionShelves.tsx +5 -0
  50. package/components/SessionList/SidebarBody.tsx +6 -0
  51. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  52. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  53. package/components/SessionList/SidebarHeader.tsx +5 -0
  54. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  55. package/components/SessionList/index.tsx +18 -14
  56. package/components/SessionList/useSidebarData.ts +10 -0
  57. package/components/ui/notice.tsx +46 -0
  58. package/components/ui/status-pill.tsx +39 -0
  59. package/data/claims/index.ts +2 -0
  60. package/data/claims/keys.ts +5 -0
  61. package/data/claims/queries.ts +47 -0
  62. package/data/peers/keys.ts +1 -0
  63. package/data/peers/queries.ts +31 -0
  64. package/data/push/topics.ts +4 -0
  65. package/data/session-groups/index.ts +156 -0
  66. package/data/sessions/index.ts +0 -1
  67. package/data/sessions/pin.ts +2 -6
  68. package/data/sessions/queries.ts +10 -29
  69. package/data/sessions/shelf.ts +2 -6
  70. package/data/tasks/index.ts +2 -0
  71. package/hooks/useSessions.ts +0 -12
  72. package/lib/__fixtures__/peer-rpc.ts +65 -33
  73. package/lib/agents/spawn.ts +4 -0
  74. package/lib/bus/index.ts +8 -0
  75. package/lib/claims/collectLocal.ts +121 -0
  76. package/lib/claims/describe.ts +41 -0
  77. package/lib/claims/exchange.ts +91 -0
  78. package/lib/claims/goal.ts +45 -0
  79. package/lib/claims/index.ts +10 -0
  80. package/lib/claims/loop.ts +51 -0
  81. package/lib/claims/machine.ts +21 -0
  82. package/lib/claims/mine.ts +43 -0
  83. package/lib/claims/notifyOverlaps.ts +101 -0
  84. package/lib/claims/onMerged.ts +85 -0
  85. package/lib/claims/outside.ts +36 -0
  86. package/lib/claims/overlap.ts +87 -0
  87. package/lib/claims/overlapCache.ts +86 -0
  88. package/lib/claims/overlapText.ts +36 -0
  89. package/lib/claims/peers.ts +56 -0
  90. package/lib/claims/startCheck.ts +100 -0
  91. package/lib/claims/startWarning.ts +29 -0
  92. package/lib/claims/store.ts +366 -0
  93. package/lib/claims/types.ts +102 -0
  94. package/lib/claims/view.ts +80 -0
  95. package/lib/claims/yield.ts +24 -0
  96. package/lib/db/changes.ts +6 -0
  97. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  98. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  99. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  100. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  101. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  102. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  103. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  104. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  105. package/lib/db/queries.ts +0 -40
  106. package/lib/db/session-parts/columns.ts +1 -0
  107. package/lib/db/types.ts +16 -8
  108. package/lib/doctor/checks.ts +64 -1
  109. package/lib/git-remote.ts +43 -0
  110. package/lib/hosts/index.ts +10 -1
  111. package/lib/orchestration.ts +2 -0
  112. package/lib/orchestrator/act.ts +54 -4
  113. package/lib/orchestrator/brief.ts +2 -1
  114. package/lib/orchestrator/claim-wait.ts +71 -0
  115. package/lib/orchestrator/conditions.ts +28 -1
  116. package/lib/orchestrator/describe.ts +6 -1
  117. package/lib/orchestrator/facts.ts +5 -0
  118. package/lib/orchestrator/serve.ts +37 -2
  119. package/lib/orchestrator/tool-names.ts +1 -0
  120. package/lib/orchestrator/tool-schemas.ts +31 -1
  121. package/lib/orchestrator/tools.ts +3 -1
  122. package/lib/orchestrator/watcher.ts +19 -1
  123. package/lib/peer/account.ts +74 -2
  124. package/lib/peer/allowlist.ts +1 -1
  125. package/lib/peer/client.ts +26 -1
  126. package/lib/peer/link-cli.ts +19 -4
  127. package/lib/peer/links.ts +117 -5
  128. package/lib/peer/machines.ts +14 -2
  129. package/lib/peer/name.ts +32 -0
  130. package/lib/peer/pairing.ts +34 -5
  131. package/lib/peer/pin.ts +10 -3
  132. package/lib/peer/reasons.ts +7 -0
  133. package/lib/peer/revoke.ts +75 -0
  134. package/lib/peer/rpc-server.ts +53 -5
  135. package/lib/peer/unlink.ts +62 -0
  136. package/lib/peer/unlinked-proof.ts +44 -0
  137. package/lib/projects.ts +5 -2
  138. package/lib/security/demo.ts +7 -0
  139. package/lib/session-groups/assignSessions.ts +81 -0
  140. package/lib/session-groups/cli.ts +61 -0
  141. package/lib/session-groups/createGroup.ts +28 -0
  142. package/lib/session-groups/index.ts +21 -0
  143. package/lib/session-groups/removeGroup.ts +36 -0
  144. package/lib/session-groups/reorderGroups.ts +31 -0
  145. package/lib/session-groups/resolveGroup.ts +19 -0
  146. package/lib/session-groups/route.ts +32 -0
  147. package/lib/session-groups/store.ts +85 -0
  148. package/lib/session-groups/types.ts +25 -0
  149. package/lib/session-groups/updateGroup.ts +35 -0
  150. package/lib/session-groups/workspace.ts +21 -0
  151. package/lib/sessions/launch.ts +5 -2
  152. package/lib/sidebar/rollup.ts +1 -0
  153. package/lib/sidebar/row-menu.ts +2 -0
  154. package/lib/sidebar/shelves.ts +90 -15
  155. package/lib/sidebar/visible-order.ts +5 -0
  156. package/lib/tasks/hold-checks.ts +3 -1
  157. package/lib/tasks/holds.ts +3 -0
  158. package/lib/tasks/index.ts +5 -0
  159. package/lib/tasks/queue-rows.ts +8 -1
  160. package/lib/tasks/queue-start.ts +1 -0
  161. package/lib/tasks/queue-types.ts +9 -1
  162. package/lib/tasks/queue.ts +15 -5
  163. package/lib/tasks/schedule-types.ts +1 -0
  164. package/lib/test-temp.ts +19 -3
  165. package/lib/workspaces.ts +11 -3
  166. package/lib/worktree-sweep/testTemp.ts +23 -106
  167. package/package.json +1 -1
  168. package/scripts/agent-os +1 -1
  169. package/scripts/autodeploy +2 -0
  170. package/scripts/autodeploy.test.ts +31 -1
  171. package/scripts/claim-hook.sh +31 -0
  172. package/scripts/claim-hook.test.ts +230 -0
  173. package/scripts/lib/cli-links.sh +36 -0
  174. package/scripts/lib/commands.sh +3 -0
  175. package/scripts/lib/machine.sh +27 -2
  176. package/scripts/lib/machine.test.ts +25 -3
  177. package/scripts/sync-cli-links +7 -0
  178. package/scripts/sync-cli-links.test.ts +69 -0
  179. package/server.ts +3 -0
  180. package/stores/claimsUi.ts +22 -0
  181. package/app/api/groups/[...path]/route.ts +0 -136
  182. package/app/api/groups/route.ts +0 -93
  183. package/data/groups/index.ts +0 -1
  184. package/data/groups/mutations.ts +0 -95
  185. package/hooks/useGroups.ts +0 -37
@@ -49,23 +49,30 @@ export function assertDemoSandbox(env: Env = process.env, home = os.homedir()) {
49
49
  // "projects/[id]"): Next serves the folder, so an id must never match them.
50
50
  // The test walks app/api and fails when one is missing here.
51
51
  export const STATIC_SIBLINGS = [
52
+ "account",
52
53
  "archived",
53
54
  "arrived",
54
55
  "availability",
55
56
  "browse",
57
+ "check",
56
58
  "clone",
57
59
  "connect",
58
60
  "detect",
59
61
  "done-idle",
60
62
  "draft",
61
63
  "ended",
64
+ "exchange",
62
65
  "held-replies",
63
66
  "import",
64
67
  "init",
68
+ "group",
65
69
  "init-script",
66
70
  "me",
67
71
  "items",
72
+ "link",
68
73
  "network",
74
+ "order",
75
+ "pending",
69
76
  "pdf",
70
77
  "preview",
71
78
  "queue",
@@ -0,0 +1,81 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { liveGroup } from "./store";
3
+ import { sessionWorkspaceId } from "./workspace";
4
+ import { GroupError, type SessionGroup } from "./types";
5
+
6
+ // The one place that checks the invariant: a session joins only a group of
7
+ // its own workspace. Membership is a view choice, so it leaves updated_at
8
+ // alone, like pinning.
9
+ export function checkMember(sessionId: string, group: SessionGroup): void {
10
+ const workspaceId = sessionWorkspaceId(sessionId);
11
+ if (workspaceId === undefined)
12
+ throw new GroupError("not-found", "Session not found");
13
+ if ((workspaceId ?? null) !== (group.workspace_id ?? null))
14
+ throw new GroupError(
15
+ "workspace",
16
+ `"${group.name}" is a group of another workspace`
17
+ );
18
+ }
19
+
20
+ // Files sessions into a group, or out of any (groupId null). All or none.
21
+ export function assignSessions(
22
+ sessionIds: readonly string[],
23
+ groupId: string | null
24
+ ): { moved: number } {
25
+ const ids = [...new Set(sessionIds)];
26
+ const group = groupId === null ? null : liveGroup(groupId);
27
+ const db = getDb();
28
+ return db.transaction(() => {
29
+ let moved = 0;
30
+ for (const id of ids) {
31
+ if (group) checkMember(id, group);
32
+ else if (sessionWorkspaceId(id) === undefined)
33
+ throw new GroupError("not-found", "Session not found");
34
+ moved += db
35
+ .prepare(
36
+ `UPDATE sessions SET session_group_id = ?
37
+ WHERE id = ? AND session_group_id IS NOT ?`
38
+ )
39
+ .run(group?.id ?? null, id, group?.id ?? null).changes;
40
+ }
41
+ return { moved };
42
+ })();
43
+ }
44
+
45
+ // For a session that is starting: its group may have been deleted, or its
46
+ // workspace changed, since it was asked for. Starting must not fail over a
47
+ // view choice, so it just starts ungrouped.
48
+ export function fileInGroup(sessionId: string, groupId: string): void {
49
+ try {
50
+ assignSessions([sessionId], groupId);
51
+ } catch (err) {
52
+ if (!(err instanceof GroupError)) throw err;
53
+ }
54
+ }
55
+
56
+ // A new session starts in the group of the one it came from (a worker from its
57
+ // conductor, a fork or a fresh start from its source), when that group is
58
+ // still live and in the new session's workspace.
59
+ export function inheritGroup(fromSessionId: string, toSessionId: string): void {
60
+ const row = getDb()
61
+ .prepare(`SELECT session_group_id AS id FROM sessions WHERE id = ?`)
62
+ .get(fromSessionId) as { id: string | null } | undefined;
63
+ if (row?.id) fileInGroup(toSessionId, row.id);
64
+ }
65
+
66
+ // Leaving a workspace leaves its groups (a project move into another one).
67
+ export function clearForeignGroup(sessionId: string): string | null {
68
+ const row = getDb()
69
+ .prepare(
70
+ `SELECT g.id AS id, g.name AS name, g.workspace_id AS ws
71
+ FROM sessions s JOIN session_groups g ON g.id = s.session_group_id
72
+ WHERE s.id = ?`
73
+ )
74
+ .get(sessionId) as
75
+ { id: string; name: string; ws: string | null } | undefined;
76
+ if (!row || (sessionWorkspaceId(sessionId) ?? null) === row.ws) return null;
77
+ getDb()
78
+ .prepare(`UPDATE sessions SET session_group_id = NULL WHERE id = ?`)
79
+ .run(sessionId);
80
+ return row.name;
81
+ }
@@ -0,0 +1,61 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { resolveReadable, readerScope } from "@/lib/bus/read-scope";
3
+ import { assignSessions } from "./assignSessions";
4
+ import { createGroup } from "./createGroup";
5
+ import { listGroups } from "./store";
6
+ import { updateGroup } from "./updateGroup";
7
+ import { resolveGroup } from "./resolveGroup";
8
+ import { GroupError } from "./types";
9
+
10
+ export type GroupCommand =
11
+ | { action: "list" }
12
+ | { action: "new"; name: string }
13
+ | { action: "rename"; group: string; name: string }
14
+ | { action: "add"; group: string; sessions: string[] }
15
+ | { action: "rm"; sessions: string[] };
16
+
17
+ // `aos group ...`: the caller's own workspace's groups, resolved the way
18
+ // `aos read` resolves sessions, so it reaches nothing outside that scope.
19
+ export function groupCommand(callerId: string, cmd: GroupCommand): string {
20
+ const scope = readerScope(callerId);
21
+ const ws = scope.workspaceId;
22
+ if (cmd.action === "list") return describe(ws, scope.label);
23
+ if (cmd.action === "new") {
24
+ const g = createGroup(ws, cmd.name);
25
+ return `created group "${g.name}" in your ${scope.label}`;
26
+ }
27
+ if (cmd.action === "rename") {
28
+ const from = resolveGroup(ws, cmd.group);
29
+ return `renamed "${from.name}" to "${updateGroup(from.id, { name: cmd.name }).name}"`;
30
+ }
31
+ if (!cmd.sessions.length)
32
+ throw new GroupError("invalid", "Name at least one session");
33
+ const target = cmd.action === "add" ? resolveGroup(ws, cmd.group) : null;
34
+ const ids = cmd.sessions.map((ref) => {
35
+ if (ref === "me") return callerId;
36
+ const found = resolveReadable(callerId, ref);
37
+ if (!found.ok) throw new GroupError("not-found", found.error);
38
+ return found.session.id;
39
+ });
40
+ assignSessions(ids, target?.id ?? null);
41
+ return target
42
+ ? `put ${ids.length} session${ids.length === 1 ? "" : "s"} in "${target.name}"`
43
+ : `took ${ids.length} session${ids.length === 1 ? "" : "s"} out of their group`;
44
+ }
45
+
46
+ function describe(workspaceId: string | null, label: string): string {
47
+ const groups = listGroups(workspaceId);
48
+ if (!groups.length) return `no groups in your ${label}`;
49
+ const counts = new Map(
50
+ (
51
+ getDb()
52
+ .prepare(
53
+ `SELECT session_group_id AS id, COUNT(*) AS n FROM sessions
54
+ WHERE session_group_id IS NOT NULL AND archived_at IS NULL
55
+ GROUP BY session_group_id`
56
+ )
57
+ .all() as { id: string; n: number }[]
58
+ ).map((r) => [r.id, r.n])
59
+ );
60
+ return groups.map((g) => `${g.name} · ${counts.get(g.id) ?? 0}`).join("\n");
61
+ }
@@ -0,0 +1,28 @@
1
+ import { randomUUID } from "crypto";
2
+ import { getDb } from "@/lib/db";
3
+ import { cleanName, findGroup, groupByName } from "./store";
4
+ import { GroupError, type SessionGroup } from "./types";
5
+
6
+ // A new group goes to the bottom of its workspace's order. The name is
7
+ // unique within the workspace, ignoring case.
8
+ export function createGroup(
9
+ workspaceId: string | null,
10
+ name: unknown
11
+ ): SessionGroup {
12
+ const clean = cleanName(name);
13
+ const db = getDb();
14
+ if (
15
+ workspaceId &&
16
+ !db.prepare(`SELECT 1 FROM workspaces WHERE id = ?`).get(workspaceId)
17
+ )
18
+ throw new GroupError("not-found", "Workspace not found");
19
+ if (groupByName(workspaceId, clean))
20
+ throw new GroupError("duplicate", `There is already a group "${clean}"`);
21
+ const id = randomUUID();
22
+ db.prepare(
23
+ `INSERT INTO session_groups (id, workspace_id, name, sort_order)
24
+ VALUES (?, ?, ?, (SELECT COALESCE(MAX(sort_order) + 1, 0)
25
+ FROM session_groups WHERE COALESCE(workspace_id, '') = COALESCE(?, '')))`
26
+ ).run(id, workspaceId, clean, workspaceId);
27
+ return findGroup(id)!;
28
+ }
@@ -0,0 +1,21 @@
1
+ export * from "./types";
2
+ export {
3
+ groupNames,
4
+ listGroups,
5
+ findGroup,
6
+ groupByName,
7
+ liveGroup,
8
+ } from "./store";
9
+ export { createGroup } from "./createGroup";
10
+ export { updateGroup } from "./updateGroup";
11
+ export { reorderGroups } from "./reorderGroups";
12
+ export { deleteGroup, restoreGroup } from "./removeGroup";
13
+ export {
14
+ assignSessions,
15
+ checkMember,
16
+ clearForeignGroup,
17
+ fileInGroup,
18
+ inheritGroup,
19
+ } from "./assignSessions";
20
+ export { resolveGroup } from "./resolveGroup";
21
+ export { sessionWorkspaceId } from "./workspace";
@@ -0,0 +1,36 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { findGroup, groupByName, liveGroup } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Soft delete: members keep their session_group_id and the sidebar ignores a
6
+ // deleted group, so they fall back to the status shelves and Undo (restore)
7
+ // puts them back. Nothing is hard-deleted.
8
+ export function deleteGroup(id: string): SessionGroup {
9
+ const group = liveGroup(id);
10
+ getDb()
11
+ .prepare(
12
+ `UPDATE session_groups SET deleted_at = datetime('now'),
13
+ updated_at = datetime('now') WHERE id = ?`
14
+ )
15
+ .run(id);
16
+ return group;
17
+ }
18
+
19
+ export function restoreGroup(id: string): SessionGroup {
20
+ const group = findGroup(id);
21
+ if (!group) throw new GroupError("not-found", "Group not found");
22
+ if (!group.deleted) return group;
23
+ // A new group took the name meanwhile.
24
+ if (groupByName(group.workspace_id, group.name))
25
+ throw new GroupError(
26
+ "duplicate",
27
+ `There is already a group "${group.name}"`
28
+ );
29
+ getDb()
30
+ .prepare(
31
+ `UPDATE session_groups SET deleted_at = NULL,
32
+ updated_at = datetime('now') WHERE id = ?`
33
+ )
34
+ .run(id);
35
+ return liveGroup(id);
36
+ }
@@ -0,0 +1,31 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { listGroups } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Sets a workspace's group order. `ids` may name only some of them (a move
6
+ // up or down sends the whole list; a stale list from another device leaves
7
+ // newer groups at the end), but never one from another workspace.
8
+ export function reorderGroups(
9
+ workspaceId: string | null,
10
+ ids: unknown
11
+ ): SessionGroup[] {
12
+ if (!Array.isArray(ids) || ids.some((i) => typeof i !== "string"))
13
+ throw new GroupError("invalid", "ids is a list of group ids");
14
+ const current = listGroups(workspaceId);
15
+ const known = new Set(current.map((g) => g.id));
16
+ const wanted = [...new Set(ids as string[])];
17
+ const stranger = wanted.find((i) => !known.has(i));
18
+ if (stranger)
19
+ throw new GroupError("workspace", "A group is not in this workspace");
20
+ const order = [
21
+ ...wanted,
22
+ ...current.map((g) => g.id).filter((i) => !wanted.includes(i)),
23
+ ];
24
+ const db = getDb();
25
+ const set = db.prepare(
26
+ `UPDATE session_groups SET sort_order = ?, updated_at = datetime('now')
27
+ WHERE id = ? AND sort_order != ?`
28
+ );
29
+ db.transaction(() => order.forEach((id, i) => set.run(i, id, i)))();
30
+ return listGroups(workspaceId);
31
+ }
@@ -0,0 +1,19 @@
1
+ import { groupByName, listGroups } from "./store";
2
+ import { GroupError, type SessionGroup } from "./types";
3
+
4
+ // A group named by a caller (`aos --group`, an orchestrator tool): it must
5
+ // exist in that workspace, and the error lists the ones that do.
6
+ export function resolveGroup(
7
+ workspaceId: string | null,
8
+ name: string
9
+ ): SessionGroup {
10
+ const found = groupByName(workspaceId, name);
11
+ if (found) return found;
12
+ const names = listGroups(workspaceId).map((g) => `"${g.name}"`);
13
+ throw new GroupError(
14
+ "not-found",
15
+ names.length
16
+ ? `No group "${name.trim()}" in this workspace. Groups: ${names.join(", ")}`
17
+ : `No group "${name.trim()}": this workspace has no groups`
18
+ );
19
+ }
@@ -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
+ }
@@ -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 &&