agent-companion 0.1.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-companion",
3
- "version": "0.1.5",
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.",
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;