@saadnvd1/agent-os 0.3.700 → 0.3.723

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 (306) hide show
  1. package/README.md +2 -0
  2. package/app/api/agent-roles/route.test.ts +67 -0
  3. package/app/api/agent-roles/route.ts +61 -0
  4. package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
  5. package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
  6. package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
  7. package/app/api/analyst/[id]/review/route.ts +22 -0
  8. package/app/api/analyst/[id]/route.ts +44 -0
  9. package/app/api/analyst/route.ts +10 -0
  10. package/app/api/bus/groups/route.ts +44 -0
  11. package/app/api/bus/spawn/route.ts +34 -2
  12. package/app/api/claims/[id]/route.ts +20 -0
  13. package/app/api/claims/check/route.ts +23 -0
  14. package/app/api/claims/check.test.ts +113 -0
  15. package/app/api/claims/exchange/route.ts +42 -0
  16. package/app/api/claims/outside.test.ts +90 -0
  17. package/app/api/claims/route.test.ts +193 -0
  18. package/app/api/claims/route.ts +73 -0
  19. package/app/api/hosts/[id]/route.ts +9 -1
  20. package/app/api/models/availability/route.ts +9 -0
  21. package/app/api/models/choices/route.ts +9 -0
  22. package/app/api/peer/unlink/route.ts +17 -0
  23. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  24. package/app/api/peers/[hostId]/route.ts +20 -0
  25. package/app/api/peers/gate.test.ts +64 -1
  26. package/app/api/peers/pending/route.test.ts +37 -0
  27. package/app/api/peers/pending/route.ts +11 -4
  28. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  29. package/app/api/session-groups/[id]/route.ts +25 -0
  30. package/app/api/session-groups/order/route.ts +15 -0
  31. package/app/api/session-groups/route.ts +21 -0
  32. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  33. package/app/api/sessions/[id]/crew/route.ts +38 -0
  34. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  35. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  36. package/app/api/sessions/[id]/export/route.ts +34 -0
  37. package/app/api/sessions/[id]/fork/route.ts +3 -0
  38. package/app/api/sessions/[id]/group/route.ts +18 -0
  39. package/app/api/sessions/[id]/model/route.ts +49 -0
  40. package/app/api/sessions/[id]/route.ts +14 -4
  41. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  42. package/app/api/sessions/claims.test.ts +122 -0
  43. package/app/api/sessions/group/route.ts +17 -0
  44. package/app/api/sessions/route.ts +31 -14
  45. package/app/page.tsx +10 -0
  46. package/components/Chat/Messages.tsx +104 -10
  47. package/components/Claims/ClaimFiles.tsx +38 -0
  48. package/components/Claims/ClaimRow.tsx +82 -0
  49. package/components/Claims/ClaimsSheet.tsx +176 -0
  50. package/components/Claims/OverlapPill.tsx +31 -0
  51. package/components/Claims/StartClaimsNotice.tsx +70 -0
  52. package/components/Claims/claims-ui.test.ts +161 -0
  53. package/components/Claims/format.ts +57 -0
  54. package/components/Claims/index.ts +3 -0
  55. package/components/Draft/DraftChips.tsx +6 -1
  56. package/components/Draft/DraftPanel.tsx +90 -3
  57. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  58. package/components/Draft/RoleChips.tsx +110 -0
  59. package/components/Export/ExportActions.tsx +136 -0
  60. package/components/Export/ExportSheet.tsx +217 -0
  61. package/components/Export/RangePicker.tsx +144 -0
  62. package/components/Export/Seg.tsx +38 -0
  63. package/components/Export/TurnSelectBar.tsx +45 -0
  64. package/components/Hosts/AccountMachines.tsx +32 -18
  65. package/components/Hosts/HostRow.tsx +155 -14
  66. package/components/Hosts/IncomingLinkSheet.tsx +28 -10
  67. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  68. package/components/Models/AgentRoleCard.tsx +114 -0
  69. package/components/Models/AgentRolesSection.tsx +132 -0
  70. package/components/Models/ModelBadges.tsx +48 -0
  71. package/components/Models/ModelsSection.tsx +4 -2
  72. package/components/Models/RoleModelSelect.tsx +87 -0
  73. package/components/Pane/DesktopTabBar.tsx +2 -0
  74. package/components/Pane/MobileTabBar.tsx +12 -0
  75. package/components/Pane/PaneMenu.tsx +10 -0
  76. package/components/SessionList/GroupShelves.tsx +195 -0
  77. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  78. package/components/SessionList/RowSubtitle.tsx +7 -1
  79. package/components/SessionList/SelectionToolbar.tsx +52 -1
  80. package/components/SessionList/SessionRow.tsx +19 -7
  81. package/components/SessionList/SessionRowMenu.tsx +23 -0
  82. package/components/SessionList/SessionShelves.tsx +5 -0
  83. package/components/SessionList/SidebarBody.tsx +6 -0
  84. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  85. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  86. package/components/SessionList/SidebarHeader.tsx +5 -0
  87. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  88. package/components/SessionList/index.tsx +18 -14
  89. package/components/SessionList/useSidebarData.ts +10 -0
  90. package/components/Sessions/RolePill.tsx +196 -0
  91. package/components/Settings/SettingsDialog.tsx +9 -0
  92. package/components/Share/ShareDialog.tsx +13 -0
  93. package/components/ui/notice.tsx +46 -0
  94. package/components/ui/status-pill.tsx +39 -0
  95. package/data/analyst/index.ts +3 -0
  96. package/data/analyst/keys.ts +6 -0
  97. package/data/analyst/mutations.ts +116 -0
  98. package/data/analyst/queries.ts +35 -0
  99. package/data/claims/index.ts +2 -0
  100. package/data/claims/keys.ts +5 -0
  101. package/data/claims/queries.ts +47 -0
  102. package/data/exports/index.ts +2 -0
  103. package/data/exports/keys.ts +3 -0
  104. package/data/exports/queries.ts +51 -0
  105. package/data/models/index.ts +98 -0
  106. package/data/peers/keys.ts +1 -0
  107. package/data/peers/queries.ts +31 -0
  108. package/data/push/topics.ts +9 -0
  109. package/data/session-groups/index.ts +156 -0
  110. package/data/sessions/index.ts +0 -1
  111. package/data/sessions/pin.ts +2 -6
  112. package/data/sessions/queries.ts +36 -29
  113. package/data/sessions/shelf.ts +2 -6
  114. package/data/tasks/index.ts +2 -0
  115. package/hooks/useSessions.ts +0 -12
  116. package/lib/__fixtures__/peer-rpc.ts +65 -33
  117. package/lib/agents/brief.ts +1 -0
  118. package/lib/agents/spawn.ts +4 -0
  119. package/lib/analyst/__fixtures__/items.ts +57 -0
  120. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  121. package/lib/analyst/brief.ts +17 -0
  122. package/lib/analyst/buildDigest.ts +204 -0
  123. package/lib/analyst/createLink.ts +98 -0
  124. package/lib/analyst/endLink.ts +31 -0
  125. package/lib/analyst/forward.ts +109 -0
  126. package/lib/analyst/index.ts +16 -0
  127. package/lib/analyst/linkView.ts +38 -0
  128. package/lib/analyst/onTurnEnd.ts +119 -0
  129. package/lib/analyst/parseReview.ts +41 -0
  130. package/lib/analyst/pushDigest.ts +143 -0
  131. package/lib/analyst/resume.ts +35 -0
  132. package/lib/analyst/route.ts +50 -0
  133. package/lib/analyst/signals.ts +121 -0
  134. package/lib/analyst/store.ts +303 -0
  135. package/lib/analyst/types.ts +140 -0
  136. package/lib/bus/condense.ts +5 -1
  137. package/lib/bus/index.ts +13 -0
  138. package/lib/bus/read-scope.ts +12 -0
  139. package/lib/bus/role-alias.ts +70 -0
  140. package/lib/bus/targets.ts +3 -0
  141. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  142. package/lib/chat/drivers/pi-approvals.ts +7 -4
  143. package/lib/chat/drivers/pi-mapper.ts +38 -0
  144. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  145. package/lib/chat/drivers/pi.ts +8 -3
  146. package/lib/chat/drivers/turn-items.ts +36 -11
  147. package/lib/chat/drivers/turn-outcome.ts +108 -0
  148. package/lib/chat/events.ts +8 -0
  149. package/lib/chat/registry.ts +13 -0
  150. package/lib/chat/store.ts +19 -0
  151. package/lib/chat/turns.ts +1 -0
  152. package/lib/chat/worker/extras.ts +4 -2
  153. package/lib/claims/collectLocal.ts +123 -0
  154. package/lib/claims/describe.ts +41 -0
  155. package/lib/claims/exchange.ts +91 -0
  156. package/lib/claims/goal.ts +45 -0
  157. package/lib/claims/index.ts +10 -0
  158. package/lib/claims/loop.ts +51 -0
  159. package/lib/claims/machine.ts +21 -0
  160. package/lib/claims/mine.ts +43 -0
  161. package/lib/claims/notifyOverlaps.ts +101 -0
  162. package/lib/claims/onMerged.ts +85 -0
  163. package/lib/claims/outside.ts +36 -0
  164. package/lib/claims/overlap.ts +87 -0
  165. package/lib/claims/overlapCache.ts +86 -0
  166. package/lib/claims/overlapText.ts +36 -0
  167. package/lib/claims/peers.ts +56 -0
  168. package/lib/claims/startCheck.ts +100 -0
  169. package/lib/claims/startWarning.ts +29 -0
  170. package/lib/claims/store.ts +366 -0
  171. package/lib/claims/types.ts +102 -0
  172. package/lib/claims/view.ts +80 -0
  173. package/lib/claims/yield.ts +24 -0
  174. package/lib/db/changes.ts +12 -0
  175. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  176. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  177. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  178. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  179. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  180. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  181. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  182. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  183. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  184. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  185. package/lib/db/queries.ts +0 -40
  186. package/lib/db/session-parts/columns.ts +3 -0
  187. package/lib/db/types.ts +19 -8
  188. package/lib/doctor/checks.ts +64 -1
  189. package/lib/done/archive.ts +2 -0
  190. package/lib/drafts.ts +4 -0
  191. package/lib/export/build.ts +470 -0
  192. package/lib/export/compact.ts +156 -0
  193. package/lib/export/estimate.ts +83 -0
  194. package/lib/export/fixtures.ts +45 -0
  195. package/lib/export/html.tsx +195 -0
  196. package/lib/export/json.ts +39 -0
  197. package/lib/export/load.ts +64 -0
  198. package/lib/export/markdown.ts +127 -0
  199. package/lib/export/request.ts +36 -0
  200. package/lib/export/service.ts +142 -0
  201. package/lib/export/summary.ts +86 -0
  202. package/lib/export/turns.ts +63 -0
  203. package/lib/export/view.ts +173 -0
  204. package/lib/git-remote.ts +43 -0
  205. package/lib/hosts/index.ts +10 -1
  206. package/lib/models/agent-binding.ts +102 -0
  207. package/lib/models/agent-roles-view.ts +157 -0
  208. package/lib/models/agent-roles.ts +87 -0
  209. package/lib/models/availability-refs.ts +17 -0
  210. package/lib/models/availability.ts +91 -0
  211. package/lib/models/catalog.ts +156 -0
  212. package/lib/models/config.ts +27 -0
  213. package/lib/models/locality.ts +33 -0
  214. package/lib/models/providers.ts +11 -0
  215. package/lib/models/resolve.ts +50 -12
  216. package/lib/models/roles.ts +12 -0
  217. package/lib/needs-you.ts +1 -1
  218. package/lib/orchestration.ts +2 -0
  219. package/lib/orchestrator/act.ts +54 -4
  220. package/lib/orchestrator/brief.ts +2 -1
  221. package/lib/orchestrator/claim-wait.ts +71 -0
  222. package/lib/orchestrator/conditions.ts +28 -1
  223. package/lib/orchestrator/describe.ts +6 -1
  224. package/lib/orchestrator/facts.ts +12 -2
  225. package/lib/orchestrator/read.ts +5 -1
  226. package/lib/orchestrator/serve.ts +37 -2
  227. package/lib/orchestrator/tool-names.ts +1 -0
  228. package/lib/orchestrator/tool-schemas.ts +31 -1
  229. package/lib/orchestrator/tools.ts +3 -1
  230. package/lib/orchestrator/untrusted.ts +5 -0
  231. package/lib/orchestrator/watcher.ts +19 -1
  232. package/lib/peer/account.ts +74 -2
  233. package/lib/peer/allowlist.ts +1 -1
  234. package/lib/peer/client.ts +26 -1
  235. package/lib/peer/link-cli.ts +19 -4
  236. package/lib/peer/links.ts +117 -5
  237. package/lib/peer/machines.ts +14 -2
  238. package/lib/peer/name.ts +32 -0
  239. package/lib/peer/pairing.ts +34 -5
  240. package/lib/peer/pin.ts +10 -3
  241. package/lib/peer/reasons.ts +7 -0
  242. package/lib/peer/revoke.ts +75 -0
  243. package/lib/peer/rpc-server.ts +53 -5
  244. package/lib/peer/unlink.ts +62 -0
  245. package/lib/peer/unlinked-proof.ts +44 -0
  246. package/lib/projects.ts +6 -2
  247. package/lib/security/demo.ts +7 -0
  248. package/lib/session-artifacts/agentos-files.ts +94 -0
  249. package/lib/session-artifacts/collect.ts +17 -0
  250. package/lib/session-groups/assignSessions.ts +81 -0
  251. package/lib/session-groups/cli.ts +61 -0
  252. package/lib/session-groups/createGroup.ts +28 -0
  253. package/lib/session-groups/index.ts +21 -0
  254. package/lib/session-groups/removeGroup.ts +36 -0
  255. package/lib/session-groups/reorderGroups.ts +31 -0
  256. package/lib/session-groups/resolveGroup.ts +19 -0
  257. package/lib/session-groups/route.ts +32 -0
  258. package/lib/session-groups/store.ts +85 -0
  259. package/lib/session-groups/types.ts +25 -0
  260. package/lib/session-groups/updateGroup.ts +35 -0
  261. package/lib/session-groups/workspace.ts +21 -0
  262. package/lib/sessions/crew.ts +72 -0
  263. package/lib/sessions/launch.ts +54 -11
  264. package/lib/settings-url.ts +1 -0
  265. package/lib/share/markdown.ts +2 -2
  266. package/lib/share/redact.ts +16 -10
  267. package/lib/share/render.tsx +1 -1
  268. package/lib/share/snapshot.ts +2 -2
  269. package/lib/sidebar/rollup.ts +1 -0
  270. package/lib/sidebar/row-menu.ts +8 -1
  271. package/lib/sidebar/shelves.ts +90 -15
  272. package/lib/sidebar/visible-order.ts +5 -0
  273. package/lib/tasks/hold-checks.ts +3 -1
  274. package/lib/tasks/holds.ts +3 -0
  275. package/lib/tasks/index.ts +11 -0
  276. package/lib/tasks/queue-rows.ts +8 -1
  277. package/lib/tasks/queue-start.ts +1 -0
  278. package/lib/tasks/queue-types.ts +9 -1
  279. package/lib/tasks/queue.ts +15 -5
  280. package/lib/tasks/schedule-types.ts +1 -0
  281. package/lib/test-temp.ts +19 -3
  282. package/lib/workspaces.ts +11 -3
  283. package/lib/worktree-deps/caches.ts +3 -2
  284. package/lib/worktree-deps/clone.ts +3 -1
  285. package/lib/worktree-deps/paths.ts +26 -0
  286. package/lib/worktree-sweep/testTemp.ts +23 -106
  287. package/package.json +1 -1
  288. package/scripts/agent-os +1 -1
  289. package/scripts/autodeploy +2 -0
  290. package/scripts/autodeploy.test.ts +31 -1
  291. package/scripts/claim-hook.sh +31 -0
  292. package/scripts/claim-hook.test.ts +230 -0
  293. package/scripts/lib/cli-links.sh +36 -0
  294. package/scripts/lib/commands.sh +3 -0
  295. package/scripts/lib/machine.sh +27 -2
  296. package/scripts/lib/machine.test.ts +25 -3
  297. package/scripts/sync-cli-links +7 -0
  298. package/scripts/sync-cli-links.test.ts +69 -0
  299. package/server.ts +12 -1
  300. package/stores/claimsUi.ts +22 -0
  301. package/stores/exportUi.ts +87 -0
  302. package/app/api/groups/[...path]/route.ts +0 -136
  303. package/app/api/groups/route.ts +0 -93
  304. package/data/groups/index.ts +0 -1
  305. package/data/groups/mutations.ts +0 -95
  306. package/hooks/useGroups.ts +0 -37
@@ -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. */
package/lib/peer/links.ts CHANGED
@@ -64,16 +64,18 @@ export const pinForHost = (hostId: string): PeerPin | null =>
64
64
 
65
65
  /** Pins a peer's keys to a Connect host. Pairing (S2) is the only caller. */
66
66
  export function savePin(pin: Omit<PeerPin, "state" | "suspendedReason">) {
67
- getDb()
68
- .prepare(
69
- `INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
67
+ const db = getDb();
68
+ db.prepare(`DELETE FROM peer_unlinked WHERE machine_id = ?`).run(
69
+ pin.machineId
70
+ );
71
+ db.prepare(
72
+ `INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
70
73
  VALUES (?, ?, ?, ?, ?)
71
74
  ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
72
75
  hostname = excluded.hostname, sign_pub = excluded.sign_pub,
73
76
  box_pub = excluded.box_pub, state = 'active', suspended_reason = NULL,
74
77
  paired_at = datetime('now'), last_error = NULL`
75
- )
76
- .run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
78
+ ).run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
77
79
  }
78
80
 
79
81
  export function suspendPin(hostId: string, reason: string) {
@@ -84,6 +86,112 @@ export function suspendPin(hostId: string, reason: string) {
84
86
  .run(reason, hostId);
85
87
  }
86
88
 
89
+ export function resumePin(hostId: string) {
90
+ getDb()
91
+ .prepare(
92
+ `UPDATE peer_links SET state = 'active', suspended_reason = NULL WHERE host_id = ?`
93
+ )
94
+ .run(hostId);
95
+ }
96
+
97
+ /** Every pin, for checking them against the account (revoke.ts). */
98
+ export const allPins = (): PeerPin[] =>
99
+ (getDb().prepare(SELECT).all() as Row[]).map((r) => toPin(r)!);
100
+
101
+ /** How long an unlinked machine is still answered "unknown peer", signed. */
102
+ export const UNLINKED_KEEP_MS = 30 * 24 * 3_600_000;
103
+
104
+ export interface Unlinked {
105
+ machineId: string;
106
+ hostId: string | null;
107
+ signPub: string;
108
+ /** The other machine unlinked it, rather than a person here. */
109
+ byPeer: boolean;
110
+ reason: string;
111
+ at: number;
112
+ }
113
+
114
+ interface UnlinkedRow {
115
+ machine_id: string;
116
+ host_id: string | null;
117
+ sign_pub: string;
118
+ by_peer: number;
119
+ reason: string;
120
+ at: number;
121
+ }
122
+
123
+ const toUnlinked = (r: UnlinkedRow | undefined): Unlinked | null =>
124
+ r
125
+ ? {
126
+ machineId: r.machine_id,
127
+ hostId: r.host_id,
128
+ signPub: r.sign_pub,
129
+ byPeer: r.by_peer === 1,
130
+ reason: r.reason,
131
+ at: r.at,
132
+ }
133
+ : null;
134
+
135
+ /** A machine unlinked here within the last 30 days, by its Connect id. */
136
+ export const unlinkedMachine = (
137
+ machineId: string,
138
+ now = Date.now()
139
+ ): Unlinked | null =>
140
+ toUnlinked(
141
+ getDb()
142
+ .prepare(`SELECT * FROM peer_unlinked WHERE machine_id = ? AND at > ?`)
143
+ .get(machineId, now - UNLINKED_KEEP_MS) as UnlinkedRow | undefined
144
+ );
145
+
146
+ export const unlinkedHost = (hostId: string): Unlinked | null =>
147
+ toUnlinked(
148
+ getDb()
149
+ .prepare(
150
+ `SELECT * FROM peer_unlinked WHERE host_id = ? ORDER BY at DESC LIMIT 1`
151
+ )
152
+ .get(hostId) as UnlinkedRow | undefined
153
+ );
154
+
155
+ export const hasRecentUnlinked = (now = Date.now()): boolean =>
156
+ getDb()
157
+ .prepare(`SELECT 1 FROM peer_unlinked WHERE at > ? LIMIT 1`)
158
+ .get(now - UNLINKED_KEEP_MS) !== undefined;
159
+
160
+ /**
161
+ * Removes a pin, keeping its signing key as unlinked (see the migration).
162
+ * The host stays: it may still hold sessions, and it says who unlinked it.
163
+ */
164
+ export function dropPin(
165
+ hostId: string,
166
+ o: { byPeer: boolean; reason: string },
167
+ now = Date.now()
168
+ ): PeerPin | null {
169
+ const pin = pinForHost(hostId);
170
+ if (!pin) return null;
171
+ const db = getDb();
172
+ db.transaction(() => {
173
+ db.prepare(`DELETE FROM peer_unlinked WHERE at <= ?`).run(
174
+ now - UNLINKED_KEEP_MS
175
+ );
176
+ db.prepare(
177
+ `INSERT INTO peer_unlinked (machine_id, host_id, sign_pub, by_peer, reason, at)
178
+ VALUES (?, ?, ?, ?, ?, ?)
179
+ ON CONFLICT(machine_id) DO UPDATE SET host_id = excluded.host_id,
180
+ sign_pub = excluded.sign_pub, by_peer = excluded.by_peer,
181
+ reason = excluded.reason, at = excluded.at`
182
+ ).run(
183
+ pin.machineId,
184
+ hostId,
185
+ pin.signPub,
186
+ o.byPeer ? 1 : 0,
187
+ o.reason.slice(0, 200),
188
+ now
189
+ );
190
+ db.prepare(`DELETE FROM peer_links WHERE host_id = ?`).run(hostId);
191
+ })();
192
+ return pin;
193
+ }
194
+
87
195
  /** Last outcome of a call to that peer, for Machines. */
88
196
  export function notePinResult(hostId: string, error: string | null) {
89
197
  getDb()
@@ -94,3 +202,7 @@ export function notePinResult(hostId: string, error: string | null) {
94
202
  )
95
203
  .run(...(error ? [error.slice(0, 300), hostId] : [hostId]));
96
204
  }
205
+
206
+ /** The other machine, `name`, unlinked this one: its signed call or refusal. */
207
+ export const unlinkedByPeer = (hostId: string, name: string): boolean =>
208
+ dropPin(hostId, { byPeer: true, reason: `Unlinked by ${name}` }) !== null;
@@ -17,6 +17,7 @@ export interface AccountMachine {
17
17
  /** The Machines host it's linked as, when it is. */
18
18
  hostId: string | null;
19
19
  linkState: "active" | "suspended" | null;
20
+ suspendedReason: string | null;
20
21
  }
21
22
 
22
23
  export interface AccountMachines {
@@ -24,6 +25,8 @@ export interface AccountMachines {
24
25
  entitlements: Entitlements | null;
25
26
  fetchedAt: number;
26
27
  error: string | null;
28
+ /** Set when the service hasn't answered for a day: since when. */
29
+ uncheckedSince: number | null;
27
30
  machines: AccountMachine[];
28
31
  }
29
32
 
@@ -39,10 +42,16 @@ export async function accountMachines(
39
42
  (
40
43
  getDb()
41
44
  .prepare(
42
- `SELECT p.machine_id, p.host_id, p.state FROM peer_links p
45
+ `SELECT p.machine_id, p.host_id, p.state, p.suspended_reason
46
+ FROM peer_links p
43
47
  JOIN hosts h ON h.id = p.host_id WHERE h.kind = 'connect'`
44
48
  )
45
- .all() as { machine_id: string; host_id: string; state: string }[]
49
+ .all() as {
50
+ machine_id: string;
51
+ host_id: string;
52
+ state: string;
53
+ suspended_reason: string | null;
54
+ }[]
46
55
  ).map((r) => [r.machine_id, r])
47
56
  );
48
57
  return {
@@ -50,6 +59,7 @@ export async function accountMachines(
50
59
  entitlements: view.entitlements,
51
60
  fetchedAt: view.fetchedAt,
52
61
  error: view.error,
62
+ uncheckedSince: view.uncheckedSince,
53
63
  machines: view.peers.map((p) => {
54
64
  const pin = pins.get(p.id);
55
65
  const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
@@ -66,6 +76,8 @@ export async function accountMachines(
66
76
  ? "active"
67
77
  : "suspended"
68
78
  : null,
79
+ suspendedReason:
80
+ pin && pin.state !== "active" ? pin.suspended_reason : null,
69
81
  };
70
82
  }),
71
83
  };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A machine's name as the Connect service tells it, made safe to show next
3
+ * to a pairing code: plain text on one line, no direction overrides, short,
4
+ * and with no digits at all once it holds 4 or more, so a lying service
5
+ * can't name a machine "type 482, 913" and have the person type that
6
+ * instead. Separators can't be enumerated ("482 and 913"), so the count is
7
+ * over the whole name.
8
+ */
9
+
10
+ export const NAME_MAX = 48;
11
+
12
+ // C0/C1 controls, zero-width and joiner characters, bidi embeddings,
13
+ // overrides and isolates, the byte-order mark.
14
+ const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
15
+ // Digits, any script.
16
+ const DIGIT = /\p{Nd}/gu;
17
+ const MAX_DIGITS = 3;
18
+
19
+ export const UNNAMED = "Unnamed machine";
20
+
21
+ export function cleanMachineName(raw: unknown): string {
22
+ if (typeof raw !== "string") return UNNAMED;
23
+ let s = raw
24
+ .normalize("NFKC")
25
+ .replace(/\s+/g, " ")
26
+ .replace(INVISIBLE, "")
27
+ .trim();
28
+ if ((s.match(DIGIT) ?? []).length > MAX_DIGITS) s = s.replace(DIGIT, "#");
29
+ if ([...s].length > NAME_MAX)
30
+ s = `${[...s].slice(0, NAME_MAX - 1).join("")}…`;
31
+ return s || UNNAMED;
32
+ }
@@ -159,7 +159,10 @@ export async function receiveHello(
159
159
  s.hellos.length >= UNPROVEN
160
160
  )
161
161
  return no(429, "too many requests");
162
- s.hellos.push({ at: now, from: null, source });
162
+ // Tagged later through this reference: other hellos may have been pushed
163
+ // by then, while this one waited on the plan and the account list.
164
+ const entry: Store["hellos"][number] = { at: now, from: null, source };
165
+ s.hellos.push(entry);
163
166
  const allowed = await deps.allowed();
164
167
  if (allowed !== true)
165
168
  return no(402, "Linking machines needs AgentOS Connect Pro");
@@ -179,7 +182,7 @@ export async function receiveHello(
179
182
  proven.filter((h) => h.from === hello.from).length >= PER_SENDER
180
183
  )
181
184
  return no(429, "too many link requests; wait a few minutes");
182
- s.hellos[s.hellos.length - 1].from = hello.from;
185
+ entry.from = hello.from;
183
186
  for (const old of s.requests.values())
184
187
  if (old.peer.id === hello.from && open(old)) {
185
188
  old.state = "declined";
@@ -309,14 +312,28 @@ export interface PendingView {
309
312
  lastSeenAt: string | null;
310
313
  expiresAt: number;
311
314
  attemptsLeft: number;
315
+ /** `accepted`: the code matched here; `confirmCode` goes on the other machine. */
316
+ state: "pending" | "accepted";
317
+ /** Only for a caller that could have accepted it (withCodes). */
318
+ confirmCode?: string;
312
319
  }
313
320
 
314
- /** Requests waiting for a person here to type the code. */
315
- export function pendingRequests(now = Date.now()): PendingView[] {
321
+ /**
322
+ * Requests waiting for a person here to type the code, and accepted ones
323
+ * still within their 5 minutes, whose second code must survive a reload.
324
+ */
325
+ export function pendingRequests(
326
+ now = Date.now(),
327
+ o: { withCodes?: boolean } = {}
328
+ ): PendingView[] {
316
329
  const s = store();
317
330
  sweep(s, now);
318
331
  return [...s.requests.values()]
319
- .filter((r) => r.state === "pending")
332
+ .filter(
333
+ (r) =>
334
+ r.state === "pending" ||
335
+ (r.state === "accepted" && now - r.createdAt <= REQUEST_TTL_MS)
336
+ )
320
337
  .map((r) => ({
321
338
  id: r.id,
322
339
  machineId: r.peer.id,
@@ -324,6 +341,10 @@ export function pendingRequests(now = Date.now()): PendingView[] {
324
341
  lastSeenAt: r.peer.lastSeenAt,
325
342
  expiresAt: r.createdAt + REQUEST_TTL_MS,
326
343
  attemptsLeft: MAX_WRONG - r.wrong,
344
+ state: r.state === "accepted" ? "accepted" : "pending",
345
+ ...(o.withCodes &&
346
+ r.state === "accepted" &&
347
+ r.confirm && { confirmCode: r.confirm }),
327
348
  }));
328
349
  }
329
350
 
@@ -379,6 +400,14 @@ export async function acceptRequest(
379
400
  status: 402,
380
401
  error: "Linking machines needs AgentOS Connect Pro.",
381
402
  };
403
+ // It may have been declined, replaced, expired or accepted meanwhile.
404
+ sweep(s, deps.now());
405
+ if (r.state !== "pending" || s.requests.get(id) !== r)
406
+ return {
407
+ ok: false,
408
+ status: 404,
409
+ error: "That request has ended. Start again from the other machine.",
410
+ };
382
411
  let hostId: string;
383
412
  try {
384
413
  hostId = deps.pin({ ...r.peer, signPub: r.aSign, boxPub: r.aBox });
package/lib/peer/pin.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * of a pin: a machine whose keys changed is unlinked first, by a person.
5
5
  */
6
6
 
7
- import { createConnectHost, listRemoteHosts } from "../hosts";
8
- import { pinForMachine, savePin } from "./links";
7
+ import { createConnectHost, getHost, listRemoteHosts } from "../hosts";
8
+ import { pinForMachine, savePin, unlinkedMachine } from "./links";
9
+ import { cleanMachineName } from "./name";
9
10
 
10
11
  export interface Paired {
11
12
  id: string;
@@ -43,7 +44,13 @@ export function keysChanged(
43
44
  export function pinPeer(p: Paired): string {
44
45
  const pinned = pinForMachine(p.id);
45
46
  if (keysChanged(p.id, p)) throw new Error(KEY_CHANGED);
46
- const hostId = pinned?.hostId ?? createConnectHost(freeName(p.name)).id;
47
+ // Linked again after an unlink: back on the host it had, if it's still here.
48
+ const was = unlinkedMachine(p.id)?.hostId;
49
+ const reuse = was && getHost(was)?.kind === "connect" ? was : null;
50
+ const hostId =
51
+ pinned?.hostId ??
52
+ reuse ??
53
+ createConnectHost(freeName(cleanMachineName(p.name))).id;
47
54
  savePin({
48
55
  hostId,
49
56
  machineId: p.id,
@@ -0,0 +1,7 @@
1
+ /** Why a Connect link is suspended (revoke.ts), shared with the UI. */
2
+
3
+ import { CONNECT_SITE } from "../connect/view";
4
+
5
+ export const REMOVED_FROM_ACCOUNT = "removed from your account";
6
+ export const NEEDS_PRO = "Linking machines needs Pro";
7
+ export const UPGRADE_URL = `${CONNECT_SITE}/settings/billing`;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Suspending links when the account says so, and lifting that when it says
3
+ * otherwise. A pin is never deleted here: a machine removed from the
4
+ * account by mistake comes back linked as soon as its key is listed again,
5
+ * with no new pairing. The account can only take trust away this way, never
6
+ * add it: a pin it didn't suspend is never touched, and a suspended pin is
7
+ * resumed only with the very key it was paired with.
8
+ */
9
+
10
+ import { allPins, resumePin, suspendPin, type PeerPin } from "./links";
11
+ import { NEEDS_PRO, REMOVED_FROM_ACCOUNT } from "./reasons";
12
+
13
+ export { NEEDS_PRO, REMOVED_FROM_ACCOUNT };
14
+
15
+ const OURS: ReadonlySet<string> = new Set([REMOVED_FROM_ACCOUNT, NEEDS_PRO]);
16
+
17
+ export interface AccountSays {
18
+ /** The plan's answer; null when it hasn't said. */
19
+ linkMachines: boolean | null;
20
+ /** Each listed machine's signing key, or null when this isn't a fresh list. */
21
+ listed: Map<string, string> | null;
22
+ }
23
+
24
+ /** What this pin's state should be, or null to leave it as it is. */
25
+ export function wanted(
26
+ pin: PeerPin,
27
+ says: AccountSays
28
+ ): { state: "active" } | { state: "suspended"; reason: string } | null {
29
+ if (pin.state === "suspended" && !OURS.has(pin.suspendedReason ?? ""))
30
+ return null;
31
+ if (says.linkMachines === false)
32
+ return { state: "suspended", reason: NEEDS_PRO };
33
+ const key = says.listed?.get(pin.machineId);
34
+ if (says.listed && key !== pin.signPub)
35
+ return { state: "suspended", reason: REMOVED_FROM_ACCOUNT };
36
+ if (pin.state === "active") return null;
37
+ const lifted =
38
+ pin.suspendedReason === NEEDS_PRO
39
+ ? says.linkMachines === true
40
+ : says.listed !== null && key === pin.signPub;
41
+ return lifted ? { state: "active" } : null;
42
+ }
43
+
44
+ export interface Changed {
45
+ hostId: string;
46
+ state: "active" | "suspended";
47
+ reason: string | null;
48
+ }
49
+
50
+ /** Applies what the account says to every pin; returns what changed. */
51
+ export function reconcilePins(
52
+ says: AccountSays,
53
+ pins: PeerPin[] = allPins()
54
+ ): Changed[] {
55
+ const changed: Changed[] = [];
56
+ for (const pin of pins) {
57
+ const want = wanted(pin, says);
58
+ if (!want) continue;
59
+ if (want.state === "active") {
60
+ resumePin(pin.hostId);
61
+ changed.push({ hostId: pin.hostId, state: "active", reason: null });
62
+ } else if (
63
+ pin.state !== "suspended" ||
64
+ pin.suspendedReason !== want.reason
65
+ ) {
66
+ suspendPin(pin.hostId, want.reason);
67
+ changed.push({
68
+ hostId: pin.hostId,
69
+ state: "suspended",
70
+ reason: want.reason,
71
+ });
72
+ }
73
+ }
74
+ return changed;
75
+ }