@jdscript/opencode-notify 0.0.0-stage → 0.1.0
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 +209 -2
- package/VERIFICATION.md +59 -0
- package/dist/crypto.d.ts +8 -0
- package/dist/crypto.js +63 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +96 -0
- package/dist/rpc.d.ts +199 -0
- package/dist/rpc.js +60 -0
- package/dist/sender.d.ts +73 -0
- package/dist/sender.js +847 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +1 -0
- package/index.js +2 -0
- package/package.json +48 -4
package/dist/sender.js
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { createECDH, createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { Subscribe } from "./rpc.js";
|
|
5
|
+
import { decode, encrypt } from "./crypto.js";
|
|
6
|
+
const StoredSubscription = z.object({
|
|
7
|
+
input: Subscribe,
|
|
8
|
+
revoked: z.boolean(),
|
|
9
|
+
unavailable: z.boolean(),
|
|
10
|
+
billingUntil: z.number().optional(),
|
|
11
|
+
});
|
|
12
|
+
const StoredActivity = z.object({
|
|
13
|
+
subscriptionID: z.string(),
|
|
14
|
+
activityID: z.string(),
|
|
15
|
+
sessionID: z.string(),
|
|
16
|
+
expiresAt: z.number(),
|
|
17
|
+
ended: z.boolean(),
|
|
18
|
+
unavailable: z.boolean().optional(),
|
|
19
|
+
lastTimestamp: z.number(),
|
|
20
|
+
lastSent: z.number(),
|
|
21
|
+
scope: z.enum(["session", "server", "workspace"]).optional(),
|
|
22
|
+
serverName: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
const StoredJob = z.object({
|
|
25
|
+
subscriptionID: z.string(),
|
|
26
|
+
body: z.object({
|
|
27
|
+
event_id: z.string(),
|
|
28
|
+
kind: z.enum(["alert", "live_activity"]),
|
|
29
|
+
ciphertext: z.string(),
|
|
30
|
+
activity_id: z.string().optional(),
|
|
31
|
+
event: z.enum(["update", "end"]).optional(),
|
|
32
|
+
timestamp: z.number().optional(),
|
|
33
|
+
settled: z.boolean().optional(),
|
|
34
|
+
}),
|
|
35
|
+
expiresAt: z.number(),
|
|
36
|
+
attempt: z.number(),
|
|
37
|
+
next: z.number(),
|
|
38
|
+
});
|
|
39
|
+
export class SenderError extends Error {
|
|
40
|
+
code;
|
|
41
|
+
constructor(code) {
|
|
42
|
+
super(code);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.name = "MobilePushError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** One process-wide sender, shared by the plugin's location registrations. */
|
|
48
|
+
export class Sender {
|
|
49
|
+
storage;
|
|
50
|
+
getSession;
|
|
51
|
+
waitSession;
|
|
52
|
+
subscriptions = new Map();
|
|
53
|
+
activities = new Map();
|
|
54
|
+
jobs = new Map();
|
|
55
|
+
nodes = new Map();
|
|
56
|
+
shells = new Map();
|
|
57
|
+
observedShells = new Set();
|
|
58
|
+
dirty = new Map();
|
|
59
|
+
receipts = new Map();
|
|
60
|
+
cleanedAt = 0;
|
|
61
|
+
chain = Promise.resolve();
|
|
62
|
+
timer;
|
|
63
|
+
flushing = false;
|
|
64
|
+
stopped = false;
|
|
65
|
+
workspaceKnown = false;
|
|
66
|
+
quietSince;
|
|
67
|
+
waitingForIdle = new Map();
|
|
68
|
+
waitGeneration = 0;
|
|
69
|
+
abort = new AbortController();
|
|
70
|
+
constructor(storage, getSession, waitSession) {
|
|
71
|
+
this.storage = storage;
|
|
72
|
+
this.getSession = getSession;
|
|
73
|
+
this.waitSession = waitSession;
|
|
74
|
+
}
|
|
75
|
+
async start() {
|
|
76
|
+
for (const prefix of ["sub/", "activity/", "job/", "receipt/"]) {
|
|
77
|
+
let after;
|
|
78
|
+
do {
|
|
79
|
+
const page = await this.storage.scan({ prefix, after, limit: 100 });
|
|
80
|
+
for (const row of page.entries) {
|
|
81
|
+
if (prefix === "sub/")
|
|
82
|
+
this.subscriptions.set(row.key.slice(4), StoredSubscription.parse(row.value));
|
|
83
|
+
if (prefix === "activity/")
|
|
84
|
+
this.activities.set(row.key.slice(9), StoredActivity.parse(row.value));
|
|
85
|
+
if (prefix === "job/")
|
|
86
|
+
this.jobs.set(row.key.slice(4), StoredJob.parse(row.value));
|
|
87
|
+
if (prefix === "receipt/")
|
|
88
|
+
this.receipts.set(row.key.slice(8), z.number().parse(row.value));
|
|
89
|
+
}
|
|
90
|
+
after = page.next;
|
|
91
|
+
} while (after);
|
|
92
|
+
}
|
|
93
|
+
this.schedule();
|
|
94
|
+
}
|
|
95
|
+
async stop() {
|
|
96
|
+
this.stopped = true;
|
|
97
|
+
clearTimeout(this.timer);
|
|
98
|
+
this.abort.abort();
|
|
99
|
+
await this.chain;
|
|
100
|
+
}
|
|
101
|
+
status(id) {
|
|
102
|
+
const sub = this.requireSubscription(id);
|
|
103
|
+
return {
|
|
104
|
+
subscriptionID: id,
|
|
105
|
+
revision: sub.input.revision,
|
|
106
|
+
expiresAt: sub.input.expiresAt,
|
|
107
|
+
state: sub.revoked
|
|
108
|
+
? "revoked"
|
|
109
|
+
: sub.input.expiresAt <= now()
|
|
110
|
+
? "expired"
|
|
111
|
+
: sub.unavailable
|
|
112
|
+
? "unavailable"
|
|
113
|
+
: "active",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
subscribe(input) {
|
|
117
|
+
return this.serial(async () => {
|
|
118
|
+
validateSubscription(input);
|
|
119
|
+
const old = this.subscriptions.get(input.subscriptionID);
|
|
120
|
+
if (old?.revoked)
|
|
121
|
+
throw new SenderError("subscription_revoked");
|
|
122
|
+
if (old && input.revision < old.input.revision)
|
|
123
|
+
throw new SenderError("stale_revision");
|
|
124
|
+
if (old && input.revision === old.input.revision) {
|
|
125
|
+
if (JSON.stringify(input) !== JSON.stringify(old.input))
|
|
126
|
+
throw new SenderError("revision_conflict");
|
|
127
|
+
return this.status(input.subscriptionID);
|
|
128
|
+
}
|
|
129
|
+
if (old &&
|
|
130
|
+
["serverID", "installationID", "recipientPublicKey", "authSecret"].some((key) => input[key] !== old.input[key])) {
|
|
131
|
+
throw new SenderError("binding_changed_use_new_subscription");
|
|
132
|
+
}
|
|
133
|
+
if (!old && this.subscriptions.size >= 128)
|
|
134
|
+
throw new SenderError("subscription_limit");
|
|
135
|
+
const sub = { input, revoked: false, unavailable: false };
|
|
136
|
+
await this.save("sub/" + input.subscriptionID, sub);
|
|
137
|
+
this.subscriptions.set(input.subscriptionID, sub);
|
|
138
|
+
this.schedule();
|
|
139
|
+
return this.status(input.subscriptionID);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
unsubscribe(id) {
|
|
143
|
+
return this.serial(async () => {
|
|
144
|
+
const sub = this.requireSubscription(id);
|
|
145
|
+
await this.save("sub/" + id, { ...sub, revoked: true });
|
|
146
|
+
sub.revoked = true;
|
|
147
|
+
for (const [key, job] of this.jobs)
|
|
148
|
+
if (job.subscriptionID === id)
|
|
149
|
+
await this.removeJob(key);
|
|
150
|
+
return { ok: true };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
attach(input) {
|
|
154
|
+
return this.serial(async () => {
|
|
155
|
+
this.requireSubscription(input.subscriptionID, true);
|
|
156
|
+
if (input.expiresAt <= now() || input.expiresAt > now() + 8 * 3600)
|
|
157
|
+
throw new SenderError("invalid_activity_expiry");
|
|
158
|
+
const key = routeKey(input.subscriptionID, input.activityID);
|
|
159
|
+
const old = this.activities.get(key);
|
|
160
|
+
if (old?.ended)
|
|
161
|
+
throw new SenderError("activity_ended");
|
|
162
|
+
if (old && old.sessionID !== input.sessionID)
|
|
163
|
+
throw new SenderError("activity_binding_changed");
|
|
164
|
+
if (old && (old.scope ?? "session") !== (input.scope ?? "session"))
|
|
165
|
+
throw new SenderError("activity_binding_changed");
|
|
166
|
+
if (input.scope === "server" || input.scope === "workspace") {
|
|
167
|
+
if (input.sessionID !== "ses_workspace")
|
|
168
|
+
throw new SenderError("invalid_workspace_binding");
|
|
169
|
+
for (const seed of input.seeds ?? []) {
|
|
170
|
+
const node = await this.node(seed.sessionID);
|
|
171
|
+
await this.root(node);
|
|
172
|
+
if (node.version === 0) {
|
|
173
|
+
if (node.working === undefined || seed.running)
|
|
174
|
+
node.working = seed.running;
|
|
175
|
+
node.waiting = new Set(seed.waiting);
|
|
176
|
+
}
|
|
177
|
+
else if (node.working === undefined)
|
|
178
|
+
node.working = seed.running;
|
|
179
|
+
this.watchIdle(node);
|
|
180
|
+
}
|
|
181
|
+
for (const shell of input.shells ?? []) {
|
|
182
|
+
if (shell.sessionID)
|
|
183
|
+
await this.root(await this.node(shell.sessionID));
|
|
184
|
+
if (!this.observedShells.has(shell.id))
|
|
185
|
+
this.shells.set(shell.id, shell.sessionID ?? "");
|
|
186
|
+
}
|
|
187
|
+
if (input.seedComplete === true)
|
|
188
|
+
this.workspaceKnown = true;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
await this.node(input.sessionID);
|
|
192
|
+
}
|
|
193
|
+
const route = {
|
|
194
|
+
subscriptionID: input.subscriptionID, activityID: input.activityID, sessionID: input.sessionID,
|
|
195
|
+
expiresAt: input.expiresAt, scope: input.scope, serverName: input.serverName,
|
|
196
|
+
ended: false,
|
|
197
|
+
unavailable: false,
|
|
198
|
+
lastTimestamp: old?.lastTimestamp ?? 0,
|
|
199
|
+
lastSent: old?.lastSent ?? 0,
|
|
200
|
+
};
|
|
201
|
+
await this.save("activity/" + key, route);
|
|
202
|
+
this.activities.set(key, route);
|
|
203
|
+
this.dirty.set(key, true);
|
|
204
|
+
await this.queueActivity(route);
|
|
205
|
+
this.schedule();
|
|
206
|
+
return { ok: true };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
detach(subscriptionID, activityID) {
|
|
210
|
+
return this.serial(async () => {
|
|
211
|
+
this.requireSubscription(subscriptionID);
|
|
212
|
+
const key = routeKey(subscriptionID, activityID);
|
|
213
|
+
const old = this.activities.get(key);
|
|
214
|
+
const route = old
|
|
215
|
+
? { ...old, ended: true, expiresAt: Math.max(old.expiresAt, now() + 8 * 3600) }
|
|
216
|
+
: {
|
|
217
|
+
subscriptionID,
|
|
218
|
+
activityID,
|
|
219
|
+
sessionID: "",
|
|
220
|
+
expiresAt: now() + 8 * 3600,
|
|
221
|
+
ended: true,
|
|
222
|
+
lastTimestamp: 0,
|
|
223
|
+
lastSent: 0,
|
|
224
|
+
};
|
|
225
|
+
await this.save("activity/" + key, route);
|
|
226
|
+
this.activities.set(key, route);
|
|
227
|
+
await this.removeJob("activity/" + key);
|
|
228
|
+
return { ok: true };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Semantic events only: no reasoning/text deltas, transcripts, tool arguments or outputs. */
|
|
232
|
+
onEvent(event) {
|
|
233
|
+
return this.serial(async () => {
|
|
234
|
+
if (event.type === "shell.created")
|
|
235
|
+
this.observedShells.add(event.data.info.id);
|
|
236
|
+
if (event.type === "shell.exited" || event.type === "shell.deleted")
|
|
237
|
+
this.observedShells.add(event.data.id);
|
|
238
|
+
if (event.type === "shell.created" && typeof event.data.info.metadata.sessionID !== "string") {
|
|
239
|
+
this.shells.set(event.data.info.id, "");
|
|
240
|
+
for (const [key, route] of this.activities)
|
|
241
|
+
if (isOverview(route) && !route.ended)
|
|
242
|
+
this.dirty.set(key, false);
|
|
243
|
+
this.schedule();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if ((event.type === "shell.exited" || event.type === "shell.deleted") && this.shells.get(event.data.id) === "") {
|
|
247
|
+
this.shells.delete(event.data.id);
|
|
248
|
+
for (const [key, route] of this.activities)
|
|
249
|
+
if (isOverview(route) && !route.ended)
|
|
250
|
+
this.dirty.set(key, false);
|
|
251
|
+
this.schedule();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const id = sessionFor(event, this.shells);
|
|
255
|
+
if (!id)
|
|
256
|
+
return;
|
|
257
|
+
if (!this.nodes.has(id) && this.nodes.size >= 4096) {
|
|
258
|
+
for (const [key, cached] of this.nodes)
|
|
259
|
+
if (!cached.working && !cached.waiting.size)
|
|
260
|
+
this.nodes.delete(key);
|
|
261
|
+
if (this.nodes.size >= 4096) {
|
|
262
|
+
this.workspaceKnown = false;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const node = await this.node(id);
|
|
267
|
+
node.version += 1;
|
|
268
|
+
node.activeAt = "created" in event ? Math.floor(event.created / 1000) : now();
|
|
269
|
+
switch (event.type) {
|
|
270
|
+
case "session.renamed":
|
|
271
|
+
node.info = { ...node.info, title: event.data.title };
|
|
272
|
+
break;
|
|
273
|
+
case "session.execution.started":
|
|
274
|
+
node.working = true;
|
|
275
|
+
node.outcome = undefined;
|
|
276
|
+
node.startedAt = Math.floor(event.created / 1000);
|
|
277
|
+
node.notified = undefined;
|
|
278
|
+
node.detail = "Working";
|
|
279
|
+
break;
|
|
280
|
+
case "session.execution.succeeded":
|
|
281
|
+
node.working = false;
|
|
282
|
+
node.outcome = "succeeded";
|
|
283
|
+
node.waiting.clear();
|
|
284
|
+
break;
|
|
285
|
+
case "session.execution.failed":
|
|
286
|
+
node.working = false;
|
|
287
|
+
node.outcome = "failed";
|
|
288
|
+
node.waiting.clear();
|
|
289
|
+
break;
|
|
290
|
+
case "session.execution.interrupted":
|
|
291
|
+
node.working = event.data.reason === "shutdown" ? undefined : false;
|
|
292
|
+
node.outcome = event.data.reason === "shutdown" ? undefined : "interrupted";
|
|
293
|
+
node.waiting.clear();
|
|
294
|
+
break;
|
|
295
|
+
case "session.status":
|
|
296
|
+
node.working = event.data.status.type !== "idle";
|
|
297
|
+
if (node.working) {
|
|
298
|
+
node.outcome = undefined;
|
|
299
|
+
node.detail = event.data.status.type === "retry" ? "Retrying" : "Working";
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
case "permission.asked":
|
|
303
|
+
node.waiting.add(event.data.id);
|
|
304
|
+
break;
|
|
305
|
+
case "permission.replied":
|
|
306
|
+
node.waiting.delete(event.data.requestID);
|
|
307
|
+
break;
|
|
308
|
+
case "form.created":
|
|
309
|
+
node.waiting.add(event.data.form.id);
|
|
310
|
+
break;
|
|
311
|
+
case "form.replied":
|
|
312
|
+
case "form.cancelled":
|
|
313
|
+
node.waiting.delete(event.data.id);
|
|
314
|
+
break;
|
|
315
|
+
case "session.tool.called":
|
|
316
|
+
node.working = true;
|
|
317
|
+
node.detail = "Using tools";
|
|
318
|
+
break;
|
|
319
|
+
case "shell.created":
|
|
320
|
+
this.shells.set(event.data.info.id, id);
|
|
321
|
+
break;
|
|
322
|
+
case "shell.exited":
|
|
323
|
+
case "shell.deleted":
|
|
324
|
+
this.shells.delete(event.data.id);
|
|
325
|
+
break;
|
|
326
|
+
case "session.deleted":
|
|
327
|
+
node.working = false;
|
|
328
|
+
node.outcome = "interrupted";
|
|
329
|
+
node.waiting.clear();
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
const root = await this.root(node);
|
|
333
|
+
const content = this.content(root);
|
|
334
|
+
const needs = event.type === "permission.asked" || event.type === "form.created";
|
|
335
|
+
const terminal = ["complete", "failed", "stopped"].includes(content.phase);
|
|
336
|
+
const marker = `${root.startedAt ?? 0}:${content.phase}`;
|
|
337
|
+
if (needs || (terminal && root.notified !== marker)) {
|
|
338
|
+
for (const sub of this.subscriptions.values()) {
|
|
339
|
+
if (!usable(sub))
|
|
340
|
+
continue;
|
|
341
|
+
if (needs
|
|
342
|
+
? !sub.input.preferences.requests
|
|
343
|
+
: content.phase === "complete"
|
|
344
|
+
? !sub.input.preferences.completed
|
|
345
|
+
: content.phase === "failed"
|
|
346
|
+
? !sub.input.preferences.failures
|
|
347
|
+
: true)
|
|
348
|
+
continue;
|
|
349
|
+
const alertContent = needs
|
|
350
|
+
? { ...content, phase: "attention", detail: "Needs your attention" }
|
|
351
|
+
: content;
|
|
352
|
+
await this.queueAlert(sub, root, alertContent, event.id);
|
|
353
|
+
}
|
|
354
|
+
if (terminal)
|
|
355
|
+
root.notified = marker;
|
|
356
|
+
}
|
|
357
|
+
// Immediate attention/end; other changing progress coalesces at most once per 5 s.
|
|
358
|
+
for (const route of this.activities.values()) {
|
|
359
|
+
if (route.ended || (!isOverview(route) && !this.within(node, route.sessionID)))
|
|
360
|
+
continue;
|
|
361
|
+
const followed = this.nodes.get(route.sessionID);
|
|
362
|
+
const urgent = needs || (followed !== undefined && ["complete", "failed", "stopped"].includes(this.content(followed).phase));
|
|
363
|
+
const key = routeKey(route.subscriptionID, route.activityID);
|
|
364
|
+
this.dirty.set(key, this.dirty.get(key) === true || urgent);
|
|
365
|
+
if (urgent || now() - route.lastSent >= 5)
|
|
366
|
+
await this.queueActivity(route);
|
|
367
|
+
}
|
|
368
|
+
this.schedule();
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async node(id) {
|
|
372
|
+
const old = this.nodes.get(id);
|
|
373
|
+
if (old && now() - old.seenAt < 3)
|
|
374
|
+
return old;
|
|
375
|
+
const info = await this.getSession(id).catch(() => old?.info);
|
|
376
|
+
if (!info)
|
|
377
|
+
throw new SenderError("session_unavailable");
|
|
378
|
+
const snapshot = {
|
|
379
|
+
id: info.id,
|
|
380
|
+
parentID: info.parentID,
|
|
381
|
+
title: info.title,
|
|
382
|
+
cost: info.cost,
|
|
383
|
+
location: info.location,
|
|
384
|
+
time: info.time,
|
|
385
|
+
};
|
|
386
|
+
if (old) {
|
|
387
|
+
old.info = snapshot;
|
|
388
|
+
old.seenAt = now();
|
|
389
|
+
return old;
|
|
390
|
+
}
|
|
391
|
+
// Historical outcome alone does not prove a currently running execution has ended.
|
|
392
|
+
const value = { info: snapshot, seenAt: now(), waiting: new Set(), detail: "Awaiting activity", version: 0 };
|
|
393
|
+
this.nodes.set(id, value);
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
observationUnavailable() {
|
|
397
|
+
this.workspaceKnown = false;
|
|
398
|
+
this.waitingForIdle.clear();
|
|
399
|
+
this.observedShells.clear();
|
|
400
|
+
this.shells.clear();
|
|
401
|
+
for (const node of this.nodes.values()) {
|
|
402
|
+
node.version = 0;
|
|
403
|
+
node.working = undefined;
|
|
404
|
+
node.waiting.clear();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async root(node) {
|
|
408
|
+
const seen = new Set();
|
|
409
|
+
while (node.info.parentID && !seen.has(node.info.id) && seen.size < 32) {
|
|
410
|
+
seen.add(node.info.id);
|
|
411
|
+
node = await this.node(node.info.parentID);
|
|
412
|
+
}
|
|
413
|
+
return node;
|
|
414
|
+
}
|
|
415
|
+
within(node, rootID) {
|
|
416
|
+
if (node.info.id === rootID)
|
|
417
|
+
return true;
|
|
418
|
+
const seen = new Set();
|
|
419
|
+
let parent = node.info.parentID;
|
|
420
|
+
while (parent && !seen.has(parent) && seen.size < 32) {
|
|
421
|
+
if (parent === rootID)
|
|
422
|
+
return true;
|
|
423
|
+
seen.add(parent);
|
|
424
|
+
parent = this.nodes.get(parent)?.info.parentID;
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
content(root) {
|
|
429
|
+
const descendants = [...this.nodes.values()].filter((node) => node.info.id !== root.info.id && this.within(node, root.info.id));
|
|
430
|
+
const ids = new Set([root.info.id, ...descendants.map((node) => node.info.id)]);
|
|
431
|
+
const waiting = [root, ...descendants].reduce((sum, node) => sum + node.waiting.size, 0);
|
|
432
|
+
const agents = descendants.filter((node) => node.working || node.waiting.size > 0).length;
|
|
433
|
+
const shells = [...this.shells.values()].filter((id) => ids.has(id)).length;
|
|
434
|
+
const phase = waiting > 0
|
|
435
|
+
? "attention"
|
|
436
|
+
: root.working || agents > 0 || shells > 0
|
|
437
|
+
? "working"
|
|
438
|
+
: root.outcome === "succeeded"
|
|
439
|
+
? "complete"
|
|
440
|
+
: root.outcome === "failed"
|
|
441
|
+
? "failed"
|
|
442
|
+
: root.outcome === "interrupted"
|
|
443
|
+
? "stopped"
|
|
444
|
+
: root.working === false
|
|
445
|
+
? "idle"
|
|
446
|
+
: "offline";
|
|
447
|
+
const detail = phase === "attention"
|
|
448
|
+
? "Needs your attention"
|
|
449
|
+
: phase === "complete"
|
|
450
|
+
? "Completed"
|
|
451
|
+
: phase === "failed"
|
|
452
|
+
? "Run failed"
|
|
453
|
+
: phase === "stopped"
|
|
454
|
+
? "Stopped"
|
|
455
|
+
: phase === "offline"
|
|
456
|
+
? "Awaiting activity"
|
|
457
|
+
: phase === "idle"
|
|
458
|
+
? "Ready"
|
|
459
|
+
: root.working
|
|
460
|
+
? root.detail
|
|
461
|
+
: "Background work";
|
|
462
|
+
return {
|
|
463
|
+
title: text(root.info.title ?? "OpenCode session", 240),
|
|
464
|
+
project: text(basename(root.info.location.directory), 120),
|
|
465
|
+
phase,
|
|
466
|
+
detail,
|
|
467
|
+
agents,
|
|
468
|
+
shells,
|
|
469
|
+
waiting,
|
|
470
|
+
...(Number.isFinite(root.info.cost) ? { cost: root.info.cost } : {}),
|
|
471
|
+
...(root.startedAt ? { startedAt: root.startedAt } : {}),
|
|
472
|
+
updatedAt: now(),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
async queueAlert(sub, root, content, sourceID) {
|
|
476
|
+
if ((sub.billingUntil ?? 0) > now())
|
|
477
|
+
return;
|
|
478
|
+
const key = `alert/${sub.input.subscriptionID}/${sourceID}`;
|
|
479
|
+
if (this.jobs.has(key) || (this.receipts.get(key) ?? 0) > now())
|
|
480
|
+
return;
|
|
481
|
+
await this.queue(key, sub, { id: root.info.id, directory: root.info.location.directory }, content);
|
|
482
|
+
}
|
|
483
|
+
watchIdle(node) {
|
|
484
|
+
if (!this.waitSession || node.working !== true || this.waitingForIdle.has(node.info.id))
|
|
485
|
+
return;
|
|
486
|
+
const id = node.info.id, version = node.version;
|
|
487
|
+
const token = ++this.waitGeneration;
|
|
488
|
+
this.waitingForIdle.set(id, token);
|
|
489
|
+
void this.waitSession(id).then(() => this.serial(async () => {
|
|
490
|
+
if (this.waitingForIdle.get(id) !== token)
|
|
491
|
+
return;
|
|
492
|
+
this.waitingForIdle.delete(id);
|
|
493
|
+
if (this.stopped || this.nodes.get(id) !== node)
|
|
494
|
+
return;
|
|
495
|
+
if (node.version !== version) {
|
|
496
|
+
this.watchIdle(node);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Waiting for execution to become idle does not resolve an independently
|
|
500
|
+
// created form. Only request replies/cancellation or execution-end events do.
|
|
501
|
+
node.working = false;
|
|
502
|
+
node.seenAt = now();
|
|
503
|
+
node.version += 1;
|
|
504
|
+
for (const [key, route] of this.activities)
|
|
505
|
+
if (isOverview(route) && !route.ended)
|
|
506
|
+
this.dirty.set(key, false);
|
|
507
|
+
this.schedule();
|
|
508
|
+
})).catch(() => { if (this.waitingForIdle.get(id) === token) {
|
|
509
|
+
this.waitingForIdle.delete(id);
|
|
510
|
+
this.workspaceKnown = false;
|
|
511
|
+
} });
|
|
512
|
+
}
|
|
513
|
+
workspaceContent(route) {
|
|
514
|
+
const roots = [...this.nodes.values()].filter((node) => !node.info.parentID);
|
|
515
|
+
const active = roots.map((node) => ({ node, value: this.content(node) }))
|
|
516
|
+
.filter(({ value }) => value.phase === "working" || value.phase === "attention")
|
|
517
|
+
.sort((a, b) => b.value.waiting - a.value.waiting || b.node.seenAt - a.node.seenAt);
|
|
518
|
+
const waiting = active.reduce((sum, item) => sum + item.value.waiting, 0);
|
|
519
|
+
const agents = [...this.nodes.values()].filter((node) => node.info.parentID && (node.working || node.waiting.size)).length;
|
|
520
|
+
const shells = this.shells.size;
|
|
521
|
+
if (active.length || shells || waiting || !this.workspaceKnown)
|
|
522
|
+
this.quietSince = undefined;
|
|
523
|
+
else
|
|
524
|
+
this.quietSince ??= now();
|
|
525
|
+
const phase = waiting ? "attention" : !this.workspaceKnown ? "offline" : active.length || shells ? "working" : now() - (this.quietSince ?? now()) >= 5 ? "complete" : "idle";
|
|
526
|
+
return {
|
|
527
|
+
title: phase === "complete" ? "All work finished" : "Workspace activity",
|
|
528
|
+
project: text(route.serverName ?? "Kiteyard", 120), phase,
|
|
529
|
+
detail: phase === "attention" ? "Needs your attention" : phase === "offline" ? "Awaiting updates" : phase === "complete" ? "All work finished" : phase === "idle" ? "Finishing work…" : "Working",
|
|
530
|
+
agents, shells, waiting, updatedAt: now(),
|
|
531
|
+
...(this.workspaceKnown ? { sessionCount: active.length } : {}),
|
|
532
|
+
items: active.slice(0, 3).map(({ node, value }) => ({ id: createHash("sha256").update(node.info.id).digest("hex").slice(0, 32), title: text(node.info.title ?? "Session", 150), phase: value.phase, updatedAt: node.activeAt ?? Math.floor(node.info.time.updated / 1000) })),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
async queueActivity(route) {
|
|
536
|
+
const sub = this.subscriptions.get(route.subscriptionID);
|
|
537
|
+
if (!sub || !usable(sub) || route.ended || route.unavailable || route.expiresAt <= now())
|
|
538
|
+
return;
|
|
539
|
+
const root = isOverview(route) ? undefined : await this.node(route.sessionID);
|
|
540
|
+
const timestamp = now();
|
|
541
|
+
if (timestamp <= route.lastTimestamp)
|
|
542
|
+
return; // coalesce same-second changes, never invent future timestamps
|
|
543
|
+
const content = root ? this.content(root) : this.workspaceContent(route);
|
|
544
|
+
const settled = ["complete", "failed", "stopped"].includes(content.phase);
|
|
545
|
+
const event = settled && route.scope !== "workspace" ? "end" : "update";
|
|
546
|
+
if (!settled && (sub.billingUntil ?? 0) > now())
|
|
547
|
+
return;
|
|
548
|
+
await this.queue("activity/" + routeKey(route.subscriptionID, route.activityID), sub, root ? { id: root.info.id, directory: root.info.location.directory } : { id: "ses_workspace", directory: "/" }, content, {
|
|
549
|
+
activityID: route.activityID,
|
|
550
|
+
event,
|
|
551
|
+
timestamp,
|
|
552
|
+
}, route.scope === "workspace" ? settled : undefined);
|
|
553
|
+
route.lastTimestamp = timestamp;
|
|
554
|
+
route.lastSent = timestamp;
|
|
555
|
+
this.dirty.delete(routeKey(route.subscriptionID, route.activityID));
|
|
556
|
+
if (isOverview(route) && content.phase === "idle")
|
|
557
|
+
this.dirty.set(routeKey(route.subscriptionID, route.activityID), false);
|
|
558
|
+
await this.save("activity/" + routeKey(route.subscriptionID, route.activityID), route);
|
|
559
|
+
}
|
|
560
|
+
async queue(key, sub, binding, content, activity, settled) {
|
|
561
|
+
if (this.jobs.size >= 512 && !this.jobs.has(key))
|
|
562
|
+
throw new SenderError("outbox_full");
|
|
563
|
+
const eventID = randomUUID();
|
|
564
|
+
const payload = {
|
|
565
|
+
version: 1,
|
|
566
|
+
subscriptionID: sub.input.subscriptionID,
|
|
567
|
+
eventID,
|
|
568
|
+
installationID: sub.input.installationID,
|
|
569
|
+
serverID: sub.input.serverID,
|
|
570
|
+
kind: activity ? "live_activity" : "alert",
|
|
571
|
+
producer: "server",
|
|
572
|
+
issuedAt: now(),
|
|
573
|
+
expiresAt: Math.min(now() + 3600, sub.input.expiresAt),
|
|
574
|
+
sessionID: binding.id,
|
|
575
|
+
directory: binding.directory,
|
|
576
|
+
content,
|
|
577
|
+
...activity,
|
|
578
|
+
};
|
|
579
|
+
const bytes = Buffer.from(JSON.stringify(payload));
|
|
580
|
+
if (bytes.length > 2400 || Buffer.byteLength(payload.directory) > 1024)
|
|
581
|
+
throw new SenderError("content_too_large");
|
|
582
|
+
const ciphertext = encrypt(bytes, sub.input.recipientPublicKey, sub.input.authSecret);
|
|
583
|
+
const job = {
|
|
584
|
+
subscriptionID: sub.input.subscriptionID,
|
|
585
|
+
expiresAt: payload.expiresAt,
|
|
586
|
+
attempt: 0,
|
|
587
|
+
next: now(),
|
|
588
|
+
body: {
|
|
589
|
+
event_id: eventID,
|
|
590
|
+
kind: payload.kind,
|
|
591
|
+
ciphertext,
|
|
592
|
+
...(activity ? { activity_id: activity.activityID, event: activity.event, timestamp: activity.timestamp } : {}),
|
|
593
|
+
...(settled === undefined ? {} : { settled }),
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
await this.save("job/" + key, job);
|
|
597
|
+
this.jobs.set(key, job);
|
|
598
|
+
}
|
|
599
|
+
schedule() {
|
|
600
|
+
if (this.stopped || this.timer)
|
|
601
|
+
return;
|
|
602
|
+
if (![...this.jobs.values()].some((job) => job.expiresAt > now() && usable(this.subscriptions.get(job.subscriptionID))) &&
|
|
603
|
+
![...this.activities.values()].some((route) => !route.ended &&
|
|
604
|
+
!route.unavailable &&
|
|
605
|
+
route.expiresAt > now() &&
|
|
606
|
+
usable(this.subscriptions.get(route.subscriptionID))))
|
|
607
|
+
return;
|
|
608
|
+
this.timer = setTimeout(() => {
|
|
609
|
+
this.timer = undefined;
|
|
610
|
+
void this.tick()
|
|
611
|
+
.catch(() => console.warn("[mobile-push] notification work failed; pending work retained"))
|
|
612
|
+
.finally(() => this.schedule());
|
|
613
|
+
}, 1000);
|
|
614
|
+
this.timer.unref?.();
|
|
615
|
+
}
|
|
616
|
+
async tick() {
|
|
617
|
+
if (this.flushing || this.stopped)
|
|
618
|
+
return;
|
|
619
|
+
this.flushing = true;
|
|
620
|
+
try {
|
|
621
|
+
await this.serial(async () => {
|
|
622
|
+
if (now() - this.cleanedAt >= 60) {
|
|
623
|
+
this.cleanedAt = now();
|
|
624
|
+
for (const [key, expiresAt] of this.receipts)
|
|
625
|
+
if (expiresAt <= now()) {
|
|
626
|
+
await this.storage.remove("receipt/" + key);
|
|
627
|
+
this.receipts.delete(key);
|
|
628
|
+
}
|
|
629
|
+
const followed = new Set([...this.activities.values()].filter((route) => !route.ended).map((route) => route.sessionID));
|
|
630
|
+
for (const [id, node] of this.nodes) {
|
|
631
|
+
if (!node.working &&
|
|
632
|
+
node.waiting.size === 0 &&
|
|
633
|
+
!followed.has(id) &&
|
|
634
|
+
(now() - node.seenAt > 3600 || this.nodes.size > 2048))
|
|
635
|
+
this.nodes.delete(id);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
for (const [key, route] of this.activities) {
|
|
639
|
+
if (route.expiresAt <= now()) {
|
|
640
|
+
await this.storage.remove("activity/" + key);
|
|
641
|
+
this.activities.delete(key);
|
|
642
|
+
this.dirty.delete(key);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!route.ended &&
|
|
646
|
+
!route.unavailable &&
|
|
647
|
+
(this.dirty.get(key) === true ||
|
|
648
|
+
(this.dirty.has(key) && now() - route.lastSent >= 5) ||
|
|
649
|
+
now() - route.lastSent >= 30))
|
|
650
|
+
await this.queueActivity(route);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
for (const [key, job] of [...this.jobs]) {
|
|
654
|
+
if (this.stopped)
|
|
655
|
+
return;
|
|
656
|
+
if (this.jobs.get(key)?.body.event_id !== job.body.event_id)
|
|
657
|
+
continue;
|
|
658
|
+
const sub = this.subscriptions.get(job.subscriptionID);
|
|
659
|
+
if ((this.receipts.get(key) ?? 0) > now()) {
|
|
660
|
+
await this.serial(() => this.removeJobIfCurrent(key, job));
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (!sub || sub.revoked || job.expiresAt <= now() || job.attempt >= 12) {
|
|
664
|
+
await this.serial(() => this.removeJobIfCurrent(key, job));
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (!usable(sub) || job.next > now())
|
|
668
|
+
continue;
|
|
669
|
+
const result = await transmit(sub.input, job, this.abort.signal);
|
|
670
|
+
await this.serial(async () => {
|
|
671
|
+
if (this.jobs.get(key)?.body.event_id !== job.body.event_id)
|
|
672
|
+
return;
|
|
673
|
+
if (result === "accepted") {
|
|
674
|
+
if (job.body.kind === "alert") {
|
|
675
|
+
const expiry = now() + 86400;
|
|
676
|
+
await this.storage.set("receipt/" + key, expiry);
|
|
677
|
+
this.receipts.set(key, expiry);
|
|
678
|
+
}
|
|
679
|
+
if (job.body.event === "end" && job.body.activity_id) {
|
|
680
|
+
const route = this.activities.get(routeKey(job.subscriptionID, job.body.activity_id));
|
|
681
|
+
if (route) {
|
|
682
|
+
route.ended = true;
|
|
683
|
+
route.expiresAt = Math.max(route.expiresAt, now() + 8 * 3600);
|
|
684
|
+
await this.save("activity/" + routeKey(route.subscriptionID, route.activityID), route);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
await this.removeJob(key);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (result === "credential") {
|
|
691
|
+
// A response for an old credential must not disable a newer enrollment.
|
|
692
|
+
if (this.subscriptions.get(job.subscriptionID)?.input.revision === sub.input.revision) {
|
|
693
|
+
sub.unavailable = true;
|
|
694
|
+
await this.save("sub/" + job.subscriptionID, sub);
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (result === "billing") {
|
|
699
|
+
if (this.subscriptions.get(job.subscriptionID)?.input.revision === sub.input.revision) {
|
|
700
|
+
sub.billingUntil = now() + 60;
|
|
701
|
+
await this.save("sub/" + job.subscriptionID, sub);
|
|
702
|
+
}
|
|
703
|
+
await this.removeJob(key);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (result === "permanent") {
|
|
707
|
+
if (job.body.activity_id) {
|
|
708
|
+
const route = this.activities.get(routeKey(job.subscriptionID, job.body.activity_id));
|
|
709
|
+
if (route) {
|
|
710
|
+
route.unavailable = true;
|
|
711
|
+
await this.save("activity/" + routeKey(route.subscriptionID, route.activityID), route);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
await this.removeJob(key);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
job.attempt += 1;
|
|
718
|
+
job.next = now() + result;
|
|
719
|
+
await this.save("job/" + key, job);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
finally {
|
|
724
|
+
this.flushing = false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
requireSubscription(id, enabled = false) {
|
|
728
|
+
const sub = this.subscriptions.get(id);
|
|
729
|
+
if (!sub)
|
|
730
|
+
throw new SenderError("subscription_not_found");
|
|
731
|
+
if (enabled && !usable(sub))
|
|
732
|
+
throw new SenderError("subscription_unavailable");
|
|
733
|
+
return sub;
|
|
734
|
+
}
|
|
735
|
+
serial(fn) {
|
|
736
|
+
const result = this.chain.then(fn);
|
|
737
|
+
this.chain = result.catch(() => undefined);
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
save(key, value) {
|
|
741
|
+
return this.storage.set(key, z.json().parse(JSON.parse(JSON.stringify(value))));
|
|
742
|
+
}
|
|
743
|
+
async removeJob(key) {
|
|
744
|
+
await this.storage.remove("job/" + key);
|
|
745
|
+
this.jobs.delete(key);
|
|
746
|
+
}
|
|
747
|
+
async removeJobIfCurrent(key, job) {
|
|
748
|
+
if (this.jobs.get(key)?.body.event_id === job.body.event_id)
|
|
749
|
+
await this.removeJob(key);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function now() {
|
|
753
|
+
return Math.floor(Date.now() / 1000);
|
|
754
|
+
}
|
|
755
|
+
function routeKey(subscriptionID, activityID) {
|
|
756
|
+
return `${subscriptionID}/${activityID}`;
|
|
757
|
+
}
|
|
758
|
+
function usable(sub) {
|
|
759
|
+
return !!sub && !sub.revoked && !sub.unavailable && sub.input.expiresAt > now();
|
|
760
|
+
}
|
|
761
|
+
function validateSubscription(input) {
|
|
762
|
+
const url = new URL(input.relayURL);
|
|
763
|
+
if (url.username ||
|
|
764
|
+
url.password ||
|
|
765
|
+
url.search ||
|
|
766
|
+
url.hash ||
|
|
767
|
+
url.pathname !== "/" ||
|
|
768
|
+
!(url.protocol === "https:" ||
|
|
769
|
+
(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))))
|
|
770
|
+
throw new SenderError("invalid_relay_origin");
|
|
771
|
+
if (input.expiresAt <= now() || input.expiresAt > now() + 90 * 86400)
|
|
772
|
+
throw new SenderError("invalid_expiry");
|
|
773
|
+
try {
|
|
774
|
+
const recipient = decode(input.recipientPublicKey, 65);
|
|
775
|
+
if (recipient[0] !== 4)
|
|
776
|
+
throw new Error("invalid key");
|
|
777
|
+
const probe = createECDH("prime256v1");
|
|
778
|
+
probe.generateKeys();
|
|
779
|
+
probe.computeSecret(recipient);
|
|
780
|
+
decode(input.authSecret, 16);
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
throw new SenderError("invalid_encryption_parameters");
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function isOverview(route) { return route.scope === "server" || route.scope === "workspace"; }
|
|
787
|
+
function sessionFor(event, shells) {
|
|
788
|
+
switch (event.type) {
|
|
789
|
+
case "session.created":
|
|
790
|
+
case "session.renamed":
|
|
791
|
+
case "session.deleted":
|
|
792
|
+
case "session.execution.started":
|
|
793
|
+
case "session.execution.succeeded":
|
|
794
|
+
case "session.execution.failed":
|
|
795
|
+
case "session.execution.interrupted":
|
|
796
|
+
case "session.status":
|
|
797
|
+
case "session.tool.called":
|
|
798
|
+
case "permission.asked":
|
|
799
|
+
case "permission.replied":
|
|
800
|
+
return event.data.sessionID;
|
|
801
|
+
case "form.created":
|
|
802
|
+
return event.data.form.sessionID.startsWith("ses") ? event.data.form.sessionID : undefined;
|
|
803
|
+
case "form.replied":
|
|
804
|
+
case "form.cancelled":
|
|
805
|
+
return event.data.sessionID.startsWith("ses") ? event.data.sessionID : undefined;
|
|
806
|
+
case "shell.created":
|
|
807
|
+
return typeof event.data.info.metadata.sessionID === "string" ? event.data.info.metadata.sessionID : undefined;
|
|
808
|
+
case "shell.exited":
|
|
809
|
+
case "shell.deleted":
|
|
810
|
+
return shells.get(event.data.id);
|
|
811
|
+
default:
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function text(value, budget) {
|
|
816
|
+
let size = 0;
|
|
817
|
+
return Array.from(value)
|
|
818
|
+
.filter((character) => {
|
|
819
|
+
size += Buffer.byteLength(character);
|
|
820
|
+
return size <= budget;
|
|
821
|
+
})
|
|
822
|
+
.join("");
|
|
823
|
+
}
|
|
824
|
+
async function transmit(sub, job, signal) {
|
|
825
|
+
try {
|
|
826
|
+
const response = await fetch(new URL("/v1/push", sub.relayURL), {
|
|
827
|
+
method: "POST",
|
|
828
|
+
headers: { Authorization: `Bearer ${sub.sendToken}`, "Content-Type": "application/json" },
|
|
829
|
+
body: JSON.stringify(job.body),
|
|
830
|
+
redirect: "error",
|
|
831
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
|
|
832
|
+
});
|
|
833
|
+
await response.body?.cancel();
|
|
834
|
+
if (response.status === 202)
|
|
835
|
+
return "accepted";
|
|
836
|
+
if (response.status === 402)
|
|
837
|
+
return "billing";
|
|
838
|
+
if (response.status === 401 || (response.status === 410 && job.body.kind === "alert"))
|
|
839
|
+
return "credential";
|
|
840
|
+
if ([400, 403, 404, 410, 413].includes(response.status))
|
|
841
|
+
return "permanent";
|
|
842
|
+
return Math.max(1, Math.min(300, Number(response.headers.get("retry-after")) || 2 ** Math.min(job.attempt + 1, 7)));
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return Math.min(120, 2 ** Math.min(job.attempt + 1, 7));
|
|
846
|
+
}
|
|
847
|
+
}
|