@raidiant/notifai 9.3.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/client.d.ts +3 -1
  3. package/dist/client.js +2 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/commands-agent-sessions.d.ts +6 -0
  6. package/dist/commands-agent-sessions.js +51 -0
  7. package/dist/commands-agent-sessions.js.map +1 -0
  8. package/dist/commands-config.js +11 -4
  9. package/dist/commands-config.js.map +1 -1
  10. package/dist/commands-core.d.ts +3 -0
  11. package/dist/commands-core.js.map +1 -1
  12. package/dist/commands-doctor.js +42 -4
  13. package/dist/commands-doctor.js.map +1 -1
  14. package/dist/commands-harness-context.d.ts +15 -2
  15. package/dist/commands-harness-context.js +32 -1
  16. package/dist/commands-harness-context.js.map +1 -1
  17. package/dist/commands-hooks.d.ts +4 -3
  18. package/dist/commands-hooks.js +17 -11
  19. package/dist/commands-hooks.js.map +1 -1
  20. package/dist/commands-send-support.js +18 -1
  21. package/dist/commands-send-support.js.map +1 -1
  22. package/dist/commands-send.js +2 -2
  23. package/dist/commands-send.js.map +1 -1
  24. package/dist/commands-sounds.d.ts +12 -0
  25. package/dist/commands-sounds.js +35 -0
  26. package/dist/commands-sounds.js.map +1 -0
  27. package/dist/commands.d.ts +2 -0
  28. package/dist/commands.js +2 -0
  29. package/dist/commands.js.map +1 -1
  30. package/dist/config-schema.js +5 -2
  31. package/dist/config-schema.js.map +1 -1
  32. package/dist/config.d.ts +3 -4
  33. package/dist/config.js +5 -2
  34. package/dist/config.js.map +1 -1
  35. package/dist/credentials.js +4 -1
  36. package/dist/credentials.js.map +1 -1
  37. package/dist/harnesses.d.ts +49 -7
  38. package/dist/harnesses.js +90 -24
  39. package/dist/harnesses.js.map +1 -1
  40. package/dist/hooks.d.ts +2 -2
  41. package/dist/hooks.js +2 -2
  42. package/dist/hooks.js.map +1 -1
  43. package/dist/install-hooks.d.ts +10 -11
  44. package/dist/install-hooks.js +3 -4
  45. package/dist/install-hooks.js.map +1 -1
  46. package/dist/invocation-context.d.ts +2 -2
  47. package/dist/orca-session-title.d.ts +10 -0
  48. package/dist/orca-session-title.js +98 -0
  49. package/dist/orca-session-title.js.map +1 -0
  50. package/dist/program.d.ts +3 -1
  51. package/dist/program.js +28 -5
  52. package/dist/program.js.map +1 -1
  53. package/dist/send.js +11 -14
  54. package/dist/send.js.map +1 -1
  55. package/dist/session-labels.d.ts +18 -2
  56. package/dist/session-labels.js +79 -8
  57. package/dist/session-labels.js.map +1 -1
  58. package/dist/sound-ref.d.ts +23 -0
  59. package/dist/sound-ref.js +28 -0
  60. package/dist/sound-ref.js.map +1 -0
  61. package/package.json +3 -3
  62. package/src/client.test.ts +12 -0
  63. package/src/client.ts +8 -0
  64. package/src/commands-agent-sessions.ts +72 -0
  65. package/src/commands-config.ts +10 -4
  66. package/src/commands-core.ts +3 -0
  67. package/src/commands-doctor.ts +48 -4
  68. package/src/commands-harness-context.ts +48 -2
  69. package/src/commands-hooks.ts +36 -18
  70. package/src/commands-send-support.ts +23 -1
  71. package/src/commands-send.ts +2 -2
  72. package/src/commands-sounds.test.ts +106 -0
  73. package/src/commands-sounds.ts +49 -0
  74. package/src/commands.test.ts +344 -11
  75. package/src/commands.ts +5 -0
  76. package/src/config-schema.test.ts +1 -0
  77. package/src/config-schema.ts +5 -2
  78. package/src/config.test.ts +42 -20
  79. package/src/config.ts +6 -5
  80. package/src/credentials.test.ts +20 -0
  81. package/src/credentials.ts +4 -1
  82. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  83. package/src/harness-integration.test.ts +212 -0
  84. package/src/harnesses.test.ts +107 -8
  85. package/src/harnesses.ts +111 -30
  86. package/src/hooks.test.ts +4 -3
  87. package/src/hooks.ts +4 -4
  88. package/src/install-hooks.test.ts +5 -4
  89. package/src/install-hooks.ts +19 -21
  90. package/src/invocation-context.ts +2 -2
  91. package/src/orca-session-title.test.ts +54 -0
  92. package/src/orca-session-title.ts +103 -0
  93. package/src/program.test.ts +27 -0
  94. package/src/program.ts +38 -4
  95. package/src/send.ts +10 -19
  96. package/src/session-labels.test.ts +58 -9
  97. package/src/session-labels.ts +95 -12
  98. package/src/skill.test.ts +14 -3
  99. package/src/sound-ref.test.ts +31 -0
  100. package/src/sound-ref.ts +34 -0
  101. package/src/ui/config-view.test.ts +1 -0
@@ -27,9 +27,9 @@ import {
27
27
  WORKER_ACTIVATION_CONTEXT,
28
28
  } from './session-activation.js'
29
29
  import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
30
+ import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
30
31
  import {
31
32
  QUESTION_STOP_TIMEOUT_SECONDS,
32
- HARNESSES,
33
33
  applyPlan,
34
34
  detectHarness,
35
35
  detectedHarnesses,
@@ -1045,7 +1045,8 @@ describe('the OpenCode adapter', () => {
1045
1045
  })
1046
1046
 
1047
1047
  it('is a harness `hooks install` knows about', () => {
1048
- expect(HARNESSES).toContain('opencode')
1048
+ expect(HOOK_INSTALLABLE_HARNESSES).toContain('opencode')
1049
+ expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
1049
1050
  expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
1050
1051
  })
1051
1052
 
@@ -1099,7 +1100,7 @@ describe('detectHarness', () => {
1099
1100
  const dir = project('AGENTS.md')
1100
1101
  const answer = detectHarness(dir)
1101
1102
  expect(answer).not.toBe('codex')
1102
- expect(HARNESSES.includes(answer as never) || answer === null).toBe(true)
1103
+ expect(HOOK_INSTALLABLE_HARNESSES.includes(answer as never) || answer === null).toBe(true)
1103
1104
  })
1104
1105
 
1105
1106
  it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
@@ -1155,7 +1156,7 @@ describe('detectHarness', () => {
1155
1156
  // Whatever this machine has, an empty project must never crash and must
1156
1157
  // return either a single harness or null — never an arbitrary pick.
1157
1158
  const answer = detectHarness(empty)
1158
- expect(answer === null || HARNESSES.includes(answer)).toBe(true)
1159
+ expect(answer === null || HOOK_INSTALLABLE_HARNESSES.includes(answer)).toBe(true)
1159
1160
  })
1160
1161
  })
1161
1162
 
@@ -20,15 +20,13 @@ import {
20
20
  type HookHostPlatform,
21
21
  } from './hook-adapter.js'
22
22
  import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
23
- import { HARNESSES, type Harness } from './harnesses.js'
23
+ import { HOOK_INSTALLABLE_HARNESSES, type HookInstallableHarness } from './harnesses.js'
24
24
  import { accountHome } from './platform.js'
25
25
  import {
26
26
  NON_ROUTING_STOP_TIMEOUT_SECONDS,
27
27
  QUESTION_STOP_TIMEOUT_SECONDS,
28
28
  } from './question-timing.js'
29
29
 
30
- export { HARNESSES, type Harness } from './harnesses.js'
31
-
32
30
  /**
33
31
  * The three joints the OpenCode plugin hooks into, as (harness event, the
34
32
  * `notifai hook` event it runs). The plugin is a module and has no settings
@@ -73,7 +71,7 @@ export interface HookGroup {
73
71
  export type HookConfig = Record<string, HookGroup[]>
74
72
 
75
73
  export interface InstallPlan {
76
- harness: Harness
74
+ harness: HookInstallableHarness
77
75
  /** Absolute path of the settings file that will be written. */
78
76
  file: string
79
77
  /** True when the file is machine-wide rather than per-project. */
@@ -97,7 +95,7 @@ export interface HookCommandOptions {
97
95
  export function hookCommand(
98
96
  adapterPath: string,
99
97
  event: string,
100
- harness?: Harness,
98
+ harness?: HookInstallableHarness,
101
99
  options: HookCommandOptions = {},
102
100
  ): string {
103
101
  return (
@@ -174,7 +172,7 @@ export interface BuildOptions {
174
172
  /** Stable user-level executable installed by the hook adapter module. */
175
173
  adapterPath: string
176
174
  /** The installed adapter stamps its exact harness into project pointers. */
177
- harness?: Harness
175
+ harness?: HookInstallableHarness
178
176
  platform?: NodeJS.Platform | HookHostPlatform
179
177
  /** Registered Node named first in Windows hook commands. */
180
178
  nodePath?: string
@@ -195,7 +193,7 @@ export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
195
193
  * `question-timing.ts`.
196
194
  */
197
195
  export function stopHandlerIsDetached(
198
- harness: Harness | undefined,
196
+ harness: HookInstallableHarness | undefined,
199
197
  platform?: Parameters<typeof hookHostPlatform>[0],
200
198
  ): boolean {
201
199
  return harness === 'claude-code' && hookHostPlatform(platform) === 'posix'
@@ -213,7 +211,7 @@ export function stopHandlerIsDetached(
213
211
  */
214
212
  function stopHandler(
215
213
  adapterPath: string,
216
- harness: Harness | undefined,
214
+ harness: HookInstallableHarness | undefined,
217
215
  options: HookCommandOptions,
218
216
  ): HookHandler {
219
217
  const command = hookCommand(adapterPath, 'stop', harness, options)
@@ -355,7 +353,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
355
353
  * questions pushed to is not a property of the repository.
356
354
  */
357
355
  export function settingsFile(
358
- harness: Harness,
356
+ harness: HookInstallableHarness,
359
357
  global: boolean,
360
358
  cwd: string,
361
359
  env: NodeJS.ProcessEnv = process.env,
@@ -606,7 +604,7 @@ export function withCodexLayerTransaction<T>(
606
604
 
607
605
  /** Every file in this layer Codex might already be reading hooks from. */
608
606
  export function hookDefinitionFiles(
609
- harness: Harness,
607
+ harness: HookInstallableHarness,
610
608
  global: boolean,
611
609
  cwd: string,
612
610
  env: NodeJS.ProcessEnv = process.env,
@@ -741,8 +739,8 @@ function ourHandlerEvents(document: SettingsDocument | null): string[] {
741
739
  * document. Treating it as evidence of any one harness would be a guess
742
740
  * dressed up as detection.
743
741
  */
744
- function localHarnessEvidence(cwd: string): Harness[] {
745
- const found: Harness[] = []
742
+ function localHarnessEvidence(cwd: string): HookInstallableHarness[] {
743
+ const found: HookInstallableHarness[] = []
746
744
  if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
747
745
  found.push('claude-code')
748
746
  }
@@ -756,9 +754,9 @@ function localHarnessEvidence(cwd: string): Harness[] {
756
754
  function globalHarnessEvidence(
757
755
  env: NodeJS.ProcessEnv = process.env,
758
756
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
759
- ): Harness[] {
757
+ ): HookInstallableHarness[] {
760
758
  const home = harnessAccountHome(env, platform)
761
- const found: Harness[] = []
759
+ const found: HookInstallableHarness[] = []
762
760
  if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
763
761
  if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
764
762
  if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
@@ -777,12 +775,12 @@ export function detectedHarnesses(
777
775
  cwd: string,
778
776
  env: NodeJS.ProcessEnv = process.env,
779
777
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
780
- ): Harness[] {
781
- const seen = new Set<Harness>([
778
+ ): HookInstallableHarness[] {
779
+ const seen = new Set<HookInstallableHarness>([
782
780
  ...localHarnessEvidence(cwd),
783
781
  ...globalHarnessEvidence(env, platform),
784
782
  ])
785
- return HARNESSES.filter((harness) => seen.has(harness))
783
+ return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness))
786
784
  }
787
785
 
788
786
  /**
@@ -798,7 +796,7 @@ export function detectHarness(
798
796
  cwd: string,
799
797
  env: NodeJS.ProcessEnv = process.env,
800
798
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
801
- ): Harness | null {
799
+ ): HookInstallableHarness | null {
802
800
  const local = localHarnessEvidence(cwd)
803
801
  if (local.length > 0) return local.length === 1 ? local[0]! : null
804
802
  const global = globalHarnessEvidence(env, platform)
@@ -1193,7 +1191,7 @@ export interface InstalledHandler {
1193
1191
  }
1194
1192
 
1195
1193
  export interface Installation {
1196
- harness: Harness
1194
+ harness: HookInstallableHarness
1197
1195
  file: string
1198
1196
  global: boolean
1199
1197
  handlers: InstalledHandler[]
@@ -1331,7 +1329,7 @@ export function findInstallations(
1331
1329
  ...(nodePath === undefined ? {} : { nodePath }),
1332
1330
  }
1333
1331
  const found: Installation[] = []
1334
- for (const harness of HARNESSES) {
1332
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
1335
1333
  for (const global of [false, true]) {
1336
1334
  const files = hookDefinitionFiles(harness, global, cwd, env, platform)
1337
1335
  for (const file of files) {
@@ -1411,7 +1409,7 @@ export function findInstallations(
1411
1409
  }
1412
1410
 
1413
1411
  function harnessMarkerProblems(
1414
- harness: Harness,
1412
+ harness: HookInstallableHarness,
1415
1413
  handlers: InstalledHandler[],
1416
1414
  adapterHome?: string,
1417
1415
  options: HookCommandOptions = {},
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import path from 'node:path'
3
3
  import { type SourceContextT } from '@raidiant/notifai-protocol'
4
- import type { Harness } from './harnesses.js'
4
+ import type { SourceContextHarness } from './harnesses.js'
5
5
  import { resolveSessionLabel } from './session-labels.js'
6
6
 
7
7
  export type GitCommand = (cwd: string, args: readonly string[]) => string | null
@@ -138,7 +138,7 @@ export interface SourceContextInput {
138
138
  sessionId?: string
139
139
  sessionLabel?: string
140
140
  activeHarness?: {
141
- harness: Harness
141
+ harness: SourceContextHarness
142
142
  sessionId?: string
143
143
  sessionLabel?: string
144
144
  sessionLabelPending?: boolean
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readOrcaSessionTitle, type OrcaCommand } from './orca-session-title.js'
3
+
4
+ const worktreeId = 'repo-123::/worktrees/agent-session-labels'
5
+
6
+ function response(overrides: Record<string, unknown> = {}): string {
7
+ return JSON.stringify({
8
+ ok: true,
9
+ result: {
10
+ worktree: {
11
+ id: worktreeId,
12
+ path: '/worktrees/agent-session-labels',
13
+ displayName: 'Agent Session context labels',
14
+ ...overrides,
15
+ },
16
+ },
17
+ })
18
+ }
19
+
20
+ describe('Orca Agent Session title context', () => {
21
+ it('accepts only the User-facing title returned for the exact managed worktree', () => {
22
+ const calls: { executable: string; args: readonly string[] }[] = []
23
+ const command: OrcaCommand = (executable, args) => {
24
+ calls.push({ executable, args })
25
+ return response()
26
+ }
27
+
28
+ expect(
29
+ readOrcaSessionTitle({ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId }, command),
30
+ ).toBe('Agent Session context labels')
31
+ expect(calls).toEqual([
32
+ {
33
+ executable: 'orca',
34
+ args: ['worktree', 'show', '--worktree', `id:${worktreeId}`, '--json'],
35
+ },
36
+ ])
37
+ })
38
+
39
+ it.each([
40
+ ['command failure', () => null],
41
+ ['malformed JSON', () => '{not json}'],
42
+ ['failed response', () => JSON.stringify({ ok: false })],
43
+ ['different worktree id', () => response({ id: 'repo-123::/worktrees/other' })],
44
+ ['different worktree path', () => response({ path: '/private/untrusted' })],
45
+ ['blank display name', () => response({ displayName: ' ' })],
46
+ ])('leaves the generated fallback exceptional when context is unavailable: %s', (_name, command) => {
47
+ expect(
48
+ readOrcaSessionTitle(
49
+ { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
50
+ command,
51
+ ),
52
+ ).toBeUndefined()
53
+ })
54
+ })
@@ -0,0 +1,103 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import path from 'node:path'
3
+
4
+ export type OrcaSessionTitleLookup = (env: NodeJS.ProcessEnv) => string | undefined
5
+ export type OrcaCommand = (executable: string, args: readonly string[]) => string | null
6
+
7
+ const runOrcaCommand: OrcaCommand = (executable, args) => {
8
+ try {
9
+ return execFileSync(executable, args, {
10
+ encoding: 'utf8',
11
+ stdio: ['ignore', 'pipe', 'ignore'],
12
+ timeout: 2_000,
13
+ maxBuffer: 512 * 1024,
14
+ windowsHide: true,
15
+ }).trim()
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ function containsControlCharacter(value: string): boolean {
22
+ return Array.from(value).some((character) => {
23
+ const codePoint = character.codePointAt(0) ?? 0
24
+ return codePoint < 32 || codePoint === 127
25
+ })
26
+ }
27
+
28
+ function worktreePathFromId(value: string): string | null {
29
+ if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value)) return null
30
+ const separator = value.indexOf('::')
31
+ if (separator <= 0) return null
32
+ const repoId = value.slice(0, separator)
33
+ const worktreePath = value.slice(separator + 2)
34
+ if (!/^[A-Za-z0-9._-]+$/u.test(repoId) || worktreePath.length === 0) return null
35
+ return path.posix.isAbsolute(worktreePath) || path.win32.isAbsolute(worktreePath)
36
+ ? worktreePath
37
+ : null
38
+ }
39
+
40
+ function orcaExecutable(env: NodeJS.ProcessEnv): string | null {
41
+ const configured = env['ORCA_CLI_COMMAND']?.trim()
42
+ if (configured !== undefined && configured.length > 0) {
43
+ return containsControlCharacter(configured) ? null : configured
44
+ }
45
+ return env['ORCA_DEV_REPO_ROOT'] === undefined ? 'orca' : 'orca-dev'
46
+ }
47
+
48
+ /**
49
+ * Read the User-facing task name assigned to this exact Orca worktree.
50
+ *
51
+ * The environment contributes only an opaque selector. Orca must return that
52
+ * exact id and its matching path before its display name becomes trusted; no
53
+ * path component or identifier is ever promoted into an Agent Session label.
54
+ */
55
+ export function readOrcaSessionTitle(
56
+ env: NodeJS.ProcessEnv,
57
+ command: OrcaCommand = runOrcaCommand,
58
+ ): string | undefined {
59
+ if (env['TERM_PROGRAM'] !== 'Orca') return undefined
60
+ const worktreeId = env['ORCA_WORKTREE_ID']
61
+ if (worktreeId === undefined) return undefined
62
+ const expectedPath = worktreePathFromId(worktreeId)
63
+ if (expectedPath === null) return undefined
64
+ const executable = orcaExecutable(env)
65
+ if (executable === null) return undefined
66
+
67
+ let output: string | null
68
+ try {
69
+ output = command(executable, [
70
+ 'worktree',
71
+ 'show',
72
+ '--worktree',
73
+ `id:${worktreeId}`,
74
+ '--json',
75
+ ])
76
+ } catch {
77
+ return undefined
78
+ }
79
+ if (output === null || output === '') return undefined
80
+
81
+ let parsed: unknown
82
+ try {
83
+ parsed = JSON.parse(output)
84
+ } catch {
85
+ return undefined
86
+ }
87
+ if (typeof parsed !== 'object' || parsed === null) return undefined
88
+ const response = parsed as {
89
+ ok?: unknown
90
+ result?: { worktree?: { id?: unknown; path?: unknown; displayName?: unknown } }
91
+ }
92
+ const worktree = response.result?.worktree
93
+ if (
94
+ response.ok !== true ||
95
+ worktree?.id !== worktreeId ||
96
+ worktree.path !== expectedPath ||
97
+ typeof worktree.displayName !== 'string' ||
98
+ worktree.displayName.trim().length === 0
99
+ ) {
100
+ return undefined
101
+ }
102
+ return worktree.displayName
103
+ }
@@ -116,6 +116,33 @@ describe('program argv parsing', () => {
116
116
  expect(enabled).toBe(1)
117
117
  })
118
118
 
119
+ it('dispatches the current Agent Session rename without exposing an id override', async () => {
120
+ let label: string | undefined
121
+ const result = await parse(['session', 'rename', 'Hermes Support'], {
122
+ agentSessionRename: (async (_deps, value) => ((label = value), 0)) as ProgramRunners['agentSessionRename'],
123
+ })
124
+ expect(result.exitCode).toBe(0)
125
+ expect(label).toBe('Hermes Support')
126
+ })
127
+
128
+ it('dispatches sounds --json onto soundsCommand', async () => {
129
+ let seen: Record<string, unknown> | undefined
130
+ const result = await parse(['sounds', '--json'], {
131
+ sounds: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['sounds'],
132
+ })
133
+ expect(result.exitCode).toBe(0)
134
+ expect(seen).toMatchObject({ json: true })
135
+ })
136
+
137
+ it('passes a custom sound name through --sound', async () => {
138
+ let seen: Record<string, unknown> | undefined
139
+ await parse(
140
+ ['send', '--kind', 'done', '--title', 'T', '--body', 'B', '--sound', 'Kitchen timer'],
141
+ { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
142
+ )
143
+ expect(seen).toMatchObject({ sound: 'Kitchen timer' })
144
+ })
145
+
119
146
  it('drops empty collector defaults so "not passed" stays absent', async () => {
120
147
  let seen: Record<string, unknown> | undefined
121
148
  await parse(
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 { HARNESSES } from './install-hooks.js'
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,
@@ -271,6 +275,21 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
271
275
  exit(runners.projectStatus(deps, opts.json === true))
272
276
  })
273
277
 
278
+ const session = program
279
+ .command('session')
280
+ .helpGroup(GROUP.agent)
281
+ .summary('Inspect or rename the current Agent Session')
282
+ .description('Operate on the exact Agent Session owned by the active harness')
283
+ session
284
+ .command('rename <label>')
285
+ .description(
286
+ 'Rename this exact Agent Session; agents use this only after its job changes completely enough that the old label would mislead',
287
+ )
288
+ .option('--json', 'machine-readable output')
289
+ .action(async (label: string, opts: { json?: boolean }) => {
290
+ exit(await runners.agentSessionRename(deps, label, opts))
291
+ })
292
+
274
293
  program
275
294
  .command('login')
276
295
  .helpGroup(GROUP.start)
@@ -323,6 +342,18 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
323
342
  exit(await runners.devices(deps, opts))
324
343
  })
325
344
 
345
+ program
346
+ .command('sounds')
347
+ .helpGroup(GROUP.daily)
348
+ .summary('List shipped and Account custom sounds')
349
+ .description(
350
+ 'List the sounds --sound and the saved sound key accept: shipped names and this Account\'s custom sounds',
351
+ )
352
+ .option('--json', 'machine-readable output')
353
+ .action(async (opts: { json?: boolean }) => {
354
+ exit(await runners.sounds(deps, opts))
355
+ })
356
+
326
357
  program
327
358
  .command('capabilities')
328
359
  .helpGroup(GROUP.advanced)
@@ -389,7 +420,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
389
420
  )
390
421
  .option('--multi', 'with --choice, several answers may be selected')
391
422
  .optionsGroup(SEND_GROUP.advanced)
392
- .option('--sound <sound>', 'override saved sound: default | done | attention | alert | none')
423
+ .option(
424
+ '--sound <sound>',
425
+ 'override saved sound: default | done | attention | alert | none, or a custom name or id from notifai sounds',
426
+ )
393
427
  .option(
394
428
  '--level <level>',
395
429
  'Apple-only interruption level: passive | active | time_sensitive (unsupported with --platform android)',
@@ -435,7 +469,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
435
469
 
436
470
  send.addHelpText(
437
471
  '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 the standard one.\nAn explicit --sound or saved sound preference outranks that default. --level is Apple-only; Android attention is owned by kind, product channels, and device settings.\n`,
472
+ `\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
473
  )
440
474
 
441
475
  program
@@ -593,7 +627,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
593
627
  .option('--owner <name>', 'internal ownership marker')
594
628
  .option('--harness <name>', 'internal harness output adapter')
595
629
  .action(async (event: string, opts: { harness?: string }) => {
596
- const harness = HARNESSES.find((candidate) => candidate === opts.harness)
630
+ const harness = HOOK_INSTALLABLE_HARNESSES.find((candidate) => candidate === opts.harness)
597
631
  exit(await runners.hookRun(deps, event, () => readStdinWithTimeout(), harness))
598
632
  })
599
633
 
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
- // Kind states what happened; the sound table turns that truth into the
221
- // attention it deserves, so an honest sender gets the right sound without
222
- // reasoning about audio. An explicit --sound and the user's saved preference
223
- // both outrank it, and `none` still means silent.
224
- const kindForSound: NotificationKind = flags.reply
225
- ? 'question'
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: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
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
- const semanticSound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
258
- appleOptions.sound = semanticSound
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
@@ -1,9 +1,20 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
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 { formatSessionFirstSeen, resolveSessionLabel } from './session-labels.js'
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('fails closed instead of replacing an unreadable name store', () => {
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
- const resolved = resolveSessionLabel({
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(resolved.ok).toBe(false)
433
- expect(!resolved.ok && resolved.error).toContain('session-name store is unreadable')
434
- expect(readFileSync(file, 'utf8')).toBe('{not json}\n')
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
  })