@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,4 +1,6 @@
1
1
  import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { createHash } from 'node:crypto'
2
4
  import {
3
5
  existsSync,
4
6
  mkdirSync,
@@ -56,6 +58,7 @@ import {
56
58
  repliesCommand,
57
59
  sendCommand,
58
60
  statusCommand,
61
+ agentSessionRenameCommand,
59
62
  type CommandDeps,
60
63
  type CommandIo,
61
64
  type CommandSpinner,
@@ -763,6 +766,28 @@ describe('command contracts', () => {
763
766
  expect(io.errLines).toEqual([])
764
767
  })
765
768
 
769
+ it('sends an Account custom sound name on the draft', async () => {
770
+ const io = new CapturedIo()
771
+ let submitted: SubmitNotificationRequestT | undefined
772
+ const client = {
773
+ submit: async (body: SubmitNotificationRequestT) => {
774
+ submitted = body
775
+ return receipt
776
+ },
777
+ } as unknown as ApiClient
778
+
779
+ expect(
780
+ await sendCommand(makeDeps(io, client), {
781
+ kind: 'done',
782
+ title: 'Build finished',
783
+ body: 'All checks passed.',
784
+ sound: 'Kitchen timer',
785
+ }),
786
+ ).toBe(EXIT.ok)
787
+ expect(submitted?.draft.platform?.ios?.sound).toBe('Kitchen timer')
788
+ expect(submitted?.draft.platform?.android?.sound).toBe('Kitchen timer')
789
+ })
790
+
766
791
  it('prints Android downgrade warnings before submitting', async () => {
767
792
  const io = new CapturedIo()
768
793
  let submitCalls = 0
@@ -856,7 +881,7 @@ describe('command contracts', () => {
856
881
  expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
857
882
  })
858
883
 
859
- it('never promotes an Orca worktree title into an Agent Session label', async () => {
884
+ it('uses trusted Orca task context for an active Claude Agent Session label', async () => {
860
885
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-claude-title-'))
861
886
  const io = new CapturedIo()
862
887
  let submitted: SubmitNotificationRequestT | undefined
@@ -879,6 +904,7 @@ describe('command contracts', () => {
879
904
  ...makeDeps(io, client),
880
905
  cwd,
881
906
  env,
907
+ orcaSessionTitle: () => 'Agent Session context labels',
882
908
  }
883
909
 
884
910
  expect(
@@ -891,13 +917,12 @@ describe('command contracts', () => {
891
917
 
892
918
  expect(submitted?.draft.source).toMatchObject({
893
919
  session_id: 'orca-claude-session',
920
+ session_label: 'Agent Session context labels',
894
921
  harness: 'claude-code',
895
922
  })
896
- expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
897
- expect(submitted?.draft.source?.session_label).not.toBe('Worker - semantic session implementation')
898
923
  })
899
924
 
900
- it('never treats an Orca worktree title as a Codex Agent Session label', async () => {
925
+ it('uses trusted Orca task context for an active Codex Agent Session label', async () => {
901
926
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
902
927
  const io = new CapturedIo()
903
928
  let submitted: SubmitNotificationRequestT | undefined
@@ -918,6 +943,7 @@ describe('command contracts', () => {
918
943
  TERM_PROGRAM: 'Orca',
919
944
  ORCA_WORKTREE_ID: worktreeId,
920
945
  },
946
+ orcaSessionTitle: () => 'Release verification',
921
947
  }
922
948
 
923
949
  expect(
@@ -930,9 +956,77 @@ describe('command contracts', () => {
930
956
 
931
957
  expect(submitted?.draft.source).toMatchObject({
932
958
  session_id: 'orca-codex-thread',
959
+ session_label: 'Release verification',
933
960
  harness: 'codex',
934
961
  })
935
- expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
962
+ })
963
+
964
+ it('sends after isolating an invalid session-name record without losing valid names', async () => {
965
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-invalid-session-name-'))
966
+ const io = new CapturedIo()
967
+ const env = {
968
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
969
+ XDG_STATE_HOME: path.join(cwd, 'state'),
970
+ CODEX_THREAD_ID: 'current-session',
971
+ }
972
+ const file = path.join(stateDir(env), 'session-labels.json')
973
+ const validKey = createHash('sha256').update('unrelated-session').digest('hex')
974
+ mkdirSync(path.dirname(file), { recursive: true })
975
+ writeFileSync(
976
+ file,
977
+ `${JSON.stringify(
978
+ {
979
+ version: 1,
980
+ sessions: {
981
+ [validKey]: {
982
+ label: 'Unrelated work',
983
+ source: 'explicit',
984
+ first_seen_at: 1_777_777_777_000,
985
+ harness: 'codex',
986
+ },
987
+ obsolete: {
988
+ label: 'Old shape',
989
+ source: 'legacy',
990
+ first_seen_at: 'unknown',
991
+ },
992
+ },
993
+ },
994
+ null,
995
+ 2,
996
+ )}\n`,
997
+ )
998
+
999
+ let submitted: SubmitNotificationRequestT | undefined
1000
+ const client = {
1001
+ submit: async (body: SubmitNotificationRequestT) => {
1002
+ submitted = body
1003
+ return receipt
1004
+ },
1005
+ } as unknown as ApiClient
1006
+
1007
+ expect(
1008
+ await sendCommand(
1009
+ { ...makeDeps(io, client), cwd, env },
1010
+ { title: 'Store recovered', body: 'The valid Agent Session name survived.', kind: 'done' },
1011
+ ),
1012
+ ).toBe(EXIT.ok)
1013
+ expect(submitted?.draft.source).toMatchObject({
1014
+ session_id: 'current-session',
1015
+ harness: 'codex',
1016
+ })
1017
+
1018
+ const recovered = JSON.parse(readFileSync(file, 'utf8')) as {
1019
+ sessions: Record<string, { label: string }>
1020
+ }
1021
+ expect(recovered.sessions[validKey]?.label).toBe('Unrelated work')
1022
+ expect(recovered.sessions['obsolete']).toBeUndefined()
1023
+ const backups = readdirSync(path.dirname(file)).filter((name) =>
1024
+ /^session-labels\.invalid-[a-f0-9]{64}\.json$/u.test(name),
1025
+ )
1026
+ expect(backups).toHaveLength(1)
1027
+ expect(readFileSync(path.join(path.dirname(file), backups[0]!), 'utf8')).toContain(
1028
+ '"obsolete"',
1029
+ )
936
1030
  })
937
1031
 
938
1032
  it('falls back safely when Orca returns a private path as its title', async () => {
@@ -1041,6 +1135,113 @@ describe('command contracts', () => {
1041
1135
  expect(existsSync(path.join(cwd, 'state', 'notifai', 'session-labels.json'))).toBe(false)
1042
1136
  })
1043
1137
 
1138
+ it('sends Hermes Source Context from HERMES_SESSION_ID and the invocation cwd', async () => {
1139
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-send-'))
1140
+ const repo = path.join(root, 'notifai')
1141
+ const worktree = path.join(root, 'hermes-topic')
1142
+ mkdirSync(repo)
1143
+ const git = (...args: string[]) => execFileSync('git', ['-C', repo, ...args], { stdio: 'ignore' })
1144
+ git('init')
1145
+ git('config', 'user.email', 'test@example.invalid')
1146
+ git('config', 'user.name', 'Test')
1147
+ git('config', 'commit.gpgsign', 'false')
1148
+ writeFileSync(path.join(repo, 'file'), 'x')
1149
+ git('add', 'file')
1150
+ git('commit', '-m', 'fixture')
1151
+ git('worktree', 'add', worktree, '-b', 'feature/hermes-send')
1152
+
1153
+ const io = new CapturedIo()
1154
+ let submitted: SubmitNotificationRequestT | undefined
1155
+ const client = {
1156
+ submit: async (body: SubmitNotificationRequestT) => {
1157
+ submitted = body
1158
+ return receipt
1159
+ },
1160
+ } as unknown as ApiClient
1161
+ const sessionId = '20260828_111302_29a404'
1162
+ const deps = {
1163
+ ...makeDeps(io, client),
1164
+ cwd: worktree,
1165
+ env: {
1166
+ ...isolatedEnv(worktree),
1167
+ HERMES_SESSION_ID: sessionId,
1168
+ },
1169
+ }
1170
+
1171
+ expect(
1172
+ await sendCommand(deps, {
1173
+ title: 'Hermes baseline landed',
1174
+ body: 'Classic CLI send carries exact session identity.',
1175
+ kind: 'done',
1176
+ }),
1177
+ ).toBe(EXIT.ok)
1178
+
1179
+ expect(submitted?.draft.source).toMatchObject({
1180
+ session_id: sessionId,
1181
+ harness: 'hermes',
1182
+ branch: 'feature/hermes-send',
1183
+ worktree: 'hermes-topic',
1184
+ })
1185
+ expect(io.outLines.join('\n')).not.toContain(sessionId)
1186
+
1187
+ submitted = undefined
1188
+ expect(
1189
+ await sendCommand(
1190
+ {
1191
+ ...deps,
1192
+ env: {
1193
+ ...deps.env,
1194
+ HERMES_SESSION_KEY: 'gateway-route-key-must-not-be-session-id',
1195
+ },
1196
+ },
1197
+ {
1198
+ title: 'Hermes deferred surface sent',
1199
+ body: 'The send stays universal while unproven Source Context is omitted.',
1200
+ kind: 'done',
1201
+ },
1202
+ ),
1203
+ ).toBe(EXIT.ok)
1204
+ expect(submitted?.draft.source).toMatchObject({
1205
+ branch: 'feature/hermes-send',
1206
+ worktree: 'hermes-topic',
1207
+ })
1208
+ expect(submitted?.draft.source).not.toHaveProperty('session_id')
1209
+ expect(submitted?.draft.source).not.toHaveProperty('harness')
1210
+ expect(JSON.stringify(submitted?.draft.source)).not.toContain('gateway-route-key')
1211
+ })
1212
+
1213
+ it('does not attribute Hermes Source Context when another harness marker is nested', async () => {
1214
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-send-'))
1215
+ const io = new CapturedIo()
1216
+ let submitted: SubmitNotificationRequestT | undefined
1217
+ const client = {
1218
+ submit: async (body: SubmitNotificationRequestT) => {
1219
+ submitted = body
1220
+ return receipt
1221
+ },
1222
+ } as unknown as ApiClient
1223
+ const deps = {
1224
+ ...makeDeps(io, client),
1225
+ cwd,
1226
+ env: {
1227
+ ...isolatedEnv(cwd),
1228
+ CLAUDECODE: '1',
1229
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
1230
+ HERMES_SESSION_ID: '20260828_111302_29a404',
1231
+ },
1232
+ }
1233
+
1234
+ expect(
1235
+ await sendCommand(deps, {
1236
+ title: 'Nested send stays honest',
1237
+ body: 'Ambiguous ownership omits harness identity.',
1238
+ kind: 'update',
1239
+ }),
1240
+ ).toBe(EXIT.ok)
1241
+ expect(submitted?.draft.source?.harness).toBeUndefined()
1242
+ expect(submitted?.draft.source?.session_id).toBeUndefined()
1243
+ })
1244
+
1044
1245
  it('uploads repeatable images in order and sends only canonical media references', async () => {
1045
1246
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
1046
1247
  const first = path.join(root, 'first.png')
@@ -1198,6 +1399,8 @@ describe('command contracts', () => {
1198
1399
  // Kind now selects the sound, so the help must say so — and must not carry
1199
1400
  // the retired separation it replaced.
1200
1401
  expect(source).toContain('Kind is required, and it selects the sound')
1402
+ expect(source).toContain('Device default')
1403
+ expect(source).toContain('custom name or id')
1201
1404
  expect(source).not.toContain('it never chooses banner sound or interruption level')
1202
1405
  expect(source).not.toContain('Kind profiles apply automatically')
1203
1406
  })
@@ -3539,19 +3742,20 @@ describe('config surfaces', () => {
3539
3742
  expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
3540
3743
  })
3541
3744
 
3542
- it('refuses an enum value the sender would later reject', async () => {
3543
- // `config set sound whatever` used to be written straight to disk: the
3544
- // typo only surfaced when a notification failed to carry the sound.
3745
+ it('refuses a sound that cannot be a shipped name, custom id, or custom name', async () => {
3545
3746
  const io = new CapturedIo()
3546
- expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
3747
+ expect(await configSetCommand(configDeps(io), 'sound', 'n'.repeat(200), { yes: true })).toBe(
3547
3748
  EXIT.usage,
3548
3749
  )
3549
- expect(io.errLines[0]).toContain('default, done, attention, alert, none')
3750
+ expect(io.errLines[0]).toContain('custom sound')
3550
3751
  })
3551
3752
 
3552
- it('still accepts a legal enum value', async () => {
3753
+ it('accepts a shipped sound and an Account custom name', async () => {
3553
3754
  const io = new CapturedIo()
3554
3755
  expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
3756
+ expect(await configSetCommand(configDeps(io), 'sound', 'Kitchen timer', { yes: true })).toBe(
3757
+ EXIT.ok,
3758
+ )
3555
3759
  })
3556
3760
 
3557
3761
  it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
@@ -6519,6 +6723,92 @@ describe('asking before the hooks have ever run', () => {
6519
6723
  expect(readSessionState('opencode-current', env).pending).toBeUndefined()
6520
6724
  })
6521
6725
 
6726
+ it('refuses Hermes ask as unsupported rather than as missing hooks', () => {
6727
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-ask-unsupported-'))
6728
+ const io = new CapturedIo()
6729
+ const env = {
6730
+ ...isolatedEnv(cwd),
6731
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6732
+ }
6733
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6734
+
6735
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6736
+ const payload = JSON.parse(io.outLines.join('\n')) as { code: string; message: string; remedy: string }
6737
+ expect(payload.code).toBe('question_routing_unavailable')
6738
+ expect(payload.message).toMatch(/no proven continuation owner/i)
6739
+ expect(payload.message).not.toMatch(/hooks are not installed/i)
6740
+ expect(payload.remedy).toMatch(/send --reply/)
6741
+ expect(hooksInstallCommand(deps, { harness: 'hermes', execPath, scriptPath })).toBe(EXIT.usage)
6742
+ expect(io.errLines.join('\n')).toMatch(/Unknown harness "hermes"/)
6743
+ })
6744
+
6745
+ it('fails closed when Hermes nested markers leave session ownership ambiguous', () => {
6746
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-ask-'))
6747
+ const io = new CapturedIo()
6748
+ const env = {
6749
+ ...isolatedEnv(cwd),
6750
+ CLAUDECODE: '1',
6751
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
6752
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6753
+ }
6754
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6755
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
6756
+ writeSessionState('claude-orchestrator', env, {
6757
+ harness: 'claude-code',
6758
+ last_prompt_at: 42,
6759
+ last_stop_at: 41,
6760
+ })
6761
+ writeProjectSession(cwd, env, 'claude-orchestrator', 42, 'claude-code')
6762
+ io.outLines = []
6763
+ io.errLines = []
6764
+
6765
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6766
+ const payload = JSON.parse(io.outLines.join('\n')) as { code: string; message: string }
6767
+ expect(payload.code).toBe('session_identity_ambiguous')
6768
+ expect(payload.message).toMatch(/Several harness sessions could own this shell/i)
6769
+ expect(payload.message).toMatch(/Hermes/)
6770
+ expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
6771
+ })
6772
+
6773
+ it('does not attribute doctor readiness when Hermes nested markers are ambiguous', async () => {
6774
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-doctor-'))
6775
+ const io = new CapturedIo()
6776
+ const env = {
6777
+ ...isolatedEnv(cwd),
6778
+ CLAUDECODE: '1',
6779
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
6780
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6781
+ }
6782
+ const readiness = await assessReadiness({
6783
+ ...makeDeps(io, {} as ApiClient),
6784
+ cwd,
6785
+ env,
6786
+ now: () => 42,
6787
+ })
6788
+ const hooks = readiness.states.find((state) => state.id === 'hooks')
6789
+ expect(hooks?.status).toBe('optional-gap')
6790
+ expect(hooks?.detail).toMatch(/Several harness sessions could own this shell/i)
6791
+ expect(hooks?.detail).toMatch(/Claude Code, Hermes/)
6792
+ expect(hooks?.remedy).toBeUndefined()
6793
+ expect(JSON.stringify(hooks)).not.toContain('hooks install --harness claude-code')
6794
+ })
6795
+
6796
+ it('does not tell an active Hermes session to install managed hooks', async () => {
6797
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-doctor-'))
6798
+ const io = new CapturedIo()
6799
+ const env = {
6800
+ ...isolatedEnv(cwd),
6801
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6802
+ }
6803
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6804
+ const readiness = await assessReadiness(deps)
6805
+ const hooks = readiness.states.find((state) => state.id === 'hooks')
6806
+ expect(hooks?.status).toBe('optional-gap')
6807
+ expect(hooks?.remedy).toBeUndefined()
6808
+ expect(JSON.stringify(hooks)).not.toContain('hooks install --harness hermes')
6809
+ expect(hooks?.detail).toMatch(/no proven continuation owner/i)
6810
+ })
6811
+
6522
6812
  it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
6523
6813
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
6524
6814
  const io = new CapturedIo()
@@ -7314,3 +7604,46 @@ describe('a second device that disagrees', () => {
7314
7604
  expect(contradictingAnswer([])).toBeNull()
7315
7605
  })
7316
7606
  })
7607
+
7608
+ describe('Agent Session rename command', () => {
7609
+ it('renames only the exact active Agent Session and updates its local frozen label', async () => {
7610
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-rename-'))
7611
+ const io = new CapturedIo()
7612
+ const seen: { session_id: string; label: string }[] = []
7613
+ const client = {
7614
+ putAgentSessionLabel: async (body: { session_id: string; label: string }) => {
7615
+ seen.push(body)
7616
+ return {
7617
+ ...body,
7618
+ renamed_by: 'agent' as const,
7619
+ updated_at: '2026-08-28T10:00:00.000Z',
7620
+ }
7621
+ },
7622
+ } as unknown as ApiClient
7623
+ const deps = {
7624
+ ...makeDeps(io, client),
7625
+ cwd,
7626
+ env: {
7627
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
7628
+ XDG_STATE_HOME: path.join(cwd, 'state'),
7629
+ CODEX_THREAD_ID: '01a04789-9589-76b2-82c8-bfe63062c867',
7630
+ },
7631
+ }
7632
+
7633
+ expect(await agentSessionRenameCommand(deps, ' Hermes Runtime Support ')).toBe(EXIT.ok)
7634
+ expect(seen).toEqual([{
7635
+ session_id: '01a04789-9589-76b2-82c8-bfe63062c867',
7636
+ label: 'Hermes Runtime Support',
7637
+ }])
7638
+ expect(io.outLines.join('\n')).toContain('Hermes Runtime Support')
7639
+ })
7640
+
7641
+ it('fails closed when the harness does not expose an exact current Agent Session', async () => {
7642
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-rename-none-'))
7643
+ const io = new CapturedIo()
7644
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
7645
+
7646
+ expect(await agentSessionRenameCommand(deps, 'A new job')).toBe(EXIT.usage)
7647
+ expect(io.errLines.join('\n')).toMatch(/exact active Agent Session/i)
7648
+ })
7649
+ })
package/src/commands.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  deviceInventory,
18
18
  devicesCommand,
19
19
  } from './commands-devices.js'
20
+ export { soundsCommand } from './commands-sounds.js'
20
21
  export {
21
22
  contradictingAnswer,
22
23
  repliesCommand,
@@ -67,4 +68,8 @@ export {
67
68
  projectEnableCommand,
68
69
  projectStatusCommand,
69
70
  } from './commands-project.js'
71
+ export {
72
+ agentSessionRenameCommand,
73
+ type AgentSessionRenameFlags,
74
+ } from './commands-agent-sessions.js'
70
75
  export { realIo } from './commands-io.js'
@@ -70,6 +70,7 @@ describe('acceptedValues', () => {
70
70
 
71
71
  it('lists the choices for an enum', () => {
72
72
  expect(acceptedValues('sound')).toContain('none')
73
+ expect(acceptedValues('sound')).toContain('custom sound')
73
74
  })
74
75
 
75
76
  it('names both values for a toggle', () => {
@@ -121,7 +121,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
121
121
  kind: 'enum',
122
122
  choices: CLI_SOUNDS,
123
123
  choiceHints: {
124
- default: 'the standard notification tone',
124
+ default: 'Device default',
125
125
  done: 'the completion chime',
126
126
  attention: 'a distinct attention tone',
127
127
  alert: 'the most insistent tone',
@@ -129,7 +129,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
129
129
  },
130
130
  summary: 'Set the sound notifications from here use',
131
131
  detail:
132
- '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.',
132
+ 'Setting this pins one sound for every notification sent from here, whatever kind it is. Shipped names are `default` (Device default), `done`, `attention`, `alert`, and `none`; an Account custom sound name or id from `notifai sounds` is accepted too.\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 Device default for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
133
133
  unsetMeans: 'not set; each kind brings its own sound',
134
134
  },
135
135
  interruption_level: {
@@ -281,6 +281,9 @@ export function boundsHint(key: ConfigKey): string | null {
281
281
  /** What a `config set` caller may legally pass, phrased for an error message. */
282
282
  export function acceptedValues(key: ConfigKey): string {
283
283
  const info = INFO[key]
284
+ if (key === 'sound') {
285
+ return `${CLI_SOUNDS.join(', ')}, a custom sound id, or a custom sound name`
286
+ }
284
287
  switch (info.kind) {
285
288
  case 'boolean':
286
289
  return 'true or false'
@@ -177,14 +177,29 @@ describe('config precedence', () => {
177
177
  expect(config.wait_seconds.source).toBe('flag')
178
178
  })
179
179
 
180
- it('uses the prior default when a newer CLI wrote an unknown enum value', () => {
180
+ it('uses the prior default when a newer CLI wrote an unknown interruption level', () => {
181
181
  const { env, cwd } = setup({
182
- globalToml: 'sound = "future-sound"\ninterruption_level = "future-level"\n',
182
+ globalToml: 'interruption_level = "future-level"\n',
183
183
  })
184
184
  const config = loadConfig({ cwd, env })
185
- expect(config.sound).toEqual({ value: null, source: 'default' })
186
185
  expect(config.interruption_level).toEqual({ value: null, source: 'default' })
187
186
  })
187
+
188
+ it('keeps a custom sound name written by the user', () => {
189
+ const { env, cwd } = setup({
190
+ globalToml: 'sound = "Kitchen timer"\n',
191
+ })
192
+ const config = loadConfig({ cwd, env })
193
+ expect(config.sound).toEqual({ value: 'Kitchen timer', source: expect.stringMatching(/^global:/) })
194
+ })
195
+
196
+ it('drops a sound value that cannot be a name or id', () => {
197
+ const { env, cwd } = setup({
198
+ globalToml: `sound = "${'n'.repeat(200)}"\n`,
199
+ })
200
+ const config = loadConfig({ cwd, env })
201
+ expect(config.sound).toEqual({ value: null, source: 'default' })
202
+ })
188
203
  })
189
204
 
190
205
  describe('draft building', () => {
@@ -199,29 +214,23 @@ describe('draft building', () => {
199
214
  presentation: { title: 'T', body: 'B' },
200
215
  targets: { mode: 'all' },
201
216
  delivery: { ttl_seconds: 86400, collapse_key: null },
202
- // An unlabelled draft is ordinary news, and news has a sound.
203
- platform: {
204
- ios: { sound: 'default' },
205
- macos: { sound: 'default' },
206
- android: { sound: 'default' },
207
- },
208
217
  })
209
218
  })
210
219
 
211
220
  it.each([
212
- { flags: {}, sound: 'default' },
213
- { flags: { kind: 'update' }, sound: 'default' },
214
- { flags: { kind: 'done' }, sound: 'done' },
215
- { flags: { kind: 'failed' }, sound: 'alert' },
216
- { flags: { kind: 'blocked' }, sound: 'attention' },
217
- { flags: { kind: 'question' }, sound: 'attention' },
218
- { flags: { reply: true }, sound: 'attention' },
219
- ])('gives $flags the $sound sound when nobody chose one', ({ flags, sound }) => {
221
+ { flags: {} },
222
+ { flags: { kind: 'update' } },
223
+ { flags: { kind: 'done' } },
224
+ { flags: { kind: 'failed' } },
225
+ { flags: { kind: 'blocked' } },
226
+ { flags: { kind: 'question' } },
227
+ { flags: { reply: true } },
228
+ ])('omits sound from the draft for $flags so the server can resolve it', ({ flags }) => {
220
229
  const config = loadConfig({ cwd: base.cwd, env: base.env })
221
230
  const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
222
231
  if (!build.ok) throw new Error(build.error)
223
- expect(build.draft.platform?.ios?.sound).toBe(sound)
224
- expect(build.draft.platform?.android?.sound).toBe(sound)
232
+ expect(build.draft.platform?.ios?.sound).toBeUndefined()
233
+ expect(build.draft.platform?.android?.sound).toBeUndefined()
225
234
  expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
226
235
  })
227
236
 
@@ -551,9 +560,22 @@ describe('draft building', () => {
551
560
  it('rejects malformed inputs with usage errors', () => {
552
561
  const config = loadConfig({ cwd: base.cwd, env: base.env })
553
562
  expect(buildDraft(config, { title: '', body: 'B' }).ok).toBe(false)
554
- expect(buildDraft(config, { title: 'T', body: 'B', sound: 'airhorn' }).ok).toBe(false)
563
+ expect(buildDraft(config, { title: 'T', body: 'B', sound: 'n'.repeat(200) }).ok).toBe(false)
555
564
  expect(buildDraft(config, { title: 'T', body: 'B', data: ['nokey'] }).ok).toBe(false)
556
565
  expect(buildDraft(config, { title: 'T', body: 'B', level: 'shouting' }).ok).toBe(false)
557
566
  expect(buildDraft(config, { title: 'T', body: 'B', platform: 'linux' }).ok).toBe(false)
558
567
  })
568
+
569
+ it('puts a custom sound name on the draft for the server to resolve', () => {
570
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
571
+ const byName = buildDraft(config, { title: 'T', body: 'B', sound: 'airhorn' })
572
+ if (!byName.ok) throw new Error(byName.error)
573
+ expect(byName.draft.platform?.ios?.sound).toBe('airhorn')
574
+ expect(byName.draft.platform?.android?.sound).toBe('airhorn')
575
+
576
+ const byId = buildDraft(config, { title: 'T', body: 'B', sound: 'snd_01HQTEST' })
577
+ if (!byId.ok) throw new Error(byId.error)
578
+ expect(byId.draft.platform?.ios?.sound).toBe('snd_01HQTEST')
579
+ expect(byId.draft.platform?.android?.sound).toBe('snd_01HQTEST')
580
+ })
559
581
  })
package/src/config.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import {
2
- CLI_SOUNDS,
3
2
  INTERRUPTION_LEVELS,
4
3
  REPLY_MAX_WINDOW_SECONDS,
5
4
  } from '@raidiant/notifai-protocol'
@@ -8,6 +7,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'
8
7
  import path from 'node:path'
9
8
  import { parse as parseToml } from 'smol-toml'
10
9
  import { configHome, stateHome } from './platform.js'
10
+ import { isCliSoundRef } from './sound-ref.js'
11
11
  import { normalizeOrigin } from './url-policy.js'
12
12
 
13
13
  /**
@@ -41,7 +41,6 @@ export interface ResolvedValue<T> {
41
41
  source: ConfigSource
42
42
  }
43
43
 
44
- type CliSound = (typeof CLI_SOUNDS)[number]
45
44
  type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
46
45
 
47
46
  /**
@@ -58,7 +57,7 @@ export interface CliConfig {
58
57
  ttl_seconds: ResolvedValue<number>
59
58
  collapse_key: ResolvedValue<string | null>
60
59
  devices: ResolvedValue<string[] | null>
61
- sound: ResolvedValue<CliSound | null>
60
+ sound: ResolvedValue<string | null>
62
61
  interruption_level: ResolvedValue<CliInterruptionLevel | null>
63
62
  /** Project identifier stamped on sends; typically set in .notifai/config.toml. */
64
63
  project: ResolvedValue<string | null>
@@ -159,7 +158,6 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
159
158
  * is the same discipline the numeric bounds follow.
160
159
  */
161
160
  export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
162
- sound: CLI_SOUNDS,
163
161
  interruption_level: INTERRUPTION_LEVELS,
164
162
  log_level: LOG_LEVELS,
165
163
  }
@@ -365,6 +363,9 @@ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
365
363
  const origins = value.map((entry) => (typeof entry === 'string' ? normalizeOrigin(entry) : null))
366
364
  return origins.every((origin): origin is string => origin !== null) ? origins : undefined
367
365
  }
366
+ if (key === 'sound') {
367
+ return typeof value === 'string' && isCliSoundRef(value) ? value : undefined
368
+ }
368
369
  const allowed = ENUM_CONFIG_VALUES[key]
369
370
  if (allowed !== undefined) {
370
371
  return typeof value === 'string' && allowed.includes(value) ? value : undefined
@@ -390,7 +391,7 @@ export interface FlagOverrides {
390
391
  ttl_seconds?: number
391
392
  collapse_key?: string | null
392
393
  devices?: string[]
393
- sound?: CliSound
394
+ sound?: string
394
395
  interruption_level?: CliInterruptionLevel
395
396
  }
396
397