@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.
@@ -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
- expect(claude['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
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
 
@@ -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, HOOK_TIMING, type Harness } from './harnesses.js'
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
- /** Generated blocking adapters grant the owner budget one fixed minute of host headroom. */
117
- const HOOK_CEILING_SECONDS = HOOK_TIMING.totalSeconds + HOOK_TIMING.hostHeadroomSeconds
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
- * Stable process budget for a blocking turn-end adapter.
144
+ * The turn-end handler, whose shape is the whole per-harness difference.
121
145
  *
122
- * Codex trusts the exact serialized hook definition. Deriving this timeout
123
- * from mutable question preferences meant changing `ask_grace_seconds`
124
- * silently invalidated Stop trust while the other two handlers kept running.
125
- * The runtime already fits grace and reply waiting inside the harness ceiling;
126
- * the installed identity therefore stays at that ceiling across preferences.
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
- export function blockingHookTimeoutSeconds(): number {
129
- return HOOK_CEILING_SECONDS
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: blockingTimeout,
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,
@@ -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. Turn\n' +
598
- 'on “Only when away” if local activity should keep it here instead.\n\n' +
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',
@@ -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: 'user-present', idle_seconds: 4 },
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=user-present')
436
- expect(line).toContain('idle_seconds=4')
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 & presence')
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('require_idle', config).join('\n')
73
- expect(text).toContain('require_idle')
74
- expect(text).toContain('Only when I have stepped away')
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 require_idle')
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
- }