@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.
- package/README.md +3 -4
- package/package.json +1 -1
- 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 +375 -39
- package/src/commands.ts +410 -104
- 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/skill.test.ts +23 -0
- package/src/ui/config-view.test.ts +5 -5
- 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,
|
|
@@ -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
|
|
1577
|
-
expect(text).toContain('
|
|
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), '
|
|
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('
|
|
1611
|
+
expect(parsed['key']).toBe('ask_notifications')
|
|
1591
1612
|
expect(parsed['accepts']).toBe('true or false')
|
|
1592
|
-
expect(parsed['detail']).toContain('
|
|
1593
|
-
expect(parsed['detail']).toContain('Turn
|
|
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), '
|
|
1599
|
-
expect(io.errLines[0]).toBe('Unknown setting "
|
|
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
|
|
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–
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
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\
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 \(
|
|
3328
|
-
|
|
3329
|
-
|
|
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 () => {
|