@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/commands.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  EXIT,
3
3
  log,
4
+ updateCliCommand,
4
5
  type CommandDeps,
5
6
  type CommandIo,
6
7
  type CommandSpinner,
@@ -63,6 +64,7 @@ export {
63
64
  type SetupScope,
64
65
  } from './commands-init.js'
65
66
  export { assessReadiness, doctorCommand } from './commands-doctor.js'
67
+ export { cliUpdateCommand, type CliUpdateFlags } from './commands-update.js'
66
68
  export {
67
69
  projectDisableCommand,
68
70
  projectEnableCommand,
@@ -0,0 +1,57 @@
1
+ {
2
+ "vendor": "openclaw/openclaw",
3
+ "release": "2026.7.1-2",
4
+ "commit": "0790d9f",
5
+ "instance": {
6
+ "harness": "openclaw",
7
+ "surface": "agent-local",
8
+ "execHost": "gateway"
9
+ },
10
+ "durableSessionKey": "agent:main:probe-owner",
11
+ "rotatingSessionId": "ae6686a4-d632-4750-b73b-56faec5fff9a",
12
+ "observedPluginEnv": {
13
+ "NOTIFAI_ACTIVE_HARNESS": "openclaw",
14
+ "NOTIFAI_ACTIVE_SESSION_ID": "agent:main:probe-owner"
15
+ },
16
+ "worker": {
17
+ "sessionKeySubstring": ":subagent:",
18
+ "provedSessionKey": "agent:main:subagent:probe-worker",
19
+ "missingIdentity": "fail-closed-worker"
20
+ },
21
+ "hooks": {
22
+ "proven": {
23
+ "activation": "before_prompt_build",
24
+ "stop": "agent_end",
25
+ "execMarkers": "resolve_exec_env"
26
+ },
27
+ "unprovenOnThisSurface": {
28
+ "presence": "message_received",
29
+ "sessionEnd": "session_end"
30
+ }
31
+ },
32
+ "evidence": {
33
+ "nativeHostOs": "macos",
34
+ "command": "openclaw agent --local",
35
+ "nativeOsUnrun": ["linux", "windows"]
36
+ },
37
+ "supported": [
38
+ "managed-setup",
39
+ "lifecycle-activation",
40
+ "source-context-from-session-key"
41
+ ],
42
+ "unsupported": [
43
+ "question-routing",
44
+ "command-stop-as-turn-end",
45
+ "2026.2.26-cli"
46
+ ],
47
+ "deferred": [
48
+ "gateway-embedded",
49
+ "gateway-remote",
50
+ "agent-exec",
51
+ "acp-nested",
52
+ "exec-sandbox",
53
+ "exec-node",
54
+ "native-linux",
55
+ "native-windows"
56
+ ]
57
+ }
@@ -210,3 +210,85 @@ describe('Hermes classic CLI/local Source Context seam', () => {
210
210
  )
211
211
  })
212
212
  })
213
+
214
+ describe('OpenClaw agent-local Source Context seam', () => {
215
+ const PINNED_OPENCLAW = JSON.parse(
216
+ readFileSync(new URL('./fixtures/openclaw-2026.7.1-2-agent-local.json', import.meta.url), 'utf8'),
217
+ ) as {
218
+ durableSessionKey: string
219
+ rotatingSessionId: string
220
+ observedPluginEnv: { NOTIFAI_ACTIVE_HARNESS: 'openclaw'; NOTIFAI_ACTIVE_SESSION_ID: string }
221
+ instance: { harness: 'openclaw'; surface: 'agent-local'; execHost: 'gateway' }
222
+ hooks: {
223
+ proven: { activation: string; stop: string; execMarkers: string }
224
+ unprovenOnThisSurface: { presence: string; sessionEnd: string }
225
+ }
226
+ deferred: string[]
227
+ }
228
+
229
+ it('pins only the probed agent-local surface, not Gateway-embedded', () => {
230
+ expect(PINNED_OPENCLAW.instance).toEqual({
231
+ harness: 'openclaw',
232
+ surface: 'agent-local',
233
+ execHost: 'gateway',
234
+ })
235
+ expect(PINNED_OPENCLAW.durableSessionKey).toBe('agent:main:probe-owner')
236
+ expect(PINNED_OPENCLAW.observedPluginEnv.NOTIFAI_ACTIVE_SESSION_ID).toBe(
237
+ PINNED_OPENCLAW.durableSessionKey,
238
+ )
239
+ expect(PINNED_OPENCLAW.hooks.proven).toEqual({
240
+ activation: 'before_prompt_build',
241
+ stop: 'agent_end',
242
+ execMarkers: 'resolve_exec_env',
243
+ })
244
+ expect(PINNED_OPENCLAW.hooks.unprovenOnThisSurface).toEqual({
245
+ presence: 'message_received',
246
+ sessionEnd: 'session_end',
247
+ })
248
+ expect(PINNED_OPENCLAW.deferred).toContain('gateway-embedded')
249
+ })
250
+
251
+ it('attributes Source Context to the plugin-injected sessionKey', () => {
252
+ const env = stateEnv(PINNED_OPENCLAW.observedPluginEnv)
253
+ const cwd = '/workspace'
254
+ const active = resolveActiveHarness(env, cwd, Date.now()).active
255
+ expect(active).toMatchObject({
256
+ harness: 'openclaw',
257
+ sessionId: PINNED_OPENCLAW.durableSessionKey,
258
+ })
259
+ const built = buildSourceContext({
260
+ env,
261
+ invocation: inferInvocationContext(cwd, fixtureGit({})),
262
+ ...(active === null ? {} : { activeHarness: active }),
263
+ now: Date.now(),
264
+ })
265
+ expect(built).toMatchObject({
266
+ ok: true,
267
+ source: {
268
+ harness: 'openclaw',
269
+ session_id: PINNED_OPENCLAW.durableSessionKey,
270
+ },
271
+ })
272
+ if (built.ok) {
273
+ expect(JSON.stringify(built.source)).not.toContain(PINNED_OPENCLAW.rotatingSessionId)
274
+ }
275
+ })
276
+
277
+ it('treats nested OpenClaw and Claude markers as contested until live evidence exists', () => {
278
+ const env = stateEnv({
279
+ ...PINNED_OPENCLAW.observedPluginEnv,
280
+ CLAUDECODE: '1',
281
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
282
+ })
283
+ const resolution = resolveActiveHarness(env, '/workspace', 1)
284
+ expect(resolution.contested.map((candidate) => candidate.harness).sort()).toEqual([
285
+ 'claude-code',
286
+ 'openclaw',
287
+ ])
288
+ })
289
+
290
+ it('keeps OpenClaw Question Routing unsupported', () => {
291
+ expect(HARNESS_CAPABILITIES.openclaw.stopContinuation).toBe('unsupported')
292
+ expect(HARNESS_CAPABILITIES.openclaw.deliveryRoutes).toEqual(['unsupported'])
293
+ })
294
+ })
@@ -34,7 +34,13 @@ describe('harness contract', () => {
34
34
  })
35
35
 
36
36
  it('keeps managed hook installation a strict subset of Source Context', () => {
37
- expect(HOOK_INSTALLABLE_HARNESSES).toEqual(['claude-code', 'codex', 'cursor', 'opencode'])
37
+ expect(HOOK_INSTALLABLE_HARNESSES).toEqual([
38
+ 'claude-code',
39
+ 'codex',
40
+ 'cursor',
41
+ 'opencode',
42
+ 'openclaw',
43
+ ])
38
44
  expect(SOURCE_CONTEXT_HARNESSES).toContain('hermes')
39
45
  expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
40
46
  expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HOOK_INSTALLABLE_HARNESSES].sort())
@@ -96,6 +102,8 @@ describe('harness contract', () => {
96
102
  })
97
103
  expect(HARNESS_CAPABILITIES.opencode.stopContinuation).toBe('unsupported')
98
104
  expect(HARNESS_CAPABILITIES.opencode.deliveryRoutes).toEqual(['unsupported'])
105
+ expect(HARNESS_CAPABILITIES.openclaw.stopContinuation).toBe('unsupported')
106
+ expect(HARNESS_CAPABILITIES.openclaw.deliveryRoutes).toEqual(['unsupported'])
99
107
  })
100
108
 
101
109
  it('treats the pinned Hermes classic CLI/local trace as send-only', () => {
package/src/harnesses.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /** Every harness Notifai can name in Source Context when authoritatively known. */
2
- export const SOURCE_CONTEXT_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode', 'hermes'] as const
2
+ export const SOURCE_CONTEXT_HARNESSES = [
3
+ 'claude-code',
4
+ 'codex',
5
+ 'cursor',
6
+ 'opencode',
7
+ 'openclaw',
8
+ 'hermes',
9
+ ] as const
3
10
 
4
11
  export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
5
12
 
@@ -10,7 +17,13 @@ export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
10
17
  * narrower, independently proven cell: Hermes can appear in Source Context
11
18
  * without being hook-installable.
12
19
  */
13
- export const HOOK_INSTALLABLE_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
20
+ export const HOOK_INSTALLABLE_HARNESSES = [
21
+ 'claude-code',
22
+ 'codex',
23
+ 'cursor',
24
+ 'opencode',
25
+ 'openclaw',
26
+ ] as const
14
27
 
15
28
  export type HookInstallableHarness = (typeof HOOK_INSTALLABLE_HARNESSES)[number]
16
29
 
@@ -23,6 +36,7 @@ export const HARNESS_LABELS: Record<SourceContextHarness, string> = {
23
36
  codex: 'Codex',
24
37
  cursor: 'Cursor',
25
38
  opencode: 'OpenCode',
39
+ openclaw: 'OpenClaw',
26
40
  hermes: 'Hermes',
27
41
  }
28
42
 
@@ -73,6 +87,13 @@ const OPENCODE_CAPABILITY: HarnessCapability = {
73
87
  'no proven answer continuation after session.idle; use a blocking reply command',
74
88
  }
75
89
 
90
+ const OPENCLAW_CAPABILITY: HarnessCapability = {
91
+ stopContinuation: 'unsupported',
92
+ deliveryRoutes: ['unsupported'],
93
+ deliveryContract:
94
+ 'no proven answer continuation after agent_end; use a blocking reply command',
95
+ }
96
+
76
97
  export const HERMES_QUESTION_ROUTING_UNAVAILABLE: HarnessCapability = {
77
98
  stopContinuation: 'unsupported',
78
99
  deliveryRoutes: ['unsupported'],
@@ -134,6 +155,7 @@ export const HARNESS_CAPABILITIES: Record<HookInstallableHarness, HarnessCapabil
134
155
  codex: CODEX_CAPABILITY,
135
156
  cursor: CURSOR_CAPABILITY,
136
157
  opencode: OPENCODE_CAPABILITY,
158
+ openclaw: OPENCLAW_CAPABILITY,
137
159
  }
138
160
 
139
161
  /** Question-routing capability for the active harness integration. */
@@ -4,6 +4,7 @@ import {
4
4
  lstatSync,
5
5
  mkdirSync,
6
6
  readFileSync,
7
+ realpathSync,
7
8
  statSync,
8
9
  } from 'node:fs'
9
10
  import os from 'node:os'
@@ -42,6 +43,19 @@ export interface HookAdapterInspection {
42
43
  problems: string[]
43
44
  }
44
45
 
46
+ /** Whether a parsed adapter runs the same installed CLI artifact as PATH. */
47
+ export function hookAdapterTargetsArtifact(
48
+ target: HookAdapterTarget | null,
49
+ artifactPath: string,
50
+ ): boolean {
51
+ if (target === null || isNpxAdapterTarget(target)) return false
52
+ try {
53
+ return realpathSync(target.scriptPath) === realpathSync(artifactPath)
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
45
59
  export function hookHostPlatform(
46
60
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
47
61
  ): HookHostPlatform {
package/src/hooks.test.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  drainRetirements,
49
49
  drainOrphanRetirements,
50
50
  handleSessionEnd,
51
+ inspectQuestionState,
51
52
  runEscalationWaiter,
52
53
  MAX_CONTINUATION_COUNT,
53
54
  MAX_HELD_DELIVERIES,
@@ -355,6 +356,7 @@ function harness(replies: ReplyView[] = []): Harness {
355
356
  sleep: async (milliseconds: number) => {
356
357
  clock += milliseconds
357
358
  },
359
+ spawnQuestionSettlement: () => {},
358
360
  },
359
361
  }
360
362
  }
@@ -2219,6 +2221,7 @@ describe('several questions in flight', () => {
2219
2221
  pending: [
2220
2222
  {
2221
2223
  question: 'Ship it?',
2224
+ question_id: 'q_answer_handoff',
2222
2225
  asked_at: NOW,
2223
2226
  request_id: 'req_existing',
2224
2227
  collapse_key: 'question-existing',
@@ -2231,6 +2234,10 @@ describe('several questions in flight', () => {
2231
2234
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
2232
2235
  const staged = readSessionState('answer-handoff', h.env)
2233
2236
  expect(staged.accepted?.answers[0]?.reply.text).toBe('Ship it')
2237
+ expect(inspectQuestionState('q_answer_handoff', h.env)).toMatchObject({
2238
+ found: true,
2239
+ question: { state: 'answered', submitted: true, request_id: 'req_existing' },
2240
+ })
2234
2241
  expect(JSON.parse(h.io.outLines.at(-1)!).decision).toBe('block')
2235
2242
 
2236
2243
  h.io.outLines = []
@@ -3054,6 +3061,40 @@ describe('user-prompt-submit hook', () => {
3054
3061
  expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
3055
3062
  })
3056
3063
 
3064
+ it('launches one settlement owner when a new prompt overtakes the asking turn Stop', async () => {
3065
+ const h = harness([])
3066
+ writeSessionState('pre-stop-prompt', h.env, { last_prompt_at: AWAY })
3067
+ registerQuestion(
3068
+ 'pre-stop-prompt',
3069
+ h.env,
3070
+ { question: 'Ship the release?', body: 'Ship the release?' },
3071
+ NOW,
3072
+ )
3073
+ let launches = 0
3074
+ const deps = {
3075
+ ...h.deps,
3076
+ spawnQuestionSettlement: () => {
3077
+ launches += 1
3078
+ },
3079
+ } as CommandDeps
3080
+
3081
+ await hookRunCommand(
3082
+ deps,
3083
+ 'user-prompt-submit',
3084
+ stdin({
3085
+ session_id: 'pre-stop-prompt',
3086
+ cwd: h.deps.cwd,
3087
+ prompt: 'Keep working on the unrelated release notes.',
3088
+ }),
3089
+ 'codex',
3090
+ )
3091
+
3092
+ expect(launches).toBe(1)
3093
+ const pending = readSessionState('pre-stop-prompt', h.env).pending?.[0]
3094
+ expect(pending?.question).toBe('Ship the release?')
3095
+ expect(pending?.request_id).toBeUndefined()
3096
+ })
3097
+
3057
3098
  it('records Stop separately so a prompt hook cannot impersonate turn-end routing', async () => {
3058
3099
  const h = harness()
3059
3100
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'events' }))
@@ -3249,6 +3290,40 @@ describe('user-prompt-submit hook', () => {
3249
3290
  expect(readSessionState('prompt-match', h.env).pending).toBeUndefined()
3250
3291
  })
3251
3292
 
3293
+ it('withdraws a matching unpushed registration without launching settlement', async () => {
3294
+ const h = harness([])
3295
+ registerQuestion('prompt-match-unpushed', h.env, {
3296
+ question: 'Ship it?',
3297
+ questions: [
3298
+ {
3299
+ id: 'q1',
3300
+ text: 'Ship it?',
3301
+ choices: [
3302
+ { id: 'ship', label: 'Ship it' },
3303
+ { id: 'wait', label: 'Wait' },
3304
+ ],
3305
+ },
3306
+ ],
3307
+ })
3308
+ let launches = 0
3309
+
3310
+ await hookRunCommand(
3311
+ {
3312
+ ...h.deps,
3313
+ spawnQuestionSettlement: () => {
3314
+ launches += 1
3315
+ },
3316
+ },
3317
+ 'user-prompt-submit',
3318
+ stdin({ session_id: 'prompt-match-unpushed', prompt: 'Ship it' }),
3319
+ 'codex',
3320
+ )
3321
+
3322
+ expect(launches).toBe(0)
3323
+ expect(h.recorder.submitted).toEqual([])
3324
+ expect(readSessionState('prompt-match-unpushed', h.env).pending).toBeUndefined()
3325
+ })
3326
+
3252
3327
  it('leaves an unrelated prompt’s live question open for the reply window', async () => {
3253
3328
  const h = harness([])
3254
3329
  writeSessionState('prompt-unrelated', h.env, {
@@ -3446,6 +3521,26 @@ describe('session-end hook', () => {
3446
3521
  expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
3447
3522
  })
3448
3523
 
3524
+ it('keeps only content-free terminal question state after SessionEnd', async () => {
3525
+ const h = harness()
3526
+ writeSessionState('ended-question', h.env, {
3527
+ last_prompt_at: NOW,
3528
+ pending: [{ question: 'Ship it?', question_id: 'q_ended_local' }],
3529
+ })
3530
+
3531
+ expect(
3532
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'ended-question' })),
3533
+ ).toBe(EXIT.ok)
3534
+
3535
+ expect(readSessionState('ended-question', h.env)).toEqual({
3536
+ question_history: [{ question_id: 'q_ended_local', state: 'withdrawn' }],
3537
+ })
3538
+ expect(inspectQuestionState('q_ended_local', h.env)).toMatchObject({
3539
+ found: true,
3540
+ question: { state: 'withdrawn', submitted: false, request_id: null },
3541
+ })
3542
+ })
3543
+
3449
3544
  it('round-trips future session-state fields when SessionEnd preserves an obligation', async () => {
3450
3545
  const h = harness()
3451
3546
  const sessionId = 'session-end-future-state'
@@ -4462,6 +4557,63 @@ describe('Claude Code Stop wake route', () => {
4462
4557
  }
4463
4558
  }
4464
4559
 
4560
+ it('settles a pre-Stop registration through the detached Claude owner', async () => {
4561
+ const h = harness([reply({ text: 'Ship it' })])
4562
+ h.recorder.acknowledgementRequiredFor = () => false
4563
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
4564
+ registerQuestion('claude-route', h.env, { question: 'Ship it?' }, NOW)
4565
+ const wake = claudeWake()
4566
+ let settlementInput: { session_id?: string; cwd?: string } | undefined
4567
+ const deps: CommandDeps = {
4568
+ ...h.deps,
4569
+ claudeWake: wake,
4570
+ claudeSourcePid: 12345,
4571
+ spawnQuestionSettlement: (launch) => {
4572
+ settlementInput = launch.envelope
4573
+ },
4574
+ }
4575
+
4576
+ await hookRunCommand(
4577
+ deps,
4578
+ 'user-prompt-submit',
4579
+ stdin({
4580
+ session_id: 'claude-route',
4581
+ cwd: h.deps.cwd,
4582
+ prompt: 'Keep working on the unrelated release notes.',
4583
+ }),
4584
+ 'claude-code',
4585
+ )
4586
+ await hookRunCommand(
4587
+ deps,
4588
+ 'question-settlement',
4589
+ stdin(settlementInput),
4590
+ 'claude-code',
4591
+ )
4592
+
4593
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
4594
+ expect(wake.sent).toHaveLength(1)
4595
+ expect(wake.resumed).toEqual([])
4596
+ expect(h.io.outLines).toEqual([])
4597
+
4598
+ await hookRunCommand(
4599
+ deps,
4600
+ 'stop',
4601
+ stdin({ session_id: 'claude-route', cwd: h.deps.cwd }),
4602
+ 'claude-code',
4603
+ )
4604
+ await hookRunCommand(
4605
+ deps,
4606
+ 'stop',
4607
+ stdin({ session_id: 'claude-route', cwd: h.deps.cwd }),
4608
+ 'claude-code',
4609
+ )
4610
+
4611
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
4612
+ expect(wake.sent).toHaveLength(1)
4613
+ expect(readSessionState('claude-route', h.env).pending).toBeUndefined()
4614
+ expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
4615
+ })
4616
+
4465
4617
  it('routes an accepted answer through the Claude inbox instead of hook stdout', async () => {
4466
4618
  const h = harness([reply({ text: 'BETA' })])
4467
4619
  writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
@@ -4877,6 +5029,129 @@ describe('Codex Stop wake route', () => {
4877
5029
  })
4878
5030
  }
4879
5031
 
5032
+ it('settles once when UserPromptSubmit overtakes the asking turn Stop', async () => {
5033
+ const h = harness([reply({ text: 'Ship it' })])
5034
+ h.recorder.acknowledgementRequiredFor = () => false
5035
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
5036
+ writeSessionState(CODEX_THREAD, h.env, { last_prompt_at: AWAY })
5037
+ registerQuestion(CODEX_THREAD, h.env, { question: 'Ship it?' }, NOW)
5038
+ const wake = codexWake({ sourceAlive: true, probe: { state: 'live' } })
5039
+ let launched:
5040
+ | { envelope: { session_id?: string; cwd?: string }; harness: string }
5041
+ | undefined
5042
+ const deps: CommandDeps = {
5043
+ ...h.deps,
5044
+ codexWake: wake,
5045
+ codexSourcePid: 12345,
5046
+ spawnQuestionSettlement: (launch) => {
5047
+ launched = launch
5048
+ },
5049
+ }
5050
+
5051
+ await hookRunCommand(
5052
+ deps,
5053
+ 'user-prompt-submit',
5054
+ stdin({
5055
+ session_id: CODEX_THREAD,
5056
+ cwd: h.deps.cwd,
5057
+ prompt: 'Keep working on the unrelated release notes.',
5058
+ }),
5059
+ 'codex',
5060
+ )
5061
+
5062
+ expect(launched).toEqual({
5063
+ envelope: { session_id: CODEX_THREAD, cwd: h.deps.cwd },
5064
+ harness: 'codex',
5065
+ })
5066
+ expect(h.deps.now?.()).toBe(NOW)
5067
+ expect(h.recorder.submitted).toEqual([])
5068
+ expect(readSessionState(CODEX_THREAD, h.env).last_stop_at).toBeUndefined()
5069
+
5070
+ await hookRunCommand(
5071
+ deps,
5072
+ 'question-settlement',
5073
+ stdin(launched?.envelope),
5074
+ 'codex',
5075
+ )
5076
+
5077
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
5078
+ expect(h.io.outLines).toEqual([])
5079
+ expect(wake.resumed).toEqual([])
5080
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
5081
+ expect(readSessionState(CODEX_THREAD, h.env).last_stop_at).toBeUndefined()
5082
+
5083
+ await hookRunCommand(
5084
+ deps,
5085
+ 'stop',
5086
+ stdin({ session_id: CODEX_THREAD, cwd: h.deps.cwd }),
5087
+ 'codex',
5088
+ )
5089
+ expect(h.io.outLines.map((line) => JSON.parse(line))).toEqual([
5090
+ expect.objectContaining({ decision: 'block' }),
5091
+ ])
5092
+
5093
+ await hookRunCommand(
5094
+ deps,
5095
+ 'stop',
5096
+ stdin({
5097
+ session_id: CODEX_THREAD,
5098
+ cwd: h.deps.cwd,
5099
+ stop_hook_active: true,
5100
+ }),
5101
+ 'codex',
5102
+ )
5103
+ await hookRunCommand(
5104
+ deps,
5105
+ 'stop',
5106
+ stdin({ session_id: CODEX_THREAD, cwd: h.deps.cwd }),
5107
+ 'codex',
5108
+ )
5109
+
5110
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
5111
+ expect(h.io.outLines).toHaveLength(1)
5112
+ expect(readSessionState(CODEX_THREAD, h.env).pending).toBeUndefined()
5113
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeUndefined()
5114
+ })
5115
+
5116
+ it('keeps the detached recovery owner through the complete reply window', async () => {
5117
+ const h = harness([])
5118
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
5119
+ writeSessionState(CODEX_THREAD, h.env, { last_prompt_at: AWAY })
5120
+ registerQuestion(CODEX_THREAD, h.env, { question: 'Ship it?' }, NOW)
5121
+ let settlementInput: { session_id?: string; cwd?: string } | undefined
5122
+ const deps: CommandDeps = {
5123
+ ...h.deps,
5124
+ codexWake: codexWake({ sourceAlive: true, probe: { state: 'live' } }),
5125
+ codexSourcePid: 12345,
5126
+ spawnQuestionSettlement: (launch) => {
5127
+ settlementInput = launch.envelope
5128
+ },
5129
+ }
5130
+
5131
+ await hookRunCommand(
5132
+ deps,
5133
+ 'user-prompt-submit',
5134
+ stdin({
5135
+ session_id: CODEX_THREAD,
5136
+ cwd: h.deps.cwd,
5137
+ prompt: 'Keep working on the unrelated release notes.',
5138
+ }),
5139
+ 'codex',
5140
+ )
5141
+ expect(h.deps.now?.()).toBe(NOW)
5142
+
5143
+ await hookRunCommand(
5144
+ deps,
5145
+ 'question-settlement',
5146
+ stdin(settlementInput),
5147
+ 'codex',
5148
+ )
5149
+
5150
+ expect(h.deps.now?.()).toBe(NOW + 480_000)
5151
+ expect(h.recorder.closed).toEqual([h.recorder.receipts[0]])
5152
+ expect(readSessionState(CODEX_THREAD, h.env).pending).toBeUndefined()
5153
+ })
5154
+
4880
5155
  it('continues the held turn with decision:block when the answer arrives during the hold', async () => {
4881
5156
  const h = harness([reply({ text: 'BETA' })])
4882
5157
  writeGlobalConfig(h, 'ask_grace_seconds = 0\n')