@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.
- package/README.md +3 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +15 -10
- package/dist/commands.js +321 -88
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +2 -2
- package/dist/config-schema.js +9 -35
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +5 -38
- package/dist/config.js +11 -32
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +3 -13
- package/dist/harnesses.js +6 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +22 -7
- package/dist/install-hooks.js +43 -28
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +2 -4
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +6 -13
- package/dist/main.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +9 -9
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +393 -40
- package/src/commands.ts +433 -109
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +11 -40
- package/src/config.test.ts +21 -16
- package/src/config.ts +15 -69
- package/src/harnesses.test.ts +7 -21
- package/src/harnesses.ts +17 -19
- package/src/hooks.test.ts +780 -290
- package/src/hooks.ts +466 -339
- package/src/install-hooks.test.ts +44 -3
- package/src/install-hooks.ts +43 -28
- package/src/interactive.ts +2 -7
- package/src/logging.test.ts +3 -3
- package/src/main.ts +6 -12
- package/src/release.ts +51 -0
- package/src/skill.test.ts +23 -0
- package/src/ui/config-view.test.ts +5 -5
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
package/src/commands.test.ts
CHANGED
|
@@ -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
|
|
1577
|
-
expect(text).toContain('
|
|
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), '
|
|
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('
|
|
1627
|
+
expect(parsed['key']).toBe('ask_notifications')
|
|
1591
1628
|
expect(parsed['accepts']).toBe('true or false')
|
|
1592
|
-
expect(parsed['detail']).toContain('
|
|
1593
|
-
expect(parsed['detail']).toContain('Turn
|
|
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), '
|
|
1599
|
-
expect(io.errLines[0]).toBe('Unknown setting "
|
|
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
|
|
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–
|
|
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:
|
|
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:
|
|
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
|
|
2059
|
-
expect(SKILLS_SOURCE).toBe(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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\
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 \(
|
|
3328
|
-
|
|
3329
|
-
|
|
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 () => {
|