pi-keyboard 0.1.2 → 0.1.3

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.
@@ -96,6 +96,14 @@ Feature: Pi Keyboard Lighting Indicator
96
96
  Then the unread status is cleared
97
97
  And the keyboard state machine transitions to "thinking"
98
98
 
99
+ Scenario: Orphaned unread record from an unexpectedly-exited session is cleaned up
100
+ Given a session had an unread chat state and then exited unexpectedly (crash / kill)
101
+ And that session's process is no longer alive, leaving an orphaned unread record
102
+ When a new session starts and sweeps the registry
103
+ Then the orphaned unread record is removed
104
+ And it no longer contributes to the green unread light
105
+ And a live unread session still keeps its green light
106
+
99
107
  Scenario: Target lighting zone selection
100
108
  Given the user configures the target zone
101
109
  When the zone is set to "matrix"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-keyboard",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -36,4 +36,4 @@
36
36
  "@earendil-works/pi-coding-agent": "*",
37
37
  "@earendil-works/pi-tui": "*"
38
38
  }
39
- }
39
+ }
@@ -13,6 +13,10 @@ export interface SessionGlowRecord {
13
13
  }
14
14
 
15
15
  export function getRegistryDir(): string {
16
+ // Test seam: allow pointing at an isolated registry so tests never touch the
17
+ // real ~/.pi/agent/directory-sessions directory.
18
+ const override = process.env.PI_DIRECTORY_SESSIONS_DIR;
19
+ if (override) return override;
16
20
  return path.join(os.homedir(), ".pi", "agent", "directory-sessions");
17
21
  }
18
22
 
@@ -81,6 +85,57 @@ export function removeSessionGlowState(cwd: string, sessionId: string): void {
81
85
  }
82
86
  }
83
87
 
88
+ /**
89
+ * Sweeps the whole registry and removes glow records whose owning process is no
90
+ * longer alive. Sessions that exit unexpectedly (crash, SIGKILL, terminal closed
91
+ * without a clean session_shutdown) never run removeSessionGlowState, so their
92
+ * leftover unread (settled) records would otherwise pile up and keep the green
93
+ * light on. Called at session start to clear that residue before evaluating.
94
+ *
95
+ * Deliberately cleans only dead-process records: a genuinely live session that
96
+ * is still unread keeps its glow record (and green light). No time-based expiry.
97
+ */
98
+ export function pruneOrphanedGlowStates(): number {
99
+ const baseDir = getRegistryDir();
100
+ if (!fs.existsSync(baseDir)) return 0;
101
+
102
+ let removed = 0;
103
+ try {
104
+ const dirEntries = fs.readdirSync(baseDir, { withFileTypes: true });
105
+ for (const dirEntry of dirEntries) {
106
+ if (!dirEntry.isDirectory()) continue;
107
+ const subDirPath = path.join(baseDir, dirEntry.name);
108
+ let sessionFiles: string[] = [];
109
+ try {
110
+ sessionFiles = fs.readdirSync(subDirPath);
111
+ } catch {
112
+ continue;
113
+ }
114
+
115
+ for (const file of sessionFiles) {
116
+ if (!file.endsWith(".json")) continue;
117
+ const filePath = path.join(subDirPath, file);
118
+ try {
119
+ const content = fs.readFileSync(filePath, "utf-8");
120
+ const record = JSON.parse(content) as SessionGlowRecord;
121
+ if (!record || typeof record !== "object" || typeof record.pid !== "number") continue;
122
+ if (!isProcessAlive(record.pid)) {
123
+ try {
124
+ fs.unlinkSync(filePath);
125
+ removed++;
126
+ } catch {}
127
+ }
128
+ } catch {
129
+ // Ignore unreadable / non-glow files (all .json in the shared dir)
130
+ }
131
+ }
132
+ }
133
+ } catch {
134
+ // Best-effort sweep
135
+ }
136
+ return removed;
137
+ }
138
+
84
139
  export interface GlobalStateSummary {
85
140
  effectiveState: KeyboardState;
86
141
  hasAnyUnread: boolean;
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import { applyKeyboardState } from "./driver";
3
3
  import {
4
4
  evaluateGlobalLightingState,
5
+ pruneOrphanedGlowStates,
5
6
  removeSessionGlowState,
6
7
  writeSessionGlowState,
7
8
  type GlobalStateSummary,
@@ -114,6 +115,11 @@ export class KeyboardStateMachine {
114
115
  this.isProcessing = false;
115
116
  this.hasUnreadChat = false;
116
117
  this.hasFatalError = false;
118
+ // Clean up unread (settled) glow records left behind by sessions that exited
119
+ // unexpectedly (crash / kill / terminal closed). Without this their leftover
120
+ // unread would pile up and keep the green light on. Only dead-process records
121
+ // are removed; a live unread session keeps its glow state.
122
+ pruneOrphanedGlowStates();
117
123
  await this.syncAndEvaluate("idle");
118
124
  }
119
125