@raidiant/notifai 0.4.0 → 0.5.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 (62) hide show
  1. package/README.md +3 -4
  2. package/dist/claude-wake.d.ts +101 -0
  3. package/dist/claude-wake.js +397 -0
  4. package/dist/claude-wake.js.map +1 -0
  5. package/dist/codex-wake.d.ts +84 -0
  6. package/dist/codex-wake.js +238 -0
  7. package/dist/codex-wake.js.map +1 -0
  8. package/dist/commands.d.ts +15 -10
  9. package/dist/commands.js +321 -88
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +2 -2
  12. package/dist/config-schema.js +9 -35
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +5 -38
  15. package/dist/config.js +11 -32
  16. package/dist/config.js.map +1 -1
  17. package/dist/harnesses.d.ts +3 -13
  18. package/dist/harnesses.js +6 -17
  19. package/dist/harnesses.js.map +1 -1
  20. package/dist/hooks.d.ts +119 -65
  21. package/dist/hooks.js +330 -285
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +22 -7
  24. package/dist/install-hooks.js +43 -28
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/interactive.d.ts +1 -1
  27. package/dist/interactive.js +2 -4
  28. package/dist/interactive.js.map +1 -1
  29. package/dist/main.js +6 -13
  30. package/dist/main.js.map +1 -1
  31. package/dist/release.d.ts +31 -0
  32. package/dist/release.js +52 -0
  33. package/dist/release.js.map +1 -0
  34. package/dist/ui/theme.d.ts +1 -1
  35. package/package.json +9 -9
  36. package/src/claude-wake.test.ts +320 -0
  37. package/src/claude-wake.ts +489 -0
  38. package/src/codex-wake.test.ts +253 -0
  39. package/src/codex-wake.ts +288 -0
  40. package/src/commands.test.ts +393 -40
  41. package/src/commands.ts +433 -109
  42. package/src/config-schema.test.ts +3 -3
  43. package/src/config-schema.ts +11 -40
  44. package/src/config.test.ts +21 -16
  45. package/src/config.ts +15 -69
  46. package/src/harnesses.test.ts +7 -21
  47. package/src/harnesses.ts +17 -19
  48. package/src/hooks.test.ts +780 -290
  49. package/src/hooks.ts +466 -339
  50. package/src/install-hooks.test.ts +44 -3
  51. package/src/install-hooks.ts +43 -28
  52. package/src/interactive.ts +2 -7
  53. package/src/logging.test.ts +3 -3
  54. package/src/main.ts +6 -12
  55. package/src/release.ts +51 -0
  56. package/src/skill.test.ts +23 -0
  57. package/src/ui/config-view.test.ts +5 -5
  58. package/dist/idle.d.ts +0 -16
  59. package/dist/idle.js +0 -44
  60. package/dist/idle.js.map +0 -1
  61. package/src/idle.test.ts +0 -32
  62. package/src/idle.ts +0 -41
@@ -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,
@@ -55,6 +56,22 @@ import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
55
56
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
56
57
  import type { Tone } from './ui/theme.js'
57
58
 
59
+ /**
60
+ * The release tag this build pins the agent skill to.
61
+ *
62
+ * Read from the manifest for the same reason the CLI itself does: a fixture
63
+ * carrying a literal tag stops matching the moment the version is bumped, and
64
+ * a test that has to be hand-edited on every release is a copy of the constant
65
+ * it is supposed to be checking.
66
+ */
67
+ const RELEASE_REF = `v${
68
+ (
69
+ JSON.parse(
70
+ readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
71
+ ) as { version: string }
72
+ ).version
73
+ }`
74
+
58
75
  class CapturedIo implements CommandIo {
59
76
  outLines: string[] = []
60
77
  errLines: string[] = []
@@ -172,6 +189,26 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
172
189
  }
173
190
  }
174
191
 
192
+ /**
193
+ * An environment that cannot see the machine running the suite.
194
+ *
195
+ * Harness discovery walks the real home directory whenever the environment
196
+ * names no override, so a developer with Notifai's own global hooks installed
197
+ * was silently running these cases against their own settings file — and a
198
+ * check that failed on that file failed only for them. Every setup case that
199
+ * asserts on readiness therefore gets its own home.
200
+ */
201
+ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
202
+ const home = path.join(cwd, 'home')
203
+ return {
204
+ HOME: home,
205
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
206
+ CODEX_HOME: path.join(home, '.codex'),
207
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
208
+ XDG_STATE_HOME: path.join(cwd, 'state'),
209
+ }
210
+ }
211
+
175
212
  const receipt: SubmissionReceipt = {
176
213
  request_id: 'req_reply_test',
177
214
  replayed: false,
@@ -1573,8 +1610,8 @@ describe('config surfaces', () => {
1573
1610
  const io = new InteractiveIo()
1574
1611
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
1575
1612
  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')
1613
+ expect(text).toContain('Questions')
1614
+ expect(text).toContain('Optional delay before a question may reach your devices')
1578
1615
  })
1579
1616
 
1580
1617
  it('keeps --plain available to a human who wants the parseable form', () => {
@@ -1585,18 +1622,18 @@ describe('config surfaces', () => {
1585
1622
 
1586
1623
  it('explains one setting, and says so in JSON when asked', () => {
1587
1624
  const io = new InteractiveIo()
1588
- expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
1625
+ expect(configExplainCommand(configDeps(io), 'ask_notifications', { json: true })).toBe(EXIT.ok)
1589
1626
  const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
1590
- expect(parsed['key']).toBe('require_idle')
1627
+ expect(parsed['key']).toBe('ask_notifications')
1591
1628
  expect(parsed['accepts']).toBe('true or false')
1592
- expect(parsed['detail']).toContain('Off (the default)')
1593
- expect(parsed['detail']).toContain('Turn this on')
1629
+ expect(parsed['detail']).toContain('The master switch')
1630
+ expect(parsed['detail']).toContain('Turn it off')
1594
1631
  })
1595
1632
 
1596
1633
  it('rejects an unknown setting and points at the nearest real one', () => {
1597
1634
  const io = new CapturedIo()
1598
- expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
1599
- expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
1635
+ expect(configExplainCommand(configDeps(io), 'ask_notification')).toBe(EXIT.usage)
1636
+ expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
1600
1637
  })
1601
1638
 
1602
1639
  it('refuses an enum value the sender would later reject', async () => {
@@ -1809,10 +1846,10 @@ describe('interactive command UX', () => {
1809
1846
  EXIT.usage,
1810
1847
  )
1811
1848
  expect(io.errLines).toEqual([
1812
- 'ask_grace_seconds must be between 0 and 334.',
1849
+ 'ask_grace_seconds must be between 0 and 360.',
1813
1850
  // Names the key and its range: `"1.5" is not an integer` left the reader
1814
1851
  // 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".',
1852
+ 'ask_grace_seconds takes a whole number from 0s–360s, not "1.5".',
1816
1853
  ])
1817
1854
  expect(existsSync(configFile)).toBe(false)
1818
1855
  })
@@ -1953,7 +1990,7 @@ describe('init', () => {
1953
1990
  source: 'Raidiant-io/notifai',
1954
1991
  sourceType: 'github',
1955
1992
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1956
- ref: 'v0.4.0',
1993
+ ref: RELEASE_REF,
1957
1994
  }
1958
1995
  }
1959
1996
 
@@ -1981,7 +2018,7 @@ describe('init', () => {
1981
2018
  return {
1982
2019
  ...makeDeps(io, client),
1983
2020
  cwd,
1984
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2021
+ env: isolatedEnv(cwd),
1985
2022
  nativeSkills,
1986
2023
  }
1987
2024
  }
@@ -2055,8 +2092,9 @@ describe('init', () => {
2055
2092
  expect(io.errLines).toEqual([])
2056
2093
  })
2057
2094
 
2058
- it('pins the skill installer to the tagged public release syntax', () => {
2059
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.4.0')
2095
+ it('pins the skill installer to the tagged release this build actually is', () => {
2096
+ expect(SKILLS_SOURCE).toBe(`Raidiant-io/notifai#${RELEASE_REF}`)
2097
+ // `#` selects a Git ref; `@` would select a skill name instead.
2060
2098
  expect(SKILLS_SOURCE).not.toContain('@v')
2061
2099
  })
2062
2100
 
@@ -2079,7 +2117,7 @@ describe('init', () => {
2079
2117
  source: 'Raidiant-io/notifai',
2080
2118
  sourceType: 'github',
2081
2119
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2082
- ref: 'v0.4.0',
2120
+ ref: RELEASE_REF,
2083
2121
  },
2084
2122
  ],
2085
2123
  }),
@@ -2311,7 +2349,7 @@ describe('init', () => {
2311
2349
  const deps: CommandDeps = {
2312
2350
  ...makeDeps(io, { health: async () => true } as unknown as ApiClient),
2313
2351
  cwd,
2314
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2352
+ env: isolatedEnv(cwd),
2315
2353
  store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
2316
2354
  }
2317
2355
 
@@ -2336,7 +2374,7 @@ describe('init', () => {
2336
2374
  const deps = {
2337
2375
  ...makeDeps(io, client),
2338
2376
  cwd,
2339
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2377
+ env: isolatedEnv(cwd),
2340
2378
  }
2341
2379
 
2342
2380
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2409,7 +2447,7 @@ describe('init', () => {
2409
2447
  const deps = {
2410
2448
  ...makeDeps(io, client),
2411
2449
  cwd,
2412
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2450
+ env: isolatedEnv(cwd),
2413
2451
  }
2414
2452
 
2415
2453
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2451,7 +2489,7 @@ describe('init', () => {
2451
2489
  const deps: CommandDeps = {
2452
2490
  ...makeDeps(io, client),
2453
2491
  cwd,
2454
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2492
+ env: isolatedEnv(cwd),
2455
2493
  now: () => now,
2456
2494
  sleep: async (milliseconds) => {
2457
2495
  now += milliseconds
@@ -2517,7 +2555,7 @@ describe('init', () => {
2517
2555
  const deps: CommandDeps = {
2518
2556
  ...makeDeps(io, client),
2519
2557
  cwd,
2520
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2558
+ env: isolatedEnv(cwd),
2521
2559
  now: () => now,
2522
2560
  sleep: async (milliseconds) => {
2523
2561
  now += milliseconds
@@ -2557,7 +2595,7 @@ describe('init', () => {
2557
2595
  const deps = {
2558
2596
  ...makeDeps(io, client),
2559
2597
  cwd,
2560
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2598
+ env: isolatedEnv(cwd),
2561
2599
  }
2562
2600
 
2563
2601
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2601,7 +2639,7 @@ describe('init', () => {
2601
2639
  const deps: CommandDeps = {
2602
2640
  ...makeDeps(io, client),
2603
2641
  cwd,
2604
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2642
+ env: isolatedEnv(cwd),
2605
2643
  now: () => now,
2606
2644
  sleep: async (milliseconds) => {
2607
2645
  now += milliseconds
@@ -2647,7 +2685,7 @@ describe('init', () => {
2647
2685
  const deps = {
2648
2686
  ...makeDeps(io, client),
2649
2687
  cwd,
2650
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: '/dev/null' },
2688
+ env: { ...isolatedEnv(cwd), XDG_STATE_HOME: '/dev/null' },
2651
2689
  }
2652
2690
 
2653
2691
  await expect(initCommand(deps, { hooks: false, skills: false })).resolves.toBe(EXIT.failed)
@@ -2674,7 +2712,7 @@ describe('init', () => {
2674
2712
  const deps = {
2675
2713
  ...makeDeps(io, client),
2676
2714
  cwd,
2677
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2715
+ env: isolatedEnv(cwd),
2678
2716
  }
2679
2717
 
2680
2718
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
@@ -2692,7 +2730,7 @@ describe('init', () => {
2692
2730
  {
2693
2731
  ...makeDeps(doctorIo, client),
2694
2732
  cwd,
2695
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2733
+ env: isolatedEnv(cwd),
2696
2734
  },
2697
2735
  {},
2698
2736
  ),
@@ -2752,7 +2790,7 @@ describe('init', () => {
2752
2790
  const deps = {
2753
2791
  ...makeDeps(io, client),
2754
2792
  cwd,
2755
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2793
+ env: isolatedEnv(cwd),
2756
2794
  }
2757
2795
 
2758
2796
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2788,7 +2826,7 @@ describe('init', () => {
2788
2826
  const deps = {
2789
2827
  ...makeDeps(io, client),
2790
2828
  cwd,
2791
- env: { XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
2829
+ env: { ...isolatedEnv(cwd), XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
2792
2830
  }
2793
2831
  expect(
2794
2832
  await initCommand(deps, { projectId: 'shared-project', hooks: false, skills: false }),
@@ -2973,7 +3011,184 @@ describe('asking before the hooks have ever run', () => {
2973
3011
 
2974
3012
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2975
3013
  expect(io.outLines).toContain(
2976
- 'Question registered. Ask it in the conversation as usual and end your turn.',
3014
+ 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
3015
+ )
3016
+ })
3017
+
3018
+ // A harness exports its markers into everything it starts, so a nested
3019
+ // harness sees its parent's markers alongside its own. Neither order between
3020
+ // two markers can be right, and both nestings are ordinary: an orchestrator
3021
+ // running inside Claude Code starts Codex, and the reverse happens just as
3022
+ // often. The environment cannot settle it; the pointer index can.
3023
+ it('routes to the live Codex thread when a Claude Code orchestrator started it', () => {
3024
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-under-claude-'))
3025
+ const io = new CapturedIo()
3026
+ const env = {
3027
+ ...isolatedEnv(cwd),
3028
+ // Inherited from the parent Claude Code process, and unstrippable.
3029
+ CLAUDECODE: '1',
3030
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3031
+ CODEX_THREAD_ID: 'codex-current-thread',
3032
+ }
3033
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3034
+
3035
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3036
+ // The configuration that made this unrecoverable: Claude Code hooks
3037
+ // installed machine-wide match every directory, including this one.
3038
+ expect(
3039
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3040
+ ).toBe(EXIT.ok)
3041
+ trustInstalledCodexHooks(cwd, env)
3042
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3043
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3044
+ io.outLines = []
3045
+
3046
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3047
+ expect(readSessionState('codex-current-thread', env).pending?.[0]?.question).toBe('Ship it?')
3048
+ expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
3049
+ })
3050
+
3051
+ it('routes to the live Claude Code session when a Codex orchestrator started it', () => {
3052
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-under-codex-'))
3053
+ const io = new CapturedIo()
3054
+ const env = {
3055
+ ...isolatedEnv(cwd),
3056
+ CODEX_THREAD_ID: 'codex-orchestrator',
3057
+ CLAUDECODE: '1',
3058
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
3059
+ }
3060
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3061
+
3062
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3063
+ EXIT.ok,
3064
+ )
3065
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3066
+ trustInstalledCodexHooks(cwd, env)
3067
+ // Both sessions are live here. The parent fired when its own turn began;
3068
+ // the child fired for the turn that is running this command.
3069
+ writeSessionState('codex-orchestrator', env, { last_prompt_at: 40, last_stop_at: 39 })
3070
+ writeProjectSession(cwd, env, 'codex-orchestrator', 40, 'codex')
3071
+ writeSessionState('claude-current', env, { last_prompt_at: 42, last_stop_at: 41 })
3072
+ writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
3073
+ io.outLines = []
3074
+
3075
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3076
+ expect(readSessionState('claude-current', env).pending?.[0]?.question).toBe('Ship it?')
3077
+ expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
3078
+ })
3079
+
3080
+ it('judges the fired check against the active harness, not a global installation of another', async () => {
3081
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-active-harness-'))
3082
+ const io = new CapturedIo()
3083
+ const env = {
3084
+ ...isolatedEnv(cwd),
3085
+ CLAUDECODE: '1',
3086
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3087
+ CODEX_THREAD_ID: 'codex-current-thread',
3088
+ }
3089
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3090
+
3091
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3092
+ expect(
3093
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3094
+ ).toBe(EXIT.ok)
3095
+ trustInstalledCodexHooks(cwd, env)
3096
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3097
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3098
+
3099
+ const readiness = await assessReadiness(deps)
3100
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3101
+ // The Codex hooks fired. A missing Claude Code pointer says nothing about
3102
+ // that, and telling this agent to send a Claude Code prompt is advice it
3103
+ // cannot act on: it would refuse to ask, forever.
3104
+ expect(fired?.status).toBe('ready')
3105
+ expect(fired?.detail).toMatch(/active Codex session/i)
3106
+ expect(fired?.detail).not.toMatch(/Claude Code/i)
3107
+ })
3108
+
3109
+ it('advises every harness whose markers are present when none of them has fired here', async () => {
3110
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-contested-'))
3111
+ const io = new CapturedIo()
3112
+ const env = {
3113
+ ...isolatedEnv(cwd),
3114
+ CLAUDECODE: '1',
3115
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3116
+ CODEX_THREAD_ID: 'codex-current-thread',
3117
+ }
3118
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3119
+
3120
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3121
+ expect(
3122
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3123
+ ).toBe(EXIT.ok)
3124
+ trustInstalledCodexHooks(cwd, env)
3125
+
3126
+ const readiness = await assessReadiness(deps)
3127
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3128
+ expect(fired?.status).toBe('optional-gap')
3129
+ expect(fired?.detail).toMatch(/Claude Code/)
3130
+ expect(fired?.detail).toMatch(/Codex: send one prompt/)
3131
+
3132
+ io.errLines = []
3133
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3134
+ expect(io.errLines.join(' ')).toMatch(/Several harness markers are present/i)
3135
+ })
3136
+
3137
+ it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
3138
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-commitment-'))
3139
+ const io = new CapturedIo()
3140
+ const env = {
3141
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3142
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3143
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3144
+ CODEX_THREAD_ID: 'codex-commitment-thread',
3145
+ }
3146
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3147
+
3148
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3149
+ trustInstalledCodexHooks(cwd, env)
3150
+ writeSessionState('codex-commitment-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3151
+ writeProjectSession(cwd, env, 'codex-commitment-thread', 42, 'codex')
3152
+ io.outLines = []
3153
+
3154
+ expect(
3155
+ askCommand(deps, 'Which environment should I deploy to?', {
3156
+ choice: ['Staging', 'Production', 'Cancel'],
3157
+ }),
3158
+ ).toBe(EXIT.ok)
3159
+
3160
+ const said = io.outLines.join('\n')
3161
+ expect(said).toContain('Before ending this turn')
3162
+ for (const answer of ['Staging', 'Production', 'Cancel']) {
3163
+ expect(said).toContain(`If the answer is "${answer}"`)
3164
+ }
3165
+ expect(said).toContain('unexpected typed answer')
3166
+ expect(said).toContain('work you will resume')
3167
+ expect(said).toContain('without asking the user to confirm again')
3168
+ expect(said).toContain('not as approval')
3169
+ expect(said).toContain('cannot answer a harness permission prompt or interactive picker')
3170
+ })
3171
+
3172
+ it('requires a concrete fallback for free-text answers before the turn ends', () => {
3173
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-free-text-'))
3174
+ const io = new CapturedIo()
3175
+ const env = {
3176
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3177
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3178
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3179
+ CODEX_THREAD_ID: 'codex-free-text-thread',
3180
+ }
3181
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3182
+
3183
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3184
+ trustInstalledCodexHooks(cwd, env)
3185
+ writeSessionState('codex-free-text-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3186
+ writeProjectSession(cwd, env, 'codex-free-text-thread', 42, 'codex')
3187
+ io.outLines = []
3188
+
3189
+ expect(askCommand(deps, 'What rollout adjustment should I make?', {})).toBe(EXIT.ok)
3190
+ expect(io.outLines.join('\n')).toContain(
3191
+ 'For the free-text answer: state how its content will determine the concrete work you resume.',
2977
3192
  )
2978
3193
  })
2979
3194
 
@@ -3039,7 +3254,7 @@ describe('asking before the hooks have ever run', () => {
3039
3254
  source: 'Raidiant-io/notifai',
3040
3255
  sourceType: 'github',
3041
3256
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
3042
- ref: 'v0.4.0',
3257
+ ref: RELEASE_REF,
3043
3258
  },
3044
3259
  ]
3045
3260
  : [],
@@ -3295,7 +3510,7 @@ describe('asking before the hooks have ever run', () => {
3295
3510
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
3296
3511
  writeFileSync(
3297
3512
  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',
3513
+ 'ask_notifications = false\nask_grace_seconds = 90\n',
3299
3514
  )
3300
3515
  const io = new CapturedIo()
3301
3516
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
@@ -3305,29 +3520,167 @@ describe('asking before the hooks have ever run', () => {
3305
3520
  const said = io.outLines.join('\n')
3306
3521
  expect(said).toContain('Question routing settings:')
3307
3522
  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
3523
  expect(said).toContain('ask_grace_seconds=90 (project-local:')
3311
- expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
3524
+ // Nothing about where the user is standing is listed, because nothing
3525
+ // about where they are standing decides anything any more.
3526
+ expect(said).not.toMatch(/require_idle|away_after_seconds|hook_reply_timeout_seconds/)
3312
3527
  })
3313
3528
 
3314
- it('keeps an installed Stop timeout valid when runtime timing preferences change', async () => {
3529
+ it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
3315
3530
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
3316
3531
  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 }
3532
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
3319
3533
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3320
3534
  EXIT.ok,
3321
3535
  )
3322
3536
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
3323
- writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
3537
+ writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
3324
3538
  io.outLines = []
3325
3539
 
3326
3540
  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',
3541
+ expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(stop shape\)/)
3542
+ })
3543
+
3544
+ it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
3545
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
3546
+ const io = new CapturedIo()
3547
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
3548
+ // Exactly what every build before the asynchronous waiter wrote: a blocking
3549
+ // handler with the old ceiling. It looks installed and it fires, but the
3550
+ // waiter would hold the turn and then be killed at Claude's silent default.
3551
+ mkdirSync(path.join(cwd, '.claude'), { recursive: true })
3552
+ applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
3553
+ hooks: {
3554
+ Stop: [
3555
+ {
3556
+ hooks: [
3557
+ {
3558
+ type: 'command',
3559
+ command: `'${path.join(cwd, 'home', '.notifai', 'bin', 'hook-adapter')}' hook stop --owner notifai --harness claude-code`,
3560
+ timeout: 540,
3561
+ },
3562
+ ],
3563
+ },
3564
+ ],
3565
+ },
3566
+ })
3567
+
3568
+ await doctorCommand(deps, {})
3569
+ const out = io.outLines.join('\n')
3570
+ expect(out).toMatch(/FAIL\s+hooks \(stop shape\)/)
3571
+ expect(out).toContain('needs `async: true`')
3572
+ expect(out).toContain('kills the backgrounded waiter at its 600s default')
3573
+ })
3574
+
3575
+ /** A configured Claude Code project with one live session claiming `pid`. */
3576
+ function claudeWakeDeps(
3577
+ io: CapturedIo,
3578
+ descriptor: (pid: number) => unknown,
3579
+ ): { deps: CommandDeps; cwd: string } {
3580
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-wake-'))
3581
+ const base = {
3582
+ ...makeDeps(io, {} as ApiClient),
3583
+ cwd,
3584
+ env: { ...isolatedEnv(cwd), CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'sess-live' },
3585
+ claudeSourcePid: 4242,
3586
+ claudeWake: { readDescriptor: descriptor } as unknown as ClaudeWakeAdapters,
3587
+ }
3588
+ expect(hooksInstallCommand(base, { harness: 'claude-code', execPath, scriptPath })).toBe(
3589
+ EXIT.ok,
3330
3590
  )
3591
+ io.outLines = []
3592
+ return { deps: base, cwd }
3593
+ }
3594
+
3595
+ it('reports a live Claude session as reachable without connecting to its socket', async () => {
3596
+ const io = new CapturedIo()
3597
+ const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
3598
+ writeFileSync(socket, '')
3599
+ const { deps } = claudeWakeDeps(io, (pid) => ({
3600
+ pid,
3601
+ sessionId: 'sess-live',
3602
+ cwd: '/tmp',
3603
+ startedAt: 1,
3604
+ procStart: 'x',
3605
+ version: '2.1.228',
3606
+ peerProtocol: 1,
3607
+ messagingSocketPath: socket,
3608
+ status: 'idle',
3609
+ }))
3610
+
3611
+ await doctorCommand(deps, {})
3612
+ const out = io.outLines.join('\n')
3613
+ expect(out).toMatch(/ok\s+hooks \(wake route\)/)
3614
+ expect(out).toContain(socket)
3615
+ expect(out).toContain('an answer can start a turn here without you')
3616
+ // Doctor never widens the user's inbound policy to make Notifai work: the
3617
+ // poster is the session's own hook child and needs no such permission.
3618
+ expect(out).not.toContain('crossSessionInbound')
3619
+ })
3620
+
3621
+ it('names a --bare session as the reason an answer would wait for the next turn', async () => {
3622
+ const io = new CapturedIo()
3623
+ const { deps } = claudeWakeDeps(io, () => {
3624
+ throw new Error('ENOENT: no such file or directory')
3625
+ })
3626
+
3627
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
3628
+ const out = io.outLines.join('\n')
3629
+ // Honest, and not a blocker: nothing is lost, it simply arrives later.
3630
+ expect(out).toMatch(/--\s+hooks \(wake route\)/)
3631
+ expect(out).toContain('`--bare` binds no inbox socket')
3632
+ expect(out).toContain("at this session's next turn")
3633
+ expect(out).not.toMatch(/FAIL\s+hooks \(wake route\)/)
3634
+ })
3635
+
3636
+ it('fails closed on an inbox protocol it does not recognise', async () => {
3637
+ const io = new CapturedIo()
3638
+ const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
3639
+ writeFileSync(socket, '')
3640
+ const { deps } = claudeWakeDeps(io, (pid) => ({
3641
+ pid,
3642
+ sessionId: 'sess-live',
3643
+ cwd: '/tmp',
3644
+ startedAt: 1,
3645
+ procStart: 'x',
3646
+ version: '2.1.228',
3647
+ peerProtocol: 7,
3648
+ messagingSocketPath: socket,
3649
+ status: 'idle',
3650
+ }))
3651
+
3652
+ await doctorCommand(deps, {})
3653
+ const out = io.outLines.join('\n')
3654
+ expect(out).toContain('speaks inbox protocol 7')
3655
+ expect(out).toContain('refusing to guess at an undocumented wire format')
3656
+ })
3657
+
3658
+ it('reports Codex resume readiness from its writer-lock directory alone', async () => {
3659
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
3660
+ const io = new CapturedIo()
3661
+ const env = {
3662
+ ...isolatedEnv(cwd),
3663
+ CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
3664
+ }
3665
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3666
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3667
+
3668
+ io.outLines = []
3669
+ await doctorCommand(deps, {})
3670
+ expect(io.outLines.join('\n')).toContain('nothing can be resumed: no thread-writer-lock')
3671
+
3672
+ mkdirSync(path.join(env['CODEX_HOME']!, 'thread-writer-locks'), { recursive: true })
3673
+ io.outLines = []
3674
+ await doctorCommand(deps, {})
3675
+ const out = io.outLines.join('\n')
3676
+ if (process.platform === 'darwin') {
3677
+ expect(out).toMatch(/ok\s+hooks \(wake route\)/)
3678
+ expect(out).toContain('can prove a stopped thread unowned before resuming it')
3679
+ } else {
3680
+ // Linux has no non-blocking `flock` through Node's `open(2)` flags, so
3681
+ // the gate stays shut and doctor says so rather than implying a wake.
3682
+ expect(out).toContain('nothing can be resumed')
3683
+ }
3331
3684
  })
3332
3685
 
3333
3686
  it('documents the failing exit contract in doctor JSON', async () => {