@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
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import http, { type IncomingMessage, type ServerResponse } from "http";
8
+ import { NextRequest } from "next/server";
8
9
  import type { AddressInfo } from "net";
9
10
  import type { FakeMachine } from "@/lib/__fixtures__/peer-machine";
10
11
  import { tunnelListener } from "@/lib/connect/serve";
@@ -30,8 +31,11 @@ export interface Seen {
30
31
  body: string;
31
32
  }
32
33
 
33
- /** What Next would be: echoes how the gate saw the call. */
34
- function fakeNext(seen: Seen[]) {
34
+ /** A real route handler, run behind the gate as Next would run it. */
35
+ export type Route = (req: NextRequest) => Response | Promise<Response>;
36
+
37
+ /** What Next would be: echoes how the gate saw the call, or runs `routes`. */
38
+ function fakeNext(seen: Seen[], routes: Record<string, Route>) {
35
39
  return async (req: IncomingMessage, res: ServerResponse) => {
36
40
  let body = "";
37
41
  for await (const c of req) body += c;
@@ -41,6 +45,22 @@ function fakeNext(seen: Seen[]) {
41
45
  headers: { ...req.headers },
42
46
  body,
43
47
  });
48
+ const route = routes[(req.url ?? "").split("?")[0]];
49
+ if (route) {
50
+ const headers = new Headers();
51
+ for (const [k, v] of Object.entries(req.headers))
52
+ if (typeof v === "string") headers.set(k, v);
53
+ const out = await route(
54
+ new NextRequest(`http://127.0.0.1${req.url}`, {
55
+ method: req.method,
56
+ headers,
57
+ body: body || undefined,
58
+ })
59
+ );
60
+ res.statusCode = out.status;
61
+ res.setHeader("Content-Type", out.headers.get("content-type") ?? "");
62
+ return res.end(Buffer.from(await out.arrayBuffer()));
63
+ }
44
64
  res.setHeader("Content-Type", "application/json");
45
65
  res.setHeader("Set-Cookie", "aos_device=leak");
46
66
  if (req.url?.startsWith("/api/tasks/t-9/merge")) {
@@ -77,7 +97,8 @@ const listen = (s: http.Server) =>
77
97
  /** One machine's AgentOS: its loopback listener and its tunnel listener. */
78
98
  export async function machine(
79
99
  me: FakeMachine,
80
- pinFor: (machineId: string) => PeerPin | null
100
+ pinFor: (machineId: string) => PeerPin | null,
101
+ o: { routes?: Record<string, Route>; unlinked?: RpcDeps["unlinked"] } = {}
81
102
  ): Promise<Machine> {
82
103
  const seen: Seen[] = [];
83
104
  const policy: AuthPolicy = {
@@ -85,12 +106,13 @@ export async function machine(
85
106
  lookup: (t) => (t === "aosd_phone" ? { id: "phone" } : null),
86
107
  peerSecret,
87
108
  };
88
- const next = fakeNext(seen);
109
+ const next = fakeNext(seen, o.routes ?? {});
89
110
  let internalPort = 0;
90
111
  const deps: RpcDeps = {
91
112
  identity: () => me,
92
113
  boxKey: () => me.box,
93
114
  pinForMachine: pinFor,
115
+ unlinked: o.unlinked,
94
116
  anyActive: () => true,
95
117
  replay: createReplayGuard(),
96
118
  dispatch: (d: Parameters<ReturnType<typeof loopbackDispatch>>[0]) =>
@@ -135,36 +157,46 @@ export function edge(
135
157
  const m = machines.find((x) => x.hostname === url.hostname);
136
158
  if (url.protocol !== "https:" || !m) throw new Error("no such host");
137
159
  const body = init?.body ? Buffer.from(init.body as Uint8Array) : undefined;
138
- const { status, buf } = await new Promise<{ status: number; buf: Buffer }>(
139
- (resolve, reject) => {
140
- const out = http.request(
141
- {
142
- host: "127.0.0.1",
143
- port: m.tunnelPort,
144
- method: init?.method ?? "GET",
145
- path: url.pathname + url.search,
146
- headers: {
147
- ...(init?.headers as Record<string, string>),
148
- host: url.hostname,
149
- ...(body && { "content-length": String(body.length) }),
150
- },
160
+ const { status, buf, headers } = await new Promise<{
161
+ status: number;
162
+ buf: Buffer;
163
+ headers: Record<string, string>;
164
+ }>((resolve, reject) => {
165
+ const out = http.request(
166
+ {
167
+ host: "127.0.0.1",
168
+ port: m.tunnelPort,
169
+ method: init?.method ?? "GET",
170
+ path: url.pathname + url.search,
171
+ headers: {
172
+ ...(init?.headers as Record<string, string>),
173
+ host: url.hostname,
174
+ ...(body && { "content-length": String(body.length) }),
151
175
  },
152
- (res) => {
153
- const chunks: Buffer[] = [];
154
- res.on("data", (c) => chunks.push(c));
155
- res.on("end", () =>
156
- resolve({
157
- status: res.statusCode ?? 0,
158
- buf: Buffer.concat(chunks),
159
- })
160
- );
161
- }
162
- );
163
- out.on("error", reject);
164
- out.end(body);
165
- }
166
- );
167
- return new Response(new Uint8Array(tamper ? tamper(buf) : buf), { status });
176
+ },
177
+ (res) => {
178
+ const chunks: Buffer[] = [];
179
+ res.on("data", (c) => chunks.push(c));
180
+ res.on("end", () =>
181
+ resolve({
182
+ status: res.statusCode ?? 0,
183
+ buf: Buffer.concat(chunks),
184
+ headers: Object.fromEntries(
185
+ Object.entries(res.headers).filter(
186
+ (e): e is [string, string] => typeof e[1] === "string"
187
+ )
188
+ ),
189
+ })
190
+ );
191
+ }
192
+ );
193
+ out.on("error", reject);
194
+ out.end(body);
195
+ });
196
+ return new Response(new Uint8Array(tamper ? tamper(buf) : buf), {
197
+ status,
198
+ headers,
199
+ });
168
200
  }) as typeof fetch;
169
201
  }
170
202
 
@@ -8,6 +8,7 @@ import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
8
8
  import { ANTHROPIC } from "../models/roles";
9
9
  import { launchClaude } from "./launch";
10
10
  import { nameFor } from "../session-titles";
11
+ import { fileInGroup } from "../session-groups/assignSessions";
11
12
 
12
13
  // "dashboards" or a project id; case-insensitive on name.
13
14
  export function findProject(ref: string) {
@@ -37,6 +38,8 @@ export async function spawnSession(opts: {
37
38
  // model unless `model` is given.
38
39
  startedBy?: string | null;
39
40
  parentSessionId?: string | null;
41
+ // The user's group it starts in (lib/session-groups).
42
+ groupId?: string;
40
43
  }): Promise<Session> {
41
44
  const prompt = opts.prompt.trim();
42
45
  if (!prompt) throw new Error("Give the new session a prompt");
@@ -91,6 +94,7 @@ export async function spawnSession(opts: {
91
94
  ? "session"
92
95
  : "person",
93
96
  });
97
+ if (opts.groupId) fileInGroup(id, opts.groupId);
94
98
  if (routed) setSessionModel(id, routed);
95
99
  void naming.refine?.(id);
96
100
  await launchClaude({ sessionId: id, tmuxName, cwd, model, prompt });
package/lib/bus/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  waitBadges,
39
39
  } from "./waits/store";
40
40
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
41
+ import { groupNames } from "../session-groups/store";
41
42
 
42
43
  export * from "./format";
43
44
  export {
@@ -64,6 +65,8 @@ export interface Peer {
64
65
  // Names of the sessions it waits on, and of those waiting on it.
65
66
  waitingOn: string[];
66
67
  waitedBy: string[];
68
+ // The user's group it sits in, when it is in one.
69
+ group?: string;
67
70
  }
68
71
 
69
72
  // A chat has no pane: its worker is the state, and it can always take a
@@ -99,6 +102,7 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
99
102
  const caller = sessionById(callerId);
100
103
  const orchestrator = heldOrchestrator(caller);
101
104
  const badges = waitBadges();
105
+ const groups = groupNames();
102
106
  const listed = await Promise.all(
103
107
  sessions.map(async (s, i) => {
104
108
  const info = await peerState(s);
@@ -114,6 +118,10 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
114
118
  orchestratorOf: orchestratorOf(s),
115
119
  waitingOn: badges.get(s.id)?.on.map((w) => w.name) ?? [],
116
120
  waitedBy: badges.get(s.id)?.by.map((w) => w.name) ?? [],
121
+ ...(s.session_group_id &&
122
+ groups.has(s.session_group_id) && {
123
+ group: groups.get(s.session_group_id),
124
+ }),
117
125
  ...(caller?.workspace_id &&
118
126
  s.role === "orchestrator" &&
119
127
  s.workspace_id === caller.workspace_id && { yours: true }),
@@ -0,0 +1,121 @@
1
+ // Turns this machine's live sessions into claims: the repo, branch and the
2
+ // paths their diff touches so far. Only paths leave git, never content.
3
+
4
+ import { getDb } from "../db";
5
+ import { getCurrentBranch, getDefaultBranch } from "../git";
6
+ import { git } from "../git-exec";
7
+ import { repoKey } from "../git-remote";
8
+ import { IN_FLIGHT } from "../tasks/queue-rows";
9
+ import { expandHome } from "../tasks/session";
10
+ import { MAX_FILES } from "./types";
11
+ import {
12
+ localLiveSessionIds,
13
+ release,
14
+ upsertLocal,
15
+ type LocalClaim,
16
+ } from "./store";
17
+
18
+ interface SessionRow {
19
+ id: string;
20
+ name: string;
21
+ working_directory: string;
22
+ worktree_path: string | null;
23
+ branch_name: string | null;
24
+ base_branch: string | null;
25
+ pr_url: string | null;
26
+ pr_status: string | null;
27
+ task_status: string | null;
28
+ }
29
+
30
+ const OPTS = { timeout: 5000 };
31
+
32
+ const paths = (out: string) => out.split("\0").filter(Boolean);
33
+
34
+ export async function changedFiles(
35
+ dir: string,
36
+ base: string
37
+ ): Promise<string[]> {
38
+ const mergeBase = (
39
+ await git(dir, ["merge-base", "HEAD", `origin/${base}`], OPTS).catch(() =>
40
+ git(dir, ["merge-base", "HEAD", base], OPTS).catch(() => "")
41
+ )
42
+ ).trim();
43
+ const [committed, untracked] = await Promise.all([
44
+ mergeBase
45
+ ? git(dir, ["diff", "--name-only", "-z", mergeBase], OPTS)
46
+ : git(dir, ["diff", "--name-only", "-z", "HEAD"], OPTS).catch(() => ""),
47
+ git(dir, ["ls-files", "--others", "--exclude-standard", "-z"], OPTS),
48
+ ]);
49
+ return [...new Set([...paths(committed), ...paths(untracked)])];
50
+ }
51
+
52
+ // Sessions that can hold a claim: local, not a mirror of another machine's.
53
+ function candidates(): SessionRow[] {
54
+ return getDb()
55
+ .prepare(
56
+ `SELECT id, name, working_directory, worktree_path, branch_name, base_branch,
57
+ pr_url, pr_status, task_status
58
+ FROM session_rows
59
+ WHERE archived_at IS NULL AND COALESCE(peer_mirror, 0) = 0
60
+ AND (host_id IS NULL OR host_id = 'local')`
61
+ )
62
+ .all() as SessionRow[];
63
+ }
64
+
65
+ const last = new Map<string, LocalClaim>();
66
+
67
+ // Writes this machine's claims and releases the ones whose session is gone,
68
+ // finished, or whose PR closed. A git failure keeps the last good paths.
69
+ export async function collectLocal(origin: {
70
+ id: string;
71
+ name: string;
72
+ }): Promise<void> {
73
+ const alive = new Set<string>();
74
+ for (const s of candidates()) {
75
+ try {
76
+ const done = s.pr_status === "merged" || s.pr_status === "closed";
77
+ const idleTask =
78
+ s.task_status &&
79
+ !IN_FLIGHT.includes(s.task_status) &&
80
+ s.pr_status !== "open";
81
+ if (done || idleTask) {
82
+ if (done) release(s.id, s.pr_status === "merged" ? "merged" : "done");
83
+ continue;
84
+ }
85
+ const dir = expandHome(s.worktree_path || s.working_directory);
86
+ const repo = await repoKey(dir);
87
+ if (!repo) continue;
88
+ const branch = s.branch_name || (await getCurrentBranch(dir));
89
+ const base = s.base_branch || (await getDefaultBranch(dir));
90
+ let files: string[];
91
+ try {
92
+ files = await changedFiles(dir, base);
93
+ } catch {
94
+ files = last.get(s.id)?.files ?? [];
95
+ }
96
+ // A shell sitting on the default branch with no changes claims nothing.
97
+ if ((branch === base || branch === "HEAD") && !files.length) continue;
98
+ const claim: LocalClaim = {
99
+ sessionId: s.id,
100
+ sessionName: s.name,
101
+ repo,
102
+ branch,
103
+ base,
104
+ goal: s.name,
105
+ files: files.slice(0, MAX_FILES),
106
+ filesMore: Math.max(0, files.length - MAX_FILES),
107
+ prUrl: s.pr_url,
108
+ };
109
+ last.set(s.id, claim);
110
+ upsertLocal(origin, claim);
111
+ alive.add(s.id);
112
+ } catch {
113
+ // This session claims nothing this round.
114
+ }
115
+ }
116
+ for (const id of localLiveSessionIds())
117
+ if (!alive.has(id)) {
118
+ release(id, "abandoned");
119
+ last.delete(id);
120
+ }
121
+ }
@@ -0,0 +1,41 @@
1
+ // The claims as text for the orchestrator and `aos claims`. A goal and a
2
+ // session name are another machine's words: fenced as data.
3
+
4
+ import { untrusted } from "../orchestrator/untrusted";
5
+ import { machineId } from "./machine";
6
+ import { findOverlaps } from "./overlap";
7
+ import type { Claim } from "./types";
8
+
9
+ const ago = (ms: number) => {
10
+ const m = Math.max(0, Math.round(ms / 60_000));
11
+ return m < 1 ? "just now" : m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
12
+ };
13
+
14
+ export function describeClaims(
15
+ claims: Claim[],
16
+ opts: { onlyOthers?: boolean; now?: number } = {}
17
+ ): string {
18
+ const now = opts.now ?? Date.now();
19
+ const self = machineId();
20
+ const shown = opts.onlyOthers
21
+ ? claims.filter((c) => c.origin !== self)
22
+ : claims;
23
+ if (!shown.length) return "";
24
+ const lines = shown.map((c) => {
25
+ const where = c.origin === self ? "here" : `on ${c.originName}`;
26
+ const files = c.files.length + c.filesMore;
27
+ return `- ${untrusted("claim", c.goal)} ${where}, ${c.branch ?? "no branch"}, ${files} file${files === 1 ? "" : "s"}, started ${ago(now - c.startedAt)} ago${c.prUrl ? `, ${c.prUrl}` : ""}`;
28
+ });
29
+ const overlaps = findOverlaps(claims).filter(
30
+ (o) => !opts.onlyOthers || o.a.origin !== self || o.b.origin !== self
31
+ );
32
+ const tail = overlaps.map(
33
+ (o) =>
34
+ `! ${o.strength} overlap (${o.kinds.join("+")}): ${untrusted("claim", o.a.goal)} (${o.a.originName}) and ${untrusted("claim", o.b.goal)} (${o.b.originName})${o.shared.length ? `, shared ${o.shared.slice(0, 5).join(", ")}` : ""}`
35
+ );
36
+ return [
37
+ "Claimed work (live sessions on linked machines):",
38
+ ...lines,
39
+ ...tail,
40
+ ].join("\n");
41
+ }
@@ -0,0 +1,91 @@
1
+ // The claims exchange between linked machines: one request carries our claims
2
+ // and brings back theirs. The side that holds the link does the calling, so
3
+ // it works when only one machine can reach the other.
4
+
5
+ import { hostApi, HostApiError, type HostLink } from "../hosts/remote-api";
6
+ import { claimsShared, machineId, machineName } from "./machine";
7
+ import { exchangeTargets, peerMachineOf, rememberPeer } from "./peers";
8
+ import { upsertRemote, wireFor } from "./store";
9
+ import { exchangeBody, type ExchangeBody } from "./types";
10
+
11
+ const OLD_PEER_MS = 60 * 60_000;
12
+ const skipUntil = new Map<string, number>();
13
+ const failing = new Set<string>();
14
+
15
+ export const selfMachine = () => ({ id: machineId(), name: machineName() });
16
+
17
+ // Takes what a peer sent; returns how many claims were new or newer.
18
+ // viaHost is the link it arrived through, when we know it.
19
+ export function acceptExchange(
20
+ body: ExchangeBody,
21
+ viaHost: string | null = null,
22
+ now = Date.now()
23
+ ): number {
24
+ if (!claimsShared()) return 0;
25
+ const self = machineId();
26
+ if (body.machine.id === self) return 0;
27
+ let n = 0;
28
+ for (const c of body.claims)
29
+ if (upsertRemote(self, body.machine.id, c, now, viaHost)) n++;
30
+ return n;
31
+ }
32
+
33
+ // What we answer a peer that called: our claims, not echoed back to it.
34
+ export function answerExchange(body: ExchangeBody): ExchangeBody {
35
+ const self = selfMachine();
36
+ return {
37
+ machine: self,
38
+ claims: claimsShared()
39
+ ? wireFor(self.id, { machineId: body.machine.id })
40
+ : [],
41
+ };
42
+ }
43
+
44
+ async function exchangeOver(
45
+ link: HostLink,
46
+ self: { id: string; name: string },
47
+ log: (line: string) => void
48
+ ): Promise<boolean> {
49
+ const hostId = link.hostId;
50
+ if ((skipUntil.get(hostId) ?? 0) > Date.now()) return false;
51
+ try {
52
+ const raw = await hostApi<unknown>(link, "/api/claims/exchange", {
53
+ body: {
54
+ machine: self,
55
+ claims: wireFor(self.id, { machineId: peerMachineOf(hostId) }),
56
+ },
57
+ timeout: 8000,
58
+ });
59
+ const parsed = exchangeBody.safeParse(raw);
60
+ if (!parsed.success) throw new Error("its answer wasn't claims");
61
+ rememberPeer(hostId, parsed.data.machine.id);
62
+ acceptExchange(parsed.data, hostId);
63
+ failing.delete(hostId);
64
+ return true;
65
+ } catch (err) {
66
+ if (err instanceof HostApiError && err.status === 404) {
67
+ skipUntil.set(hostId, Date.now() + OLD_PEER_MS);
68
+ return false;
69
+ }
70
+ if (!failing.has(hostId)) {
71
+ failing.add(hostId);
72
+ log(
73
+ `claims: ${link.hostName} didn't answer: ${(err as Error)?.message ?? err}`
74
+ );
75
+ }
76
+ return false;
77
+ }
78
+ }
79
+
80
+ // Each machine once per tick, over its best working link.
81
+ export async function syncPeers(
82
+ log: (line: string) => void = () => {}
83
+ ): Promise<void> {
84
+ if (!claimsShared()) return;
85
+ const self = selfMachine();
86
+ await Promise.all(
87
+ exchangeTargets().map(async (links) => {
88
+ for (const link of links) if (await exchangeOver(link, self, log)) return;
89
+ })
90
+ );
91
+ }
@@ -0,0 +1,45 @@
1
+ // How alike two one-line goals read: the share of content words they have
2
+ // in common. Words every task title has (add, fix, update) carry nothing.
3
+
4
+ const STOP = new Set(
5
+ "a an and the to of for in on at with from by or as is are be it its this that into via add adds added fix fixes fixed update updates make makes use using new support improve implement allow when not no all use".split(
6
+ " "
7
+ )
8
+ );
9
+
10
+ const stem = (w: string) =>
11
+ w.length > 5 && w.endsWith("ing")
12
+ ? w.slice(0, -3)
13
+ : w.length > 3 && w.endsWith("s") && !w.endsWith("ss")
14
+ ? w.slice(0, -1)
15
+ : w;
16
+
17
+ export function goalWords(goal: string): Set<string> {
18
+ return new Set(
19
+ goal
20
+ .toLowerCase()
21
+ .split(/[^a-z0-9]+/)
22
+ .filter((w) => w.length > 1 && !STOP.has(w))
23
+ .map(stem)
24
+ );
25
+ }
26
+
27
+ export function goalSimilarity(
28
+ a: string,
29
+ b: string
30
+ ): {
31
+ score: number;
32
+ shared: number;
33
+ } {
34
+ const x = goalWords(a);
35
+ const y = goalWords(b);
36
+ let shared = 0;
37
+ for (const w of x) if (y.has(w)) shared++;
38
+ const union = x.size + y.size - shared;
39
+ return { score: union ? shared / union : 0, shared };
40
+ }
41
+
42
+ export const goalsAlike = (a: string, b: string) => {
43
+ const { score, shared } = goalSimilarity(a, b);
44
+ return score >= 0.5 && shared >= 2;
45
+ };
@@ -0,0 +1,10 @@
1
+ export { startClaimsLoop, claimsTick } from "./loop";
2
+ export { acceptExchange, answerExchange, selfMachine } from "./exchange";
3
+ export { liveClaim, liveClaims } from "./store";
4
+ export { findOverlaps, overlapBetween } from "./overlap";
5
+ export { describeClaims } from "./describe";
6
+ export { claimsShared, machineId, machineName } from "./machine";
7
+ export type { Claim, Overlap, ClaimWire } from "./types";
8
+ export { endOutside, registerOutside } from "./outside";
9
+ export { claimsSnapshot, refreshOverlapCache } from "./overlapCache";
10
+ export { claimsView } from "./view";
@@ -0,0 +1,51 @@
1
+ import { collectLocal } from "./collectLocal";
2
+ import { selfMachine, syncPeers } from "./exchange";
3
+ import { claimsShared } from "./machine";
4
+ import { dropViaInactive } from "./peers";
5
+ import { notifyOverlaps } from "./notifyOverlaps";
6
+ import { handleMerged } from "./onMerged";
7
+ import { refreshOverlapCache } from "./overlapCache";
8
+ import { purgeExpired } from "./store";
9
+ import { CLAIMS_SYNC_MS } from "./types";
10
+
11
+ export async function claimsTick(
12
+ log: (line: string) => void = () => {}
13
+ ): Promise<void> {
14
+ if (!claimsShared()) return;
15
+ try {
16
+ await collectLocal(selfMachine());
17
+ await syncPeers(log);
18
+ await notifyOverlaps();
19
+ await handleMerged();
20
+ dropViaInactive();
21
+ purgeExpired();
22
+ refreshOverlapCache();
23
+ } catch (err) {
24
+ log(`claims: ${(err as Error)?.message ?? err}`);
25
+ }
26
+ }
27
+
28
+ // One tick a minute. Nothing here may throw into the server.
29
+ export function startClaimsLoop(
30
+ log: (line: string) => void = (l) => console.log(l)
31
+ ): { stop: () => void } {
32
+ let stopped = false;
33
+ let timer: NodeJS.Timeout | null = null;
34
+ const next = () => {
35
+ if (stopped) return;
36
+ timer = setTimeout(run, CLAIMS_SYNC_MS);
37
+ timer.unref?.();
38
+ };
39
+ const run = async () => {
40
+ await claimsTick(log);
41
+ next();
42
+ };
43
+ timer = setTimeout(run, 5000);
44
+ timer.unref?.();
45
+ return {
46
+ stop: () => {
47
+ stopped = true;
48
+ if (timer) clearTimeout(timer);
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,21 @@
1
+ import { randomUUID } from "crypto";
2
+ import os from "os";
3
+ import { getSetting, putSetting } from "../settings-store";
4
+
5
+ const ID_KEY = "claims.machine_id";
6
+
7
+ export function machineId(): string {
8
+ const have = getSetting(ID_KEY);
9
+ if (have) return have;
10
+ const id = randomUUID();
11
+ putSetting(ID_KEY, id);
12
+ return id;
13
+ }
14
+
15
+ export function machineName(): string {
16
+ const set = getSetting("claims.machine_name")?.trim();
17
+ return (set || os.hostname().split(".")[0] || "machine").slice(0, 60);
18
+ }
19
+
20
+ // Off: this machine neither sends nor takes claims.
21
+ export const claimsShared = () => getSetting("claims.share") !== "off";
@@ -0,0 +1,43 @@
1
+ // The strong overlaps that touch a session on this machine, each seen from
2
+ // that session's side. Pure over the claims it is given.
3
+
4
+ import { findOverlaps } from "./overlap";
5
+ import { pairKey, whoYields } from "./yield";
6
+ import type { Claim } from "./types";
7
+
8
+ export interface SessionOverlap {
9
+ sessionId: string;
10
+ pairKey: string;
11
+ mine: Claim;
12
+ other: Claim;
13
+ shared: string[];
14
+ kinds: ("branch" | "files" | "goal")[];
15
+ // This session is the one asked to give way.
16
+ yields: boolean;
17
+ }
18
+
19
+ export function sessionOverlaps(
20
+ self: string,
21
+ claims: Claim[]
22
+ ): SessionOverlap[] {
23
+ const out: SessionOverlap[] = [];
24
+ for (const o of findOverlaps(claims)) {
25
+ if (o.strength !== "strong") continue;
26
+ const yielder = whoYields(o.a, o.b);
27
+ for (const [mine, other] of [
28
+ [o.a, o.b],
29
+ [o.b, o.a],
30
+ ] as const)
31
+ if (mine.origin === self && mine.sessionId)
32
+ out.push({
33
+ sessionId: mine.sessionId,
34
+ pairKey: pairKey(o.a, o.b),
35
+ mine,
36
+ other,
37
+ shared: o.shared,
38
+ kinds: o.kinds,
39
+ yields: yielder === mine,
40
+ });
41
+ }
42
+ return out;
43
+ }