@raidiant/notifai 10.0.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 (54) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/dist/commands-auth.js +4 -4
  4. package/dist/commands-auth.js.map +1 -1
  5. package/dist/commands-core.d.ts +2 -0
  6. package/dist/commands-core.js +2 -0
  7. package/dist/commands-core.js.map +1 -1
  8. package/dist/commands-doctor.js +79 -36
  9. package/dist/commands-doctor.js.map +1 -1
  10. package/dist/commands-harness-context.js +8 -0
  11. package/dist/commands-harness-context.js.map +1 -1
  12. package/dist/commands-hooks.js +122 -5
  13. package/dist/commands-hooks.js.map +1 -1
  14. package/dist/commands-init.js +10 -5
  15. package/dist/commands-init.js.map +1 -1
  16. package/dist/harnesses.d.ts +2 -2
  17. package/dist/harnesses.js +22 -2
  18. package/dist/harnesses.js.map +1 -1
  19. package/dist/install-hooks.js +15 -9
  20. package/dist/install-hooks.js.map +1 -1
  21. package/dist/interactive.js +20 -23
  22. package/dist/interactive.js.map +1 -1
  23. package/dist/openclaw-plugin.d.ts +49 -0
  24. package/dist/openclaw-plugin.js +350 -0
  25. package/dist/openclaw-plugin.js.map +1 -0
  26. package/dist/program.js +9 -8
  27. package/dist/program.js.map +1 -1
  28. package/dist/session-activation.js +1 -1
  29. package/dist/session-activation.js.map +1 -1
  30. package/dist/ui/help.d.ts +4 -5
  31. package/dist/ui/help.js +5 -6
  32. package/dist/ui/help.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/commands-auth.ts +4 -3
  35. package/src/commands-core.ts +3 -0
  36. package/src/commands-doctor.ts +95 -34
  37. package/src/commands-harness-context.ts +8 -0
  38. package/src/commands-hooks.ts +144 -2
  39. package/src/commands-init.ts +10 -4
  40. package/src/commands.test.ts +185 -6
  41. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  42. package/src/harness-integration.test.ts +82 -0
  43. package/src/harnesses.test.ts +9 -1
  44. package/src/harnesses.ts +24 -2
  45. package/src/install-hooks.test.ts +208 -0
  46. package/src/install-hooks.ts +19 -9
  47. package/src/interactive.test.ts +31 -0
  48. package/src/interactive.ts +20 -26
  49. package/src/openclaw-plugin.ts +409 -0
  50. package/src/program.ts +9 -8
  51. package/src/session-activation.ts +1 -1
  52. package/src/skill.test.ts +2 -2
  53. package/src/ui/help.test.ts +16 -0
  54. package/src/ui/help.ts +6 -7
@@ -25,6 +25,7 @@ import { buildDraft, formatReceipt } from './send.js'
25
25
  import { loginCommand } from './commands-auth.js'
26
26
  import {
27
27
  EXIT,
28
+ SETUP_COMMAND,
28
29
  UPDATE_CLI_COMMAND,
29
30
  authedClient,
30
31
  loadLoggedConfig,
@@ -848,8 +849,8 @@ async function printInitClose(
848
849
  await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
849
850
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
850
851
  deps.io.out(` ${remedyLine(blocker)}`)
851
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
852
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
852
+ if (shouldRestateInit(blocker)) {
853
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
853
854
  }
854
855
  await deps.io.outro?.('One step remains (above)')
855
856
  return
@@ -866,7 +867,12 @@ async function printInitClose(
866
867
  }
867
868
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
868
869
  deps.io.out(` ${remedyLine(blocker)}`)
869
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
870
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
870
+ if (shouldRestateInit(blocker)) {
871
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
871
872
  }
872
873
  }
874
+
875
+ function shouldRestateInit(blocker: ReadinessState): boolean {
876
+ if (blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.by !== 'user-here') return false
877
+ return !('command' in blocker.remedy) || blocker.remedy.command !== SETUP_COMMAND
878
+ }
@@ -34,6 +34,7 @@ import {
34
34
  acknowledgeCommand,
35
35
  askCommand,
36
36
  accessStatusCommand,
37
+ authStatusCommand,
37
38
  buildQuestions,
38
39
  assessReadiness,
39
40
  capabilitiesCommand,
@@ -446,6 +447,16 @@ describe('command contracts', () => {
446
447
  expect(submitted?.draft.project).toBeUndefined()
447
448
  expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
448
449
  })
450
+ it('tells an unsigned machine to run init, not login', () => {
451
+ const io = new CapturedIo()
452
+ const deps = makeDeps(io, {} as ApiClient)
453
+ deps.store.load = () => null
454
+
455
+ expect(authStatusCommand(deps, {})).toBe(EXIT.auth)
456
+ expect(io.errLines.join('\n')).toMatch(/Not signed in\. Run `notifai init`/)
457
+ expect(io.errLines.join('\n')).not.toContain('notifai login')
458
+ })
459
+
449
460
  it('shows an actionable no-plan access state', async () => {
450
461
  const io = new CapturedIo()
451
462
  const client = {
@@ -2596,7 +2607,7 @@ describe('credential origin pinning', () => {
2596
2607
  expect(io.errLines).toEqual([
2597
2608
  'This account has no active plan or temporary Alpha access.',
2598
2609
  'next: Ask the account owner for Alpha access.',
2599
- 'After access is granted, run `notifai login` again.',
2610
+ 'After access is granted, run `notifai init` again.',
2600
2611
  ])
2601
2612
  })
2602
2613
 
@@ -3277,6 +3288,71 @@ describe('harness activation guidance', () => {
3277
3288
  expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
3278
3289
  expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
3279
3290
  })
3291
+
3292
+ it('installs an owned OpenClaw plugin and reports unsupported continuation', async () => {
3293
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-activation-'))
3294
+ const io = new CapturedIo()
3295
+ const client = {
3296
+ health: async () => true,
3297
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3298
+ listDevices: async () => ({ devices: [] }),
3299
+ } as unknown as ApiClient
3300
+ const home = path.join(cwd, 'home')
3301
+ const deps = {
3302
+ ...makeDeps(io, client),
3303
+ cwd,
3304
+ env: {
3305
+ HOME: home,
3306
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3307
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3308
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
3309
+ },
3310
+ }
3311
+
3312
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
3313
+ expect(io.outLines.join('\n')).toContain('Installed openclaw hooks in')
3314
+ expect(io.outLines.join('\n')).toContain(
3315
+ 'Restart the OpenClaw Gateway, start one fresh Agent Session, send one prompt, then run `notifai doctor`.',
3316
+ )
3317
+ const pluginFile = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
3318
+ const plugin = readFileSync(pluginFile, 'utf8')
3319
+ expect(plugin).toContain('api.on("before_prompt_build"')
3320
+ expect(plugin).toContain('api.on("resolve_exec_env"')
3321
+ expect(plugin).not.toContain('command:stop')
3322
+ const config = JSON.parse(
3323
+ readFileSync(path.join(cwd, 'openclaw-home', 'openclaw.json'), 'utf8'),
3324
+ ) as {
3325
+ plugins: { entries: { notifai: { enabled: boolean; hooks: { allowConversationAccess: boolean } } } }
3326
+ }
3327
+ expect(config.plugins.entries.notifai).toEqual({
3328
+ enabled: true,
3329
+ hooks: { allowConversationAccess: true },
3330
+ })
3331
+
3332
+ io.outLines = []
3333
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
3334
+ expect(io.outLines.join('\n')).toContain('no proven answer continuation')
3335
+ })
3336
+
3337
+ it('refuses to overwrite a foreign OpenClaw plugin', () => {
3338
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-foreign-'))
3339
+ const plugin = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
3340
+ mkdirSync(path.dirname(plugin), { recursive: true })
3341
+ writeFileSync(plugin, 'export default { id: "foreign" }\n')
3342
+ const io = new CapturedIo()
3343
+ const deps = {
3344
+ ...makeDeps(io, {} as ApiClient),
3345
+ cwd,
3346
+ env: {
3347
+ HOME: path.join(cwd, 'home'),
3348
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3349
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
3350
+ },
3351
+ }
3352
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.failed)
3353
+ expect(io.errLines.join('\n')).toMatch(/was not written by Notifai/)
3354
+ expect(readFileSync(plugin, 'utf8')).toBe('export default { id: "foreign" }\n')
3355
+ })
3280
3356
  })
3281
3357
 
3282
3358
  describe('stable hook installation', () => {
@@ -4524,8 +4600,9 @@ describe('init', () => {
4524
4600
  expect(await initCommand(deps, {})).toBe(EXIT.failed)
4525
4601
  const out = io.outLines.join('\n')
4526
4602
  expect(out).toContain('Next: This machine')
4527
- expect(out).toContain('notifai login')
4528
- expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
4603
+ expect(out).toContain('notifai init')
4604
+ expect(out).not.toContain('notifai login')
4605
+ expect(out).not.toContain('Then re-run')
4529
4606
  // The device gap is real and downstream; it must stay hidden until the
4530
4607
  // sign-in that would let anyone actually check it has happened.
4531
4608
  expect(out).not.toContain('companion app')
@@ -5071,7 +5148,8 @@ describe('init', () => {
5071
5148
  }
5072
5149
 
5073
5150
  expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
5074
- expect(io.outLines.join('\n')).toContain('notifai login')
5151
+ expect(io.outLines.join('\n')).toContain('notifai init')
5152
+ expect(io.outLines.join('\n')).not.toContain('notifai login')
5075
5153
  })
5076
5154
 
5077
5155
  it('announces sign-in for a present human without re-confirming', async () => {
@@ -5104,7 +5182,8 @@ describe('init', () => {
5104
5182
  const out = io.outLines.join('\n')
5105
5183
  expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
5106
5184
  expect(out).toContain('Next: This machine')
5107
- expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
5185
+ expect(out).toContain('notifai init')
5186
+ expect(out).not.toContain('notifai login')
5108
5187
  })
5109
5188
 
5110
5189
  it('never prompts or opens a browser when an agent runs it unattended', async () => {
@@ -5667,10 +5746,88 @@ describe('init', () => {
5667
5746
  const out = io.outLines.join('\n')
5668
5747
  expect(out).toContain('Next: Account')
5669
5748
  expect(out).toContain('pair it again')
5670
- expect(out).toContain('notifai login')
5749
+ expect(out).toContain('notifai init')
5750
+ expect(out).not.toContain('notifai login')
5671
5751
  expect(out.match(/^Next:/gm)).toHaveLength(1)
5672
5752
  })
5673
5753
 
5754
+ it('names the support next action when a paired account has no active plan', async () => {
5755
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-'))
5756
+ const io = new CapturedIo()
5757
+ const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
5758
+ const client = {
5759
+ health: async () => true,
5760
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5761
+ listDevices: async () => {
5762
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
5763
+ },
5764
+ accessStatus: async () => ({
5765
+ status: 'no_active_plan',
5766
+ reason: 'no_active_grant',
5767
+ expires_at: null,
5768
+ email: 'rafael@example.test',
5769
+ }),
5770
+ } as unknown as ApiClient
5771
+ const deps = {
5772
+ ...makeDeps(io, client),
5773
+ cwd,
5774
+ env: isolatedEnv(cwd),
5775
+ }
5776
+
5777
+ const readiness = await assessReadiness(deps)
5778
+ const auth = readiness.states.find((state) => state.id === 'auth')
5779
+ expect(auth).toMatchObject({
5780
+ status: 'gap',
5781
+ detail: 'no active plan or temporary Alpha access (rafael@example.test)',
5782
+ remedy: { by: 'user-elsewhere', summary: nextAction },
5783
+ })
5784
+ expect(auth?.remedy && 'command' in auth.remedy ? auth.remedy.command : undefined).toBeUndefined()
5785
+
5786
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
5787
+ const out = io.outLines.join('\n')
5788
+ expect(out).toContain('Next: Account')
5789
+ expect(out).toContain(nextAction)
5790
+ expect(out).not.toContain('pair it again')
5791
+ expect(out).not.toContain('notifai login')
5792
+ expect(out.match(/^Next:/gm)).toHaveLength(1)
5793
+
5794
+ const doctorIo = new CapturedIo()
5795
+ expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.failed)
5796
+ const payload = JSON.parse(doctorIo.outLines[0] ?? '{}') as {
5797
+ states: Array<{ id: string; remedy?: { summary?: string; command?: string } }>
5798
+ }
5799
+ const doctorAuth = payload.states.find((state) => state.id === 'auth')
5800
+ expect(doctorAuth?.remedy?.summary).toBe(nextAction)
5801
+ expect(doctorAuth?.remedy?.command).toBeUndefined()
5802
+ })
5803
+
5804
+ it('does not treat a listDevices no-plan 403 as a revoked machine when accessStatus fails', async () => {
5805
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-devices-only-'))
5806
+ const io = new CapturedIo()
5807
+ const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
5808
+ const client = {
5809
+ health: async () => true,
5810
+ listDevices: async () => {
5811
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
5812
+ },
5813
+ accessStatus: async () => {
5814
+ throw new ApiCallError(500, 'internal_error', 'access status unavailable')
5815
+ },
5816
+ } as unknown as ApiClient
5817
+ const deps = {
5818
+ ...makeDeps(io, client),
5819
+ cwd,
5820
+ env: isolatedEnv(cwd),
5821
+ }
5822
+
5823
+ const readiness = await assessReadiness(deps)
5824
+ const auth = readiness.states.find((state) => state.id === 'auth')
5825
+ expect(auth?.status).toBe('gap')
5826
+ expect(auth?.detail).toContain('no active plan')
5827
+ expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
5828
+ expect(JSON.stringify(auth)).not.toContain('pair it again')
5829
+ })
5830
+
5674
5831
  it('reuses proof across worktrees of one Project on the same Approved Machine', async () => {
5675
5832
  const stateRoot = mkdtempSync(path.join(os.tmpdir(), 'init-worktrees-state-'))
5676
5833
  let submitCalls = 0
@@ -6723,6 +6880,28 @@ describe('asking before the hooks have ever run', () => {
6723
6880
  expect(readSessionState('opencode-current', env).pending).toBeUndefined()
6724
6881
  })
6725
6882
 
6883
+ it('rejects OpenClaw before registration even with an exact matching pointer', () => {
6884
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-openclaw-unsupported-'))
6885
+ const io = new CapturedIo()
6886
+ const env = {
6887
+ HOME: path.join(cwd, 'home'),
6888
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
6889
+ XDG_STATE_HOME: path.join(cwd, 'state'),
6890
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
6891
+ NOTIFAI_ACTIVE_HARNESS: 'openclaw',
6892
+ NOTIFAI_ACTIVE_SESSION_ID: 'agent:main:main',
6893
+ }
6894
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6895
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
6896
+ writeSessionState('agent:main:main', env, { harness: 'openclaw', last_prompt_at: 42, last_stop_at: 41 })
6897
+ writeProjectSession(cwd, env, 'agent:main:main', 42, 'openclaw')
6898
+ io.outLines = []
6899
+
6900
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6901
+ expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
6902
+ expect(readSessionState('agent:main:main', env).pending).toBeUndefined()
6903
+ })
6904
+
6726
6905
  it('refuses Hermes ask as unsupported rather than as missing hooks', () => {
6727
6906
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-ask-unsupported-'))
6728
6907
  const io = new CapturedIo()
@@ -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
+ }
@@ -210,3 +210,85 @@ describe('Hermes classic CLI/local Source Context seam', () => {
210
210
  )
211
211
  })
212
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
+ })
@@ -34,7 +34,13 @@ describe('harness contract', () => {
34
34
  })
35
35
 
36
36
  it('keeps managed hook installation a strict subset of Source Context', () => {
37
- expect(HOOK_INSTALLABLE_HARNESSES).toEqual(['claude-code', 'codex', 'cursor', 'opencode'])
37
+ expect(HOOK_INSTALLABLE_HARNESSES).toEqual([
38
+ 'claude-code',
39
+ 'codex',
40
+ 'cursor',
41
+ 'opencode',
42
+ 'openclaw',
43
+ ])
38
44
  expect(SOURCE_CONTEXT_HARNESSES).toContain('hermes')
39
45
  expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
40
46
  expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HOOK_INSTALLABLE_HARNESSES].sort())
@@ -96,6 +102,8 @@ describe('harness contract', () => {
96
102
  })
97
103
  expect(HARNESS_CAPABILITIES.opencode.stopContinuation).toBe('unsupported')
98
104
  expect(HARNESS_CAPABILITIES.opencode.deliveryRoutes).toEqual(['unsupported'])
105
+ expect(HARNESS_CAPABILITIES.openclaw.stopContinuation).toBe('unsupported')
106
+ expect(HARNESS_CAPABILITIES.openclaw.deliveryRoutes).toEqual(['unsupported'])
99
107
  })
100
108
 
101
109
  it('treats the pinned Hermes classic CLI/local trace as send-only', () => {
package/src/harnesses.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /** Every harness Notifai can name in Source Context when authoritatively known. */
2
- export const SOURCE_CONTEXT_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode', 'hermes'] as const
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
11
  export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
5
12
 
@@ -10,7 +17,13 @@ export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
10
17
  * narrower, independently proven cell: Hermes can appear in Source Context
11
18
  * without being hook-installable.
12
19
  */
13
- export const HOOK_INSTALLABLE_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
20
+ export const HOOK_INSTALLABLE_HARNESSES = [
21
+ 'claude-code',
22
+ 'codex',
23
+ 'cursor',
24
+ 'opencode',
25
+ 'openclaw',
26
+ ] as const
14
27
 
15
28
  export type HookInstallableHarness = (typeof HOOK_INSTALLABLE_HARNESSES)[number]
16
29
 
@@ -23,6 +36,7 @@ export const HARNESS_LABELS: Record<SourceContextHarness, string> = {
23
36
  codex: 'Codex',
24
37
  cursor: 'Cursor',
25
38
  opencode: 'OpenCode',
39
+ openclaw: 'OpenClaw',
26
40
  hermes: 'Hermes',
27
41
  }
28
42
 
@@ -73,6 +87,13 @@ const OPENCODE_CAPABILITY: HarnessCapability = {
73
87
  'no proven answer continuation after session.idle; use a blocking reply command',
74
88
  }
75
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
+
76
97
  export const HERMES_QUESTION_ROUTING_UNAVAILABLE: HarnessCapability = {
77
98
  stopContinuation: 'unsupported',
78
99
  deliveryRoutes: ['unsupported'],
@@ -134,6 +155,7 @@ export const HARNESS_CAPABILITIES: Record<HookInstallableHarness, HarnessCapabil
134
155
  codex: CODEX_CAPABILITY,
135
156
  cursor: CURSOR_CAPABILITY,
136
157
  opencode: OPENCODE_CAPABILITY,
158
+ openclaw: OPENCLAW_CAPABILITY,
137
159
  }
138
160
 
139
161
  /** Question-routing capability for the active harness integration. */