@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
@@ -1,5 +1,26 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import { describe, expect, it } from 'vitest'
2
- import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
3
+ import {
4
+ HARNESS_CAPABILITIES,
5
+ HARNESS_LABELS,
6
+ HERMES_CLASSIC_CLI_LOCAL_CAPABILITY,
7
+ HERMES_QUESTION_ROUTING_UNAVAILABLE,
8
+ HOOK_INSTALLABLE_HARNESSES,
9
+ SOURCE_CONTEXT_HARNESSES,
10
+ hermesClassicCliLocalInstance,
11
+ isHookInstallableHarness,
12
+ questionRoutingCapability,
13
+ } from './harnesses.js'
14
+
15
+ const PINNED_HERMES_TRACE = JSON.parse(
16
+ readFileSync(new URL('./fixtures/hermes-0.20.6-classic-cli-local.json', import.meta.url), 'utf8'),
17
+ ) as {
18
+ instance: { harness: 'hermes'; surface: 'classic-cli'; terminalBackend: 'local' }
19
+ observedToolSubprocessEnv: { HERMES_SESSION_ID: string }
20
+ supported: string[]
21
+ unsupported: string[]
22
+ deferred: string[]
23
+ }
3
24
 
4
25
  /**
5
26
  * This file used to hold a verbatim copy of the table below it, which meant
@@ -8,23 +29,42 @@ import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
8
29
  * edit. What is worth asserting is the shape the rest of the CLI assumes.
9
30
  */
10
31
  describe('harness contract', () => {
11
- it('describes every shipped harness, and only those', () => {
12
- expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
13
- expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...HARNESSES].sort())
32
+ it('describes every shipped Source Context harness, and only those', () => {
33
+ expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...SOURCE_CONTEXT_HARNESSES].sort())
34
+ })
35
+
36
+ it('keeps managed hook installation a strict subset of Source Context', () => {
37
+ expect(HOOK_INSTALLABLE_HARNESSES).toEqual([
38
+ 'claude-code',
39
+ 'codex',
40
+ 'cursor',
41
+ 'opencode',
42
+ 'openclaw',
43
+ ])
44
+ expect(SOURCE_CONTEXT_HARNESSES).toContain('hermes')
45
+ expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
46
+ expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HOOK_INSTALLABLE_HARNESSES].sort())
47
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
48
+ expect(isHookInstallableHarness(harness)).toBe(true)
49
+ }
50
+ expect(isHookInstallableHarness('hermes')).toBe(false)
14
51
  })
15
52
 
16
53
  it('gives a harness that cannot continue a turn no route to pretend with', () => {
17
54
  // `ask` fails closed on these, and the reason it can is that the two facts
18
55
  // agree. A harness claiming a delivery route while declaring it cannot
19
56
  // continue would accept an answer into a void.
20
- for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
57
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
58
+ const capability = questionRoutingCapability(harness)
21
59
  if (capability.stopContinuation !== 'unsupported') continue
22
60
  expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
23
61
  }
62
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
24
63
  })
25
64
 
26
65
  it('gives a harness that can continue a turn at least one real route', () => {
27
- for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
66
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
67
+ const capability = HARNESS_CAPABILITIES[harness]
28
68
  if (capability.stopContinuation === 'unsupported') continue
29
69
  expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
30
70
  expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
@@ -35,8 +75,75 @@ describe('harness contract', () => {
35
75
  })
36
76
 
37
77
  it('explains each harness in prose an operator can act on', () => {
38
- for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
39
- expect(capability.deliveryContract.length, harness).toBeGreaterThan(40)
78
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
79
+ expect(questionRoutingCapability(harness).deliveryContract.length, harness).toBeGreaterThan(40)
80
+ }
81
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract.length).toBeGreaterThan(40)
82
+ })
83
+
84
+ it('keeps Claude Code, Codex, Cursor, and OpenCode continuation contracts unchanged', () => {
85
+ expect(HARNESS_CAPABILITIES['claude-code'].stopContinuation).toBe('decision-block')
86
+ expect(HARNESS_CAPABILITIES['claude-code'].deliveryRoutes).toEqual([
87
+ 'hook-continuation',
88
+ 'inbox-socket',
89
+ 'cold-resume',
90
+ 'hold-for-next-turn',
91
+ ])
92
+ expect(HARNESS_CAPABILITIES.codex.stopContinuation).toBe('decision-block')
93
+ expect(HARNESS_CAPABILITIES.codex.deliveryRoutes).toEqual([
94
+ 'hook-continuation',
95
+ 'cold-resume',
96
+ 'hold-for-next-turn',
97
+ ])
98
+ expect(HARNESS_CAPABILITIES.cursor).toEqual({
99
+ stopContinuation: 'unsupported',
100
+ deliveryRoutes: ['unsupported'],
101
+ deliveryContract: HARNESS_CAPABILITIES.cursor.deliveryContract,
102
+ })
103
+ expect(HARNESS_CAPABILITIES.opencode.stopContinuation).toBe('unsupported')
104
+ expect(HARNESS_CAPABILITIES.opencode.deliveryRoutes).toEqual(['unsupported'])
105
+ expect(HARNESS_CAPABILITIES.openclaw.stopContinuation).toBe('unsupported')
106
+ expect(HARNESS_CAPABILITIES.openclaw.deliveryRoutes).toEqual(['unsupported'])
107
+ })
108
+
109
+ it('treats the pinned Hermes classic CLI/local trace as send-only', () => {
110
+ expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance).toEqual(PINNED_HERMES_TRACE.instance)
111
+ expect(PINNED_HERMES_TRACE.supported).toContain('deliberate-send')
112
+ expect(PINNED_HERMES_TRACE.unsupported).toContain('question-routing')
113
+ expect(PINNED_HERMES_TRACE.deferred).toContain('gateway')
114
+ expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.sourceContext).toBe(
115
+ 'hermes-session-id-and-invocation-cwd',
116
+ )
117
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.stopContinuation).toBe('unsupported')
118
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
119
+ })
120
+
121
+ it('resolves only the pinned classic CLI/local marker envelope', () => {
122
+ expect(
123
+ hermesClassicCliLocalInstance({
124
+ HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
125
+ }),
126
+ ).toEqual(PINNED_HERMES_TRACE.instance)
127
+ expect(
128
+ hermesClassicCliLocalInstance({
129
+ HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
130
+ HERMES_SESSION_SOURCE: 'cli',
131
+ TERMINAL_ENV: 'local',
132
+ }),
133
+ ).toEqual(PINNED_HERMES_TRACE.instance)
134
+ for (const env of [
135
+ { HERMES_SESSION_KEY: 'gateway-key' },
136
+ { HERMES_SESSION_PLATFORM: 'api_server' },
137
+ { HERMES_SESSION_SOURCE: 'tui' },
138
+ { TERMINAL_ENV: 'docker' },
139
+ { TERMINAL_ENV: 'ssh' },
140
+ ]) {
141
+ expect(
142
+ hermesClassicCliLocalInstance({
143
+ HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
144
+ ...env,
145
+ }),
146
+ ).toBeNull()
40
147
  }
41
148
  })
42
149
  })
package/src/harnesses.ts CHANGED
@@ -1,13 +1,43 @@
1
- /** Every harness Notifai ships. This is the sole source of the harness union. */
2
- export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
1
+ /** Every harness Notifai can name in Source Context when authoritatively known. */
2
+ export const SOURCE_CONTEXT_HARNESSES = [
3
+ 'claude-code',
4
+ 'codex',
5
+ 'cursor',
6
+ 'opencode',
7
+ 'openclaw',
8
+ 'hermes',
9
+ ] as const
3
10
 
4
- export type Harness = (typeof HARNESSES)[number]
11
+ export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
5
12
 
6
- export const HARNESS_LABELS: Record<Harness, string> = {
13
+ /**
14
+ * Harnesses whose lifecycle Notifai can install and manage.
15
+ *
16
+ * Source Context is the broader vocabulary. Managed hook installation is a
17
+ * narrower, independently proven cell: Hermes can appear in Source Context
18
+ * without being hook-installable.
19
+ */
20
+ export const HOOK_INSTALLABLE_HARNESSES = [
21
+ 'claude-code',
22
+ 'codex',
23
+ 'cursor',
24
+ 'opencode',
25
+ 'openclaw',
26
+ ] as const
27
+
28
+ export type HookInstallableHarness = (typeof HOOK_INSTALLABLE_HARNESSES)[number]
29
+
30
+ export function isHookInstallableHarness(value: string): value is HookInstallableHarness {
31
+ return (HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(value)
32
+ }
33
+
34
+ export const HARNESS_LABELS: Record<SourceContextHarness, string> = {
7
35
  'claude-code': 'Claude Code',
8
36
  codex: 'Codex',
9
37
  cursor: 'Cursor',
10
38
  opencode: 'OpenCode',
39
+ openclaw: 'OpenClaw',
40
+ hermes: 'Hermes',
11
41
  }
12
42
 
13
43
  export type DeliveryRoute =
@@ -29,33 +59,106 @@ export interface HarnessCapability {
29
59
  deliveryContract: string
30
60
  }
31
61
 
62
+ const CLAUDE_CODE_CAPABILITY: HarnessCapability = {
63
+ stopContinuation: 'decision-block',
64
+ deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
65
+ deliveryContract:
66
+ 'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
67
+ }
68
+
69
+ const CODEX_CAPABILITY: HarnessCapability = {
70
+ stopContinuation: 'decision-block',
71
+ deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
72
+ deliveryContract:
73
+ 'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
74
+ }
75
+
76
+ const CURSOR_CAPABILITY: HarnessCapability = {
77
+ stopContinuation: 'unsupported',
78
+ deliveryRoutes: ['unsupported'],
79
+ deliveryContract:
80
+ 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
81
+ }
82
+
83
+ const OPENCODE_CAPABILITY: HarnessCapability = {
84
+ stopContinuation: 'unsupported',
85
+ deliveryRoutes: ['unsupported'],
86
+ deliveryContract:
87
+ 'no proven answer continuation after session.idle; use a blocking reply command',
88
+ }
89
+
90
+ const OPENCLAW_CAPABILITY: HarnessCapability = {
91
+ stopContinuation: 'unsupported',
92
+ deliveryRoutes: ['unsupported'],
93
+ deliveryContract:
94
+ 'no proven answer continuation after agent_end; use a blocking reply command',
95
+ }
96
+
97
+ export const HERMES_QUESTION_ROUTING_UNAVAILABLE: HarnessCapability = {
98
+ stopContinuation: 'unsupported',
99
+ deliveryRoutes: ['unsupported'],
100
+ deliveryContract:
101
+ 'Hermes asynchronous ask has no proven continuation owner on this integration surface. Use a blocking `notifai send --reply` question',
102
+ }
103
+
32
104
  /**
33
- * Exhaustive runtime contract. Adding a harness without deciding every
34
- * continuation joint is a type error, not a silently degraded notification.
105
+ * The one Hermes integration instance proven by the pinned vertical probe.
106
+ *
107
+ * This is evidence-bearing capability data, not an adapter. Unsupported and
108
+ * deferred surfaces intentionally have no empty implementation object here.
35
109
  */
36
- export const HARNESS_CAPABILITIES: Record<Harness, HarnessCapability> = {
37
- 'claude-code': {
38
- stopContinuation: 'decision-block',
39
- deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
40
- deliveryContract:
41
- 'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
42
- },
43
- codex: {
44
- stopContinuation: 'decision-block',
45
- deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
46
- deliveryContract:
47
- 'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
48
- },
49
- cursor: {
50
- stopContinuation: 'unsupported',
51
- deliveryRoutes: ['unsupported'],
52
- deliveryContract:
53
- 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
54
- },
55
- opencode: {
56
- stopContinuation: 'unsupported',
57
- deliveryRoutes: ['unsupported'],
58
- deliveryContract:
59
- 'no proven answer continuation after session.idle; use a blocking reply command',
110
+ export const HERMES_CLASSIC_CLI_LOCAL_CAPABILITY = {
111
+ instance: {
112
+ harness: 'hermes',
113
+ surface: 'classic-cli',
114
+ terminalBackend: 'local',
60
115
  },
116
+ setup: 'unsupported',
117
+ activation: 'unsupported',
118
+ sourceContext: 'hermes-session-id-and-invocation-cwd',
119
+ continuation: 'unsupported',
120
+ } as const
121
+
122
+ export type HermesClassicCliLocalInstance =
123
+ (typeof HERMES_CLASSIC_CLI_LOCAL_CAPABILITY)['instance']
124
+
125
+ /**
126
+ * Resolve only the Hermes instance whose complete marker envelope was proven.
127
+ *
128
+ * `HERMES_SESSION_ID` is also bridged by deferred gateway, TUI, API, ACP, and
129
+ * remote-backend paths. Their first-party routing/source/backend markers keep
130
+ * those cells distinct. Missing source/backend values are the pinned classic
131
+ * CLI defaults; a gateway key is never part of that instance.
132
+ */
133
+ export function hermesClassicCliLocalInstance(
134
+ env: NodeJS.ProcessEnv,
135
+ ): HermesClassicCliLocalInstance | null {
136
+ const sessionId = (env['HERMES_SESSION_ID'] ?? '').trim()
137
+ const sessionKey = (env['HERMES_SESSION_KEY'] ?? '').trim()
138
+ const platform = (env['HERMES_SESSION_PLATFORM'] ?? '').trim().toLowerCase()
139
+ const source = (env['HERMES_SESSION_SOURCE'] ?? '').trim().toLowerCase()
140
+ const terminalBackend = (env['TERMINAL_ENV'] ?? '').trim().toLowerCase()
141
+ if (sessionId === '' || sessionKey !== '' || platform !== '') return null
142
+ if (source !== '' && source !== 'cli') return null
143
+ if (terminalBackend !== '' && terminalBackend !== 'local') return null
144
+ return HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance
145
+ }
146
+
147
+ /**
148
+ * Question-routing contract for hook-installable harnesses.
149
+ *
150
+ * Hermes is deliberately absent: a send-only surface must not occupy this
151
+ * table with a fake managed-hook continuation route.
152
+ */
153
+ export const HARNESS_CAPABILITIES: Record<HookInstallableHarness, HarnessCapability> = {
154
+ 'claude-code': CLAUDE_CODE_CAPABILITY,
155
+ codex: CODEX_CAPABILITY,
156
+ cursor: CURSOR_CAPABILITY,
157
+ opencode: OPENCODE_CAPABILITY,
158
+ openclaw: OPENCLAW_CAPABILITY,
159
+ }
160
+
161
+ /** Question-routing capability for the active harness integration. */
162
+ export function questionRoutingCapability(harness: HookInstallableHarness): HarnessCapability {
163
+ return HARNESS_CAPABILITIES[harness]
61
164
  }
package/src/hooks.test.ts CHANGED
@@ -561,8 +561,9 @@ describe('pushing a registered question', () => {
561
561
  title: 'Ship it?',
562
562
  body: 'Ship it?',
563
563
  })
564
- // A pushed question is a question, and a question has an attention tone.
565
- expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
564
+ // Kind default sounds are resolved server-side; stamping one here would
565
+ // skip Account and Project maps.
566
+ expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBeUndefined()
566
567
  })
567
568
 
568
569
  it('routes a registered question to an answer-capable Android installation', async () => {
@@ -594,7 +595,7 @@ describe('pushing a registered question', () => {
594
595
  mode: 'selected',
595
596
  device_ids: ['dev_android'],
596
597
  })
597
- expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBe('attention')
598
+ expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBeUndefined()
598
599
  })
599
600
 
600
601
  it('keeps sparse alt text paired with its original media item', async () => {
package/src/hooks.ts CHANGED
@@ -48,9 +48,9 @@ import { inferInvocationContext } from './invocation-context.js'
48
48
  import type { Logger } from './logging.js'
49
49
  import {
50
50
  HARNESS_CAPABILITIES,
51
- HARNESSES,
51
+ HOOK_INSTALLABLE_HARNESSES,
52
52
  type DeliveryRoute,
53
- type Harness,
53
+ type HookInstallableHarness,
54
54
  } from './harnesses.js'
55
55
 
56
56
  /**
@@ -896,7 +896,7 @@ function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPo
896
896
  }
897
897
  if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
898
898
  if (typeof entry.updated_at !== 'number') return null
899
- if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return null
899
+ if (!(HOOK_INSTALLABLE_HARNESSES as readonly unknown[]).includes(entry.harness)) return null
900
900
  return {
901
901
  sessionId: entry.session_id,
902
902
  updatedAt: entry.updated_at,
@@ -1135,7 +1135,7 @@ function gate(
1135
1135
  ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
1136
1136
  }
1137
1137
 
1138
- export type HookHarness = Harness
1138
+ export type HookHarness = HookInstallableHarness
1139
1139
 
1140
1140
  /**
1141
1141
  * One round of the waiter's reply poll. Short enough that several questions
@@ -22,14 +22,25 @@ import {
22
22
  opencodePluginSource,
23
23
  opencodePluginTarget,
24
24
  } from './opencode-plugin.js'
25
+ import {
26
+ OPENCLAW_PLUGIN_MARKER,
27
+ isOurOpenclawPlugin,
28
+ mergeOpenclawNotifaiEntry,
29
+ openclawHasGlobalEvidence,
30
+ openclawPluginPath,
31
+ openclawPluginSource,
32
+ openclawPluginTarget,
33
+ parseOpenclawConfig,
34
+ removeOpenclawNotifaiEntry,
35
+ } from './openclaw-plugin.js'
25
36
  import {
26
37
  MISSING_LIFECYCLE_GUIDANCE_CONTEXT,
27
38
  WORKER_ACTIVATION_CONTEXT,
28
39
  } from './session-activation.js'
29
40
  import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
41
+ import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
30
42
  import {
31
43
  QUESTION_STOP_TIMEOUT_SECONDS,
32
- HARNESSES,
33
44
  applyPlan,
34
45
  detectHarness,
35
46
  detectedHarnesses,
@@ -1045,7 +1056,8 @@ describe('the OpenCode adapter', () => {
1045
1056
  })
1046
1057
 
1047
1058
  it('is a harness `hooks install` knows about', () => {
1048
- expect(HARNESSES).toContain('opencode')
1059
+ expect(HOOK_INSTALLABLE_HARNESSES).toContain('opencode')
1060
+ expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
1049
1061
  expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
1050
1062
  })
1051
1063
 
@@ -1067,6 +1079,179 @@ describe('the OpenCode adapter', () => {
1067
1079
  })
1068
1080
  })
1069
1081
 
1082
+ describe('the OpenClaw adapter', () => {
1083
+ const source = openclawPluginSource({
1084
+ adapterPath: ADAPTER,
1085
+ timeoutSeconds: 240,
1086
+ })
1087
+
1088
+ async function loadPlugin() {
1089
+ return (await import(
1090
+ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
1091
+ )) as {
1092
+ default: {
1093
+ id: string
1094
+ register(api: {
1095
+ on(name: string, handler: (...args: never[]) => unknown): void
1096
+ }): void
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ function handlersOf(mod: Awaited<ReturnType<typeof loadPlugin>>) {
1102
+ const handlers = new Map<string, (...args: never[]) => unknown>()
1103
+ mod.default.register({
1104
+ on(name, handler) {
1105
+ handlers.set(name, handler)
1106
+ },
1107
+ })
1108
+ return handlers
1109
+ }
1110
+
1111
+ it('shells out to the same hook commands the other harnesses run', () => {
1112
+ expect(source).toContain('"hook", event')
1113
+ expect(source).toContain('"--harness", "openclaw"')
1114
+ expect(source).toContain(JSON.stringify(ADAPTER))
1115
+ expect(source).not.toContain('command:stop')
1116
+ })
1117
+
1118
+ it('wires current plugin joints and leaves user abort alone', () => {
1119
+ expect(source).toContain('api.on("before_prompt_build"')
1120
+ expect(source).toContain('api.on("message_received"')
1121
+ expect(source).toContain('api.on("agent_end"')
1122
+ expect(source).toContain('api.on("session_end"')
1123
+ expect(source).toContain('api.on("resolve_exec_env"')
1124
+ expect(source).not.toContain('command:stop')
1125
+ })
1126
+
1127
+ it('classifies root, subagent, ACP, and missing identity safely', async () => {
1128
+ const handlers = handlersOf(await loadPlugin())
1129
+ const activate = handlers.get('before_prompt_build') as (
1130
+ event: object,
1131
+ ctx: object,
1132
+ ) => Promise<{ prependContext?: string } | undefined>
1133
+
1134
+ const root = await activate({}, { sessionKey: 'agent:main:main', workspaceDir: '/ws' })
1135
+ expect(root?.prependContext).toBe(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)
1136
+
1137
+ const worker = await activate({}, { sessionKey: 'agent:main:subagent:abc', workspaceDir: '/ws' })
1138
+ expect(worker?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
1139
+
1140
+ const acp = await activate({}, { sessionKey: 'agent:main:acp-child', targetKind: 'acp' })
1141
+ expect(acp?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
1142
+
1143
+ const missing = await activate({}, {})
1144
+ expect(missing?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
1145
+ })
1146
+
1147
+ it('does not treat heartbeat or cron traffic as User presence', async () => {
1148
+ const handlers = handlersOf(await loadPlugin())
1149
+ const presence = handlers.get('message_received') as (
1150
+ event: object,
1151
+ ctx: object,
1152
+ ) => Promise<void>
1153
+ await presence({ from: '' }, { sessionKey: 'agent:main:main' })
1154
+ await presence({ from: 'cron' }, { sessionKey: 'agent:main:main', trigger: 'cron' })
1155
+ await presence({ from: 'hb' }, { sessionKey: 'agent:main:main', trigger: 'heartbeat' })
1156
+ expect(source).toContain('trigger === "cron" || trigger === "heartbeat"')
1157
+ })
1158
+
1159
+ it('publishes exact sessionKey markers into exec without PATH', async () => {
1160
+ const handlers = handlersOf(await loadPlugin())
1161
+ const resolve = handlers.get('resolve_exec_env') as (
1162
+ event: object,
1163
+ ctx: object,
1164
+ ) => Record<string, string> | undefined
1165
+ expect(resolve({}, { sessionKey: 'agent:main:telegram:dm:1' })).toEqual({
1166
+ NOTIFAI_ACTIVE_HARNESS: 'openclaw',
1167
+ NOTIFAI_ACTIVE_SESSION_ID: 'agent:main:telegram:dm:1',
1168
+ })
1169
+ expect(resolve({}, {})).toBeUndefined()
1170
+ expect(source).not.toContain('PATH:')
1171
+ })
1172
+
1173
+ it('activates a parent session only once', async () => {
1174
+ const handlers = handlersOf(await loadPlugin())
1175
+ const activate = handlers.get('before_prompt_build') as (
1176
+ event: object,
1177
+ ctx: object,
1178
+ ) => Promise<{ prependContext?: string } | undefined>
1179
+ const first = await activate({}, { sessionKey: 'agent:main:main' })
1180
+ const second = await activate({}, { sessionKey: 'agent:main:main' })
1181
+ expect(first?.prependContext).toBe(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)
1182
+ expect(second).toBeUndefined()
1183
+ })
1184
+
1185
+ it('carries the ownership marker so a second checkout replaces it', () => {
1186
+ expect(source).toContain(OPENCLAW_PLUGIN_MARKER)
1187
+ expect(isOurOpenclawPlugin(source)).toBe(true)
1188
+ expect(isOurOpenclawPlugin('export default { id: "other" }')).toBe(false)
1189
+ })
1190
+
1191
+ it('installs beside the OpenClaw config rather than into a hook document', () => {
1192
+ const local = openclawPluginPath(false, '/repo', {})
1193
+ expect(local).toBe(path.join('/repo', '.openclaw', 'extensions', 'notifai', 'index.js'))
1194
+ const global = openclawPluginPath(true, '/repo', { OPENCLAW_STATE_DIR: '/cfg/openclaw' })
1195
+ expect(global).toBe(path.join('/cfg/openclaw', 'extensions', 'notifai', 'index.js'))
1196
+ })
1197
+
1198
+ it('is a harness `hooks install` knows about', () => {
1199
+ expect(HOOK_INSTALLABLE_HARNESSES).toContain('openclaw')
1200
+ expect(settingsFile('openclaw', false, '/repo', {})).toContain('index.js')
1201
+ })
1202
+
1203
+ it('reports the same stable adapter identity as command-hook harnesses', () => {
1204
+ expect(openclawPluginTarget(source)).toEqual({
1205
+ adapter: ADAPTER,
1206
+ current: true,
1207
+ timeoutSeconds: 240,
1208
+ })
1209
+ expect(openclawPluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
1210
+ adapter: ADAPTER,
1211
+ current: false,
1212
+ timeoutSeconds: 240,
1213
+ })
1214
+ expect(openclawPluginTarget('export default { id: "other" }')).toBeNull()
1215
+ })
1216
+
1217
+ it('merges only the Notifai plugin entry into OpenClaw config', () => {
1218
+ const merged = mergeOpenclawNotifaiEntry(
1219
+ { gateway: { port: 18789 }, plugins: { entries: { memory: { enabled: true } } } },
1220
+ '/repo/.openclaw/extensions/notifai',
1221
+ false,
1222
+ )
1223
+ expect(merged).toMatchObject({
1224
+ gateway: { port: 18789 },
1225
+ plugins: {
1226
+ entries: {
1227
+ memory: { enabled: true },
1228
+ notifai: { enabled: true, hooks: { allowConversationAccess: true } },
1229
+ },
1230
+ load: { paths: ['/repo/.openclaw/extensions/notifai'] },
1231
+ },
1232
+ })
1233
+ const stripped = removeOpenclawNotifaiEntry(merged, '/repo/.openclaw/extensions/notifai')
1234
+ expect(stripped).toEqual({
1235
+ gateway: { port: 18789 },
1236
+ plugins: { entries: { memory: { enabled: true } } },
1237
+ })
1238
+ })
1239
+
1240
+ it('parses JSON with comments without requiring a JSON5 dependency', () => {
1241
+ expect(parseOpenclawConfig('{\n // port\n "gateway": { "port": 1 },\n}\n')).toEqual({
1242
+ gateway: { port: 1 },
1243
+ })
1244
+ })
1245
+
1246
+ it('does not treat a skills-only ~/.openclaw tree as OpenClaw evidence', () => {
1247
+ const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-skills-only-'))
1248
+ mkdirSync(path.join(home, '.openclaw', 'skills'), { recursive: true })
1249
+ expect(openclawHasGlobalEvidence(existsSync, { HOME: home })).toBe(false)
1250
+ writeFileSync(path.join(home, '.openclaw', 'openclaw.json'), '{}\n')
1251
+ expect(openclawHasGlobalEvidence(existsSync, { HOME: home })).toBe(true)
1252
+ })
1253
+ })
1254
+
1070
1255
  describe('detectHarness', () => {
1071
1256
  function project(...entries: string[]): string {
1072
1257
  const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-'))
@@ -1099,7 +1284,7 @@ describe('detectHarness', () => {
1099
1284
  const dir = project('AGENTS.md')
1100
1285
  const answer = detectHarness(dir)
1101
1286
  expect(answer).not.toBe('codex')
1102
- expect(HARNESSES.includes(answer as never) || answer === null).toBe(true)
1287
+ expect(HOOK_INSTALLABLE_HARNESSES.includes(answer as never) || answer === null).toBe(true)
1103
1288
  })
1104
1289
 
1105
1290
  it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
@@ -1112,6 +1297,7 @@ describe('detectHarness', () => {
1112
1297
  expect(detectHarness(project('.codex'))).toBe('codex')
1113
1298
  expect(detectHarness(project('.cursor'))).toBe('cursor')
1114
1299
  expect(detectHarness(project('.opencode'))).toBe('opencode')
1300
+ expect(detectHarness(project('.openclaw'))).toBe('openclaw')
1115
1301
  })
1116
1302
 
1117
1303
  it('returns null when the project itself names two harnesses', () => {
@@ -1155,7 +1341,7 @@ describe('detectHarness', () => {
1155
1341
  // Whatever this machine has, an empty project must never crash and must
1156
1342
  // return either a single harness or null — never an arbitrary pick.
1157
1343
  const answer = detectHarness(empty)
1158
- expect(answer === null || HARNESSES.includes(answer)).toBe(true)
1344
+ expect(answer === null || HOOK_INSTALLABLE_HARNESSES.includes(answer)).toBe(true)
1159
1345
  })
1160
1346
  })
1161
1347
 
@@ -1232,6 +1418,9 @@ describe('Windows hook commands and discovery', () => {
1232
1418
  expect(opencodePluginPath(true, '/repo', env, 'win32')).toBe(
1233
1419
  path.join('C:\\Users\\Ada', '.config', 'opencode', 'plugins', 'notifai.js'),
1234
1420
  )
1421
+ expect(openclawPluginPath(true, '/repo', env, 'win32')).toBe(
1422
+ path.join('C:\\Users\\Ada', '.openclaw', 'extensions', 'notifai', 'index.js'),
1423
+ )
1235
1424
  })
1236
1425
 
1237
1426
  it('makes OpenCode spawn the adapter through Node on Windows with shell:false', () => {
@@ -1253,6 +1442,26 @@ describe('Windows hook commands and discovery', () => {
1253
1442
  nodePath: winNode,
1254
1443
  })
1255
1444
  })
1445
+
1446
+ it('makes OpenClaw spawn the adapter through Node on Windows with shell:false', () => {
1447
+ const source = openclawPluginSource({
1448
+ adapterPath: winAdapter,
1449
+ timeoutSeconds: 240,
1450
+ platform: 'win32',
1451
+ nodePath: winNode,
1452
+ })
1453
+ expect(source).toContain(`const NODE = ${JSON.stringify(winNode)}`)
1454
+ expect(source).toContain('spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "openclaw"]')
1455
+ expect(source).toContain('shell: false')
1456
+ expect(source).toContain('windowsHide: true')
1457
+ expect(source).not.toContain('spawn(ADAPTER,')
1458
+ expect(openclawPluginTarget(source)).toEqual({
1459
+ adapter: winAdapter,
1460
+ current: true,
1461
+ timeoutSeconds: 240,
1462
+ nodePath: winNode,
1463
+ })
1464
+ })
1256
1465
  })
1257
1466
 
1258
1467
  /**