@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
@@ -5,7 +5,12 @@ import { inspectClaudeInbox, systemClaudeWakeAdapters } from './claude-wake.js'
5
5
  import { ApiCallError, type ApiClient } from './client.js'
6
6
  import { inspectCodexResume } from './codex-wake.js'
7
7
  import { type CliConfig } from './config.js'
8
- import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
8
+ import {
9
+ HARNESS_CAPABILITIES,
10
+ HARNESS_LABELS,
11
+ HERMES_QUESTION_ROUTING_UNAVAILABLE,
12
+ isHookInstallableHarness,
13
+ } from './harnesses.js'
9
14
  import { inspectHookAdapter } from './hook-adapter.js'
10
15
  import {
11
16
  readLiveProjectSessionPointers,
@@ -52,7 +57,6 @@ import {
52
57
  } from './commands-core.js'
53
58
  import { deviceInstallRemedy, readyCompanionDevices, supportPageUrl } from './commands-devices.js'
54
59
  import {
55
- activeHarnessSession,
56
60
  claudeSessionPid,
57
61
  resolveActiveHarness,
58
62
  type ActiveHarnessSession,
@@ -761,7 +765,11 @@ export function remedyLine(state: ReadinessState): string {
761
765
  */
762
766
  function hookStates(deps: CommandDeps): ReadinessState[] {
763
767
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
764
- const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
768
+ const { active, contested } = resolveActiveHarness(
769
+ deps.env,
770
+ deps.cwd,
771
+ (deps.now ?? Date.now)(),
772
+ )
765
773
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
766
774
  const settings: ReadinessState = {
767
775
  id: 'question-routing-settings',
@@ -782,6 +790,28 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
782
790
  }),
783
791
  }
784
792
  if (installations.length === 0) {
793
+ if (contested.length > 1) {
794
+ return [
795
+ {
796
+ id: 'hooks',
797
+ title: 'Question routing',
798
+ status: 'optional-gap',
799
+ detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
800
+ },
801
+ settings,
802
+ ]
803
+ }
804
+ if (active !== null && !isHookInstallableHarness(active.harness)) {
805
+ return [
806
+ {
807
+ id: 'hooks',
808
+ title: 'Question routing',
809
+ status: 'optional-gap',
810
+ detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
811
+ },
812
+ settings,
813
+ ]
814
+ }
785
815
  return [
786
816
  {
787
817
  id: 'hooks',
@@ -925,7 +955,21 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
925
955
  active === null
926
956
  ? []
927
957
  : installations.filter((installation) => installation.harness === active.harness)
928
- if (active !== null) {
958
+ if (contested.length > 1) {
959
+ checks.push({
960
+ name: 'hooks (active harness)',
961
+ ok: false,
962
+ informational: true,
963
+ detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
964
+ })
965
+ } else if (active !== null && !isHookInstallableHarness(active.harness)) {
966
+ checks.push({
967
+ name: 'hooks (active harness)',
968
+ ok: true,
969
+ informational: true,
970
+ detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
971
+ })
972
+ } else if (active !== null) {
929
973
  checks.push({
930
974
  name: 'hooks (active harness)',
931
975
  ok: activeInstallations.length > 0,
@@ -1,5 +1,10 @@
1
+ import {
2
+ hermesClassicCliLocalInstance,
3
+ isHookInstallableHarness,
4
+ type HermesClassicCliLocalInstance,
5
+ type SourceContextHarness,
6
+ } from './harnesses.js'
1
7
  import { readLiveProjectSessionPointers, readSessionState } from './hooks.js'
2
- import { type Harness } from './install-hooks.js'
3
8
 
4
9
  /**
5
10
  * Exact evidence that this shell command is running inside one supported
@@ -7,11 +12,17 @@ import { type Harness } from './install-hooks.js'
7
12
  * describe where a tool stores files, not which tool owns the current shell.
8
13
  * OpenCode's generated plugin supplies the Notifai-owned marker because its
9
14
  * plugin API exposes Agent Session identity but the ordinary environment does not.
15
+ *
16
+ * Hermes contributes `HERMES_SESSION_ID` only — the durable Agent Session id
17
+ * bridged into tool subprocesses. `HERMES_SESSION_KEY` is a gateway routing
18
+ * key and must never be treated as Agent Session identity.
10
19
  */
11
20
  export interface ActiveHarnessSession {
12
- harness: Harness
21
+ harness: SourceContextHarness
13
22
  label: string
14
23
  sessionId?: string
24
+ /** Exact surface/backend cell proven for this marker envelope. */
25
+ integrationInstance?: HermesClassicCliLocalInstance
15
26
  /** Trusted human title published by this harness's managed adapter. */
16
27
  sessionLabel?: string
17
28
  /** The harness has only a temporary placeholder title so far. */
@@ -53,6 +64,16 @@ function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
53
64
  candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
54
65
  }
55
66
  if ((env['CURSOR_AGENT'] ?? '') !== '') candidates.push({ harness: 'cursor', label: 'Cursor' })
67
+ const hermesSession = env['HERMES_SESSION_ID']
68
+ if (hermesSession !== undefined && hermesSession !== '') {
69
+ const integrationInstance = hermesClassicCliLocalInstance(env)
70
+ candidates.push({
71
+ harness: 'hermes',
72
+ label: 'Hermes',
73
+ sessionId: hermesSession,
74
+ ...(integrationInstance === null ? {} : { integrationInstance }),
75
+ })
76
+ }
56
77
  return candidates
57
78
  }
58
79
 
@@ -97,6 +118,12 @@ export function resolveActiveHarness(
97
118
  if (first === undefined) return { active: null, contested: [] }
98
119
  if (candidates.length === 1) return { active: first, contested: [] }
99
120
 
121
+ // A send-only harness has no hook evidence, so live pointer/state of a
122
+ // hook-installable parent cannot prove which nested process owns this
123
+ // shell. Mixing Hermes with another marker therefore stays contested.
124
+ const mixedWithSendOnly = candidates.some((candidate) => !isHookInstallableHarness(candidate.harness))
125
+ if (mixedWithSendOnly) return { active: first, contested: candidates }
126
+
100
127
  // Exact lifecycle state is machine-global and survives a command moving to
101
128
  // another linked checkout. Prefer it before the checkout-local
102
129
  // pointer. A unique newest hook event proves which nested harness owns this
@@ -133,6 +160,25 @@ export function activeHarnessSession(
133
160
  return resolveActiveHarness(env, cwd, now).active
134
161
  }
135
162
 
163
+ /**
164
+ * Source Context attribution. Hook-installable nesting still uses declared
165
+ * order when nothing has fired — that existing send behavior is preserved.
166
+ * A contested mix that includes a send-only harness (Hermes) fails closed:
167
+ * the wrong Agent Session would otherwise be named as the owner.
168
+ */
169
+ export function sourceContextHarnessSession(
170
+ env: NodeJS.ProcessEnv,
171
+ cwd: string,
172
+ now: number,
173
+ ): ActiveHarnessSession | null {
174
+ const { active, contested } = resolveActiveHarness(env, cwd, now)
175
+ if (contested.some((candidate) => !isHookInstallableHarness(candidate.harness))) {
176
+ return null
177
+ }
178
+ if (active?.harness === 'hermes' && active.integrationInstance === undefined) return null
179
+ return active
180
+ }
181
+
136
182
  /**
137
183
  * The pid of the Claude Code session this hook belongs to.
138
184
  *
@@ -21,7 +21,14 @@ import { ApiCallError } from './client.js'
21
21
  import { codexWakeRoute } from './codex-wake.js'
22
22
  import { loadConfig, type CliConfig } from './config.js'
23
23
  import { withTargetFileLock } from './file-lock.js'
24
- import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
24
+ import {
25
+ HARNESS_LABELS,
26
+ HERMES_QUESTION_ROUTING_UNAVAILABLE,
27
+ HOOK_INSTALLABLE_HARNESSES,
28
+ isHookInstallableHarness,
29
+ questionRoutingCapability,
30
+ type HookInstallableHarness,
31
+ } from './harnesses.js'
25
32
  import {
26
33
  inspectHookAdapter,
27
34
  installHookAdapter,
@@ -51,7 +58,6 @@ import {
51
58
  } from './hooks.js'
52
59
  import {
53
60
  NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
54
- HARNESSES,
55
61
  applyPlan,
56
62
  buildCursorHookConfig,
57
63
  buildHookConfig,
@@ -75,7 +81,6 @@ import {
75
81
  removeHooks,
76
82
  settingsFile,
77
83
  withCodexLayerTransaction,
78
- type Harness,
79
84
  type Installation,
80
85
  } from './install-hooks.js'
81
86
  import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
@@ -138,7 +143,7 @@ export const HOOK_EVENTS = [
138
143
  export type HookEvent = (typeof HOOK_EVENTS)[number]
139
144
 
140
145
  /** Lifecycle handlers one installed harness must carry in this CLI build. */
141
- export function requiredHookEvents(harness: Harness): readonly HookEvent[] {
146
+ export function requiredHookEvents(harness: HookInstallableHarness): readonly HookEvent[] {
142
147
  if (harness === 'opencode') return []
143
148
  return harness === 'cursor'
144
149
  ? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
@@ -572,7 +577,7 @@ export async function hookRunCommand(
572
577
  */
573
578
  function stopWakeRoute(
574
579
  deps: CommandDeps,
575
- harness: Harness | undefined,
580
+ harness: HookInstallableHarness | undefined,
576
581
  sessionId: string | undefined,
577
582
  cwd: string,
578
583
  ): EscalationDeliveryRoute | undefined {
@@ -1102,6 +1107,17 @@ export function askCommand(
1102
1107
  'run the ask from a shell with one exact active harness session',
1103
1108
  )
1104
1109
  }
1110
+ if (!isHookInstallableHarness(active.harness)) {
1111
+ const capability = HERMES_QUESTION_ROUTING_UNAVAILABLE
1112
+ return askFailure(
1113
+ deps,
1114
+ flags,
1115
+ 'question_routing_unavailable',
1116
+ 'hook_contract',
1117
+ `${active.label}: ${capability.deliveryContract}`,
1118
+ 'use a blocking `notifai send --reply` question',
1119
+ )
1120
+ }
1105
1121
  const exactState = active.sessionId === undefined
1106
1122
  ? null
1107
1123
  : readSessionState(active.sessionId, deps.env)
@@ -1253,7 +1269,9 @@ export function activeQuestionRouteProblems(
1253
1269
  `the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`,
1254
1270
  )
1255
1271
  }
1256
- const capability = HARNESS_CAPABILITIES[active.harness]
1272
+ const capability = isHookInstallableHarness(active.harness)
1273
+ ? questionRoutingCapability(active.harness)
1274
+ : HERMES_QUESTION_ROUTING_UNAVAILABLE
1257
1275
  if (capability.stopContinuation === 'unsupported') {
1258
1276
  problems.push(`${active.label}: ${capability.deliveryContract}`)
1259
1277
  }
@@ -1683,7 +1701,7 @@ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags):
1683
1701
  return { execPath, scriptPath }
1684
1702
  }
1685
1703
 
1686
- function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
1704
+ function printHooksInstallClose(deps: CommandDeps, harness: HookInstallableHarness, file: string): void {
1687
1705
  const label = HARNESS_LABELS[harness]
1688
1706
  const activation =
1689
1707
  harness === 'codex'
@@ -1705,7 +1723,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1705
1723
  if (flags.harness === undefined) {
1706
1724
  const detected = detectedHarnesses(deps.cwd, deps.env)
1707
1725
  if (detected.length === 0) {
1708
- deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`)
1726
+ deps.io.err(`Could not tell which harness you mean — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
1709
1727
  return EXIT.usage
1710
1728
  }
1711
1729
  let ok = true
@@ -2049,17 +2067,17 @@ function assertOwnedRegularFile(file: string): void {
2049
2067
  }
2050
2068
  }
2051
2069
 
2052
- function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
2070
+ function resolveHarness(deps: CommandDeps, requested: string | undefined): HookInstallableHarness | null {
2053
2071
  if (requested !== undefined) {
2054
- if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
2072
+ if ((HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(requested)) return requested as HookInstallableHarness
2055
2073
  deps.io.err(
2056
- `Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`,
2074
+ `Unknown harness "${requested}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`,
2057
2075
  )
2058
2076
  return null
2059
2077
  }
2060
2078
  const detected = detectHarness(deps.cwd, deps.env)
2061
2079
  if (!detected) {
2062
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
2080
+ deps.io.err(`Could not tell which harness to install for — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
2063
2081
  return null
2064
2082
  }
2065
2083
  return detected
@@ -2073,7 +2091,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
2073
2091
  export async function pickHarnessesToInstall(
2074
2092
  deps: CommandDeps,
2075
2093
  requested?: string,
2076
- ): Promise<Harness[] | null> {
2094
+ ): Promise<HookInstallableHarness[] | null> {
2077
2095
  if (requested !== undefined) {
2078
2096
  const harness = resolveHarness(deps, requested)
2079
2097
  return harness === null ? null : [harness]
@@ -2083,7 +2101,7 @@ export async function pickHarnessesToInstall(
2083
2101
  if (deps.io.interactive === true && deps.io.multiselect) {
2084
2102
  const picked = await deps.io.multiselect(
2085
2103
  'Which agent harnesses should Notifai wire here?',
2086
- HARNESSES.map((name) => ({
2104
+ HOOK_INSTALLABLE_HARNESSES.map((name) => ({
2087
2105
  value: name,
2088
2106
  label: HARNESS_LABELS[name],
2089
2107
  ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
@@ -2091,16 +2109,16 @@ export async function pickHarnessesToInstall(
2091
2109
  detected,
2092
2110
  )
2093
2111
  if (picked === null) return null
2094
- const unknown = picked.filter((name) => !(HARNESSES as readonly string[]).includes(name))
2112
+ const unknown = picked.filter((name) => !(HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(name))
2095
2113
  if (unknown.length > 0) {
2096
- deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`)
2114
+ deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`)
2097
2115
  return null
2098
2116
  }
2099
- return picked as Harness[]
2117
+ return picked as HookInstallableHarness[]
2100
2118
  }
2101
2119
  if (detected.length === 0) {
2102
2120
  deps.io.err(
2103
- `Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
2121
+ `Could not tell which harness to wire. Run: notifai hooks install --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>`,
2104
2122
  )
2105
2123
  return null
2106
2124
  }
@@ -5,16 +5,38 @@ import path from 'node:path'
5
5
  import { isRetryableReplyPollError, type ApiClient } from './client.js'
6
6
  import { fetchMediaUrl } from './url-policy.js'
7
7
  import { buildSourceContext, inferInvocationContext } from './invocation-context.js'
8
+ import { readOrcaSessionTitle } from './orca-session-title.js'
8
9
  import { type DraftInvocation, type SendFlags } from './send.js'
9
10
  import { EXIT, reportError, type CommandDeps } from './commands-core.js'
10
11
  import type { ActiveHarnessSession } from './commands-harness-context.js'
11
12
 
13
+ function managedSessionTitle(
14
+ deps: CommandDeps,
15
+ flags: Pick<SendFlags, 'sessionLabel'>,
16
+ active: ActiveHarnessSession | null,
17
+ ): string | undefined {
18
+ if (active?.sessionLabel !== undefined) return active.sessionLabel
19
+ if (
20
+ active?.sessionId === undefined ||
21
+ flags.sessionLabel !== undefined ||
22
+ deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
23
+ ) {
24
+ return undefined
25
+ }
26
+ try {
27
+ return (deps.orcaSessionTitle ?? readOrcaSessionTitle)(deps.env)
28
+ } catch {
29
+ return undefined
30
+ }
31
+ }
32
+
12
33
  export function resolveDraftInvocation(
13
34
  deps: CommandDeps,
14
35
  flags: Pick<SendFlags, 'sessionId' | 'sessionLabel'>,
15
36
  active: ActiveHarnessSession | null,
16
37
  ): { ok: true; invocation: DraftInvocation } | { ok: false; error: string } {
17
38
  const inferred = inferInvocationContext(deps.cwd)
39
+ const sessionTitle = managedSessionTitle(deps, flags, active)
18
40
  const source = buildSourceContext({
19
41
  env: deps.env,
20
42
  invocation: inferred,
@@ -26,7 +48,7 @@ export function resolveDraftInvocation(
26
48
  activeHarness: {
27
49
  harness: active.harness,
28
50
  ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
29
- ...(active.sessionLabel === undefined ? {} : { sessionLabel: active.sessionLabel }),
51
+ ...(sessionTitle === undefined ? {} : { sessionLabel: sessionTitle }),
30
52
  ...(active.sessionLabelPending === true ? { sessionLabelPending: true } : {}),
31
53
  },
32
54
  }),
@@ -27,7 +27,7 @@ import {
27
27
  reportError,
28
28
  type CommandDeps,
29
29
  } from './commands-core.js'
30
- import { activeHarnessSession } from './commands-harness-context.js'
30
+ import { sourceContextHarnessSession } from './commands-harness-context.js'
31
31
  import { resolveCommandSession } from './command-session.js'
32
32
  import {
33
33
  beginSendAttempt,
@@ -143,7 +143,7 @@ export async function sendCommand(
143
143
  const source = resolveDraftInvocation(
144
144
  deps,
145
145
  flags,
146
- activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
146
+ sourceContextHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
147
147
  )
148
148
  if (!source.ok) {
149
149
  deps.io.err(source.error)
@@ -0,0 +1,106 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import type { ApiClient } from './client.js'
5
+ import { EXIT, type CommandDeps, type CommandIo } from './commands-core.js'
6
+ import { soundsCommand } from './commands-sounds.js'
7
+
8
+ class CapturedIo implements CommandIo {
9
+ readonly outLines: string[] = []
10
+ readonly errLines: string[] = []
11
+ out(line: string): void {
12
+ this.outLines.push(line)
13
+ }
14
+ err(line: string): void {
15
+ this.errLines.push(line)
16
+ }
17
+ async confirm(): Promise<boolean> {
18
+ return true
19
+ }
20
+ openUrl(): void {}
21
+ }
22
+
23
+ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
24
+ const testRoot = path.join(os.tmpdir(), 'notifai-cli-sounds-tests')
25
+ return {
26
+ io,
27
+ store: {
28
+ load: () => ({
29
+ machineId: 'mac_test',
30
+ secret: 'test-secret',
31
+ baseUrl: 'https://test.notifai.invalid',
32
+ machineName: 'test-machine',
33
+ }),
34
+ save: () => {},
35
+ clear: () => {},
36
+ describe: () => 'test credential store',
37
+ },
38
+ env: {
39
+ XDG_CONFIG_HOME: testRoot,
40
+ XDG_STATE_HOME: path.join(os.tmpdir(), 'notifai-cli-sounds-tests-state'),
41
+ },
42
+ cwd: os.tmpdir(),
43
+ clientFactory: () => client,
44
+ }
45
+ }
46
+
47
+ const kitchenTimer = {
48
+ sound_id: 'snd_kitchen',
49
+ name: 'Kitchen timer',
50
+ duration_ms: 2400,
51
+ content_hash: 'a'.repeat(64),
52
+ url: 'https://wav.example/kitchen.wav',
53
+ }
54
+
55
+ describe('soundsCommand', () => {
56
+ it('lists shipped names and Account custom sounds', async () => {
57
+ const io = new CapturedIo()
58
+ const client = {
59
+ listSounds: async () => ({ sounds: [kitchenTimer] }),
60
+ } as unknown as ApiClient
61
+
62
+ expect(await soundsCommand(makeDeps(io, client), {})).toBe(EXIT.ok)
63
+ expect(io.outLines[0]).toBe('default Device default')
64
+ expect(io.outLines).toContain('done completion chime')
65
+ expect(io.outLines).toContain('none silent')
66
+ expect(io.outLines.at(-1)).toBe('snd_kitchen Kitchen timer 2.4s')
67
+ })
68
+
69
+ it('prints the catalog as JSON when asked', async () => {
70
+ const io = new CapturedIo()
71
+ const client = {
72
+ listSounds: async () => ({ sounds: [kitchenTimer] }),
73
+ } as unknown as ApiClient
74
+
75
+ expect(await soundsCommand(makeDeps(io, client), { json: true })).toBe(EXIT.ok)
76
+ const parsed = JSON.parse(io.outLines.join('\n')) as {
77
+ shipped: { ref: string; name: string }[]
78
+ custom: typeof kitchenTimer[]
79
+ }
80
+ expect(parsed.shipped[0]).toEqual({ ref: 'default', name: 'Device default' })
81
+ expect(parsed.custom).toEqual([kitchenTimer])
82
+ })
83
+
84
+ it('refuses to list custom sounds when this machine is not signed in', async () => {
85
+ const io = new CapturedIo()
86
+ let calls = 0
87
+ const deps: CommandDeps = {
88
+ ...makeDeps(io, {
89
+ listSounds: async () => {
90
+ calls += 1
91
+ return { sounds: [] }
92
+ },
93
+ } as unknown as ApiClient),
94
+ store: {
95
+ load: () => null,
96
+ save: () => {},
97
+ clear: () => {},
98
+ describe: () => 'empty store',
99
+ },
100
+ }
101
+
102
+ expect(await soundsCommand(deps, {})).toBe(EXIT.auth)
103
+ expect(calls).toBe(0)
104
+ expect(io.errLines.join('\n')).toMatch(/not signed in/i)
105
+ })
106
+ })
@@ -0,0 +1,49 @@
1
+ import type { ListSoundsResponse, SoundView } from '@raidiant/notifai-protocol'
2
+ import {
3
+ EXIT,
4
+ authedClient,
5
+ loadLoggedConfig,
6
+ reportError,
7
+ type CommandDeps,
8
+ } from './commands-core.js'
9
+ import { SHIPPED_SOUND_LISTING } from './sound-ref.js'
10
+
11
+ export interface SoundsListing {
12
+ shipped: readonly { ref: string; name: string }[]
13
+ custom: SoundView[]
14
+ }
15
+
16
+ function formatSoundDuration(ms: number): string {
17
+ const seconds = ms / 1000
18
+ const text = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1)
19
+ return `${text}s`
20
+ }
21
+
22
+ export async function soundsCommand(
23
+ deps: CommandDeps,
24
+ flags: { json?: boolean },
25
+ ): Promise<number> {
26
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
27
+ const authed = authedClient(deps, config)
28
+ if (!authed) return EXIT.auth
29
+ try {
30
+ const result: ListSoundsResponse = await authed.client.listSounds()
31
+ const listing: SoundsListing = {
32
+ shipped: SHIPPED_SOUND_LISTING,
33
+ custom: result.sounds,
34
+ }
35
+ if (flags.json) {
36
+ deps.io.out(JSON.stringify(listing, null, 2))
37
+ return EXIT.ok
38
+ }
39
+ for (const sound of listing.shipped) {
40
+ deps.io.out(`${sound.ref} ${sound.name}`)
41
+ }
42
+ for (const sound of listing.custom) {
43
+ deps.io.out(`${sound.sound_id} ${sound.name} ${formatSoundDuration(sound.duration_ms)}`)
44
+ }
45
+ return EXIT.ok
46
+ } catch (err) {
47
+ return reportError(deps, err)
48
+ }
49
+ }