@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
@@ -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,
@@ -32,6 +34,7 @@ import {
32
34
  acknowledgeCommand,
33
35
  askCommand,
34
36
  accessStatusCommand,
37
+ authStatusCommand,
35
38
  buildQuestions,
36
39
  assessReadiness,
37
40
  capabilitiesCommand,
@@ -56,6 +59,7 @@ import {
56
59
  repliesCommand,
57
60
  sendCommand,
58
61
  statusCommand,
62
+ agentSessionRenameCommand,
59
63
  type CommandDeps,
60
64
  type CommandIo,
61
65
  type CommandSpinner,
@@ -443,6 +447,16 @@ describe('command contracts', () => {
443
447
  expect(submitted?.draft.project).toBeUndefined()
444
448
  expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
445
449
  })
450
+ it('tells an unsigned machine to run init, not login', () => {
451
+ const io = new CapturedIo()
452
+ const deps = makeDeps(io, {} as ApiClient)
453
+ deps.store.load = () => null
454
+
455
+ expect(authStatusCommand(deps, {})).toBe(EXIT.auth)
456
+ expect(io.errLines.join('\n')).toMatch(/Not signed in\. Run `notifai init`/)
457
+ expect(io.errLines.join('\n')).not.toContain('notifai login')
458
+ })
459
+
446
460
  it('shows an actionable no-plan access state', async () => {
447
461
  const io = new CapturedIo()
448
462
  const client = {
@@ -763,6 +777,28 @@ describe('command contracts', () => {
763
777
  expect(io.errLines).toEqual([])
764
778
  })
765
779
 
780
+ it('sends an Account custom sound name on the draft', async () => {
781
+ const io = new CapturedIo()
782
+ let submitted: SubmitNotificationRequestT | undefined
783
+ const client = {
784
+ submit: async (body: SubmitNotificationRequestT) => {
785
+ submitted = body
786
+ return receipt
787
+ },
788
+ } as unknown as ApiClient
789
+
790
+ expect(
791
+ await sendCommand(makeDeps(io, client), {
792
+ kind: 'done',
793
+ title: 'Build finished',
794
+ body: 'All checks passed.',
795
+ sound: 'Kitchen timer',
796
+ }),
797
+ ).toBe(EXIT.ok)
798
+ expect(submitted?.draft.platform?.ios?.sound).toBe('Kitchen timer')
799
+ expect(submitted?.draft.platform?.android?.sound).toBe('Kitchen timer')
800
+ })
801
+
766
802
  it('prints Android downgrade warnings before submitting', async () => {
767
803
  const io = new CapturedIo()
768
804
  let submitCalls = 0
@@ -856,7 +892,7 @@ describe('command contracts', () => {
856
892
  expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
857
893
  })
858
894
 
859
- it('never promotes an Orca worktree title into an Agent Session label', async () => {
895
+ it('uses trusted Orca task context for an active Claude Agent Session label', async () => {
860
896
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-claude-title-'))
861
897
  const io = new CapturedIo()
862
898
  let submitted: SubmitNotificationRequestT | undefined
@@ -879,6 +915,7 @@ describe('command contracts', () => {
879
915
  ...makeDeps(io, client),
880
916
  cwd,
881
917
  env,
918
+ orcaSessionTitle: () => 'Agent Session context labels',
882
919
  }
883
920
 
884
921
  expect(
@@ -891,13 +928,12 @@ describe('command contracts', () => {
891
928
 
892
929
  expect(submitted?.draft.source).toMatchObject({
893
930
  session_id: 'orca-claude-session',
931
+ session_label: 'Agent Session context labels',
894
932
  harness: 'claude-code',
895
933
  })
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
934
  })
899
935
 
900
- it('never treats an Orca worktree title as a Codex Agent Session label', async () => {
936
+ it('uses trusted Orca task context for an active Codex Agent Session label', async () => {
901
937
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
902
938
  const io = new CapturedIo()
903
939
  let submitted: SubmitNotificationRequestT | undefined
@@ -918,6 +954,7 @@ describe('command contracts', () => {
918
954
  TERM_PROGRAM: 'Orca',
919
955
  ORCA_WORKTREE_ID: worktreeId,
920
956
  },
957
+ orcaSessionTitle: () => 'Release verification',
921
958
  }
922
959
 
923
960
  expect(
@@ -930,9 +967,77 @@ describe('command contracts', () => {
930
967
 
931
968
  expect(submitted?.draft.source).toMatchObject({
932
969
  session_id: 'orca-codex-thread',
970
+ session_label: 'Release verification',
933
971
  harness: 'codex',
934
972
  })
935
- expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
973
+ })
974
+
975
+ it('sends after isolating an invalid session-name record without losing valid names', async () => {
976
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-invalid-session-name-'))
977
+ const io = new CapturedIo()
978
+ const env = {
979
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
980
+ XDG_STATE_HOME: path.join(cwd, 'state'),
981
+ CODEX_THREAD_ID: 'current-session',
982
+ }
983
+ const file = path.join(stateDir(env), 'session-labels.json')
984
+ const validKey = createHash('sha256').update('unrelated-session').digest('hex')
985
+ mkdirSync(path.dirname(file), { recursive: true })
986
+ writeFileSync(
987
+ file,
988
+ `${JSON.stringify(
989
+ {
990
+ version: 1,
991
+ sessions: {
992
+ [validKey]: {
993
+ label: 'Unrelated work',
994
+ source: 'explicit',
995
+ first_seen_at: 1_777_777_777_000,
996
+ harness: 'codex',
997
+ },
998
+ obsolete: {
999
+ label: 'Old shape',
1000
+ source: 'legacy',
1001
+ first_seen_at: 'unknown',
1002
+ },
1003
+ },
1004
+ },
1005
+ null,
1006
+ 2,
1007
+ )}\n`,
1008
+ )
1009
+
1010
+ let submitted: SubmitNotificationRequestT | undefined
1011
+ const client = {
1012
+ submit: async (body: SubmitNotificationRequestT) => {
1013
+ submitted = body
1014
+ return receipt
1015
+ },
1016
+ } as unknown as ApiClient
1017
+
1018
+ expect(
1019
+ await sendCommand(
1020
+ { ...makeDeps(io, client), cwd, env },
1021
+ { title: 'Store recovered', body: 'The valid Agent Session name survived.', kind: 'done' },
1022
+ ),
1023
+ ).toBe(EXIT.ok)
1024
+ expect(submitted?.draft.source).toMatchObject({
1025
+ session_id: 'current-session',
1026
+ harness: 'codex',
1027
+ })
1028
+
1029
+ const recovered = JSON.parse(readFileSync(file, 'utf8')) as {
1030
+ sessions: Record<string, { label: string }>
1031
+ }
1032
+ expect(recovered.sessions[validKey]?.label).toBe('Unrelated work')
1033
+ expect(recovered.sessions['obsolete']).toBeUndefined()
1034
+ const backups = readdirSync(path.dirname(file)).filter((name) =>
1035
+ /^session-labels\.invalid-[a-f0-9]{64}\.json$/u.test(name),
1036
+ )
1037
+ expect(backups).toHaveLength(1)
1038
+ expect(readFileSync(path.join(path.dirname(file), backups[0]!), 'utf8')).toContain(
1039
+ '"obsolete"',
1040
+ )
936
1041
  })
937
1042
 
938
1043
  it('falls back safely when Orca returns a private path as its title', async () => {
@@ -1041,6 +1146,113 @@ describe('command contracts', () => {
1041
1146
  expect(existsSync(path.join(cwd, 'state', 'notifai', 'session-labels.json'))).toBe(false)
1042
1147
  })
1043
1148
 
1149
+ it('sends Hermes Source Context from HERMES_SESSION_ID and the invocation cwd', async () => {
1150
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-send-'))
1151
+ const repo = path.join(root, 'notifai')
1152
+ const worktree = path.join(root, 'hermes-topic')
1153
+ mkdirSync(repo)
1154
+ const git = (...args: string[]) => execFileSync('git', ['-C', repo, ...args], { stdio: 'ignore' })
1155
+ git('init')
1156
+ git('config', 'user.email', 'test@example.invalid')
1157
+ git('config', 'user.name', 'Test')
1158
+ git('config', 'commit.gpgsign', 'false')
1159
+ writeFileSync(path.join(repo, 'file'), 'x')
1160
+ git('add', 'file')
1161
+ git('commit', '-m', 'fixture')
1162
+ git('worktree', 'add', worktree, '-b', 'feature/hermes-send')
1163
+
1164
+ const io = new CapturedIo()
1165
+ let submitted: SubmitNotificationRequestT | undefined
1166
+ const client = {
1167
+ submit: async (body: SubmitNotificationRequestT) => {
1168
+ submitted = body
1169
+ return receipt
1170
+ },
1171
+ } as unknown as ApiClient
1172
+ const sessionId = '20260828_111302_29a404'
1173
+ const deps = {
1174
+ ...makeDeps(io, client),
1175
+ cwd: worktree,
1176
+ env: {
1177
+ ...isolatedEnv(worktree),
1178
+ HERMES_SESSION_ID: sessionId,
1179
+ },
1180
+ }
1181
+
1182
+ expect(
1183
+ await sendCommand(deps, {
1184
+ title: 'Hermes baseline landed',
1185
+ body: 'Classic CLI send carries exact session identity.',
1186
+ kind: 'done',
1187
+ }),
1188
+ ).toBe(EXIT.ok)
1189
+
1190
+ expect(submitted?.draft.source).toMatchObject({
1191
+ session_id: sessionId,
1192
+ harness: 'hermes',
1193
+ branch: 'feature/hermes-send',
1194
+ worktree: 'hermes-topic',
1195
+ })
1196
+ expect(io.outLines.join('\n')).not.toContain(sessionId)
1197
+
1198
+ submitted = undefined
1199
+ expect(
1200
+ await sendCommand(
1201
+ {
1202
+ ...deps,
1203
+ env: {
1204
+ ...deps.env,
1205
+ HERMES_SESSION_KEY: 'gateway-route-key-must-not-be-session-id',
1206
+ },
1207
+ },
1208
+ {
1209
+ title: 'Hermes deferred surface sent',
1210
+ body: 'The send stays universal while unproven Source Context is omitted.',
1211
+ kind: 'done',
1212
+ },
1213
+ ),
1214
+ ).toBe(EXIT.ok)
1215
+ expect(submitted?.draft.source).toMatchObject({
1216
+ branch: 'feature/hermes-send',
1217
+ worktree: 'hermes-topic',
1218
+ })
1219
+ expect(submitted?.draft.source).not.toHaveProperty('session_id')
1220
+ expect(submitted?.draft.source).not.toHaveProperty('harness')
1221
+ expect(JSON.stringify(submitted?.draft.source)).not.toContain('gateway-route-key')
1222
+ })
1223
+
1224
+ it('does not attribute Hermes Source Context when another harness marker is nested', async () => {
1225
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-send-'))
1226
+ const io = new CapturedIo()
1227
+ let submitted: SubmitNotificationRequestT | undefined
1228
+ const client = {
1229
+ submit: async (body: SubmitNotificationRequestT) => {
1230
+ submitted = body
1231
+ return receipt
1232
+ },
1233
+ } as unknown as ApiClient
1234
+ const deps = {
1235
+ ...makeDeps(io, client),
1236
+ cwd,
1237
+ env: {
1238
+ ...isolatedEnv(cwd),
1239
+ CLAUDECODE: '1',
1240
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
1241
+ HERMES_SESSION_ID: '20260828_111302_29a404',
1242
+ },
1243
+ }
1244
+
1245
+ expect(
1246
+ await sendCommand(deps, {
1247
+ title: 'Nested send stays honest',
1248
+ body: 'Ambiguous ownership omits harness identity.',
1249
+ kind: 'update',
1250
+ }),
1251
+ ).toBe(EXIT.ok)
1252
+ expect(submitted?.draft.source?.harness).toBeUndefined()
1253
+ expect(submitted?.draft.source?.session_id).toBeUndefined()
1254
+ })
1255
+
1044
1256
  it('uploads repeatable images in order and sends only canonical media references', async () => {
1045
1257
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
1046
1258
  const first = path.join(root, 'first.png')
@@ -1198,6 +1410,8 @@ describe('command contracts', () => {
1198
1410
  // Kind now selects the sound, so the help must say so — and must not carry
1199
1411
  // the retired separation it replaced.
1200
1412
  expect(source).toContain('Kind is required, and it selects the sound')
1413
+ expect(source).toContain('Device default')
1414
+ expect(source).toContain('custom name or id')
1201
1415
  expect(source).not.toContain('it never chooses banner sound or interruption level')
1202
1416
  expect(source).not.toContain('Kind profiles apply automatically')
1203
1417
  })
@@ -2393,7 +2607,7 @@ describe('credential origin pinning', () => {
2393
2607
  expect(io.errLines).toEqual([
2394
2608
  'This account has no active plan or temporary Alpha access.',
2395
2609
  'next: Ask the account owner for Alpha access.',
2396
- 'After access is granted, run `notifai login` again.',
2610
+ 'After access is granted, run `notifai init` again.',
2397
2611
  ])
2398
2612
  })
2399
2613
 
@@ -3074,6 +3288,71 @@ describe('harness activation guidance', () => {
3074
3288
  expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
3075
3289
  expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
3076
3290
  })
3291
+
3292
+ it('installs an owned OpenClaw plugin and reports unsupported continuation', async () => {
3293
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-activation-'))
3294
+ const io = new CapturedIo()
3295
+ const client = {
3296
+ health: async () => true,
3297
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3298
+ listDevices: async () => ({ devices: [] }),
3299
+ } as unknown as ApiClient
3300
+ const home = path.join(cwd, 'home')
3301
+ const deps = {
3302
+ ...makeDeps(io, client),
3303
+ cwd,
3304
+ env: {
3305
+ HOME: home,
3306
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3307
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3308
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
3309
+ },
3310
+ }
3311
+
3312
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
3313
+ expect(io.outLines.join('\n')).toContain('Installed openclaw hooks in')
3314
+ expect(io.outLines.join('\n')).toContain(
3315
+ 'Restart the OpenClaw Gateway, start one fresh Agent Session, send one prompt, then run `notifai doctor`.',
3316
+ )
3317
+ const pluginFile = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
3318
+ const plugin = readFileSync(pluginFile, 'utf8')
3319
+ expect(plugin).toContain('api.on("before_prompt_build"')
3320
+ expect(plugin).toContain('api.on("resolve_exec_env"')
3321
+ expect(plugin).not.toContain('command:stop')
3322
+ const config = JSON.parse(
3323
+ readFileSync(path.join(cwd, 'openclaw-home', 'openclaw.json'), 'utf8'),
3324
+ ) as {
3325
+ plugins: { entries: { notifai: { enabled: boolean; hooks: { allowConversationAccess: boolean } } } }
3326
+ }
3327
+ expect(config.plugins.entries.notifai).toEqual({
3328
+ enabled: true,
3329
+ hooks: { allowConversationAccess: true },
3330
+ })
3331
+
3332
+ io.outLines = []
3333
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
3334
+ expect(io.outLines.join('\n')).toContain('no proven answer continuation')
3335
+ })
3336
+
3337
+ it('refuses to overwrite a foreign OpenClaw plugin', () => {
3338
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-foreign-'))
3339
+ const plugin = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
3340
+ mkdirSync(path.dirname(plugin), { recursive: true })
3341
+ writeFileSync(plugin, 'export default { id: "foreign" }\n')
3342
+ const io = new CapturedIo()
3343
+ const deps = {
3344
+ ...makeDeps(io, {} as ApiClient),
3345
+ cwd,
3346
+ env: {
3347
+ HOME: path.join(cwd, 'home'),
3348
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3349
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
3350
+ },
3351
+ }
3352
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.failed)
3353
+ expect(io.errLines.join('\n')).toMatch(/was not written by Notifai/)
3354
+ expect(readFileSync(plugin, 'utf8')).toBe('export default { id: "foreign" }\n')
3355
+ })
3077
3356
  })
3078
3357
 
3079
3358
  describe('stable hook installation', () => {
@@ -3539,19 +3818,20 @@ describe('config surfaces', () => {
3539
3818
  expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
3540
3819
  })
3541
3820
 
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.
3821
+ it('refuses a sound that cannot be a shipped name, custom id, or custom name', async () => {
3545
3822
  const io = new CapturedIo()
3546
- expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
3823
+ expect(await configSetCommand(configDeps(io), 'sound', 'n'.repeat(200), { yes: true })).toBe(
3547
3824
  EXIT.usage,
3548
3825
  )
3549
- expect(io.errLines[0]).toContain('default, done, attention, alert, none')
3826
+ expect(io.errLines[0]).toContain('custom sound')
3550
3827
  })
3551
3828
 
3552
- it('still accepts a legal enum value', async () => {
3829
+ it('accepts a shipped sound and an Account custom name', async () => {
3553
3830
  const io = new CapturedIo()
3554
3831
  expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
3832
+ expect(await configSetCommand(configDeps(io), 'sound', 'Kitchen timer', { yes: true })).toBe(
3833
+ EXIT.ok,
3834
+ )
3555
3835
  })
3556
3836
 
3557
3837
  it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
@@ -4320,8 +4600,9 @@ describe('init', () => {
4320
4600
  expect(await initCommand(deps, {})).toBe(EXIT.failed)
4321
4601
  const out = io.outLines.join('\n')
4322
4602
  expect(out).toContain('Next: This machine')
4323
- expect(out).toContain('notifai login')
4324
- expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
4603
+ expect(out).toContain('notifai init')
4604
+ expect(out).not.toContain('notifai login')
4605
+ expect(out).not.toContain('Then re-run')
4325
4606
  // The device gap is real and downstream; it must stay hidden until the
4326
4607
  // sign-in that would let anyone actually check it has happened.
4327
4608
  expect(out).not.toContain('companion app')
@@ -4867,7 +5148,8 @@ describe('init', () => {
4867
5148
  }
4868
5149
 
4869
5150
  expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
4870
- expect(io.outLines.join('\n')).toContain('notifai login')
5151
+ expect(io.outLines.join('\n')).toContain('notifai init')
5152
+ expect(io.outLines.join('\n')).not.toContain('notifai login')
4871
5153
  })
4872
5154
 
4873
5155
  it('announces sign-in for a present human without re-confirming', async () => {
@@ -4900,7 +5182,8 @@ describe('init', () => {
4900
5182
  const out = io.outLines.join('\n')
4901
5183
  expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
4902
5184
  expect(out).toContain('Next: This machine')
4903
- expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
5185
+ expect(out).toContain('notifai init')
5186
+ expect(out).not.toContain('notifai login')
4904
5187
  })
4905
5188
 
4906
5189
  it('never prompts or opens a browser when an agent runs it unattended', async () => {
@@ -5463,8 +5746,86 @@ describe('init', () => {
5463
5746
  const out = io.outLines.join('\n')
5464
5747
  expect(out).toContain('Next: Account')
5465
5748
  expect(out).toContain('pair it again')
5466
- expect(out).toContain('notifai login')
5749
+ expect(out).toContain('notifai init')
5750
+ expect(out).not.toContain('notifai login')
5751
+ expect(out.match(/^Next:/gm)).toHaveLength(1)
5752
+ })
5753
+
5754
+ it('names the support next action when a paired account has no active plan', async () => {
5755
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-'))
5756
+ const io = new CapturedIo()
5757
+ const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
5758
+ const client = {
5759
+ health: async () => true,
5760
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5761
+ listDevices: async () => {
5762
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
5763
+ },
5764
+ accessStatus: async () => ({
5765
+ status: 'no_active_plan',
5766
+ reason: 'no_active_grant',
5767
+ expires_at: null,
5768
+ email: 'rafael@example.test',
5769
+ }),
5770
+ } as unknown as ApiClient
5771
+ const deps = {
5772
+ ...makeDeps(io, client),
5773
+ cwd,
5774
+ env: isolatedEnv(cwd),
5775
+ }
5776
+
5777
+ const readiness = await assessReadiness(deps)
5778
+ const auth = readiness.states.find((state) => state.id === 'auth')
5779
+ expect(auth).toMatchObject({
5780
+ status: 'gap',
5781
+ detail: 'no active plan or temporary Alpha access (rafael@example.test)',
5782
+ remedy: { by: 'user-elsewhere', summary: nextAction },
5783
+ })
5784
+ expect(auth?.remedy && 'command' in auth.remedy ? auth.remedy.command : undefined).toBeUndefined()
5785
+
5786
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
5787
+ const out = io.outLines.join('\n')
5788
+ expect(out).toContain('Next: Account')
5789
+ expect(out).toContain(nextAction)
5790
+ expect(out).not.toContain('pair it again')
5791
+ expect(out).not.toContain('notifai login')
5467
5792
  expect(out.match(/^Next:/gm)).toHaveLength(1)
5793
+
5794
+ const doctorIo = new CapturedIo()
5795
+ expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.failed)
5796
+ const payload = JSON.parse(doctorIo.outLines[0] ?? '{}') as {
5797
+ states: Array<{ id: string; remedy?: { summary?: string; command?: string } }>
5798
+ }
5799
+ const doctorAuth = payload.states.find((state) => state.id === 'auth')
5800
+ expect(doctorAuth?.remedy?.summary).toBe(nextAction)
5801
+ expect(doctorAuth?.remedy?.command).toBeUndefined()
5802
+ })
5803
+
5804
+ it('does not treat a listDevices no-plan 403 as a revoked machine when accessStatus fails', async () => {
5805
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-devices-only-'))
5806
+ const io = new CapturedIo()
5807
+ const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
5808
+ const client = {
5809
+ health: async () => true,
5810
+ listDevices: async () => {
5811
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
5812
+ },
5813
+ accessStatus: async () => {
5814
+ throw new ApiCallError(500, 'internal_error', 'access status unavailable')
5815
+ },
5816
+ } as unknown as ApiClient
5817
+ const deps = {
5818
+ ...makeDeps(io, client),
5819
+ cwd,
5820
+ env: isolatedEnv(cwd),
5821
+ }
5822
+
5823
+ const readiness = await assessReadiness(deps)
5824
+ const auth = readiness.states.find((state) => state.id === 'auth')
5825
+ expect(auth?.status).toBe('gap')
5826
+ expect(auth?.detail).toContain('no active plan')
5827
+ expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
5828
+ expect(JSON.stringify(auth)).not.toContain('pair it again')
5468
5829
  })
5469
5830
 
5470
5831
  it('reuses proof across worktrees of one Project on the same Approved Machine', async () => {
@@ -6519,6 +6880,114 @@ describe('asking before the hooks have ever run', () => {
6519
6880
  expect(readSessionState('opencode-current', env).pending).toBeUndefined()
6520
6881
  })
6521
6882
 
6883
+ it('rejects OpenClaw before registration even with an exact matching pointer', () => {
6884
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-openclaw-unsupported-'))
6885
+ const io = new CapturedIo()
6886
+ const env = {
6887
+ HOME: path.join(cwd, 'home'),
6888
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
6889
+ XDG_STATE_HOME: path.join(cwd, 'state'),
6890
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
6891
+ NOTIFAI_ACTIVE_HARNESS: 'openclaw',
6892
+ NOTIFAI_ACTIVE_SESSION_ID: 'agent:main:main',
6893
+ }
6894
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6895
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
6896
+ writeSessionState('agent:main:main', env, { harness: 'openclaw', last_prompt_at: 42, last_stop_at: 41 })
6897
+ writeProjectSession(cwd, env, 'agent:main:main', 42, 'openclaw')
6898
+ io.outLines = []
6899
+
6900
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6901
+ expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
6902
+ expect(readSessionState('agent:main:main', env).pending).toBeUndefined()
6903
+ })
6904
+
6905
+ it('refuses Hermes ask as unsupported rather than as missing hooks', () => {
6906
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-ask-unsupported-'))
6907
+ const io = new CapturedIo()
6908
+ const env = {
6909
+ ...isolatedEnv(cwd),
6910
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6911
+ }
6912
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6913
+
6914
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6915
+ const payload = JSON.parse(io.outLines.join('\n')) as { code: string; message: string; remedy: string }
6916
+ expect(payload.code).toBe('question_routing_unavailable')
6917
+ expect(payload.message).toMatch(/no proven continuation owner/i)
6918
+ expect(payload.message).not.toMatch(/hooks are not installed/i)
6919
+ expect(payload.remedy).toMatch(/send --reply/)
6920
+ expect(hooksInstallCommand(deps, { harness: 'hermes', execPath, scriptPath })).toBe(EXIT.usage)
6921
+ expect(io.errLines.join('\n')).toMatch(/Unknown harness "hermes"/)
6922
+ })
6923
+
6924
+ it('fails closed when Hermes nested markers leave session ownership ambiguous', () => {
6925
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-ask-'))
6926
+ const io = new CapturedIo()
6927
+ const env = {
6928
+ ...isolatedEnv(cwd),
6929
+ CLAUDECODE: '1',
6930
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
6931
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6932
+ }
6933
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6934
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
6935
+ writeSessionState('claude-orchestrator', env, {
6936
+ harness: 'claude-code',
6937
+ last_prompt_at: 42,
6938
+ last_stop_at: 41,
6939
+ })
6940
+ writeProjectSession(cwd, env, 'claude-orchestrator', 42, 'claude-code')
6941
+ io.outLines = []
6942
+ io.errLines = []
6943
+
6944
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6945
+ const payload = JSON.parse(io.outLines.join('\n')) as { code: string; message: string }
6946
+ expect(payload.code).toBe('session_identity_ambiguous')
6947
+ expect(payload.message).toMatch(/Several harness sessions could own this shell/i)
6948
+ expect(payload.message).toMatch(/Hermes/)
6949
+ expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
6950
+ })
6951
+
6952
+ it('does not attribute doctor readiness when Hermes nested markers are ambiguous', async () => {
6953
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-doctor-'))
6954
+ const io = new CapturedIo()
6955
+ const env = {
6956
+ ...isolatedEnv(cwd),
6957
+ CLAUDECODE: '1',
6958
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
6959
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6960
+ }
6961
+ const readiness = await assessReadiness({
6962
+ ...makeDeps(io, {} as ApiClient),
6963
+ cwd,
6964
+ env,
6965
+ now: () => 42,
6966
+ })
6967
+ const hooks = readiness.states.find((state) => state.id === 'hooks')
6968
+ expect(hooks?.status).toBe('optional-gap')
6969
+ expect(hooks?.detail).toMatch(/Several harness sessions could own this shell/i)
6970
+ expect(hooks?.detail).toMatch(/Claude Code, Hermes/)
6971
+ expect(hooks?.remedy).toBeUndefined()
6972
+ expect(JSON.stringify(hooks)).not.toContain('hooks install --harness claude-code')
6973
+ })
6974
+
6975
+ it('does not tell an active Hermes session to install managed hooks', async () => {
6976
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-doctor-'))
6977
+ const io = new CapturedIo()
6978
+ const env = {
6979
+ ...isolatedEnv(cwd),
6980
+ HERMES_SESSION_ID: '20260828_111302_29a404',
6981
+ }
6982
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6983
+ const readiness = await assessReadiness(deps)
6984
+ const hooks = readiness.states.find((state) => state.id === 'hooks')
6985
+ expect(hooks?.status).toBe('optional-gap')
6986
+ expect(hooks?.remedy).toBeUndefined()
6987
+ expect(JSON.stringify(hooks)).not.toContain('hooks install --harness hermes')
6988
+ expect(hooks?.detail).toMatch(/no proven continuation owner/i)
6989
+ })
6990
+
6522
6991
  it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
6523
6992
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
6524
6993
  const io = new CapturedIo()
@@ -7314,3 +7783,46 @@ describe('a second device that disagrees', () => {
7314
7783
  expect(contradictingAnswer([])).toBeNull()
7315
7784
  })
7316
7785
  })
7786
+
7787
+ describe('Agent Session rename command', () => {
7788
+ it('renames only the exact active Agent Session and updates its local frozen label', async () => {
7789
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-rename-'))
7790
+ const io = new CapturedIo()
7791
+ const seen: { session_id: string; label: string }[] = []
7792
+ const client = {
7793
+ putAgentSessionLabel: async (body: { session_id: string; label: string }) => {
7794
+ seen.push(body)
7795
+ return {
7796
+ ...body,
7797
+ renamed_by: 'agent' as const,
7798
+ updated_at: '2026-08-28T10:00:00.000Z',
7799
+ }
7800
+ },
7801
+ } as unknown as ApiClient
7802
+ const deps = {
7803
+ ...makeDeps(io, client),
7804
+ cwd,
7805
+ env: {
7806
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
7807
+ XDG_STATE_HOME: path.join(cwd, 'state'),
7808
+ CODEX_THREAD_ID: '01a04789-9589-76b2-82c8-bfe63062c867',
7809
+ },
7810
+ }
7811
+
7812
+ expect(await agentSessionRenameCommand(deps, ' Hermes Runtime Support ')).toBe(EXIT.ok)
7813
+ expect(seen).toEqual([{
7814
+ session_id: '01a04789-9589-76b2-82c8-bfe63062c867',
7815
+ label: 'Hermes Runtime Support',
7816
+ }])
7817
+ expect(io.outLines.join('\n')).toContain('Hermes Runtime Support')
7818
+ })
7819
+
7820
+ it('fails closed when the harness does not expose an exact current Agent Session', async () => {
7821
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-rename-none-'))
7822
+ const io = new CapturedIo()
7823
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
7824
+
7825
+ expect(await agentSessionRenameCommand(deps, 'A new job')).toBe(EXIT.usage)
7826
+ expect(io.errLines.join('\n')).toMatch(/exact active Agent Session/i)
7827
+ })
7828
+ })