supipowers 0.2.7 → 0.3.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 (44) hide show
  1. package/package.json +21 -6
  2. package/skills/debugging/SKILL.md +54 -15
  3. package/skills/planning/SKILL.md +70 -10
  4. package/skills/receiving-code-review/SKILL.md +87 -0
  5. package/skills/tdd/SKILL.md +83 -0
  6. package/skills/verification/SKILL.md +54 -0
  7. package/src/commands/plan.ts +96 -31
  8. package/src/commands/qa.ts +150 -29
  9. package/src/commands/release.ts +1 -1
  10. package/src/commands/review.ts +2 -2
  11. package/src/commands/run.ts +52 -2
  12. package/src/commands/update.ts +2 -2
  13. package/src/discipline/debugging.ts +57 -0
  14. package/src/discipline/receiving-review.ts +65 -0
  15. package/src/discipline/tdd.ts +77 -0
  16. package/src/discipline/verification.ts +68 -0
  17. package/src/git/branch-finish.ts +101 -0
  18. package/src/git/worktree.ts +119 -0
  19. package/src/index.ts +11 -2
  20. package/src/lsp/detector.ts +2 -2
  21. package/src/orchestrator/agent-prompts.ts +282 -0
  22. package/src/orchestrator/dispatcher.ts +150 -1
  23. package/src/orchestrator/prompts.ts +17 -31
  24. package/src/planning/plan-reviewer.ts +49 -0
  25. package/src/planning/plan-writer-prompt.ts +173 -0
  26. package/src/planning/prompt-builder.ts +178 -0
  27. package/src/planning/spec-reviewer.ts +43 -0
  28. package/src/qa/phases/discovery.ts +34 -0
  29. package/src/qa/phases/execution.ts +65 -0
  30. package/src/qa/phases/matrix.ts +41 -0
  31. package/src/qa/phases/reporting.ts +71 -0
  32. package/src/qa/session.ts +104 -0
  33. package/src/storage/qa-sessions.ts +83 -0
  34. package/src/storage/specs.ts +36 -0
  35. package/src/types.ts +70 -0
  36. package/src/visual/companion.ts +115 -0
  37. package/src/visual/prompt-instructions.ts +102 -0
  38. package/src/visual/scripts/frame-template.html +201 -0
  39. package/src/visual/scripts/helper.js +88 -0
  40. package/src/visual/scripts/index.js +148 -0
  41. package/src/visual/scripts/package.json +10 -0
  42. package/src/visual/scripts/start-server.sh +98 -0
  43. package/src/visual/scripts/stop-server.sh +21 -0
  44. package/src/visual/types.ts +16 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Build the visual companion instruction block to append to sub-agent prompts.
3
+ * Tells the agent how to write HTML screens, what CSS classes are available,
4
+ * and how to read user interaction events.
5
+ */
6
+ export function buildVisualInstructions(url: string, sessionDir: string): string {
7
+ const fence = "```";
8
+
9
+ const sections: string[] = [
10
+ "## Visual Companion Active",
11
+ "",
12
+ `A browser companion is running at ${url}. The user can see visual content there.`,
13
+ "",
14
+ "### When to Use Browser vs Terminal",
15
+ "",
16
+ "- **Use the browser** for content that IS visual: mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs, A/B/C option cards, pros/cons tables",
17
+ "- **Use the terminal** for content that is text: requirements questions, conceptual choices, discussion, final plan output",
18
+ "",
19
+ "A question about a UI topic is not automatically a visual question. Conceptual questions go to the terminal. Visual comparisons go to the browser.",
20
+ "",
21
+ "### How to Write HTML Screens",
22
+ "",
23
+ `Write HTML fragment files to \`${sessionDir}/\` with descriptive filenames:`,
24
+ "- `screen-001-approaches.html`",
25
+ "- `screen-002-architecture.html`",
26
+ "",
27
+ "The server auto-wraps fragments in a styled frame with dark/light theme support. You do NOT need to write full HTML documents — just the content inside `<body>`.",
28
+ "",
29
+ "### Available CSS Classes",
30
+ "",
31
+ "Your HTML fragments can use these classes (provided by the frame template):",
32
+ "",
33
+ "**Choices (A/B/C options):**",
34
+ "- `.options` > `.option[data-choice=\"x\"]` with `.letter` + `.content` children",
35
+ "",
36
+ `${fence}html`,
37
+ '<div class="options">',
38
+ ' <div class="option" data-choice="a" onclick="toggleSelect(this)">',
39
+ ' <div class="letter">A</div>',
40
+ ' <div class="content">',
41
+ " <h3>Option Title</h3>",
42
+ " <p>Description text</p>",
43
+ " </div>",
44
+ " </div>",
45
+ "</div>",
46
+ fence,
47
+ "",
48
+ "**Cards (grid layout, multi-select with `data-multiselect`):**",
49
+ '- `.cards` > `.card[data-choice="x"]` with `.card-image` + `.card-body`',
50
+ "",
51
+ "**Mockup containers:**",
52
+ "- `.mockup` > `.mockup-header` + `.mockup-body`",
53
+ "",
54
+ "**Side-by-side comparison:**",
55
+ "- `.split` — two-column grid (responsive)",
56
+ "",
57
+ "**Pros/Cons:**",
58
+ "- `.pros-cons` > `.pros` + `.cons` — color-coded green/red headers",
59
+ "",
60
+ "**Placeholders:**",
61
+ "- `.placeholder` — dashed border boxes for areas to be filled",
62
+ "",
63
+ "**Mock UI elements:**",
64
+ "- `.mock-nav`, `.mock-sidebar`, `.mock-content`, `.mock-button`, `.mock-input`",
65
+ "",
66
+ "**Typography:**",
67
+ "- `h2` (page title), `h3` (section heading), `.subtitle`, `.label`, `.section`",
68
+ "",
69
+ "### Example Screen",
70
+ "",
71
+ `${fence}html`,
72
+ '<h2>Architecture Approach</h2>',
73
+ '<p class="subtitle">Choose the approach that best fits your needs</p>',
74
+ '<div class="options">',
75
+ ' <div class="option" data-choice="monolith" onclick="toggleSelect(this)">',
76
+ ' <div class="letter">A</div>',
77
+ ' <div class="content">',
78
+ " <h3>Monolith</h3>",
79
+ " <p>Single service, simpler deployment</p>",
80
+ " </div>",
81
+ " </div>",
82
+ ' <div class="option" data-choice="microservices" onclick="toggleSelect(this)">',
83
+ ' <div class="letter">B</div>',
84
+ ' <div class="content">',
85
+ " <h3>Microservices</h3>",
86
+ " <p>Distributed, independently scalable</p>",
87
+ " </div>",
88
+ " </div>",
89
+ "</div>",
90
+ fence,
91
+ "",
92
+ "### Reading User Choices",
93
+ "",
94
+ "When you present choices in the browser, the user clicks `[data-choice]` elements.",
95
+ `Read \`${sessionDir}/.events\` to see their selections (newline-delimited JSON).`,
96
+ 'Each event: `{ "type": "click", "choice": "a", "text": "Option Title", "timestamp": 1234 }`',
97
+ "",
98
+ "After presenting a visual screen, tell the user to check their browser and respond in the terminal. Then read the .events file to see what they clicked.",
99
+ ];
100
+
101
+ return sections.join("\n");
102
+ }
@@ -0,0 +1,201 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Supipowers Visual Companion</title>
5
+ <style>
6
+ * { box-sizing: border-box; margin: 0; padding: 0; }
7
+ html, body { height: 100%; overflow: hidden; }
8
+
9
+ /* ===== THEME VARIABLES ===== */
10
+ :root {
11
+ --bg-primary: #f5f5f7;
12
+ --bg-secondary: #ffffff;
13
+ --bg-tertiary: #e5e5e7;
14
+ --border: #d1d1d6;
15
+ --text-primary: #1d1d1f;
16
+ --text-secondary: #86868b;
17
+ --text-tertiary: #aeaeb2;
18
+ --accent: #0071e3;
19
+ --accent-hover: #0077ed;
20
+ --success: #34c759;
21
+ --warning: #ff9f0a;
22
+ --error: #ff3b30;
23
+ --selected-bg: #e8f4fd;
24
+ --selected-border: #0071e3;
25
+ }
26
+
27
+ @media (prefers-color-scheme: dark) {
28
+ :root {
29
+ --bg-primary: #1d1d1f;
30
+ --bg-secondary: #2d2d2f;
31
+ --bg-tertiary: #3d3d3f;
32
+ --border: #424245;
33
+ --text-primary: #f5f5f7;
34
+ --text-secondary: #86868b;
35
+ --text-tertiary: #636366;
36
+ --accent: #0a84ff;
37
+ --accent-hover: #409cff;
38
+ --selected-bg: rgba(10, 132, 255, 0.15);
39
+ --selected-border: #0a84ff;
40
+ }
41
+ }
42
+
43
+ body {
44
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
45
+ background: var(--bg-primary);
46
+ color: var(--text-primary);
47
+ display: flex;
48
+ flex-direction: column;
49
+ line-height: 1.5;
50
+ }
51
+
52
+ /* ===== FRAME STRUCTURE ===== */
53
+ .header {
54
+ background: var(--bg-secondary);
55
+ padding: 0.5rem 1.5rem;
56
+ display: flex;
57
+ justify-content: space-between;
58
+ align-items: center;
59
+ border-bottom: 1px solid var(--border);
60
+ flex-shrink: 0;
61
+ }
62
+ .header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
63
+ .header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
64
+ .header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
65
+
66
+ .main { flex: 1; overflow-y: auto; }
67
+ #claude-content { padding: 2rem; min-height: 100%; }
68
+
69
+ .indicator-bar {
70
+ background: var(--bg-secondary);
71
+ border-top: 1px solid var(--border);
72
+ padding: 0.5rem 1.5rem;
73
+ flex-shrink: 0;
74
+ text-align: center;
75
+ }
76
+ .indicator-bar span {
77
+ font-size: 0.75rem;
78
+ color: var(--text-secondary);
79
+ }
80
+ .indicator-bar .selected-text {
81
+ color: var(--accent);
82
+ font-weight: 500;
83
+ }
84
+
85
+ /* ===== TYPOGRAPHY ===== */
86
+ h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
87
+ h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
88
+ .subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
89
+ .section { margin-bottom: 2rem; }
90
+ .label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
91
+
92
+ /* ===== OPTIONS (for A/B/C choices) ===== */
93
+ .options { display: flex; flex-direction: column; gap: 0.75rem; }
94
+ .option {
95
+ background: var(--bg-secondary);
96
+ border: 2px solid var(--border);
97
+ border-radius: 12px;
98
+ padding: 1rem 1.25rem;
99
+ cursor: pointer;
100
+ transition: all 0.15s ease;
101
+ display: flex;
102
+ align-items: flex-start;
103
+ gap: 1rem;
104
+ }
105
+ .option:hover { border-color: var(--accent); }
106
+ .option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
107
+ .option .letter {
108
+ background: var(--bg-tertiary);
109
+ color: var(--text-secondary);
110
+ width: 1.75rem; height: 1.75rem;
111
+ border-radius: 6px;
112
+ display: flex; align-items: center; justify-content: center;
113
+ font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
114
+ }
115
+ .option.selected .letter { background: var(--accent); color: white; }
116
+ .option .content { flex: 1; }
117
+ .option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
118
+ .option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
119
+
120
+ /* ===== CARDS (for showing designs/mockups) ===== */
121
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
122
+ .card {
123
+ background: var(--bg-secondary);
124
+ border: 1px solid var(--border);
125
+ border-radius: 12px;
126
+ overflow: hidden;
127
+ cursor: pointer;
128
+ transition: all 0.15s ease;
129
+ }
130
+ .card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
131
+ .card.selected { border-color: var(--selected-border); border-width: 2px; }
132
+ .card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
133
+ .card-body { padding: 1rem; }
134
+ .card-body h3 { margin-bottom: 0.25rem; }
135
+ .card-body p { color: var(--text-secondary); font-size: 0.85rem; }
136
+
137
+ /* ===== MOCKUP CONTAINER ===== */
138
+ .mockup {
139
+ background: var(--bg-secondary);
140
+ border: 1px solid var(--border);
141
+ border-radius: 12px;
142
+ overflow: hidden;
143
+ margin-bottom: 1.5rem;
144
+ }
145
+ .mockup-header {
146
+ background: var(--bg-tertiary);
147
+ padding: 0.5rem 1rem;
148
+ font-size: 0.75rem;
149
+ color: var(--text-secondary);
150
+ border-bottom: 1px solid var(--border);
151
+ }
152
+ .mockup-body { padding: 1.5rem; }
153
+
154
+ /* ===== SPLIT VIEW (side-by-side comparison) ===== */
155
+ .split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
156
+ @media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
157
+
158
+ /* ===== PROS/CONS ===== */
159
+ .pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
160
+ .pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
161
+ .pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
162
+ .cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
163
+ .pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
164
+ .pros li, .cons li { margin-bottom: 0.25rem; }
165
+
166
+ /* ===== PLACEHOLDER (for mockup areas) ===== */
167
+ .placeholder {
168
+ background: var(--bg-tertiary);
169
+ border: 2px dashed var(--border);
170
+ border-radius: 8px;
171
+ padding: 2rem;
172
+ text-align: center;
173
+ color: var(--text-tertiary);
174
+ }
175
+
176
+ /* ===== INLINE MOCKUP ELEMENTS ===== */
177
+ .mock-nav { background: var(--accent); color: white; padding: 0.75rem 1rem; display: flex; gap: 1.5rem; font-size: 0.9rem; }
178
+ .mock-sidebar { background: var(--bg-tertiary); padding: 1rem; min-width: 180px; }
179
+ .mock-content { padding: 1.5rem; flex: 1; }
180
+ .mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
181
+ .mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
182
+ </style>
183
+ </head>
184
+ <body>
185
+ <div class="header">
186
+ <h1>Supipowers</h1>
187
+ <div class="status">Connected</div>
188
+ </div>
189
+
190
+ <div class="main">
191
+ <div id="claude-content">
192
+ <!-- CONTENT -->
193
+ </div>
194
+ </div>
195
+
196
+ <div class="indicator-bar">
197
+ <span id="indicator-text">Click an option above, then return to the terminal</span>
198
+ </div>
199
+
200
+ </body>
201
+ </html>
@@ -0,0 +1,88 @@
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 (defer so toggleSelect runs first)
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 = 'Click an option above, 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
+ // Frame UI: selection tracking
65
+ window.selectedChoice = null;
66
+
67
+ window.toggleSelect = function(el) {
68
+ const container = el.closest('.options') || el.closest('.cards');
69
+ const multi = container && container.dataset.multiselect !== undefined;
70
+ if (container && !multi) {
71
+ container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
72
+ }
73
+ if (multi) {
74
+ el.classList.toggle('selected');
75
+ } else {
76
+ el.classList.add('selected');
77
+ }
78
+ window.selectedChoice = el.dataset.choice;
79
+ };
80
+
81
+ // Expose API for explicit use
82
+ window.supipowers = {
83
+ send: sendEvent,
84
+ choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
85
+ };
86
+
87
+ connect();
88
+ })();
@@ -0,0 +1,148 @@
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const WebSocket = require('ws');
4
+ const chokidar = require('chokidar');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const PORT = process.env.SUPI_VISUAL_PORT || (49152 + Math.floor(Math.random() * 16383));
9
+ const HOST = process.env.SUPI_VISUAL_HOST || '127.0.0.1';
10
+ const URL_HOST = process.env.SUPI_VISUAL_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
11
+ const SCREEN_DIR = process.env.SUPI_VISUAL_DIR || '/tmp/supi-visual';
12
+
13
+ if (!fs.existsSync(SCREEN_DIR)) {
14
+ fs.mkdirSync(SCREEN_DIR, { recursive: true });
15
+ }
16
+
17
+ // Load frame template and helper script once at startup
18
+ const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
19
+ const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
20
+ const helperInjection = `<script>\n${helperScript}\n</script>`;
21
+
22
+ // Detect whether content is a full HTML document or a bare fragment
23
+ function isFullDocument(html) {
24
+ const trimmed = html.trimStart().toLowerCase();
25
+ return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
26
+ }
27
+
28
+ // Wrap a content fragment in the frame template
29
+ function wrapInFrame(content) {
30
+ return frameTemplate.replace('<!-- CONTENT -->', content);
31
+ }
32
+
33
+ // Find the newest .html file in the directory by mtime
34
+ function getNewestScreen() {
35
+ const files = fs.readdirSync(SCREEN_DIR)
36
+ .filter(f => f.endsWith('.html'))
37
+ .map(f => ({
38
+ name: f,
39
+ path: path.join(SCREEN_DIR, f),
40
+ mtime: fs.statSync(path.join(SCREEN_DIR, f)).mtime.getTime()
41
+ }))
42
+ .sort((a, b) => b.mtime - a.mtime);
43
+
44
+ return files.length > 0 ? files[0].path : null;
45
+ }
46
+
47
+ const WAITING_PAGE = `<!DOCTYPE html>
48
+ <html>
49
+ <head>
50
+ <title>Supipowers Visual Companion</title>
51
+ <style>
52
+ body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
53
+ h1 { color: #333; }
54
+ p { color: #666; }
55
+ @media (prefers-color-scheme: dark) {
56
+ body { background: #1d1d1f; }
57
+ h1 { color: #f5f5f7; }
58
+ p { color: #86868b; }
59
+ }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <h1>Supipowers Visual Companion</h1>
64
+ <p>Waiting for content to be pushed...</p>
65
+ </body>
66
+ </html>`;
67
+
68
+ const app = express();
69
+ const server = http.createServer(app);
70
+ const wss = new WebSocket.Server({ server });
71
+
72
+ const clients = new Set();
73
+
74
+ wss.on('connection', (ws) => {
75
+ clients.add(ws);
76
+ ws.on('close', () => clients.delete(ws));
77
+
78
+ ws.on('message', (data) => {
79
+ const event = JSON.parse(data.toString());
80
+ console.log(JSON.stringify({ source: 'user-event', ...event }));
81
+ // Write user events to .events file for agent to read
82
+ if (event.choice) {
83
+ const eventsFile = path.join(SCREEN_DIR, '.events');
84
+ fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
85
+ }
86
+ });
87
+ });
88
+
89
+ // Serve newest screen with helper.js injected
90
+ app.get('/', (req, res) => {
91
+ const screenFile = getNewestScreen();
92
+ let html;
93
+
94
+ if (!screenFile) {
95
+ html = WAITING_PAGE;
96
+ } else {
97
+ const raw = fs.readFileSync(screenFile, 'utf-8');
98
+ html = isFullDocument(raw) ? raw : wrapInFrame(raw);
99
+ }
100
+
101
+ // Inject helper script
102
+ if (html.includes('</body>')) {
103
+ html = html.replace('</body>', `${helperInjection}\n</body>`);
104
+ } else {
105
+ html += helperInjection;
106
+ }
107
+
108
+ res.type('html').send(html);
109
+ });
110
+
111
+ // Watch for new or changed .html files
112
+ chokidar.watch(SCREEN_DIR, { ignoreInitial: true })
113
+ .on('add', (filePath) => {
114
+ if (filePath.endsWith('.html')) {
115
+ // Clear events from previous screen
116
+ const eventsFile = path.join(SCREEN_DIR, '.events');
117
+ if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
118
+ console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
119
+ clients.forEach(ws => {
120
+ if (ws.readyState === WebSocket.OPEN) {
121
+ ws.send(JSON.stringify({ type: 'reload' }));
122
+ }
123
+ });
124
+ }
125
+ })
126
+ .on('change', (filePath) => {
127
+ if (filePath.endsWith('.html')) {
128
+ console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
129
+ clients.forEach(ws => {
130
+ if (ws.readyState === WebSocket.OPEN) {
131
+ ws.send(JSON.stringify({ type: 'reload' }));
132
+ }
133
+ });
134
+ }
135
+ });
136
+
137
+ server.listen(PORT, HOST, () => {
138
+ const info = JSON.stringify({
139
+ type: 'server-started',
140
+ port: PORT,
141
+ host: HOST,
142
+ url_host: URL_HOST,
143
+ url: `http://${URL_HOST}:${PORT}`,
144
+ screen_dir: SCREEN_DIR
145
+ });
146
+ console.log(info);
147
+ fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n');
148
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "supipowers-visual-server",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "express": "^4.21.0",
7
+ "ws": "^8.18.0",
8
+ "chokidar": "^4.0.0"
9
+ }
10
+ }
@@ -0,0 +1,98 @@
1
+ #!/bin/bash
2
+ # Start the visual companion server and output connection info
3
+ # Usage: start-server.sh [--host <bind-host>] [--url-host <display-host>] [--foreground]
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
6
+
7
+ # Parse arguments
8
+ FOREGROUND="false"
9
+ BIND_HOST="127.0.0.1"
10
+ URL_HOST=""
11
+ while [[ $# -gt 0 ]]; do
12
+ case "$1" in
13
+ --host)
14
+ BIND_HOST="$2"
15
+ shift 2
16
+ ;;
17
+ --url-host)
18
+ URL_HOST="$2"
19
+ shift 2
20
+ ;;
21
+ --foreground|--no-daemon)
22
+ FOREGROUND="true"
23
+ shift
24
+ ;;
25
+ *)
26
+ echo "{\"error\": \"Unknown argument: $1\"}"
27
+ exit 1
28
+ ;;
29
+ esac
30
+ done
31
+
32
+ if [[ -z "$URL_HOST" ]]; then
33
+ if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
34
+ URL_HOST="localhost"
35
+ else
36
+ URL_HOST="$BIND_HOST"
37
+ fi
38
+ fi
39
+
40
+ # Session dir must be set via environment
41
+ SCREEN_DIR="${SUPI_VISUAL_DIR}"
42
+ if [[ -z "$SCREEN_DIR" ]]; then
43
+ echo '{"error": "SUPI_VISUAL_DIR environment variable not set"}'
44
+ exit 1
45
+ fi
46
+
47
+ PID_FILE="${SCREEN_DIR}/.server.pid"
48
+ LOG_FILE="${SCREEN_DIR}/.server.log"
49
+
50
+ # Create session directory if needed
51
+ mkdir -p "$SCREEN_DIR"
52
+
53
+ # Kill any existing server for this session
54
+ if [[ -f "$PID_FILE" ]]; then
55
+ old_pid=$(cat "$PID_FILE")
56
+ kill "$old_pid" 2>/dev/null
57
+ rm -f "$PID_FILE"
58
+ fi
59
+
60
+ cd "$SCRIPT_DIR"
61
+
62
+ # Foreground mode
63
+ if [[ "$FOREGROUND" == "true" ]]; then
64
+ echo "$$" > "$PID_FILE"
65
+ env SUPI_VISUAL_DIR="$SCREEN_DIR" SUPI_VISUAL_HOST="$BIND_HOST" SUPI_VISUAL_URL_HOST="$URL_HOST" node index.js
66
+ exit $?
67
+ fi
68
+
69
+ # Background mode
70
+ nohup env SUPI_VISUAL_DIR="$SCREEN_DIR" SUPI_VISUAL_HOST="$BIND_HOST" SUPI_VISUAL_URL_HOST="$URL_HOST" node index.js > "$LOG_FILE" 2>&1 &
71
+ SERVER_PID=$!
72
+ disown "$SERVER_PID" 2>/dev/null
73
+ echo "$SERVER_PID" > "$PID_FILE"
74
+
75
+ # Wait for server-started message
76
+ for i in {1..50}; do
77
+ if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
78
+ # Verify server is still alive
79
+ alive="true"
80
+ for _ in {1..20}; do
81
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
82
+ alive="false"
83
+ break
84
+ fi
85
+ sleep 0.1
86
+ done
87
+ if [[ "$alive" != "true" ]]; then
88
+ echo "{\"error\": \"Server started but was killed. Retry with --foreground\"}"
89
+ exit 1
90
+ fi
91
+ grep "server-started" "$LOG_FILE" | head -1
92
+ exit 0
93
+ fi
94
+ sleep 0.1
95
+ done
96
+
97
+ echo '{"error": "Server failed to start within 5 seconds"}'
98
+ exit 1
@@ -0,0 +1,21 @@
1
+ #!/bin/bash
2
+ # Stop the visual companion server
3
+ # Usage: stop-server.sh <screen_dir>
4
+
5
+ SCREEN_DIR="$1"
6
+
7
+ if [[ -z "$SCREEN_DIR" ]]; then
8
+ echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
9
+ exit 1
10
+ fi
11
+
12
+ PID_FILE="${SCREEN_DIR}/.server.pid"
13
+
14
+ if [[ -f "$PID_FILE" ]]; then
15
+ pid=$(cat "$PID_FILE")
16
+ kill "$pid" 2>/dev/null
17
+ rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
18
+ echo '{"status": "stopped"}'
19
+ else
20
+ echo '{"status": "not_running"}'
21
+ fi
@@ -0,0 +1,16 @@
1
+ /** Server connection info returned on startup */
2
+ export interface VisualServerInfo {
3
+ port: number;
4
+ host: string;
5
+ url: string;
6
+ screenDir: string;
7
+ }
8
+
9
+ /** A user interaction event captured from the browser */
10
+ export interface VisualEvent {
11
+ type: string;
12
+ choice?: string;
13
+ text?: string;
14
+ id?: string | null;
15
+ timestamp: number;
16
+ }