agent-companion 0.1.4 → 0.1.6
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 +0 -13
- package/bridge/defaultState.mjs +11 -109
- package/bridge/server.mjs +22 -6
- package/package.json +2 -1
- package/relay/server.mjs +446 -0
package/README.md
CHANGED
|
@@ -22,19 +22,6 @@ npm i -g agent-companion
|
|
|
22
22
|
agent-companion
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
## Options
|
|
26
|
-
Use your hosted relay:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
agent-companion --relay https://agent-companion-relay.onrender.com
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Run with a local relay for development:
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
agent-companion --with-local-relay
|
|
36
|
-
```
|
|
37
|
-
|
|
38
25
|
## Pairing flow
|
|
39
26
|
1. Run `agent-companion` on your computer
|
|
40
27
|
2. Copy the pairing code shown in the terminal
|
package/bridge/defaultState.mjs
CHANGED
|
@@ -6,6 +6,7 @@ const SESSION_STATES = new Set(["RUNNING", "WAITING_INPUT", "COMPLETED", "FAILED
|
|
|
6
6
|
const EVENT_CATEGORIES = new Set(["INFO", "ACTION", "INPUT", "ERROR"]);
|
|
7
7
|
const TURN_ROLES = new Set(["USER", "ASSISTANT"]);
|
|
8
8
|
const TURN_KINDS = new Set(["MESSAGE", "FINAL_OUTPUT", "APPROVAL_ACTION"]);
|
|
9
|
+
const SEEDED_SAMPLE_SESSION_IDS = new Set(["s_codex_live_01", "s_claude_live_01", "s_codex_live_02"]);
|
|
9
10
|
|
|
10
11
|
function safeNumber(value, fallback = 0) {
|
|
11
12
|
const parsed = Number(value);
|
|
@@ -290,93 +291,6 @@ function buildDefaultThreads(sessions) {
|
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
export function buildDefaultState() {
|
|
293
|
-
const now = Date.now();
|
|
294
|
-
|
|
295
|
-
const sessions = [
|
|
296
|
-
createSession(
|
|
297
|
-
"s_codex_live_01",
|
|
298
|
-
"CODEX",
|
|
299
|
-
"Refactor notification pipeline",
|
|
300
|
-
"agent-control-plane",
|
|
301
|
-
"feature/queue-replay",
|
|
302
|
-
"RUNNING",
|
|
303
|
-
16_000,
|
|
304
|
-
64,
|
|
305
|
-
{
|
|
306
|
-
promptTokens: 27120,
|
|
307
|
-
completionTokens: 18340,
|
|
308
|
-
totalTokens: 45460,
|
|
309
|
-
costUsd: 0.62
|
|
310
|
-
}
|
|
311
|
-
),
|
|
312
|
-
createSession(
|
|
313
|
-
"s_claude_live_01",
|
|
314
|
-
"CLAUDE",
|
|
315
|
-
"Fix websocket reconnect",
|
|
316
|
-
"agent-bridge",
|
|
317
|
-
"bugfix/retry-loop",
|
|
318
|
-
"WAITING_INPUT",
|
|
319
|
-
75_000,
|
|
320
|
-
79,
|
|
321
|
-
{
|
|
322
|
-
promptTokens: 18110,
|
|
323
|
-
completionTokens: 11040,
|
|
324
|
-
totalTokens: 29150,
|
|
325
|
-
costUsd: 0.43
|
|
326
|
-
}
|
|
327
|
-
),
|
|
328
|
-
createSession(
|
|
329
|
-
"s_codex_live_02",
|
|
330
|
-
"CODEX",
|
|
331
|
-
"Token analytics API",
|
|
332
|
-
"agent-api",
|
|
333
|
-
"feat/token-metrics",
|
|
334
|
-
"COMPLETED",
|
|
335
|
-
4_300_000,
|
|
336
|
-
100,
|
|
337
|
-
{
|
|
338
|
-
promptTokens: 32002,
|
|
339
|
-
completionTokens: 22994,
|
|
340
|
-
totalTokens: 54996,
|
|
341
|
-
costUsd: 0.78
|
|
342
|
-
}
|
|
343
|
-
)
|
|
344
|
-
];
|
|
345
|
-
|
|
346
|
-
const pendingInputs = [
|
|
347
|
-
sanitizePendingInput({
|
|
348
|
-
id: "p_live_01",
|
|
349
|
-
sessionId: "s_claude_live_01",
|
|
350
|
-
prompt: "Can I cap backoff at 45s and ship this patch?",
|
|
351
|
-
requestedAt: now - 75_000,
|
|
352
|
-
priority: "HIGH"
|
|
353
|
-
})
|
|
354
|
-
].filter(Boolean);
|
|
355
|
-
|
|
356
|
-
const events = [
|
|
357
|
-
sanitizeEvent({
|
|
358
|
-
id: "e_live_101",
|
|
359
|
-
sessionId: "s_codex_live_01",
|
|
360
|
-
summary: "Running integration tests on queue replay logic.",
|
|
361
|
-
timestamp: now - 16_000,
|
|
362
|
-
category: "INFO"
|
|
363
|
-
}),
|
|
364
|
-
sanitizeEvent({
|
|
365
|
-
id: "e_live_201",
|
|
366
|
-
sessionId: "s_claude_live_01",
|
|
367
|
-
summary: "Input requested: confirm retry cap before patch.",
|
|
368
|
-
timestamp: now - 75_000,
|
|
369
|
-
category: "INPUT"
|
|
370
|
-
}),
|
|
371
|
-
sanitizeEvent({
|
|
372
|
-
id: "e_live_301",
|
|
373
|
-
sessionId: "s_codex_live_02",
|
|
374
|
-
summary: "Session completed: endpoint + tests merged.",
|
|
375
|
-
timestamp: now - 4_300_000,
|
|
376
|
-
category: "ACTION"
|
|
377
|
-
})
|
|
378
|
-
].filter(Boolean);
|
|
379
|
-
|
|
380
294
|
const settings = {
|
|
381
295
|
criticalRealtime: true,
|
|
382
296
|
digest: true,
|
|
@@ -387,26 +301,13 @@ export function buildDefaultState() {
|
|
|
387
301
|
workspaceRoot: resolveDefaultWorkspaceRoot()
|
|
388
302
|
};
|
|
389
303
|
|
|
390
|
-
const sessionThreads = buildDefaultThreads(sessions);
|
|
391
|
-
const chatTurns = [
|
|
392
|
-
sanitizeChatTurn({
|
|
393
|
-
id: "turn_live_01",
|
|
394
|
-
sessionId: "s_claude_live_01",
|
|
395
|
-
role: "ASSISTANT",
|
|
396
|
-
kind: "FINAL_OUTPUT",
|
|
397
|
-
text: "Can I cap backoff at 45s and ship this patch?",
|
|
398
|
-
createdAt: now - 75_000,
|
|
399
|
-
source: "LEGACY"
|
|
400
|
-
})
|
|
401
|
-
].filter(Boolean);
|
|
402
|
-
|
|
403
304
|
return {
|
|
404
305
|
source: "bridge",
|
|
405
|
-
sessions,
|
|
406
|
-
sessionThreads,
|
|
407
|
-
chatTurns,
|
|
408
|
-
pendingInputs,
|
|
409
|
-
events,
|
|
306
|
+
sessions: [],
|
|
307
|
+
sessionThreads: [],
|
|
308
|
+
chatTurns: [],
|
|
309
|
+
pendingInputs: [],
|
|
310
|
+
events: [],
|
|
410
311
|
pendingHandledAt: {},
|
|
411
312
|
settings
|
|
412
313
|
};
|
|
@@ -438,23 +339,24 @@ export function sanitizeState(raw) {
|
|
|
438
339
|
Array.isArray(candidate.sessions) ? candidate.sessions : fallback.sessions
|
|
439
340
|
)
|
|
440
341
|
.map((session) => sanitizeSession(session))
|
|
441
|
-
.filter(Boolean);
|
|
342
|
+
.filter((session) => Boolean(session && !SEEDED_SAMPLE_SESSION_IDS.has(session.id)));
|
|
442
343
|
|
|
443
344
|
const pendingInputs = (
|
|
444
345
|
Array.isArray(candidate.pendingInputs) ? candidate.pendingInputs : fallback.pendingInputs
|
|
445
346
|
)
|
|
446
347
|
.map((item) => sanitizePendingInput(item))
|
|
447
|
-
.filter(Boolean);
|
|
348
|
+
.filter((item) => Boolean(item && !SEEDED_SAMPLE_SESSION_IDS.has(item.sessionId)));
|
|
448
349
|
|
|
449
350
|
const events = (Array.isArray(candidate.events) ? candidate.events : fallback.events)
|
|
450
351
|
.map((event) => sanitizeEvent(event))
|
|
451
|
-
.filter(Boolean);
|
|
352
|
+
.filter((event) => Boolean(event && !SEEDED_SAMPLE_SESSION_IDS.has(event.sessionId)));
|
|
452
353
|
|
|
453
354
|
const sessionThreadMap = new Map();
|
|
454
355
|
const rawThreads = Array.isArray(candidate.sessionThreads) ? candidate.sessionThreads : [];
|
|
455
356
|
for (const item of rawThreads) {
|
|
456
357
|
const sanitized = sanitizeSessionThread(item);
|
|
457
358
|
if (!sanitized) continue;
|
|
359
|
+
if (SEEDED_SAMPLE_SESSION_IDS.has(sanitized.id)) continue;
|
|
458
360
|
sessionThreadMap.set(sanitized.id, sanitized);
|
|
459
361
|
}
|
|
460
362
|
|
|
@@ -481,7 +383,7 @@ export function sanitizeState(raw) {
|
|
|
481
383
|
|
|
482
384
|
const chatTurns = (Array.isArray(candidate.chatTurns) ? candidate.chatTurns : fallback.chatTurns)
|
|
483
385
|
.map((turn) => sanitizeChatTurn(turn))
|
|
484
|
-
.filter((turn) => Boolean(turn && knownSessionIds.has(turn.sessionId)))
|
|
386
|
+
.filter((turn) => Boolean(turn && knownSessionIds.has(turn.sessionId) && !SEEDED_SAMPLE_SESSION_IDS.has(turn.sessionId)))
|
|
485
387
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
486
388
|
|
|
487
389
|
return {
|
package/bridge/server.mjs
CHANGED
|
@@ -1244,12 +1244,8 @@ function createWorkspaceFolder(input) {
|
|
|
1244
1244
|
return { statusCode: 500, error: String(error?.message || error) };
|
|
1245
1245
|
}
|
|
1246
1246
|
|
|
1247
|
-
const normalizedPath = normalizeExistingDirectoryPath(targetPath);
|
|
1248
|
-
|
|
1249
|
-
return { statusCode: 500, error: "workspace path could not be resolved" };
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
const workspace = describeWorkspaceCandidate(normalizedPath);
|
|
1247
|
+
const normalizedPath = normalizeExistingDirectoryPath(targetPath) || path.resolve(targetPath);
|
|
1248
|
+
const workspace = describeWorkspaceCandidate(normalizedPath) || buildWorkspaceFallback(normalizedPath);
|
|
1253
1249
|
if (!workspace) {
|
|
1254
1250
|
return { statusCode: 500, error: "workspace could not be indexed" };
|
|
1255
1251
|
}
|
|
@@ -1262,6 +1258,26 @@ function createWorkspaceFolder(input) {
|
|
|
1262
1258
|
};
|
|
1263
1259
|
}
|
|
1264
1260
|
|
|
1261
|
+
function buildWorkspaceFallback(workspacePath) {
|
|
1262
|
+
const normalized = safeTrimmedText(workspacePath, 2000);
|
|
1263
|
+
if (!normalized) return null;
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
const stat = fs.statSync(normalized);
|
|
1267
|
+
if (!stat.isDirectory()) return null;
|
|
1268
|
+
|
|
1269
|
+
return {
|
|
1270
|
+
path: normalized,
|
|
1271
|
+
name: path.basename(normalized),
|
|
1272
|
+
hasGit: fs.existsSync(path.join(normalized, ".git")),
|
|
1273
|
+
score: 0,
|
|
1274
|
+
lastModified: stat.mtimeMs || Date.now()
|
|
1275
|
+
};
|
|
1276
|
+
} catch {
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1265
1281
|
function detectWorkspaceMeta(workspacePath) {
|
|
1266
1282
|
const repo = path.basename(workspacePath);
|
|
1267
1283
|
const branch = detectGitBranch(workspacePath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-companion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Phone-to-computer companion for Codex and Claude Code.",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@radix-ui/react-switch": "^1.2.6",
|
|
57
|
+
"@vercel/analytics": "^1.6.1",
|
|
57
58
|
"class-variance-authority": "^0.7.1",
|
|
58
59
|
"clsx": "^2.1.1",
|
|
59
60
|
"cors": "^2.8.6",
|
package/relay/server.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const RELAY_PUBLIC_URL = trimTrailingSlash(
|
|
|
17
17
|
process.env.RELAY_PUBLIC_URL || process.env.RENDER_EXTERNAL_URL || `http://localhost:${RELAY_PORT}`
|
|
18
18
|
);
|
|
19
19
|
const RELAY_TOKEN_SECRET = resolveRelayTokenSecret();
|
|
20
|
+
const RELAY_ADMIN_TOKEN = safeText(process.env.RELAY_ADMIN_TOKEN || process.env.ADMIN_TOKEN || "", 500);
|
|
20
21
|
const RELAY_WAKE_PROXY_URL = trimTrailingSlash(process.env.RELAY_WAKE_PROXY_URL || process.env.WAKE_PROXY_URL || "");
|
|
21
22
|
const RELAY_WAKE_PROXY_TOKEN = safeText(process.env.RELAY_WAKE_PROXY_TOKEN || process.env.WAKE_PROXY_TOKEN || "", 500);
|
|
22
23
|
const RELAY_WAKE_TIMEOUT_MS = clamp(toInt(process.env.RELAY_WAKE_TIMEOUT_MS, 90_000), 5_000, 5 * 60 * 1000);
|
|
@@ -38,6 +39,7 @@ const MAX_PHONE_TOKENS = 2_000;
|
|
|
38
39
|
const MAX_PREVIEW_RECORDS = 4_000;
|
|
39
40
|
const PHONE_TOKEN_HEADER = "x-agent-companion-phone-token";
|
|
40
41
|
const LAPTOP_TOKEN_HEADER = "x-agent-companion-laptop-token";
|
|
42
|
+
const SEEDED_SAMPLE_SESSION_IDS = new Set(["s_codex_live_01", "s_claude_live_01", "s_codex_live_02"]);
|
|
41
43
|
|
|
42
44
|
const STATE_FILE = path.resolve(PROJECT_ROOT, "relay", "state.json");
|
|
43
45
|
const app = express();
|
|
@@ -82,6 +84,13 @@ app.get("/health", (_req, res) => {
|
|
|
82
84
|
});
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
app.get("/api/admin/analytics", requireAdminToken, (_req, res) => {
|
|
88
|
+
cleanupExpiredPairings();
|
|
89
|
+
cleanupExpiredPreviews();
|
|
90
|
+
res.setHeader("Cache-Control", "no-store");
|
|
91
|
+
res.json(buildAdminAnalytics());
|
|
92
|
+
});
|
|
93
|
+
|
|
85
94
|
app.get("/pair", (req, res) => {
|
|
86
95
|
const code = normalizePairCode(req.query?.code);
|
|
87
96
|
const pairing = code ? findPairingByCode(code) : null;
|
|
@@ -281,6 +290,16 @@ app.post("/api/pairings/claim", (req, res) => {
|
|
|
281
290
|
mutateState(() => {
|
|
282
291
|
pairing.claimedAt = now;
|
|
283
292
|
pairing.phoneToken = phoneToken;
|
|
293
|
+
if (!isObject(state.revokedPhoneDevices)) {
|
|
294
|
+
state.revokedPhoneDevices = {};
|
|
295
|
+
}
|
|
296
|
+
delete state.revokedPhoneDevices[pairing.deviceId];
|
|
297
|
+
if (!isObject(state.analytics)) {
|
|
298
|
+
state.analytics = {
|
|
299
|
+
totalPairClaims: 0
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
state.analytics.totalPairClaims = toInt(state.analytics.totalPairClaims, 0) + 1;
|
|
284
303
|
|
|
285
304
|
state.phones.push({
|
|
286
305
|
phoneToken,
|
|
@@ -304,6 +323,37 @@ app.post("/api/pairings/claim", (req, res) => {
|
|
|
304
323
|
});
|
|
305
324
|
});
|
|
306
325
|
|
|
326
|
+
app.delete("/api/devices/:id/pairing", requirePhoneToken, (req, res) => {
|
|
327
|
+
const deviceId = safeText(req.params.id, 200);
|
|
328
|
+
const phone = req.phoneSession;
|
|
329
|
+
if (!deviceId) {
|
|
330
|
+
return res.status(400).json({ ok: false, error: "device id is required" });
|
|
331
|
+
}
|
|
332
|
+
if (!phone || safeText(phone.deviceId, 200) !== deviceId) {
|
|
333
|
+
return res.status(403).json({ ok: false, error: "token cannot access this device" });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
mutateState(() => {
|
|
337
|
+
if (!isObject(state.revokedPhoneDevices)) {
|
|
338
|
+
state.revokedPhoneDevices = {};
|
|
339
|
+
}
|
|
340
|
+
state.revokedPhoneDevices[deviceId] = Date.now();
|
|
341
|
+
state.phones = state.phones.filter((item) => safeText(item.deviceId, 200) !== deviceId);
|
|
342
|
+
state.pairings = state.pairings.filter((item) => safeText(item.deviceId, 200) !== deviceId);
|
|
343
|
+
for (const laptop of state.laptops) {
|
|
344
|
+
if (safeText(laptop.deviceId, 200) !== deviceId) continue;
|
|
345
|
+
laptop.pairedAt = null;
|
|
346
|
+
laptop.pairCode = null;
|
|
347
|
+
laptop.pairingExpiresAt = null;
|
|
348
|
+
laptop.pairingUrl = null;
|
|
349
|
+
laptop.pairingPayload = null;
|
|
350
|
+
laptop.updatedAt = Date.now();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return res.json({ ok: true, deviceId });
|
|
355
|
+
});
|
|
356
|
+
|
|
307
357
|
app.use("/api/devices/:id", requirePhoneToken, requireDeviceAccess);
|
|
308
358
|
|
|
309
359
|
app.get("/api/devices/:id/status", (req, res) => {
|
|
@@ -1486,6 +1536,9 @@ function findPhoneByToken(token) {
|
|
|
1486
1536
|
function resolvePhoneSession(token) {
|
|
1487
1537
|
const exact = findPhoneByToken(token);
|
|
1488
1538
|
if (exact) {
|
|
1539
|
+
if (isPhoneDeviceRevoked(exact.deviceId)) {
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1489
1542
|
const refreshedToken = issuePhoneToken(exact.deviceId);
|
|
1490
1543
|
return {
|
|
1491
1544
|
phone: exact,
|
|
@@ -1498,6 +1551,7 @@ function resolvePhoneSession(token) {
|
|
|
1498
1551
|
|
|
1499
1552
|
const deviceId = safeText(claims.deviceId, 200);
|
|
1500
1553
|
if (!deviceId) return null;
|
|
1554
|
+
if (isPhoneDeviceRevoked(deviceId)) return null;
|
|
1501
1555
|
|
|
1502
1556
|
const restored = {
|
|
1503
1557
|
phoneToken: token,
|
|
@@ -1855,6 +1909,10 @@ function loadState() {
|
|
|
1855
1909
|
pairings: [],
|
|
1856
1910
|
phones: [],
|
|
1857
1911
|
previews: [],
|
|
1912
|
+
revokedPhoneDevices: {},
|
|
1913
|
+
analytics: {
|
|
1914
|
+
totalPairClaims: 0
|
|
1915
|
+
},
|
|
1858
1916
|
updatedAt: Date.now()
|
|
1859
1917
|
};
|
|
1860
1918
|
|
|
@@ -1870,12 +1928,376 @@ function loadState() {
|
|
|
1870
1928
|
}
|
|
1871
1929
|
}
|
|
1872
1930
|
|
|
1931
|
+
function requireAdminToken(req, res, next) {
|
|
1932
|
+
if (!RELAY_ADMIN_TOKEN) {
|
|
1933
|
+
return res.status(503).json({
|
|
1934
|
+
ok: false,
|
|
1935
|
+
error: "admin analytics not configured"
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const incoming = extractAdminToken(req);
|
|
1940
|
+
if (!incoming || !secureTokenMatch(incoming, RELAY_ADMIN_TOKEN)) {
|
|
1941
|
+
return res.status(401).json({
|
|
1942
|
+
ok: false,
|
|
1943
|
+
error: "unauthorized"
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
next();
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function extractAdminToken(req) {
|
|
1951
|
+
const authHeader = safeText(req.headers?.authorization || "", 4000);
|
|
1952
|
+
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
1953
|
+
return safeText(authHeader.slice(7), 4000);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const headerToken = safeText(req.headers?.["x-admin-token"] || "", 4000);
|
|
1957
|
+
if (headerToken) return headerToken;
|
|
1958
|
+
|
|
1959
|
+
return safeText(req.query?.token || "", 4000);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function secureTokenMatch(left, right) {
|
|
1963
|
+
const actual = Buffer.from(String(left || ""), "utf8");
|
|
1964
|
+
const expected = Buffer.from(String(right || ""), "utf8");
|
|
1965
|
+
if (actual.length !== expected.length || actual.length === 0) return false;
|
|
1966
|
+
return timingSafeEqual(actual, expected);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function buildAdminAnalytics() {
|
|
1970
|
+
const now = Date.now();
|
|
1971
|
+
const onlineLaptopIds = new Set(
|
|
1972
|
+
[...laptopSockets.entries()]
|
|
1973
|
+
.filter(([, ws]) => ws && ws.readyState === WebSocket.OPEN)
|
|
1974
|
+
.map(([laptopId]) => laptopId)
|
|
1975
|
+
);
|
|
1976
|
+
const claimedPairings = state.pairings.filter((item) => item.claimedAt);
|
|
1977
|
+
const activePreviews = state.previews.filter((item) => item.expiresAt > now);
|
|
1978
|
+
const uniqueCurrentPairedDeviceIds = new Set(state.phones.map((item) => safeText(item.deviceId, 200)).filter(Boolean));
|
|
1979
|
+
const uniqueKnownDeviceIds = new Set([
|
|
1980
|
+
...uniqueCurrentPairedDeviceIds,
|
|
1981
|
+
...claimedPairings.map((item) => safeText(item.deviceId, 200)).filter(Boolean)
|
|
1982
|
+
]);
|
|
1983
|
+
const totalPairs = Math.max(
|
|
1984
|
+
toInt(state.analytics?.totalPairClaims, 0),
|
|
1985
|
+
uniqueCurrentPairedDeviceIds.size,
|
|
1986
|
+
claimedPairings.length
|
|
1987
|
+
);
|
|
1988
|
+
const analyticsLaptops = listCurrentPairedLaptops(uniqueCurrentPairedDeviceIds);
|
|
1989
|
+
const snapshotRollup = collectSnapshotAnalytics(analyticsLaptops, onlineLaptopIds, now);
|
|
1990
|
+
|
|
1991
|
+
return {
|
|
1992
|
+
ok: true,
|
|
1993
|
+
generatedAt: now,
|
|
1994
|
+
summary: {
|
|
1995
|
+
totalUsers: uniqueKnownDeviceIds.size,
|
|
1996
|
+
currentPairs: uniqueCurrentPairedDeviceIds.size,
|
|
1997
|
+
totalPairs,
|
|
1998
|
+
newUsers24h: countUniqueRecentPairings(claimedPairings, now, 24 * 60 * 60 * 1000),
|
|
1999
|
+
newUsers7d: countUniqueRecentPairings(claimedPairings, now, 7 * 24 * 60 * 60 * 1000),
|
|
2000
|
+
onlineDevices: analyticsLaptops.filter((item) => onlineLaptopIds.has(item.laptopId)).length,
|
|
2001
|
+
activeSessions: snapshotRollup.summary.activeSessions,
|
|
2002
|
+
appSessionsTotal: snapshotRollup.summary.appSessionsTotal,
|
|
2003
|
+
appRunsTotal: snapshotRollup.summary.appRunsTotal,
|
|
2004
|
+
appRuns24h: snapshotRollup.summary.appRuns24h,
|
|
2005
|
+
appRuns7d: snapshotRollup.summary.appRuns7d
|
|
2006
|
+
},
|
|
2007
|
+
agentUsage: snapshotRollup.agentUsage,
|
|
2008
|
+
sessionStates: snapshotRollup.sessionStates,
|
|
2009
|
+
runStates: snapshotRollup.runStates,
|
|
2010
|
+
daily: buildDailyActivitySeries({
|
|
2011
|
+
pairings: claimedPairings.map((item) => item.claimedAt),
|
|
2012
|
+
runs: snapshotRollup.dailyRuns
|
|
2013
|
+
}),
|
|
2014
|
+
devices: snapshotRollup.devices
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function listCurrentPairedLaptops(currentPairDeviceIds) {
|
|
2019
|
+
const byDeviceId = new Map();
|
|
2020
|
+
|
|
2021
|
+
for (const laptop of state.laptops) {
|
|
2022
|
+
const deviceId = safeText(laptop.deviceId, 200);
|
|
2023
|
+
if (!deviceId || !currentPairDeviceIds.has(deviceId)) continue;
|
|
2024
|
+
|
|
2025
|
+
const previous = byDeviceId.get(deviceId) || null;
|
|
2026
|
+
if (!previous) {
|
|
2027
|
+
byDeviceId.set(deviceId, laptop);
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
const previousScore = Math.max(
|
|
2032
|
+
toInt(previous.lastConnectedAt, 0),
|
|
2033
|
+
toInt(previous.lastSnapshotAt, 0),
|
|
2034
|
+
toInt(previous.updatedAt, 0),
|
|
2035
|
+
toInt(previous.createdAt, 0)
|
|
2036
|
+
);
|
|
2037
|
+
const nextScore = Math.max(
|
|
2038
|
+
toInt(laptop.lastConnectedAt, 0),
|
|
2039
|
+
toInt(laptop.lastSnapshotAt, 0),
|
|
2040
|
+
toInt(laptop.updatedAt, 0),
|
|
2041
|
+
toInt(laptop.createdAt, 0)
|
|
2042
|
+
);
|
|
2043
|
+
|
|
2044
|
+
if (nextScore >= previousScore) {
|
|
2045
|
+
byDeviceId.set(deviceId, laptop);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
return [...byDeviceId.values()];
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function collectSnapshotAnalytics(laptops, onlineLaptopIds, now) {
|
|
2053
|
+
const sessionStates = {
|
|
2054
|
+
RUNNING: 0,
|
|
2055
|
+
WAITING_INPUT: 0,
|
|
2056
|
+
COMPLETED: 0,
|
|
2057
|
+
FAILED: 0,
|
|
2058
|
+
CANCELLED: 0
|
|
2059
|
+
};
|
|
2060
|
+
const runStates = {
|
|
2061
|
+
STARTING: 0,
|
|
2062
|
+
RUNNING: 0,
|
|
2063
|
+
COMPLETED: 0,
|
|
2064
|
+
FAILED: 0,
|
|
2065
|
+
STOPPED: 0
|
|
2066
|
+
};
|
|
2067
|
+
const agentUsage = {
|
|
2068
|
+
codexRuns: 0,
|
|
2069
|
+
claudeRuns: 0,
|
|
2070
|
+
codexSessionsFromApp: 0,
|
|
2071
|
+
claudeSessionsFromApp: 0
|
|
2072
|
+
};
|
|
2073
|
+
const dailyRuns = [];
|
|
2074
|
+
|
|
2075
|
+
const devices = laptops
|
|
2076
|
+
.map((laptop) => {
|
|
2077
|
+
const snapshot = isObject(laptop.latestSnapshot) ? laptop.latestSnapshot : {};
|
|
2078
|
+
const runs = Array.isArray(snapshot.runs) ? snapshot.runs.filter((item) => isObject(item)) : [];
|
|
2079
|
+
const sessionSummaries = Array.isArray(snapshot.sessionSummaries)
|
|
2080
|
+
? snapshot.sessionSummaries.filter((item) => {
|
|
2081
|
+
if (!isObject(item)) return false;
|
|
2082
|
+
const sessionId = safeText(item.id || item?.session?.id, 200);
|
|
2083
|
+
return !SEEDED_SAMPLE_SESSION_IDS.has(sessionId);
|
|
2084
|
+
})
|
|
2085
|
+
: [];
|
|
2086
|
+
const sessions = Array.isArray(snapshot.sessions)
|
|
2087
|
+
? snapshot.sessions.filter((item) => {
|
|
2088
|
+
if (!isObject(item)) return false;
|
|
2089
|
+
const sessionId = safeText(item.id || item?.session?.id, 200);
|
|
2090
|
+
return !SEEDED_SAMPLE_SESSION_IDS.has(sessionId);
|
|
2091
|
+
})
|
|
2092
|
+
: [];
|
|
2093
|
+
const chatTurns = Array.isArray(snapshot.chatTurns)
|
|
2094
|
+
? snapshot.chatTurns.filter((item) => {
|
|
2095
|
+
if (!isObject(item)) return false;
|
|
2096
|
+
const sessionId = safeText(item.sessionId, 200);
|
|
2097
|
+
return sessionId && !SEEDED_SAMPLE_SESSION_IDS.has(sessionId);
|
|
2098
|
+
})
|
|
2099
|
+
: [];
|
|
2100
|
+
const sessionList = sessionSummaries.length > 0 ? sessionSummaries : sessions;
|
|
2101
|
+
const sessionLookup = new Map();
|
|
2102
|
+
for (const session of sessionList) {
|
|
2103
|
+
const sessionId = safeText(session.id || session?.session?.id, 200);
|
|
2104
|
+
if (sessionId) {
|
|
2105
|
+
sessionLookup.set(sessionId, session);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
const appSessionIds = new Set();
|
|
2109
|
+
|
|
2110
|
+
for (const run of runs) {
|
|
2111
|
+
const status = safeText(run.status, 40).toUpperCase();
|
|
2112
|
+
if (Object.prototype.hasOwnProperty.call(runStates, status)) {
|
|
2113
|
+
runStates[status] += 1;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
const agentType = safeText(run.agentType, 20).toUpperCase();
|
|
2117
|
+
if (agentType === "CODEX") agentUsage.codexRuns += 1;
|
|
2118
|
+
if (agentType === "CLAUDE") agentUsage.claudeRuns += 1;
|
|
2119
|
+
|
|
2120
|
+
const sessionId = safeText(run.sessionId, 200);
|
|
2121
|
+
if (sessionId && !SEEDED_SAMPLE_SESSION_IDS.has(sessionId)) appSessionIds.add(sessionId);
|
|
2122
|
+
|
|
2123
|
+
const createdAt = toInt(run.createdAt, 0);
|
|
2124
|
+
if (createdAt) {
|
|
2125
|
+
dailyRuns.push(createdAt);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
for (const turn of chatTurns) {
|
|
2130
|
+
const sessionId = safeText(turn.sessionId, 200);
|
|
2131
|
+
const role = safeText(turn.role, 20).toUpperCase();
|
|
2132
|
+
const source = safeText(turn.source, 40).toUpperCase();
|
|
2133
|
+
const text = safeText(turn.text || turn.content, 20_000);
|
|
2134
|
+
if (!sessionId || role !== "USER") continue;
|
|
2135
|
+
if (source === "MESSAGE_API" && text && !SEEDED_SAMPLE_SESSION_IDS.has(sessionId)) {
|
|
2136
|
+
appSessionIds.add(sessionId);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const activeAppSessionIds = [...appSessionIds].filter((sessionId) => {
|
|
2141
|
+
if (sessionLookup.has(sessionId)) return true;
|
|
2142
|
+
return runs.some((run) => safeText(run.sessionId, 200) === sessionId);
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
for (const sessionId of activeAppSessionIds) {
|
|
2146
|
+
const session = sessionLookup.get(sessionId);
|
|
2147
|
+
if (!session) continue;
|
|
2148
|
+
|
|
2149
|
+
const sessionState =
|
|
2150
|
+
safeText(session?.session?.state, 40).toUpperCase() ||
|
|
2151
|
+
safeText(session.state, 40).toUpperCase();
|
|
2152
|
+
if (Object.prototype.hasOwnProperty.call(sessionStates, sessionState)) {
|
|
2153
|
+
sessionStates[sessionState] += 1;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
const agentType =
|
|
2157
|
+
safeText(session?.thread?.agentType, 20).toUpperCase() ||
|
|
2158
|
+
safeText(session?.session?.agentType, 20).toUpperCase() ||
|
|
2159
|
+
safeText(session.agentType, 20).toUpperCase();
|
|
2160
|
+
if (agentType === "CODEX") agentUsage.codexSessionsFromApp += 1;
|
|
2161
|
+
if (agentType === "CLAUDE") agentUsage.claudeSessionsFromApp += 1;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const lastSeenAt =
|
|
2165
|
+
laptop.lastSnapshotAt ||
|
|
2166
|
+
laptop.lastConnectedAt ||
|
|
2167
|
+
laptop.lastDisconnectedAt ||
|
|
2168
|
+
laptop.pairedAt ||
|
|
2169
|
+
laptop.createdAt ||
|
|
2170
|
+
now;
|
|
2171
|
+
|
|
2172
|
+
return {
|
|
2173
|
+
laptopId: laptop.laptopId,
|
|
2174
|
+
deviceId: laptop.deviceId,
|
|
2175
|
+
name: laptop.name || "Unnamed computer",
|
|
2176
|
+
online: onlineLaptopIds.has(laptop.laptopId),
|
|
2177
|
+
createdAt: laptop.createdAt,
|
|
2178
|
+
pairedAt: laptop.pairedAt || null,
|
|
2179
|
+
lastSeenAt,
|
|
2180
|
+
sessionCount: activeAppSessionIds.length,
|
|
2181
|
+
activeSessionCount: activeAppSessionIds
|
|
2182
|
+
.map((sessionId) => sessionLookup.get(sessionId))
|
|
2183
|
+
.filter(Boolean)
|
|
2184
|
+
.filter((session) => {
|
|
2185
|
+
const stateValue =
|
|
2186
|
+
safeText(session?.session?.state, 40).toUpperCase() ||
|
|
2187
|
+
safeText(session.state, 40).toUpperCase();
|
|
2188
|
+
return stateValue === "RUNNING" || stateValue === "WAITING_INPUT";
|
|
2189
|
+
}).length,
|
|
2190
|
+
runCount: runs.length,
|
|
2191
|
+
codexRuns: runs.filter((item) => safeText(item.agentType, 20).toUpperCase() === "CODEX").length,
|
|
2192
|
+
claudeRuns: runs.filter((item) => safeText(item.agentType, 20).toUpperCase() === "CLAUDE").length
|
|
2193
|
+
};
|
|
2194
|
+
})
|
|
2195
|
+
.sort((a, b) => {
|
|
2196
|
+
if (a.online !== b.online) return a.online ? -1 : 1;
|
|
2197
|
+
return (b.lastSeenAt || 0) - (a.lastSeenAt || 0);
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
return {
|
|
2201
|
+
summary: {
|
|
2202
|
+
activeSessions:
|
|
2203
|
+
sessionStates.RUNNING + sessionStates.WAITING_INPUT,
|
|
2204
|
+
appSessionsTotal: devices.reduce((sum, item) => sum + item.sessionCount, 0),
|
|
2205
|
+
appRunsTotal: devices.reduce((sum, item) => sum + item.runCount, 0),
|
|
2206
|
+
appRuns24h: countRecentTimestamps(dailyRuns, now, 24 * 60 * 60 * 1000),
|
|
2207
|
+
appRuns7d: countRecentTimestamps(dailyRuns, now, 7 * 24 * 60 * 60 * 1000)
|
|
2208
|
+
},
|
|
2209
|
+
sessionStates,
|
|
2210
|
+
runStates,
|
|
2211
|
+
agentUsage,
|
|
2212
|
+
devices,
|
|
2213
|
+
dailyRuns
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
function countRecentDevices(laptops, now, windowMs) {
|
|
2218
|
+
return laptops.filter((item) => {
|
|
2219
|
+
const lastSeenAt =
|
|
2220
|
+
item.lastSnapshotAt ||
|
|
2221
|
+
item.lastConnectedAt ||
|
|
2222
|
+
item.lastDisconnectedAt ||
|
|
2223
|
+
item.pairedAt ||
|
|
2224
|
+
item.createdAt ||
|
|
2225
|
+
0;
|
|
2226
|
+
return lastSeenAt > 0 && now - lastSeenAt <= windowMs;
|
|
2227
|
+
}).length;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function countUniqueRecentPairings(pairings, now, windowMs) {
|
|
2231
|
+
const deviceIds = new Set();
|
|
2232
|
+
for (const pairing of pairings) {
|
|
2233
|
+
const claimedAt = toInt(pairing?.claimedAt, 0);
|
|
2234
|
+
const deviceId = safeText(pairing?.deviceId, 200);
|
|
2235
|
+
if (!claimedAt || !deviceId) continue;
|
|
2236
|
+
if (now - claimedAt > windowMs) continue;
|
|
2237
|
+
deviceIds.add(deviceId);
|
|
2238
|
+
}
|
|
2239
|
+
return deviceIds.size;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function countRecentTimestamps(values, now, windowMs) {
|
|
2243
|
+
return values.filter((value) => {
|
|
2244
|
+
const timestamp = toInt(value, 0);
|
|
2245
|
+
return timestamp > 0 && now - timestamp <= windowMs;
|
|
2246
|
+
}).length;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function buildDailyActivitySeries({ pairings, runs }, days = 14) {
|
|
2250
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
2251
|
+
const startOfToday = new Date();
|
|
2252
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
2253
|
+
const startTime = startOfToday.getTime() - (days - 1) * dayMs;
|
|
2254
|
+
const buckets = new Map();
|
|
2255
|
+
|
|
2256
|
+
for (let offset = 0; offset < days; offset += 1) {
|
|
2257
|
+
const ts = startTime + offset * dayMs;
|
|
2258
|
+
const key = formatDayKey(ts);
|
|
2259
|
+
buckets.set(key, {
|
|
2260
|
+
date: key,
|
|
2261
|
+
pairings: 0,
|
|
2262
|
+
runs: 0
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
for (const ts of pairings) {
|
|
2267
|
+
const value = toInt(ts, 0);
|
|
2268
|
+
if (!value || value < startTime) continue;
|
|
2269
|
+
const bucket = buckets.get(formatDayKey(value));
|
|
2270
|
+
if (bucket) bucket.pairings += 1;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
for (const ts of runs) {
|
|
2274
|
+
const value = toInt(ts, 0);
|
|
2275
|
+
if (!value || value < startTime) continue;
|
|
2276
|
+
const bucket = buckets.get(formatDayKey(value));
|
|
2277
|
+
if (bucket) bucket.runs += 1;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
return [...buckets.values()];
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function formatDayKey(timestamp) {
|
|
2284
|
+
const date = new Date(timestamp);
|
|
2285
|
+
const year = date.getFullYear();
|
|
2286
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
2287
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
2288
|
+
return `${year}-${month}-${day}`;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
1873
2291
|
function sanitizeState(raw) {
|
|
1874
2292
|
const fallback = {
|
|
1875
2293
|
laptops: [],
|
|
1876
2294
|
pairings: [],
|
|
1877
2295
|
phones: [],
|
|
1878
2296
|
previews: [],
|
|
2297
|
+
revokedPhoneDevices: {},
|
|
2298
|
+
analytics: {
|
|
2299
|
+
totalPairClaims: 0
|
|
2300
|
+
},
|
|
1879
2301
|
updatedAt: Date.now()
|
|
1880
2302
|
};
|
|
1881
2303
|
|
|
@@ -1957,11 +2379,30 @@ function sanitizeState(raw) {
|
|
|
1957
2379
|
.filter((item) => item.previewId && item.accessToken && item.laptopId && item.deviceId && item.target)
|
|
1958
2380
|
: [];
|
|
1959
2381
|
|
|
2382
|
+
const revokedPhoneDevices = isObject(raw.revokedPhoneDevices)
|
|
2383
|
+
? Object.fromEntries(
|
|
2384
|
+
Object.entries(raw.revokedPhoneDevices)
|
|
2385
|
+
.map(([deviceId, revokedAt]) => [safeText(deviceId, 200), toInt(revokedAt, 0)])
|
|
2386
|
+
.filter(([deviceId, revokedAt]) => deviceId && revokedAt > 0)
|
|
2387
|
+
)
|
|
2388
|
+
: {};
|
|
2389
|
+
|
|
2390
|
+
const totalPairClaims = Math.max(
|
|
2391
|
+
toInt(raw?.analytics?.totalPairClaims, 0),
|
|
2392
|
+
phones.length,
|
|
2393
|
+
new Set(laptops.filter((item) => item.pairedAt).map((item) => item.deviceId).filter(Boolean)).size,
|
|
2394
|
+
pairings.filter((item) => item.claimedAt).length
|
|
2395
|
+
);
|
|
2396
|
+
|
|
1960
2397
|
return {
|
|
1961
2398
|
laptops,
|
|
1962
2399
|
pairings,
|
|
1963
2400
|
phones,
|
|
1964
2401
|
previews,
|
|
2402
|
+
revokedPhoneDevices,
|
|
2403
|
+
analytics: {
|
|
2404
|
+
totalPairClaims
|
|
2405
|
+
},
|
|
1965
2406
|
updatedAt: toInt(raw.updatedAt, Date.now())
|
|
1966
2407
|
};
|
|
1967
2408
|
}
|
|
@@ -1989,6 +2430,11 @@ function trimStateCollections() {
|
|
|
1989
2430
|
}
|
|
1990
2431
|
}
|
|
1991
2432
|
|
|
2433
|
+
function isPhoneDeviceRevoked(deviceId) {
|
|
2434
|
+
if (!deviceId || !isObject(state.revokedPhoneDevices)) return false;
|
|
2435
|
+
return toInt(state.revokedPhoneDevices[deviceId], 0) > 0;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
1992
2438
|
function gracefulShutdown(signal) {
|
|
1993
2439
|
if (shuttingDown) return;
|
|
1994
2440
|
shuttingDown = true;
|