@raidiant/notifai 11.4.1 → 11.6.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +28 -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 +11 -0
  7. package/dist/commands-acknowledge.js.map +1 -1
  8. package/dist/commands-ask.js +22 -3
  9. package/dist/commands-ask.js.map +1 -1
  10. package/dist/commands-doctor.js +149 -39
  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 +2 -0
  15. package/dist/commands-hook-attend.js +87 -22
  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 +245 -8
  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-support.js +3 -0
  28. package/dist/commands-send-support.js.map +1 -1
  29. package/dist/commands-send.js +29 -0
  30. package/dist/commands-send.js.map +1 -1
  31. package/dist/harnesses.d.ts +14 -10
  32. package/dist/harnesses.js +39 -9
  33. package/dist/harnesses.js.map +1 -1
  34. package/dist/hermes-attendant.d.ts +38 -0
  35. package/dist/hermes-attendant.js +333 -0
  36. package/dist/hermes-attendant.js.map +1 -0
  37. package/dist/hermes-plugin.d.ts +11 -0
  38. package/dist/hermes-plugin.js +299 -0
  39. package/dist/hermes-plugin.js.map +1 -0
  40. package/dist/hook-events.d.ts +9 -2
  41. package/dist/hook-events.js +16 -6
  42. package/dist/hook-events.js.map +1 -1
  43. package/dist/hook-lifecycle.d.ts +1 -1
  44. package/dist/hook-lifecycle.js +27 -10
  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 +23 -0
  50. package/dist/install-hooks.js +51 -4
  51. package/dist/install-hooks.js.map +1 -1
  52. package/dist/invocation-context.d.ts +1 -0
  53. package/dist/invocation-context.js +3 -0
  54. package/dist/invocation-context.js.map +1 -1
  55. package/dist/openclaw-continuation-bridge.d.ts +7 -0
  56. package/dist/openclaw-continuation-bridge.js +64 -0
  57. package/dist/openclaw-continuation-bridge.js.map +1 -0
  58. package/dist/openclaw-gateway-readiness.d.ts +1 -0
  59. package/dist/openclaw-gateway-readiness.js +24 -0
  60. package/dist/openclaw-gateway-readiness.js.map +1 -0
  61. package/dist/openclaw-generation.d.ts +21 -0
  62. package/dist/openclaw-generation.js +115 -0
  63. package/dist/openclaw-generation.js.map +1 -0
  64. package/dist/openclaw-message-bridge.d.ts +8 -0
  65. package/dist/openclaw-message-bridge.js +93 -0
  66. package/dist/openclaw-message-bridge.js.map +1 -0
  67. package/dist/openclaw-pending.d.ts +8 -0
  68. package/dist/openclaw-pending.js +47 -0
  69. package/dist/openclaw-pending.js.map +1 -0
  70. package/dist/openclaw-plugin.d.ts +22 -4
  71. package/dist/openclaw-plugin.js +1075 -90
  72. package/dist/openclaw-plugin.js.map +1 -1
  73. package/dist/openclaw-session-access.d.ts +12 -0
  74. package/dist/openclaw-session-access.js +79 -0
  75. package/dist/openclaw-session-access.js.map +1 -0
  76. package/dist/program.js +13 -8
  77. package/dist/program.js.map +1 -1
  78. package/dist/session-activation.js +5 -4
  79. package/dist/session-activation.js.map +1 -1
  80. package/dist/session-attendant-probe.d.ts +11 -0
  81. package/dist/session-attendant-probe.js +41 -0
  82. package/dist/session-attendant-probe.js.map +1 -1
  83. package/dist/session-attendant-state.d.ts +5 -0
  84. package/dist/session-attendant-state.js +14 -0
  85. package/dist/session-attendant-state.js.map +1 -1
  86. package/dist/session-attendant.d.ts +6 -0
  87. package/dist/session-attendant.js +12 -0
  88. package/dist/session-attendant.js.map +1 -1
  89. package/dist/session-delivery.d.ts +2 -0
  90. package/dist/session-delivery.js +2 -0
  91. package/dist/session-delivery.js.map +1 -1
  92. package/dist/session-handoff.d.ts +1 -1
  93. package/dist/session-labels.d.ts +2 -0
  94. package/dist/session-labels.js +5 -0
  95. package/dist/session-labels.js.map +1 -1
  96. package/dist/session-message-handoff.d.ts +5 -1
  97. package/dist/session-message-handoff.js +3 -1
  98. package/dist/session-message-handoff.js.map +1 -1
  99. package/dist/skill-source/manifest.json +6 -6
  100. package/dist/skill-source/notifai/SKILL.md +63 -59
  101. package/dist/skill-source/notifai/references/harness-setup.md +67 -18
  102. package/dist/skill-source/notifai/references/notes-and-edits.md +12 -1
  103. package/dist/skill-source/notifai/references/updates.md +7 -6
  104. package/dist/update-handoff.d.ts +1 -1
  105. package/dist/update-handoff.js +2 -1
  106. package/dist/update-handoff.js.map +1 -1
  107. 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 = 5;
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,759 @@ 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
+ let MESSAGE_JOURNAL_DIR = null
66
+ let pendingMessageJournals = null
67
+ let awaitingMessageContexts = new Map()
68
+ const GATEWAY_BOOT_ID = randomUUID()
69
+
70
+ function readinessPath() {
71
+ return path.join(path.dirname(JOURNAL_DIR), "continuation-ready.json")
72
+ }
73
+
74
+ function writeReadiness(target) {
75
+ const start = execFileSync("ps", ["-o", "lstart=", "-p", String(process.pid)], {
76
+ encoding: "utf8", timeout: 2_000,
77
+ env: { PATH: process.env.PATH ?? "/bin:/usr/bin", TZ: "UTC", LC_ALL: "C" },
78
+ stdio: ["ignore", "pipe", "ignore"],
79
+ }).trim().replace(/\\s+/g, " ")
80
+ if (start === "") return false
81
+ const file = readinessPath()
82
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
83
+ const temp = file + "." + randomUUID() + ".tmp"
84
+ writeFileSync(temp, JSON.stringify({ pid: process.pid, start, boot_id: GATEWAY_BOOT_ID,
85
+ script: target.script, script_mtime: statSync(target.script).mtimeMs }) + "\\n",
86
+ { mode: 0o600, flag: "wx" })
87
+ renameSync(temp, file)
88
+ return true
89
+ }
90
+
91
+ function clearReadiness() {
92
+ try { unlinkSync(readinessPath()) } catch { /* Already absent. */ }
93
+ }
94
+
95
+ function journalPath(deliveryId) {
96
+ return path.join(JOURNAL_DIR, deliveryId + ".json")
97
+ }
98
+
99
+ function saveJournal(record) {
100
+ mkdirSync(JOURNAL_DIR, { recursive: true, mode: 0o700 })
101
+ const file = journalPath(record.delivery_id)
102
+ const temp = file + "." + randomUUID() + ".tmp"
103
+ writeFileSync(temp, JSON.stringify(record) + "\\n", { mode: 0o600, flag: "wx" })
104
+ const fd = openSync(temp, "r")
105
+ try { fsyncSync(fd) } finally { closeSync(fd) }
106
+ renameSync(temp, file)
107
+ try {
108
+ const directory = openSync(JOURNAL_DIR, "r")
109
+ try { fsyncSync(directory) } finally { closeSync(directory) }
110
+ } catch { /* Directory fsync is unavailable on some hosts. */ }
111
+ }
112
+
113
+ function readJournals() {
114
+ try {
115
+ return readdirSync(JOURNAL_DIR).filter((name) => name.endsWith(".json"))
116
+ .flatMap((name) => {
117
+ try {
118
+ const record = JSON.parse(readFileSync(path.join(JOURNAL_DIR, name), "utf8"))
119
+ return record && typeof record.delivery_id === "string" ? [record] : []
120
+ } catch { return [] }
121
+ })
122
+ } catch { return [] }
123
+ }
124
+
125
+ function existingJournal(deliveryId) {
126
+ try {
127
+ return JSON.parse(readFileSync(journalPath(deliveryId), "utf8"))
128
+ } catch { return null }
129
+ }
130
+
131
+ function messageJournalPath(deliveryId) {
132
+ return path.join(MESSAGE_JOURNAL_DIR, deliveryId + ".json")
133
+ }
134
+
135
+ function messageContextMarkerPath(deliveryId) {
136
+ return path.join(MESSAGE_JOURNAL_DIR, deliveryId + ".context-used")
137
+ }
138
+
139
+ function saveMessageJournal(record) {
140
+ mkdirSync(MESSAGE_JOURNAL_DIR, { recursive: true, mode: 0o700 })
141
+ const file = messageJournalPath(record.delivery_id)
142
+ const temp = file + "." + randomUUID() + ".tmp"
143
+ const { text: _text, ...withoutText } = record
144
+ const stored = existsSync(messageContextMarkerPath(record.delivery_id)) ? withoutText : record
145
+ writeFileSync(temp, JSON.stringify(stored) + "\\n", { mode: 0o600, flag: "wx" })
146
+ const fd = openSync(temp, "r")
147
+ try { fsyncSync(fd) } finally { closeSync(fd) }
148
+ renameSync(temp, file)
149
+ if (pendingMessageJournals !== null) {
150
+ if (stored.phase === "transcript" || stored.phase === "unconfirmed") pendingMessageJournals.delete(stored.delivery_id)
151
+ else pendingMessageJournals.set(stored.delivery_id, stored)
152
+ }
153
+ if (stored.phase === "transcript" && stored.text !== undefined) {
154
+ awaitingMessageContexts.set(stored.delivery_id, stored)
155
+ } else {
156
+ awaitingMessageContexts.delete(stored.delivery_id)
157
+ }
158
+ try {
159
+ const directory = openSync(MESSAGE_JOURNAL_DIR, "r")
160
+ try { fsyncSync(directory) } finally { closeSync(directory) }
161
+ } catch { /* Directory fsync is unavailable on some hosts. */ }
162
+ // The prompt hook runs in another plugin process. If it consumed the context
163
+ // during this synchronous save, its marker wins and text is redacted again.
164
+ if (stored.text !== undefined && existsSync(messageContextMarkerPath(record.delivery_id))) {
165
+ saveMessageJournal(withoutText)
166
+ }
167
+ }
168
+
169
+ function readMessageJournal(deliveryId) {
170
+ try {
171
+ const record = JSON.parse(readFileSync(messageJournalPath(deliveryId), "utf8"))
172
+ if (!record || record.delivery_id !== deliveryId) throw new Error("Invalid message journal")
173
+ return record
174
+ } catch (error) {
175
+ if (error?.code === "ENOENT") return null
176
+ throw error
177
+ }
178
+ }
179
+
180
+ function retryMessageJournals() {
181
+ if (pendingMessageJournals === null) {
182
+ let names
183
+ try { names = readdirSync(MESSAGE_JOURNAL_DIR) }
184
+ catch (error) { if (error?.code !== "ENOENT") throw error; names = [] }
185
+ const pending = new Map()
186
+ awaitingMessageContexts = new Map()
187
+ for (const name of names.filter((value) => value.endsWith(".json"))) {
188
+ const record = readMessageJournal(name.slice(0, -5))
189
+ if (record !== null && record.boot_id !== GATEWAY_BOOT_ID) {
190
+ if (record.text !== undefined) {
191
+ settleMessageJournal(record, record.phase === "transcript" ? "transcript" : "unconfirmed")
192
+ }
193
+ continue
194
+ }
195
+ if (record !== null && record.phase !== "transcript" && record.phase !== "unconfirmed") {
196
+ pending.set(record.delivery_id, record)
197
+ } else if (record !== null && record.phase === "transcript" &&
198
+ record.text !== undefined) {
199
+ awaitingMessageContexts.set(record.delivery_id, record)
200
+ }
201
+ }
202
+ pendingMessageJournals = pending
203
+ }
204
+ return [...pendingMessageJournals.values()]
205
+ }
206
+
207
+ function settleMessageJournal(record, phase) {
208
+ const { text: _text, ...rest } = record
209
+ // A committed pointer can begin its model turn after chat.history sees the
210
+ // user transcript. Keep context until that exact turn claims it once.
211
+ saveMessageJournal(phase === "transcript" && record.boot_id === GATEWAY_BOOT_ID &&
212
+ !existsSync(messageContextMarkerPath(record.delivery_id))
213
+ ? { ...record, phase } : { ...rest, phase })
214
+ }
215
+
216
+ function nativeSessionRevision(api, sessionKey, sessionId) {
217
+ if (typeof sessionId !== "string" || sessionId === "") return null
218
+ try {
219
+ const entry = api.runtime?.agent?.session?.getSessionEntry?.({
220
+ sessionKey, readConsistency: "latest",
221
+ })
222
+ return entry?.sessionId === sessionId &&
223
+ typeof entry.lifecycleRevision === "string" && entry.lifecycleRevision !== ""
224
+ ? entry.lifecycleRevision : null
225
+ } catch { return null }
226
+ }
227
+
228
+ function claimTimeAvailable(record) {
229
+ return typeof record.deadline_ns === "string" &&
230
+ record.deadline_ns.length <= 32 && /^[0-9]+$/.test(record.deadline_ns) &&
231
+ BigInt(record.deadline_ns) > process.hrtime.bigint()
232
+ }
233
+
234
+ function openclawCliTarget(config) {
235
+ if (process.env.OPENCLAW_SHELL === "exec") return null
236
+ const gateway = config?.gateway ?? {}
237
+ if (gateway.mode === "remote" || (gateway.bind !== undefined && gateway.bind !== "loopback")) return null
238
+ let script
239
+ try { script = realpathSync(process.argv[1]) } catch { return null }
240
+ let folder = path.dirname(script)
241
+ let packageVersion = null
242
+ for (;;) {
243
+ try {
244
+ const manifest = JSON.parse(readFileSync(path.join(folder, "package.json"), "utf8"))
245
+ if (manifest.name === "openclaw" && typeof manifest.version === "string") {
246
+ packageVersion = manifest.version
247
+ break
248
+ }
249
+ } catch { /* Keep walking within this executable's ancestors. */ }
250
+ const parent = path.dirname(folder)
251
+ if (parent === folder) return null
252
+ folder = parent
253
+ }
254
+ const explicit = []
255
+ for (let i = 0; i < process.argv.length - 1; i += 1) {
256
+ if (process.argv[i] === "--port") explicit.push(Number(process.argv[i + 1]))
257
+ }
258
+ if (explicit.length > 1) return null
259
+ const configured = Number(gateway.port ?? process.env.OPENCLAW_GATEWAY_PORT ?? 18789)
260
+ const port = explicit[0] ?? configured
261
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null
262
+ return { script, port, packageVersion }
263
+ }
264
+
265
+ function openclawExec(target, args) {
266
+ return new Promise((resolve, reject) => {
267
+ execFile(process.execPath, [target.script, ...args], {
268
+ shell: false, windowsHide: true, timeout: 20_000, maxBuffer: 2_000_000,
269
+ env: process.env,
270
+ }, (error, stdout) => {
271
+ if (error) reject(new Error("OpenClaw CLI call failed"))
272
+ else resolve(stdout.trim())
273
+ })
274
+ })
275
+ }
276
+
277
+ async function gatewayCall(target, method, params) {
278
+ const output = await openclawExec(target, ["gateway", "call", method,
279
+ "--port", String(target.port), "--params", JSON.stringify(params), "--json"])
280
+ return JSON.parse(output)
281
+ }
282
+
283
+ async function verifiedGatewayTarget(config, logger) {
284
+ const target = openclawCliTarget(config)
285
+ if (target === null) {
286
+ logger?.warn?.("notifai continuation target: local CLI path or Gateway bind unavailable")
287
+ return null
288
+ }
289
+ try {
290
+ const banner = (await openclawExec(target, ["--version"])).trim()
291
+ const cliVersion = /^OpenClaw ([0-9]+\\.[0-9]+\\.[0-9]+)(?: \\([0-9a-f]+\\))?$/.exec(banner)?.[1]
292
+ const status = await gatewayCall(target, "status", {})
293
+ if (cliVersion !== target.packageVersion || status?.runtimeVersion !== cliVersion) {
294
+ logger?.warn?.("notifai continuation target: CLI and Gateway versions differ")
295
+ return null
296
+ }
297
+ if (status?.pid !== process.pid) {
298
+ logger?.warn?.("notifai continuation target: Gateway process identity differs")
299
+ return null
300
+ }
301
+ return target
302
+ } catch {
303
+ logger?.warn?.("notifai continuation target: local CLI status unavailable")
304
+ return null
305
+ }
306
+ }
307
+
308
+ function submissionKey(record) {
309
+ return "notifai-" + record.delivery_id + "-a" + record.attempt
310
+ }
311
+
312
+ function submissionIdentity(message) {
313
+ const meta = message?.metadata ?? message?.__openclaw ?? {}
314
+ return message?.idempotencyKey ?? meta.idempotencyKey ?? null
315
+ }
316
+
317
+ async function inspectSubmission(target, record) {
318
+ let offset = 0
319
+ let before
320
+ let pendingFinished = false
321
+ let interrupted = false
322
+ for (let page = 0; page < 1000; page += 1) {
323
+ const params = { sessionKey: record.session_key, limit: 200, offset,
324
+ ...(before === undefined ? {} : { pendingBefore: before }) }
325
+ const history = await gatewayCall(target, "chat.history", params)
326
+ if (history?.kind === "reset" || history?.truncationReason ||
327
+ (record.openclaw_session_id && history?.sessionInfo?.sessionId &&
328
+ history.sessionInfo.sessionId !== record.openclaw_session_id)) return "unconfirmed"
329
+ const messages = history?.messages
330
+ if (!Array.isArray(messages)) return "unconfirmed"
331
+ if (messages.some((entry) => entry?.role === "user" &&
332
+ submissionIdentity(entry) === submissionKey(record) + ":user")) return "transcript"
333
+ const pending = history?.pendingInputs
334
+ if (!pending || !Array.isArray(pending.items) ||
335
+ !Number.isInteger(pending.total) || pending.total < pending.items.length ||
336
+ typeof history.hasMore !== "boolean") return "unconfirmed"
337
+ for (const item of pending.items) {
338
+ if (item?.runId !== submissionKey(record) &&
339
+ item?.idempotencyKey !== submissionKey(record)) continue
340
+ if (item.state === "queued" || item.state === "running") return "pending"
341
+ if (item.state === "interrupted" || item.state === "cancelled") interrupted = true
342
+ else return "unconfirmed"
343
+ }
344
+ const nextOffset = history.nextOffset
345
+ const hasMore = history.hasMore === true
346
+ const nextBefore = pending.nextBefore
347
+ if (!pendingFinished && typeof nextBefore === "string" && nextBefore !== before) {
348
+ before = nextBefore
349
+ continue
350
+ }
351
+ if (!pendingFinished && pending.total > pending.items.length) return "unconfirmed"
352
+ pendingFinished = true
353
+ if (hasMore && Number.isInteger(nextOffset) && nextOffset > offset) {
354
+ offset = nextOffset
355
+ before = undefined
356
+ continue
357
+ }
358
+ if (hasMore) return "unconfirmed"
359
+ return interrupted ? "interrupted" : "absent"
360
+ }
361
+ return "unconfirmed"
362
+ }
363
+
364
+ function pointerMessage(record) {
365
+ return "A Notifai reply is waiting for this session. " +
366
+ record.request_ids.map((id) => "Run notifai replies " + id +
367
+ " --json, then notifai acknowledge " + id +
368
+ " with the concrete work you will do.").join(" ")
369
+ }
370
+
371
+ function messagePointer(record) {
372
+ return "Notifai Session Message " + record.message_id +
373
+ " should appear in this turn's context. If its full context is absent, " +
374
+ "say that this Session Message is missing and do not acknowledge it. " +
375
+ "If present, read it and run notifai acknowledge " + record.message_id +
376
+ " with the concrete work you will do when text is required."
377
+ }
378
+
379
+ /** Attach private text only to the admitted pointer turn of its native generation. */
380
+ async function messageContextForPointer(api, event, ctx, sessionKey, nativeRevision) {
381
+ if (MESSAGE_JOURNAL_DIR === null || JOURNAL_DIR === null || nativeRevision === null) return null
382
+ const prompt = typeof event?.currentUserMessage === "string"
383
+ ? event.currentUserMessage : event?.prompt
384
+ if (typeof prompt !== "string" || !prompt.startsWith("Notifai Session Message ")) return null
385
+ const messageId = prompt.slice("Notifai Session Message ".length).split(" ", 1)[0]
386
+ if (!/^sm_[A-Za-z0-9_-]+$/.test(messageId)) return null
387
+ const envelope = { ...envelopeFor(sessionKey, event, ctx, "BeforePromptBuild"),
388
+ openclaw_lifecycle_revision: nativeRevision }
389
+ const generation = (await runHook("openclaw-generation", envelope))?.trim()
390
+ if (typeof generation !== "string" || !/^[0-9a-f-]{36}$/i.test(generation)) return null
391
+ const deliveryId = createHash("sha256").update(generation + "\\0" + messageId)
392
+ .digest("hex").slice(0, 32)
393
+ try {
394
+ const record = readMessageJournal(deliveryId)
395
+ const readiness = JSON.parse(readFileSync(readinessPath(), "utf8"))
396
+ if (record === null || record.message_id !== messageId ||
397
+ record.session_key !== sessionKey || record.generation !== generation ||
398
+ record.native_revision !== nativeRevision ||
399
+ record.openclaw_session_id !== sessionIdOf(event, ctx) ||
400
+ record.boot_id !== readiness?.boot_id ||
401
+ typeof record.text !== "string" ||
402
+ (record.phase !== "submitting" && record.phase !== "admitted" &&
403
+ record.phase !== "transcript") ||
404
+ prompt !== messagePointer(record) ||
405
+ existsSync(messageContextMarkerPath(deliveryId)) ||
406
+ nativeSessionRevision(api, sessionKey, sessionIdOf(event, ctx)) !== nativeRevision) return null
407
+ ctx?.hookInvocation?.assertActive?.()
408
+ writeFileSync(messageContextMarkerPath(deliveryId), "used\\n", { mode: 0o600, flag: "wx" })
409
+ saveMessageJournal(record)
410
+ return record.text
411
+ } catch { return null }
412
+ }
413
+
414
+ async function gatewaySessions(target) {
415
+ let offset = 0
416
+ const sessions = new Map()
417
+ for (let page = 0; page < 1000; page += 1) {
418
+ const result = await gatewayCall(target, "sessions.list", { limit: 200, offset })
419
+ if (!Array.isArray(result?.sessions) || typeof result?.hasMore !== "boolean") return null
420
+ for (const session of result.sessions) {
421
+ if (typeof session?.key === "string") sessions.set(session.key, session)
422
+ }
423
+ if (!result.hasMore) return sessions
424
+ if (!Number.isInteger(result.nextOffset) || result.nextOffset <= offset) return null
425
+ offset = result.nextOffset
426
+ }
427
+ return null
428
+ }
429
+
430
+ async function currentSession(target, record) {
431
+ const sessions = await gatewaySessions(target)
432
+ const session = sessions?.get(record.session_key)
433
+ return typeof record.openclaw_session_id === "string" &&
434
+ session?.sessionId === record.openclaw_session_id &&
435
+ session.archived !== true ? session : null
436
+ }
437
+
438
+ function spawnOwnedHook(event, envelope, extraFds = false) {
439
+ const child = spawn(HOOK_COMMAND, [...HOOK_PREFIX, "hook", event,
440
+ "--owner", "notifai", "--harness", "openclaw"], {
441
+ cwd: envelope.cwd,
442
+ env: { ...process.env, NOTIFAI_HOOK_SOURCE_PID: String(process.pid),
443
+ ...(event === "attend" && extraFds ? { NOTIFAI_OPENCLAW_MESSAGE_BRIDGE: "1" } : {}) },
444
+ shell: false, windowsHide: true,
445
+ stdio: extraFds ? ["pipe", "ignore", "ignore", "pipe", "pipe"] : ["pipe", "ignore", "ignore"],
446
+ })
447
+ child.stdin.end(JSON.stringify(envelope))
448
+ return child
449
+ }
450
+
451
+ function makeContinuationService(config, logger, api) {
452
+ const active = new Map()
453
+ const delivering = new Set()
454
+ const reported = new Map()
455
+ const deliveringMessages = new Map()
456
+ let stopped = false
457
+ let timer = null
458
+ let scanning = false
459
+ let ready = false
460
+ let gatewayTarget = null
461
+
462
+ function report(record, reason) {
463
+ if (reported.get(record.delivery_id) === reason) return
464
+ reported.set(record.delivery_id, reason)
465
+ logger?.warn?.("notifai continuation " + record.delivery_id + ": " + reason)
466
+ }
467
+
468
+ async function deliver(record) {
469
+ if (delivering.has(record.delivery_id) || record.phase === "transcript") return
470
+ delivering.add(record.delivery_id)
471
+ try {
472
+ const envelope = { session_id: record.session_key, cwd: record.cwd,
473
+ openclaw_session_id: record.openclaw_session_id }
474
+ const generation = (await runHook("openclaw-generation", envelope))?.trim()
475
+ if (generation !== record.generation) { report(record, "generation-fenced"); return }
476
+ if (record.phase === "prepared") {
477
+ const proved = await runHook("openclaw-verify-prepared", {
478
+ ...envelope, openclaw_request_ids: record.request_ids })
479
+ if (proved?.trim() !== "committed") return
480
+ record = { ...record, phase: "committed" }
481
+ saveJournal(record)
482
+ }
483
+ const target = await verifiedGatewayTarget(config, logger)
484
+ if (target === null) { report(record, "gateway-target-unverified"); return }
485
+ const state = await inspectSubmission(target, record)
486
+ if (state === "unconfirmed") { report(record, "history-unconfirmed"); return }
487
+ if (state === "transcript") {
488
+ saveJournal({ ...record, phase: "transcript" })
489
+ return
490
+ }
491
+ if (state === "pending") {
492
+ if (record.phase !== "admitted") saveJournal({ ...record, phase: "admitted" })
493
+ return
494
+ }
495
+ if (state === "interrupted" || (state === "absent" && record.phase === "admitted")) {
496
+ record = { ...record, attempt: record.attempt + 1, phase: "committed" }
497
+ saveJournal(record)
498
+ }
499
+ if (state !== "absent" && state !== "interrupted") return
500
+ saveJournal({ ...record, phase: "submitting" })
501
+ report(record, "submitting-pointer")
502
+ await gatewayCall(target, "chat.send", { sessionKey: record.session_key,
503
+ message: pointerMessage(record), queueMode: "followup",
504
+ idempotencyKey: submissionKey(record) })
505
+ saveJournal({ ...record, phase: "admitted" })
506
+ report(record, "pointer-admitted")
507
+ } catch {
508
+ // An uncertain RPC is reconciled by identity before any retry.
509
+ report(record, "rpc-or-journal-unconfirmed")
510
+ } finally {
511
+ delivering.delete(record.delivery_id)
512
+ }
513
+ }
514
+
515
+ async function deliverMessageOnce(record) {
516
+ try {
517
+ if (record.boot_id !== GATEWAY_BOOT_ID) {
518
+ settleMessageJournal(record, "unconfirmed")
519
+ return false
520
+ }
521
+ if (!claimTimeAvailable(record) && record.phase !== "admitted" &&
522
+ record.phase !== "transcript") {
523
+ settleMessageJournal(record, "unconfirmed")
524
+ return false
525
+ }
526
+ const envelope = { session_id: record.session_key, cwd: record.cwd,
527
+ openclaw_session_id: record.openclaw_session_id,
528
+ openclaw_lifecycle_revision: record.native_revision }
529
+ if (nativeSessionRevision(api, record.session_key, record.openclaw_session_id) !==
530
+ record.native_revision) {
531
+ settleMessageJournal(record, "unconfirmed")
532
+ return false
533
+ }
534
+ const generation = (await runHook("openclaw-generation", envelope))?.trim()
535
+ if (generation !== record.generation) {
536
+ settleMessageJournal(record, "unconfirmed")
537
+ return false
538
+ }
539
+ const attendance = (await runHook("openclaw-attendance-ready", envelope))?.trim()
540
+ if (attendance !== "ready") return false
541
+ const target = await verifiedGatewayTarget(config, logger)
542
+ if (target === null) return false
543
+ const session = await currentSession(target, record)
544
+ if (session === null) return false
545
+ const state = await inspectSubmission(target, record)
546
+ if (state === "transcript") {
547
+ settleMessageJournal(record, "transcript")
548
+ return true
549
+ }
550
+ if (state === "pending") return true
551
+ if (state !== "absent" || record.phase === "admitted" ||
552
+ record.phase === "submitting") {
553
+ settleMessageJournal(record, "unconfirmed")
554
+ return false
555
+ }
556
+ if (!claimTimeAvailable(record)) {
557
+ settleMessageJournal(record, "unconfirmed")
558
+ return false
559
+ }
560
+ if ((await runHook("openclaw-generation", envelope))?.trim() !== record.generation) {
561
+ settleMessageJournal(record, "unconfirmed")
562
+ return false
563
+ }
564
+ if (nativeSessionRevision(api, record.session_key, record.openclaw_session_id) !==
565
+ record.native_revision) {
566
+ settleMessageJournal(record, "unconfirmed")
567
+ return false
568
+ }
569
+ if (await currentSession(target, record) === null) return false
570
+ saveMessageJournal({ ...record, phase: "submitting" })
571
+ await gatewayCall(target, "chat.send", {
572
+ sessionKey: record.session_key, message: messagePointer(record),
573
+ queueMode: "followup",
574
+ idempotencyKey: submissionKey(record),
575
+ })
576
+ saveMessageJournal({ ...record, phase: "admitted" })
577
+ return true
578
+ } catch {
579
+ // An uncertain admission is reconciled by identity, never blindly replayed.
580
+ return false
581
+ } finally { /* The wrapper removes the in-flight entry. */ }
582
+ }
583
+
584
+ function deliverMessage(record) {
585
+ if (record.phase === "transcript" || record.phase === "unconfirmed") return Promise.resolve(false)
586
+ const existing = deliveringMessages.get(record.delivery_id)
587
+ if (existing) return existing
588
+ const run = deliverMessageOnce(record).finally(() => deliveringMessages.delete(record.delivery_id))
589
+ deliveringMessages.set(record.delivery_id, run)
590
+ return run
591
+ }
592
+
593
+ function startMessageBridge(session, child) {
594
+ let buffer = ""
595
+ let queue = Promise.resolve()
596
+ child.stdio[4].on("error", () => { /* A closed attendant cannot accept a result. */ })
597
+ child.stdio[3].setEncoding("utf8")
598
+ child.stdio[3].on("data", (chunk) => {
599
+ buffer += chunk
600
+ if (buffer.length > 100_000) { child.kill("SIGTERM"); return }
601
+ for (;;) {
602
+ const end = buffer.indexOf("\\n")
603
+ if (end < 0) break
604
+ const line = buffer.slice(0, end)
605
+ buffer = buffer.slice(end + 1)
606
+ let payload
607
+ try { payload = JSON.parse(line) } catch { continue }
608
+ if (payload?.type !== "message" ||
609
+ !/^sm_[A-Za-z0-9_-]+$/.test(payload.message_id) ||
610
+ payload.session_key !== session.session_key ||
611
+ payload.generation !== session.generation ||
612
+ typeof payload.text !== "string" || payload.text.length > 32768 ||
613
+ typeof payload.deadline_ns !== "string" ||
614
+ payload.deadline_ns.length > 32 ||
615
+ !/^[0-9]+$/.test(payload.deadline_ns)) continue
616
+ queue = queue.then(async () => {
617
+ const deliveryId = createHash("sha256")
618
+ .update(session.generation + "\\0" + payload.message_id)
619
+ .digest("hex").slice(0, 32)
620
+ const record = { delivery_id: deliveryId, message_id: payload.message_id,
621
+ session_key: session.session_key, cwd: session.cwd,
622
+ openclaw_session_id: session.session_id, generation: session.generation,
623
+ native_revision: nativeSessionRevision(api, session.session_key, session.session_id),
624
+ boot_id: GATEWAY_BOOT_ID, text: payload.text, deadline_ns: payload.deadline_ns,
625
+ attempt: 1, phase: "prepared" }
626
+ const existing = readMessageJournal(deliveryId)
627
+ const current = active.get(session.session_key)
628
+ let written = false
629
+ if (current?.attendant === child && record.native_revision !== null &&
630
+ (existing === null ||
631
+ (existing.message_id === record.message_id &&
632
+ existing.session_key === record.session_key &&
633
+ existing.generation === record.generation))) {
634
+ if (existing === null) saveMessageJournal(record)
635
+ written = await deliverMessage(existing ?? record)
636
+ }
637
+ child.stdio[4].write(JSON.stringify({ message_id: payload.message_id,
638
+ status: written ? "written" : "unconfirmed" }) + "\\n")
639
+ }).catch(() => {
640
+ child.stdio[4].write(JSON.stringify({ message_id: payload.message_id,
641
+ status: "unconfirmed" }) + "\\n")
642
+ })
643
+ }
644
+ })
645
+ }
646
+
647
+ function startSettlement(session, entry) {
648
+ const envelope = { session_id: session.session_key, cwd: session.cwd,
649
+ openclaw_session_id: session.session_id, hook_event_name: "SessionStart" }
650
+ const child = spawnOwnedHook("openclaw-settlement", envelope, true)
651
+ entry.child = child
652
+ let buffer = ""
653
+ let prepared = null
654
+ child.stdio[3].setEncoding("utf8")
655
+ child.stdio[3].on("data", (chunk) => {
656
+ buffer += chunk
657
+ for (;;) {
658
+ const end = buffer.indexOf("\\n")
659
+ if (end < 0) break
660
+ const line = buffer.slice(0, end)
661
+ buffer = buffer.slice(end + 1)
662
+ let payload
663
+ try { payload = JSON.parse(line) } catch { continue }
664
+ if (payload.type === "prepare") {
665
+ const ids = payload.request_ids
666
+ if (payload.session_key !== session.session_key ||
667
+ payload.generation !== session.generation || !Array.isArray(ids) ||
668
+ ids.length < 1 || ids.some((id) => typeof id !== "string" ||
669
+ !/^req_[A-Za-z0-9_-]+$/.test(id))) continue
670
+ const deliveryId = createHash("sha256")
671
+ .update(session.generation + "\\0" + ids.join("\\0"))
672
+ .digest("hex").slice(0, 32)
673
+ const existing = existingJournal(deliveryId)
674
+ if (existing === null && existsSync(journalPath(deliveryId))) continue
675
+ if (existing !== null && (existing.session_key !== session.session_key ||
676
+ existing.generation !== session.generation ||
677
+ JSON.stringify(existing.request_ids) !== JSON.stringify(ids))) continue
678
+ prepared = existing ?? { delivery_id: deliveryId, session_key: session.session_key,
679
+ cwd: session.cwd, generation: session.generation,
680
+ openclaw_session_id: session.session_id, request_ids: ids,
681
+ attempt: 1, phase: "prepared" }
682
+ if (existing === null) saveJournal(prepared)
683
+ child.stdio[4].write('{"type":"prepared"}\\n')
684
+ } else if (payload.type === "committed" && prepared !== null) {
685
+ if (prepared.phase === "prepared") {
686
+ prepared = { ...prepared, phase: "committed" }
687
+ saveJournal(prepared)
688
+ }
689
+ void deliver(prepared)
690
+ }
691
+ }
692
+ })
693
+ child.on("close", () => {
694
+ const current = active.get(session.session_key)
695
+ if (current?.child === child) {
696
+ current.child = null
697
+ }
698
+ })
699
+ }
700
+
701
+ function startSession(session) {
702
+ const envelope = { session_id: session.session_key, cwd: session.cwd,
703
+ openclaw_session_id: session.session_id, hook_event_name: "SessionStart" }
704
+ const entry = { generation: session.generation,
705
+ attendant: spawnOwnedHook("attend", envelope, true), child: null }
706
+ active.set(session.session_key, entry)
707
+ startMessageBridge(session, entry.attendant)
708
+ startSettlement(session, entry)
709
+ }
710
+
711
+ function sendActivity(entry, session) {
712
+ if (entry.attendant.exitCode === null && entry.attendant.stdio[4].writable) {
713
+ entry.attendant.stdio[4].write(JSON.stringify({ type: "activity",
714
+ activity: session.hasActiveRun === true ? "working" : "idle" }) + "\\n")
715
+ }
716
+ }
717
+
718
+ async function tick() {
719
+ if (stopped || scanning) return
720
+ scanning = true
721
+ try {
722
+ if (!ready) {
723
+ const target = await verifiedGatewayTarget(config, logger)
724
+ if (target === null || !writeReadiness(target)) return
725
+ gatewayTarget = target
726
+ ready = true
727
+ }
728
+ const raw = await runHook("openclaw-list-pending", { cwd: process.cwd() })
729
+ if (raw === null) return
730
+ const pending = JSON.parse(raw)
731
+ const sessions = await gatewaySessions(gatewayTarget)
732
+ if (sessions === null) return
733
+ const seen = new Set()
734
+ if (Array.isArray(pending)) for (const session of pending) {
735
+ if (typeof session?.session_key !== "string" ||
736
+ typeof session?.cwd !== "string" ||
737
+ typeof session?.generation !== "string") continue
738
+ const gatewaySession = sessions.get(session.session_key)
739
+ if (typeof session.session_id !== "string" ||
740
+ gatewaySession?.sessionId !== session.session_id ||
741
+ gatewaySession.archived === true) continue
742
+ seen.add(session.session_key)
743
+ const current = active.get(session.session_key)
744
+ if (current?.generation === session.generation) {
745
+ if (current.attendant.exitCode !== null) {
746
+ active.delete(session.session_key)
747
+ if (current.child !== null) current.child.kill("SIGTERM")
748
+ startSession(session)
749
+ sendActivity(active.get(session.session_key), gatewaySession)
750
+ } else if (current.child === null) {
751
+ const ready = await runHook("openclaw-attendance-ready", {
752
+ session_id: session.session_key, cwd: session.cwd,
753
+ openclaw_session_id: session.session_id })
754
+ if (ready?.trim() === "ready") startSettlement(session, current)
755
+ }
756
+ if (active.get(session.session_key) === current) sendActivity(current, gatewaySession)
757
+ continue
758
+ }
759
+ if (current) {
760
+ if (current.child !== null) current.child.kill("SIGTERM")
761
+ current.attendant.kill("SIGTERM")
762
+ }
763
+ startSession(session)
764
+ sendActivity(active.get(session.session_key), gatewaySession)
765
+ }
766
+ for (const [key, entry] of active) {
767
+ if (seen.has(key)) continue
768
+ if (entry.child !== null) entry.child.kill("SIGTERM")
769
+ entry.attendant.kill("SIGTERM")
770
+ active.delete(key)
771
+ }
772
+ for (const record of readJournals()) void deliver(record)
773
+ for (const [deliveryId] of awaitingMessageContexts) {
774
+ const record = readMessageJournal(deliveryId)
775
+ if (record === null || record.text === undefined) {
776
+ awaitingMessageContexts.delete(deliveryId)
777
+ } else if (record.boot_id !== GATEWAY_BOOT_ID ||
778
+ nativeSessionRevision(api, record.session_key, record.openclaw_session_id) !==
779
+ record.native_revision) {
780
+ const { text: _text, ...withoutText } = record
781
+ saveMessageJournal(withoutText)
782
+ }
783
+ }
784
+ for (const pending of retryMessageJournals()) {
785
+ const record = readMessageJournal(pending.delivery_id)
786
+ if (record === null) continue
787
+ if (record.phase === "transcript" || record.phase === "unconfirmed") {
788
+ pendingMessageJournals?.delete(record.delivery_id)
789
+ continue
790
+ }
791
+ void deliverMessage(record)
792
+ }
793
+ } catch { /* The next Gateway tick retries discovery. */ }
794
+ finally { scanning = false }
795
+ }
796
+
797
+ return {
798
+ start() { pendingMessageJournals = null; timer = setInterval(() => { void tick() }, 2_000); void tick() },
799
+ stop() {
800
+ stopped = true
801
+ clearReadiness()
802
+ if (timer !== null) clearInterval(timer)
803
+ for (const entry of active.values()) {
804
+ if (entry.child !== null) entry.child.kill("SIGTERM")
805
+ entry.attendant.kill("SIGTERM")
806
+ }
807
+ active.clear()
808
+ },
809
+ }
810
+ }
811
+ `;
812
+ }
57
813
  export function openclawPluginManifest() {
58
814
  return `${JSON.stringify({
59
815
  id: OPENCLAW_PLUGIN_ID,
@@ -84,9 +840,14 @@ export function openclawPluginSource(options) {
84
840
  return `${OPENCLAW_PLUGIN_MARKER}
85
841
  // Generated by \`notifai hooks install --harness openclaw\`. Edits are lost on
86
842
  // the next install; change the CLI instead, which is where the logic lives.
87
- import { spawn } from "node:child_process"
843
+ import { spawn, execFile, execFileSync } from "node:child_process"
844
+ import { createHash, randomUUID } from "node:crypto"
845
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
846
+ import path from "node:path"
88
847
 
89
848
  ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
849
+ const HOOK_COMMAND = ${JSON.stringify(win32 ? options.nodePath ?? process.execPath : adapterPath)}
850
+ const HOOK_PREFIX = ${JSON.stringify(win32 ? [adapterPath] : [])}
90
851
  const TIMEOUT_MS = ${timeoutSeconds * 1000}
91
852
  const ADAPTER_VERSION = ${OPENCLAW_ADAPTER_VERSION}
92
853
  const MISSING_LIFECYCLE_GUIDANCE_CONTEXT = ${JSON.stringify(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)}
@@ -140,6 +901,21 @@ function sessionKeyOf(event, ctx) {
140
901
  return typeof key === "string" ? key.trim() : ""
141
902
  }
142
903
 
904
+ function sessionIdOf(event, ctx) {
905
+ const id = event?.sessionId ?? ctx?.sessionId
906
+ return typeof id === "string" && id.length > 0 ? id : undefined
907
+ }
908
+
909
+ function resumedFromOf(event) {
910
+ return typeof event?.resumedFrom === "string" && event.resumedFrom.length > 0
911
+ ? event.resumedFrom : undefined
912
+ }
913
+
914
+ function reasonOf(event) {
915
+ return typeof event?.reason === "string" && event.reason.length > 0
916
+ ? event.reason : undefined
917
+ }
918
+
143
919
  function workspaceDirOf(event, ctx) {
144
920
  const dir = ctx?.workspaceDir ?? event?.workspaceDir ?? event?.context?.workspaceDir
145
921
  return typeof dir === "string" && dir.length > 0 ? dir : process.cwd()
@@ -162,23 +938,85 @@ function isUserMessage(event, ctx) {
162
938
  return true
163
939
  }
164
940
 
941
+ function envelopeFor(sessionKey, event, ctx, hookEventName) {
942
+ return {
943
+ session_id: sessionKey,
944
+ cwd: workspaceDirOf(event, ctx),
945
+ hook_event_name: hookEventName,
946
+ openclaw_session_id: sessionIdOf(event, ctx),
947
+ openclaw_reason: reasonOf(event),
948
+ openclaw_resumed_from: resumedFromOf(event),
949
+ }
950
+ }
951
+
952
+ function onIfSupported(api, name, handler) {
953
+ try {
954
+ api.on(name, handler)
955
+ } catch {
956
+ // Older OpenClaw builds may reject a typed hook. The prompt fallback
957
+ // remains available, while unobserved rotations cannot be guessed.
958
+ }
959
+ }
960
+
961
+ ${openclawContinuationServiceSource()}
962
+
165
963
  function register(api) {
964
+ try {
965
+ const stateDir = api.runtime?.state?.resolveStateDir?.()
966
+ if (typeof stateDir === "string" && path.isAbsolute(stateDir)) {
967
+ JOURNAL_DIR = path.join(stateDir, "notifai", "continuation-journal")
968
+ MESSAGE_JOURNAL_DIR = path.join(stateDir, "notifai", "message-journal")
969
+ }
970
+ } catch { /* No private journal is available in this plugin instance. */ }
971
+ api.registerService?.({
972
+ id: "notifai-continuation",
973
+ start(ctx) {
974
+ if (typeof ctx.stateDir !== "string" || !path.isAbsolute(ctx.stateDir)) return
975
+ JOURNAL_DIR = path.join(ctx.stateDir, "notifai", "continuation-journal")
976
+ MESSAGE_JOURNAL_DIR = path.join(ctx.stateDir, "notifai", "message-journal")
977
+ continuationService = makeContinuationService(ctx.config, ctx.logger, api)
978
+ continuationService.start()
979
+ },
980
+ stop() {
981
+ continuationService?.stop()
982
+ continuationService = null
983
+ },
984
+ })
985
+ onIfSupported(api, "session_start", async (event, ctx) => {
986
+ const sessionKey = sessionKeyOf(event, ctx)
987
+ if (sessionKey === "") return
988
+ await runHook("openclaw-lifecycle", envelopeFor(sessionKey, event, ctx, "SessionStart"))
989
+ })
990
+
991
+ onIfSupported(api, "before_reset", async (event, ctx) => {
992
+ const sessionKey = sessionKeyOf(event, ctx)
993
+ if (sessionKey === "") return
994
+ const nativeRevision = nativeSessionRevision(api, sessionKey, sessionIdOf(event, ctx))
995
+ await runHook("openclaw-lifecycle", { ...envelopeFor(sessionKey, event, ctx, "BeforeReset"),
996
+ ...(nativeRevision === null ? {} : { openclaw_lifecycle_revision: nativeRevision }) })
997
+ })
998
+
166
999
  api.on("before_prompt_build", async (event, ctx) => {
167
1000
  const sessionKey = sessionKeyOf(event, ctx)
168
1001
  if (sessionKey === "") return { prependContext: WORKER_ACTIVATION_CONTEXT }
169
1002
  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.
177
- if (resolved !== null && resolved.trim().length === 0) return
1003
+ const nativeRevision = nativeSessionRevision(api, sessionKey, sessionIdOf(event, ctx))
1004
+ const envelope = { ...envelopeFor(sessionKey, event, ctx, "BeforePromptBuild"),
1005
+ ...(nativeRevision === null ? {} : { openclaw_lifecycle_revision: nativeRevision }) }
1006
+ const resolved = await runHook(worker ? "subagent-start" : "session-start",
1007
+ { ...envelope, hook_event_name: worker ? "SubagentStart" : "SessionStart" })
1008
+ // chat.send does not emit message_received on this supported Gateway path.
1009
+ // The prompt build is the exact session's authoritative turn-start seam.
1010
+ await runHook("openclaw-turn-start", envelope)
1011
+ const messageContext = worker ? null :
1012
+ await messageContextForPointer(api, event, ctx, sessionKey, nativeRevision)
1013
+ // Successful empty output means this Project is disabled or this
1014
+ // generation was already activated. A later prompt rechecks enablement.
1015
+ if (resolved !== null && resolved.trim().length === 0 && messageContext === null) return
178
1016
  const context = typeof resolved === "string" && resolved.trim().length > 0
179
1017
  ? resolved.trim()
180
- : worker ? WORKER_ACTIVATION_CONTEXT : MISSING_LIFECYCLE_GUIDANCE_CONTEXT
181
- return { prependContext: context }
1018
+ : resolved === null ? worker ? WORKER_ACTIVATION_CONTEXT : MISSING_LIFECYCLE_GUIDANCE_CONTEXT : ""
1019
+ return { prependContext: [context, messageContext].filter(Boolean).join("\\n\\n") }
182
1020
  })
183
1021
 
184
1022
  api.on("message_received", async (event, ctx) => {
@@ -195,29 +1033,25 @@ function register(api) {
195
1033
  api.on("agent_end", async (event, ctx) => {
196
1034
  const sessionKey = sessionKeyOf(event, ctx)
197
1035
  if (sessionKey === "") return
198
- await runHook("stop", {
199
- session_id: sessionKey,
200
- cwd: workspaceDirOf(event, ctx),
201
- hook_event_name: "Stop",
202
- })
1036
+ await runHook("openclaw-turn-end", envelopeFor(sessionKey, event, ctx, "Stop"))
203
1037
  })
204
1038
 
205
1039
  api.on("session_end", async (event, ctx) => {
206
1040
  const sessionKey = sessionKeyOf(event, ctx)
207
1041
  if (sessionKey === "") return
208
- await runHook("session-end", {
209
- session_id: sessionKey,
210
- cwd: workspaceDirOf(event, ctx),
211
- hook_event_name: "SessionEnd",
212
- })
1042
+ await runHook("session-end", envelopeFor(sessionKey, event, ctx, "SessionEnd"))
213
1043
  })
214
1044
 
215
- api.on("resolve_exec_env", (event, ctx) => {
1045
+ api.on("resolve_exec_env", async (event, ctx) => {
216
1046
  const sessionKey = sessionKeyOf(event, ctx)
217
1047
  if (sessionKey === "") return
1048
+ const generation = await runHook("openclaw-generation",
1049
+ envelopeFor(sessionKey, event, ctx, "ResolveExecEnv"))
1050
+ if (generation === null || !/^[0-9a-f-]{36}$/i.test(generation.trim())) return
218
1051
  return {
219
1052
  NOTIFAI_ACTIVE_HARNESS: "openclaw",
220
1053
  NOTIFAI_ACTIVE_SESSION_ID: sessionKey,
1054
+ NOTIFAI_ACTIVE_OPENCLAW_GENERATION: generation.trim(),
221
1055
  }
222
1056
  })
223
1057
  }
@@ -253,24 +1087,193 @@ export function openclawPluginTarget(contents) {
253
1087
  return null;
254
1088
  }
255
1089
  }
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;
1090
+ function isJsonObject(value) {
1091
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1092
+ }
1093
+ export function parseOpenclawConfig(source, configFile = 'OpenClaw config') {
1094
+ const errors = [];
1095
+ const parsed = parse(source, errors, { allowTrailingComma: true });
1096
+ if (errors.length > 0) {
1097
+ const problems = errors
1098
+ .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
1099
+ .join(', ');
1100
+ throw new Error(`${configFile} contains invalid JSONC (${problems}); refusing to write`);
1101
+ }
1102
+ if (isJsonObject(parsed))
1103
+ return parsed;
1104
+ throw new Error(`${configFile} is not a JSON object; refusing to write`);
1105
+ }
1106
+ function openclawFormattingOptions(source) {
1107
+ const indentation = /(?:^|\r?\n)([\t ]+)(?=")/.exec(source)?.[1] ?? ' ';
1108
+ return {
1109
+ insertSpaces: !indentation.includes('\t'),
1110
+ tabSize: indentation.length,
1111
+ eol: source.includes('\r\n') ? '\r\n' : '\n',
1112
+ };
1113
+ }
1114
+ /** Apply one JSONC edit while leaving unrelated source ranges intact. */
1115
+ export function editOpenclawConfigText(source, configFile, location, value) {
1116
+ parseOpenclawConfig(source, configFile);
1117
+ return applyEdits(source, modify(source, location, value, { formattingOptions: openclawFormattingOptions(source) }));
1118
+ }
1119
+ function objectAt(value) {
1120
+ return isJsonObject(value) ? value : null;
1121
+ }
1122
+ /** Install or refresh Notifai's entry with a JSONC parser edit. */
1123
+ export function enableOpenclawNotifaiConfigText(source, configFile) {
1124
+ const config = parseOpenclawConfig(source, configFile);
1125
+ const merged = mergeOpenclawNotifaiEntry(config);
1126
+ const mergedPlugins = objectAt(merged.plugins);
1127
+ const mergedEntries = objectAt(mergedPlugins['entries']);
1128
+ const plugins = objectAt(config.plugins);
1129
+ if (plugins === null) {
1130
+ return editOpenclawConfigText(source, configFile, ['plugins'], merged.plugins);
1131
+ }
1132
+ if (Object.hasOwn(plugins, 'entries') && !isJsonObject(plugins['entries'])) {
1133
+ return editOpenclawConfigText(source, configFile, ['plugins', 'entries'], mergedEntries);
1134
+ }
1135
+ return editOpenclawConfigText(source, configFile, ['plugins', 'entries', OPENCLAW_PLUGIN_ID], mergedEntries[OPENCLAW_PLUGIN_ID]);
1136
+ }
1137
+ // jsonc-parser's SyntaxKind is an ambient const enum, unavailable with
1138
+ // verbatimModuleSyntax. Keep the scanner token values local to this helper.
1139
+ const COMMA_TOKEN = 5;
1140
+ const LINE_COMMENT_TOKEN = 12;
1141
+ const BLOCK_COMMENT_TOKEN = 13;
1142
+ const EOF_TOKEN = 17;
1143
+ function jsoncGap(source, from, to) {
1144
+ const scanner = createScanner(source);
1145
+ scanner.setPosition(from);
1146
+ let comma = null;
1147
+ let hasComment = false;
1148
+ while (scanner.getPosition() < to) {
1149
+ const token = scanner.scan();
1150
+ const offset = scanner.getTokenOffset();
1151
+ if (offset >= to || token === EOF_TOKEN)
1152
+ break;
1153
+ if (token === COMMA_TOKEN)
1154
+ comma = offset;
1155
+ if (token === LINE_COMMENT_TOKEN || token === BLOCK_COMMENT_TOKEN) {
1156
+ hasComment = true;
261
1157
  }
262
1158
  }
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;
1159
+ return { comma, hasComment };
1160
+ }
1161
+ function removeJsoncItem(source, path) {
1162
+ const root = parseTree(source);
1163
+ if (root === undefined)
1164
+ return source;
1165
+ const value = findNodeAtLocation(root, path);
1166
+ if (value === undefined)
1167
+ return source;
1168
+ const item = value.parent?.type === 'property' ? value.parent : value;
1169
+ const container = item.parent;
1170
+ const siblings = container?.children;
1171
+ if (container === undefined || siblings === undefined)
1172
+ return source;
1173
+ const index = siblings.indexOf(item);
1174
+ if (index < 0)
1175
+ return source;
1176
+ const end = item.offset + item.length;
1177
+ const ranges = [];
1178
+ if (index < siblings.length - 1) {
1179
+ const gap = jsoncGap(source, end, siblings[index + 1].offset);
1180
+ if (gap.comma === null)
1181
+ return source;
1182
+ if (gap.hasComment) {
1183
+ ranges.push([item.offset, end], [gap.comma, gap.comma + 1]);
1184
+ }
1185
+ else {
1186
+ ranges.push([item.offset, gap.comma + 1]);
1187
+ }
1188
+ }
1189
+ else {
1190
+ const trailing = jsoncGap(source, end, container.offset + container.length - 1);
1191
+ if (trailing.comma !== null)
1192
+ ranges.push([trailing.comma, trailing.comma + 1]);
1193
+ if (index > 0) {
1194
+ const previous = siblings[index - 1];
1195
+ const gap = jsoncGap(source, previous.offset + previous.length, item.offset);
1196
+ if (gap.comma === null)
1197
+ return source;
1198
+ if (gap.hasComment) {
1199
+ ranges.push([gap.comma, gap.comma + 1], [item.offset, end]);
1200
+ }
1201
+ else {
1202
+ ranges.push([gap.comma, end]);
1203
+ }
1204
+ }
1205
+ else {
1206
+ // An inserted sole property occupies a line of its own. Recover the
1207
+ // original commented-empty object by removing that added line.
1208
+ const lineStart = source.lastIndexOf('\n', item.offset - 1) + 1;
1209
+ const lineEndOffset = source.indexOf('\n', end);
1210
+ const lineEnd = lineEndOffset < 0 ? source.length : lineEndOffset;
1211
+ const onOwnLine = source.slice(lineStart, item.offset).trim() === '' &&
1212
+ source.slice(end, lineEnd).trim() === (trailing.comma === null ? '' : ',');
1213
+ const lineBreakStart = source[lineStart - 2] === '\r' ? lineStart - 2 : lineStart - 1;
1214
+ ranges.push([onOwnLine && lineStart > 0 ? lineBreakStart : item.offset, end]);
271
1215
  }
272
1216
  }
273
- throw new Error('OpenClaw config is not a JSON object');
1217
+ let text = source;
1218
+ for (const [start, stop] of ranges.sort((a, b) => b[0] - a[0])) {
1219
+ text = text.slice(0, start) + text.slice(stop);
1220
+ }
1221
+ return text;
1222
+ }
1223
+ function removeOpenclawLoadPathText(source, pluginDir) {
1224
+ let text = source;
1225
+ for (;;) {
1226
+ const config = parseOpenclawConfig(text);
1227
+ const paths = objectAt(objectAt(config.plugins)?.['load'])?.['paths'];
1228
+ if (!Array.isArray(paths))
1229
+ return text;
1230
+ const index = paths.findIndex((value) => value === pluginDir);
1231
+ if (index < 0)
1232
+ return text;
1233
+ const next = removeJsoncItem(text, ['plugins', 'load', 'paths', index]);
1234
+ if (next === text)
1235
+ throw new Error('Could not remove the OpenClaw load path');
1236
+ text = next;
1237
+ }
1238
+ }
1239
+ /** Remove only the entry and explicit load path owned by this installation. */
1240
+ export function removeOpenclawNotifaiConfigText(source, configFile, pluginDir) {
1241
+ const config = parseOpenclawConfig(source, configFile);
1242
+ const entries = objectAt(objectAt(config.plugins)?.['entries']);
1243
+ const withoutEntry = entries !== null && Object.hasOwn(entries, OPENCLAW_PLUGIN_ID)
1244
+ ? removeJsoncItem(source, ['plugins', 'entries', OPENCLAW_PLUGIN_ID])
1245
+ : source;
1246
+ return removeOpenclawLoadPathText(withoutEntry, pluginDir);
1247
+ }
1248
+ /** Remove one older Project load path while leaving the shared entry alone. */
1249
+ export function removeOpenclawLoadPathConfigText(source, configFile, pluginDir) {
1250
+ parseOpenclawConfig(source, configFile);
1251
+ return removeOpenclawLoadPathText(source, pluginDir);
1252
+ }
1253
+ export function openclawPluginLoadBlockers(config) {
1254
+ const plugins = objectAt(config.plugins);
1255
+ if (plugins === null)
1256
+ return [];
1257
+ const blockers = [];
1258
+ const allow = plugins['allow'];
1259
+ if (Array.isArray(allow) && allow.length > 0 && !allow.includes(OPENCLAW_PLUGIN_ID)) {
1260
+ blockers.push({ key: 'plugins.allow', action: 'add' });
1261
+ }
1262
+ const deny = plugins['deny'];
1263
+ if (Array.isArray(deny) && deny.includes(OPENCLAW_PLUGIN_ID)) {
1264
+ blockers.push({ key: 'plugins.deny', action: 'remove' });
1265
+ }
1266
+ return blockers;
1267
+ }
1268
+ export function openclawPluginLoadRemedy(blocker) {
1269
+ return blocker.action === 'add'
1270
+ ? `add \"${OPENCLAW_PLUGIN_ID}\" to ${blocker.key}`
1271
+ : `remove \"${OPENCLAW_PLUGIN_ID}\" from ${blocker.key}`;
1272
+ }
1273
+ export function openclawPluginLoadWarning(blockers) {
1274
+ if (blockers.length === 0)
1275
+ return null;
1276
+ return `OpenClaw will not load Notifai: ${blockers.map(openclawPluginLoadRemedy).join('; ')}.`;
274
1277
  }
275
1278
  export function mergeOpenclawNotifaiEntry(config) {
276
1279
  const plugins = config.plugins !== null && typeof config.plugins === 'object' && !Array.isArray(config.plugins)
@@ -301,62 +1304,44 @@ export function mergeOpenclawNotifaiEntry(config) {
301
1304
  * exactly as it was.
302
1305
  */
303
1306
  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))
1307
+ const currentPlugins = objectAt(config.plugins);
1308
+ const currentLoad = objectAt(currentPlugins?.['load']);
1309
+ if (currentPlugins === null || currentLoad === null || !Array.isArray(currentLoad['paths'])) {
311
1310
  return config;
312
- const paths = load.paths.filter((value) => value !== pluginDir);
313
- if (paths.length === load.paths.length)
1311
+ }
1312
+ const load = { ...currentLoad };
1313
+ const originalPaths = currentLoad['paths'];
1314
+ const paths = originalPaths.filter((value) => value !== pluginDir);
1315
+ if (paths.length === originalPaths.length)
314
1316
  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;
1317
+ load.paths = paths;
1318
+ return { ...config, plugins: { ...currentPlugins, load } };
328
1319
  }
329
1320
  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)
1321
+ if (!isJsonObject(config.plugins))
1322
+ return config;
1323
+ const currentPlugins = config.plugins;
1324
+ const currentEntries = objectAt(currentPlugins['entries']);
1325
+ const currentLoad = objectAt(currentPlugins['load']);
1326
+ const removesEntry = currentEntries !== null && Object.hasOwn(currentEntries, OPENCLAW_PLUGIN_ID);
1327
+ const removesLoadPath = currentLoad !== null &&
1328
+ Array.isArray(currentLoad['paths']) &&
1329
+ currentLoad['paths'].includes(pluginDir);
1330
+ if (!removesEntry && !removesLoadPath)
1331
+ return config;
1332
+ const plugins = { ...currentPlugins };
1333
+ if (removesEntry) {
1334
+ const entries = { ...currentEntries };
1335
+ delete entries[OPENCLAW_PLUGIN_ID];
338
1336
  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
1337
  }
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;
1338
+ if (removesLoadPath) {
1339
+ plugins.load = {
1340
+ ...currentLoad,
1341
+ paths: currentLoad['paths'].filter((value) => value !== pluginDir),
1342
+ };
1343
+ }
1344
+ return { ...config, plugins };
360
1345
  }
361
1346
  /** Config-directory presence is not ownership. These files mean OpenClaw is actually configured. */
362
1347
  export function openclawHasGlobalEvidence(exists, env = process.env, platform = process.platform) {