pi-lean-portal 0.1.0
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/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- package/verify-ship-manifest.ts +126 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation settle detection for the browser extension.
|
|
3
|
+
*
|
|
4
|
+
* After a user interaction (click, press) that may or may not trigger
|
|
5
|
+
* a page navigation, this module provides a reliable way to wait for
|
|
6
|
+
* the page to settle before reading URL / title / snapshot.
|
|
7
|
+
*
|
|
8
|
+
* The core insight: instead of a fixed sleep (e.g. `waitForTimeout(300)`)
|
|
9
|
+
* that races against navigation commit, we listen for the actual
|
|
10
|
+
* `framenavigated` event and wait for page readiness (load + networkidle)
|
|
11
|
+
* only when navigation has actually started.
|
|
12
|
+
*
|
|
13
|
+
* This eliminates the URL / DOM mismatch that causes stale-@e-ref and
|
|
14
|
+
* mismatched URL/content bugs.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ─── Public types ───────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal Page-like interface sufficient for navigation settle detection.
|
|
21
|
+
*
|
|
22
|
+
* The Playwright `Page` type structurally satisfies this interface, so
|
|
23
|
+
* callers can pass a real `Page` directly. Mocks in unit tests implement
|
|
24
|
+
* this interface to avoid importing Playwright.
|
|
25
|
+
*/
|
|
26
|
+
export interface NavigationSettlePage {
|
|
27
|
+
url(): string;
|
|
28
|
+
mainFrame(): unknown;
|
|
29
|
+
on(event: string, handler: (...args: unknown[]) => void): void;
|
|
30
|
+
off(event: string, handler: (...args: unknown[]) => void): void;
|
|
31
|
+
waitForTimeout(ms: number): Promise<void>;
|
|
32
|
+
waitForLoadState(
|
|
33
|
+
state?: "load" | "domcontentloaded" | "networkidle",
|
|
34
|
+
options?: { timeout?: number },
|
|
35
|
+
): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options for `waitForNavigationSettle`. */
|
|
39
|
+
export interface NavigationSettleOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Maximum time (ms) to wait for page readiness (load + networkidle)
|
|
42
|
+
* when a navigation is detected. Shared budget for both waits.
|
|
43
|
+
* Default: 5000.
|
|
44
|
+
*/
|
|
45
|
+
navTimeoutMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Short settle delay (ms) when no navigation occurs. This allows
|
|
48
|
+
* client-side rerenders / animations to finish. Default: 400.
|
|
49
|
+
*/
|
|
50
|
+
settleTimeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Result of `waitForNavigationSettle`. */
|
|
54
|
+
export interface NavigationSettleResult {
|
|
55
|
+
/** Whether a `framenavigated` event was observed on the main frame. */
|
|
56
|
+
navigated: boolean;
|
|
57
|
+
/** The page URL after settling (guaranteed consistent with DOM). */
|
|
58
|
+
url: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wait for the page to be fully ready after a navigation.
|
|
65
|
+
*
|
|
66
|
+
* Calls `waitForLoadState("load")` for the document + sub-resources,
|
|
67
|
+
* then also waits for network idle so that SPA same-document route
|
|
68
|
+
* changes (pushState) settle before we snapshot. Errors/timeouts
|
|
69
|
+
* are swallowed — the caller proceeds with whatever state it has.
|
|
70
|
+
*/
|
|
71
|
+
async function waitForPageReady(
|
|
72
|
+
page: NavigationSettlePage,
|
|
73
|
+
timeout: number,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
// Cross-document or same-document navigation started.
|
|
76
|
+
// waitForLoadState("load") resolves after the document and its
|
|
77
|
+
// sub-resources load; for same-document pushState it resolves
|
|
78
|
+
// immediately since "load" already fired.
|
|
79
|
+
await page.waitForLoadState("load", { timeout }).catch(() => {
|
|
80
|
+
// Timeout or error — continue anyway. The caller gets the
|
|
81
|
+
// current URL and can decide how to proceed.
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// For SPA route changes (pushState/replaceState), "load" is already
|
|
85
|
+
// satisfied and resolves instantly — the SPA may still be fetching
|
|
86
|
+
// data. A bounded networkidle wait catches the XHR/fetch burst
|
|
87
|
+
// without hanging on long-polling sites.
|
|
88
|
+
await page.waitForLoadState("networkidle", { timeout }).catch(() => {
|
|
89
|
+
// Timeout or error — continue regardless.
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Public API ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wait for navigation to settle after a user interaction (click, press,
|
|
97
|
+
* etc.) that may or may not trigger a page navigation.
|
|
98
|
+
*
|
|
99
|
+
* **Usage:**
|
|
100
|
+
* ```ts
|
|
101
|
+
* const urlBefore = page.url();
|
|
102
|
+
* await locator.click();
|
|
103
|
+
* const { navigated, url } = await waitForNavigationSettle(page, urlBefore);
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* **How it works:**
|
|
107
|
+
* 1. Registers a `framenavigated` listener on the page *before* settling.
|
|
108
|
+
* 2. Races a brief window (~150 ms) against the `framenavigated` event
|
|
109
|
+
* so that instant navigations don't pay the full window.
|
|
110
|
+
* 3. If the main frame navigated (cross-document or same-document via
|
|
111
|
+
* `pushState`), calls `waitForPageReady` for load + bounded networkidle
|
|
112
|
+
* (capped at `navTimeoutMs` each).
|
|
113
|
+
* 4. Falls back to URL-change detection if the URL changed without a
|
|
114
|
+
* `framenavigated` event (defense-in-depth).
|
|
115
|
+
* 5. Otherwise, waits `settleTimeoutMs` for client-side rerenders.
|
|
116
|
+
* 6. A late-arrival gate catches navigations that start during the settle
|
|
117
|
+
* window (e.g. an async click handler with setTimeout).
|
|
118
|
+
*
|
|
119
|
+
* The returned URL is always read **after** settling, guaranteeing
|
|
120
|
+
* consistency between the URL and the current DOM.
|
|
121
|
+
*
|
|
122
|
+
* @param page - A Page-like object (real Playwright Page or mock).
|
|
123
|
+
* @param urlBefore - The page URL before the interaction.
|
|
124
|
+
* @param opts - Optional timeout overrides.
|
|
125
|
+
* @returns `{ navigated, url }` after the page has settled.
|
|
126
|
+
*/
|
|
127
|
+
export async function waitForNavigationSettle(
|
|
128
|
+
page: NavigationSettlePage,
|
|
129
|
+
urlBefore: string,
|
|
130
|
+
opts?: NavigationSettleOptions,
|
|
131
|
+
): Promise<NavigationSettleResult> {
|
|
132
|
+
const navTimeout = opts?.navTimeoutMs ?? 5000;
|
|
133
|
+
const settleTimeout = opts?.settleTimeoutMs ?? 400;
|
|
134
|
+
|
|
135
|
+
let navigated = false;
|
|
136
|
+
|
|
137
|
+
// Deferred promise that resolves when framenavigated fires on the main
|
|
138
|
+
// frame. We race this against the detection window so that instant
|
|
139
|
+
// navigations don't pay the full 150ms.
|
|
140
|
+
let navResolve: () => void = () => {};
|
|
141
|
+
const navStarted = new Promise<void>((resolve) => {
|
|
142
|
+
navResolve = resolve;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const onNav = (frame: unknown) => {
|
|
146
|
+
if (frame === page.mainFrame()) {
|
|
147
|
+
navigated = true;
|
|
148
|
+
navResolve();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
page.on("framenavigated", onNav);
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Race the detection window against the framenavigated event.
|
|
156
|
+
// Most link clicks and Enter presses trigger navigation within one
|
|
157
|
+
// event-loop tick; the race means we don't waste time when nav
|
|
158
|
+
// starts immediately.
|
|
159
|
+
await Promise.race([page.waitForTimeout(150), navStarted]);
|
|
160
|
+
|
|
161
|
+
let waitedForLoad = false;
|
|
162
|
+
if (navigated) {
|
|
163
|
+
await waitForPageReady(page, navTimeout);
|
|
164
|
+
waitedForLoad = true;
|
|
165
|
+
} else if (page.url() !== urlBefore) {
|
|
166
|
+
// URL changed without a framenavigated event (possible edge case).
|
|
167
|
+
await waitForPageReady(page, navTimeout);
|
|
168
|
+
waitedForLoad = true;
|
|
169
|
+
} else {
|
|
170
|
+
// No navigation detected yet — allow client-side rerenders /
|
|
171
|
+
// scrolling to settle before reading the snapshot.
|
|
172
|
+
await page.waitForTimeout(settleTimeout);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Late-arrival gate: a navigation may have started during the settle
|
|
176
|
+
// timeout (e.g. an async click handler with a 200ms setTimeout). If
|
|
177
|
+
// we didn't already call waitForPageReady but navigated has since
|
|
178
|
+
// become true (or the URL changed), wait for readiness now.
|
|
179
|
+
if (!waitedForLoad && (navigated || page.url() !== urlBefore)) {
|
|
180
|
+
await waitForPageReady(page, navTimeout);
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
page.off("framenavigated", onNav);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { navigated, url: page.url() };
|
|
187
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Path & Filename Utilities — single source of truth for temp
|
|
3
|
+
* file directories and task ID sanitization.
|
|
4
|
+
*
|
|
5
|
+
* Previously these constants and helpers were duplicated across
|
|
6
|
+
* snapshot-cache.ts, fetch-backend.ts, and router.ts. Consolidating
|
|
7
|
+
* them here prevents divergence and simplifies changes.
|
|
8
|
+
*
|
|
9
|
+
* @module paths
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { tmpdir, homedir } from "node:os";
|
|
13
|
+
import { mkdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Base temp directory for all pi-lean-portal ephemeral files. */
|
|
17
|
+
export const BROWSER_TEMP_DIR = `${tmpdir()}/pi-lean-portal`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Portal-owned data root under ~/.pi/agent/, namespaced by package name.
|
|
21
|
+
* Houses user-authored guides, browser profiles, and any other runtime
|
|
22
|
+
* user data that must survive package upgrades.
|
|
23
|
+
* Siblings: sessions/ (pi-core owned — DO NOT move under this).
|
|
24
|
+
*/
|
|
25
|
+
export const PORTAL_DATA_DIR = join(
|
|
26
|
+
homedir(),
|
|
27
|
+
".pi",
|
|
28
|
+
"agent",
|
|
29
|
+
"pi-lean-portal",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sanitize a taskId (or any string) for use in filenames.
|
|
34
|
+
* Replaces any character that is not alphanumeric or hyphen with `_`.
|
|
35
|
+
*
|
|
36
|
+
* Note: `taskId()` from `task-id.ts` always returns filename-safe values
|
|
37
|
+
* (`browser-N` or `browser-default`), so callers can skip this on its
|
|
38
|
+
* output. This function is useful for sanitizing arbitrary strings such
|
|
39
|
+
* as raw pi session IDs.
|
|
40
|
+
*/
|
|
41
|
+
export function safeTaskId(taskId: string): string {
|
|
42
|
+
return taskId.replace(/[^a-zA-Z0-9-]/g, "_");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ensure the browser temp directory exists (best-effort).
|
|
47
|
+
* Creates the directory recursively. Silently ignores errors so that
|
|
48
|
+
* callers don't need their own try-catch blocks.
|
|
49
|
+
*/
|
|
50
|
+
export function ensureBrowserTempDir(): void {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(BROWSER_TEMP_DIR, { recursive: true });
|
|
53
|
+
} catch {
|
|
54
|
+
/* best-effort */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager — tracks browser session lifecycle per task_id.
|
|
3
|
+
*
|
|
4
|
+
* Design: sessions track metadata; browsers and contexts are managed
|
|
5
|
+
* entirely by the plugin. The session manager is Playwright-agnostic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Runtime state of a single browsing session */
|
|
9
|
+
export interface BrowserSession {
|
|
10
|
+
taskId: string;
|
|
11
|
+
/** Name of the plugin this session is bound to (set once, never changes) */
|
|
12
|
+
pluginName: string;
|
|
13
|
+
/** The URL currently loaded in this session */
|
|
14
|
+
currentUrl?: string;
|
|
15
|
+
/** Page title if available */
|
|
16
|
+
currentTitle?: string;
|
|
17
|
+
/** Stable hash of the last snapshot's accessibility tree (for DOM-change detection) */
|
|
18
|
+
currentSnapshotFingerprint?: string;
|
|
19
|
+
/** Timestamp when element cache was last populated (for staleness detection) */
|
|
20
|
+
cachePopulatedAt?: number;
|
|
21
|
+
/** Timestamp of the last interaction that may have mutated the DOM */
|
|
22
|
+
lastInteractionAt?: number;
|
|
23
|
+
/** Timestamp of last activity */
|
|
24
|
+
lastActive: number;
|
|
25
|
+
/** Whether the session has crashed and needs recovery */
|
|
26
|
+
crashed: boolean;
|
|
27
|
+
/** Whether to auto-save storage state on cleanup */
|
|
28
|
+
persistState?: boolean;
|
|
29
|
+
/** Profile name used for this session (undefined = default) */
|
|
30
|
+
profileName?: string;
|
|
31
|
+
/** pi session ID for session-scoped profiles */
|
|
32
|
+
piSessionId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stored last navigation for a task (used to auto-recover sessions) */
|
|
36
|
+
interface LastNavEntry {
|
|
37
|
+
url: string;
|
|
38
|
+
title: string;
|
|
39
|
+
/** Plugin name that was used for the original navigation */
|
|
40
|
+
pluginName: string;
|
|
41
|
+
/** Profile name active during this navigation (for restoring state on recovery) */
|
|
42
|
+
profileName?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class SessionManager {
|
|
46
|
+
#sessions = new Map<string, BrowserSession>();
|
|
47
|
+
/** Last navigation URL per task (survives session removal, cleared explicitly) */
|
|
48
|
+
#lastNav = new Map<string, LastNavEntry>();
|
|
49
|
+
|
|
50
|
+
createSession(taskId: string, pluginName: string): BrowserSession {
|
|
51
|
+
const existing = this.#sessions.get(taskId);
|
|
52
|
+
if (existing) {
|
|
53
|
+
existing.pluginName = pluginName;
|
|
54
|
+
delete existing.currentUrl;
|
|
55
|
+
delete existing.currentTitle;
|
|
56
|
+
existing.lastActive = Date.now();
|
|
57
|
+
existing.crashed = false;
|
|
58
|
+
return existing;
|
|
59
|
+
}
|
|
60
|
+
const session: BrowserSession = {
|
|
61
|
+
taskId,
|
|
62
|
+
pluginName,
|
|
63
|
+
lastActive: Date.now(),
|
|
64
|
+
crashed: false,
|
|
65
|
+
};
|
|
66
|
+
this.#sessions.set(taskId, session);
|
|
67
|
+
return session;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getSession(taskId: string): BrowserSession | undefined {
|
|
71
|
+
return this.#sessions.get(taskId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
updateSession(
|
|
75
|
+
taskId: string,
|
|
76
|
+
updates: Partial<
|
|
77
|
+
Pick<
|
|
78
|
+
BrowserSession,
|
|
79
|
+
| "currentUrl"
|
|
80
|
+
| "currentTitle"
|
|
81
|
+
| "pluginName"
|
|
82
|
+
| "crashed"
|
|
83
|
+
| "currentSnapshotFingerprint"
|
|
84
|
+
| "cachePopulatedAt"
|
|
85
|
+
| "lastInteractionAt"
|
|
86
|
+
| "persistState"
|
|
87
|
+
| "profileName"
|
|
88
|
+
| "piSessionId"
|
|
89
|
+
>
|
|
90
|
+
>,
|
|
91
|
+
): void {
|
|
92
|
+
const session = this.#sessions.get(taskId);
|
|
93
|
+
if (session) {
|
|
94
|
+
if (updates.currentUrl !== undefined)
|
|
95
|
+
session.currentUrl = updates.currentUrl;
|
|
96
|
+
if (updates.currentTitle !== undefined)
|
|
97
|
+
session.currentTitle = updates.currentTitle;
|
|
98
|
+
if (updates.pluginName !== undefined)
|
|
99
|
+
session.pluginName = updates.pluginName;
|
|
100
|
+
if (updates.crashed !== undefined) session.crashed = updates.crashed;
|
|
101
|
+
if (updates.currentSnapshotFingerprint !== undefined)
|
|
102
|
+
session.currentSnapshotFingerprint = updates.currentSnapshotFingerprint;
|
|
103
|
+
if (updates.cachePopulatedAt !== undefined)
|
|
104
|
+
session.cachePopulatedAt = updates.cachePopulatedAt;
|
|
105
|
+
if (updates.lastInteractionAt !== undefined)
|
|
106
|
+
session.lastInteractionAt = updates.lastInteractionAt;
|
|
107
|
+
if (updates.persistState !== undefined)
|
|
108
|
+
session.persistState = updates.persistState;
|
|
109
|
+
if (updates.profileName !== undefined)
|
|
110
|
+
session.profileName = updates.profileName;
|
|
111
|
+
if (updates.piSessionId !== undefined)
|
|
112
|
+
session.piSessionId = updates.piSessionId;
|
|
113
|
+
session.lastActive = Date.now();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Last navigation storage (for session auto-recovery) ───
|
|
118
|
+
|
|
119
|
+
setLastNav(
|
|
120
|
+
taskId: string,
|
|
121
|
+
url: string,
|
|
122
|
+
title: string,
|
|
123
|
+
pluginName: string,
|
|
124
|
+
profileName?: string,
|
|
125
|
+
): void {
|
|
126
|
+
const entry: LastNavEntry = { url, title, pluginName };
|
|
127
|
+
if (profileName !== undefined) {
|
|
128
|
+
entry.profileName = profileName;
|
|
129
|
+
}
|
|
130
|
+
this.#lastNav.set(taskId, entry);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getLastNav(taskId: string): LastNavEntry | undefined {
|
|
134
|
+
return this.#lastNav.get(taskId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
clearLastNav(taskId: string): void {
|
|
138
|
+
this.#lastNav.delete(taskId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Session lifecycle ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
removeSession(taskId: string): void {
|
|
144
|
+
this.#sessions.delete(taskId);
|
|
145
|
+
this.#lastNav.delete(taskId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async removeAll(): Promise<void> {
|
|
149
|
+
this.#sessions.clear();
|
|
150
|
+
this.#lastNav.clear();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get a display symbol for a plugin name.
|
|
155
|
+
* Known plugins get short symbols; unknown plugins get the first 3 chars.
|
|
156
|
+
*/
|
|
157
|
+
pluginSymbol(pluginName: string): string {
|
|
158
|
+
switch (pluginName) {
|
|
159
|
+
case "chromium":
|
|
160
|
+
return "PW";
|
|
161
|
+
case "firefox":
|
|
162
|
+
return "FF";
|
|
163
|
+
default:
|
|
164
|
+
// Return up to 3 uppercase chars for custom plugins
|
|
165
|
+
return pluginName.slice(0, 3).toUpperCase();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getStatus(): string {
|
|
170
|
+
const active = this.getActiveSessions();
|
|
171
|
+
const crashed = Array.from(this.#sessions.values()).filter(
|
|
172
|
+
(s) => s.crashed,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (active.length === 0) {
|
|
176
|
+
if (crashed.length > 0) {
|
|
177
|
+
return `${crashed.length} crashed`;
|
|
178
|
+
}
|
|
179
|
+
return "idle";
|
|
180
|
+
}
|
|
181
|
+
if (active.length === 1) {
|
|
182
|
+
const s = active[0]!;
|
|
183
|
+
const domain = s.currentUrl ? extractDomain(s.currentUrl) : undefined;
|
|
184
|
+
const sym = this.pluginSymbol(s.pluginName);
|
|
185
|
+
const profileTag = s.profileName
|
|
186
|
+
? ` [${profileDisplayName(s.profileName)}]`
|
|
187
|
+
: "";
|
|
188
|
+
let status = domain
|
|
189
|
+
? `${sym}: ${domain}${profileTag}`
|
|
190
|
+
: `${sym}${profileTag}`;
|
|
191
|
+
if (crashed.length > 0) {
|
|
192
|
+
status += ` · ${crashed.length} crashed`;
|
|
193
|
+
}
|
|
194
|
+
return status;
|
|
195
|
+
}
|
|
196
|
+
// Multiple active sessions — group by profile
|
|
197
|
+
const byProfile = new Map<string, number>();
|
|
198
|
+
for (const s of active) {
|
|
199
|
+
const key = s.profileName ?? "ephemeral";
|
|
200
|
+
byProfile.set(key, (byProfile.get(key) ?? 0) + 1);
|
|
201
|
+
}
|
|
202
|
+
const profileParts = Array.from(byProfile.entries()).map(([name, count]) =>
|
|
203
|
+
count > 1
|
|
204
|
+
? `${profileDisplayName(name)}×${count}`
|
|
205
|
+
: profileDisplayName(name),
|
|
206
|
+
);
|
|
207
|
+
const sym = this.pluginSymbol(active[0]!.pluginName);
|
|
208
|
+
let status = `${active.length} active (${sym}): ${profileParts.join(", ")}`;
|
|
209
|
+
if (crashed.length > 0) {
|
|
210
|
+
status += ` · ${crashed.length} crashed`;
|
|
211
|
+
}
|
|
212
|
+
return status;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
getActiveSessions(): BrowserSession[] {
|
|
216
|
+
return Array.from(this.#sessions.values()).filter(
|
|
217
|
+
(s) => s.currentUrl && !s.crashed,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get activeCount(): number {
|
|
222
|
+
return this.getActiveSessions().length;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Find the taskId for a given pi session ID.
|
|
227
|
+
* Used by command handlers (like `/web cookies`) to resolve the correct
|
|
228
|
+
* taskId without duplicating index.ts's monotonic counter logic.
|
|
229
|
+
*/
|
|
230
|
+
getTaskIdForPiSessionId(piSessionId: string): string | undefined {
|
|
231
|
+
for (const [taskId, session] of this.#sessions.entries()) {
|
|
232
|
+
if (session.piSessionId === piSessionId) return taskId;
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Short display name for a profile in the TUI status bar.
|
|
240
|
+
* Session-scoped profiles render as a "📋 session" badge;
|
|
241
|
+
* named profiles show their name;
|
|
242
|
+
* "ephemeral" (no profile) label shows as-is.
|
|
243
|
+
*/
|
|
244
|
+
function profileDisplayName(name: string): string {
|
|
245
|
+
if (name.startsWith("_session-")) return "📋 session";
|
|
246
|
+
if (name === "ephemeral") return "ephemeral";
|
|
247
|
+
return name;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function extractDomain(url: string): string {
|
|
251
|
+
try {
|
|
252
|
+
return new URL(url).hostname;
|
|
253
|
+
} catch {
|
|
254
|
+
return url;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const sessionManager = new SessionManager();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared settings.json reader — used by multiple modules that need to
|
|
3
|
+
* read pi's settings.json files (global and project-local).
|
|
4
|
+
*
|
|
5
|
+
* Provides a unified `readSettingsFile()` function and canonical path
|
|
6
|
+
* constants to prevent drift across the codebase.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// ─── Config paths ─────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Global pi settings path. */
|
|
18
|
+
export const GLOBAL_SETTINGS_PATH = join(
|
|
19
|
+
homedir(),
|
|
20
|
+
".pi",
|
|
21
|
+
"agent",
|
|
22
|
+
"settings.json",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** Project pi settings path (relative to cwd). */
|
|
26
|
+
export const PROJECT_SETTINGS_PATH = ".pi/settings.json";
|
|
27
|
+
|
|
28
|
+
// ─── Reader ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read and parse a JSON settings file. Returns {} on any failure.
|
|
32
|
+
*/
|
|
33
|
+
export function readSettingsFile(path: string): Record<string, unknown> {
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(path)) return {};
|
|
36
|
+
const raw = readFileSync(path, "utf-8");
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
39
|
+
return parsed as Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
return {};
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read global + project settings and merge them (project overrides global).
|
|
49
|
+
*
|
|
50
|
+
* Convenience wrapper so consumers don't duplicate the merge pattern.
|
|
51
|
+
*
|
|
52
|
+
* @param globalPath - Path to the global settings file (defaults to GLOBAL_SETTINGS_PATH)
|
|
53
|
+
* @param projectPath - Path to the project settings file (defaults to PROJECT_SETTINGS_PATH)
|
|
54
|
+
* @returns Merged settings object with project values taking precedence
|
|
55
|
+
*/
|
|
56
|
+
export function readMergedSettings(
|
|
57
|
+
globalPath: string = GLOBAL_SETTINGS_PATH,
|
|
58
|
+
projectPath: string = PROJECT_SETTINGS_PATH,
|
|
59
|
+
): Record<string, unknown> {
|
|
60
|
+
const global = readSettingsFile(globalPath);
|
|
61
|
+
const project = readSettingsFile(projectPath);
|
|
62
|
+
return { ...global, ...project };
|
|
63
|
+
}
|