@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/src/hooks.ts ADDED
@@ -0,0 +1,1099 @@
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 type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol'
5
+ import type { ApiClient } from './client.js'
6
+ import {
7
+ loadConfig,
8
+ projectSessionPointerPath,
9
+ sanitizeSessionId,
10
+ sessionConfigPath,
11
+ stateDir,
12
+ type CliConfig,
13
+ } from './config.js'
14
+ import { buildDraft } from './send.js'
15
+
16
+ /**
17
+ * Harness hook handlers.
18
+ *
19
+ * Claude Code, Codex and OpenCode all expose the same three joints: a blocking
20
+ * pre-approval hook that returns a decision, a turn-end hook, and a hook that
21
+ * fires when the user submits a prompt. That is enough to answer a blocked
22
+ * agent from the phone without the agent cooperating at all — no question
23
+ * detection, no pending state in a context window that compaction will eat.
24
+ *
25
+ * The load-bearing constraint is that these hooks are synchronous: while one
26
+ * blocks, the harness cannot show its own prompt either. So a hook may only
27
+ * take over when the user is demonstrably absent. Present user, or no evidence
28
+ * either way, means exit immediately and let the terminal do its job.
29
+ */
30
+
31
+ /** Fields we read from harness hook JSON. Everything else is passed through. */
32
+ export interface HookEnvelope {
33
+ session_id?: string
34
+ cwd?: string
35
+ hook_event_name?: string
36
+ /** Set by the harness when this Stop follows a previous Stop continuation. */
37
+ stop_hook_active?: boolean
38
+ /** Cursor's stable per-conversation identifier. */
39
+ conversation_id?: string
40
+ /** Cursor's project roots; the first is the hook's configuration root. */
41
+ workspace_roots?: string[]
42
+ /** Cursor increments this after each stop-hook automatic follow-up. */
43
+ loop_count?: number
44
+ }
45
+
46
+ export interface SessionState {
47
+ /** Epoch ms of the user's last prompt in this session — our presence signal. */
48
+ last_prompt_at?: number
49
+ /** Question registered by `notifai ask`, awaiting the turn to end. */
50
+ pending?: PendingQuestion
51
+ /**
52
+ * Questions that have been delivered to the user's devices and are now dead,
53
+ * but whose retirement has not been confirmed yet.
54
+ *
55
+ * A retirement needs a network call and the moment we learn a question is
56
+ * dead is not always a moment we can make one — `notifai ask` supersedes the
57
+ * previous question from a bare shell command, and the machine may be
58
+ * offline. Dropping the ids there is how a delivered question becomes
59
+ * permanently unretirable, so they are parked here instead and every later
60
+ * hook with a client drains them. Retirement is idempotent, so a duplicate
61
+ * attempt costs nothing and a missed one costs a stale notification for ever.
62
+ */
63
+ retiring?: RetiringQuestion[]
64
+ }
65
+
66
+ /** A delivered question awaiting its retirement push. */
67
+ export interface RetiringQuestion {
68
+ request_id: string
69
+ collapse_key: string
70
+ /** Shown if the companion has no history entry to correlate against. */
71
+ question: string
72
+ state: LifecycleEndState
73
+ }
74
+
75
+ /**
76
+ * A retirement that outlived its session.
77
+ *
78
+ * Per-session parking assumes some later hook in the SAME session will hold a
79
+ * client, and `SessionEnd` is exactly where that assumption breaks: it may not
80
+ * touch the network, and no hook for that session ever fires again. Deleting
81
+ * the state there lost the only copy of the delivered question's ids, so the
82
+ * phone kept an answerable question nobody was listening to. These entries are
83
+ * moved to a machine-global queue instead, drained by whichever session's hook
84
+ * next holds a client.
85
+ */
86
+ export interface OrphanRetirement extends RetiringQuestion {
87
+ /** Label of the session that asked, so the retirement sync matches its badge. */
88
+ session?: string
89
+ /** Epoch ms when the entry was orphaned; entries beyond the TTL are dropped. */
90
+ enqueued_at: number
91
+ }
92
+
93
+ /**
94
+ * Past this, the question's reply window (3600s) has long expired server-side
95
+ * and the companion shows it as dead on next open anyway; pushing a retirement
96
+ * sync for it is noise. Also the backstop that keeps an unreachable server
97
+ * from growing the queue for ever.
98
+ */
99
+ const ORPHAN_TTL_MS = 24 * 3600 * 1000
100
+
101
+ /** More orphans than this means something is looping; keep the newest. */
102
+ const ORPHAN_QUEUE_CAP = 50
103
+
104
+ export interface PendingQuestion {
105
+ question: string
106
+ /**
107
+ * Epoch ms when `notifai ask` registered this. The grace window runs from
108
+ * here, not from the turn's end: a question the agent asked five minutes ago
109
+ * while it kept working has already served its wait in the terminal.
110
+ */
111
+ asked_at?: number
112
+ /**
113
+ * Canonical answer labels, already split and validated by `notifai ask`.
114
+ * Stored as a list rather than a comma-joined string so a label containing a
115
+ * comma survives the round trip.
116
+ */
117
+ choices?: string[]
118
+ /** Set once the question has actually been pushed, so it can be retired. */
119
+ request_id?: string
120
+ collapse_key?: string
121
+ }
122
+
123
+ /** How often the grace window rechecks whether the user has come back. */
124
+ const GRACE_POLL_MS = 5_000
125
+
126
+ /**
127
+ * Total seconds a Stop hook may spend blocking. Both harnesses kill a command
128
+ * hook at 600s, and a killed hook loses an answer the user has already given,
129
+ * so the grace window yields to the reply wait rather than the other way round.
130
+ */
131
+ const STOP_BUDGET_SECONDS = 480
132
+
133
+ export type GraceOutcome =
134
+ /** The window elapsed with the user still gone; escalate. */
135
+ | 'absent'
136
+ /** The user touched the machine; the terminal is theirs. */
137
+ | 'user-returned'
138
+ /** No idle source, so waiting would hold a terminal we cannot monitor. */
139
+ | 'no-signal'
140
+
141
+ /**
142
+ * The terminal-first wait: the question sits in the terminal for
143
+ * `ask_grace_seconds` from when it was sent, and only then reaches companion
144
+ * devices.
145
+ *
146
+ * Holding a blocking Stop hook open is normally hostile — while it blocks, the
147
+ * harness cannot show its prompt either, so a user wanting to answer locally is
148
+ * locked out. What makes it safe is that the wait is abandoned the moment the
149
+ * user touches the keyboard or mouse. The block therefore only ever persists
150
+ * while they are demonstrably not using the machine, which costs them nothing.
151
+ *
152
+ * That safety depends entirely on the idle signal, so with no idle source this
153
+ * refuses to wait at all rather than holding a terminal it cannot monitor.
154
+ *
155
+ * With `require_idle` off the whole calculus changes: the user has said they
156
+ * want the question to reach them whether or not they are at the keyboard, so
157
+ * there is nothing to watch for and this becomes what its name always claimed —
158
+ * a plain timer. It then works on machines with no idle source too.
159
+ */
160
+ export async function awaitGrace(ctx: HookContext, askedAt: number): Promise<GraceOutcome> {
161
+ const threshold = ctx.config.away_after_seconds.value
162
+ // Never let the grace window eat the reply wait's share of the hook budget.
163
+ const graceSeconds = Math.min(
164
+ ctx.config.ask_grace_seconds.value,
165
+ Math.max(0, STOP_BUDGET_SECONDS - ctx.config.hook_reply_timeout_seconds.value),
166
+ )
167
+ // `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
168
+ // future would wait far past the hook's budget, one from the distant past
169
+ // would skip the terminal-first window the user asked for entirely. Anything
170
+ // outside a plausible range restarts the window from now.
171
+ const elapsed = ctx.now() - askedAt
172
+ const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now()
173
+ const deadline = start + graceSeconds * 1000
174
+
175
+ for (;;) {
176
+ if (ctx.config.require_idle.value) {
177
+ const idle = ctx.idleSeconds()
178
+ if (idle === null) return 'no-signal'
179
+ if (idle < threshold) return 'user-returned'
180
+ }
181
+ if (ctx.now() >= deadline) return 'absent'
182
+ await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())))
183
+ }
184
+ }
185
+
186
+ export interface HookOutcome {
187
+ /** Written to stdout verbatim — the harness parses this as the decision. */
188
+ stdout?: string
189
+ /** Diagnostics; harnesses surface hook stderr in the transcript. */
190
+ notes: string[]
191
+ }
192
+
193
+ function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
194
+ return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
195
+ }
196
+
197
+ export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): SessionState {
198
+ const file = sessionStatePath(sessionId, env)
199
+ if (!existsSync(file)) return {}
200
+ try {
201
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
202
+ return typeof parsed === 'object' && parsed !== null ? (parsed as SessionState) : {}
203
+ } catch {
204
+ // A corrupt marker must not wedge the harness; treat it as "no evidence",
205
+ // which fails closed onto ordinary terminal behaviour.
206
+ return {}
207
+ }
208
+ }
209
+
210
+ export function writeSessionState(
211
+ sessionId: string,
212
+ env: NodeJS.ProcessEnv,
213
+ state: SessionState,
214
+ ): void {
215
+ const file = sessionStatePath(sessionId, env)
216
+ mkdirSync(path.dirname(file), { recursive: true })
217
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
218
+ }
219
+
220
+ export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
221
+ rmSync(sessionStatePath(sessionId, env), { force: true })
222
+ // The session override lives in a sibling file; leaving it behind meant a
223
+ // later session reusing the id silently inherited `ask_notifications = false`.
224
+ rmSync(sessionConfigPath(sessionId, env), { force: true })
225
+ }
226
+
227
+ /**
228
+ * Session state a crashed harness left behind.
229
+ *
230
+ * `SessionEnd` removes both the marker and the session override, but a harness
231
+ * that crashes or is killed never reaches it. At roughly a hundred sessions a
232
+ * day that is tens of thousands of files a year, none of which anything reads.
233
+ *
234
+ * Opportunistic rather than scheduled: hooks are the only thing that runs, so
235
+ * a hook is where this has to live. It is rate-limited by its own stamp file
236
+ * so the common case costs one `stat`, not a directory walk — a Stop hook is
237
+ * on the critical path of every turn.
238
+ */
239
+ const SESSION_PRUNE_AFTER_MS = 7 * 24 * 3600 * 1000
240
+ const SESSION_PRUNE_INTERVAL_MS = 24 * 3600 * 1000
241
+
242
+ export function pruneAbandonedSessions(
243
+ env: NodeJS.ProcessEnv,
244
+ now: number = Date.now(),
245
+ maxAgeMs: number = SESSION_PRUNE_AFTER_MS,
246
+ ): number {
247
+ const directory = path.join(stateDir(env), 'sessions')
248
+ const stamp = path.join(stateDir(env), 'last-prune')
249
+ try {
250
+ if (existsSync(stamp) && now - statSync(stamp).mtimeMs < SESSION_PRUNE_INTERVAL_MS) return 0
251
+ mkdirSync(path.dirname(stamp), { recursive: true })
252
+ writeFileSync(stamp, '', { mode: 0o600 })
253
+ if (!existsSync(directory)) return 0
254
+
255
+ let removed = 0
256
+ for (const name of readdirSync(directory)) {
257
+ const file = path.join(directory, name)
258
+ try {
259
+ const age = now - statSync(file).mtimeMs
260
+ // A negative age means the clock moved, not that the file is old.
261
+ // Deleting live session state on an NTP correction would lose a
262
+ // question already on the user's phone.
263
+ if (age <= maxAgeMs) continue
264
+ rmSync(file, { force: true })
265
+ removed += 1
266
+ } catch {
267
+ // A file that vanished under us, or one we may not read. Neither is
268
+ // worth failing a hook for, and the next pass will see it again.
269
+ }
270
+ }
271
+ return removed
272
+ } catch {
273
+ // Housekeeping must never be the reason a turn fails.
274
+ return 0
275
+ }
276
+ }
277
+
278
+ /**
279
+ * One question, one push, even with two Stop hooks racing.
280
+ *
281
+ * Path-independent hook ownership stops the *usual* cause of two handlers
282
+ * firing, but it cannot stop every one — two harnesses in one directory, or an
283
+ * install this build does not recognise. Both processes would read the same
284
+ * pending question, see no `request_id`, and both escalate: one question, two
285
+ * notifications.
286
+ *
287
+ * `wx` is the whole mechanism. Exclusive create is atomic on POSIX, so exactly
288
+ * one process gets the claim and the other steps aside. A claim older than the
289
+ * hook budget is a crashed process rather than a live one, and is broken — the
290
+ * alternative is a stale lock file suppressing every question for that session
291
+ * for ever, which is worse than the duplicate this prevents.
292
+ */
293
+ const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
294
+
295
+ export function claimQuestionPush(
296
+ sessionId: string,
297
+ env: NodeJS.ProcessEnv,
298
+ now: number = Date.now(),
299
+ ): boolean {
300
+ const file = claimPath(sessionId, env)
301
+ mkdirSync(path.dirname(file), { recursive: true })
302
+ try {
303
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
304
+ mode: 0o600,
305
+ flag: 'wx',
306
+ })
307
+ return true
308
+ } catch {
309
+ // Held. Break it only if whoever holds it cannot still be running.
310
+ try {
311
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
312
+ const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
313
+ if (age >= 0 && age < CLAIM_TTL_MS) return false
314
+ } catch {
315
+ // Unreadable or corrupt: treat as abandoned.
316
+ }
317
+ rmSync(file, { force: true })
318
+ try {
319
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
320
+ mode: 0o600,
321
+ flag: 'wx',
322
+ })
323
+ return true
324
+ } catch {
325
+ // Someone else broke it first and won. One of us proceeding is the point.
326
+ return false
327
+ }
328
+ }
329
+ }
330
+
331
+ export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
332
+ rmSync(claimPath(sessionId, env), { force: true })
333
+ }
334
+
335
+ function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
336
+ return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.claim`)
337
+ }
338
+
339
+ /**
340
+ * A question is stored so a later hook can push it, and it reaches us from a
341
+ * shell command, so its size is whatever the agent typed. The push itself is
342
+ * bounded by the 4096-byte APNs envelope; this bounds what sits on disk in the
343
+ * meantime, and keeps one runaway agent from writing megabytes per session.
344
+ */
345
+ const MAX_STORED_QUESTION_CHARS = 2000
346
+
347
+ /** Records which session is working in a directory, for `notifai ask`. */
348
+ export function writeProjectSession(
349
+ cwd: string,
350
+ env: NodeJS.ProcessEnv,
351
+ sessionId: string,
352
+ now: number,
353
+ ): void {
354
+ const file = projectSessionPointerPath(cwd, env)
355
+ mkdirSync(path.dirname(file), { recursive: true })
356
+ writeFileSync(file, `${JSON.stringify({ session_id: sessionId, updated_at: now })}\n`, {
357
+ mode: 0o600,
358
+ })
359
+ }
360
+
361
+ /**
362
+ * Resolves the session working in `cwd`. Stale pointers are ignored rather than
363
+ * trusted: an id left by a session that ended days ago would send the question
364
+ * into state nothing is watching.
365
+ */
366
+ export function readProjectSession(
367
+ cwd: string,
368
+ env: NodeJS.ProcessEnv,
369
+ now: number,
370
+ maxAgeMs = 24 * 3600 * 1000,
371
+ ): string | null {
372
+ const file = projectSessionPointerPath(cwd, env)
373
+ if (!existsSync(file)) return null
374
+ try {
375
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
376
+ session_id?: unknown
377
+ updated_at?: unknown
378
+ }
379
+ if (typeof parsed.session_id !== 'string' || parsed.session_id === '') return null
380
+ if (typeof parsed.updated_at === 'number' && now - parsed.updated_at > maxAgeMs) return null
381
+ return parsed.session_id
382
+ } catch {
383
+ return null
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Absent means the user is not at this machine's keyboard.
389
+ *
390
+ * OS idle time answers that directly, so where it exists it decides alone.
391
+ * Silence since the user's last prompt is only ever a proxy for it, and a poor
392
+ * one — it is wrong in both directions:
393
+ *
394
+ * - Too long: it counts the agent's own turn, so a user watching a build was
395
+ * read as absent and had the question pushed at them.
396
+ * - Too short: a session that has just been spawned always has a fresh
397
+ * prompt, so its FIRST question could never escalate however long its
398
+ * owner had been gone. That is the "kick off some agents and
399
+ * walk away" case this feature mainly exists for, and requiring both
400
+ * signals to agree is what broke it.
401
+ *
402
+ * The proxy therefore survives only as the fallback for machines with no idle
403
+ * source. There, never having seen a prompt is not evidence of absence — it is
404
+ * a missing `UserPromptSubmit` hook — so it resolves to "present".
405
+ *
406
+ * Answering from a companion device does not make the user present; only touching this
407
+ * machine does. Answering on a device is evidence of being away from it.
408
+ *
409
+ * All of which only matters if presence is being consulted at all. With
410
+ * `require_idle` off the user has said their whereabouts are not a
411
+ * precondition, so this stops guessing and answers yes.
412
+ */
413
+ export function isUserAway(
414
+ state: SessionState,
415
+ config: CliConfig,
416
+ now: number,
417
+ idleSeconds: number | null,
418
+ ): boolean {
419
+ if (!config.require_idle.value) return true
420
+ const threshold = config.away_after_seconds.value
421
+ if (idleSeconds !== null) return idleSeconds >= threshold
422
+ if (state.last_prompt_at === undefined) return false
423
+ const silence = now - state.last_prompt_at
424
+ // Both signs of a clock jump produce a wrong answer here, and the fallback
425
+ // path has no monotonic reference to check against: a forward jump (NTP
426
+ // correction, VM resume) hijacks a terminal whose user is sitting right
427
+ // there, and a backward one suppresses escalation for someone genuinely
428
+ // gone. Neither delta is evidence of anything, so it resolves the way every
429
+ // other absence of evidence does — present.
430
+ if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS) return false
431
+ return silence >= threshold * 1000
432
+ }
433
+
434
+ /**
435
+ * Beyond this, silence says more about the clock or an abandoned session file
436
+ * than about the user. Matches the project pointer's staleness horizon.
437
+ */
438
+ const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000
439
+
440
+ export interface HookContext {
441
+ client: ApiClient
442
+ config: CliConfig
443
+ env: NodeJS.ProcessEnv
444
+ now: () => number
445
+ /** Seconds since the last keyboard/mouse event, or null if unknowable. */
446
+ idleSeconds: () => number | null
447
+ /** Injected so tests advance a virtual clock instead of sleeping. */
448
+ sleep: (milliseconds: number) => Promise<void>
449
+ /** Bounded wait for the first reply; injected so tests do not sleep. */
450
+ waitForFirstReply: (
451
+ requestId: string,
452
+ timeoutSeconds: number,
453
+ ) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
454
+ }
455
+
456
+ interface AskResult {
457
+ requestId: string
458
+ collapseKey: string
459
+ reply: ReplyView | null
460
+ /** The devices the question went to; retirement must not reach any other. */
461
+ devices: string[]
462
+ /** The wait ended amid network failures, so "no answer" is unproven. */
463
+ degraded: boolean
464
+ }
465
+
466
+ /**
467
+ * Push a question and block for the answer.
468
+ *
469
+ * An answered question is closed immediately: the first answer wins, and the
470
+ * other devices must stop offering to change it. An unanswered one stays open,
471
+ * because nobody has acted on the silence yet and the next turn can still
472
+ * collect the answer with `notifai replies`.
473
+ */
474
+ async function askAndWait(
475
+ ctx: HookContext,
476
+ options: {
477
+ title: string
478
+ body: string
479
+ choices?: string[]
480
+ event: string
481
+ /** Which agent is asking; two of them must not look alike. */
482
+ session?: string | undefined
483
+ /** How long the server keeps accepting an answer. */
484
+ windowSeconds: number
485
+ /** Called once the question is live, before the block begins. */
486
+ onSubmitted?: (live: { requestId: string; collapseKey: string; devices: string[] }) => void
487
+ },
488
+ ): Promise<AskResult | { error: string }> {
489
+ const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
490
+ const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
491
+ // A draft carrying `reply` is rejected outright if it targets a device that
492
+ // cannot answer, so resolve the healthy companion platforms explicitly.
493
+ const answerable = await answerableDevices(ctx)
494
+ if (answerable.length === 0) {
495
+ return { error: 'no device can answer a question yet; leaving this to the terminal' }
496
+ }
497
+ const build = buildDraft(ctx.config, {
498
+ title: options.title,
499
+ body: options.body,
500
+ event: options.event,
501
+ lifecycle: { tier: 'needs_you' },
502
+ ...(options.session !== undefined ? { session: options.session } : {}),
503
+ device: answerable,
504
+ reply: true,
505
+ replyWindow: Math.max(60, options.windowSeconds),
506
+ ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
507
+ collapseKey,
508
+ level: 'time_sensitive',
509
+ })
510
+ if (!build.ok) return { error: build.error }
511
+
512
+ const receipt = await ctx.client.submit(
513
+ { idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft },
514
+ 0,
515
+ )
516
+ // Record what is now live on the user's devices BEFORE blocking. If we only
517
+ // learned these ids after the wait, a question that timed out would leave no
518
+ // trace, and the user returning to the terminal could never retire it — the
519
+ // notification would stay answerable for an hour with nobody listening.
520
+ options.onSubmitted?.({
521
+ requestId: receipt.request_id,
522
+ collapseKey,
523
+ devices: answerable,
524
+ })
525
+ const result = await ctx.waitForFirstReply(receipt.request_id, timeoutSeconds)
526
+ if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
527
+ return {
528
+ requestId: receipt.request_id,
529
+ collapseKey,
530
+ reply: result.replies[0] ?? null,
531
+ devices: answerable,
532
+ degraded: result.degraded === true,
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Healthy companion devices that implement replies. Both the iOS app and the
538
+ * macOS app register reply categories and submit answers directly.
539
+ */
540
+ async function answerableDevices(ctx: HookContext): Promise<string[]> {
541
+ const configured = ctx.config.devices.value
542
+ const { devices } = await ctx.client.listDevices()
543
+ return devices
544
+ .filter(
545
+ (device) =>
546
+ (device.platform === 'ios' || device.platform === 'macos') &&
547
+ device.registration_healthy,
548
+ )
549
+ .filter((device) => configured === null || configured.includes(device.device_id))
550
+ .map((device) => device.device_id)
551
+ }
552
+
553
+ /**
554
+ * The session id this push is attributed to — the same one `send` carries.
555
+ *
556
+ * The hook has always known `session_id` and never passed it on, so two agents
557
+ * in separate worktrees produced identical notifications and the user could
558
+ * answer the wrong one's question. An exported `NOTIFAI_SESSION`
559
+ * still wins, for coherence rather than vanity: it is THE session id wherever
560
+ * it is set, so a session that exported one before launching must carry
561
+ * the same on its own sends and on the questions its hooks push. A name the
562
+ * user chose also outlives harness restarts, which a per-launch UUID cannot.
563
+ */
564
+ function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefined {
565
+ const explicit = ctx.env['NOTIFAI_SESSION']
566
+ if (explicit !== undefined && explicit !== '') return explicit
567
+ return envelope.session_id
568
+ }
569
+
570
+ /**
571
+ * Everything retirement needs. Narrower than a HookContext on purpose:
572
+ * `notifai ask` supersedes the previous question and it is a plain command with
573
+ * no hook payload, no idle probe and nothing to sleep for.
574
+ */
575
+ export type RetireDeps = Pick<HookContext, 'client' | 'config'>
576
+
577
+ /** Best effort: a question that outlives its hook is a nuisance, not a failure. */
578
+ async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
579
+ try {
580
+ await ctx.client.closeReplies(requestId)
581
+ } catch {
582
+ // The window expires on its own; nothing here is worth failing a hook for.
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Retire the question on every device it reached. A state change is not news
588
+ * (D-B): the retirement rides as a background state sync — no alert, no
589
+ * sound — carrying the shared collapse key so the companion removes the
590
+ * delivered question and marks it done. If the app cannot run, the stale
591
+ * question simply waits until next open, which beats a tombstone banner
592
+ * announcing what the user just did.
593
+ */
594
+ async function retire(
595
+ ctx: RetireDeps,
596
+ collapseKey: string,
597
+ title: string,
598
+ body: string,
599
+ endState: LifecycleEndState,
600
+ retiresRequestId: string,
601
+ devices?: string[],
602
+ session?: string | undefined,
603
+ ): Promise<boolean> {
604
+ const build = buildDraft(ctx.config, {
605
+ title,
606
+ body,
607
+ event: 'question_retired',
608
+ lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
609
+ ...(session !== undefined ? { session } : {}),
610
+ ...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
611
+ collapseKey,
612
+ level: 'passive',
613
+ })
614
+ if (!build.ok) return false
615
+ try {
616
+ await ctx.client.submit(
617
+ { idempotency_key: `retire-${randomBytes(12).toString('base64url')}`, draft: build.draft },
618
+ 0,
619
+ )
620
+ return true
621
+ } catch {
622
+ // Same reasoning as closeQuietly: the window is already closed server-side.
623
+ return false
624
+ }
625
+ }
626
+
627
+ /**
628
+ * The title a retirement carries. It is never rendered as a banner — the push
629
+ * is `content-available` only — but it is what a companion with no matching
630
+ * history entry has to fall back on, and it is what shows in server-side logs.
631
+ */
632
+ const RETIREMENT_TITLES: Record<LifecycleEndState, string> = {
633
+ answered: 'Answered',
634
+ answered_elsewhere: 'Answered in the terminal',
635
+ expired: 'Question expired',
636
+ superseded: 'Replaced by a newer question',
637
+ }
638
+
639
+ /**
640
+ * Park a delivered question for retirement. Called at the moment we learn it is
641
+ * dead, which is frequently not a moment we can reach the network.
642
+ *
643
+ * Nothing is parked for a question that never reached a device: with no
644
+ * request_id there is nothing on any device to retire.
645
+ */
646
+ export function parkForRetirement(
647
+ sessionId: string,
648
+ env: NodeJS.ProcessEnv,
649
+ pending: PendingQuestion,
650
+ state: LifecycleEndState,
651
+ ): void {
652
+ if (pending.request_id === undefined || pending.collapse_key === undefined) return
653
+ const current = readSessionState(sessionId, env)
654
+ const already = current.retiring ?? []
655
+ if (already.some((entry) => entry.request_id === pending.request_id)) return
656
+ writeSessionState(sessionId, env, {
657
+ ...current,
658
+ retiring: [
659
+ ...already,
660
+ {
661
+ request_id: pending.request_id,
662
+ collapse_key: pending.collapse_key,
663
+ question: pending.question,
664
+ state,
665
+ },
666
+ ],
667
+ })
668
+ }
669
+
670
+ /**
671
+ * Send every parked retirement, and forget the ones that landed. A failure
672
+ * leaves the entry in place for the next hook rather than losing it, which is
673
+ * the whole reason the queue exists.
674
+ */
675
+ export async function drainRetirements(
676
+ ctx: RetireDeps,
677
+ sessionId: string,
678
+ env: NodeJS.ProcessEnv,
679
+ session?: string | undefined,
680
+ ): Promise<string[]> {
681
+ const queue = readSessionState(sessionId, env).retiring ?? []
682
+ if (queue.length === 0) return []
683
+
684
+ const retired: string[] = []
685
+ const remaining: RetiringQuestion[] = []
686
+ for (const entry of queue) {
687
+ await closeQuietly(ctx, entry.request_id)
688
+ const sent = await retire(
689
+ ctx,
690
+ entry.collapse_key,
691
+ RETIREMENT_TITLES[entry.state],
692
+ entry.question,
693
+ entry.state,
694
+ entry.request_id,
695
+ undefined,
696
+ session,
697
+ )
698
+ if (sent) retired.push(entry.request_id)
699
+ else remaining.push(entry)
700
+ }
701
+
702
+ // Re-read rather than reusing the snapshot: `notifai ask` may have parked
703
+ // another question while these were in flight.
704
+ const current = readSessionState(sessionId, env)
705
+ const untouched = (current.retiring ?? []).filter(
706
+ (entry) => !retired.includes(entry.request_id),
707
+ )
708
+ writeSessionState(sessionId, env, { ...current, retiring: untouched.length > 0 ? untouched : [] })
709
+ return retired
710
+ }
711
+
712
+ function orphanQueuePath(env: NodeJS.ProcessEnv): string {
713
+ return path.join(stateDir(env), 'retire-queue.json')
714
+ }
715
+
716
+ function readOrphanQueue(env: NodeJS.ProcessEnv): OrphanRetirement[] {
717
+ const file = orphanQueuePath(env)
718
+ if (!existsSync(file)) return []
719
+ try {
720
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
721
+ return Array.isArray(parsed) ? (parsed as OrphanRetirement[]) : []
722
+ } catch {
723
+ // Same stance as session state: corruption fails closed to "nothing queued".
724
+ return []
725
+ }
726
+ }
727
+
728
+ function writeOrphanQueue(env: NodeJS.ProcessEnv, queue: OrphanRetirement[]): void {
729
+ const file = orphanQueuePath(env)
730
+ mkdirSync(path.dirname(file), { recursive: true })
731
+ writeFileSync(file, `${JSON.stringify(queue, null, 2)}\n`, { mode: 0o600 })
732
+ }
733
+
734
+ /** Move retirements into the global queue; deduped so a retry costs nothing. */
735
+ export function orphanRetirements(
736
+ env: NodeJS.ProcessEnv,
737
+ entries: RetiringQuestion[],
738
+ session: string | undefined,
739
+ now: number,
740
+ ): void {
741
+ if (entries.length === 0) return
742
+ const queue = readOrphanQueue(env)
743
+ const known = new Set(queue.map((entry) => entry.request_id))
744
+ const added = entries
745
+ .filter((entry) => !known.has(entry.request_id))
746
+ .map((entry) => ({ ...entry, ...(session !== undefined ? { session } : {}), enqueued_at: now }))
747
+ if (added.length === 0) return
748
+ writeOrphanQueue(env, [...queue, ...added].slice(-ORPHAN_QUEUE_CAP))
749
+ }
750
+
751
+ /**
752
+ * Retire everything a dead session left behind. Failures stay queued for the
753
+ * next holder of a client; entries past the TTL are dropped as already dead.
754
+ */
755
+ export async function drainOrphanRetirements(
756
+ ctx: RetireDeps,
757
+ env: NodeJS.ProcessEnv,
758
+ now: number,
759
+ ): Promise<string[]> {
760
+ const queue = readOrphanQueue(env)
761
+ if (queue.length === 0) return []
762
+
763
+ const done: string[] = []
764
+ for (const entry of queue) {
765
+ const age = now - entry.enqueued_at
766
+ // A negative age is a clock jump, not a fresh entry; retiring is idempotent
767
+ // and cheap, so treat it as due rather than letting it linger for ever.
768
+ if (age > ORPHAN_TTL_MS) {
769
+ done.push(entry.request_id)
770
+ continue
771
+ }
772
+ await closeQuietly(ctx, entry.request_id)
773
+ const sent = await retire(
774
+ ctx,
775
+ entry.collapse_key,
776
+ RETIREMENT_TITLES[entry.state],
777
+ entry.question,
778
+ entry.state,
779
+ entry.request_id,
780
+ undefined,
781
+ entry.session,
782
+ )
783
+ if (sent) done.push(entry.request_id)
784
+ }
785
+
786
+ // Re-read: another session's SessionEnd may have queued more while these were
787
+ // in flight, and clobbering its write would recreate the very loss this
788
+ // queue exists to prevent.
789
+ const current = readOrphanQueue(env).filter((entry) => !done.includes(entry.request_id))
790
+ writeOrphanQueue(env, current)
791
+ return done
792
+ }
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // UserPromptSubmit — the user is at the keyboard
796
+ // ---------------------------------------------------------------------------
797
+
798
+ /**
799
+ * Records presence and retires anything still asking on companion devices. This is the
800
+ * "answered in the terminal" case from the original design: we cannot tell
801
+ * whether the new prompt answers the question, but we do not need to — the
802
+ * user being here is what makes the notification noise.
803
+ */
804
+ export async function handleUserPromptSubmit(
805
+ ctx: HookContext,
806
+ envelope: HookEnvelope,
807
+ ): Promise<HookOutcome> {
808
+ const notes: string[] = []
809
+ const sessionId = envelope.session_id
810
+ if (!sessionId) return { notes }
811
+
812
+ const state = readSessionState(sessionId, ctx.env)
813
+ const pending = state.pending
814
+ // Deliberately drops `pending` — the user is here, so the question is the
815
+ // terminal's now. `retiring` is carried across: it is the only record of
816
+ // notifications still live on the devices, and this reset used to erase it.
817
+ writeSessionState(sessionId, ctx.env, {
818
+ last_prompt_at: ctx.now(),
819
+ ...(state.retiring !== undefined ? { retiring: state.retiring } : {}),
820
+ })
821
+ // The bridge that lets a plain `notifai ask` find its own session: an agent
822
+ // shell command gets no hook payload and no harness exports the id.
823
+ if (envelope.cwd !== undefined) writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now())
824
+
825
+ if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
826
+ const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
827
+ const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
828
+ const swept = [...retired, ...orphaned]
829
+ if (swept.length > 0) {
830
+ notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
831
+ }
832
+ return { notes }
833
+ }
834
+
835
+ // ---------------------------------------------------------------------------
836
+ // Stop — the turn ended; escalate a registered question
837
+ // ---------------------------------------------------------------------------
838
+
839
+ /**
840
+ * How long an escalated question keeps accepting an answer after the turn
841
+ * ended. Long enough to survive a walk away from the desk, short enough that a
842
+ * forgotten question does not resurface days later as a live prompt.
843
+ */
844
+ const QUESTION_WINDOW_SECONDS = 3600
845
+
846
+ /**
847
+ * Only engages for a question the agent explicitly registered with
848
+ * `notifai ask`. Guessing from the last assistant message was the alternative
849
+ * and it is not worth it: a false positive here hijacks the terminal.
850
+ */
851
+ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Promise<HookOutcome> {
852
+ const notes: string[] = []
853
+ const sessionId = envelope.session_id
854
+ if (!sessionId) return { notes }
855
+
856
+ // Anything retired since the last hook is still live on the devices. This
857
+ // runs before every early return below, including the nagging guard: a queued
858
+ // retirement has nothing to do with whether *this* turn has a question to
859
+ // escalate, and the turn that supersedes a question is very often the one
860
+ // continuing from the previous answer.
861
+ const swept = [
862
+ ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
863
+ ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
864
+ ]
865
+ if (swept.length > 0) {
866
+ notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
867
+ }
868
+
869
+ // The harness sets this when it is already resuming us from a previous Stop
870
+ // decision. Pushing again here is how a question turns into nagging.
871
+ if (envelope.stop_hook_active === true) {
872
+ notes.push('already continuing from an answer; not asking again this turn')
873
+ return { notes }
874
+ }
875
+
876
+ const state = readSessionState(sessionId, ctx.env)
877
+ const pending = state.pending
878
+ if (!pending) return { notes }
879
+ if (pending.request_id !== undefined) {
880
+ // Already live on the user's devices from an earlier Stop; asking twice for
881
+ // one question is the nagging failure this feature exists to avoid.
882
+ notes.push(`already asked (${pending.request_id}); waiting for that answer`)
883
+ return { notes }
884
+ }
885
+ if (!ctx.config.ask_notifications.value) return { notes }
886
+ if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
887
+ notes.push('you are at the keyboard; leaving the question in the terminal')
888
+ return { notes }
889
+ }
890
+
891
+ // Claim before the grace window, not after: two racing hooks would otherwise
892
+ // both wait, both find the user still absent, and both push.
893
+ // Real clock, deliberately, not `ctx.now` — the claim answers "is another
894
+ // process alive right now", which the injectable clock cannot speak to. It
895
+ // is also the only clock the *other* process shares.
896
+ if (!claimQuestionPush(sessionId, ctx.env)) {
897
+ notes.push('another hook is already handling this question')
898
+ return { notes }
899
+ }
900
+ try {
901
+ return await escalate(ctx, envelope, sessionId, state, pending, notes)
902
+ } finally {
903
+ releaseQuestionPush(sessionId, ctx.env)
904
+ }
905
+ }
906
+
907
+ /** The escalation itself, split out so the claim is released on every path. */
908
+ async function escalate(
909
+ ctx: HookContext,
910
+ envelope: HookEnvelope,
911
+ sessionId: string,
912
+ state: SessionState,
913
+ pending: PendingQuestion,
914
+ notes: string[],
915
+ ): Promise<HookOutcome> {
916
+ // Away right now, but the question still owes the user its terminal-first
917
+ // window before anything reaches their devices.
918
+ const grace = await awaitGrace(ctx, pending.asked_at ?? ctx.now())
919
+ if (grace === 'user-returned') {
920
+ notes.push('you came back before the wait elapsed; leaving the question in the terminal')
921
+ return { notes }
922
+ }
923
+ if (grace === 'no-signal') {
924
+ notes.push('no idle signal on this machine; asking now rather than holding the terminal')
925
+ }
926
+
927
+ const asked = await askAndWait(ctx, {
928
+ title: 'A question from your agent',
929
+ body: pending.question,
930
+ ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
931
+ event: 'agent_question',
932
+ session: sessionLabel(ctx, envelope),
933
+ // Outlives the block, and stays open on purpose: the answer is still
934
+ // useful to the next turn, which collects it with `notifai replies`.
935
+ windowSeconds: QUESTION_WINDOW_SECONDS,
936
+ onSubmitted: (live) => {
937
+ // Re-read: the drain at the top of this hook rewrote the queue, and the
938
+ // snapshot in `state` predates it.
939
+ writeSessionState(sessionId, ctx.env, {
940
+ ...readSessionState(sessionId, ctx.env),
941
+ pending: { ...pending, request_id: live.requestId, collapse_key: live.collapseKey },
942
+ })
943
+ },
944
+ })
945
+ if ('error' in asked) {
946
+ notes.push(asked.error)
947
+ return { notes }
948
+ }
949
+
950
+ // What survives replacing the pending record. `retiring` has to: dropping it
951
+ // is exactly the class of bug this guards against — a delivered notification
952
+ // whose ids no longer exist anywhere.
953
+ const current = readSessionState(sessionId, ctx.env)
954
+ const carried = {
955
+ ...(state.last_prompt_at !== undefined ? { last_prompt_at: state.last_prompt_at } : {}),
956
+ ...(current.retiring !== undefined ? { retiring: current.retiring } : {}),
957
+ }
958
+
959
+ if (!asked.reply) {
960
+ // Keep the pending record so a returning user's UserPromptSubmit can retire
961
+ // the notification that is still live on their devices. `request_id` being
962
+ // set is also what stops the next Stop pushing the same question again.
963
+ writeSessionState(sessionId, ctx.env, {
964
+ ...carried,
965
+ pending: { ...pending, request_id: asked.requestId, collapse_key: asked.collapseKey },
966
+ })
967
+ notes.push(
968
+ asked.degraded
969
+ ? `could not reach the server to find out whether you answered; check with: notifai replies ${asked.requestId}`
970
+ : `no answer in time; retrieve it later with: notifai replies ${asked.requestId}`,
971
+ )
972
+ return { notes }
973
+ }
974
+
975
+ // Answered and closed — nothing left to retire.
976
+ writeSessionState(sessionId, ctx.env, carried)
977
+
978
+ await retire(
979
+ ctx,
980
+ asked.collapseKey,
981
+ 'Answered',
982
+ asked.reply.text,
983
+ 'answered',
984
+ asked.requestId,
985
+ asked.devices,
986
+ sessionLabel(ctx, envelope),
987
+ )
988
+ notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`)
989
+ return {
990
+ stdout: JSON.stringify({
991
+ decision: 'block',
992
+ // Harnesses render a Stop decision under an error-ish label, so this
993
+ // text must read as an answer on its own rather than as a failure.
994
+ reason:
995
+ `NotifAI — the user answered from ${asked.reply.device_name}: ` +
996
+ `"${asked.reply.text}". Continue with that answer.`,
997
+ }),
998
+ notes,
999
+ }
1000
+ }
1001
+
1002
+ // ---------------------------------------------------------------------------
1003
+ // SessionEnd — local cleanup only
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ /**
1007
+ * Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
1008
+ * second, so this cannot make a network call. It drops the local marker — but
1009
+ * first moves anything still live on the user's devices into the global
1010
+ * retirement queue, because this file was the only record of those ids and no
1011
+ * hook for this session will ever run again. A question whose
1012
+ * agent just exited can receive no answer, so it is orphaned as `expired`.
1013
+ */
1014
+ export function handleSessionEnd(
1015
+ env: NodeJS.ProcessEnv,
1016
+ envelope: HookEnvelope,
1017
+ now: number = Date.now(),
1018
+ ): HookOutcome {
1019
+ const notes: string[] = []
1020
+ const sessionId = envelope.session_id
1021
+ if (!sessionId) return { notes }
1022
+
1023
+ const state = readSessionState(sessionId, env)
1024
+ const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
1025
+ const pending = state.pending
1026
+ if (pending?.request_id !== undefined && pending.collapse_key !== undefined) {
1027
+ orphans.push({
1028
+ request_id: pending.request_id,
1029
+ collapse_key: pending.collapse_key,
1030
+ question: pending.question,
1031
+ state: 'expired',
1032
+ })
1033
+ }
1034
+ if (orphans.length > 0) {
1035
+ const label = env['NOTIFAI_SESSION']
1036
+ orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now)
1037
+ notes.push(
1038
+ `queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
1039
+ )
1040
+ }
1041
+ clearSessionState(sessionId, env)
1042
+ return { notes }
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // ask — register a question for the turn's end
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ /**
1050
+ * One session holds one live question, so registering a second one ends the
1051
+ * first.
1052
+ *
1053
+ * This used to replace `pending` wholesale, which silently discarded the
1054
+ * `request_id` and `collapse_key` of a question already delivered to the user's
1055
+ * devices. Nothing else knows those ids, so the notification became
1056
+ * unretirable — it sat on the lock screen for ever asking a question no answer
1057
+ * could reach. That is where the stale pile-up came from.
1058
+ *
1059
+ * Supersession is keyed on the session, not the project: several agents may be
1060
+ * running in one project at once, and one agent's new question killing another
1061
+ * agent's live one would be worse than the staleness this fixes.
1062
+ */
1063
+ export function registerQuestion(
1064
+ sessionId: string,
1065
+ env: NodeJS.ProcessEnv,
1066
+ question: PendingQuestion,
1067
+ now: number = Date.now(),
1068
+ ): void {
1069
+ const state = readSessionState(sessionId, env)
1070
+ if (state.pending !== undefined) parkForRetirement(sessionId, env, state.pending, 'superseded')
1071
+ // Re-read: parkForRetirement wrote the queue we must not clobber.
1072
+ writeSessionState(sessionId, env, {
1073
+ ...readSessionState(sessionId, env),
1074
+ pending: {
1075
+ asked_at: now,
1076
+ ...question,
1077
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1078
+ },
1079
+ })
1080
+ }
1081
+
1082
+ /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
1083
+ export function parseHookInput(raw: string): HookEnvelope {
1084
+ if (raw.trim() === '') return {}
1085
+ try {
1086
+ const parsed: unknown = JSON.parse(raw)
1087
+ return typeof parsed === 'object' && parsed !== null ? (parsed as HookEnvelope) : {}
1088
+ } catch {
1089
+ return {}
1090
+ }
1091
+ }
1092
+
1093
+ export function resolveHookConfig(
1094
+ cwd: string,
1095
+ env: NodeJS.ProcessEnv,
1096
+ sessionId: string | undefined,
1097
+ ): CliConfig {
1098
+ return loadConfig({ cwd, env, sessionId })
1099
+ }