@saadnvd1/agent-os 0.3.698 → 0.3.715

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/send/route.ts +11 -1
  4. package/app/api/bus/spawn/route.ts +12 -1
  5. package/app/api/bus/wait/route.ts +22 -0
  6. package/app/api/claims/[id]/route.ts +20 -0
  7. package/app/api/claims/check/route.ts +23 -0
  8. package/app/api/claims/check.test.ts +113 -0
  9. package/app/api/claims/exchange/route.ts +42 -0
  10. package/app/api/claims/outside.test.ts +90 -0
  11. package/app/api/claims/route.test.ts +193 -0
  12. package/app/api/claims/route.ts +73 -0
  13. package/app/api/hosts/[id]/route.ts +9 -1
  14. package/app/api/peer/hello/reveal/route.ts +6 -0
  15. package/app/api/peer/hello/route.ts +9 -0
  16. package/app/api/peer/hello/status/route.ts +6 -0
  17. package/app/api/peer/unlink/route.ts +17 -0
  18. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  19. package/app/api/peers/[hostId]/route.ts +20 -0
  20. package/app/api/peers/account/route.ts +9 -0
  21. package/app/api/peers/gate.test.ts +174 -0
  22. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  23. package/app/api/peers/link/[id]/route.ts +20 -0
  24. package/app/api/peers/link/route.ts +23 -0
  25. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  26. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  27. package/app/api/peers/pending/route.test.ts +37 -0
  28. package/app/api/peers/pending/route.ts +14 -0
  29. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  30. package/app/api/session-groups/[id]/route.ts +25 -0
  31. package/app/api/session-groups/order/route.ts +15 -0
  32. package/app/api/session-groups/route.ts +21 -0
  33. package/app/api/sessions/[id]/fork/route.ts +3 -0
  34. package/app/api/sessions/[id]/group/route.ts +18 -0
  35. package/app/api/sessions/[id]/route.ts +14 -4
  36. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  37. package/app/api/sessions/claims.test.ts +122 -0
  38. package/app/api/sessions/group/route.ts +17 -0
  39. package/app/api/sessions/route.ts +31 -13
  40. package/app/page.tsx +7 -0
  41. package/components/Claims/ClaimFiles.tsx +38 -0
  42. package/components/Claims/ClaimRow.tsx +82 -0
  43. package/components/Claims/ClaimsSheet.tsx +176 -0
  44. package/components/Claims/OverlapPill.tsx +31 -0
  45. package/components/Claims/StartClaimsNotice.tsx +70 -0
  46. package/components/Claims/claims-ui.test.ts +161 -0
  47. package/components/Claims/format.ts +57 -0
  48. package/components/Claims/index.ts +3 -0
  49. package/components/Draft/DraftPanel.tsx +40 -2
  50. package/components/Hosts/AccountMachines.tsx +194 -0
  51. package/components/Hosts/HostRow.tsx +195 -45
  52. package/components/Hosts/HostsDialog.tsx +49 -37
  53. package/components/Hosts/IncomingLinkSheet.tsx +246 -0
  54. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  55. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  56. package/components/Hosts/sheet.ts +3 -0
  57. package/components/SessionList/GroupShelves.tsx +195 -0
  58. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  59. package/components/SessionList/RowSubtitle.tsx +18 -2
  60. package/components/SessionList/SelectionToolbar.tsx +52 -1
  61. package/components/SessionList/SessionRow.tsx +1 -0
  62. package/components/SessionList/SessionRowMenu.tsx +17 -0
  63. package/components/SessionList/SessionShelves.tsx +5 -0
  64. package/components/SessionList/SidebarBody.tsx +6 -0
  65. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  66. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  67. package/components/SessionList/SidebarHeader.tsx +5 -0
  68. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  69. package/components/SessionList/index.tsx +18 -14
  70. package/components/SessionList/useSidebarData.ts +10 -0
  71. package/components/Welcome/ConnectBlock.tsx +1 -1
  72. package/components/ui/notice.tsx +46 -0
  73. package/components/ui/status-pill.tsx +39 -0
  74. package/data/claims/index.ts +2 -0
  75. package/data/claims/keys.ts +5 -0
  76. package/data/claims/queries.ts +47 -0
  77. package/data/peers/index.ts +2 -0
  78. package/data/peers/keys.ts +7 -0
  79. package/data/peers/queries.ts +172 -0
  80. package/data/push/topics.ts +4 -0
  81. package/data/session-groups/index.ts +156 -0
  82. package/data/sessions/index.ts +0 -1
  83. package/data/sessions/pin.ts +2 -6
  84. package/data/sessions/queries.ts +10 -29
  85. package/data/sessions/shelf.ts +2 -6
  86. package/data/tasks/index.ts +2 -0
  87. package/hooks/useSessions.ts +0 -12
  88. package/lib/__fixtures__/peer-rpc.ts +65 -33
  89. package/lib/agents/brief.ts +3 -1
  90. package/lib/agents/spawn.ts +4 -0
  91. package/lib/bus/format.ts +25 -4
  92. package/lib/bus/index.ts +121 -106
  93. package/lib/bus/resolve.ts +16 -2
  94. package/lib/bus/targets.ts +122 -0
  95. package/lib/bus/waits/kick.ts +12 -0
  96. package/lib/bus/waits/lines.ts +69 -0
  97. package/lib/bus/waits/register.ts +83 -0
  98. package/lib/bus/waits/store.ts +196 -0
  99. package/lib/bus/waits/tick.ts +192 -0
  100. package/lib/claims/collectLocal.ts +121 -0
  101. package/lib/claims/describe.ts +41 -0
  102. package/lib/claims/exchange.ts +91 -0
  103. package/lib/claims/goal.ts +45 -0
  104. package/lib/claims/index.ts +10 -0
  105. package/lib/claims/loop.ts +51 -0
  106. package/lib/claims/machine.ts +21 -0
  107. package/lib/claims/mine.ts +43 -0
  108. package/lib/claims/notifyOverlaps.ts +101 -0
  109. package/lib/claims/onMerged.ts +85 -0
  110. package/lib/claims/outside.ts +36 -0
  111. package/lib/claims/overlap.ts +87 -0
  112. package/lib/claims/overlapCache.ts +86 -0
  113. package/lib/claims/overlapText.ts +36 -0
  114. package/lib/claims/peers.ts +56 -0
  115. package/lib/claims/startCheck.ts +100 -0
  116. package/lib/claims/startWarning.ts +29 -0
  117. package/lib/claims/store.ts +366 -0
  118. package/lib/claims/types.ts +102 -0
  119. package/lib/claims/view.ts +80 -0
  120. package/lib/claims/yield.ts +24 -0
  121. package/lib/connect/control-api.ts +108 -2
  122. package/lib/connect/heartbeat.ts +11 -3
  123. package/lib/connect/serve.ts +2 -0
  124. package/lib/db/changes.ts +6 -0
  125. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  126. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  127. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  128. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  129. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  130. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  131. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  132. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  133. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  134. package/lib/db/queries.ts +0 -40
  135. package/lib/db/session-parts/columns.ts +1 -0
  136. package/lib/db/types.ts +20 -8
  137. package/lib/doctor/checks.ts +64 -1
  138. package/lib/done/archive.ts +2 -0
  139. package/lib/git-remote.ts +43 -0
  140. package/lib/hosts/index.ts +10 -1
  141. package/lib/orchestration.ts +2 -0
  142. package/lib/orchestrator/act.ts +54 -4
  143. package/lib/orchestrator/brief.ts +2 -1
  144. package/lib/orchestrator/claim-wait.ts +71 -0
  145. package/lib/orchestrator/conditions.ts +28 -1
  146. package/lib/orchestrator/describe.ts +19 -1
  147. package/lib/orchestrator/facts.ts +22 -0
  148. package/lib/orchestrator/serve.ts +37 -2
  149. package/lib/orchestrator/tool-names.ts +1 -0
  150. package/lib/orchestrator/tool-schemas.ts +31 -1
  151. package/lib/orchestrator/tools.ts +3 -1
  152. package/lib/orchestrator/watcher.ts +19 -1
  153. package/lib/peer/account.ts +270 -0
  154. package/lib/peer/allowlist.ts +1 -1
  155. package/lib/peer/cli.ts +24 -0
  156. package/lib/peer/client.ts +26 -1
  157. package/lib/peer/code.ts +3 -0
  158. package/lib/peer/hello-route.ts +32 -0
  159. package/lib/peer/hello.ts +346 -0
  160. package/lib/peer/link-cli.ts +248 -0
  161. package/lib/peer/links.ts +117 -5
  162. package/lib/peer/machines.ts +84 -0
  163. package/lib/peer/name.ts +32 -0
  164. package/lib/peer/notify.ts +32 -0
  165. package/lib/peer/pairing-out.ts +382 -0
  166. package/lib/peer/pairing.ts +432 -0
  167. package/lib/peer/pin.ts +62 -0
  168. package/lib/peer/reasons.ts +7 -0
  169. package/lib/peer/revoke.ts +75 -0
  170. package/lib/peer/rpc-intake.ts +37 -8
  171. package/lib/peer/rpc-server.ts +72 -6
  172. package/lib/peer/unlink.ts +62 -0
  173. package/lib/peer/unlinked-proof.ts +44 -0
  174. package/lib/projects.ts +5 -2
  175. package/lib/schedules/when.ts +1 -1
  176. package/lib/security/auth.ts +10 -2
  177. package/lib/security/demo.ts +7 -0
  178. package/lib/security/gate.ts +3 -0
  179. package/lib/session-groups/assignSessions.ts +81 -0
  180. package/lib/session-groups/cli.ts +61 -0
  181. package/lib/session-groups/createGroup.ts +28 -0
  182. package/lib/session-groups/index.ts +21 -0
  183. package/lib/session-groups/removeGroup.ts +36 -0
  184. package/lib/session-groups/reorderGroups.ts +31 -0
  185. package/lib/session-groups/resolveGroup.ts +19 -0
  186. package/lib/session-groups/route.ts +32 -0
  187. package/lib/session-groups/store.ts +85 -0
  188. package/lib/session-groups/types.ts +25 -0
  189. package/lib/session-groups/updateGroup.ts +35 -0
  190. package/lib/session-groups/workspace.ts +21 -0
  191. package/lib/sessions/end.ts +3 -0
  192. package/lib/sessions/launch.ts +5 -2
  193. package/lib/sidebar/rollup.ts +1 -0
  194. package/lib/sidebar/row-menu.ts +2 -0
  195. package/lib/sidebar/shelves.ts +90 -15
  196. package/lib/sidebar/visible-order.ts +5 -0
  197. package/lib/tasks/hold-checks.ts +3 -1
  198. package/lib/tasks/holds.ts +3 -0
  199. package/lib/tasks/index.ts +5 -0
  200. package/lib/tasks/queue-rows.ts +8 -1
  201. package/lib/tasks/queue-start.ts +1 -0
  202. package/lib/tasks/queue-types.ts +9 -1
  203. package/lib/tasks/queue.ts +15 -5
  204. package/lib/tasks/schedule-types.ts +1 -0
  205. package/lib/test-temp.ts +19 -3
  206. package/lib/workspaces.ts +11 -3
  207. package/lib/worktree-sweep/testTemp.ts +23 -106
  208. package/package.json +1 -1
  209. package/scripts/agent-os +3 -1
  210. package/scripts/autodeploy +2 -0
  211. package/scripts/autodeploy.test.ts +31 -1
  212. package/scripts/claim-hook.sh +31 -0
  213. package/scripts/claim-hook.test.ts +230 -0
  214. package/scripts/lib/cli-links.sh +36 -0
  215. package/scripts/lib/commands.sh +5 -0
  216. package/scripts/lib/machine.sh +27 -2
  217. package/scripts/lib/machine.test.ts +25 -3
  218. package/scripts/sync-cli-links +7 -0
  219. package/scripts/sync-cli-links.test.ts +69 -0
  220. package/server.ts +6 -0
  221. package/stores/claimsUi.ts +22 -0
  222. package/app/api/groups/[...path]/route.ts +0 -136
  223. package/app/api/groups/route.ts +0 -93
  224. package/data/groups/index.ts +0 -1
  225. package/data/groups/mutations.ts +0 -95
  226. package/hooks/useGroups.ts +0 -37
@@ -7,12 +7,14 @@
7
7
 
8
8
  import fs from "fs";
9
9
  import path from "path";
10
- import { ApiError, clean } from "./control-api";
10
+ import { ApiError, clean, type Beat } from "./control-api";
11
11
 
12
12
  export const HEARTBEAT_MS = 60_000;
13
13
 
14
14
  export interface HeartbeatOptions {
15
- beat: () => Promise<{ enabled: boolean }>;
15
+ beat: () => Promise<Beat>;
16
+ /** Every answer, for what it says about the account (plan, peers). */
17
+ onBeat?: (beat: Beat) => void;
16
18
  /** The account disabled this machine: stop the tunnel. */
17
19
  onDisabled: () => void;
18
20
  /** The service no longer knows this machine. */
@@ -43,9 +45,15 @@ export function startHeartbeat(opts: HeartbeatOptions) {
43
45
  const tick = async () => {
44
46
  timer = null;
45
47
  try {
46
- const { enabled } = await opts.beat();
48
+ const answer = await opts.beat();
49
+ const { enabled } = answer;
47
50
  if (stopped) return;
48
51
  failing = false;
52
+ try {
53
+ opts.onBeat?.(answer);
54
+ } catch (err) {
55
+ log(`connect: ${clean((err as Error)?.message ?? String(err))}`);
56
+ }
49
57
  if (enabled === false) {
50
58
  stop();
51
59
  log("connect: this machine was disabled in your account; tunnel off");
@@ -40,6 +40,7 @@ import {
40
40
  type Tunnel,
41
41
  } from "./control-api";
42
42
  import { appVersion, startHeartbeat } from "./heartbeat";
43
+ import { noteHeartbeat } from "@/lib/peer/account";
43
44
  import { ensureCloudflared } from "./cloudflared";
44
45
  import {
45
46
  reapTunnel,
@@ -331,6 +332,7 @@ export function startConnect(
331
332
  const version = appVersion();
332
333
  const heartbeat = startHeartbeat({
333
334
  beat: () => beat(config, machineKey, version),
335
+ onBeat: noteHeartbeat,
334
336
  log,
335
337
  intervalMs: deps.heartbeatMs,
336
338
  onDisabled: () => {
package/lib/db/changes.ts CHANGED
@@ -45,6 +45,10 @@ export const TABLES_WATCHED_CHAT_BOOKMARKS = ["chat_bookmarks"] as const;
45
45
 
46
46
  export const TABLES_WATCHED_LEARNING_CARDS = ["learning_cards"] as const;
47
47
 
48
+ export const TABLES_WATCHED_SESSION_GROUPS = ["session_groups"] as const;
49
+
50
+ export const TABLES_WATCHED_WORK_CLAIMS = ["work_claims"] as const;
51
+
48
52
  // Every table any migration watches, for the browser's topic map to cover.
49
53
  export const WATCHED_TABLES: readonly string[] = [
50
54
  ...TABLES_WATCHED_45,
@@ -55,6 +59,8 @@ export const WATCHED_TABLES: readonly string[] = [
55
59
  ...TABLES_WATCHED_80,
56
60
  ...TABLES_WATCHED_CHAT_BOOKMARKS,
57
61
  ...TABLES_WATCHED_LEARNING_CARDS,
62
+ ...TABLES_WATCHED_SESSION_GROUPS,
63
+ ...TABLES_WATCHED_WORK_CLAIMS,
58
64
  ];
59
65
 
60
66
  export function installChangeTriggers(
@@ -0,0 +1,35 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T1400_work_claims",
5
+ up: (db) => {
6
+ // What each live session, here or on a linked machine, is working on:
7
+ // repo, branch, goal and the paths of its diff. Never code. Times are
8
+ // this machine's clock in ms; they cross the wire as durations.
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS work_claims (
11
+ id TEXT PRIMARY KEY,
12
+ origin TEXT NOT NULL,
13
+ origin_name TEXT NOT NULL,
14
+ via_machine TEXT,
15
+ hops INTEGER NOT NULL DEFAULT 0,
16
+ source TEXT NOT NULL,
17
+ session_id TEXT,
18
+ session_name TEXT,
19
+ repo TEXT NOT NULL,
20
+ branch TEXT,
21
+ base TEXT,
22
+ goal TEXT NOT NULL,
23
+ files TEXT NOT NULL DEFAULT '[]',
24
+ files_more INTEGER NOT NULL DEFAULT 0,
25
+ pr_url TEXT,
26
+ started_at INTEGER NOT NULL,
27
+ expires_at INTEGER NOT NULL,
28
+ released TEXT,
29
+ updated_at INTEGER NOT NULL
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_work_claims_repo ON work_claims (repo, expires_at);
32
+ CREATE INDEX IF NOT EXISTS idx_work_claims_session ON work_claims (session_id);
33
+ `);
34
+ },
35
+ } satisfies Migration;
@@ -0,0 +1,22 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T1515_peer_unlinked",
5
+ up: (db) => {
6
+ // A machine unlinked here, from either side (lib/peer/unlink.ts): its pin
7
+ // is gone, and its signing key is kept for 30 days so its next call can
8
+ // be told, provably, that it's no longer linked, and so Machines can say
9
+ // who unlinked it. Never trusted for anything else.
10
+ db.exec(`CREATE TABLE IF NOT EXISTS peer_unlinked (
11
+ machine_id TEXT PRIMARY KEY,
12
+ host_id TEXT,
13
+ sign_pub TEXT NOT NULL,
14
+ by_peer INTEGER NOT NULL DEFAULT 0,
15
+ reason TEXT NOT NULL,
16
+ at INTEGER NOT NULL
17
+ )`);
18
+ db.exec(
19
+ `CREATE INDEX IF NOT EXISTS idx_peer_unlinked_host ON peer_unlinked(host_id)`
20
+ );
21
+ },
22
+ } satisfies Migration;
@@ -0,0 +1,35 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T1600_bus_waits",
5
+ up: (db) => {
6
+ // A session waiting on another (lib/bus/waits): for its reply, or for it
7
+ // to go idle or merge. Closed rows stay as history.
8
+ db.exec(`CREATE TABLE IF NOT EXISTS bus_waits (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ waiter_id TEXT NOT NULL,
11
+ target_id TEXT NOT NULL,
12
+ kind TEXT NOT NULL,
13
+ message_id INTEGER,
14
+ request TEXT,
15
+ deadline INTEGER NOT NULL,
16
+ created_at INTEGER NOT NULL,
17
+ seen_busy_at INTEGER,
18
+ nudged_at INTEGER,
19
+ closed_at INTEGER,
20
+ outcome TEXT,
21
+ reply_id INTEGER
22
+ )`);
23
+ db.exec(
24
+ `CREATE INDEX IF NOT EXISTS idx_bus_waits_open ON bus_waits(target_id) WHERE closed_at IS NULL`
25
+ );
26
+ db.exec(
27
+ `CREATE INDEX IF NOT EXISTS idx_bus_waits_waiter ON bus_waits(waiter_id) WHERE closed_at IS NULL`
28
+ );
29
+ const cols = db.prepare(`PRAGMA table_info(bus_messages)`).all() as {
30
+ name: string;
31
+ }[];
32
+ if (!cols.some((c) => c.name === "reply_to"))
33
+ db.exec(`ALTER TABLE bus_messages ADD COLUMN reply_to INTEGER`);
34
+ },
35
+ } satisfies Migration;
@@ -0,0 +1,18 @@
1
+ import type { Migration } from "../migration-types";
2
+ import { cleanMachineName } from "../../peer/name";
3
+
4
+ export default {
5
+ key: "20261020T1700_clean_connect_names",
6
+ up: (db) => {
7
+ // Connect machines linked before names were cleaned in full (lib/peer/name.ts)
8
+ // keep the name the service gave them; clean those once.
9
+ const rows = db
10
+ .prepare(`SELECT id, name FROM hosts WHERE kind = 'connect'`)
11
+ .all() as { id: string; name: string }[];
12
+ const set = db.prepare(`UPDATE hosts SET name = ? WHERE id = ?`);
13
+ for (const r of rows) {
14
+ const clean = cleanMachineName(r.name);
15
+ if (clean !== r.name) set.run(clean, r.id);
16
+ }
17
+ },
18
+ } satisfies Migration;
@@ -0,0 +1,49 @@
1
+ import type { Migration } from "../migration-types";
2
+ import {
3
+ installChangeTriggers,
4
+ TABLES_WATCHED_SESSION_GROUPS,
5
+ } from "../changes";
6
+ import { createSessionRowsView } from "../session-parts/view";
7
+
8
+ export default {
9
+ key: "20261020T1800_session_groups",
10
+ up: (db) => {
11
+ // Named shelves a user files sessions into (lib/session-groups). Not the
12
+ // old `groups` table, which nothing reads. Additive.
13
+ db.exec(`CREATE TABLE IF NOT EXISTS session_groups (
14
+ id TEXT PRIMARY KEY,
15
+ workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
16
+ name TEXT NOT NULL,
17
+ sort_order INTEGER NOT NULL DEFAULT 0,
18
+ collapsed INTEGER NOT NULL DEFAULT 0,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
21
+ deleted_at TEXT
22
+ )`);
23
+ db.exec(
24
+ `CREATE UNIQUE INDEX IF NOT EXISTS session_groups_name
25
+ ON session_groups(COALESCE(workspace_id, ''), lower(name))
26
+ WHERE deleted_at IS NULL`
27
+ );
28
+ const has = (
29
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
30
+ ).some((c) => c.name === "session_group_id");
31
+ if (!has)
32
+ db.exec(
33
+ `ALTER TABLE sessions ADD COLUMN session_group_id TEXT
34
+ REFERENCES session_groups(id) ON DELETE SET NULL`
35
+ );
36
+ db.exec(
37
+ `CREATE INDEX IF NOT EXISTS sessions_session_group
38
+ ON sessions(session_group_id)`
39
+ );
40
+ // A queued task remembers the group it starts in.
41
+ const queueHas = (
42
+ db.prepare(`PRAGMA table_info(task_queue)`).all() as { name: string }[]
43
+ ).some((c) => c.name === "session_group_id");
44
+ if (!queueHas)
45
+ db.exec(`ALTER TABLE task_queue ADD COLUMN session_group_id TEXT`);
46
+ createSessionRowsView(db);
47
+ installChangeTriggers(db, TABLES_WATCHED_SESSION_GROUPS);
48
+ },
49
+ } satisfies Migration;
@@ -0,0 +1,13 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T1900_task_queue_after_claim",
5
+ up: (db) => {
6
+ // The work claim (lib/claims) a queued task waits on, by claim id.
7
+ const cols = db.prepare(`PRAGMA table_info(task_queue)`).all() as {
8
+ name: string;
9
+ }[];
10
+ if (!cols.some((c) => c.name === "after_claim"))
11
+ db.exec(`ALTER TABLE task_queue ADD COLUMN after_claim TEXT`);
12
+ },
13
+ } satisfies Migration;
@@ -0,0 +1,20 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T2000_claim_notices",
5
+ up: (db) => {
6
+ // What a mid-work overlap between two claims has told the local session:
7
+ // how many syncs it has held, the shared paths last told, and whether the
8
+ // winner's merge has been passed on. Times are this machine's clock in ms.
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS claim_notices (
11
+ pair_key TEXT PRIMARY KEY,
12
+ hits INTEGER NOT NULL DEFAULT 0,
13
+ shared TEXT NOT NULL DEFAULT '[]',
14
+ told_at INTEGER,
15
+ merged_at INTEGER,
16
+ seen_at INTEGER NOT NULL
17
+ );
18
+ `);
19
+ },
20
+ } satisfies Migration;
@@ -0,0 +1,21 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T2100_claims_via_host",
5
+ up: (db) => {
6
+ // The link (hosts.id) a remote claim arrived through, and which claims
7
+ // machine each link talks to, so a machine linked twice is asked once.
8
+ const cols = db.prepare(`PRAGMA table_info(work_claims)`).all() as {
9
+ name: string;
10
+ }[];
11
+ if (!cols.some((c) => c.name === "via_host"))
12
+ db.exec(`ALTER TABLE work_claims ADD COLUMN via_host TEXT`);
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS claim_peers (
15
+ host_id TEXT PRIMARY KEY,
16
+ machine_id TEXT NOT NULL,
17
+ seen_at INTEGER NOT NULL
18
+ );
19
+ `);
20
+ },
21
+ } satisfies Migration;
@@ -0,0 +1,10 @@
1
+ import type { Migration } from "../migration-types";
2
+ import { installChangeTriggers, TABLES_WATCHED_WORK_CLAIMS } from "../changes";
3
+
4
+ export default {
5
+ key: "20261020T2200_work_claims_watch",
6
+ up: (db) => {
7
+ // The claims sheet and the overlap pills refetch when a claim changes.
8
+ installChangeTriggers(db, TABLES_WATCHED_WORK_CLAIMS);
9
+ },
10
+ } satisfies Migration;
package/lib/db/queries.ts CHANGED
@@ -94,24 +94,6 @@ export const queries = {
94
94
  `UPDATE sessions SET worktree_path = ?, branch_name = ?, base_branch = ?, dev_server_port = ?, updated_at = datetime('now') WHERE id = ?`
95
95
  ),
96
96
 
97
- updateSessionGroup: (db: Database.Database) =>
98
- getStmt(
99
- db,
100
- `UPDATE sessions SET group_path = ?, updated_at = datetime('now') WHERE id = ?`
101
- ),
102
-
103
- getSessionsByGroup: (db: Database.Database) =>
104
- getStmt(
105
- db,
106
- `SELECT * FROM session_rows WHERE group_path = ? ORDER BY updated_at DESC`
107
- ),
108
-
109
- moveSessionsToGroup: (db: Database.Database) =>
110
- getStmt(
111
- db,
112
- `UPDATE sessions SET group_path = ?, updated_at = datetime('now') WHERE group_path = ?`
113
- ),
114
-
115
97
  updateSessionProject: (db: Database.Database) =>
116
98
  getStmt(
117
99
  db,
@@ -196,28 +178,6 @@ export const queries = {
196
178
  `SELECT * FROM tool_calls WHERE message_id = ? ORDER BY timestamp ASC`
197
179
  ),
198
180
 
199
- // Groups
200
- getAllGroups: (db: Database.Database) =>
201
- getStmt(db, `SELECT * FROM groups ORDER BY sort_order ASC, name ASC`),
202
-
203
- getGroup: (db: Database.Database) =>
204
- getStmt(db, `SELECT * FROM groups WHERE path = ?`),
205
-
206
- createGroup: (db: Database.Database) =>
207
- getStmt(db, `INSERT INTO groups (path, name, sort_order) VALUES (?, ?, ?)`),
208
-
209
- updateGroupName: (db: Database.Database) =>
210
- getStmt(db, `UPDATE groups SET name = ? WHERE path = ?`),
211
-
212
- updateGroupExpanded: (db: Database.Database) =>
213
- getStmt(db, `UPDATE groups SET expanded = ? WHERE path = ?`),
214
-
215
- updateGroupOrder: (db: Database.Database) =>
216
- getStmt(db, `UPDATE groups SET sort_order = ? WHERE path = ?`),
217
-
218
- deleteGroup: (db: Database.Database) =>
219
- getStmt(db, `DELETE FROM groups WHERE path = ?`),
220
-
221
181
  // Projects
222
182
  createProject: (db: Database.Database) =>
223
183
  getStmt(
@@ -183,4 +183,5 @@ export const SESSION_ROW_COLUMNS: readonly string[] = [
183
183
  "started_via",
184
184
  "plan_waits_for_caller",
185
185
  "plan_handoff_message",
186
+ "session_group_id",
186
187
  ];
package/lib/db/types.ts CHANGED
@@ -90,11 +90,27 @@ export interface Session {
90
90
  pause_id?: number | null;
91
91
  // Why a requested resume hasn't happened yet.
92
92
  pause_note?: string | null;
93
+ // Set only by GET /api/sessions: the sessions it waits on, and who waits on
94
+ // it, as a short line (lib/bus/waits/store).
95
+ waiting_on?: string | null;
96
+ waited_by?: string | null;
97
+ // Set only by GET /api/sessions: the strongest overlap of this session's
98
+ // work with a claim here or on a linked machine (lib/claims/overlapCache).
99
+ overlap?: {
100
+ strength: "strong" | "weak";
101
+ machine: string;
102
+ goal: string;
103
+ shared: number;
104
+ claimId: string;
105
+ mineId: string;
106
+ } | null;
93
107
  placed_note?: string | null;
94
108
  // Set by done: hidden from the sidebar and the orchestrator, never deleted.
95
109
  archived_at: string | null;
96
110
  // On the sidebar's Pinned shelf (0/1 from SQLite).
97
111
  pinned: boolean;
112
+ // The user's group it sits in (session_groups), or null.
113
+ session_group_id?: string | null;
98
114
  // A workspace's standing orchestrator chat (and that workspace), or null.
99
115
  role: "orchestrator" | null;
100
116
  // An orchestrator's tool-call secret.
@@ -161,14 +177,6 @@ export interface Session {
161
177
  worker_status: "pending" | "running" | "completed" | "failed" | null;
162
178
  }
163
179
 
164
- export interface Group {
165
- path: string;
166
- name: string;
167
- expanded: boolean;
168
- sort_order: number;
169
- created_at: string;
170
- }
171
-
172
180
  // production: every merge gate applies. fast: a task's PR merges when it opens.
173
181
  export type ProjectTier = "production" | "fast";
174
182
 
@@ -310,4 +318,8 @@ export interface Host {
310
318
  kind?: "ssh" | "connect";
311
319
  // Paired with that machine's own AgentOS (lib/hosts/link.ts); 0/1 from SQLite.
312
320
  linked?: boolean | number;
321
+ // Connect hosts (lib/peer): the link's state, and why it's suspended or
322
+ // who unlinked it.
323
+ peer_state?: "active" | "suspended" | "unlinked" | null;
324
+ peer_note?: string | null;
313
325
  }
@@ -5,7 +5,11 @@
5
5
  */
6
6
 
7
7
  import { execFile } from "child_process";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
8
11
  import { promisify } from "util";
12
+ import { agentOsHome, liveCheckout } from "../dev-instance/liveServer";
9
13
  import { probeAgents, type AgentProbe } from "../agents/probe";
10
14
  import { getAllProviderDefinitions } from "../providers/registry";
11
15
 
@@ -37,12 +41,14 @@ export interface DoctorDeps {
37
41
  exec: ExecFn;
38
42
  probe: (fresh: boolean) => Promise<Record<string, AgentProbe>>;
39
43
  nodeVersion: string;
44
+ cliLinks: () => DoctorCheck | null;
40
45
  }
41
46
 
42
47
  const defaultDeps: DoctorDeps = {
43
48
  exec: promisify(execFile) as ExecFn,
44
49
  probe: probeAgents,
45
50
  nodeVersion: process.versions.node,
51
+ cliLinks: () => checkCliLinks(),
46
52
  };
47
53
 
48
54
  const INSTALL = {
@@ -190,6 +196,62 @@ export function checkAgents(probes: Record<string, AgentProbe>): DoctorCheck {
190
196
  };
191
197
  }
192
198
 
199
+ const CLI_LINKS: Array<[string, string]> = [
200
+ ["agent-os", "scripts/agent-os"],
201
+ ["aos", "bin/aos"],
202
+ ];
203
+
204
+ const real = (p: string) => {
205
+ try {
206
+ return fs.realpathSync(p);
207
+ } catch {
208
+ return null;
209
+ }
210
+ };
211
+
212
+ /**
213
+ * Do the CLI links in ~/.local/bin run the checkout the live server runs from?
214
+ * A link into an older clone runs an old `aos`. Null when there is no live
215
+ * server record or no link to judge.
216
+ */
217
+ export function checkCliLinks(
218
+ binDir = process.env.AOS_BIN_DIR || path.join(os.homedir(), ".local/bin"),
219
+ home = agentOsHome()
220
+ ): DoctorCheck | null {
221
+ const live = liveCheckout(home);
222
+ if (!live) return null;
223
+ const stale: string[] = [];
224
+ let seen = 0;
225
+ for (const [name, rel] of CLI_LINKS) {
226
+ const link = path.join(binDir, name);
227
+ let target: string;
228
+ try {
229
+ target = fs.readlinkSync(link);
230
+ } catch {
231
+ continue;
232
+ }
233
+ seen++;
234
+ const want = real(path.join(live, rel));
235
+ if (!want || real(path.resolve(binDir, target)) !== want)
236
+ stale.push(`${name} -> ${target}`);
237
+ }
238
+ if (seen === 0) return null;
239
+ if (stale.length === 0)
240
+ return {
241
+ id: "cli-links",
242
+ label: "CLI links",
243
+ status: "ok",
244
+ detail: `Point at ${live}`,
245
+ };
246
+ return {
247
+ id: "cli-links",
248
+ label: "CLI links",
249
+ status: "warn",
250
+ detail: `${stale.join(", ")}; the live server runs from ${live}`,
251
+ fix: `${live}/scripts/sync-cli-links`,
252
+ };
253
+ }
254
+
193
255
  let cache: { at: number; checks: Promise<DoctorCheck[]> } | null = null;
194
256
 
195
257
  /** Every check, in parallel, cached for a minute. `fresh` runs them again. */
@@ -214,7 +276,8 @@ export function runDoctor(
214
276
  detail: "Couldn't check the agent CLIs",
215
277
  fix: "Check again",
216
278
  })),
217
- ]);
279
+ Promise.resolve(d.cliLinks()),
280
+ ]).then((all) => all.filter((c): c is DoctorCheck => c !== null));
218
281
  if (!custom) cache = { at: Date.now(), checks };
219
282
  return checks;
220
283
  }
@@ -4,6 +4,7 @@
4
4
  import fs from "fs";
5
5
  import { db, queries, type Session } from "../db";
6
6
  import { statusOf } from "../orchestrator/facts";
7
+ import { kickWaits } from "../bus/waits/kick";
7
8
  import { peerSessionLink } from "../hosts/peer-actions";
8
9
 
9
10
  export interface ArchivedView {
@@ -26,6 +27,7 @@ export function archiveSession(id: string): void {
26
27
  db.prepare(
27
28
  `UPDATE sessions SET archived_at = datetime('now') WHERE id = ? AND archived_at IS NULL`
28
29
  ).run(id);
30
+ kickWaits();
29
31
  }
30
32
 
31
33
  export function unarchiveSession(id: string): Session {
package/lib/git-remote.ts CHANGED
@@ -12,6 +12,36 @@ export function slugOfRemote(url: string | null | undefined): string | null {
12
12
  return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
13
13
  }
14
14
 
15
+ // A repository's key across clones and URL forms: ssh and https of the same
16
+ // remote give the same key, with no scheme, credentials, port or ".git".
17
+ // "host/path", lower case; null for anything that isn't a remote URL.
18
+ export function repoKeyOfRemote(url: string | null | undefined): string | null {
19
+ const raw = (url ?? "").trim();
20
+ if (!raw) return null;
21
+ let host = "";
22
+ let path = "";
23
+ const scp = /^(?:[^@/:\s]+@)?([^/:\s]+):(?!\/\/)(.+)$/.exec(raw);
24
+ if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
25
+ host = scp[1];
26
+ path = scp[2];
27
+ } else {
28
+ try {
29
+ const u = new URL(raw);
30
+ if (!/^(https?|ssh|git):$/.test(u.protocol)) return null;
31
+ host = u.hostname;
32
+ path = u.pathname;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ path = path
38
+ .replace(/^\/+|\/+$/g, "")
39
+ .replace(/\.git$/i, "")
40
+ .toLowerCase();
41
+ host = host.toLowerCase();
42
+ return host && path.includes("/") ? `${host}/${path}` : null;
43
+ }
44
+
15
45
  // A remote's URL doesn't change under a running server often enough to ask
16
46
  // git on every lookup.
17
47
  const SLUG_TTL_MS = 5 * 60 * 1000;
@@ -45,3 +75,16 @@ export function repoSlugSync(dir: string): string | null {
45
75
  slugs.set(dir, { at: Date.now(), slug });
46
76
  return slug;
47
77
  }
78
+
79
+ const keys = new Map<string, { at: number; key: string | null }>();
80
+
81
+ export async function repoKey(dir: string): Promise<string | null> {
82
+ const hit = keys.get(dir);
83
+ if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.key;
84
+ const url = await git(dir, ["config", "--get", "remote.origin.url"], {
85
+ timeout: 10000,
86
+ }).catch(() => "");
87
+ const key = repoKeyOfRemote(url);
88
+ keys.set(dir, { at: Date.now(), key });
89
+ return key;
90
+ }
@@ -29,7 +29,16 @@ export function listRemoteHosts(): Host[] {
29
29
  `SELECT h.*, CASE WHEN h.kind = 'connect'
30
30
  THEN EXISTS (SELECT 1 FROM peer_links p WHERE p.host_id = h.id)
31
31
  ELSE EXISTS (SELECT 1 FROM host_links l WHERE l.host_id = h.id)
32
- END AS linked
32
+ END AS linked,
33
+ CASE WHEN h.kind = 'connect' THEN COALESCE(
34
+ (SELECT p.state FROM peer_links p WHERE p.host_id = h.id),
35
+ (SELECT 'unlinked' FROM peer_unlinked u WHERE u.host_id = h.id LIMIT 1))
36
+ END AS peer_state,
37
+ CASE WHEN h.kind = 'connect' THEN COALESCE(
38
+ (SELECT p.suspended_reason FROM peer_links p WHERE p.host_id = h.id),
39
+ (SELECT u.reason FROM peer_unlinked u WHERE u.host_id = h.id
40
+ ORDER BY u.at DESC LIMIT 1))
41
+ END AS peer_note
33
42
  FROM hosts h ORDER BY sort_order, created_at`
34
43
  )
35
44
  .all() as Host[];
@@ -19,6 +19,7 @@ import { UNSET_SERVER_ENV } from "./agents/server-env";
19
19
  import { runInBackground } from "./async-operations";
20
20
  import { tmux } from "./tmux/exec";
21
21
  import { endTmuxSession } from "./tmux/end";
22
+ import { inheritGroup } from "@/lib/session-groups/assignSessions";
22
23
 
23
24
  export interface SpawnWorkerOptions {
24
25
  conductorSessionId: string;
@@ -146,6 +147,7 @@ export async function spawnWorker(
146
147
  agentType,
147
148
  "uncategorized" // project_id
148
149
  );
150
+ inheritGroup(conductorSessionId, sessionId);
149
151
 
150
152
  // Update worktree info if created
151
153
  if (worktreePath) {