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 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
@@ -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
- if (!normalizedPath) {
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.4",
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;