@raidiant/notifai 9.2.2 → 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 (149) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/client.d.ts +3 -1
  6. package/dist/client.js +2 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/codex-wake.js +9 -0
  9. package/dist/codex-wake.js.map +1 -1
  10. package/dist/commands-agent-sessions.d.ts +6 -0
  11. package/dist/commands-agent-sessions.js +51 -0
  12. package/dist/commands-agent-sessions.js.map +1 -0
  13. package/dist/commands-config.js +11 -4
  14. package/dist/commands-config.js.map +1 -1
  15. package/dist/commands-core.d.ts +1 -1
  16. package/dist/commands-doctor.js +83 -6
  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 +32 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hook-shape.d.ts +4 -3
  22. package/dist/commands-hook-shape.js +19 -10
  23. package/dist/commands-hook-shape.js.map +1 -1
  24. package/dist/commands-hooks.d.ts +4 -3
  25. package/dist/commands-hooks.js +80 -23
  26. package/dist/commands-hooks.js.map +1 -1
  27. package/dist/commands-init.js +12 -1
  28. package/dist/commands-init.js.map +1 -1
  29. package/dist/commands-project.d.ts +4 -0
  30. package/dist/commands-project.js +41 -0
  31. package/dist/commands-project.js.map +1 -0
  32. package/dist/commands-send-support.js +0 -4
  33. package/dist/commands-send-support.js.map +1 -1
  34. package/dist/commands-send.js +12 -6
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +58 -0
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-sounds.d.ts +12 -0
  39. package/dist/commands-sounds.js +35 -0
  40. package/dist/commands-sounds.js.map +1 -0
  41. package/dist/commands.d.ts +3 -0
  42. package/dist/commands.js +3 -0
  43. package/dist/commands.js.map +1 -1
  44. package/dist/config-schema.js +7 -4
  45. package/dist/config-schema.js.map +1 -1
  46. package/dist/config.d.ts +6 -7
  47. package/dist/config.js +9 -9
  48. package/dist/config.js.map +1 -1
  49. package/dist/credentials.js +4 -1
  50. package/dist/credentials.js.map +1 -1
  51. package/dist/guidance-content.d.ts +3 -0
  52. package/dist/guidance-content.js +20 -0
  53. package/dist/guidance-content.js.map +1 -1
  54. package/dist/guidance-render.js +3 -3
  55. package/dist/guidance-render.js.map +1 -1
  56. package/dist/harnesses.d.ts +49 -7
  57. package/dist/harnesses.js +90 -24
  58. package/dist/harnesses.js.map +1 -1
  59. package/dist/hooks.d.ts +28 -52
  60. package/dist/hooks.js +393 -152
  61. package/dist/hooks.js.map +1 -1
  62. package/dist/install-hooks.d.ts +17 -39
  63. package/dist/install-hooks.js +21 -41
  64. package/dist/install-hooks.js.map +1 -1
  65. package/dist/invocation-context.d.ts +2 -2
  66. package/dist/orca-session-title.d.ts +2 -2
  67. package/dist/orca-session-title.js +3 -4
  68. package/dist/orca-session-title.js.map +1 -1
  69. package/dist/program.d.ts +6 -1
  70. package/dist/program.js +46 -5
  71. package/dist/program.js.map +1 -1
  72. package/dist/project-enablement.d.ts +15 -0
  73. package/dist/project-enablement.js +87 -0
  74. package/dist/project-enablement.js.map +1 -0
  75. package/dist/question-timing.d.ts +46 -0
  76. package/dist/question-timing.js +48 -0
  77. package/dist/question-timing.js.map +1 -0
  78. package/dist/readiness.d.ts +7 -0
  79. package/dist/readiness.js.map +1 -1
  80. package/dist/send.d.ts +2 -0
  81. package/dist/send.js +17 -15
  82. package/dist/send.js.map +1 -1
  83. package/dist/session-activation.d.ts +1 -1
  84. package/dist/session-activation.js +3 -4
  85. package/dist/session-activation.js.map +1 -1
  86. package/dist/session-labels.d.ts +18 -2
  87. package/dist/session-labels.js +79 -8
  88. package/dist/session-labels.js.map +1 -1
  89. package/dist/sound-ref.d.ts +23 -0
  90. package/dist/sound-ref.js +28 -0
  91. package/dist/sound-ref.js.map +1 -0
  92. package/package.json +3 -3
  93. package/src/claude-wake.test.ts +1 -0
  94. package/src/claude-wake.ts +10 -0
  95. package/src/client.test.ts +12 -0
  96. package/src/client.ts +8 -0
  97. package/src/codex-wake.test.ts +1 -0
  98. package/src/codex-wake.ts +9 -0
  99. package/src/commands-agent-sessions.ts +72 -0
  100. package/src/commands-config.ts +10 -4
  101. package/src/commands-core.ts +1 -1
  102. package/src/commands-doctor.ts +96 -8
  103. package/src/commands-harness-context.ts +48 -2
  104. package/src/commands-hook-shape.ts +26 -10
  105. package/src/commands-hooks.ts +95 -31
  106. package/src/commands-init.ts +11 -1
  107. package/src/commands-project.ts +47 -0
  108. package/src/commands-send-support.ts +0 -4
  109. package/src/commands-send.ts +10 -5
  110. package/src/commands-skill.test.ts +44 -0
  111. package/src/commands-skill.ts +57 -0
  112. package/src/commands-sounds.test.ts +106 -0
  113. package/src/commands-sounds.ts +49 -0
  114. package/src/commands.test.ts +386 -23
  115. package/src/commands.ts +10 -0
  116. package/src/config-schema.test.ts +1 -0
  117. package/src/config-schema.ts +7 -4
  118. package/src/config.test.ts +44 -23
  119. package/src/config.ts +13 -15
  120. package/src/credentials.test.ts +20 -0
  121. package/src/credentials.ts +4 -1
  122. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  123. package/src/guidance-content.ts +22 -0
  124. package/src/guidance-render.ts +3 -3
  125. package/src/harness-integration.test.ts +212 -0
  126. package/src/harnesses.test.ts +107 -8
  127. package/src/harnesses.ts +111 -30
  128. package/src/hook-adapter.test.ts +27 -8
  129. package/src/hooks.test.ts +812 -83
  130. package/src/hooks.ts +529 -185
  131. package/src/install-hooks.test.ts +13 -16
  132. package/src/install-hooks.ts +40 -58
  133. package/src/invocation-context.ts +2 -2
  134. package/src/orca-session-title.test.ts +8 -49
  135. package/src/orca-session-title.ts +3 -5
  136. package/src/program.test.ts +45 -0
  137. package/src/program.ts +60 -4
  138. package/src/project-enablement.test.ts +55 -0
  139. package/src/project-enablement.ts +99 -0
  140. package/src/question-timing.ts +56 -0
  141. package/src/readiness.ts +8 -1
  142. package/src/send.ts +18 -20
  143. package/src/session-activation.ts +3 -4
  144. package/src/session-labels.test.ts +58 -9
  145. package/src/session-labels.ts +95 -12
  146. package/src/skill.test.ts +14 -3
  147. package/src/sound-ref.test.ts +31 -0
  148. package/src/sound-ref.ts +34 -0
  149. 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,
@@ -68,6 +71,7 @@ import {
68
71
  codexTrustKey,
69
72
  findInstallations,
70
73
  handlerEvent,
74
+ QUESTION_STOP_TIMEOUT_SECONDS,
71
75
  settingsFile,
72
76
  } from './install-hooks.js'
73
77
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
@@ -78,6 +82,7 @@ import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
78
82
  import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
79
83
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
80
84
  import type { Tone } from './ui/theme.js'
85
+ import { projectBinding, projectEnabled } from './project-enablement.js'
81
86
 
82
87
  afterEach(() => {
83
88
  resetLatestPublishedCliVersionForTest()
@@ -416,6 +421,31 @@ function replyResponse(
416
421
  }
417
422
 
418
423
  describe('command contracts', () => {
424
+ it('durably enables a valid in-Project send before missing authentication stops it', async () => {
425
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-explicit-enable-'))
426
+ const io = new CapturedIo()
427
+ const env = isolatedEnv(cwd)
428
+ const deps = {
429
+ ...makeDeps(io, {} as ApiClient),
430
+ cwd,
431
+ env,
432
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
433
+ }
434
+ expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done' })).toBe(EXIT.auth)
435
+ expect(projectEnabled(projectBinding(cwd, env))).toBe(true)
436
+ })
437
+
438
+ it('sends deliberately Projectless without enabling the inferred Project', async () => {
439
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-projectless-'))
440
+ const io = new CapturedIo()
441
+ const env = isolatedEnv(cwd)
442
+ let submitted: SubmitNotificationRequestT | undefined
443
+ const client = { submit: async (body: SubmitNotificationRequestT) => ((submitted = body), receipt) } as unknown as ApiClient
444
+ const deps = { ...makeDeps(io, client), cwd, env }
445
+ expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done', projectless: true })).toBe(EXIT.ok)
446
+ expect(submitted?.draft.project).toBeUndefined()
447
+ expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
448
+ })
419
449
  it('shows an actionable no-plan access state', async () => {
420
450
  const io = new CapturedIo()
421
451
  const client = {
@@ -736,6 +766,28 @@ describe('command contracts', () => {
736
766
  expect(io.errLines).toEqual([])
737
767
  })
738
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
+
739
791
  it('prints Android downgrade warnings before submitting', async () => {
740
792
  const io = new CapturedIo()
741
793
  let submitCalls = 0
@@ -829,7 +881,7 @@ describe('command contracts', () => {
829
881
  expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
830
882
  })
831
883
 
832
- it('uses the exact Orca worktree title for an active Claude session send', async () => {
884
+ it('uses trusted Orca task context for an active Claude Agent Session label', async () => {
833
885
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-claude-title-'))
834
886
  const io = new CapturedIo()
835
887
  let submitted: SubmitNotificationRequestT | undefined
@@ -852,10 +904,7 @@ describe('command contracts', () => {
852
904
  ...makeDeps(io, client),
853
905
  cwd,
854
906
  env,
855
- orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
856
- expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
857
- return 'Worker - semantic session implementation'
858
- },
907
+ orcaSessionTitle: () => 'Agent Session context labels',
859
908
  }
860
909
 
861
910
  expect(
@@ -868,12 +917,12 @@ describe('command contracts', () => {
868
917
 
869
918
  expect(submitted?.draft.source).toMatchObject({
870
919
  session_id: 'orca-claude-session',
871
- session_label: 'Worker - semantic session implementation',
920
+ session_label: 'Agent Session context labels',
872
921
  harness: 'claude-code',
873
922
  })
874
923
  })
875
924
 
876
- it('uses the exact Orca worktree title for an active Codex session send', async () => {
925
+ it('uses trusted Orca task context for an active Codex Agent Session label', async () => {
877
926
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
878
927
  const io = new CapturedIo()
879
928
  let submitted: SubmitNotificationRequestT | undefined
@@ -894,10 +943,7 @@ describe('command contracts', () => {
894
943
  TERM_PROGRAM: 'Orca',
895
944
  ORCA_WORKTREE_ID: worktreeId,
896
945
  },
897
- orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
898
- expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
899
- return 'Worker - semantic session implementation'
900
- },
946
+ orcaSessionTitle: () => 'Release verification',
901
947
  }
902
948
 
903
949
  expect(
@@ -910,11 +956,79 @@ describe('command contracts', () => {
910
956
 
911
957
  expect(submitted?.draft.source).toMatchObject({
912
958
  session_id: 'orca-codex-thread',
913
- session_label: 'Worker - semantic session implementation',
959
+ session_label: 'Release verification',
914
960
  harness: 'codex',
915
961
  })
916
962
  })
917
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
+ )
1030
+ })
1031
+
918
1032
  it('falls back safely when Orca returns a private path as its title', async () => {
919
1033
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-private-title-'))
920
1034
  const io = new CapturedIo()
@@ -936,7 +1050,6 @@ describe('command contracts', () => {
936
1050
  TERM_PROGRAM: 'Orca',
937
1051
  ORCA_WORKTREE_ID: `repo-123::${cwd}`,
938
1052
  },
939
- orcaSessionTitle: () => '/private/untrusted/worktree',
940
1053
  }
941
1054
 
942
1055
  expect(
@@ -1022,6 +1135,113 @@ describe('command contracts', () => {
1022
1135
  expect(existsSync(path.join(cwd, 'state', 'notifai', 'session-labels.json'))).toBe(false)
1023
1136
  })
1024
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
+
1025
1245
  it('uploads repeatable images in order and sends only canonical media references', async () => {
1026
1246
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
1027
1247
  const first = path.join(root, 'first.png')
@@ -1179,6 +1399,8 @@ describe('command contracts', () => {
1179
1399
  // Kind now selects the sound, so the help must say so — and must not carry
1180
1400
  // the retired separation it replaced.
1181
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')
1182
1404
  expect(source).not.toContain('it never chooses banner sound or interruption level')
1183
1405
  expect(source).not.toContain('Kind profiles apply automatically')
1184
1406
  })
@@ -3520,19 +3742,20 @@ describe('config surfaces', () => {
3520
3742
  expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
3521
3743
  })
3522
3744
 
3523
- it('refuses an enum value the sender would later reject', async () => {
3524
- // `config set sound whatever` used to be written straight to disk: the
3525
- // 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 () => {
3526
3746
  const io = new CapturedIo()
3527
- expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
3747
+ expect(await configSetCommand(configDeps(io), 'sound', 'n'.repeat(200), { yes: true })).toBe(
3528
3748
  EXIT.usage,
3529
3749
  )
3530
- expect(io.errLines[0]).toContain('default, done, attention, alert, none')
3750
+ expect(io.errLines[0]).toContain('custom sound')
3531
3751
  })
3532
3752
 
3533
- it('still accepts a legal enum value', async () => {
3753
+ it('accepts a shipped sound and an Account custom name', async () => {
3534
3754
  const io = new CapturedIo()
3535
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
+ )
3536
3759
  })
3537
3760
 
3538
3761
  it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
@@ -6093,7 +6316,7 @@ describe('asking before the hooks have ever run', () => {
6093
6316
  expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
6094
6317
  })
6095
6318
 
6096
- it('registers an Orca-managed Claude question under the semantic worktree title', () => {
6319
+ it('registers an Orca-managed Claude question without borrowing the worktree title', () => {
6097
6320
  const cwd = scratchDir('notifai-orca-claude-ask-')
6098
6321
  const io = new CapturedIo()
6099
6322
  const env = {
@@ -6108,7 +6331,6 @@ describe('asking before the hooks have ever run', () => {
6108
6331
  cwd,
6109
6332
  env,
6110
6333
  now: () => 42,
6111
- orcaSessionTitle: () => 'Worker - semantic session implementation',
6112
6334
  }
6113
6335
 
6114
6336
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
@@ -6121,9 +6343,10 @@ describe('asking before the hooks have ever run', () => {
6121
6343
  expect(askCommand(deps, 'Ship the semantic resolver?', {})).toBe(EXIT.ok)
6122
6344
  expect(readSessionState('orca-claude-question', env).pending?.[0]?.source).toMatchObject({
6123
6345
  session_id: 'orca-claude-question',
6124
- session_label: 'Worker - semantic session implementation',
6125
6346
  harness: 'claude-code',
6126
6347
  })
6348
+ expect(readSessionState('orca-claude-question', env).pending?.[0]?.source?.session_label)
6349
+ .toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
6127
6350
  })
6128
6351
 
6129
6352
  it('judges the fired check against the active harness, not a global installation of another', async () => {
@@ -6276,6 +6499,16 @@ describe('asking before the hooks have ever run', () => {
6276
6499
  await doctorCommand(deps, {})
6277
6500
  expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
6278
6501
  expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
6502
+ const readiness = await assessReadiness(deps)
6503
+ expect(readiness.states.find((state) => state.id === 'hooks-trust')?.remedy).toMatchObject({
6504
+ command: '/hooks',
6505
+ user_action: {
6506
+ code: 'codex_hook_approval_required',
6507
+ harness: 'codex',
6508
+ action: 'approve_or_enable_notifai_hooks',
6509
+ message: expect.stringContaining('Open `/hooks` in Codex'),
6510
+ },
6511
+ })
6279
6512
  })
6280
6513
 
6281
6514
  it('gives doctor the same active-Codex diagnosis as ask', async () => {
@@ -6490,6 +6723,92 @@ describe('asking before the hooks have ever run', () => {
6490
6723
  expect(readSessionState('opencode-current', env).pending).toBeUndefined()
6491
6724
  })
6492
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
+
6493
6812
  it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
6494
6813
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
6495
6814
  const io = new CapturedIo()
@@ -6650,7 +6969,8 @@ describe('asking before the hooks have ever run', () => {
6650
6969
  const out = io.outLines.join('\n')
6651
6970
  expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
6652
6971
  expect(out).toContain('needs `async: true`')
6653
- expect(out).toContain('kills the backgrounded waiter at its 600s default')
6972
+ expect(out).toContain('kills the backgrounded waiter silently before the complete answer window')
6973
+ expect(out).toContain(`needs an explicit ${QUESTION_STOP_TIMEOUT_SECONDS}s`)
6654
6974
  })
6655
6975
 
6656
6976
  /** A configured Claude Code project with one live session claiming `pid`. */
@@ -7284,3 +7604,46 @@ describe('a second device that disagrees', () => {
7284
7604
  expect(contradictingAnswer([])).toBeNull()
7285
7605
  })
7286
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,
@@ -62,4 +63,13 @@ export {
62
63
  type SetupScope,
63
64
  } from './commands-init.js'
64
65
  export { assessReadiness, doctorCommand } from './commands-doctor.js'
66
+ export {
67
+ projectDisableCommand,
68
+ projectEnableCommand,
69
+ projectStatusCommand,
70
+ } from './commands-project.js'
71
+ export {
72
+ agentSessionRenameCommand,
73
+ type AgentSessionRenameFlags,
74
+ } from './commands-agent-sessions.js'
65
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', () => {
@@ -100,7 +100,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
100
100
  unit: 's',
101
101
  summary: 'Optional delay before a question may reach your devices',
102
102
  detail:
103
- 'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nA timer and nothing more. Whether you happen to be at this keyboard does not change it: waiting no longer holds your terminal, so there is nothing your being here would protect you from.',
103
+ 'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nThis controls when the question reaches devices, not how the harness keeps the Agent Session available afterward. Claude Code waits out of band; Codex holds the asking turn.',
104
104
  example: '0',
105
105
  },
106
106
 
@@ -111,7 +111,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
111
111
  unit: 's',
112
112
  summary: 'How long the service keeps accepting your answer to a question',
113
113
  detail:
114
- 'A question stays answerable for this long after it is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
114
+ 'A question stays answerable for this long after it reaches the service. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nQuestion Routing keeps the exact Agent Session available for this complete window. Claude Code waits out of band and wakes the Agent Session; Codex keeps its turn held. This is separate from `--reply-timeout`, which controls how long a direct `send --reply` command blocks.',
115
115
  example: '86400',
116
116
  },
117
117
 
@@ -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'