@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
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What `--sound` and the saved `sound` key accept: shipped semantic names,
3
+ * the CLI silent spelling, an Account custom sound id, or a custom display name.
4
+ */
5
+ export declare function isCliSoundRef(value: string): boolean;
6
+ export declare function unknownSoundMessage(sound: string): string;
7
+ /** Shipped names plus the CLI silent spelling, with User-facing labels. */
8
+ export declare const SHIPPED_SOUND_LISTING: readonly [{
9
+ readonly ref: "default";
10
+ readonly name: "Device default";
11
+ }, {
12
+ readonly ref: "done";
13
+ readonly name: "completion chime";
14
+ }, {
15
+ readonly ref: "attention";
16
+ readonly name: "attention tone";
17
+ }, {
18
+ readonly ref: "alert";
19
+ readonly name: "most insistent tone";
20
+ }, {
21
+ readonly ref: "none";
22
+ readonly name: "silent";
23
+ }];
@@ -0,0 +1,28 @@
1
+ import { CLI_SOUNDS, SOUND_NAME_MAX_LENGTH, SOUND_REF_MAX_LENGTH, isCustomSoundId, } from '@raidiant/notifai-protocol';
2
+ /**
3
+ * What `--sound` and the saved `sound` key accept: shipped semantic names,
4
+ * the CLI silent spelling, an Account custom sound id, or a custom display name.
5
+ */
6
+ export function isCliSoundRef(value) {
7
+ if (CLI_SOUNDS.includes(value))
8
+ return true;
9
+ if (value.length < 1 || value.length > SOUND_REF_MAX_LENGTH)
10
+ return false;
11
+ if (isCustomSoundId(value))
12
+ return true;
13
+ const trimmed = value.trim();
14
+ return trimmed.length >= 1 && trimmed.length <= SOUND_NAME_MAX_LENGTH;
15
+ }
16
+ export function unknownSoundMessage(sound) {
17
+ return (`Unknown sound "${sound}" — use a shipped name (${CLI_SOUNDS.join(', ')}), ` +
18
+ 'a custom sound id, or a custom sound name. Run `notifai sounds` to list what this Account can play.');
19
+ }
20
+ /** Shipped names plus the CLI silent spelling, with User-facing labels. */
21
+ export const SHIPPED_SOUND_LISTING = [
22
+ { ref: 'default', name: 'Device default' },
23
+ { ref: 'done', name: 'completion chime' },
24
+ { ref: 'attention', name: 'attention tone' },
25
+ { ref: 'alert', name: 'most insistent tone' },
26
+ { ref: 'none', name: 'silent' },
27
+ ];
28
+ //# sourceMappingURL=sound-ref.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sound-ref.js","sourceRoot":"","sources":["../src/sound-ref.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,qBAAqB,EACrB,oBAAoB,EACpB,eAAe,GAChB,MAAM,4BAA4B,CAAA;AAEnC;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,IAAK,UAAgC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAClE,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,oBAAoB;QAAE,OAAO,KAAK,CAAA;IACzE,IAAI,eAAe,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACvC,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC5B,OAAO,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,OAAO,CAAC,MAAM,IAAI,qBAAqB,CAAA;AACvE,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,KAAa;IAC/C,OAAO,CACL,kBAAkB,KAAK,2BAA2B,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;QAC5E,qGAAqG,CACtG,CAAA;AACH,CAAC;AAED,2EAA2E;AAC3E,MAAM,CAAC,MAAM,qBAAqB,GAAG;IACnC,EAAE,GAAG,EAAE,SAAS,EAAE,IAAI,EAAE,gBAAgB,EAAE;IAC1C,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,kBAAkB,EAAE;IACzC,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,gBAAgB,EAAE;IAC5C,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,qBAAqB,EAAE;IAC7C,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE;CACvB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidiant/notifai",
3
- "version": "9.2.2",
3
+ "version": "10.0.0",
4
4
  "type": "module",
5
5
  "description": "Send native device notifications from agents and local programs",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^1.7.0",
24
- "@raidiant/notifai-protocol": "5.0.1",
24
+ "@raidiant/notifai-protocol": "6.0.0",
25
25
  "commander": "^14.0.0",
26
26
  "picocolors": "^1.1.1",
27
27
  "smol-toml": "^1.4.0"
@@ -31,7 +31,7 @@
31
31
  "vitest": "^3.2.0"
32
32
  },
33
33
  "engines": {
34
- "node": ">=20"
34
+ "node": ">=22"
35
35
  },
36
36
  "license": "Apache-2.0",
37
37
  "author": "Rafael Vidaurre",
@@ -76,6 +76,7 @@ const event = {
76
76
  remaining: 0,
77
77
  request_ids: ['req_test'],
78
78
  journal_recorded_at: STARTED_AT,
79
+ commitDelivery: () => true,
79
80
  }
80
81
 
81
82
  describe('Claude session observation', () => {
@@ -357,6 +357,7 @@ export function claudeWakeRoute(options: {
357
357
  acknowledgement: 'held',
358
358
  }
359
359
  }
360
+ if (!event.commitDelivery()) return cancelledDelivery()
360
361
  await adapters.resume(options.sessionId, sourceDescriptor.cwd, event.context)
361
362
  return {
362
363
  notes: ['cold-resumed the stopped Claude session with its accepted answer'],
@@ -376,6 +377,7 @@ export function claudeWakeRoute(options: {
376
377
  acknowledgement: 'held',
377
378
  }
378
379
  }
380
+ if (!event.commitDelivery()) return cancelledDelivery()
379
381
  await adapters.sendSocket(
380
382
  observation.descriptor.messagingSocketPath,
381
383
  socketLine(event.context),
@@ -400,6 +402,14 @@ export function claudeWakeRoute(options: {
400
402
  }
401
403
  }
402
404
 
405
+ function cancelledDelivery(): DeliveryOutcome {
406
+ return {
407
+ notes: ['the Agent Session ended before answer delivery; stopping this observer'],
408
+ log: { route: 'hold-for-next-turn', stage: 'queued', reason: 'session-ended' },
409
+ acknowledgement: 'held',
410
+ }
411
+ }
412
+
403
413
  function runClaude(args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> {
404
414
  return new Promise((resolve, reject) => {
405
415
  const child = spawn('claude', args, {
@@ -110,6 +110,18 @@ describe('a server that never answers', () => {
110
110
  ])
111
111
  })
112
112
 
113
+ it('lists Account custom sounds from the catalog endpoint', async () => {
114
+ let seen: string | undefined
115
+ const baseUrl = await serving((request, response) => {
116
+ seen = `${request.method} ${request.url}`
117
+ response.setHeader('content-type', 'application/json')
118
+ response.end(JSON.stringify({ sounds: [] }))
119
+ })
120
+ const result = await createClient(baseUrl, 'Bearer test').listSounds()
121
+ expect(seen).toBe('GET /api/v1/sounds')
122
+ expect(result).toEqual({ sounds: [] })
123
+ })
124
+
113
125
  it('refuses redirects on bearer-authenticated calls', async () => {
114
126
  let followed = false
115
127
  const baseUrl = await serving((request, response) => {
package/src/client.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  EvidenceSnapshot,
10
10
  GetAgentAcknowledgementResponse,
11
11
  ListDevicesResponse,
12
+ ListSoundsResponse,
12
13
  ClientCapability,
13
14
  CompatibilityResponse,
14
15
  RecoveryAction,
@@ -17,6 +18,8 @@ import type {
17
18
  PollPairingResponse,
18
19
  PutAgentAcknowledgementRequestT,
19
20
  PutAgentAcknowledgementResponse,
21
+ PutAgentSessionLabelRequestT,
22
+ AgentSessionView,
20
23
  SubmissionReceipt,
21
24
  SubmitNotificationRequestT,
22
25
  } from '@raidiant/notifai-protocol'
@@ -76,6 +79,7 @@ export interface ApiClient {
76
79
  pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
77
80
  accessStatus(): Promise<AccountAccessResponse>
78
81
  listDevices(): Promise<ListDevicesResponse>
82
+ listSounds(): Promise<ListSoundsResponse>
79
83
  capabilities(
80
84
  platform?: Platform,
81
85
  appVersion?: string,
@@ -94,6 +98,7 @@ export interface ApiClient {
94
98
  requestId: string,
95
99
  body: PutAgentAcknowledgementRequestT,
96
100
  ): Promise<PutAgentAcknowledgementResponse>
101
+ putAgentSessionLabel(body: PutAgentSessionLabelRequestT): Promise<AgentSessionView>
97
102
  agentAcknowledgement(
98
103
  requestId: string,
99
104
  options: { waitSeconds: number },
@@ -250,6 +255,7 @@ export function createClient(
250
255
  }),
251
256
  accessStatus: () => call('GET', '/api/v1/account/access'),
252
257
  listDevices: () => call('GET', '/api/v1/devices'),
258
+ listSounds: () => call('GET', '/api/v1/sounds'),
253
259
  capabilities: (platform = 'ios', appVersion, appBuild) => {
254
260
  const query = new URLSearchParams()
255
261
  if (appVersion !== undefined) query.set('app_version', appVersion)
@@ -279,6 +285,8 @@ export function createClient(
279
285
  `/api/v1/notifications/${encodeURIComponent(requestId)}/agent-acknowledgement`,
280
286
  body,
281
287
  ),
288
+ putAgentSessionLabel: (body) =>
289
+ call<AgentSessionView>('PUT', '/api/v1/agent-sessions/label', body),
282
290
  agentAcknowledgement: (requestId, { waitSeconds }) =>
283
291
  call<GetAgentAcknowledgementResponse>(
284
292
  'GET',
@@ -21,6 +21,7 @@ const event = {
21
21
  remaining: 0,
22
22
  request_ids: ['req_test'],
23
23
  journal_recorded_at: 1_800_000_000_000,
24
+ commitDelivery: () => true,
24
25
  }
25
26
 
26
27
  const temporaries: string[] = []
package/src/codex-wake.ts CHANGED
@@ -156,6 +156,7 @@ export function codexWakeRoute(options: {
156
156
  const raced = holdForNextTurn(confirmed)
157
157
  if (raced !== null) return raced
158
158
 
159
+ if (!event.commitDelivery()) return cancelledDelivery()
159
160
  await adapters.resume(options.threadId, options.cwd, event.context)
160
161
  return {
161
162
  notes: ['cold-resumed the stopped Codex thread with its accepted answer'],
@@ -168,6 +169,14 @@ export function codexWakeRoute(options: {
168
169
  }
169
170
  }
170
171
 
172
+ function cancelledDelivery(): DeliveryOutcome {
173
+ return {
174
+ notes: ['the Agent Session ended before answer delivery; stopping this observer'],
175
+ log: { route: 'hold-for-next-turn', stage: 'queued', reason: 'session-ended' },
176
+ acknowledgement: 'held',
177
+ }
178
+ }
179
+
171
180
  /** The journal, with the exact reason the thread could not be resumed. */
172
181
  function holdForNextTurn(observation: CodexWakeObservation): DeliveryOutcome | null {
173
182
  if (observation.state === 'stopped') return null
@@ -0,0 +1,72 @@
1
+ import { resolveActiveHarness } from './commands-harness-context.js'
2
+ import {
3
+ authedClient,
4
+ EXIT,
5
+ loadLoggedConfig,
6
+ reportError,
7
+ type CommandDeps,
8
+ } from './commands-core.js'
9
+ import {
10
+ normalizeExplicitSessionLabel,
11
+ renameStoredSessionLabel,
12
+ } from './session-labels.js'
13
+
14
+ export interface AgentSessionRenameFlags {
15
+ json?: boolean
16
+ }
17
+
18
+ /** Rename only the exact harness-owned Agent Session running this command. */
19
+ export async function agentSessionRenameCommand(
20
+ deps: CommandDeps,
21
+ label: string,
22
+ flags: AgentSessionRenameFlags = {},
23
+ ): Promise<number> {
24
+ const now = deps.now?.() ?? Date.now()
25
+ const resolution = resolveActiveHarness(deps.env, deps.cwd, now)
26
+ const active = resolution.active
27
+ if (resolution.contested.length > 0 || active?.sessionId === undefined) {
28
+ deps.io.err(
29
+ 'No exact active Agent Session can be proven here. Run this command inside the Agent Session you intend to rename.',
30
+ )
31
+ return EXIT.usage
32
+ }
33
+
34
+ const normalized = normalizeExplicitSessionLabel({
35
+ env: deps.env,
36
+ sessionId: active.sessionId,
37
+ label,
38
+ })
39
+ if (!normalized.ok) {
40
+ deps.io.err(normalized.error)
41
+ return EXIT.usage
42
+ }
43
+
44
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
45
+ const auth = authedClient(deps, config)
46
+ if (!auth) return EXIT.auth
47
+ try {
48
+ const renamed = await auth.client.putAgentSessionLabel({
49
+ session_id: active.sessionId,
50
+ label: normalized.label,
51
+ })
52
+ const local = renameStoredSessionLabel({
53
+ env: deps.env,
54
+ sessionId: active.sessionId,
55
+ label: renamed.label,
56
+ harness: active.harness,
57
+ now,
58
+ })
59
+ if (!local.ok) {
60
+ deps.io.err(local.error)
61
+ return EXIT.failed
62
+ }
63
+ deps.io.out(
64
+ flags.json
65
+ ? JSON.stringify(renamed)
66
+ : `Agent Session renamed to "${renamed.label}" across your Notifai account.`,
67
+ )
68
+ return EXIT.ok
69
+ } catch (err) {
70
+ return reportError(deps, err, { operation: 'agent_session_rename' })
71
+ }
72
+ }
@@ -26,6 +26,7 @@ import {
26
26
  import { normalizeOrigin } from './url-policy.js'
27
27
  import { renderConfigExplain, renderConfigList, renderConfigPlain } from './ui/config-view.js'
28
28
  import { EXIT, loadLoggedConfig, type CommandDeps } from './commands-core.js'
29
+ import { isCliSoundRef } from './sound-ref.js'
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // config show / set / unset
@@ -186,10 +187,15 @@ export async function configSetCommand(
186
187
  }
187
188
  value = rawValue === 'true'
188
189
  }
189
- // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
190
- // value the sender would later reject — a typo that only surfaces at the
191
- // moment a notification fails to carry the sound you asked for.
192
- if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
190
+ // Sound is listed as an enum of shipped names so the interactive picker has
191
+ // choices, but it also accepts an Account custom name or id. Other enum keys
192
+ // stay closed: an unrecognised value used to be written and only fail at send.
193
+ if (configKey === 'sound') {
194
+ if (!isCliSoundRef(rawValue)) {
195
+ deps.io.err(`${key} takes ${acceptedValues(configKey)} — not "${rawValue}".`)
196
+ return EXIT.usage
197
+ }
198
+ } else if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
193
199
  deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
194
200
  return EXIT.usage
195
201
  }
@@ -82,7 +82,7 @@ export interface CommandDeps {
82
82
  codexSourcePid?: number
83
83
  /** Test seam and production adapter for the external native skills installer. */
84
84
  nativeSkills?: NativeSkills
85
- /** Test seam for Orca's exact-worktree semantic title lookup. */
85
+ /** Test seam for Orca's exact-worktree task/session title lookup. */
86
86
  orcaSessionTitle?: OrcaSessionTitleLookup
87
87
  /** Test seam for registry reads; production uses fetch. */
88
88
  fetchImpl?: typeof fetch
@@ -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,
@@ -13,8 +18,8 @@ import {
13
18
  readSessionState,
14
19
  } from './hooks.js'
15
20
  import {
16
- BLOCKING_STOP_TIMEOUT_SECONDS,
17
- CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
21
+ NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
22
+ QUESTION_STOP_TIMEOUT_SECONDS,
18
23
  codexCoexistenceNotes,
19
24
  codexHomeNote,
20
25
  codexLayerDir,
@@ -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,
@@ -80,6 +84,7 @@ import {
80
84
  setupProofApplies,
81
85
  } from './commands-setup-proof.js'
82
86
  import { skillReadiness } from './commands-skill.js'
87
+ import { projectBinding, projectEnabled } from './project-enablement.js'
83
88
 
84
89
  // ---------------------------------------------------------------------------
85
90
  // doctor
@@ -229,6 +234,37 @@ function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState
229
234
  }
230
235
  }
231
236
 
237
+ function projectEnablementReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
238
+ const binding = projectBinding(deps.cwd, deps.env, config.project.value)
239
+ if (binding === null) {
240
+ return {
241
+ id: 'project-enablement',
242
+ title: 'Project enablement',
243
+ status: 'optional-gap',
244
+ detail: 'no Project is available here; lifecycle hooks stay silent',
245
+ }
246
+ }
247
+ if (projectEnabled(binding)) {
248
+ return {
249
+ id: 'project-enablement',
250
+ title: 'Project enablement',
251
+ status: 'ready',
252
+ detail: `enabled by the User for Project "${binding.project}"`,
253
+ }
254
+ }
255
+ return {
256
+ id: 'project-enablement',
257
+ title: 'Project enablement',
258
+ status: 'optional-gap',
259
+ detail: `disabled for Project "${binding.project}"; installed lifecycle hooks will emit no model guidance`,
260
+ remedy: {
261
+ by: 'cli',
262
+ summary: 'enable Notifai lifecycle behavior for this Project',
263
+ command: 'notifai project enable',
264
+ },
265
+ }
266
+ }
267
+
232
268
  function remoteStatesFrom(previous: Readiness): {
233
269
  credential: ReadinessState
234
270
  server: ReadinessState
@@ -303,6 +339,7 @@ export async function assessReadiness(
303
339
  return {
304
340
  states: [
305
341
  cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform),
342
+ projectEnablementReadiness(deps, config),
306
343
  projectReadiness(deps, config),
307
344
  reused.credential,
308
345
  reused.server,
@@ -322,6 +359,7 @@ export async function assessReadiness(
322
359
  let accountDevices: RoutableDevice[] | null = null
323
360
 
324
361
  states.push(cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform))
362
+ states.push(projectEnablementReadiness(deps, config))
325
363
  states.push(projectReadiness(deps, config))
326
364
 
327
365
  states.push(
@@ -727,7 +765,11 @@ export function remedyLine(state: ReadinessState): string {
727
765
  */
728
766
  function hookStates(deps: CommandDeps): ReadinessState[] {
729
767
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
730
- 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
+ )
731
773
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
732
774
  const settings: ReadinessState = {
733
775
  id: 'question-routing-settings',
@@ -748,6 +790,28 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
748
790
  }),
749
791
  }
750
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
+ }
751
815
  return [
752
816
  {
753
817
  id: 'hooks',
@@ -838,7 +902,11 @@ interface HookCheck {
838
902
  /** Real but not in the way: worth a line, never a blocker. */
839
903
  informational?: boolean
840
904
  /** A remedy truer than the generic `notifai hooks install`. */
841
- remedy?: { summary: string; command: string }
905
+ remedy?: {
906
+ summary: string
907
+ command: string
908
+ user_action?: { code: string; harness: string; action: string; message: string }
909
+ }
842
910
  }
843
911
 
844
912
  function hookChecks(deps: CommandDeps): HookCheck[] {
@@ -887,7 +955,21 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
887
955
  active === null
888
956
  ? []
889
957
  : installations.filter((installation) => installation.harness === active.harness)
890
- 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) {
891
973
  checks.push({
892
974
  name: 'hooks (active harness)',
893
975
  ok: activeInstallations.length > 0,
@@ -995,6 +1077,12 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
995
1077
  remedy: {
996
1078
  summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
997
1079
  command: '/hooks',
1080
+ user_action: {
1081
+ code: 'codex_hook_approval_required',
1082
+ harness: 'codex',
1083
+ action: 'approve_or_enable_notifai_hooks',
1084
+ message: 'Open `/hooks` in Codex, approve or enable the Notifai handlers, then tell me when it is done. I will finish setup and verify a fresh session.',
1085
+ },
998
1086
  },
999
1087
  }),
1000
1088
  })
@@ -1010,7 +1098,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
1010
1098
  ok: shapeProblems.length === 0,
1011
1099
  detail:
1012
1100
  shapeProblems.length === 0
1013
- ? `every installed Stop handler declares the shape its harness needs: Claude Code async with an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s waiter budget, blocking hosts ${BLOCKING_STOP_TIMEOUT_SECONDS}s, Codex host-owned`
1101
+ ? `every installed Stop handler declares the shape its harness needs: Claude Code async and Codex blocking with an explicit ${QUESTION_STOP_TIMEOUT_SECONDS}s full-window budget; non-routing blocking hosts ${NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS}s`
1014
1102
  : shapeProblems.join('; '),
1015
1103
  })
1016
1104
 
@@ -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
  *