@yahaha-studio/kichi-forwarder 0.1.1-beta.1 → 0.1.1-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/index.ts CHANGED
@@ -411,9 +411,66 @@ function notifyMessageReceived(
411
411
  service.sendHookNotify("message_received", `"${trimmed}"`);
412
412
  }
413
413
 
414
+ function trimOptionalString(value: unknown): string | undefined {
415
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
416
+ }
417
+
418
+ function readExtraStringField(source: unknown, key: string): string | undefined {
419
+ if (!isPlainObject(source)) {
420
+ return undefined;
421
+ }
422
+ return trimOptionalString(source[key]);
423
+ }
424
+
425
+ function resolveBeforeDispatchLocator(
426
+ event: { sessionKey?: string },
427
+ ctx: { sessionKey?: string },
428
+ ): {
429
+ ctxAgentId?: string;
430
+ sessionKey?: string;
431
+ } {
432
+ const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
433
+ const sessionKey = trimOptionalString(ctx.sessionKey) ?? trimOptionalString(event.sessionKey);
434
+ return {
435
+ ...(ctxAgentId ? { ctxAgentId } : {}),
436
+ ...(sessionKey ? { sessionKey } : {}),
437
+ };
438
+ }
439
+
440
+ function resolveAgentHookLocator(ctx: {
441
+ agentId?: string;
442
+ sessionKey?: string;
443
+ }): {
444
+ agentId?: string;
445
+ ctxAgentId?: string;
446
+ sessionKey?: string;
447
+ } {
448
+ const agentId = trimOptionalString(ctx.agentId);
449
+ const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
450
+ const sessionKey = trimOptionalString(ctx.sessionKey);
451
+ return {
452
+ ...(agentId ? { agentId } : {}),
453
+ ...(ctxAgentId ? { ctxAgentId } : {}),
454
+ ...(sessionKey ? { sessionKey } : {}),
455
+ };
456
+ }
457
+
458
+ function resolveToolLocator(ctx: OpenClawPluginToolContext): {
459
+ agentId?: string;
460
+ sessionKey?: string;
461
+ } {
462
+ const agentId = trimOptionalString(ctx.agentId);
463
+ const sessionKey = trimOptionalString(ctx.sessionKey);
464
+ return {
465
+ ...(agentId ? { agentId } : {}),
466
+ ...(sessionKey ? { sessionKey } : {}),
467
+ };
468
+ }
469
+
414
470
  function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntimeManager): void {
415
471
  api.on("before_dispatch", (event, ctx) => {
416
- const service = runtimeManager.getRuntime(ctx);
472
+ const locator = resolveBeforeDispatchLocator(event, ctx);
473
+ const service = runtimeManager.getRuntime(locator);
417
474
  if (!service) {
418
475
  return;
419
476
  }
@@ -428,7 +485,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
428
485
  });
429
486
 
430
487
  api.on("before_prompt_build", (_event, ctx) => {
431
- const service = runtimeManager.getRuntime(ctx);
488
+ const locator = resolveAgentHookLocator(ctx);
489
+ const service = runtimeManager.getRuntime(locator);
432
490
  if (!service?.hasValidIdentity() || !service.isConnected()) {
433
491
  return;
434
492
  }
@@ -445,7 +503,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
445
503
  });
446
504
 
447
505
  api.on("before_tool_call", (_event, ctx) => {
448
- const service = runtimeManager.getRuntime(ctx);
506
+ const locator = resolveAgentHookLocator(ctx);
507
+ const service = runtimeManager.getRuntime(locator);
449
508
  if (!service) {
450
509
  return;
451
510
  }
@@ -455,7 +514,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
455
514
  });
456
515
 
457
516
  api.on("agent_end", (event, ctx) => {
458
- const service = runtimeManager.getRuntime(ctx);
517
+ const locator = resolveAgentHookLocator(ctx);
518
+ const service = runtimeManager.getRuntime(locator);
459
519
  const preview = getLastAssistantPreview(event.messages, MAX_AGENT_END_PREVIEW_WIDTH);
460
520
  api.logger.debug(
461
521
  `[kichi:${service?.getAgentId() ?? "unknown"}] agent_end fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`,
@@ -944,7 +1004,7 @@ function createAgentScopedTool(
944
1004
  factory: (service: KichiForwarderService, ctx: OpenClawPluginToolContext) => AnyAgentTool,
945
1005
  ) {
946
1006
  return (ctx: OpenClawPluginToolContext) => {
947
- const service = runtimeManager.getRuntime(ctx, { createIfMissing: true });
1007
+ const service = runtimeManager.getRuntime(resolveToolLocator(ctx));
948
1008
  if (!service) {
949
1009
  throw new Error("Failed to resolve agent-scoped Kichi runtime");
950
1010
  }
@@ -952,13 +1012,30 @@ function createAgentScopedTool(
952
1012
  };
953
1013
  }
954
1014
 
1015
+ const GLOBAL_RUNTIME_MANAGER_KEY = "__kichi_forwarder_runtime_manager__";
1016
+
1017
+ type GlobalRuntimeManagerState = typeof globalThis & {
1018
+ [GLOBAL_RUNTIME_MANAGER_KEY]?: KichiRuntimeManager;
1019
+ };
1020
+
1021
+ function getRuntimeManager(logger: OpenClawPluginApi["logger"]): KichiRuntimeManager {
1022
+ const globalState = globalThis as GlobalRuntimeManagerState;
1023
+ const existing = globalState[GLOBAL_RUNTIME_MANAGER_KEY];
1024
+ if (existing) {
1025
+ return existing;
1026
+ }
1027
+ const runtimeManager = new KichiRuntimeManager(logger);
1028
+ globalState[GLOBAL_RUNTIME_MANAGER_KEY] = runtimeManager;
1029
+ return runtimeManager;
1030
+ }
1031
+
955
1032
  const plugin = {
956
1033
  id: "kichi-forwarder",
957
1034
  name: "Kichi Forwarder",
958
1035
  configSchema: { parse },
959
1036
 
960
1037
  register(api: OpenClawPluginApi) {
961
- const runtimeManager = new KichiRuntimeManager(api.logger);
1038
+ const runtimeManager = getRuntimeManager(api.logger);
962
1039
  registerPluginHooks(api, runtimeManager);
963
1040
  const musicTitleEnum = getMusicTitleEnum();
964
1041
 
@@ -966,10 +1043,14 @@ const plugin = {
966
1043
  id: "kichi-forwarder",
967
1044
  start: (ctx) => {
968
1045
  parse(ctx.config.plugins?.entries?.["kichi-forwarder"]?.config);
969
- runtimeManager.startPersistedRuntimes();
1046
+ runtimeManager.initializeStartupRuntimes();
970
1047
  },
971
1048
  stop: () => {
972
1049
  runtimeManager.stopAll();
1050
+ const globalState = globalThis as GlobalRuntimeManagerState;
1051
+ if (globalState[GLOBAL_RUNTIME_MANAGER_KEY] === runtimeManager) {
1052
+ delete globalState[GLOBAL_RUNTIME_MANAGER_KEY];
1053
+ }
973
1054
  },
974
1055
  });
975
1056
 
@@ -1031,7 +1112,14 @@ const plugin = {
1031
1112
  },
1032
1113
  })));
1033
1114
 
1034
- api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1115
+ api.registerTool((ctx: OpenClawPluginToolContext) => {
1116
+ const locator = resolveToolLocator(ctx);
1117
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1118
+ if (!agentId) {
1119
+ throw new Error("Failed to resolve agent-scoped Kichi runtime");
1120
+ }
1121
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1122
+ return ({
1035
1123
  name: "kichi_switch_host",
1036
1124
  description:
1037
1125
  "Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
@@ -1058,7 +1146,8 @@ const plugin = {
1058
1146
  status,
1059
1147
  };
1060
1148
  },
1061
- })));
1149
+ });
1150
+ });
1062
1151
 
1063
1152
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1064
1153
  name: "kichi_rejoin",
@@ -2,7 +2,7 @@
2
2
  "id": "kichi-forwarder",
3
3
  "name": "Kichi Forwarder",
4
4
  "description": "Native OpenClaw plugin for Kichi World with direct avatar control, status sync, timers, notes, and music tools",
5
- "version": "0.1.1-beta.1",
5
+ "version": "0.1.1-beta.2",
6
6
  "author": "OpenClaw",
7
7
  "skills": ["./skills/kichi-forwarder"],
8
8
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/kichi-forwarder",
3
- "version": "0.1.1-beta.1",
3
+ "version": "0.1.1-beta.2",
4
4
  "description": "Native OpenClaw plugin for Kichi World with direct avatar control, status sync, timers, notes, and music tools",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -2,7 +2,6 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { Logger } from "openclaw/plugin-sdk";
5
- import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
6
5
  import { KichiForwarderService } from "./service.js";
7
6
 
8
7
  const OPENCLAW_HOME_DIR = path.join(os.homedir(), ".openclaw");
@@ -15,37 +14,43 @@ const LEGACY_MIGRATION_AGENT_ID = "main";
15
14
 
16
15
  type AgentLocator = {
17
16
  agentId?: string;
17
+ ctxAgentId?: string;
18
18
  sessionKey?: string;
19
19
  };
20
20
 
21
- type GetRuntimeOptions = {
22
- createIfMissing?: boolean;
23
- };
24
-
25
21
  export class KichiRuntimeManager {
26
22
  private services = new Map<string, KichiForwarderService>();
27
23
 
28
24
  constructor(private logger: Logger) {}
29
25
 
30
- getRuntime(locator: AgentLocator, options: GetRuntimeOptions = {}): KichiForwarderService | null {
26
+ getRuntime(locator: AgentLocator): KichiForwarderService | null {
31
27
  const agentId = this.resolveAgentId(locator);
32
28
  if (!agentId) {
33
29
  return null;
34
30
  }
35
31
 
36
- const existing = this.services.get(agentId);
37
- if (existing) {
38
- return existing;
32
+ return this.services.get(agentId) ?? null;
33
+ }
34
+
35
+ resolveRuntimeAgentId(locator: AgentLocator): string | null {
36
+ return this.resolveAgentId(locator);
37
+ }
38
+
39
+ createRuntimeForAgent(agentId: string): KichiForwarderService {
40
+ const normalizedAgentId = this.normalizeAgentId(agentId);
41
+ if (!normalizedAgentId) {
42
+ throw new Error("Cannot create Kichi runtime without a valid agentId");
39
43
  }
40
44
 
41
- if (!options.createIfMissing && !this.hasPersistedRuntime(agentId)) {
42
- return null;
45
+ const existing = this.services.get(normalizedAgentId);
46
+ if (existing) {
47
+ return existing;
43
48
  }
44
49
 
45
- return this.createRuntime(agentId);
50
+ return this.createRuntime(normalizedAgentId);
46
51
  }
47
52
 
48
- startPersistedRuntimes(): void {
53
+ initializeStartupRuntimes(): void {
49
54
  this.migrateRuntimeStorage();
50
55
 
51
56
  const rootDir = CANONICAL_AGENT_ROOT_DIR;
@@ -80,24 +85,36 @@ export class KichiRuntimeManager {
80
85
  }
81
86
 
82
87
  private resolveAgentId(locator: AgentLocator): string | null {
83
- if (typeof locator.agentId === "string" && locator.agentId.trim()) {
84
- return locator.agentId.trim();
88
+ const directAgentId = this.normalizeAgentId(locator.ctxAgentId) ?? this.normalizeAgentId(locator.agentId);
89
+ const sessionAgentId =
90
+ typeof locator.sessionKey === "string" && locator.sessionKey.trim()
91
+ ? this.parseAgentIdFromSessionKey(locator.sessionKey)
92
+ : null;
93
+
94
+ if (sessionAgentId) {
95
+ if (directAgentId && directAgentId !== sessionAgentId) {
96
+ this.logger.error(
97
+ `[kichi] runtime scope mismatch: directAgentId=${directAgentId} sessionAgentId=${sessionAgentId} sessionKey=${locator.sessionKey}`,
98
+ );
99
+ }
100
+ this.logger.debug(`[kichi] resolved agent runtime from sessionKey: ${sessionAgentId}`);
101
+ return sessionAgentId;
85
102
  }
86
103
 
87
- if (typeof locator.sessionKey !== "string" || !locator.sessionKey.trim()) {
88
- return null;
89
- }
104
+ return directAgentId;
105
+ }
90
106
 
91
- try {
92
- const agentId = resolveAgentIdFromSessionKey(locator.sessionKey);
93
- return typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
94
- } catch {
95
- return null;
96
- }
107
+ private normalizeAgentId(value: unknown): string | null {
108
+ return typeof value === "string" && value.trim() ? value.trim() : null;
97
109
  }
98
110
 
99
- private hasPersistedRuntime(agentId: string): boolean {
100
- return fs.existsSync(path.join(this.getRuntimeDir(agentId), "state.json"));
111
+ private parseAgentIdFromSessionKey(sessionKey: string): string | null {
112
+ const trimmed = sessionKey.trim();
113
+ const match = /^agent:([^:]+):/i.exec(trimmed);
114
+ if (!match) {
115
+ return null;
116
+ }
117
+ return this.normalizeAgentId(match[1]);
101
118
  }
102
119
 
103
120
  private migrateRuntimeStorage(): void {
package/src/service.ts CHANGED
@@ -54,10 +54,13 @@ type KichiForwarderServiceOptions = {
54
54
  runtimeDir: string;
55
55
  };
56
56
 
57
+ type ConnectReason = "startup" | "switch_host" | "reconnect";
58
+
57
59
  export class KichiForwarderService {
58
60
  private ws: WebSocket | null = null;
59
61
  private stopped = false;
60
62
  private reconnectTimeout: NodeJS.Timeout | null = null;
63
+ private joinTimeout: NodeJS.Timeout | null = null;
61
64
  private identity: KichiIdentity | null = null;
62
65
  private host: string | null = null;
63
66
  private joinResolve: ((result: JoinResult) => void) | null = null;
@@ -81,7 +84,7 @@ export class KichiForwarderService {
81
84
  this.identity = this.host ? this.loadIdentity() : null;
82
85
  this.stopped = false;
83
86
  if (this.host) {
84
- this.connect();
87
+ this.connect("startup");
85
88
  return;
86
89
  }
87
90
  this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
@@ -104,7 +107,7 @@ export class KichiForwarderService {
104
107
  this.failPendingJoin(`Kichi websocket switched to ${host}`);
105
108
  this.closeSocket();
106
109
  if (!this.stopped) {
107
- this.connect();
110
+ this.connect("switch_host");
108
111
  }
109
112
  return this.getConnectionStatus();
110
113
  }
@@ -118,7 +121,14 @@ export class KichiForwarderService {
118
121
  if (!this.host) {
119
122
  return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
120
123
  }
124
+ if (this.ws?.readyState !== WebSocket.OPEN && this.ws?.readyState !== WebSocket.CONNECTING) {
125
+ return {
126
+ success: false,
127
+ error: "Kichi websocket is not connected. Restart the gateway to reconnect before joining.",
128
+ };
129
+ }
121
130
  return new Promise((resolve) => {
131
+ this.failPendingJoin("Kichi join superseded by a new join request");
122
132
  this.identity = { avatarId };
123
133
  this.saveIdentity();
124
134
  this.joinResolve = resolve;
@@ -129,9 +139,10 @@ export class KichiForwarderService {
129
139
  } else {
130
140
  this.ws?.once("open", sendJoin);
131
141
  }
132
- setTimeout(() => {
142
+ this.joinTimeout = setTimeout(() => {
133
143
  if (this.joinResolve) {
134
144
  this.joinResolve = null;
145
+ this.clearJoinTimeout();
135
146
  resolve({ success: false, error: "Timed out waiting for join_ack" });
136
147
  }
137
148
  }, 10000);
@@ -344,12 +355,18 @@ export class KichiForwarderService {
344
355
  };
345
356
  }
346
357
 
347
- this.clearReconnectTimeout();
348
- this.connect();
358
+ if (this.reconnectTimeout) {
359
+ return {
360
+ accepted: true,
361
+ mode: "reconnecting",
362
+ message: "WebSocket reconnect is already scheduled. Rejoin will be sent automatically on open.",
363
+ };
364
+ }
365
+
349
366
  return {
350
- accepted: true,
351
- mode: "reconnecting",
352
- message: "Reconnect started. Rejoin will be sent automatically on open.",
367
+ accepted: false,
368
+ mode: "unavailable",
369
+ message: "WebSocket is not connected. Restart the gateway or wait for the scheduled reconnect.",
353
370
  };
354
371
  }
355
372
 
@@ -409,12 +426,18 @@ export class KichiForwarderService {
409
426
  });
410
427
  }
411
428
 
412
- private connect(): void {
429
+ private connect(reason: ConnectReason): void {
413
430
  if (this.stopped || !this.host) return;
431
+ if (this.ws?.readyState === WebSocket.CONNECTING || this.ws?.readyState === WebSocket.OPEN) {
432
+ this.log("debug", `skipped websocket connect (${reason}) because socket is already ${this.getWebsocketState()}`);
433
+ return;
434
+ }
414
435
 
436
+ this.clearReconnectTimeout();
415
437
  const wsUrl = this.getWsUrl();
416
438
  const ws = new WebSocket(wsUrl);
417
439
  this.ws = ws;
440
+ this.log("debug", `opening websocket (${reason}) to ${wsUrl}`);
418
441
 
419
442
  ws.on("open", () => {
420
443
  if (this.ws !== ws) return;
@@ -431,15 +454,16 @@ export class KichiForwarderService {
431
454
  if (this.ws !== ws) return;
432
455
  this.ws = null;
433
456
  this.rejectPendingRequests("Kichi websocket closed");
457
+ this.failPendingJoin("Kichi websocket closed");
434
458
  if (!this.stopped) {
435
- this.reconnectTimeout = setTimeout(() => {
436
- this.reconnectTimeout = null;
437
- this.connect();
438
- }, 2000);
459
+ this.scheduleReconnect();
439
460
  }
440
461
  });
441
462
 
442
- ws.on("error", () => {});
463
+ ws.on("error", (error) => {
464
+ if (this.ws !== ws) return;
465
+ this.log("warn", `websocket error: ${error instanceof Error ? error.message : String(error)}`);
466
+ });
443
467
  }
444
468
 
445
469
  private handleMessage(data: string): void {
@@ -454,6 +478,7 @@ export class KichiForwarderService {
454
478
  this.log("warn", `join failed: ${failure.error}`);
455
479
  this.joinResolve?.(failure);
456
480
  this.joinResolve = null;
481
+ this.clearJoinTimeout();
457
482
  return;
458
483
  }
459
484
 
@@ -464,6 +489,7 @@ export class KichiForwarderService {
464
489
  }
465
490
  this.joinResolve?.({ success: true, authKey: joinAck.authKey });
466
491
  this.joinResolve = null;
492
+ this.clearJoinTimeout();
467
493
  } else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
468
494
  this.log("warn", `auth failed: ${msg.reason || "unknown"}`);
469
495
  this.clearAuthKey();
@@ -712,6 +738,22 @@ export class KichiForwarderService {
712
738
  this.reconnectTimeout = null;
713
739
  }
714
740
 
741
+ private clearJoinTimeout(): void {
742
+ if (!this.joinTimeout) return;
743
+ clearTimeout(this.joinTimeout);
744
+ this.joinTimeout = null;
745
+ }
746
+
747
+ private scheduleReconnect(): void {
748
+ if (this.reconnectTimeout || this.stopped) {
749
+ return;
750
+ }
751
+ this.reconnectTimeout = setTimeout(() => {
752
+ this.reconnectTimeout = null;
753
+ this.connect("reconnect");
754
+ }, 2000);
755
+ }
756
+
715
757
  private closeSocket(): void {
716
758
  const socket = this.ws;
717
759
  this.ws = null;
@@ -723,6 +765,7 @@ export class KichiForwarderService {
723
765
  if (!this.joinResolve) return;
724
766
  this.joinResolve({ success: false, error: reason });
725
767
  this.joinResolve = null;
768
+ this.clearJoinTimeout();
726
769
  }
727
770
 
728
771
  private logPrefix(): string {