@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/commands-auth.js +4 -4
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +2 -0
- package/dist/commands-core.js +2 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +79 -36
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.js +8 -0
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.js +122 -5
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +10 -5
- package/dist/commands-init.js.map +1 -1
- package/dist/harnesses.d.ts +2 -2
- package/dist/harnesses.js +22 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/install-hooks.js +15 -9
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.js +20 -23
- package/dist/interactive.js.map +1 -1
- package/dist/openclaw-plugin.d.ts +49 -0
- package/dist/openclaw-plugin.js +350 -0
- package/dist/openclaw-plugin.js.map +1 -0
- package/dist/program.js +9 -8
- package/dist/program.js.map +1 -1
- package/dist/session-activation.js +1 -1
- package/dist/session-activation.js.map +1 -1
- package/dist/ui/help.d.ts +4 -5
- package/dist/ui/help.js +5 -6
- package/dist/ui/help.js.map +1 -1
- package/package.json +2 -2
- package/src/commands-auth.ts +4 -3
- package/src/commands-core.ts +3 -0
- package/src/commands-doctor.ts +95 -34
- package/src/commands-harness-context.ts +8 -0
- package/src/commands-hooks.ts +144 -2
- package/src/commands-init.ts +10 -4
- package/src/commands.test.ts +185 -6
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
- package/src/harness-integration.test.ts +82 -0
- package/src/harnesses.test.ts +9 -1
- package/src/harnesses.ts +24 -2
- package/src/install-hooks.test.ts +208 -0
- package/src/install-hooks.ts +19 -9
- package/src/interactive.test.ts +31 -0
- package/src/interactive.ts +20 -26
- package/src/openclaw-plugin.ts +409 -0
- package/src/program.ts +9 -8
- package/src/session-activation.ts +1 -1
- package/src/skill.test.ts +2 -2
- package/src/ui/help.test.ts +16 -0
- package/src/ui/help.ts +6 -7
package/src/commands-init.ts
CHANGED
|
@@ -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
|
|
852
|
-
deps.io.out(
|
|
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
|
|
870
|
-
deps.io.out(
|
|
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
|
+
}
|
package/src/commands.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
4528
|
-
expect(out).toContain('
|
|
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
|
|
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('
|
|
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
|
|
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
|
+
})
|
package/src/harnesses.test.ts
CHANGED
|
@@ -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([
|
|
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 = [
|
|
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 = [
|
|
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. */
|