@saadnvd1/agent-os 0.3.518 → 0.3.520

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 (47) hide show
  1. package/README.md +19 -1
  2. package/app/api/bus/peers/route.ts +8 -2
  3. package/app/api/bus/spawn/route.ts +4 -0
  4. package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
  5. package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
  6. package/app/api/tasks/[id]/moved/route.ts +7 -0
  7. package/app/api/tasks/held-replies/route.ts +31 -0
  8. package/app/api/tasks/route.ts +2 -0
  9. package/components/Tasks/TaskRow.tsx +46 -1
  10. package/components/Tasks/task-state-labels.ts +1 -0
  11. package/data/tasks/index.ts +12 -0
  12. package/lib/bus/held-reply.ts +89 -0
  13. package/lib/bus/index.ts +86 -10
  14. package/lib/db/migrations.ts +40 -0
  15. package/lib/db/types.ts +5 -1
  16. package/lib/done/worktree.ts +24 -10
  17. package/lib/hosts/remote-api.ts +12 -0
  18. package/lib/lumifyhub/lists.ts +1 -0
  19. package/lib/models/plan-step.ts +116 -32
  20. package/lib/models/roles.ts +3 -1
  21. package/lib/orchestrator/act.ts +24 -2
  22. package/lib/orchestrator/brief.ts +3 -1
  23. package/lib/orchestrator/conditions.ts +1 -0
  24. package/lib/orchestrator/describe.ts +1 -1
  25. package/lib/orchestrator/facts.ts +11 -2
  26. package/lib/orchestrator/serve.ts +8 -0
  27. package/lib/orchestrator/task-state.ts +40 -15
  28. package/lib/orchestrator/tool-names.ts +1 -0
  29. package/lib/orchestrator/tool-schemas.ts +18 -0
  30. package/lib/orchestrator/tools.ts +3 -1
  31. package/lib/security/demo.ts +1 -0
  32. package/lib/status-detector.ts +4 -0
  33. package/lib/tasks/held-remote.ts +31 -2
  34. package/lib/tasks/held-replies.ts +106 -0
  35. package/lib/tasks/import.ts +5 -3
  36. package/lib/tasks/index.ts +28 -7
  37. package/lib/tasks/move-bundle.ts +19 -2
  38. package/lib/tasks/move-flow.ts +21 -2
  39. package/lib/tasks/move-guard.ts +2 -0
  40. package/lib/tasks/move.ts +20 -1
  41. package/lib/tasks/moved-away.ts +37 -0
  42. package/lib/tasks/plan-view.ts +6 -1
  43. package/lib/tasks/queue.ts +6 -2
  44. package/lib/tasks/remote.ts +3 -0
  45. package/lib/tasks/start.ts +1 -1
  46. package/lib/tasks/state.ts +7 -1
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -539,6 +539,21 @@ and **Usage → Compare** splits cost into planned and not. When the plan step
539
539
  finishes, the orchestrator gets one event with the plan file's path and a
540
540
  3-5 line summary; the handoff to implementation stays automatic.
541
541
 
542
+ **Plan only.** A task started with `plan_only` (`start_task`'s `plan_only`,
543
+ `aos task --plan-only`, or `plan_only: true` on `POST /api/tasks`) writes its
544
+ plan on the planning model and then stops: no model switch, no implement
545
+ prompt, even when one model does every role. It sits in a **Plan ready**
546
+ state (`plan_phase` `awaiting`, stored on the task, so it survives a restart)
547
+ that isn't blocked, doesn't count as needing you, and holds no agent process
548
+ (its worker is released; the conversation resumes on approval). The
549
+ orchestrator gets one event with the plan's path, a summary and that the task
550
+ is waiting. **Approve** on the task row (plan viewable there), `POST
551
+ /api/tasks/<id>/approve-plan`, or the orchestrator's `approve_plan(task,
552
+ message?)` continues it on the implementation model, once; a message amends
553
+ the plan and is recorded on the task's brief as a scope change, so the review
554
+ judges against it. Reject with **Drop**. A chat task on this machine only; it
555
+ can't move until approved.
556
+
542
557
  **Per task.** The task draft has a **Model** chip ("Routed by role", or one
543
558
  model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
544
559
  `"provider/model"` for the task's own roles or a map such as
@@ -1587,7 +1602,10 @@ Once linked:
1587
1602
  judging the PR at its head, reads BLOCKED lines and open approvals from the
1588
1603
  task's chat there, and gets PR, CI, idle and plan events by polling that
1589
1604
  machine. Its messages arrive marked as from the orchestrator, never as
1590
- yours. Guards (write guard, shell deny list, attribution settings) run in
1605
+ yours. The task can `aos send` its orchestrator back: the machine it runs
1606
+ on only stores the message, and the orchestrator's machine pulls it and
1607
+ delivers it as that task's own. Moving it back stops its agent there and
1608
+ removes its worktree when nothing in it would be lost. Guards (write guard, shell deny list, attribution settings) run in
1591
1609
  the task's worker on either machine, and the task can't merge or push to
1592
1610
  the default branch. Moving counts toward the brakes and Pause refuses the
1593
1611
  tool.
@@ -1,10 +1,16 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { listPeers, resolveTarget } from "@/lib/bus";
2
+ import { listPeers, resolveHeldOrchestrator, resolveTarget } from "@/lib/bus";
3
3
 
4
4
  // ?ref=<name>: just the session that name reaches, the way aos send would.
5
5
  export async function GET(request: NextRequest) {
6
6
  const ref = request.nextUrl.searchParams.get("ref");
7
- if (ref === null) return NextResponse.json({ peers: await listPeers() });
7
+ // from: the asking session, which a held task needs for its orchestrator.
8
+ const from = request.nextUrl.searchParams.get("from") ?? "";
9
+ if (ref === null)
10
+ return NextResponse.json({ peers: await listPeers(from || undefined) });
11
+ const held = from ? resolveHeldOrchestrator(from, ref) : null;
12
+ if (held)
13
+ return NextResponse.json({ peer: { id: held.id, name: held.name } });
8
14
  try {
9
15
  const { session, note } = resolveTarget(ref);
10
16
  return NextResponse.json({
@@ -29,12 +29,15 @@ export async function POST(request: NextRequest) {
29
29
  on,
30
30
  after,
31
31
  view,
32
+ planOnly,
32
33
  from,
33
34
  } = await request.json();
34
35
  const startedBy = orchestratorFrom(from);
35
36
  const given = typeof name === "string" ? name : undefined;
36
37
  if (after !== undefined && mode !== "task")
37
38
  throw new Error("--after works for tasks only");
39
+ if (planOnly !== undefined && mode !== "task")
40
+ throw new Error("--plan-only works for tasks only");
38
41
  if (mode === "task") {
39
42
  const task = {
40
43
  projectId: findProject(String(project ?? "")).id,
@@ -43,6 +46,7 @@ export async function POST(request: NextRequest) {
43
46
  model,
44
47
  hostId: on ? hostIdNamed(String(on)) : undefined,
45
48
  view: startView(view),
49
+ planOnly: planOnly === true ? true : undefined,
46
50
  startedBy,
47
51
  };
48
52
  const out = await startOrQueue(
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { approvePlanNow } from "@/lib/models/plan-step";
3
+
4
+ // Approves a plan-only task's plan: it continues on the implementation
5
+ // model. An optional message amends the plan (recorded as a scope change).
6
+ export async function POST(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ try {
11
+ const body = await request.json().catch(() => ({}));
12
+ const message =
13
+ typeof body?.message === "string" && body.message.trim()
14
+ ? body.message.trim().slice(0, 8000)
15
+ : undefined;
16
+ const out = await approvePlanNow((await params).id, message);
17
+ return NextResponse.json({ success: true, model: out.ref });
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return NextResponse.json({ error: message }, { status: 409 });
21
+ }
22
+ }
@@ -0,0 +1,21 @@
1
+ import { NextRequest } from "next/server";
2
+ import { blockedReason } from "@/lib/tasks/state";
3
+ import { saidSinceLastMessage } from "@/lib/chat/store";
4
+ import { chatStateNow } from "@/lib/chat/runner";
5
+ import { heldRoute } from "@/lib/tasks/held-routes";
6
+
7
+ // GET /api/tasks/:id/chat-facts - what its chat says about being blocked
8
+ // and whether a question is open, for the orchestrator that moved it here.
9
+ // Cheap (no PR lookups), unlike the whole task list.
10
+ export async function GET(
11
+ request: NextRequest,
12
+ ctx: { params: Promise<{ id: string }> }
13
+ ) {
14
+ return heldRoute(request, ctx, async (task) => {
15
+ if (task.view !== "chat") return { blocked: null, turn: null };
16
+ return {
17
+ blocked: blockedReason(saidSinceLastMessage(task.id)),
18
+ turn: await chatStateNow(task.id).catch(() => "unknown"),
19
+ };
20
+ });
21
+ }
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { markMoved } from "@/lib/tasks/move";
3
3
  import { callingDevice } from "@/lib/security/device-me";
4
4
  import { heldRefusal } from "@/lib/tasks/held";
5
+ import { cleanUpMovedAway } from "@/lib/tasks/moved-away";
6
+ import { runInBackground } from "@/lib/async-operations";
5
7
 
6
8
  // POST /api/tasks/:id/moved - the task now runs on the machine named `to`
7
9
  export async function POST(
@@ -13,5 +15,10 @@ export async function POST(
13
15
  if (held) return NextResponse.json({ error: held }, { status: 403 });
14
16
  const { to } = await request.json().catch(() => ({}));
15
17
  markMoved(id, String(to || "another machine").slice(0, 100));
18
+ // 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}`);
16
23
  return NextResponse.json({ success: true });
17
24
  }
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { callingDevice } from "@/lib/security/device-me";
3
+ import { ackReplies, pendingReplies } from "@/lib/bus/held-reply";
4
+
5
+ // GET /api/tasks/held-replies - what the tasks this device moved here have
6
+ // said to their orchestrator, for it to deliver there. Only that device's.
7
+ export async function GET(request: NextRequest) {
8
+ const device = callingDevice(request);
9
+ if (!device)
10
+ return NextResponse.json(
11
+ { error: "Needs a paired device" },
12
+ { status: 403 }
13
+ );
14
+ return NextResponse.json({ messages: pendingReplies(device) });
15
+ }
16
+
17
+ // POST { ids } - those are delivered; they are not offered again.
18
+ export async function POST(request: NextRequest) {
19
+ const device = callingDevice(request);
20
+ if (!device)
21
+ return NextResponse.json(
22
+ { error: "Needs a paired device" },
23
+ { status: 403 }
24
+ );
25
+ const { ids } = await request.json().catch(() => ({}));
26
+ const list = (Array.isArray(ids) ? ids : []).filter((n): n is number =>
27
+ Number.isSafeInteger(n)
28
+ );
29
+ ackReplies(device, list.slice(0, 200));
30
+ return NextResponse.json({ success: true });
31
+ }
@@ -36,6 +36,7 @@ export async function POST(request: NextRequest) {
36
36
  view,
37
37
  plan,
38
38
  plan_reason,
39
+ plan_only,
39
40
  } = await request.json();
40
41
  if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
41
42
  const pid =
@@ -48,6 +49,7 @@ export async function POST(request: NextRequest) {
48
49
  // Checked now, so a queued task can't fail on it later.
49
50
  model: parseOverride(model) ?? undefined,
50
51
  plan: typeof plan === "boolean" ? plan : undefined,
52
+ planOnly: plan_only === true ? true : undefined,
51
53
  planReason:
52
54
  typeof plan_reason === "string" && plan_reason.trim()
53
55
  ? plan_reason.trim().slice(0, 200)
@@ -10,7 +10,13 @@ import {
10
10
  import { Button } from "@/components/ui/button";
11
11
  import type { TaskView } from "@/lib/tasks";
12
12
  import { isFinished } from "@/lib/tasks/state";
13
- import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
13
+ import {
14
+ useApprovePlan,
15
+ useArmTask,
16
+ useDropTask,
17
+ useSignOffTask,
18
+ } from "@/data/tasks";
19
+ import { Markdown } from "@/components/Chat/Markdown";
14
20
  import { tmuxAttachActions } from "@/stores/tmuxAttach";
15
21
  import { tasksUiActions } from "@/stores/tasksUi";
16
22
  import { cn } from "@/lib/utils";
@@ -23,6 +29,8 @@ export function TaskRow({ task }: { task: TaskView }) {
23
29
  const signOff = useSignOffTask();
24
30
  const drop = useDropTask();
25
31
  const arm = useArmTask();
32
+ const approve = useApprovePlan();
33
+ const [showPlan, setShowPlan] = useState(false);
26
34
  const [confirmDrop, setConfirmDrop] = useState(false);
27
35
  const [moveError, setMoveError] = useState<string | null>(null);
28
36
  const live = !isFinished(task.state);
@@ -30,6 +38,7 @@ export function TaskRow({ task }: { task: TaskView }) {
30
38
  signOff.error?.message ||
31
39
  drop.error?.message ||
32
40
  arm.error?.message ||
41
+ approve.error?.message ||
33
42
  moveError ||
34
43
  task.hostError;
35
44
  const settingUp = live && task.setup?.status === "running";
@@ -148,6 +157,26 @@ export function TaskRow({ task }: { task: TaskView }) {
148
157
  {task.armed ? "Armed" : "Arm merge"}
149
158
  </Button>
150
159
  )}
160
+ {task.state === "plan-ready" && (
161
+ <>
162
+ <Button
163
+ size="sm"
164
+ variant="outline"
165
+ aria-expanded={showPlan}
166
+ onClick={() => setShowPlan((v) => !v)}
167
+ >
168
+ {showPlan ? "Hide plan" : "View plan"}
169
+ </Button>
170
+ <Button
171
+ size="sm"
172
+ className="h-11 md:h-8"
173
+ disabled={approve.isPending}
174
+ onClick={() => approve.mutate({ id: task.id })}
175
+ >
176
+ {approve.isPending ? "Approving..." : "Approve"}
177
+ </Button>
178
+ </>
179
+ )}
151
180
  {task.state === "review" && (
152
181
  <Button
153
182
  size="sm"
@@ -159,6 +188,22 @@ export function TaskRow({ task }: { task: TaskView }) {
159
188
  )}
160
189
  </div>
161
190
  )}
191
+ {live && task.state === "plan-ready" && showPlan && (
192
+ <div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
193
+ {task.plan?.text ? (
194
+ <Markdown text={task.plan.text} />
195
+ ) : (
196
+ <p className="text-muted-foreground text-xs">
197
+ No plan file was written.
198
+ </p>
199
+ )}
200
+ {task.plan?.truncated && (
201
+ <p className="text-muted-foreground mt-2 font-mono text-[11px]">
202
+ Shortened; the whole plan is {task.plan.path} in the worktree.
203
+ </p>
204
+ )}
205
+ </div>
206
+ )}
162
207
  {error && <p className="text-destructive text-xs">{error}</p>}
163
208
  </div>
164
209
  );
@@ -8,6 +8,7 @@ export const TASK_STATE: Record<TaskState, { label: string; tone: string }> = {
8
8
  tone: "text-amber-600 dark:text-amber-400",
9
9
  },
10
10
  blocked: { label: "Blocked", tone: "text-amber-600 dark:text-amber-400" },
11
+ "plan-ready": { label: "Plan ready", tone: "text-primary" },
11
12
  review: { label: "Ready for review", tone: "text-primary" },
12
13
  "checks-failing": { label: "Checks failing", tone: "text-destructive" },
13
14
  exited: { label: "Agent exited", tone: "text-amber-600 dark:text-amber-400" },
@@ -85,6 +85,18 @@ export function useDropTask() {
85
85
  );
86
86
  }
87
87
 
88
+ export function useApprovePlan() {
89
+ return useTaskMutation(async (input: { id: string; message?: string }) =>
90
+ json(
91
+ await fetch(`/api/tasks/${input.id}/approve-plan`, {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({ message: input.message }),
95
+ })
96
+ )
97
+ );
98
+ }
99
+
88
100
  export function useResumeTask() {
89
101
  return useTaskMutation(async (input: { id: string; force?: boolean }) =>
90
102
  json(
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A task its orchestrator moved here has no orchestrator on this machine to
3
+ * message, and this machine never reaches back to the one that holds it. Its
4
+ * message to the orchestrator is only stored here (relay_state 'pending');
5
+ * the holder pulls it (lib/tasks/held-replies.ts) and delivers it there as
6
+ * that task's own. This machine never delivers or attributes it, and the
7
+ * holder, not the sender, says who receives it.
8
+ */
9
+
10
+ import { db, type Session } from "../db";
11
+ import { resolveRef } from "./resolve";
12
+
13
+ export interface HeldOrchestrator {
14
+ id: string;
15
+ name: string;
16
+ heldFrom: string;
17
+ }
18
+
19
+ /** The orchestrator a running held task messages, or null. */
20
+ export function heldOrchestrator(
21
+ s: Session | undefined
22
+ ): HeldOrchestrator | null {
23
+ if (
24
+ !s ||
25
+ !s.held_by_device ||
26
+ !s.held_for ||
27
+ !s.held_for_name ||
28
+ s.task_status !== "running"
29
+ )
30
+ return null;
31
+ return {
32
+ id: s.held_for,
33
+ name: s.held_for_name,
34
+ heldFrom: s.held_from || "another machine",
35
+ };
36
+ }
37
+
38
+ /** The orchestrator `ref` names for this held sender: its name, id prefix or "orchestrator". */
39
+ export function heldTarget(
40
+ from: Session | undefined,
41
+ ref: string
42
+ ): HeldOrchestrator | null {
43
+ const o = from && heldOrchestrator(from);
44
+ if (!o) return null;
45
+ if (ref.trim().toLowerCase() === "orchestrator") return o;
46
+ const r = resolveRef(ref, [
47
+ {
48
+ id: o.id,
49
+ name: o.name,
50
+ projectName: null,
51
+ tmuxName: "",
52
+ previousNames: [],
53
+ },
54
+ ]);
55
+ return r.ok ? o : null;
56
+ }
57
+
58
+ export interface PendingReply {
59
+ id: number;
60
+ fromId: string;
61
+ body: string;
62
+ createdAt: string;
63
+ }
64
+
65
+ /** What the calling device's held tasks have said to their orchestrators. */
66
+ export function pendingReplies(device: string, limit = 50): PendingReply[] {
67
+ return (
68
+ db
69
+ .prepare(
70
+ `SELECT m.id, m.from_id AS fromId, m.body, m.created_at AS createdAt
71
+ FROM bus_messages m JOIN sessions s ON s.id = m.from_id
72
+ WHERE m.relay_state = 'pending' AND s.held_by_device = ?
73
+ ORDER BY m.id LIMIT ?`
74
+ )
75
+ .all(device, limit) as PendingReply[]
76
+ ).map((r) => ({ ...r, id: Number(r.id) }));
77
+ }
78
+
79
+ /** Marks them pulled; only the device's own are touched. */
80
+ export function ackReplies(device: string, ids: number[]): void {
81
+ const mark = db.prepare(
82
+ `UPDATE bus_messages SET relay_state = 'relayed', delivered_at = datetime('now')
83
+ WHERE id = ? AND relay_state = 'pending'
84
+ AND from_id IN (SELECT id FROM sessions WHERE held_by_device = ?)`
85
+ );
86
+ db.transaction(() => {
87
+ for (const id of ids) mark.run(id, device);
88
+ })();
89
+ }
package/lib/bus/index.ts CHANGED
@@ -16,6 +16,7 @@ import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
17
  import { hostLink } from "../hosts/remote-api";
18
18
  import { relayToHeldTask } from "../tasks/held-remote";
19
+ import { heldOrchestrator, heldTarget } from "./held-reply";
19
20
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
20
21
 
21
22
  export * from "./format";
@@ -54,11 +55,21 @@ function candidates(sessions: Session[]): Candidate[] {
54
55
  }));
55
56
  }
56
57
 
57
- export async function listPeers(): Promise<Peer[]> {
58
+ const sessionById = (id: string | null | undefined) =>
59
+ id
60
+ ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
61
+ | Session
62
+ | undefined)
63
+ : undefined;
64
+
65
+ // `callerId`: the asking session. A task its orchestrator moved here also
66
+ // sees that orchestrator, which it can message though it runs elsewhere.
67
+ export async function listPeers(callerId?: string): Promise<Peer[]> {
58
68
  await statusDetector.refreshCache();
59
69
  const sessions = liveSessions();
60
70
  const named = candidates(sessions);
61
- return Promise.all(
71
+ const orchestrator = heldOrchestrator(sessionById(callerId));
72
+ const listed = await Promise.all(
62
73
  sessions.map(async (s, i) => {
63
74
  const status = statusDetector.sessionExists(s.tmux_name)
64
75
  ? await statusDetector.getStatus(s.tmux_name)
@@ -76,6 +87,25 @@ export async function listPeers(): Promise<Peer[]> {
76
87
  };
77
88
  })
78
89
  );
90
+ if (!orchestrator) return listed;
91
+ return [
92
+ ...listed,
93
+ {
94
+ id: orchestrator.id,
95
+ name: orchestrator.name,
96
+ was: [],
97
+ projectName: null,
98
+ tmuxName: "",
99
+ hostId: "remote",
100
+ running: true,
101
+ doing: `your orchestrator, on ${orchestrator.heldFrom}`,
102
+ },
103
+ ];
104
+ }
105
+
106
+ // The orchestrator a held caller's ref names, if it names it.
107
+ export function resolveHeldOrchestrator(callerId: string, ref: string) {
108
+ return heldTarget(sessionById(callerId), ref);
79
109
  }
80
110
 
81
111
  // A live session by name, old name, project/name, id or id prefix. The note
@@ -208,16 +238,18 @@ export async function sendMessage(opts: {
208
238
  // It's never left unread for `aos inbox` to show in another form; a
209
239
  // failed one is the sender's to retry.
210
240
  line?: string;
241
+ // A message pulled from a linked machine, by its key there: one already
242
+ // stored is not stored or delivered again.
243
+ relayKey?: string;
211
244
  }): Promise<{ message: BusMessageView; delivery: Delivery; note?: string }> {
212
245
  const body = opts.body.trim();
213
246
  if (!body) throw new Error("Message is empty");
214
- const { session: to, note } = resolveTarget(opts.to);
215
- const from = opts.fromId
216
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(opts.fromId) as
217
- | Session
218
- | undefined)
219
- : undefined;
247
+ const from = sessionById(opts.fromId);
220
248
  if (opts.fromId && !from) throw new Error("Unknown sender session");
249
+ // A held task's message to its orchestrator is kept for the holder to pull.
250
+ const held = heldTarget(from, opts.to);
251
+ if (from && held) return storeForHolder(from, held, body);
252
+ const { session: to, note } = resolveTarget(opts.to);
221
253
  if (from && from.id === to.id)
222
254
  throw new Error("A session can't message itself");
223
255
  if (from && overRateLimit(pairTimestamps(from.id, to.id))) {
@@ -225,13 +257,32 @@ export async function sendMessage(opts: {
225
257
  `Too many messages between ${from.name} and ${to.name}; wait a few minutes`
226
258
  );
227
259
  }
260
+ if (opts.relayKey) {
261
+ const seen = db
262
+ .prepare(`SELECT id FROM bus_messages WHERE relay_key = ?`)
263
+ .get(opts.relayKey) as { id: number } | undefined;
264
+ if (seen)
265
+ return {
266
+ message: toView(
267
+ db.prepare(`${SELECT} WHERE m.id = ?`).get(seen.id) as Row
268
+ ),
269
+ delivery: { state: "delivered" },
270
+ };
271
+ }
228
272
 
229
273
  const fromName = from?.name ?? opts.fromLabel ?? HUMAN;
230
274
  const { lastInsertRowid } = db
231
275
  .prepare(
232
- `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body) VALUES (?, ?, ?, ?, ?)`
276
+ `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key) VALUES (?, ?, ?, ?, ?, ?)`
233
277
  )
234
- .run(from?.id ?? null, fromName, to.id, to.name, body);
278
+ .run(
279
+ from?.id ?? null,
280
+ fromName,
281
+ to.id,
282
+ to.name,
283
+ body,
284
+ opts.relayKey ?? null
285
+ );
235
286
  const id = Number(lastInsertRowid);
236
287
 
237
288
  await statusDetector.refreshCache();
@@ -260,6 +311,31 @@ export async function sendMessage(opts: {
260
311
  return { message, delivery, note };
261
312
  }
262
313
 
314
+ function storeForHolder(
315
+ from: Session,
316
+ to: { id: string; name: string; heldFrom: string },
317
+ body: string
318
+ ) {
319
+ if (overRateLimit(pairTimestamps(from.id, to.id)))
320
+ throw new Error(
321
+ `Too many messages between ${from.name} and ${to.name}; wait a few minutes`
322
+ );
323
+ const { lastInsertRowid } = db
324
+ .prepare(
325
+ `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_state)
326
+ VALUES (?, ?, ?, ?, ?, 'pending')`
327
+ )
328
+ .run(from.id, from.name, to.id, to.name, body);
329
+ const message = toView(
330
+ db.prepare(`${SELECT} WHERE m.id = ?`).get(Number(lastInsertRowid)) as Row
331
+ );
332
+ return {
333
+ message,
334
+ delivery: { state: "queued" as const },
335
+ note: `${to.name} runs on ${to.heldFrom}: it gets this when that machine next checks in, within a few seconds`,
336
+ };
337
+ }
338
+
263
339
  // Unread messages for a session, marked read as they're returned.
264
340
  export function readInbox(sessionId: string): BusMessageView[] {
265
341
  const rows = db
@@ -1714,6 +1714,46 @@ const migrations: Migration[] = [
1714
1714
  add(col);
1715
1715
  },
1716
1716
  },
1717
+ {
1718
+ id: 74,
1719
+ name: "held_task_replies",
1720
+ up: (db) => {
1721
+ // held_for / held_for_name: the orchestrator (id, name there) a held
1722
+ // task messages. relay_state: a held task's message to it, waiting for
1723
+ // the holder to pull it (pending) or pulled (relayed). relay_key: a
1724
+ // pulled message's key on the orchestrator's machine, so a repeat pull
1725
+ // is not delivered twice.
1726
+ const has = (table: string, col: string) =>
1727
+ (
1728
+ db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
1729
+ ).some((c) => c.name === col);
1730
+ for (const col of ["held_for", "held_for_name"])
1731
+ if (!has("sessions", col))
1732
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
1733
+ for (const col of ["relay_state", "relay_key"])
1734
+ if (!has("bus_messages", col))
1735
+ db.exec(`ALTER TABLE bus_messages ADD COLUMN ${col} TEXT`);
1736
+ db.exec(
1737
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_bus_relay_key ON bus_messages(relay_key) WHERE relay_key IS NOT NULL;
1738
+ CREATE INDEX IF NOT EXISTS idx_bus_relay_pending ON bus_messages(relay_state) WHERE relay_state = 'pending'`
1739
+ );
1740
+ },
1741
+ },
1742
+ {
1743
+ id: 75,
1744
+ name: "plan_only_tasks",
1745
+ up: (db) => {
1746
+ // plan_only: 1 for a task that stops after its plan and waits for
1747
+ // approval (plan_phase 'awaiting'); queued tasks keep it until they start.
1748
+ const has = (table: string) =>
1749
+ (
1750
+ db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
1751
+ ).some((c) => c.name === "plan_only");
1752
+ for (const table of ["sessions", "task_queue"])
1753
+ if (!has(table))
1754
+ db.exec(`ALTER TABLE ${table} ADD COLUMN plan_only INTEGER`);
1755
+ },
1756
+ },
1717
1757
  ];
1718
1758
 
1719
1759
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -19,7 +19,9 @@ export interface Session {
19
19
  // A task's own models: "provider/model", or JSON of role to one.
20
20
  model_overrides?: string | null;
21
21
  // A task's plan step: planning on the planning model, then implementing.
22
- plan_phase?: "planning" | "implementing" | null;
22
+ // 'awaiting': a plan-only task's plan is written; it waits for approval.
23
+ plan_phase?: "planning" | "awaiting" | "implementing" | null;
24
+ plan_only?: number | null;
23
25
  // The orchestrator session that started this one; null for a person.
24
26
  started_by?: string | null;
25
27
  // 1 planned, 0 no plan, null not stated; why, in the starter's words.
@@ -124,6 +126,8 @@ export interface Session {
124
126
  // The device and machine of the orchestrator that moved this task here.
125
127
  held_by_device?: string | null;
126
128
  held_from?: string | null;
129
+ held_for?: string | null;
130
+ held_for_name?: string | null;
127
131
  // A held task's "plan written" line, for its orchestrator to read.
128
132
  plan_note?: string | null;
129
133
  // Orchestration fields