@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.
- package/README.md +23 -3
- package/app/api/bus/peers/route.ts +8 -2
- package/app/api/tasks/[id]/arm/route.ts +5 -0
- package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
- package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
- package/app/api/tasks/[id]/done/route.ts +15 -0
- package/app/api/tasks/[id]/export/route.ts +6 -1
- package/app/api/tasks/[id]/merge/route.ts +9 -1
- package/app/api/tasks/[id]/message/route.ts +39 -0
- package/app/api/tasks/[id]/moved/route.ts +13 -1
- package/app/api/tasks/[id]/resume/route.ts +6 -1
- package/app/api/tasks/[id]/stop/route.ts +14 -0
- package/app/api/tasks/held-replies/route.ts +31 -0
- package/app/api/tasks/import/route.ts +11 -1
- package/lib/bus/held-reply.ts +89 -0
- package/lib/bus/index.ts +101 -12
- package/lib/db/migrations.ts +50 -0
- package/lib/db/types.ts +9 -0
- package/lib/done/bulk.ts +8 -0
- package/lib/done/index.ts +15 -0
- package/lib/done/worktree.ts +24 -10
- package/lib/hosts/remote-api.ts +12 -0
- package/lib/models/plan-step.ts +13 -3
- package/lib/orchestrator/act.ts +41 -1
- package/lib/orchestrator/armed.ts +6 -1
- package/lib/orchestrator/brakes.ts +9 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/facts.ts +72 -21
- package/lib/orchestrator/read.ts +14 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +15 -0
- package/lib/orchestrator/task-state.ts +40 -15
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +11 -0
- package/lib/orchestrator/tools.ts +1 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sessions/stop.ts +15 -1
- package/lib/status-detector.ts +4 -0
- package/lib/tasks/branch-worktree.ts +16 -1
- package/lib/tasks/finish.ts +5 -1
- package/lib/tasks/held-remote.ts +109 -0
- package/lib/tasks/held-replies.ts +106 -0
- package/lib/tasks/held-routes.ts +33 -0
- package/lib/tasks/held.ts +28 -0
- package/lib/tasks/import.ts +65 -10
- package/lib/tasks/index.ts +11 -1
- package/lib/tasks/last-active.ts +25 -0
- package/lib/tasks/move-bundle.ts +72 -3
- package/lib/tasks/move-carry.ts +102 -0
- package/lib/tasks/move-flow.ts +55 -9
- package/lib/tasks/move-guard.ts +17 -13
- package/lib/tasks/move-recover.ts +2 -0
- package/lib/tasks/move.ts +49 -3
- package/lib/tasks/moved-away.ts +37 -0
- package/lib/tasks/remote.ts +6 -0
- package/lib/tasks/session.ts +1 -1
- 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`), `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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(
|
|
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
|