@wakeshift/openclaw 0.0.0-placeholder → 1.4.24

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/dist/index.js ADDED
@@ -0,0 +1,287 @@
1
+ // The OpenClaw plugin entry: wires plugin.ts to OpenClaw's plugin API
2
+ // (2026.10.4). Only Node built-ins; no OpenClaw imports, so the same file
3
+ // loads in any OpenClaw that has these APIs.
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { HooksControl } from "./control.js";
9
+ import { bindMode, listensBeyondLoopback } from "./hooks.js";
10
+ import { Wakeshift, hasBeaconLine, stripBeacon } from "./plugin.js";
11
+ import { reset } from "./reset.js";
12
+ import { Store } from "./store.js";
13
+ import { hooksPath, wakeBody, wakeResult } from "./wake.js";
14
+ const ID = "wakeshift";
15
+ /**
16
+ * The Wakeshift whose beacons the plugin trusts when
17
+ * plugins.entries.wakeshift.config.issuer isn't set: Wakeshift's own
18
+ * service. Another Wakeshift's install command sets the issuer first.
19
+ */
20
+ export const DEFAULT_ISSUER = "https://app.wakeshift.ai";
21
+ /** How long a wake request may wait for its turn to finish. */
22
+ export const WAKE_TIMEOUT = 15 * 60 * 1000;
23
+ function stateDir(api) {
24
+ try {
25
+ const d = api.runtime?.state?.resolveStateDir?.(process.env);
26
+ if (typeof d === "string" && d)
27
+ return join(d, ID);
28
+ }
29
+ catch {
30
+ /* fall back */
31
+ }
32
+ return join(process.env.OPENCLAW_STATE_DIR || join(homedir(), ".openclaw"), ID);
33
+ }
34
+ // register never throws into the gateway: a plugin that can't start (its
35
+ // state directory unwritable, an OpenClaw without an API it needs) logs why
36
+ // and does nothing. OpenClaw registers it again when its setting changes.
37
+ export function register(api, overrides = {}) {
38
+ const log = {
39
+ info: (m) => api?.logger?.info?.(m),
40
+ warn: (m) => api?.logger?.warn?.(m),
41
+ };
42
+ try {
43
+ registerCli(api);
44
+ }
45
+ catch (e) {
46
+ log.warn(`wakeshift: no openclaw wakeshift command (${e?.message ?? e})`);
47
+ }
48
+ // Collecting CLI commands for help: there's no runtime to start with.
49
+ if (api?.registrationMode === "cli-metadata")
50
+ return;
51
+ try {
52
+ start(api, log, overrides);
53
+ }
54
+ catch (e) {
55
+ log.warn(`wakeshift: couldn't start (${e?.message ?? e}); the plugin does nothing until it's installed again`);
56
+ }
57
+ }
58
+ /** The plugin's root command, as openclaw.plugin.json's cliCommands declares it too. */
59
+ export const CLI = { name: "wakeshift", description: "Wakeshift's plugin: undo what it turned on (reset)", hasSubcommands: true };
60
+ /**
61
+ * `openclaw wakeshift reset`, run where the gateway runs, before
62
+ * `openclaw plugins uninstall wakeshift` (reset.ts says what it undoes).
63
+ */
64
+ function registerCli(api) {
65
+ if (typeof api?.registerCli !== "function")
66
+ return;
67
+ api.registerCli(({ program }) => {
68
+ const root = program.command(CLI.name).description(CLI.description);
69
+ root
70
+ .command("reset")
71
+ .description("Undo what Wakeshift's plugin turned on: OpenClaw's hooks endpoint and the plugin's token for it, the hooks settings it added, the agreement to hooks on a gateway that listens beyond this computer, and the plugin's state. Then uninstall it with: openclaw plugins uninstall wakeshift --force")
72
+ .action(async () => {
73
+ try {
74
+ const config = api.runtime.config;
75
+ const lines = await reset({
76
+ dir: stateDir(api),
77
+ id: ID,
78
+ config: {
79
+ current: () => config.current?.(),
80
+ mutate: (fn) => config.mutateConfigFile({ mutate: fn, afterWrite: { mode: "auto" } }),
81
+ },
82
+ });
83
+ for (const l of lines)
84
+ console.log(l);
85
+ console.log("To remove the plugin too: openclaw plugins uninstall wakeshift --force");
86
+ }
87
+ catch (e) {
88
+ console.error(`wakeshift: couldn't reset (${e?.message ?? e}); its state wasn't deleted, so openclaw wakeshift reset can be run again`);
89
+ process.exitCode = 1;
90
+ }
91
+ });
92
+ }, { descriptors: [CLI] });
93
+ }
94
+ /** The gateway's config now (the process snapshot). */
95
+ const current = (api) => {
96
+ try {
97
+ return api.runtime.config.current() ?? {};
98
+ }
99
+ catch {
100
+ return {};
101
+ }
102
+ };
103
+ /**
104
+ * One wake request to this gateway's own hooks endpoint, on loopback (its
105
+ * --port, which the gateway puts in OPENCLAW_GATEWAY_PORT, or gateway.port).
106
+ * A gateway bound to one other address (custom) is asked there.
107
+ */
108
+ export async function wakeOnce(api, f, r, timeout = WAKE_TIMEOUT) {
109
+ const cfg = current(api);
110
+ const hooks = cfg.hooks ?? {};
111
+ if (hooks.enabled !== true || typeof hooks.token !== "string" || !hooks.token) {
112
+ return { kind: "broken", why: "OpenClaw's hooks are off here (hooks.enabled), so a message can't start a turn" };
113
+ }
114
+ const port = Number(process.env.OPENCLAW_GATEWAY_PORT) || Number(cfg.gateway?.port) || 18789;
115
+ const custom = cfg.gateway?.bind === "custom" && typeof cfg.gateway?.customBindHost === "string" ? String(cfg.gateway.customBindHost).trim() : "";
116
+ const hosts = ["127.0.0.1", ...(custom && custom !== "127.0.0.1" ? [custom.includes(":") ? `[${custom}]` : custom] : [])];
117
+ const init = {
118
+ method: "POST",
119
+ headers: { Authorization: `Bearer ${hooks.token}`, "Content-Type": "application/json", "Idempotency-Key": r.idempotencyKey },
120
+ body: JSON.stringify(wakeBody(r)),
121
+ redirect: "error", // the token goes only to this gateway
122
+ };
123
+ for (const host of hosts) {
124
+ let res;
125
+ try {
126
+ res = await f(`http://${host}:${port}${hooksPath(hooks.path)}/agent`, { ...init, signal: AbortSignal.timeout(timeout) });
127
+ }
128
+ catch (e) {
129
+ if (e?.name === "TimeoutError")
130
+ return { kind: "retry", fresh: false, why: "the woken turn didn't finish in time" };
131
+ continue; // not listening there: the next address, or unreachable
132
+ }
133
+ const text = await res.text().catch(() => "");
134
+ let body = text;
135
+ try {
136
+ body = JSON.parse(text);
137
+ }
138
+ catch {
139
+ /* plain text */
140
+ }
141
+ return wakeResult(res.status, body, !!r.route);
142
+ }
143
+ return wakeResult(0, undefined, !!r.route);
144
+ }
145
+ function start(api, log, overrides) {
146
+ // Installed with no setting (from npm), it trusts Wakeshift's own service.
147
+ const set = typeof api.pluginConfig?.issuer === "string" ? api.pluginConfig.issuer.trim().replace(/\/$/, "") : "";
148
+ const issuer = set || DEFAULT_ISSUER;
149
+ let scheduler;
150
+ let n = 0;
151
+ // Runtime calls run inside the gateway's admission: a scheduler job of the
152
+ // plugin's service. A job settles when its work does, or at once when the
153
+ // scheduler closes: a stop or a replacement (a reinstall) never waits on
154
+ // work the job started.
155
+ const run = (fn) => new Promise((resolve, reject) => {
156
+ const s = scheduler;
157
+ if (!s || s.signal?.aborted)
158
+ return reject(new Error("the Wakeshift service isn't running"));
159
+ s.schedule({ id: `wake-${++n}`, delayMs: 0, run: () => settleOrAbort(fn(), s.signal).then(resolve, reject) });
160
+ });
161
+ const host = { wake: (r) => wakeOnce(api, overrides.fetch ?? fetch, r), log };
162
+ const store = new Store(stateDir(api));
163
+ const hooks = new HooksControl({
164
+ dir: store.dir,
165
+ log,
166
+ allowNetwork: api.pluginConfig?.allowNetworkHooks === true,
167
+ network: () => {
168
+ const gw = current(api).gateway;
169
+ return listensBeyondLoopback(bindMode(process.argv, gw, existsSync("/.dockerenv")), gw?.customBindHost);
170
+ },
171
+ restoreScript: fileURLToPath(new URL("./hooks.js", import.meta.url)),
172
+ config: {
173
+ hooks: () => current(api).hooks,
174
+ mutateHooks: (fn) => run(() => api.runtime.config.mutateConfigFile({
175
+ mutate: (draft) => {
176
+ const next = fn(draft.hooks);
177
+ if (next === undefined)
178
+ delete draft.hooks;
179
+ else
180
+ draft.hooks = next;
181
+ },
182
+ afterWrite: { mode: "auto" },
183
+ })).then(() => { }),
184
+ },
185
+ });
186
+ const wake = new Wakeshift({ issuer, store, host, hooks, ...overrides });
187
+ api.registerService({
188
+ id: "wakeshift-streams",
189
+ apiVersion: 2,
190
+ start(ctx) {
191
+ scheduler = ctx?.scheduler;
192
+ wake.start();
193
+ },
194
+ stop() {
195
+ wake.stop();
196
+ scheduler = undefined;
197
+ },
198
+ });
199
+ // A note for the session's next turn: a short, trusted notification of
200
+ // Wakeshift's own, queued in the gateway's scheduler like every other
201
+ // runtime call.
202
+ const noteLater = (note, sessionKey, agentId) => void run(async () => api.runtime.system.enqueueSystemEvent(note, { sessionKey, ...(agentId ? { agentId } : {}) })).catch((e) => log.warn(`wakeshift: couldn't queue the note: ${e.message}`));
203
+ // The channel each session last spoke on, for a woken turn's reply.
204
+ try {
205
+ api.on("message_received", (_event, ctx) => {
206
+ const sessionKey = ctx?.sessionKey;
207
+ if (typeof sessionKey !== "string" || !sessionKey)
208
+ return;
209
+ wake.onMessage(sessionKey, routeOf(ctx));
210
+ });
211
+ }
212
+ catch (e) {
213
+ log.warn(`wakeshift: no message_received hook (${e.message}); woken turns' replies stay in their session`);
214
+ }
215
+ // OpenClaw's runtime: the result the model sees, before it does. Takes the
216
+ // beacon line out and says "Wakeshift: connected" (or why a message
217
+ // couldn't wake the session). With Codex running the turn, Codex has
218
+ // already shown the model its result: _meta, which Codex keeps from it,
219
+ // carries the beacon there, and the note comes at the next turn.
220
+ const middleware = async (event, ctx) => {
221
+ const result = event?.result;
222
+ const sessionKey = ctx?.sessionKey;
223
+ if (!result || !sessionKey)
224
+ return undefined;
225
+ const note = (await wake.onResult(result, { sessionKey, agentId: ctx?.agentId, toolCallId: event?.toolCallId, toolName: event?.toolName })) || wake.takeNote(sessionKey);
226
+ if (ctx?.runtime === "codex") {
227
+ if (note)
228
+ noteLater(note, sessionKey, ctx?.agentId);
229
+ return undefined;
230
+ }
231
+ if (!note && !hasBeaconLine(result))
232
+ return undefined;
233
+ const stripped = stripBeacon(result);
234
+ if (note)
235
+ stripped.content = [...(Array.isArray(stripped.content) ? stripped.content : []), { type: "text", text: note }];
236
+ return { result: stripped };
237
+ };
238
+ let filtering = false;
239
+ try {
240
+ api.registerAgentToolResultMiddleware(middleware, { runtimes: ["openclaw", "codex"] });
241
+ filtering = true;
242
+ }
243
+ catch (e) {
244
+ log.warn(`wakeshift: no tool-result filter (${e.message}); beacons stay in what the model sees`);
245
+ }
246
+ // Every tool's result, as a fallback (and for the call inside OpenClaw's
247
+ // code mode, which the filter sees only as part of the exec result).
248
+ api.on("after_tool_call", (event, ctx) => {
249
+ const sessionKey = ctx?.sessionKey;
250
+ if (!event?.result || !sessionKey)
251
+ return;
252
+ void wake.onResult(event.result, { sessionKey, agentId: ctx?.agentId, toolCallId: event?.toolCallId ?? ctx?.toolCallId, toolName: event?.toolName }).then((note) => {
253
+ if (note && !filtering)
254
+ noteLater(note, sessionKey, ctx?.agentId);
255
+ });
256
+ });
257
+ }
258
+ // Channels that are OpenClaw's own surfaces, not a channel a reply can be
259
+ // sent to: a session there keeps its reply in its transcript.
260
+ const LOCAL_CHANNELS = new Set(["webchat", "tui", "cli", "control-ui", "internal", "gateway"]);
261
+ /** A message's route (message_received's context), when it came from a channel. */
262
+ export function routeOf(ctx) {
263
+ const channel = typeof ctx.channelId === "string" ? ctx.channelId.trim() : "";
264
+ const to = typeof ctx.conversationId === "string" ? ctx.conversationId.trim() : "";
265
+ if (!channel || !to || LOCAL_CHANNELS.has(channel.toLowerCase()))
266
+ return undefined;
267
+ const accountId = typeof ctx.accountId === "string" && ctx.accountId.trim() ? ctx.accountId.trim() : undefined;
268
+ return { channel, to, ...(accountId ? { accountId } : {}) };
269
+ }
270
+ // p, or a rejection once signal aborts (whichever is first).
271
+ export function settleOrAbort(p, signal) {
272
+ if (!signal)
273
+ return p;
274
+ return new Promise((resolve, reject) => {
275
+ const onAbort = () => reject(new Error("the Wakeshift service stopped"));
276
+ if (signal.aborted)
277
+ return onAbort();
278
+ signal.addEventListener("abort", onAbort, { once: true });
279
+ p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
280
+ });
281
+ }
282
+ export default {
283
+ id: ID,
284
+ name: "Wakeshift",
285
+ description: "Wakes OpenClaw sessions when an app they watch through Wakeshift sends something.",
286
+ register,
287
+ };