@yahaha-studio/kichi-forwarder 0.1.2-beta.17 → 0.1.2-beta.19

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
@@ -31,7 +31,7 @@ Use the bare package name for installation. OpenClaw tries ClawHub first and fal
31
31
 
32
32
  Kichi provides the install command and the connection details you need to connect a companion.
33
33
 
34
- Get the `host` and `avatarId` from Kichi, then use them with `kichi_switch_host` and `kichi_join`.
34
+ Get the environment, `avatarId`, and test `host` when using test, then use them with `kichi_join`.
35
35
 
36
36
  ## What Your Companion Can Do
37
37
 
@@ -48,7 +48,7 @@ Get the `host` and `avatarId` from Kichi, then use them with `kichi_switch_host`
48
48
 
49
49
  1. Install the plugin.
50
50
  2. Start OpenClaw with the plugin enabled.
51
- 3. Use `kichi_switch_host` and `kichi_join` to connect your companion to Kichi.
51
+ 3. Use `kichi_join` to connect your companion to Kichi.
52
52
  4. Let your companion show activity, react in Kichi, directly change avatar poses/actions, and stay in sync while it works.
53
53
  5. Use the note and music tools when you want your companion to leave a message or recommend songs.
54
54
 
package/dist/index.js CHANGED
@@ -13,24 +13,28 @@ const FIXED_HOOK_STATUSES = {
13
13
  poseType: "sit",
14
14
  action: "Thinking",
15
15
  bubble: "Planning task",
16
+ avatarStatus: "Busy",
16
17
  log: "I'm reading the request and getting started.",
17
18
  },
18
19
  beforeToolCall: {
19
20
  poseType: "sit",
20
21
  action: "Typing with Keyboard",
21
22
  bubble: "Working step",
23
+ avatarStatus: "Busy",
22
24
  log: "I'm at the keyboard and working through this step.",
23
25
  },
24
26
  agentEndSuccess: {
25
27
  poseType: "stand",
26
28
  action: "Yay",
27
29
  bubble: "Task complete",
30
+ avatarStatus: "Idle",
28
31
  log: "I wrapped it up and everything landed cleanly.",
29
32
  },
30
33
  agentEndFailure: {
31
34
  poseType: "stand",
32
35
  action: "Tired",
33
36
  bubble: "Task failed",
37
+ avatarStatus: "Idle",
34
38
  log: "I hit a problem here and need another pass.",
35
39
  },
36
40
  };
@@ -40,6 +44,7 @@ const MAX_AGENT_END_PREVIEW_WIDTH = 10;
40
44
  const MESSAGE_RECEIVED_ELLIPSIS = "...";
41
45
  const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
42
46
  const IDLE_PLAN_POMODORO_PHASES = ["focus", "shortBreak", "longBreak", "none"];
47
+ const AVATAR_STATUSES = ["Idle", "Busy", "Activities", "Break"];
43
48
  let cachedStaticConfig = null;
44
49
  let cachedStaticConfigMtime = 0;
45
50
  function isAlbumConfig(value) {
@@ -192,9 +197,26 @@ function resolveEnvironmentHost(environment) {
192
197
  }
193
198
  return { error: `environment "${environment}" has no configured host — update config/environments.json first` };
194
199
  }
200
+ function resolveJoinEnvironmentHost(params) {
201
+ if (!isKichiEnvironment(params.environment)) {
202
+ return { error: `environment must be one of: ${VALID_ENVIRONMENTS.join(", ")}` };
203
+ }
204
+ if (params.environment === "test") {
205
+ const testHost = typeof params.host === "string" ? params.host.trim() : "";
206
+ if (!testHost) {
207
+ return { error: "host is required for the test environment" };
208
+ }
209
+ return { environment: params.environment, host: testHost };
210
+ }
211
+ const resolved = resolveEnvironmentHost(params.environment);
212
+ if (resolved.error) {
213
+ return { environment: params.environment, error: resolved.error };
214
+ }
215
+ return { environment: params.environment, host: resolved.host };
216
+ }
195
217
  function sendStatusUpdate(service, status) {
196
218
  const actionDefinition = getActionDefinition(status.poseType, status.action);
197
- service.sendStatus(status.poseType, actionDefinition.name, status.bubble || status.action, typeof status.log === "string" ? status.log.trim() : "", getActionPlayback(actionDefinition), status.propId);
219
+ service.sendStatus(status.poseType, actionDefinition.name, status.bubble || status.action, typeof status.log === "string" ? status.log.trim() : "", getActionPlayback(actionDefinition), status.avatarStatus, status.propId);
198
220
  }
199
221
  function syncFixedStatus(service, status) {
200
222
  if (!service.hasValidIdentity() || !service.isConnected()) {
@@ -497,6 +519,12 @@ function isClockAction(value) {
497
519
  function isIdlePlanPomodoroPhase(value) {
498
520
  return IDLE_PLAN_POMODORO_PHASES.includes(String(value));
499
521
  }
522
+ function normalizeAvatarStatus(value, fieldPath) {
523
+ if (typeof value !== "string" || !AVATAR_STATUSES.includes(value)) {
524
+ return { error: `${fieldPath} must be one of: ${AVATAR_STATUSES.join(", ")}` };
525
+ }
526
+ return { avatarStatus: value };
527
+ }
500
528
  function normalizeIdlePlan(value) {
501
529
  if (!isPlainObject(value)) {
502
530
  return { error: "idle plan payload must be an object" };
@@ -527,6 +555,7 @@ function normalizeIdlePlan(value) {
527
555
  const name = rawStage.name;
528
556
  const purpose = rawStage.purpose;
529
557
  const pomodoroPhase = rawStage.pomodoroPhase;
558
+ const avatarStatus = rawStage.avatarStatus;
530
559
  const durationSeconds = rawStage.durationSeconds;
531
560
  const actions = rawStage.actions;
532
561
  if (typeof name !== "string" || !name.trim()) {
@@ -540,6 +569,10 @@ function normalizeIdlePlan(value) {
540
569
  error: `stages[${stageIndex}].pomodoroPhase must be one of: ${IDLE_PLAN_POMODORO_PHASES.join(", ")}`,
541
570
  };
542
571
  }
572
+ const normalizedAvatarStatus = normalizeAvatarStatus(avatarStatus, `stages[${stageIndex}].avatarStatus`);
573
+ if (normalizedAvatarStatus.error || normalizedAvatarStatus.avatarStatus === undefined) {
574
+ return { error: normalizedAvatarStatus.error ?? `stages[${stageIndex}].avatarStatus is invalid` };
575
+ }
543
576
  if (!isPositiveInteger(durationSeconds)) {
544
577
  return { error: `stages[${stageIndex}].durationSeconds must be a positive integer` };
545
578
  }
@@ -616,6 +649,7 @@ function normalizeIdlePlan(value) {
616
649
  name: name.trim(),
617
650
  purpose: purpose.trim(),
618
651
  pomodoroPhase,
652
+ avatarStatus: normalizedAvatarStatus.avatarStatus,
619
653
  durationSeconds,
620
654
  actions: normalizedActions,
621
655
  });
@@ -806,6 +840,7 @@ function buildKichiActionDescription(service) {
806
840
  "Directly control the avatar inside Kichi World.",
807
841
  "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.",
808
842
  "For most work, prefer a sit pose and switch actions as the task moves between stages.",
843
+ "Set avatarStatus to the current avatar status: Idle, Busy, Activities, or Break.",
809
844
  "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.",
810
845
  `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
811
846
  `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
@@ -829,9 +864,10 @@ function buildKichiIdlePlanDescription() {
829
864
  "2. Set the overall goal to that project. Do not use a vague atmosphere, a generic productivity task, or a catch-all routine summary as the goal.",
830
865
  "3. Break the full heartbeat interval into ordered stages. Each stage purpose must explain what you are actually doing in that stage as part of the same project, not just how you want to feel. Do not switch to unrelated tasks just to use more actions.",
831
866
  "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.",
832
- "5. Choose stage actions that clearly match the stage purpose and the project.",
833
- "6. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
834
- "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.",
867
+ "5. Set each stage avatarStatus to the avatar status for that stage: Idle, Busy, Activities, or Break.",
868
+ "6. Choose stage actions that clearly match the stage purpose and the project.",
869
+ "7. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
870
+ "8. 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.",
835
871
  "Use your memory to recall what you did in past heartbeats and to stay consistent with your established personality and interests.",
836
872
  "Use the same language as the current conversation for goal, purpose, bubble, and log.",
837
873
  `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
@@ -855,6 +891,7 @@ function buildKichiPrompt() {
855
891
  "2. Step switch: call when the task moves into a different stage. Keep the pose aligned with the work, usually staying seated while switching actions within the task as needed.",
856
892
  "3. Task end: call BEFORE final reply. Use the order `kichi_action` -> reply.",
857
893
  "bubble: 2-5 word companion speech. log: one short natural first-person sentence under 15 words. Match the language of the bubble and mention the current action and immediate focus like a real companion.",
894
+ "avatarStatus: set the current avatar status as Idle, Busy, Activities, or Break.",
858
895
  "",
859
896
  "kichi_clock: set countDown for tasks with 2+ steps or >10s work. Skip for quick one-shots.",
860
897
  "",
@@ -948,18 +985,27 @@ const plugin = {
948
985
  api.registerTool((ctx) => ({
949
986
  name: "kichi_join",
950
987
  label: "kichi_join",
951
- description: "Join Kichi world with avatarId, the current bot name, a short bio, and personality tags",
988
+ description: "Join Kichi world in the target environment with avatarId, the current bot name, a short bio, and personality tags. For test, pass host.",
952
989
  parameters: {
953
990
  type: "object",
954
991
  properties: {
955
992
  avatarId: { type: "string", description: "Avatar ID to join Kichi world" },
993
+ environment: {
994
+ type: "string",
995
+ enum: VALID_ENVIRONMENTS,
996
+ description: "Target environment. kichi_join switches to this environment before joining.",
997
+ },
998
+ host: {
999
+ type: "string",
1000
+ description: "Test host, required when environment is test and ignored otherwise",
1001
+ },
956
1002
  botName: {
957
1003
  type: "string",
958
1004
  description: "Current bot name to include in the join message",
959
1005
  },
960
1006
  bio: {
961
1007
  type: "string",
962
- description: "Short bio covering OpenClaw personality and role",
1008
+ description: "Short bio extracted from SOUL.md, covering persona and idle plan goals if present",
963
1009
  },
964
1010
  tags: {
965
1011
  type: "array",
@@ -971,7 +1017,7 @@ const plugin = {
971
1017
  description: "Optional join source identifier. Defaults to Kichi World join-source.json, then openclaw.",
972
1018
  },
973
1019
  },
974
- required: ["botName", "bio"],
1020
+ required: ["environment", "avatarId", "botName", "bio"],
975
1021
  },
976
1022
  execute: async (_toolCallId, params) => {
977
1023
  const locator = resolveToolLocator(ctx);
@@ -980,17 +1026,23 @@ const plugin = {
980
1026
  return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
981
1027
  }
982
1028
  const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
983
- let avatarId = params?.avatarId;
984
- const botName = params?.botName?.trim();
985
- const bio = params?.bio?.trim();
986
- const rawSource = params?.source;
987
- const { tags, error: tagsError } = normalizeJoinTags(params?.tags);
988
- if (!avatarId) {
989
- avatarId = service.readSavedAvatarId() ?? undefined;
1029
+ const p = params;
1030
+ const target = resolveJoinEnvironmentHost({
1031
+ environment: p?.environment,
1032
+ host: p?.host,
1033
+ });
1034
+ if (target.error) {
1035
+ return jsonResult({ success: false, error: target.error });
990
1036
  }
991
- if (!avatarId) {
992
- return jsonResult({ success: false, error: "No avatarId" });
1037
+ const currentStatus = service.getConnectionStatus();
1038
+ let avatarId = p?.avatarId;
1039
+ if (!avatarId && currentStatus.host === target.host) {
1040
+ avatarId = service.readSavedAvatarId() ?? undefined;
993
1041
  }
1042
+ const botName = p?.botName?.trim();
1043
+ const bio = p?.bio?.trim();
1044
+ const rawSource = p?.source;
1045
+ const { tags, error: tagsError } = normalizeJoinTags(p?.tags);
994
1046
  if (!botName) {
995
1047
  return jsonResult({ success: false, error: "No botName" });
996
1048
  }
@@ -1012,14 +1064,49 @@ const plugin = {
1012
1064
  if (tagsError) {
1013
1065
  return jsonResult({ success: false, error: tagsError });
1014
1066
  }
1067
+ let leaveStatus;
1068
+ const shouldLeaveCurrentConnection = currentStatus.connected && currentStatus.hasAuthKey && ((!!currentStatus.host && currentStatus.host !== target.host) ||
1069
+ (currentStatus.host === target.host && !!currentStatus.avatarId && !!avatarId && currentStatus.avatarId !== avatarId));
1070
+ if (shouldLeaveCurrentConnection) {
1071
+ try {
1072
+ leaveStatus = await service.leave();
1073
+ }
1074
+ catch (err) {
1075
+ leaveStatus = {
1076
+ success: false,
1077
+ error: err instanceof Error ? err.message : String(err),
1078
+ };
1079
+ }
1080
+ }
1081
+ let switchStatus;
1082
+ if (target.environment && target.host && service.getCurrentHost() !== target.host) {
1083
+ switchStatus = await service.switchHost(target.host, target.environment);
1084
+ }
1085
+ if (!avatarId) {
1086
+ avatarId = service.readSavedAvatarId() ?? undefined;
1087
+ }
1088
+ if (!avatarId) {
1089
+ return jsonResult({ success: false, error: "No avatarId" });
1090
+ }
1015
1091
  const result = await service.join(avatarId, botName, bio, tags ?? [], source);
1016
1092
  if (result.success) {
1017
- return jsonResult({ success: true, authKey: result.authKey });
1093
+ return jsonResult({
1094
+ success: true,
1095
+ authKey: result.authKey,
1096
+ ...(target.environment ? { environment: target.environment } : {}),
1097
+ ...(target.host ? { host: target.host } : {}),
1098
+ ...(switchStatus ? { switchStatus } : {}),
1099
+ ...(leaveStatus ? { leaveStatus } : {}),
1100
+ });
1018
1101
  }
1019
1102
  const failure = result;
1020
1103
  return jsonResult({
1021
1104
  success: false,
1022
1105
  error: failure.error,
1106
+ ...(target.environment ? { environment: target.environment } : {}),
1107
+ ...(target.host ? { host: target.host } : {}),
1108
+ ...(switchStatus ? { switchStatus } : {}),
1109
+ ...(leaveStatus ? { leaveStatus } : {}),
1023
1110
  ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
1024
1111
  ...(failure.errorMessage ? { errorMessage: failure.errorMessage } : {}),
1025
1112
  });
@@ -1039,7 +1126,7 @@ const plugin = {
1039
1126
  },
1040
1127
  host: {
1041
1128
  type: "string",
1042
- description: "Test node host (required for test environment, ignored otherwise)",
1129
+ description: "Test host (required for test environment, ignored otherwise)",
1043
1130
  },
1044
1131
  },
1045
1132
  required: ["environment"],
@@ -1159,6 +1246,11 @@ const plugin = {
1159
1246
  description: "Action name for the selected pose (for example Sit Nicely, Typing with Keyboard, Reading, High Five, or Meditate)",
1160
1247
  },
1161
1248
  bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
1249
+ avatarStatus: {
1250
+ type: "string",
1251
+ description: "Current avatar status: Idle, Busy, Activities, or Break.",
1252
+ enum: [...AVATAR_STATUSES],
1253
+ },
1162
1254
  log: {
1163
1255
  type: "string",
1164
1256
  description: "Short natural first-person sentence under 15 words. Match the language of the bubble and mention the current action and immediate focus.",
@@ -1172,7 +1264,7 @@ const plugin = {
1172
1264
  description: "Optional poseable prop ID from RoomContext.PoseableProps (obtained via kichi_query_status or cached). When specified, the avatar is seated at this prop; when omitted, the server picks the nearest available prop.",
1173
1265
  },
1174
1266
  },
1175
- required: ["poseType", "action"],
1267
+ required: ["poseType", "action", "avatarStatus"],
1176
1268
  },
1177
1269
  execute: async (_toolCallId, params) => {
1178
1270
  const locator = resolveToolLocator(ctx);
@@ -1181,7 +1273,7 @@ const plugin = {
1181
1273
  return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
1182
1274
  }
1183
1275
  const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1184
- const { poseType, action, bubble, log, verify, propId } = (params || {});
1276
+ const { poseType, action, bubble, avatarStatus, log, verify, propId } = (params || {});
1185
1277
  if (!poseType || !action) {
1186
1278
  return jsonResult({ success: false, error: "poseType and action parameters are required" });
1187
1279
  }
@@ -1191,6 +1283,10 @@ const plugin = {
1191
1283
  error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
1192
1284
  });
1193
1285
  }
1286
+ const normalizedAvatarStatus = normalizeAvatarStatus(avatarStatus, "avatarStatus");
1287
+ if (normalizedAvatarStatus.error || normalizedAvatarStatus.avatarStatus === undefined) {
1288
+ return jsonResult({ success: false, error: normalizedAvatarStatus.error ?? "avatarStatus is invalid" });
1289
+ }
1194
1290
  if (!service.hasValidIdentity() || !service.isConnected()) {
1195
1291
  return jsonResult({ success: false, error: "Not connected to Kichi world" });
1196
1292
  }
@@ -1209,7 +1305,7 @@ const plugin = {
1209
1305
  const playback = getActionPlayback(matched);
1210
1306
  if (verify) {
1211
1307
  try {
1212
- const ack = await service.sendStatusVerified(normalizedPoseType, matched.name, bubbleText, logText, playback, propId);
1308
+ const ack = await service.sendStatusVerified(normalizedPoseType, matched.name, bubbleText, logText, playback, normalizedAvatarStatus.avatarStatus, propId);
1213
1309
  if (ack.warning) {
1214
1310
  return jsonResult({
1215
1311
  success: true,
@@ -1229,6 +1325,7 @@ const plugin = {
1229
1325
  action: matched.name,
1230
1326
  bubble: bubbleText,
1231
1327
  log: logText,
1328
+ avatarStatus: normalizedAvatarStatus.avatarStatus,
1232
1329
  propId,
1233
1330
  });
1234
1331
  }
@@ -1238,6 +1335,7 @@ const plugin = {
1238
1335
  action: matched.name,
1239
1336
  bubble: bubbleText,
1240
1337
  log: logText,
1338
+ avatarStatus: normalizedAvatarStatus.avatarStatus,
1241
1339
  playback,
1242
1340
  });
1243
1341
  },
@@ -1334,6 +1432,11 @@ const plugin = {
1334
1432
  description: "Pomodoro phase for this stage: focus, shortBreak, longBreak, or none. Set it from the stage's actual role. Treat none as exceptional, not the default for the whole plan.",
1335
1433
  enum: [...IDLE_PLAN_POMODORO_PHASES],
1336
1434
  },
1435
+ avatarStatus: {
1436
+ type: "string",
1437
+ description: "Avatar status for this stage: Idle, Busy, Activities, or Break.",
1438
+ enum: [...AVATAR_STATUSES],
1439
+ },
1337
1440
  durationSeconds: {
1338
1441
  type: "number",
1339
1442
  description: "Required duration in seconds for this stage.",
@@ -1373,7 +1476,7 @@ const plugin = {
1373
1476
  },
1374
1477
  },
1375
1478
  },
1376
- required: ["name", "purpose", "pomodoroPhase", "durationSeconds", "actions"],
1479
+ required: ["name", "purpose", "pomodoroPhase", "avatarStatus", "durationSeconds", "actions"],
1377
1480
  },
1378
1481
  },
1379
1482
  },
@@ -98,7 +98,7 @@ export class KichiForwarderService {
98
98
  }, 10000);
99
99
  });
100
100
  }
101
- sendStatus(poseType, action, bubble, log, playback, propId) {
101
+ sendStatus(poseType, action, bubble, log, playback, avatarStatus, propId) {
102
102
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN)
103
103
  return;
104
104
  const payload = {
@@ -110,11 +110,12 @@ export class KichiForwarderService {
110
110
  bubble,
111
111
  log,
112
112
  playback,
113
+ avatarStatus,
113
114
  ...(propId ? { propId } : {}),
114
115
  };
115
116
  this.ws.send(JSON.stringify(payload));
116
117
  }
117
- async sendStatusVerified(poseType, action, bubble, log, playback, propId) {
118
+ async sendStatusVerified(poseType, action, bubble, log, playback, avatarStatus, propId) {
118
119
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
119
120
  throw new Error("Kichi websocket is not connected");
120
121
  }
@@ -128,6 +129,7 @@ export class KichiForwarderService {
128
129
  bubble,
129
130
  log,
130
131
  playback,
132
+ avatarStatus,
131
133
  ...(propId ? { propId } : {}),
132
134
  };
133
135
  return this.sendRequest(payload, "status_ack", 5000);
package/index.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  ActionPlayback,
14
14
  ActionResult,
15
15
  Album,
16
+ AvatarStatus,
16
17
  BotMessageHistoryEntry,
17
18
  BotMessageReceivedPayload,
18
19
  ClockAction,
@@ -34,24 +35,28 @@ const FIXED_HOOK_STATUSES: Record<string, ActionResult> = {
34
35
  poseType: "sit",
35
36
  action: "Thinking",
36
37
  bubble: "Planning task",
38
+ avatarStatus: "Busy",
37
39
  log: "I'm reading the request and getting started.",
38
40
  },
39
41
  beforeToolCall: {
40
42
  poseType: "sit",
41
43
  action: "Typing with Keyboard",
42
44
  bubble: "Working step",
45
+ avatarStatus: "Busy",
43
46
  log: "I'm at the keyboard and working through this step.",
44
47
  },
45
48
  agentEndSuccess: {
46
49
  poseType: "stand",
47
50
  action: "Yay",
48
51
  bubble: "Task complete",
52
+ avatarStatus: "Idle",
49
53
  log: "I wrapped it up and everything landed cleanly.",
50
54
  },
51
55
  agentEndFailure: {
52
56
  poseType: "stand",
53
57
  action: "Tired",
54
58
  bubble: "Task failed",
59
+ avatarStatus: "Idle",
55
60
  log: "I hit a problem here and need another pass.",
56
61
  },
57
62
  };
@@ -62,9 +67,11 @@ const MAX_AGENT_END_PREVIEW_WIDTH = 10;
62
67
  const MESSAGE_RECEIVED_ELLIPSIS = "...";
63
68
  const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
64
69
  const IDLE_PLAN_POMODORO_PHASES = ["focus", "shortBreak", "longBreak", "none"] as const;
70
+ const AVATAR_STATUSES = ["Idle", "Busy", "Activities", "Break"] as const;
65
71
  let cachedStaticConfig: KichiStaticConfig | null = null;
66
72
  let cachedStaticConfigMtime = 0;
67
73
 
74
+ type AvatarStatusName = typeof AVATAR_STATUSES[number];
68
75
  type IdlePlanPomodoroPhase = typeof IDLE_PLAN_POMODORO_PHASES[number];
69
76
  type IdlePlanAction = {
70
77
  poseType: PoseType;
@@ -82,6 +89,7 @@ type IdlePlan = {
82
89
  name: string;
83
90
  purpose: string;
84
91
  pomodoroPhase: IdlePlanPomodoroPhase;
92
+ avatarStatus: AvatarStatus;
85
93
  durationSeconds: number;
86
94
  actions: IdlePlanAction[];
87
95
  }>;
@@ -256,6 +264,27 @@ function resolveEnvironmentHost(environment: KichiEnvironment): { host?: string;
256
264
  return { error: `environment "${environment}" has no configured host — update config/environments.json first` };
257
265
  }
258
266
 
267
+ function resolveJoinEnvironmentHost(params: {
268
+ environment?: unknown;
269
+ host?: unknown;
270
+ }): { environment?: KichiEnvironment; host?: string; error?: string } {
271
+ if (!isKichiEnvironment(params.environment)) {
272
+ return { error: `environment must be one of: ${VALID_ENVIRONMENTS.join(", ")}` };
273
+ }
274
+ if (params.environment === "test") {
275
+ const testHost = typeof params.host === "string" ? params.host.trim() : "";
276
+ if (!testHost) {
277
+ return { error: "host is required for the test environment" };
278
+ }
279
+ return { environment: params.environment, host: testHost };
280
+ }
281
+ const resolved = resolveEnvironmentHost(params.environment);
282
+ if (resolved.error) {
283
+ return { environment: params.environment, error: resolved.error };
284
+ }
285
+ return { environment: params.environment, host: resolved.host };
286
+ }
287
+
259
288
  function sendStatusUpdate(service: KichiForwarderService, status: ActionResult): void {
260
289
  const actionDefinition = getActionDefinition(status.poseType, status.action);
261
290
  service.sendStatus(
@@ -264,6 +293,7 @@ function sendStatusUpdate(service: KichiForwarderService, status: ActionResult):
264
293
  status.bubble || status.action,
265
294
  typeof status.log === "string" ? status.log.trim() : "",
266
295
  getActionPlayback(actionDefinition),
296
+ status.avatarStatus,
267
297
  status.propId,
268
298
  );
269
299
  }
@@ -643,6 +673,13 @@ function isIdlePlanPomodoroPhase(value: unknown): value is IdlePlanPomodoroPhase
643
673
  return IDLE_PLAN_POMODORO_PHASES.includes(String(value) as IdlePlanPomodoroPhase);
644
674
  }
645
675
 
676
+ function normalizeAvatarStatus(value: unknown, fieldPath: string): { avatarStatus?: AvatarStatus; error?: string } {
677
+ if (typeof value !== "string" || !AVATAR_STATUSES.includes(value as AvatarStatusName)) {
678
+ return { error: `${fieldPath} must be one of: ${AVATAR_STATUSES.join(", ")}` };
679
+ }
680
+ return { avatarStatus: value as AvatarStatus };
681
+ }
682
+
646
683
  function normalizeIdlePlan(value: unknown): { idlePlan?: IdlePlan; error?: string } {
647
684
  if (!isPlainObject(value)) {
648
685
  return { error: "idle plan payload must be an object" };
@@ -678,6 +715,7 @@ function normalizeIdlePlan(value: unknown): { idlePlan?: IdlePlan; error?: strin
678
715
  const name = rawStage.name;
679
716
  const purpose = rawStage.purpose;
680
717
  const pomodoroPhase = rawStage.pomodoroPhase;
718
+ const avatarStatus = rawStage.avatarStatus;
681
719
  const durationSeconds = rawStage.durationSeconds;
682
720
  const actions = rawStage.actions;
683
721
 
@@ -692,6 +730,10 @@ function normalizeIdlePlan(value: unknown): { idlePlan?: IdlePlan; error?: strin
692
730
  error: `stages[${stageIndex}].pomodoroPhase must be one of: ${IDLE_PLAN_POMODORO_PHASES.join(", ")}`,
693
731
  };
694
732
  }
733
+ const normalizedAvatarStatus = normalizeAvatarStatus(avatarStatus, `stages[${stageIndex}].avatarStatus`);
734
+ if (normalizedAvatarStatus.error || normalizedAvatarStatus.avatarStatus === undefined) {
735
+ return { error: normalizedAvatarStatus.error ?? `stages[${stageIndex}].avatarStatus is invalid` };
736
+ }
695
737
  if (!isPositiveInteger(durationSeconds)) {
696
738
  return { error: `stages[${stageIndex}].durationSeconds must be a positive integer` };
697
739
  }
@@ -776,6 +818,7 @@ function normalizeIdlePlan(value: unknown): { idlePlan?: IdlePlan; error?: strin
776
818
  name: name.trim(),
777
819
  purpose: purpose.trim(),
778
820
  pomodoroPhase,
821
+ avatarStatus: normalizedAvatarStatus.avatarStatus,
779
822
  durationSeconds,
780
823
  actions: normalizedActions,
781
824
  });
@@ -1001,6 +1044,7 @@ function buildKichiActionDescription(service?: KichiForwarderService): string {
1001
1044
  "Directly control the avatar inside Kichi World.",
1002
1045
  "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.",
1003
1046
  "For most work, prefer a sit pose and switch actions as the task moves between stages.",
1047
+ "Set avatarStatus to the current avatar status: Idle, Busy, Activities, or Break.",
1004
1048
  "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.",
1005
1049
  `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
1006
1050
  `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
@@ -1032,9 +1076,10 @@ function buildKichiIdlePlanDescription(): string {
1032
1076
  "2. Set the overall goal to that project. Do not use a vague atmosphere, a generic productivity task, or a catch-all routine summary as the goal.",
1033
1077
  "3. Break the full heartbeat interval into ordered stages. Each stage purpose must explain what you are actually doing in that stage as part of the same project, not just how you want to feel. Do not switch to unrelated tasks just to use more actions.",
1034
1078
  "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.",
1035
- "5. Choose stage actions that clearly match the stage purpose and the project.",
1036
- "6. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
1037
- "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.",
1079
+ "5. Set each stage avatarStatus to the avatar status for that stage: Idle, Busy, Activities, or Break.",
1080
+ "6. Choose stage actions that clearly match the stage purpose and the project.",
1081
+ "7. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
1082
+ "8. 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.",
1038
1083
  "Use your memory to recall what you did in past heartbeats and to stay consistent with your established personality and interests.",
1039
1084
  "Use the same language as the current conversation for goal, purpose, bubble, and log.",
1040
1085
  `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
@@ -1059,6 +1104,7 @@ function buildKichiPrompt(): string {
1059
1104
  "2. Step switch: call when the task moves into a different stage. Keep the pose aligned with the work, usually staying seated while switching actions within the task as needed.",
1060
1105
  "3. Task end: call BEFORE final reply. Use the order `kichi_action` -> reply.",
1061
1106
  "bubble: 2-5 word companion speech. log: one short natural first-person sentence under 15 words. Match the language of the bubble and mention the current action and immediate focus like a real companion.",
1107
+ "avatarStatus: set the current avatar status as Idle, Busy, Activities, or Break.",
1062
1108
  "",
1063
1109
  "kichi_clock: set countDown for tasks with 2+ steps or >10s work. Skip for quick one-shots.",
1064
1110
  "",
@@ -1166,18 +1212,29 @@ const plugin = {
1166
1212
  api.registerTool((ctx) => ({
1167
1213
  name: "kichi_join",
1168
1214
  label: "kichi_join",
1169
- description: "Join Kichi world with avatarId, the current bot name, a short bio, and personality tags",
1215
+ description:
1216
+ "Join Kichi world in the target environment with avatarId, the current bot name, a short bio, and personality tags. For test, pass host.",
1170
1217
  parameters: {
1171
1218
  type: "object",
1172
1219
  properties: {
1173
1220
  avatarId: { type: "string", description: "Avatar ID to join Kichi world" },
1221
+ environment: {
1222
+ type: "string",
1223
+ enum: VALID_ENVIRONMENTS,
1224
+ description:
1225
+ "Target environment. kichi_join switches to this environment before joining.",
1226
+ },
1227
+ host: {
1228
+ type: "string",
1229
+ description: "Test host, required when environment is test and ignored otherwise",
1230
+ },
1174
1231
  botName: {
1175
1232
  type: "string",
1176
1233
  description: "Current bot name to include in the join message",
1177
1234
  },
1178
1235
  bio: {
1179
1236
  type: "string",
1180
- description: "Short bio covering OpenClaw personality and role",
1237
+ description: "Short bio extracted from SOUL.md, covering persona and idle plan goals if present",
1181
1238
  },
1182
1239
  tags: {
1183
1240
  type: "array",
@@ -1189,7 +1246,7 @@ const plugin = {
1189
1246
  description: "Optional join source identifier. Defaults to Kichi World join-source.json, then openclaw.",
1190
1247
  },
1191
1248
  },
1192
- required: ["botName", "bio"],
1249
+ required: ["environment", "avatarId", "botName", "bio"],
1193
1250
  },
1194
1251
  execute: async (_toolCallId, params) => {
1195
1252
  const locator = resolveToolLocator(ctx);
@@ -1198,19 +1255,33 @@ const plugin = {
1198
1255
  return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
1199
1256
  }
1200
1257
  const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1201
- let avatarId = (params as { avatarId?: string } | null)?.avatarId;
1202
- const botName = (params as { botName?: string } | null)?.botName?.trim();
1203
- const bio = (params as { bio?: string } | null)?.bio?.trim();
1204
- const rawSource = (params as { source?: unknown } | null)?.source;
1205
- const { tags, error: tagsError } = normalizeJoinTags(
1206
- (params as { tags?: unknown } | null)?.tags,
1207
- );
1208
- if (!avatarId) {
1209
- avatarId = service.readSavedAvatarId() ?? undefined;
1258
+ const p = params as {
1259
+ avatarId?: string;
1260
+ environment?: unknown;
1261
+ host?: unknown;
1262
+ botName?: string;
1263
+ bio?: string;
1264
+ source?: unknown;
1265
+ tags?: unknown;
1266
+ } | null;
1267
+ const target = resolveJoinEnvironmentHost({
1268
+ environment: p?.environment,
1269
+ host: p?.host,
1270
+ });
1271
+ if (target.error) {
1272
+ return jsonResult({ success: false, error: target.error });
1210
1273
  }
1211
- if (!avatarId) {
1212
- return jsonResult({ success: false, error: "No avatarId" });
1274
+ const currentStatus = service.getConnectionStatus();
1275
+ let avatarId = p?.avatarId;
1276
+ if (!avatarId && currentStatus.host === target.host) {
1277
+ avatarId = service.readSavedAvatarId() ?? undefined;
1213
1278
  }
1279
+ const botName = p?.botName?.trim();
1280
+ const bio = p?.bio?.trim();
1281
+ const rawSource = p?.source;
1282
+ const { tags, error: tagsError } = normalizeJoinTags(
1283
+ p?.tags,
1284
+ );
1214
1285
  if (!botName) {
1215
1286
  return jsonResult({ success: false, error: "No botName" });
1216
1287
  }
@@ -1231,14 +1302,50 @@ const plugin = {
1231
1302
  if (tagsError) {
1232
1303
  return jsonResult({ success: false, error: tagsError });
1233
1304
  }
1305
+ let leaveStatus;
1306
+ const shouldLeaveCurrentConnection = currentStatus.connected && currentStatus.hasAuthKey && (
1307
+ (!!currentStatus.host && currentStatus.host !== target.host) ||
1308
+ (currentStatus.host === target.host && !!currentStatus.avatarId && !!avatarId && currentStatus.avatarId !== avatarId)
1309
+ );
1310
+ if (shouldLeaveCurrentConnection) {
1311
+ try {
1312
+ leaveStatus = await service.leave();
1313
+ } catch (err) {
1314
+ leaveStatus = {
1315
+ success: false,
1316
+ error: err instanceof Error ? err.message : String(err),
1317
+ };
1318
+ }
1319
+ }
1320
+ let switchStatus;
1321
+ if (target.environment && target.host && service.getCurrentHost() !== target.host) {
1322
+ switchStatus = await service.switchHost(target.host, target.environment);
1323
+ }
1324
+ if (!avatarId) {
1325
+ avatarId = service.readSavedAvatarId() ?? undefined;
1326
+ }
1327
+ if (!avatarId) {
1328
+ return jsonResult({ success: false, error: "No avatarId" });
1329
+ }
1234
1330
  const result = await service.join(avatarId, botName, bio, tags ?? [], source);
1235
1331
  if (result.success) {
1236
- return jsonResult({ success: true, authKey: result.authKey });
1332
+ return jsonResult({
1333
+ success: true,
1334
+ authKey: result.authKey,
1335
+ ...(target.environment ? { environment: target.environment } : {}),
1336
+ ...(target.host ? { host: target.host } : {}),
1337
+ ...(switchStatus ? { switchStatus } : {}),
1338
+ ...(leaveStatus ? { leaveStatus } : {}),
1339
+ });
1237
1340
  }
1238
1341
  const failure = result as { success: false; error: string; errorCode?: string; errorMessage?: string };
1239
1342
  return jsonResult({
1240
1343
  success: false,
1241
1344
  error: failure.error,
1345
+ ...(target.environment ? { environment: target.environment } : {}),
1346
+ ...(target.host ? { host: target.host } : {}),
1347
+ ...(switchStatus ? { switchStatus } : {}),
1348
+ ...(leaveStatus ? { leaveStatus } : {}),
1242
1349
  ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
1243
1350
  ...(failure.errorMessage ? { errorMessage: failure.errorMessage } : {}),
1244
1351
  });
@@ -1260,7 +1367,7 @@ const plugin = {
1260
1367
  },
1261
1368
  host: {
1262
1369
  type: "string",
1263
- description: "Test node host (required for test environment, ignored otherwise)",
1370
+ description: "Test host (required for test environment, ignored otherwise)",
1264
1371
  },
1265
1372
  },
1266
1373
  required: ["environment"],
@@ -1386,6 +1493,11 @@ const plugin = {
1386
1493
  description: "Action name for the selected pose (for example Sit Nicely, Typing with Keyboard, Reading, High Five, or Meditate)",
1387
1494
  },
1388
1495
  bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
1496
+ avatarStatus: {
1497
+ type: "string",
1498
+ description: "Current avatar status: Idle, Busy, Activities, or Break.",
1499
+ enum: [...AVATAR_STATUSES],
1500
+ },
1389
1501
  log: {
1390
1502
  type: "string",
1391
1503
  description:
@@ -1402,7 +1514,7 @@ const plugin = {
1402
1514
  "Optional poseable prop ID from RoomContext.PoseableProps (obtained via kichi_query_status or cached). When specified, the avatar is seated at this prop; when omitted, the server picks the nearest available prop.",
1403
1515
  },
1404
1516
  },
1405
- required: ["poseType", "action"],
1517
+ required: ["poseType", "action", "avatarStatus"],
1406
1518
  },
1407
1519
  execute: async (_toolCallId, params) => {
1408
1520
  const locator = resolveToolLocator(ctx);
@@ -1411,10 +1523,11 @@ const plugin = {
1411
1523
  return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
1412
1524
  }
1413
1525
  const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1414
- const { poseType, action, bubble, log, verify, propId } = (params || {}) as {
1526
+ const { poseType, action, bubble, avatarStatus, log, verify, propId } = (params || {}) as {
1415
1527
  poseType?: string;
1416
1528
  action?: string;
1417
1529
  bubble?: string;
1530
+ avatarStatus?: unknown;
1418
1531
  log?: string;
1419
1532
  verify?: boolean;
1420
1533
  propId?: string;
@@ -1428,6 +1541,10 @@ const plugin = {
1428
1541
  error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
1429
1542
  });
1430
1543
  }
1544
+ const normalizedAvatarStatus = normalizeAvatarStatus(avatarStatus, "avatarStatus");
1545
+ if (normalizedAvatarStatus.error || normalizedAvatarStatus.avatarStatus === undefined) {
1546
+ return jsonResult({ success: false, error: normalizedAvatarStatus.error ?? "avatarStatus is invalid" });
1547
+ }
1431
1548
  if (!service.hasValidIdentity() || !service.isConnected()) {
1432
1549
  return jsonResult({ success: false, error: "Not connected to Kichi world" });
1433
1550
  }
@@ -1450,7 +1567,13 @@ const plugin = {
1450
1567
  if (verify) {
1451
1568
  try {
1452
1569
  const ack = await service.sendStatusVerified(
1453
- normalizedPoseType, matched.name, bubbleText, logText, playback, propId,
1570
+ normalizedPoseType,
1571
+ matched.name,
1572
+ bubbleText,
1573
+ logText,
1574
+ playback,
1575
+ normalizedAvatarStatus.avatarStatus,
1576
+ propId,
1454
1577
  );
1455
1578
  if (ack.warning) {
1456
1579
  return jsonResult({
@@ -1469,6 +1592,7 @@ const plugin = {
1469
1592
  action: matched.name,
1470
1593
  bubble: bubbleText,
1471
1594
  log: logText,
1595
+ avatarStatus: normalizedAvatarStatus.avatarStatus,
1472
1596
  propId,
1473
1597
  });
1474
1598
  }
@@ -1479,6 +1603,7 @@ const plugin = {
1479
1603
  action: matched.name,
1480
1604
  bubble: bubbleText,
1481
1605
  log: logText,
1606
+ avatarStatus: normalizedAvatarStatus.avatarStatus,
1482
1607
  playback,
1483
1608
  });
1484
1609
  },
@@ -1586,6 +1711,11 @@ const plugin = {
1586
1711
  description: "Pomodoro phase for this stage: focus, shortBreak, longBreak, or none. Set it from the stage's actual role. Treat none as exceptional, not the default for the whole plan.",
1587
1712
  enum: [...IDLE_PLAN_POMODORO_PHASES],
1588
1713
  },
1714
+ avatarStatus: {
1715
+ type: "string",
1716
+ description: "Avatar status for this stage: Idle, Busy, Activities, or Break.",
1717
+ enum: [...AVATAR_STATUSES],
1718
+ },
1589
1719
  durationSeconds: {
1590
1720
  type: "number",
1591
1721
  description: "Required duration in seconds for this stage.",
@@ -1625,7 +1755,7 @@ const plugin = {
1625
1755
  },
1626
1756
  },
1627
1757
  },
1628
- required: ["name", "purpose", "pomodoroPhase", "durationSeconds", "actions"],
1758
+ required: ["name", "purpose", "pomodoroPhase", "avatarStatus", "durationSeconds", "actions"],
1629
1759
  },
1630
1760
  },
1631
1761
  },
@@ -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.2-beta.17",
5
+ "version": "0.1.2-beta.19",
6
6
  "author": "OpenClaw",
7
7
  "skills": ["./skills/kichi-forwarder"],
8
8
  "contracts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/kichi-forwarder",
3
- "version": "0.1.2-beta.17",
3
+ "version": "0.1.2-beta.19",
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": "./dist/index.js",
@@ -23,7 +23,7 @@ Install and connect requests use `on <environment>` syntax. Supported environmen
23
23
 
24
24
  - `steam`: connects to `focus-wss.yahaha.com`
25
25
  - `steam-playtest`: connects to `focus-steam-playtest-wss-int.yahaha.com`
26
- - `test`: no fixed host — ask the user for the current test node host, then call `kichi_switch_host` with both the environment and host. The host is persisted in `state.json` and reused on restart
26
+ - `test`: no fixed host — use the test host from the user request when provided; otherwise ask the user for the test host before calling `kichi_join`
27
27
 
28
28
  ## Runtime State
29
29
 
@@ -69,26 +69,23 @@ For install/onboarding/connect requests:
69
69
 
70
70
  Use this order unless the user asks for a different explicit action. For install/onboarding requests, follow `install.md` first.
71
71
 
72
- 1. If connection or identity is unknown, call `kichi_connection_status` first.
73
- 2. If the requested environment differs from the current environment, call `kichi_switch_host` with the target environment.
74
- 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`.
75
- 4. If no `authKey` is available, call `kichi_join`.
76
- 5. If `authKey` exists but websocket is not open, call `kichi_rejoin` or wait for automatic reconnect and rejoin.
77
- 6. Use `kichi_action`, `kichi_glance`, `kichi_clock`, note board tools, and music album tools after status is ready.
72
+ 1. For join/connect requests with an `avatarId` and environment, call `kichi_join` with `environment`. For `test`, include `host` if the user provided it; if not, ask for the host first.
78
73
 
79
74
  ## Tools
80
75
 
81
76
  ### kichi_join
82
77
 
83
78
  ```text
84
- kichi_join(avatarId: "your-avatar-id", botName: "<from IDENTITY.md>", bio: "<from SOUL.md>", tags: ["calm", "focused", "curious"])
79
+ kichi_join(environment: "steam-playtest", avatarId: "your-avatar-id", botName: "<from IDENTITY.md>", bio: "<from SOUL.md>", tags: ["calm", "focused", "curious"])
80
+ kichi_join(environment: "test", host: "192.168.1.100", avatarId: "your-avatar-id", botName: "<from IDENTITY.md>", bio: "<from SOUL.md>", tags: ["calm", "focused", "curious"])
85
81
  ```
86
82
 
83
+ - `environment`: required. One of `steam`, `steam-playtest`, `test`. `kichi_join` switches to the target environment before joining.
84
+ - `host`: required for `test` environment, ignored otherwise. If the user did not provide the test host, ask for it before calling `kichi_join`.
85
+ - `avatarId`: required
87
86
  - `botName`: required
88
- - `bio`: required
89
- - `avatarId`: optional. If omitted, the tool reads `avatarId` from the current host's `identity.json`. If missing, the call fails.
87
+ - `bio`: required. Extract from `SOUL.md`, covering persona and idle plan goals if present.
90
88
  - `tags`: optional string list. Empty strings are ignored and duplicates are removed. If omitted, the join payload sends `[]`.
91
- - If the current host is still joined with a different `avatarId`, call `kichi_leave` first, then call `kichi_join` with the new `avatarId`.
92
89
 
93
90
  ### kichi_switch_host
94
91
 
@@ -98,7 +95,7 @@ kichi_switch_host(environment: "test", host: "192.168.1.100")
98
95
  ```
99
96
 
100
97
  - `environment`: required. One of `steam`, `steam-playtest`, `test`.
101
- - `host`: required for `test` environment, ignored otherwise. The test host is persisted in `state.json` and reused on restart.
98
+ - `host`: required for `test` environment, ignored otherwise.
102
99
  - For `steam` and `steam-playtest`, the host is resolved automatically from the bundled config.
103
100
  - This reloads the host-specific `identity.json` and reconnects the websocket immediately.
104
101
 
@@ -6,7 +6,7 @@ Install and connect requests use `on <environment>` syntax. Supported environmen
6
6
 
7
7
  - `steam`: connects to `focus-wss.yahaha.com`
8
8
  - `steam-playtest`: connects to `focus-steam-playtest-wss-int.yahaha.com`
9
- - `test`: no fixed host — ask the user for the current test node host, then call `kichi_switch_host` with both the environment and host
9
+ - `test`: no fixed host — use the test host from the user request when provided; otherwise ask the user for the test host before calling `kichi_join`
10
10
 
11
11
  ## Runtime Files
12
12
 
@@ -23,17 +23,6 @@ Persist runtime state to the current agent's `state.json`:
23
23
  }
24
24
  ```
25
25
 
26
- If the current host has no saved `avatarId` yet, save it to the current agent's host-specific `identity.json` (this helps `kichi_join` resolve the avatar automatically when `avatarId` is omitted):
27
-
28
- - Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`
29
- - Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\hosts\<encoded-host>\identity.json`
30
-
31
- ```json
32
- {
33
- "avatarId": "your-avatar-id"
34
- }
35
- ```
36
-
37
26
  ## Install
38
27
 
39
28
  1. Download the npm package archive:
@@ -64,7 +53,7 @@ When the user asks with one of the commands above, execute in this fixed order:
64
53
 
65
54
  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.
66
55
  2. Parse `avatarId` from user text (`AvatarId`/`avatarId`, case-insensitive).
67
- 3. Parse environment from the `on <environment>` part of the command (e.g. `on steam-playtest`). Write the current agent's `state.json`.
56
+ 3. Parse environment from the `on <environment>` part of the command (e.g. `on steam-playtest`). For `test`, parse the host from the request or ask the user for it before continuing.
68
57
  4. Run `npm pack @yahaha-studio/kichi-forwarder`, then install the generated `.tgz` with `openclaw plugins install <tgz-path>`.
69
58
  5. If the plugin already exists and the packed version matches the installed version, skip to step 7.
70
59
  6. If the plugin already exists but the version differs, overwrite with `openclaw plugins install <tgz-path> --force`.
@@ -72,11 +61,7 @@ When the user asks with one of the commands above, execute in this fixed order:
72
61
  8. Run `openclaw --version`. If the version is **5.7 or later**, ensure `openclaw.json` has `plugins.entries.kichi-forwarder.hooks.allowConversationAccess` set to `true`. If missing, add it. On older versions, skip this step.
73
62
  9. If the plugin was newly installed or upgraded in this flow, check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing. An empty or blank `HEARTBEAT.md` means the snippet is missing — treat it the same as "snippet not found", not as a read failure.
74
63
  10. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Setup` from [heartbeat.md](heartbeat.md). If the update fails, warn the user and continue.
75
- 11. Call `kichi_connection_status`.
76
- 12. If the current agent runtime environment does not match the requested one, call `kichi_switch_host` with the target environment (and host for test).
77
- 13. 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`.
78
- 14. Otherwise, if `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
79
- 15. Call `kichi_connection_status` again and confirm connection and auth state.
64
+ 11. Call `kichi_join` with parsed `environment`, `host` for test, `avatarId`, `botName`, `bio`, and `tags`.
80
65
 
81
66
  ## Required Post-install Integration
82
67
 
@@ -85,7 +70,7 @@ Use this completion checklist:
85
70
  - [ ] plugin installed, enabled, and at latest version
86
71
  - [ ] `openclaw.json` has `plugins.entries.kichi-forwarder.hooks.allowConversationAccess: true` (OpenClaw >= 5.7 only)
87
72
  - [ ] `HEARTBEAT.md` updated with the Kichi heartbeat workflow snippet from [heartbeat.md](heartbeat.md)
88
- - [ ] `kichi_connection_status` verified the final connected/auth state
73
+ - [ ] `kichi_join` completed successfully
89
74
 
90
75
  If any box is unchecked, the onboarding remains incomplete.
91
76
 
package/src/service.ts CHANGED
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import type { PluginLogger } from "openclaw/plugin-sdk";
6
6
  import type {
7
7
  ActionPlayback,
8
+ AvatarStatus,
8
9
  BotMessageHistoryEntry,
9
10
  BotMessagePayload,
10
11
  BotMessageReceivedPayload,
@@ -189,7 +190,15 @@ export class KichiForwarderService {
189
190
  });
190
191
  }
191
192
 
192
- sendStatus(poseType: PoseType | "", action: string, bubble: string, log: string, playback: ActionPlayback, propId?: string): void {
193
+ sendStatus(
194
+ poseType: PoseType | "",
195
+ action: string,
196
+ bubble: string,
197
+ log: string,
198
+ playback: ActionPlayback,
199
+ avatarStatus: AvatarStatus,
200
+ propId?: string,
201
+ ): void {
193
202
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
194
203
  const payload: StatusPayload = {
195
204
  type: "status",
@@ -200,6 +209,7 @@ export class KichiForwarderService {
200
209
  bubble,
201
210
  log,
202
211
  playback,
212
+ avatarStatus,
203
213
  ...(propId ? { propId } : {}),
204
214
  };
205
215
  this.ws.send(JSON.stringify(payload));
@@ -211,6 +221,7 @@ export class KichiForwarderService {
211
221
  bubble: string,
212
222
  log: string,
213
223
  playback: ActionPlayback,
224
+ avatarStatus: AvatarStatus,
214
225
  propId?: string,
215
226
  ): Promise<StatusAckPayload> {
216
227
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
@@ -226,6 +237,7 @@ export class KichiForwarderService {
226
237
  bubble,
227
238
  log,
228
239
  playback,
240
+ avatarStatus,
229
241
  ...(propId ? { propId } : {}),
230
242
  };
231
243
  return this.sendRequest<StatusAckPayload>(payload, "status_ack", 5000);
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@ export type KichiForwarderConfig = Record<string, never>;
2
2
 
3
3
  export type PoseType = "stand" | "sit" | "lay" | "floor";
4
4
  export type ActionPlaybackMode = "loop" | "once";
5
+ export type AvatarStatus = "Idle" | "Busy" | "Activities" | "Break";
5
6
  export type ActionPlayback = {
6
7
  mode: ActionPlaybackMode;
7
8
  resumeAction?: string;
@@ -16,6 +17,7 @@ export type ActionResult = {
16
17
  poseType: PoseType;
17
18
  action: string;
18
19
  bubble: string;
20
+ avatarStatus: AvatarStatus;
19
21
  log?: string;
20
22
  propId?: string;
21
23
  };
@@ -120,6 +122,7 @@ export type StatusPayload = {
120
122
  bubble: string;
121
123
  log: string;
122
124
  playback: ActionPlayback;
125
+ avatarStatus: AvatarStatus;
123
126
  propId?: string;
124
127
  };
125
128
 
@@ -173,6 +176,7 @@ export type IdlePlanStage = {
173
176
  name: string;
174
177
  purpose: string;
175
178
  pomodoroPhase: IdlePlanPhase;
179
+ avatarStatus: AvatarStatus;
176
180
  durationSeconds: number;
177
181
  actions: IdlePlanStageAction[];
178
182
  };