@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.
- package/CHANGELOG.md +40 -0
- package/README.md +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands-agent-sessions.d.ts +6 -0
- package/dist/commands-agent-sessions.js +51 -0
- package/dist/commands-agent-sessions.js.map +1 -0
- package/dist/commands-auth.js +4 -4
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +5 -0
- package/dist/commands-core.js +2 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +121 -40
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +40 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +139 -16
- 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/commands-send-support.js +18 -1
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +2 -2
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-sounds.d.ts +12 -0
- package/dist/commands-sounds.js +35 -0
- package/dist/commands-sounds.js.map +1 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +5 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -4
- package/dist/config.js +5 -2
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +110 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +2 -2
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -11
- package/dist/install-hooks.js +18 -13
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.js +20 -23
- package/dist/interactive.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- 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/orca-session-title.d.ts +10 -0
- package/dist/orca-session-title.js +98 -0
- package/dist/orca-session-title.js.map +1 -0
- package/dist/program.d.ts +3 -1
- package/dist/program.js +37 -13
- package/dist/program.js.map +1 -1
- package/dist/send.js +11 -14
- package/dist/send.js.map +1 -1
- package/dist/session-activation.js +1 -1
- package/dist/session-activation.js.map +1 -1
- package/dist/session-labels.d.ts +18 -2
- package/dist/session-labels.js +79 -8
- package/dist/session-labels.js.map +1 -1
- package/dist/sound-ref.d.ts +23 -0
- package/dist/sound-ref.js +28 -0
- package/dist/sound-ref.js.map +1 -0
- 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 +3 -3
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-auth.ts +4 -3
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +6 -0
- package/src/commands-doctor.ts +143 -38
- package/src/commands-harness-context.ts +56 -2
- package/src/commands-hooks.ts +180 -20
- package/src/commands-init.ts +10 -4
- package/src/commands-send-support.ts +23 -1
- package/src/commands-send.ts +2 -2
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +529 -17
- package/src/commands.ts +5 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +5 -2
- package/src/config.test.ts +42 -20
- package/src/config.ts +6 -5
- package/src/credentials.test.ts +20 -0
- package/src/credentials.ts +4 -1
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
- package/src/harness-integration.test.ts +294 -0
- package/src/harnesses.test.ts +115 -8
- package/src/harnesses.ts +133 -30
- package/src/hooks.test.ts +4 -3
- package/src/hooks.ts +4 -4
- package/src/install-hooks.test.ts +213 -4
- package/src/install-hooks.ts +38 -30
- package/src/interactive.test.ts +31 -0
- package/src/interactive.ts +20 -26
- package/src/invocation-context.ts +2 -2
- package/src/openclaw-plugin.ts +409 -0
- package/src/orca-session-title.test.ts +54 -0
- package/src/orca-session-title.ts +103 -0
- package/src/program.test.ts +27 -0
- package/src/program.ts +47 -12
- package/src/send.ts +10 -19
- package/src/session-activation.ts +1 -1
- package/src/session-labels.test.ts +58 -9
- package/src/session-labels.ts +95 -12
- package/src/skill.test.ts +14 -3
- package/src/sound-ref.test.ts +31 -0
- package/src/sound-ref.ts +34 -0
- package/src/ui/config-view.test.ts +1 -0
- package/src/ui/help.test.ts +16 -0
- 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'
|
package/src/config-schema.ts
CHANGED
|
@@ -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: '
|
|
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
|
|
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'
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
|
180
|
+
it('uses the prior default when a newer CLI wrote an unknown interruption level', () => {
|
|
181
181
|
const { env, cwd } = setup({
|
|
182
|
-
globalToml: '
|
|
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: {}
|
|
213
|
-
{ flags: { kind: 'update' }
|
|
214
|
-
{ flags: { kind: 'done' }
|
|
215
|
-
{ flags: { kind: 'failed' }
|
|
216
|
-
{ flags: { kind: 'blocked' }
|
|
217
|
-
{ flags: { kind: 'question' }
|
|
218
|
-
{ flags: { reply: true }
|
|
219
|
-
])('
|
|
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).
|
|
224
|
-
expect(build.draft.platform?.android?.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: '
|
|
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<
|
|
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?:
|
|
394
|
+
sound?: string
|
|
394
395
|
interruption_level?: CliInterruptionLevel
|
|
395
396
|
}
|
|
396
397
|
|
package/src/credentials.test.ts
CHANGED
|
@@ -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
|
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|