@raidiant/notifai 10.0.0 → 10.1.1

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 (127) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +32 -14
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +10 -1
  19. package/dist/commands-core.js +13 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +158 -71
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-harness-context.js +8 -0
  27. package/dist/commands-harness-context.js.map +1 -1
  28. package/dist/commands-hooks.js +169 -15
  29. package/dist/commands-hooks.js.map +1 -1
  30. package/dist/commands-init.d.ts +16 -0
  31. package/dist/commands-init.js +254 -91
  32. package/dist/commands-init.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-update.d.ts +10 -0
  37. package/dist/commands-update.js +141 -0
  38. package/dist/commands-update.js.map +1 -0
  39. package/dist/commands.d.ts +2 -1
  40. package/dist/commands.js +2 -1
  41. package/dist/commands.js.map +1 -1
  42. package/dist/harnesses.d.ts +2 -2
  43. package/dist/harnesses.js +22 -2
  44. package/dist/harnesses.js.map +1 -1
  45. package/dist/hook-adapter.d.ts +2 -0
  46. package/dist/hook-adapter.js +12 -1
  47. package/dist/hook-adapter.js.map +1 -1
  48. package/dist/hooks.d.ts +43 -2
  49. package/dist/hooks.js +163 -21
  50. package/dist/hooks.js.map +1 -1
  51. package/dist/install-hooks.js +15 -9
  52. package/dist/install-hooks.js.map +1 -1
  53. package/dist/interactive.js +20 -23
  54. package/dist/interactive.js.map +1 -1
  55. package/dist/main-run.d.ts +1 -0
  56. package/dist/main-run.js +42 -0
  57. package/dist/main-run.js.map +1 -0
  58. package/dist/main.js +10 -31
  59. package/dist/main.js.map +1 -1
  60. package/dist/node-floor.d.ts +18 -0
  61. package/dist/node-floor.js +35 -0
  62. package/dist/node-floor.js.map +1 -0
  63. package/dist/openclaw-plugin.d.ts +49 -0
  64. package/dist/openclaw-plugin.js +350 -0
  65. package/dist/openclaw-plugin.js.map +1 -0
  66. package/dist/program.d.ts +2 -1
  67. package/dist/program.js +31 -13
  68. package/dist/program.js.map +1 -1
  69. package/dist/question-settlement-process.d.ts +15 -0
  70. package/dist/question-settlement-process.js +35 -0
  71. package/dist/question-settlement-process.js.map +1 -0
  72. package/dist/readiness.d.ts +25 -1
  73. package/dist/readiness.js +32 -2
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/session-activation.js +1 -1
  76. package/dist/session-activation.js.map +1 -1
  77. package/dist/setup-destinations.d.ts +23 -0
  78. package/dist/setup-destinations.js +44 -0
  79. package/dist/setup-destinations.js.map +1 -0
  80. package/dist/ui/help.d.ts +4 -5
  81. package/dist/ui/help.js +5 -6
  82. package/dist/ui/help.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/cli-bin.test.ts +145 -7
  85. package/src/cli-bin.ts +176 -10
  86. package/src/cli-update-contract.ts +9 -0
  87. package/src/cli-update.test.ts +125 -0
  88. package/src/client.ts +7 -0
  89. package/src/codex-wake.ts +5 -1
  90. package/src/commands-auth.ts +47 -13
  91. package/src/commands-core.ts +21 -4
  92. package/src/commands-devices.ts +23 -16
  93. package/src/commands-doctor.ts +197 -75
  94. package/src/commands-harness-context.ts +8 -0
  95. package/src/commands-hooks.ts +210 -18
  96. package/src/commands-init.ts +275 -92
  97. package/src/commands-send.ts +145 -39
  98. package/src/commands-update.ts +180 -0
  99. package/src/commands.test.ts +701 -53
  100. package/src/commands.ts +2 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +82 -0
  103. package/src/harnesses.test.ts +9 -1
  104. package/src/harnesses.ts +24 -2
  105. package/src/hook-adapter.ts +14 -0
  106. package/src/hooks.test.ts +275 -0
  107. package/src/hooks.ts +270 -18
  108. package/src/install-hooks.test.ts +208 -0
  109. package/src/install-hooks.ts +19 -9
  110. package/src/interactive.test.ts +31 -0
  111. package/src/interactive.ts +20 -26
  112. package/src/main-run.ts +45 -0
  113. package/src/main.ts +9 -33
  114. package/src/node-floor.test.ts +27 -0
  115. package/src/node-floor.ts +37 -0
  116. package/src/openclaw-plugin.ts +409 -0
  117. package/src/program.test.ts +35 -0
  118. package/src/program.ts +37 -12
  119. package/src/question-settlement-process.ts +47 -0
  120. package/src/readiness.ts +35 -2
  121. package/src/session-activation.ts +1 -1
  122. package/src/setup-destinations.test.ts +41 -0
  123. package/src/setup-destinations.ts +50 -0
  124. package/src/skill.test.ts +13 -2
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
  127. package/src/commands-devices.test.ts +0 -15
package/src/hooks.ts CHANGED
@@ -111,6 +111,13 @@ export interface SessionState {
111
111
  * question the moment a second was registered (2026-08-09).
112
112
  */
113
113
  pending?: PendingQuestion[]
114
+ /**
115
+ * Bounded terminal history for stable `q_...` lookup after the live queue is
116
+ * gone. It carries identities and lifecycle state only; question content
117
+ * remains in the ordinary pending/retirement records under their existing
118
+ * retention boundary.
119
+ */
120
+ question_history?: QuestionHistoryEntry[]
114
121
  /**
115
122
  * Questions that have been delivered to the user's devices and are now dead,
116
123
  * but whose retirement has not been confirmed yet.
@@ -191,6 +198,8 @@ interface AcceptedAnswerDelivery {
191
198
 
192
199
  /** A delivered question awaiting its retirement push. */
193
200
  export interface RetiringQuestion {
201
+ /** Stable local identity retained through remote retirement. */
202
+ question_id?: string
194
203
  request_id: string
195
204
  collapse_key: string
196
205
  /** The Device Installations that actually received the question. */
@@ -273,6 +282,38 @@ export interface PendingQuestion {
273
282
  submission?: PendingSubmissionIntent
274
283
  }
275
284
 
285
+ export type QuestionDeliveryState =
286
+ | 'local'
287
+ | 'frozen'
288
+ | 'live'
289
+ | 'answered'
290
+ | 'withdrawn'
291
+ | 'retired'
292
+
293
+ type QuestionTerminalState = Extract<QuestionDeliveryState, 'answered' | 'withdrawn' | 'retired'>
294
+
295
+ export interface QuestionHistoryEntry {
296
+ question_id: string
297
+ state: QuestionTerminalState
298
+ /** Present only after local promotion proves the Notification Request exists. */
299
+ request_id?: string
300
+ /** Reserved idempotent identity that was never locally promoted. */
301
+ frozen_request_id?: string
302
+ }
303
+
304
+ export interface QuestionStateView {
305
+ question_id: string
306
+ state: QuestionDeliveryState
307
+ /** Null means a frozen submission may or may not have crossed the service boundary. */
308
+ submitted: boolean | null
309
+ request_id: string | null
310
+ frozen_request_id: string | null
311
+ }
312
+
313
+ export type QuestionStateLookup =
314
+ | { found: true; session_id: string; question: QuestionStateView }
315
+ | { found: false; ambiguous: boolean }
316
+
276
317
  /** Keep immutable session grouping while replacing per-event Git location authoritatively. */
277
318
  function sourceContextAtHookEvent(
278
319
  source: SourceContextT | undefined,
@@ -355,6 +396,8 @@ export interface HookOutcome {
355
396
  notes: string[]
356
397
  /** Structured lifecycle detail that belongs on hook.end without user text. */
357
398
  log?: Record<string, unknown>
399
+ /** An unpushed registration survived UserPromptSubmit and needs its own owner. */
400
+ settlementRequired?: boolean
358
401
  }
359
402
 
360
403
  /** An accepted continuation ready for whichever host owns the last meter. */
@@ -416,6 +459,8 @@ export interface EscalationWaiterOptions {
416
459
  envelope: HookEnvelope
417
460
  route: EscalationDeliveryRoute
418
461
  processDeadlineAt?: number
462
+ /** Stop owns diagnostics; a pre-Stop recovery owner must not impersonate it. */
463
+ recordStop?: boolean
419
464
  }
420
465
 
421
466
  function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
@@ -500,14 +545,19 @@ export function findOwningSession(
500
545
  const pendingMatch = pendingList(state).some(
501
546
  (entry) => entry.question_id === id || entry.request_id === id,
502
547
  )
503
- const retiringMatch = (state.retiring ?? []).some((entry) => entry.request_id === id)
548
+ const retiringMatch = (state.retiring ?? []).some(
549
+ (entry) => entry.question_id === id || entry.request_id === id,
550
+ )
551
+ const historyMatch = (state.question_history ?? []).some(
552
+ (entry) => entry.question_id === id || entry.request_id === id,
553
+ )
504
554
  const acknowledgementMatch = (state.acknowledgement_due ?? []).some(
505
555
  (entry) => entry.request_id === id,
506
556
  )
507
557
  const acceptedMatch = state.accepted?.answers.some(
508
558
  ({ pending }) => pending.question_id === id || pending.request_id === id,
509
559
  ) ?? false
510
- if (pendingMatch || retiringMatch || acknowledgementMatch || acceptedMatch) {
560
+ if (pendingMatch || retiringMatch || historyMatch || acknowledgementMatch || acceptedMatch) {
511
561
  matches.push(sessionId)
512
562
  if (matches.length > 1) return { sessionId: null, ambiguous: true }
513
563
  }
@@ -1452,14 +1502,24 @@ export function parkForRetirement(
1452
1502
  updateSessionState(sessionId, env, (current) => {
1453
1503
  const already = current.retiring ?? []
1454
1504
  const existing = already.findIndex((entry) => entry.request_id === retirement.request_id)
1455
- if (existing < 0) return { ...current, retiring: [...already, retirement] }
1505
+ if (existing < 0) {
1506
+ return rememberQuestionState(
1507
+ { ...current, retiring: [...already, retirement] },
1508
+ pending,
1509
+ state === 'answered' ? 'answered' : 'retired',
1510
+ )
1511
+ }
1456
1512
  const retiring = [...already]
1457
1513
  // An observed answer is final truth and upgrades an earlier supersession
1458
1514
  // parked while the submission callback was racing a newer question.
1459
1515
  if (retirement.state === 'answered' && retiring[existing]!.state !== 'answered') {
1460
1516
  retiring[existing] = retirement
1461
1517
  }
1462
- return { ...current, retiring }
1518
+ return rememberQuestionState(
1519
+ { ...current, retiring },
1520
+ pending,
1521
+ state === 'answered' ? 'answered' : 'retired',
1522
+ )
1463
1523
  })
1464
1524
  }
1465
1525
 
@@ -1494,6 +1554,7 @@ function retiringQuestion(
1494
1554
  }
1495
1555
  const source = sourceContextAtHookEvent(pending.source, cwd)
1496
1556
  return {
1557
+ ...(pending.question_id !== undefined ? { question_id: pending.question_id } : {}),
1497
1558
  request_id: requestId!,
1498
1559
  collapse_key: collapseKey!,
1499
1560
  device_ids: [...deviceIds!],
@@ -1821,6 +1882,144 @@ function pendingList(state: SessionState): PendingQuestion[] {
1821
1882
  return Array.isArray(state.pending) ? state.pending : []
1822
1883
  }
1823
1884
 
1885
+ const QUESTION_HISTORY_CAP = 50
1886
+
1887
+ type QuestionIdentity = Pick<PendingQuestion, 'question_id' | 'request_id' | 'submission'>
1888
+
1889
+ /** Keep a bounded, content-free map from local identity to its terminal state. */
1890
+ function rememberQuestionState(
1891
+ current: SessionState,
1892
+ question: QuestionIdentity,
1893
+ state: QuestionTerminalState,
1894
+ ): SessionState {
1895
+ if (question.question_id === undefined) return current
1896
+ const existing = (current.question_history ?? []).find(
1897
+ (entry) => entry.question_id === question.question_id,
1898
+ )
1899
+ const terminalState =
1900
+ existing?.state === 'withdrawn' || existing?.state === 'answered'
1901
+ ? existing.state
1902
+ : state === 'answered'
1903
+ ? 'answered'
1904
+ : state
1905
+ const requestId = question.request_id ?? existing?.request_id
1906
+ const frozenRequestId =
1907
+ requestId === undefined
1908
+ ? question.submission?.request_id ?? existing?.frozen_request_id
1909
+ : undefined
1910
+ const record: QuestionHistoryEntry = {
1911
+ question_id: question.question_id,
1912
+ state: terminalState,
1913
+ ...(requestId === undefined ? {} : { request_id: requestId }),
1914
+ ...(frozenRequestId === undefined ? {} : { frozen_request_id: frozenRequestId }),
1915
+ }
1916
+ const history = [
1917
+ ...(current.question_history ?? []).filter(
1918
+ (entry) => entry.question_id !== question.question_id,
1919
+ ),
1920
+ record,
1921
+ ].slice(-QUESTION_HISTORY_CAP)
1922
+ return { ...current, question_history: history }
1923
+ }
1924
+
1925
+ function pendingQuestionView(entry: PendingQuestion): QuestionStateView | null {
1926
+ if (entry.question_id === undefined) return null
1927
+ if (entry.request_id !== undefined) {
1928
+ return {
1929
+ question_id: entry.question_id,
1930
+ state: 'live',
1931
+ submitted: true,
1932
+ request_id: entry.request_id,
1933
+ frozen_request_id: null,
1934
+ }
1935
+ }
1936
+ if (entry.submission !== undefined) {
1937
+ return {
1938
+ question_id: entry.question_id,
1939
+ state: 'frozen',
1940
+ submitted: null,
1941
+ request_id: null,
1942
+ frozen_request_id: entry.submission.request_id,
1943
+ }
1944
+ }
1945
+ return {
1946
+ question_id: entry.question_id,
1947
+ state: 'local',
1948
+ submitted: false,
1949
+ request_id: null,
1950
+ frozen_request_id: null,
1951
+ }
1952
+ }
1953
+
1954
+ /** Read one stable local question identity without minting or submitting anything. */
1955
+ export function inspectQuestionState(
1956
+ questionId: string,
1957
+ env: NodeJS.ProcessEnv,
1958
+ ): QuestionStateLookup {
1959
+ const owner = findOwningSession(questionId, env)
1960
+ if (owner.ambiguous || owner.sessionId === null) {
1961
+ return { found: false, ambiguous: owner.ambiguous }
1962
+ }
1963
+ const state = readSessionState(owner.sessionId, env)
1964
+ const pending = pendingList(state).find((entry) => entry.question_id === questionId)
1965
+ const pendingView = pending === undefined ? null : pendingQuestionView(pending)
1966
+ if (pendingView !== null) {
1967
+ return { found: true, session_id: owner.sessionId, question: pendingView }
1968
+ }
1969
+ const accepted = state.accepted?.answers.find(
1970
+ ({ pending: entry }) => entry.question_id === questionId,
1971
+ )?.pending
1972
+ if (accepted?.question_id !== undefined) {
1973
+ return {
1974
+ found: true,
1975
+ session_id: owner.sessionId,
1976
+ question: {
1977
+ question_id: accepted.question_id,
1978
+ state: 'answered',
1979
+ submitted: true,
1980
+ request_id: accepted.request_id ?? null,
1981
+ frozen_request_id: null,
1982
+ },
1983
+ }
1984
+ }
1985
+ const retiring = (state.retiring ?? []).find((entry) => entry.question_id === questionId)
1986
+ if (retiring?.question_id !== undefined) {
1987
+ return {
1988
+ found: true,
1989
+ session_id: owner.sessionId,
1990
+ question: {
1991
+ question_id: retiring.question_id,
1992
+ state: retiring.state === 'answered' ? 'answered' : 'retired',
1993
+ submitted: true,
1994
+ request_id: retiring.request_id,
1995
+ frozen_request_id: null,
1996
+ },
1997
+ }
1998
+ }
1999
+ const history = (state.question_history ?? []).find(
2000
+ (entry) => entry.question_id === questionId,
2001
+ )
2002
+ if (history !== undefined) {
2003
+ return {
2004
+ found: true,
2005
+ session_id: owner.sessionId,
2006
+ question: {
2007
+ question_id: history.question_id,
2008
+ state: history.state,
2009
+ submitted:
2010
+ history.request_id !== undefined
2011
+ ? true
2012
+ : history.frozen_request_id !== undefined
2013
+ ? null
2014
+ : false,
2015
+ request_id: history.request_id ?? null,
2016
+ frozen_request_id: history.frozen_request_id ?? null,
2017
+ },
2018
+ }
2019
+ }
2020
+ return { found: false, ambiguous: false }
2021
+ }
2022
+
1824
2023
  /** Registration identity — the convention every racing writer compares by. */
1825
2024
  function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
1826
2025
  if (a.question_id !== undefined || b.question_id !== undefined) {
@@ -1842,13 +2041,14 @@ export function dropPendingQuestion(
1842
2041
  sessionId: string,
1843
2042
  env: NodeJS.ProcessEnv,
1844
2043
  entry: PendingQuestion,
2044
+ terminalState: QuestionTerminalState = 'retired',
1845
2045
  ): void {
1846
2046
  updateSessionState(sessionId, env, (current) => {
1847
2047
  const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry))
1848
2048
  const next: SessionState = { ...current }
1849
2049
  if (pending.length > 0) next.pending = pending
1850
2050
  else delete next.pending
1851
- return next
2051
+ return rememberQuestionState(next, entry, terminalState)
1852
2052
  })
1853
2053
  }
1854
2054
 
@@ -1870,7 +2070,10 @@ export function withdrawUnpushedQuestions(
1870
2070
  const next: SessionState = { ...current }
1871
2071
  if (remaining.length > 0) next.pending = remaining
1872
2072
  else delete next.pending
1873
- return next
2073
+ return withdrawn.reduce(
2074
+ (remembered, entry) => rememberQuestionState(remembered, entry, 'withdrawn'),
2075
+ next,
2076
+ )
1874
2077
  })
1875
2078
  return withdrawn
1876
2079
  }
@@ -2058,7 +2261,10 @@ function stageAcceptedAnswers(
2058
2261
  else delete next.pending
2059
2262
  if (acknowledgementDue.length > 0) next.acknowledgement_due = acknowledgementDue
2060
2263
  else delete next.acknowledgement_due
2061
- return next
2264
+ return answered.reduce(
2265
+ (remembered, answer) => rememberQuestionState(remembered, answer.pending, 'answered'),
2266
+ next,
2267
+ )
2062
2268
  })
2063
2269
  return accepted
2064
2270
  }
@@ -2296,7 +2502,14 @@ async function retirePendings(
2296
2502
  else delete next.pending
2297
2503
  if (retiring.length > 0) next.retiring = retiring
2298
2504
  else delete next.retiring
2299
- return next
2505
+ return entries.reduce(
2506
+ (remembered, entry) => rememberQuestionState(
2507
+ remembered,
2508
+ entry,
2509
+ state === 'answered' ? 'answered' : 'retired',
2510
+ ),
2511
+ next,
2512
+ )
2300
2513
  })
2301
2514
  // Network retirement is deliberately deferred to a later no-question hook.
2302
2515
  // The state write above is the durable operation; blocking here creates a
@@ -2399,7 +2612,7 @@ export async function handleUserPromptSubmit(
2399
2612
  // Park before dropping `pending`. If the process dies between these writes,
2400
2613
  // the next hook sees both copies and dedupes them; the old order could die in
2401
2614
  // the gap after erasing the only request/collapse/device identifiers.
2402
- updateSessionState(sessionId, ctx.env, (current) => {
2615
+ const updated = updateSessionState(sessionId, ctx.env, (current) => {
2403
2616
  const retiring = [...(current.retiring ?? [])]
2404
2617
  const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current))
2405
2618
  const unmatched = pendingList(current).filter(
@@ -2438,7 +2651,14 @@ export async function handleUserPromptSubmit(
2438
2651
  if (current.continuation !== undefined) {
2439
2652
  next.continuation = { ...current.continuation, count: 0 }
2440
2653
  }
2441
- return next
2654
+ return matchedNow.reduce(
2655
+ (remembered, entry) => rememberQuestionState(
2656
+ remembered,
2657
+ entry,
2658
+ entry.request_id === undefined ? 'withdrawn' : 'retired',
2659
+ ),
2660
+ next,
2661
+ )
2442
2662
  })
2443
2663
  // The bridge that lets a plain `notifai ask` find the hook's canonical
2444
2664
  // session: an agent shell command gets no hook payload, and not every
@@ -2452,7 +2672,10 @@ export async function handleUserPromptSubmit(
2452
2672
  // model. Keep the journal and let the next Stop use its acknowledged
2453
2673
  // continuation channel instead of recreating the crash-before-stdout gap.
2454
2674
  notes.push('the late device answer will continue the agent at this turn’s Stop')
2455
- return { notes }
2675
+ return {
2676
+ notes,
2677
+ settlementRequired: pendingList(updated).some((entry) => entry.request_id === undefined),
2678
+ }
2456
2679
  }
2457
2680
  const retired = await drainRetirements(ctx, sessionId, ctx.env)
2458
2681
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
@@ -2460,7 +2683,10 @@ export async function handleUserPromptSubmit(
2460
2683
  if (swept.length > 0) {
2461
2684
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
2462
2685
  }
2463
- return { notes }
2686
+ return {
2687
+ notes,
2688
+ settlementRequired: pendingList(updated).some((entry) => entry.request_id === undefined),
2689
+ }
2464
2690
  }
2465
2691
 
2466
2692
  // ---------------------------------------------------------------------------
@@ -2477,6 +2703,7 @@ export async function handleStop(
2477
2703
  envelope: HookEnvelope,
2478
2704
  processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000,
2479
2705
  route: EscalationDeliveryRoute = hookContinuationRoute(),
2706
+ recordStop = true,
2480
2707
  ): Promise<HookOutcome> {
2481
2708
  const sessionId = envelope.session_id
2482
2709
  if (!sessionId) {
@@ -2488,6 +2715,7 @@ export async function handleStop(
2488
2715
  envelope,
2489
2716
  route,
2490
2717
  processDeadlineAt,
2718
+ recordStop,
2491
2719
  })
2492
2720
  }
2493
2721
 
@@ -2545,11 +2773,13 @@ export async function runEscalationWaiter(
2545
2773
  // This event marker is diagnostic evidence in its own right. Record it
2546
2774
  // before every early return so doctor can distinguish a working Stop route
2547
2775
  // from the prompt-only state that previously looked healthy.
2548
- updateSessionState(sessionId, ctx.env, (current) => ({
2549
- ...current,
2550
- ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2551
- last_stop_at: ctx.now(),
2552
- }))
2776
+ if (options.recordStop !== false) {
2777
+ updateSessionState(sessionId, ctx.env, (current) => ({
2778
+ ...current,
2779
+ ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2780
+ last_stop_at: ctx.now(),
2781
+ }))
2782
+ }
2553
2783
 
2554
2784
  // The waiter, not its host, owns this lease. It spans grace, submission,
2555
2785
  // polling, close fencing, route delivery, and every retirement path.
@@ -3600,6 +3830,24 @@ export function handleSessionEnd(
3600
3830
  if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
3601
3831
 
3602
3832
  const state = readSessionState(sessionId, env)
3833
+ let stateWithHistory = state
3834
+ for (const entry of pendingList(state)) {
3835
+ stateWithHistory = rememberQuestionState(
3836
+ stateWithHistory,
3837
+ entry,
3838
+ entry.request_id === undefined && entry.submission === undefined ? 'withdrawn' : 'retired',
3839
+ )
3840
+ }
3841
+ for (const answer of state.accepted?.answers ?? []) {
3842
+ stateWithHistory = rememberQuestionState(stateWithHistory, answer.pending, 'answered')
3843
+ }
3844
+ for (const retirement of state.retiring ?? []) {
3845
+ stateWithHistory = rememberQuestionState(
3846
+ stateWithHistory,
3847
+ retirement,
3848
+ retirement.state === 'answered' ? 'answered' : 'retired',
3849
+ )
3850
+ }
3603
3851
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
3604
3852
  const retirementCandidates = [
3605
3853
  ...pendingList(state),
@@ -3631,7 +3879,7 @@ export function handleSessionEnd(
3631
3879
  )
3632
3880
  }
3633
3881
  if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
3634
- const preserved: SessionState = { ...state }
3882
+ const preserved: SessionState = { ...stateWithHistory }
3635
3883
  delete preserved.pending
3636
3884
  delete preserved.retiring
3637
3885
  delete preserved.acknowledgement_blocks
@@ -3654,7 +3902,11 @@ export function handleSessionEnd(
3654
3902
  },
3655
3903
  }
3656
3904
  }
3905
+ const history = stateWithHistory.question_history
3657
3906
  clearSessionState(sessionId, env)
3907
+ if ((history?.length ?? 0) > 0) {
3908
+ writeSessionState(sessionId, env, { question_history: history! })
3909
+ }
3658
3910
  return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } }
3659
3911
  }
3660
3912