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,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON-RPC 2.0 transport over stdin/stdout for the Python bridge.
|
|
3
|
+
|
|
4
|
+
Protocol:
|
|
5
|
+
- Each message is a single JSON-RPC 2.0 request or response, one per line.
|
|
6
|
+
- Requests use newline-delimited framing (each JSON object on one line).
|
|
7
|
+
- Responses are written to stdout as a single JSON line, then flushed.
|
|
8
|
+
|
|
9
|
+
Request format::
|
|
10
|
+
|
|
11
|
+
{"jsonrpc":"2.0","method":"browser.navigate","params":{...},"id":1}
|
|
12
|
+
|
|
13
|
+
Success response::
|
|
14
|
+
|
|
15
|
+
{"jsonrpc":"2.0","result":{...},"id":1}
|
|
16
|
+
|
|
17
|
+
Error response::
|
|
18
|
+
|
|
19
|
+
{"jsonrpc":"2.0","error":{"code":-32000,"message":"...","data":{...}},"id":1}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
import traceback
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── Standard JSON-RPC error codes ────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
PARSE_ERROR = -32700
|
|
31
|
+
INVALID_REQUEST = -32600
|
|
32
|
+
METHOD_NOT_FOUND = -32601
|
|
33
|
+
INVALID_PARAMS = -32602
|
|
34
|
+
INTERNAL_ERROR = -32603
|
|
35
|
+
|
|
36
|
+
# Custom error codes (application-level)
|
|
37
|
+
APPLICATION_ERROR = -32000
|
|
38
|
+
TIMEOUT_ERROR = -32001
|
|
39
|
+
SESSION_ERROR = -32002
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─── Custom exceptions ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InvalidRequestError(ValueError):
|
|
46
|
+
"""Raised when a request has valid JSON but is not a valid JSON-RPC
|
|
47
|
+
Request object (e.g. missing ``jsonrpc`` or ``method`` fields).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── Reading requests ─────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def read_request() -> Optional[dict[str, Any]]:
|
|
54
|
+
"""Read one JSON-RPC request from stdin (line-delimited).
|
|
55
|
+
|
|
56
|
+
Returns the parsed request dict, or None on EOF.
|
|
57
|
+
Raises ValueError on JSON parse failure.
|
|
58
|
+
Raises InvalidRequestError on structural validation failure.
|
|
59
|
+
"""
|
|
60
|
+
# Skip empty lines (heartbeats), stop at EOF
|
|
61
|
+
while True:
|
|
62
|
+
line = sys.stdin.readline()
|
|
63
|
+
if not line:
|
|
64
|
+
return None # EOF
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if line:
|
|
67
|
+
break # non-empty line found
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
request: dict[str, Any] = json.loads(line)
|
|
71
|
+
except json.JSONDecodeError as exc:
|
|
72
|
+
raise ValueError(f"Failed to parse JSON-RPC request: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
# Validate basic JSON-RPC structure
|
|
75
|
+
if not isinstance(request, dict):
|
|
76
|
+
raise InvalidRequestError("JSON-RPC request must be a JSON object")
|
|
77
|
+
|
|
78
|
+
if request.get("jsonrpc") != "2.0":
|
|
79
|
+
raise InvalidRequestError('Missing or invalid "jsonrpc": "2.0" field')
|
|
80
|
+
|
|
81
|
+
if "method" not in request or not isinstance(request.get("method"), str):
|
|
82
|
+
raise InvalidRequestError('Missing or invalid "method" field')
|
|
83
|
+
|
|
84
|
+
return request
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ─── Writing responses ────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def write_response(response: dict[str, Any]) -> None:
|
|
90
|
+
"""Write a JSON-RPC response to stdout as a single JSON line, then flush.
|
|
91
|
+
|
|
92
|
+
The response should already be a complete JSON-RPC response dict
|
|
93
|
+
(with ``jsonrpc``, ``id``, and either ``result`` or ``error``).
|
|
94
|
+
"""
|
|
95
|
+
line = json.dumps(response, ensure_ascii=False, default=str)
|
|
96
|
+
sys.stdout.write(line + "\n")
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def make_success_response(request_id: Any, result: Any) -> dict[str, Any]:
|
|
101
|
+
"""Build a JSON-RPC success response."""
|
|
102
|
+
return {
|
|
103
|
+
"jsonrpc": "2.0",
|
|
104
|
+
"id": request_id,
|
|
105
|
+
"result": result,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def make_error_response(
|
|
110
|
+
request_id: Any,
|
|
111
|
+
code: int,
|
|
112
|
+
message: str,
|
|
113
|
+
data: Optional[dict[str, Any]] = None,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""Build a JSON-RPC error response."""
|
|
116
|
+
error: dict[str, Any] = {
|
|
117
|
+
"code": code,
|
|
118
|
+
"message": message,
|
|
119
|
+
}
|
|
120
|
+
if data is not None:
|
|
121
|
+
error["data"] = data
|
|
122
|
+
return {
|
|
123
|
+
"jsonrpc": "2.0",
|
|
124
|
+
"id": request_id,
|
|
125
|
+
"error": error,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_parse_error(request_id: Any = None) -> dict[str, Any]:
|
|
130
|
+
"""Build a JSON-RPC parse error response."""
|
|
131
|
+
return make_error_response(request_id, PARSE_ERROR, "Parse error")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def make_invalid_request(request_id: Any = None) -> dict[str, Any]:
|
|
135
|
+
"""Build a JSON-RPC invalid request response."""
|
|
136
|
+
return make_error_response(request_id, INVALID_REQUEST, "Invalid Request")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def make_internal_error(
|
|
140
|
+
request_id: Any,
|
|
141
|
+
exc: Exception,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Build an internal error response including Python traceback."""
|
|
144
|
+
tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
145
|
+
return make_error_response(
|
|
146
|
+
request_id,
|
|
147
|
+
INTERNAL_ERROR,
|
|
148
|
+
f"Internal error: {exc}",
|
|
149
|
+
data={"traceback": "".join(tb)},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def make_application_error(
|
|
154
|
+
request_id: Any,
|
|
155
|
+
message: str,
|
|
156
|
+
traceback_str: Optional[str] = None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Build an application-level error response."""
|
|
159
|
+
data: dict[str, Any] = {}
|
|
160
|
+
if traceback_str:
|
|
161
|
+
data["traceback"] = traceback_str
|
|
162
|
+
return make_error_response(
|
|
163
|
+
request_id,
|
|
164
|
+
APPLICATION_ERROR,
|
|
165
|
+
message,
|
|
166
|
+
data=data or None,
|
|
167
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pi-browser-bridge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared Python bridge library for pi-lean-portal browser automation plugins"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = ["playwright>=1.50"]
|
|
11
|
+
|
|
12
|
+
[tool.pytest.ini_options]
|
|
13
|
+
minversion = "9.0"
|
|
14
|
+
testpaths = ["tests"]
|
|
15
|
+
pythonpath = ["pi_browser_bridge"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Cookies management — extracted from browser-toggle.ts.
|
|
3
|
+
*
|
|
4
|
+
* Provides the `/web cookies` subcommand handler for inspecting and
|
|
5
|
+
* clearing session cookies via the router.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import * as router from "./core/router.js";
|
|
10
|
+
import { taskId } from "./core/shared/task-id.js";
|
|
11
|
+
|
|
12
|
+
// ─── Handler ─────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle the `/web cookies` subcommand.
|
|
16
|
+
*
|
|
17
|
+
* @param sub The sub-command text after "cookies" (e.g. "", "list", "clear --confirm")
|
|
18
|
+
* @param ctx The extension context (for UI notifications)
|
|
19
|
+
*/
|
|
20
|
+
export async function handleCookiesSubcommand(
|
|
21
|
+
sub: string,
|
|
22
|
+
ctx: ExtensionContext,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
if (sub === "" || sub === "list") {
|
|
25
|
+
const result = await router.getCookies(
|
|
26
|
+
taskId(ctx as { sessionManager?: { getSessionId?(): string } }),
|
|
27
|
+
);
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
ctx.ui.notify(
|
|
30
|
+
`Failed to get cookies: ${result.error ?? "unknown error"}`,
|
|
31
|
+
"warning",
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (result.cookies.length === 0) {
|
|
36
|
+
ctx.ui.notify("No cookies found for the current session.", "info");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Format cookies
|
|
40
|
+
const lines = [`Found ${result.cookies.length} cookie(s):`, ""];
|
|
41
|
+
for (const c of result.cookies) {
|
|
42
|
+
const flags = [
|
|
43
|
+
c.httpOnly ? "HttpOnly" : "",
|
|
44
|
+
c.secure ? "Secure" : "",
|
|
45
|
+
c.sameSite || "",
|
|
46
|
+
]
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(" ");
|
|
49
|
+
const expires =
|
|
50
|
+
c.expires && c.expires > 0
|
|
51
|
+
? new Date(c.expires * 1000).toISOString()
|
|
52
|
+
: "Session";
|
|
53
|
+
lines.push(
|
|
54
|
+
` ${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "…" : ""}`,
|
|
55
|
+
);
|
|
56
|
+
lines.push(` Domain: ${c.domain ?? "?"} Path: ${c.path ?? "/"}`);
|
|
57
|
+
lines.push(` Expires: ${expires} ${flags}`.trimEnd());
|
|
58
|
+
lines.push("");
|
|
59
|
+
}
|
|
60
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
61
|
+
} else if (sub === "clear" || sub === "clear --confirm") {
|
|
62
|
+
if (sub === "clear") {
|
|
63
|
+
ctx.ui.notify(
|
|
64
|
+
"⚠ This will clear ALL cookies for the current session.\n" +
|
|
65
|
+
" Run /web cookies clear --confirm to proceed.",
|
|
66
|
+
"warning",
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const result = await router.clearCookies(
|
|
71
|
+
taskId(ctx as { sessionManager?: { getSessionId?(): string } }),
|
|
72
|
+
);
|
|
73
|
+
if (!result.success) {
|
|
74
|
+
ctx.ui.notify(
|
|
75
|
+
`Failed to clear cookies: ${result.error ?? "unknown error"}`,
|
|
76
|
+
"warning",
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ctx.ui.notify("Cleared all cookies for the current session.", "info");
|
|
81
|
+
} else {
|
|
82
|
+
ctx.ui.notify(
|
|
83
|
+
`Unknown cookies sub-command: "${sub}". ` +
|
|
84
|
+
`Usage: /web cookies [list|clear [--confirm]]`,
|
|
85
|
+
"warning",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Profile management — extracted from browser-toggle.ts.
|
|
3
|
+
*
|
|
4
|
+
* Provides profile listing, formatting, and the `/web profile` subcommand
|
|
5
|
+
* handler. Stateful operations (setting the conversation-scoped default
|
|
6
|
+
* profile) are delegated through a callback to avoid circular dependency
|
|
7
|
+
* with browser-toggle.ts's internal state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ExtensionContext,
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { existsSync, readdirSync, statSync, mkdirSync } from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
PROFILE_DIR,
|
|
17
|
+
profileFilePath,
|
|
18
|
+
deleteStorageState,
|
|
19
|
+
pruneStaleSessionProfiles,
|
|
20
|
+
isSessionProfile,
|
|
21
|
+
sanitizeProfileName,
|
|
22
|
+
profileDir,
|
|
23
|
+
} from "./core/shared/storage-state.js";
|
|
24
|
+
|
|
25
|
+
// ─── Profile helpers ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get a human-readable size description for a profile's storage state.
|
|
29
|
+
* Returns "no state" if the file doesn't exist or is empty.
|
|
30
|
+
*/
|
|
31
|
+
export function profileStateSize(profileName: string): string {
|
|
32
|
+
const path = profileFilePath(profileName);
|
|
33
|
+
try {
|
|
34
|
+
if (!existsSync(path)) return "no state";
|
|
35
|
+
const bytes = statSync(path).size;
|
|
36
|
+
if (bytes === 0) return "empty";
|
|
37
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
38
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
39
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
40
|
+
} catch {
|
|
41
|
+
return "?";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List all profiles found on disk.
|
|
47
|
+
* Returns an array of { name, stateSize } objects.
|
|
48
|
+
*/
|
|
49
|
+
export function listProfiles(): Array<{
|
|
50
|
+
name: string;
|
|
51
|
+
stateSize: string;
|
|
52
|
+
}> {
|
|
53
|
+
try {
|
|
54
|
+
if (!existsSync(PROFILE_DIR)) return [];
|
|
55
|
+
const entries = readdirSync(PROFILE_DIR, { withFileTypes: true });
|
|
56
|
+
const profiles = entries
|
|
57
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
58
|
+
.map((e) => ({
|
|
59
|
+
name: e.name,
|
|
60
|
+
stateSize: profileStateSize(e.name),
|
|
61
|
+
}));
|
|
62
|
+
profiles.sort((a, b) => a.name.localeCompare(b.name));
|
|
63
|
+
return profiles;
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Short human-readable label for a profile name.
|
|
71
|
+
* Session-scoped profiles show "📋" instead of the raw `_session-` prefix.
|
|
72
|
+
* Named profiles show as-is.
|
|
73
|
+
*/
|
|
74
|
+
export function profileLabel(name: string): string {
|
|
75
|
+
if (isSessionProfile(name)) return "📋 session";
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format a profile list as a human-readable string.
|
|
81
|
+
*/
|
|
82
|
+
export function formatProfileList(
|
|
83
|
+
profiles: ReturnType<typeof listProfiles>,
|
|
84
|
+
): string {
|
|
85
|
+
if (profiles.length === 0) {
|
|
86
|
+
return "No profiles found on disk.";
|
|
87
|
+
}
|
|
88
|
+
const lines = [`Profiles (${profiles.length}):`];
|
|
89
|
+
for (const p of profiles) {
|
|
90
|
+
const sessionBadge = isSessionProfile(p.name) ? " 📋" : "";
|
|
91
|
+
lines.push(` ${profileLabel(p.name)} (${p.stateSize})${sessionBadge}`);
|
|
92
|
+
}
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Profile subcommand handler ──────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Handle the `/web profile` subcommand.
|
|
100
|
+
*
|
|
101
|
+
* @param sub The sub-command text after "profile" (e.g. "", "list", "create shopping")
|
|
102
|
+
* @param ctx The extension context (for UI notifications)
|
|
103
|
+
* @param pi The ExtensionAPI (for tool state queries)
|
|
104
|
+
* @param setConversationDefaultProfile Callback setter — accepts "none" or a profile name
|
|
105
|
+
*/
|
|
106
|
+
export async function handleProfileSubcommand(
|
|
107
|
+
sub: string,
|
|
108
|
+
ctx: ExtensionContext,
|
|
109
|
+
_pi: ExtensionAPI,
|
|
110
|
+
setConversationDefaultProfile: (profile: string) => void,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
// --- list (default) ---
|
|
113
|
+
if (sub === "" || sub === "list") {
|
|
114
|
+
const profiles = listProfiles();
|
|
115
|
+
const formatted = formatProfileList(profiles);
|
|
116
|
+
ctx.ui.notify(formatted, "info");
|
|
117
|
+
|
|
118
|
+
// --- mode switches (special keywords) ---
|
|
119
|
+
} else if (sub === "none") {
|
|
120
|
+
setConversationDefaultProfile("none");
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
"Default profile set to 'none' (ephemeral, no persistence).",
|
|
123
|
+
"info",
|
|
124
|
+
);
|
|
125
|
+
} else if (sub === "session") {
|
|
126
|
+
setConversationDefaultProfile("session");
|
|
127
|
+
ctx.ui.notify(
|
|
128
|
+
"Default profile set to 'session' (persists for this conversation).",
|
|
129
|
+
"info",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// --- create <name> ---
|
|
133
|
+
} else if (sub === "create" || sub.startsWith("create ")) {
|
|
134
|
+
const name = sub === "create" ? "" : sub.slice("create ".length).trim();
|
|
135
|
+
if (!name) {
|
|
136
|
+
ctx.ui.notify(
|
|
137
|
+
"Usage: /web profile create <name>\n" +
|
|
138
|
+
" Profile names must be alphanumeric, hyphens, and underscores only.\n" +
|
|
139
|
+
" Reserved names: none, session, create, list, clear, clear-all, prune.",
|
|
140
|
+
"warning",
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
sanitizeProfileName(name);
|
|
146
|
+
mkdirSync(profileDir(name), { recursive: true, mode: 0o700 });
|
|
147
|
+
ctx.ui.notify(
|
|
148
|
+
`Created profile '${name}'. Use browser-navigate profile="${name}" to start.`,
|
|
149
|
+
"info",
|
|
150
|
+
);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
ctx.ui.notify(
|
|
153
|
+
`Invalid profile name: ${err instanceof Error ? err.message : String(err)}`,
|
|
154
|
+
"warning",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- prune [--confirm] ---
|
|
159
|
+
} else if (sub === "prune" || sub === "prune --confirm") {
|
|
160
|
+
const result = pruneStaleSessionProfiles();
|
|
161
|
+
if (result.pruned.length === 0) {
|
|
162
|
+
ctx.ui.notify("No stale session profiles to prune.", "info");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (sub === "prune") {
|
|
166
|
+
ctx.ui.notify(
|
|
167
|
+
`Found ${result.pruned.length} stale session profile(s): ${result.pruned.join(", ")}\n` +
|
|
168
|
+
` Run /web profile prune --confirm to delete.`,
|
|
169
|
+
"warning",
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
ctx.ui.notify(
|
|
173
|
+
`Pruned ${result.pruned.length} stale session profile(s).`,
|
|
174
|
+
"info",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- switch to existing named profile ---
|
|
179
|
+
} else if (
|
|
180
|
+
!sub.includes(" ") &&
|
|
181
|
+
sub !== "clear" &&
|
|
182
|
+
sub !== "clear-all" &&
|
|
183
|
+
sub !== "prune"
|
|
184
|
+
) {
|
|
185
|
+
// Single word that isn't a known keyword — try as a named profile switch
|
|
186
|
+
try {
|
|
187
|
+
const targetDir = profileDir(sub);
|
|
188
|
+
if (!existsSync(targetDir)) {
|
|
189
|
+
ctx.ui.notify(
|
|
190
|
+
`Profile '${sub}' does not exist.\n` +
|
|
191
|
+
` Create it first with /web profile create ${sub}`,
|
|
192
|
+
"warning",
|
|
193
|
+
);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setConversationDefaultProfile(sub);
|
|
197
|
+
ctx.ui.notify(
|
|
198
|
+
`Default profile set to '${sub}' (shared across tasks).`,
|
|
199
|
+
"info",
|
|
200
|
+
);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
ctx.ui.notify(
|
|
203
|
+
`Invalid profile name: ${err instanceof Error ? err.message : String(err)}`,
|
|
204
|
+
"warning",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- clear <name> ---
|
|
209
|
+
} else if (sub === "clear" || sub.startsWith("clear ")) {
|
|
210
|
+
const name = sub === "clear" ? "" : sub.slice("clear ".length).trim();
|
|
211
|
+
if (!name) {
|
|
212
|
+
ctx.ui.notify(
|
|
213
|
+
"Usage: /web profile clear <name> — provide a profile name.",
|
|
214
|
+
"warning",
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const path = profileFilePath(name);
|
|
219
|
+
if (!existsSync(path)) {
|
|
220
|
+
ctx.ui.notify(
|
|
221
|
+
`Profile '${name}' has no saved state. Nothing to clear.`,
|
|
222
|
+
"info",
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
deleteStorageState(name);
|
|
227
|
+
ctx.ui.notify(`Cleared profile '${name}' state.`, "info");
|
|
228
|
+
|
|
229
|
+
// --- clear-all [--confirm] ---
|
|
230
|
+
} else if (sub === "clear-all" || sub === "clear-all --confirm") {
|
|
231
|
+
const profiles = listProfiles();
|
|
232
|
+
if (profiles.length === 0) {
|
|
233
|
+
ctx.ui.notify("No profiles to clear.", "info");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (sub === "clear-all") {
|
|
237
|
+
const names = profiles.map((p) => p.name).join(", ");
|
|
238
|
+
ctx.ui.notify(
|
|
239
|
+
`⚠ This will clear ALL profile states: ${names}\n` +
|
|
240
|
+
` Run /web profile clear-all --confirm to proceed.`,
|
|
241
|
+
"warning",
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
let cleared = 0;
|
|
245
|
+
for (const p of profiles) {
|
|
246
|
+
deleteStorageState(p.name);
|
|
247
|
+
cleared++;
|
|
248
|
+
}
|
|
249
|
+
ctx.ui.notify(`Cleared ${cleared} profile(s).`, "info");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- unknown ---
|
|
253
|
+
} else {
|
|
254
|
+
ctx.ui.notify(
|
|
255
|
+
`Unknown profile sub-command: "${sub}". ` +
|
|
256
|
+
`Usage: /web profile [list|create <name>|<name>|none|session|clear <name>|clear-all [--confirm]|prune [--confirm]]`,
|
|
257
|
+
"warning",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Status — /web status subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Combines toggle state (browser on/off, learn on/off) with runtime state
|
|
5
|
+
* (backend health, active sessions, plugins, profiles on disk).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { sessionManager } from "./core/shared/session-manager.js";
|
|
10
|
+
import { pluginRegistry } from "./core/plugin-registry.js";
|
|
11
|
+
import { listProfiles } from "./browser-profile.js";
|
|
12
|
+
import { isSessionProfile } from "./core/shared/storage-state.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Show detailed browser runtime status.
|
|
16
|
+
*
|
|
17
|
+
* @param browserOn - whether browser tools are currently active in context
|
|
18
|
+
* @param learnOn - whether learn tools are currently active in context
|
|
19
|
+
*/
|
|
20
|
+
export function handleStatusSubcommand(
|
|
21
|
+
ctx: ExtensionContext,
|
|
22
|
+
browserOn: boolean,
|
|
23
|
+
learnOn: boolean,
|
|
24
|
+
): void {
|
|
25
|
+
const status = sessionManager.getStatus();
|
|
26
|
+
const active = sessionManager.getActiveSessions();
|
|
27
|
+
|
|
28
|
+
let msg = `🌐 Browser tools: ${browserOn ? "✅ on" : "❌ off"}`;
|
|
29
|
+
msg += ` | 📖 Learn mode: ${learnOn ? "✅ on" : "❌ off"}`;
|
|
30
|
+
msg += `\n────────────────────────────────────────`;
|
|
31
|
+
msg += `\nStatus: ${status}`;
|
|
32
|
+
|
|
33
|
+
// List available plugins
|
|
34
|
+
const allPlugins = pluginRegistry.availableAll();
|
|
35
|
+
const backendLines: string[] = [];
|
|
36
|
+
for (const p of allPlugins) {
|
|
37
|
+
if (p.enabled) {
|
|
38
|
+
backendLines.push(p.name);
|
|
39
|
+
} else {
|
|
40
|
+
backendLines.push(`${p.name} (disabled)`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
msg += `\nPlugins: ${backendLines.join(", ")}`;
|
|
44
|
+
msg += `\nUse web-fetch for stateless HTTP fetches.`;
|
|
45
|
+
|
|
46
|
+
if (active.length > 0) {
|
|
47
|
+
msg += `\nActive sessions: ${active.length}`;
|
|
48
|
+
for (const s of active) {
|
|
49
|
+
const sym = sessionManager.pluginSymbol(s.pluginName);
|
|
50
|
+
msg += `\n ${sym} [${s.pluginName}] ${s.currentUrl || "(pending)"}`;
|
|
51
|
+
if (s.currentTitle) msg += ` — ${s.currentTitle}`;
|
|
52
|
+
if (s.profileName) msg += ` [profile: ${s.profileName}]`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Profiles section
|
|
57
|
+
// Session profiles (auto-generated `_session-*`) are ephemeral and
|
|
58
|
+
// not user-named — they clutter the dashboard, so they are collapsed
|
|
59
|
+
// into a single summary line. Named profiles get full rows. Use
|
|
60
|
+
// `/web profile list` to inspect individual session profiles.
|
|
61
|
+
const profiles = listProfiles();
|
|
62
|
+
const named = profiles.filter((p) => !isSessionProfile(p.name));
|
|
63
|
+
const session = profiles.filter((p) => isSessionProfile(p.name));
|
|
64
|
+
const activeProfile = active.find((s) => s.profileName)?.profileName;
|
|
65
|
+
|
|
66
|
+
if (named.length > 0) {
|
|
67
|
+
const lines = [`\nProfiles: ${named.length} on disk (named)`];
|
|
68
|
+
for (const p of named) {
|
|
69
|
+
const current = p.name === activeProfile ? " ← active" : "";
|
|
70
|
+
lines.push(` ${p.name} (${p.stateSize})${current}`);
|
|
71
|
+
}
|
|
72
|
+
msg += lines.join("\n");
|
|
73
|
+
} else if (session.length === 0) {
|
|
74
|
+
msg += `\nProfiles: none`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (session.length > 0) {
|
|
78
|
+
const activeSession = session.some((p) => p.name === activeProfile);
|
|
79
|
+
const tag = activeSession ? " (active)" : "";
|
|
80
|
+
msg += `\nSession profiles: ${session.length}${tag} (manage with /web profile)`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.ui.notify(msg, "info");
|
|
84
|
+
}
|