@raidiant/notifai 9.3.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/dist/client.d.ts +3 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-agent-sessions.d.ts +6 -0
  7. package/dist/commands-agent-sessions.js +51 -0
  8. package/dist/commands-agent-sessions.js.map +1 -0
  9. package/dist/commands-auth.js +4 -4
  10. package/dist/commands-auth.js.map +1 -1
  11. package/dist/commands-config.js +11 -4
  12. package/dist/commands-config.js.map +1 -1
  13. package/dist/commands-core.d.ts +5 -0
  14. package/dist/commands-core.js +2 -0
  15. package/dist/commands-core.js.map +1 -1
  16. package/dist/commands-doctor.js +121 -40
  17. package/dist/commands-doctor.js.map +1 -1
  18. package/dist/commands-harness-context.d.ts +15 -2
  19. package/dist/commands-harness-context.js +40 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hooks.d.ts +4 -3
  22. package/dist/commands-hooks.js +139 -16
  23. package/dist/commands-hooks.js.map +1 -1
  24. package/dist/commands-init.js +10 -5
  25. package/dist/commands-init.js.map +1 -1
  26. package/dist/commands-send-support.js +18 -1
  27. package/dist/commands-send-support.js.map +1 -1
  28. package/dist/commands-send.js +2 -2
  29. package/dist/commands-send.js.map +1 -1
  30. package/dist/commands-sounds.d.ts +12 -0
  31. package/dist/commands-sounds.js +35 -0
  32. package/dist/commands-sounds.js.map +1 -0
  33. package/dist/commands.d.ts +2 -0
  34. package/dist/commands.js +2 -0
  35. package/dist/commands.js.map +1 -1
  36. package/dist/config-schema.js +5 -2
  37. package/dist/config-schema.js.map +1 -1
  38. package/dist/config.d.ts +3 -4
  39. package/dist/config.js +5 -2
  40. package/dist/config.js.map +1 -1
  41. package/dist/credentials.js +4 -1
  42. package/dist/credentials.js.map +1 -1
  43. package/dist/harnesses.d.ts +49 -7
  44. package/dist/harnesses.js +110 -24
  45. package/dist/harnesses.js.map +1 -1
  46. package/dist/hooks.d.ts +2 -2
  47. package/dist/hooks.js +2 -2
  48. package/dist/hooks.js.map +1 -1
  49. package/dist/install-hooks.d.ts +10 -11
  50. package/dist/install-hooks.js +18 -13
  51. package/dist/install-hooks.js.map +1 -1
  52. package/dist/interactive.js +20 -23
  53. package/dist/interactive.js.map +1 -1
  54. package/dist/invocation-context.d.ts +2 -2
  55. package/dist/openclaw-plugin.d.ts +49 -0
  56. package/dist/openclaw-plugin.js +350 -0
  57. package/dist/openclaw-plugin.js.map +1 -0
  58. package/dist/orca-session-title.d.ts +10 -0
  59. package/dist/orca-session-title.js +98 -0
  60. package/dist/orca-session-title.js.map +1 -0
  61. package/dist/program.d.ts +3 -1
  62. package/dist/program.js +37 -13
  63. package/dist/program.js.map +1 -1
  64. package/dist/send.js +11 -14
  65. package/dist/send.js.map +1 -1
  66. package/dist/session-activation.js +1 -1
  67. package/dist/session-activation.js.map +1 -1
  68. package/dist/session-labels.d.ts +18 -2
  69. package/dist/session-labels.js +79 -8
  70. package/dist/session-labels.js.map +1 -1
  71. package/dist/sound-ref.d.ts +23 -0
  72. package/dist/sound-ref.js +28 -0
  73. package/dist/sound-ref.js.map +1 -0
  74. package/dist/ui/help.d.ts +4 -5
  75. package/dist/ui/help.js +5 -6
  76. package/dist/ui/help.js.map +1 -1
  77. package/package.json +3 -3
  78. package/src/client.test.ts +12 -0
  79. package/src/client.ts +8 -0
  80. package/src/commands-agent-sessions.ts +72 -0
  81. package/src/commands-auth.ts +4 -3
  82. package/src/commands-config.ts +10 -4
  83. package/src/commands-core.ts +6 -0
  84. package/src/commands-doctor.ts +143 -38
  85. package/src/commands-harness-context.ts +56 -2
  86. package/src/commands-hooks.ts +180 -20
  87. package/src/commands-init.ts +10 -4
  88. package/src/commands-send-support.ts +23 -1
  89. package/src/commands-send.ts +2 -2
  90. package/src/commands-sounds.test.ts +106 -0
  91. package/src/commands-sounds.ts +49 -0
  92. package/src/commands.test.ts +529 -17
  93. package/src/commands.ts +5 -0
  94. package/src/config-schema.test.ts +1 -0
  95. package/src/config-schema.ts +5 -2
  96. package/src/config.test.ts +42 -20
  97. package/src/config.ts +6 -5
  98. package/src/credentials.test.ts +20 -0
  99. package/src/credentials.ts +4 -1
  100. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +294 -0
  103. package/src/harnesses.test.ts +115 -8
  104. package/src/harnesses.ts +133 -30
  105. package/src/hooks.test.ts +4 -3
  106. package/src/hooks.ts +4 -4
  107. package/src/install-hooks.test.ts +213 -4
  108. package/src/install-hooks.ts +38 -30
  109. package/src/interactive.test.ts +31 -0
  110. package/src/interactive.ts +20 -26
  111. package/src/invocation-context.ts +2 -2
  112. package/src/openclaw-plugin.ts +409 -0
  113. package/src/orca-session-title.test.ts +54 -0
  114. package/src/orca-session-title.ts +103 -0
  115. package/src/program.test.ts +27 -0
  116. package/src/program.ts +47 -12
  117. package/src/send.ts +10 -19
  118. package/src/session-activation.ts +1 -1
  119. package/src/session-labels.test.ts +58 -9
  120. package/src/session-labels.ts +95 -12
  121. package/src/skill.test.ts +14 -3
  122. package/src/sound-ref.test.ts +31 -0
  123. package/src/sound-ref.ts +34 -0
  124. package/src/ui/config-view.test.ts +1 -0
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
@@ -12,6 +12,8 @@ import {
12
12
  lstatSync,
13
13
  mkdirSync,
14
14
  readFileSync,
15
+ readdirSync,
16
+ rmdirSync,
15
17
  rmSync,
16
18
  } from 'node:fs'
17
19
  import path from 'node:path'
@@ -21,7 +23,14 @@ import { ApiCallError } from './client.js'
21
23
  import { codexWakeRoute } from './codex-wake.js'
22
24
  import { loadConfig, type CliConfig } from './config.js'
23
25
  import { withTargetFileLock } from './file-lock.js'
24
- import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
26
+ import {
27
+ HARNESS_LABELS,
28
+ HERMES_QUESTION_ROUTING_UNAVAILABLE,
29
+ HOOK_INSTALLABLE_HARNESSES,
30
+ isHookInstallableHarness,
31
+ questionRoutingCapability,
32
+ type HookInstallableHarness,
33
+ } from './harnesses.js'
25
34
  import {
26
35
  inspectHookAdapter,
27
36
  installHookAdapter,
@@ -51,7 +60,6 @@ import {
51
60
  } from './hooks.js'
52
61
  import {
53
62
  NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
54
- HARNESSES,
55
63
  applyPlan,
56
64
  buildCursorHookConfig,
57
65
  buildHookConfig,
@@ -75,13 +83,24 @@ import {
75
83
  removeHooks,
76
84
  settingsFile,
77
85
  withCodexLayerTransaction,
78
- type Harness,
79
86
  type Installation,
80
87
  } from './install-hooks.js'
81
88
  import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
82
89
  import { inferInvocationContext } from './invocation-context.js'
83
90
  import { logConfigResolved, logSettingsFrom } from './logging.js'
84
91
  import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js'
92
+ import {
93
+ OPENCLAW_PLUGIN_MANIFEST,
94
+ OPENCLAW_PLUGIN_PACKAGE,
95
+ isOurOpenclawPlugin,
96
+ mergeOpenclawNotifaiEntry,
97
+ openclawConfigPath,
98
+ openclawPluginManifest,
99
+ openclawPluginPackage,
100
+ openclawPluginSource,
101
+ parseOpenclawConfig,
102
+ removeOpenclawNotifaiEntry,
103
+ } from './openclaw-plugin.js'
85
104
  import { packageVersion } from './release.js'
86
105
  import { enableProject, projectBinding, projectEnabled } from './project-enablement.js'
87
106
  import { rejectAccidentalEscapedNewlines } from './send.js'
@@ -99,6 +118,7 @@ import {
99
118
  } from './send.js'
100
119
  import {
101
120
  EXIT,
121
+ SETUP_COMMAND,
102
122
  authedClient,
103
123
  diagnoseIgnoredOriginOverride,
104
124
  loadLoggedConfig,
@@ -138,8 +158,8 @@ export const HOOK_EVENTS = [
138
158
  export type HookEvent = (typeof HOOK_EVENTS)[number]
139
159
 
140
160
  /** Lifecycle handlers one installed harness must carry in this CLI build. */
141
- export function requiredHookEvents(harness: Harness): readonly HookEvent[] {
142
- if (harness === 'opencode') return []
161
+ export function requiredHookEvents(harness: HookInstallableHarness): readonly HookEvent[] {
162
+ if (harness === 'opencode' || harness === 'openclaw') return []
143
163
  return harness === 'cursor'
144
164
  ? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
145
165
  : ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end']
@@ -464,7 +484,7 @@ export async function hookRunCommand(
464
484
  const credential = deps.store.load()
465
485
  if (!credential) {
466
486
  logger.error('hook.end', { hook: event, outcome: 'not-paired' })
467
- deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
487
+ deps.io.err(`notifai: hook skipped: this machine is not paired; run \`${SETUP_COMMAND}\``)
468
488
  return EXIT.ok
469
489
  }
470
490
  // Pin authenticated traffic to the origin the credential was issued for. A
@@ -572,7 +592,7 @@ export async function hookRunCommand(
572
592
  */
573
593
  function stopWakeRoute(
574
594
  deps: CommandDeps,
575
- harness: Harness | undefined,
595
+ harness: HookInstallableHarness | undefined,
576
596
  sessionId: string | undefined,
577
597
  cwd: string,
578
598
  ): EscalationDeliveryRoute | undefined {
@@ -1102,6 +1122,17 @@ export function askCommand(
1102
1122
  'run the ask from a shell with one exact active harness session',
1103
1123
  )
1104
1124
  }
1125
+ if (!isHookInstallableHarness(active.harness)) {
1126
+ const capability = HERMES_QUESTION_ROUTING_UNAVAILABLE
1127
+ return askFailure(
1128
+ deps,
1129
+ flags,
1130
+ 'question_routing_unavailable',
1131
+ 'hook_contract',
1132
+ `${active.label}: ${capability.deliveryContract}`,
1133
+ 'use a blocking `notifai send --reply` question',
1134
+ )
1135
+ }
1105
1136
  const exactState = active.sessionId === undefined
1106
1137
  ? null
1107
1138
  : readSessionState(active.sessionId, deps.env)
@@ -1253,7 +1284,9 @@ export function activeQuestionRouteProblems(
1253
1284
  `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
1285
  )
1255
1286
  }
1256
- const capability = HARNESS_CAPABILITIES[active.harness]
1287
+ const capability = isHookInstallableHarness(active.harness)
1288
+ ? questionRoutingCapability(active.harness)
1289
+ : HERMES_QUESTION_ROUTING_UNAVAILABLE
1257
1290
  if (capability.stopContinuation === 'unsupported') {
1258
1291
  problems.push(`${active.label}: ${capability.deliveryContract}`)
1259
1292
  }
@@ -1330,6 +1363,11 @@ export function hookActivationAdvice(installations: Installation[]): string {
1330
1363
  'OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
1331
1364
  )
1332
1365
  }
1366
+ if (harnesses.has('openclaw')) {
1367
+ advice.push(
1368
+ 'OpenClaw: restart the Gateway, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
1369
+ )
1370
+ }
1333
1371
  return `${advice.join('. ')}.`
1334
1372
  }
1335
1373
 
@@ -1683,7 +1721,7 @@ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags):
1683
1721
  return { execPath, scriptPath }
1684
1722
  }
1685
1723
 
1686
- function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
1724
+ function printHooksInstallClose(deps: CommandDeps, harness: HookInstallableHarness, file: string): void {
1687
1725
  const label = HARNESS_LABELS[harness]
1688
1726
  const activation =
1689
1727
  harness === 'codex'
@@ -1692,6 +1730,8 @@ function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: strin
1692
1730
  ? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
1693
1731
  : harness === 'opencode'
1694
1732
  ? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
1733
+ : harness === 'openclaw'
1734
+ ? 'Restart the OpenClaw Gateway, start one fresh Agent Session, send one prompt, then run `notifai doctor`.'
1695
1735
  : `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`
1696
1736
  if (deps.io.interactive === true && deps.io.note) {
1697
1737
  void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`)
@@ -1705,7 +1745,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1705
1745
  if (flags.harness === undefined) {
1706
1746
  const detected = detectedHarnesses(deps.cwd, deps.env)
1707
1747
  if (detected.length === 0) {
1708
- deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`)
1748
+ deps.io.err(`Could not tell which harness you mean — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
1709
1749
  return EXIT.usage
1710
1750
  }
1711
1751
  let ok = true
@@ -1784,6 +1824,16 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1784
1824
  ...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
1785
1825
  })
1786
1826
  }
1827
+ if (harness === 'openclaw') {
1828
+ return installOpenclawPlugin(deps, settingsTarget, {
1829
+ adapterPath,
1830
+ timeoutSeconds: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
1831
+ platform: hookPlatform,
1832
+ nodePath,
1833
+ global: wantGlobal,
1834
+ ...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
1835
+ })
1836
+ }
1787
1837
 
1788
1838
  if (harness === 'cursor') {
1789
1839
  try {
@@ -1930,6 +1980,113 @@ function installOpencodePlugin(
1930
1980
  return EXIT.ok
1931
1981
  }
1932
1982
 
1983
+ function installOpenclawPlugin(
1984
+ deps: CommandDeps,
1985
+ file: string,
1986
+ options: {
1987
+ adapterPath: string
1988
+ timeoutSeconds: number
1989
+ platform?: NodeJS.Platform
1990
+ nodePath?: string
1991
+ global: boolean
1992
+ narrate?: boolean
1993
+ },
1994
+ ): number {
1995
+ const pluginDir = path.dirname(file)
1996
+ try {
1997
+ withTargetFileLock(file, () => {
1998
+ if (existsSync(file)) {
1999
+ assertOwnedRegularFile(file)
2000
+ const existing = readFileSync(file, 'utf8')
2001
+ if (!isOurOpenclawPlugin(existing)) {
2002
+ throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
2003
+ }
2004
+ }
2005
+ atomicWriteFileSync(file, openclawPluginSource(options), {
2006
+ mode: 0o600,
2007
+ preserveMode: false,
2008
+ requireCurrentUserOwner: true,
2009
+ })
2010
+ atomicWriteFileSync(path.join(pluginDir, OPENCLAW_PLUGIN_MANIFEST), openclawPluginManifest(), {
2011
+ mode: 0o600,
2012
+ preserveMode: false,
2013
+ requireCurrentUserOwner: true,
2014
+ })
2015
+ atomicWriteFileSync(path.join(pluginDir, OPENCLAW_PLUGIN_PACKAGE), openclawPluginPackage(), {
2016
+ mode: 0o600,
2017
+ preserveMode: false,
2018
+ requireCurrentUserOwner: true,
2019
+ })
2020
+ })
2021
+ writeOpenclawEnablement(deps, pluginDir, options.global)
2022
+ } catch (err) {
2023
+ deps.io.err(String(err))
2024
+ return EXIT.failed
2025
+ }
2026
+ if (options.narrate !== false) printHooksInstallClose(deps, 'openclaw', file)
2027
+ return EXIT.ok
2028
+ }
2029
+
2030
+ function writeOpenclawEnablement(deps: CommandDeps, pluginDir: string, global: boolean): void {
2031
+ const configFile = openclawConfigPath(deps.env, deps.hookPlatform)
2032
+ withTargetFileLock(configFile, () => {
2033
+ let config: Record<string, unknown> = {}
2034
+ if (existsSync(configFile)) {
2035
+ assertOwnedRegularFile(configFile)
2036
+ config = parseOpenclawConfig(readFileSync(configFile, 'utf8'))
2037
+ }
2038
+ const merged = mergeOpenclawNotifaiEntry(config, pluginDir, global)
2039
+ atomicWriteFileSync(configFile, `${JSON.stringify(merged, null, 2)}\n`, {
2040
+ mode: 0o600,
2041
+ preserveMode: true,
2042
+ requireCurrentUserOwner: true,
2043
+ })
2044
+ })
2045
+ }
2046
+
2047
+ function uninstallOpenclawPlugin(deps: CommandDeps, file: string): number {
2048
+ const pluginDir = path.dirname(file)
2049
+ try {
2050
+ const removed = withTargetFileLock(file, () => {
2051
+ if (!existsSync(file)) return false
2052
+ assertOwnedRegularFile(file)
2053
+ if (!isOurOpenclawPlugin(readFileSync(file, 'utf8'))) {
2054
+ deps.io.out(`Left ${file} alone: Notifai did not write it.`)
2055
+ return false
2056
+ }
2057
+ rmSync(file, { force: true })
2058
+ rmSync(path.join(pluginDir, OPENCLAW_PLUGIN_MANIFEST), { force: true })
2059
+ rmSync(path.join(pluginDir, OPENCLAW_PLUGIN_PACKAGE), { force: true })
2060
+ if (existsSync(pluginDir) && readdirSync(pluginDir).length === 0) {
2061
+ rmdirSync(pluginDir)
2062
+ }
2063
+ return true
2064
+ })
2065
+ if (removed) {
2066
+ const configFile = openclawConfigPath(deps.env, deps.hookPlatform)
2067
+ if (existsSync(configFile)) {
2068
+ withTargetFileLock(configFile, () => {
2069
+ assertOwnedRegularFile(configFile)
2070
+ const config = parseOpenclawConfig(readFileSync(configFile, 'utf8'))
2071
+ const next = removeOpenclawNotifaiEntry(config, pluginDir)
2072
+ atomicWriteFileSync(configFile, `${JSON.stringify(next, null, 2)}\n`, {
2073
+ mode: 0o600,
2074
+ preserveMode: true,
2075
+ requireCurrentUserOwner: true,
2076
+ })
2077
+ })
2078
+ }
2079
+ deps.io.out(`Removed the Notifai OpenClaw plugin at ${file}`)
2080
+ } else if (!existsSync(file)) {
2081
+ deps.io.out(`Nothing to remove: ${file} does not exist.`)
2082
+ }
2083
+ return EXIT.ok
2084
+ } catch (err) {
2085
+ deps.io.err(String(err))
2086
+ return EXIT.failed
2087
+ }
2088
+ }
2089
+
1933
2090
  export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
1934
2091
  const harness = resolveHarness(deps, flags.harness)
1935
2092
  if (!harness) return EXIT.usage
@@ -1963,6 +2120,9 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1963
2120
  return EXIT.failed
1964
2121
  }
1965
2122
  }
2123
+ if (harness === 'openclaw') {
2124
+ return uninstallOpenclawPlugin(deps, file)
2125
+ }
1966
2126
  if (harness === 'cursor') {
1967
2127
  let stripped: ReturnType<typeof removeCursorHooks> | null
1968
2128
  try {
@@ -2049,17 +2209,17 @@ function assertOwnedRegularFile(file: string): void {
2049
2209
  }
2050
2210
  }
2051
2211
 
2052
- function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
2212
+ function resolveHarness(deps: CommandDeps, requested: string | undefined): HookInstallableHarness | null {
2053
2213
  if (requested !== undefined) {
2054
- if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
2214
+ if ((HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(requested)) return requested as HookInstallableHarness
2055
2215
  deps.io.err(
2056
- `Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`,
2216
+ `Unknown harness "${requested}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`,
2057
2217
  )
2058
2218
  return null
2059
2219
  }
2060
2220
  const detected = detectHarness(deps.cwd, deps.env)
2061
2221
  if (!detected) {
2062
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
2222
+ deps.io.err(`Could not tell which harness to install for — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
2063
2223
  return null
2064
2224
  }
2065
2225
  return detected
@@ -2073,7 +2233,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
2073
2233
  export async function pickHarnessesToInstall(
2074
2234
  deps: CommandDeps,
2075
2235
  requested?: string,
2076
- ): Promise<Harness[] | null> {
2236
+ ): Promise<HookInstallableHarness[] | null> {
2077
2237
  if (requested !== undefined) {
2078
2238
  const harness = resolveHarness(deps, requested)
2079
2239
  return harness === null ? null : [harness]
@@ -2083,7 +2243,7 @@ export async function pickHarnessesToInstall(
2083
2243
  if (deps.io.interactive === true && deps.io.multiselect) {
2084
2244
  const picked = await deps.io.multiselect(
2085
2245
  'Which agent harnesses should Notifai wire here?',
2086
- HARNESSES.map((name) => ({
2246
+ HOOK_INSTALLABLE_HARNESSES.map((name) => ({
2087
2247
  value: name,
2088
2248
  label: HARNESS_LABELS[name],
2089
2249
  ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
@@ -2091,16 +2251,16 @@ export async function pickHarnessesToInstall(
2091
2251
  detected,
2092
2252
  )
2093
2253
  if (picked === null) return null
2094
- const unknown = picked.filter((name) => !(HARNESSES as readonly string[]).includes(name))
2254
+ const unknown = picked.filter((name) => !(HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(name))
2095
2255
  if (unknown.length > 0) {
2096
- deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`)
2256
+ deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`)
2097
2257
  return null
2098
2258
  }
2099
- return picked as Harness[]
2259
+ return picked as HookInstallableHarness[]
2100
2260
  }
2101
2261
  if (detected.length === 0) {
2102
2262
  deps.io.err(
2103
- `Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
2263
+ `Could not tell which harness to wire. Run: notifai hooks install --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>`,
2104
2264
  )
2105
2265
  return null
2106
2266
  }
@@ -25,6 +25,7 @@ import { buildDraft, formatReceipt } from './send.js'
25
25
  import { loginCommand } from './commands-auth.js'
26
26
  import {
27
27
  EXIT,
28
+ SETUP_COMMAND,
28
29
  UPDATE_CLI_COMMAND,
29
30
  authedClient,
30
31
  loadLoggedConfig,
@@ -848,8 +849,8 @@ async function printInitClose(
848
849
  await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
849
850
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
850
851
  deps.io.out(` ${remedyLine(blocker)}`)
851
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
852
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
852
+ if (shouldRestateInit(blocker)) {
853
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
853
854
  }
854
855
  await deps.io.outro?.('One step remains (above)')
855
856
  return
@@ -866,7 +867,12 @@ async function printInitClose(
866
867
  }
867
868
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
868
869
  deps.io.out(` ${remedyLine(blocker)}`)
869
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
870
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
870
+ if (shouldRestateInit(blocker)) {
871
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
871
872
  }
872
873
  }
874
+
875
+ function shouldRestateInit(blocker: ReadinessState): boolean {
876
+ if (blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.by !== 'user-here') return false
877
+ return !('command' in blocker.remedy) || blocker.remedy.command !== SETUP_COMMAND
878
+ }
@@ -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
+ }