@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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Drains the push outbox into the Connect relay. Never throws at a caller and
3
+ * never blocks one: `kick()` starts a drain and returns. Retries back off;
4
+ * a relay that says push isn't set up is left alone for ten minutes instead
5
+ * of being asked per message, and what was waiting falls back to Telegram.
6
+ */
7
+
8
+ import type { PushRequestBody } from "@/lib/connect/control-api";
9
+ import {
10
+ getDevicePush,
11
+ pruneDevicePush,
12
+ setDevicePushError,
13
+ } from "@/lib/security/devices";
14
+ import * as outbox from "./outbox";
15
+ import { relaySend, type RelayAnswer } from "./relay";
16
+ import { isTestKey, type OutboxRow } from "./outbox";
17
+
18
+ export const MAX_ATTEMPTS = 3;
19
+ const BACKOFF_MS = [30_000, 120_000];
20
+ const MAX_BACKOFF_MS = 600_000;
21
+ export const UNAVAILABLE_MS = 600_000;
22
+ const BATCH = 10;
23
+ /** Dead tokens are hex; anything else would make the relay refuse the batch. */
24
+ const APNS_TOKEN = /^[0-9a-fA-F]{64,200}$/;
25
+
26
+ export interface SenderDeps {
27
+ now: () => number;
28
+ relay: (body: PushRequestBody) => Promise<RelayAnswer>;
29
+ /** Sends a held message by the other channel. */
30
+ fallback: (source: string, text: string) => Promise<void>;
31
+ log: (line: string) => void;
32
+ }
33
+
34
+ type Sink = SenderDeps["fallback"];
35
+ // On globalThis like the sender: the app's bundles each have their own copy
36
+ // of this module, and the one that sets the sink may not be the one that built
37
+ // the sender.
38
+ const sinks = globalThis as unknown as { __agentosPushFallback?: Sink };
39
+
40
+ /** Where held text goes when push can't deliver it (Telegram). */
41
+ export function setFallbackSink(fn: Sink): void {
42
+ sinks.__agentosPushFallback = fn;
43
+ }
44
+
45
+ const defaults = (): SenderDeps => ({
46
+ now: () => Date.now(),
47
+ relay: relaySend,
48
+ fallback: async (source, text) => sinks.__agentosPushFallback?.(source, text),
49
+ log: (line) => console.error(`[push] ${line}`),
50
+ });
51
+
52
+ export class PushSender {
53
+ private deps: SenderDeps;
54
+ private running = false;
55
+ private again = false;
56
+ private current: Promise<void> = Promise.resolve();
57
+ private timer: ReturnType<typeof setTimeout> | null = null;
58
+ private down: { until: number; reason: string } | null = null;
59
+
60
+ constructor(deps: Partial<SenderDeps> = {}) {
61
+ this.deps = { ...defaults(), ...deps };
62
+ }
63
+
64
+ /** Why pushes aren't being tried right now, or null. */
65
+ unavailable(): string | null {
66
+ if (this.down && this.deps.now() >= this.down.until) this.down = null;
67
+ return this.down?.reason ?? null;
68
+ }
69
+
70
+ clearUnavailable(): void {
71
+ this.down = null;
72
+ }
73
+
74
+ /** Stops the retry timer (a test discarding its sender). */
75
+ dispose(): void {
76
+ if (this.timer) clearTimeout(this.timer);
77
+ this.timer = null;
78
+ }
79
+
80
+ /** Starts a drain if none is running; resolves when it has caught up. */
81
+ kick(): Promise<void> {
82
+ if (this.running) {
83
+ this.again = true;
84
+ return this.current;
85
+ }
86
+ this.running = true;
87
+ this.current = this.drain().finally(() => {
88
+ this.running = false;
89
+ this.arm();
90
+ });
91
+ return this.current;
92
+ }
93
+
94
+ /** Re-arms after a restart. */
95
+ resume(): void {
96
+ outbox.resume(this.deps.now());
97
+ void this.kick();
98
+ }
99
+
100
+ private async drain(): Promise<void> {
101
+ try {
102
+ do {
103
+ this.again = false;
104
+ for (const key of outbox.expire(this.deps.now()))
105
+ await this.settle(key);
106
+ for (;;) {
107
+ const rows = outbox.claimDue(this.deps.now());
108
+ if (!rows.length) break;
109
+ const byEvent = new Map<string, OutboxRow[]>();
110
+ for (const r of rows)
111
+ byEvent.set(r.event_key, [...(byEvent.get(r.event_key) ?? []), r]);
112
+ for (const group of byEvent.values())
113
+ for (let i = 0; i < group.length; i += BATCH)
114
+ await this.sendGroup(group.slice(i, i + BATCH));
115
+ }
116
+ } while (this.again);
117
+ } catch (error) {
118
+ this.deps.log(
119
+ `drain failed: ${error instanceof Error ? error.message : error}`
120
+ );
121
+ }
122
+ }
123
+
124
+ private arm(): void {
125
+ if (this.timer) clearTimeout(this.timer);
126
+ this.timer = null;
127
+ const at = outbox.nextDueAt();
128
+ if (at === null) return;
129
+ this.timer = setTimeout(
130
+ () => void this.kick(),
131
+ Math.max(1000, at - this.deps.now())
132
+ );
133
+ this.timer.unref?.();
134
+ }
135
+
136
+ private async sendGroup(rows: OutboxRow[]): Promise<void> {
137
+ // An earlier group in this drain found the relay unusable.
138
+ const down = this.unavailable();
139
+ if (down) {
140
+ for (const r of rows) outbox.markFailed(r.id, down);
141
+ for (const key of new Set(rows.map((r) => r.event_key)))
142
+ await this.settle(key);
143
+ return;
144
+ }
145
+ const sendable: {
146
+ row: OutboxRow;
147
+ token: string;
148
+ env: "production" | "sandbox";
149
+ }[] = [];
150
+ for (const row of rows) {
151
+ const push = getDevicePush(row.device_id);
152
+ if (!push) outbox.markFailed(row.id, "no push token", true);
153
+ else if (!APNS_TOKEN.test(push.token)) {
154
+ outbox.markFailed(row.id, "not an APNs token");
155
+ setDevicePushError(
156
+ row.device_id,
157
+ "the registered token isn't an APNs token"
158
+ );
159
+ } else sendable.push({ row, token: push.token, env: push.environment });
160
+ }
161
+ // The relay folds a repeated token into one result, so send each once.
162
+ const seen = new Set<string>();
163
+ const unique = sendable.filter((s) => {
164
+ const k = `${s.token.toLowerCase()}:${s.env}`;
165
+ if (seen.has(k)) {
166
+ outbox.markFailed(s.row.id, "duplicate token");
167
+ return false;
168
+ }
169
+ seen.add(k);
170
+ return true;
171
+ });
172
+ if (unique.length) {
173
+ const first = unique[0].row;
174
+ const answer = await this.deps.relay({
175
+ v: 1,
176
+ devices: unique.map((s) => ({ token: s.token, env: s.env })),
177
+ push_type: "alert",
178
+ priority: first.level === "passive" ? 5 : 10,
179
+ level: first.level,
180
+ ...(first.collapse_id && { collapse_id: first.collapse_id }),
181
+ thread_id: first.category,
182
+ payload: JSON.parse(first.payload),
183
+ });
184
+ await this.apply(
185
+ unique.map((s) => s.row),
186
+ answer
187
+ );
188
+ }
189
+ for (const key of new Set(rows.map((r) => r.event_key)))
190
+ await this.settle(key);
191
+ }
192
+
193
+ private async apply(rows: OutboxRow[], answer: RelayAnswer): Promise<void> {
194
+ const now = this.deps.now();
195
+ if (answer.kind === "unavailable") {
196
+ this.down = { until: now + UNAVAILABLE_MS, reason: answer.reason };
197
+ this.deps.log(`push unavailable: ${answer.reason}`);
198
+ for (const r of rows) {
199
+ outbox.markFailed(r.id, answer.reason);
200
+ setDevicePushError(r.device_id, answer.reason);
201
+ }
202
+ for (const key of outbox.failPending(answer.reason))
203
+ await this.settle(key);
204
+ return;
205
+ }
206
+ if (answer.kind === "invalid") {
207
+ for (const r of rows) {
208
+ outbox.markFailed(r.id, answer.reason);
209
+ setDevicePushError(r.device_id, answer.reason);
210
+ }
211
+ return;
212
+ }
213
+ rows.forEach((r, i) => {
214
+ if (answer.kind === "retry")
215
+ return this.retry(r, now, answer.reason, answer.afterMs);
216
+ const res = answer.results[i];
217
+ if (!res) return this.retry(r, now, "no answer for this device");
218
+ if (res.status === "sent") {
219
+ outbox.markSent(r.id, now);
220
+ setDevicePushError(r.device_id, null);
221
+ } else if (res.status === "gone" || res.prune) {
222
+ const why = `token no longer valid${res.reason ? ` (${res.reason})` : ""}`;
223
+ pruneDevicePush(r.device_id, why);
224
+ outbox.markFailed(r.id, why, true);
225
+ } else if (res.status === "retry") {
226
+ this.retry(
227
+ r,
228
+ now,
229
+ res.reason ?? "try again",
230
+ res.retry_after ? res.retry_after * 1000 : undefined
231
+ );
232
+ } else {
233
+ const why = res.reason ?? "rejected";
234
+ outbox.markFailed(r.id, why);
235
+ setDevicePushError(r.device_id, why);
236
+ }
237
+ });
238
+ }
239
+
240
+ private retry(
241
+ r: OutboxRow,
242
+ now: number,
243
+ why: string,
244
+ afterMs?: number
245
+ ): void {
246
+ const max = isTestKey(r.event_key) ? 1 : MAX_ATTEMPTS;
247
+ if (r.attempts >= max) {
248
+ outbox.markFailed(r.id, why);
249
+ setDevicePushError(r.device_id, why);
250
+ return;
251
+ }
252
+ const wait = Math.min(
253
+ Math.max(BACKOFF_MS[r.attempts - 1] ?? BACKOFF_MS.at(-1)!, afterMs ?? 0),
254
+ MAX_BACKOFF_MS
255
+ );
256
+ outbox.markRetry(r.id, now + wait, why);
257
+ }
258
+
259
+ /** Once an event has ended with no phone reached, the held text goes by Telegram. */
260
+ private async settle(eventKey: string): Promise<void> {
261
+ const held = outbox.claimFallback(eventKey);
262
+ if (!held) return;
263
+ try {
264
+ await this.deps.fallback(held.source, held.text);
265
+ } catch (error) {
266
+ this.deps.log(
267
+ `fallback for ${held.source} didn't send: ${error instanceof Error ? error.message : error}`
268
+ );
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,30 @@
1
+ // Test fixtures for the push sender: paired phones with push tokens.
2
+
3
+ import crypto from "crypto";
4
+ import { db } from "../../db";
5
+ import { mintDevice, setDevicePush } from "@/lib/security/devices";
6
+ import type { PushCategory } from "../push-types";
7
+
8
+ export function pairPhone(categories?: PushCategory[]): string {
9
+ const { device } = mintDevice("phone");
10
+ setDevicePush(device.id, {
11
+ token: crypto.randomBytes(32).toString("hex"),
12
+ platform: "ios",
13
+ environment: "production",
14
+ categories,
15
+ });
16
+ return device.id;
17
+ }
18
+
19
+ export function resetPush(): void {
20
+ db.prepare(`DELETE FROM push_outbox`).run();
21
+ db.prepare(`DELETE FROM devices`).run();
22
+ const g = globalThis as { __agentosPush?: { dispose(): void } };
23
+ g.__agentosPush?.dispose();
24
+ g.__agentosPush = undefined;
25
+ }
26
+
27
+ export const outboxRows = (where = "1=1") =>
28
+ db
29
+ .prepare(`SELECT * FROM push_outbox WHERE ${where} ORDER BY id`)
30
+ .all() as import("./outbox").OutboxRow[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * "Needs you" is a session that has waited on Saad for a minute. A timer
3
+ * starts when it begins waiting and is cancelled by any change, so a quick
4
+ * permission prompt that he answers never buzzes.
5
+ */
6
+
7
+ import { pushNeedsYou } from "./events";
8
+ import { WAIT_MS } from "./policy";
9
+
10
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
11
+
12
+ export function noteWaiting(sessionId: string, waiting: boolean): void {
13
+ const armed = timers.get(sessionId);
14
+ if (!waiting) {
15
+ if (armed) {
16
+ clearTimeout(armed);
17
+ timers.delete(sessionId);
18
+ }
19
+ return;
20
+ }
21
+ if (armed) return;
22
+ const armedAt = Date.now();
23
+ const timer = setTimeout(() => {
24
+ timers.delete(sessionId);
25
+ pushNeedsYou(sessionId, armedAt);
26
+ }, WAIT_MS);
27
+ timer.unref?.();
28
+ timers.set(sessionId, timer);
29
+ }
@@ -74,4 +74,6 @@ export interface PushEvent {
74
74
  id?: string | null;
75
75
  /** Dedupe key, e.g. `ask:<id>` or `review:<task>:<sha>`. */
76
76
  key?: string | null;
77
+ /** Events sharing this replace each other on the phone. Defaults to key. */
78
+ collapse?: string | null;
77
79
  }
@@ -16,6 +16,17 @@ const SECRET_PATTERNS: RegExp[] = [
16
16
  /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g,
17
17
  ];
18
18
 
19
+ /**
20
+ * An id or key made by the app: no known token shape, and no single
21
+ * segment (split on - _ : .) that is a long hex or base64 run. A uuid or a
22
+ * timestamped id passes; a pasted key or a 40-character sha does not.
23
+ */
24
+ function plainIdent(text: string): boolean {
25
+ if (SECRET_PATTERNS.some((re) => ((re.lastIndex = 0), re.test(text))))
26
+ return false;
27
+ return !text.split(/[-_:.]/).some((seg) => seg.length >= 24);
28
+ }
29
+
19
30
  /** True when the string looks like a token, key or JWT. */
20
31
  export function looksSecret(text: string): boolean {
21
32
  return [...SECRET_PATTERNS, LONG_RUN].some((re) => {
@@ -42,7 +53,7 @@ export function safeId(id: unknown): string | null {
42
53
  if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID)
43
54
  return null;
44
55
  if (!SAFE_ID.test(id)) return null;
45
- return looksSecret(id) ? null : id;
56
+ return plainIdent(id) ? id : null;
46
57
  }
47
58
 
48
59
  /**
@@ -53,5 +64,5 @@ export function safeKey(key: unknown): string | null {
53
64
  if (typeof key !== "string" || key.length === 0 || key.length > 128)
54
65
  return null;
55
66
  if (!/^[A-Za-z0-9_:.-]+$/.test(key)) return null;
56
- return looksSecret(key) ? null : key;
67
+ return plainIdent(key) ? key : null;
57
68
  }
@@ -6,6 +6,7 @@
6
6
  * gate or brake ask is spent on that one item: never standing permission.
7
7
  */
8
8
 
9
+ import { pushAskRaised } from "../notify/push/events";
9
10
  import { db } from "../db";
10
11
  import { answerKey, queueEvent } from "./events";
11
12
  import { addNote } from "./notes";
@@ -167,7 +168,7 @@ export function raiseAsk(input: {
167
168
  const sha = input.sha ?? null;
168
169
  const brakeKey = input.brakeKey ?? null;
169
170
  const raisedBy = input.raisedBy ?? null;
170
- return db.transaction(() => {
171
+ const result = db.transaction(() => {
171
172
  const open = openBySubject(input.workspaceId, input.subject);
172
173
  if (open) {
173
174
  db.prepare(
@@ -209,6 +210,9 @@ export function raiseAsk(input: {
209
210
  created: true,
210
211
  };
211
212
  })();
213
+ // A new ask buzzes the phone; a refresh of an open one doesn't.
214
+ if (result.created) pushAskRaised(result.ask.id, input.workspaceId);
215
+ return result;
212
216
  }
213
217
 
214
218
  export function openAsks(workspaceId: string): AskRow[] {
@@ -10,6 +10,7 @@
10
10
  * the orchestrator hears the verdict as an event.
11
11
  */
12
12
 
13
+ import { pushReviewBlocked, pushReviewReady } from "../notify/push/events";
13
14
  import { db, type Session } from "../db";
14
15
  import { amendmentsOf } from "./amendments";
15
16
  import type { TaskPR } from "../tasks/state";
@@ -275,6 +276,8 @@ export async function reviewAt(
275
276
  .then((done) => {
276
277
  if (done.status === "pass" && target.task)
277
278
  noteQuickReview(target.task, sha);
279
+ if (done.status === "pass") pushReviewReady(target.id, sha);
280
+ else if (done.status === "block") pushReviewBlocked(target.id, sha);
278
281
  const said =
279
282
  done.status === "pass"
280
283
  ? "passed"
@@ -24,7 +24,9 @@ import {
24
24
  applyProgramReport,
25
25
  dropProgramTransient,
26
26
  programSessions,
27
+ programSummary,
27
28
  } from "./store";
29
+ import { noteWaiting } from "../notify/push/waiting";
28
30
 
29
31
  const execFileAsync = promisify(execFile);
30
32
  const tmux = (args: string[]) =>
@@ -85,7 +87,15 @@ async function handle(name: string, event: OscEvent): Promise<void> {
85
87
  ? await foreground(name)
86
88
  : undefined
87
89
  );
88
- if (changed) notifyStatusChanged();
90
+ if (changed) {
91
+ notifyStatusChanged();
92
+ const id = (
93
+ getDb()
94
+ .prepare(`SELECT id FROM sessions WHERE tmux_name = ?`)
95
+ .get(name) as { id: string } | undefined
96
+ )?.id;
97
+ if (id) noteWaiting(id, programSummary(name)?.state === "blocked");
98
+ }
89
99
  }
90
100
 
91
101
  // What one pane has said but isn't applied yet: the latest report per
@@ -237,7 +237,11 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
237
237
  const text = `Schedule "${schedule.name}" is paused: ${why}. Point it at another session or delete it.`;
238
238
  console.error(`[schedules] ${text}`);
239
239
  addNote(schedule.workspace_id, text, "pause");
240
- notifyPhone(`schedule:${schedule.id}`, text);
240
+ notifyPhone(`schedule:${schedule.id}`, text, {
241
+ category: "system",
242
+ key: `system:schedule:${schedule.id}:${Date.now()}`,
243
+ collapse: `system:schedule:${schedule.id}`,
244
+ });
241
245
  return;
242
246
  }
243
247
  console.error(`[schedules] ${schedule.name} failed: ${why}`);
@@ -248,7 +252,12 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
248
252
  );
249
253
  notifyPhone(
250
254
  `schedule:${schedule.id}`,
251
- `Schedule "${schedule.name}" failed: ${why}`
255
+ `Schedule "${schedule.name}" failed: ${why}`,
256
+ {
257
+ category: "system",
258
+ key: `system:schedule:${schedule.id}:${Date.now()}`,
259
+ collapse: `system:schedule:${schedule.id}`,
260
+ }
252
261
  );
253
262
  }
254
263
 
@@ -86,13 +86,52 @@ export function touchDevice(id: string, address?: string): boolean {
86
86
  return true;
87
87
  }
88
88
 
89
- export function listDevices(): Device[] {
90
- return getDb()
89
+ /** What the Devices page shows of a device's push: never the token. */
90
+ export interface DevicePushView {
91
+ enabled: boolean;
92
+ categories: PushCategory[];
93
+ lastError: string | null;
94
+ updatedAt: string | null;
95
+ }
96
+
97
+ export type ListedDevice = Device & { push: DevicePushView };
98
+
99
+ export function listDevices(): ListedDevice[] {
100
+ const rows = getDb()
91
101
  .prepare(
92
- `SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve
102
+ `SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve,
103
+ ${PUSH_COLS}
93
104
  FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC`
94
105
  )
95
- .all() as Device[];
106
+ .all() as (Device & PushRow)[];
107
+ return rows.map((r) => {
108
+ const {
109
+ push_token,
110
+ push_platform,
111
+ push_environment,
112
+ push_categories,
113
+ push_updated_at,
114
+ push_last_error,
115
+ ...device
116
+ } = r;
117
+ const push = pushOf({
118
+ push_token,
119
+ push_platform,
120
+ push_environment,
121
+ push_categories,
122
+ push_updated_at,
123
+ push_last_error,
124
+ });
125
+ return {
126
+ ...device,
127
+ push: {
128
+ enabled: !!push,
129
+ categories: push?.categories ?? [],
130
+ lastError: push_last_error,
131
+ updatedAt: push?.updatedAt ?? null,
132
+ },
133
+ };
134
+ });
96
135
  }
97
136
 
98
137
  export function renameDevice(id: string, name: string): boolean {
@@ -301,3 +340,17 @@ function dropDeviceSockets(id: string): void {
301
340
  for (const s of sockets.get(id) ?? []) s.destroy();
302
341
  sockets.delete(id);
303
342
  }
343
+
344
+ /**
345
+ * The relay said this token is dead: forget it, keep the chosen categories,
346
+ * say why. The app registers a fresh token on its next launch.
347
+ */
348
+ export function pruneDevicePush(id: string, reason: string): void {
349
+ getDb()
350
+ .prepare(
351
+ `UPDATE devices SET push_token = NULL, push_platform = NULL,
352
+ push_environment = NULL, push_last_error = ?
353
+ WHERE id = ?`
354
+ )
355
+ .run(reason.slice(0, 200), id);
356
+ }
@@ -12,6 +12,7 @@ import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
12
12
  import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
13
13
  import { endSessionAgent } from "../sessions/end";
14
14
  import { run } from "./gh";
15
+ import { pushMerged } from "../notify/push/events";
15
16
  import { gitFetch, gitPush } from "../git-exec";
16
17
  import {
17
18
  mergePolicy,
@@ -155,6 +156,7 @@ export async function signOffTask(
155
156
  signingOff.delete(id);
156
157
  }
157
158
  syncTaskCardInBackground(session, "merged", pr);
159
+ pushMerged(id);
158
160
  // Reads the plan file now: cleanup below removes the worktree.
159
161
  void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
160
162
  const head = opts.head ?? pr!.head;
@@ -16,6 +16,12 @@ import { findPRStrict } from "./gh";
16
16
  import { currentBranch, git } from "../git-exec";
17
17
  import { lookupPR } from "./pr-poll";
18
18
  import { captureMergedTask } from "../session-logs";
19
+ import { getOrchestrator } from "../orchestrator/home";
20
+ import {
21
+ pushCiFailed,
22
+ pushMerged,
23
+ pushReviewReady,
24
+ } from "../notify/push/events";
19
25
  import { notifyStatusChanged } from "../status/hub";
20
26
  import type { TaskPR } from "./state";
21
27
  import { markStatusDirty } from "../status/dirty";
@@ -193,14 +199,19 @@ export async function prFor(
193
199
  prCache.set(session.id, { at: Date.now(), pr });
194
200
  markStatusDirty(session.id);
195
201
  // A PR whose stage inputs moved is news for the sidebar's badge.
196
- if (prSignature(before) !== prSignature(pr)) notifyStatusChanged();
202
+ if (prSignature(before) !== prSignature(pr)) {
203
+ notifyStatusChanged();
204
+ if (pr) pushPrChange(session, pr);
205
+ }
197
206
  if (pr) {
198
207
  db.prepare(
199
208
  `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
200
209
  ).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
201
210
  // Merged on GitHub rather than through AgentOS: its log is due too.
202
- if (pr.state === "MERGED")
211
+ if (pr.state === "MERGED") {
212
+ if (session.pr_status !== "merged") pushMerged(session.id);
203
213
  void captureMergedTask(session.id, { head: pr.head });
214
+ }
204
215
  }
205
216
  // Only an answer just read from GitHub: a cached one may predate a
206
217
  // retarget AgentOS made itself (a restack).
@@ -283,3 +294,16 @@ export function forgetPR(sessionId: string): void {
283
294
  prCache.delete(sessionId);
284
295
  markStatusDirty(sessionId);
285
296
  }
297
+
298
+ // CI failing is news for the phone in any workspace. Green is "ready for
299
+ // review" only where no orchestrator runs a review of its own (its verdict is
300
+ // the signal there). Each head is pushed once: the event key holds the sha.
301
+ export function pushPrChange(session: Session, pr: TaskPR): void {
302
+ if (pr.state !== "OPEN" || !pr.head) return;
303
+ if (pr.checks === "fail") pushCiFailed(session.id, pr.head);
304
+ else if (
305
+ pr.checks === "pass" &&
306
+ !(session.workspace_id && getOrchestrator(session.workspace_id))
307
+ )
308
+ pushReviewReady(session.id, pr.head);
309
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.516",
3
+ "version": "0.3.517",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -15,6 +15,7 @@ import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
15
15
  import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
16
16
  import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
17
17
  import { resumePhoneOutbox } from "./lib/notify";
18
+ import { resumePushOutbox } from "./lib/notify/push";
18
19
  import {
19
20
  bindAddresses,
20
21
  requestAllowed,
@@ -395,6 +396,7 @@ app.prepare().then(async () => {
395
396
  // Schedules tick here, once a minute, and nowhere else.
396
397
  if (schedulesEnabled(process.env)) startScheduler(realDeps);
397
398
  resumePhoneOutbox();
399
+ resumePushOutbox();
398
400
  // The usage ledger's daily rollup and its ratchet alerts.
399
401
  if (usageRollupEnabled(process.env)) startUsageRollup();
400
402
  // Machine load: a gauge, notes on heavy commands, one alert when red.