pi-keyboard 0.1.0 → 0.1.1
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/features/keyboard.feature +16 -0
- package/package.json +1 -1
- package/src/global-sessions.ts +177 -34
- package/src/index.ts +34 -5
- package/src/state-machine.ts +95 -44
|
@@ -27,6 +27,7 @@ Feature: Pi Keyboard Lighting Indicator
|
|
|
27
27
|
Scenario: User activates thread and marks message as read
|
|
28
28
|
Given the keyboard is currently displaying unread chat state
|
|
29
29
|
When the user activates or focuses the terminal window
|
|
30
|
+
And no other Pi sessions have unread messages
|
|
30
31
|
And no other Pi sessions are running
|
|
31
32
|
Then the unread status is cleared
|
|
32
33
|
And the keyboard lighting transitions to white idle breathing
|
|
@@ -34,10 +35,25 @@ Feature: Pi Keyboard Lighting Indicator
|
|
|
34
35
|
Scenario: User activates thread while another session is running
|
|
35
36
|
Given the keyboard is currently displaying unread chat state
|
|
36
37
|
When the user activates or focuses the terminal window
|
|
38
|
+
And no other Pi sessions have unread messages
|
|
37
39
|
And another background Pi session is currently running
|
|
38
40
|
Then the unread status is cleared
|
|
39
41
|
And the keyboard lighting transitions to blue thinking breathing
|
|
40
42
|
|
|
43
|
+
Scenario: All sessions must be read for unread green light to clear
|
|
44
|
+
Given multiple Pi sessions exist across terminal tabs
|
|
45
|
+
And one session finishes generating an unread response
|
|
46
|
+
When the user activates one session but another session still has unread messages
|
|
47
|
+
Then the keyboard lighting remains in green unread chat breathing state
|
|
48
|
+
When all active sessions are read or activated
|
|
49
|
+
Then the keyboard lighting transitions out of unread chat to idle or thinking
|
|
50
|
+
|
|
51
|
+
Scenario: User manual abort does not trigger red error light
|
|
52
|
+
Given an agent turn is actively executing
|
|
53
|
+
When the user cancels or aborts the turn via Ctrl+C or abort
|
|
54
|
+
Then the keyboard lighting does not trigger red error alert
|
|
55
|
+
And the lighting state transitions cleanly back to idle or thinking
|
|
56
|
+
|
|
41
57
|
Scenario: Pi transitions to thinking state with blue breathing light
|
|
42
58
|
Given a user has submitted a prompt or the agent is executing a turn
|
|
43
59
|
When the agent starts thinking or executing tools
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-keyboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi states (idle, thinking, unread, need-approval, error)",
|
|
5
5
|
"author": "Frad LEE <fradser@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
package/src/global-sessions.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import type { KeyboardState } from "./types";
|
|
4
5
|
|
|
5
|
-
export interface
|
|
6
|
+
export interface SessionGlowRecord {
|
|
6
7
|
sessionId: string;
|
|
7
8
|
pid: number;
|
|
8
9
|
cwd: string;
|
|
9
|
-
status: "running" | "idle" | "settled" | "exited";
|
|
10
|
+
status: "running" | "idle" | "settled" | "error" | "need_approval" | "exited";
|
|
11
|
+
hasUnread: boolean;
|
|
10
12
|
updatedAt: number;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -14,6 +16,15 @@ export function getRegistryDir(): string {
|
|
|
14
16
|
return path.join(os.homedir(), ".pi", "agent", "directory-sessions");
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
export function getSessionFileKey(cwd: string): string {
|
|
20
|
+
const normalized = path.resolve(cwd || process.cwd());
|
|
21
|
+
return `--${normalized.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDirectoryRegistryPath(cwd: string): string {
|
|
25
|
+
return path.join(getRegistryDir(), getSessionFileKey(cwd));
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
export function isProcessAlive(pid: number): boolean {
|
|
18
29
|
if (!pid || pid <= 0) return false;
|
|
19
30
|
try {
|
|
@@ -25,54 +36,186 @@ export function isProcessAlive(pid: number): boolean {
|
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
/**
|
|
28
|
-
*
|
|
29
|
-
* Automatically checks PID vitality and excludes dead or stale processes.
|
|
39
|
+
* Writes or updates the session's active glow and unread state in the registry.
|
|
30
40
|
*/
|
|
31
|
-
export function
|
|
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
|
-
|
|
41
|
+
export function writeSessionGlowState(record: SessionGlowRecord): void {
|
|
38
42
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
const dir = getDirectoryRegistryPath(record.cwd);
|
|
44
|
+
if (!fs.existsSync(dir)) {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
const filePath = path.join(dir, `${record.sessionId}.json`);
|
|
48
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
// Merge with existing session data if present to avoid overwriting other fields
|
|
51
|
+
let merged: Record<string, unknown> = { ...record };
|
|
52
|
+
if (fs.existsSync(filePath)) {
|
|
45
53
|
try {
|
|
46
|
-
|
|
54
|
+
const existing = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
55
|
+
if (existing && typeof existing === "object") {
|
|
56
|
+
merged = { ...existing, ...record };
|
|
57
|
+
}
|
|
47
58
|
} catch {
|
|
48
|
-
|
|
59
|
+
// use fresh record
|
|
49
60
|
}
|
|
61
|
+
}
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
64
|
+
fs.renameSync(tmpPath, filePath);
|
|
65
|
+
} catch {
|
|
66
|
+
// Best-effort write
|
|
67
|
+
}
|
|
68
|
+
}
|
|
54
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Removes the session record upon clean shutdown.
|
|
72
|
+
*/
|
|
73
|
+
export function removeSessionGlowState(cwd: string, sessionId: string): void {
|
|
74
|
+
try {
|
|
75
|
+
const filePath = path.join(getDirectoryRegistryPath(cwd), `${sessionId}.json`);
|
|
76
|
+
if (fs.existsSync(filePath)) {
|
|
77
|
+
fs.unlinkSync(filePath);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Best-effort remove
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GlobalStateSummary {
|
|
85
|
+
effectiveState: KeyboardState;
|
|
86
|
+
hasAnyUnread: boolean;
|
|
87
|
+
hasAnyRunning: boolean;
|
|
88
|
+
hasAnyError: boolean;
|
|
89
|
+
hasAnyNeedApproval: boolean;
|
|
90
|
+
activeSessionCount: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads all active sessions across all project directories, prunes dead PIDs,
|
|
95
|
+
* and determines the single authoritative global lighting state.
|
|
96
|
+
*
|
|
97
|
+
* Precedence:
|
|
98
|
+
* 1. Fatal Error (any session has fatal error) -> "error" (Red Blinking)
|
|
99
|
+
* 2. Need Approval (any session is waiting for user confirmation) -> "need_approval" (Yellow Blinking)
|
|
100
|
+
* 3. Unread Chat (ANY active session has unread messages) -> "unread_chat" (Green Breathing)
|
|
101
|
+
* 4. Thinking / Running (any session is actively executing) -> "thinking" (Blue Breathing)
|
|
102
|
+
* 5. Idle (all sessions are read and idle) -> "idle" (White Breathing)
|
|
103
|
+
*/
|
|
104
|
+
export function evaluateGlobalLightingState(
|
|
105
|
+
selfSessionId?: string,
|
|
106
|
+
selfRecord?: Partial<SessionGlowRecord>,
|
|
107
|
+
): GlobalStateSummary {
|
|
108
|
+
const baseDir = getRegistryDir();
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const maxStaleAgeMs = 5 * 60 * 1000; // 5 minutes
|
|
111
|
+
|
|
112
|
+
const allRecords: SessionGlowRecord[] = [];
|
|
113
|
+
|
|
114
|
+
if (fs.existsSync(baseDir)) {
|
|
115
|
+
try {
|
|
116
|
+
const dirEntries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
117
|
+
for (const dirEntry of dirEntries) {
|
|
118
|
+
if (!dirEntry.isDirectory()) continue;
|
|
119
|
+
|
|
120
|
+
const subDirPath = path.join(baseDir, dirEntry.name);
|
|
121
|
+
let sessionFiles: string[] = [];
|
|
55
122
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
123
|
+
sessionFiles = fs.readdirSync(subDirPath);
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const file of sessionFiles) {
|
|
129
|
+
if (!file.endsWith(".json")) continue;
|
|
130
|
+
const filePath = path.join(subDirPath, file);
|
|
58
131
|
|
|
59
|
-
|
|
60
|
-
|
|
132
|
+
try {
|
|
133
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
134
|
+
const record = JSON.parse(content) as SessionGlowRecord;
|
|
61
135
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
136
|
+
if (!record || typeof record !== "object") continue;
|
|
137
|
+
|
|
138
|
+
// Prune dead process IDs
|
|
139
|
+
if (!isProcessAlive(record.pid)) {
|
|
140
|
+
try {
|
|
141
|
+
fs.unlinkSync(filePath);
|
|
142
|
+
} catch {}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Skip stale records
|
|
147
|
+
if (now - (record.updatedAt || 0) > maxStaleAgeMs) {
|
|
148
|
+
continue;
|
|
66
149
|
}
|
|
150
|
+
|
|
151
|
+
// If this is self, use the latest in-memory selfRecord values
|
|
152
|
+
if (selfSessionId && record.sessionId === selfSessionId && selfRecord) {
|
|
153
|
+
allRecords.push({ ...record, ...selfRecord } as SessionGlowRecord);
|
|
154
|
+
} else {
|
|
155
|
+
allRecords.push(record);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Ignore unreadable files
|
|
67
159
|
}
|
|
68
|
-
} catch {
|
|
69
|
-
// Ignore unreadable or corrupt temp files
|
|
70
160
|
}
|
|
71
161
|
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Ignore base scan errors
|
|
72
164
|
}
|
|
73
|
-
} catch {
|
|
74
|
-
return false;
|
|
75
165
|
}
|
|
76
166
|
|
|
77
|
-
|
|
167
|
+
// Ensure self record is included if not found on disk yet
|
|
168
|
+
if (selfSessionId && selfRecord && !allRecords.some((r) => r.sessionId === selfSessionId)) {
|
|
169
|
+
allRecords.push({
|
|
170
|
+
sessionId: selfSessionId,
|
|
171
|
+
pid: process.pid,
|
|
172
|
+
cwd: process.cwd(),
|
|
173
|
+
status: "idle",
|
|
174
|
+
hasUnread: false,
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
...selfRecord,
|
|
177
|
+
} as SessionGlowRecord);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let hasAnyError = false;
|
|
181
|
+
let hasAnyNeedApproval = false;
|
|
182
|
+
let hasAnyUnread = false;
|
|
183
|
+
let hasAnyRunning = false;
|
|
184
|
+
|
|
185
|
+
for (const r of allRecords) {
|
|
186
|
+
if (r.status === "error") {
|
|
187
|
+
hasAnyError = true;
|
|
188
|
+
}
|
|
189
|
+
if (r.status === "need_approval") {
|
|
190
|
+
hasAnyNeedApproval = true;
|
|
191
|
+
}
|
|
192
|
+
if (r.hasUnread) {
|
|
193
|
+
hasAnyUnread = true;
|
|
194
|
+
}
|
|
195
|
+
if (r.status === "running") {
|
|
196
|
+
hasAnyRunning = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let effectiveState: KeyboardState = "idle";
|
|
201
|
+
if (hasAnyError) {
|
|
202
|
+
effectiveState = "error";
|
|
203
|
+
} else if (hasAnyNeedApproval) {
|
|
204
|
+
effectiveState = "need_approval";
|
|
205
|
+
} else if (hasAnyUnread) {
|
|
206
|
+
effectiveState = "unread_chat";
|
|
207
|
+
} else if (hasAnyRunning) {
|
|
208
|
+
effectiveState = "thinking";
|
|
209
|
+
} else {
|
|
210
|
+
effectiveState = "idle";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
effectiveState,
|
|
215
|
+
hasAnyUnread,
|
|
216
|
+
hasAnyRunning,
|
|
217
|
+
hasAnyError,
|
|
218
|
+
hasAnyNeedApproval,
|
|
219
|
+
activeSessionCount: allRecords.length,
|
|
220
|
+
};
|
|
78
221
|
}
|
package/src/index.ts
CHANGED
|
@@ -25,19 +25,33 @@ function getStateMachine(): KeyboardStateMachine {
|
|
|
25
25
|
export default function (pi: ExtensionAPI): void {
|
|
26
26
|
const sm = getStateMachine();
|
|
27
27
|
let terminalInputDetached: (() => void) | undefined;
|
|
28
|
+
let rawStdinListener: ((chunk: Buffer | string) => void) | undefined;
|
|
28
29
|
|
|
29
30
|
// Pi Lifecycle Hooks
|
|
30
31
|
pi.on("session_start", async (_event, ctx) => {
|
|
31
|
-
|
|
32
|
+
const sessionId = (ctx as { sessionManager?: { getSessionId?: () => string } }).sessionManager?.getSessionId?.();
|
|
33
|
+
sm.setSessionContext(sessionId, ctx.cwd);
|
|
34
|
+
|
|
35
|
+
// Passive terminal input observer via extension UI context
|
|
32
36
|
if (ctx.hasUI && typeof ctx.ui.onTerminalInput === "function" && !terminalInputDetached) {
|
|
33
37
|
terminalInputDetached = ctx.ui.onTerminalInput((_data: string) => {
|
|
34
|
-
if (sm.isUnread()) {
|
|
38
|
+
if (sm.isUnread() || sm.hasError()) {
|
|
35
39
|
void sm.onUserActivated();
|
|
36
40
|
}
|
|
37
41
|
return undefined; // Passive observer: never consume or block input
|
|
38
42
|
});
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
// Direct raw stdin focus / keypress observer for reliable thread activation
|
|
46
|
+
if (process.stdin.isTTY && !rawStdinListener) {
|
|
47
|
+
rawStdinListener = (_chunk: Buffer | string) => {
|
|
48
|
+
if (sm.isUnread() || sm.hasError()) {
|
|
49
|
+
void sm.onUserActivated();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
process.stdin.on("data", rawStdinListener);
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
await sm.onSessionStart();
|
|
42
56
|
});
|
|
43
57
|
|
|
@@ -67,14 +81,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
67
81
|
|
|
68
82
|
pi.on("turn_end", async (event, _ctx) => {
|
|
69
83
|
const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
|
|
70
|
-
if (msg?.stopReason === "
|
|
84
|
+
if (msg?.stopReason === "aborted") {
|
|
85
|
+
await sm.onMessageEnd("aborted");
|
|
86
|
+
} else if (msg?.stopReason === "error" || Boolean(msg?.errorMessage)) {
|
|
71
87
|
await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
|
|
72
88
|
}
|
|
73
89
|
});
|
|
74
90
|
|
|
75
91
|
pi.on("message_end", async (event, _ctx) => {
|
|
76
92
|
const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
|
|
77
|
-
if (msg?.stopReason === "
|
|
93
|
+
if (msg?.stopReason === "aborted") {
|
|
94
|
+
await sm.onMessageEnd("aborted");
|
|
95
|
+
} else if (msg?.stopReason === "error" || Boolean(msg?.errorMessage)) {
|
|
78
96
|
await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
|
|
79
97
|
}
|
|
80
98
|
});
|
|
@@ -89,7 +107,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
89
107
|
const entry = branch[i];
|
|
90
108
|
if (entry && entry.type === "message" && entry.message && entry.message.role === "assistant") {
|
|
91
109
|
const msg = entry.message as { stopReason?: string; errorMessage?: string };
|
|
92
|
-
if (msg.stopReason === "
|
|
110
|
+
if (msg.stopReason === "aborted") {
|
|
111
|
+
hasError = false;
|
|
112
|
+
} else if (msg.stopReason === "error" || Boolean(msg.errorMessage)) {
|
|
93
113
|
hasError = true;
|
|
94
114
|
}
|
|
95
115
|
break;
|
|
@@ -107,6 +127,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
107
127
|
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
108
128
|
terminalInputDetached?.();
|
|
109
129
|
terminalInputDetached = undefined;
|
|
130
|
+
|
|
131
|
+
if (rawStdinListener) {
|
|
132
|
+
process.stdin.off("data", rawStdinListener);
|
|
133
|
+
rawStdinListener = undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
110
136
|
await sm.onShutdown();
|
|
111
137
|
});
|
|
112
138
|
|
|
@@ -114,6 +140,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
114
140
|
pi.registerCommand("keyboard", {
|
|
115
141
|
description: "Manage VIA/QMK keyboard RGB lighting status indicators",
|
|
116
142
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
143
|
+
// Executing a command in the session confirms user activation
|
|
144
|
+
void sm.onUserActivated();
|
|
145
|
+
|
|
117
146
|
const trimmed = args.trim().toLowerCase();
|
|
118
147
|
|
|
119
148
|
if (trimmed === "on") {
|
package/src/state-machine.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import { applyKeyboardState } from "./driver";
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
evaluateGlobalLightingState,
|
|
5
|
+
removeSessionGlowState,
|
|
6
|
+
writeSessionGlowState,
|
|
7
|
+
type GlobalStateSummary,
|
|
8
|
+
type SessionGlowRecord,
|
|
9
|
+
} from "./global-sessions";
|
|
3
10
|
import {
|
|
4
11
|
KEYBOARD_STATE_DEFINITIONS,
|
|
5
12
|
type KeyboardConfig,
|
|
@@ -11,11 +18,32 @@ export class KeyboardStateMachine {
|
|
|
11
18
|
private isProcessing = false;
|
|
12
19
|
private hasUnreadChat = false;
|
|
13
20
|
private hasFatalError = false;
|
|
21
|
+
private sessionId: string;
|
|
22
|
+
private cwd: string;
|
|
14
23
|
|
|
15
24
|
constructor(
|
|
16
25
|
private config: KeyboardConfig,
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
sessionId?: string,
|
|
27
|
+
cwd?: string,
|
|
28
|
+
private evalGlobalState: (id: string, record: SessionGlowRecord) => GlobalStateSummary = (id, rec) =>
|
|
29
|
+
evaluateGlobalLightingState(id, rec),
|
|
30
|
+
) {
|
|
31
|
+
this.sessionId = sessionId || crypto.randomUUID();
|
|
32
|
+
this.cwd = cwd || process.cwd();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public getSessionId(): string {
|
|
36
|
+
return this.sessionId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public getCwd(): string {
|
|
40
|
+
return this.cwd;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public setSessionContext(sessionId?: string, cwd?: string): void {
|
|
44
|
+
if (sessionId) this.sessionId = sessionId;
|
|
45
|
+
if (cwd) this.cwd = cwd;
|
|
46
|
+
}
|
|
19
47
|
|
|
20
48
|
public getConfig(): KeyboardConfig {
|
|
21
49
|
return { ...this.config };
|
|
@@ -42,6 +70,34 @@ export class KeyboardStateMachine {
|
|
|
42
70
|
return KEYBOARD_STATE_DEFINITIONS[state]?.labelZh ?? state;
|
|
43
71
|
}
|
|
44
72
|
|
|
73
|
+
private buildSelfRecord(statusOverride?: SessionGlowRecord["status"]): SessionGlowRecord {
|
|
74
|
+
let status: SessionGlowRecord["status"] = "idle";
|
|
75
|
+
if (this.hasFatalError) {
|
|
76
|
+
status = "error";
|
|
77
|
+
} else if (this.isProcessing) {
|
|
78
|
+
status = "running";
|
|
79
|
+
} else if (this.hasUnreadChat) {
|
|
80
|
+
status = "settled";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
sessionId: this.sessionId,
|
|
85
|
+
pid: process.pid,
|
|
86
|
+
cwd: this.cwd,
|
|
87
|
+
status: statusOverride ?? status,
|
|
88
|
+
hasUnread: this.hasUnreadChat,
|
|
89
|
+
updatedAt: Date.now(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async syncAndEvaluate(statusOverride?: SessionGlowRecord["status"]): Promise<void> {
|
|
94
|
+
const record = this.buildSelfRecord(statusOverride);
|
|
95
|
+
writeSessionGlowState(record);
|
|
96
|
+
|
|
97
|
+
const summary = this.evalGlobalState(this.sessionId, record);
|
|
98
|
+
await this.transitionTo(summary.effectiveState);
|
|
99
|
+
}
|
|
100
|
+
|
|
45
101
|
public async transitionTo(nextState: KeyboardState, force = false): Promise<void> {
|
|
46
102
|
if (this.currentState === nextState && !force) {
|
|
47
103
|
return;
|
|
@@ -58,26 +114,21 @@ export class KeyboardStateMachine {
|
|
|
58
114
|
this.isProcessing = false;
|
|
59
115
|
this.hasUnreadChat = false;
|
|
60
116
|
this.hasFatalError = false;
|
|
61
|
-
|
|
62
|
-
if (this.checkOtherRunning()) {
|
|
63
|
-
await this.transitionTo("thinking");
|
|
64
|
-
} else {
|
|
65
|
-
await this.transitionTo("idle");
|
|
66
|
-
}
|
|
117
|
+
await this.syncAndEvaluate("idle");
|
|
67
118
|
}
|
|
68
119
|
|
|
69
120
|
public async onAgentStart(): Promise<void> {
|
|
70
121
|
this.isProcessing = true;
|
|
71
122
|
this.hasUnreadChat = false;
|
|
72
123
|
this.hasFatalError = false;
|
|
73
|
-
await this.
|
|
124
|
+
await this.syncAndEvaluate("running");
|
|
74
125
|
}
|
|
75
126
|
|
|
76
127
|
public async onTurnStart(): Promise<void> {
|
|
77
128
|
this.isProcessing = true;
|
|
78
129
|
this.hasUnreadChat = false;
|
|
79
130
|
this.hasFatalError = false;
|
|
80
|
-
await this.
|
|
131
|
+
await this.syncAndEvaluate("running");
|
|
81
132
|
}
|
|
82
133
|
|
|
83
134
|
public async onToolCall(toolName: string, _input?: Record<string, unknown>): Promise<void> {
|
|
@@ -89,15 +140,15 @@ export class KeyboardStateMachine {
|
|
|
89
140
|
toolName === "question";
|
|
90
141
|
|
|
91
142
|
if (isInteractive) {
|
|
92
|
-
await this.
|
|
143
|
+
await this.syncAndEvaluate("need_approval");
|
|
93
144
|
} else {
|
|
94
|
-
await this.
|
|
145
|
+
await this.syncAndEvaluate("running");
|
|
95
146
|
}
|
|
96
147
|
}
|
|
97
148
|
|
|
98
149
|
public async onToolResult(): Promise<void> {
|
|
99
150
|
if (this.isProcessing) {
|
|
100
|
-
await this.
|
|
151
|
+
await this.syncAndEvaluate("running");
|
|
101
152
|
}
|
|
102
153
|
}
|
|
103
154
|
|
|
@@ -106,16 +157,25 @@ export class KeyboardStateMachine {
|
|
|
106
157
|
this.hasFatalError = true;
|
|
107
158
|
this.isProcessing = false;
|
|
108
159
|
this.hasUnreadChat = false;
|
|
109
|
-
await this.
|
|
160
|
+
await this.syncAndEvaluate("error");
|
|
110
161
|
}
|
|
111
162
|
}
|
|
112
163
|
|
|
113
164
|
public async onMessageEnd(stopReason?: string, errorMessage?: string): Promise<void> {
|
|
114
|
-
if (stopReason === "
|
|
165
|
+
if (stopReason === "aborted") {
|
|
166
|
+
// User manually stopped/cancelled session — NOT an error!
|
|
167
|
+
this.isProcessing = false;
|
|
168
|
+
this.hasFatalError = false;
|
|
169
|
+
this.hasUnreadChat = false;
|
|
170
|
+
await this.syncAndEvaluate("idle");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (stopReason === "error" || Boolean(errorMessage)) {
|
|
115
175
|
this.hasFatalError = true;
|
|
116
176
|
this.isProcessing = false;
|
|
117
177
|
this.hasUnreadChat = false;
|
|
118
|
-
await this.
|
|
178
|
+
await this.syncAndEvaluate("error");
|
|
119
179
|
}
|
|
120
180
|
}
|
|
121
181
|
|
|
@@ -124,53 +184,44 @@ export class KeyboardStateMachine {
|
|
|
124
184
|
if (hasError || this.hasFatalError) {
|
|
125
185
|
this.hasFatalError = true;
|
|
126
186
|
this.hasUnreadChat = false;
|
|
127
|
-
await this.
|
|
187
|
+
await this.syncAndEvaluate("error");
|
|
128
188
|
} else {
|
|
189
|
+
this.hasFatalError = false;
|
|
129
190
|
this.hasUnreadChat = true;
|
|
130
|
-
await this.
|
|
191
|
+
await this.syncAndEvaluate("settled");
|
|
131
192
|
}
|
|
132
193
|
}
|
|
133
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Called when user focuses the terminal window or interacts (key press / FocusIn).
|
|
197
|
+
* Marks current session as read and re-evaluates global system status:
|
|
198
|
+
* - If any OTHER session still has unread messages -> stays in unread_chat (green)
|
|
199
|
+
* - Else if any session is running -> thinking (blue)
|
|
200
|
+
* - Else -> idle (white)
|
|
201
|
+
*/
|
|
134
202
|
public async onUserActivated(): Promise<void> {
|
|
135
|
-
if (!this.hasUnreadChat) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
203
|
this.hasUnreadChat = false;
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
}
|
|
204
|
+
this.hasFatalError = false;
|
|
205
|
+
await this.syncAndEvaluate(this.isProcessing ? "running" : "idle");
|
|
150
206
|
}
|
|
151
207
|
|
|
152
208
|
public async onUserInput(): Promise<void> {
|
|
153
209
|
this.hasUnreadChat = false;
|
|
154
210
|
this.hasFatalError = false;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
} else {
|
|
158
|
-
await this.transitionTo("idle");
|
|
159
|
-
}
|
|
211
|
+
this.isProcessing = true;
|
|
212
|
+
await this.syncAndEvaluate("running");
|
|
160
213
|
}
|
|
161
214
|
|
|
162
215
|
public async onError(): Promise<void> {
|
|
163
216
|
this.isProcessing = false;
|
|
164
217
|
this.hasFatalError = true;
|
|
165
218
|
this.hasUnreadChat = false;
|
|
166
|
-
await this.
|
|
219
|
+
await this.syncAndEvaluate("error");
|
|
167
220
|
}
|
|
168
221
|
|
|
169
222
|
public async onShutdown(): Promise<void> {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
await this.transitionTo("idle");
|
|
174
|
-
}
|
|
223
|
+
removeSessionGlowState(this.cwd, this.sessionId);
|
|
224
|
+
const summary = evaluateGlobalLightingState();
|
|
225
|
+
await this.transitionTo(summary.effectiveState);
|
|
175
226
|
}
|
|
176
227
|
}
|