@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidiant/notifai",
3
- "version": "9.3.0",
3
+ "version": "10.1.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.2",
24
+ "@raidiant/notifai-protocol": "6.0.1",
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",
@@ -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',
@@ -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
+ }
@@ -7,6 +7,7 @@ import { type FlagOverrides } from './config.js'
7
7
  import { checkApproveUrl } from './url-policy.js'
8
8
  import {
9
9
  EXIT,
10
+ SETUP_COMMAND,
10
11
  authedClient,
11
12
  loadLoggedConfig,
12
13
  makeClient,
@@ -128,14 +129,14 @@ export async function loginCommand(
128
129
  spinner?.error('Account has no Alpha access')
129
130
  deps.io.err('This account has no active plan or temporary Alpha access.')
130
131
  deps.io.err(`next: ${next}`)
131
- deps.io.err('After access is granted, run `notifai login` again.')
132
+ deps.io.err(`After access is granted, run \`${SETUP_COMMAND}\` again.`)
132
133
  return EXIT.auth
133
134
  }
134
135
  if (poll.status === 'expired') break
135
136
  spinner?.message(approvalWaitMessage())
136
137
  }
137
138
  spinner?.error('Pairing expired')
138
- deps.io.err('Pairing expired before it was approved. Run `notifai login` again.')
139
+ deps.io.err(`Pairing expired before it was approved. Run \`${SETUP_COMMAND}\` again.`)
139
140
  return EXIT.auth
140
141
  }
141
142
 
@@ -166,7 +167,7 @@ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }):
166
167
  return credential ? EXIT.ok : EXIT.auth
167
168
  }
168
169
  if (!credential) {
169
- deps.io.err('Not signed in. Run `notifai login`.')
170
+ deps.io.err(`Not signed in. Run \`${SETUP_COMMAND}\`; it will coordinate machine login and device setup.`)
170
171
  return EXIT.auth
171
172
  }
172
173
  deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`)
@@ -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
  }
@@ -13,6 +13,7 @@ import type { CredentialStore, MachineCredential } from './credentials.js'
13
13
  import { type HookAdapterTarget } from './hook-adapter.js'
14
14
  import { logConfigResolved, logSettingsFrom, nullLogger, type Logger } from './logging.js'
15
15
  import type { NativeSkills } from './native-skills.js'
16
+ import type { OrcaSessionTitleLookup } from './orca-session-title.js'
16
17
  import { packageVersion } from './release.js'
17
18
  import type { Tone } from './ui/theme.js'
18
19
 
@@ -81,6 +82,8 @@ export interface CommandDeps {
81
82
  codexSourcePid?: number
82
83
  /** Test seam and production adapter for the external native skills installer. */
83
84
  nativeSkills?: NativeSkills
85
+ /** Test seam for Orca's exact-worktree task/session title lookup. */
86
+ orcaSessionTitle?: OrcaSessionTitleLookup
84
87
  /** Test seam for registry reads; production uses fetch. */
85
88
  fetchImpl?: typeof fetch
86
89
  /**
@@ -183,6 +186,9 @@ export function authedClient(deps: CommandDeps, config: CliConfig): { client: Ap
183
186
 
184
187
  export const UPDATE_CLI_COMMAND = 'npm install -g @raidiant/notifai'
185
188
 
189
+ /** The one first-run command an unsigned machine is told to run. */
190
+ export const SETUP_COMMAND = 'notifai init'
191
+
186
192
  function affectedDeviceNames(details: unknown): string[] {
187
193
  if (typeof details !== 'object' || details === null || !('device_names' in details)) return []
188
194
  const names = (details as { device_names?: unknown }).device_names
@@ -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,
@@ -43,6 +48,7 @@ import type { Tone } from './ui/theme.js'
43
48
  import type { MachineCredential } from './credentials.js'
44
49
  import {
45
50
  EXIT,
51
+ SETUP_COMMAND,
46
52
  UPDATE_CLI_COMMAND,
47
53
  diagnoseIgnoredOriginOverride,
48
54
  loadLoggedConfig,
@@ -52,7 +58,6 @@ import {
52
58
  } from './commands-core.js'
53
59
  import { deviceInstallRemedy, readyCompanionDevices, supportPageUrl } from './commands-devices.js'
54
60
  import {
55
- activeHarnessSession,
56
61
  claudeSessionPid,
57
62
  resolveActiveHarness,
58
63
  type ActiveHarnessSession,
@@ -299,6 +304,94 @@ function remoteInvalidatedByConfig(
299
304
  return server === undefined || !server.detail.includes(origin)
300
305
  }
301
306
 
307
+ function isNoActivePlanError(err: unknown): err is ApiCallError {
308
+ return err instanceof ApiCallError && err.code === 'no_active_plan'
309
+ }
310
+
311
+ /**
312
+ * Device listing is grant-gated; access status is not. A paired Account whose
313
+ * plan lapsed must not be diagnosed as a revoked machine.
314
+ */
315
+ async function probeAccount(
316
+ client: ApiClient,
317
+ credential: MachineCredential,
318
+ baseUrl: string,
319
+ ): Promise<{
320
+ email: string | null
321
+ devices: RoutableDevice[] | null
322
+ lookupFailed: boolean
323
+ auth: ReadinessState
324
+ }> {
325
+ const [devicesOutcome, accessOutcome] = await Promise.allSettled([
326
+ Promise.resolve().then(() => client.listDevices()),
327
+ Promise.resolve().then(() => client.accessStatus()),
328
+ ])
329
+ const access = accessOutcome.status === 'fulfilled' ? accessOutcome.value : null
330
+ const devices = devicesOutcome.status === 'fulfilled' ? devicesOutcome.value.devices : null
331
+ const email = access?.email ?? null
332
+ const devicesErr = devicesOutcome.status === 'rejected' ? devicesOutcome.reason : null
333
+ const noActivePlan =
334
+ access?.status === 'no_active_plan' || isNoActivePlanError(devicesErr)
335
+
336
+ if (noActivePlan) {
337
+ const next =
338
+ (isNoActivePlanError(devicesErr) ? devicesErr.nextAction : null) ??
339
+ `Open ${supportPageUrl(baseUrl)} to request Alpha access, then retry.`
340
+ return {
341
+ email,
342
+ devices: null,
343
+ lookupFailed: true,
344
+ auth: {
345
+ id: 'auth',
346
+ title: 'Account',
347
+ status: 'gap',
348
+ detail: email
349
+ ? `no active plan or temporary Alpha access (${email})`
350
+ : 'no active plan or temporary Alpha access',
351
+ remedy: {
352
+ by: 'user-elsewhere',
353
+ summary: next,
354
+ },
355
+ },
356
+ }
357
+ }
358
+
359
+ if (devices !== null) {
360
+ return {
361
+ email,
362
+ devices,
363
+ lookupFailed: false,
364
+ auth: {
365
+ id: 'auth',
366
+ title: 'Account',
367
+ status: 'ready',
368
+ detail: email
369
+ ? `machine ${credential.machineId} accepted (${email})`
370
+ : `machine ${credential.machineId} accepted`,
371
+ },
372
+ }
373
+ }
374
+
375
+ const err = devicesErr ?? (accessOutcome.status === 'rejected' ? accessOutcome.reason : null)
376
+ return {
377
+ email: null,
378
+ devices: null,
379
+ lookupFailed: true,
380
+ auth: {
381
+ id: 'auth',
382
+ title: 'Account',
383
+ status: 'gap',
384
+ detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
385
+ remedy: {
386
+ by: 'user-here',
387
+ summary: 'this machine is no longer recognised; pair it again',
388
+ command: SETUP_COMMAND,
389
+ interactive: true,
390
+ },
391
+ },
392
+ }
393
+ }
394
+
302
395
  /**
303
396
  * Read the whole setup once, in dependency order.
304
397
  *
@@ -374,7 +467,7 @@ export async function assessReadiness(
374
467
  remedy: {
375
468
  by: 'user-here',
376
469
  summary: 'sign in — this opens your browser to approve the machine',
377
- command: 'notifai login',
470
+ command: SETUP_COMMAND,
378
471
  interactive: true,
379
472
  },
380
473
  },
@@ -433,39 +526,11 @@ export async function assessReadiness(
433
526
  } else {
434
527
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
435
528
  accountClient = client
436
- try {
437
- const [{ devices }, email] = await Promise.all([
438
- client.listDevices(),
439
- Promise.resolve()
440
- .then(async () => (await client.accessStatus()).email)
441
- .catch(() => null as string | null),
442
- ])
443
- accountDevices = devices
444
- accountEmail = email
445
- states.push({
446
- id: 'auth',
447
- title: 'Account',
448
- status: 'ready',
449
- detail: accountEmail
450
- ? `machine ${credential.machineId} accepted (${accountEmail})`
451
- : `machine ${credential.machineId} accepted`,
452
- })
453
- } catch (err) {
454
- // A credential the server rejects is revocation, not absence, and the
455
- // remedy is the same sign-in either way.
456
- accountLookupFailed = true
457
- states.push({
458
- id: 'auth',
459
- title: 'Account',
460
- status: 'gap',
461
- detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
462
- remedy: {
463
- by: 'user-here',
464
- summary: 'this machine is no longer recognised; pair it again',
465
- command: 'notifai login',
466
- },
467
- })
468
- }
529
+ const probed = await probeAccount(client, credential, baseUrl)
530
+ accountDevices = probed.devices
531
+ accountEmail = probed.email
532
+ accountLookupFailed = probed.lookupFailed
533
+ states.push(probed.auth)
469
534
  }
470
535
 
471
536
  // Optional setup that works without a companion device must appear before the
@@ -761,7 +826,11 @@ export function remedyLine(state: ReadinessState): string {
761
826
  */
762
827
  function hookStates(deps: CommandDeps): ReadinessState[] {
763
828
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
764
- const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
829
+ const { active, contested } = resolveActiveHarness(
830
+ deps.env,
831
+ deps.cwd,
832
+ (deps.now ?? Date.now)(),
833
+ )
765
834
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
766
835
  const settings: ReadinessState = {
767
836
  id: 'question-routing-settings',
@@ -782,6 +851,28 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
782
851
  }),
783
852
  }
784
853
  if (installations.length === 0) {
854
+ if (contested.length > 1) {
855
+ return [
856
+ {
857
+ id: 'hooks',
858
+ title: 'Question routing',
859
+ status: 'optional-gap',
860
+ detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
861
+ },
862
+ settings,
863
+ ]
864
+ }
865
+ if (active !== null && !isHookInstallableHarness(active.harness)) {
866
+ return [
867
+ {
868
+ id: 'hooks',
869
+ title: 'Question routing',
870
+ status: 'optional-gap',
871
+ detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
872
+ },
873
+ settings,
874
+ ]
875
+ }
785
876
  return [
786
877
  {
787
878
  id: 'hooks',
@@ -925,7 +1016,21 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
925
1016
  active === null
926
1017
  ? []
927
1018
  : installations.filter((installation) => installation.harness === active.harness)
928
- if (active !== null) {
1019
+ if (contested.length > 1) {
1020
+ checks.push({
1021
+ name: 'hooks (active harness)',
1022
+ ok: false,
1023
+ informational: true,
1024
+ detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
1025
+ })
1026
+ } else if (active !== null && !isHookInstallableHarness(active.harness)) {
1027
+ checks.push({
1028
+ name: 'hooks (active harness)',
1029
+ ok: true,
1030
+ informational: true,
1031
+ detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
1032
+ })
1033
+ } else if (active !== null) {
929
1034
  checks.push({
930
1035
  name: 'hooks (active harness)',
931
1036
  ok: activeInstallations.length > 0,
@@ -1,5 +1,10 @@
1
+ import {
2
+ hermesClassicCliLocalInstance,
3
+ isHookInstallableHarness,
4
+ type HermesClassicCliLocalInstance,
5
+ type SourceContextHarness,
6
+ } from './harnesses.js'
1
7
  import { readLiveProjectSessionPointers, readSessionState } from './hooks.js'
2
- import { type Harness } from './install-hooks.js'
3
8
 
4
9
  /**
5
10
  * Exact evidence that this shell command is running inside one supported
@@ -7,11 +12,17 @@ import { type Harness } from './install-hooks.js'
7
12
  * describe where a tool stores files, not which tool owns the current shell.
8
13
  * OpenCode's generated plugin supplies the Notifai-owned marker because its
9
14
  * plugin API exposes Agent Session identity but the ordinary environment does not.
15
+ *
16
+ * Hermes contributes `HERMES_SESSION_ID` only — the durable Agent Session id
17
+ * bridged into tool subprocesses. `HERMES_SESSION_KEY` is a gateway routing
18
+ * key and must never be treated as Agent Session identity.
10
19
  */
11
20
  export interface ActiveHarnessSession {
12
- harness: Harness
21
+ harness: SourceContextHarness
13
22
  label: string
14
23
  sessionId?: string
24
+ /** Exact surface/backend cell proven for this marker envelope. */
25
+ integrationInstance?: HermesClassicCliLocalInstance
15
26
  /** Trusted human title published by this harness's managed adapter. */
16
27
  sessionLabel?: string
17
28
  /** The harness has only a temporary placeholder title so far. */
@@ -40,6 +51,14 @@ function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
40
51
  ...(sessionLabelPending ? { sessionLabelPending: true } : {}),
41
52
  })
42
53
  }
54
+ if (env['NOTIFAI_ACTIVE_HARNESS'] === 'openclaw') {
55
+ const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
56
+ candidates.push({
57
+ harness: 'openclaw',
58
+ label: 'OpenClaw',
59
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
60
+ })
61
+ }
43
62
  if (env['CLAUDECODE'] === '1') {
44
63
  const sessionId = env['CLAUDE_CODE_SESSION_ID']
45
64
  candidates.push({
@@ -53,6 +72,16 @@ function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
53
72
  candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
54
73
  }
55
74
  if ((env['CURSOR_AGENT'] ?? '') !== '') candidates.push({ harness: 'cursor', label: 'Cursor' })
75
+ const hermesSession = env['HERMES_SESSION_ID']
76
+ if (hermesSession !== undefined && hermesSession !== '') {
77
+ const integrationInstance = hermesClassicCliLocalInstance(env)
78
+ candidates.push({
79
+ harness: 'hermes',
80
+ label: 'Hermes',
81
+ sessionId: hermesSession,
82
+ ...(integrationInstance === null ? {} : { integrationInstance }),
83
+ })
84
+ }
56
85
  return candidates
57
86
  }
58
87
 
@@ -97,6 +126,12 @@ export function resolveActiveHarness(
97
126
  if (first === undefined) return { active: null, contested: [] }
98
127
  if (candidates.length === 1) return { active: first, contested: [] }
99
128
 
129
+ // A send-only harness has no hook evidence, so live pointer/state of a
130
+ // hook-installable parent cannot prove which nested process owns this
131
+ // shell. Mixing Hermes with another marker therefore stays contested.
132
+ const mixedWithSendOnly = candidates.some((candidate) => !isHookInstallableHarness(candidate.harness))
133
+ if (mixedWithSendOnly) return { active: first, contested: candidates }
134
+
100
135
  // Exact lifecycle state is machine-global and survives a command moving to
101
136
  // another linked checkout. Prefer it before the checkout-local
102
137
  // pointer. A unique newest hook event proves which nested harness owns this
@@ -133,6 +168,25 @@ export function activeHarnessSession(
133
168
  return resolveActiveHarness(env, cwd, now).active
134
169
  }
135
170
 
171
+ /**
172
+ * Source Context attribution. Hook-installable nesting still uses declared
173
+ * order when nothing has fired — that existing send behavior is preserved.
174
+ * A contested mix that includes a send-only harness (Hermes) fails closed:
175
+ * the wrong Agent Session would otherwise be named as the owner.
176
+ */
177
+ export function sourceContextHarnessSession(
178
+ env: NodeJS.ProcessEnv,
179
+ cwd: string,
180
+ now: number,
181
+ ): ActiveHarnessSession | null {
182
+ const { active, contested } = resolveActiveHarness(env, cwd, now)
183
+ if (contested.some((candidate) => !isHookInstallableHarness(candidate.harness))) {
184
+ return null
185
+ }
186
+ if (active?.harness === 'hermes' && active.integrationInstance === undefined) return null
187
+ return active
188
+ }
189
+
136
190
  /**
137
191
  * The pid of the Claude Code session this hook belongs to.
138
192
  *