@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 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
@@ -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 { 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
+ }
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
- if (!asks.length && !held.length) return null;
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
 
@@ -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.
@@ -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
+ }
@@ -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") void captureMergedTask(s.id, { head: pr?.head });
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({
@@ -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
- return sendPhone(
146
- session ? `session:${session.id}` : "cli",
147
- session ? `${session.name}: ${body}` : body
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(source: string, text: string): void {
153
- sendPhone(source, text).catch((error) => {
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}:`,
@@ -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];