@raidiant/notifai 4.0.0 → 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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +5 -0
  3. package/dist/commands.d.ts +10 -1
  4. package/dist/commands.js +174 -58
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +14 -5
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +11 -0
  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 +72 -5
  17. package/dist/hooks.js +407 -79
  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/logging.d.ts +8 -0
  25. package/dist/logging.js +8 -0
  26. package/dist/logging.js.map +1 -1
  27. package/dist/main.js +44 -17
  28. package/dist/main.js.map +1 -1
  29. package/dist/opencode-plugin.d.ts +8 -0
  30. package/dist/opencode-plugin.js +24 -162
  31. package/dist/opencode-plugin.js.map +1 -1
  32. package/dist/send.d.ts +6 -0
  33. package/dist/send.js +25 -14
  34. package/dist/send.js.map +1 -1
  35. package/dist/ui/help.d.ts +0 -1
  36. package/dist/ui/help.js +6 -3
  37. package/dist/ui/help.js.map +1 -1
  38. package/dist/ui/theme.d.ts +0 -15
  39. package/dist/ui/theme.js +2 -18
  40. package/dist/ui/theme.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/commands.test.ts +185 -51
  43. package/src/commands.ts +198 -56
  44. package/src/config-schema.test.ts +1 -1
  45. package/src/config-schema.ts +16 -5
  46. package/src/config.test.ts +41 -9
  47. package/src/config.ts +13 -0
  48. package/src/file-lock.test.ts +41 -0
  49. package/src/file-lock.ts +59 -9
  50. package/src/harnesses.test.ts +37 -29
  51. package/src/harnesses.ts +1 -4
  52. package/src/hooks.test.ts +358 -21
  53. package/src/hooks.ts +457 -97
  54. package/src/install-hooks.test.ts +232 -12
  55. package/src/install-hooks.ts +241 -37
  56. package/src/interactive.ts +1 -1
  57. package/src/logging-process-worker.test.ts +8 -2
  58. package/src/logging.test.ts +33 -18
  59. package/src/logging.ts +11 -0
  60. package/src/main.ts +57 -23
  61. package/src/opencode-plugin.ts +24 -164
  62. package/src/question-caps.test.ts +49 -0
  63. package/src/send.ts +32 -16
  64. package/src/skill.test.ts +199 -146
  65. package/src/ui/help.ts +8 -3
  66. package/src/ui/theme.ts +2 -16
@@ -191,20 +191,32 @@ describe('draft building', () => {
191
191
  presentation: { title: 'T', body: 'B' },
192
192
  targets: { mode: 'all' },
193
193
  delivery: { ttl_seconds: 86400, collapse_key: null },
194
+ // An unlabelled draft is ordinary news, and news has a sound.
195
+ platform: { ios: { sound: 'default' }, macos: { sound: 'default' } },
194
196
  })
195
197
  })
196
198
 
197
199
  it.each([
198
- {},
199
- { kind: 'done' },
200
- { kind: 'failed' },
201
- { kind: 'blocked' },
202
- { reply: true },
203
- ])('never derives sound or interruption level from kind: %j', (flags) => {
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 }) => {
204
208
  const config = loadConfig({ cwd: base.cwd, env: base.env })
205
209
  const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
206
210
  if (!build.ok) throw new Error(build.error)
207
- expect(build.draft.platform).toBeUndefined()
211
+ expect(build.draft.platform?.ios?.sound).toBe(sound)
212
+ expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
213
+ })
214
+
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()
208
220
  })
209
221
 
210
222
  it('orders explicit flags over saved user config', () => {
@@ -233,12 +245,32 @@ describe('draft building', () => {
233
245
  })
234
246
  })
235
247
 
236
- 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.
237
252
  const config = loadConfig({ cwd: base.cwd, env: base.env })
238
253
  const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
239
254
  if (!build.ok) throw new Error(build.error)
240
255
 
241
- expect(build.draft.reply?.expires_in_seconds).toBe(3_600)
256
+ expect(build.draft.reply?.expires_in_seconds).toBe(86_400)
257
+ })
258
+
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)
242
274
  })
243
275
 
244
276
  it('orders explicit and configured Project identity over inference', () => {
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".
@@ -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. */