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.
- 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/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,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,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
|
+
}
|