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,450 @@
|
|
|
1
|
+
const pty = require("node-pty-prebuilt-multiarch");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const { execSync } = require("node:child_process");
|
|
4
|
+
const { analyze } = require("./analyzer");
|
|
5
|
+
const { importPrivateKey, importPublicKey, deriveSharedSecret, deriveAesKey, encrypt, decrypt, computeFingerprint, generateKeyPair, exportPublicKey } = require("../shared/crypto");
|
|
6
|
+
|
|
7
|
+
const RELAY_URL = process.env.RELAY_URL || "ws://127.0.0.1:8787/ws";
|
|
8
|
+
const SESSION_ID = process.env.SESSION_ID;
|
|
9
|
+
const SESSION_TOKEN = process.env.SESSION_TOKEN;
|
|
10
|
+
const SHELL = process.env.SHELL || "/bin/zsh";
|
|
11
|
+
const CLI_COMMAND = process.env.CLI_COMMAND || "";
|
|
12
|
+
const INITIAL_COLS = Number(process.env.COLS || 80);
|
|
13
|
+
const INITIAL_ROWS = Number(process.env.ROWS || 24);
|
|
14
|
+
const RECONNECT_MS = 1000;
|
|
15
|
+
const IDLE_TIMEOUT_MS = 3000;
|
|
16
|
+
const BUFFER_MAX = 4096;
|
|
17
|
+
|
|
18
|
+
// E2E encryption keys (passed from CLI via env vars)
|
|
19
|
+
const MAC_PRIVATE_KEY = process.env.MAC_PRIVATE_KEY;
|
|
20
|
+
const MAC_PUBLIC_KEY = process.env.MAC_PUBLIC_KEY;
|
|
21
|
+
let privateKey = null;
|
|
22
|
+
|
|
23
|
+
if (MAC_PRIVATE_KEY && MAC_PUBLIC_KEY) {
|
|
24
|
+
try {
|
|
25
|
+
privateKey = importPrivateKey(MAC_PRIVATE_KEY);
|
|
26
|
+
console.log("[mac-agent] E2E encryption enabled");
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error("[mac-agent] Warning: failed to import private key, E2E disabled:", err.message);
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
console.log("[mac-agent] E2E encryption disabled (no keys provided)");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Per-device encryption state
|
|
35
|
+
const mobileKeys = new Map(); // deviceId → AES key (Buffer)
|
|
36
|
+
const mobilePendingKeys = new Map(); // deviceId → { oldKey, fingerprint, ttl }
|
|
37
|
+
const preKeyOutputBuffer = []; // output buffered before any key exchange
|
|
38
|
+
const PRE_KEY_MAX = 128;
|
|
39
|
+
|
|
40
|
+
// Terminal DA response patterns to filter from mobile input.
|
|
41
|
+
// The mobile's xterm.js auto-responds to DA queries from tmux/zsh —
|
|
42
|
+
// these must not reach the pty as shell input.
|
|
43
|
+
const DA_RESPONSE = /^\x1b\[\??[0-9;]*[a-zA-Z]$/;
|
|
44
|
+
|
|
45
|
+
// Validate required session parameters
|
|
46
|
+
if (!SESSION_ID || !SESSION_TOKEN) {
|
|
47
|
+
console.error("[mac-agent] Error: SESSION_ID and SESSION_TOKEN are required.");
|
|
48
|
+
console.error("[mac-agent] These are provided by the Sema CLI (npm run start -- <command>).");
|
|
49
|
+
console.error("[mac-agent] Or set them manually: SESSION_ID=<id> SESSION_TOKEN=<token> node agent.js");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const TMUX_NAME = `sema-${SESSION_ID}`;
|
|
54
|
+
|
|
55
|
+
// Alert state machine: idle → analyze → smart_alert (or skip)
|
|
56
|
+
let idleTimer = null;
|
|
57
|
+
let alertSent = false;
|
|
58
|
+
let lastAlertKey = null;
|
|
59
|
+
let outputBuffer = "";
|
|
60
|
+
|
|
61
|
+
let ws = null;
|
|
62
|
+
let connected = false;
|
|
63
|
+
const pendingOutput = [];
|
|
64
|
+
|
|
65
|
+
function tmuxSessionExists() {
|
|
66
|
+
try {
|
|
67
|
+
execSync(`tmux has-session -t ${TMUX_NAME} 2>/dev/null`);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureTmuxSession() {
|
|
75
|
+
if (tmuxSessionExists()) {
|
|
76
|
+
console.log(`[mac-agent] tmux session "${TMUX_NAME}" exists, will reattach`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const cmd = CLI_COMMAND || SHELL;
|
|
80
|
+
execSync(
|
|
81
|
+
`tmux new-session -d -s ${TMUX_NAME} -x ${INITIAL_COLS} -y ${INITIAL_ROWS} ${JSON.stringify(cmd)}`,
|
|
82
|
+
{
|
|
83
|
+
env: { ...process.env, TERM: "xterm-256color", SEMA_SESSION_ID: SESSION_ID },
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
console.log(`[mac-agent] created tmux session "${TMUX_NAME}" running: ${cmd}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ensureTmuxSession();
|
|
90
|
+
|
|
91
|
+
const ptyProcess = pty.spawn("tmux", ["attach-session", "-t", TMUX_NAME], {
|
|
92
|
+
name: "xterm-256color",
|
|
93
|
+
cols: INITIAL_COLS,
|
|
94
|
+
rows: INITIAL_ROWS,
|
|
95
|
+
cwd: process.cwd(),
|
|
96
|
+
env: { ...process.env, TERM: "xterm-256color" },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function relayUrl() {
|
|
100
|
+
const url = new URL(RELAY_URL);
|
|
101
|
+
url.searchParams.set("role", "mac");
|
|
102
|
+
url.searchParams.set("sessionId", SESSION_ID);
|
|
103
|
+
url.searchParams.set("sessionToken", SESSION_TOKEN);
|
|
104
|
+
return url.toString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function send(message) {
|
|
108
|
+
const data = JSON.stringify(message);
|
|
109
|
+
if (connected && ws && ws.readyState === WebSocket.OPEN) {
|
|
110
|
+
ws.send(data);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (message.type === "output" || message.type === "alert" || message.type === "smart_alert") pendingOutput.push(data);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function flushPendingOutput() {
|
|
117
|
+
while (pendingOutput.length && ws && ws.readyState === WebSocket.OPEN) {
|
|
118
|
+
ws.send(pendingOutput.shift());
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decrypt a message from a specific device, trying current key first,
|
|
124
|
+
* then falling back to pending old key (during key rotation).
|
|
125
|
+
* @returns {string|null} decrypted plaintext or null on failure
|
|
126
|
+
*/
|
|
127
|
+
function decryptFromDevice(deviceId, encrypted) {
|
|
128
|
+
const currentKey = mobileKeys.get(deviceId);
|
|
129
|
+
if (currentKey) {
|
|
130
|
+
try { return decrypt(encrypted, currentKey); } catch {}
|
|
131
|
+
}
|
|
132
|
+
const pending = mobilePendingKeys.get(deviceId);
|
|
133
|
+
if (pending?.oldKey) {
|
|
134
|
+
try { return decrypt(encrypted, pending.oldKey); } catch {}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function connect() {
|
|
140
|
+
ws = new WebSocket(relayUrl());
|
|
141
|
+
|
|
142
|
+
ws.addEventListener("open", () => {
|
|
143
|
+
connected = true;
|
|
144
|
+
console.log(`[mac-agent] connected session=${SESSION_ID}`);
|
|
145
|
+
send({
|
|
146
|
+
type: "output",
|
|
147
|
+
sessionId: SESSION_ID,
|
|
148
|
+
data: `\r\n\x1b[1;32m[Sema Mac Agent connected on ${os.hostname()}]\x1b[0m\r\n`,
|
|
149
|
+
});
|
|
150
|
+
flushPendingOutput();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
ws.addEventListener("message", (event) => {
|
|
154
|
+
try {
|
|
155
|
+
const message = JSON.parse(event.data);
|
|
156
|
+
|
|
157
|
+
// Key exchange: mobile sends its public key
|
|
158
|
+
if (message.type === "key_exchange") {
|
|
159
|
+
if (!privateKey) {
|
|
160
|
+
console.error("[mac-agent] key_exchange received but E2E is disabled");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const peerPublicKey = importPublicKey(message.publicKey);
|
|
165
|
+
|
|
166
|
+
// Phase 1: Derive initial shared secret (K_old)
|
|
167
|
+
const sharedSecret = deriveSharedSecret(privateKey, peerPublicKey);
|
|
168
|
+
const aesKey = deriveAesKey(sharedSecret);
|
|
169
|
+
|
|
170
|
+
// Send encrypted key_ack with K_old
|
|
171
|
+
const ackPayload = encrypt(JSON.stringify({ type: "key_ack" }), aesKey);
|
|
172
|
+
send({
|
|
173
|
+
type: "key_ack",
|
|
174
|
+
sessionId: SESSION_ID,
|
|
175
|
+
deviceId: message.deviceId,
|
|
176
|
+
...ackPayload,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Phase 2: Generate ephemeral keypair for this device (forward secrecy)
|
|
180
|
+
const ephKeyPair = generateKeyPair();
|
|
181
|
+
const ephSharedSecret = deriveSharedSecret(ephKeyPair.privateKey, peerPublicKey);
|
|
182
|
+
const ephAesKey = deriveAesKey(ephSharedSecret);
|
|
183
|
+
const ephFingerprint = computeFingerprint(ephSharedSecret);
|
|
184
|
+
|
|
185
|
+
// Send key_rotation encrypted with K_old (protects ephemeral public key)
|
|
186
|
+
const ephPubB64 = exportPublicKey(ephKeyPair.publicKey);
|
|
187
|
+
const rotPayload = encrypt(JSON.stringify({
|
|
188
|
+
type: "key_rotation",
|
|
189
|
+
publicKey: ephPubB64,
|
|
190
|
+
}), aesKey);
|
|
191
|
+
send({
|
|
192
|
+
type: "key_rotation",
|
|
193
|
+
sessionId: SESSION_ID,
|
|
194
|
+
deviceId: message.deviceId,
|
|
195
|
+
...rotPayload,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Store: new key for subsequent traffic, old key pending rot_ack
|
|
199
|
+
mobileKeys.set(message.deviceId, ephAesKey);
|
|
200
|
+
const ttl = setTimeout(() => {
|
|
201
|
+
mobilePendingKeys.delete(message.deviceId);
|
|
202
|
+
console.log(`[mac-agent] key rotation timeout: device=${message.deviceId}`);
|
|
203
|
+
}, 30_000);
|
|
204
|
+
mobilePendingKeys.set(message.deviceId, { oldKey: aesKey, fingerprint: ephFingerprint, ttl });
|
|
205
|
+
|
|
206
|
+
// Display fingerprint in terminal for user verification
|
|
207
|
+
console.log(`[mac-agent] 🔐 Fingerprint for device ${message.deviceId.slice(0, 8)}: ${ephFingerprint}`);
|
|
208
|
+
|
|
209
|
+
// Flush pre-key buffer to this device (encrypted with new key)
|
|
210
|
+
for (const buffered of preKeyOutputBuffer) {
|
|
211
|
+
const plaintext = JSON.stringify({ type: "output", data: buffered });
|
|
212
|
+
const enc = encrypt(plaintext, ephAesKey);
|
|
213
|
+
send({
|
|
214
|
+
type: "output",
|
|
215
|
+
sessionId: SESSION_ID,
|
|
216
|
+
deviceId: message.deviceId,
|
|
217
|
+
...enc,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error("[mac-agent] key exchange failed:", err.message);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Key rotation ack: mobile confirms it has the new key (must be encrypted)
|
|
227
|
+
if (message.type === "key_rot_ack") {
|
|
228
|
+
if (!message.deviceId || !message.iv || !message.ct || !message.tag) {
|
|
229
|
+
console.error("[mac-agent] key_rot_ack missing encryption fields, rejected");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const plaintext = decryptFromDevice(message.deviceId, {
|
|
233
|
+
iv: message.iv, ct: message.ct, tag: message.tag,
|
|
234
|
+
});
|
|
235
|
+
if (!plaintext) {
|
|
236
|
+
console.error("[mac-agent] key_rot_ack decryption failed, rejected");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const pending = mobilePendingKeys.get(message.deviceId);
|
|
240
|
+
if (pending) {
|
|
241
|
+
clearTimeout(pending.ttl);
|
|
242
|
+
mobilePendingKeys.delete(message.deviceId);
|
|
243
|
+
}
|
|
244
|
+
console.log(`[mac-agent] key rotation complete: device=${message.deviceId}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Device revoked: drop key for this device
|
|
249
|
+
if (message.type === "device_revoked") {
|
|
250
|
+
mobileKeys.delete(message.deviceId);
|
|
251
|
+
console.log(`[mac-agent] device revoked: ${message.deviceId}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Encrypted input
|
|
256
|
+
if (message.type === "input") {
|
|
257
|
+
// Try to decrypt if E2E is enabled and deviceId is present
|
|
258
|
+
if (privateKey && message.deviceId && message.iv && message.ct && message.tag) {
|
|
259
|
+
const plaintext = decryptFromDevice(message.deviceId, {
|
|
260
|
+
iv: message.iv, ct: message.ct, tag: message.tag,
|
|
261
|
+
});
|
|
262
|
+
if (!plaintext) {
|
|
263
|
+
console.error("[mac-agent] input decryption failed: no matching key");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const decrypted = JSON.parse(plaintext);
|
|
268
|
+
const input = decrypted.data || "";
|
|
269
|
+
if (input && !DA_RESPONSE.test(input)) {
|
|
270
|
+
ptyProcess.write(input);
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error("[mac-agent] input parse failed:", err.message);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Plaintext fallback (no E2E)
|
|
279
|
+
const input = message.data || "";
|
|
280
|
+
if (input && !DA_RESPONSE.test(input)) {
|
|
281
|
+
ptyProcess.write(input);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Encrypted resize
|
|
287
|
+
if (message.type === "resize") {
|
|
288
|
+
if (privateKey && message.deviceId && message.iv && message.ct && message.tag) {
|
|
289
|
+
const plaintext = decryptFromDevice(message.deviceId, {
|
|
290
|
+
iv: message.iv, ct: message.ct, tag: message.tag,
|
|
291
|
+
});
|
|
292
|
+
if (!plaintext) return;
|
|
293
|
+
try {
|
|
294
|
+
const decrypted = JSON.parse(plaintext);
|
|
295
|
+
const cols = Number(decrypted.cols) || INITIAL_COLS;
|
|
296
|
+
const rows = Number(decrypted.rows) || INITIAL_ROWS;
|
|
297
|
+
ptyProcess.resize(cols, rows);
|
|
298
|
+
console.log(`[mac-agent] resized to ${cols}x${rows}`);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error("[mac-agent] resize parse failed:", err.message);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Plaintext fallback
|
|
306
|
+
const cols = Number(message.cols) || INITIAL_COLS;
|
|
307
|
+
const rows = Number(message.rows) || INITIAL_ROWS;
|
|
308
|
+
ptyProcess.resize(cols, rows);
|
|
309
|
+
console.log(`[mac-agent] resized to ${cols}x${rows}`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
console.error("[mac-agent] invalid relay message");
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
ws.addEventListener("close", () => {
|
|
318
|
+
if (connected) console.log("[mac-agent] disconnected, retrying...");
|
|
319
|
+
connected = false;
|
|
320
|
+
// Keep device keys — mobile reconnect will trigger new key_exchange
|
|
321
|
+
// Old keys retained for potential key_rotation with re-exchanged devices
|
|
322
|
+
setTimeout(connect, RECONNECT_MS);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
ws.addEventListener("error", () => {
|
|
326
|
+
connected = false;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function onIdle() {
|
|
331
|
+
if (alertSent) return;
|
|
332
|
+
alertSent = true;
|
|
333
|
+
|
|
334
|
+
const result = analyze(outputBuffer);
|
|
335
|
+
|
|
336
|
+
if (result && result.confidence >= 0.7) {
|
|
337
|
+
// Dedup: skip if same question was already alerted
|
|
338
|
+
const alertKey = `${result.kind}:${result.question}`;
|
|
339
|
+
if (alertKey === lastAlertKey) {
|
|
340
|
+
console.log(`[mac-agent] duplicate alert, skipping`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
lastAlertKey = alertKey;
|
|
344
|
+
|
|
345
|
+
console.log(`[mac-agent] smart alert: ${result.kind} (${result.confidence})`);
|
|
346
|
+
|
|
347
|
+
const alertMessage = {
|
|
348
|
+
type: "smart_alert",
|
|
349
|
+
sessionId: SESSION_ID,
|
|
350
|
+
kind: result.kind,
|
|
351
|
+
question: result.question,
|
|
352
|
+
options: result.options,
|
|
353
|
+
context: result.context,
|
|
354
|
+
confidence: result.confidence,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// E2E: encrypt for each device
|
|
358
|
+
if (privateKey && mobileKeys.size > 0) {
|
|
359
|
+
for (const [deviceId, aesKey] of mobileKeys) {
|
|
360
|
+
try {
|
|
361
|
+
const plaintext = JSON.stringify(alertMessage);
|
|
362
|
+
const enc = encrypt(plaintext, aesKey);
|
|
363
|
+
send({
|
|
364
|
+
type: "smart_alert",
|
|
365
|
+
sessionId: SESSION_ID,
|
|
366
|
+
deviceId,
|
|
367
|
+
...enc,
|
|
368
|
+
});
|
|
369
|
+
} catch (err) {
|
|
370
|
+
console.error(`[mac-agent] smart_alert encrypt failed:`, err.message);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
// Plaintext mode
|
|
375
|
+
send(alertMessage);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
// Quality gate: unrecognized idle → no notification
|
|
379
|
+
console.log(`[mac-agent] idle, no pattern matched — skipping alert`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
ptyProcess.onData((data) => {
|
|
384
|
+
// E2E: encrypt output for each connected device
|
|
385
|
+
if (privateKey && mobileKeys.size > 0) {
|
|
386
|
+
for (const [deviceId, aesKey] of mobileKeys) {
|
|
387
|
+
try {
|
|
388
|
+
const plaintext = JSON.stringify({ type: "output", data });
|
|
389
|
+
const enc = encrypt(plaintext, aesKey);
|
|
390
|
+
send({
|
|
391
|
+
type: "output",
|
|
392
|
+
sessionId: SESSION_ID,
|
|
393
|
+
deviceId,
|
|
394
|
+
...enc,
|
|
395
|
+
});
|
|
396
|
+
} catch (err) {
|
|
397
|
+
console.error(`[mac-agent] encrypt failed for device ${deviceId}:`, err.message);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} else if (privateKey) {
|
|
401
|
+
// E2E enabled but no devices connected yet — buffer
|
|
402
|
+
if (preKeyOutputBuffer.length < PRE_KEY_MAX) {
|
|
403
|
+
preKeyOutputBuffer.push(data);
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
// Plaintext mode: send directly
|
|
407
|
+
send({
|
|
408
|
+
type: "output",
|
|
409
|
+
sessionId: SESSION_ID,
|
|
410
|
+
data,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Accumulate output buffer for semantic analysis
|
|
415
|
+
outputBuffer += data;
|
|
416
|
+
if (outputBuffer.length > BUFFER_MAX) {
|
|
417
|
+
outputBuffer = outputBuffer.slice(outputBuffer.length - BUFFER_MAX);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Reset alert state on new output
|
|
421
|
+
alertSent = false;
|
|
422
|
+
lastAlertKey = null;
|
|
423
|
+
clearTimeout(idleTimer);
|
|
424
|
+
|
|
425
|
+
// Start idle timer — if no output for IDLE_TIMEOUT_MS, analyze
|
|
426
|
+
idleTimer = setTimeout(onIdle, IDLE_TIMEOUT_MS);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
430
|
+
send({
|
|
431
|
+
type: "output",
|
|
432
|
+
sessionId: SESSION_ID,
|
|
433
|
+
data: `\r\n\x1b[1;31m[Sema detached from tmux session]\x1b[0m\r\n`,
|
|
434
|
+
});
|
|
435
|
+
process.exit(exitCode || 0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
process.on("SIGINT", () => {
|
|
439
|
+
ptyProcess.kill();
|
|
440
|
+
process.exit(0);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
process.on("SIGTERM", () => {
|
|
444
|
+
ptyProcess.kill();
|
|
445
|
+
process.exit(0);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const modeLabel = CLI_COMMAND ? `cli=${CLI_COMMAND}` : `shell=${SHELL}`;
|
|
449
|
+
console.log(`[mac-agent] tmux=${TMUX_NAME} ${modeLabel} session=${SESSION_ID} size=${INITIAL_COLS}x${INITIAL_ROWS}`);
|
|
450
|
+
connect();
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// --- ANSI stripping ---
|
|
4
|
+
|
|
5
|
+
function stripAnsi(str) {
|
|
6
|
+
return str
|
|
7
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") // CSI sequences (colors, cursor, etc.)
|
|
8
|
+
.replace(/\x1b\][^\x07]*\x07/g, "") // OSC sequences (title, etc.)
|
|
9
|
+
.replace(/\x1b[()][AB012]/g, "") // charset select
|
|
10
|
+
.replace(/\x1b[=>]/g, "") // keypad mode
|
|
11
|
+
.replace(/\x1b\[\?[0-9;]*[hl]/g, "") // private mode set/reset
|
|
12
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // control chars (keep \t\n\r)
|
|
13
|
+
.replace(/\r/g, "") // CR (keep LF only)
|
|
14
|
+
.trimEnd();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- Pattern matchers ---
|
|
18
|
+
|
|
19
|
+
const YESNO_RE = /[\(\[]\s*[yYnN]\s*\/\s*[yYnN]\s*[\)\]]/;
|
|
20
|
+
|
|
21
|
+
function matchYesNo(clean, lines) {
|
|
22
|
+
if (!YESNO_RE.test(clean)) return null;
|
|
23
|
+
|
|
24
|
+
// Find the last non-empty line containing the y/n pattern
|
|
25
|
+
let questionLine = "";
|
|
26
|
+
let questionIdx = -1;
|
|
27
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
28
|
+
if (YESNO_RE.test(lines[i])) {
|
|
29
|
+
questionLine = lines[i].trim();
|
|
30
|
+
questionIdx = i;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!questionLine) return null;
|
|
35
|
+
|
|
36
|
+
// Context: 2-3 lines before the question
|
|
37
|
+
const contextLines = lines.slice(Math.max(0, questionIdx - 3), questionIdx);
|
|
38
|
+
const context = contextLines.join("\n").slice(0, 300);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
kind: "yesno",
|
|
42
|
+
question: questionLine.slice(0, 200),
|
|
43
|
+
options: [
|
|
44
|
+
{ label: "Yes", data: "y\n" },
|
|
45
|
+
{ label: "No", data: "n\n" },
|
|
46
|
+
],
|
|
47
|
+
context,
|
|
48
|
+
confidence: 0.9,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CHOICE_LINE_RE = /^\s*\d+[\.\)]\s+.+/;
|
|
53
|
+
|
|
54
|
+
function matchChoice(lines) {
|
|
55
|
+
// Find consecutive lines matching numbered choice pattern
|
|
56
|
+
const choiceIndices = [];
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
if (CHOICE_LINE_RE.test(lines[i])) {
|
|
59
|
+
choiceIndices.push(i);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Need at least 2 choice lines
|
|
64
|
+
if (choiceIndices.length < 2) return null;
|
|
65
|
+
|
|
66
|
+
// Check that at least some are consecutive (within 1 line gap)
|
|
67
|
+
let consecutiveCount = 1;
|
|
68
|
+
let maxConsecutive = 1;
|
|
69
|
+
for (let i = 1; i < choiceIndices.length; i++) {
|
|
70
|
+
if (choiceIndices[i] - choiceIndices[i - 1] <= 2) {
|
|
71
|
+
consecutiveCount++;
|
|
72
|
+
maxConsecutive = Math.max(maxConsecutive, consecutiveCount);
|
|
73
|
+
} else {
|
|
74
|
+
consecutiveCount = 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (maxConsecutive < 2) return null;
|
|
78
|
+
|
|
79
|
+
// Extract options
|
|
80
|
+
const options = [];
|
|
81
|
+
for (const idx of choiceIndices) {
|
|
82
|
+
const line = lines[idx].trim();
|
|
83
|
+
const numMatch = line.match(/^\s*(\d+)/);
|
|
84
|
+
if (numMatch) {
|
|
85
|
+
options.push({
|
|
86
|
+
label: line.slice(0, 60),
|
|
87
|
+
data: numMatch[1] + "\n",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Add "Other" option for custom input
|
|
93
|
+
options.push({ label: "Other", data: "" });
|
|
94
|
+
|
|
95
|
+
// Question: last non-choice line before the choices
|
|
96
|
+
const firstChoiceIdx = choiceIndices[0];
|
|
97
|
+
let question = "";
|
|
98
|
+
for (let i = firstChoiceIdx - 1; i >= 0; i--) {
|
|
99
|
+
if (!CHOICE_LINE_RE.test(lines[i]) && lines[i].trim()) {
|
|
100
|
+
question = lines[i].trim();
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Context: lines before the question
|
|
106
|
+
const contextStart = Math.max(0, firstChoiceIdx - 4);
|
|
107
|
+
const contextEnd = firstChoiceIdx;
|
|
108
|
+
const contextLines = lines.slice(contextStart, contextEnd).filter(
|
|
109
|
+
(l) => l.trim() && !CHOICE_LINE_RE.test(l)
|
|
110
|
+
);
|
|
111
|
+
const context = contextLines.join("\n").slice(0, 300);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
kind: "choice",
|
|
115
|
+
question: question.slice(0, 200) || "Select an option",
|
|
116
|
+
options,
|
|
117
|
+
context,
|
|
118
|
+
confidence: 0.8,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ERROR_PATTERNS = [
|
|
123
|
+
/^\s*Error[: ]/i,
|
|
124
|
+
/\bfailed\b/i,
|
|
125
|
+
/permission denied/i,
|
|
126
|
+
/\bENOENT\b/,
|
|
127
|
+
/command not found/i,
|
|
128
|
+
/exit(?:ed)? with (?:code|status) [1-9]/,
|
|
129
|
+
/[✗✘❌]/,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const ERROR_EXCLUSIONS = [
|
|
133
|
+
/0\s*errors?/i,
|
|
134
|
+
/no\s+error/i,
|
|
135
|
+
/error\s*(?:rate|count).*[:=]\s*0/i,
|
|
136
|
+
/\bsuccess\b/i,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
function isErrorLine(line) {
|
|
140
|
+
// Check exclusions first
|
|
141
|
+
for (const excl of ERROR_EXCLUSIONS) {
|
|
142
|
+
if (excl.test(line)) return false;
|
|
143
|
+
}
|
|
144
|
+
// Check positive patterns
|
|
145
|
+
for (const pat of ERROR_PATTERNS) {
|
|
146
|
+
if (pat.test(line)) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function matchError(lines) {
|
|
152
|
+
// Scan from bottom up — find the last error line
|
|
153
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
154
|
+
if (isErrorLine(lines[i])) {
|
|
155
|
+
const question = lines[i].trim().slice(0, 200);
|
|
156
|
+
|
|
157
|
+
// Context: 2 lines before
|
|
158
|
+
const contextLines = lines.slice(Math.max(0, i - 2), i);
|
|
159
|
+
const context = contextLines.join("\n").slice(0, 300);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
kind: "error",
|
|
163
|
+
question,
|
|
164
|
+
options: [{ label: "Acknowledge", data: "\r" }],
|
|
165
|
+
context,
|
|
166
|
+
confidence: 0.7,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Main analyze function ---
|
|
174
|
+
|
|
175
|
+
function analyze(rawOutput) {
|
|
176
|
+
if (!rawOutput || !rawOutput.trim()) return null;
|
|
177
|
+
|
|
178
|
+
// Unified truncation at entry — 512 chars from the tail
|
|
179
|
+
const tail = rawOutput.slice(-512);
|
|
180
|
+
const clean = stripAnsi(tail);
|
|
181
|
+
const lines = clean.split("\n").filter((l) => l.trim());
|
|
182
|
+
|
|
183
|
+
if (lines.length === 0) return null;
|
|
184
|
+
|
|
185
|
+
// Match order: most specific → most general
|
|
186
|
+
return matchYesNo(clean, lines) || matchChoice(lines) || matchError(lines);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { analyze, stripAnsi };
|