@raidiant/notifai 9.3.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/dist/client.d.ts +3 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-agent-sessions.d.ts +6 -0
  7. package/dist/commands-agent-sessions.js +51 -0
  8. package/dist/commands-agent-sessions.js.map +1 -0
  9. package/dist/commands-auth.js +4 -4
  10. package/dist/commands-auth.js.map +1 -1
  11. package/dist/commands-config.js +11 -4
  12. package/dist/commands-config.js.map +1 -1
  13. package/dist/commands-core.d.ts +5 -0
  14. package/dist/commands-core.js +2 -0
  15. package/dist/commands-core.js.map +1 -1
  16. package/dist/commands-doctor.js +121 -40
  17. package/dist/commands-doctor.js.map +1 -1
  18. package/dist/commands-harness-context.d.ts +15 -2
  19. package/dist/commands-harness-context.js +40 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hooks.d.ts +4 -3
  22. package/dist/commands-hooks.js +139 -16
  23. package/dist/commands-hooks.js.map +1 -1
  24. package/dist/commands-init.js +10 -5
  25. package/dist/commands-init.js.map +1 -1
  26. package/dist/commands-send-support.js +18 -1
  27. package/dist/commands-send-support.js.map +1 -1
  28. package/dist/commands-send.js +2 -2
  29. package/dist/commands-send.js.map +1 -1
  30. package/dist/commands-sounds.d.ts +12 -0
  31. package/dist/commands-sounds.js +35 -0
  32. package/dist/commands-sounds.js.map +1 -0
  33. package/dist/commands.d.ts +2 -0
  34. package/dist/commands.js +2 -0
  35. package/dist/commands.js.map +1 -1
  36. package/dist/config-schema.js +5 -2
  37. package/dist/config-schema.js.map +1 -1
  38. package/dist/config.d.ts +3 -4
  39. package/dist/config.js +5 -2
  40. package/dist/config.js.map +1 -1
  41. package/dist/credentials.js +4 -1
  42. package/dist/credentials.js.map +1 -1
  43. package/dist/harnesses.d.ts +49 -7
  44. package/dist/harnesses.js +110 -24
  45. package/dist/harnesses.js.map +1 -1
  46. package/dist/hooks.d.ts +2 -2
  47. package/dist/hooks.js +2 -2
  48. package/dist/hooks.js.map +1 -1
  49. package/dist/install-hooks.d.ts +10 -11
  50. package/dist/install-hooks.js +18 -13
  51. package/dist/install-hooks.js.map +1 -1
  52. package/dist/interactive.js +20 -23
  53. package/dist/interactive.js.map +1 -1
  54. package/dist/invocation-context.d.ts +2 -2
  55. package/dist/openclaw-plugin.d.ts +49 -0
  56. package/dist/openclaw-plugin.js +350 -0
  57. package/dist/openclaw-plugin.js.map +1 -0
  58. package/dist/orca-session-title.d.ts +10 -0
  59. package/dist/orca-session-title.js +98 -0
  60. package/dist/orca-session-title.js.map +1 -0
  61. package/dist/program.d.ts +3 -1
  62. package/dist/program.js +37 -13
  63. package/dist/program.js.map +1 -1
  64. package/dist/send.js +11 -14
  65. package/dist/send.js.map +1 -1
  66. package/dist/session-activation.js +1 -1
  67. package/dist/session-activation.js.map +1 -1
  68. package/dist/session-labels.d.ts +18 -2
  69. package/dist/session-labels.js +79 -8
  70. package/dist/session-labels.js.map +1 -1
  71. package/dist/sound-ref.d.ts +23 -0
  72. package/dist/sound-ref.js +28 -0
  73. package/dist/sound-ref.js.map +1 -0
  74. package/dist/ui/help.d.ts +4 -5
  75. package/dist/ui/help.js +5 -6
  76. package/dist/ui/help.js.map +1 -1
  77. package/package.json +3 -3
  78. package/src/client.test.ts +12 -0
  79. package/src/client.ts +8 -0
  80. package/src/commands-agent-sessions.ts +72 -0
  81. package/src/commands-auth.ts +4 -3
  82. package/src/commands-config.ts +10 -4
  83. package/src/commands-core.ts +6 -0
  84. package/src/commands-doctor.ts +143 -38
  85. package/src/commands-harness-context.ts +56 -2
  86. package/src/commands-hooks.ts +180 -20
  87. package/src/commands-init.ts +10 -4
  88. package/src/commands-send-support.ts +23 -1
  89. package/src/commands-send.ts +2 -2
  90. package/src/commands-sounds.test.ts +106 -0
  91. package/src/commands-sounds.ts +49 -0
  92. package/src/commands.test.ts +529 -17
  93. package/src/commands.ts +5 -0
  94. package/src/config-schema.test.ts +1 -0
  95. package/src/config-schema.ts +5 -2
  96. package/src/config.test.ts +42 -20
  97. package/src/config.ts +6 -5
  98. package/src/credentials.test.ts +20 -0
  99. package/src/credentials.ts +4 -1
  100. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +294 -0
  103. package/src/harnesses.test.ts +115 -8
  104. package/src/harnesses.ts +133 -30
  105. package/src/hooks.test.ts +4 -3
  106. package/src/hooks.ts +4 -4
  107. package/src/install-hooks.test.ts +213 -4
  108. package/src/install-hooks.ts +38 -30
  109. package/src/interactive.test.ts +31 -0
  110. package/src/interactive.ts +20 -26
  111. package/src/invocation-context.ts +2 -2
  112. package/src/openclaw-plugin.ts +409 -0
  113. package/src/orca-session-title.test.ts +54 -0
  114. package/src/orca-session-title.ts +103 -0
  115. package/src/program.test.ts +27 -0
  116. package/src/program.ts +47 -12
  117. package/src/send.ts +10 -19
  118. package/src/session-activation.ts +1 -1
  119. package/src/session-labels.test.ts +58 -9
  120. package/src/session-labels.ts +95 -12
  121. package/src/skill.test.ts +14 -3
  122. package/src/sound-ref.test.ts +31 -0
  123. package/src/sound-ref.ts +34 -0
  124. package/src/ui/config-view.test.ts +1 -0
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
package/src/program.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  acknowledgeCommand,
5
5
  askCommand,
6
6
  accessStatusCommand,
7
+ agentSessionRenameCommand,
7
8
  authStatusCommand,
8
9
  capabilitiesCommand,
9
10
  closeCommand,
@@ -30,11 +31,12 @@ import {
30
31
  repliesCommand,
31
32
  reportAskFailure,
32
33
  sendCommand,
34
+ soundsCommand,
33
35
  statusCommand,
34
36
  type CommandDeps,
35
37
  } from './commands.js'
36
38
  import { packageVersion } from './release.js'
37
- import { HARNESSES } from './install-hooks.js'
39
+ import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
38
40
  import type { Platform } from '@raidiant/notifai-protocol'
39
41
  import type { SkillScope } from './native-skills.js'
40
42
  import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
@@ -83,10 +85,12 @@ const defaultRunners = {
83
85
  authStatus: authStatusCommand,
84
86
  accessStatus: accessStatusCommand,
85
87
  devices: devicesCommand,
88
+ sounds: soundsCommand,
86
89
  capabilities: capabilitiesCommand,
87
90
  projectEnable: projectEnableCommand,
88
91
  projectDisable: projectDisableCommand,
89
92
  projectStatus: projectStatusCommand,
93
+ agentSessionRename: agentSessionRenameCommand,
90
94
  send: sendCommand,
91
95
  replies: repliesCommand,
92
96
  acknowledge: acknowledgeCommand,
@@ -176,11 +180,12 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
176
180
  /**
177
181
  * Bare `notifai`.
178
182
  *
179
- * At a human terminal this opens the interactive app; anywhere else it prints
180
- * help exactly as before. The check is the same one every other prompt in this
181
- * CLI is gated on — stdin and stdout both TTYs, not CI, `NOTIFAI_NO_INPUT`
182
- * unset — because an agent that reaches a prompt does not fail, it hangs
183
- * for ever waiting on a stdin nobody is typing into.
183
+ * At a human terminal this opens the interactive app, which enters `init`
184
+ * when the machine is not yet paired and otherwise offers daily actions.
185
+ * Anywhere else it prints help exactly as before. The check is the same one
186
+ * every other prompt in this CLI is gated on — stdin and stdout both TTYs,
187
+ * not CI, `NOTIFAI_NO_INPUT` unset — because an agent that reaches a prompt
188
+ * does not fail, it hangs for ever waiting on a stdin nobody is typing into.
184
189
  */
185
190
  /**
186
191
  * An unrecognised subcommand reaches the root as an excess argument, and
@@ -271,9 +276,24 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
271
276
  exit(runners.projectStatus(deps, opts.json === true))
272
277
  })
273
278
 
279
+ const session = program
280
+ .command('session')
281
+ .helpGroup(GROUP.agent)
282
+ .summary('Inspect or rename the current Agent Session')
283
+ .description('Operate on the exact Agent Session owned by the active harness')
284
+ session
285
+ .command('rename <label>')
286
+ .description(
287
+ 'Rename this exact Agent Session; agents use this only after its job changes completely enough that the old label would mislead',
288
+ )
289
+ .option('--json', 'machine-readable output')
290
+ .action(async (label: string, opts: { json?: boolean }) => {
291
+ exit(await runners.agentSessionRename(deps, label, opts))
292
+ })
293
+
274
294
  program
275
295
  .command('login')
276
- .helpGroup(GROUP.start)
296
+ .helpGroup(GROUP.advanced)
277
297
  .summary('Sign in and pair this machine')
278
298
  .description('Pair this machine with your Notifai account via browser approval')
279
299
  .option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
@@ -323,6 +343,18 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
323
343
  exit(await runners.devices(deps, opts))
324
344
  })
325
345
 
346
+ program
347
+ .command('sounds')
348
+ .helpGroup(GROUP.daily)
349
+ .summary('List shipped and Account custom sounds')
350
+ .description(
351
+ 'List the sounds --sound and the saved sound key accept: shipped names and this Account\'s custom sounds',
352
+ )
353
+ .option('--json', 'machine-readable output')
354
+ .action(async (opts: { json?: boolean }) => {
355
+ exit(await runners.sounds(deps, opts))
356
+ })
357
+
326
358
  program
327
359
  .command('capabilities')
328
360
  .helpGroup(GROUP.advanced)
@@ -389,7 +421,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
389
421
  )
390
422
  .option('--multi', 'with --choice, several answers may be selected')
391
423
  .optionsGroup(SEND_GROUP.advanced)
392
- .option('--sound <sound>', 'override saved sound: default | done | attention | alert | none')
424
+ .option(
425
+ '--sound <sound>',
426
+ 'override saved sound: default | done | attention | alert | none, or a custom name or id from notifai sounds',
427
+ )
393
428
  .option(
394
429
  '--level <level>',
395
430
  'Apple-only interruption level: passive | active | time_sensitive (unsupported with --platform android)',
@@ -435,7 +470,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
435
470
 
436
471
  send.addHelpText(
437
472
  'after',
438
- `\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update the standard one.\nAn explicit --sound or saved sound preference outranks that default. --level is Apple-only; Android attention is owned by kind, product channels, and device settings.\n`,
473
+ `\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update Device default.\nAn explicit --sound or saved sound preference outranks that default. --sound accepts a shipped name, an Account custom sound name or id from \`notifai sounds\`, or none. --level is Apple-only; Android attention is owned by kind, product channels, and device settings.\n`,
439
474
  )
440
475
 
441
476
  program
@@ -593,7 +628,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
593
628
  .option('--owner <name>', 'internal ownership marker')
594
629
  .option('--harness <name>', 'internal harness output adapter')
595
630
  .action(async (event: string, opts: { harness?: string }) => {
596
- const harness = HARNESSES.find((candidate) => candidate === opts.harness)
631
+ const harness = HOOK_INSTALLABLE_HARNESSES.find((candidate) => candidate === opts.harness)
597
632
  exit(await runners.hookRun(deps, event, () => readStdinWithTimeout(), harness))
598
633
  })
599
634
 
@@ -607,7 +642,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
607
642
  .description('Wire this harness to route registered questions to your devices')
608
643
  .option(
609
644
  '--harness <name>',
610
- 'claude-code | codex | cursor | opencode (default: every detected harness)',
645
+ 'claude-code | codex | cursor | opencode | openclaw (default: every detected harness)',
611
646
  )
612
647
  .option('--global', 'install for every project instead of just this one')
613
648
  .action((opts: { harness?: string; global?: boolean }) => {
@@ -616,7 +651,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
616
651
  hooks
617
652
  .command('uninstall')
618
653
  .description('Remove the hooks this CLI installed')
619
- .option('--harness <name>', 'claude-code | codex | cursor | opencode (default: detected)')
654
+ .option('--harness <name>', 'claude-code | codex | cursor | opencode | openclaw (default: detected)')
620
655
  .option('--global', 'remove the machine-wide install')
621
656
  .action((opts: { harness?: string; global?: boolean }) => {
622
657
  exit(runners.hooksUninstall(deps, opts))
package/src/send.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import {
2
- CLI_SOUNDS,
3
2
  COLLAPSE_KEY_MAX_BYTES,
4
3
  INTERRUPTION_LEVELS,
5
4
  MEDIA_MAX_ITEMS,
@@ -13,9 +12,6 @@ import {
13
12
  type MediaItemT,
14
13
  type NotificationDraftT,
15
14
  NOTIFICATION_KINDS,
16
- DEFAULT_NOTIFICATION_KIND,
17
- defaultSoundForKind,
18
- type NotificationKind,
19
15
  PLATFORMS,
20
16
  type Platform,
21
17
  type QuestionT,
@@ -23,8 +19,7 @@ import {
23
19
  type SubmissionReceipt,
24
20
  } from '@raidiant/notifai-protocol'
25
21
  import type { CliConfig } from './config.js'
26
-
27
- type CliSound = (typeof CLI_SOUNDS)[number]
22
+ import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
28
23
 
29
24
  export interface SendFlags {
30
25
  title: string
@@ -217,18 +212,15 @@ export function buildDraft(
217
212
  }
218
213
  }
219
214
 
220
- // Kind states what happened; the sound table turns that truth into the
221
- // attention it deserves, so an honest sender gets the right sound without
222
- // reasoning about audio. An explicit --sound and the user's saved preference
223
- // both outrank it, and `none` still means silent.
224
- const kindForSound: NotificationKind = flags.reply
225
- ? 'question'
226
- : ((flags.kind as NotificationKind | undefined) ?? DEFAULT_NOTIFICATION_KIND)
227
- const sound = flags.sound ?? config.sound.value ?? defaultSoundForKind(kindForSound)
228
- if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
215
+ // Explicit --sound and the saved sound key outrank kind. The server is the
216
+ // one that applies kind, Project, and Account maps — stamping a kind default
217
+ // here would look like an explicit choice and skip those layers. `none` is
218
+ // still silent.
219
+ const sound = flags.sound !== undefined ? flags.sound : config.sound.value
220
+ if (sound !== null && sound !== undefined && !isCliSoundRef(sound)) {
229
221
  return {
230
222
  ok: false,
231
- error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
223
+ error: unknownSoundMessage(sound),
232
224
  }
233
225
  }
234
226
  const level =
@@ -254,9 +246,8 @@ export function buildDraft(
254
246
  appleOptions.sound = null
255
247
  androidOptions.sound = null
256
248
  } else if (sound !== null && sound !== undefined) {
257
- const semanticSound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
258
- appleOptions.sound = semanticSound
259
- androidOptions.sound = semanticSound
249
+ appleOptions.sound = sound
250
+ androidOptions.sound = sound
260
251
  }
261
252
  if (flags.threadId !== undefined) {
262
253
  appleOptions.thread_id = flags.threadId
@@ -31,7 +31,7 @@ export function sessionActivationOutput(
31
31
  const context = hookEventName === 'SubagentStart'
32
32
  ? WORKER_ACTIVATION_CONTEXT
33
33
  : rootActivationContext(cwd, env)
34
- if (harness === 'opencode') return context
34
+ if (harness === 'opencode' || harness === 'openclaw') return context
35
35
  if (harness === 'cursor') {
36
36
  return JSON.stringify({ additional_context: context })
37
37
  }
@@ -1,9 +1,20 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
2
9
  import os from 'node:os'
3
10
  import path from 'node:path'
4
11
  import { describe, expect, it } from 'vitest'
5
12
  import { stateDir } from './config.js'
6
- import { formatSessionFirstSeen, resolveSessionLabel } from './session-labels.js'
13
+ import {
14
+ formatSessionFirstSeen,
15
+ renameStoredSessionLabel,
16
+ resolveSessionLabel,
17
+ } from './session-labels.js'
7
18
 
8
19
  function fixture(): { env: NodeJS.ProcessEnv; now: number } {
9
20
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-labels-'))
@@ -416,26 +427,64 @@ describe('semantic session labels', () => {
416
427
  })
417
428
  })
418
429
 
419
- it('fails closed instead of replacing an unreadable name store', () => {
430
+ it('isolates an unreadable name store before creating a clean one', () => {
420
431
  const { env, now } = fixture()
421
432
  const file = path.join(stateDir(env), 'session-labels.json')
422
433
  mkdirSync(path.dirname(file), { recursive: true })
423
434
  writeFileSync(file, '{not json}\n')
424
435
 
425
- const resolved = resolveSessionLabel({
436
+ expect(
437
+ resolveSessionLabel({
426
438
  env,
427
439
  now,
428
440
  sessionId: 'new-session',
429
441
  explicitLabel: 'Do not overwrite me',
430
- })
431
-
432
- expect(resolved.ok).toBe(false)
433
- expect(!resolved.ok && resolved.error).toContain('session-name store is unreadable')
434
- expect(readFileSync(file, 'utf8')).toBe('{not json}\n')
442
+ }),
443
+ ).toEqual({ ok: true, label: 'Do not overwrite me', source: 'explicit' })
444
+ expect(readFileSync(file, 'utf8')).not.toBe('{not json}\n')
445
+ const backups = readdirSync(path.dirname(file)).filter((name) =>
446
+ /^session-labels\.invalid-[a-f0-9]{64}\.json$/u.test(name),
447
+ )
448
+ expect(backups).toHaveLength(1)
449
+ expect(readFileSync(path.join(path.dirname(file), backups[0]!), 'utf8')).toBe('{not json}\n')
435
450
  })
436
451
 
437
452
  it('formats first seen using the machine local calendar', () => {
438
453
  const now = new Date(2026, 0, 2, 3, 4).getTime()
439
454
  expect(formatSessionFirstSeen(now)).toBe('Jan 2, 2026 03:04')
440
455
  })
456
+
457
+ it('explicitly replaces a semantic or fallback label after an authorized rename', () => {
458
+ const { env, now } = fixture()
459
+ resolveSessionLabel({
460
+ env,
461
+ now,
462
+ sessionId: 'rename-me',
463
+ harness: 'codex',
464
+ explicitLabel: 'Old job',
465
+ })
466
+
467
+ expect(
468
+ renameStoredSessionLabel({
469
+ env,
470
+ now: now + 1_000,
471
+ sessionId: 'rename-me',
472
+ harness: 'codex',
473
+ label: ' Completely different job ',
474
+ }),
475
+ ).toEqual({ ok: true, label: 'Completely different job', source: 'explicit' })
476
+ expect(
477
+ resolveSessionLabel({
478
+ env,
479
+ now: now + 2_000,
480
+ sessionId: 'rename-me',
481
+ harness: 'codex',
482
+ harnessLabel: 'Later task context',
483
+ }),
484
+ ).toEqual({
485
+ ok: true,
486
+ label: 'Completely different job',
487
+ source: 'explicit',
488
+ })
489
+ })
441
490
  })
@@ -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|OpenClaw|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', 'OpenClaw', '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
  })
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { rootHelpFooter } from './help.js'
3
+
4
+ function stripAnsi(text: string): string {
5
+ return text.replace(/\u001B\[[0-9;]*m/g, '')
6
+ }
7
+
8
+ describe('rootHelpFooter', () => {
9
+ it('leads first-run with init, not the interactive app', () => {
10
+ const footer = stripAnsi(rootHelpFooter())
11
+ const start = footer.slice(footer.indexOf('Start here'))
12
+ const firstCommand = start.match(/notifai(?: init)?/)?.[0]
13
+ expect(firstCommand).toBe('notifai init')
14
+ expect(start.indexOf('notifai init')).toBeLessThan(start.search(/notifai\s{2,}/))
15
+ })
16
+ })