@raidiant/notifai 11.7.1 → 12.0.0-beta.5
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 +48 -0
- package/NOTICE +16 -0
- package/THIRD_PARTY_NOTICES +725 -0
- package/bin/notifai.mjs +11387 -0
- package/data/install.ps1 +318 -0
- package/data/release.json +1 -0
- package/inventory.json +1 -0
- package/npm-adapter-files.json +52 -0
- package/package.json +14 -44
- package/README.md +0 -168
- package/dist/agent-update-notice.d.ts +0 -7
- package/dist/agent-update-notice.js +0 -77
- package/dist/agent-update-notice.js.map +0 -1
- package/dist/atomic-file.d.ts +0 -30
- package/dist/atomic-file.js +0 -148
- package/dist/atomic-file.js.map +0 -1
- package/dist/attendant-update.d.ts +0 -15
- package/dist/attendant-update.js +0 -96
- package/dist/attendant-update.js.map +0 -1
- package/dist/claude-wake.d.ts +0 -108
- package/dist/claude-wake.js +0 -369
- package/dist/claude-wake.js.map +0 -1
- package/dist/cli-bin.d.ts +0 -26
- package/dist/cli-bin.js +0 -220
- package/dist/cli-bin.js.map +0 -1
- package/dist/cli-contract.d.ts +0 -16
- package/dist/cli-contract.js +0 -27
- package/dist/cli-contract.js.map +0 -1
- package/dist/cli-release.d.ts +0 -36
- package/dist/cli-release.js +0 -85
- package/dist/cli-release.js.map +0 -1
- package/dist/client.d.ts +0 -109
- package/dist/client.js +0 -217
- package/dist/client.js.map +0 -1
- package/dist/codex-answer-control.d.ts +0 -14
- package/dist/codex-answer-control.js +0 -41
- package/dist/codex-answer-control.js.map +0 -1
- package/dist/codex-answer-presentation.d.ts +0 -18
- package/dist/codex-answer-presentation.js +0 -113
- package/dist/codex-answer-presentation.js.map +0 -1
- package/dist/codex-input-lifecycle.d.ts +0 -10
- package/dist/codex-input-lifecycle.js +0 -63
- package/dist/codex-input-lifecycle.js.map +0 -1
- package/dist/codex-native-control.d.ts +0 -13
- package/dist/codex-native-control.js +0 -120
- package/dist/codex-native-control.js.map +0 -1
- package/dist/codex-native-turn.d.ts +0 -37
- package/dist/codex-native-turn.js +0 -155
- package/dist/codex-native-turn.js.map +0 -1
- package/dist/codex-question-bindings.d.ts +0 -76
- package/dist/codex-question-bindings.js +0 -151
- package/dist/codex-question-bindings.js.map +0 -1
- package/dist/codex-queue-control.d.ts +0 -21
- package/dist/codex-queue-control.js +0 -59
- package/dist/codex-queue-control.js.map +0 -1
- package/dist/codex-tool-messages.d.ts +0 -7
- package/dist/codex-tool-messages.js +0 -126
- package/dist/codex-tool-messages.js.map +0 -1
- package/dist/codex-wake.d.ts +0 -58
- package/dist/codex-wake.js +0 -135
- package/dist/codex-wake.js.map +0 -1
- package/dist/command-session.d.ts +0 -14
- package/dist/command-session.js +0 -31
- package/dist/command-session.js.map +0 -1
- package/dist/commands-acknowledge.d.ts +0 -13
- package/dist/commands-acknowledge.js +0 -116
- package/dist/commands-acknowledge.js.map +0 -1
- package/dist/commands-agent-sessions.d.ts +0 -6
- package/dist/commands-agent-sessions.js +0 -51
- package/dist/commands-agent-sessions.js.map +0 -1
- package/dist/commands-ask.d.ts +0 -62
- package/dist/commands-ask.js +0 -503
- package/dist/commands-ask.js.map +0 -1
- package/dist/commands-auth.d.ts +0 -76
- package/dist/commands-auth.js +0 -421
- package/dist/commands-auth.js.map +0 -1
- package/dist/commands-close.d.ts +0 -6
- package/dist/commands-close.js +0 -273
- package/dist/commands-close.js.map +0 -1
- package/dist/commands-config.d.ts +0 -31
- package/dist/commands-config.js +0 -272
- package/dist/commands-config.js.map +0 -1
- package/dist/commands-core.d.ts +0 -145
- package/dist/commands-core.js +0 -190
- package/dist/commands-core.js.map +0 -1
- package/dist/commands-devices.d.ts +0 -41
- package/dist/commands-devices.js +0 -144
- package/dist/commands-devices.js.map +0 -1
- package/dist/commands-doctor.d.ts +0 -25
- package/dist/commands-doctor.js +0 -1553
- package/dist/commands-doctor.js.map +0 -1
- package/dist/commands-guidance.d.ts +0 -27
- package/dist/commands-guidance.js +0 -150
- package/dist/commands-guidance.js.map +0 -1
- package/dist/commands-harness-context.d.ts +0 -57
- package/dist/commands-harness-context.js +0 -102
- package/dist/commands-harness-context.js.map +0 -1
- package/dist/commands-hook-attend.d.ts +0 -70
- package/dist/commands-hook-attend.js +0 -655
- package/dist/commands-hook-attend.js.map +0 -1
- package/dist/commands-hook-diagnostics.d.ts +0 -37
- package/dist/commands-hook-diagnostics.js +0 -136
- package/dist/commands-hook-diagnostics.js.map +0 -1
- package/dist/commands-hook-install.d.ts +0 -26
- package/dist/commands-hook-install.js +0 -784
- package/dist/commands-hook-install.js.map +0 -1
- package/dist/commands-hook-run.d.ts +0 -29
- package/dist/commands-hook-run.js +0 -933
- package/dist/commands-hook-run.js.map +0 -1
- package/dist/commands-hook-shape.d.ts +0 -19
- package/dist/commands-hook-shape.js +0 -48
- package/dist/commands-hook-shape.js.map +0 -1
- package/dist/commands-init.d.ts +0 -42
- package/dist/commands-init.js +0 -901
- package/dist/commands-init.js.map +0 -1
- package/dist/commands-io.d.ts +0 -2
- package/dist/commands-io.js +0 -117
- package/dist/commands-io.js.map +0 -1
- package/dist/commands-logs.d.ts +0 -30
- package/dist/commands-logs.js +0 -201
- package/dist/commands-logs.js.map +0 -1
- package/dist/commands-native-acknowledge.d.ts +0 -8
- package/dist/commands-native-acknowledge.js +0 -114
- package/dist/commands-native-acknowledge.js.map +0 -1
- package/dist/commands-project.d.ts +0 -4
- package/dist/commands-project.js +0 -41
- package/dist/commands-project.js.map +0 -1
- package/dist/commands-receive.d.ts +0 -3
- package/dist/commands-receive.js +0 -59
- package/dist/commands-receive.js.map +0 -1
- package/dist/commands-send-support.d.ts +0 -59
- package/dist/commands-send-support.js +0 -258
- package/dist/commands-send-support.js.map +0 -1
- package/dist/commands-send.d.ts +0 -34
- package/dist/commands-send.js +0 -900
- package/dist/commands-send.js.map +0 -1
- package/dist/commands-setup-proof.d.ts +0 -43
- package/dist/commands-setup-proof.js +0 -134
- package/dist/commands-setup-proof.js.map +0 -1
- package/dist/commands-skill.d.ts +0 -21
- package/dist/commands-skill.js +0 -199
- package/dist/commands-skill.js.map +0 -1
- package/dist/commands-sounds.d.ts +0 -12
- package/dist/commands-sounds.js +0 -35
- package/dist/commands-sounds.js.map +0 -1
- package/dist/commands-update-check.d.ts +0 -6
- package/dist/commands-update-check.js +0 -102
- package/dist/commands-update-check.js.map +0 -1
- package/dist/commands-update-resume.d.ts +0 -7
- package/dist/commands-update-resume.js +0 -143
- package/dist/commands-update-resume.js.map +0 -1
- package/dist/commands-update-skill.d.ts +0 -5
- package/dist/commands-update-skill.js +0 -36
- package/dist/commands-update-skill.js.map +0 -1
- package/dist/commands-update.d.ts +0 -11
- package/dist/commands-update.js +0 -286
- package/dist/commands-update.js.map +0 -1
- package/dist/commands.d.ts +0 -26
- package/dist/commands.js +0 -27
- package/dist/commands.js.map +0 -1
- package/dist/config-schema.d.ts +0 -62
- package/dist/config-schema.js +0 -302
- package/dist/config-schema.js.map +0 -1
- package/dist/config.d.ts +0 -181
- package/dist/config.js +0 -357
- package/dist/config.js.map +0 -1
- package/dist/credentials.d.ts +0 -96
- package/dist/credentials.js +0 -342
- package/dist/credentials.js.map +0 -1
- package/dist/file-lock.d.ts +0 -34
- package/dist/file-lock.js +0 -367
- package/dist/file-lock.js.map +0 -1
- package/dist/generated-session-label.d.ts +0 -2
- package/dist/generated-session-label.js +0 -152
- package/dist/generated-session-label.js.map +0 -1
- package/dist/guidance-content.d.ts +0 -53
- package/dist/guidance-content.js +0 -248
- package/dist/guidance-content.js.map +0 -1
- package/dist/guidance-render.d.ts +0 -25
- package/dist/guidance-render.js +0 -47
- package/dist/guidance-render.js.map +0 -1
- package/dist/guidance.d.ts +0 -69
- package/dist/guidance.js +0 -173
- package/dist/guidance.js.map +0 -1
- package/dist/harness-session-title.d.ts +0 -22
- package/dist/harness-session-title.js +0 -74
- package/dist/harness-session-title.js.map +0 -1
- package/dist/harnesses.d.ts +0 -65
- package/dist/harnesses.js +0 -159
- package/dist/harnesses.js.map +0 -1
- package/dist/hermes-attendant.d.ts +0 -38
- package/dist/hermes-attendant.js +0 -337
- package/dist/hermes-attendant.js.map +0 -1
- package/dist/hermes-plugin.d.ts +0 -11
- package/dist/hermes-plugin.js +0 -299
- package/dist/hermes-plugin.js.map +0 -1
- package/dist/hook-acknowledgements.d.ts +0 -70
- package/dist/hook-acknowledgements.js +0 -490
- package/dist/hook-acknowledgements.js.map +0 -1
- package/dist/hook-adapter.d.ts +0 -48
- package/dist/hook-adapter.js +0 -419
- package/dist/hook-adapter.js.map +0 -1
- package/dist/hook-events.d.ts +0 -105
- package/dist/hook-events.js +0 -130
- package/dist/hook-events.js.map +0 -1
- package/dist/hook-gates.d.ts +0 -7
- package/dist/hook-gates.js +0 -20
- package/dist/hook-gates.js.map +0 -1
- package/dist/hook-input.d.ts +0 -10
- package/dist/hook-input.js +0 -64
- package/dist/hook-input.js.map +0 -1
- package/dist/hook-lifecycle.d.ts +0 -112
- package/dist/hook-lifecycle.js +0 -2031
- package/dist/hook-lifecycle.js.map +0 -1
- package/dist/hook-project-sessions.d.ts +0 -28
- package/dist/hook-project-sessions.js +0 -141
- package/dist/hook-project-sessions.js.map +0 -1
- package/dist/hook-question-lock.d.ts +0 -29
- package/dist/hook-question-lock.js +0 -217
- package/dist/hook-question-lock.js.map +0 -1
- package/dist/hook-question-retirement.d.ts +0 -74
- package/dist/hook-question-retirement.js +0 -314
- package/dist/hook-question-retirement.js.map +0 -1
- package/dist/hook-question-state.d.ts +0 -58
- package/dist/hook-question-state.js +0 -239
- package/dist/hook-question-state.js.map +0 -1
- package/dist/hook-session-state.d.ts +0 -127
- package/dist/hook-session-state.js +0 -488
- package/dist/hook-session-state.js.map +0 -1
- package/dist/hook-types.d.ts +0 -476
- package/dist/hook-types.js +0 -4
- package/dist/hook-types.js.map +0 -1
- package/dist/injection-render.d.ts +0 -36
- package/dist/injection-render.js +0 -61
- package/dist/injection-render.js.map +0 -1
- package/dist/install-hooks.d.ts +0 -470
- package/dist/install-hooks.js +0 -1622
- package/dist/install-hooks.js.map +0 -1
- package/dist/integration-health.d.ts +0 -26
- package/dist/integration-health.js +0 -133
- package/dist/integration-health.js.map +0 -1
- package/dist/interactive.d.ts +0 -22
- package/dist/interactive.js +0 -644
- package/dist/interactive.js.map +0 -1
- package/dist/invocation-context.d.ts +0 -47
- package/dist/invocation-context.js +0 -164
- package/dist/invocation-context.js.map +0 -1
- package/dist/local-path.d.ts +0 -7
- package/dist/local-path.js +0 -27
- package/dist/local-path.js.map +0 -1
- package/dist/logging.d.ts +0 -157
- package/dist/logging.js +0 -599
- package/dist/logging.js.map +0 -1
- package/dist/main-run.d.ts +0 -1
- package/dist/main-run.js +0 -42
- package/dist/main-run.js.map +0 -1
- package/dist/main.d.ts +0 -2
- package/dist/main.js +0 -12
- package/dist/main.js.map +0 -1
- package/dist/native-answer-operation.d.ts +0 -70
- package/dist/native-answer-operation.js +0 -247
- package/dist/native-answer-operation.js.map +0 -1
- package/dist/native-skills.d.ts +0 -64
- package/dist/native-skills.js +0 -167
- package/dist/native-skills.js.map +0 -1
- package/dist/node-floor.d.ts +0 -19
- package/dist/node-floor.js +0 -32
- package/dist/node-floor.js.map +0 -1
- package/dist/npm-invocation.d.ts +0 -34
- package/dist/npm-invocation.js +0 -53
- package/dist/npm-invocation.js.map +0 -1
- package/dist/openclaw-continuation-bridge.d.ts +0 -7
- package/dist/openclaw-continuation-bridge.js +0 -64
- package/dist/openclaw-continuation-bridge.js.map +0 -1
- package/dist/openclaw-gateway-readiness.d.ts +0 -1
- package/dist/openclaw-gateway-readiness.js +0 -24
- package/dist/openclaw-gateway-readiness.js.map +0 -1
- package/dist/openclaw-generation.d.ts +0 -21
- package/dist/openclaw-generation.js +0 -115
- package/dist/openclaw-generation.js.map +0 -1
- package/dist/openclaw-message-bridge.d.ts +0 -8
- package/dist/openclaw-message-bridge.js +0 -93
- package/dist/openclaw-message-bridge.js.map +0 -1
- package/dist/openclaw-pending.d.ts +0 -8
- package/dist/openclaw-pending.js +0 -47
- package/dist/openclaw-pending.js.map +0 -1
- package/dist/openclaw-plugin.d.ts +0 -75
- package/dist/openclaw-plugin.js +0 -1353
- package/dist/openclaw-plugin.js.map +0 -1
- package/dist/openclaw-session-access.d.ts +0 -12
- package/dist/openclaw-session-access.js +0 -79
- package/dist/openclaw-session-access.js.map +0 -1
- package/dist/opencode-plugin.d.ts +0 -88
- package/dist/opencode-plugin.js +0 -286
- package/dist/opencode-plugin.js.map +0 -1
- package/dist/orca-session-title.d.ts +0 -11
- package/dist/orca-session-title.js +0 -111
- package/dist/orca-session-title.js.map +0 -1
- package/dist/pack-feedback-slice.d.ts +0 -35
- package/dist/pack-feedback-slice.js +0 -126
- package/dist/pack-feedback-slice.js.map +0 -1
- package/dist/pairing-qr.d.ts +0 -5
- package/dist/pairing-qr.js +0 -24
- package/dist/pairing-qr.js.map +0 -1
- package/dist/pending-pairing.d.ts +0 -38
- package/dist/pending-pairing.js +0 -70
- package/dist/pending-pairing.js.map +0 -1
- package/dist/platform.d.ts +0 -67
- package/dist/platform.js +0 -140
- package/dist/platform.js.map +0 -1
- package/dist/process-identity.d.ts +0 -26
- package/dist/process-identity.js +0 -82
- package/dist/process-identity.js.map +0 -1
- package/dist/program.d.ts +0 -59
- package/dist/program.js +0 -691
- package/dist/program.js.map +0 -1
- package/dist/project-enablement.d.ts +0 -21
- package/dist/project-enablement.js +0 -71
- package/dist/project-enablement.js.map +0 -1
- package/dist/question-settlement-process.d.ts +0 -17
- package/dist/question-settlement-process.js +0 -35
- package/dist/question-settlement-process.js.map +0 -1
- package/dist/question-timing.d.ts +0 -46
- package/dist/question-timing.js +0 -48
- package/dist/question-timing.js.map +0 -1
- package/dist/readiness.d.ts +0 -153
- package/dist/readiness.js +0 -149
- package/dist/readiness.js.map +0 -1
- package/dist/release.d.ts +0 -31
- package/dist/release.js +0 -52
- package/dist/release.js.map +0 -1
- package/dist/send-attempts.d.ts +0 -26
- package/dist/send-attempts.js +0 -139
- package/dist/send-attempts.js.map +0 -1
- package/dist/send.d.ts +0 -115
- package/dist/send.js +0 -421
- package/dist/send.js.map +0 -1
- package/dist/session-activation.d.ts +0 -13
- package/dist/session-activation.js +0 -64
- package/dist/session-activation.js.map +0 -1
- package/dist/session-attendant-probe.d.ts +0 -85
- package/dist/session-attendant-probe.js +0 -173
- package/dist/session-attendant-probe.js.map +0 -1
- package/dist/session-attendant-state.d.ts +0 -70
- package/dist/session-attendant-state.js +0 -256
- package/dist/session-attendant-state.js.map +0 -1
- package/dist/session-attendant.d.ts +0 -143
- package/dist/session-attendant.js +0 -520
- package/dist/session-attendant.js.map +0 -1
- package/dist/session-delivery.d.ts +0 -249
- package/dist/session-delivery.js +0 -576
- package/dist/session-delivery.js.map +0 -1
- package/dist/session-handoff.d.ts +0 -98
- package/dist/session-handoff.js +0 -118
- package/dist/session-handoff.js.map +0 -1
- package/dist/session-input-wakes.d.ts +0 -78
- package/dist/session-input-wakes.js +0 -239
- package/dist/session-input-wakes.js.map +0 -1
- package/dist/session-inputs.d.ts +0 -57
- package/dist/session-inputs.js +0 -434
- package/dist/session-inputs.js.map +0 -1
- package/dist/session-labels.d.ts +0 -56
- package/dist/session-labels.js +0 -396
- package/dist/session-labels.js.map +0 -1
- package/dist/session-message-handoff.d.ts +0 -39
- package/dist/session-message-handoff.js +0 -107
- package/dist/session-message-handoff.js.map +0 -1
- package/dist/setup-destinations.d.ts +0 -23
- package/dist/setup-destinations.js +0 -44
- package/dist/setup-destinations.js.map +0 -1
- package/dist/skill-integrity.d.ts +0 -54
- package/dist/skill-integrity.js +0 -164
- package/dist/skill-integrity.js.map +0 -1
- package/dist/skill-source/manifest.json +0 -40
- package/dist/skill-source/notifai/SKILL.md +0 -350
- package/dist/skill-source/notifai/references/diagnostics.md +0 -36
- package/dist/skill-source/notifai/references/harness-setup.md +0 -384
- package/dist/skill-source/notifai/references/native-questions.md +0 -51
- package/dist/skill-source/notifai/references/notes-and-edits.md +0 -54
- package/dist/skill-source/notifai/references/send-details.md +0 -14
- package/dist/skill-source/notifai/references/updates.md +0 -116
- package/dist/skill-source/notifai/references/writing-examples.md +0 -56
- package/dist/sound-ref.d.ts +0 -23
- package/dist/sound-ref.js +0 -28
- package/dist/sound-ref.js.map +0 -1
- package/dist/ui/banner.d.ts +0 -22
- package/dist/ui/banner.js +0 -87
- package/dist/ui/banner.js.map +0 -1
- package/dist/ui/config-view.d.ts +0 -30
- package/dist/ui/config-view.js +0 -108
- package/dist/ui/config-view.js.map +0 -1
- package/dist/ui/help.d.ts +0 -44
- package/dist/ui/help.js +0 -107
- package/dist/ui/help.js.map +0 -1
- package/dist/ui/theme.d.ts +0 -90
- package/dist/ui/theme.js +0 -244
- package/dist/ui/theme.js.map +0 -1
- package/dist/update-handoff.d.ts +0 -29
- package/dist/update-handoff.js +0 -69
- package/dist/update-handoff.js.map +0 -1
- package/dist/url-policy.d.ts +0 -85
- package/dist/url-policy.js +0 -369
- package/dist/url-policy.js.map +0 -1
- package/dist/version.d.ts +0 -40
- package/dist/version.js +0 -132
- package/dist/version.js.map +0 -1
- package/dist/wake-support.d.ts +0 -40
- package/dist/wake-support.js +0 -95
- package/dist/wake-support.js.map +0 -1
package/dist/install-hooks.js
DELETED
|
@@ -1,1622 +0,0 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, } from 'node:fs';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
5
|
-
import { atomicWriteFileSync } from './atomic-file.js';
|
|
6
|
-
import { withTargetFileLock } from './file-lock.js';
|
|
7
|
-
import { hookAdapterPath, hookHostPlatform, inspectHookAdapter, } from './hook-adapter.js';
|
|
8
|
-
import { OPENCODE_PLUGIN_FILENAME, opencodeConfigDir, opencodePluginPath, opencodePluginTarget, } from './opencode-plugin.js';
|
|
9
|
-
import { legacyOpenclawProjectPluginPath, openclawHasGlobalEvidence, openclawPluginPath, openclawPluginTarget, } from './openclaw-plugin.js';
|
|
10
|
-
import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js';
|
|
11
|
-
import { hermesPluginCurrent, hermesPluginDir, isOurHermesPlugin } from './hermes-plugin.js';
|
|
12
|
-
import { attendDocumentEvents, HOOK_EVENT_COMMAND_RE, HOOK_EVENT_PATTERN, HOOK_EVENT_TABLE, OPENCLAW_EVENTS, OPENCODE_EVENTS, installsSessionAttendant, } from './hook-events.js';
|
|
13
|
-
import { accountHome } from './platform.js';
|
|
14
|
-
import { sameLocalPath } from './local-path.js';
|
|
15
|
-
import { NON_ROUTING_STOP_TIMEOUT_SECONDS, QUESTION_STOP_TIMEOUT_SECONDS, } from './question-timing.js';
|
|
16
|
-
/**
|
|
17
|
-
* The command each hook runs. Harness definitions know only the stable
|
|
18
|
-
* user-level adapter. Mutable Node, package-manager, version, and checkout
|
|
19
|
-
* paths live behind that seam and never enter a trusted hook identity —
|
|
20
|
-
* except on Windows, where CreateProcess cannot run the adapter without an
|
|
21
|
-
* explicit Node executable, so the registered interpreter is named first.
|
|
22
|
-
*/
|
|
23
|
-
export function hookCommand(adapterPath, event, harness, options = {}) {
|
|
24
|
-
return (`${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
|
|
25
|
-
(harness === undefined ? '' : ` --harness ${harness}`));
|
|
26
|
-
}
|
|
27
|
-
/** The leading argv that every Notifai handler must start with. */
|
|
28
|
-
export function hookCommandPrefix(adapterPath, options = {}) {
|
|
29
|
-
const host = hookHostPlatform(options.platform);
|
|
30
|
-
if (host === 'win32') {
|
|
31
|
-
const nodePath = options.nodePath ?? process.execPath;
|
|
32
|
-
return `${quoteWindowsArg(nodePath)} ${quoteWindowsArg(adapterPath)} `;
|
|
33
|
-
}
|
|
34
|
-
return `${quote(adapterPath)} `;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Says "Notifai installed this" without saying which checkout did.
|
|
38
|
-
*
|
|
39
|
-
* Ownership used to be matched on the absolute script path, so installing from
|
|
40
|
-
* a second checkout did not recognise the first one's handlers as ours. Both
|
|
41
|
-
* stayed, the harness ran both, and one question produced two notifications;
|
|
42
|
-
* uninstalling the second silently left the first running. A marker that every
|
|
43
|
-
* Notifai build writes and no build's path appears in fixes that by making
|
|
44
|
-
* ownership a property of the handler rather than of the machine it was
|
|
45
|
-
* installed from.
|
|
46
|
-
*
|
|
47
|
-
* It is a real (ignored) CLI flag rather than a comment because not every
|
|
48
|
-
* harness runs the command through a shell.
|
|
49
|
-
*/
|
|
50
|
-
export const OWNER_MARKER = '--owner notifai';
|
|
51
|
-
/**
|
|
52
|
-
* POSIX single-quoting. Double quotes were wrong: they still expand `$(...)`
|
|
53
|
-
* and backticks, so a checkout path containing shell syntax became command
|
|
54
|
-
* execution on every hook event. Single quotes suppress all expansion; the
|
|
55
|
-
* only escape needed is for a literal single quote.
|
|
56
|
-
*/
|
|
57
|
-
function quote(value) {
|
|
58
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
|
|
62
|
-
* it as a single argument. Always used for the Node and adapter paths, which
|
|
63
|
-
* routinely contain spaces (`Program Files`) and must not use POSIX quotes
|
|
64
|
-
* inside JSON or TOML harness documents.
|
|
65
|
-
*/
|
|
66
|
-
export function quoteWindowsArg(value) {
|
|
67
|
-
let out = '"';
|
|
68
|
-
let slashes = 0;
|
|
69
|
-
for (const ch of value) {
|
|
70
|
-
if (ch === '\\') {
|
|
71
|
-
slashes += 1;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (ch === '"') {
|
|
75
|
-
out += '\\'.repeat(slashes * 2 + 1) + '"';
|
|
76
|
-
slashes = 0;
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
out += '\\'.repeat(slashes) + ch;
|
|
80
|
-
slashes = 0;
|
|
81
|
-
}
|
|
82
|
-
return `${out}${'\\'.repeat(slashes * 2)}"`;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Harnesses without asynchronous Question Routing use this short Stop budget.
|
|
86
|
-
* Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
|
|
87
|
-
*/
|
|
88
|
-
export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS;
|
|
89
|
-
export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js';
|
|
90
|
-
/**
|
|
91
|
-
* Codex's Session Attendant lifetime. Codex kills an async hook at its
|
|
92
|
-
* declared timeout, so the attendant declares the same complete window a
|
|
93
|
-
* question owner does; the next prompt or turn end starts another.
|
|
94
|
-
*/
|
|
95
|
-
export const CODEX_ATTEND_TIMEOUT_SECONDS = QUESTION_STOP_TIMEOUT_SECONDS;
|
|
96
|
-
/** Codex clamps an Interrupt hook to three seconds; declaring it keeps the definition honest. */
|
|
97
|
-
export const CODEX_INTERRUPT_TIMEOUT_SECONDS = 3;
|
|
98
|
-
/**
|
|
99
|
-
* Whether this harness's Stop handler runs detached from the turn.
|
|
100
|
-
*
|
|
101
|
-
* The installer and doctor share this predicate so they agree which harnesses
|
|
102
|
-
* run out of band. Owner lifetime is route-neutral and lives in
|
|
103
|
-
* `question-timing.ts`.
|
|
104
|
-
*
|
|
105
|
-
* Codex is detached on every platform because its delivery route is a write to
|
|
106
|
-
* the thread's own durable inbox rather than this hook's stdout: there is no
|
|
107
|
-
* continuation to keep a turn open for, and the queue has no platform
|
|
108
|
-
* dependency to gate on. Claude Code still needs its turn held on Windows,
|
|
109
|
-
* where no inbox socket exists.
|
|
110
|
-
*/
|
|
111
|
-
export function stopHandlerIsDetached(harness, platform) {
|
|
112
|
-
if (harness === 'codex')
|
|
113
|
-
return true;
|
|
114
|
-
return harness === 'claude-code' && hookHostPlatform(platform) === 'posix';
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* The turn-end handler, whose shape is the whole per-harness difference.
|
|
118
|
-
*
|
|
119
|
-
* Claude Code takes the answer over its own inbox socket and Codex takes it
|
|
120
|
-
* through its thread's durable inbox, so both Stop hooks are `async: true`:
|
|
121
|
-
* they return immediately, the terminal is never held, and the waiter finishes
|
|
122
|
-
* out of band. Only Claude on Windows still blocks and prints a continuation to
|
|
123
|
-
* stdout, because no inbox socket exists there. Every Question Routing owner
|
|
124
|
-
* declares the same complete-window timeout, because the detached waiter must
|
|
125
|
-
* outlive the answer window in every case; what changed for Codex is that a
|
|
126
|
-
* short or missing timeout no longer truncates a held *turn*. Blocking hosts
|
|
127
|
-
* also set `statusMessage` so the held turn is not mistaken for a hang.
|
|
128
|
-
*/
|
|
129
|
-
export const BLOCKING_STOP_STATUS_MESSAGE = 'Notifai: waiting for your answer';
|
|
130
|
-
function stopHandler(adapterPath, harness, options) {
|
|
131
|
-
const command = hookCommand(adapterPath, 'stop', harness, options);
|
|
132
|
-
if (stopHandlerIsDetached(harness, options.platform)) {
|
|
133
|
-
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true };
|
|
134
|
-
}
|
|
135
|
-
if (harness === 'grok') {
|
|
136
|
-
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS };
|
|
137
|
-
}
|
|
138
|
-
if (harness === 'codex' || harness === 'claude-code') {
|
|
139
|
-
return {
|
|
140
|
-
type: 'command',
|
|
141
|
-
command,
|
|
142
|
-
timeout: QUESTION_STOP_TIMEOUT_SECONDS,
|
|
143
|
-
statusMessage: BLOCKING_STOP_STATUS_MESSAGE,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS };
|
|
147
|
-
}
|
|
148
|
-
function commandOptionsFrom(options) {
|
|
149
|
-
return {
|
|
150
|
-
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
151
|
-
...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
export function buildHookConfig(options) {
|
|
155
|
-
const { adapterPath } = options;
|
|
156
|
-
const commandOptions = commandOptionsFrom(options);
|
|
157
|
-
const hooks = Object.create(null);
|
|
158
|
-
for (const row of HOOK_EVENT_TABLE) {
|
|
159
|
-
if (row.document === null)
|
|
160
|
-
continue;
|
|
161
|
-
if (row.notifai === 'post-tool-use' &&
|
|
162
|
-
(options.harness !== 'codex' || !installsSessionAttendant(options.harness, options.platform)))
|
|
163
|
-
continue;
|
|
164
|
-
if (row.notifai === 'stop') {
|
|
165
|
-
hooks[row.document] = [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }];
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
hooks[row.document] = [
|
|
169
|
-
{
|
|
170
|
-
hooks: [
|
|
171
|
-
{
|
|
172
|
-
type: 'command',
|
|
173
|
-
command: hookCommand(adapterPath, row.notifai, options.harness, commandOptions),
|
|
174
|
-
timeout: row.timeoutSeconds,
|
|
175
|
-
},
|
|
176
|
-
],
|
|
177
|
-
},
|
|
178
|
-
];
|
|
179
|
-
}
|
|
180
|
-
if (installsSessionAttendant(options.harness, options.platform)) {
|
|
181
|
-
// A second handler in the same group, never folded into the first: the
|
|
182
|
-
// harness runs the activation handler's context back promptly and does
|
|
183
|
-
// not wait for an async handler. Claude Code does not time out a
|
|
184
|
-
// backgrounded hook, so its attendant declares none and ends itself with
|
|
185
|
-
// its session. Codex enforces every async timeout, and its 600-second
|
|
186
|
-
// default would kill the attendant: it declares the complete answer window
|
|
187
|
-
// and a later prompt or turn end re-arms it after that. Codex caps
|
|
188
|
-
// Interrupt at three seconds; that copy only records the turn's end.
|
|
189
|
-
for (const event of attendDocumentEvents(options.harness)) {
|
|
190
|
-
const attend = {
|
|
191
|
-
type: 'command',
|
|
192
|
-
command: hookCommand(adapterPath, 'attend', options.harness, commandOptions),
|
|
193
|
-
async: true,
|
|
194
|
-
...(options.harness !== 'codex'
|
|
195
|
-
? {}
|
|
196
|
-
: { timeout: event === 'Interrupt' ? CODEX_INTERRUPT_TIMEOUT_SECONDS : CODEX_ATTEND_TIMEOUT_SECONDS }),
|
|
197
|
-
};
|
|
198
|
-
const group = hooks[event]?.[0];
|
|
199
|
-
if (group === undefined)
|
|
200
|
-
hooks[event] = [{ hooks: [attend] }];
|
|
201
|
-
else
|
|
202
|
-
group.hooks.push(attend);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return hooks;
|
|
206
|
-
}
|
|
207
|
-
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
208
|
-
export function buildCursorHookConfig(options) {
|
|
209
|
-
const commandOptions = commandOptionsFrom(options);
|
|
210
|
-
const hooks = Object.create(null);
|
|
211
|
-
for (const row of HOOK_EVENT_TABLE) {
|
|
212
|
-
if (row.cursor === null)
|
|
213
|
-
continue;
|
|
214
|
-
const handler = {
|
|
215
|
-
command: hookCommand(options.adapterPath, row.notifai, 'cursor', commandOptions),
|
|
216
|
-
timeout: row.notifai === 'stop' ? NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS : row.timeoutSeconds,
|
|
217
|
-
};
|
|
218
|
-
if ('cursorLoopLimit' in row)
|
|
219
|
-
handler.loop_limit = row.cursorLoopLimit;
|
|
220
|
-
hooks[row.cursor] = [...(hooks[row.cursor] ?? []), handler];
|
|
221
|
-
}
|
|
222
|
-
return hooks;
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* The one file each harness reads Notifai's lifecycle wiring from: the current
|
|
226
|
-
* User's account, in that harness's active home.
|
|
227
|
-
*/
|
|
228
|
-
export function settingsFile(harness, env = process.env, platform = process.platform) {
|
|
229
|
-
switch (harness) {
|
|
230
|
-
// OpenCode has no settings document to merge into — its adapter is a
|
|
231
|
-
// generated plugin module, so it owns a whole file instead.
|
|
232
|
-
case 'opencode':
|
|
233
|
-
return opencodePluginPath(env, platform);
|
|
234
|
-
case 'openclaw':
|
|
235
|
-
return openclawPluginPath(env, platform);
|
|
236
|
-
case 'hermes':
|
|
237
|
-
return path.join(hermesPluginDir(env), '__init__.py');
|
|
238
|
-
case 'cursor':
|
|
239
|
-
return path.join(harnessAccountHome(env, platform), '.cursor', 'hooks.json');
|
|
240
|
-
case 'claude-code':
|
|
241
|
-
return path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json');
|
|
242
|
-
case 'grok':
|
|
243
|
-
return path.join(configHome(env, 'GROK_HOME', '.grok', platform), 'hooks', 'notifai.json');
|
|
244
|
-
case 'codex':
|
|
245
|
-
return inspectCodexLayer(codexMachineLayerPaths(env, platform)).writeTarget;
|
|
246
|
-
default:
|
|
247
|
-
return assertNeverHarness(harness);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Files an older build may have left inside this Project, which Notifai now
|
|
252
|
-
* only ever reads in order to remove.
|
|
253
|
-
*
|
|
254
|
-
* Nothing installs here any more. Every path is still enumerated because a
|
|
255
|
-
* leftover Project-scoped handler keeps firing beside the Machine one, and
|
|
256
|
-
* silently leaving it would be exactly the duplicate-routing defect dropping
|
|
257
|
-
* the second scope was meant to end.
|
|
258
|
-
*/
|
|
259
|
-
export function legacyProjectHookFiles(harness, cwd) {
|
|
260
|
-
switch (harness) {
|
|
261
|
-
case 'opencode':
|
|
262
|
-
return [path.join(cwd, '.opencode', 'plugins', OPENCODE_PLUGIN_FILENAME)];
|
|
263
|
-
case 'openclaw':
|
|
264
|
-
return [legacyOpenclawProjectPluginPath(cwd)];
|
|
265
|
-
case 'cursor':
|
|
266
|
-
return [path.join(cwd, '.cursor', 'hooks.json')];
|
|
267
|
-
case 'claude-code':
|
|
268
|
-
return [path.join(cwd, '.claude', 'settings.local.json')];
|
|
269
|
-
case 'grok':
|
|
270
|
-
return [];
|
|
271
|
-
case 'hermes':
|
|
272
|
-
return [];
|
|
273
|
-
case 'codex':
|
|
274
|
-
return codexLegacyProjectLayers(cwd).flatMap((paths) => [paths.hooksJson, paths.configToml]);
|
|
275
|
-
default:
|
|
276
|
-
return assertNeverHarness(harness);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* The account home harness config roots are resolved against.
|
|
281
|
-
*
|
|
282
|
-
* Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
|
|
283
|
-
* Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
|
|
284
|
-
*/
|
|
285
|
-
export function harnessAccountHome(env = process.env, platform = process.platform) {
|
|
286
|
-
return accountHome(env, hookHostPlatform(platform) === 'win32' ? 'win32' : 'linux');
|
|
287
|
-
}
|
|
288
|
-
function assertNeverHarness(harness) {
|
|
289
|
-
throw new Error(`Unsupported harness: ${String(harness)}`);
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Where Codex looks for a project's hooks — the **main** repository, not the
|
|
293
|
-
* working directory.
|
|
294
|
-
*
|
|
295
|
-
* Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
|
|
296
|
-
* (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
|
|
297
|
-
* `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
|
|
298
|
-
* fired and an identical one at the worktree root did not; removing the main
|
|
299
|
-
* one left nothing firing at all. Writing to cwd therefore produces a silent
|
|
300
|
-
* no-op for anyone whose agent runs in a worktree, which is the normal case
|
|
301
|
-
* under tooling like Orca.
|
|
302
|
-
*
|
|
303
|
-
* Claude Code does not share this behaviour — it reads the worktree's own
|
|
304
|
-
* `.claude/settings.local.json` — so this deliberately applies to Codex only.
|
|
305
|
-
*/
|
|
306
|
-
export function codexProjectRoot(cwd) {
|
|
307
|
-
const entry = findGitEntry(cwd);
|
|
308
|
-
if (entry === null)
|
|
309
|
-
return cwd;
|
|
310
|
-
try {
|
|
311
|
-
if (statSync(entry).isDirectory())
|
|
312
|
-
return path.dirname(entry);
|
|
313
|
-
return linkedWorktreeMainRoot(entry) ?? cwd;
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
// An unreadable or exotic checkout is not worth failing an install over;
|
|
317
|
-
// cwd is what we used to do and is right for every non-worktree case.
|
|
318
|
-
return cwd;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
/** The nearest `.git` at or above `from`, or null outside a repository. */
|
|
322
|
-
function findGitEntry(from) {
|
|
323
|
-
let dir = path.resolve(from);
|
|
324
|
-
for (;;) {
|
|
325
|
-
const entry = path.join(dir, '.git');
|
|
326
|
-
if (existsSync(entry))
|
|
327
|
-
return entry;
|
|
328
|
-
const parent = path.dirname(dir);
|
|
329
|
-
if (parent === dir)
|
|
330
|
-
return null;
|
|
331
|
-
dir = parent;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
/**
|
|
335
|
-
* Resolve the main checkout only from Git's reciprocal linked-worktree
|
|
336
|
-
* metadata. A repository can contain an arbitrary `.git` file, so its one-way
|
|
337
|
-
* `gitdir`/`commondir` claims are not authority to write outside the Project.
|
|
338
|
-
*/
|
|
339
|
-
function linkedWorktreeMainRoot(gitFile) {
|
|
340
|
-
if (!isOwnedRegularFile(gitFile))
|
|
341
|
-
return null;
|
|
342
|
-
const gitdirClaim = readPathMarker(gitFile, /^gitdir:[ \t]+([^\r\n]+)\r?\n?$/);
|
|
343
|
-
if (gitdirClaim === null)
|
|
344
|
-
return null;
|
|
345
|
-
const gitDir = path.resolve(path.dirname(gitFile), gitdirClaim);
|
|
346
|
-
if (!isOwnedDirectory(gitDir))
|
|
347
|
-
return null;
|
|
348
|
-
const commonClaim = readPathMarker(path.join(gitDir, 'commondir'), /^([^\r\n]+)\r?\n?$/);
|
|
349
|
-
const backlinkClaim = readPathMarker(path.join(gitDir, 'gitdir'), /^([^\r\n]+)\r?\n?$/);
|
|
350
|
-
if (commonClaim === null || backlinkClaim === null)
|
|
351
|
-
return null;
|
|
352
|
-
const commonDir = path.resolve(gitDir, commonClaim);
|
|
353
|
-
const backlink = path.resolve(gitDir, backlinkClaim);
|
|
354
|
-
const worktreesDir = path.join(commonDir, 'worktrees');
|
|
355
|
-
if (path.basename(commonDir).toLowerCase() !== '.git' ||
|
|
356
|
-
!isOwnedDirectory(commonDir) ||
|
|
357
|
-
!isOwnedDirectory(worktreesDir) ||
|
|
358
|
-
!sameLocalPath(path.dirname(realpathSync(gitDir)), realpathSync(worktreesDir)) ||
|
|
359
|
-
!sameLocalPath(backlink, gitFile)) {
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
const mainRoot = path.dirname(commonDir);
|
|
363
|
-
return isOwnedDirectory(mainRoot) ? mainRoot : null;
|
|
364
|
-
}
|
|
365
|
-
function readPathMarker(file, pattern) {
|
|
366
|
-
if (!isOwnedRegularFile(file))
|
|
367
|
-
return null;
|
|
368
|
-
const match = pattern.exec(readFileSync(file, 'utf8'));
|
|
369
|
-
const claim = match?.[1]?.trim();
|
|
370
|
-
return claim === undefined || claim === '' ? null : claim;
|
|
371
|
-
}
|
|
372
|
-
function isOwnedRegularFile(file) {
|
|
373
|
-
return isOwnedEntry(file, 'file');
|
|
374
|
-
}
|
|
375
|
-
function isOwnedDirectory(file) {
|
|
376
|
-
return isOwnedEntry(file, 'directory');
|
|
377
|
-
}
|
|
378
|
-
function isOwnedEntry(file, kind) {
|
|
379
|
-
const metadata = lstatSync(file);
|
|
380
|
-
if (metadata.isSymbolicLink())
|
|
381
|
-
return false;
|
|
382
|
-
if (kind === 'file' ? !metadata.isFile() : !metadata.isDirectory())
|
|
383
|
-
return false;
|
|
384
|
-
const uid = process.getuid?.();
|
|
385
|
-
return uid === undefined || metadata.uid === uid;
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* Harness-specific home variables relocate the whole active harness home.
|
|
389
|
-
* This matters under session managers that give each Codex account its own
|
|
390
|
-
* `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
|
|
391
|
-
* different Codex installation than the one running the command.
|
|
392
|
-
*/
|
|
393
|
-
export function configHome(env, variable, fallback, platform = process.platform) {
|
|
394
|
-
const override = env[variable];
|
|
395
|
-
if (override !== undefined && override !== '')
|
|
396
|
-
return override;
|
|
397
|
-
const home = harnessAccountHome(env, platform);
|
|
398
|
-
return path.join(home, fallback);
|
|
399
|
-
}
|
|
400
|
-
/**
|
|
401
|
-
* User-global Codex config directory for the *running* Codex installation.
|
|
402
|
-
*
|
|
403
|
-
* `CODEX_HOME` replaces the home; it does not shadow it. `codex doctor` with
|
|
404
|
-
* the variable set reports that directory for every path it resolves and never
|
|
405
|
-
* consults `~/.codex` at all. So a session manager that points Codex at its own
|
|
406
|
-
* home makes `~/.codex` inert for those sessions, and installing there would
|
|
407
|
-
* write hooks the running agent never reads while reporting success.
|
|
408
|
-
*
|
|
409
|
-
* Following the variable is therefore correct even though it means install and
|
|
410
|
-
* doctor resolve different homes when run from different shells. Doctor names
|
|
411
|
-
* the home it inspected for exactly that reason — see `codexHomeNote`.
|
|
412
|
-
*/
|
|
413
|
-
export function codexGlobalDir(env = process.env, platform = process.platform) {
|
|
414
|
-
return configHome(env, 'CODEX_HOME', '.codex', platform);
|
|
415
|
-
}
|
|
416
|
-
function codexLayerPathsIn(dir) {
|
|
417
|
-
return {
|
|
418
|
-
dir,
|
|
419
|
-
hooksJson: path.join(dir, 'hooks.json'),
|
|
420
|
-
configToml: path.join(dir, 'config.toml'),
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
/** The two files Codex will look at in the Machine layer Notifai installs into. */
|
|
424
|
-
export function codexMachineLayerPaths(env = process.env, platform = process.platform) {
|
|
425
|
-
return codexLayerPathsIn(codexGlobalDir(env, platform));
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Project layers an older build may have written into, for removal only.
|
|
429
|
-
*
|
|
430
|
-
* Codex resolves a project hook file against the main repository, so a linked
|
|
431
|
-
* worktree has two candidate directories: the main checkout's `.codex`, which
|
|
432
|
-
* Codex reads, and the worktree's own, which older builds sometimes created and
|
|
433
|
-
* Codex never reads. Both are legacy residue and both are enumerated here.
|
|
434
|
-
*/
|
|
435
|
-
export function codexLegacyProjectLayers(cwd) {
|
|
436
|
-
const dirs = [path.join(codexProjectRoot(cwd), '.codex')];
|
|
437
|
-
const entry = findGitEntry(cwd);
|
|
438
|
-
if (entry !== null) {
|
|
439
|
-
const worktreeLayer = path.join(path.dirname(entry), '.codex');
|
|
440
|
-
if (!dirs.some((dir) => path.resolve(dir) === path.resolve(worktreeLayer))) {
|
|
441
|
-
dirs.push(worktreeLayer);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return dirs.map(codexLayerPathsIn);
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* Where Notifai writes in this Codex layer.
|
|
448
|
-
*
|
|
449
|
-
* `config.toml` is the User's whole Codex configuration and Codex rewrites it
|
|
450
|
-
* itself for `[hooks.state]`; keeping Notifai out of it bounds the blast radius
|
|
451
|
-
* to hooks, removes a second writer racing those trust records, and replaces
|
|
452
|
-
* TOML splicing with the same JSON merge Claude Code uses. Joining an inline
|
|
453
|
-
* layer that already has the User's handlers is the one exception, and it
|
|
454
|
-
* exists only so Codex never prints its dual-representation warning.
|
|
455
|
-
*
|
|
456
|
-
* A fresh installation uses `hooks.json` unless foreign inline handlers make
|
|
457
|
-
* `config.toml` the layer's established representation. Notifai-owned inline
|
|
458
|
-
* handlers with no foreign inline neighbours are moved to `hooks.json`. Codex
|
|
459
|
-
* keys approval by source path, so that move is a User-owned `/hooks` review,
|
|
460
|
-
* not a silent identity-preserving refresh. `[hooks.state]` is the trust store
|
|
461
|
-
* rather than a hook definition, so it never counts as a representation and is
|
|
462
|
-
* preserved through every write.
|
|
463
|
-
*/
|
|
464
|
-
export function inspectCodexLayer(paths) {
|
|
465
|
-
const jsonDocument = tryLoadSettings(paths.hooksJson);
|
|
466
|
-
const tomlDocument = tryLoadSettings(paths.configToml);
|
|
467
|
-
const ourJsonEvents = ourHandlerEvents(jsonDocument);
|
|
468
|
-
const ourTomlEvents = ourHandlerEvents(tomlDocument);
|
|
469
|
-
const foreignTomlHooks = tomlDocument !== null &&
|
|
470
|
-
locateAllHandlers(tomlDocument).some((handler) => !isNotifaiCommand(handler.command));
|
|
471
|
-
return {
|
|
472
|
-
paths,
|
|
473
|
-
jsonEvents: hookEventNames(jsonDocument?.hooks),
|
|
474
|
-
tomlEvents: hookEventNames(tomlDocument?.hooks),
|
|
475
|
-
ourJsonEvents,
|
|
476
|
-
ourTomlEvents,
|
|
477
|
-
foreignTomlHooks,
|
|
478
|
-
writeTarget: foreignTomlHooks && !(ourJsonEvents.length > 0 && ourTomlEvents.length === 0)
|
|
479
|
-
? paths.configToml
|
|
480
|
-
: paths.hooksJson,
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* Serialize one Notifai transaction across both representations in a Codex
|
|
485
|
-
* layer, then inspect that layer only after acquiring the shared anchor.
|
|
486
|
-
*/
|
|
487
|
-
export function withCodexLayerTransaction(paths, action) {
|
|
488
|
-
return withTargetFileLock(paths.configToml, () => action(inspectCodexLayer(paths)));
|
|
489
|
-
}
|
|
490
|
-
/** Every Machine-layer file this harness might already be reading hooks from. */
|
|
491
|
-
export function machineHookFiles(harness, env = process.env, platform = process.platform) {
|
|
492
|
-
if (harness === 'grok') {
|
|
493
|
-
const target = settingsFile('grok', env, platform);
|
|
494
|
-
const directory = path.dirname(target);
|
|
495
|
-
let names;
|
|
496
|
-
try {
|
|
497
|
-
names = readdirSync(directory).filter((name) => name.endsWith('.json')).sort();
|
|
498
|
-
}
|
|
499
|
-
catch {
|
|
500
|
-
return [target];
|
|
501
|
-
}
|
|
502
|
-
return [target, ...names.map((name) => path.join(directory, name)).filter((file) => file !== target)];
|
|
503
|
-
}
|
|
504
|
-
if (harness !== 'codex')
|
|
505
|
-
return [settingsFile(harness, env, platform)];
|
|
506
|
-
const paths = codexMachineLayerPaths(env, platform);
|
|
507
|
-
return [paths.hooksJson, paths.configToml];
|
|
508
|
-
}
|
|
509
|
-
/**
|
|
510
|
-
* The Machine layer with *Notifai* installed in both Codex representations.
|
|
511
|
-
*
|
|
512
|
-
* A layer that uses `hooks.json` and inline `[hooks]` at once is not by itself
|
|
513
|
-
* a Notifai fault: Codex supports both, loads both, runs every matching
|
|
514
|
-
* handler, and already prints its own "prefer a single representation" warning
|
|
515
|
-
* at startup. Foreign handlers in the other file are the user's — often written
|
|
516
|
-
* by another tool that manages that file — and repeating Codex's warning as a
|
|
517
|
-
* Notifai failure demanded an edit to configuration Notifai does not own, for a
|
|
518
|
-
* condition where every handler still fires exactly once.
|
|
519
|
-
*
|
|
520
|
-
* What is ours to diagnose is Notifai in both files. Then one event really does
|
|
521
|
-
* send two Notification Requests per turn, and the copy outside the write
|
|
522
|
-
* target keeps firing an older definition after the next install refreshes only
|
|
523
|
-
* the other one. Uninstall strips Notifai from both files, so the remedy is
|
|
524
|
-
* exact — and it never touches a foreign handler.
|
|
525
|
-
*/
|
|
526
|
-
export function codexRepresentationProblems(env = process.env, platform = process.platform) {
|
|
527
|
-
const layer = inspectCodexLayer(codexMachineLayerPaths(env, platform));
|
|
528
|
-
if (layer.ourJsonEvents.length === 0 || layer.ourTomlEvents.length === 0)
|
|
529
|
-
return [];
|
|
530
|
-
const doubled = layer.ourJsonEvents.filter((event) => layer.ourTomlEvents.includes(event));
|
|
531
|
-
const consequence = doubled.length > 0
|
|
532
|
-
? `Codex runs every matching handler, so this layer notifies twice per turn for ${doubled.join(', ')}`
|
|
533
|
-
: `Codex runs both files, so this layer's Notifai handlers are split between them (${[...new Set([...layer.ourJsonEvents, ...layer.ourTomlEvents])].join(', ')}) and the next install refreshes only one file`;
|
|
534
|
-
return [
|
|
535
|
-
`Notifai hooks are installed in both ${layer.paths.hooksJson} and ${layer.paths.configToml}; ${consequence}. Run \`notifai hooks uninstall --harness codex\` and then \`notifai hooks install --harness codex\` to leave exactly one copy; foreign hooks in either file are left alone.`,
|
|
536
|
-
];
|
|
537
|
-
}
|
|
538
|
-
/**
|
|
539
|
-
* The Machine layer where Notifai and someone else each own one Codex
|
|
540
|
-
* representation.
|
|
541
|
-
*
|
|
542
|
-
* This is not a fault and never fails a check. Codex loads `hooks.json` and
|
|
543
|
-
* inline `[hooks]` together and runs every matching handler, so both sets fire
|
|
544
|
-
* exactly once — but Codex also prints "prefer one representation per layer"
|
|
545
|
-
* at startup, and a user who sees that warning deserves to know which file is
|
|
546
|
-
* Notifai's, which is not, and that Notifai will not touch the one it does not
|
|
547
|
-
* own. Saying nothing here is what made the warning look like Notifai's bug.
|
|
548
|
-
*/
|
|
549
|
-
export function codexCoexistenceNotes(env = process.env, platform = process.platform) {
|
|
550
|
-
const layer = inspectCodexLayer(codexMachineLayerPaths(env, platform));
|
|
551
|
-
const inJson = layer.ourJsonEvents.length > 0;
|
|
552
|
-
const inToml = layer.ourTomlEvents.length > 0;
|
|
553
|
-
if (inJson === inToml)
|
|
554
|
-
return [];
|
|
555
|
-
const ours = inJson ? layer.paths.hooksJson : layer.paths.configToml;
|
|
556
|
-
const theirs = inJson ? layer.paths.configToml : layer.paths.hooksJson;
|
|
557
|
-
const theirEvents = inJson ? layer.tomlEvents : layer.jsonEvents;
|
|
558
|
-
if (theirEvents.length === 0)
|
|
559
|
-
return [];
|
|
560
|
-
return [
|
|
561
|
-
`Notifai's Codex hooks are in ${ours}. ${theirs} defines hooks Notifai does not own (${theirEvents.join(', ')}) and Notifai will not modify it. Codex loads both files and runs every matching handler, so each set fires exactly once; its "prefer one representation per layer" startup warning is reporting that, not a Notifai fault.`,
|
|
562
|
-
];
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* What `CODEX_HOME` is doing to this shell, when it is doing anything.
|
|
566
|
-
*
|
|
567
|
-
* Codex replaces its whole home when the variable is set, so hooks installed
|
|
568
|
-
* from a shell without it are simply absent here, and its trust store is keyed
|
|
569
|
-
* by absolute file path — a config copied between homes arrives untrusted.
|
|
570
|
-
* Both facts turn into "Notifai is not wired" reports that look like a bug in
|
|
571
|
-
* the installer, so doctor names the home it actually inspected.
|
|
572
|
-
*/
|
|
573
|
-
export function codexHomeNote(env = process.env, platform = process.platform) {
|
|
574
|
-
const effective = codexGlobalDir(env, platform);
|
|
575
|
-
const accountDefault = path.join(harnessAccountHome(env, platform), '.codex');
|
|
576
|
-
if (effective === accountDefault)
|
|
577
|
-
return null;
|
|
578
|
-
return `CODEX_HOME points Codex at ${effective}, so ${accountDefault} is not read by Codex in this shell at all. Global hooks installed from a shell without CODEX_HOME live there and are invisible here; Codex also keys hook trust by absolute file path, so hooks copied between homes need approving again in each one.`;
|
|
579
|
-
}
|
|
580
|
-
function tryLoadSettings(file) {
|
|
581
|
-
if (!existsSync(file))
|
|
582
|
-
return null;
|
|
583
|
-
try {
|
|
584
|
-
return loadSettings(file);
|
|
585
|
-
}
|
|
586
|
-
catch {
|
|
587
|
-
return null;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
function hookEventNames(hooks) {
|
|
591
|
-
return Object.entries(hooks ?? {})
|
|
592
|
-
.filter(([, value]) => Array.isArray(value))
|
|
593
|
-
.map(([event]) => event);
|
|
594
|
-
}
|
|
595
|
-
/** Events this document defines a Notifai handler for, by their event key. */
|
|
596
|
-
function ourHandlerEvents(document) {
|
|
597
|
-
if (document === null)
|
|
598
|
-
return [];
|
|
599
|
-
return [...new Set(locateHandlers(document).map((handler) => handler.event))];
|
|
600
|
-
}
|
|
601
|
-
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
602
|
-
/**
|
|
603
|
-
* Harnesses this *project* shows evidence of, in the working directory only.
|
|
604
|
-
*
|
|
605
|
-
* `CLAUDE.md` counts because it is Claude Code's own project file and is
|
|
606
|
-
* frequently the only marker: a repository can be worked in daily through
|
|
607
|
-
* Claude Code and never accumulate a `.claude/` directory.
|
|
608
|
-
*
|
|
609
|
-
* `AGENTS.md` deliberately counts for nothing. It began as a Codex
|
|
610
|
-
* convention and is now read by most agent tooling — including Claude Code,
|
|
611
|
-
* and including this repository, where the two filenames are the same
|
|
612
|
-
* document. Treating it as evidence of any one harness would be a guess
|
|
613
|
-
* dressed up as detection.
|
|
614
|
-
*/
|
|
615
|
-
function localHarnessEvidence(cwd) {
|
|
616
|
-
const found = [];
|
|
617
|
-
if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
|
|
618
|
-
found.push('claude-code');
|
|
619
|
-
}
|
|
620
|
-
if (existsSync(path.join(cwd, '.codex')))
|
|
621
|
-
found.push('codex');
|
|
622
|
-
if (existsSync(path.join(cwd, '.cursor')))
|
|
623
|
-
found.push('cursor');
|
|
624
|
-
if (existsSync(path.join(cwd, '.opencode')))
|
|
625
|
-
found.push('opencode');
|
|
626
|
-
if (existsSync(path.join(cwd, '.openclaw')))
|
|
627
|
-
found.push('openclaw');
|
|
628
|
-
if (existsSync(path.join(cwd, '.grok')))
|
|
629
|
-
found.push('grok');
|
|
630
|
-
return found;
|
|
631
|
-
}
|
|
632
|
-
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
633
|
-
function globalHarnessEvidence(env = process.env, platform = process.platform) {
|
|
634
|
-
const home = harnessAccountHome(env, platform);
|
|
635
|
-
const found = [];
|
|
636
|
-
if (existsSync(path.join(home, '.claude')))
|
|
637
|
-
found.push('claude-code');
|
|
638
|
-
if (existsSync(codexGlobalDir(env, platform)))
|
|
639
|
-
found.push('codex');
|
|
640
|
-
if (existsSync(path.join(home, '.cursor')))
|
|
641
|
-
found.push('cursor');
|
|
642
|
-
if (existsSync(opencodeConfigDir(env, platform)))
|
|
643
|
-
found.push('opencode');
|
|
644
|
-
if (openclawHasGlobalEvidence(existsSync, env, platform))
|
|
645
|
-
found.push('openclaw');
|
|
646
|
-
if (env['HERMES_SESSION_ID'] || (env['HERMES_HOME']?.trim() && existsSync(env['HERMES_HOME']))) {
|
|
647
|
-
found.push('hermes');
|
|
648
|
-
}
|
|
649
|
-
if (existsSync(configHome(env, 'GROK_HOME', '.grok', platform)))
|
|
650
|
-
found.push('grok');
|
|
651
|
-
return found;
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* Every supported harness this project or machine shows evidence of, in
|
|
655
|
-
* declared order. Project markers come first, then machine installs that the
|
|
656
|
-
* project did not already name.
|
|
657
|
-
*
|
|
658
|
-
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
659
|
-
*/
|
|
660
|
-
export function detectedHarnesses(cwd, env = process.env, platform = process.platform) {
|
|
661
|
-
const seen = new Set([
|
|
662
|
-
...localHarnessEvidence(cwd),
|
|
663
|
-
...globalHarnessEvidence(env, platform),
|
|
664
|
-
]);
|
|
665
|
-
return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness));
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Which *single* harness to wire when a caller still needs exactly one, or
|
|
669
|
-
* null when it is genuinely unclear.
|
|
670
|
-
*
|
|
671
|
-
* Project evidence decides, and machine evidence is consulted only when the
|
|
672
|
-
* project offers none. Several markers are not a failure of detection — they
|
|
673
|
-
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
674
|
-
* this helper stays for uninstall and any caller that cannot take a list.
|
|
675
|
-
*/
|
|
676
|
-
export function detectHarness(cwd, env = process.env, platform = process.platform) {
|
|
677
|
-
const local = localHarnessEvidence(cwd);
|
|
678
|
-
if (local.length > 0)
|
|
679
|
-
return local.length === 1 ? local[0] : null;
|
|
680
|
-
const global = globalHarnessEvidence(env, platform);
|
|
681
|
-
return global.length === 1 ? global[0] : null;
|
|
682
|
-
}
|
|
683
|
-
function readCursorSettings(file) {
|
|
684
|
-
if (!existsSync(file))
|
|
685
|
-
return { version: 1 };
|
|
686
|
-
const source = readOwnedRegularFile(file);
|
|
687
|
-
try {
|
|
688
|
-
const parsed = JSON.parse(source);
|
|
689
|
-
return typeof parsed === 'object' && parsed !== null
|
|
690
|
-
? parsed
|
|
691
|
-
: { version: 1 };
|
|
692
|
-
}
|
|
693
|
-
catch {
|
|
694
|
-
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
function isOurCommand(command, scriptPath) {
|
|
698
|
-
if (command.includes(OWNER_MARKER))
|
|
699
|
-
return true;
|
|
700
|
-
return (command.includes(`${scriptPath}' hook `) ||
|
|
701
|
-
command.includes(`${scriptPath}" hook `) ||
|
|
702
|
-
command.includes(`${scriptPath} hook `) ||
|
|
703
|
-
isLegacyNotifaiCommand(command));
|
|
704
|
-
}
|
|
705
|
-
/** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
|
|
706
|
-
function isLegacyNotifaiCommand(command) {
|
|
707
|
-
const hook = `['"]?\\s+hook (?:${HOOK_EVENT_PATTERN})\\b`;
|
|
708
|
-
return (new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
|
|
709
|
-
new RegExp(`(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`, 'i').test(command) ||
|
|
710
|
-
new RegExp(`[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`, 'i').test(command));
|
|
711
|
-
}
|
|
712
|
-
export function mergeCursorHooks(existing, incoming, scriptPath) {
|
|
713
|
-
const hooks = Object.create(null);
|
|
714
|
-
const added = [];
|
|
715
|
-
const replaced = [];
|
|
716
|
-
const removed = [];
|
|
717
|
-
for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
|
|
718
|
-
const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath));
|
|
719
|
-
if (foreign.length !== handlers.length) {
|
|
720
|
-
if (event in incoming)
|
|
721
|
-
replaced.push(event);
|
|
722
|
-
else
|
|
723
|
-
removed.push(event);
|
|
724
|
-
}
|
|
725
|
-
if (foreign.length > 0)
|
|
726
|
-
hooks[event] = foreign;
|
|
727
|
-
}
|
|
728
|
-
for (const [event, handlers] of Object.entries(incoming)) {
|
|
729
|
-
if (!replaced.includes(event))
|
|
730
|
-
added.push(event);
|
|
731
|
-
hooks[event] = [...(hooks[event] ?? []), ...handlers];
|
|
732
|
-
}
|
|
733
|
-
return { document: { ...existing, version: 1, hooks }, added, replaced, removed };
|
|
734
|
-
}
|
|
735
|
-
export function removeCursorHooks(existing, scriptPath) {
|
|
736
|
-
const hooks = Object.create(null);
|
|
737
|
-
const replaced = [];
|
|
738
|
-
for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
|
|
739
|
-
const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath));
|
|
740
|
-
if (foreign.length !== handlers.length)
|
|
741
|
-
replaced.push(event);
|
|
742
|
-
if (foreign.length > 0)
|
|
743
|
-
hooks[event] = foreign;
|
|
744
|
-
}
|
|
745
|
-
const document = { ...existing };
|
|
746
|
-
if (Object.keys(hooks).length > 0)
|
|
747
|
-
document.hooks = hooks;
|
|
748
|
-
else
|
|
749
|
-
delete document.hooks;
|
|
750
|
-
// `version` is Cursor's schema marker, which Notifai writes only because it
|
|
751
|
-
// is writing hooks. Alone it is residue, not settings.
|
|
752
|
-
if (Object.keys(document).length === 1 && document.version !== undefined)
|
|
753
|
-
delete document.version;
|
|
754
|
-
return { document, added: [], replaced, removed: [] };
|
|
755
|
-
}
|
|
756
|
-
function readSettings(file) {
|
|
757
|
-
if (!existsSync(file))
|
|
758
|
-
return {};
|
|
759
|
-
const source = readOwnedRegularFile(file);
|
|
760
|
-
try {
|
|
761
|
-
const parsed = JSON.parse(source);
|
|
762
|
-
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
763
|
-
}
|
|
764
|
-
catch {
|
|
765
|
-
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
/**
|
|
769
|
-
* True when a single handler is ours. Deliberately per-handler, not per-group:
|
|
770
|
-
* removing a whole group destroyed a user's own handler if they had added one
|
|
771
|
-
* beside ours inside the same matcher group.
|
|
772
|
-
*/
|
|
773
|
-
function isOurHandler(handler, scriptPath) {
|
|
774
|
-
return isOurCommand(handler.command, scriptPath);
|
|
775
|
-
}
|
|
776
|
-
/** Drops only our handlers, keeping the group and anyone else's handlers. */
|
|
777
|
-
function withoutOurs(groups, scriptPath) {
|
|
778
|
-
let removed = false;
|
|
779
|
-
const kept = [];
|
|
780
|
-
for (const group of groups) {
|
|
781
|
-
const hooks = group.hooks.filter((handler) => {
|
|
782
|
-
const ours = isOurHandler(handler, scriptPath);
|
|
783
|
-
if (ours)
|
|
784
|
-
removed = true;
|
|
785
|
-
return !ours;
|
|
786
|
-
});
|
|
787
|
-
if (hooks.length > 0)
|
|
788
|
-
kept.push({ ...group, hooks });
|
|
789
|
-
}
|
|
790
|
-
return { groups: kept, removed };
|
|
791
|
-
}
|
|
792
|
-
/**
|
|
793
|
-
* Refresh owned handlers in their existing slots. Codex's approval identity
|
|
794
|
-
* includes group and handler indices, so stripping then appending an unchanged
|
|
795
|
-
* handler can revoke its approval and stale an active session's Stop definition.
|
|
796
|
-
* Missing handlers append after existing slots; foreign handlers and matcher
|
|
797
|
-
* metadata stay in place.
|
|
798
|
-
*
|
|
799
|
-
* Retired handlers are considered in every event. Hosts without index-based
|
|
800
|
-
* approval can remove them; strict Codex repair refuses ambiguous removal so
|
|
801
|
-
* an explicit migration can account for native trust and active sessions.
|
|
802
|
-
*/
|
|
803
|
-
export function mergeHooks(existing, incoming, scriptPath, options = {}) {
|
|
804
|
-
const hooks = Object.create(null);
|
|
805
|
-
const added = [];
|
|
806
|
-
const replaced = [];
|
|
807
|
-
const removed = [];
|
|
808
|
-
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
809
|
-
if (!Array.isArray(groups)) {
|
|
810
|
-
// Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
|
|
811
|
-
;
|
|
812
|
-
hooks[event] = groups;
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
const desired = (incoming[event] ?? []).map(group => ({ ...group, hooks: [...group.hooks] }));
|
|
816
|
-
const refreshed = [];
|
|
817
|
-
let hadOurs = false;
|
|
818
|
-
for (const group of groups) {
|
|
819
|
-
const next = [];
|
|
820
|
-
for (const handler of group.hooks) {
|
|
821
|
-
if (!isOurHandler(handler, scriptPath)) {
|
|
822
|
-
next.push(handler);
|
|
823
|
-
continue;
|
|
824
|
-
}
|
|
825
|
-
hadOurs = true;
|
|
826
|
-
const replacementGroup = desired.find(candidate => candidate.hooks.some(replacement => handlerEvent(replacement.command) === handlerEvent(handler.command)));
|
|
827
|
-
const index = replacementGroup?.hooks.findIndex(replacement => handlerEvent(replacement.command) === handlerEvent(handler.command)) ?? -1;
|
|
828
|
-
if (replacementGroup !== undefined && index >= 0) {
|
|
829
|
-
next.push(replacementGroup.hooks.splice(index, 1)[0]);
|
|
830
|
-
}
|
|
831
|
-
else if (options.preserveIdentities === true) {
|
|
832
|
-
throw new Error(`Cannot preserve Codex hook identities for ${event}: duplicate or retired owned handlers require explicit migration. Review the existing hooks before retrying; the hook document was not changed.`);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
// Empty native groups also occupy an approval index.
|
|
836
|
-
if (next.length > 0 || options.preserveIdentities === true)
|
|
837
|
-
refreshed.push({ ...group, hooks: next });
|
|
838
|
-
}
|
|
839
|
-
for (const group of desired.filter(candidate => candidate.hooks.length > 0)) {
|
|
840
|
-
// Append to a matching owned group without inserting before a foreign
|
|
841
|
-
// handler. An omitted matcher and '*' both cover the complete event.
|
|
842
|
-
const target = refreshed.find(candidate => (candidate.matcher ?? '*') === (group.matcher ?? '*') &&
|
|
843
|
-
candidate.hooks.some(handler => isOurHandler(handler, scriptPath)));
|
|
844
|
-
if (target === undefined)
|
|
845
|
-
refreshed.push(group);
|
|
846
|
-
else
|
|
847
|
-
target.hooks.push(...group.hooks);
|
|
848
|
-
}
|
|
849
|
-
if (hadOurs) {
|
|
850
|
-
if (event in incoming)
|
|
851
|
-
replaced.push(event);
|
|
852
|
-
else
|
|
853
|
-
removed.push(event);
|
|
854
|
-
}
|
|
855
|
-
if (refreshed.length > 0)
|
|
856
|
-
hooks[event] = refreshed;
|
|
857
|
-
}
|
|
858
|
-
for (const [event, groups] of Object.entries(incoming)) {
|
|
859
|
-
if (!replaced.includes(event))
|
|
860
|
-
added.push(event);
|
|
861
|
-
if (!(event in (existing.hooks ?? {})))
|
|
862
|
-
hooks[event] = groups;
|
|
863
|
-
}
|
|
864
|
-
return { document: { ...existing, hooks }, added, replaced, removed };
|
|
865
|
-
}
|
|
866
|
-
export function removeHooks(existing, scriptPath) {
|
|
867
|
-
const hooks = Object.create(null);
|
|
868
|
-
const replaced = [];
|
|
869
|
-
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
870
|
-
if (!Array.isArray(groups)) {
|
|
871
|
-
;
|
|
872
|
-
hooks[event] = groups;
|
|
873
|
-
continue;
|
|
874
|
-
}
|
|
875
|
-
const { groups: foreign, removed } = withoutOurs(groups, scriptPath);
|
|
876
|
-
if (removed)
|
|
877
|
-
replaced.push(event);
|
|
878
|
-
if (foreign.length > 0)
|
|
879
|
-
hooks[event] = foreign;
|
|
880
|
-
}
|
|
881
|
-
const remaining = Object.keys(hooks).length > 0 ? hooks : undefined;
|
|
882
|
-
const document = { ...existing };
|
|
883
|
-
if (remaining === undefined)
|
|
884
|
-
delete document.hooks;
|
|
885
|
-
else
|
|
886
|
-
document.hooks = remaining;
|
|
887
|
-
return { document, added: [], replaced, removed: [] };
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* Writes the settings file the way a tool that does not own it should.
|
|
891
|
-
*
|
|
892
|
-
* Truncating in place lost the user's whole harness configuration if the write
|
|
893
|
-
* failed halfway, and clobbered a concurrent editor's changes; following a
|
|
894
|
-
* symlink wrote through to a target they never named. So: refuse
|
|
895
|
-
* anything that is not a regular file, write a sibling temp file, fsync it, and
|
|
896
|
-
* rename over the original — atomic within a directory on every platform we
|
|
897
|
-
* support.
|
|
898
|
-
*/
|
|
899
|
-
export function applyPlan(file, document) {
|
|
900
|
-
preparePlan(file, document)();
|
|
901
|
-
}
|
|
902
|
-
/** Validate a proposed document before any file in a multi-file edit changes. */
|
|
903
|
-
export function preparePlan(file, document, previous = readOptionalOwnedRegularFile(file)) {
|
|
904
|
-
const body = isTomlSettingsPath(file)
|
|
905
|
-
? tomlBody(file, document, previous)
|
|
906
|
-
: `${JSON.stringify(document, null, 2)}\n`;
|
|
907
|
-
const empty = isTomlSettingsPath(file) ? body.trim() === '' : isEmptyJsonDocument(body);
|
|
908
|
-
return prepareFileWrite(file, previous, empty ? null : body);
|
|
909
|
-
}
|
|
910
|
-
function readOptionalOwnedRegularFile(file) {
|
|
911
|
-
try {
|
|
912
|
-
return readOwnedRegularFile(file);
|
|
913
|
-
}
|
|
914
|
-
catch (err) {
|
|
915
|
-
if (err.code === 'ENOENT')
|
|
916
|
-
return null;
|
|
917
|
-
throw err;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
function prepareFileWrite(file, previous, next) {
|
|
921
|
-
return () => {
|
|
922
|
-
const current = readOptionalOwnedRegularFile(file);
|
|
923
|
-
if (current !== previous)
|
|
924
|
-
throw new Error(`Configuration changed while preparing ${file}; retry.`);
|
|
925
|
-
if (current === next)
|
|
926
|
-
return;
|
|
927
|
-
if (next === null)
|
|
928
|
-
rmSync(file, { force: true });
|
|
929
|
-
else
|
|
930
|
-
atomicWriteFileSync(file, next, { requireCurrentUserOwner: true });
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
function isEmptyJsonDocument(body) {
|
|
934
|
-
try {
|
|
935
|
-
const parsed = JSON.parse(body);
|
|
936
|
-
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0;
|
|
937
|
-
}
|
|
938
|
-
catch {
|
|
939
|
-
return false;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
/** Delete an emptied Codex hooks.json and a now-empty layer directory. */
|
|
943
|
-
export function cleanupEmptiedCodexLayer(paths) {
|
|
944
|
-
if (existsSync(paths.hooksJson)) {
|
|
945
|
-
try {
|
|
946
|
-
const document = loadSettings(paths.hooksJson);
|
|
947
|
-
const keys = Object.keys(document);
|
|
948
|
-
const hooksEmpty = document.hooks === undefined || Object.keys(document.hooks).length === 0;
|
|
949
|
-
if (keys.every((key) => key === 'hooks') && hooksEmpty) {
|
|
950
|
-
rmSync(paths.hooksJson, { force: true });
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
catch {
|
|
954
|
-
// Leave a file uninstall could not parse; the caller already reported it.
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
if (!existsSync(paths.dir))
|
|
958
|
-
return;
|
|
959
|
-
try {
|
|
960
|
-
if (readdirSync(paths.dir).length === 0)
|
|
961
|
-
rmdirSync(paths.dir);
|
|
962
|
-
}
|
|
963
|
-
catch {
|
|
964
|
-
// A concurrently replaced directory is not ours to fight.
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* A TOML file rewritten around its `[hooks]` tables, not over them.
|
|
969
|
-
*
|
|
970
|
-
* `config.toml` is the user's, and for most of them it is hand-written: model
|
|
971
|
-
* choices, sandbox settings, MCP servers, and the comments explaining why. A
|
|
972
|
-
* parse-and-restringify write returns all of that as data and none of it as
|
|
973
|
-
* the document they wrote — comments gone, order normalized, spacing theirs no
|
|
974
|
-
* longer. Notifai adds three hook handlers; it has no business reflowing the
|
|
975
|
-
* rest.
|
|
976
|
-
*
|
|
977
|
-
* So everything outside `[hooks...]` is carried across as the exact bytes it
|
|
978
|
-
* came in as, and only the hooks tables are regenerated — re-emitted at the end
|
|
979
|
-
* of the file, because TOML does not care where a table sits and splicing into
|
|
980
|
-
* the middle would mean reasoning about a region line by line. If the result
|
|
981
|
-
* cannot be proven to parse back to the document asked for, refuse the write.
|
|
982
|
-
*/
|
|
983
|
-
function tomlBody(file, document, previous) {
|
|
984
|
-
if (previous === null)
|
|
985
|
-
return `${stringifyToml(document)}\n`;
|
|
986
|
-
if (sameTomlValue(parseToml(previous), document))
|
|
987
|
-
return previous;
|
|
988
|
-
const next = spliceTomlHooks(previous, document);
|
|
989
|
-
if (next === null)
|
|
990
|
-
throw new Error(`Cannot preserve unrelated TOML configuration in ${file}; no changes written.`);
|
|
991
|
-
return next;
|
|
992
|
-
}
|
|
993
|
-
/** Only locate syntax; smol-toml owns validity and decoded key identity. */
|
|
994
|
-
function tomlStatements(source) {
|
|
995
|
-
parseToml(source);
|
|
996
|
-
const statements = [];
|
|
997
|
-
let offset = 0;
|
|
998
|
-
while (offset < source.length) {
|
|
999
|
-
if (/\s/.test(source[offset])) {
|
|
1000
|
-
offset++;
|
|
1001
|
-
continue;
|
|
1002
|
-
}
|
|
1003
|
-
if (source[offset] === '#') {
|
|
1004
|
-
while (offset < source.length && source[offset] !== '\n')
|
|
1005
|
-
offset++;
|
|
1006
|
-
continue;
|
|
1007
|
-
}
|
|
1008
|
-
const start = offset;
|
|
1009
|
-
let depth = 0;
|
|
1010
|
-
let equals = -1;
|
|
1011
|
-
const comments = [];
|
|
1012
|
-
while (offset < source.length) {
|
|
1013
|
-
const char = source[offset];
|
|
1014
|
-
if (char === '"' || char === "'") {
|
|
1015
|
-
const multiline = source.slice(offset, offset + 3) === char.repeat(3);
|
|
1016
|
-
offset += multiline ? 3 : 1;
|
|
1017
|
-
while (offset < source.length) {
|
|
1018
|
-
if (char === '"' && source[offset] === '\\') {
|
|
1019
|
-
offset += 2;
|
|
1020
|
-
continue;
|
|
1021
|
-
}
|
|
1022
|
-
if (source[offset] === char) {
|
|
1023
|
-
let count = 1;
|
|
1024
|
-
while (source[offset + count] === char)
|
|
1025
|
-
count++;
|
|
1026
|
-
if (!multiline || count >= 3) {
|
|
1027
|
-
offset += multiline ? count : 1;
|
|
1028
|
-
break;
|
|
1029
|
-
}
|
|
1030
|
-
offset += count;
|
|
1031
|
-
}
|
|
1032
|
-
else
|
|
1033
|
-
offset++;
|
|
1034
|
-
}
|
|
1035
|
-
continue;
|
|
1036
|
-
}
|
|
1037
|
-
if (char === '#') {
|
|
1038
|
-
if (depth === 0)
|
|
1039
|
-
break;
|
|
1040
|
-
const commentStart = offset;
|
|
1041
|
-
while (offset < source.length && source[offset] !== '\n')
|
|
1042
|
-
offset++;
|
|
1043
|
-
comments.push({ start: commentStart, end: offset });
|
|
1044
|
-
continue;
|
|
1045
|
-
}
|
|
1046
|
-
if (char === '\n' && depth === 0)
|
|
1047
|
-
break;
|
|
1048
|
-
if (char === '[' || char === '{')
|
|
1049
|
-
depth++;
|
|
1050
|
-
if (char === ']' || char === '}')
|
|
1051
|
-
depth--;
|
|
1052
|
-
if (char === '=' && depth === 0 && equals === -1)
|
|
1053
|
-
equals = offset;
|
|
1054
|
-
offset++;
|
|
1055
|
-
}
|
|
1056
|
-
const table = source[start] === '[';
|
|
1057
|
-
const syntax = source.slice(start, offset).trim();
|
|
1058
|
-
// A standalone table or a dummy assignment decodes dotted/quoted/escaped
|
|
1059
|
-
// keys without interpreting brackets, dots or quotes inside a key as syntax.
|
|
1060
|
-
let decoded = parseToml(table ? syntax : `${source.slice(start, equals)} = 0`);
|
|
1061
|
-
const keys = [];
|
|
1062
|
-
while (decoded !== null && typeof decoded === 'object') {
|
|
1063
|
-
if (Array.isArray(decoded)) {
|
|
1064
|
-
decoded = decoded[0];
|
|
1065
|
-
continue;
|
|
1066
|
-
}
|
|
1067
|
-
const entries = Object.entries(decoded);
|
|
1068
|
-
if (entries.length !== 1)
|
|
1069
|
-
break;
|
|
1070
|
-
const [key, value] = entries[0];
|
|
1071
|
-
keys.push(key);
|
|
1072
|
-
decoded = value;
|
|
1073
|
-
}
|
|
1074
|
-
statements.push({ start, end: offset, table, path: keys, comments });
|
|
1075
|
-
}
|
|
1076
|
-
return statements;
|
|
1077
|
-
}
|
|
1078
|
-
/** Splice syntax spans only; even comments inside removed arrays survive. */
|
|
1079
|
-
function omitTomlStatements(source, removed) {
|
|
1080
|
-
let next = '';
|
|
1081
|
-
let offset = 0;
|
|
1082
|
-
for (const statement of removed) {
|
|
1083
|
-
next += source.slice(offset, statement.start);
|
|
1084
|
-
for (const comment of statement.comments)
|
|
1085
|
-
next += `${source.slice(comment.start, comment.end)}\n`;
|
|
1086
|
-
offset = statement.end;
|
|
1087
|
-
}
|
|
1088
|
-
return next + source.slice(offset);
|
|
1089
|
-
}
|
|
1090
|
-
/** Replace hook syntax only, validating all unrelated parsed values. */
|
|
1091
|
-
function spliceTomlHooks(source, document) {
|
|
1092
|
-
try {
|
|
1093
|
-
let table = [];
|
|
1094
|
-
const removed = tomlStatements(source).filter((statement) => {
|
|
1095
|
-
if (statement.table)
|
|
1096
|
-
table = statement.path;
|
|
1097
|
-
return (statement.table ? table : [...table, ...statement.path])[0] === 'hooks';
|
|
1098
|
-
});
|
|
1099
|
-
const head = omitTomlStatements(source, removed);
|
|
1100
|
-
const body = document.hooks === undefined ? '' : stringifyToml({ hooks: document.hooks });
|
|
1101
|
-
const spliced = body === '' ? head : `${head}${head === '' || head.endsWith('\n') ? '' : '\n'}${body}\n`;
|
|
1102
|
-
return sameTomlValue(parseToml(spliced), document) ? spliced : null;
|
|
1103
|
-
}
|
|
1104
|
-
catch {
|
|
1105
|
-
return null;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
/** Structural equality for parsed TOML, which carries dates as well as data. */
|
|
1109
|
-
function sameTomlValue(a, b) {
|
|
1110
|
-
if (Object.is(a, b))
|
|
1111
|
-
return true;
|
|
1112
|
-
if (a instanceof Date && b instanceof Date)
|
|
1113
|
-
return a.getTime() === b.getTime();
|
|
1114
|
-
if (Array.isArray(a) || Array.isArray(b)) {
|
|
1115
|
-
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
1116
|
-
return false;
|
|
1117
|
-
return a.every((item, index) => sameTomlValue(item, b[index]));
|
|
1118
|
-
}
|
|
1119
|
-
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
1120
|
-
return false;
|
|
1121
|
-
const left = a;
|
|
1122
|
-
const right = b;
|
|
1123
|
-
const keys = Object.keys(left);
|
|
1124
|
-
if (keys.length !== Object.keys(right).length)
|
|
1125
|
-
return false;
|
|
1126
|
-
return keys.every((key) => Object.hasOwn(right, key) && sameTomlValue(left[key], right[key]));
|
|
1127
|
-
}
|
|
1128
|
-
export function loadSettings(file) {
|
|
1129
|
-
return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file);
|
|
1130
|
-
}
|
|
1131
|
-
export function isTomlSettingsPath(file) {
|
|
1132
|
-
return file.endsWith('.toml');
|
|
1133
|
-
}
|
|
1134
|
-
function readTomlSettings(file) {
|
|
1135
|
-
if (!existsSync(file))
|
|
1136
|
-
return {};
|
|
1137
|
-
const source = readOwnedRegularFile(file);
|
|
1138
|
-
try {
|
|
1139
|
-
const parsed = parseToml(source);
|
|
1140
|
-
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
1141
|
-
}
|
|
1142
|
-
catch {
|
|
1143
|
-
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
export function loadCursorSettings(file) {
|
|
1147
|
-
return readCursorSettings(file);
|
|
1148
|
-
}
|
|
1149
|
-
/** Codex's canonical event spelling inside its persisted trust keys. */
|
|
1150
|
-
function codexEventName(event) {
|
|
1151
|
-
return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
1152
|
-
}
|
|
1153
|
-
/** Recursively sort object keys before hashing, matching Codex's canonical JSON. */
|
|
1154
|
-
function canonicalValue(value) {
|
|
1155
|
-
if (Array.isArray(value))
|
|
1156
|
-
return value.map(canonicalValue);
|
|
1157
|
-
if (typeof value !== 'object' || value === null)
|
|
1158
|
-
return value;
|
|
1159
|
-
return Object.fromEntries(Object.entries(value)
|
|
1160
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
1161
|
-
.map(([key, child]) => [key, canonicalValue(child)]));
|
|
1162
|
-
}
|
|
1163
|
-
/**
|
|
1164
|
-
* Best-effort mirror of the identity current Codex builds compare with
|
|
1165
|
-
* `hooks.state.*.trusted_hash`. Codex hashes `type`, `command`, `timeout`,
|
|
1166
|
-
* `async`, `statusMessage`, and `additionalContextLimit` (omitting the 2,500
|
|
1167
|
-
* default). This persisted format is diagnostic evidence, not an API: Codex's
|
|
1168
|
-
* `/hooks` UI remains authoritative and Notifai never writes the trust store.
|
|
1169
|
-
*/
|
|
1170
|
-
/**
|
|
1171
|
-
* Codex's default additionalContext spill threshold, in approximate tokens.
|
|
1172
|
-
* Codex strips this value before hashing, so an explicit 2,500 matches omit.
|
|
1173
|
-
*/
|
|
1174
|
-
const CODEX_DEFAULT_ADDITIONAL_CONTEXT_LIMIT = 2_500;
|
|
1175
|
-
export function codexHookIdentityHash(handler) {
|
|
1176
|
-
const command = {
|
|
1177
|
-
type: 'command',
|
|
1178
|
-
command: handler.command,
|
|
1179
|
-
timeout: Math.max(1, handler.timeout ?? 600),
|
|
1180
|
-
async: handler.async ?? false,
|
|
1181
|
-
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
1182
|
-
...(handler.additionalContextLimit === undefined
|
|
1183
|
-
|| handler.additionalContextLimit === CODEX_DEFAULT_ADDITIONAL_CONTEXT_LIMIT
|
|
1184
|
-
? {}
|
|
1185
|
-
: { additionalContextLimit: handler.additionalContextLimit }),
|
|
1186
|
-
};
|
|
1187
|
-
const normalized = canonicalValue({
|
|
1188
|
-
event_name: codexEventName(handler.event),
|
|
1189
|
-
hooks: [command],
|
|
1190
|
-
});
|
|
1191
|
-
return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`;
|
|
1192
|
-
}
|
|
1193
|
-
function codexTrustState(env) {
|
|
1194
|
-
const file = path.join(codexGlobalDir(env), 'config.toml');
|
|
1195
|
-
if (!existsSync(file))
|
|
1196
|
-
return {};
|
|
1197
|
-
try {
|
|
1198
|
-
const parsed = parseToml(readFileSync(file, 'utf8'));
|
|
1199
|
-
const hooks = parsed['hooks'];
|
|
1200
|
-
if (typeof hooks !== 'object' || hooks === null)
|
|
1201
|
-
return {};
|
|
1202
|
-
const state = hooks['state'];
|
|
1203
|
-
return typeof state === 'object' && state !== null ? state : {};
|
|
1204
|
-
}
|
|
1205
|
-
catch {
|
|
1206
|
-
return {};
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
/** Best-effort mirror of current Codex's persisted key for one handler. */
|
|
1210
|
-
export function codexTrustKey(installation, handler) {
|
|
1211
|
-
let source = installation.file;
|
|
1212
|
-
try {
|
|
1213
|
-
source = path.join(realpathSync(path.dirname(source)), path.basename(source));
|
|
1214
|
-
}
|
|
1215
|
-
catch {
|
|
1216
|
-
// Codex falls back to the logical source path when canonicalization fails,
|
|
1217
|
-
// so the diagnostic must do the same.
|
|
1218
|
-
}
|
|
1219
|
-
return `${source}:${codexEventName(handler.event)}:${handler.groupIndex}:${handler.handlerIndex}`;
|
|
1220
|
-
}
|
|
1221
|
-
/**
|
|
1222
|
-
* Stable identity of the one Codex Stop definition an Agent Session loaded.
|
|
1223
|
-
*
|
|
1224
|
-
* Codex materializes hook handlers into the session runtime. A later config
|
|
1225
|
-
* change can therefore make the file on disk current while an older session
|
|
1226
|
-
* keeps executing the definition it loaded at activation. Include Codex's
|
|
1227
|
-
* source key as well as the normalized handler identity so moving an otherwise
|
|
1228
|
-
* byte-identical definition between project and global layers also requires a
|
|
1229
|
-
* fresh session.
|
|
1230
|
-
*/
|
|
1231
|
-
export function codexStopDefinitionFingerprint(installations) {
|
|
1232
|
-
const candidates = installations
|
|
1233
|
-
.filter((installation) => installation.harness === 'codex')
|
|
1234
|
-
.flatMap((installation) => installation.handlers
|
|
1235
|
-
// The attend handler shares the Stop group; it is not the Stop definition.
|
|
1236
|
-
.filter((handler) => handler.event === 'Stop' && handlerEvent(handler.command) !== 'attend')
|
|
1237
|
-
.map((handler) => ({ installation, handler })));
|
|
1238
|
-
if (candidates.length !== 1)
|
|
1239
|
-
return undefined;
|
|
1240
|
-
const candidate = candidates[0];
|
|
1241
|
-
if (candidate === undefined)
|
|
1242
|
-
return undefined;
|
|
1243
|
-
const { installation, handler } = candidate;
|
|
1244
|
-
return createHash('sha256')
|
|
1245
|
-
.update(`${codexTrustKey(installation, handler)}\0${codexHookIdentityHash(handler)}`)
|
|
1246
|
-
.digest('hex');
|
|
1247
|
-
}
|
|
1248
|
-
/**
|
|
1249
|
-
* Trust defects that make installed Codex handlers look present while Codex
|
|
1250
|
-
* skips them. Trust is user-owned; the supported repair is Codex's `/hooks`
|
|
1251
|
-
* review UI, never writing the trust store on the user's behalf. Because the
|
|
1252
|
-
* persisted shape is not a public Codex contract, this diagnosis can drift;
|
|
1253
|
-
* `/hooks` remains the source of truth.
|
|
1254
|
-
*/
|
|
1255
|
-
export function codexTrustProblems(installations, env = process.env) {
|
|
1256
|
-
const state = codexTrustState(env);
|
|
1257
|
-
return installations
|
|
1258
|
-
.filter((installation) => installation.harness === 'codex')
|
|
1259
|
-
.flatMap((installation) => installation.handlers.flatMap((handler) => {
|
|
1260
|
-
const key = codexTrustKey(installation, handler);
|
|
1261
|
-
const entry = state[key];
|
|
1262
|
-
const trustedHash = typeof entry === 'object' && entry !== null
|
|
1263
|
-
? entry['trusted_hash']
|
|
1264
|
-
: undefined;
|
|
1265
|
-
const currentHash = codexHookIdentityHash(handler);
|
|
1266
|
-
if (typeof entry === 'object' &&
|
|
1267
|
-
entry !== null &&
|
|
1268
|
-
entry['enabled'] === false) {
|
|
1269
|
-
return [
|
|
1270
|
-
`${handler.event} in ${installation.file} is disabled in Codex; open \`/hooks\` and enable the Notifai handler`,
|
|
1271
|
-
];
|
|
1272
|
-
}
|
|
1273
|
-
if (trustedHash === currentHash)
|
|
1274
|
-
return [];
|
|
1275
|
-
return [
|
|
1276
|
-
`${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`,
|
|
1277
|
-
];
|
|
1278
|
-
}));
|
|
1279
|
-
}
|
|
1280
|
-
/**
|
|
1281
|
-
* Trust defects in the handlers Question Routing runs. The Session Attendant
|
|
1282
|
-
* is presence, never a routing gate: an attend handler still awaiting
|
|
1283
|
-
* approval is reported by doctor and init and never refuses a question.
|
|
1284
|
-
*/
|
|
1285
|
-
export function codexRoutingTrustProblems(installations, env = process.env) {
|
|
1286
|
-
return codexTrustProblems(installations.map((installation) => ({
|
|
1287
|
-
...installation,
|
|
1288
|
-
handlers: installation.handlers.filter((handler) => !['attend', 'post-tool-use'].includes(handlerEvent(handler.command) ?? '')),
|
|
1289
|
-
})), env);
|
|
1290
|
-
}
|
|
1291
|
-
/**
|
|
1292
|
-
* Any Notifai handler, whatever checkout wrote it.
|
|
1293
|
-
*
|
|
1294
|
-
* Deliberately looser than `isOurHandler`: this answers "has Notifai been set
|
|
1295
|
-
* up here at all", and a handler installed from a second checkout is still
|
|
1296
|
-
* evidence that it has (and is itself worth reporting).
|
|
1297
|
-
*/
|
|
1298
|
-
function isNotifaiCommand(command) {
|
|
1299
|
-
return HOOK_EVENT_COMMAND_RE.test(command);
|
|
1300
|
-
}
|
|
1301
|
-
/**
|
|
1302
|
-
* Native plugin ids that would fire beside document hooks if left enabled.
|
|
1303
|
-
*
|
|
1304
|
-
* Notifai no longer wires through harness plugins. A leftover enablement from
|
|
1305
|
-
* a trial or an older experiment still runs the same adapter command, so
|
|
1306
|
-
* document handlers plus that plugin fire every event twice. Matching is the
|
|
1307
|
-
* plugin name `notifai` with an optional `@marketplace` suffix — never a
|
|
1308
|
-
* substring of someone else's plugin id.
|
|
1309
|
-
*/
|
|
1310
|
-
function isNotifaiNativePluginKey(key) {
|
|
1311
|
-
const id = key.split('@')[0]?.trim() ?? '';
|
|
1312
|
-
return id.toLowerCase() === 'notifai';
|
|
1313
|
-
}
|
|
1314
|
-
function pluginEntryIsEnabled(value) {
|
|
1315
|
-
if (value === false)
|
|
1316
|
-
return false;
|
|
1317
|
-
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
1318
|
-
return value['enabled'] !== false;
|
|
1319
|
-
}
|
|
1320
|
-
return true;
|
|
1321
|
-
}
|
|
1322
|
-
export function notifaiNativePluginEnablementKeys(value) {
|
|
1323
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
1324
|
-
return [];
|
|
1325
|
-
return Object.entries(value)
|
|
1326
|
-
.filter(([key, enabled]) => isNotifaiNativePluginKey(key) && pluginEntryIsEnabled(enabled))
|
|
1327
|
-
.map(([key]) => key);
|
|
1328
|
-
}
|
|
1329
|
-
export function stripObsoleteNotifaiPluginEnablement(document) {
|
|
1330
|
-
const next = { ...document };
|
|
1331
|
-
const removed = [];
|
|
1332
|
-
for (const field of ['enabledPlugins', 'plugins']) {
|
|
1333
|
-
const value = next[field];
|
|
1334
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
1335
|
-
continue;
|
|
1336
|
-
const record = { ...value };
|
|
1337
|
-
let changed = false;
|
|
1338
|
-
for (const key of Object.keys(record)) {
|
|
1339
|
-
if (!isNotifaiNativePluginKey(key) || !pluginEntryIsEnabled(record[key]))
|
|
1340
|
-
continue;
|
|
1341
|
-
delete record[key];
|
|
1342
|
-
removed.push(key);
|
|
1343
|
-
changed = true;
|
|
1344
|
-
}
|
|
1345
|
-
if (!changed)
|
|
1346
|
-
continue;
|
|
1347
|
-
if (Object.keys(record).length === 0)
|
|
1348
|
-
delete next[field];
|
|
1349
|
-
else
|
|
1350
|
-
next[field] = record;
|
|
1351
|
-
}
|
|
1352
|
-
return { document: next, removed };
|
|
1353
|
-
}
|
|
1354
|
-
/**
|
|
1355
|
-
* Remove enabled Notifai plugin tables only after lexical ownership and full
|
|
1356
|
-
* semantic preservation are proven. Unsupported dotted/inline definitions
|
|
1357
|
-
* fail before mutation, rather than silently leaving a second firing path.
|
|
1358
|
-
*/
|
|
1359
|
-
export function spliceOutNotifaiPluginTables(source) {
|
|
1360
|
-
const expected = parseToml(source);
|
|
1361
|
-
const plugins = expected['plugins'];
|
|
1362
|
-
const removed = notifaiNativePluginEnablementKeys(plugins);
|
|
1363
|
-
if (removed.length === 0)
|
|
1364
|
-
return { next: source, removed };
|
|
1365
|
-
const remaining = { ...plugins };
|
|
1366
|
-
for (const key of removed)
|
|
1367
|
-
delete remaining[key];
|
|
1368
|
-
expected['plugins'] = remaining;
|
|
1369
|
-
let dropping = false;
|
|
1370
|
-
const omitted = tomlStatements(source).filter((statement) => {
|
|
1371
|
-
if (statement.table) {
|
|
1372
|
-
dropping = statement.path[0] === 'plugins' && removed.includes(statement.path[1] ?? '');
|
|
1373
|
-
}
|
|
1374
|
-
return dropping;
|
|
1375
|
-
});
|
|
1376
|
-
const next = omitTomlStatements(source, omitted);
|
|
1377
|
-
const parsed = parseToml(next);
|
|
1378
|
-
// Removing the last child table may also remove its implicit parent.
|
|
1379
|
-
for (const value of [expected, parsed]) {
|
|
1380
|
-
const entries = value['plugins'];
|
|
1381
|
-
if (entries && typeof entries === 'object' && !Array.isArray(entries) && Object.keys(entries).length === 0) {
|
|
1382
|
-
delete value['plugins'];
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
if (!sameTomlValue(parsed, expected)) {
|
|
1386
|
-
throw new Error('Cannot safely remove obsolete native plugin wiring from this TOML form; disable the Notifai plugin or use [plugins."notifai"] table form and retry. No changes written.');
|
|
1387
|
-
}
|
|
1388
|
-
return { next, removed };
|
|
1389
|
-
}
|
|
1390
|
-
/**
|
|
1391
|
-
* Leftover native plugin enablement that would fire beside document hooks.
|
|
1392
|
-
*
|
|
1393
|
-
* Reads only harness settings files this installer already owns inspecting.
|
|
1394
|
-
* It never shells out to a plugin CLI and never writes a plugin.
|
|
1395
|
-
*/
|
|
1396
|
-
export function findObsoleteNotifaiPluginWiring(env = process.env, platform = process.platform) {
|
|
1397
|
-
const found = [];
|
|
1398
|
-
const claudeFile = settingsFile('claude-code', env, platform);
|
|
1399
|
-
if (existsSync(claudeFile)) {
|
|
1400
|
-
try {
|
|
1401
|
-
const keys = notifaiNativePluginEnablementKeys(loadSettings(claudeFile)['enabledPlugins']);
|
|
1402
|
-
if (keys.length > 0)
|
|
1403
|
-
found.push({ harness: 'claude-code', file: claudeFile, keys });
|
|
1404
|
-
}
|
|
1405
|
-
catch {
|
|
1406
|
-
// Unreadable settings are reported by the ordinary install/doctor path.
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
const codexToml = codexMachineLayerPaths(env, platform).configToml;
|
|
1410
|
-
if (existsSync(codexToml)) {
|
|
1411
|
-
try {
|
|
1412
|
-
const keys = notifaiNativePluginEnablementKeys(loadSettings(codexToml)['plugins']);
|
|
1413
|
-
if (keys.length > 0)
|
|
1414
|
-
found.push({ harness: 'codex', file: codexToml, keys });
|
|
1415
|
-
}
|
|
1416
|
-
catch {
|
|
1417
|
-
// Same: leave diagnosis to the settings reader that already ran.
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
return found;
|
|
1421
|
-
}
|
|
1422
|
-
/**
|
|
1423
|
-
* Plan native plugin retirement without changing configuration. The caller
|
|
1424
|
-
* validates every destination before applying this plan under the layer lock.
|
|
1425
|
-
*/
|
|
1426
|
-
export function prepareCodexPluginCleanup(file) {
|
|
1427
|
-
const previous = readOptionalOwnedRegularFile(file);
|
|
1428
|
-
if (previous === null)
|
|
1429
|
-
return { source: null, removed: [], apply: prepareFileWrite(file, null, null) };
|
|
1430
|
-
const { next, removed } = spliceOutNotifaiPluginTables(previous);
|
|
1431
|
-
return { source: next, removed, apply: prepareFileWrite(file, previous, next) };
|
|
1432
|
-
}
|
|
1433
|
-
/**
|
|
1434
|
-
* The one Machine installation each harness would read a Notifai handler from.
|
|
1435
|
-
*/
|
|
1436
|
-
export function findInstallations(env = process.env, adapterHome, platform = process.platform) {
|
|
1437
|
-
return HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => collectInstallations(harness, machineHookFiles(harness, env, platform), adapterHome, platform, env));
|
|
1438
|
-
}
|
|
1439
|
-
/**
|
|
1440
|
-
* Notifai handlers a previous build left in Project-scoped files.
|
|
1441
|
-
*
|
|
1442
|
-
* These are never an install target and never a fallback: they are residue to
|
|
1443
|
-
* report and remove once the Machine installation is proven current. Foreign
|
|
1444
|
-
* handlers sharing those files are not returned and are never touched.
|
|
1445
|
-
*/
|
|
1446
|
-
export function findLegacyProjectInstallations(cwd, env = process.env, adapterHome, platform = process.platform) {
|
|
1447
|
-
const machine = new Set(HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => machineHookFiles(harness, env, platform).map((file) => path.resolve(file))));
|
|
1448
|
-
return HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => collectInstallations(harness,
|
|
1449
|
-
// A Project checkout that happens to be the harness home would otherwise
|
|
1450
|
-
// report the Machine installation as legacy residue and delete it.
|
|
1451
|
-
legacyProjectHookFiles(harness, cwd).filter((file) => !machine.has(path.resolve(file))), adapterHome, platform, env));
|
|
1452
|
-
}
|
|
1453
|
-
function collectInstallations(harness, files, adapterHome, platform, env) {
|
|
1454
|
-
const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath;
|
|
1455
|
-
const commandOptions = {
|
|
1456
|
-
platform,
|
|
1457
|
-
...(nodePath === undefined ? {} : { nodePath }),
|
|
1458
|
-
};
|
|
1459
|
-
const found = [];
|
|
1460
|
-
for (const file of files) {
|
|
1461
|
-
if (!existsSync(file))
|
|
1462
|
-
continue;
|
|
1463
|
-
if (harness === 'hermes') {
|
|
1464
|
-
if (!isOurHermesPlugin(path.dirname(file)))
|
|
1465
|
-
continue;
|
|
1466
|
-
found.push({
|
|
1467
|
-
harness,
|
|
1468
|
-
file,
|
|
1469
|
-
handlers: [],
|
|
1470
|
-
...(hermesPluginCurrent(adapterHome, env) ? {} : {
|
|
1471
|
-
problems: ['Hermes plugin does not match the current Notifai adapter; rerun `notifai hooks install --harness hermes`'],
|
|
1472
|
-
}),
|
|
1473
|
-
});
|
|
1474
|
-
continue;
|
|
1475
|
-
}
|
|
1476
|
-
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
1477
|
-
// is reported as one installation covering all three events rather than
|
|
1478
|
-
// parsed for handlers.
|
|
1479
|
-
if (harness === 'opencode' || harness === 'openclaw') {
|
|
1480
|
-
let source;
|
|
1481
|
-
try {
|
|
1482
|
-
source = readOwnedRegularFile(file);
|
|
1483
|
-
}
|
|
1484
|
-
catch {
|
|
1485
|
-
continue;
|
|
1486
|
-
}
|
|
1487
|
-
const target = harness === 'openclaw' ? openclawPluginTarget(source) : opencodePluginTarget(source);
|
|
1488
|
-
if (target === null)
|
|
1489
|
-
continue;
|
|
1490
|
-
const label = harness === 'openclaw' ? 'OpenClaw' : 'OpenCode';
|
|
1491
|
-
const problems = [
|
|
1492
|
-
...(!target.current
|
|
1493
|
-
? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
|
|
1494
|
-
: []),
|
|
1495
|
-
...(target.adapter !== hookAdapterPath(adapterHome)
|
|
1496
|
-
? [
|
|
1497
|
-
`${label} still names a mutable CLI or runtime path; rerun \`notifai hooks install --harness ${harness}\``,
|
|
1498
|
-
]
|
|
1499
|
-
: []),
|
|
1500
|
-
];
|
|
1501
|
-
const events = harness === 'openclaw' ? OPENCLAW_EVENTS : OPENCODE_EVENTS;
|
|
1502
|
-
found.push({
|
|
1503
|
-
harness,
|
|
1504
|
-
file,
|
|
1505
|
-
...(problems.length > 0 ? { problems } : {}),
|
|
1506
|
-
handlers: events.map(([event, hookEvent]) => ({
|
|
1507
|
-
event,
|
|
1508
|
-
groupIndex: 0,
|
|
1509
|
-
handlerIndex: 0,
|
|
1510
|
-
command: hookCommand(target.adapter, hookEvent, harness, {
|
|
1511
|
-
...commandOptions,
|
|
1512
|
-
...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
|
|
1513
|
-
}),
|
|
1514
|
-
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
1515
|
-
})),
|
|
1516
|
-
});
|
|
1517
|
-
continue;
|
|
1518
|
-
}
|
|
1519
|
-
if (harness === 'cursor') {
|
|
1520
|
-
let document;
|
|
1521
|
-
try {
|
|
1522
|
-
document = readCursorSettings(file);
|
|
1523
|
-
}
|
|
1524
|
-
catch {
|
|
1525
|
-
continue;
|
|
1526
|
-
}
|
|
1527
|
-
const handlers = locateCursorHandlers(document);
|
|
1528
|
-
if (handlers.length > 0) {
|
|
1529
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
1530
|
-
found.push({ harness, file, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
1531
|
-
}
|
|
1532
|
-
continue;
|
|
1533
|
-
}
|
|
1534
|
-
let document;
|
|
1535
|
-
try {
|
|
1536
|
-
document = loadSettings(file);
|
|
1537
|
-
}
|
|
1538
|
-
catch {
|
|
1539
|
-
continue;
|
|
1540
|
-
}
|
|
1541
|
-
const handlers = locateHandlers(document);
|
|
1542
|
-
if (handlers.length > 0) {
|
|
1543
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
1544
|
-
found.push({ harness, file, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
return found;
|
|
1548
|
-
}
|
|
1549
|
-
function harnessMarkerProblems(harness, handlers, adapterHome, options = {}) {
|
|
1550
|
-
const problems = [];
|
|
1551
|
-
if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
|
|
1552
|
-
problems.push(`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``);
|
|
1553
|
-
}
|
|
1554
|
-
const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `;
|
|
1555
|
-
if (!handlers.every((handler) => handler.command.startsWith(expected))) {
|
|
1556
|
-
problems.push('installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter');
|
|
1557
|
-
}
|
|
1558
|
-
return problems;
|
|
1559
|
-
}
|
|
1560
|
-
function locateCursorHandlers(document) {
|
|
1561
|
-
const handlers = [];
|
|
1562
|
-
for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
|
|
1563
|
-
eventHandlers.forEach((handler, handlerIndex) => {
|
|
1564
|
-
if (isNotifaiCommand(handler.command)) {
|
|
1565
|
-
handlers.push({
|
|
1566
|
-
event,
|
|
1567
|
-
groupIndex: 0,
|
|
1568
|
-
handlerIndex,
|
|
1569
|
-
command: handler.command,
|
|
1570
|
-
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
1571
|
-
});
|
|
1572
|
-
}
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
return handlers;
|
|
1576
|
-
}
|
|
1577
|
-
function locateAllHandlers(document) {
|
|
1578
|
-
const handlers = [];
|
|
1579
|
-
for (const [event, groups] of Object.entries(document.hooks ?? {})) {
|
|
1580
|
-
if (!Array.isArray(groups))
|
|
1581
|
-
continue;
|
|
1582
|
-
groups.forEach((group, groupIndex) => {
|
|
1583
|
-
group.hooks?.forEach((handler, handlerIndex) => {
|
|
1584
|
-
if (typeof handler?.command !== 'string')
|
|
1585
|
-
return;
|
|
1586
|
-
handlers.push({
|
|
1587
|
-
event,
|
|
1588
|
-
groupIndex,
|
|
1589
|
-
handlerIndex,
|
|
1590
|
-
command: handler.command,
|
|
1591
|
-
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
1592
|
-
...(handler.async === undefined ? {} : { async: handler.async }),
|
|
1593
|
-
...(handler.asyncRewake === undefined ? {} : { asyncRewake: handler.asyncRewake }),
|
|
1594
|
-
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
1595
|
-
...(typeof handler.additionalContextLimit === 'number'
|
|
1596
|
-
? { additionalContextLimit: handler.additionalContextLimit }
|
|
1597
|
-
: {}),
|
|
1598
|
-
});
|
|
1599
|
-
});
|
|
1600
|
-
});
|
|
1601
|
-
}
|
|
1602
|
-
return handlers;
|
|
1603
|
-
}
|
|
1604
|
-
function locateHandlers(document) {
|
|
1605
|
-
return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command));
|
|
1606
|
-
}
|
|
1607
|
-
/** The hook event a handler's command actually invokes, e.g. `stop`. */
|
|
1608
|
-
export function handlerEvent(command) {
|
|
1609
|
-
return / hook ([a-z-]+)/.exec(command)?.[1] ?? null;
|
|
1610
|
-
}
|
|
1611
|
-
function readOwnedRegularFile(file) {
|
|
1612
|
-
const stat = lstatSync(file);
|
|
1613
|
-
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1614
|
-
throw new Error(`${file} is not a regular file${stat.isSymbolicLink() ? ' (symlink)' : ''}; refusing to read it.`);
|
|
1615
|
-
}
|
|
1616
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
1617
|
-
if (uid !== undefined && stat.uid !== uid) {
|
|
1618
|
-
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
|
|
1619
|
-
}
|
|
1620
|
-
return readFileSync(file, 'utf8');
|
|
1621
|
-
}
|
|
1622
|
-
//# sourceMappingURL=install-hooks.js.map
|