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.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,527 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ // fs functions used by profile/cookies handlers are in their respective modules
6
+
7
+ /**
8
+ * Browser Toggle — integrated into pi-lean-portal extension.
9
+ *
10
+ * Provides /web on, /web off, /web learn, and /web status commands to enable / disable
11
+ * browser automation tools (browsing tools) and learn tools (web-learn) in the system prompt.
12
+ *
13
+ * Three states: on (browsing only), learn (browsing + web-learn), off (all disabled).
14
+ * Internally stores two independent booleans (browserToolsEnabled, learnToolsEnabled).
15
+ *
16
+ * When browser tools are disabled, the LLM cannot see them — no descriptions,
17
+ * parameter schemas, prompt snippets, or guidelines are included in the context,
18
+ * saving roughly 1500–2000 tokens per turn.
19
+ *
20
+ * ── Stateless fallback ─────────────────────────────────────────
21
+ *
22
+ * web-fetch is a stateless HTTP tool (no session, no interactivity). If you
23
+ * only need quick page content and never use the interactive browser tools,
24
+ * you can leave browser tools disabled and rely on web-fetch alone.
25
+ */
26
+
27
+ import { readMergedSettings } from "./core/shared/settings-reader.js";
28
+
29
+ // ---- Constants -------------------------------------------------
30
+
31
+ /** Default toggle config key */
32
+ const CONFIG_KEY = "browserToggle";
33
+
34
+ /** Names of every browser browsing tool registered by pi-lean-portal's index.ts (excludes learn tools). */
35
+ const BROWSER_TOOL_NAMES = new Set([
36
+ "web-fetch",
37
+ "browser-navigate",
38
+ "browser-snapshot",
39
+ "browser-click",
40
+ "browser-type",
41
+ "browser-scroll",
42
+ "browser-back",
43
+ "browser-press",
44
+ "browser-console",
45
+ "browser-inspect",
46
+ "web-guide",
47
+ ]);
48
+
49
+ /** Names of learn tools (web-learn) that require /web learn to be active. */
50
+ const LEARN_TOOL_NAMES = new Set(["web-learn"]);
51
+
52
+ /**
53
+ * Names of sibling-package tools that are toggled alongside browser tools
54
+ * by /web on|off|learn.
55
+ *
56
+ * - "web-search": SearXNG search from pi-lean-search (Sprint 4).
57
+ *
58
+ * Exact-name `Set.has()` membership — NO regex (avoids false positives
59
+ * on third-party web-* tools).
60
+ */
61
+ const SIBLING_TOOL_NAMES = new Set<string>(["web-search"]);
62
+
63
+ /** Persisted state shape — two independent booleans plus conversation-scoped default profile. */
64
+ interface BrowserToggleState {
65
+ browserToolsEnabled: boolean;
66
+ learnToolsEnabled: boolean;
67
+ /** Default profile for the current conversation. "none" means no override. */
68
+ defaultProfile: string;
69
+ }
70
+
71
+ /** Last known toggle state — used by status bar */
72
+ let _lastToggleState = true;
73
+
74
+ /** Last known learn state — used by status bar for glyph coloring */
75
+ let _lastLearnState = false;
76
+
77
+ /** Conversation-scoped default profile. "none" means: read from config file. */
78
+ let _conversationDefaultProfile: string | undefined;
79
+
80
+ /**
81
+ * Get the effective default profile. Returns the conversation-scoped override
82
+ * if set, otherwise falls back to "none" (the router reads browser.defaultProfile
83
+ * from settings.json as its own fallback).
84
+ */
85
+ export function getConversationDefaultProfile(): string | undefined {
86
+ return _conversationDefaultProfile;
87
+ }
88
+
89
+ // ---- Helpers ---------------------------------------------------
90
+
91
+ /**
92
+ * Return the subset of BROWSER_TOOL_NAMES that are actually registered.
93
+ * (With the toggle integrated into pi-lean-portal, this is always non-empty
94
+ * when pi-lean-portal is loaded, but the helper remains for robustness.)
95
+ */
96
+ function getRegisteredBrowserTools(pi: ExtensionAPI): string[] {
97
+ return pi
98
+ .getAllTools()
99
+ .map((t) => t.name)
100
+ .filter((n) => BROWSER_TOOL_NAMES.has(n));
101
+ }
102
+
103
+ /**
104
+ * Check whether browser tools are currently active in the system prompt.
105
+ * Returns true when no browser tools exist (nothing to toggle).
106
+ */
107
+ function isBrowserEnabled(pi: ExtensionAPI): boolean {
108
+ const registered = getRegisteredBrowserTools(pi);
109
+ if (registered.length === 0) return true; // nothing registered → vacuously "enabled"
110
+
111
+ const active = new Set(pi.getActiveTools());
112
+ return registered.some((name) => active.has(name));
113
+ }
114
+
115
+ /**
116
+ * Apply a browser-tool state without persisting.
117
+ * @param pi ExtensionAPI
118
+ * @param enable true = add browser tools to the active set, false = remove them
119
+ */
120
+ function applyBrowserState(pi: ExtensionAPI, enable: boolean): void {
121
+ // Combine browser tools + sibling tools into one toggle set.
122
+ // (/web on enables both; /web off disables both.)
123
+ const registered = new Set([
124
+ ...getRegisteredBrowserTools(pi),
125
+ ...getRegisteredSiblingTools(pi),
126
+ ]);
127
+ if (registered.size === 0) return;
128
+ _lastToggleState = enable;
129
+
130
+ if (enable) {
131
+ // Merge browser+sibling tools back into whatever is currently active
132
+ const current = pi.getActiveTools();
133
+ pi.setActiveTools([...new Set([...current, ...registered])]);
134
+ } else {
135
+ // Keep everything that is NOT a browser+sibling tool
136
+ const all = pi.getAllTools().map((t) => t.name);
137
+ pi.setActiveTools(all.filter((name) => !registered.has(name)));
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Persist the current toggle state into the session for branch-aware
143
+ * restoration across /reload, /resume, /fork, and /tree navigation.
144
+ */
145
+ function persistState(pi: ExtensionAPI, state: BrowserToggleState): void {
146
+ pi.appendEntry<BrowserToggleState>("web-toggle-state", state);
147
+ }
148
+
149
+ // ---- Branch-aware restoration ----------------------------------
150
+
151
+ function restoreFromBranch(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
152
+ const registered = getRegisteredBrowserTools(pi);
153
+ if (registered.length === 0) return false;
154
+
155
+ let savedState: BrowserToggleState | undefined;
156
+
157
+ for (const entry of ctx.sessionManager.getBranch()) {
158
+ if (entry.type === "custom" && entry.customType === "web-toggle-state") {
159
+ const data = entry.data as Record<string, unknown>;
160
+ if (data && typeof data.browserToolsEnabled === "boolean") {
161
+ savedState = data as unknown as BrowserToggleState;
162
+ }
163
+ }
164
+ }
165
+
166
+ if (savedState !== undefined) {
167
+ applyBrowserState(pi, savedState.browserToolsEnabled);
168
+ applyLearnState(pi, savedState.learnToolsEnabled);
169
+ // Restore conversation-scoped default profile
170
+ if (savedState.defaultProfile && savedState.defaultProfile !== "none") {
171
+ _conversationDefaultProfile = savedState.defaultProfile;
172
+ }
173
+ return true;
174
+ }
175
+
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Apply the config-file default on a fresh session (no branch state found).
181
+ * Reads `browserToggle.defaultEnabled` from pi's settings.json files.
182
+ * Learn always starts disabled by default.
183
+ */
184
+ function applyConfigDefault(pi: ExtensionAPI): void {
185
+ const enabled = readBrowserToggleConfig();
186
+ applyBrowserState(pi, enabled);
187
+ applyLearnState(pi, false);
188
+ // Persist so subsequent branch navigation sees this as the initial state
189
+ persistState(pi, {
190
+ browserToolsEnabled: enabled,
191
+ learnToolsEnabled: false,
192
+ defaultProfile: "none",
193
+ });
194
+ }
195
+
196
+ // ---- Unit-test exports -----------------------------------------
197
+ // Helper functions are exported for testing while the default export
198
+ // wires the toggle into pi-lean-portal's runtime.
199
+
200
+ /**
201
+ * Return the last known toggle state (true = enabled, false = disabled).
202
+ * Returns true initially at startup (browser tools start enabled).
203
+ */
204
+ export function getToggleState(): boolean {
205
+ return _lastToggleState;
206
+ }
207
+
208
+ /**
209
+ * Return the last known learn state (true = learn mode enabled).
210
+ * Used by the status bar to color the glyph differently.
211
+ */
212
+ export function getLearnState(): boolean {
213
+ return _lastLearnState;
214
+ }
215
+
216
+ /** @internal Reset module-level state to defaults (test helper). */
217
+ function _resetToggleStateForTest(): void {
218
+ _lastToggleState = true;
219
+ _lastLearnState = false;
220
+ }
221
+
222
+ /**
223
+ * Reset all module-level toggle state to defaults.
224
+ *
225
+ * Called at the start of the extension entry function to ensure safe
226
+ * re-invocation when pi reuses the cached module factory (e.g.
227
+ * during /resume to the same working directory).
228
+ * Resets both the toggle status and conversation-scoped profile.
229
+ */
230
+ export function resetToggleModuleState(): void {
231
+ _lastToggleState = true;
232
+ _lastLearnState = false;
233
+ _conversationDefaultProfile = undefined;
234
+ }
235
+
236
+ // ---- Config-file support -----------------------------------------
237
+
238
+ /**
239
+ * Read the browser toggle default from pi's settings.json files.
240
+ *
241
+ * Looks for `browserToggle.defaultEnabled` in:
242
+ * 1. `~/.pi/agent/settings.json` (global)
243
+ * 2. `.pi/settings.json` (project-local, overrides global)
244
+ *
245
+ * Returns the config value, or `true` if unset (backward-compatible default).
246
+ * Silently returns `true` on missing files, parse errors, or type mismatches.
247
+ */
248
+ function readBrowserToggleConfig(): boolean {
249
+ const merged = readMergedSettings();
250
+ const segment = merged[CONFIG_KEY];
251
+
252
+ if (
253
+ segment &&
254
+ typeof segment === "object" &&
255
+ !Array.isArray(segment) &&
256
+ typeof (segment as Record<string, unknown>).defaultEnabled === "boolean"
257
+ ) {
258
+ return (segment as Record<string, unknown>).defaultEnabled as boolean;
259
+ }
260
+
261
+ return true; // default: enabled
262
+ }
263
+
264
+ /**
265
+ * Return the subset of LEARN_TOOL_NAMES that are actually registered.
266
+ */
267
+ function getRegisteredLearnTools(pi: ExtensionAPI): string[] {
268
+ return pi
269
+ .getAllTools()
270
+ .map((t) => t.name)
271
+ .filter((n) => LEARN_TOOL_NAMES.has(n));
272
+ }
273
+
274
+ /**
275
+ * Return the subset of SIBLING_TOOL_NAMES that are actually registered.
276
+ * Used by applyBrowserState to include sibling tools in toggle operations.
277
+ */
278
+ function getRegisteredSiblingTools(pi: ExtensionAPI): string[] {
279
+ return pi
280
+ .getAllTools()
281
+ .map((t) => t.name)
282
+ .filter((n) => SIBLING_TOOL_NAMES.has(n));
283
+ }
284
+
285
+ /**
286
+ * Check whether learn tools are currently active.
287
+ * Returns true when no learn tools exist (vacuously enabled).
288
+ */
289
+ function isLearnEnabled(pi: ExtensionAPI): boolean {
290
+ const registered = getRegisteredLearnTools(pi);
291
+ if (registered.length === 0) return true;
292
+ const active = new Set(pi.getActiveTools());
293
+ return registered.some((name) => active.has(name));
294
+ }
295
+
296
+ /**
297
+ * Apply a learn-tool state without persisting.
298
+ * Uses getActiveTools() (not getAllTools()) to avoid accidentally activating
299
+ * learn tools that are not currently registered.
300
+ */
301
+ function applyLearnState(pi: ExtensionAPI, enable: boolean): void {
302
+ _lastLearnState = enable;
303
+ const registered = new Set(getRegisteredLearnTools(pi));
304
+ if (registered.size === 0) return;
305
+ if (enable) {
306
+ const current = pi.getActiveTools();
307
+ pi.setActiveTools([...new Set([...current, ...registered])]);
308
+ } else {
309
+ // Remove learn tools from the *currently active* set
310
+ const current = pi.getActiveTools();
311
+ pi.setActiveTools(current.filter((name) => !registered.has(name)));
312
+ }
313
+ }
314
+
315
+ // ── Test-only exports ──────────────────────────────────
316
+ // These are exported solely for unit testing via
317
+ // __tests__/helpers/toggle-test-utils.ts. Do not import
318
+ // them directly from production code.
319
+ /** @internal */
320
+ export {
321
+ getRegisteredBrowserTools,
322
+ isBrowserEnabled,
323
+ applyBrowserState,
324
+ persistState,
325
+ restoreFromBranch,
326
+ readBrowserToggleConfig,
327
+ applyConfigDefault,
328
+ getRegisteredLearnTools,
329
+ getRegisteredSiblingTools,
330
+ isLearnEnabled,
331
+ applyLearnState,
332
+ _resetToggleStateForTest,
333
+ };
334
+ export type { BrowserToggleState };
335
+
336
+ // ---- Profile & Cookies (extracted) ------------------------------
337
+
338
+ import { handleProfileSubcommand } from "./browser-profile.js";
339
+ import { handleCookiesSubcommand } from "./browser-cookies.js";
340
+ import { handleStatusSubcommand } from "./browser-status.js";
341
+
342
+ // ---- Internal helpers (used by delegated handlers) ---------------
343
+
344
+ /**
345
+ * Set the conversation-scoped default profile.
346
+ * Affects this conversation only; survives /reload, /resume, /fork.
347
+ * "none" resets to the config-file default.
348
+ */
349
+ function setConversationDefaultProfile(
350
+ pi: ExtensionAPI,
351
+ profile: string,
352
+ ): void {
353
+ if (profile === "none") {
354
+ _conversationDefaultProfile = undefined;
355
+ } else {
356
+ _conversationDefaultProfile = profile;
357
+ }
358
+
359
+ // Persist so it survives /reload, /resume, /fork
360
+ const currentState = getCurrentState(pi);
361
+ persistState(pi, {
362
+ ...currentState,
363
+ defaultProfile: profile,
364
+ });
365
+ }
366
+
367
+ /** Read the current in-memory toggle state. */
368
+ function getCurrentState(
369
+ pi: ExtensionAPI,
370
+ ): Pick<BrowserToggleState, "browserToolsEnabled" | "learnToolsEnabled"> {
371
+ return {
372
+ browserToolsEnabled: isBrowserEnabled(pi),
373
+ learnToolsEnabled: isLearnEnabled(pi),
374
+ };
375
+ }
376
+
377
+ // ---- Toggle initializer ----------------------------------------
378
+
379
+ /**
380
+ * Register the /web command and session-restoration hooks.
381
+ * Called from pi-lean-portal's index.ts entry point.
382
+ */
383
+ export default function initBrowserToggle(pi: ExtensionAPI) {
384
+ // ── Commands ──────────────────────────────────────────────
385
+
386
+ pi.registerCommand("web", {
387
+ description:
388
+ "Enable/disable browser automation tools. " +
389
+ "Usage: /web on | off | learn | status",
390
+ handler: async (args, ctx) => {
391
+ const cmd = args.trim().toLowerCase();
392
+ const hasBrowserTools = getRegisteredBrowserTools(pi).length > 0;
393
+
394
+ if (!hasBrowserTools) {
395
+ ctx.ui.notify(
396
+ "🌐 pi-lean-portal extension not detected. Nothing to toggle.",
397
+ "warning",
398
+ );
399
+ return;
400
+ }
401
+
402
+ if (cmd === "on") {
403
+ applyBrowserState(pi, true);
404
+ applyLearnState(pi, false);
405
+ persistState(pi, {
406
+ browserToolsEnabled: true,
407
+ learnToolsEnabled: false,
408
+ defaultProfile: _conversationDefaultProfile ?? "none",
409
+ });
410
+ ctx.ui.setStatus("browser", ctx.ui.theme.fg("accent", "●") + " idle");
411
+
412
+ // Clear any stale search off-glyph — sibling tools are now enabled.
413
+ const hasSiblingTools = getRegisteredSiblingTools(pi).length > 0;
414
+ if (hasSiblingTools) {
415
+ ctx.ui.setStatus(
416
+ "search",
417
+ ctx.ui.theme.fg("accent", "●") + " searxng",
418
+ );
419
+ }
420
+
421
+ ctx.ui.notify(
422
+ "🌐 Browser tools enabled. /web learn to make web-learn available.",
423
+ "info",
424
+ );
425
+ } else if (cmd === "learn") {
426
+ applyBrowserState(pi, true);
427
+ applyLearnState(pi, true);
428
+ persistState(pi, {
429
+ browserToolsEnabled: true,
430
+ learnToolsEnabled: true,
431
+ defaultProfile: _conversationDefaultProfile ?? "none",
432
+ });
433
+ ctx.ui.setStatus("browser", ctx.ui.theme.fg("success", "●") + " idle");
434
+
435
+ // Clear any stale search off-glyph — sibling tools are now enabled.
436
+ const hasSiblingTools = getRegisteredSiblingTools(pi).length > 0;
437
+ if (hasSiblingTools) {
438
+ ctx.ui.setStatus(
439
+ "search",
440
+ ctx.ui.theme.fg("accent", "●") + " searxng",
441
+ );
442
+ }
443
+
444
+ ctx.ui.notify(
445
+ "📖 web-learn tool is now available. Agent will save/update guides when asked.",
446
+ "info",
447
+ );
448
+ } else if (cmd === "off") {
449
+ applyBrowserState(pi, false);
450
+ applyLearnState(pi, false);
451
+ persistState(pi, {
452
+ browserToolsEnabled: false,
453
+ learnToolsEnabled: false,
454
+ defaultProfile: _conversationDefaultProfile ?? "none",
455
+ });
456
+ ctx.ui.setStatus("browser", "○ web off");
457
+
458
+ // Set the search tool status to off if sibling apps are present.
459
+ // Search owns the colored glyph for on states; portal only writes
460
+ // the off state when tools are explicitly disabled.
461
+ const hasSiblingTools = getRegisteredSiblingTools(pi).length > 0;
462
+ if (hasSiblingTools) {
463
+ ctx.ui.setStatus("search", "○ searxng");
464
+ }
465
+
466
+ ctx.ui.notify(
467
+ "🌐 Browser tools disabled. /web on to re-enable.",
468
+ "info",
469
+ );
470
+ } else if (cmd === "profile" || cmd.startsWith("profile ")) {
471
+ const sub = cmd.slice("profile".length).trim();
472
+ await handleProfileSubcommand(sub, ctx, pi, (profile: string) => {
473
+ setConversationDefaultProfile(pi, profile);
474
+ });
475
+ } else if (cmd === "cookies" || cmd.startsWith("cookies ")) {
476
+ const sub = cmd.slice("cookies".length).trim();
477
+ await handleCookiesSubcommand(sub, ctx);
478
+ } else if (cmd === "status") {
479
+ handleStatusSubcommand(ctx, isBrowserEnabled(pi), isLearnEnabled(pi));
480
+ } else {
481
+ // Default: show status
482
+ const browserStatus = isBrowserEnabled(pi) ? "✅ on" : "❌ off";
483
+ const learnStatus = isLearnEnabled(pi) ? "✅ on" : "❌ off";
484
+ ctx.ui.notify(
485
+ `🌐 Browser tools: ${browserStatus}\n` +
486
+ `📖 Learn mode: ${learnStatus}\n` +
487
+ ` /web profile manage browser profiles\n` +
488
+ ` /web cookies inspect or clear session cookies\n` +
489
+ ` /web off disable all browser tools\n` +
490
+ ` /web on enable browsing only\n` +
491
+ ` /web learn enable browsing + guide-saving\n` +
492
+ ` /web status detailed runtime status (sessions, plugins, profiles)\n` +
493
+ ` /web show this status`,
494
+ "info",
495
+ );
496
+ }
497
+ },
498
+ });
499
+
500
+ // ── State restoration across session boundaries ────────────
501
+ // Ensures the toggle survives /reload, /resume, /fork, and
502
+ // /tree navigation without "forgetting" whether we're on or off.
503
+ //
504
+ // For fresh sessions (no branch state), falls back to the config
505
+ // default from settings.json (`browserToggle.defaultEnabled`).
506
+
507
+ pi.on("session_start", async (_event, ctx) => {
508
+ const restored = restoreFromBranch(pi, ctx);
509
+ if (!restored) {
510
+ // No persisted state in this branch → apply config file default
511
+ applyConfigDefault(pi);
512
+ }
513
+
514
+ // After state restoration, ensure the search slot reflects the actual
515
+ // toggle state. Without this, a race between portal's session_start
516
+ // (async — restores/applies state) and search's session_start (probes
517
+ // health) can leave the search glyph showing blue when tools are off.
518
+ const hasSiblingTools = getRegisteredSiblingTools(pi).length > 0;
519
+ if (hasSiblingTools && !isBrowserEnabled(pi)) {
520
+ ctx.ui.setStatus("search", "○ searxng");
521
+ }
522
+ });
523
+
524
+ pi.on("session_tree", async (_event, ctx) => {
525
+ restoreFromBranch(pi, ctx);
526
+ });
527
+ }