@yahaha-studio/kichi-forwarder 0.0.1-alpha.44 → 0.0.1-alpha.46

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
@@ -71,6 +71,21 @@ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
71
71
  const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
72
72
  const MESSAGE_RECEIVED_ELLIPSIS = "...";
73
73
  const BUNDLED_ALBUM_CONFIG_PATH = new URL("./config/album-config.json", import.meta.url);
74
+ const ANSI = {
75
+ reset: "\u001b[0m",
76
+ bold: "\u001b[1m",
77
+ dim: "\u001b[2m",
78
+ cyan: "\u001b[36m",
79
+ green: "\u001b[32m",
80
+ yellow: "\u001b[33m",
81
+ magenta: "\u001b[35m",
82
+ blue: "\u001b[34m",
83
+ gray: "\u001b[90m",
84
+ white: "\u001b[37m",
85
+ };
86
+ const WORKSPACE_SCREEN_WIDTH = 109;
87
+ const WORKSPACE_ACTIVITY_LIMIT = 8;
88
+ const WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS = 150;
74
89
  let cachedConfig: KichiRuntimeConfig | null = null;
75
90
  let cachedConfigMtime = 0;
76
91
  let cachedConfigPath = "";
@@ -78,6 +93,173 @@ let cachedAlbumConfig: Album | null = null;
78
93
  let cachedAlbumConfigMtime = 0;
79
94
  let service: KichiForwarderService | null = null;
80
95
  let pluginApi: OpenClawPluginApi | null = null;
96
+ let workspaceState: WorkspaceScreenState = createWorkspaceScreenState();
97
+ let workspacePushTimer: NodeJS.Timeout | null = null;
98
+
99
+ type WorkspaceScreenState = {
100
+ sessionLabel: string;
101
+ mode: string;
102
+ phase: string;
103
+ channel: string;
104
+ updatedAtLabel: string;
105
+ recentActivity: string[];
106
+ currentFocus: string;
107
+ hint: string;
108
+ prompt: string;
109
+ title: string;
110
+ shellName: string;
111
+ cwdLabel: string;
112
+ modelLabel: string;
113
+ };
114
+
115
+ function createWorkspaceScreenState(): WorkspaceScreenState {
116
+ return {
117
+ sessionLabel: "main",
118
+ mode: "idle",
119
+ phase: "waiting",
120
+ channel: "unknown",
121
+ updatedAtLabel: "just now",
122
+ recentActivity: [],
123
+ currentFocus: "Waiting for the next thread to pick up.",
124
+ hint: "Low-noise live workspace view.",
125
+ prompt: "$ _",
126
+ title: "Workspace",
127
+ shellName: "agent",
128
+ cwdLabel: process.cwd(),
129
+ modelLabel: "model: unknown",
130
+ };
131
+ }
132
+
133
+ function normalizeShellToken(value: string): string {
134
+ const normalized = value
135
+ .trim()
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9._-]+/g, "-")
138
+ .replace(/^-+|-+$/g, "");
139
+ return normalized || "agent";
140
+ }
141
+
142
+ function parseIdentityNameFromWorkspace(workspaceRoot: string): string | null {
143
+ try {
144
+ const identityPath = path.join(workspaceRoot, "IDENTITY.md");
145
+ if (!fs.existsSync(identityPath)) return null;
146
+ const raw = fs.readFileSync(identityPath, "utf-8");
147
+ const match = raw.match(/^-\s*\*\*Name:\*\*\s*(.+)$/m);
148
+ const value = match?.[1]?.trim();
149
+ return value || null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ function deriveWorkspaceIdentity(workspaceRoot: string): Pick<WorkspaceScreenState, "title" | "shellName" | "cwdLabel"> {
156
+ const identityName = parseIdentityNameFromWorkspace(workspaceRoot);
157
+ const titleBase = identityName || path.basename(workspaceRoot) || "workspace";
158
+ return {
159
+ title: `${titleBase} Workspace`,
160
+ shellName: normalizeShellToken(identityName || titleBase),
161
+ cwdLabel: workspaceRoot,
162
+ };
163
+ }
164
+
165
+ function nowLabel(): string {
166
+ const date = new Date();
167
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
168
+ }
169
+
170
+ function stripAnsi(text: string): string {
171
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
172
+ }
173
+
174
+ function visibleLength(text: string): number {
175
+ return Array.from(stripAnsi(text)).length;
176
+ }
177
+
178
+ function padVisible(text: string, width: number): string {
179
+ const pad = Math.max(0, width - visibleLength(text));
180
+ return text + " ".repeat(pad);
181
+ }
182
+
183
+ function truncatePlain(text: string, width: number): string {
184
+ const chars = Array.from(text);
185
+ if (chars.length <= width) return text;
186
+ if (width <= 1) return chars.slice(0, width).join("");
187
+ return chars.slice(0, width - 1).join("") + "…";
188
+ }
189
+
190
+ function color(text: string, tone: keyof typeof ANSI): string {
191
+ return `${ANSI[tone]}${text}${ANSI.reset}`;
192
+ }
193
+
194
+ function pushActivity(line: string): void {
195
+ if (!line.trim()) return;
196
+ const stamped = `[${nowLabel()}] ${line.trim()}`;
197
+ workspaceState.recentActivity.unshift(stamped);
198
+ workspaceState.recentActivity = workspaceState.recentActivity.slice(0, WORKSPACE_ACTIVITY_LIMIT);
199
+ workspaceState.updatedAtLabel = "just now";
200
+ }
201
+
202
+ function renderWorkspaceScreen(): string {
203
+ const innerWidth = WORKSPACE_SCREEN_WIDTH - 2;
204
+ const topTitle = `${color(` ${workspaceState.title} `, "bold")}${color("live session", "gray")}`;
205
+ const topLine = `╭─── ${topTitle}${"─".repeat(Math.max(0, innerWidth - 4 - visibleLength(topTitle)))}╮`;
206
+ const bottomLine = `╰${"─".repeat(innerWidth)}╯`;
207
+
208
+ const body: string[] = [];
209
+ const pushRow = (text = "") => {
210
+ body.push(`│${padVisible(text, innerWidth)}│`);
211
+ };
212
+
213
+ pushRow("");
214
+ pushRow(`${color(" Welcome back.", "white")}`);
215
+ pushRow(`${color(" Current session is active and mirrored as a terminal-style workspace.", "gray")}`);
216
+ pushRow("");
217
+ pushRow(`${color(" Recent activity", "cyan")}`);
218
+ for (const item of workspaceState.recentActivity.length ? workspaceState.recentActivity : ["[--:--:--] idle"] ) {
219
+ pushRow(` ${color("•", "green")} ${truncatePlain(item, innerWidth - 4)}`);
220
+ }
221
+ pushRow("");
222
+ pushRow(`${color(" Current focus", "cyan")}`);
223
+ pushRow(` ${truncatePlain(workspaceState.currentFocus, innerWidth - 2)}`);
224
+ pushRow("");
225
+ pushRow(`${color(" Status", "cyan")}`);
226
+ pushRow(` ${color("mode", "gray")} ${workspaceState.mode}`);
227
+ pushRow(` ${color("phase", "gray")} ${workspaceState.phase}`);
228
+ pushRow(` ${color("channel", "gray")} ${workspaceState.channel}`);
229
+ pushRow(` ${color("session", "gray")} ${workspaceState.sessionLabel}`);
230
+ pushRow("");
231
+ pushRow(`${color(" Hint", "cyan")}`);
232
+ pushRow(` ${truncatePlain(workspaceState.hint, innerWidth - 2)}`);
233
+ pushRow("");
234
+ pushRow(` ${color(workspaceState.modelLabel, "magenta")}`);
235
+ pushRow(` ${color(workspaceState.cwdLabel, "blue")}`);
236
+ pushRow(` ${color(`updated: ${workspaceState.updatedAtLabel}`, "gray")}`);
237
+ pushRow("");
238
+
239
+ return [
240
+ topLine,
241
+ ...body,
242
+ bottomLine,
243
+ "",
244
+ color("─".repeat(WORKSPACE_SCREEN_WIDTH), "gray"),
245
+ `${color(workspaceState.shellName, "green")}:${color("~", "blue")} ${workspaceState.prompt}`,
246
+ ].join("\n");
247
+ }
248
+
249
+ function scheduleWorkspacePush(): void {
250
+ if (!service?.hasValidIdentity() || !service?.isConnected()) return;
251
+ if (workspacePushTimer) clearTimeout(workspacePushTimer);
252
+ workspacePushTimer = setTimeout(() => {
253
+ workspacePushTimer = null;
254
+ service?.sendWorkspaceScreen(renderWorkspaceScreen(), true);
255
+ }, WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS);
256
+ }
257
+
258
+ function updateWorkspace(partial: Partial<WorkspaceScreenState>, activity?: string): void {
259
+ workspaceState = { ...workspaceState, ...partial, updatedAtLabel: "just now" };
260
+ if (activity) pushActivity(activity);
261
+ scheduleWorkspacePush();
262
+ }
81
263
 
82
264
  function isAlbumConfig(value: unknown): value is Album {
83
265
  if (!value || typeof value !== "object") {
@@ -297,6 +479,16 @@ async function handleMessageReceivedHook(content: string): Promise<void> {
297
479
  }
298
480
  const trimmed = truncateByDisplayWidth(content, MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH);
299
481
  service.sendHookNotify("message_received", `"${trimmed}"`);
482
+ updateWorkspace(
483
+ {
484
+ mode: "reading",
485
+ phase: "processing inbound message",
486
+ currentFocus: trimmed ? `User message: ${trimmed}` : "Reading the latest message.",
487
+ hint: "Inbound message updated the live workspace.",
488
+ prompt: "$ reading-message",
489
+ },
490
+ `received message: ${trimmed || "(empty)"}`,
491
+ );
300
492
  }
301
493
 
302
494
  function handleMessageSentHook(): void {
@@ -304,10 +496,30 @@ function handleMessageSentHook(): void {
304
496
  return;
305
497
  }
306
498
  service.sendHookNotify("before_send_message", pickRandomAction(MESSAGE_SENT_BUBBLES));
499
+ updateWorkspace(
500
+ {
501
+ mode: "sent",
502
+ phase: "message delivered",
503
+ currentFocus: "Latest reply has been sent to the active chat.",
504
+ hint: "Workspace settles after delivery.",
505
+ prompt: "$ idle",
506
+ },
507
+ "sent assistant reply",
508
+ );
307
509
  }
308
510
 
309
511
  function registerPluginHooks(api: OpenClawPluginApi): void {
310
512
  api.on("before_prompt_build", () => {
513
+ updateWorkspace(
514
+ {
515
+ mode: "thinking",
516
+ phase: "building prompt",
517
+ currentFocus: "Preparing the next response from current session context.",
518
+ hint: "Prompt assembly is in progress.",
519
+ prompt: "$ build-prompt",
520
+ },
521
+ "building prompt context",
522
+ );
311
523
  if (!service?.hasValidIdentity() || !service?.isConnected()) {
312
524
  return;
313
525
  }
@@ -320,13 +532,65 @@ function registerPluginHooks(api: OpenClawPluginApi): void {
320
532
  };
321
533
  });
322
534
 
323
- api.on("before_tool_call", () => {
535
+ api.on("llm_input", (event) => {
536
+ updateWorkspace(
537
+ {
538
+ mode: "thinking",
539
+ phase: `llm input · ${event.model}`,
540
+ currentFocus: "Feeding the model the current thread and constraints.",
541
+ hint: `provider: ${event.provider} · images: ${event.imagesCount}`,
542
+ prompt: "$ llm-input",
543
+ modelLabel: `${event.provider}/${event.model}`,
544
+ },
545
+ `entered llm input: ${event.model}`,
546
+ );
547
+ });
548
+
549
+ api.on("llm_output", (event) => {
550
+ updateWorkspace(
551
+ {
552
+ mode: "writing",
553
+ phase: `llm output · ${event.model}`,
554
+ currentFocus: "Shaping model output into the visible reply.",
555
+ hint: `assistant chunks: ${event.assistantTexts.length}`,
556
+ prompt: "$ draft-reply",
557
+ modelLabel: `${event.provider}/${event.model}`,
558
+ },
559
+ `received llm output: ${event.model}`,
560
+ );
561
+ });
562
+
563
+ api.on("before_tool_call", (event, ctx) => {
564
+ updateWorkspace(
565
+ {
566
+ mode: "tool",
567
+ phase: `running ${event.toolName}`,
568
+ currentFocus: `Tool call in flight: ${event.toolName}`,
569
+ hint: `tool context: ${ctx.toolName}`,
570
+ prompt: `$ tool ${event.toolName}`,
571
+ },
572
+ `tool start: ${event.toolName}`,
573
+ );
324
574
  if (!isLlmRuntimeEnabled()) {
325
575
  syncFixedStatus(FIXED_HOOK_STATUSES.beforeToolCall);
326
576
  }
327
577
  });
328
578
 
329
- api.on("message_received", async (event) => {
579
+ api.on("after_tool_call", (event) => {
580
+ updateWorkspace(
581
+ {
582
+ mode: "thinking",
583
+ phase: `tool finished ${event.toolName}`,
584
+ currentFocus: `Tool result returned from ${event.toolName}.`,
585
+ hint: event.error ? `tool error: ${event.error}` : `tool completed in ${event.durationMs ?? 0}ms`,
586
+ prompt: `$ continue ${event.toolName}`,
587
+ },
588
+ event.error ? `tool error: ${event.toolName}` : `tool done: ${event.toolName}`,
589
+ );
590
+ });
591
+
592
+ api.on("message_received", async (event, ctx) => {
593
+ workspaceState.channel = ctx.channelId || workspaceState.channel;
330
594
  await handleMessageReceivedHook(event.content);
331
595
  });
332
596
 
@@ -335,6 +599,16 @@ function registerPluginHooks(api: OpenClawPluginApi): void {
335
599
  });
336
600
 
337
601
  api.on("agent_end", (event) => {
602
+ updateWorkspace(
603
+ {
604
+ mode: event.success ? "idle" : "error",
605
+ phase: event.success ? "run complete" : "run failed",
606
+ currentFocus: event.success ? "Run complete. Waiting for the next thread." : `Run failed: ${event.error ?? "unknown error"}`,
607
+ hint: `duration: ${event.durationMs ?? 0}ms`,
608
+ prompt: event.success ? "$ _" : "$ recover",
609
+ },
610
+ event.success ? "agent run complete" : "agent run failed",
611
+ );
338
612
  if (isLlmRuntimeEnabled()) {
339
613
  return;
340
614
  }
@@ -596,6 +870,13 @@ const plugin = {
596
870
  ctx.config.plugins?.entries?.["kichi-forwarder"]?.config,
597
871
  ) as KichiForwarderConfig;
598
872
  service = new KichiForwarderService(cfg, api.logger);
873
+ const workspaceRoot = ctx.repoPath ?? "/Users/xiaoxinshi/.openclaw/workspace";
874
+ workspaceState = {
875
+ ...createWorkspaceScreenState(),
876
+ ...deriveWorkspaceIdentity(workspaceRoot),
877
+ };
878
+ workspaceState.channel = ctx.channelId ?? "unknown";
879
+ scheduleWorkspacePush();
599
880
  return service.start();
600
881
  },
601
882
  stop: () => service?.stop(),
@@ -656,6 +937,7 @@ const plugin = {
656
937
  return { success: false, error: "Kichi service is not initialized" };
657
938
  }
658
939
  if (result.success) {
940
+ scheduleWorkspacePush();
659
941
  return { success: true, authKey: result.authKey };
660
942
  }
661
943
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/kichi-forwarder",
3
- "version": "0.0.1-alpha.44",
3
+ "version": "0.0.1-alpha.46",
4
4
  "description": "Forward OpenClaw agent events to external WebSocket server for visualization",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/service.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  QueryStatusPayload,
23
23
  QueryStatusResultPayload,
24
24
  StatusPayload,
25
+ WorkspaceScreenPayload,
25
26
  } from "./types.js";
26
27
 
27
28
  const IDENTITY_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
@@ -110,7 +111,6 @@ export class KichiForwarderService {
110
111
  this.ws = new WebSocket(this.config.wsUrl);
111
112
  this.ws.on("open", () => {
112
113
  this.logger.info(`Connected to ${this.config.wsUrl}`);
113
- // Automatically send rejoin when a valid identity is available.
114
114
  this.sendRejoinPayload();
115
115
  });
116
116
  this.ws.on("message", (data) => this.handleMessage(data.toString()));
@@ -150,7 +150,6 @@ export class KichiForwarderService {
150
150
  this.joinResolve?.({ success: true, authKey: joinAck.authKey });
151
151
  this.joinResolve = null;
152
152
  } else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
153
- // AuthKey invalid/expired, clear it
154
153
  this.logger.warn(`Auth failed: ${msg.reason || "unknown"}`);
155
154
  this.clearAuthKey();
156
155
  } else if (msg.type === "leave_ack") {
@@ -335,6 +334,20 @@ export class KichiForwarderService {
335
334
  this.ws.send(JSON.stringify(payload));
336
335
  }
337
336
 
337
+ sendWorkspaceScreen(text: string, ansi = true): boolean {
338
+ const identity = this.requireIdentity();
339
+ if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
340
+ const payload: WorkspaceScreenPayload = {
341
+ type: "workspace_screen",
342
+ avatarId: identity.avatarId,
343
+ authKey: identity.authKey,
344
+ text,
345
+ ansi,
346
+ };
347
+ this.ws.send(JSON.stringify(payload));
348
+ return true;
349
+ }
350
+
338
351
  sendClock(action: ClockAction, clock?: ClockConfig, requestId?: string): boolean {
339
352
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
340
353
  if (action === "set" && !clock) return false;
package/src/types.ts CHANGED
@@ -105,6 +105,14 @@ export type HookNotifyPayload = {
105
105
  bubble: string;
106
106
  };
107
107
 
108
+ export type WorkspaceScreenPayload = {
109
+ type: "workspace_screen";
110
+ avatarId: string;
111
+ authKey: string;
112
+ text: string;
113
+ ansi?: boolean;
114
+ };
115
+
108
116
  export type ClockAction = "set" | "stop";
109
117
 
110
118
  export type ClockMode = "pomodoro" | "countDown" | "countUp";