@raidiant/notifai 0.4.0 → 0.5.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.
@@ -12,6 +12,7 @@ import type {
12
12
  } from '@raidiant/notifai-protocol'
13
13
  import { describe, expect, it } from 'vitest'
14
14
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
15
+ import type { ClaudeWakeAdapters } from './claude-wake.js'
15
16
  import {
16
17
  askCommand,
17
18
  accessStatusCommand,
@@ -172,6 +173,26 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
172
173
  }
173
174
  }
174
175
 
176
+ /**
177
+ * An environment that cannot see the machine running the suite.
178
+ *
179
+ * Harness discovery walks the real home directory whenever the environment
180
+ * names no override, so a developer with Notifai's own global hooks installed
181
+ * was silently running these cases against their own settings file — and a
182
+ * check that failed on that file failed only for them. Every setup case that
183
+ * asserts on readiness therefore gets its own home.
184
+ */
185
+ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
186
+ const home = path.join(cwd, 'home')
187
+ return {
188
+ HOME: home,
189
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
190
+ CODEX_HOME: path.join(home, '.codex'),
191
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
192
+ XDG_STATE_HOME: path.join(cwd, 'state'),
193
+ }
194
+ }
195
+
175
196
  const receipt: SubmissionReceipt = {
176
197
  request_id: 'req_reply_test',
177
198
  replayed: false,
@@ -1573,8 +1594,8 @@ describe('config surfaces', () => {
1573
1594
  const io = new InteractiveIo()
1574
1595
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
1575
1596
  const text = io.outLines.join('\n')
1576
- expect(text).toContain('Questions & presence')
1577
- expect(text).toContain('Whether sitting at this keyboard holds a question back')
1597
+ expect(text).toContain('Questions')
1598
+ expect(text).toContain('Optional delay before a question may reach your devices')
1578
1599
  })
1579
1600
 
1580
1601
  it('keeps --plain available to a human who wants the parseable form', () => {
@@ -1585,18 +1606,18 @@ describe('config surfaces', () => {
1585
1606
 
1586
1607
  it('explains one setting, and says so in JSON when asked', () => {
1587
1608
  const io = new InteractiveIo()
1588
- expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
1609
+ expect(configExplainCommand(configDeps(io), 'ask_notifications', { json: true })).toBe(EXIT.ok)
1589
1610
  const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
1590
- expect(parsed['key']).toBe('require_idle')
1611
+ expect(parsed['key']).toBe('ask_notifications')
1591
1612
  expect(parsed['accepts']).toBe('true or false')
1592
- expect(parsed['detail']).toContain('Off (the default)')
1593
- expect(parsed['detail']).toContain('Turn this on')
1613
+ expect(parsed['detail']).toContain('The master switch')
1614
+ expect(parsed['detail']).toContain('Turn it off')
1594
1615
  })
1595
1616
 
1596
1617
  it('rejects an unknown setting and points at the nearest real one', () => {
1597
1618
  const io = new CapturedIo()
1598
- expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
1599
- expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
1619
+ expect(configExplainCommand(configDeps(io), 'ask_notification')).toBe(EXIT.usage)
1620
+ expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
1600
1621
  })
1601
1622
 
1602
1623
  it('refuses an enum value the sender would later reject', async () => {
@@ -1809,10 +1830,10 @@ describe('interactive command UX', () => {
1809
1830
  EXIT.usage,
1810
1831
  )
1811
1832
  expect(io.errLines).toEqual([
1812
- 'ask_grace_seconds must be between 0 and 334.',
1833
+ 'ask_grace_seconds must be between 0 and 360.',
1813
1834
  // Names the key and its range: `"1.5" is not an integer` left the reader
1814
1835
  // to work out which of the two settings they had just mistyped.
1815
- 'ask_grace_seconds takes a whole number from 0s–334s, not "1.5".',
1836
+ 'ask_grace_seconds takes a whole number from 0s–360s, not "1.5".',
1816
1837
  ])
1817
1838
  expect(existsSync(configFile)).toBe(false)
1818
1839
  })
@@ -1953,7 +1974,7 @@ describe('init', () => {
1953
1974
  source: 'Raidiant-io/notifai',
1954
1975
  sourceType: 'github',
1955
1976
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1956
- ref: 'v0.4.0',
1977
+ ref: 'v0.5.0',
1957
1978
  }
1958
1979
  }
1959
1980
 
@@ -1981,7 +2002,7 @@ describe('init', () => {
1981
2002
  return {
1982
2003
  ...makeDeps(io, client),
1983
2004
  cwd,
1984
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2005
+ env: isolatedEnv(cwd),
1985
2006
  nativeSkills,
1986
2007
  }
1987
2008
  }
@@ -2056,7 +2077,7 @@ describe('init', () => {
2056
2077
  })
2057
2078
 
2058
2079
  it('pins the skill installer to the tagged public release syntax', () => {
2059
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.4.0')
2080
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.5.0')
2060
2081
  expect(SKILLS_SOURCE).not.toContain('@v')
2061
2082
  })
2062
2083
 
@@ -2079,7 +2100,7 @@ describe('init', () => {
2079
2100
  source: 'Raidiant-io/notifai',
2080
2101
  sourceType: 'github',
2081
2102
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2082
- ref: 'v0.4.0',
2103
+ ref: 'v0.5.0',
2083
2104
  },
2084
2105
  ],
2085
2106
  }),
@@ -2311,7 +2332,7 @@ describe('init', () => {
2311
2332
  const deps: CommandDeps = {
2312
2333
  ...makeDeps(io, { health: async () => true } as unknown as ApiClient),
2313
2334
  cwd,
2314
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2335
+ env: isolatedEnv(cwd),
2315
2336
  store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
2316
2337
  }
2317
2338
 
@@ -2336,7 +2357,7 @@ describe('init', () => {
2336
2357
  const deps = {
2337
2358
  ...makeDeps(io, client),
2338
2359
  cwd,
2339
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2360
+ env: isolatedEnv(cwd),
2340
2361
  }
2341
2362
 
2342
2363
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2409,7 +2430,7 @@ describe('init', () => {
2409
2430
  const deps = {
2410
2431
  ...makeDeps(io, client),
2411
2432
  cwd,
2412
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2433
+ env: isolatedEnv(cwd),
2413
2434
  }
2414
2435
 
2415
2436
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2451,7 +2472,7 @@ describe('init', () => {
2451
2472
  const deps: CommandDeps = {
2452
2473
  ...makeDeps(io, client),
2453
2474
  cwd,
2454
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2475
+ env: isolatedEnv(cwd),
2455
2476
  now: () => now,
2456
2477
  sleep: async (milliseconds) => {
2457
2478
  now += milliseconds
@@ -2517,7 +2538,7 @@ describe('init', () => {
2517
2538
  const deps: CommandDeps = {
2518
2539
  ...makeDeps(io, client),
2519
2540
  cwd,
2520
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2541
+ env: isolatedEnv(cwd),
2521
2542
  now: () => now,
2522
2543
  sleep: async (milliseconds) => {
2523
2544
  now += milliseconds
@@ -2557,7 +2578,7 @@ describe('init', () => {
2557
2578
  const deps = {
2558
2579
  ...makeDeps(io, client),
2559
2580
  cwd,
2560
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2581
+ env: isolatedEnv(cwd),
2561
2582
  }
2562
2583
 
2563
2584
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2601,7 +2622,7 @@ describe('init', () => {
2601
2622
  const deps: CommandDeps = {
2602
2623
  ...makeDeps(io, client),
2603
2624
  cwd,
2604
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2625
+ env: isolatedEnv(cwd),
2605
2626
  now: () => now,
2606
2627
  sleep: async (milliseconds) => {
2607
2628
  now += milliseconds
@@ -2647,7 +2668,7 @@ describe('init', () => {
2647
2668
  const deps = {
2648
2669
  ...makeDeps(io, client),
2649
2670
  cwd,
2650
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: '/dev/null' },
2671
+ env: { ...isolatedEnv(cwd), XDG_STATE_HOME: '/dev/null' },
2651
2672
  }
2652
2673
 
2653
2674
  await expect(initCommand(deps, { hooks: false, skills: false })).resolves.toBe(EXIT.failed)
@@ -2674,7 +2695,7 @@ describe('init', () => {
2674
2695
  const deps = {
2675
2696
  ...makeDeps(io, client),
2676
2697
  cwd,
2677
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2698
+ env: isolatedEnv(cwd),
2678
2699
  }
2679
2700
 
2680
2701
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
@@ -2692,7 +2713,7 @@ describe('init', () => {
2692
2713
  {
2693
2714
  ...makeDeps(doctorIo, client),
2694
2715
  cwd,
2695
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2716
+ env: isolatedEnv(cwd),
2696
2717
  },
2697
2718
  {},
2698
2719
  ),
@@ -2752,7 +2773,7 @@ describe('init', () => {
2752
2773
  const deps = {
2753
2774
  ...makeDeps(io, client),
2754
2775
  cwd,
2755
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2776
+ env: isolatedEnv(cwd),
2756
2777
  }
2757
2778
 
2758
2779
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2788,7 +2809,7 @@ describe('init', () => {
2788
2809
  const deps = {
2789
2810
  ...makeDeps(io, client),
2790
2811
  cwd,
2791
- env: { XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
2812
+ env: { ...isolatedEnv(cwd), XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
2792
2813
  }
2793
2814
  expect(
2794
2815
  await initCommand(deps, { projectId: 'shared-project', hooks: false, skills: false }),
@@ -2973,7 +2994,184 @@ describe('asking before the hooks have ever run', () => {
2973
2994
 
2974
2995
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2975
2996
  expect(io.outLines).toContain(
2976
- 'Question registered. Ask it in the conversation as usual and end your turn.',
2997
+ 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
2998
+ )
2999
+ })
3000
+
3001
+ // A harness exports its markers into everything it starts, so a nested
3002
+ // harness sees its parent's markers alongside its own. Neither order between
3003
+ // two markers can be right, and both nestings are ordinary: an orchestrator
3004
+ // running inside Claude Code starts Codex, and the reverse happens just as
3005
+ // often. The environment cannot settle it; the pointer index can.
3006
+ it('routes to the live Codex thread when a Claude Code orchestrator started it', () => {
3007
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-under-claude-'))
3008
+ const io = new CapturedIo()
3009
+ const env = {
3010
+ ...isolatedEnv(cwd),
3011
+ // Inherited from the parent Claude Code process, and unstrippable.
3012
+ CLAUDECODE: '1',
3013
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3014
+ CODEX_THREAD_ID: 'codex-current-thread',
3015
+ }
3016
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3017
+
3018
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3019
+ // The configuration that made this unrecoverable: Claude Code hooks
3020
+ // installed machine-wide match every directory, including this one.
3021
+ expect(
3022
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3023
+ ).toBe(EXIT.ok)
3024
+ trustInstalledCodexHooks(cwd, env)
3025
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3026
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3027
+ io.outLines = []
3028
+
3029
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3030
+ expect(readSessionState('codex-current-thread', env).pending?.[0]?.question).toBe('Ship it?')
3031
+ expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
3032
+ })
3033
+
3034
+ it('routes to the live Claude Code session when a Codex orchestrator started it', () => {
3035
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-under-codex-'))
3036
+ const io = new CapturedIo()
3037
+ const env = {
3038
+ ...isolatedEnv(cwd),
3039
+ CODEX_THREAD_ID: 'codex-orchestrator',
3040
+ CLAUDECODE: '1',
3041
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
3042
+ }
3043
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3044
+
3045
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3046
+ EXIT.ok,
3047
+ )
3048
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3049
+ trustInstalledCodexHooks(cwd, env)
3050
+ // Both sessions are live here. The parent fired when its own turn began;
3051
+ // the child fired for the turn that is running this command.
3052
+ writeSessionState('codex-orchestrator', env, { last_prompt_at: 40, last_stop_at: 39 })
3053
+ writeProjectSession(cwd, env, 'codex-orchestrator', 40, 'codex')
3054
+ writeSessionState('claude-current', env, { last_prompt_at: 42, last_stop_at: 41 })
3055
+ writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
3056
+ io.outLines = []
3057
+
3058
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3059
+ expect(readSessionState('claude-current', env).pending?.[0]?.question).toBe('Ship it?')
3060
+ expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
3061
+ })
3062
+
3063
+ it('judges the fired check against the active harness, not a global installation of another', async () => {
3064
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-active-harness-'))
3065
+ const io = new CapturedIo()
3066
+ const env = {
3067
+ ...isolatedEnv(cwd),
3068
+ CLAUDECODE: '1',
3069
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3070
+ CODEX_THREAD_ID: 'codex-current-thread',
3071
+ }
3072
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3073
+
3074
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3075
+ expect(
3076
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3077
+ ).toBe(EXIT.ok)
3078
+ trustInstalledCodexHooks(cwd, env)
3079
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3080
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3081
+
3082
+ const readiness = await assessReadiness(deps)
3083
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3084
+ // The Codex hooks fired. A missing Claude Code pointer says nothing about
3085
+ // that, and telling this agent to send a Claude Code prompt is advice it
3086
+ // cannot act on: it would refuse to ask, forever.
3087
+ expect(fired?.status).toBe('ready')
3088
+ expect(fired?.detail).toMatch(/active Codex session/i)
3089
+ expect(fired?.detail).not.toMatch(/Claude Code/i)
3090
+ })
3091
+
3092
+ it('advises every harness whose markers are present when none of them has fired here', async () => {
3093
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-contested-'))
3094
+ const io = new CapturedIo()
3095
+ const env = {
3096
+ ...isolatedEnv(cwd),
3097
+ CLAUDECODE: '1',
3098
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3099
+ CODEX_THREAD_ID: 'codex-current-thread',
3100
+ }
3101
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3102
+
3103
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3104
+ expect(
3105
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3106
+ ).toBe(EXIT.ok)
3107
+ trustInstalledCodexHooks(cwd, env)
3108
+
3109
+ const readiness = await assessReadiness(deps)
3110
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3111
+ expect(fired?.status).toBe('optional-gap')
3112
+ expect(fired?.detail).toMatch(/Claude Code/)
3113
+ expect(fired?.detail).toMatch(/Codex: send one prompt/)
3114
+
3115
+ io.errLines = []
3116
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3117
+ expect(io.errLines.join(' ')).toMatch(/Several harness markers are present/i)
3118
+ })
3119
+
3120
+ it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
3121
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-commitment-'))
3122
+ const io = new CapturedIo()
3123
+ const env = {
3124
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3125
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3126
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3127
+ CODEX_THREAD_ID: 'codex-commitment-thread',
3128
+ }
3129
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3130
+
3131
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3132
+ trustInstalledCodexHooks(cwd, env)
3133
+ writeSessionState('codex-commitment-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3134
+ writeProjectSession(cwd, env, 'codex-commitment-thread', 42, 'codex')
3135
+ io.outLines = []
3136
+
3137
+ expect(
3138
+ askCommand(deps, 'Which environment should I deploy to?', {
3139
+ choice: ['Staging', 'Production', 'Cancel'],
3140
+ }),
3141
+ ).toBe(EXIT.ok)
3142
+
3143
+ const said = io.outLines.join('\n')
3144
+ expect(said).toContain('Before ending this turn')
3145
+ for (const answer of ['Staging', 'Production', 'Cancel']) {
3146
+ expect(said).toContain(`If the answer is "${answer}"`)
3147
+ }
3148
+ expect(said).toContain('unexpected typed answer')
3149
+ expect(said).toContain('work you will resume')
3150
+ expect(said).toContain('without asking the user to confirm again')
3151
+ expect(said).toContain('not as approval')
3152
+ expect(said).toContain('cannot answer a harness permission prompt or interactive picker')
3153
+ })
3154
+
3155
+ it('requires a concrete fallback for free-text answers before the turn ends', () => {
3156
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-free-text-'))
3157
+ const io = new CapturedIo()
3158
+ const env = {
3159
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3160
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3161
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3162
+ CODEX_THREAD_ID: 'codex-free-text-thread',
3163
+ }
3164
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3165
+
3166
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3167
+ trustInstalledCodexHooks(cwd, env)
3168
+ writeSessionState('codex-free-text-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3169
+ writeProjectSession(cwd, env, 'codex-free-text-thread', 42, 'codex')
3170
+ io.outLines = []
3171
+
3172
+ expect(askCommand(deps, 'What rollout adjustment should I make?', {})).toBe(EXIT.ok)
3173
+ expect(io.outLines.join('\n')).toContain(
3174
+ 'For the free-text answer: state how its content will determine the concrete work you resume.',
2977
3175
  )
2978
3176
  })
2979
3177
 
@@ -3039,7 +3237,7 @@ describe('asking before the hooks have ever run', () => {
3039
3237
  source: 'Raidiant-io/notifai',
3040
3238
  sourceType: 'github',
3041
3239
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
3042
- ref: 'v0.4.0',
3240
+ ref: 'v0.5.0',
3043
3241
  },
3044
3242
  ]
3045
3243
  : [],
@@ -3295,7 +3493,7 @@ describe('asking before the hooks have ever run', () => {
3295
3493
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
3296
3494
  writeFileSync(
3297
3495
  path.join(cwd, '.notifai', 'config.local.toml'),
3298
- 'ask_notifications = false\nrequire_idle = false\naway_after_seconds = 45\nask_grace_seconds = 90\nhook_reply_timeout_seconds = 120\n',
3496
+ 'ask_notifications = false\nask_grace_seconds = 90\n',
3299
3497
  )
3300
3498
  const io = new CapturedIo()
3301
3499
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
@@ -3305,29 +3503,167 @@ describe('asking before the hooks have ever run', () => {
3305
3503
  const said = io.outLines.join('\n')
3306
3504
  expect(said).toContain('Question routing settings:')
3307
3505
  expect(said).toContain('ask_notifications=false (project-local:')
3308
- expect(said).toContain('require_idle=false (project-local:')
3309
- expect(said).toContain('away_after_seconds=45 (project-local:')
3310
3506
  expect(said).toContain('ask_grace_seconds=90 (project-local:')
3311
- expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
3507
+ // Nothing about where the user is standing is listed, because nothing
3508
+ // about where they are standing decides anything any more.
3509
+ expect(said).not.toMatch(/require_idle|away_after_seconds|hook_reply_timeout_seconds/)
3312
3510
  })
3313
3511
 
3314
- it('keeps an installed Stop timeout valid when runtime timing preferences change', async () => {
3512
+ it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
3315
3513
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
3316
3514
  const io = new CapturedIo()
3317
- const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
3318
- const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3515
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
3319
3516
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3320
3517
  EXIT.ok,
3321
3518
  )
3322
3519
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
3323
- writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
3520
+ writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
3324
3521
  io.outLines = []
3325
3522
 
3326
3523
  await doctorCommand(deps, {})
3327
- expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(timeout\)/)
3328
- expect(io.outLines.join('\n')).toContain(
3329
- 'declared Stop timeouts match the fixed 540s process budget where required; Codex uses host defaults',
3524
+ expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(stop shape\)/)
3525
+ })
3526
+
3527
+ it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
3528
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
3529
+ const io = new CapturedIo()
3530
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
3531
+ // Exactly what every build before the asynchronous waiter wrote: a blocking
3532
+ // handler with the old ceiling. It looks installed and it fires, but the
3533
+ // waiter would hold the turn and then be killed at Claude's silent default.
3534
+ mkdirSync(path.join(cwd, '.claude'), { recursive: true })
3535
+ applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
3536
+ hooks: {
3537
+ Stop: [
3538
+ {
3539
+ hooks: [
3540
+ {
3541
+ type: 'command',
3542
+ command: `'${path.join(cwd, 'home', '.notifai', 'bin', 'hook-adapter')}' hook stop --owner notifai --harness claude-code`,
3543
+ timeout: 540,
3544
+ },
3545
+ ],
3546
+ },
3547
+ ],
3548
+ },
3549
+ })
3550
+
3551
+ await doctorCommand(deps, {})
3552
+ const out = io.outLines.join('\n')
3553
+ expect(out).toMatch(/FAIL\s+hooks \(stop shape\)/)
3554
+ expect(out).toContain('needs `async: true`')
3555
+ expect(out).toContain('kills the backgrounded waiter at its 600s default')
3556
+ })
3557
+
3558
+ /** A configured Claude Code project with one live session claiming `pid`. */
3559
+ function claudeWakeDeps(
3560
+ io: CapturedIo,
3561
+ descriptor: (pid: number) => unknown,
3562
+ ): { deps: CommandDeps; cwd: string } {
3563
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-wake-'))
3564
+ const base = {
3565
+ ...makeDeps(io, {} as ApiClient),
3566
+ cwd,
3567
+ env: { ...isolatedEnv(cwd), CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'sess-live' },
3568
+ claudeSourcePid: 4242,
3569
+ claudeWake: { readDescriptor: descriptor } as unknown as ClaudeWakeAdapters,
3570
+ }
3571
+ expect(hooksInstallCommand(base, { harness: 'claude-code', execPath, scriptPath })).toBe(
3572
+ EXIT.ok,
3330
3573
  )
3574
+ io.outLines = []
3575
+ return { deps: base, cwd }
3576
+ }
3577
+
3578
+ it('reports a live Claude session as reachable without connecting to its socket', async () => {
3579
+ const io = new CapturedIo()
3580
+ const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
3581
+ writeFileSync(socket, '')
3582
+ const { deps } = claudeWakeDeps(io, (pid) => ({
3583
+ pid,
3584
+ sessionId: 'sess-live',
3585
+ cwd: '/tmp',
3586
+ startedAt: 1,
3587
+ procStart: 'x',
3588
+ version: '2.1.228',
3589
+ peerProtocol: 1,
3590
+ messagingSocketPath: socket,
3591
+ status: 'idle',
3592
+ }))
3593
+
3594
+ await doctorCommand(deps, {})
3595
+ const out = io.outLines.join('\n')
3596
+ expect(out).toMatch(/ok\s+hooks \(wake route\)/)
3597
+ expect(out).toContain(socket)
3598
+ expect(out).toContain('an answer can start a turn here without you')
3599
+ // Doctor never widens the user's inbound policy to make Notifai work: the
3600
+ // poster is the session's own hook child and needs no such permission.
3601
+ expect(out).not.toContain('crossSessionInbound')
3602
+ })
3603
+
3604
+ it('names a --bare session as the reason an answer would wait for the next turn', async () => {
3605
+ const io = new CapturedIo()
3606
+ const { deps } = claudeWakeDeps(io, () => {
3607
+ throw new Error('ENOENT: no such file or directory')
3608
+ })
3609
+
3610
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
3611
+ const out = io.outLines.join('\n')
3612
+ // Honest, and not a blocker: nothing is lost, it simply arrives later.
3613
+ expect(out).toMatch(/--\s+hooks \(wake route\)/)
3614
+ expect(out).toContain('`--bare` binds no inbox socket')
3615
+ expect(out).toContain("at this session's next turn")
3616
+ expect(out).not.toMatch(/FAIL\s+hooks \(wake route\)/)
3617
+ })
3618
+
3619
+ it('fails closed on an inbox protocol it does not recognise', async () => {
3620
+ const io = new CapturedIo()
3621
+ const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
3622
+ writeFileSync(socket, '')
3623
+ const { deps } = claudeWakeDeps(io, (pid) => ({
3624
+ pid,
3625
+ sessionId: 'sess-live',
3626
+ cwd: '/tmp',
3627
+ startedAt: 1,
3628
+ procStart: 'x',
3629
+ version: '2.1.228',
3630
+ peerProtocol: 7,
3631
+ messagingSocketPath: socket,
3632
+ status: 'idle',
3633
+ }))
3634
+
3635
+ await doctorCommand(deps, {})
3636
+ const out = io.outLines.join('\n')
3637
+ expect(out).toContain('speaks inbox protocol 7')
3638
+ expect(out).toContain('refusing to guess at an undocumented wire format')
3639
+ })
3640
+
3641
+ it('reports Codex resume readiness from its writer-lock directory alone', async () => {
3642
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
3643
+ const io = new CapturedIo()
3644
+ const env = {
3645
+ ...isolatedEnv(cwd),
3646
+ CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
3647
+ }
3648
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3649
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3650
+
3651
+ io.outLines = []
3652
+ await doctorCommand(deps, {})
3653
+ expect(io.outLines.join('\n')).toContain('nothing can be resumed: no thread-writer-lock')
3654
+
3655
+ mkdirSync(path.join(env['CODEX_HOME']!, 'thread-writer-locks'), { recursive: true })
3656
+ io.outLines = []
3657
+ await doctorCommand(deps, {})
3658
+ const out = io.outLines.join('\n')
3659
+ if (process.platform === 'darwin') {
3660
+ expect(out).toMatch(/ok\s+hooks \(wake route\)/)
3661
+ expect(out).toContain('can prove a stopped thread unowned before resuming it')
3662
+ } else {
3663
+ // Linux has no non-blocking `flock` through Node's `open(2)` flags, so
3664
+ // the gate stays shut and doctor says so rather than implying a wake.
3665
+ expect(out).toContain('nothing can be resumed')
3666
+ }
3331
3667
  })
3332
3668
 
3333
3669
  it('documents the failing exit contract in doctor JSON', async () => {