agent-companion 0.1.5 → 0.1.7

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 {
@@ -28,6 +28,8 @@ const MAX_JSONL_TAIL_BYTES = 1_500_000;
28
28
  const MAX_JSONL_TAIL_LINES = 1200;
29
29
  const DIRECT_RUNNING_FRESH_WINDOW_SEC = 20;
30
30
  const DIRECT_RUNNING_STALE_TIMEOUT_SEC = 180;
31
+ const INTERNAL_PLAN_MODE_SUFFIX =
32
+ "You are in PLAN MODE. Do not implement changes yet. Return only a concrete plan, then end with a single explicit approval question asking whether to proceed with implementation.";
31
33
  const PENDING_PATTERN =
32
34
  /input required|needs input|waiting for input|approval[_\s-]*required|awaiting approval|please approve|approve (?:this|the) plan|should i (?:proceed|implement|execute)|would you like me to (?:proceed|implement|execute)|ready to (?:implement|execute)|want me to (?:implement|execute)|proceed with implementation/i;
33
35
 
@@ -217,7 +219,7 @@ function parseCodexFile(file, nowMs) {
217
219
  }
218
220
 
219
221
  if (record.type === "event_msg" && record.payload?.type === "user_message") {
220
- const message = String(record.payload?.message || "").trim();
222
+ const message = normalizeVisibleUserText(record.payload?.message || "");
221
223
  flushPendingAssistant();
222
224
  if (message && !isNoisePrompt(message) && !firstPrompt) firstPrompt = message;
223
225
  appendDirectTurn(chatTurns, {
@@ -271,7 +273,7 @@ function parseCodexFile(file, nowMs) {
271
273
  if (record.type === "response_item" && record.payload?.type === "message") {
272
274
  const role = record.payload?.role;
273
275
  if (role === "user") {
274
- const text = extractCodexText(record.payload?.content);
276
+ const text = normalizeVisibleUserText(extractCodexText(record.payload?.content));
275
277
  flushPendingAssistant();
276
278
  if (text && !isNoisePrompt(text) && !firstPrompt) firstPrompt = text;
277
279
  appendDirectTurn(chatTurns, {
@@ -622,7 +624,7 @@ function formatCodexToolCall(item) {
622
624
  function extractClaudeUserText(message) {
623
625
  if (!message) return "";
624
626
  if (typeof message.content === "string") {
625
- const text = sanitizeDirectText(message.content);
627
+ const text = normalizeVisibleUserText(message.content);
626
628
  return shouldIncludeDirectUserText(text) ? text : "";
627
629
  }
628
630
  if (!Array.isArray(message.content)) return "";
@@ -630,7 +632,7 @@ function extractClaudeUserText(message) {
630
632
  const collected = [];
631
633
  for (const part of message.content) {
632
634
  if (typeof part === "string") {
633
- const text = sanitizeDirectText(part);
635
+ const text = normalizeVisibleUserText(part);
634
636
  if (shouldIncludeDirectUserText(text)) {
635
637
  collected.push(text);
636
638
  }
@@ -639,20 +641,20 @@ function extractClaudeUserText(message) {
639
641
  const partType = String(part?.type || "").trim().toLowerCase();
640
642
  if (partType && partType !== "text" && partType !== "input_text") continue;
641
643
  if (typeof part?.text === "string") {
642
- const text = sanitizeDirectText(part.text);
644
+ const text = normalizeVisibleUserText(part.text);
643
645
  if (shouldIncludeDirectUserText(text)) {
644
646
  collected.push(text);
645
647
  }
646
648
  }
647
649
  if (typeof part?.content === "string" && part.content.trim()) {
648
- const text = sanitizeDirectText(part.content);
650
+ const text = normalizeVisibleUserText(part.content);
649
651
  if (shouldIncludeDirectUserText(text)) {
650
652
  collected.push(text);
651
653
  }
652
654
  }
653
655
  }
654
656
 
655
- return collected.join("\n\n").trim();
657
+ return dedupeCollectedText(collected).join("\n\n").trim();
656
658
  }
657
659
 
658
660
  function extractClaudeAssistantText(message) {
@@ -869,6 +871,37 @@ function sanitizeDirectText(value) {
869
871
  .trim();
870
872
  }
871
873
 
874
+ function normalizeVisibleUserText(value) {
875
+ const text = sanitizeDirectText(value);
876
+ if (!text) return "";
877
+
878
+ return text
879
+ .replace(new RegExp(`(?:\\n\\s*)*${escapeRegExp(INTERNAL_PLAN_MODE_SUFFIX)}\\s*$`, "i"), "")
880
+ .trim();
881
+ }
882
+
883
+ function dedupeCollectedText(values) {
884
+ const deduped = [];
885
+ const seen = new Set();
886
+
887
+ for (const value of values) {
888
+ const text = String(value || "").trim();
889
+ if (!text) continue;
890
+
891
+ const normalized = normalizeComparableText(text);
892
+ if (!normalized || seen.has(normalized)) continue;
893
+
894
+ seen.add(normalized);
895
+ deduped.push(text);
896
+ }
897
+
898
+ return deduped;
899
+ }
900
+
901
+ function escapeRegExp(value) {
902
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
903
+ }
904
+
872
905
  function getRecentJsonlFiles(rootDir, limit) {
873
906
  const now = Date.now();
874
907
  const cached = recentFilesCache.get(rootDir);
package/bridge/server.mjs CHANGED
@@ -1333,11 +1333,7 @@ function normalizeCommandList(input, agentType, prompt) {
1333
1333
  }
1334
1334
 
1335
1335
  function buildPlanPrompt(prompt) {
1336
- const base = safeTrimmedText(prompt, 1500);
1337
- if (!base) return "";
1338
- const suffix =
1339
- "\n\nYou are in PLAN MODE. Do not implement changes yet. Return only a concrete plan, then end with a single explicit approval question asking whether to proceed with implementation.";
1340
- return safeTrimmedText(`${base}${suffix}`, 1900);
1336
+ return safeTrimmedText(prompt, 1500);
1341
1337
  }
1342
1338
 
1343
1339
  function createLauncherRun(input) {
@@ -2527,13 +2523,19 @@ function mergeDirectSnapshot(snapshot) {
2527
2523
  .map((item) => item?.id)
2528
2524
  .filter((id) => typeof id === "string")
2529
2525
  );
2526
+ const incomingDirectTurnSessionIds = new Set(
2527
+ (Array.isArray(snapshot.chatTurns) ? snapshot.chatTurns : [])
2528
+ .map((item) => safeTrimmedText(item?.sessionId, 160))
2529
+ .filter(Boolean)
2530
+ );
2530
2531
  state.chatTurns = (Array.isArray(state.chatTurns) ? state.chatTurns : []).filter((item) => {
2531
2532
  const sessionId = String(item?.sessionId || "");
2532
- const isDirectSession = sessionId.startsWith("codex:") || sessionId.startsWith("claude:");
2533
2533
  const isDirectTurn = safeTrimmedText(item?.source, 48).toUpperCase() === "DIRECT" || String(item?.id || "").startsWith("direct:");
2534
- if (!isDirectSession || !isDirectTurn) return true;
2535
- if (!incomingDirectSessionIds.has(sessionId)) return false;
2536
- return incomingDirectTurnIds.has(item.id);
2534
+ if (!isDirectTurn) return true;
2535
+ if (incomingDirectTurnIds.has(item.id)) return true;
2536
+ if (incomingDirectTurnSessionIds.has(sessionId)) return false;
2537
+ if (incomingDirectSessionIds.has(sessionId)) return false;
2538
+ return true;
2537
2539
  });
2538
2540
 
2539
2541
  const existingTurns = new Map((Array.isArray(state.chatTurns) ? state.chatTurns : []).map((item) => [item.id, item]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-companion",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Phone-to-computer companion for Codex and Claude Code.",
package/relay/server.mjs CHANGED
@@ -39,6 +39,7 @@ const MAX_PHONE_TOKENS = 2_000;
39
39
  const MAX_PREVIEW_RECORDS = 4_000;
40
40
  const PHONE_TOKEN_HEADER = "x-agent-companion-phone-token";
41
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"]);
42
43
 
43
44
  const STATE_FILE = path.resolve(PROJECT_ROOT, "relay", "state.json");
44
45
  const app = express();
@@ -86,6 +87,7 @@ app.get("/health", (_req, res) => {
86
87
  app.get("/api/admin/analytics", requireAdminToken, (_req, res) => {
87
88
  cleanupExpiredPairings();
88
89
  cleanupExpiredPreviews();
90
+ res.setHeader("Cache-Control", "no-store");
89
91
  res.json(buildAdminAnalytics());
90
92
  });
91
93
 
@@ -288,6 +290,16 @@ app.post("/api/pairings/claim", (req, res) => {
288
290
  mutateState(() => {
289
291
  pairing.claimedAt = now;
290
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;
291
303
 
292
304
  state.phones.push({
293
305
  phoneToken,
@@ -311,6 +323,37 @@ app.post("/api/pairings/claim", (req, res) => {
311
323
  });
312
324
  });
313
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
+
314
357
  app.use("/api/devices/:id", requirePhoneToken, requireDeviceAccess);
315
358
 
316
359
  app.get("/api/devices/:id/status", (req, res) => {
@@ -1493,6 +1536,9 @@ function findPhoneByToken(token) {
1493
1536
  function resolvePhoneSession(token) {
1494
1537
  const exact = findPhoneByToken(token);
1495
1538
  if (exact) {
1539
+ if (isPhoneDeviceRevoked(exact.deviceId)) {
1540
+ return null;
1541
+ }
1496
1542
  const refreshedToken = issuePhoneToken(exact.deviceId);
1497
1543
  return {
1498
1544
  phone: exact,
@@ -1505,6 +1551,7 @@ function resolvePhoneSession(token) {
1505
1551
 
1506
1552
  const deviceId = safeText(claims.deviceId, 200);
1507
1553
  if (!deviceId) return null;
1554
+ if (isPhoneDeviceRevoked(deviceId)) return null;
1508
1555
 
1509
1556
  const restored = {
1510
1557
  phoneToken: token,
@@ -1862,6 +1909,10 @@ function loadState() {
1862
1909
  pairings: [],
1863
1910
  phones: [],
1864
1911
  previews: [],
1912
+ revokedPhoneDevices: {},
1913
+ analytics: {
1914
+ totalPairClaims: 0
1915
+ },
1865
1916
  updatedAt: Date.now()
1866
1917
  };
1867
1918
 
@@ -1924,20 +1975,29 @@ function buildAdminAnalytics() {
1924
1975
  );
1925
1976
  const claimedPairings = state.pairings.filter((item) => item.claimedAt);
1926
1977
  const activePreviews = state.previews.filter((item) => item.expiresAt > now);
1927
- const snapshotRollup = collectSnapshotAnalytics(state.laptops, onlineLaptopIds, now);
1928
- const uniquePairedDeviceIds = new Set(
1929
- state.laptops.map((item) => safeText(item.deviceId, 200)).filter(Boolean)
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
1930
1987
  );
1988
+ const analyticsLaptops = listCurrentPairedLaptops(uniqueCurrentPairedDeviceIds);
1989
+ const snapshotRollup = collectSnapshotAnalytics(analyticsLaptops, onlineLaptopIds, now);
1931
1990
 
1932
1991
  return {
1933
1992
  ok: true,
1934
1993
  generatedAt: now,
1935
1994
  summary: {
1936
- totalUsers: uniquePairedDeviceIds.size,
1937
- totalPairs: claimedPairings.length,
1995
+ totalUsers: uniqueKnownDeviceIds.size,
1996
+ currentPairs: uniqueCurrentPairedDeviceIds.size,
1997
+ totalPairs,
1938
1998
  newUsers24h: countUniqueRecentPairings(claimedPairings, now, 24 * 60 * 60 * 1000),
1939
1999
  newUsers7d: countUniqueRecentPairings(claimedPairings, now, 7 * 24 * 60 * 60 * 1000),
1940
- onlineDevices: onlineLaptopIds.size,
2000
+ onlineDevices: analyticsLaptops.filter((item) => onlineLaptopIds.has(item.laptopId)).length,
1941
2001
  activeSessions: snapshotRollup.summary.activeSessions,
1942
2002
  appSessionsTotal: snapshotRollup.summary.appSessionsTotal,
1943
2003
  appRunsTotal: snapshotRollup.summary.appRunsTotal,
@@ -1955,6 +2015,40 @@ function buildAdminAnalytics() {
1955
2015
  };
1956
2016
  }
1957
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
+
1958
2052
  function collectSnapshotAnalytics(laptops, onlineLaptopIds, now) {
1959
2053
  const sessionStates = {
1960
2054
  RUNNING: 0,
@@ -1983,10 +2077,26 @@ function collectSnapshotAnalytics(laptops, onlineLaptopIds, now) {
1983
2077
  const snapshot = isObject(laptop.latestSnapshot) ? laptop.latestSnapshot : {};
1984
2078
  const runs = Array.isArray(snapshot.runs) ? snapshot.runs.filter((item) => isObject(item)) : [];
1985
2079
  const sessionSummaries = Array.isArray(snapshot.sessionSummaries)
1986
- ? snapshot.sessionSummaries.filter((item) => isObject(item))
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
+ })
1987
2099
  : [];
1988
- const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions.filter((item) => isObject(item)) : [];
1989
- const chatTurns = Array.isArray(snapshot.chatTurns) ? snapshot.chatTurns.filter((item) => isObject(item)) : [];
1990
2100
  const sessionList = sessionSummaries.length > 0 ? sessionSummaries : sessions;
1991
2101
  const sessionLookup = new Map();
1992
2102
  for (const session of sessionList) {
@@ -2008,7 +2118,7 @@ function collectSnapshotAnalytics(laptops, onlineLaptopIds, now) {
2008
2118
  if (agentType === "CLAUDE") agentUsage.claudeRuns += 1;
2009
2119
 
2010
2120
  const sessionId = safeText(run.sessionId, 200);
2011
- if (sessionId) appSessionIds.add(sessionId);
2121
+ if (sessionId && !SEEDED_SAMPLE_SESSION_IDS.has(sessionId)) appSessionIds.add(sessionId);
2012
2122
 
2013
2123
  const createdAt = toInt(run.createdAt, 0);
2014
2124
  if (createdAt) {
@@ -2020,13 +2130,19 @@ function collectSnapshotAnalytics(laptops, onlineLaptopIds, now) {
2020
2130
  const sessionId = safeText(turn.sessionId, 200);
2021
2131
  const role = safeText(turn.role, 20).toUpperCase();
2022
2132
  const source = safeText(turn.source, 40).toUpperCase();
2133
+ const text = safeText(turn.text || turn.content, 20_000);
2023
2134
  if (!sessionId || role !== "USER") continue;
2024
- if (source === "MESSAGE_API" || source === "LAUNCH") {
2135
+ if (source === "MESSAGE_API" && text && !SEEDED_SAMPLE_SESSION_IDS.has(sessionId)) {
2025
2136
  appSessionIds.add(sessionId);
2026
2137
  }
2027
2138
  }
2028
2139
 
2029
- for (const sessionId of appSessionIds) {
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) {
2030
2146
  const session = sessionLookup.get(sessionId);
2031
2147
  if (!session) continue;
2032
2148
 
@@ -2061,8 +2177,8 @@ function collectSnapshotAnalytics(laptops, onlineLaptopIds, now) {
2061
2177
  createdAt: laptop.createdAt,
2062
2178
  pairedAt: laptop.pairedAt || null,
2063
2179
  lastSeenAt,
2064
- sessionCount: appSessionIds.size,
2065
- activeSessionCount: [...appSessionIds]
2180
+ sessionCount: activeAppSessionIds.length,
2181
+ activeSessionCount: activeAppSessionIds
2066
2182
  .map((sessionId) => sessionLookup.get(sessionId))
2067
2183
  .filter(Boolean)
2068
2184
  .filter((session) => {
@@ -2178,6 +2294,10 @@ function sanitizeState(raw) {
2178
2294
  pairings: [],
2179
2295
  phones: [],
2180
2296
  previews: [],
2297
+ revokedPhoneDevices: {},
2298
+ analytics: {
2299
+ totalPairClaims: 0
2300
+ },
2181
2301
  updatedAt: Date.now()
2182
2302
  };
2183
2303
 
@@ -2259,11 +2379,30 @@ function sanitizeState(raw) {
2259
2379
  .filter((item) => item.previewId && item.accessToken && item.laptopId && item.deviceId && item.target)
2260
2380
  : [];
2261
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
+
2262
2397
  return {
2263
2398
  laptops,
2264
2399
  pairings,
2265
2400
  phones,
2266
2401
  previews,
2402
+ revokedPhoneDevices,
2403
+ analytics: {
2404
+ totalPairClaims
2405
+ },
2267
2406
  updatedAt: toInt(raw.updatedAt, Date.now())
2268
2407
  };
2269
2408
  }
@@ -2291,6 +2430,11 @@ function trimStateCollections() {
2291
2430
  }
2292
2431
  }
2293
2432
 
2433
+ function isPhoneDeviceRevoked(deviceId) {
2434
+ if (!deviceId || !isObject(state.revokedPhoneDevices)) return false;
2435
+ return toInt(state.revokedPhoneDevices[deviceId], 0) > 0;
2436
+ }
2437
+
2294
2438
  function gracefulShutdown(signal) {
2295
2439
  if (shuttingDown) return;
2296
2440
  shuttingDown = true;