@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
|
@@ -90,6 +90,7 @@ describe('hook config', () => {
|
|
|
90
90
|
it('uses one fixed blocking process budget rather than accepting mutable timing inputs', () => {
|
|
91
91
|
const config = buildHookConfig({ adapterPath: ADAPTER })
|
|
92
92
|
expect(config['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
|
|
93
|
+
expect(config['Stop']?.[0]?.hooks[0]?.async).toBeUndefined()
|
|
93
94
|
// Claude Code caps UserPromptSubmit at 30s; Codex gives SessionEnd 1-3s.
|
|
94
95
|
expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
|
|
95
96
|
expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
|
|
@@ -110,12 +111,52 @@ describe('hook config', () => {
|
|
|
110
111
|
|
|
111
112
|
it('lets Codex own Stop timeout while keeping short fixed lifecycle budgets', () => {
|
|
112
113
|
const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
|
|
113
|
-
const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
|
|
114
114
|
|
|
115
115
|
expect(codex['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
|
|
116
|
-
expect(codex['Stop']?.[0]?.hooks[0]?.timeout).toBeUndefined()
|
|
117
116
|
expect(codex['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
|
|
118
|
-
|
|
117
|
+
// Codex hashes the exact serialized definition into `trusted_hash`, so its
|
|
118
|
+
// Stop handler stays a bare blocking command: declaring a timeout would
|
|
119
|
+
// invalidate the user's approval and the handler would silently stop.
|
|
120
|
+
expect(codex['Stop']?.[0]?.hooks[0]).toEqual({
|
|
121
|
+
type: 'command',
|
|
122
|
+
command: hookCommand(ADAPTER, 'stop', 'codex'),
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('gives Claude Code an asynchronous Stop with an explicit waiter budget', () => {
|
|
127
|
+
const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
|
|
128
|
+
|
|
129
|
+
// `async: true` is what frees the turn: the handler returns at once and the
|
|
130
|
+
// same process lives on as the waiter. The explicit timeout is what keeps
|
|
131
|
+
// that process alive — Claude kills a background hook at its own 600s
|
|
132
|
+
// default and reports nothing, so a wait near that boundary loses answers.
|
|
133
|
+
expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
|
|
134
|
+
type: 'command',
|
|
135
|
+
command: hookCommand(ADAPTER, 'stop', 'claude-code'),
|
|
136
|
+
timeout: 3600,
|
|
137
|
+
async: true,
|
|
138
|
+
})
|
|
139
|
+
expect(claude['UserPromptSubmit']?.[0]?.hooks[0]?.async).toBeUndefined()
|
|
140
|
+
expect(claude['SessionEnd']?.[0]?.hooks[0]?.async).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('leaves exactly one handler per event when an old blocking shape is reinstalled over', () => {
|
|
144
|
+
const old = mergeHooks({}, buildHookConfig({ adapterPath: ADAPTER }), SCRIPT)
|
|
145
|
+
const migrated = mergeHooks(
|
|
146
|
+
old.document,
|
|
147
|
+
buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' }),
|
|
148
|
+
SCRIPT,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
for (const event of ['UserPromptSubmit', 'Stop', 'SessionEnd']) {
|
|
152
|
+
const groups = migrated.document.hooks?.[event] ?? []
|
|
153
|
+
expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
|
|
154
|
+
}
|
|
155
|
+
expect(migrated.document.hooks?.['Stop']?.[0]?.hooks[0]).toMatchObject({
|
|
156
|
+
timeout: 3600,
|
|
157
|
+
async: true,
|
|
158
|
+
})
|
|
159
|
+
expect(migrated.replaced.sort()).toEqual(['SessionEnd', 'Stop', 'UserPromptSubmit'])
|
|
119
160
|
})
|
|
120
161
|
})
|
|
121
162
|
|
package/src/install-hooks.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { parse as parseToml } from 'smol-toml'
|
|
|
12
12
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
13
13
|
import { hookAdapterPath } from './hook-adapter.js'
|
|
14
14
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
15
|
-
import { HARNESSES,
|
|
15
|
+
import { HARNESSES, type Harness } from './harnesses.js'
|
|
16
16
|
|
|
17
17
|
export { HARNESSES, type Harness } from './harnesses.js'
|
|
18
18
|
|
|
@@ -113,28 +113,54 @@ export interface BuildOptions {
|
|
|
113
113
|
harness?: Harness
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Claude Code's Stop handler returns instantly and the same process lives on
|
|
118
|
+
* out of band as the escalation waiter, so this `timeout` bounds a background
|
|
119
|
+
* process rather than the user's turn.
|
|
120
|
+
*
|
|
121
|
+
* It is always declared, and declared far above the longest wait a waiter can
|
|
122
|
+
* take. Claude's default is 600 s and the kill is **silent** — the backgrounded
|
|
123
|
+
* process simply vanishes, no error is reported anywhere, and the answer the
|
|
124
|
+
* user already gave is never delivered. Sitting anywhere near that boundary
|
|
125
|
+
* turns a slow reply into a lost one, so this does not: an explicit value
|
|
126
|
+
* raises the ceiling (verified against a 660 s wait under an hour-long
|
|
127
|
+
* declaration), and an hour is several times the waiter's own ceiling.
|
|
128
|
+
*/
|
|
129
|
+
export const CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS = 3600
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The declared process budget for a host that holds its turn while the waiter
|
|
133
|
+
* runs: the waiter's own ceiling plus a minute of host headroom, as one plain
|
|
134
|
+
* number rather than a budget carved into reserves.
|
|
135
|
+
*
|
|
136
|
+
* Codex is deliberately absent from this. It owns its Stop timeout today, and
|
|
137
|
+
* declaring one would change the serialized definition Codex hashes into
|
|
138
|
+
* `trusted_hash` — which silently stops every Notifai handler from running
|
|
139
|
+
* until the user re-approves them in `/hooks`.
|
|
140
|
+
*/
|
|
141
|
+
export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
|
|
118
142
|
|
|
119
143
|
/**
|
|
120
|
-
*
|
|
144
|
+
* The turn-end handler, whose shape is the whole per-harness difference.
|
|
121
145
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* the
|
|
146
|
+
* Claude Code takes the answer over its own inbox socket, so its Stop hook is
|
|
147
|
+
* `async: true`: it returns immediately, the terminal is never held, and the
|
|
148
|
+
* waiter finishes out of band. Every other host still delivers by printing a
|
|
149
|
+
* continuation to this process's stdout, so its Stop hook blocks and needs a
|
|
150
|
+
* declared ceiling above the wait — except Codex, which owns that number
|
|
151
|
+
* itself and whose definition must stay byte-stable for its trust store.
|
|
127
152
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
function stopHandler(adapterPath: string, harness: Harness | undefined): HookHandler {
|
|
154
|
+
const command = hookCommand(adapterPath, 'stop', harness)
|
|
155
|
+
if (harness === 'claude-code') {
|
|
156
|
+
return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true }
|
|
157
|
+
}
|
|
158
|
+
if (harness === 'codex') return { type: 'command', command }
|
|
159
|
+
return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
130
160
|
}
|
|
131
161
|
|
|
132
162
|
export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
133
163
|
const { adapterPath } = options
|
|
134
|
-
const hostOwnsStopTimeout = options.harness === 'codex'
|
|
135
|
-
// Runtime preferences fit inside this fixed process budget without becoming
|
|
136
|
-
// part of the serialized definition Codex asks the user to trust.
|
|
137
|
-
const blockingTimeout = blockingHookTimeoutSeconds()
|
|
138
164
|
return {
|
|
139
165
|
UserPromptSubmit: [
|
|
140
166
|
{
|
|
@@ -149,17 +175,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
149
175
|
],
|
|
150
176
|
},
|
|
151
177
|
],
|
|
152
|
-
Stop: [
|
|
153
|
-
{
|
|
154
|
-
hooks: [
|
|
155
|
-
{
|
|
156
|
-
type: 'command',
|
|
157
|
-
command: hookCommand(adapterPath, 'stop', options.harness),
|
|
158
|
-
...(hostOwnsStopTimeout ? {} : { timeout: blockingTimeout }),
|
|
159
|
-
},
|
|
160
|
-
],
|
|
161
|
-
},
|
|
162
|
-
],
|
|
178
|
+
Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
|
|
163
179
|
SessionEnd: [
|
|
164
180
|
{
|
|
165
181
|
hooks: [
|
|
@@ -192,7 +208,6 @@ interface CursorSettingsDocument {
|
|
|
192
208
|
|
|
193
209
|
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
194
210
|
export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
195
|
-
const blockingTimeout = blockingHookTimeoutSeconds()
|
|
196
211
|
return {
|
|
197
212
|
beforeSubmitPrompt: [
|
|
198
213
|
{
|
|
@@ -203,7 +218,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
203
218
|
stop: [
|
|
204
219
|
{
|
|
205
220
|
command: hookCommand(options.adapterPath, 'stop', 'cursor'),
|
|
206
|
-
timeout:
|
|
221
|
+
timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
207
222
|
// A continuation may register a real follow-up question. Match the
|
|
208
223
|
// session-state cap so those chains are useful but never unbounded.
|
|
209
224
|
loop_limit: 3,
|
package/src/interactive.ts
CHANGED
|
@@ -594,16 +594,11 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
594
594
|
clack.note(
|
|
595
595
|
'When an agent registers a question, it appears in your terminal first.\n' +
|
|
596
596
|
'After the grace below, it goes to your phone and Mac as a notification\n' +
|
|
597
|
-
'with real answer buttons, and your answer comes back to the agent
|
|
598
|
-
'
|
|
597
|
+
'with real answer buttons, and your answer comes back to the agent —\n' +
|
|
598
|
+
'whether or not you are still at this machine.\n\n' +
|
|
599
599
|
[
|
|
600
600
|
row('Wired to', wiringSummary(hooks)),
|
|
601
601
|
row('Send', config.ask_notifications.value ? 'yes' : 'no — questions stay in the terminal'),
|
|
602
|
-
row(
|
|
603
|
-
'Only when away',
|
|
604
|
-
config.require_idle.value ? 'yes' : 'no — reach me even while I am working',
|
|
605
|
-
),
|
|
606
|
-
row('Away after', formatValue('away_after_seconds', config.away_after_seconds.value)),
|
|
607
602
|
row('Grace', formatValue('ask_grace_seconds', config.ask_grace_seconds.value)),
|
|
608
603
|
].join('\n'),
|
|
609
604
|
'Question routing',
|
package/src/logging.test.ts
CHANGED
|
@@ -428,12 +428,12 @@ describe('rendering', () => {
|
|
|
428
428
|
run: 'r_1',
|
|
429
429
|
cmd: 'hook stop',
|
|
430
430
|
pid: 1,
|
|
431
|
-
data: { verdict: 'held', reason: '
|
|
431
|
+
data: { verdict: 'held', reason: 'notifications-off', source: 'default' },
|
|
432
432
|
})
|
|
433
433
|
expect(line).toContain('2026-08-11 12:04:31')
|
|
434
434
|
expect(line).toContain('hook.gate')
|
|
435
|
-
expect(line).toContain('reason=
|
|
436
|
-
expect(line).toContain('
|
|
435
|
+
expect(line).toContain('reason=notifications-off')
|
|
436
|
+
expect(line).toContain('source=default')
|
|
437
437
|
})
|
|
438
438
|
|
|
439
439
|
it('stays on one line whatever the payload', () => {
|
package/src/skill.test.ts
CHANGED
|
@@ -34,6 +34,29 @@ describe('Notifai agent skill', () => {
|
|
|
34
34
|
}
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
+
it('turns a registered question into a same-turn work-resumption commitment', () => {
|
|
38
|
+
const askGuidance = skill.slice(
|
|
39
|
+
skill.indexOf('### Register a turn-ending question'),
|
|
40
|
+
skill.indexOf('## Verify delivery and readiness'),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
for (const required of [
|
|
44
|
+
'same turn',
|
|
45
|
+
'each offered answer',
|
|
46
|
+
'unexpected typed answer',
|
|
47
|
+
'work you will resume',
|
|
48
|
+
'never as approval',
|
|
49
|
+
'permission prompt',
|
|
50
|
+
'interactive picker',
|
|
51
|
+
'question identity',
|
|
52
|
+
'question text',
|
|
53
|
+
"user's answer",
|
|
54
|
+
]) {
|
|
55
|
+
expect(askGuidance).toContain(required)
|
|
56
|
+
}
|
|
57
|
+
expect(askGuidance).toMatch(/without asking the user to\s+confirm again/)
|
|
58
|
+
})
|
|
59
|
+
|
|
37
60
|
it('tells the agent where to look when something did not happen', () => {
|
|
38
61
|
// The log's whole value is diagnostic, so the skill has to name the case
|
|
39
62
|
// that produces it — a question that was registered and never travelled.
|
|
@@ -45,7 +45,7 @@ describe('renderConfigList', () => {
|
|
|
45
45
|
if (configInfo(key).advanced === true) continue
|
|
46
46
|
expect(text, key).toContain(key)
|
|
47
47
|
}
|
|
48
|
-
expect(text).toContain('Questions
|
|
48
|
+
expect(text).toContain('Questions')
|
|
49
49
|
expect(text).toContain('notifai config explain')
|
|
50
50
|
})
|
|
51
51
|
|
|
@@ -69,13 +69,13 @@ describe('renderConfigList', () => {
|
|
|
69
69
|
|
|
70
70
|
describe('renderConfigExplain', () => {
|
|
71
71
|
it('answers what it is, what it is now, and what it accepts', () => {
|
|
72
|
-
const text = renderConfigExplain('
|
|
73
|
-
expect(text).toContain('
|
|
74
|
-
expect(text).toContain('
|
|
72
|
+
const text = renderConfigExplain('ask_notifications', config).join('\n')
|
|
73
|
+
expect(text).toContain('ask_notifications')
|
|
74
|
+
expect(text).toContain('Send questions to my devices')
|
|
75
75
|
expect(text).toContain('Accepts')
|
|
76
76
|
expect(text).toContain('true or false')
|
|
77
77
|
// The copyable next step; the old output stopped at the value.
|
|
78
|
-
expect(text).toContain('notifai config set
|
|
78
|
+
expect(text).toContain('notifai config set ask_notifications')
|
|
79
79
|
})
|
|
80
80
|
|
|
81
81
|
it('lists what each choice of an enum means', () => {
|
package/src/idle.test.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { parseIdleSeconds, readIdleSeconds } from './idle.js'
|
|
3
|
-
|
|
4
|
-
describe('OS idle probe', () => {
|
|
5
|
-
it('reads HIDIdleTime as nanoseconds', () => {
|
|
6
|
-
// Verbatim shape of `ioreg -c IOHIDSystem -r -k HIDIdleTime`.
|
|
7
|
-
const output = `+-o IOHIDSystem <class IOHIDSystem, id 0x100000abc>
|
|
8
|
-
{
|
|
9
|
-
"HIDIdleTime" = 151627250
|
|
10
|
-
"HIDPointerAcceleration" = 6144
|
|
11
|
-
}
|
|
12
|
-
`
|
|
13
|
-
expect(parseIdleSeconds(output)).toBeCloseTo(0.15, 2)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('reads a long idle period', () => {
|
|
17
|
-
expect(parseIdleSeconds('"HIDIdleTime" = 900000000000')).toBe(900)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('returns null rather than a wrong number when the key is absent or junk', () => {
|
|
21
|
-
// null degrades to the elapsed-time signal; a bogus 0 would instead pin the
|
|
22
|
-
// user "present" forever and silently disable escalation altogether.
|
|
23
|
-
expect(parseIdleSeconds('')).toBeNull()
|
|
24
|
-
expect(parseIdleSeconds('"HIDPointerAcceleration" = 6144')).toBeNull()
|
|
25
|
-
expect(parseIdleSeconds('"HIDIdleTime" = <ptr>')).toBeNull()
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('reports no signal on platforms without one, instead of throwing', () => {
|
|
29
|
-
expect(readIdleSeconds('linux')).toBeNull()
|
|
30
|
-
expect(readIdleSeconds('win32')).toBeNull()
|
|
31
|
-
})
|
|
32
|
-
})
|
package/src/idle.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Seconds since the user last touched the keyboard or mouse, or null when this
|
|
5
|
-
* machine cannot say.
|
|
6
|
-
*
|
|
7
|
-
* The hooks need to know whether the user is at their desk, and the only signal
|
|
8
|
-
* they had was silence since the last prompt — which includes the agent's own
|
|
9
|
-
* turn, so a user watching a long build was indistinguishable from a user who
|
|
10
|
-
* had left. This is the corroborating signal.
|
|
11
|
-
*
|
|
12
|
-
* null is a first-class answer, not a failure: on a machine with no idle source
|
|
13
|
-
* the caller keeps its previous behaviour rather than guessing. That is why
|
|
14
|
-
* every error path here returns null instead of throwing.
|
|
15
|
-
*/
|
|
16
|
-
export function readIdleSeconds(platform: NodeJS.Platform = process.platform): number | null {
|
|
17
|
-
if (platform !== 'darwin') return null
|
|
18
|
-
try {
|
|
19
|
-
// -r -k narrows the dump to the one node carrying the key (~5KB), so this
|
|
20
|
-
// stays a ~20ms call on the hot path of every Stop hook.
|
|
21
|
-
const out = execFileSync('ioreg', ['-c', 'IOHIDSystem', '-r', '-k', 'HIDIdleTime'], {
|
|
22
|
-
encoding: 'utf8',
|
|
23
|
-
timeout: 2000,
|
|
24
|
-
maxBuffer: 1_000_000,
|
|
25
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
-
})
|
|
27
|
-
return parseIdleSeconds(out)
|
|
28
|
-
} catch {
|
|
29
|
-
// Missing binary, sandbox denial, timeout — all mean "no signal".
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Exported for tests: HIDIdleTime is nanoseconds since the last HID event. */
|
|
35
|
-
export function parseIdleSeconds(ioregOutput: string): number | null {
|
|
36
|
-
const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ioregOutput)
|
|
37
|
-
if (match?.[1] === undefined) return null
|
|
38
|
-
const nanos = Number(match[1])
|
|
39
|
-
if (!Number.isFinite(nanos) || nanos < 0) return null
|
|
40
|
-
return nanos / 1e9
|
|
41
|
-
}
|