agent-companion 0.1.1 → 0.1.3

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.
@@ -290,6 +290,7 @@ function parseCodexFile(file, nowMs) {
290
290
  agentType: "CODEX",
291
291
  title,
292
292
  repo,
293
+ workspacePath: cwd || "",
293
294
  branch: "main",
294
295
  state,
295
296
  lastUpdated: effectiveLastUpdated,
@@ -461,6 +462,7 @@ function parseClaudeFile(file, nowMs) {
461
462
  agentType: "CLAUDE",
462
463
  title,
463
464
  repo,
465
+ workspacePath: cwd || "",
464
466
  branch,
465
467
  state,
466
468
  lastUpdated: effectiveLastUpdated,
@@ -608,6 +610,8 @@ function isNoisePrompt(text) {
608
610
  if (raw.includes("Filesystem sandboxing defines")) return true;
609
611
  if (raw.includes("AGENT_COMPANION_PERSIST_SERVER_HINT_V1")) return true;
610
612
  if (raw.includes("[Request interrupted by user for tool use]")) return true;
613
+ if (/^\[background-service\]/i.test(raw.trim())) return true;
614
+ if (/"service"\s*:\s*\{/.test(raw) && /"localhostUrl"\s*:/.test(raw)) return true;
611
615
  if (raw === "Answer questions?") return true;
612
616
  return false;
613
617
  }
package/bridge/server.mjs CHANGED
@@ -151,6 +151,11 @@ function normalizeState(value) {
151
151
  return allowed.includes(value) ? value : "RUNNING";
152
152
  }
153
153
 
154
+ function isDirectSessionId(value) {
155
+ const id = safeTrimmedText(value, 160);
156
+ return id.startsWith("codex:") || id.startsWith("claude:");
157
+ }
158
+
154
159
  function normalizeCategory(value) {
155
160
  const allowed = ["INFO", "ACTION", "INPUT", "ERROR"];
156
161
  return allowed.includes(value) ? value : "INFO";
@@ -479,6 +484,7 @@ function upsertSessionThread(input) {
479
484
  function syncSessionThreadFromSession(session, input = {}) {
480
485
  if (!session?.id) return null;
481
486
  const existingThread = findSessionThread(session.id);
487
+ const inferredIdentity = inferThreadIdentityFromSessionId(session.id);
482
488
  const fallbackLookupKey = buildSessionThreadLookupKey({
483
489
  agentType: session.agentType,
484
490
  workspacePath: input.workspacePath || existingThread?.workspacePath || "",
@@ -504,10 +510,15 @@ function syncSessionThreadFromSession(session, input = {}) {
504
510
  input.lastMessageAt,
505
511
  safeNumber(existingThread?.lastMessageAt, safeNumber(session.lastUpdated, 0))
506
512
  ),
507
- codexThreadId: safeTrimmedText(input.codexThreadId, 120) || existingThread?.codexThreadId || null,
513
+ codexThreadId:
514
+ safeTrimmedText(input.codexThreadId, 120) ||
515
+ existingThread?.codexThreadId ||
516
+ inferredIdentity.codexThreadId ||
517
+ null,
508
518
  claudeSessionId:
509
519
  safeTrimmedText(input.claudeSessionId, 120).toLowerCase() ||
510
520
  safeTrimmedText(existingThread?.claudeSessionId, 120).toLowerCase() ||
521
+ inferredIdentity.claudeSessionId ||
511
522
  null
512
523
  });
513
524
  }
@@ -627,17 +638,214 @@ function extractClaudeSessionIdFromRun(run) {
627
638
  return "";
628
639
  }
629
640
 
641
+ function normalizeClaudeSessionId(value) {
642
+ const candidate = safeTrimmedText(value, 120).toLowerCase();
643
+ if (!candidate) return "";
644
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(candidate)) {
645
+ return candidate;
646
+ }
647
+ return "";
648
+ }
649
+
650
+ function inferThreadIdentityFromSessionId(sessionId) {
651
+ const id = safeTrimmedText(sessionId, 160);
652
+ if (!id) {
653
+ return { codexThreadId: null, claudeSessionId: null };
654
+ }
655
+
656
+ if (id.startsWith("codex:")) {
657
+ const codexThreadId = safeTrimmedText(id.slice("codex:".length), 120).toLowerCase();
658
+ return {
659
+ codexThreadId: codexThreadId || null,
660
+ claudeSessionId: null
661
+ };
662
+ }
663
+
664
+ if (id.startsWith("claude:")) {
665
+ const claudeSessionId = normalizeClaudeSessionId(id.slice("claude:".length));
666
+ return {
667
+ codexThreadId: null,
668
+ claudeSessionId: claudeSessionId || null
669
+ };
670
+ }
671
+
672
+ return { codexThreadId: null, claudeSessionId: null };
673
+ }
674
+
675
+ function getLauncherRunUpdatedAt(run) {
676
+ return Math.max(
677
+ safeNumber(run?.endedAt, 0),
678
+ safeNumber(run?.startedAt, 0),
679
+ safeNumber(run?.createdAt, 0)
680
+ );
681
+ }
682
+
683
+ function resolveCanonicalSessionIdForDirectSession(sessionId) {
684
+ const directSessionId = safeTrimmedText(sessionId, 160);
685
+ if (!isDirectSessionId(directSessionId)) return directSessionId;
686
+
687
+ if (directSessionId.startsWith("codex:")) {
688
+ const threadId = directSessionId.slice("codex:".length).trim().toLowerCase();
689
+ if (!threadId) return directSessionId;
690
+
691
+ const mappedRun = [...launcherRuns.values()]
692
+ .slice()
693
+ .sort((a, b) => getLauncherRunUpdatedAt(b) - getLauncherRunUpdatedAt(a))
694
+ .find((run) => !isDirectSessionId(run?.sessionId) && extractCodexThreadIdFromRun(run) === threadId);
695
+ if (mappedRun?.sessionId) return mappedRun.sessionId;
696
+
697
+ const mappedThread = (Array.isArray(state.sessionThreads) ? state.sessionThreads : [])
698
+ .slice()
699
+ .sort((a, b) => safeNumber(b.updatedAt, 0) - safeNumber(a.updatedAt, 0))
700
+ .find(
701
+ (thread) =>
702
+ !isDirectSessionId(thread?.id) &&
703
+ safeTrimmedText(thread?.codexThreadId, 120).toLowerCase() === threadId
704
+ );
705
+ if (mappedThread?.id) return mappedThread.id;
706
+
707
+ return directSessionId;
708
+ }
709
+
710
+ const claudeSessionId = normalizeClaudeSessionId(directSessionId.slice("claude:".length));
711
+ if (!claudeSessionId) return directSessionId;
712
+
713
+ const mappedRun = [...launcherRuns.values()]
714
+ .slice()
715
+ .sort((a, b) => getLauncherRunUpdatedAt(b) - getLauncherRunUpdatedAt(a))
716
+ .find((run) => !isDirectSessionId(run?.sessionId) && extractClaudeSessionIdFromRun(run) === claudeSessionId);
717
+ if (mappedRun?.sessionId) return mappedRun.sessionId;
718
+
719
+ const mappedThread = (Array.isArray(state.sessionThreads) ? state.sessionThreads : [])
720
+ .slice()
721
+ .sort((a, b) => safeNumber(b.updatedAt, 0) - safeNumber(a.updatedAt, 0))
722
+ .find(
723
+ (thread) =>
724
+ !isDirectSessionId(thread?.id) &&
725
+ normalizeClaudeSessionId(thread?.claudeSessionId) === claudeSessionId
726
+ );
727
+ if (mappedThread?.id) return mappedThread.id;
728
+
729
+ return directSessionId;
730
+ }
731
+
732
+ function rewriteDirectScopedId(id, originalSessionId, canonicalSessionId) {
733
+ const text = safeTrimmedText(id, 240);
734
+ if (!text || !originalSessionId || !canonicalSessionId || originalSessionId === canonicalSessionId) {
735
+ return text || id;
736
+ }
737
+
738
+ if (text === `pending:${originalSessionId}`) {
739
+ return `pending:${canonicalSessionId}`;
740
+ }
741
+ if (text === `event:${originalSessionId}`) {
742
+ return `event:${canonicalSessionId}`;
743
+ }
744
+
745
+ const directPrefix = `direct:${originalSessionId}:`;
746
+ if (text.startsWith(directPrefix)) {
747
+ return `direct:${canonicalSessionId}:${text.slice(directPrefix.length)}`;
748
+ }
749
+
750
+ return text;
751
+ }
752
+
753
+ function canonicalizeDirectSnapshot(snapshot) {
754
+ if (!snapshot || typeof snapshot !== "object") return snapshot;
755
+
756
+ const sessionIdMap = new Map();
757
+ const sourceDirectSessionIds = new Set();
758
+
759
+ const remapSessionId = (value) => {
760
+ const sessionId = safeTrimmedText(value, 160);
761
+ if (!isDirectSessionId(sessionId)) return sessionId;
762
+ sourceDirectSessionIds.add(sessionId);
763
+
764
+ const existing = sessionIdMap.get(sessionId);
765
+ if (existing) return existing;
766
+
767
+ const canonical = resolveCanonicalSessionIdForDirectSession(sessionId) || sessionId;
768
+ sessionIdMap.set(sessionId, canonical);
769
+ return canonical;
770
+ };
771
+
772
+ const sessions = (Array.isArray(snapshot.sessions) ? snapshot.sessions : []).map((item) => {
773
+ const originalSessionId = safeTrimmedText(item?.id, 160);
774
+ const canonicalSessionId = remapSessionId(originalSessionId);
775
+ if (!originalSessionId || !canonicalSessionId || canonicalSessionId === originalSessionId) {
776
+ return item;
777
+ }
778
+ return { ...item, id: canonicalSessionId };
779
+ });
780
+
781
+ const pendingInputs = (Array.isArray(snapshot.pendingInputs) ? snapshot.pendingInputs : []).map((item) => {
782
+ const originalSessionId = safeTrimmedText(item?.sessionId, 160);
783
+ const canonicalSessionId = remapSessionId(originalSessionId);
784
+ if (!originalSessionId || !canonicalSessionId || canonicalSessionId === originalSessionId) {
785
+ return item;
786
+ }
787
+ return {
788
+ ...item,
789
+ id: rewriteDirectScopedId(item?.id, originalSessionId, canonicalSessionId),
790
+ sessionId: canonicalSessionId
791
+ };
792
+ });
793
+
794
+ const events = (Array.isArray(snapshot.events) ? snapshot.events : []).map((item) => {
795
+ const originalSessionId = safeTrimmedText(item?.sessionId, 160);
796
+ const canonicalSessionId = remapSessionId(originalSessionId);
797
+ if (!originalSessionId || !canonicalSessionId || canonicalSessionId === originalSessionId) {
798
+ return item;
799
+ }
800
+ return {
801
+ ...item,
802
+ id: rewriteDirectScopedId(item?.id, originalSessionId, canonicalSessionId),
803
+ sessionId: canonicalSessionId
804
+ };
805
+ });
806
+
807
+ const chatTurns = (Array.isArray(snapshot.chatTurns) ? snapshot.chatTurns : []).map((item) => {
808
+ const originalSessionId = safeTrimmedText(item?.sessionId, 160);
809
+ const canonicalSessionId = remapSessionId(originalSessionId);
810
+ if (!originalSessionId || !canonicalSessionId || canonicalSessionId === originalSessionId) {
811
+ return item;
812
+ }
813
+ return {
814
+ ...item,
815
+ id: rewriteDirectScopedId(item?.id, originalSessionId, canonicalSessionId),
816
+ sessionId: canonicalSessionId
817
+ };
818
+ });
819
+
820
+ return {
821
+ ...snapshot,
822
+ sessions,
823
+ pendingInputs,
824
+ events,
825
+ chatTurns,
826
+ directSourceSessionIds: [...sourceDirectSessionIds]
827
+ };
828
+ }
829
+
630
830
  function buildContinueCommandFromThread(thread, prompt) {
631
831
  const safePrompt = safeTrimmedText(prompt, 1500);
632
832
  if (!safePrompt || !thread) return null;
633
833
 
634
834
  if (thread.agentType === "CODEX") {
635
- const threadId = safeTrimmedText(thread.codexThreadId, 120).toLowerCase();
835
+ const threadId =
836
+ safeTrimmedText(thread.codexThreadId, 120).toLowerCase() ||
837
+ (safeTrimmedText(thread.id, 160).startsWith("codex:")
838
+ ? safeTrimmedText(thread.id.slice("codex:".length), 120).toLowerCase()
839
+ : "");
636
840
  if (!threadId) return null;
637
841
  return ["codex", "exec", "resume", threadId, safePrompt];
638
842
  }
639
843
 
640
- const claudeSessionId = safeTrimmedText(thread.claudeSessionId, 120).toLowerCase();
844
+ const claudeSessionId =
845
+ safeTrimmedText(thread.claudeSessionId, 120).toLowerCase() ||
846
+ (safeTrimmedText(thread.id, 160).startsWith("claude:")
847
+ ? normalizeClaudeSessionId(thread.id.slice("claude:".length))
848
+ : "");
641
849
  if (!claudeSessionId) return null;
642
850
  return ["claude", "--resume", claudeSessionId, "-p", safePrompt, "--output-format", "stream-json"];
643
851
  }
@@ -655,14 +863,12 @@ function resolveContinueCommandForSession(sessionId, prompt) {
655
863
  }
656
864
 
657
865
  function validateSessionReuse(input) {
658
- const sessionId = safeTrimmedText(input?.sessionId, 160);
659
- if (!sessionId) {
866
+ const requestedSessionId = safeTrimmedText(input?.sessionId, 160);
867
+ if (!requestedSessionId) {
660
868
  return { ok: true, sessionId: "", continueCommand: null };
661
869
  }
662
870
 
663
- if (sessionId.startsWith("codex:") || sessionId.startsWith("claude:")) {
664
- return { ok: false, error: "direct local sessions cannot be reused from launcher" };
665
- }
871
+ const sessionId = resolveCanonicalSessionIdForDirectSession(requestedSessionId) || requestedSessionId;
666
872
 
667
873
  const session = findSession(sessionId);
668
874
  const thread = findSessionThread(sessionId);
@@ -729,9 +935,11 @@ function writeToRunStdin(run, message) {
729
935
 
730
936
  function buildEphemeralSessionThread(session) {
731
937
  if (!session?.id) return null;
938
+ const workspacePath = safeTrimmedText(session?.workspacePath, 500) || "";
939
+ const inferredIdentity = inferThreadIdentityFromSessionId(session.id);
732
940
  const lookupKey = buildSessionThreadLookupKey({
733
941
  agentType: session.agentType,
734
- workspacePath: "",
942
+ workspacePath,
735
943
  repo: session.repo,
736
944
  title: session.title
737
945
  });
@@ -740,7 +948,7 @@ function buildEphemeralSessionThread(session) {
740
948
  key: lookupKey,
741
949
  lookupKey,
742
950
  agentType: session.agentType,
743
- workspacePath: "",
951
+ workspacePath,
744
952
  repo: session.repo,
745
953
  branch: session.branch,
746
954
  title: session.title,
@@ -749,7 +957,9 @@ function buildEphemeralSessionThread(session) {
749
957
  updatedAt: safeNumber(session.lastUpdated, Date.now()),
750
958
  lastRunId: null,
751
959
  runCount: 0,
752
- lastMessageAt: safeNumber(session.lastUpdated, 0)
960
+ lastMessageAt: safeNumber(session.lastUpdated, 0),
961
+ codexThreadId: inferredIdentity.codexThreadId,
962
+ claudeSessionId: inferredIdentity.claudeSessionId
753
963
  };
754
964
  }
755
965
 
@@ -1937,21 +2147,28 @@ function stopManagedService(service, signalInput) {
1937
2147
  function mergeDirectSnapshot(snapshot) {
1938
2148
  if (!snapshot || typeof snapshot !== "object") return;
1939
2149
 
2150
+ snapshot = canonicalizeDirectSnapshot(snapshot);
2151
+
1940
2152
  const incomingDirectSessionIds = new Set(
1941
2153
  (Array.isArray(snapshot.sessions) ? snapshot.sessions : [])
1942
2154
  .map((item) => item?.id)
1943
- .filter((id) => typeof id === "string")
2155
+ .filter((id) => typeof id === "string" && isDirectSessionId(id))
2156
+ );
2157
+ const incomingDirectSourceSessionIds = new Set(
2158
+ (Array.isArray(snapshot.directSourceSessionIds) ? snapshot.directSourceSessionIds : [])
2159
+ .map((item) => safeTrimmedText(item, 160))
2160
+ .filter(Boolean)
1944
2161
  );
1945
2162
 
1946
- if (incomingDirectSessionIds.size > 0) {
2163
+ if (incomingDirectSessionIds.size > 0 || incomingDirectSourceSessionIds.size > 0) {
1947
2164
  state.sessions = state.sessions.filter((item) => {
1948
2165
  const id = String(item?.id || "");
1949
- if (!id.startsWith("codex:") && !id.startsWith("claude:")) return true;
2166
+ if (!isDirectSessionId(id)) return true;
1950
2167
  return incomingDirectSessionIds.has(id);
1951
2168
  });
1952
2169
  state.sessionThreads = (Array.isArray(state.sessionThreads) ? state.sessionThreads : []).filter((item) => {
1953
2170
  const id = String(item?.id || "");
1954
- if (!id.startsWith("codex:") && !id.startsWith("claude:")) return true;
2171
+ if (!isDirectSessionId(id)) return true;
1955
2172
  return incomingDirectSessionIds.has(id);
1956
2173
  });
1957
2174
  }
@@ -1963,11 +2180,13 @@ function mergeDirectSnapshot(snapshot) {
1963
2180
  if (previous) {
1964
2181
  Object.assign(previous, incoming);
1965
2182
  syncSessionThreadFromSession(previous, {
2183
+ workspacePath: safeTrimmedText(previous?.workspacePath, 500),
1966
2184
  updatedAt: safeNumber(previous.lastUpdated, Date.now())
1967
2185
  });
1968
2186
  } else {
1969
2187
  state.sessions.push(incoming);
1970
2188
  syncSessionThreadFromSession(incoming, {
2189
+ workspacePath: safeTrimmedText(incoming?.workspacePath, 500),
1971
2190
  updatedAt: safeNumber(incoming.lastUpdated, Date.now())
1972
2191
  });
1973
2192
  }
@@ -2802,11 +3021,14 @@ app.post("/api/import/snapshot", (req, res) => {
2802
3021
  return res.json({ ok: true });
2803
3022
  });
2804
3023
 
3024
+ function refreshDirectSnapshot() {
3025
+ withPersist(() => {
3026
+ const snapshot = collectDirectSnapshot();
3027
+ mergeDirectSnapshot(snapshot);
3028
+ });
3029
+ }
3030
+
2805
3031
  app.listen(PORT, () => {
2806
- setInterval(() => {
2807
- withPersist(() => {
2808
- const snapshot = collectDirectSnapshot();
2809
- mergeDirectSnapshot(snapshot);
2810
- });
2811
- }, DIRECT_SNAPSHOT_POLL_INTERVAL_MS);
3032
+ refreshDirectSnapshot();
3033
+ setInterval(refreshDirectSnapshot, DIRECT_SNAPSHOT_POLL_INTERVAL_MS);
2812
3034
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-companion",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Phone-to-computer companion for Codex and Claude Code.",
@@ -47,8 +47,6 @@
47
47
  "build": "tsc -b && vite build",
48
48
  "preview": "vite preview",
49
49
  "wake-proxy": "node wake-proxy/server.mjs",
50
- "video:studio": "remotion studio video/index.ts",
51
- "video:render": "remotion render video/index.ts AgentCompanionLaunch video-renders/agent-companion-launch.mp4",
52
50
  "prepublishOnly": "npm run build"
53
51
  },
54
52
  "bin": {
@@ -67,7 +65,6 @@
67
65
  "ws": "^8.18.3"
68
66
  },
69
67
  "devDependencies": {
70
- "@remotion/cli": "^4.0.431",
71
68
  "@types/node": "^25.2.3",
72
69
  "@types/react": "^19.2.14",
73
70
  "@types/react-dom": "^19.2.3",
@@ -75,10 +72,8 @@
75
72
  "@vitejs/plugin-react": "^5.1.0",
76
73
  "autoprefixer": "^10.4.24",
77
74
  "postcss": "^8.5.6",
78
- "remotion": "^4.0.431",
79
75
  "tailwindcss": "^3.4.17",
80
76
  "tailwindcss-animate": "^1.0.7",
81
- "tslib": "^2.8.1",
82
77
  "typescript": "^5.9.3",
83
78
  "vite": "^7.3.1",
84
79
  "vite-plugin-pwa": "^1.2.0"
package/relay/server.mjs CHANGED
@@ -23,6 +23,7 @@ const RELAY_WAKE_TIMEOUT_MS = clamp(toInt(process.env.RELAY_WAKE_TIMEOUT_MS, 90_
23
23
  const RELAY_WAKE_POLL_INTERVAL_MS = clamp(toInt(process.env.RELAY_WAKE_POLL_INTERVAL_MS, 1200), 250, 10_000);
24
24
  const RELAY_WAKE_REQUEST_TIMEOUT_MS = clamp(toInt(process.env.RELAY_WAKE_REQUEST_TIMEOUT_MS, 6000), 1000, 60_000);
25
25
  const PAIRING_TTL_MS = clamp(toInt(process.env.PAIRING_TTL_MS, 10 * 60 * 1000), 30_000, 24 * 60 * 60 * 1000);
26
+ const PAIR_CODE_LENGTH = clamp(toInt(process.env.PAIR_CODE_LENGTH, 10), 8, 16);
26
27
  const RPC_TIMEOUT_MS = clamp(toInt(process.env.RELAY_RPC_TIMEOUT_MS, 15_000), 500, 60_000);
27
28
  const PREVIEW_DEFAULT_TTL_MS = clamp(toInt(process.env.RELAY_PREVIEW_TTL_MS, 2 * 60 * 60 * 1000), 60_000, 7 * 24 * 60 * 60 * 1000);
28
29
  const PREVIEW_MAX_TTL_MS = clamp(
@@ -396,6 +397,14 @@ app.get("/api/devices/:id/launcher/runs", async (req, res) => {
396
397
  });
397
398
  });
398
399
 
400
+ app.get("/api/devices/:id/launcher/runs/:runId", async (req, res) => {
401
+ const runId = encodeURIComponent(String(req.params.runId || ""));
402
+ return proxyToLaptopBridge(req, res, {
403
+ method: "GET",
404
+ path: `/api/launcher/runs/${runId}`
405
+ });
406
+ });
407
+
399
408
  app.get("/api/devices/:id/launcher/services", async (req, res) => {
400
409
  const pathWithQuery = withQuery("/api/launcher/services", req.query);
401
410
  return proxyToLaptopBridge(req, res, {
@@ -1368,7 +1377,7 @@ function createPairingForLaptop(laptop) {
1368
1377
  }
1369
1378
 
1370
1379
  if (!code) {
1371
- code = `${generatePairCode()}${Math.floor(Math.random() * 10)}`;
1380
+ code = generatePairCode();
1372
1381
  }
1373
1382
 
1374
1383
  const now = Date.now();
@@ -1789,7 +1798,7 @@ function parseSignedToken(token, expectedPrefix) {
1789
1798
  function generatePairCode() {
1790
1799
  const alphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
1791
1800
  let out = "";
1792
- for (let i = 0; i < 6; i += 1) {
1801
+ for (let i = 0; i < PAIR_CODE_LENGTH; i += 1) {
1793
1802
  out += alphabet[Math.floor(Math.random() * alphabet.length)];
1794
1803
  }
1795
1804
  return out;
@@ -43,6 +43,8 @@ let reconnectDelayMs = 1200;
43
43
 
44
44
  let bridgeChild = null;
45
45
  let bridgeStartedByCompanion = false;
46
+ let pairingWatchTimer = null;
47
+ let pairingNoticeShown = false;
46
48
 
47
49
  process.on("SIGINT", () => shutdown("SIGINT"));
48
50
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -63,6 +65,7 @@ async function main() {
63
65
 
64
66
  let registration = await getOrCreateLaptopRegistration();
65
67
  printPairingInfo(registration);
68
+ startPairingWatch(registration.laptopToken);
66
69
 
67
70
  while (!shuttingDown) {
68
71
  const cached = loadCompanionState();
@@ -84,6 +87,7 @@ async function main() {
84
87
  } else if (connectionResult?.authRejected) {
85
88
  registration = await getOrCreateLaptopRegistration();
86
89
  printPairingInfo(registration);
90
+ startPairingWatch(registration.laptopToken);
87
91
  }
88
92
 
89
93
  const waitMs = reconnectDelayMs;
@@ -275,10 +279,12 @@ async function waitForRelayAvailability() {
275
279
  }
276
280
 
277
281
  function printPairingInfo(registration) {
282
+ pairingNoticeShown = false;
278
283
  console.log("");
279
284
  console.log("Agent Companion computer is ready.");
280
285
  console.log(`Pairing code: ${registration.pairCode}`);
281
- console.log("Enter this code in the app to pair.");
286
+ console.log(formatCliWarning("Open the app and enter this code. Don't share it."));
287
+ console.log("Keep Agent Companion running after pairing.");
282
288
  if (!quietLogs) {
283
289
  if (wakeMacAddress) {
284
290
  console.log(`Auto-wake MAC: ${wakeMacAddress}`);
@@ -292,6 +298,58 @@ function printPairingInfo(registration) {
292
298
  console.log("");
293
299
  }
294
300
 
301
+ function startPairingWatch(laptopToken) {
302
+ stopPairingWatch();
303
+
304
+ const token = safeText(laptopToken, 1000);
305
+ if (!token || pairingNoticeShown) return;
306
+
307
+ const poll = async () => {
308
+ if (shuttingDown || pairingNoticeShown) return;
309
+
310
+ try {
311
+ const response = await fetchWithTimeout(
312
+ `${relayBaseUrl}/api/laptops/me`,
313
+ {
314
+ method: "GET",
315
+ headers: {
316
+ Accept: "application/json",
317
+ Authorization: `Bearer ${token}`
318
+ }
319
+ },
320
+ 5000
321
+ );
322
+
323
+ if (response.ok) {
324
+ const body = await safeParseJson(response);
325
+ if (body?.pairedAt) {
326
+ pairingNoticeShown = true;
327
+ console.log("");
328
+ console.log("Device paired. Keep Agent Companion running.");
329
+ console.log("");
330
+ stopPairingWatch();
331
+ return;
332
+ }
333
+ }
334
+ } catch {
335
+ // ignore transient relay issues while waiting for pairing
336
+ }
337
+
338
+ pairingWatchTimer = setTimeout(() => {
339
+ void poll();
340
+ }, 3000);
341
+ };
342
+
343
+ void poll();
344
+ }
345
+
346
+ function stopPairingWatch() {
347
+ if (pairingWatchTimer) {
348
+ clearTimeout(pairingWatchTimer);
349
+ pairingWatchTimer = null;
350
+ }
351
+ }
352
+
295
353
  function connectWebSocketOnce(laptopToken) {
296
354
  const wsUrl = buildLaptopWsUrl(relayBaseUrl, laptopToken);
297
355
 
@@ -330,6 +388,9 @@ function connectWebSocketOnce(laptopToken) {
330
388
  pairingExpiresAt: cached?.pairingExpiresAt || null,
331
389
  pairingUrl: cached?.pairingUrl || null
332
390
  });
391
+ if (!pairingNoticeShown) {
392
+ startPairingWatch(safeText(message.laptopToken, 1000));
393
+ }
333
394
  }
334
395
  if (message.type === "rpc_request" && typeof message.id === "string") {
335
396
  void handleRpcRequest(socket, message);
@@ -1079,6 +1140,7 @@ async function shutdown(signal) {
1079
1140
  console.log(`[companion] received ${signal}, shutting down`);
1080
1141
  }
1081
1142
  stopSnapshotLoop();
1143
+ stopPairingWatch();
1082
1144
 
1083
1145
  if (websocket && websocket.readyState === WebSocket.OPEN) {
1084
1146
  websocket.close(1000, "shutdown");
@@ -1101,6 +1163,11 @@ function sleep(ms) {
1101
1163
  return new Promise((resolve) => setTimeout(resolve, ms));
1102
1164
  }
1103
1165
 
1166
+ function formatCliWarning(text) {
1167
+ if (!process.stdout.isTTY) return text;
1168
+ return `\u001b[31m${text}\u001b[0m`;
1169
+ }
1170
+
1104
1171
  function isObject(value) {
1105
1172
  return value !== null && typeof value === "object" && !Array.isArray(value);
1106
1173
  }