@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.
- package/CHANGELOG.md +28 -0
- package/README.md +7 -3
- package/dist/codex-tool-messages.d.ts +17 -0
- package/dist/codex-tool-messages.js +142 -0
- package/dist/codex-tool-messages.js.map +1 -0
- package/dist/commands-acknowledge.js +11 -0
- package/dist/commands-acknowledge.js.map +1 -1
- package/dist/commands-ask.js +22 -3
- package/dist/commands-ask.js.map +1 -1
- package/dist/commands-doctor.js +149 -39
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.js +3 -0
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hook-attend.d.ts +2 -0
- package/dist/commands-hook-attend.js +87 -22
- package/dist/commands-hook-attend.js.map +1 -1
- package/dist/commands-hook-diagnostics.d.ts +11 -1
- package/dist/commands-hook-diagnostics.js +40 -1
- package/dist/commands-hook-diagnostics.js.map +1 -1
- package/dist/commands-hook-install.js +162 -21
- package/dist/commands-hook-install.js.map +1 -1
- package/dist/commands-hook-run.js +245 -8
- package/dist/commands-hook-run.js.map +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +5 -4
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +29 -0
- package/dist/commands-send.js.map +1 -1
- package/dist/harnesses.d.ts +14 -10
- package/dist/harnesses.js +39 -9
- package/dist/harnesses.js.map +1 -1
- package/dist/hermes-attendant.d.ts +38 -0
- package/dist/hermes-attendant.js +333 -0
- package/dist/hermes-attendant.js.map +1 -0
- package/dist/hermes-plugin.d.ts +11 -0
- package/dist/hermes-plugin.js +299 -0
- package/dist/hermes-plugin.js.map +1 -0
- package/dist/hook-events.d.ts +9 -2
- package/dist/hook-events.js +16 -6
- package/dist/hook-events.js.map +1 -1
- package/dist/hook-lifecycle.d.ts +1 -1
- package/dist/hook-lifecycle.js +27 -10
- package/dist/hook-lifecycle.js.map +1 -1
- package/dist/hook-session-state.d.ts +4 -1
- package/dist/hook-session-state.js +16 -4
- package/dist/hook-session-state.js.map +1 -1
- package/dist/hook-types.d.ts +23 -0
- package/dist/install-hooks.js +51 -4
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +3 -0
- package/dist/invocation-context.js.map +1 -1
- package/dist/openclaw-continuation-bridge.d.ts +7 -0
- package/dist/openclaw-continuation-bridge.js +64 -0
- package/dist/openclaw-continuation-bridge.js.map +1 -0
- package/dist/openclaw-gateway-readiness.d.ts +1 -0
- package/dist/openclaw-gateway-readiness.js +24 -0
- package/dist/openclaw-gateway-readiness.js.map +1 -0
- package/dist/openclaw-generation.d.ts +21 -0
- package/dist/openclaw-generation.js +115 -0
- package/dist/openclaw-generation.js.map +1 -0
- package/dist/openclaw-message-bridge.d.ts +8 -0
- package/dist/openclaw-message-bridge.js +93 -0
- package/dist/openclaw-message-bridge.js.map +1 -0
- package/dist/openclaw-pending.d.ts +8 -0
- package/dist/openclaw-pending.js +47 -0
- package/dist/openclaw-pending.js.map +1 -0
- package/dist/openclaw-plugin.d.ts +22 -4
- package/dist/openclaw-plugin.js +1075 -90
- package/dist/openclaw-plugin.js.map +1 -1
- package/dist/openclaw-session-access.d.ts +12 -0
- package/dist/openclaw-session-access.js +79 -0
- package/dist/openclaw-session-access.js.map +1 -0
- package/dist/program.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/session-activation.js +5 -4
- package/dist/session-activation.js.map +1 -1
- package/dist/session-attendant-probe.d.ts +11 -0
- package/dist/session-attendant-probe.js +41 -0
- package/dist/session-attendant-probe.js.map +1 -1
- package/dist/session-attendant-state.d.ts +5 -0
- package/dist/session-attendant-state.js +14 -0
- package/dist/session-attendant-state.js.map +1 -1
- package/dist/session-attendant.d.ts +6 -0
- package/dist/session-attendant.js +12 -0
- package/dist/session-attendant.js.map +1 -1
- package/dist/session-delivery.d.ts +2 -0
- package/dist/session-delivery.js +2 -0
- package/dist/session-delivery.js.map +1 -1
- package/dist/session-handoff.d.ts +1 -1
- package/dist/session-labels.d.ts +2 -0
- package/dist/session-labels.js +5 -0
- package/dist/session-labels.js.map +1 -1
- package/dist/session-message-handoff.d.ts +5 -1
- package/dist/session-message-handoff.js +3 -1
- package/dist/session-message-handoff.js.map +1 -1
- package/dist/skill-source/manifest.json +6 -6
- package/dist/skill-source/notifai/SKILL.md +63 -59
- package/dist/skill-source/notifai/references/harness-setup.md +67 -18
- package/dist/skill-source/notifai/references/notes-and-edits.md +12 -1
- package/dist/skill-source/notifai/references/updates.md +7 -6
- package/dist/update-handoff.d.ts +1 -1
- package/dist/update-handoff.js +2 -1
- package/dist/update-handoff.js.map +1 -1
- package/package.json +3 -2
package/dist/openclaw-plugin.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
20
|
-
*
|
|
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 =
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
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("
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 (
|
|
352
|
-
plugins.load =
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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) {
|