@raidiant/notifai 9.2.2 → 10.0.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 +45 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.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-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-doctor.js +83 -6
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +32 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +80 -23
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +0 -4
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +12 -6
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.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 +3 -0
- package/dist/commands.js +3 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +7 -4
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +6 -7
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +90 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +28 -52
- package/dist/hooks.js +393 -152
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +17 -39
- package/dist/install-hooks.js +21 -41
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +2 -2
- package/dist/orca-session-title.js +3 -4
- package/dist/orca-session-title.js.map +1 -1
- package/dist/program.d.ts +6 -1
- package/dist/program.js +46 -5
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +2 -0
- package/dist/send.js +17 -15
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- 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/package.json +3 -3
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +1 -1
- package/src/commands-doctor.ts +96 -8
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +95 -31
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +0 -4
- package/src/commands-send.ts +10 -5
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +386 -23
- package/src/commands.ts +10 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +7 -4
- package/src/config.test.ts +44 -23
- package/src/config.ts +13 -15
- 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/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +812 -83
- package/src/hooks.ts +529 -185
- package/src/install-hooks.test.ts +13 -16
- package/src/install-hooks.ts +40 -58
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +8 -49
- package/src/orca-session-title.ts +3 -5
- package/src/program.test.ts +45 -0
- package/src/program.ts +60 -4
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +18 -20
- package/src/session-activation.ts +3 -4
- 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/config.test.ts
CHANGED
|
@@ -141,10 +141,9 @@ describe('harness config layers', () => {
|
|
|
141
141
|
expect(config.ask_grace_seconds).toEqual({ value: 0, source: 'default' })
|
|
142
142
|
})
|
|
143
143
|
|
|
144
|
-
it('bounds the terminal-first window
|
|
144
|
+
it('bounds the terminal-first window to the supported preference', () => {
|
|
145
145
|
// Config is readable from a repository, so an unbounded grace is hostile
|
|
146
|
-
// input
|
|
147
|
-
// server would accept an answer into.
|
|
146
|
+
// input rather than permission to hold question delivery arbitrarily.
|
|
148
147
|
const { env, cwd } = setup({ globalToml: 'ask_grace_seconds = 100000\n' })
|
|
149
148
|
expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(360)
|
|
150
149
|
})
|
|
@@ -178,14 +177,29 @@ describe('config precedence', () => {
|
|
|
178
177
|
expect(config.wait_seconds.source).toBe('flag')
|
|
179
178
|
})
|
|
180
179
|
|
|
181
|
-
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', () => {
|
|
182
181
|
const { env, cwd } = setup({
|
|
183
|
-
globalToml: '
|
|
182
|
+
globalToml: 'interruption_level = "future-level"\n',
|
|
184
183
|
})
|
|
185
184
|
const config = loadConfig({ cwd, env })
|
|
186
|
-
expect(config.sound).toEqual({ value: null, source: 'default' })
|
|
187
185
|
expect(config.interruption_level).toEqual({ value: null, source: 'default' })
|
|
188
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
|
+
})
|
|
189
203
|
})
|
|
190
204
|
|
|
191
205
|
describe('draft building', () => {
|
|
@@ -200,29 +214,23 @@ describe('draft building', () => {
|
|
|
200
214
|
presentation: { title: 'T', body: 'B' },
|
|
201
215
|
targets: { mode: 'all' },
|
|
202
216
|
delivery: { ttl_seconds: 86400, collapse_key: null },
|
|
203
|
-
// An unlabelled draft is ordinary news, and news has a sound.
|
|
204
|
-
platform: {
|
|
205
|
-
ios: { sound: 'default' },
|
|
206
|
-
macos: { sound: 'default' },
|
|
207
|
-
android: { sound: 'default' },
|
|
208
|
-
},
|
|
209
217
|
})
|
|
210
218
|
})
|
|
211
219
|
|
|
212
220
|
it.each([
|
|
213
|
-
{ flags: {}
|
|
214
|
-
{ flags: { kind: 'update' }
|
|
215
|
-
{ flags: { kind: 'done' }
|
|
216
|
-
{ flags: { kind: 'failed' }
|
|
217
|
-
{ flags: { kind: 'blocked' }
|
|
218
|
-
{ flags: { kind: 'question' }
|
|
219
|
-
{ flags: { reply: true }
|
|
220
|
-
])('
|
|
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 }) => {
|
|
221
229
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
222
230
|
const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
|
|
223
231
|
if (!build.ok) throw new Error(build.error)
|
|
224
|
-
expect(build.draft.platform?.ios?.sound).
|
|
225
|
-
expect(build.draft.platform?.android?.sound).
|
|
232
|
+
expect(build.draft.platform?.ios?.sound).toBeUndefined()
|
|
233
|
+
expect(build.draft.platform?.android?.sound).toBeUndefined()
|
|
226
234
|
expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
|
|
227
235
|
})
|
|
228
236
|
|
|
@@ -552,9 +560,22 @@ describe('draft building', () => {
|
|
|
552
560
|
it('rejects malformed inputs with usage errors', () => {
|
|
553
561
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
554
562
|
expect(buildDraft(config, { title: '', body: 'B' }).ok).toBe(false)
|
|
555
|
-
expect(buildDraft(config, { title: 'T', body: 'B', sound: '
|
|
563
|
+
expect(buildDraft(config, { title: 'T', body: 'B', sound: 'n'.repeat(200) }).ok).toBe(false)
|
|
556
564
|
expect(buildDraft(config, { title: 'T', body: 'B', data: ['nokey'] }).ok).toBe(false)
|
|
557
565
|
expect(buildDraft(config, { title: 'T', body: 'B', level: 'shouting' }).ok).toBe(false)
|
|
558
566
|
expect(buildDraft(config, { title: 'T', body: 'B', platform: 'linux' }).ok).toBe(false)
|
|
559
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
|
+
})
|
|
560
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>
|
|
@@ -70,8 +69,8 @@ export interface CliConfig {
|
|
|
70
69
|
* elapses does the question reach companion devices.
|
|
71
70
|
*
|
|
72
71
|
* A timer, and only a timer. Whether the user happens to be at this keyboard
|
|
73
|
-
* decides nothing
|
|
74
|
-
*
|
|
72
|
+
* decides nothing; the harness-specific owner continues waiting either in
|
|
73
|
+
* the held turn or out of band.
|
|
75
74
|
*/
|
|
76
75
|
ask_grace_seconds: ResolvedValue<number>
|
|
77
76
|
/**
|
|
@@ -90,7 +89,7 @@ export interface CliConfig {
|
|
|
90
89
|
* usefulness.
|
|
91
90
|
*/
|
|
92
91
|
log_max_bytes: ResolvedValue<number>
|
|
93
|
-
/** How long the server
|
|
92
|
+
/** How long the server accepts an answer and Question Routing owns its return path. */
|
|
94
93
|
reply_window_seconds: ResolvedValue<number>
|
|
95
94
|
/** Log files kept in total, the active one included. */
|
|
96
95
|
log_max_files: ResolvedValue<number>
|
|
@@ -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
|
}
|
|
@@ -330,17 +328,14 @@ export function findProjectLocalConfigPath(
|
|
|
330
328
|
|
|
331
329
|
/**
|
|
332
330
|
* Ranges every layer is clamped to. Config is readable from a repository, so an
|
|
333
|
-
* out-of-range value is attacker input, not a typo
|
|
334
|
-
* would consume the waiter's whole ceiling and silently leave no time at all in
|
|
335
|
-
* which the user's answer could still be accepted.
|
|
331
|
+
* out-of-range value is attacker input, not a typo.
|
|
336
332
|
*/
|
|
337
333
|
const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
|
|
338
334
|
wait_seconds: { min: 0, max: 300 },
|
|
339
335
|
ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// will accept — with room to spare for a timer that wakes late.
|
|
336
|
+
// Six minutes is the supported terminal-first preference bound. The Stop
|
|
337
|
+
// owner carries separate startup headroom, so this never shortens the answer
|
|
338
|
+
// window that begins after submission.
|
|
344
339
|
ask_grace_seconds: { min: 0, max: 360 },
|
|
345
340
|
// The floor is the shortest window the wire contract accepts; the ceiling is
|
|
346
341
|
// the point past which a question would outlive the retained content it
|
|
@@ -368,6 +363,9 @@ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
|
|
|
368
363
|
const origins = value.map((entry) => (typeof entry === 'string' ? normalizeOrigin(entry) : null))
|
|
369
364
|
return origins.every((origin): origin is string => origin !== null) ? origins : undefined
|
|
370
365
|
}
|
|
366
|
+
if (key === 'sound') {
|
|
367
|
+
return typeof value === 'string' && isCliSoundRef(value) ? value : undefined
|
|
368
|
+
}
|
|
371
369
|
const allowed = ENUM_CONFIG_VALUES[key]
|
|
372
370
|
if (allowed !== undefined) {
|
|
373
371
|
return typeof value === 'string' && allowed.includes(value) ? value : undefined
|
|
@@ -393,7 +391,7 @@ export interface FlagOverrides {
|
|
|
393
391
|
ttl_seconds?: number
|
|
394
392
|
collapse_key?: string | null
|
|
395
393
|
devices?: string[]
|
|
396
|
-
sound?:
|
|
394
|
+
sound?: string
|
|
397
395
|
interruption_level?: CliInterruptionLevel
|
|
398
396
|
}
|
|
399
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
|
+
}
|
package/src/guidance-content.ts
CHANGED
|
@@ -24,6 +24,28 @@ export interface GuidanceTopic {
|
|
|
24
24
|
content: string
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Canonical lifecycle vocabulary, always emitted from trusted CLI source. */
|
|
28
|
+
export const AGENT_TERMS_PREAMBLE = `# Notifai terms
|
|
29
|
+
|
|
30
|
+
An **Agent Event** is something that happened in the agent's work: completion,
|
|
31
|
+
failure, a blocker, a question that needs the User, or another meaningful
|
|
32
|
+
change. It stays inside the agent team until the Notification Request owner
|
|
33
|
+
decides it should reach the User.
|
|
34
|
+
|
|
35
|
+
A **Notification Request** is the deliberate User-visible message or question
|
|
36
|
+
submitted through Notifai about an Agent Event. It is not every event and it
|
|
37
|
+
is not an internal worker report.
|
|
38
|
+
|
|
39
|
+
The root agent owns Notification Requests by default. Ordinary workers report
|
|
40
|
+
Agent Events to their parent and do not notify the User themselves. Explicit
|
|
41
|
+
delegation transfers that ownership for the delegated work.`
|
|
42
|
+
|
|
43
|
+
export const AGENT_TERMS_FALLBACK = `# Notifai terms
|
|
44
|
+
|
|
45
|
+
An **Agent Event** is a meaningful occurrence in agent work. A **Notification
|
|
46
|
+
Request** is a deliberate User-visible message about one. Root owns requests;
|
|
47
|
+
workers report events unless ownership is explicitly delegated.`
|
|
48
|
+
|
|
27
49
|
const WHEN_TO_NOTIFY = `# When to notify
|
|
28
50
|
|
|
29
51
|
Notify when something changed for the user:
|
package/src/guidance-render.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
2
|
import { describeSource } from './config-schema.js'
|
|
3
|
-
import { GUIDANCE_TRUST_PREAMBLE } from './guidance-content.js'
|
|
3
|
+
import { AGENT_TERMS_FALLBACK, AGENT_TERMS_PREAMBLE, GUIDANCE_TRUST_PREAMBLE } from './guidance-content.js'
|
|
4
4
|
import { resolveGuidance, type GuidanceAuthority, type ResolvedGuidanceTopic } from './guidance.js'
|
|
5
5
|
|
|
6
6
|
const AUTHORITY_LABEL: Record<GuidanceAuthority, string> = {
|
|
@@ -24,7 +24,7 @@ export function renderGuidance(topics: readonly ResolvedGuidanceTopic[]): string
|
|
|
24
24
|
`${filePath === null ? '' : ` file=${encodeURIComponent(filePath)}`} -->`
|
|
25
25
|
return `${marker}\n${markerSafe(topic.content).trimEnd()}`
|
|
26
26
|
})
|
|
27
|
-
return [GUIDANCE_TRUST_PREAMBLE, ...blocks].join('\n\n')
|
|
27
|
+
return [GUIDANCE_TRUST_PREAMBLE, AGENT_TERMS_PREAMBLE, ...blocks].join('\n\n')
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** Hard ceiling for model-visible lifecycle context, including repository topics. */
|
|
@@ -48,7 +48,7 @@ export function boundedEffectiveGuidance(options: {
|
|
|
48
48
|
bytes,
|
|
49
49
|
max_bytes: maxBytes,
|
|
50
50
|
fallback:
|
|
51
|
-
`${GUIDANCE_TRUST_PREAMBLE}\n\n` +
|
|
51
|
+
`${GUIDANCE_TRUST_PREAMBLE}\n\n${AGENT_TERMS_FALLBACK}\n\n` +
|
|
52
52
|
`Notifai guidance is ${bytes} bytes, above the ${maxBytes}-byte lifecycle limit. ` +
|
|
53
53
|
'Before deciding whether or how to notify, run `notifai guidance` once in this session. ' +
|
|
54
54
|
'Do not infer or partially follow the omitted topics.',
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
})
|