@saadnvd1/agent-os 0.3.631 → 0.3.639

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 (177) hide show
  1. package/app/api/sessions/[id]/pr/route.ts +3 -3
  2. package/app/api/sessions/[id]/setup/route.ts +3 -3
  3. package/app/api/tasks/[id]/approve-plan/route.ts +5 -1
  4. package/app/api/tasks/[id]/arm/route.ts +6 -5
  5. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  6. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  7. package/app/api/tasks/[id]/checks/route.ts +1 -1
  8. package/app/api/tasks/[id]/diff/route.ts +1 -1
  9. package/app/api/tasks/[id]/done/route.ts +2 -2
  10. package/app/api/tasks/[id]/drop/route.ts +6 -2
  11. package/app/api/tasks/[id]/export/route.ts +3 -4
  12. package/app/api/tasks/[id]/merge/route.ts +3 -3
  13. package/app/api/tasks/[id]/message/route.ts +2 -2
  14. package/app/api/tasks/[id]/model/route.ts +5 -1
  15. package/app/api/tasks/[id]/move/route.ts +5 -1
  16. package/app/api/tasks/[id]/moved/route.ts +6 -8
  17. package/app/api/tasks/[id]/plan/route.ts +35 -0
  18. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  19. package/app/api/tasks/[id]/priority/route.ts +4 -1
  20. package/app/api/tasks/[id]/resume/route.ts +3 -4
  21. package/app/api/tasks/[id]/stop/route.ts +2 -2
  22. package/app/api/tasks/route.ts +5 -2
  23. package/app/globals.css +28 -0
  24. package/components/Chat/ChatPanel.tsx +2 -11
  25. package/components/Chat/EventTurns.tsx +1 -7
  26. package/components/Chat/Markdown.tsx +1 -0
  27. package/components/Chat/Messages.tsx +1 -1
  28. package/lib/approvals/enable.ts +9 -2
  29. package/lib/away/report.ts +4 -4
  30. package/lib/away/run.ts +2 -2
  31. package/lib/bookmarks/store.ts +3 -3
  32. package/lib/bus/held-reply.ts +2 -2
  33. package/lib/bus/index.ts +3 -3
  34. package/lib/chat/compact-context.ts +1 -1
  35. package/lib/chat/events.ts +4 -0
  36. package/lib/chat/group.ts +3 -1
  37. package/lib/chat/hold.ts +3 -3
  38. package/lib/chat/reading-order.ts +50 -0
  39. package/lib/chat/refresh.ts +1 -1
  40. package/lib/chat/registry.ts +3 -3
  41. package/lib/chat/runner.ts +1 -1
  42. package/lib/chat/turns.ts +22 -3
  43. package/lib/chat/worker/host.ts +3 -0
  44. package/lib/chat/worker/main.ts +1 -1
  45. package/lib/db/index.ts +5 -1
  46. package/lib/db/lumifyhub.ts +8 -9
  47. package/lib/db/machine-scope.ts +12 -0
  48. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  49. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  50. package/lib/db/migrations/README.md +7 -0
  51. package/lib/db/queries.ts +16 -31
  52. package/lib/db/session-parts/columns.ts +186 -0
  53. package/lib/db/session-parts/fill.ts +33 -0
  54. package/lib/db/session-parts/index.ts +9 -0
  55. package/lib/db/session-parts/pauses.ts +11 -0
  56. package/lib/db/session-parts/placement.ts +11 -0
  57. package/lib/db/session-parts/plans.ts +31 -0
  58. package/lib/db/session-parts/prs.ts +31 -0
  59. package/lib/db/session-parts/tasks.ts +27 -0
  60. package/lib/db/session-parts/view.ts +43 -0
  61. package/lib/db/session-parts/write.ts +69 -0
  62. package/lib/db/types.ts +6 -1
  63. package/lib/done/archive.ts +2 -2
  64. package/lib/done/auto-archive.ts +1 -1
  65. package/lib/done/bulk.ts +1 -1
  66. package/lib/done/index.ts +8 -14
  67. package/lib/env-setup.ts +17 -4
  68. package/lib/health/evidence.ts +1 -1
  69. package/lib/health/facts.ts +1 -1
  70. package/lib/health/pieces.ts +1 -1
  71. package/lib/hosts/peer-actions.ts +2 -2
  72. package/lib/hosts/peer-mirror.ts +2 -2
  73. package/lib/hosts/peer-sync.ts +69 -32
  74. package/lib/learning-cards/input.ts +1 -1
  75. package/lib/learning-cards/store.ts +1 -1
  76. package/lib/models/plan-step.ts +83 -46
  77. package/lib/models/resume.ts +5 -7
  78. package/lib/notify/index.ts +1 -1
  79. package/lib/notify/push/events.ts +1 -1
  80. package/lib/notify/push/index.ts +1 -1
  81. package/lib/orchestrator/act.ts +1 -1
  82. package/lib/orchestrator/armed.ts +17 -15
  83. package/lib/orchestrator/ask-approvals.ts +1 -1
  84. package/lib/orchestrator/ask-heads.ts +1 -1
  85. package/lib/orchestrator/ask-settle.ts +1 -1
  86. package/lib/orchestrator/brakes.ts +1 -1
  87. package/lib/orchestrator/digest.ts +2 -2
  88. package/lib/orchestrator/external-pr.ts +2 -2
  89. package/lib/orchestrator/facts.ts +4 -28
  90. package/lib/orchestrator/held-asks.ts +3 -3
  91. package/lib/orchestrator/home.ts +2 -2
  92. package/lib/orchestrator/overview.ts +1 -1
  93. package/lib/orchestrator/pr-wake.ts +3 -5
  94. package/lib/orchestrator/questions.ts +1 -1
  95. package/lib/orchestrator/quick-store.ts +1 -1
  96. package/lib/orchestrator/quick.ts +10 -7
  97. package/lib/orchestrator/read.ts +1 -1
  98. package/lib/orchestrator/rebase.ts +8 -11
  99. package/lib/orchestrator/reconcile.ts +2 -2
  100. package/lib/orchestrator/signoff.ts +3 -1
  101. package/lib/orchestrator/targets.ts +1 -1
  102. package/lib/orchestrator/task-state.ts +4 -2
  103. package/lib/plans/features.ts +1 -1
  104. package/lib/plans/release.ts +1 -1
  105. package/lib/ports.ts +1 -1
  106. package/lib/resurrect/find.ts +1 -1
  107. package/lib/resurrect/index.ts +24 -18
  108. package/lib/schedules/store.ts +1 -1
  109. package/lib/session-audit/sources.ts +3 -3
  110. package/lib/session-logs/index.ts +1 -1
  111. package/lib/session-titles.ts +1 -1
  112. package/lib/sessions/setup-progress.ts +1 -1
  113. package/lib/sessions/stop.ts +1 -1
  114. package/lib/sessions/worktree-setup.ts +2 -1
  115. package/lib/simulator/index.ts +1 -1
  116. package/lib/simulator/task-devices.ts +1 -1
  117. package/lib/stacks/board.ts +1 -1
  118. package/lib/stacks/land.ts +1 -1
  119. package/lib/stacks/restack.ts +1 -1
  120. package/lib/stacks/start.ts +2 -2
  121. package/lib/stacks/tick.ts +1 -1
  122. package/lib/stacks/view.ts +1 -1
  123. package/lib/status/incremental.ts +1 -1
  124. package/lib/tasks/contract/actions.ts +35 -0
  125. package/lib/tasks/contract/authorize.ts +82 -0
  126. package/lib/tasks/contract/client.ts +296 -0
  127. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  128. package/lib/tasks/contract/index.ts +28 -0
  129. package/lib/tasks/contract/plan-path.ts +7 -0
  130. package/lib/tasks/contract/plan.ts +274 -0
  131. package/lib/tasks/contract/routes.ts +26 -0
  132. package/lib/tasks/contract/start.ts +122 -0
  133. package/lib/tasks/contract/sync.ts +126 -0
  134. package/lib/tasks/contract/types.ts +41 -0
  135. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  136. package/lib/tasks/finish.ts +10 -9
  137. package/lib/tasks/held-replies.ts +7 -13
  138. package/lib/tasks/held.ts +36 -17
  139. package/lib/tasks/hold-checks.ts +134 -0
  140. package/lib/tasks/holds.ts +281 -0
  141. package/lib/tasks/import.ts +26 -14
  142. package/lib/tasks/index.ts +56 -22
  143. package/lib/tasks/launch-gate.ts +1 -1
  144. package/lib/tasks/move-bundle.ts +17 -2
  145. package/lib/tasks/move-carry.ts +15 -7
  146. package/lib/tasks/move-flow.ts +29 -36
  147. package/lib/tasks/move-guard.ts +10 -2
  148. package/lib/tasks/move-recover.ts +6 -25
  149. package/lib/tasks/move.ts +28 -27
  150. package/lib/tasks/moved-away.ts +1 -1
  151. package/lib/tasks/pause-hold.ts +13 -5
  152. package/lib/tasks/pause.ts +21 -54
  153. package/lib/tasks/placement.ts +16 -16
  154. package/lib/tasks/plan-view.ts +11 -3
  155. package/lib/tasks/queue-candidates.ts +151 -0
  156. package/lib/tasks/queue-placement.ts +155 -0
  157. package/lib/tasks/queue-rows.ts +236 -0
  158. package/lib/tasks/queue-start.ts +213 -0
  159. package/lib/tasks/queue-tick.ts +148 -0
  160. package/lib/tasks/queue-types.ts +141 -0
  161. package/lib/tasks/queue-view.ts +112 -0
  162. package/lib/tasks/queue.ts +67 -1102
  163. package/lib/tasks/schedule.ts +44 -147
  164. package/lib/tasks/session.ts +7 -4
  165. package/lib/tasks/start.ts +4 -4
  166. package/lib/tasks/starter.ts +8 -11
  167. package/lib/usage/calls.ts +1 -1
  168. package/lib/usage/compare.ts +1 -1
  169. package/lib/usage/quality.ts +1 -1
  170. package/lib/usage/turns.ts +1 -1
  171. package/lib/voice/vocab.ts +1 -1
  172. package/package.json +1 -1
  173. package/scripts/backfill-session-logs.ts +1 -1
  174. package/scripts/check +55 -7
  175. package/lib/tasks/auto-hold.ts +0 -17
  176. package/lib/tasks/held-routes.ts +0 -33
  177. package/lib/tasks/remote.ts +0 -238
@@ -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,3 +1,4 @@
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
4
 
@@ -7,13 +8,16 @@ 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, "plan");
13
+ if (refused) return refused;
10
14
  try {
11
15
  const body = await request.json().catch(() => ({}));
12
16
  const message =
13
17
  typeof body?.message === "string" && body.message.trim()
14
18
  ? body.message.trim().slice(0, 8000)
15
19
  : undefined;
16
- const out = await approvePlanNow((await params).id, message);
20
+ const out = await approvePlanNow(id, message);
17
21
  return NextResponse.json({ success: true, model: out.ref });
18
22
  } catch (error) {
19
23
  const message = error instanceof Error ? error.message : String(error);
@@ -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 } 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,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, "plan");
14
+ if (refused) return refused;
16
15
  try {
17
16
  const { to } = await request.json().catch(() => ({}));
18
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
  }
@@ -0,0 +1,35 @@
1
+ import { refuse } from "@/lib/tasks/contract/authorize";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { NextRequest, NextResponse } from "next/server";
5
+ import { db, type Session } from "@/lib/db";
6
+ import { MAX_PLAN_BYTES } from "@/lib/tasks/move-bundle";
7
+ import { PLAN_FILE } from "@/lib/models/plan-step";
8
+ import { expandHome } from "@/lib/tasks/session";
9
+
10
+ // GET /api/tasks/:id/plan - the plan file of a task whose plan is written, for
11
+ // the machine that placed the plan here and decides what follows it.
12
+ export async function GET(
13
+ request: NextRequest,
14
+ { params }: { params: Promise<{ id: string }> }
15
+ ) {
16
+ const id = (await params).id;
17
+ const refused = refuse(request, id, "plan");
18
+ if (refused) return refused;
19
+ const s = db
20
+ .prepare(`SELECT plan_phase, worktree_path FROM session_rows WHERE id = ?`)
21
+ .get(id) as Pick<Session, "plan_phase" | "worktree_path"> | undefined;
22
+ if (!s?.worktree_path)
23
+ return NextResponse.json({ error: "No such task" }, { status: 404 });
24
+ let text: string | null = null;
25
+ try {
26
+ const read = await fs.readFile(
27
+ path.join(expandHome(s.worktree_path), PLAN_FILE),
28
+ "utf8"
29
+ );
30
+ text = read.length > MAX_PLAN_BYTES ? read.slice(0, MAX_PLAN_BYTES) : read;
31
+ } catch {
32
+ // No plan file was written.
33
+ }
34
+ return NextResponse.json({ text, phase: s.plan_phase ?? null });
35
+ }
@@ -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 {
@@ -46,6 +46,7 @@ export async function POST(request: NextRequest) {
46
46
  plan,
47
47
  plan_reason,
48
48
  plan_only,
49
+ plan_waits,
49
50
  priority,
50
51
  machine,
51
52
  orchestrated,
@@ -76,7 +77,7 @@ export async function POST(request: NextRequest) {
76
77
  ? model
77
78
  : (parseOverride(model) ?? undefined),
78
79
  plan: typeof plan === "boolean" ? plan : undefined,
79
- planOnly: plan_only === true ? true : undefined,
80
+ planOnly: plan_only === true || plan_waits === true ? true : undefined,
80
81
  planReason:
81
82
  typeof plan_reason === "string" && plan_reason.trim()
82
83
  ? plan_reason.trim().slice(0, 200)
@@ -107,6 +108,8 @@ export async function POST(request: NextRequest) {
107
108
  id,
108
109
  ...opts,
109
110
  held,
111
+ // Another machine's plan stops for it to decide what follows.
112
+ planWaits: plan_waits === true && !!device ? true : undefined,
110
113
  startedVia: "remote",
111
114
  startedByDevice: device ?? undefined,
112
115
  });
package/app/globals.css CHANGED
@@ -110,6 +110,34 @@
110
110
  0 0 0 4px hsl(var(--primary) / 0.15);
111
111
  }
112
112
 
113
+ /* One look for every link in the chat feed: it reacts to hover, keyboard focus
114
+ and touch (:active), since a phone has no hover. */
115
+ @utility chat-link {
116
+ cursor: pointer;
117
+ overflow-wrap: anywhere;
118
+ border-radius: 0.125rem;
119
+ text-decoration-line: underline;
120
+ text-decoration-thickness: 1px;
121
+ text-underline-offset: 2px;
122
+ transition:
123
+ color 120ms,
124
+ background-color 120ms,
125
+ text-decoration-thickness 120ms;
126
+ &:hover {
127
+ color: hsl(var(--primary));
128
+ background-color: hsl(var(--primary) / 0.1);
129
+ text-decoration-thickness: 2px;
130
+ }
131
+ &:active {
132
+ color: hsl(var(--primary));
133
+ background-color: hsl(var(--primary) / 0.2);
134
+ }
135
+ &:focus-visible {
136
+ outline: 2px solid hsl(var(--ring));
137
+ outline-offset: 2px;
138
+ }
139
+ }
140
+
113
141
  @utility label-mono {
114
142
  font-family: var(--font-geist-mono);
115
143
  font-size: 0.6875rem;
@@ -62,7 +62,7 @@ import { NeedsYouStrip } from "@/components/Orchestrator/NeedsYouStrip";
62
62
  import { useResolveQuestion } from "@/data/orchestrators";
63
63
  import { needsYouFocus, needsYouFocusActions } from "@/stores/needsYouFocus";
64
64
  import { hrefWithSession } from "@/lib/session-url";
65
- import { isQuestionTool } from "@/lib/chat/turns";
65
+ import { questionSource } from "@/lib/chat/turns";
66
66
  import { useViewport } from "@/hooks/useViewport";
67
67
  import { useChatCommands } from "./useChatCommands";
68
68
  import { copySelection } from "./copy-selection";
@@ -751,16 +751,7 @@ export function ChatPanel({
751
751
  )
752
752
  );
753
753
  window.dispatchEvent(new PopStateEvent("popstate"));
754
- } else
755
- goTo((all) => {
756
- const asked = all.findLast(
757
- (i) =>
758
- isQuestionTool(i) &&
759
- (i as { input?: { text?: string } }).input?.text ===
760
- item.question.text
761
- );
762
- return asked?.id ?? null;
763
- });
754
+ } else goTo((all) => questionSource(all, item.question.text));
764
755
  }}
765
756
  onReply={(item) => {
766
757
  setReplyTo({ id: item.question.id, text: item.question.text });
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { ReactNode } from "react";
4
- import { ChevronRight, Loader2, MessageCircleQuestion } from "lucide-react";
4
+ import { ChevronRight, Loader2 } from "lucide-react";
5
5
  import type { ChatItem } from "@/lib/chat/events";
6
6
  import type { EventTurn } from "@/lib/chat/turns";
7
7
  import { cn } from "@/lib/utils";
@@ -60,12 +60,6 @@ export function EventTurnRow({
60
60
  <span className="text-foreground/80 max-w-[40%] min-w-0 shrink-0 truncate font-medium">
61
61
  {turn.task}
62
62
  </span>
63
- {turn.hasQuestion && (
64
- <MessageCircleQuestion
65
- className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400"
66
- aria-label="Asked a question"
67
- />
68
- )}
69
63
  <span className="min-w-0 flex-1 truncate">{turn.summary}</span>
70
64
  {turn.live && (
71
65
  <Loader2
@@ -49,6 +49,7 @@ const components: Components = {
49
49
  href={local ? imageUrl(href) : href}
50
50
  target="_blank"
51
51
  rel="noreferrer"
52
+ className="chat-link"
52
53
  >
53
54
  {children}
54
55
  </a>
@@ -42,7 +42,7 @@ function UserLink({ href }: { href: string }) {
42
42
  target={id ? undefined : "_blank"}
43
43
  rel={id ? undefined : "noopener noreferrer"}
44
44
  onClick={id ? (e) => openSessionLink(e, id) : undefined}
45
- className="font-medium [overflow-wrap:anywhere] underline underline-offset-2"
45
+ className="chat-link font-medium"
46
46
  >
47
47
  {href}
48
48
  </a>
@@ -2,6 +2,7 @@
2
2
  // brings what already exists in line with it.
3
3
 
4
4
  import { db } from "../db";
5
+ import { isRemoteHost } from "../hosts";
5
6
  import { reapplyChatModes } from "../chat/settings";
6
7
  import { approvePlanNow } from "../models/plan-step";
7
8
  import { autoApproveAsk, type AskRow } from "../orchestrator/asks";
@@ -28,10 +29,16 @@ async function sweep(): Promise<void> {
28
29
  }
29
30
  const plans = db
30
31
  .prepare(
31
- `SELECT id FROM sessions WHERE plan_phase = 'awaiting' AND task_status = 'running'`
32
+ `SELECT id, host_id, plan_waits_for_caller FROM session_rows WHERE plan_phase = 'awaiting' AND task_status = 'running'`
32
33
  )
33
- .all() as { id: string }[];
34
+ .all() as {
35
+ id: string;
36
+ host_id: string;
37
+ plan_waits_for_caller: number | null;
38
+ }[];
34
39
  for (const p of plans) {
40
+ // A plan placed here by another machine is that machine's to decide.
41
+ if (p.plan_waits_for_caller && !isRemoteHost(p.host_id)) continue;
35
42
  try {
36
43
  await approvePlanNow(p.id, undefined, "no-approvals");
37
44
  } catch (e) {