@saadnvd1/agent-os 0.3.517 → 0.3.519

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 (57) hide show
  1. package/README.md +23 -3
  2. package/app/api/bus/peers/route.ts +8 -2
  3. package/app/api/tasks/[id]/arm/route.ts +5 -0
  4. package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
  5. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  6. package/app/api/tasks/[id]/done/route.ts +15 -0
  7. package/app/api/tasks/[id]/export/route.ts +6 -1
  8. package/app/api/tasks/[id]/merge/route.ts +9 -1
  9. package/app/api/tasks/[id]/message/route.ts +39 -0
  10. package/app/api/tasks/[id]/moved/route.ts +13 -1
  11. package/app/api/tasks/[id]/resume/route.ts +6 -1
  12. package/app/api/tasks/[id]/stop/route.ts +14 -0
  13. package/app/api/tasks/held-replies/route.ts +31 -0
  14. package/app/api/tasks/import/route.ts +11 -1
  15. package/lib/bus/held-reply.ts +89 -0
  16. package/lib/bus/index.ts +101 -12
  17. package/lib/db/migrations.ts +50 -0
  18. package/lib/db/types.ts +9 -0
  19. package/lib/done/bulk.ts +8 -0
  20. package/lib/done/index.ts +15 -0
  21. package/lib/done/worktree.ts +24 -10
  22. package/lib/hosts/remote-api.ts +12 -0
  23. package/lib/models/plan-step.ts +13 -3
  24. package/lib/orchestrator/act.ts +41 -1
  25. package/lib/orchestrator/armed.ts +6 -1
  26. package/lib/orchestrator/brakes.ts +9 -0
  27. package/lib/orchestrator/brief.ts +2 -1
  28. package/lib/orchestrator/facts.ts +72 -21
  29. package/lib/orchestrator/read.ts +14 -2
  30. package/lib/orchestrator/serve.ts +6 -0
  31. package/lib/orchestrator/shell-guard.ts +15 -0
  32. package/lib/orchestrator/task-state.ts +40 -15
  33. package/lib/orchestrator/tool-names.ts +1 -0
  34. package/lib/orchestrator/tool-schemas.ts +11 -0
  35. package/lib/orchestrator/tools.ts +1 -0
  36. package/lib/security/demo.ts +1 -0
  37. package/lib/sessions/stop.ts +15 -1
  38. package/lib/status-detector.ts +4 -0
  39. package/lib/tasks/branch-worktree.ts +16 -1
  40. package/lib/tasks/finish.ts +5 -1
  41. package/lib/tasks/held-remote.ts +109 -0
  42. package/lib/tasks/held-replies.ts +106 -0
  43. package/lib/tasks/held-routes.ts +33 -0
  44. package/lib/tasks/held.ts +28 -0
  45. package/lib/tasks/import.ts +65 -10
  46. package/lib/tasks/index.ts +11 -1
  47. package/lib/tasks/last-active.ts +25 -0
  48. package/lib/tasks/move-bundle.ts +72 -3
  49. package/lib/tasks/move-carry.ts +102 -0
  50. package/lib/tasks/move-flow.ts +55 -9
  51. package/lib/tasks/move-guard.ts +17 -13
  52. package/lib/tasks/move-recover.ts +2 -0
  53. package/lib/tasks/move.ts +49 -3
  54. package/lib/tasks/moved-away.ts +37 -0
  55. package/lib/tasks/remote.ts +6 -0
  56. package/lib/tasks/session.ts +1 -1
  57. package/package.json +1 -1
package/README.md CHANGED
@@ -1201,7 +1201,7 @@ it may do on its own, and the lines that always come back to you as asks.
1201
1201
  - **Tools:** `sessions` (every session's status, activity, task, PR, CI and
1202
1202
  stack position), `read` (the end of a terminal or chat) and `cards` (the
1203
1203
  linked boards' cards), plus acting tools: `send`, `start_task`,
1204
- `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`,
1204
+ `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`, `move`,
1205
1205
  `add_project` (a git repository on the machine becomes a project of the
1206
1206
  workspace, or the project already at that path moves in from no workspace;
1207
1207
  folders that hold secrets and projects of another workspace are refused),
@@ -1222,7 +1222,8 @@ it may do on its own, and the lines that always come back to you as asks.
1222
1222
  naming the tool to use instead: merging, closing or retargeting a PR
1223
1223
  (`gh pr merge`, `gh pr close`, `gh pr edit --base`, and `gh api` or `curl`
1224
1224
  calls that merge a pull request or PATCH or PUT one; use `sign_off`, `arm`,
1225
- `land` or `drop`), `git push` (`quick_pr` pushes), writing in a project
1225
+ `land` or `drop`), `aos move`, `aos done` and `aos stop` (use `move`, `done` and
1226
+ `stop`, which are scoped to the workspace and held by Pause), `git push` (`quick_pr` pushes), writing in a project
1226
1227
  repository or task worktree other than its own open quick changes (git
1227
1228
  commit, reset, checkout, rebase, merge, stash, clean or rm there, and `rm`,
1228
1229
  `mv`, `cp`, `tee` or a redirect into one), writing AgentOS's state (`sqlite3`
@@ -1574,6 +1575,25 @@ Once linked:
1574
1575
  press Move again to finish it, or **Resume here**, which first asks the
1575
1576
  other machine whether it arrived. Tasks waiting on you (an escalated gate or
1576
1577
  an open ask), card tasks and stacked tasks don't move yet.
1578
+ - **An orchestrator's task moves too**, by the orchestrator's `move` tool (`task`,
1579
+ `machine`; `here` brings it back) or the Move button. Its state is re-keyed to
1580
+ the task's new session id in the same step it lands: brief and scope changes,
1581
+ plan file and phase, gate checks and failure counts, asks, wakes, queue
1582
+ order, arm, model and provider. Both machines need an AgentOS that can do
1583
+ it, and the arriving one must have the task's provider. The machine it lands
1584
+ on holds it for the orchestrator's machine alone: only that device can
1585
+ merge, arm, message, stop, done or read it, and the arriving machine's own
1586
+ orchestrator, armed merges and bulk done skip it. The orchestrator keeps
1587
+ judging the PR at its head, reads BLOCKED lines and open approvals from the
1588
+ task's chat there, and gets PR, CI, idle and plan events by polling that
1589
+ machine. Its messages arrive marked as from the orchestrator, never as
1590
+ yours. The task can `aos send` its orchestrator back: the machine it runs
1591
+ on only stores the message, and the orchestrator's machine pulls it and
1592
+ delivers it as that task's own. Moving it back stops its agent there and
1593
+ removes its worktree when nothing in it would be lost. Guards (write guard, shell deny list, attribution settings) run in
1594
+ the task's worker on either machine, and the task can't merge or push to
1595
+ the default branch. Moving counts toward the brakes and Pause refuses the
1596
+ tool.
1577
1597
  - **A chat task moves the same way**, between turns: a running turn is let
1578
1598
  finish (up to 3 minutes, else the move is refused and it carries on here),
1579
1599
  and one waiting on your answer isn't moved. What's sent to it while its
@@ -1586,7 +1606,7 @@ Once linked:
1586
1606
  Works for any project. Requests to the other machine carry its device token,
1587
1607
  so it can sit behind a proxy that never trusts loopback. Linking needs this
1588
1608
  machine or the tailnet, and only pairs with the ssh target's own host name.
1589
- The orchestrator still starts tasks on its own machine only.
1609
+ The orchestrator starts tasks on its own machine and moves them afterwards.
1590
1610
 
1591
1611
  ## Security
1592
1612
 
@@ -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({
@@ -1,6 +1,8 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db, type Session } from "@/lib/db";
3
3
  import { armTask, setArmed } from "@/lib/orchestrator/armed";
4
+ import { callingDevice } from "@/lib/security/device-me";
5
+ import { heldRefusal } from "@/lib/tasks/held";
4
6
 
5
7
  // { armed: true } merges the task's PR by itself once every gate passes;
6
8
  // false takes that back.
@@ -8,6 +10,9 @@ export async function POST(
8
10
  request: NextRequest,
9
11
  { params }: { params: Promise<{ id: string }> }
10
12
  ) {
13
+ const id = (await params).id;
14
+ const held = heldRefusal(id, callingDevice(request));
15
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
11
16
  const { armed } = await request.json().catch(() => ({}));
12
17
  if (typeof armed !== "boolean")
13
18
  return NextResponse.json(
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ import { NextRequest } from "next/server";
2
+ import { chatTailText } from "@/lib/orchestrator/read";
3
+ import { heldRoute } from "@/lib/tasks/held-routes";
4
+
5
+ // GET /api/tasks/:id/chat-tail?lines= - the last lines of its conversation,
6
+ // for the orchestrator that moved it here to read.
7
+ export async function GET(
8
+ request: NextRequest,
9
+ ctx: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const lines = Number(request.nextUrl.searchParams.get("lines")) || 60;
12
+ return heldRoute(request, ctx, async (task) => ({
13
+ text: task.view === "chat" ? chatTailText(task.id, lines) : "",
14
+ }));
15
+ }
@@ -0,0 +1,15 @@
1
+ import { NextRequest } from "next/server";
2
+ import { doneSession } from "@/lib/done";
3
+ import { heldRoute } from "@/lib/tasks/held-routes";
4
+
5
+ // POST /api/tasks/:id/done - its orchestrator finishes it: the agent stops
6
+ // and the worktree goes. Never a merge (noMerge): an open PR is refused
7
+ // here, since only its orchestrator's gates merge it.
8
+ export async function POST(
9
+ request: NextRequest,
10
+ ctx: { params: Promise<{ id: string }> }
11
+ ) {
12
+ return heldRoute(request, ctx, async (task) => ({
13
+ outcome: await doneSession(task.id, { by: "direct", noMerge: true }),
14
+ }));
15
+ }
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { exportOrResume } from "@/lib/tasks/move";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { heldRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // POST /api/tasks/:id/export - stop the agent, push, hand over the task.
5
7
  // The caller confirms with /moved, or restarts it here with /resume; if it
@@ -8,10 +10,13 @@ export async function POST(
8
10
  request: NextRequest,
9
11
  { params }: { params: Promise<{ id: string }> }
10
12
  ) {
13
+ const id = (await params).id;
14
+ const held = heldRefusal(id, callingDevice(request));
15
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
11
16
  try {
12
17
  const { to } = await request.json().catch(() => ({}));
13
18
  const bundle = await exportOrResume(
14
- (await params).id,
19
+ id,
15
20
  String(to || "another machine").slice(0, 100)
16
21
  );
17
22
  return NextResponse.json({ bundle });
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { signOffTask } from "@/lib/tasks";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { heldRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // head: merge only if the PR is still at this commit; the answer names the
5
7
  // commit it was pinned to, so another machine forwarding a merge can check.
@@ -7,6 +9,9 @@ export async function POST(
7
9
  request: NextRequest,
8
10
  { params }: { params: Promise<{ id: string }> }
9
11
  ) {
12
+ const id = (await params).id;
13
+ const held = heldRefusal(id, callingDevice(request));
14
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
10
15
  const { head } = await request.json().catch(() => ({}));
11
16
  if (
12
17
  head !== undefined &&
@@ -14,7 +19,10 @@ export async function POST(
14
19
  )
15
20
  return NextResponse.json({ error: "Bad head commit" }, { status: 400 });
16
21
  try {
17
- const method = await signOffTask((await params).id, { head });
22
+ const method = await signOffTask(id, {
23
+ head,
24
+ device: callingDevice(request),
25
+ });
18
26
  return NextResponse.json({ success: true, head: head ?? null, method });
19
27
  } catch (error) {
20
28
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,39 @@
1
+ import { NextRequest } from "next/server";
2
+ import { statusDetector } from "@/lib/status-detector";
3
+ import { deliver } from "@/lib/bus";
4
+ import { sendChatConfirmed } from "@/lib/chat/runner";
5
+ import { cleanRemoteText } from "@/lib/hosts/remote-api";
6
+ import { heldRoute } from "@/lib/tasks/held-routes";
7
+
8
+ const MAX_LINE = 40_000;
9
+
10
+ // POST /api/tasks/:id/message {id?, line, from} - the orchestrator that moved
11
+ // this task here speaks to it. It arrives as that orchestrator's message,
12
+ // never as the user's, and a repeat of the same id is not delivered twice.
13
+ export async function POST(
14
+ request: NextRequest,
15
+ ctx: { params: Promise<{ id: string }> }
16
+ ) {
17
+ const body = await request.json().catch(() => ({}));
18
+ return heldRoute(request, ctx, async (task) => {
19
+ const line = typeof body.line === "string" ? body.line.trim() : "";
20
+ if (!line || line.length > MAX_LINE) throw new Error("Bad message");
21
+ const id = typeof body.id === "string" ? body.id.slice(0, 100) : undefined;
22
+ const from = cleanRemoteText(body.from).slice(0, 100) || "the orchestrator";
23
+ const label = `${from} on ${task.held_from ?? "another machine"}`;
24
+ if (task.view === "chat") {
25
+ const state = await sendChatConfirmed(task.id, {
26
+ id,
27
+ text: line,
28
+ from: label,
29
+ origin: { kind: "peer", label },
30
+ compact: "auto",
31
+ });
32
+ return { state };
33
+ }
34
+ await statusDetector.refreshCache();
35
+ const delivery = await deliver(task, line, { name: label });
36
+ if (delivery.state === "failed") throw new Error(delivery.why);
37
+ return { state: delivery.state };
38
+ });
39
+ }
@@ -1,12 +1,24 @@
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";
5
+ import { cleanUpMovedAway } from "@/lib/tasks/moved-away";
6
+ import { runInBackground } from "@/lib/async-operations";
3
7
 
4
8
  // POST /api/tasks/:id/moved - the task now runs on the machine named `to`
5
9
  export async function POST(
6
10
  request: NextRequest,
7
11
  { params }: { params: Promise<{ id: string }> }
8
12
  ) {
13
+ const id = (await params).id;
14
+ const held = heldRefusal(id, callingDevice(request));
15
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
9
16
  const { to } = await request.json().catch(() => ({}));
10
- markMoved((await params).id, String(to || "another machine").slice(0, 100));
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}`);
11
23
  return NextResponse.json({ success: true });
12
24
  }
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { resumeHere } from "@/lib/tasks/move-recover";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { heldRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // POST /api/tasks/:id/resume {force?} - start its agent again here, on its
5
7
  // conversation. A task left moving asks its target first unless forced.
@@ -7,9 +9,12 @@ export async function POST(
7
9
  request: NextRequest,
8
10
  { params }: { params: Promise<{ id: string }> }
9
11
  ) {
12
+ const id = (await params).id;
13
+ const held = heldRefusal(id, callingDevice(request));
14
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
10
15
  const { force } = await request.json().catch(() => ({}));
11
16
  try {
12
- const result = await resumeHere((await params).id, force === true);
17
+ const result = await resumeHere(id, force === true);
13
18
  return NextResponse.json({ success: true, ...result });
14
19
  } catch (error) {
15
20
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,14 @@
1
+ import { NextRequest } from "next/server";
2
+ import { stopSession } from "@/lib/sessions/stop";
3
+ import { heldRoute } from "@/lib/tasks/held-routes";
4
+
5
+ // POST /api/tasks/:id/stop - its orchestrator stops the agent; the
6
+ // conversation and worktree are kept.
7
+ export async function POST(
8
+ request: NextRequest,
9
+ ctx: { params: Promise<{ id: string }> }
10
+ ) {
11
+ return heldRoute(request, ctx, async (task) => ({
12
+ text: await stopSession(task),
13
+ }));
14
+ }
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { callingDevice } from "@/lib/security/device-me";
2
3
  import { importTask } from "@/lib/tasks/import";
3
4
  import { MAX_BUNDLE_BYTES, statusFor } from "@/lib/tasks/move-bundle";
4
5
 
@@ -11,7 +12,16 @@ export async function POST(request: NextRequest) {
11
12
  { status: 413 }
12
13
  );
13
14
  try {
14
- const session = await importTask(await request.json());
15
+ const bundle = await request.json();
16
+ // An orchestrator's task is held for the device that sent it, which
17
+ // only a paired device is.
18
+ const holder = callingDevice(request);
19
+ if (bundle?.orchestrated && !holder)
20
+ return NextResponse.json(
21
+ { error: "An orchestrator's task comes only from a paired machine" },
22
+ { status: 403 }
23
+ );
24
+ const session = await importTask(bundle, { holder });
15
25
  return NextResponse.json({ session }, { status: 201 });
16
26
  } catch (error) {
17
27
  const message = error instanceof Error ? error.message : String(error);
@@ -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
@@ -14,6 +14,9 @@ import { previousNames } from "../session-names";
14
14
  import { deliverToPane, type Delivery } from "./delivery";
15
15
  import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
+ import { hostLink } from "../hosts/remote-api";
18
+ import { relayToHeldTask } from "../tasks/held-remote";
19
+ import { heldOrchestrator, heldTarget } from "./held-reply";
17
20
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
18
21
 
19
22
  export * from "./format";
@@ -52,11 +55,21 @@ function candidates(sessions: Session[]): Candidate[] {
52
55
  }));
53
56
  }
54
57
 
55
- 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[]> {
56
68
  await statusDetector.refreshCache();
57
69
  const sessions = liveSessions();
58
70
  const named = candidates(sessions);
59
- return Promise.all(
71
+ const orchestrator = heldOrchestrator(sessionById(callerId));
72
+ const listed = await Promise.all(
60
73
  sessions.map(async (s, i) => {
61
74
  const status = statusDetector.sessionExists(s.tmux_name)
62
75
  ? await statusDetector.getStatus(s.tmux_name)
@@ -74,6 +87,25 @@ export async function listPeers(): Promise<Peer[]> {
74
87
  };
75
88
  })
76
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);
77
109
  }
78
110
 
79
111
  // A live session by name, old name, project/name, id or id prefix. The note
@@ -142,11 +174,21 @@ function pairTimestamps(a: string | null, b: string): number[] {
142
174
 
143
175
  // Hands the message to the recipient: a chat session's worker, or typed
144
176
  // into a terminal's pane and checked that it went in.
145
- async function deliver(
177
+ export async function deliver(
146
178
  to: Session,
147
179
  line: string,
148
- from: { name: string; peer?: PeerMessage; origin?: ChatOrigin }
180
+ from: {
181
+ name: string;
182
+ peer?: PeerMessage;
183
+ origin?: ChatOrigin;
184
+ // The stored message's id, which a relayed one is keyed by.
185
+ messageId?: number;
186
+ }
149
187
  ): Promise<Delivery> {
188
+ // A task this machine's orchestrator moved to a linked machine: that
189
+ // machine's AgentOS delivers it, labelled as the orchestrator's (held.ts).
190
+ if (to.task_prompt && to.started_by && hostLink(to.host_id))
191
+ return relayToHeldTask(to, line, from.name, from.messageId);
150
192
  // A linked machine's own session: an agent's message would reach it as
151
193
  // the user's (see runner.ts), so none is relayed yet.
152
194
  if (to.peer_mirror && !to.task_prompt)
@@ -196,16 +238,18 @@ export async function sendMessage(opts: {
196
238
  // It's never left unread for `aos inbox` to show in another form; a
197
239
  // failed one is the sender's to retry.
198
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;
199
244
  }): Promise<{ message: BusMessageView; delivery: Delivery; note?: string }> {
200
245
  const body = opts.body.trim();
201
246
  if (!body) throw new Error("Message is empty");
202
- const { session: to, note } = resolveTarget(opts.to);
203
- const from = opts.fromId
204
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(opts.fromId) as
205
- | Session
206
- | undefined)
207
- : undefined;
247
+ const from = sessionById(opts.fromId);
208
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);
209
253
  if (from && from.id === to.id)
210
254
  throw new Error("A session can't message itself");
211
255
  if (from && overRateLimit(pairTimestamps(from.id, to.id))) {
@@ -213,13 +257,32 @@ export async function sendMessage(opts: {
213
257
  `Too many messages between ${from.name} and ${to.name}; wait a few minutes`
214
258
  );
215
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
+ }
216
272
 
217
273
  const fromName = from?.name ?? opts.fromLabel ?? HUMAN;
218
274
  const { lastInsertRowid } = db
219
275
  .prepare(
220
- `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 (?, ?, ?, ?, ?, ?)`
221
277
  )
222
- .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
+ );
223
286
  const id = Number(lastInsertRowid);
224
287
 
225
288
  await statusDetector.refreshCache();
@@ -228,6 +291,7 @@ export async function sendMessage(opts: {
228
291
  opts.line ?? wakeLine({ fromName, fromId: from?.id ?? null, body }),
229
292
  {
230
293
  name: fromName,
294
+ messageId: id,
231
295
  peer: from ? { sessionId: from.id, body } : undefined,
232
296
  origin: from
233
297
  ? { kind: "peer", label: from.name, sessionId: from.id }
@@ -247,6 +311,31 @@ export async function sendMessage(opts: {
247
311
  return { message, delivery, note };
248
312
  }
249
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
+
250
339
  // Unread messages for a session, marked read as they're returned.
251
340
  export function readInbox(sessionId: string): BusMessageView[] {
252
341
  const rows = db