@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.
- package/CHANGELOG.md +40 -0
- package/README.md +1 -1
- 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-auth.js +4 -4
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +5 -0
- package/dist/commands-core.js +2 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +121 -40
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +40 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +139 -16
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +10 -5
- package/dist/commands-init.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 +110 -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 +18 -13
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.js +20 -23
- package/dist/interactive.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/openclaw-plugin.d.ts +49 -0
- package/dist/openclaw-plugin.js +350 -0
- package/dist/openclaw-plugin.js.map +1 -0
- 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 +37 -13
- package/dist/program.js.map +1 -1
- package/dist/send.js +11 -14
- package/dist/send.js.map +1 -1
- package/dist/session-activation.js +1 -1
- package/dist/session-activation.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/dist/ui/help.d.ts +4 -5
- package/dist/ui/help.js +5 -6
- package/dist/ui/help.js.map +1 -1
- 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-auth.ts +4 -3
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +6 -0
- package/src/commands-doctor.ts +143 -38
- package/src/commands-harness-context.ts +56 -2
- package/src/commands-hooks.ts +180 -20
- package/src/commands-init.ts +10 -4
- 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 +529 -17
- 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/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
- package/src/harness-integration.test.ts +294 -0
- package/src/harnesses.test.ts +115 -8
- package/src/harnesses.ts +133 -30
- package/src/hooks.test.ts +4 -3
- package/src/hooks.ts +4 -4
- package/src/install-hooks.test.ts +213 -4
- package/src/install-hooks.ts +38 -30
- package/src/interactive.test.ts +31 -0
- package/src/interactive.ts +20 -26
- package/src/invocation-context.ts +2 -2
- package/src/openclaw-plugin.ts +409 -0
- 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 +47 -12
- package/src/send.ts +10 -19
- package/src/session-activation.ts +1 -1
- 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/ui/help.test.ts +16 -0
- package/src/ui/help.ts +6 -7
package/src/program.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
acknowledgeCommand,
|
|
5
5
|
askCommand,
|
|
6
6
|
accessStatusCommand,
|
|
7
|
+
agentSessionRenameCommand,
|
|
7
8
|
authStatusCommand,
|
|
8
9
|
capabilitiesCommand,
|
|
9
10
|
closeCommand,
|
|
@@ -30,11 +31,12 @@ import {
|
|
|
30
31
|
repliesCommand,
|
|
31
32
|
reportAskFailure,
|
|
32
33
|
sendCommand,
|
|
34
|
+
soundsCommand,
|
|
33
35
|
statusCommand,
|
|
34
36
|
type CommandDeps,
|
|
35
37
|
} from './commands.js'
|
|
36
38
|
import { packageVersion } from './release.js'
|
|
37
|
-
import {
|
|
39
|
+
import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
|
|
38
40
|
import type { Platform } from '@raidiant/notifai-protocol'
|
|
39
41
|
import type { SkillScope } from './native-skills.js'
|
|
40
42
|
import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
|
|
@@ -83,10 +85,12 @@ const defaultRunners = {
|
|
|
83
85
|
authStatus: authStatusCommand,
|
|
84
86
|
accessStatus: accessStatusCommand,
|
|
85
87
|
devices: devicesCommand,
|
|
88
|
+
sounds: soundsCommand,
|
|
86
89
|
capabilities: capabilitiesCommand,
|
|
87
90
|
projectEnable: projectEnableCommand,
|
|
88
91
|
projectDisable: projectDisableCommand,
|
|
89
92
|
projectStatus: projectStatusCommand,
|
|
93
|
+
agentSessionRename: agentSessionRenameCommand,
|
|
90
94
|
send: sendCommand,
|
|
91
95
|
replies: repliesCommand,
|
|
92
96
|
acknowledge: acknowledgeCommand,
|
|
@@ -176,11 +180,12 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
176
180
|
/**
|
|
177
181
|
* Bare `notifai`.
|
|
178
182
|
*
|
|
179
|
-
* At a human terminal this opens the interactive app
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
183
|
+
* At a human terminal this opens the interactive app, which enters `init`
|
|
184
|
+
* when the machine is not yet paired and otherwise offers daily actions.
|
|
185
|
+
* Anywhere else it prints help exactly as before. The check is the same one
|
|
186
|
+
* every other prompt in this CLI is gated on — stdin and stdout both TTYs,
|
|
187
|
+
* not CI, `NOTIFAI_NO_INPUT` unset — because an agent that reaches a prompt
|
|
188
|
+
* does not fail, it hangs for ever waiting on a stdin nobody is typing into.
|
|
184
189
|
*/
|
|
185
190
|
/**
|
|
186
191
|
* An unrecognised subcommand reaches the root as an excess argument, and
|
|
@@ -271,9 +276,24 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
271
276
|
exit(runners.projectStatus(deps, opts.json === true))
|
|
272
277
|
})
|
|
273
278
|
|
|
279
|
+
const session = program
|
|
280
|
+
.command('session')
|
|
281
|
+
.helpGroup(GROUP.agent)
|
|
282
|
+
.summary('Inspect or rename the current Agent Session')
|
|
283
|
+
.description('Operate on the exact Agent Session owned by the active harness')
|
|
284
|
+
session
|
|
285
|
+
.command('rename <label>')
|
|
286
|
+
.description(
|
|
287
|
+
'Rename this exact Agent Session; agents use this only after its job changes completely enough that the old label would mislead',
|
|
288
|
+
)
|
|
289
|
+
.option('--json', 'machine-readable output')
|
|
290
|
+
.action(async (label: string, opts: { json?: boolean }) => {
|
|
291
|
+
exit(await runners.agentSessionRename(deps, label, opts))
|
|
292
|
+
})
|
|
293
|
+
|
|
274
294
|
program
|
|
275
295
|
.command('login')
|
|
276
|
-
.helpGroup(GROUP.
|
|
296
|
+
.helpGroup(GROUP.advanced)
|
|
277
297
|
.summary('Sign in and pair this machine')
|
|
278
298
|
.description('Pair this machine with your Notifai account via browser approval')
|
|
279
299
|
.option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
|
|
@@ -323,6 +343,18 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
323
343
|
exit(await runners.devices(deps, opts))
|
|
324
344
|
})
|
|
325
345
|
|
|
346
|
+
program
|
|
347
|
+
.command('sounds')
|
|
348
|
+
.helpGroup(GROUP.daily)
|
|
349
|
+
.summary('List shipped and Account custom sounds')
|
|
350
|
+
.description(
|
|
351
|
+
'List the sounds --sound and the saved sound key accept: shipped names and this Account\'s custom sounds',
|
|
352
|
+
)
|
|
353
|
+
.option('--json', 'machine-readable output')
|
|
354
|
+
.action(async (opts: { json?: boolean }) => {
|
|
355
|
+
exit(await runners.sounds(deps, opts))
|
|
356
|
+
})
|
|
357
|
+
|
|
326
358
|
program
|
|
327
359
|
.command('capabilities')
|
|
328
360
|
.helpGroup(GROUP.advanced)
|
|
@@ -389,7 +421,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
389
421
|
)
|
|
390
422
|
.option('--multi', 'with --choice, several answers may be selected')
|
|
391
423
|
.optionsGroup(SEND_GROUP.advanced)
|
|
392
|
-
.option(
|
|
424
|
+
.option(
|
|
425
|
+
'--sound <sound>',
|
|
426
|
+
'override saved sound: default | done | attention | alert | none, or a custom name or id from notifai sounds',
|
|
427
|
+
)
|
|
393
428
|
.option(
|
|
394
429
|
'--level <level>',
|
|
395
430
|
'Apple-only interruption level: passive | active | time_sensitive (unsupported with --platform android)',
|
|
@@ -435,7 +470,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
435
470
|
|
|
436
471
|
send.addHelpText(
|
|
437
472
|
'after',
|
|
438
|
-
`\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update
|
|
473
|
+
`\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update Device default.\nAn explicit --sound or saved sound preference outranks that default. --sound accepts a shipped name, an Account custom sound name or id from \`notifai sounds\`, or none. --level is Apple-only; Android attention is owned by kind, product channels, and device settings.\n`,
|
|
439
474
|
)
|
|
440
475
|
|
|
441
476
|
program
|
|
@@ -593,7 +628,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
593
628
|
.option('--owner <name>', 'internal ownership marker')
|
|
594
629
|
.option('--harness <name>', 'internal harness output adapter')
|
|
595
630
|
.action(async (event: string, opts: { harness?: string }) => {
|
|
596
|
-
const harness =
|
|
631
|
+
const harness = HOOK_INSTALLABLE_HARNESSES.find((candidate) => candidate === opts.harness)
|
|
597
632
|
exit(await runners.hookRun(deps, event, () => readStdinWithTimeout(), harness))
|
|
598
633
|
})
|
|
599
634
|
|
|
@@ -607,7 +642,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
607
642
|
.description('Wire this harness to route registered questions to your devices')
|
|
608
643
|
.option(
|
|
609
644
|
'--harness <name>',
|
|
610
|
-
'claude-code | codex | cursor | opencode (default: every detected harness)',
|
|
645
|
+
'claude-code | codex | cursor | opencode | openclaw (default: every detected harness)',
|
|
611
646
|
)
|
|
612
647
|
.option('--global', 'install for every project instead of just this one')
|
|
613
648
|
.action((opts: { harness?: string; global?: boolean }) => {
|
|
@@ -616,7 +651,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
616
651
|
hooks
|
|
617
652
|
.command('uninstall')
|
|
618
653
|
.description('Remove the hooks this CLI installed')
|
|
619
|
-
.option('--harness <name>', 'claude-code | codex | cursor | opencode (default: detected)')
|
|
654
|
+
.option('--harness <name>', 'claude-code | codex | cursor | opencode | openclaw (default: detected)')
|
|
620
655
|
.option('--global', 'remove the machine-wide install')
|
|
621
656
|
.action((opts: { harness?: string; global?: boolean }) => {
|
|
622
657
|
exit(runners.hooksUninstall(deps, opts))
|
package/src/send.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
CLI_SOUNDS,
|
|
3
2
|
COLLAPSE_KEY_MAX_BYTES,
|
|
4
3
|
INTERRUPTION_LEVELS,
|
|
5
4
|
MEDIA_MAX_ITEMS,
|
|
@@ -13,9 +12,6 @@ import {
|
|
|
13
12
|
type MediaItemT,
|
|
14
13
|
type NotificationDraftT,
|
|
15
14
|
NOTIFICATION_KINDS,
|
|
16
|
-
DEFAULT_NOTIFICATION_KIND,
|
|
17
|
-
defaultSoundForKind,
|
|
18
|
-
type NotificationKind,
|
|
19
15
|
PLATFORMS,
|
|
20
16
|
type Platform,
|
|
21
17
|
type QuestionT,
|
|
@@ -23,8 +19,7 @@ import {
|
|
|
23
19
|
type SubmissionReceipt,
|
|
24
20
|
} from '@raidiant/notifai-protocol'
|
|
25
21
|
import type { CliConfig } from './config.js'
|
|
26
|
-
|
|
27
|
-
type CliSound = (typeof CLI_SOUNDS)[number]
|
|
22
|
+
import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
|
|
28
23
|
|
|
29
24
|
export interface SendFlags {
|
|
30
25
|
title: string
|
|
@@ -217,18 +212,15 @@ export function buildDraft(
|
|
|
217
212
|
}
|
|
218
213
|
}
|
|
219
214
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
: ((flags.kind as NotificationKind | undefined) ?? DEFAULT_NOTIFICATION_KIND)
|
|
227
|
-
const sound = flags.sound ?? config.sound.value ?? defaultSoundForKind(kindForSound)
|
|
228
|
-
if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
|
|
215
|
+
// Explicit --sound and the saved sound key outrank kind. The server is the
|
|
216
|
+
// one that applies kind, Project, and Account maps — stamping a kind default
|
|
217
|
+
// here would look like an explicit choice and skip those layers. `none` is
|
|
218
|
+
// still silent.
|
|
219
|
+
const sound = flags.sound !== undefined ? flags.sound : config.sound.value
|
|
220
|
+
if (sound !== null && sound !== undefined && !isCliSoundRef(sound)) {
|
|
229
221
|
return {
|
|
230
222
|
ok: false,
|
|
231
|
-
error:
|
|
223
|
+
error: unknownSoundMessage(sound),
|
|
232
224
|
}
|
|
233
225
|
}
|
|
234
226
|
const level =
|
|
@@ -254,9 +246,8 @@ export function buildDraft(
|
|
|
254
246
|
appleOptions.sound = null
|
|
255
247
|
androidOptions.sound = null
|
|
256
248
|
} else if (sound !== null && sound !== undefined) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
androidOptions.sound = semanticSound
|
|
249
|
+
appleOptions.sound = sound
|
|
250
|
+
androidOptions.sound = sound
|
|
260
251
|
}
|
|
261
252
|
if (flags.threadId !== undefined) {
|
|
262
253
|
appleOptions.thread_id = flags.threadId
|
|
@@ -31,7 +31,7 @@ export function sessionActivationOutput(
|
|
|
31
31
|
const context = hookEventName === 'SubagentStart'
|
|
32
32
|
? WORKER_ACTIVATION_CONTEXT
|
|
33
33
|
: rootActivationContext(cwd, env)
|
|
34
|
-
if (harness === 'opencode') return context
|
|
34
|
+
if (harness === 'opencode' || harness === 'openclaw') return context
|
|
35
35
|
if (harness === 'cursor') {
|
|
36
36
|
return JSON.stringify({ additional_context: context })
|
|
37
37
|
}
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
2
9
|
import os from 'node:os'
|
|
3
10
|
import path from 'node:path'
|
|
4
11
|
import { describe, expect, it } from 'vitest'
|
|
5
12
|
import { stateDir } from './config.js'
|
|
6
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
formatSessionFirstSeen,
|
|
15
|
+
renameStoredSessionLabel,
|
|
16
|
+
resolveSessionLabel,
|
|
17
|
+
} from './session-labels.js'
|
|
7
18
|
|
|
8
19
|
function fixture(): { env: NodeJS.ProcessEnv; now: number } {
|
|
9
20
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-labels-'))
|
|
@@ -416,26 +427,64 @@ describe('semantic session labels', () => {
|
|
|
416
427
|
})
|
|
417
428
|
})
|
|
418
429
|
|
|
419
|
-
it('
|
|
430
|
+
it('isolates an unreadable name store before creating a clean one', () => {
|
|
420
431
|
const { env, now } = fixture()
|
|
421
432
|
const file = path.join(stateDir(env), 'session-labels.json')
|
|
422
433
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
423
434
|
writeFileSync(file, '{not json}\n')
|
|
424
435
|
|
|
425
|
-
|
|
436
|
+
expect(
|
|
437
|
+
resolveSessionLabel({
|
|
426
438
|
env,
|
|
427
439
|
now,
|
|
428
440
|
sessionId: 'new-session',
|
|
429
441
|
explicitLabel: 'Do not overwrite me',
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
expect(
|
|
433
|
-
|
|
434
|
-
|
|
442
|
+
}),
|
|
443
|
+
).toEqual({ ok: true, label: 'Do not overwrite me', source: 'explicit' })
|
|
444
|
+
expect(readFileSync(file, 'utf8')).not.toBe('{not json}\n')
|
|
445
|
+
const backups = readdirSync(path.dirname(file)).filter((name) =>
|
|
446
|
+
/^session-labels\.invalid-[a-f0-9]{64}\.json$/u.test(name),
|
|
447
|
+
)
|
|
448
|
+
expect(backups).toHaveLength(1)
|
|
449
|
+
expect(readFileSync(path.join(path.dirname(file), backups[0]!), 'utf8')).toBe('{not json}\n')
|
|
435
450
|
})
|
|
436
451
|
|
|
437
452
|
it('formats first seen using the machine local calendar', () => {
|
|
438
453
|
const now = new Date(2026, 0, 2, 3, 4).getTime()
|
|
439
454
|
expect(formatSessionFirstSeen(now)).toBe('Jan 2, 2026 03:04')
|
|
440
455
|
})
|
|
456
|
+
|
|
457
|
+
it('explicitly replaces a semantic or fallback label after an authorized rename', () => {
|
|
458
|
+
const { env, now } = fixture()
|
|
459
|
+
resolveSessionLabel({
|
|
460
|
+
env,
|
|
461
|
+
now,
|
|
462
|
+
sessionId: 'rename-me',
|
|
463
|
+
harness: 'codex',
|
|
464
|
+
explicitLabel: 'Old job',
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
expect(
|
|
468
|
+
renameStoredSessionLabel({
|
|
469
|
+
env,
|
|
470
|
+
now: now + 1_000,
|
|
471
|
+
sessionId: 'rename-me',
|
|
472
|
+
harness: 'codex',
|
|
473
|
+
label: ' Completely different job ',
|
|
474
|
+
}),
|
|
475
|
+
).toEqual({ ok: true, label: 'Completely different job', source: 'explicit' })
|
|
476
|
+
expect(
|
|
477
|
+
resolveSessionLabel({
|
|
478
|
+
env,
|
|
479
|
+
now: now + 2_000,
|
|
480
|
+
sessionId: 'rename-me',
|
|
481
|
+
harness: 'codex',
|
|
482
|
+
harnessLabel: 'Later task context',
|
|
483
|
+
}),
|
|
484
|
+
).toEqual({
|
|
485
|
+
ok: true,
|
|
486
|
+
label: 'Completely different job',
|
|
487
|
+
source: 'explicit',
|
|
488
|
+
})
|
|
489
|
+
})
|
|
441
490
|
})
|
package/src/session-labels.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { SESSION_LABEL_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
5
5
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
6
6
|
import { stateDir } from './config.js'
|
|
7
7
|
import { withFileLock } from './file-lock.js'
|
|
8
8
|
import { generatedSessionLabel } from './generated-session-label.js'
|
|
9
|
-
import { HARNESS_LABELS, type
|
|
9
|
+
import { HARNESS_LABELS, type SourceContextHarness } from './harnesses.js'
|
|
10
10
|
|
|
11
11
|
const STORE_VERSION = 1
|
|
12
12
|
const MONTHS = [
|
|
@@ -35,7 +35,7 @@ interface StoredSessionLabel {
|
|
|
35
35
|
label: string
|
|
36
36
|
source: SessionLabelSource
|
|
37
37
|
first_seen_at: number
|
|
38
|
-
harness?:
|
|
38
|
+
harness?: SourceContextHarness
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
interface SessionLabelStore {
|
|
@@ -46,13 +46,21 @@ interface SessionLabelStore {
|
|
|
46
46
|
export interface SessionLabelInput {
|
|
47
47
|
env: NodeJS.ProcessEnv
|
|
48
48
|
sessionId: string
|
|
49
|
-
harness?:
|
|
49
|
+
harness?: SourceContextHarness
|
|
50
50
|
explicitLabel?: string
|
|
51
51
|
harnessLabel?: string
|
|
52
52
|
harnessLabelPending?: boolean
|
|
53
53
|
now?: number
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface SessionLabelRenameInput {
|
|
57
|
+
env: NodeJS.ProcessEnv
|
|
58
|
+
sessionId: string
|
|
59
|
+
label: string
|
|
60
|
+
harness?: SourceContextHarness
|
|
61
|
+
now?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
export type SessionLabelResolution =
|
|
57
65
|
| { ok: true; label: string; source: SessionLabelSource }
|
|
58
66
|
| { ok: false; error: string }
|
|
@@ -70,6 +78,24 @@ function emptyStore(): SessionLabelStore {
|
|
|
70
78
|
return { version: STORE_VERSION, sessions: {} }
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
function quarantineInvalidStore(file: string, raw: string): void {
|
|
82
|
+
const digest = createHash('sha256').update(raw).digest('hex')
|
|
83
|
+
const backup = path.join(path.dirname(file), `session-labels.invalid-${digest}.json`)
|
|
84
|
+
if (existsSync(backup)) {
|
|
85
|
+
if (readFileSync(backup, 'utf8') !== raw) {
|
|
86
|
+
throw new Error('the session-name store recovery backup does not match its content hash')
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
copyFileSync(file, backup)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function recoverInvalidStore(file: string, raw: string, store: SessionLabelStore): SessionLabelStore {
|
|
94
|
+
quarantineInvalidStore(file, raw)
|
|
95
|
+
writeStore(file, store)
|
|
96
|
+
return store
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
function storedRecord(candidate: unknown): StoredSessionLabel | null {
|
|
74
100
|
if (typeof candidate !== 'object' || candidate === null) return null
|
|
75
101
|
const value = candidate as Partial<StoredSessionLabel>
|
|
@@ -95,33 +121,42 @@ function storedRecord(candidate: unknown): StoredSessionLabel | null {
|
|
|
95
121
|
|
|
96
122
|
function readStore(file: string): SessionLabelStore {
|
|
97
123
|
if (!existsSync(file)) return emptyStore()
|
|
98
|
-
let
|
|
124
|
+
let rawText: string
|
|
99
125
|
try {
|
|
100
|
-
|
|
126
|
+
rawText = readFileSync(file, 'utf8')
|
|
101
127
|
} catch (err) {
|
|
102
128
|
throw new Error(
|
|
103
129
|
`the session-name store is unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
104
130
|
)
|
|
105
131
|
}
|
|
132
|
+
let parsed: unknown
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(rawText)
|
|
135
|
+
} catch {
|
|
136
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
137
|
+
}
|
|
106
138
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
107
|
-
|
|
139
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
108
140
|
}
|
|
109
141
|
const raw = parsed as { version?: unknown; sessions?: unknown }
|
|
110
142
|
if (raw.version !== STORE_VERSION) {
|
|
111
|
-
|
|
143
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
112
144
|
}
|
|
113
145
|
if (typeof raw.sessions !== 'object' || raw.sessions === null) {
|
|
114
|
-
|
|
146
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
115
147
|
}
|
|
116
148
|
const sessions: Record<string, StoredSessionLabel> = {}
|
|
149
|
+
let invalidRecordFound = false
|
|
117
150
|
for (const [key, candidate] of Object.entries(raw.sessions)) {
|
|
118
151
|
const record = storedRecord(candidate)
|
|
119
152
|
if (!/^[a-f0-9]{64}$/.test(key) || record === null) {
|
|
120
|
-
|
|
153
|
+
invalidRecordFound = true
|
|
154
|
+
continue
|
|
121
155
|
}
|
|
122
156
|
sessions[key] = record
|
|
123
157
|
}
|
|
124
|
-
|
|
158
|
+
const store = { version: STORE_VERSION, sessions } satisfies SessionLabelStore
|
|
159
|
+
return invalidRecordFound ? recoverInvalidStore(file, rawText, store) : store
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
function writeStore(file: string, store: SessionLabelStore): void {
|
|
@@ -203,6 +238,20 @@ function explicitCandidate(
|
|
|
203
238
|
return { label: normalized, source: 'explicit' }
|
|
204
239
|
}
|
|
205
240
|
|
|
241
|
+
export function normalizeExplicitSessionLabel(
|
|
242
|
+
input: Pick<SessionLabelRenameInput, 'env' | 'sessionId' | 'label'>,
|
|
243
|
+
): { ok: true; label: string } | { ok: false; error: string } {
|
|
244
|
+
const candidate = explicitCandidate(input.label, {
|
|
245
|
+
env: input.env,
|
|
246
|
+
sessionId: input.sessionId,
|
|
247
|
+
})
|
|
248
|
+
if (candidate === null) {
|
|
249
|
+
return { ok: false, error: '--session-label must not be empty.' }
|
|
250
|
+
}
|
|
251
|
+
if ('error' in candidate) return { ok: false, error: candidate.error }
|
|
252
|
+
return { ok: true, label: candidate.label }
|
|
253
|
+
}
|
|
254
|
+
|
|
206
255
|
function harnessLabelIsPending(
|
|
207
256
|
value: string | undefined,
|
|
208
257
|
input: SessionLabelInput,
|
|
@@ -260,7 +309,7 @@ function collisionKey(value: string): string {
|
|
|
260
309
|
function uniqueLabel(
|
|
261
310
|
base: string,
|
|
262
311
|
source: SessionLabelSource,
|
|
263
|
-
harness:
|
|
312
|
+
harness: SourceContextHarness | undefined,
|
|
264
313
|
now: number,
|
|
265
314
|
used: ReadonlySet<string>,
|
|
266
315
|
): string {
|
|
@@ -389,3 +438,37 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
|
|
|
389
438
|
}
|
|
390
439
|
}
|
|
391
440
|
}
|
|
441
|
+
|
|
442
|
+
/** Replace the local frozen label only after the Account-authoritative rename succeeds. */
|
|
443
|
+
export function renameStoredSessionLabel(
|
|
444
|
+
input: SessionLabelRenameInput,
|
|
445
|
+
): SessionLabelResolution {
|
|
446
|
+
const normalized = normalizeExplicitSessionLabel(input)
|
|
447
|
+
if (!normalized.ok) return normalized
|
|
448
|
+
const file = storePath(input.env)
|
|
449
|
+
const key = sessionKey(input.sessionId)
|
|
450
|
+
const now = input.now ?? Date.now()
|
|
451
|
+
try {
|
|
452
|
+
return withFileLock(`${file}.lock`, () => {
|
|
453
|
+
const store = readStore(file)
|
|
454
|
+
const existing = store.sessions[key]
|
|
455
|
+
store.sessions[key] = {
|
|
456
|
+
label: normalized.label,
|
|
457
|
+
source: 'explicit',
|
|
458
|
+
first_seen_at: existing?.first_seen_at ?? now,
|
|
459
|
+
...(input.harness === undefined
|
|
460
|
+
? existing?.harness === undefined
|
|
461
|
+
? {}
|
|
462
|
+
: { harness: existing.harness }
|
|
463
|
+
: { harness: input.harness }),
|
|
464
|
+
}
|
|
465
|
+
writeStore(file, store)
|
|
466
|
+
return { ok: true, label: normalized.label, source: 'explicit' }
|
|
467
|
+
})
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
error: `The Account label changed, but this machine could not save it locally: ${err instanceof Error ? err.message : String(err)}`,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
package/src/skill.test.ts
CHANGED
|
@@ -225,7 +225,7 @@ describe('Notifai agent skill', () => {
|
|
|
225
225
|
expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
|
|
226
226
|
})
|
|
227
227
|
|
|
228
|
-
it('
|
|
228
|
+
it('keeps initial labels stable and teaches the narrow exact-session rename exception', () => {
|
|
229
229
|
const send = section('## Send')
|
|
230
230
|
expect(send).toContain('--session-label')
|
|
231
231
|
expect(send).toMatch(/2-6 words/i)
|
|
@@ -239,6 +239,11 @@ describe('Notifai agent skill', () => {
|
|
|
239
239
|
expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
|
|
240
240
|
expect(send).toMatch(/never pass\s+`--session-id`/i)
|
|
241
241
|
expect(send).toMatch(/identifier, hash, or\s+filesystem path/i)
|
|
242
|
+
expect(send).toContain('notifai session rename')
|
|
243
|
+
expect(send).toMatch(/job changed completely[\s\S]{0,100}current\s+name would now mislead/i)
|
|
244
|
+
expect(send).toMatch(/never rename for milestones, ordinary progress/i)
|
|
245
|
+
expect(send).toMatch(/accepts no Agent Session id/i)
|
|
246
|
+
expect(send).toMatch(/active harness proves the exact current Agent Session/i)
|
|
242
247
|
})
|
|
243
248
|
|
|
244
249
|
it('never teaches by enumerating environments the CLI can detect itself', () => {
|
|
@@ -247,7 +252,7 @@ describe('Notifai agent skill', () => {
|
|
|
247
252
|
// Harness names may appear only in setup/routing material, where the
|
|
248
253
|
// harness IS the subject.
|
|
249
254
|
for (const heading of ['## Decide whether to notify', '## Send', '## Ask a question', '## When the answer arrives']) {
|
|
250
|
-
expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex/)
|
|
255
|
+
expect(section(heading), heading).not.toMatch(/OpenCode|OpenClaw|Orca|Cursor|Codex|Hermes/)
|
|
251
256
|
}
|
|
252
257
|
})
|
|
253
258
|
|
|
@@ -383,6 +388,7 @@ describe('Notifai agent skill', () => {
|
|
|
383
388
|
'notifai guidance',
|
|
384
389
|
'notifai logs',
|
|
385
390
|
'notifai status',
|
|
391
|
+
'notifai sounds',
|
|
386
392
|
]) {
|
|
387
393
|
expect(skill, `${command} is unreachable from the skill`).toContain(command)
|
|
388
394
|
}
|
|
@@ -391,9 +397,14 @@ describe('Notifai agent skill', () => {
|
|
|
391
397
|
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
392
398
|
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
393
399
|
expect(skill).toMatch(/read it when you are\s+installing hooks or diagnosing routing/i)
|
|
394
|
-
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
|
400
|
+
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode', 'OpenClaw', 'Hermes']) {
|
|
395
401
|
expect(harnessReference).toContain(`**${harness}:**`)
|
|
396
402
|
}
|
|
403
|
+
expect(harnessReference).toMatch(/exact `HERMES_SESSION_ID`/i)
|
|
404
|
+
expect(harnessReference).toMatch(/Do not run `notifai hooks install --harness hermes`/i)
|
|
405
|
+
expect(harnessReference).toMatch(/blocking `notifai send --reply`/i)
|
|
406
|
+
expect(harnessReference).toMatch(/npx skills/i)
|
|
407
|
+
expect(harnessReference).toMatch(/no Hermes plugin may vendor/i)
|
|
397
408
|
// The agent runs what a process can run; the human only does the human part.
|
|
398
409
|
expect(harnessReference).toMatch(/let init install and assess\s+the hooks/i)
|
|
399
410
|
expect(harnessReference).toContain('--setup-scope <project|global>')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { SOUND_NAME_MAX_LENGTH, SOUND_REF_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
3
|
+
import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
|
|
4
|
+
|
|
5
|
+
describe('isCliSoundRef', () => {
|
|
6
|
+
it('accepts shipped names and the silent spelling', () => {
|
|
7
|
+
for (const ref of ['default', 'done', 'attention', 'alert', 'none']) {
|
|
8
|
+
expect(isCliSoundRef(ref), ref).toBe(true)
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('accepts an Account custom sound id and a display name', () => {
|
|
13
|
+
expect(isCliSoundRef('snd_01HQTEST')).toBe(true)
|
|
14
|
+
expect(isCliSoundRef('Kitchen timer')).toBe(true)
|
|
15
|
+
expect(isCliSoundRef('airhorn')).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('rejects empty, blank, and over-long values', () => {
|
|
19
|
+
expect(isCliSoundRef('')).toBe(false)
|
|
20
|
+
expect(isCliSoundRef(' ')).toBe(false)
|
|
21
|
+
expect(isCliSoundRef('n'.repeat(SOUND_NAME_MAX_LENGTH + 1))).toBe(false)
|
|
22
|
+
expect(isCliSoundRef(`snd_${'a'.repeat(SOUND_REF_MAX_LENGTH)}`)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('unknownSoundMessage', () => {
|
|
27
|
+
it('points at the list command rather than only the shipped set', () => {
|
|
28
|
+
expect(unknownSoundMessage('nope')).toContain('notifai sounds')
|
|
29
|
+
expect(unknownSoundMessage('nope')).toContain('custom sound')
|
|
30
|
+
})
|
|
31
|
+
})
|
package/src/sound-ref.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_SOUNDS,
|
|
3
|
+
SOUND_NAME_MAX_LENGTH,
|
|
4
|
+
SOUND_REF_MAX_LENGTH,
|
|
5
|
+
isCustomSoundId,
|
|
6
|
+
} from '@raidiant/notifai-protocol'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What `--sound` and the saved `sound` key accept: shipped semantic names,
|
|
10
|
+
* the CLI silent spelling, an Account custom sound id, or a custom display name.
|
|
11
|
+
*/
|
|
12
|
+
export function isCliSoundRef(value: string): boolean {
|
|
13
|
+
if ((CLI_SOUNDS as readonly string[]).includes(value)) return true
|
|
14
|
+
if (value.length < 1 || value.length > SOUND_REF_MAX_LENGTH) return false
|
|
15
|
+
if (isCustomSoundId(value)) return true
|
|
16
|
+
const trimmed = value.trim()
|
|
17
|
+
return trimmed.length >= 1 && trimmed.length <= SOUND_NAME_MAX_LENGTH
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function unknownSoundMessage(sound: string): string {
|
|
21
|
+
return (
|
|
22
|
+
`Unknown sound "${sound}" — use a shipped name (${CLI_SOUNDS.join(', ')}), ` +
|
|
23
|
+
'a custom sound id, or a custom sound name. Run `notifai sounds` to list what this Account can play.'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Shipped names plus the CLI silent spelling, with User-facing labels. */
|
|
28
|
+
export const SHIPPED_SOUND_LISTING = [
|
|
29
|
+
{ ref: 'default', name: 'Device default' },
|
|
30
|
+
{ ref: 'done', name: 'completion chime' },
|
|
31
|
+
{ ref: 'attention', name: 'attention tone' },
|
|
32
|
+
{ ref: 'alert', name: 'most insistent tone' },
|
|
33
|
+
{ ref: 'none', name: 'silent' },
|
|
34
|
+
] as const
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { rootHelpFooter } from './help.js'
|
|
3
|
+
|
|
4
|
+
function stripAnsi(text: string): string {
|
|
5
|
+
return text.replace(/\u001B\[[0-9;]*m/g, '')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('rootHelpFooter', () => {
|
|
9
|
+
it('leads first-run with init, not the interactive app', () => {
|
|
10
|
+
const footer = stripAnsi(rootHelpFooter())
|
|
11
|
+
const start = footer.slice(footer.indexOf('Start here'))
|
|
12
|
+
const firstCommand = start.match(/notifai(?: init)?/)?.[0]
|
|
13
|
+
expect(firstCommand).toBe('notifai init')
|
|
14
|
+
expect(start.indexOf('notifai init')).toBeLessThan(start.search(/notifai\s{2,}/))
|
|
15
|
+
})
|
|
16
|
+
})
|