pi-ele-picker 0.1.3 → 0.2.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/README.md CHANGED
@@ -9,6 +9,8 @@ The picker lives entirely inside a Shadow DOM, so it never leaks styles into —
9
9
  ```
10
10
  browser page ──(same-origin /__pi_picker__/config)──▶ Vite plugin ──▶ reads ~/.pi/agent/pi-picker/*.json
11
11
  │ (discovers the live bridge)
12
+ ├──(GET http://127.0.0.1:<port>/status, every 3s)───────────────────────▶ pi bridge extension
13
+ │ (session status → launcher dot)
12
14
  └──(POST http://127.0.0.1:<port>/pick)────────────────────────────────▶ pi bridge extension
13
15
  │
14
16
  writes to the pi input box
@@ -72,6 +74,10 @@ piElePicker({
72
74
  // Directory whose pi bridge to discover; must match the cwd the pi session
73
75
  // was started in. Defaults to process.cwd().
74
76
  root: process.cwd(),
77
+ // Project-level default for the in-page session-status switch. Off by default:
78
+ // the status poll costs one loopback request every 3s, so users opt in from the
79
+ // picker panel unless you flip this on for them.
80
+ status: false,
75
81
  });
76
82
  ```
77
83
 
@@ -87,6 +93,7 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
87
93
 
88
94
  - **Draggable** — grab it and drop it anywhere; the position is remembered per browser (`localStorage`).
89
95
  - **Fades when idle** (opacity `0.5`) and returns to full opacity on hover.
96
+ - **Shows the live pi session status** as a coloured dot **when enabled** — green while pi is running, amber while follow-ups are queued, grey while awaiting input, red when the bridge is gone (see [Session status](#session-status)).
90
97
  - The panel always opens **next to the launcher**, flipping sides and clamping to stay on screen.
91
98
 
92
99
  1. Click **🎯 pi 选择器** and pick an element on the page.
@@ -95,11 +102,41 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
95
102
  - **写入pi输入框** — pre-fills the current pi input box with the composed prompt; you edit and press Enter.
96
103
  - **直接发送** — submits the composed prompt as a real user turn immediately (queued as a follow-up if pi is mid-turn).
97
104
 
105
+ ### Session status
106
+
107
+ Status polling is **opt-in and off by default**: each cycle opens a loopback request, so it is disabled unless you want it. There are two levels:
108
+
109
+ - **Per browser (runtime):** the switch in the picker panel (labelled **状态**) turns polling on/off; the choice is remembered in `localStorage`.
110
+ - **Per project (build time):** the `status` option above sets the default for anyone who has not chosen yet.
111
+
112
+ Effective state = this browser's saved choice, else the project default. When it is off, the picker issues **no** status requests at all.
113
+
114
+ When enabled, the launcher carries a small status dot and the panel repeats it as text (status · model · session id). The picker polls the bridge's `GET /status` every 3 seconds (pausing while the tab is hidden and re-discovering the bridge after a pi restart):
115
+
116
+ | Dot | State | Meaning |
117
+ | --- | --- | --- |
118
+ | 🟢 pulsing | `running` | pi is streaming / executing a turn |
119
+ | 🟡 | `queued` | pi is idle but follow-up messages are queued |
120
+ | ⚪ | `idle` | pi is idle, waiting for your input |
121
+ | 🔴 | `offline` | no live bridge for this project — pi is stopped / not running here |
122
+ | ◯ hollow | — | status display is switched off |
123
+
124
+ The same payload is available from the bridge directly:
125
+
126
+ ```jsonc
127
+ // GET http://127.0.0.1:<bridge port>/status
128
+ { "ok": true, "status": "running", "idle": false, "pending": false,
129
+ "mode": "tui", "sessionId": "…", "model": "Claude Sonnet 4",
130
+ "provider": "anthropic", "pid": 12345, "cwd": "…", "updatedAt": 1700000000000 }
131
+ ```
132
+
133
+ `/status` needs no token, but non-loopback browser origins get `403`, so an arbitrary website cannot probe session metadata.
134
+
98
135
  ## Notes
99
136
 
100
137
  - The Vite plugin only runs during `vite dev` (`apply: 'serve'`); it has no effect on builds.
101
138
  - Discovery is per-project: the bridge whose `cwd` matches `root` (and whose process is still alive) is selected, so several projects/sessions can run side by side without cross-talk.
102
- - If pi restarts while a page is open (new random port + token), the next send re-discovers the bridge and retries once automatically — no page refresh needed.
139
+ - If pi restarts while a page is open (new random port + token), the next send re-discovers the bridge and retries once automatically — no page refresh needed. The status poll does the same: a stale port flips to `offline` for one cycle, then rediscovers.
103
140
  - The bridge never auto-submits on its own: **写入pi输入框** only fills the editor; **直接发送** is an explicit user action in the page.
104
141
 
105
142
  ## License
package/client/picker.js CHANGED
@@ -46,6 +46,23 @@
46
46
  '.trigger:hover { background: #4338ca; }',
47
47
  '.trigger.dragging { cursor: grabbing; }',
48
48
  '.trigger.active { background: #059669; }',
49
+ '.dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: #6b7280; transition: background-color .2s ease; }',
50
+ '.dot.st-running { background: #34d399; animation: pipulse 1.4s ease-out infinite; }',
51
+ '.dot.st-queued { background: #fbbf24; }',
52
+ '.dot.st-idle { background: #9a9aa5; }',
53
+ '.dot.st-offline { background: #f87171; }',
54
+ '.dot.st-unknown { background: #6b7280; }',
55
+ '.dot.st-off { background: transparent; box-shadow: inset 0 0 0 1.5px #6b7280; }',
56
+ '@keyframes pipulse { 0% { box-shadow: 0 0 0 0 rgba(52,211,153,.6); } 70% { box-shadow: 0 0 0 6px rgba(52,211,153,0); } 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); } }',
57
+ '.session { display: flex; align-items: center; gap: 6px; padding: 8px 12px 0; color: #b9b9c2; font-size: 11px; }',
58
+ '.session .st { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
59
+ '.sw { display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto; cursor: pointer; color: #9a9aa5; }',
60
+ '.sw input { appearance: none; -webkit-appearance: none; width: 28px; height: 16px; margin: 0; border-radius: 999px; background: #34353d; position: relative; cursor: pointer; transition: background-color .15s ease; }',
61
+ '.sw input::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: #cfcfd6; transition: transform .15s ease; }',
62
+ '.sw input:checked { background: #4f46e5; }',
63
+ '.sw input:checked::after { transform: translateX(12px); background: #fff; }',
64
+ '.sw input:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; }',
65
+ '.sw .sw-label { font-size: 11px; }',
49
66
  '.panel { position: fixed; width: 380px; max-width: calc(100vw - 36px); z-index: 2147483647; pointer-events: auto; background: #1b1c22; color: #e7e7ea; border: 1px solid #34353d; border-radius: 12px; box-shadow: 0 18px 48px rgba(0,0,0,.5); font-size: 13px; display: none; }',
50
67
  '.panel.open { display: block; }',
51
68
  '.hd { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #2b2c33; }',
@@ -96,7 +113,10 @@
96
113
  var trigger = document.createElement('button');
97
114
  trigger.className = 'trigger';
98
115
  trigger.type = 'button';
99
- trigger.textContent = '🎯 pi 选择器';
116
+ var triggerDot = document.createElement('span');
117
+ triggerDot.className = 'dot st-off';
118
+ trigger.appendChild(triggerDot);
119
+ trigger.appendChild(document.createTextNode('pi picker'));
100
120
  trigger.title = '拖拽可移动 · 点击开始选择元素';
101
121
  root.appendChild(trigger);
102
122
 
@@ -104,6 +124,7 @@
104
124
  panel.className = 'panel';
105
125
  panel.innerHTML =
106
126
  '<div class="hd"><div class="title">元素修改</div><button class="close" type="button" title="关闭">✕</button></div>' +
127
+ '<div class="session"><span class="dot st-off"></span><span class="st">状态显示已关闭</span><label class="sw" title="每 3 秒同步一次 pi 会话状态;关闭后不再发起任何轮询请求"><input type="checkbox" class="status-toggle" /><span class="sw-label">状态</span></label></div>' +
107
128
  '<div class="meta"></div>' +
108
129
  '<textarea placeholder="描述你想要的修改,例如:把这个按钮改成圆角主色、增加 hover 效果…(可直接粘贴截图)"></textarea>' +
109
130
  '<div class="shots"></div>' +
@@ -113,6 +134,9 @@
113
134
  root.appendChild(panel);
114
135
 
115
136
  var metaEl = panel.querySelector('.meta');
137
+ var sessionDot = panel.querySelector('.session .dot');
138
+ var sessionText = panel.querySelector('.session .st');
139
+ var statusToggle = panel.querySelector('.status-toggle');
116
140
  var taEl = panel.querySelector('textarea');
117
141
  var shotsEl = panel.querySelector('.shots');
118
142
  var statusEl = panel.querySelector('.status');
@@ -638,5 +662,134 @@
638
662
  readFiles(e.dataTransfer && e.dataTransfer.files);
639
663
  });
640
664
 
641
- loadConfig();
665
+ // --- live session status (opt-in) ----------------------------------------
666
+ // Polling is OFF by default because each cycle opens a loopback connection.
667
+ // Users opt in per browser with the panel switch, or a project flips the
668
+ // default with the Vite option (`piElePicker({ status: true })`). While enabled
669
+ // the launcher dot + panel line show whether pi is running, waiting for input,
670
+ // holding queued follow-ups, or gone. Polling pauses on a hidden tab and drops
671
+ // a stale port so the next cycle re-discovers a restarted bridge.
672
+ var STATUS_INTERVAL = 3000;
673
+ var STATUS_KEY = 'pi-ele-picker:status:v1';
674
+ var statusTimer = null;
675
+ var statusTitle = '';
676
+ var statusEnabled = false;
677
+ var STATUS_LABEL = {
678
+ running: 'pi 执行中',
679
+ queued: 'pi 排队中',
680
+ idle: 'pi 等待输入',
681
+ offline: 'pi 已停止 / 未连接',
682
+ unknown: 'pi 状态检测中…',
683
+ off: '状态显示已关闭',
684
+ };
685
+
686
+ function renderStatus(info) {
687
+ var st = info && info.status;
688
+ if (!STATUS_LABEL[st]) st = 'unknown';
689
+ var cls = 'dot st-' + st;
690
+ triggerDot.className = cls;
691
+ sessionDot.className = cls;
692
+ var detail = [];
693
+ if (info && info.model) detail.push(info.model);
694
+ if (info && info.sessionId) detail.push('会话 ' + String(info.sessionId).slice(0, 8));
695
+ var line = STATUS_LABEL[st] + (detail.length ? ' · ' + detail.join(' · ') : '');
696
+ sessionText.textContent = line;
697
+ var title = line + ' · 拖拽可移动 · 点击开始选择元素';
698
+ if (title !== statusTitle) {
699
+ trigger.title = title;
700
+ statusTitle = title;
701
+ }
702
+ }
703
+
704
+ function readStatusOverride() {
705
+ try {
706
+ var v = localStorage.getItem(STATUS_KEY);
707
+ if (v === 'on') return true;
708
+ if (v === 'off') return false;
709
+ } catch (e) {
710
+ /* storage unavailable — fall back to the project default */
711
+ }
712
+ return null;
713
+ }
714
+
715
+ function writeStatusOverride(enabled) {
716
+ try {
717
+ localStorage.setItem(STATUS_KEY, enabled ? 'on' : 'off');
718
+ } catch (e) {
719
+ /* ignore */
720
+ }
721
+ }
722
+
723
+ function stopStatusPoll() {
724
+ if (statusTimer) {
725
+ clearTimeout(statusTimer);
726
+ statusTimer = null;
727
+ }
728
+ }
729
+
730
+ /** Turn polling on/off; `persist` records the choice as this browser's override. */
731
+ function setStatusEnabled(enabled, persist) {
732
+ statusEnabled = !!enabled;
733
+ if (persist) writeStatusOverride(statusEnabled);
734
+ if (statusToggle) statusToggle.checked = statusEnabled;
735
+ if (statusEnabled) {
736
+ renderStatus({ status: 'unknown' });
737
+ pollStatus();
738
+ } else {
739
+ stopStatusPoll();
740
+ renderStatus({ status: 'off' });
741
+ }
742
+ }
743
+
744
+ /** Never rejects: any failure becomes the offline state. */
745
+ function fetchStatus() {
746
+ return loadConfig(false).then(function (c) {
747
+ if (!c || !c.port) return { status: 'offline' };
748
+ return fetch('http://127.0.0.1:' + c.port + '/status', { cache: 'no-store' })
749
+ .then(function (r) {
750
+ if (!r.ok) throw new Error('HTTP ' + r.status);
751
+ return r.json();
752
+ })
753
+ .catch(function () {
754
+ // The cached port may be stale after a pi restart: clear it so the next
755
+ // poll re-reads the config and finds the new bridge.
756
+ cfg = { port: null, token: null };
757
+ return { status: 'offline' };
758
+ });
759
+ });
760
+ }
761
+
762
+ function scheduleStatusPoll() {
763
+ if (statusTimer) clearTimeout(statusTimer);
764
+ statusTimer = setTimeout(pollStatus, STATUS_INTERVAL);
765
+ }
766
+
767
+ function pollStatus() {
768
+ if (!statusEnabled) return;
769
+ if (document.hidden) {
770
+ scheduleStatusPoll();
771
+ return;
772
+ }
773
+ fetchStatus()
774
+ .then(renderStatus)
775
+ .catch(function () {
776
+ renderStatus({ status: 'offline' });
777
+ })
778
+ .then(scheduleStatusPoll);
779
+ }
780
+
781
+ document.addEventListener('visibilitychange', function () {
782
+ if (!document.hidden && statusEnabled) pollStatus();
783
+ });
784
+
785
+ statusToggle.addEventListener('change', function () {
786
+ setStatusEnabled(statusToggle.checked, true);
787
+ });
788
+
789
+ // Resolve the project default first (statusDefault from the Vite plugin), then
790
+ // apply this browser's override if the user has one.
791
+ loadConfig().then(function () {
792
+ var override = readStatusOverride();
793
+ setStatusEnabled(override !== null ? override : cfg && cfg.statusDefault === true, false);
794
+ });
642
795
  })();
package/dist/index.cjs CHANGED
@@ -74,6 +74,7 @@ function discoverBridge(root) {
74
74
  }
75
75
  function piElePicker(options = {}) {
76
76
  const root = options.root ?? process.cwd();
77
+ const statusDefault = options.status ?? false;
77
78
  return {
78
79
  name: "pi-ele-picker",
79
80
  apply: "serve",
@@ -93,7 +94,13 @@ function piElePicker(options = {}) {
93
94
  const info = discoverBridge(root);
94
95
  res.setHeader("content-type", "application/json");
95
96
  res.setHeader("cache-control", "no-store");
96
- res.end(JSON.stringify(info ? { port: info.port, token: info.token } : { port: null }));
97
+ res.end(
98
+ JSON.stringify({
99
+ port: info ? info.port : null,
100
+ token: info ? info.token : null,
101
+ statusDefault
102
+ })
103
+ );
97
104
  return;
98
105
  }
99
106
  if (url === "/picker.js") {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../node_modules/tsup/assets/cjs_shims.js"],"sourcesContent":["import type { Plugin } from 'vite';\nimport { existsSync, readFileSync, readdirSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { fileURLToPath } from 'node:url';\n\n/**\n * pi-ele-picker\n *\n * A Vite dev-only plugin that injects the pi element picker into the page and\n * exposes a same-origin endpoint the picker uses to discover the running pi\n * bridge.\n *\n * The bridge registry is written by the pi extension at\n * `~/.pi/agent/extensions/pi-picker.ts` (or any extension implementing the same\n * `~/.pi/agent/pi-picker/<pid>.json` registry + `POST /pick` protocol).\n */\n\n/** Directory where pi bridge extensions register themselves. */\nconst REG_DIR = join(homedir(), '.pi', 'agent', 'pi-picker');\n\n/** URL prefix under which the picker client and discovery endpoint are served. */\nconst ENDPOINT = '/__pi_picker__';\n\n/** Path to this package's own browser client, anchored at the plugin's install location. */\nconst OWN_CLIENT_FILE = fileURLToPath(new URL('../client/picker.js', import.meta.url));\n\n/**\n * Resolve the browser client to serve. Called per request, never cached.\n *\n * The plugin module is evaluated once when the dev server boots, so anything\n * captured at module scope is frozen for the life of that process. Upgrading\n * pi-ele-picker while a dev server is running therefore used to keep serving the\n * old client forever: the frozen path still pointed at\n * `node_modules/.pnpm/pi-ele-picker@<old>/...`, pnpm leaves that directory on\n * disk, and the stale client was handed out silently — refreshing never helped.\n *\n * Resolving from the project root instead means the currently installed copy\n * wins, with no server restart.\n *\n * Two traps this deliberately avoids:\n *\n * 1. Anchoring at `import.meta.url`. From inside `.../pi-ele-picker@<old>/dist/`\n * Node resolves `pi-ele-picker` straight back to that same old copy.\n * 2. `require.resolve()`. Node memoises resolutions in `Module._pathCache` and\n * never invalidates them when `node_modules` changes underneath a running\n * process, which pins the first lookup forever — the same staleness, moved.\n *\n * So the lookup is a plain filesystem walk, mirroring Node's own node_modules\n * search (upward from the project root, for workspace/monorepo layouts), and it\n * falls back to this package's own copy when nothing matches.\n */\nfunction resolveClientFile(projectRoot: string): string | null {\n for (let dir = projectRoot; dir; ) {\n const candidate = join(dir, 'node_modules', 'pi-ele-picker', 'client', 'picker.js');\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n dir = parent === dir ? '' : parent; // stop at the filesystem root\n }\n return existsSync(OWN_CLIENT_FILE) ? OWN_CLIENT_FILE : null;\n}\n\nexport interface PiElePickerOptions {\n /**\n * Directory whose pi bridge to discover. Must match the `cwd` the pi session\n * was started in. Defaults to `process.cwd()`.\n */\n root?: string;\n}\n\ninterface BridgeInfo {\n port: number;\n pid: number;\n cwd: string;\n token: string;\n updatedAt: number;\n}\n\nfunction isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/** Find the freshest live bridge registered for the given project root. */\nfunction discoverBridge(root: string): BridgeInfo | null {\n let files: string[];\n try {\n files = readdirSync(REG_DIR).filter((f) => f.endsWith('.json'));\n } catch {\n return null;\n }\n let best: BridgeInfo | null = null;\n for (const f of files) {\n try {\n const info = JSON.parse(readFileSync(join(REG_DIR, f), 'utf8')) as BridgeInfo;\n if (info.cwd !== root) continue;\n if (!isPidAlive(info.pid)) continue;\n if (!best || info.updatedAt > best.updatedAt) best = info;\n } catch {\n /* skip malformed */\n }\n }\n return best;\n}\n\nexport default function piElePicker(options: PiElePickerOptions = {}): Plugin {\n const root = options.root ?? process.cwd();\n\n return {\n name: 'pi-ele-picker',\n apply: 'serve',\n transformIndexHtml() {\n return [\n {\n tag: 'script',\n attrs: { type: 'module', src: `${ENDPOINT}/picker.js` },\n injectTo: 'body',\n },\n ];\n },\n configureServer(server) {\n server.middlewares.use(ENDPOINT, (req, res, next) => {\n const url = (req.url ?? '').split('?')[0];\n\n if (url === '/config') {\n const info = discoverBridge(root);\n res.setHeader('content-type', 'application/json');\n res.setHeader('cache-control', 'no-store');\n res.end(JSON.stringify(info ? { port: info.port, token: info.token } : { port: null }));\n return;\n }\n\n if (url === '/picker.js') {\n const clientFile = resolveClientFile(server.config.root);\n if (!clientFile) {\n res.statusCode = 404;\n res.end('pi-ele-picker client missing');\n return;\n }\n res.setHeader('content-type', 'text/javascript');\n res.setHeader('cache-control', 'no-store');\n res.end(readFileSync(clientFile));\n return;\n }\n\n next();\n });\n },\n };\n}\n","// Shim globals in cjs bundle\n// There's a weird bug that esbuild will always inject importMetaUrl\n// if we export it as `const importMetaUrl = ... __filename ...`\n// But using a function will not cause this issue\n\nconst getImportMetaUrl = () => \n typeof document === \"undefined\" \n ? new URL(`file:${__filename}`).href \n : (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') \n ? document.currentScript.src \n : new URL(\"main.js\", document.baseURI).href;\n\nexport const importMetaUrl = /* @__PURE__ */ getImportMetaUrl()\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../node_modules/tsup/assets/cjs_shims.js"],"sourcesContent":["import type { Plugin } from 'vite';\nimport { existsSync, readFileSync, readdirSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { fileURLToPath } from 'node:url';\n\n/**\n * pi-ele-picker\n *\n * A Vite dev-only plugin that injects the pi element picker into the page and\n * exposes a same-origin endpoint the picker uses to discover the running pi\n * bridge.\n *\n * The bridge registry is written by the pi extension at\n * `~/.pi/agent/extensions/pi-picker.ts` (or any extension implementing the same\n * `~/.pi/agent/pi-picker/<pid>.json` registry + `POST /pick` protocol).\n */\n\n/** Directory where pi bridge extensions register themselves. */\nconst REG_DIR = join(homedir(), '.pi', 'agent', 'pi-picker');\n\n/** URL prefix under which the picker client and discovery endpoint are served. */\nconst ENDPOINT = '/__pi_picker__';\n\n/** Path to this package's own browser client, anchored at the plugin's install location. */\nconst OWN_CLIENT_FILE = fileURLToPath(new URL('../client/picker.js', import.meta.url));\n\n/**\n * Resolve the browser client to serve. Called per request, never cached.\n *\n * The plugin module is evaluated once when the dev server boots, so anything\n * captured at module scope is frozen for the life of that process. Upgrading\n * pi-ele-picker while a dev server is running therefore used to keep serving the\n * old client forever: the frozen path still pointed at\n * `node_modules/.pnpm/pi-ele-picker@<old>/...`, pnpm leaves that directory on\n * disk, and the stale client was handed out silently — refreshing never helped.\n *\n * Resolving from the project root instead means the currently installed copy\n * wins, with no server restart.\n *\n * Two traps this deliberately avoids:\n *\n * 1. Anchoring at `import.meta.url`. From inside `.../pi-ele-picker@<old>/dist/`\n * Node resolves `pi-ele-picker` straight back to that same old copy.\n * 2. `require.resolve()`. Node memoises resolutions in `Module._pathCache` and\n * never invalidates them when `node_modules` changes underneath a running\n * process, which pins the first lookup forever — the same staleness, moved.\n *\n * So the lookup is a plain filesystem walk, mirroring Node's own node_modules\n * search (upward from the project root, for workspace/monorepo layouts), and it\n * falls back to this package's own copy when nothing matches.\n */\nfunction resolveClientFile(projectRoot: string): string | null {\n for (let dir = projectRoot; dir; ) {\n const candidate = join(dir, 'node_modules', 'pi-ele-picker', 'client', 'picker.js');\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n dir = parent === dir ? '' : parent; // stop at the filesystem root\n }\n return existsSync(OWN_CLIENT_FILE) ? OWN_CLIENT_FILE : null;\n}\n\nexport interface PiElePickerOptions {\n /**\n * Directory whose pi bridge to discover. Must match the `cwd` the pi session\n * was started in. Defaults to `process.cwd()`.\n */\n root?: string;\n /**\n * Project-level default for the in-page \"session status\" toggle. The picker\n * polls the bridge every 3s to show whether pi is running/idle/stopped, which\n * costs a loopback request per cycle; it is therefore **off by default**.\n *\n * This is only a default: each browser can override it with the switch in the\n * picker panel (remembered in `localStorage`). Defaults to `false`.\n */\n status?: boolean;\n}\n\ninterface BridgeInfo {\n port: number;\n pid: number;\n cwd: string;\n token: string;\n updatedAt: number;\n}\n\nfunction isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/** Find the freshest live bridge registered for the given project root. */\nfunction discoverBridge(root: string): BridgeInfo | null {\n let files: string[];\n try {\n files = readdirSync(REG_DIR).filter((f) => f.endsWith('.json'));\n } catch {\n return null;\n }\n let best: BridgeInfo | null = null;\n for (const f of files) {\n try {\n const info = JSON.parse(readFileSync(join(REG_DIR, f), 'utf8')) as BridgeInfo;\n if (info.cwd !== root) continue;\n if (!isPidAlive(info.pid)) continue;\n if (!best || info.updatedAt > best.updatedAt) best = info;\n } catch {\n /* skip malformed */\n }\n }\n return best;\n}\n\nexport default function piElePicker(options: PiElePickerOptions = {}): Plugin {\n const root = options.root ?? process.cwd();\n const statusDefault = options.status ?? false;\n\n return {\n name: 'pi-ele-picker',\n apply: 'serve',\n transformIndexHtml() {\n return [\n {\n tag: 'script',\n attrs: { type: 'module', src: `${ENDPOINT}/picker.js` },\n injectTo: 'body',\n },\n ];\n },\n configureServer(server) {\n server.middlewares.use(ENDPOINT, (req, res, next) => {\n const url = (req.url ?? '').split('?')[0];\n\n if (url === '/config') {\n const info = discoverBridge(root);\n res.setHeader('content-type', 'application/json');\n res.setHeader('cache-control', 'no-store');\n res.end(\n JSON.stringify({\n port: info ? info.port : null,\n token: info ? info.token : null,\n statusDefault,\n })\n );\n return;\n }\n\n if (url === '/picker.js') {\n const clientFile = resolveClientFile(server.config.root);\n if (!clientFile) {\n res.statusCode = 404;\n res.end('pi-ele-picker client missing');\n return;\n }\n res.setHeader('content-type', 'text/javascript');\n res.setHeader('cache-control', 'no-store');\n res.end(readFileSync(clientFile));\n return;\n }\n\n next();\n });\n },\n };\n}\n","// Shim globals in cjs bundle\n// There's a weird bug that esbuild will always inject importMetaUrl\n// if we export it as `const importMetaUrl = ... __filename ...`\n// But using a function will not cause this issue\n\nconst getImportMetaUrl = () => \n typeof document === \"undefined\" \n ? new URL(`file:${__filename}`).href \n : (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') \n ? document.currentScript.src \n : new URL(\"main.js\", document.baseURI).href;\n\nexport const importMetaUrl = /* @__PURE__ */ getImportMetaUrl()\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -6,6 +6,15 @@ interface PiElePickerOptions {
6
6
  * was started in. Defaults to `process.cwd()`.
7
7
  */
8
8
  root?: string;
9
+ /**
10
+ * Project-level default for the in-page "session status" toggle. The picker
11
+ * polls the bridge every 3s to show whether pi is running/idle/stopped, which
12
+ * costs a loopback request per cycle; it is therefore **off by default**.
13
+ *
14
+ * This is only a default: each browser can override it with the switch in the
15
+ * picker panel (remembered in `localStorage`). Defaults to `false`.
16
+ */
17
+ status?: boolean;
9
18
  }
10
19
  declare function piElePicker(options?: PiElePickerOptions): Plugin;
11
20
 
package/dist/index.d.ts CHANGED
@@ -6,6 +6,15 @@ interface PiElePickerOptions {
6
6
  * was started in. Defaults to `process.cwd()`.
7
7
  */
8
8
  root?: string;
9
+ /**
10
+ * Project-level default for the in-page "session status" toggle. The picker
11
+ * polls the bridge every 3s to show whether pi is running/idle/stopped, which
12
+ * costs a loopback request per cycle; it is therefore **off by default**.
13
+ *
14
+ * This is only a default: each browser can override it with the switch in the
15
+ * picker panel (remembered in `localStorage`). Defaults to `false`.
16
+ */
17
+ status?: boolean;
9
18
  }
10
19
  declare function piElePicker(options?: PiElePickerOptions): Plugin;
11
20
 
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ function discoverBridge(root) {
44
44
  }
45
45
  function piElePicker(options = {}) {
46
46
  const root = options.root ?? process.cwd();
47
+ const statusDefault = options.status ?? false;
47
48
  return {
48
49
  name: "pi-ele-picker",
49
50
  apply: "serve",
@@ -63,7 +64,13 @@ function piElePicker(options = {}) {
63
64
  const info = discoverBridge(root);
64
65
  res.setHeader("content-type", "application/json");
65
66
  res.setHeader("cache-control", "no-store");
66
- res.end(JSON.stringify(info ? { port: info.port, token: info.token } : { port: null }));
67
+ res.end(
68
+ JSON.stringify({
69
+ port: info ? info.port : null,
70
+ token: info ? info.token : null,
71
+ statusDefault
72
+ })
73
+ );
67
74
  return;
68
75
  }
69
76
  if (url === "/picker.js") {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Plugin } from 'vite';\nimport { existsSync, readFileSync, readdirSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { fileURLToPath } from 'node:url';\n\n/**\n * pi-ele-picker\n *\n * A Vite dev-only plugin that injects the pi element picker into the page and\n * exposes a same-origin endpoint the picker uses to discover the running pi\n * bridge.\n *\n * The bridge registry is written by the pi extension at\n * `~/.pi/agent/extensions/pi-picker.ts` (or any extension implementing the same\n * `~/.pi/agent/pi-picker/<pid>.json` registry + `POST /pick` protocol).\n */\n\n/** Directory where pi bridge extensions register themselves. */\nconst REG_DIR = join(homedir(), '.pi', 'agent', 'pi-picker');\n\n/** URL prefix under which the picker client and discovery endpoint are served. */\nconst ENDPOINT = '/__pi_picker__';\n\n/** Path to this package's own browser client, anchored at the plugin's install location. */\nconst OWN_CLIENT_FILE = fileURLToPath(new URL('../client/picker.js', import.meta.url));\n\n/**\n * Resolve the browser client to serve. Called per request, never cached.\n *\n * The plugin module is evaluated once when the dev server boots, so anything\n * captured at module scope is frozen for the life of that process. Upgrading\n * pi-ele-picker while a dev server is running therefore used to keep serving the\n * old client forever: the frozen path still pointed at\n * `node_modules/.pnpm/pi-ele-picker@<old>/...`, pnpm leaves that directory on\n * disk, and the stale client was handed out silently — refreshing never helped.\n *\n * Resolving from the project root instead means the currently installed copy\n * wins, with no server restart.\n *\n * Two traps this deliberately avoids:\n *\n * 1. Anchoring at `import.meta.url`. From inside `.../pi-ele-picker@<old>/dist/`\n * Node resolves `pi-ele-picker` straight back to that same old copy.\n * 2. `require.resolve()`. Node memoises resolutions in `Module._pathCache` and\n * never invalidates them when `node_modules` changes underneath a running\n * process, which pins the first lookup forever — the same staleness, moved.\n *\n * So the lookup is a plain filesystem walk, mirroring Node's own node_modules\n * search (upward from the project root, for workspace/monorepo layouts), and it\n * falls back to this package's own copy when nothing matches.\n */\nfunction resolveClientFile(projectRoot: string): string | null {\n for (let dir = projectRoot; dir; ) {\n const candidate = join(dir, 'node_modules', 'pi-ele-picker', 'client', 'picker.js');\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n dir = parent === dir ? '' : parent; // stop at the filesystem root\n }\n return existsSync(OWN_CLIENT_FILE) ? OWN_CLIENT_FILE : null;\n}\n\nexport interface PiElePickerOptions {\n /**\n * Directory whose pi bridge to discover. Must match the `cwd` the pi session\n * was started in. Defaults to `process.cwd()`.\n */\n root?: string;\n}\n\ninterface BridgeInfo {\n port: number;\n pid: number;\n cwd: string;\n token: string;\n updatedAt: number;\n}\n\nfunction isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/** Find the freshest live bridge registered for the given project root. */\nfunction discoverBridge(root: string): BridgeInfo | null {\n let files: string[];\n try {\n files = readdirSync(REG_DIR).filter((f) => f.endsWith('.json'));\n } catch {\n return null;\n }\n let best: BridgeInfo | null = null;\n for (const f of files) {\n try {\n const info = JSON.parse(readFileSync(join(REG_DIR, f), 'utf8')) as BridgeInfo;\n if (info.cwd !== root) continue;\n if (!isPidAlive(info.pid)) continue;\n if (!best || info.updatedAt > best.updatedAt) best = info;\n } catch {\n /* skip malformed */\n }\n }\n return best;\n}\n\nexport default function piElePicker(options: PiElePickerOptions = {}): Plugin {\n const root = options.root ?? process.cwd();\n\n return {\n name: 'pi-ele-picker',\n apply: 'serve',\n transformIndexHtml() {\n return [\n {\n tag: 'script',\n attrs: { type: 'module', src: `${ENDPOINT}/picker.js` },\n injectTo: 'body',\n },\n ];\n },\n configureServer(server) {\n server.middlewares.use(ENDPOINT, (req, res, next) => {\n const url = (req.url ?? '').split('?')[0];\n\n if (url === '/config') {\n const info = discoverBridge(root);\n res.setHeader('content-type', 'application/json');\n res.setHeader('cache-control', 'no-store');\n res.end(JSON.stringify(info ? { port: info.port, token: info.token } : { port: null }));\n return;\n }\n\n if (url === '/picker.js') {\n const clientFile = resolveClientFile(server.config.root);\n if (!clientFile) {\n res.statusCode = 404;\n res.end('pi-ele-picker client missing');\n return;\n }\n res.setHeader('content-type', 'text/javascript');\n res.setHeader('cache-control', 'no-store');\n res.end(readFileSync(clientFile));\n return;\n }\n\n next();\n });\n },\n };\n}\n"],"mappings":";AACA,SAAS,YAAY,cAAc,mBAAmB;AACtD,SAAS,SAAS,YAAY;AAC9B,SAAS,eAAe;AACxB,SAAS,qBAAqB;AAe9B,IAAM,UAAU,KAAK,QAAQ,GAAG,OAAO,SAAS,WAAW;AAG3D,IAAM,WAAW;AAGjB,IAAM,kBAAkB,cAAc,IAAI,IAAI,uBAAuB,YAAY,GAAG,CAAC;AA2BrF,SAAS,kBAAkB,aAAoC;AAC7D,WAAS,MAAM,aAAa,OAAO;AACjC,UAAM,YAAY,KAAK,KAAK,gBAAgB,iBAAiB,UAAU,WAAW;AAClF,QAAI,WAAW,SAAS,EAAG,QAAO;AAClC,UAAM,SAAS,QAAQ,GAAG;AAC1B,UAAM,WAAW,MAAM,KAAK;AAAA,EAC9B;AACA,SAAO,WAAW,eAAe,IAAI,kBAAkB;AACzD;AAkBA,SAAS,WAAW,KAAsB;AACxC,MAAI;AACF,YAAQ,KAAK,KAAK,CAAC;AACnB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,eAAe,MAAiC;AACvD,MAAI;AACJ,MAAI;AACF,YAAQ,YAAY,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,SAAS,OAAO,CAAC;AAAA,EAChE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,OAA0B;AAC9B,aAAW,KAAK,OAAO;AACrB,QAAI;AACF,YAAM,OAAO,KAAK,MAAM,aAAa,KAAK,SAAS,CAAC,GAAG,MAAM,CAAC;AAC9D,UAAI,KAAK,QAAQ,KAAM;AACvB,UAAI,CAAC,WAAW,KAAK,GAAG,EAAG;AAC3B,UAAI,CAAC,QAAQ,KAAK,YAAY,KAAK,UAAW,QAAO;AAAA,IACvD,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AAEe,SAAR,YAA6B,UAA8B,CAAC,GAAW;AAC5E,QAAM,OAAO,QAAQ,QAAQ,QAAQ,IAAI;AAEzC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,IACP,qBAAqB;AACnB,aAAO;AAAA,QACL;AAAA,UACE,KAAK;AAAA,UACL,OAAO,EAAE,MAAM,UAAU,KAAK,GAAG,QAAQ,aAAa;AAAA,UACtD,UAAU;AAAA,QACZ;AAAA,MACF;AAAA,IACF;AAAA,IACA,gBAAgB,QAAQ;AACtB,aAAO,YAAY,IAAI,UAAU,CAAC,KAAK,KAAK,SAAS;AACnD,cAAM,OAAO,IAAI,OAAO,IAAI,MAAM,GAAG,EAAE,CAAC;AAExC,YAAI,QAAQ,WAAW;AACrB,gBAAM,OAAO,eAAe,IAAI;AAChC,cAAI,UAAU,gBAAgB,kBAAkB;AAChD,cAAI,UAAU,iBAAiB,UAAU;AACzC,cAAI,IAAI,KAAK,UAAU,OAAO,EAAE,MAAM,KAAK,MAAM,OAAO,KAAK,MAAM,IAAI,EAAE,MAAM,KAAK,CAAC,CAAC;AACtF;AAAA,QACF;AAEA,YAAI,QAAQ,cAAc;AACxB,gBAAM,aAAa,kBAAkB,OAAO,OAAO,IAAI;AACvD,cAAI,CAAC,YAAY;AACf,gBAAI,aAAa;AACjB,gBAAI,IAAI,8BAA8B;AACtC;AAAA,UACF;AACA,cAAI,UAAU,gBAAgB,iBAAiB;AAC/C,cAAI,UAAU,iBAAiB,UAAU;AACzC,cAAI,IAAI,aAAa,UAAU,CAAC;AAChC;AAAA,QACF;AAEA,aAAK;AAAA,MACP,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Plugin } from 'vite';\nimport { existsSync, readFileSync, readdirSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { fileURLToPath } from 'node:url';\n\n/**\n * pi-ele-picker\n *\n * A Vite dev-only plugin that injects the pi element picker into the page and\n * exposes a same-origin endpoint the picker uses to discover the running pi\n * bridge.\n *\n * The bridge registry is written by the pi extension at\n * `~/.pi/agent/extensions/pi-picker.ts` (or any extension implementing the same\n * `~/.pi/agent/pi-picker/<pid>.json` registry + `POST /pick` protocol).\n */\n\n/** Directory where pi bridge extensions register themselves. */\nconst REG_DIR = join(homedir(), '.pi', 'agent', 'pi-picker');\n\n/** URL prefix under which the picker client and discovery endpoint are served. */\nconst ENDPOINT = '/__pi_picker__';\n\n/** Path to this package's own browser client, anchored at the plugin's install location. */\nconst OWN_CLIENT_FILE = fileURLToPath(new URL('../client/picker.js', import.meta.url));\n\n/**\n * Resolve the browser client to serve. Called per request, never cached.\n *\n * The plugin module is evaluated once when the dev server boots, so anything\n * captured at module scope is frozen for the life of that process. Upgrading\n * pi-ele-picker while a dev server is running therefore used to keep serving the\n * old client forever: the frozen path still pointed at\n * `node_modules/.pnpm/pi-ele-picker@<old>/...`, pnpm leaves that directory on\n * disk, and the stale client was handed out silently — refreshing never helped.\n *\n * Resolving from the project root instead means the currently installed copy\n * wins, with no server restart.\n *\n * Two traps this deliberately avoids:\n *\n * 1. Anchoring at `import.meta.url`. From inside `.../pi-ele-picker@<old>/dist/`\n * Node resolves `pi-ele-picker` straight back to that same old copy.\n * 2. `require.resolve()`. Node memoises resolutions in `Module._pathCache` and\n * never invalidates them when `node_modules` changes underneath a running\n * process, which pins the first lookup forever — the same staleness, moved.\n *\n * So the lookup is a plain filesystem walk, mirroring Node's own node_modules\n * search (upward from the project root, for workspace/monorepo layouts), and it\n * falls back to this package's own copy when nothing matches.\n */\nfunction resolveClientFile(projectRoot: string): string | null {\n for (let dir = projectRoot; dir; ) {\n const candidate = join(dir, 'node_modules', 'pi-ele-picker', 'client', 'picker.js');\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n dir = parent === dir ? '' : parent; // stop at the filesystem root\n }\n return existsSync(OWN_CLIENT_FILE) ? OWN_CLIENT_FILE : null;\n}\n\nexport interface PiElePickerOptions {\n /**\n * Directory whose pi bridge to discover. Must match the `cwd` the pi session\n * was started in. Defaults to `process.cwd()`.\n */\n root?: string;\n /**\n * Project-level default for the in-page \"session status\" toggle. The picker\n * polls the bridge every 3s to show whether pi is running/idle/stopped, which\n * costs a loopback request per cycle; it is therefore **off by default**.\n *\n * This is only a default: each browser can override it with the switch in the\n * picker panel (remembered in `localStorage`). Defaults to `false`.\n */\n status?: boolean;\n}\n\ninterface BridgeInfo {\n port: number;\n pid: number;\n cwd: string;\n token: string;\n updatedAt: number;\n}\n\nfunction isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/** Find the freshest live bridge registered for the given project root. */\nfunction discoverBridge(root: string): BridgeInfo | null {\n let files: string[];\n try {\n files = readdirSync(REG_DIR).filter((f) => f.endsWith('.json'));\n } catch {\n return null;\n }\n let best: BridgeInfo | null = null;\n for (const f of files) {\n try {\n const info = JSON.parse(readFileSync(join(REG_DIR, f), 'utf8')) as BridgeInfo;\n if (info.cwd !== root) continue;\n if (!isPidAlive(info.pid)) continue;\n if (!best || info.updatedAt > best.updatedAt) best = info;\n } catch {\n /* skip malformed */\n }\n }\n return best;\n}\n\nexport default function piElePicker(options: PiElePickerOptions = {}): Plugin {\n const root = options.root ?? process.cwd();\n const statusDefault = options.status ?? false;\n\n return {\n name: 'pi-ele-picker',\n apply: 'serve',\n transformIndexHtml() {\n return [\n {\n tag: 'script',\n attrs: { type: 'module', src: `${ENDPOINT}/picker.js` },\n injectTo: 'body',\n },\n ];\n },\n configureServer(server) {\n server.middlewares.use(ENDPOINT, (req, res, next) => {\n const url = (req.url ?? '').split('?')[0];\n\n if (url === '/config') {\n const info = discoverBridge(root);\n res.setHeader('content-type', 'application/json');\n res.setHeader('cache-control', 'no-store');\n res.end(\n JSON.stringify({\n port: info ? info.port : null,\n token: info ? info.token : null,\n statusDefault,\n })\n );\n return;\n }\n\n if (url === '/picker.js') {\n const clientFile = resolveClientFile(server.config.root);\n if (!clientFile) {\n res.statusCode = 404;\n res.end('pi-ele-picker client missing');\n return;\n }\n res.setHeader('content-type', 'text/javascript');\n res.setHeader('cache-control', 'no-store');\n res.end(readFileSync(clientFile));\n return;\n }\n\n next();\n });\n },\n };\n}\n"],"mappings":";AACA,SAAS,YAAY,cAAc,mBAAmB;AACtD,SAAS,SAAS,YAAY;AAC9B,SAAS,eAAe;AACxB,SAAS,qBAAqB;AAe9B,IAAM,UAAU,KAAK,QAAQ,GAAG,OAAO,SAAS,WAAW;AAG3D,IAAM,WAAW;AAGjB,IAAM,kBAAkB,cAAc,IAAI,IAAI,uBAAuB,YAAY,GAAG,CAAC;AA2BrF,SAAS,kBAAkB,aAAoC;AAC7D,WAAS,MAAM,aAAa,OAAO;AACjC,UAAM,YAAY,KAAK,KAAK,gBAAgB,iBAAiB,UAAU,WAAW;AAClF,QAAI,WAAW,SAAS,EAAG,QAAO;AAClC,UAAM,SAAS,QAAQ,GAAG;AAC1B,UAAM,WAAW,MAAM,KAAK;AAAA,EAC9B;AACA,SAAO,WAAW,eAAe,IAAI,kBAAkB;AACzD;AA2BA,SAAS,WAAW,KAAsB;AACxC,MAAI;AACF,YAAQ,KAAK,KAAK,CAAC;AACnB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,eAAe,MAAiC;AACvD,MAAI;AACJ,MAAI;AACF,YAAQ,YAAY,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,SAAS,OAAO,CAAC;AAAA,EAChE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,OAA0B;AAC9B,aAAW,KAAK,OAAO;AACrB,QAAI;AACF,YAAM,OAAO,KAAK,MAAM,aAAa,KAAK,SAAS,CAAC,GAAG,MAAM,CAAC;AAC9D,UAAI,KAAK,QAAQ,KAAM;AACvB,UAAI,CAAC,WAAW,KAAK,GAAG,EAAG;AAC3B,UAAI,CAAC,QAAQ,KAAK,YAAY,KAAK,UAAW,QAAO;AAAA,IACvD,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AAEe,SAAR,YAA6B,UAA8B,CAAC,GAAW;AAC5E,QAAM,OAAO,QAAQ,QAAQ,QAAQ,IAAI;AACzC,QAAM,gBAAgB,QAAQ,UAAU;AAExC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,IACP,qBAAqB;AACnB,aAAO;AAAA,QACL;AAAA,UACE,KAAK;AAAA,UACL,OAAO,EAAE,MAAM,UAAU,KAAK,GAAG,QAAQ,aAAa;AAAA,UACtD,UAAU;AAAA,QACZ;AAAA,MACF;AAAA,IACF;AAAA,IACA,gBAAgB,QAAQ;AACtB,aAAO,YAAY,IAAI,UAAU,CAAC,KAAK,KAAK,SAAS;AACnD,cAAM,OAAO,IAAI,OAAO,IAAI,MAAM,GAAG,EAAE,CAAC;AAExC,YAAI,QAAQ,WAAW;AACrB,gBAAM,OAAO,eAAe,IAAI;AAChC,cAAI,UAAU,gBAAgB,kBAAkB;AAChD,cAAI,UAAU,iBAAiB,UAAU;AACzC,cAAI;AAAA,YACF,KAAK,UAAU;AAAA,cACb,MAAM,OAAO,KAAK,OAAO;AAAA,cACzB,OAAO,OAAO,KAAK,QAAQ;AAAA,cAC3B;AAAA,YACF,CAAC;AAAA,UACH;AACA;AAAA,QACF;AAEA,YAAI,QAAQ,cAAc;AACxB,gBAAM,aAAa,kBAAkB,OAAO,OAAO,IAAI;AACvD,cAAI,CAAC,YAAY;AACf,gBAAI,aAAa;AACjB,gBAAI,IAAI,8BAA8B;AACtC;AAAA,UACF;AACA,cAAI,UAAU,gBAAgB,iBAAiB;AAC/C,cAAI,UAAU,iBAAiB,UAAU;AACzC,cAAI,IAAI,aAAa,UAAU,CAAC;AAChC;AAAA,QACF;AAEA,aAAK;AAAA,MACP,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
@@ -17,6 +17,15 @@
17
17
  * reply: { ok: true, mode, inserted } | { ok: true, mode, submitted, queued? }
18
18
  * | { ok: false, error }
19
19
  *
20
+ * Wire protocol (GET /status):
21
+ * reply: { ok: true, status: "running" | "queued" | "idle", idle, pending,
22
+ * mode, sessionId, model, provider, pid, cwd, updatedAt }
23
+ *
24
+ * `/status` is polled by the browser picker to render a live session badge (the
25
+ * launcher dot + the panel status line). It has no token: any loopback-origin
26
+ * page may read it, but non-loopback browser origins are rejected so an arbitrary
27
+ * website cannot probe session metadata.
28
+ *
20
29
  * Two delivery modes:
21
30
  * - default (submit falsy): pre-fill the current pi input editor for review.
22
31
  * - submit: true: deliver a real user turn immediately (queued as a follow-up
@@ -169,6 +178,58 @@ function compose(payload: PickPayload, imagePaths: string[]): string {
169
178
  return lines.join('\n');
170
179
  }
171
180
 
181
+ interface SessionStatus {
182
+ ok: true;
183
+ /** Coarse state for the picker UI. */
184
+ status: 'running' | 'queued' | 'idle';
185
+ /** Whether the agent loop is idle (not streaming). */
186
+ idle: boolean;
187
+ /** Whether follow-up messages are queued behind the current run. */
188
+ pending: boolean;
189
+ mode: string | null;
190
+ sessionId: string | null;
191
+ model: string | null;
192
+ provider: string | null;
193
+ pid: number;
194
+ cwd: string;
195
+ updatedAt: number;
196
+ }
197
+
198
+ /**
199
+ * Snapshot the active session's live state for GET /status.
200
+ *
201
+ * Derivation is ordered so "busy" always wins: a session that is mid-run reads
202
+ * `running` even while follow-ups are queued; only an idle session with queued
203
+ * messages reports `queued`; everything else is `idle`.
204
+ */
205
+ function sessionStatus(ctx: unknown): SessionStatus {
206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
207
+ const c = ctx as any;
208
+ let idle = true;
209
+ let pending = false;
210
+ try {
211
+ if (c && typeof c.isIdle === 'function') idle = !!c.isIdle();
212
+ if (c && typeof c.hasPendingMessages === 'function') pending = !!c.hasPendingMessages();
213
+ } catch {
214
+ /* fall back to the idle/no-queue defaults */
215
+ }
216
+ const model = c && c.model ? c.model.name || c.model.id || null : null;
217
+ const provider = c && c.model ? c.model.provider ?? null : null;
218
+ return {
219
+ ok: true,
220
+ status: !idle ? 'running' : pending ? 'queued' : 'idle',
221
+ idle,
222
+ pending,
223
+ mode: c ? c.mode ?? null : null,
224
+ sessionId: c ? c.sessionId ?? null : null,
225
+ model,
226
+ provider,
227
+ pid: process.pid,
228
+ cwd: process.cwd(),
229
+ updatedAt: Date.now(),
230
+ };
231
+ }
232
+
172
233
  export default function (pi: ExtensionAPI): void {
173
234
  const token = randomBytes(16).toString('hex');
174
235
  let server: Server | undefined;
@@ -249,6 +310,19 @@ export default function (pi: ExtensionAPI): void {
249
310
  return;
250
311
  }
251
312
 
313
+ if (req.method === 'GET' && url === '/status') {
314
+ // Read-only and tokenless, so gate it on a loopback browser origin: reject a
315
+ // foreign page while still allowing no-Origin callers (curl, local scripts).
316
+ if (origin && !isLoopbackOrigin(origin)) {
317
+ res.writeHead(403, { 'content-type': 'application/json' });
318
+ res.end(JSON.stringify({ ok: false, error: 'forbidden origin' }));
319
+ return;
320
+ }
321
+ res.writeHead(200, { 'content-type': 'application/json' });
322
+ res.end(JSON.stringify(sessionStatus(activeCtx)));
323
+ return;
324
+ }
325
+
252
326
  if (req.method !== 'POST' || url !== '/pick') {
253
327
  res.writeHead(404, { 'content-type': 'application/json' });
254
328
  res.end(JSON.stringify({ ok: false, error: 'not found' }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ele-picker",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Pick an element in your running page, describe the change, and send it to a pi session. Ships a Vite dev plugin (browser picker + discovery endpoint) and the matching pi bridge extension.",
5
5
  "type": "module",
6
6
  "license": "MIT",