@twsxtd/hapi-openclaw 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/dist/index.js +414 -167
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,14 +2,17 @@
2
2
 
3
3
  Native OpenClaw plugin adapter for HAPI integration work.
4
4
 
5
- This package now installs as an OpenClaw native plugin and exposes the HAPI-facing `/hapi/*` route surface from inside the OpenClaw Gateway. The command runtime is still mock-backed for now, but the package can already capture real OpenClaw transcript update payloads through a native plugin service.
5
+ This package installs as an OpenClaw native plugin and exposes the HAPI-facing `/hapi/*` route surface from inside the OpenClaw Gateway. V1 now creates or resumes real OpenClaw sessions, starts real embedded-agent runs, and forwards real assistant transcript messages back into HAPI. Approval bridging is still deferred.
6
6
 
7
7
  What it does:
8
8
 
9
9
  - exposes `/hapi/health` and `/hapi/channel/*` through `api.registerHttpRoute(...)`
10
10
  - enforces plugin-managed bearer auth
11
11
  - signs callback events back to HAPI
12
- - emits deterministic mock assistant and approval events for now
12
+ - derives deterministic OpenClaw session keys from HAPI namespace + user key
13
+ - starts real OpenClaw embedded-agent runs for `send-message`
14
+ - bridges assistant transcript text updates into HAPI `message` / `state` callbacks
15
+ - returns `501` for approval endpoints until the real approval bridge lands
13
16
  - records real transcript-update payloads to plugin state when `prototypeCaptureSessionKey` is configured
14
17
 
15
18
  Plugin config lives under `plugins.entries.hapi-openclaw.config` in OpenClaw config:
@@ -53,7 +56,7 @@ Example OpenClaw config:
53
56
  "hapiBaseUrl": "http://127.0.0.1:3006",
54
57
  "sharedSecret": "test-secret",
55
58
  "namespace": "default",
56
- "prototypeCaptureSessionKey": "hapi-openclaw:default:debug-user"
59
+ "prototypeCaptureSessionKey": "agent:main:hapi-openclaw:default:debug-user"
57
60
  }
58
61
  }
59
62
  }
@@ -65,4 +68,6 @@ Current milestone note:
65
68
 
66
69
  - HAPI official mode should point `OPENCLAW_PLUGIN_BASE_URL` at the OpenClaw Gateway base URL
67
70
  - the plugin route surface is native now
68
- - the real OpenClaw run/approval adapter is still not implemented; command behavior is still backed by the mock runtime
71
+ - `ensure-default-conversation` and `send-message` use the real OpenClaw runtime now
72
+ - assistant replies in HAPI come from real OpenClaw transcript updates, not mock text
73
+ - approval request / approve / deny bridging is still not implemented in this milestone
package/dist/index.js CHANGED
@@ -163,6 +163,233 @@ async function forwardNodeRequestToHono(app, req, res) {
163
163
  return true;
164
164
  }
165
165
 
166
+ // src/openclawAdapter.ts
167
+ import { randomUUID } from "node:crypto";
168
+
169
+ // src/adapterState.ts
170
+ var activeRuns = new Set;
171
+ var seenTranscriptMessageIds = new Set;
172
+ var adapterState = {
173
+ startRun(sessionKey) {
174
+ if (activeRuns.has(sessionKey)) {
175
+ return false;
176
+ }
177
+ activeRuns.add(sessionKey);
178
+ return true;
179
+ },
180
+ isRunActive(sessionKey) {
181
+ return activeRuns.has(sessionKey);
182
+ },
183
+ finishRun(sessionKey) {
184
+ return activeRuns.delete(sessionKey);
185
+ },
186
+ rememberTranscriptMessage(messageId) {
187
+ if (seenTranscriptMessageIds.has(messageId)) {
188
+ return false;
189
+ }
190
+ seenTranscriptMessageIds.add(messageId);
191
+ return true;
192
+ },
193
+ resetForTests() {
194
+ activeRuns.clear();
195
+ seenTranscriptMessageIds.clear();
196
+ }
197
+ };
198
+
199
+ // src/sessionKeys.ts
200
+ import { createHash } from "node:crypto";
201
+ var HAPI_SESSION_PREFIX = "hapi-openclaw";
202
+ var DEFAULT_AGENT_ID = "main";
203
+ var REPLY_TO_CURRENT_PREFIX = "[[reply_to_current]]";
204
+ function encodeUserKey(externalUserKey) {
205
+ const normalized = externalUserKey.trim();
206
+ if (!normalized) {
207
+ throw new Error("externalUserKey must be a non-empty string");
208
+ }
209
+ return encodeURIComponent(normalized);
210
+ }
211
+ function getDefaultAgentId() {
212
+ return DEFAULT_AGENT_ID;
213
+ }
214
+ function buildHapiConversationToken(namespace, externalUserKey) {
215
+ return `${HAPI_SESSION_PREFIX}:${namespace}:${encodeUserKey(externalUserKey)}`;
216
+ }
217
+ function buildHapiSessionKey(namespace, externalUserKey, agentId = DEFAULT_AGENT_ID) {
218
+ return `agent:${agentId}:${buildHapiConversationToken(namespace, externalUserKey)}`;
219
+ }
220
+ function parseHapiSessionKey(sessionKey) {
221
+ if (!sessionKey) {
222
+ return null;
223
+ }
224
+ const match = /^agent:([^:]+):hapi-openclaw:([^:]+):(.+)$/.exec(sessionKey.trim());
225
+ if (!match) {
226
+ return null;
227
+ }
228
+ try {
229
+ return {
230
+ agentId: match[1],
231
+ namespace: match[2],
232
+ externalUserKey: decodeURIComponent(match[3])
233
+ };
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+ function deriveDeterministicSessionId(sessionKey) {
239
+ const hex = createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
240
+ return [
241
+ hex.slice(0, 8),
242
+ hex.slice(8, 12),
243
+ hex.slice(12, 16),
244
+ hex.slice(16, 20),
245
+ hex.slice(20, 32)
246
+ ].join("-");
247
+ }
248
+ function stripReplyToCurrentPrefix(text) {
249
+ return text.replace(new RegExp(`^${REPLY_TO_CURRENT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`), "").trim();
250
+ }
251
+
252
+ // src/openclawAdapter.ts
253
+ var CONVERSATION_TITLE = "OpenClaw";
254
+ var RUN_COMPLETION_SETTLE_MS = 50;
255
+
256
+ class ConversationBusyError extends Error {
257
+ constructor() {
258
+ super("Conversation already has an active OpenClaw run");
259
+ }
260
+ }
261
+ function createStateEvent(params) {
262
+ return {
263
+ type: "state",
264
+ eventId: randomUUID(),
265
+ occurredAt: Date.now(),
266
+ namespace: params.namespace,
267
+ conversationId: params.conversationId,
268
+ connected: true,
269
+ thinking: params.thinking,
270
+ lastError: params.lastError
271
+ };
272
+ }
273
+ function getStateNamespace(sessionKey, fallbackNamespace) {
274
+ return parseHapiSessionKey(sessionKey)?.namespace ?? fallbackNamespace;
275
+ }
276
+ function delay(ms) {
277
+ return new Promise((resolve) => setTimeout(resolve, ms));
278
+ }
279
+ async function ensureSessionBinding(runtime, sessionKey, agentId) {
280
+ const storePath = runtime.agent.session.resolveStorePath(undefined, { agentId });
281
+ const store = runtime.agent.session.loadSessionStore(storePath);
282
+ const existing = store[sessionKey];
283
+ const sessionId = existing?.sessionId?.trim() || deriveDeterministicSessionId(sessionKey);
284
+ const sessionFile = runtime.agent.session.resolveSessionFilePath(sessionId, existing, { agentId });
285
+ store[sessionKey] = {
286
+ ...existing,
287
+ sessionId,
288
+ sessionFile,
289
+ updatedAt: Date.now(),
290
+ label: existing?.label ?? CONVERSATION_TITLE,
291
+ displayName: existing?.displayName ?? CONVERSATION_TITLE
292
+ };
293
+ await runtime.agent.session.saveSessionStore(storePath, store, {
294
+ activeSessionKey: sessionKey
295
+ });
296
+ return {
297
+ sessionId,
298
+ sessionFile
299
+ };
300
+ }
301
+
302
+ class RealOpenClawAdapter {
303
+ namespace;
304
+ runtime;
305
+ callbackClient;
306
+ supportsApprovals = false;
307
+ constructor(namespace, runtime, callbackClient) {
308
+ this.namespace = namespace;
309
+ this.runtime = runtime;
310
+ this.callbackClient = callbackClient;
311
+ }
312
+ async ensureDefaultConversation(externalUserKey) {
313
+ return {
314
+ conversationId: buildHapiSessionKey(this.namespace, externalUserKey, getDefaultAgentId()),
315
+ title: CONVERSATION_TITLE
316
+ };
317
+ }
318
+ isConversationBusy(conversationId) {
319
+ return adapterState.isRunActive(conversationId);
320
+ }
321
+ async sendMessage(action) {
322
+ if (!adapterState.startRun(action.conversationId)) {
323
+ throw new ConversationBusyError;
324
+ }
325
+ const namespace = getStateNamespace(action.conversationId, this.namespace);
326
+ await this.callbackClient.postEvent(createStateEvent({
327
+ namespace,
328
+ conversationId: action.conversationId,
329
+ thinking: true,
330
+ lastError: null
331
+ }));
332
+ try {
333
+ const config = this.runtime.config.loadConfig();
334
+ const agentId = parseHapiSessionKey(action.conversationId)?.agentId ?? getDefaultAgentId();
335
+ const workspaceDir = this.runtime.agent.resolveAgentWorkspaceDir(config, agentId);
336
+ await this.runtime.agent.ensureAgentWorkspace({ dir: workspaceDir });
337
+ const { sessionId, sessionFile } = await ensureSessionBinding(this.runtime, action.conversationId, agentId);
338
+ const result = await this.runtime.agent.runEmbeddedAgent({
339
+ sessionId,
340
+ sessionKey: action.conversationId,
341
+ sessionFile,
342
+ workspaceDir,
343
+ agentId,
344
+ prompt: action.text,
345
+ timeoutMs: this.runtime.agent.resolveAgentTimeoutMs({ cfg: config }),
346
+ runId: randomUUID(),
347
+ trigger: "user"
348
+ });
349
+ const runError = result.meta.error?.message?.trim() || null;
350
+ if (runError) {
351
+ if (adapterState.finishRun(action.conversationId)) {
352
+ await this.callbackClient.postEvent(createStateEvent({
353
+ namespace,
354
+ conversationId: action.conversationId,
355
+ thinking: false,
356
+ lastError: runError
357
+ }));
358
+ }
359
+ return;
360
+ }
361
+ if (result.meta.finalAssistantVisibleText) {
362
+ await delay(RUN_COMPLETION_SETTLE_MS);
363
+ }
364
+ if (adapterState.finishRun(action.conversationId)) {
365
+ await this.callbackClient.postEvent(createStateEvent({
366
+ namespace,
367
+ conversationId: action.conversationId,
368
+ thinking: false,
369
+ lastError: null
370
+ }));
371
+ }
372
+ } catch (error2) {
373
+ const message = error2 instanceof Error ? error2.message : "OpenClaw embedded run failed";
374
+ if (adapterState.finishRun(action.conversationId)) {
375
+ await this.callbackClient.postEvent(createStateEvent({
376
+ namespace,
377
+ conversationId: action.conversationId,
378
+ thinking: false,
379
+ lastError: message
380
+ }));
381
+ }
382
+ throw error2;
383
+ }
384
+ }
385
+ async approve(_action) {
386
+ throw new Error("Real OpenClaw approval bridge is not implemented yet");
387
+ }
388
+ async deny(_action) {
389
+ throw new Error("Real OpenClaw approval bridge is not implemented yet");
390
+ }
391
+ }
392
+
166
393
  // ../node_modules/.bun/openclaw@2026.4.11+63c81f13a188c8c3/node_modules/openclaw/dist/runtime-store-B1YLS6z5.js
167
394
  function createPluginRuntimeStore(errorMessage) {
168
395
  let runtime = null;
@@ -187,6 +414,156 @@ function createPluginRuntimeStore(errorMessage) {
187
414
  // src/runtimeStore.ts
188
415
  var runtimeStore = createPluginRuntimeStore("OpenClaw plugin runtime is not available outside native plugin registration");
189
416
 
417
+ // src/signing.ts
418
+ import { createHmac } from "node:crypto";
419
+ function signCallbackBody(timestamp, rawBody, secret) {
420
+ return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
421
+ }
422
+
423
+ // src/hapiClient.ts
424
+ class HapiCallbackClient {
425
+ hapiBaseUrl;
426
+ sharedSecret;
427
+ constructor(hapiBaseUrl, sharedSecret) {
428
+ this.hapiBaseUrl = hapiBaseUrl;
429
+ this.sharedSecret = sharedSecret;
430
+ }
431
+ async postEvent(event) {
432
+ const rawBody = JSON.stringify(event);
433
+ const timestamp = Date.now();
434
+ const signature = signCallbackBody(timestamp, rawBody, this.sharedSecret);
435
+ const response = await fetch(new URL("/api/openclaw/channel/events", this.hapiBaseUrl).toString(), {
436
+ method: "POST",
437
+ headers: {
438
+ "content-type": "application/json",
439
+ "x-openclaw-timestamp": `${timestamp}`,
440
+ "x-openclaw-signature": signature
441
+ },
442
+ body: rawBody
443
+ });
444
+ if (!response.ok) {
445
+ const text = await response.text().catch(() => "");
446
+ const detail = text ? `: ${text}` : "";
447
+ throw new Error(`HAPI callback failed with HTTP ${response.status}${detail}`);
448
+ }
449
+ }
450
+ }
451
+
452
+ // src/transcriptEvents.ts
453
+ function isRecord2(value) {
454
+ return typeof value === "object" && value !== null && !Array.isArray(value);
455
+ }
456
+ function extractAssistantText(content) {
457
+ if (typeof content === "string") {
458
+ const normalized = stripReplyToCurrentPrefix(content);
459
+ return normalized.length > 0 ? normalized : null;
460
+ }
461
+ if (!Array.isArray(content)) {
462
+ return null;
463
+ }
464
+ const texts = content.flatMap((entry) => {
465
+ if (!isRecord2(entry)) {
466
+ return [];
467
+ }
468
+ const block = entry;
469
+ if (block.type !== "text" || typeof block.text !== "string") {
470
+ return [];
471
+ }
472
+ const normalized = stripReplyToCurrentPrefix(block.text);
473
+ return normalized.length > 0 ? [normalized] : [];
474
+ });
475
+ if (texts.length === 0) {
476
+ return null;
477
+ }
478
+ return texts.join(`
479
+
480
+ `);
481
+ }
482
+ function normalizeAssistantTranscriptEvent(update) {
483
+ const parsed = parseHapiSessionKey(update.sessionKey);
484
+ if (!parsed || !isRecord2(update.message)) {
485
+ return null;
486
+ }
487
+ const message = update.message;
488
+ if (message.role !== "assistant") {
489
+ return null;
490
+ }
491
+ const text = extractAssistantText(message.content);
492
+ if (!text) {
493
+ return null;
494
+ }
495
+ const externalMessageId = typeof update.messageId === "string" && update.messageId.length > 0 ? update.messageId : typeof message.responseId === "string" && message.responseId.length > 0 ? message.responseId : null;
496
+ if (!externalMessageId) {
497
+ return null;
498
+ }
499
+ const createdAt = typeof message.timestamp === "number" && Number.isFinite(message.timestamp) ? message.timestamp : Date.now();
500
+ return {
501
+ type: "message",
502
+ eventId: `message:${externalMessageId}`,
503
+ occurredAt: createdAt,
504
+ namespace: parsed.namespace,
505
+ conversationId: update.sessionKey,
506
+ externalMessageId,
507
+ role: "assistant",
508
+ content: {
509
+ mode: "replace",
510
+ text
511
+ },
512
+ createdAt,
513
+ status: "completed"
514
+ };
515
+ }
516
+
517
+ // src/transcriptBridge.ts
518
+ async function handleTranscriptUpdate(ctx, callbackClient, update) {
519
+ const event = normalizeAssistantTranscriptEvent(update);
520
+ if (!event) {
521
+ return;
522
+ }
523
+ if (!adapterState.rememberTranscriptMessage(event.externalMessageId)) {
524
+ return;
525
+ }
526
+ await callbackClient.postEvent(event);
527
+ if (adapterState.finishRun(event.conversationId)) {
528
+ await callbackClient.postEvent({
529
+ type: "state",
530
+ eventId: `${event.eventId}:state`,
531
+ occurredAt: Date.now(),
532
+ namespace: event.namespace,
533
+ conversationId: event.conversationId,
534
+ connected: true,
535
+ thinking: false,
536
+ lastError: null
537
+ });
538
+ }
539
+ }
540
+ function createTranscriptBridgeService() {
541
+ let stopListening = null;
542
+ return {
543
+ id: `${OPENCLAW_PLUGIN_ID}:transcript-bridge`,
544
+ async start(ctx) {
545
+ const config = resolvePluginConfigFromOpenClawConfig(ctx.config);
546
+ if (!config) {
547
+ ctx.logger.warn(`Skipping ${OPENCLAW_PLUGIN_ID} transcript bridge because plugin config is unavailable`);
548
+ return;
549
+ }
550
+ const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
551
+ const runtime = runtimeStore.getRuntime();
552
+ stopListening = runtime.events.onSessionTranscriptUpdate((update) => {
553
+ handleTranscriptUpdate(ctx, callbackClient, update).catch((error2) => {
554
+ const message = error2 instanceof Error ? error2.message : String(error2);
555
+ ctx.logger.error(`Failed to bridge transcript update: ${message}`);
556
+ });
557
+ });
558
+ ctx.logger.info(`Started ${OPENCLAW_PLUGIN_ID} transcript-bridge service`);
559
+ },
560
+ async stop() {
561
+ stopListening?.();
562
+ stopListening = null;
563
+ }
564
+ };
565
+ }
566
+
190
567
  // src/transcriptCapture.ts
191
568
  import { appendFile, mkdir } from "node:fs/promises";
192
569
  import { join } from "node:path";
@@ -238,165 +615,6 @@ function createTranscriptCaptureService() {
238
615
  };
239
616
  }
240
617
 
241
- // src/signing.ts
242
- import { createHmac } from "node:crypto";
243
- function signCallbackBody(timestamp, rawBody, secret) {
244
- return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
245
- }
246
-
247
- // src/hapiClient.ts
248
- class HapiCallbackClient {
249
- hapiBaseUrl;
250
- sharedSecret;
251
- constructor(hapiBaseUrl, sharedSecret) {
252
- this.hapiBaseUrl = hapiBaseUrl;
253
- this.sharedSecret = sharedSecret;
254
- }
255
- async postEvent(event) {
256
- const rawBody = JSON.stringify(event);
257
- const timestamp = Date.now();
258
- const signature = signCallbackBody(timestamp, rawBody, this.sharedSecret);
259
- const response = await fetch(new URL("/api/openclaw/channel/events", this.hapiBaseUrl).toString(), {
260
- method: "POST",
261
- headers: {
262
- "content-type": "application/json",
263
- "x-openclaw-timestamp": `${timestamp}`,
264
- "x-openclaw-signature": signature
265
- },
266
- body: rawBody
267
- });
268
- if (!response.ok) {
269
- const text = await response.text().catch(() => "");
270
- const detail = text ? `: ${text}` : "";
271
- throw new Error(`HAPI callback failed with HTTP ${response.status}${detail}`);
272
- }
273
- }
274
- }
275
-
276
- // src/openclawRuntime.ts
277
- import { randomUUID } from "node:crypto";
278
-
279
- class MockOpenClawRuntime {
280
- namespace;
281
- constructor(namespace) {
282
- this.namespace = namespace;
283
- }
284
- ensureDefaultConversation(externalUserKey) {
285
- return {
286
- conversationId: `openclaw-plugin:${externalUserKey}`,
287
- title: "OpenClaw"
288
- };
289
- }
290
- run(action) {
291
- const now = Date.now();
292
- if (action.kind === "send-message") {
293
- if (action.text.toLowerCase().includes("approval")) {
294
- const requestId = `approval:${randomUUID()}`;
295
- return [
296
- {
297
- type: "state",
298
- eventId: randomUUID(),
299
- occurredAt: now,
300
- namespace: this.namespace,
301
- conversationId: action.conversationId,
302
- connected: true,
303
- thinking: true,
304
- lastError: null
305
- },
306
- {
307
- type: "approval-request",
308
- eventId: randomUUID(),
309
- occurredAt: now + 1,
310
- namespace: this.namespace,
311
- conversationId: action.conversationId,
312
- requestId,
313
- title: "Approve OpenClaw action",
314
- description: action.text,
315
- createdAt: now + 1
316
- },
317
- {
318
- type: "state",
319
- eventId: randomUUID(),
320
- occurredAt: now + 2,
321
- namespace: this.namespace,
322
- conversationId: action.conversationId,
323
- connected: true,
324
- thinking: false,
325
- lastError: null
326
- }
327
- ];
328
- }
329
- const externalMessageId = `assistant:${randomUUID()}`;
330
- return [
331
- {
332
- type: "state",
333
- eventId: randomUUID(),
334
- occurredAt: now,
335
- namespace: this.namespace,
336
- conversationId: action.conversationId,
337
- connected: true,
338
- thinking: true,
339
- lastError: null
340
- },
341
- {
342
- type: "message",
343
- eventId: randomUUID(),
344
- occurredAt: now + 1,
345
- namespace: this.namespace,
346
- conversationId: action.conversationId,
347
- externalMessageId,
348
- role: "assistant",
349
- content: { mode: "replace", text: "OpenClaw plugin echo: " },
350
- createdAt: now + 1,
351
- status: "streaming"
352
- },
353
- {
354
- type: "message",
355
- eventId: randomUUID(),
356
- occurredAt: now + 2,
357
- namespace: this.namespace,
358
- conversationId: action.conversationId,
359
- externalMessageId,
360
- role: "assistant",
361
- content: { mode: "append", delta: action.text.trim() || "(empty message)" },
362
- createdAt: now + 2,
363
- status: "completed"
364
- },
365
- {
366
- type: "state",
367
- eventId: randomUUID(),
368
- occurredAt: now + 3,
369
- namespace: this.namespace,
370
- conversationId: action.conversationId,
371
- connected: true,
372
- thinking: false,
373
- lastError: null
374
- }
375
- ];
376
- }
377
- if (action.kind === "approve") {
378
- return [{
379
- type: "approval-resolved",
380
- eventId: randomUUID(),
381
- occurredAt: now,
382
- namespace: this.namespace,
383
- conversationId: action.conversationId,
384
- requestId: action.requestId,
385
- status: "approved"
386
- }];
387
- }
388
- return [{
389
- type: "approval-resolved",
390
- eventId: randomUUID(),
391
- occurredAt: now,
392
- namespace: this.namespace,
393
- conversationId: action.conversationId,
394
- requestId: action.requestId,
395
- status: "denied"
396
- }];
397
- }
398
- }
399
-
400
618
  // src/routes.ts
401
619
  import { randomUUID as randomUUID2 } from "node:crypto";
402
620
 
@@ -1927,6 +2145,12 @@ async function dispatchEvents(callbackClient, events) {
1927
2145
  await callbackClient.postEvent(event);
1928
2146
  }
1929
2147
  }
2148
+ async function dispatchMaybeEvents(callbackClient, maybeEvents) {
2149
+ if (!maybeEvents || maybeEvents.length === 0) {
2150
+ return;
2151
+ }
2152
+ await dispatchEvents(callbackClient, maybeEvents);
2153
+ }
1930
2154
  function createPluginApp(deps) {
1931
2155
  const app = new Hono2;
1932
2156
  const healthHandler = (c) => {
@@ -1956,7 +2180,7 @@ function createPluginApp(deps) {
1956
2180
  if (!body?.externalUserKey) {
1957
2181
  return c.json({ error: "Invalid body" }, 400);
1958
2182
  }
1959
- return c.json(deps.runtime.ensureDefaultConversation(body.externalUserKey));
2183
+ return c.json(await deps.runtime.ensureDefaultConversation(body.externalUserKey));
1960
2184
  };
1961
2185
  const sendMessageHandler = async (c) => {
1962
2186
  const idempotencyKey = c.req.header("idempotency-key");
@@ -1971,6 +2195,12 @@ function createPluginApp(deps) {
1971
2195
  if (!body?.conversationId || typeof body.text !== "string" || !body.localMessageId) {
1972
2196
  return c.json({ error: "Invalid body" }, 400);
1973
2197
  }
2198
+ if (deps.runtime.isConversationBusy?.(body.conversationId)) {
2199
+ return c.json({
2200
+ error: "Conversation already has an active OpenClaw run",
2201
+ retryAfterMs: 1000
2202
+ }, 409);
2203
+ }
1974
2204
  const ack = {
1975
2205
  accepted: true,
1976
2206
  upstreamRequestId: `plugin-send:${randomUUID2()}`,
@@ -1979,16 +2209,25 @@ function createPluginApp(deps) {
1979
2209
  };
1980
2210
  deps.idempotencyCache.set(idempotencyKey, ack);
1981
2211
  queueMicrotask(() => {
1982
- dispatchEvents(deps.callbackClient, deps.runtime.run({
2212
+ deps.runtime.sendMessage({
1983
2213
  kind: "send-message",
1984
2214
  conversationId: body.conversationId,
1985
2215
  text: body.text,
1986
2216
  localMessageId: body.localMessageId
1987
- }));
2217
+ }).then(async (events) => {
2218
+ await dispatchMaybeEvents(deps.callbackClient, events);
2219
+ }).catch((error2) => {
2220
+ if (error2 instanceof ConversationBusyError) {
2221
+ return;
2222
+ }
2223
+ });
1988
2224
  });
1989
2225
  return c.json(ack);
1990
2226
  };
1991
2227
  const approveHandler = async (c) => {
2228
+ if (!deps.runtime.supportsApprovals) {
2229
+ return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
2230
+ }
1992
2231
  const idempotencyKey = c.req.header("idempotency-key");
1993
2232
  if (!idempotencyKey) {
1994
2233
  return c.json({ error: "Missing idempotency-key" }, 400);
@@ -2009,15 +2248,20 @@ function createPluginApp(deps) {
2009
2248
  };
2010
2249
  deps.idempotencyCache.set(idempotencyKey, ack);
2011
2250
  queueMicrotask(() => {
2012
- dispatchEvents(deps.callbackClient, deps.runtime.run({
2251
+ deps.runtime.approve({
2013
2252
  kind: "approve",
2014
2253
  conversationId: body.conversationId,
2015
2254
  requestId: c.req.param("requestId")
2016
- }));
2255
+ }).then(async (events) => {
2256
+ await dispatchMaybeEvents(deps.callbackClient, events);
2257
+ }).catch(() => {});
2017
2258
  });
2018
2259
  return c.json(ack);
2019
2260
  };
2020
2261
  const denyHandler = async (c) => {
2262
+ if (!deps.runtime.supportsApprovals) {
2263
+ return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
2264
+ }
2021
2265
  const idempotencyKey = c.req.header("idempotency-key");
2022
2266
  if (!idempotencyKey) {
2023
2267
  return c.json({ error: "Missing idempotency-key" }, 400);
@@ -2038,11 +2282,13 @@ function createPluginApp(deps) {
2038
2282
  };
2039
2283
  deps.idempotencyCache.set(idempotencyKey, ack);
2040
2284
  queueMicrotask(() => {
2041
- dispatchEvents(deps.callbackClient, deps.runtime.run({
2285
+ deps.runtime.deny({
2042
2286
  kind: "deny",
2043
2287
  conversationId: body.conversationId,
2044
2288
  requestId: c.req.param("requestId")
2045
- }));
2289
+ }).then(async (events) => {
2290
+ await dispatchMaybeEvents(deps.callbackClient, events);
2291
+ }).catch(() => {});
2046
2292
  });
2047
2293
  return c.json(ack);
2048
2294
  };
@@ -2057,7 +2303,7 @@ function createPluginApp(deps) {
2057
2303
  function registerPluginRoutes(api) {
2058
2304
  const config = resolvePluginConfig(api.pluginConfig ?? {});
2059
2305
  const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
2060
- const runtime = new MockOpenClawRuntime(config.namespace);
2306
+ const runtime = new RealOpenClawAdapter(config.namespace, api.runtime, callbackClient);
2061
2307
  const app = createPluginApp({
2062
2308
  sharedSecret: config.sharedSecret,
2063
2309
  namespace: config.namespace,
@@ -2098,6 +2344,7 @@ var src_default = definePluginEntry({
2098
2344
  }
2099
2345
  runtimeStore.setRuntime(api.runtime);
2100
2346
  registerPluginRoutes(api);
2347
+ api.registerService(createTranscriptBridgeService());
2101
2348
  api.registerService(createTranscriptCaptureService());
2102
2349
  }
2103
2350
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twsxtd/hapi-openclaw",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Native OpenClaw plugin that bridges HAPI to the OpenClaw Gateway route surface.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",