@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/src/commands.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  deviceInventory,
18
18
  devicesCommand,
19
19
  } from './commands-devices.js'
20
+ export { soundsCommand } from './commands-sounds.js'
20
21
  export {
21
22
  contradictingAnswer,
22
23
  repliesCommand,
@@ -67,4 +68,8 @@ export {
67
68
  projectEnableCommand,
68
69
  projectStatusCommand,
69
70
  } from './commands-project.js'
71
+ export {
72
+ agentSessionRenameCommand,
73
+ type AgentSessionRenameFlags,
74
+ } from './commands-agent-sessions.js'
70
75
  export { realIo } from './commands-io.js'
@@ -70,6 +70,7 @@ describe('acceptedValues', () => {
70
70
 
71
71
  it('lists the choices for an enum', () => {
72
72
  expect(acceptedValues('sound')).toContain('none')
73
+ expect(acceptedValues('sound')).toContain('custom sound')
73
74
  })
74
75
 
75
76
  it('names both values for a toggle', () => {
@@ -121,7 +121,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
121
121
  kind: 'enum',
122
122
  choices: CLI_SOUNDS,
123
123
  choiceHints: {
124
- default: 'the standard notification tone',
124
+ default: 'Device default',
125
125
  done: 'the completion chime',
126
126
  attention: 'a distinct attention tone',
127
127
  alert: 'the most insistent tone',
@@ -129,7 +129,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
129
129
  },
130
130
  summary: 'Set the sound notifications from here use',
131
131
  detail:
132
- 'Setting this pins one sound for every notification sent from here, whatever kind it is.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and the standard tone for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
132
+ 'Setting this pins one sound for every notification sent from here, whatever kind it is. Shipped names are `default` (Device default), `done`, `attention`, `alert`, and `none`; an Account custom sound name or id from `notifai sounds` is accepted too.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and Device default for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
133
133
  unsetMeans: 'not set; each kind brings its own sound',
134
134
  },
135
135
  interruption_level: {
@@ -281,6 +281,9 @@ export function boundsHint(key: ConfigKey): string | null {
281
281
  /** What a `config set` caller may legally pass, phrased for an error message. */
282
282
  export function acceptedValues(key: ConfigKey): string {
283
283
  const info = INFO[key]
284
+ if (key === 'sound') {
285
+ return `${CLI_SOUNDS.join(', ')}, a custom sound id, or a custom sound name`
286
+ }
284
287
  switch (info.kind) {
285
288
  case 'boolean':
286
289
  return 'true or false'
@@ -177,14 +177,29 @@ describe('config precedence', () => {
177
177
  expect(config.wait_seconds.source).toBe('flag')
178
178
  })
179
179
 
180
- it('uses the prior default when a newer CLI wrote an unknown enum value', () => {
180
+ it('uses the prior default when a newer CLI wrote an unknown interruption level', () => {
181
181
  const { env, cwd } = setup({
182
- globalToml: 'sound = "future-sound"\ninterruption_level = "future-level"\n',
182
+ globalToml: 'interruption_level = "future-level"\n',
183
183
  })
184
184
  const config = loadConfig({ cwd, env })
185
- expect(config.sound).toEqual({ value: null, source: 'default' })
186
185
  expect(config.interruption_level).toEqual({ value: null, source: 'default' })
187
186
  })
187
+
188
+ it('keeps a custom sound name written by the user', () => {
189
+ const { env, cwd } = setup({
190
+ globalToml: 'sound = "Kitchen timer"\n',
191
+ })
192
+ const config = loadConfig({ cwd, env })
193
+ expect(config.sound).toEqual({ value: 'Kitchen timer', source: expect.stringMatching(/^global:/) })
194
+ })
195
+
196
+ it('drops a sound value that cannot be a name or id', () => {
197
+ const { env, cwd } = setup({
198
+ globalToml: `sound = "${'n'.repeat(200)}"\n`,
199
+ })
200
+ const config = loadConfig({ cwd, env })
201
+ expect(config.sound).toEqual({ value: null, source: 'default' })
202
+ })
188
203
  })
189
204
 
190
205
  describe('draft building', () => {
@@ -199,29 +214,23 @@ describe('draft building', () => {
199
214
  presentation: { title: 'T', body: 'B' },
200
215
  targets: { mode: 'all' },
201
216
  delivery: { ttl_seconds: 86400, collapse_key: null },
202
- // An unlabelled draft is ordinary news, and news has a sound.
203
- platform: {
204
- ios: { sound: 'default' },
205
- macos: { sound: 'default' },
206
- android: { sound: 'default' },
207
- },
208
217
  })
209
218
  })
210
219
 
211
220
  it.each([
212
- { flags: {}, sound: 'default' },
213
- { flags: { kind: 'update' }, sound: 'default' },
214
- { flags: { kind: 'done' }, sound: 'done' },
215
- { flags: { kind: 'failed' }, sound: 'alert' },
216
- { flags: { kind: 'blocked' }, sound: 'attention' },
217
- { flags: { kind: 'question' }, sound: 'attention' },
218
- { flags: { reply: true }, sound: 'attention' },
219
- ])('gives $flags the $sound sound when nobody chose one', ({ flags, sound }) => {
221
+ { flags: {} },
222
+ { flags: { kind: 'update' } },
223
+ { flags: { kind: 'done' } },
224
+ { flags: { kind: 'failed' } },
225
+ { flags: { kind: 'blocked' } },
226
+ { flags: { kind: 'question' } },
227
+ { flags: { reply: true } },
228
+ ])('omits sound from the draft for $flags so the server can resolve it', ({ flags }) => {
220
229
  const config = loadConfig({ cwd: base.cwd, env: base.env })
221
230
  const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
222
231
  if (!build.ok) throw new Error(build.error)
223
- expect(build.draft.platform?.ios?.sound).toBe(sound)
224
- expect(build.draft.platform?.android?.sound).toBe(sound)
232
+ expect(build.draft.platform?.ios?.sound).toBeUndefined()
233
+ expect(build.draft.platform?.android?.sound).toBeUndefined()
225
234
  expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
226
235
  })
227
236
 
@@ -551,9 +560,22 @@ describe('draft building', () => {
551
560
  it('rejects malformed inputs with usage errors', () => {
552
561
  const config = loadConfig({ cwd: base.cwd, env: base.env })
553
562
  expect(buildDraft(config, { title: '', body: 'B' }).ok).toBe(false)
554
- expect(buildDraft(config, { title: 'T', body: 'B', sound: 'airhorn' }).ok).toBe(false)
563
+ expect(buildDraft(config, { title: 'T', body: 'B', sound: 'n'.repeat(200) }).ok).toBe(false)
555
564
  expect(buildDraft(config, { title: 'T', body: 'B', data: ['nokey'] }).ok).toBe(false)
556
565
  expect(buildDraft(config, { title: 'T', body: 'B', level: 'shouting' }).ok).toBe(false)
557
566
  expect(buildDraft(config, { title: 'T', body: 'B', platform: 'linux' }).ok).toBe(false)
558
567
  })
568
+
569
+ it('puts a custom sound name on the draft for the server to resolve', () => {
570
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
571
+ const byName = buildDraft(config, { title: 'T', body: 'B', sound: 'airhorn' })
572
+ if (!byName.ok) throw new Error(byName.error)
573
+ expect(byName.draft.platform?.ios?.sound).toBe('airhorn')
574
+ expect(byName.draft.platform?.android?.sound).toBe('airhorn')
575
+
576
+ const byId = buildDraft(config, { title: 'T', body: 'B', sound: 'snd_01HQTEST' })
577
+ if (!byId.ok) throw new Error(byId.error)
578
+ expect(byId.draft.platform?.ios?.sound).toBe('snd_01HQTEST')
579
+ expect(byId.draft.platform?.android?.sound).toBe('snd_01HQTEST')
580
+ })
559
581
  })
package/src/config.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import {
2
- CLI_SOUNDS,
3
2
  INTERRUPTION_LEVELS,
4
3
  REPLY_MAX_WINDOW_SECONDS,
5
4
  } from '@raidiant/notifai-protocol'
@@ -8,6 +7,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'
8
7
  import path from 'node:path'
9
8
  import { parse as parseToml } from 'smol-toml'
10
9
  import { configHome, stateHome } from './platform.js'
10
+ import { isCliSoundRef } from './sound-ref.js'
11
11
  import { normalizeOrigin } from './url-policy.js'
12
12
 
13
13
  /**
@@ -41,7 +41,6 @@ export interface ResolvedValue<T> {
41
41
  source: ConfigSource
42
42
  }
43
43
 
44
- type CliSound = (typeof CLI_SOUNDS)[number]
45
44
  type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
46
45
 
47
46
  /**
@@ -58,7 +57,7 @@ export interface CliConfig {
58
57
  ttl_seconds: ResolvedValue<number>
59
58
  collapse_key: ResolvedValue<string | null>
60
59
  devices: ResolvedValue<string[] | null>
61
- sound: ResolvedValue<CliSound | null>
60
+ sound: ResolvedValue<string | null>
62
61
  interruption_level: ResolvedValue<CliInterruptionLevel | null>
63
62
  /** Project identifier stamped on sends; typically set in .notifai/config.toml. */
64
63
  project: ResolvedValue<string | null>
@@ -159,7 +158,6 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
159
158
  * is the same discipline the numeric bounds follow.
160
159
  */
161
160
  export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
162
- sound: CLI_SOUNDS,
163
161
  interruption_level: INTERRUPTION_LEVELS,
164
162
  log_level: LOG_LEVELS,
165
163
  }
@@ -365,6 +363,9 @@ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
365
363
  const origins = value.map((entry) => (typeof entry === 'string' ? normalizeOrigin(entry) : null))
366
364
  return origins.every((origin): origin is string => origin !== null) ? origins : undefined
367
365
  }
366
+ if (key === 'sound') {
367
+ return typeof value === 'string' && isCliSoundRef(value) ? value : undefined
368
+ }
368
369
  const allowed = ENUM_CONFIG_VALUES[key]
369
370
  if (allowed !== undefined) {
370
371
  return typeof value === 'string' && allowed.includes(value) ? value : undefined
@@ -390,7 +391,7 @@ export interface FlagOverrides {
390
391
  ttl_seconds?: number
391
392
  collapse_key?: string | null
392
393
  devices?: string[]
393
- sound?: CliSound
394
+ sound?: string
394
395
  interruption_level?: CliInterruptionLevel
395
396
  }
396
397
 
@@ -285,6 +285,25 @@ describe('KeychainStore', () => {
285
285
  })
286
286
 
287
287
  describe('WindowsDpapiStore', () => {
288
+ it('round-trips through real Windows CurrentUser DPAPI without plaintext on disk', () => {
289
+ if (process.platform !== 'win32') return
290
+
291
+ const local = path.join(tmp, 'real dpapi & Ω', 'local app data')
292
+ mkdirSync(local, { recursive: true })
293
+ const env = { ...process.env, LOCALAPPDATA: local }
294
+ const store = new WindowsDpapiStore(env)
295
+ store.save(SAMPLE)
296
+
297
+ const file = path.join(local, 'notifai', 'credentials.dpapi')
298
+ const onDisk = readFileSync(file, 'utf8')
299
+ expect(onDisk).not.toContain(SAMPLE.secret)
300
+ expect(onDisk).not.toContain(SAMPLE.machineId)
301
+ expect(new WindowsDpapiStore(env).load()).toEqual(SAMPLE)
302
+
303
+ store.clear()
304
+ expect(existsSync(file)).toBe(false)
305
+ })
306
+
288
307
  it('protects under LOCALAPPDATA and never writes roaming or config trees', () => {
289
308
  const { env, roaming, config } = sandbox('dpapi-location')
290
309
  const { runner, calls } = mockDpapi()
@@ -304,6 +323,7 @@ describe('WindowsDpapiStore', () => {
304
323
  expect(calls[0]?.args).toEqual(
305
324
  expect.arrayContaining(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass']),
306
325
  )
326
+ expect(calls.every((call) => call.timeoutMs === 30_000)).toBe(true)
307
327
  assertNoSecretInArgs(calls)
308
328
  })
309
329
 
@@ -47,7 +47,10 @@ const SERVICE = 'io.notifai.cli'
47
47
  const CREDENTIAL_FORMAT = 'notifai.machine-credential.v1'
48
48
  const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
49
49
  const DPAPI_ENTROPY = 'io.notifai.cli'
50
- const DPAPI_TIMEOUT_MS = 15_000
50
+ // Inbox Windows PowerShell can have a slow first start on a newly provisioned
51
+ // or heavily loaded machine. Keep the helper bounded without rejecting a
52
+ // valid DPAPI operation during that cold-start window.
53
+ const DPAPI_TIMEOUT_MS = 30_000
51
54
  const DPAPI_FILE = 'credentials.dpapi'
52
55
  const KEYCHAIN_TIMEOUT_MS = 15_000
53
56
 
@@ -0,0 +1,50 @@
1
+ {
2
+ "vendor": "NousResearch/hermes-agent",
3
+ "release": "0.20.6",
4
+ "commit": "5f75ec197b16d1a055688fb9df63ac49586a2230",
5
+ "instance": {
6
+ "harness": "hermes",
7
+ "surface": "classic-cli",
8
+ "terminalBackend": "local"
9
+ },
10
+ "durableSessionId": "20260828_111302_29a404",
11
+ "observedToolSubprocessEnv": {
12
+ "HERMES_SESSION_ID": "20260828_111302_29a404"
13
+ },
14
+ "cwd": {
15
+ "invocation": "/workspace",
16
+ "shellHookCwdIsProcessCwd": true,
17
+ "promptSectionCwdRequiresTerminalOverride": true
18
+ },
19
+ "evidence": {
20
+ "nativeHostOs": "macos",
21
+ "portableFixtureFamilies": ["posix", "windows"],
22
+ "nativeOsUnrun": ["linux", "windows"]
23
+ },
24
+ "compression": {
25
+ "defaultMode": "in-place",
26
+ "sessionIdBefore": "20260828_120000_inplace",
27
+ "sessionIdAfter": "20260828_120000_inplace",
28
+ "legacyRotatingSessionId": "20260828_111114_88ae83"
29
+ },
30
+ "supported": ["deliberate-send", "source-context-from-invocation-cwd"],
31
+ "unsupported": [
32
+ "managed-setup",
33
+ "lifecycle-activation",
34
+ "question-routing"
35
+ ],
36
+ "deferred": [
37
+ "tui",
38
+ "gateway",
39
+ "api",
40
+ "acp-session",
41
+ "safe-mode",
42
+ "native-linux",
43
+ "native-windows",
44
+ "live-auto-compression",
45
+ "interactive-reset",
46
+ "docker-backend",
47
+ "ssh-backend",
48
+ "other-remote-backends"
49
+ ]
50
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "vendor": "openclaw/openclaw",
3
+ "release": "2026.7.1-2",
4
+ "commit": "0790d9f",
5
+ "instance": {
6
+ "harness": "openclaw",
7
+ "surface": "agent-local",
8
+ "execHost": "gateway"
9
+ },
10
+ "durableSessionKey": "agent:main:probe-owner",
11
+ "rotatingSessionId": "ae6686a4-d632-4750-b73b-56faec5fff9a",
12
+ "observedPluginEnv": {
13
+ "NOTIFAI_ACTIVE_HARNESS": "openclaw",
14
+ "NOTIFAI_ACTIVE_SESSION_ID": "agent:main:probe-owner"
15
+ },
16
+ "worker": {
17
+ "sessionKeySubstring": ":subagent:",
18
+ "provedSessionKey": "agent:main:subagent:probe-worker",
19
+ "missingIdentity": "fail-closed-worker"
20
+ },
21
+ "hooks": {
22
+ "proven": {
23
+ "activation": "before_prompt_build",
24
+ "stop": "agent_end",
25
+ "execMarkers": "resolve_exec_env"
26
+ },
27
+ "unprovenOnThisSurface": {
28
+ "presence": "message_received",
29
+ "sessionEnd": "session_end"
30
+ }
31
+ },
32
+ "evidence": {
33
+ "nativeHostOs": "macos",
34
+ "command": "openclaw agent --local",
35
+ "nativeOsUnrun": ["linux", "windows"]
36
+ },
37
+ "supported": [
38
+ "managed-setup",
39
+ "lifecycle-activation",
40
+ "source-context-from-session-key"
41
+ ],
42
+ "unsupported": [
43
+ "question-routing",
44
+ "command-stop-as-turn-end",
45
+ "2026.2.26-cli"
46
+ ],
47
+ "deferred": [
48
+ "gateway-embedded",
49
+ "gateway-remote",
50
+ "agent-exec",
51
+ "acp-nested",
52
+ "exec-sandbox",
53
+ "exec-node",
54
+ "native-linux",
55
+ "native-windows"
56
+ ]
57
+ }
@@ -0,0 +1,294 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { mkdtempSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { describe, expect, it } from 'vitest'
6
+ import {
7
+ resolveActiveHarness,
8
+ sourceContextHarnessSession,
9
+ } from './commands-harness-context.js'
10
+ import {
11
+ HARNESS_CAPABILITIES,
12
+ HERMES_CLASSIC_CLI_LOCAL_CAPABILITY,
13
+ HERMES_QUESTION_ROUTING_UNAVAILABLE,
14
+ } from './harnesses.js'
15
+ import { buildSourceContext, inferInvocationContext, type GitCommand } from './invocation-context.js'
16
+
17
+ const PINNED = JSON.parse(
18
+ readFileSync(new URL('./fixtures/hermes-0.20.6-classic-cli-local.json', import.meta.url), 'utf8'),
19
+ ) as {
20
+ durableSessionId: string
21
+ observedToolSubprocessEnv: { HERMES_SESSION_ID: string }
22
+ instance: { harness: 'hermes'; surface: 'classic-cli'; terminalBackend: 'local' }
23
+ cwd: { invocation: string }
24
+ evidence: {
25
+ nativeHostOs: 'macos'
26
+ portableFixtureFamilies: ['posix', 'windows']
27
+ nativeOsUnrun: ['linux', 'windows']
28
+ }
29
+ compression: {
30
+ defaultMode: 'in-place'
31
+ sessionIdBefore: string
32
+ sessionIdAfter: string
33
+ legacyRotatingSessionId: string
34
+ }
35
+ }
36
+
37
+ const SYNTHETIC_GATEWAY_SESSION_KEY = 'synthetic-gateway-route-key-not-a-session-id'
38
+
39
+ function stateEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
40
+ return {
41
+ XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-context-')),
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ function fixtureGit(values: Record<string, string | null>): GitCommand {
47
+ return (_cwd, args) => values[args.join(' ')] ?? null
48
+ }
49
+
50
+ describe('Hermes classic CLI/local Source Context seam', () => {
51
+ it('attributes an uncontested HERMES_SESSION_ID as the durable Agent Session', () => {
52
+ const cwd = PINNED.cwd.invocation
53
+ const now = 1
54
+ const resolution = resolveActiveHarness(
55
+ { HERMES_SESSION_ID: PINNED.observedToolSubprocessEnv.HERMES_SESSION_ID },
56
+ cwd,
57
+ now,
58
+ )
59
+ expect(resolution.contested).toEqual([])
60
+ expect(resolution.active).toMatchObject({
61
+ harness: 'hermes',
62
+ sessionId: PINNED.durableSessionId,
63
+ })
64
+ expect(sourceContextHarnessSession({ HERMES_SESSION_ID: PINNED.durableSessionId }, cwd, now)).toEqual(
65
+ resolution.active,
66
+ )
67
+ })
68
+
69
+ it('never treats HERMES_SESSION_KEY as Agent Session identity', () => {
70
+ const cwd = '/workspace'
71
+ const now = 1
72
+ const keyOnly = resolveActiveHarness(
73
+ { HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY },
74
+ cwd,
75
+ now,
76
+ )
77
+ expect(keyOnly.active).toBeNull()
78
+
79
+ const gatewayEnv = {
80
+ HERMES_SESSION_ID: PINNED.observedToolSubprocessEnv.HERMES_SESSION_ID,
81
+ HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY,
82
+ }
83
+ const both = resolveActiveHarness(gatewayEnv, cwd, now)
84
+ expect(both.active?.sessionId).toBe(PINNED.durableSessionId)
85
+ expect(both.active?.sessionId).not.toBe(SYNTHETIC_GATEWAY_SESSION_KEY)
86
+ expect(sourceContextHarnessSession(gatewayEnv, cwd, now)).toBeNull()
87
+ })
88
+
89
+ it('fails closed when Hermes markers nest with a hook-installable harness', () => {
90
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-'))
91
+ const env = {
92
+ CLAUDECODE: '1',
93
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
94
+ HERMES_SESSION_ID: PINNED.durableSessionId,
95
+ }
96
+ const resolution = resolveActiveHarness(env, cwd, 42)
97
+ expect(resolution.contested.map((candidate) => candidate.harness).sort()).toEqual([
98
+ 'claude-code',
99
+ 'hermes',
100
+ ])
101
+ expect(sourceContextHarnessSession(env, cwd, 42)).toBeNull()
102
+ })
103
+
104
+ it('does not apply the send-only mix rule to a Hermes-free Claude/Codex nest', () => {
105
+ // Existing send attribution still uses declared order when nothing has
106
+ // fired. Live-evidence ownership for ask lives in commands.test.ts.
107
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-codex-pair-'))
108
+ const env = {
109
+ CLAUDECODE: '1',
110
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
111
+ CODEX_THREAD_ID: 'codex-current-thread',
112
+ }
113
+ const resolution = resolveActiveHarness(env, cwd, 42)
114
+ expect(resolution.contested.map((candidate) => candidate.harness).sort()).toEqual([
115
+ 'claude-code',
116
+ 'codex',
117
+ ])
118
+ expect(sourceContextHarnessSession(env, cwd, 42)?.harness).toBe('claude-code')
119
+ })
120
+
121
+ it('builds Source Context from HERMES_SESSION_ID plus invocation cwd git/worktree', () => {
122
+ const env = stateEnv({
123
+ HERMES_SESSION_ID: PINNED.durableSessionId,
124
+ HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY,
125
+ })
126
+ const invocation = inferInvocationContext(
127
+ '/worktrees/hermes-topic/apps/cli',
128
+ fixtureGit({
129
+ 'rev-parse --git-common-dir': '/code/notifai/.git',
130
+ 'rev-parse --git-dir': '/code/notifai/.git/worktrees/hermes-topic',
131
+ 'rev-parse --show-toplevel': '/worktrees/hermes-topic/',
132
+ 'rev-parse --abbrev-ref HEAD': 'feature/hermes-send',
133
+ }),
134
+ )
135
+ const active = resolveActiveHarness(env, '/worktrees/hermes-topic/apps/cli', Date.now()).active
136
+ const built = buildSourceContext({
137
+ env,
138
+ invocation,
139
+ ...(active === null ? {} : { activeHarness: active }),
140
+ now: new Date(2026, 7, 28, 11, 13).getTime(),
141
+ })
142
+ expect(built).toMatchObject({
143
+ ok: true,
144
+ source: {
145
+ session_id: PINNED.durableSessionId,
146
+ harness: 'hermes',
147
+ branch: 'feature/hermes-send',
148
+ worktree: 'hermes-topic',
149
+ },
150
+ })
151
+ if (built.ok) {
152
+ expect(JSON.stringify(built.source)).not.toContain('gateway-route-key')
153
+ expect(JSON.stringify(built.source)).not.toContain('/worktrees/')
154
+ expect(JSON.stringify(built.source)).not.toContain('/code/')
155
+ }
156
+ })
157
+
158
+ it('omits harness attribution when nested markers make ownership ambiguous', () => {
159
+ const env = stateEnv({
160
+ CLAUDECODE: '1',
161
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
162
+ HERMES_SESSION_ID: PINNED.durableSessionId,
163
+ })
164
+ const invocation = inferInvocationContext('/workspace', fixtureGit({}))
165
+ const active = sourceContextHarnessSession(env, '/workspace', 1)
166
+ expect(active).toBeNull()
167
+ expect(
168
+ buildSourceContext({
169
+ env,
170
+ invocation,
171
+ now: 1,
172
+ }),
173
+ ).toEqual({ ok: true })
174
+ })
175
+
176
+ it('does not invent a Question Routing route for the pinned Hermes instance', () => {
177
+ expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance).toEqual(PINNED.instance)
178
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.stopContinuation).toBe('unsupported')
179
+ expect(Object.keys(HARNESS_CAPABILITIES)).not.toContain('hermes')
180
+ })
181
+
182
+ it('keeps unproven Hermes surfaces out of Source Context', () => {
183
+ for (const extra of [
184
+ { HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY },
185
+ { HERMES_SESSION_PLATFORM: 'api_server' },
186
+ { HERMES_SESSION_SOURCE: 'tui' },
187
+ { TERMINAL_ENV: 'docker' },
188
+ { TERMINAL_ENV: 'ssh' },
189
+ ]) {
190
+ expect(
191
+ sourceContextHarnessSession(
192
+ { HERMES_SESSION_ID: PINNED.durableSessionId, ...extra },
193
+ '/workspace',
194
+ 1,
195
+ ),
196
+ ).toBeNull()
197
+ }
198
+ })
199
+
200
+ it('records the precise platform-fixture and compression evidence boundary', () => {
201
+ expect(PINNED.evidence).toEqual({
202
+ nativeHostOs: 'macos',
203
+ portableFixtureFamilies: ['posix', 'windows'],
204
+ nativeOsUnrun: ['linux', 'windows'],
205
+ })
206
+ expect(PINNED.compression.defaultMode).toBe('in-place')
207
+ expect(PINNED.compression.sessionIdAfter).toBe(PINNED.compression.sessionIdBefore)
208
+ expect(PINNED.compression.legacyRotatingSessionId).not.toBe(
209
+ PINNED.compression.sessionIdBefore,
210
+ )
211
+ })
212
+ })
213
+
214
+ describe('OpenClaw agent-local Source Context seam', () => {
215
+ const PINNED_OPENCLAW = JSON.parse(
216
+ readFileSync(new URL('./fixtures/openclaw-2026.7.1-2-agent-local.json', import.meta.url), 'utf8'),
217
+ ) as {
218
+ durableSessionKey: string
219
+ rotatingSessionId: string
220
+ observedPluginEnv: { NOTIFAI_ACTIVE_HARNESS: 'openclaw'; NOTIFAI_ACTIVE_SESSION_ID: string }
221
+ instance: { harness: 'openclaw'; surface: 'agent-local'; execHost: 'gateway' }
222
+ hooks: {
223
+ proven: { activation: string; stop: string; execMarkers: string }
224
+ unprovenOnThisSurface: { presence: string; sessionEnd: string }
225
+ }
226
+ deferred: string[]
227
+ }
228
+
229
+ it('pins only the probed agent-local surface, not Gateway-embedded', () => {
230
+ expect(PINNED_OPENCLAW.instance).toEqual({
231
+ harness: 'openclaw',
232
+ surface: 'agent-local',
233
+ execHost: 'gateway',
234
+ })
235
+ expect(PINNED_OPENCLAW.durableSessionKey).toBe('agent:main:probe-owner')
236
+ expect(PINNED_OPENCLAW.observedPluginEnv.NOTIFAI_ACTIVE_SESSION_ID).toBe(
237
+ PINNED_OPENCLAW.durableSessionKey,
238
+ )
239
+ expect(PINNED_OPENCLAW.hooks.proven).toEqual({
240
+ activation: 'before_prompt_build',
241
+ stop: 'agent_end',
242
+ execMarkers: 'resolve_exec_env',
243
+ })
244
+ expect(PINNED_OPENCLAW.hooks.unprovenOnThisSurface).toEqual({
245
+ presence: 'message_received',
246
+ sessionEnd: 'session_end',
247
+ })
248
+ expect(PINNED_OPENCLAW.deferred).toContain('gateway-embedded')
249
+ })
250
+
251
+ it('attributes Source Context to the plugin-injected sessionKey', () => {
252
+ const env = stateEnv(PINNED_OPENCLAW.observedPluginEnv)
253
+ const cwd = '/workspace'
254
+ const active = resolveActiveHarness(env, cwd, Date.now()).active
255
+ expect(active).toMatchObject({
256
+ harness: 'openclaw',
257
+ sessionId: PINNED_OPENCLAW.durableSessionKey,
258
+ })
259
+ const built = buildSourceContext({
260
+ env,
261
+ invocation: inferInvocationContext(cwd, fixtureGit({})),
262
+ ...(active === null ? {} : { activeHarness: active }),
263
+ now: Date.now(),
264
+ })
265
+ expect(built).toMatchObject({
266
+ ok: true,
267
+ source: {
268
+ harness: 'openclaw',
269
+ session_id: PINNED_OPENCLAW.durableSessionKey,
270
+ },
271
+ })
272
+ if (built.ok) {
273
+ expect(JSON.stringify(built.source)).not.toContain(PINNED_OPENCLAW.rotatingSessionId)
274
+ }
275
+ })
276
+
277
+ it('treats nested OpenClaw and Claude markers as contested until live evidence exists', () => {
278
+ const env = stateEnv({
279
+ ...PINNED_OPENCLAW.observedPluginEnv,
280
+ CLAUDECODE: '1',
281
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
282
+ })
283
+ const resolution = resolveActiveHarness(env, '/workspace', 1)
284
+ expect(resolution.contested.map((candidate) => candidate.harness).sort()).toEqual([
285
+ 'claude-code',
286
+ 'openclaw',
287
+ ])
288
+ })
289
+
290
+ it('keeps OpenClaw Question Routing unsupported', () => {
291
+ expect(HARNESS_CAPABILITIES.openclaw.stopContinuation).toBe('unsupported')
292
+ expect(HARNESS_CAPABILITIES.openclaw.deliveryRoutes).toEqual(['unsupported'])
293
+ })
294
+ })