@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.
- package/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- 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
|
+
}
|