@raidiant/notifai 11.4.0 → 11.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +7 -3
  3. package/dist/codex-tool-messages.d.ts +17 -0
  4. package/dist/codex-tool-messages.js +142 -0
  5. package/dist/codex-tool-messages.js.map +1 -0
  6. package/dist/commands-acknowledge.js +8 -0
  7. package/dist/commands-acknowledge.js.map +1 -1
  8. package/dist/commands-ask.js +14 -2
  9. package/dist/commands-ask.js.map +1 -1
  10. package/dist/commands-doctor.js +140 -36
  11. package/dist/commands-doctor.js.map +1 -1
  12. package/dist/commands-harness-context.js +3 -0
  13. package/dist/commands-harness-context.js.map +1 -1
  14. package/dist/commands-hook-attend.d.ts +10 -6
  15. package/dist/commands-hook-attend.js +80 -32
  16. package/dist/commands-hook-attend.js.map +1 -1
  17. package/dist/commands-hook-diagnostics.d.ts +11 -1
  18. package/dist/commands-hook-diagnostics.js +40 -1
  19. package/dist/commands-hook-diagnostics.js.map +1 -1
  20. package/dist/commands-hook-install.js +162 -21
  21. package/dist/commands-hook-install.js.map +1 -1
  22. package/dist/commands-hook-run.js +223 -10
  23. package/dist/commands-hook-run.js.map +1 -1
  24. package/dist/commands-hook-shape.d.ts +4 -3
  25. package/dist/commands-hook-shape.js +5 -4
  26. package/dist/commands-hook-shape.js.map +1 -1
  27. package/dist/commands-send.d.ts +1 -0
  28. package/dist/commands-send.js +146 -8
  29. package/dist/commands-send.js.map +1 -1
  30. package/dist/commands-update.js +22 -7
  31. package/dist/commands-update.js.map +1 -1
  32. package/dist/harnesses.d.ts +8 -9
  33. package/dist/harnesses.js +25 -7
  34. package/dist/harnesses.js.map +1 -1
  35. package/dist/hermes-plugin.d.ts +11 -0
  36. package/dist/hermes-plugin.js +161 -0
  37. package/dist/hermes-plugin.js.map +1 -0
  38. package/dist/hook-acknowledgements.d.ts +5 -1
  39. package/dist/hook-acknowledgements.js +28 -2
  40. package/dist/hook-acknowledgements.js.map +1 -1
  41. package/dist/hook-events.d.ts +9 -2
  42. package/dist/hook-events.js +16 -6
  43. package/dist/hook-events.js.map +1 -1
  44. package/dist/hook-lifecycle.js +15 -2
  45. package/dist/hook-lifecycle.js.map +1 -1
  46. package/dist/hook-session-state.d.ts +4 -1
  47. package/dist/hook-session-state.js +16 -4
  48. package/dist/hook-session-state.js.map +1 -1
  49. package/dist/hook-types.d.ts +26 -0
  50. package/dist/install-hooks.js +51 -4
  51. package/dist/install-hooks.js.map +1 -1
  52. package/dist/openclaw-continuation-bridge.d.ts +7 -0
  53. package/dist/openclaw-continuation-bridge.js +64 -0
  54. package/dist/openclaw-continuation-bridge.js.map +1 -0
  55. package/dist/openclaw-gateway-readiness.d.ts +1 -0
  56. package/dist/openclaw-gateway-readiness.js +24 -0
  57. package/dist/openclaw-gateway-readiness.js.map +1 -0
  58. package/dist/openclaw-generation.d.ts +17 -0
  59. package/dist/openclaw-generation.js +93 -0
  60. package/dist/openclaw-generation.js.map +1 -0
  61. package/dist/openclaw-pending.d.ts +8 -0
  62. package/dist/openclaw-pending.js +52 -0
  63. package/dist/openclaw-pending.js.map +1 -0
  64. package/dist/openclaw-plugin.d.ts +22 -4
  65. package/dist/openclaw-plugin.js +701 -87
  66. package/dist/openclaw-plugin.js.map +1 -1
  67. package/dist/openclaw-session-access.d.ts +10 -0
  68. package/dist/openclaw-session-access.js +69 -0
  69. package/dist/openclaw-session-access.js.map +1 -0
  70. package/dist/program.js +8 -7
  71. package/dist/program.js.map +1 -1
  72. package/dist/session-activation.js +4 -4
  73. package/dist/session-activation.js.map +1 -1
  74. package/dist/session-attendant-probe.d.ts +10 -0
  75. package/dist/session-attendant-probe.js +31 -0
  76. package/dist/session-attendant-probe.js.map +1 -1
  77. package/dist/session-attendant-state.d.ts +8 -4
  78. package/dist/session-attendant-state.js +17 -10
  79. package/dist/session-attendant-state.js.map +1 -1
  80. package/dist/session-delivery.d.ts +3 -1
  81. package/dist/session-delivery.js +6 -2
  82. package/dist/session-delivery.js.map +1 -1
  83. package/dist/session-handoff.d.ts +1 -1
  84. package/dist/session-message-handoff.d.ts +2 -0
  85. package/dist/session-message-handoff.js +2 -0
  86. package/dist/session-message-handoff.js.map +1 -1
  87. package/dist/skill-source/manifest.json +6 -6
  88. package/dist/skill-source/notifai/SKILL.md +2 -2
  89. package/dist/skill-source/notifai/references/harness-setup.md +51 -18
  90. package/dist/skill-source/notifai/references/notes-and-edits.md +6 -1
  91. package/dist/skill-source/notifai/references/updates.md +6 -6
  92. package/dist/update-handoff.d.ts +1 -1
  93. package/dist/update-handoff.js +1 -0
  94. package/dist/update-handoff.js.map +1 -1
  95. package/package.json +3 -2
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { applyEdits, createScanner, findNodeAtLocation, modify, parse, parseTree, printParseErrorCode, } from 'jsonc-parser';
2
3
  import { hookHostPlatform } from './hook-adapter.js';
3
4
  import { harnessAccountHome } from './install-hooks.js';
4
5
  import { MISSING_LIFECYCLE_GUIDANCE_CONTEXT, WORKER_ACTIVATION_CONTEXT, } from './session-activation.js';
@@ -10,21 +11,23 @@ import { MISSING_LIFECYCLE_GUIDANCE_CONTEXT, WORKER_ACTIVATION_CONTEXT, } from '
10
11
  * commands every other harness invokes, so presence, activation, and
11
12
  * retirement stay in the CLI.
12
13
  *
13
- * before_prompt_build -> session-start / subagent-start (current owner/worker guidance)
14
+ * session_start -> openclaw-lifecycle (generation observation)
15
+ * before_reset -> openclaw-lifecycle (retire the old generation)
16
+ * before_prompt_build -> session-start / subagent-start (context once per generation)
14
17
  * message_received -> user-prompt-submit (User is present)
15
18
  * agent_end -> stop (the turn ended)
16
19
  * session_end -> session-end (retire local state)
17
20
  * resolve_exec_env -> NOTIFAI_ACTIVE_* markers (exact Source Context)
18
21
  *
19
- * `command:stop` is a User abort and is deliberately not wired. Asynchronous
20
- * ask is unsupported until a live continuation probe.
22
+ * `command:stop` is a User abort and is deliberately not wired. The Gateway
23
+ * service owns answer continuation after agent_end records its turn boundary.
21
24
  */
22
25
  export const OPENCLAW_PLUGIN_MARKER = '// notifai managed openclaw plugin';
23
26
  export const OPENCLAW_PLUGIN_ID = 'notifai';
24
27
  export const OPENCLAW_PLUGIN_FILENAME = 'index.js';
25
28
  export const OPENCLAW_PLUGIN_MANIFEST = 'openclaw.plugin.json';
26
29
  export const OPENCLAW_PLUGIN_PACKAGE = 'package.json';
27
- const OPENCLAW_ADAPTER_VERSION = 2;
30
+ const OPENCLAW_ADAPTER_VERSION = 4;
28
31
  export function openclawStateDir(env = process.env, platform = process.platform) {
29
32
  const override = env['OPENCLAW_STATE_DIR'];
30
33
  if (override !== undefined && override !== '')
@@ -54,6 +57,403 @@ export function legacyOpenclawProjectPluginDir(cwd) {
54
57
  export function legacyOpenclawProjectPluginPath(cwd) {
55
58
  return path.join(legacyOpenclawProjectPluginDir(cwd), OPENCLAW_PLUGIN_FILENAME);
56
59
  }
60
+ /** Gateway-owned answer pointer delivery. The generated module stays standalone. */
61
+ function openclawContinuationServiceSource() {
62
+ return `
63
+ let continuationService = null
64
+ let JOURNAL_DIR = null
65
+
66
+ function readinessPath() {
67
+ return path.join(path.dirname(JOURNAL_DIR), "continuation-ready.json")
68
+ }
69
+
70
+ function writeReadiness(target) {
71
+ const start = execFileSync("ps", ["-o", "lstart=", "-p", String(process.pid)], {
72
+ encoding: "utf8", timeout: 2_000,
73
+ env: { PATH: process.env.PATH ?? "/bin:/usr/bin", TZ: "UTC", LC_ALL: "C" },
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ }).trim().replace(/\\s+/g, " ")
76
+ if (start === "") return false
77
+ const file = readinessPath()
78
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
79
+ const temp = file + "." + randomUUID() + ".tmp"
80
+ writeFileSync(temp, JSON.stringify({ pid: process.pid, start,
81
+ script: target.script, script_mtime: statSync(target.script).mtimeMs }) + "\\n",
82
+ { mode: 0o600, flag: "wx" })
83
+ renameSync(temp, file)
84
+ return true
85
+ }
86
+
87
+ function clearReadiness() {
88
+ try { unlinkSync(readinessPath()) } catch { /* Already absent. */ }
89
+ }
90
+
91
+ function journalPath(deliveryId) {
92
+ return path.join(JOURNAL_DIR, deliveryId + ".json")
93
+ }
94
+
95
+ function saveJournal(record) {
96
+ mkdirSync(JOURNAL_DIR, { recursive: true, mode: 0o700 })
97
+ const file = journalPath(record.delivery_id)
98
+ const temp = file + "." + randomUUID() + ".tmp"
99
+ writeFileSync(temp, JSON.stringify(record) + "\\n", { mode: 0o600, flag: "wx" })
100
+ const fd = openSync(temp, "r")
101
+ try { fsyncSync(fd) } finally { closeSync(fd) }
102
+ renameSync(temp, file)
103
+ try {
104
+ const directory = openSync(JOURNAL_DIR, "r")
105
+ try { fsyncSync(directory) } finally { closeSync(directory) }
106
+ } catch { /* Directory fsync is unavailable on some hosts. */ }
107
+ }
108
+
109
+ function readJournals() {
110
+ try {
111
+ return readdirSync(JOURNAL_DIR).filter((name) => name.endsWith(".json"))
112
+ .flatMap((name) => {
113
+ try {
114
+ const record = JSON.parse(readFileSync(path.join(JOURNAL_DIR, name), "utf8"))
115
+ return record && typeof record.delivery_id === "string" ? [record] : []
116
+ } catch { return [] }
117
+ })
118
+ } catch { return [] }
119
+ }
120
+
121
+ function existingJournal(deliveryId) {
122
+ try {
123
+ return JSON.parse(readFileSync(journalPath(deliveryId), "utf8"))
124
+ } catch { return null }
125
+ }
126
+
127
+ function openclawCliTarget(config) {
128
+ if (process.env.OPENCLAW_SHELL === "exec") return null
129
+ const gateway = config?.gateway ?? {}
130
+ if (gateway.mode === "remote" || (gateway.bind !== undefined && gateway.bind !== "loopback")) return null
131
+ let script
132
+ try { script = realpathSync(process.argv[1]) } catch { return null }
133
+ let folder = path.dirname(script)
134
+ let packageVersion = null
135
+ for (;;) {
136
+ try {
137
+ const manifest = JSON.parse(readFileSync(path.join(folder, "package.json"), "utf8"))
138
+ if (manifest.name === "openclaw" && typeof manifest.version === "string") {
139
+ packageVersion = manifest.version
140
+ break
141
+ }
142
+ } catch { /* Keep walking within this executable's ancestors. */ }
143
+ const parent = path.dirname(folder)
144
+ if (parent === folder) return null
145
+ folder = parent
146
+ }
147
+ const explicit = []
148
+ for (let i = 0; i < process.argv.length - 1; i += 1) {
149
+ if (process.argv[i] === "--port") explicit.push(Number(process.argv[i + 1]))
150
+ }
151
+ if (explicit.length > 1) return null
152
+ const configured = Number(gateway.port ?? process.env.OPENCLAW_GATEWAY_PORT ?? 18789)
153
+ const port = explicit[0] ?? configured
154
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null
155
+ return { script, port, packageVersion }
156
+ }
157
+
158
+ function openclawExec(target, args) {
159
+ return new Promise((resolve, reject) => {
160
+ execFile(process.execPath, [target.script, ...args], {
161
+ shell: false, windowsHide: true, timeout: 20_000, maxBuffer: 2_000_000,
162
+ env: process.env,
163
+ }, (error, stdout) => {
164
+ if (error) reject(new Error("OpenClaw CLI call failed"))
165
+ else resolve(stdout.trim())
166
+ })
167
+ })
168
+ }
169
+
170
+ async function gatewayCall(target, method, params) {
171
+ const output = await openclawExec(target, ["gateway", "call", method,
172
+ "--port", String(target.port), "--params", JSON.stringify(params), "--json"])
173
+ return JSON.parse(output)
174
+ }
175
+
176
+ async function verifiedGatewayTarget(config, logger) {
177
+ const target = openclawCliTarget(config)
178
+ if (target === null) {
179
+ logger?.warn?.("notifai continuation target: local CLI path or Gateway bind unavailable")
180
+ return null
181
+ }
182
+ try {
183
+ const banner = (await openclawExec(target, ["--version"])).trim()
184
+ const cliVersion = /^OpenClaw ([0-9]+\\.[0-9]+\\.[0-9]+)(?: \\([0-9a-f]+\\))?$/.exec(banner)?.[1]
185
+ const status = await gatewayCall(target, "status", {})
186
+ if (cliVersion !== target.packageVersion || status?.runtimeVersion !== cliVersion) {
187
+ logger?.warn?.("notifai continuation target: CLI and Gateway versions differ")
188
+ return null
189
+ }
190
+ if (status?.pid !== process.pid) {
191
+ logger?.warn?.("notifai continuation target: Gateway process identity differs")
192
+ return null
193
+ }
194
+ return target
195
+ } catch {
196
+ logger?.warn?.("notifai continuation target: local CLI status unavailable")
197
+ return null
198
+ }
199
+ }
200
+
201
+ function submissionKey(record) {
202
+ return "notifai-" + record.delivery_id + "-a" + record.attempt
203
+ }
204
+
205
+ function submissionIdentity(message) {
206
+ const meta = message?.metadata ?? message?.__openclaw ?? {}
207
+ return message?.idempotencyKey ?? meta.idempotencyKey ?? null
208
+ }
209
+
210
+ async function inspectSubmission(target, record) {
211
+ let offset = 0
212
+ let before
213
+ let pendingFinished = false
214
+ let interrupted = false
215
+ for (let page = 0; page < 1000; page += 1) {
216
+ const params = { sessionKey: record.session_key, limit: 200, offset,
217
+ ...(before === undefined ? {} : { pendingBefore: before }) }
218
+ const history = await gatewayCall(target, "chat.history", params)
219
+ if (history?.kind === "reset" || history?.truncationReason ||
220
+ (record.openclaw_session_id && history?.sessionInfo?.sessionId &&
221
+ history.sessionInfo.sessionId !== record.openclaw_session_id)) return "unconfirmed"
222
+ const messages = history?.messages
223
+ if (!Array.isArray(messages)) return "unconfirmed"
224
+ if (messages.some((entry) => entry?.role === "user" &&
225
+ submissionIdentity(entry) === submissionKey(record) + ":user")) return "transcript"
226
+ const pending = history?.pendingInputs
227
+ if (!pending || !Array.isArray(pending.items) ||
228
+ !Number.isInteger(pending.total) || pending.total < pending.items.length ||
229
+ typeof history.hasMore !== "boolean") return "unconfirmed"
230
+ for (const item of pending.items) {
231
+ if (item?.runId !== submissionKey(record) &&
232
+ item?.idempotencyKey !== submissionKey(record)) continue
233
+ if (item.state === "queued" || item.state === "running") return "pending"
234
+ if (item.state === "interrupted" || item.state === "cancelled") interrupted = true
235
+ else return "unconfirmed"
236
+ }
237
+ const nextOffset = history.nextOffset
238
+ const hasMore = history.hasMore === true
239
+ const nextBefore = pending.nextBefore
240
+ if (!pendingFinished && typeof nextBefore === "string" && nextBefore !== before) {
241
+ before = nextBefore
242
+ continue
243
+ }
244
+ if (!pendingFinished && pending.total > pending.items.length) return "unconfirmed"
245
+ pendingFinished = true
246
+ if (hasMore && Number.isInteger(nextOffset) && nextOffset > offset) {
247
+ offset = nextOffset
248
+ before = undefined
249
+ continue
250
+ }
251
+ if (hasMore) return "unconfirmed"
252
+ return interrupted ? "interrupted" : "absent"
253
+ }
254
+ return "unconfirmed"
255
+ }
256
+
257
+ function pointerMessage(record) {
258
+ return "A Notifai reply is waiting for this session. " +
259
+ record.request_ids.map((id) => "Run notifai replies " + id +
260
+ " --json, then notifai acknowledge " + id +
261
+ " with the concrete work you will do.").join(" ")
262
+ }
263
+
264
+ function spawnOwnedHook(event, envelope, extraFds = false) {
265
+ const child = spawn(HOOK_COMMAND, [...HOOK_PREFIX, "hook", event,
266
+ "--owner", "notifai", "--harness", "openclaw"], {
267
+ cwd: envelope.cwd,
268
+ env: { ...process.env, NOTIFAI_HOOK_SOURCE_PID: String(process.pid) },
269
+ shell: false, windowsHide: true,
270
+ stdio: extraFds ? ["pipe", "ignore", "ignore", "pipe", "pipe"] : ["pipe", "ignore", "ignore"],
271
+ })
272
+ child.stdin.end(JSON.stringify(envelope))
273
+ return child
274
+ }
275
+
276
+ function makeContinuationService(config, logger) {
277
+ const active = new Map()
278
+ const delivering = new Set()
279
+ const reported = new Map()
280
+ let stopped = false
281
+ let timer = null
282
+ let scanning = false
283
+ let ready = false
284
+
285
+ function report(record, reason) {
286
+ if (reported.get(record.delivery_id) === reason) return
287
+ reported.set(record.delivery_id, reason)
288
+ logger?.warn?.("notifai continuation " + record.delivery_id + ": " + reason)
289
+ }
290
+
291
+ async function deliver(record) {
292
+ if (delivering.has(record.delivery_id) || record.phase === "transcript") return
293
+ delivering.add(record.delivery_id)
294
+ try {
295
+ const envelope = { session_id: record.session_key, cwd: record.cwd,
296
+ openclaw_session_id: record.openclaw_session_id }
297
+ const generation = (await runHook("openclaw-generation", envelope))?.trim()
298
+ if (generation !== record.generation) { report(record, "generation-fenced"); return }
299
+ if (record.phase === "prepared") {
300
+ const proved = await runHook("openclaw-verify-prepared", {
301
+ ...envelope, openclaw_request_ids: record.request_ids })
302
+ if (proved?.trim() !== "committed") return
303
+ record = { ...record, phase: "committed" }
304
+ saveJournal(record)
305
+ }
306
+ const target = await verifiedGatewayTarget(config, logger)
307
+ if (target === null) { report(record, "gateway-target-unverified"); return }
308
+ const state = await inspectSubmission(target, record)
309
+ if (state === "unconfirmed") { report(record, "history-unconfirmed"); return }
310
+ if (state === "transcript") {
311
+ saveJournal({ ...record, phase: "transcript" })
312
+ return
313
+ }
314
+ if (state === "pending") {
315
+ if (record.phase !== "admitted") saveJournal({ ...record, phase: "admitted" })
316
+ return
317
+ }
318
+ if (state === "interrupted" || (state === "absent" && record.phase === "admitted")) {
319
+ record = { ...record, attempt: record.attempt + 1, phase: "committed" }
320
+ saveJournal(record)
321
+ }
322
+ if (state !== "absent" && state !== "interrupted") return
323
+ saveJournal({ ...record, phase: "submitting" })
324
+ report(record, "submitting-pointer")
325
+ await gatewayCall(target, "chat.send", { sessionKey: record.session_key,
326
+ message: pointerMessage(record), queueMode: "followup",
327
+ idempotencyKey: submissionKey(record) })
328
+ saveJournal({ ...record, phase: "admitted" })
329
+ report(record, "pointer-admitted")
330
+ } catch {
331
+ // An uncertain RPC is reconciled by identity before any retry.
332
+ report(record, "rpc-or-journal-unconfirmed")
333
+ } finally {
334
+ delivering.delete(record.delivery_id)
335
+ }
336
+ }
337
+
338
+ function startSettlement(session, entry) {
339
+ const envelope = { session_id: session.session_key, cwd: session.cwd,
340
+ openclaw_session_id: session.session_id, hook_event_name: "SessionStart" }
341
+ const child = spawnOwnedHook("openclaw-settlement", envelope, true)
342
+ entry.child = child
343
+ let buffer = ""
344
+ let prepared = null
345
+ child.stdio[3].setEncoding("utf8")
346
+ child.stdio[3].on("data", (chunk) => {
347
+ buffer += chunk
348
+ for (;;) {
349
+ const end = buffer.indexOf("\\n")
350
+ if (end < 0) break
351
+ const line = buffer.slice(0, end)
352
+ buffer = buffer.slice(end + 1)
353
+ let payload
354
+ try { payload = JSON.parse(line) } catch { continue }
355
+ if (payload.type === "prepare") {
356
+ const ids = payload.request_ids
357
+ if (payload.session_key !== session.session_key ||
358
+ payload.generation !== session.generation || !Array.isArray(ids) ||
359
+ ids.length < 1 || ids.some((id) => typeof id !== "string" ||
360
+ !/^req_[A-Za-z0-9_-]+$/.test(id))) continue
361
+ const deliveryId = createHash("sha256")
362
+ .update(session.generation + "\\0" + ids.join("\\0"))
363
+ .digest("hex").slice(0, 32)
364
+ const existing = existingJournal(deliveryId)
365
+ if (existing === null && existsSync(journalPath(deliveryId))) continue
366
+ if (existing !== null && (existing.session_key !== session.session_key ||
367
+ existing.generation !== session.generation ||
368
+ JSON.stringify(existing.request_ids) !== JSON.stringify(ids))) continue
369
+ prepared = existing ?? { delivery_id: deliveryId, session_key: session.session_key,
370
+ cwd: session.cwd, generation: session.generation,
371
+ openclaw_session_id: session.session_id, request_ids: ids,
372
+ attempt: 1, phase: "prepared" }
373
+ if (existing === null) saveJournal(prepared)
374
+ child.stdio[4].write('{"type":"prepared"}\\n')
375
+ } else if (payload.type === "committed" && prepared !== null) {
376
+ if (prepared.phase === "prepared") {
377
+ prepared = { ...prepared, phase: "committed" }
378
+ saveJournal(prepared)
379
+ }
380
+ void deliver(prepared)
381
+ }
382
+ }
383
+ })
384
+ child.on("close", () => {
385
+ const current = active.get(session.session_key)
386
+ if (current?.child === child) {
387
+ current.child = null
388
+ }
389
+ })
390
+ }
391
+
392
+ function startSession(session) {
393
+ const envelope = { session_id: session.session_key, cwd: session.cwd,
394
+ openclaw_session_id: session.session_id, hook_event_name: "SessionStart" }
395
+ const entry = { generation: session.generation,
396
+ attendant: spawnOwnedHook("attend", envelope), child: null }
397
+ active.set(session.session_key, entry)
398
+ startSettlement(session, entry)
399
+ }
400
+
401
+ async function tick() {
402
+ if (stopped || scanning) return
403
+ scanning = true
404
+ try {
405
+ if (!ready) {
406
+ const target = await verifiedGatewayTarget(config, logger)
407
+ if (target === null || !writeReadiness(target)) return
408
+ ready = true
409
+ }
410
+ const raw = await runHook("openclaw-list-pending", { cwd: process.cwd() })
411
+ const pending = raw === null ? [] : JSON.parse(raw)
412
+ if (Array.isArray(pending)) for (const session of pending) {
413
+ if (typeof session?.session_key !== "string" ||
414
+ typeof session?.cwd !== "string" ||
415
+ typeof session?.generation !== "string") continue
416
+ const current = active.get(session.session_key)
417
+ if (current?.generation === session.generation) {
418
+ if (current.attendant.exitCode !== null) {
419
+ active.delete(session.session_key)
420
+ if (current.child !== null) current.child.kill("SIGTERM")
421
+ startSession(session)
422
+ } else if (current.child === null) {
423
+ const ready = await runHook("openclaw-attendance-ready", {
424
+ session_id: session.session_key, cwd: session.cwd,
425
+ openclaw_session_id: session.session_id })
426
+ if (ready?.trim() === "ready") startSettlement(session, current)
427
+ }
428
+ continue
429
+ }
430
+ if (current) {
431
+ if (current.child !== null) current.child.kill("SIGTERM")
432
+ current.attendant.kill("SIGTERM")
433
+ }
434
+ startSession(session)
435
+ }
436
+ for (const record of readJournals()) void deliver(record)
437
+ } catch { /* The next Gateway tick retries discovery. */ }
438
+ finally { scanning = false }
439
+ }
440
+
441
+ return {
442
+ start() { timer = setInterval(() => { void tick() }, 2_000); void tick() },
443
+ stop() {
444
+ stopped = true
445
+ clearReadiness()
446
+ if (timer !== null) clearInterval(timer)
447
+ for (const entry of active.values()) {
448
+ if (entry.child !== null) entry.child.kill("SIGTERM")
449
+ entry.attendant.kill("SIGTERM")
450
+ }
451
+ active.clear()
452
+ },
453
+ }
454
+ }
455
+ `;
456
+ }
57
457
  export function openclawPluginManifest() {
58
458
  return `${JSON.stringify({
59
459
  id: OPENCLAW_PLUGIN_ID,
@@ -84,9 +484,14 @@ export function openclawPluginSource(options) {
84
484
  return `${OPENCLAW_PLUGIN_MARKER}
85
485
  // Generated by \`notifai hooks install --harness openclaw\`. Edits are lost on
86
486
  // the next install; change the CLI instead, which is where the logic lives.
87
- import { spawn } from "node:child_process"
487
+ import { spawn, execFile, execFileSync } from "node:child_process"
488
+ import { createHash, randomUUID } from "node:crypto"
489
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
490
+ import path from "node:path"
88
491
 
89
492
  ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
493
+ const HOOK_COMMAND = ${JSON.stringify(win32 ? options.nodePath ?? process.execPath : adapterPath)}
494
+ const HOOK_PREFIX = ${JSON.stringify(win32 ? [adapterPath] : [])}
90
495
  const TIMEOUT_MS = ${timeoutSeconds * 1000}
91
496
  const ADAPTER_VERSION = ${OPENCLAW_ADAPTER_VERSION}
92
497
  const MISSING_LIFECYCLE_GUIDANCE_CONTEXT = ${JSON.stringify(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)}
@@ -140,6 +545,21 @@ function sessionKeyOf(event, ctx) {
140
545
  return typeof key === "string" ? key.trim() : ""
141
546
  }
142
547
 
548
+ function sessionIdOf(event, ctx) {
549
+ const id = event?.sessionId ?? ctx?.sessionId
550
+ return typeof id === "string" && id.length > 0 ? id : undefined
551
+ }
552
+
553
+ function resumedFromOf(event) {
554
+ return typeof event?.resumedFrom === "string" && event.resumedFrom.length > 0
555
+ ? event.resumedFrom : undefined
556
+ }
557
+
558
+ function reasonOf(event) {
559
+ return typeof event?.reason === "string" && event.reason.length > 0
560
+ ? event.reason : undefined
561
+ }
562
+
143
563
  function workspaceDirOf(event, ctx) {
144
564
  const dir = ctx?.workspaceDir ?? event?.workspaceDir ?? event?.context?.workspaceDir
145
565
  return typeof dir === "string" && dir.length > 0 ? dir : process.cwd()
@@ -162,18 +582,65 @@ function isUserMessage(event, ctx) {
162
582
  return true
163
583
  }
164
584
 
585
+ function envelopeFor(sessionKey, event, ctx, hookEventName) {
586
+ return {
587
+ session_id: sessionKey,
588
+ cwd: workspaceDirOf(event, ctx),
589
+ hook_event_name: hookEventName,
590
+ openclaw_session_id: sessionIdOf(event, ctx),
591
+ openclaw_reason: reasonOf(event),
592
+ openclaw_resumed_from: resumedFromOf(event),
593
+ }
594
+ }
595
+
596
+ function onIfSupported(api, name, handler) {
597
+ try {
598
+ api.on(name, handler)
599
+ } catch {
600
+ // Older OpenClaw builds may reject a typed hook. The prompt fallback
601
+ // remains available, while unobserved rotations cannot be guessed.
602
+ }
603
+ }
604
+
605
+ ${openclawContinuationServiceSource()}
606
+
165
607
  function register(api) {
608
+ api.registerService?.({
609
+ id: "notifai-continuation",
610
+ start(ctx) {
611
+ if (typeof ctx.stateDir !== "string" || !path.isAbsolute(ctx.stateDir)) return
612
+ JOURNAL_DIR = path.join(ctx.stateDir, "notifai", "continuation-journal")
613
+ continuationService = makeContinuationService(ctx.config, ctx.logger)
614
+ continuationService.start()
615
+ },
616
+ stop() {
617
+ continuationService?.stop()
618
+ continuationService = null
619
+ },
620
+ })
621
+ onIfSupported(api, "session_start", async (event, ctx) => {
622
+ const sessionKey = sessionKeyOf(event, ctx)
623
+ if (sessionKey === "") return
624
+ await runHook("openclaw-lifecycle", envelopeFor(sessionKey, event, ctx, "SessionStart"))
625
+ })
626
+
627
+ onIfSupported(api, "before_reset", async (event, ctx) => {
628
+ const sessionKey = sessionKeyOf(event, ctx)
629
+ if (sessionKey === "") return
630
+ await runHook("openclaw-lifecycle", envelopeFor(sessionKey, event, ctx, "BeforeReset"))
631
+ })
632
+
166
633
  api.on("before_prompt_build", async (event, ctx) => {
167
634
  const sessionKey = sessionKeyOf(event, ctx)
168
635
  if (sessionKey === "") return { prependContext: WORKER_ACTIVATION_CONTEXT }
169
636
  const worker = isWorkerSession(sessionKey, ctx)
170
- const resolved = await runHook(worker ? "subagent-start" : "session-start", {
171
- session_id: sessionKey,
172
- cwd: workspaceDirOf(event, ctx),
173
- hook_event_name: worker ? "SubagentStart" : "SessionStart",
174
- })
175
- // Successful empty output means this Project is disabled. A later prompt
176
- // rechecks enablement and supplies context after compaction or re-enable.
637
+ const resolved = await runHook(worker ? "subagent-start" : "session-start",
638
+ envelopeFor(sessionKey, event, ctx, worker ? "SubagentStart" : "SessionStart"))
639
+ // chat.send does not emit message_received on this supported Gateway path.
640
+ // The prompt build is the exact session's authoritative turn-start seam.
641
+ await runHook("openclaw-turn-start", envelopeFor(sessionKey, event, ctx, "BeforePromptBuild"))
642
+ // Successful empty output means this Project is disabled or this
643
+ // generation was already activated. A later prompt rechecks enablement.
177
644
  if (resolved !== null && resolved.trim().length === 0) return
178
645
  const context = typeof resolved === "string" && resolved.trim().length > 0
179
646
  ? resolved.trim()
@@ -195,29 +662,25 @@ function register(api) {
195
662
  api.on("agent_end", async (event, ctx) => {
196
663
  const sessionKey = sessionKeyOf(event, ctx)
197
664
  if (sessionKey === "") return
198
- await runHook("stop", {
199
- session_id: sessionKey,
200
- cwd: workspaceDirOf(event, ctx),
201
- hook_event_name: "Stop",
202
- })
665
+ await runHook("openclaw-turn-end", envelopeFor(sessionKey, event, ctx, "Stop"))
203
666
  })
204
667
 
205
668
  api.on("session_end", async (event, ctx) => {
206
669
  const sessionKey = sessionKeyOf(event, ctx)
207
670
  if (sessionKey === "") return
208
- await runHook("session-end", {
209
- session_id: sessionKey,
210
- cwd: workspaceDirOf(event, ctx),
211
- hook_event_name: "SessionEnd",
212
- })
671
+ await runHook("session-end", envelopeFor(sessionKey, event, ctx, "SessionEnd"))
213
672
  })
214
673
 
215
- api.on("resolve_exec_env", (event, ctx) => {
674
+ api.on("resolve_exec_env", async (event, ctx) => {
216
675
  const sessionKey = sessionKeyOf(event, ctx)
217
676
  if (sessionKey === "") return
677
+ const generation = await runHook("openclaw-generation",
678
+ envelopeFor(sessionKey, event, ctx, "ResolveExecEnv"))
679
+ if (generation === null || !/^[0-9a-f-]{36}$/i.test(generation.trim())) return
218
680
  return {
219
681
  NOTIFAI_ACTIVE_HARNESS: "openclaw",
220
682
  NOTIFAI_ACTIVE_SESSION_ID: sessionKey,
683
+ NOTIFAI_ACTIVE_OPENCLAW_GENERATION: generation.trim(),
221
684
  }
222
685
  })
223
686
  }
@@ -253,24 +716,193 @@ export function openclawPluginTarget(contents) {
253
716
  return null;
254
717
  }
255
718
  }
256
- export function parseOpenclawConfig(source) {
257
- try {
258
- const parsed = JSON.parse(source);
259
- if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
260
- return parsed;
719
+ function isJsonObject(value) {
720
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
721
+ }
722
+ export function parseOpenclawConfig(source, configFile = 'OpenClaw config') {
723
+ const errors = [];
724
+ const parsed = parse(source, errors, { allowTrailingComma: true });
725
+ if (errors.length > 0) {
726
+ const problems = errors
727
+ .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
728
+ .join(', ');
729
+ throw new Error(`${configFile} contains invalid JSONC (${problems}); refusing to write`);
730
+ }
731
+ if (isJsonObject(parsed))
732
+ return parsed;
733
+ throw new Error(`${configFile} is not a JSON object; refusing to write`);
734
+ }
735
+ function openclawFormattingOptions(source) {
736
+ const indentation = /(?:^|\r?\n)([\t ]+)(?=")/.exec(source)?.[1] ?? ' ';
737
+ return {
738
+ insertSpaces: !indentation.includes('\t'),
739
+ tabSize: indentation.length,
740
+ eol: source.includes('\r\n') ? '\r\n' : '\n',
741
+ };
742
+ }
743
+ /** Apply one JSONC edit while leaving unrelated source ranges intact. */
744
+ export function editOpenclawConfigText(source, configFile, location, value) {
745
+ parseOpenclawConfig(source, configFile);
746
+ return applyEdits(source, modify(source, location, value, { formattingOptions: openclawFormattingOptions(source) }));
747
+ }
748
+ function objectAt(value) {
749
+ return isJsonObject(value) ? value : null;
750
+ }
751
+ /** Install or refresh Notifai's entry with a JSONC parser edit. */
752
+ export function enableOpenclawNotifaiConfigText(source, configFile) {
753
+ const config = parseOpenclawConfig(source, configFile);
754
+ const merged = mergeOpenclawNotifaiEntry(config);
755
+ const mergedPlugins = objectAt(merged.plugins);
756
+ const mergedEntries = objectAt(mergedPlugins['entries']);
757
+ const plugins = objectAt(config.plugins);
758
+ if (plugins === null) {
759
+ return editOpenclawConfigText(source, configFile, ['plugins'], merged.plugins);
760
+ }
761
+ if (Object.hasOwn(plugins, 'entries') && !isJsonObject(plugins['entries'])) {
762
+ return editOpenclawConfigText(source, configFile, ['plugins', 'entries'], mergedEntries);
763
+ }
764
+ return editOpenclawConfigText(source, configFile, ['plugins', 'entries', OPENCLAW_PLUGIN_ID], mergedEntries[OPENCLAW_PLUGIN_ID]);
765
+ }
766
+ // jsonc-parser's SyntaxKind is an ambient const enum, unavailable with
767
+ // verbatimModuleSyntax. Keep the scanner token values local to this helper.
768
+ const COMMA_TOKEN = 5;
769
+ const LINE_COMMENT_TOKEN = 12;
770
+ const BLOCK_COMMENT_TOKEN = 13;
771
+ const EOF_TOKEN = 17;
772
+ function jsoncGap(source, from, to) {
773
+ const scanner = createScanner(source);
774
+ scanner.setPosition(from);
775
+ let comma = null;
776
+ let hasComment = false;
777
+ while (scanner.getPosition() < to) {
778
+ const token = scanner.scan();
779
+ const offset = scanner.getTokenOffset();
780
+ if (offset >= to || token === EOF_TOKEN)
781
+ break;
782
+ if (token === COMMA_TOKEN)
783
+ comma = offset;
784
+ if (token === LINE_COMMENT_TOKEN || token === BLOCK_COMMENT_TOKEN) {
785
+ hasComment = true;
261
786
  }
262
787
  }
263
- catch {
264
- const stripped = source
265
- .replace(/\/\*[\s\S]*?\*\//g, '')
266
- .replace(/^\s*\/\/.*$/gm, '')
267
- .replace(/,(\s*[}\]])/g, '$1');
268
- const parsed = JSON.parse(stripped);
269
- if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
270
- return parsed;
788
+ return { comma, hasComment };
789
+ }
790
+ function removeJsoncItem(source, path) {
791
+ const root = parseTree(source);
792
+ if (root === undefined)
793
+ return source;
794
+ const value = findNodeAtLocation(root, path);
795
+ if (value === undefined)
796
+ return source;
797
+ const item = value.parent?.type === 'property' ? value.parent : value;
798
+ const container = item.parent;
799
+ const siblings = container?.children;
800
+ if (container === undefined || siblings === undefined)
801
+ return source;
802
+ const index = siblings.indexOf(item);
803
+ if (index < 0)
804
+ return source;
805
+ const end = item.offset + item.length;
806
+ const ranges = [];
807
+ if (index < siblings.length - 1) {
808
+ const gap = jsoncGap(source, end, siblings[index + 1].offset);
809
+ if (gap.comma === null)
810
+ return source;
811
+ if (gap.hasComment) {
812
+ ranges.push([item.offset, end], [gap.comma, gap.comma + 1]);
813
+ }
814
+ else {
815
+ ranges.push([item.offset, gap.comma + 1]);
816
+ }
817
+ }
818
+ else {
819
+ const trailing = jsoncGap(source, end, container.offset + container.length - 1);
820
+ if (trailing.comma !== null)
821
+ ranges.push([trailing.comma, trailing.comma + 1]);
822
+ if (index > 0) {
823
+ const previous = siblings[index - 1];
824
+ const gap = jsoncGap(source, previous.offset + previous.length, item.offset);
825
+ if (gap.comma === null)
826
+ return source;
827
+ if (gap.hasComment) {
828
+ ranges.push([gap.comma, gap.comma + 1], [item.offset, end]);
829
+ }
830
+ else {
831
+ ranges.push([gap.comma, end]);
832
+ }
271
833
  }
834
+ else {
835
+ // An inserted sole property occupies a line of its own. Recover the
836
+ // original commented-empty object by removing that added line.
837
+ const lineStart = source.lastIndexOf('\n', item.offset - 1) + 1;
838
+ const lineEndOffset = source.indexOf('\n', end);
839
+ const lineEnd = lineEndOffset < 0 ? source.length : lineEndOffset;
840
+ const onOwnLine = source.slice(lineStart, item.offset).trim() === '' &&
841
+ source.slice(end, lineEnd).trim() === (trailing.comma === null ? '' : ',');
842
+ const lineBreakStart = source[lineStart - 2] === '\r' ? lineStart - 2 : lineStart - 1;
843
+ ranges.push([onOwnLine && lineStart > 0 ? lineBreakStart : item.offset, end]);
844
+ }
845
+ }
846
+ let text = source;
847
+ for (const [start, stop] of ranges.sort((a, b) => b[0] - a[0])) {
848
+ text = text.slice(0, start) + text.slice(stop);
849
+ }
850
+ return text;
851
+ }
852
+ function removeOpenclawLoadPathText(source, pluginDir) {
853
+ let text = source;
854
+ for (;;) {
855
+ const config = parseOpenclawConfig(text);
856
+ const paths = objectAt(objectAt(config.plugins)?.['load'])?.['paths'];
857
+ if (!Array.isArray(paths))
858
+ return text;
859
+ const index = paths.findIndex((value) => value === pluginDir);
860
+ if (index < 0)
861
+ return text;
862
+ const next = removeJsoncItem(text, ['plugins', 'load', 'paths', index]);
863
+ if (next === text)
864
+ throw new Error('Could not remove the OpenClaw load path');
865
+ text = next;
272
866
  }
273
- throw new Error('OpenClaw config is not a JSON object');
867
+ }
868
+ /** Remove only the entry and explicit load path owned by this installation. */
869
+ export function removeOpenclawNotifaiConfigText(source, configFile, pluginDir) {
870
+ const config = parseOpenclawConfig(source, configFile);
871
+ const entries = objectAt(objectAt(config.plugins)?.['entries']);
872
+ const withoutEntry = entries !== null && Object.hasOwn(entries, OPENCLAW_PLUGIN_ID)
873
+ ? removeJsoncItem(source, ['plugins', 'entries', OPENCLAW_PLUGIN_ID])
874
+ : source;
875
+ return removeOpenclawLoadPathText(withoutEntry, pluginDir);
876
+ }
877
+ /** Remove one older Project load path while leaving the shared entry alone. */
878
+ export function removeOpenclawLoadPathConfigText(source, configFile, pluginDir) {
879
+ parseOpenclawConfig(source, configFile);
880
+ return removeOpenclawLoadPathText(source, pluginDir);
881
+ }
882
+ export function openclawPluginLoadBlockers(config) {
883
+ const plugins = objectAt(config.plugins);
884
+ if (plugins === null)
885
+ return [];
886
+ const blockers = [];
887
+ const allow = plugins['allow'];
888
+ if (Array.isArray(allow) && allow.length > 0 && !allow.includes(OPENCLAW_PLUGIN_ID)) {
889
+ blockers.push({ key: 'plugins.allow', action: 'add' });
890
+ }
891
+ const deny = plugins['deny'];
892
+ if (Array.isArray(deny) && deny.includes(OPENCLAW_PLUGIN_ID)) {
893
+ blockers.push({ key: 'plugins.deny', action: 'remove' });
894
+ }
895
+ return blockers;
896
+ }
897
+ export function openclawPluginLoadRemedy(blocker) {
898
+ return blocker.action === 'add'
899
+ ? `add \"${OPENCLAW_PLUGIN_ID}\" to ${blocker.key}`
900
+ : `remove \"${OPENCLAW_PLUGIN_ID}\" from ${blocker.key}`;
901
+ }
902
+ export function openclawPluginLoadWarning(blockers) {
903
+ if (blockers.length === 0)
904
+ return null;
905
+ return `OpenClaw will not load Notifai: ${blockers.map(openclawPluginLoadRemedy).join('; ')}.`;
274
906
  }
275
907
  export function mergeOpenclawNotifaiEntry(config) {
276
908
  const plugins = config.plugins !== null && typeof config.plugins === 'object' && !Array.isArray(config.plugins)
@@ -301,62 +933,44 @@ export function mergeOpenclawNotifaiEntry(config) {
301
933
  * exactly as it was.
302
934
  */
303
935
  export function removeOpenclawLoadPath(config, pluginDir) {
304
- const plugins = config.plugins !== null && typeof config.plugins === 'object' && !Array.isArray(config.plugins)
305
- ? { ...config.plugins }
306
- : {};
307
- const load = plugins.load !== null && typeof plugins.load === 'object' && !Array.isArray(plugins.load)
308
- ? { ...plugins.load }
309
- : {};
310
- if (!Array.isArray(load.paths))
936
+ const currentPlugins = objectAt(config.plugins);
937
+ const currentLoad = objectAt(currentPlugins?.['load']);
938
+ if (currentPlugins === null || currentLoad === null || !Array.isArray(currentLoad['paths'])) {
311
939
  return config;
312
- const paths = load.paths.filter((value) => value !== pluginDir);
313
- if (paths.length === load.paths.length)
940
+ }
941
+ const load = { ...currentLoad };
942
+ const originalPaths = currentLoad['paths'];
943
+ const paths = originalPaths.filter((value) => value !== pluginDir);
944
+ if (paths.length === originalPaths.length)
314
945
  return config;
315
- if (paths.length > 0)
316
- load.paths = paths;
317
- else
318
- delete load.paths;
319
- if (Object.keys(load).length > 0)
320
- plugins.load = load;
321
- else
322
- delete plugins.load;
323
- if (Object.keys(plugins).length > 0)
324
- return { ...config, plugins };
325
- const next = { ...config };
326
- delete next.plugins;
327
- return next;
946
+ load.paths = paths;
947
+ return { ...config, plugins: { ...currentPlugins, load } };
328
948
  }
329
949
  export function removeOpenclawNotifaiEntry(config, pluginDir) {
330
- const plugins = config.plugins !== null && typeof config.plugins === 'object' && !Array.isArray(config.plugins)
331
- ? { ...config.plugins }
332
- : {};
333
- const entries = plugins.entries !== null && typeof plugins.entries === 'object' && !Array.isArray(plugins.entries)
334
- ? { ...plugins.entries }
335
- : {};
336
- delete entries[OPENCLAW_PLUGIN_ID];
337
- if (Object.keys(entries).length > 0)
950
+ if (!isJsonObject(config.plugins))
951
+ return config;
952
+ const currentPlugins = config.plugins;
953
+ const currentEntries = objectAt(currentPlugins['entries']);
954
+ const currentLoad = objectAt(currentPlugins['load']);
955
+ const removesEntry = currentEntries !== null && Object.hasOwn(currentEntries, OPENCLAW_PLUGIN_ID);
956
+ const removesLoadPath = currentLoad !== null &&
957
+ Array.isArray(currentLoad['paths']) &&
958
+ currentLoad['paths'].includes(pluginDir);
959
+ if (!removesEntry && !removesLoadPath)
960
+ return config;
961
+ const plugins = { ...currentPlugins };
962
+ if (removesEntry) {
963
+ const entries = { ...currentEntries };
964
+ delete entries[OPENCLAW_PLUGIN_ID];
338
965
  plugins.entries = entries;
339
- else
340
- delete plugins.entries;
341
- const load = plugins.load !== null && typeof plugins.load === 'object' && !Array.isArray(plugins.load)
342
- ? { ...plugins.load }
343
- : {};
344
- if (Array.isArray(load.paths)) {
345
- const paths = load.paths.filter((value) => value !== pluginDir);
346
- if (paths.length > 0)
347
- load.paths = paths;
348
- else
349
- delete load.paths;
350
966
  }
351
- if (Object.keys(load).length > 0)
352
- plugins.load = load;
353
- else
354
- delete plugins.load;
355
- if (Object.keys(plugins).length > 0)
356
- return { ...config, plugins };
357
- const next = { ...config };
358
- delete next.plugins;
359
- return next;
967
+ if (removesLoadPath) {
968
+ plugins.load = {
969
+ ...currentLoad,
970
+ paths: currentLoad['paths'].filter((value) => value !== pluginDir),
971
+ };
972
+ }
973
+ return { ...config, plugins };
360
974
  }
361
975
  /** Config-directory presence is not ownership. These files mean OpenClaw is actually configured. */
362
976
  export function openclawHasGlobalEvidence(exists, env = process.env, platform = process.platform) {