@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
@@ -0,0 +1,1030 @@
1
+ import { mkdirSync, mkdtempSync, realpathSync, utimesSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import type {
5
+ ListRepliesResponse,
6
+ ReplyView,
7
+ SubmissionReceipt,
8
+ SubmitNotificationRequestT,
9
+ } from '@raidiant/notifai-protocol'
10
+ import { describe, expect, it } from 'vitest'
11
+ import type { ApiClient } from './client.js'
12
+ import { EXIT, askCommand, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
13
+ import { loadConfig, sanitizeSessionId } from './config.js'
14
+ import {
15
+ claimQuestionPush,
16
+ drainOrphanRetirements,
17
+ isUserAway,
18
+ orphanRetirements,
19
+ pruneAbandonedSessions,
20
+ releaseQuestionPush,
21
+ readProjectSession,
22
+ readSessionState,
23
+ registerQuestion,
24
+ writeSessionState,
25
+ } from './hooks.js'
26
+
27
+ class CapturedIo implements CommandIo {
28
+ outLines: string[] = []
29
+ errLines: string[] = []
30
+ out(line: string) {
31
+ this.outLines.push(line)
32
+ }
33
+ err(line: string) {
34
+ this.errLines.push(line)
35
+ }
36
+ async confirm() {
37
+ return false
38
+ }
39
+ openUrl() {}
40
+ }
41
+
42
+ interface Recorder {
43
+ submitted: SubmitNotificationRequestT[]
44
+ /** `submitted[i]`'s request id, so a test can name what it just sent. */
45
+ receipts: string[]
46
+ closed: string[]
47
+ /** Simulates an offline machine; retirement has to survive one. */
48
+ failSubmits?: boolean
49
+ }
50
+
51
+ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
52
+ let submissions = 0
53
+ return {
54
+ beginPairing: notUsed,
55
+ pollPairing: notUsed,
56
+ // Both current companion apps register reply categories.
57
+ listDevices: async () => ({
58
+ devices: [
59
+ {
60
+ device_id: 'dev_iphone',
61
+ display_name: 'Furankuphone',
62
+ platform: 'ios' as const,
63
+ permission_status: 'authorized',
64
+ registration_healthy: true,
65
+ last_seen_at: null,
66
+ },
67
+ {
68
+ device_id: 'dev_mac',
69
+ display_name: 'FurankuMac',
70
+ platform: 'macos' as const,
71
+ permission_status: 'authorized',
72
+ registration_healthy: true,
73
+ last_seen_at: null,
74
+ },
75
+ ],
76
+ }),
77
+ capabilities: notUsed,
78
+ evidence: notUsed,
79
+ createMediaUpload: notUsed,
80
+ uploadMedia: notUsed,
81
+ health: async () => true,
82
+ submit: async (body) => {
83
+ if (recorder.failSubmits === true) throw new Error('offline')
84
+ recorder.submitted.push(body)
85
+ submissions += 1
86
+ recorder.receipts.push(`req_hook_${submissions}`)
87
+ return {
88
+ request_id: `req_hook_${submissions}`,
89
+ replayed: false,
90
+ overall: 'provider_accepted_all',
91
+ deliveries: [],
92
+ warnings: [],
93
+ } satisfies SubmissionReceipt
94
+ },
95
+ replies: async (requestId) =>
96
+ ({ request_id: requestId, reply_expires_at: null, replies }) satisfies ListRepliesResponse,
97
+ closeReplies: async (requestId) => {
98
+ recorder.closed.push(requestId)
99
+ },
100
+ } as ApiClient
101
+ }
102
+
103
+ function notUsed(): never {
104
+ throw new Error('not used in these tests')
105
+ }
106
+
107
+ function reply(overrides: Partial<ReplyView> = {}): ReplyView {
108
+ return {
109
+ reply_id: 'rpl_test',
110
+ seq: 1,
111
+ delivery_id: 'del_test',
112
+ device_id: 'dev_test',
113
+ device_name: 'Furankuphone',
114
+ text: 'Allow',
115
+ choice_id: 'allow',
116
+ created_at: new Date().toISOString(),
117
+ ...overrides,
118
+ }
119
+ }
120
+
121
+ interface Harness {
122
+ deps: CommandDeps
123
+ io: CapturedIo
124
+ recorder: Recorder
125
+ env: NodeJS.ProcessEnv
126
+ }
127
+
128
+ const NOW = 1_800_000_000_000
129
+
130
+ /**
131
+ * `idleSeconds` defaults to null — "this machine has no idle source" — so these
132
+ * cases exercise the degraded path and stay independent of whether the person
133
+ * running the suite is touching their own keyboard.
134
+ */
135
+ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null): Harness {
136
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-'))
137
+ const env: NodeJS.ProcessEnv = {
138
+ XDG_CONFIG_HOME: path.join(root, 'config'),
139
+ XDG_STATE_HOME: path.join(root, 'state'),
140
+ }
141
+ const io = new CapturedIo()
142
+ const recorder: Recorder = { submitted: [], receipts: [], closed: [] }
143
+ // Virtual clock: sleeps advance it instead of costing wall time. A frozen
144
+ // clock would make the reply poll's deadline unreachable and spin forever.
145
+ let clock = NOW
146
+ return {
147
+ io,
148
+ recorder,
149
+ env,
150
+ deps: {
151
+ io,
152
+ store: {
153
+ load: () => ({
154
+ machineId: 'mac_test',
155
+ secret: 'test-secret',
156
+ baseUrl: 'https://test.notifai.invalid',
157
+ machineName: 'test-machine',
158
+ }),
159
+ save: () => {},
160
+ clear: () => {},
161
+ describe: () => 'test credential store',
162
+ },
163
+ env,
164
+ cwd: root,
165
+ clientFactory: () => fakeClient(recorder, replies),
166
+ now: () => clock,
167
+ idleSeconds: () => idleSeconds,
168
+ sleep: async (milliseconds: number) => {
169
+ clock += milliseconds
170
+ },
171
+ },
172
+ }
173
+ }
174
+
175
+ function stdin(payload: unknown): () => Promise<string> {
176
+ return async () => JSON.stringify(payload)
177
+ }
178
+
179
+ /** Long enough ago to be away under the 120s default. */
180
+ const AWAY = NOW - 600_000
181
+ const PRESENT = NOW - 5_000
182
+
183
+ describe('presence gate', () => {
184
+ it('treats a never-seen session as present, so a missing hook cannot hijack the terminal', () => {
185
+ const { env, deps } = harness()
186
+ const config = loadConfig({ cwd: deps.cwd, env })
187
+ expect(isUserAway({}, config, NOW, null)).toBe(false)
188
+ })
189
+
190
+ it('is away only once the configured silence has elapsed', () => {
191
+ const { env, deps } = harness()
192
+ const config = loadConfig({ cwd: deps.cwd, env })
193
+ expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, null)).toBe(false)
194
+ expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
195
+ })
196
+
197
+ // The case that motivated this: "run the full test suite", then three
198
+ // minutes of watching. Elapsed time alone said away; the machine knows better.
199
+ it('keeps a user who is watching a long turn present, however long the turn ran', () => {
200
+ const { env, deps } = harness()
201
+ const config = loadConfig({ cwd: deps.cwd, env })
202
+ expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 3)).toBe(false)
203
+ })
204
+
205
+ // Found by a live Claude Code session: a spawned agent's session
206
+ // always has a just-set last_prompt_at, so requiring session silence too meant
207
+ // its FIRST question could never escalate — the "kick off agents and walk
208
+ // away" case the feature is for.
209
+ it('lets a freshly spawned session escalate when the machine says nobody is there', () => {
210
+ const { env, deps } = harness()
211
+ const config = loadConfig({ cwd: deps.cwd, env })
212
+ expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, 900)).toBe(true)
213
+ expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 900)).toBe(true)
214
+ })
215
+
216
+ it('pushes a spawned session first question once the machine has gone quiet', async () => {
217
+ const h = harness([reply({ text: 'Yes' })], 900)
218
+ // Prompt 20s ago, exactly as a just-spawned agent has.
219
+ writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
220
+ registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
221
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'spawn1' }))
222
+ expect(h.recorder.submitted.length).toBeGreaterThan(0)
223
+ expect(h.recorder.submitted[0]?.draft.targets).toEqual({
224
+ mode: 'selected',
225
+ device_ids: ['dev_iphone', 'dev_mac'],
226
+ })
227
+ })
228
+
229
+ it('falls back to elapsed time where no idle source exists', () => {
230
+ const { env, deps } = harness()
231
+ const config = loadConfig({ cwd: deps.cwd, env })
232
+ expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
233
+ })
234
+
235
+ it('does not push a question to the phone while the user is at the keyboard', async () => {
236
+ // Silent for ten minutes by the session clock, but active by the machine's.
237
+ const h = harness([reply({ text: 'Yes' })], 2)
238
+ writeSessionState('idle1', h.env, { last_prompt_at: AWAY })
239
+ registerQuestion('idle1', h.env, { question: 'Ship it?' })
240
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'idle1' }))
241
+ expect(h.recorder.submitted).toHaveLength(0)
242
+ expect(h.io.errLines.join('\n')).toContain('at the keyboard')
243
+ })
244
+ })
245
+
246
+ /**
247
+ * Wanting notifications while still at the machine is a legitimate setting —
248
+ * nobody should have to stop using their computer in order to be reached.
249
+ * Presence is a precondition only while the user wants it to be, and switching
250
+ * it off must not disturb the grace timer.
251
+ */
252
+ describe('presence gating is optional (require_idle)', () => {
253
+ function writeGlobalConfig(h: Harness, toml: string): void {
254
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
255
+ mkdirSync(dir, { recursive: true })
256
+ writeFileSync(path.join(dir, 'config.toml'), toml)
257
+ }
258
+
259
+ it('counts a user actively typing as reachable', () => {
260
+ // One second of idle: as present as it is possible to be.
261
+ const config = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
262
+ expect(isUserAway({ last_prompt_at: NOW }, config, NOW, 1)).toBe(false)
263
+
264
+ const off = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
265
+ off.require_idle = { value: false, source: 'default' }
266
+ expect(isUserAway({ last_prompt_at: NOW }, off, NOW, 1)).toBe(true)
267
+ })
268
+
269
+ it('escalates a question while the user is at the keyboard', async () => {
270
+ const h = harness([], 1)
271
+ writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 0\n')
272
+ writeSessionState('present1', h.env, { last_prompt_at: NOW })
273
+ registerQuestion('present1', h.env, { question: 'Ship it?' })
274
+
275
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
276
+
277
+ expect(h.recorder.submitted).toHaveLength(1)
278
+ expect(h.io.errLines.join('\n')).not.toContain('at the keyboard')
279
+ })
280
+
281
+ it('still gives the terminal its grace window first', async () => {
282
+ // The two knobs are independent: not needing the user to leave does not
283
+ // mean skipping the wait that offers the question to the terminal.
284
+ const h = harness([], 1)
285
+ writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 300\n')
286
+ writeSessionState('present2', h.env, { last_prompt_at: NOW })
287
+ registerQuestion('present2', h.env, { question: 'Ship it?' }, NOW)
288
+
289
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present2' }))
290
+
291
+ expect(h.recorder.submitted).toHaveLength(1)
292
+ // The clock only advances when the hook sleeps, so this is proof the wait
293
+ // actually happened rather than being skipped.
294
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
295
+ })
296
+
297
+ it('honours the grace window on a machine with no idle source at all', async () => {
298
+ // With presence gating on, no idle source means refusing to wait
299
+ // ('no-signal'). With it off there is nothing to watch for, so the timer
300
+ // is just a timer and works everywhere.
301
+ const h = harness([], null)
302
+ writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 120\n')
303
+ registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
304
+
305
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
306
+
307
+ expect(h.recorder.submitted).toHaveLength(1)
308
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
309
+ expect(h.io.errLines.join('\n')).not.toContain('no idle signal')
310
+ })
311
+
312
+ it('is not a way to switch escalation on when the user has switched it off', async () => {
313
+ // ask_notifications is the "do not reach me" switch and outranks this one;
314
+ // wanting to be reachable while working is a different question entirely.
315
+ const h = harness([], 1)
316
+ writeGlobalConfig(h, 'require_idle = false\nask_notifications = false\n')
317
+ registerQuestion('present4', h.env, { question: 'Ship it?' })
318
+
319
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
320
+
321
+ expect(h.recorder.submitted).toHaveLength(0)
322
+ })
323
+ })
324
+
325
+ describe('terminal-first grace window', () => {
326
+ /** Registers a question asked `agoMs` ago, with the user long since silent. */
327
+ function pending(h: Harness, session: string, agoMs: number): void {
328
+ writeSessionState(session, h.env, { last_prompt_at: AWAY })
329
+ registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
330
+ }
331
+
332
+ it('holds the question in the terminal until the window elapses', async () => {
333
+ // Idle 900s: the user is gone, so the wait runs to completion rather than
334
+ // being abandoned. Sleeps advance the virtual clock.
335
+ const h = harness([reply({ text: 'Yes' })], 900)
336
+ pending(h, 'g1', 0)
337
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
338
+ expect(h.recorder.submitted.length).toBeGreaterThan(0)
339
+ // Nothing was pushed before the 300s default had passed.
340
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
341
+ })
342
+
343
+ it('counts the window from when the question was sent, not from the turn end', async () => {
344
+ // Asked 290s ago while the agent kept working: only 10s of wait remains.
345
+ const h = harness([reply({ text: 'Yes' })], 900)
346
+ pending(h, 'g2', 290_000)
347
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g2' }))
348
+ expect(h.recorder.submitted.length).toBeGreaterThan(0)
349
+ expect(h.deps.now?.()).toBeLessThan(NOW + 60_000)
350
+ })
351
+
352
+ it('sends nothing if the user comes back to the keyboard during the window', async () => {
353
+ let idle = 900
354
+ const h = harness([reply({ text: 'Yes' })], 900)
355
+ // Machine goes active on the second poll: the user sat down.
356
+ h.deps.idleSeconds = () => {
357
+ const current = idle
358
+ idle = 1
359
+ return current
360
+ }
361
+ pending(h, 'g3', 0)
362
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g3' }))
363
+ expect(h.recorder.submitted).toHaveLength(0)
364
+ expect(h.io.errLines.join('\n')).toContain('came back')
365
+ })
366
+
367
+ it('refuses to hold a terminal it cannot monitor', async () => {
368
+ // No idle source: waiting would block the prompt with no way to notice the
369
+ // user returning, so it asks immediately instead.
370
+ const h = harness([reply({ text: 'Yes' })], null)
371
+ pending(h, 'g4', 0)
372
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g4' }))
373
+ expect(h.recorder.submitted.length).toBeGreaterThan(0)
374
+ expect(h.deps.now?.()).toBe(NOW)
375
+ expect(h.io.errLines.join('\n')).toContain('no idle signal')
376
+ })
377
+
378
+ it('never lets the window crowd out the reply wait past the hook budget', async () => {
379
+ // Both dials at maximum would be 540 + 540 — nearly twice the 600s ceiling
380
+ // the harness kills a hook at, and a killed hook loses an answer already
381
+ // given. The reply wait wins, so the window yields to nothing at all.
382
+ const h = harness([reply({ text: 'Yes' })], 900)
383
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
384
+ mkdirSync(dir, { recursive: true })
385
+ writeFileSync(
386
+ path.join(dir, 'config.toml'),
387
+ 'ask_grace_seconds = 540\nhook_reply_timeout_seconds = 540\n',
388
+ )
389
+ pending(h, 'g5', 0)
390
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
391
+ expect(h.recorder.submitted.length).toBeGreaterThan(0)
392
+ expect(h.deps.now?.()).toBe(NOW)
393
+ })
394
+ })
395
+
396
+ describe('config resolution inside a hook', () => {
397
+ it('reads project config from the session cwd, not the hook process cwd', async () => {
398
+ const h = harness([reply({ choice_id: 'allow' })])
399
+ // A project that has turned the feature off.
400
+ const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-proj-'))
401
+ mkdirSync(path.join(project, '.notifai'), { recursive: true })
402
+ writeFileSync(
403
+ path.join(project, '.notifai', 'config.local.toml'),
404
+ 'ask_notifications = false\n',
405
+ )
406
+ writeSessionState('c1', h.env, { last_prompt_at: AWAY })
407
+ registerQuestion('c1', h.env, { question: 'Ship it?' })
408
+
409
+ // deps.cwd is elsewhere; only envelope.cwd points at the project. The
410
+ // payload's cwd is the harness's statement of which project this is, and
411
+ // must win over whatever directory we were spawned in.
412
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'c1', cwd: project }))
413
+
414
+ expect(h.recorder.submitted).toEqual([])
415
+ expect(h.io.outLines).toEqual([])
416
+ })
417
+ })
418
+
419
+ describe('nagging guards', () => {
420
+ it('does not ask twice for one question across successive Stops', async () => {
421
+ const h = harness([])
422
+ writeSessionState('n1', h.env, { last_prompt_at: AWAY })
423
+ registerQuestion('n1', h.env, { question: 'Ship it?' })
424
+
425
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
426
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
427
+
428
+ expect(h.recorder.submitted).toHaveLength(1)
429
+ })
430
+
431
+ it('respects the harness recursion guard', async () => {
432
+ const h = harness([reply({ text: 'Yes' })])
433
+ writeSessionState('n2', h.env, { last_prompt_at: AWAY })
434
+ registerQuestion('n2', h.env, { question: 'Ship it?' })
435
+
436
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n2', stop_hook_active: true }))
437
+
438
+ expect(h.recorder.submitted).toEqual([])
439
+ expect(h.io.outLines).toEqual([])
440
+ })
441
+ })
442
+
443
+ describe('Cursor stop output', () => {
444
+ it('maps the phone answer to one native followup_message', async () => {
445
+ const h = harness([reply({ text: 'Ship it' })], 900)
446
+ writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
447
+ registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
448
+
449
+ await hookRunCommand(
450
+ h.deps,
451
+ 'stop',
452
+ stdin({
453
+ conversation_id: 'cursor-conversation',
454
+ workspace_roots: [h.deps.cwd],
455
+ loop_count: 0,
456
+ }),
457
+ 'cursor',
458
+ )
459
+
460
+ expect(h.io.outLines).toHaveLength(1)
461
+ const output = JSON.parse(h.io.outLines[0] ?? '{}') as Record<string, unknown>
462
+ expect(output['followup_message']).toContain(
463
+ 'NotifAI — the user answered from Furankuphone: "Ship it". Continue with that answer.',
464
+ )
465
+ expect(output).not.toHaveProperty('decision')
466
+ })
467
+ })
468
+
469
+ /**
470
+ * Stale questions piling up on a phone are worse than no questions at all:
471
+ * they teach the user to ignore the surface. Every one of them was a delivered
472
+ * question whose ids had been thrown away, so nothing could reach it again.
473
+ */
474
+ describe('superseding a live question', () => {
475
+ /** The lifecycle state of each retirement push the recorder captured. */
476
+ function retirements(h: Harness): { state: unknown; retires: unknown }[] {
477
+ return h.recorder.submitted
478
+ .filter((s) => s.draft.event === 'question_retired')
479
+ .map((s) => ({
480
+ state: s.draft.lifecycle?.state,
481
+ retires: s.draft.lifecycle?.retires_request_id,
482
+ }))
483
+ }
484
+
485
+ it('retires the first question instead of orphaning it', async () => {
486
+ const h = harness([])
487
+ writeSessionState('sup1', h.env, { last_prompt_at: AWAY })
488
+ registerQuestion('sup1', h.env, { question: 'Ship it?' })
489
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
490
+ const first = h.recorder.receipts[0]
491
+ expect(first).toBeDefined()
492
+
493
+ // The agent carried on and asked something else. The first question is
494
+ // dead the moment this returns, whether or not anyone answers the second.
495
+ registerQuestion('sup1', h.env, { question: 'Deploy it?' })
496
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
497
+
498
+ expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
499
+ // And the replacement was still asked; superseding is not suppressing.
500
+ expect(
501
+ h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
502
+ ).toHaveLength(1)
503
+ })
504
+
505
+ it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
506
+ const h = harness([])
507
+ writeSessionState('sup2', h.env, { last_prompt_at: AWAY })
508
+ registerQuestion('sup2', h.env, { question: 'Ship it?' })
509
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
510
+ const first = h.recorder.receipts[0]
511
+
512
+ registerQuestion('sup2', h.env, { question: 'Deploy it?' })
513
+ expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
514
+
515
+ // Offline: the drain runs, fails, and must not forget what it was for.
516
+ h.recorder.failSubmits = true
517
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
518
+ expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
519
+
520
+ h.recorder.failSubmits = false
521
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
522
+ expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
523
+ expect(readSessionState('sup2', h.env).retiring).toEqual([])
524
+ })
525
+
526
+ it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
527
+ // stop_hook_active short-circuits the escalation path, and a superseding
528
+ // turn is very often exactly this turn.
529
+ const h = harness([])
530
+ writeSessionState('sup3', h.env, { last_prompt_at: AWAY })
531
+ registerQuestion('sup3', h.env, { question: 'Ship it?' })
532
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
533
+ const first = h.recorder.receipts[0]
534
+
535
+ registerQuestion('sup3', h.env, { question: 'Deploy it?' })
536
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
537
+
538
+ expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
539
+ })
540
+
541
+ it('does not lose a queued retirement when the user comes back to the terminal', async () => {
542
+ // UserPromptSubmit resets session state to record presence, and that reset
543
+ // used to take the retirement queue with it.
544
+ const h = harness([])
545
+ writeSessionState('sup4', h.env, { last_prompt_at: AWAY })
546
+ registerQuestion('sup4', h.env, { question: 'Ship it?' })
547
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
548
+ const first = h.recorder.receipts[0]
549
+
550
+ registerQuestion('sup4', h.env, { question: 'Deploy it?' })
551
+ h.recorder.failSubmits = true
552
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
553
+ h.recorder.failSubmits = false
554
+
555
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
556
+
557
+ expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
558
+ expect(readSessionState('sup4', h.env).retiring).toEqual([])
559
+ })
560
+
561
+ it('parks nothing for a question that never reached a device', async () => {
562
+ // No request_id means there is no notification anywhere to retire, and a
563
+ // retirement push for one would be pure noise.
564
+ const h = harness([])
565
+ registerQuestion('sup5', h.env, { question: 'Ship it?' })
566
+ registerQuestion('sup5', h.env, { question: 'Deploy it?' })
567
+
568
+ expect(readSessionState('sup5', h.env).retiring ?? []).toEqual([])
569
+ })
570
+ })
571
+
572
+ describe('a question that outlives its session', () => {
573
+ function retirements(h: Harness): { state: unknown; retires: unknown }[] {
574
+ return h.recorder.submitted
575
+ .filter((s) => s.draft.event === 'question_retired')
576
+ .map((s) => ({
577
+ state: s.draft.lifecycle?.state,
578
+ retires: s.draft.lifecycle?.retires_request_id,
579
+ }))
580
+ }
581
+
582
+ /** Escalate a question that nobody answers, so it is live on the devices. */
583
+ async function pushUnanswered(h: Harness, sessionId: string): Promise<string> {
584
+ writeSessionState(sessionId, h.env, { last_prompt_at: AWAY })
585
+ registerQuestion(sessionId, h.env, { question: 'Ship it?' })
586
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: sessionId }))
587
+ const requestId = h.recorder.receipts[0]
588
+ expect(requestId).toBeDefined()
589
+ return requestId!
590
+ }
591
+
592
+ it('retires a question the harness exited on, from a later session', async () => {
593
+ const h = harness([])
594
+ const first = await pushUnanswered(h, 'dead1')
595
+
596
+ // The user quits the harness. SessionEnd cannot reach the network, so the
597
+ // question must survive the state file it used to die with.
598
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead1' }))
599
+ expect(readSessionState('dead1', h.env)).toEqual({})
600
+
601
+ // A different session's next hook holds a client and inherits the debt.
602
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
603
+ expect(h.recorder.closed).toContain(first)
604
+ expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
605
+ })
606
+
607
+ it('carries parked retirements across SessionEnd too', async () => {
608
+ const h = harness([])
609
+ const first = await pushUnanswered(h, 'dead2')
610
+
611
+ // Superseded while offline: the retirement is parked, not sent.
612
+ registerQuestion('dead2', h.env, { question: 'Deploy it?' })
613
+ h.recorder.failSubmits = true
614
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'dead2' }))
615
+ h.recorder.failSubmits = false
616
+
617
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
618
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
619
+ expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
620
+ })
621
+
622
+ it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
623
+ const h = harness([])
624
+ const first = await pushUnanswered(h, 'dead3')
625
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
626
+
627
+ // Still offline: the queue must survive a failed drain.
628
+ h.recorder.failSubmits = true
629
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
630
+ h.recorder.failSubmits = false
631
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
632
+ expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
633
+
634
+ // And a second drain does not send it twice.
635
+ const count = retirements(h).filter((r) => r.retires === first).length
636
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
637
+ expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
638
+ })
639
+
640
+ it('gives up on an orphan older than a day instead of queueing it for ever', async () => {
641
+ const h = harness([])
642
+ orphanRetirements(
643
+ h.env,
644
+ [{ request_id: 'req_old', collapse_key: 'ck_old', question: 'Old?', state: 'expired' }],
645
+ undefined,
646
+ NOW - 25 * 3600 * 1000,
647
+ )
648
+ const drained = await drainOrphanRetirements(
649
+ { client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
650
+ h.env,
651
+ NOW,
652
+ )
653
+ // Dropped as handled, but no retirement push was spent on it.
654
+ expect(drained).toContain('req_old')
655
+ expect(h.recorder.submitted).toHaveLength(0)
656
+ })
657
+
658
+ it('queues nothing for a session with nothing live on the devices', async () => {
659
+ const h = harness([])
660
+ registerQuestion('dead4', h.env, { question: 'Ship it?' })
661
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead4' }))
662
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next4' }))
663
+ expect(h.recorder.submitted).toHaveLength(0)
664
+ expect(h.recorder.closed).toHaveLength(0)
665
+ })
666
+ })
667
+
668
+ describe('hostile input', () => {
669
+ it('never sends the credential to a base_url a repository asked for', async () => {
670
+ const h = harness([reply({ text: 'Yes' })])
671
+ const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-evil-'))
672
+ mkdirSync(path.join(project, '.notifai'), { recursive: true })
673
+ writeFileSync(
674
+ path.join(project, '.notifai', 'config.toml'),
675
+ 'base_url = "https://attacker.example"\n',
676
+ )
677
+ writeSessionState('h1', h.env, { last_prompt_at: AWAY })
678
+ registerQuestion('h1', h.env, { question: 'Ship it?' })
679
+
680
+ let seen: string | null = null
681
+ h.deps.clientFactory = (baseUrl) => {
682
+ seen = baseUrl
683
+ return fakeClient(h.recorder, [reply({ text: 'Yes' })])
684
+ }
685
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'h1', cwd: project }))
686
+
687
+ expect(seen).toBe('https://test.notifai.invalid')
688
+ })
689
+
690
+ it('clamps an out-of-range away threshold instead of trusting it', () => {
691
+ const h = harness()
692
+ const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-bounds-'))
693
+ mkdirSync(path.join(project, '.notifai'), { recursive: true })
694
+ writeFileSync(path.join(project, '.notifai', 'config.toml'), 'away_after_seconds = -1\n')
695
+ const config = loadConfig({ cwd: project, env: h.env })
696
+ // -1 would make someone who just typed count as absent.
697
+ expect(config.away_after_seconds.value).toBeGreaterThanOrEqual(5)
698
+ })
699
+ })
700
+
701
+ describe('ask registration', () => {
702
+ it('rejects a malformed choice set at registration, not at push time', () => {
703
+ const h = harness()
704
+ // Inside a hook, a rejection is only a stderr note the agent never reads —
705
+ // so it would look registered and then silently never ask.
706
+ expect(askCommand(h.deps, 'Ship it?', { choice: 'Yes|No', session: 'a1' })).toBe(EXIT.usage)
707
+ expect(readSessionState('a1', h.env).pending).toBeUndefined()
708
+ })
709
+
710
+ it('stores validated labels verbatim, commas and all', () => {
711
+ const h = harness()
712
+ expect(
713
+ askCommand(h.deps, 'Ship it?', {
714
+ choice: ['Yes, ship it', 'No, hold'],
715
+ session: 'a2',
716
+ }),
717
+ ).toBe(EXIT.ok)
718
+ expect(readSessionState('a2', h.env).pending?.choices).toEqual(['Yes, ship it', 'No, hold'])
719
+ })
720
+
721
+ it('resolves the session as flag, then hook pointer, then NOTIFAI_SESSION', async () => {
722
+ // The exported id is often a chosen label while hook state is keyed by the
723
+ // harness's own id, so the pointer must outrank the env var.
724
+ const h = harness()
725
+ h.deps.env['NOTIFAI_SESSION'] = 'my-label'
726
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'real1', cwd: h.deps.cwd }))
727
+ expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
728
+ expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
729
+ expect(readSessionState('my-label', h.env).pending).toBeUndefined()
730
+ })
731
+
732
+ it('falls back to NOTIFAI_SESSION where no hook has spoken', () => {
733
+ const h = harness()
734
+ h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
735
+ expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
736
+ expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
737
+ })
738
+ })
739
+
740
+ describe('user-prompt-submit hook', () => {
741
+ it('records presence', async () => {
742
+ const h = harness()
743
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
744
+ expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
745
+ })
746
+
747
+ it('retires a question a real timed-out Stop left live on the devices', async () => {
748
+ // Drives the actual flow rather than hand-writing state: the previous
749
+ // version of this test fabricated a shape production never wrote, so it
750
+ // passed while the retirement path was unreachable.
751
+ const h = harness([])
752
+ writeSessionState('s11', h.env, { last_prompt_at: AWAY })
753
+ registerQuestion('s11', h.env, { question: 'Which environment?' })
754
+
755
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
756
+ const live = readSessionState('s11', h.env).pending
757
+ expect(live?.request_id).toBe('req_hook_1')
758
+ expect(live?.collapse_key).toBeDefined()
759
+
760
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
761
+
762
+ expect(h.recorder.closed).toEqual(['req_hook_1'])
763
+ const retirement = h.recorder.submitted.at(-1)?.draft
764
+ expect(retirement?.presentation.title).toBe('Answered in the terminal')
765
+ expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
766
+ expect(retirement?.reply).toBeUndefined()
767
+ })
768
+
769
+ it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
770
+ // A state change is not news: the retirement must ride the wire as a
771
+ // lifecycle update, which the server renders as a background push — the
772
+ // old "Answered" tombstone alert told the user what they just did.
773
+ const h = harness([])
774
+ writeSessionState('s15', h.env, { last_prompt_at: AWAY })
775
+ registerQuestion('s15', h.env, { question: 'Which environment?' })
776
+
777
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's15' }))
778
+ expect(h.recorder.submitted[0]?.draft.lifecycle).toEqual({ tier: 'needs_you' })
779
+
780
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
781
+
782
+ const retirement = h.recorder.submitted.at(-1)?.draft
783
+ expect(retirement?.lifecycle).toEqual({
784
+ tier: 'done',
785
+ state: 'answered_elsewhere',
786
+ // The history-entry correlation id: companions key entries by request
787
+ // id and never persisted the collapse key.
788
+ retires_request_id: 'req_hook_1',
789
+ })
790
+ })
791
+
792
+ it('retires as done/answered when the answer came from a device', async () => {
793
+ const h = harness([reply({ text: 'Yes' })], 900)
794
+ registerQuestion('s16', h.env, { question: 'Ship it?' })
795
+
796
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
797
+
798
+ const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
799
+ expect(retirement?.draft.lifecycle).toEqual({
800
+ tier: 'done',
801
+ state: 'answered',
802
+ retires_request_id: 'req_hook_1',
803
+ })
804
+ })
805
+
806
+ it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
807
+ const h = harness()
808
+ // A real directory reachable by two names. os.tmpdir() is /var/folders on
809
+ // macOS, so create under /tmp explicitly to get the symlinked pair that
810
+ // broke this live: the harness reports cwd unresolved, a shell resolved.
811
+ const viaSymlink = mkdtempSync('/tmp/notifai-ptr-')
812
+ const project = realpathSync(viaSymlink)
813
+ if (project === viaSymlink) return // no symlink on this platform; nothing to prove
814
+
815
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's14', cwd: viaSymlink }))
816
+
817
+ expect(readProjectSession(project, h.env, NOW)).toBe('s14')
818
+ expect(readProjectSession(viaSymlink, h.env, NOW)).toBe('s14')
819
+ // A pointer older than a day is not evidence of a live session.
820
+ expect(readProjectSession(project, h.env, NOW + 2 * 24 * 3600 * 1000)).toBeNull()
821
+ })
822
+ })
823
+
824
+ describe('session-end hook', () => {
825
+ it('drops local state without touching the network, inside its ~1s budget', async () => {
826
+ const h = harness()
827
+ writeSessionState('s12', h.env, { last_prompt_at: NOW })
828
+
829
+ const code = await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's12' }))
830
+
831
+ expect(code).toBe(EXIT.ok)
832
+ expect(readSessionState('s12', h.env)).toEqual({})
833
+ expect(h.recorder.closed).toEqual([])
834
+ })
835
+ })
836
+
837
+ describe('malformed input', () => {
838
+ it('never fails the harness on unparseable hook JSON', async () => {
839
+ const h = harness()
840
+ const code = await hookRunCommand(h.deps, 'stop', async () => 'not json{')
841
+ expect(code).toBe(EXIT.ok)
842
+ expect(h.io.outLines).toEqual([])
843
+ })
844
+ })
845
+
846
+ describe('telling concurrent agents apart', () => {
847
+ it('stamps the harness session on the question it pushes', async () => {
848
+ // The hook has always known session_id and never passed it on, so two
849
+ // agents in separate worktrees produced identical notifications and the
850
+ // user could answer the wrong one's question.
851
+ const h = harness([], 900)
852
+ registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
853
+
854
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
855
+
856
+ expect(h.recorder.submitted[0]?.draft.session).toBe('sess-abc')
857
+ })
858
+
859
+ it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
860
+ const h = harness([reply({ text: 'Yes' })], 900)
861
+ registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
862
+
863
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
864
+
865
+ const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
866
+ expect(retirement?.draft.session).toBe('sess-abc')
867
+ })
868
+
869
+ it('prefers a name the user chose over the harness UUID', async () => {
870
+ const h = harness([], 900)
871
+ h.env['NOTIFAI_SESSION'] = 'migration-worktree'
872
+ registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
873
+
874
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
875
+
876
+ expect(h.recorder.submitted[0]?.draft.session).toBe('migration-worktree')
877
+ })
878
+ })
879
+
880
+ describe('clock jumps', () => {
881
+ const config = loadConfig({ cwd: '/nowhere', env: {} })
882
+
883
+ it('does not hijack a terminal because the clock jumped forward', () => {
884
+ // NTP correction or a VM resume moves `now` without any time passing for
885
+ // the person sitting at the keyboard. Without an idle source there is
886
+ // nothing to check the delta against, so a huge one is not evidence.
887
+ const state = { last_prompt_at: NOW - 400 * 24 * 3600 * 1000 }
888
+ expect(isUserAway(state, config, NOW, null)).toBe(false)
889
+ })
890
+
891
+ it('does not read a backward jump as the user being present either', () => {
892
+ // A negative delta is nonsense, not freshness. It resolves the same way:
893
+ // no evidence, so leave the terminal alone.
894
+ expect(isUserAway({ last_prompt_at: NOW + 60_000 }, config, NOW, null)).toBe(false)
895
+ })
896
+
897
+ it('still escalates on an ordinary long silence', () => {
898
+ expect(isUserAway({ last_prompt_at: NOW - 3600_000 }, config, NOW, null)).toBe(true)
899
+ })
900
+
901
+ it('lets the OS idle signal decide regardless of the wall clock', () => {
902
+ // The idle probe measures elapsed time directly, so it is unaffected — and
903
+ // it outranks the proxy anyway.
904
+ const nonsense = { last_prompt_at: NOW + 999_999_999 }
905
+ expect(isUserAway(nonsense, config, NOW, 900)).toBe(true)
906
+ expect(isUserAway(nonsense, config, NOW, 1)).toBe(false)
907
+ })
908
+
909
+ it('does not let a stamp from the future hold the terminal past the budget', async () => {
910
+ // asked_at is wall-clock too. A future stamp used to make the grace window
911
+ // unreachable, blocking Stop until the harness killed it.
912
+ const h = harness([], 900)
913
+ registerQuestion('sess-jump', h.env, { question: 'Ship it?' }, NOW + 30 * 24 * 3600 * 1000)
914
+
915
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-jump' }))
916
+
917
+ // It escalated rather than spinning: the window restarted from now.
918
+ expect(h.recorder.submitted.length).toBeGreaterThan(0)
919
+ })
920
+ })
921
+
922
+ /**
923
+ * SessionEnd cleans up, but a crashed harness never reaches it,
924
+ * and roughly a hundred sessions a day is tens of thousands of dead files a
925
+ * year that nothing reads.
926
+ */
927
+ describe('pruning abandoned session state', () => {
928
+ function sessionFile(h: Harness, name: string): string {
929
+ return path.join(h.env['XDG_STATE_HOME'] as string, 'notifai', 'sessions', name)
930
+ }
931
+
932
+ // Real wall-clock, because this reasons about file mtimes.
933
+ const REAL = Date.now()
934
+
935
+ it('removes state a crashed harness left behind, and keeps live state', () => {
936
+ const h = harness()
937
+ writeSessionState('alive', h.env, { last_prompt_at: NOW })
938
+ writeSessionState('abandoned', h.env, { last_prompt_at: NOW })
939
+ const old = new Date(REAL - 30 * 24 * 3600 * 1000)
940
+ utimesSync(sessionFile(h, `${sanitizeSessionId('abandoned')}.json`), old, old)
941
+
942
+ expect(pruneAbandonedSessions(h.env, REAL)).toBe(1)
943
+
944
+ expect(readSessionState('alive', h.env).last_prompt_at).toBe(NOW)
945
+ expect(readSessionState('abandoned', h.env)).toEqual({})
946
+ })
947
+
948
+ it('does not walk the directory again for a day', () => {
949
+ const h = harness()
950
+ writeSessionState('a', h.env, { last_prompt_at: NOW })
951
+ pruneAbandonedSessions(h.env, REAL)
952
+
953
+ const old = new Date(REAL - 30 * 24 * 3600 * 1000)
954
+ utimesSync(sessionFile(h, `${sanitizeSessionId('a')}.json`), old, old)
955
+
956
+ // Same day: skipped entirely, so the stale file survives.
957
+ expect(pruneAbandonedSessions(h.env, REAL + 3600_000)).toBe(0)
958
+ expect(readSessionState('a', h.env).last_prompt_at).toBe(NOW)
959
+ // Next day: swept.
960
+ expect(pruneAbandonedSessions(h.env, REAL + 25 * 3600 * 1000)).toBe(1)
961
+ })
962
+
963
+ it('does not read a clock jump as a directory full of dead sessions', () => {
964
+ // A backward jump makes every file look like it came from the future.
965
+ // Deleting live state there would lose a question already on the phone.
966
+ const h = harness()
967
+ writeSessionState('jumped', h.env, { last_prompt_at: NOW })
968
+
969
+ expect(pruneAbandonedSessions(h.env, REAL - 400 * 24 * 3600 * 1000)).toBe(0)
970
+ expect(readSessionState('jumped', h.env).last_prompt_at).toBe(NOW)
971
+ })
972
+
973
+ it('never fails a hook because housekeeping could not run', () => {
974
+ expect(pruneAbandonedSessions({ XDG_STATE_HOME: '/proc/nonexistent/nope' }, REAL)).toBe(0)
975
+ })
976
+
977
+ it('bounds what one runaway question can write to disk', () => {
978
+ const h = harness()
979
+ registerQuestion('big', h.env, { question: 'x'.repeat(50_000) })
980
+ expect(readSessionState('big', h.env).pending?.question.length).toBeLessThanOrEqual(2000)
981
+ })
982
+ })
983
+
984
+ /**
985
+ * Path-independent ownership stops the usual cause
986
+ * of two handlers firing; this stops the consequence when something else does.
987
+ */
988
+ describe('two hooks racing one question', () => {
989
+ const REAL = Date.now()
990
+
991
+ it('lets exactly one process push', () => {
992
+ const h = harness()
993
+ expect(claimQuestionPush('race1', h.env, REAL)).toBe(true)
994
+ expect(claimQuestionPush('race1', h.env, REAL)).toBe(false)
995
+ })
996
+
997
+ it('frees the claim for the next turn', () => {
998
+ const h = harness()
999
+ claimQuestionPush('race2', h.env, REAL)
1000
+ releaseQuestionPush('race2', h.env)
1001
+ expect(claimQuestionPush('race2', h.env, REAL)).toBe(true)
1002
+ })
1003
+
1004
+ it('breaks a claim whose holder cannot still be running', () => {
1005
+ // A crashed hook must not suppress every question for this session for
1006
+ // ever — that is worse than the duplicate the claim prevents.
1007
+ const h = harness()
1008
+ claimQuestionPush('race3', h.env, REAL - 10 * 60_000)
1009
+ expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
1010
+ })
1011
+
1012
+ it('does not let two sessions block each other', () => {
1013
+ const h = harness()
1014
+ expect(claimQuestionPush('race4a', h.env, REAL)).toBe(true)
1015
+ expect(claimQuestionPush('race4b', h.env, REAL)).toBe(true)
1016
+ })
1017
+
1018
+ it('sends one notification when a second Stop arrives mid-flight', async () => {
1019
+ const h = harness([], 900)
1020
+ writeSessionState('race5', h.env, { last_prompt_at: AWAY })
1021
+ registerQuestion('race5', h.env, { question: 'Ship it?' })
1022
+ // Standing in for the other process: the claim is already held.
1023
+ claimQuestionPush('race5', h.env, Date.now())
1024
+
1025
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race5' }))
1026
+
1027
+ expect(h.recorder.submitted).toHaveLength(0)
1028
+ expect(h.io.errLines.join(" ")).toContain('already handling')
1029
+ })
1030
+ })