pi-chrome 0.13.0 → 0.14.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.13.0",
4
+ "version": "0.14.1",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation", "debugger"],
7
7
  "host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"],
@@ -60,6 +60,13 @@ async function maybeUpgradeToTrusted(kind, params, syntheticResult, trustedFn) {
60
60
  }
61
61
  }
62
62
 
63
+ // Last few attach failures, kept for /chrome doctor + trusted.debug diagnostics.
64
+ const attachDebugLog = [];
65
+ function recordAttachEvent(entry) {
66
+ attachDebugLog.push({ ...entry, t: Date.now() });
67
+ if (attachDebugLog.length > 20) attachDebugLog.shift();
68
+ }
69
+
63
70
  async function attachDebugger(tabId) {
64
71
  if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
65
72
  if (attachedTabs.has(tabId)) {
@@ -67,28 +74,75 @@ async function attachDebugger(tabId) {
67
74
  entry.detachAt = Date.now() + TRUSTED_IDLE_DETACH_MS;
68
75
  return entry;
69
76
  }
77
+ // Before each attach, force-detach any stale CDP target this extension owns on the tab.
78
+ // Chrome sometimes keeps a half-dead session around (extension reload mid-attach, etc.) and
79
+ // surfaces it as "Cannot access a chrome-extension://" on the next attach attempt.
70
80
  try {
71
- await chrome.debugger.attach({ tabId }, CDP_VERSION);
72
- } catch (error) {
73
- // Chrome occasionally rejects attach with "Cannot access a chrome-extension:// URL of
74
- // different extension" right after a navigation, even when the target tab's URL is a
75
- // normal page. Wait a tick, verify the tab is on a non-privileged URL, and retry once.
76
- const msg = String(error?.message || error);
81
+ const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || [])));
82
+ for (const tgt of targets) {
83
+ if (tgt.tabId === tabId && tgt.attached) {
84
+ recordAttachEvent({ kind: "stale-target-found", tabId, target: { id: tgt.id, type: tgt.type, url: tgt.url, extensionId: tgt.extensionId } });
85
+ try { await chrome.debugger.detach({ tabId }); } catch {}
86
+ await sleep(80);
87
+ break;
88
+ }
89
+ }
90
+ } catch {}
91
+ const attemptAttach = async () => {
92
+ try {
93
+ await chrome.debugger.attach({ tabId }, CDP_VERSION);
94
+ return null;
95
+ } catch (error) {
96
+ return error;
97
+ }
98
+ };
99
+ let err = await attemptAttach();
100
+ if (err) {
101
+ const msg = String(err?.message || err);
77
102
  const transient = /Cannot access a chrome-extension|Cannot access contents of|No tab with id|Debugger is not attached|Another debugger|Target closed/i.test(msg);
78
- if (!transient) throw error;
103
+ const tabSnapshot = await chrome.tabs.get(tabId).catch(() => null);
104
+ recordAttachEvent({ kind: "attach-failed", tabId, message: msg, tabUrl: tabSnapshot?.url, transient });
105
+ if (!transient) throw err;
106
+ if (!tabSnapshot || (tabSnapshot.url || "").startsWith("chrome://") || (tabSnapshot.url || "").startsWith("chrome-extension://")) {
107
+ throw new Error(`Chrome can't attach the debugger to this tab (${tabSnapshot?.url ?? "unknown"}). Open a normal http(s) tab and try again.`);
108
+ }
79
109
  await sleep(180);
80
- const tab = await chrome.tabs.get(tabId).catch(() => null);
81
- if (!tab || (tab.url || "").startsWith("chrome://") || (tab.url || "").startsWith("chrome-extension://")) {
82
- throw new Error(`Chrome can't attach the debugger to this tab (${tab?.url ?? "unknown"}). Open a normal http(s) tab and try again.`);
110
+ err = await attemptAttach();
111
+ if (err) {
112
+ recordAttachEvent({ kind: "attach-retry-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
113
+ // One more try after a longer settle. Some Chrome builds need ~500ms after a navigation
114
+ // for content-script registration on the tab to drain before chrome.debugger.attach
115
+ // will accept the target.
116
+ await sleep(500);
117
+ err = await attemptAttach();
118
+ if (err) {
119
+ recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
120
+ throw err;
121
+ }
83
122
  }
84
- await chrome.debugger.attach({ tabId }, CDP_VERSION);
85
123
  }
124
+ recordAttachEvent({ kind: "attached", tabId });
86
125
  // Seed pointer in a plausible "just left the address bar" location.
87
126
  const entry = { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
88
127
  attachedTabs.set(tabId, entry);
89
128
  return entry;
90
129
  }
91
130
 
131
+ async function trustedDebug(params) {
132
+ const tab = params?.targetId ? await chrome.tabs.get(Number(params.targetId)).catch(() => null) : null;
133
+ let targets = [];
134
+ try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
135
+ return {
136
+ extensionVersion: chrome.runtime.getManifest().version,
137
+ extensionId: chrome.runtime.id,
138
+ trustedMode: TRUSTED_MODE,
139
+ attachedTabs: Array.from(attachedTabs.keys()),
140
+ requestedTab: tab ? { id: tab.id, url: tab.url, status: tab.status, title: tab.title } : null,
141
+ cdpTargets: targets,
142
+ recentAttachEvents: attachDebugLog.slice(),
143
+ };
144
+ }
145
+
92
146
  async function detachDebugger(tabId) {
93
147
  if (!attachedTabs.has(tabId)) return;
94
148
  attachedTabs.delete(tabId);
@@ -639,6 +693,8 @@ async function dispatch(action, params) {
639
693
  return setTrustedMode(params.mode);
640
694
  case "trusted.status":
641
695
  return trustedStatus();
696
+ case "trusted.debug":
697
+ return trustedDebug(params);
642
698
  case "page.console.list":
643
699
  return executeInTab(params, listConsoleMessages, [params.clear === true]);
644
700
  case "page.network.list":
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
2
3
  import { StringEnum } from "@earendil-works/pi-ai";
4
+ import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
3
5
  import { Type } from "typebox";
4
6
  import { existsSync, readFileSync, statSync } from "node:fs";
5
7
  import { mkdir, writeFile } from "node:fs/promises";
@@ -679,6 +681,81 @@ Usage rules:
679
681
  ctx.ui.notify(await statusSummary(), "info");
680
682
  };
681
683
 
684
+ // Interactive dialog: each row is a setting whose value cycles with Space/Enter. Enter on
685
+ // the last value also saves; Esc / 'q' closes. The description below changes with the
686
+ // current value so users always see what the active setting means.
687
+ const openSettingsDialog = async (ctx: ExtensionContext): Promise<void> => {
688
+ // Read current click mode (might fail if extension permission missing).
689
+ let clicksMode: string = "auto";
690
+ let permissionGranted = false;
691
+ try {
692
+ const t = (await bridge.send("trusted.status", {}, 5_000)) as { mode?: string; permissionGranted?: boolean };
693
+ clicksMode = t.mode ?? "auto";
694
+ permissionGranted = !!t.permissionGranted;
695
+ } catch {}
696
+
697
+ const clicksItem: SettingItem = {
698
+ id: "clicks",
699
+ label: "Click realism",
700
+ currentValue: clicksMode,
701
+ values: ["auto", "on", "off"],
702
+ description: permissionGranted
703
+ ? (CLICKS_DESC[clicksMode] ?? "")
704
+ : "Real-looking clicks unavailable: reload the Chrome extension in chrome://extensions and accept the new permission prompt.",
705
+ };
706
+ const quietItem: SettingItem = {
707
+ id: "quiet",
708
+ label: "Quiet mode",
709
+ currentValue: backgroundDefault ? "on" : "off",
710
+ values: ["on", "off"],
711
+ description: QUIET_DESC[backgroundDefault ? "on" : "off"] ?? "",
712
+ };
713
+ const items: SettingItem[] = [clicksItem, quietItem];
714
+
715
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
716
+ const container = new Container();
717
+ container.addChild(new Text(theme.fg("accent", theme.bold("pi-chrome settings")), 1, 1));
718
+ container.addChild(new Text(theme.fg("muted", "\u2191\u2193 navigate · space/enter cycle · esc close"), 1, 0));
719
+
720
+ let list: SettingsList;
721
+ list = new SettingsList(
722
+ items,
723
+ Math.min(items.length + 2, 8),
724
+ getSettingsListTheme(),
725
+ (id, newValue) => {
726
+ if (id === "clicks") {
727
+ if (!permissionGranted) {
728
+ ctx.ui.notify("Click mode locked: reload the Chrome extension first.", "warning");
729
+ // Revert by snapping back to the previous value.
730
+ list.updateValue("clicks", clicksItem.currentValue);
731
+ return;
732
+ }
733
+ // Mutate description so the help text matches the new value.
734
+ clicksItem.currentValue = newValue;
735
+ clicksItem.description = CLICKS_DESC[newValue] ?? "";
736
+ list.invalidate();
737
+ void bridge.send("trusted.mode", { mode: newValue }, 5_000).catch((err) => {
738
+ ctx.ui.notify(`Couldn't switch click mode: ${(err as Error).message}`, "warning");
739
+ });
740
+ } else if (id === "quiet") {
741
+ backgroundDefault = newValue === "on";
742
+ quietItem.currentValue = newValue;
743
+ quietItem.description = QUIET_DESC[newValue] ?? "";
744
+ list.invalidate();
745
+ }
746
+ },
747
+ () => done(undefined),
748
+ );
749
+ container.addChild(list);
750
+
751
+ return {
752
+ render: (w) => container.render(w),
753
+ invalidate: () => container.invalidate(),
754
+ handleInput: (data: string) => list.handleInput(data),
755
+ };
756
+ });
757
+ };
758
+
682
759
  pi.registerCommand("chrome", {
683
760
  description:
684
761
  "All pi-chrome controls in one place.\n /chrome status — one-line snapshot of connection + current modes.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome clicks [auto|off|on|status] — how realistic should pi-chrome's clicks be.\n /chrome quiet [on|off|status|toggle] — whether Chrome pops to the front when pi-chrome acts.\nRun with no arguments for an interactive picker that shows current state.",
@@ -727,32 +804,7 @@ Usage rules:
727
804
  handler: async (args, ctx) => {
728
805
  const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
729
806
  if (tokens.length === 0) {
730
- const header = await statusSummary();
731
- // Compute next-cycle targets so the picker labels describe the toggle action.
732
- let clicksNow = "?";
733
- try {
734
- const t = (await bridge.send("trusted.status", {}, 3_000)) as { mode?: string };
735
- clicksNow = t.mode ?? "?";
736
- } catch {}
737
- const clicksNext = (() => {
738
- const idx = CLICKS_CYCLE.indexOf(clicksNow as typeof CLICKS_CYCLE[number]);
739
- return idx >= 0 ? CLICKS_CYCLE[(idx + 1) % CLICKS_CYCLE.length] : "auto";
740
- })();
741
- const quietNow = backgroundDefault ? "on" : "off";
742
- const quietNext = backgroundDefault ? "off" : "on";
743
- const picked = await ctx.ui.select(`pi-chrome — ${header}`, [
744
- `status — print the line above (so you can copy it).`,
745
- `doctor — full health check; explains anything broken.`,
746
- `onboard — install the Chrome companion extension (first-time setup).`,
747
- `clicks: ${clicksNow} → switch to ${clicksNext} — ${CLICKS_DESC[clicksNext] ?? ""}`,
748
- `quiet: ${quietNow} → switch to ${quietNext} — ${QUIET_DESC[quietNext] ?? ""}`,
749
- ]);
750
- if (!picked) return;
751
- if (picked.startsWith("status")) return statusHandler(ctx);
752
- if (picked.startsWith("doctor")) return doctorHandler(ctx);
753
- if (picked.startsWith("onboard")) return onboardHandler(ctx);
754
- if (picked.startsWith("clicks")) return trustedHandler(ctx, ""); // cycles
755
- if (picked.startsWith("quiet")) return backgroundHandler(ctx, ""); // toggles
807
+ await openSettingsDialog(ctx);
756
808
  return;
757
809
  }
758
810
  const [head, ...rest] = tokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Drive your existing logged-in Chrome from Pi — no re-login, no throwaway profile, watch the agent work in real time (or toggle quiet background mode).",
5
5
  "keywords": [
6
6
  "pi-package",