poltertab 1.1.0 → 1.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
@@ -229,6 +229,41 @@ poltertab/
229
229
  └── README.md
230
230
  ```
231
231
 
232
+ ## Staying up to date
233
+
234
+ ```bash
235
+ poltertab doctor
236
+ ```
237
+
238
+ Reports the installed version, whether npm has a newer one, and — the part that
239
+ matters — whether the two halves agree:
240
+
241
+ ```
242
+ server 1.1.0
243
+ extension 1.0.0 SKEW last seen 3m ago
244
+ bridge listening on port 7822
245
+ state ~/.poltertab
246
+ npm 1.2.0 available
247
+ ```
248
+
249
+ The npm package updates when you run `npm update -g poltertab`. The extension,
250
+ loaded unpacked, **never updates on its own** — Chrome only auto-updates what came
251
+ from the Web Store. So the interesting failure is not a missing update, it is
252
+ skew: a newer server sending a command an older extension does not implement,
253
+ which surfaces as an element mysteriously not being found.
254
+
255
+ Both halves ship as one version, so a mismatch is unambiguous. The extension
256
+ reports its version when it connects; the server compares, logs it, and shows it
257
+ in the extension popup. If either half is behind, the first tool response of a
258
+ session says so once, so you hear about it without having to think to check.
259
+
260
+ Update checks hit `registry.npmjs.org` at most once a day, cached in
261
+ `~/.poltertab/update-check.json`, and never block anything. Set
262
+ `POLTERTAB_NO_UPDATE_CHECK=1` to turn them off — skew detection is local and
263
+ keeps working either way.
264
+
265
+ `poltertab doctor` exits non-zero on skew, so CI or a shell prompt can act on it.
266
+
232
267
  ## Where your data lives
233
268
 
234
269
  Nothing the server accumulates is stored inside the package:
package/bin/doctor.js ADDED
@@ -0,0 +1,174 @@
1
+ // `poltertab doctor` — what is installed, what is available, and whether the
2
+ // two halves agree.
3
+ //
4
+ // Deliberately works with no server running and no network. Someone runs this
5
+ // because something is already broken, which is the worst time to demand that
6
+ // more things be working.
7
+
8
+ const fs = require("fs");
9
+ const net = require("net");
10
+ const os = require("os");
11
+ const path = require("path");
12
+
13
+ const PKG = path.join(__dirname, "..");
14
+ const updates = require(path.join(PKG, "mcp-server", "update-check.js"));
15
+
16
+ const c = {
17
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
18
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
19
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
20
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
21
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
22
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
23
+ };
24
+
25
+ const home = () =>
26
+ process.env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
27
+
28
+ function port() {
29
+ const i = process.argv.indexOf("--port");
30
+ if (i !== -1 && process.argv[i + 1]) return Number(process.argv[i + 1]);
31
+ return Number(process.env.MCP_BROWSER_WS_PORT) || 7822;
32
+ }
33
+
34
+ // A listening port means a server holds the bridge. Cheaper and more reliable
35
+ // than speaking the protocol just to answer "is anything running".
36
+ function probePort(p, timeout = 700) {
37
+ return new Promise((resolve) => {
38
+ const s = net.connect({ port: p, host: "127.0.0.1" });
39
+ const done = (v) => {
40
+ s.destroy();
41
+ resolve(v);
42
+ };
43
+ s.setTimeout(timeout);
44
+ s.once("connect", () => done(true));
45
+ s.once("timeout", () => done(false));
46
+ s.once("error", () => done(false));
47
+ });
48
+ }
49
+
50
+ const ago = (ts) => {
51
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
52
+ if (s < 90) return `${s}s ago`;
53
+ const m = Math.round(s / 60);
54
+ if (m < 90) return `${m}m ago`;
55
+ const h = Math.round(m / 60);
56
+ return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
57
+ };
58
+
59
+ async function run(argv = []) {
60
+ const H = home();
61
+ const own = require(path.join(PKG, "package.json")).version;
62
+ const row = (k, v) => console.log(` ${k.padEnd(12)}${v}`);
63
+
64
+ console.log(`\n ${c.bold("PolterTab")} ${c.dim("doctor")}\n`);
65
+
66
+ // Start the registry lookup before printing anything, then report everything
67
+ // local while it is in flight. A cold lookup costs seconds, and blocking the
68
+ // whole report on it makes doctor look hung exactly when someone is already
69
+ // suspicious that something is broken.
70
+ const checking = updates
71
+ .checkForUpdate({ current: own, home: H, force: argv.includes("--force") })
72
+ .catch(() => ({ latest: null, updateAvailable: false }));
73
+
74
+ // --- versions
75
+ const seen = updates.readExtensionState(H);
76
+ const ext = seen && seen.extensionVersion;
77
+ const sk = updates.skew(own, ext);
78
+
79
+ row("server", own);
80
+
81
+ if (!ext) {
82
+ row("extension", c.dim("never seen — has it connected to a server yet?"));
83
+ } else {
84
+ row(
85
+ "extension",
86
+ `${ext} ${sk ? c.red("SKEW") : c.green("matches server")}` +
87
+ c.dim(` last seen ${ago(seen.seenAt || 0)}`),
88
+ );
89
+ }
90
+
91
+ const listening = await probePort(port());
92
+ row(
93
+ "bridge",
94
+ listening
95
+ ? `${c.green("listening")} ${c.dim(`on port ${port()}`)}`
96
+ : `${c.dim(`nothing on port ${port()}`)}`,
97
+ );
98
+
99
+ row("state", c.dim(H));
100
+
101
+ // Now collect the answer we started with.
102
+ const check = await checking;
103
+ if (updates.disabled() && !check.latest) {
104
+ row("npm", c.dim("update check disabled by POLTERTAB_NO_UPDATE_CHECK"));
105
+ } else if (check.latest) {
106
+ row(
107
+ "npm",
108
+ check.updateAvailable
109
+ ? `${c.yellow(check.latest)} ${c.dim("available")}`
110
+ : `${c.green("up to date")} ${c.dim(`(latest is ${check.latest})`)}`,
111
+ );
112
+ } else {
113
+ row("npm", c.dim("could not reach the registry"));
114
+ }
115
+
116
+ // --- what to do about it
117
+ const todo = [];
118
+ if (sk && sk.kind === "extension-behind") {
119
+ todo.push(
120
+ `${c.red("The extension is older than the server.")} Browser commands can fail\n` +
121
+ ` in ways that look like a missing element. Download and reload it:\n` +
122
+ ` ${c.cyan(updates.EXTENSION_URL)}`,
123
+ );
124
+ } else if (sk && sk.kind === "server-behind") {
125
+ todo.push(
126
+ `${c.yellow("The extension is newer than the server.")} Update the package:\n` +
127
+ ` ${c.cyan("npm update -g poltertab")}`,
128
+ );
129
+ }
130
+ if (check.updateAvailable) {
131
+ todo.push(
132
+ `PolterTab ${c.bold(check.latest)} is available:\n` +
133
+ ` ${c.cyan("npm update -g poltertab")}\n` +
134
+ ` ${c.dim("then reload the extension from")} ${c.cyan(updates.EXTENSION_URL)}`,
135
+ );
136
+ }
137
+ if (!listening) {
138
+ todo.push(
139
+ `No bridge on port ${port()}. That is expected unless an MCP client has\n` +
140
+ ` PolterTab running — the server starts when the client starts it.`,
141
+ );
142
+ }
143
+
144
+ if (todo.length) {
145
+ console.log(`\n ${c.bold("To do")}\n`);
146
+ for (const t of todo) console.log(` • ${t}`);
147
+ } else if (!ext) {
148
+ // Claiming "both halves agree" having never seen one of them is the kind of
149
+ // all-clear that sends someone looking for the fault somewhere else.
150
+ console.log(
151
+ `\n ${c.yellow("Nothing wrong found, but the extension has never checked in.")}\n` +
152
+ ` ${c.dim("Install it, open a page, and run doctor again to compare versions:")}\n` +
153
+ ` ${c.cyan(updates.EXTENSION_URL)}`,
154
+ );
155
+ } else {
156
+ console.log(`\n ${c.green("Nothing to do — both halves agree and are current.")}`);
157
+ }
158
+ console.log("");
159
+
160
+ // Non-zero on skew only: an available update is information, but two halves
161
+ // that disagree is a real fault, and a script may want to act on it.
162
+ return sk ? 1 : 0;
163
+ }
164
+
165
+ module.exports = { run, probePort, ago };
166
+
167
+ if (require.main === module) {
168
+ run(process.argv.slice(3))
169
+ .then((code) => process.exit(code))
170
+ .catch((err) => {
171
+ console.error(`\n doctor failed: ${err.message}\n`);
172
+ process.exit(1);
173
+ });
174
+ }
package/bin/poltertab.js CHANGED
@@ -15,6 +15,14 @@ if (arg === "setup") {
15
15
  console.error(`\n setup failed: ${err.message}\n`);
16
16
  process.exit(1);
17
17
  });
18
+ } else if (arg === "doctor") {
19
+ require("./doctor.js")
20
+ .run(process.argv.slice(3))
21
+ .then((code) => process.exit(code))
22
+ .catch((err) => {
23
+ console.error(`\n doctor failed: ${err.message}\n`);
24
+ process.exit(1);
25
+ });
18
26
  } else if (arg === "--version" || arg === "-v") {
19
27
  console.log(require("../package.json").version);
20
28
  } else if (arg === "--help" || arg === "-h") {
@@ -23,9 +31,14 @@ if (arg === "setup") {
23
31
 
24
32
  poltertab start the MCP server (stdio)
25
33
  poltertab setup install skill, CLAUDE.md section, and MCP registration
34
+ poltertab doctor versions, update check, and extension/server skew
26
35
  poltertab --version
27
36
 
28
- setup flags: --global | --project (skips the prompt)
37
+ setup flags: --global | --project (skips the prompt)
38
+ doctor flags: --force (ignore the cached update check), --port <n>
39
+
40
+ POLTERTAB_HOME where memory and downloads live
41
+ POLTERTAB_NO_UPDATE_CHECK set to disable the npm version check
29
42
  `);
30
43
  } else {
31
44
  require("../mcp-server/index.js");
@@ -30,6 +30,26 @@ const DOWNLOADS_DIR = path.join(POLTERTAB_HOME, "downloads");
30
30
 
31
31
  fs.mkdirSync(MEMORY_DIR, { recursive: true });
32
32
 
33
+ const updates = require("./update-check.js");
34
+ const OWN_VERSION = require("./../package.json").version;
35
+
36
+ // Populated when the extension connects; stays null until then, which is itself
37
+ // worth reporting — a skew warning must not fire on "no extension yet".
38
+ let extensionVersion = null;
39
+ let updateState = { latest: null, updateAvailable: false };
40
+ let noticeDelivered = false;
41
+
42
+ // Fire and forget at startup so the answer is ready by the first tool call.
43
+ // A rejected promise here must never reach the top level.
44
+ if (!updates.disabled()) {
45
+ updates
46
+ .checkForUpdate({ current: OWN_VERSION, home: POLTERTAB_HOME })
47
+ .then((r) => {
48
+ updateState = r;
49
+ })
50
+ .catch(() => {});
51
+ }
52
+
33
53
  // Installs predating the move kept memory beside the code. Copy it forward once
34
54
  // so an upgrade does not look like the agent forgot everything it learned.
35
55
  // Never overwrite: if both sides have a note for a domain, the one already in
@@ -370,6 +390,27 @@ function setupPrimaryWss(wss) {
370
390
  extensionSocket = ws;
371
391
  console.error("[PolterTab MCP] Chrome extension connected.");
372
392
  }
393
+ // The extension has always sent its version here and we always
394
+ // dropped it, so the one mismatch that actually breaks commands was
395
+ // invisible to both halves.
396
+ if (msg.version && msg.version !== extensionVersion) {
397
+ extensionVersion = msg.version;
398
+ updates.recordExtension(POLTERTAB_HOME, msg.version);
399
+ const sk = updates.skew(OWN_VERSION, msg.version);
400
+ console.error(
401
+ `[PolterTab MCP] Extension v${msg.version} (server v${OWN_VERSION})` +
402
+ (sk ? ` — VERSION SKEW: ${sk.kind}` : ""),
403
+ );
404
+ // Let the extension show the mismatch in its popup; it cannot
405
+ // know the server's version otherwise.
406
+ try {
407
+ ws.send(
408
+ JSON.stringify({ type: "server_version", version: OWN_VERSION }),
409
+ );
410
+ } catch (_) {
411
+ // A socket that died mid-handshake reconnects on its own.
412
+ }
413
+ }
373
414
  }
374
415
  return;
375
416
  }
@@ -554,7 +595,9 @@ async function sendCommand(action, params) {
554
595
  const server = new Server(
555
596
  {
556
597
  name: "poltertab-browser-mcp",
557
- version: "1.0.0",
598
+ // Read, not hardcoded: this said "1.0.0" through every release, so the
599
+ // version the client reported had nothing to do with what was installed.
600
+ version: OWN_VERSION,
558
601
  },
559
602
  {
560
603
  capabilities: {
@@ -867,7 +910,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
867
910
  };
868
911
  });
869
912
 
870
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
913
+ const handleToolCall = async (request) => {
871
914
  const { name, arguments: args } = request.params;
872
915
 
873
916
  if (!name.startsWith("browser_")) {
@@ -1064,6 +1107,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1064
1107
  ],
1065
1108
  };
1066
1109
  }
1110
+ };
1111
+
1112
+ // Update and skew notices ride out on the first tool response and never again.
1113
+ // Doctor and the extension popup both require the user to already suspect
1114
+ // something is wrong; the agent's reply is the one place they are certainly
1115
+ // looking. Appended as its own content block so it cannot corrupt a payload
1116
+ // something downstream is parsing.
1117
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1118
+ const result = await handleToolCall(request);
1119
+ if (noticeDelivered) return result;
1120
+
1121
+ const text = updates.notice({
1122
+ current: OWN_VERSION,
1123
+ latest: updateState.latest,
1124
+ updateAvailable: updateState.updateAvailable,
1125
+ skew: updates.skew(OWN_VERSION, extensionVersion),
1126
+ });
1127
+ if (!text) return result;
1128
+
1129
+ noticeDelivered = true;
1130
+ if (!result || !Array.isArray(result.content)) return result;
1131
+ return { ...result, content: [...result.content, { type: "text", text }] };
1067
1132
  });
1068
1133
 
1069
1134
  // Start the server
@@ -0,0 +1,242 @@
1
+ // Update and version-skew checking.
2
+ //
3
+ // Two halves ship as one version but update on completely different schedules.
4
+ // The npm package updates when someone remembers to run `npm update -g`; the
5
+ // extension, loaded unpacked, never updates at all — Chrome only auto-updates
6
+ // what came from the Web Store. So the interesting failure is not "an update
7
+ // exists", it is skew: a 1.2 server sending a command a 1.0 extension does not
8
+ // implement. That surfaces as an element mysteriously not being found, which is
9
+ // indistinguishable from the page genuinely not having it.
10
+ //
11
+ // Nothing here may block startup or throw into a caller. A failed update check
12
+ // is a non-event; the browser tools have to keep working with no network.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ const CACHE_FILE = "update-check.json";
18
+ const STATE_FILE = "state.json";
19
+ const OK_TTL_MS = 24 * 60 * 60 * 1000; // a day between successful checks
20
+ const FAIL_TTL_MS = 60 * 60 * 1000; // retry sooner after a failure
21
+
22
+ // Measured, not guessed: a cold connection to registry.npmjs.org costs ~4s here
23
+ // (DNS + TLS; the body itself arrives in ~5ms). Every check is cold, because it
24
+ // runs once at startup in a fresh process. An earlier 3s value aborted 100% of
25
+ // real lookups while every unit test passed, since those inject a fake fetch —
26
+ // so keep a wide margin over the observed cost. Nothing waits on this: the
27
+ // server fires it and forgets, and doctor prints local state first.
28
+ const FETCH_TIMEOUT_MS = 15000;
29
+
30
+ // --- semver ----------------------------------------------------------------
31
+
32
+ function parse(version) {
33
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(
34
+ String(version || "").trim().replace(/^v/, ""),
35
+ );
36
+ if (!m) return null;
37
+ return {
38
+ nums: [Number(m[1]), Number(m[2]), Number(m[3])],
39
+ pre: m[4] ? m[4].split(".") : [],
40
+ };
41
+ }
42
+
43
+ // Full semver precedence, because the shortcuts are all wrong in ways that
44
+ // matter here: string compare puts "1.10.0" below "1.9.0", and ignoring the
45
+ // prerelease tail would tell someone on 1.2.0-beta.1 to "update" to 1.1.0.
46
+ function compareVersions(a, b) {
47
+ const A = parse(a);
48
+ const B = parse(b);
49
+ if (!A || !B) return 0;
50
+
51
+ for (let i = 0; i < 3; i++) {
52
+ if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
53
+ }
54
+
55
+ // 1.2.0 outranks every 1.2.0-anything.
56
+ if (!A.pre.length && B.pre.length) return 1;
57
+ if (A.pre.length && !B.pre.length) return -1;
58
+
59
+ for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
60
+ const x = A.pre[i];
61
+ const y = B.pre[i];
62
+ if (x === undefined) return -1; // a shorter prerelease ranks lower
63
+ if (y === undefined) return 1;
64
+ const xNum = /^\d+$/.test(x);
65
+ const yNum = /^\d+$/.test(y);
66
+ if (xNum && yNum) {
67
+ if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1;
68
+ } else if (xNum !== yNum) {
69
+ return xNum ? -1 : 1; // numeric identifiers rank below alphanumeric
70
+ } else if (x !== y) {
71
+ return x < y ? -1 : 1;
72
+ }
73
+ }
74
+ return 0;
75
+ }
76
+
77
+ const isNewer = (candidate, current) => compareVersions(candidate, current) > 0;
78
+
79
+ // --- on-disk state ---------------------------------------------------------
80
+
81
+ function readJson(file) {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(file, "utf8"));
84
+ } catch (_) {
85
+ return null; // absent, unreadable, or truncated — all mean "no state"
86
+ }
87
+ }
88
+
89
+ function writeJson(file, data) {
90
+ try {
91
+ fs.mkdirSync(path.dirname(file), { recursive: true });
92
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
93
+ return true;
94
+ } catch (_) {
95
+ return false; // a read-only home is not worth failing a browser command
96
+ }
97
+ }
98
+
99
+ // The extension only ever talks to a running server, so `poltertab doctor` has
100
+ // no way to ask it directly. Recording the version here means doctor can report
101
+ // it — honestly labelled as last-seen — without any new protocol, and even when
102
+ // no server is running.
103
+ const readExtensionState = (home) => readJson(path.join(home, STATE_FILE));
104
+
105
+ function recordExtension(home, version, now = Date.now()) {
106
+ if (!version) return false;
107
+ return writeJson(path.join(home, STATE_FILE), {
108
+ extensionVersion: version,
109
+ seenAt: now,
110
+ });
111
+ }
112
+
113
+ // --- registry --------------------------------------------------------------
114
+
115
+ function disabled() {
116
+ const v = process.env.POLTERTAB_NO_UPDATE_CHECK;
117
+ return v !== undefined && v !== "" && v !== "0" && v !== "false";
118
+ }
119
+
120
+ // Resolves to a version string, or null for any failure at all — offline,
121
+ // 404 (the package is not published yet), timeout, garbage response.
122
+ async function fetchLatest(pkg = "poltertab", fetchImpl = globalThis.fetch) {
123
+ if (typeof fetchImpl !== "function") return null;
124
+ const ac = new AbortController();
125
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
126
+ try {
127
+ const res = await fetchImpl(
128
+ `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`,
129
+ { signal: ac.signal, headers: { accept: "application/json" } },
130
+ );
131
+ if (!res || !res.ok) return null;
132
+ const body = await res.json();
133
+ return parse(body && body.version) ? body.version : null;
134
+ } catch (_) {
135
+ return null;
136
+ } finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+
141
+ // Cached so a per-session server start does not mean a per-session request.
142
+ // Returns the cached answer without touching the network when it is fresh.
143
+ async function checkForUpdate({
144
+ current,
145
+ home,
146
+ pkg = "poltertab",
147
+ now = Date.now(),
148
+ fetchImpl = globalThis.fetch,
149
+ force = false,
150
+ } = {}) {
151
+ const cacheFile = path.join(home, CACHE_FILE);
152
+ const cached = readJson(cacheFile);
153
+
154
+ if (!force && cached && typeof cached.checkedAt === "number") {
155
+ const ttl = cached.latest ? OK_TTL_MS : FAIL_TTL_MS;
156
+ if (now - cached.checkedAt < ttl) {
157
+ return {
158
+ latest: cached.latest || null,
159
+ updateAvailable: !!cached.latest && isNewer(cached.latest, current),
160
+ fromCache: true,
161
+ };
162
+ }
163
+ }
164
+
165
+ if (!force && disabled()) {
166
+ return { latest: null, updateAvailable: false, disabled: true };
167
+ }
168
+
169
+ const latest = await fetchLatest(pkg, fetchImpl);
170
+ writeJson(cacheFile, { checkedAt: now, latest });
171
+ return {
172
+ latest,
173
+ updateAvailable: !!latest && isNewer(latest, current),
174
+ fromCache: false,
175
+ };
176
+ }
177
+
178
+ // --- skew ------------------------------------------------------------------
179
+
180
+ // Compares only major.minor. A patch-level difference between the two halves is
181
+ // normal — someone updates the package and reloads the extension a minute later
182
+ // — and warning about it would train people to ignore the warning.
183
+ function skew(serverVersion, extensionVersion) {
184
+ if (!extensionVersion) return null;
185
+ const s = parse(serverVersion);
186
+ const e = parse(extensionVersion);
187
+ if (!s || !e) return null;
188
+ if (s.nums[0] === e.nums[0] && s.nums[1] === e.nums[1]) return null;
189
+ const behind = compareVersions(extensionVersion, serverVersion) < 0;
190
+ return {
191
+ kind: behind ? "extension-behind" : "server-behind",
192
+ server: serverVersion,
193
+ extension: extensionVersion,
194
+ };
195
+ }
196
+
197
+ // --- the one-line notice ---------------------------------------------------
198
+
199
+ const EXTENSION_URL = "https://github.com/abhinav162/poltertab/releases/latest";
200
+
201
+ // Surfaced once per server process on a tool response, because it is the only
202
+ // channel the user reliably reads: doctor and the popup both require them to
203
+ // already suspect something is wrong.
204
+ function notice({ current, latest, updateAvailable, skew: sk }) {
205
+ const parts = [];
206
+ if (sk && sk.kind === "extension-behind") {
207
+ parts.push(
208
+ `The Chrome extension (${sk.extension}) is older than this server (${sk.server}) — ` +
209
+ `browser commands may fail in ways that look like missing elements. ` +
210
+ `Reload it from ${EXTENSION_URL}`,
211
+ );
212
+ } else if (sk && sk.kind === "server-behind") {
213
+ parts.push(
214
+ `The Chrome extension (${sk.extension}) is newer than this server (${sk.server}). ` +
215
+ `Update with: npm update -g poltertab`,
216
+ );
217
+ }
218
+ if (updateAvailable && latest) {
219
+ parts.push(
220
+ `PolterTab ${latest} is available (running ${current}). ` +
221
+ `Update with: npm update -g poltertab && poltertab setup`,
222
+ );
223
+ }
224
+ if (!parts.length) return null;
225
+ return `[PolterTab] ${parts.join(" ")} (Mention this to the user once, then carry on.)`;
226
+ }
227
+
228
+ module.exports = {
229
+ compareVersions,
230
+ isNewer,
231
+ parse,
232
+ readExtensionState,
233
+ recordExtension,
234
+ fetchLatest,
235
+ checkForUpdate,
236
+ skew,
237
+ notice,
238
+ disabled,
239
+ EXTENSION_URL,
240
+ CACHE_FILE,
241
+ STATE_FILE,
242
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Let any AI assistant drive and scrape your existing Chrome profile — no headless browser, no complex setup.",
5
5
  "bin": {
6
6
  "poltertab": "./bin/poltertab.js"