@raidiant/notifai 0.2.1 → 0.4.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 (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
package/src/hooks.test.ts CHANGED
@@ -1,15 +1,19 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
1
2
  import {
3
+ existsSync,
2
4
  mkdirSync,
3
5
  mkdtempSync,
4
6
  readFileSync,
5
7
  readdirSync,
6
8
  realpathSync,
9
+ rmSync,
7
10
  symlinkSync,
8
11
  utimesSync,
9
12
  writeFileSync,
10
13
  } from 'node:fs'
11
14
  import os from 'node:os'
12
15
  import path from 'node:path'
16
+ import { PassThrough } from 'node:stream'
13
17
  import type {
14
18
  ListRepliesResponse,
15
19
  ReplyView,
@@ -17,9 +21,23 @@ import type {
17
21
  SubmitNotificationRequestT,
18
22
  } from '@raidiant/notifai-protocol'
19
23
  import { describe, expect, it } from 'vitest'
20
- import type { ApiClient } from './client.js'
21
- import { EXIT, askCommand, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
22
- import { loadConfig, projectSessionPointerPath, sanitizeSessionId } from './config.js'
24
+ import { ApiCallError, type ApiClient } from './client.js'
25
+ import { readStdinWithTimeout } from './hook-input.js'
26
+ import {
27
+ activeLogPath,
28
+ bootstrapLogger,
29
+ createLogger,
30
+ readLogRecords,
31
+ type LogRecord,
32
+ } from './logging.js'
33
+ import { EXIT, askCommand, buildQuestions, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
34
+ import {
35
+ loadConfig,
36
+ projectSessionPointerPath,
37
+ sanitizeSessionId,
38
+ sessionConfigPath,
39
+ stateDir,
40
+ } from './config.js'
23
41
  import {
24
42
  claimQuestionPush,
25
43
  clearSessionState,
@@ -29,6 +47,7 @@ import {
29
47
  orphanRetirements,
30
48
  pruneAbandonedSessions,
31
49
  releaseQuestionPush,
50
+ readMatchingProjectSessionPointer,
32
51
  readProjectSession,
33
52
  readSessionState,
34
53
  registerQuestion,
@@ -55,15 +74,31 @@ interface Recorder {
55
74
  submitted: SubmitNotificationRequestT[]
56
75
  /** `submitted[i]`'s request id, so a test can name what it just sent. */
57
76
  receipts: string[]
77
+ /** Stable test aliases survive the fresh client created for each hook. */
78
+ aliases: Map<string, number>
58
79
  closed: string[]
59
80
  /** Simulates an offline machine; retirement has to survive one. */
60
81
  failSubmits?: boolean
61
82
  /** Runs while an agent_question submit is in flight, before onSubmitted. */
62
83
  beforeQuestionSubmit?: () => void
84
+ /** Lets race tests hold every replies call until all contenders are polling. */
85
+ beforeReplies?: () => Promise<void>
86
+ /** Per-request answer stream for multi-question ownership tests. */
87
+ repliesFor?: (requestId: string) => ReplyView[]
63
88
  }
64
89
 
65
90
  function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
66
91
  let submissions = 0
92
+ const recordedReplies = (requestId: string): ReplyView[] => {
93
+ const direct = recorder.repliesFor?.(requestId)
94
+ if (direct !== undefined && direct.length > 0) return direct
95
+ const ordinal = recorder.aliases.get(requestId)
96
+ if (ordinal !== undefined) {
97
+ const legacyAlias = recorder.repliesFor?.(`req_hook_${ordinal}`)
98
+ if (legacyAlias !== undefined) return legacyAlias
99
+ }
100
+ return direct ?? replies
101
+ }
67
102
  return {
68
103
  beginPairing: notUsed,
69
104
  pollPairing: notUsed,
@@ -98,19 +133,33 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
98
133
  if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
99
134
  recorder.submitted.push(body)
100
135
  submissions += 1
101
- recorder.receipts.push(`req_hook_${submissions}`)
136
+ const requestId = body.request_id ?? `req_hook_${submissions}`
137
+ recorder.aliases.set(requestId, recorder.receipts.length + 1)
138
+ recorder.receipts.push(requestId)
102
139
  return {
103
- request_id: `req_hook_${submissions}`,
140
+ request_id: requestId,
141
+ reply_expires_at: new Date(NOW + 480_000).toISOString(),
104
142
  replayed: false,
105
143
  overall: 'provider_accepted_all',
106
144
  deliveries: [],
107
145
  warnings: [],
108
146
  } satisfies SubmissionReceipt
109
147
  },
110
- replies: async (requestId) =>
111
- ({ request_id: requestId, reply_expires_at: null, replies }) satisfies ListRepliesResponse,
148
+ replies: async (requestId) => {
149
+ await recorder.beforeReplies?.()
150
+ return ({
151
+ request_id: requestId,
152
+ reply_expires_at: null,
153
+ replies: recordedReplies(requestId),
154
+ }) satisfies ListRepliesResponse
155
+ },
112
156
  closeReplies: async (requestId) => {
113
157
  recorder.closed.push(requestId)
158
+ return {
159
+ request_id: requestId,
160
+ reply_expires_at: new Date(NOW).toISOString(),
161
+ replies: recordedReplies(requestId),
162
+ } satisfies ListRepliesResponse
114
163
  },
115
164
  } as ApiClient
116
165
  }
@@ -139,6 +188,7 @@ interface Harness {
139
188
  io: CapturedIo
140
189
  recorder: Recorder
141
190
  env: NodeJS.ProcessEnv
191
+ advanceClock(milliseconds: number): void
142
192
  }
143
193
 
144
194
  const NOW = 1_800_000_000_000
@@ -155,7 +205,7 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
155
205
  XDG_STATE_HOME: path.join(root, 'state'),
156
206
  }
157
207
  const io = new CapturedIo()
158
- const recorder: Recorder = { submitted: [], receipts: [], closed: [] }
208
+ const recorder: Recorder = { submitted: [], receipts: [], closed: [], aliases: new Map() }
159
209
  // Virtual clock: sleeps advance it instead of costing wall time. A frozen
160
210
  // clock would make the reply poll's deadline unreachable and spin forever.
161
211
  let clock = NOW
@@ -163,6 +213,9 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
163
213
  io,
164
214
  recorder,
165
215
  env,
216
+ advanceClock(milliseconds: number) {
217
+ clock += milliseconds
218
+ },
166
219
  deps: {
167
220
  io,
168
221
  store: {
@@ -192,6 +245,140 @@ function stdin(payload: unknown): () => Promise<string> {
192
245
  return async () => JSON.stringify(payload)
193
246
  }
194
247
 
248
+ async function waitUntil(
249
+ predicate: () => boolean,
250
+ timeoutMs: number,
251
+ failure: string,
252
+ ): Promise<void> {
253
+ const deadline = Date.now() + timeoutMs
254
+ while (!predicate()) {
255
+ if (Date.now() >= deadline) throw new Error(failure)
256
+ await new Promise((resolve) => setTimeout(resolve, 5))
257
+ }
258
+ }
259
+
260
+ interface SessionEndLogLockHolder {
261
+ child: ChildProcess
262
+ readyPath: string
263
+ releasePath: string
264
+ done: Promise<void>
265
+ }
266
+
267
+ function runSessionEndLogLockHolder(
268
+ root: string,
269
+ env: NodeJS.ProcessEnv,
270
+ lockPath: string,
271
+ ): SessionEndLogLockHolder {
272
+ const readyPath = path.join(root, 'session-end-log-lock-ready')
273
+ const releasePath = path.join(root, 'session-end-log-lock-release')
274
+ const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
275
+ const child = spawn(
276
+ process.execPath,
277
+ [
278
+ vitest,
279
+ 'run',
280
+ 'src/session-end-log-lock-worker.test.ts',
281
+ '--reporter=dot',
282
+ '--maxWorkers=1',
283
+ ],
284
+ {
285
+ cwd: process.cwd(),
286
+ env: {
287
+ ...process.env,
288
+ ...env,
289
+ NOTIFAI_SESSION_END_LOG_LOCK_WORKER_MODE: 'hold',
290
+ NOTIFAI_SESSION_END_LOG_LOCK_PATH: lockPath,
291
+ NOTIFAI_SESSION_END_LOG_LOCK_READY: readyPath,
292
+ NOTIFAI_SESSION_END_LOG_LOCK_RELEASE: releasePath,
293
+ },
294
+ stdio: ['ignore', 'pipe', 'pipe'],
295
+ },
296
+ )
297
+ const stdout: Buffer[] = []
298
+ const stderr: Buffer[] = []
299
+ child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
300
+ child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
301
+ const done = new Promise<void>((resolve, reject) => {
302
+ child.once('error', reject)
303
+ child.once('exit', (code) => {
304
+ if (code === 0) resolve()
305
+ else {
306
+ reject(
307
+ new Error(
308
+ `SessionEnd log lock worker exited ${String(code)}\n${Buffer.concat(stdout).toString()}\n${Buffer.concat(stderr).toString()}`,
309
+ ),
310
+ )
311
+ }
312
+ })
313
+ })
314
+ void done.catch(() => undefined)
315
+ return { child, readyPath, releasePath, done }
316
+ }
317
+
318
+ interface CliProcessResult {
319
+ code: number | null
320
+ signal: NodeJS.Signals | null
321
+ stdout: string
322
+ stderr: string
323
+ durationMs: number
324
+ }
325
+
326
+ function runSessionEndCli(
327
+ env: NodeJS.ProcessEnv,
328
+ cwd: string,
329
+ sessionId: string,
330
+ ): { child: ChildProcess; done: Promise<CliProcessResult> } {
331
+ const startedAt = Date.now()
332
+ const child = spawn(
333
+ process.execPath,
334
+ [
335
+ path.join(process.cwd(), 'dist', 'main.js'),
336
+ 'hook',
337
+ 'session-end',
338
+ '--owner',
339
+ 'notifai',
340
+ '--harness',
341
+ 'codex',
342
+ ],
343
+ {
344
+ cwd,
345
+ env: { ...process.env, ...env, NOTIFAI_CREDENTIALS: 'file' },
346
+ stdio: ['pipe', 'pipe', 'pipe'],
347
+ },
348
+ )
349
+ const stdout: Buffer[] = []
350
+ const stderr: Buffer[] = []
351
+ child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
352
+ child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
353
+ child.stdin!.end(JSON.stringify({ session_id: sessionId, cwd }))
354
+ const done = new Promise<CliProcessResult>((resolve, reject) => {
355
+ child.once('error', reject)
356
+ child.once('exit', (code, signal) => {
357
+ resolve({
358
+ code,
359
+ signal,
360
+ stdout: Buffer.concat(stdout).toString(),
361
+ stderr: Buffer.concat(stderr).toString(),
362
+ durationMs: Date.now() - startedAt,
363
+ })
364
+ })
365
+ })
366
+ void done.catch(() => undefined)
367
+ return { child, done }
368
+ }
369
+
370
+ function writeGlobalConfig(h: Harness, toml: string): void {
371
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
372
+ mkdirSync(dir, { recursive: true })
373
+ writeFileSync(path.join(dir, 'config.toml'), toml)
374
+ }
375
+
376
+ function presenceGatedConfig(cwd: string, env: NodeJS.ProcessEnv) {
377
+ const config = loadConfig({ cwd, env })
378
+ config.require_idle = { value: true, source: 'global:test' }
379
+ return config
380
+ }
381
+
195
382
  /** Long enough ago to be away under the 120s default. */
196
383
  const AWAY = NOW - 600_000
197
384
  const PRESENT = NOW - 5_000
@@ -199,13 +386,13 @@ const PRESENT = NOW - 5_000
199
386
  describe('presence gate', () => {
200
387
  it('treats a never-seen session as present, so a missing hook cannot hijack the terminal', () => {
201
388
  const { env, deps } = harness()
202
- const config = loadConfig({ cwd: deps.cwd, env })
389
+ const config = presenceGatedConfig(deps.cwd, env)
203
390
  expect(isUserAway({}, config, NOW, null)).toBe(false)
204
391
  })
205
392
 
206
393
  it('is away only once the configured silence has elapsed', () => {
207
394
  const { env, deps } = harness()
208
- const config = loadConfig({ cwd: deps.cwd, env })
395
+ const config = presenceGatedConfig(deps.cwd, env)
209
396
  expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, null)).toBe(false)
210
397
  expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
211
398
  })
@@ -214,7 +401,7 @@ describe('presence gate', () => {
214
401
  // minutes of watching. Elapsed time alone said away; the machine knows better.
215
402
  it('keeps a user who is watching a long turn present, however long the turn ran', () => {
216
403
  const { env, deps } = harness()
217
- const config = loadConfig({ cwd: deps.cwd, env })
404
+ const config = presenceGatedConfig(deps.cwd, env)
218
405
  expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 3)).toBe(false)
219
406
  })
220
407
 
@@ -224,13 +411,14 @@ describe('presence gate', () => {
224
411
  // away" case the feature is for.
225
412
  it('lets a freshly spawned session escalate when the machine says nobody is there', () => {
226
413
  const { env, deps } = harness()
227
- const config = loadConfig({ cwd: deps.cwd, env })
414
+ const config = presenceGatedConfig(deps.cwd, env)
228
415
  expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, 900)).toBe(true)
229
416
  expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 900)).toBe(true)
230
417
  })
231
418
 
232
419
  it('pushes a spawned session first question once the machine has gone quiet', async () => {
233
420
  const h = harness([reply({ text: 'Yes' })], 900)
421
+ writeGlobalConfig(h, 'require_idle = true\n')
234
422
  // Prompt 20s ago, exactly as a just-spawned agent has.
235
423
  writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
236
424
  registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
@@ -268,13 +456,14 @@ describe('presence gate', () => {
268
456
 
269
457
  it('falls back to elapsed time where no idle source exists', () => {
270
458
  const { env, deps } = harness()
271
- const config = loadConfig({ cwd: deps.cwd, env })
459
+ const config = presenceGatedConfig(deps.cwd, env)
272
460
  expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
273
461
  })
274
462
 
275
463
  it('does not push a question to the phone while the user is at the keyboard', async () => {
276
464
  // Silent for ten minutes by the session clock, but active by the machine's.
277
465
  const h = harness([reply({ text: 'Yes' })], 2)
466
+ writeGlobalConfig(h, 'require_idle = true\n')
278
467
  writeSessionState('idle1', h.env, { last_prompt_at: AWAY })
279
468
  registerQuestion('idle1', h.env, { question: 'Ship it?' })
280
469
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'idle1' }))
@@ -283,6 +472,199 @@ describe('presence gate', () => {
283
472
  })
284
473
  })
285
474
 
475
+ /**
476
+ * A hook's decisions are invisible from everywhere else: its stderr belongs to
477
+ * the harness, and its usual outcome is to do nothing at all. These are the
478
+ * tests that the local log closes that gap — that "my question never reached my
479
+ * phone" has an answer rather than a silence.
480
+ */
481
+ describe('what the hook leaves behind', () => {
482
+ function recording(h: Harness): CommandDeps {
483
+ return { ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) }
484
+ }
485
+
486
+ function gates(h: Harness): LogRecord[] {
487
+ return readLogRecords(h.env, { event: ['hook.gate'], limit: 50 }).records
488
+ }
489
+
490
+ it('records why a question was held, in a name a filter can match', async () => {
491
+ const h = harness([], 2)
492
+ writeGlobalConfig(h, 'require_idle = true\n')
493
+ writeSessionState('held', h.env, { last_prompt_at: AWAY })
494
+ registerQuestion('held', h.env, { question: 'Ship it?' })
495
+
496
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'held', cwd: h.deps.cwd }))
497
+
498
+ const held = gates(h).find((record) => record.data?.['reason'] === 'user-present')
499
+ expect(held).toBeDefined()
500
+ expect(held!.data).toMatchObject({ verdict: 'held', idle_seconds: 2, away_after_seconds: 120 })
501
+ })
502
+
503
+ it('records the switch being off, which the user is deliberately never told', async () => {
504
+ // Silent to the user by design, so from outside "you turned it off" and "a
505
+ // bug ate my question" look identical. This is what tells them apart.
506
+ const h = harness([], 600)
507
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
508
+ mkdirSync(dir, { recursive: true })
509
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_notifications = false\n')
510
+ writeSessionState('off', h.env, { last_prompt_at: AWAY })
511
+ registerQuestion('off', h.env, { question: 'Ship it?' })
512
+
513
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'off', cwd: h.deps.cwd }))
514
+
515
+ expect(h.recorder.submitted).toHaveLength(0)
516
+ // Nothing was printed, and the log is the only account of it.
517
+ expect(h.io.errLines.join('\n')).not.toContain('notifications')
518
+ expect(gates(h).map((record) => record.data?.['reason'])).toContain('notifications-off')
519
+ })
520
+
521
+ it('records a turn with no question at all, so "did the hook run" is answerable', async () => {
522
+ const h = harness()
523
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'quiet', cwd: h.deps.cwd }))
524
+ const events = readLogRecords(h.env, { limit: 50 }).records.map((record) => record.event)
525
+ expect(events).toContain('hook.start')
526
+ expect(events).toContain('hook.end')
527
+ expect(gates(h).map((record) => record.data?.['reason'])).toContain('no-question')
528
+ })
529
+
530
+ it('lets the hook project re-enable a bootstrap logger and records resolved config', async () => {
531
+ const h = harness()
532
+ writeGlobalConfig(h, 'log_level = "off"\n')
533
+ const startup = path.join(h.deps.cwd, 'startup')
534
+ const project = path.join(h.deps.cwd, 'project')
535
+ mkdirSync(startup, { recursive: true })
536
+ mkdirSync(path.join(project, '.notifai'), { recursive: true })
537
+ writeFileSync(
538
+ path.join(project, '.notifai', 'config.toml'),
539
+ 'project = "hook-project"\nlog_level = "debug"\n',
540
+ )
541
+ const deps = { ...h.deps, cwd: startup, logger: bootstrapLogger({ env: h.env, cwd: startup }) }
542
+
543
+ await hookRunCommand(deps, 'stop', stdin({ session_id: 'reenabled', cwd: project }))
544
+
545
+ const records = readLogRecords(h.env, { limit: 50 }).records
546
+ expect(records.map((record) => record.event)).toEqual(
547
+ expect.arrayContaining(['config.resolved', 'hook.start', 'hook.end']),
548
+ )
549
+ expect(records.find((record) => record.event === 'config.resolved')).toMatchObject({
550
+ project: 'hook-project',
551
+ session: 'reenabled',
552
+ })
553
+ })
554
+
555
+ it('persists an answer once while still reporting it to the harness', async () => {
556
+ const h = harness([reply({ text: 'Allow exactly once' })], 900)
557
+ writeSessionState('answer-log', h.env, { last_prompt_at: AWAY })
558
+ registerQuestion('answer-log', h.env, { question: 'Ship it?' })
559
+
560
+ await hookRunCommand(
561
+ recording(h),
562
+ 'stop',
563
+ stdin({ session_id: 'answer-log', cwd: h.deps.cwd }),
564
+ )
565
+
566
+ const raw = readFileSync(activeLogPath(h.env), 'utf8')
567
+ expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
568
+ expect(readLogRecords(h.env, { event: ['hook.answer'] }).records).toHaveLength(1)
569
+ expect(h.io.errLines.join('\n')).toContain('Allow exactly once')
570
+ })
571
+
572
+ it('records the push and ties it to the request id the server knows', async () => {
573
+ const h = harness([], 600)
574
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
575
+ mkdirSync(dir, { recursive: true })
576
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
577
+ writeSessionState('pushed', h.env, { last_prompt_at: AWAY })
578
+ registerQuestion('pushed', h.env, { question: 'Ship it?' })
579
+
580
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'pushed', cwd: h.deps.cwd }))
581
+
582
+ const pushed = readLogRecords(h.env, { event: ['hook.pushed'] }).records
583
+ expect(pushed).toHaveLength(1)
584
+ expect(pushed[0]!.data).toMatchObject({ ok: true })
585
+ // The local record and the server's evidence trail share one id, which is
586
+ // what makes `notifai logs --request <id>` and `notifai status <id>` two
587
+ // views of one thing.
588
+ expect(pushed[0]!.data!['request_id']).toBe(h.recorder.receipts[0])
589
+ })
590
+
591
+ it('stores every request in a multi-question wait as a structured identity', async () => {
592
+ const h = harness([], 900)
593
+ writeSessionState('request-log', h.env, { last_prompt_at: AWAY })
594
+ registerQuestion('request-log', h.env, { question: 'Ship it?' }, NOW)
595
+ registerQuestion('request-log', h.env, { question: 'Deploy it?' }, NOW + 1)
596
+
597
+ await hookRunCommand(
598
+ recording(h),
599
+ 'stop',
600
+ stdin({ session_id: 'request-log', cwd: h.deps.cwd }),
601
+ )
602
+
603
+ const unanswered = readLogRecords(h.env, { event: ['hook.answer'] }).records.at(-1)
604
+ expect(unanswered?.data?.['request_ids']).toEqual(h.recorder.receipts)
605
+ expect(readSessionState('request-log', h.env).pending).toBeUndefined()
606
+
607
+ for (const requestId of h.recorder.receipts) {
608
+ const matching = readLogRecords(h.env, { request: requestId }).records
609
+ expect(matching).toContainEqual(unanswered)
610
+ }
611
+ expect(readLogRecords(h.env, { request: 'req_hook' }).records).not.toContainEqual(unanswered)
612
+ })
613
+
614
+ it('records a config parse failure as a complete fail-open lifecycle', async () => {
615
+ const h = harness()
616
+ mkdirSync(path.join(h.deps.cwd, '.notifai'), { recursive: true })
617
+ writeFileSync(path.join(h.deps.cwd, '.notifai', 'config.toml'), 'not valid = [toml')
618
+
619
+ await hookRunCommand(
620
+ { ...h.deps, logger: createLogger({ env: h.env }) },
621
+ 'stop',
622
+ stdin({ session_id: 'bad-config', cwd: h.deps.cwd }),
623
+ )
624
+
625
+ const records = readLogRecords(h.env, { limit: 10 }).records
626
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
627
+ expect(records.at(-1)?.data).toMatchObject({ outcome: 'failed', reason: 'config-failed' })
628
+ })
629
+
630
+ it('records a hook failure with the server own words, not just that it failed', async () => {
631
+ const h = harness()
632
+ const deps: CommandDeps = {
633
+ ...recording(h),
634
+ clientFactory: () => {
635
+ throw new ApiCallError(422, 'validation_failed', 'draft rejected', null, [
636
+ { path: '/draft/lifecycle' },
637
+ ])
638
+ },
639
+ }
640
+ registerQuestion('broken', h.env, { question: 'Ship it?' })
641
+ writeSessionState('broken', h.env, { last_prompt_at: AWAY })
642
+
643
+ await hookRunCommand(deps, 'stop', stdin({ session_id: 'broken', cwd: h.deps.cwd }))
644
+
645
+ const failures = readLogRecords(h.env, { level: 'error' }).records
646
+ expect(failures.length).toBeGreaterThan(0)
647
+ expect(JSON.stringify(failures)).toContain('/draft/lifecycle')
648
+ expect(JSON.stringify(failures)).toContain('validation_failed')
649
+ })
650
+
651
+ it('never writes the machine credential, whatever the hook did', async () => {
652
+ const h = harness([], 600)
653
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
654
+ mkdirSync(dir, { recursive: true })
655
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
656
+ writeSessionState('secret', h.env, { last_prompt_at: AWAY })
657
+ registerQuestion('secret', h.env, { question: 'Ship it?' })
658
+
659
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'secret', cwd: h.deps.cwd }))
660
+
661
+ // The log is the artefact most likely to be pasted into a conversation.
662
+ const raw = readFileSync(activeLogPath(h.env), 'utf8')
663
+ expect(raw).not.toContain('test-secret')
664
+ expect(raw).not.toContain('nfm_mac_test')
665
+ })
666
+ })
667
+
286
668
  /**
287
669
  * Wanting notifications while still at the machine is a legitimate setting —
288
670
  * nobody should have to stop using their computer in order to be reached.
@@ -290,31 +672,29 @@ describe('presence gate', () => {
290
672
  * it off must not disturb the grace timer.
291
673
  */
292
674
  describe('presence gating is optional (require_idle)', () => {
293
- function writeGlobalConfig(h: Harness, toml: string): void {
294
- const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
295
- mkdirSync(dir, { recursive: true })
296
- writeFileSync(path.join(dir, 'config.toml'), toml)
297
- }
298
-
299
- it('counts a user actively typing as reachable', () => {
675
+ it('consults active typing only when presence gating is explicitly enabled', () => {
300
676
  // One second of idle: as present as it is possible to be.
301
- const config = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
302
- expect(isUserAway({ last_prompt_at: NOW }, config, NOW, 1)).toBe(false)
677
+ const ungated = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
678
+ expect(isUserAway({ last_prompt_at: NOW }, ungated, NOW, 1)).toBe(true)
303
679
 
304
- const off = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
305
- off.require_idle = { value: false, source: 'default' }
306
- expect(isUserAway({ last_prompt_at: NOW }, off, NOW, 1)).toBe(true)
680
+ const gated = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
681
+ gated.require_idle = { value: true, source: 'global:test' }
682
+ expect(isUserAway({ last_prompt_at: NOW }, gated, NOW, 1)).toBe(false)
307
683
  })
308
684
 
309
- it('escalates a question while the user is at the keyboard', async () => {
685
+ it('escalates immediately by default while the user is at the keyboard', async () => {
310
686
  const h = harness([], 1)
311
- writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 0\n')
687
+ let submittedAt: number | undefined
688
+ h.recorder.beforeQuestionSubmit = () => {
689
+ submittedAt = h.deps.now?.()
690
+ }
312
691
  writeSessionState('present1', h.env, { last_prompt_at: NOW })
313
692
  registerQuestion('present1', h.env, { question: 'Ship it?' })
314
693
 
315
694
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
316
695
 
317
- expect(h.recorder.submitted).toHaveLength(1)
696
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
697
+ expect(submittedAt).toBe(NOW)
318
698
  expect(h.io.errLines.join('\n')).not.toContain('at the keyboard')
319
699
  })
320
700
 
@@ -328,7 +708,7 @@ describe('presence gating is optional (require_idle)', () => {
328
708
 
329
709
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present2' }))
330
710
 
331
- expect(h.recorder.submitted).toHaveLength(1)
711
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
332
712
  // The clock only advances when the hook sleeps, so this is proof the wait
333
713
  // actually happened rather than being skipped.
334
714
  expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
@@ -344,7 +724,7 @@ describe('presence gating is optional (require_idle)', () => {
344
724
 
345
725
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
346
726
 
347
- expect(h.recorder.submitted).toHaveLength(1)
727
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
348
728
  expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
349
729
  expect(h.io.errLines.join('\n')).not.toContain('no idle signal')
350
730
  })
@@ -373,10 +753,11 @@ describe('terminal-first grace window', () => {
373
753
  // Idle 900s: the user is gone, so the wait runs to completion rather than
374
754
  // being abandoned. Sleeps advance the virtual clock.
375
755
  const h = harness([reply({ text: 'Yes' })], 900)
756
+ writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
376
757
  pending(h, 'g1', 0)
377
758
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
378
759
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
379
- // Nothing was pushed before the 300s default had passed.
760
+ // Nothing was pushed before the explicitly configured 300s had passed.
380
761
  expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
381
762
  })
382
763
 
@@ -392,6 +773,7 @@ describe('terminal-first grace window', () => {
392
773
  it('sends nothing if the user comes back to the keyboard during the window', async () => {
393
774
  let idle = 900
394
775
  const h = harness([reply({ text: 'Yes' })], 900)
776
+ writeGlobalConfig(h, 'require_idle = true\n')
395
777
  // Machine goes active on the second poll: the user sat down.
396
778
  h.deps.idleSeconds = () => {
397
779
  const current = idle
@@ -408,6 +790,7 @@ describe('terminal-first grace window', () => {
408
790
  // No idle source: waiting would block the prompt with no way to notice the
409
791
  // user returning, so it asks immediately instead.
410
792
  const h = harness([reply({ text: 'Yes' })], null)
793
+ writeGlobalConfig(h, 'require_idle = true\n')
411
794
  pending(h, 'g4', 0)
412
795
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g4' }))
413
796
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
@@ -416,20 +799,73 @@ describe('terminal-first grace window', () => {
416
799
  })
417
800
 
418
801
  it('never lets the window crowd out the reply wait past the hook budget', async () => {
419
- // Both dials at maximum would be 540 + 540 — nearly twice the 600s ceiling
420
- // the harness kills a hook at, and a killed hook loses an answer already
421
- // given. The reply wait wins, so the window yields to nothing at all.
802
+ // Both preferences can be large, but the shared budget honors maximum
803
+ // grace while retaining a protocol-valid answer window and scheduling
804
+ // slack. Real timers do not wake on the exact requested millisecond.
422
805
  const h = harness([reply({ text: 'Yes' })], 900)
806
+ const virtualSleep = h.deps.sleep!
807
+ h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
423
808
  const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
424
809
  mkdirSync(dir, { recursive: true })
425
810
  writeFileSync(
426
811
  path.join(dir, 'config.toml'),
427
- 'ask_grace_seconds = 540\nhook_reply_timeout_seconds = 540\n',
812
+ 'ask_grace_seconds = 334\nhook_reply_timeout_seconds = 540\n',
428
813
  )
429
814
  pending(h, 'g5', 0)
430
815
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
431
816
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
432
- expect(h.deps.now?.()).toBe(NOW)
817
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 334_000)
818
+ expect(h.deps.now?.()).toBeLessThan(NOW + 335_000)
819
+ expect(
820
+ h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
821
+ ?.expires_in_seconds,
822
+ ).toBeGreaterThanOrEqual(60)
823
+ })
824
+
825
+ it('reserves transport overrun and close time inside the whole Stop budget', async () => {
826
+ const h = harness([], 900)
827
+ const factory = h.deps.clientFactory
828
+ h.deps.clientFactory = () => {
829
+ const client = factory!()
830
+ return {
831
+ ...client,
832
+ listDevices: async () => {
833
+ h.advanceClock(20_000)
834
+ return client.listDevices()
835
+ },
836
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
837
+ h.advanceClock(20_000)
838
+ return client.submit(body, waitSeconds)
839
+ },
840
+ replies: async (
841
+ requestId: string,
842
+ options: { waitSeconds: number; afterSeq: number },
843
+ ) => {
844
+ // Server hold plus the client's 20s AbortSignal allowance.
845
+ h.advanceClock((options.waitSeconds + 20) * 1000)
846
+ return {
847
+ request_id: requestId,
848
+ reply_expires_at: new Date(NOW + 480_000).toISOString(),
849
+ replies: [],
850
+ }
851
+ },
852
+ closeReplies: async (requestId: string) => {
853
+ h.advanceClock(20_000)
854
+ return client.closeReplies(requestId)
855
+ },
856
+ } as ApiClient
857
+ }
858
+ writeGlobalConfig(h, 'ask_grace_seconds = 250\nhook_reply_timeout_seconds = 540\n')
859
+ pending(h, 'transport-budget', 0)
860
+
861
+ await hookRunCommand(h.deps, 'stop', async () => {
862
+ // stdin/config/setup belong to the same invocation budget as polling.
863
+ h.advanceClock(30_000)
864
+ return JSON.stringify({ session_id: 'transport-budget' })
865
+ })
866
+
867
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
868
+ expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
433
869
  })
434
870
  })
435
871
 
@@ -465,7 +901,7 @@ describe('nagging guards', () => {
465
901
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
466
902
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
467
903
 
468
- expect(h.recorder.submitted).toHaveLength(1)
904
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
469
905
  })
470
906
 
471
907
  it('respects the harness recursion guard', async () => {
@@ -516,21 +952,24 @@ describe('nagging guards', () => {
516
952
  })
517
953
 
518
954
  describe('late answer collection', () => {
519
- it('collects a late answer on Stop and resumes with it instead of asking twice', async () => {
520
- const answers: ReplyView[] = []
521
- const h = harness(answers, 900)
522
- writeSessionState('late-stop', h.env, { last_prompt_at: AWAY })
523
- registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
524
-
525
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
526
- expect(readSessionState('late-stop', h.env).pending?.[0]?.request_id).toBe('req_hook_1')
527
- answers.push(reply({ text: 'Ship it' }))
528
- h.io.outLines = []
955
+ it('collects an answer that arrived during the resumed model turn', async () => {
956
+ const h = harness([reply({ text: 'Ship it' })], 900)
957
+ writeSessionState('late-stop', h.env, {
958
+ last_prompt_at: AWAY,
959
+ pending: [{
960
+ question: 'Ship it?',
961
+ asked_at: NOW,
962
+ request_id: 'req_live',
963
+ collapse_key: 'collapse-live',
964
+ device_ids: ['dev_iphone'],
965
+ reply_deadline_at: NOW + 60_000,
966
+ }],
967
+ })
529
968
 
530
969
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
531
970
 
532
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
533
- expect(h.recorder.closed).toContain('req_hook_1')
971
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
972
+ expect(h.recorder.closed).toContain('req_live')
534
973
  expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
535
974
  expect(h.io.outLines).toHaveLength(1)
536
975
  expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
@@ -540,13 +979,18 @@ describe('late answer collection', () => {
540
979
  })
541
980
 
542
981
  it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
543
- const answers: ReplyView[] = []
544
- const h = harness(answers, 900)
545
- writeSessionState('late-prompt', h.env, { last_prompt_at: AWAY })
546
- registerQuestion('late-prompt', h.env, { question: 'Ship it?' }, NOW)
547
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
548
- answers.push(reply({ text: 'Hold' }))
549
- h.io.outLines = []
982
+ const h = harness([reply({ text: 'Hold' })], 900)
983
+ writeSessionState('late-prompt', h.env, {
984
+ last_prompt_at: AWAY,
985
+ pending: [{
986
+ question: 'Ship it?',
987
+ asked_at: NOW,
988
+ request_id: 'req_live',
989
+ collapse_key: 'collapse-live',
990
+ device_ids: ['dev_iphone'],
991
+ reply_deadline_at: NOW + 60_000,
992
+ }],
993
+ })
550
994
 
551
995
  await hookRunCommand(
552
996
  h.deps,
@@ -554,49 +998,229 @@ describe('late answer collection', () => {
554
998
  stdin({ session_id: 'late-prompt', cwd: h.deps.cwd }),
555
999
  )
556
1000
 
1001
+ expect(readSessionState('late-prompt', h.env).accepted?.answers[0]?.reply.text).toBe('Hold')
1002
+ expect(h.io.outLines).toEqual([])
1003
+
1004
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
1005
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
1006
+ await hookRunCommand(
1007
+ h.deps,
1008
+ 'stop',
1009
+ stdin({ session_id: 'late-prompt', stop_hook_active: true }),
1010
+ )
1011
+
557
1012
  const retirement = h.recorder.submitted.find(
558
1013
  (entry) => entry.draft.event === 'question_retired',
559
1014
  )?.draft
560
1015
  expect(retirement?.lifecycle).toMatchObject({
561
1016
  state: 'answered',
562
- retires_request_id: 'req_hook_1',
1017
+ retires_request_id: 'req_live',
563
1018
  })
564
1019
  expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
565
1020
  expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
566
- expect(JSON.parse(h.io.outLines[0] ?? '{}')).toEqual({
567
- hookSpecificOutput: {
568
- hookEventName: 'UserPromptSubmit',
569
- additionalContext: expect.stringContaining('Hold'),
570
- },
571
- })
1021
+ expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
572
1022
  })
573
1023
  })
574
1024
 
575
- describe('OpenCode answer preservation', () => {
576
- it('pushes without waiting and leaves the answerable request intact', async () => {
1025
+ describe('OpenCode answer continuation', () => {
1026
+ it('fails closed instead of accepting an answer the idle event cannot deliver', async () => {
577
1027
  const h = harness([reply({ text: 'Approve' })], 900)
578
1028
  writeSessionState('open1', h.env, { last_prompt_at: AWAY })
579
1029
  registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
580
1030
 
581
1031
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
582
1032
 
583
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
1033
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
584
1034
  expect(h.recorder.closed).toEqual([])
585
1035
  expect(
586
1036
  h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
587
- ).toEqual([])
588
- expect(readSessionState('open1', h.env).pending?.[0]).toMatchObject({
589
- question: 'Deploy?',
590
- request_id: 'req_hook_1',
591
- device_ids: ['dev_iphone', 'dev_mac'],
592
- })
1037
+ ).toHaveLength(0)
1038
+ expect(readSessionState('open1', h.env).pending).toBeUndefined()
593
1039
  expect(h.io.outLines).toEqual([])
1040
+ expect(h.io.errLines.join('\n')).toContain('no proven answer continuation')
594
1041
  })
595
1042
  })
596
1043
 
597
1044
  describe('reply-wait presence monitoring', () => {
1045
+ it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
1046
+ const h = harness([], 900)
1047
+ const factory = h.deps.clientFactory
1048
+ h.deps.clientFactory = () => {
1049
+ const client = factory!()
1050
+ return {
1051
+ ...client,
1052
+ replies: async (requestId: string) => ({
1053
+ request_id: requestId,
1054
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
1055
+ replies: [],
1056
+ }),
1057
+ closeReplies: async (requestId: string) => {
1058
+ h.recorder.closed.push(requestId)
1059
+ return {
1060
+ request_id: requestId,
1061
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
1062
+ replies: [reply({ text: 'Committed at the fence' })],
1063
+ }
1064
+ },
1065
+ } as ApiClient
1066
+ }
1067
+ writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
1068
+ writeSessionState('fenced-reply', h.env, { last_prompt_at: AWAY })
1069
+ registerQuestion('fenced-reply', h.env, { question: 'Deploy?' }, NOW)
1070
+
1071
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fenced-reply' }))
1072
+
1073
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain(
1074
+ 'Committed at the fence',
1075
+ )
1076
+ expect(readSessionState('fenced-reply', h.env).accepted).toBeDefined()
1077
+ })
1078
+
1079
+ it('preserves a silent question when the close fence cannot be proven', async () => {
1080
+ const h = harness([], 900)
1081
+ const factory = h.deps.clientFactory
1082
+ h.deps.clientFactory = () => {
1083
+ const client = factory!()
1084
+ return {
1085
+ ...client,
1086
+ closeReplies: async () => {
1087
+ throw new Error('partitioned during close')
1088
+ },
1089
+ } as ApiClient
1090
+ }
1091
+ writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
1092
+ writeSessionState('unproven-close', h.env, { last_prompt_at: AWAY })
1093
+ registerQuestion('unproven-close', h.env, { question: 'Deploy?' }, NOW)
1094
+
1095
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'unproven-close' }))
1096
+
1097
+ const state = readSessionState('unproven-close', h.env)
1098
+ expect(state.pending?.[0]?.request_id).toBe(h.recorder.receipts[0])
1099
+ expect(state.retiring).toBeUndefined()
1100
+ expect(h.io.errLines.join('\n')).toContain('preserving ownership')
1101
+ })
1102
+
1103
+ it('recovers an accepted question after its submit response is lost', async () => {
1104
+ const h = harness([], 900)
1105
+ const factory = h.deps.clientFactory
1106
+ let acceptedRequestId: string | undefined
1107
+ h.deps.clientFactory = () => {
1108
+ const client = factory!()
1109
+ return {
1110
+ ...client,
1111
+ submit: async (body: SubmitNotificationRequestT) => {
1112
+ if (body.draft.event !== 'agent_question') return client.submit(body, 0)
1113
+ acceptedRequestId = body.request_id
1114
+ h.recorder.submitted.push(body)
1115
+ h.recorder.receipts.push(body.request_id!)
1116
+ h.recorder.aliases.set(body.request_id!, 1)
1117
+ throw new Error('response lost after commit')
1118
+ },
1119
+ replies: async (requestId: string) => ({
1120
+ request_id: requestId,
1121
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
1122
+ replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
1123
+ }),
1124
+ closeReplies: async (requestId: string) => ({
1125
+ request_id: requestId,
1126
+ reply_expires_at: new Date(NOW).toISOString(),
1127
+ replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
1128
+ }),
1129
+ } as ApiClient
1130
+ }
1131
+ writeSessionState('ambiguous-submit', h.env, { last_prompt_at: AWAY })
1132
+ registerQuestion('ambiguous-submit', h.env, { question: 'Deploy?' }, NOW)
1133
+
1134
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ambiguous-submit' }))
1135
+
1136
+ expect(acceptedRequestId).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1137
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Yes, recover it')
1138
+ expect(readSessionState('ambiguous-submit', h.env).accepted?.answers[0]?.pending.request_id)
1139
+ .toBe(acceptedRequestId)
1140
+ })
1141
+
1142
+ it('replays the frozen intent when an ambiguous submit never reached the server', async () => {
1143
+ const h = harness([], 900)
1144
+ const factory = h.deps.clientFactory
1145
+ let firstAttempt: SubmitNotificationRequestT | undefined
1146
+ h.deps.clientFactory = () => {
1147
+ const client = factory!()
1148
+ return {
1149
+ ...client,
1150
+ submit: async (body: SubmitNotificationRequestT) => {
1151
+ firstAttempt = body
1152
+ throw new Error('connection failed before commit')
1153
+ },
1154
+ replies: async () => {
1155
+ throw new ApiCallError(404, 'not_found', 'No such request.')
1156
+ },
1157
+ closeReplies: async () => {
1158
+ throw new ApiCallError(404, 'not_found', 'No such request.')
1159
+ },
1160
+ } as ApiClient
1161
+ }
1162
+ writeSessionState('precommit-loss', h.env, { last_prompt_at: AWAY })
1163
+ registerQuestion('precommit-loss', h.env, { question: 'Deploy?' }, NOW)
1164
+
1165
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
1166
+
1167
+ const stranded = readSessionState('precommit-loss', h.env).pending?.[0]
1168
+ expect(stranded?.request_id).toBeUndefined()
1169
+ expect(stranded?.submission).toBeDefined()
1170
+ expect(h.io.errLines.join('\n')).toContain('response was ambiguous')
1171
+
1172
+ h.advanceClock(600_000)
1173
+ h.deps.clientFactory = factory
1174
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
1175
+
1176
+ expect(h.recorder.submitted[0]).toEqual(firstAttempt)
1177
+ })
1178
+
1179
+ it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
1180
+ const h = harness([], 900)
1181
+ const factory = h.deps.clientFactory
1182
+ let polls = 0
1183
+ h.deps.clientFactory = () => {
1184
+ const client = factory!()
1185
+ return {
1186
+ ...client,
1187
+ submit: async () => {
1188
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1189
+ },
1190
+ replies: async () => {
1191
+ polls += 1
1192
+ throw new Error('must not poll an unaccepted request')
1193
+ },
1194
+ } as ApiClient
1195
+ }
1196
+ writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1197
+ registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1198
+
1199
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1200
+
1201
+ const pending = readSessionState('rejected-submit', h.env).pending?.[0]
1202
+ expect(pending?.request_id).toBeUndefined()
1203
+ expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1204
+ expect(polls).toBe(0)
1205
+ expect(h.io.errLines.join('\n')).toContain('submission was rejected')
1206
+
1207
+ const frozen = pending?.submission
1208
+ if (frozen === undefined) throw new Error('expected a persisted submission intent')
1209
+ writeGlobalConfig(h, 'project = "changed-after-crash"\n')
1210
+ h.advanceClock(600_000)
1211
+ h.deps.clientFactory = factory
1212
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1213
+
1214
+ expect(h.recorder.submitted[0]).toEqual({
1215
+ request_id: frozen.request_id,
1216
+ idempotency_key: frozen.idempotency_key,
1217
+ draft: frozen.draft,
1218
+ })
1219
+ })
1220
+
598
1221
  it('returns the terminal when the user comes back after the push', async () => {
599
1222
  const h = harness([], 900)
1223
+ writeGlobalConfig(h, 'require_idle = true\n')
600
1224
  let checks = 0
601
1225
  h.deps.idleSeconds = () => {
602
1226
  checks += 1
@@ -605,19 +1229,92 @@ describe('reply-wait presence monitoring', () => {
605
1229
  writeSessionState('returned', h.env, { last_prompt_at: AWAY })
606
1230
  registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
607
1231
 
608
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
1232
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
1233
+
1234
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
1235
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1236
+ expect(readSessionState('returned', h.env).pending).toBeUndefined()
1237
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
1238
+ expect(h.io.errLines.join('\n')).toContain('came back')
1239
+ expect(h.io.errLines.join('\n')).toContain('retired before returning the terminal')
1240
+ })
1241
+
1242
+ it('closes and retires silence at the absolute owner deadline', async () => {
1243
+ const h = harness([], 900)
1244
+ const factory = h.deps.clientFactory
1245
+ h.deps.clientFactory = () => {
1246
+ const client = factory!()
1247
+ return {
1248
+ ...client,
1249
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => ({
1250
+ ...(await client.submit(body, waitSeconds)),
1251
+ // The real server commits the configured reply window, rather than
1252
+ // the generic recorder's deliberately broad 480s fixture.
1253
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
1254
+ }),
1255
+ } as ApiClient
1256
+ }
1257
+ writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
1258
+ writeSessionState('deadline', h.env, { last_prompt_at: AWAY })
1259
+ registerQuestion('deadline', h.env, { question: 'Deploy?' }, NOW)
1260
+
1261
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
1262
+
1263
+ expect(h.deps.now?.()).toBe(NOW + 60_000)
1264
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1265
+ expect(readSessionState('deadline', h.env).pending).toBeUndefined()
1266
+ expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
1267
+ })
1268
+
1269
+ it('charges sequential submission latency to one deadline', async () => {
1270
+ const h = harness([reply({ text: 'Done' })], 900)
1271
+ writeSessionState('latency', h.env, { last_prompt_at: AWAY })
1272
+ registerQuestion('latency', h.env, { question: 'First?' }, NOW)
1273
+ registerQuestion('latency', h.env, { question: 'Second?' }, NOW)
1274
+ h.recorder.beforeQuestionSubmit = () => h.advanceClock(40_000)
1275
+
1276
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
1277
+
1278
+ const windows = h.recorder.submitted
1279
+ .filter((entry) => entry.draft.event === 'agent_question')
1280
+ .map((entry) => entry.draft.reply?.expires_in_seconds)
1281
+ expect(windows).toEqual([395, 355])
1282
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 435_000)
1283
+ })
1284
+
1285
+ it('re-arms the next Stop for an independently pending answer', async () => {
1286
+ const h = harness([], 900)
1287
+ const answers = new Map<string, ReplyView[]>([
1288
+ ['req_hook_1', [reply({ text: 'First answer' })]],
1289
+ ['req_hook_2', []],
1290
+ ])
1291
+ h.recorder.repliesFor = (requestId) => answers.get(requestId) ?? []
1292
+ writeSessionState('successor', h.env, { last_prompt_at: AWAY })
1293
+ registerQuestion('successor', h.env, { question: 'First?' }, NOW)
1294
+ registerQuestion('successor', h.env, { question: 'Second?' }, NOW)
1295
+
1296
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'successor' }))
1297
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('First answer')
1298
+ expect(readSessionState('successor', h.env).pending?.map((entry) => entry.question)).toEqual([
1299
+ 'Second?',
1300
+ ])
609
1301
 
610
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
611
- expect(h.recorder.closed).toEqual([])
612
- expect(readSessionState('returned', h.env).pending?.[0]?.request_id).toBe('req_hook_1')
613
- expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
614
- expect(h.io.errLines.join('\n')).toContain('came back')
615
- expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
1302
+ answers.set('req_hook_2', [reply({ text: 'Second answer' })])
1303
+ h.io.outLines = []
1304
+ await hookRunCommand(
1305
+ h.deps,
1306
+ 'stop',
1307
+ stdin({ session_id: 'successor', stop_hook_active: true }),
1308
+ )
1309
+
1310
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
1311
+ expect(readSessionState('successor', h.env).pending).toBeUndefined()
1312
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(2)
616
1313
  })
617
1314
  })
618
1315
 
619
1316
  describe('Cursor stop output', () => {
620
- it('maps the phone answer to one native followup_message', async () => {
1317
+ it('fails closed when the invoking shell cannot name the exact conversation', async () => {
621
1318
  const h = harness([reply({ text: 'Ship it' })], 900)
622
1319
  writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
623
1320
  registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
@@ -633,12 +1330,9 @@ describe('Cursor stop output', () => {
633
1330
  'cursor',
634
1331
  )
635
1332
 
636
- expect(h.io.outLines).toHaveLength(1)
637
- const output = JSON.parse(h.io.outLines[0] ?? '{}') as Record<string, unknown>
638
- expect(output['followup_message']).toContain(
639
- 'Notifai — the user answered from Furankuphone: "Ship it". Continue with that answer.',
640
- )
641
- expect(output).not.toHaveProperty('decision')
1333
+ expect(h.recorder.submitted).toHaveLength(0)
1334
+ expect(h.io.outLines).toHaveLength(0)
1335
+ expect(h.io.errLines.join('\n')).toContain('asynchronous ask is unsupported')
642
1336
  })
643
1337
  })
644
1338
 
@@ -661,36 +1355,40 @@ describe('question queueing and retirement debt', () => {
661
1355
  }))
662
1356
  }
663
1357
 
664
- it('keeps the first question live and asks the second alongside it', async () => {
1358
+ function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
1359
+ writeSessionState(sessionId, h.env, {
1360
+ last_prompt_at: AWAY,
1361
+ pending: [{
1362
+ question: 'Ship it?',
1363
+ asked_at: NOW,
1364
+ request_id: requestId,
1365
+ collapse_key: 'collapse-live',
1366
+ device_ids: ['dev_iphone', 'dev_mac'],
1367
+ reply_deadline_at: NOW + 60_000,
1368
+ }],
1369
+ })
1370
+ }
1371
+
1372
+ it('registers a second question without mutating the live first question', () => {
665
1373
  const h = harness([])
666
- writeSessionState('sup1', h.env, { last_prompt_at: AWAY })
667
- registerQuestion('sup1', h.env, { question: 'Ship it?' })
668
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
669
- const first = h.recorder.receipts[0]
670
- expect(first).toBeDefined()
1374
+ seedLive(h, 'sup1')
671
1375
 
672
1376
  // The agent carried on and asked something else. The first question is
673
1377
  // still the user's to answer — a second question never ends the first;
674
1378
  // superseding is reply semantics, not question semantics.
675
1379
  registerQuestion('sup1', h.env, { question: 'Deploy it?' })
676
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
677
1380
 
678
1381
  expect(retirements(h)).toEqual([])
679
- expect(
680
- h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
681
- ).toHaveLength(1)
682
1382
  const live = readSessionState('sup1', h.env).pending
683
1383
  expect(live).toHaveLength(2)
684
1384
  expect(live?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
685
- expect(live?.every((entry) => entry.request_id !== undefined)).toBe(true)
1385
+ expect(live?.map((entry) => entry.request_id)).toEqual(['req_live', undefined])
686
1386
  })
687
1387
 
688
1388
  it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
689
1389
  const h = harness([])
690
- writeSessionState('sup2', h.env, { last_prompt_at: AWAY })
691
- registerQuestion('sup2', h.env, { question: 'Ship it?' })
692
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
693
- const first = h.recorder.receipts[0]
1390
+ seedLive(h, 'sup2')
1391
+ const first = 'req_live'
694
1392
 
695
1393
  // Offline when the user returns: the wipe parks the retirement, the
696
1394
  // drain fails, and it must not forget what it was for.
@@ -706,13 +1404,11 @@ describe('question queueing and retirement debt', () => {
706
1404
 
707
1405
  it('keeps the original delivery targets when routing changes before retirement', async () => {
708
1406
  const h = harness([])
709
- writeSessionState('sup-targets', h.env, { last_prompt_at: AWAY })
710
- registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
711
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
1407
+ seedLive(h, 'sup-targets')
712
1408
 
713
1409
  const live = readSessionState('sup-targets', h.env).pending?.[0]
714
1410
  expect(live).toMatchObject({
715
- request_id: 'req_hook_1',
1411
+ request_id: 'req_live',
716
1412
  device_ids: ['dev_iphone', 'dev_mac'],
717
1413
  })
718
1414
 
@@ -727,7 +1423,7 @@ describe('question queueing and retirement debt', () => {
727
1423
  const retirement = h.recorder.submitted.find(
728
1424
  (submission) => submission.draft.event === 'question_retired',
729
1425
  )
730
- expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_hook_1')
1426
+ expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_live')
731
1427
  expect(retirement?.draft.targets).toEqual({
732
1428
  mode: 'selected',
733
1429
  device_ids: ['dev_iphone', 'dev_mac'],
@@ -739,10 +1435,8 @@ describe('question queueing and retirement debt', () => {
739
1435
  // before every guard — retirement debt has nothing to do with whether
740
1436
  // this turn may ask.
741
1437
  const h = harness([])
742
- writeSessionState('sup3', h.env, { last_prompt_at: AWAY })
743
- registerQuestion('sup3', h.env, { question: 'Ship it?' })
744
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
745
- const first = h.recorder.receipts[0]
1438
+ seedLive(h, 'sup3')
1439
+ const first = 'req_live'
746
1440
 
747
1441
  h.recorder.failSubmits = true
748
1442
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
@@ -759,10 +1453,8 @@ describe('question queueing and retirement debt', () => {
759
1453
  // UserPromptSubmit resets session state to record presence, and that reset
760
1454
  // used to take the retirement queue with it.
761
1455
  const h = harness([])
762
- writeSessionState('sup4', h.env, { last_prompt_at: AWAY })
763
- registerQuestion('sup4', h.env, { question: 'Ship it?' })
764
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
765
- const first = h.recorder.receipts[0]
1456
+ seedLive(h, 'sup4')
1457
+ const first = 'req_live'
766
1458
 
767
1459
  // The first return is offline: the wipe parks, the drain fails, and the
768
1460
  // next prompt must still find the debt.
@@ -795,18 +1487,7 @@ describe('question queueing and retirement debt', () => {
795
1487
  describe('several questions in flight', () => {
796
1488
  /** Route each live request its own replies; the shared list cannot say who answered what. */
797
1489
  function repliesByRequest(h: Harness, byRequest: Map<string, ReplyView[]>): void {
798
- const factory = h.deps.clientFactory
799
- h.deps.clientFactory = () => {
800
- const client = factory!()
801
- return {
802
- ...client,
803
- replies: async (requestId: string) => ({
804
- request_id: requestId,
805
- reply_expires_at: null,
806
- replies: byRequest.get(requestId) ?? [],
807
- }),
808
- } as ApiClient
809
- }
1490
+ h.recorder.repliesFor = (requestId) => byRequest.get(requestId) ?? []
810
1491
  }
811
1492
 
812
1493
  it('escalates every registered question in one pass, each as its own notification', async () => {
@@ -822,8 +1503,155 @@ describe('several questions in flight', () => {
822
1503
  // Each is its own notification with its own collapse key — one ask never
823
1504
  // stands in for, or replaces, another.
824
1505
  expect(new Set(questions.map((s) => s.draft.delivery.collapse_key)).size).toBe(2)
825
- const live = readSessionState('multi1', h.env).pending
826
- expect(live?.map((entry) => entry.request_id)).toEqual(['req_hook_1', 'req_hook_2'])
1506
+ expect(readSessionState('multi1', h.env).pending).toBeUndefined()
1507
+ expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
1508
+ })
1509
+
1510
+ /**
1511
+ * A registered form/ask must become a durable server request at turn-end.
1512
+ * Codex and Claude both enter through the same Stop
1513
+ * handler; the harness flag only changes answer injection, not settlement.
1514
+ */
1515
+ it('settles a registered form into a durable request_id on the Codex Stop path', async () => {
1516
+ const h = harness([], 900)
1517
+ writeSessionState('codex-form', h.env, { last_prompt_at: AWAY })
1518
+ const built = buildQuestions(
1519
+ { form: JSON.stringify({
1520
+ questions: [
1521
+ { text: 'Start personally?', choices: ['Yes', 'No'] },
1522
+ { text: 'Which region?', choices: ['US', 'EU'] },
1523
+ ],
1524
+ }) },
1525
+ undefined,
1526
+ )
1527
+ expect(built.ok).toBe(true)
1528
+ if (!built.ok) return
1529
+ registerQuestion('codex-form', h.env, {
1530
+ question: built.questions[0]!.text,
1531
+ questions: built.questions,
1532
+ })
1533
+
1534
+ // Registration alone must not submit; durability happens at turn-end.
1535
+ expect(h.recorder.submitted).toEqual([])
1536
+ expect(readSessionState('codex-form', h.env).pending?.[0]?.request_id).toBeUndefined()
1537
+
1538
+ await hookRunCommand(
1539
+ h.deps,
1540
+ 'stop',
1541
+ stdin({ session_id: 'codex-form' }),
1542
+ 'codex',
1543
+ )
1544
+
1545
+ expect(h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')).toHaveLength(1)
1546
+ expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
1547
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1548
+ expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
1549
+ })
1550
+
1551
+ it('keeps a settled ask durable and collects the answer after a transient internal_error', async () => {
1552
+ const h = harness([], 900)
1553
+ let polls = 0
1554
+ const factory = h.deps.clientFactory
1555
+ h.deps.clientFactory = () => {
1556
+ const client = factory!()
1557
+ return {
1558
+ ...client,
1559
+ replies: async (requestId: string) => {
1560
+ polls += 1
1561
+ if (polls <= 2) {
1562
+ throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
1563
+ }
1564
+ return {
1565
+ request_id: requestId,
1566
+ reply_expires_at: null,
1567
+ replies: [reply({ text: 'Yes — start personally' })],
1568
+ } satisfies ListRepliesResponse
1569
+ },
1570
+ } as ApiClient
1571
+ }
1572
+ writeSessionState('recover-500', h.env, { last_prompt_at: AWAY })
1573
+ registerQuestion('recover-500', h.env, { question: 'Start personally?' }, NOW - 300_000)
1574
+
1575
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'recover-500' }), 'codex')
1576
+
1577
+ // Submit recorded the durable id before any wait; a 500 must not erase it
1578
+ // mid-flight, and recovery must still surface the late answer.
1579
+ const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1580
+ expect(questions).toHaveLength(1)
1581
+ expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1582
+ expect(polls).toBeGreaterThanOrEqual(3)
1583
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1584
+ expect(readSessionState('recover-500', h.env).pending).toBeUndefined()
1585
+ const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
1586
+ expect(output.decision).toBe('block')
1587
+ expect(output.reason).toContain('Yes — start personally')
1588
+ })
1589
+
1590
+ it('stops retrying and names a permanent rejection during the blocking multi-wait', async () => {
1591
+ const h = harness([], 900)
1592
+ let polls = 0
1593
+ const factory = h.deps.clientFactory
1594
+ h.deps.clientFactory = () => {
1595
+ const client = factory!()
1596
+ return {
1597
+ ...client,
1598
+ replies: async () => {
1599
+ polls += 1
1600
+ throw new ApiCallError(404, 'not_found', 'No such request.')
1601
+ },
1602
+ } as ApiClient
1603
+ }
1604
+ writeSessionState('permanent-wait', h.env, { last_prompt_at: AWAY })
1605
+ registerQuestion('permanent-wait', h.env, { question: 'Still there?' }, NOW - 300_000)
1606
+
1607
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-wait' }), 'codex')
1608
+
1609
+ expect(polls).toBe(1)
1610
+ expect(readSessionState('permanent-wait', h.env).pending).toBeUndefined()
1611
+ expect(h.io.errLines.join('\n')).toContain(
1612
+ `${h.recorder.receipts[0]}: not_found (HTTP 404): No such request.`,
1613
+ )
1614
+ expect(h.io.errLines.join('\n')).not.toContain('could not reach the server')
1615
+ })
1616
+
1617
+ it('distinguishes a permanent late-poll rejection from transient trouble', async () => {
1618
+ const h = harness([], 900)
1619
+ let polls = 0
1620
+ const factory = h.deps.clientFactory
1621
+ h.deps.clientFactory = () => {
1622
+ const client = factory!()
1623
+ return {
1624
+ ...client,
1625
+ replies: async () => {
1626
+ polls += 1
1627
+ throw new ApiCallError(401, 'machine_revoked', 'This machine was revoked.')
1628
+ },
1629
+ } as ApiClient
1630
+ }
1631
+ writeSessionState('permanent-late', h.env, {
1632
+ last_prompt_at: AWAY,
1633
+ pending: [
1634
+ {
1635
+ question: 'Deploy?',
1636
+ asked_at: NOW - 300_000,
1637
+ request_id: 'req_existing',
1638
+ collapse_key: 'question-existing',
1639
+ device_ids: ['dev_iphone'],
1640
+ reply_deadline_at: NOW + 60_000,
1641
+ },
1642
+ ],
1643
+ })
1644
+
1645
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-late' }), 'codex')
1646
+
1647
+ expect(polls).toBe(1)
1648
+ expect(h.io.errLines.join('\n')).toContain(
1649
+ 'req_existing: machine_revoked (HTTP 401): This machine was revoked.',
1650
+ )
1651
+ expect(h.io.errLines.join('\n')).toContain(
1652
+ 'reply polling stopped after a permanent server rejection',
1653
+ )
1654
+ expect(h.io.errLines.join('\n')).not.toContain('could not check whether its answer arrived')
827
1655
  })
828
1656
 
829
1657
  it('resumes with every answer that arrived, each tied to its question', async () => {
@@ -843,7 +1671,7 @@ describe('several questions in flight', () => {
843
1671
 
844
1672
  // Both reply windows close immediately (first answer claims each
845
1673
  // question); the retirement drain closes again, which is idempotent.
846
- expect(new Set(h.recorder.closed)).toEqual(new Set(['req_hook_1', 'req_hook_2']))
1674
+ expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
847
1675
  expect(readSessionState('multi2', h.env).pending).toBeUndefined()
848
1676
  const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
849
1677
  expect(output.decision).toBe('block')
@@ -852,6 +1680,49 @@ describe('several questions in flight', () => {
852
1680
  expect(output.reason).toContain('"Deploy where?" → "Staging"')
853
1681
  })
854
1682
 
1683
+ it('journals an accepted answer until a successor Stop proves delivery', async () => {
1684
+ const h = harness([reply({ text: 'Ship it' })], 900)
1685
+ writeSessionState('answer-handoff', h.env, {
1686
+ last_prompt_at: AWAY,
1687
+ pending: [
1688
+ {
1689
+ question: 'Ship it?',
1690
+ asked_at: NOW,
1691
+ request_id: 'req_existing',
1692
+ collapse_key: 'question-existing',
1693
+ device_ids: ['dev_iphone'],
1694
+ reply_deadline_at: NOW + 60_000,
1695
+ },
1696
+ ],
1697
+ })
1698
+
1699
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
1700
+ const staged = readSessionState('answer-handoff', h.env)
1701
+ expect(staged.accepted?.answers[0]?.reply.text).toBe('Ship it')
1702
+ expect(JSON.parse(h.io.outLines.at(-1)!).decision).toBe('block')
1703
+
1704
+ h.io.outLines = []
1705
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
1706
+ expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
1707
+ expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
1708
+
1709
+ h.io.outLines = []
1710
+ await hookRunCommand(
1711
+ h.deps,
1712
+ 'stop',
1713
+ stdin({ session_id: 'answer-handoff', stop_hook_active: true }),
1714
+ )
1715
+ expect(h.io.outLines).toEqual([])
1716
+ expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
1717
+ expect(
1718
+ h.recorder.submitted.find(
1719
+ (entry) =>
1720
+ entry.draft.event === 'question_retired' &&
1721
+ entry.draft.lifecycle?.retires_request_id === 'req_existing',
1722
+ )?.draft.lifecycle?.state,
1723
+ ).toBe('answered')
1724
+ })
1725
+
855
1726
  it('resumes with a partial answer and keeps the rest registered', async () => {
856
1727
  const h = harness([], 900)
857
1728
  const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
@@ -867,7 +1738,7 @@ describe('several questions in flight', () => {
867
1738
  expect(output.reason).toContain('1 more registered question is still waiting')
868
1739
  const live = readSessionState('multi3', h.env).pending
869
1740
  expect(live?.map((entry) => entry.question)).toEqual(['Deploy where?'])
870
- expect(live?.[0]?.request_id).toBe('req_hook_2')
1741
+ expect(live?.[0]?.request_id).toBe(h.recorder.receipts[1])
871
1742
 
872
1743
  // The remaining question's answer arrives before the next turn ends; the
873
1744
  // late-answer path hands it over without asking anything twice.
@@ -894,14 +1765,20 @@ describe('a question that outlives its session', () => {
894
1765
  }))
895
1766
  }
896
1767
 
897
- /** Escalate a question that nobody answers, so it is live on the devices. */
1768
+ /** State a still-owned request can have when SessionEnd races its Stop. */
898
1769
  async function pushUnanswered(h: Harness, sessionId: string): Promise<string> {
899
- writeSessionState(sessionId, h.env, { last_prompt_at: AWAY })
900
- registerQuestion(sessionId, h.env, { question: 'Ship it?' })
901
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: sessionId }))
902
- const requestId = h.recorder.receipts[0]
903
- expect(requestId).toBeDefined()
904
- return requestId!
1770
+ writeSessionState(sessionId, h.env, {
1771
+ last_prompt_at: AWAY,
1772
+ pending: [{
1773
+ question: 'Ship it?',
1774
+ asked_at: NOW,
1775
+ request_id: 'req_live',
1776
+ collapse_key: 'collapse-live',
1777
+ device_ids: ['dev_iphone'],
1778
+ reply_deadline_at: NOW + 60_000,
1779
+ }],
1780
+ })
1781
+ return 'req_live'
905
1782
  }
906
1783
 
907
1784
  it('retires a question the harness exited on, from a later session', async () => {
@@ -1030,12 +1907,10 @@ describe('ask registration', () => {
1030
1907
 
1031
1908
  it('stores the validated question set, comma-bearing labels verbatim', () => {
1032
1909
  const h = harness()
1033
- expect(
1034
- askCommand(h.deps, 'Ship it?', {
1035
- choice: ['Yes, ship it', 'No, hold'],
1036
- session: 'a2',
1037
- }),
1038
- ).toBe(EXIT.ok)
1910
+ const built = buildQuestions({ choice: ['Yes, ship it', 'No, hold'] }, 'Ship it?')
1911
+ expect(built.ok).toBe(true)
1912
+ if (!built.ok) return
1913
+ registerQuestion('a2', h.env, { question: built.questions[0]!.text, questions: built.questions })
1039
1914
  expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
1040
1915
  {
1041
1916
  id: 'ship-it',
@@ -1051,18 +1926,23 @@ describe('ask registration', () => {
1051
1926
  it('registers a multi-question form and pushes it as one set', async () => {
1052
1927
  const h = harness([reply({ text: 'Yes' })], 900)
1053
1928
  writeSessionState('form1', h.env, { last_prompt_at: AWAY })
1054
- expect(
1055
- askCommand(h.deps, undefined, {
1056
- session: 'form1',
1057
- form: JSON.stringify({
1929
+ const built = buildQuestions(
1930
+ { form: JSON.stringify({
1058
1931
  questions: [
1059
1932
  { text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
1060
1933
  { text: 'Anything to watch?' },
1061
1934
  ],
1062
1935
  detail: '## Context\nThe long story.',
1063
- }),
1064
- }),
1065
- ).toBe(EXIT.ok)
1936
+ }) },
1937
+ undefined,
1938
+ )
1939
+ expect(built.ok).toBe(true)
1940
+ if (!built.ok) return
1941
+ registerQuestion('form1', h.env, {
1942
+ question: built.questions[0]!.text,
1943
+ questions: built.questions,
1944
+ ...(built.detail === undefined ? {} : { detail: built.detail }),
1945
+ })
1066
1946
  const pending = readSessionState('form1', h.env).pending?.[0]
1067
1947
  expect(pending?.questions).toHaveLength(2)
1068
1948
  expect(pending?.detail).toContain('long story')
@@ -1101,9 +1981,10 @@ describe('ask registration', () => {
1101
1981
  900,
1102
1982
  )
1103
1983
  writeSessionState('latest1', h.env, { last_prompt_at: AWAY })
1104
- expect(
1105
- askCommand(h.deps, 'Ship it?', { session: 'latest1', choice: ['Yes', 'No'] }),
1106
- ).toBe(EXIT.ok)
1984
+ const built = buildQuestions({ choice: ['Yes', 'No'] }, 'Ship it?')
1985
+ expect(built.ok).toBe(true)
1986
+ if (!built.ok) return
1987
+ registerQuestion('latest1', h.env, { question: 'Ship it?', questions: built.questions })
1107
1988
 
1108
1989
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
1109
1990
 
@@ -1127,7 +2008,7 @@ describe('ask registration', () => {
1127
2008
  900,
1128
2009
  )
1129
2010
  writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
1130
- expect(askCommand(h.deps, 'Which color?', { session: 'parts1' })).toBe(EXIT.ok)
2011
+ registerQuestion('parts1', h.env, { question: 'Which color?' })
1131
2012
 
1132
2013
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
1133
2014
 
@@ -1136,27 +2017,17 @@ describe('ask registration', () => {
1136
2017
  expect(decision.reason).toContain('"Use blue", then "actually teal"')
1137
2018
  })
1138
2019
 
1139
- it('resolves the session as flag, then hook pointer, then NOTIFAI_SESSION', async () => {
1140
- // The exported id is often a chosen label while hook state is keyed by the
1141
- // harness's own id, so the pointer must outrank the env var.
2020
+ it('does not let an explicit session bypass exact active-harness proof', () => {
1142
2021
  const h = harness()
1143
- h.deps.env['NOTIFAI_SESSION'] = 'my-label'
1144
- await hookRunCommand(
1145
- h.deps,
1146
- 'user-prompt-submit',
1147
- stdin({ session_id: 'real1', cwd: h.deps.cwd }),
1148
- 'claude-code',
1149
- )
1150
- expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
1151
- expect(readSessionState('real1', h.env).pending?.[0]?.question).toBe('Ship it?')
1152
- expect(readSessionState('my-label', h.env).pending).toBeUndefined()
2022
+ expect(askCommand(h.deps, 'Ship it?', { session: 'guessed' })).toBe(EXIT.usage)
2023
+ expect(readSessionState('guessed', h.env).pending).toBeUndefined()
1153
2024
  })
1154
2025
 
1155
- it('falls back to NOTIFAI_SESSION where no hook has spoken', () => {
2026
+ it('does not route from NOTIFAI_SESSION where no exact harness has spoken', () => {
1156
2027
  const h = harness()
1157
2028
  h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
1158
- expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
1159
- expect(readSessionState('solo-session', h.env).pending?.[0]?.question).toBe('Ship it?')
2029
+ expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.usage)
2030
+ expect(readSessionState('solo-session', h.env).pending).toBeUndefined()
1160
2031
  })
1161
2032
 
1162
2033
  it('queues alongside incomplete live state without touching it', () => {
@@ -1174,7 +2045,7 @@ describe('ask registration', () => {
1174
2045
  ],
1175
2046
  })
1176
2047
 
1177
- expect(askCommand(h.deps, 'Another?', { session: 'incomplete' })).toBe(EXIT.ok)
2048
+ registerQuestion('incomplete', h.env, { question: 'Another?' })
1178
2049
  const pending = readSessionState('incomplete', h.env).pending
1179
2050
  expect(pending?.map((entry) => entry.question)).toEqual(['Original?', 'Another?'])
1180
2051
  expect(pending?.[0]).toMatchObject({
@@ -1186,11 +2057,10 @@ describe('ask registration', () => {
1186
2057
  it('refuses a fifth question and names the form alternative', () => {
1187
2058
  const h = harness()
1188
2059
  for (const question of ['One?', 'Two?', 'Three?', 'Four?']) {
1189
- expect(askCommand(h.deps, question, { session: 'crowded' })).toBe(EXIT.ok)
2060
+ registerQuestion('crowded', h.env, { question })
1190
2061
  }
1191
2062
 
1192
- expect(askCommand(h.deps, 'Five?', { session: 'crowded' })).toBe(EXIT.failed)
1193
- expect(h.io.errLines.join('\n')).toContain('ask --form')
2063
+ expect(() => registerQuestion('crowded', h.env, { question: 'Five?' })).toThrow(/ask --form/)
1194
2064
  expect(readSessionState('crowded', h.env).pending).toHaveLength(4)
1195
2065
  })
1196
2066
  })
@@ -1202,14 +2072,52 @@ describe('user-prompt-submit hook', () => {
1202
2072
  expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
1203
2073
  })
1204
2074
 
1205
- it('diagnoses malformed or truncated hook input instead of silently doing nothing', async () => {
2075
+ it('records Stop separately so a prompt hook cannot impersonate turn-end routing', async () => {
1206
2076
  const h = harness()
2077
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'events' }))
2078
+ expect(readSessionState('events', h.env).last_stop_at).toBeUndefined()
1207
2079
 
1208
- expect(await hookRunCommand(h.deps, 'user-prompt-submit', async () => '{"session_id":')).toBe(
1209
- EXIT.ok,
1210
- )
2080
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'events' }))
2081
+ expect(readSessionState('events', h.env).last_stop_at).toBe(NOW)
2082
+ })
2083
+
2084
+ it('diagnoses partial production input as malformed or truncated', async () => {
2085
+ const h = harness()
2086
+ const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
2087
+ const input = new PassThrough()
2088
+ input.write('{"session_id":')
2089
+
2090
+ expect(
2091
+ await hookRunCommand(
2092
+ deps,
2093
+ 'user-prompt-submit',
2094
+ () => readStdinWithTimeout(input, 5),
2095
+ ),
2096
+ ).toBe(EXIT.ok)
1211
2097
 
1212
2098
  expect(h.io.errLines.join('\n')).toMatch(/malformed|truncated/i)
2099
+ const records = readLogRecords(h.env, { limit: 10 }).records
2100
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
2101
+ expect(records.at(-1)?.data).toMatchObject({ reason: 'malformed-input' })
2102
+ })
2103
+
2104
+ it('records an empty production input timeout before failing open', async () => {
2105
+ const h = harness()
2106
+ const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
2107
+ const input = new PassThrough()
2108
+
2109
+ expect(
2110
+ await hookRunCommand(
2111
+ deps,
2112
+ 'user-prompt-submit',
2113
+ () => readStdinWithTimeout(input, 5),
2114
+ ),
2115
+ ).toBe(EXIT.ok)
2116
+
2117
+ const records = readLogRecords(h.env, { limit: 10 }).records
2118
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
2119
+ expect(records.at(-1)?.data).toMatchObject({ reason: 'input-read-failed' })
2120
+ expect(records.at(-1)?.data?.['message']).toMatch(/timed out waiting/)
1213
2121
  })
1214
2122
 
1215
2123
  it('explains when a missing machine credential makes a hook skip routing', async () => {
@@ -1225,28 +2133,20 @@ describe('user-prompt-submit hook', () => {
1225
2133
 
1226
2134
  it('preserves a delivered question through a second ask and the prompt transition', async () => {
1227
2135
  const h = harness([], 900)
1228
- await hookRunCommand(
1229
- h.deps,
1230
- 'user-prompt-submit',
1231
- stdin({ session_id: 'transition', cwd: h.deps.cwd }),
1232
- 'claude-code',
1233
- )
1234
- expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
1235
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
1236
-
1237
- const live = readSessionState('transition', h.env).pending?.[0]
1238
- expect(live).toMatchObject({
2136
+ const live = {
1239
2137
  question: 'Ship it?',
1240
- request_id: 'req_hook_1',
2138
+ request_id: 'req_live',
2139
+ collapse_key: 'collapse-live',
1241
2140
  device_ids: ['dev_iphone', 'dev_mac'],
1242
- })
1243
- expect(live?.collapse_key).toMatch(/^notifai-hook-/)
2141
+ reply_deadline_at: NOW + 60_000,
2142
+ }
2143
+ writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
1244
2144
 
1245
2145
  // A second plain `ask` resolves through the project pointer and joins the
1246
2146
  // queue behind the live question — it must not touch it. The next prompt
1247
2147
  // then resets presence before another Stop can run — the transition that
1248
2148
  // used to erase the retirement debt.
1249
- expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
2149
+ registerQuestion('transition', h.env, { question: 'Deploy it?' })
1250
2150
  const queued = readSessionState('transition', h.env)
1251
2151
  expect(queued.retiring ?? []).toEqual([])
1252
2152
  expect(queued.pending?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
@@ -1258,16 +2158,17 @@ describe('user-prompt-submit hook', () => {
1258
2158
  'claude-code',
1259
2159
  )
1260
2160
 
1261
- // The delivered question retires truthfully; the never-delivered second
1262
- // one has nothing on any device and leaves no trace.
2161
+ // The delivered question retires truthfully. The never-delivered second
2162
+ // one must remain queued: if Codex skipped Stop, erasing it here loses the
2163
+ // only copy before it ever reached a device.
1263
2164
  const retirement = h.recorder.submitted.find(
1264
2165
  (submission) => submission.draft.event === 'question_retired',
1265
2166
  )?.draft
1266
- expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
2167
+ expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
1267
2168
  expect(retirement?.lifecycle).toEqual({
1268
2169
  tier: 'done',
1269
2170
  state: 'answered_elsewhere',
1270
- retires_request_id: live?.request_id,
2171
+ retires_request_id: live.request_id,
1271
2172
  })
1272
2173
  expect(retirement?.targets).toEqual({
1273
2174
  mode: 'selected',
@@ -1276,28 +2177,29 @@ describe('user-prompt-submit hook', () => {
1276
2177
  expect(
1277
2178
  h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
1278
2179
  ).toHaveLength(1)
2180
+ expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
2181
+ 'Deploy it?',
2182
+ ])
2183
+
2184
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
2185
+ expect(readSessionState('transition', h.env).pending).toBeUndefined()
1279
2186
  })
1280
2187
 
1281
- it('retires a question a real timed-out Stop left live on the devices', async () => {
1282
- // Drives the actual flow rather than hand-writing state: the previous
1283
- // version of this test fabricated a shape production never wrote, so it
1284
- // passed while the retirement path was unreachable.
2188
+ it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
1285
2189
  const h = harness([])
1286
2190
  writeSessionState('s11', h.env, { last_prompt_at: AWAY })
1287
2191
  registerQuestion('s11', h.env, { question: 'Which environment?' })
1288
2192
 
1289
2193
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
1290
- const live = readSessionState('s11', h.env).pending?.[0]
1291
- expect(live?.request_id).toBe('req_hook_1')
1292
- expect(live?.collapse_key).toBeDefined()
2194
+ expect(readSessionState('s11', h.env).pending).toBeUndefined()
2195
+ const retirementCount = h.recorder.submitted.filter(
2196
+ (entry) => entry.draft.event === 'question_retired',
2197
+ ).length
1293
2198
 
1294
2199
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
1295
2200
 
1296
- expect(h.recorder.closed).toEqual(['req_hook_1'])
1297
- const retirement = h.recorder.submitted.at(-1)?.draft
1298
- expect(retirement?.presentation.title).toBe('Answered in the terminal')
1299
- expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
1300
- expect(retirement?.reply).toBeUndefined()
2201
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2202
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired')).toHaveLength(retirementCount + 1)
1301
2203
  })
1302
2204
 
1303
2205
  it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
@@ -1305,11 +2207,16 @@ describe('user-prompt-submit hook', () => {
1305
2207
  // lifecycle update, which the server renders as a background push — the
1306
2208
  // old "Answered" tombstone alert told the user what they just did.
1307
2209
  const h = harness([])
1308
- writeSessionState('s15', h.env, { last_prompt_at: AWAY })
1309
- registerQuestion('s15', h.env, { question: 'Which environment?' })
1310
-
1311
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's15' }))
1312
- expect(h.recorder.submitted[0]?.draft.lifecycle).toEqual({ tier: 'needs_you' })
2210
+ writeSessionState('s15', h.env, {
2211
+ last_prompt_at: AWAY,
2212
+ pending: [{
2213
+ question: 'Which environment?',
2214
+ request_id: 'req_live',
2215
+ collapse_key: 'collapse-live',
2216
+ device_ids: ['dev_iphone'],
2217
+ reply_deadline_at: NOW + 60_000,
2218
+ }],
2219
+ })
1313
2220
 
1314
2221
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
1315
2222
 
@@ -1319,7 +2226,7 @@ describe('user-prompt-submit hook', () => {
1319
2226
  state: 'answered_elsewhere',
1320
2227
  // The history-entry correlation id: companions key entries by request
1321
2228
  // id and never persisted the collapse key.
1322
- retires_request_id: 'req_hook_1',
2229
+ retires_request_id: 'req_live',
1323
2230
  })
1324
2231
  })
1325
2232
 
@@ -1328,12 +2235,17 @@ describe('user-prompt-submit hook', () => {
1328
2235
  registerQuestion('s16', h.env, { question: 'Ship it?' })
1329
2236
 
1330
2237
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
2238
+ await hookRunCommand(
2239
+ h.deps,
2240
+ 'stop',
2241
+ stdin({ session_id: 's16', stop_hook_active: true }),
2242
+ )
1331
2243
 
1332
2244
  const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
1333
2245
  expect(retirement?.draft.lifecycle).toEqual({
1334
2246
  tier: 'done',
1335
2247
  state: 'answered',
1336
- retires_request_id: 'req_hook_1',
2248
+ retires_request_id: h.recorder.receipts[0],
1337
2249
  })
1338
2250
  })
1339
2251
 
@@ -1364,19 +2276,101 @@ describe('user-prompt-submit hook', () => {
1364
2276
  })
1365
2277
 
1366
2278
  describe('session-end hook', () => {
1367
- it('drops local state without touching the network, inside its ~1s budget', async () => {
2279
+ it('drops local state without touching the network and records the lifecycle', async () => {
1368
2280
  const h = harness()
1369
2281
  writeSessionState('s12', h.env, { last_prompt_at: NOW })
2282
+ const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
1370
2283
 
1371
- const code = await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's12' }))
2284
+ const code = await hookRunCommand(deps, 'session-end', stdin({ session_id: 's12' }))
1372
2285
 
1373
2286
  expect(code).toBe(EXIT.ok)
1374
2287
  expect(readSessionState('s12', h.env)).toEqual({})
1375
2288
  expect(h.recorder.closed).toEqual([])
2289
+ const records = readLogRecords(h.env, { limit: 10 }).records
2290
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
2291
+ expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
2292
+ })
2293
+
2294
+ it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
2295
+ const h = harness()
2296
+ const sessionId = 'session-end-budget'
2297
+ writeSessionState(sessionId, h.env, { last_prompt_at: NOW })
2298
+ writeProjectSession(h.deps.cwd, h.env, sessionId, Date.now(), 'codex')
2299
+ const configFile = sessionConfigPath(sessionId, h.env)
2300
+ mkdirSync(path.dirname(configFile), { recursive: true })
2301
+ writeFileSync(configFile, 'ask_notifications = false\nlog_level = "info"\n')
2302
+ expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toMatchObject({
2303
+ value: false,
2304
+ source: expect.stringMatching(/^session:/),
2305
+ })
2306
+
2307
+ const stateFile = path.join(
2308
+ stateDir(h.env),
2309
+ 'sessions',
2310
+ `${sanitizeSessionId(sessionId)}.json`,
2311
+ )
2312
+ const projectPointer = projectSessionPointerPath(h.deps.cwd, h.env)
2313
+ expect(existsSync(stateFile)).toBe(true)
2314
+ expect(existsSync(configFile)).toBe(true)
2315
+ expect(existsSync(projectPointer)).toBe(true)
2316
+
2317
+ const logLock = `${activeLogPath(h.env)}.lock`
2318
+ const holder = runSessionEndLogLockHolder(h.deps.cwd, h.env, logLock)
2319
+ let cli: ReturnType<typeof runSessionEndCli> | undefined
2320
+ try {
2321
+ await waitUntil(
2322
+ () => existsSync(holder.readyPath),
2323
+ 10_000,
2324
+ 'SessionEnd log lock worker did not acquire its bakery ticket',
2325
+ )
2326
+ expect(readdirSync(logLock).filter((name) => name.startsWith('ticket-'))).toHaveLength(1)
2327
+
2328
+ cli = runSessionEndCli(h.env, h.deps.cwd, sessionId)
2329
+ await waitUntil(
2330
+ () =>
2331
+ readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
2332
+ 700,
2333
+ 'the real Codex hook did not reach contended diagnostics before its deadline',
2334
+ )
2335
+
2336
+ // Reaching the second bakery ticket proves main.ts preAction did not write
2337
+ // first. Every durable cleanup must already be visible while diagnostics
2338
+ // are still blocked behind the live holder.
2339
+ expect(existsSync(stateFile)).toBe(false)
2340
+ expect(existsSync(configFile)).toBe(false)
2341
+ expect(existsSync(projectPointer)).toBe(false)
2342
+
2343
+ const result = await cli.done
2344
+ expect(result).toMatchObject({ code: EXIT.ok, signal: null, stdout: '', stderr: '' })
2345
+ expect(result.durationMs).toBeGreaterThanOrEqual(900)
2346
+ expect(result.durationMs).toBeLessThan(2_000)
2347
+ // The diagnostic contender timed out rather than stealing the held lock,
2348
+ // disabled its sink, and still let the real CLI exit successfully.
2349
+ expect(existsSync(activeLogPath(h.env))).toBe(false)
2350
+
2351
+ writeFileSync(holder.releasePath, 'release')
2352
+ await holder.done
2353
+
2354
+ expect(readSessionState(sessionId, h.env)).toEqual({})
2355
+ expect(readProjectSession(h.deps.cwd, h.env, Date.now())).toBeNull()
2356
+ expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toEqual({
2357
+ value: true,
2358
+ source: 'default',
2359
+ })
2360
+ } finally {
2361
+ if (!existsSync(holder.releasePath)) writeFileSync(holder.releasePath, 'release')
2362
+ if (cli?.child.exitCode === null && cli.child.signalCode === null) cli.child.kill('SIGKILL')
2363
+ if (holder.child.exitCode === null && holder.child.signalCode === null) {
2364
+ holder.child.kill('SIGKILL')
2365
+ }
2366
+ rmSync(h.deps.cwd, { recursive: true, force: true })
2367
+ }
1376
2368
  })
1377
2369
 
1378
- it('clears only the ending session project pointer', async () => {
2370
+ it('clears only the ending session from a concurrent project index', async () => {
1379
2371
  const h = harness()
2372
+ writeSessionState('still-running', h.env, { last_prompt_at: NOW })
2373
+ writeProjectSession(h.deps.cwd, h.env, 'still-running', NOW - 1, 'codex')
1380
2374
  await hookRunCommand(
1381
2375
  h.deps,
1382
2376
  'user-prompt-submit',
@@ -1384,6 +2378,15 @@ describe('session-end hook', () => {
1384
2378
  'claude-code',
1385
2379
  )
1386
2380
  expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('ending')
2381
+ expect(
2382
+ readMatchingProjectSessionPointer(
2383
+ h.deps.cwd,
2384
+ h.env,
2385
+ NOW,
2386
+ 'still-running',
2387
+ 'codex',
2388
+ ),
2389
+ ).toEqual({ sessionId: 'still-running', harness: 'codex' })
1387
2390
 
1388
2391
  await hookRunCommand(
1389
2392
  h.deps,
@@ -1392,7 +2395,16 @@ describe('session-end hook', () => {
1392
2395
  'claude-code',
1393
2396
  )
1394
2397
 
1395
- expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBeNull()
2398
+ expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('still-running')
2399
+ expect(
2400
+ readMatchingProjectSessionPointer(
2401
+ h.deps.cwd,
2402
+ h.env,
2403
+ NOW,
2404
+ 'still-running',
2405
+ 'codex',
2406
+ ),
2407
+ ).toEqual({ sessionId: 'still-running', harness: 'codex' })
1396
2408
  })
1397
2409
 
1398
2410
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
@@ -1443,6 +2455,11 @@ describe('telling concurrent agents apart', () => {
1443
2455
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
1444
2456
 
1445
2457
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2458
+ await hookRunCommand(
2459
+ h.deps,
2460
+ 'stop',
2461
+ stdin({ session_id: 'sess-abc', stop_hook_active: true }),
2462
+ )
1446
2463
 
1447
2464
  const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
1448
2465
  expect(retirement?.draft.session).toBe('sess-abc')
@@ -1460,7 +2477,7 @@ describe('telling concurrent agents apart', () => {
1460
2477
  })
1461
2478
 
1462
2479
  describe('clock jumps', () => {
1463
- const config = loadConfig({ cwd: '/nowhere', env: {} })
2480
+ const config = presenceGatedConfig('/nowhere', {})
1464
2481
 
1465
2482
  it('does not hijack a terminal because the clock jumped forward', () => {
1466
2483
  // NTP correction or a VM resume moves `now` without any time passing for
@@ -1694,13 +2711,33 @@ describe('two hooks racing one question', () => {
1694
2711
  // A crashed hook must not suppress every question for this session for
1695
2712
  // ever — that is worse than the duplicate the claim prevents.
1696
2713
  const h = harness()
1697
- claimQuestionPush('race3', h.env, REAL - 10 * 60_000)
2714
+ const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId('race3')}.claim`)
2715
+ mkdirSync(path.dirname(file), { recursive: true })
2716
+ writeFileSync(
2717
+ file,
2718
+ `${JSON.stringify({ pid: 999_999_999, at: REAL - 1, token: 'dead' })}\n`,
2719
+ )
1698
2720
  expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
1699
2721
  })
1700
2722
 
2723
+ it('never steals an old claim while its process is still alive', () => {
2724
+ const h = harness()
2725
+ expect(claimQuestionPush('race-live', h.env, REAL - 10 * 60_000)).toBe(true)
2726
+ expect(claimQuestionPush('race-live', h.env, REAL)).toBe(false)
2727
+ })
2728
+
1701
2729
  it('serializes a contender that arrives while a stale claim is being replaced', () => {
1702
2730
  const h = harness()
1703
- claimQuestionPush('race-break', h.env, REAL - 10 * 60_000)
2731
+ const file = path.join(
2732
+ stateDir(h.env),
2733
+ 'sessions',
2734
+ `${sanitizeSessionId('race-break')}.claim`,
2735
+ )
2736
+ mkdirSync(path.dirname(file), { recursive: true })
2737
+ writeFileSync(
2738
+ file,
2739
+ `${JSON.stringify({ pid: 999_999_999, at: REAL - 10 * 60_000, token: 'dead' })}\n`,
2740
+ )
1704
2741
  let contender: ReturnType<typeof claimQuestionPush> | undefined
1705
2742
 
1706
2743
  const recovered = claimQuestionPush('race-break', h.env, REAL, () => {
@@ -1729,10 +2766,56 @@ describe('two hooks racing one question', () => {
1729
2766
  expect(h.recorder.submitted).toHaveLength(0)
1730
2767
  expect(h.io.errLines.join(" ")).toContain('already handling')
1731
2768
  })
2769
+
2770
+ it('resumes the agent once when two Stops collect the same late answer', async () => {
2771
+ const answers: ReplyView[] = []
2772
+ const h = harness(answers, 900)
2773
+ writeSessionState('race-late-answer', h.env, {
2774
+ last_prompt_at: AWAY,
2775
+ pending: [
2776
+ {
2777
+ question: 'Ship it?',
2778
+ asked_at: NOW,
2779
+ request_id: 'req_existing',
2780
+ collapse_key: 'question-existing',
2781
+ device_ids: ['dev_iphone'],
2782
+ reply_deadline_at: NOW + 60_000,
2783
+ },
2784
+ ],
2785
+ })
2786
+ answers.push(reply({ text: 'Ship it' }))
2787
+ h.io.outLines = []
2788
+
2789
+ let polls = 0
2790
+ let markFirstPoll: (() => void) | undefined
2791
+ const firstPoll = new Promise<void>((resolve) => {
2792
+ markFirstPoll = resolve
2793
+ })
2794
+ let releasePolls: (() => void) | undefined
2795
+ const heldPoll = new Promise<void>((resolve) => {
2796
+ releasePolls = resolve
2797
+ })
2798
+ h.recorder.beforeReplies = async () => {
2799
+ polls += 1
2800
+ markFirstPoll?.()
2801
+ await heldPoll
2802
+ }
2803
+
2804
+ const firstStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
2805
+ await firstPoll
2806
+ const secondStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
2807
+ await Promise.race([secondStop, new Promise<void>((resolve) => setImmediate(resolve))])
2808
+ releasePolls?.()
2809
+ await Promise.all([firstStop, secondStop])
2810
+
2811
+ const resumes = h.io.outLines.map((line) => JSON.parse(line) as { decision?: string })
2812
+ expect(polls).toBe(1)
2813
+ expect(resumes.filter((entry) => entry.decision === 'block')).toHaveLength(1)
2814
+ })
1732
2815
  })
1733
2816
 
1734
2817
  describe('question registration racing a Stop submission', () => {
1735
- it('keeps both questions when a new ask races the older submit', async () => {
2818
+ it('keeps the new question when the older owner expires', async () => {
1736
2819
  const h = harness([], 900)
1737
2820
  writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
1738
2821
  registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
@@ -1743,16 +2826,16 @@ describe('question registration racing a Stop submission', () => {
1743
2826
 
1744
2827
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
1745
2828
 
1746
- // The racing ask appended; the in-flight submit still finds its own entry
1747
- // and records the delivery ids on it. Nobody retires anybody.
2829
+ // The racing ask appended and remains unasked. The already-delivered old
2830
+ // question is retired when this Stop owner reaches its deadline, so it
2831
+ // cannot accept a phone answer after the hook has returned.
1748
2832
  const state = readSessionState('submit-race', h.env)
1749
- expect(state.pending?.map((entry) => entry.question)).toEqual([
1750
- 'Old question?',
1751
- 'New question?',
1752
- ])
1753
- expect(state.pending?.[0]?.request_id).toBe('req_hook_1')
1754
- expect(state.pending?.[1]?.request_id).toBeUndefined()
1755
- expect(state.retiring ?? []).toEqual([])
2833
+ expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
2834
+ expect(state.pending?.[0]?.request_id).toBeUndefined()
2835
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2836
+ expect(state.retiring).toContainEqual(
2837
+ expect.objectContaining({ request_id: h.recorder.receipts[0], state: 'expired' }),
2838
+ )
1756
2839
  })
1757
2840
 
1758
2841
  it('keeps the newer question when the older in-flight question receives an answer', async () => {
@@ -1768,12 +2851,7 @@ describe('question registration racing a Stop submission', () => {
1768
2851
 
1769
2852
  const state = readSessionState('answer-race', h.env)
1770
2853
  expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
1771
- expect(
1772
- h.recorder.submitted.find(
1773
- (entry) =>
1774
- entry.draft.event === 'question_retired' &&
1775
- entry.draft.lifecycle?.retires_request_id === 'req_hook_1',
1776
- )?.draft.lifecycle?.state,
1777
- ).toBe('answered')
2854
+ expect(state.accepted?.answers[0]?.pending.request_id).toBe(h.recorder.receipts[0])
2855
+ expect(state.accepted?.answers[0]?.reply.text).toBe('Old answer')
1778
2856
  })
1779
2857
  })