@raidiant/notifai 3.0.1 → 5.0.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 (71) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -4
  3. package/dist/commands.d.ts +27 -10
  4. package/dist/commands.js +429 -152
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +20 -11
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +12 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +84 -12
  17. package/dist/hooks.js +433 -125
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/invocation-context.d.ts +42 -0
  25. package/dist/invocation-context.js +130 -0
  26. package/dist/invocation-context.js.map +1 -0
  27. package/dist/logging.d.ts +8 -0
  28. package/dist/logging.js +8 -0
  29. package/dist/logging.js.map +1 -1
  30. package/dist/main.js +84 -49
  31. package/dist/main.js.map +1 -1
  32. package/dist/opencode-plugin.d.ts +8 -0
  33. package/dist/opencode-plugin.js +24 -162
  34. package/dist/opencode-plugin.js.map +1 -1
  35. package/dist/send.d.ts +38 -7
  36. package/dist/send.js +97 -41
  37. package/dist/send.js.map +1 -1
  38. package/dist/ui/help.d.ts +0 -1
  39. package/dist/ui/help.js +6 -3
  40. package/dist/ui/help.js.map +1 -1
  41. package/dist/ui/theme.d.ts +0 -15
  42. package/dist/ui/theme.js +2 -18
  43. package/dist/ui/theme.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/commands.test.ts +501 -63
  46. package/src/commands.ts +547 -187
  47. package/src/config-schema.test.ts +1 -1
  48. package/src/config-schema.ts +22 -11
  49. package/src/config.test.ts +178 -18
  50. package/src/config.ts +14 -1
  51. package/src/file-lock.test.ts +41 -0
  52. package/src/file-lock.ts +59 -9
  53. package/src/harnesses.test.ts +37 -29
  54. package/src/harnesses.ts +1 -4
  55. package/src/hooks.test.ts +500 -58
  56. package/src/hooks.ts +523 -169
  57. package/src/install-hooks.test.ts +232 -12
  58. package/src/install-hooks.ts +241 -37
  59. package/src/interactive.ts +1 -1
  60. package/src/invocation-context.test.ts +253 -0
  61. package/src/invocation-context.ts +176 -0
  62. package/src/logging-process-worker.test.ts +8 -2
  63. package/src/logging.test.ts +33 -18
  64. package/src/logging.ts +11 -0
  65. package/src/main.ts +119 -54
  66. package/src/opencode-plugin.ts +24 -164
  67. package/src/question-caps.test.ts +49 -0
  68. package/src/send.ts +144 -50
  69. package/src/skill.test.ts +197 -120
  70. package/src/ui/help.ts +8 -3
  71. package/src/ui/theme.ts +2 -16
package/src/hooks.test.ts CHANGED
@@ -14,11 +14,12 @@ import {
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { PassThrough } from 'node:stream'
17
- import type {
18
- ListRepliesResponse,
19
- ReplyView,
20
- SubmissionReceipt,
21
- SubmitNotificationRequestT,
17
+ import {
18
+ sessionLabelFromId,
19
+ type ListRepliesResponse,
20
+ type ReplyView,
21
+ type SubmissionReceipt,
22
+ type SubmitNotificationRequestT,
22
23
  } from '@raidiant/notifai-protocol'
23
24
  import { describe, expect, it, vi } from 'vitest'
24
25
  import { ApiCallError, type ApiClient } from './client.js'
@@ -48,16 +49,60 @@ import {
48
49
  drainOrphanRetirements,
49
50
  runEscalationWaiter,
50
51
  MAX_CONTINUATION_COUNT,
52
+ MAX_HELD_DELIVERIES,
51
53
  orphanRetirements,
52
54
  pruneAbandonedSessions,
53
55
  releaseQuestionPush,
54
56
  readMatchingProjectSessionPointer,
55
57
  readProjectSession,
56
58
  readSessionState,
57
- registerQuestion,
59
+ registerQuestion as persistQuestion,
58
60
  writeProjectSession,
59
- writeSessionState,
61
+ writeSessionState as persistSessionState,
62
+ type PendingQuestion,
63
+ type SessionState,
64
+ waiterCeilingSeconds,
65
+ WAITER_CEILING_SECONDS,
66
+ DETACHED_WAITER_CEILING_SECONDS,
60
67
  } from './hooks.js'
68
+ import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
69
+ import {
70
+ BLOCKING_STOP_TIMEOUT_SECONDS,
71
+ CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
72
+ } from './install-hooks.js'
73
+
74
+ /** New-format test fixtures always carry the canonical body explicitly. */
75
+ function registerQuestion(
76
+ sessionId: string,
77
+ env: NodeJS.ProcessEnv,
78
+ question: PendingQuestion,
79
+ now?: number,
80
+ ): void {
81
+ persistQuestion(
82
+ sessionId,
83
+ env,
84
+ { ...question, body: question.body ?? question.question },
85
+ now,
86
+ )
87
+ }
88
+
89
+ function writeSessionState(
90
+ sessionId: string,
91
+ env: NodeJS.ProcessEnv,
92
+ state: SessionState,
93
+ ): void {
94
+ persistSessionState(sessionId, env, {
95
+ ...state,
96
+ ...(state.pending === undefined
97
+ ? {}
98
+ : {
99
+ pending: state.pending.map((entry) => ({
100
+ ...entry,
101
+ body: entry.body ?? entry.question,
102
+ })),
103
+ }),
104
+ })
105
+ }
61
106
 
62
107
  class CapturedIo implements CommandIo {
63
108
  outLines: string[] = []
@@ -90,6 +135,10 @@ interface Recorder {
90
135
  /** Per-request answer stream for multi-question ownership tests. */
91
136
  repliesFor?: (requestId: string) => ReplyView[]
92
137
  acknowledgementRequiredFor?: (requestId: string) => boolean
138
+ /** The window the server commits to, when a test needs one unlike the waiter's. */
139
+ replyExpiresAt?: string
140
+ /** Whether that request's acknowledgement must carry text; default true. */
141
+ acknowledgementTextRequiredFor?: (requestId: string) => boolean
93
142
  acknowledged?: Set<string>
94
143
  acknowledgementChecks?: string[]
95
144
  }
@@ -140,7 +189,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
140
189
  return {
141
190
  status: 'recorded',
142
191
  agent_acknowledgement: {
143
- text: body.text,
192
+ text: body.text ?? '',
144
193
  created_at: new Date(NOW).toISOString(),
145
194
  },
146
195
  }
@@ -152,6 +201,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
152
201
  return {
153
202
  request_id: requestId,
154
203
  agent_acknowledgement_required: required,
204
+ agent_acknowledgement_text_required:
205
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
155
206
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
156
207
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
157
208
  : null,
@@ -171,9 +222,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
171
222
  recorder.receipts.push(requestId)
172
223
  return {
173
224
  request_id: requestId,
174
- reply_expires_at: new Date(NOW + 480_000).toISOString(),
225
+ reply_expires_at: recorder.replyExpiresAt ?? new Date(NOW + 480_000).toISOString(),
175
226
  agent_acknowledgement_required:
176
227
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
228
+ agent_acknowledgement_text_required:
229
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
177
230
  replayed: false,
178
231
  overall: 'provider_accepted_all',
179
232
  deliveries: [],
@@ -187,6 +240,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
187
240
  reply_expires_at: null,
188
241
  agent_acknowledgement_required:
189
242
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
243
+ agent_acknowledgement_text_required:
244
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
190
245
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
191
246
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
192
247
  : null,
@@ -200,6 +255,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
200
255
  reply_expires_at: new Date(NOW).toISOString(),
201
256
  agent_acknowledgement_required:
202
257
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
258
+ agent_acknowledgement_text_required:
259
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
203
260
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
204
261
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
205
262
  : null,
@@ -415,6 +472,27 @@ function writeGlobalConfig(h: Harness, toml: string): void {
415
472
  /** Long enough ago that no test is accidentally sensitive to the stamp. */
416
473
  const AWAY = NOW - 600_000
417
474
 
475
+ describe('the waiter wall clock', () => {
476
+ it('spends a held turn sparingly and a detached one generously', () => {
477
+ // A held turn is somebody's terminal, so the short ceiling is the cost of
478
+ // waiting. Detached, nothing is held, and the same number only shortens how
479
+ // long a direct wake stays possible.
480
+ expect(waiterCeilingSeconds(false)).toBe(WAITER_CEILING_SECONDS)
481
+ expect(waiterCeilingSeconds(true)).toBe(DETACHED_WAITER_CEILING_SECONDS)
482
+ expect(DETACHED_WAITER_CEILING_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
483
+ })
484
+
485
+ it('stays under the timeout the async Stop handler declares', () => {
486
+ // That declaration's kill is silent: past it the waiter vanishes and the
487
+ // answer the user already gave is never delivered.
488
+ expect(DETACHED_WAITER_CEILING_SECONDS).toBeLessThan(CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS)
489
+ })
490
+
491
+ it('leaves the blocking host headroom above the waiter it declares for', () => {
492
+ expect(BLOCKING_STOP_TIMEOUT_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
493
+ })
494
+ })
495
+
418
496
  describe('pushing a registered question', () => {
419
497
  // Found by a live Claude Code session: a spawned agent always has a
420
498
  // just-set last_prompt_at, and the routing gate once read that as the user
@@ -432,16 +510,39 @@ describe('pushing a registered question', () => {
432
510
  device_ids: ['dev_iphone', 'dev_mac'],
433
511
  })
434
512
  expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
435
- title: 'Question',
513
+ title: 'Ship it?',
436
514
  body: 'Ship it?',
437
515
  })
438
- expect(h.recorder.submitted[0]?.draft.platform).toEqual({
439
- ios: { sound: 'attention', interruption_level: 'active' },
440
- macos: { sound: 'attention', interruption_level: 'active' },
441
- })
516
+ // A pushed question is a question, and a question has an attention tone.
517
+ expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
442
518
  })
443
519
 
444
- it('names the resolved project in the pushed question title', async () => {
520
+ it('keeps sparse alt text paired with its original media item', async () => {
521
+ const h = harness([reply({ text: 'Yes' })])
522
+ writeSessionState('media-order', h.env, { last_prompt_at: AWAY })
523
+ registerQuestion(
524
+ 'media-order',
525
+ h.env,
526
+ {
527
+ question: 'Which image should ship?',
528
+ body: 'Compare ![the second image](media:med_second).',
529
+ media: [
530
+ { media_id: 'med_first' },
531
+ { media_id: 'med_second', alt: 'Second image' },
532
+ ],
533
+ },
534
+ NOW,
535
+ )
536
+
537
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'media-order' }))
538
+
539
+ expect(h.recorder.submitted[0]?.draft.presentation.media).toEqual([
540
+ { media_id: 'med_first' },
541
+ { media_id: 'med_second', alt: 'Second image' },
542
+ ])
543
+ })
544
+
545
+ it('keeps Project identity out of the substantive question title', async () => {
445
546
  const h = harness([reply({ text: 'Yes' })])
446
547
  const projectDir = path.join(h.deps.cwd, '.notifai')
447
548
  mkdirSync(projectDir, { recursive: true })
@@ -450,11 +551,17 @@ describe('pushing a registered question', () => {
450
551
  'project = "notifai-cli"\nask_grace_seconds = 0\n',
451
552
  )
452
553
  writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
453
- registerQuestion('project-title', h.env, { question: 'Ship it?' }, NOW)
554
+ registerQuestion(
555
+ 'project-title',
556
+ h.env,
557
+ { question: 'Ship it?', project: 'notifai-cli' },
558
+ NOW,
559
+ )
454
560
 
455
561
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
456
562
 
457
- expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
563
+ expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Ship it?')
564
+ expect(h.recorder.submitted[0]?.draft.project).toBe('notifai-cli')
458
565
  })
459
566
  })
460
567
 
@@ -981,6 +1088,12 @@ describe('late answer collection', () => {
981
1088
 
982
1089
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
983
1090
  expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
1091
+ // The obligation survives the user typing — that is the whole point of
1092
+ // recording it — so the turn after the answer is held for it. Satisfy it
1093
+ // the way the agent would, then the retirement proceeds.
1094
+ expect(readSessionState('late-prompt', h.env).acknowledgement_due).toHaveLength(1)
1095
+ h.recorder.acknowledged = new Set(['req_live'])
1096
+
984
1097
  await hookRunCommand(
985
1098
  h.deps,
986
1099
  'stop',
@@ -1152,10 +1265,66 @@ describe('the waiter owning one question to the end', () => {
1152
1265
  expect(h.recorder.submitted[0]).toEqual(firstAttempt)
1153
1266
  })
1154
1267
 
1155
- it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
1268
+ it('remints a frozen draft after a terminal 422 instead of replaying it', async () => {
1269
+ const h = harness([])
1270
+ const factory = h.deps.clientFactory
1271
+ h.deps.clientFactory = () => {
1272
+ const client = factory!()
1273
+ return {
1274
+ ...client,
1275
+ submit: async () => {
1276
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1277
+ },
1278
+ } as ApiClient
1279
+ }
1280
+ writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1281
+ registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1282
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1283
+ const frozen = readSessionState('rejected-submit', h.env).pending?.[0]?.submission
1284
+ if (frozen === undefined) throw new Error('expected a persisted submission intent')
1285
+
1286
+ let submits = 0
1287
+ h.deps.clientFactory = () => {
1288
+ const client = factory!()
1289
+ return {
1290
+ ...client,
1291
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
1292
+ submits += 1
1293
+ if (body.request_id === frozen.request_id) {
1294
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.', null, [
1295
+ { path: 'presentation.detail', message: 'Unexpected property' },
1296
+ ])
1297
+ }
1298
+ return client.submit(body, waitSeconds)
1299
+ },
1300
+ } as ApiClient
1301
+ }
1302
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1303
+
1304
+ expect(submits).toBe(2)
1305
+ expect(h.recorder.submitted[0]?.request_id).toBeDefined()
1306
+ expect(h.recorder.submitted[0]?.request_id).not.toBe(frozen.request_id)
1307
+ expect(h.io.errLines.join('\n')).toContain('reminting the draft')
1308
+ expect(readSessionState('rejected-submit', h.env).pending).toBeUndefined()
1309
+ })
1310
+
1311
+ it('retires a reminted draft that the server still rejects', async () => {
1156
1312
  const h = harness([])
1157
1313
  const factory = h.deps.clientFactory
1158
- let polls = 0
1314
+ h.deps.clientFactory = () => {
1315
+ const client = factory!()
1316
+ return {
1317
+ ...client,
1318
+ submit: async () => {
1319
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1320
+ },
1321
+ } as ApiClient
1322
+ }
1323
+ writeSessionState('rejected-forever', h.env, { last_prompt_at: AWAY })
1324
+ registerQuestion('rejected-forever', h.env, { question: 'Deploy?' }, NOW)
1325
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1326
+ expect(readSessionState('rejected-forever', h.env).pending?.[0]?.submission).toBeDefined()
1327
+
1159
1328
  h.deps.clientFactory = () => {
1160
1329
  const client = factory!()
1161
1330
  return {
@@ -1163,29 +1332,72 @@ describe('the waiter owning one question to the end', () => {
1163
1332
  submit: async () => {
1164
1333
  throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1165
1334
  },
1335
+ } as ApiClient
1336
+ }
1337
+ h.advanceClock(600_000)
1338
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1339
+ expect(readSessionState('rejected-forever', h.env).pending).toBeUndefined()
1340
+
1341
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1342
+ expect(h.recorder.submitted).toEqual([])
1343
+ expect(h.io.errLines.join('\n')).toContain('retiring the question so it is not retried forever')
1344
+ })
1345
+
1346
+ it('retires a freshly minted draft the current contract rejects', async () => {
1347
+ const h = harness([])
1348
+ const factory = h.deps.clientFactory
1349
+ h.deps.clientFactory = () => {
1350
+ const client = factory!()
1351
+ return {
1352
+ ...client,
1353
+ submit: async () => {
1354
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1355
+ },
1356
+ } as ApiClient
1357
+ }
1358
+ writeSessionState('fresh-422', h.env, { last_prompt_at: AWAY })
1359
+ registerQuestion('fresh-422', h.env, { question: 'Deploy?' }, NOW)
1360
+
1361
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fresh-422' }))
1362
+
1363
+ expect(readSessionState('fresh-422', h.env).pending).toBeUndefined()
1364
+ expect(h.io.errLines.join('\n')).toContain('retiring it because the current draft will never be accepted')
1365
+ })
1366
+
1367
+ it('preserves a frozen intent after a retryable client rejection', async () => {
1368
+ const h = harness([])
1369
+ const factory = h.deps.clientFactory
1370
+ let polls = 0
1371
+ h.deps.clientFactory = () => {
1372
+ const client = factory!()
1373
+ return {
1374
+ ...client,
1375
+ submit: async () => {
1376
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1377
+ },
1166
1378
  replies: async () => {
1167
1379
  polls += 1
1168
1380
  throw new Error('must not poll an unaccepted request')
1169
1381
  },
1170
1382
  } as ApiClient
1171
1383
  }
1172
- writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1173
- registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1384
+ writeSessionState('auth-submit', h.env, { last_prompt_at: AWAY })
1385
+ registerQuestion('auth-submit', h.env, { question: 'Deploy?' }, NOW)
1174
1386
 
1175
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1387
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
1176
1388
 
1177
- const pending = readSessionState('rejected-submit', h.env).pending?.[0]
1389
+ const pending = readSessionState('auth-submit', h.env).pending?.[0]
1178
1390
  expect(pending?.request_id).toBeUndefined()
1179
1391
  expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1180
1392
  expect(polls).toBe(0)
1181
- expect(h.io.errLines.join('\n')).toContain('submission was rejected')
1393
+ expect(h.io.errLines.join('\n')).toContain('preserving it for recovery')
1182
1394
 
1183
1395
  const frozen = pending?.submission
1184
1396
  if (frozen === undefined) throw new Error('expected a persisted submission intent')
1185
1397
  writeGlobalConfig(h, 'project = "changed-after-crash"\n')
1186
1398
  h.advanceClock(600_000)
1187
1399
  h.deps.clientFactory = factory
1188
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1400
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
1189
1401
 
1190
1402
  expect(h.recorder.submitted[0]).toEqual({
1191
1403
  request_id: frozen.request_id,
@@ -1220,7 +1432,7 @@ describe('the waiter owning one question to the end', () => {
1220
1432
  expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
1221
1433
  })
1222
1434
 
1223
- it('charges sequential submission latency to one deadline', async () => {
1435
+ it('charges sequential submission latency to one owner deadline, not to the answer window', async () => {
1224
1436
  const h = harness([reply({ text: 'Done' })])
1225
1437
  writeSessionState('latency', h.env, { last_prompt_at: AWAY })
1226
1438
  registerQuestion('latency', h.env, { question: 'First?' }, NOW)
@@ -1232,9 +1444,12 @@ describe('the waiter owning one question to the end', () => {
1232
1444
  const windows = h.recorder.submitted
1233
1445
  .filter((entry) => entry.draft.event === 'agent_question')
1234
1446
  .map((entry) => entry.draft.reply?.expires_in_seconds)
1235
- // Both windows end at the same absolute moment: the second question's is
1236
- // shorter by exactly the 40s the first submission spent.
1237
- expect(windows).toEqual([480, 440])
1447
+ // How long the user may answer does not shrink because this owner spent
1448
+ // part of its own budget getting the question out. Both questions stay
1449
+ // answerable for the configured window.
1450
+ expect(windows).toEqual([86_400, 86_400])
1451
+ // The owner's own budget is still one shared deadline, and it is still
1452
+ // charged for the latency: the whole pass fits inside the waiter ceiling.
1238
1453
  expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
1239
1454
  })
1240
1455
 
@@ -1699,7 +1914,7 @@ describe('several questions in flight', () => {
1699
1914
 
1700
1915
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
1701
1916
  expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
1702
- { request_id: 'req_ack_required', recorded_at: NOW },
1917
+ { request_id: 'req_ack_required', recorded_at: NOW, text_required: true },
1703
1918
  ])
1704
1919
 
1705
1920
  h.io.outLines = []
@@ -1719,6 +1934,66 @@ describe('several questions in flight', () => {
1719
1934
  expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
1720
1935
  })
1721
1936
 
1937
+ it('still blocks Stop, without asking for text, when the account turned text off', async () => {
1938
+ const h = harness([reply({ text: 'Ship it' })])
1939
+ h.recorder.acknowledgementTextRequiredFor = () => false
1940
+ writeSessionState('ack-textless', h.env, {
1941
+ pending: [
1942
+ {
1943
+ question: 'Ship it?',
1944
+ request_id: 'req_ack_textless',
1945
+ collapse_key: 'collapse-ack-textless',
1946
+ device_ids: ['dev_iphone'],
1947
+ reply_deadline_at: NOW + 60_000,
1948
+ },
1949
+ ],
1950
+ })
1951
+
1952
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-textless' }))
1953
+ // The obligation is unchanged by the preference: the acknowledgement still
1954
+ // has to happen, because the user's read-state depends on it.
1955
+ expect(readSessionState('ack-textless', h.env).acknowledgement_due).toEqual([
1956
+ { request_id: 'req_ack_textless', recorded_at: NOW, text_required: false },
1957
+ ])
1958
+
1959
+ h.io.outLines = []
1960
+ await hookRunCommand(
1961
+ h.deps,
1962
+ 'stop',
1963
+ stdin({ session_id: 'ack-textless', stop_hook_active: true }),
1964
+ )
1965
+
1966
+ const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
1967
+ expect(blocked.decision).toBe('block')
1968
+ expect(blocked.reason).toContain('notifai acknowledge req_ack_textless`')
1969
+ expect(blocked.reason).not.toContain('--text')
1970
+ })
1971
+
1972
+ it('gives up holding the turn rather than wedging the session for ever', async () => {
1973
+ // The acknowledgement gate is the one place hooks break the fail-open rule.
1974
+ // Unbounded, an agent that never acknowledges — or a server that stays
1975
+ // unreachable, since an error counts as unresolved — held every turn of
1976
+ // this session for good.
1977
+ const h = harness([])
1978
+ writeSessionState('ack-wedge', h.env, {
1979
+ acknowledgement_due: [{ request_id: 'req_wedge', recorded_at: NOW }],
1980
+ })
1981
+
1982
+ const blocked: unknown[] = []
1983
+ for (let turn = 0; turn < 3; turn += 1) {
1984
+ h.io.outLines.length = 0
1985
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
1986
+ blocked.push(h.io.outLines.length > 0)
1987
+ }
1988
+ expect(blocked).toEqual([true, true, true])
1989
+
1990
+ // The fourth turn is let through, and the obligation stops being owed.
1991
+ h.io.outLines.length = 0
1992
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
1993
+ expect(h.io.outLines).toEqual([])
1994
+ expect(readSessionState('ack-wedge', h.env).acknowledgement_due).toBeUndefined()
1995
+ })
1996
+
1722
1997
  it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
1723
1998
  const h = harness([])
1724
1999
  writeSessionState('ack-heal', h.env, {
@@ -1889,7 +2164,7 @@ describe('a question that outlives its session', () => {
1889
2164
  expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
1890
2165
  })
1891
2166
 
1892
- it('gives up on an orphan older than a day instead of queueing it for ever', async () => {
2167
+ it('gives up on an orphan that outlived the longest answerable window', async () => {
1893
2168
  const h = harness([])
1894
2169
  orphanRetirements(
1895
2170
  h.env,
@@ -1900,8 +2175,7 @@ describe('a question that outlives its session', () => {
1900
2175
  question: 'Old?',
1901
2176
  state: 'expired',
1902
2177
  }],
1903
- undefined,
1904
- NOW - 25 * 3600 * 1000,
2178
+ NOW - (REPLY_MAX_WINDOW_SECONDS + 2 * 3600) * 1000,
1905
2179
  )
1906
2180
  const drained = await drainOrphanRetirements(
1907
2181
  { client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
@@ -1962,7 +2236,7 @@ describe('ask registration', () => {
1962
2236
  const h = harness()
1963
2237
  // Inside a hook, a rejection is only a stderr note the agent never reads —
1964
2238
  // so it would look registered and then silently never ask.
1965
- expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], session: 'a1' })).toBe(EXIT.usage)
2239
+ expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], sessionId: 'a1' })).toBe(EXIT.usage)
1966
2240
  expect(readSessionState('a1', h.env).pending).toBeUndefined()
1967
2241
  })
1968
2242
 
@@ -1993,7 +2267,7 @@ describe('ask registration', () => {
1993
2267
  { text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
1994
2268
  { text: 'Anything to watch?' },
1995
2269
  ],
1996
- detail: '## Context\nThe long story.',
2270
+ body: '## Context\nThe long story.',
1997
2271
  }) },
1998
2272
  undefined,
1999
2273
  )
@@ -2002,18 +2276,18 @@ describe('ask registration', () => {
2002
2276
  registerQuestion('form1', h.env, {
2003
2277
  question: built.questions[0]!.text,
2004
2278
  questions: built.questions,
2005
- ...(built.detail === undefined ? {} : { detail: built.detail }),
2279
+ body: built.body,
2006
2280
  })
2007
2281
  const pending = readSessionState('form1', h.env).pending?.[0]
2008
2282
  expect(pending?.questions).toHaveLength(2)
2009
- expect(pending?.detail).toContain('long story')
2283
+ expect(pending?.body).toContain('long story')
2010
2284
 
2011
2285
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
2012
2286
  const draft = h.recorder.submitted[0]?.draft
2013
- // The banner leads with the first question and admits the rest exists;
2014
- // the full set rides the payload for the answering card.
2015
- expect(draft?.presentation.body).toBe('Deploy where? (+1 more)')
2016
- expect(draft?.presentation.detail).toContain('long story')
2287
+ expect(draft?.presentation.title).toBe('Deploy where?')
2288
+ expect(draft?.presentation.body).toBe(
2289
+ '1. Deploy where?\n2. Anything to watch?\n\n## Context\nThe long story.',
2290
+ )
2017
2291
  expect(draft?.reply?.questions).toEqual([
2018
2292
  {
2019
2293
  id: 'deploy-where',
@@ -2108,7 +2382,7 @@ describe('ask registration', () => {
2108
2382
 
2109
2383
  it('does not let an explicit session bypass exact active-harness proof', () => {
2110
2384
  const h = harness()
2111
- expect(askCommand(h.deps, 'Ship it?', { session: 'guessed' })).toBe(EXIT.usage)
2385
+ expect(askCommand(h.deps, 'Ship it?', { sessionId: 'guessed' })).toBe(EXIT.usage)
2112
2386
  expect(readSessionState('guessed', h.env).pending).toBeUndefined()
2113
2387
  })
2114
2388
 
@@ -2529,21 +2803,46 @@ describe('malformed input', () => {
2529
2803
  })
2530
2804
 
2531
2805
  describe('telling concurrent agents apart', () => {
2532
- it('stamps the harness session on the question it pushes', async () => {
2533
- // The hook has always known session_id and never passed it on, so two
2534
- // agents in separate worktrees produced identical notifications and the
2535
- // user could answer the wrong one's question.
2806
+ it('stamps structured Source Context on the question it pushes', async () => {
2536
2807
  const h = harness([])
2537
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2808
+ registerQuestion(
2809
+ 'sess-abc',
2810
+ h.env,
2811
+ {
2812
+ question: 'Ship it?',
2813
+ source: {
2814
+ session_id: 'sess-abc',
2815
+ session_label: sessionLabelFromId('sess-abc'),
2816
+ harness: 'claude-code',
2817
+ },
2818
+ },
2819
+ NOW,
2820
+ )
2538
2821
 
2539
2822
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2540
2823
 
2541
- expect(h.recorder.submitted[0]?.draft.session).toBe('sess-abc')
2824
+ expect(h.recorder.submitted[0]?.draft.source).toEqual({
2825
+ session_id: 'sess-abc',
2826
+ session_label: sessionLabelFromId('sess-abc'),
2827
+ harness: 'claude-code',
2828
+ })
2542
2829
  })
2543
2830
 
2544
2831
  it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
2545
2832
  const h = harness([reply({ text: 'Yes' })])
2546
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2833
+ registerQuestion(
2834
+ 'sess-abc',
2835
+ h.env,
2836
+ {
2837
+ question: 'Ship it?',
2838
+ source: {
2839
+ session_id: 'sess-abc',
2840
+ session_label: sessionLabelFromId('sess-abc'),
2841
+ harness: 'claude-code',
2842
+ },
2843
+ },
2844
+ NOW,
2845
+ )
2547
2846
 
2548
2847
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2549
2848
  h.recorder.acknowledged ??= new Set()
@@ -2555,17 +2854,35 @@ describe('telling concurrent agents apart', () => {
2555
2854
  )
2556
2855
 
2557
2856
  const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
2558
- expect(retirement?.draft.session).toBe('sess-abc')
2857
+ expect(retirement?.draft.source).toEqual({
2858
+ session_id: 'sess-abc',
2859
+ session_label: sessionLabelFromId('sess-abc'),
2860
+ harness: 'claude-code',
2861
+ })
2559
2862
  })
2560
2863
 
2561
- it('prefers a name the user chose over the harness UUID', async () => {
2864
+ it('preserves a human session label without displaying the opaque id', async () => {
2562
2865
  const h = harness([])
2563
- h.env['NOTIFAI_SESSION'] = 'migration-worktree'
2564
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2866
+ registerQuestion(
2867
+ 'sess-abc',
2868
+ h.env,
2869
+ {
2870
+ question: 'Ship it?',
2871
+ source: {
2872
+ session_id: 'sess-abc',
2873
+ session_label: 'Migration Worktree',
2874
+ harness: 'claude-code',
2875
+ },
2876
+ },
2877
+ NOW,
2878
+ )
2565
2879
 
2566
2880
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2567
2881
 
2568
- expect(h.recorder.submitted[0]?.draft.session).toBe('migration-worktree')
2882
+ const draft = h.recorder.submitted[0]?.draft
2883
+ expect(draft?.source?.session_label).toBe('Migration Worktree')
2884
+ expect(draft?.presentation.title).not.toContain('sess-abc')
2885
+ expect(draft?.presentation.body).not.toContain('sess-abc')
2569
2886
  })
2570
2887
  })
2571
2888
 
@@ -2713,7 +3030,6 @@ describe('durable state writes', () => {
2713
3030
  state: 'expired',
2714
3031
  },
2715
3032
  ],
2716
- 'linked-pointer',
2717
3033
  NOW,
2718
3034
  ),
2719
3035
  ).toThrow(/symlink/)
@@ -3456,6 +3772,47 @@ describe('escalation waiter delivery seam', () => {
3456
3772
  expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
3457
3773
  })
3458
3774
 
3775
+ it('does not spend the delivery cap on turns where nothing was handed over', async () => {
3776
+ // The bug: holds counted against MAX_CONTINUATION_COUNT, so three turns
3777
+ // where a liveness probe could not reach a busy session were enough to
3778
+ // settle an answer the user had already given, unread.
3779
+ const h = harness([])
3780
+ const sessionId = 'waiter-transient-holds'
3781
+ journaled(h, sessionId)
3782
+ const context = waiterContext(h)
3783
+ let handedOver = 0
3784
+ let pass = 0
3785
+
3786
+ for (; pass < MAX_CONTINUATION_COUNT + 2; pass += 1) {
3787
+ await runEscalationWaiter(context, {
3788
+ sessionId,
3789
+ envelope: { session_id: sessionId, stop_hook_active: false },
3790
+ route: {
3791
+ kind: 'inbox-socket',
3792
+ deliver: async () => ({ notes: [], acknowledgement: 'held' as const }),
3793
+ },
3794
+ processDeadlineAt: NOW + 480_000,
3795
+ })
3796
+ }
3797
+ // Still journaled after more holds than the delivery cap allows.
3798
+ expect(readSessionState(sessionId, h.env).accepted).toBeDefined()
3799
+
3800
+ // And when a route can finally take it, it is delivered rather than lost.
3801
+ await runEscalationWaiter(context, {
3802
+ sessionId,
3803
+ envelope: { session_id: sessionId, stop_hook_active: false },
3804
+ route: {
3805
+ kind: 'inbox-socket',
3806
+ deliver: async () => {
3807
+ handedOver += 1
3808
+ return { notes: [], acknowledgement: 'delivered' as const }
3809
+ },
3810
+ },
3811
+ processDeadlineAt: NOW + 480_000,
3812
+ })
3813
+ expect(handedOver).toBe(1)
3814
+ })
3815
+
3459
3816
  it('bounds deliveries by the continuation cap on every route', async () => {
3460
3817
  // A route that never acknowledges must still stop. The cap lives where all
3461
3818
  // routes pass, so no future route can be the one it does not cover.
@@ -3467,7 +3824,7 @@ describe('escalation waiter delivery seam', () => {
3467
3824
  let deliveries = 0
3468
3825
  const notes: string[] = []
3469
3826
 
3470
- for (let pass = 0; pass < MAX_CONTINUATION_COUNT + 3; pass += 1) {
3827
+ for (let pass = 0; pass < MAX_HELD_DELIVERIES + 3; pass += 1) {
3471
3828
  const outcome = await runEscalationWaiter(context, {
3472
3829
  sessionId,
3473
3830
  envelope: { session_id: sessionId, stop_hook_active: false },
@@ -3483,9 +3840,11 @@ describe('escalation waiter delivery seam', () => {
3483
3840
  notes.push(...outcome.notes)
3484
3841
  }
3485
3842
 
3486
- expect(deliveries).toBe(MAX_CONTINUATION_COUNT)
3843
+ // A hold is not a delivery — nothing was handed over — so it does not
3844
+ // spend the wake-loop cap. It still has to end, on its own looser bound.
3845
+ expect(deliveries).toBe(MAX_HELD_DELIVERIES)
3487
3846
  expect(notes.join('\n')).toContain(
3488
- `${MAX_CONTINUATION_COUNT} times without being acknowledged`,
3847
+ `could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns`,
3489
3848
  )
3490
3849
  expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
3491
3850
  }
@@ -3519,3 +3878,86 @@ describe('escalation waiter delivery seam', () => {
3519
3878
  releaseQuestionPush('waiter-failed-route', h.env)
3520
3879
  })
3521
3880
  })
3881
+
3882
+
3883
+ describe('session state across a prompt', () => {
3884
+ it('carries every kind of session state across a typed prompt', async () => {
3885
+ // The prompt handler rebuilds state from named fields, so a field nobody
3886
+ // remembered to name is erased by the user typing. That is how an
3887
+ // acknowledgement obligation once vanished. This fails if a future field is
3888
+ // added to SessionState and not carried across.
3889
+ const h = harness([])
3890
+ const before = {
3891
+ last_prompt_at: AWAY,
3892
+ last_stop_at: NOW - 1_000,
3893
+ retiring: [
3894
+ {
3895
+ request_id: 'req_carry',
3896
+ collapse_key: 'collapse-carry',
3897
+ device_ids: ['dev_iphone'],
3898
+ question: 'Carried?',
3899
+ state: 'expired' as const,
3900
+ },
3901
+ ],
3902
+ continuation: { answered_at: NOW - 2_000, count: 2 },
3903
+ acknowledgement_due: [{ request_id: 'req_carry_ack', recorded_at: NOW }],
3904
+ acknowledgement_blocks: 2,
3905
+ }
3906
+ writeSessionState('carry-all', h.env, before)
3907
+
3908
+ await hookRunCommand(
3909
+ h.deps,
3910
+ 'user-prompt-submit',
3911
+ stdin({ session_id: 'carry-all', cwd: h.deps.cwd }),
3912
+ )
3913
+
3914
+ const after = readSessionState('carry-all', h.env)
3915
+ for (const key of Object.keys(before) as (keyof typeof before)[]) {
3916
+ expect(after[key], `${key} did not survive the prompt`).toBeDefined()
3917
+ }
3918
+ // The one field a prompt resets rather than carries.
3919
+ expect(after.continuation?.count).toBe(0)
3920
+ expect(after.acknowledgement_blocks).toBe(2)
3921
+ })
3922
+ })
3923
+
3924
+ describe('a question outliving the waiter that pushed it', () => {
3925
+ it('records when the server stops accepting, not when this owner stops waiting', async () => {
3926
+ // These were the same number until the window became a setting, and taking
3927
+ // the minimum capped every question at the waiter's ceiling: a day-long
3928
+ // window was swept as stale about eight minutes later and retired, so the
3929
+ // answer given over lunch met a closed question.
3930
+ const h = harness([])
3931
+ const aDayOut = NOW + 86_400_000
3932
+ h.recorder.replyExpiresAt = new Date(aDayOut).toISOString()
3933
+ writeSessionState('outlive', h.env, { last_prompt_at: AWAY })
3934
+ registerQuestion('outlive', h.env, { question: 'Ship it?' }, NOW)
3935
+
3936
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'outlive' }))
3937
+
3938
+ const state = readSessionState('outlive', h.env)
3939
+ const live = state.pending?.[0]
3940
+ // Still live, still carrying the server's window — not the waiter's.
3941
+ expect(live?.request_id).toBeDefined()
3942
+ expect(live?.reply_deadline_at).toBe(aDayOut)
3943
+ // Not closed and not retired: those are what capped it at eight minutes.
3944
+ expect(state.retiring).toBeUndefined()
3945
+ expect(h.recorder.closed).toEqual([])
3946
+ // And the waiter still stopped at its own ceiling rather than waiting a day.
3947
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
3948
+ })
3949
+
3950
+ it('still closes a question the server has genuinely stopped accepting', async () => {
3951
+ const h = harness([])
3952
+ h.recorder.replyExpiresAt = new Date(NOW + 90_000).toISOString()
3953
+ writeSessionState('short-window', h.env, { last_prompt_at: AWAY })
3954
+ registerQuestion('short-window', h.env, { question: 'Ship it?' }, NOW)
3955
+
3956
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'short-window' }))
3957
+
3958
+ // Its window closed inside the waiter's ceiling, so it is retired here
3959
+ // rather than left for a turn that could never collect it.
3960
+ expect(readSessionState('short-window', h.env).pending).toBeUndefined()
3961
+ expect(h.recorder.closed.length).toBe(1)
3962
+ })
3963
+ })