@raidiant/notifai 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. package/tsconfig.json +10 -0
@@ -0,0 +1,243 @@
1
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterAll, describe, expect, it } from 'vitest'
5
+ import { loadConfig, sessionConfigPath } from './config.js'
6
+ import { buildDraft } from './send.js'
7
+
8
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'notifai-cli-'))
9
+ afterAll(() => rmSync(tmp, { recursive: true, force: true }))
10
+
11
+ function setup(options: {
12
+ globalToml?: string
13
+ projectToml?: string
14
+ projectLocalToml?: string
15
+ sessionToml?: string
16
+ sessionId?: string
17
+ }) {
18
+ const home = path.join(tmp, `case-${Math.random().toString(36).slice(2)}`)
19
+ const configDir = path.join(home, 'notifai')
20
+ mkdirSync(configDir, { recursive: true })
21
+ if (options.globalToml !== undefined) {
22
+ writeFileSync(path.join(configDir, 'config.toml'), options.globalToml)
23
+ }
24
+ const project = path.join(home, 'repo', 'nested')
25
+ mkdirSync(project, { recursive: true })
26
+ const projectDir = path.join(home, 'repo', '.notifai')
27
+ if (options.projectToml !== undefined) {
28
+ mkdirSync(projectDir, { recursive: true })
29
+ writeFileSync(path.join(projectDir, 'config.toml'), options.projectToml)
30
+ }
31
+ if (options.projectLocalToml !== undefined) {
32
+ mkdirSync(projectDir, { recursive: true })
33
+ writeFileSync(path.join(projectDir, 'config.local.toml'), options.projectLocalToml)
34
+ }
35
+ const state = path.join(home, 'state')
36
+ const env = { XDG_CONFIG_HOME: home, XDG_STATE_HOME: state } as NodeJS.ProcessEnv
37
+ if (options.sessionToml !== undefined) {
38
+ const file = sessionConfigPath(options.sessionId ?? 'sess', env)
39
+ mkdirSync(path.dirname(file), { recursive: true })
40
+ writeFileSync(file, options.sessionToml)
41
+ }
42
+ return { env, cwd: project }
43
+ }
44
+
45
+ describe('harness config layers', () => {
46
+ it('orders session over project-local over project over global', () => {
47
+ const { env, cwd } = setup({
48
+ globalToml: 'away_after_seconds = 10\n',
49
+ projectToml: 'away_after_seconds = 20\n',
50
+ projectLocalToml: 'away_after_seconds = 30\n',
51
+ sessionToml: 'away_after_seconds = 40\n',
52
+ sessionId: 'abc',
53
+ })
54
+ expect(loadConfig({ cwd, env, sessionId: 'abc' }).away_after_seconds).toMatchObject({
55
+ value: 40,
56
+ source: expect.stringMatching(/^session:/),
57
+ })
58
+ // Same files, different session: the session layer simply does not apply.
59
+ expect(loadConfig({ cwd, env, sessionId: 'other' }).away_after_seconds).toMatchObject({
60
+ value: 30,
61
+ source: expect.stringMatching(/^project-local:/),
62
+ })
63
+ expect(loadConfig({ cwd, env }).away_after_seconds.value).toBe(30)
64
+ })
65
+
66
+ it('keeps a personal toggle out of the committed project file', () => {
67
+ const { env, cwd } = setup({
68
+ projectToml: 'ask_notifications = true\n',
69
+ projectLocalToml: 'ask_notifications = false\n',
70
+ })
71
+ const config = loadConfig({ cwd, env })
72
+ expect(config.ask_notifications.value).toBe(false)
73
+ expect(config.ask_notifications.source).toMatch(/config\.local\.toml$/)
74
+ })
75
+
76
+ it('keeps hostile or colliding session ids inside their own file', () => {
77
+ const { env, cwd } = setup({
78
+ sessionToml: 'away_after_seconds = 99\n',
79
+ sessionId: '../../etc/passwd',
80
+ })
81
+ // The id round-trips to its own state, and nothing escapes the directory.
82
+ expect(loadConfig({ cwd, env, sessionId: '../../etc/passwd' }).away_after_seconds.value).toBe(99)
83
+ expect(sessionConfigPath('../../etc/passwd', env)).toContain('/notifai/sessions/')
84
+ expect(sessionConfigPath('../../etc/passwd', env)).not.toContain('..')
85
+ // Ids that a sanitiser would have collapsed together must stay distinct:
86
+ // sharing a file meant sharing presence and pending questions.
87
+ expect(sessionConfigPath('a/b', env)).not.toBe(sessionConfigPath('a?b', env))
88
+ expect(loadConfig({ cwd, env, sessionId: 'a/b' }).away_after_seconds.value).toBe(120)
89
+ })
90
+
91
+ it('defaults the hook behaviour to on, gated by the presence check rather than a flag', () => {
92
+ const { env, cwd } = setup({})
93
+ const config = loadConfig({ cwd, env })
94
+ expect(config.ask_notifications.value).toBe(true)
95
+ expect(config.away_after_seconds.value).toBe(120)
96
+ // 300 grace + 180 reply + 60 installer headroom = 540, inside the 600s
97
+ // ceiling both harnesses kill a command hook at.
98
+ expect(config.hook_reply_timeout_seconds.value).toBe(180)
99
+ expect(config.ask_grace_seconds.value).toBe(300)
100
+ })
101
+ })
102
+
103
+ describe('config precedence', () => {
104
+ it('defaults apply with provenance', () => {
105
+ const { env, cwd } = setup({})
106
+ const config = loadConfig({ cwd, env })
107
+ expect(config.wait_seconds.value).toBe(10)
108
+ expect(config.wait_seconds.source).toBe('default')
109
+ expect(config.ttl_seconds.value).toBe(86400)
110
+ })
111
+
112
+ it('project config (found by parent walk) beats machine-global', () => {
113
+ const { env, cwd } = setup({
114
+ globalToml: 'wait_seconds = 20\nsound = "default"\n',
115
+ projectToml: 'wait_seconds = 5\n',
116
+ })
117
+ const config = loadConfig({ cwd, env })
118
+ expect(config.wait_seconds.value).toBe(5)
119
+ expect(config.wait_seconds.source).toMatch(/^project:/)
120
+ expect(config.sound.value).toBe('default')
121
+ expect(config.sound.source).toMatch(/^global:/)
122
+ })
123
+
124
+ it('flags beat everything', () => {
125
+ const { env, cwd } = setup({ projectToml: 'wait_seconds = 5\n' })
126
+ const config = loadConfig({ cwd, env, flags: { wait_seconds: 2 } })
127
+ expect(config.wait_seconds.value).toBe(2)
128
+ expect(config.wait_seconds.source).toBe('flag')
129
+ })
130
+ })
131
+
132
+ describe('draft building', () => {
133
+ const base = setup({})
134
+
135
+ it('builds a minimal draft with defaults', () => {
136
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
137
+ const build = buildDraft(config, { title: 'T', body: 'B' })
138
+ if (!build.ok) throw new Error(build.error)
139
+ expect(build.draft).toEqual({
140
+ schema_version: 1,
141
+ presentation: { title: 'T', body: 'B' },
142
+ targets: { mode: 'all' },
143
+ delivery: { ttl_seconds: 86400, collapse_key: null },
144
+ })
145
+ })
146
+
147
+ it('stamps the project from flag or project config', () => {
148
+ const { env, cwd } = setup({ projectToml: 'project = "my-app"\n' })
149
+ const config = loadConfig({ cwd, env })
150
+ const fromConfig = buildDraft(config, { title: 'T', body: 'B' })
151
+ if (!fromConfig.ok) throw new Error(fromConfig.error)
152
+ expect(fromConfig.draft.project).toBe('my-app')
153
+ const fromFlag = buildDraft(config, { title: 'T', body: 'B', project: 'other-app' })
154
+ if (!fromFlag.ok) throw new Error(fromFlag.error)
155
+ expect(fromFlag.draft.project).toBe('other-app')
156
+ })
157
+
158
+ it('carries --kind and rejects one outside the vocabulary', () => {
159
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
160
+ const done = buildDraft(config, { title: 'T', body: 'B', kind: 'done' })
161
+ if (!done.ok) throw new Error(done.error)
162
+ expect(done.draft.kind).toBe('done')
163
+ // Absent stays absent rather than becoming an explicit 'update': the
164
+ // server reads a missing field as the default, and the envelope has 4096
165
+ // bytes to spend on things that say something.
166
+ const plain = buildDraft(config, { title: 'T', body: 'B' })
167
+ if (!plain.ok) throw new Error(plain.error)
168
+ expect(plain.draft.kind).toBeUndefined()
169
+ // Caught in the CLI so the agent gets the list, not a schema error from
170
+ // the server after a round trip.
171
+ const bad = buildDraft(config, { title: 'T', body: 'B', kind: 'finished' })
172
+ expect(bad.ok).toBe(false)
173
+ if (bad.ok) throw new Error('expected rejection')
174
+ expect(bad.error).toContain('"done"')
175
+ })
176
+
177
+ it('uses configured devices unless --all is passed', () => {
178
+ const { env, cwd } = setup({ projectToml: 'devices = ["dev_a", "dev_b"]\n' })
179
+ const config = loadConfig({ cwd, env })
180
+ const scoped = buildDraft(config, { title: 'T', body: 'B' })
181
+ if (!scoped.ok) throw new Error(scoped.error)
182
+ expect(scoped.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_a', 'dev_b'] })
183
+ const all = buildDraft(config, { title: 'T', body: 'B', all: true })
184
+ if (!all.ok) throw new Error(all.error)
185
+ expect(all.draft.targets).toEqual({ mode: 'all' })
186
+ })
187
+
188
+ it('maps sound none to explicit null and parses custom data', () => {
189
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
190
+ const build = buildDraft(config, {
191
+ title: 'T',
192
+ body: 'B',
193
+ sound: 'none',
194
+ level: 'time_sensitive',
195
+ data: ['run_id=42', 'branch=main'],
196
+ })
197
+ if (!build.ok) throw new Error(build.error)
198
+ expect(build.draft.platform?.ios).toEqual({
199
+ sound: null,
200
+ interruption_level: 'time_sensitive',
201
+ custom_data: { run_id: '42', branch: 'main' },
202
+ })
203
+ })
204
+
205
+ it('maps optional fields into the selected macOS platform slot', () => {
206
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
207
+ const build = buildDraft(config, {
208
+ title: 'T',
209
+ body: 'B',
210
+ platform: 'macos',
211
+ sound: 'none',
212
+ badge: 7,
213
+ threadId: 'desktop-builds',
214
+ level: 'passive',
215
+ relevance: 0.8,
216
+ targetContentId: 'build-detail',
217
+ data: ['run_id=42'],
218
+ })
219
+ if (!build.ok) throw new Error(build.error)
220
+
221
+ expect(build.platform).toBe('macos')
222
+ expect(build.draft.platform).toEqual({
223
+ macos: {
224
+ sound: null,
225
+ badge: 7,
226
+ thread_id: 'desktop-builds',
227
+ interruption_level: 'passive',
228
+ relevance_score: 0.8,
229
+ target_content_id: 'build-detail',
230
+ custom_data: { run_id: '42' },
231
+ },
232
+ })
233
+ })
234
+
235
+ it('rejects malformed inputs with usage errors', () => {
236
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
237
+ expect(buildDraft(config, { title: '', body: 'B' }).ok).toBe(false)
238
+ expect(buildDraft(config, { title: 'T', body: 'B', sound: 'airhorn' }).ok).toBe(false)
239
+ expect(buildDraft(config, { title: 'T', body: 'B', data: ['nokey'] }).ok).toBe(false)
240
+ expect(buildDraft(config, { title: 'T', body: 'B', level: 'shouting' }).ok).toBe(false)
241
+ expect(buildDraft(config, { title: 'T', body: 'B', platform: 'linux' }).ok).toBe(false)
242
+ })
243
+ })
package/src/config.ts ADDED
@@ -0,0 +1,347 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { parse as parseToml } from 'smol-toml'
6
+ import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
7
+
8
+ /**
9
+ * Layered configuration with provenance. Most specific wins:
10
+ *
11
+ * flag > session > project-local > project > machine-global > default
12
+ *
13
+ * `session` holds what the user told the agent in conversation ("stop
14
+ * notifying me for now"). It lives on disk keyed by harness session id rather
15
+ * than in the agent's context, because context is lost to compaction and a
16
+ * preference that evaporates mid-session is worse than none.
17
+ *
18
+ * `project-local` is `.notifai/config.local.toml`, intended to be gitignored.
19
+ * `.notifai/config.toml` is intended for committed, shared configuration, so it
20
+ * must not be where a personal "route my questions to my devices" preference
21
+ * lands. The CLI does not edit a repository's ignore rules or create commits.
22
+ *
23
+ * `config show --explain` surfaces the winning layer per key.
24
+ */
25
+
26
+ export type ConfigSource =
27
+ | 'flag'
28
+ | `session:${string}`
29
+ | `project-local:${string}`
30
+ | `project:${string}`
31
+ | `global:${string}`
32
+ | 'default'
33
+
34
+ export interface ResolvedValue<T> {
35
+ value: T
36
+ source: ConfigSource
37
+ }
38
+
39
+ type CliSound = (typeof CLI_SOUNDS)[number]
40
+ type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
41
+
42
+ export interface CliConfig {
43
+ base_url: ResolvedValue<string>
44
+ wait_seconds: ResolvedValue<number>
45
+ ttl_seconds: ResolvedValue<number>
46
+ collapse_key: ResolvedValue<string | null>
47
+ devices: ResolvedValue<string[] | null>
48
+ sound: ResolvedValue<CliSound | null>
49
+ interruption_level: ResolvedValue<CliInterruptionLevel | null>
50
+ /** Project identifier stamped on sends; typically set in .notifai/config.toml. */
51
+ project: ResolvedValue<string | null>
52
+ /** Free-text notification criteria agents consult before sending. */
53
+ notify_criteria: ResolvedValue<string | null>
54
+ /** Route a registered agent question to companion devices after the presence gate. */
55
+ ask_notifications: ResolvedValue<boolean>
56
+ /**
57
+ * Whether being at this machine suppresses a question. Sitting at the
58
+ * keyboard is not the same as not wanting to be reached — needing to stop
59
+ * using the machine in order to be notified is its own kind of interruption.
60
+ *
61
+ * On, the default, is the original behaviour: a question stays in the
62
+ * terminal while the keyboard is in use, because the person is right there
63
+ * and a push would be redundant. Off says notify me anyway — the grace window
64
+ * still runs, so the question is still offered to the terminal first, it just
65
+ * no longer needs the user to walk away before it may leave.
66
+ *
67
+ * This is deliberately separate from `ask_notifications`, which switches
68
+ * escalation off altogether. Wanting to be reached while working is the
69
+ * opposite of not wanting to be reached.
70
+ */
71
+ require_idle: ResolvedValue<boolean>
72
+ /**
73
+ * Keyboard/mouse idle time after which this machine counts as unattended.
74
+ * When the OS idle signal is unavailable, silence since the user's last prompt
75
+ * is the conservative fallback. Below the threshold the hooks stay out of the
76
+ * way entirely, because a blocking hook also blocks the terminal prompt the
77
+ * present user would use.
78
+ *
79
+ * Only consulted while `require_idle` is on.
80
+ */
81
+ away_after_seconds: ResolvedValue<number>
82
+ /**
83
+ * How long a hook blocks waiting for a companion-device answer before falling through
84
+ * to the harness's own prompt. Must stay under the harness hook timeout
85
+ * (600s in both Claude Code and Codex) or the harness kills us mid-wait.
86
+ */
87
+ hook_reply_timeout_seconds: ResolvedValue<number>
88
+ /**
89
+ * Terminal-first grace window, measured from the moment the question was
90
+ * sent: the agent asks in the terminal and starts a timer, and only once it
91
+ * elapses does the question reach companion devices.
92
+ *
93
+ * A timer, and only a timer. It is configured independently of
94
+ * `away_after_seconds` and honoured whether or not presence is consulted at
95
+ * all — how long to wait and whether anyone is watching are separate
96
+ * questions, and answering one should not answer the other.
97
+ */
98
+ ask_grace_seconds: ResolvedValue<number>
99
+ }
100
+
101
+ export const CONFIG_KEYS = [
102
+ 'base_url',
103
+ 'wait_seconds',
104
+ 'ttl_seconds',
105
+ 'collapse_key',
106
+ 'devices',
107
+ 'sound',
108
+ 'interruption_level',
109
+ 'project',
110
+ 'notify_criteria',
111
+ 'ask_notifications',
112
+ 'require_idle',
113
+ 'away_after_seconds',
114
+ 'hook_reply_timeout_seconds',
115
+ 'ask_grace_seconds',
116
+ ] as const
117
+ export type ConfigKey = (typeof CONFIG_KEYS)[number]
118
+
119
+ /** Keys whose TOML value must parse as a boolean; used by `config set`. */
120
+ export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = [
121
+ 'ask_notifications',
122
+ 'require_idle',
123
+ ]
124
+
125
+ /** Keys whose TOML value must parse as an integer within its configured bounds. */
126
+ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
127
+ 'wait_seconds',
128
+ 'ttl_seconds',
129
+ 'away_after_seconds',
130
+ 'hook_reply_timeout_seconds',
131
+ 'ask_grace_seconds',
132
+ ]
133
+
134
+ const DEFAULTS = {
135
+ base_url: 'https://notifai.fly.dev',
136
+ wait_seconds: 10,
137
+ ttl_seconds: 86400,
138
+ collapse_key: null,
139
+ devices: null,
140
+ sound: null,
141
+ interruption_level: null,
142
+ project: null,
143
+ notify_criteria: null,
144
+ // Installing the hooks is the opt-in; these exist to switch the behaviour
145
+ // back off for a project or a session without uninstalling anything.
146
+ ask_notifications: true,
147
+ // On by default because it is the less surprising of the two: a question
148
+ // appearing on your phone while you are typing the answer into the terminal
149
+ // is worse as a default than one that waits for you to leave.
150
+ require_idle: true,
151
+ away_after_seconds: 120,
152
+ // 300 + 180 + the installer's 60s headroom = 540, inside the 600s
153
+ // command-hook ceiling both harnesses enforce.
154
+ hook_reply_timeout_seconds: 180,
155
+ ask_grace_seconds: 300,
156
+ } satisfies Record<ConfigKey, unknown>
157
+
158
+ export function globalConfigDir(env: NodeJS.ProcessEnv = process.env): string {
159
+ const xdg = env['XDG_CONFIG_HOME']
160
+ const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.config')
161
+ return path.join(base, 'notifai')
162
+ }
163
+
164
+ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
165
+ return path.join(globalConfigDir(env), 'config.toml')
166
+ }
167
+
168
+ /**
169
+ * Machine-local mutable state: presence markers, pending questions, and
170
+ * session config overrides. Separate from the config dir because none of it is
171
+ * user-authored and none of it should ever be synced or committed.
172
+ */
173
+ export function stateDir(env: NodeJS.ProcessEnv = process.env): string {
174
+ const xdg = env['XDG_STATE_HOME']
175
+ const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
176
+ return path.join(base, 'notifai')
177
+ }
178
+
179
+ /** Per-harness-session override file; see the ConfigSource doc comment. */
180
+ export function sessionConfigPath(sessionId: string, env: NodeJS.ProcessEnv = process.env): string {
181
+ return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.config.toml`)
182
+ }
183
+
184
+ /**
185
+ * Session ids come from harness JSON, so they reach the filesystem from
186
+ * outside. Hash rather than merely sanitising: replacing unsafe characters
187
+ * collapsed distinct ids onto one file — `a/b` and `a?b` both became `a_b`, and
188
+ * two sessions would then share presence, pending questions and overrides.
189
+ * A readable prefix is kept only so the directory is debuggable.
190
+ */
191
+ export function sanitizeSessionId(sessionId: string): string {
192
+ const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 16)
193
+ const readable = sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32)
194
+ return readable === '' ? digest : `${readable}-${digest}`
195
+ }
196
+
197
+ /**
198
+ * Pointer from a project directory to the harness session currently working in
199
+ * it, written by the UserPromptSubmit hook. Without it `notifai ask` has no way
200
+ * to learn its own session id: an ordinary agent shell command receives no hook
201
+ * payload, and no harness exports one into the environment.
202
+ */
203
+ export function projectSessionPointerPath(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
204
+ const digest = createHash('sha256').update(canonicalDir(cwd)).digest('hex').slice(0, 32)
205
+ return path.join(stateDir(env), 'projects', `${digest}.json`)
206
+ }
207
+
208
+ /**
209
+ * Resolve symlinks before hashing. The harness reports `cwd` as it knows it
210
+ * while a shell reports `process.cwd()` already resolved — on macOS `/tmp` and
211
+ * `/private/tmp` are the same directory, so hashing the unresolved paths gave
212
+ * two different keys and `notifai ask` could never find the pointer the hook
213
+ * had just written.
214
+ */
215
+ function canonicalDir(dir: string): string {
216
+ try {
217
+ return realpathSync(path.resolve(dir))
218
+ } catch {
219
+ return path.resolve(dir)
220
+ }
221
+ }
222
+
223
+ /** Walk up from cwd looking for .notifai/<name> (Project Override). */
224
+ function findProjectFile(startDir: string, name: string): string | null {
225
+ let dir = path.resolve(startDir)
226
+ for (;;) {
227
+ const candidate = path.join(dir, '.notifai', name)
228
+ if (existsSync(candidate)) return candidate
229
+ const parent = path.dirname(dir)
230
+ if (parent === dir) return null
231
+ dir = parent
232
+ }
233
+ }
234
+
235
+ export function findProjectConfigPath(startDir: string): string | null {
236
+ return findProjectFile(startDir, 'config.toml')
237
+ }
238
+
239
+ /** Personal sibling of the shared project config; callers should keep it gitignored. */
240
+ export function findProjectLocalConfigPath(startDir: string): string | null {
241
+ return findProjectFile(startDir, 'config.local.toml')
242
+ }
243
+
244
+ /**
245
+ * Ranges every layer is clamped to. Config is readable from a repository, so an
246
+ * out-of-range value is attacker input, not a typo: `away_after_seconds = -1`
247
+ * would make a user who just typed count as absent, and an oversized reply
248
+ * timeout would push the generated hook past the harness's 600s ceiling so the
249
+ * hook is killed after the user has already answered.
250
+ */
251
+ const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
252
+ wait_seconds: { min: 0, max: 300 },
253
+ ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
254
+ away_after_seconds: { min: 5, max: 24 * 3600 },
255
+ // 540 + the installer's 60s headroom stays inside the 600s command-hook
256
+ // budget shared by Claude Code and Codex.
257
+ hook_reply_timeout_seconds: { min: 10, max: 540 },
258
+ ask_grace_seconds: { min: 0, max: 540 },
259
+ }
260
+
261
+ /** Drops values a layer must not be trusted to supply, rather than throwing. */
262
+ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
263
+ const bounds = NUMERIC_BOUNDS[key]
264
+ if (bounds !== undefined) {
265
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
266
+ return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(value)))
267
+ }
268
+ if (BOOLEAN_CONFIG_KEYS.includes(key)) return typeof value === 'boolean' ? value : undefined
269
+ return value
270
+ }
271
+
272
+ export function configBounds(key: ConfigKey): { min: number; max: number } | undefined {
273
+ return NUMERIC_BOUNDS[key]
274
+ }
275
+
276
+ function readTomlFile(filePath: string): Record<string, unknown> {
277
+ try {
278
+ return parseToml(readFileSync(filePath, 'utf8')) as Record<string, unknown>
279
+ } catch (err) {
280
+ throw new Error(`Could not parse ${filePath}: ${String(err)}`)
281
+ }
282
+ }
283
+
284
+ export interface FlagOverrides {
285
+ base_url?: string
286
+ wait_seconds?: number
287
+ ttl_seconds?: number
288
+ collapse_key?: string | null
289
+ devices?: string[]
290
+ sound?: CliSound
291
+ interruption_level?: CliInterruptionLevel
292
+ }
293
+
294
+ export function loadConfig(options: {
295
+ cwd?: string
296
+ env?: NodeJS.ProcessEnv
297
+ flags?: FlagOverrides
298
+ /** Harness session id; enables the session override layer when present. */
299
+ sessionId?: string | undefined
300
+ }): CliConfig {
301
+ const env = options.env ?? process.env
302
+ const flags = options.flags ?? {}
303
+ const globalPath = globalConfigPath(env)
304
+ const globalRaw = existsSync(globalPath) ? readTomlFile(globalPath) : {}
305
+ const cwd = options.cwd ?? process.cwd()
306
+ const projectPath = findProjectConfigPath(cwd)
307
+ const projectRaw = projectPath ? readTomlFile(projectPath) : {}
308
+ const projectLocalPath = findProjectLocalConfigPath(cwd)
309
+ const projectLocalRaw = projectLocalPath ? readTomlFile(projectLocalPath) : {}
310
+ const sessionId = options.sessionId ?? env['NOTIFAI_SESSION']
311
+ const sessionPath = sessionId ? sessionConfigPath(sessionId, env) : null
312
+ const sessionRaw = sessionPath && existsSync(sessionPath) ? readTomlFile(sessionPath) : {}
313
+
314
+ function resolve<K extends ConfigKey>(key: K): ResolvedValue<never> {
315
+ const layers: [unknown, ConfigSource][] = [
316
+ [(flags as Record<string, unknown>)[key], 'flag'],
317
+ [sessionPath ? sessionRaw[key] : undefined, `session:${sessionPath ?? ''}`],
318
+ [projectLocalPath ? projectLocalRaw[key] : undefined, `project-local:${projectLocalPath ?? ''}`],
319
+ [projectPath ? projectRaw[key] : undefined, `project:${projectPath ?? ''}`],
320
+ [globalRaw[key], `global:${globalPath}`],
321
+ ]
322
+ for (const [raw, source] of layers) {
323
+ if (raw === undefined) continue
324
+ const coerced = coerce(key, raw)
325
+ if (coerced === undefined) continue
326
+ return { value: coerced as never, source }
327
+ }
328
+ return { value: DEFAULTS[key] as never, source: 'default' }
329
+ }
330
+
331
+ return {
332
+ base_url: resolve('base_url'),
333
+ wait_seconds: resolve('wait_seconds'),
334
+ ttl_seconds: resolve('ttl_seconds'),
335
+ collapse_key: resolve('collapse_key'),
336
+ devices: resolve('devices'),
337
+ sound: resolve('sound'),
338
+ interruption_level: resolve('interruption_level'),
339
+ project: resolve('project'),
340
+ notify_criteria: resolve('notify_criteria'),
341
+ ask_notifications: resolve('ask_notifications'),
342
+ require_idle: resolve('require_idle'),
343
+ away_after_seconds: resolve('away_after_seconds'),
344
+ hook_reply_timeout_seconds: resolve('hook_reply_timeout_seconds'),
345
+ ask_grace_seconds: resolve('ask_grace_seconds'),
346
+ }
347
+ }