tabctl 0.6.0-rc.3 → 0.6.0-rc.4

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
@@ -234,21 +234,24 @@ See [CLI.md](CLI.md#configuration) for full details.
234
234
  - Undo log: `<dataDir>/undo.jsonl` (default: `~/.local/state/tabctl/undo.jsonl`)
235
235
  - Browser-state history DB: `<dataDir>/state.db` (default: `~/.local/state/tabctl/state.db`)
236
236
  - Profile registry: `<configDir>/profiles.json`
237
- - WSL TCP port file: `<dataDir>/tcp-port` (written by the Windows host)
237
+ - Windows pipe endpoint file: `<dataDir>/pipe-endpoint`
238
238
 
239
239
  ## Windows + WSL transport
240
240
 
241
- On Windows, the host exposes a dual endpoint model:
241
+ On Windows, the host exposes a named-pipe endpoint model:
242
242
  - Windows native clients use a named pipe endpoint (`\\.\pipe\tabctl-<hash>`).
243
- - WSL/Linux clients use `tcp://127.0.0.1:<port>`, with the host writing `<dataDir>/tcp-port`.
243
+ - WSL/Linux clients use a Windows named-pipe bridge; the Windows host publishes `<dataDir>/pipe-endpoint`, and the WSL CLI relays through `powershell.exe`.
244
244
 
245
245
  WSL endpoint discovery (CLI):
246
- 1. `TABCTL_SOCKET` (explicit endpoint); if this is a pipe endpoint in WSL, CLI still prefers discovered TCP.
247
- 2. `TABCTL_TCP_PORT` (forces `127.0.0.1:<port>`).
248
- 3. `tcp-port` file discovery from resolved data dir (and equivalent `/mnt/c/Users/*/.../tabctl/.../tcp-port` locations).
249
- 4. Fallback: `tcp://127.0.0.1:38000`.
246
+ 1. `TABCTL_SOCKET` (explicit endpoint).
247
+ 2. `pipe-endpoint` file discovery from resolved data dir (and equivalent `/mnt/c/Users/*/.../tabctl/.../pipe-endpoint` locations).
250
248
 
251
- Relevant knobs: `TABCTL_SOCKET`, `TABCTL_TCP_PORT`, `TABCTL_PROFILE`, `TABCTL_DATA_DIR`, `TABCTL_STATE_DIR`, `TABCTL_CONFIG_DIR`.
249
+ WSL named-pipe mode:
250
+ - This is the default WSL transport now.
251
+ - The CLI discovers the pipe endpoint from `<dataDir>/pipe-endpoint` (including the mirrored `/mnt/c/Users/*/...` candidate paths used for other WSL bridge files).
252
+ - TCP is disabled for the WSL transport path.
253
+
254
+ Relevant knobs: `TABCTL_SOCKET`, `TABCTL_PROFILE`, `TABCTL_DATA_DIR`, `TABCTL_STATE_DIR`, `TABCTL_CONFIG_DIR`.
252
255
 
253
256
  ## Troubleshooting (setup/ping on Windows + WSL)
254
257
 
@@ -258,9 +261,9 @@ Relevant knobs: `TABCTL_SOCKET`, `TABCTL_TCP_PORT`, `TABCTL_PROFILE`, `TABCTL_DA
258
261
  - Runtime command runs can auto-sync extension files when host/extension versions drift; rerun `tabctl query 'mutation { reloadExtension { reloading } }'` if the browser does not pick up changes immediately.
259
262
  - For local release-like testing while developing, force runtime sync behavior with `TABCTL_AUTO_SYNC_MODE=release-like`.
260
263
  - Disable runtime sync entirely with `TABCTL_AUTO_SYNC_MODE=off`.
261
- - `tabctl ping --json` is the canonical runtime version check (`versionsInSync`, `hostBaseVersion`, `baseVersion`).
262
- - Version metadata is intentionally health-only: regular GraphQL payloads do not include version fields unless you explicitly query health surfaces.
263
- - `tabctl ping` returns connect errors (`ENOENT`, `ECONNREFUSED`, timeout): ensure extension is loaded and active, rerun `tabctl setup`, and in WSL verify `TABCTL_TCP_PORT` or `<dataDir>/tcp-port` matches a listening localhost port.
264
+ - `tabctl ping --json` is a host connectivity/health check; use it to confirm the native host is reachable and healthy.
265
+ - Version metadata is intentionally health-only: regular GraphQL payloads do not include version fields unless you explicitly query health surfaces, which may expose fields such as `versionsInSync`, `hostBaseVersion`, and `baseVersion`.
266
+ - `tabctl ping` returns connect errors (`ENOENT`, `ECONNREFUSED`, timeout): ensure extension is loaded and active, rerun `tabctl setup`, and in WSL verify the profile data dir contains a current `pipe-endpoint` file.
264
267
  - `tabctl doctor --fix --json` includes per-profile connectivity diagnostics in `data.profiles[].connectivity`; if ping remains unhealthy after local repairs, follow `manualSteps`.
265
268
 
266
269
  Local release-like sync test recipe:
@@ -271,7 +274,7 @@ tabctl setup --browser edge --extension-id <extension-id> --release-tag v0.5.2
271
274
  # 2) Run the current binary with forced release-like auto-sync
272
275
  TABCTL_AUTO_SYNC_MODE=release-like cargo run --manifest-path rust/Cargo.toml -p tabctl -- query '{ tabs { total } }'
273
276
 
274
- # 3) Verify host/extension base versions are back in sync
277
+ # 3) Verify host connectivity/health after auto-sync
275
278
  tabctl ping --json
276
279
  ```
277
280
 
@@ -323,8 +326,7 @@ Policy is shared across all profiles.
323
326
  ## Security
324
327
  - The native host is locked to your extension ID.
325
328
  - All data stays local; no external API keys are used.
326
- - TCP connections (used for WSL ↔ Windows communication) are secured with a per-session auth token. The host generates a random token on startup; the CLI reads it automatically. See [CLI.md](CLI.md) for details.
327
- - TCP transport is available on all platforms via `TABCTL_HOST_TCP=1` (host) and `TABCTL_TRANSPORT=tcp` (CLI). All TCP connections are authenticated. See [CLI.md](CLI.md) for details.
329
+ - WSL ↔ Windows communication uses a local named-pipe bridge via `powershell.exe`; no TCP fallback is used on that path.
328
330
 
329
331
  ## Development
330
332
 
@@ -342,8 +344,20 @@ npm test # unit tests
342
344
  Rust-only validation:
343
345
  ```bash
344
346
  npm run rust:verify
347
+ npm run check:targets # local cross-target cfg/type check
345
348
  ```
346
349
 
350
+ On macOS, `npm run check:targets` can use Zig for the C cross-compiler needed
351
+ by `libsqlite3-sys`:
352
+
353
+ ```bash
354
+ brew install zig
355
+ ```
356
+
357
+ The script auto-detects Zig outside CI and wires the Linux/Windows C compiler
358
+ environment for the check. The pre-push hook does not run this optional check;
359
+ run it manually when you want local cross-target coverage.
360
+
347
361
  Browser-backed integration harness (requires built dist artifacts and Chrome):
348
362
  ```bash
349
363
  npm run test:integration
@@ -369,8 +383,14 @@ Pre-release staging flow:
369
383
  - `bump:rc` promotes alpha to `x.y.z-rc.1` (or increments RC)
370
384
  - `bump:stable` drops the prerelease suffix for final stable publish
371
385
 
372
- Release publishing (`.github/workflows/publish.yml`) enforces:
386
+ Release automation:
387
+ - Run the **Prepare Release** workflow to choose or auto-detect the next version and open a release PR.
388
+ - When that PR merges, **Tag Release** creates `v<version>` and dispatches **Release**.
389
+ - The root `package.json` version and `optionalDependencies.tabctl-win32-x64` are kept in sync by `scripts/bump-version.js`.
390
+
391
+ Release publishing (`.github/workflows/release.yml`) supports both tag pushes and explicit workflow dispatch, and enforces:
373
392
  - Git tag must match `package.json` version (`v<version>`)
393
+ - `package.json.optionalDependencies["tabctl-win32-x64"]` must match `package.json` version
374
394
  - prerelease tags publish to `alpha`/`rc`; stable publishes to `latest`
375
395
  - `npm run build` and `npm test` must pass before publish
376
396
  - release assets include `tabctl-extension.zip` plus `tabctl-extension.zip.sha256`
@@ -474,8 +474,13 @@
474
474
  dirty: parsed.dirty
475
475
  };
476
476
  var KEEPALIVE_ALARM = "tabctl-keepalive";
477
+ var RECONNECT_ALARM = "tabctl-reconnect";
477
478
  var KEEPALIVE_INTERVAL_MINUTES = 1;
478
479
  var BROWSER_STATE_SYNC_DEBOUNCE_MS = 750;
480
+ var RECONNECT_INITIAL_DELAY_MS = 250;
481
+ var RECONNECT_MAX_DELAY_MS = 3e4;
482
+ var RECONNECT_ALARM_MIN_DELAY_MS = 3e4;
483
+ var RECONNECT_STABLE_RESET_MS = 5e3;
479
484
  var screenshot = require_screenshot();
480
485
  var content = require_content();
481
486
  var { delay, executeWithTimeout } = content;
@@ -487,7 +492,10 @@
487
492
  return n;
488
493
  }
489
494
  var state = {
490
- port: null
495
+ port: null,
496
+ reconnectTimer: null,
497
+ reconnectStableTimer: null,
498
+ reconnectAttempt: 0
491
499
  };
492
500
  var browserState = {
493
501
  nextId: 1,
@@ -500,17 +508,71 @@
500
508
  function log(...args) {
501
509
  console.log("[tabctl]", ...args);
502
510
  }
503
- function sendResponse(id, ok, payload) {
504
- if (!state.port) {
511
+ function reconnectDelayMs(attempt) {
512
+ return Math.min(RECONNECT_INITIAL_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS);
513
+ }
514
+ function clearReconnectTimer() {
515
+ if (!state.reconnectTimer) {
516
+ return;
517
+ }
518
+ clearTimeout(state.reconnectTimer);
519
+ state.reconnectTimer = null;
520
+ }
521
+ function clearReconnectAlarm() {
522
+ chrome.alarms.clear(RECONNECT_ALARM);
523
+ }
524
+ function clearReconnectStableTimer() {
525
+ if (!state.reconnectStableTimer) {
526
+ return;
527
+ }
528
+ clearTimeout(state.reconnectStableTimer);
529
+ state.reconnectStableTimer = null;
530
+ }
531
+ function scheduleReconnect(reason) {
532
+ if (state.port || state.reconnectTimer) {
505
533
  return;
506
534
  }
507
- if (ok) {
508
- const data = typeof payload === "object" && payload !== null ? payload : { payload };
509
- state.port.postMessage({ id, ok: true, data });
535
+ const attempt = state.reconnectAttempt;
536
+ const delayMs = reconnectDelayMs(attempt);
537
+ state.reconnectAttempt += 1;
538
+ log("Scheduling native host reconnect", { reason, delayMs, attempt });
539
+ chrome.alarms.create(RECONNECT_ALARM, {
540
+ delayInMinutes: Math.max(delayMs, RECONNECT_ALARM_MIN_DELAY_MS) / 6e4
541
+ });
542
+ state.reconnectTimer = setTimeout(() => {
543
+ state.reconnectTimer = null;
544
+ clearReconnectAlarm();
545
+ connectNative();
546
+ }, delayMs);
547
+ }
548
+ function resetReconnectBackoffAfterStablePort(port) {
549
+ clearReconnectStableTimer();
550
+ state.reconnectStableTimer = setTimeout(() => {
551
+ state.reconnectStableTimer = null;
552
+ if (state.port === port) {
553
+ state.reconnectAttempt = 0;
554
+ }
555
+ }, RECONNECT_STABLE_RESET_MS);
556
+ }
557
+ function ensureKeepaliveAlarm() {
558
+ chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_INTERVAL_MINUTES });
559
+ }
560
+ function sendResponse(port, id, ok, payload) {
561
+ if (!port) {
562
+ log("dropping response because native port is unavailable", { id, ok });
510
563
  return;
511
564
  }
512
- const error = payload instanceof Error ? { message: payload.message, stack: payload.stack } : payload;
513
- state.port.postMessage({ id, ok: false, error });
565
+ try {
566
+ if (ok) {
567
+ const data = typeof payload === "object" && payload !== null ? payload : { payload };
568
+ port.postMessage({ id, ok: true, data });
569
+ return;
570
+ }
571
+ const error = payload instanceof Error ? { message: payload.message, stack: payload.stack } : payload;
572
+ port.postMessage({ id, ok: false, error });
573
+ } catch (error) {
574
+ log("failed to send native response", { id, ok, error });
575
+ }
514
576
  }
515
577
  function connectNative() {
516
578
  if (state.port) {
@@ -518,8 +580,13 @@
518
580
  }
519
581
  try {
520
582
  const port = chrome.runtime.connectNative(HOST_NAME);
583
+ clearReconnectTimer();
584
+ clearReconnectAlarm();
521
585
  state.port = port;
522
- port.onMessage.addListener(handleNativeMessage);
586
+ resetReconnectBackoffAfterStablePort(port);
587
+ port.onMessage.addListener((message) => {
588
+ void handleNativeMessage(port, message);
589
+ });
523
590
  port.onDisconnect.addListener(() => {
524
591
  const lastError = chrome.runtime.lastError;
525
592
  if (lastError) {
@@ -527,12 +594,17 @@
527
594
  } else {
528
595
  log("Native host disconnected");
529
596
  }
530
- state.port = null;
597
+ if (state.port === port) {
598
+ state.port = null;
599
+ }
600
+ clearReconnectStableTimer();
601
+ scheduleReconnect("disconnect");
531
602
  });
532
603
  log("Native host connected");
533
604
  queueBrowserStateSync("startup");
534
605
  } catch (error) {
535
606
  log("Native host connection failed", error);
607
+ scheduleReconnect("connect-failed");
536
608
  }
537
609
  }
538
610
  function nextBrowserStateId() {
@@ -639,7 +711,7 @@
639
711
  windowId: tab.windowId,
640
712
  groupId: tab.groupId,
641
713
  incognito: tab.incognito,
642
- url: tab.url,
714
+ url: tab.url || tab.pendingUrl,
643
715
  title: tab.title,
644
716
  index: tab.index
645
717
  });
@@ -745,20 +817,24 @@
745
817
  }
746
818
  connectNative();
747
819
  registerBrowserStateListeners();
820
+ ensureKeepaliveAlarm();
748
821
  chrome.runtime.onInstalled.addListener(() => {
749
822
  connectNative();
750
- chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_INTERVAL_MINUTES });
823
+ ensureKeepaliveAlarm();
751
824
  });
752
825
  chrome.runtime.onStartup.addListener(() => {
753
826
  connectNative();
754
- chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_INTERVAL_MINUTES });
827
+ ensureKeepaliveAlarm();
755
828
  });
756
829
  chrome.alarms.onAlarm.addListener((alarm) => {
757
830
  if (alarm.name === KEEPALIVE_ALARM) {
758
831
  connectNative();
832
+ } else if (alarm.name === RECONNECT_ALARM) {
833
+ clearReconnectTimer();
834
+ connectNative();
759
835
  }
760
836
  });
761
- async function handleNativeMessage(message) {
837
+ async function handleNativeMessage(requestPort, message) {
762
838
  if (!message || typeof message !== "object") {
763
839
  return;
764
840
  }
@@ -768,9 +844,9 @@
768
844
  }
769
845
  try {
770
846
  const data = await handleAction(action, params || {}, id);
771
- sendResponse(id, true, data);
847
+ sendResponse(requestPort, id, true, data);
772
848
  } catch (error) {
773
- sendResponse(id, false, error);
849
+ sendResponse(requestPort, id, false, error);
774
850
  }
775
851
  }
776
852
  async function handleAction(action, params, requestId) {
@@ -790,6 +866,9 @@
790
866
  component: "extension"
791
867
  };
792
868
  case "reload":
869
+ chrome.alarms.create(RECONNECT_ALARM, {
870
+ delayInMinutes: RECONNECT_ALARM_MIN_DELAY_MS / 6e4
871
+ });
793
872
  setTimeout(() => chrome.runtime.reload(), 100);
794
873
  return { reloading: true };
795
874
  // --- Primitives: thin Chrome API wrappers (p: prefix) ---
@@ -877,7 +956,7 @@
877
956
  windowId: win.id,
878
957
  index: tab.index,
879
958
  incognito: win.incognito || false,
880
- url: tab.url,
959
+ url: tab.url || tab.pendingUrl,
881
960
  title: tab.title,
882
961
  active: tab.active,
883
962
  pinned: tab.pinned,
@@ -19,5 +19,5 @@
19
19
  "background": {
20
20
  "service_worker": "background.js"
21
21
  },
22
- "version_name": "0.6.0-rc.3"
22
+ "version_name": "0.6.0-rc.4"
23
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabctl",
3
- "version": "0.6.0-rc.3",
3
+ "version": "0.6.0-rc.4",
4
4
  "description": "CLI tool to manage and analyze browser tabs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  "typescript": "^5.4.5"
53
53
  },
54
54
  "optionalDependencies": {
55
- "tabctl-win32-x64": "0.3.0"
55
+ "tabctl-win32-x64": "0.6.0-rc.4"
56
56
  },
57
57
  "dependencies": {
58
58
  "normalize-url": "^8.1.1"