@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
@@ -0,0 +1,87 @@
1
+ // Whether two live claims are working on the same thing. Pure: the same two
2
+ // claims give the same answer on every machine, which the yield rule needs.
3
+
4
+ import { goalsAlike } from "./goal";
5
+ import type { Claim, Overlap } from "./types";
6
+
7
+ const NOISE =
8
+ /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Gemfile\.lock|Cargo\.lock|go\.sum|poetry\.lock|uv\.lock|composer\.lock|CHANGELOG[^/]*|__snapshots__\/.*|migrations\.generated\.ts)$/i;
9
+
10
+ export const isNoise = (path: string) =>
11
+ NOISE.test(path) || /(^|\/)__snapshots__\//.test(path);
12
+
13
+ // A path this many claims touch is a shared index or router, not a signal.
14
+ export const HUB_CLAIMS = 4;
15
+
16
+ const isBase = (b: string | null, c: Claim, o: Claim) =>
17
+ !b ||
18
+ b === "HEAD" ||
19
+ b === "main" ||
20
+ b === "master" ||
21
+ b === c.base ||
22
+ b === o.base;
23
+
24
+ function sameWork(a: Claim, b: Claim): boolean {
25
+ if (a.id === b.id) return true;
26
+ if (a.sessionId && a.sessionId === b.sessionId && a.origin === b.origin)
27
+ return true;
28
+ return !!a.branch && a.origin === b.origin && a.branch === b.branch;
29
+ }
30
+
31
+ export function overlapBetween(
32
+ a: Claim,
33
+ b: Claim,
34
+ hubs: ReadonlySet<string> = new Set()
35
+ ): Overlap | null {
36
+ if (a.repo !== b.repo || sameWork(a, b)) return null;
37
+ const kinds: Overlap["kinds"] = [];
38
+ let strong = false;
39
+
40
+ if (a.branch && a.branch === b.branch && !isBase(a.branch, a, b)) {
41
+ kinds.push("branch");
42
+ strong = true;
43
+ }
44
+
45
+ const fa = new Set(a.files.filter((f) => !isNoise(f)));
46
+ const fb = new Set(b.files.filter((f) => !isNoise(f)));
47
+ let shared = [...fa].filter((f) => fb.has(f));
48
+ if (shared.length && shared.every((f) => hubs.has(f))) shared = [];
49
+ const smaller = Math.min(fa.size, fb.size);
50
+ const ratio = smaller ? shared.length / smaller : 0;
51
+ const alike = goalsAlike(a.goal, b.goal);
52
+ if (shared.length && (shared.length >= 2 || ratio >= 0.3)) {
53
+ kinds.push("files");
54
+ if (shared.length >= 3 || ratio >= 0.5 || alike) strong = true;
55
+ }
56
+ if (alike) kinds.push("goal");
57
+ if (!kinds.length) return null;
58
+ return {
59
+ a,
60
+ b,
61
+ kinds,
62
+ shared: shared.sort(),
63
+ strength: strong ? "strong" : "weak",
64
+ };
65
+ }
66
+
67
+ export function findOverlaps(claims: Claim[]): Overlap[] {
68
+ const live = claims.filter((c) => !c.released);
69
+ const byRepo = new Map<string, Claim[]>();
70
+ for (const c of live) byRepo.set(c.repo, [...(byRepo.get(c.repo) ?? []), c]);
71
+ const out: Overlap[] = [];
72
+ for (const group of byRepo.values()) {
73
+ const touched = new Map<string, number>();
74
+ for (const c of group)
75
+ for (const f of new Set(c.files))
76
+ touched.set(f, (touched.get(f) ?? 0) + 1);
77
+ const hubs = new Set(
78
+ [...touched].filter(([, n]) => n >= HUB_CLAIMS).map(([f]) => f)
79
+ );
80
+ for (let i = 0; i < group.length; i++)
81
+ for (let j = i + 1; j < group.length; j++) {
82
+ const o = overlapBetween(group[i], group[j], hubs);
83
+ if (o) out.push(o);
84
+ }
85
+ }
86
+ return out;
87
+ }
@@ -0,0 +1,86 @@
1
+ // The overlaps of this machine's sessions, worked out once a sync tick so
2
+ // /api/sessions needs no git and no pairwise pass per request.
3
+
4
+ import { findOverlaps } from "./overlap";
5
+ import { machineId } from "./machine";
6
+ import { liveClaims } from "./store";
7
+ import { overlapView, type OverlapView } from "./view";
8
+ import type { Claim } from "./types";
9
+
10
+ // A session row's pill: the strongest overlap it has.
11
+ export interface SessionOverlap {
12
+ strength: "strong" | "weak";
13
+ machine: string;
14
+ goal: string;
15
+ shared: number;
16
+ // This session's own claim and the other one: the pair a pill opens.
17
+ claimId: string;
18
+ mineId: string;
19
+ }
20
+
21
+ // A claim without its paths: enough to list on a phone.
22
+ export type ClaimSummary = Omit<Claim, "files"> & { fileCount: number };
23
+
24
+ export interface ClaimsSnapshot {
25
+ claims: ClaimSummary[];
26
+ overlaps: OverlapView[];
27
+ bySession: Map<string, SessionOverlap>;
28
+ }
29
+
30
+ const EMPTY: ClaimsSnapshot = {
31
+ claims: [],
32
+ overlaps: [],
33
+ bySession: new Map(),
34
+ };
35
+ // server.ts (the loop) and the route bundles are separate module graphs, so
36
+ // the snapshot lives on globalThis to be one.
37
+ const g = globalThis as unknown as { __agentosClaimsSnapshot?: ClaimsSnapshot };
38
+ const get = () => g.__agentosClaimsSnapshot ?? EMPTY;
39
+
40
+ export const summarize = (c: Claim): ClaimSummary => {
41
+ const { files, ...rest } = c;
42
+ return { ...rest, fileCount: files.length + c.filesMore };
43
+ };
44
+
45
+ const rank = (o: SessionOverlap) =>
46
+ (o.strength === "strong" ? 1e6 : 0) + o.shared;
47
+
48
+ export function buildSnapshot(claims: Claim[], self: string): ClaimsSnapshot {
49
+ const bySession = new Map<string, SessionOverlap>();
50
+ const overlaps = findOverlaps(claims);
51
+ for (const o of overlaps)
52
+ for (const [mine, other] of [
53
+ [o.a, o.b],
54
+ [o.b, o.a],
55
+ ] as const) {
56
+ if (mine.origin !== self || !mine.sessionId) continue;
57
+ const next: SessionOverlap = {
58
+ strength: o.strength,
59
+ machine: other.originName,
60
+ goal: other.goal,
61
+ shared: o.shared.length,
62
+ claimId: other.id,
63
+ mineId: mine.id,
64
+ };
65
+ const have = bySession.get(mine.sessionId);
66
+ if (!have || rank(next) > rank(have)) bySession.set(mine.sessionId, next);
67
+ }
68
+ return {
69
+ claims: claims.map(summarize),
70
+ overlaps: overlaps.map(overlapView),
71
+ bySession,
72
+ };
73
+ }
74
+
75
+ export function refreshOverlapCache(now = Date.now()): void {
76
+ try {
77
+ g.__agentosClaimsSnapshot = buildSnapshot(liveClaims({ now }), machineId());
78
+ } catch (err) {
79
+ console.error("[claims] overlap cache:", err);
80
+ }
81
+ }
82
+
83
+ export const claimsSnapshot = (): ClaimsSnapshot => get();
84
+ export const resetOverlapCache = () => {
85
+ g.__agentosClaimsSnapshot = EMPTY;
86
+ };
@@ -0,0 +1,36 @@
1
+ // What a session or the orchestrator reads about an overlap. The other
2
+ // claim's goal, machine and paths are another machine's words: fenced.
3
+
4
+ import { untrusted } from "../orchestrator/untrusted";
5
+ import type { SessionOverlap } from "./mine";
6
+
7
+ const SHOWN = 5;
8
+
9
+ function where(o: SessionOverlap): string {
10
+ const files = o.shared.slice(0, SHOWN).join(", ");
11
+ const more =
12
+ o.shared.length > SHOWN ? ` and ${o.shared.length - SHOWN} more` : "";
13
+ const what = o.kinds.includes("branch")
14
+ ? "the same branch"
15
+ : "the same files";
16
+ return `${untrusted("claim", `${o.other.goal} on ${o.other.originName}`)} is working on ${what}${files ? `: ${untrusted("claim", files)}${more}` : ""}.`;
17
+ }
18
+
19
+ // The message the local session gets, with the verdict.
20
+ export function sessionMessage(o: SessionOverlap): string {
21
+ const head = `Overlap: ${where(o)}`;
22
+ return o.yields
23
+ ? `${head} The other work is closer to merging, so give way: finish your current step, commit, and don't restructure those files further. If your change depends on theirs, say so (BLOCKED: or tell the orchestrator) rather than copy it. Nothing is paused.`
24
+ : `${head} Yours is closer to merging, so keep going; just don't widen into those files. Nothing is paused.`;
25
+ }
26
+
27
+ export function eventLine(o: SessionOverlap, name: string): string {
28
+ return `${name}: overlaps ${where(o)} ${o.yields ? "It yields." : "The other yields."}`;
29
+ }
30
+
31
+ export function mergedMessage(
32
+ winner: { goal: string; originName: string },
33
+ base: string | null
34
+ ): string {
35
+ return `${untrusted("claim", `${winner.goal} on ${winner.originName}`)} merged, and it overlapped your work. Rebase onto origin/${base ?? "main"} at your next stopping point.`;
36
+ }
@@ -0,0 +1,56 @@
1
+ import { getDb } from "../db";
2
+ import { hostLink, linkedHostIds, type HostLink } from "../hosts/remote-api";
3
+
4
+ export function rememberPeer(
5
+ hostId: string,
6
+ machineId: string,
7
+ now = Date.now()
8
+ ): void {
9
+ getDb()
10
+ .prepare(
11
+ `INSERT INTO claim_peers (host_id, machine_id, seen_at) VALUES (?, ?, ?)
12
+ ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
13
+ seen_at = excluded.seen_at`
14
+ )
15
+ .run(hostId, machineId, now);
16
+ }
17
+
18
+ export function peerMachineOf(hostId: string): string | null {
19
+ const r = getDb()
20
+ .prepare(`SELECT machine_id FROM claim_peers WHERE host_id = ?`)
21
+ .get(hostId) as { machine_id: string } | undefined;
22
+ return r?.machine_id ?? null;
23
+ }
24
+
25
+ // One list per machine, best link first (token, then Connect). A Connect pin
26
+ // that isn't active is left out: peerCall would only refuse it. Links whose
27
+ // machine isn't known yet are each their own machine.
28
+ export function exchangeTargets(): HostLink[][] {
29
+ const groups = new Map<string, HostLink[]>();
30
+ for (const hostId of [...linkedHostIds()].sort()) {
31
+ const link = hostLink(hostId);
32
+ if (!link) continue;
33
+ if (link.connect && link.connect.state !== "active") continue;
34
+ const key = peerMachineOf(hostId) ?? `host:${hostId}`;
35
+ groups.set(key, [...(groups.get(key) ?? []), link]);
36
+ }
37
+ return [...groups.values()].map((g) =>
38
+ [...g].sort(
39
+ (a, b) =>
40
+ Number(a.transport === "connect") - Number(b.transport === "connect")
41
+ )
42
+ );
43
+ }
44
+
45
+ // Claims that arrived through a link that has since been suspended or
46
+ // unlinked stop counting now, not when their lifetime runs out.
47
+ export function dropViaInactive(): number {
48
+ return getDb()
49
+ .prepare(
50
+ `DELETE FROM work_claims WHERE via_host IS NOT NULL
51
+ AND via_host NOT IN (SELECT host_id FROM host_links
52
+ WHERE host_id NOT IN (SELECT id FROM hosts WHERE kind = 'connect'))
53
+ AND via_host NOT IN (SELECT host_id FROM peer_links WHERE state = 'active')`
54
+ )
55
+ .run().changes;
56
+ }
@@ -0,0 +1,100 @@
1
+ // Before a task starts: who else is working on the same thing. The files it
2
+ // will touch are guessed from the prompt (paths that exist in the repo).
3
+ // Any failure means no overlaps: this never blocks a start.
4
+
5
+ import { git } from "../git-exec";
6
+ import { repoKey } from "../git-remote";
7
+ import { getProject } from "../projects";
8
+ import { untrusted } from "../orchestrator/untrusted";
9
+ import { expandHome } from "../tasks/session";
10
+ import { claimsShared, machineId, machineName } from "./machine";
11
+ import { overlapBetween } from "./overlap";
12
+ import { liveClaims } from "./store";
13
+ import { MAX_FILES, type Claim, type Overlap } from "./types";
14
+
15
+ export interface StartCheck {
16
+ // Overlaps of the new start (as `a`) with live claims (as `b`), strong first.
17
+ overlaps: Overlap[];
18
+ }
19
+
20
+ const PATH_LIKE = /[\w@.+-]+(?:\/[\w@.+-]+)*\.[A-Za-z0-9]{1,8}\b/g;
21
+
22
+ async function predictedFiles(dir: string, prompt: string): Promise<string[]> {
23
+ const tokens = [...new Set(prompt.match(PATH_LIKE) ?? [])].slice(0, 100);
24
+ if (!tokens.length) return [];
25
+ const tracked = new Set(
26
+ (await git(dir, ["ls-files", "-z"], { timeout: 5000 }))
27
+ .split("\0")
28
+ .filter(Boolean)
29
+ );
30
+ return tokens.filter((t) => tracked.has(t)).slice(0, MAX_FILES);
31
+ }
32
+
33
+ export async function checkStart(opts: {
34
+ projectId: string;
35
+ name?: string;
36
+ prompt: string;
37
+ branch?: string | null;
38
+ now?: number;
39
+ }): Promise<StartCheck> {
40
+ const none: StartCheck = { overlaps: [] };
41
+ try {
42
+ if (!claimsShared()) return none;
43
+ const project = getProject(opts.projectId);
44
+ if (!project) return none;
45
+ const dir = expandHome(project.working_directory);
46
+ const repo = await repoKey(dir);
47
+ if (!repo) return none;
48
+ const live = liveClaims({ repo, now: opts.now });
49
+ if (!live.length) return none;
50
+ const now = opts.now ?? Date.now();
51
+ const goal = (opts.name?.trim() || opts.prompt.split("\n")[0]).slice(
52
+ 0,
53
+ 120
54
+ );
55
+ const mine: Claim = {
56
+ id: "start",
57
+ origin: machineId(),
58
+ originName: machineName(),
59
+ viaMachine: null,
60
+ hops: 0,
61
+ source: "session",
62
+ sessionId: null,
63
+ sessionName: null,
64
+ repo,
65
+ branch: opts.branch ?? null,
66
+ base: null,
67
+ goal,
68
+ files: await predictedFiles(dir, opts.prompt).catch(() => []),
69
+ filesMore: 0,
70
+ prUrl: null,
71
+ startedAt: now,
72
+ expiresAt: now,
73
+ released: null,
74
+ updatedAt: now,
75
+ };
76
+ const overlaps = live
77
+ .map((c) => overlapBetween(mine, c))
78
+ .filter((o): o is Overlap => !!o)
79
+ .sort(
80
+ (x, y) =>
81
+ Number(y.strength === "strong") - Number(x.strength === "strong")
82
+ );
83
+ return { overlaps };
84
+ } catch (error) {
85
+ console.error("[claims] start check:", error);
86
+ return none;
87
+ }
88
+ }
89
+
90
+ const where = (c: Claim) =>
91
+ c.origin === machineId() ? "here" : `on ${c.originName}`;
92
+
93
+ // "'<goal>' on box (branch x) touches a.ts, b.ts", fenced: another machine's words.
94
+ export function overlapText(o: Overlap): string {
95
+ const shared = o.shared.length
96
+ ? ` touches ${o.shared.slice(0, 5).join(", ")}`
97
+ : "";
98
+ const branch = o.b.branch ? ` (branch ${o.b.branch})` : "";
99
+ return `${untrusted("claim", o.b.goal)} ${where(o.b)}${branch}${shared}`;
100
+ }
@@ -0,0 +1,29 @@
1
+ // An overlap of a new start with a live claim, as the start sheet shows it.
2
+
3
+ import type { Overlap } from "./types";
4
+
5
+ export interface StartWarning {
6
+ strength: Overlap["strength"];
7
+ kinds: Overlap["kinds"];
8
+ shared: string[];
9
+ claim: {
10
+ id: string;
11
+ goal: string;
12
+ machine: string;
13
+ branch: string | null;
14
+ sessionName: string | null;
15
+ };
16
+ }
17
+
18
+ export const startWarning = (o: Overlap): StartWarning => ({
19
+ strength: o.strength,
20
+ kinds: o.kinds,
21
+ shared: o.shared.slice(0, 20),
22
+ claim: {
23
+ id: o.b.id,
24
+ goal: o.b.goal,
25
+ machine: o.b.originName,
26
+ branch: o.b.branch,
27
+ sessionName: o.b.sessionName,
28
+ },
29
+ });