@raidiant/notifai 7.0.2 → 9.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 (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
package/src/hooks.ts CHANGED
@@ -69,6 +69,8 @@ export interface HookEnvelope {
69
69
  session_id?: string
70
70
  cwd?: string
71
71
  hook_event_name?: string
72
+ /** How a harness lifecycle began: startup, resume, clear, compact, or fork. */
73
+ source?: string
72
74
  /** Set by the harness when this Stop follows a previous Stop continuation. */
73
75
  stop_hook_active?: boolean
74
76
  /** Cursor's stable per-conversation identifier. */
@@ -77,9 +79,20 @@ export interface HookEnvelope {
77
79
  workspace_roots?: string[]
78
80
  /** Cursor increments this after each stop-hook automatic follow-up. */
79
81
  loop_count?: number
82
+ /** Cursor Stop completion state; cancellation must not auto-follow. */
83
+ status?: string
84
+ /** The prompt the user just submitted, when the harness includes it. */
85
+ prompt?: string
80
86
  }
81
87
 
82
88
  export interface SessionState {
89
+ /** Model-visible proactive context already emitted for this harness session. */
90
+ activation_context_emitted_at?: number
91
+ /** Harness process that received it; a resumed session has a new owner. */
92
+ activation_context_owner_pid?: number
93
+ /** Cursor's documented session context path is lossy; bounded first-Stop fallback journal. */
94
+ cursor_activation_claimed_at?: number
95
+ cursor_activation_confirmed_at?: number
83
96
  /** Epoch ms of the user's last prompt in this session — our presence signal. */
84
97
  last_prompt_at?: number
85
98
  /** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
@@ -239,6 +252,15 @@ export interface PendingQuestion {
239
252
  device_ids?: string[]
240
253
  /** Absolute end of the server reply window and its local continuation owner. */
241
254
  reply_deadline_at?: number
255
+ /**
256
+ * When this Stop owner stops listening for a direct wake. Distinct from
257
+ * `reply_deadline_at`: the server may still accept an answer for a day after
258
+ * this owner has returned. A later Stop that treats the reply window as a
259
+ * fresh waiter ceiling monopolizes the claim and skips newly registered
260
+ * questions. Absent on state written before this field existed — treated as
261
+ * already spent, because that is the shape a degraded waiter left behind.
262
+ */
263
+ owner_deadline_at?: number
242
264
  /** Frozen before the first network byte, so an ambiguous submit is replayable. */
243
265
  submission?: PendingSubmissionIntent
244
266
  }
@@ -319,6 +341,14 @@ export function waiterCeilingSeconds(detached: boolean): number {
319
341
  */
320
342
  export const MIN_REPLY_WINDOW_SECONDS = 60
321
343
 
344
+ /**
345
+ * HTTP budget reserved for the waiter's final reply fetch and close fence.
346
+ * Taken from the owner deadline rather than added after it, so the hook does
347
+ * not block past its ceiling. A near-zero remainder used to produce a
348
+ * misleading 0s timeout and an unproven retirement.
349
+ */
350
+ export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
351
+
322
352
  /**
323
353
  * The terminal-first wait: the question sits in the terminal for
324
354
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -348,8 +378,10 @@ async function awaitTerminalFirstWindow(
348
378
  }
349
379
 
350
380
  export interface HookOutcome {
351
- /** Written to stdout verbatim — the harness parses this as the decision. */
381
+ /** Written to stdout verbatim — the harness parses this as output. */
352
382
  stdout?: string
383
+ /** Whether stdout takes over the turn, rather than adding prompt context. */
384
+ decided?: boolean
353
385
  /** Diagnostics; harnesses surface hook stderr in the transcript. */
354
386
  notes: string[]
355
387
  /** Structured lifecycle detail that belongs on hook.end without user text. */
@@ -462,6 +494,108 @@ function updateSessionState(
462
494
  })
463
495
  }
464
496
 
497
+ /** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
498
+ export function markSessionActivation(
499
+ sessionId: string,
500
+ env: NodeJS.ProcessEnv,
501
+ now: number,
502
+ ownerPid: number,
503
+ ): void {
504
+ updateSessionState(sessionId, env, (current) => ({
505
+ ...current,
506
+ activation_context_emitted_at: now,
507
+ activation_context_owner_pid: ownerPid,
508
+ }))
509
+ }
510
+
511
+ /**
512
+ * Atomically own first-prompt activation only when no lifecycle hook did.
513
+ *
514
+ * Some managed hosts preserve UserPromptSubmit while regenerating away newer
515
+ * SessionStart definitions. This is a compatibility seam, not the primary
516
+ * lifecycle: one session gets at most one model-visible activation from it.
517
+ */
518
+ export function claimPromptActivation(
519
+ sessionId: string,
520
+ env: NodeJS.ProcessEnv,
521
+ now: number,
522
+ ownerPid: number,
523
+ ): boolean {
524
+ let claimed = false
525
+ updateSessionState(sessionId, env, (current) => {
526
+ if (
527
+ current.activation_context_emitted_at !== undefined &&
528
+ current.activation_context_owner_pid === ownerPid
529
+ ) {
530
+ return current
531
+ }
532
+ claimed = true
533
+ return {
534
+ ...current,
535
+ activation_context_emitted_at: now,
536
+ activation_context_owner_pid: ownerPid,
537
+ }
538
+ })
539
+ return claimed
540
+ }
541
+
542
+ /** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
543
+ export function claimCursorStopActivation(
544
+ sessionId: string,
545
+ env: NodeJS.ProcessEnv,
546
+ now: number,
547
+ ): boolean {
548
+ const claimGuardMs = 30_000
549
+ let claimed = false
550
+ updateSessionState(sessionId, env, (current) => {
551
+ if (current.cursor_activation_confirmed_at !== undefined) return current
552
+ if (
553
+ current.cursor_activation_claimed_at !== undefined &&
554
+ now - current.cursor_activation_claimed_at < claimGuardMs
555
+ ) {
556
+ return current
557
+ }
558
+ claimed = true
559
+ return { ...current, cursor_activation_claimed_at: now }
560
+ })
561
+ return claimed
562
+ }
563
+
564
+ /** Cursor's loop_count proves the previous Stop follow-up reached the host. */
565
+ export function confirmCursorStopActivation(
566
+ sessionId: string,
567
+ env: NodeJS.ProcessEnv,
568
+ now: number,
569
+ ): boolean {
570
+ let owned = false
571
+ updateSessionState(sessionId, env, (current) => {
572
+ if (current.cursor_activation_confirmed_at !== undefined) {
573
+ owned = true
574
+ return current
575
+ }
576
+ if (current.cursor_activation_claimed_at === undefined) return current
577
+ owned = true
578
+ return { ...current, cursor_activation_confirmed_at: now }
579
+ })
580
+ return owned
581
+ }
582
+
583
+ /** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
584
+ export function resetCursorStopActivation(sessionId: string, env: NodeJS.ProcessEnv): void {
585
+ updateSessionState(sessionId, env, (current) => {
586
+ if (
587
+ current.cursor_activation_claimed_at === undefined &&
588
+ current.cursor_activation_confirmed_at === undefined
589
+ ) {
590
+ return current
591
+ }
592
+ const next = { ...current }
593
+ delete next.cursor_activation_claimed_at
594
+ delete next.cursor_activation_confirmed_at
595
+ return next
596
+ })
597
+ }
598
+
465
599
  /**
466
600
  * Session state a crashed harness left behind.
467
601
  *
@@ -651,8 +785,9 @@ function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
651
785
  /**
652
786
  * A question is stored so a later hook can push it, and it reaches us from a
653
787
  * shell command, so its size is whatever the agent typed. The push itself is
654
- * bounded by the 4096-byte APNs envelope; this bounds what sits on disk in the
655
- * meantime, and keeps one runaway agent from writing megabytes per session.
788
+ * bounded by the current 4096-byte push-provider envelopes; this bounds what
789
+ * sits on disk in the meantime, and keeps one runaway agent from writing
790
+ * megabytes per session.
656
791
  */
657
792
  const MAX_STORED_QUESTION_CHARS = 2000
658
793
 
@@ -950,7 +1085,6 @@ async function prepareQuestionSubmission(
950
1085
  media?: MediaItemT[] | undefined
951
1086
  project?: string | undefined
952
1087
  source?: SourceContextT | undefined
953
- event: string
954
1088
  /** How long the server keeps accepting an answer. */
955
1089
  windowSeconds: number
956
1090
  /** Absolute owner deadline persisted before submit. */
@@ -969,7 +1103,6 @@ async function prepareQuestionSubmission(
969
1103
  {
970
1104
  title: options.title,
971
1105
  body: options.body,
972
- event: options.event,
973
1106
  lifecycle: { tier: 'needs_you' },
974
1107
  ...(options.project !== undefined ? { project: options.project } : {}),
975
1108
  ...(options.media !== undefined ? { media: options.media } : {}),
@@ -1036,6 +1169,10 @@ async function waitForAnyReply(
1036
1169
  let firstPoll = true
1037
1170
  const permanentFailures = new Map<string, string>()
1038
1171
 
1172
+ if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
1173
+ return { byRequest: new Map(), degraded: false, permanentFailures }
1174
+ }
1175
+
1039
1176
  for (;;) {
1040
1177
  const remainingMs = Math.max(0, deadline - ctx.now())
1041
1178
  if (!firstPoll && remainingMs === 0) {
@@ -1089,17 +1226,16 @@ function permanentReplyFailureNote(failures: Map<string, string>): string | null
1089
1226
  return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
1090
1227
  }
1091
1228
 
1092
- /**
1093
- * Healthy companion devices that implement replies. Both the iOS app and the
1094
- * macOS app register reply categories and submit answers directly.
1095
- */
1229
+ /** Healthy Companion devices that advertise the current answer job. */
1096
1230
  async function answerableDevices(ctx: HookContext): Promise<string[]> {
1097
1231
  const configured = ctx.config.devices.value
1098
1232
  const { devices } = await ctx.client.listDevices()
1099
1233
  return devices
1100
1234
  .filter(
1101
1235
  (device) =>
1102
- (device.platform === 'ios' || device.platform === 'macos') &&
1236
+ (device.platform === 'ios' ||
1237
+ device.platform === 'macos' ||
1238
+ device.platform === 'android') &&
1103
1239
  device.registration_healthy &&
1104
1240
  device.capabilities?.includes('answer') === true &&
1105
1241
  device.derived_status !== 'must_update',
@@ -1113,7 +1249,7 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
1113
1249
  * `notifai ask` supersedes the previous question and it is a plain command with
1114
1250
  * no hook payload, no idle probe and nothing to sleep for.
1115
1251
  */
1116
- export type RetireDeps = Pick<HookContext, 'client' | 'config'>
1252
+ export type RetireDeps = Pick<HookContext, 'client' | 'config'> & Pick<Partial<HookContext>, 'log'>
1117
1253
 
1118
1254
  /**
1119
1255
  * Close is a server-side serialization fence: a successful response contains
@@ -1138,60 +1274,22 @@ async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
1138
1274
  }
1139
1275
 
1140
1276
  /**
1141
- * Retire the question on every device it reached. A state change is not news
1142
- * (D-B): the retirement rides as a background state sync — no alert, no
1143
- * sound — carrying the shared collapse key so the companion removes the
1144
- * delivered question and marks it done. If the app cannot run, the stale
1145
- * question simply waits until next open, which beats a tombstone banner
1146
- * announcing what the user just did.
1277
+ * Close the reply window so the server can mint the retirement push. Client
1278
+ * submissions cannot carry `question_retired`; that event is reserved for
1279
+ * server-origin state syncs.
1147
1280
  */
1148
- async function retire(
1281
+ async function proveRetirement(
1149
1282
  ctx: RetireDeps,
1150
- collapseKey: string,
1151
- title: string,
1152
- body: string,
1153
- endState: LifecycleEndState,
1154
- retiresRequestId: string,
1155
- devices?: string[],
1156
- project?: string | undefined,
1157
- source?: SourceContextT | undefined,
1283
+ requestId: string,
1158
1284
  ): Promise<boolean> {
1159
- const build = buildDraft(
1160
- ctx.config,
1161
- {
1162
- title,
1163
- body,
1164
- event: 'question_retired',
1165
- lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
1166
- ...(project !== undefined ? { project } : {}),
1167
- ...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
1168
- collapseKey,
1169
- level: 'passive',
1170
- },
1171
- source === undefined ? {} : { source },
1172
- )
1173
- if (!build.ok) return false
1174
- try {
1175
- await ctx.client.submit(
1176
- { idempotency_key: `retire-${randomBytes(12).toString('base64url')}`, draft: build.draft },
1177
- 0,
1178
- )
1179
- return true
1180
- } catch {
1181
- // Same reasoning as closeQuietly: the window is already closed server-side.
1182
- return false
1183
- }
1184
- }
1185
-
1186
- /**
1187
- * The title a retirement carries. It is never rendered as a banner — the push
1188
- * is `content-available` only — but it is what a companion with no matching
1189
- * history entry has to fall back on, and it is what shows in server-side logs.
1190
- */
1191
- const RETIREMENT_TITLES: Record<LifecycleEndState, string> = {
1192
- answered: 'Answered',
1193
- answered_elsewhere: 'Answered in the terminal',
1194
- expired: 'Question expired',
1285
+ const response = await finalizeReplies(ctx, requestId)
1286
+ const proven = response !== null
1287
+ ctx.log?.info('hook.retirement', {
1288
+ request_id: requestId,
1289
+ attempted: true,
1290
+ proven,
1291
+ })
1292
+ return proven
1195
1293
  }
1196
1294
 
1197
1295
  /**
@@ -1257,13 +1355,42 @@ function retiringQuestion(
1257
1355
  }
1258
1356
  }
1259
1357
 
1260
- function retirementDeviceIds(entry: RetiringQuestion): string[] {
1261
- if (!Array.isArray(entry.device_ids) || entry.device_ids.length === 0) {
1262
- throw new Error(
1263
- `retirement for ${entry.request_id} is missing its device identifiers; refusing to re-resolve routing`,
1264
- )
1265
- }
1266
- return entry.device_ids
1358
+ const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay'])
1359
+
1360
+ function normalizePrompt(value: string): string {
1361
+ return value.trim().replace(/\s+/g, ' ').toLowerCase()
1362
+ }
1363
+
1364
+ function pendingChoiceLabels(pending: PendingQuestion): string[] {
1365
+ const fromQuestions = (pending.questions ?? []).flatMap((question) =>
1366
+ (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]),
1367
+ )
1368
+ return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0)
1369
+ }
1370
+
1371
+ function pendingHasMatchingChoice(pending: PendingQuestion, normalizedPrompt: string): boolean {
1372
+ return pendingChoiceLabels(pending).includes(normalizedPrompt)
1373
+ }
1374
+
1375
+ /**
1376
+ * Which outstanding questions this prompt plausibly answers.
1377
+ *
1378
+ * A unique closed-choice match is enough. A short confirmation such as "done"
1379
+ * matches only when exactly one question is outstanding. Long unrelated
1380
+ * prompts leave every question open for the reply window.
1381
+ */
1382
+ export function pendingAnsweredByPrompt(
1383
+ prompt: string | undefined,
1384
+ pending: readonly PendingQuestion[],
1385
+ ): PendingQuestion[] {
1386
+ const normalized = normalizePrompt(prompt ?? '')
1387
+ if (normalized.length === 0 || pending.length === 0) return []
1388
+
1389
+ const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized))
1390
+ if (labelled.length === 1) return labelled
1391
+ if (labelled.length > 1) return []
1392
+ if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized)) return [...pending]
1393
+ return []
1267
1394
  }
1268
1395
 
1269
1396
  /**
@@ -1281,19 +1408,8 @@ export async function drainRetirements(
1281
1408
 
1282
1409
  const retired: string[] = []
1283
1410
  for (const entry of queue) {
1284
- await closeQuietly(ctx, entry.request_id)
1285
- const sent = await retire(
1286
- ctx,
1287
- entry.collapse_key,
1288
- RETIREMENT_TITLES[entry.state],
1289
- entry.question,
1290
- entry.state,
1291
- entry.request_id,
1292
- retirementDeviceIds(entry),
1293
- entry.project,
1294
- entry.source,
1295
- )
1296
- if (sent) {
1411
+ const proven = await proveRetirement(ctx, entry.request_id)
1412
+ if (proven) {
1297
1413
  retired.push(entry.request_id)
1298
1414
  // Persist each success before touching the next entry. A later corrupt
1299
1415
  // record or interrupted process must not resurrect work already done.
@@ -1380,19 +1496,8 @@ export async function drainOrphanRetirements(
1380
1496
  )
1381
1497
  continue
1382
1498
  }
1383
- await closeQuietly(ctx, entry.request_id)
1384
- const sent = await retire(
1385
- ctx,
1386
- entry.collapse_key,
1387
- RETIREMENT_TITLES[entry.state],
1388
- entry.question,
1389
- entry.state,
1390
- entry.request_id,
1391
- retirementDeviceIds(entry),
1392
- entry.project,
1393
- entry.source,
1394
- )
1395
- if (sent) {
1499
+ const proven = await proveRetirement(ctx, entry.request_id)
1500
+ if (proven) {
1396
1501
  done.push(entry.request_id)
1397
1502
  updateOrphanQueue(env, (current) =>
1398
1503
  current.filter((candidate) => candidate.request_id !== entry.request_id),
@@ -1484,10 +1589,15 @@ async function finalizePendings(
1484
1589
  pending: PendingQuestion[],
1485
1590
  ): Promise<FinalizedPending[]> {
1486
1591
  return Promise.all(
1487
- pending.map(async (entry) => ({
1488
- pending: entry,
1489
- response: await finalizeReplies(ctx, entry.request_id!),
1490
- })),
1592
+ pending.map(async (entry) => {
1593
+ const response = await finalizeReplies(ctx, entry.request_id!)
1594
+ ctx.log?.info('hook.retirement', {
1595
+ request_id: entry.request_id,
1596
+ attempted: true,
1597
+ proven: response !== null,
1598
+ })
1599
+ return { pending: entry, response }
1600
+ }),
1491
1601
  )
1492
1602
  }
1493
1603
 
@@ -1521,7 +1631,7 @@ function reportAnswer(
1521
1631
  ...(late ? { late: true } : {}),
1522
1632
  request_id: answered.pending.request_id,
1523
1633
  device: answered.reply.device_name,
1524
- text: answered.reply.text,
1634
+ text_chars: answered.reply.text.length,
1525
1635
  })
1526
1636
  notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
1527
1637
  }
@@ -1579,7 +1689,7 @@ function isTerminalDraftRejection(err: ApiCallError): boolean {
1579
1689
  return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
1580
1690
  }
1581
1691
 
1582
- function dropPendingQuestion(
1692
+ export function dropPendingQuestion(
1583
1693
  sessionId: string,
1584
1694
  env: NodeJS.ProcessEnv,
1585
1695
  entry: PendingQuestion,
@@ -1593,6 +1703,29 @@ function dropPendingQuestion(
1593
1703
  })
1594
1704
  }
1595
1705
 
1706
+ /**
1707
+ * Drop registrations that never reached a device, so a later Stop cannot push
1708
+ * them. A retirement push would be noise: there is nothing on any device to
1709
+ * withdraw. Returns the withdrawn entries in registration order.
1710
+ */
1711
+ export function withdrawUnpushedQuestions(
1712
+ sessionId: string,
1713
+ env: NodeJS.ProcessEnv,
1714
+ ): PendingQuestion[] {
1715
+ let withdrawn: PendingQuestion[] = []
1716
+ updateSessionState(sessionId, env, (current) => {
1717
+ const pending = pendingList(current)
1718
+ withdrawn = pending.filter((entry) => entry.request_id === undefined)
1719
+ if (withdrawn.length === 0) return current
1720
+ const remaining = pending.filter((entry) => entry.request_id !== undefined)
1721
+ const next: SessionState = { ...current }
1722
+ if (remaining.length > 0) next.pending = remaining
1723
+ else delete next.pending
1724
+ return next
1725
+ })
1726
+ return withdrawn
1727
+ }
1728
+
1596
1729
  function clearFrozenSubmission(
1597
1730
  sessionId: string,
1598
1731
  env: NodeJS.ProcessEnv,
@@ -1992,10 +2125,9 @@ async function retirePendings(
1992
2125
  // ---------------------------------------------------------------------------
1993
2126
 
1994
2127
  /**
1995
- * Records presence and retires anything still asking on companion devices. This is the
1996
- * "answered in the terminal" case from the original design: we cannot tell
1997
- * whether the new prompt answers the question, but we do not need to — the
1998
- * user being here is what makes the notification noise.
2128
+ * Records presence. Retires a live question only when this prompt plausibly
2129
+ * answers it. Typing in the terminal is not by itself a resolution: the User
2130
+ * may be answering a different question on a device.
1999
2131
  */
2000
2132
  export async function handleUserPromptSubmit(
2001
2133
  ctx: HookContext,
@@ -2070,13 +2202,26 @@ export async function handleUserPromptSubmit(
2070
2202
  const permanentFailure = permanentReplyFailureNote(permanentFailures)
2071
2203
  if (permanentFailure !== null) notes.push(permanentFailure)
2072
2204
  }
2205
+ const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state))
2206
+ for (const entry of matched) {
2207
+ ctx.log?.info('hook.retirement', {
2208
+ request_id: entry.request_id,
2209
+ question_id: entry.question_id,
2210
+ reason: 'prompt-matched',
2211
+ attempted: entry.request_id !== undefined,
2212
+ proven: false,
2213
+ })
2214
+ }
2073
2215
  // Park before dropping `pending`. If the process dies between these writes,
2074
2216
  // the next hook sees both copies and dedupes them; the old order could die in
2075
2217
  // the gap after erasing the only request/collapse/device identifiers.
2076
2218
  updateSessionState(sessionId, ctx.env, (current) => {
2077
2219
  const retiring = [...(current.retiring ?? [])]
2078
- const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
2079
- for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
2220
+ const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current))
2221
+ const unmatched = pendingList(current).filter(
2222
+ (entry) => !matchedNow.some((item) => isSamePending(item, entry)),
2223
+ )
2224
+ for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
2080
2225
  const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
2081
2226
  if (
2082
2227
  retirement !== null &&
@@ -2085,11 +2230,17 @@ export async function handleUserPromptSubmit(
2085
2230
  retiring.push(retirement)
2086
2231
  }
2087
2232
  }
2233
+ const unasked = unmatched.filter((entry) => entry.request_id === undefined)
2234
+ const stillLive = unmatched.filter((entry) => entry.request_id !== undefined)
2235
+ const remaining = [...stillLive, ...unasked]
2088
2236
 
2089
2237
  // Start from the whole document so fields introduced by a newer CLI remain
2090
2238
  // attached while this older writer applies the prompt transition it knows.
2091
- const next: SessionState = { ...current, last_prompt_at: ctx.now() }
2092
- if (unasked.length > 0) next.pending = unasked
2239
+ const next: SessionState = {
2240
+ ...current,
2241
+ last_prompt_at: ctx.now(),
2242
+ }
2243
+ if (remaining.length > 0) next.pending = remaining
2093
2244
  else delete next.pending
2094
2245
  if (retiring.length > 0) next.retiring = retiring
2095
2246
  else delete next.retiring
@@ -2428,6 +2579,11 @@ async function deliverAcceptedAnswers(
2428
2579
  return outcome
2429
2580
  }
2430
2581
 
2582
+ /** True while this Stop owner is still allowed a direct-wake wait. */
2583
+ function ownerLeaseActive(entry: PendingQuestion, now: number): boolean {
2584
+ return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now
2585
+ }
2586
+
2431
2587
  /** The complete per-session Stop decision, after this process owns the claim. */
2432
2588
  async function handleClaimedStop(
2433
2589
  ctx: HookContext,
@@ -2515,26 +2671,44 @@ async function handleClaimedStop(
2515
2671
  const recoverableLive = live.filter(
2516
2672
  (entry) => !permanentFailures.has(entry.request_id!),
2517
2673
  )
2518
- liveToEscalate = recoverableLive
2674
+ // A spent owner has already used its waiter ceiling. Passing those
2675
+ // questions back into `escalate` starts a fresh ceiling, holds the
2676
+ // per-session claim for another 3300s, and is why a later `ask` never
2677
+ // reached a device after a degraded waiter (the next Stop never ran, or
2678
+ // ran as claimed-elsewhere). Late answers still ride the short poll above
2679
+ // and every later turn. Independent questions whose lease is still running
2680
+ // keep their successor wait.
2681
+ const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
2682
+ liveToEscalate = stillOwned
2519
2683
  if (unasked.length === 0) {
2520
2684
  if (recoverableLive.length === 0) return { notes }
2521
- // A previous answer may have resumed the model while independent
2522
- // questions remain. The next Stop is their successor owner: keep waiting
2523
- // to the original absolute deadline instead of polling for four seconds
2524
- // and abandoning them.
2685
+ if (stillOwned.length === 0) {
2686
+ const requestIdSummary = summarizeRequestIds(recoverableLive)
2687
+ ctx.log?.info('hook.answer', {
2688
+ answered: false,
2689
+ stage: 'queued',
2690
+ request_ids: requestIdSummary.ids,
2691
+ owner_spent: true,
2692
+ })
2693
+ notes.push(
2694
+ `${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still answerable; the previous waiter already used its ceiling, so a later turn will collect a late answer rather than waiting again`,
2695
+ )
2696
+ return { notes }
2697
+ }
2525
2698
  return await escalate(
2526
2699
  ctx,
2527
2700
  envelope,
2528
2701
  sessionId,
2529
2702
  [],
2530
- recoverableLive,
2703
+ stillOwned,
2531
2704
  notes,
2532
2705
  hardDeadlineAt,
2533
2706
  route,
2534
2707
  )
2535
2708
  }
2536
2709
  // Questions registered after the earlier push still owe the user their
2537
- // notification; fall through and escalate just those.
2710
+ // notification; fall through and escalate just those, plus any live
2711
+ // question whose owner lease is still running.
2538
2712
  }
2539
2713
 
2540
2714
  // A Stop answer may immediately produce a legitimate follow-up question.
@@ -2672,7 +2846,6 @@ async function escalate(
2672
2846
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2673
2847
  ...(entry.project !== undefined ? { project: entry.project } : {}),
2674
2848
  ...(eventSource !== undefined ? { source: eventSource } : {}),
2675
- event: 'agent_question',
2676
2849
  windowSeconds: replyWindowSeconds,
2677
2850
  ownerDeadlineAt,
2678
2851
  })
@@ -2751,7 +2924,6 @@ async function escalate(
2751
2924
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2752
2925
  ...(entry.project !== undefined ? { project: entry.project } : {}),
2753
2926
  ...(eventSource !== undefined ? { source: eventSource } : {}),
2754
- event: 'agent_question',
2755
2927
  windowSeconds: replyWindowSeconds,
2756
2928
  ownerDeadlineAt,
2757
2929
  })
@@ -2821,7 +2993,7 @@ async function escalate(
2821
2993
  request_id: intent.request_id,
2822
2994
  devices: intent.device_ids.length,
2823
2995
  questions: questions.length,
2824
- text: questions[0]!.text,
2996
+ text_chars: questions[0]!.text.length,
2825
2997
  })
2826
2998
  }
2827
2999
  const committedReplyDeadline =
@@ -2841,14 +3013,14 @@ async function escalate(
2841
3013
  // stale about eight minutes later and retired, so the answer given over
2842
3014
  // lunch met a closed question.
2843
3015
  //
2844
- // Both consumers of this field already clamp to the owner's own ceiling
2845
- // (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
2846
- // `Math.min(ceilingAt, …)` when deriving the wait), so recording the
2847
- // real expiry here lengthens what the user gets without lengthening what
2848
- // this process waits.
3016
+ // The reply window is how long the server accepts an answer. This
3017
+ // process still clamps its own wait to `hardDeadlineAt`. Recording the
3018
+ // owner lease separately is what stops the *next* Stop from treating
3019
+ // the remaining window as a fresh waiter ceiling.
2849
3020
  reply_deadline_at: Number.isFinite(committedReplyDeadline)
2850
3021
  ? committedReplyDeadline
2851
3022
  : intent.owner_deadline_at,
3023
+ owner_deadline_at: intent.owner_deadline_at,
2852
3024
  }
2853
3025
  if (intent.draft.source === undefined) delete live.source
2854
3026
  delete live.submission
@@ -2927,7 +3099,8 @@ async function escalate(
2927
3099
  ceilingAt,
2928
3100
  Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
2929
3101
  )
2930
- const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
3102
+ const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000
3103
+ const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000))
2931
3104
  const waited = await waitForAnyReply(
2932
3105
  ctx,
2933
3106
  waitingOn.map((entry) => entry.request_id!),
@@ -2955,7 +3128,7 @@ async function escalate(
2955
3128
  const expired = waitingOn.filter(
2956
3129
  (entry) =>
2957
3130
  entry.reply_deadline_at === undefined ||
2958
- entry.reply_deadline_at <= ctx.now() ||
3131
+ entry.reply_deadline_at <= ownerDeadline ||
2959
3132
  // A permanent rejection is the server saying this question can never be
2960
3133
  // answered, whatever its window says. Leaving it open would strand a
2961
3134
  // dead question on the user's devices.
@@ -3001,10 +3174,10 @@ async function escalate(
3001
3174
  permanent_failures: waited.permanentFailures.size,
3002
3175
  waited_seconds: timeoutSeconds,
3003
3176
  })
3004
- if (waited.permanentFailures.size === 0) {
3177
+ if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
3005
3178
  notes.push(
3006
3179
  waited.degraded
3007
- ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
3180
+ ? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
3008
3181
  : 'no answer in time; the question expired with its continuation owner',
3009
3182
  )
3010
3183
  }
@@ -3159,8 +3332,9 @@ export function registerQuestion(
3159
3332
  env: NodeJS.ProcessEnv,
3160
3333
  question: PendingQuestion,
3161
3334
  now: number = Date.now(),
3162
- ): void {
3335
+ ): string {
3163
3336
  let full: 'unasked' | 'live' | null = null
3337
+ const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`
3164
3338
  updateSessionState(sessionId, env, (state) => {
3165
3339
  const pending = pendingList(state)
3166
3340
  const unasked = pending.filter((entry) => entry.request_id === undefined)
@@ -3177,9 +3351,9 @@ export function registerQuestion(
3177
3351
  pending: [
3178
3352
  ...pending,
3179
3353
  {
3180
- question_id: `q_${randomBytes(12).toString('base64url')}`,
3181
3354
  asked_at: now,
3182
3355
  ...question,
3356
+ question_id: questionId,
3183
3357
  question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
3184
3358
  },
3185
3359
  ],
@@ -3194,9 +3368,10 @@ export function registerQuestion(
3194
3368
  if (full === 'live') {
3195
3369
  throw new Error(
3196
3370
  `${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
3197
- 'Retire the ones you no longer need with `notifai close <request_id>` before asking another.',
3371
+ 'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.',
3198
3372
  )
3199
3373
  }
3374
+ return questionId
3200
3375
  }
3201
3376
 
3202
3377
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */