@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.
Files changed (77) hide show
  1. package/README.md +47 -3
  2. package/app/api/devices/route.ts +2 -0
  3. package/app/api/push/test/route.test.ts +112 -0
  4. package/app/api/push/test/route.ts +38 -0
  5. package/app/api/tasks/[id]/arm/route.ts +5 -0
  6. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  7. package/app/api/tasks/[id]/done/route.ts +15 -0
  8. package/app/api/tasks/[id]/export/route.ts +6 -1
  9. package/app/api/tasks/[id]/merge/route.ts +9 -1
  10. package/app/api/tasks/[id]/message/route.ts +39 -0
  11. package/app/api/tasks/[id]/moved/route.ts +6 -1
  12. package/app/api/tasks/[id]/resume/route.ts +6 -1
  13. package/app/api/tasks/[id]/stop/route.ts +14 -0
  14. package/app/api/tasks/import/route.ts +11 -1
  15. package/components/Devices/DeviceRow.tsx +52 -3
  16. package/components/Devices/DevicesPanel.tsx +13 -0
  17. package/data/devices/queries.ts +30 -2
  18. package/lib/away/run.ts +15 -3
  19. package/lib/bus/index.ts +15 -2
  20. package/lib/chat/registry.ts +2 -0
  21. package/lib/chat/runner.ts +3 -0
  22. package/lib/connect/control-api.ts +38 -0
  23. package/lib/db/migrations.ts +56 -0
  24. package/lib/db/types.ts +7 -0
  25. package/lib/done/bulk.ts +8 -0
  26. package/lib/done/index.ts +20 -1
  27. package/lib/load/alert.ts +5 -1
  28. package/lib/models/plan-step.ts +13 -3
  29. package/lib/notify/index.ts +46 -7
  30. package/lib/notify/limiter.ts +6 -1
  31. package/lib/notify/push/encode.ts +42 -0
  32. package/lib/notify/push/events.ts +72 -0
  33. package/lib/notify/push/index.ts +183 -0
  34. package/lib/notify/push/outbox.ts +238 -0
  35. package/lib/notify/push/policy.ts +33 -0
  36. package/lib/notify/push/presence.ts +45 -0
  37. package/lib/notify/push/relay.ts +50 -0
  38. package/lib/notify/push/sender.ts +271 -0
  39. package/lib/notify/push/testing.ts +30 -0
  40. package/lib/notify/push/waiting.ts +29 -0
  41. package/lib/notify/push-types.ts +2 -0
  42. package/lib/notify/redact.ts +13 -2
  43. package/lib/orchestrator/act.ts +41 -1
  44. package/lib/orchestrator/armed.ts +6 -1
  45. package/lib/orchestrator/asks.ts +5 -1
  46. package/lib/orchestrator/brakes.ts +9 -0
  47. package/lib/orchestrator/brief.ts +2 -1
  48. package/lib/orchestrator/facts.ts +72 -21
  49. package/lib/orchestrator/read.ts +14 -2
  50. package/lib/orchestrator/review.ts +3 -0
  51. package/lib/orchestrator/serve.ts +6 -0
  52. package/lib/orchestrator/shell-guard.ts +15 -0
  53. package/lib/orchestrator/tool-names.ts +1 -0
  54. package/lib/orchestrator/tool-schemas.ts +11 -0
  55. package/lib/orchestrator/tools.ts +1 -0
  56. package/lib/program-status/tap.ts +11 -1
  57. package/lib/schedules/start.ts +11 -2
  58. package/lib/security/devices.ts +57 -4
  59. package/lib/sessions/stop.ts +15 -1
  60. package/lib/tasks/branch-worktree.ts +16 -1
  61. package/lib/tasks/finish.ts +7 -1
  62. package/lib/tasks/held-remote.ts +80 -0
  63. package/lib/tasks/held-routes.ts +33 -0
  64. package/lib/tasks/held.ts +28 -0
  65. package/lib/tasks/import.ts +60 -7
  66. package/lib/tasks/index.ts +11 -1
  67. package/lib/tasks/last-active.ts +25 -0
  68. package/lib/tasks/move-bundle.ts +53 -1
  69. package/lib/tasks/move-carry.ts +102 -0
  70. package/lib/tasks/move-flow.ts +34 -7
  71. package/lib/tasks/move-guard.ts +17 -13
  72. package/lib/tasks/move-recover.ts +2 -0
  73. package/lib/tasks/move.ts +34 -3
  74. package/lib/tasks/remote.ts +3 -0
  75. package/lib/tasks/session.ts +27 -3
  76. package/package.json +1 -1
  77. 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`), `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
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 still starts tasks on its own machine only.
1606
+ The orchestrator starts tasks on its own machine and moves them afterwards.
1563
1607
 
1564
1608
  ## Security
1565
1609
 
@@ -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
- (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,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((await params).id, String(to || "another machine").slice(0, 100));
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((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
+ }
@@ -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);
@@ -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 { Device } from "@/lib/security/devices";
6
- import { useRevokeDevice, useSetCanApprove } from "@/data/devices";
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: 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&apos;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" />
@@ -1,5 +1,5 @@
1
1
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import type { Device } from "@/lib/security/devices";
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: Device[];
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
+ }