@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
@@ -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;
@@ -23,6 +23,7 @@ import { randomUUID } from "crypto";
23
23
  import { db } from "../db";
24
24
  import { getProject } from "../projects";
25
25
  import { isRemoteHost } from "../hosts";
26
+ import { liveClaim } from "../claims/store";
26
27
  import { isPaused } from "../orchestrator/pause";
27
28
  import { BrakeRefused, recordStart } from "../orchestrator/brakes";
28
29
  import { discardQueuedHeld, queuedLabel } from "./queue-held";
@@ -81,6 +82,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
81
82
  const limit = limitOf(workspaceId);
82
83
  let afterTask: string | null = null;
83
84
  let afterAny: string[] | null = null;
85
+ let afterClaim: string | null = null;
84
86
  const after = req.after?.trim();
85
87
  // "any" with nothing working is nothing to wait for: it starts at once.
86
88
  let waits = !!after;
@@ -92,6 +94,12 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
92
94
  afterAny = null;
93
95
  waits = false;
94
96
  }
97
+ } else if (after?.toLowerCase().startsWith("claim:")) {
98
+ afterClaim = after.slice("claim:".length).trim();
99
+ if (!liveClaim(afterClaim))
100
+ throw new Error(
101
+ `No live work claim "${afterClaim}": it ended, or it isn't known here`
102
+ );
95
103
  } else if (after)
96
104
  afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
97
105
  if (req.now && waits)
@@ -133,10 +141,10 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
133
141
  // The row and the piece claim stand or fall together.
134
142
  db.transaction(() => {
135
143
  db.prepare(
136
- `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, parent_session_id, started_by_device, started_via, view, agent_type, base_branch,
137
- host_id, after_task, after_any, origin_workspace_id, priority, machine_needs, position)
138
- VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @parent_session_id, @started_by_device, @started_via, @view, @agent_type, @base_branch,
139
- @host_id, @after_task, @after_any, @origin, @priority, @machine_needs,
144
+ `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, parent_session_id, started_by_device, started_via, view, session_group_id, agent_type, base_branch,
145
+ host_id, after_task, after_any, after_claim, origin_workspace_id, priority, machine_needs, position)
146
+ VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @parent_session_id, @started_by_device, @started_via, @view, @session_group_id, @agent_type, @base_branch,
147
+ @host_id, @after_task, @after_any, @after_claim, @origin, @priority, @machine_needs,
140
148
  (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
141
149
  ).run({
142
150
  id,
@@ -156,11 +164,13 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
156
164
  started_by_device: req.startedByDevice ?? null,
157
165
  started_via: req.startedVia,
158
166
  view: req.view ?? null,
167
+ session_group_id: req.groupId ?? null,
159
168
  agent_type: req.agent ?? null,
160
169
  base_branch: req.baseBranch || null,
161
170
  host_id: req.hostId || null,
162
171
  after_task: afterTask,
163
172
  after_any: afterAny ? JSON.stringify(afterAny) : null,
173
+ after_claim: afterClaim,
164
174
  origin: req.originWorkspaceId ?? null,
165
175
  priority: req.priority ?? DEFAULT_PRIORITY,
166
176
  machine_needs: setting ? JSON.stringify(setting) : null,
@@ -217,7 +227,7 @@ export async function startQueuedForOrchestrator(
217
227
  wasFull: slots.full,
218
228
  running: slots.running,
219
229
  limit: slots.limit,
220
- skippedAfter: !!(r.after_task || r.after_any),
230
+ skippedAfter: !!(r.after_task || r.after_any || r.after_claim),
221
231
  };
222
232
  const held = (text: string): ForcedStart => ({
223
233
  ...base,
@@ -33,6 +33,7 @@ export const isPriority = (v: unknown): v is Priority =>
33
33
  export type WaitCode =
34
34
  | "after"
35
35
  | "after_any"
36
+ | "after_claim"
36
37
  | "orch_paused"
37
38
  | "window"
38
39
  | "load_red"
package/lib/test-temp.ts CHANGED
@@ -1,19 +1,35 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { join, sep } from "node:path";
4
+ import { RUN_PREFIX } from "@/lib/worktree-sweep/testTemp";
4
5
 
5
6
  // Folders made by tempDir, removed when the test file ends (vitest.setup.ts
6
7
  // calls removeTempDirs). Kept on globalThis: vi.resetModules() would
7
8
  // otherwise lose the list.
8
9
  const KEY = Symbol.for("agentos.testTempDirs");
10
+ const ROOT_KEY = Symbol.for("agentos.testTempRoot");
9
11
  const made = (): Set<string> => {
10
12
  const g = globalThis as unknown as Record<symbol, Set<string> | undefined>;
11
13
  return (g[KEY] ??= new Set<string>());
12
14
  };
13
15
 
16
+ // The sweep recognises a test's leftovers by the RUN_PREFIX folder above
17
+ // them. The suite's TMPDIR is already inside one; outside it (a script
18
+ // importing this) the folder is made here, so every tempDir is under one.
19
+ function base(): string {
20
+ const root = tmpdir();
21
+ if (root.split(sep).some((s) => s.startsWith(RUN_PREFIX))) return root;
22
+ const g = globalThis as unknown as Record<symbol, string | undefined>;
23
+ if (!g[ROOT_KEY] || !existsSync(g[ROOT_KEY])) {
24
+ g[ROOT_KEY] = mkdtempSync(join(root, RUN_PREFIX));
25
+ made().add(g[ROOT_KEY]);
26
+ }
27
+ return g[ROOT_KEY];
28
+ }
29
+
14
30
  /** A fresh temp folder that is removed when the test file ends. */
15
31
  export function tempDir(prefix: string): string {
16
- const dir = mkdtempSync(join(tmpdir(), prefix));
32
+ const dir = mkdtempSync(join(base(), prefix));
17
33
  made().add(dir);
18
34
  return dir;
19
35
  }
package/lib/workspaces.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { randomUUID } from "crypto";
7
7
  import { db, type Workspace } from "./db";
8
+ import { clearForeignGroup } from "./session-groups/assignSessions";
8
9
 
9
10
  type WorkspaceRow = Omit<
10
11
  Workspace,
@@ -141,7 +142,14 @@ export function setProjectWorkspace(
141
142
  if (workspaceId && !getWorkspace(workspaceId)) {
142
143
  throw new Error("Unknown workspace");
143
144
  }
144
- db.prepare(
145
- `UPDATE projects SET workspace_id = ? WHERE id = ? AND is_uncategorized = 0`
146
- ).run(workspaceId, projectId);
145
+ db.transaction(() => {
146
+ db.prepare(
147
+ `UPDATE projects SET workspace_id = ? WHERE id = ? AND is_uncategorized = 0`
148
+ ).run(workspaceId, projectId);
149
+ // Its sessions changed workspace, so they leave the old one's groups.
150
+ const ids = db
151
+ .prepare(`SELECT id FROM sessions WHERE project_id = ?`)
152
+ .all(projectId) as { id: string }[];
153
+ for (const { id } of ids) clearForeignGroup(id);
154
+ })();
147
155
  }
@@ -1,116 +1,33 @@
1
- // Folders the test suite makes in the OS temp folder, by name. The sweep
2
- // takes only these, never another aos-… or agent-os-… folder, so AgentOS's
3
- // own temp folders (aos-review-, aos-rebase-…) and a person's are safe.
4
- // lib/test-temp.test.ts fails when a test uses a prefix missing here.
5
- export const TEST_TEMP_PREFIXES: readonly string[] = [
6
- "agent-os-git-",
1
+ // What the sweep may take from the OS temp folder as a test run's leftovers.
2
+ // Every folder a test makes with tempDir() lives inside a run root named
3
+ // RUN_PREFIX…, so the sweep matches that one prefix and a new test needs no
4
+ // list edit. Names AgentOS makes itself (aos-snap-, aos-review-…) never
5
+ // start with it.
6
+
7
+ /** Short: a Unix socket path is capped at 104 bytes on macOS. */
8
+ export const RUN_PREFIX = "aos-t-";
9
+
10
+ // Names only the test suite created, left directly in the OS temp folder by
11
+ // runs from before every test got its own root. Frozen: nothing new belongs
12
+ // here. Names server code also creates (agentos-db-, agentos-img-…) are not
13
+ // listed, since a live server could own them.
14
+ export const LEGACY_TEST_TEMP_PREFIXES: readonly string[] = [
15
+ "agent-os-test-",
7
16
  "agent-os-home-",
8
- "agent-os-kept-",
9
17
  "agent-os-limits-",
18
+ "agent-os-git-",
10
19
  "agent-os-session-logs-",
11
- "agent-os-test-",
12
- "aos-acc-",
13
- "aos-add-",
14
- "aos-armed-repo-",
15
- "aos-art-",
16
- "aos-autodeploy-",
17
- "aos-caches-",
18
- "aos-channel-",
19
- "aos-clone-",
20
- "aos-config-",
21
- "aos-crypt-",
22
- "aos-db-",
23
- "aos-db-drops-",
24
- "aos-db-wt-",
25
- "aos-decl-",
26
- "aos-device-keys-",
27
- "aos-done-home-",
20
+ "agent-os-kept-",
28
21
  "aos-done-repo-",
29
- "aos-env-",
30
- "aos-fast-",
31
- "aos-files-",
32
- "aos-git-",
33
- "aos-gr-",
34
- "aos-heavy-",
35
- "aos-init-route-",
36
- "aos-install-",
37
- "aos-kind-",
38
- "aos-launch-plain-",
39
- "aos-launch-repo-",
40
- "aos-machine-",
41
- "aos-limits-",
42
- "aos-mcp-",
43
- "aos-merge-policy-",
44
- "aos-mig-",
45
- "aos-move-",
46
- "aos-no-approvals-",
47
- "aos-nogit-",
48
- "aos-notify-",
49
- "aos-orch-act-",
50
- "aos-orch-add-",
51
- "aos-orch-approve-",
52
- "aos-orch-armed-",
53
- "aos-orch-asks-",
54
- "aos-orch-brakes-",
22
+ "aos-orch-repo-",
23
+ "aos-armed-repo-",
55
24
  "aos-orch-ext-repo-",
56
25
  "aos-orch-external-",
57
- "aos-orch-home-",
58
- "aos-orch-move-",
59
- "aos-orch-plain-",
60
- "aos-orch-questions-",
61
- "aos-orch-recipes-",
62
- "aos-orch-remind-",
63
- "aos-orch-repo-",
64
- "aos-orch-rules-",
65
- "aos-orch-settle-",
66
- "aos-orch-signoff-",
67
- "aos-orch-sweep-",
68
- "aos-origin-",
69
- "aos-path-",
70
- "aos-peer-connect-",
71
- "aos-peer-keys-",
72
- "aos-plain-",
73
- "aos-plan-",
74
- "aos-ports-",
75
- "aos-pr-",
76
- "aos-pr-async-",
77
- "aos-pr-create-",
78
- "aos-pr-gen-",
79
- "aos-pr-route-",
80
- "aos-prompts-",
81
- "aos-quick-",
82
26
  "aos-quick-repo-",
83
- "aos-relay-",
84
- "aos-relay-none-",
85
- "aos-report-",
86
- "aos-resurrect-claude-",
87
- "aos-resurrect-home-",
88
- "aos-run-",
89
- "aos-runner-",
90
- "aos-sec-",
91
- "aos-shot-",
92
- "aos-sim-",
93
- "aos-spare-",
94
- "aos-stack-",
95
- "aos-start-opts-",
96
- "aos-start-plain-",
97
- "aos-sweep-own-",
98
- "aos-t-",
99
- "aos-task-diff-",
100
- "aos-temp-helper-",
101
- "aos-tmux-",
102
- "aos-transcripts-",
103
- "aos-ui-",
104
- "aos-upd-",
105
- "aos-usage-",
106
- "aos-wg-",
107
- "aos-wg-path-",
108
- "aos-wl-",
109
- "aos-worker-env-",
110
- "aos-worker-tmux-",
111
- "aos-wsl-",
112
- "aos-wt-",
27
+ "aos-config-",
28
+ "agentos-link-",
113
29
  ];
114
30
 
115
31
  export const isTestTempName = (name: string) =>
116
- TEST_TEMP_PREFIXES.some((p) => name.startsWith(p));
32
+ name.startsWith(RUN_PREFIX) ||
33
+ LEGACY_TEST_TEMP_PREFIXES.some((p) => name.startsWith(p));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.700",
3
+ "version": "0.3.715",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/scripts/agent-os CHANGED
@@ -88,7 +88,7 @@ case "${1:-}" in
88
88
  passkeys) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/security/passkeys-cli.ts "$@") ;;
89
89
  logs) cmd_logs ;;
90
90
  update) cmd_update ;;
91
- machine) cmd_machine ;;
91
+ machine) shift; cmd_machine "$@" ;;
92
92
  enable) cmd_enable ;;
93
93
  disable) cmd_disable ;;
94
94
  uninstall) cmd_uninstall ;;
@@ -100,6 +100,8 @@ if [ "$head" != "$deployed" ]; then
100
100
  # The pull takes what landed while it waited, too.
101
101
  git rev-parse HEAD > "$state/deployed"
102
102
  date +%s > "$state/restarted"
103
+ # A link into an older clone would run an old aos on this machine.
104
+ scripts/sync-cli-links || log "CLI link sync failed"
103
105
  fi
104
106
 
105
107
  # Publishing waits minutes for npm to serve a version, so it runs on its own