@raidiant/notifai 7.0.2 → 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 +75 -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-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- 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 +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- 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 +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- 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 +238 -92
- 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 +14 -9
- 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 +5 -487
- 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.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- 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/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- 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 +6 -4
- 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-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- 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 +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- 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
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* The menu leads with whatever is actually in the way. Someone who is not
|
|
19
19
|
* signed in is offered sign-in first; someone with no device that can receive
|
|
20
20
|
* is offered a device; everyone else is offered the test notification, because
|
|
21
|
-
* seeing the thing arrive on your
|
|
21
|
+
* seeing the thing arrive on your device is what makes the product real.
|
|
22
22
|
*
|
|
23
23
|
* Detail is earned, not given. A settings group shows fourteen one-line
|
|
24
24
|
* summaries, and the paragraph explaining a key appears once the reader has
|
|
@@ -365,7 +365,11 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
|
|
|
365
365
|
if (!signedIn) {
|
|
366
366
|
options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
|
|
367
367
|
} else if (!hasDevice) {
|
|
368
|
-
options.push({
|
|
368
|
+
options.push({
|
|
369
|
+
value: 'devices',
|
|
370
|
+
label: 'Add a device',
|
|
371
|
+
hint: 'install a supported Companion App',
|
|
372
|
+
})
|
|
369
373
|
} else {
|
|
370
374
|
options.push({
|
|
371
375
|
value: 'test',
|
|
@@ -414,8 +418,8 @@ async function testNotificationScreen(deps: CommandDeps): Promise<void> {
|
|
|
414
418
|
|
|
415
419
|
const body = await clack.text({
|
|
416
420
|
message: 'Body',
|
|
417
|
-
placeholder: 'If you can read this on your
|
|
418
|
-
defaultValue: 'If you can read this on your
|
|
421
|
+
placeholder: 'If you can read this on your device, it works.',
|
|
422
|
+
defaultValue: 'If you can read this on your device, it works.',
|
|
419
423
|
})
|
|
420
424
|
if (cancelled(body)) return
|
|
421
425
|
|
|
@@ -457,8 +461,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
|
|
|
457
461
|
if (devices.length === 0) {
|
|
458
462
|
clack.note(
|
|
459
463
|
'No devices are registered yet.\n\n' +
|
|
460
|
-
'Install
|
|
461
|
-
'
|
|
464
|
+
'Install a supported Notifai Companion App, sign in with the same\n' +
|
|
465
|
+
'Account, and allow notifications when it asks.',
|
|
462
466
|
'No devices',
|
|
463
467
|
)
|
|
464
468
|
return
|
|
@@ -697,7 +701,7 @@ async function chooseLayer(): Promise<'global' | 'project' | 'local' | null> {
|
|
|
697
701
|
options: [
|
|
698
702
|
{ value: 'global', label: 'This machine', hint: 'every project you work on here' },
|
|
699
703
|
{ value: 'project', label: 'This project, shared', hint: '.notifai/config.toml — commit it' },
|
|
700
|
-
{ 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' },
|
|
701
705
|
],
|
|
702
706
|
initialValue: 'global',
|
|
703
707
|
})
|
|
@@ -733,8 +737,9 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
733
737
|
const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
|
|
734
738
|
clack.note(
|
|
735
739
|
'When an agent registers a question, it appears in your terminal first.\n' +
|
|
736
|
-
'After the grace below, it goes to your
|
|
737
|
-
'with
|
|
740
|
+
'After the grace below, it goes to your Companion devices as a\n' +
|
|
741
|
+
'notification with a supported native or in-app answer surface, and\n' +
|
|
742
|
+
'your answer comes back to the agent —\n' +
|
|
738
743
|
'whether or not you are still at this machine.\n\n' +
|
|
739
744
|
[
|
|
740
745
|
row('Wired to', wiringSummary(hooks)),
|
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', () => {
|