@raidiant/notifai 9.3.0 → 10.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 (101) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/client.d.ts +3 -1
  3. package/dist/client.js +2 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/commands-agent-sessions.d.ts +6 -0
  6. package/dist/commands-agent-sessions.js +51 -0
  7. package/dist/commands-agent-sessions.js.map +1 -0
  8. package/dist/commands-config.js +11 -4
  9. package/dist/commands-config.js.map +1 -1
  10. package/dist/commands-core.d.ts +3 -0
  11. package/dist/commands-core.js.map +1 -1
  12. package/dist/commands-doctor.js +42 -4
  13. package/dist/commands-doctor.js.map +1 -1
  14. package/dist/commands-harness-context.d.ts +15 -2
  15. package/dist/commands-harness-context.js +32 -1
  16. package/dist/commands-harness-context.js.map +1 -1
  17. package/dist/commands-hooks.d.ts +4 -3
  18. package/dist/commands-hooks.js +17 -11
  19. package/dist/commands-hooks.js.map +1 -1
  20. package/dist/commands-send-support.js +18 -1
  21. package/dist/commands-send-support.js.map +1 -1
  22. package/dist/commands-send.js +2 -2
  23. package/dist/commands-send.js.map +1 -1
  24. package/dist/commands-sounds.d.ts +12 -0
  25. package/dist/commands-sounds.js +35 -0
  26. package/dist/commands-sounds.js.map +1 -0
  27. package/dist/commands.d.ts +2 -0
  28. package/dist/commands.js +2 -0
  29. package/dist/commands.js.map +1 -1
  30. package/dist/config-schema.js +5 -2
  31. package/dist/config-schema.js.map +1 -1
  32. package/dist/config.d.ts +3 -4
  33. package/dist/config.js +5 -2
  34. package/dist/config.js.map +1 -1
  35. package/dist/credentials.js +4 -1
  36. package/dist/credentials.js.map +1 -1
  37. package/dist/harnesses.d.ts +49 -7
  38. package/dist/harnesses.js +90 -24
  39. package/dist/harnesses.js.map +1 -1
  40. package/dist/hooks.d.ts +2 -2
  41. package/dist/hooks.js +2 -2
  42. package/dist/hooks.js.map +1 -1
  43. package/dist/install-hooks.d.ts +10 -11
  44. package/dist/install-hooks.js +3 -4
  45. package/dist/install-hooks.js.map +1 -1
  46. package/dist/invocation-context.d.ts +2 -2
  47. package/dist/orca-session-title.d.ts +10 -0
  48. package/dist/orca-session-title.js +98 -0
  49. package/dist/orca-session-title.js.map +1 -0
  50. package/dist/program.d.ts +3 -1
  51. package/dist/program.js +28 -5
  52. package/dist/program.js.map +1 -1
  53. package/dist/send.js +11 -14
  54. package/dist/send.js.map +1 -1
  55. package/dist/session-labels.d.ts +18 -2
  56. package/dist/session-labels.js +79 -8
  57. package/dist/session-labels.js.map +1 -1
  58. package/dist/sound-ref.d.ts +23 -0
  59. package/dist/sound-ref.js +28 -0
  60. package/dist/sound-ref.js.map +1 -0
  61. package/package.json +3 -3
  62. package/src/client.test.ts +12 -0
  63. package/src/client.ts +8 -0
  64. package/src/commands-agent-sessions.ts +72 -0
  65. package/src/commands-config.ts +10 -4
  66. package/src/commands-core.ts +3 -0
  67. package/src/commands-doctor.ts +48 -4
  68. package/src/commands-harness-context.ts +48 -2
  69. package/src/commands-hooks.ts +36 -18
  70. package/src/commands-send-support.ts +23 -1
  71. package/src/commands-send.ts +2 -2
  72. package/src/commands-sounds.test.ts +106 -0
  73. package/src/commands-sounds.ts +49 -0
  74. package/src/commands.test.ts +344 -11
  75. package/src/commands.ts +5 -0
  76. package/src/config-schema.test.ts +1 -0
  77. package/src/config-schema.ts +5 -2
  78. package/src/config.test.ts +42 -20
  79. package/src/config.ts +6 -5
  80. package/src/credentials.test.ts +20 -0
  81. package/src/credentials.ts +4 -1
  82. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  83. package/src/harness-integration.test.ts +212 -0
  84. package/src/harnesses.test.ts +107 -8
  85. package/src/harnesses.ts +111 -30
  86. package/src/hooks.test.ts +4 -3
  87. package/src/hooks.ts +4 -4
  88. package/src/install-hooks.test.ts +5 -4
  89. package/src/install-hooks.ts +19 -21
  90. package/src/invocation-context.ts +2 -2
  91. package/src/orca-session-title.test.ts +54 -0
  92. package/src/orca-session-title.ts +103 -0
  93. package/src/program.test.ts +27 -0
  94. package/src/program.ts +38 -4
  95. package/src/send.ts +10 -19
  96. package/src/session-labels.test.ts +58 -9
  97. package/src/session-labels.ts +95 -12
  98. package/src/skill.test.ts +14 -3
  99. package/src/sound-ref.test.ts +31 -0
  100. package/src/sound-ref.ts +34 -0
  101. package/src/ui/config-view.test.ts +1 -0
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { existsSync, readFileSync } from 'node:fs'
2
+ import { copyFileSync, existsSync, readFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { SESSION_LABEL_MAX_LENGTH } from '@raidiant/notifai-protocol'
5
5
  import { atomicWriteFileSync } from './atomic-file.js'
6
6
  import { stateDir } from './config.js'
7
7
  import { withFileLock } from './file-lock.js'
8
8
  import { generatedSessionLabel } from './generated-session-label.js'
9
- import { HARNESS_LABELS, type Harness } from './harnesses.js'
9
+ import { HARNESS_LABELS, type SourceContextHarness } from './harnesses.js'
10
10
 
11
11
  const STORE_VERSION = 1
12
12
  const MONTHS = [
@@ -35,7 +35,7 @@ interface StoredSessionLabel {
35
35
  label: string
36
36
  source: SessionLabelSource
37
37
  first_seen_at: number
38
- harness?: Harness
38
+ harness?: SourceContextHarness
39
39
  }
40
40
 
41
41
  interface SessionLabelStore {
@@ -46,13 +46,21 @@ interface SessionLabelStore {
46
46
  export interface SessionLabelInput {
47
47
  env: NodeJS.ProcessEnv
48
48
  sessionId: string
49
- harness?: Harness
49
+ harness?: SourceContextHarness
50
50
  explicitLabel?: string
51
51
  harnessLabel?: string
52
52
  harnessLabelPending?: boolean
53
53
  now?: number
54
54
  }
55
55
 
56
+ export interface SessionLabelRenameInput {
57
+ env: NodeJS.ProcessEnv
58
+ sessionId: string
59
+ label: string
60
+ harness?: SourceContextHarness
61
+ now?: number
62
+ }
63
+
56
64
  export type SessionLabelResolution =
57
65
  | { ok: true; label: string; source: SessionLabelSource }
58
66
  | { ok: false; error: string }
@@ -70,6 +78,24 @@ function emptyStore(): SessionLabelStore {
70
78
  return { version: STORE_VERSION, sessions: {} }
71
79
  }
72
80
 
81
+ function quarantineInvalidStore(file: string, raw: string): void {
82
+ const digest = createHash('sha256').update(raw).digest('hex')
83
+ const backup = path.join(path.dirname(file), `session-labels.invalid-${digest}.json`)
84
+ if (existsSync(backup)) {
85
+ if (readFileSync(backup, 'utf8') !== raw) {
86
+ throw new Error('the session-name store recovery backup does not match its content hash')
87
+ }
88
+ return
89
+ }
90
+ copyFileSync(file, backup)
91
+ }
92
+
93
+ function recoverInvalidStore(file: string, raw: string, store: SessionLabelStore): SessionLabelStore {
94
+ quarantineInvalidStore(file, raw)
95
+ writeStore(file, store)
96
+ return store
97
+ }
98
+
73
99
  function storedRecord(candidate: unknown): StoredSessionLabel | null {
74
100
  if (typeof candidate !== 'object' || candidate === null) return null
75
101
  const value = candidate as Partial<StoredSessionLabel>
@@ -95,33 +121,42 @@ function storedRecord(candidate: unknown): StoredSessionLabel | null {
95
121
 
96
122
  function readStore(file: string): SessionLabelStore {
97
123
  if (!existsSync(file)) return emptyStore()
98
- let parsed: unknown
124
+ let rawText: string
99
125
  try {
100
- parsed = JSON.parse(readFileSync(file, 'utf8'))
126
+ rawText = readFileSync(file, 'utf8')
101
127
  } catch (err) {
102
128
  throw new Error(
103
129
  `the session-name store is unreadable: ${err instanceof Error ? err.message : String(err)}`,
104
130
  )
105
131
  }
132
+ let parsed: unknown
133
+ try {
134
+ parsed = JSON.parse(rawText)
135
+ } catch {
136
+ return recoverInvalidStore(file, rawText, emptyStore())
137
+ }
106
138
  if (typeof parsed !== 'object' || parsed === null) {
107
- throw new Error('the session-name store is not a JSON object')
139
+ return recoverInvalidStore(file, rawText, emptyStore())
108
140
  }
109
141
  const raw = parsed as { version?: unknown; sessions?: unknown }
110
142
  if (raw.version !== STORE_VERSION) {
111
- throw new Error(`unsupported session-name store version ${String(raw.version)}`)
143
+ return recoverInvalidStore(file, rawText, emptyStore())
112
144
  }
113
145
  if (typeof raw.sessions !== 'object' || raw.sessions === null) {
114
- throw new Error('the session-name store has no sessions object')
146
+ return recoverInvalidStore(file, rawText, emptyStore())
115
147
  }
116
148
  const sessions: Record<string, StoredSessionLabel> = {}
149
+ let invalidRecordFound = false
117
150
  for (const [key, candidate] of Object.entries(raw.sessions)) {
118
151
  const record = storedRecord(candidate)
119
152
  if (!/^[a-f0-9]{64}$/.test(key) || record === null) {
120
- throw new Error('the session-name store contains an invalid record')
153
+ invalidRecordFound = true
154
+ continue
121
155
  }
122
156
  sessions[key] = record
123
157
  }
124
- return { version: STORE_VERSION, sessions }
158
+ const store = { version: STORE_VERSION, sessions } satisfies SessionLabelStore
159
+ return invalidRecordFound ? recoverInvalidStore(file, rawText, store) : store
125
160
  }
126
161
 
127
162
  function writeStore(file: string, store: SessionLabelStore): void {
@@ -203,6 +238,20 @@ function explicitCandidate(
203
238
  return { label: normalized, source: 'explicit' }
204
239
  }
205
240
 
241
+ export function normalizeExplicitSessionLabel(
242
+ input: Pick<SessionLabelRenameInput, 'env' | 'sessionId' | 'label'>,
243
+ ): { ok: true; label: string } | { ok: false; error: string } {
244
+ const candidate = explicitCandidate(input.label, {
245
+ env: input.env,
246
+ sessionId: input.sessionId,
247
+ })
248
+ if (candidate === null) {
249
+ return { ok: false, error: '--session-label must not be empty.' }
250
+ }
251
+ if ('error' in candidate) return { ok: false, error: candidate.error }
252
+ return { ok: true, label: candidate.label }
253
+ }
254
+
206
255
  function harnessLabelIsPending(
207
256
  value: string | undefined,
208
257
  input: SessionLabelInput,
@@ -260,7 +309,7 @@ function collisionKey(value: string): string {
260
309
  function uniqueLabel(
261
310
  base: string,
262
311
  source: SessionLabelSource,
263
- harness: Harness | undefined,
312
+ harness: SourceContextHarness | undefined,
264
313
  now: number,
265
314
  used: ReadonlySet<string>,
266
315
  ): string {
@@ -389,3 +438,37 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
389
438
  }
390
439
  }
391
440
  }
441
+
442
+ /** Replace the local frozen label only after the Account-authoritative rename succeeds. */
443
+ export function renameStoredSessionLabel(
444
+ input: SessionLabelRenameInput,
445
+ ): SessionLabelResolution {
446
+ const normalized = normalizeExplicitSessionLabel(input)
447
+ if (!normalized.ok) return normalized
448
+ const file = storePath(input.env)
449
+ const key = sessionKey(input.sessionId)
450
+ const now = input.now ?? Date.now()
451
+ try {
452
+ return withFileLock(`${file}.lock`, () => {
453
+ const store = readStore(file)
454
+ const existing = store.sessions[key]
455
+ store.sessions[key] = {
456
+ label: normalized.label,
457
+ source: 'explicit',
458
+ first_seen_at: existing?.first_seen_at ?? now,
459
+ ...(input.harness === undefined
460
+ ? existing?.harness === undefined
461
+ ? {}
462
+ : { harness: existing.harness }
463
+ : { harness: input.harness }),
464
+ }
465
+ writeStore(file, store)
466
+ return { ok: true, label: normalized.label, source: 'explicit' }
467
+ })
468
+ } catch (err) {
469
+ return {
470
+ ok: false,
471
+ error: `The Account label changed, but this machine could not save it locally: ${err instanceof Error ? err.message : String(err)}`,
472
+ }
473
+ }
474
+ }
package/src/skill.test.ts CHANGED
@@ -225,7 +225,7 @@ describe('Notifai agent skill', () => {
225
225
  expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
226
226
  })
227
227
 
228
- it('lets callers repeat one immutable semantic session name without supplying its id', () => {
228
+ it('keeps initial labels stable and teaches the narrow exact-session rename exception', () => {
229
229
  const send = section('## Send')
230
230
  expect(send).toContain('--session-label')
231
231
  expect(send).toMatch(/2-6 words/i)
@@ -239,6 +239,11 @@ describe('Notifai agent skill', () => {
239
239
  expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
240
240
  expect(send).toMatch(/never pass\s+`--session-id`/i)
241
241
  expect(send).toMatch(/identifier, hash, or\s+filesystem path/i)
242
+ expect(send).toContain('notifai session rename')
243
+ expect(send).toMatch(/job changed completely[\s\S]{0,100}current\s+name would now mislead/i)
244
+ expect(send).toMatch(/never rename for milestones, ordinary progress/i)
245
+ expect(send).toMatch(/accepts no Agent Session id/i)
246
+ expect(send).toMatch(/active harness proves the exact current Agent Session/i)
242
247
  })
243
248
 
244
249
  it('never teaches by enumerating environments the CLI can detect itself', () => {
@@ -247,7 +252,7 @@ describe('Notifai agent skill', () => {
247
252
  // Harness names may appear only in setup/routing material, where the
248
253
  // harness IS the subject.
249
254
  for (const heading of ['## Decide whether to notify', '## Send', '## Ask a question', '## When the answer arrives']) {
250
- expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex/)
255
+ expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex|Hermes/)
251
256
  }
252
257
  })
253
258
 
@@ -383,6 +388,7 @@ describe('Notifai agent skill', () => {
383
388
  'notifai guidance',
384
389
  'notifai logs',
385
390
  'notifai status',
391
+ 'notifai sounds',
386
392
  ]) {
387
393
  expect(skill, `${command} is unreachable from the skill`).toContain(command)
388
394
  }
@@ -391,9 +397,14 @@ describe('Notifai agent skill', () => {
391
397
  it('moves per-harness mechanics into progressive disclosure', () => {
392
398
  expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
393
399
  expect(skill).toMatch(/read it when you are\s+installing hooks or diagnosing routing/i)
394
- for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
400
+ for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode', 'Hermes']) {
395
401
  expect(harnessReference).toContain(`**${harness}:**`)
396
402
  }
403
+ expect(harnessReference).toMatch(/exact `HERMES_SESSION_ID`/i)
404
+ expect(harnessReference).toMatch(/Do not run `notifai hooks install --harness hermes`/i)
405
+ expect(harnessReference).toMatch(/blocking `notifai send --reply`/i)
406
+ expect(harnessReference).toMatch(/npx skills/i)
407
+ expect(harnessReference).toMatch(/no Hermes plugin may vendor/i)
397
408
  // The agent runs what a process can run; the human only does the human part.
398
409
  expect(harnessReference).toMatch(/let init install and assess\s+the hooks/i)
399
410
  expect(harnessReference).toContain('--setup-scope <project|global>')
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { SOUND_NAME_MAX_LENGTH, SOUND_REF_MAX_LENGTH } from '@raidiant/notifai-protocol'
3
+ import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
4
+
5
+ describe('isCliSoundRef', () => {
6
+ it('accepts shipped names and the silent spelling', () => {
7
+ for (const ref of ['default', 'done', 'attention', 'alert', 'none']) {
8
+ expect(isCliSoundRef(ref), ref).toBe(true)
9
+ }
10
+ })
11
+
12
+ it('accepts an Account custom sound id and a display name', () => {
13
+ expect(isCliSoundRef('snd_01HQTEST')).toBe(true)
14
+ expect(isCliSoundRef('Kitchen timer')).toBe(true)
15
+ expect(isCliSoundRef('airhorn')).toBe(true)
16
+ })
17
+
18
+ it('rejects empty, blank, and over-long values', () => {
19
+ expect(isCliSoundRef('')).toBe(false)
20
+ expect(isCliSoundRef(' ')).toBe(false)
21
+ expect(isCliSoundRef('n'.repeat(SOUND_NAME_MAX_LENGTH + 1))).toBe(false)
22
+ expect(isCliSoundRef(`snd_${'a'.repeat(SOUND_REF_MAX_LENGTH)}`)).toBe(false)
23
+ })
24
+ })
25
+
26
+ describe('unknownSoundMessage', () => {
27
+ it('points at the list command rather than only the shipped set', () => {
28
+ expect(unknownSoundMessage('nope')).toContain('notifai sounds')
29
+ expect(unknownSoundMessage('nope')).toContain('custom sound')
30
+ })
31
+ })
@@ -0,0 +1,34 @@
1
+ import {
2
+ CLI_SOUNDS,
3
+ SOUND_NAME_MAX_LENGTH,
4
+ SOUND_REF_MAX_LENGTH,
5
+ isCustomSoundId,
6
+ } from '@raidiant/notifai-protocol'
7
+
8
+ /**
9
+ * What `--sound` and the saved `sound` key accept: shipped semantic names,
10
+ * the CLI silent spelling, an Account custom sound id, or a custom display name.
11
+ */
12
+ export function isCliSoundRef(value: string): boolean {
13
+ if ((CLI_SOUNDS as readonly string[]).includes(value)) return true
14
+ if (value.length < 1 || value.length > SOUND_REF_MAX_LENGTH) return false
15
+ if (isCustomSoundId(value)) return true
16
+ const trimmed = value.trim()
17
+ return trimmed.length >= 1 && trimmed.length <= SOUND_NAME_MAX_LENGTH
18
+ }
19
+
20
+ export function unknownSoundMessage(sound: string): string {
21
+ return (
22
+ `Unknown sound "${sound}" — use a shipped name (${CLI_SOUNDS.join(', ')}), ` +
23
+ 'a custom sound id, or a custom sound name. Run `notifai sounds` to list what this Account can play.'
24
+ )
25
+ }
26
+
27
+ /** Shipped names plus the CLI silent spelling, with User-facing labels. */
28
+ export const SHIPPED_SOUND_LISTING = [
29
+ { ref: 'default', name: 'Device default' },
30
+ { ref: 'done', name: 'completion chime' },
31
+ { ref: 'attention', name: 'attention tone' },
32
+ { ref: 'alert', name: 'most insistent tone' },
33
+ { ref: 'none', name: 'silent' },
34
+ ] as const
@@ -85,5 +85,6 @@ describe('renderConfigExplain', () => {
85
85
  expect(text, choice).toContain(choice)
86
86
  }
87
87
  expect(text).toContain('the completion chime')
88
+ expect(text).toContain('Device default')
88
89
  })
89
90
  })