@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/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
 
package/lib/bus/index.ts CHANGED
@@ -14,6 +14,8 @@ import { previousNames } from "../session-names";
14
14
  import { deliverToPane, type Delivery } from "./delivery";
15
15
  import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
+ import { hostLink } from "../hosts/remote-api";
18
+ import { relayToHeldTask } from "../tasks/held-remote";
17
19
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
18
20
 
19
21
  export * from "./format";
@@ -142,11 +144,21 @@ function pairTimestamps(a: string | null, b: string): number[] {
142
144
 
143
145
  // Hands the message to the recipient: a chat session's worker, or typed
144
146
  // into a terminal's pane and checked that it went in.
145
- async function deliver(
147
+ export async function deliver(
146
148
  to: Session,
147
149
  line: string,
148
- from: { name: string; peer?: PeerMessage; origin?: ChatOrigin }
150
+ from: {
151
+ name: string;
152
+ peer?: PeerMessage;
153
+ origin?: ChatOrigin;
154
+ // The stored message's id, which a relayed one is keyed by.
155
+ messageId?: number;
156
+ }
149
157
  ): Promise<Delivery> {
158
+ // A task this machine's orchestrator moved to a linked machine: that
159
+ // machine's AgentOS delivers it, labelled as the orchestrator's (held.ts).
160
+ if (to.task_prompt && to.started_by && hostLink(to.host_id))
161
+ return relayToHeldTask(to, line, from.name, from.messageId);
150
162
  // A linked machine's own session: an agent's message would reach it as
151
163
  // the user's (see runner.ts), so none is relayed yet.
152
164
  if (to.peer_mirror && !to.task_prompt)
@@ -228,6 +240,7 @@ export async function sendMessage(opts: {
228
240
  opts.line ?? wakeLine({ fromName, fromId: from?.id ?? null, body }),
229
241
  {
230
242
  name: fromName,
243
+ messageId: id,
231
244
  peer: from ? { sessionId: from.id, body } : undefined,
232
245
  origin: from
233
246
  ? { kind: "peer", label: from.name, sessionId: from.id }
@@ -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,62 @@ 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
+ },
1692
+ {
1693
+ id: 73,
1694
+ name: "orchestrated_task_moves",
1695
+ up: (db) => {
1696
+ // task_since: when the task first started, kept across moves (null:
1697
+ // created_at). held_by_device / held_from: the device and machine of
1698
+ // the orchestrator that moved this task here; only that device may
1699
+ // merge, arm, message or move it. plan_note: the "plan written" line
1700
+ // of a held task, for its orchestrator to read.
1701
+ const add = (col: string) => {
1702
+ const columns = db.prepare(`PRAGMA table_info(sessions)`).all() as {
1703
+ name: string;
1704
+ }[];
1705
+ if (!columns.some((c) => c.name === col))
1706
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
1707
+ };
1708
+ for (const col of [
1709
+ "task_since",
1710
+ "held_by_device",
1711
+ "held_from",
1712
+ "plan_note",
1713
+ ])
1714
+ add(col);
1715
+ },
1716
+ },
1661
1717
  ];
1662
1718
 
1663
1719
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -119,6 +119,13 @@ export interface Session {
119
119
  rebase_asked_head?: string | null;
120
120
  // When its PR's reviews and comments started being watched (pr-wake.ts).
121
121
  pr_wake_since?: string | null;
122
+ // When the task first started, kept across moves (null: created_at).
123
+ task_since?: string | null;
124
+ // The device and machine of the orchestrator that moved this task here.
125
+ held_by_device?: string | null;
126
+ held_from?: string | null;
127
+ // A held task's "plan written" line, for its orchestrator to read.
128
+ plan_note?: string | null;
122
129
  // Orchestration fields
123
130
  conductor_session_id: string | null;
124
131
  worker_task: string | null;
package/lib/done/bulk.ts CHANGED
@@ -54,6 +54,8 @@ function scopeSessions(scope: DoneScope): Session[] {
54
54
  AND role IS NULL AND archived_at IS NULL
55
55
  -- A linked machine's own sessions are cleaned up there.
56
56
  AND peer_mirror = 0
57
+ -- A task another machine's orchestrator moved here is its to finish.
58
+ AND held_by_device IS NULL
57
59
  AND (task_status IS NULL OR task_status = 'running')
58
60
  ORDER BY created_at`
59
61
  )
@@ -75,6 +77,12 @@ export async function idleSessions(
75
77
 
76
78
  // The caller's workspace when its project has one, else its project.
77
79
  export function scopeOf(session: Session): DoneScope {
80
+ // An orchestrator acts through its tools, which Pause holds and which are
81
+ // logged; aos would reach the same sessions past both.
82
+ if (session.role === "orchestrator")
83
+ throw new Error(
84
+ `${session.name} is an orchestrator: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
85
+ );
78
86
  const project = session.project_id ? getProject(session.project_id) : null;
79
87
  if (!project || project.is_uncategorized)
80
88
  throw new Error(`${session.name} isn't in a project`);
package/lib/done/index.ts CHANGED
@@ -25,7 +25,10 @@ import { sessionMergePolicy } from "../tasks/finish";
25
25
  import { releasePorts } from "../ports";
26
26
  import { dropSessionDatabase } from "../project-config/database";
27
27
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
28
+ import { hostLink } from "../hosts/remote-api";
29
+ import { doneHeldTask } from "../tasks/held-remote";
28
30
  import { captureMergedTask } from "../session-logs";
31
+ import { pushMerged } from "../notify/push/events";
29
32
 
30
33
  export interface DoneOptions {
31
34
  // Who asked: the orchestrator's gate failures count toward Saad, as its
@@ -103,7 +106,10 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
103
106
  : `UPDATE sessions SET task_status = 'done' WHERE id = ?`
104
107
  ).run(s.id);
105
108
  syncTaskCardInBackground(s, status, pr);
106
- if (status === "merged") void captureMergedTask(s.id, { head: pr?.head });
109
+ if (status === "merged") {
110
+ pushMerged(s.id);
111
+ void captureMergedTask(s.id, { head: pr?.head });
112
+ }
107
113
  }
108
114
 
109
115
  export async function doneSession(
@@ -157,6 +163,19 @@ export async function doneSession(
157
163
  await stopAgent(s);
158
164
  // The sign-off's cleanup has already settled it; this reads the result.
159
165
  worktree = await settleWorktree(s, { prHead: m.sha });
166
+ } else if (s.started_by && s.task_prompt && hostLink(s.host_id)) {
167
+ // Moved to a linked machine by its orchestrator: that machine stops the
168
+ // agent and removes the worktree, which is where they are.
169
+ await doneHeldTask(s);
170
+ if (plan.mark) mark(s, plan.mark, plan.pr);
171
+ else
172
+ db.prepare(
173
+ `UPDATE sessions SET task_status = 'done' WHERE id = ? AND task_status = 'running'`
174
+ ).run(s.id);
175
+ worktree = {
176
+ action: "removed",
177
+ why: `on ${hostLink(s.host_id)?.hostName ?? "its machine"}`,
178
+ };
160
179
  } else {
161
180
  if (plan.mark) mark(s, plan.mark, plan.pr);
162
181
  if (
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({
@@ -73,9 +73,12 @@ export function planSummary(text: string, max = 5): string[] {
73
73
 
74
74
  // Tells the orchestrator that started a task its plan is written, once.
75
75
  function announcePlan(s: Session, ref: string): void {
76
- if (!s.started_by) return;
76
+ if (!s.started_by && !s.held_by_device) return;
77
77
  const workspaceId = sessionWorkspace(s);
78
- if (!workspaceId || !s.worktree_path) return;
78
+ // A task held for another machine's orchestrator can't reach it: the line
79
+ // waits on the task for that orchestrator to read (orchestrator/facts).
80
+ if (!s.held_by_device && !workspaceId) return;
81
+ if (!s.worktree_path) return;
79
82
  const file = path.join(s.worktree_path, PLAN_FILE);
80
83
  let head = `Task "${s.name}": no plan file was written; implementing on ${ref} anyway.`;
81
84
  try {
@@ -85,7 +88,14 @@ function announcePlan(s: Session, ref: string): void {
85
88
  } catch {
86
89
  // No plan file: the line says so.
87
90
  }
88
- queueEvent(workspaceId, `plan-done:${s.id}`, s.id, head);
91
+ if (s.held_by_device) {
92
+ db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(
93
+ head,
94
+ s.id
95
+ );
96
+ return;
97
+ }
98
+ queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
89
99
  }
90
100
 
91
101
  export const implementMessageId = (sessionId: string) =>
@@ -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];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The one place an event becomes the bytes the relay is given. v1 is the
3
+ * generic, content-free envelope. Encrypted rich text (v2) replaces the body
4
+ * of this function and nothing else: callers hand over an event, never a
5
+ * payload.
6
+ */
7
+
8
+ import crypto from "crypto";
9
+ import { buildPushPayload, isGenericPayload } from "../copy";
10
+ import type { PushEnvelope, PushEvent, PushLevel } from "../push-types";
11
+
12
+ export interface EncodedPush {
13
+ payload: PushEnvelope;
14
+ level: PushLevel;
15
+ priority: 10 | 5;
16
+ /** Opaque: a hash, so the relay and Apple learn nothing from it. */
17
+ collapseId: string;
18
+ threadId: string;
19
+ }
20
+
21
+ /** Same event key, same id, on every machine that sends it. */
22
+ export const collapseId = (key: string) =>
23
+ crypto.createHash("sha256").update(key).digest("hex").slice(0, 32);
24
+
25
+ export function encodePush(
26
+ event: PushEvent,
27
+ _device?: { id: string }
28
+ ): EncodedPush {
29
+ const payload = buildPushPayload({ ...event, key: null });
30
+ // Nothing but what the registry could have built may leave the machine.
31
+ if (!isGenericPayload(payload))
32
+ throw new Error(
33
+ "refusing to send a push payload the registry didn't build"
34
+ );
35
+ return {
36
+ payload,
37
+ level: payload.p,
38
+ priority: payload.p === "passive" ? 5 : 10,
39
+ collapseId: collapseId(event.collapse ?? event.key ?? payload.k),
40
+ threadId: event.category,
41
+ };
42
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * One helper per event that can reach the phone. Each is fire-and-forget:
3
+ * a failure here must never touch the code that raised the event.
4
+ */
5
+
6
+ import { db } from "../../db";
7
+ import { pushEvent } from "./index";
8
+ import type { PushEvent } from "../push-types";
9
+
10
+ function fire(event: PushEvent, sessionId?: string | null): void {
11
+ pushEvent(event, { sessionId }).catch((error) =>
12
+ console.error(
13
+ `[push] ${event.category}:`,
14
+ error instanceof Error ? error.message : error
15
+ )
16
+ );
17
+ }
18
+
19
+ export const pushAskRaised = (askId: number, workspaceId: string) =>
20
+ fire({ category: "asks", id: workspaceId, key: `ask:${askId}` });
21
+
22
+ export const pushNeedsYou = (sessionId: string, armedAt: number) =>
23
+ fire(
24
+ {
25
+ category: "needs-you",
26
+ id: sessionId,
27
+ key: `needs-you:${sessionId}:${armedAt}`,
28
+ collapse: `needs-you:${sessionId}`,
29
+ },
30
+ sessionId
31
+ );
32
+
33
+ const forTask = (
34
+ category: "review" | "ci" | "merged",
35
+ prefix: string,
36
+ taskId: string,
37
+ sha?: string | null
38
+ ) =>
39
+ fire(
40
+ {
41
+ category,
42
+ id: taskId,
43
+ key: `${prefix}:${taskId}${sha ? `:${sha}` : ""}`,
44
+ collapse: `task:${taskId}`,
45
+ },
46
+ taskId
47
+ );
48
+
49
+ export const pushReviewReady = (taskId: string, sha: string) =>
50
+ forTask("review", "review", taskId, sha);
51
+ export const pushCiFailed = (taskId: string, sha: string) =>
52
+ forTask("ci", "ci", taskId, sha);
53
+ export const pushReviewBlocked = (taskId: string, sha: string) =>
54
+ forTask("ci", "review-block", taskId, sha);
55
+ export const pushMerged = (taskId: string) =>
56
+ forTask("merged", "merged", taskId);
57
+
58
+ export const pushPlanReady = (sessionId: string, itemId: string) => {
59
+ // Only a task has a screen to open; a plain chat's plan goes to the list.
60
+ const task = db
61
+ .prepare(`SELECT task_status FROM sessions WHERE id = ?`)
62
+ .get(sessionId) as { task_status: string | null } | undefined;
63
+ fire(
64
+ {
65
+ category: "review",
66
+ id: task?.task_status ? sessionId : null,
67
+ key: `plan:${sessionId}:${itemId}`,
68
+ collapse: `plan:${sessionId}`,
69
+ },
70
+ sessionId
71
+ );
72
+ };