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
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Sema
|
|
2
|
+
|
|
3
|
+
> **Sema — the signal between you and your agents.**
|
|
4
|
+
|
|
5
|
+
Sema is a mobile control console for AI CLI workflows running on a user's Mac.
|
|
6
|
+
|
|
7
|
+
The first product promise:
|
|
8
|
+
|
|
9
|
+
> Use an iPhone to monitor and control AI CLI sessions running on a Mac, without managing SSH, tmux, Tailscale, or fragile mobile network reconnection by hand.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Run instantly (no install):
|
|
15
|
+
npx sema-cli claude
|
|
16
|
+
|
|
17
|
+
# Or install globally:
|
|
18
|
+
npm install -g sema-cli
|
|
19
|
+
sema claude
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires macOS + Node 22+ + tmux. A QR code appears in your terminal — scan it with your phone, confirm the fingerprint, and your agent can reach you on the go. End-to-end encrypted (X25519 + AES-256-GCM); the relay sees only ciphertext.
|
|
23
|
+
|
|
24
|
+
## Why "Sema"
|
|
25
|
+
|
|
26
|
+
From Greek **σῆμα** — *a sign, a signal* — and the root of **semantics**. The product's job is to turn an agent's raw terminal output into the one signal a human can act on in seconds. That same semantic layer is the product's technical moat (`docs/PRODUCT.md`): SSH will never do this. A *sema* is what an agent sends when it needs you.
|
|
27
|
+
|
|
28
|
+
## Current Stage
|
|
29
|
+
|
|
30
|
+
Stage: project kickoff.
|
|
31
|
+
|
|
32
|
+
The project is not yet in product code mode. We are first setting up the operating system for Vibe Coding:
|
|
33
|
+
|
|
34
|
+
- product scope
|
|
35
|
+
- agent organization
|
|
36
|
+
- collaboration loop
|
|
37
|
+
- system architecture
|
|
38
|
+
- security model
|
|
39
|
+
- acceptance tests
|
|
40
|
+
- Sprint 1 plan
|
|
41
|
+
|
|
42
|
+
## First Milestone
|
|
43
|
+
|
|
44
|
+
The first milestone is a technical slice:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
Mac local shell process
|
|
48
|
+
-> WebSocket relay
|
|
49
|
+
-> mobile web page
|
|
50
|
+
-> output visible on phone
|
|
51
|
+
-> phone input written back to shell
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This milestone proves the core remote-control loop before investing in native macOS or iOS apps.
|
|
55
|
+
|
|
56
|
+
## Directory Map
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
docs/ Project strategy, architecture, security, and process docs
|
|
60
|
+
apps/mac-agent/ Future macOS local agent
|
|
61
|
+
apps/mobile-pwa/ First mobile prototype
|
|
62
|
+
apps/ios-app/ Future native iOS app
|
|
63
|
+
services/relay-server/ Future relay service
|
|
64
|
+
experiments/ Throwaway technical spikes
|
|
65
|
+
ops/ Demo, beta, launch, and user feedback artifacts
|
|
66
|
+
prompts/ Reusable Vibe Coding prompts and role briefs
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Working Rule
|
|
70
|
+
|
|
71
|
+
Every implementation task must have:
|
|
72
|
+
|
|
73
|
+
1. one clear goal
|
|
74
|
+
2. one owner agent
|
|
75
|
+
3. one acceptance test
|
|
76
|
+
4. one way to run it locally
|
|
77
|
+
5. one decision log entry if it changes architecture or security
|
|
78
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sema Analytics CLI
|
|
5
|
+
*
|
|
6
|
+
* Reads event data from data/events.jsonl and waitlist from data/waitlist.jsonl,
|
|
7
|
+
* outputs usage statistics to the terminal.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node bin/analytics.js # All-time stats
|
|
11
|
+
* node bin/analytics.js --days 7 # Last N days
|
|
12
|
+
* node bin/analytics.js --raw # Raw JSONL output
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require("node:fs");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
|
|
18
|
+
const DATA_DIR = path.join(__dirname, "../../../data");
|
|
19
|
+
const EVENTS_FILE = path.join(DATA_DIR, "events.jsonl");
|
|
20
|
+
const WAITLIST_FILE = path.join(DATA_DIR, "waitlist.jsonl");
|
|
21
|
+
|
|
22
|
+
// Parse CLI args
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const daysArg = args.indexOf("--days");
|
|
25
|
+
const daysFilter = daysArg !== -1 ? Number(args[daysArg + 1]) : null;
|
|
26
|
+
const rawMode = args.includes("--raw");
|
|
27
|
+
|
|
28
|
+
function readJsonl(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
31
|
+
if (!raw) return [];
|
|
32
|
+
return raw.split("\n").map(line => JSON.parse(line));
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Read data
|
|
39
|
+
let events = readJsonl(EVENTS_FILE);
|
|
40
|
+
const waitlist = readJsonl(WAITLIST_FILE);
|
|
41
|
+
|
|
42
|
+
// Apply date filter
|
|
43
|
+
if (daysFilter) {
|
|
44
|
+
const cutoff = new Date(Date.now() - daysFilter * 24 * 60 * 60 * 1000).toISOString();
|
|
45
|
+
events = events.filter(e => e.t >= cutoff);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Raw mode
|
|
49
|
+
if (rawMode) {
|
|
50
|
+
console.log(JSON.stringify({ events, waitlist }, null, 2));
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Aggregate ---
|
|
55
|
+
|
|
56
|
+
function count(type, filter) {
|
|
57
|
+
return events.filter(e => e.type === type && (!filter || filter(e))).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sessionCreated = count("session.created");
|
|
61
|
+
const sessionCleaned = count("session.cleaned");
|
|
62
|
+
const pairCompleted = count("pair.completed");
|
|
63
|
+
const pairFailed = count("pair.failed");
|
|
64
|
+
const alertTotal = count("alert.sent");
|
|
65
|
+
const alertSmart = count("alert.sent", e => e.kind === "smart_alert");
|
|
66
|
+
const alertIdle = alertTotal - alertSmart;
|
|
67
|
+
const keyExchanges = count("key_exchange.completed");
|
|
68
|
+
const macConnects = count("ws.connected", e => e.role === "mac");
|
|
69
|
+
const mobileConnects = count("ws.connected", e => e.role === "mobile");
|
|
70
|
+
const disconnections = events.filter(e => e.type === "ws.disconnected" && e.durationMs);
|
|
71
|
+
|
|
72
|
+
// Connection duration stats
|
|
73
|
+
let avgDurationMin = 0;
|
|
74
|
+
let maxDurationMin = 0;
|
|
75
|
+
if (disconnections.length) {
|
|
76
|
+
const durations = disconnections.map(e => e.durationMs);
|
|
77
|
+
avgDurationMin = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length / 60000);
|
|
78
|
+
maxDurationMin = Math.round(Math.max(...durations) / 60000);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Session lifetime stats
|
|
82
|
+
const cleanedSessions = events.filter(e => e.type === "session.cleaned" && e.lifetimeMs);
|
|
83
|
+
let avgLifetimeMin = 0;
|
|
84
|
+
if (cleanedSessions.length) {
|
|
85
|
+
const lifetimes = cleanedSessions.map(e => e.lifetimeMs);
|
|
86
|
+
avgLifetimeMin = Math.round(lifetimes.reduce((a, b) => a + b, 0) / lifetimes.length / 60000);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Daily breakdown
|
|
90
|
+
const byDay = {};
|
|
91
|
+
for (const e of events) {
|
|
92
|
+
const day = e.t.slice(0, 10);
|
|
93
|
+
if (!byDay[day]) byDay[day] = { sessions: 0, pairs: 0, alerts: 0 };
|
|
94
|
+
if (e.type === "session.created") byDay[day].sessions++;
|
|
95
|
+
if (e.type === "pair.completed") byDay[day].pairs++;
|
|
96
|
+
if (e.type === "alert.sent") byDay[day].alerts++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Hourly distribution (when do users connect?)
|
|
100
|
+
const byHour = new Array(24).fill(0);
|
|
101
|
+
for (const e of events) {
|
|
102
|
+
if (e.type === "ws.connected") {
|
|
103
|
+
const hour = new Date(e.t).getHours();
|
|
104
|
+
byHour[hour]++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Pair success rate
|
|
109
|
+
const pairTotal = pairCompleted + pairFailed;
|
|
110
|
+
const pairSuccessRate = pairTotal > 0 ? Math.round((pairCompleted / pairTotal) * 100) : 0;
|
|
111
|
+
|
|
112
|
+
// Waitlist pricing distribution
|
|
113
|
+
const pricingDist = {};
|
|
114
|
+
for (const w of waitlist) {
|
|
115
|
+
const p = w.pricing || "unspecified";
|
|
116
|
+
pricingDist[p] = (pricingDist[p] || 0) + 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Output ---
|
|
120
|
+
|
|
121
|
+
const period = daysFilter ? `Last ${daysFilter} days` : "All time";
|
|
122
|
+
const eventRange = events.length
|
|
123
|
+
? `${events[0].t.slice(0, 10)} → ${events[events.length - 1].t.slice(0, 10)}`
|
|
124
|
+
: "No events yet";
|
|
125
|
+
|
|
126
|
+
console.log("");
|
|
127
|
+
console.log("╔══════════════════════════════════════════╗");
|
|
128
|
+
console.log("║ Sema Analytics ║");
|
|
129
|
+
console.log("╚══════════════════════════════════════════╝");
|
|
130
|
+
console.log("");
|
|
131
|
+
console.log(` Period: ${period} (${eventRange})`);
|
|
132
|
+
console.log(` Total events: ${events.length}`);
|
|
133
|
+
console.log("");
|
|
134
|
+
|
|
135
|
+
console.log(" ─── Sessions ───");
|
|
136
|
+
console.log(` Created: ${sessionCreated}`);
|
|
137
|
+
console.log(` Cleaned: ${sessionCleaned}`);
|
|
138
|
+
if (cleanedSessions.length) {
|
|
139
|
+
console.log(` Avg lifetime: ${avgLifetimeMin} min`);
|
|
140
|
+
}
|
|
141
|
+
console.log("");
|
|
142
|
+
|
|
143
|
+
console.log(" ─── Pairing ───");
|
|
144
|
+
console.log(` Completed: ${pairCompleted}`);
|
|
145
|
+
console.log(` Failed: ${pairFailed}`);
|
|
146
|
+
if (pairTotal > 0) {
|
|
147
|
+
console.log(` Success rate: ${pairSuccessRate}%`);
|
|
148
|
+
}
|
|
149
|
+
console.log("");
|
|
150
|
+
|
|
151
|
+
console.log(" ─── Alerts ───");
|
|
152
|
+
console.log(` Total: ${alertTotal}`);
|
|
153
|
+
console.log(` Smart: ${alertSmart}`);
|
|
154
|
+
console.log(` Idle: ${alertIdle}`);
|
|
155
|
+
console.log("");
|
|
156
|
+
|
|
157
|
+
console.log(" ─── Connections ───");
|
|
158
|
+
console.log(` Mac: ${macConnects}`);
|
|
159
|
+
console.log(` Mobile: ${mobileConnects}`);
|
|
160
|
+
if (disconnections.length) {
|
|
161
|
+
console.log(` Avg duration: ${avgDurationMin} min`);
|
|
162
|
+
console.log(` Max duration: ${maxDurationMin} min`);
|
|
163
|
+
}
|
|
164
|
+
console.log(` Key exchanges: ${keyExchanges}`);
|
|
165
|
+
console.log("");
|
|
166
|
+
|
|
167
|
+
if (waitlist.length) {
|
|
168
|
+
console.log(" ─── Waitlist ───");
|
|
169
|
+
console.log(` Signups: ${waitlist.length}`);
|
|
170
|
+
if (Object.keys(pricingDist).length) {
|
|
171
|
+
console.log(" Pricing preferences:");
|
|
172
|
+
for (const [price, cnt] of Object.entries(pricingDist).sort((a, b) => b[1] - a[1])) {
|
|
173
|
+
console.log(` ${price.padEnd(16)} ${cnt}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
console.log("");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Object.keys(byDay).length > 0) {
|
|
180
|
+
console.log(" ─── Daily Breakdown ───");
|
|
181
|
+
console.log(" Date Sessions Pairs Alerts");
|
|
182
|
+
const sortedDays = Object.entries(byDay).sort((a, b) => b[0].localeCompare(a[0]));
|
|
183
|
+
for (const [day, d] of sortedDays.slice(0, 14)) {
|
|
184
|
+
console.log(` ${day} ${String(d.sessions).padStart(8)} ${String(d.pairs).padStart(5)} ${String(d.alerts).padStart(6)}`);
|
|
185
|
+
}
|
|
186
|
+
console.log("");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Peak hours
|
|
190
|
+
const peakHours = byHour
|
|
191
|
+
.map((count, hour) => ({ hour, count }))
|
|
192
|
+
.filter(h => h.count > 0)
|
|
193
|
+
.sort((a, b) => b.count - a.count);
|
|
194
|
+
|
|
195
|
+
if (peakHours.length) {
|
|
196
|
+
console.log(" ─── Peak Hours ───");
|
|
197
|
+
for (const h of peakHours.slice(0, 5)) {
|
|
198
|
+
const label = `${String(h.hour).padStart(2, "0")}:00`;
|
|
199
|
+
const bar = "█".repeat(Math.min(h.count, 30));
|
|
200
|
+
console.log(` ${label} ${bar} ${h.count}`);
|
|
201
|
+
}
|
|
202
|
+
console.log("");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (events.length === 0) {
|
|
206
|
+
console.log(" No events recorded yet.");
|
|
207
|
+
console.log(" Start the relay and create a session to generate events.");
|
|
208
|
+
console.log("");
|
|
209
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sema CLI — Public relay mode
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* sema claude # Start Claude Code with public relay
|
|
8
|
+
* sema claude codex # Multi-agent
|
|
9
|
+
* sema --local claude # Local relay mode (same as start.js)
|
|
10
|
+
* RELAY_URL=https://my-relay.com sema claude # Custom relay
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { fork } = require("node:child_process");
|
|
14
|
+
const http = require("node:http");
|
|
15
|
+
const https = require("node:https");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
|
|
18
|
+
const { generateKeyPair, exportPublicKey, exportPrivateKey } = require(
|
|
19
|
+
path.join(__dirname, "../shared/crypto"),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// --- Config ---
|
|
23
|
+
|
|
24
|
+
// Default public relay — deployed on Railway (see docs/DEPLOY.md).
|
|
25
|
+
// Override per-run with: SEMA_RELAY=https://<other-relay> sema claude
|
|
26
|
+
const DEFAULT_RELAY_HTTP = process.env.SEMA_RELAY || "https://sema-relay.up.railway.app";
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const USE_LOCAL = args.includes("--local");
|
|
29
|
+
|
|
30
|
+
if (USE_LOCAL) {
|
|
31
|
+
// Delegate to local mode (starts relay + agent together)
|
|
32
|
+
const filteredArgs = args.filter((a) => a !== "--local");
|
|
33
|
+
process.argv = [process.argv[0], process.argv[1], ...filteredArgs];
|
|
34
|
+
require("./start.js");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const RELAY_HTTP = (process.env.SEMA_RELAY || DEFAULT_RELAY_HTTP).replace(/\/$/, "");
|
|
39
|
+
const RELAY_WS = RELAY_HTTP.replace(/^http:/, "ws:").replace(/^https:/, "wss:") + "/ws";
|
|
40
|
+
|
|
41
|
+
const SPIKE_DIR = path.resolve(__dirname, "..");
|
|
42
|
+
const AGENT_SCRIPT = path.join(SPIKE_DIR, "mac-agent/agent.js");
|
|
43
|
+
|
|
44
|
+
// CLI arguments: one or more commands to run (e.g., "claude", "claude codex")
|
|
45
|
+
const COMMANDS = args.filter((a) => !a.startsWith("--"));
|
|
46
|
+
const commands = COMMANDS.length > 0 ? COMMANDS : [null]; // [null] = default shell
|
|
47
|
+
|
|
48
|
+
const agentProcesses = [];
|
|
49
|
+
let shuttingDown = false;
|
|
50
|
+
|
|
51
|
+
// --- HTTP helpers (support both http and https) ---
|
|
52
|
+
|
|
53
|
+
function getLib(urlString) {
|
|
54
|
+
return new URL(urlString).protocol === "https:" ? https : http;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function httpGet(urlString) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const lib = getLib(urlString);
|
|
60
|
+
const req = lib.get(urlString, { timeout: 5000 }, (res) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
res.on("data", (c) => chunks.push(c));
|
|
63
|
+
res.on("end", () =>
|
|
64
|
+
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
req.on("error", reject);
|
|
68
|
+
req.on("timeout", () => {
|
|
69
|
+
req.destroy();
|
|
70
|
+
reject(new Error("timeout"));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function httpPost(urlString, body = null) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const parsedUrl = new URL(urlString);
|
|
78
|
+
const lib = getLib(urlString);
|
|
79
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
80
|
+
const req = lib.request(
|
|
81
|
+
{
|
|
82
|
+
hostname: parsedUrl.hostname,
|
|
83
|
+
port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80),
|
|
84
|
+
path: parsedUrl.pathname,
|
|
85
|
+
method: "POST",
|
|
86
|
+
timeout: 10000,
|
|
87
|
+
headers: payload
|
|
88
|
+
? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }
|
|
89
|
+
: {},
|
|
90
|
+
},
|
|
91
|
+
(res) => {
|
|
92
|
+
const chunks = [];
|
|
93
|
+
res.on("data", (c) => chunks.push(c));
|
|
94
|
+
res.on("end", () => {
|
|
95
|
+
try {
|
|
96
|
+
resolve({
|
|
97
|
+
status: res.statusCode,
|
|
98
|
+
data: JSON.parse(Buffer.concat(chunks).toString()),
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
reject(new Error("Invalid JSON from relay"));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
req.on("error", reject);
|
|
107
|
+
req.on("timeout", () => {
|
|
108
|
+
req.destroy();
|
|
109
|
+
reject(new Error("timeout"));
|
|
110
|
+
});
|
|
111
|
+
if (payload) req.write(payload);
|
|
112
|
+
req.end();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Agent management ---
|
|
117
|
+
|
|
118
|
+
function startAgent(sessionId, macToken, macPrivateKey, macPublicKey, command) {
|
|
119
|
+
const label = command || "shell";
|
|
120
|
+
const env = {
|
|
121
|
+
...process.env,
|
|
122
|
+
SESSION_ID: sessionId,
|
|
123
|
+
SESSION_TOKEN: macToken,
|
|
124
|
+
RELAY_URL: RELAY_WS,
|
|
125
|
+
MAC_PRIVATE_KEY: macPrivateKey,
|
|
126
|
+
MAC_PUBLIC_KEY: macPublicKey,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (command) {
|
|
130
|
+
env.CLI_COMMAND = command;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const agent = fork(AGENT_SCRIPT, [], {
|
|
134
|
+
env,
|
|
135
|
+
stdio: "pipe",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
agent.stdout.on("data", (data) => {
|
|
139
|
+
const msg = data.toString().trim();
|
|
140
|
+
if (msg) console.log(` [${label}] ${msg.replace(/^\[mac-agent\] /, "")}`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
agent.stderr.on("data", (data) => {
|
|
144
|
+
const msg = data.toString().trim();
|
|
145
|
+
if (msg) console.error(` [${label}:err] ${msg}`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
agent.on("exit", (code) => {
|
|
149
|
+
if (!shuttingDown) {
|
|
150
|
+
console.log(`\n[start] Agent "${label}" exited (code: ${code || 0}).`);
|
|
151
|
+
const idx = agentProcesses.findIndex((a) => a.process === agent);
|
|
152
|
+
if (idx >= 0) agentProcesses.splice(idx, 1);
|
|
153
|
+
if (agentProcesses.length === 0) {
|
|
154
|
+
console.log("[start] All agents exited. Shutting down.");
|
|
155
|
+
cleanup();
|
|
156
|
+
process.exit(code || 0);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return agent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Cleanup ---
|
|
165
|
+
|
|
166
|
+
function cleanup() {
|
|
167
|
+
if (shuttingDown) return;
|
|
168
|
+
shuttingDown = true;
|
|
169
|
+
|
|
170
|
+
for (const { process: agent } of agentProcesses) {
|
|
171
|
+
if (agent && !agent.killed) agent.kill("SIGTERM");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
for (const { process: agent } of agentProcesses) {
|
|
176
|
+
if (agent && !agent.killed) agent.kill("SIGKILL");
|
|
177
|
+
}
|
|
178
|
+
}, 3000);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
process.on("SIGINT", () => {
|
|
182
|
+
console.log("\n[start] Shutting down...");
|
|
183
|
+
cleanup();
|
|
184
|
+
process.exit(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
process.on("SIGTERM", () => {
|
|
188
|
+
cleanup();
|
|
189
|
+
process.exit(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
process.on("uncaughtException", (err) => {
|
|
193
|
+
console.error(`\n\x1b[31m[start] Uncaught error: ${err.message}\x1b[0m`);
|
|
194
|
+
cleanup();
|
|
195
|
+
process.exit(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// --- Display ---
|
|
199
|
+
|
|
200
|
+
function displayInfo(sessionInfos) {
|
|
201
|
+
const qrcode = require("qrcode-terminal");
|
|
202
|
+
const pairUrl = `${RELAY_HTTP}/pair?code=${sessionInfos[0].code}`;
|
|
203
|
+
|
|
204
|
+
console.log("");
|
|
205
|
+
console.log("┌─────────────────────────────────────┐");
|
|
206
|
+
if (sessionInfos.length === 1) {
|
|
207
|
+
console.log("│ \x1b[1;32mSema Session Ready\x1b[0m │");
|
|
208
|
+
} else {
|
|
209
|
+
console.log(`│ \x1b[1;32mSema — ${sessionInfos.length} Agents Ready\x1b[0m │`);
|
|
210
|
+
}
|
|
211
|
+
console.log("└─────────────────────────────────────┘");
|
|
212
|
+
console.log("");
|
|
213
|
+
|
|
214
|
+
qrcode.generate(pairUrl, { small: true }, (qr) => {
|
|
215
|
+
console.log(qr);
|
|
216
|
+
console.log("");
|
|
217
|
+
console.log(` \x1b[2mScan with phone camera, or visit:\x1b[0m`);
|
|
218
|
+
console.log(` \x1b[1;36m${pairUrl}\x1b[0m`);
|
|
219
|
+
console.log("");
|
|
220
|
+
|
|
221
|
+
if (sessionInfos.length > 1) {
|
|
222
|
+
console.log(" Sessions:");
|
|
223
|
+
}
|
|
224
|
+
for (let i = 0; i < sessionInfos.length; i++) {
|
|
225
|
+
const info = sessionInfos[i];
|
|
226
|
+
const prefix = sessionInfos.length > 1 ? ` ${i + 1}. ` : " ";
|
|
227
|
+
const label = info.label.padEnd(12);
|
|
228
|
+
console.log(`${prefix}\x1b[1m${label}\x1b[0m Code: \x1b[1;36m${info.code}\x1b[0m`);
|
|
229
|
+
}
|
|
230
|
+
console.log("");
|
|
231
|
+
console.log(` \x1b[2mRelay: ${RELAY_HTTP}\x1b[0m`);
|
|
232
|
+
console.log(" \x1b[2mPress Ctrl+C to stop.\x1b[0m");
|
|
233
|
+
console.log("");
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Main ---
|
|
238
|
+
|
|
239
|
+
async function main() {
|
|
240
|
+
console.log("");
|
|
241
|
+
console.log(" \x1b[1mSema\x1b[0m");
|
|
242
|
+
console.log("");
|
|
243
|
+
|
|
244
|
+
// 1. Check relay health
|
|
245
|
+
console.log(`[start] Connecting to relay at ${RELAY_HTTP}...`);
|
|
246
|
+
try {
|
|
247
|
+
const res = await httpGet(`${RELAY_HTTP}/health`);
|
|
248
|
+
if (res.status !== 200) throw new Error(`Relay returned HTTP ${res.status}`);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error(`\x1b[31m✗ Cannot reach relay at ${RELAY_HTTP}\x1b[0m`);
|
|
251
|
+
console.error(` ${err.message}`);
|
|
252
|
+
console.error("");
|
|
253
|
+
console.error(" Options:");
|
|
254
|
+
console.error(" • Check your internet connection");
|
|
255
|
+
console.error(" • Set SEMA_RELAY=<url> for a custom relay");
|
|
256
|
+
console.error(" • Use --local to run a local relay instead");
|
|
257
|
+
console.error("");
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
console.log("[start] Relay is reachable.");
|
|
261
|
+
|
|
262
|
+
// 2. Create sessions + start agents
|
|
263
|
+
const sessionInfos = [];
|
|
264
|
+
|
|
265
|
+
for (const command of commands) {
|
|
266
|
+
const label = command || "shell";
|
|
267
|
+
|
|
268
|
+
// Generate E2E keypair
|
|
269
|
+
console.log(`[start] Generating keys for ${label}...`);
|
|
270
|
+
const keyPair = generateKeyPair();
|
|
271
|
+
const macPublicKey = exportPublicKey(keyPair.publicKey);
|
|
272
|
+
const macPrivateKey = exportPrivateKey(keyPair.privateKey);
|
|
273
|
+
|
|
274
|
+
// Create session on public relay
|
|
275
|
+
console.log(`[start] Creating session for ${label}...`);
|
|
276
|
+
let sessionData;
|
|
277
|
+
try {
|
|
278
|
+
const res = await httpPost(`${RELAY_HTTP}/api/sessions`, {
|
|
279
|
+
macPublicKey,
|
|
280
|
+
command: command || null,
|
|
281
|
+
});
|
|
282
|
+
if (res.status !== 201) {
|
|
283
|
+
throw new Error(res.data.error || `HTTP ${res.status}`);
|
|
284
|
+
}
|
|
285
|
+
sessionData = res.data;
|
|
286
|
+
} catch (err) {
|
|
287
|
+
console.error(`\x1b[31m✗ Failed to create session for ${label}: ${err.message}\x1b[0m`);
|
|
288
|
+
cleanup();
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
sessionInfos.push({
|
|
293
|
+
label,
|
|
294
|
+
code: sessionData.code,
|
|
295
|
+
sessionId: sessionData.sessionId,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Start agent
|
|
299
|
+
const agent = startAgent(
|
|
300
|
+
sessionData.sessionId,
|
|
301
|
+
sessionData.macToken,
|
|
302
|
+
macPrivateKey,
|
|
303
|
+
macPublicKey,
|
|
304
|
+
command,
|
|
305
|
+
);
|
|
306
|
+
agentProcesses.push({
|
|
307
|
+
process: agent,
|
|
308
|
+
command,
|
|
309
|
+
label,
|
|
310
|
+
sessionId: sessionData.sessionId,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 3. Display QR + session info
|
|
315
|
+
displayInfo(sessionInfos);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
main().catch((err) => {
|
|
319
|
+
console.error(`\x1b[31m✗ ${err.message}\x1b[0m`);
|
|
320
|
+
cleanup();
|
|
321
|
+
process.exit(1);
|
|
322
|
+
});
|