pi-keyboard 0.1.0

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 ADDED
@@ -0,0 +1,102 @@
1
+ # pi-keyboard
2
+
3
+ Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi's internal states.
4
+
5
+ Ambient physical awareness of what your AI coding agent is doing right on your mechanical keyboard:
6
+
7
+ | Pi State | Color & Effect | Trigger Condition |
8
+ | :--- | :--- | :--- |
9
+ | **Idle** | **White Breathing** (白色 呼吸灯) | Session started, ready & waiting for user input |
10
+ | **Thinking** | **Blue Breathing** (蓝色 呼吸灯) | Model generating, reasoning, or executing tools |
11
+ | **Unread Chat** | **Green Breathing** (绿色 呼吸灯) | Agent settled with a completed response for user |
12
+ | **Need Approval** | **Yellow Blinking** (黄色 闪烁) | User prompt, question, or interactive permission gate |
13
+ | **Error** | **Red Blinking** (红色 闪烁) | API failure, tool error, or abort |
14
+
15
+ ---
16
+
17
+ ## Zero Flash Wear (`--no-save`)
18
+
19
+ All hardware updates are executed strictly in memory (`--no-save` mode). Lighting changes modify active RAM state on the keyboard's MCU without writing to EEPROM/Flash, avoiding storage wear across thousands of state transitions.
20
+
21
+ ---
22
+
23
+ ## Multi-Zone Control
24
+
25
+ Supports discrete control for keyboard lighting channels:
26
+ - **All Zones** (`all`): Both per-key backlight and underglow/side strips.
27
+ - **Per-Key Matrix** (`matrix`): Keycap backlight (Channel 3 in VIA/QMK).
28
+ - **Underglow / Side Strips** (`underglow`): Bottom and edge light strips (Channel 2 in VIA/QMK).
29
+
30
+ ---
31
+
32
+ ## Installation
33
+
34
+ Install from this monorepo or local path:
35
+
36
+ ```bash
37
+ pi install ~/Developer/FradSer/pi-packages/packages/keyboard
38
+ ```
39
+
40
+ When published to npm:
41
+
42
+ ```bash
43
+ pi install npm:pi-keyboard
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Usage
49
+
50
+ ### Interactive Menu
51
+
52
+ Type `/keyboard` in any Pi session:
53
+ - **Toggle On / Off**
54
+ - **Test Lighting States** (preview Idle, Thinking, Unread, Approval, Error)
55
+ - **Active Zone Selection** (`all`, `matrix`, `underglow`)
56
+ - **Brightness Scaling** (100%, 75%, 50%, 25%)
57
+ - **Hardware Connection Info**
58
+
59
+ ### CLI Commands inside Pi
60
+
61
+ ```bash
62
+ /keyboard on # Enable lighting indicators
63
+ /keyboard off # Disable lighting indicators
64
+ /keyboard status # View active status and hardware info
65
+ /keyboard test thinking # Test blue thinking light
66
+ /keyboard test error # Test red blinking light
67
+ /keyboard test unread # Test green unread chat light
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Standalone CLI: `via-rgb`
73
+
74
+ This package coordinates with the native macOS/Linux `via-rgb` binary (`~/.local/bin/via-rgb`), allowing shell scripts, cron jobs, Raycast, and Hammerspoon to control lighting outside Pi as well:
75
+
76
+ ```bash
77
+ # Query status
78
+ via-rgb status
79
+
80
+ # Turn off / on
81
+ via-rgb off
82
+ via-rgb on -b 200
83
+
84
+ # Set specific color & zone
85
+ via-rgb --zone matrix set -c blue
86
+ via-rgb --zone side set -c warmwhite
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Configuration
92
+
93
+ Settings are saved in `~/.pi/agent/keyboard.json`:
94
+
95
+ ```json
96
+ {
97
+ "enabled": true,
98
+ "zone": "all",
99
+ "brightnessScale": 1.0,
100
+ "saveToEeprom": false
101
+ }
102
+ ```
@@ -0,0 +1,102 @@
1
+ # pi-keyboard
2
+
3
+ 基于 VIA/QMK 协议的 Pi 键盘 RGB 状态指示灯插件。
4
+
5
+ 通过机械键盘的 RGB 灯光,实时提供 AI Coding Agent 运行状态的环境感知:
6
+
7
+ | Pi 运行状态 | 灯光颜色与效果 | 触发时机 |
8
+ | :--- | :--- | :--- |
9
+ | **Idle (待命)** | **白色 呼吸灯** | 启动完成、等待用户输入新任务 |
10
+ | **Thinking (运行)** | **蓝色 呼吸灯** | 模型生成中、深度思考中或正在执行工具 |
11
+ | **Unread Chat (未读)** | **绿色 呼吸灯** | Agent 回答完成,等待用户查阅 |
12
+ | **Need Approval (确认)** | **黄色 闪烁** | 提问、等待用户输入或交互式权限确认 |
13
+ | **Error (异常)** | **红色 闪烁** | API 报错、工具执行失败或异常中断 |
14
+
15
+ ---
16
+
17
+ ## 零 Flash 损耗设计(`--no-save`)
18
+
19
+ 所有状态变更均以 `--no-save` 纯内存模式向键盘 MCU 发送指令,**不写入 Flash / EEPROM**。即使每天触发数千次思考与交互状态切换,也不会产生硬件擦写寿命损耗。
20
+
21
+ ---
22
+
23
+ ## 多分区独立控制
24
+
25
+ 支持精准控制键盘的不同发光区域:
26
+ - **全部区域** (`all`):按键背光与侧边底灯联动。
27
+ - **按键背光矩阵** (`matrix`):仅控制键帽背光(QMK 通道 3)。
28
+ - **侧边底灯** (`underglow`):仅控制外壳侧边/底部灯带(QMK 通道 2)。
29
+
30
+ ---
31
+
32
+ ## 安装方式
33
+
34
+ 从本地仓库安装:
35
+
36
+ ```bash
37
+ pi install ~/Developer/FradSer/pi-packages/packages/keyboard
38
+ ```
39
+
40
+ 当发布到 npm 后也可直接安装:
41
+
42
+ ```bash
43
+ pi install npm:pi-keyboard
44
+ ```
45
+
46
+ ---
47
+
48
+ ## 使用指南
49
+
50
+ ### 交互式菜单
51
+
52
+ 在 Pi 对话框中输入 `/keyboard`:
53
+ - **开关切换**:开启或关闭状态指示灯
54
+ - **状态效果测试**:预览测试待命、思考、未读、确认、错误各状态
55
+ - **活动分区配置**:选择全部、仅背光或仅侧灯
56
+ - **亮度比例调节**:100%、75%、50%、25% 亮度随心调节
57
+ - **硬件连接信息**:查看识别到的键盘型号与 VIA 协议版本
58
+
59
+ ### 对话内快捷指令
60
+
61
+ ```bash
62
+ /keyboard on # 启用指示灯
63
+ /keyboard off # 关闭指示灯
64
+ /keyboard status # 查看当前配置与键盘硬件状态
65
+ /keyboard test thinking # 预览测试蓝色思考呼吸灯
66
+ /keyboard test error # 预览测试红色报错闪烁灯
67
+ /keyboard test unread # 预览测试绿色未读呼吸灯
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 独立命令行工具:`via-rgb`
73
+
74
+ 本插件配套安装了系统级命令 `via-rgb`(位于 `~/.local/bin/via-rgb`),在 Pi 之外也可用于 Shell、Raycast、Hammerspoon 或睡眠唤醒脚本:
75
+
76
+ ```bash
77
+ # 查询当前状态
78
+ via-rgb status
79
+
80
+ # 开关灯光
81
+ via-rgb off
82
+ via-rgb on -b 200
83
+
84
+ # 自定义颜色与分区
85
+ via-rgb --zone matrix set -c blue
86
+ via-rgb --zone side set -c warmwhite
87
+ ```
88
+
89
+ ---
90
+
91
+ ## 配置文件
92
+
93
+ 配置自动保存在 `~/.pi/agent/keyboard.json`:
94
+
95
+ ```json
96
+ {
97
+ "enabled": true,
98
+ "zone": "all",
99
+ "brightnessScale": 1.0,
100
+ "saveToEeprom": false
101
+ }
102
+ ```
@@ -0,0 +1,112 @@
1
+ Feature: Pi Keyboard Lighting Indicator
2
+ As a Pi developer
3
+ I want my VIA/QMK keyboard lighting to dynamically reflect Pi's internal states
4
+ So that I have ambient physical awareness of agent status without looking at the terminal
5
+
6
+ Background:
7
+ Given a VIA-compatible keyboard with Raw-HID support
8
+ And the keyboard has RGB lighting configuration
9
+
10
+ Scenario: Pi transitions to idle state with white breathing light
11
+ Given Pi is in session startup or waiting for new user input
12
+ And no other Pi session is currently running
13
+ When the keyboard state machine updates to "idle"
14
+ Then the keyboard lighting is set to white color
15
+ And the effect mode is set to breathing
16
+ And the update is applied immediately in memory without saving to EEPROM
17
+
18
+ Scenario: Pi transitions to unread chat state with green breathing light
19
+ Given the agent has finished generating a response
20
+ And the user has not yet submitted new input or activated the thread
21
+ When the agent settles with a completed message
22
+ Then the keyboard state machine updates to "unread_chat"
23
+ And the keyboard lighting is set to green color
24
+ And the effect mode is set to breathing
25
+ And the update is applied with --no-save in memory
26
+
27
+ Scenario: User activates thread and marks message as read
28
+ Given the keyboard is currently displaying unread chat state
29
+ When the user activates or focuses the terminal window
30
+ And no other Pi sessions are running
31
+ Then the unread status is cleared
32
+ And the keyboard lighting transitions to white idle breathing
33
+
34
+ Scenario: User activates thread while another session is running
35
+ Given the keyboard is currently displaying unread chat state
36
+ When the user activates or focuses the terminal window
37
+ And another background Pi session is currently running
38
+ Then the unread status is cleared
39
+ And the keyboard lighting transitions to blue thinking breathing
40
+
41
+ Scenario: Pi transitions to thinking state with blue breathing light
42
+ Given a user has submitted a prompt or the agent is executing a turn
43
+ When the agent starts thinking or executing tools
44
+ Then the keyboard state machine updates to "thinking"
45
+ And the keyboard lighting is set to blue color
46
+ And the effect mode is set to breathing
47
+ And the update is applied with --no-save in memory
48
+
49
+ Scenario: Pi transitions to need approval state with yellow blinking light
50
+ Given a tool call requires user confirmation or approval
51
+ When an interactive gate or question is prompted
52
+ Then the keyboard state machine updates to "need_approval"
53
+ And the keyboard lighting is set to yellow color
54
+ And the effect mode is set to blinking alert
55
+ And the update is applied with --no-save in memory
56
+
57
+ Scenario: Pi transitions to error state with red blinking light
58
+ Given an agent execution error, API failure, or turn error occurs
59
+ When a fatal error event is captured
60
+ Then the keyboard state machine updates to "error"
61
+ And the keyboard lighting is set to red color
62
+ And the effect mode is set to blinking alert
63
+ And the update is applied with --no-save in memory
64
+
65
+ Scenario: Non-fatal tool errors do not trigger red blinking light
66
+ Given the agent executes a bash command or test that returns a non-zero exit code
67
+ When the tool result arrives during ongoing turn reasoning
68
+ Then the keyboard lighting remains in "thinking" blue breathing state
69
+ And it does not trigger the red error alert
70
+
71
+ Scenario: Upstream provider rate limit (429) triggers red blinking error light
72
+ Given the model provider returns an HTTP 429 quota error or 500 failure
73
+ When the provider error response is received
74
+ Then the keyboard state machine transitions to "error" red blinking
75
+ And the subsequent agent settle event does not overwrite it with green
76
+
77
+ Scenario: User submits input and clears unread chat status
78
+ Given the keyboard is currently displaying unread chat state
79
+ When the user submits a new prompt in the conversation
80
+ Then the unread status is cleared
81
+ And the keyboard state machine transitions to "thinking"
82
+
83
+ Scenario: Target lighting zone selection
84
+ Given the user configures the target zone
85
+ When the zone is set to "matrix"
86
+ Then only channel 3 (key backlight) is updated
87
+ When the zone is set to "underglow"
88
+ Then only channel 2 (side strip) is updated
89
+ When the zone is set to "all"
90
+ Then both channel 2 and channel 3 are updated
91
+
92
+ Scenario: In-memory updates without EEPROM wear
93
+ Given any state transition is dispatched to hardware
94
+ When the HID command is executed
95
+ Then it operates strictly in RAM using no-save mode without sending save command 0x09
96
+
97
+ Scenario: State change deduplication prevents redundant HID writes
98
+ Given the keyboard is already displaying "thinking" state
99
+ When another turn or tool call event occurs while still "thinking"
100
+ Then redundant hardware HID commands are skipped
101
+
102
+ Scenario: Keyboard disconnection handling
103
+ Given the keyboard is unplugged or unavailable
104
+ When a state transition occurs
105
+ Then the driver fails gracefully without throwing unhandled exceptions or crashing Pi
106
+
107
+ Scenario: /keyboard command allows manual state testing and toggle
108
+ Given the /keyboard command is executed
109
+ When the user toggles lighting to "off"
110
+ Then all keyboard lighting updates are disabled
111
+ When the user runs "/keyboard test thinking"
112
+ Then the keyboard lighting displays the blue thinking state
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "pi-keyboard",
3
+ "version": "0.1.0",
4
+ "description": "Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi states (idle, thinking, unread, need-approval, error)",
5
+ "author": "Frad LEE <fradser@gmail.com>",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "keywords": [
9
+ "pi-package",
10
+ "keyboard",
11
+ "lighting",
12
+ "via",
13
+ "qmk",
14
+ "rgb-status",
15
+ "ambient-indicator"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/FradSer/pi-packages.git"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "features",
27
+ "README.md",
28
+ "README.zh-CN.md"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ]
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*"
38
+ }
39
+ }
package/src/config.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { KeyboardConfig, KeyboardZone } from "./types";
5
+
6
+ export const DEFAULT_KEYBOARD_CONFIG: KeyboardConfig = {
7
+ enabled: true,
8
+ zone: "all",
9
+ brightnessScale: 1.0,
10
+ saveToEeprom: false, // strictly in-memory (--no-save) by default
11
+ };
12
+
13
+ export function keyboardConfigPath(): string {
14
+ return join(homedir(), ".pi", "agent", "keyboard.json");
15
+ }
16
+
17
+ export function readKeyboardConfig(): KeyboardConfig {
18
+ const filePath = keyboardConfigPath();
19
+ if (!existsSync(filePath)) {
20
+ return { ...DEFAULT_KEYBOARD_CONFIG };
21
+ }
22
+
23
+ try {
24
+ const raw = readFileSync(filePath, "utf-8");
25
+ const parsed = JSON.parse(raw);
26
+ if (!parsed || typeof parsed !== "object") {
27
+ return { ...DEFAULT_KEYBOARD_CONFIG };
28
+ }
29
+
30
+ const zone: KeyboardZone =
31
+ parsed.zone === "underglow" || parsed.zone === "matrix" || parsed.zone === "all"
32
+ ? parsed.zone
33
+ : DEFAULT_KEYBOARD_CONFIG.zone;
34
+
35
+ const brightnessScale =
36
+ typeof parsed.brightnessScale === "number" &&
37
+ parsed.brightnessScale >= 0.1 &&
38
+ parsed.brightnessScale <= 1.0
39
+ ? parsed.brightnessScale
40
+ : DEFAULT_KEYBOARD_CONFIG.brightnessScale;
41
+
42
+ return {
43
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_KEYBOARD_CONFIG.enabled,
44
+ zone,
45
+ brightnessScale,
46
+ saveToEeprom:
47
+ typeof parsed.saveToEeprom === "boolean"
48
+ ? parsed.saveToEeprom
49
+ : DEFAULT_KEYBOARD_CONFIG.saveToEeprom,
50
+ cliPath: typeof parsed.cliPath === "string" ? parsed.cliPath : undefined,
51
+ };
52
+ } catch {
53
+ return { ...DEFAULT_KEYBOARD_CONFIG };
54
+ }
55
+ }
56
+
57
+ export function writeKeyboardConfig(config: KeyboardConfig): void {
58
+ const filePath = keyboardConfigPath();
59
+ try {
60
+ mkdirSync(dirname(filePath), { recursive: true });
61
+ writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
62
+ } catch {
63
+ // Non-fatal config write failure
64
+ }
65
+ }
package/src/driver.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import {
7
+ KEYBOARD_STATE_DEFINITIONS,
8
+ type KeyboardConfig,
9
+ type KeyboardDeviceInfo,
10
+ type KeyboardState,
11
+ } from "./types";
12
+
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ let activeBlinkTimer: NodeJS.Timeout | null = null;
16
+ let commandQueue: Promise<void> = Promise.resolve();
17
+
18
+ export function resolveCliPath(customPath?: string): string {
19
+ if (customPath && existsSync(customPath)) {
20
+ return customPath;
21
+ }
22
+
23
+ const standardPaths = [
24
+ join(homedir(), ".local", "bin", "via-rgb"),
25
+ "/opt/homebrew/bin/via-rgb",
26
+ "/usr/local/bin/via-rgb",
27
+ ];
28
+
29
+ for (const p of standardPaths) {
30
+ if (existsSync(p)) {
31
+ return p;
32
+ }
33
+ }
34
+
35
+ return "via-rgb"; // fallback to PATH lookup
36
+ }
37
+
38
+ export interface HardwareApplyResult {
39
+ success: boolean;
40
+ state: KeyboardState;
41
+ zone: string;
42
+ error?: string;
43
+ }
44
+
45
+ export async function applyKeyboardState(
46
+ state: KeyboardState,
47
+ config: KeyboardConfig,
48
+ ): Promise<HardwareApplyResult> {
49
+ if (!config.enabled) {
50
+ return { success: true, state, zone: config.zone };
51
+ }
52
+
53
+ const def = KEYBOARD_STATE_DEFINITIONS[state];
54
+ if (!def) {
55
+ return { success: false, state, zone: config.zone, error: `Unknown state: ${state}` };
56
+ }
57
+
58
+ // Clear any ongoing software blink
59
+ if (activeBlinkTimer) {
60
+ clearInterval(activeBlinkTimer);
61
+ activeBlinkTimer = null;
62
+ }
63
+
64
+ const targetBrightness = Math.round(def.brightness * config.brightnessScale);
65
+ const cli = resolveCliPath(config.cliPath);
66
+
67
+ const baseArgs = ["--zone", config.zone];
68
+ if (!config.saveToEeprom) {
69
+ baseArgs.push("--no-save");
70
+ }
71
+
72
+ const task = async (): Promise<HardwareApplyResult> => {
73
+ try {
74
+ // Set color, effect, speed, brightness in memory
75
+ const setArgs = [
76
+ ...baseArgs,
77
+ "set",
78
+ "-b",
79
+ String(targetBrightness),
80
+ "-e",
81
+ String(def.effect),
82
+ "-s",
83
+ String(def.speed),
84
+ "-c",
85
+ `${def.hue} ${def.sat}`,
86
+ ];
87
+
88
+ await execFileAsync(cli, setArgs, { timeout: 3000 });
89
+
90
+ // If state specifies a blinking alert (yellow / red), run a short asynchronous strobe
91
+ if (def.pattern === "blinking" && (def.blinkCount ?? 0) > 0) {
92
+ startSoftwareStrobe(cli, baseArgs, targetBrightness, def.blinkCount ?? 4);
93
+ }
94
+
95
+ return { success: true, state, zone: config.zone };
96
+ } catch (err: unknown) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return { success: false, state, zone: config.zone, error: msg };
99
+ }
100
+ };
101
+
102
+ // Queue to ensure strictly ordered HID writes
103
+ commandQueue = commandQueue.then(
104
+ async () => {
105
+ await task();
106
+ },
107
+ async () => {
108
+ await task();
109
+ },
110
+ );
111
+ await commandQueue;
112
+ return { success: true, state, zone: config.zone };
113
+ }
114
+
115
+ function startSoftwareStrobe(
116
+ cli: string,
117
+ baseArgs: string[],
118
+ maxBrightness: number,
119
+ cycles: number,
120
+ ): void {
121
+ let step = 0;
122
+ const totalSteps = cycles * 2;
123
+
124
+ activeBlinkTimer = setInterval(() => {
125
+ step++;
126
+ const isBright = step % 2 === 0;
127
+ const brightness = isBright ? maxBrightness : 0;
128
+
129
+ execFile(cli, [...baseArgs, "set", "-b", String(brightness)], { timeout: 1500 }, () => {});
130
+
131
+ if (step >= totalSteps) {
132
+ if (activeBlinkTimer) {
133
+ clearInterval(activeBlinkTimer);
134
+ activeBlinkTimer = null;
135
+ }
136
+ // Restore target brightness at the end
137
+ execFile(cli, [...baseArgs, "set", "-b", String(maxBrightness)], { timeout: 1500 }, () => {});
138
+ }
139
+ }, 180);
140
+ }
141
+
142
+ export async function fetchKeyboardStatus(
143
+ config: KeyboardConfig,
144
+ ): Promise<{
145
+ connected: boolean;
146
+ device?: KeyboardDeviceInfo;
147
+ protocolVersion?: string;
148
+ error?: string;
149
+ }> {
150
+ const cli = resolveCliPath(config.cliPath);
151
+ try {
152
+ const { stdout } = await execFileAsync(cli, ["--json", "status"], { timeout: 3000 });
153
+ const parsed = JSON.parse(stdout);
154
+ return {
155
+ connected: true,
156
+ device: parsed.device,
157
+ protocolVersion: parsed.protocol_version,
158
+ };
159
+ } catch (err: unknown) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ return {
162
+ connected: false,
163
+ error: msg,
164
+ };
165
+ }
166
+ }
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export interface SessionRecord {
6
+ sessionId: string;
7
+ pid: number;
8
+ cwd: string;
9
+ status: "running" | "idle" | "settled" | "exited";
10
+ updatedAt: number;
11
+ }
12
+
13
+ export function getRegistryDir(): string {
14
+ return path.join(os.homedir(), ".pi", "agent", "directory-sessions");
15
+ }
16
+
17
+ export function isProcessAlive(pid: number): boolean {
18
+ if (!pid || pid <= 0) return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Scans all directory session registries to check if any other session is currently running.
29
+ * Automatically checks PID vitality and excludes dead or stale processes.
30
+ */
31
+ export function hasOtherRunningSessions(excludePid: number = process.pid): boolean {
32
+ const baseDir = getRegistryDir();
33
+ if (!fs.existsSync(baseDir)) return false;
34
+
35
+ const now = Date.now();
36
+ const maxStaleAgeMs = 5 * 60 * 1000; // 5 minutes max stale threshold
37
+
38
+ try {
39
+ const dirEntries = fs.readdirSync(baseDir, { withFileTypes: true });
40
+ for (const dirEntry of dirEntries) {
41
+ if (!dirEntry.isDirectory()) continue;
42
+
43
+ const subDirPath = path.join(baseDir, dirEntry.name);
44
+ let sessionFiles: string[] = [];
45
+ try {
46
+ sessionFiles = fs.readdirSync(subDirPath);
47
+ } catch {
48
+ continue;
49
+ }
50
+
51
+ for (const file of sessionFiles) {
52
+ if (!file.endsWith(".json")) continue;
53
+ const filePath = path.join(subDirPath, file);
54
+
55
+ try {
56
+ const content = fs.readFileSync(filePath, "utf-8");
57
+ const record = JSON.parse(content) as SessionRecord;
58
+
59
+ if (!record || typeof record !== "object") continue;
60
+ if (record.pid === excludePid) continue;
61
+
62
+ // Check if process is alive and actively running
63
+ if (record.status === "running" && isProcessAlive(record.pid)) {
64
+ if (now - (record.updatedAt || 0) < maxStaleAgeMs) {
65
+ return true;
66
+ }
67
+ }
68
+ } catch {
69
+ // Ignore unreadable or corrupt temp files
70
+ }
71
+ }
72
+ }
73
+ } catch {
74
+ return false;
75
+ }
76
+
77
+ return false;
78
+ }
package/src/index.ts ADDED
@@ -0,0 +1,260 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { readKeyboardConfig, writeKeyboardConfig } from "./config";
6
+ import { fetchKeyboardStatus } from "./driver";
7
+ import { KeyboardStateMachine } from "./state-machine";
8
+ import {
9
+ KEYBOARD_STATE_DEFINITIONS,
10
+ type KeyboardConfig,
11
+ type KeyboardState,
12
+ type KeyboardZone,
13
+ } from "./types";
14
+
15
+ let stateMachine: KeyboardStateMachine | null = null;
16
+
17
+ function getStateMachine(): KeyboardStateMachine {
18
+ if (!stateMachine) {
19
+ const config = readKeyboardConfig();
20
+ stateMachine = new KeyboardStateMachine(config);
21
+ }
22
+ return stateMachine;
23
+ }
24
+
25
+ export default function (pi: ExtensionAPI): void {
26
+ const sm = getStateMachine();
27
+ let terminalInputDetached: (() => void) | undefined;
28
+
29
+ // Pi Lifecycle Hooks
30
+ pi.on("session_start", async (_event, ctx) => {
31
+ // Attach passive terminal input / focus observer to detect thread activation
32
+ if (ctx.hasUI && typeof ctx.ui.onTerminalInput === "function" && !terminalInputDetached) {
33
+ terminalInputDetached = ctx.ui.onTerminalInput((_data: string) => {
34
+ if (sm.isUnread()) {
35
+ void sm.onUserActivated();
36
+ }
37
+ return undefined; // Passive observer: never consume or block input
38
+ });
39
+ }
40
+
41
+ await sm.onSessionStart();
42
+ });
43
+
44
+ pi.on("agent_start", async (_event, _ctx) => {
45
+ await sm.onAgentStart();
46
+ });
47
+
48
+ pi.on("turn_start", async (_event, _ctx) => {
49
+ await sm.onTurnStart();
50
+ });
51
+
52
+ pi.on("after_provider_response", async (event, _ctx) => {
53
+ // Detect upstream provider errors (429 rate limit, 401 auth, 500 error, etc.)
54
+ if (event.status && event.status >= 400) {
55
+ await sm.onProviderResponse(event.status);
56
+ }
57
+ });
58
+
59
+ pi.on("tool_call", async (event, _ctx) => {
60
+ const input = event.input && typeof event.input === "object" ? (event.input as Record<string, unknown>) : undefined;
61
+ await sm.onToolCall(event.toolName, input);
62
+ });
63
+
64
+ pi.on("tool_result", async (_event, _ctx) => {
65
+ await sm.onToolResult();
66
+ });
67
+
68
+ pi.on("turn_end", async (event, _ctx) => {
69
+ const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
70
+ if (msg?.stopReason === "error" || msg?.stopReason === "aborted" || Boolean(msg?.errorMessage)) {
71
+ await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
72
+ }
73
+ });
74
+
75
+ pi.on("message_end", async (event, _ctx) => {
76
+ const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
77
+ if (msg?.stopReason === "error" || msg?.stopReason === "aborted" || Boolean(msg?.errorMessage)) {
78
+ await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
79
+ }
80
+ });
81
+
82
+ pi.on("agent_settled", async (_event, ctx) => {
83
+ let hasError = sm.hasError();
84
+
85
+ // Inspect session branch entries to detect trailing assistant errors
86
+ const branch = ctx.sessionManager?.getBranch?.();
87
+ if (branch && branch.length > 0) {
88
+ for (let i = branch.length - 1; i >= 0; i--) {
89
+ const entry = branch[i];
90
+ if (entry && entry.type === "message" && entry.message && entry.message.role === "assistant") {
91
+ const msg = entry.message as { stopReason?: string; errorMessage?: string };
92
+ if (msg.stopReason === "error" || msg.stopReason === "aborted" || Boolean(msg.errorMessage)) {
93
+ hasError = true;
94
+ }
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ await sm.onAgentSettled(hasError);
101
+ });
102
+
103
+ pi.on("input", async (_event, _ctx) => {
104
+ await sm.onUserInput();
105
+ });
106
+
107
+ pi.on("session_shutdown", async (_event, _ctx) => {
108
+ terminalInputDetached?.();
109
+ terminalInputDetached = undefined;
110
+ await sm.onShutdown();
111
+ });
112
+
113
+ // Register /keyboard Command & Menu
114
+ pi.registerCommand("keyboard", {
115
+ description: "Manage VIA/QMK keyboard RGB lighting status indicators",
116
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
117
+ const trimmed = args.trim().toLowerCase();
118
+
119
+ if (trimmed === "on") {
120
+ sm.updateConfig({ enabled: true });
121
+ writeKeyboardConfig(sm.getConfig());
122
+ ctx.ui.notify("Keyboard lighting indicator enabled", "info");
123
+ return;
124
+ }
125
+
126
+ if (trimmed === "off") {
127
+ sm.updateConfig({ enabled: false });
128
+ writeKeyboardConfig(sm.getConfig());
129
+ ctx.ui.notify("Keyboard lighting indicator disabled", "info");
130
+ return;
131
+ }
132
+
133
+ if (trimmed === "status") {
134
+ await showStatus(ctx, sm);
135
+ return;
136
+ }
137
+
138
+ if (trimmed.startsWith("test")) {
139
+ const parts = trimmed.split(/\s+/);
140
+ const targetState = (parts[1] || "thinking") as KeyboardState;
141
+ if (targetState in KEYBOARD_STATE_DEFINITIONS) {
142
+ await sm.transitionTo(targetState, true);
143
+ ctx.ui.notify(`Testing state: ${KEYBOARD_STATE_DEFINITIONS[targetState].labelZh}`, "info");
144
+ } else {
145
+ ctx.ui.notify(`Unknown test state. Options: ${Object.keys(KEYBOARD_STATE_DEFINITIONS).join(", ")}`, "error");
146
+ }
147
+ return;
148
+ }
149
+
150
+ // Interactive Menu
151
+ await openKeyboardMenu(ctx, sm);
152
+ },
153
+ });
154
+ }
155
+
156
+ async function showStatus(ctx: ExtensionCommandContext, sm: KeyboardStateMachine): Promise<void> {
157
+ const config = sm.getConfig();
158
+ const hw = await fetchKeyboardStatus(config);
159
+ const current = sm.getCurrentState();
160
+
161
+ const lines = [
162
+ `Indicator Status : ${config.enabled ? "Enabled (On)" : "Disabled (Off)"}`,
163
+ `Current State : ${sm.getStateLabel(current)}`,
164
+ `Active Zone : ${config.zone.toUpperCase()}`,
165
+ `Brightness Scale : ${Math.round(config.brightnessScale * 100)}%`,
166
+ `Hardware Status : ${hw.connected ? `Connected (${hw.device?.product || "VIA Keyboard"})` : "Not connected / Not detected"}`,
167
+ ];
168
+
169
+ ctx.ui.notify(lines.join("\n"), "info");
170
+ }
171
+
172
+ async function openKeyboardMenu(
173
+ ctx: ExtensionCommandContext,
174
+ sm: KeyboardStateMachine,
175
+ ): Promise<void> {
176
+ const config = sm.getConfig();
177
+ const current = sm.getCurrentState();
178
+
179
+ const toggleOption = config.enabled ? "Disable lighting indicator" : "Enable lighting indicator";
180
+ const mainOptions = [
181
+ toggleOption,
182
+ "Test Lighting States (Idle, Thinking, Unread, Approval, Error)",
183
+ `Set Active Zone (Current: ${config.zone.toUpperCase()})`,
184
+ `Set Brightness Scale (Current: ${Math.round(config.brightnessScale * 100)}%)`,
185
+ "View Hardware Status",
186
+ ];
187
+
188
+ const choice = await ctx.ui.select(
189
+ `Keyboard Lighting [${config.enabled ? "ON" : "OFF"} · State: ${sm.getStateLabel(current)}]`,
190
+ mainOptions,
191
+ );
192
+
193
+ if (!choice) return;
194
+
195
+ if (choice === toggleOption) {
196
+ const next = !config.enabled;
197
+ sm.updateConfig({ enabled: next });
198
+ writeKeyboardConfig(sm.getConfig());
199
+ ctx.ui.notify(`Keyboard lighting indicator is now ${next ? "ON" : "OFF"}`, "info");
200
+ return;
201
+ }
202
+
203
+ if (choice.startsWith("Test Lighting States")) {
204
+ const stateKeys = Object.keys(KEYBOARD_STATE_DEFINITIONS) as KeyboardState[];
205
+ const stateOptions = stateKeys.map((st) => KEYBOARD_STATE_DEFINITIONS[st].labelZh);
206
+ const stateChoice = await ctx.ui.select("Select a state to test on keyboard:", stateOptions);
207
+ if (stateChoice) {
208
+ const idx = stateOptions.indexOf(stateChoice);
209
+ const chosenState = stateKeys[idx];
210
+ if (chosenState) {
211
+ await sm.transitionTo(chosenState, true);
212
+ ctx.ui.notify(`Applied state: ${KEYBOARD_STATE_DEFINITIONS[chosenState].labelZh}`, "info");
213
+ }
214
+ }
215
+ return;
216
+ }
217
+
218
+ if (choice.startsWith("Set Active Zone")) {
219
+ const zoneOptions = [
220
+ "All Zones (Both Backlight & Underglow)",
221
+ "Per-Key Backlight Matrix (Channel 3)",
222
+ "Underglow / Side Strips (Channel 2)",
223
+ ];
224
+ const zoneChoice = await ctx.ui.select("Select target lighting zone:", zoneOptions);
225
+ if (zoneChoice) {
226
+ const zoneMap: Record<string, KeyboardZone> = {
227
+ "All Zones (Both Backlight & Underglow)": "all",
228
+ "Per-Key Backlight Matrix (Channel 3)": "matrix",
229
+ "Underglow / Side Strips (Channel 2)": "underglow",
230
+ };
231
+ const zone = zoneMap[zoneChoice] || "all";
232
+ sm.updateConfig({ zone });
233
+ writeKeyboardConfig(sm.getConfig());
234
+ ctx.ui.notify(`Active zone set to: ${zone.toUpperCase()}`, "info");
235
+ }
236
+ return;
237
+ }
238
+
239
+ if (choice.startsWith("Set Brightness Scale")) {
240
+ const scaleOptions = ["100% Brightness", "75% Brightness", "50% Brightness", "25% Brightness"];
241
+ const scaleChoice = await ctx.ui.select("Select brightness scale:", scaleOptions);
242
+ if (scaleChoice) {
243
+ const scaleMap: Record<string, number> = {
244
+ "100% Brightness": 1.0,
245
+ "75% Brightness": 0.75,
246
+ "50% Brightness": 0.50,
247
+ "25% Brightness": 0.25,
248
+ };
249
+ const scale = scaleMap[scaleChoice] ?? 1.0;
250
+ sm.updateConfig({ brightnessScale: scale });
251
+ writeKeyboardConfig(sm.getConfig());
252
+ ctx.ui.notify(`Brightness scale set to ${Math.round(scale * 100)}%`, "info");
253
+ }
254
+ return;
255
+ }
256
+
257
+ if (choice === "View Hardware Status") {
258
+ await showStatus(ctx, sm);
259
+ }
260
+ }
@@ -0,0 +1,62 @@
1
+ import type { KeyboardZone } from "./types";
2
+
3
+ export const VIA_RAW_USAGE_PAGE = 0xff60;
4
+ export const VIA_RAW_USAGE = 0x0061;
5
+
6
+ export const CMD_GET_PROTOCOL_VERSION = 0x01;
7
+ export const CMD_CUSTOM_SET_VALUE = 0x07;
8
+ export const CMD_CUSTOM_GET_VALUE = 0x08;
9
+ export const CMD_CUSTOM_SAVE = 0x09;
10
+
11
+ export const CH_RGBLIGHT = 2; // Zone 1: Side strip / Underglow
12
+ export const CH_RGB_MATRIX = 3; // Zone 2: Per-key RGB Matrix
13
+
14
+ export const VAL_BRIGHTNESS = 1;
15
+ export const VAL_EFFECT = 2;
16
+ export const VAL_EFFECT_SPEED = 3;
17
+ export const VAL_COLOR = 4;
18
+
19
+ export function resolveChannels(zone: KeyboardZone): number[] {
20
+ switch (zone) {
21
+ case "underglow":
22
+ return [CH_RGBLIGHT];
23
+ case "matrix":
24
+ return [CH_RGB_MATRIX];
25
+ case "all":
26
+ default:
27
+ return [CH_RGBLIGHT, CH_RGB_MATRIX];
28
+ }
29
+ }
30
+
31
+ export function buildPacket(payload: number[]): Uint8Array {
32
+ const packet = new Uint8Array(32);
33
+ for (let i = 0; i < payload.length && i < 32; i++) {
34
+ packet[i] = payload[i];
35
+ }
36
+ return packet;
37
+ }
38
+
39
+ export function buildSetBrightnessPacket(channel: number, brightness: number): Uint8Array {
40
+ const val = Math.max(0, Math.min(255, Math.round(brightness)));
41
+ return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_BRIGHTNESS, val]);
42
+ }
43
+
44
+ export function buildSetEffectPacket(channel: number, effect: number): Uint8Array {
45
+ const val = Math.max(0, Math.min(255, Math.round(effect)));
46
+ return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_EFFECT, val]);
47
+ }
48
+
49
+ export function buildSetSpeedPacket(channel: number, speed: number): Uint8Array {
50
+ const val = Math.max(0, Math.min(255, Math.round(speed)));
51
+ return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_EFFECT_SPEED, val]);
52
+ }
53
+
54
+ export function buildSetColorPacket(channel: number, hue: number, sat: number): Uint8Array {
55
+ const h = Math.max(0, Math.min(255, Math.round(hue)));
56
+ const s = Math.max(0, Math.min(255, Math.round(sat)));
57
+ return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_COLOR, h, s]);
58
+ }
59
+
60
+ export function buildSavePacket(channel: number): Uint8Array {
61
+ return buildPacket([CMD_CUSTOM_SAVE, channel]);
62
+ }
@@ -0,0 +1,176 @@
1
+ import { applyKeyboardState } from "./driver";
2
+ import { hasOtherRunningSessions } from "./global-sessions";
3
+ import {
4
+ KEYBOARD_STATE_DEFINITIONS,
5
+ type KeyboardConfig,
6
+ type KeyboardState,
7
+ } from "./types";
8
+
9
+ export class KeyboardStateMachine {
10
+ private currentState: KeyboardState = "idle";
11
+ private isProcessing = false;
12
+ private hasUnreadChat = false;
13
+ private hasFatalError = false;
14
+
15
+ constructor(
16
+ private config: KeyboardConfig,
17
+ private checkOtherRunning: () => boolean = () => hasOtherRunningSessions(),
18
+ ) {}
19
+
20
+ public getConfig(): KeyboardConfig {
21
+ return { ...this.config };
22
+ }
23
+
24
+ public updateConfig(newConfig: Partial<KeyboardConfig>): void {
25
+ this.config = { ...this.config, ...newConfig };
26
+ this.applyCurrentState(true);
27
+ }
28
+
29
+ public getCurrentState(): KeyboardState {
30
+ return this.currentState;
31
+ }
32
+
33
+ public isUnread(): boolean {
34
+ return this.hasUnreadChat;
35
+ }
36
+
37
+ public hasError(): boolean {
38
+ return this.hasFatalError;
39
+ }
40
+
41
+ public getStateLabel(state: KeyboardState = this.currentState): string {
42
+ return KEYBOARD_STATE_DEFINITIONS[state]?.labelZh ?? state;
43
+ }
44
+
45
+ public async transitionTo(nextState: KeyboardState, force = false): Promise<void> {
46
+ if (this.currentState === nextState && !force) {
47
+ return;
48
+ }
49
+ this.currentState = nextState;
50
+ await this.applyCurrentState(force);
51
+ }
52
+
53
+ private async applyCurrentState(force = false): Promise<void> {
54
+ await applyKeyboardState(this.currentState, this.config);
55
+ }
56
+
57
+ public async onSessionStart(): Promise<void> {
58
+ this.isProcessing = false;
59
+ this.hasUnreadChat = false;
60
+ this.hasFatalError = false;
61
+
62
+ if (this.checkOtherRunning()) {
63
+ await this.transitionTo("thinking");
64
+ } else {
65
+ await this.transitionTo("idle");
66
+ }
67
+ }
68
+
69
+ public async onAgentStart(): Promise<void> {
70
+ this.isProcessing = true;
71
+ this.hasUnreadChat = false;
72
+ this.hasFatalError = false;
73
+ await this.transitionTo("thinking");
74
+ }
75
+
76
+ public async onTurnStart(): Promise<void> {
77
+ this.isProcessing = true;
78
+ this.hasUnreadChat = false;
79
+ this.hasFatalError = false;
80
+ await this.transitionTo("thinking");
81
+ }
82
+
83
+ public async onToolCall(toolName: string, _input?: Record<string, unknown>): Promise<void> {
84
+ this.isProcessing = true;
85
+ const isInteractive =
86
+ toolName.includes("confirm") ||
87
+ toolName.includes("ask") ||
88
+ toolName.includes("prompt") ||
89
+ toolName === "question";
90
+
91
+ if (isInteractive) {
92
+ await this.transitionTo("need_approval");
93
+ } else {
94
+ await this.transitionTo("thinking");
95
+ }
96
+ }
97
+
98
+ public async onToolResult(): Promise<void> {
99
+ if (this.isProcessing) {
100
+ await this.transitionTo("thinking");
101
+ }
102
+ }
103
+
104
+ public async onProviderResponse(status?: number): Promise<void> {
105
+ if (typeof status === "number" && status >= 400) {
106
+ this.hasFatalError = true;
107
+ this.isProcessing = false;
108
+ this.hasUnreadChat = false;
109
+ await this.transitionTo("error");
110
+ }
111
+ }
112
+
113
+ public async onMessageEnd(stopReason?: string, errorMessage?: string): Promise<void> {
114
+ if (stopReason === "error" || stopReason === "aborted" || Boolean(errorMessage)) {
115
+ this.hasFatalError = true;
116
+ this.isProcessing = false;
117
+ this.hasUnreadChat = false;
118
+ await this.transitionTo("error");
119
+ }
120
+ }
121
+
122
+ public async onAgentSettled(hasError = false): Promise<void> {
123
+ this.isProcessing = false;
124
+ if (hasError || this.hasFatalError) {
125
+ this.hasFatalError = true;
126
+ this.hasUnreadChat = false;
127
+ await this.transitionTo("error");
128
+ } else {
129
+ this.hasUnreadChat = true;
130
+ await this.transitionTo("unread_chat");
131
+ }
132
+ }
133
+
134
+ public async onUserActivated(): Promise<void> {
135
+ if (!this.hasUnreadChat) {
136
+ return;
137
+ }
138
+ this.hasUnreadChat = false;
139
+
140
+ if (this.hasFatalError) {
141
+ await this.transitionTo("error");
142
+ return;
143
+ }
144
+
145
+ if (this.isProcessing || this.checkOtherRunning()) {
146
+ await this.transitionTo("thinking");
147
+ } else {
148
+ await this.transitionTo("idle");
149
+ }
150
+ }
151
+
152
+ public async onUserInput(): Promise<void> {
153
+ this.hasUnreadChat = false;
154
+ this.hasFatalError = false;
155
+ if (this.isProcessing || this.checkOtherRunning()) {
156
+ await this.transitionTo("thinking");
157
+ } else {
158
+ await this.transitionTo("idle");
159
+ }
160
+ }
161
+
162
+ public async onError(): Promise<void> {
163
+ this.isProcessing = false;
164
+ this.hasFatalError = true;
165
+ this.hasUnreadChat = false;
166
+ await this.transitionTo("error");
167
+ }
168
+
169
+ public async onShutdown(): Promise<void> {
170
+ if (this.checkOtherRunning()) {
171
+ await this.transitionTo("thinking");
172
+ } else {
173
+ await this.transitionTo("idle");
174
+ }
175
+ }
176
+ }
package/src/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ export type KeyboardState =
2
+ | "idle"
3
+ | "unread_chat"
4
+ | "thinking"
5
+ | "need_approval"
6
+ | "error";
7
+
8
+ export type KeyboardZone = "all" | "underglow" | "matrix";
9
+
10
+ export type KeyboardPattern = "breathing" | "blinking" | "solid";
11
+
12
+ export interface KeyboardStateDefinition {
13
+ state: KeyboardState;
14
+ label: string;
15
+ labelZh: string;
16
+ hue: number;
17
+ sat: number;
18
+ effect: number; // 1 = solid, 2 = breathing
19
+ speed: number;
20
+ brightness: number;
21
+ pattern: KeyboardPattern;
22
+ blinkCount?: number;
23
+ }
24
+
25
+ export interface KeyboardConfig {
26
+ enabled: boolean;
27
+ zone: KeyboardZone;
28
+ brightnessScale: number; // 0.1 to 1.0 (default 1.0)
29
+ saveToEeprom: boolean; // default false: strictly in-memory (--no-save)
30
+ cliPath?: string;
31
+ }
32
+
33
+ export interface KeyboardDeviceInfo {
34
+ vendorId: string;
35
+ productId: string;
36
+ product?: string;
37
+ manufacturer?: string;
38
+ }
39
+
40
+ export const KEYBOARD_STATE_DEFINITIONS: Record<KeyboardState, KeyboardStateDefinition> = {
41
+ idle: {
42
+ state: "idle",
43
+ label: "White Breathing (Idle)",
44
+ labelZh: "白色 呼吸灯 (空闲待命)",
45
+ hue: 0,
46
+ sat: 0, // White
47
+ effect: 2, // Breathing
48
+ speed: 100,
49
+ brightness: 180,
50
+ pattern: "breathing",
51
+ },
52
+ unread_chat: {
53
+ state: "unread_chat",
54
+ label: "Green Breathing (Unread Chat)",
55
+ labelZh: "绿色 呼吸灯 (未读消息)",
56
+ hue: 85, // Green
57
+ sat: 255,
58
+ effect: 2, // Breathing
59
+ speed: 150,
60
+ brightness: 220,
61
+ pattern: "breathing",
62
+ },
63
+ thinking: {
64
+ state: "thinking",
65
+ label: "Blue Breathing (Thinking)",
66
+ labelZh: "蓝色 呼吸灯 (正在思考/执行)",
67
+ hue: 170, // Blue
68
+ sat: 255,
69
+ effect: 2, // Breathing
70
+ speed: 200,
71
+ brightness: 255,
72
+ pattern: "breathing",
73
+ },
74
+ need_approval: {
75
+ state: "need_approval",
76
+ label: "Yellow Blinking (Need Approval / Question)",
77
+ labelZh: "黄色 闪烁 (需要确认/提问)",
78
+ hue: 43, // Yellow
79
+ sat: 255,
80
+ effect: 1, // Alert / Fast pulse
81
+ speed: 255,
82
+ brightness: 255,
83
+ pattern: "blinking",
84
+ blinkCount: 4,
85
+ },
86
+ error: {
87
+ state: "error",
88
+ label: "Red Blinking (Error)",
89
+ labelZh: "红色 闪烁 (异常错误)",
90
+ hue: 0, // Red
91
+ sat: 255,
92
+ effect: 1, // Alert / Fast pulse
93
+ speed: 255,
94
+ brightness: 255,
95
+ pattern: "blinking",
96
+ blinkCount: 4,
97
+ },
98
+ };