@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
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { PEER_HEADER, TRUST_HEADER } from "@/lib/security/auth";
3
+ import { getHost } from "@/lib/hosts";
4
+ import { unlinkedByPeer } from "@/lib/peer/links";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ // POST /api/peer/unlink - a linked machine unlinked this one. Reached only
9
+ // through its signed envelope (lib/peer/rpc-server.ts), which the gate turns
10
+ // into `peer` trust naming the host it's pinned to; nothing else may call it.
11
+ export function POST(request: NextRequest) {
12
+ const hostId = request.headers.get(PEER_HEADER);
13
+ if (request.headers.get(TRUST_HEADER) !== "peer" || !hostId)
14
+ return NextResponse.json({ error: "forbidden" }, { status: 403 });
15
+ const name = getHost(hostId)?.name ?? "the other machine";
16
+ return NextResponse.json({ unlinked: unlinkedByPeer(hostId, name) });
17
+ }
@@ -0,0 +1,14 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { peerAudit } from "@/lib/peer/audit";
4
+
5
+ // GET /api/peers/:hostId/audit - every call to and from that linked machine,
6
+ // newest first: Machines → Activity.
7
+ export async function GET(
8
+ request: NextRequest,
9
+ { params }: { params: Promise<{ hostId: string }> }
10
+ ) {
11
+ const gate = requireApprover(request);
12
+ if (!gate.ok) return gate.response;
13
+ return NextResponse.json({ rows: peerAudit((await params).hostId) });
14
+ }
@@ -0,0 +1,20 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { unlinkPeer } from "@/lib/peer/unlink";
4
+
5
+ // DELETE /api/peers/:hostId - unlink a machine linked through Connect: its
6
+ // pin goes here at once, and it's told to drop its side. Takes the same
7
+ // trust as linking.
8
+ export async function DELETE(
9
+ request: NextRequest,
10
+ { params }: { params: Promise<{ hostId: string }> }
11
+ ) {
12
+ const gate = requireApprover(request);
13
+ if (!gate.ok) return gate.response;
14
+ try {
15
+ return NextResponse.json(await unlinkPeer((await params).hostId));
16
+ } catch (err) {
17
+ const error = err instanceof Error ? err.message : String(err);
18
+ return NextResponse.json({ error }, { status: 404 });
19
+ }
20
+ }
@@ -9,6 +9,9 @@ const link = await import("./link/route");
9
9
  const cancel = await import("./link/[id]/route");
10
10
  const confirm = await import("./link/[id]/confirm/route");
11
11
  const hello = await import("../peer/hello/route");
12
+ const unlink = await import("./[hostId]/route");
13
+ const audit = await import("./[hostId]/audit/route");
14
+ const hostDelete = await import("../hosts/[id]/route");
12
15
 
13
16
  const plain = mintDevice("phone").device.id;
14
17
  const approver = mintDevice("tablet", null, true).device.id;
@@ -24,7 +27,10 @@ const req = (
24
27
  ...(o.device && { "x-agentos-device": o.device }),
25
28
  "content-type": "application/json",
26
29
  },
27
- body: o.body ?? JSON.stringify({ code: "123456" }),
30
+ body:
31
+ o.method === "GET"
32
+ ? undefined
33
+ : (o.body ?? JSON.stringify({ code: "123456" })),
28
34
  });
29
35
  const params = { params: Promise.resolve({ id: "nope" }) };
30
36
 
@@ -105,7 +111,64 @@ describe("linking routes", () => {
105
111
  "/api/peers/account",
106
112
  "/api/peers/link",
107
113
  "/api/peers/link/x/confirm",
114
+ "/api/peers/h1",
115
+ "/api/peers/h1/audit",
116
+ "/api/peer/unlink",
108
117
  ])
109
118
  expect(isPublicPath(p)).toBe(false);
110
119
  });
120
+
121
+ it("unlink and Activity take the same trust as linking", async () => {
122
+ const hostParams = { params: Promise.resolve({ hostId: "nope" }) };
123
+ for (const who of [
124
+ {},
125
+ { trust: "device", device: plain },
126
+ { trust: "peer" },
127
+ { trust: "open" },
128
+ ]) {
129
+ const del = req("/api/peers/nope", {
130
+ ...who,
131
+ method: "DELETE",
132
+ body: "",
133
+ });
134
+ expect((await unlink.DELETE(del, hostParams)).status).toBe(403);
135
+ const get = req("/api/peers/nope/audit", { ...who, method: "GET" });
136
+ expect((await audit.GET(get, hostParams)).status).toBe(403);
137
+ }
138
+ const asApprover = { trust: "device", device: approver };
139
+ expect(
140
+ (
141
+ await unlink.DELETE(
142
+ req("/x", { ...asApprover, method: "DELETE", body: "" }),
143
+ hostParams
144
+ )
145
+ ).status
146
+ ).toBe(404);
147
+ const rows = await audit.GET(
148
+ req("/x", { ...asApprover, method: "GET" }),
149
+ hostParams
150
+ );
151
+ expect(await rows.json()).toEqual({ rows: [] });
152
+ });
153
+
154
+ it("won't remove a Connect-linked machine without unlinking it", async () => {
155
+ const { createConnectHost } = await import("@/lib/hosts");
156
+ const { savePin, pinForHost } = await import("@/lib/peer/links");
157
+ const id = createConnectHost("Linked").id;
158
+ savePin({
159
+ hostId: id,
160
+ machineId: "f0f0f0f0",
161
+ hostname: "m-0000000000000000.runagentos.com",
162
+ signPub: "x",
163
+ boxPub: "y",
164
+ });
165
+ const res = await hostDelete.DELETE(
166
+ req("/x", { method: "DELETE", body: "" }),
167
+ {
168
+ params: Promise.resolve({ id }),
169
+ }
170
+ );
171
+ expect(res.status).toBe(409);
172
+ expect(pinForHost(id)).not.toBeNull();
173
+ });
111
174
  });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { mintDevice } from "@/lib/security/devices";
4
+
5
+ const seen: unknown[] = [];
6
+ vi.mock("@/lib/peer/pairing", () => ({
7
+ pendingRequests: (_now: number, o: unknown) => {
8
+ seen.push(o);
9
+ return [];
10
+ },
11
+ }));
12
+ const { GET } = await import("./route");
13
+
14
+ const get = (headers: Record<string, string>) =>
15
+ GET(new NextRequest("http://localhost/api/peers/pending", { headers }));
16
+
17
+ describe("GET /api/peers/pending", () => {
18
+ it("hands out the second code only to a caller that could accept", async () => {
19
+ const plain = mintDevice("phone").device.id;
20
+ const approver = mintDevice("tablet", null, true).device.id;
21
+ for (const h of [
22
+ {} as Record<string, string>,
23
+ { "x-agentos-trust": "device", "x-agentos-device": plain },
24
+ { "x-agentos-trust": "peer" },
25
+ ])
26
+ expect((await get(h)).status).toBe(200);
27
+ await get({ "x-agentos-trust": "loopback" });
28
+ await get({ "x-agentos-trust": "device", "x-agentos-device": approver });
29
+ expect(seen).toEqual([
30
+ { withCodes: false },
31
+ { withCodes: false },
32
+ { withCodes: false },
33
+ { withCodes: true },
34
+ { withCodes: true },
35
+ ]);
36
+ });
37
+ });
@@ -1,7 +1,14 @@
1
- import { NextResponse } from "next/server";
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { approverOf } from "@/lib/security/approver";
2
3
  import { pendingRequests } from "@/lib/peer/pairing";
3
4
 
4
- // GET /api/peers/pending - machines asking to link here, waiting for the code.
5
- export function GET() {
6
- return NextResponse.json({ requests: pendingRequests() });
5
+ // GET /api/peers/pending - machines asking to link here, waiting for the
6
+ // code, and accepted ones whose second code is still to be typed there. That
7
+ // code goes only to a caller that could have accepted.
8
+ export function GET(request: NextRequest) {
9
+ return NextResponse.json({
10
+ requests: pendingRequests(Date.now(), {
11
+ withCodes: approverOf(request.headers) !== null,
12
+ }),
13
+ });
7
14
  }
@@ -0,0 +1,11 @@
1
+ import { restoreGroup } from "@/lib/session-groups";
2
+ import { run } from "@/lib/session-groups/route";
3
+
4
+ // POST /api/session-groups/:id/restore - undo a delete.
5
+ export async function POST(
6
+ _request: Request,
7
+ { params }: { params: Promise<{ id: string }> }
8
+ ) {
9
+ const { id } = await params;
10
+ return run(() => ({ sessionGroup: restoreGroup(id) }));
11
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import { deleteGroup, updateGroup } from "@/lib/session-groups";
3
+ import { groupRoute, run } from "@/lib/session-groups/route";
4
+
5
+ type Ctx = { params: Promise<{ id: string }> };
6
+
7
+ const patch = z.object({
8
+ name: z.string().optional(),
9
+ collapsed: z.boolean().optional(),
10
+ });
11
+
12
+ // PATCH /api/session-groups/:id { name?, collapsed? } - rename or fold.
13
+ export async function PATCH(request: Request, { params }: Ctx) {
14
+ const { id } = await params;
15
+ return groupRoute(request, patch, (b) => ({
16
+ sessionGroup: updateGroup(id, b),
17
+ }));
18
+ }
19
+
20
+ // DELETE /api/session-groups/:id - soft delete; its sessions fall back to the
21
+ // status shelves and restore brings them back.
22
+ export async function DELETE(_request: Request, { params }: Ctx) {
23
+ const { id } = await params;
24
+ return run(() => ({ sessionGroup: deleteGroup(id) }));
25
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import { reorderGroups } from "@/lib/session-groups";
3
+ import { groupRoute } from "@/lib/session-groups/route";
4
+
5
+ const order = z.object({
6
+ workspaceId: z.string().nullable(),
7
+ ids: z.array(z.string()),
8
+ });
9
+
10
+ // POST /api/session-groups/order { workspaceId, ids } - a workspace's order.
11
+ export async function POST(request: Request) {
12
+ return groupRoute(request, order, (b) => ({
13
+ sessionGroups: reorderGroups(b.workspaceId, b.ids),
14
+ }));
15
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import { createGroup, listGroups } from "@/lib/session-groups";
3
+ import { groupRoute, run } from "@/lib/session-groups/route";
4
+
5
+ const create = z.object({
6
+ name: z.string({ error: "A group needs a name" }),
7
+ workspaceId: z.string().nullable(),
8
+ });
9
+
10
+ // GET /api/session-groups - every live group.
11
+ export async function GET() {
12
+ return run(() => ({ sessionGroups: listGroups() }));
13
+ }
14
+
15
+ // POST /api/session-groups { name, workspaceId } - create one at the bottom
16
+ // of its workspace's order.
17
+ export async function POST(request: Request) {
18
+ return groupRoute(request, create, (b) => ({
19
+ sessionGroup: createGroup(b.workspaceId, b.name),
20
+ }));
21
+ }
@@ -1,3 +1,4 @@
1
+ import { inheritGroup } from "@/lib/session-groups/assignSessions";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { randomUUID } from "crypto";
3
4
  import { getDb, queries, type Session, type Message } from "@/lib/db";
@@ -63,6 +64,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
63
64
  parent.host_id
64
65
  );
65
66
 
67
+ inheritGroup(parentId, newId);
68
+
66
69
  // NOTE: We do NOT copy claude_session_id here.
67
70
  // When the forked session is first attached, it will use --fork-session flag
68
71
  // with the parent's claude_session_id to create a new branched conversation.
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import { assignSessions } from "@/lib/session-groups";
3
+ import { groupRoute } from "@/lib/session-groups/route";
4
+
5
+ const body = z.object({ groupId: z.string().nullable() });
6
+
7
+ // POST /api/sessions/:id/group { groupId | null } - join a group of the
8
+ // session's workspace, or leave.
9
+ export async function POST(
10
+ request: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const { id } = await params;
14
+ return groupRoute(request, body, (b) => ({
15
+ ok: true,
16
+ ...assignSessions([id], b.groupId),
17
+ }));
18
+ }
@@ -19,6 +19,7 @@ import { statusDetector } from "@/lib/status-detector";
19
19
  import { deleteItems } from "@/lib/chat/store";
20
20
  import { clearQueue } from "@/lib/chat/queued";
21
21
  import { recordPreviousName } from "@/lib/session-names";
22
+ import { getProject, moveSessionToProject } from "@/lib/projects";
22
23
  import { generateBranchName, getCurrentBranch, renameBranch } from "@/lib/git";
23
24
  import { runInBackground } from "@/lib/async-operations";
24
25
  import {
@@ -206,9 +207,15 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
206
207
  updates.push("system_prompt = ?");
207
208
  values.push(body.systemPrompt);
208
209
  }
209
- if (body.groupPath !== undefined) {
210
- updates.push("group_path = ?");
211
- values.push(body.groupPath);
210
+
211
+ let clearedGroup: string | null = null;
212
+ if (typeof body.projectId === "string" && !existing.role) {
213
+ if (!getProject(body.projectId))
214
+ return NextResponse.json(
215
+ { error: "Project not found" },
216
+ { status: 404 }
217
+ );
218
+ clearedGroup = moveSessionToProject(id, body.projectId).clearedGroup;
212
219
  }
213
220
 
214
221
  if (updates.length > 0) {
@@ -221,7 +228,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
221
228
  }
222
229
 
223
230
  const session = queries.getSession(db).get(id) as Session;
224
- return NextResponse.json({ session });
231
+ return NextResponse.json({
232
+ session,
233
+ ...(clearedGroup && { clearedGroup }),
234
+ });
225
235
  } catch (error) {
226
236
  console.error("Error updating session:", error);
227
237
  return NextResponse.json(
@@ -1,3 +1,4 @@
1
+ import { inheritGroup } from "@/lib/session-groups/assignSessions";
1
2
  import { effectiveAutoApprove } from "@/lib/approvals/no-approvals";
2
3
  import { routedRun } from "@/lib/models/launch";
3
4
  import { NextRequest, NextResponse } from "next/server";
@@ -305,6 +306,7 @@ export async function POST(
305
306
  session.host_id
306
307
  );
307
308
 
309
+ inheritGroup(session.id, newId);
308
310
  newSession = queries.getSession(db).get(newId) as Session;
309
311
  const newTmuxSession = tmuxName;
310
312
 
@@ -0,0 +1,122 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { seedSession, seedWorkspace } from "@/lib/orchestrator/testing";
3
+
4
+ const { db } = await import("@/lib/db");
5
+ const { machineId } = await import("@/lib/claims/machine");
6
+ const { upsertLocal, upsertRemote } = await import("@/lib/claims/store");
7
+ const { refreshOverlapCache, resetOverlapCache } =
8
+ await import("@/lib/claims/overlapCache");
9
+ const { GET } = await import("./route");
10
+
11
+ const REPO = "git.example.test/team/widgets";
12
+
13
+ beforeEach(() => {
14
+ db.prepare(`DELETE FROM work_claims`).run();
15
+ resetOverlapCache();
16
+ });
17
+
18
+ function localClaim(sessionId: string, files: string[]) {
19
+ upsertLocal(
20
+ { id: machineId(), name: "here" },
21
+ {
22
+ sessionId,
23
+ sessionName: "Tidy auth",
24
+ repo: REPO,
25
+ branch: "feature/tidy",
26
+ base: "main",
27
+ goal: "Tidy auth",
28
+ files,
29
+ filesMore: 0,
30
+ prUrl: null,
31
+ }
32
+ );
33
+ }
34
+
35
+ const remote = (id: string, files: string[], branch = "feature/login") =>
36
+ upsertRemote(machineId(), "peer-1", {
37
+ id,
38
+ origin: "box-machine",
39
+ origin_name: "box",
40
+ hops: 0,
41
+ source: "session",
42
+ session_name: "Rework login sessions",
43
+ repo: REPO,
44
+ branch,
45
+ base: "main",
46
+ goal: "Rework login sessions",
47
+ files,
48
+ files_more: 0,
49
+ pr_url: null,
50
+ ttl_s: 600,
51
+ age_s: 60,
52
+ released: null,
53
+ });
54
+
55
+ const FILES = ["a/one.ts", "a/two.ts", "a/three.ts"];
56
+
57
+ const rows = async () => {
58
+ const body = await (await GET()).json();
59
+ return body as {
60
+ sessions: { id: string; overlap?: Record<string, unknown> | null }[];
61
+ claims: { id: string; fileCount: number; files?: string[] }[];
62
+ claimOverlaps: { a: string; b: string; strength: string }[];
63
+ };
64
+ };
65
+
66
+ describe("GET /api/sessions with claims", () => {
67
+ it("puts a strong overlap on the session that has it, and no other", async () => {
68
+ const t = seedWorkspace();
69
+ const mine = seedSession({ projectId: t.app.id, name: "Tidy auth" });
70
+ const other = seedSession({ projectId: t.app.id, name: "Unrelated" });
71
+ localClaim(mine, FILES);
72
+ remote("claim-box-1", FILES);
73
+ refreshOverlapCache();
74
+ const body = await rows();
75
+ expect(body.sessions.find((s) => s.id === mine)?.overlap).toEqual({
76
+ strength: "strong",
77
+ machine: "box",
78
+ goal: "Rework login sessions",
79
+ shared: 3,
80
+ claimId: "claim-box-1",
81
+ mineId: `s-${mine}`,
82
+ });
83
+ expect(body.sessions.find((s) => s.id === other)?.overlap).toBeUndefined();
84
+ });
85
+
86
+ it("marks a small file overlap weak", async () => {
87
+ const t = seedWorkspace();
88
+ const mine = seedSession({ projectId: t.app.id, name: "Tidy auth" });
89
+ localClaim(mine, ["a/1.ts", "a/2.ts", "a/3.ts", "a/4.ts", "a/5.ts"]);
90
+ remote(
91
+ "claim-box-2",
92
+ ["a/1.ts", "a/2.ts", "b/1.ts", "b/2.ts", "b/3.ts"],
93
+ "feature/other"
94
+ );
95
+ refreshOverlapCache();
96
+ const row = (await rows()).sessions.find((s) => s.id === mine);
97
+ expect(row?.overlap?.strength).toBe("weak");
98
+ });
99
+
100
+ it("lists claims without their paths, and the overlapping pairs", async () => {
101
+ const t = seedWorkspace();
102
+ const mine = seedSession({ projectId: t.app.id, name: "Tidy auth" });
103
+ localClaim(mine, FILES);
104
+ remote("claim-box-1", FILES);
105
+ refreshOverlapCache();
106
+ const body = await rows();
107
+ expect(body.claims.map((c) => c.id).sort()).toEqual(
108
+ [`s-${mine}`, "claim-box-1"].sort()
109
+ );
110
+ expect(body.claims.every((c) => c.fileCount === 3 && !c.files)).toBe(true);
111
+ expect(body.claimOverlaps).toHaveLength(1);
112
+ expect(body.claimOverlaps[0].strength).toBe("strong");
113
+ });
114
+
115
+ it("serves no claims before the loop has run", async () => {
116
+ const t = seedWorkspace();
117
+ seedSession({ projectId: t.app.id, name: "Tidy auth" });
118
+ const body = await rows();
119
+ expect(body.claims).toEqual([]);
120
+ expect(body.sessions.every((s) => !s.overlap)).toBe(true);
121
+ });
122
+ });
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ import { assignSessions } from "@/lib/session-groups";
3
+ import { groupRoute } from "@/lib/session-groups/route";
4
+
5
+ const body = z.object({
6
+ ids: z.array(z.string()).min(1).max(200),
7
+ groupId: z.string().nullable(),
8
+ });
9
+
10
+ // POST /api/sessions/group { ids, groupId | null } - the multi-select move.
11
+ // All or none: one session of another workspace refuses the lot.
12
+ export async function POST(request: Request) {
13
+ return groupRoute(request, body, (b) => ({
14
+ ok: true,
15
+ ...assignSessions(b.ids, b.groupId),
16
+ }));
17
+ }
@@ -1,13 +1,15 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { getDb, queries, type Session, type Group } from "@/lib/db";
2
+ import { getDb, queries, type Session } from "@/lib/db";
3
+ import { listGroups } from "@/lib/session-groups";
3
4
  import { isValidAgentType } from "@/lib/providers";
4
5
  import { clientSend } from "@/lib/chat/client-send";
5
6
  import { CHAT_ACCESS } from "@/lib/chat/events";
6
7
  import { launchSession } from "@/lib/sessions/launch";
7
8
  import { namesLine, waitBadges } from "@/lib/bus/waits/store";
9
+ import { claimsSnapshot } from "@/lib/claims";
8
10
  import { isProjectRef } from "@/lib/tasks/project-ref";
9
11
 
10
- // GET /api/sessions - List all sessions and groups
12
+ // GET /api/sessions - List the sidebar's sessions and the user's groups
11
13
  export async function GET() {
12
14
  try {
13
15
  const db = getDb();
@@ -15,12 +17,15 @@ export async function GET() {
15
17
  // tasks live in the Tasks panel, a moved one on the machine it moved to,
16
18
  // and archived sessions in the Archived view: none is in the sidebar.
17
19
  const badges = waitBadges();
20
+ const claims = claimsSnapshot();
18
21
  const sessions = (queries.getSidebarSessions(db).all() as Session[]).map(
19
22
  (s) => {
20
23
  const b = badges.get(s.id);
24
+ const overlap = claims.bySession.get(s.id);
25
+ const row = overlap ? { ...s, overlap } : s;
21
26
  return b
22
27
  ? {
23
- ...s,
28
+ ...row,
24
29
  waiting_on: b.on.length
25
30
  ? namesLine(b.on.map((w) => w.name))
26
31
  : null,
@@ -28,18 +33,15 @@ export async function GET() {
28
33
  ? namesLine(b.by.map((w) => w.name))
29
34
  : null,
30
35
  }
31
- : s;
36
+ : row;
32
37
  }
33
38
  );
34
- const groups = queries.getAllGroups(db).all() as Group[];
35
-
36
- // Convert expanded from 0/1 to boolean
37
- const formattedGroups = groups.map((g) => ({
38
- ...g,
39
- expanded: Boolean(g.expanded),
40
- }));
41
-
42
- return NextResponse.json({ sessions, groups: formattedGroups });
39
+ return NextResponse.json({
40
+ sessions,
41
+ sessionGroups: listGroups(),
42
+ claims: claims.claims,
43
+ claimOverlaps: claims.overlaps,
44
+ });
43
45
  } catch (error) {
44
46
  console.error("Error fetching sessions:", error);
45
47
  return NextResponse.json(
@@ -95,7 +97,6 @@ export async function POST(request: NextRequest) {
95
97
  name: str(body.name),
96
98
  view: body.view === "terminal" ? "terminal" : undefined,
97
99
  parentSessionId: str(body.parentSessionId),
98
- groupPath: str(body.groupPath) ?? undefined,
99
100
  systemPrompt: str(body.systemPrompt),
100
101
  });
101
102
  const db = getDb();
package/app/page.tsx CHANGED
@@ -122,6 +122,10 @@ const LumifyHubDialogs = dynamic(
122
122
  ),
123
123
  { ssr: false }
124
124
  );
125
+ const ClaimsSheet = dynamic(
126
+ () => import("@/components/Claims/ClaimsSheet").then((m) => m.ClaimsSheet),
127
+ { ssr: false }
128
+ );
125
129
  const UsageDialog = dynamic(
126
130
  () => import("@/components/Usage/UsageDialog").then((m) => m.UsageDialog),
127
131
  { ssr: false }
@@ -159,6 +163,7 @@ const AppDialogs = memo(function AppDialogs({
159
163
  <CleanupDialog />
160
164
  <LumifyHubDialogs />
161
165
  <UsageDialog />
166
+ <ClaimsSheet />
162
167
  <MoveDialog />
163
168
  <ShareDialog />
164
169
  <AwayReport />
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ // The paths a claim touches, the ones another claim also touches marked.
6
+ export function ClaimFiles({
7
+ files,
8
+ more,
9
+ shared,
10
+ }: {
11
+ files: string[];
12
+ more: number;
13
+ shared: ReadonlySet<string>;
14
+ }) {
15
+ return (
16
+ <ul className="bg-muted/40 mt-1 max-h-48 overflow-y-auto rounded-lg p-2 font-mono text-[11px] leading-5">
17
+ {files.map((f) => (
18
+ <li
19
+ key={f}
20
+ className={cn(
21
+ "break-all",
22
+ shared.has(f)
23
+ ? "text-amber-700 dark:text-amber-400"
24
+ : "text-muted-foreground"
25
+ )}
26
+ >
27
+ {f}
28
+ </li>
29
+ ))}
30
+ {more > 0 && (
31
+ <li className="text-muted-foreground pt-1">and {more} more</li>
32
+ )}
33
+ {files.length === 0 && more === 0 && (
34
+ <li className="text-muted-foreground">No changed files yet</li>
35
+ )}
36
+ </ul>
37
+ );
38
+ }