@raidiant/notifai 10.1.0 → 10.1.2

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.
Files changed (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
@@ -1,2268 +0,0 @@
1
- import {
2
- AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
3
- CAPABILITIES_V1,
4
- QUESTION_TEXT_MAX_LENGTH,
5
- REPLY_MAX_QUESTIONS,
6
- validateDraft,
7
- type NotificationDraftT,
8
- type QuestionT,
9
- } from '@raidiant/notifai-protocol'
10
- import {
11
- existsSync,
12
- lstatSync,
13
- mkdirSync,
14
- readFileSync,
15
- readdirSync,
16
- rmdirSync,
17
- rmSync,
18
- } from 'node:fs'
19
- import path from 'node:path'
20
- import { atomicWriteFileSync } from './atomic-file.js'
21
- import { claudeWakeRoute } from './claude-wake.js'
22
- import { ApiCallError } from './client.js'
23
- import { codexWakeRoute } from './codex-wake.js'
24
- import { loadConfig, type CliConfig } from './config.js'
25
- import { withTargetFileLock } from './file-lock.js'
26
- import {
27
- HARNESS_LABELS,
28
- HERMES_QUESTION_ROUTING_UNAVAILABLE,
29
- HOOK_INSTALLABLE_HARNESSES,
30
- isHookInstallableHarness,
31
- questionRoutingCapability,
32
- type HookInstallableHarness,
33
- } from './harnesses.js'
34
- import {
35
- inspectHookAdapter,
36
- installHookAdapter,
37
- isNpxAdapterTarget,
38
- type HookAdapterTarget,
39
- } from './hook-adapter.js'
40
- import {
41
- clearAcknowledgementObligation,
42
- claimCursorStopActivation,
43
- confirmCursorStopActivation,
44
- dropPendingQuestion,
45
- handleSessionEnd,
46
- handleStop,
47
- handleUserPromptSubmit,
48
- recordSessionStart,
49
- parkForRetirement,
50
- parseHookInput,
51
- pruneAbandonedSessions,
52
- readSessionState,
53
- resetCursorStopActivation,
54
- registerQuestion,
55
- withdrawUnpushedQuestions,
56
- type EscalationDeliveryRoute,
57
- type HookContext,
58
- type HookHarness,
59
- type PendingQuestion,
60
- } from './hooks.js'
61
- import {
62
- NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
63
- applyPlan,
64
- buildCursorHookConfig,
65
- buildHookConfig,
66
- cleanupEmptiedCodexLayer,
67
- codexCoexistenceNotes,
68
- codexHomeNote,
69
- codexLayerDir,
70
- codexLayerPaths,
71
- codexRepresentationProblems,
72
- codexTrustProblems,
73
- detectHarness,
74
- detectedHarnesses,
75
- findInstallations,
76
- handlerEvent,
77
- hookDefinitionFiles,
78
- loadCursorSettings,
79
- loadSettings,
80
- mergeCursorHooks,
81
- mergeHooks,
82
- removeCursorHooks,
83
- removeHooks,
84
- settingsFile,
85
- withCodexLayerTransaction,
86
- type Installation,
87
- } from './install-hooks.js'
88
- import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
89
- import { inferInvocationContext } from './invocation-context.js'
90
- import { logConfigResolved, logSettingsFrom } from './logging.js'
91
- import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js'
92
- import {
93
- OPENCLAW_PLUGIN_MANIFEST,
94
- OPENCLAW_PLUGIN_PACKAGE,
95
- isOurOpenclawPlugin,
96
- mergeOpenclawNotifaiEntry,
97
- openclawConfigPath,
98
- openclawPluginManifest,
99
- openclawPluginPackage,
100
- openclawPluginSource,
101
- parseOpenclawConfig,
102
- removeOpenclawNotifaiEntry,
103
- } from './openclaw-plugin.js'
104
- import { packageVersion } from './release.js'
105
- import { enableProject, projectBinding, projectEnabled } from './project-enablement.js'
106
- import { rejectAccidentalEscapedNewlines } from './send.js'
107
- import {
108
- cursorStopActivationOutput,
109
- sessionActivationOutput,
110
- } from './session-activation.js'
111
- import {
112
- CHOICE_USAGE,
113
- buildDraft,
114
- parseChoices,
115
- slugify,
116
- validateMediaInputs,
117
- type DraftInvocation,
118
- } from './send.js'
119
- import {
120
- EXIT,
121
- SETUP_COMMAND,
122
- authedClient,
123
- diagnoseIgnoredOriginOverride,
124
- loadLoggedConfig,
125
- log,
126
- makeClient,
127
- rejectedPaths,
128
- reportError,
129
- type CommandDeps,
130
- } from './commands-core.js'
131
- import {
132
- claudeSessionPid,
133
- resolveActiveHarness,
134
- type ActiveHarnessSession,
135
- } from './commands-harness-context.js'
136
- import { stopShapeProblems } from './commands-hook-shape.js'
137
- import {
138
- acknowledgementCommand,
139
- printAcknowledgementStatus,
140
- resolveDraftInvocation,
141
- uploadImage,
142
- waitForReply,
143
- } from './commands-send-support.js'
144
- import { resolveCommandSession } from './command-session.js'
145
-
146
- // ---------------------------------------------------------------------------
147
- // hook / ask / close — harness integration
148
- // ---------------------------------------------------------------------------
149
-
150
- export const HOOK_EVENTS = [
151
- 'session-start',
152
- 'subagent-start',
153
- 'activation-stop',
154
- 'user-prompt-submit',
155
- 'stop',
156
- 'session-end',
157
- ] as const
158
- export type HookEvent = (typeof HOOK_EVENTS)[number]
159
-
160
- /** Lifecycle handlers one installed harness must carry in this CLI build. */
161
- export function requiredHookEvents(harness: HookInstallableHarness): readonly HookEvent[] {
162
- if (harness === 'opencode' || harness === 'openclaw') return []
163
- return harness === 'cursor'
164
- ? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
165
- : ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end']
166
- }
167
-
168
- /** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
169
- export function hookDefersDiagnosticsUntilAfterCleanup(
170
- event: unknown,
171
- ): event is 'session-end' {
172
- return event === 'session-end'
173
- }
174
-
175
- /**
176
- * Runs one harness hook. Contract with every harness: hook JSON arrives on
177
- * stdin, harness output (if any) goes to stdout, diagnostics go to stderr, and
178
- * exit 0 with no stdout means "no decision or added context — carry on as normal".
179
- *
180
- * Every failure path in here must reach that no-decision state. A hook that
181
- * throws, or that blocks past the harness's timeout, degrades the agent for a
182
- * feature the user only asked to make it more convenient.
183
- */
184
- export async function hookRunCommand(
185
- deps: CommandDeps,
186
- event: string,
187
- readStdin: () => Promise<string>,
188
- harness?: HookHarness,
189
- ): Promise<number> {
190
- if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
191
- deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
192
- return EXIT.usage
193
- }
194
-
195
- // One clock owns the complete Stop invocation, including stdin, config,
196
- // credentials, and client construction. Starting this inside `handleStop`
197
- // would grant slow setup a second budget and let the harness kill us before
198
- // an accepted answer is journaled or written to stdout.
199
- const now = deps.now ?? Date.now
200
- // One owner lifetime covers startup and the longest answer window. Claude
201
- // runs it detached; Codex holds the turn. The delivery mechanism does not
202
- // change how long the exact Agent Session remains reachable.
203
- const processDeadlineAt = now() + QUESTION_WAITER_CEILING_SECONDS * 1000
204
-
205
- const logger = log(deps)
206
- logger.bind({ cmd: `hook ${event}` })
207
- let started = false
208
- const start = (data: Record<string, unknown> = {}): void => {
209
- if (started) return
210
- started = true
211
- logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data })
212
- }
213
- const failureData = (err: unknown): Record<string, unknown> =>
214
- err instanceof ApiCallError
215
- ? { status: err.status, code: err.code, message: err.message, details: err.details }
216
- : { message: err instanceof Error ? err.message : String(err) }
217
-
218
- // Cursor may load ~/.claude/settings.json in addition to its own native
219
- // hooks. Cursor guarantees CURSOR_PROJECT_DIR to hook processes, so a Claude
220
- // compatibility copy becomes a no-op and the native Cursor definition is
221
- // the single owner. Real Claude hooks do not receive that hook-only marker.
222
- if (harness === 'claude-code' && deps.env['CURSOR_PROJECT_DIR']) {
223
- start({ outcome: 'cursor-compatibility-copy-skipped' })
224
- logger.info('hook.end', {
225
- hook: event,
226
- outcome: 'ignored',
227
- reason: 'cursor-native-handler-owns-event',
228
- decided: false,
229
- })
230
- return EXIT.ok
231
- }
232
-
233
- let raw: string
234
- try {
235
- raw = await readStdin()
236
- } catch (err) {
237
- start({ input: 'unavailable' })
238
- logger.error('hook.end', {
239
- hook: event,
240
- outcome: 'ignored',
241
- reason: 'input-read-failed',
242
- ...failureData(err),
243
- })
244
- return EXIT.ok
245
- }
246
-
247
- if (raw.trim() !== '') {
248
- try {
249
- const parsed: unknown = JSON.parse(raw)
250
- if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
251
- } catch {
252
- start({ input: 'malformed' })
253
- logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' })
254
- deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
255
- return EXIT.ok
256
- }
257
- }
258
-
259
- let envelope = parseHookInput(raw)
260
- if (harness === 'cursor') {
261
- const sessionId = envelope.session_id ?? envelope.conversation_id
262
- const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
263
- envelope = {
264
- ...envelope,
265
- ...(sessionId === undefined ? {} : { session_id: sessionId }),
266
- ...(cwd === undefined ? {} : { cwd }),
267
- stop_hook_active:
268
- envelope.stop_hook_active ??
269
- (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
270
- }
271
- }
272
-
273
- const cwd = envelope.cwd ?? deps.cwd
274
- const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
275
- logger.bind({ session: envelope.session_id ?? null })
276
- const lifecycleEnabled = (): boolean => {
277
- try {
278
- const activationConfig = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
279
- return projectEnabled(projectBinding(cwd, deps.env, activationConfig.project.value))
280
- } catch (err) {
281
- logger.error('hook.end', { hook: event, outcome: 'enablement-unavailable', ...failureData(err) })
282
- return false
283
- }
284
- }
285
- // Installation only makes lifecycle hooks available. Model-visible
286
- // activation is a separate User-owned Project decision, checked anew on
287
- // every run so disabling takes effect without reinstalling anything.
288
- if (event === 'session-start' || event === 'subagent-start') {
289
- start({ cwd, source: envelope.source ?? null })
290
- if (!lifecycleEnabled()) {
291
- logger.info('hook.end', {
292
- hook: event,
293
- outcome: 'ignored',
294
- reason: 'project-disabled',
295
- decided: false,
296
- })
297
- return EXIT.ok
298
- }
299
- if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
300
- try {
301
- resetCursorStopActivation(envelope.session_id, deps.env)
302
- } catch (err) {
303
- logger.error('hook.end', {
304
- hook: event,
305
- outcome: 'reset-failed',
306
- ...failureData(err),
307
- })
308
- }
309
- }
310
- const stdout = sessionActivationOutput(
311
- harness,
312
- event === 'session-start' ? 'SessionStart' : 'SubagentStart',
313
- cwd,
314
- deps.env,
315
- )
316
- if (stdout !== undefined) deps.io.out(stdout)
317
- if (event === 'session-start' && envelope.session_id !== undefined) {
318
- try {
319
- recordSessionStart(envelope.session_id, deps.env, harness, cwd)
320
- } catch (err) {
321
- logger.error('hook.end', {
322
- hook: event,
323
- outcome: 'record-failed',
324
- reason: 'session-state-failed',
325
- ...failureData(err),
326
- })
327
- }
328
- }
329
- logger.info('hook.end', {
330
- hook: event,
331
- outcome: stdout === undefined ? 'unsupported-harness' : 'context-added',
332
- decided: false,
333
- })
334
- return EXIT.ok
335
- }
336
-
337
- // Cursor has a confirmed host bug in which sessionStart.additional_context
338
- // is accepted but never reaches the model. A native Stop follow-up is the
339
- // host's guaranteed model-visible channel. Claim it once per conversation,
340
- // before config/auth/network, and leave the ordinary Stop handler separate
341
- // so question delivery is never displaced by activation.
342
- if (event === 'activation-stop') {
343
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
344
- if (harness !== 'cursor') {
345
- logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false })
346
- return EXIT.ok
347
- }
348
- if (!lifecycleEnabled()) {
349
- logger.info('hook.end', {
350
- hook: event,
351
- outcome: 'ignored',
352
- reason: 'project-disabled',
353
- decided: false,
354
- })
355
- return EXIT.ok
356
- }
357
- if (envelope.status === 'aborted') {
358
- logger.info('hook.end', {
359
- hook: event,
360
- outcome: 'ignored',
361
- reason: `turn-${envelope.status}`,
362
- decided: false,
363
- })
364
- return EXIT.ok
365
- }
366
- if (envelope.session_id === undefined) {
367
- logger.info('hook.end', {
368
- hook: event,
369
- outcome: 'ignored',
370
- reason: 'missing-conversation-id',
371
- decided: false,
372
- })
373
- return EXIT.ok
374
- }
375
- if (
376
- envelope.session_id !== undefined &&
377
- typeof envelope.loop_count === 'number' &&
378
- envelope.loop_count > 0
379
- ) {
380
- let activationOwned = false
381
- try {
382
- activationOwned = confirmCursorStopActivation(envelope.session_id, deps.env, now())
383
- } catch (err) {
384
- logger.error('hook.end', {
385
- hook: event,
386
- outcome: 'confirm-failed',
387
- ...failureData(err),
388
- })
389
- }
390
- if (activationOwned) {
391
- logger.info('hook.end', { hook: event, outcome: 'confirmed', decided: false })
392
- return EXIT.ok
393
- }
394
- }
395
- const cursorState = readSessionState(envelope.session_id, deps.env)
396
- if (
397
- (cursorState.pending?.length ?? 0) > 0 ||
398
- cursorState.accepted !== undefined ||
399
- (cursorState.acknowledgement_due?.length ?? 0) > 0
400
- ) {
401
- logger.info('hook.end', {
402
- hook: event,
403
- outcome: 'deferred',
404
- reason: 'question-continuation-owns-stop',
405
- decided: false,
406
- })
407
- return EXIT.ok
408
- }
409
- let claimed = false
410
- try {
411
- claimed = claimCursorStopActivation(envelope.session_id, deps.env, now())
412
- } catch (err) {
413
- logger.error('hook.end', {
414
- hook: event,
415
- outcome: 'claim-failed',
416
- ...failureData(err),
417
- })
418
- }
419
- logger.info('hook.end', {
420
- hook: event,
421
- outcome: claimed ? 'followup-added' : 'already-activated',
422
- decided: claimed,
423
- })
424
- if (claimed) deps.io.out(cursorStopActivationOutput(cwd, deps.env))
425
- return EXIT.ok
426
- }
427
-
428
- let config: CliConfig | null = null
429
- let configFailure: unknown
430
- try {
431
- config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
432
- // The hook's project is the session's, not this process's, and the log
433
- // settings that apply are that project's too. Keeping a mutable bootstrap
434
- // logger lets this more-specific layer turn logging back on.
435
- logger.adopt(logSettingsFrom(config))
436
- logger.bind({ project: config.project.value })
437
- if (!sessionEnd) {
438
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
439
- logConfigResolved(logger, config)
440
- }
441
- } catch (err) {
442
- configFailure = err
443
- if (!sessionEnd) {
444
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
445
- logger.error('hook.end', {
446
- hook: event,
447
- outcome: 'failed',
448
- reason: 'config-failed',
449
- ...failureData(err),
450
- })
451
- for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
452
- return EXIT.ok
453
- }
454
- }
455
-
456
- // Everything below is inside one fail-open boundary. Credential loading,
457
- // client construction and hook handling can all throw, and a hook that exits
458
- // non-zero makes the harness report a failure — strictly worse than skipping.
459
- try {
460
- if (sessionEnd) {
461
- // Codex gives SessionEnd one second total. Do every durable cleanup write
462
- // before lifecycle diagnostics: the log lock is deliberately allowed to
463
- // wait that long, and a busy log must never preserve ended-session state
464
- // or its inherited configuration. The resolved config above is retained
465
- // in memory so logging still uses the ending session's settings afterwards.
466
- const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)())
467
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
468
- if (config !== null) logConfigResolved(logger, config)
469
- const data = { hook: event, decided: false, ...outcome.log }
470
- if (configFailure === undefined) logger.info('hook.end', data)
471
- else {
472
- logger.error('hook.end', {
473
- ...data,
474
- reason: 'config-failed',
475
- config_error: failureData(configFailure),
476
- })
477
- }
478
- for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
479
- return EXIT.ok
480
- }
481
-
482
- // Non-SessionEnd hooks cannot reach here without resolved configuration.
483
- const resolved = config!
484
- const credential = deps.store.load()
485
- if (!credential) {
486
- logger.error('hook.end', { hook: event, outcome: 'not-paired' })
487
- deps.io.err(`notifai: hook skipped: this machine is not paired; run \`${SETUP_COMMAND}\``)
488
- return EXIT.ok
489
- }
490
- // Pin authenticated traffic to the origin the credential was issued for. A
491
- // repository can commit `.notifai/config.toml`, and honouring a base_url
492
- // from it would hand this machine's bearer token to whatever host it names.
493
- const baseUrl = credential.baseUrl
494
- diagnoseIgnoredOriginOverride(deps.io, resolved, credential)
495
- // UserPromptSubmit runs in front of the user's own prompt under a 15s
496
- // harness ceiling and can make two calls, so each gets a small slice of it;
497
- // Stop is allowed to block and keeps the ordinary budget.
498
- const client = makeClient(
499
- deps,
500
- baseUrl,
501
- `Bearer nfm_${credential.machineId}.${credential.secret}`,
502
- {
503
- timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
504
- ...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
505
- },
506
- )
507
- const ctx: HookContext = {
508
- client,
509
- config: resolved,
510
- env: deps.env,
511
- now,
512
- sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
513
- waitForFirstReply: async (requestId, timeoutSeconds) => {
514
- const result = await waitForReply(client, requestId, {
515
- timeoutSeconds,
516
- afterSeq: 0,
517
- now: deps.now,
518
- sleep: deps.sleep,
519
- })
520
- return {
521
- replies: result.response.replies,
522
- timedOut: result.timedOut,
523
- degraded: result.degraded,
524
- }
525
- },
526
- log: logger,
527
- ...(harness === undefined ? {} : { harness }),
528
- }
529
-
530
- // Real clock, deliberately, not `deps.now`. This compares against file
531
- // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
532
- // would have it delete live session state as "abandoned".
533
- // Daily state pruning is housekeeping, not part of the Stop delivery
534
- // contract. Its directory scan has no useful bound, so keep it on the
535
- // short prompt path and never spend the answer owner's finite budget on it.
536
- if (event !== 'stop') pruneAbandonedSessions(deps.env)
537
-
538
- const outcome =
539
- event === 'user-prompt-submit'
540
- ? await handleUserPromptSubmit(ctx, envelope)
541
- : await handleStop(
542
- ctx,
543
- envelope,
544
- processDeadlineAt,
545
- stopWakeRoute(deps, harness, envelope.session_id, cwd),
546
- )
547
- // Answer diagnostics are already persisted once as hook.answer. Keep every
548
- // other note in the lifecycle record without duplicating the user's text.
549
- const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
550
- logger.info('hook.end', {
551
- hook: event,
552
- decided: outcome.decided ?? outcome.stdout !== undefined,
553
- ...(notes.length === 0 ? {} : { notes }),
554
- ...outcome.log,
555
- })
556
- for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
557
- if (outcome.stdout !== undefined) {
558
- // No work, await, or diagnostic may sit between this cross-process
559
- // SessionEnd fence and the irreversible harness stdout write.
560
- if (outcome.commitStdout === undefined || outcome.commitStdout()) {
561
- deps.io.out(outcome.stdout)
562
- } else {
563
- deps.io.err('notifai: the Agent Session ended before answer delivery; no continuation was written')
564
- }
565
- }
566
- return EXIT.ok
567
- } catch (err) {
568
- // SessionEnd defers its start record until after cleanup; if cleanup itself
569
- // fails, begin the after-the-fact lifecycle here before recording why.
570
- if (sessionEnd) start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
571
- // The hook still exits 0 — handing the terminal back is always right. What
572
- // this adds is that the reason survives, including the server's own words.
573
- logger.error('hook.end', {
574
- hook: event,
575
- outcome: 'failed',
576
- reason: 'execution-failed',
577
- ...failureData(err),
578
- })
579
- for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
580
- return EXIT.ok
581
- }
582
- }
583
-
584
- /**
585
- * The last meter for an answer this Stop hook accepted, chosen by harness.
586
- *
587
- * Both wake adapters need the harness process that invoked this hook: Claude's
588
- * to prove exact own-child session ownership before it posts to the inbox
589
- * socket, Codex's to know whether its own stdout is still a live continuation
590
- * channel. Without an exact session id neither can prove anything, so the
591
- * waiter falls back to the plain blocking Stop continuation.
592
- */
593
- function stopWakeRoute(
594
- deps: CommandDeps,
595
- harness: HookInstallableHarness | undefined,
596
- sessionId: string | undefined,
597
- cwd: string,
598
- ): EscalationDeliveryRoute | undefined {
599
- if (sessionId === undefined) return undefined
600
- const declaredSourcePid = declaredHookSourcePid(deps)
601
- if (harness === 'claude-code') {
602
- if ((deps.hookPlatform ?? process.platform) === 'win32') return undefined
603
- return claudeWakeRoute({
604
- sessionId,
605
- cwd,
606
- sourcePid: deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env),
607
- ...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
608
- })
609
- }
610
- if (harness === 'codex') {
611
- return codexWakeRoute({
612
- threadId: sessionId,
613
- cwd,
614
- sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
615
- env: deps.env,
616
- ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
617
- })
618
- }
619
- return undefined
620
- }
621
-
622
- /** Stable harness parent propagated by the managed adapter across child tools. */
623
- function declaredHookSourcePid(deps: CommandDeps): number | undefined {
624
- const value = Number(deps.env['NOTIFAI_HOOK_SOURCE_PID'])
625
- return Number.isInteger(value) && value > 0 ? value : undefined
626
- }
627
-
628
-
629
- /**
630
- * What went wrong, in terms of what to do about it.
631
- *
632
- * On 2026-08-03 a contract change shipped without the server deploy that goes
633
- * with it. The CLI stamped `lifecycle` on every question draft, the deployed
634
- * server rejected the unknown field, and escalation stopped working entirely —
635
- * announced as "hook failed, deferring to the terminal", which reads like a
636
- * flaky network. The information needed to diagnose it in one second was
637
- * already in hand: a 422 whose details name the offending path. It was being
638
- * thrown away by `String(err)`.
639
- *
640
- * A hook still exits 0 whatever this says. Handing the terminal back is always
641
- * right; the only question is whether the user is told anything they can use.
642
- */
643
- export function describeHookFailure(err: unknown): string[] {
644
- if (!(err instanceof ApiCallError)) {
645
- return [`hook failed, deferring to the terminal (${String(err)})`]
646
- }
647
- const lines = [`hook failed, deferring to the terminal (${err.code}: ${err.message})`]
648
- const paths = rejectedPaths(err.details)
649
- if (paths.length > 0) lines.push(`the server rejected: ${paths.join(', ')}`)
650
- // A 422 on a draft this CLI built is not a user error — this CLI's own
651
- // contract produced it. Either the server is behind, or the two disagree.
652
- if (err.status === 422) {
653
- lines.push(
654
- 'this build sent a field the server did not accept, which usually means the server ' +
655
- 'is older than this CLI — check with `notifai doctor`',
656
- )
657
- }
658
- return lines
659
- }
660
-
661
-
662
- export interface AskFlags {
663
- /** Emit the registration and its turn obligation as one JSON object. */
664
- json?: boolean
665
- choice?: string[]
666
- /** The single question is multi-select: several answers may be chosen. */
667
- multi?: boolean
668
- /** Optional Markdown context appended after the question block. */
669
- body?: string
670
- /** Allow visible backslash-n sequences in `--body`. */
671
- literalBackslashN?: boolean
672
- /** Raw JSON for a multi-question form; replaces the positional question. */
673
- form?: string
674
- image?: string[]
675
- imageAlt?: string[]
676
- project?: string
677
- sessionLabel?: string
678
- }
679
-
680
- export interface AskFailure {
681
- ok: false
682
- registered: false
683
- code: string
684
- check_id: string
685
- exit_code: number
686
- remedy: string
687
- message: string
688
- }
689
-
690
- /** One stable failure contract for every machine-readable ask refusal. */
691
- export function reportAskFailure(
692
- deps: CommandDeps,
693
- flags: Pick<AskFlags, 'json'>,
694
- failure: Omit<AskFailure, 'ok' | 'registered'>,
695
- ): number {
696
- if (flags.json === true) {
697
- deps.io.out(JSON.stringify({ ok: false, registered: false, ...failure }, null, 2))
698
- } else {
699
- deps.io.err(failure.message)
700
- deps.io.err(`next: ${failure.remedy}`)
701
- }
702
- return failure.exit_code
703
- }
704
-
705
- function askFailure(
706
- deps: CommandDeps,
707
- flags: AskFlags,
708
- code: string,
709
- checkId: string,
710
- message: string,
711
- remedy: string,
712
- exitCode: number = EXIT.usage,
713
- ): number {
714
- return reportAskFailure(deps, flags, {
715
- code,
716
- check_id: checkId,
717
- exit_code: exitCode,
718
- remedy,
719
- message,
720
- })
721
- }
722
-
723
- /** The `--form` document: what an agent writes to ask several things at once. */
724
- interface AskFormQuestion {
725
- text: string
726
- choices?: string[]
727
- multi?: boolean
728
- }
729
-
730
- export interface BuiltQuestions {
731
- questions: QuestionT[]
732
- /**
733
- * Canonical Markdown body. The question already travels as the notification
734
- * title and as structured questions, so the body carries only the context —
735
- * repeating the question there put it on the lock screen and the reply
736
- * screen twice. Only when there is no context does the question text stand
737
- * in, because the wire requires a body.
738
- */
739
- body: string
740
- }
741
-
742
- /**
743
- * Turn ask input into questions plus their canonical body. Everything is
744
- * validated at registration because a later hook failure is easy to miss.
745
- */
746
- export function buildQuestions(
747
- flags: AskFlags,
748
- question: string | undefined,
749
- ): { ok: true; questions: QuestionT[]; body: string } | { ok: false; error: string } {
750
- if (flags.form !== undefined) {
751
- if (question !== undefined || flags.choice?.length || flags.multi) {
752
- return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
753
- }
754
- let parsed: unknown
755
- try {
756
- parsed = JSON.parse(flags.form)
757
- } catch {
758
- return {
759
- ok: false,
760
- error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
761
- }
762
- }
763
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
764
- return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
765
- }
766
- const record = parsed as Record<string, unknown>
767
- const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body')
768
- if (unknownKeys.length > 0) {
769
- return {
770
- ok: false,
771
- error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
772
- }
773
- }
774
- if (!Array.isArray(record['questions'])) {
775
- return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
776
- }
777
- const formQuestions = record['questions']
778
- if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
779
- return {
780
- ok: false,
781
- error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
782
- }
783
- }
784
- if (record['body'] !== undefined && typeof record['body'] !== 'string') {
785
- return { ok: false, error: '"body" must be a Markdown string.' }
786
- }
787
- if (flags.body !== undefined && record['body'] !== undefined) {
788
- return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' }
789
- }
790
- const questions: QuestionT[] = []
791
- const usedIds = new Set<string>()
792
- for (const [index, entry] of formQuestions.entries()) {
793
- if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
794
- return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
795
- }
796
- const spec = entry as AskFormQuestion
797
- const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds)
798
- if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
799
- questions.push(built.question)
800
- }
801
- const context = flags.body ?? (record['body'] as string | undefined)
802
- return {
803
- ok: true,
804
- questions,
805
- body:
806
- context !== undefined && context.trim() !== ''
807
- ? context
808
- : questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n'),
809
- }
810
- }
811
-
812
- if (question === undefined || question.trim() === '') {
813
- return { ok: false, error: 'The question cannot be empty.' }
814
- }
815
- const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
816
- if ('error' in built) return { ok: false, error: built.error }
817
- const context = flags.body
818
- return {
819
- ok: true,
820
- questions: [built.question],
821
- body: context !== undefined && context.trim() !== '' ? context : built.question.text,
822
- }
823
- }
824
-
825
- function buildOneQuestion(
826
- text: string,
827
- choiceLabels: string[] | undefined,
828
- multi: boolean,
829
- index: number,
830
- usedIds: Set<string>,
831
- ): { question: QuestionT } | { error: string } {
832
- const trimmed = text.trim()
833
- if (trimmed === '') return { error: 'the question text cannot be empty.' }
834
- if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
835
- return {
836
- error:
837
- `a question must be readable where it is answered: keep it within ` +
838
- `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
839
- }
840
- }
841
- const choices = parseChoices(choiceLabels)
842
- if (choices === 'invalid') return { error: CHOICE_USAGE }
843
- if (multi && choices === null) {
844
- return { error: '--multi needs answers to select between; add --choice.' }
845
- }
846
- let id = slugify(trimmed)
847
- if (id === '' || usedIds.has(id)) id = `q${index + 1}`
848
- usedIds.add(id)
849
- return {
850
- question: {
851
- id,
852
- text: trimmed,
853
- ...(choices !== null ? { choices } : {}),
854
- ...(multi ? { multi: true } : {}),
855
- },
856
- }
857
- }
858
-
859
- function buildAskDraft(
860
- config: CliConfig,
861
- built: BuiltQuestions,
862
- flags: AskFlags,
863
- invocation: DraftInvocation,
864
- mediaIds: string[],
865
- ): { ok: true; draft: NotificationDraftT } | { ok: false; error: string } {
866
- const result = buildDraft(
867
- config,
868
- {
869
- title: built.questions[0]!.text,
870
- body: built.body,
871
- ...(flags.project !== undefined ? { project: flags.project } : {}),
872
- ...(mediaIds.length > 0 ? { image: mediaIds } : {}),
873
- ...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
874
- reply: true,
875
- questions: built.questions,
876
- },
877
- invocation,
878
- )
879
- if (!result.ok) return result
880
- const capabilities = CAPABILITIES_V1.describe(result.platform)
881
- if (capabilities === null) return { ok: false, error: 'No iOS capability contract is available.' }
882
- const validation = validateDraft(result.draft, capabilities)
883
- if (!validation.ok) {
884
- return {
885
- ok: false,
886
- error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
887
- }
888
- }
889
- return { ok: true, draft: result.draft }
890
- }
891
-
892
- function recordRegisteredQuestion(
893
- deps: CommandDeps,
894
- sessionId: string,
895
- built: BuiltQuestions,
896
- draft: NotificationDraftT,
897
- json = false,
898
- ): number {
899
- let questionId: string
900
- try {
901
- questionId = registerQuestion(
902
- sessionId,
903
- deps.env,
904
- {
905
- question: built.questions[0]!.text,
906
- questions: built.questions,
907
- body: draft.presentation.body,
908
- ...(draft.project !== undefined ? { project: draft.project } : {}),
909
- ...(draft.source !== undefined ? { source: draft.source } : {}),
910
- ...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
911
- },
912
- (deps.now ?? Date.now)(),
913
- )
914
- } catch (err) {
915
- log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
916
- return askFailure(
917
- deps,
918
- { json },
919
- 'registration_failed',
920
- 'question_registration',
921
- `Could not register the question: ${err instanceof Error ? err.message : String(err)}`,
922
- 'retire an older pending question if the limit was reached, then retry the same ask',
923
- EXIT.failed,
924
- )
925
- }
926
- log(deps).info('ask.registered', {
927
- ok: true,
928
- session: sessionId,
929
- question_id: questionId,
930
- questions: built.questions.length,
931
- text_chars: built.questions[0]!.text.length,
932
- choices: built.questions[0]!.choices?.length ?? 0,
933
- media: draft.presentation.media?.length ?? 0,
934
- })
935
- // The block below is the densest guidance this CLI prints, and until now it
936
- // was prose only: an agent could not read back the choice ids it must branch
937
- // on without asking the server for them. The JSON form carries the same
938
- // obligation as data.
939
- if (json) {
940
- deps.io.out(
941
- JSON.stringify(
942
- {
943
- registered: true,
944
- question_id: questionId,
945
- questions: built.questions.map((entry) => ({
946
- id: entry.id,
947
- text: entry.text,
948
- ...(entry.choices === undefined ? {} : { choices: entry.choices }),
949
- ...(entry.multi === true ? { multi: true } : {}),
950
- })),
951
- close: `notifai close ${questionId}`,
952
- next: {
953
- end_turn: true,
954
- in_this_turn:
955
- 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
956
- route_neutral:
957
- 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
958
- on_answer:
959
- 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
960
- answered_outside_notifai: `If they answer in the conversation instead, run \`notifai close ${questionId}\` before ending the turn so a later Stop will not send this question.`,
961
- },
962
- },
963
- null,
964
- 2,
965
- ),
966
- )
967
- return EXIT.ok
968
- }
969
- for (const [index, entry] of built.questions.entries()) {
970
- const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
971
- if (entry.choices !== undefined) {
972
- const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
973
- deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
974
- } else if (built.questions.length > 1) {
975
- deps.io.out(`${prefix}${entry.text} — free text`)
976
- }
977
- }
978
- deps.io.out(
979
- built.questions.length > 1
980
- ? `${built.questions.length} questions registered as one form (${questionId}). Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
981
- : `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`,
982
- )
983
- deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
984
- for (const [index, entry] of built.questions.entries()) {
985
- const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
986
- if (entry.choices !== undefined) {
987
- for (const choice of entry.choices) {
988
- deps.io.out(
989
- `- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
990
- )
991
- }
992
- deps.io.out(
993
- `- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
994
- )
995
- } else {
996
- deps.io.out(
997
- `- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
998
- )
999
- }
1000
- }
1001
- deps.io.out(
1002
- 'When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.',
1003
- )
1004
- deps.io.out(
1005
- 'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
1006
- )
1007
- deps.io.out(
1008
- `If they answer in this conversation instead, retire it with \`notifai close ${questionId}\` so a later Stop will not send it.`,
1009
- )
1010
- return EXIT.ok
1011
- }
1012
-
1013
- async function uploadAskMedia(
1014
- deps: CommandDeps,
1015
- config: CliConfig,
1016
- sessionId: string,
1017
- built: BuiltQuestions,
1018
- flags: AskFlags,
1019
- invocation: DraftInvocation,
1020
- ): Promise<number> {
1021
- const authed = authedClient(deps, config)
1022
- if (!authed) {
1023
- return askFailure(
1024
- deps,
1025
- flags,
1026
- 'auth_required',
1027
- 'credential',
1028
- 'Question routing is not paired on this machine.',
1029
- 'run `notifai init --json`',
1030
- EXIT.auth,
1031
- )
1032
- }
1033
- const mediaIds: string[] = []
1034
- for (const image of flags.image ?? []) {
1035
- if (image.startsWith('med_')) {
1036
- mediaIds.push(image)
1037
- continue
1038
- }
1039
- const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
1040
- if (!uploaded.ok) {
1041
- if (uploaded.error !== null) deps.io.err(uploaded.error)
1042
- return askFailure(
1043
- deps,
1044
- flags,
1045
- 'media_upload_failed',
1046
- 'media',
1047
- uploaded.error ?? 'The image upload failed.',
1048
- 'fix the reported image or network problem, then retry the same ask',
1049
- uploaded.exit,
1050
- )
1051
- }
1052
- mediaIds.push(uploaded.mediaId)
1053
- }
1054
- const ready = buildAskDraft(config, built, flags, invocation, mediaIds)
1055
- if (!ready.ok) {
1056
- return askFailure(deps, flags, 'invalid_draft', 'draft', ready.error, 'fix the reported field and retry')
1057
- }
1058
- return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
1059
- }
1060
-
1061
- /**
1062
- * Registers a question for turn-end routing. Returns immediately so the agent
1063
- * can ask in prose and end its turn; the terminal keeps the question to itself
1064
- * for `ask_grace_seconds` before it reaches any device.
1065
- */
1066
- export function askCommand(
1067
- deps: CommandDeps,
1068
- question: string | undefined,
1069
- flags: AskFlags,
1070
- ): number | Promise<number> {
1071
- // Validate before route discovery. A malformed question belongs to the
1072
- // caller and should not be hidden behind whichever harness setup issue
1073
- // happens to exist on this machine.
1074
- const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
1075
- if (escapedBody !== null) {
1076
- return askFailure(deps, flags, 'invalid_input', 'body', escapedBody, 'fix the body and retry')
1077
- }
1078
- const built = buildQuestions(flags, question)
1079
- if (!built.ok) {
1080
- return askFailure(deps, flags, 'invalid_input', 'questions', built.error, 'fix the question input and retry')
1081
- }
1082
- const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
1083
- if (mediaInputError !== null) {
1084
- return askFailure(deps, flags, 'invalid_input', 'media', mediaInputError, 'fix the media flags and retry')
1085
- }
1086
- let explicitUseConfig: CliConfig
1087
- try {
1088
- explicitUseConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
1089
- } catch (err) {
1090
- return askFailure(deps, flags, 'config_invalid', 'configuration', `Question routing configuration is invalid: ${String(err)}`, 'fix the reported Notifai configuration and retry')
1091
- }
1092
- const explicitProject = flags.project ?? explicitUseConfig.project.value ?? inferInvocationContext(deps.cwd).project
1093
- if (explicitProject !== null) {
1094
- const binding = projectBinding(deps.cwd, deps.env, explicitProject)
1095
- if (binding !== null) enableProject(binding)
1096
- }
1097
- if (deps.store.load() === null) {
1098
- return askFailure(
1099
- deps,
1100
- flags,
1101
- 'auth_required',
1102
- 'credential',
1103
- 'Question routing is not paired on this machine.',
1104
- 'run `notifai init --json`',
1105
- EXIT.auth,
1106
- )
1107
- }
1108
- // An agent calling this gets no hook payload. Harness-native environment
1109
- // markers identify the active owner, while UserPromptSubmit adds the hook's
1110
- // canonical id to the directory's concurrent-session index.
1111
- const now = (deps.now ?? Date.now)()
1112
- const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
1113
- let sessionId: string | undefined
1114
- if (active !== null) {
1115
- if (contested.length > 1) {
1116
- return askFailure(
1117
- deps,
1118
- flags,
1119
- 'session_identity_ambiguous',
1120
- 'exact_session',
1121
- `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}).`,
1122
- 'run the ask from a shell with one exact active harness session',
1123
- )
1124
- }
1125
- if (!isHookInstallableHarness(active.harness)) {
1126
- const capability = HERMES_QUESTION_ROUTING_UNAVAILABLE
1127
- return askFailure(
1128
- deps,
1129
- flags,
1130
- 'question_routing_unavailable',
1131
- 'hook_contract',
1132
- `${active.label}: ${capability.deliveryContract}`,
1133
- 'use a blocking `notifai send --reply` question',
1134
- )
1135
- }
1136
- const exactState = active.sessionId === undefined
1137
- ? null
1138
- : readSessionState(active.sessionId, deps.env)
1139
- const installationCwd = exactState?.activation_cwd ?? deps.cwd
1140
- const installationDeps = installationCwd === deps.cwd ? deps : { ...deps, cwd: installationCwd }
1141
- const installations = findInstallations(installationCwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
1142
- const activeInstalled = installations.some(
1143
- (installation) => installation.harness === active.harness,
1144
- )
1145
- if (!activeInstalled) {
1146
- return askFailure(
1147
- deps,
1148
- flags,
1149
- 'hooks_not_installed',
1150
- 'hook_installation',
1151
- `Notifai ${active.label} hooks are not installed for this project.`,
1152
- `run \`notifai init --json\` from this ${active.label} session`,
1153
- )
1154
- }
1155
- if (active.sessionId === undefined) {
1156
- return askFailure(
1157
- deps,
1158
- flags,
1159
- 'session_identity_missing',
1160
- 'exact_session',
1161
- `The active ${active.label} shell does not expose an exact session id.`,
1162
- 'use a blocking `notifai send --reply` question',
1163
- )
1164
- }
1165
- const routeProblems = activeQuestionRouteProblems(installationDeps, active, installations)
1166
- if (routeProblems.length > 0) {
1167
- return askFailure(
1168
- deps,
1169
- flags,
1170
- 'question_routing_unavailable',
1171
- 'hook_contract',
1172
- `Question routing is not ready: ${routeProblems.join('; ')}`,
1173
- 'run `notifai init --json` and follow its question-routing remedy',
1174
- )
1175
- }
1176
- const state = exactState ?? readSessionState(active.sessionId, deps.env)
1177
- if (state.harness !== active.harness || state.last_prompt_at === undefined) {
1178
- return askFailure(
1179
- deps,
1180
- flags,
1181
- 'session_not_activated',
1182
- 'user_prompt_submit',
1183
- `This exact ${active.label} session has not fired UserPromptSubmit.`,
1184
- `send one prompt in this ${active.label} session, then retry \`notifai ask --json\``,
1185
- )
1186
- }
1187
- if (state.last_stop_at === undefined) {
1188
- return askFailure(
1189
- deps,
1190
- flags,
1191
- 'stop_not_observed',
1192
- 'stop_hook',
1193
- `This exact ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed.`,
1194
- `end one harmless turn, send a new prompt, then retry \`notifai ask --json\``,
1195
- )
1196
- }
1197
- sessionId = active.sessionId
1198
- } else {
1199
- return askFailure(
1200
- deps,
1201
- flags,
1202
- 'session_identity_missing',
1203
- 'exact_session',
1204
- 'Could not prove which live Agent Session owns this command.',
1205
- 'run it from a supported harness with exact Agent Session identity, or use a blocking `notifai send --reply` question',
1206
- )
1207
- }
1208
- if (!sessionId) {
1209
- return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active Agent Session is available.', 'use a blocking `notifai send --reply` question')
1210
- }
1211
- let routingConfig: CliConfig
1212
- try {
1213
- routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env, sessionId })
1214
- } catch (err) {
1215
- return askFailure(
1216
- deps,
1217
- flags,
1218
- 'config_invalid',
1219
- 'configuration',
1220
- `Question routing configuration is invalid: ${String(err)}`,
1221
- 'fix the reported Notifai configuration and retry',
1222
- )
1223
- }
1224
- if (!routingConfig.ask_notifications.value) {
1225
- return askFailure(
1226
- deps,
1227
- flags,
1228
- 'question_routing_disabled',
1229
- 'ask_notifications',
1230
- 'Question routing is disabled by ask_notifications=false.',
1231
- 'enable question routing or use a blocking `notifai send --reply` question',
1232
- )
1233
- }
1234
- const source = resolveDraftInvocation(deps, flags, active)
1235
- if (!source.ok) {
1236
- return askFailure(deps, flags, 'invalid_source', 'source_context', source.error, 'remove the conflicting source override and retry')
1237
- }
1238
- if (source.invocation.source?.session_id !== sessionId) {
1239
- return askFailure(
1240
- deps,
1241
- flags,
1242
- 'session_identity_mismatch',
1243
- 'exact_session',
1244
- `The inferred session does not match the exact active ${active.label} session.`,
1245
- 'remove NOTIFAI_SESSION_ID and retry from the exact active session',
1246
- )
1247
- }
1248
-
1249
- // Placeholders let every body, source, project, media, and payload limit fail
1250
- // before an upload starts. The real ids replace them only after this passes.
1251
- const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`)
1252
- const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders)
1253
- if (!preflight.ok) {
1254
- return askFailure(deps, flags, 'invalid_draft', 'draft', preflight.error, 'fix the reported field and retry')
1255
- }
1256
- if ((flags.image?.length ?? 0) > 0) {
1257
- return uploadAskMedia(
1258
- deps,
1259
- routingConfig,
1260
- sessionId,
1261
- built,
1262
- flags,
1263
- source.invocation,
1264
- )
1265
- }
1266
- return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true)
1267
- }
1268
-
1269
-
1270
- /**
1271
- * One fail-closed admission gate shared by every active harness route.
1272
- * Finding a file is not readiness: the exact session, one current definition,
1273
- * the stable adapter, a long enough Stop owner, and (for Codex) trust must all
1274
- * be true before `ask` is allowed to create an answerable notification.
1275
- */
1276
- export function activeQuestionRouteProblems(
1277
- deps: CommandDeps,
1278
- active: ActiveHarnessSession,
1279
- installations: Installation[],
1280
- ): string[] {
1281
- const problems: string[] = []
1282
- if (active.sessionId === undefined) {
1283
- problems.push(
1284
- `the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`,
1285
- )
1286
- }
1287
- const capability = isHookInstallableHarness(active.harness)
1288
- ? questionRoutingCapability(active.harness)
1289
- : HERMES_QUESTION_ROUTING_UNAVAILABLE
1290
- if (capability.stopContinuation === 'unsupported') {
1291
- problems.push(`${active.label}: ${capability.deliveryContract}`)
1292
- }
1293
- const matching = installations.filter(
1294
- (installation) => installation.harness === active.harness,
1295
- )
1296
- if (matching.length > 1) {
1297
- problems.push(
1298
- `${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`,
1299
- )
1300
- }
1301
- for (const installation of matching) {
1302
- for (const problem of installation.problems ?? []) {
1303
- problems.push(`${installation.file}: ${problem}`)
1304
- }
1305
- for (const handler of installation.handlers) {
1306
- const event = handlerEvent(handler.command)
1307
- if (event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)) {
1308
- problems.push(
1309
- `${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`,
1310
- )
1311
- }
1312
- }
1313
- }
1314
- for (const problem of inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform).problems) {
1315
- problems.push(problem)
1316
- }
1317
- for (const installation of matching) problems.push(...stopShapeProblems(installation, deps.hookPlatform))
1318
- problems.push(...codexTrustProblems(matching, deps.env))
1319
- return [...new Set(problems)]
1320
- }
1321
-
1322
- /** The least disruptive verified way to make each installed adapter run once. */
1323
- export function hookActivationAdvice(installations: Installation[]): string {
1324
- const harnesses = new Set(installations.map((installation) => installation.harness))
1325
- const advice: string[] = []
1326
- if (
1327
- installations.some(
1328
- (installation) => installation.harness === 'claude-code' && !installation.global,
1329
- )
1330
- ) {
1331
- advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`')
1332
- }
1333
- if (
1334
- installations.some(
1335
- (installation) => installation.harness === 'claude-code' && installation.global,
1336
- )
1337
- ) {
1338
- advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`')
1339
- }
1340
- if (
1341
- installations.some(
1342
- (installation) => installation.harness === 'cursor' && !installation.global,
1343
- )
1344
- ) {
1345
- advice.push(
1346
- 'Cursor: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`',
1347
- )
1348
- }
1349
- if (
1350
- installations.some(
1351
- (installation) => installation.harness === 'cursor' && installation.global,
1352
- )
1353
- ) {
1354
- advice.push('Cursor global hooks: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`')
1355
- }
1356
- if (harnesses.has('codex')) {
1357
- advice.push(
1358
- 'Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`',
1359
- )
1360
- }
1361
- if (harnesses.has('opencode')) {
1362
- advice.push(
1363
- 'OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
1364
- )
1365
- }
1366
- if (harnesses.has('openclaw')) {
1367
- advice.push(
1368
- 'OpenClaw: restart the Gateway, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
1369
- )
1370
- }
1371
- return `${advice.join('. ')}.`
1372
- }
1373
-
1374
- /**
1375
- * Record the one Agent Acknowledgement associated with a replied-to request.
1376
- *
1377
- * `--text` is optional here because the acknowledgement is not: an account may
1378
- * turn the agent's written reply off, and the receipt must still be recorded so
1379
- * the user sees that an agent read the answer. The service holds the account's
1380
- * snapshot, so it — not this process — decides whether text was owed.
1381
- */
1382
- export async function acknowledgeCommand(
1383
- deps: CommandDeps,
1384
- requestId: string,
1385
- flags: { text?: string; json?: boolean },
1386
- ): Promise<number> {
1387
- const text = flags.text?.trim() ?? ''
1388
- if (flags.text !== undefined && text.length === 0) {
1389
- deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.')
1390
- return EXIT.usage
1391
- }
1392
- if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
1393
- deps.io.err(
1394
- `--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`,
1395
- )
1396
- return EXIT.usage
1397
- }
1398
-
1399
- const logger = log(deps)
1400
- const lifecycleSession = resolveCommandSession(deps, requestId)
1401
- const config = loadLoggedConfig(deps, {
1402
- cwd: deps.cwd,
1403
- env: deps.env,
1404
- ...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
1405
- })
1406
- logger.info('acknowledgement.attempted', {
1407
- request_id: requestId,
1408
- characters: text.length,
1409
- })
1410
- const authed = authedClient(deps, config)
1411
- if (!authed) return EXIT.auth
1412
- try {
1413
- const result = await authed.client.putAgentAcknowledgement(
1414
- requestId,
1415
- text.length > 0 ? { text } : {},
1416
- )
1417
- const output = {
1418
- request_id: requestId,
1419
- outcome: result.status,
1420
- acknowledgement: result.agent_acknowledgement,
1421
- agent_acknowledgement_required: true,
1422
- }
1423
- logger.info('acknowledgement.outcome', {
1424
- request_id: requestId,
1425
- outcome: result.status,
1426
- text_chars: result.agent_acknowledgement.text.length,
1427
- created_at: result.agent_acknowledgement.created_at,
1428
- agent_acknowledgement_required: true,
1429
- })
1430
- if (lifecycleSession !== null) {
1431
- clearAcknowledgementObligation(lifecycleSession.sessionId, deps.env, requestId)
1432
- }
1433
- if (flags.json) deps.io.out(JSON.stringify(output, null, 2))
1434
- else {
1435
- deps.io.out(
1436
- `Agent Acknowledgement ${result.status} for ${requestId} at ${result.agent_acknowledgement.created_at}.`,
1437
- )
1438
- }
1439
- return EXIT.ok
1440
- } catch (err) {
1441
- return reportError(deps, err, { operation: 'agent_acknowledgement', request_id: requestId })
1442
- }
1443
- }
1444
-
1445
- /** Retire a question so a late answer is rejected rather than silently lost. */
1446
- export async function closeCommand(
1447
- deps: CommandDeps,
1448
- requestId: string | undefined,
1449
- flags: { json?: boolean; pending?: boolean } = {},
1450
- ): Promise<number> {
1451
- if (flags.pending === true && requestId !== undefined) {
1452
- deps.io.err('Pass a request id or --pending, not both.')
1453
- return EXIT.usage
1454
- }
1455
- if (flags.pending !== true && requestId === undefined) {
1456
- deps.io.err('Pass a request id or --pending.')
1457
- return EXIT.usage
1458
- }
1459
-
1460
- if (flags.pending === true) {
1461
- return closePendingQuestions(deps, flags.json === true)
1462
- }
1463
-
1464
- const local = await closeLocalQuestion(deps, requestId!, flags.json === true)
1465
- if (local !== null) return local
1466
-
1467
- const lifecycleSession = resolveCommandSession(deps, requestId!)
1468
- const config = loadLoggedConfig(deps, {
1469
- cwd: deps.cwd,
1470
- env: deps.env,
1471
- ...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
1472
- })
1473
- const authed = authedClient(deps, config)
1474
- if (!authed) return EXIT.auth
1475
- try {
1476
- const response = await authed.client.closeReplies(requestId!)
1477
- forgetClosedQuestion(deps, requestId!)
1478
- if (flags.json) {
1479
- deps.io.out(
1480
- JSON.stringify(
1481
- {
1482
- ...response,
1483
- acknowledgement_command: acknowledgementCommand(
1484
- response.request_id,
1485
- response.agent_acknowledgement_required,
1486
- response.agent_acknowledgement_text_required,
1487
- response.agent_acknowledgement,
1488
- response.replies.length > 0,
1489
- ),
1490
- },
1491
- null,
1492
- 2,
1493
- ),
1494
- )
1495
- } else {
1496
- deps.io.out(`Closed the reply window for ${requestId}.`)
1497
- if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
1498
- }
1499
- return EXIT.ok
1500
- } catch (err) {
1501
- return reportError(deps, err)
1502
- }
1503
- }
1504
-
1505
- /**
1506
- * Retire this session's outstanding questions, including registrations the
1507
- * Stop hook has not pushed yet. Unpushed entries only exist locally: dropping
1508
- * them is the whole retirement. Live ones still need their reply window closed.
1509
- */
1510
- async function closePendingQuestions(deps: CommandDeps, json: boolean): Promise<number> {
1511
- const lifecycleSession = resolveCommandSession(deps)
1512
- if (lifecycleSession === null) {
1513
- deps.io.err('No active session pointer is available in this directory.')
1514
- return EXIT.noReply
1515
- }
1516
- const sessionId = lifecycleSession.sessionId
1517
- const pending = readSessionState(sessionId, deps.env).pending ?? []
1518
- if (pending.length === 0) {
1519
- if (json) {
1520
- deps.io.out(
1521
- JSON.stringify({ session_id: sessionId, withdrawn: [], closed: [] }, null, 2),
1522
- )
1523
- } else {
1524
- deps.io.err(`Session ${sessionId} has no registered question pending.`)
1525
- }
1526
- return EXIT.noReply
1527
- }
1528
-
1529
- const withdrawn = withdrawUnpushedQuestions(sessionId, deps.env)
1530
- const live = (readSessionState(sessionId, deps.env).pending ?? []).filter(
1531
- (entry): entry is PendingQuestion & { request_id: string } =>
1532
- entry.request_id !== undefined,
1533
- )
1534
-
1535
- const closed: string[] = []
1536
- if (live.length > 0) {
1537
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId })
1538
- const authed = authedClient(deps, config)
1539
- if (!authed) return EXIT.auth
1540
- for (const entry of live) {
1541
- try {
1542
- await authed.client.closeReplies(entry.request_id)
1543
- parkClosedQuestion(sessionId, deps.env, entry)
1544
- dropPendingQuestion(sessionId, deps.env, entry)
1545
- closed.push(entry.request_id)
1546
- } catch (err) {
1547
- return reportError(deps, err, { operation: 'close', request_id: entry.request_id })
1548
- }
1549
- }
1550
- }
1551
-
1552
- const output = {
1553
- session_id: sessionId,
1554
- withdrawn: withdrawn.map((entry) => ({
1555
- question: entry.question,
1556
- ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
1557
- })),
1558
- closed,
1559
- }
1560
- if (json) deps.io.out(JSON.stringify(output, null, 2))
1561
- else {
1562
- if (withdrawn.length > 0) {
1563
- deps.io.out(
1564
- withdrawn.length === 1
1565
- ? 'Withdrew 1 unpushed question so a later Stop will not send it.'
1566
- : `Withdrew ${withdrawn.length} unpushed questions so a later Stop will not send them.`,
1567
- )
1568
- }
1569
- for (const requestId of closed) {
1570
- deps.io.out(`Closed the reply window for ${requestId}.`)
1571
- }
1572
- }
1573
- return EXIT.ok
1574
- }
1575
-
1576
- /**
1577
- * Close one outstanding question by the stable id `notifai ask` returned, or
1578
- * by a request id already on a device. Unpushed entries only exist locally.
1579
- * Returns null when this directory's session has no matching registration, so
1580
- * the caller can still close a live request id against the service.
1581
- */
1582
- async function closeLocalQuestion(
1583
- deps: CommandDeps,
1584
- id: string,
1585
- json: boolean,
1586
- ): Promise<number | null> {
1587
- const lifecycleSession = resolveCommandSession(deps, id)
1588
- if (lifecycleSession === null) return null
1589
- const sessionId = lifecycleSession.sessionId
1590
- const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
1591
- (candidate) => candidate.question_id === id || candidate.request_id === id,
1592
- )
1593
- if (entry === undefined) return null
1594
-
1595
- if (entry.request_id === undefined) {
1596
- dropPendingQuestion(sessionId, deps.env, entry)
1597
- const output = {
1598
- session_id: sessionId,
1599
- withdrawn: [
1600
- {
1601
- question: entry.question,
1602
- ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
1603
- },
1604
- ],
1605
- closed: [] as string[],
1606
- }
1607
- if (json) deps.io.out(JSON.stringify(output, null, 2))
1608
- else {
1609
- deps.io.out(
1610
- `Withdrew unpushed question ${entry.question_id ?? id} so a later Stop will not send it.`,
1611
- )
1612
- }
1613
- return EXIT.ok
1614
- }
1615
-
1616
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId })
1617
- const authed = authedClient(deps, config)
1618
- if (!authed) return EXIT.auth
1619
- try {
1620
- const response = await authed.client.closeReplies(entry.request_id)
1621
- parkClosedQuestion(sessionId, deps.env, entry)
1622
- dropPendingQuestion(sessionId, deps.env, entry)
1623
- if (json) {
1624
- deps.io.out(
1625
- JSON.stringify(
1626
- {
1627
- ...response,
1628
- question_id: entry.question_id,
1629
- acknowledgement_command: acknowledgementCommand(
1630
- response.request_id,
1631
- response.agent_acknowledgement_required,
1632
- response.agent_acknowledgement_text_required,
1633
- response.agent_acknowledgement,
1634
- response.replies.length > 0,
1635
- ),
1636
- },
1637
- null,
1638
- 2,
1639
- ),
1640
- )
1641
- } else {
1642
- deps.io.out(`Closed the reply window for ${entry.request_id}.`)
1643
- if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
1644
- }
1645
- return EXIT.ok
1646
- } catch (err) {
1647
- return reportError(deps, err, { operation: 'close', request_id: entry.request_id })
1648
- }
1649
- }
1650
-
1651
- function forgetClosedQuestion(deps: CommandDeps, requestId: string): void {
1652
- const lifecycleSession = resolveCommandSession(deps, requestId)
1653
- if (lifecycleSession === null) return
1654
- const sessionId = lifecycleSession.sessionId
1655
- const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
1656
- (candidate) => candidate.request_id === requestId,
1657
- )
1658
- if (entry === undefined) return
1659
- parkClosedQuestion(sessionId, deps.env, entry)
1660
- dropPendingQuestion(sessionId, deps.env, entry)
1661
- }
1662
-
1663
- function parkClosedQuestion(
1664
- sessionId: string,
1665
- env: NodeJS.ProcessEnv,
1666
- entry: PendingQuestion,
1667
- ): void {
1668
- try {
1669
- parkForRetirement(sessionId, env, entry, 'expired')
1670
- } catch {
1671
- // Incomplete identifiers cannot be retired on a device; dropping the
1672
- // local record is still the right way to stop a later Stop from waiting.
1673
- }
1674
- }
1675
-
1676
- export interface HooksInstallFlags {
1677
- harness?: string
1678
- global?: boolean
1679
- /** Init owns the final setup result and suppresses per-harness close narration. */
1680
- narrate?: boolean
1681
- /** Test seam; production resolves the running CLI. */
1682
- execPath?: string
1683
- scriptPath?: string
1684
- }
1685
-
1686
- /** True when this process is `npx` / `npm exec`, not a global or linked install. */
1687
- export function runningViaNpx(env: NodeJS.ProcessEnv, scriptPath: string): boolean {
1688
- if (env['npm_command'] === 'exec') return true
1689
- return scriptPath.includes(`${path.sep}_npx${path.sep}`)
1690
- }
1691
-
1692
- function fileHookInstallTarget(
1693
- target: HookAdapterTarget | undefined,
1694
- ): { execPath: string; scriptPath: string } | undefined {
1695
- if (target === undefined || isNpxAdapterTarget(target)) return undefined
1696
- return target
1697
- }
1698
-
1699
- function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags): HookAdapterTarget {
1700
- if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
1701
- return deps.hookInstallTarget
1702
- }
1703
- const fileTarget = fileHookInstallTarget(deps.hookInstallTarget)
1704
- const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath
1705
- const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai'
1706
- if (runningViaNpx(deps.env, scriptPath)) {
1707
- const version = packageVersion()
1708
- const npmCli = deps.env['npm_execpath']
1709
- if (version === null) {
1710
- throw new Error(
1711
- 'Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
1712
- )
1713
- }
1714
- if (typeof npmCli !== 'string' || npmCli === '') {
1715
- throw new Error(
1716
- 'This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
1717
- )
1718
- }
1719
- return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` }
1720
- }
1721
- return { execPath, scriptPath }
1722
- }
1723
-
1724
- function printHooksInstallClose(deps: CommandDeps, harness: HookInstallableHarness, file: string): void {
1725
- const label = HARNESS_LABELS[harness]
1726
- const activation =
1727
- harness === 'codex'
1728
- ? 'Approve the Notifai handlers in `/hooks` if Codex asks, then start one fresh Codex session, send one prompt, and run `notifai doctor`.'
1729
- : harness === 'cursor'
1730
- ? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
1731
- : harness === 'opencode'
1732
- ? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
1733
- : harness === 'openclaw'
1734
- ? 'Restart the OpenClaw Gateway, start one fresh Agent Session, send one prompt, then run `notifai doctor`.'
1735
- : `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`
1736
- if (deps.io.interactive === true && deps.io.note) {
1737
- void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`)
1738
- return
1739
- }
1740
- deps.io.out(`Installed ${harness} hooks in ${file}`)
1741
- deps.io.out(activation)
1742
- }
1743
-
1744
- export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
1745
- if (flags.harness === undefined) {
1746
- const detected = detectedHarnesses(deps.cwd, deps.env)
1747
- if (detected.length === 0) {
1748
- deps.io.err(`Could not tell which harness you mean — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
1749
- return EXIT.usage
1750
- }
1751
- let ok = true
1752
- for (const harness of detected) {
1753
- if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok) ok = false
1754
- }
1755
- return ok ? EXIT.ok : EXIT.failed
1756
- }
1757
- const harness = resolveHarness(deps, flags.harness)
1758
- if (!harness) return EXIT.usage
1759
- const adapterTarget = resolveHookAdapterTarget(deps, flags)
1760
- const scriptPath =
1761
- flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai'
1762
- const hookPlatform = deps.hookPlatform ?? process.platform
1763
- const nodePath = adapterTarget.execPath
1764
- let adapterPath: string
1765
- try {
1766
- adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome, hookPlatform).path
1767
- } catch (err) {
1768
- deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
1769
- return EXIT.failed
1770
- }
1771
- const wantGlobal = flags.global === true
1772
- const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter(
1773
- (installation) => installation.harness === harness,
1774
- )
1775
- const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
1776
- if (!wantGlobal && otherScope.some((installation) => installation.global)) {
1777
- const globalInstallations = otherScope.filter((installation) => installation.global)
1778
- const globalInstallation = globalInstallations[0]
1779
- const installedEvents = new Set(
1780
- globalInstallation?.handlers
1781
- .map((handler) => handlerEvent(handler.command))
1782
- .filter((event): event is HookEvent =>
1783
- event !== null && (HOOK_EVENTS as readonly string[]).includes(event),
1784
- ) ?? [],
1785
- )
1786
- if (
1787
- globalInstallations.length !== 1 ||
1788
- globalInstallations.some((installation) => (installation.problems?.length ?? 0) > 0) ||
1789
- globalInstallations.some(
1790
- (installation) => stopShapeProblems(installation, deps.hookPlatform).length > 0,
1791
- ) ||
1792
- !requiredHookEvents(harness).every((event) => installedEvents.has(event))
1793
- ) {
1794
- return hooksInstallCommand(deps, { ...flags, global: true, harness })
1795
- }
1796
- const globalFile = globalInstallation?.file
1797
- if (flags.narrate !== false) {
1798
- deps.io.out(
1799
- `${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`,
1800
- )
1801
- }
1802
- return EXIT.ok
1803
- }
1804
- if (wantGlobal && otherScope.some((installation) => !installation.global)) {
1805
- if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
1806
- return EXIT.failed
1807
- }
1808
- }
1809
- const codexPaths =
1810
- harness === 'codex'
1811
- ? codexLayerPaths(wantGlobal, deps.cwd, deps.env, hookPlatform)
1812
- : null
1813
- const settingsTarget =
1814
- codexPaths?.configToml ?? settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
1815
-
1816
- // OpenCode's adapter is a generated plugin module rather than a handler
1817
- // merged into a settings document, so it owns the whole file.
1818
- if (harness === 'opencode') {
1819
- return installOpencodePlugin(deps, settingsTarget, {
1820
- adapterPath,
1821
- timeoutSeconds: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
1822
- platform: hookPlatform,
1823
- nodePath,
1824
- ...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
1825
- })
1826
- }
1827
- if (harness === 'openclaw') {
1828
- return installOpenclawPlugin(deps, settingsTarget, {
1829
- adapterPath,
1830
- timeoutSeconds: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
1831
- platform: hookPlatform,
1832
- nodePath,
1833
- global: wantGlobal,
1834
- ...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
1835
- })
1836
- }
1837
-
1838
- if (harness === 'cursor') {
1839
- try {
1840
- withTargetFileLock(settingsTarget, () => {
1841
- const document = loadCursorSettings(settingsTarget)
1842
- const result = mergeCursorHooks(
1843
- document,
1844
- buildCursorHookConfig({
1845
- adapterPath,
1846
- harness: 'cursor',
1847
- platform: hookPlatform,
1848
- nodePath,
1849
- }),
1850
- scriptPath,
1851
- )
1852
- applyPlan(settingsTarget, result.document)
1853
- return result
1854
- })
1855
- } catch (err) {
1856
- deps.io.err(String(err))
1857
- return EXIT.failed
1858
- }
1859
- if (flags.narrate !== false) printHooksInstallClose(deps, harness, settingsTarget)
1860
- return EXIT.ok
1861
- }
1862
-
1863
- const installInto = (file: string): { file: string; foreignStopCount: number } => {
1864
- const document = loadSettings(file)
1865
- const foreignStopCount = foreignStopHandlers(document).length
1866
- const result = mergeHooks(
1867
- document,
1868
- buildHookConfig({
1869
- adapterPath,
1870
- harness,
1871
- platform: hookPlatform,
1872
- nodePath,
1873
- }),
1874
- scriptPath,
1875
- )
1876
- applyPlan(file, result.document)
1877
- return { file, foreignStopCount }
1878
- }
1879
-
1880
- let installed: { file: string; foreignStopCount: number }
1881
- try {
1882
- installed =
1883
- codexPaths === null
1884
- ? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
1885
- : withCodexLayerTransaction(codexPaths, (inspection) => {
1886
- const staleTarget =
1887
- inspection.writeTarget === inspection.paths.hooksJson
1888
- ? inspection.paths.configToml
1889
- : inspection.paths.hooksJson
1890
- const staleEvents =
1891
- staleTarget === inspection.paths.hooksJson
1892
- ? inspection.ourJsonEvents
1893
- : inspection.ourTomlEvents
1894
- if (staleEvents.length > 0) {
1895
- const staleDocument = loadSettings(staleTarget)
1896
- const stripped = removeHooks(staleDocument, scriptPath)
1897
- if (stripped.replaced.length > 0) applyPlan(staleTarget, stripped.document)
1898
- }
1899
- return installInto(inspection.writeTarget)
1900
- })
1901
- } catch (err) {
1902
- deps.io.err(String(err))
1903
- return EXIT.failed
1904
- }
1905
-
1906
- if (harness === 'codex') {
1907
- const layer = flags.global ? null : codexLayerDir(deps.cwd)
1908
- if (layer !== null) mkdirSync(layer, { recursive: true })
1909
- }
1910
- if (flags.narrate !== false) printHooksInstallClose(deps, harness, installed.file)
1911
- if (installed.foreignStopCount > 0) {
1912
- deps.io.out(
1913
- "This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
1914
- )
1915
- }
1916
- if (harness === 'codex') {
1917
- for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
1918
- deps.io.out(problem)
1919
- }
1920
- for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
1921
- deps.io.out(note)
1922
- }
1923
- if (flags.global) {
1924
- const home = codexHomeNote(deps.env, hookPlatform)
1925
- if (home !== null) deps.io.out(home)
1926
- }
1927
- }
1928
- return EXIT.ok
1929
- }
1930
-
1931
- function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { command: string }[] }[]> }): { command: string }[] {
1932
- const groups = document.hooks?.['Stop']
1933
- if (!Array.isArray(groups)) return []
1934
- return groups
1935
- .flatMap((group) => group.hooks ?? [])
1936
- .filter(
1937
- (handler) =>
1938
- !/ hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(
1939
- handler.command,
1940
- ),
1941
- )
1942
- }
1943
-
1944
- /**
1945
- * Writes the OpenCode plugin, replacing any Notifai plugin already there —
1946
- * including one a different checkout wrote, matched on the managed marker for
1947
- * the same reason command hooks are.
1948
- */
1949
- function installOpencodePlugin(
1950
- deps: CommandDeps,
1951
- file: string,
1952
- options: {
1953
- adapterPath: string
1954
- timeoutSeconds: number
1955
- platform?: NodeJS.Platform
1956
- nodePath?: string
1957
- narrate?: boolean
1958
- },
1959
- ): number {
1960
- try {
1961
- withTargetFileLock(file, () => {
1962
- if (existsSync(file)) {
1963
- assertOwnedRegularFile(file)
1964
- const existing = readFileSync(file, 'utf8')
1965
- if (!isOurOpencodePlugin(existing)) {
1966
- throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
1967
- }
1968
- }
1969
- atomicWriteFileSync(file, opencodePluginSource(options), {
1970
- mode: 0o600,
1971
- preserveMode: false,
1972
- requireCurrentUserOwner: true,
1973
- })
1974
- })
1975
- } catch (err) {
1976
- deps.io.err(String(err))
1977
- return EXIT.failed
1978
- }
1979
- if (options.narrate !== false) printHooksInstallClose(deps, 'opencode', file)
1980
- return EXIT.ok
1981
- }
1982
-
1983
- function installOpenclawPlugin(
1984
- deps: CommandDeps,
1985
- file: string,
1986
- options: {
1987
- adapterPath: string
1988
- timeoutSeconds: number
1989
- platform?: NodeJS.Platform
1990
- nodePath?: string
1991
- global: boolean
1992
- narrate?: boolean
1993
- },
1994
- ): number {
1995
- const pluginDir = path.dirname(file)
1996
- try {
1997
- withTargetFileLock(file, () => {
1998
- if (existsSync(file)) {
1999
- assertOwnedRegularFile(file)
2000
- const existing = readFileSync(file, 'utf8')
2001
- if (!isOurOpenclawPlugin(existing)) {
2002
- throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
2003
- }
2004
- }
2005
- atomicWriteFileSync(file, openclawPluginSource(options), {
2006
- mode: 0o600,
2007
- preserveMode: false,
2008
- requireCurrentUserOwner: true,
2009
- })
2010
- atomicWriteFileSync(path.join(pluginDir, OPENCLAW_PLUGIN_MANIFEST), openclawPluginManifest(), {
2011
- mode: 0o600,
2012
- preserveMode: false,
2013
- requireCurrentUserOwner: true,
2014
- })
2015
- atomicWriteFileSync(path.join(pluginDir, OPENCLAW_PLUGIN_PACKAGE), openclawPluginPackage(), {
2016
- mode: 0o600,
2017
- preserveMode: false,
2018
- requireCurrentUserOwner: true,
2019
- })
2020
- })
2021
- writeOpenclawEnablement(deps, pluginDir, options.global)
2022
- } catch (err) {
2023
- deps.io.err(String(err))
2024
- return EXIT.failed
2025
- }
2026
- if (options.narrate !== false) printHooksInstallClose(deps, 'openclaw', file)
2027
- return EXIT.ok
2028
- }
2029
-
2030
- function writeOpenclawEnablement(deps: CommandDeps, pluginDir: string, global: boolean): void {
2031
- const configFile = openclawConfigPath(deps.env, deps.hookPlatform)
2032
- withTargetFileLock(configFile, () => {
2033
- let config: Record<string, unknown> = {}
2034
- if (existsSync(configFile)) {
2035
- assertOwnedRegularFile(configFile)
2036
- config = parseOpenclawConfig(readFileSync(configFile, 'utf8'))
2037
- }
2038
- const merged = mergeOpenclawNotifaiEntry(config, pluginDir, global)
2039
- atomicWriteFileSync(configFile, `${JSON.stringify(merged, null, 2)}\n`, {
2040
- mode: 0o600,
2041
- preserveMode: true,
2042
- requireCurrentUserOwner: true,
2043
- })
2044
- })
2045
- }
2046
-
2047
- function uninstallOpenclawPlugin(deps: CommandDeps, file: string): number {
2048
- const pluginDir = path.dirname(file)
2049
- try {
2050
- const removed = withTargetFileLock(file, () => {
2051
- if (!existsSync(file)) return false
2052
- assertOwnedRegularFile(file)
2053
- if (!isOurOpenclawPlugin(readFileSync(file, 'utf8'))) {
2054
- deps.io.out(`Left ${file} alone: Notifai did not write it.`)
2055
- return false
2056
- }
2057
- rmSync(file, { force: true })
2058
- rmSync(path.join(pluginDir, OPENCLAW_PLUGIN_MANIFEST), { force: true })
2059
- rmSync(path.join(pluginDir, OPENCLAW_PLUGIN_PACKAGE), { force: true })
2060
- if (existsSync(pluginDir) && readdirSync(pluginDir).length === 0) {
2061
- rmdirSync(pluginDir)
2062
- }
2063
- return true
2064
- })
2065
- if (removed) {
2066
- const configFile = openclawConfigPath(deps.env, deps.hookPlatform)
2067
- if (existsSync(configFile)) {
2068
- withTargetFileLock(configFile, () => {
2069
- assertOwnedRegularFile(configFile)
2070
- const config = parseOpenclawConfig(readFileSync(configFile, 'utf8'))
2071
- const next = removeOpenclawNotifaiEntry(config, pluginDir)
2072
- atomicWriteFileSync(configFile, `${JSON.stringify(next, null, 2)}\n`, {
2073
- mode: 0o600,
2074
- preserveMode: true,
2075
- requireCurrentUserOwner: true,
2076
- })
2077
- })
2078
- }
2079
- deps.io.out(`Removed the Notifai OpenClaw plugin at ${file}`)
2080
- } else if (!existsSync(file)) {
2081
- deps.io.out(`Nothing to remove: ${file} does not exist.`)
2082
- }
2083
- return EXIT.ok
2084
- } catch (err) {
2085
- deps.io.err(String(err))
2086
- return EXIT.failed
2087
- }
2088
- }
2089
-
2090
- export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
2091
- const harness = resolveHarness(deps, flags.harness)
2092
- if (!harness) return EXIT.usage
2093
- const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
2094
- const global = flags.global ?? false
2095
- const codexPaths =
2096
- harness === 'codex'
2097
- ? codexLayerPaths(global, deps.cwd, deps.env, deps.hookPlatform)
2098
- : null
2099
- const file =
2100
- codexPaths?.configToml ?? settingsFile(harness, global, deps.cwd, deps.env, deps.hookPlatform)
2101
- if (harness === 'opencode') {
2102
- try {
2103
- return withTargetFileLock(file, () => {
2104
- if (!existsSync(file)) {
2105
- deps.io.out(`Nothing to remove: ${file} does not exist.`)
2106
- return EXIT.ok
2107
- }
2108
- assertOwnedRegularFile(file)
2109
- // We own the whole file, but only if we wrote it.
2110
- if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
2111
- deps.io.out(`Left ${file} alone: Notifai did not write it.`)
2112
- return EXIT.ok
2113
- }
2114
- rmSync(file, { force: true })
2115
- deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
2116
- return EXIT.ok
2117
- })
2118
- } catch (err) {
2119
- deps.io.err(String(err))
2120
- return EXIT.failed
2121
- }
2122
- }
2123
- if (harness === 'openclaw') {
2124
- return uninstallOpenclawPlugin(deps, file)
2125
- }
2126
- if (harness === 'cursor') {
2127
- let stripped: ReturnType<typeof removeCursorHooks> | null
2128
- try {
2129
- stripped = withTargetFileLock(file, () => {
2130
- if (!existsSync(file)) return null
2131
- const document = loadCursorSettings(file)
2132
- const result = removeCursorHooks(document, scriptPath)
2133
- applyPlan(file, result.document)
2134
- return result
2135
- })
2136
- } catch (err) {
2137
- deps.io.err(String(err))
2138
- return EXIT.failed
2139
- }
2140
- if (stripped === null) {
2141
- deps.io.out(`Nothing to remove: ${file} does not exist.`)
2142
- return EXIT.ok
2143
- }
2144
- deps.io.out(
2145
- stripped.replaced.length > 0
2146
- ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
2147
- : `No Notifai hooks found in ${file}`,
2148
- )
2149
- return EXIT.ok
2150
- }
2151
- const removeInstalledHooks = (): { existing: string[]; removedAny: boolean } => {
2152
- const files =
2153
- codexPaths === null
2154
- ? hookDefinitionFiles(harness, global, deps.cwd, deps.env, deps.hookPlatform)
2155
- : [codexPaths.hooksJson, codexPaths.configToml]
2156
- const existing = files.filter((candidate) => existsSync(candidate))
2157
- let removedAny = false
2158
- for (const candidate of existing) {
2159
- const removeFromCandidate = () => {
2160
- const document = loadSettings(candidate)
2161
- const result = removeHooks(document, scriptPath)
2162
- if (result.replaced.length > 0) applyPlan(candidate, result.document)
2163
- return result
2164
- }
2165
- const stripped =
2166
- codexPaths === null
2167
- ? withTargetFileLock(candidate, removeFromCandidate)
2168
- : removeFromCandidate()
2169
- if (stripped.replaced.length > 0) {
2170
- removedAny = true
2171
- deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`)
2172
- }
2173
- }
2174
- return { existing, removedAny }
2175
- }
2176
-
2177
- let result: { existing: string[]; removedAny: boolean }
2178
- try {
2179
- result =
2180
- codexPaths === null
2181
- ? removeInstalledHooks()
2182
- : withCodexLayerTransaction(codexPaths, (inspection) => {
2183
- const stripped = removeInstalledHooks()
2184
- cleanupEmptiedCodexLayer(inspection.paths)
2185
- return stripped
2186
- })
2187
- } catch (err) {
2188
- deps.io.err(String(err))
2189
- return EXIT.failed
2190
- }
2191
- if (result.existing.length === 0) {
2192
- deps.io.out(`Nothing to remove: ${file} does not exist.`)
2193
- return EXIT.ok
2194
- }
2195
- if (!result.removedAny) {
2196
- deps.io.out(`No Notifai hooks found in ${result.existing.join(', ')}`)
2197
- }
2198
- return EXIT.ok
2199
- }
2200
-
2201
- function assertOwnedRegularFile(file: string): void {
2202
- const stat = lstatSync(file)
2203
- if (stat.isSymbolicLink() || !stat.isFile()) {
2204
- throw new Error(`${file} is not a regular file; refusing to read or replace it.`)
2205
- }
2206
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
2207
- if (uid !== undefined && stat.uid !== uid) {
2208
- throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
2209
- }
2210
- }
2211
-
2212
- function resolveHarness(deps: CommandDeps, requested: string | undefined): HookInstallableHarness | null {
2213
- if (requested !== undefined) {
2214
- if ((HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(requested)) return requested as HookInstallableHarness
2215
- deps.io.err(
2216
- `Unknown harness "${requested}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`,
2217
- )
2218
- return null
2219
- }
2220
- const detected = detectHarness(deps.cwd, deps.env)
2221
- if (!detected) {
2222
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
2223
- return null
2224
- }
2225
- return detected
2226
- }
2227
-
2228
- /**
2229
- * Which harnesses to wire. An explicit `--harness` still wins as a singleton.
2230
- * Otherwise: every detected harness, or a human picker when detection is empty
2231
- * or names more than one.
2232
- */
2233
- export async function pickHarnessesToInstall(
2234
- deps: CommandDeps,
2235
- requested?: string,
2236
- ): Promise<HookInstallableHarness[] | null> {
2237
- if (requested !== undefined) {
2238
- const harness = resolveHarness(deps, requested)
2239
- return harness === null ? null : [harness]
2240
- }
2241
- const detected = detectedHarnesses(deps.cwd, deps.env)
2242
- if (detected.length === 1) return detected
2243
- if (deps.io.interactive === true && deps.io.multiselect) {
2244
- const picked = await deps.io.multiselect(
2245
- 'Which agent harnesses should Notifai wire here?',
2246
- HOOK_INSTALLABLE_HARNESSES.map((name) => ({
2247
- value: name,
2248
- label: HARNESS_LABELS[name],
2249
- ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
2250
- })),
2251
- detected,
2252
- )
2253
- if (picked === null) return null
2254
- const unknown = picked.filter((name) => !(HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(name))
2255
- if (unknown.length > 0) {
2256
- deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`)
2257
- return null
2258
- }
2259
- return picked as HookInstallableHarness[]
2260
- }
2261
- if (detected.length === 0) {
2262
- deps.io.err(
2263
- `Could not tell which harness to wire. Run: notifai hooks install --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>`,
2264
- )
2265
- return null
2266
- }
2267
- return detected
2268
- }