Skip to content

Commit a196df7

Browse files
A.R.claude
andcommitted
feat(#daemonSync): live daemon↔UI auth status sync
- shared: add DaemonAuthStatus + daemonAuth field on AccountSnapshot - profiles.js: add daemonStatus + loginBrowserData to ProfilePaths (impl + .d.ts) - client.ts: writeDaemonStatus() after every init/reinit/shutdown; fix bare throw→throw err - session.ts: read daemon-status.json into AccountSnapshot - DashboardProvider: FSWatcher on daemon-status.json with 300ms debounce + dispose cleanup; dashboard:refresh touches .reinit when daemon is anonymous with stored login - views.tsx: daemonAuth status dot in hero panel (ok/warn; hidden in stdio mode) - logout.js: clear loginBrowserData on softLogout so next login starts fresh - checks/profiles.js: daemon-status.json auth/tier report + loginBrowserData presence note Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ee8568f commit a196df7

9 files changed

Lines changed: 274 additions & 57 deletions

File tree

packages/extension/src/auth/session.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync, readFileSync, statSync } from "node:fs";
2-
import type { AccountSnapshot, ModelsConfigSource, RefreshTier } from "@perplexity-user-mcp/shared";
2+
import type { AccountSnapshot, DaemonAuthStatus, ModelsConfigSource, RefreshTier } from "@perplexity-user-mcp/shared";
33
import { MODELS_FALLBACK, MODELS_FALLBACK_CAPTURED_AT } from "@perplexity-user-mcp/shared";
44
import { getConfigDir, getProfilePaths, getActiveName } from "perplexity-user-mcp/profiles";
55
import type { AccountInfo } from "../browser/runtime.js";
@@ -91,6 +91,9 @@ export function getAccountSnapshot(): AccountSnapshot {
9191

9292
const speedBoost = getImpitStatus();
9393

94+
// Read live daemon auth state — null when file absent (stdio mode / first run).
95+
const daemonAuth = readJsonFile<DaemonAuthStatus>(paths.daemonStatus);
96+
9497
return {
9598
loggedIn,
9699
userId: null,
@@ -109,6 +112,7 @@ export function getAccountSnapshot(): AccountSnapshot {
109112
installedAt: speedBoost.installedAt,
110113
runtimeDir: speedBoost.runtimeDir,
111114
},
115+
daemonAuth,
112116
};
113117
}
114118

packages/extension/src/webview/DashboardProvider.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as crypto from "node:crypto";
2+
import * as fs from "node:fs";
23
import * as os from "node:os";
34
import * as path from "node:path";
45
import * as vscode from "vscode";
@@ -55,6 +56,7 @@ import {
5556
import {
5657
listProfiles,
5758
getActiveName,
59+
getProfilePaths,
5860
setActive,
5961
createProfile,
6062
deleteProfile,
@@ -119,6 +121,7 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
119121
private otpResolvers = new Map<string, (s: string | null) => void>();
120122
private onMcpServerDefinitionsChanged?: () => void;
121123
private daemonEventsAbort: AbortController | null = null;
124+
private daemonStatusWatcher: fs.FSWatcher | null = null;
122125
// v0.8.5: deps factory injected from extension.ts so the auto-regen hook
123126
// on `postStaleness` can reuse the live ApplyIdeConfigDeps without pulling
124127
// the daemon runtime singletons into the webview module.
@@ -231,6 +234,7 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
231234

232235
const disposeHook = (webviewView as vscode.WebviewView & { onDidDispose?: (listener: () => void) => vscode.Disposable }).onDidDispose?.(() => {
233236
this.stopDaemonEventStream();
237+
this.stopDaemonStatusWatch();
234238
});
235239
if (disposeHook) {
236240
this.context.subscriptions.push(disposeHook);
@@ -249,8 +253,16 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
249253
try {
250254
switch (message.type) {
251255
case "ready":
256+
debug("Handling ready");
257+
await this.refresh();
258+
break;
252259
case "dashboard:refresh":
253-
debug("Handling refresh/ready");
260+
debug("Handling refresh");
261+
// If the daemon is reporting anonymous but the profile has stored
262+
// credentials, touch .reinit so the daemon re-runs init() and
263+
// re-checks auth. The daemon-status.json watcher picks up the
264+
// result and calls refresh() automatically when it completes.
265+
this.triggerDaemonReinitIfStale();
254266
await this.refresh();
255267
break;
256268
case "auth:login":
@@ -1583,6 +1595,8 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
15831595
return;
15841596
}
15851597

1598+
this.ensureDaemonStatusWatch();
1599+
15861600
await this.view.webview.postMessage({
15871601
type: "dashboard:state",
15881602
payload: this.buildState()
@@ -2056,6 +2070,82 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
20562070
this.daemonEventsAbort = null;
20572071
}
20582072

2073+
// ── daemon-status.json watcher ────────────────────────────────────────────
2074+
2075+
/**
2076+
* Watch daemon-status.json for the active profile. When it changes (daemon
2077+
* finished init/reinit), push a fresh dashboard state to the webview so the
2078+
* daemonAuth indicator updates without the user manually clicking Refresh.
2079+
*/
2080+
private startDaemonStatusWatch(): void {
2081+
this.stopDaemonStatusWatch();
2082+
const profile = getActiveName() ?? "default";
2083+
const statusFile = getProfilePaths(profile).daemonStatus;
2084+
let debounce: ReturnType<typeof setTimeout> | null = null;
2085+
try {
2086+
this.daemonStatusWatcher = fs.watch(statusFile, () => {
2087+
if (debounce) clearTimeout(debounce);
2088+
debounce = setTimeout(() => {
2089+
debounce = null;
2090+
void this.refresh();
2091+
}, 300);
2092+
});
2093+
this.daemonStatusWatcher.on("error", () => {
2094+
// File may not exist yet (daemon not started). Re-arm on next refresh.
2095+
this.stopDaemonStatusWatch();
2096+
});
2097+
} catch {
2098+
// daemon-status.json doesn't exist yet — watcher will be re-armed
2099+
// the next time refresh() is called (startDaemonStatusWatch is called
2100+
// from refresh() → ensureWatcher() path below).
2101+
this.daemonStatusWatcher = null;
2102+
}
2103+
}
2104+
2105+
private stopDaemonStatusWatch(): void {
2106+
this.daemonStatusWatcher?.close();
2107+
this.daemonStatusWatcher = null;
2108+
}
2109+
2110+
/**
2111+
* Called from refresh() to ensure the watcher is tracking the current
2112+
* active profile. Re-arms if the profile changed or the previous watch
2113+
* failed because the file didn't exist yet.
2114+
*/
2115+
private ensureDaemonStatusWatch(): void {
2116+
const profile = getActiveName() ?? "default";
2117+
const statusFile = getProfilePaths(profile).daemonStatus;
2118+
// Re-arm if watcher is null (never started, file missing at last attempt,
2119+
// or profile changed). A live watcher needs no action.
2120+
if (!this.daemonStatusWatcher) {
2121+
if (fs.existsSync(statusFile)) {
2122+
this.startDaemonStatusWatch();
2123+
}
2124+
}
2125+
}
2126+
2127+
/**
2128+
* If the daemon's last-known auth state is anonymous while the profile has
2129+
* stored credentials, touch the .reinit sentinel to ask the daemon to
2130+
* re-run init() and re-check auth. The daemon-status.json watcher will
2131+
* pick up the result automatically.
2132+
*/
2133+
private triggerDaemonReinitIfStale(): void {
2134+
try {
2135+
const snapshot = this.buildState().snapshot;
2136+
if (!snapshot.loggedIn) return;
2137+
if (!snapshot.daemonAuth) return;
2138+
if (snapshot.daemonAuth.authenticated) return;
2139+
// Stored login but daemon is anonymous → touch .reinit
2140+
const profile = getActiveName() ?? "default";
2141+
const reinitPath = getProfilePaths(profile).reinit;
2142+
fs.writeFileSync(reinitPath, String(Date.now()));
2143+
debug("[daemonStatusSync] Touched .reinit — daemon was anonymous with stored login");
2144+
} catch (err) {
2145+
debug(`[daemonStatusSync] triggerDaemonReinitIfStale error: ${(err as Error).message}`);
2146+
}
2147+
}
2148+
20592149
private async readDaemonEvents(body: ReadableStream<Uint8Array>, controller: AbortController): Promise<void> {
20602150
const reader = body.getReader();
20612151
const decoder = new TextDecoder();

packages/mcp-server/src/checks/profiles.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,43 @@ export async function run(opts = {}) {
9191
results.push({ category: CATEGORY, name: `${name}/models-cache`, status: "pass", message: `${Math.round(ageDays)}d old` });
9292
}
9393
}
94+
95+
const daemonStatusPath = join(pdir, "daemon-status.json");
96+
if (!existsSync(daemonStatusPath)) {
97+
results.push({ category: CATEGORY, name: `${name}/daemon-status`, status: "skip", message: "no daemon status file (stdio-only or not yet started)" });
98+
} else {
99+
try {
100+
const ds = JSON.parse(readFileSync(daemonStatusPath, "utf8"));
101+
if (!ds.authenticated) {
102+
results.push({
103+
category: CATEGORY,
104+
name: `${name}/daemon-status`,
105+
status: "warn",
106+
message: `daemon last init: ${ds.lastInit} — authenticated: false (tier: ${ds.tier})${ds.error ? `, error: ${ds.error}` : ""}`,
107+
hint: "Open the extension dashboard and click 'Refresh state' to trigger a daemon reinit.",
108+
});
109+
} else {
110+
results.push({
111+
category: CATEGORY,
112+
name: `${name}/daemon-status`,
113+
status: "pass",
114+
message: `authenticated as ${ds.tier}, last init: ${ds.lastInit} (${ds.initDurationMs}ms)`,
115+
});
116+
}
117+
} catch {
118+
results.push({ category: CATEGORY, name: `${name}/daemon-status`, status: "warn", message: "daemon-status.json is corrupt or unreadable" });
119+
}
120+
}
121+
122+
const loginBrowserDataPath = join(pdir, "login-browser-data");
123+
if (existsSync(loginBrowserDataPath)) {
124+
results.push({
125+
category: CATEGORY,
126+
name: `${name}/login-browser-data`,
127+
status: "info",
128+
message: "login-browser-data directory present (leftover from a past login session; safe to ignore)",
129+
});
130+
}
94131
}
95132

96133
return results;

packages/mcp-server/src/client.ts

Lines changed: 101 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { isImpitAvailable, impitFetchJson } from "./refresh.js";
3333
import { writeFileSync, readFileSync, mkdirSync, existsSync } from "fs";
3434
import { join } from "path";
3535
import { getActiveName, getConfigDir, getProfilePaths } from "./profiles.js";
36+
import type { DaemonAuthStatus } from "@perplexity-user-mcp/shared";
3637
import { clearStaleSingletonLocks } from "./fs-utils.js";
3738

3839
function getActiveProfileName(): string {
@@ -894,69 +895,77 @@ export class PerplexityClient {
894895
* Set env PERPLEXITY_HEADLESS_ONLY=1 to skip the headed phase (uses disk cache).
895896
*/
896897
async init(): Promise<void> {
897-
const activePaths = getActivePaths();
898-
if (!existsSync(activePaths.browserData)) {
899-
mkdirSync(activePaths.browserData, { recursive: true });
900-
}
898+
const _initAt = Date.now();
899+
try {
900+
const activePaths = getActivePaths();
901+
if (!existsSync(activePaths.browserData)) {
902+
mkdirSync(activePaths.browserData, { recursive: true });
903+
}
901904

902-
// Fail fast with a readable message if no browser is installed at all.
903-
const browser = await resolveBrowserExecutable();
904-
console.error(`[perplexity-mcp] Using ${browser.source}: ${browser.path}`);
905+
// Fail fast with a readable message if no browser is installed at all.
906+
const browser = await resolveBrowserExecutable();
907+
console.error(`[perplexity-mcp] Using ${browser.source}: ${browser.path}`);
905908

906-
// Phase 1: Headed session — solve CF challenge + fetch account info
907-
const skipHeaded = process.env.PERPLEXITY_HEADLESS_ONLY === "1";
908-
if (!skipHeaded) {
909-
await this.headedBootstrap();
910-
} else {
911-
console.error("[perplexity-mcp] Skipping headed session (PERPLEXITY_HEADLESS_ONLY=1).");
912-
this.loadCachedAccountInfo();
913-
}
914-
915-
// Phase 2: Headless browser for search operations.
916-
// Use the SAME persistent browserData directory as Phase 1 so that
917-
// any cf_clearance cookie acquired during the headed bootstrap is
918-
// already on disk and loaded automatically. This fixes the bug where
919-
// Phase 2 used a non-persistent context and only had stale vault
920-
// cookies (issue #5).
921-
console.error("[perplexity-mcp] Launching headless persistent browser...");
922-
const launchOpts = buildLaunchOptions(true);
923-
this.context = await chromium.launchPersistentContext(
924-
activePaths.browserData,
925-
launchOpts,
926-
);
927-
this.browser = this.context.browser();
928-
929-
// Inject vault cookies only for cookies not already present on disk.
930-
// The headed bootstrap may have refreshed cf_clearance; we must not
931-
// overwrite the fresh disk cookie with the stale vault copy.
932-
const saved = await getSavedCookies();
933-
if (saved.length > 0) {
934-
const current = await this.context.cookies();
935-
const currentNames = new Set(current.map((c) => c.name));
936-
const toInject = saved.filter((c) => !currentNames.has(c.name));
937-
if (toInject.length > 0) {
938-
await this.context.addCookies(toInject);
939-
console.error(`[perplexity-mcp] Injected ${toInject.length} missing cookies from vault.`);
909+
// Phase 1: Headed session — solve CF challenge + fetch account info
910+
const skipHeaded = process.env.PERPLEXITY_HEADLESS_ONLY === "1";
911+
if (!skipHeaded) {
912+
await this.headedBootstrap();
940913
} else {
941-
console.error("[perplexity-mcp] All vault cookies already present on disk; skipping injection.");
914+
console.error("[perplexity-mcp] Skipping headed session (PERPLEXITY_HEADLESS_ONLY=1).");
915+
this.loadCachedAccountInfo();
942916
}
943-
}
944917

945-
this.page = await this.context.newPage();
918+
// Phase 2: Headless browser for search operations.
919+
// Use the SAME persistent browserData directory as Phase 1 so that
920+
// any cf_clearance cookie acquired during the headed bootstrap is
921+
// already on disk and loaded automatically. This fixes the bug where
922+
// Phase 2 used a non-persistent context and only had stale vault
923+
// cookies (issue #5).
924+
console.error("[perplexity-mcp] Launching headless persistent browser...");
925+
const launchOpts = buildLaunchOptions(true);
926+
this.context = await chromium.launchPersistentContext(
927+
activePaths.browserData,
928+
launchOpts,
929+
);
930+
this.browser = this.context.browser();
931+
932+
// Inject vault cookies only for cookies not already present on disk.
933+
// The headed bootstrap may have refreshed cf_clearance; we must not
934+
// overwrite the fresh disk cookie with the stale vault copy.
935+
const saved = await getSavedCookies();
936+
if (saved.length > 0) {
937+
const current = await this.context.cookies();
938+
const currentNames = new Set(current.map((c) => c.name));
939+
const toInject = saved.filter((c) => !currentNames.has(c.name));
940+
if (toInject.length > 0) {
941+
await this.context.addCookies(toInject);
942+
console.error(`[perplexity-mcp] Injected ${toInject.length} missing cookies from vault.`);
943+
} else {
944+
console.error("[perplexity-mcp] All vault cookies already present on disk; skipping injection.");
945+
}
946+
}
946947

947-
// Navigate to Perplexity (headless — relies on fresh cf_clearance from headed phase)
948-
try {
949-
await this.page.goto(PERPLEXITY_URL, { waitUntil: "domcontentloaded", timeout: 30000 });
950-
await this.page.waitForTimeout(2000);
951-
} catch (err) {
952-
console.error("[perplexity-mcp] Navigation warning:", (err as Error).message);
953-
}
948+
this.page = await this.context.newPage();
954949

955-
await this.checkAuth();
950+
// Navigate to Perplexity (headless — relies on fresh cf_clearance from headed phase)
951+
try {
952+
await this.page.goto(PERPLEXITY_URL, { waitUntil: "domcontentloaded", timeout: 30000 });
953+
await this.page.waitForTimeout(2000);
954+
} catch (err) {
955+
console.error("[perplexity-mcp] Navigation warning:", (err as Error).message);
956+
}
957+
958+
await this.checkAuth();
956959

957-
// If headed phase was skipped or failed, try loading account info from headless
958-
if (!this.accountInfo.modelsConfig) {
959-
await this.loadAccountInfo();
960+
// If headed phase was skipped or failed, try loading account info from headless
961+
if (!this.accountInfo.modelsConfig) {
962+
await this.loadAccountInfo();
963+
}
964+
965+
this.writeDaemonStatus(_initAt, null);
966+
} catch (err: unknown) {
967+
this.writeDaemonStatus(_initAt, err instanceof Error ? err.message : String(err));
968+
throw err;
960969
}
961970
}
962971

@@ -2601,5 +2610,42 @@ export class PerplexityClient {
26012610
await this.browser.close().catch(() => {});
26022611
this.browser = null;
26032612
}
2613+
this.authenticated = false;
2614+
this.userId = null;
2615+
this.writeDaemonStatus(Date.now(), null);
2616+
}
2617+
2618+
// ── Daemon status file ─────────────────────────────────────────────────────
2619+
2620+
private daemonTier(): DaemonAuthStatus["tier"] {
2621+
if (!this.authenticated) return "Anonymous";
2622+
if (this.accountInfo.isMax) return "Max";
2623+
if (this.accountInfo.isPro) return "Pro";
2624+
if (this.accountInfo.isEnterprise) return "Enterprise";
2625+
return "Authenticated";
2626+
}
2627+
2628+
/**
2629+
* Write daemon-status.json so the extension UI can show live auth state
2630+
* instead of relying on the stale models-cache.json snapshot.
2631+
* @param startedAt - Date.now() captured at the start of init/reinit
2632+
* @param error - error message if init threw, null on success or shutdown
2633+
*/
2634+
private writeDaemonStatus(startedAt: number, error: string | null): void {
2635+
try {
2636+
const paths = getActivePaths();
2637+
const status: DaemonAuthStatus = {
2638+
authenticated: this.authenticated,
2639+
tier: this.daemonTier(),
2640+
userId: this.userId,
2641+
pid: process.pid,
2642+
lastInit: new Date().toISOString(),
2643+
initDurationMs: Date.now() - startedAt,
2644+
error,
2645+
};
2646+
writeFileSync(paths.daemonStatus, JSON.stringify(status, null, 2) + "\n");
2647+
} catch {
2648+
// Never let a status write crash the daemon.
2649+
}
26042650
}
26052651
}

0 commit comments

Comments
 (0)