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.
- package/package.json +21 -6
- package/skills/debugging/SKILL.md +54 -15
- package/skills/planning/SKILL.md +70 -10
- package/skills/receiving-code-review/SKILL.md +87 -0
- package/skills/tdd/SKILL.md +83 -0
- package/skills/verification/SKILL.md +54 -0
- package/src/commands/config.ts +53 -23
- package/src/commands/plan.ts +96 -31
- package/src/commands/qa.ts +150 -29
- package/src/commands/release.ts +1 -1
- package/src/commands/review.ts +2 -2
- package/src/commands/run.ts +52 -2
- package/src/commands/update.ts +2 -2
- package/src/discipline/debugging.ts +57 -0
- package/src/discipline/receiving-review.ts +65 -0
- package/src/discipline/tdd.ts +77 -0
- package/src/discipline/verification.ts +68 -0
- package/src/git/branch-finish.ts +101 -0
- package/src/git/worktree.ts +119 -0
- package/src/index.ts +11 -2
- package/src/lsp/detector.ts +2 -2
- package/src/orchestrator/agent-prompts.ts +282 -0
- package/src/orchestrator/dispatcher.ts +150 -1
- package/src/orchestrator/prompts.ts +17 -31
- package/src/planning/plan-reviewer.ts +49 -0
- package/src/planning/plan-writer-prompt.ts +173 -0
- package/src/planning/prompt-builder.ts +178 -0
- package/src/planning/spec-reviewer.ts +43 -0
- package/src/qa/phases/discovery.ts +34 -0
- package/src/qa/phases/execution.ts +65 -0
- package/src/qa/phases/matrix.ts +41 -0
- package/src/qa/phases/reporting.ts +71 -0
- package/src/qa/session.ts +104 -0
- package/src/storage/qa-sessions.ts +83 -0
- package/src/storage/specs.ts +36 -0
- package/src/types.ts +70 -0
- package/src/visual/companion.ts +115 -0
- package/src/visual/prompt-instructions.ts +102 -0
- package/src/visual/scripts/frame-template.html +201 -0
- package/src/visual/scripts/helper.js +88 -0
- package/src/visual/scripts/index.js +148 -0
- package/src/visual/scripts/package.json +10 -0
- package/src/visual/scripts/start-server.sh +98 -0
- package/src/visual/scripts/stop-server.sh +21 -0
- 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,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
|
+
}
|