@raidiant/notifai 0.3.0 → 0.4.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/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +891 -43
- package/src/commands.ts +1133 -332
- package/src/config-schema.ts +51 -9
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +3 -3
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/config-view.test.ts +4 -2
- package/src/ui/config-view.ts +3 -0
package/src/install-hooks.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
existsSync,
|
|
3
|
+
lstatSync,
|
|
3
4
|
readFileSync,
|
|
5
|
+
realpathSync,
|
|
4
6
|
statSync,
|
|
5
7
|
} from 'node:fs'
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
6
9
|
import os from 'node:os'
|
|
7
10
|
import path from 'node:path'
|
|
11
|
+
import { parse as parseToml } from 'smol-toml'
|
|
8
12
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
13
|
+
import { hookAdapterPath } from './hook-adapter.js'
|
|
9
14
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
15
|
+
import { HARNESSES, HOOK_TIMING, type Harness } from './harnesses.js'
|
|
16
|
+
|
|
17
|
+
export { HARNESSES, type Harness } from './harnesses.js'
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
@@ -32,13 +40,12 @@ const OPENCODE_EVENTS = [
|
|
|
32
40
|
* adapter instead of being forced through this shared document shape.
|
|
33
41
|
*/
|
|
34
42
|
|
|
35
|
-
export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
36
|
-
export type Harness = (typeof HARNESSES)[number]
|
|
37
|
-
|
|
38
43
|
export interface HookHandler {
|
|
39
44
|
type: 'command'
|
|
40
45
|
command: string
|
|
41
46
|
timeout?: number
|
|
47
|
+
async?: boolean
|
|
48
|
+
statusMessage?: string
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface HookGroup {
|
|
@@ -58,18 +65,17 @@ export interface InstallPlan {
|
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
61
|
-
* The command each hook runs.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
68
|
+
* The command each hook runs. Harness definitions know only the stable
|
|
69
|
+
* user-level adapter. Mutable Node, package-manager, version, and checkout
|
|
70
|
+
* paths live behind that seam and never enter a trusted hook identity.
|
|
64
71
|
*/
|
|
65
72
|
export function hookCommand(
|
|
66
|
-
|
|
67
|
-
scriptPath: string,
|
|
73
|
+
adapterPath: string,
|
|
68
74
|
event: string,
|
|
69
75
|
harness?: Harness,
|
|
70
76
|
): string {
|
|
71
77
|
return (
|
|
72
|
-
`${quote(
|
|
78
|
+
`${quote(adapterPath)} hook ${event} ${OWNER_MARKER}` +
|
|
73
79
|
(harness === undefined ? '' : ` --harness ${harness}`)
|
|
74
80
|
)
|
|
75
81
|
}
|
|
@@ -101,40 +107,41 @@ function quote(value: string): string {
|
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
export interface BuildOptions {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
/** Seconds a blocking hook may wait for a companion-device answer. */
|
|
107
|
-
replyTimeoutSeconds: number
|
|
108
|
-
/** Seconds the question waits in the terminal before it is pushed at all. */
|
|
109
|
-
graceSeconds: number
|
|
110
|
+
/** Stable user-level executable installed by the hook adapter module. */
|
|
111
|
+
adapterPath: string
|
|
110
112
|
/** The installed adapter stamps its exact harness into project pointers. */
|
|
111
113
|
harness?: Harness
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
/**
|
|
115
|
-
const HOOK_CEILING_SECONDS =
|
|
116
|
+
/** Generated blocking adapters grant the owner budget one fixed minute of host headroom. */
|
|
117
|
+
const HOOK_CEILING_SECONDS = HOOK_TIMING.totalSeconds + HOOK_TIMING.hostHeadroomSeconds
|
|
116
118
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Stable process budget for a blocking turn-end adapter.
|
|
121
|
+
*
|
|
122
|
+
* Codex trusts the exact serialized hook definition. Deriving this timeout
|
|
123
|
+
* from mutable question preferences meant changing `ask_grace_seconds`
|
|
124
|
+
* silently invalidated Stop trust while the other two handlers kept running.
|
|
125
|
+
* The runtime already fits grace and reply waiting inside the harness ceiling;
|
|
126
|
+
* the installed identity therefore stays at that ceiling across preferences.
|
|
127
|
+
*/
|
|
128
|
+
export function blockingHookTimeoutSeconds(): number {
|
|
129
|
+
return HOOK_CEILING_SECONDS
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
123
|
-
const {
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
const blockingTimeout = blockingHookTimeoutSeconds(
|
|
128
|
-
options.graceSeconds,
|
|
129
|
-
options.replyTimeoutSeconds,
|
|
130
|
-
)
|
|
133
|
+
const { adapterPath } = options
|
|
134
|
+
const hostOwnsStopTimeout = options.harness === 'codex'
|
|
135
|
+
// Runtime preferences fit inside this fixed process budget without becoming
|
|
136
|
+
// part of the serialized definition Codex asks the user to trust.
|
|
137
|
+
const blockingTimeout = blockingHookTimeoutSeconds()
|
|
131
138
|
return {
|
|
132
139
|
UserPromptSubmit: [
|
|
133
140
|
{
|
|
134
141
|
hooks: [
|
|
135
142
|
{
|
|
136
143
|
type: 'command',
|
|
137
|
-
command: hookCommand(
|
|
144
|
+
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness),
|
|
138
145
|
// Claude Code caps UserPromptSubmit at 30s; stay well inside it so
|
|
139
146
|
// a slow network can never delay the user's own prompt.
|
|
140
147
|
timeout: 15,
|
|
@@ -147,8 +154,8 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
147
154
|
hooks: [
|
|
148
155
|
{
|
|
149
156
|
type: 'command',
|
|
150
|
-
command: hookCommand(
|
|
151
|
-
timeout: blockingTimeout,
|
|
157
|
+
command: hookCommand(adapterPath, 'stop', options.harness),
|
|
158
|
+
...(hostOwnsStopTimeout ? {} : { timeout: blockingTimeout }),
|
|
152
159
|
},
|
|
153
160
|
],
|
|
154
161
|
},
|
|
@@ -158,7 +165,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
158
165
|
hooks: [
|
|
159
166
|
{
|
|
160
167
|
type: 'command',
|
|
161
|
-
command: hookCommand(
|
|
168
|
+
command: hookCommand(adapterPath, 'session-end', options.harness),
|
|
162
169
|
// Both harnesses give SessionEnd a ~1-3s budget, so this handler
|
|
163
170
|
// only touches local state.
|
|
164
171
|
timeout: 3,
|
|
@@ -185,20 +192,17 @@ interface CursorSettingsDocument {
|
|
|
185
192
|
|
|
186
193
|
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
187
194
|
export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
188
|
-
const blockingTimeout = blockingHookTimeoutSeconds(
|
|
189
|
-
options.graceSeconds,
|
|
190
|
-
options.replyTimeoutSeconds,
|
|
191
|
-
)
|
|
195
|
+
const blockingTimeout = blockingHookTimeoutSeconds()
|
|
192
196
|
return {
|
|
193
197
|
beforeSubmitPrompt: [
|
|
194
198
|
{
|
|
195
|
-
command: hookCommand(options.
|
|
199
|
+
command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor'),
|
|
196
200
|
timeout: 15,
|
|
197
201
|
},
|
|
198
202
|
],
|
|
199
203
|
stop: [
|
|
200
204
|
{
|
|
201
|
-
command: hookCommand(options.
|
|
205
|
+
command: hookCommand(options.adapterPath, 'stop', 'cursor'),
|
|
202
206
|
timeout: blockingTimeout,
|
|
203
207
|
// A continuation may register a real follow-up question. Match the
|
|
204
208
|
// session-state cap so those chains are useful but never unbounded.
|
|
@@ -207,7 +211,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
207
211
|
],
|
|
208
212
|
sessionEnd: [
|
|
209
213
|
{
|
|
210
|
-
command: hookCommand(options.
|
|
214
|
+
command: hookCommand(options.adapterPath, 'session-end', 'cursor'),
|
|
211
215
|
timeout: 3,
|
|
212
216
|
},
|
|
213
217
|
],
|
|
@@ -225,24 +229,32 @@ export function settingsFile(
|
|
|
225
229
|
cwd: string,
|
|
226
230
|
env: NodeJS.ProcessEnv = process.env,
|
|
227
231
|
): string {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
232
|
+
switch (harness) {
|
|
233
|
+
// OpenCode has no settings document to merge into — its adapter is a
|
|
234
|
+
// generated plugin module, so it owns a whole file instead.
|
|
235
|
+
case 'opencode':
|
|
236
|
+
return opencodePluginPath(global, cwd, env)
|
|
237
|
+
case 'cursor': {
|
|
238
|
+
const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
|
|
239
|
+
return global
|
|
240
|
+
? path.join(home, '.cursor', 'hooks.json')
|
|
241
|
+
: path.join(cwd, '.cursor', 'hooks.json')
|
|
242
|
+
}
|
|
243
|
+
case 'claude-code':
|
|
244
|
+
return global
|
|
245
|
+
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
|
|
246
|
+
: path.join(cwd, '.claude', 'settings.local.json')
|
|
247
|
+
case 'codex':
|
|
248
|
+
return global
|
|
249
|
+
? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
|
|
250
|
+
: path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
|
|
251
|
+
default:
|
|
252
|
+
return assertNeverHarness(harness)
|
|
242
253
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function assertNeverHarness(harness: never): never {
|
|
257
|
+
throw new Error(`Unsupported harness: ${String(harness)}`)
|
|
246
258
|
}
|
|
247
259
|
|
|
248
260
|
/**
|
|
@@ -403,8 +415,9 @@ interface SettingsDocument {
|
|
|
403
415
|
|
|
404
416
|
function readCursorSettings(file: string): CursorSettingsDocument {
|
|
405
417
|
if (!existsSync(file)) return { version: 1 }
|
|
418
|
+
const source = readOwnedRegularFile(file)
|
|
406
419
|
try {
|
|
407
|
-
const parsed: unknown = JSON.parse(
|
|
420
|
+
const parsed: unknown = JSON.parse(source)
|
|
408
421
|
return typeof parsed === 'object' && parsed !== null
|
|
409
422
|
? (parsed as CursorSettingsDocument)
|
|
410
423
|
: { version: 1 }
|
|
@@ -418,7 +431,24 @@ function isOurCommand(command: string, scriptPath: string): boolean {
|
|
|
418
431
|
return (
|
|
419
432
|
command.includes(`${scriptPath}' hook `) ||
|
|
420
433
|
command.includes(`${scriptPath}" hook `) ||
|
|
421
|
-
command.includes(`${scriptPath} hook `)
|
|
434
|
+
command.includes(`${scriptPath} hook `) ||
|
|
435
|
+
isLegacyNotifaiCommand(command)
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
|
|
440
|
+
function isLegacyNotifaiCommand(command: string): boolean {
|
|
441
|
+
const hook = `['"]?\\s+hook (?:user-prompt-submit|stop|session-end)\\b`
|
|
442
|
+
return (
|
|
443
|
+
new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
|
|
444
|
+
new RegExp(
|
|
445
|
+
`(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`,
|
|
446
|
+
'i',
|
|
447
|
+
).test(command) ||
|
|
448
|
+
new RegExp(
|
|
449
|
+
`[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`,
|
|
450
|
+
'i',
|
|
451
|
+
).test(command)
|
|
422
452
|
)
|
|
423
453
|
}
|
|
424
454
|
|
|
@@ -463,8 +493,9 @@ export function removeCursorHooks(
|
|
|
463
493
|
|
|
464
494
|
function readSettings(file: string): SettingsDocument {
|
|
465
495
|
if (!existsSync(file)) return {}
|
|
496
|
+
const source = readOwnedRegularFile(file)
|
|
466
497
|
try {
|
|
467
|
-
const parsed: unknown = JSON.parse(
|
|
498
|
+
const parsed: unknown = JSON.parse(source)
|
|
468
499
|
return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
|
|
469
500
|
} catch {
|
|
470
501
|
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
|
|
@@ -563,7 +594,9 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
|
|
|
563
594
|
* support.
|
|
564
595
|
*/
|
|
565
596
|
export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
|
|
566
|
-
atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n
|
|
597
|
+
atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`, {
|
|
598
|
+
requireCurrentUserOwner: true,
|
|
599
|
+
})
|
|
567
600
|
}
|
|
568
601
|
|
|
569
602
|
export function loadSettings(file: string): SettingsDocument {
|
|
@@ -588,6 +621,8 @@ export interface InstalledHandler {
|
|
|
588
621
|
command: string
|
|
589
622
|
/** Declared process lifetime, used to detect config/install drift. */
|
|
590
623
|
timeout?: number
|
|
624
|
+
async?: boolean
|
|
625
|
+
statusMessage?: string
|
|
591
626
|
}
|
|
592
627
|
|
|
593
628
|
export interface Installation {
|
|
@@ -599,6 +634,112 @@ export interface Installation {
|
|
|
599
634
|
problems?: string[]
|
|
600
635
|
}
|
|
601
636
|
|
|
637
|
+
/** Codex's canonical event spelling inside its persisted trust keys. */
|
|
638
|
+
function codexEventName(event: string): string {
|
|
639
|
+
return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Recursively sort object keys before hashing, matching Codex's canonical JSON. */
|
|
643
|
+
function canonicalValue(value: unknown): unknown {
|
|
644
|
+
if (Array.isArray(value)) return value.map(canonicalValue)
|
|
645
|
+
if (typeof value !== 'object' || value === null) return value
|
|
646
|
+
return Object.fromEntries(
|
|
647
|
+
Object.entries(value as Record<string, unknown>)
|
|
648
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
649
|
+
.map(([key, child]) => [key, canonicalValue(child)]),
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Best-effort mirror of the identity current Codex builds compare with
|
|
655
|
+
* `hooks.state.*.trusted_hash`. This persisted format is diagnostic evidence,
|
|
656
|
+
* not an API: Codex's `/hooks` UI remains authoritative and Notifai never
|
|
657
|
+
* writes the trust store.
|
|
658
|
+
*/
|
|
659
|
+
export function codexHookIdentityHash(handler: InstalledHandler): string {
|
|
660
|
+
const command = {
|
|
661
|
+
type: 'command',
|
|
662
|
+
command: handler.command,
|
|
663
|
+
timeout: Math.max(1, handler.timeout ?? 600),
|
|
664
|
+
async: handler.async ?? false,
|
|
665
|
+
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
666
|
+
}
|
|
667
|
+
const normalized = canonicalValue({
|
|
668
|
+
event_name: codexEventName(handler.event),
|
|
669
|
+
hooks: [command],
|
|
670
|
+
})
|
|
671
|
+
return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function codexTrustState(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
675
|
+
const file = path.join(configHome(env, 'CODEX_HOME', '.codex'), 'config.toml')
|
|
676
|
+
if (!existsSync(file)) return {}
|
|
677
|
+
try {
|
|
678
|
+
const parsed = parseToml(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
679
|
+
const hooks = parsed['hooks']
|
|
680
|
+
if (typeof hooks !== 'object' || hooks === null) return {}
|
|
681
|
+
const state = (hooks as Record<string, unknown>)['state']
|
|
682
|
+
return typeof state === 'object' && state !== null ? state as Record<string, unknown> : {}
|
|
683
|
+
} catch {
|
|
684
|
+
return {}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Best-effort mirror of current Codex's persisted key for one handler. */
|
|
689
|
+
export function codexTrustKey(
|
|
690
|
+
installation: Installation,
|
|
691
|
+
handler: InstalledHandler,
|
|
692
|
+
): string {
|
|
693
|
+
let source = installation.file
|
|
694
|
+
try {
|
|
695
|
+
source = path.join(realpathSync(path.dirname(source)), path.basename(source))
|
|
696
|
+
} catch {
|
|
697
|
+
// Codex falls back to the logical source path when canonicalization fails,
|
|
698
|
+
// so the diagnostic must do the same.
|
|
699
|
+
}
|
|
700
|
+
return `${source}:${codexEventName(handler.event)}:${handler.groupIndex}:${handler.handlerIndex}`
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Trust defects that make installed Codex handlers look present while Codex
|
|
705
|
+
* skips them. Trust is user-owned; the supported repair is Codex's `/hooks`
|
|
706
|
+
* review UI, never writing the trust store on the user's behalf. Because the
|
|
707
|
+
* persisted shape is not a public Codex contract, this diagnosis can drift;
|
|
708
|
+
* `/hooks` remains the source of truth.
|
|
709
|
+
*/
|
|
710
|
+
export function codexTrustProblems(
|
|
711
|
+
installations: Installation[],
|
|
712
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
713
|
+
): string[] {
|
|
714
|
+
const state = codexTrustState(env)
|
|
715
|
+
return installations
|
|
716
|
+
.filter((installation) => installation.harness === 'codex')
|
|
717
|
+
.flatMap((installation) =>
|
|
718
|
+
installation.handlers.flatMap((handler) => {
|
|
719
|
+
const key = codexTrustKey(installation, handler)
|
|
720
|
+
const entry = state[key]
|
|
721
|
+
const trustedHash =
|
|
722
|
+
typeof entry === 'object' && entry !== null
|
|
723
|
+
? (entry as Record<string, unknown>)['trusted_hash']
|
|
724
|
+
: undefined
|
|
725
|
+
const currentHash = codexHookIdentityHash(handler)
|
|
726
|
+
if (
|
|
727
|
+
typeof entry === 'object' &&
|
|
728
|
+
entry !== null &&
|
|
729
|
+
(entry as Record<string, unknown>)['enabled'] === false
|
|
730
|
+
) {
|
|
731
|
+
return [
|
|
732
|
+
`${handler.event} in ${installation.file} is disabled in Codex; open \`/hooks\` and enable the Notifai handler`,
|
|
733
|
+
]
|
|
734
|
+
}
|
|
735
|
+
if (trustedHash === currentHash) return []
|
|
736
|
+
return [
|
|
737
|
+
`${handler.event} in ${installation.file} is ${typeof trustedHash === 'string' ? 'changed since it was trusted' : 'not trusted'}; open \`/hooks\` in Codex and approve the Notifai handler`,
|
|
738
|
+
]
|
|
739
|
+
}),
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
|
|
602
743
|
/**
|
|
603
744
|
* Any Notifai handler, whatever checkout wrote it.
|
|
604
745
|
*
|
|
@@ -615,7 +756,11 @@ function isNotifaiHandler(handler: HookHandler): boolean {
|
|
|
615
756
|
}
|
|
616
757
|
|
|
617
758
|
/** Every place either harness would read a Notifai handler from. */
|
|
618
|
-
export function findInstallations(
|
|
759
|
+
export function findInstallations(
|
|
760
|
+
cwd: string,
|
|
761
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
762
|
+
adapterHome?: string,
|
|
763
|
+
): Installation[] {
|
|
619
764
|
const found: Installation[] = []
|
|
620
765
|
for (const harness of HARNESSES) {
|
|
621
766
|
for (const global of [false, true]) {
|
|
@@ -625,15 +770,29 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
625
770
|
// is reported as one installation covering all three events rather than
|
|
626
771
|
// parsed for handlers.
|
|
627
772
|
if (harness === 'opencode') {
|
|
628
|
-
|
|
773
|
+
let source: string
|
|
774
|
+
try {
|
|
775
|
+
source = readOwnedRegularFile(file)
|
|
776
|
+
} catch {
|
|
777
|
+
continue
|
|
778
|
+
}
|
|
779
|
+
const target = opencodePluginTarget(source)
|
|
629
780
|
if (target === null) continue
|
|
781
|
+
const problems = [
|
|
782
|
+
...(!target.current
|
|
783
|
+
? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
|
|
784
|
+
: []),
|
|
785
|
+
...(target.adapter !== hookAdapterPath(adapterHome)
|
|
786
|
+
? [
|
|
787
|
+
'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
|
|
788
|
+
]
|
|
789
|
+
: []),
|
|
790
|
+
]
|
|
630
791
|
found.push({
|
|
631
792
|
harness,
|
|
632
793
|
file,
|
|
633
794
|
global,
|
|
634
|
-
...(
|
|
635
|
-
? { problems: ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`'] }
|
|
636
|
-
: {}),
|
|
795
|
+
...(problems.length > 0 ? { problems } : {}),
|
|
637
796
|
// Reported as the command line the plugin will actually run, not as
|
|
638
797
|
// the plugin's own path: every check downstream asks which build a
|
|
639
798
|
// handler invokes, and for a module that answer lives inside it.
|
|
@@ -641,7 +800,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
641
800
|
event,
|
|
642
801
|
groupIndex: 0,
|
|
643
802
|
handlerIndex: 0,
|
|
644
|
-
command: hookCommand(target.
|
|
803
|
+
command: hookCommand(target.adapter, hookEvent, 'opencode'),
|
|
645
804
|
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
646
805
|
})),
|
|
647
806
|
})
|
|
@@ -656,7 +815,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
656
815
|
}
|
|
657
816
|
const handlers = locateCursorHandlers(document)
|
|
658
817
|
if (handlers.length > 0) {
|
|
659
|
-
const problems = harnessMarkerProblems(harness, handlers)
|
|
818
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome)
|
|
660
819
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
661
820
|
}
|
|
662
821
|
continue
|
|
@@ -669,7 +828,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
669
828
|
}
|
|
670
829
|
const handlers = locateHandlers(document)
|
|
671
830
|
if (handlers.length > 0) {
|
|
672
|
-
const problems = harnessMarkerProblems(harness, handlers)
|
|
831
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome)
|
|
673
832
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
674
833
|
}
|
|
675
834
|
}
|
|
@@ -677,12 +836,24 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
677
836
|
return found
|
|
678
837
|
}
|
|
679
838
|
|
|
680
|
-
function harnessMarkerProblems(
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
839
|
+
function harnessMarkerProblems(
|
|
840
|
+
harness: Harness,
|
|
841
|
+
handlers: InstalledHandler[],
|
|
842
|
+
adapterHome?: string,
|
|
843
|
+
): string[] {
|
|
844
|
+
const problems: string[] = []
|
|
845
|
+
if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
|
|
846
|
+
problems.push(
|
|
847
|
+
`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
const expected = `${quote(hookAdapterPath(adapterHome))} hook `
|
|
851
|
+
if (!handlers.every((handler) => handler.command.startsWith(expected))) {
|
|
852
|
+
problems.push(
|
|
853
|
+
'installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter',
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
return problems
|
|
686
857
|
}
|
|
687
858
|
|
|
688
859
|
function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
|
|
@@ -715,6 +886,8 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
|
715
886
|
handlerIndex,
|
|
716
887
|
command: handler.command,
|
|
717
888
|
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
889
|
+
...(handler.async === undefined ? {} : { async: handler.async }),
|
|
890
|
+
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
718
891
|
})
|
|
719
892
|
}
|
|
720
893
|
})
|
|
@@ -727,3 +900,15 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
|
727
900
|
export function handlerEvent(command: string): string | null {
|
|
728
901
|
return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
|
|
729
902
|
}
|
|
903
|
+
|
|
904
|
+
function readOwnedRegularFile(file: string): string {
|
|
905
|
+
const stat = lstatSync(file)
|
|
906
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
907
|
+
throw new Error(`${file} is not a regular file; refusing to read it.`)
|
|
908
|
+
}
|
|
909
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
910
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
911
|
+
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
|
|
912
|
+
}
|
|
913
|
+
return readFileSync(file, 'utf8')
|
|
914
|
+
}
|
package/src/interactive.ts
CHANGED
|
@@ -593,9 +593,9 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
593
593
|
const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
|
|
594
594
|
clack.note(
|
|
595
595
|
'When an agent registers a question, it appears in your terminal first.\n' +
|
|
596
|
-
'
|
|
597
|
-
'
|
|
598
|
-
'
|
|
596
|
+
'After the grace below, it goes to your phone and Mac as a notification\n' +
|
|
597
|
+
'with real answer buttons, and your answer comes back to the agent. Turn\n' +
|
|
598
|
+
'on “Only when away” if local activity should keep it here instead.\n\n' +
|
|
599
599
|
[
|
|
600
600
|
row('Wired to', wiringSummary(hooks)),
|
|
601
601
|
row('Send', config.ask_notifications.value ? 'yes' : 'no — questions stay in the terminal'),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createLogger } from './logging.js'
|
|
4
|
+
|
|
5
|
+
const worker = process.env['NOTIFAI_ROTATION_WORKER']
|
|
6
|
+
|
|
7
|
+
if (worker === undefined) {
|
|
8
|
+
describe('rotation process worker', () => {
|
|
9
|
+
it('stays dormant outside the concurrency regression', () => {
|
|
10
|
+
expect(worker).toBeUndefined()
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
} else {
|
|
14
|
+
describe('rotation process worker', () => {
|
|
15
|
+
it('writes one share of an overlapping log stream', async () => {
|
|
16
|
+
const readyPath = process.env['NOTIFAI_ROTATION_READY']!
|
|
17
|
+
const startPath = process.env['NOTIFAI_ROTATION_START']!
|
|
18
|
+
const records = Number(process.env['NOTIFAI_ROTATION_RECORDS'])
|
|
19
|
+
const maxBytes = Number(process.env['NOTIFAI_ROTATION_MAX_BYTES'])
|
|
20
|
+
const maxFiles = Number(process.env['NOTIFAI_ROTATION_MAX_FILES'])
|
|
21
|
+
writeFileSync(readyPath, 'ready')
|
|
22
|
+
const startDeadline = Date.now() + 10_000
|
|
23
|
+
while (!existsSync(startPath)) {
|
|
24
|
+
if (Date.now() >= startDeadline) throw new Error('rotation parent never released the barrier')
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const logger = createLogger({
|
|
29
|
+
runId: `r_worker_${worker}`,
|
|
30
|
+
settings: { maxBytes, maxFiles },
|
|
31
|
+
})
|
|
32
|
+
for (let sequence = 0; sequence < records; sequence += 1) {
|
|
33
|
+
logger.info('cli.end', { worker, sequence, pad: 'p'.repeat(140) })
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
35
|
+
}
|
|
36
|
+
expect(logger.enabled).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|