@raidiant/notifai 9.2.2 → 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 (149) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/client.d.ts +3 -1
  6. package/dist/client.js +2 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/codex-wake.js +9 -0
  9. package/dist/codex-wake.js.map +1 -1
  10. package/dist/commands-agent-sessions.d.ts +6 -0
  11. package/dist/commands-agent-sessions.js +51 -0
  12. package/dist/commands-agent-sessions.js.map +1 -0
  13. package/dist/commands-config.js +11 -4
  14. package/dist/commands-config.js.map +1 -1
  15. package/dist/commands-core.d.ts +1 -1
  16. package/dist/commands-doctor.js +83 -6
  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 +32 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hook-shape.d.ts +4 -3
  22. package/dist/commands-hook-shape.js +19 -10
  23. package/dist/commands-hook-shape.js.map +1 -1
  24. package/dist/commands-hooks.d.ts +4 -3
  25. package/dist/commands-hooks.js +80 -23
  26. package/dist/commands-hooks.js.map +1 -1
  27. package/dist/commands-init.js +12 -1
  28. package/dist/commands-init.js.map +1 -1
  29. package/dist/commands-project.d.ts +4 -0
  30. package/dist/commands-project.js +41 -0
  31. package/dist/commands-project.js.map +1 -0
  32. package/dist/commands-send-support.js +0 -4
  33. package/dist/commands-send-support.js.map +1 -1
  34. package/dist/commands-send.js +12 -6
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +58 -0
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-sounds.d.ts +12 -0
  39. package/dist/commands-sounds.js +35 -0
  40. package/dist/commands-sounds.js.map +1 -0
  41. package/dist/commands.d.ts +3 -0
  42. package/dist/commands.js +3 -0
  43. package/dist/commands.js.map +1 -1
  44. package/dist/config-schema.js +7 -4
  45. package/dist/config-schema.js.map +1 -1
  46. package/dist/config.d.ts +6 -7
  47. package/dist/config.js +9 -9
  48. package/dist/config.js.map +1 -1
  49. package/dist/credentials.js +4 -1
  50. package/dist/credentials.js.map +1 -1
  51. package/dist/guidance-content.d.ts +3 -0
  52. package/dist/guidance-content.js +20 -0
  53. package/dist/guidance-content.js.map +1 -1
  54. package/dist/guidance-render.js +3 -3
  55. package/dist/guidance-render.js.map +1 -1
  56. package/dist/harnesses.d.ts +49 -7
  57. package/dist/harnesses.js +90 -24
  58. package/dist/harnesses.js.map +1 -1
  59. package/dist/hooks.d.ts +28 -52
  60. package/dist/hooks.js +393 -152
  61. package/dist/hooks.js.map +1 -1
  62. package/dist/install-hooks.d.ts +17 -39
  63. package/dist/install-hooks.js +21 -41
  64. package/dist/install-hooks.js.map +1 -1
  65. package/dist/invocation-context.d.ts +2 -2
  66. package/dist/orca-session-title.d.ts +2 -2
  67. package/dist/orca-session-title.js +3 -4
  68. package/dist/orca-session-title.js.map +1 -1
  69. package/dist/program.d.ts +6 -1
  70. package/dist/program.js +46 -5
  71. package/dist/program.js.map +1 -1
  72. package/dist/project-enablement.d.ts +15 -0
  73. package/dist/project-enablement.js +87 -0
  74. package/dist/project-enablement.js.map +1 -0
  75. package/dist/question-timing.d.ts +46 -0
  76. package/dist/question-timing.js +48 -0
  77. package/dist/question-timing.js.map +1 -0
  78. package/dist/readiness.d.ts +7 -0
  79. package/dist/readiness.js.map +1 -1
  80. package/dist/send.d.ts +2 -0
  81. package/dist/send.js +17 -15
  82. package/dist/send.js.map +1 -1
  83. package/dist/session-activation.d.ts +1 -1
  84. package/dist/session-activation.js +3 -4
  85. package/dist/session-activation.js.map +1 -1
  86. package/dist/session-labels.d.ts +18 -2
  87. package/dist/session-labels.js +79 -8
  88. package/dist/session-labels.js.map +1 -1
  89. package/dist/sound-ref.d.ts +23 -0
  90. package/dist/sound-ref.js +28 -0
  91. package/dist/sound-ref.js.map +1 -0
  92. package/package.json +3 -3
  93. package/src/claude-wake.test.ts +1 -0
  94. package/src/claude-wake.ts +10 -0
  95. package/src/client.test.ts +12 -0
  96. package/src/client.ts +8 -0
  97. package/src/codex-wake.test.ts +1 -0
  98. package/src/codex-wake.ts +9 -0
  99. package/src/commands-agent-sessions.ts +72 -0
  100. package/src/commands-config.ts +10 -4
  101. package/src/commands-core.ts +1 -1
  102. package/src/commands-doctor.ts +96 -8
  103. package/src/commands-harness-context.ts +48 -2
  104. package/src/commands-hook-shape.ts +26 -10
  105. package/src/commands-hooks.ts +95 -31
  106. package/src/commands-init.ts +11 -1
  107. package/src/commands-project.ts +47 -0
  108. package/src/commands-send-support.ts +0 -4
  109. package/src/commands-send.ts +10 -5
  110. package/src/commands-skill.test.ts +44 -0
  111. package/src/commands-skill.ts +57 -0
  112. package/src/commands-sounds.test.ts +106 -0
  113. package/src/commands-sounds.ts +49 -0
  114. package/src/commands.test.ts +386 -23
  115. package/src/commands.ts +10 -0
  116. package/src/config-schema.test.ts +1 -0
  117. package/src/config-schema.ts +7 -4
  118. package/src/config.test.ts +44 -23
  119. package/src/config.ts +13 -15
  120. package/src/credentials.test.ts +20 -0
  121. package/src/credentials.ts +4 -1
  122. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  123. package/src/guidance-content.ts +22 -0
  124. package/src/guidance-render.ts +3 -3
  125. package/src/harness-integration.test.ts +212 -0
  126. package/src/harnesses.test.ts +107 -8
  127. package/src/harnesses.ts +111 -30
  128. package/src/hook-adapter.test.ts +27 -8
  129. package/src/hooks.test.ts +812 -83
  130. package/src/hooks.ts +529 -185
  131. package/src/install-hooks.test.ts +13 -16
  132. package/src/install-hooks.ts +40 -58
  133. package/src/invocation-context.ts +2 -2
  134. package/src/orca-session-title.test.ts +8 -49
  135. package/src/orca-session-title.ts +3 -5
  136. package/src/program.test.ts +45 -0
  137. package/src/program.ts +60 -4
  138. package/src/project-enablement.test.ts +55 -0
  139. package/src/project-enablement.ts +99 -0
  140. package/src/question-timing.ts +56 -0
  141. package/src/readiness.ts +8 -1
  142. package/src/send.ts +18 -20
  143. package/src/session-activation.ts +3 -4
  144. package/src/session-labels.test.ts +58 -9
  145. package/src/session-labels.ts +95 -12
  146. package/src/skill.test.ts +14 -3
  147. package/src/sound-ref.test.ts +31 -0
  148. package/src/sound-ref.ts +34 -0
  149. package/src/ui/config-view.test.ts +1 -0
@@ -13,6 +13,7 @@ import os from 'node:os'
13
13
  import path from 'node:path'
14
14
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
15
15
  import { describe, expect, it } from 'vitest'
16
+ import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
16
17
  import { parseChoices } from './send.js'
17
18
  import {
18
19
  OPENCODE_PLUGIN_MARKER,
@@ -26,9 +27,9 @@ import {
26
27
  WORKER_ACTIVATION_CONTEXT,
27
28
  } from './session-activation.js'
28
29
  import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
30
+ import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
29
31
  import {
30
- BLOCKING_STOP_TIMEOUT_SECONDS,
31
- HARNESSES,
32
+ QUESTION_STOP_TIMEOUT_SECONDS,
32
33
  applyPlan,
33
34
  detectHarness,
34
35
  detectedHarnesses,
@@ -152,18 +153,13 @@ describe('hook config', () => {
152
153
  ).toBe(true)
153
154
  })
154
155
 
155
- it('lets Codex own Stop timeout while keeping short fixed lifecycle budgets', () => {
156
+ it('gives Codex a Stop timeout beyond the longest answer window', () => {
156
157
  const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
157
158
 
158
159
  expect(codex['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
159
160
  expect(codex['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
160
- // Codex hashes the exact serialized definition into `trusted_hash`, so its
161
- // Stop handler stays a bare blocking command: declaring a timeout would
162
- // invalidate the user's approval and the handler would silently stop.
163
- expect(codex['Stop']?.[0]?.hooks[0]).toEqual({
164
- type: 'command',
165
- command: hookCommand(ADAPTER, 'stop', 'codex'),
166
- })
161
+ expect(codex['Stop']?.[0]?.hooks[0]?.timeout).toBe(QUESTION_STOP_TIMEOUT_SECONDS)
162
+ expect(QUESTION_STOP_TIMEOUT_SECONDS).toBeGreaterThan(REPLY_MAX_WINDOW_SECONDS)
167
163
  })
168
164
 
169
165
  it('gives Claude Code an asynchronous Stop with an explicit waiter budget on POSIX', () => {
@@ -176,7 +172,7 @@ describe('hook config', () => {
176
172
  expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
177
173
  type: 'command',
178
174
  command: hookCommand(ADAPTER, 'stop', 'claude-code'),
179
- timeout: 3600,
175
+ timeout: QUESTION_STOP_TIMEOUT_SECONDS,
180
176
  async: true,
181
177
  })
182
178
  expect(claude['UserPromptSubmit']?.[0]?.hooks[0]?.async).toBeUndefined()
@@ -195,7 +191,7 @@ describe('hook config', () => {
195
191
  expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
196
192
  type: 'command',
197
193
  command: hookCommand(options.adapterPath, 'stop', 'claude-code', options),
198
- timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
194
+ timeout: QUESTION_STOP_TIMEOUT_SECONDS,
199
195
  })
200
196
  })
201
197
 
@@ -213,7 +209,7 @@ describe('hook config', () => {
213
209
  expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
214
210
  }
215
211
  expect(migrated.document.hooks?.['Stop']?.[0]?.hooks[0]).toMatchObject({
216
- timeout: 3600,
212
+ timeout: QUESTION_STOP_TIMEOUT_SECONDS,
217
213
  async: true,
218
214
  })
219
215
  expect(migrated.replaced.sort()).toEqual([
@@ -1049,7 +1045,8 @@ describe('the OpenCode adapter', () => {
1049
1045
  })
1050
1046
 
1051
1047
  it('is a harness `hooks install` knows about', () => {
1052
- expect(HARNESSES).toContain('opencode')
1048
+ expect(HOOK_INSTALLABLE_HARNESSES).toContain('opencode')
1049
+ expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
1053
1050
  expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
1054
1051
  })
1055
1052
 
@@ -1103,7 +1100,7 @@ describe('detectHarness', () => {
1103
1100
  const dir = project('AGENTS.md')
1104
1101
  const answer = detectHarness(dir)
1105
1102
  expect(answer).not.toBe('codex')
1106
- expect(HARNESSES.includes(answer as never) || answer === null).toBe(true)
1103
+ expect(HOOK_INSTALLABLE_HARNESSES.includes(answer as never) || answer === null).toBe(true)
1107
1104
  })
1108
1105
 
1109
1106
  it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
@@ -1159,7 +1156,7 @@ describe('detectHarness', () => {
1159
1156
  // Whatever this machine has, an empty project must never crash and must
1160
1157
  // return either a single harness or null — never an arbitrary pick.
1161
1158
  const answer = detectHarness(empty)
1162
- expect(answer === null || HARNESSES.includes(answer)).toBe(true)
1159
+ expect(answer === null || HOOK_INSTALLABLE_HARNESSES.includes(answer)).toBe(true)
1163
1160
  })
1164
1161
  })
1165
1162
 
@@ -20,10 +20,12 @@ 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
-
26
- export { HARNESSES, type Harness } from './harnesses.js'
25
+ import {
26
+ NON_ROUTING_STOP_TIMEOUT_SECONDS,
27
+ QUESTION_STOP_TIMEOUT_SECONDS,
28
+ } from './question-timing.js'
27
29
 
28
30
  /**
29
31
  * The three joints the OpenCode plugin hooks into, as (harness event, the
@@ -69,7 +71,7 @@ export interface HookGroup {
69
71
  export type HookConfig = Record<string, HookGroup[]>
70
72
 
71
73
  export interface InstallPlan {
72
- harness: Harness
74
+ harness: HookInstallableHarness
73
75
  /** Absolute path of the settings file that will be written. */
74
76
  file: string
75
77
  /** True when the file is machine-wide rather than per-project. */
@@ -93,7 +95,7 @@ export interface HookCommandOptions {
93
95
  export function hookCommand(
94
96
  adapterPath: string,
95
97
  event: string,
96
- harness?: Harness,
98
+ harness?: HookInstallableHarness,
97
99
  options: HookCommandOptions = {},
98
100
  ): string {
99
101
  return (
@@ -170,50 +172,28 @@ export interface BuildOptions {
170
172
  /** Stable user-level executable installed by the hook adapter module. */
171
173
  adapterPath: string
172
174
  /** The installed adapter stamps its exact harness into project pointers. */
173
- harness?: Harness
175
+ harness?: HookInstallableHarness
174
176
  platform?: NodeJS.Platform | HookHostPlatform
175
177
  /** Registered Node named first in Windows hook commands. */
176
178
  nodePath?: string
177
179
  }
178
180
 
179
181
  /**
180
- * Claude Code's Stop handler returns instantly and the same process lives on
181
- * out of band as the escalation waiter, so this `timeout` bounds a background
182
- * process rather than the user's turn.
183
- *
184
- * It is always declared, and declared far above the longest wait a waiter can
185
- * take. Claude's default is 600 s and the kill is **silent** — the backgrounded
186
- * process simply vanishes, no error is reported anywhere, and the answer the
187
- * user already gave is never delivered. Sitting anywhere near that boundary
188
- * turns a slow reply into a lost one, so this does not: an explicit value
189
- * raises the ceiling (verified against a 660 s wait under an hour-long
190
- * declaration), and an hour is several times the waiter's own ceiling.
191
- */
192
- export const CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS = 3600
193
-
194
- /**
195
- * The declared process budget for a host that holds its turn while the waiter
196
- * runs: the waiter's own ceiling plus a minute of host headroom, as one plain
197
- * number rather than a budget carved into reserves.
198
- *
199
- * Codex is deliberately absent from this. It owns its Stop timeout today, and
200
- * declaring one would change the serialized definition Codex hashes into
201
- * `trusted_hash` — which silently stops every Notifai handler from running
202
- * until the user re-approves them in `/hooks`.
182
+ * Harnesses without asynchronous Question Routing use this short Stop budget.
183
+ * Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
203
184
  */
204
- export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
185
+ export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS
186
+ export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
205
187
 
206
188
  /**
207
189
  * Whether this harness's Stop handler runs detached from the turn.
208
190
  *
209
- * Three places decide this — the installer that declares `async: true`, the
210
- * hook runtime that picks the waiter's wall clock, and doctor's check that the
211
- * installed definition still has the right shape — and they must agree or the
212
- * waiter outlives a turn nobody left open. They were three separate spellings
213
- * of the same condition with nothing tying them together.
191
+ * The installer and doctor share this predicate so they agree whether Claude
192
+ * runs out of band. Owner lifetime is route-neutral and lives in
193
+ * `question-timing.ts`.
214
194
  */
215
195
  export function stopHandlerIsDetached(
216
- harness: Harness | undefined,
196
+ harness: HookInstallableHarness | undefined,
217
197
  platform?: Parameters<typeof hookHostPlatform>[0],
218
198
  ): boolean {
219
199
  return harness === 'claude-code' && hookHostPlatform(platform) === 'posix'
@@ -224,22 +204,24 @@ export function stopHandlerIsDetached(
224
204
  *
225
205
  * Claude Code takes the answer over its own inbox socket, so its Stop hook is
226
206
  * `async: true`: it returns immediately, the terminal is never held, and the
227
- * waiter finishes out of band. Every other host still delivers by printing a
228
- * continuation to this process's stdout, so its Stop hook blocks and needs a
229
- * declared ceiling above the wait — except Codex, which owns that number
230
- * itself and whose definition must stay byte-stable for its trust store.
207
+ * waiter finishes out of band. Codex and Claude on Windows block and print a
208
+ * continuation to stdout. Every Question Routing owner declares the same
209
+ * complete-window timeout; changing Codex's definition requires one explicit
210
+ * trust approval.
231
211
  */
232
212
  function stopHandler(
233
213
  adapterPath: string,
234
- harness: Harness | undefined,
214
+ harness: HookInstallableHarness | undefined,
235
215
  options: HookCommandOptions,
236
216
  ): HookHandler {
237
217
  const command = hookCommand(adapterPath, 'stop', harness, options)
238
218
  if (stopHandlerIsDetached(harness, options.platform)) {
239
- return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true }
219
+ return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true }
220
+ }
221
+ if (harness === 'codex' || harness === 'claude-code') {
222
+ return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS }
240
223
  }
241
- if (harness === 'codex') return { type: 'command', command }
242
- return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
224
+ return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS }
243
225
  }
244
226
 
245
227
  function commandOptionsFrom(options: BuildOptions): HookCommandOptions {
@@ -350,7 +332,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
350
332
  },
351
333
  {
352
334
  command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
353
- timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
335
+ timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
354
336
  // A continuation may register a real follow-up question. Match the
355
337
  // session-state cap so those chains are useful but never unbounded.
356
338
  loop_limit: 3,
@@ -371,7 +353,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
371
353
  * questions pushed to is not a property of the repository.
372
354
  */
373
355
  export function settingsFile(
374
- harness: Harness,
356
+ harness: HookInstallableHarness,
375
357
  global: boolean,
376
358
  cwd: string,
377
359
  env: NodeJS.ProcessEnv = process.env,
@@ -622,7 +604,7 @@ export function withCodexLayerTransaction<T>(
622
604
 
623
605
  /** Every file in this layer Codex might already be reading hooks from. */
624
606
  export function hookDefinitionFiles(
625
- harness: Harness,
607
+ harness: HookInstallableHarness,
626
608
  global: boolean,
627
609
  cwd: string,
628
610
  env: NodeJS.ProcessEnv = process.env,
@@ -757,8 +739,8 @@ function ourHandlerEvents(document: SettingsDocument | null): string[] {
757
739
  * document. Treating it as evidence of any one harness would be a guess
758
740
  * dressed up as detection.
759
741
  */
760
- function localHarnessEvidence(cwd: string): Harness[] {
761
- const found: Harness[] = []
742
+ function localHarnessEvidence(cwd: string): HookInstallableHarness[] {
743
+ const found: HookInstallableHarness[] = []
762
744
  if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
763
745
  found.push('claude-code')
764
746
  }
@@ -772,9 +754,9 @@ function localHarnessEvidence(cwd: string): Harness[] {
772
754
  function globalHarnessEvidence(
773
755
  env: NodeJS.ProcessEnv = process.env,
774
756
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
775
- ): Harness[] {
757
+ ): HookInstallableHarness[] {
776
758
  const home = harnessAccountHome(env, platform)
777
- const found: Harness[] = []
759
+ const found: HookInstallableHarness[] = []
778
760
  if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
779
761
  if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
780
762
  if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
@@ -793,12 +775,12 @@ export function detectedHarnesses(
793
775
  cwd: string,
794
776
  env: NodeJS.ProcessEnv = process.env,
795
777
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
796
- ): Harness[] {
797
- const seen = new Set<Harness>([
778
+ ): HookInstallableHarness[] {
779
+ const seen = new Set<HookInstallableHarness>([
798
780
  ...localHarnessEvidence(cwd),
799
781
  ...globalHarnessEvidence(env, platform),
800
782
  ])
801
- return HARNESSES.filter((harness) => seen.has(harness))
783
+ return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness))
802
784
  }
803
785
 
804
786
  /**
@@ -814,7 +796,7 @@ export function detectHarness(
814
796
  cwd: string,
815
797
  env: NodeJS.ProcessEnv = process.env,
816
798
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
817
- ): Harness | null {
799
+ ): HookInstallableHarness | null {
818
800
  const local = localHarnessEvidence(cwd)
819
801
  if (local.length > 0) return local.length === 1 ? local[0]! : null
820
802
  const global = globalHarnessEvidence(env, platform)
@@ -1209,7 +1191,7 @@ export interface InstalledHandler {
1209
1191
  }
1210
1192
 
1211
1193
  export interface Installation {
1212
- harness: Harness
1194
+ harness: HookInstallableHarness
1213
1195
  file: string
1214
1196
  global: boolean
1215
1197
  handlers: InstalledHandler[]
@@ -1347,7 +1329,7 @@ export function findInstallations(
1347
1329
  ...(nodePath === undefined ? {} : { nodePath }),
1348
1330
  }
1349
1331
  const found: Installation[] = []
1350
- for (const harness of HARNESSES) {
1332
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
1351
1333
  for (const global of [false, true]) {
1352
1334
  const files = hookDefinitionFiles(harness, global, cwd, env, platform)
1353
1335
  for (const file of files) {
@@ -1427,7 +1409,7 @@ export function findInstallations(
1427
1409
  }
1428
1410
 
1429
1411
  function harnessMarkerProblems(
1430
- harness: Harness,
1412
+ harness: HookInstallableHarness,
1431
1413
  handlers: InstalledHandler[],
1432
1414
  adapterHome?: string,
1433
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
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { readOrcaSessionTitle, type OrcaCommand } from './orca-session-title.js'
3
3
 
4
- const worktreeId = 'repo-123::/worktrees/semantic-session-implementation'
4
+ const worktreeId = 'repo-123::/worktrees/agent-session-labels'
5
5
 
6
6
  function response(overrides: Record<string, unknown> = {}): string {
7
7
  return JSON.stringify({
@@ -9,16 +9,16 @@ function response(overrides: Record<string, unknown> = {}): string {
9
9
  result: {
10
10
  worktree: {
11
11
  id: worktreeId,
12
- path: '/worktrees/semantic-session-implementation',
13
- displayName: 'Worker - semantic session implementation',
12
+ path: '/worktrees/agent-session-labels',
13
+ displayName: 'Agent Session context labels',
14
14
  ...overrides,
15
15
  },
16
16
  },
17
17
  })
18
18
  }
19
19
 
20
- describe('Orca semantic session title', () => {
21
- it('accepts only the display name returned for the exact managed worktree', () => {
20
+ describe('Orca Agent Session title context', () => {
21
+ it('accepts only the User-facing title returned for the exact managed worktree', () => {
22
22
  const calls: { executable: string; args: readonly string[] }[] = []
23
23
  const command: OrcaCommand = (executable, args) => {
24
24
  calls.push({ executable, args })
@@ -26,11 +26,8 @@ describe('Orca semantic session title', () => {
26
26
  }
27
27
 
28
28
  expect(
29
- readOrcaSessionTitle(
30
- { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
31
- command,
32
- ),
33
- ).toBe('Worker - semantic session implementation')
29
+ readOrcaSessionTitle({ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId }, command),
30
+ ).toBe('Agent Session context labels')
34
31
  expect(calls).toEqual([
35
32
  {
36
33
  executable: 'orca',
@@ -39,52 +36,14 @@ describe('Orca semantic session title', () => {
39
36
  ])
40
37
  })
41
38
 
42
- it('uses Orca-provided executable selection without invoking a shell', () => {
43
- const calls: string[] = []
44
- expect(
45
- readOrcaSessionTitle(
46
- {
47
- TERM_PROGRAM: 'Orca',
48
- ORCA_WORKTREE_ID: worktreeId,
49
- ORCA_CLI_COMMAND: '/mnt/c/Program Files/Orca/orca.exe',
50
- },
51
- (executable) => {
52
- calls.push(executable)
53
- return response()
54
- },
55
- ),
56
- ).toBe('Worker - semantic session implementation')
57
- expect(calls).toEqual(['/mnt/c/Program Files/Orca/orca.exe'])
58
- })
59
-
60
- it('ignores non-Orca and malformed selectors without running a command', () => {
61
- const command: OrcaCommand = () => {
62
- throw new Error('must not run')
63
- }
64
- expect(readOrcaSessionTitle({ ORCA_WORKTREE_ID: worktreeId }, command)).toBeUndefined()
65
- expect(
66
- readOrcaSessionTitle(
67
- { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: '/private/untrusted' },
68
- command,
69
- ),
70
- ).toBeUndefined()
71
- expect(
72
- readOrcaSessionTitle(
73
- { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: 'repo-123::relative/path' },
74
- command,
75
- ),
76
- ).toBeUndefined()
77
- })
78
-
79
39
  it.each([
80
40
  ['command failure', () => null],
81
- ['thrown command failure', () => { throw new Error('offline') }],
82
41
  ['malformed JSON', () => '{not json}'],
83
42
  ['failed response', () => JSON.stringify({ ok: false })],
84
43
  ['different worktree id', () => response({ id: 'repo-123::/worktrees/other' })],
85
44
  ['different worktree path', () => response({ path: '/private/untrusted' })],
86
45
  ['blank display name', () => response({ displayName: ' ' })],
87
- ])('fails closed for %s', (_name, command) => {
46
+ ])('leaves the generated fallback exceptional when context is unavailable: %s', (_name, command) => {
88
47
  expect(
89
48
  readOrcaSessionTitle(
90
49
  { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
@@ -26,9 +26,7 @@ function containsControlCharacter(value: string): boolean {
26
26
  }
27
27
 
28
28
  function worktreePathFromId(value: string): string | null {
29
- if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value)) {
30
- return null
31
- }
29
+ if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value)) return null
32
30
  const separator = value.indexOf('::')
33
31
  if (separator <= 0) return null
34
32
  const repoId = value.slice(0, separator)
@@ -48,11 +46,11 @@ function orcaExecutable(env: NodeJS.ProcessEnv): string | null {
48
46
  }
49
47
 
50
48
  /**
51
- * Read the semantic display name assigned to this exact Orca worktree.
49
+ * Read the User-facing task name assigned to this exact Orca worktree.
52
50
  *
53
51
  * The environment contributes only an opaque selector. Orca must return that
54
52
  * exact id and its matching path before its display name becomes trusted; no
55
- * path component or identifier is ever promoted into the User-visible label.
53
+ * path component or identifier is ever promoted into an Agent Session label.
56
54
  */
57
55
  export function readOrcaSessionTitle(
58
56
  env: NodeJS.ProcessEnv,
@@ -98,6 +98,51 @@ describe('program argv parsing', () => {
98
98
  })
99
99
  })
100
100
 
101
+ it('preserves deliberate Projectless send intent at the argv boundary', async () => {
102
+ let seen: Record<string, unknown> | undefined
103
+ await parse(
104
+ ['send', '--kind', 'done', '--title', 'T', '--body', 'B', '--projectless'],
105
+ { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
106
+ )
107
+ expect(seen).toMatchObject({ projectless: true })
108
+ })
109
+
110
+ it('dispatches User-owned Project enablement commands', async () => {
111
+ let enabled = 0
112
+ const result = await parse(['project', 'enable'], {
113
+ projectEnable: ((_deps) => (enabled += 1, 0)) as ProgramRunners['projectEnable'],
114
+ })
115
+ expect(result.exitCode).toBe(0)
116
+ expect(enabled).toBe(1)
117
+ })
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
+
101
146
  it('drops empty collector defaults so "not passed" stays absent', async () => {
102
147
  let seen: Record<string, unknown> | undefined
103
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,
@@ -23,15 +24,19 @@ import {
23
24
  initCommand,
24
25
  loginCommand,
25
26
  logoutCommand,
27
+ projectDisableCommand,
28
+ projectEnableCommand,
29
+ projectStatusCommand,
26
30
  logsCommand,
27
31
  repliesCommand,
28
32
  reportAskFailure,
29
33
  sendCommand,
34
+ soundsCommand,
30
35
  statusCommand,
31
36
  type CommandDeps,
32
37
  } from './commands.js'
33
38
  import { packageVersion } from './release.js'
34
- import { HARNESSES } from './install-hooks.js'
39
+ import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
35
40
  import type { Platform } from '@raidiant/notifai-protocol'
36
41
  import type { SkillScope } from './native-skills.js'
37
42
  import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
@@ -80,7 +85,12 @@ const defaultRunners = {
80
85
  authStatus: authStatusCommand,
81
86
  accessStatus: accessStatusCommand,
82
87
  devices: devicesCommand,
88
+ sounds: soundsCommand,
83
89
  capabilities: capabilitiesCommand,
90
+ projectEnable: projectEnableCommand,
91
+ projectDisable: projectDisableCommand,
92
+ projectStatus: projectStatusCommand,
93
+ agentSessionRename: agentSessionRenameCommand,
84
94
  send: sendCommand,
85
95
  replies: repliesCommand,
86
96
  acknowledge: acknowledgeCommand,
@@ -250,6 +260,36 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
250
260
  exit(await runners.doctor(deps, opts))
251
261
  })
252
262
 
263
+ const project = program
264
+ .command('project')
265
+ .helpGroup(GROUP.daily)
266
+ .summary('Control Notifai for this Project')
267
+ .description('Inspect or change this Project\'s User-owned lifecycle enablement')
268
+ project.command('enable').description('Enable lifecycle guidance for this Project').action(() => {
269
+ exit(runners.projectEnable(deps))
270
+ })
271
+ project.command('disable').description('Disable lifecycle guidance for this Project').action(() => {
272
+ exit(runners.projectDisable(deps))
273
+ })
274
+ project.command('status').description('Show lifecycle enablement for this Project').option('--json').action((opts: { json?: boolean }) => {
275
+ exit(runners.projectStatus(deps, opts.json === true))
276
+ })
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
+
253
293
  program
254
294
  .command('login')
255
295
  .helpGroup(GROUP.start)
@@ -302,6 +342,18 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
302
342
  exit(await runners.devices(deps, opts))
303
343
  })
304
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
+
305
357
  program
306
358
  .command('capabilities')
307
359
  .helpGroup(GROUP.advanced)
@@ -340,6 +392,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
340
392
  .optionsGroup(SEND_GROUP.routing)
341
393
  .option('--kind <kind>', 'what this is (required): update | done | failed | blocked (no User reply would resume it) — question is set by --reply')
342
394
  .option('--project <id>', 'Project identifier override (otherwise configured or inferred)')
395
+ .option('--projectless', 'deliberately omit Project identity for this notification')
343
396
  .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
344
397
  .option('--all', 'target all routable devices (overrides configured devices)')
345
398
  .option('--session-id <id>', 'low-level automation override for an opaque exact Agent Session (env: NOTIFAI_SESSION_ID)')
@@ -367,7 +420,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
367
420
  )
368
421
  .option('--multi', 'with --choice, several answers may be selected')
369
422
  .optionsGroup(SEND_GROUP.advanced)
370
- .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
+ )
371
427
  .option(
372
428
  '--level <level>',
373
429
  'Apple-only interruption level: passive | active | time_sensitive (unsupported with --platform android)',
@@ -413,7 +469,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
413
469
 
414
470
  send.addHelpText(
415
471
  'after',
416
- `\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`,
417
473
  )
418
474
 
419
475
  program
@@ -571,7 +627,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
571
627
  .option('--owner <name>', 'internal ownership marker')
572
628
  .option('--harness <name>', 'internal harness output adapter')
573
629
  .action(async (event: string, opts: { harness?: string }) => {
574
- const harness = HARNESSES.find((candidate) => candidate === opts.harness)
630
+ const harness = HOOK_INSTALLABLE_HARNESSES.find((candidate) => candidate === opts.harness)
575
631
  exit(await runners.hookRun(deps, event, () => readStdinWithTimeout(), harness))
576
632
  })
577
633