supipowers 0.2.6 → 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 (45) 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/config.ts +53 -23
  8. package/src/commands/plan.ts +96 -31
  9. package/src/commands/qa.ts +150 -29
  10. package/src/commands/release.ts +1 -1
  11. package/src/commands/review.ts +2 -2
  12. package/src/commands/run.ts +52 -2
  13. package/src/commands/update.ts +2 -2
  14. package/src/discipline/debugging.ts +57 -0
  15. package/src/discipline/receiving-review.ts +65 -0
  16. package/src/discipline/tdd.ts +77 -0
  17. package/src/discipline/verification.ts +68 -0
  18. package/src/git/branch-finish.ts +101 -0
  19. package/src/git/worktree.ts +119 -0
  20. package/src/index.ts +11 -2
  21. package/src/lsp/detector.ts +2 -2
  22. package/src/orchestrator/agent-prompts.ts +282 -0
  23. package/src/orchestrator/dispatcher.ts +150 -1
  24. package/src/orchestrator/prompts.ts +17 -31
  25. package/src/planning/plan-reviewer.ts +49 -0
  26. package/src/planning/plan-writer-prompt.ts +173 -0
  27. package/src/planning/prompt-builder.ts +178 -0
  28. package/src/planning/spec-reviewer.ts +43 -0
  29. package/src/qa/phases/discovery.ts +34 -0
  30. package/src/qa/phases/execution.ts +65 -0
  31. package/src/qa/phases/matrix.ts +41 -0
  32. package/src/qa/phases/reporting.ts +71 -0
  33. package/src/qa/session.ts +104 -0
  34. package/src/storage/qa-sessions.ts +83 -0
  35. package/src/storage/specs.ts +36 -0
  36. package/src/types.ts +70 -0
  37. package/src/visual/companion.ts +115 -0
  38. package/src/visual/prompt-instructions.ts +102 -0
  39. package/src/visual/scripts/frame-template.html +201 -0
  40. package/src/visual/scripts/helper.js +88 -0
  41. package/src/visual/scripts/index.js +148 -0
  42. package/src/visual/scripts/package.json +10 -0
  43. package/src/visual/scripts/start-server.sh +98 -0
  44. package/src/visual/scripts/stop-server.sh +21 -0
  45. package/src/visual/types.ts +16 -0
@@ -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
+ }