@raidiant/notifai 8.0.0 → 9.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 +41 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +71 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +63 -28
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hooks.d.ts +9 -4
- package/dist/commands-hooks.js +430 -32
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +114 -32
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +60 -11
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +231 -85
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +99 -13
- package/src/commands-guidance.ts +75 -29
- package/src/commands-hooks.ts +503 -34
- package/src/commands-init.ts +139 -38
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +73 -10
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1083 -101
- package/src/commands.ts +1 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance.test.ts +42 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +746 -165
- package/src/hooks.ts +299 -124
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +40 -12
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +45 -0
- package/src/skill.test.ts +120 -4
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
existsSync,
|
|
2
3
|
lstatSync,
|
|
3
4
|
mkdirSync,
|
|
4
5
|
mkdtempSync,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
opencodePluginSource,
|
|
21
22
|
opencodePluginTarget,
|
|
22
23
|
} from './opencode-plugin.js'
|
|
24
|
+
import { SESSION_ACTIVATION_CONTEXT } from './session-activation.js'
|
|
23
25
|
import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
|
|
24
26
|
import {
|
|
25
27
|
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
applyPlan,
|
|
28
30
|
detectHarness,
|
|
29
31
|
detectedHarnesses,
|
|
32
|
+
buildCursorHookConfig,
|
|
30
33
|
buildHookConfig,
|
|
31
34
|
codexLayerDir,
|
|
32
35
|
codexProjectRoot,
|
|
@@ -85,6 +88,33 @@ describe('choice labels', () => {
|
|
|
85
88
|
})
|
|
86
89
|
|
|
87
90
|
describe('hook config', () => {
|
|
91
|
+
it('activates Notifai at the session lifecycle seam for every native hook adapter', () => {
|
|
92
|
+
const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
|
|
93
|
+
const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
|
|
94
|
+
const cursor = buildCursorHookConfig({ adapterPath: ADAPTER, harness: 'cursor' })
|
|
95
|
+
|
|
96
|
+
expect(claude['SessionStart']?.[0]?.hooks[0]).toMatchObject({
|
|
97
|
+
command: hookCommand(ADAPTER, 'session-start', 'claude-code'),
|
|
98
|
+
})
|
|
99
|
+
expect(codex['SessionStart']?.[0]?.hooks[0]).toMatchObject({
|
|
100
|
+
command: hookCommand(ADAPTER, 'session-start', 'codex'),
|
|
101
|
+
})
|
|
102
|
+
expect(cursor['sessionStart']?.[0]).toMatchObject({
|
|
103
|
+
command: hookCommand(ADAPTER, 'session-start', 'cursor'),
|
|
104
|
+
})
|
|
105
|
+
expect(claude['SubagentStart']?.[0]?.hooks[0]).toMatchObject({
|
|
106
|
+
command: hookCommand(ADAPTER, 'subagent-start', 'claude-code'),
|
|
107
|
+
})
|
|
108
|
+
expect(codex['SubagentStart']?.[0]?.hooks[0]).toMatchObject({
|
|
109
|
+
command: hookCommand(ADAPTER, 'subagent-start', 'codex'),
|
|
110
|
+
})
|
|
111
|
+
expect(cursor['subagentStart']).toBeUndefined()
|
|
112
|
+
expect(cursor['stop']?.[0]).toMatchObject({
|
|
113
|
+
command: hookCommand(ADAPTER, 'activation-stop', 'cursor'),
|
|
114
|
+
loop_limit: 1,
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
88
118
|
it('invokes one stable adapter so a sparse hook PATH still works', () => {
|
|
89
119
|
expect(hookCommand(ADAPTER, 'stop')).toBe(`'${ADAPTER}' hook stop --owner notifai`)
|
|
90
120
|
})
|
|
@@ -101,7 +131,7 @@ describe('hook config', () => {
|
|
|
101
131
|
const config = buildHookConfig({ adapterPath: ADAPTER })
|
|
102
132
|
expect(config['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
|
|
103
133
|
expect(config['Stop']?.[0]?.hooks[0]?.async).toBeUndefined()
|
|
104
|
-
// Claude Code
|
|
134
|
+
// Claude Code defaults UserPromptSubmit to 30s; Codex gives SessionEnd 1-3s.
|
|
105
135
|
expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
|
|
106
136
|
expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
|
|
107
137
|
})
|
|
@@ -174,7 +204,7 @@ describe('hook config', () => {
|
|
|
174
204
|
SCRIPT,
|
|
175
205
|
)
|
|
176
206
|
|
|
177
|
-
for (const event of ['UserPromptSubmit', 'Stop', 'SessionEnd']) {
|
|
207
|
+
for (const event of ['SessionStart', 'SubagentStart', 'UserPromptSubmit', 'Stop', 'SessionEnd']) {
|
|
178
208
|
const groups = migrated.document.hooks?.[event] ?? []
|
|
179
209
|
expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
|
|
180
210
|
}
|
|
@@ -182,7 +212,13 @@ describe('hook config', () => {
|
|
|
182
212
|
timeout: 3600,
|
|
183
213
|
async: true,
|
|
184
214
|
})
|
|
185
|
-
expect(migrated.replaced.sort()).toEqual([
|
|
215
|
+
expect(migrated.replaced.sort()).toEqual([
|
|
216
|
+
'SessionEnd',
|
|
217
|
+
'SessionStart',
|
|
218
|
+
'Stop',
|
|
219
|
+
'SubagentStart',
|
|
220
|
+
'UserPromptSubmit',
|
|
221
|
+
])
|
|
186
222
|
})
|
|
187
223
|
})
|
|
188
224
|
|
|
@@ -272,13 +308,7 @@ describe('settings locations', () => {
|
|
|
272
308
|
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
|
|
273
309
|
})
|
|
274
310
|
|
|
275
|
-
|
|
276
|
-
* A `hooks.json` nobody has proven is Notifai's is not Notifai's to write.
|
|
277
|
-
* Joining it would avoid Codex's dual-representation warning, but a hook
|
|
278
|
-
* entry carries no owner, so the file may belong to a tool that regenerates
|
|
279
|
-
* it wholesale and would drop our handlers without a word.
|
|
280
|
-
*/
|
|
281
|
-
it('writes config.toml rather than joining a hooks.json that is not ours', () => {
|
|
311
|
+
it('joins the only populated hook representation without special-casing its owner', () => {
|
|
282
312
|
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
|
|
283
313
|
const layer = path.join(repo, '.codex')
|
|
284
314
|
mkdirSync(layer, { recursive: true })
|
|
@@ -292,6 +322,20 @@ describe('settings locations', () => {
|
|
|
292
322
|
},
|
|
293
323
|
})
|
|
294
324
|
|
|
325
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('keeps inline hooks when config.toml is the only populated representation', () => {
|
|
329
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-toml-'))
|
|
330
|
+
const layer = path.join(repo, '.codex')
|
|
331
|
+
mkdirSync(layer, { recursive: true })
|
|
332
|
+
writeFileSync(
|
|
333
|
+
path.join(layer, 'config.toml'),
|
|
334
|
+
['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
|
|
335
|
+
'\n',
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
|
|
295
339
|
expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
|
|
296
340
|
})
|
|
297
341
|
|
|
@@ -640,7 +684,9 @@ describe('finding what is installed', () => {
|
|
|
640
684
|
expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
|
|
641
685
|
expect(problems[0]).toContain(path.join(layer, 'config.toml'))
|
|
642
686
|
expect(problems[0]).toMatch(/Stop/)
|
|
643
|
-
expect(problems[0]).toMatch(
|
|
687
|
+
expect(problems[0]).toMatch(
|
|
688
|
+
/notifies twice per turn for SessionStart, SubagentStart, UserPromptSubmit, Stop, SessionEnd/,
|
|
689
|
+
)
|
|
644
690
|
expect(problems[0]).toMatch(/notifai hooks uninstall --harness codex/)
|
|
645
691
|
expect(problems[0]).not.toMatch(/--global/)
|
|
646
692
|
})
|
|
@@ -692,7 +738,9 @@ describe('finding what is installed', () => {
|
|
|
692
738
|
expect(claude).toBeDefined()
|
|
693
739
|
expect(claude?.handlers.map((h) => h.event).sort()).toEqual([
|
|
694
740
|
'SessionEnd',
|
|
741
|
+
'SessionStart',
|
|
695
742
|
'Stop',
|
|
743
|
+
'SubagentStart',
|
|
696
744
|
'UserPromptSubmit',
|
|
697
745
|
])
|
|
698
746
|
expect(claude?.handlers.every((h) => h.groupIndex === 0 && h.handlerIndex === 0)).toBe(true)
|
|
@@ -725,17 +773,23 @@ describe('finding what is installed', () => {
|
|
|
725
773
|
)
|
|
726
774
|
|
|
727
775
|
const stale = codexTrustProblems(installations, env)
|
|
728
|
-
expect(stale).toHaveLength(
|
|
776
|
+
expect(stale).toHaveLength(5)
|
|
729
777
|
expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
|
|
730
778
|
|
|
731
779
|
writeFileSync(
|
|
732
780
|
trustFile,
|
|
733
781
|
`[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
|
|
734
782
|
)
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
783
|
+
const untrusted = codexTrustProblems(installations, env)
|
|
784
|
+
expect(untrusted).toHaveLength(4)
|
|
785
|
+
expect(untrusted).toEqual(
|
|
786
|
+
expect.arrayContaining([
|
|
787
|
+
expect.stringMatching(/SessionStart.*not trusted.*\/hooks/i),
|
|
788
|
+
expect.stringMatching(/SubagentStart.*not trusted.*\/hooks/i),
|
|
789
|
+
expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
|
|
790
|
+
expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
|
|
791
|
+
]),
|
|
792
|
+
)
|
|
739
793
|
|
|
740
794
|
writeFileSync(
|
|
741
795
|
trustFile,
|
|
@@ -828,6 +882,34 @@ describe('the OpenCode adapter', () => {
|
|
|
828
882
|
expect(source).not.toContain('"permission.ask"')
|
|
829
883
|
})
|
|
830
884
|
|
|
885
|
+
it('adds session activation through OpenCode system context without setup or network', async () => {
|
|
886
|
+
expect(source).toContain('"experimental.chat.system.transform"')
|
|
887
|
+
const generated = (await import(
|
|
888
|
+
`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
|
|
889
|
+
)) as {
|
|
890
|
+
NotifAIPlugin(input: { directory: string; client: object }): Promise<{
|
|
891
|
+
'experimental.chat.system.transform'?: (
|
|
892
|
+
input: object,
|
|
893
|
+
output: { system: string[] },
|
|
894
|
+
) => Promise<void>
|
|
895
|
+
}>
|
|
896
|
+
}
|
|
897
|
+
const plugin = await generated.NotifAIPlugin({ directory: '/repo', client: {} })
|
|
898
|
+
const output = { system: ['existing context'] }
|
|
899
|
+
|
|
900
|
+
await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_1' }, output)
|
|
901
|
+
|
|
902
|
+
expect(output.system).toEqual([`existing context\n\n${SESSION_ACTIVATION_CONTEXT}`])
|
|
903
|
+
|
|
904
|
+
const internal = { system: ['internal agent-generation context'] }
|
|
905
|
+
await plugin['experimental.chat.system.transform']?.({}, internal)
|
|
906
|
+
expect(internal.system).toEqual(['internal agent-generation context'])
|
|
907
|
+
|
|
908
|
+
const empty = { system: [] as string[] }
|
|
909
|
+
await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_2' }, empty)
|
|
910
|
+
expect(empty.system).toEqual([SESSION_ACTIVATION_CONTEXT])
|
|
911
|
+
})
|
|
912
|
+
|
|
831
913
|
it('publishes the session pointer on a user message without injecting content', () => {
|
|
832
914
|
// The CLI has never emitted `hookSpecificOutput.additionalContext`, so the
|
|
833
915
|
// injection this used to assert could not fire. The handler earns its place
|
|
@@ -1262,4 +1344,41 @@ describe('writing config.toml around what is already there', () => {
|
|
|
1262
1344
|
expect(after).toContain('[[hooks.Stop]]')
|
|
1263
1345
|
expect(after).not.toContain('hooks = {}')
|
|
1264
1346
|
})
|
|
1347
|
+
|
|
1348
|
+
it('is a no-op when the serialized document already matches the file', () => {
|
|
1349
|
+
const layer = layerFor('notifai-toml-noop-')
|
|
1350
|
+
const file = path.join(layer, 'config.toml')
|
|
1351
|
+
writeFileSync(file, '# keep me\nmodel = "gpt-5.6"\n')
|
|
1352
|
+
applyPlan(file, { ...loadSettings(file), hooks: ours() })
|
|
1353
|
+
const once = readFileSync(file, 'utf8')
|
|
1354
|
+
applyPlan(file, { ...loadSettings(file), hooks: ours() })
|
|
1355
|
+
expect(readFileSync(file, 'utf8')).toBe(once)
|
|
1356
|
+
})
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
describe('emptied Codex representations', () => {
|
|
1360
|
+
it('deletes an empty JSON hooks file instead of writing {}', () => {
|
|
1361
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-empty-json-'))
|
|
1362
|
+
const file = path.join(repo, 'hooks.json')
|
|
1363
|
+
applyPlan(file, { hooks: ours() })
|
|
1364
|
+
expect(existsSync(file)).toBe(true)
|
|
1365
|
+
const stripped = removeHooks(loadSettings(file), SCRIPT)
|
|
1366
|
+
applyPlan(file, stripped.document)
|
|
1367
|
+
expect(existsSync(file)).toBe(false)
|
|
1368
|
+
})
|
|
1369
|
+
|
|
1370
|
+
it('keeps a trust-only TOML file and drops empty event tables', () => {
|
|
1371
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-trust-only-'))
|
|
1372
|
+
const file = path.join(repo, 'config.toml')
|
|
1373
|
+
writeFileSync(
|
|
1374
|
+
file,
|
|
1375
|
+
`model = "gpt-5.6"\n\n[hooks.state."${file}:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
|
|
1376
|
+
)
|
|
1377
|
+
const stripped = removeHooks(loadSettings(file), SCRIPT)
|
|
1378
|
+
applyPlan(file, stripped.document)
|
|
1379
|
+
const after = readFileSync(file, 'utf8')
|
|
1380
|
+
expect(after).toContain('model = "gpt-5.6"')
|
|
1381
|
+
expect(after).toContain('trusted_hash')
|
|
1382
|
+
expect(after).not.toContain('[[hooks.Stop]]')
|
|
1383
|
+
})
|
|
1265
1384
|
})
|
package/src/install-hooks.ts
CHANGED
|
@@ -2,7 +2,10 @@ import {
|
|
|
2
2
|
existsSync,
|
|
3
3
|
lstatSync,
|
|
4
4
|
readFileSync,
|
|
5
|
+
readdirSync,
|
|
5
6
|
realpathSync,
|
|
7
|
+
rmdirSync,
|
|
8
|
+
rmSync,
|
|
6
9
|
statSync,
|
|
7
10
|
} from 'node:fs'
|
|
8
11
|
import { createHash } from 'node:crypto'
|
|
@@ -43,8 +46,7 @@ const OPENCODE_EVENTS = [
|
|
|
43
46
|
* `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
|
|
44
47
|
* We default a hook-empty layer to inline config and preserve whichever single
|
|
45
48
|
* representation a populated layer already uses. Notifai lives in one of them,
|
|
46
|
-
* never both
|
|
47
|
-
* other tool's arrangement, and stays untouched.
|
|
49
|
+
* never both.
|
|
48
50
|
*
|
|
49
51
|
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
50
52
|
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
@@ -251,14 +253,37 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
251
253
|
const { adapterPath } = options
|
|
252
254
|
const commandOptions = commandOptionsFrom(options)
|
|
253
255
|
return {
|
|
256
|
+
SessionStart: [
|
|
257
|
+
{
|
|
258
|
+
hooks: [
|
|
259
|
+
{
|
|
260
|
+
type: 'command',
|
|
261
|
+
command: hookCommand(adapterPath, 'session-start', options.harness, commandOptions),
|
|
262
|
+
// Activation is local context only: no config, credentials, or network.
|
|
263
|
+
timeout: 5,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
SubagentStart: [
|
|
269
|
+
{
|
|
270
|
+
hooks: [
|
|
271
|
+
{
|
|
272
|
+
type: 'command',
|
|
273
|
+
command: hookCommand(adapterPath, 'subagent-start', options.harness, commandOptions),
|
|
274
|
+
timeout: 5,
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
],
|
|
254
279
|
UserPromptSubmit: [
|
|
255
280
|
{
|
|
256
281
|
hooks: [
|
|
257
282
|
{
|
|
258
283
|
type: 'command',
|
|
259
284
|
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
|
|
260
|
-
// Claude Code
|
|
261
|
-
// a slow network
|
|
285
|
+
// Claude Code defaults UserPromptSubmit to 30s. Choose a shorter
|
|
286
|
+
// explicit budget so a slow network cannot delay the user's prompt.
|
|
262
287
|
timeout: 15,
|
|
263
288
|
},
|
|
264
289
|
],
|
|
@@ -299,13 +324,30 @@ interface CursorSettingsDocument {
|
|
|
299
324
|
export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
300
325
|
const commandOptions = commandOptionsFrom(options)
|
|
301
326
|
return {
|
|
327
|
+
sessionStart: [
|
|
328
|
+
{
|
|
329
|
+
command: hookCommand(options.adapterPath, 'session-start', 'cursor', commandOptions),
|
|
330
|
+
// Activation only emits local context and must not depend on setup.
|
|
331
|
+
timeout: 5,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
302
334
|
beforeSubmitPrompt: [
|
|
303
335
|
{
|
|
304
|
-
command: hookCommand(
|
|
336
|
+
command: hookCommand(
|
|
337
|
+
options.adapterPath,
|
|
338
|
+
'user-prompt-submit',
|
|
339
|
+
'cursor',
|
|
340
|
+
commandOptions,
|
|
341
|
+
),
|
|
305
342
|
timeout: 15,
|
|
306
343
|
},
|
|
307
344
|
],
|
|
308
345
|
stop: [
|
|
346
|
+
{
|
|
347
|
+
command: hookCommand(options.adapterPath, 'activation-stop', 'cursor', commandOptions),
|
|
348
|
+
timeout: 5,
|
|
349
|
+
loop_limit: 1,
|
|
350
|
+
},
|
|
309
351
|
{
|
|
310
352
|
command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
|
|
311
353
|
timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
@@ -527,24 +569,15 @@ export interface CodexLayerInspection {
|
|
|
527
569
|
}
|
|
528
570
|
|
|
529
571
|
/**
|
|
530
|
-
* Where Notifai writes in this Codex layer: wherever Notifai already is,
|
|
531
|
-
*
|
|
572
|
+
* Where Notifai writes in this Codex layer: wherever Notifai already is, then
|
|
573
|
+
* whichever single hook representation the layer already uses, and inline
|
|
574
|
+
* `config.toml` for a hook-empty or already-mixed layer.
|
|
532
575
|
*
|
|
533
576
|
* Codex supports `hooks.json` and inline `[hooks]` side by side, loads both,
|
|
534
|
-
* and runs every matching handler.
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
* full of foreign handlers may be one a person wrote by hand or one a tool
|
|
539
|
-
* regenerates wholesale, and those two look identical from here. Appending to
|
|
540
|
-
* the second kind gets Notifai's handlers deleted the next time that tool
|
|
541
|
-
* rewrites its file — silently, on a product whose entire job is reaching
|
|
542
|
-
* someone who is not at the keyboard to notice it stopped.
|
|
543
|
-
*
|
|
544
|
-
* So Notifai only ever writes a file it owns. The warning it may cause is
|
|
545
|
-
* Codex accurately reporting that two handler sets merge and all of them fire;
|
|
546
|
-
* it is noise, and `codexCoexistenceNotes` explains it. That trade is
|
|
547
|
-
* deliberate: visible noise over silent non-delivery.
|
|
577
|
+
* and runs every matching handler. Reusing the one populated representation is
|
|
578
|
+
* the generic layer contract: it avoids creating Codex's dual-representation
|
|
579
|
+
* warning and, for a managed layer, writes through the same authority that
|
|
580
|
+
* persists the layer rather than a sibling file it may regenerate away.
|
|
548
581
|
*
|
|
549
582
|
* Staying put where Notifai already is also keeps reinstalls idempotent and
|
|
550
583
|
* preserves the `[hooks.state]` trust hash, which Codex keys by file path and
|
|
@@ -559,7 +592,13 @@ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection
|
|
|
559
592
|
const ourJsonEvents = ourHandlerEvents(jsonDocument)
|
|
560
593
|
const ourTomlEvents = ourHandlerEvents(tomlDocument)
|
|
561
594
|
const writeTarget =
|
|
562
|
-
ourJsonEvents.length > 0 && ourTomlEvents.length === 0
|
|
595
|
+
ourJsonEvents.length > 0 && ourTomlEvents.length === 0
|
|
596
|
+
? paths.hooksJson
|
|
597
|
+
: ourTomlEvents.length > 0
|
|
598
|
+
? paths.configToml
|
|
599
|
+
: jsonEvents.length > 0 && tomlEvents.length === 0
|
|
600
|
+
? paths.hooksJson
|
|
601
|
+
: paths.configToml
|
|
563
602
|
return {
|
|
564
603
|
paths,
|
|
565
604
|
jsonEvents,
|
|
@@ -812,7 +851,7 @@ function isOurCommand(command: string, scriptPath: string): boolean {
|
|
|
812
851
|
|
|
813
852
|
/** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
|
|
814
853
|
function isLegacyNotifaiCommand(command: string): boolean {
|
|
815
|
-
const hook = `['"]?\\s+hook (?:user-prompt-submit|stop|session-end)\\b`
|
|
854
|
+
const hook = `['"]?\\s+hook (?:session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\\b`
|
|
816
855
|
return (
|
|
817
856
|
new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
|
|
818
857
|
new RegExp(
|
|
@@ -963,7 +1002,11 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
|
|
|
963
1002
|
if (removed) replaced.push(event)
|
|
964
1003
|
if (foreign.length > 0) hooks[event] = foreign
|
|
965
1004
|
}
|
|
966
|
-
|
|
1005
|
+
const remaining = Object.keys(hooks).length > 0 ? hooks : undefined
|
|
1006
|
+
const document = { ...existing }
|
|
1007
|
+
if (remaining === undefined) delete document.hooks
|
|
1008
|
+
else document.hooks = remaining
|
|
1009
|
+
return { document, added: [], replaced, removed: [] }
|
|
967
1010
|
}
|
|
968
1011
|
|
|
969
1012
|
/**
|
|
@@ -980,11 +1023,56 @@ export function applyPlan(file: string, document: SettingsDocument | CursorSetti
|
|
|
980
1023
|
const body = isTomlSettingsPath(file)
|
|
981
1024
|
? tomlBody(file, document as SettingsDocument)
|
|
982
1025
|
: `${JSON.stringify(document, null, 2)}\n`
|
|
1026
|
+
if (
|
|
1027
|
+
(path.basename(file) === 'hooks.json' && isEmptyJsonDocument(body)) ||
|
|
1028
|
+
(isTomlSettingsPath(file) && body.trim() === '')
|
|
1029
|
+
) {
|
|
1030
|
+
if (existsSync(file)) rmSync(file, { force: true })
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
if (existsSync(file)) {
|
|
1034
|
+
try {
|
|
1035
|
+
if (readOwnedRegularFile(file) === body) return
|
|
1036
|
+
} catch {
|
|
1037
|
+
// A file we cannot re-read still needs the replacement below.
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
983
1040
|
atomicWriteFileSync(file, body, {
|
|
984
1041
|
requireCurrentUserOwner: true,
|
|
985
1042
|
})
|
|
986
1043
|
}
|
|
987
1044
|
|
|
1045
|
+
function isEmptyJsonDocument(body: string): boolean {
|
|
1046
|
+
try {
|
|
1047
|
+
const parsed: unknown = JSON.parse(body)
|
|
1048
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
|
|
1049
|
+
} catch {
|
|
1050
|
+
return false
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Delete an emptied Codex hooks.json and a now-empty layer directory. */
|
|
1055
|
+
export function cleanupEmptiedCodexLayer(paths: CodexLayerPaths): void {
|
|
1056
|
+
if (existsSync(paths.hooksJson)) {
|
|
1057
|
+
try {
|
|
1058
|
+
const document = loadSettings(paths.hooksJson)
|
|
1059
|
+
const keys = Object.keys(document)
|
|
1060
|
+
const hooksEmpty = document.hooks === undefined || Object.keys(document.hooks).length === 0
|
|
1061
|
+
if (keys.every((key) => key === 'hooks') && hooksEmpty) {
|
|
1062
|
+
rmSync(paths.hooksJson, { force: true })
|
|
1063
|
+
}
|
|
1064
|
+
} catch {
|
|
1065
|
+
// Leave a file uninstall could not parse; the caller already reported it.
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (!existsSync(paths.dir)) return
|
|
1069
|
+
try {
|
|
1070
|
+
if (readdirSync(paths.dir).length === 0) rmdirSync(paths.dir)
|
|
1071
|
+
} catch {
|
|
1072
|
+
// A concurrently replaced directory is not ours to fight.
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
988
1076
|
/**
|
|
989
1077
|
* A TOML file rewritten around its `[hooks]` tables, not over them.
|
|
990
1078
|
*
|
|
@@ -1243,7 +1331,7 @@ export function codexTrustProblems(
|
|
|
1243
1331
|
* evidence that it has (and is itself worth reporting).
|
|
1244
1332
|
*/
|
|
1245
1333
|
function isNotifaiCommand(command: string): boolean {
|
|
1246
|
-
return / hook (user-prompt-submit|stop|session-end)\b/.test(command)
|
|
1334
|
+
return / hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(command)
|
|
1247
1335
|
}
|
|
1248
1336
|
|
|
1249
1337
|
/** Every place either harness would read a Notifai handler from. */
|
package/src/interactive.ts
CHANGED
|
@@ -701,7 +701,7 @@ async function chooseLayer(): Promise<'global' | 'project' | 'local' | null> {
|
|
|
701
701
|
options: [
|
|
702
702
|
{ value: 'global', label: 'This machine', hint: 'every project you work on here' },
|
|
703
703
|
{ value: 'project', label: 'This project, shared', hint: '.notifai/config.toml — commit it' },
|
|
704
|
-
{ value: 'local', label: 'This project, just me', hint: '
|
|
704
|
+
{ value: 'local', label: 'This project, just me', hint: 'stored on this machine, not in the repo' },
|
|
705
705
|
],
|
|
706
706
|
initialValue: 'global',
|
|
707
707
|
})
|
package/src/logging.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
LOG_SCHEMA_VERSION,
|
|
15
15
|
activeLogPath,
|
|
16
16
|
archiveLogPaths,
|
|
17
|
+
argvFlagNames,
|
|
17
18
|
createLogger,
|
|
18
19
|
logsDiskUsage,
|
|
19
20
|
nullLogger,
|
|
@@ -182,9 +183,71 @@ describe('redaction', () => {
|
|
|
182
183
|
})
|
|
183
184
|
|
|
184
185
|
it('truncates a long string instead of carrying it whole', () => {
|
|
185
|
-
const shaped = shape({
|
|
186
|
-
expect(shaped.
|
|
187
|
-
expect(shaped.
|
|
186
|
+
const shaped = shape({ detail: 'x'.repeat(1000) }) as { detail: string }
|
|
187
|
+
expect(shaped.detail.length).toBeLessThan(500)
|
|
188
|
+
expect(shaped.detail).toContain('+600 chars')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('never persists notification content, answers, or raw argv at debug', () => {
|
|
192
|
+
const env = sandbox()
|
|
193
|
+
const logger = createLogger({ env, settings: { level: 'debug' } })
|
|
194
|
+
logger.debug('cli.start', {
|
|
195
|
+
argv: ['send', '--title', 'SECRET_TITLE', '--body', 'SECRET_BODY'],
|
|
196
|
+
title: 'SECRET_TITLE',
|
|
197
|
+
body: 'SECRET_BODY',
|
|
198
|
+
question: 'SECRET_QUESTION',
|
|
199
|
+
text: 'SECRET_REPLY',
|
|
200
|
+
answers: ['SECRET_ANSWER'],
|
|
201
|
+
choices: ['SECRET_CHOICE'],
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const raw = readFileSync(activeLogPath(env), 'utf8')
|
|
205
|
+
expect(raw).not.toContain('SECRET_TITLE')
|
|
206
|
+
expect(raw).not.toContain('SECRET_BODY')
|
|
207
|
+
expect(raw).not.toContain('SECRET_QUESTION')
|
|
208
|
+
expect(raw).not.toContain('SECRET_REPLY')
|
|
209
|
+
expect(raw).not.toContain('SECRET_ANSWER')
|
|
210
|
+
expect(raw).not.toContain('SECRET_CHOICE')
|
|
211
|
+
const record = JSON.parse(raw) as LogRecord
|
|
212
|
+
expect(record.data).toMatchObject({
|
|
213
|
+
title_chars: 'SECRET_TITLE'.length,
|
|
214
|
+
body_chars: 'SECRET_BODY'.length,
|
|
215
|
+
question_chars: 'SECRET_QUESTION'.length,
|
|
216
|
+
text_chars: 'SECRET_REPLY'.length,
|
|
217
|
+
answers_count: 1,
|
|
218
|
+
choices_count: 1,
|
|
219
|
+
argv_count: 5,
|
|
220
|
+
flags: ['--title', '--body'],
|
|
221
|
+
})
|
|
222
|
+
expect(record.data).not.toHaveProperty('argv')
|
|
223
|
+
expect(record.data).not.toHaveProperty('title')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('strips inline --name=value assignments so notification content is never logged', () => {
|
|
227
|
+
const inlineTitle = 'private title words'
|
|
228
|
+
const inlineBody = 'private body words'
|
|
229
|
+
expect(
|
|
230
|
+
argvFlagNames(['send', `--title=${inlineTitle}`, `--body=${inlineBody}`, '--reply', '--']),
|
|
231
|
+
).toEqual(['--title', '--body', '--reply'])
|
|
232
|
+
|
|
233
|
+
const env = sandbox()
|
|
234
|
+
const logger = createLogger({ env, settings: { level: 'debug' } })
|
|
235
|
+
logger.debug('cli.start', {
|
|
236
|
+
argv: ['send', `--title=${inlineTitle}`, `--body=${inlineBody}`, 'plain'],
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const raw = readFileSync(activeLogPath(env), 'utf8')
|
|
240
|
+
expect(raw).not.toContain(inlineTitle)
|
|
241
|
+
expect(raw).not.toContain(inlineBody)
|
|
242
|
+
expect(shape({ argv: ['send', `--title=${inlineTitle}`, '--choice=yes'] })).toEqual({
|
|
243
|
+
flags: ['--title', '--choice'],
|
|
244
|
+
argv_count: 3,
|
|
245
|
+
})
|
|
246
|
+
const record = JSON.parse(raw) as LogRecord
|
|
247
|
+
expect(record.data).toMatchObject({
|
|
248
|
+
flags: ['--title', '--body'],
|
|
249
|
+
argv_count: 4,
|
|
250
|
+
})
|
|
188
251
|
})
|
|
189
252
|
|
|
190
253
|
it('refuses to recurse for ever into a cyclic object', () => {
|
package/src/logging.ts
CHANGED
|
@@ -80,7 +80,7 @@ const LEVEL_RANK: Record<LogLevel, number> = { off: 0, error: 1, info: 2, debug:
|
|
|
80
80
|
* existing one, and keep each event answering exactly one question.
|
|
81
81
|
*/
|
|
82
82
|
export const LOG_EVENTS = [
|
|
83
|
-
/** A CLI invocation began: which command, which
|
|
83
|
+
/** A CLI invocation began: which command, which flag names, which version. */
|
|
84
84
|
'cli.start',
|
|
85
85
|
/** It ended: exit code and how long it took. */
|
|
86
86
|
'cli.end',
|
|
@@ -90,6 +90,8 @@ export const LOG_EVENTS = [
|
|
|
90
90
|
'config.resolved',
|
|
91
91
|
/** One HTTP call to the service: method, path, status, duration. */
|
|
92
92
|
'http.call',
|
|
93
|
+
/** A send crossed its ambiguous network boundary with this retry identity. */
|
|
94
|
+
'send.attempt',
|
|
93
95
|
/** A notification was accepted by the service. */
|
|
94
96
|
'send.submitted',
|
|
95
97
|
/** What became of it per device. */
|
|
@@ -108,6 +110,8 @@ export const LOG_EVENTS = [
|
|
|
108
110
|
'hook.gate',
|
|
109
111
|
/** A question reached the user's devices. */
|
|
110
112
|
'hook.pushed',
|
|
113
|
+
/** A retirement close was attempted, and whether the server proved it. */
|
|
114
|
+
'hook.retirement',
|
|
111
115
|
/** The hook handed an answer back to the agent. */
|
|
112
116
|
'hook.answer',
|
|
113
117
|
/** The hook finished, with or without a decision. */
|
|
@@ -155,6 +159,10 @@ const MAX_DEPTH = 4
|
|
|
155
159
|
*/
|
|
156
160
|
const SECRET_KEY_PATTERN = /secret|token|password|authorization|credential|verifier|api[_-]?key/i
|
|
157
161
|
const MACHINE_TOKEN_PATTERN = /nfm_[A-Za-z0-9._-]{6,}/g
|
|
162
|
+
/** Notification and question content: persist length, never the words. */
|
|
163
|
+
const CONTENT_STRING_KEYS = new Set(['title', 'subtitle', 'body', 'text', 'question'])
|
|
164
|
+
/** Choice labels, reply answers, and raw argv: persist counts, never values. */
|
|
165
|
+
const CONTENT_LIST_KEYS = new Set(['answers', 'choices', 'argv'])
|
|
158
166
|
|
|
159
167
|
export const REDACTED = '[redacted]'
|
|
160
168
|
|
|
@@ -207,6 +215,22 @@ function redactString(value: string): string {
|
|
|
207
215
|
return `${scrubbed.slice(0, MAX_STRING_CHARS)}…(+${scrubbed.length - MAX_STRING_CHARS} chars)`
|
|
208
216
|
}
|
|
209
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Flag names only — argv values are notification content and user text.
|
|
220
|
+
*
|
|
221
|
+
* `--title=SECRET` must become `--title`. Filtering on a `--` prefix is not
|
|
222
|
+
* enough: inline assignment would otherwise persist the value.
|
|
223
|
+
*/
|
|
224
|
+
export function argvFlagNames(argv: readonly string[]): string[] {
|
|
225
|
+
const names: string[] = []
|
|
226
|
+
for (const token of argv) {
|
|
227
|
+
if (!token.startsWith('--') || token === '--') continue
|
|
228
|
+
const eq = token.indexOf('=')
|
|
229
|
+
names.push(eq === -1 ? token : token.slice(0, eq))
|
|
230
|
+
}
|
|
231
|
+
return names
|
|
232
|
+
}
|
|
233
|
+
|
|
210
234
|
/**
|
|
211
235
|
* Shape a value for the record: drop what must never be written, truncate what
|
|
212
236
|
* would flood a reader, and refuse to recurse for ever into a cyclic object.
|
|
@@ -226,7 +250,26 @@ export function shape(value: unknown, depth = 0): unknown {
|
|
|
226
250
|
const out: Record<string, unknown> = {}
|
|
227
251
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
228
252
|
if (nested === undefined) continue
|
|
229
|
-
|
|
253
|
+
if (SECRET_KEY_PATTERN.test(key)) {
|
|
254
|
+
out[key] = REDACTED
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (CONTENT_STRING_KEYS.has(key) && typeof nested === 'string') {
|
|
258
|
+
out[`${key}_chars`] = nested.length
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (key === 'argv' && Array.isArray(nested)) {
|
|
262
|
+
out.flags = argvFlagNames(
|
|
263
|
+
nested.filter((token): token is string => typeof token === 'string'),
|
|
264
|
+
)
|
|
265
|
+
out.argv_count = nested.length
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
if (CONTENT_LIST_KEYS.has(key) && Array.isArray(nested)) {
|
|
269
|
+
out[`${key}_count`] = nested.length
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
out[key] = shape(nested, depth + 1)
|
|
230
273
|
}
|
|
231
274
|
return out
|
|
232
275
|
}
|