@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.
- package/CHANGELOG.md +31 -0
- package/README.md +4 -1
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +32 -14
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +10 -1
- package/dist/commands-core.js +13 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +158 -71
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.js +8 -0
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.js +169 -15
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -91
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/harnesses.d.ts +2 -2
- package/dist/harnesses.js +22 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.js +15 -9
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.js +20 -23
- package/dist/interactive.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/node-floor.d.ts +18 -0
- package/dist/node-floor.js +35 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/openclaw-plugin.d.ts +49 -0
- package/dist/openclaw-plugin.js +350 -0
- package/dist/openclaw-plugin.js.map +1 -0
- package/dist/program.d.ts +2 -1
- package/dist/program.js +31 -13
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/session-activation.js +1 -1
- package/dist/session-activation.js.map +1 -1
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/ui/help.d.ts +4 -5
- package/dist/ui/help.js +5 -6
- package/dist/ui/help.js.map +1 -1
- package/package.json +2 -2
- package/src/cli-bin.test.ts +145 -7
- package/src/cli-bin.ts +176 -10
- package/src/cli-update-contract.ts +9 -0
- package/src/cli-update.test.ts +125 -0
- package/src/client.ts +7 -0
- package/src/codex-wake.ts +5 -1
- package/src/commands-auth.ts +47 -13
- package/src/commands-core.ts +21 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +197 -75
- package/src/commands-harness-context.ts +8 -0
- package/src/commands-hooks.ts +210 -18
- package/src/commands-init.ts +275 -92
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +701 -53
- package/src/commands.ts +2 -0
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
- package/src/harness-integration.test.ts +82 -0
- package/src/harnesses.test.ts +9 -1
- package/src/harnesses.ts +24 -2
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- package/src/install-hooks.test.ts +208 -0
- package/src/install-hooks.ts +19 -9
- package/src/interactive.test.ts +31 -0
- package/src/interactive.ts +20 -26
- package/src/main-run.ts +45 -0
- package/src/main.ts +9 -33
- package/src/node-floor.test.ts +27 -0
- package/src/node-floor.ts +37 -0
- package/src/openclaw-plugin.ts +409 -0
- package/src/program.test.ts +35 -0
- package/src/program.ts +37 -12
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/session-activation.ts +1 -1
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +13 -2
- package/src/ui/help.test.ts +16 -0
- package/src/ui/help.ts +6 -7
- 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
|
+
}
|
package/src/program.test.ts
CHANGED
|
@@ -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
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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.
|
|
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 <
|
|
516
|
+
.command('status <question_or_request_id>')
|
|
503
517
|
.helpGroup(GROUP.agent)
|
|
504
|
-
.summary('Show
|
|
505
|
-
.description('Show
|
|
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
|
-
|
|
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
|
|
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
|
}
|