@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
@@ -15,7 +15,10 @@
15
15
  * Refusals before decryption are plain and fixed (`bad envelope`, `bad
16
16
  * signature`, `stale`, `replay`, `busy`). An unknown sender, a suspended one
17
17
  * and a bad signature all read `bad signature`, so a stranger can't tell
18
- * which machines are linked here; the audit keeps the difference. Everything
18
+ * which machines are linked here; the audit keeps the difference. The one
19
+ * exception is a machine unlinked here, proven by the key it was pinned
20
+ * with: it gets 403 `unknown peer`, signed (unlinked-proof.ts), so it can
21
+ * show that it was unlinked. Everything
19
22
  * after is inside the envelope,
20
23
  * always HTTP 200, so whoever carries the traffic never learns the route or
21
24
  * its status.
@@ -45,7 +48,14 @@ import {
45
48
  type BoxKey,
46
49
  type Identity,
47
50
  } from "./keys";
48
- import { hasActivePin, pinForMachine, type PeerPin } from "./links";
51
+ import {
52
+ hasActivePin,
53
+ hasRecentUnlinked,
54
+ pinForMachine,
55
+ unlinkedMachine,
56
+ type PeerPin,
57
+ } from "./links";
58
+ import { UNLINKED_PROOF_HEADER, signUnlinked } from "./unlinked-proof";
49
59
  import { sqliteNonceStore } from "./nonce-store";
50
60
  import { createReplayGuard, type ReplayGuard } from "./replay";
51
61
  import {
@@ -75,7 +85,11 @@ export interface RpcDeps {
75
85
  identity: () => Identity | null;
76
86
  boxKey: () => BoxKey;
77
87
  pinForMachine: (machineId: string) => PeerPin | null;
78
- /** False while no link is active: the route is closed to everyone. */
88
+ /** A machine unlinked here lately: the key it was pinned with. */
89
+ unlinked?: (
90
+ machineId: string
91
+ ) => { signPub: string; hostId: string | null } | null;
92
+ /** False while no link is active or recently unlinked: the route is closed to everyone. */
79
93
  anyActive: () => boolean;
80
94
  replay: ReplayGuard;
81
95
  dispatch: (d: Dispatch) => Promise<PeerResponse>;
@@ -125,6 +139,10 @@ export async function handlePeerRpc(
125
139
  const self = deps.identity();
126
140
  let fromMachine: string | null = null;
127
141
  let pin: PeerPin | null = null;
142
+ // An unlinked sender whose header its old key proved, and its nonce.
143
+ let unlinkedHost: string | null = null;
144
+ let viaUnlinked = false;
145
+ let provenUnlinked: string | null = null;
128
146
  const audit = (
129
147
  outcome: Outcome,
130
148
  status: number,
@@ -133,7 +151,7 @@ export async function handlePeerRpc(
133
151
  bytes = 0
134
152
  ) =>
135
153
  deps.audit({
136
- hostId: pin?.hostId ?? null,
154
+ hostId: pin?.hostId ?? unlinkedHost,
137
155
  machineId: fromMachine,
138
156
  direction: "in",
139
157
  method,
@@ -144,6 +162,19 @@ export async function handlePeerRpc(
144
162
  outcome,
145
163
  });
146
164
  const refuse = (refusal: Refusal): null => {
165
+ if (refusal === "unknown_peer" && provenUnlinked && self && fromMachine) {
166
+ audit("unknown_peer", 403);
167
+ res.setHeader(
168
+ UNLINKED_PROOF_HEADER,
169
+ signUnlinked(self.signKey, {
170
+ from: self.machineId,
171
+ to: fromMachine,
172
+ nonce: provenUnlinked,
173
+ })
174
+ );
175
+ plain(res, 403, "unknown peer");
176
+ return null;
177
+ }
147
178
  const [status, text, outcome] = REFUSED[refusal];
148
179
  audit(outcome, status);
149
180
  plain(res, status, text);
@@ -161,6 +192,18 @@ export async function handlePeerRpc(
161
192
  : found.state !== "active"
162
193
  ? "suspended"
163
194
  : null;
195
+ if (!found) {
196
+ const gone = deps.unlinked?.(machineId);
197
+ if (gone)
198
+ try {
199
+ const key = signPublic(gone.signPub);
200
+ unlinkedHost = gone.hostId;
201
+ viaUnlinked = true;
202
+ return key;
203
+ } catch {
204
+ // Unreadable: a decoy, like any stranger.
205
+ }
206
+ }
164
207
  if (!found || hidden) return DECOY_KEY;
165
208
  try {
166
209
  return signPublic(found.signPub);
@@ -210,6 +253,10 @@ export async function handlePeerRpc(
210
253
  now
211
254
  );
212
255
  if (fresh !== "ok") return { refusal: fresh };
256
+ if (viaUnlinked) {
257
+ provenUnlinked = header.nonce;
258
+ return { refusal: "unknown_peer" };
259
+ }
213
260
  claimed = `${header.from}:${header.nonce}`;
214
261
  return { peer: header.from };
215
262
  },
@@ -360,7 +407,8 @@ export function rpcDeps(port: number): RpcDeps {
360
407
  identity: () => currentIdentity(),
361
408
  boxKey: () => (box ??= loadBoxKey()),
362
409
  pinForMachine,
363
- anyActive: hasActivePin,
410
+ unlinked: (machineId) => unlinkedMachine(machineId),
411
+ anyActive: () => hasActivePin() || hasRecentUnlinked(),
364
412
  replay: createReplayGuard({ store: sqliteNonceStore() }),
365
413
  dispatch: loopbackDispatch(port),
366
414
  audit: recordPeerCall,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Unlinking a machine linked through Connect, from either side. Here: the
3
+ * pin goes at once (links.ts dropPin), then the other machine is told with a
4
+ * signed POST /api/peer/unlink, best effort. There: the same call drops its
5
+ * pin and its Machines says "Unlinked by <this machine>". If that call never
6
+ * arrives, the other machine's next call here is answered with a signed
7
+ * "unknown peer" (rpc-server.ts), which does the same.
8
+ */
9
+
10
+ import { deleteHost } from "../hosts";
11
+ import { hostLink, type HostLink } from "../hosts/remote-api";
12
+ import { peerCall } from "./client";
13
+ import { dropPin, pinForHost } from "./links";
14
+
15
+ export const UNLINKED_HERE = "Unlinked here";
16
+
17
+ export interface UnlinkDeps {
18
+ tell: (link: HostLink) => Promise<unknown>;
19
+ remove: (hostId: string) => void;
20
+ }
21
+
22
+ const realDeps: UnlinkDeps = {
23
+ tell: (link) =>
24
+ peerCall(link, "/api/peer/unlink", { body: {}, timeout: 8_000 }),
25
+ remove: deleteHost,
26
+ };
27
+
28
+ export interface UnlinkResult {
29
+ name: string;
30
+ /** The other machine confirmed it dropped its side. */
31
+ told: boolean;
32
+ /** The machine left Machines here too (it held no sessions or projects). */
33
+ removed: boolean;
34
+ }
35
+
36
+ /** A person here unlinks it. Throws when it isn't linked through Connect. */
37
+ export async function unlinkPeer(
38
+ hostId: string,
39
+ deps: UnlinkDeps = realDeps
40
+ ): Promise<UnlinkResult> {
41
+ const link = hostLink(hostId);
42
+ if (!link?.connect || !pinForHost(hostId))
43
+ throw new Error("That machine isn't linked through Connect.");
44
+ dropPin(hostId, { byPeer: false, reason: UNLINKED_HERE });
45
+ let told = false;
46
+ // A suspended link is refused there too; nothing would arrive.
47
+ if (link.connect.state === "active")
48
+ try {
49
+ await deps.tell(link);
50
+ told = true;
51
+ } catch {
52
+ // Best effort: its next call here is refused and says why.
53
+ }
54
+ let removed = false;
55
+ try {
56
+ deps.remove(hostId);
57
+ removed = true;
58
+ } catch {
59
+ // It still holds sessions or projects; it stays, unlinked.
60
+ }
61
+ return { name: link.hostName, told, removed };
62
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The one refusal that is signed: "unknown peer", sent to a machine that was
3
+ * unlinked here, bound to its own request's nonce. Only the key it had pinned
4
+ * for this machine verifies it, so whoever carries the traffic can't forge an
5
+ * unlink, and a replayed one never matches a new request.
6
+ */
7
+
8
+ import crypto, { type KeyObject } from "crypto";
9
+
10
+ export const UNLINKED_PROOF_HEADER = "x-agentos-peer-unlinked";
11
+ const DOMAIN = "agentos-peer-unlinked-v1";
12
+
13
+ interface Bound {
14
+ /** The machine refusing: the one that unlinked. */
15
+ from: string;
16
+ /** The caller, which it no longer knows. */
17
+ to: string;
18
+ /** The caller's request nonce, base64url. */
19
+ nonce: string;
20
+ }
21
+
22
+ const message = (o: Bound) =>
23
+ Buffer.from(`${DOMAIN}|${o.from}|${o.to}|${o.nonce}`);
24
+
25
+ export const signUnlinked = (signKey: KeyObject, o: Bound): string =>
26
+ crypto.sign(null, message(o), signKey).toString("base64url");
27
+
28
+ export function verifyUnlinked(
29
+ signPub: KeyObject,
30
+ o: Bound,
31
+ sig: string | null
32
+ ): boolean {
33
+ if (!sig || !/^[A-Za-z0-9_-]{86}$/.test(sig)) return false;
34
+ try {
35
+ return crypto.verify(
36
+ null,
37
+ message(o),
38
+ signPub,
39
+ Buffer.from(sig, "base64url")
40
+ );
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
package/lib/projects.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type DevServerType,
20
20
  } from "./db";
21
21
  import { resolveModelForAgent } from "./model-catalog";
22
+ import { clearForeignGroup } from "./session-groups/assignSessions";
22
23
  import type { AgentType } from "./providers";
23
24
 
24
25
  const execAsync = promisify(exec);
@@ -293,6 +294,7 @@ export function deleteProject(id: string): boolean {
293
294
 
294
295
  // Its merge settings override (lib/tasks/merge-policy.ts)
295
296
  db.prepare(`DELETE FROM settings WHERE key = ?`).run(`merge.project.${id}`);
297
+ db.prepare(`DELETE FROM settings WHERE key = ?`).run(`models.project.${id}`);
296
298
 
297
299
  // Delete project
298
300
  queries.deleteProject(db).run(id);
@@ -312,7 +314,7 @@ export function getProjectSessions(projectId: string): Session[] {
312
314
  export function moveSessionToProject(
313
315
  sessionId: string,
314
316
  projectId: string
315
- ): void {
317
+ ): { clearedGroup: string | null } {
316
318
  const to = getProject(projectId);
317
319
  const from = db
318
320
  .prepare(
@@ -321,7 +323,7 @@ export function moveSessionToProject(
321
323
  )
322
324
  .get(sessionId) as
323
325
  { workspace_id: string | null; own: string | null } | undefined;
324
- db.transaction(() => {
326
+ return db.transaction(() => {
325
327
  queries.updateSessionProject(db).run(projectId, sessionId);
326
328
  // A project's session follows the project; one left in none keeps the
327
329
  // workspace it was in.
@@ -333,6 +335,8 @@ export function moveSessionToProject(
333
335
  : (from?.workspace_id ?? from?.own ?? null),
334
336
  sessionId
335
337
  );
338
+ // A group belongs to the workspace the session just left.
339
+ return { clearedGroup: clearForeignGroup(sessionId) };
336
340
  })();
337
341
  }
338
342
 
@@ -49,23 +49,30 @@ export function assertDemoSandbox(env: Env = process.env, home = os.homedir()) {
49
49
  // "projects/[id]"): Next serves the folder, so an id must never match them.
50
50
  // The test walks app/api and fails when one is missing here.
51
51
  export const STATIC_SIBLINGS = [
52
+ "account",
52
53
  "archived",
53
54
  "arrived",
54
55
  "availability",
55
56
  "browse",
57
+ "check",
56
58
  "clone",
57
59
  "connect",
58
60
  "detect",
59
61
  "done-idle",
60
62
  "draft",
61
63
  "ended",
64
+ "exchange",
62
65
  "held-replies",
63
66
  "import",
64
67
  "init",
68
+ "group",
65
69
  "init-script",
66
70
  "me",
67
71
  "items",
72
+ "link",
68
73
  "network",
74
+ "order",
75
+ "pending",
69
76
  "pdf",
70
77
  "preview",
71
78
  "queue",
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What a task saves under .agentos/mockups and .agentos/screenshots. Git
3
+ * ignores that folder (its .gitignore is `*`) and a Bash write isn't a
4
+ * file-writing tool call, so neither other source lists them.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { isSecretPath } from "../secret-paths";
10
+ import { SCREENSHOT_DIR } from "../tasks/ui-brief";
11
+ import { kindForPath, type Candidate } from "./derive";
12
+
13
+ export const MOCKUP_DIR = ".agentos/mockups";
14
+ export const AGENTOS_DIRS = [MOCKUP_DIR, SCREENSHOT_DIR];
15
+ const MAX_DEPTH = 3;
16
+ const MAX_LISTED = 200;
17
+
18
+ const isAgentosFile = (rel: string) =>
19
+ AGENTOS_DIRS.some((d) => rel.startsWith(`${d}/`));
20
+
21
+ function candidate(
22
+ worktree: string,
23
+ rel: string,
24
+ at: number
25
+ ): Candidate | null {
26
+ const file = path.join(worktree, rel);
27
+ if (isSecretPath(file)) return null;
28
+ return {
29
+ key: `file:${file}`,
30
+ kind: kindForPath(file),
31
+ name: path.basename(file),
32
+ at,
33
+ by: "shell",
34
+ source: { type: "file", path: file },
35
+ };
36
+ }
37
+
38
+ function walk(root: string, rel: string, depth: number, out: string[]): void {
39
+ if (out.length >= MAX_LISTED) return;
40
+ let names: fs.Dirent[];
41
+ try {
42
+ names = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
43
+ } catch {
44
+ return;
45
+ }
46
+ for (const d of names) {
47
+ const child = `${rel}/${d.name}`;
48
+ // Symlinks are skipped: they could point out of the worktree.
49
+ if (d.isFile()) out.push(child);
50
+ else if (d.isDirectory() && depth < MAX_DEPTH)
51
+ walk(root, child, depth + 1, out);
52
+ }
53
+ }
54
+
55
+ export function agentosFiles(worktree: string): { rel: string; at: number }[] {
56
+ const rels: string[] = [];
57
+ for (const dir of AGENTOS_DIRS) walk(worktree, dir, 1, rels);
58
+ return rels.slice(0, MAX_LISTED).flatMap((rel) => {
59
+ try {
60
+ return [{ rel, at: fs.statSync(path.join(worktree, rel)).mtimeMs }];
61
+ } catch {
62
+ return [];
63
+ }
64
+ });
65
+ }
66
+
67
+ export function fromAgentosDirs(
68
+ worktree: string,
69
+ known: Set<string>
70
+ ): Candidate[] {
71
+ return agentosFiles(worktree).flatMap(({ rel, at }) => {
72
+ const c = candidate(worktree, rel, at);
73
+ return c && !known.has(c.key) ? [c] : [];
74
+ });
75
+ }
76
+
77
+ // After the worktree is gone: the copies that were kept.
78
+ export function fromKept(
79
+ worktree: string,
80
+ kept: string[],
81
+ at: number,
82
+ known: Set<string>
83
+ ): Candidate[] {
84
+ return kept.filter(isAgentosFile).flatMap((rel) => {
85
+ const c = candidate(worktree, rel, at);
86
+ return c && !known.has(c.key) ? [c] : [];
87
+ });
88
+ }
89
+
90
+ // Changes when a file is added, removed or rewritten there.
91
+ export function agentosSignature(worktree: string): string {
92
+ const files = agentosFiles(worktree);
93
+ return `${files.length}:${files.reduce((m, f) => Math.max(m, f.at), 0)}`;
94
+ }
@@ -32,6 +32,7 @@ import {
32
32
  type ItemRow,
33
33
  type RunRow,
34
34
  } from "./derive";
35
+ import { agentosSignature, fromAgentosDirs, fromKept } from "./agentos-files";
35
36
  import { keepFiles, readManifest, relInside, sessionKeptDir } from "./keep";
36
37
 
37
38
  export const MAX_FILES = 500;
@@ -160,6 +161,16 @@ async function build(session: Session): Promise<Entry[]> {
160
161
  }
161
162
 
162
163
  const manifest = worktree && !worktreeThere ? readManifest(session.id) : null;
164
+ if (worktree) {
165
+ const known = new Set(candidates.map((c) => c.key));
166
+ candidates.push(
167
+ ...(worktreeThere
168
+ ? fromAgentosDirs(worktree, known)
169
+ : manifest
170
+ ? fromKept(worktree, manifest.kept, sqliteMs(manifest.at), known)
171
+ : [])
172
+ );
173
+ }
163
174
  if (worktree) {
164
175
  const planFile = path.join(worktree, PLAN_FILE);
165
176
  const named = candidates.some((c) => c.key === `file:${planFile}`);
@@ -255,6 +266,9 @@ interface Cached {
255
266
  }
256
267
  const cache = new Map<string, Cached>();
257
268
 
269
+ const isLocalWorktree = (s: Session) =>
270
+ !!s.worktree_path && (!s.host_id || s.host_id === "local");
271
+
258
272
  function signature(session: Session): string {
259
273
  const row = db
260
274
  .prepare(`SELECT max(seq) AS seq FROM chat_items WHERE session_id = ?`)
@@ -265,6 +279,9 @@ function signature(session: Session): string {
265
279
  session.pr_number,
266
280
  session.worktree_path,
267
281
  session.updated_at,
282
+ isLocalWorktree(session)
283
+ ? agentosSignature(expand(session.worktree_path!))
284
+ : "",
268
285
  ].join("|");
269
286
  }
270
287
 
@@ -0,0 +1,81 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { liveGroup } from "./store";
3
+ import { sessionWorkspaceId } from "./workspace";
4
+ import { GroupError, type SessionGroup } from "./types";
5
+
6
+ // The one place that checks the invariant: a session joins only a group of
7
+ // its own workspace. Membership is a view choice, so it leaves updated_at
8
+ // alone, like pinning.
9
+ export function checkMember(sessionId: string, group: SessionGroup): void {
10
+ const workspaceId = sessionWorkspaceId(sessionId);
11
+ if (workspaceId === undefined)
12
+ throw new GroupError("not-found", "Session not found");
13
+ if ((workspaceId ?? null) !== (group.workspace_id ?? null))
14
+ throw new GroupError(
15
+ "workspace",
16
+ `"${group.name}" is a group of another workspace`
17
+ );
18
+ }
19
+
20
+ // Files sessions into a group, or out of any (groupId null). All or none.
21
+ export function assignSessions(
22
+ sessionIds: readonly string[],
23
+ groupId: string | null
24
+ ): { moved: number } {
25
+ const ids = [...new Set(sessionIds)];
26
+ const group = groupId === null ? null : liveGroup(groupId);
27
+ const db = getDb();
28
+ return db.transaction(() => {
29
+ let moved = 0;
30
+ for (const id of ids) {
31
+ if (group) checkMember(id, group);
32
+ else if (sessionWorkspaceId(id) === undefined)
33
+ throw new GroupError("not-found", "Session not found");
34
+ moved += db
35
+ .prepare(
36
+ `UPDATE sessions SET session_group_id = ?
37
+ WHERE id = ? AND session_group_id IS NOT ?`
38
+ )
39
+ .run(group?.id ?? null, id, group?.id ?? null).changes;
40
+ }
41
+ return { moved };
42
+ })();
43
+ }
44
+
45
+ // For a session that is starting: its group may have been deleted, or its
46
+ // workspace changed, since it was asked for. Starting must not fail over a
47
+ // view choice, so it just starts ungrouped.
48
+ export function fileInGroup(sessionId: string, groupId: string): void {
49
+ try {
50
+ assignSessions([sessionId], groupId);
51
+ } catch (err) {
52
+ if (!(err instanceof GroupError)) throw err;
53
+ }
54
+ }
55
+
56
+ // A new session starts in the group of the one it came from (a worker from its
57
+ // conductor, a fork or a fresh start from its source), when that group is
58
+ // still live and in the new session's workspace.
59
+ export function inheritGroup(fromSessionId: string, toSessionId: string): void {
60
+ const row = getDb()
61
+ .prepare(`SELECT session_group_id AS id FROM sessions WHERE id = ?`)
62
+ .get(fromSessionId) as { id: string | null } | undefined;
63
+ if (row?.id) fileInGroup(toSessionId, row.id);
64
+ }
65
+
66
+ // Leaving a workspace leaves its groups (a project move into another one).
67
+ export function clearForeignGroup(sessionId: string): string | null {
68
+ const row = getDb()
69
+ .prepare(
70
+ `SELECT g.id AS id, g.name AS name, g.workspace_id AS ws
71
+ FROM sessions s JOIN session_groups g ON g.id = s.session_group_id
72
+ WHERE s.id = ?`
73
+ )
74
+ .get(sessionId) as
75
+ { id: string; name: string; ws: string | null } | undefined;
76
+ if (!row || (sessionWorkspaceId(sessionId) ?? null) === row.ws) return null;
77
+ getDb()
78
+ .prepare(`UPDATE sessions SET session_group_id = NULL WHERE id = ?`)
79
+ .run(sessionId);
80
+ return row.name;
81
+ }
@@ -0,0 +1,61 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { resolveReadable, readerScope } from "@/lib/bus/read-scope";
3
+ import { assignSessions } from "./assignSessions";
4
+ import { createGroup } from "./createGroup";
5
+ import { listGroups } from "./store";
6
+ import { updateGroup } from "./updateGroup";
7
+ import { resolveGroup } from "./resolveGroup";
8
+ import { GroupError } from "./types";
9
+
10
+ export type GroupCommand =
11
+ | { action: "list" }
12
+ | { action: "new"; name: string }
13
+ | { action: "rename"; group: string; name: string }
14
+ | { action: "add"; group: string; sessions: string[] }
15
+ | { action: "rm"; sessions: string[] };
16
+
17
+ // `aos group ...`: the caller's own workspace's groups, resolved the way
18
+ // `aos read` resolves sessions, so it reaches nothing outside that scope.
19
+ export function groupCommand(callerId: string, cmd: GroupCommand): string {
20
+ const scope = readerScope(callerId);
21
+ const ws = scope.workspaceId;
22
+ if (cmd.action === "list") return describe(ws, scope.label);
23
+ if (cmd.action === "new") {
24
+ const g = createGroup(ws, cmd.name);
25
+ return `created group "${g.name}" in your ${scope.label}`;
26
+ }
27
+ if (cmd.action === "rename") {
28
+ const from = resolveGroup(ws, cmd.group);
29
+ return `renamed "${from.name}" to "${updateGroup(from.id, { name: cmd.name }).name}"`;
30
+ }
31
+ if (!cmd.sessions.length)
32
+ throw new GroupError("invalid", "Name at least one session");
33
+ const target = cmd.action === "add" ? resolveGroup(ws, cmd.group) : null;
34
+ const ids = cmd.sessions.map((ref) => {
35
+ if (ref === "me") return callerId;
36
+ const found = resolveReadable(callerId, ref);
37
+ if (!found.ok) throw new GroupError("not-found", found.error);
38
+ return found.session.id;
39
+ });
40
+ assignSessions(ids, target?.id ?? null);
41
+ return target
42
+ ? `put ${ids.length} session${ids.length === 1 ? "" : "s"} in "${target.name}"`
43
+ : `took ${ids.length} session${ids.length === 1 ? "" : "s"} out of their group`;
44
+ }
45
+
46
+ function describe(workspaceId: string | null, label: string): string {
47
+ const groups = listGroups(workspaceId);
48
+ if (!groups.length) return `no groups in your ${label}`;
49
+ const counts = new Map(
50
+ (
51
+ getDb()
52
+ .prepare(
53
+ `SELECT session_group_id AS id, COUNT(*) AS n FROM sessions
54
+ WHERE session_group_id IS NOT NULL AND archived_at IS NULL
55
+ GROUP BY session_group_id`
56
+ )
57
+ .all() as { id: string; n: number }[]
58
+ ).map((r) => [r.id, r.n])
59
+ );
60
+ return groups.map((g) => `${g.name} · ${counts.get(g.id) ?? 0}`).join("\n");
61
+ }
@@ -0,0 +1,28 @@
1
+ import { randomUUID } from "crypto";
2
+ import { getDb } from "@/lib/db";
3
+ import { cleanName, findGroup, groupByName } from "./store";
4
+ import { GroupError, type SessionGroup } from "./types";
5
+
6
+ // A new group goes to the bottom of its workspace's order. The name is
7
+ // unique within the workspace, ignoring case.
8
+ export function createGroup(
9
+ workspaceId: string | null,
10
+ name: unknown
11
+ ): SessionGroup {
12
+ const clean = cleanName(name);
13
+ const db = getDb();
14
+ if (
15
+ workspaceId &&
16
+ !db.prepare(`SELECT 1 FROM workspaces WHERE id = ?`).get(workspaceId)
17
+ )
18
+ throw new GroupError("not-found", "Workspace not found");
19
+ if (groupByName(workspaceId, clean))
20
+ throw new GroupError("duplicate", `There is already a group "${clean}"`);
21
+ const id = randomUUID();
22
+ db.prepare(
23
+ `INSERT INTO session_groups (id, workspace_id, name, sort_order)
24
+ VALUES (?, ?, ?, (SELECT COALESCE(MAX(sort_order) + 1, 0)
25
+ FROM session_groups WHERE COALESCE(workspace_id, '') = COALESCE(?, '')))`
26
+ ).run(id, workspaceId, clean, workspaceId);
27
+ return findGroup(id)!;
28
+ }
@@ -0,0 +1,21 @@
1
+ export * from "./types";
2
+ export {
3
+ groupNames,
4
+ listGroups,
5
+ findGroup,
6
+ groupByName,
7
+ liveGroup,
8
+ } from "./store";
9
+ export { createGroup } from "./createGroup";
10
+ export { updateGroup } from "./updateGroup";
11
+ export { reorderGroups } from "./reorderGroups";
12
+ export { deleteGroup, restoreGroup } from "./removeGroup";
13
+ export {
14
+ assignSessions,
15
+ checkMember,
16
+ clearForeignGroup,
17
+ fileInGroup,
18
+ inheritGroup,
19
+ } from "./assignSessions";
20
+ export { resolveGroup } from "./resolveGroup";
21
+ export { sessionWorkspaceId } from "./workspace";
@@ -0,0 +1,36 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { findGroup, groupByName, liveGroup } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Soft delete: members keep their session_group_id and the sidebar ignores a
6
+ // deleted group, so they fall back to the status shelves and Undo (restore)
7
+ // puts them back. Nothing is hard-deleted.
8
+ export function deleteGroup(id: string): SessionGroup {
9
+ const group = liveGroup(id);
10
+ getDb()
11
+ .prepare(
12
+ `UPDATE session_groups SET deleted_at = datetime('now'),
13
+ updated_at = datetime('now') WHERE id = ?`
14
+ )
15
+ .run(id);
16
+ return group;
17
+ }
18
+
19
+ export function restoreGroup(id: string): SessionGroup {
20
+ const group = findGroup(id);
21
+ if (!group) throw new GroupError("not-found", "Group not found");
22
+ if (!group.deleted) return group;
23
+ // A new group took the name meanwhile.
24
+ if (groupByName(group.workspace_id, group.name))
25
+ throw new GroupError(
26
+ "duplicate",
27
+ `There is already a group "${group.name}"`
28
+ );
29
+ getDb()
30
+ .prepare(
31
+ `UPDATE session_groups SET deleted_at = NULL,
32
+ updated_at = datetime('now') WHERE id = ?`
33
+ )
34
+ .run(id);
35
+ return liveGroup(id);
36
+ }