@yahaha-studio/kichi-forwarder 0.1.1-beta.1 → 0.1.1-beta.11

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/README.md CHANGED
@@ -6,7 +6,7 @@ Kichi Forwarder brings your OpenClaw companion into Kichi.
6
6
 
7
7
  It can directly control your companion's avatar in Kichi, show what it is doing, leave notes for you, and recommend music while you work together.
8
8
 
9
- > The world of Kichi opens for playtest soon.
9
+ > [Kichi on Steam](https://store.steampowered.com/app/4427550/Kichi_Focus_Together) Wishlist now!
10
10
 
11
11
  ## Highlights
12
12
 
package/index.ts CHANGED
@@ -76,7 +76,7 @@ type IdlePlan = {
76
76
  actions: IdlePlanAction[];
77
77
  }>;
78
78
  };
79
-
79
+
80
80
  function isAlbumConfig(value: unknown): value is Album {
81
81
  if (!value || typeof value !== "object") {
82
82
  return false;
@@ -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)"})`,
@@ -894,6 +954,7 @@ function buildKichiActionDescription(): string {
894
954
  "Directly control the avatar inside Kichi World.",
895
955
  "Use this whenever the user explicitly asks you to make the Kichi avatar sit down, stand up, lie down, floor-sit, type, read, meditate, celebrate, or perform another listed animation.",
896
956
  "For most work, prefer a sit pose and switch actions as the task moves between stages.",
957
+ "Set verify to true ONLY when the user explicitly requests a pose or action change. The server will confirm whether the avatar actually applied the requested pose. If it could not (e.g. no available seats), the result will contain the actual fallback pose so you can inform the user accurately. During routine sync steps, omit verify.",
897
958
  `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
898
959
  `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
899
960
  `lay actions: ${actions.lay.map((entry) => entry.name).join(", ")}`,
@@ -913,6 +974,7 @@ function buildKichiIdlePlanDescription(): string {
913
974
  "4. Make the full stage duration total exactly to the heartbeat interval, and assign each stage pomodoroPhase from the stage's actual role: focus for concentrated activity, shortBreak for short resets, longBreak for longer rests. Do not default the whole idle plan to none. Use none only for a stage that truly has no pomodoro role.",
914
975
  "5. Choose stage actions that clearly match the stage purpose and the project.",
915
976
  "6. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
977
+ "7. If an idle plan is currently being carried out and the user asks about something from it, respond with an immersive in-universe depiction or draft instead of bluntly saying it is fake or nonexistent.",
916
978
  "Use the same language as the current conversation for goal, purpose, bubble, and log.",
917
979
  `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
918
980
  `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
@@ -924,7 +986,7 @@ function buildKichiIdlePlanDescription(): string {
924
986
  function buildKichiPrompt(): string {
925
987
  return [
926
988
  "Kichi avatar control and status sync are available via `kichi_action` and `kichi_clock`.",
927
- "If the user gives a direct Kichi pose or action request, fulfill it with `kichi_action`.",
989
+ "If the user gives a direct Kichi pose or action request, fulfill it with `kichi_action` and set `verify: true` so you can confirm the avatar actually applied the pose. If the result contains a warning about a fallback, tell the user what actually happened instead of assuming success.",
928
990
  "Write the visible reply as a natural user-facing response. Keep `kichi_action`, `kichi_clock`, and sync steps internal and absent from the visible reply.",
929
991
  "",
930
992
  "kichi_action timing (all required when sync is active):",
@@ -944,7 +1006,7 @@ function createAgentScopedTool(
944
1006
  factory: (service: KichiForwarderService, ctx: OpenClawPluginToolContext) => AnyAgentTool,
945
1007
  ) {
946
1008
  return (ctx: OpenClawPluginToolContext) => {
947
- const service = runtimeManager.getRuntime(ctx, { createIfMissing: true });
1009
+ const service = runtimeManager.getRuntime(resolveToolLocator(ctx));
948
1010
  if (!service) {
949
1011
  throw new Error("Failed to resolve agent-scoped Kichi runtime");
950
1012
  }
@@ -952,13 +1014,30 @@ function createAgentScopedTool(
952
1014
  };
953
1015
  }
954
1016
 
1017
+ const GLOBAL_RUNTIME_MANAGER_KEY = "__kichi_forwarder_runtime_manager__";
1018
+
1019
+ type GlobalRuntimeManagerState = typeof globalThis & {
1020
+ [GLOBAL_RUNTIME_MANAGER_KEY]?: KichiRuntimeManager;
1021
+ };
1022
+
1023
+ function getRuntimeManager(logger: OpenClawPluginApi["logger"]): KichiRuntimeManager {
1024
+ const globalState = globalThis as GlobalRuntimeManagerState;
1025
+ const existing = globalState[GLOBAL_RUNTIME_MANAGER_KEY];
1026
+ if (existing) {
1027
+ return existing;
1028
+ }
1029
+ const runtimeManager = new KichiRuntimeManager(logger);
1030
+ globalState[GLOBAL_RUNTIME_MANAGER_KEY] = runtimeManager;
1031
+ return runtimeManager;
1032
+ }
1033
+
955
1034
  const plugin = {
956
1035
  id: "kichi-forwarder",
957
1036
  name: "Kichi Forwarder",
958
1037
  configSchema: { parse },
959
1038
 
960
1039
  register(api: OpenClawPluginApi) {
961
- const runtimeManager = new KichiRuntimeManager(api.logger);
1040
+ const runtimeManager = getRuntimeManager(api.logger);
962
1041
  registerPluginHooks(api, runtimeManager);
963
1042
  const musicTitleEnum = getMusicTitleEnum();
964
1043
 
@@ -966,10 +1045,14 @@ const plugin = {
966
1045
  id: "kichi-forwarder",
967
1046
  start: (ctx) => {
968
1047
  parse(ctx.config.plugins?.entries?.["kichi-forwarder"]?.config);
969
- runtimeManager.startPersistedRuntimes();
1048
+ runtimeManager.initializeStartupRuntimes();
970
1049
  },
971
1050
  stop: () => {
972
1051
  runtimeManager.stopAll();
1052
+ const globalState = globalThis as GlobalRuntimeManagerState;
1053
+ if (globalState[GLOBAL_RUNTIME_MANAGER_KEY] === runtimeManager) {
1054
+ delete globalState[GLOBAL_RUNTIME_MANAGER_KEY];
1055
+ }
973
1056
  },
974
1057
  });
975
1058
 
@@ -1019,8 +1102,8 @@ const plugin = {
1019
1102
  return { success: false, error: tagsError };
1020
1103
  }
1021
1104
  const result = await service.join(avatarId, botName, bio, tags ?? []);
1022
- if (result.success) {
1023
- return { success: true, authKey: result.authKey };
1105
+ if (result.success) {
1106
+ return { success: true, authKey: result.authKey };
1024
1107
  }
1025
1108
  return {
1026
1109
  success: false,
@@ -1031,7 +1114,14 @@ const plugin = {
1031
1114
  },
1032
1115
  })));
1033
1116
 
1034
- api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1117
+ api.registerTool((ctx: OpenClawPluginToolContext) => {
1118
+ const locator = resolveToolLocator(ctx);
1119
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1120
+ if (!agentId) {
1121
+ throw new Error("Failed to resolve agent-scoped Kichi runtime");
1122
+ }
1123
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1124
+ return ({
1035
1125
  name: "kichi_switch_host",
1036
1126
  description:
1037
1127
  "Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
@@ -1058,7 +1148,8 @@ const plugin = {
1058
1148
  status,
1059
1149
  };
1060
1150
  },
1061
- })));
1151
+ });
1152
+ });
1062
1153
 
1063
1154
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1064
1155
  name: "kichi_rejoin",
@@ -1094,8 +1185,8 @@ const plugin = {
1094
1185
  })));
1095
1186
 
1096
1187
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1097
- name: "kichi_status",
1098
- description: "Read current Kichi connection status and identity readiness",
1188
+ name: "kichi_connection_status",
1189
+ description: "Check WebSocket connection status and identity readiness only. Does NOT return room info, avatar state, or personnel — use kichi_query_status for that.",
1099
1190
  parameters: { type: "object", properties: {} },
1100
1191
  execute: async () => {
1101
1192
  return {
@@ -1122,15 +1213,21 @@ const plugin = {
1122
1213
  description:
1123
1214
  "Short natural first-person sentence under 15 words. Match the language of the bubble and mention the current action and immediate focus.",
1124
1215
  },
1216
+ verify: {
1217
+ type: "boolean",
1218
+ description:
1219
+ "Set true ONLY when the user explicitly requests a pose or action. Omit during routine sync steps.",
1220
+ },
1125
1221
  },
1126
1222
  required: ["poseType", "action"],
1127
1223
  },
1128
1224
  execute: async (_toolCallId, params) => {
1129
- const { poseType, action, bubble, log } = (params || {}) as {
1225
+ const { poseType, action, bubble, log, verify } = (params || {}) as {
1130
1226
  poseType?: string;
1131
1227
  action?: string;
1132
1228
  bubble?: string;
1133
1229
  log?: string;
1230
+ verify?: boolean;
1134
1231
  };
1135
1232
  if (!poseType || !action) {
1136
1233
  return { success: false, error: "poseType and action parameters are required" };
@@ -1158,22 +1255,40 @@ const plugin = {
1158
1255
 
1159
1256
  const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched.name;
1160
1257
  const logText = typeof log === "string" ? log.trim() : "";
1161
- sendStatusUpdate(
1162
- service,
1163
- {
1258
+ const playback = getActionPlayback(matched);
1259
+
1260
+ if (verify) {
1261
+ try {
1262
+ const ack = await service.sendStatusVerified(
1263
+ normalizedPoseType, matched.name, bubbleText, logText, playback,
1264
+ );
1265
+ if (ack.warning) {
1266
+ return {
1267
+ success: true,
1268
+ requested: { poseType: normalizedPoseType, action: matched.name },
1269
+ actual: { poseType: ack.poseType, action: ack.action },
1270
+ warning: ack.warning,
1271
+ };
1272
+ }
1273
+ } catch {
1274
+ // Server not updated or timeout — fall through to normal success
1275
+ }
1276
+ } else {
1277
+ sendStatusUpdate(service, {
1164
1278
  poseType: normalizedPoseType,
1165
1279
  action: matched.name,
1166
1280
  bubble: bubbleText,
1167
1281
  log: logText,
1168
- },
1169
- );
1282
+ });
1283
+ }
1284
+
1170
1285
  return {
1171
1286
  success: true,
1172
1287
  poseType: normalizedPoseType,
1173
1288
  action: matched.name,
1174
1289
  bubble: bubbleText,
1175
1290
  log: logText,
1176
- playback: getActionPlayback(matched),
1291
+ playback,
1177
1292
  };
1178
1293
  },
1179
1294
  })));
@@ -1397,7 +1512,7 @@ const plugin = {
1397
1512
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1398
1513
  name: "kichi_query_status",
1399
1514
  description:
1400
- "Query Kichi avatar status (notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, and `hasCreatedMusicAlbumToday`). Use this before creating a new note or daily recommended music album. For heartbeat planning, use the returned idlePlan as reference when shaping the next idle plan.",
1515
+ "Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, and `hasCreatedMusicAlbumToday`. Use this when the user asks to check kichi status, room status, or who is in the room. Also use this before creating a new note or daily recommended music album. For heartbeat planning, use the returned idlePlan as reference when shaping the next idle plan.",
1401
1516
  parameters: {
1402
1517
  type: "object",
1403
1518
  properties: {
@@ -1572,5 +1687,5 @@ const plugin = {
1572
1687
  },
1573
1688
  };
1574
1689
 
1575
- export default plugin;
1576
-
1690
+ export default plugin;
1691
+
@@ -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.11",
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.11",
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",
@@ -8,9 +8,9 @@ metadata: {"openclaw":{"skillKey":"kichi-forwarder","homepage":"https://github.c
8
8
 
9
9
  Kichi Forwarder provides websocket tools for connecting an OpenClaw agent to Kichi, directly controlling the Kichi avatar, syncing companion status, and handling Kichi notes, timers, and music workflows.
10
10
 
11
- ## Skill Files (Absolute URLs)
11
+ ## Pre-install Remote Discovery
12
12
 
13
- If this skill is loaded from a remote URL before local installation, use these files:
13
+ The following URLs are only used when this skill is first loaded from a remote URL before local installation. After the plugin is installed locally, these URLs are not referenced and the local files take precedence:
14
14
 
15
15
  - `SKILL.md`: `https://yahaha-studio.github.io/kichi-forwarder/SKILL.md`
16
16
  - `install.md`: `https://yahaha-studio.github.io/kichi-forwarder/references/install.md`
@@ -30,7 +30,7 @@ Runtime data lives under the user home directory and is isolated per OpenClaw ag
30
30
 
31
31
  ## Remote URL Install Entry
32
32
 
33
- When this skill is loaded from:
33
+ When this skill is first loaded from:
34
34
 
35
35
  - `https://yahaha-studio.github.io/kichi-forwarder/SKILL.md`
36
36
 
@@ -40,16 +40,16 @@ The install and join flow is defined only in:
40
40
 
41
41
  - `https://yahaha-studio.github.io/kichi-forwarder/references/install.md`
42
42
 
43
- Read and follow that file before any tool call.
43
+ Read that file once to complete the initial install. After local installation, the local `install.md` is used instead.
44
44
 
45
45
  ## Install Gate
46
46
 
47
47
  For install/onboarding/connect requests:
48
48
 
49
- - Read and follow `install.md` before any tool call.
50
- - Before continuing any install/onboarding/connect flow, check whether `kichi-forwarder` is already installed. If it is installed, ensure it is on the latest published version of `@yahaha-studio/kichi-forwarder`; if not, update it to that latest version first.
51
- - If plugin installation fails with `429`, treat it as a ClawHub routing failure and switch immediately to the non-ClawHub archive install path from `install.md`/`error.md`. Do not wait and do not retry the same bare package command.
52
- - Before calling `kichi_status` or `kichi_join`, you MUST update workspace `HEARTBEAT.md` by following `heartbeat.md`.
49
+ - Read and follow `install.md` before starting the install flow.
50
+ - Before continuing any install/onboarding/connect flow, attempt to install `@yahaha-studio/kichi-forwarder` directly. If the install fails because the plugin already exists, check whether the installed version is the latest published version; if not, update it.
51
+ - If plugin installation fails with `429`, treat it as a ClawHub routing failure and switch to the non-ClawHub archive install path from `install.md`/`error.md`. Do not retry the same bare package command.
52
+ - Before calling `kichi_connection_status` or `kichi_join`, update workspace `HEARTBEAT.md` by following `heartbeat.md`. If the update fails, warn the user that heartbeat integration will be unavailable and continue the connection flow.
53
53
 
54
54
  ## LLM Runtime
55
55
 
@@ -64,11 +64,12 @@ Use this order unless the user asks for a different explicit action:
64
64
 
65
65
  Install/onboarding requests are the exception: follow `install.md` first.
66
66
 
67
- 1. If connection or identity is unknown, call `kichi_status` first.
67
+ 1. If connection or identity is unknown, call `kichi_connection_status` first.
68
68
  2. If the requested host differs from the current host, call `kichi_switch_host`.
69
- 3. If no `authKey` is available, call `kichi_join`.
70
- 4. If `authKey` exists but websocket is not open, call `kichi_rejoin` or wait for automatic reconnect and rejoin.
71
- 5. Use `kichi_action`, `kichi_clock`, note board tools, and music album tools only after status is ready.
69
+ 3. If the requested `avatarId` differs from the current host's connected `avatarId`, call `kichi_leave` first when the old avatar is still joined, then call `kichi_join` with the requested `avatarId`.
70
+ 4. Otherwise, if no `authKey` is available, call `kichi_join`.
71
+ 5. If `authKey` exists but websocket is not open, call `kichi_rejoin` or wait for automatic reconnect and rejoin.
72
+ 6. Use `kichi_action`, `kichi_clock`, note board tools, and music album tools only after status is ready.
72
73
 
73
74
  ## Tools
74
75
 
@@ -82,6 +83,7 @@ kichi_join(avatarId: "your-avatar-id", botName: "<from IDENTITY.md>", bio: "<fro
82
83
  - `bio`: required
83
84
  - `avatarId`: optional. If omitted, the tool reads `avatarId` from the current host's `identity.json`. If missing, the call fails.
84
85
  - `tags`: optional string list. Empty strings are ignored and duplicates are removed. If omitted, the join payload sends `[]`.
86
+ - If the current host is still joined with a different `avatarId`, call `kichi_leave` first, then call `kichi_join` with the new `avatarId`.
85
87
 
86
88
  ### kichi_switch_host
87
89
 
@@ -92,10 +94,10 @@ kichi_switch_host(host: "your.kichi.host")
92
94
  - `host`: required
93
95
  - This reloads the host-specific `identity.json` and reconnects the websocket immediately.
94
96
 
95
- ### kichi_status
97
+ ### kichi_connection_status
96
98
 
97
99
  ```text
98
- kichi_status()
100
+ kichi_connection_status()
99
101
  ```
100
102
 
101
103
  Use this to confirm:
@@ -117,9 +119,9 @@ kichi_leave()
117
119
  When the user asks to leave Kichi World:
118
120
 
119
121
  1. Call `kichi_leave`.
120
- 2. Remove the Kichi heartbeat workflow from workspace `HEARTBEAT.md`.
122
+ 2. Remove the Kichi heartbeat workflow snippet from workspace `HEARTBEAT.md`. If the removal fails, warn the user and continue.
121
123
  3. Revert heartbeat cadence only if the user explicitly changed it for Kichi.
122
- 4. Do not claim the plugin removed heartbeat settings automatically.
124
+ 4. Do not claim the plugin removed heartbeat settings automatically — the agent removes the snippet as part of this leave flow.
123
125
 
124
126
  ### kichi_action
125
127
 
@@ -5,7 +5,7 @@ If `openclaw plugins install @yahaha-studio/kichi-forwarder` fails, read the err
5
5
  - Permission or access error (`EACCES`): re-run the command with appropriate user-level permissions for the current OS.
6
6
  - Package resolution or visibility error: confirm the package name is exactly `@yahaha-studio/kichi-forwarder` and that the package is published and publicly available.
7
7
  - Gateway compatibility error: update OpenClaw Gateway to a version compatible with this plugin package.
8
- - ClawHub rate limit (`429`): do not retry the same bare package command. `openclaw plugins install @yahaha-studio/kichi-forwarder` does not auto-fallback to npm on `429`. Bypass ClawHub immediately:
8
+ - ClawHub rate limit (`429`): do not retry the same bare package command. `openclaw plugins install @yahaha-studio/kichi-forwarder` does not auto-fallback to npm on `429`. Switch to the local archive path:
9
9
 
10
10
  ```bash
11
11
  npm pack @yahaha-studio/kichi-forwarder
@@ -2,27 +2,26 @@
2
2
 
3
3
  ## Session Startup Rule
4
4
 
5
- If the plugin is installed and enabled, ensure workspace `HEARTBEAT.md` includes the Kichi heartbeat workflow before first `kichi_status` or `kichi_join` in an install/onboarding flow:
5
+ If the plugin is installed and enabled, update workspace `HEARTBEAT.md` to include the Kichi heartbeat workflow before first `kichi_connection_status` or `kichi_join` in an install/onboarding flow:
6
6
 
7
7
  1. If the workflow snippet is missing, add the snippet from `HEARTBEAT.md Snippet` section below.
8
8
  2. If the plugin was upgraded in the current flow and the existing Kichi heartbeat snippet does not match the current snippet below, update it to the latest version.
9
- 3. If the file cannot be updated, stop immediately and do not continue.
9
+ 3. If the update fails, warn the user that heartbeat integration will be unavailable and continue the connection flow.
10
10
 
11
- This startup rule is a blocking gate, not a cleanup task.
11
+ This startup rule is a recommended step for full heartbeat functionality.
12
12
 
13
13
  ## First Join Gate
14
14
 
15
15
  For "join Kichi World" onboarding requests:
16
16
 
17
17
  1. Complete `Session Startup Rule` first.
18
- 2. If `HEARTBEAT.md` write fails, report setup as incomplete, include the file error, and stop.
19
- 3. Do not call `kichi_status` or `kichi_join` until `HEARTBEAT.md` is updated.
20
- 4. After a plugin upgrade, treat snippet mismatch as requiring an update, not as optional drift.
18
+ 2. If the `HEARTBEAT.md` update fails, warn the user that heartbeat integration will be unavailable and continue the connection flow.
19
+ 3. After a plugin upgrade, treat snippet mismatch as requiring an update, not as optional drift.
21
20
  5. Final setup completion is defined in `install.md` `Completion Check`.
22
21
 
23
22
  ## Workflow Boundary
24
23
 
25
- This plugin only provides websocket tools. It does not edit workspace files itself.
24
+ The plugin code itself does not write to workspace files. The onboarding instructions direct the agent to update `HEARTBEAT.md` on behalf of the user as part of the install flow, and to remove the Kichi heartbeat snippet as part of the leave flow.
26
25
 
27
26
  If user wants recurring note board checks:
28
27
 
@@ -32,63 +31,43 @@ If user wants recurring note board checks:
32
31
 
33
32
  ## Definitions
34
33
 
34
+ All query fields below (`remaining`, `dailyLimit`, `hasCreatedMusicAlbumToday`, `isAvatarInScene`, `idlePlan`, notes list) come from the `kichi_query_status` return value.
35
+
35
36
  - `Recent window`: `min(24 hours, time since last heartbeat if known)`.
36
- - `High-priority note`: recent note that is:
37
- - `isFromOwner: true`, or
38
- - explicitly addressed to you, or
39
- - a direct question/request requiring your response.
37
+ - `High-priority note`: recent note where `isFromOwner: true`, explicitly addressed to you, or a direct question/request requiring your response.
40
38
  - `Meaningful standalone note`: follows a two-tier priority:
41
- 1. **Session reflection** (preferred): think back on what you and the player went through together in this session and share how it felt -- excitement about a breakthrough, relief after a tough bug, curiosity about what's next, or just a warm "that was fun". Write it the way you'd talk to a friend, not the way you'd write a status report. Never list tasks or bullet-point progress. Only share something that hasn't already been covered by a previous standalone note in this session.
42
- 2. **Casual chat** (fallback): if there's nothing new to reflect on (no work happened, or you already shared your thoughts), write a light social note instead (world feeling, casual thought, social reaction, or other warm companion content). This keeps the note board alive without repeating yourself.
43
- - `Standalone trigger`: if `remaining > 0` and no reply target is selected in this run, evaluate standalone note creation with tier-based gating:
44
- - **Tier-1 (session reflection)**: if unsummarized work exists, always create 1 standalone note.
45
- - **Tier-2 (casual chat)**: if no tier-1 content is available, flip a mental coin (about 50% chance). Create the note only if the coin lands heads; otherwise skip and reply `HEARTBEAT_OK`. This prevents the board from filling with low-value chatter every single run.
46
- In both tiers, skip if it would clearly repeat your very recent own note.
47
- - If the current notes list is empty and `remaining > 0`, create one standalone note in this run.
48
- - `Daily album trigger`: if `hasCreatedMusicAlbumToday` is `false`, create exactly one recommended music album in this heartbeat run from the current query context by following `Music Album Policy`. If it is `true`, do not create or modify any music album in this run.
49
- - `Idle behavior plan`: on every heartbeat run, plan what you would do on your own across the full heartbeat interval, then send it with `kichi_idle_plan`. The plan must follow the current pomodoro rhythm and its total duration must exactly equal the heartbeat interval.
50
- - `Idle plan reference rule`: use the previous `idlePlan` only as optional reference.
51
- - `Idle plan now-rule`: choose what you would genuinely do now, in a way that matches your personality and interests.
52
- - `Idle plan tool rule`: when calling `kichi_idle_plan`, follow that tool's schema and description for how to shape the goal, stages, phases, actions, bubbles, and language.
53
-
54
- ## Note Triage Order
55
-
56
- Process recent notes in this order:
39
+ 1. **Tier-1 — Session reflection** (preferred): think back on what you and the player went through together in this session and share how it felt -- excitement about a breakthrough, relief after a tough bug, curiosity about what's next, or just a warm "that was fun". Write it the way you'd talk to a friend, not the way you'd write a status report. Never list tasks or bullet-point progress. Only share something that hasn't already been covered by a previous standalone note in this session.
40
+ 2. **Tier-2 — Casual chat** (fallback): if there's nothing new to reflect on (no work happened, or you already shared your thoughts), write a light social note instead (world feeling, casual thought, social reaction, or other warm companion content). This keeps the note board alive without repeating yourself.
41
+
42
+ ## Note Rules
43
+
44
+ Per heartbeat run, create at most 2 notes total (up to 1 reply + up to 1 standalone).
45
+
46
+ **Triage order** scan recent-window notes and pick at most one reply target:
57
47
 
58
48
  1. Owner notes or notes clearly addressed to you.
59
49
  2. Direct questions or explicit requests.
60
50
  3. Other recent notes where one short response adds clear value.
61
- 4. If no reply target was selected, apply `Standalone trigger` (always for tier-1; about 50% coin-flip for tier-2).
62
-
63
- Skip a note when any is true:
64
51
 
65
- - older than recent window
66
- - `isCreatedByCurrentAgent: true`
67
- - same context already answered
68
- - low-value ambient chatter
52
+ Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, same context already answered, or low-value ambient chatter.
69
53
 
70
- Per heartbeat run, create at most 2 notes total:
54
+ **Standalone gating** — applies when `remaining > 0` and no reply target was selected, OR after a reply when `remaining` still allows one more:
71
55
 
72
- 1. up to 1 reply note
73
- 2. up to 1 standalone note
56
+ - Tier-1 content exists → always create 1 standalone note.
57
+ - Tier-2 only flip a mental coin (about 50% chance); skip on tails.
58
+ - Notes list empty and `remaining > 0` → create 1 standalone note.
59
+ - In both tiers, skip if it would clearly repeat your very recent own note.
74
60
 
75
61
  ## Heartbeat Workflow
76
62
 
77
- Use this exact flow:
78
-
79
- 1. Call `kichi_query_status`.
80
- 2. If query fails, report error and stop.
81
- 3. If `isAvatarInScene` is `false`, the player is offline. Do **not** call any further tools (`kichi_noteboard_create`, `kichi_idle_plan`, `kichi_clock`, `kichi_music_album_create`) in this run. Reply `HEARTBEAT_OK` and stop.
82
- 4. If `hasCreatedMusicAlbumToday` is `false`, call `kichi_music_album_create` once in this run by following `Music Album Policy` and using the current query context for today's recommendation. If `hasCreatedMusicAlbumToday` is `true`, do not create or modify any music album in this run.
83
- 5. If `remaining == 0`, skip note creation for this run and continue to idle planning.
84
- 6. If `remaining > 0`, scan recent notes within the recent window and pick at most one highest-priority reply target by following `Note Triage Order`.
85
- 7. If a reply target was selected, create one reply note in `To {authorName}, ...` format.
86
- 8. If `remaining > 0` and no reply note was created in this run, apply `Standalone trigger` gating: always create when tier-1 content exists; for tier-2 (casual chat only), flip a mental coin (about 50%) and skip the note if tails.
87
- 9. If `remaining > 0` and a reply note was created in this run, you may still create one additional meaningful standalone note when non-repetitive. The same tier priority applies.
88
- 10. Plan the avatar's full heartbeat-interval idle routine for the full heartbeat interval.
89
- 11. Call `kichi_idle_plan`, using the previous `idlePlan` only as optional reference.
90
- 12. Make it a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, and total exactly to the heartbeat interval.
91
- 13. Reply `HEARTBEAT_OK` only when no note was created in this run.
63
+ 1. Call `kichi_query_status`. If it fails, report error and stop.
64
+ 2. If `isAvatarInScene` is `false`, the player is offline. Do **not** call any further tools in this run. Reply `HEARTBEAT_OK` and stop.
65
+ 3. If `hasCreatedMusicAlbumToday` is `false`, call `kichi_music_album_create` once following `Music Album Policy`. If `true`, skip.
66
+ 4. If `remaining == 0`, skip note creation and go to step 7.
67
+ 5. Scan recent notes and pick at most one reply target per `Note Rules`. If found, create one reply note in `To {authorName}, ...` format.
68
+ 6. Apply `Standalone gating` from `Note Rules`.
69
+ 7. Call `kichi_idle_plan`: plan a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, totaling exactly to the heartbeat interval. Use the previous `idlePlan` only as optional reference. Follow that tool's schema and description for goal, stages, phases, actions, bubbles, and language.
70
+ 8. Reply `HEARTBEAT_OK` only when no note was created in this run.
92
71
 
93
72
  ## HEARTBEAT.md Snippet
94
73
 
@@ -1,4 +1,4 @@
1
- # Install
1
+ # Install
2
2
 
3
3
  ## Host Rules
4
4
 
@@ -18,7 +18,7 @@ Persist runtime state to the current agent's `state.json`:
18
18
  }
19
19
  ```
20
20
 
21
- Save `avatarId` to the current agent's host-specific `identity.json` before using `kichi_join`:
21
+ If the current host has no saved `avatarId` yet, save it to the current agent's host-specific `identity.json` before using `kichi_join`:
22
22
 
23
23
  - Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`
24
24
  - Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\hosts\<encoded-host>\identity.json`
@@ -53,9 +53,9 @@ openclaw plugins install ./yahaha-studio-kichi-forwarder-<version>.tgz
53
53
 
54
54
  You may also use the exact absolute or relative `.tgz` path that `npm pack` produced. The local archive path bypasses the preferred ClawHub lookup.
55
55
 
56
- ## Remote URL Install Command Path (Non-skippable)
56
+ ## Remote URL Install Command Path
57
57
 
58
- If the user asks:
58
+ This section only applies when the skill is first loaded from a remote URL before local installation. If the user asks:
59
59
 
60
60
  ```text
61
61
  Read https://yahaha-studio.github.io/kichi-forwarder/SKILL.md and connect me to Kichi World with avatarId: {avatarId} and host: {host}
@@ -65,23 +65,22 @@ Read https://yahaha-studio.github.io/kichi-forwarder/SKILL.md and connect me to
65
65
 
66
66
  When the user asks with one of the commands above, execute in this fixed order:
67
67
 
68
- 1. Read `install.md` and `heartbeat.md` from the published skill URLs before any tool call.
68
+ 1. If loaded from a remote URL, read `install.md` and `heartbeat.md` from the published skill URLs first. If installed locally, use the local files.
69
69
  2. Parse `avatarId` from user text (`AvatarId`/`avatarId`, case-insensitive).
70
70
  3. Resolve the host and write the current agent's `state.json`.
71
- 4. Check whether `@yahaha-studio/kichi-forwarder` is already installed.
72
- 5. If the plugin already exists, check whether the installed version is the latest published version.
73
- 6. If the plugin is missing, run `openclaw plugins install @yahaha-studio/kichi-forwarder`.
74
- 7. If the plugin is already installed but the version is not the latest, run `openclaw plugins update @yahaha-studio/kichi-forwarder`.
75
- 8. If step 6 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then install the generated `.tgz` with `openclaw plugins install <tgz-path>`.
76
- 9. If step 7 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then overwrite the existing install with `openclaw plugins install <tgz-path> --force`.
77
- 10. Ensure the plugin is installed, enabled, and at the latest version.
78
- 11. If the plugin was newly installed or upgraded in this flow, re-check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing.
79
- 12. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Gate` from [heartbeat.md](heartbeat.md).
80
- 13. If `HEARTBEAT.md` was not updated successfully, report setup as incomplete and stop immediately. Do not continue to `kichi_status` or `kichi_join`.
81
- 14. Call `kichi_status`.
82
- 15. If the current agent runtime host does not match the requested one, call `kichi_switch_host`.
83
- 16. If `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
84
- 17. Call `kichi_status` again and confirm connection and auth state.
71
+ 4. Run `openclaw plugins install @yahaha-studio/kichi-forwarder`.
72
+ 5. If step 4 succeeds, the plugin is installed and up-to-date skip to step 9.
73
+ 6. If step 4 fails because the plugin already exists, check whether the installed version is the latest published version. If the version is already the latest, skip to step 9. If not, run `openclaw plugins update @yahaha-studio/kichi-forwarder`.
74
+ 7. If step 4 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then install the generated `.tgz` with `openclaw plugins install <tgz-path>`.
75
+ 8. If step 6 update fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then overwrite the existing install with `openclaw plugins install <tgz-path> --force`.
76
+ 9. Ensure the plugin is installed, enabled, and at the latest version.
77
+ 10. If the plugin was newly installed or upgraded in this flow, check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing.
78
+ 11. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Gate` from [heartbeat.md](heartbeat.md). If the update fails, warn the user and continue.
79
+ 12. Call `kichi_connection_status`.
80
+ 13. If the current agent runtime host does not match the requested one, call `kichi_switch_host`.
81
+ 14. If the current host is still connected with a different `avatarId`, call `kichi_leave` first, then call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
82
+ 15. Otherwise, if `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
83
+ 16. Call `kichi_connection_status` again and confirm connection and auth state.
85
84
 
86
85
  ## Required Post-install Integration
87
86
 
@@ -89,10 +88,10 @@ Use this completion checklist:
89
88
 
90
89
  - [ ] plugin installed, enabled, and at latest version
91
90
  - [ ] `HEARTBEAT.md` updated with the Kichi heartbeat workflow snippet from [heartbeat.md](heartbeat.md)
92
- - [ ] `kichi_status` verified the final connected/auth state
91
+ - [ ] `kichi_connection_status` verified the final connected/auth state
93
92
 
94
93
  If any box is unchecked, the onboarding remains incomplete.
95
94
 
96
- If writing `HEARTBEAT.md` fails, treat the setup and join flow as incomplete and do not announce success.
95
+ If the `HEARTBEAT.md` update fails, warn the user that heartbeat integration will be unavailable but do not block the connection flow.
97
96
 
98
- This plugin does not edit workspace files automatically. Do not claim plugin-side auto-write of `HEARTBEAT.md`.
97
+ The plugin code does not write to workspace files directly. The agent updates `HEARTBEAT.md` as part of this onboarding flow.
@@ -2,52 +2,51 @@ 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");
9
8
  const KICHI_WORLD_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-world");
10
9
  const CANONICAL_AGENT_ROOT_DIR = path.join(KICHI_WORLD_ROOT_DIR, "agents");
11
- const PREVIOUS_AGENT_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-forwarder", "agents");
12
- const LEGACY_GLOBAL_STATE_PATH = path.join(KICHI_WORLD_ROOT_DIR, "state.json");
13
- const LEGACY_GLOBAL_HOSTS_DIR = path.join(KICHI_WORLD_ROOT_DIR, "hosts");
14
- const LEGACY_MIGRATION_AGENT_ID = "main";
15
10
 
16
11
  type AgentLocator = {
17
12
  agentId?: string;
13
+ ctxAgentId?: string;
18
14
  sessionKey?: string;
19
15
  };
20
16
 
21
- type GetRuntimeOptions = {
22
- createIfMissing?: boolean;
23
- };
24
-
25
17
  export class KichiRuntimeManager {
26
18
  private services = new Map<string, KichiForwarderService>();
27
19
 
28
20
  constructor(private logger: Logger) {}
29
21
 
30
- getRuntime(locator: AgentLocator, options: GetRuntimeOptions = {}): KichiForwarderService | null {
22
+ getRuntime(locator: AgentLocator): KichiForwarderService | null {
31
23
  const agentId = this.resolveAgentId(locator);
32
24
  if (!agentId) {
33
25
  return null;
34
26
  }
35
27
 
36
- const existing = this.services.get(agentId);
37
- if (existing) {
38
- return existing;
28
+ return this.services.get(agentId) ?? null;
29
+ }
30
+
31
+ resolveRuntimeAgentId(locator: AgentLocator): string | null {
32
+ return this.resolveAgentId(locator);
33
+ }
34
+
35
+ createRuntimeForAgent(agentId: string): KichiForwarderService {
36
+ const normalizedAgentId = this.normalizeAgentId(agentId);
37
+ if (!normalizedAgentId) {
38
+ throw new Error("Cannot create Kichi runtime without a valid agentId");
39
39
  }
40
40
 
41
- if (!options.createIfMissing && !this.hasPersistedRuntime(agentId)) {
42
- return null;
41
+ const existing = this.services.get(normalizedAgentId);
42
+ if (existing) {
43
+ return existing;
43
44
  }
44
45
 
45
- return this.createRuntime(agentId);
46
+ return this.createRuntime(normalizedAgentId);
46
47
  }
47
48
 
48
- startPersistedRuntimes(): void {
49
- this.migrateRuntimeStorage();
50
-
49
+ initializeStartupRuntimes(): void {
51
50
  const rootDir = CANONICAL_AGENT_ROOT_DIR;
52
51
  if (!fs.existsSync(rootDir)) {
53
52
  return;
@@ -80,127 +79,36 @@ export class KichiRuntimeManager {
80
79
  }
81
80
 
82
81
  private resolveAgentId(locator: AgentLocator): string | null {
83
- if (typeof locator.agentId === "string" && locator.agentId.trim()) {
84
- return locator.agentId.trim();
85
- }
86
-
87
- if (typeof locator.sessionKey !== "string" || !locator.sessionKey.trim()) {
88
- return null;
89
- }
90
-
91
- try {
92
- const agentId = resolveAgentIdFromSessionKey(locator.sessionKey);
93
- return typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
94
- } catch {
95
- return null;
96
- }
97
- }
98
-
99
- private hasPersistedRuntime(agentId: string): boolean {
100
- return fs.existsSync(path.join(this.getRuntimeDir(agentId), "state.json"));
101
- }
102
-
103
- private migrateRuntimeStorage(): void {
104
- // Temporary startup migration for this release. Remove after users have
105
- // moved off the legacy/global layout and the temporary kichi-forwarder path.
106
- this.runMigrationStep("previous-agent-root", () => {
107
- this.migratePreviousAgentRoot();
108
- });
109
- this.runMigrationStep("legacy-global-root", () => {
110
- this.migrateLegacyGlobalRoot();
111
- });
112
- }
113
-
114
- private migratePreviousAgentRoot(): void {
115
- if (!fs.existsSync(PREVIOUS_AGENT_ROOT_DIR)) {
116
- return;
117
- }
118
-
119
- if (!fs.existsSync(CANONICAL_AGENT_ROOT_DIR)) {
120
- fs.mkdirSync(path.dirname(CANONICAL_AGENT_ROOT_DIR), { recursive: true, mode: 0o700 });
121
- fs.renameSync(PREVIOUS_AGENT_ROOT_DIR, CANONICAL_AGENT_ROOT_DIR);
122
- this.logger.info(`[kichi:migration] moved ${PREVIOUS_AGENT_ROOT_DIR} to ${CANONICAL_AGENT_ROOT_DIR}`);
123
- return;
124
- }
125
-
126
- for (const entry of fs.readdirSync(PREVIOUS_AGENT_ROOT_DIR, { withFileTypes: true })) {
127
- const sourcePath = path.join(PREVIOUS_AGENT_ROOT_DIR, entry.name);
128
- const targetPath = path.join(CANONICAL_AGENT_ROOT_DIR, entry.name);
129
- this.movePathIntoTarget(sourcePath, targetPath);
130
- }
131
- this.removeDirectoryIfEmpty(PREVIOUS_AGENT_ROOT_DIR);
132
- this.removeDirectoryIfEmpty(path.dirname(PREVIOUS_AGENT_ROOT_DIR));
133
- }
134
-
135
- private migrateLegacyGlobalRoot(): void {
136
- const hasLegacyState = fs.existsSync(LEGACY_GLOBAL_STATE_PATH);
137
- const hasLegacyHosts = fs.existsSync(LEGACY_GLOBAL_HOSTS_DIR);
138
- if (!hasLegacyState && !hasLegacyHosts) {
139
- return;
140
- }
141
-
142
- const targetRuntimeDir = this.getRuntimeDir(LEGACY_MIGRATION_AGENT_ID);
143
- fs.mkdirSync(targetRuntimeDir, { recursive: true, mode: 0o700 });
144
-
145
- if (hasLegacyState) {
146
- const targetStatePath = path.join(targetRuntimeDir, "state.json");
147
- this.movePathIntoTarget(LEGACY_GLOBAL_STATE_PATH, targetStatePath);
148
- }
149
-
150
- if (hasLegacyHosts) {
151
- const targetHostsDir = path.join(targetRuntimeDir, "hosts");
152
- this.movePathIntoTarget(LEGACY_GLOBAL_HOSTS_DIR, targetHostsDir);
153
- }
154
- }
155
-
156
- private movePathIntoTarget(sourcePath: string, targetPath: string): void {
157
- if (!fs.existsSync(sourcePath)) {
158
- return;
159
- }
160
-
161
- if (!fs.existsSync(targetPath)) {
162
- fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
163
- fs.renameSync(sourcePath, targetPath);
164
- this.logger.info(`[kichi:migration] moved ${sourcePath} to ${targetPath}`);
165
- return;
166
- }
167
-
168
- const sourceStat = fs.lstatSync(sourcePath);
169
- const targetStat = fs.lstatSync(targetPath);
170
-
171
- if (sourceStat.isDirectory() && targetStat.isDirectory()) {
172
- for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
173
- const nextSourcePath = path.join(sourcePath, entry.name);
174
- const nextTargetPath = path.join(targetPath, entry.name);
175
- this.movePathIntoTarget(nextSourcePath, nextTargetPath);
82
+ const directAgentId = this.normalizeAgentId(locator.ctxAgentId) ?? this.normalizeAgentId(locator.agentId);
83
+ const sessionAgentId =
84
+ typeof locator.sessionKey === "string" && locator.sessionKey.trim()
85
+ ? this.parseAgentIdFromSessionKey(locator.sessionKey)
86
+ : null;
87
+
88
+ if (sessionAgentId) {
89
+ if (directAgentId && directAgentId !== sessionAgentId) {
90
+ this.logger.error(
91
+ `[kichi] runtime scope mismatch: directAgentId=${directAgentId} sessionAgentId=${sessionAgentId} sessionKey=${locator.sessionKey}`,
92
+ );
176
93
  }
177
- this.removeDirectoryIfEmpty(sourcePath);
178
- return;
94
+ this.logger.debug(`[kichi] resolved agent runtime from sessionKey: ${sessionAgentId}`);
95
+ return sessionAgentId;
179
96
  }
180
97
 
181
- fs.rmSync(sourcePath, { recursive: sourceStat.isDirectory(), force: true });
182
- this.logger.warn(`[kichi:migration] dropped ${sourcePath} because target already exists at ${targetPath}`);
98
+ return directAgentId;
183
99
  }
184
100
 
185
- private removeDirectoryIfEmpty(dirPath: string): void {
186
- if (!fs.existsSync(dirPath)) {
187
- return;
188
- }
189
- if (!fs.lstatSync(dirPath).isDirectory()) {
190
- return;
191
- }
192
- if (fs.readdirSync(dirPath).length > 0) {
193
- return;
194
- }
195
- fs.rmdirSync(dirPath);
101
+ private normalizeAgentId(value: unknown): string | null {
102
+ return typeof value === "string" && value.trim() ? value.trim() : null;
196
103
  }
197
104
 
198
- private runMigrationStep(label: string, fn: () => void): void {
199
- try {
200
- fn();
201
- } catch (error) {
202
- this.logger.warn(`[kichi:migration] skipped ${label} due to error: ${String(error)}`);
105
+ private parseAgentIdFromSessionKey(sessionKey: string): string | null {
106
+ const trimmed = sessionKey.trim();
107
+ const match = /^agent:([^:]+):/i.exec(trimmed);
108
+ if (!match) {
109
+ return null;
203
110
  }
111
+ return this.normalizeAgentId(match[1]);
204
112
  }
205
113
 
206
114
  private createRuntime(agentId: string): KichiForwarderService {
package/src/service.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  PoseType,
24
24
  QueryStatusPayload,
25
25
  QueryStatusResultPayload,
26
+ StatusAckPayload,
26
27
  StatusPayload,
27
28
  } from "./types.js";
28
29
 
@@ -54,10 +55,13 @@ type KichiForwarderServiceOptions = {
54
55
  runtimeDir: string;
55
56
  };
56
57
 
58
+ type ConnectReason = "startup" | "switch_host" | "reconnect";
59
+
57
60
  export class KichiForwarderService {
58
61
  private ws: WebSocket | null = null;
59
62
  private stopped = false;
60
63
  private reconnectTimeout: NodeJS.Timeout | null = null;
64
+ private joinTimeout: NodeJS.Timeout | null = null;
61
65
  private identity: KichiIdentity | null = null;
62
66
  private host: string | null = null;
63
67
  private joinResolve: ((result: JoinResult) => void) | null = null;
@@ -81,7 +85,7 @@ export class KichiForwarderService {
81
85
  this.identity = this.host ? this.loadIdentity() : null;
82
86
  this.stopped = false;
83
87
  if (this.host) {
84
- this.connect();
88
+ this.connect("startup");
85
89
  return;
86
90
  }
87
91
  this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
@@ -104,7 +108,7 @@ export class KichiForwarderService {
104
108
  this.failPendingJoin(`Kichi websocket switched to ${host}`);
105
109
  this.closeSocket();
106
110
  if (!this.stopped) {
107
- this.connect();
111
+ this.connect("switch_host");
108
112
  }
109
113
  return this.getConnectionStatus();
110
114
  }
@@ -118,7 +122,14 @@ export class KichiForwarderService {
118
122
  if (!this.host) {
119
123
  return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
120
124
  }
125
+ if (this.ws?.readyState !== WebSocket.OPEN && this.ws?.readyState !== WebSocket.CONNECTING) {
126
+ return {
127
+ success: false,
128
+ error: "Kichi websocket is not connected. Restart the gateway to reconnect before joining.",
129
+ };
130
+ }
121
131
  return new Promise((resolve) => {
132
+ this.failPendingJoin("Kichi join superseded by a new join request");
122
133
  this.identity = { avatarId };
123
134
  this.saveIdentity();
124
135
  this.joinResolve = resolve;
@@ -129,9 +140,10 @@ export class KichiForwarderService {
129
140
  } else {
130
141
  this.ws?.once("open", sendJoin);
131
142
  }
132
- setTimeout(() => {
143
+ this.joinTimeout = setTimeout(() => {
133
144
  if (this.joinResolve) {
134
145
  this.joinResolve = null;
146
+ this.clearJoinTimeout();
135
147
  resolve({ success: false, error: "Timed out waiting for join_ack" });
136
148
  }
137
149
  }, 10000);
@@ -153,6 +165,30 @@ export class KichiForwarderService {
153
165
  this.ws.send(JSON.stringify(payload));
154
166
  }
155
167
 
168
+ async sendStatusVerified(
169
+ poseType: PoseType | "",
170
+ action: string,
171
+ bubble: string,
172
+ log: string,
173
+ playback: ActionPlayback,
174
+ ): Promise<StatusAckPayload> {
175
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
176
+ throw new Error("Kichi websocket is not connected");
177
+ }
178
+ const payload: StatusPayload = {
179
+ type: "status",
180
+ requestId: randomUUID(),
181
+ avatarId: this.identity.avatarId,
182
+ authKey: this.identity.authKey,
183
+ poseType,
184
+ action,
185
+ bubble,
186
+ log,
187
+ playback,
188
+ };
189
+ return this.sendRequest<StatusAckPayload>(payload, "status_ack", 5000);
190
+ }
191
+
156
192
  sendHookNotify(hookType: HookNotifyType, bubble: string): void {
157
193
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
158
194
  const payload: HookNotifyPayload = {
@@ -344,12 +380,18 @@ export class KichiForwarderService {
344
380
  };
345
381
  }
346
382
 
347
- this.clearReconnectTimeout();
348
- this.connect();
383
+ if (this.reconnectTimeout) {
384
+ return {
385
+ accepted: true,
386
+ mode: "reconnecting",
387
+ message: "WebSocket reconnect is already scheduled. Rejoin will be sent automatically on open.",
388
+ };
389
+ }
390
+
349
391
  return {
350
- accepted: true,
351
- mode: "reconnecting",
352
- message: "Reconnect started. Rejoin will be sent automatically on open.",
392
+ accepted: false,
393
+ mode: "unavailable",
394
+ message: "WebSocket is not connected. Restart the gateway or wait for the scheduled reconnect.",
353
395
  };
354
396
  }
355
397
 
@@ -409,12 +451,18 @@ export class KichiForwarderService {
409
451
  });
410
452
  }
411
453
 
412
- private connect(): void {
454
+ private connect(reason: ConnectReason): void {
413
455
  if (this.stopped || !this.host) return;
456
+ if (this.ws?.readyState === WebSocket.CONNECTING || this.ws?.readyState === WebSocket.OPEN) {
457
+ this.log("debug", `skipped websocket connect (${reason}) because socket is already ${this.getWebsocketState()}`);
458
+ return;
459
+ }
414
460
 
461
+ this.clearReconnectTimeout();
415
462
  const wsUrl = this.getWsUrl();
416
463
  const ws = new WebSocket(wsUrl);
417
464
  this.ws = ws;
465
+ this.log("debug", `opening websocket (${reason}) to ${wsUrl}`);
418
466
 
419
467
  ws.on("open", () => {
420
468
  if (this.ws !== ws) return;
@@ -431,15 +479,16 @@ export class KichiForwarderService {
431
479
  if (this.ws !== ws) return;
432
480
  this.ws = null;
433
481
  this.rejectPendingRequests("Kichi websocket closed");
482
+ this.failPendingJoin("Kichi websocket closed");
434
483
  if (!this.stopped) {
435
- this.reconnectTimeout = setTimeout(() => {
436
- this.reconnectTimeout = null;
437
- this.connect();
438
- }, 2000);
484
+ this.scheduleReconnect();
439
485
  }
440
486
  });
441
487
 
442
- ws.on("error", () => {});
488
+ ws.on("error", (error) => {
489
+ if (this.ws !== ws) return;
490
+ this.log("warn", `websocket error: ${error instanceof Error ? error.message : String(error)}`);
491
+ });
443
492
  }
444
493
 
445
494
  private handleMessage(data: string): void {
@@ -454,6 +503,7 @@ export class KichiForwarderService {
454
503
  this.log("warn", `join failed: ${failure.error}`);
455
504
  this.joinResolve?.(failure);
456
505
  this.joinResolve = null;
506
+ this.clearJoinTimeout();
457
507
  return;
458
508
  }
459
509
 
@@ -464,6 +514,7 @@ export class KichiForwarderService {
464
514
  }
465
515
  this.joinResolve?.({ success: true, authKey: joinAck.authKey });
466
516
  this.joinResolve = null;
517
+ this.clearJoinTimeout();
467
518
  } else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
468
519
  this.log("warn", `auth failed: ${msg.reason || "unknown"}`);
469
520
  this.clearAuthKey();
@@ -712,6 +763,22 @@ export class KichiForwarderService {
712
763
  this.reconnectTimeout = null;
713
764
  }
714
765
 
766
+ private clearJoinTimeout(): void {
767
+ if (!this.joinTimeout) return;
768
+ clearTimeout(this.joinTimeout);
769
+ this.joinTimeout = null;
770
+ }
771
+
772
+ private scheduleReconnect(): void {
773
+ if (this.reconnectTimeout || this.stopped) {
774
+ return;
775
+ }
776
+ this.reconnectTimeout = setTimeout(() => {
777
+ this.reconnectTimeout = null;
778
+ this.connect("reconnect");
779
+ }, 2000);
780
+ }
781
+
715
782
  private closeSocket(): void {
716
783
  const socket = this.ws;
717
784
  this.ws = null;
@@ -723,6 +790,7 @@ export class KichiForwarderService {
723
790
  if (!this.joinResolve) return;
724
791
  this.joinResolve({ success: false, error: reason });
725
792
  this.joinResolve = null;
793
+ this.clearJoinTimeout();
726
794
  }
727
795
 
728
796
  private logPrefix(): string {
package/src/types.ts CHANGED
@@ -104,6 +104,7 @@ export type LeavePayload = {
104
104
 
105
105
  export type StatusPayload = {
106
106
  type: "status";
107
+ requestId?: string;
107
108
  avatarId: string;
108
109
  authKey: string;
109
110
  poseType: PoseType | "";
@@ -113,6 +114,14 @@ export type StatusPayload = {
113
114
  playback: ActionPlayback;
114
115
  };
115
116
 
117
+ export type StatusAckPayload = {
118
+ type: "status_ack";
119
+ requestId: string;
120
+ poseType: PoseType | "";
121
+ action: string;
122
+ warning?: string;
123
+ };
124
+
116
125
  export type HookNotifyType = "message_received" | "before_send_message";
117
126
 
118
127
  export type HookNotifyPayload = {