@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.
- package/CHANGELOG.md +26 -0
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands-agent-sessions.d.ts +6 -0
- package/dist/commands-agent-sessions.js +51 -0
- package/dist/commands-agent-sessions.js.map +1 -0
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +3 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +42 -4
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +32 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +17 -11
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-send-support.js +18 -1
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +2 -2
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-sounds.d.ts +12 -0
- package/dist/commands-sounds.js +35 -0
- package/dist/commands-sounds.js.map +1 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +5 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -4
- package/dist/config.js +5 -2
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +90 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +2 -2
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -11
- package/dist/install-hooks.js +3 -4
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +10 -0
- package/dist/orca-session-title.js +98 -0
- package/dist/orca-session-title.js.map +1 -0
- package/dist/program.d.ts +3 -1
- package/dist/program.js +28 -5
- package/dist/program.js.map +1 -1
- package/dist/send.js +11 -14
- package/dist/send.js.map +1 -1
- package/dist/session-labels.d.ts +18 -2
- package/dist/session-labels.js +79 -8
- package/dist/session-labels.js.map +1 -1
- package/dist/sound-ref.d.ts +23 -0
- package/dist/sound-ref.js +28 -0
- package/dist/sound-ref.js.map +1 -0
- package/package.json +3 -3
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +3 -0
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hooks.ts +36 -18
- package/src/commands-send-support.ts +23 -1
- package/src/commands-send.ts +2 -2
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +344 -11
- package/src/commands.ts +5 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +5 -2
- package/src/config.test.ts +42 -20
- package/src/config.ts +6 -5
- package/src/credentials.test.ts +20 -0
- package/src/credentials.ts +4 -1
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
- package/src/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hooks.test.ts +4 -3
- package/src/hooks.ts +4 -4
- package/src/install-hooks.test.ts +5 -4
- package/src/install-hooks.ts +19 -21
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +54 -0
- package/src/orca-session-title.ts +103 -0
- package/src/program.test.ts +27 -0
- package/src/program.ts +38 -4
- package/src/send.ts +10 -19
- package/src/session-labels.test.ts +58 -9
- package/src/session-labels.ts +95 -12
- package/src/skill.test.ts +14 -3
- package/src/sound-ref.test.ts +31 -0
- package/src/sound-ref.ts +34 -0
- package/src/ui/config-view.test.ts +1 -0
package/src/commands.test.ts
CHANGED
|
@@ -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('
|
|
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('
|
|
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
|
-
|
|
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
|
|
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', '
|
|
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('
|
|
3750
|
+
expect(io.errLines[0]).toContain('custom sound')
|
|
3550
3751
|
})
|
|
3551
3752
|
|
|
3552
|
-
it('
|
|
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'
|
package/src/config-schema.ts
CHANGED
|
@@ -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: '
|
|
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
|
|
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'
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
|
180
|
+
it('uses the prior default when a newer CLI wrote an unknown interruption level', () => {
|
|
181
181
|
const { env, cwd } = setup({
|
|
182
|
-
globalToml: '
|
|
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: {}
|
|
213
|
-
{ flags: { kind: 'update' }
|
|
214
|
-
{ flags: { kind: 'done' }
|
|
215
|
-
{ flags: { kind: 'failed' }
|
|
216
|
-
{ flags: { kind: 'blocked' }
|
|
217
|
-
{ flags: { kind: 'question' }
|
|
218
|
-
{ flags: { reply: true }
|
|
219
|
-
])('
|
|
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).
|
|
224
|
-
expect(build.draft.platform?.android?.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: '
|
|
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<
|
|
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?:
|
|
394
|
+
sound?: string
|
|
394
395
|
interruption_level?: CliInterruptionLevel
|
|
395
396
|
}
|
|
396
397
|
|