@raidiant/notifai 3.0.1 → 5.0.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/CHANGELOG.md +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
|
@@ -86,7 +86,7 @@ describe('formatValue', () => {
|
|
|
86
86
|
it('explains what an unset value means instead of printing null', () => {
|
|
87
87
|
// `sound = null` is accurate and tells the reader nothing about what will
|
|
88
88
|
// happen; the whole complaint about the old output in one line.
|
|
89
|
-
expect(formatValue('sound', null)).toBe('
|
|
89
|
+
expect(formatValue('sound', null)).toBe('not set; each kind brings its own sound')
|
|
90
90
|
expect(formatValue('devices', null)).toBe('every device that can receive')
|
|
91
91
|
})
|
|
92
92
|
|
package/src/config-schema.ts
CHANGED
|
@@ -85,7 +85,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
85
85
|
kind: 'boolean',
|
|
86
86
|
summary: 'Whether a registered question may reach your phone and Mac at all',
|
|
87
87
|
detail:
|
|
88
|
-
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever
|
|
88
|
+
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
|
|
89
89
|
example: 'true',
|
|
90
90
|
},
|
|
91
91
|
ask_grace_seconds: {
|
|
@@ -99,6 +99,17 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
99
99
|
example: '0',
|
|
100
100
|
},
|
|
101
101
|
|
|
102
|
+
reply_window_seconds: {
|
|
103
|
+
label: 'How long an answer is accepted',
|
|
104
|
+
group: 'questions',
|
|
105
|
+
kind: 'integer',
|
|
106
|
+
unit: 's',
|
|
107
|
+
summary: 'How long the service keeps accepting your answer to a question',
|
|
108
|
+
detail:
|
|
109
|
+
'A question stays answerable for this long after it is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
|
|
110
|
+
example: '86400',
|
|
111
|
+
},
|
|
112
|
+
|
|
102
113
|
sound: {
|
|
103
114
|
label: 'Sound',
|
|
104
115
|
group: 'delivery',
|
|
@@ -107,14 +118,14 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
107
118
|
choiceHints: {
|
|
108
119
|
default: 'the standard notification tone',
|
|
109
120
|
done: 'the completion chime',
|
|
110
|
-
attention: '
|
|
121
|
+
attention: 'a distinct attention tone',
|
|
111
122
|
alert: 'the most insistent tone',
|
|
112
123
|
none: 'silent — arrives without a sound',
|
|
113
124
|
},
|
|
114
|
-
summary: '
|
|
125
|
+
summary: 'Set the sound notifications from here use',
|
|
115
126
|
detail:
|
|
116
|
-
'
|
|
117
|
-
unsetMeans: '
|
|
127
|
+
'Setting this pins one sound for every notification sent from here, whatever kind it is.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and the standard tone for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
|
|
128
|
+
unsetMeans: 'not set; each kind brings its own sound',
|
|
118
129
|
},
|
|
119
130
|
interruption_level: {
|
|
120
131
|
label: 'Interruption level',
|
|
@@ -124,12 +135,12 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
124
135
|
choiceHints: {
|
|
125
136
|
passive: 'no sound or wake — waits in the notification list',
|
|
126
137
|
active: 'wakes the screen and plays the sound',
|
|
127
|
-
time_sensitive: '
|
|
138
|
+
time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
|
|
128
139
|
},
|
|
129
|
-
summary: '
|
|
140
|
+
summary: 'Set how insistently notifications from here arrive',
|
|
130
141
|
detail:
|
|
131
|
-
'How hard a notification tries to reach you, in the operating system\'s terms.
|
|
132
|
-
unsetMeans: '
|
|
142
|
+
'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind chooses the sound, never this.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` is accepted and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities` for what a platform actually honours.',
|
|
143
|
+
unsetMeans: 'not specified; the destination uses its normal behavior',
|
|
133
144
|
},
|
|
134
145
|
devices: {
|
|
135
146
|
label: 'Default devices',
|
|
@@ -178,8 +189,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
178
189
|
kind: 'string',
|
|
179
190
|
summary: 'The name this project reports itself as',
|
|
180
191
|
detail:
|
|
181
|
-
'Stamped on every notification sent from this directory, and how the companion apps group and filter what arrives.
|
|
182
|
-
unsetMeans: '
|
|
192
|
+
'Stamped on every notification sent from this directory, and how the companion apps group and filter what arrives. When unset, the CLI infers a stable identifier from Git (shared across linked worktrees) or the current directory. Set it in `.notifai/config.toml` only when that inferred name is not the identity you want.',
|
|
193
|
+
unsetMeans: 'inferred from Git or the current directory',
|
|
183
194
|
example: 'my-app',
|
|
184
195
|
},
|
|
185
196
|
notify_criteria: {
|
package/src/config.test.ts
CHANGED
|
@@ -98,6 +98,25 @@ describe('harness config layers', () => {
|
|
|
98
98
|
})
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
it('uses NOTIFAI_SESSION_ID for the session layer and ignores the deleted env name', () => {
|
|
102
|
+
const { env, cwd } = setup({
|
|
103
|
+
sessionToml: 'ask_grace_seconds = 45\n',
|
|
104
|
+
sessionId: 'exact-session',
|
|
105
|
+
})
|
|
106
|
+
expect(
|
|
107
|
+
loadConfig({
|
|
108
|
+
cwd,
|
|
109
|
+
env: { ...env, NOTIFAI_SESSION_ID: 'exact-session' },
|
|
110
|
+
}).ask_grace_seconds.value,
|
|
111
|
+
).toBe(45)
|
|
112
|
+
expect(
|
|
113
|
+
loadConfig({
|
|
114
|
+
cwd,
|
|
115
|
+
env: { ...env, NOTIFAI_SESSION: 'exact-session' },
|
|
116
|
+
}).ask_grace_seconds.value,
|
|
117
|
+
).toBe(0)
|
|
118
|
+
})
|
|
119
|
+
|
|
101
120
|
it('keeps hostile or colliding session ids inside their own file', () => {
|
|
102
121
|
const { env, cwd } = setup({
|
|
103
122
|
sessionToml: 'ask_grace_seconds = 99\n',
|
|
@@ -172,28 +191,35 @@ describe('draft building', () => {
|
|
|
172
191
|
presentation: { title: 'T', body: 'B' },
|
|
173
192
|
targets: { mode: 'all' },
|
|
174
193
|
delivery: { ttl_seconds: 86400, collapse_key: null },
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
macos: { sound: null, interruption_level: 'passive' },
|
|
178
|
-
},
|
|
194
|
+
// An unlabelled draft is ordinary news, and news has a sound.
|
|
195
|
+
platform: { ios: { sound: 'default' }, macos: { sound: 'default' } },
|
|
179
196
|
})
|
|
180
197
|
})
|
|
181
198
|
|
|
182
199
|
it.each([
|
|
183
|
-
{ flags: {}, sound:
|
|
184
|
-
{ flags: { kind: '
|
|
185
|
-
{ flags: {
|
|
186
|
-
|
|
200
|
+
{ flags: {}, sound: 'default' },
|
|
201
|
+
{ flags: { kind: 'update' }, sound: 'default' },
|
|
202
|
+
{ flags: { kind: 'done' }, sound: 'done' },
|
|
203
|
+
{ flags: { kind: 'failed' }, sound: 'alert' },
|
|
204
|
+
{ flags: { kind: 'blocked' }, sound: 'attention' },
|
|
205
|
+
{ flags: { kind: 'question' }, sound: 'attention' },
|
|
206
|
+
{ flags: { reply: true }, sound: 'attention' },
|
|
207
|
+
])('gives $flags the $sound sound when nobody chose one', ({ flags, sound }) => {
|
|
187
208
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
188
209
|
const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
|
|
189
210
|
if (!build.ok) throw new Error(build.error)
|
|
190
|
-
expect(build.draft.platform).
|
|
191
|
-
|
|
192
|
-
macos: { sound, interruption_level: level },
|
|
193
|
-
})
|
|
211
|
+
expect(build.draft.platform?.ios?.sound).toBe(sound)
|
|
212
|
+
expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
|
|
194
213
|
})
|
|
195
214
|
|
|
196
|
-
it('
|
|
215
|
+
it('lets an explicit sound outrank the kind it would have derived', () => {
|
|
216
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
217
|
+
const explicit = buildDraft(config, { title: 'T', body: 'B', kind: 'failed', sound: 'none' })
|
|
218
|
+
if (!explicit.ok) throw new Error(explicit.error)
|
|
219
|
+
expect(explicit.draft.platform?.ios?.sound).toBeNull()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('orders explicit flags over saved user config', () => {
|
|
197
223
|
const { env, cwd } = setup({
|
|
198
224
|
projectToml: 'sound = "alert"\ninterruption_level = "active"\n',
|
|
199
225
|
})
|
|
@@ -219,23 +245,157 @@ describe('draft building', () => {
|
|
|
219
245
|
})
|
|
220
246
|
})
|
|
221
247
|
|
|
222
|
-
it('keeps a
|
|
248
|
+
it('keeps a question answerable for a day by default', () => {
|
|
249
|
+
// A question asked before someone steps away should still be theirs to
|
|
250
|
+
// answer when they come back; the CLI used to cut the wire contract's own
|
|
251
|
+
// default down to an hour.
|
|
223
252
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
224
253
|
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
225
254
|
if (!build.ok) throw new Error(build.error)
|
|
226
255
|
|
|
227
|
-
expect(build.draft.reply?.expires_in_seconds).toBe(
|
|
256
|
+
expect(build.draft.reply?.expires_in_seconds).toBe(86_400)
|
|
228
257
|
})
|
|
229
258
|
|
|
230
|
-
it('
|
|
259
|
+
it('lets the user shorten or lengthen how long an answer is accepted', () => {
|
|
260
|
+
const { env, cwd } = setup({ projectToml: 'reply_window_seconds = 7200\n' })
|
|
261
|
+
const config = loadConfig({ cwd, env })
|
|
262
|
+
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
263
|
+
if (!build.ok) throw new Error(build.error)
|
|
264
|
+
expect(build.draft.reply?.expires_in_seconds).toBe(7_200)
|
|
265
|
+
|
|
266
|
+
const explicit = buildDraft(config, {
|
|
267
|
+
title: 'Question',
|
|
268
|
+
body: 'Deploy?',
|
|
269
|
+
reply: true,
|
|
270
|
+
replyWindow: 600,
|
|
271
|
+
})
|
|
272
|
+
if (!explicit.ok) throw new Error(explicit.error)
|
|
273
|
+
expect(explicit.draft.reply?.expires_in_seconds).toBe(600)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('orders explicit and configured Project identity over inference', () => {
|
|
231
277
|
const { env, cwd } = setup({ projectToml: 'project = "my-app"\n' })
|
|
232
278
|
const config = loadConfig({ cwd, env })
|
|
233
|
-
const fromConfig = buildDraft(
|
|
279
|
+
const fromConfig = buildDraft(
|
|
280
|
+
config,
|
|
281
|
+
{ title: 'T', body: 'B' },
|
|
282
|
+
{ inferredProject: 'inferred-app' },
|
|
283
|
+
)
|
|
234
284
|
if (!fromConfig.ok) throw new Error(fromConfig.error)
|
|
235
285
|
expect(fromConfig.draft.project).toBe('my-app')
|
|
236
|
-
const fromFlag = buildDraft(
|
|
286
|
+
const fromFlag = buildDraft(
|
|
287
|
+
config,
|
|
288
|
+
{ title: 'T', body: 'B', project: 'other-app' },
|
|
289
|
+
{ inferredProject: 'inferred-app' },
|
|
290
|
+
)
|
|
237
291
|
if (!fromFlag.ok) throw new Error(fromFlag.error)
|
|
238
292
|
expect(fromFlag.draft.project).toBe('other-app')
|
|
293
|
+
|
|
294
|
+
const inferredCase = setup({})
|
|
295
|
+
const unset = loadConfig({ cwd: inferredCase.cwd, env: inferredCase.env })
|
|
296
|
+
const inferred = buildDraft(
|
|
297
|
+
unset,
|
|
298
|
+
{ title: 'T', body: 'B' },
|
|
299
|
+
{ inferredProject: 'inferred-app' },
|
|
300
|
+
)
|
|
301
|
+
if (!inferred.ok) throw new Error(inferred.error)
|
|
302
|
+
expect(inferred.draft.project).toBe('inferred-app')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('carries Source Context without copying authoring overrides into legacy fields', () => {
|
|
306
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
307
|
+
const build = buildDraft(
|
|
308
|
+
config,
|
|
309
|
+
{ title: 'T', body: 'B', sessionId: 'opaque', sessionLabel: 'Visible' },
|
|
310
|
+
{
|
|
311
|
+
source: {
|
|
312
|
+
session_id: 'opaque',
|
|
313
|
+
session_label: 'Visible',
|
|
314
|
+
harness: 'codex',
|
|
315
|
+
branch: 'main',
|
|
316
|
+
worktree: 'topic',
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
if (!build.ok) throw new Error(build.error)
|
|
321
|
+
expect(build.draft.source).toEqual({
|
|
322
|
+
session_id: 'opaque',
|
|
323
|
+
session_label: 'Visible',
|
|
324
|
+
harness: 'codex',
|
|
325
|
+
branch: 'main',
|
|
326
|
+
worktree: 'topic',
|
|
327
|
+
})
|
|
328
|
+
expect(build.draft).not.toHaveProperty('session')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('rewrites positional media references and preserves canonical ids', () => {
|
|
332
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
333
|
+
const build = buildDraft(config, {
|
|
334
|
+
title: 'Visual result',
|
|
335
|
+
body: ' then  and ',
|
|
336
|
+
image: ['med_first', 'med_second', 'med_attached'],
|
|
337
|
+
imageAlt: ['First image', 'Second image'],
|
|
338
|
+
})
|
|
339
|
+
if (!build.ok) throw new Error(build.error)
|
|
340
|
+
expect(build.draft.presentation.body).toBe(
|
|
341
|
+
' then  and ',
|
|
342
|
+
)
|
|
343
|
+
expect(build.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
|
|
344
|
+
expect(build.draft.presentation.media).toEqual([
|
|
345
|
+
{ media_id: 'med_first', alt: 'First image' },
|
|
346
|
+
{ media_id: 'med_second', alt: 'Second image' },
|
|
347
|
+
{ media_id: 'med_attached' },
|
|
348
|
+
])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('preserves sparse alt text in an internal ordered media manifest', () => {
|
|
352
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
353
|
+
const build = buildDraft(config, {
|
|
354
|
+
title: 'Visual result',
|
|
355
|
+
body: '',
|
|
356
|
+
media: [
|
|
357
|
+
{ media_id: 'med_first' },
|
|
358
|
+
{ media_id: 'med_second', alt: 'Second image' },
|
|
359
|
+
],
|
|
360
|
+
})
|
|
361
|
+
if (!build.ok) throw new Error(build.error)
|
|
362
|
+
expect(build.draft.presentation.body).toBe('')
|
|
363
|
+
expect(build.draft.presentation.media).toEqual([
|
|
364
|
+
{ media_id: 'med_first' },
|
|
365
|
+
{ media_id: 'med_second', alt: 'Second image' },
|
|
366
|
+
])
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('names an out-of-range positional media reference', () => {
|
|
370
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
371
|
+
const build = buildDraft(config, {
|
|
372
|
+
title: 'Visual result',
|
|
373
|
+
body: '',
|
|
374
|
+
image: ['med_first', 'med_second'],
|
|
375
|
+
})
|
|
376
|
+
expect(build).toEqual({
|
|
377
|
+
ok: false,
|
|
378
|
+
error: 'Body reference "media:3" has no matching --image occurrence.',
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('validates media cardinality and alt pairing', () => {
|
|
383
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
384
|
+
expect(
|
|
385
|
+
buildDraft(config, {
|
|
386
|
+
title: 'T',
|
|
387
|
+
body: 'B',
|
|
388
|
+
image: Array.from({ length: 9 }, (_, index) => `med_${index}`),
|
|
389
|
+
}),
|
|
390
|
+
).toMatchObject({ ok: false, error: expect.stringContaining('at most 8') })
|
|
391
|
+
expect(
|
|
392
|
+
buildDraft(config, {
|
|
393
|
+
title: 'T',
|
|
394
|
+
body: 'B',
|
|
395
|
+
image: ['med_one'],
|
|
396
|
+
imageAlt: ['one', 'two'],
|
|
397
|
+
}),
|
|
398
|
+
).toMatchObject({ ok: false, error: expect.stringContaining('2 --image-alt') })
|
|
239
399
|
})
|
|
240
400
|
|
|
241
401
|
it('carries --kind and rejects one outside the vocabulary', () => {
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
2
3
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
4
|
import path from 'node:path'
|
|
@@ -87,6 +88,8 @@ export interface CliConfig {
|
|
|
87
88
|
* usefulness.
|
|
88
89
|
*/
|
|
89
90
|
log_max_bytes: ResolvedValue<number>
|
|
91
|
+
/** How long the server keeps accepting an answer to a question. */
|
|
92
|
+
reply_window_seconds: ResolvedValue<number>
|
|
90
93
|
/** Log files kept in total, the active one included. */
|
|
91
94
|
log_max_files: ResolvedValue<number>
|
|
92
95
|
}
|
|
@@ -102,6 +105,7 @@ export const CONFIG_KEYS = [
|
|
|
102
105
|
'notify_criteria',
|
|
103
106
|
'ask_notifications',
|
|
104
107
|
'ask_grace_seconds',
|
|
108
|
+
'reply_window_seconds',
|
|
105
109
|
'log_level',
|
|
106
110
|
'log_max_bytes',
|
|
107
111
|
'log_max_files',
|
|
@@ -116,6 +120,7 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
|
116
120
|
'wait_seconds',
|
|
117
121
|
'ttl_seconds',
|
|
118
122
|
'ask_grace_seconds',
|
|
123
|
+
'reply_window_seconds',
|
|
119
124
|
'log_max_bytes',
|
|
120
125
|
'log_max_files',
|
|
121
126
|
]
|
|
@@ -149,6 +154,9 @@ const DEFAULTS = {
|
|
|
149
154
|
// Questions reach devices as soon as the turn ends. A terminal-first grace
|
|
150
155
|
// window is an opt-in delay on top of that.
|
|
151
156
|
ask_grace_seconds: 0,
|
|
157
|
+
// A day. The wire contract's own default, and long enough that a question
|
|
158
|
+
// asked before someone steps away is still answerable when they come back.
|
|
159
|
+
reply_window_seconds: 86_400,
|
|
152
160
|
// On by default: the log is the only account of what a headless hook did, and
|
|
153
161
|
// a diagnostic nobody switched on before the thing went wrong is not one.
|
|
154
162
|
log_level: 'info',
|
|
@@ -302,6 +310,10 @@ const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> =
|
|
|
302
310
|
// longest window a user can choose still leaves an answer window the server
|
|
303
311
|
// will accept — with room to spare for a timer that wakes late.
|
|
304
312
|
ask_grace_seconds: { min: 0, max: 360 },
|
|
313
|
+
// The floor is the shortest window the wire contract accepts; the ceiling is
|
|
314
|
+
// the point past which a question would outlive the retained content it
|
|
315
|
+
// refers to.
|
|
316
|
+
reply_window_seconds: { min: 60, max: REPLY_MAX_WINDOW_SECONDS },
|
|
305
317
|
// The floor keeps rotation from thrashing (a file smaller than a handful of
|
|
306
318
|
// records would rotate on nearly every write); the ceiling is the point past
|
|
307
319
|
// which "local diagnostics" has become "a database on your disk".
|
|
@@ -380,7 +392,7 @@ export function loadConfig(options: {
|
|
|
380
392
|
const projectRaw = projectPath ? readTomlFile(projectPath) : {}
|
|
381
393
|
const projectLocalPath = findProjectLocalConfigPath(cwd, env)
|
|
382
394
|
const projectLocalRaw = projectLocalPath ? readTomlFile(projectLocalPath) : {}
|
|
383
|
-
const sessionId = options.sessionId ?? env['
|
|
395
|
+
const sessionId = options.sessionId ?? env['NOTIFAI_SESSION_ID']
|
|
384
396
|
const sessionPath = sessionId ? sessionConfigPath(sessionId, env) : null
|
|
385
397
|
const sessionRaw = sessionPath && existsSync(sessionPath) ? readTomlFile(sessionPath) : {}
|
|
386
398
|
|
|
@@ -413,6 +425,7 @@ export function loadConfig(options: {
|
|
|
413
425
|
notify_criteria: resolve('notify_criteria'),
|
|
414
426
|
ask_notifications: resolve('ask_notifications'),
|
|
415
427
|
ask_grace_seconds: resolve('ask_grace_seconds'),
|
|
428
|
+
reply_window_seconds: resolve('reply_window_seconds'),
|
|
416
429
|
log_level: resolve('log_level'),
|
|
417
430
|
log_max_bytes: resolve('log_max_bytes'),
|
|
418
431
|
log_max_files: resolve('log_max_files'),
|
package/src/file-lock.test.ts
CHANGED
|
@@ -206,6 +206,47 @@ describe('cross-process file lock', () => {
|
|
|
206
206
|
}, 30_000)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
|
+
describe('rendezvous contention', () => {
|
|
210
|
+
// The last contender out removes the rendezvous directory, so a contender
|
|
211
|
+
// registering at that instant races the removal. Both tests drive that race
|
|
212
|
+
// from the registering observation, which is the window the racing releaser
|
|
213
|
+
// would otherwise have to hit by luck — on a loaded machine it does.
|
|
214
|
+
function raceAtRegistration(interfere: (lock: string) => void): void {
|
|
215
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-rendezvous-'))
|
|
216
|
+
const lock = path.join(root, 'shared.lock')
|
|
217
|
+
let raced = false
|
|
218
|
+
let entered = 0
|
|
219
|
+
|
|
220
|
+
withFileLock(lock, () => { entered += 1 }, {
|
|
221
|
+
observe(observation) {
|
|
222
|
+
if (observation.phase !== 'registering' || raced) return
|
|
223
|
+
raced = true
|
|
224
|
+
interfere(lock)
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(raced).toBe(true)
|
|
229
|
+
expect(entered).toBe(1)
|
|
230
|
+
// Release still cleans up after itself, whichever rendezvous it ended in.
|
|
231
|
+
expect(existsSync(lock)).toBe(false)
|
|
232
|
+
rmSync(root, { recursive: true, force: true })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
it('registers again when a releasing contender removed the rendezvous', () => {
|
|
236
|
+
raceAtRegistration((lock) => rmSync(lock, { recursive: true }))
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('holds the rendezvous it landed in, not the one it scanned', () => {
|
|
240
|
+
// A third contender recreates the directory before this one publishes, so
|
|
241
|
+
// the entry lands in a directory whose identity nobody has recorded yet.
|
|
242
|
+
// Treating that as tampering used to break the caller outright.
|
|
243
|
+
raceAtRegistration((lock) => {
|
|
244
|
+
rmSync(lock, { recursive: true })
|
|
245
|
+
mkdirSync(lock, { mode: 0o700 })
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
209
250
|
describe('target file transaction lock', () => {
|
|
210
251
|
it('refuses a symlinked target parent before publishing a contender', () => {
|
|
211
252
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
package/src/file-lock.ts
CHANGED
|
@@ -22,6 +22,14 @@ import path from 'node:path'
|
|
|
22
22
|
* process cannot remove a later owner's entry; because process liveness, rather
|
|
23
23
|
* than age, decides recovery, a delayed holder is never mistaken for a dead one.
|
|
24
24
|
*
|
|
25
|
+
* The rendezvous directory lives only as long as it has entries: the last
|
|
26
|
+
* contender out removes it, so an idle machine keeps no lock state. That makes
|
|
27
|
+
* the directory itself a contended resource. A contender registering while
|
|
28
|
+
* another releases can find it gone, half-deleted, or already replaced by a
|
|
29
|
+
* third contender, and none of those is a broken lock — so publication retries
|
|
30
|
+
* within the caller's deadline and pins its rendezvous only once its own entry
|
|
31
|
+
* is in the directory, which is the moment removal becomes impossible.
|
|
32
|
+
*
|
|
25
33
|
* Acquisition is deliberately bounded because both harness hooks and logging
|
|
26
34
|
* must fail open rather than hold an agent turn indefinitely.
|
|
27
35
|
*/
|
|
@@ -32,6 +40,8 @@ const ENTRY_PATTERN = /^(choosing|ticket-(\d+))-(\d+)-([0-9a-f]+)$/
|
|
|
32
40
|
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
33
41
|
|
|
34
42
|
export type FileLockObservation =
|
|
43
|
+
/** The rendezvous has been scanned and this contender is about to publish. */
|
|
44
|
+
| { phase: 'registering'; entry: string }
|
|
35
45
|
| { phase: 'choosing-published'; entry: string }
|
|
36
46
|
| { phase: 'stale-entry'; entry: string }
|
|
37
47
|
| { phase: 'waiting'; blockers: string[] }
|
|
@@ -125,6 +135,16 @@ function assertOwnedDirectory(directory: string): FileIdentity {
|
|
|
125
135
|
return { dev: stat.dev, ino: stat.ino }
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The rendezvous a contender was using is no longer the directory at that path.
|
|
140
|
+
*
|
|
141
|
+
* Fatal once an entry is published — a contender's own entry makes the
|
|
142
|
+
* directory non-empty, so no cooperating releaser can remove it. Before
|
|
143
|
+
* publication it means only that a releaser removed an empty rendezvous while
|
|
144
|
+
* this contender was registering in it, which is ordinary contention.
|
|
145
|
+
*/
|
|
146
|
+
class RendezvousReplaced extends Error {}
|
|
147
|
+
|
|
128
148
|
function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
129
149
|
const current = lstatSync(directory)
|
|
130
150
|
if (
|
|
@@ -133,7 +153,7 @@ function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
|
133
153
|
current.dev !== expected.dev ||
|
|
134
154
|
current.ino !== expected.ino
|
|
135
155
|
) {
|
|
136
|
-
throw new
|
|
156
|
+
throw new RendezvousReplaced(`${directory} changed while the file lock was active.`)
|
|
137
157
|
}
|
|
138
158
|
}
|
|
139
159
|
|
|
@@ -207,6 +227,24 @@ function publishEmpty(file: string): EntryIdentity {
|
|
|
207
227
|
}
|
|
208
228
|
}
|
|
209
229
|
|
|
230
|
+
/**
|
|
231
|
+
* True when a publication attempt lost its rendezvous rather than found a
|
|
232
|
+
* broken one.
|
|
233
|
+
*
|
|
234
|
+
* A releaser removes an empty rendezvous directory the moment the last entry
|
|
235
|
+
* goes, so a contender registering at that instant sees the removal from
|
|
236
|
+
* whichever side it reached first. All three are the same event: `ENOENT` when
|
|
237
|
+
* the directory is already gone, `EINVAL` when APFS reports a directory that is
|
|
238
|
+
* still being deleted, and a replaced identity when a third contender recreated
|
|
239
|
+
* it first. None of them says anything about the lock's integrity, because a
|
|
240
|
+
* contender with no published entry owns nothing yet.
|
|
241
|
+
*/
|
|
242
|
+
function lostTheRendezvous(err: unknown): boolean {
|
|
243
|
+
if (err instanceof RendezvousReplaced) return true
|
|
244
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
245
|
+
return code === 'ENOENT' || code === 'EINVAL'
|
|
246
|
+
}
|
|
247
|
+
|
|
210
248
|
function publishChoosing(
|
|
211
249
|
directory: string,
|
|
212
250
|
name: string,
|
|
@@ -217,21 +255,33 @@ function publishChoosing(
|
|
|
217
255
|
for (;;) {
|
|
218
256
|
try {
|
|
219
257
|
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
220
|
-
const
|
|
258
|
+
const registrar = assertOwnedDirectory(directory)
|
|
221
259
|
chmodSync(directory, 0o700)
|
|
222
260
|
// Recover before registration so a delayed stale cleanup can only name an
|
|
223
261
|
// old owner's unique path, never this contender's future one.
|
|
224
|
-
liveEntries(directory,
|
|
262
|
+
liveEntries(directory, registrar, observe)
|
|
263
|
+
observe?.({ phase: 'registering', entry: name })
|
|
225
264
|
const fileIdentity = publishEmpty(file)
|
|
226
|
-
|
|
265
|
+
// Pin the rendezvous that holds the entry, not the one scanned before it
|
|
266
|
+
// existed. `open` resolves the path atomically, so the entry lands in
|
|
267
|
+
// whichever directory the path named at that instant — and from then on
|
|
268
|
+
// that directory is non-empty, so no cooperating releaser can remove it.
|
|
269
|
+
// Reading the identity here therefore names the contender's real
|
|
270
|
+
// rendezvous even when the scanned one was removed and recreated
|
|
271
|
+
// underneath it.
|
|
272
|
+
const directoryIdentity = assertOwnedDirectory(directory)
|
|
273
|
+
if (!sameEntryIdentity(file, fileIdentity)) {
|
|
274
|
+
throw new RendezvousReplaced(`${directory} no longer holds ${name}.`)
|
|
275
|
+
}
|
|
227
276
|
return { file, fileIdentity, directoryIdentity }
|
|
228
277
|
} catch (err) {
|
|
229
|
-
if ((err
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// releaser can remove the non-empty directory underneath us.
|
|
278
|
+
if (!lostTheRendezvous(err)) throw err
|
|
279
|
+
// Recreate it and register again; the deadline, not the number of losses,
|
|
280
|
+
// decides when contention becomes a failure.
|
|
233
281
|
if (Date.now() >= deadline) {
|
|
234
|
-
throw new Error(
|
|
282
|
+
throw new Error(
|
|
283
|
+
`timed out publishing file lock contender in ${directory}: ${(err as Error).message}`,
|
|
284
|
+
)
|
|
235
285
|
}
|
|
236
286
|
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
237
287
|
}
|
package/src/harnesses.test.ts
CHANGED
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { HARNESS_CAPABILITIES, HARNESSES } from './harnesses.js'
|
|
2
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* This file used to hold a verbatim copy of the table below it, which meant
|
|
6
|
+
* changing a harness contract required changing the same words twice and the
|
|
7
|
+
* test could never disagree with the code — only with whoever forgot the second
|
|
8
|
+
* edit. What is worth asserting is the shape the rest of the CLI assumes.
|
|
9
|
+
*/
|
|
10
|
+
describe('harness contract', () => {
|
|
11
|
+
it('describes every shipped harness, and only those', () => {
|
|
6
12
|
expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
|
|
7
|
-
expect(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
13
|
+
expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...HARNESSES].sort())
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('gives a harness that cannot continue a turn no route to pretend with', () => {
|
|
17
|
+
// `ask` fails closed on these, and the reason it can is that the two facts
|
|
18
|
+
// agree. A harness claiming a delivery route while declaring it cannot
|
|
19
|
+
// continue would accept an answer into a void.
|
|
20
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
21
|
+
if (capability.stopContinuation !== 'unsupported') continue
|
|
22
|
+
expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('gives a harness that can continue a turn at least one real route', () => {
|
|
27
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
28
|
+
if (capability.stopContinuation === 'unsupported') continue
|
|
29
|
+
expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
|
|
30
|
+
expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
|
|
31
|
+
// The journal is the floor under every supported route: without it an
|
|
32
|
+
// answer that misses its turn has nowhere to wait.
|
|
33
|
+
expect(capability.deliveryRoutes, harness).toContain('hold-for-next-turn')
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('explains each harness in prose an operator can act on', () => {
|
|
38
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
39
|
+
expect(capability.deliveryContract.length, harness).toBeGreaterThan(40)
|
|
40
|
+
}
|
|
33
41
|
})
|
|
34
42
|
})
|
package/src/harnesses.ts
CHANGED
|
@@ -18,10 +18,7 @@ export type DeliveryRoute =
|
|
|
18
18
|
| 'hold-for-next-turn'
|
|
19
19
|
| 'unsupported'
|
|
20
20
|
|
|
21
|
-
export type StopContinuation =
|
|
22
|
-
| 'decision-block'
|
|
23
|
-
| 'followup-message'
|
|
24
|
-
| 'unsupported'
|
|
21
|
+
export type StopContinuation = 'decision-block' | 'unsupported'
|
|
25
22
|
|
|
26
23
|
export interface HarnessCapability {
|
|
27
24
|
/** How an answer is admitted to another turn in the already-open session. */
|