@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
@@ -10,6 +10,8 @@
10
10
  import { runForSession } from "../recipes/store";
11
11
  import { db, type StackItemStatus, type StackStatus } from "../db";
12
12
  import { isFinished, type TaskPR } from "../tasks/state";
13
+ import { eventLine } from "../claims/overlapText";
14
+ import type { SessionOverlap } from "../claims/mine";
13
15
  import { ciWord } from "./describe";
14
16
  import type { SessionFacts } from "./facts";
15
17
  import { untrusted } from "./untrusted";
@@ -192,6 +194,29 @@ function stackConditions(s: StackFacts): Condition[] {
192
194
  return out;
193
195
  }
194
196
 
197
+ // A strong overlap between a session here and other work, anywhere. Held
198
+ // until it ends, so it goes through the events' two-diff damping and
199
+ // REFIRE_MS, and an overlap that only flickers is dropped.
200
+ function overlapConditions(
201
+ facts: SessionFacts[],
202
+ overlaps: SessionOverlap[]
203
+ ): Condition[] {
204
+ const names = new Map(facts.map((f) => [f.id, f.name]));
205
+ return overlaps.flatMap((o) => {
206
+ const name = names.get(o.sessionId);
207
+ if (!name) return [];
208
+ return [
209
+ {
210
+ key: conditionKey(`overlap:${o.pairKey}`, o.sessionId),
211
+ subject: o.sessionId,
212
+ line: eventLine(o, name),
213
+ sticky: false,
214
+ low: true,
215
+ },
216
+ ];
217
+ });
218
+ }
219
+
195
220
  // Sessions the orchestrator itself messaged this recently don't raise
196
221
  // "needs input": that's most likely it waiting on its own question.
197
222
  export const QUIET_AFTER_MESSAGE_MS = 2 * 60 * 1000;
@@ -200,11 +225,13 @@ export function conditionsFor(
200
225
  facts: SessionFacts[],
201
226
  stacks: StackFacts[],
202
227
  now = Date.now(),
203
- quiet: ReadonlySet<string> = new Set()
228
+ quiet: ReadonlySet<string> = new Set(),
229
+ overlaps: SessionOverlap[] = []
204
230
  ): Condition[] {
205
231
  return [
206
232
  ...facts.flatMap((f) => sessionConditions(f, now, quiet.has(f.id))),
207
233
  ...stacks.flatMap(stackConditions),
234
+ ...overlapConditions(facts, overlaps),
208
235
  ];
209
236
  }
210
237
 
@@ -37,6 +37,7 @@ function sessionLine(f: SessionFacts): string {
37
37
  task += `, BLOCKED: ${untrusted(f.name, f.task.blocked)}`;
38
38
  parts.push(task);
39
39
  }
40
+ if (f.group) parts.push(`in group "${f.group}"`);
40
41
  if (f.plan)
41
42
  parts.push(
42
43
  `${f.plan.awaiting ? "plan ready, awaiting your approve_plan" : f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
@@ -66,7 +67,8 @@ function sessionLine(f: SessionFacts): string {
66
67
  // The sessions tool's answer: one line per session, busiest news first.
67
68
  export function describeSessions(
68
69
  workspace: string,
69
- facts: SessionFacts[]
70
+ facts: SessionFacts[],
71
+ groups: readonly string[] = []
70
72
  ): string {
71
73
  if (!facts.length) return `No sessions are running in ${workspace}.`;
72
74
  const rank = { waiting: 0, running: 1, idle: 2, dead: 3 };
@@ -82,5 +84,8 @@ export function describeSessions(
82
84
  return [
83
85
  `${facts.length} ${noun} in ${workspace}${summary}:`,
84
86
  ...sorted.map(sessionLine),
87
+ ...(groups.length
88
+ ? [`Saad's groups here: ${groups.map((g) => `"${g}"`).join(", ")}.`]
89
+ : []),
85
90
  ].join("\n");
86
91
  }
@@ -5,6 +5,7 @@
5
5
  * read this.
6
6
  */
7
7
 
8
+ import { groupNames } from "../session-groups/store";
8
9
  import { db, type Session, type StackItemStatus } from "../db";
9
10
  import { statusDetector } from "../status-detector";
10
11
  import { hostLink } from "../hosts/remote-api";
@@ -43,6 +44,8 @@ export interface SessionFacts {
43
44
  stalled?: Stall | null;
44
45
  // Its agent is summarising the context: not stuck, not plain work.
45
46
  compacting?: boolean;
47
+ // Saad's group it sits in, when it is in one.
48
+ group?: string | null;
46
49
  // Last sign of life, in ms.
47
50
  lastActive: number;
48
51
  // Open waits (aos send --await, aos wait): who it waits on, and who waits on it.
@@ -308,6 +311,7 @@ export async function sessionFacts(
308
311
  ): Promise<SessionFacts[]> {
309
312
  await statusDetector.refreshCache();
310
313
  const badges = waitBadges();
314
+ const groups = groupNames();
311
315
  return Promise.all(
312
316
  workspaceSessions(workspaceId).map(async (s) => {
313
317
  const [{ status, activity, needsInput }, task] = await Promise.all([
@@ -336,6 +340,7 @@ export async function sessionFacts(
336
340
  s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
337
341
  compacting:
338
342
  s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
343
+ group: groups.get(s.session_group_id ?? "") ?? null,
339
344
  lastActive: await lastActiveFor(s),
340
345
  branch: s.branch_name,
341
346
  quick: !!s.quick_of,
@@ -1,6 +1,7 @@
1
1
  import { resumeOnModel } from "../models/resume";
2
2
  import { workspaceTask } from "./targets";
3
3
  import { getWorkspace } from "../workspaces";
4
+ import { listGroups } from "../session-groups/store";
4
5
  import {
5
6
  approveTaskPlan,
6
7
  archive,
@@ -11,6 +12,7 @@ import {
11
12
  send,
12
13
  stack,
13
14
  stackStatus,
15
+ setGroup,
14
16
  startSession,
15
17
  startTask,
16
18
  startNow,
@@ -19,6 +21,10 @@ import {
19
21
  resumeTask,
20
22
  stop,
21
23
  } from "./act";
24
+ import { describeClaims, liveClaims } from "../claims";
25
+ import { repoKey } from "../git-remote";
26
+ import { expandHome } from "../tasks/session";
27
+ import { workspaceProjects } from "./brief";
22
28
  import { readCards } from "./cards";
23
29
  import { readLogs } from "./logs";
24
30
  import { describeSessions } from "./describe";
@@ -59,6 +65,7 @@ const ACTING = new Set<ToolName>([
59
65
  "start_task",
60
66
  "set_task_model",
61
67
  "start_session",
68
+ "set_group",
62
69
  "run_recipe",
63
70
  "recipe_save",
64
71
  "recipe_update",
@@ -83,6 +90,21 @@ const ACTING = new Set<ToolName>([
83
90
  "add_project",
84
91
  ]);
85
92
 
93
+ // What live sessions on other machines claim in this workspace's repos.
94
+ async function claimedElsewhere(w: string): Promise<string> {
95
+ const keys = await Promise.all(
96
+ workspaceProjects(w)
97
+ .filter((p) => !p.host_id || p.host_id === "local")
98
+ .map((p) => repoKey(expandHome(p.working_directory)))
99
+ );
100
+ const repos = new Set(keys.filter((k): k is string => !!k));
101
+ if (!repos.size) return "";
102
+ return describeClaims(
103
+ liveClaims().filter((c) => repos.has(c.repo)),
104
+ { onlyOthers: true }
105
+ );
106
+ }
107
+
86
108
  // The orchestrator's tools, each scoped to its workspace and its arguments
87
109
  // validated, answered as compact text.
88
110
  export async function runTool(
@@ -97,14 +119,20 @@ export async function runTool(
97
119
  switch (tool) {
98
120
  case "sessions": {
99
121
  p(tool, raw);
100
- const [facts, external] = await Promise.all([
122
+ const [facts, external, claimed] = await Promise.all([
101
123
  sessionFacts(w),
102
124
  externalSessions(w).catch(() => []),
125
+ claimedElsewhere(w).catch(() => ""),
103
126
  ]);
104
127
  resolveStaleAsks(w);
105
128
  return [
106
- describeSessions(workspace.name, facts),
129
+ describeSessions(
130
+ workspace.name,
131
+ facts,
132
+ listGroups(w).map((g) => g.name)
133
+ ),
107
134
  describeExternal(external),
135
+ claimed,
108
136
  ]
109
137
  .filter(Boolean)
110
138
  .join("\n\n");
@@ -147,7 +175,9 @@ export async function runTool(
147
175
  machine: a.machine,
148
176
  now: a.now,
149
177
  nowReason: a.now_reason,
178
+ ignoreClaims: a.ignore_claims,
150
179
  piece: a.piece,
180
+ group: a.group,
151
181
  });
152
182
  }
153
183
  case "start_now": {
@@ -187,8 +217,13 @@ export async function runTool(
187
217
  name: a.name,
188
218
  view: a.view,
189
219
  model: a.model,
220
+ group: a.group,
190
221
  });
191
222
  }
223
+ case "set_group": {
224
+ const a = p(tool, raw);
225
+ return setGroup(w, a.session, a.group);
226
+ }
192
227
  case "quick_change": {
193
228
  const a = p(tool, raw);
194
229
  return quickChange(w, a.project, a.name);
@@ -20,6 +20,7 @@ export const TOOL_NAMES = {
20
20
  start_task: name("start_task"),
21
21
  set_task_model: name("set_task_model"),
22
22
  start_session: name("start_session"),
23
+ set_group: name("set_group"),
23
24
  quick_change: name("quick_change"),
24
25
  quick_pr: name("quick_pr"),
25
26
  stack: name("stack"),
@@ -22,6 +22,16 @@ const view = z
22
22
  "How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
23
23
  );
24
24
 
25
+ const group = z
26
+ .string()
27
+ .trim()
28
+ .min(1)
29
+ .max(60)
30
+ .optional()
31
+ .describe(
32
+ "One of Saad's groups in this workspace, by name (see sessions): file it there when he asked, or when it continues work already in that group. It must exist; you can't create groups"
33
+ );
34
+
25
35
  const sweepMachine = z
26
36
  .string()
27
37
  .trim()
@@ -96,6 +106,7 @@ export const TOOL_SHAPES = {
96
106
  },
97
107
  start_task: {
98
108
  project,
109
+ group,
99
110
  prompt: z
100
111
  .string()
101
112
  .trim()
@@ -122,7 +133,7 @@ export const TOOL_SHAPES = {
122
133
  .max(200)
123
134
  .optional()
124
135
  .describe(
125
- 'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first'
136
+ 'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first, or "claim:<id>" for work another machine has claimed'
126
137
  ),
127
138
  priority: z
128
139
  .enum(PRIORITIES)
@@ -149,6 +160,12 @@ export const TOOL_SHAPES = {
149
160
  .max(300)
150
161
  .optional()
151
162
  .describe("With now: why it can't wait for a slot; logged for Saad"),
163
+ ignore_claims: z
164
+ .boolean()
165
+ .optional()
166
+ .describe(
167
+ "Don't queue it after work another session (here or on a linked machine) has claimed on the same branch or files; start it as usual. Not needed with now"
168
+ ),
152
169
  piece: z
153
170
  .string()
154
171
  .trim()
@@ -196,6 +213,7 @@ export const TOOL_SHAPES = {
196
213
  },
197
214
  start_session: {
198
215
  project,
216
+ group,
199
217
  prompt: z.string().trim().min(1).max(20000),
200
218
  name: z
201
219
  .string()
@@ -211,6 +229,18 @@ export const TOOL_SHAPES = {
211
229
  'One model for the session, "provider/model"; default: the implementation model. Opus only when the work needs deep reasoning'
212
230
  ),
213
231
  },
232
+ set_group: {
233
+ session,
234
+ group: z
235
+ .string()
236
+ .trim()
237
+ .min(1)
238
+ .max(60)
239
+ .nullable()
240
+ .describe(
241
+ "One of Saad's groups in this workspace by name, or null to take the session out of its group"
242
+ ),
243
+ },
214
244
  quick_change: {
215
245
  project,
216
246
  name: z
@@ -126,11 +126,13 @@ const DESCRIPTIONS: Record<ToolName, string> = {
126
126
  message_orchestrator:
127
127
  "Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
128
128
  start_task:
129
- 'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. Refused while a brake holds.',
129
+ 'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. A strong overlap with work another session has claimed (same branch, or the same files, here or on a linked machine) queues the start after it and says so; the result also lists weaker overlaps. ignore_claims true skips that. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. Refused while a brake holds.',
130
130
  set_task_model:
131
131
  'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
132
132
  start_session:
133
133
  'Start an agent session in a project with a prompt, for work that isn\'t a PR task. It runs on the implementation model unless you give model (one "provider/model"; Opus only when the work needs deep reasoning), as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.',
134
+ set_group:
135
+ "File a session of this workspace in one of Saad's groups (by name), or out of its group with null. Groups only organise his sidebar; they change nothing about the session. You can use groups that exist but can't create, rename or delete them.",
134
136
  quick_change:
135
137
  "Make a change yourself (without full access, a small low-risk one: a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. Its dependencies install in the background (a note says when). You may write there (Write, Edit) and, with Orchestrator full access, run any git there; without it, only `git -C <path> ...` status, diff, add, rm, mv, restore, reset, log, show, and nowhere else in a project. Without full access, anything needing real work, tests or an investigation is a task (start_task).",
136
138
  quick_pr:
@@ -14,6 +14,9 @@ import { deliverEvents } from "./deliver";
14
14
  import { recordConditions } from "./events";
15
15
  import { sessionFacts, type SessionFacts } from "./facts";
16
16
  import { listOrchestrators } from "./home";
17
+ import { claimsShared, machineId } from "../claims/machine";
18
+ import { sessionOverlaps, type SessionOverlap } from "../claims/mine";
19
+ import { liveClaims } from "../claims/store";
17
20
  import { BRAKE_SUBJECT, openAsks } from "./asks";
18
21
  import { settleStaleAsks } from "./ask-settle";
19
22
  import { brakesOn, liftBrake } from "./brakes";
@@ -35,6 +38,15 @@ async function settleAsks(workspaceId: string): Promise<void> {
35
38
  if (brake && !(await brakesOn(workspaceId)).length) liftBrake(workspaceId);
36
39
  }
37
40
 
41
+ // Claims code never breaks the watcher: any error means no overlaps.
42
+ function localOverlaps(): SessionOverlap[] {
43
+ try {
44
+ return claimsShared() ? sessionOverlaps(machineId(), liveClaims()) : [];
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
38
50
  const DELIVER_EVERY_MS = 5000;
39
51
  export const DIFF_BUSY_MS = 15 * 1000;
40
52
  export const DIFF_IDLE_MS = 60 * 1000;
@@ -58,7 +70,13 @@ export async function diffWorkspace(
58
70
  ];
59
71
  recordConditions(
60
72
  workspaceId,
61
- conditionsFor(facts, stacks, now, recentlyMessaged(orchestratorId, now)),
73
+ conditionsFor(
74
+ facts,
75
+ stacks,
76
+ now,
77
+ recentlyMessaged(orchestratorId, now),
78
+ localOverlaps()
79
+ ),
62
80
  subjects,
63
81
  now
64
82
  );
@@ -19,6 +19,9 @@ import {
19
19
  } from "../connect/control-api";
20
20
  import { peerHostAllowed } from "./client";
21
21
  import { rawPublic } from "./keys";
22
+ import { cleanMachineName } from "./name";
23
+ import { reconcilePins, type AccountSays } from "./revoke";
24
+ import { getSetting, putSetting } from "../settings-store";
22
25
 
23
26
  export interface AccountPeer {
24
27
  id: string;
@@ -37,9 +40,15 @@ export interface AccountView {
37
40
  /** When the list was last fetched, 0 if never. */
38
41
  fetchedAt: number;
39
42
  error: string | null;
43
+ /** Set when the service hasn't answered for a day: since when. */
44
+ uncheckedSince: number | null;
40
45
  }
41
46
 
42
47
  export const LIST_MAX_AGE_MS = 60_000;
48
+ export const UNCHECKED_NOTICE_MS = 24 * 3_600_000;
49
+ const OK_KEY = "peer.account_ok_at";
50
+ // The last answer is written down at most this often (it survives restarts).
51
+ const OK_SAVE_MS = 10 * 60_000;
43
52
  // Never more often than this, whoever asks.
44
53
  const MIN_GAP_MS = 5_000;
45
54
 
@@ -52,6 +61,8 @@ interface State {
52
61
  stale: boolean;
53
62
  error: string | null;
54
63
  inflight: Promise<void> | null;
64
+ okAt: number;
65
+ okSavedAt: number;
55
66
  }
56
67
 
57
68
  export type Fetcher = () => Promise<PeerList | null>;
@@ -70,6 +81,8 @@ const fresh = (): State => ({
70
81
  stale: false,
71
82
  error: null,
72
83
  inflight: null,
84
+ okAt: 0,
85
+ okSavedAt: 0,
73
86
  });
74
87
 
75
88
  const state = () => (g.__agentosPeerAccount ??= fresh());
@@ -97,6 +110,42 @@ export function rawFromSpki(pem: string): string | null {
97
110
  }
98
111
  }
99
112
 
113
+ /** The service answered: links can be checked against it again. */
114
+ export function noteAccountOk(now = Date.now()) {
115
+ const s = state();
116
+ s.okAt = now;
117
+ if (now - s.okSavedAt < OK_SAVE_MS) return;
118
+ s.okSavedAt = now;
119
+ try {
120
+ putSetting(OK_KEY, String(now));
121
+ } catch {
122
+ // Bookkeeping only.
123
+ }
124
+ }
125
+
126
+ /** When the service last answered; a first look starts the clock. */
127
+ function lastOk(now: number): number {
128
+ const s = state();
129
+ if (s.okAt) return s.okAt;
130
+ let saved = NaN;
131
+ try {
132
+ saved = Number(getSetting(OK_KEY));
133
+ } catch {
134
+ // No database: count from now.
135
+ }
136
+ if (Number.isFinite(saved) && saved > 0) return (s.okAt = saved);
137
+ noteAccountOk(now);
138
+ return now;
139
+ }
140
+
141
+ function applyToPins(says: AccountSays) {
142
+ try {
143
+ reconcilePins(says);
144
+ } catch (err) {
145
+ console.error("peer revoke:", err);
146
+ }
147
+ }
148
+
100
149
  function adopt(list: PeerList) {
101
150
  const s = state();
102
151
  const peers: AccountPeer[] = [];
@@ -107,7 +156,7 @@ function adopt(list: PeerList) {
107
156
  if (!signPub || !peerHostAllowed(m.hostname)) continue;
108
157
  peers.push({
109
158
  id: m.id,
110
- name: m.name,
159
+ name: cleanMachineName(m.name),
111
160
  hostname: m.hostname,
112
161
  signPub,
113
162
  lastSeenAt: m.lastSeenAt,
@@ -120,6 +169,11 @@ function adopt(list: PeerList) {
120
169
  s.fetchedAt = Date.now();
121
170
  s.stale = false;
122
171
  s.error = null;
172
+ noteAccountOk(s.fetchedAt);
173
+ applyToPins({
174
+ linkMachines: s.entitlements?.linkMachines ?? null,
175
+ listed: new Map(peers.map((p) => [p.id, p.signPub])),
176
+ });
123
177
  }
124
178
 
125
179
  async function refresh(): Promise<void> {
@@ -161,15 +215,19 @@ export async function accountView(
161
215
  peers: [],
162
216
  fetchedAt: 0,
163
217
  error: null,
218
+ uncheckedSince: null,
164
219
  };
165
220
  const s = state();
166
221
  if (s.stale || Date.now() - s.fetchedAt > maxAgeMs) await refresh();
222
+ const now = Date.now();
223
+ const ok = lastOk(now);
167
224
  return {
168
225
  enrolled: true,
169
226
  entitlements: s.entitlements,
170
227
  peers: s.peers,
171
228
  fetchedAt: s.fetchedAt,
172
229
  error: s.error,
230
+ uncheckedSince: now - ok > UNCHECKED_NOTICE_MS ? ok : null,
173
231
  };
174
232
  }
175
233
 
@@ -187,12 +245,26 @@ export async function linkingAllowed(): Promise<boolean | null> {
187
245
  return entitlements ? entitlements.linkMachines : null;
188
246
  }
189
247
 
190
- /** What a heartbeat says: the plan now, and whether the list changed. */
248
+ /**
249
+ * What a heartbeat says: the service is there, the plan now, and whether the
250
+ * list changed, which is fetched again at once so a machine removed from the
251
+ * account is suspended within a beat or so.
252
+ */
191
253
  export function noteHeartbeat(beat: Beat) {
192
254
  const s = state();
255
+ noteAccountOk();
256
+ const plan = beat.entitlements?.linkMachines;
257
+ const planChanged =
258
+ plan !== undefined && plan !== s.entitlements?.linkMachines;
193
259
  if (beat.entitlements) s.entitlements = beat.entitlements;
194
260
  if (beat.peersEtag && beat.peersEtag !== s.etag) {
195
261
  s.etag = beat.peersEtag;
196
262
  s.stale = true;
197
263
  }
264
+ // A downgrade suspends now. An upgrade waits for a fresh list, so a pin
265
+ // whose machine left the account meanwhile isn't resumed on the way.
266
+ if (planChanged && plan === false)
267
+ applyToPins({ linkMachines: false, listed: null });
268
+ else if (planChanged) s.stale = true;
269
+ if (s.stale && enrolled()) void refresh();
198
270
  }
@@ -67,7 +67,7 @@ export const PEER_ROUTES: readonly PeerRoute[] = [
67
67
  { method: "POST", pattern: "/api/peer/unlink" },
68
68
  { method: "GET", pattern: "/api/peer/ping" },
69
69
 
70
- // The work registry's exchange (not built yet: 404 until it is).
70
+ // The work registry's exchange.
71
71
  { method: "POST", pattern: "/api/claims/exchange" },
72
72
  ];
73
73
 
@@ -15,7 +15,8 @@ import {
15
15
  sealRequest,
16
16
  } from "./envelope";
17
17
  import { boxPublic, currentIdentity, signPublic, type Identity } from "./keys";
18
- import { notePinResult } from "./links";
18
+ import { notePinResult, unlinkedByPeer } from "./links";
19
+ import { UNLINKED_PROOF_HEADER, verifyUnlinked } from "./unlinked-proof";
19
20
  import type { HostLink } from "../hosts/remote-api";
20
21
  import { readCappedBytes } from "../hosts/read-capped-bytes";
21
22
 
@@ -67,12 +68,15 @@ export interface ClientDeps {
67
68
  identity: () => Identity | null;
68
69
  audit: (e: AuditEntry) => void;
69
70
  noteResult: (hostId: string, error: string | null) => void;
71
+ /** It proved it unlinked this machine: drop the pin here too. */
72
+ unlinked: (hostId: string, name: string) => void;
70
73
  }
71
74
 
72
75
  const realDeps: ClientDeps = {
73
76
  identity: () => currentIdentity(),
74
77
  audit: recordPeerCall,
75
78
  noteResult: notePinResult,
79
+ unlinked: unlinkedByPeer,
76
80
  };
77
81
 
78
82
  export async function peerCall(
@@ -155,6 +159,27 @@ export async function peerCall(
155
159
  }
156
160
  if (res.status !== 200) {
157
161
  const text = raw.subarray(0, 64).toString("utf8").trim();
162
+ if (
163
+ res.status === 403 &&
164
+ text === "unknown peer" &&
165
+ verifyUnlinked(
166
+ signPublic(peer.signPub),
167
+ {
168
+ from: peer.machineId,
169
+ to: identity.machineId,
170
+ nonce: sealed.nonce.toString("base64url"),
171
+ },
172
+ res.headers.get(UNLINKED_PROOF_HEADER)
173
+ )
174
+ ) {
175
+ audit("unknown_peer", 403, sealed.envelope.length);
176
+ deps.unlinked(link.hostId, link.hostName);
177
+ throw new PeerCallError(
178
+ `${link.hostName} unlinked this machine. Link it again in Machines.`,
179
+ true,
180
+ 403
181
+ );
182
+ }
158
183
  const outcome = REFUSALS[text];
159
184
  if (outcome && res.status >= 400 && res.status < 500) {
160
185
  audit(outcome, res.status, sealed.envelope.length);
@@ -100,7 +100,9 @@ async function list(io: CliIo) {
100
100
  );
101
101
  for (const r of requests)
102
102
  io.out(
103
- `\n${r.name} wants to link here: agent-os link accept <the code it shows>`
103
+ r.state === "accepted"
104
+ ? `\n${r.name} is linked here. To finish, type this code on ${r.name}: ${r.confirmCode ? showCode(r.confirmCode) : "(see the browser)"}`
105
+ : `\n${r.name} wants to link here: agent-os link accept <the code it shows>`
104
106
  );
105
107
  if (account.machines.some((m) => !m.linkState))
106
108
  io.out("\nLink one with: agent-os link <name>");
@@ -177,7 +179,12 @@ async function answer(io: CliIo, verb: "accept" | "decline", rest: string[]) {
177
179
  io,
178
180
  "/api/peers/pending"
179
181
  );
180
- const req = pick(requests, from, (r) => r.machineId, "link request");
182
+ const req = pick(
183
+ requests.filter((r) => r.state !== "accepted"),
184
+ from,
185
+ (r) => r.machineId,
186
+ "link request"
187
+ );
181
188
  if (verb === "decline") {
182
189
  await call(io, `/api/peers/pending/${req.id}/decline`, { method: "POST" });
183
190
  io.out(`Declined ${req.name}.`);
@@ -210,10 +217,18 @@ async function unlink(io: CliIo, ref: string | undefined) {
210
217
  (h) => h.id,
211
218
  "linked machine"
212
219
  );
213
- await call(io, `/api/hosts/${host.id}`, { method: "DELETE" });
220
+ const r = await call<{ told: boolean; removed: boolean }>(
221
+ io,
222
+ `/api/peers/${host.id}`,
223
+ { method: "DELETE" }
224
+ );
214
225
  io.out(
215
- `Unlinked ${host.name} here. Remove this machine from its Machines too.`
226
+ r.told
227
+ ? `Unlinked ${host.name}, on both machines.`
228
+ : `Unlinked ${host.name} here. It couldn't be told now; its next call here is refused and it will show as unlinked.`
216
229
  );
230
+ if (!r.removed)
231
+ io.out(`It stays in Machines while it still has projects or sessions.`);
217
232
  }
218
233
 
219
234
  /** Runs one command; returns the exit code. */