@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.
- package/README.md +47 -3
- 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/app/api/tasks/[id]/arm/route.ts +5 -0
- package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
- package/app/api/tasks/[id]/done/route.ts +15 -0
- package/app/api/tasks/[id]/export/route.ts +6 -1
- package/app/api/tasks/[id]/merge/route.ts +9 -1
- package/app/api/tasks/[id]/message/route.ts +39 -0
- package/app/api/tasks/[id]/moved/route.ts +6 -1
- package/app/api/tasks/[id]/resume/route.ts +6 -1
- package/app/api/tasks/[id]/stop/route.ts +14 -0
- package/app/api/tasks/import/route.ts +11 -1
- 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/bus/index.ts +15 -2
- 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 +56 -0
- package/lib/db/types.ts +7 -0
- package/lib/done/bulk.ts +8 -0
- package/lib/done/index.ts +20 -1
- package/lib/load/alert.ts +5 -1
- package/lib/models/plan-step.ts +13 -3
- 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/act.ts +41 -1
- package/lib/orchestrator/armed.ts +6 -1
- package/lib/orchestrator/asks.ts +5 -1
- package/lib/orchestrator/brakes.ts +9 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/facts.ts +72 -21
- package/lib/orchestrator/read.ts +14 -2
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +15 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +11 -0
- package/lib/orchestrator/tools.ts +1 -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/sessions/stop.ts +15 -1
- package/lib/tasks/branch-worktree.ts +16 -1
- package/lib/tasks/finish.ts +7 -1
- package/lib/tasks/held-remote.ts +80 -0
- package/lib/tasks/held-routes.ts +33 -0
- package/lib/tasks/held.ts +28 -0
- package/lib/tasks/import.ts +60 -7
- package/lib/tasks/index.ts +11 -1
- package/lib/tasks/last-active.ts +25 -0
- package/lib/tasks/move-bundle.ts +53 -1
- package/lib/tasks/move-carry.ts +102 -0
- package/lib/tasks/move-flow.ts +34 -7
- package/lib/tasks/move-guard.ts +17 -13
- package/lib/tasks/move-recover.ts +2 -0
- package/lib/tasks/move.ts +34 -3
- package/lib/tasks/remote.ts +3 -0
- package/lib/tasks/session.ts +27 -3
- package/package.json +1 -1
- 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
|
-
|
|
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: {
|
|
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 }
|
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,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")
|
|
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({
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
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];
|
|
@@ -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
|
+
};
|