@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,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
@@ -94,11 +94,23 @@ export interface Session {
94
94
  // it, as a short line (lib/bus/waits/store).
95
95
  waiting_on?: string | null;
96
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;
97
107
  placed_note?: string | null;
98
108
  // Set by done: hidden from the sidebar and the orchestrator, never deleted.
99
109
  archived_at: string | null;
100
110
  // On the sidebar's Pinned shelf (0/1 from SQLite).
101
111
  pinned: boolean;
112
+ // The user's group it sits in (session_groups), or null.
113
+ session_group_id?: string | null;
102
114
  // A workspace's standing orchestrator chat (and that workspace), or null.
103
115
  role: "orchestrator" | null;
104
116
  // An orchestrator's tool-call secret.
@@ -165,14 +177,6 @@ export interface Session {
165
177
  worker_status: "pending" | "running" | "completed" | "failed" | null;
166
178
  }
167
179
 
168
- export interface Group {
169
- path: string;
170
- name: string;
171
- expanded: boolean;
172
- sort_order: number;
173
- created_at: string;
174
- }
175
-
176
180
  // production: every merge gate applies. fast: a task's PR merges when it opens.
177
181
  export type ProjectTier = "production" | "fast";
178
182
 
@@ -314,4 +318,8 @@ export interface Host {
314
318
  kind?: "ssh" | "connect";
315
319
  // Paired with that machine's own AgentOS (lib/hosts/link.ts); 0/1 from SQLite.
316
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;
317
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
  }
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) {
@@ -22,6 +22,7 @@ import {
22
22
  import { assertMovable, createTask, dropTask, moveTask } from "../tasks";
23
23
  import { getHost, hostIdNamed } from "../hosts";
24
24
  import { queueEvent } from "./events";
25
+ import { claimQueuedEvent, claimWait } from "./claim-wait";
25
26
  import {
26
27
  findQueued,
27
28
  queuedLabel,
@@ -45,6 +46,7 @@ import { braked } from "./brakes";
45
46
  import { getOrchestrator } from "./home";
46
47
  import { addNote } from "./notes";
47
48
  import { findWorkspaceSession } from "./read";
49
+ import { assignSessions, resolveGroup } from "../session-groups";
48
50
  import {
49
51
  assertTakesMessages,
50
52
  findQueuedTarget,
@@ -162,7 +164,9 @@ export async function startTask(
162
164
  machine?: "mac" | "any";
163
165
  now?: boolean;
164
166
  nowReason?: string;
167
+ ignoreClaims?: boolean;
165
168
  piece?: string;
169
+ group?: string;
166
170
  } = {}
167
171
  ): Promise<string> {
168
172
  if (opts.planOnly && opts.plan === false)
@@ -170,6 +174,7 @@ export async function startTask(
170
174
  if (opts.now && !opts.nowReason)
171
175
  throw new Error("now needs now_reason: say why it can't wait for a slot");
172
176
  const project = workspaceProject(workspaceId, projectRef);
177
+ const groupId = groupIdOf(workspaceId, opts.group);
173
178
  const model = parseOverride(opts.model) ?? undefined;
174
179
  const startedBy = orchestratorId(workspaceId);
175
180
  const pieceId = opts.piece
@@ -182,6 +187,19 @@ export async function startTask(
182
187
  );
183
188
  const view = opts.view ?? DEFAULT_START_VIEW;
184
189
  const slots = slotState(project.id);
190
+ // Work claimed elsewhere: a strong overlap queues this start after it,
191
+ // unless it starts now or the caller said to ignore claims.
192
+ const claims = opts.ignoreClaims
193
+ ? null
194
+ : await claimWait(
195
+ { projectId: project.id, name: opts.name, prompt, base: opts.base },
196
+ {
197
+ workspaceId,
198
+ queue: !opts.now && !opts.after,
199
+ }
200
+ );
201
+ const after = opts.after ?? claims?.after;
202
+ const headsUp = claims?.headsUp ?? "";
185
203
  // Held for a slot or its `after`, it starts by itself through the brakes;
186
204
  // you get an event when it does.
187
205
  const out = await startOrQueue(
@@ -199,11 +217,12 @@ export async function startTask(
199
217
  startedVia: "orchestrator",
200
218
  pieceId,
201
219
  baseBranch: opts.base || undefined,
202
- after: opts.after,
220
+ after,
203
221
  originWorkspaceId: workspaceId,
204
222
  priority: opts.priority,
205
223
  machine: opts.machine,
206
224
  now: opts.now,
225
+ groupId,
207
226
  },
208
227
  () =>
209
228
  braked(
@@ -224,14 +243,17 @@ export async function startTask(
224
243
  parentSessionId: startedBy,
225
244
  startedVia: "orchestrator",
226
245
  pieceId,
246
+ groupId,
227
247
  }),
228
248
  (s) => s.id
229
249
  )
230
250
  );
231
251
  if ("queued" in out) {
232
252
  const { queued } = out;
253
+ if (claims?.strong && !opts.after)
254
+ claimQueuedEvent(workspaceId, queued, claims.strong);
233
255
  const builds = pieceId ? ` It builds ${pieceLabel(pieceId)}.` : "";
234
- return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}.${builds} It starts by itself; you'll get an event when it does.`;
256
+ return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}.${builds} It starts by itself; you'll get an event when it does.${headsUp}`;
235
257
  }
236
258
  const task = out.started;
237
259
  if (opts.priority) stampPriority(task.id, opts.priority);
@@ -248,7 +270,7 @@ export async function startTask(
248
270
  opts.plan && !opts.planOnly && task.plan_phase !== "planning"
249
271
  ? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
250
272
  : "";
251
- return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${pieceId ? ` It builds ${pieceLabel(pieceId)}.` : ""}${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}`;
273
+ return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${pieceId ? ` It builds ${pieceLabel(pieceId)}.` : ""}${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}${headsUp}`;
252
274
  }
253
275
 
254
276
  // Starts a queued task now, past the running-task limit and its `after`.
@@ -299,9 +321,15 @@ export async function startSession(
299
321
  workspaceId: string,
300
322
  projectRef: string,
301
323
  prompt: string,
302
- opts: { name?: string; view?: StartView; model?: string } = {}
324
+ opts: {
325
+ name?: string;
326
+ view?: StartView;
327
+ model?: string;
328
+ group?: string;
329
+ } = {}
303
330
  ): Promise<string> {
304
331
  const project = workspaceProject(workspaceId, projectRef);
332
+ const groupId = groupIdOf(workspaceId, opts.group);
305
333
  const startedBy = orchestratorId(workspaceId);
306
334
  const model = parseOverride(opts.model);
307
335
  if (model && typeof model !== "string")
@@ -326,6 +354,7 @@ export async function startSession(
326
354
  startedBy,
327
355
  parentSessionId: startedBy,
328
356
  startedVia: "orchestrator",
357
+ groupId,
329
358
  })
330
359
  ).session
331
360
  : spawnSession({
@@ -335,6 +364,7 @@ export async function startSession(
335
364
  model,
336
365
  startedBy,
337
366
  parentSessionId: startedBy,
367
+ groupId,
338
368
  }),
339
369
  (s) => s.id
340
370
  );
@@ -408,6 +438,26 @@ export async function drop(
408
438
  return `Dropped ${task.name}: its PR is closed and its worktree removed.`;
409
439
  }
410
440
 
441
+ // One of Saad's groups in the workspace by name; the error lists the ones
442
+ // there are. The orchestrator files into groups, it never creates them.
443
+ function groupIdOf(workspaceId: string, name?: string): string | undefined {
444
+ return name ? resolveGroup(workspaceId, name).id : undefined;
445
+ }
446
+
447
+ // Files a session of this workspace in a group, or out of its group (null).
448
+ export async function setGroup(
449
+ workspaceId: string,
450
+ ref: string,
451
+ group: string | null
452
+ ): Promise<string> {
453
+ const s = findWorkspaceSession(workspaceId, ref);
454
+ const target = group === null ? null : resolveGroup(workspaceId, group);
455
+ assignSessions([s.id], target?.id ?? null);
456
+ return target
457
+ ? `Filed ${s.name} in "${target.name}".`
458
+ : `Took ${s.name} out of its group.`;
459
+ }
460
+
411
461
  // Finished work: merged through the gates if its PR is open, then
412
462
  // stopped and cleaned up, then left on the Done shelf (lib/done).
413
463
  export async function done(workspaceId: string, ref: string): Promise<string> {
@@ -122,11 +122,12 @@ Reading:
122
122
  Acting:
123
123
  - \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
124
124
  - \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
125
- - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task in a production project (see "Triage every job"); a fast project skips the plan unless you pass plan true. Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
125
+ - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, ignore_claims?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. If another session (here or on a linked machine) has claimed the same branch or files, it is queued after that work and you get an event saying so; pass ignore_claims only when the overlap is wrong. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task in a production project (see "Triage every job"); a fast project skips the plan unless you pass plan true. Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
126
126
  - \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
127
127
  - \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
128
128
  - \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
129
129
  - \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?, model?): a session without a worktree or PR, a chat unless view is terminal. It runs on the implementation model (Sonnet) unless you pass model; use it for work outside a repository, with the steps in the prompt.
130
+ - \`${TOOL_NAMES.set_group}\` (session, group): file a session in one of Saad's groups by name, or out of its group with null. Saad's groups organise his sidebar: put a task in one when he asks, or when it continues work that's already in that group. \`start_task\` and \`start_session\` take a group too. A group has to exist: you can't create, rename or delete one, so if he asks for one that doesn't, say so.
130
131
  - \`${TOOL_NAMES.recipes}\` (name?): the workspace's recipes with their last runs; with a name, one in full.
131
132
  - \`${TOOL_NAMES.run_recipe}\` (name, note?): run a recipe now: a fresh task or session from its saved brief. See "Recipes".
132
133
  - \`${TOOL_NAMES.recipe_save}\` (name, project, kind, brief, model?, view?): save a recipe (kind task ends in a PR; session has none).
@@ -0,0 +1,71 @@
1
+ // What the orchestrator's start_task does about work claimed elsewhere: a
2
+ // strong overlap queues the start after that claim, any other overlap is a
3
+ // heads-up. A person's start never comes through here.
4
+
5
+ import { checkStart, overlapText } from "../claims/startCheck";
6
+ import { machineId } from "../claims/machine";
7
+ import { IN_FLIGHT, resolveAfter, sessionOf } from "../tasks/queue-rows";
8
+ import { queueEvent } from "./events";
9
+ import type { Overlap } from "../claims/types";
10
+
11
+ export interface ClaimWait {
12
+ // The `after` to queue with: a local task's id, or "claim:<id>".
13
+ after?: string;
14
+ // The overlap that set it, for the event once the start is queued.
15
+ strong: Overlap | null;
16
+ headsUp: string;
17
+ }
18
+
19
+ const isLocalTask = (sessionId: string) => {
20
+ const s = sessionOf(sessionId);
21
+ return !!s?.task_status && IN_FLIGHT.includes(s.task_status);
22
+ };
23
+
24
+ // A local task is waited on as a task (the existing `after`); anything else
25
+ // as a claim.
26
+ function afterOf(o: Overlap, workspaceId: string): string | undefined {
27
+ const c = o.b;
28
+ if (c.origin !== machineId() || !c.sessionId) return `claim:${c.id}`;
29
+ if (!isLocalTask(c.sessionId)) return `claim:${c.id}`;
30
+ try {
31
+ return resolveAfter(c.sessionId, workspaceId).id;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ export async function claimWait(
38
+ start: { projectId: string; name?: string; prompt: string; base?: string },
39
+ opts: { workspaceId: string; queue: boolean }
40
+ ): Promise<ClaimWait> {
41
+ const check = await checkStart(start);
42
+ if (!check.overlaps.length) return { strong: null, headsUp: "" };
43
+ const strong =
44
+ (opts.queue && check.overlaps.find((o) => o.strength === "strong")) || null;
45
+ const after = strong ? afterOf(strong, opts.workspaceId) : undefined;
46
+ const lines = check.overlaps
47
+ .slice(0, 3)
48
+ .map((o) => `- ${o.strength}: ${overlapText(o)}`);
49
+ return {
50
+ after,
51
+ strong: after ? strong : null,
52
+ headsUp: `\nHeads-up, work already claimed:\n${lines.join("\n")}`,
53
+ };
54
+ }
55
+
56
+ // Tells the orchestrator it was queued behind a claim, and how to override.
57
+ export function claimQueuedEvent(
58
+ workspaceId: string,
59
+ queued: { id: string; name: string },
60
+ o: Overlap
61
+ ): void {
62
+ const shared = o.shared.length
63
+ ? ` (shared: ${o.shared.slice(0, 5).join(", ")})`
64
+ : "";
65
+ queueEvent(
66
+ workspaceId,
67
+ `claim-queued:${queued.id}`,
68
+ queued.id,
69
+ `Queued "${queued.name}" (id ${queued.id.slice(0, 8)}) after ${overlapText(o)}${shared}. It starts when that work ends; start_now overrides, or drop it.`
70
+ );
71
+ }