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 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
+ });