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

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)"})`,
@@ -913,6 +973,7 @@ function buildKichiIdlePlanDescription(): string {
913
973
  "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
974
  "5. Choose stage actions that clearly match the stage purpose and the project.",
915
975
  "6. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
976
+ "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
977
  "Use the same language as the current conversation for goal, purpose, bubble, and log.",
917
978
  `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
918
979
  `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
@@ -944,7 +1005,7 @@ function createAgentScopedTool(
944
1005
  factory: (service: KichiForwarderService, ctx: OpenClawPluginToolContext) => AnyAgentTool,
945
1006
  ) {
946
1007
  return (ctx: OpenClawPluginToolContext) => {
947
- const service = runtimeManager.getRuntime(ctx, { createIfMissing: true });
1008
+ const service = runtimeManager.getRuntime(resolveToolLocator(ctx));
948
1009
  if (!service) {
949
1010
  throw new Error("Failed to resolve agent-scoped Kichi runtime");
950
1011
  }
@@ -952,13 +1013,30 @@ function createAgentScopedTool(
952
1013
  };
953
1014
  }
954
1015
 
1016
+ const GLOBAL_RUNTIME_MANAGER_KEY = "__kichi_forwarder_runtime_manager__";
1017
+
1018
+ type GlobalRuntimeManagerState = typeof globalThis & {
1019
+ [GLOBAL_RUNTIME_MANAGER_KEY]?: KichiRuntimeManager;
1020
+ };
1021
+
1022
+ function getRuntimeManager(logger: OpenClawPluginApi["logger"]): KichiRuntimeManager {
1023
+ const globalState = globalThis as GlobalRuntimeManagerState;
1024
+ const existing = globalState[GLOBAL_RUNTIME_MANAGER_KEY];
1025
+ if (existing) {
1026
+ return existing;
1027
+ }
1028
+ const runtimeManager = new KichiRuntimeManager(logger);
1029
+ globalState[GLOBAL_RUNTIME_MANAGER_KEY] = runtimeManager;
1030
+ return runtimeManager;
1031
+ }
1032
+
955
1033
  const plugin = {
956
1034
  id: "kichi-forwarder",
957
1035
  name: "Kichi Forwarder",
958
1036
  configSchema: { parse },
959
1037
 
960
1038
  register(api: OpenClawPluginApi) {
961
- const runtimeManager = new KichiRuntimeManager(api.logger);
1039
+ const runtimeManager = getRuntimeManager(api.logger);
962
1040
  registerPluginHooks(api, runtimeManager);
963
1041
  const musicTitleEnum = getMusicTitleEnum();
964
1042
 
@@ -966,10 +1044,14 @@ const plugin = {
966
1044
  id: "kichi-forwarder",
967
1045
  start: (ctx) => {
968
1046
  parse(ctx.config.plugins?.entries?.["kichi-forwarder"]?.config);
969
- runtimeManager.startPersistedRuntimes();
1047
+ runtimeManager.initializeStartupRuntimes();
970
1048
  },
971
1049
  stop: () => {
972
1050
  runtimeManager.stopAll();
1051
+ const globalState = globalThis as GlobalRuntimeManagerState;
1052
+ if (globalState[GLOBAL_RUNTIME_MANAGER_KEY] === runtimeManager) {
1053
+ delete globalState[GLOBAL_RUNTIME_MANAGER_KEY];
1054
+ }
973
1055
  },
974
1056
  });
975
1057
 
@@ -1031,7 +1113,14 @@ const plugin = {
1031
1113
  },
1032
1114
  })));
1033
1115
 
1034
- api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1116
+ api.registerTool((ctx: OpenClawPluginToolContext) => {
1117
+ const locator = resolveToolLocator(ctx);
1118
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1119
+ if (!agentId) {
1120
+ throw new Error("Failed to resolve agent-scoped Kichi runtime");
1121
+ }
1122
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1123
+ return ({
1035
1124
  name: "kichi_switch_host",
1036
1125
  description:
1037
1126
  "Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
@@ -1058,7 +1147,8 @@ const plugin = {
1058
1147
  status,
1059
1148
  };
1060
1149
  },
1061
- })));
1150
+ });
1151
+ });
1062
1152
 
1063
1153
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1064
1154
  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.3",
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.3",
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",
@@ -66,9 +66,10 @@ Install/onboarding requests are the exception: follow `install.md` first.
66
66
 
67
67
  1. If connection or identity is unknown, call `kichi_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
 
@@ -32,63 +32,43 @@ If user wants recurring note board checks:
32
32
 
33
33
  ## Definitions
34
34
 
35
+ All query fields below (`remaining`, `dailyLimit`, `hasCreatedMusicAlbumToday`, `isAvatarInScene`, `idlePlan`, notes list) come from the `kichi_query_status` return value.
36
+
35
37
  - `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.
38
+ - `High-priority note`: recent note where `isFromOwner: true`, explicitly addressed to you, or a direct question/request requiring your response.
40
39
  - `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:
40
+ 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.
41
+ 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.
42
+
43
+ ## Note Rules
44
+
45
+ Per heartbeat run, create at most 2 notes total (up to 1 reply + up to 1 standalone).
46
+
47
+ **Triage order** scan recent-window notes and pick at most one reply target:
57
48
 
58
49
  1. Owner notes or notes clearly addressed to you.
59
50
  2. Direct questions or explicit requests.
60
51
  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
52
 
65
- - older than recent window
66
- - `isCreatedByCurrentAgent: true`
67
- - same context already answered
68
- - low-value ambient chatter
53
+ Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, same context already answered, or low-value ambient chatter.
69
54
 
70
- Per heartbeat run, create at most 2 notes total:
55
+ **Standalone gating** — applies when `remaining > 0` and no reply target was selected, OR after a reply when `remaining` still allows one more:
71
56
 
72
- 1. up to 1 reply note
73
- 2. up to 1 standalone note
57
+ - Tier-1 content exists → always create 1 standalone note.
58
+ - Tier-2 only flip a mental coin (about 50% chance); skip on tails.
59
+ - Notes list empty and `remaining > 0` → create 1 standalone note.
60
+ - In both tiers, skip if it would clearly repeat your very recent own note.
74
61
 
75
62
  ## Heartbeat Workflow
76
63
 
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.
64
+ 1. Call `kichi_query_status`. If it fails, report error and stop.
65
+ 2. If `isAvatarInScene` is `false`, the player is offline. Do **not** call any further tools in this run. Reply `HEARTBEAT_OK` and stop.
66
+ 3. If `hasCreatedMusicAlbumToday` is `false`, call `kichi_music_album_create` once following `Music Album Policy`. If `true`, skip.
67
+ 4. If `remaining == 0`, skip note creation and go to step 7.
68
+ 5. Scan recent notes and pick at most one reply target per `Note Rules`. If found, create one reply note in `To {authorName}, ...` format.
69
+ 6. Apply `Standalone gating` from `Note Rules`.
70
+ 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.
71
+ 8. Reply `HEARTBEAT_OK` only when no note was created in this run.
92
72
 
93
73
  ## HEARTBEAT.md Snippet
94
74
 
@@ -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`
@@ -80,8 +80,9 @@ When the user asks with one of the commands above, execute in this fixed order:
80
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
81
  14. Call `kichi_status`.
82
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.
83
+ 16. 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`.
84
+ 17. Otherwise, if `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
85
+ 18. Call `kichi_status` again and confirm connection and auth state.
85
86
 
86
87
  ## Required Post-install Integration
87
88
 
@@ -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 {