@yahaha-studio/kichi-forwarder 0.0.1-alpha.49 → 0.0.1-alpha.51

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/src/service.ts CHANGED
@@ -5,31 +5,31 @@ import * as path from "path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import type { Logger } from "openclaw/plugin-sdk";
7
7
  import type {
8
- AvatarCommand,
9
- AvatarCommandPayload,
10
8
  ClockAction,
11
9
  ClockConfig,
12
10
  ClockPayload,
13
11
  CreateMusicAlbumPayload,
12
+ CreateNotesBoardNotePayload,
14
13
  HookNotifyPayload,
15
14
  HookNotifyType,
16
15
  JoinAckPayload,
17
16
  JoinPayload,
18
17
  KichiConnectionStatus,
19
- CreateNotesBoardNotePayload,
20
18
  KichiForwarderConfig,
21
19
  KichiIdentity,
20
+ KichiState,
22
21
  LeaveAckPayload,
23
22
  PoseType,
24
23
  QueryStatusPayload,
25
24
  QueryStatusResultPayload,
26
25
  StatusPayload,
27
- WorkspaceScreenPayload,
28
26
  } from "./types.js";
29
27
 
30
- const IDENTITY_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
31
- const IDENTITY_PATH = path.join(IDENTITY_DIR, "identity.json");
28
+ const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
29
+ const HOSTS_DIR = path.join(KICHI_WORLD_DIR, "hosts");
30
+ const STATE_PATH = path.join(KICHI_WORLD_DIR, "state.json");
32
31
  const MAX_NOTEBOARD_TEXT_LENGTH = 200;
32
+ const DEFAULT_LLM_RUNTIME_ENABLED = true;
33
33
 
34
34
  type AckFailureResult = {
35
35
  success: false;
@@ -56,6 +56,7 @@ export class KichiForwarderService {
56
56
  private stopped = false;
57
57
  private reconnectTimeout: NodeJS.Timeout | null = null;
58
58
  private identity: KichiIdentity | null = null;
59
+ private host: string;
59
60
  private joinResolve: ((result: JoinResult) => void) | null = null;
60
61
  private pendingRequests = new Map<
61
62
  string,
@@ -67,9 +68,12 @@ export class KichiForwarderService {
67
68
  }
68
69
  >();
69
70
 
70
- constructor(private config: KichiForwarderConfig, private logger: Logger) {}
71
+ constructor(private config: KichiForwarderConfig, private logger: Logger) {
72
+ this.host = config.defaultHost;
73
+ }
71
74
 
72
75
  async start(): Promise<void> {
76
+ this.host = this.loadCurrentHost();
73
77
  this.identity = this.loadIdentity();
74
78
  this.stopped = false;
75
79
  this.connect();
@@ -77,10 +81,24 @@ export class KichiForwarderService {
77
81
 
78
82
  async stop(): Promise<void> {
79
83
  this.stopped = true;
80
- if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
84
+ this.clearReconnectTimeout();
81
85
  this.rejectPendingRequests("Kichi websocket stopped");
82
- this.ws?.close();
83
- this.ws = null;
86
+ this.failPendingJoin("Kichi websocket stopped");
87
+ this.closeSocket();
88
+ }
89
+
90
+ async switchHost(host: string): Promise<KichiConnectionStatus> {
91
+ this.persistCurrentHost(host);
92
+ this.host = host;
93
+ this.identity = this.loadIdentity();
94
+ this.clearReconnectTimeout();
95
+ this.rejectPendingRequests(`Kichi websocket switched to ${host}`);
96
+ this.failPendingJoin(`Kichi websocket switched to ${host}`);
97
+ this.closeSocket();
98
+ if (!this.stopped) {
99
+ this.connect();
100
+ }
101
+ return this.getConnectionStatus();
84
102
  }
85
103
 
86
104
  async join(
@@ -91,6 +109,7 @@ export class KichiForwarderService {
91
109
  ): Promise<JoinResult> {
92
110
  return new Promise((resolve) => {
93
111
  this.identity = { avatarId };
112
+ this.saveIdentity();
94
113
  this.joinResolve = resolve;
95
114
  const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags };
96
115
  const sendJoin = () => this.ws?.send(JSON.stringify(payload));
@@ -108,15 +127,253 @@ export class KichiForwarderService {
108
127
  });
109
128
  }
110
129
 
130
+ sendStatus(poseType: PoseType | "", action: string, bubble: string, log: string): void {
131
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
132
+ const payload: StatusPayload = {
133
+ type: "status",
134
+ avatarId: this.identity.avatarId,
135
+ authKey: this.identity.authKey,
136
+ poseType,
137
+ action,
138
+ bubble,
139
+ log,
140
+ };
141
+ this.ws.send(JSON.stringify(payload));
142
+ }
143
+
144
+ sendHookNotify(hookType: HookNotifyType, bubble: string): void {
145
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
146
+ const payload: HookNotifyPayload = {
147
+ type: hookType,
148
+ avatarId: this.identity.avatarId,
149
+ authKey: this.identity.authKey,
150
+ bubble,
151
+ };
152
+ this.ws.send(JSON.stringify(payload));
153
+ }
154
+
155
+ sendClock(action: ClockAction, clock?: ClockConfig, requestId?: string): boolean {
156
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
157
+ if (action === "set" && !clock) return false;
158
+
159
+ const basePayload = {
160
+ type: "clock" as const,
161
+ avatarId: this.identity.avatarId,
162
+ authKey: this.identity.authKey,
163
+ ...(requestId ? { requestId } : {}),
164
+ };
165
+
166
+ const payload: ClockPayload =
167
+ action === "set"
168
+ ? {
169
+ ...basePayload,
170
+ action,
171
+ clock,
172
+ }
173
+ : {
174
+ ...basePayload,
175
+ action,
176
+ };
177
+
178
+ this.ws.send(JSON.stringify(payload));
179
+ return true;
180
+ }
181
+
182
+ async queryStatus(requestId?: string): Promise<QueryStatusResultPayload> {
183
+ const identity = this.requireIdentity();
184
+ if (!identity) {
185
+ throw new Error("Missing Kichi identity");
186
+ }
187
+
188
+ const payload: QueryStatusPayload = {
189
+ type: "query_status",
190
+ requestId: requestId?.trim() || randomUUID(),
191
+ avatarId: identity.avatarId,
192
+ authKey: identity.authKey,
193
+ };
194
+ return this.sendRequest<QueryStatusResultPayload>(payload, "query_status_result");
195
+ }
196
+
197
+ createNotesBoardNote(propId: string, data: string): void {
198
+ const identity = this.requireIdentity();
199
+ if (!identity) {
200
+ throw new Error("Missing Kichi identity");
201
+ }
202
+
203
+ if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
204
+ throw new Error(`Note content must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`);
205
+ }
206
+
207
+ if (this.ws?.readyState !== WebSocket.OPEN) {
208
+ throw new Error("Kichi websocket is not connected");
209
+ }
210
+
211
+ const payload: CreateNotesBoardNotePayload = {
212
+ type: "create_notes_board_note",
213
+ avatarId: identity.avatarId,
214
+ authKey: identity.authKey,
215
+ propId,
216
+ data,
217
+ };
218
+ this.ws.send(JSON.stringify(payload));
219
+ }
220
+
221
+ createMusicAlbum(albumTitle: string, musicTitles: string[], requestId?: string): string {
222
+ const identity = this.requireIdentity();
223
+ if (!identity) {
224
+ throw new Error("Missing Kichi identity");
225
+ }
226
+ if (!albumTitle.trim()) {
227
+ throw new Error("albumTitle is required");
228
+ }
229
+ if (!Array.isArray(musicTitles) || musicTitles.length === 0) {
230
+ throw new Error("musicTitles must contain at least one track title");
231
+ }
232
+ if (this.ws?.readyState !== WebSocket.OPEN) {
233
+ throw new Error("Kichi websocket is not connected");
234
+ }
235
+
236
+ const normalizedRequestId = requestId?.trim() || randomUUID();
237
+ const payload: CreateMusicAlbumPayload = {
238
+ type: "create_music_album",
239
+ requestId: normalizedRequestId,
240
+ avatarId: identity.avatarId,
241
+ authKey: identity.authKey,
242
+ albumTitle: albumTitle.trim(),
243
+ musicTitles,
244
+ };
245
+ this.ws.send(JSON.stringify(payload));
246
+ return normalizedRequestId;
247
+ }
248
+
249
+ isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
250
+
251
+ hasValidIdentity(): boolean { return !!this.identity?.avatarId && !!this.identity?.authKey; }
252
+
253
+ getCurrentHost(): string {
254
+ return this.host;
255
+ }
256
+
257
+ getIdentityPath(): string {
258
+ return path.join(this.getIdentityDir(), "identity.json");
259
+ }
260
+
261
+ readSavedAvatarId(): string | null {
262
+ return this.loadIdentity()?.avatarId ?? null;
263
+ }
264
+
265
+ requestRejoin(): { accepted: boolean; mode: "sent" | "waiting_open" | "reconnecting" | "unavailable"; message: string } {
266
+ if (!this.identity?.avatarId || !this.identity?.authKey) {
267
+ return {
268
+ accepted: false,
269
+ mode: "unavailable",
270
+ message: "Missing authKey. Run kichi_join first.",
271
+ };
272
+ }
273
+
274
+ if (this.ws?.readyState === WebSocket.OPEN) {
275
+ const sent = this.sendRejoinPayload();
276
+ return {
277
+ accepted: sent,
278
+ mode: sent ? "sent" : "unavailable",
279
+ message: sent ? "Rejoin payload sent." : "Unable to send rejoin payload.",
280
+ };
281
+ }
282
+
283
+ if (this.ws?.readyState === WebSocket.CONNECTING) {
284
+ return {
285
+ accepted: true,
286
+ mode: "waiting_open",
287
+ message: "WebSocket is connecting. Rejoin will be sent automatically on open.",
288
+ };
289
+ }
290
+
291
+ if (this.stopped) {
292
+ return {
293
+ accepted: false,
294
+ mode: "unavailable",
295
+ message: "Service is not running.",
296
+ };
297
+ }
298
+
299
+ this.clearReconnectTimeout();
300
+ this.connect();
301
+ return {
302
+ accepted: true,
303
+ mode: "reconnecting",
304
+ message: "Reconnect started. Rejoin will be sent automatically on open.",
305
+ };
306
+ }
307
+
308
+ getConnectionStatus(): KichiConnectionStatus {
309
+ return {
310
+ host: this.host,
311
+ wsUrl: this.getWsUrl(),
312
+ identityPath: this.getIdentityPath(),
313
+ connected: this.isConnected(),
314
+ websocketState: this.getWebsocketState(),
315
+ hasIdentity: !!this.identity?.avatarId,
316
+ avatarId: this.identity?.avatarId,
317
+ hasAuthKey: !!this.identity?.authKey,
318
+ pendingRequestCount: this.pendingRequests.size,
319
+ reconnectScheduled: !!this.reconnectTimeout,
320
+ };
321
+ }
322
+
323
+ async leave(): Promise<LeaveResult> {
324
+ if (!this.identity?.avatarId || !this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
325
+ return { success: false, error: "Failed or not connected" };
326
+ }
327
+
328
+ return new Promise((resolve) => {
329
+ const handler = (data: WebSocket.Data) => {
330
+ try {
331
+ const msg = JSON.parse(data.toString());
332
+ if (msg.type === "leave_ack") {
333
+ this.ws?.off("message", handler);
334
+ const leaveAck = msg as LeaveAckPayload;
335
+ if (leaveAck.success === false) {
336
+ resolve(this.buildAckFailure(leaveAck, "Leave failed"));
337
+ return;
338
+ }
339
+ this.clearAuthKey();
340
+ resolve({ success: true });
341
+ }
342
+ } catch (e) {
343
+ this.logger.warn(`Failed to parse leave response: ${e}`);
344
+ }
345
+ };
346
+ this.ws!.on("message", handler);
347
+ this.ws!.send(
348
+ JSON.stringify({ type: "leave", avatarId: this.identity!.avatarId, authKey: this.identity!.authKey }),
349
+ );
350
+ setTimeout(() => {
351
+ this.ws?.off("message", handler);
352
+ resolve({ success: false, error: "Timed out waiting for leave_ack" });
353
+ }, 10000);
354
+ });
355
+ }
356
+
111
357
  private connect(): void {
112
358
  if (this.stopped) return;
113
- this.ws = new WebSocket(this.config.wsUrl);
114
- this.ws.on("open", () => {
115
- this.logger.info(`Connected to ${this.config.wsUrl}`);
359
+
360
+ const wsUrl = this.getWsUrl();
361
+ const ws = new WebSocket(wsUrl);
362
+ this.ws = ws;
363
+
364
+ ws.on("open", () => {
365
+ if (this.ws !== ws) return;
366
+ this.logger.info(`Connected to ${wsUrl} (${this.host})`);
116
367
  this.sendRejoinPayload();
117
368
  });
118
- this.ws.on("message", (data) => this.handleMessage(data.toString()));
119
- this.ws.on("close", () => {
369
+
370
+ ws.on("message", (data) => {
371
+ if (this.ws !== ws) return;
372
+ this.handleMessage(data.toString());
373
+ });
374
+
375
+ ws.on("close", () => {
376
+ if (this.ws !== ws) return;
120
377
  this.ws = null;
121
378
  this.rejectPendingRequests("Kichi websocket closed");
122
379
  if (!this.stopped) {
@@ -126,7 +383,8 @@ export class KichiForwarderService {
126
383
  }, 2000);
127
384
  }
128
385
  });
129
- this.ws.on("error", () => {});
386
+
387
+ ws.on("error", () => {});
130
388
  }
131
389
 
132
390
  private handleMessage(data: string): void {
@@ -266,8 +524,9 @@ export class KichiForwarderService {
266
524
 
267
525
  private loadIdentity(): KichiIdentity | null {
268
526
  try {
269
- if (!fs.existsSync(IDENTITY_PATH)) return null;
270
- const data = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8"));
527
+ const identityPath = this.getIdentityPath();
528
+ if (!fs.existsSync(identityPath)) return null;
529
+ const data = JSON.parse(fs.readFileSync(identityPath, "utf-8"));
271
530
  const avatarId = typeof data.avatarId === "string" && data.avatarId ? data.avatarId : null;
272
531
  if (avatarId) {
273
532
  return {
@@ -285,8 +544,10 @@ export class KichiForwarderService {
285
544
  private saveIdentity(): void {
286
545
  if (!this.identity?.avatarId) return;
287
546
  try {
288
- if (!fs.existsSync(IDENTITY_DIR)) fs.mkdirSync(IDENTITY_DIR, { recursive: true, mode: 0o700 });
289
- fs.writeFileSync(IDENTITY_PATH, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
547
+ const identityDir = this.getIdentityDir();
548
+ const identityPath = this.getIdentityPath();
549
+ if (!fs.existsSync(identityDir)) fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
550
+ fs.writeFileSync(identityPath, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
290
551
  } catch (e) {
291
552
  this.logger.error(`Failed to save identity: ${e}`);
292
553
  }
@@ -311,267 +572,91 @@ export class KichiForwarderService {
311
572
  return true;
312
573
  }
313
574
 
314
- sendStatus(poseType: PoseType | "", action: string, bubble: string, log: string): void {
315
- if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
316
- const payload: StatusPayload = {
317
- type: "status",
318
- avatarId: this.identity.avatarId,
319
- authKey: this.identity.authKey,
320
- poseType,
321
- action,
322
- bubble,
323
- log,
324
- };
325
- this.ws.send(JSON.stringify(payload));
326
- }
327
-
328
- sendAvatarCommand(command: AvatarCommand, bubble?: string, log?: string): boolean {
329
- const identity = this.requireIdentity();
330
- if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
331
-
332
- const payload: AvatarCommandPayload = {
333
- type: "avatar_command",
334
- avatarId: identity.avatarId,
335
- authKey: identity.authKey,
336
- command,
337
- ...(typeof bubble === "string" && bubble.trim() ? { bubble: bubble.trim() } : {}),
338
- ...(typeof log === "string" && log.trim() ? { log: log.trim() } : {}),
339
- };
340
- this.ws.send(JSON.stringify(payload));
341
- return true;
342
- }
575
+ private getWebsocketState(): KichiConnectionStatus["websocketState"] {
576
+ if (!this.ws) {
577
+ return "idle";
578
+ }
343
579
 
344
- sendHookNotify(hookType: HookNotifyType, bubble: string): void {
345
- if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
346
- const payload: HookNotifyPayload = {
347
- type: hookType,
348
- avatarId: this.identity.avatarId,
349
- authKey: this.identity.authKey,
350
- bubble,
351
- };
352
- this.ws.send(JSON.stringify(payload));
580
+ if (this.ws.readyState === WebSocket.CONNECTING) {
581
+ return "connecting";
582
+ }
583
+ if (this.ws.readyState === WebSocket.OPEN) {
584
+ return "open";
585
+ }
586
+ if (this.ws.readyState === WebSocket.CLOSING) {
587
+ return "closing";
588
+ }
589
+ return "closed";
353
590
  }
354
591
 
355
- sendWorkspaceScreen(text: string, ansi = true): boolean {
356
- const identity = this.requireIdentity();
357
- if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
358
- const payload: WorkspaceScreenPayload = {
359
- type: "workspace_screen",
360
- avatarId: identity.avatarId,
361
- authKey: identity.authKey,
362
- text,
363
- ansi,
364
- };
365
- this.ws.send(JSON.stringify(payload));
366
- return true;
592
+ private getIdentityDir(): string {
593
+ return path.join(HOSTS_DIR, encodeURIComponent(this.host));
367
594
  }
368
595
 
369
- sendClock(action: ClockAction, clock?: ClockConfig, requestId?: string): boolean {
370
- if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
371
- if (action === "set" && !clock) return false;
372
-
373
- const basePayload = {
374
- type: "clock" as const,
375
- avatarId: this.identity.avatarId,
376
- authKey: this.identity.authKey,
377
- ...(requestId ? { requestId } : {}),
378
- };
379
-
380
- const payload: ClockPayload =
381
- action === "set"
382
- ? {
383
- ...basePayload,
384
- action,
385
- clock,
386
- }
387
- : {
388
- ...basePayload,
389
- action,
390
- };
391
-
392
- this.ws.send(JSON.stringify(payload));
393
- return true;
596
+ private getWsUrl(): string {
597
+ const protocol = this.isPlainIpHost(this.host) || this.host === "localhost" ? "ws" : "wss";
598
+ return `${protocol}://${this.host}:48870/ws/openclaw`;
394
599
  }
395
600
 
396
- async queryStatus(requestId?: string): Promise<QueryStatusResultPayload> {
397
- const identity = this.requireIdentity();
398
- if (!identity) {
399
- throw new Error("Missing Kichi identity");
400
- }
401
-
402
- const payload: QueryStatusPayload = {
403
- type: "query_status",
404
- requestId: requestId?.trim() || randomUUID(),
405
- avatarId: identity.avatarId,
406
- authKey: identity.authKey,
407
- };
408
- return this.sendRequest<QueryStatusResultPayload>(payload, "query_status_result");
601
+ private isPlainIpHost(host: string): boolean {
602
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
603
+ || /^\[[0-9a-f:]+\]$/i.test(host)
604
+ || /^[0-9a-f:]+$/i.test(host);
409
605
  }
410
606
 
411
- createNotesBoardNote(propId: string, data: string): void {
412
- const identity = this.requireIdentity();
413
- if (!identity) {
414
- throw new Error("Missing Kichi identity");
415
- }
416
-
417
- if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
418
- throw new Error(`Note content must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`);
419
- }
420
-
421
- if (this.ws?.readyState !== WebSocket.OPEN) {
422
- throw new Error("Kichi websocket is not connected");
607
+ private loadCurrentHost(): string {
608
+ try {
609
+ if (!fs.existsSync(STATE_PATH)) {
610
+ this.persistCurrentHost(this.config.defaultHost);
611
+ return this.config.defaultHost;
612
+ }
613
+ const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as { currentHost?: unknown };
614
+ if (typeof data.currentHost === "string" && data.currentHost.trim()) {
615
+ return data.currentHost;
616
+ }
617
+ throw new Error(`Invalid currentHost value in ${STATE_PATH}`);
618
+ } catch (error) {
619
+ throw new Error(`Failed to load current host: ${error}`);
423
620
  }
424
-
425
- const payload: CreateNotesBoardNotePayload = {
426
- type: "create_notes_board_note",
427
- avatarId: identity.avatarId,
428
- authKey: identity.authKey,
429
- propId,
430
- data,
431
- };
432
- this.ws.send(JSON.stringify(payload));
433
621
  }
434
622
 
435
- createMusicAlbum(albumTitle: string, musicTitles: string[], requestId?: string): string {
436
- const identity = this.requireIdentity();
437
- if (!identity) {
438
- throw new Error("Missing Kichi identity");
439
- }
440
- if (!albumTitle.trim()) {
441
- throw new Error("albumTitle is required");
442
- }
443
- if (!Array.isArray(musicTitles) || musicTitles.length === 0) {
444
- throw new Error("musicTitles must contain at least one track title");
445
- }
446
- if (this.ws?.readyState !== WebSocket.OPEN) {
447
- throw new Error("Kichi websocket is not connected");
448
- }
449
-
450
- const normalizedRequestId = requestId?.trim() || randomUUID();
451
- const payload: CreateMusicAlbumPayload = {
452
- type: "create_music_album",
453
- requestId: normalizedRequestId,
454
- avatarId: identity.avatarId,
455
- authKey: identity.authKey,
456
- albumTitle: albumTitle.trim(),
457
- musicTitles,
623
+ private persistCurrentHost(host: string): void {
624
+ const previousState = this.readStateFile();
625
+ const nextState: KichiState = {
626
+ currentHost: host,
627
+ llmRuntimeEnabled: previousState?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED,
458
628
  };
459
- this.ws.send(JSON.stringify(payload));
460
- return normalizedRequestId;
629
+ fs.mkdirSync(KICHI_WORLD_DIR, { recursive: true, mode: 0o700 });
630
+ fs.writeFileSync(STATE_PATH, JSON.stringify(nextState, null, 2), { mode: 0o600 });
461
631
  }
462
632
 
463
- isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
464
-
465
- hasValidIdentity(): boolean { return !!this.identity?.avatarId && !!this.identity?.authKey; }
466
-
467
- requestRejoin(): { accepted: boolean; mode: "sent" | "waiting_open" | "reconnecting" | "unavailable"; message: string } {
468
- if (!this.identity?.avatarId || !this.identity?.authKey) {
469
- return {
470
- accepted: false,
471
- mode: "unavailable",
472
- message: "Missing authKey. Run kichi_join first.",
473
- };
474
- }
475
-
476
- if (this.ws?.readyState === WebSocket.OPEN) {
477
- const sent = this.sendRejoinPayload();
478
- return {
479
- accepted: sent,
480
- mode: sent ? "sent" : "unavailable",
481
- message: sent ? "Rejoin payload sent." : "Unable to send rejoin payload.",
482
- };
483
- }
484
-
485
- if (this.ws?.readyState === WebSocket.CONNECTING) {
486
- return {
487
- accepted: true,
488
- mode: "waiting_open",
489
- message: "WebSocket is connecting. Rejoin will be sent automatically on open.",
490
- };
491
- }
492
-
493
- if (this.stopped) {
494
- return {
495
- accepted: false,
496
- mode: "unavailable",
497
- message: "Service is not running.",
498
- };
633
+ private readStateFile(): Partial<KichiState> | null {
634
+ if (!fs.existsSync(STATE_PATH)) {
635
+ return null;
499
636
  }
500
-
501
- if (this.reconnectTimeout) {
502
- clearTimeout(this.reconnectTimeout);
503
- this.reconnectTimeout = null;
637
+ const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as unknown;
638
+ if (!data || typeof data !== "object") {
639
+ throw new Error(`Invalid state payload in ${STATE_PATH}`);
504
640
  }
505
-
506
- this.connect();
507
- return {
508
- accepted: true,
509
- mode: "reconnecting",
510
- message: "Reconnect started. Rejoin will be sent automatically on open.",
511
- };
641
+ return data as Partial<KichiState>;
512
642
  }
513
643
 
514
- getConnectionStatus(): KichiConnectionStatus {
515
- return {
516
- wsUrl: this.config.wsUrl,
517
- connected: this.isConnected(),
518
- websocketState: this.getWebsocketState(),
519
- hasIdentity: !!this.identity?.avatarId,
520
- avatarId: this.identity?.avatarId,
521
- hasAuthKey: !!this.identity?.authKey,
522
- pendingRequestCount: this.pendingRequests.size,
523
- reconnectScheduled: !!this.reconnectTimeout,
524
- };
644
+ private clearReconnectTimeout(): void {
645
+ if (!this.reconnectTimeout) return;
646
+ clearTimeout(this.reconnectTimeout);
647
+ this.reconnectTimeout = null;
525
648
  }
526
649
 
527
- private getWebsocketState(): KichiConnectionStatus["websocketState"] {
528
- if (!this.ws) {
529
- return "idle";
530
- }
531
-
532
- if (this.ws.readyState === WebSocket.CONNECTING) {
533
- return "connecting";
534
- }
535
- if (this.ws.readyState === WebSocket.OPEN) {
536
- return "open";
537
- }
538
- if (this.ws.readyState === WebSocket.CLOSING) {
539
- return "closing";
540
- }
541
- return "closed";
650
+ private closeSocket(): void {
651
+ const socket = this.ws;
652
+ this.ws = null;
653
+ socket?.removeAllListeners();
654
+ socket?.close();
542
655
  }
543
656
 
544
- async leave(): Promise<LeaveResult> {
545
- if (!this.identity?.avatarId || !this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
546
- return { success: false, error: "Failed or not connected" };
547
- }
548
-
549
- return new Promise((resolve) => {
550
- const handler = (data: WebSocket.Data) => {
551
- try {
552
- const msg = JSON.parse(data.toString());
553
- if (msg.type === "leave_ack") {
554
- this.ws?.off("message", handler);
555
- const leaveAck = msg as LeaveAckPayload;
556
- if (leaveAck.success === false) {
557
- resolve(this.buildAckFailure(leaveAck, "Leave failed"));
558
- return;
559
- }
560
- this.clearAuthKey();
561
- resolve({ success: true });
562
- }
563
- } catch (e) {
564
- this.logger.warn(`Failed to parse leave response: ${e}`);
565
- }
566
- };
567
- this.ws!.on("message", handler);
568
- this.ws!.send(
569
- JSON.stringify({ type: "leave", avatarId: this.identity!.avatarId, authKey: this.identity!.authKey }),
570
- );
571
- setTimeout(() => {
572
- this.ws?.off("message", handler);
573
- resolve({ success: false, error: "Timed out waiting for leave_ack" });
574
- }, 10000);
575
- });
657
+ private failPendingJoin(reason: string): void {
658
+ if (!this.joinResolve) return;
659
+ this.joinResolve({ success: false, error: reason });
660
+ this.joinResolve = null;
576
661
  }
577
662
  }