@saadnvd1/agent-os 0.3.637 → 0.3.644

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 (211) hide show
  1. package/app/api/bus/branches/route.ts +25 -0
  2. package/app/api/bus/resurrect/route.test.ts +3 -6
  3. package/app/api/sessions/[id]/pr/route.ts +3 -3
  4. package/app/api/sessions/[id]/setup/route.ts +3 -3
  5. package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
  6. package/app/api/tasks/[id]/arm/route.ts +6 -5
  7. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  8. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  9. package/app/api/tasks/[id]/checks/route.ts +1 -1
  10. package/app/api/tasks/[id]/diff/route.ts +1 -1
  11. package/app/api/tasks/[id]/done/route.ts +2 -2
  12. package/app/api/tasks/[id]/drop/route.ts +6 -2
  13. package/app/api/tasks/[id]/export/route.ts +3 -5
  14. package/app/api/tasks/[id]/merge/route.ts +3 -3
  15. package/app/api/tasks/[id]/message/route.ts +2 -2
  16. package/app/api/tasks/[id]/model/route.ts +5 -1
  17. package/app/api/tasks/[id]/move/route.ts +5 -1
  18. package/app/api/tasks/[id]/moved/route.ts +6 -8
  19. package/app/api/tasks/[id]/plan/route.ts +4 -6
  20. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  21. package/app/api/tasks/[id]/priority/route.ts +4 -1
  22. package/app/api/tasks/[id]/resume/route.ts +3 -4
  23. package/app/api/tasks/[id]/stop/route.ts +2 -2
  24. package/app/api/tasks/route.ts +1 -1
  25. package/app/api/worktree-sweep/route.test.ts +40 -0
  26. package/app/api/worktree-sweep/route.ts +31 -0
  27. package/app/api/worktree-sweep/run/route.ts +26 -0
  28. package/components/Health/HealthPanel.tsx +4 -1
  29. package/components/Health/SweepRuns.tsx +125 -0
  30. package/components/Merge/GlobalMergeSection.tsx +2 -0
  31. package/components/Merge/ProjectMergeSection.tsx +2 -0
  32. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  33. package/components/Settings/NoApprovalsSection.tsx +1 -1
  34. package/components/Settings/SettingsDialog.tsx +1 -1
  35. package/data/worktree-sweep/index.ts +56 -0
  36. package/lib/approvals/enable.ts +1 -1
  37. package/lib/away/report.ts +5 -5
  38. package/lib/away/run.ts +2 -2
  39. package/lib/bookmarks/store.ts +3 -3
  40. package/lib/bus/held-reply.ts +2 -2
  41. package/lib/bus/index.ts +3 -3
  42. package/lib/chat/compact-context.ts +1 -1
  43. package/lib/chat/hold.ts +3 -3
  44. package/lib/chat/refresh.ts +1 -1
  45. package/lib/chat/registry.ts +3 -3
  46. package/lib/chat/runner.ts +1 -1
  47. package/lib/chat/worker/main.ts +1 -1
  48. package/lib/db/backup.ts +49 -0
  49. package/lib/db/index.ts +5 -1
  50. package/lib/db/lumifyhub.ts +8 -9
  51. package/lib/db/machine-scope.ts +12 -0
  52. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  53. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  54. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  55. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  56. package/lib/db/migrations/README.md +11 -0
  57. package/lib/db/queries.ts +16 -31
  58. package/lib/db/session-parts/columns.ts +186 -0
  59. package/lib/db/session-parts/fill.ts +33 -0
  60. package/lib/db/session-parts/index.ts +10 -0
  61. package/lib/db/session-parts/pauses.ts +11 -0
  62. package/lib/db/session-parts/placement.ts +11 -0
  63. package/lib/db/session-parts/plans.ts +31 -0
  64. package/lib/db/session-parts/prs.ts +31 -0
  65. package/lib/db/session-parts/rows.ts +69 -0
  66. package/lib/db/session-parts/tasks.ts +27 -0
  67. package/lib/db/session-parts/view.ts +50 -0
  68. package/lib/db/session-parts/write.ts +61 -0
  69. package/lib/done/archive.ts +2 -2
  70. package/lib/done/auto-archive.ts +1 -1
  71. package/lib/done/bulk.ts +1 -1
  72. package/lib/done/index.ts +8 -14
  73. package/lib/done/remote.ts +9 -20
  74. package/lib/health/evidence.ts +1 -1
  75. package/lib/health/facts.ts +1 -1
  76. package/lib/health/pieces.ts +1 -1
  77. package/lib/hosts/peer-actions.ts +2 -2
  78. package/lib/hosts/peer-mirror.ts +2 -2
  79. package/lib/hosts/peer-sync.ts +69 -32
  80. package/lib/learning-cards/input.ts +1 -1
  81. package/lib/learning-cards/store.ts +1 -1
  82. package/lib/models/plan-step.ts +10 -24
  83. package/lib/models/resume.ts +5 -7
  84. package/lib/notify/index.ts +1 -1
  85. package/lib/notify/push/events.ts +1 -1
  86. package/lib/notify/push/index.ts +1 -1
  87. package/lib/orchestrator/act.ts +1 -1
  88. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  89. package/lib/orchestrator/armed.ts +18 -16
  90. package/lib/orchestrator/ask-approvals.ts +1 -1
  91. package/lib/orchestrator/ask-heads.ts +1 -1
  92. package/lib/orchestrator/ask-settle.ts +1 -1
  93. package/lib/orchestrator/ask-verdict.ts +111 -0
  94. package/lib/orchestrator/asks.ts +26 -0
  95. package/lib/orchestrator/brakes.ts +1 -1
  96. package/lib/orchestrator/brief.ts +2 -1
  97. package/lib/orchestrator/digest.ts +2 -2
  98. package/lib/orchestrator/escalate.ts +25 -12
  99. package/lib/orchestrator/external-gates.ts +1 -0
  100. package/lib/orchestrator/external-pr.ts +2 -2
  101. package/lib/orchestrator/facts.ts +4 -28
  102. package/lib/orchestrator/gates.ts +94 -25
  103. package/lib/orchestrator/held-asks.ts +8 -11
  104. package/lib/orchestrator/held-review.ts +8 -4
  105. package/lib/orchestrator/home.ts +2 -2
  106. package/lib/orchestrator/loop-guard.ts +145 -0
  107. package/lib/orchestrator/merge-approvals.ts +2 -2
  108. package/lib/orchestrator/overview.ts +1 -1
  109. package/lib/orchestrator/pr-wake.ts +3 -5
  110. package/lib/orchestrator/questions.ts +1 -1
  111. package/lib/orchestrator/quick-store.ts +1 -1
  112. package/lib/orchestrator/quick.ts +11 -8
  113. package/lib/orchestrator/read.ts +1 -1
  114. package/lib/orchestrator/rebase.ts +8 -11
  115. package/lib/orchestrator/reconcile.ts +2 -2
  116. package/lib/orchestrator/review-prompt.ts +2 -0
  117. package/lib/orchestrator/review.ts +14 -4
  118. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  119. package/lib/orchestrator/signoff.ts +49 -11
  120. package/lib/orchestrator/targets.ts +1 -1
  121. package/lib/orchestrator/task-state.ts +4 -2
  122. package/lib/orchestrator/testing.ts +11 -14
  123. package/lib/orchestrator/watcher.ts +4 -0
  124. package/lib/plans/features.ts +1 -1
  125. package/lib/plans/release.ts +1 -1
  126. package/lib/ports.ts +1 -1
  127. package/lib/recipes/test-fixtures.ts +2 -3
  128. package/lib/resurrect/find.ts +1 -1
  129. package/lib/resurrect/index.ts +44 -20
  130. package/lib/resurrect/swept.ts +61 -0
  131. package/lib/schedules/store.ts +1 -1
  132. package/lib/session-audit/sources.ts +3 -3
  133. package/lib/session-logs/index.ts +1 -1
  134. package/lib/session-titles.ts +1 -1
  135. package/lib/sessions/setup-progress.ts +1 -1
  136. package/lib/sessions/stop.ts +1 -1
  137. package/lib/sessions/worktree-setup.ts +2 -1
  138. package/lib/simulator/index.ts +1 -1
  139. package/lib/simulator/task-devices.ts +1 -1
  140. package/lib/stacks/board.ts +1 -1
  141. package/lib/stacks/land.ts +1 -1
  142. package/lib/stacks/restack.ts +1 -1
  143. package/lib/stacks/start.ts +2 -2
  144. package/lib/stacks/testing.ts +17 -16
  145. package/lib/stacks/tick.ts +1 -1
  146. package/lib/stacks/view.ts +1 -1
  147. package/lib/status/incremental.ts +1 -1
  148. package/lib/tasks/contract/actions.ts +35 -0
  149. package/lib/tasks/contract/authorize.ts +82 -0
  150. package/lib/tasks/contract/client.ts +296 -0
  151. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  152. package/lib/tasks/contract/index.ts +28 -0
  153. package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
  154. package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
  155. package/lib/tasks/contract/routes.ts +26 -0
  156. package/lib/tasks/contract/start.ts +122 -0
  157. package/lib/tasks/contract/sync.ts +126 -0
  158. package/lib/tasks/contract/types.ts +41 -0
  159. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  160. package/lib/tasks/finish.ts +30 -23
  161. package/lib/tasks/held-replies.ts +7 -13
  162. package/lib/tasks/held.ts +14 -18
  163. package/lib/tasks/import.ts +19 -15
  164. package/lib/tasks/index.ts +34 -22
  165. package/lib/tasks/launch-gate.ts +1 -1
  166. package/lib/tasks/move-carry.ts +15 -7
  167. package/lib/tasks/move-flow.ts +29 -36
  168. package/lib/tasks/move-guard.ts +1 -1
  169. package/lib/tasks/move-recover.ts +6 -25
  170. package/lib/tasks/move-testing.ts +16 -7
  171. package/lib/tasks/move.ts +23 -23
  172. package/lib/tasks/moved-away.ts +1 -1
  173. package/lib/tasks/pause-hold.ts +13 -5
  174. package/lib/tasks/pause.ts +19 -23
  175. package/lib/tasks/plan-view.ts +1 -1
  176. package/lib/tasks/pr-activity.ts +11 -2
  177. package/lib/tasks/prune-branches.ts +193 -0
  178. package/lib/tasks/prune-scope.ts +22 -0
  179. package/lib/tasks/queue-placement.ts +1 -1
  180. package/lib/tasks/queue-rows.ts +4 -4
  181. package/lib/tasks/queue-start.ts +5 -3
  182. package/lib/tasks/queue-tick.ts +2 -2
  183. package/lib/tasks/queue.ts +14 -6
  184. package/lib/tasks/remote-branch.ts +170 -0
  185. package/lib/tasks/session.ts +7 -4
  186. package/lib/tasks/start.ts +4 -4
  187. package/lib/tasks/starter.ts +8 -11
  188. package/lib/tasks/testing.ts +13 -7
  189. package/lib/usage/calls.ts +1 -1
  190. package/lib/usage/compare.ts +1 -1
  191. package/lib/usage/quality.ts +1 -1
  192. package/lib/usage/turns.ts +1 -1
  193. package/lib/voice/vocab.ts +1 -1
  194. package/lib/worktree-sweep/commits.ts +65 -0
  195. package/lib/worktree-sweep/findOrphans.ts +139 -0
  196. package/lib/worktree-sweep/findSettled.ts +112 -0
  197. package/lib/worktree-sweep/index.ts +10 -0
  198. package/lib/worktree-sweep/judge.ts +31 -0
  199. package/lib/worktree-sweep/panes.ts +18 -0
  200. package/lib/worktree-sweep/runSweep.ts +243 -0
  201. package/lib/worktree-sweep/settings.ts +83 -0
  202. package/lib/worktree-sweep/start.ts +64 -0
  203. package/lib/worktree-sweep/state.ts +36 -0
  204. package/lib/worktree-sweep/store.ts +96 -0
  205. package/lib/worktree-sweep/types.ts +53 -0
  206. package/package.json +1 -1
  207. package/scripts/backfill-session-logs.ts +1 -1
  208. package/scripts/screenshots/seed.ts +19 -18
  209. package/server.ts +3 -0
  210. package/lib/tasks/held-routes.ts +0 -33
  211. package/lib/tasks/remote.ts +0 -255
@@ -17,7 +17,11 @@ import { unarchiveSession } from "../done/archive";
17
17
  import { reopenSession } from "../sidebar/shelf-state";
18
18
  import { peerSessionLink } from "../hosts/peer-actions";
19
19
  import { hostApi, hostLink } from "../hosts/remote-api";
20
- import { forgetHostTasks, isMirror, unknownOutcome } from "../tasks/remote";
20
+ import {
21
+ forget as forgetHostTasks,
22
+ unknownOutcome,
23
+ } from "../tasks/contract/client";
24
+ import { isMirror } from "../tasks/contract/start";
21
25
  import { heldRefusal } from "../tasks/held";
22
26
  import { expandHome, projectPathFor } from "../tasks/session";
23
27
  import { run } from "../tasks/gh";
@@ -40,6 +44,9 @@ import { launchClaude } from "../agents/launch";
40
44
  import { PLAN_FILE, preparePlanDir } from "../models/plan-step";
41
45
  import { planFromTranscript } from "./plan";
42
46
  import { dropSnapshot, restoreWorktree, type RestoredAs } from "./snapshot";
47
+ import { restoreSwept, sweptNote } from "./swept";
48
+ import { forgetSwept } from "../worktree-sweep/store";
49
+ import { setPr, setTask } from "../db/session-parts";
43
50
 
44
51
  export interface ResurrectOptions {
45
52
  // The paired device asking: a task held for another machine's orchestrator
@@ -64,6 +71,7 @@ const ENDED_TASK = new Set(["dropped", "done"]);
64
71
  // Restores in flight here, so two clicks can't both build a worktree.
65
72
  const g = globalThis as unknown as { __agentosResurrecting?: Set<string> };
66
73
  const inFlight = (g.__agentosResurrecting ??= new Set());
74
+ export const restoring = (id: string) => inFlight.has(id);
67
75
 
68
76
  interface PrInfo {
69
77
  number: number;
@@ -119,12 +127,22 @@ export async function resurrectSession(
119
127
  // restoring it is the whole of it.
120
128
  if (!endedTask || (s.archived_at && s.task_status !== "dropped")) {
121
129
  if (s.archived_at) {
130
+ const swept = await restoreSwept(s);
122
131
  unarchiveSession(id);
123
- return outcome(s, "unarchived", `${s.name} is back in the sidebar.`);
132
+ return outcome(
133
+ s,
134
+ "unarchived",
135
+ `${s.name} is back in the sidebar${swept ? `; ${swept}` : ""}.`
136
+ );
124
137
  }
125
138
  if (s.settled_at) {
139
+ const swept = await restoreSwept(s);
126
140
  reopenSession(id);
127
- return outcome(s, "reopened", `${s.name} reopened.`);
141
+ return outcome(
142
+ s,
143
+ "reopened",
144
+ `${s.name} reopened${swept ? `; ${swept}` : ""}.`
145
+ );
128
146
  }
129
147
  throw new Error(`${s.name} isn't dropped, done or archived.`);
130
148
  }
@@ -157,9 +175,12 @@ async function onItsMachine(s: Session): Promise<ResurrectOutcome> {
157
175
  `/api/sessions/${encodeURIComponent(s.id)}/resurrect`,
158
176
  { body: {}, timeout: 120_000 }
159
177
  );
160
- db.prepare(
161
- `UPDATE sessions SET task_status = 'running' WHERE id = ? AND task_status IN ('dropped', 'done')`
162
- ).run(s.id);
178
+ setTask(
179
+ db,
180
+ s.id,
181
+ { task_status: "running" },
182
+ { task_status: ["dropped", "done"] }
183
+ );
163
184
  forgetHostTasks(link.hostId);
164
185
  return outcome(
165
186
  s,
@@ -172,7 +193,7 @@ async function onItsMachine(s: Session): Promise<ResurrectOutcome> {
172
193
  }
173
194
  const before = db
174
195
  .prepare(
175
- `SELECT * FROM sessions WHERE host_id = 'local' AND task_status = 'moved'
196
+ `SELECT * FROM session_rows WHERE host_id = 'local' AND task_status = 'moved'
176
197
  AND project_id IS ? AND branch_name IS ? ORDER BY updated_at DESC LIMIT 1`
177
198
  )
178
199
  .get(s.project_id, s.branch_name) as Session | undefined;
@@ -217,7 +238,7 @@ async function restoreTask(
217
238
  if (s.branch_name) {
218
239
  const busy = db
219
240
  .prepare(
220
- `SELECT name FROM sessions WHERE project_id = ? AND branch_name = ? AND id != ?
241
+ `SELECT name FROM session_rows WHERE project_id = ? AND branch_name = ? AND id != ?
221
242
  AND task_status IN ('running', 'moving') AND host_id = 'local'`
222
243
  )
223
244
  .get(project.id, s.branch_name, s.id) as { name: string } | undefined;
@@ -238,11 +259,12 @@ async function restoreTask(
238
259
  if (inFlight.has(s.id))
239
260
  throw new Error(`${s.name} is being restored already.`);
240
261
  inFlight.add(s.id);
241
- const claimed = db
242
- .prepare(
243
- `UPDATE sessions SET task_status = 'running', task_ended_at = NULL WHERE id = ? AND task_status = ?`
244
- )
245
- .run(s.id, prev).changes;
262
+ const claimed = setTask(
263
+ db,
264
+ s.id,
265
+ { task_status: "running", task_ended_at: null },
266
+ { task_status: prev }
267
+ );
246
268
  if (!claimed) {
247
269
  inFlight.delete(s.id);
248
270
  throw new Error(`${s.name} is being restored already.`);
@@ -250,9 +272,7 @@ async function restoreTask(
250
272
  try {
251
273
  return await bringBack(s, prev, repo, oldCwd, pr);
252
274
  } catch (err) {
253
- db.prepare(
254
- `UPDATE sessions SET task_status = ? WHERE id = ? AND task_status = 'running'`
255
- ).run(prev, s.id);
275
+ setTask(db, s.id, { task_status: prev }, { task_status: "running" });
256
276
  throw err;
257
277
  } finally {
258
278
  inFlight.delete(s.id);
@@ -310,10 +330,11 @@ async function bringBack(
310
330
  if (!restored) {
311
331
  // Nothing to make a worktree from: the conversation alone, in the project.
312
332
  db.prepare(
313
- `UPDATE sessions SET task_status = NULL, task_ended_at = NULL, worktree_path = NULL,
333
+ `UPDATE sessions SET worktree_path = NULL,
314
334
  working_directory = ?, settled_at = NULL, snoozed_until = NULL, snoozed_at = NULL,
315
335
  archived_at = NULL, active_at = datetime('now') WHERE id = ?`
316
336
  ).run(cwd, s.id);
337
+ setTask(db, s.id, { task_status: null, task_ended_at: null });
317
338
  notes.push(
318
339
  "its branch and worktree were gone, so only the conversation came back, in the project folder"
319
340
  );
@@ -335,9 +356,7 @@ async function bringBack(
335
356
  try {
336
357
  await gitPush(restored.path, ["-u", "origin", s.branch_name!]);
337
358
  await run("gh", ["pr", "reopen", String(pr.number)], repo);
338
- db.prepare(`UPDATE sessions SET pr_status = 'open' WHERE id = ?`).run(
339
- s.id
340
- );
359
+ setPr(db, s.id, { pr_status: "open" });
341
360
  reopened = pr.number;
342
361
  } catch (err) {
343
362
  notes.push(
@@ -351,6 +370,11 @@ async function bringBack(
351
370
  if (restored && after.view !== "chat") await relaunch(after, restored.path);
352
371
  syncTaskCardInBackground(after, "working", null);
353
372
  void dropSnapshot(repo, s.id);
373
+ const swept = sweptNote(s.id);
374
+ if (swept) {
375
+ notes.unshift(swept);
376
+ forgetSwept(s.id);
377
+ }
354
378
  const text = `${s.name} is back${restored ? ` in ${restored.path}` : ""}${reopened ? `, PR #${reopened} reopened` : ""}${notes.length ? `; ${notes.join("; ")}` : ""}.`;
355
379
  return outcome(s, "task", text, {
356
380
  worktree: restored?.mode ?? "chat-only",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A session whose worktree the age clean-up removed: bringing it back
3
+ * recreates the worktree from its branch, or says plainly that it's gone.
4
+ */
5
+
6
+ import fs from "fs";
7
+ import { db, type Session } from "../db";
8
+ import { getProject } from "../projects";
9
+ import { expandHome, projectPathFor } from "../tasks/session";
10
+ import { forgetSwept, sweptAt } from "../worktree-sweep/store";
11
+ import { restoreWorktree } from "./snapshot";
12
+
13
+ const day = (t: string) => t.slice(0, 10);
14
+
15
+ // "its worktree was cleaned up on 2026-10-15" for a session the sweep removed
16
+ // the worktree of; null for any other.
17
+ export function sweptNote(id: string): string | null {
18
+ const at = sweptAt(id);
19
+ return at ? `its worktree was cleaned up on ${day(at)}` : null;
20
+ }
21
+
22
+ /**
23
+ * For a session coming back without a task restore (unarchive, reopen):
24
+ * rebuild a swept worktree. The note says what happened; null when the
25
+ * session wasn't swept or its worktree is still there.
26
+ */
27
+ export async function restoreSwept(s: Session): Promise<string | null> {
28
+ const note = sweptNote(s.id);
29
+ if (!note || !s.worktree_path) return null;
30
+ const old = expandHome(s.worktree_path);
31
+ if (fs.existsSync(old)) {
32
+ forgetSwept(s.id);
33
+ return null;
34
+ }
35
+ const project = s.project_id ? getProject(s.project_id) : null;
36
+ const repo = project ? projectPathFor(s) : null;
37
+ const restored =
38
+ repo && s.branch_name && fs.existsSync(repo)
39
+ ? await restoreWorktree({
40
+ sessionId: s.id,
41
+ repo,
42
+ branch: s.branch_name,
43
+ base: s.base_branch,
44
+ oldPath: old,
45
+ allowFresh: false,
46
+ }).catch(() => null)
47
+ : null;
48
+ if (restored) {
49
+ db.prepare(
50
+ `UPDATE sessions SET worktree_path = ?, working_directory = ? WHERE id = ?`
51
+ ).run(restored.path, restored.path, s.id);
52
+ forgetSwept(s.id);
53
+ return `${note} and recreated from ${s.branch_name}`;
54
+ }
55
+ // No branch to build from: the conversation alone, in the project folder.
56
+ db.prepare(
57
+ `UPDATE sessions SET worktree_path = NULL, working_directory = ? WHERE id = ?`
58
+ ).run(repo ?? s.working_directory, s.id);
59
+ forgetSwept(s.id);
60
+ return `${note} and its branch is gone, so only the conversation came back`;
61
+ }
@@ -163,7 +163,7 @@ export function findSchedule(ref: string): Schedule {
163
163
 
164
164
  export function getSessionRow(id: string): Session | null {
165
165
  return (
166
- (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
166
+ (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
167
167
  | Session
168
168
  | undefined) ?? null
169
169
  );
@@ -81,7 +81,7 @@ export function loadSessions(
81
81
  `SELECT s.id, s.name, s.created_at, s.role, s.view, s.task_prompt,
82
82
  length(s.task_brief) AS task_brief_chars, s.working_directory,
83
83
  s.worktree_path, s.claude_session_id, s.workspace_id, p.name AS project
84
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
84
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
85
85
  WHERE coalesce(s.host_id, 'local') = 'local'`
86
86
  )
87
87
  .all() as SessionRow[];
@@ -297,8 +297,8 @@ export function loadSignals(
297
297
  }>(
298
298
  `SELECT m.from_id, m.to_id, m.body, f.role AS from_role, (t.task_prompt IS NOT NULL) AS to_task
299
299
  FROM bus_messages m
300
- LEFT JOIN sessions f ON f.id = m.from_id
301
- LEFT JOIN sessions t ON t.id = m.to_id
300
+ LEFT JOIN session_rows f ON f.id = m.from_id
301
+ LEFT JOIN session_rows t ON t.id = m.to_id
302
302
  WHERE m.created_at >= ? AND m.created_at < ?`,
303
303
  a,
304
304
  b
@@ -120,7 +120,7 @@ export async function writeLog(
120
120
  id: string,
121
121
  opts: CaptureOpts & { plan?: string | null } = {}
122
122
  ): Promise<boolean> {
123
- const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
123
+ const row = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
124
124
  | Session
125
125
  | undefined;
126
126
  const existing = getLog(id);
@@ -283,7 +283,7 @@ export function titleChatFromMessage(
283
283
  ): Promise<void> | void {
284
284
  const s = db
285
285
  .prepare(
286
- `SELECT name, name_source, working_directory, task_prompt FROM sessions WHERE id = ?`
286
+ `SELECT name, name_source, working_directory, task_prompt FROM session_rows WHERE id = ?`
287
287
  )
288
288
  .get(sessionId) as
289
289
  | {
@@ -110,7 +110,7 @@ export function getSetup(sessionId: string): SetupView | null {
110
110
  function savedSetup(sessionId: string): string | null {
111
111
  const row = db
112
112
  .prepare(
113
- `SELECT setup_status FROM sessions WHERE id = ? AND task_status IS NULL`
113
+ `SELECT setup_status FROM session_rows WHERE id = ? AND task_status IS NULL`
114
114
  )
115
115
  .get(sessionId) as { setup_status: string | null } | undefined;
116
116
  return row?.setup_status ?? null;
@@ -2,7 +2,7 @@ import { endTmuxSession } from "../tmux/end";
2
2
  import { hostLink } from "../hosts/remote-api";
3
3
  import { stopChatTurn } from "../chat/stop";
4
4
  import type { Session } from "../db";
5
- import { isHeldRemote, stopHeldTask } from "../tasks/held-remote";
5
+ import { isHeldRemote, stopHeldTask } from "../tasks/contract/held-calls";
6
6
  import { releaseOnEnd } from "../simulator";
7
7
  import { forgetPlanWait } from "../models/plan-step";
8
8
 
@@ -175,7 +175,8 @@ export function failInterruptedSetups(): number {
175
175
  THEN COALESCE((SELECT working_directory FROM projects WHERE id = sessions.project_id), working_directory)
176
176
  ELSE working_directory END,
177
177
  setup_error = 'AgentOS restarted before setup finished. Your message is still queued: send it when you are ready.'
178
- WHERE setup_status = 'running' AND task_status IS NULL`
178
+ WHERE setup_status = 'running'
179
+ AND id IN (SELECT id FROM session_rows WHERE task_status IS NULL)`
179
180
  )
180
181
  .run().changes;
181
182
  }
@@ -70,7 +70,7 @@ export { simulatorAvailable };
70
70
  export { shutTaskSimulators, shutFinishedTaskSimulators } from "./task-devices";
71
71
 
72
72
  const session = (id: string) =>
73
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
73
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
74
74
  | Session
75
75
  | undefined;
76
76
 
@@ -148,7 +148,7 @@ export async function shutFinishedTaskSimulators(): Promise<number> {
148
148
  db
149
149
  .prepare(
150
150
  `SELECT id, branch_name, worktree_path, task_status, host_id
151
- FROM sessions WHERE task_status IS NOT NULL`
151
+ FROM session_rows WHERE task_status IS NOT NULL`
152
152
  )
153
153
  .all() as (TaskRef & { host_id: string })[]
154
154
  ).filter((t) => !isRemoteHost(t.host_id));
@@ -44,7 +44,7 @@ export function toPlanCards(
44
44
  function runningTasks(boardId: string): Map<string, string> {
45
45
  const rows = db
46
46
  .prepare(
47
- `SELECT id, lh_card_id FROM sessions
47
+ `SELECT id, lh_card_id FROM session_rows
48
48
  WHERE lh_board_id = ? AND lh_card_id IS NOT NULL AND task_status = 'running'`
49
49
  )
50
50
  .all(boardId) as Array<{ id: string; lh_card_id: string }>;
@@ -67,7 +67,7 @@ export const LAND_DEFAULTS: LandDeps = {
67
67
 
68
68
  const sessionOf = (id: string | null) =>
69
69
  (id
70
- ? db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id)
70
+ ? db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id)
71
71
  : undefined) as Session | undefined;
72
72
 
73
73
  class Stop extends Error {}
@@ -109,7 +109,7 @@ const defaults: RestackDeps = {
109
109
 
110
110
  const sessionOf = (id: string | null) =>
111
111
  id
112
- ? ((db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
112
+ ? ((db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
113
113
  | Session
114
114
  | undefined) ?? null)
115
115
  : null;
@@ -25,7 +25,7 @@ async function stackedBase(
25
25
  item: StackItemRow
26
26
  ) {
27
27
  const session = db
28
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
28
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
29
29
  .get(parent.session_id) as Session | undefined;
30
30
  const branch = session?.branch_name;
31
31
  if (!branch) throw new Error(`${itemName(parent)} has no branch to stack on`);
@@ -52,7 +52,7 @@ async function stackedBase(
52
52
  export function existingTask(cardId: string): string | null {
53
53
  const row = db
54
54
  .prepare(
55
- `SELECT id FROM sessions WHERE lh_card_id = ? AND task_status = 'running'
55
+ `SELECT id FROM session_rows WHERE lh_card_id = ? AND task_status = 'running'
56
56
  ORDER BY created_at DESC LIMIT 1`
57
57
  )
58
58
  .get(cardId) as { id: string } | undefined;
@@ -1,7 +1,12 @@
1
1
  // Test fixtures: a project, task sessions and a stack in the test database.
2
2
 
3
3
  import { randomUUID } from "crypto";
4
- import { db, stackQueries as q, type StackItemStatus } from "../db";
4
+ import {
5
+ db,
6
+ insertSessionRow,
7
+ stackQueries as q,
8
+ type StackItemStatus,
9
+ } from "../db";
5
10
  import { createProject } from "../projects";
6
11
 
7
12
  export interface SeedItem {
@@ -28,22 +33,18 @@ export function seedStack(repo: string, specs: SeedItem[]) {
28
33
  if (!s.branch) continue;
29
34
  const id = randomUUID();
30
35
  sessions.set(s.key, id);
31
- db.prepare(
32
- `INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, task_status,
33
- branch_name, worktree_path, base_branch, pr_number)
34
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
35
- ).run(
36
+ insertSessionRow(db, {
36
37
  id,
37
- s.key,
38
- `claude-${id}`,
39
- s.worktree ?? repo,
40
- project.id,
41
- s.status === "merged" ? "merged" : "running",
42
- s.branch,
43
- s.worktree ?? null,
44
- s.baseBranch ?? null,
45
- s.pr ?? null
46
- );
38
+ name: s.key,
39
+ tmux_name: `claude-${id}`,
40
+ working_directory: s.worktree ?? repo,
41
+ project_id: project.id,
42
+ task_status: s.status === "merged" ? "merged" : "running",
43
+ branch_name: s.branch,
44
+ worktree_path: s.worktree ?? null,
45
+ base_branch: s.baseBranch ?? null,
46
+ pr_number: s.pr ?? null,
47
+ });
47
48
  }
48
49
  q.create(
49
50
  db,
@@ -39,7 +39,7 @@ export function setStartGate(gate: StartGate | null): void {
39
39
  const LIVE = new Set(["running", "pr"]);
40
40
 
41
41
  const sessionOf = (id: string) =>
42
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
42
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
43
43
  | Session
44
44
  | undefined;
45
45
 
@@ -51,7 +51,7 @@ const prUrlOf = (sessionId: string | null) =>
51
51
  sessionId
52
52
  ? ((
53
53
  db
54
- .prepare(`SELECT pr_url FROM sessions WHERE id = ?`)
54
+ .prepare(`SELECT pr_url FROM session_rows WHERE id = ?`)
55
55
  .get(sessionId) as { pr_url: string | null } | undefined
56
56
  )?.pr_url ?? null)
57
57
  : null;
@@ -60,7 +60,7 @@ export function resetStatusCaches(): void {
60
60
  function loadRows(dirty: Set<string>): void {
61
61
  const rows = getDb()
62
62
  .prepare(
63
- `SELECT s.*, p.workspace_id AS project_workspace_id FROM sessions s
63
+ `SELECT s.*, p.workspace_id AS project_workspace_id FROM session_rows s
64
64
  LEFT JOIN projects p ON p.id = s.project_id
65
65
  WHERE s.archived_at IS NULL`
66
66
  )
@@ -0,0 +1,35 @@
1
+ import type { Session } from "../../db";
2
+ import { requireHostLink } from "../../hosts/remote-api";
3
+ import type { MergeMethod } from "../merge-methods";
4
+ import * as client from "./client";
5
+ import { setMirrorStatus } from "./start";
6
+
7
+ /**
8
+ * Sign off on the machine that runs it. It returns the method that machine
9
+ * merged with; null when it doesn't say (an older AgentOS).
10
+ */
11
+ export async function mergeThere(
12
+ session: Session,
13
+ head?: string,
14
+ base?: string | null
15
+ ): Promise<MergeMethod | null> {
16
+ const link = requireHostLink(session.host_id);
17
+ const res = await client.merge(
18
+ link,
19
+ session.id,
20
+ head ? { head, base } : undefined,
21
+ session.name
22
+ );
23
+ setMirrorStatus(session.id, "merged");
24
+ if (head && res.head !== head)
25
+ throw new Error(
26
+ `${link.hostName} merged without confirming it was ${head.slice(0, 7)}`
27
+ );
28
+ return res.method;
29
+ }
30
+
31
+ /** Drop on the machine that runs it. */
32
+ export async function dropThere(session: Session): Promise<void> {
33
+ await client.drop(requireHostLink(session.host_id), session.id);
34
+ setMirrorStatus(session.id, "dropped");
35
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Who may call a task route on the machine that runs the task. One rule per
3
+ * route, applied the same way everywhere:
4
+ *
5
+ * - holder: a task an orchestrator on another machine holds (held.ts); only
6
+ * the device that holds it, and a task nobody holds is "No such held task".
7
+ * - open: the holder, when there is one; any caller otherwise. A mirror row
8
+ * or a task started here is never held, so this machine's own web UI
9
+ * (which sends no device) is never refused on them.
10
+ * - plan: open, and a plan another machine placed here is read and approved
11
+ * by that machine only.
12
+ */
13
+
14
+ import { NextRequest, NextResponse } from "next/server";
15
+ import { db, type Session } from "../../db";
16
+ import { callingDevice } from "../../security/device-me";
17
+ import { heldRefusal, holderRefusal, planWaitRefusal } from "../held";
18
+
19
+ export type Rule = "holder" | "open" | "plan";
20
+
21
+ type Ctx = { params: Promise<{ id: string }> };
22
+
23
+ export interface Refusal {
24
+ status: 403 | 404;
25
+ error: string;
26
+ }
27
+
28
+ export function callerRefusal(
29
+ id: string,
30
+ device: string | null | undefined,
31
+ rule: Rule
32
+ ): Refusal | null {
33
+ if (rule === "holder") {
34
+ const task = db
35
+ .prepare(
36
+ `SELECT held_by_device, held_from FROM session_rows WHERE id = ?`
37
+ )
38
+ .get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
39
+ if (!task?.held_by_device)
40
+ return { status: 404, error: "No such held task" };
41
+ const error = holderRefusal(task, device);
42
+ return error ? { status: 403, error } : null;
43
+ }
44
+ const error =
45
+ heldRefusal(id, device) ??
46
+ (rule === "plan" ? planWaitRefusal(id, device) : null);
47
+ return error ? { status: 403, error } : null;
48
+ }
49
+
50
+ /** The refusal as a response, or null when the caller may go on. */
51
+ export function refuse(
52
+ request: NextRequest,
53
+ id: string,
54
+ rule: Rule
55
+ ): NextResponse | null {
56
+ const refusal = callerRefusal(id, callingDevice(request), rule);
57
+ return refusal
58
+ ? NextResponse.json({ error: refusal.error }, { status: refusal.status })
59
+ : null;
60
+ }
61
+
62
+ export async function taskRoute(
63
+ request: NextRequest,
64
+ { params }: Ctx,
65
+ rule: Rule,
66
+ run: (task: Session) => Promise<object>
67
+ ): Promise<NextResponse> {
68
+ const id = (await params).id;
69
+ const refused = refuse(request, id, rule);
70
+ if (refused) return refused;
71
+ const task = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
72
+ | Session
73
+ | undefined;
74
+ if (!task)
75
+ return NextResponse.json({ error: "No such task" }, { status: 404 });
76
+ try {
77
+ return NextResponse.json(await run(task));
78
+ } catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ return NextResponse.json({ error: message }, { status: 409 });
81
+ }
82
+ }