@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
@@ -0,0 +1,25 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getDoneTarget } from "@/lib/done";
3
+ import { scopeOf } from "@/lib/done/bulk";
4
+ import { prunableRepos } from "@/lib/tasks/prune-scope";
5
+ import { pruneRepo, pruneText } from "@/lib/tasks/prune-branches";
6
+
7
+ // `aos branches prune`, from inside a session: reaches only the caller's
8
+ // workspace (or its project when it has none). Lists unless `yes` is true.
9
+ export async function POST(request: NextRequest) {
10
+ try {
11
+ const { from = null, yes = false } = await request.json();
12
+ if (typeof from !== "string" || !from)
13
+ throw new Error("aos branches runs from inside an AgentOS session");
14
+ const scope = scopeOf(getDoneTarget(from));
15
+ const repos = await prunableRepos(scope);
16
+ if (!repos.length) throw new Error("No GitHub projects in scope");
17
+ const results = [];
18
+ for (const repo of repos)
19
+ results.push(await pruneRepo(repo, { yes: yes === true }));
20
+ return NextResponse.json({ summary: pruneText(results, yes === true) });
21
+ } catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ return NextResponse.json({ error: message }, { status: 409 });
24
+ }
25
+ }
@@ -1,4 +1,5 @@
1
1
  import { NextRequest } from "next/server";
2
+ import { updateSessionRow } from "@/lib/db";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
 
4
5
  const resurrect = vi.hoisted(() => vi.fn());
@@ -26,12 +27,8 @@ describe("POST /api/bus/resurrect (aos resurrect)", () => {
26
27
  it("reaches only the caller's workspace, and lists what ended with no session", async () => {
27
28
  const mine = seedWorkspace("Bus mine");
28
29
  const other = seedWorkspace("Bus other");
29
- db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
30
- mine.task
31
- );
32
- db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
33
- other.task
34
- );
30
+ updateSessionRow(db, mine.task, { task_status: "dropped" });
31
+ updateSessionRow(db, other.task, { task_status: "dropped" });
35
32
  const list = await (await call({ from: mine.chat })).json();
36
33
  expect(list.sessions.map((s: { id: string }) => s.id)).toEqual([mine.task]);
37
34
 
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { execFile } from "child_process";
3
3
  import { promisify } from "util";
4
- import { getDb, queries, type Session } from "@/lib/db";
4
+ import { getDb, queries, recordPr, type Session } from "@/lib/db";
5
5
  import { gitPush } from "@/lib/git-exec";
6
6
 
7
7
  const execFileAsync = promisify(execFile);
@@ -129,7 +129,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
129
129
 
130
130
  // Update session with PR info if found
131
131
  if (pr) {
132
- queries.updateSessionPR(db).run(pr.url, pr.number, pr.state, id);
132
+ recordPr(db, id, pr);
133
133
  }
134
134
 
135
135
  return NextResponse.json({ pr });
@@ -197,7 +197,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
197
197
  );
198
198
 
199
199
  // Save PR info to session
200
- queries.updateSessionPR(db).run(pr.url, pr.number, pr.state, id);
200
+ recordPr(db, id, pr);
201
201
 
202
202
  return NextResponse.json({ pr }, { status: 201 });
203
203
  } catch (error) {
@@ -16,9 +16,9 @@ export async function GET(
16
16
  const { id } = await params;
17
17
  const live = getSetup(id);
18
18
  if (live) return NextResponse.json({ setup: live });
19
- const session = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
20
- | Session
21
- | undefined;
19
+ const session = db
20
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
21
+ .get(id) as Session | undefined;
22
22
  if (!session)
23
23
  return NextResponse.json({ error: "Session not found" }, { status: 404 });
24
24
  const link = isRemoteHost(session.host_id) ? hostLink(session.host_id) : null;
@@ -1,7 +1,6 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { approvePlanNow } from "@/lib/models/plan-step";
3
- import { callingDevice } from "@/lib/security/device-me";
4
- import { planWaitRefusal } from "@/lib/tasks/held";
5
4
 
6
5
  // Approves a plan-only task's plan: it continues on the implementation
7
6
  // model. An optional message amends the plan (recorded as a scope change).
@@ -10,8 +9,8 @@ export async function POST(
10
9
  { params }: { params: Promise<{ id: string }> }
11
10
  ) {
12
11
  const id = (await params).id;
13
- const refusal = planWaitRefusal(id, callingDevice(request));
14
- if (refusal) return NextResponse.json({ error: refusal }, { status: 403 });
12
+ const refused = refuse(request, id, "plan");
13
+ if (refused) return refused;
15
14
  try {
16
15
  const body = await request.json().catch(() => ({}));
17
16
  const message =
@@ -1,8 +1,7 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { db, type Session } from "@/lib/db";
3
4
  import { armTask, setArmed } from "@/lib/orchestrator/armed";
4
- import { callingDevice } from "@/lib/security/device-me";
5
- import { heldRefusal } from "@/lib/tasks/held";
6
5
 
7
6
  // { armed: true } merges the task's PR by itself once every gate passes;
8
7
  // false takes that back.
@@ -11,8 +10,8 @@ export async function POST(
11
10
  { params }: { params: Promise<{ id: string }> }
12
11
  ) {
13
12
  const id = (await params).id;
14
- const held = heldRefusal(id, callingDevice(request));
15
- if (held) return NextResponse.json({ error: held }, { status: 403 });
13
+ const refused = refuse(request, id, "open");
14
+ if (refused) return refused;
16
15
  const { armed } = await request.json().catch(() => ({}));
17
16
  if (typeof armed !== "boolean")
18
17
  return NextResponse.json(
@@ -20,7 +19,9 @@ export async function POST(
20
19
  { status: 400 }
21
20
  );
22
21
  const task = db
23
- .prepare(`SELECT * FROM sessions WHERE id = ? AND task_status IS NOT NULL`)
22
+ .prepare(
23
+ `SELECT * FROM session_rows WHERE id = ? AND task_status IS NOT NULL`
24
+ )
24
25
  .get((await params).id) as Session | undefined;
25
26
  if (!task)
26
27
  return NextResponse.json({ error: "No such task" }, { status: 404 });
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
2
2
  import { blockedReason } from "@/lib/tasks/state";
3
3
  import { saidSinceLastMessage } from "@/lib/chat/store";
4
4
  import { chatStateNow } from "@/lib/chat/runner";
5
- import { heldRoute } from "@/lib/tasks/held-routes";
5
+ import { taskRoute } from "@/lib/tasks/contract/authorize";
6
6
 
7
7
  // GET /api/tasks/:id/chat-facts - what its chat says about being blocked
8
8
  // and whether a question is open, for the orchestrator that moved it here.
@@ -11,7 +11,7 @@ export async function GET(
11
11
  request: NextRequest,
12
12
  ctx: { params: Promise<{ id: string }> }
13
13
  ) {
14
- return heldRoute(request, ctx, async (task) => {
14
+ return taskRoute(request, ctx, "holder", async (task) => {
15
15
  if (task.view !== "chat") return { blocked: null, turn: null };
16
16
  return {
17
17
  blocked: blockedReason(saidSinceLastMessage(task.id)),
@@ -1,6 +1,6 @@
1
1
  import { NextRequest } from "next/server";
2
2
  import { chatTailText } from "@/lib/orchestrator/read";
3
- import { heldRoute } from "@/lib/tasks/held-routes";
3
+ import { taskRoute } from "@/lib/tasks/contract/authorize";
4
4
 
5
5
  // GET /api/tasks/:id/chat-tail?lines= - the last lines of its conversation,
6
6
  // for the orchestrator that moved it here to read.
@@ -9,7 +9,7 @@ export async function GET(
9
9
  ctx: { params: Promise<{ id: string }> }
10
10
  ) {
11
11
  const lines = Number(request.nextUrl.searchParams.get("lines")) || 60;
12
- return heldRoute(request, ctx, async (task) => ({
12
+ return taskRoute(request, ctx, "holder", async (task) => ({
13
13
  text: task.view === "chat" ? chatTailText(task.id, lines) : "",
14
14
  }));
15
15
  }
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { db, queries, type Session } from "@/lib/db";
3
3
  import { taskChecks } from "@/lib/tasks/checks-view";
4
4
  import { LoopError, localTask } from "@/lib/tasks/loop-error";
5
- import { isMirror } from "@/lib/tasks/remote";
5
+ import { isMirror } from "@/lib/tasks/contract/start";
6
6
 
7
7
  // GET /api/tasks/:id/checks - the PR head's checks, the tail of a failing
8
8
  // job's log, and the review gate's verdict on the head. ?log=0 leaves the
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { db, queries, type Session } from "@/lib/db";
3
3
  import { taskDiff } from "@/lib/tasks/diff";
4
4
  import { LoopError, localTask } from "@/lib/tasks/loop-error";
5
- import { isMirror } from "@/lib/tasks/remote";
5
+ import { isMirror } from "@/lib/tasks/contract/start";
6
6
 
7
7
  // GET /api/tasks/:id/diff - the task's whole change, base to head: a file
8
8
  // list with status and line counts; with ?file=<path>, that file's patch.
@@ -1,6 +1,6 @@
1
1
  import { NextRequest } from "next/server";
2
2
  import { doneSession } from "@/lib/done";
3
- import { heldRoute } from "@/lib/tasks/held-routes";
3
+ import { taskRoute } from "@/lib/tasks/contract/authorize";
4
4
 
5
5
  // POST /api/tasks/:id/done - its orchestrator finishes it: the agent stops
6
6
  // and the worktree goes. Never a merge (noMerge): an open PR is refused
@@ -9,7 +9,7 @@ export async function POST(
9
9
  request: NextRequest,
10
10
  ctx: { params: Promise<{ id: string }> }
11
11
  ) {
12
- return heldRoute(request, ctx, async (task) => ({
12
+ return taskRoute(request, ctx, "holder", async (task) => ({
13
13
  outcome: await doneSession(task.id, { by: "direct", noMerge: true }),
14
14
  }));
15
15
  }
@@ -1,12 +1,16 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { refuse } from "@/lib/tasks/contract/authorize";
2
3
  import { dropTask } from "@/lib/tasks";
3
4
 
4
5
  export async function POST(
5
- _request: NextRequest,
6
+ request: NextRequest,
6
7
  { params }: { params: Promise<{ id: string }> }
7
8
  ) {
9
+ const id = (await params).id;
10
+ const refused = refuse(request, id, "open");
11
+ if (refused) return refused;
8
12
  try {
9
- await dropTask((await params).id);
13
+ await dropTask(id);
10
14
  return NextResponse.json({ success: true });
11
15
  } catch (error) {
12
16
  const message = error instanceof Error ? error.message : String(error);
@@ -1,7 +1,6 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { exportOrResume } from "@/lib/tasks/move";
3
- import { callingDevice } from "@/lib/security/device-me";
4
- import { heldRefusal, planWaitRefusal } from "@/lib/tasks/held";
5
4
 
6
5
  // POST /api/tasks/:id/export - stop the agent, push, hand over the task.
7
6
  // The caller confirms with /moved, or restarts it here with /resume; if it
@@ -11,9 +10,8 @@ export async function POST(
11
10
  { params }: { params: Promise<{ id: string }> }
12
11
  ) {
13
12
  const id = (await params).id;
14
- const device = callingDevice(request);
15
- const held = heldRefusal(id, device) ?? planWaitRefusal(id, device);
16
- if (held) return NextResponse.json({ error: held }, { status: 403 });
13
+ const refused = refuse(request, id, "plan");
14
+ if (refused) return refused;
17
15
  try {
18
16
  const { to } = await request.json().catch(() => ({}));
19
17
  const bundle = await exportOrResume(
@@ -1,8 +1,8 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { signOffTask } from "@/lib/tasks";
3
4
  import { callingDevice } from "@/lib/security/device-me";
4
5
  import { isBranchName } from "@/lib/git";
5
- import { heldRefusal } from "@/lib/tasks/held";
6
6
 
7
7
  // head: merge only if the PR is still at this commit; the answer names the
8
8
  // commit it was pinned to, so another machine forwarding a merge can check.
@@ -11,8 +11,8 @@ export async function POST(
11
11
  { params }: { params: Promise<{ id: string }> }
12
12
  ) {
13
13
  const id = (await params).id;
14
- const held = heldRefusal(id, callingDevice(request));
15
- if (held) return NextResponse.json({ error: held }, { status: 403 });
14
+ const refused = refuse(request, id, "open");
15
+ if (refused) return refused;
16
16
  const { head, base } = await request.json().catch(() => ({}));
17
17
  if (
18
18
  head !== undefined &&
@@ -3,7 +3,7 @@ import { statusDetector } from "@/lib/status-detector";
3
3
  import { deliver } from "@/lib/bus";
4
4
  import { sendChatConfirmed } from "@/lib/chat/runner";
5
5
  import { cleanRemoteText } from "@/lib/hosts/remote-api";
6
- import { heldRoute } from "@/lib/tasks/held-routes";
6
+ import { taskRoute } from "@/lib/tasks/contract/authorize";
7
7
 
8
8
  const MAX_LINE = 40_000;
9
9
 
@@ -15,7 +15,7 @@ export async function POST(
15
15
  ctx: { params: Promise<{ id: string }> }
16
16
  ) {
17
17
  const body = await request.json().catch(() => ({}));
18
- return heldRoute(request, ctx, async (task) => {
18
+ return taskRoute(request, ctx, "holder", async (task) => {
19
19
  const line = typeof body.line === "string" ? body.line.trim() : "";
20
20
  if (!line || line.length > MAX_LINE) throw new Error("Bad message");
21
21
  const id = typeof body.id === "string" ? body.id.slice(0, 100) : undefined;
@@ -1,3 +1,4 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { resumeOnModel } from "@/lib/models/resume";
3
4
 
@@ -8,10 +9,13 @@ export async function POST(
8
9
  request: NextRequest,
9
10
  { params }: { params: Promise<{ id: string }> }
10
11
  ) {
12
+ const id = (await params).id;
13
+ const refused = refuse(request, id, "open");
14
+ if (refused) return refused;
11
15
  const { model, message } = await request.json().catch(() => ({}));
12
16
  try {
13
17
  const result = await resumeOnModel(
14
- (await params).id,
18
+ id,
15
19
  model,
16
20
  typeof message === "string" && message.trim() ? message : undefined
17
21
  );
@@ -1,4 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { refuse } from "@/lib/tasks/contract/authorize";
2
3
  import { moveTask } from "@/lib/tasks";
3
4
  import { getProgress } from "@/lib/tasks/move-progress";
4
5
 
@@ -7,9 +8,12 @@ export async function POST(
7
8
  request: NextRequest,
8
9
  { params }: { params: Promise<{ id: string }> }
9
10
  ) {
11
+ const id = (await params).id;
12
+ const refused = refuse(request, id, "open");
13
+ if (refused) return refused;
10
14
  try {
11
15
  const { hostId } = await request.json();
12
- const session = await moveTask((await params).id, String(hostId || ""));
16
+ const session = await moveTask(id, String(hostId || ""));
13
17
  return NextResponse.json({ session });
14
18
  } catch (error) {
15
19
  const message = error instanceof Error ? error.message : String(error);
@@ -1,7 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { markMoved } from "@/lib/tasks/move";
3
- import { callingDevice } from "@/lib/security/device-me";
4
- import { heldRefusal } from "@/lib/tasks/held";
3
+ import { refuse } from "@/lib/tasks/contract/authorize";
5
4
  import { cleanUpMovedAway } from "@/lib/tasks/moved-away";
6
5
  import { runInBackground } from "@/lib/async-operations";
7
6
 
@@ -11,14 +10,13 @@ export async function POST(
11
10
  { params }: { params: Promise<{ id: string }> }
12
11
  ) {
13
12
  const id = (await params).id;
14
- const held = heldRefusal(id, callingDevice(request));
15
- if (held) return NextResponse.json({ error: held }, { status: 403 });
13
+ const refused = refuse(request, id, "open");
14
+ if (refused) return refused;
16
15
  const { to } = await request.json().catch(() => ({}));
17
16
  markMoved(id, String(to || "another machine").slice(0, 100));
18
17
  // A task its orchestrator took back: its agent and worktree here go.
19
- if (held === null)
20
- runInBackground(async () => {
21
- await cleanUpMovedAway(id);
22
- }, `moved-away-${id}`);
18
+ runInBackground(async () => {
19
+ await cleanUpMovedAway(id);
20
+ }, `moved-away-${id}`);
23
21
  return NextResponse.json({ success: true });
24
22
  }
@@ -1,9 +1,8 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import fs from "fs/promises";
2
3
  import path from "path";
3
4
  import { NextRequest, NextResponse } from "next/server";
4
5
  import { db, type Session } from "@/lib/db";
5
- import { callingDevice } from "@/lib/security/device-me";
6
- import { heldRefusal, planWaitRefusal } from "@/lib/tasks/held";
7
6
  import { MAX_PLAN_BYTES } from "@/lib/tasks/move-bundle";
8
7
  import { PLAN_FILE } from "@/lib/models/plan-step";
9
8
  import { expandHome } from "@/lib/tasks/session";
@@ -15,11 +14,10 @@ export async function GET(
15
14
  { params }: { params: Promise<{ id: string }> }
16
15
  ) {
17
16
  const id = (await params).id;
18
- const device = callingDevice(request);
19
- const refusal = heldRefusal(id, device) ?? planWaitRefusal(id, device);
20
- if (refusal) return NextResponse.json({ error: refusal }, { status: 403 });
17
+ const refused = refuse(request, id, "plan");
18
+ if (refused) return refused;
21
19
  const s = db
22
- .prepare(`SELECT plan_phase, worktree_path FROM sessions WHERE id = ?`)
20
+ .prepare(`SELECT plan_phase, worktree_path FROM session_rows WHERE id = ?`)
23
21
  .get(id) as Pick<Session, "plan_phase" | "worktree_path"> | undefined;
24
22
  if (!s?.worktree_path)
25
23
  return NextResponse.json({ error: "No such task" }, { status: 404 });
@@ -1,6 +1,8 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { db, type Session } from "@/lib/db";
3
4
  import { notifyStatusChanged } from "@/lib/status/hub";
5
+ import { setPlan } from "@/lib/db/session-parts";
4
6
 
5
7
  // Saad's edits to a waiting plan's pieces: which to fold into the one before
6
8
  // and which to leave out. Kept on the task and applied when the plan is
@@ -10,6 +12,8 @@ export async function PUT(
10
12
  { params }: { params: Promise<{ id: string }> }
11
13
  ) {
12
14
  const { id } = await params;
15
+ const refused = refuse(request, id, "open");
16
+ if (refused) return refused;
13
17
  const body = await request.json().catch(() => ({}));
14
18
  const ints = (v: unknown) =>
15
19
  Array.isArray(v)
@@ -18,17 +22,14 @@ export async function PUT(
18
22
  const merge = ints(body?.merge);
19
23
  const drop = ints(body?.drop).filter((n) => !merge.includes(n));
20
24
  const session = db
21
- .prepare(`SELECT plan_phase FROM sessions WHERE id = ?`)
25
+ .prepare(`SELECT plan_phase FROM session_rows WHERE id = ?`)
22
26
  .get(id) as Pick<Session, "plan_phase"> | undefined;
23
27
  if (session?.plan_phase !== "awaiting")
24
28
  return NextResponse.json(
25
29
  { error: "That task isn't waiting for plan approval" },
26
30
  { status: 409 }
27
31
  );
28
- db.prepare(`UPDATE sessions SET plan_edits = ? WHERE id = ?`).run(
29
- JSON.stringify({ merge, drop }),
30
- id
31
- );
32
+ setPlan(db, id, { plan_edits: JSON.stringify({ merge, drop }) });
32
33
  notifyStatusChanged();
33
34
  return NextResponse.json({ success: true, merge, drop });
34
35
  }
@@ -1,4 +1,5 @@
1
- import { NextResponse } from "next/server";
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
2
+ import { NextRequest, NextResponse } from "next/server";
2
3
  import { setPriority } from "@/lib/tasks/queue";
3
4
  import { isPriority } from "@/lib/tasks/schedule-types";
4
5
 
@@ -9,6 +10,8 @@ export async function PATCH(
9
10
  { params }: { params: Promise<{ id: string }> }
10
11
  ) {
11
12
  const { id } = await params;
13
+ const refused = refuse(request as NextRequest, id, "open");
14
+ if (refused) return refused;
12
15
  const body = (await request.json().catch(() => null)) as {
13
16
  priority?: unknown;
14
17
  } | null;
@@ -1,7 +1,6 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { resumeHere } from "@/lib/tasks/move-recover";
3
- import { callingDevice } from "@/lib/security/device-me";
4
- import { heldRefusal } from "@/lib/tasks/held";
5
4
 
6
5
  // POST /api/tasks/:id/resume {force?} - start its agent again here, on its
7
6
  // conversation. A task left moving asks its target first unless forced.
@@ -10,8 +9,8 @@ export async function POST(
10
9
  { params }: { params: Promise<{ id: string }> }
11
10
  ) {
12
11
  const id = (await params).id;
13
- const held = heldRefusal(id, callingDevice(request));
14
- if (held) return NextResponse.json({ error: held }, { status: 403 });
12
+ const refused = refuse(request, id, "open");
13
+ if (refused) return refused;
15
14
  const { force } = await request.json().catch(() => ({}));
16
15
  try {
17
16
  const result = await resumeHere(id, force === true);
@@ -1,6 +1,6 @@
1
1
  import { NextRequest } from "next/server";
2
2
  import { stopSession } from "@/lib/sessions/stop";
3
- import { heldRoute } from "@/lib/tasks/held-routes";
3
+ import { taskRoute } from "@/lib/tasks/contract/authorize";
4
4
 
5
5
  // POST /api/tasks/:id/stop - its orchestrator stops the agent; the
6
6
  // conversation and worktree are kept.
@@ -8,7 +8,7 @@ export async function POST(
8
8
  request: NextRequest,
9
9
  ctx: { params: Promise<{ id: string }> }
10
10
  ) {
11
- return heldRoute(request, ctx, async (task) => ({
11
+ return taskRoute(request, ctx, "holder", async (task) => ({
12
12
  text: await stopSession(task),
13
13
  }));
14
14
  }
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
3
3
  import { createTask, listTasks, movedTasks } from "@/lib/tasks";
4
4
  import { stampPriority, startOrQueue } from "@/lib/tasks/queue";
5
5
  import { isPriority } from "@/lib/tasks/schedule-types";
6
- import { TASK_CAPABILITIES } from "@/lib/tasks/remote";
6
+ import { TASK_CAPABILITIES } from "@/lib/tasks/contract/types";
7
7
  import { ensureProject, type ProjectRef } from "@/lib/tasks/project-ref";
8
8
  import { callingDevice } from "@/lib/security/device-me";
9
9
  import {
@@ -0,0 +1,40 @@
1
+ import { randomUUID } from "crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { NextRequest } from "next/server";
4
+
5
+ const { GET, PUT } = await import("./route");
6
+ const { createProject } = await import("@/lib/projects");
7
+
8
+ const put = (body: unknown) =>
9
+ PUT(
10
+ new NextRequest("http://localhost/api/worktree-sweep", {
11
+ method: "PUT",
12
+ body: JSON.stringify(body),
13
+ })
14
+ );
15
+
16
+ describe("/api/worktree-sweep", () => {
17
+ it("round-trips the global number and a project's own", async () => {
18
+ const p = createProject({
19
+ name: `app-${randomUUID().slice(0, 8)}`,
20
+ workingDirectory: "/tmp/app",
21
+ });
22
+ expect((await (await GET()).json()).days).toBeNull();
23
+ expect((await (await put({ days: 3 })).json()).days).toBe(3);
24
+ const state = await (await put({ days: 0, projectId: p.id })).json();
25
+ expect(state.overrides).toEqual([
26
+ { projectId: p.id, name: p.name, days: 0 },
27
+ ]);
28
+ const cleared = await (await put({ days: null, projectId: p.id })).json();
29
+ expect(cleared.overrides).toEqual([]);
30
+ expect((await (await put({ days: null })).json()).days).toBeNull();
31
+ });
32
+
33
+ it("refuses a number that isn't a whole 0-365", async () => {
34
+ for (const days of [-1, 1.5, 400, "7", undefined]) {
35
+ const res = await put({ days });
36
+ expect(res.status).toBe(400);
37
+ }
38
+ expect((await put("nope")).status).toBe(400);
39
+ });
40
+ });
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import {
4
+ setGlobalSweepDays,
5
+ setProjectSweepDays,
6
+ sweepDays,
7
+ } from "@/lib/worktree-sweep/settings";
8
+ import { sweepState } from "@/lib/worktree-sweep/state";
9
+
10
+ const body = z.object({
11
+ // Null clears the setting: the global one, then the default, applies.
12
+ days: sweepDays.nullable(),
13
+ projectId: z.string().min(1).optional(),
14
+ });
15
+
16
+ export async function GET() {
17
+ return NextResponse.json(sweepState());
18
+ }
19
+
20
+ export async function PUT(request: NextRequest) {
21
+ const got = body.safeParse(await request.json().catch(() => null));
22
+ if (!got.success)
23
+ return NextResponse.json(
24
+ { error: "days must be a whole number from 0 to 365, or null" },
25
+ { status: 400 }
26
+ );
27
+ const { days, projectId } = got.data;
28
+ if (projectId) setProjectSweepDays(projectId, days);
29
+ else setGlobalSweepDays(days);
30
+ return NextResponse.json(sweepState());
31
+ }
@@ -0,0 +1,26 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { sweepNow } from "@/lib/worktree-sweep/start";
4
+
5
+ const body = z.object({ dryRun: z.boolean().optional() });
6
+
7
+ // Starts a run and answers at once; its result is in the run list.
8
+ export async function POST(request: NextRequest) {
9
+ const got = body.safeParse(await request.json().catch(() => ({})));
10
+ if (!got.success)
11
+ return NextResponse.json(
12
+ { error: "dryRun must be a boolean" },
13
+ { status: 400 }
14
+ );
15
+ try {
16
+ const started = new Promise<void>((resolve, reject) => {
17
+ sweepNow(got.data.dryRun).then(() => resolve(), reject);
18
+ setTimeout(resolve, 250);
19
+ });
20
+ await started;
21
+ return NextResponse.json({ started: true }, { status: 202 });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return NextResponse.json({ error: message }, { status: 409 });
25
+ }
26
+ }
@@ -19,14 +19,16 @@ import {
19
19
  type CheckSummary,
20
20
  } from "@/lib/health/view";
21
21
  import { cn } from "@/lib/utils";
22
+ import { SweepRuns } from "./SweepRuns";
22
23
 
23
- const TABS = ["open", "history", "notes", "thresholds"] as const;
24
+ const TABS = ["open", "history", "notes", "thresholds", "cleanup"] as const;
24
25
  type Tab = (typeof TABS)[number];
25
26
  const TAB_LABEL: Record<Tab, string> = {
26
27
  open: "Open",
27
28
  history: "History",
28
29
  notes: "Notes",
29
30
  thresholds: "Thresholds",
31
+ cleanup: "Clean-up",
30
32
  };
31
33
 
32
34
  const tokens = (n: number) =>
@@ -407,6 +409,7 @@ export function HealthPanel() {
407
409
  )}
408
410
  {tab === "notes" && <Notes />}
409
411
  {tab === "thresholds" && <Thresholds />}
412
+ {tab === "cleanup" && <SweepRuns />}
410
413
  </div>
411
414
  );
412
415
  }