@saadnvd1/agent-os 0.3.516 → 0.3.518
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 +47 -3
- package/app/api/devices/route.ts +2 -0
- package/app/api/push/test/route.test.ts +112 -0
- package/app/api/push/test/route.ts +38 -0
- package/app/api/tasks/[id]/arm/route.ts +5 -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 +6 -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/import/route.ts +11 -1
- package/components/Devices/DeviceRow.tsx +52 -3
- package/components/Devices/DevicesPanel.tsx +13 -0
- package/data/devices/queries.ts +30 -2
- package/lib/away/run.ts +15 -3
- package/lib/bus/index.ts +15 -2
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/connect/control-api.ts +38 -0
- package/lib/db/migrations.ts +56 -0
- package/lib/db/types.ts +7 -0
- package/lib/done/bulk.ts +8 -0
- package/lib/done/index.ts +20 -1
- package/lib/load/alert.ts +5 -1
- package/lib/models/plan-step.ts +13 -3
- package/lib/notify/index.ts +46 -7
- package/lib/notify/limiter.ts +6 -1
- package/lib/notify/push/encode.ts +42 -0
- package/lib/notify/push/events.ts +72 -0
- package/lib/notify/push/index.ts +183 -0
- package/lib/notify/push/outbox.ts +238 -0
- package/lib/notify/push/policy.ts +33 -0
- package/lib/notify/push/presence.ts +45 -0
- package/lib/notify/push/relay.ts +50 -0
- package/lib/notify/push/sender.ts +271 -0
- package/lib/notify/push/testing.ts +30 -0
- package/lib/notify/push/waiting.ts +29 -0
- package/lib/notify/push-types.ts +2 -0
- package/lib/notify/redact.ts +13 -2
- package/lib/orchestrator/act.ts +41 -1
- package/lib/orchestrator/armed.ts +6 -1
- package/lib/orchestrator/asks.ts +5 -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/review.ts +3 -0
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +15 -0
- 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/program-status/tap.ts +11 -1
- package/lib/schedules/start.ts +11 -2
- package/lib/security/devices.ts +57 -4
- package/lib/sessions/stop.ts +15 -1
- package/lib/tasks/branch-worktree.ts +16 -1
- package/lib/tasks/finish.ts +7 -1
- package/lib/tasks/held-remote.ts +80 -0
- package/lib/tasks/held-routes.ts +33 -0
- package/lib/tasks/held.ts +28 -0
- package/lib/tasks/import.ts +60 -7
- package/lib/tasks/index.ts +11 -1
- package/lib/tasks/last-active.ts +25 -0
- package/lib/tasks/move-bundle.ts +53 -1
- package/lib/tasks/move-carry.ts +102 -0
- package/lib/tasks/move-flow.ts +34 -7
- package/lib/tasks/move-guard.ts +17 -13
- package/lib/tasks/move-recover.ts +2 -0
- package/lib/tasks/move.ts +34 -3
- package/lib/tasks/remote.ts +3 -0
- package/lib/tasks/session.ts +27 -3
- package/package.json +1 -1
- package/server.ts +2 -0
package/README.md
CHANGED
|
@@ -914,6 +914,32 @@ line at the bottom of Schedules), one of:
|
|
|
914
914
|
- **Telegram**: a bot token and your chat id. The token is kept in the
|
|
915
915
|
database and never shown again, returned by the API or logged.
|
|
916
916
|
|
|
917
|
+
#### Push to the phone app
|
|
918
|
+
|
|
919
|
+
A machine linked to Connect sends to the paired phones that have registered
|
|
920
|
+
for push, instead of Telegram. A push is generic: the title "AgentOS", a
|
|
921
|
+
category word and a link to open, no session or task text (the payload is a
|
|
922
|
+
versioned envelope, so an encrypted body can replace it later). It goes
|
|
923
|
+
through the Connect relay, signed with the machine key; AgentOS keeps a
|
|
924
|
+
`push_outbox` that retries with backoff, forgets tokens the relay reports as
|
|
925
|
+
dead, and caps alerting pushes at 10 an hour per device (one summary after).
|
|
926
|
+
|
|
927
|
+
- **What pushes**: an ask raised, a session waiting on you for a minute,
|
|
928
|
+
a task ready for review, CI or a review failing, a plan ready to approve,
|
|
929
|
+
and the Away digest. Merged is off by default. Each device picks its
|
|
930
|
+
categories (`PATCH /api/devices/me/push`). A task mirrored from another
|
|
931
|
+
machine never pushes from here.
|
|
932
|
+
- **Quiet rules**: waiting, review, CI and merged don't push while you're at
|
|
933
|
+
this Mac (keyboard or mouse used in the last 3 minutes). While Away is on,
|
|
934
|
+
everything except asks folds into the 10-minute digest.
|
|
935
|
+
- **Fallback**: agent messages, the Away digest and machine alerts (load,
|
|
936
|
+
schedules) go to Telegram when push isn't configured or fails. The newer
|
|
937
|
+
events are push-only. If Connect says push isn't set up yet, AgentOS
|
|
938
|
+
stops asking for 10 minutes and uses Telegram meanwhile.
|
|
939
|
+
- **Check it**: `aos push test` sends a real push to each registered phone
|
|
940
|
+
and prints what came back; Settings → Devices shows each device's push
|
|
941
|
+
state, categories, last error and a Send test button.
|
|
942
|
+
|
|
917
943
|
## Agent network
|
|
918
944
|
|
|
919
945
|
Sessions started by AgentOS can find and talk to each other without you,
|
|
@@ -940,6 +966,7 @@ aos schedules # every schedule, its next run and last outcom
|
|
|
940
966
|
aos schedule run <name> # run a schedule now
|
|
941
967
|
aos schedule add --session <s> --every 30m "prompt" # check in on a session
|
|
942
968
|
aos notify "text" # push a message to your phone
|
|
969
|
+
aos push test # a real test push to the paired phones, and the result
|
|
943
970
|
aos heavy -- npm test # run it now; says first what else heavy is running
|
|
944
971
|
aos stop <session> # stop its turn now; the next message runs as a new turn
|
|
945
972
|
aos done <session> # finished: merge through the gates, archive
|
|
@@ -1174,7 +1201,7 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
1174
1201
|
- **Tools:** `sessions` (every session's status, activity, task, PR, CI and
|
|
1175
1202
|
stack position), `read` (the end of a terminal or chat) and `cards` (the
|
|
1176
1203
|
linked boards' cards), plus acting tools: `send`, `start_task`,
|
|
1177
|
-
`start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`,
|
|
1204
|
+
`start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`, `move`,
|
|
1178
1205
|
`add_project` (a git repository on the machine becomes a project of the
|
|
1179
1206
|
workspace, or the project already at that path moves in from no workspace;
|
|
1180
1207
|
folders that hold secrets and projects of another workspace are refused),
|
|
@@ -1195,7 +1222,8 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
1195
1222
|
naming the tool to use instead: merging, closing or retargeting a PR
|
|
1196
1223
|
(`gh pr merge`, `gh pr close`, `gh pr edit --base`, and `gh api` or `curl`
|
|
1197
1224
|
calls that merge a pull request or PATCH or PUT one; use `sign_off`, `arm`,
|
|
1198
|
-
`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
|
|
1199
1227
|
repository or task worktree other than its own open quick changes (git
|
|
1200
1228
|
commit, reset, checkout, rebase, merge, stash, clean or rm there, and `rm`,
|
|
1201
1229
|
`mv`, `cp`, `tee` or a redirect into one), writing AgentOS's state (`sqlite3`
|
|
@@ -1547,6 +1575,22 @@ Once linked:
|
|
|
1547
1575
|
press Move again to finish it, or **Resume here**, which first asks the
|
|
1548
1576
|
other machine whether it arrived. Tasks waiting on you (an escalated gate or
|
|
1549
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. Guards (write guard, shell deny list, attribution settings) run in
|
|
1591
|
+
the task's worker on either machine, and the task can't merge or push to
|
|
1592
|
+
the default branch. Moving counts toward the brakes and Pause refuses the
|
|
1593
|
+
tool.
|
|
1550
1594
|
- **A chat task moves the same way**, between turns: a running turn is let
|
|
1551
1595
|
finish (up to 3 minutes, else the move is refused and it carries on here),
|
|
1552
1596
|
and one waiting on your answer isn't moved. What's sent to it while its
|
|
@@ -1559,7 +1603,7 @@ Once linked:
|
|
|
1559
1603
|
Works for any project. Requests to the other machine carry its device token,
|
|
1560
1604
|
so it can sit behind a proxy that never trusts loopback. Linking needs this
|
|
1561
1605
|
machine or the tailnet, and only pairs with the ssh target's own host name.
|
|
1562
|
-
The orchestrator
|
|
1606
|
+
The orchestrator starts tasks on its own machine and moves them afterwards.
|
|
1563
1607
|
|
|
1564
1608
|
## Security
|
|
1565
1609
|
|
package/app/api/devices/route.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { listDevices } from "@/lib/security/devices";
|
|
3
3
|
import { DEVICE_HEADER, TRUST_HEADER } from "@/lib/security/auth";
|
|
4
4
|
import { demoMode } from "@/lib/security/demo";
|
|
5
|
+
import { pushStatus } from "@/lib/notify/push";
|
|
5
6
|
|
|
6
7
|
// GET /api/devices - paired devices, and how this request was trusted
|
|
7
8
|
export async function GET(request: NextRequest) {
|
|
@@ -10,6 +11,7 @@ export async function GET(request: NextRequest) {
|
|
|
10
11
|
devices: demoMode()
|
|
11
12
|
? listDevices().map(({ last_address: _a, user_agent: _u, ...d }) => d)
|
|
12
13
|
: listDevices(),
|
|
14
|
+
push: pushStatus(),
|
|
13
15
|
current: {
|
|
14
16
|
deviceId: request.headers.get(DEVICE_HEADER),
|
|
15
17
|
via: request.headers.get(TRUST_HEADER),
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const relaySend = vi.fn();
|
|
5
|
+
vi.mock("@/lib/notify/push/relay", () => ({
|
|
6
|
+
linked: () => true,
|
|
7
|
+
NOT_LINKED: "this machine isn't linked to Connect",
|
|
8
|
+
relaySend: (...a: unknown[]) => relaySend(...a),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const { POST } = await import("./route");
|
|
12
|
+
const { GET } = await import("../../devices/route");
|
|
13
|
+
const { pairPhone, resetPush } = await import("@/lib/notify/push/testing");
|
|
14
|
+
const { db } = await import("@/lib/db");
|
|
15
|
+
|
|
16
|
+
const post = (body?: unknown) =>
|
|
17
|
+
POST(
|
|
18
|
+
new NextRequest("http://x/api/push/test", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
resetPush();
|
|
26
|
+
relaySend.mockReset();
|
|
27
|
+
relaySend.mockImplementation(async (b: { devices: unknown[] }) => ({
|
|
28
|
+
kind: "results",
|
|
29
|
+
results: b.devices.map(() => ({ status: "sent" })),
|
|
30
|
+
}));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("POST /api/push/test", () => {
|
|
34
|
+
it("409s with no phone registered", async () => {
|
|
35
|
+
expect((await post({})).status).toBe(409);
|
|
36
|
+
expect(relaySend).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("404s for a device with no push", async () => {
|
|
40
|
+
pairPhone();
|
|
41
|
+
expect((await post({ deviceId: "nope" })).status).toBe(404);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("sends to the named device only and says what happened", async () => {
|
|
45
|
+
const a = pairPhone();
|
|
46
|
+
pairPhone();
|
|
47
|
+
const res = await post({ deviceId: a });
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
expect(await res.json()).toEqual({
|
|
50
|
+
results: [{ deviceId: a, status: "sent", error: null }],
|
|
51
|
+
unavailable: null,
|
|
52
|
+
});
|
|
53
|
+
expect(relaySend).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("explains when push isn't set up", async () => {
|
|
57
|
+
pairPhone();
|
|
58
|
+
relaySend.mockResolvedValue({
|
|
59
|
+
kind: "unavailable",
|
|
60
|
+
reason: "push isn't set up on the Connect service yet",
|
|
61
|
+
});
|
|
62
|
+
const res = await post({});
|
|
63
|
+
const body = await res.json();
|
|
64
|
+
expect(body.results[0].status).toBe("failed");
|
|
65
|
+
expect(body.unavailable).toMatch(/isn't set up/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("GET /api/devices push status", () => {
|
|
70
|
+
it("lists each device's push state and categories, never the token", async () => {
|
|
71
|
+
const id = pairPhone(["asks", "review"]);
|
|
72
|
+
const res = await GET(new NextRequest("http://x/api/devices"));
|
|
73
|
+
const body = await res.json();
|
|
74
|
+
const device = body.devices.find((d: { id: string }) => d.id === id);
|
|
75
|
+
expect(device.push).toEqual({
|
|
76
|
+
enabled: true,
|
|
77
|
+
categories: ["asks", "review"],
|
|
78
|
+
lastError: null,
|
|
79
|
+
updatedAt: expect.any(String),
|
|
80
|
+
});
|
|
81
|
+
expect(body.push).toEqual({ linked: true, unavailable: null });
|
|
82
|
+
const token = (
|
|
83
|
+
db.prepare(`SELECT push_token FROM devices WHERE id = ?`).get(id) as {
|
|
84
|
+
push_token: string;
|
|
85
|
+
}
|
|
86
|
+
).push_token;
|
|
87
|
+
expect(JSON.stringify(body)).not.toContain(token);
|
|
88
|
+
expect(Object.keys(device)).not.toContain("push_token");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("shows a device with no push as off, and a pruned one with its error", async () => {
|
|
92
|
+
const { mintDevice, pruneDevicePush } =
|
|
93
|
+
await import("@/lib/security/devices");
|
|
94
|
+
const off = mintDevice("laptop").device.id;
|
|
95
|
+
const dead = pairPhone();
|
|
96
|
+
pruneDevicePush(dead, "token no longer valid (Unregistered)");
|
|
97
|
+
const body = await (
|
|
98
|
+
await GET(new NextRequest("http://x/api/devices"))
|
|
99
|
+
).json();
|
|
100
|
+
const by = (id: string) =>
|
|
101
|
+
body.devices.find((d: { id: string }) => d.id === id).push;
|
|
102
|
+
expect(by(off)).toMatchObject({
|
|
103
|
+
enabled: false,
|
|
104
|
+
categories: [],
|
|
105
|
+
lastError: null,
|
|
106
|
+
});
|
|
107
|
+
expect(by(dead)).toMatchObject({
|
|
108
|
+
enabled: false,
|
|
109
|
+
lastError: expect.stringMatching(/Unregistered/),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { sendTestPush } from "@/lib/notify/push";
|
|
3
|
+
import { listPushDevices } from "@/lib/security/devices";
|
|
4
|
+
import { readCapped } from "@/lib/security/read-capped";
|
|
5
|
+
|
|
6
|
+
// POST /api/push/test { deviceId? } - a real push to the registered phones
|
|
7
|
+
// now, ignoring the quiet rules, and what the relay said about each.
|
|
8
|
+
export async function POST(request: NextRequest) {
|
|
9
|
+
let deviceId: string | undefined;
|
|
10
|
+
try {
|
|
11
|
+
const text = await readCapped(request, 1024);
|
|
12
|
+
if (text === null)
|
|
13
|
+
return NextResponse.json({ error: "Too large" }, { status: 413 });
|
|
14
|
+
const v = JSON.parse(text || "{}");
|
|
15
|
+
if (typeof v?.deviceId === "string") deviceId = v.deviceId;
|
|
16
|
+
} catch {
|
|
17
|
+
return NextResponse.json({ error: "Bad JSON" }, { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
const devices = listPushDevices().filter(
|
|
20
|
+
(d) => !deviceId || d.id === deviceId
|
|
21
|
+
);
|
|
22
|
+
if (!devices.length)
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{
|
|
25
|
+
error: deviceId
|
|
26
|
+
? "That device has no push registered"
|
|
27
|
+
: "No phone has push registered yet",
|
|
28
|
+
},
|
|
29
|
+
{ status: deviceId ? 404 : 409 }
|
|
30
|
+
);
|
|
31
|
+
const { results, unavailable } = await sendTestPush(deviceId);
|
|
32
|
+
if (unavailable && !results.length)
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: `Push isn't available: ${unavailable}` },
|
|
35
|
+
{ status: 409 }
|
|
36
|
+
);
|
|
37
|
+
return NextResponse.json({ results, unavailable });
|
|
38
|
+
}
|
|
@@ -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,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,17 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { markMoved } from "@/lib/tasks/move";
|
|
3
|
+
import { callingDevice } from "@/lib/security/device-me";
|
|
4
|
+
import { heldRefusal } from "@/lib/tasks/held";
|
|
3
5
|
|
|
4
6
|
// POST /api/tasks/:id/moved - the task now runs on the machine named `to`
|
|
5
7
|
export async function POST(
|
|
6
8
|
request: NextRequest,
|
|
7
9
|
{ params }: { params: Promise<{ id: string }> }
|
|
8
10
|
) {
|
|
11
|
+
const id = (await params).id;
|
|
12
|
+
const held = heldRefusal(id, callingDevice(request));
|
|
13
|
+
if (held) return NextResponse.json({ error: held }, { status: 403 });
|
|
9
14
|
const { to } = await request.json().catch(() => ({}));
|
|
10
|
-
markMoved(
|
|
15
|
+
markMoved(id, String(to || "another machine").slice(0, 100));
|
|
11
16
|
return NextResponse.json({ success: true });
|
|
12
17
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { Laptop, Smartphone, Tablet, Trash2 } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
|
-
import type {
|
|
6
|
-
import {
|
|
5
|
+
import type { ListedDevice } from "@/lib/security/devices";
|
|
6
|
+
import { CATEGORIES } from "@/lib/notify/copy";
|
|
7
|
+
import {
|
|
8
|
+
useRevokeDevice,
|
|
9
|
+
useSendTestPush,
|
|
10
|
+
useSetCanApprove,
|
|
11
|
+
} from "@/data/devices";
|
|
7
12
|
import { Switch } from "@/components/ui/switch";
|
|
8
13
|
|
|
9
14
|
// SQLite's datetime('now') is UTC without a zone.
|
|
@@ -31,13 +36,22 @@ export function DeviceRow({
|
|
|
31
36
|
isCurrent,
|
|
32
37
|
canManage,
|
|
33
38
|
}: {
|
|
34
|
-
device:
|
|
39
|
+
device: ListedDevice;
|
|
35
40
|
isCurrent: boolean;
|
|
36
41
|
// Only this machine or the tailnet may let a device approve asks.
|
|
37
42
|
canManage: boolean;
|
|
38
43
|
}) {
|
|
39
44
|
const revoke = useRevokeDevice();
|
|
40
45
|
const setCanApprove = useSetCanApprove();
|
|
46
|
+
const sendTest = useSendTestPush();
|
|
47
|
+
const tested = sendTest.data?.results[0];
|
|
48
|
+
const testNote = sendTest.isError
|
|
49
|
+
? sendTest.error.message
|
|
50
|
+
: tested
|
|
51
|
+
? tested.status === "sent"
|
|
52
|
+
? "Test sent"
|
|
53
|
+
: `Test failed: ${tested.error}`
|
|
54
|
+
: null;
|
|
41
55
|
|
|
42
56
|
const onRevoke = () => {
|
|
43
57
|
if (
|
|
@@ -64,7 +78,42 @@ export function DeviceRow({
|
|
|
64
78
|
{lastSeen(device.last_seen_at)}
|
|
65
79
|
{device.last_address ? ` · ${device.last_address}` : ""}
|
|
66
80
|
</p>
|
|
81
|
+
{device.push.enabled && (
|
|
82
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
83
|
+
Push on ·{" "}
|
|
84
|
+
{device.push.categories
|
|
85
|
+
.map((c) => CATEGORIES[c].label)
|
|
86
|
+
.join(", ") || "nothing selected"}
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
{device.push.lastError && (
|
|
90
|
+
<p
|
|
91
|
+
className="text-destructive truncate text-xs"
|
|
92
|
+
title={device.push.lastError}
|
|
93
|
+
>
|
|
94
|
+
Push error: {device.push.lastError}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
{testNote && (
|
|
98
|
+
<p
|
|
99
|
+
className={`truncate text-xs ${tested?.status === "sent" ? "text-muted-foreground" : "text-destructive"}`}
|
|
100
|
+
role="status"
|
|
101
|
+
>
|
|
102
|
+
{testNote}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
67
105
|
</div>
|
|
106
|
+
{device.push.enabled && (
|
|
107
|
+
<Button
|
|
108
|
+
variant="outline"
|
|
109
|
+
size="sm"
|
|
110
|
+
className="h-11 shrink-0 sm:h-8"
|
|
111
|
+
onClick={() => sendTest.mutate(device.id)}
|
|
112
|
+
disabled={sendTest.isPending}
|
|
113
|
+
>
|
|
114
|
+
{sendTest.isPending ? "Sending…" : "Send test"}
|
|
115
|
+
</Button>
|
|
116
|
+
)}
|
|
68
117
|
<label className="text-muted-foreground flex min-h-11 shrink-0 items-center gap-2 text-xs sm:min-h-8">
|
|
69
118
|
Can approve
|
|
70
119
|
<Switch
|
|
@@ -43,6 +43,19 @@ export function DevicesPanel() {
|
|
|
43
43
|
)
|
|
44
44
|
)}
|
|
45
45
|
|
|
46
|
+
{data && !data.push.linked && (
|
|
47
|
+
<p className="text-muted-foreground text-sm">
|
|
48
|
+
Phone push needs this machine linked to Connect. Until then,
|
|
49
|
+
notifications go to Telegram if it's set up.
|
|
50
|
+
</p>
|
|
51
|
+
)}
|
|
52
|
+
{data?.push.unavailable && (
|
|
53
|
+
<p className="text-destructive text-sm">
|
|
54
|
+
Push is paused: {data.push.unavailable}. Notifications go to Telegram
|
|
55
|
+
meanwhile.
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
|
|
46
59
|
<div className="space-y-2">
|
|
47
60
|
{isPending && (
|
|
48
61
|
<div className="bg-muted/40 h-14 animate-pulse rounded-lg" />
|
package/data/devices/queries.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ListedDevice } from "@/lib/security/devices";
|
|
3
3
|
import type { NetworkState } from "@/lib/security/network-state";
|
|
4
4
|
import type { PairingOffer } from "@/lib/security/pair-qr";
|
|
5
5
|
import { deviceKeys } from "./keys";
|
|
@@ -12,7 +12,9 @@ async function json<T>(res: Response): Promise<T> {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface DevicesResponse {
|
|
15
|
-
devices:
|
|
15
|
+
devices: ListedDevice[];
|
|
16
|
+
// Whether this machine can reach the push relay at all.
|
|
17
|
+
push: { linked: boolean; unavailable: string | null };
|
|
16
18
|
current: { deviceId: string | null; via: string | null };
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -128,3 +130,29 @@ export function useSetCanApprove() {
|
|
|
128
130
|
queryClient.invalidateQueries({ queryKey: deviceKeys.list() }),
|
|
129
131
|
});
|
|
130
132
|
}
|
|
133
|
+
|
|
134
|
+
export interface PushTestResponse {
|
|
135
|
+
results: {
|
|
136
|
+
deviceId: string;
|
|
137
|
+
status: "sent" | "gone" | "failed";
|
|
138
|
+
error: string | null;
|
|
139
|
+
}[];
|
|
140
|
+
unavailable: string | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function useSendTestPush() {
|
|
144
|
+
const queryClient = useQueryClient();
|
|
145
|
+
return useMutation({
|
|
146
|
+
mutationFn: async (deviceId: string) =>
|
|
147
|
+
json<PushTestResponse>(
|
|
148
|
+
await fetch("/api/push/test", {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ deviceId }),
|
|
152
|
+
})
|
|
153
|
+
),
|
|
154
|
+
// A dead token is forgotten, and the last error changes.
|
|
155
|
+
onSettled: () =>
|
|
156
|
+
queryClient.invalidateQueries({ queryKey: deviceKeys.list() }),
|
|
157
|
+
});
|
|
158
|
+
}
|