bigpowers 1.0.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.
Files changed (96) hide show
  1. package/.gitmessage +5 -0
  2. package/.releaserc.json +17 -0
  3. package/CHANGELOG.md +61 -0
  4. package/CLAUDE.md +61 -0
  5. package/CONVENTIONS.md +140 -0
  6. package/GEMINI.md +53 -0
  7. package/LICENSE +21 -0
  8. package/README.md +116 -0
  9. package/RELEASE.md +108 -0
  10. package/SKILL-INDEX.md +146 -0
  11. package/assess-impact/SKILL.md +76 -0
  12. package/audit-code/HEURISTICS.md +43 -0
  13. package/audit-code/SKILL.md +81 -0
  14. package/bin/bigpowers.js +27 -0
  15. package/change-request/REFERENCE.md +60 -0
  16. package/change-request/SKILL.md +42 -0
  17. package/commit-message/REFERENCE.md +81 -0
  18. package/commit-message/SKILL.md +39 -0
  19. package/countable-story-format.md +293 -0
  20. package/craft-skill/REFERENCE.md +88 -0
  21. package/craft-skill/SKILL.md +55 -0
  22. package/deepen-architecture/DEEPENING.md +37 -0
  23. package/deepen-architecture/INTERFACE-DESIGN.md +44 -0
  24. package/deepen-architecture/LANGUAGE.md +53 -0
  25. package/deepen-architecture/SKILL.md +76 -0
  26. package/define-language/SKILL.md +75 -0
  27. package/define-success/SKILL.md +60 -0
  28. package/delegate-task/SKILL.md +70 -0
  29. package/design-interface/SKILL.md +94 -0
  30. package/develop-tdd/SKILL.md +160 -0
  31. package/develop-tdd/deep-modules.md +33 -0
  32. package/develop-tdd/interface-design.md +31 -0
  33. package/develop-tdd/mocking.md +59 -0
  34. package/develop-tdd/refactoring.md +10 -0
  35. package/develop-tdd/tests.md +71 -0
  36. package/dispatch-agents/SKILL.md +72 -0
  37. package/edit-document/SKILL.md +14 -0
  38. package/elaborate-spec/SKILL.md +79 -0
  39. package/enforce-first/SKILL.md +75 -0
  40. package/execute-plan/SKILL.md +84 -0
  41. package/grill-me/REFERENCE.md +63 -0
  42. package/grill-me/SKILL.md +25 -0
  43. package/guard-git/REFERENCE.md +136 -0
  44. package/guard-git/SKILL.md +39 -0
  45. package/guard-git/scripts/block-dangerous-git.sh +41 -0
  46. package/guard-git/scripts/lib/git-guardrails-core.sh +29 -0
  47. package/hook-commits/SKILL.md +91 -0
  48. package/hooks/pre-tool-use.sh +130 -0
  49. package/index.js +6 -0
  50. package/inspect-quality/SKILL.md +101 -0
  51. package/investigate-bug/SKILL.md +111 -0
  52. package/kickoff-branch/SKILL.md +87 -0
  53. package/map-codebase/SKILL.md +66 -0
  54. package/migrate-spec/REFERENCE-GSD.md +137 -0
  55. package/migrate-spec/REFERENCE.md +186 -0
  56. package/migrate-spec/SKILL.md +150 -0
  57. package/model-domain/ADR-FORMAT.md +47 -0
  58. package/model-domain/CONTEXT-FORMAT.md +77 -0
  59. package/model-domain/SKILL.md +82 -0
  60. package/opencode.json +4 -0
  61. package/orchestrate-project/REFERENCE.md +89 -0
  62. package/orchestrate-project/SKILL.md +59 -0
  63. package/organize-workspace/REFERENCE.md +80 -0
  64. package/organize-workspace/SKILL.md +74 -0
  65. package/package.json +45 -0
  66. package/plan-refactor/SKILL.md +75 -0
  67. package/plan-release/SKILL.md +75 -0
  68. package/plan-work/SKILL.md +124 -0
  69. package/playwright.config.ts +56 -0
  70. package/release-branch/SKILL.md +116 -0
  71. package/request-review/SKILL.md +70 -0
  72. package/respond-review/SKILL.md +68 -0
  73. package/scripts/audit-compliance.sh +256 -0
  74. package/scripts/cleanup-worktrees.sh +44 -0
  75. package/scripts/install-cursor-skills-local.sh +13 -0
  76. package/scripts/install-cursor-skills.sh +34 -0
  77. package/scripts/install.sh +240 -0
  78. package/scripts/project-survey.sh +54 -0
  79. package/scripts/sync-skills.sh +110 -0
  80. package/seed-conventions/SKILL.md +185 -0
  81. package/session-state/SKILL.md +69 -0
  82. package/skills-lock.json +157 -0
  83. package/spike-prototype/SKILL.md +92 -0
  84. package/survey-context/SKILL.md +93 -0
  85. package/terse-mode/SKILL.md +35 -0
  86. package/trace-requirement/SKILL.md +68 -0
  87. package/using-bigpowers/SKILL.md +65 -0
  88. package/validate-fix/SKILL.md +93 -0
  89. package/visual-dashboard/SKILL.md +49 -0
  90. package/visual-dashboard/scripts/frame-template.html +189 -0
  91. package/visual-dashboard/scripts/helper.js +83 -0
  92. package/visual-dashboard/scripts/server.cjs +345 -0
  93. package/visual-dashboard/scripts/start-server.sh +121 -0
  94. package/visual-dashboard/scripts/stop-server.sh +46 -0
  95. package/wire-observability/SKILL.md +90 -0
  96. package/write-document/SKILL.md +63 -0
@@ -0,0 +1,189 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Bigpowers Dashboard</title>
6
+ <style>
7
+ /*
8
+ * DASHBOARD FRAME TEMPLATE
9
+ */
10
+
11
+ * { box-sizing: border-box; margin: 0; padding: 0; }
12
+ html, body { height: 100%; overflow: hidden; }
13
+
14
+ /* ===== THEME VARIABLES ===== */
15
+ :root {
16
+ --bg-primary: #f5f5f7;
17
+ --bg-secondary: #ffffff;
18
+ --bg-tertiary: #e5e5e7;
19
+ --border: #d1d1d6;
20
+ --text-primary: #1d1d1f;
21
+ --text-secondary: #86868b;
22
+ --text-tertiary: #aeaeb2;
23
+ --accent: #0071e3;
24
+ --accent-hover: #0077ed;
25
+ --success: #34c759;
26
+ --warning: #ff9f0a;
27
+ --error: #ff3b30;
28
+ --selected-bg: #e8f4fd;
29
+ --selected-border: #0071e3;
30
+ }
31
+
32
+ @media (prefers-color-scheme: dark) {
33
+ :root {
34
+ --bg-primary: #1d1d1f;
35
+ --bg-secondary: #2d2d2f;
36
+ --bg-tertiary: #3d3d3f;
37
+ --border: #424245;
38
+ --text-primary: #f5f5f7;
39
+ --text-secondary: #86868b;
40
+ --text-tertiary: #636366;
41
+ --accent: #0a84ff;
42
+ --accent-hover: #409cff;
43
+ --selected-bg: rgba(10, 132, 255, 0.15);
44
+ --selected-border: #0a84ff;
45
+ }
46
+ }
47
+
48
+ body {
49
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
50
+ background: var(--bg-primary);
51
+ color: var(--text-primary);
52
+ display: flex;
53
+ flex-direction: column;
54
+ line-height: 1.5;
55
+ }
56
+
57
+ /* ===== FRAME STRUCTURE ===== */
58
+ .header {
59
+ background: var(--bg-secondary);
60
+ padding: 0.5rem 1.5rem;
61
+ display: flex;
62
+ justify-content: space-between;
63
+ align-items: center;
64
+ border-bottom: 1px solid var(--border);
65
+ flex-shrink: 0;
66
+ }
67
+ .header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
68
+ .header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
69
+ .header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
70
+
71
+ .main { flex: 1; overflow-y: auto; }
72
+ #claude-content { padding: 2rem; min-height: 100%; }
73
+
74
+ .indicator-bar {
75
+ background: var(--bg-secondary);
76
+ border-top: 1px solid var(--border);
77
+ padding: 0.5rem 1.5rem;
78
+ flex-shrink: 0;
79
+ text-align: center;
80
+ }
81
+ .indicator-bar span {
82
+ font-size: 0.75rem;
83
+ color: var(--text-secondary);
84
+ }
85
+ .indicator-bar .selected-text {
86
+ color: var(--accent);
87
+ font-weight: 500;
88
+ }
89
+
90
+ /* ===== TYPOGRAPHY ===== */
91
+ h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
92
+ h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
93
+ .subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
94
+ .section { margin-bottom: 2rem; }
95
+ .label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
96
+
97
+ /* ===== OPTIONS ===== */
98
+ .options { display: flex; flex-direction: column; gap: 0.75rem; }
99
+ .option {
100
+ background: var(--bg-secondary);
101
+ border: 2px solid var(--border);
102
+ border-radius: 12px;
103
+ padding: 1rem 1.25rem;
104
+ cursor: pointer;
105
+ transition: all 0.15s ease;
106
+ display: flex;
107
+ align-items: flex-start;
108
+ gap: 1rem;
109
+ }
110
+ .option:hover { border-color: var(--accent); }
111
+ .option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
112
+ .option .letter {
113
+ background: var(--bg-tertiary);
114
+ color: var(--text-secondary);
115
+ width: 1.75rem; height: 1.75rem;
116
+ border-radius: 6px;
117
+ display: flex; align-items: center; justify-content: center;
118
+ font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
119
+ }
120
+ .option.selected .letter { background: var(--accent); color: white; }
121
+ .option .content { flex: 1; }
122
+ .option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
123
+ .option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
124
+
125
+ /* ===== CARDS ===== */
126
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
127
+ .card {
128
+ background: var(--bg-secondary);
129
+ border: 1px solid var(--border);
130
+ border-radius: 12px;
131
+ overflow: hidden;
132
+ cursor: pointer;
133
+ transition: all 0.15s ease;
134
+ }
135
+ .card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
136
+ .card.selected { border-color: var(--selected-border); border-width: 2px; }
137
+ .card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
138
+ .card-body { padding: 1rem; }
139
+ .card-body h3 { margin-bottom: 0.25rem; }
140
+ .card-body p { color: var(--text-secondary); font-size: 0.85rem; }
141
+
142
+ /* ===== MOCKUP CONTAINER ===== */
143
+ .mockup {
144
+ background: var(--bg-secondary);
145
+ border: 1px solid var(--border);
146
+ border-radius: 12px;
147
+ overflow: hidden;
148
+ margin-bottom: 1.5rem;
149
+ }
150
+ .mockup-header {
151
+ background: var(--bg-tertiary);
152
+ padding: 0.5rem 1rem;
153
+ font-size: 0.75rem;
154
+ color: var(--text-secondary);
155
+ border-bottom: 1px solid var(--border);
156
+ }
157
+ .mockup-body { padding: 1.5rem; }
158
+
159
+ /* ===== SPLIT VIEW ===== */
160
+ .split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
161
+ @media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
162
+
163
+ /* ===== PROS/CONS ===== */
164
+ .pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
165
+ .pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
166
+ .pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
167
+ .cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
168
+ .pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
169
+ .pros li, .cons li { margin-bottom: 0.25rem; }
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <div class="header">
174
+ <h1>Bigpowers Dashboard</h1>
175
+ <div class="status">Connected</div>
176
+ </div>
177
+
178
+ <div class="main">
179
+ <div id="claude-content">
180
+ <!-- CONTENT -->
181
+ </div>
182
+ </div>
183
+
184
+ <div class="indicator-bar">
185
+ <span id="indicator-text">Interact with the dashboard, then return to the terminal</span>
186
+ </div>
187
+
188
+ </body>
189
+ </html>
@@ -0,0 +1,83 @@
1
+ (function() {
2
+ const WS_URL = 'ws://' + window.location.host;
3
+ let ws = null;
4
+ let eventQueue = [];
5
+
6
+ function connect() {
7
+ ws = new WebSocket(WS_URL);
8
+
9
+ ws.onopen = () => {
10
+ eventQueue.forEach(e => ws.send(JSON.stringify(e)));
11
+ eventQueue = [];
12
+ };
13
+
14
+ ws.onmessage = (msg) => {
15
+ const data = JSON.parse(msg.data);
16
+ if (data.type === 'reload') {
17
+ window.location.reload();
18
+ }
19
+ };
20
+
21
+ ws.onclose = () => {
22
+ setTimeout(connect, 1000);
23
+ };
24
+ }
25
+
26
+ function sendEvent(event) {
27
+ event.timestamp = Date.now();
28
+ if (ws && ws.readyState === WebSocket.OPEN) {
29
+ ws.send(JSON.stringify(event));
30
+ } else {
31
+ eventQueue.push(event);
32
+ }
33
+ }
34
+
35
+ // Capture clicks on choice elements
36
+ document.addEventListener('click', (e) => {
37
+ const target = e.target.closest('[data-choice]');
38
+ if (!target) return;
39
+
40
+ sendEvent({
41
+ type: 'click',
42
+ text: target.textContent.trim(),
43
+ choice: target.dataset.choice,
44
+ id: target.id || null
45
+ });
46
+
47
+ // Update indicator bar
48
+ setTimeout(() => {
49
+ const indicator = document.getElementById('indicator-text');
50
+ if (!indicator) return;
51
+ const container = target.closest('.options') || target.closest('.cards');
52
+ const selected = container ? container.querySelectorAll('.selected') : [];
53
+ if (selected.length === 0) {
54
+ indicator.textContent = 'Interact with the dashboard, then return to the terminal';
55
+ } else if (selected.length === 1) {
56
+ const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
57
+ indicator.innerHTML = '<span class="selected-text">' + label + ' selected</span> — return to terminal to continue';
58
+ } else {
59
+ indicator.innerHTML = '<span class="selected-text">' + selected.length + ' selected</span> — return to terminal to continue';
60
+ }
61
+ }, 0);
62
+ });
63
+
64
+ window.toggleSelect = function(el) {
65
+ const container = el.closest('.options') || el.closest('.cards');
66
+ const multi = container && container.dataset.multiselect !== undefined;
67
+ if (container && !multi) {
68
+ container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
69
+ }
70
+ if (multi) {
71
+ el.classList.toggle('selected');
72
+ } else {
73
+ el.classList.add('selected');
74
+ }
75
+ };
76
+
77
+ window.dashboard = {
78
+ send: sendEvent,
79
+ choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
80
+ };
81
+
82
+ connect();
83
+ })();
@@ -0,0 +1,345 @@
1
+ const crypto = require('crypto');
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ========== WebSocket Protocol (RFC 6455) ==========
7
+
8
+ const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
9
+ const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10
+
11
+ function computeAcceptKey(clientKey) {
12
+ return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
13
+ }
14
+
15
+ function encodeFrame(opcode, payload) {
16
+ const fin = 0x80;
17
+ const len = payload.length;
18
+ let header;
19
+
20
+ if (len < 126) {
21
+ header = Buffer.alloc(2);
22
+ header[0] = fin | opcode;
23
+ header[1] = len;
24
+ } else if (len < 65536) {
25
+ header = Buffer.alloc(4);
26
+ header[0] = fin | opcode;
27
+ header[1] = 126;
28
+ header.writeUInt16BE(len, 2);
29
+ } else {
30
+ header = Buffer.alloc(10);
31
+ header[0] = fin | opcode;
32
+ header[1] = 127;
33
+ header.writeBigUInt64BE(BigInt(len), 2);
34
+ }
35
+
36
+ return Buffer.concat([header, payload]);
37
+ }
38
+
39
+ function decodeFrame(buffer) {
40
+ if (buffer.length < 2) return null;
41
+
42
+ const secondByte = buffer[1];
43
+ const opcode = buffer[0] & 0x0F;
44
+ const masked = (secondByte & 0x80) !== 0;
45
+ let payloadLen = secondByte & 0x7F;
46
+ let offset = 2;
47
+
48
+ if (!masked) throw new Error('Client frames must be masked');
49
+
50
+ if (payloadLen === 126) {
51
+ if (buffer.length < 4) return null;
52
+ payloadLen = buffer.readUInt16BE(2);
53
+ offset = 4;
54
+ } else if (payloadLen === 127) {
55
+ if (buffer.length < 10) return null;
56
+ payloadLen = Number(buffer.readBigUInt64BE(2));
57
+ offset = 10;
58
+ }
59
+
60
+ const maskOffset = offset;
61
+ const dataOffset = offset + 4;
62
+ const totalLen = dataOffset + payloadLen;
63
+ if (buffer.length < totalLen) return null;
64
+
65
+ const mask = buffer.slice(maskOffset, dataOffset);
66
+ const data = Buffer.alloc(payloadLen);
67
+ for (let i = 0; i < payloadLen; i++) {
68
+ data[i] = buffer[dataOffset + i] ^ mask[i % 4];
69
+ }
70
+
71
+ return { opcode, payload: data, bytesConsumed: totalLen };
72
+ }
73
+
74
+ // ========== Configuration ==========
75
+
76
+ const PORT = process.env.BIGPOWERS_DASHBOARD_PORT || (49152 + Math.floor(Math.random() * 16383));
77
+ const HOST = process.env.BIGPOWERS_DASHBOARD_HOST || '127.0.0.1';
78
+ const URL_HOST = process.env.BIGPOWERS_DASHBOARD_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
79
+ const SESSION_DIR = process.env.BIGPOWERS_DASHBOARD_DIR || '/tmp/bigpowers-dashboard';
80
+ const CONTENT_DIR = path.join(SESSION_DIR, 'content');
81
+ const STATE_DIR = path.join(SESSION_DIR, 'state');
82
+ let ownerPid = process.env.BIGPOWERS_DASHBOARD_OWNER_PID ? Number(process.env.BIGPOWERS_DASHBOARD_OWNER_PID) : null;
83
+
84
+ const MIME_TYPES = {
85
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
86
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
87
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
88
+ };
89
+
90
+ // ========== Templates and Constants ==========
91
+
92
+ const WAITING_PAGE = `<!DOCTYPE html>
93
+ <html>
94
+ <head><meta charset="utf-8"><title>Bigpowers Dashboard</title>
95
+ <style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
96
+ h1 { color: #333; } p { color: #666; }</style>
97
+ </head>
98
+ <body><h1>Bigpowers Dashboard</h1>
99
+ <p>Waiting for the agent to push a screen...</p></body></html>`;
100
+
101
+ const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
102
+ const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
103
+ const helperInjection = '<script>\n' + helperScript + '\n</script>';
104
+
105
+ // ========== Helper Functions ==========
106
+
107
+ function isFullDocument(html) {
108
+ const trimmed = html.trimStart().toLowerCase();
109
+ return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
110
+ }
111
+
112
+ function wrapInFrame(content) {
113
+ return frameTemplate.replace('<!-- CONTENT -->', content);
114
+ }
115
+
116
+ function getNewestScreen() {
117
+ const files = fs.readdirSync(CONTENT_DIR)
118
+ .filter(f => f.endsWith('.html'))
119
+ .map(f => {
120
+ const fp = path.join(CONTENT_DIR, f);
121
+ return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
122
+ })
123
+ .sort((a, b) => b.mtime - a.mtime);
124
+ return files.length > 0 ? files[0].path : null;
125
+ }
126
+
127
+ // ========== HTTP Request Handler ==========
128
+
129
+ function handleRequest(req, res) {
130
+ touchActivity();
131
+ if (req.method === 'GET' && req.url === '/') {
132
+ const screenFile = getNewestScreen();
133
+ let html = screenFile
134
+ ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
135
+ : WAITING_PAGE;
136
+
137
+ if (html.includes('</body>')) {
138
+ html = html.replace('</body>', helperInjection + '\n</body>');
139
+ } else {
140
+ html += helperInjection;
141
+ }
142
+
143
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
144
+ res.end(html);
145
+ } else if (req.method === 'GET' && req.url.startsWith('/files/')) {
146
+ const fileName = req.url.slice(7);
147
+ const filePath = path.join(CONTENT_DIR, path.basename(fileName));
148
+ if (!fs.existsSync(filePath)) {
149
+ res.writeHead(404);
150
+ res.end('Not found');
151
+ return;
152
+ }
153
+ const ext = path.extname(filePath).toLowerCase();
154
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
155
+ res.writeHead(200, { 'Content-Type': contentType });
156
+ res.end(fs.readFileSync(filePath));
157
+ } else {
158
+ res.writeHead(404);
159
+ res.end('Not found');
160
+ }
161
+ }
162
+
163
+ // ========== WebSocket Connection Handling ==========
164
+
165
+ const clients = new Set();
166
+
167
+ function handleUpgrade(req, socket) {
168
+ const key = req.headers['sec-websocket-key'];
169
+ if (!key) { socket.destroy(); return; }
170
+
171
+ const accept = computeAcceptKey(key);
172
+ socket.write(
173
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
174
+ 'Upgrade: websocket\r\n' +
175
+ 'Connection: Upgrade\r\n' +
176
+ 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
177
+ );
178
+
179
+ let buffer = Buffer.alloc(0);
180
+ clients.add(socket);
181
+
182
+ socket.on('data', (chunk) => {
183
+ buffer = Buffer.concat([buffer, chunk]);
184
+ while (buffer.length > 0) {
185
+ let result;
186
+ try {
187
+ result = decodeFrame(buffer);
188
+ } catch (e) {
189
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
190
+ clients.delete(socket);
191
+ return;
192
+ }
193
+ if (!result) break;
194
+ buffer = buffer.slice(result.bytesConsumed);
195
+
196
+ switch (result.opcode) {
197
+ case OPCODES.TEXT:
198
+ handleMessage(result.payload.toString());
199
+ break;
200
+ case OPCODES.CLOSE:
201
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
202
+ clients.delete(socket);
203
+ return;
204
+ case OPCODES.PING:
205
+ socket.write(encodeFrame(OPCODES.PONG, result.payload));
206
+ break;
207
+ case OPCODES.PONG:
208
+ break;
209
+ default: {
210
+ const closeBuf = Buffer.alloc(2);
211
+ closeBuf.writeUInt16BE(1003);
212
+ socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
213
+ clients.delete(socket);
214
+ return;
215
+ }
216
+ }
217
+ }
218
+ });
219
+
220
+ socket.on('close', () => clients.delete(socket));
221
+ socket.on('error', () => clients.delete(socket));
222
+ }
223
+
224
+ function handleMessage(text) {
225
+ let event;
226
+ try {
227
+ event = JSON.parse(text);
228
+ } catch (e) {
229
+ console.error('Failed to parse WebSocket message:', e.message);
230
+ return;
231
+ }
232
+ touchActivity();
233
+ console.log(JSON.stringify({ source: 'user-event', ...event }));
234
+ if (event.choice) {
235
+ const eventsFile = path.join(STATE_DIR, 'events');
236
+ fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
237
+ }
238
+ }
239
+
240
+ function broadcast(msg) {
241
+ const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));
242
+ for (const socket of clients) {
243
+ try { socket.write(frame); } catch (e) { clients.delete(socket); }
244
+ }
245
+ }
246
+
247
+ // ========== Activity Tracking ==========
248
+
249
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
250
+ let lastActivity = Date.now();
251
+
252
+ function touchActivity() {
253
+ lastActivity = Date.now();
254
+ }
255
+
256
+ // ========== File Watching ==========
257
+
258
+ const debounceTimers = new Map();
259
+
260
+ // ========== Server Startup ==========
261
+
262
+ function startServer() {
263
+ if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true });
264
+ if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
265
+
266
+ const knownFiles = new Set(
267
+ fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith('.html'))
268
+ );
269
+
270
+ const server = http.createServer(handleRequest);
271
+ server.on('upgrade', handleUpgrade);
272
+
273
+ const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {
274
+ if (!filename || !filename.endsWith('.html')) return;
275
+
276
+ if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
277
+ debounceTimers.set(filename, setTimeout(() => {
278
+ debounceTimers.delete(filename);
279
+ const filePath = path.join(CONTENT_DIR, filename);
280
+
281
+ if (!fs.existsSync(filePath)) return; // file was deleted
282
+ touchActivity();
283
+
284
+ if (!knownFiles.has(filename)) {
285
+ knownFiles.add(filename);
286
+ const eventsFile = path.join(STATE_DIR, 'events');
287
+ if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
288
+ console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
289
+ } else {
290
+ console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
291
+ }
292
+
293
+ broadcast({ type: 'reload' });
294
+ }, 100));
295
+ });
296
+ watcher.on('error', (err) => console.error('fs.watch error:', err.message));
297
+
298
+ function shutdown(reason) {
299
+ console.log(JSON.stringify({ type: 'server-stopped', reason }));
300
+ const infoFile = path.join(STATE_DIR, 'server-info');
301
+ if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
302
+ fs.writeFileSync(
303
+ path.join(STATE_DIR, 'server-stopped'),
304
+ JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
305
+ );
306
+ watcher.close();
307
+ clearInterval(lifecycleCheck);
308
+ server.close(() => process.exit(0));
309
+ }
310
+
311
+ function ownerAlive() {
312
+ if (!ownerPid) return true;
313
+ try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
314
+ }
315
+
316
+ const lifecycleCheck = setInterval(() => {
317
+ if (!ownerAlive()) shutdown('owner process exited');
318
+ else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
319
+ }, 60 * 1000);
320
+ lifecycleCheck.unref();
321
+
322
+ if (ownerPid) {
323
+ try { process.kill(ownerPid, 0); }
324
+ catch (e) {
325
+ if (e.code !== 'EPERM') {
326
+ console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' }));
327
+ ownerPid = null;
328
+ }
329
+ }
330
+ }
331
+
332
+ server.listen(PORT, HOST, () => {
333
+ const info = JSON.stringify({
334
+ type: 'server-started', port: Number(PORT), host: HOST,
335
+ url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
336
+ screen_dir: CONTENT_DIR, state_dir: STATE_DIR
337
+ });
338
+ console.log(info);
339
+ fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n');
340
+ });
341
+ }
342
+
343
+ if (require.main === module) {
344
+ startServer();
345
+ }