sema-cli 0.1.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/README.md +78 -0
- package/experiments/spike-shell-stream/bin/analytics.js +209 -0
- package/experiments/spike-shell-stream/bin/sema.js +322 -0
- package/experiments/spike-shell-stream/bin/start.js +387 -0
- package/experiments/spike-shell-stream/mac-agent/agent.js +450 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.js +189 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.test.js +307 -0
- package/experiments/spike-shell-stream/mac-agent/session.js +38 -0
- package/experiments/spike-shell-stream/mobile-web/inbox.html +431 -0
- package/experiments/spike-shell-stream/mobile-web/index.html +1093 -0
- package/experiments/spike-shell-stream/mobile-web/landing.html +586 -0
- package/experiments/spike-shell-stream/mobile-web/pair.html +304 -0
- package/experiments/spike-shell-stream/relay-server/server.js +1085 -0
- package/experiments/spike-shell-stream/shared/crypto.js +138 -0
- package/experiments/spike-shell-stream/shared/crypto.test.js +350 -0
- package/package.json +52 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Sema</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
11
|
+
background: #101417;
|
|
12
|
+
color: #f4f1e8;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
min-height: 100dvh;
|
|
20
|
+
background: #101417;
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
header {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
gap: 12px;
|
|
30
|
+
padding: 10px 14px;
|
|
31
|
+
border-bottom: 1px solid #2b3337;
|
|
32
|
+
flex-shrink: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
h1 {
|
|
36
|
+
font-size: 16px;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
margin: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#refresh-btn {
|
|
42
|
+
background: none;
|
|
43
|
+
border: 1px solid #3a444a;
|
|
44
|
+
border-radius: 6px;
|
|
45
|
+
color: #8a9099;
|
|
46
|
+
font-size: 16px;
|
|
47
|
+
padding: 4px 10px;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
min-height: 32px;
|
|
50
|
+
transition: border-color 0.2s, color 0.2s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#refresh-btn:hover {
|
|
54
|
+
border-color: #4ec9b0;
|
|
55
|
+
color: #4ec9b0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#sessions-list {
|
|
59
|
+
flex: 1;
|
|
60
|
+
overflow-y: auto;
|
|
61
|
+
padding: 12px 14px;
|
|
62
|
+
display: flex;
|
|
63
|
+
flex-direction: column;
|
|
64
|
+
gap: 12px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.card {
|
|
68
|
+
background: #1a1f24;
|
|
69
|
+
border: 1px solid #2b3337;
|
|
70
|
+
border-radius: 12px;
|
|
71
|
+
padding: 16px;
|
|
72
|
+
transition: border-color 0.2s;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.card.paired { border-color: #2b4a3a; }
|
|
76
|
+
.card.offline { opacity: 0.6; }
|
|
77
|
+
|
|
78
|
+
.card-title {
|
|
79
|
+
font-size: 16px;
|
|
80
|
+
font-weight: 700;
|
|
81
|
+
margin-bottom: 6px;
|
|
82
|
+
color: #f4f1e8;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.card-status {
|
|
86
|
+
font-size: 13px;
|
|
87
|
+
color: #8a9099;
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 6px;
|
|
91
|
+
margin-bottom: 12px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.dot {
|
|
95
|
+
display: inline-block;
|
|
96
|
+
width: 8px;
|
|
97
|
+
height: 8px;
|
|
98
|
+
border-radius: 50%;
|
|
99
|
+
flex-shrink: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.dot-online { background: #4ec9b0; }
|
|
103
|
+
.dot-offline { background: #5a6068; }
|
|
104
|
+
.dot-unpaired { background: #a77a3d; }
|
|
105
|
+
|
|
106
|
+
.card-actions {
|
|
107
|
+
display: flex;
|
|
108
|
+
gap: 8px;
|
|
109
|
+
flex-wrap: wrap;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.btn-primary, .btn-secondary {
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
justify-content: center;
|
|
116
|
+
border-radius: 8px;
|
|
117
|
+
font-size: 14px;
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
padding: 10px 16px;
|
|
120
|
+
min-height: 40px;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
text-decoration: none;
|
|
123
|
+
transition: opacity 0.2s;
|
|
124
|
+
border: none;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.btn-primary:hover, .btn-secondary:hover { opacity: 0.9; }
|
|
128
|
+
.btn-primary:active, .btn-secondary:active { opacity: 0.8; }
|
|
129
|
+
|
|
130
|
+
.btn-primary {
|
|
131
|
+
background: #4ec9b0;
|
|
132
|
+
color: #101417;
|
|
133
|
+
flex: 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.btn-secondary {
|
|
137
|
+
background: transparent;
|
|
138
|
+
border: 1px solid #3a444a;
|
|
139
|
+
color: #8a9099;
|
|
140
|
+
padding: 10px 14px;
|
|
141
|
+
flex: 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.actions {
|
|
145
|
+
padding: 12px 14px;
|
|
146
|
+
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
|
147
|
+
border-top: 1px solid #2b3337;
|
|
148
|
+
flex-shrink: 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.action-btn {
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
justify-content: center;
|
|
155
|
+
width: 100%;
|
|
156
|
+
background: #4ec9b0;
|
|
157
|
+
color: #101417;
|
|
158
|
+
border: none;
|
|
159
|
+
border-radius: 8px;
|
|
160
|
+
padding: 14px;
|
|
161
|
+
font-size: 16px;
|
|
162
|
+
font-weight: 600;
|
|
163
|
+
cursor: pointer;
|
|
164
|
+
text-decoration: none;
|
|
165
|
+
transition: opacity 0.2s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.action-btn:hover { opacity: 0.9; }
|
|
169
|
+
.action-btn:active { opacity: 0.8; }
|
|
170
|
+
|
|
171
|
+
.empty {
|
|
172
|
+
flex: 1;
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
align-items: center;
|
|
176
|
+
justify-content: center;
|
|
177
|
+
color: #5a6068;
|
|
178
|
+
font-size: 14px;
|
|
179
|
+
text-align: center;
|
|
180
|
+
padding: 40px 20px;
|
|
181
|
+
gap: 12px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.empty-icon {
|
|
185
|
+
font-size: 32px;
|
|
186
|
+
opacity: 0.5;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.loading {
|
|
190
|
+
flex: 1;
|
|
191
|
+
display: flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
justify-content: center;
|
|
194
|
+
color: #5a6068;
|
|
195
|
+
font-size: 14px;
|
|
196
|
+
padding: 40px 20px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.spinner {
|
|
200
|
+
display: inline-block;
|
|
201
|
+
width: 16px;
|
|
202
|
+
height: 16px;
|
|
203
|
+
border: 2px solid #5a6068;
|
|
204
|
+
border-top-color: transparent;
|
|
205
|
+
border-radius: 50%;
|
|
206
|
+
animation: spin 0.8s linear infinite;
|
|
207
|
+
margin-right: 8px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@keyframes spin {
|
|
211
|
+
to { transform: rotate(360deg); }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.back-link {
|
|
215
|
+
display: inline-block;
|
|
216
|
+
color: #4ec9b0;
|
|
217
|
+
text-decoration: none;
|
|
218
|
+
font-size: 14px;
|
|
219
|
+
margin-top: 16px;
|
|
220
|
+
}
|
|
221
|
+
</style>
|
|
222
|
+
</head>
|
|
223
|
+
<body>
|
|
224
|
+
<header>
|
|
225
|
+
<h1>Sema</h1>
|
|
226
|
+
<button id="refresh-btn" title="Refresh">↻</button>
|
|
227
|
+
</header>
|
|
228
|
+
|
|
229
|
+
<div id="sessions-list">
|
|
230
|
+
<div class="loading"><span class="spinner"></span>Loading sessions...</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div class="actions">
|
|
234
|
+
<a href="/pair.html" class="action-btn">+ Pair New Session</a>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<script>
|
|
238
|
+
// --- Storage helpers ---
|
|
239
|
+
|
|
240
|
+
function getStoredSessions() {
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(localStorage.getItem("vc_sessions") || "{}");
|
|
243
|
+
} catch { return {}; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function removeStoredSession(sessionId) {
|
|
247
|
+
const sessions = getStoredSessions();
|
|
248
|
+
delete sessions[sessionId];
|
|
249
|
+
localStorage.setItem("vc_sessions", JSON.stringify(sessions));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- API ---
|
|
253
|
+
|
|
254
|
+
async function fetchActiveSessions() {
|
|
255
|
+
try {
|
|
256
|
+
const res = await fetch("/api/sessions");
|
|
257
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
258
|
+
const data = await res.json();
|
|
259
|
+
return data.sessions || [];
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.warn("Failed to fetch sessions:", err.message);
|
|
262
|
+
return null; // null = API unavailable
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Merge ---
|
|
267
|
+
|
|
268
|
+
function mergeSessions(stored, active) {
|
|
269
|
+
const merged = new Map();
|
|
270
|
+
|
|
271
|
+
if (active) {
|
|
272
|
+
for (const s of active) {
|
|
273
|
+
const storedSession = stored[s.id];
|
|
274
|
+
merged.set(s.id, {
|
|
275
|
+
id: s.id,
|
|
276
|
+
command: s.command || (storedSession ? storedSession.command : null),
|
|
277
|
+
createdAt: s.createdAt,
|
|
278
|
+
pendingCode: s.pendingCode,
|
|
279
|
+
deviceCount: s.deviceCount,
|
|
280
|
+
hasMac: s.hasMac,
|
|
281
|
+
hasMobiles: s.hasMobiles,
|
|
282
|
+
hasE2E: s.hasE2E,
|
|
283
|
+
paired: !!storedSession,
|
|
284
|
+
offline: false,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Add stored sessions not in API (offline)
|
|
290
|
+
for (const [id, s] of Object.entries(stored)) {
|
|
291
|
+
if (!merged.has(id)) {
|
|
292
|
+
merged.set(id, {
|
|
293
|
+
id: id,
|
|
294
|
+
command: s.command,
|
|
295
|
+
pairedAt: s.pairedAt,
|
|
296
|
+
paired: true,
|
|
297
|
+
offline: true,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return [...merged.values()];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- Utilities ---
|
|
306
|
+
|
|
307
|
+
function esc(str) {
|
|
308
|
+
if (!str) return "";
|
|
309
|
+
return String(str)
|
|
310
|
+
.replace(/&/g, "&").replace(/</g, "<")
|
|
311
|
+
.replace(/>/g, ">").replace(/"/g, """)
|
|
312
|
+
.replace(/'/g, "'");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function formatTimeAgo(timestamp) {
|
|
316
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
317
|
+
if (seconds < 60) return "just now";
|
|
318
|
+
const minutes = Math.floor(seconds / 60);
|
|
319
|
+
if (minutes < 60) return minutes + "m ago";
|
|
320
|
+
const hours = Math.floor(minutes / 60);
|
|
321
|
+
if (hours < 24) return hours + "h ago";
|
|
322
|
+
const days = Math.floor(hours / 24);
|
|
323
|
+
return days + "d ago";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- Rendering ---
|
|
327
|
+
|
|
328
|
+
function renderCard(s) {
|
|
329
|
+
const displayName = s.command || "shell";
|
|
330
|
+
const timeAgo = s.createdAt ? formatTimeAgo(s.createdAt) : "";
|
|
331
|
+
const safeId = esc(s.id);
|
|
332
|
+
|
|
333
|
+
if (s.offline) {
|
|
334
|
+
return '<div class="card offline">'
|
|
335
|
+
+ '<div class="card-title">' + esc(displayName) + '</div>'
|
|
336
|
+
+ '<div class="card-status"><span class="dot dot-offline"></span> Offline</div>'
|
|
337
|
+
+ '<div class="card-actions">'
|
|
338
|
+
+ '<button class="btn-secondary" onclick="handleRemove(\'' + safeId + '\')">Remove</button>'
|
|
339
|
+
+ '</div></div>';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (s.paired) {
|
|
343
|
+
const status = s.hasMac && s.hasMobiles ? "Connected"
|
|
344
|
+
: s.hasMac ? "Mac Online" : "No Mac";
|
|
345
|
+
const dotClass = s.hasMac ? "dot-online" : "dot-offline";
|
|
346
|
+
const extras = (s.deviceCount ? " · " + s.deviceCount + " device" + (s.deviceCount > 1 ? "s" : "") : "")
|
|
347
|
+
+ (timeAgo ? " · " + timeAgo : "");
|
|
348
|
+
return '<div class="card paired">'
|
|
349
|
+
+ '<div class="card-title">' + esc(displayName) + '</div>'
|
|
350
|
+
+ '<div class="card-status"><span class="dot ' + dotClass + '"></span> ' + status + esc(extras) + '</div>'
|
|
351
|
+
+ '<div class="card-actions">'
|
|
352
|
+
+ '<button class="btn-primary" onclick="openSession(\'' + safeId + '\')">Open Terminal</button>'
|
|
353
|
+
+ '<button class="btn-secondary" onclick="handleRemove(\'' + safeId + '\')">Forget</button>'
|
|
354
|
+
+ '</div></div>';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Unpaired
|
|
358
|
+
if (s.pendingCode) {
|
|
359
|
+
const code = s.pendingCode.slice(0, 3) + "-" + s.pendingCode.slice(3);
|
|
360
|
+
const dotClass = s.hasMac ? "dot-unpaired" : "dot-offline";
|
|
361
|
+
const macStatus = s.hasMac ? "Mac Online" : "No Mac";
|
|
362
|
+
return '<div class="card unpaired">'
|
|
363
|
+
+ '<div class="card-title">' + esc(displayName) + '</div>'
|
|
364
|
+
+ '<div class="card-status"><span class="dot ' + dotClass + '"></span> ' + macStatus + ' · Not paired</div>'
|
|
365
|
+
+ '<div class="card-actions">'
|
|
366
|
+
+ '<button class="btn-primary" onclick="pairSession(\'' + esc(s.pendingCode) + '\')">Pair — Code: ' + esc(code) + '</button>'
|
|
367
|
+
+ '</div></div>';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Unpaired, no code available
|
|
371
|
+
const dotClass = s.hasMac ? "dot-unpaired" : "dot-offline";
|
|
372
|
+
const macStatus = s.hasMac ? "Mac Online" : "No Mac";
|
|
373
|
+
return '<div class="card unpaired">'
|
|
374
|
+
+ '<div class="card-title">' + esc(displayName) + '</div>'
|
|
375
|
+
+ '<div class="card-status"><span class="dot ' + dotClass + '"></span> ' + macStatus + ' · Pairing code unavailable</div>'
|
|
376
|
+
+ '<div class="card-actions">'
|
|
377
|
+
+ '<a href="/pair.html" class="btn-secondary">Pair Manually</a>'
|
|
378
|
+
+ '</div></div>';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function renderSessions(sessions) {
|
|
382
|
+
const list = document.getElementById("sessions-list");
|
|
383
|
+
if (sessions.length === 0) {
|
|
384
|
+
list.innerHTML = '<div class="empty">'
|
|
385
|
+
+ '<div class="empty-icon">📱</div>'
|
|
386
|
+
+ '<div>No sessions yet.</div>'
|
|
387
|
+
+ '<div>Pair a new session below.</div>'
|
|
388
|
+
+ '</div>';
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
list.innerHTML = sessions.map(renderCard).join("");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- Actions ---
|
|
395
|
+
|
|
396
|
+
function openSession(sessionId) {
|
|
397
|
+
window.location.href = "/?sessionId=" + encodeURIComponent(sessionId);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function pairSession(code) {
|
|
401
|
+
window.location.href = "/pair.html?code=" + encodeURIComponent(code);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function handleRemove(sessionId) {
|
|
405
|
+
removeStoredSession(sessionId);
|
|
406
|
+
load();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- Main ---
|
|
410
|
+
|
|
411
|
+
async function load() {
|
|
412
|
+
const list = document.getElementById("sessions-list");
|
|
413
|
+
// Only show loading spinner on initial load
|
|
414
|
+
if (!list.querySelector(".card") && !list.querySelector(".empty")) {
|
|
415
|
+
list.innerHTML = '<div class="loading"><span class="spinner"></span>Loading sessions...</div>';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const stored = getStoredSessions();
|
|
419
|
+
const active = await fetchActiveSessions();
|
|
420
|
+
const sessions = mergeSessions(stored, active);
|
|
421
|
+
renderSessions(sessions);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
load();
|
|
425
|
+
document.getElementById("refresh-btn").addEventListener("click", load);
|
|
426
|
+
|
|
427
|
+
// Auto-refresh every 10 seconds
|
|
428
|
+
setInterval(load, 10000);
|
|
429
|
+
</script>
|
|
430
|
+
</body>
|
|
431
|
+
</html>
|