@raidiant/notifai 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +3 -4
  2. package/dist/claude-wake.d.ts +101 -0
  3. package/dist/claude-wake.js +397 -0
  4. package/dist/claude-wake.js.map +1 -0
  5. package/dist/codex-wake.d.ts +84 -0
  6. package/dist/codex-wake.js +238 -0
  7. package/dist/codex-wake.js.map +1 -0
  8. package/dist/commands.d.ts +15 -10
  9. package/dist/commands.js +321 -88
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +2 -2
  12. package/dist/config-schema.js +9 -35
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +5 -38
  15. package/dist/config.js +11 -32
  16. package/dist/config.js.map +1 -1
  17. package/dist/harnesses.d.ts +3 -13
  18. package/dist/harnesses.js +6 -17
  19. package/dist/harnesses.js.map +1 -1
  20. package/dist/hooks.d.ts +119 -65
  21. package/dist/hooks.js +330 -285
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +22 -7
  24. package/dist/install-hooks.js +43 -28
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/interactive.d.ts +1 -1
  27. package/dist/interactive.js +2 -4
  28. package/dist/interactive.js.map +1 -1
  29. package/dist/main.js +6 -13
  30. package/dist/main.js.map +1 -1
  31. package/dist/release.d.ts +31 -0
  32. package/dist/release.js +52 -0
  33. package/dist/release.js.map +1 -0
  34. package/dist/ui/theme.d.ts +1 -1
  35. package/package.json +9 -9
  36. package/src/claude-wake.test.ts +320 -0
  37. package/src/claude-wake.ts +489 -0
  38. package/src/codex-wake.test.ts +253 -0
  39. package/src/codex-wake.ts +288 -0
  40. package/src/commands.test.ts +393 -40
  41. package/src/commands.ts +433 -109
  42. package/src/config-schema.test.ts +3 -3
  43. package/src/config-schema.ts +11 -40
  44. package/src/config.test.ts +21 -16
  45. package/src/config.ts +15 -69
  46. package/src/harnesses.test.ts +7 -21
  47. package/src/harnesses.ts +17 -19
  48. package/src/hooks.test.ts +780 -290
  49. package/src/hooks.ts +466 -339
  50. package/src/install-hooks.test.ts +44 -3
  51. package/src/install-hooks.ts +43 -28
  52. package/src/interactive.ts +2 -7
  53. package/src/logging.test.ts +3 -3
  54. package/src/main.ts +6 -12
  55. package/src/release.ts +51 -0
  56. package/src/skill.test.ts +23 -0
  57. package/src/ui/config-view.test.ts +5 -5
  58. package/dist/idle.d.ts +0 -16
  59. package/dist/idle.js +0 -44
  60. package/dist/idle.js.map +0 -1
  61. package/src/idle.test.ts +0 -32
  62. package/src/idle.ts +0 -41
@@ -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/main.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs'
3
- import path from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
3
  import { Command } from 'commander'
6
4
  import {
7
5
  askCommand,
@@ -30,6 +28,7 @@ import {
30
28
  type CommandDeps,
31
29
  } from './commands.js'
32
30
  import { defaultCredentialStore } from './credentials.js'
31
+ import { packageVersion } from './release.js'
33
32
  import { HARNESSES } from './install-hooks.js'
34
33
  import type { Platform } from '@raidiant/notifai-protocol'
35
34
  import { nativeSkills, type SkillScope } from './native-skills.js'
@@ -64,18 +63,13 @@ const deps: CommandDeps = {
64
63
  * does not hold — `package.json` moved and `notifai --version` kept reporting
65
64
  * the old number, which is exactly the string someone pastes into a bug report
66
65
  * to say what they are running.
66
+ *
67
+ * `unknown` is the display form of "this build cannot tell"; the skill pin
68
+ * derived from the same source refuses instead, because a wrong ref installs
69
+ * the wrong thing while a wrong version string only misinforms.
67
70
  */
68
71
  function version(): string {
69
- try {
70
- const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
71
- const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
72
- if (typeof parsed === 'object' && parsed !== null && typeof (parsed as { version?: unknown }).version === 'string') {
73
- return (parsed as { version: string }).version
74
- }
75
- } catch {
76
- // Fall through: an unreadable manifest must not stop the CLI running.
77
- }
78
- return 'unknown'
72
+ return packageVersion() ?? 'unknown'
79
73
  }
80
74
 
81
75
  /**
package/src/release.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ /**
6
+ * What this build is, according to the manifest npm actually shipped.
7
+ *
8
+ * Read from disk rather than compiled in, because a constant baked into
9
+ * `dist/` is only ever as fresh as the last build. A stale `dist/` sitting
10
+ * next to a newer `package.json` is a real failure mode — it produces a CLI
11
+ * that reports the new version perfectly while running the old code — and the
12
+ * only defence that survives it is refusing to carry a second copy of the
13
+ * number at all.
14
+ *
15
+ * The manifest sits one level above both `src/` and `dist/`, so a single
16
+ * relative path resolves in a source checkout and in a published install.
17
+ *
18
+ * Returns null rather than guessing: a build that cannot establish its own
19
+ * identity has to say so, so callers can degrade honestly instead of
20
+ * asserting something they do not know.
21
+ */
22
+ export function packageVersion(): string | null {
23
+ try {
24
+ const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
25
+ const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
26
+ if (typeof parsed === 'object' && parsed !== null) {
27
+ const { version } = parsed as { version?: unknown }
28
+ if (typeof version === 'string' && version.length > 0) return version
29
+ }
30
+ } catch {
31
+ // Fall through: an unreadable manifest must not stop the CLI running.
32
+ }
33
+ return null
34
+ }
35
+
36
+ /**
37
+ * Where `npx skills add` fetches the agent skill from, as printed to users.
38
+ *
39
+ * Always an immutable tag naming this exact build. The skill and the CLI ship
40
+ * from one repository at one version, and the skill describes how to drive the
41
+ * CLI — so a moving ref would hand someone guidance for a tool they do not
42
+ * have. Deriving it from the version is what keeps the two from disagreeing;
43
+ * it used to be maintained by hand in three places, and the copies drifted.
44
+ *
45
+ * In the skills CLI grammar `owner/repo#ref` selects a Git ref, while
46
+ * `owner/repo@name` selects a skill — so the `#` here is load-bearing.
47
+ */
48
+ export function skillsSource(): string | null {
49
+ const version = packageVersion()
50
+ return version === null ? null : `Raidiant-io/notifai#v${version}`
51
+ }
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/dist/idle.d.ts DELETED
@@ -1,16 +0,0 @@
1
- /**
2
- * Seconds since the user last touched the keyboard or mouse, or null when this
3
- * machine cannot say.
4
- *
5
- * The hooks need to know whether the user is at their desk, and the only signal
6
- * they had was silence since the last prompt — which includes the agent's own
7
- * turn, so a user watching a long build was indistinguishable from a user who
8
- * had left. This is the corroborating signal.
9
- *
10
- * null is a first-class answer, not a failure: on a machine with no idle source
11
- * the caller keeps its previous behaviour rather than guessing. That is why
12
- * every error path here returns null instead of throwing.
13
- */
14
- export declare function readIdleSeconds(platform?: NodeJS.Platform): number | null;
15
- /** Exported for tests: HIDIdleTime is nanoseconds since the last HID event. */
16
- export declare function parseIdleSeconds(ioregOutput: string): number | null;
package/dist/idle.js DELETED
@@ -1,44 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- /**
3
- * Seconds since the user last touched the keyboard or mouse, or null when this
4
- * machine cannot say.
5
- *
6
- * The hooks need to know whether the user is at their desk, and the only signal
7
- * they had was silence since the last prompt — which includes the agent's own
8
- * turn, so a user watching a long build was indistinguishable from a user who
9
- * had left. This is the corroborating signal.
10
- *
11
- * null is a first-class answer, not a failure: on a machine with no idle source
12
- * the caller keeps its previous behaviour rather than guessing. That is why
13
- * every error path here returns null instead of throwing.
14
- */
15
- export function readIdleSeconds(platform = process.platform) {
16
- if (platform !== 'darwin')
17
- 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
- }
29
- catch {
30
- // Missing binary, sandbox denial, timeout — all mean "no signal".
31
- return null;
32
- }
33
- }
34
- /** Exported for tests: HIDIdleTime is nanoseconds since the last HID event. */
35
- export function parseIdleSeconds(ioregOutput) {
36
- const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ioregOutput);
37
- if (match?.[1] === undefined)
38
- return null;
39
- const nanos = Number(match[1]);
40
- if (!Number.isFinite(nanos) || nanos < 0)
41
- return null;
42
- return nanos / 1e9;
43
- }
44
- //# sourceMappingURL=idle.js.map
package/dist/idle.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"idle.js","sourceRoot":"","sources":["../src/idle.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEjD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,eAAe,CAAC,WAA4B,OAAO,CAAC,QAAQ;IAC1E,IAAI,QAAQ,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IACtC,IAAI,CAAC;QACH,0EAA0E;QAC1E,yDAAyD;QACzD,MAAM,GAAG,GAAG,YAAY,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,aAAa,CAAC,EAAE;YAClF,QAAQ,EAAE,MAAM;YAChB,OAAO,EAAE,IAAI;YACb,SAAS,EAAE,SAAS;YACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;SACpC,CAAC,CAAA;QACF,OAAO,gBAAgB,CAAC,GAAG,CAAC,CAAA;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,kEAAkE;QAClE,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB,CAAC,WAAmB;IAClD,MAAM,KAAK,GAAG,2BAA2B,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC3D,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,SAAS;QAAE,OAAO,IAAI,CAAA;IACzC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;IAC9B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACrD,OAAO,KAAK,GAAG,GAAG,CAAA;AACpB,CAAC"}
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
- }