@raidiant/notifai 8.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +48 -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/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. 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,24 @@ 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
+ /** Harness that owns this exact lifecycle state. */
90
+ harness?: HookHarness
91
+ /** Checkout whose hook definition activated this session; lifecycle diagnostics only. */
92
+ activation_cwd?: string
93
+ /** Model-visible proactive context already emitted for this harness session. */
94
+ activation_context_emitted_at?: number
95
+ /** Harness process that received it; a resumed session has a new owner. */
96
+ activation_context_owner_pid?: number
97
+ /** Cursor's documented session context path is lossy; bounded first-Stop fallback journal. */
98
+ cursor_activation_claimed_at?: number
99
+ cursor_activation_confirmed_at?: number
83
100
  /** Epoch ms of the user's last prompt in this session — our presence signal. */
84
101
  last_prompt_at?: number
85
102
  /** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
@@ -239,6 +256,15 @@ export interface PendingQuestion {
239
256
  device_ids?: string[]
240
257
  /** Absolute end of the server reply window and its local continuation owner. */
241
258
  reply_deadline_at?: number
259
+ /**
260
+ * When this Stop owner stops listening for a direct wake. Distinct from
261
+ * `reply_deadline_at`: the server may still accept an answer for a day after
262
+ * this owner has returned. A later Stop that treats the reply window as a
263
+ * fresh waiter ceiling monopolizes the claim and skips newly registered
264
+ * questions. Absent on state written before this field existed — treated as
265
+ * already spent, because that is the shape a degraded waiter left behind.
266
+ */
267
+ owner_deadline_at?: number
242
268
  /** Frozen before the first network byte, so an ambiguous submit is replayable. */
243
269
  submission?: PendingSubmissionIntent
244
270
  }
@@ -319,6 +345,14 @@ export function waiterCeilingSeconds(detached: boolean): number {
319
345
  */
320
346
  export const MIN_REPLY_WINDOW_SECONDS = 60
321
347
 
348
+ /**
349
+ * HTTP budget reserved for the waiter's final reply fetch and close fence.
350
+ * Taken from the owner deadline rather than added after it, so the hook does
351
+ * not block past its ceiling. A near-zero remainder used to produce a
352
+ * misleading 0s timeout and an unproven retirement.
353
+ */
354
+ export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
355
+
322
356
  /**
323
357
  * The terminal-first wait: the question sits in the terminal for
324
358
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -348,8 +382,10 @@ async function awaitTerminalFirstWindow(
348
382
  }
349
383
 
350
384
  export interface HookOutcome {
351
- /** Written to stdout verbatim — the harness parses this as the decision. */
385
+ /** Written to stdout verbatim — the harness parses this as output. */
352
386
  stdout?: string
387
+ /** Whether stdout takes over the turn, rather than adding prompt context. */
388
+ decided?: boolean
353
389
  /** Diagnostics; harnesses surface hook stderr in the transcript. */
354
390
  notes: string[]
355
391
  /** Structured lifecycle detail that belongs on hook.end without user text. */
@@ -418,7 +454,10 @@ export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): Ses
418
454
  if (!existsSync(file)) return {}
419
455
  try {
420
456
  const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
421
- return typeof parsed === 'object' && parsed !== null ? (parsed as SessionState) : {}
457
+ if (typeof parsed !== 'object' || parsed === null) return {}
458
+ const state = { ...(parsed as SessionState & { session_id?: unknown }) }
459
+ delete state.session_id
460
+ return state
422
461
  } catch {
423
462
  // A corrupt marker must not wedge the harness; treat it as "no evidence",
424
463
  // which fails closed onto ordinary terminal behaviour.
@@ -426,13 +465,59 @@ export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): Ses
426
465
  }
427
466
  }
428
467
 
468
+ /**
469
+ * Resolve a stable question or request id to the one exact session that owns it.
470
+ *
471
+ * This deliberately searches lifecycle state, not checkout pointers: question
472
+ * identity is global on this machine, while a linked worktree is only where a
473
+ * command happened to run. Ambiguous or corrupt matches fail closed.
474
+ */
475
+ export function findOwningSession(
476
+ id: string,
477
+ env: NodeJS.ProcessEnv,
478
+ ): { sessionId: string | null; ambiguous: boolean } {
479
+ const directory = path.join(stateDir(env), 'sessions')
480
+ if (!existsSync(directory)) return { sessionId: null, ambiguous: false }
481
+ const matches: string[] = []
482
+ for (const name of readdirSync(directory)) {
483
+ if (!name.endsWith('.json')) continue
484
+ let persisted: (SessionState & { session_id?: unknown }) | null = null
485
+ try {
486
+ const parsed: unknown = JSON.parse(readFileSync(path.join(directory, name), 'utf8'))
487
+ if (typeof parsed === 'object' && parsed !== null) {
488
+ persisted = parsed as SessionState & { session_id?: unknown }
489
+ }
490
+ } catch {
491
+ // Corrupt lifecycle state is not identity evidence.
492
+ }
493
+ if (persisted === null || typeof persisted.session_id !== 'string') continue
494
+ const sessionId = persisted.session_id
495
+ const state = persisted
496
+ const pendingMatch = pendingList(state).some(
497
+ (entry) => entry.question_id === id || entry.request_id === id,
498
+ )
499
+ const retiringMatch = (state.retiring ?? []).some((entry) => entry.request_id === id)
500
+ const acknowledgementMatch = (state.acknowledgement_due ?? []).some(
501
+ (entry) => entry.request_id === id,
502
+ )
503
+ const acceptedMatch = state.accepted?.answers.some(
504
+ ({ pending }) => pending.question_id === id || pending.request_id === id,
505
+ ) ?? false
506
+ if (pendingMatch || retiringMatch || acknowledgementMatch || acceptedMatch) {
507
+ matches.push(sessionId)
508
+ if (matches.length > 1) return { sessionId: null, ambiguous: true }
509
+ }
510
+ }
511
+ return { sessionId: matches[0] ?? null, ambiguous: false }
512
+ }
513
+
429
514
  export function writeSessionState(
430
515
  sessionId: string,
431
516
  env: NodeJS.ProcessEnv,
432
517
  state: SessionState,
433
518
  ): void {
434
519
  const file = sessionStatePath(sessionId, env)
435
- withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state))
520
+ withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, sessionId, state))
436
521
  }
437
522
 
438
523
  export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
@@ -445,8 +530,8 @@ export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): vo
445
530
  })
446
531
  }
447
532
 
448
- function writeSessionStateUnlocked(file: string, state: SessionState): void {
449
- atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
533
+ function writeSessionStateUnlocked(file: string, sessionId: string, state: SessionState): void {
534
+ atomicWriteFileSync(file, `${JSON.stringify({ ...state, session_id: sessionId }, null, 2)}\n`)
450
535
  }
451
536
 
452
537
  function updateSessionState(
@@ -457,7 +542,117 @@ function updateSessionState(
457
542
  const file = sessionStatePath(sessionId, env)
458
543
  return withFileLock(`${file}.lock`, () => {
459
544
  const next = update(readSessionState(sessionId, env))
460
- writeSessionStateUnlocked(file, next)
545
+ writeSessionStateUnlocked(file, sessionId, next)
546
+ return next
547
+ })
548
+ }
549
+
550
+ /** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
551
+ export function markSessionActivation(
552
+ sessionId: string,
553
+ env: NodeJS.ProcessEnv,
554
+ now: number,
555
+ ownerPid: number,
556
+ harness?: HookHarness,
557
+ cwd?: string,
558
+ ): void {
559
+ updateSessionState(sessionId, env, (current) => ({
560
+ ...current,
561
+ ...(harness === undefined ? {} : { harness }),
562
+ ...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
563
+ activation_context_emitted_at: now,
564
+ activation_context_owner_pid: ownerPid,
565
+ }))
566
+ }
567
+
568
+ /**
569
+ * Atomically own first-prompt activation only when no lifecycle hook did.
570
+ *
571
+ * Some managed hosts preserve UserPromptSubmit while regenerating away newer
572
+ * SessionStart definitions. This is a compatibility seam, not the primary
573
+ * lifecycle: one session gets at most one model-visible activation from it.
574
+ */
575
+ export function claimPromptActivation(
576
+ sessionId: string,
577
+ env: NodeJS.ProcessEnv,
578
+ now: number,
579
+ ownerPid: number,
580
+ harness?: HookHarness,
581
+ cwd?: string,
582
+ ): boolean {
583
+ let claimed = false
584
+ updateSessionState(sessionId, env, (current) => {
585
+ if (
586
+ current.activation_context_emitted_at !== undefined &&
587
+ current.activation_context_owner_pid === ownerPid
588
+ ) {
589
+ return current
590
+ }
591
+ claimed = true
592
+ return {
593
+ ...current,
594
+ ...(harness === undefined ? {} : { harness }),
595
+ ...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
596
+ activation_context_emitted_at: now,
597
+ activation_context_owner_pid: ownerPid,
598
+ }
599
+ })
600
+ return claimed
601
+ }
602
+
603
+ /** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
604
+ export function claimCursorStopActivation(
605
+ sessionId: string,
606
+ env: NodeJS.ProcessEnv,
607
+ now: number,
608
+ ): boolean {
609
+ const claimGuardMs = 30_000
610
+ let claimed = false
611
+ updateSessionState(sessionId, env, (current) => {
612
+ if (current.cursor_activation_confirmed_at !== undefined) return current
613
+ if (
614
+ current.cursor_activation_claimed_at !== undefined &&
615
+ now - current.cursor_activation_claimed_at < claimGuardMs
616
+ ) {
617
+ return current
618
+ }
619
+ claimed = true
620
+ return { ...current, cursor_activation_claimed_at: now }
621
+ })
622
+ return claimed
623
+ }
624
+
625
+ /** Cursor's loop_count proves the previous Stop follow-up reached the host. */
626
+ export function confirmCursorStopActivation(
627
+ sessionId: string,
628
+ env: NodeJS.ProcessEnv,
629
+ now: number,
630
+ ): boolean {
631
+ let owned = false
632
+ updateSessionState(sessionId, env, (current) => {
633
+ if (current.cursor_activation_confirmed_at !== undefined) {
634
+ owned = true
635
+ return current
636
+ }
637
+ if (current.cursor_activation_claimed_at === undefined) return current
638
+ owned = true
639
+ return { ...current, cursor_activation_confirmed_at: now }
640
+ })
641
+ return owned
642
+ }
643
+
644
+ /** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
645
+ export function resetCursorStopActivation(sessionId: string, env: NodeJS.ProcessEnv): void {
646
+ updateSessionState(sessionId, env, (current) => {
647
+ if (
648
+ current.cursor_activation_claimed_at === undefined &&
649
+ current.cursor_activation_confirmed_at === undefined
650
+ ) {
651
+ return current
652
+ }
653
+ const next = { ...current }
654
+ delete next.cursor_activation_claimed_at
655
+ delete next.cursor_activation_confirmed_at
461
656
  return next
462
657
  })
463
658
  }
@@ -951,7 +1146,6 @@ async function prepareQuestionSubmission(
951
1146
  media?: MediaItemT[] | undefined
952
1147
  project?: string | undefined
953
1148
  source?: SourceContextT | undefined
954
- event: string
955
1149
  /** How long the server keeps accepting an answer. */
956
1150
  windowSeconds: number
957
1151
  /** Absolute owner deadline persisted before submit. */
@@ -970,7 +1164,6 @@ async function prepareQuestionSubmission(
970
1164
  {
971
1165
  title: options.title,
972
1166
  body: options.body,
973
- event: options.event,
974
1167
  lifecycle: { tier: 'needs_you' },
975
1168
  ...(options.project !== undefined ? { project: options.project } : {}),
976
1169
  ...(options.media !== undefined ? { media: options.media } : {}),
@@ -1037,6 +1230,10 @@ async function waitForAnyReply(
1037
1230
  let firstPoll = true
1038
1231
  const permanentFailures = new Map<string, string>()
1039
1232
 
1233
+ if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
1234
+ return { byRequest: new Map(), degraded: false, permanentFailures }
1235
+ }
1236
+
1040
1237
  for (;;) {
1041
1238
  const remainingMs = Math.max(0, deadline - ctx.now())
1042
1239
  if (!firstPoll && remainingMs === 0) {
@@ -1113,7 +1310,7 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
1113
1310
  * `notifai ask` supersedes the previous question and it is a plain command with
1114
1311
  * no hook payload, no idle probe and nothing to sleep for.
1115
1312
  */
1116
- export type RetireDeps = Pick<HookContext, 'client' | 'config'>
1313
+ export type RetireDeps = Pick<HookContext, 'client' | 'config'> & Pick<Partial<HookContext>, 'log'>
1117
1314
 
1118
1315
  /**
1119
1316
  * Close is a server-side serialization fence: a successful response contains
@@ -1138,60 +1335,22 @@ async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
1138
1335
  }
1139
1336
 
1140
1337
  /**
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.
1338
+ * Close the reply window so the server can mint the retirement push. Client
1339
+ * submissions cannot carry `question_retired`; that event is reserved for
1340
+ * server-origin state syncs.
1147
1341
  */
1148
- async function retire(
1342
+ async function proveRetirement(
1149
1343
  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,
1344
+ requestId: string,
1158
1345
  ): 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',
1346
+ const response = await finalizeReplies(ctx, requestId)
1347
+ const proven = response !== null
1348
+ ctx.log?.info('hook.retirement', {
1349
+ request_id: requestId,
1350
+ attempted: true,
1351
+ proven,
1352
+ })
1353
+ return proven
1195
1354
  }
1196
1355
 
1197
1356
  /**
@@ -1257,13 +1416,42 @@ function retiringQuestion(
1257
1416
  }
1258
1417
  }
1259
1418
 
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
1419
+ const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay'])
1420
+
1421
+ function normalizePrompt(value: string): string {
1422
+ return value.trim().replace(/\s+/g, ' ').toLowerCase()
1423
+ }
1424
+
1425
+ function pendingChoiceLabels(pending: PendingQuestion): string[] {
1426
+ const fromQuestions = (pending.questions ?? []).flatMap((question) =>
1427
+ (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]),
1428
+ )
1429
+ return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0)
1430
+ }
1431
+
1432
+ function pendingHasMatchingChoice(pending: PendingQuestion, normalizedPrompt: string): boolean {
1433
+ return pendingChoiceLabels(pending).includes(normalizedPrompt)
1434
+ }
1435
+
1436
+ /**
1437
+ * Which outstanding questions this prompt plausibly answers.
1438
+ *
1439
+ * A unique closed-choice match is enough. A short confirmation such as "done"
1440
+ * matches only when exactly one question is outstanding. Long unrelated
1441
+ * prompts leave every question open for the reply window.
1442
+ */
1443
+ export function pendingAnsweredByPrompt(
1444
+ prompt: string | undefined,
1445
+ pending: readonly PendingQuestion[],
1446
+ ): PendingQuestion[] {
1447
+ const normalized = normalizePrompt(prompt ?? '')
1448
+ if (normalized.length === 0 || pending.length === 0) return []
1449
+
1450
+ const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized))
1451
+ if (labelled.length === 1) return labelled
1452
+ if (labelled.length > 1) return []
1453
+ if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized)) return [...pending]
1454
+ return []
1267
1455
  }
1268
1456
 
1269
1457
  /**
@@ -1281,19 +1469,8 @@ export async function drainRetirements(
1281
1469
 
1282
1470
  const retired: string[] = []
1283
1471
  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) {
1472
+ const proven = await proveRetirement(ctx, entry.request_id)
1473
+ if (proven) {
1297
1474
  retired.push(entry.request_id)
1298
1475
  // Persist each success before touching the next entry. A later corrupt
1299
1476
  // record or interrupted process must not resurrect work already done.
@@ -1380,19 +1557,8 @@ export async function drainOrphanRetirements(
1380
1557
  )
1381
1558
  continue
1382
1559
  }
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) {
1560
+ const proven = await proveRetirement(ctx, entry.request_id)
1561
+ if (proven) {
1396
1562
  done.push(entry.request_id)
1397
1563
  updateOrphanQueue(env, (current) =>
1398
1564
  current.filter((candidate) => candidate.request_id !== entry.request_id),
@@ -1484,10 +1650,15 @@ async function finalizePendings(
1484
1650
  pending: PendingQuestion[],
1485
1651
  ): Promise<FinalizedPending[]> {
1486
1652
  return Promise.all(
1487
- pending.map(async (entry) => ({
1488
- pending: entry,
1489
- response: await finalizeReplies(ctx, entry.request_id!),
1490
- })),
1653
+ pending.map(async (entry) => {
1654
+ const response = await finalizeReplies(ctx, entry.request_id!)
1655
+ ctx.log?.info('hook.retirement', {
1656
+ request_id: entry.request_id,
1657
+ attempted: true,
1658
+ proven: response !== null,
1659
+ })
1660
+ return { pending: entry, response }
1661
+ }),
1491
1662
  )
1492
1663
  }
1493
1664
 
@@ -1521,7 +1692,7 @@ function reportAnswer(
1521
1692
  ...(late ? { late: true } : {}),
1522
1693
  request_id: answered.pending.request_id,
1523
1694
  device: answered.reply.device_name,
1524
- text: answered.reply.text,
1695
+ text_chars: answered.reply.text.length,
1525
1696
  })
1526
1697
  notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
1527
1698
  }
@@ -1579,7 +1750,7 @@ function isTerminalDraftRejection(err: ApiCallError): boolean {
1579
1750
  return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
1580
1751
  }
1581
1752
 
1582
- function dropPendingQuestion(
1753
+ export function dropPendingQuestion(
1583
1754
  sessionId: string,
1584
1755
  env: NodeJS.ProcessEnv,
1585
1756
  entry: PendingQuestion,
@@ -1593,6 +1764,29 @@ function dropPendingQuestion(
1593
1764
  })
1594
1765
  }
1595
1766
 
1767
+ /**
1768
+ * Drop registrations that never reached a device, so a later Stop cannot push
1769
+ * them. A retirement push would be noise: there is nothing on any device to
1770
+ * withdraw. Returns the withdrawn entries in registration order.
1771
+ */
1772
+ export function withdrawUnpushedQuestions(
1773
+ sessionId: string,
1774
+ env: NodeJS.ProcessEnv,
1775
+ ): PendingQuestion[] {
1776
+ let withdrawn: PendingQuestion[] = []
1777
+ updateSessionState(sessionId, env, (current) => {
1778
+ const pending = pendingList(current)
1779
+ withdrawn = pending.filter((entry) => entry.request_id === undefined)
1780
+ if (withdrawn.length === 0) return current
1781
+ const remaining = pending.filter((entry) => entry.request_id !== undefined)
1782
+ const next: SessionState = { ...current }
1783
+ if (remaining.length > 0) next.pending = remaining
1784
+ else delete next.pending
1785
+ return next
1786
+ })
1787
+ return withdrawn
1788
+ }
1789
+
1596
1790
  function clearFrozenSubmission(
1597
1791
  sessionId: string,
1598
1792
  env: NodeJS.ProcessEnv,
@@ -1992,10 +2186,9 @@ async function retirePendings(
1992
2186
  // ---------------------------------------------------------------------------
1993
2187
 
1994
2188
  /**
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.
2189
+ * Records presence. Retires a live question only when this prompt plausibly
2190
+ * answers it. Typing in the terminal is not by itself a resolution: the User
2191
+ * may be answering a different question on a device.
1999
2192
  */
2000
2193
  export async function handleUserPromptSubmit(
2001
2194
  ctx: HookContext,
@@ -2009,6 +2202,7 @@ export async function handleUserPromptSubmit(
2009
2202
  if (state.accepted !== undefined) {
2010
2203
  updateSessionState(sessionId, ctx.env, (current) => ({
2011
2204
  ...current,
2205
+ ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2012
2206
  last_prompt_at: ctx.now(),
2013
2207
  }))
2014
2208
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
@@ -2070,13 +2264,26 @@ export async function handleUserPromptSubmit(
2070
2264
  const permanentFailure = permanentReplyFailureNote(permanentFailures)
2071
2265
  if (permanentFailure !== null) notes.push(permanentFailure)
2072
2266
  }
2267
+ const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state))
2268
+ for (const entry of matched) {
2269
+ ctx.log?.info('hook.retirement', {
2270
+ request_id: entry.request_id,
2271
+ question_id: entry.question_id,
2272
+ reason: 'prompt-matched',
2273
+ attempted: entry.request_id !== undefined,
2274
+ proven: false,
2275
+ })
2276
+ }
2073
2277
  // Park before dropping `pending`. If the process dies between these writes,
2074
2278
  // the next hook sees both copies and dedupes them; the old order could die in
2075
2279
  // the gap after erasing the only request/collapse/device identifiers.
2076
2280
  updateSessionState(sessionId, ctx.env, (current) => {
2077
2281
  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)) {
2282
+ const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current))
2283
+ const unmatched = pendingList(current).filter(
2284
+ (entry) => !matchedNow.some((item) => isSamePending(item, entry)),
2285
+ )
2286
+ for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
2080
2287
  const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
2081
2288
  if (
2082
2289
  retirement !== null &&
@@ -2085,11 +2292,18 @@ export async function handleUserPromptSubmit(
2085
2292
  retiring.push(retirement)
2086
2293
  }
2087
2294
  }
2295
+ const unasked = unmatched.filter((entry) => entry.request_id === undefined)
2296
+ const stillLive = unmatched.filter((entry) => entry.request_id !== undefined)
2297
+ const remaining = [...stillLive, ...unasked]
2088
2298
 
2089
2299
  // Start from the whole document so fields introduced by a newer CLI remain
2090
2300
  // 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
2301
+ const next: SessionState = {
2302
+ ...current,
2303
+ ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2304
+ last_prompt_at: ctx.now(),
2305
+ }
2306
+ if (remaining.length > 0) next.pending = remaining
2093
2307
  else delete next.pending
2094
2308
  if (retiring.length > 0) next.retiring = retiring
2095
2309
  else delete next.retiring
@@ -2173,6 +2387,7 @@ export async function runEscalationWaiter(
2173
2387
  // from the prompt-only state that previously looked healthy.
2174
2388
  updateSessionState(sessionId, ctx.env, (current) => ({
2175
2389
  ...current,
2390
+ ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2176
2391
  last_stop_at: ctx.now(),
2177
2392
  }))
2178
2393
 
@@ -2428,6 +2643,11 @@ async function deliverAcceptedAnswers(
2428
2643
  return outcome
2429
2644
  }
2430
2645
 
2646
+ /** True while this Stop owner is still allowed a direct-wake wait. */
2647
+ function ownerLeaseActive(entry: PendingQuestion, now: number): boolean {
2648
+ return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now
2649
+ }
2650
+
2431
2651
  /** The complete per-session Stop decision, after this process owns the claim. */
2432
2652
  async function handleClaimedStop(
2433
2653
  ctx: HookContext,
@@ -2515,26 +2735,44 @@ async function handleClaimedStop(
2515
2735
  const recoverableLive = live.filter(
2516
2736
  (entry) => !permanentFailures.has(entry.request_id!),
2517
2737
  )
2518
- liveToEscalate = recoverableLive
2738
+ // A spent owner has already used its waiter ceiling. Passing those
2739
+ // questions back into `escalate` starts a fresh ceiling, holds the
2740
+ // per-session claim for another 3300s, and is why a later `ask` never
2741
+ // reached a device after a degraded waiter (the next Stop never ran, or
2742
+ // ran as claimed-elsewhere). Late answers still ride the short poll above
2743
+ // and every later turn. Independent questions whose lease is still running
2744
+ // keep their successor wait.
2745
+ const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
2746
+ liveToEscalate = stillOwned
2519
2747
  if (unasked.length === 0) {
2520
2748
  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.
2749
+ if (stillOwned.length === 0) {
2750
+ const requestIdSummary = summarizeRequestIds(recoverableLive)
2751
+ ctx.log?.info('hook.answer', {
2752
+ answered: false,
2753
+ stage: 'queued',
2754
+ request_ids: requestIdSummary.ids,
2755
+ owner_spent: true,
2756
+ })
2757
+ notes.push(
2758
+ `${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`,
2759
+ )
2760
+ return { notes }
2761
+ }
2525
2762
  return await escalate(
2526
2763
  ctx,
2527
2764
  envelope,
2528
2765
  sessionId,
2529
2766
  [],
2530
- recoverableLive,
2767
+ stillOwned,
2531
2768
  notes,
2532
2769
  hardDeadlineAt,
2533
2770
  route,
2534
2771
  )
2535
2772
  }
2536
2773
  // Questions registered after the earlier push still owe the user their
2537
- // notification; fall through and escalate just those.
2774
+ // notification; fall through and escalate just those, plus any live
2775
+ // question whose owner lease is still running.
2538
2776
  }
2539
2777
 
2540
2778
  // A Stop answer may immediately produce a legitimate follow-up question.
@@ -2672,7 +2910,6 @@ async function escalate(
2672
2910
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2673
2911
  ...(entry.project !== undefined ? { project: entry.project } : {}),
2674
2912
  ...(eventSource !== undefined ? { source: eventSource } : {}),
2675
- event: 'agent_question',
2676
2913
  windowSeconds: replyWindowSeconds,
2677
2914
  ownerDeadlineAt,
2678
2915
  })
@@ -2751,7 +2988,6 @@ async function escalate(
2751
2988
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2752
2989
  ...(entry.project !== undefined ? { project: entry.project } : {}),
2753
2990
  ...(eventSource !== undefined ? { source: eventSource } : {}),
2754
- event: 'agent_question',
2755
2991
  windowSeconds: replyWindowSeconds,
2756
2992
  ownerDeadlineAt,
2757
2993
  })
@@ -2821,7 +3057,7 @@ async function escalate(
2821
3057
  request_id: intent.request_id,
2822
3058
  devices: intent.device_ids.length,
2823
3059
  questions: questions.length,
2824
- text: questions[0]!.text,
3060
+ text_chars: questions[0]!.text.length,
2825
3061
  })
2826
3062
  }
2827
3063
  const committedReplyDeadline =
@@ -2841,14 +3077,14 @@ async function escalate(
2841
3077
  // stale about eight minutes later and retired, so the answer given over
2842
3078
  // lunch met a closed question.
2843
3079
  //
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.
3080
+ // The reply window is how long the server accepts an answer. This
3081
+ // process still clamps its own wait to `hardDeadlineAt`. Recording the
3082
+ // owner lease separately is what stops the *next* Stop from treating
3083
+ // the remaining window as a fresh waiter ceiling.
2849
3084
  reply_deadline_at: Number.isFinite(committedReplyDeadline)
2850
3085
  ? committedReplyDeadline
2851
3086
  : intent.owner_deadline_at,
3087
+ owner_deadline_at: intent.owner_deadline_at,
2852
3088
  }
2853
3089
  if (intent.draft.source === undefined) delete live.source
2854
3090
  delete live.submission
@@ -2927,7 +3163,8 @@ async function escalate(
2927
3163
  ceilingAt,
2928
3164
  Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
2929
3165
  )
2930
- const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
3166
+ const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000
3167
+ const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000))
2931
3168
  const waited = await waitForAnyReply(
2932
3169
  ctx,
2933
3170
  waitingOn.map((entry) => entry.request_id!),
@@ -2955,7 +3192,7 @@ async function escalate(
2955
3192
  const expired = waitingOn.filter(
2956
3193
  (entry) =>
2957
3194
  entry.reply_deadline_at === undefined ||
2958
- entry.reply_deadline_at <= ctx.now() ||
3195
+ entry.reply_deadline_at <= ownerDeadline ||
2959
3196
  // A permanent rejection is the server saying this question can never be
2960
3197
  // answered, whatever its window says. Leaving it open would strand a
2961
3198
  // dead question on the user's devices.
@@ -3001,10 +3238,10 @@ async function escalate(
3001
3238
  permanent_failures: waited.permanentFailures.size,
3002
3239
  waited_seconds: timeoutSeconds,
3003
3240
  })
3004
- if (waited.permanentFailures.size === 0) {
3241
+ if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
3005
3242
  notes.push(
3006
3243
  waited.degraded
3007
- ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
3244
+ ? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
3008
3245
  : 'no answer in time; the question expired with its continuation owner',
3009
3246
  )
3010
3247
  }
@@ -3159,8 +3396,9 @@ export function registerQuestion(
3159
3396
  env: NodeJS.ProcessEnv,
3160
3397
  question: PendingQuestion,
3161
3398
  now: number = Date.now(),
3162
- ): void {
3399
+ ): string {
3163
3400
  let full: 'unasked' | 'live' | null = null
3401
+ const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`
3164
3402
  updateSessionState(sessionId, env, (state) => {
3165
3403
  const pending = pendingList(state)
3166
3404
  const unasked = pending.filter((entry) => entry.request_id === undefined)
@@ -3177,9 +3415,9 @@ export function registerQuestion(
3177
3415
  pending: [
3178
3416
  ...pending,
3179
3417
  {
3180
- question_id: `q_${randomBytes(12).toString('base64url')}`,
3181
3418
  asked_at: now,
3182
3419
  ...question,
3420
+ question_id: questionId,
3183
3421
  question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
3184
3422
  },
3185
3423
  ],
@@ -3194,9 +3432,10 @@ export function registerQuestion(
3194
3432
  if (full === 'live') {
3195
3433
  throw new Error(
3196
3434
  `${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.',
3435
+ 'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.',
3198
3436
  )
3199
3437
  }
3438
+ return questionId
3200
3439
  }
3201
3440
 
3202
3441
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */