@raidiant/notifai 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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. package/tsconfig.json +10 -0
package/dist/hooks.js ADDED
@@ -0,0 +1,792 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
5
+ import { buildDraft } from './send.js';
6
+ /**
7
+ * Past this, the question's reply window (3600s) has long expired server-side
8
+ * and the companion shows it as dead on next open anyway; pushing a retirement
9
+ * sync for it is noise. Also the backstop that keeps an unreachable server
10
+ * from growing the queue for ever.
11
+ */
12
+ const ORPHAN_TTL_MS = 24 * 3600 * 1000;
13
+ /** More orphans than this means something is looping; keep the newest. */
14
+ const ORPHAN_QUEUE_CAP = 50;
15
+ /** How often the grace window rechecks whether the user has come back. */
16
+ const GRACE_POLL_MS = 5_000;
17
+ /**
18
+ * Total seconds a Stop hook may spend blocking. Both harnesses kill a command
19
+ * hook at 600s, and a killed hook loses an answer the user has already given,
20
+ * so the grace window yields to the reply wait rather than the other way round.
21
+ */
22
+ const STOP_BUDGET_SECONDS = 480;
23
+ /**
24
+ * The terminal-first wait: the question sits in the terminal for
25
+ * `ask_grace_seconds` from when it was sent, and only then reaches companion
26
+ * devices.
27
+ *
28
+ * Holding a blocking Stop hook open is normally hostile — while it blocks, the
29
+ * harness cannot show its prompt either, so a user wanting to answer locally is
30
+ * locked out. What makes it safe is that the wait is abandoned the moment the
31
+ * user touches the keyboard or mouse. The block therefore only ever persists
32
+ * while they are demonstrably not using the machine, which costs them nothing.
33
+ *
34
+ * That safety depends entirely on the idle signal, so with no idle source this
35
+ * refuses to wait at all rather than holding a terminal it cannot monitor.
36
+ *
37
+ * With `require_idle` off the whole calculus changes: the user has said they
38
+ * want the question to reach them whether or not they are at the keyboard, so
39
+ * there is nothing to watch for and this becomes what its name always claimed —
40
+ * a plain timer. It then works on machines with no idle source too.
41
+ */
42
+ export async function awaitGrace(ctx, askedAt) {
43
+ const threshold = ctx.config.away_after_seconds.value;
44
+ // Never let the grace window eat the reply wait's share of the hook budget.
45
+ const graceSeconds = Math.min(ctx.config.ask_grace_seconds.value, Math.max(0, STOP_BUDGET_SECONDS - ctx.config.hook_reply_timeout_seconds.value));
46
+ // `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
47
+ // future would wait far past the hook's budget, one from the distant past
48
+ // would skip the terminal-first window the user asked for entirely. Anything
49
+ // outside a plausible range restarts the window from now.
50
+ const elapsed = ctx.now() - askedAt;
51
+ const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now();
52
+ const deadline = start + graceSeconds * 1000;
53
+ for (;;) {
54
+ if (ctx.config.require_idle.value) {
55
+ const idle = ctx.idleSeconds();
56
+ if (idle === null)
57
+ return 'no-signal';
58
+ if (idle < threshold)
59
+ return 'user-returned';
60
+ }
61
+ if (ctx.now() >= deadline)
62
+ return 'absent';
63
+ await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())));
64
+ }
65
+ }
66
+ function sessionStatePath(sessionId, env) {
67
+ return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`);
68
+ }
69
+ export function readSessionState(sessionId, env) {
70
+ const file = sessionStatePath(sessionId, env);
71
+ if (!existsSync(file))
72
+ return {};
73
+ try {
74
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
75
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
76
+ }
77
+ catch {
78
+ // A corrupt marker must not wedge the harness; treat it as "no evidence",
79
+ // which fails closed onto ordinary terminal behaviour.
80
+ return {};
81
+ }
82
+ }
83
+ export function writeSessionState(sessionId, env, state) {
84
+ const file = sessionStatePath(sessionId, env);
85
+ mkdirSync(path.dirname(file), { recursive: true });
86
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
87
+ }
88
+ export function clearSessionState(sessionId, env) {
89
+ rmSync(sessionStatePath(sessionId, env), { force: true });
90
+ // The session override lives in a sibling file; leaving it behind meant a
91
+ // later session reusing the id silently inherited `ask_notifications = false`.
92
+ rmSync(sessionConfigPath(sessionId, env), { force: true });
93
+ }
94
+ /**
95
+ * Session state a crashed harness left behind.
96
+ *
97
+ * `SessionEnd` removes both the marker and the session override, but a harness
98
+ * that crashes or is killed never reaches it. At roughly a hundred sessions a
99
+ * day that is tens of thousands of files a year, none of which anything reads.
100
+ *
101
+ * Opportunistic rather than scheduled: hooks are the only thing that runs, so
102
+ * a hook is where this has to live. It is rate-limited by its own stamp file
103
+ * so the common case costs one `stat`, not a directory walk — a Stop hook is
104
+ * on the critical path of every turn.
105
+ */
106
+ const SESSION_PRUNE_AFTER_MS = 7 * 24 * 3600 * 1000;
107
+ const SESSION_PRUNE_INTERVAL_MS = 24 * 3600 * 1000;
108
+ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION_PRUNE_AFTER_MS) {
109
+ const directory = path.join(stateDir(env), 'sessions');
110
+ const stamp = path.join(stateDir(env), 'last-prune');
111
+ try {
112
+ if (existsSync(stamp) && now - statSync(stamp).mtimeMs < SESSION_PRUNE_INTERVAL_MS)
113
+ return 0;
114
+ mkdirSync(path.dirname(stamp), { recursive: true });
115
+ writeFileSync(stamp, '', { mode: 0o600 });
116
+ if (!existsSync(directory))
117
+ return 0;
118
+ let removed = 0;
119
+ for (const name of readdirSync(directory)) {
120
+ const file = path.join(directory, name);
121
+ try {
122
+ const age = now - statSync(file).mtimeMs;
123
+ // A negative age means the clock moved, not that the file is old.
124
+ // Deleting live session state on an NTP correction would lose a
125
+ // question already on the user's phone.
126
+ if (age <= maxAgeMs)
127
+ continue;
128
+ rmSync(file, { force: true });
129
+ removed += 1;
130
+ }
131
+ catch {
132
+ // A file that vanished under us, or one we may not read. Neither is
133
+ // worth failing a hook for, and the next pass will see it again.
134
+ }
135
+ }
136
+ return removed;
137
+ }
138
+ catch {
139
+ // Housekeeping must never be the reason a turn fails.
140
+ return 0;
141
+ }
142
+ }
143
+ /**
144
+ * One question, one push, even with two Stop hooks racing.
145
+ *
146
+ * Path-independent hook ownership stops the *usual* cause of two handlers
147
+ * firing, but it cannot stop every one — two harnesses in one directory, or an
148
+ * install this build does not recognise. Both processes would read the same
149
+ * pending question, see no `request_id`, and both escalate: one question, two
150
+ * notifications.
151
+ *
152
+ * `wx` is the whole mechanism. Exclusive create is atomic on POSIX, so exactly
153
+ * one process gets the claim and the other steps aside. A claim older than the
154
+ * hook budget is a crashed process rather than a live one, and is broken — the
155
+ * alternative is a stale lock file suppressing every question for that session
156
+ * for ever, which is worse than the duplicate this prevents.
157
+ */
158
+ const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000;
159
+ export function claimQuestionPush(sessionId, env, now = Date.now()) {
160
+ const file = claimPath(sessionId, env);
161
+ mkdirSync(path.dirname(file), { recursive: true });
162
+ try {
163
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
164
+ mode: 0o600,
165
+ flag: 'wx',
166
+ });
167
+ return true;
168
+ }
169
+ catch {
170
+ // Held. Break it only if whoever holds it cannot still be running.
171
+ try {
172
+ const held = JSON.parse(readFileSync(file, 'utf8'));
173
+ const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY;
174
+ if (age >= 0 && age < CLAIM_TTL_MS)
175
+ return false;
176
+ }
177
+ catch {
178
+ // Unreadable or corrupt: treat as abandoned.
179
+ }
180
+ rmSync(file, { force: true });
181
+ try {
182
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
183
+ mode: 0o600,
184
+ flag: 'wx',
185
+ });
186
+ return true;
187
+ }
188
+ catch {
189
+ // Someone else broke it first and won. One of us proceeding is the point.
190
+ return false;
191
+ }
192
+ }
193
+ }
194
+ export function releaseQuestionPush(sessionId, env) {
195
+ rmSync(claimPath(sessionId, env), { force: true });
196
+ }
197
+ function claimPath(sessionId, env) {
198
+ return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.claim`);
199
+ }
200
+ /**
201
+ * A question is stored so a later hook can push it, and it reaches us from a
202
+ * shell command, so its size is whatever the agent typed. The push itself is
203
+ * bounded by the 4096-byte APNs envelope; this bounds what sits on disk in the
204
+ * meantime, and keeps one runaway agent from writing megabytes per session.
205
+ */
206
+ const MAX_STORED_QUESTION_CHARS = 2000;
207
+ /** Records which session is working in a directory, for `notifai ask`. */
208
+ export function writeProjectSession(cwd, env, sessionId, now) {
209
+ const file = projectSessionPointerPath(cwd, env);
210
+ mkdirSync(path.dirname(file), { recursive: true });
211
+ writeFileSync(file, `${JSON.stringify({ session_id: sessionId, updated_at: now })}\n`, {
212
+ mode: 0o600,
213
+ });
214
+ }
215
+ /**
216
+ * Resolves the session working in `cwd`. Stale pointers are ignored rather than
217
+ * trusted: an id left by a session that ended days ago would send the question
218
+ * into state nothing is watching.
219
+ */
220
+ export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
221
+ const file = projectSessionPointerPath(cwd, env);
222
+ if (!existsSync(file))
223
+ return null;
224
+ try {
225
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
226
+ if (typeof parsed.session_id !== 'string' || parsed.session_id === '')
227
+ return null;
228
+ if (typeof parsed.updated_at === 'number' && now - parsed.updated_at > maxAgeMs)
229
+ return null;
230
+ return parsed.session_id;
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
236
+ /**
237
+ * Absent means the user is not at this machine's keyboard.
238
+ *
239
+ * OS idle time answers that directly, so where it exists it decides alone.
240
+ * Silence since the user's last prompt is only ever a proxy for it, and a poor
241
+ * one — it is wrong in both directions:
242
+ *
243
+ * - Too long: it counts the agent's own turn, so a user watching a build was
244
+ * read as absent and had the question pushed at them.
245
+ * - Too short: a session that has just been spawned always has a fresh
246
+ * prompt, so its FIRST question could never escalate however long its
247
+ * owner had been gone. That is the "kick off some agents and
248
+ * walk away" case this feature mainly exists for, and requiring both
249
+ * signals to agree is what broke it.
250
+ *
251
+ * The proxy therefore survives only as the fallback for machines with no idle
252
+ * source. There, never having seen a prompt is not evidence of absence — it is
253
+ * a missing `UserPromptSubmit` hook — so it resolves to "present".
254
+ *
255
+ * Answering from a companion device does not make the user present; only touching this
256
+ * machine does. Answering on a device is evidence of being away from it.
257
+ *
258
+ * All of which only matters if presence is being consulted at all. With
259
+ * `require_idle` off the user has said their whereabouts are not a
260
+ * precondition, so this stops guessing and answers yes.
261
+ */
262
+ export function isUserAway(state, config, now, idleSeconds) {
263
+ if (!config.require_idle.value)
264
+ return true;
265
+ const threshold = config.away_after_seconds.value;
266
+ if (idleSeconds !== null)
267
+ return idleSeconds >= threshold;
268
+ if (state.last_prompt_at === undefined)
269
+ return false;
270
+ const silence = now - state.last_prompt_at;
271
+ // Both signs of a clock jump produce a wrong answer here, and the fallback
272
+ // path has no monotonic reference to check against: a forward jump (NTP
273
+ // correction, VM resume) hijacks a terminal whose user is sitting right
274
+ // there, and a backward one suppresses escalation for someone genuinely
275
+ // gone. Neither delta is evidence of anything, so it resolves the way every
276
+ // other absence of evidence does — present.
277
+ if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS)
278
+ return false;
279
+ return silence >= threshold * 1000;
280
+ }
281
+ /**
282
+ * Beyond this, silence says more about the clock or an abandoned session file
283
+ * than about the user. Matches the project pointer's staleness horizon.
284
+ */
285
+ const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000;
286
+ /**
287
+ * Push a question and block for the answer.
288
+ *
289
+ * An answered question is closed immediately: the first answer wins, and the
290
+ * other devices must stop offering to change it. An unanswered one stays open,
291
+ * because nobody has acted on the silence yet and the next turn can still
292
+ * collect the answer with `notifai replies`.
293
+ */
294
+ async function askAndWait(ctx, options) {
295
+ const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`;
296
+ const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value;
297
+ // A draft carrying `reply` is rejected outright if it targets a device that
298
+ // cannot answer, so resolve the healthy companion platforms explicitly.
299
+ const answerable = await answerableDevices(ctx);
300
+ if (answerable.length === 0) {
301
+ return { error: 'no device can answer a question yet; leaving this to the terminal' };
302
+ }
303
+ const build = buildDraft(ctx.config, {
304
+ title: options.title,
305
+ body: options.body,
306
+ event: options.event,
307
+ lifecycle: { tier: 'needs_you' },
308
+ ...(options.session !== undefined ? { session: options.session } : {}),
309
+ device: answerable,
310
+ reply: true,
311
+ replyWindow: Math.max(60, options.windowSeconds),
312
+ ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
313
+ collapseKey,
314
+ level: 'time_sensitive',
315
+ });
316
+ if (!build.ok)
317
+ return { error: build.error };
318
+ const receipt = await ctx.client.submit({ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft }, 0);
319
+ // Record what is now live on the user's devices BEFORE blocking. If we only
320
+ // learned these ids after the wait, a question that timed out would leave no
321
+ // trace, and the user returning to the terminal could never retire it — the
322
+ // notification would stay answerable for an hour with nobody listening.
323
+ options.onSubmitted?.({
324
+ requestId: receipt.request_id,
325
+ collapseKey,
326
+ devices: answerable,
327
+ });
328
+ const result = await ctx.waitForFirstReply(receipt.request_id, timeoutSeconds);
329
+ if (result.replies.length > 0)
330
+ await closeQuietly(ctx, receipt.request_id);
331
+ return {
332
+ requestId: receipt.request_id,
333
+ collapseKey,
334
+ reply: result.replies[0] ?? null,
335
+ devices: answerable,
336
+ degraded: result.degraded === true,
337
+ };
338
+ }
339
+ /**
340
+ * Healthy companion devices that implement replies. Both the iOS app and the
341
+ * macOS app register reply categories and submit answers directly.
342
+ */
343
+ async function answerableDevices(ctx) {
344
+ const configured = ctx.config.devices.value;
345
+ const { devices } = await ctx.client.listDevices();
346
+ return devices
347
+ .filter((device) => (device.platform === 'ios' || device.platform === 'macos') &&
348
+ device.registration_healthy)
349
+ .filter((device) => configured === null || configured.includes(device.device_id))
350
+ .map((device) => device.device_id);
351
+ }
352
+ /**
353
+ * The session id this push is attributed to — the same one `send` carries.
354
+ *
355
+ * The hook has always known `session_id` and never passed it on, so two agents
356
+ * in separate worktrees produced identical notifications and the user could
357
+ * answer the wrong one's question. An exported `NOTIFAI_SESSION`
358
+ * still wins, for coherence rather than vanity: it is THE session id wherever
359
+ * it is set, so a session that exported one before launching must carry
360
+ * the same on its own sends and on the questions its hooks push. A name the
361
+ * user chose also outlives harness restarts, which a per-launch UUID cannot.
362
+ */
363
+ function sessionLabel(ctx, envelope) {
364
+ const explicit = ctx.env['NOTIFAI_SESSION'];
365
+ if (explicit !== undefined && explicit !== '')
366
+ return explicit;
367
+ return envelope.session_id;
368
+ }
369
+ /** Best effort: a question that outlives its hook is a nuisance, not a failure. */
370
+ async function closeQuietly(ctx, requestId) {
371
+ try {
372
+ await ctx.client.closeReplies(requestId);
373
+ }
374
+ catch {
375
+ // The window expires on its own; nothing here is worth failing a hook for.
376
+ }
377
+ }
378
+ /**
379
+ * Retire the question on every device it reached. A state change is not news
380
+ * (D-B): the retirement rides as a background state sync — no alert, no
381
+ * sound — carrying the shared collapse key so the companion removes the
382
+ * delivered question and marks it done. If the app cannot run, the stale
383
+ * question simply waits until next open, which beats a tombstone banner
384
+ * announcing what the user just did.
385
+ */
386
+ async function retire(ctx, collapseKey, title, body, endState, retiresRequestId, devices, session) {
387
+ const build = buildDraft(ctx.config, {
388
+ title,
389
+ body,
390
+ event: 'question_retired',
391
+ lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
392
+ ...(session !== undefined ? { session } : {}),
393
+ ...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
394
+ collapseKey,
395
+ level: 'passive',
396
+ });
397
+ if (!build.ok)
398
+ return false;
399
+ try {
400
+ await ctx.client.submit({ idempotency_key: `retire-${randomBytes(12).toString('base64url')}`, draft: build.draft }, 0);
401
+ return true;
402
+ }
403
+ catch {
404
+ // Same reasoning as closeQuietly: the window is already closed server-side.
405
+ return false;
406
+ }
407
+ }
408
+ /**
409
+ * The title a retirement carries. It is never rendered as a banner — the push
410
+ * is `content-available` only — but it is what a companion with no matching
411
+ * history entry has to fall back on, and it is what shows in server-side logs.
412
+ */
413
+ const RETIREMENT_TITLES = {
414
+ answered: 'Answered',
415
+ answered_elsewhere: 'Answered in the terminal',
416
+ expired: 'Question expired',
417
+ superseded: 'Replaced by a newer question',
418
+ };
419
+ /**
420
+ * Park a delivered question for retirement. Called at the moment we learn it is
421
+ * dead, which is frequently not a moment we can reach the network.
422
+ *
423
+ * Nothing is parked for a question that never reached a device: with no
424
+ * request_id there is nothing on any device to retire.
425
+ */
426
+ export function parkForRetirement(sessionId, env, pending, state) {
427
+ if (pending.request_id === undefined || pending.collapse_key === undefined)
428
+ return;
429
+ const current = readSessionState(sessionId, env);
430
+ const already = current.retiring ?? [];
431
+ if (already.some((entry) => entry.request_id === pending.request_id))
432
+ return;
433
+ writeSessionState(sessionId, env, {
434
+ ...current,
435
+ retiring: [
436
+ ...already,
437
+ {
438
+ request_id: pending.request_id,
439
+ collapse_key: pending.collapse_key,
440
+ question: pending.question,
441
+ state,
442
+ },
443
+ ],
444
+ });
445
+ }
446
+ /**
447
+ * Send every parked retirement, and forget the ones that landed. A failure
448
+ * leaves the entry in place for the next hook rather than losing it, which is
449
+ * the whole reason the queue exists.
450
+ */
451
+ export async function drainRetirements(ctx, sessionId, env, session) {
452
+ const queue = readSessionState(sessionId, env).retiring ?? [];
453
+ if (queue.length === 0)
454
+ return [];
455
+ const retired = [];
456
+ const remaining = [];
457
+ for (const entry of queue) {
458
+ await closeQuietly(ctx, entry.request_id);
459
+ const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, undefined, session);
460
+ if (sent)
461
+ retired.push(entry.request_id);
462
+ else
463
+ remaining.push(entry);
464
+ }
465
+ // Re-read rather than reusing the snapshot: `notifai ask` may have parked
466
+ // another question while these were in flight.
467
+ const current = readSessionState(sessionId, env);
468
+ const untouched = (current.retiring ?? []).filter((entry) => !retired.includes(entry.request_id));
469
+ writeSessionState(sessionId, env, { ...current, retiring: untouched.length > 0 ? untouched : [] });
470
+ return retired;
471
+ }
472
+ function orphanQueuePath(env) {
473
+ return path.join(stateDir(env), 'retire-queue.json');
474
+ }
475
+ function readOrphanQueue(env) {
476
+ const file = orphanQueuePath(env);
477
+ if (!existsSync(file))
478
+ return [];
479
+ try {
480
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
481
+ return Array.isArray(parsed) ? parsed : [];
482
+ }
483
+ catch {
484
+ // Same stance as session state: corruption fails closed to "nothing queued".
485
+ return [];
486
+ }
487
+ }
488
+ function writeOrphanQueue(env, queue) {
489
+ const file = orphanQueuePath(env);
490
+ mkdirSync(path.dirname(file), { recursive: true });
491
+ writeFileSync(file, `${JSON.stringify(queue, null, 2)}\n`, { mode: 0o600 });
492
+ }
493
+ /** Move retirements into the global queue; deduped so a retry costs nothing. */
494
+ export function orphanRetirements(env, entries, session, now) {
495
+ if (entries.length === 0)
496
+ return;
497
+ const queue = readOrphanQueue(env);
498
+ const known = new Set(queue.map((entry) => entry.request_id));
499
+ const added = entries
500
+ .filter((entry) => !known.has(entry.request_id))
501
+ .map((entry) => ({ ...entry, ...(session !== undefined ? { session } : {}), enqueued_at: now }));
502
+ if (added.length === 0)
503
+ return;
504
+ writeOrphanQueue(env, [...queue, ...added].slice(-ORPHAN_QUEUE_CAP));
505
+ }
506
+ /**
507
+ * Retire everything a dead session left behind. Failures stay queued for the
508
+ * next holder of a client; entries past the TTL are dropped as already dead.
509
+ */
510
+ export async function drainOrphanRetirements(ctx, env, now) {
511
+ const queue = readOrphanQueue(env);
512
+ if (queue.length === 0)
513
+ return [];
514
+ const done = [];
515
+ for (const entry of queue) {
516
+ const age = now - entry.enqueued_at;
517
+ // A negative age is a clock jump, not a fresh entry; retiring is idempotent
518
+ // and cheap, so treat it as due rather than letting it linger for ever.
519
+ if (age > ORPHAN_TTL_MS) {
520
+ done.push(entry.request_id);
521
+ continue;
522
+ }
523
+ await closeQuietly(ctx, entry.request_id);
524
+ const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, undefined, entry.session);
525
+ if (sent)
526
+ done.push(entry.request_id);
527
+ }
528
+ // Re-read: another session's SessionEnd may have queued more while these were
529
+ // in flight, and clobbering its write would recreate the very loss this
530
+ // queue exists to prevent.
531
+ const current = readOrphanQueue(env).filter((entry) => !done.includes(entry.request_id));
532
+ writeOrphanQueue(env, current);
533
+ return done;
534
+ }
535
+ // ---------------------------------------------------------------------------
536
+ // UserPromptSubmit — the user is at the keyboard
537
+ // ---------------------------------------------------------------------------
538
+ /**
539
+ * Records presence and retires anything still asking on companion devices. This is the
540
+ * "answered in the terminal" case from the original design: we cannot tell
541
+ * whether the new prompt answers the question, but we do not need to — the
542
+ * user being here is what makes the notification noise.
543
+ */
544
+ export async function handleUserPromptSubmit(ctx, envelope) {
545
+ const notes = [];
546
+ const sessionId = envelope.session_id;
547
+ if (!sessionId)
548
+ return { notes };
549
+ const state = readSessionState(sessionId, ctx.env);
550
+ const pending = state.pending;
551
+ // Deliberately drops `pending` — the user is here, so the question is the
552
+ // terminal's now. `retiring` is carried across: it is the only record of
553
+ // notifications still live on the devices, and this reset used to erase it.
554
+ writeSessionState(sessionId, ctx.env, {
555
+ last_prompt_at: ctx.now(),
556
+ ...(state.retiring !== undefined ? { retiring: state.retiring } : {}),
557
+ });
558
+ // The bridge that lets a plain `notifai ask` find its own session: an agent
559
+ // shell command gets no hook payload and no harness exports the id.
560
+ if (envelope.cwd !== undefined)
561
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now());
562
+ if (pending !== undefined)
563
+ parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere');
564
+ const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
565
+ const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
566
+ const swept = [...retired, ...orphaned];
567
+ if (swept.length > 0) {
568
+ notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
569
+ }
570
+ return { notes };
571
+ }
572
+ // ---------------------------------------------------------------------------
573
+ // Stop — the turn ended; escalate a registered question
574
+ // ---------------------------------------------------------------------------
575
+ /**
576
+ * How long an escalated question keeps accepting an answer after the turn
577
+ * ended. Long enough to survive a walk away from the desk, short enough that a
578
+ * forgotten question does not resurface days later as a live prompt.
579
+ */
580
+ const QUESTION_WINDOW_SECONDS = 3600;
581
+ /**
582
+ * Only engages for a question the agent explicitly registered with
583
+ * `notifai ask`. Guessing from the last assistant message was the alternative
584
+ * and it is not worth it: a false positive here hijacks the terminal.
585
+ */
586
+ export async function handleStop(ctx, envelope) {
587
+ const notes = [];
588
+ const sessionId = envelope.session_id;
589
+ if (!sessionId)
590
+ return { notes };
591
+ // Anything retired since the last hook is still live on the devices. This
592
+ // runs before every early return below, including the nagging guard: a queued
593
+ // retirement has nothing to do with whether *this* turn has a question to
594
+ // escalate, and the turn that supersedes a question is very often the one
595
+ // continuing from the previous answer.
596
+ const swept = [
597
+ ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
598
+ ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
599
+ ];
600
+ if (swept.length > 0) {
601
+ notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
602
+ }
603
+ // The harness sets this when it is already resuming us from a previous Stop
604
+ // decision. Pushing again here is how a question turns into nagging.
605
+ if (envelope.stop_hook_active === true) {
606
+ notes.push('already continuing from an answer; not asking again this turn');
607
+ return { notes };
608
+ }
609
+ const state = readSessionState(sessionId, ctx.env);
610
+ const pending = state.pending;
611
+ if (!pending)
612
+ return { notes };
613
+ if (pending.request_id !== undefined) {
614
+ // Already live on the user's devices from an earlier Stop; asking twice for
615
+ // one question is the nagging failure this feature exists to avoid.
616
+ notes.push(`already asked (${pending.request_id}); waiting for that answer`);
617
+ return { notes };
618
+ }
619
+ if (!ctx.config.ask_notifications.value)
620
+ return { notes };
621
+ if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
622
+ notes.push('you are at the keyboard; leaving the question in the terminal');
623
+ return { notes };
624
+ }
625
+ // Claim before the grace window, not after: two racing hooks would otherwise
626
+ // both wait, both find the user still absent, and both push.
627
+ // Real clock, deliberately, not `ctx.now` — the claim answers "is another
628
+ // process alive right now", which the injectable clock cannot speak to. It
629
+ // is also the only clock the *other* process shares.
630
+ if (!claimQuestionPush(sessionId, ctx.env)) {
631
+ notes.push('another hook is already handling this question');
632
+ return { notes };
633
+ }
634
+ try {
635
+ return await escalate(ctx, envelope, sessionId, state, pending, notes);
636
+ }
637
+ finally {
638
+ releaseQuestionPush(sessionId, ctx.env);
639
+ }
640
+ }
641
+ /** The escalation itself, split out so the claim is released on every path. */
642
+ async function escalate(ctx, envelope, sessionId, state, pending, notes) {
643
+ // Away right now, but the question still owes the user its terminal-first
644
+ // window before anything reaches their devices.
645
+ const grace = await awaitGrace(ctx, pending.asked_at ?? ctx.now());
646
+ if (grace === 'user-returned') {
647
+ notes.push('you came back before the wait elapsed; leaving the question in the terminal');
648
+ return { notes };
649
+ }
650
+ if (grace === 'no-signal') {
651
+ notes.push('no idle signal on this machine; asking now rather than holding the terminal');
652
+ }
653
+ const asked = await askAndWait(ctx, {
654
+ title: 'A question from your agent',
655
+ body: pending.question,
656
+ ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
657
+ event: 'agent_question',
658
+ session: sessionLabel(ctx, envelope),
659
+ // Outlives the block, and stays open on purpose: the answer is still
660
+ // useful to the next turn, which collects it with `notifai replies`.
661
+ windowSeconds: QUESTION_WINDOW_SECONDS,
662
+ onSubmitted: (live) => {
663
+ // Re-read: the drain at the top of this hook rewrote the queue, and the
664
+ // snapshot in `state` predates it.
665
+ writeSessionState(sessionId, ctx.env, {
666
+ ...readSessionState(sessionId, ctx.env),
667
+ pending: { ...pending, request_id: live.requestId, collapse_key: live.collapseKey },
668
+ });
669
+ },
670
+ });
671
+ if ('error' in asked) {
672
+ notes.push(asked.error);
673
+ return { notes };
674
+ }
675
+ // What survives replacing the pending record. `retiring` has to: dropping it
676
+ // is exactly the class of bug this guards against — a delivered notification
677
+ // whose ids no longer exist anywhere.
678
+ const current = readSessionState(sessionId, ctx.env);
679
+ const carried = {
680
+ ...(state.last_prompt_at !== undefined ? { last_prompt_at: state.last_prompt_at } : {}),
681
+ ...(current.retiring !== undefined ? { retiring: current.retiring } : {}),
682
+ };
683
+ if (!asked.reply) {
684
+ // Keep the pending record so a returning user's UserPromptSubmit can retire
685
+ // the notification that is still live on their devices. `request_id` being
686
+ // set is also what stops the next Stop pushing the same question again.
687
+ writeSessionState(sessionId, ctx.env, {
688
+ ...carried,
689
+ pending: { ...pending, request_id: asked.requestId, collapse_key: asked.collapseKey },
690
+ });
691
+ notes.push(asked.degraded
692
+ ? `could not reach the server to find out whether you answered; check with: notifai replies ${asked.requestId}`
693
+ : `no answer in time; retrieve it later with: notifai replies ${asked.requestId}`);
694
+ return { notes };
695
+ }
696
+ // Answered and closed — nothing left to retire.
697
+ writeSessionState(sessionId, ctx.env, carried);
698
+ await retire(ctx, asked.collapseKey, 'Answered', asked.reply.text, 'answered', asked.requestId, asked.devices, sessionLabel(ctx, envelope));
699
+ notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`);
700
+ return {
701
+ stdout: JSON.stringify({
702
+ decision: 'block',
703
+ // Harnesses render a Stop decision under an error-ish label, so this
704
+ // text must read as an answer on its own rather than as a failure.
705
+ reason: `NotifAI — the user answered from ${asked.reply.device_name}: ` +
706
+ `"${asked.reply.text}". Continue with that answer.`,
707
+ }),
708
+ notes,
709
+ };
710
+ }
711
+ // ---------------------------------------------------------------------------
712
+ // SessionEnd — local cleanup only
713
+ // ---------------------------------------------------------------------------
714
+ /**
715
+ * Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
716
+ * second, so this cannot make a network call. It drops the local marker — but
717
+ * first moves anything still live on the user's devices into the global
718
+ * retirement queue, because this file was the only record of those ids and no
719
+ * hook for this session will ever run again. A question whose
720
+ * agent just exited can receive no answer, so it is orphaned as `expired`.
721
+ */
722
+ export function handleSessionEnd(env, envelope, now = Date.now()) {
723
+ const notes = [];
724
+ const sessionId = envelope.session_id;
725
+ if (!sessionId)
726
+ return { notes };
727
+ const state = readSessionState(sessionId, env);
728
+ const orphans = [...(state.retiring ?? [])];
729
+ const pending = state.pending;
730
+ if (pending?.request_id !== undefined && pending.collapse_key !== undefined) {
731
+ orphans.push({
732
+ request_id: pending.request_id,
733
+ collapse_key: pending.collapse_key,
734
+ question: pending.question,
735
+ state: 'expired',
736
+ });
737
+ }
738
+ if (orphans.length > 0) {
739
+ const label = env['NOTIFAI_SESSION'];
740
+ orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now);
741
+ notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
742
+ }
743
+ clearSessionState(sessionId, env);
744
+ return { notes };
745
+ }
746
+ // ---------------------------------------------------------------------------
747
+ // ask — register a question for the turn's end
748
+ // ---------------------------------------------------------------------------
749
+ /**
750
+ * One session holds one live question, so registering a second one ends the
751
+ * first.
752
+ *
753
+ * This used to replace `pending` wholesale, which silently discarded the
754
+ * `request_id` and `collapse_key` of a question already delivered to the user's
755
+ * devices. Nothing else knows those ids, so the notification became
756
+ * unretirable — it sat on the lock screen for ever asking a question no answer
757
+ * could reach. That is where the stale pile-up came from.
758
+ *
759
+ * Supersession is keyed on the session, not the project: several agents may be
760
+ * running in one project at once, and one agent's new question killing another
761
+ * agent's live one would be worse than the staleness this fixes.
762
+ */
763
+ export function registerQuestion(sessionId, env, question, now = Date.now()) {
764
+ const state = readSessionState(sessionId, env);
765
+ if (state.pending !== undefined)
766
+ parkForRetirement(sessionId, env, state.pending, 'superseded');
767
+ // Re-read: parkForRetirement wrote the queue we must not clobber.
768
+ writeSessionState(sessionId, env, {
769
+ ...readSessionState(sessionId, env),
770
+ pending: {
771
+ asked_at: now,
772
+ ...question,
773
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
774
+ },
775
+ });
776
+ }
777
+ /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
778
+ export function parseHookInput(raw) {
779
+ if (raw.trim() === '')
780
+ return {};
781
+ try {
782
+ const parsed = JSON.parse(raw);
783
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
784
+ }
785
+ catch {
786
+ return {};
787
+ }
788
+ }
789
+ export function resolveHookConfig(cwd, env, sessionId) {
790
+ return loadConfig({ cwd, env, sessionId });
791
+ }
792
+ //# sourceMappingURL=hooks.js.map