@raidiant/notifai 0.3.0 → 0.5.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 (87) hide show
  1. package/README.md +19 -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 +56 -7
  9. package/dist/commands.js +980 -318
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +1 -1
  12. package/dist/config-schema.js +46 -8
  13. package/dist/config-schema.js.map +1 -1
  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 +1 -1
  36. package/dist/interactive.js +3 -3
  37. package/dist/interactive.js.map +1 -1
  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 +97 -36
  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/config-view.js +3 -0
  47. package/dist/ui/config-view.js.map +1 -1
  48. package/dist/ui/theme.d.ts +1 -1
  49. package/package.json +3 -2
  50. package/src/atomic-file.ts +86 -7
  51. package/src/claude-wake.test.ts +320 -0
  52. package/src/claude-wake.ts +489 -0
  53. package/src/client.test.ts +12 -0
  54. package/src/client.ts +72 -4
  55. package/src/codex-wake.test.ts +253 -0
  56. package/src/codex-wake.ts +288 -0
  57. package/src/commands.test.ts +1261 -77
  58. package/src/commands.ts +1482 -375
  59. package/src/config-schema.test.ts +3 -3
  60. package/src/config-schema.ts +57 -44
  61. package/src/config.test.ts +22 -16
  62. package/src/config.ts +81 -66
  63. package/src/file-lock-process-worker.test.ts +64 -0
  64. package/src/file-lock.test.ts +279 -0
  65. package/src/file-lock.ts +318 -0
  66. package/src/harnesses.test.ts +34 -0
  67. package/src/harnesses.ts +57 -0
  68. package/src/hook-adapter.test.ts +187 -0
  69. package/src/hook-adapter.ts +212 -0
  70. package/src/hook-input.test.ts +39 -0
  71. package/src/hook-input.ts +72 -0
  72. package/src/hooks.test.ts +1985 -417
  73. package/src/hooks.ts +1338 -461
  74. package/src/install-hooks.test.ts +131 -41
  75. package/src/install-hooks.ts +284 -84
  76. package/src/interactive.ts +3 -8
  77. package/src/logging-process-worker.test.ts +39 -0
  78. package/src/logging.test.ts +453 -0
  79. package/src/logging.ts +639 -0
  80. package/src/main.ts +113 -32
  81. package/src/opencode-plugin.ts +15 -16
  82. package/src/session-end-log-lock-worker.test.ts +35 -0
  83. package/src/skill.test.ts +34 -0
  84. package/src/ui/config-view.test.ts +9 -7
  85. package/src/ui/config-view.ts +3 -0
  86. package/src/idle.test.ts +0 -32
  87. package/src/idle.ts +0 -41
package/src/hooks.test.ts CHANGED
@@ -1,34 +1,56 @@
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,
16
20
  SubmissionReceipt,
17
21
  SubmitNotificationRequestT,
18
22
  } from '@raidiant/notifai-protocol'
19
- 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'
23
+ import { describe, expect, it, vi } from 'vitest'
24
+ import { ApiCallError, type ApiClient } from './client.js'
25
+ import { CLAUDE_POST_SEND_LIVENESS_MS, type ClaudeWakeAdapters } from './claude-wake.js'
26
+ import type { CodexWakeAdapters, CodexWakeObservation } from './codex-wake.js'
27
+ import { readStdinWithTimeout } from './hook-input.js'
28
+ import {
29
+ activeLogPath,
30
+ bootstrapLogger,
31
+ createLogger,
32
+ readLogRecords,
33
+ type LogRecord,
34
+ } from './logging.js'
35
+ import { EXIT, askCommand, buildQuestions, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
36
+ import {
37
+ loadConfig,
38
+ projectSessionPointerPath,
39
+ sanitizeSessionId,
40
+ sessionConfigPath,
41
+ stateDir,
42
+ } from './config.js'
23
43
  import {
24
44
  claimQuestionPush,
25
45
  clearSessionState,
26
46
  drainRetirements,
27
47
  drainOrphanRetirements,
28
- isUserAway,
48
+ runEscalationWaiter,
49
+ MAX_CONTINUATION_COUNT,
29
50
  orphanRetirements,
30
51
  pruneAbandonedSessions,
31
52
  releaseQuestionPush,
53
+ readMatchingProjectSessionPointer,
32
54
  readProjectSession,
33
55
  readSessionState,
34
56
  registerQuestion,
@@ -55,15 +77,31 @@ interface Recorder {
55
77
  submitted: SubmitNotificationRequestT[]
56
78
  /** `submitted[i]`'s request id, so a test can name what it just sent. */
57
79
  receipts: string[]
80
+ /** Stable test aliases survive the fresh client created for each hook. */
81
+ aliases: Map<string, number>
58
82
  closed: string[]
59
83
  /** Simulates an offline machine; retirement has to survive one. */
60
84
  failSubmits?: boolean
61
85
  /** Runs while an agent_question submit is in flight, before onSubmitted. */
62
86
  beforeQuestionSubmit?: () => void
87
+ /** Lets race tests hold every replies call until all contenders are polling. */
88
+ beforeReplies?: () => Promise<void>
89
+ /** Per-request answer stream for multi-question ownership tests. */
90
+ repliesFor?: (requestId: string) => ReplyView[]
63
91
  }
64
92
 
65
93
  function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
66
94
  let submissions = 0
95
+ const recordedReplies = (requestId: string): ReplyView[] => {
96
+ const direct = recorder.repliesFor?.(requestId)
97
+ if (direct !== undefined && direct.length > 0) return direct
98
+ const ordinal = recorder.aliases.get(requestId)
99
+ if (ordinal !== undefined) {
100
+ const legacyAlias = recorder.repliesFor?.(`req_hook_${ordinal}`)
101
+ if (legacyAlias !== undefined) return legacyAlias
102
+ }
103
+ return direct ?? replies
104
+ }
67
105
  return {
68
106
  beginPairing: notUsed,
69
107
  pollPairing: notUsed,
@@ -98,19 +136,33 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
98
136
  if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
99
137
  recorder.submitted.push(body)
100
138
  submissions += 1
101
- recorder.receipts.push(`req_hook_${submissions}`)
139
+ const requestId = body.request_id ?? `req_hook_${submissions}`
140
+ recorder.aliases.set(requestId, recorder.receipts.length + 1)
141
+ recorder.receipts.push(requestId)
102
142
  return {
103
- request_id: `req_hook_${submissions}`,
143
+ request_id: requestId,
144
+ reply_expires_at: new Date(NOW + 480_000).toISOString(),
104
145
  replayed: false,
105
146
  overall: 'provider_accepted_all',
106
147
  deliveries: [],
107
148
  warnings: [],
108
149
  } satisfies SubmissionReceipt
109
150
  },
110
- replies: async (requestId) =>
111
- ({ request_id: requestId, reply_expires_at: null, replies }) satisfies ListRepliesResponse,
151
+ replies: async (requestId) => {
152
+ await recorder.beforeReplies?.()
153
+ return ({
154
+ request_id: requestId,
155
+ reply_expires_at: null,
156
+ replies: recordedReplies(requestId),
157
+ }) satisfies ListRepliesResponse
158
+ },
112
159
  closeReplies: async (requestId) => {
113
160
  recorder.closed.push(requestId)
161
+ return {
162
+ request_id: requestId,
163
+ reply_expires_at: new Date(NOW).toISOString(),
164
+ replies: recordedReplies(requestId),
165
+ } satisfies ListRepliesResponse
114
166
  },
115
167
  } as ApiClient
116
168
  }
@@ -139,23 +191,19 @@ interface Harness {
139
191
  io: CapturedIo
140
192
  recorder: Recorder
141
193
  env: NodeJS.ProcessEnv
194
+ advanceClock(milliseconds: number): void
142
195
  }
143
196
 
144
197
  const NOW = 1_800_000_000_000
145
198
 
146
- /**
147
- * `idleSeconds` defaults to null — "this machine has no idle source" — so these
148
- * cases exercise the degraded path and stay independent of whether the person
149
- * running the suite is touching their own keyboard.
150
- */
151
- function harness(replies: ReplyView[] = [], idleSeconds: number | null = null): Harness {
199
+ function harness(replies: ReplyView[] = []): Harness {
152
200
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-'))
153
201
  const env: NodeJS.ProcessEnv = {
154
202
  XDG_CONFIG_HOME: path.join(root, 'config'),
155
203
  XDG_STATE_HOME: path.join(root, 'state'),
156
204
  }
157
205
  const io = new CapturedIo()
158
- const recorder: Recorder = { submitted: [], receipts: [], closed: [] }
206
+ const recorder: Recorder = { submitted: [], receipts: [], closed: [], aliases: new Map() }
159
207
  // Virtual clock: sleeps advance it instead of costing wall time. A frozen
160
208
  // clock would make the reply poll's deadline unreachable and spin forever.
161
209
  let clock = NOW
@@ -163,6 +211,9 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
163
211
  io,
164
212
  recorder,
165
213
  env,
214
+ advanceClock(milliseconds: number) {
215
+ clock += milliseconds
216
+ },
166
217
  deps: {
167
218
  io,
168
219
  store: {
@@ -180,7 +231,6 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
180
231
  cwd: root,
181
232
  clientFactory: () => fakeClient(recorder, replies),
182
233
  now: () => clock,
183
- idleSeconds: () => idleSeconds,
184
234
  sleep: async (milliseconds: number) => {
185
235
  clock += milliseconds
186
236
  },
@@ -192,45 +242,144 @@ function stdin(payload: unknown): () => Promise<string> {
192
242
  return async () => JSON.stringify(payload)
193
243
  }
194
244
 
195
- /** Long enough ago to be away under the 120s default. */
196
- const AWAY = NOW - 600_000
197
- const PRESENT = NOW - 5_000
245
+ async function waitUntil(
246
+ predicate: () => boolean,
247
+ timeoutMs: number,
248
+ failure: string,
249
+ ): Promise<void> {
250
+ const deadline = Date.now() + timeoutMs
251
+ while (!predicate()) {
252
+ if (Date.now() >= deadline) throw new Error(failure)
253
+ await new Promise((resolve) => setTimeout(resolve, 5))
254
+ }
255
+ }
198
256
 
199
- describe('presence gate', () => {
200
- it('treats a never-seen session as present, so a missing hook cannot hijack the terminal', () => {
201
- const { env, deps } = harness()
202
- const config = loadConfig({ cwd: deps.cwd, env })
203
- expect(isUserAway({}, config, NOW, null)).toBe(false)
204
- })
257
+ interface SessionEndLogLockHolder {
258
+ child: ChildProcess
259
+ readyPath: string
260
+ releasePath: string
261
+ done: Promise<void>
262
+ }
205
263
 
206
- it('is away only once the configured silence has elapsed', () => {
207
- const { env, deps } = harness()
208
- const config = loadConfig({ cwd: deps.cwd, env })
209
- expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, null)).toBe(false)
210
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
264
+ function runSessionEndLogLockHolder(
265
+ root: string,
266
+ env: NodeJS.ProcessEnv,
267
+ lockPath: string,
268
+ ): SessionEndLogLockHolder {
269
+ const readyPath = path.join(root, 'session-end-log-lock-ready')
270
+ const releasePath = path.join(root, 'session-end-log-lock-release')
271
+ const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
272
+ const child = spawn(
273
+ process.execPath,
274
+ [
275
+ vitest,
276
+ 'run',
277
+ 'src/session-end-log-lock-worker.test.ts',
278
+ '--reporter=dot',
279
+ '--maxWorkers=1',
280
+ ],
281
+ {
282
+ cwd: process.cwd(),
283
+ env: {
284
+ ...process.env,
285
+ ...env,
286
+ NOTIFAI_SESSION_END_LOG_LOCK_WORKER_MODE: 'hold',
287
+ NOTIFAI_SESSION_END_LOG_LOCK_PATH: lockPath,
288
+ NOTIFAI_SESSION_END_LOG_LOCK_READY: readyPath,
289
+ NOTIFAI_SESSION_END_LOG_LOCK_RELEASE: releasePath,
290
+ },
291
+ stdio: ['ignore', 'pipe', 'pipe'],
292
+ },
293
+ )
294
+ const stdout: Buffer[] = []
295
+ const stderr: Buffer[] = []
296
+ child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
297
+ child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
298
+ const done = new Promise<void>((resolve, reject) => {
299
+ child.once('error', reject)
300
+ child.once('exit', (code) => {
301
+ if (code === 0) resolve()
302
+ else {
303
+ reject(
304
+ new Error(
305
+ `SessionEnd log lock worker exited ${String(code)}\n${Buffer.concat(stdout).toString()}\n${Buffer.concat(stderr).toString()}`,
306
+ ),
307
+ )
308
+ }
309
+ })
211
310
  })
311
+ void done.catch(() => undefined)
312
+ return { child, readyPath, releasePath, done }
313
+ }
212
314
 
213
- // The case that motivated this: "run the full test suite", then three
214
- // minutes of watching. Elapsed time alone said away; the machine knows better.
215
- it('keeps a user who is watching a long turn present, however long the turn ran', () => {
216
- const { env, deps } = harness()
217
- const config = loadConfig({ cwd: deps.cwd, env })
218
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 3)).toBe(false)
219
- })
315
+ interface CliProcessResult {
316
+ code: number | null
317
+ signal: NodeJS.Signals | null
318
+ stdout: string
319
+ stderr: string
320
+ durationMs: number
321
+ }
220
322
 
221
- // Found by a live Claude Code session: a spawned agent's session
222
- // always has a just-set last_prompt_at, so requiring session silence too meant
223
- // its FIRST question could never escalate — the "kick off agents and walk
224
- // away" case the feature is for.
225
- it('lets a freshly spawned session escalate when the machine says nobody is there', () => {
226
- const { env, deps } = harness()
227
- const config = loadConfig({ cwd: deps.cwd, env })
228
- expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, 900)).toBe(true)
229
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 900)).toBe(true)
323
+ function runSessionEndCli(
324
+ env: NodeJS.ProcessEnv,
325
+ cwd: string,
326
+ sessionId: string,
327
+ ): { child: ChildProcess; done: Promise<CliProcessResult> } {
328
+ const startedAt = Date.now()
329
+ const child = spawn(
330
+ process.execPath,
331
+ [
332
+ path.join(process.cwd(), 'dist', 'main.js'),
333
+ 'hook',
334
+ 'session-end',
335
+ '--owner',
336
+ 'notifai',
337
+ '--harness',
338
+ 'codex',
339
+ ],
340
+ {
341
+ cwd,
342
+ env: { ...process.env, ...env, NOTIFAI_CREDENTIALS: 'file' },
343
+ stdio: ['pipe', 'pipe', 'pipe'],
344
+ },
345
+ )
346
+ const stdout: Buffer[] = []
347
+ const stderr: Buffer[] = []
348
+ child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
349
+ child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
350
+ child.stdin!.end(JSON.stringify({ session_id: sessionId, cwd }))
351
+ const done = new Promise<CliProcessResult>((resolve, reject) => {
352
+ child.once('error', reject)
353
+ child.once('exit', (code, signal) => {
354
+ resolve({
355
+ code,
356
+ signal,
357
+ stdout: Buffer.concat(stdout).toString(),
358
+ stderr: Buffer.concat(stderr).toString(),
359
+ durationMs: Date.now() - startedAt,
360
+ })
361
+ })
230
362
  })
363
+ void done.catch(() => undefined)
364
+ return { child, done }
365
+ }
366
+
367
+ function writeGlobalConfig(h: Harness, toml: string): void {
368
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
369
+ mkdirSync(dir, { recursive: true })
370
+ writeFileSync(path.join(dir, 'config.toml'), toml)
371
+ }
231
372
 
232
- it('pushes a spawned session first question once the machine has gone quiet', async () => {
233
- const h = harness([reply({ text: 'Yes' })], 900)
373
+ /** Long enough ago that no test is accidentally sensitive to the stamp. */
374
+ const AWAY = NOW - 600_000
375
+
376
+ describe('pushing a registered question', () => {
377
+ // Found by a live Claude Code session: a spawned agent always has a
378
+ // just-set last_prompt_at, and the routing gate once read that as the user
379
+ // being present — so its FIRST question could never escalate, which is the
380
+ // "kick off agents and walk away" case the feature exists for.
381
+ it('pushes a freshly spawned session first question', async () => {
382
+ const h = harness([reply({ text: 'Yes' })])
234
383
  // Prompt 20s ago, exactly as a just-spawned agent has.
235
384
  writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
236
385
  registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
@@ -251,7 +400,7 @@ describe('presence gate', () => {
251
400
  })
252
401
 
253
402
  it('names the resolved project in the pushed question title', async () => {
254
- const h = harness([reply({ text: 'Yes' })], 900)
403
+ const h = harness([reply({ text: 'Yes' })])
255
404
  const projectDir = path.join(h.deps.cwd, '.notifai')
256
405
  mkdirSync(projectDir, { recursive: true })
257
406
  writeFileSync(
@@ -265,171 +414,354 @@ describe('presence gate', () => {
265
414
 
266
415
  expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
267
416
  })
417
+ })
418
+
419
+ /**
420
+ * A hook's decisions are invisible from everywhere else: its stderr belongs to
421
+ * the harness, and its usual outcome is to do nothing at all. These are the
422
+ * tests that the local log closes that gap — that "my question never reached my
423
+ * phone" has an answer rather than a silence.
424
+ */
425
+ describe('what the hook leaves behind', () => {
426
+ function recording(h: Harness): CommandDeps {
427
+ return { ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) }
428
+ }
429
+
430
+ function gates(h: Harness): LogRecord[] {
431
+ return readLogRecords(h.env, { event: ['hook.gate'], limit: 50 }).records
432
+ }
433
+
434
+ it('records why a question was held, in a name a filter can match', async () => {
435
+ const h = harness([])
436
+ writeSessionState('held', h.env, { accepted: undefined })
437
+ registerQuestion('held', h.env, { question: 'Ship it?' }, NOW)
438
+ // A continuation turn must not re-ask the question it is continuing from.
439
+ writeSessionState('held', h.env, {
440
+ ...readSessionState('held', h.env),
441
+ continuation: { answered_at: NOW + 1000, count: 1 },
442
+ })
443
+
444
+ await hookRunCommand(
445
+ recording(h),
446
+ 'stop',
447
+ stdin({ session_id: 'held', cwd: h.deps.cwd, stop_hook_active: true }),
448
+ )
268
449
 
269
- it('falls back to elapsed time where no idle source exists', () => {
270
- const { env, deps } = harness()
271
- const config = loadConfig({ cwd: deps.cwd, env })
272
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
450
+ const held = gates(h).find((record) => record.data?.['reason'] === 'continuation-repeat')
451
+ expect(held).toBeDefined()
452
+ expect(held!.data).toMatchObject({ verdict: 'held' })
453
+ expect(h.recorder.submitted).toHaveLength(0)
273
454
  })
274
455
 
275
- it('does not push a question to the phone while the user is at the keyboard', async () => {
276
- // Silent for ten minutes by the session clock, but active by the machine's.
277
- const h = harness([reply({ text: 'Yes' })], 2)
278
- writeSessionState('idle1', h.env, { last_prompt_at: AWAY })
279
- registerQuestion('idle1', h.env, { question: 'Ship it?' })
280
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'idle1' }))
456
+ it('records the switch being off, which the user is deliberately never told', async () => {
457
+ // Silent to the user by design, so from outside "you turned it off" and "a
458
+ // bug ate my question" look identical. This is what tells them apart.
459
+ const h = harness([])
460
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
461
+ mkdirSync(dir, { recursive: true })
462
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_notifications = false\n')
463
+ writeSessionState('off', h.env, { last_prompt_at: AWAY })
464
+ registerQuestion('off', h.env, { question: 'Ship it?' })
465
+
466
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'off', cwd: h.deps.cwd }))
467
+
281
468
  expect(h.recorder.submitted).toHaveLength(0)
282
- expect(h.io.errLines.join('\n')).toContain('at the keyboard')
469
+ // Nothing was printed, and the log is the only account of it.
470
+ expect(h.io.errLines.join('\n')).not.toContain('notifications')
471
+ expect(gates(h).map((record) => record.data?.['reason'])).toContain('notifications-off')
283
472
  })
284
- })
285
473
 
286
- /**
287
- * Wanting notifications while still at the machine is a legitimate setting —
288
- * nobody should have to stop using their computer in order to be reached.
289
- * Presence is a precondition only while the user wants it to be, and switching
290
- * it off must not disturb the grace timer.
291
- */
292
- describe('presence gating is optional (require_idle)', () => {
293
- function writeGlobalConfig(h: Harness, toml: string): void {
474
+ it('records a turn with no question at all, so "did the hook run" is answerable', async () => {
475
+ const h = harness()
476
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'quiet', cwd: h.deps.cwd }))
477
+ const events = readLogRecords(h.env, { limit: 50 }).records.map((record) => record.event)
478
+ expect(events).toContain('hook.start')
479
+ expect(events).toContain('hook.end')
480
+ expect(gates(h).map((record) => record.data?.['reason'])).toContain('no-question')
481
+ })
482
+
483
+ it('lets the hook project re-enable a bootstrap logger and records resolved config', async () => {
484
+ const h = harness()
485
+ writeGlobalConfig(h, 'log_level = "off"\n')
486
+ const startup = path.join(h.deps.cwd, 'startup')
487
+ const project = path.join(h.deps.cwd, 'project')
488
+ mkdirSync(startup, { recursive: true })
489
+ mkdirSync(path.join(project, '.notifai'), { recursive: true })
490
+ writeFileSync(
491
+ path.join(project, '.notifai', 'config.toml'),
492
+ 'project = "hook-project"\nlog_level = "debug"\n',
493
+ )
494
+ const deps = { ...h.deps, cwd: startup, logger: bootstrapLogger({ env: h.env, cwd: startup }) }
495
+
496
+ await hookRunCommand(deps, 'stop', stdin({ session_id: 'reenabled', cwd: project }))
497
+
498
+ const records = readLogRecords(h.env, { limit: 50 }).records
499
+ expect(records.map((record) => record.event)).toEqual(
500
+ expect.arrayContaining(['config.resolved', 'hook.start', 'hook.end']),
501
+ )
502
+ expect(records.find((record) => record.event === 'config.resolved')).toMatchObject({
503
+ project: 'hook-project',
504
+ session: 'reenabled',
505
+ })
506
+ })
507
+
508
+ it('persists an answer once while still reporting it to the harness', async () => {
509
+ const h = harness([reply({ text: 'Allow exactly once' })])
510
+ writeSessionState('answer-log', h.env, { last_prompt_at: AWAY })
511
+ registerQuestion('answer-log', h.env, { question: 'Ship it?' })
512
+
513
+ await hookRunCommand(
514
+ recording(h),
515
+ 'stop',
516
+ stdin({ session_id: 'answer-log', cwd: h.deps.cwd }),
517
+ )
518
+
519
+ const raw = readFileSync(activeLogPath(h.env), 'utf8')
520
+ expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
521
+ const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
522
+ expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
523
+ 'queued',
524
+ 'delivered',
525
+ ])
526
+ expect(h.io.errLines.join('\n')).toContain('Allow exactly once')
527
+ })
528
+
529
+ it('records the push and ties it to the request id the server knows', async () => {
530
+ const h = harness([])
294
531
  const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
295
532
  mkdirSync(dir, { recursive: true })
296
- writeFileSync(path.join(dir, 'config.toml'), toml)
297
- }
533
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
534
+ writeSessionState('pushed', h.env, { last_prompt_at: AWAY })
535
+ registerQuestion('pushed', h.env, { question: 'Ship it?' })
298
536
 
299
- it('counts a user actively typing as reachable', () => {
300
- // 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)
537
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'pushed', cwd: h.deps.cwd }))
303
538
 
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)
539
+ const pushed = readLogRecords(h.env, { event: ['hook.pushed'] }).records
540
+ expect(pushed).toHaveLength(1)
541
+ expect(pushed[0]!.data).toMatchObject({ ok: true })
542
+ // The local record and the server's evidence trail share one id, which is
543
+ // what makes `notifai logs --request <id>` and `notifai status <id>` two
544
+ // views of one thing.
545
+ expect(pushed[0]!.data!['request_id']).toBe(h.recorder.receipts[0])
307
546
  })
308
547
 
309
- it('escalates a question while the user is at the keyboard', async () => {
310
- const h = harness([], 1)
311
- writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 0\n')
312
- writeSessionState('present1', h.env, { last_prompt_at: NOW })
313
- registerQuestion('present1', h.env, { question: 'Ship it?' })
548
+ it('stores every request in a multi-question wait as a structured identity', async () => {
549
+ const h = harness([])
550
+ writeSessionState('request-log', h.env, { last_prompt_at: AWAY })
551
+ registerQuestion('request-log', h.env, { question: 'Ship it?' }, NOW)
552
+ registerQuestion('request-log', h.env, { question: 'Deploy it?' }, NOW + 1)
314
553
 
315
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
554
+ await hookRunCommand(
555
+ recording(h),
556
+ 'stop',
557
+ stdin({ session_id: 'request-log', cwd: h.deps.cwd }),
558
+ )
559
+
560
+ const unanswered = readLogRecords(h.env, { event: ['hook.answer'] }).records.at(-1)
561
+ expect(unanswered?.data?.['request_ids']).toEqual(h.recorder.receipts)
562
+ expect(readSessionState('request-log', h.env).pending).toBeUndefined()
316
563
 
317
- expect(h.recorder.submitted).toHaveLength(1)
318
- expect(h.io.errLines.join('\n')).not.toContain('at the keyboard')
564
+ for (const requestId of h.recorder.receipts) {
565
+ const matching = readLogRecords(h.env, { request: requestId }).records
566
+ expect(matching).toContainEqual(unanswered)
567
+ }
568
+ expect(readLogRecords(h.env, { request: 'req_hook' }).records).not.toContainEqual(unanswered)
319
569
  })
320
570
 
321
- it('still gives the terminal its grace window first', async () => {
322
- // The two knobs are independent: not needing the user to leave does not
323
- // mean skipping the wait that offers the question to the terminal.
324
- const h = harness([], 1)
325
- writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 300\n')
326
- writeSessionState('present2', h.env, { last_prompt_at: NOW })
327
- registerQuestion('present2', h.env, { question: 'Ship it?' }, NOW)
571
+ it('records a config parse failure as a complete fail-open lifecycle', async () => {
572
+ const h = harness()
573
+ mkdirSync(path.join(h.deps.cwd, '.notifai'), { recursive: true })
574
+ writeFileSync(path.join(h.deps.cwd, '.notifai', 'config.toml'), 'not valid = [toml')
328
575
 
329
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present2' }))
576
+ await hookRunCommand(
577
+ { ...h.deps, logger: createLogger({ env: h.env }) },
578
+ 'stop',
579
+ stdin({ session_id: 'bad-config', cwd: h.deps.cwd }),
580
+ )
330
581
 
331
- expect(h.recorder.submitted).toHaveLength(1)
332
- // The clock only advances when the hook sleeps, so this is proof the wait
333
- // actually happened rather than being skipped.
334
- expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
582
+ const records = readLogRecords(h.env, { limit: 10 }).records
583
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
584
+ expect(records.at(-1)?.data).toMatchObject({ outcome: 'failed', reason: 'config-failed' })
335
585
  })
336
586
 
337
- it('honours the grace window on a machine with no idle source at all', async () => {
338
- // With presence gating on, no idle source means refusing to wait
339
- // ('no-signal'). With it off there is nothing to watch for, so the timer
340
- // is just a timer and works everywhere.
341
- const h = harness([], null)
342
- writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 120\n')
343
- registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
587
+ it('records a hook failure with the server own words, not just that it failed', async () => {
588
+ const h = harness()
589
+ const deps: CommandDeps = {
590
+ ...recording(h),
591
+ clientFactory: () => {
592
+ throw new ApiCallError(422, 'validation_failed', 'draft rejected', null, [
593
+ { path: '/draft/lifecycle' },
594
+ ])
595
+ },
596
+ }
597
+ registerQuestion('broken', h.env, { question: 'Ship it?' })
598
+ writeSessionState('broken', h.env, { last_prompt_at: AWAY })
344
599
 
345
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
600
+ await hookRunCommand(deps, 'stop', stdin({ session_id: 'broken', cwd: h.deps.cwd }))
346
601
 
347
- expect(h.recorder.submitted).toHaveLength(1)
348
- expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
349
- expect(h.io.errLines.join('\n')).not.toContain('no idle signal')
602
+ const failures = readLogRecords(h.env, { level: 'error' }).records
603
+ expect(failures.length).toBeGreaterThan(0)
604
+ expect(JSON.stringify(failures)).toContain('/draft/lifecycle')
605
+ expect(JSON.stringify(failures)).toContain('validation_failed')
350
606
  })
351
607
 
352
- it('is not a way to switch escalation on when the user has switched it off', async () => {
353
- // ask_notifications is the "do not reach me" switch and outranks this one;
354
- // wanting to be reachable while working is a different question entirely.
355
- const h = harness([], 1)
356
- writeGlobalConfig(h, 'require_idle = false\nask_notifications = false\n')
357
- registerQuestion('present4', h.env, { question: 'Ship it?' })
608
+ it('never writes the machine credential, whatever the hook did', async () => {
609
+ const h = harness([])
610
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
611
+ mkdirSync(dir, { recursive: true })
612
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
613
+ writeSessionState('secret', h.env, { last_prompt_at: AWAY })
614
+ registerQuestion('secret', h.env, { question: 'Ship it?' })
358
615
 
359
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
616
+ await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'secret', cwd: h.deps.cwd }))
360
617
 
361
- expect(h.recorder.submitted).toHaveLength(0)
618
+ // The log is the artefact most likely to be pasted into a conversation.
619
+ const raw = readFileSync(activeLogPath(h.env), 'utf8')
620
+ expect(raw).not.toContain('test-secret')
621
+ expect(raw).not.toContain('nfm_mac_test')
362
622
  })
363
623
  })
364
624
 
625
+ /**
626
+ * Wanting notifications while still at the machine is a legitimate setting —
627
+ * nobody should have to stop using their computer in order to be reached.
628
+ * Presence is a precondition only while the user wants it to be, and switching
629
+ * it off must not disturb the grace timer.
630
+ */
365
631
  describe('terminal-first grace window', () => {
366
- /** Registers a question asked `agoMs` ago, with the user long since silent. */
632
+ /** Registers a question asked `agoMs` ago. */
367
633
  function pending(h: Harness, session: string, agoMs: number): void {
368
634
  writeSessionState(session, h.env, { last_prompt_at: AWAY })
369
635
  registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
370
636
  }
371
637
 
638
+ it('pushes as soon as the turn ends when no window is configured', async () => {
639
+ const h = harness([])
640
+ let submittedAt: number | undefined
641
+ h.recorder.beforeQuestionSubmit = () => {
642
+ submittedAt = h.deps.now?.()
643
+ }
644
+ // The user typed this very instant. It changes nothing: the waiter does
645
+ // not hold their terminal, so their being here is not a reason to wait.
646
+ writeSessionState('present1', h.env, { last_prompt_at: NOW })
647
+ registerQuestion('present1', h.env, { question: 'Ship it?' })
648
+
649
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
650
+
651
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
652
+ expect(submittedAt).toBe(NOW)
653
+ })
654
+
372
655
  it('holds the question in the terminal until the window elapses', async () => {
373
- // Idle 900s: the user is gone, so the wait runs to completion rather than
374
- // being abandoned. Sleeps advance the virtual clock.
375
- const h = harness([reply({ text: 'Yes' })], 900)
656
+ const h = harness([reply({ text: 'Yes' })])
657
+ writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
376
658
  pending(h, 'g1', 0)
377
659
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
378
660
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
379
- // Nothing was pushed before the 300s default had passed.
661
+ // Sleeps advance the virtual clock, so this is proof the wait happened.
380
662
  expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
381
663
  })
382
664
 
383
665
  it('counts the window from when the question was sent, not from the turn end', async () => {
384
666
  // Asked 290s ago while the agent kept working: only 10s of wait remains.
385
- const h = harness([reply({ text: 'Yes' })], 900)
667
+ const h = harness([reply({ text: 'Yes' })])
668
+ writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
386
669
  pending(h, 'g2', 290_000)
387
670
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g2' }))
388
671
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
389
672
  expect(h.deps.now?.()).toBeLessThan(NOW + 60_000)
390
673
  })
391
674
 
392
- it('sends nothing if the user comes back to the keyboard during the window', async () => {
393
- let idle = 900
394
- const h = harness([reply({ text: 'Yes' })], 900)
395
- // Machine goes active on the second poll: the user sat down.
396
- h.deps.idleSeconds = () => {
397
- const current = idle
398
- idle = 1
399
- return current
400
- }
401
- pending(h, 'g3', 0)
402
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g3' }))
403
- expect(h.recorder.submitted).toHaveLength(0)
404
- expect(h.io.errLines.join('\n')).toContain('came back')
675
+ it('waits the window out on a machine with no idle signal at all', async () => {
676
+ // The timer once refused to run without an idle source to watch, because
677
+ // it was holding a terminal it could not monitor. It holds nothing now.
678
+ const h = harness([])
679
+ writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
680
+ registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
681
+
682
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
683
+
684
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
685
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
405
686
  })
406
687
 
407
- it('refuses to hold a terminal it cannot monitor', async () => {
408
- // No idle source: waiting would block the prompt with no way to notice the
409
- // user returning, so it asks immediately instead.
410
- const h = harness([reply({ text: 'Yes' })], null)
411
- pending(h, 'g4', 0)
412
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g4' }))
413
- expect(h.recorder.submitted.length).toBeGreaterThan(0)
414
- expect(h.deps.now?.()).toBe(NOW)
415
- expect(h.io.errLines.join('\n')).toContain('no idle signal')
688
+ it('is not a way to switch escalation on when the user has switched it off', async () => {
689
+ // ask_notifications is the "do not reach me" switch and outranks the timer.
690
+ const h = harness([])
691
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\nask_notifications = false\n')
692
+ registerQuestion('present4', h.env, { question: 'Ship it?' })
693
+
694
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
695
+
696
+ expect(h.recorder.submitted).toHaveLength(0)
416
697
  })
417
698
 
418
- 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.
422
- const h = harness([reply({ text: 'Yes' })], 900)
423
- const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
424
- mkdirSync(dir, { recursive: true })
425
- writeFileSync(
426
- path.join(dir, 'config.toml'),
427
- 'ask_grace_seconds = 540\nhook_reply_timeout_seconds = 540\n',
428
- )
699
+ it('never lets the longest window crowd out a reply window the server accepts', async () => {
700
+ // The maximum grace a user can configure still has to leave the protocol's
701
+ // 60s minimum inside the waiter ceiling. Real timers do not wake on the
702
+ // exact requested millisecond, so the sleep deliberately overshoots.
703
+ const h = harness([reply({ text: 'Yes' })])
704
+ const virtualSleep = h.deps.sleep!
705
+ h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
706
+ writeGlobalConfig(h, 'ask_grace_seconds = 360\n')
429
707
  pending(h, 'g5', 0)
430
708
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
431
709
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
432
- expect(h.deps.now?.()).toBe(NOW)
710
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
711
+ expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
712
+ expect(
713
+ h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
714
+ ?.expires_in_seconds,
715
+ ).toBeGreaterThanOrEqual(60)
716
+ })
717
+
718
+ it('charges transport overrun and close time to the one waiter ceiling', async () => {
719
+ const h = harness([])
720
+ const factory = h.deps.clientFactory
721
+ h.deps.clientFactory = () => {
722
+ const client = factory!()
723
+ return {
724
+ ...client,
725
+ listDevices: async () => {
726
+ h.advanceClock(20_000)
727
+ return client.listDevices()
728
+ },
729
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
730
+ h.advanceClock(20_000)
731
+ return client.submit(body, waitSeconds)
732
+ },
733
+ replies: async (
734
+ requestId: string,
735
+ options: { waitSeconds: number; afterSeq: number },
736
+ ) => {
737
+ // Server hold plus the client's 20s AbortSignal allowance.
738
+ h.advanceClock((options.waitSeconds + 20) * 1000)
739
+ return {
740
+ request_id: requestId,
741
+ reply_expires_at: new Date(NOW + 480_000).toISOString(),
742
+ replies: [],
743
+ }
744
+ },
745
+ closeReplies: async (requestId: string) => {
746
+ h.advanceClock(20_000)
747
+ return client.closeReplies(requestId)
748
+ },
749
+ } as ApiClient
750
+ }
751
+ writeGlobalConfig(h, 'ask_grace_seconds = 250\n')
752
+ pending(h, 'transport-budget', 0)
753
+
754
+ await hookRunCommand(h.deps, 'stop', async () => {
755
+ // stdin/config/setup belong to the same invocation budget as polling.
756
+ h.advanceClock(30_000)
757
+ return JSON.stringify({ session_id: 'transport-budget' })
758
+ })
759
+
760
+ // The waiter's 480s ceiling is the last moment it will accept an answer;
761
+ // closing the window and retiring the card happens after it, which is what
762
+ // the blocking hosts' extra minute of declared headroom is there for.
763
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 540_000)
764
+ expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
433
765
  })
434
766
  })
435
767
 
@@ -465,7 +797,7 @@ describe('nagging guards', () => {
465
797
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
466
798
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
467
799
 
468
- expect(h.recorder.submitted).toHaveLength(1)
800
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
469
801
  })
470
802
 
471
803
  it('respects the harness recursion guard', async () => {
@@ -480,7 +812,7 @@ describe('nagging guards', () => {
480
812
  })
481
813
 
482
814
  it('delivers a new question registered during an answer continuation', async () => {
483
- const h = harness([reply({ text: 'First answer' })], 900)
815
+ const h = harness([reply({ text: 'First answer' })])
484
816
  writeSessionState('n3', h.env, { last_prompt_at: AWAY })
485
817
  registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
486
818
 
@@ -500,7 +832,7 @@ describe('nagging guards', () => {
500
832
  })
501
833
 
502
834
  it('stops chained questions at the consecutive continuation cap', async () => {
503
- const h = harness([], 900)
835
+ const h = harness([])
504
836
  writeSessionState('n4', h.env, {
505
837
  last_prompt_at: AWAY,
506
838
  continuation: { answered_at: NOW - 1, count: 3 },
@@ -513,24 +845,43 @@ describe('nagging guards', () => {
513
845
  expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
514
846
  expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
515
847
  })
848
+
849
+ it('starts the consecutive continuation count over when the user takes a turn', async () => {
850
+ // The cap bounds an agent running on its own. A human at the keyboard is
851
+ // the end of that chain, not another link in it.
852
+ const h = harness([])
853
+ writeSessionState('n5', h.env, {
854
+ continuation: { answered_at: NOW - 1, count: MAX_CONTINUATION_COUNT },
855
+ })
856
+
857
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'n5' }))
858
+
859
+ expect(readSessionState('n5', h.env).continuation).toEqual({
860
+ answered_at: NOW - 1,
861
+ count: 0,
862
+ })
863
+ })
516
864
  })
517
865
 
518
866
  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 = []
867
+ it('collects an answer that arrived during the resumed model turn', async () => {
868
+ const h = harness([reply({ text: 'Ship it' })])
869
+ writeSessionState('late-stop', h.env, {
870
+ last_prompt_at: AWAY,
871
+ pending: [{
872
+ question: 'Ship it?',
873
+ asked_at: NOW,
874
+ request_id: 'req_live',
875
+ collapse_key: 'collapse-live',
876
+ device_ids: ['dev_iphone'],
877
+ reply_deadline_at: NOW + 60_000,
878
+ }],
879
+ })
529
880
 
530
881
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
531
882
 
532
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
533
- expect(h.recorder.closed).toContain('req_hook_1')
883
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
884
+ expect(h.recorder.closed).toContain('req_live')
534
885
  expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
535
886
  expect(h.io.outLines).toHaveLength(1)
536
887
  expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
@@ -540,13 +891,18 @@ describe('late answer collection', () => {
540
891
  })
541
892
 
542
893
  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 = []
894
+ const h = harness([reply({ text: 'Hold' })])
895
+ writeSessionState('late-prompt', h.env, {
896
+ last_prompt_at: AWAY,
897
+ pending: [{
898
+ question: 'Ship it?',
899
+ asked_at: NOW,
900
+ request_id: 'req_live',
901
+ collapse_key: 'collapse-live',
902
+ device_ids: ['dev_iphone'],
903
+ reply_deadline_at: NOW + 60_000,
904
+ }],
905
+ })
550
906
 
551
907
  await hookRunCommand(
552
908
  h.deps,
@@ -554,71 +910,302 @@ describe('late answer collection', () => {
554
910
  stdin({ session_id: 'late-prompt', cwd: h.deps.cwd }),
555
911
  )
556
912
 
913
+ expect(readSessionState('late-prompt', h.env).accepted?.answers[0]?.reply.text).toBe('Hold')
914
+ expect(h.io.outLines).toEqual([])
915
+
916
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
917
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
918
+ await hookRunCommand(
919
+ h.deps,
920
+ 'stop',
921
+ stdin({ session_id: 'late-prompt', stop_hook_active: true }),
922
+ )
923
+
557
924
  const retirement = h.recorder.submitted.find(
558
925
  (entry) => entry.draft.event === 'question_retired',
559
926
  )?.draft
560
927
  expect(retirement?.lifecycle).toMatchObject({
561
928
  state: 'answered',
562
- retires_request_id: 'req_hook_1',
929
+ retires_request_id: 'req_live',
563
930
  })
564
931
  expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
565
932
  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
- })
933
+ expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
572
934
  })
573
935
  })
574
936
 
575
- describe('OpenCode answer preservation', () => {
576
- it('pushes without waiting and leaves the answerable request intact', async () => {
577
- const h = harness([reply({ text: 'Approve' })], 900)
937
+ describe('OpenCode answer continuation', () => {
938
+ it('fails closed instead of accepting an answer the idle event cannot deliver', async () => {
939
+ const h = harness([reply({ text: 'Approve' })])
578
940
  writeSessionState('open1', h.env, { last_prompt_at: AWAY })
579
941
  registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
580
942
 
581
943
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
582
944
 
583
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
945
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
584
946
  expect(h.recorder.closed).toEqual([])
585
947
  expect(
586
948
  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
- })
949
+ ).toHaveLength(0)
950
+ expect(readSessionState('open1', h.env).pending).toBeUndefined()
593
951
  expect(h.io.outLines).toEqual([])
952
+ expect(h.io.errLines.join('\n')).toContain('no proven answer continuation')
594
953
  })
595
954
  })
596
955
 
597
- describe('reply-wait presence monitoring', () => {
598
- it('returns the terminal when the user comes back after the push', async () => {
599
- const h = harness([], 900)
600
- let checks = 0
601
- h.deps.idleSeconds = () => {
602
- checks += 1
603
- return checks >= 4 ? 1 : 900
956
+ describe('the waiter owning one question to the end', () => {
957
+ it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
958
+ const h = harness([])
959
+ const factory = h.deps.clientFactory
960
+ h.deps.clientFactory = () => {
961
+ const client = factory!()
962
+ return {
963
+ ...client,
964
+ replies: async (requestId: string) => ({
965
+ request_id: requestId,
966
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
967
+ replies: [],
968
+ }),
969
+ closeReplies: async (requestId: string) => {
970
+ h.recorder.closed.push(requestId)
971
+ return {
972
+ request_id: requestId,
973
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
974
+ replies: [reply({ text: 'Committed at the fence' })],
975
+ }
976
+ },
977
+ } as ApiClient
978
+ }
979
+ writeSessionState('fenced-reply', h.env, { last_prompt_at: AWAY })
980
+ registerQuestion('fenced-reply', h.env, { question: 'Deploy?' }, NOW)
981
+
982
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fenced-reply' }))
983
+
984
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain(
985
+ 'Committed at the fence',
986
+ )
987
+ expect(readSessionState('fenced-reply', h.env).accepted).toBeDefined()
988
+ })
989
+
990
+ it('preserves a silent question when the close fence cannot be proven', async () => {
991
+ const h = harness([])
992
+ const factory = h.deps.clientFactory
993
+ h.deps.clientFactory = () => {
994
+ const client = factory!()
995
+ return {
996
+ ...client,
997
+ closeReplies: async () => {
998
+ throw new Error('partitioned during close')
999
+ },
1000
+ } as ApiClient
604
1001
  }
605
- writeSessionState('returned', h.env, { last_prompt_at: AWAY })
606
- registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
1002
+ writeSessionState('unproven-close', h.env, { last_prompt_at: AWAY })
1003
+ registerQuestion('unproven-close', h.env, { question: 'Deploy?' }, NOW)
607
1004
 
608
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
1005
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'unproven-close' }))
609
1006
 
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')
1007
+ const state = readSessionState('unproven-close', h.env)
1008
+ expect(state.pending?.[0]?.request_id).toBe(h.recorder.receipts[0])
1009
+ expect(state.retiring).toBeUndefined()
1010
+ expect(h.io.errLines.join('\n')).toContain('preserving ownership')
1011
+ })
1012
+
1013
+ it('recovers an accepted question after its submit response is lost', async () => {
1014
+ const h = harness([])
1015
+ const factory = h.deps.clientFactory
1016
+ let acceptedRequestId: string | undefined
1017
+ h.deps.clientFactory = () => {
1018
+ const client = factory!()
1019
+ return {
1020
+ ...client,
1021
+ submit: async (body: SubmitNotificationRequestT) => {
1022
+ if (body.draft.event !== 'agent_question') return client.submit(body, 0)
1023
+ acceptedRequestId = body.request_id
1024
+ h.recorder.submitted.push(body)
1025
+ h.recorder.receipts.push(body.request_id!)
1026
+ h.recorder.aliases.set(body.request_id!, 1)
1027
+ throw new Error('response lost after commit')
1028
+ },
1029
+ replies: async (requestId: string) => ({
1030
+ request_id: requestId,
1031
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
1032
+ replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
1033
+ }),
1034
+ closeReplies: async (requestId: string) => ({
1035
+ request_id: requestId,
1036
+ reply_expires_at: new Date(NOW).toISOString(),
1037
+ replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
1038
+ }),
1039
+ } as ApiClient
1040
+ }
1041
+ writeSessionState('ambiguous-submit', h.env, { last_prompt_at: AWAY })
1042
+ registerQuestion('ambiguous-submit', h.env, { question: 'Deploy?' }, NOW)
1043
+
1044
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ambiguous-submit' }))
1045
+
1046
+ expect(acceptedRequestId).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1047
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Yes, recover it')
1048
+ expect(readSessionState('ambiguous-submit', h.env).accepted?.answers[0]?.pending.request_id)
1049
+ .toBe(acceptedRequestId)
1050
+ })
1051
+
1052
+ it('replays the frozen intent when an ambiguous submit never reached the server', async () => {
1053
+ const h = harness([])
1054
+ const factory = h.deps.clientFactory
1055
+ let firstAttempt: SubmitNotificationRequestT | undefined
1056
+ h.deps.clientFactory = () => {
1057
+ const client = factory!()
1058
+ return {
1059
+ ...client,
1060
+ submit: async (body: SubmitNotificationRequestT) => {
1061
+ firstAttempt = body
1062
+ throw new Error('connection failed before commit')
1063
+ },
1064
+ replies: async () => {
1065
+ throw new ApiCallError(404, 'not_found', 'No such request.')
1066
+ },
1067
+ closeReplies: async () => {
1068
+ throw new ApiCallError(404, 'not_found', 'No such request.')
1069
+ },
1070
+ } as ApiClient
1071
+ }
1072
+ writeSessionState('precommit-loss', h.env, { last_prompt_at: AWAY })
1073
+ registerQuestion('precommit-loss', h.env, { question: 'Deploy?' }, NOW)
1074
+
1075
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
1076
+
1077
+ const stranded = readSessionState('precommit-loss', h.env).pending?.[0]
1078
+ expect(stranded?.request_id).toBeUndefined()
1079
+ expect(stranded?.submission).toBeDefined()
1080
+ expect(h.io.errLines.join('\n')).toContain('response was ambiguous')
1081
+
1082
+ h.advanceClock(600_000)
1083
+ h.deps.clientFactory = factory
1084
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
1085
+
1086
+ expect(h.recorder.submitted[0]).toEqual(firstAttempt)
1087
+ })
1088
+
1089
+ it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
1090
+ const h = harness([])
1091
+ const factory = h.deps.clientFactory
1092
+ let polls = 0
1093
+ h.deps.clientFactory = () => {
1094
+ const client = factory!()
1095
+ return {
1096
+ ...client,
1097
+ submit: async () => {
1098
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1099
+ },
1100
+ replies: async () => {
1101
+ polls += 1
1102
+ throw new Error('must not poll an unaccepted request')
1103
+ },
1104
+ } as ApiClient
1105
+ }
1106
+ writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1107
+ registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1108
+
1109
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1110
+
1111
+ const pending = readSessionState('rejected-submit', h.env).pending?.[0]
1112
+ expect(pending?.request_id).toBeUndefined()
1113
+ expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1114
+ expect(polls).toBe(0)
1115
+ expect(h.io.errLines.join('\n')).toContain('submission was rejected')
1116
+
1117
+ const frozen = pending?.submission
1118
+ if (frozen === undefined) throw new Error('expected a persisted submission intent')
1119
+ writeGlobalConfig(h, 'project = "changed-after-crash"\n')
1120
+ h.advanceClock(600_000)
1121
+ h.deps.clientFactory = factory
1122
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1123
+
1124
+ expect(h.recorder.submitted[0]).toEqual({
1125
+ request_id: frozen.request_id,
1126
+ idempotency_key: frozen.idempotency_key,
1127
+ draft: frozen.draft,
1128
+ })
1129
+ })
1130
+
1131
+ it('closes and retires silence at the absolute owner deadline', async () => {
1132
+ const h = harness([])
1133
+ const factory = h.deps.clientFactory
1134
+ h.deps.clientFactory = () => {
1135
+ const client = factory!()
1136
+ return {
1137
+ ...client,
1138
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => ({
1139
+ ...(await client.submit(body, waitSeconds)),
1140
+ // The real server commits the configured reply window, rather than
1141
+ // the generic recorder's deliberately broad 480s fixture.
1142
+ reply_expires_at: new Date(NOW + 60_000).toISOString(),
1143
+ }),
1144
+ } as ApiClient
1145
+ }
1146
+ writeSessionState('deadline', h.env, { last_prompt_at: AWAY })
1147
+ registerQuestion('deadline', h.env, { question: 'Deploy?' }, NOW)
1148
+
1149
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
1150
+
1151
+ expect(h.deps.now?.()).toBe(NOW + 60_000)
1152
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1153
+ expect(readSessionState('deadline', h.env).pending).toBeUndefined()
1154
+ expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
1155
+ })
1156
+
1157
+ it('charges sequential submission latency to one deadline', async () => {
1158
+ const h = harness([reply({ text: 'Done' })])
1159
+ writeSessionState('latency', h.env, { last_prompt_at: AWAY })
1160
+ registerQuestion('latency', h.env, { question: 'First?' }, NOW)
1161
+ registerQuestion('latency', h.env, { question: 'Second?' }, NOW)
1162
+ h.recorder.beforeQuestionSubmit = () => h.advanceClock(40_000)
1163
+
1164
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
1165
+
1166
+ const windows = h.recorder.submitted
1167
+ .filter((entry) => entry.draft.event === 'agent_question')
1168
+ .map((entry) => entry.draft.reply?.expires_in_seconds)
1169
+ // Both windows end at the same absolute moment: the second question's is
1170
+ // shorter by exactly the 40s the first submission spent.
1171
+ expect(windows).toEqual([480, 440])
1172
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
1173
+ })
1174
+
1175
+ it('re-arms the next Stop for an independently pending answer', async () => {
1176
+ const h = harness([])
1177
+ const answers = new Map<string, ReplyView[]>([
1178
+ ['req_hook_1', [reply({ text: 'First answer' })]],
1179
+ ['req_hook_2', []],
1180
+ ])
1181
+ h.recorder.repliesFor = (requestId) => answers.get(requestId) ?? []
1182
+ writeSessionState('successor', h.env, { last_prompt_at: AWAY })
1183
+ registerQuestion('successor', h.env, { question: 'First?' }, NOW)
1184
+ registerQuestion('successor', h.env, { question: 'Second?' }, NOW)
1185
+
1186
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'successor' }))
1187
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('First answer')
1188
+ expect(readSessionState('successor', h.env).pending?.map((entry) => entry.question)).toEqual([
1189
+ 'Second?',
1190
+ ])
1191
+
1192
+ answers.set('req_hook_2', [reply({ text: 'Second answer' })])
1193
+ h.io.outLines = []
1194
+ await hookRunCommand(
1195
+ h.deps,
1196
+ 'stop',
1197
+ stdin({ session_id: 'successor', stop_hook_active: true }),
1198
+ )
1199
+
1200
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
1201
+ expect(readSessionState('successor', h.env).pending).toBeUndefined()
1202
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(2)
616
1203
  })
617
1204
  })
618
1205
 
619
1206
  describe('Cursor stop output', () => {
620
- it('maps the phone answer to one native followup_message', async () => {
621
- const h = harness([reply({ text: 'Ship it' })], 900)
1207
+ it('fails closed when the invoking shell cannot name the exact conversation', async () => {
1208
+ const h = harness([reply({ text: 'Ship it' })])
622
1209
  writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
623
1210
  registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
624
1211
 
@@ -633,12 +1220,9 @@ describe('Cursor stop output', () => {
633
1220
  'cursor',
634
1221
  )
635
1222
 
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')
1223
+ expect(h.recorder.submitted).toHaveLength(0)
1224
+ expect(h.io.outLines).toHaveLength(0)
1225
+ expect(h.io.errLines.join('\n')).toContain('asynchronous ask is unsupported')
642
1226
  })
643
1227
  })
644
1228
 
@@ -661,36 +1245,40 @@ describe('question queueing and retirement debt', () => {
661
1245
  }))
662
1246
  }
663
1247
 
664
- it('keeps the first question live and asks the second alongside it', async () => {
1248
+ function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
1249
+ writeSessionState(sessionId, h.env, {
1250
+ last_prompt_at: AWAY,
1251
+ pending: [{
1252
+ question: 'Ship it?',
1253
+ asked_at: NOW,
1254
+ request_id: requestId,
1255
+ collapse_key: 'collapse-live',
1256
+ device_ids: ['dev_iphone', 'dev_mac'],
1257
+ reply_deadline_at: NOW + 60_000,
1258
+ }],
1259
+ })
1260
+ }
1261
+
1262
+ it('registers a second question without mutating the live first question', () => {
665
1263
  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()
1264
+ seedLive(h, 'sup1')
671
1265
 
672
1266
  // The agent carried on and asked something else. The first question is
673
1267
  // still the user's to answer — a second question never ends the first;
674
1268
  // superseding is reply semantics, not question semantics.
675
1269
  registerQuestion('sup1', h.env, { question: 'Deploy it?' })
676
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
677
1270
 
678
1271
  expect(retirements(h)).toEqual([])
679
- expect(
680
- h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
681
- ).toHaveLength(1)
682
1272
  const live = readSessionState('sup1', h.env).pending
683
1273
  expect(live).toHaveLength(2)
684
1274
  expect(live?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
685
- expect(live?.every((entry) => entry.request_id !== undefined)).toBe(true)
1275
+ expect(live?.map((entry) => entry.request_id)).toEqual(['req_live', undefined])
686
1276
  })
687
1277
 
688
1278
  it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
689
1279
  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]
1280
+ seedLive(h, 'sup2')
1281
+ const first = 'req_live'
694
1282
 
695
1283
  // Offline when the user returns: the wipe parks the retirement, the
696
1284
  // drain fails, and it must not forget what it was for.
@@ -706,13 +1294,11 @@ describe('question queueing and retirement debt', () => {
706
1294
 
707
1295
  it('keeps the original delivery targets when routing changes before retirement', async () => {
708
1296
  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' }))
1297
+ seedLive(h, 'sup-targets')
712
1298
 
713
1299
  const live = readSessionState('sup-targets', h.env).pending?.[0]
714
1300
  expect(live).toMatchObject({
715
- request_id: 'req_hook_1',
1301
+ request_id: 'req_live',
716
1302
  device_ids: ['dev_iphone', 'dev_mac'],
717
1303
  })
718
1304
 
@@ -727,7 +1313,7 @@ describe('question queueing and retirement debt', () => {
727
1313
  const retirement = h.recorder.submitted.find(
728
1314
  (submission) => submission.draft.event === 'question_retired',
729
1315
  )
730
- expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_hook_1')
1316
+ expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_live')
731
1317
  expect(retirement?.draft.targets).toEqual({
732
1318
  mode: 'selected',
733
1319
  device_ids: ['dev_iphone', 'dev_mac'],
@@ -739,10 +1325,8 @@ describe('question queueing and retirement debt', () => {
739
1325
  // before every guard — retirement debt has nothing to do with whether
740
1326
  // this turn may ask.
741
1327
  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]
1328
+ seedLive(h, 'sup3')
1329
+ const first = 'req_live'
746
1330
 
747
1331
  h.recorder.failSubmits = true
748
1332
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
@@ -759,10 +1343,8 @@ describe('question queueing and retirement debt', () => {
759
1343
  // UserPromptSubmit resets session state to record presence, and that reset
760
1344
  // used to take the retirement queue with it.
761
1345
  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]
1346
+ seedLive(h, 'sup4')
1347
+ const first = 'req_live'
766
1348
 
767
1349
  // The first return is offline: the wipe parks, the drain fails, and the
768
1350
  // next prompt must still find the debt.
@@ -795,22 +1377,11 @@ describe('question queueing and retirement debt', () => {
795
1377
  describe('several questions in flight', () => {
796
1378
  /** Route each live request its own replies; the shared list cannot say who answered what. */
797
1379
  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
- }
1380
+ h.recorder.repliesFor = (requestId) => byRequest.get(requestId) ?? []
810
1381
  }
811
1382
 
812
1383
  it('escalates every registered question in one pass, each as its own notification', async () => {
813
- const h = harness([], 900)
1384
+ const h = harness([])
814
1385
  writeSessionState('multi1', h.env, { last_prompt_at: AWAY })
815
1386
  registerQuestion('multi1', h.env, { question: 'Ship it?' }, NOW)
816
1387
  registerQuestion('multi1', h.env, { question: 'Deploy where?' }, NOW + 1)
@@ -822,12 +1393,159 @@ describe('several questions in flight', () => {
822
1393
  // Each is its own notification with its own collapse key — one ask never
823
1394
  // stands in for, or replaces, another.
824
1395
  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'])
1396
+ expect(readSessionState('multi1', h.env).pending).toBeUndefined()
1397
+ expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
1398
+ })
1399
+
1400
+ /**
1401
+ * A registered form/ask must become a durable server request at turn-end.
1402
+ * Codex and Claude both enter through the same Stop
1403
+ * handler; the harness flag only changes answer injection, not settlement.
1404
+ */
1405
+ it('settles a registered form into a durable request_id on the Codex Stop path', async () => {
1406
+ const h = harness([])
1407
+ writeSessionState('codex-form', h.env, { last_prompt_at: AWAY })
1408
+ const built = buildQuestions(
1409
+ { form: JSON.stringify({
1410
+ questions: [
1411
+ { text: 'Start personally?', choices: ['Yes', 'No'] },
1412
+ { text: 'Which region?', choices: ['US', 'EU'] },
1413
+ ],
1414
+ }) },
1415
+ undefined,
1416
+ )
1417
+ expect(built.ok).toBe(true)
1418
+ if (!built.ok) return
1419
+ registerQuestion('codex-form', h.env, {
1420
+ question: built.questions[0]!.text,
1421
+ questions: built.questions,
1422
+ })
1423
+
1424
+ // Registration alone must not submit; durability happens at turn-end.
1425
+ expect(h.recorder.submitted).toEqual([])
1426
+ expect(readSessionState('codex-form', h.env).pending?.[0]?.request_id).toBeUndefined()
1427
+
1428
+ await hookRunCommand(
1429
+ h.deps,
1430
+ 'stop',
1431
+ stdin({ session_id: 'codex-form' }),
1432
+ 'codex',
1433
+ )
1434
+
1435
+ expect(h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')).toHaveLength(1)
1436
+ expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
1437
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1438
+ expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
1439
+ })
1440
+
1441
+ it('keeps a settled ask durable and collects the answer after a transient internal_error', async () => {
1442
+ const h = harness([])
1443
+ let polls = 0
1444
+ const factory = h.deps.clientFactory
1445
+ h.deps.clientFactory = () => {
1446
+ const client = factory!()
1447
+ return {
1448
+ ...client,
1449
+ replies: async (requestId: string) => {
1450
+ polls += 1
1451
+ if (polls <= 2) {
1452
+ throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
1453
+ }
1454
+ return {
1455
+ request_id: requestId,
1456
+ reply_expires_at: null,
1457
+ replies: [reply({ text: 'Yes — start personally' })],
1458
+ } satisfies ListRepliesResponse
1459
+ },
1460
+ } as ApiClient
1461
+ }
1462
+ writeSessionState('recover-500', h.env, { last_prompt_at: AWAY })
1463
+ registerQuestion('recover-500', h.env, { question: 'Start personally?' }, NOW - 300_000)
1464
+
1465
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'recover-500' }), 'codex')
1466
+
1467
+ // Submit recorded the durable id before any wait; a 500 must not erase it
1468
+ // mid-flight, and recovery must still surface the late answer.
1469
+ const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1470
+ expect(questions).toHaveLength(1)
1471
+ expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1472
+ expect(polls).toBeGreaterThanOrEqual(3)
1473
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1474
+ expect(readSessionState('recover-500', h.env).pending).toBeUndefined()
1475
+ const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
1476
+ expect(output.decision).toBe('block')
1477
+ expect(output.reason).toContain('Yes — start personally')
1478
+ })
1479
+
1480
+ it('stops retrying and names a permanent rejection during the blocking multi-wait', async () => {
1481
+ const h = harness([])
1482
+ let polls = 0
1483
+ const factory = h.deps.clientFactory
1484
+ h.deps.clientFactory = () => {
1485
+ const client = factory!()
1486
+ return {
1487
+ ...client,
1488
+ replies: async () => {
1489
+ polls += 1
1490
+ throw new ApiCallError(404, 'not_found', 'No such request.')
1491
+ },
1492
+ } as ApiClient
1493
+ }
1494
+ writeSessionState('permanent-wait', h.env, { last_prompt_at: AWAY })
1495
+ registerQuestion('permanent-wait', h.env, { question: 'Still there?' }, NOW - 300_000)
1496
+
1497
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-wait' }), 'codex')
1498
+
1499
+ expect(polls).toBe(1)
1500
+ expect(readSessionState('permanent-wait', h.env).pending).toBeUndefined()
1501
+ expect(h.io.errLines.join('\n')).toContain(
1502
+ `${h.recorder.receipts[0]}: not_found (HTTP 404): No such request.`,
1503
+ )
1504
+ expect(h.io.errLines.join('\n')).not.toContain('could not reach the server')
1505
+ })
1506
+
1507
+ it('distinguishes a permanent late-poll rejection from transient trouble', async () => {
1508
+ const h = harness([])
1509
+ let polls = 0
1510
+ const factory = h.deps.clientFactory
1511
+ h.deps.clientFactory = () => {
1512
+ const client = factory!()
1513
+ return {
1514
+ ...client,
1515
+ replies: async () => {
1516
+ polls += 1
1517
+ throw new ApiCallError(401, 'machine_revoked', 'This machine was revoked.')
1518
+ },
1519
+ } as ApiClient
1520
+ }
1521
+ writeSessionState('permanent-late', h.env, {
1522
+ last_prompt_at: AWAY,
1523
+ pending: [
1524
+ {
1525
+ question: 'Deploy?',
1526
+ asked_at: NOW - 300_000,
1527
+ request_id: 'req_existing',
1528
+ collapse_key: 'question-existing',
1529
+ device_ids: ['dev_iphone'],
1530
+ reply_deadline_at: NOW + 60_000,
1531
+ },
1532
+ ],
1533
+ })
1534
+
1535
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-late' }), 'codex')
1536
+
1537
+ expect(polls).toBe(1)
1538
+ expect(h.io.errLines.join('\n')).toContain(
1539
+ 'req_existing: machine_revoked (HTTP 401): This machine was revoked.',
1540
+ )
1541
+ expect(h.io.errLines.join('\n')).toContain(
1542
+ 'reply polling stopped after a permanent server rejection',
1543
+ )
1544
+ expect(h.io.errLines.join('\n')).not.toContain('could not check whether its answer arrived')
827
1545
  })
828
1546
 
829
1547
  it('resumes with every answer that arrived, each tied to its question', async () => {
830
- const h = harness([], 900)
1548
+ const h = harness([])
831
1549
  repliesByRequest(
832
1550
  h,
833
1551
  new Map([
@@ -843,7 +1561,7 @@ describe('several questions in flight', () => {
843
1561
 
844
1562
  // Both reply windows close immediately (first answer claims each
845
1563
  // 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']))
1564
+ expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
847
1565
  expect(readSessionState('multi2', h.env).pending).toBeUndefined()
848
1566
  const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
849
1567
  expect(output.decision).toBe('block')
@@ -852,8 +1570,51 @@ describe('several questions in flight', () => {
852
1570
  expect(output.reason).toContain('"Deploy where?" → "Staging"')
853
1571
  })
854
1572
 
1573
+ it('journals an accepted answer until a successor Stop proves delivery', async () => {
1574
+ const h = harness([reply({ text: 'Ship it' })])
1575
+ writeSessionState('answer-handoff', h.env, {
1576
+ last_prompt_at: AWAY,
1577
+ pending: [
1578
+ {
1579
+ question: 'Ship it?',
1580
+ asked_at: NOW,
1581
+ request_id: 'req_existing',
1582
+ collapse_key: 'question-existing',
1583
+ device_ids: ['dev_iphone'],
1584
+ reply_deadline_at: NOW + 60_000,
1585
+ },
1586
+ ],
1587
+ })
1588
+
1589
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
1590
+ const staged = readSessionState('answer-handoff', h.env)
1591
+ expect(staged.accepted?.answers[0]?.reply.text).toBe('Ship it')
1592
+ expect(JSON.parse(h.io.outLines.at(-1)!).decision).toBe('block')
1593
+
1594
+ h.io.outLines = []
1595
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
1596
+ expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
1597
+ expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
1598
+
1599
+ h.io.outLines = []
1600
+ await hookRunCommand(
1601
+ h.deps,
1602
+ 'stop',
1603
+ stdin({ session_id: 'answer-handoff', stop_hook_active: true }),
1604
+ )
1605
+ expect(h.io.outLines).toEqual([])
1606
+ expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
1607
+ expect(
1608
+ h.recorder.submitted.find(
1609
+ (entry) =>
1610
+ entry.draft.event === 'question_retired' &&
1611
+ entry.draft.lifecycle?.retires_request_id === 'req_existing',
1612
+ )?.draft.lifecycle?.state,
1613
+ ).toBe('answered')
1614
+ })
1615
+
855
1616
  it('resumes with a partial answer and keeps the rest registered', async () => {
856
- const h = harness([], 900)
1617
+ const h = harness([])
857
1618
  const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
858
1619
  repliesByRequest(h, byRequest)
859
1620
  writeSessionState('multi3', h.env, { last_prompt_at: AWAY })
@@ -867,7 +1628,7 @@ describe('several questions in flight', () => {
867
1628
  expect(output.reason).toContain('1 more registered question is still waiting')
868
1629
  const live = readSessionState('multi3', h.env).pending
869
1630
  expect(live?.map((entry) => entry.question)).toEqual(['Deploy where?'])
870
- expect(live?.[0]?.request_id).toBe('req_hook_2')
1631
+ expect(live?.[0]?.request_id).toBe(h.recorder.receipts[1])
871
1632
 
872
1633
  // The remaining question's answer arrives before the next turn ends; the
873
1634
  // late-answer path hands it over without asking anything twice.
@@ -894,14 +1655,20 @@ describe('a question that outlives its session', () => {
894
1655
  }))
895
1656
  }
896
1657
 
897
- /** Escalate a question that nobody answers, so it is live on the devices. */
1658
+ /** State a still-owned request can have when SessionEnd races its Stop. */
898
1659
  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!
1660
+ writeSessionState(sessionId, h.env, {
1661
+ last_prompt_at: AWAY,
1662
+ pending: [{
1663
+ question: 'Ship it?',
1664
+ asked_at: NOW,
1665
+ request_id: 'req_live',
1666
+ collapse_key: 'collapse-live',
1667
+ device_ids: ['dev_iphone'],
1668
+ reply_deadline_at: NOW + 60_000,
1669
+ }],
1670
+ })
1671
+ return 'req_live'
905
1672
  }
906
1673
 
907
1674
  it('retires a question the harness exited on, from a later session', async () => {
@@ -1008,14 +1775,15 @@ describe('hostile input', () => {
1008
1775
  expect(seen).toBe('https://test.notifai.invalid')
1009
1776
  })
1010
1777
 
1011
- it('clamps an out-of-range away threshold instead of trusting it', () => {
1778
+ it('clamps an out-of-range grace window instead of trusting it', () => {
1012
1779
  const h = harness()
1013
1780
  const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-bounds-'))
1014
1781
  mkdirSync(path.join(project, '.notifai'), { recursive: true })
1015
- writeFileSync(path.join(project, '.notifai', 'config.toml'), 'away_after_seconds = -1\n')
1782
+ writeFileSync(path.join(project, '.notifai', 'config.toml'), 'ask_grace_seconds = 99999\n')
1016
1783
  const config = loadConfig({ cwd: project, env: h.env })
1017
- // -1 would make someone who just typed count as absent.
1018
- expect(config.away_after_seconds.value).toBeGreaterThanOrEqual(5)
1784
+ // A committed repository file must not be able to consume the waiter's
1785
+ // whole ceiling and leave no window in which an answer is still accepted.
1786
+ expect(config.ask_grace_seconds.value).toBeLessThanOrEqual(360)
1019
1787
  })
1020
1788
  })
1021
1789
 
@@ -1030,12 +1798,10 @@ describe('ask registration', () => {
1030
1798
 
1031
1799
  it('stores the validated question set, comma-bearing labels verbatim', () => {
1032
1800
  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)
1801
+ const built = buildQuestions({ choice: ['Yes, ship it', 'No, hold'] }, 'Ship it?')
1802
+ expect(built.ok).toBe(true)
1803
+ if (!built.ok) return
1804
+ registerQuestion('a2', h.env, { question: built.questions[0]!.text, questions: built.questions })
1039
1805
  expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
1040
1806
  {
1041
1807
  id: 'ship-it',
@@ -1049,20 +1815,25 @@ describe('ask registration', () => {
1049
1815
  })
1050
1816
 
1051
1817
  it('registers a multi-question form and pushes it as one set', async () => {
1052
- const h = harness([reply({ text: 'Yes' })], 900)
1818
+ const h = harness([reply({ text: 'Yes' })])
1053
1819
  writeSessionState('form1', h.env, { last_prompt_at: AWAY })
1054
- expect(
1055
- askCommand(h.deps, undefined, {
1056
- session: 'form1',
1057
- form: JSON.stringify({
1820
+ const built = buildQuestions(
1821
+ { form: JSON.stringify({
1058
1822
  questions: [
1059
1823
  { text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
1060
1824
  { text: 'Anything to watch?' },
1061
1825
  ],
1062
1826
  detail: '## Context\nThe long story.',
1063
- }),
1064
- }),
1065
- ).toBe(EXIT.ok)
1827
+ }) },
1828
+ undefined,
1829
+ )
1830
+ expect(built.ok).toBe(true)
1831
+ if (!built.ok) return
1832
+ registerQuestion('form1', h.env, {
1833
+ question: built.questions[0]!.text,
1834
+ questions: built.questions,
1835
+ ...(built.detail === undefined ? {} : { detail: built.detail }),
1836
+ })
1066
1837
  const pending = readSessionState('form1', h.env).pending?.[0]
1067
1838
  expect(pending?.questions).toHaveLength(2)
1068
1839
  expect(pending?.detail).toContain('long story')
@@ -1087,6 +1858,34 @@ describe('ask registration', () => {
1087
1858
  ])
1088
1859
  })
1089
1860
 
1861
+ it('relays the real registered identity, question text, and answer without trust or approval claims', async () => {
1862
+ const h = harness(
1863
+ [
1864
+ reply({
1865
+ text: 'BETA',
1866
+ answers: [{ question_id: 'rollout-option', choice_ids: ['beta'], text: null }],
1867
+ }),
1868
+ ],
1869
+ 900,
1870
+ )
1871
+ writeSessionState('framing1', h.env, { last_prompt_at: AWAY })
1872
+ const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
1873
+ expect(built.ok).toBe(true)
1874
+ if (!built.ok) return
1875
+ registerQuestion('framing1', h.env, {
1876
+ question: 'Which rollout option?',
1877
+ questions: built.questions,
1878
+ })
1879
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'framing1' }))
1880
+
1881
+ const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
1882
+ expect(decision.decision).toBe('block')
1883
+ expect(decision.reason).toContain('question_id rollout-option')
1884
+ expect(decision.reason).toContain('"Which rollout option?"')
1885
+ expect(decision.reason).toContain('"BETA"')
1886
+ expect(decision.reason).not.toMatch(/trusted|urgent|permission|approval/i)
1887
+ })
1888
+
1090
1889
  it('acts on the latest reply when answers conflict, because it is a correction', async () => {
1091
1890
  const h = harness(
1092
1891
  [
@@ -1101,9 +1900,10 @@ describe('ask registration', () => {
1101
1900
  900,
1102
1901
  )
1103
1902
  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)
1903
+ const built = buildQuestions({ choice: ['Yes', 'No'] }, 'Ship it?')
1904
+ expect(built.ok).toBe(true)
1905
+ if (!built.ok) return
1906
+ registerQuestion('latest1', h.env, { question: 'Ship it?', questions: built.questions })
1107
1907
 
1108
1908
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
1109
1909
 
@@ -1127,7 +1927,7 @@ describe('ask registration', () => {
1127
1927
  900,
1128
1928
  )
1129
1929
  writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
1130
- expect(askCommand(h.deps, 'Which color?', { session: 'parts1' })).toBe(EXIT.ok)
1930
+ registerQuestion('parts1', h.env, { question: 'Which color?' })
1131
1931
 
1132
1932
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
1133
1933
 
@@ -1136,27 +1936,17 @@ describe('ask registration', () => {
1136
1936
  expect(decision.reason).toContain('"Use blue", then "actually teal"')
1137
1937
  })
1138
1938
 
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.
1939
+ it('does not let an explicit session bypass exact active-harness proof', () => {
1142
1940
  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()
1941
+ expect(askCommand(h.deps, 'Ship it?', { session: 'guessed' })).toBe(EXIT.usage)
1942
+ expect(readSessionState('guessed', h.env).pending).toBeUndefined()
1153
1943
  })
1154
1944
 
1155
- it('falls back to NOTIFAI_SESSION where no hook has spoken', () => {
1945
+ it('does not route from NOTIFAI_SESSION where no exact harness has spoken', () => {
1156
1946
  const h = harness()
1157
1947
  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?')
1948
+ expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.usage)
1949
+ expect(readSessionState('solo-session', h.env).pending).toBeUndefined()
1160
1950
  })
1161
1951
 
1162
1952
  it('queues alongside incomplete live state without touching it', () => {
@@ -1174,7 +1964,7 @@ describe('ask registration', () => {
1174
1964
  ],
1175
1965
  })
1176
1966
 
1177
- expect(askCommand(h.deps, 'Another?', { session: 'incomplete' })).toBe(EXIT.ok)
1967
+ registerQuestion('incomplete', h.env, { question: 'Another?' })
1178
1968
  const pending = readSessionState('incomplete', h.env).pending
1179
1969
  expect(pending?.map((entry) => entry.question)).toEqual(['Original?', 'Another?'])
1180
1970
  expect(pending?.[0]).toMatchObject({
@@ -1186,11 +1976,10 @@ describe('ask registration', () => {
1186
1976
  it('refuses a fifth question and names the form alternative', () => {
1187
1977
  const h = harness()
1188
1978
  for (const question of ['One?', 'Two?', 'Three?', 'Four?']) {
1189
- expect(askCommand(h.deps, question, { session: 'crowded' })).toBe(EXIT.ok)
1979
+ registerQuestion('crowded', h.env, { question })
1190
1980
  }
1191
1981
 
1192
- expect(askCommand(h.deps, 'Five?', { session: 'crowded' })).toBe(EXIT.failed)
1193
- expect(h.io.errLines.join('\n')).toContain('ask --form')
1982
+ expect(() => registerQuestion('crowded', h.env, { question: 'Five?' })).toThrow(/ask --form/)
1194
1983
  expect(readSessionState('crowded', h.env).pending).toHaveLength(4)
1195
1984
  })
1196
1985
  })
@@ -1202,14 +1991,52 @@ describe('user-prompt-submit hook', () => {
1202
1991
  expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
1203
1992
  })
1204
1993
 
1205
- it('diagnoses malformed or truncated hook input instead of silently doing nothing', async () => {
1994
+ it('records Stop separately so a prompt hook cannot impersonate turn-end routing', async () => {
1206
1995
  const h = harness()
1996
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'events' }))
1997
+ expect(readSessionState('events', h.env).last_stop_at).toBeUndefined()
1207
1998
 
1208
- expect(await hookRunCommand(h.deps, 'user-prompt-submit', async () => '{"session_id":')).toBe(
1209
- EXIT.ok,
1210
- )
1999
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'events' }))
2000
+ expect(readSessionState('events', h.env).last_stop_at).toBe(NOW)
2001
+ })
2002
+
2003
+ it('diagnoses partial production input as malformed or truncated', async () => {
2004
+ const h = harness()
2005
+ const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
2006
+ const input = new PassThrough()
2007
+ input.write('{"session_id":')
2008
+
2009
+ expect(
2010
+ await hookRunCommand(
2011
+ deps,
2012
+ 'user-prompt-submit',
2013
+ () => readStdinWithTimeout(input, 5),
2014
+ ),
2015
+ ).toBe(EXIT.ok)
1211
2016
 
1212
2017
  expect(h.io.errLines.join('\n')).toMatch(/malformed|truncated/i)
2018
+ const records = readLogRecords(h.env, { limit: 10 }).records
2019
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
2020
+ expect(records.at(-1)?.data).toMatchObject({ reason: 'malformed-input' })
2021
+ })
2022
+
2023
+ it('records an empty production input timeout before failing open', async () => {
2024
+ const h = harness()
2025
+ const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
2026
+ const input = new PassThrough()
2027
+
2028
+ expect(
2029
+ await hookRunCommand(
2030
+ deps,
2031
+ 'user-prompt-submit',
2032
+ () => readStdinWithTimeout(input, 5),
2033
+ ),
2034
+ ).toBe(EXIT.ok)
2035
+
2036
+ const records = readLogRecords(h.env, { limit: 10 }).records
2037
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
2038
+ expect(records.at(-1)?.data).toMatchObject({ reason: 'input-read-failed' })
2039
+ expect(records.at(-1)?.data?.['message']).toMatch(/timed out waiting/)
1213
2040
  })
1214
2041
 
1215
2042
  it('explains when a missing machine credential makes a hook skip routing', async () => {
@@ -1224,29 +2051,21 @@ describe('user-prompt-submit hook', () => {
1224
2051
  })
1225
2052
 
1226
2053
  it('preserves a delivered question through a second ask and the prompt transition', async () => {
1227
- 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({
2054
+ const h = harness([])
2055
+ const live = {
1239
2056
  question: 'Ship it?',
1240
- request_id: 'req_hook_1',
2057
+ request_id: 'req_live',
2058
+ collapse_key: 'collapse-live',
1241
2059
  device_ids: ['dev_iphone', 'dev_mac'],
1242
- })
1243
- expect(live?.collapse_key).toMatch(/^notifai-hook-/)
2060
+ reply_deadline_at: NOW + 60_000,
2061
+ }
2062
+ writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
1244
2063
 
1245
2064
  // A second plain `ask` resolves through the project pointer and joins the
1246
2065
  // queue behind the live question — it must not touch it. The next prompt
1247
2066
  // then resets presence before another Stop can run — the transition that
1248
2067
  // used to erase the retirement debt.
1249
- expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
2068
+ registerQuestion('transition', h.env, { question: 'Deploy it?' })
1250
2069
  const queued = readSessionState('transition', h.env)
1251
2070
  expect(queued.retiring ?? []).toEqual([])
1252
2071
  expect(queued.pending?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
@@ -1258,16 +2077,17 @@ describe('user-prompt-submit hook', () => {
1258
2077
  'claude-code',
1259
2078
  )
1260
2079
 
1261
- // The delivered question retires truthfully; the never-delivered second
1262
- // one has nothing on any device and leaves no trace.
2080
+ // The delivered question retires truthfully. The never-delivered second
2081
+ // one must remain queued: if Codex skipped Stop, erasing it here loses the
2082
+ // only copy before it ever reached a device.
1263
2083
  const retirement = h.recorder.submitted.find(
1264
2084
  (submission) => submission.draft.event === 'question_retired',
1265
2085
  )?.draft
1266
- expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
2086
+ expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
1267
2087
  expect(retirement?.lifecycle).toEqual({
1268
2088
  tier: 'done',
1269
2089
  state: 'answered_elsewhere',
1270
- retires_request_id: live?.request_id,
2090
+ retires_request_id: live.request_id,
1271
2091
  })
1272
2092
  expect(retirement?.targets).toEqual({
1273
2093
  mode: 'selected',
@@ -1276,28 +2096,29 @@ describe('user-prompt-submit hook', () => {
1276
2096
  expect(
1277
2097
  h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
1278
2098
  ).toHaveLength(1)
2099
+ expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
2100
+ 'Deploy it?',
2101
+ ])
2102
+
2103
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
2104
+ expect(readSessionState('transition', h.env).pending).toBeUndefined()
1279
2105
  })
1280
2106
 
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.
2107
+ it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
1285
2108
  const h = harness([])
1286
2109
  writeSessionState('s11', h.env, { last_prompt_at: AWAY })
1287
2110
  registerQuestion('s11', h.env, { question: 'Which environment?' })
1288
2111
 
1289
2112
  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()
2113
+ expect(readSessionState('s11', h.env).pending).toBeUndefined()
2114
+ const retirementCount = h.recorder.submitted.filter(
2115
+ (entry) => entry.draft.event === 'question_retired',
2116
+ ).length
1293
2117
 
1294
2118
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
1295
2119
 
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()
2120
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2121
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired')).toHaveLength(retirementCount + 1)
1301
2122
  })
1302
2123
 
1303
2124
  it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
@@ -1305,11 +2126,16 @@ describe('user-prompt-submit hook', () => {
1305
2126
  // lifecycle update, which the server renders as a background push — the
1306
2127
  // old "Answered" tombstone alert told the user what they just did.
1307
2128
  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' })
2129
+ writeSessionState('s15', h.env, {
2130
+ last_prompt_at: AWAY,
2131
+ pending: [{
2132
+ question: 'Which environment?',
2133
+ request_id: 'req_live',
2134
+ collapse_key: 'collapse-live',
2135
+ device_ids: ['dev_iphone'],
2136
+ reply_deadline_at: NOW + 60_000,
2137
+ }],
2138
+ })
1313
2139
 
1314
2140
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
1315
2141
 
@@ -1319,21 +2145,26 @@ describe('user-prompt-submit hook', () => {
1319
2145
  state: 'answered_elsewhere',
1320
2146
  // The history-entry correlation id: companions key entries by request
1321
2147
  // id and never persisted the collapse key.
1322
- retires_request_id: 'req_hook_1',
2148
+ retires_request_id: 'req_live',
1323
2149
  })
1324
2150
  })
1325
2151
 
1326
2152
  it('retires as done/answered when the answer came from a device', async () => {
1327
- const h = harness([reply({ text: 'Yes' })], 900)
2153
+ const h = harness([reply({ text: 'Yes' })])
1328
2154
  registerQuestion('s16', h.env, { question: 'Ship it?' })
1329
2155
 
1330
2156
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
2157
+ await hookRunCommand(
2158
+ h.deps,
2159
+ 'stop',
2160
+ stdin({ session_id: 's16', stop_hook_active: true }),
2161
+ )
1331
2162
 
1332
2163
  const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
1333
2164
  expect(retirement?.draft.lifecycle).toEqual({
1334
2165
  tier: 'done',
1335
2166
  state: 'answered',
1336
- retires_request_id: 'req_hook_1',
2167
+ retires_request_id: h.recorder.receipts[0],
1337
2168
  })
1338
2169
  })
1339
2170
 
@@ -1364,19 +2195,101 @@ describe('user-prompt-submit hook', () => {
1364
2195
  })
1365
2196
 
1366
2197
  describe('session-end hook', () => {
1367
- it('drops local state without touching the network, inside its ~1s budget', async () => {
2198
+ it('drops local state without touching the network and records the lifecycle', async () => {
1368
2199
  const h = harness()
1369
2200
  writeSessionState('s12', h.env, { last_prompt_at: NOW })
2201
+ const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
1370
2202
 
1371
- const code = await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's12' }))
2203
+ const code = await hookRunCommand(deps, 'session-end', stdin({ session_id: 's12' }))
1372
2204
 
1373
2205
  expect(code).toBe(EXIT.ok)
1374
2206
  expect(readSessionState('s12', h.env)).toEqual({})
1375
2207
  expect(h.recorder.closed).toEqual([])
2208
+ const records = readLogRecords(h.env, { limit: 10 }).records
2209
+ expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
2210
+ expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
2211
+ })
2212
+
2213
+ it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
2214
+ const h = harness()
2215
+ const sessionId = 'session-end-budget'
2216
+ writeSessionState(sessionId, h.env, { last_prompt_at: NOW })
2217
+ writeProjectSession(h.deps.cwd, h.env, sessionId, Date.now(), 'codex')
2218
+ const configFile = sessionConfigPath(sessionId, h.env)
2219
+ mkdirSync(path.dirname(configFile), { recursive: true })
2220
+ writeFileSync(configFile, 'ask_notifications = false\nlog_level = "info"\n')
2221
+ expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toMatchObject({
2222
+ value: false,
2223
+ source: expect.stringMatching(/^session:/),
2224
+ })
2225
+
2226
+ const stateFile = path.join(
2227
+ stateDir(h.env),
2228
+ 'sessions',
2229
+ `${sanitizeSessionId(sessionId)}.json`,
2230
+ )
2231
+ const projectPointer = projectSessionPointerPath(h.deps.cwd, h.env)
2232
+ expect(existsSync(stateFile)).toBe(true)
2233
+ expect(existsSync(configFile)).toBe(true)
2234
+ expect(existsSync(projectPointer)).toBe(true)
2235
+
2236
+ const logLock = `${activeLogPath(h.env)}.lock`
2237
+ const holder = runSessionEndLogLockHolder(h.deps.cwd, h.env, logLock)
2238
+ let cli: ReturnType<typeof runSessionEndCli> | undefined
2239
+ try {
2240
+ await waitUntil(
2241
+ () => existsSync(holder.readyPath),
2242
+ 10_000,
2243
+ 'SessionEnd log lock worker did not acquire its bakery ticket',
2244
+ )
2245
+ expect(readdirSync(logLock).filter((name) => name.startsWith('ticket-'))).toHaveLength(1)
2246
+
2247
+ cli = runSessionEndCli(h.env, h.deps.cwd, sessionId)
2248
+ await waitUntil(
2249
+ () =>
2250
+ readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
2251
+ 700,
2252
+ 'the real Codex hook did not reach contended diagnostics before its deadline',
2253
+ )
2254
+
2255
+ // Reaching the second bakery ticket proves main.ts preAction did not write
2256
+ // first. Every durable cleanup must already be visible while diagnostics
2257
+ // are still blocked behind the live holder.
2258
+ expect(existsSync(stateFile)).toBe(false)
2259
+ expect(existsSync(configFile)).toBe(false)
2260
+ expect(existsSync(projectPointer)).toBe(false)
2261
+
2262
+ const result = await cli.done
2263
+ expect(result).toMatchObject({ code: EXIT.ok, signal: null, stdout: '', stderr: '' })
2264
+ expect(result.durationMs).toBeGreaterThanOrEqual(900)
2265
+ expect(result.durationMs).toBeLessThan(2_000)
2266
+ // The diagnostic contender timed out rather than stealing the held lock,
2267
+ // disabled its sink, and still let the real CLI exit successfully.
2268
+ expect(existsSync(activeLogPath(h.env))).toBe(false)
2269
+
2270
+ writeFileSync(holder.releasePath, 'release')
2271
+ await holder.done
2272
+
2273
+ expect(readSessionState(sessionId, h.env)).toEqual({})
2274
+ expect(readProjectSession(h.deps.cwd, h.env, Date.now())).toBeNull()
2275
+ expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toEqual({
2276
+ value: true,
2277
+ source: 'default',
2278
+ })
2279
+ } finally {
2280
+ if (!existsSync(holder.releasePath)) writeFileSync(holder.releasePath, 'release')
2281
+ if (cli?.child.exitCode === null && cli.child.signalCode === null) cli.child.kill('SIGKILL')
2282
+ if (holder.child.exitCode === null && holder.child.signalCode === null) {
2283
+ holder.child.kill('SIGKILL')
2284
+ }
2285
+ rmSync(h.deps.cwd, { recursive: true, force: true })
2286
+ }
1376
2287
  })
1377
2288
 
1378
- it('clears only the ending session project pointer', async () => {
2289
+ it('clears only the ending session from a concurrent project index', async () => {
1379
2290
  const h = harness()
2291
+ writeSessionState('still-running', h.env, { last_prompt_at: NOW })
2292
+ writeProjectSession(h.deps.cwd, h.env, 'still-running', NOW - 1, 'codex')
1380
2293
  await hookRunCommand(
1381
2294
  h.deps,
1382
2295
  'user-prompt-submit',
@@ -1384,6 +2297,15 @@ describe('session-end hook', () => {
1384
2297
  'claude-code',
1385
2298
  )
1386
2299
  expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('ending')
2300
+ expect(
2301
+ readMatchingProjectSessionPointer(
2302
+ h.deps.cwd,
2303
+ h.env,
2304
+ NOW,
2305
+ 'still-running',
2306
+ 'codex',
2307
+ ),
2308
+ ).toEqual({ sessionId: 'still-running', harness: 'codex' })
1387
2309
 
1388
2310
  await hookRunCommand(
1389
2311
  h.deps,
@@ -1392,7 +2314,16 @@ describe('session-end hook', () => {
1392
2314
  'claude-code',
1393
2315
  )
1394
2316
 
1395
- expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBeNull()
2317
+ expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('still-running')
2318
+ expect(
2319
+ readMatchingProjectSessionPointer(
2320
+ h.deps.cwd,
2321
+ h.env,
2322
+ NOW,
2323
+ 'still-running',
2324
+ 'codex',
2325
+ ),
2326
+ ).toEqual({ sessionId: 'still-running', harness: 'codex' })
1396
2327
  })
1397
2328
 
1398
2329
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
@@ -1430,7 +2361,7 @@ describe('telling concurrent agents apart', () => {
1430
2361
  // The hook has always known session_id and never passed it on, so two
1431
2362
  // agents in separate worktrees produced identical notifications and the
1432
2363
  // user could answer the wrong one's question.
1433
- const h = harness([], 900)
2364
+ const h = harness([])
1434
2365
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
1435
2366
 
1436
2367
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
@@ -1439,17 +2370,22 @@ describe('telling concurrent agents apart', () => {
1439
2370
  })
1440
2371
 
1441
2372
  it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
1442
- const h = harness([reply({ text: 'Yes' })], 900)
2373
+ const h = harness([reply({ text: 'Yes' })])
1443
2374
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
1444
2375
 
1445
2376
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2377
+ await hookRunCommand(
2378
+ h.deps,
2379
+ 'stop',
2380
+ stdin({ session_id: 'sess-abc', stop_hook_active: true }),
2381
+ )
1446
2382
 
1447
2383
  const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
1448
2384
  expect(retirement?.draft.session).toBe('sess-abc')
1449
2385
  })
1450
2386
 
1451
2387
  it('prefers a name the user chose over the harness UUID', async () => {
1452
- const h = harness([], 900)
2388
+ const h = harness([])
1453
2389
  h.env['NOTIFAI_SESSION'] = 'migration-worktree'
1454
2390
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
1455
2391
 
@@ -1460,44 +2396,19 @@ describe('telling concurrent agents apart', () => {
1460
2396
  })
1461
2397
 
1462
2398
  describe('clock jumps', () => {
1463
- const config = loadConfig({ cwd: '/nowhere', env: {} })
1464
-
1465
- it('does not hijack a terminal because the clock jumped forward', () => {
1466
- // NTP correction or a VM resume moves `now` without any time passing for
1467
- // the person sitting at the keyboard. Without an idle source there is
1468
- // nothing to check the delta against, so a huge one is not evidence.
1469
- const state = { last_prompt_at: NOW - 400 * 24 * 3600 * 1000 }
1470
- expect(isUserAway(state, config, NOW, null)).toBe(false)
1471
- })
1472
-
1473
- it('does not read a backward jump as the user being present either', () => {
1474
- // A negative delta is nonsense, not freshness. It resolves the same way:
1475
- // no evidence, so leave the terminal alone.
1476
- expect(isUserAway({ last_prompt_at: NOW + 60_000 }, config, NOW, null)).toBe(false)
1477
- })
1478
-
1479
- it('still escalates on an ordinary long silence', () => {
1480
- expect(isUserAway({ last_prompt_at: NOW - 3600_000 }, config, NOW, null)).toBe(true)
1481
- })
1482
-
1483
- it('lets the OS idle signal decide regardless of the wall clock', () => {
1484
- // The idle probe measures elapsed time directly, so it is unaffected — and
1485
- // it outranks the proxy anyway.
1486
- const nonsense = { last_prompt_at: NOW + 999_999_999 }
1487
- expect(isUserAway(nonsense, config, NOW, 900)).toBe(true)
1488
- expect(isUserAway(nonsense, config, NOW, 1)).toBe(false)
1489
- })
1490
-
1491
- it('does not let a stamp from the future hold the terminal past the budget', async () => {
1492
- // asked_at is wall-clock too. A future stamp used to make the grace window
1493
- // unreachable, blocking Stop until the harness killed it.
1494
- const h = harness([], 900)
2399
+ it('does not let a stamp from the future consume the whole waiter ceiling', async () => {
2400
+ // `asked_at` is wall-clock, and an NTP correction or VM resume can move it.
2401
+ // A stamp thirty days out would otherwise make the grace window unreachable
2402
+ // and leave nothing of the ceiling for an answer to arrive in.
2403
+ const h = harness([])
2404
+ writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
1495
2405
  registerQuestion('sess-jump', h.env, { question: 'Ship it?' }, NOW + 30 * 24 * 3600 * 1000)
1496
2406
 
1497
2407
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-jump' }))
1498
2408
 
1499
- // It escalated rather than spinning: the window restarted from now.
1500
2409
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
2410
+ // The window ran from now, not from the impossible stamp.
2411
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
1501
2412
  })
1502
2413
  })
1503
2414
 
@@ -1694,13 +2605,33 @@ describe('two hooks racing one question', () => {
1694
2605
  // A crashed hook must not suppress every question for this session for
1695
2606
  // ever — that is worse than the duplicate the claim prevents.
1696
2607
  const h = harness()
1697
- claimQuestionPush('race3', h.env, REAL - 10 * 60_000)
2608
+ const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId('race3')}.claim`)
2609
+ mkdirSync(path.dirname(file), { recursive: true })
2610
+ writeFileSync(
2611
+ file,
2612
+ `${JSON.stringify({ pid: 999_999_999, at: REAL - 1, token: 'dead' })}\n`,
2613
+ )
1698
2614
  expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
1699
2615
  })
1700
2616
 
2617
+ it('never steals an old claim while its process is still alive', () => {
2618
+ const h = harness()
2619
+ expect(claimQuestionPush('race-live', h.env, REAL - 10 * 60_000)).toBe(true)
2620
+ expect(claimQuestionPush('race-live', h.env, REAL)).toBe(false)
2621
+ })
2622
+
1701
2623
  it('serializes a contender that arrives while a stale claim is being replaced', () => {
1702
2624
  const h = harness()
1703
- claimQuestionPush('race-break', h.env, REAL - 10 * 60_000)
2625
+ const file = path.join(
2626
+ stateDir(h.env),
2627
+ 'sessions',
2628
+ `${sanitizeSessionId('race-break')}.claim`,
2629
+ )
2630
+ mkdirSync(path.dirname(file), { recursive: true })
2631
+ writeFileSync(
2632
+ file,
2633
+ `${JSON.stringify({ pid: 999_999_999, at: REAL - 10 * 60_000, token: 'dead' })}\n`,
2634
+ )
1704
2635
  let contender: ReturnType<typeof claimQuestionPush> | undefined
1705
2636
 
1706
2637
  const recovered = claimQuestionPush('race-break', h.env, REAL, () => {
@@ -1718,7 +2649,7 @@ describe('two hooks racing one question', () => {
1718
2649
  })
1719
2650
 
1720
2651
  it('sends one notification when a second Stop arrives mid-flight', async () => {
1721
- const h = harness([], 900)
2652
+ const h = harness([])
1722
2653
  writeSessionState('race5', h.env, { last_prompt_at: AWAY })
1723
2654
  registerQuestion('race5', h.env, { question: 'Ship it?' })
1724
2655
  // Standing in for the other process: the claim is already held.
@@ -1729,11 +2660,57 @@ describe('two hooks racing one question', () => {
1729
2660
  expect(h.recorder.submitted).toHaveLength(0)
1730
2661
  expect(h.io.errLines.join(" ")).toContain('already handling')
1731
2662
  })
2663
+
2664
+ it('resumes the agent once when two Stops collect the same late answer', async () => {
2665
+ const answers: ReplyView[] = []
2666
+ const h = harness(answers)
2667
+ writeSessionState('race-late-answer', h.env, {
2668
+ last_prompt_at: AWAY,
2669
+ pending: [
2670
+ {
2671
+ question: 'Ship it?',
2672
+ asked_at: NOW,
2673
+ request_id: 'req_existing',
2674
+ collapse_key: 'question-existing',
2675
+ device_ids: ['dev_iphone'],
2676
+ reply_deadline_at: NOW + 60_000,
2677
+ },
2678
+ ],
2679
+ })
2680
+ answers.push(reply({ text: 'Ship it' }))
2681
+ h.io.outLines = []
2682
+
2683
+ let polls = 0
2684
+ let markFirstPoll: (() => void) | undefined
2685
+ const firstPoll = new Promise<void>((resolve) => {
2686
+ markFirstPoll = resolve
2687
+ })
2688
+ let releasePolls: (() => void) | undefined
2689
+ const heldPoll = new Promise<void>((resolve) => {
2690
+ releasePolls = resolve
2691
+ })
2692
+ h.recorder.beforeReplies = async () => {
2693
+ polls += 1
2694
+ markFirstPoll?.()
2695
+ await heldPoll
2696
+ }
2697
+
2698
+ const firstStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
2699
+ await firstPoll
2700
+ const secondStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
2701
+ await Promise.race([secondStop, new Promise<void>((resolve) => setImmediate(resolve))])
2702
+ releasePolls?.()
2703
+ await Promise.all([firstStop, secondStop])
2704
+
2705
+ const resumes = h.io.outLines.map((line) => JSON.parse(line) as { decision?: string })
2706
+ expect(polls).toBe(1)
2707
+ expect(resumes.filter((entry) => entry.decision === 'block')).toHaveLength(1)
2708
+ })
1732
2709
  })
1733
2710
 
1734
2711
  describe('question registration racing a Stop submission', () => {
1735
- it('keeps both questions when a new ask races the older submit', async () => {
1736
- const h = harness([], 900)
2712
+ it('keeps the new question when the older owner expires', async () => {
2713
+ const h = harness([])
1737
2714
  writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
1738
2715
  registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
1739
2716
  h.recorder.beforeQuestionSubmit = () => {
@@ -1743,20 +2720,20 @@ describe('question registration racing a Stop submission', () => {
1743
2720
 
1744
2721
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
1745
2722
 
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.
2723
+ // The racing ask appended and remains unasked. The already-delivered old
2724
+ // question is retired when this Stop owner reaches its deadline, so it
2725
+ // cannot accept a phone answer after the hook has returned.
1748
2726
  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([])
2727
+ expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
2728
+ expect(state.pending?.[0]?.request_id).toBeUndefined()
2729
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2730
+ expect(state.retiring).toContainEqual(
2731
+ expect.objectContaining({ request_id: h.recorder.receipts[0], state: 'expired' }),
2732
+ )
1756
2733
  })
1757
2734
 
1758
2735
  it('keeps the newer question when the older in-flight question receives an answer', async () => {
1759
- const h = harness([reply({ text: 'Old answer' })], 900)
2736
+ const h = harness([reply({ text: 'Old answer' })])
1760
2737
  writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
1761
2738
  registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
1762
2739
  h.recorder.beforeQuestionSubmit = () => {
@@ -1768,12 +2745,603 @@ describe('question registration racing a Stop submission', () => {
1768
2745
 
1769
2746
  const state = readSessionState('answer-race', h.env)
1770
2747
  expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
2748
+ expect(state.accepted?.answers[0]?.pending.request_id).toBe(h.recorder.receipts[0])
2749
+ expect(state.accepted?.answers[0]?.reply.text).toBe('Old answer')
2750
+ })
2751
+ })
2752
+
2753
+ describe('Claude Code Stop wake route', () => {
2754
+ function claudeWake(status: 'idle' | 'busy' = 'idle'): ClaudeWakeAdapters & {
2755
+ sent: string[]
2756
+ resumed: string[]
2757
+ sleeps: number[]
2758
+ } {
2759
+ const sent: string[] = []
2760
+ const resumed: string[] = []
2761
+ const sleeps: number[] = []
2762
+ return {
2763
+ sent,
2764
+ resumed,
2765
+ sleeps,
2766
+ async listAgents() {
2767
+ return [
2768
+ {
2769
+ pid: 12345,
2770
+ sessionId: 'claude-route',
2771
+ startedAt: NOW,
2772
+ status,
2773
+ },
2774
+ ]
2775
+ },
2776
+ readDescriptor() {
2777
+ return {
2778
+ pid: 12345,
2779
+ sessionId: 'claude-route',
2780
+ cwd: '/tmp/claude-route',
2781
+ startedAt: NOW,
2782
+ procStart: 'Wed Aug 12 08:17:53 2026',
2783
+ version: '2.1.228',
2784
+ peerProtocol: 1,
2785
+ messagingSocketPath: '/tmp/cc-socks/12345.sock',
2786
+ status,
2787
+ }
2788
+ },
2789
+ async sendSocket(_socketPath, line) {
2790
+ sent.push(line)
2791
+ },
2792
+ async resume(_sessionId, _cwd, context) {
2793
+ resumed.push(context)
2794
+ },
2795
+ async sleep(milliseconds) {
2796
+ sleeps.push(milliseconds)
2797
+ },
2798
+ }
2799
+ }
2800
+
2801
+ it('routes an accepted answer through the Claude inbox instead of hook stdout', async () => {
2802
+ const h = harness([reply({ text: 'BETA' })])
2803
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
2804
+ writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
2805
+ const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
2806
+ expect(built.ok).toBe(true)
2807
+ if (!built.ok) return
2808
+ registerQuestion('claude-route', h.env, {
2809
+ question: 'Which rollout option?',
2810
+ questions: built.questions,
2811
+ })
2812
+ const wake = claudeWake()
2813
+
2814
+ await hookRunCommand(
2815
+ { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
2816
+ 'stop',
2817
+ stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
2818
+ 'claude-code',
2819
+ )
2820
+
2821
+ expect(h.io.outLines).toEqual([])
2822
+ expect(wake.sent).toHaveLength(1)
2823
+ const message = JSON.parse(wake.sent[0]!) as {
2824
+ type: string
2825
+ message: { role: string; content: string }
2826
+ }
2827
+ expect(message).toMatchObject({ type: 'user', message: { role: 'user' } })
2828
+ expect(message.message.content).toContain('question_id')
2829
+ expect(message.message.content).toContain('"Which rollout option?"')
2830
+ expect(message.message.content).toContain('"BETA"')
2831
+ expect(message.message.content).not.toMatch(/trusted|urgent|permission|approval/i)
2832
+ expect(wake.sleeps).toEqual([CLAUDE_POST_SEND_LIVENESS_MS])
2833
+ expect(readSessionState('claude-route', h.env).accepted).toBeDefined()
2834
+ })
2835
+
2836
+ /** One answer already accepted and journaled, ready for the next Stop. */
2837
+ function journaledAnswer(
2838
+ h: Harness,
2839
+ options: { recordedAt?: number; deliveredAt?: number } = {},
2840
+ ): void {
2841
+ const recordedAt = options.recordedAt ?? NOW
2842
+ writeSessionState('claude-route', h.env, {
2843
+ ...readSessionState('claude-route', h.env),
2844
+ accepted: {
2845
+ ...(options.deliveredAt === undefined
2846
+ ? {}
2847
+ : { delivered_at: options.deliveredAt, delivered_route: 'inbox-socket' }),
2848
+ answers: [
2849
+ {
2850
+ pending: {
2851
+ question: 'Which rollout option?',
2852
+ request_id: 'req_existing',
2853
+ collapse_key: 'question-existing',
2854
+ device_ids: ['dev_iphone'],
2855
+ },
2856
+ reply: reply({ text: 'BETA' }),
2857
+ replies: [reply({ text: 'BETA' })],
2858
+ },
2859
+ ],
2860
+ remaining: 0,
2861
+ recorded_at: recordedAt,
2862
+ },
2863
+ })
2864
+ }
2865
+
2866
+ it('settles a socket delivery so the woken turn cannot redeliver the answer', async () => {
2867
+ // The exact device-proof failure. A socket delivery starts a brand-new turn
2868
+ // rather than continuing this one, so the Stop that ends the woken turn
2869
+ // reports stop_hook_active=false — for ever. While that flag was the only
2870
+ // acknowledgement, the journal never settled and every turn-end delivered
2871
+ // the same answer again: 250 times, in the transcript that found this.
2872
+ const h = harness([])
2873
+ journaledAnswer(h)
2874
+ const wake = claudeWake()
2875
+ const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
2876
+ const stop = (stopHookActive: boolean) =>
2877
+ hookRunCommand(
2878
+ deps,
2879
+ 'stop',
2880
+ stdin({
2881
+ session_id: 'claude-route',
2882
+ cwd: '/tmp/claude-route',
2883
+ stop_hook_active: stopHookActive,
2884
+ }),
2885
+ 'claude-code',
2886
+ )
2887
+
2888
+ await stop(false)
2889
+
2890
+ expect(wake.sent).toHaveLength(1)
2891
+ const delivered = readSessionState('claude-route', h.env).accepted
2892
+ expect(delivered?.delivered_route).toBe('inbox-socket')
2893
+ expect(typeof delivered?.delivered_at).toBe('number')
2894
+
2895
+ // Three more turn-ends of the woken session, none of them a continuation.
2896
+ await stop(false)
2897
+ await stop(false)
2898
+ await stop(false)
2899
+
2900
+ expect(wake.sent).toHaveLength(1)
2901
+ expect(wake.resumed).toEqual([])
2902
+ expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
2903
+ // Settled, not merely dropped: the answered question is retired truthfully.
1771
2904
  expect(
1772
2905
  h.recorder.submitted.find(
1773
2906
  (entry) =>
1774
2907
  entry.draft.event === 'question_retired' &&
1775
- entry.draft.lifecycle?.retires_request_id === 'req_hook_1',
2908
+ entry.draft.lifecycle?.retires_request_id === 'req_existing',
1776
2909
  )?.draft.lifecycle?.state,
1777
2910
  ).toBe('answered')
1778
2911
  })
2912
+
2913
+ it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
2914
+ // The chained loop the cap exists for, on a route that starts new turns.
2915
+ // Keyed to stop_hook_active the cap could never fire here at all.
2916
+ const h = harness([])
2917
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
2918
+ writeSessionState('claude-route', h.env, {
2919
+ last_prompt_at: AWAY,
2920
+ continuation: { answered_at: NOW - 2000, count: MAX_CONTINUATION_COUNT - 1 },
2921
+ })
2922
+ journaledAnswer(h, { recordedAt: NOW - 1000, deliveredAt: NOW - 900 })
2923
+ // The woken turn asked a genuinely new question before it ended.
2924
+ registerQuestion('claude-route', h.env, { question: 'And after that?' }, NOW)
2925
+ const wake = claudeWake()
2926
+
2927
+ await hookRunCommand(
2928
+ { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
2929
+ 'stop',
2930
+ stdin({
2931
+ session_id: 'claude-route',
2932
+ cwd: '/tmp/claude-route',
2933
+ stop_hook_active: false,
2934
+ }),
2935
+ 'claude-code',
2936
+ )
2937
+
2938
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toEqual(
2939
+ [],
2940
+ )
2941
+ expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')
2942
+ expect(h.io.errLines.join('\n')).toContain(
2943
+ `continuation limit (${MAX_CONTINUATION_COUNT}) reached`,
2944
+ )
2945
+ })
2946
+
2947
+ it('uses the accepted journal as the loop guard after a socket wake fires Stop again', async () => {
2948
+ const h = harness([])
2949
+ writeSessionState('claude-route', h.env, {
2950
+ accepted: {
2951
+ answers: [
2952
+ {
2953
+ pending: {
2954
+ question: 'Which rollout option?',
2955
+ request_id: 'req_existing',
2956
+ collapse_key: 'question-existing',
2957
+ device_ids: ['dev_iphone'],
2958
+ },
2959
+ reply: reply({ text: 'BETA' }),
2960
+ replies: [reply({ text: 'BETA' })],
2961
+ },
2962
+ ],
2963
+ remaining: 0,
2964
+ recorded_at: NOW,
2965
+ },
2966
+ })
2967
+ const wake = claudeWake()
2968
+
2969
+ await hookRunCommand(
2970
+ { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
2971
+ 'stop',
2972
+ stdin({
2973
+ session_id: 'claude-route',
2974
+ cwd: '/tmp/claude-route',
2975
+ stop_hook_active: true,
2976
+ }),
2977
+ 'claude-code',
2978
+ )
2979
+
2980
+ expect(wake.sent).toEqual([])
2981
+ expect(wake.resumed).toEqual([])
2982
+ expect(h.io.outLines).toEqual([])
2983
+ expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
2984
+ })
2985
+ })
2986
+
2987
+ describe('Codex Stop wake route', () => {
2988
+ const CODEX_THREAD = '019ff69d-a07f-7161-ab6e-bd06b3b93c8e'
2989
+
2990
+ function codexWake(
2991
+ options: { sourceAlive?: boolean; probe?: CodexWakeObservation } = {},
2992
+ ): CodexWakeAdapters & { resumed: string[]; probed: string[] } {
2993
+ const resumed: string[] = []
2994
+ const probed: string[] = []
2995
+ return {
2996
+ resumed,
2997
+ probed,
2998
+ probeThreadWriter(lockPath) {
2999
+ probed.push(lockPath)
3000
+ return options.probe ?? { state: 'stopped' }
3001
+ },
3002
+ sourceAlive() {
3003
+ return options.sourceAlive ?? true
3004
+ },
3005
+ async resume(_threadId, _cwd, context) {
3006
+ resumed.push(context)
3007
+ },
3008
+ }
3009
+ }
3010
+
3011
+ function journaledAnswer(h: Harness): void {
3012
+ writeSessionState(CODEX_THREAD, h.env, {
3013
+ accepted: {
3014
+ answers: [
3015
+ {
3016
+ pending: {
3017
+ question: 'Which rollout option?',
3018
+ request_id: 'req_existing',
3019
+ collapse_key: 'question-existing',
3020
+ device_ids: ['dev_iphone'],
3021
+ },
3022
+ reply: reply({ text: 'BETA' }),
3023
+ replies: [reply({ text: 'BETA' })],
3024
+ },
3025
+ ],
3026
+ remaining: 0,
3027
+ recorded_at: NOW,
3028
+ },
3029
+ })
3030
+ }
3031
+
3032
+ it('continues the held turn with decision:block when the answer arrives during the hold', async () => {
3033
+ const h = harness([reply({ text: 'BETA' })])
3034
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
3035
+ writeSessionState(CODEX_THREAD, h.env, { last_prompt_at: AWAY })
3036
+ const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
3037
+ expect(built.ok).toBe(true)
3038
+ if (!built.ok) return
3039
+ registerQuestion(CODEX_THREAD, h.env, {
3040
+ question: 'Which rollout option?',
3041
+ questions: built.questions,
3042
+ })
3043
+ const wake = codexWake()
3044
+
3045
+ await hookRunCommand(
3046
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3047
+ 'stop',
3048
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3049
+ 'codex',
3050
+ )
3051
+
3052
+ expect(h.io.outLines).toHaveLength(1)
3053
+ const decision = JSON.parse(h.io.outLines[0]!) as { decision: string; reason: string }
3054
+ expect(decision.decision).toBe('block')
3055
+ expect(decision.reason).toContain('"BETA"')
3056
+ // The default route owes the thread-writer lock nothing: a live Codex is
3057
+ // reading this stdout, and a probe could only take the answer away.
3058
+ expect(wake.probed).toEqual([])
3059
+ expect(wake.resumed).toEqual([])
3060
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
3061
+ })
3062
+
3063
+ it('replays an answer journaled after the hold on the next Stop', async () => {
3064
+ const h = harness([])
3065
+ journaledAnswer(h)
3066
+ const wake = codexWake()
3067
+
3068
+ await hookRunCommand(
3069
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3070
+ 'stop',
3071
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3072
+ 'codex',
3073
+ )
3074
+
3075
+ expect(h.io.outLines).toHaveLength(1)
3076
+ expect(JSON.parse(h.io.outLines[0]!)).toMatchObject({ decision: 'block' })
3077
+ // Still journaled: only a successor Stop proves the continued turn ran.
3078
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
3079
+ })
3080
+
3081
+ it('cold-resumes the journaled answer when the Codex process is gone and no writer holds the thread', async () => {
3082
+ const h = harness([])
3083
+ journaledAnswer(h)
3084
+ const wake = codexWake({ sourceAlive: false, probe: { state: 'stopped' } })
3085
+
3086
+ await hookRunCommand(
3087
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3088
+ 'stop',
3089
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3090
+ 'codex',
3091
+ )
3092
+
3093
+ expect(h.io.outLines).toEqual([])
3094
+ expect(wake.resumed).toHaveLength(1)
3095
+ expect(wake.resumed[0]).toContain('"BETA"')
3096
+ expect(wake.probed).toHaveLength(2)
3097
+ })
3098
+
3099
+ it('holds the answer rather than resuming a thread a live writer owns', async () => {
3100
+ const h = harness([])
3101
+ journaledAnswer(h)
3102
+ const wake = codexWake({ sourceAlive: false, probe: { state: 'live' } })
3103
+
3104
+ await hookRunCommand(
3105
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3106
+ 'stop',
3107
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3108
+ 'codex',
3109
+ )
3110
+
3111
+ expect(h.io.outLines).toEqual([])
3112
+ expect(wake.resumed).toEqual([])
3113
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
3114
+ expect(h.io.errLines.join('\n')).toContain('holding the accepted answer for the next turn')
3115
+ })
3116
+ })
3117
+
3118
+ describe('escalation waiter delivery seam', () => {
3119
+ function waiterContext(h: Harness) {
3120
+ const client = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer test')
3121
+ return {
3122
+ client,
3123
+ config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
3124
+ env: h.env,
3125
+ now: h.deps.now!,
3126
+ sleep: h.deps.sleep!,
3127
+ waitForFirstReply: async (requestId: string, timeoutSeconds: number) => {
3128
+ const response = await client.replies(requestId, {
3129
+ waitSeconds: timeoutSeconds,
3130
+ afterSeq: 0,
3131
+ })
3132
+ return { replies: response.replies, timedOut: response.replies.length === 0 }
3133
+ },
3134
+ harness: 'codex' as const,
3135
+ }
3136
+ }
3137
+
3138
+ it('waits through grace, routes the fenced answer, and holds the claim until delivery settles', async () => {
3139
+ const h = harness([reply({ text: 'Ship it' })])
3140
+ writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
3141
+ writeSessionState('waiter-route', h.env, { last_prompt_at: AWAY })
3142
+ registerQuestion('waiter-route', h.env, { question: 'Ship it?' }, NOW)
3143
+
3144
+ const context = waiterContext(h)
3145
+ const deliveries: Array<{ route: string; context: string }> = []
3146
+ let claimHeldDuringDelivery = false
3147
+ const deliver = vi.fn(async (event: { context: string }) => {
3148
+ deliveries.push({ route: 'hook-continuation', context: event.context })
3149
+ claimHeldDuringDelivery = !claimQuestionPush('waiter-route', h.env)
3150
+ return { stdout: JSON.stringify({ decision: 'block', reason: event.context }) }
3151
+ })
3152
+
3153
+ const outcome = await runEscalationWaiter(context, {
3154
+ sessionId: 'waiter-route',
3155
+ envelope: { session_id: 'waiter-route' },
3156
+ route: { kind: 'hook-continuation', deliver },
3157
+ processDeadlineAt: NOW + 480_000,
3158
+ })
3159
+
3160
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
3161
+ expect(deliver).toHaveBeenCalledTimes(1)
3162
+ expect(deliver).toHaveBeenCalledWith(
3163
+ expect.objectContaining({ journal_recorded_at: expect.any(Number) }),
3164
+ )
3165
+ expect(deliveries).toEqual([
3166
+ { route: 'hook-continuation', context: expect.stringContaining('Ship it') },
3167
+ ])
3168
+ expect(claimHeldDuringDelivery).toBe(true)
3169
+ expect(JSON.parse(outcome.stdout ?? '{}')).toMatchObject({
3170
+ decision: 'block',
3171
+ reason: expect.stringContaining('Ship it'),
3172
+ })
3173
+ expect(claimQuestionPush('waiter-route', h.env)).toBe(true)
3174
+ releaseQuestionPush('waiter-route', h.env)
3175
+ })
3176
+
3177
+ it('races a second Stop while grace, polling, fencing, and route delivery are still owned', async () => {
3178
+ const h = harness([])
3179
+ writeGlobalConfig(h, 'ask_grace_seconds = 10\n')
3180
+ writeSessionState('waiter-race-lifetime', h.env, { last_prompt_at: AWAY })
3181
+ registerQuestion('waiter-race-lifetime', h.env, { question: 'Ship it?' }, NOW)
3182
+ const context = waiterContext(h)
3183
+ const phases: string[] = []
3184
+
3185
+ let waitingForReply = false
3186
+ context.sleep = async (milliseconds: number) => {
3187
+ if (!waitingForReply) {
3188
+ phases.push('grace')
3189
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3190
+ }
3191
+ h.advanceClock(milliseconds)
3192
+ }
3193
+ context.waitForFirstReply = async () => {
3194
+ waitingForReply = true
3195
+ phases.push('reply-wait')
3196
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3197
+ return { replies: [reply({ text: 'Ship it' })], timedOut: false }
3198
+ }
3199
+ const closeReplies = context.client.closeReplies.bind(context.client)
3200
+ context.client.closeReplies = async (requestId) => {
3201
+ phases.push('close-fence')
3202
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3203
+ return closeReplies(requestId)
3204
+ }
3205
+
3206
+ await runEscalationWaiter(context, {
3207
+ sessionId: 'waiter-race-lifetime',
3208
+ envelope: { session_id: 'waiter-race-lifetime' },
3209
+ route: {
3210
+ kind: 'hook-continuation',
3211
+ deliver: async (event) => {
3212
+ phases.push('delivery')
3213
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3214
+ return { stdout: JSON.stringify({ decision: 'block', reason: event.context }), notes: [] }
3215
+ },
3216
+ },
3217
+ processDeadlineAt: NOW + 480_000,
3218
+ })
3219
+
3220
+ expect(phases.filter((phase) => phase === 'grace').length).toBeGreaterThan(0)
3221
+ expect(phases.filter((phase) => phase !== 'grace')).toEqual([
3222
+ 'reply-wait',
3223
+ 'close-fence',
3224
+ 'delivery',
3225
+ ])
3226
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(true)
3227
+ releaseQuestionPush('waiter-race-lifetime', h.env)
3228
+ })
3229
+
3230
+ /** One accepted answer on the journal, whatever route is about to own it. */
3231
+ function journaled(h: Harness, sessionId: string): void {
3232
+ writeSessionState(sessionId, h.env, {
3233
+ accepted: {
3234
+ answers: [
3235
+ {
3236
+ pending: {
3237
+ question: 'Ship it?',
3238
+ request_id: 'req_existing',
3239
+ collapse_key: 'question-existing',
3240
+ device_ids: ['dev_iphone'],
3241
+ },
3242
+ reply: reply({ text: 'Ship it' }),
3243
+ replies: [reply({ text: 'Ship it' })],
3244
+ },
3245
+ ],
3246
+ remaining: 0,
3247
+ recorded_at: NOW,
3248
+ },
3249
+ })
3250
+ }
3251
+
3252
+ it('delivers an accepted answer once on a route that acknowledges its own write', async () => {
3253
+ const h = harness([])
3254
+ journaled(h, 'waiter-once')
3255
+ const context = waiterContext(h)
3256
+ const deliveries: string[] = []
3257
+ const route = {
3258
+ kind: 'inbox-socket' as const,
3259
+ deliver: async (event: { context: string }) => {
3260
+ deliveries.push(event.context)
3261
+ return {
3262
+ notes: ['posted the accepted answer'],
3263
+ log: { route: 'inbox-socket', stage: 'delivered' },
3264
+ acknowledgement: 'delivered' as const,
3265
+ }
3266
+ },
3267
+ }
3268
+
3269
+ // Five turn-ends, none of them a harness continuation — the shape of every
3270
+ // route that wakes a new turn instead of continuing this one.
3271
+ for (let pass = 0; pass < 5; pass += 1) {
3272
+ await runEscalationWaiter(context, {
3273
+ sessionId: 'waiter-once',
3274
+ envelope: { session_id: 'waiter-once', stop_hook_active: false },
3275
+ route,
3276
+ processDeadlineAt: NOW + 480_000,
3277
+ })
3278
+ }
3279
+
3280
+ expect(deliveries).toHaveLength(1)
3281
+ expect(deliveries[0]).toContain('Ship it')
3282
+ expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
3283
+ })
3284
+
3285
+ it('bounds deliveries by the continuation cap on every route', async () => {
3286
+ // A route that never acknowledges must still stop. The cap lives where all
3287
+ // routes pass, so no future route can be the one it does not cover.
3288
+ for (const kind of ['inbox-socket', 'hook-continuation'] as const) {
3289
+ const h = harness([])
3290
+ const sessionId = `waiter-cap-${kind}`
3291
+ journaled(h, sessionId)
3292
+ const context = waiterContext(h)
3293
+ let deliveries = 0
3294
+ const notes: string[] = []
3295
+
3296
+ for (let pass = 0; pass < MAX_CONTINUATION_COUNT + 3; pass += 1) {
3297
+ const outcome = await runEscalationWaiter(context, {
3298
+ sessionId,
3299
+ envelope: { session_id: sessionId, stop_hook_active: false },
3300
+ route: {
3301
+ kind,
3302
+ deliver: async () => {
3303
+ deliveries += 1
3304
+ return { notes: [], acknowledgement: 'held' as const }
3305
+ },
3306
+ },
3307
+ processDeadlineAt: NOW + 480_000,
3308
+ })
3309
+ notes.push(...outcome.notes)
3310
+ }
3311
+
3312
+ expect(deliveries).toBe(MAX_CONTINUATION_COUNT)
3313
+ expect(notes.join('\n')).toContain(
3314
+ `${MAX_CONTINUATION_COUNT} times without being acknowledged`,
3315
+ )
3316
+ expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
3317
+ }
3318
+ })
3319
+
3320
+ it('keeps the accepted journal when an out-of-band route rejects delivery', async () => {
3321
+ const h = harness([reply({ text: 'Hold it' })])
3322
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
3323
+ writeSessionState('waiter-failed-route', h.env, { last_prompt_at: AWAY })
3324
+ registerQuestion('waiter-failed-route', h.env, { question: 'Ship it?' }, NOW)
3325
+ const context = waiterContext(h)
3326
+
3327
+ await expect(
3328
+ runEscalationWaiter(context, {
3329
+ sessionId: 'waiter-failed-route',
3330
+ envelope: { session_id: 'waiter-failed-route' },
3331
+ route: {
3332
+ kind: 'inbox-socket',
3333
+ deliver: async () => {
3334
+ throw new Error('socket unavailable')
3335
+ },
3336
+ },
3337
+ processDeadlineAt: NOW + 480_000,
3338
+ }),
3339
+ ).rejects.toThrow('socket unavailable')
3340
+
3341
+ expect(readSessionState('waiter-failed-route', h.env).accepted?.answers[0]?.reply.text).toBe(
3342
+ 'Hold it',
3343
+ )
3344
+ expect(claimQuestionPush('waiter-failed-route', h.env)).toBe(true)
3345
+ releaseQuestionPush('waiter-failed-route', h.env)
3346
+ })
1779
3347
  })