@raidiant/notifai 10.0.0 → 10.1.1

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 (127) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  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 +32 -14
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +10 -1
  19. package/dist/commands-core.js +13 -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 +158 -71
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-harness-context.js +8 -0
  27. package/dist/commands-harness-context.js.map +1 -1
  28. package/dist/commands-hooks.js +169 -15
  29. package/dist/commands-hooks.js.map +1 -1
  30. package/dist/commands-init.d.ts +16 -0
  31. package/dist/commands-init.js +254 -91
  32. package/dist/commands-init.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-update.d.ts +10 -0
  37. package/dist/commands-update.js +141 -0
  38. package/dist/commands-update.js.map +1 -0
  39. package/dist/commands.d.ts +2 -1
  40. package/dist/commands.js +2 -1
  41. package/dist/commands.js.map +1 -1
  42. package/dist/harnesses.d.ts +2 -2
  43. package/dist/harnesses.js +22 -2
  44. package/dist/harnesses.js.map +1 -1
  45. package/dist/hook-adapter.d.ts +2 -0
  46. package/dist/hook-adapter.js +12 -1
  47. package/dist/hook-adapter.js.map +1 -1
  48. package/dist/hooks.d.ts +43 -2
  49. package/dist/hooks.js +163 -21
  50. package/dist/hooks.js.map +1 -1
  51. package/dist/install-hooks.js +15 -9
  52. package/dist/install-hooks.js.map +1 -1
  53. package/dist/interactive.js +20 -23
  54. package/dist/interactive.js.map +1 -1
  55. package/dist/main-run.d.ts +1 -0
  56. package/dist/main-run.js +42 -0
  57. package/dist/main-run.js.map +1 -0
  58. package/dist/main.js +10 -31
  59. package/dist/main.js.map +1 -1
  60. package/dist/node-floor.d.ts +18 -0
  61. package/dist/node-floor.js +35 -0
  62. package/dist/node-floor.js.map +1 -0
  63. package/dist/openclaw-plugin.d.ts +49 -0
  64. package/dist/openclaw-plugin.js +350 -0
  65. package/dist/openclaw-plugin.js.map +1 -0
  66. package/dist/program.d.ts +2 -1
  67. package/dist/program.js +31 -13
  68. package/dist/program.js.map +1 -1
  69. package/dist/question-settlement-process.d.ts +15 -0
  70. package/dist/question-settlement-process.js +35 -0
  71. package/dist/question-settlement-process.js.map +1 -0
  72. package/dist/readiness.d.ts +25 -1
  73. package/dist/readiness.js +32 -2
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/session-activation.js +1 -1
  76. package/dist/session-activation.js.map +1 -1
  77. package/dist/setup-destinations.d.ts +23 -0
  78. package/dist/setup-destinations.js +44 -0
  79. package/dist/setup-destinations.js.map +1 -0
  80. package/dist/ui/help.d.ts +4 -5
  81. package/dist/ui/help.js +5 -6
  82. package/dist/ui/help.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/cli-bin.test.ts +145 -7
  85. package/src/cli-bin.ts +176 -10
  86. package/src/cli-update-contract.ts +9 -0
  87. package/src/cli-update.test.ts +125 -0
  88. package/src/client.ts +7 -0
  89. package/src/codex-wake.ts +5 -1
  90. package/src/commands-auth.ts +47 -13
  91. package/src/commands-core.ts +21 -4
  92. package/src/commands-devices.ts +23 -16
  93. package/src/commands-doctor.ts +197 -75
  94. package/src/commands-harness-context.ts +8 -0
  95. package/src/commands-hooks.ts +210 -18
  96. package/src/commands-init.ts +275 -92
  97. package/src/commands-send.ts +145 -39
  98. package/src/commands-update.ts +180 -0
  99. package/src/commands.test.ts +701 -53
  100. package/src/commands.ts +2 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +82 -0
  103. package/src/harnesses.test.ts +9 -1
  104. package/src/harnesses.ts +24 -2
  105. package/src/hook-adapter.ts +14 -0
  106. package/src/hooks.test.ts +275 -0
  107. package/src/hooks.ts +270 -18
  108. package/src/install-hooks.test.ts +208 -0
  109. package/src/install-hooks.ts +19 -9
  110. package/src/interactive.test.ts +31 -0
  111. package/src/interactive.ts +20 -26
  112. package/src/main-run.ts +45 -0
  113. package/src/main.ts +9 -33
  114. package/src/node-floor.test.ts +27 -0
  115. package/src/node-floor.ts +37 -0
  116. package/src/openclaw-plugin.ts +409 -0
  117. package/src/program.test.ts +35 -0
  118. package/src/program.ts +37 -12
  119. package/src/question-settlement-process.ts +47 -0
  120. package/src/readiness.ts +35 -2
  121. package/src/session-activation.ts +1 -1
  122. package/src/setup-destinations.test.ts +41 -0
  123. package/src/setup-destinations.ts +50 -0
  124. package/src/skill.test.ts +13 -2
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
  127. package/src/commands-devices.test.ts +0 -15
@@ -0,0 +1,409 @@
1
+ import path from 'node:path'
2
+ import { hookHostPlatform, type HookHostPlatform } from './hook-adapter.js'
3
+ import { harnessAccountHome } from './install-hooks.js'
4
+ import {
5
+ MISSING_LIFECYCLE_GUIDANCE_CONTEXT,
6
+ WORKER_ACTIVATION_CONTEXT,
7
+ } from './session-activation.js'
8
+
9
+ /**
10
+ * The OpenClaw adapter.
11
+ *
12
+ * OpenClaw's extension point is an in-process Gateway plugin, not a command
13
+ * hook. The generated module shells out to the same `notifai hook <event>`
14
+ * commands every other harness invokes, so presence, activation, and
15
+ * retirement stay in the CLI.
16
+ *
17
+ * before_prompt_build -> session-start / subagent-start (first owner/worker)
18
+ * message_received -> user-prompt-submit (User is present)
19
+ * agent_end -> stop (the turn ended)
20
+ * session_end -> session-end (retire local state)
21
+ * resolve_exec_env -> NOTIFAI_ACTIVE_* markers (exact Source Context)
22
+ *
23
+ * `command:stop` is a User abort and is deliberately not wired. Asynchronous
24
+ * ask is unsupported until a live continuation probe.
25
+ */
26
+
27
+ export const OPENCLAW_PLUGIN_MARKER = '// notifai managed openclaw plugin'
28
+ export const OPENCLAW_PLUGIN_ID = 'notifai'
29
+ export const OPENCLAW_PLUGIN_FILENAME = 'index.js'
30
+ export const OPENCLAW_PLUGIN_MANIFEST = 'openclaw.plugin.json'
31
+ export const OPENCLAW_PLUGIN_PACKAGE = 'package.json'
32
+
33
+ const OPENCLAW_ADAPTER_VERSION = 1
34
+
35
+ export const OPENCLAW_EVENTS = [
36
+ ['SessionStart', 'session-start'],
37
+ ['SubagentStart', 'subagent-start'],
38
+ ['UserPromptSubmit', 'user-prompt-submit'],
39
+ ['Stop', 'stop'],
40
+ ['SessionEnd', 'session-end'],
41
+ ] as const
42
+
43
+ export function openclawStateDir(
44
+ env: NodeJS.ProcessEnv = process.env,
45
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
46
+ ): string {
47
+ const override = env['OPENCLAW_STATE_DIR']
48
+ if (override !== undefined && override !== '') return override
49
+ const homeOverride = env['OPENCLAW_HOME']
50
+ if (homeOverride !== undefined && homeOverride !== '') {
51
+ return path.join(homeOverride, '.openclaw')
52
+ }
53
+ return path.join(harnessAccountHome(env, platform), '.openclaw')
54
+ }
55
+
56
+ export function openclawConfigPath(
57
+ env: NodeJS.ProcessEnv = process.env,
58
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
59
+ ): string {
60
+ const override = env['OPENCLAW_CONFIG_PATH']
61
+ if (override !== undefined && override !== '') return override
62
+ return path.join(openclawStateDir(env, platform), 'openclaw.json')
63
+ }
64
+
65
+ export function openclawPluginDir(
66
+ global: boolean,
67
+ cwd: string,
68
+ env: NodeJS.ProcessEnv = process.env,
69
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
70
+ ): string {
71
+ return global
72
+ ? path.join(openclawStateDir(env, platform), 'extensions', OPENCLAW_PLUGIN_ID)
73
+ : path.join(cwd, '.openclaw', 'extensions', OPENCLAW_PLUGIN_ID)
74
+ }
75
+
76
+ export function openclawPluginPath(
77
+ global: boolean,
78
+ cwd: string,
79
+ env: NodeJS.ProcessEnv = process.env,
80
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
81
+ ): string {
82
+ return path.join(openclawPluginDir(global, cwd, env, platform), OPENCLAW_PLUGIN_FILENAME)
83
+ }
84
+
85
+ export interface OpenclawPluginOptions {
86
+ adapterPath: string
87
+ timeoutSeconds: number
88
+ platform?: NodeJS.Platform | HookHostPlatform
89
+ nodePath?: string
90
+ }
91
+
92
+ export function openclawPluginManifest(): string {
93
+ return `${JSON.stringify(
94
+ {
95
+ id: OPENCLAW_PLUGIN_ID,
96
+ name: 'Notifai',
97
+ description: 'Notifai lifecycle activation, Source Context, and session observation.',
98
+ activation: { onStartup: true },
99
+ configSchema: { type: 'object', additionalProperties: false },
100
+ },
101
+ null,
102
+ 2,
103
+ )}\n`
104
+ }
105
+
106
+ export function openclawPluginPackage(): string {
107
+ return `${JSON.stringify(
108
+ {
109
+ name: 'notifai-openclaw',
110
+ version: '1.0.0',
111
+ type: 'module',
112
+ openclaw: { extensions: [`./${OPENCLAW_PLUGIN_FILENAME}`] },
113
+ },
114
+ null,
115
+ 2,
116
+ )}\n`
117
+ }
118
+
119
+ export function openclawPluginSource(options: OpenclawPluginOptions): string {
120
+ const { adapterPath, timeoutSeconds } = options
121
+ const win32 = hookHostPlatform(options.platform) === 'win32'
122
+ const nodeConstant = win32
123
+ ? `const NODE = ${JSON.stringify(options.nodePath ?? process.execPath)}\n`
124
+ : ''
125
+ const spawnArguments = win32
126
+ ? 'NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "openclaw"]'
127
+ : 'ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "openclaw"]'
128
+ const windowsHide = win32 ? '\n windowsHide: true,' : ''
129
+ return `${OPENCLAW_PLUGIN_MARKER}
130
+ // Generated by \`notifai hooks install --harness openclaw\`. Edits are lost on
131
+ // the next install; change the CLI instead, which is where the logic lives.
132
+ import { spawn } from "node:child_process"
133
+
134
+ ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
135
+ const TIMEOUT_MS = ${timeoutSeconds * 1000}
136
+ const ADAPTER_VERSION = ${OPENCLAW_ADAPTER_VERSION}
137
+ const MISSING_LIFECYCLE_GUIDANCE_CONTEXT = ${JSON.stringify(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)}
138
+ const WORKER_ACTIVATION_CONTEXT = ${JSON.stringify(WORKER_ACTIVATION_CONTEXT)}
139
+
140
+ function runHook(event, envelope) {
141
+ return new Promise((resolve) => {
142
+ let child
143
+ try {
144
+ child = spawn(${spawnArguments}, {
145
+ env: process.env,
146
+ shell: false,${windowsHide}
147
+ stdio: ["pipe", "pipe", "pipe"],
148
+ })
149
+ } catch {
150
+ resolve(null)
151
+ return
152
+ }
153
+
154
+ let stdout = ""
155
+ let settled = false
156
+ const finish = (value) => {
157
+ if (settled) return
158
+ settled = true
159
+ clearTimeout(timer)
160
+ resolve(value)
161
+ }
162
+ const timer = setTimeout(() => {
163
+ child.kill("SIGTERM")
164
+ finish(null)
165
+ }, TIMEOUT_MS)
166
+
167
+ child.stdout?.setEncoding("utf-8")
168
+ child.stdout?.on("data", (chunk) => {
169
+ stdout += chunk
170
+ })
171
+ child.stderr?.resume()
172
+ child.on("error", () => finish(null))
173
+ child.on("close", () => finish(stdout))
174
+
175
+ try {
176
+ child.stdin?.end(JSON.stringify(envelope))
177
+ } catch {
178
+ finish(null)
179
+ }
180
+ })
181
+ }
182
+
183
+ function sessionKeyOf(event, ctx) {
184
+ const key = ctx?.sessionKey ?? event?.sessionKey ?? event?.context?.sessionKey
185
+ return typeof key === "string" ? key.trim() : ""
186
+ }
187
+
188
+ function workspaceDirOf(event, ctx) {
189
+ const dir = ctx?.workspaceDir ?? event?.workspaceDir ?? event?.context?.workspaceDir
190
+ return typeof dir === "string" && dir.length > 0 ? dir : process.cwd()
191
+ }
192
+
193
+ function isWorkerSession(sessionKey, ctx) {
194
+ if (sessionKey === "") return true
195
+ if (sessionKey.includes(":subagent:")) return true
196
+ if (sessionKey.includes(":acp:")) return true
197
+ if (ctx?.targetKind === "acp" || ctx?.dispatchKind === "acp") return true
198
+ if (typeof ctx?.parentSessionKey === "string" && ctx.parentSessionKey.length > 0) return true
199
+ return false
200
+ }
201
+
202
+ function isUserMessage(event, ctx) {
203
+ const from = event?.from ?? event?.context?.from ?? ctx?.senderId
204
+ if (typeof from !== "string" || from.trim().length === 0) return false
205
+ const trigger = ctx?.trigger ?? event?.trigger
206
+ if (trigger === "cron" || trigger === "heartbeat") return false
207
+ return true
208
+ }
209
+
210
+ function register(api) {
211
+ const activated = new Set()
212
+ api.on("before_prompt_build", async (event, ctx) => {
213
+ const sessionKey = sessionKeyOf(event, ctx)
214
+ if (sessionKey === "") return { prependContext: WORKER_ACTIVATION_CONTEXT }
215
+ if (activated.has(sessionKey)) return
216
+ const worker = isWorkerSession(sessionKey, ctx)
217
+ const resolved = await runHook(worker ? "subagent-start" : "session-start", {
218
+ session_id: sessionKey,
219
+ cwd: workspaceDirOf(event, ctx),
220
+ hook_event_name: worker ? "SubagentStart" : "SessionStart",
221
+ })
222
+ const context = typeof resolved === "string" && resolved.trim().length > 0
223
+ ? resolved.trim()
224
+ : worker ? WORKER_ACTIVATION_CONTEXT : MISSING_LIFECYCLE_GUIDANCE_CONTEXT
225
+ activated.add(sessionKey)
226
+ return { prependContext: context }
227
+ })
228
+
229
+ api.on("message_received", async (event, ctx) => {
230
+ if (!isUserMessage(event, ctx)) return
231
+ const sessionKey = sessionKeyOf(event, ctx)
232
+ if (sessionKey === "") return
233
+ await runHook("user-prompt-submit", {
234
+ session_id: sessionKey,
235
+ cwd: workspaceDirOf(event, ctx),
236
+ hook_event_name: "UserPromptSubmit",
237
+ })
238
+ })
239
+
240
+ api.on("agent_end", async (event, ctx) => {
241
+ const sessionKey = sessionKeyOf(event, ctx)
242
+ if (sessionKey === "") return
243
+ await runHook("stop", {
244
+ session_id: sessionKey,
245
+ cwd: workspaceDirOf(event, ctx),
246
+ hook_event_name: "Stop",
247
+ })
248
+ })
249
+
250
+ api.on("session_end", async (event, ctx) => {
251
+ const sessionKey = sessionKeyOf(event, ctx)
252
+ if (sessionKey === "") return
253
+ activated.delete(sessionKey)
254
+ await runHook("session-end", {
255
+ session_id: sessionKey,
256
+ cwd: workspaceDirOf(event, ctx),
257
+ hook_event_name: "SessionEnd",
258
+ })
259
+ })
260
+
261
+ api.on("resolve_exec_env", (event, ctx) => {
262
+ const sessionKey = sessionKeyOf(event, ctx)
263
+ if (sessionKey === "") return
264
+ return {
265
+ NOTIFAI_ACTIVE_HARNESS: "openclaw",
266
+ NOTIFAI_ACTIVE_SESSION_ID: sessionKey,
267
+ }
268
+ })
269
+ }
270
+
271
+ export default {
272
+ id: ${JSON.stringify(OPENCLAW_PLUGIN_ID)},
273
+ name: "Notifai",
274
+ register,
275
+ }
276
+ `
277
+ }
278
+
279
+ export function isOurOpenclawPlugin(contents: string): boolean {
280
+ return contents.includes(OPENCLAW_PLUGIN_MARKER)
281
+ }
282
+
283
+ export function openclawPluginTarget(
284
+ contents: string,
285
+ ): { adapter: string; current: boolean; timeoutSeconds?: number; nodePath?: string } | null {
286
+ if (!isOurOpenclawPlugin(contents)) return null
287
+ const adapter = /^const ADAPTER = (".*")$/m.exec(contents)?.[1]
288
+ const node = /^const NODE = (".*")$/m.exec(contents)?.[1]
289
+ const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 0)
290
+ const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
291
+ if (adapter === undefined) return null
292
+ try {
293
+ return {
294
+ adapter: JSON.parse(adapter) as string,
295
+ current: version === OPENCLAW_ADAPTER_VERSION,
296
+ ...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
297
+ ...(node === undefined ? {} : { nodePath: JSON.parse(node) as string }),
298
+ }
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ export function parseOpenclawConfig(source: string): Record<string, unknown> {
305
+ try {
306
+ const parsed: unknown = JSON.parse(source)
307
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
308
+ return parsed as Record<string, unknown>
309
+ }
310
+ } catch {
311
+ const stripped = source
312
+ .replace(/\/\*[\s\S]*?\*\//g, '')
313
+ .replace(/^\s*\/\/.*$/gm, '')
314
+ .replace(/,(\s*[}\]])/g, '$1')
315
+ const parsed: unknown = JSON.parse(stripped)
316
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
317
+ return parsed as Record<string, unknown>
318
+ }
319
+ }
320
+ throw new Error('OpenClaw config is not a JSON object')
321
+ }
322
+
323
+ export function mergeOpenclawNotifaiEntry(
324
+ config: Record<string, unknown>,
325
+ pluginDir: string,
326
+ global: boolean,
327
+ ): Record<string, unknown> {
328
+ const plugins =
329
+ config.plugins !== null && typeof config.plugins === 'object' && !Array.isArray(config.plugins)
330
+ ? { ...(config.plugins as Record<string, unknown>) }
331
+ : {}
332
+ const entries =
333
+ plugins.entries !== null && typeof plugins.entries === 'object' && !Array.isArray(plugins.entries)
334
+ ? { ...(plugins.entries as Record<string, unknown>) }
335
+ : {}
336
+ const existing =
337
+ entries[OPENCLAW_PLUGIN_ID] !== null &&
338
+ typeof entries[OPENCLAW_PLUGIN_ID] === 'object' &&
339
+ !Array.isArray(entries[OPENCLAW_PLUGIN_ID])
340
+ ? { ...(entries[OPENCLAW_PLUGIN_ID] as Record<string, unknown>) }
341
+ : {}
342
+ const hooks =
343
+ existing.hooks !== null && typeof existing.hooks === 'object' && !Array.isArray(existing.hooks)
344
+ ? { ...(existing.hooks as Record<string, unknown>) }
345
+ : {}
346
+ entries[OPENCLAW_PLUGIN_ID] = {
347
+ ...existing,
348
+ enabled: true,
349
+ hooks: { ...hooks, allowConversationAccess: true },
350
+ }
351
+ plugins.entries = entries
352
+ if (!global) {
353
+ const load =
354
+ plugins.load !== null && typeof plugins.load === 'object' && !Array.isArray(plugins.load)
355
+ ? { ...(plugins.load as Record<string, unknown>) }
356
+ : {}
357
+ const paths = Array.isArray(load.paths) ? [...load.paths] : []
358
+ if (!paths.includes(pluginDir)) paths.push(pluginDir)
359
+ load.paths = paths
360
+ plugins.load = load
361
+ }
362
+ return { ...config, plugins }
363
+ }
364
+
365
+ export function removeOpenclawNotifaiEntry(
366
+ config: Record<string, unknown>,
367
+ pluginDir: string,
368
+ ): Record<string, unknown> {
369
+ const plugins =
370
+ config.plugins !== null && typeof config.plugins === 'object' && !Array.isArray(config.plugins)
371
+ ? { ...(config.plugins as Record<string, unknown>) }
372
+ : {}
373
+ const entries =
374
+ plugins.entries !== null && typeof plugins.entries === 'object' && !Array.isArray(plugins.entries)
375
+ ? { ...(plugins.entries as Record<string, unknown>) }
376
+ : {}
377
+ delete entries[OPENCLAW_PLUGIN_ID]
378
+ if (Object.keys(entries).length > 0) plugins.entries = entries
379
+ else delete plugins.entries
380
+ const load =
381
+ plugins.load !== null && typeof plugins.load === 'object' && !Array.isArray(plugins.load)
382
+ ? { ...(plugins.load as Record<string, unknown>) }
383
+ : {}
384
+ if (Array.isArray(load.paths)) {
385
+ const paths = load.paths.filter((value) => value !== pluginDir)
386
+ if (paths.length > 0) load.paths = paths
387
+ else delete load.paths
388
+ }
389
+ if (Object.keys(load).length > 0) plugins.load = load
390
+ else delete plugins.load
391
+ if (Object.keys(plugins).length > 0) return { ...config, plugins }
392
+ const next = { ...config }
393
+ delete next.plugins
394
+ return next
395
+ }
396
+
397
+ /** Config-directory presence is not ownership. These files mean OpenClaw is actually configured. */
398
+ export function openclawHasGlobalEvidence(
399
+ exists: (file: string) => boolean,
400
+ env: NodeJS.ProcessEnv = process.env,
401
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
402
+ ): boolean {
403
+ const state = openclawStateDir(env, platform)
404
+ return (
405
+ exists(openclawConfigPath(env, platform)) ||
406
+ exists(path.join(state, 'agents')) ||
407
+ exists(path.join(state, 'workspace'))
408
+ )
409
+ }
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import { describe, expect, it } from 'vitest'
5
5
  import { buildProgram, type ProgramRunners } from './program.js'
6
6
  import type { CommandDeps } from './commands.js'
7
+ import { QUESTION_SETTLEMENT_INPUT_ENV } from './question-settlement-process.js'
7
8
 
8
9
  /**
9
10
  * The layer these tests own is the argv boundary: what a real command line
@@ -125,6 +126,30 @@ describe('program argv parsing', () => {
125
126
  expect(label).toBe('Hermes Support')
126
127
  })
127
128
 
129
+ it('hands the detached settlement input to the internal hook owner', async () => {
130
+ const deps = makeDeps()
131
+ const input = JSON.stringify({ session_id: 'session-1', cwd: '/tmp/project' })
132
+ deps.env[QUESTION_SETTLEMENT_INPUT_ENV] = input
133
+ let seen:
134
+ | { event: string; input: string; harness: string | undefined }
135
+ | undefined
136
+
137
+ const result = await parse(
138
+ ['hook', 'question-settlement', '--owner', 'notifai', '--harness', 'codex'],
139
+ {
140
+ hookRun: (async (_deps, event, readInput, harness) => {
141
+ seen = { event, input: await readInput(), harness }
142
+ return 0
143
+ }) as ProgramRunners['hookRun'],
144
+ },
145
+ deps,
146
+ )
147
+
148
+ expect(result.exitCode).toBe(0)
149
+ expect(seen).toEqual({ event: 'question-settlement', input, harness: 'codex' })
150
+ expect(deps.env[QUESTION_SETTLEMENT_INPUT_ENV]).toBeUndefined()
151
+ })
152
+
128
153
  it('dispatches sounds --json onto soundsCommand', async () => {
129
154
  let seen: Record<string, unknown> | undefined
130
155
  const result = await parse(['sounds', '--json'], {
@@ -166,6 +191,16 @@ describe('program argv parsing', () => {
166
191
  expect(seen).toMatchObject({ skills: true, setupScope: 'global', hooks: false })
167
192
  })
168
193
 
194
+ it('dispatches update --json onto the local CLI recovery', async () => {
195
+ let seen: Record<string, unknown> | undefined
196
+ const { exitCode } = await parse(
197
+ ['update', '--json'],
198
+ { update: ((_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['update'] },
199
+ )
200
+ expect(exitCode).toBe(0)
201
+ expect(seen).toEqual({ json: true })
202
+ })
203
+
169
204
  it('disentangles --no-wait and --wait from their shared commander flag', async () => {
170
205
  const capture = (sink: Record<string, unknown>[]): ProgramRunners['send'] =>
171
206
  (async (_deps, flags) => (sink.push(flags as Record<string, unknown>), 0)) as ProgramRunners['send']
package/src/program.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  authStatusCommand,
9
9
  capabilitiesCommand,
10
10
  closeCommand,
11
+ cliUpdateCommand,
11
12
  configExplainCommand,
12
13
  configSetCommand,
13
14
  configShowCommand,
@@ -42,6 +43,7 @@ import type { SkillScope } from './native-skills.js'
42
43
  import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
43
44
  import { readStdinWithTimeout } from './hook-input.js'
44
45
  import { argvFlagNames } from './logging.js'
46
+ import { QUESTION_SETTLEMENT_INPUT_ENV } from './question-settlement-process.js'
45
47
 
46
48
  /**
47
49
  * One source of truth for the version: the manifest npm actually published.
@@ -80,6 +82,7 @@ function commandPath(command: Command): string {
80
82
  const defaultRunners = {
81
83
  init: initCommand,
82
84
  doctor: doctorCommand,
85
+ update: cliUpdateCommand,
83
86
  login: loginCommand,
84
87
  logout: logoutCommand,
85
88
  authStatus: authStatusCommand,
@@ -180,11 +183,12 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
180
183
  /**
181
184
  * Bare `notifai`.
182
185
  *
183
- * At a human terminal this opens the interactive app; anywhere else it prints
184
- * help exactly as before. The check is the same one every other prompt in this
185
- * CLI is gated on — stdin and stdout both TTYs, not CI, `NOTIFAI_NO_INPUT`
186
- * unset — because an agent that reaches a prompt does not fail, it hangs
187
- * for ever waiting on a stdin nobody is typing into.
186
+ * At a human terminal this opens the interactive app, which enters `init`
187
+ * when the machine is not yet paired and otherwise offers daily actions.
188
+ * Anywhere else it prints help exactly as before. The check is the same one
189
+ * every other prompt in this CLI is gated on — stdin and stdout both TTYs,
190
+ * not CI, `NOTIFAI_NO_INPUT` unset — because an agent that reaches a prompt
191
+ * does not fail, it hangs for ever waiting on a stdin nobody is typing into.
188
192
  */
189
193
  /**
190
194
  * An unrecognised subcommand reaches the root as an excess argument, and
@@ -260,6 +264,16 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
260
264
  exit(await runners.doctor(deps, opts))
261
265
  })
262
266
 
267
+ program
268
+ .command('update')
269
+ .helpGroup(GROUP.start)
270
+ .summary('Update the Notifai command this shell uses')
271
+ .description('Update the effective global Notifai installation and keep Question Routing on it')
272
+ .option('--json', 'machine-readable installation and adapter result')
273
+ .action((opts: { json?: boolean }) => {
274
+ exit(runners.update(deps, opts))
275
+ })
276
+
263
277
  const project = program
264
278
  .command('project')
265
279
  .helpGroup(GROUP.daily)
@@ -292,7 +306,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
292
306
 
293
307
  program
294
308
  .command('login')
295
- .helpGroup(GROUP.start)
309
+ .helpGroup(GROUP.advanced)
296
310
  .summary('Sign in and pair this machine')
297
311
  .description('Pair this machine with your Notifai account via browser approval')
298
312
  .option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
@@ -499,10 +513,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
499
513
  })
500
514
 
501
515
  program
502
- .command('status <request_id>')
516
+ .command('status <question_or_request_id>')
503
517
  .helpGroup(GROUP.agent)
504
- .summary('Show the evidence trail for a request')
505
- .description('Show the evidence trail for a notification request')
518
+ .summary('Show question state or request evidence')
519
+ .description('Show local question state and, once submitted, the notification request evidence trail')
506
520
  .option('--json', 'machine-readable output')
507
521
  .action(async (requestId: string, opts: { json?: boolean }) => {
508
522
  exit(await runners.status(deps, requestId, opts))
@@ -628,7 +642,18 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
628
642
  .option('--harness <name>', 'internal harness output adapter')
629
643
  .action(async (event: string, opts: { harness?: string }) => {
630
644
  const harness = HOOK_INSTALLABLE_HARNESSES.find((candidate) => candidate === opts.harness)
631
- exit(await runners.hookRun(deps, event, () => readStdinWithTimeout(), harness))
645
+ const settlementInput = deps.env[QUESTION_SETTLEMENT_INPUT_ENV]
646
+ if (event === 'question-settlement') {
647
+ delete deps.env[QUESTION_SETTLEMENT_INPUT_ENV]
648
+ }
649
+ exit(await runners.hookRun(
650
+ deps,
651
+ event,
652
+ event === 'question-settlement' && settlementInput !== undefined
653
+ ? async () => settlementInput
654
+ : () => readStdinWithTimeout(),
655
+ harness,
656
+ ))
632
657
  })
633
658
 
634
659
  const hooks = program
@@ -641,7 +666,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
641
666
  .description('Wire this harness to route registered questions to your devices')
642
667
  .option(
643
668
  '--harness <name>',
644
- 'claude-code | codex | cursor | opencode (default: every detected harness)',
669
+ 'claude-code | codex | cursor | opencode | openclaw (default: every detected harness)',
645
670
  )
646
671
  .option('--global', 'install for every project instead of just this one')
647
672
  .action((opts: { harness?: string; global?: boolean }) => {
@@ -650,7 +675,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
650
675
  hooks
651
676
  .command('uninstall')
652
677
  .description('Remove the hooks this CLI installed')
653
- .option('--harness <name>', 'claude-code | codex | cursor | opencode (default: detected)')
678
+ .option('--harness <name>', 'claude-code | codex | cursor | opencode | openclaw (default: detected)')
654
679
  .option('--global', 'remove the machine-wide install')
655
680
  .action((opts: { harness?: string; global?: boolean }) => {
656
681
  exit(runners.hooksUninstall(deps, opts))
@@ -0,0 +1,47 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
3
+ import type { HookInstallableHarness } from './harnesses.js'
4
+ import type { HookEnvelope } from './hooks.js'
5
+
6
+ /** Private handoff from the short prompt hook to its detached question owner. */
7
+ export const QUESTION_SETTLEMENT_INPUT_ENV = 'NOTIFAI_INTERNAL_QUESTION_SETTLEMENT_INPUT'
8
+
9
+ export interface QuestionSettlementLaunch {
10
+ envelope: Pick<HookEnvelope, 'session_id' | 'cwd'>
11
+ harness: HookInstallableHarness
12
+ }
13
+
14
+ /**
15
+ * Launch the exact installed CLI build as a detached owner.
16
+ *
17
+ * UserPromptSubmit sits in front of the User's new turn, so it may only pay the
18
+ * process-spawn cost. The child owns submission and the complete answer window.
19
+ */
20
+ export function spawnQuestionSettlement(launch: QuestionSettlementLaunch): void {
21
+ const child = spawn(
22
+ process.execPath,
23
+ [
24
+ fileURLToPath(new URL('./main.js', import.meta.url)),
25
+ 'hook',
26
+ 'question-settlement',
27
+ '--owner',
28
+ 'notifai',
29
+ '--harness',
30
+ launch.harness,
31
+ ],
32
+ {
33
+ cwd: launch.envelope.cwd ?? process.cwd(),
34
+ detached: true,
35
+ env: {
36
+ ...process.env,
37
+ [QUESTION_SETTLEMENT_INPUT_ENV]: JSON.stringify(launch.envelope),
38
+ },
39
+ stdio: 'ignore',
40
+ windowsHide: true,
41
+ },
42
+ )
43
+ // Spawn failures are diagnosed by the next ordinary lifecycle hook, which
44
+ // still owns the durable registration. Never turn one into a prompt delay.
45
+ child.once('error', () => undefined)
46
+ child.unref()
47
+ }
package/src/readiness.ts CHANGED
@@ -80,15 +80,48 @@ export function firstBlocker(readiness: Readiness): ReadinessState | null {
80
80
  return readiness.states.find((s) => s.status === 'gap') ?? null
81
81
  }
82
82
 
83
+ /**
84
+ * Question Routing: what lets a question reach a phone when nobody is at the
85
+ * terminal. Hooks, their sub-states, and the setting that admits them.
86
+ *
87
+ * Separate from `isOptionalSetup` because these are also the states a human
88
+ * should not be handed as leftovers — "Optional, not set up" is a fair line
89
+ * about the agent skill and a confusing one about a session pointer.
90
+ */
91
+ export function isOptionalAutomation(id: string): boolean {
92
+ return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
93
+ }
94
+
95
+ /**
96
+ * Automation layered on top of a product that already sends: Question Routing
97
+ * and the agent guidance skill.
98
+ *
99
+ * Real, and reported. `doctor` is the report and judges it strictly. Anything
100
+ * whose job is to get a first notification delivered treats it as a line to
101
+ * print, never a gate — a hook diagnostic or a duplicate skill install in
102
+ * front of the delivery proof means a setup that can already send never proves
103
+ * that it can. The one that fires most often, a turn that has not ended yet,
104
+ * cannot be closed by the agent standing inside that turn at all.
105
+ */
106
+ export function isOptionalSetup(id: string): boolean {
107
+ return id === 'skill' || isOptionalAutomation(id)
108
+ }
109
+
110
+ /** The first thing in the way that is not optional automation. */
111
+ export function firstRequiredBlocker(readiness: Readiness): ReadinessState | null {
112
+ return readiness.states.find((s) => s.status === 'gap' && !isOptionalSetup(s.id)) ?? null
113
+ }
114
+
83
115
  /**
84
116
  * Ready enough to be useful, which is not the same as every box ticked.
85
117
  *
86
118
  * Hooks and the agent skill are genuinely optional — `send` works without
87
119
  * them — so an install that declined both is finished, not half-done. Saying
88
- * otherwise trains people to ignore the summary line.
120
+ * otherwise trains people to ignore the summary line, and it is the summary
121
+ * line an agent branches on.
89
122
  */
90
123
  export function isReady(readiness: Readiness): boolean {
91
- return firstBlocker(readiness) === null
124
+ return firstRequiredBlocker(readiness) === null
92
125
  }
93
126
 
94
127
  const SEND_PREREQUISITES = new Set([
@@ -31,7 +31,7 @@ export function sessionActivationOutput(
31
31
  const context = hookEventName === 'SubagentStart'
32
32
  ? WORKER_ACTIVATION_CONTEXT
33
33
  : rootActivationContext(cwd, env)
34
- if (harness === 'opencode') return context
34
+ if (harness === 'opencode' || harness === 'openclaw') return context
35
35
  if (harness === 'cursor') {
36
36
  return JSON.stringify({ additional_context: context })
37
37
  }