@saadnvd1/agent-os 0.3.516 → 0.3.517
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 +27 -0
- 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/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/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 +31 -0
- package/lib/done/index.ts +5 -1
- package/lib/load/alert.ts +5 -1
- 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/asks.ts +5 -1
- package/lib/orchestrator/review.ts +3 -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/tasks/finish.ts +2 -0
- package/lib/tasks/session.ts +26 -2
- 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
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/away/run.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { review } from "../orchestrator/review";
|
|
|
19
19
|
import { repoOf } from "../orchestrator/repo";
|
|
20
20
|
import { clip } from "../orchestrator/digest";
|
|
21
21
|
import { notifyPhone } from "../notify";
|
|
22
|
+
import { takeFoldedPushes } from "../notify/push";
|
|
22
23
|
import { getProject } from "../projects";
|
|
23
24
|
import { loadProjectConfig } from "../project-config";
|
|
24
25
|
import { run } from "../tasks/gh";
|
|
@@ -194,7 +195,11 @@ export function turnAwayOff(
|
|
|
194
195
|
// no report), then the summary. A second later, so the window takes
|
|
195
196
|
// everything up to now.
|
|
196
197
|
notifyBatch(ended, now + 1000);
|
|
197
|
-
notifyPhone(PHONE_SOURCE, phoneSummary(report)
|
|
198
|
+
notifyPhone(PHONE_SOURCE, phoneSummary(report), {
|
|
199
|
+
category: "digest",
|
|
200
|
+
key: `digest:end:${ended.id}`,
|
|
201
|
+
collapse: "digest",
|
|
202
|
+
});
|
|
198
203
|
return { ...ended, report: JSON.stringify(report) };
|
|
199
204
|
}
|
|
200
205
|
|
|
@@ -516,7 +521,9 @@ export function notifyBatch(away: AwayRow, now = Date.now()): string | null {
|
|
|
516
521
|
}[];
|
|
517
522
|
const deploys = held.filter((h) => h.kind === "deploy_failed");
|
|
518
523
|
const notes = held.filter((h) => h.kind === "notify");
|
|
519
|
-
|
|
524
|
+
// What push held for this digest (ready for review, CI, agent messages).
|
|
525
|
+
const pushed = takeFoldedPushes(now);
|
|
526
|
+
if (!asks.length && !held.length && !pushed) return null;
|
|
520
527
|
const lines = [
|
|
521
528
|
...deploys.map(
|
|
522
529
|
(d) => `- ${d.workspace}: deploy failed: ${clip(d.detail ?? "", 120)}`
|
|
@@ -534,6 +541,7 @@ export function notifyBatch(away: AwayRow, now = Date.now()): string | null {
|
|
|
534
541
|
"note from an orchestrator",
|
|
535
542
|
"notes from orchestrators"
|
|
536
543
|
),
|
|
544
|
+
...count(pushed, "other update", "other updates"),
|
|
537
545
|
];
|
|
538
546
|
const head = `While you're away: ${parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0]}`;
|
|
539
547
|
const shown = lines.slice(0, PHONE_LINES);
|
|
@@ -541,7 +549,11 @@ export function notifyBatch(away: AwayRow, now = Date.now()): string | null {
|
|
|
541
549
|
const text = [head, ...shown, ...(more ? [`- and ${more} more`] : [])].join(
|
|
542
550
|
"\n"
|
|
543
551
|
);
|
|
544
|
-
notifyPhone(PHONE_SOURCE, text
|
|
552
|
+
notifyPhone(PHONE_SOURCE, text, {
|
|
553
|
+
category: "digest",
|
|
554
|
+
key: `digest:${away.id}:${now}`,
|
|
555
|
+
collapse: "digest",
|
|
556
|
+
});
|
|
545
557
|
return text;
|
|
546
558
|
}
|
|
547
559
|
|
package/lib/chat/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { noteWaiting } from "../notify/push/waiting";
|
|
1
2
|
import { db, type Session } from "../db";
|
|
2
3
|
import type { WorkerClient } from "./worker/client";
|
|
3
4
|
import { notifyStatusChanged } from "../status/hub";
|
|
@@ -90,6 +91,7 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
|
|
|
90
91
|
// Items come from the worker, which writes them a moment after saying so.
|
|
91
92
|
if (m.type === "state" || m.type === "compacting" || m.type === "item")
|
|
92
93
|
markStatusDirty(sessionId, EMIT_SETTLE_MS);
|
|
94
|
+
if (m.type === "state") noteWaiting(sessionId, m.state === "waiting");
|
|
93
95
|
registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
|
|
94
96
|
// Idle, running, waiting and back, and a finished turn (unread): the
|
|
95
97
|
// sidebar hears at once.
|
package/lib/chat/runner.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* events to whoever is watching. Restarting the server leaves turns running.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { pushPlanReady } from "../notify/push/events";
|
|
8
9
|
import { handoffAfterPlan } from "../models/plan-step";
|
|
9
10
|
import { randomUUID } from "crypto";
|
|
10
11
|
import os from "os";
|
|
@@ -152,6 +153,8 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
|
152
153
|
if (e.type === "item" && e.item.kind === "task")
|
|
153
154
|
retireIfStale(sessionId, live);
|
|
154
155
|
if (e.type === "item") {
|
|
156
|
+
if (e.item.kind === "plan" && !e.item.carried)
|
|
157
|
+
pushPlanReady(sessionId, e.item.id);
|
|
155
158
|
if ("streaming" in e.item && e.item.streaming)
|
|
156
159
|
live.streaming.set(e.item.id, e.item);
|
|
157
160
|
else live.streaming.delete(e.item.id);
|
|
@@ -226,3 +226,41 @@ export async function heartbeat(
|
|
|
226
226
|
);
|
|
227
227
|
return (await res.json()) as { enabled: boolean };
|
|
228
228
|
}
|
|
229
|
+
|
|
230
|
+
export interface PushRequestBody {
|
|
231
|
+
v: 1;
|
|
232
|
+
devices: { token: string; env: "production" | "sandbox" }[];
|
|
233
|
+
push_type: "alert" | "background";
|
|
234
|
+
priority: 10 | 5;
|
|
235
|
+
level: "passive" | "active" | "time-sensitive";
|
|
236
|
+
collapse_id?: string;
|
|
237
|
+
thread_id?: string;
|
|
238
|
+
payload: Record<string, unknown>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface PushResult {
|
|
242
|
+
status: "sent" | "gone" | "retry" | "rejected";
|
|
243
|
+
apns_status?: number;
|
|
244
|
+
reason?: string;
|
|
245
|
+
prune?: boolean;
|
|
246
|
+
retry_after?: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One APNs send per device, answered in device order. Signed. */
|
|
250
|
+
export async function pushRelay(
|
|
251
|
+
config: ConnectConfig,
|
|
252
|
+
privateKey: string,
|
|
253
|
+
body: PushRequestBody
|
|
254
|
+
): Promise<PushResult[]> {
|
|
255
|
+
const res = await signed(config, privateKey)(
|
|
256
|
+
"POST",
|
|
257
|
+
`/api/v1/machines/${config.machineId}/push`,
|
|
258
|
+
body
|
|
259
|
+
);
|
|
260
|
+
const r = (await res.json().catch(() => null)) as {
|
|
261
|
+
results?: PushResult[];
|
|
262
|
+
} | null;
|
|
263
|
+
if (!Array.isArray(r?.results))
|
|
264
|
+
throw new Error("the Connect service sent no push results");
|
|
265
|
+
return r.results;
|
|
266
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1658,6 +1658,37 @@ const migrations: Migration[] = [
|
|
|
1658
1658
|
);
|
|
1659
1659
|
},
|
|
1660
1660
|
},
|
|
1661
|
+
{
|
|
1662
|
+
id: 72,
|
|
1663
|
+
name: "push_outbox",
|
|
1664
|
+
up: (db) => {
|
|
1665
|
+
// One row per (event, device). device_id is '' for a row folded into
|
|
1666
|
+
// the Away digest. state: pending, sending, sent, failed, gone,
|
|
1667
|
+
// capped, superseded, folded, digested.
|
|
1668
|
+
db.exec(`
|
|
1669
|
+
CREATE TABLE IF NOT EXISTS push_outbox (
|
|
1670
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1671
|
+
event_key TEXT NOT NULL,
|
|
1672
|
+
device_id TEXT NOT NULL,
|
|
1673
|
+
category TEXT NOT NULL,
|
|
1674
|
+
level TEXT NOT NULL,
|
|
1675
|
+
payload TEXT NOT NULL,
|
|
1676
|
+
collapse_id TEXT,
|
|
1677
|
+
state TEXT NOT NULL,
|
|
1678
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1679
|
+
next_attempt_at INTEGER NOT NULL,
|
|
1680
|
+
created_at INTEGER NOT NULL,
|
|
1681
|
+
sent_at INTEGER,
|
|
1682
|
+
error TEXT,
|
|
1683
|
+
fallback_source TEXT,
|
|
1684
|
+
fallback_text TEXT,
|
|
1685
|
+
UNIQUE(event_key, device_id)
|
|
1686
|
+
);
|
|
1687
|
+
CREATE INDEX IF NOT EXISTS idx_push_outbox_due ON push_outbox(state, next_attempt_at);
|
|
1688
|
+
CREATE INDEX IF NOT EXISTS idx_push_outbox_device ON push_outbox(device_id, sent_at);
|
|
1689
|
+
`);
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1661
1692
|
];
|
|
1662
1693
|
|
|
1663
1694
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/done/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { releasePorts } from "../ports";
|
|
|
26
26
|
import { dropSessionDatabase } from "../project-config/database";
|
|
27
27
|
import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
|
|
28
28
|
import { captureMergedTask } from "../session-logs";
|
|
29
|
+
import { pushMerged } from "../notify/push/events";
|
|
29
30
|
|
|
30
31
|
export interface DoneOptions {
|
|
31
32
|
// Who asked: the orchestrator's gate failures count toward Saad, as its
|
|
@@ -103,7 +104,10 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
|
|
|
103
104
|
: `UPDATE sessions SET task_status = 'done' WHERE id = ?`
|
|
104
105
|
).run(s.id);
|
|
105
106
|
syncTaskCardInBackground(s, status, pr);
|
|
106
|
-
if (status === "merged")
|
|
107
|
+
if (status === "merged") {
|
|
108
|
+
pushMerged(s.id);
|
|
109
|
+
void captureMergedTask(s.id, { head: pr?.head });
|
|
110
|
+
}
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
export async function doneSession(
|
package/lib/load/alert.ts
CHANGED
|
@@ -30,7 +30,11 @@ const BACK_OFF =
|
|
|
30
30
|
"Ask your running tasks to use targeted tests and hold off on full suites, type checks and builds until it clears. Nothing has been stopped.";
|
|
31
31
|
|
|
32
32
|
export function raiseAlert(text: string): void {
|
|
33
|
-
notifyPhone("load", text
|
|
33
|
+
notifyPhone("load", text, {
|
|
34
|
+
category: "system",
|
|
35
|
+
key: `system:load:${Date.now()}`,
|
|
36
|
+
collapse: "system:load",
|
|
37
|
+
});
|
|
34
38
|
for (const orch of listOrchestrators()) {
|
|
35
39
|
if (orch.archived_at) continue;
|
|
36
40
|
sendMessage({
|
package/lib/notify/index.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { db, type Session } from "../db";
|
|
|
9
9
|
import { Limiter, type Outcome } from "./limiter";
|
|
10
10
|
import { holdForBatch, msToNextSweep } from "../away/state";
|
|
11
11
|
import { selectNotifier, type TelegramConfig } from "./notifiers";
|
|
12
|
+
import { pushAvailable, pushEvent } from "./push";
|
|
13
|
+
import { setFallbackSink } from "./push/sender";
|
|
14
|
+
import type { PushCategory } from "./push-types";
|
|
12
15
|
|
|
13
16
|
export type { Outcome } from "./limiter";
|
|
14
17
|
|
|
@@ -99,6 +102,23 @@ export function resumePhoneOutbox(): void {
|
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
// Held text that push couldn't deliver goes to Telegram once.
|
|
106
|
+
setFallbackSink(async (source, text) => {
|
|
107
|
+
const outcome = await limiter().push(source, text);
|
|
108
|
+
if (outcome.state === "failed") throw new Error(outcome.why);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Which phone-app category a message belongs to. A message with one goes to
|
|
112
|
+
// a registered device instead of Telegram, and to Telegram if push is off.
|
|
113
|
+
export interface PushRoute {
|
|
114
|
+
category: PushCategory;
|
|
115
|
+
id?: string | null;
|
|
116
|
+
key?: string | null;
|
|
117
|
+
collapse?: string | null;
|
|
118
|
+
/** The session it is about, so a mirror of another machine's never pushes. */
|
|
119
|
+
sessionId?: string | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
export class NotConfigured extends Error {
|
|
103
123
|
constructor() {
|
|
104
124
|
super(
|
|
@@ -111,10 +131,21 @@ export class NotConfigured extends Error {
|
|
|
111
131
|
// NotConfigured when nothing would send it.
|
|
112
132
|
export async function sendPhone(
|
|
113
133
|
source: string,
|
|
114
|
-
text: string
|
|
134
|
+
text: string,
|
|
135
|
+
push?: PushRoute
|
|
115
136
|
): Promise<Outcome> {
|
|
116
137
|
const body = text.trim();
|
|
117
138
|
if (!body) throw new Error("Nothing to send");
|
|
139
|
+
if (push && pushAvailable()) {
|
|
140
|
+
const { sessionId, ...event } = push;
|
|
141
|
+
const out = await pushEvent(event, {
|
|
142
|
+
sessionId,
|
|
143
|
+
fallback: { source, text: body },
|
|
144
|
+
});
|
|
145
|
+
if (out === "queued") return { state: "pushed" };
|
|
146
|
+
if (out === "folded") return { state: "held", inMs: msToNextSweep() };
|
|
147
|
+
if (out === "dropped" || out === "muted") return { state: "skipped" };
|
|
148
|
+
}
|
|
118
149
|
if (!selectNotifier(process.env, telegramConfig())) throw new NotConfigured();
|
|
119
150
|
const outcome = await limiter().push(source, body);
|
|
120
151
|
if (outcome.state === "failed")
|
|
@@ -142,15 +173,23 @@ export async function notifyFrom(
|
|
|
142
173
|
holdForBatch(session.workspace_id, session.id, body)
|
|
143
174
|
)
|
|
144
175
|
return { state: "held", inMs: msToNextSweep() };
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
176
|
+
const source = session ? `session:${session.id}` : "cli";
|
|
177
|
+
return sendPhone(source, session ? `${session.name}: ${body}` : body, {
|
|
178
|
+
category: "agents",
|
|
179
|
+
id: session?.id,
|
|
180
|
+
key: `agents:${session?.id ?? "cli"}:${Date.now()}`,
|
|
181
|
+
collapse: `agents:${session?.id ?? "cli"}`,
|
|
182
|
+
sessionId: session?.id,
|
|
183
|
+
});
|
|
149
184
|
}
|
|
150
185
|
|
|
151
186
|
// For the server's own alerts: never throws, never waits on the send.
|
|
152
|
-
export function notifyPhone(
|
|
153
|
-
|
|
187
|
+
export function notifyPhone(
|
|
188
|
+
source: string,
|
|
189
|
+
text: string,
|
|
190
|
+
push?: PushRoute
|
|
191
|
+
): void {
|
|
192
|
+
sendPhone(source, text, push).catch((error) => {
|
|
154
193
|
if (!(error instanceof NotConfigured))
|
|
155
194
|
console.error(
|
|
156
195
|
`[notify] ${source}:`,
|
package/lib/notify/limiter.ts
CHANGED
|
@@ -16,7 +16,12 @@ export type Outcome =
|
|
|
16
16
|
| { state: "held"; inMs: number }
|
|
17
17
|
| { state: "duplicate" }
|
|
18
18
|
| { state: "dropped" }
|
|
19
|
-
| { state: "failed"; why: string }
|
|
19
|
+
| { state: "failed"; why: string }
|
|
20
|
+
// Went to the phone app; Telegram only steps in if that fails.
|
|
21
|
+
| { state: "pushed" }
|
|
22
|
+
// Push had nothing to do: the device turned the category off, or Saad is
|
|
23
|
+
// at the Mac.
|
|
24
|
+
| { state: "skipped" };
|
|
20
25
|
|
|
21
26
|
export function collapse(texts: string[]): string {
|
|
22
27
|
if (texts.length === 1) return texts[0];
|