@raidiant/notifai 9.3.0 → 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 +26 -0
- 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-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +3 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +42 -4
- 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-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +17 -11
- package/dist/commands-hooks.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 +90 -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 +3 -4
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- 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 +28 -5
- package/dist/program.js.map +1 -1
- package/dist/send.js +11 -14
- package/dist/send.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/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +3 -0
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hooks.ts +36 -18
- 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 +344 -11
- 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/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hooks.test.ts +4 -3
- package/src/hooks.ts +4 -4
- package/src/install-hooks.test.ts +5 -4
- package/src/install-hooks.ts +19 -21
- package/src/invocation-context.ts +2 -2
- 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 +38 -4
- package/src/send.ts +10 -19
- 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/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,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
|
+
})
|
package/src/harnesses.test.ts
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
HARNESS_CAPABILITIES,
|
|
5
|
+
HARNESS_LABELS,
|
|
6
|
+
HERMES_CLASSIC_CLI_LOCAL_CAPABILITY,
|
|
7
|
+
HERMES_QUESTION_ROUTING_UNAVAILABLE,
|
|
8
|
+
HOOK_INSTALLABLE_HARNESSES,
|
|
9
|
+
SOURCE_CONTEXT_HARNESSES,
|
|
10
|
+
hermesClassicCliLocalInstance,
|
|
11
|
+
isHookInstallableHarness,
|
|
12
|
+
questionRoutingCapability,
|
|
13
|
+
} from './harnesses.js'
|
|
14
|
+
|
|
15
|
+
const PINNED_HERMES_TRACE = JSON.parse(
|
|
16
|
+
readFileSync(new URL('./fixtures/hermes-0.20.6-classic-cli-local.json', import.meta.url), 'utf8'),
|
|
17
|
+
) as {
|
|
18
|
+
instance: { harness: 'hermes'; surface: 'classic-cli'; terminalBackend: 'local' }
|
|
19
|
+
observedToolSubprocessEnv: { HERMES_SESSION_ID: string }
|
|
20
|
+
supported: string[]
|
|
21
|
+
unsupported: string[]
|
|
22
|
+
deferred: string[]
|
|
23
|
+
}
|
|
3
24
|
|
|
4
25
|
/**
|
|
5
26
|
* This file used to hold a verbatim copy of the table below it, which meant
|
|
@@ -8,23 +29,36 @@ import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
|
|
|
8
29
|
* edit. What is worth asserting is the shape the rest of the CLI assumes.
|
|
9
30
|
*/
|
|
10
31
|
describe('harness contract', () => {
|
|
11
|
-
it('describes every shipped harness, and only those', () => {
|
|
12
|
-
expect(Object.keys(
|
|
13
|
-
|
|
32
|
+
it('describes every shipped Source Context harness, and only those', () => {
|
|
33
|
+
expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...SOURCE_CONTEXT_HARNESSES].sort())
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('keeps managed hook installation a strict subset of Source Context', () => {
|
|
37
|
+
expect(HOOK_INSTALLABLE_HARNESSES).toEqual(['claude-code', 'codex', 'cursor', 'opencode'])
|
|
38
|
+
expect(SOURCE_CONTEXT_HARNESSES).toContain('hermes')
|
|
39
|
+
expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
|
|
40
|
+
expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HOOK_INSTALLABLE_HARNESSES].sort())
|
|
41
|
+
for (const harness of HOOK_INSTALLABLE_HARNESSES) {
|
|
42
|
+
expect(isHookInstallableHarness(harness)).toBe(true)
|
|
43
|
+
}
|
|
44
|
+
expect(isHookInstallableHarness('hermes')).toBe(false)
|
|
14
45
|
})
|
|
15
46
|
|
|
16
47
|
it('gives a harness that cannot continue a turn no route to pretend with', () => {
|
|
17
48
|
// `ask` fails closed on these, and the reason it can is that the two facts
|
|
18
49
|
// agree. A harness claiming a delivery route while declaring it cannot
|
|
19
50
|
// continue would accept an answer into a void.
|
|
20
|
-
for (const
|
|
51
|
+
for (const harness of HOOK_INSTALLABLE_HARNESSES) {
|
|
52
|
+
const capability = questionRoutingCapability(harness)
|
|
21
53
|
if (capability.stopContinuation !== 'unsupported') continue
|
|
22
54
|
expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
|
|
23
55
|
}
|
|
56
|
+
expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
|
|
24
57
|
})
|
|
25
58
|
|
|
26
59
|
it('gives a harness that can continue a turn at least one real route', () => {
|
|
27
|
-
for (const
|
|
60
|
+
for (const harness of HOOK_INSTALLABLE_HARNESSES) {
|
|
61
|
+
const capability = HARNESS_CAPABILITIES[harness]
|
|
28
62
|
if (capability.stopContinuation === 'unsupported') continue
|
|
29
63
|
expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
|
|
30
64
|
expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
|
|
@@ -35,8 +69,73 @@ describe('harness contract', () => {
|
|
|
35
69
|
})
|
|
36
70
|
|
|
37
71
|
it('explains each harness in prose an operator can act on', () => {
|
|
38
|
-
for (const
|
|
39
|
-
expect(
|
|
72
|
+
for (const harness of HOOK_INSTALLABLE_HARNESSES) {
|
|
73
|
+
expect(questionRoutingCapability(harness).deliveryContract.length, harness).toBeGreaterThan(40)
|
|
74
|
+
}
|
|
75
|
+
expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract.length).toBeGreaterThan(40)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps Claude Code, Codex, Cursor, and OpenCode continuation contracts unchanged', () => {
|
|
79
|
+
expect(HARNESS_CAPABILITIES['claude-code'].stopContinuation).toBe('decision-block')
|
|
80
|
+
expect(HARNESS_CAPABILITIES['claude-code'].deliveryRoutes).toEqual([
|
|
81
|
+
'hook-continuation',
|
|
82
|
+
'inbox-socket',
|
|
83
|
+
'cold-resume',
|
|
84
|
+
'hold-for-next-turn',
|
|
85
|
+
])
|
|
86
|
+
expect(HARNESS_CAPABILITIES.codex.stopContinuation).toBe('decision-block')
|
|
87
|
+
expect(HARNESS_CAPABILITIES.codex.deliveryRoutes).toEqual([
|
|
88
|
+
'hook-continuation',
|
|
89
|
+
'cold-resume',
|
|
90
|
+
'hold-for-next-turn',
|
|
91
|
+
])
|
|
92
|
+
expect(HARNESS_CAPABILITIES.cursor).toEqual({
|
|
93
|
+
stopContinuation: 'unsupported',
|
|
94
|
+
deliveryRoutes: ['unsupported'],
|
|
95
|
+
deliveryContract: HARNESS_CAPABILITIES.cursor.deliveryContract,
|
|
96
|
+
})
|
|
97
|
+
expect(HARNESS_CAPABILITIES.opencode.stopContinuation).toBe('unsupported')
|
|
98
|
+
expect(HARNESS_CAPABILITIES.opencode.deliveryRoutes).toEqual(['unsupported'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('treats the pinned Hermes classic CLI/local trace as send-only', () => {
|
|
102
|
+
expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance).toEqual(PINNED_HERMES_TRACE.instance)
|
|
103
|
+
expect(PINNED_HERMES_TRACE.supported).toContain('deliberate-send')
|
|
104
|
+
expect(PINNED_HERMES_TRACE.unsupported).toContain('question-routing')
|
|
105
|
+
expect(PINNED_HERMES_TRACE.deferred).toContain('gateway')
|
|
106
|
+
expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.sourceContext).toBe(
|
|
107
|
+
'hermes-session-id-and-invocation-cwd',
|
|
108
|
+
)
|
|
109
|
+
expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.stopContinuation).toBe('unsupported')
|
|
110
|
+
expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('resolves only the pinned classic CLI/local marker envelope', () => {
|
|
114
|
+
expect(
|
|
115
|
+
hermesClassicCliLocalInstance({
|
|
116
|
+
HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
|
|
117
|
+
}),
|
|
118
|
+
).toEqual(PINNED_HERMES_TRACE.instance)
|
|
119
|
+
expect(
|
|
120
|
+
hermesClassicCliLocalInstance({
|
|
121
|
+
HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
|
|
122
|
+
HERMES_SESSION_SOURCE: 'cli',
|
|
123
|
+
TERMINAL_ENV: 'local',
|
|
124
|
+
}),
|
|
125
|
+
).toEqual(PINNED_HERMES_TRACE.instance)
|
|
126
|
+
for (const env of [
|
|
127
|
+
{ HERMES_SESSION_KEY: 'gateway-key' },
|
|
128
|
+
{ HERMES_SESSION_PLATFORM: 'api_server' },
|
|
129
|
+
{ HERMES_SESSION_SOURCE: 'tui' },
|
|
130
|
+
{ TERMINAL_ENV: 'docker' },
|
|
131
|
+
{ TERMINAL_ENV: 'ssh' },
|
|
132
|
+
]) {
|
|
133
|
+
expect(
|
|
134
|
+
hermesClassicCliLocalInstance({
|
|
135
|
+
HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
|
|
136
|
+
...env,
|
|
137
|
+
}),
|
|
138
|
+
).toBeNull()
|
|
40
139
|
}
|
|
41
140
|
})
|
|
42
141
|
})
|
package/src/harnesses.ts
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
/** Every harness Notifai
|
|
2
|
-
export const
|
|
1
|
+
/** Every harness Notifai can name in Source Context when authoritatively known. */
|
|
2
|
+
export const SOURCE_CONTEXT_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode', 'hermes'] as const
|
|
3
3
|
|
|
4
|
-
export type
|
|
4
|
+
export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Harnesses whose lifecycle Notifai can install and manage.
|
|
8
|
+
*
|
|
9
|
+
* Source Context is the broader vocabulary. Managed hook installation is a
|
|
10
|
+
* narrower, independently proven cell: Hermes can appear in Source Context
|
|
11
|
+
* without being hook-installable.
|
|
12
|
+
*/
|
|
13
|
+
export const HOOK_INSTALLABLE_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
14
|
+
|
|
15
|
+
export type HookInstallableHarness = (typeof HOOK_INSTALLABLE_HARNESSES)[number]
|
|
16
|
+
|
|
17
|
+
export function isHookInstallableHarness(value: string): value is HookInstallableHarness {
|
|
18
|
+
return (HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const HARNESS_LABELS: Record<SourceContextHarness, string> = {
|
|
7
22
|
'claude-code': 'Claude Code',
|
|
8
23
|
codex: 'Codex',
|
|
9
24
|
cursor: 'Cursor',
|
|
10
25
|
opencode: 'OpenCode',
|
|
26
|
+
hermes: 'Hermes',
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
export type DeliveryRoute =
|
|
@@ -29,33 +45,98 @@ export interface HarnessCapability {
|
|
|
29
45
|
deliveryContract: string
|
|
30
46
|
}
|
|
31
47
|
|
|
48
|
+
const CLAUDE_CODE_CAPABILITY: HarnessCapability = {
|
|
49
|
+
stopContinuation: 'decision-block',
|
|
50
|
+
deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
|
|
51
|
+
deliveryContract:
|
|
52
|
+
'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CODEX_CAPABILITY: HarnessCapability = {
|
|
56
|
+
stopContinuation: 'decision-block',
|
|
57
|
+
deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
|
|
58
|
+
deliveryContract:
|
|
59
|
+
'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const CURSOR_CAPABILITY: HarnessCapability = {
|
|
63
|
+
stopContinuation: 'unsupported',
|
|
64
|
+
deliveryRoutes: ['unsupported'],
|
|
65
|
+
deliveryContract:
|
|
66
|
+
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const OPENCODE_CAPABILITY: HarnessCapability = {
|
|
70
|
+
stopContinuation: 'unsupported',
|
|
71
|
+
deliveryRoutes: ['unsupported'],
|
|
72
|
+
deliveryContract:
|
|
73
|
+
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const HERMES_QUESTION_ROUTING_UNAVAILABLE: HarnessCapability = {
|
|
77
|
+
stopContinuation: 'unsupported',
|
|
78
|
+
deliveryRoutes: ['unsupported'],
|
|
79
|
+
deliveryContract:
|
|
80
|
+
'Hermes asynchronous ask has no proven continuation owner on this integration surface. Use a blocking `notifai send --reply` question',
|
|
81
|
+
}
|
|
82
|
+
|
|
32
83
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
84
|
+
* The one Hermes integration instance proven by the pinned vertical probe.
|
|
85
|
+
*
|
|
86
|
+
* This is evidence-bearing capability data, not an adapter. Unsupported and
|
|
87
|
+
* deferred surfaces intentionally have no empty implementation object here.
|
|
35
88
|
*/
|
|
36
|
-
export const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
|
|
42
|
-
},
|
|
43
|
-
codex: {
|
|
44
|
-
stopContinuation: 'decision-block',
|
|
45
|
-
deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
|
|
46
|
-
deliveryContract:
|
|
47
|
-
'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
|
|
48
|
-
},
|
|
49
|
-
cursor: {
|
|
50
|
-
stopContinuation: 'unsupported',
|
|
51
|
-
deliveryRoutes: ['unsupported'],
|
|
52
|
-
deliveryContract:
|
|
53
|
-
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
54
|
-
},
|
|
55
|
-
opencode: {
|
|
56
|
-
stopContinuation: 'unsupported',
|
|
57
|
-
deliveryRoutes: ['unsupported'],
|
|
58
|
-
deliveryContract:
|
|
59
|
-
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
89
|
+
export const HERMES_CLASSIC_CLI_LOCAL_CAPABILITY = {
|
|
90
|
+
instance: {
|
|
91
|
+
harness: 'hermes',
|
|
92
|
+
surface: 'classic-cli',
|
|
93
|
+
terminalBackend: 'local',
|
|
60
94
|
},
|
|
95
|
+
setup: 'unsupported',
|
|
96
|
+
activation: 'unsupported',
|
|
97
|
+
sourceContext: 'hermes-session-id-and-invocation-cwd',
|
|
98
|
+
continuation: 'unsupported',
|
|
99
|
+
} as const
|
|
100
|
+
|
|
101
|
+
export type HermesClassicCliLocalInstance =
|
|
102
|
+
(typeof HERMES_CLASSIC_CLI_LOCAL_CAPABILITY)['instance']
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve only the Hermes instance whose complete marker envelope was proven.
|
|
106
|
+
*
|
|
107
|
+
* `HERMES_SESSION_ID` is also bridged by deferred gateway, TUI, API, ACP, and
|
|
108
|
+
* remote-backend paths. Their first-party routing/source/backend markers keep
|
|
109
|
+
* those cells distinct. Missing source/backend values are the pinned classic
|
|
110
|
+
* CLI defaults; a gateway key is never part of that instance.
|
|
111
|
+
*/
|
|
112
|
+
export function hermesClassicCliLocalInstance(
|
|
113
|
+
env: NodeJS.ProcessEnv,
|
|
114
|
+
): HermesClassicCliLocalInstance | null {
|
|
115
|
+
const sessionId = (env['HERMES_SESSION_ID'] ?? '').trim()
|
|
116
|
+
const sessionKey = (env['HERMES_SESSION_KEY'] ?? '').trim()
|
|
117
|
+
const platform = (env['HERMES_SESSION_PLATFORM'] ?? '').trim().toLowerCase()
|
|
118
|
+
const source = (env['HERMES_SESSION_SOURCE'] ?? '').trim().toLowerCase()
|
|
119
|
+
const terminalBackend = (env['TERMINAL_ENV'] ?? '').trim().toLowerCase()
|
|
120
|
+
if (sessionId === '' || sessionKey !== '' || platform !== '') return null
|
|
121
|
+
if (source !== '' && source !== 'cli') return null
|
|
122
|
+
if (terminalBackend !== '' && terminalBackend !== 'local') return null
|
|
123
|
+
return HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Question-routing contract for hook-installable harnesses.
|
|
128
|
+
*
|
|
129
|
+
* Hermes is deliberately absent: a send-only surface must not occupy this
|
|
130
|
+
* table with a fake managed-hook continuation route.
|
|
131
|
+
*/
|
|
132
|
+
export const HARNESS_CAPABILITIES: Record<HookInstallableHarness, HarnessCapability> = {
|
|
133
|
+
'claude-code': CLAUDE_CODE_CAPABILITY,
|
|
134
|
+
codex: CODEX_CAPABILITY,
|
|
135
|
+
cursor: CURSOR_CAPABILITY,
|
|
136
|
+
opencode: OPENCODE_CAPABILITY,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Question-routing capability for the active harness integration. */
|
|
140
|
+
export function questionRoutingCapability(harness: HookInstallableHarness): HarnessCapability {
|
|
141
|
+
return HARNESS_CAPABILITIES[harness]
|
|
61
142
|
}
|
package/src/hooks.test.ts
CHANGED
|
@@ -561,8 +561,9 @@ describe('pushing a registered question', () => {
|
|
|
561
561
|
title: 'Ship it?',
|
|
562
562
|
body: 'Ship it?',
|
|
563
563
|
})
|
|
564
|
-
//
|
|
565
|
-
|
|
564
|
+
// Kind default sounds are resolved server-side; stamping one here would
|
|
565
|
+
// skip Account and Project maps.
|
|
566
|
+
expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBeUndefined()
|
|
566
567
|
})
|
|
567
568
|
|
|
568
569
|
it('routes a registered question to an answer-capable Android installation', async () => {
|
|
@@ -594,7 +595,7 @@ describe('pushing a registered question', () => {
|
|
|
594
595
|
mode: 'selected',
|
|
595
596
|
device_ids: ['dev_android'],
|
|
596
597
|
})
|
|
597
|
-
expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).
|
|
598
|
+
expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBeUndefined()
|
|
598
599
|
})
|
|
599
600
|
|
|
600
601
|
it('keeps sparse alt text paired with its original media item', async () => {
|
package/src/hooks.ts
CHANGED
|
@@ -48,9 +48,9 @@ import { inferInvocationContext } from './invocation-context.js'
|
|
|
48
48
|
import type { Logger } from './logging.js'
|
|
49
49
|
import {
|
|
50
50
|
HARNESS_CAPABILITIES,
|
|
51
|
-
|
|
51
|
+
HOOK_INSTALLABLE_HARNESSES,
|
|
52
52
|
type DeliveryRoute,
|
|
53
|
-
type
|
|
53
|
+
type HookInstallableHarness,
|
|
54
54
|
} from './harnesses.js'
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -896,7 +896,7 @@ function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPo
|
|
|
896
896
|
}
|
|
897
897
|
if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
|
|
898
898
|
if (typeof entry.updated_at !== 'number') return null
|
|
899
|
-
if (!(
|
|
899
|
+
if (!(HOOK_INSTALLABLE_HARNESSES as readonly unknown[]).includes(entry.harness)) return null
|
|
900
900
|
return {
|
|
901
901
|
sessionId: entry.session_id,
|
|
902
902
|
updatedAt: entry.updated_at,
|
|
@@ -1135,7 +1135,7 @@ function gate(
|
|
|
1135
1135
|
ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
|
|
1136
1136
|
}
|
|
1137
1137
|
|
|
1138
|
-
export type HookHarness =
|
|
1138
|
+
export type HookHarness = HookInstallableHarness
|
|
1139
1139
|
|
|
1140
1140
|
/**
|
|
1141
1141
|
* One round of the waiter's reply poll. Short enough that several questions
|