@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -4
  3. package/dist/commands.d.ts +27 -10
  4. package/dist/commands.js +429 -152
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +20 -11
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +12 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +84 -12
  17. package/dist/hooks.js +433 -125
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/invocation-context.d.ts +42 -0
  25. package/dist/invocation-context.js +130 -0
  26. package/dist/invocation-context.js.map +1 -0
  27. package/dist/logging.d.ts +8 -0
  28. package/dist/logging.js +8 -0
  29. package/dist/logging.js.map +1 -1
  30. package/dist/main.js +84 -49
  31. package/dist/main.js.map +1 -1
  32. package/dist/opencode-plugin.d.ts +8 -0
  33. package/dist/opencode-plugin.js +24 -162
  34. package/dist/opencode-plugin.js.map +1 -1
  35. package/dist/send.d.ts +38 -7
  36. package/dist/send.js +97 -41
  37. package/dist/send.js.map +1 -1
  38. package/dist/ui/help.d.ts +0 -1
  39. package/dist/ui/help.js +6 -3
  40. package/dist/ui/help.js.map +1 -1
  41. package/dist/ui/theme.d.ts +0 -15
  42. package/dist/ui/theme.js +2 -18
  43. package/dist/ui/theme.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/commands.test.ts +501 -63
  46. package/src/commands.ts +547 -187
  47. package/src/config-schema.test.ts +1 -1
  48. package/src/config-schema.ts +22 -11
  49. package/src/config.test.ts +178 -18
  50. package/src/config.ts +14 -1
  51. package/src/file-lock.test.ts +41 -0
  52. package/src/file-lock.ts +59 -9
  53. package/src/harnesses.test.ts +37 -29
  54. package/src/harnesses.ts +1 -4
  55. package/src/hooks.test.ts +500 -58
  56. package/src/hooks.ts +523 -169
  57. package/src/install-hooks.test.ts +232 -12
  58. package/src/install-hooks.ts +241 -37
  59. package/src/interactive.ts +1 -1
  60. package/src/invocation-context.test.ts +253 -0
  61. package/src/invocation-context.ts +176 -0
  62. package/src/logging-process-worker.test.ts +8 -2
  63. package/src/logging.test.ts +33 -18
  64. package/src/logging.ts +11 -0
  65. package/src/main.ts +119 -54
  66. package/src/opencode-plugin.ts +24 -164
  67. package/src/question-caps.test.ts +49 -0
  68. package/src/send.ts +144 -50
  69. package/src/skill.test.ts +197 -120
  70. package/src/ui/help.ts +8 -3
  71. 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('each notification uses the sound for its kind')
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
 
@@ -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 the presence settings say. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
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: 'used for questions that want an answer',
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: 'Override the sound every notification uses',
125
+ summary: 'Set the sound notifications from here use',
115
126
  detail:
116
- 'Normally the sound comes from what the notification is: progress updates are silent, completions get the done chime, questions get the attention tone. Setting this pins one sound for everything sent from here, overriding those profiles.\n\nA common use is `none`, to make a noisy project completely silent.',
117
- unsetMeans: 'each notification uses the sound for its kind',
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: 'may break through Focus modes',
138
+ time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
128
139
  },
129
- summary: 'Override how insistently notifications arrive',
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. Like the sound, this is normally decided by what the notification is; setting it pins one level for everything.\n\n`time_sensitive` can pierce a Focus mode, so it is worth reserving for the cases that genuinely earn it.',
132
- unsetMeans: 'each notification uses the level for its kind',
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. Normally set once in `.notifai/config.toml` and committed, so everyone working on the repository reports the same name.',
182
- unsetMeans: 'notifications arrive without a project',
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: {
@@ -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
- platform: {
176
- ios: { sound: null, interruption_level: 'passive' },
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: null, level: 'passive' },
184
- { flags: { kind: 'done' }, sound: 'done', level: 'passive' },
185
- { flags: { reply: true }, sound: 'attention', level: 'active' },
186
- ])('derives the $sound / $level kind profile from $flags', ({ flags, sound, level }) => {
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).toEqual({
191
- ios: { sound, interruption_level: level },
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('orders explicit flags over user config over the kind profile', () => {
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 blocking reply answerable for one hour by default', () => {
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(3_600)
256
+ expect(build.draft.reply?.expires_in_seconds).toBe(86_400)
228
257
  })
229
258
 
230
- it('stamps the project from flag or project config', () => {
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(config, { title: 'T', body: 'B' })
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(config, { title: 'T', body: 'B', project: 'other-app' })
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: '![first](media:1) then ![second](media:2) and ![existing](media:med_keep)',
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
+ '![first](media:med_first) then ![second](media:med_second) and ![existing](media:med_keep)',
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: '![second](media:2)',
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('![second](media:med_second)')
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: '![missing](media:3)',
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['NOTIFAI_SESSION']
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'),
@@ -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 Error(`${directory} changed while the file lock was active.`)
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 directoryIdentity = assertOwnedDirectory(directory)
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, directoryIdentity, observe)
262
+ liveEntries(directory, registrar, observe)
263
+ observe?.({ phase: 'registering', entry: name })
225
264
  const fileIdentity = publishEmpty(file)
226
- assertSameDirectory(directory, directoryIdentity)
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 as NodeJS.ErrnoException).code !== 'ENOENT') throw err
230
- // The previous holder removed an empty rendezvous directory between our
231
- // mkdir and publication. Recreate it; once our entry exists, no cooperating
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(`timed out publishing file lock contender in ${directory}`)
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
  }
@@ -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
- describe('harness continuation contract', () => {
5
- it('requires one explicit native delivery contract for every shipped harness', () => {
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(HARNESS_CAPABILITIES).toEqual({
8
- 'claude-code': {
9
- stopContinuation: 'decision-block',
10
- deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
11
- deliveryContract:
12
- 'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
13
- },
14
- codex: {
15
- stopContinuation: 'decision-block',
16
- deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
17
- deliveryContract:
18
- 'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
19
- },
20
- cursor: {
21
- stopContinuation: 'unsupported',
22
- deliveryRoutes: ['unsupported'],
23
- deliveryContract:
24
- 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
25
- },
26
- opencode: {
27
- stopContinuation: 'unsupported',
28
- deliveryRoutes: ['unsupported'],
29
- deliveryContract:
30
- 'no proven answer continuation after session.idle; use a blocking reply command',
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. */