@twsxtd/hapi-openclaw 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/dist/index.js +414 -183
  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
@@ -106,17 +106,6 @@ function resolvePluginConfig(value) {
106
106
  prototypeCaptureFileName: readOptionalNonEmptyString(value.prototypeCaptureFileName) ?? "transcript-capture.jsonl"
107
107
  };
108
108
  }
109
- function resolvePluginConfigFromOpenClawConfig(config) {
110
- const pluginConfig = config.plugins?.entries?.[OPENCLAW_PLUGIN_ID]?.config;
111
- if (!pluginConfig) {
112
- return null;
113
- }
114
- try {
115
- return resolvePluginConfig(pluginConfig);
116
- } catch {
117
- return null;
118
- }
119
- }
120
109
 
121
110
  // src/nativeRoute.ts
122
111
  import { Readable } from "node:stream";
@@ -163,6 +152,233 @@ async function forwardNodeRequestToHono(app, req, res) {
163
152
  return true;
164
153
  }
165
154
 
155
+ // src/openclawAdapter.ts
156
+ import { randomUUID } from "node:crypto";
157
+
158
+ // src/adapterState.ts
159
+ var activeRuns = new Set;
160
+ var seenTranscriptMessageIds = new Set;
161
+ var adapterState = {
162
+ startRun(sessionKey) {
163
+ if (activeRuns.has(sessionKey)) {
164
+ return false;
165
+ }
166
+ activeRuns.add(sessionKey);
167
+ return true;
168
+ },
169
+ isRunActive(sessionKey) {
170
+ return activeRuns.has(sessionKey);
171
+ },
172
+ finishRun(sessionKey) {
173
+ return activeRuns.delete(sessionKey);
174
+ },
175
+ rememberTranscriptMessage(messageId) {
176
+ if (seenTranscriptMessageIds.has(messageId)) {
177
+ return false;
178
+ }
179
+ seenTranscriptMessageIds.add(messageId);
180
+ return true;
181
+ },
182
+ resetForTests() {
183
+ activeRuns.clear();
184
+ seenTranscriptMessageIds.clear();
185
+ }
186
+ };
187
+
188
+ // src/sessionKeys.ts
189
+ import { createHash } from "node:crypto";
190
+ var HAPI_SESSION_PREFIX = "hapi-openclaw";
191
+ var DEFAULT_AGENT_ID = "main";
192
+ var REPLY_TO_CURRENT_PREFIX = "[[reply_to_current]]";
193
+ function encodeUserKey(externalUserKey) {
194
+ const normalized = externalUserKey.trim();
195
+ if (!normalized) {
196
+ throw new Error("externalUserKey must be a non-empty string");
197
+ }
198
+ return encodeURIComponent(normalized);
199
+ }
200
+ function getDefaultAgentId() {
201
+ return DEFAULT_AGENT_ID;
202
+ }
203
+ function buildHapiConversationToken(namespace, externalUserKey) {
204
+ return `${HAPI_SESSION_PREFIX}:${namespace}:${encodeUserKey(externalUserKey)}`;
205
+ }
206
+ function buildHapiSessionKey(namespace, externalUserKey, agentId = DEFAULT_AGENT_ID) {
207
+ return `agent:${agentId}:${buildHapiConversationToken(namespace, externalUserKey)}`;
208
+ }
209
+ function parseHapiSessionKey(sessionKey) {
210
+ if (!sessionKey) {
211
+ return null;
212
+ }
213
+ const match = /^agent:([^:]+):hapi-openclaw:([^:]+):(.+)$/.exec(sessionKey.trim());
214
+ if (!match) {
215
+ return null;
216
+ }
217
+ try {
218
+ return {
219
+ agentId: match[1],
220
+ namespace: match[2],
221
+ externalUserKey: decodeURIComponent(match[3])
222
+ };
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+ function deriveDeterministicSessionId(sessionKey) {
228
+ const hex = createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
229
+ return [
230
+ hex.slice(0, 8),
231
+ hex.slice(8, 12),
232
+ hex.slice(12, 16),
233
+ hex.slice(16, 20),
234
+ hex.slice(20, 32)
235
+ ].join("-");
236
+ }
237
+ function stripReplyToCurrentPrefix(text) {
238
+ return text.replace(new RegExp(`^${REPLY_TO_CURRENT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`), "").trim();
239
+ }
240
+
241
+ // src/openclawAdapter.ts
242
+ var CONVERSATION_TITLE = "OpenClaw";
243
+ var RUN_COMPLETION_SETTLE_MS = 50;
244
+
245
+ class ConversationBusyError extends Error {
246
+ constructor() {
247
+ super("Conversation already has an active OpenClaw run");
248
+ }
249
+ }
250
+ function createStateEvent(params) {
251
+ return {
252
+ type: "state",
253
+ eventId: randomUUID(),
254
+ occurredAt: Date.now(),
255
+ namespace: params.namespace,
256
+ conversationId: params.conversationId,
257
+ connected: true,
258
+ thinking: params.thinking,
259
+ lastError: params.lastError
260
+ };
261
+ }
262
+ function getStateNamespace(sessionKey, fallbackNamespace) {
263
+ return parseHapiSessionKey(sessionKey)?.namespace ?? fallbackNamespace;
264
+ }
265
+ function delay(ms) {
266
+ return new Promise((resolve) => setTimeout(resolve, ms));
267
+ }
268
+ async function ensureSessionBinding(runtime, sessionKey, agentId) {
269
+ const storePath = runtime.agent.session.resolveStorePath(undefined, { agentId });
270
+ const store = runtime.agent.session.loadSessionStore(storePath);
271
+ const existing = store[sessionKey];
272
+ const sessionId = existing?.sessionId?.trim() || deriveDeterministicSessionId(sessionKey);
273
+ const sessionFile = runtime.agent.session.resolveSessionFilePath(sessionId, existing, { agentId });
274
+ store[sessionKey] = {
275
+ ...existing,
276
+ sessionId,
277
+ sessionFile,
278
+ updatedAt: Date.now(),
279
+ label: existing?.label ?? CONVERSATION_TITLE,
280
+ displayName: existing?.displayName ?? CONVERSATION_TITLE
281
+ };
282
+ await runtime.agent.session.saveSessionStore(storePath, store, {
283
+ activeSessionKey: sessionKey
284
+ });
285
+ return {
286
+ sessionId,
287
+ sessionFile
288
+ };
289
+ }
290
+
291
+ class RealOpenClawAdapter {
292
+ namespace;
293
+ runtime;
294
+ callbackClient;
295
+ supportsApprovals = false;
296
+ constructor(namespace, runtime, callbackClient) {
297
+ this.namespace = namespace;
298
+ this.runtime = runtime;
299
+ this.callbackClient = callbackClient;
300
+ }
301
+ async ensureDefaultConversation(externalUserKey) {
302
+ return {
303
+ conversationId: buildHapiSessionKey(this.namespace, externalUserKey, getDefaultAgentId()),
304
+ title: CONVERSATION_TITLE
305
+ };
306
+ }
307
+ isConversationBusy(conversationId) {
308
+ return adapterState.isRunActive(conversationId);
309
+ }
310
+ async sendMessage(action) {
311
+ if (!adapterState.startRun(action.conversationId)) {
312
+ throw new ConversationBusyError;
313
+ }
314
+ const namespace = getStateNamespace(action.conversationId, this.namespace);
315
+ await this.callbackClient.postEvent(createStateEvent({
316
+ namespace,
317
+ conversationId: action.conversationId,
318
+ thinking: true,
319
+ lastError: null
320
+ }));
321
+ try {
322
+ const config = this.runtime.config.loadConfig();
323
+ const agentId = parseHapiSessionKey(action.conversationId)?.agentId ?? getDefaultAgentId();
324
+ const workspaceDir = this.runtime.agent.resolveAgentWorkspaceDir(config, agentId);
325
+ await this.runtime.agent.ensureAgentWorkspace({ dir: workspaceDir });
326
+ const { sessionId, sessionFile } = await ensureSessionBinding(this.runtime, action.conversationId, agentId);
327
+ const result = await this.runtime.agent.runEmbeddedAgent({
328
+ sessionId,
329
+ sessionKey: action.conversationId,
330
+ sessionFile,
331
+ workspaceDir,
332
+ agentId,
333
+ prompt: action.text,
334
+ timeoutMs: this.runtime.agent.resolveAgentTimeoutMs({ cfg: config }),
335
+ runId: randomUUID(),
336
+ trigger: "user"
337
+ });
338
+ const runError = result.meta.error?.message?.trim() || null;
339
+ if (runError) {
340
+ if (adapterState.finishRun(action.conversationId)) {
341
+ await this.callbackClient.postEvent(createStateEvent({
342
+ namespace,
343
+ conversationId: action.conversationId,
344
+ thinking: false,
345
+ lastError: runError
346
+ }));
347
+ }
348
+ return;
349
+ }
350
+ if (result.meta.finalAssistantVisibleText) {
351
+ await delay(RUN_COMPLETION_SETTLE_MS);
352
+ }
353
+ if (adapterState.finishRun(action.conversationId)) {
354
+ await this.callbackClient.postEvent(createStateEvent({
355
+ namespace,
356
+ conversationId: action.conversationId,
357
+ thinking: false,
358
+ lastError: null
359
+ }));
360
+ }
361
+ } catch (error2) {
362
+ const message = error2 instanceof Error ? error2.message : "OpenClaw embedded run failed";
363
+ if (adapterState.finishRun(action.conversationId)) {
364
+ await this.callbackClient.postEvent(createStateEvent({
365
+ namespace,
366
+ conversationId: action.conversationId,
367
+ thinking: false,
368
+ lastError: message
369
+ }));
370
+ }
371
+ throw error2;
372
+ }
373
+ }
374
+ async approve(_action) {
375
+ throw new Error("Real OpenClaw approval bridge is not implemented yet");
376
+ }
377
+ async deny(_action) {
378
+ throw new Error("Real OpenClaw approval bridge is not implemented yet");
379
+ }
380
+ }
381
+
166
382
  // ../node_modules/.bun/openclaw@2026.4.11+63c81f13a188c8c3/node_modules/openclaw/dist/runtime-store-B1YLS6z5.js
167
383
  function createPluginRuntimeStore(errorMessage) {
168
384
  let runtime = null;
@@ -187,12 +403,157 @@ function createPluginRuntimeStore(errorMessage) {
187
403
  // src/runtimeStore.ts
188
404
  var runtimeStore = createPluginRuntimeStore("OpenClaw plugin runtime is not available outside native plugin registration");
189
405
 
406
+ // src/signing.ts
407
+ import { createHmac } from "node:crypto";
408
+ function signCallbackBody(timestamp, rawBody, secret) {
409
+ return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
410
+ }
411
+
412
+ // src/hapiClient.ts
413
+ class HapiCallbackClient {
414
+ hapiBaseUrl;
415
+ sharedSecret;
416
+ constructor(hapiBaseUrl, sharedSecret) {
417
+ this.hapiBaseUrl = hapiBaseUrl;
418
+ this.sharedSecret = sharedSecret;
419
+ }
420
+ async postEvent(event) {
421
+ const rawBody = JSON.stringify(event);
422
+ const timestamp = Date.now();
423
+ const signature = signCallbackBody(timestamp, rawBody, this.sharedSecret);
424
+ const response = await fetch(new URL("/api/openclaw/channel/events", this.hapiBaseUrl).toString(), {
425
+ method: "POST",
426
+ headers: {
427
+ "content-type": "application/json",
428
+ "x-openclaw-timestamp": `${timestamp}`,
429
+ "x-openclaw-signature": signature
430
+ },
431
+ body: rawBody
432
+ });
433
+ if (!response.ok) {
434
+ const text = await response.text().catch(() => "");
435
+ const detail = text ? `: ${text}` : "";
436
+ throw new Error(`HAPI callback failed with HTTP ${response.status}${detail}`);
437
+ }
438
+ }
439
+ }
440
+
441
+ // src/transcriptEvents.ts
442
+ function isRecord2(value) {
443
+ return typeof value === "object" && value !== null && !Array.isArray(value);
444
+ }
445
+ function extractAssistantText(content) {
446
+ if (typeof content === "string") {
447
+ const normalized = stripReplyToCurrentPrefix(content);
448
+ return normalized.length > 0 ? normalized : null;
449
+ }
450
+ if (!Array.isArray(content)) {
451
+ return null;
452
+ }
453
+ const texts = content.flatMap((entry) => {
454
+ if (!isRecord2(entry)) {
455
+ return [];
456
+ }
457
+ const block = entry;
458
+ if (block.type !== "text" || typeof block.text !== "string") {
459
+ return [];
460
+ }
461
+ const normalized = stripReplyToCurrentPrefix(block.text);
462
+ return normalized.length > 0 ? [normalized] : [];
463
+ });
464
+ if (texts.length === 0) {
465
+ return null;
466
+ }
467
+ return texts.join(`
468
+
469
+ `);
470
+ }
471
+ function normalizeAssistantTranscriptEvent(update) {
472
+ const parsed = parseHapiSessionKey(update.sessionKey);
473
+ if (!parsed || !isRecord2(update.message)) {
474
+ return null;
475
+ }
476
+ const message = update.message;
477
+ if (message.role !== "assistant") {
478
+ return null;
479
+ }
480
+ const text = extractAssistantText(message.content);
481
+ if (!text) {
482
+ return null;
483
+ }
484
+ const externalMessageId = typeof update.messageId === "string" && update.messageId.length > 0 ? update.messageId : typeof message.responseId === "string" && message.responseId.length > 0 ? message.responseId : null;
485
+ if (!externalMessageId) {
486
+ return null;
487
+ }
488
+ const createdAt = typeof message.timestamp === "number" && Number.isFinite(message.timestamp) ? message.timestamp : Date.now();
489
+ return {
490
+ type: "message",
491
+ eventId: `message:${externalMessageId}`,
492
+ occurredAt: createdAt,
493
+ namespace: parsed.namespace,
494
+ conversationId: update.sessionKey,
495
+ externalMessageId,
496
+ role: "assistant",
497
+ content: {
498
+ mode: "replace",
499
+ text
500
+ },
501
+ createdAt,
502
+ status: "completed"
503
+ };
504
+ }
505
+
506
+ // src/transcriptBridge.ts
507
+ async function handleTranscriptUpdate(ctx, callbackClient, update) {
508
+ const event = normalizeAssistantTranscriptEvent(update);
509
+ if (!event) {
510
+ return;
511
+ }
512
+ if (!adapterState.rememberTranscriptMessage(event.externalMessageId)) {
513
+ return;
514
+ }
515
+ await callbackClient.postEvent(event);
516
+ if (adapterState.finishRun(event.conversationId)) {
517
+ await callbackClient.postEvent({
518
+ type: "state",
519
+ eventId: `${event.eventId}:state`,
520
+ occurredAt: Date.now(),
521
+ namespace: event.namespace,
522
+ conversationId: event.conversationId,
523
+ connected: true,
524
+ thinking: false,
525
+ lastError: null
526
+ });
527
+ }
528
+ }
529
+ function createTranscriptBridgeService(config) {
530
+ let stopListening = null;
531
+ return {
532
+ id: `${OPENCLAW_PLUGIN_ID}:transcript-bridge`,
533
+ async start(ctx) {
534
+ const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
535
+ const runtime = runtimeStore.getRuntime();
536
+ stopListening = runtime.events.onSessionTranscriptUpdate((update) => {
537
+ handleTranscriptUpdate(ctx, callbackClient, update).catch((error2) => {
538
+ const message = error2 instanceof Error ? error2.message : String(error2);
539
+ ctx.logger.error(`Failed to bridge transcript update: ${message}`);
540
+ });
541
+ });
542
+ ctx.logger.info(`Started ${OPENCLAW_PLUGIN_ID} transcript-bridge service`);
543
+ },
544
+ async stop() {
545
+ stopListening?.();
546
+ stopListening = null;
547
+ }
548
+ };
549
+ }
550
+
190
551
  // src/transcriptCapture.ts
191
552
  import { appendFile, mkdir } from "node:fs/promises";
192
553
  import { join } from "node:path";
193
554
  var CAPTURE_DIRECTORY = "hapi-openclaw";
194
555
  function resolveCaptureFilePath(ctx, config) {
195
- const fileName = config?.prototypeCaptureFileName ?? "transcript-capture.jsonl";
556
+ const fileName = config.prototypeCaptureFileName;
196
557
  return join(ctx.stateDir, CAPTURE_DIRECTORY, fileName);
197
558
  }
198
559
  async function writeCaptureRecord(ctx, config, record) {
@@ -202,18 +563,17 @@ async function writeCaptureRecord(ctx, config, record) {
202
563
  `, "utf8");
203
564
  }
204
565
  function shouldCapture(config, sessionKey) {
205
- if (!config?.prototypeCaptureSessionKey) {
566
+ if (!config.prototypeCaptureSessionKey) {
206
567
  return false;
207
568
  }
208
569
  return sessionKey === config.prototypeCaptureSessionKey;
209
570
  }
210
- function createTranscriptCaptureService() {
571
+ function createTranscriptCaptureService(config) {
211
572
  let stopListening = null;
212
573
  return {
213
574
  id: `${OPENCLAW_PLUGIN_ID}:transcript-capture`,
214
575
  async start(ctx) {
215
576
  const runtime = runtimeStore.getRuntime();
216
- const config = resolvePluginConfigFromOpenClawConfig(ctx.config);
217
577
  stopListening = runtime.events.onSessionTranscriptUpdate((update) => {
218
578
  if (!shouldCapture(config, update.sessionKey)) {
219
579
  return;
@@ -238,165 +598,6 @@ function createTranscriptCaptureService() {
238
598
  };
239
599
  }
240
600
 
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
601
  // src/routes.ts
401
602
  import { randomUUID as randomUUID2 } from "node:crypto";
402
603
 
@@ -1927,6 +2128,12 @@ async function dispatchEvents(callbackClient, events) {
1927
2128
  await callbackClient.postEvent(event);
1928
2129
  }
1929
2130
  }
2131
+ async function dispatchMaybeEvents(callbackClient, maybeEvents) {
2132
+ if (!maybeEvents || maybeEvents.length === 0) {
2133
+ return;
2134
+ }
2135
+ await dispatchEvents(callbackClient, maybeEvents);
2136
+ }
1930
2137
  function createPluginApp(deps) {
1931
2138
  const app = new Hono2;
1932
2139
  const healthHandler = (c) => {
@@ -1956,7 +2163,7 @@ function createPluginApp(deps) {
1956
2163
  if (!body?.externalUserKey) {
1957
2164
  return c.json({ error: "Invalid body" }, 400);
1958
2165
  }
1959
- return c.json(deps.runtime.ensureDefaultConversation(body.externalUserKey));
2166
+ return c.json(await deps.runtime.ensureDefaultConversation(body.externalUserKey));
1960
2167
  };
1961
2168
  const sendMessageHandler = async (c) => {
1962
2169
  const idempotencyKey = c.req.header("idempotency-key");
@@ -1971,6 +2178,12 @@ function createPluginApp(deps) {
1971
2178
  if (!body?.conversationId || typeof body.text !== "string" || !body.localMessageId) {
1972
2179
  return c.json({ error: "Invalid body" }, 400);
1973
2180
  }
2181
+ if (deps.runtime.isConversationBusy?.(body.conversationId)) {
2182
+ return c.json({
2183
+ error: "Conversation already has an active OpenClaw run",
2184
+ retryAfterMs: 1000
2185
+ }, 409);
2186
+ }
1974
2187
  const ack = {
1975
2188
  accepted: true,
1976
2189
  upstreamRequestId: `plugin-send:${randomUUID2()}`,
@@ -1979,16 +2192,25 @@ function createPluginApp(deps) {
1979
2192
  };
1980
2193
  deps.idempotencyCache.set(idempotencyKey, ack);
1981
2194
  queueMicrotask(() => {
1982
- dispatchEvents(deps.callbackClient, deps.runtime.run({
2195
+ deps.runtime.sendMessage({
1983
2196
  kind: "send-message",
1984
2197
  conversationId: body.conversationId,
1985
2198
  text: body.text,
1986
2199
  localMessageId: body.localMessageId
1987
- }));
2200
+ }).then(async (events) => {
2201
+ await dispatchMaybeEvents(deps.callbackClient, events);
2202
+ }).catch((error2) => {
2203
+ if (error2 instanceof ConversationBusyError) {
2204
+ return;
2205
+ }
2206
+ });
1988
2207
  });
1989
2208
  return c.json(ack);
1990
2209
  };
1991
2210
  const approveHandler = async (c) => {
2211
+ if (!deps.runtime.supportsApprovals) {
2212
+ return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
2213
+ }
1992
2214
  const idempotencyKey = c.req.header("idempotency-key");
1993
2215
  if (!idempotencyKey) {
1994
2216
  return c.json({ error: "Missing idempotency-key" }, 400);
@@ -2009,15 +2231,20 @@ function createPluginApp(deps) {
2009
2231
  };
2010
2232
  deps.idempotencyCache.set(idempotencyKey, ack);
2011
2233
  queueMicrotask(() => {
2012
- dispatchEvents(deps.callbackClient, deps.runtime.run({
2234
+ deps.runtime.approve({
2013
2235
  kind: "approve",
2014
2236
  conversationId: body.conversationId,
2015
2237
  requestId: c.req.param("requestId")
2016
- }));
2238
+ }).then(async (events) => {
2239
+ await dispatchMaybeEvents(deps.callbackClient, events);
2240
+ }).catch(() => {});
2017
2241
  });
2018
2242
  return c.json(ack);
2019
2243
  };
2020
2244
  const denyHandler = async (c) => {
2245
+ if (!deps.runtime.supportsApprovals) {
2246
+ return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
2247
+ }
2021
2248
  const idempotencyKey = c.req.header("idempotency-key");
2022
2249
  if (!idempotencyKey) {
2023
2250
  return c.json({ error: "Missing idempotency-key" }, 400);
@@ -2038,11 +2265,13 @@ function createPluginApp(deps) {
2038
2265
  };
2039
2266
  deps.idempotencyCache.set(idempotencyKey, ack);
2040
2267
  queueMicrotask(() => {
2041
- dispatchEvents(deps.callbackClient, deps.runtime.run({
2268
+ deps.runtime.deny({
2042
2269
  kind: "deny",
2043
2270
  conversationId: body.conversationId,
2044
2271
  requestId: c.req.param("requestId")
2045
- }));
2272
+ }).then(async (events) => {
2273
+ await dispatchMaybeEvents(deps.callbackClient, events);
2274
+ }).catch(() => {});
2046
2275
  });
2047
2276
  return c.json(ack);
2048
2277
  };
@@ -2057,7 +2286,7 @@ function createPluginApp(deps) {
2057
2286
  function registerPluginRoutes(api) {
2058
2287
  const config = resolvePluginConfig(api.pluginConfig ?? {});
2059
2288
  const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
2060
- const runtime = new MockOpenClawRuntime(config.namespace);
2289
+ const runtime = new RealOpenClawAdapter(config.namespace, api.runtime, callbackClient);
2061
2290
  const app = createPluginApp({
2062
2291
  sharedSecret: config.sharedSecret,
2063
2292
  namespace: config.namespace,
@@ -2098,7 +2327,9 @@ var src_default = definePluginEntry({
2098
2327
  }
2099
2328
  runtimeStore.setRuntime(api.runtime);
2100
2329
  registerPluginRoutes(api);
2101
- api.registerService(createTranscriptCaptureService());
2330
+ const config = resolvePluginConfig(api.pluginConfig ?? {});
2331
+ api.registerService(createTranscriptBridgeService(config));
2332
+ api.registerService(createTranscriptCaptureService(config));
2102
2333
  }
2103
2334
  });
2104
2335
  export {
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.2",
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",