impel-cli 0.20.49 → 0.20.51

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.51 — Restore Claude Tasks in the sidebar
4
+
5
+ - Restores one embedded Tasks row in managed Claude's existing sidebar,
6
+ directly after the Home/Code switcher and before New and Customize.
7
+ - Uses the same confined `WebContentsView` board lifecycle as ChatGPT while
8
+ keeping the native fallback disabled, so Tasks cannot cover Claude during
9
+ startup or create a second control window.
10
+ - Promotes Claude's real pinned-app renderer smoke from boot-only to a required
11
+ UI contract: working surface, exact row placement, interactive tenant-bound
12
+ board, restored host navigation, screenshots, and credential/egress checks.
13
+ - Advances the managed profile contract to v42 so installed Claude apps rewrite
14
+ the repaired preload and restart without rebuilding their vendor bundles.
15
+
16
+ ## 0.20.50 — Restore ChatGPT Tasks safely
17
+
18
+ - Restores one embedded Tasks entry in the managed ChatGPT sidebar while
19
+ keeping Claude's production preload inert and preserving normal ChatGPT
20
+ startup and navigation.
21
+ - Disables the native fallback in the production ChatGPT profile, so Tasks
22
+ cannot create a second control view or cover the vendor app during startup.
23
+ - Debounces sidebar discovery, makes attached-control checks constant-time,
24
+ and removes the outer reinjection retry chain that could repeatedly scan the
25
+ renderer while its sidebar anchor was late.
26
+ - Injects and accepts Tasks navigation only in the main vendor frame; subframes
27
+ cannot open, resize, or bootstrap the authenticated board.
28
+ - Deduplicates paired Electron navigation events and reuses the loaded board,
29
+ keeping one tenant-bound credential exchange across ordinary hide/reopen
30
+ cycles while allowing an expired or failed board session to recover.
31
+ - Advances the managed profile contract to v41 so existing macOS ChatGPT apps
32
+ rewrite and reload the repaired preload without rebuilding vendor bundles.
33
+ - Adds a known-bad `0.20.48` control and a real packaged-app gate with screenshot
34
+ artifacts for the normal surface, one Tasks tab, and the interactive board;
35
+ it also externally observes the board target becoming compositor-hidden when
36
+ host navigation is restored, with zero fallback controls and one bootstrap.
37
+
3
38
  ## 0.20.49 — Restore managed app startup
4
39
 
5
40
  - Restores normal ChatGPT and Claude startup by disabling the experimental
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.49",
3
+ "version": "0.20.51",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -660,9 +660,12 @@ export function appPaths(homeDir = os.homedir(), tenantId = null, {
660
660
  };
661
661
  }
662
662
 
663
- function writeDesktopTasksProfileAssets(root, appUrl) {
663
+ function writeDesktopTasksProfileAssets(root, appUrl, { enableEmbed = false } = {}) {
664
664
  const assets = desktopTasksAssetPaths(root);
665
- writeAtomic(assets.mainPreload, desktopTasksMainPreload(appUrl), 0o600);
665
+ writeAtomic(assets.mainPreload, desktopTasksMainPreload(appUrl, {
666
+ enableEmbed,
667
+ enableNativeFallback: false,
668
+ }), 0o600);
666
669
  writeAtomic(assets.viewPreload, desktopTasksViewPreload(), 0o600);
667
670
  // This exact path is Impel-owned. Remove the former local MCP Apps host when
668
671
  // a profile is regenerated; old released clients keep the immutable server
@@ -879,6 +882,7 @@ export function installManagedAppFiles({
879
882
  writeDesktopTasksProfileAssets(
880
883
  paths[target].root,
881
884
  config.appUrl || RUNTIME_BRAND.controlPlane.defaultOrigin,
885
+ { enableEmbed: true },
882
886
  );
883
887
  // Non-bundle targets are the background-refresh / fast-open path: configs,
884
888
  // token helper, catalog, and manifest only. Bundle swaps require the app
@@ -87,6 +87,7 @@ export function parseDesktopTasksCredential(output, tenantId, {
87
87
 
88
88
  const NAV_ANCHOR_RULES_SOURCE = JSON.stringify(DESKTOP_TASKS_NAV_ANCHOR_RULES);
89
89
  const TASKS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M9 3v18"></path><path d="M15 3v18"></path></svg>';
90
+ const NAVIGATION_NONCE_PLACEHOLDER = "IMPEL_DESKTOP_TASKS_NAVIGATION_NONCE_PLACEHOLDER";
90
91
 
91
92
  const NAVIGATION_SOURCE = String.raw`(() => {
92
93
  const NAV_ID = "impel-desktop-tasks-nav";
@@ -104,6 +105,9 @@ const NAVIGATION_SOURCE = String.raw`(() => {
104
105
  let active = false;
105
106
  let lastAnchor = null;
106
107
  let readySignalled = false;
108
+ let ensureTimer = null;
109
+ const signalSequenceKey = "__impelDesktopTasksSignalSequence";
110
+ const navigationNonce = ${JSON.stringify(NAVIGATION_NONCE_PLACEHOLDER)};
107
111
  const roots = () => {
108
112
  const result = [document];
109
113
  for (let index = 0; index < result.length; index += 1) {
@@ -157,6 +161,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
157
161
  };
158
162
  const signal = (action) => {
159
163
  const query = new URLSearchParams(Object.entries(mainBounds()).map(([key, value]) => [key, String(value)]));
164
+ const signalSequence = Number(window[signalSequenceKey] || 0) + 1;
165
+ window[signalSequenceKey] = signalSequence;
166
+ query.set("event", Date.now().toString(36) + "-" + signalSequence.toString(36));
167
+ query.set("nonce", navigationNonce);
160
168
  location.href = "impel-tasks://" + action + "?" + query;
161
169
  };
162
170
  const select = (selected) => {
@@ -210,6 +218,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
210
218
  return button;
211
219
  };
212
220
  const ensureNav = () => {
221
+ if (nav?.isConnected) {
222
+ select(active);
223
+ return;
224
+ }
213
225
  let rule = null;
214
226
  let anchor = null;
215
227
  for (const candidate of RULES) {
@@ -253,11 +265,19 @@ const NAVIGATION_SOURCE = String.raw`(() => {
253
265
  const onResize = () => { if (active) signal("layout"); };
254
266
  window.addEventListener("click", onClick, true);
255
267
  window.addEventListener("resize", onResize);
256
- const observer = new MutationObserver(() => ensureNav());
268
+ const scheduleEnsure = () => {
269
+ if (ensureTimer !== null) return;
270
+ ensureTimer = setTimeout(() => {
271
+ ensureTimer = null;
272
+ ensureNav();
273
+ }, 250);
274
+ };
275
+ const observer = new MutationObserver(scheduleEnsure);
257
276
  observer.observe(document.body, { childList: true, subtree: true });
258
277
  ensureNav();
259
278
  window.__impelDesktopTasksCleanup = () => {
260
279
  observer.disconnect();
280
+ if (ensureTimer !== null) clearTimeout(ensureTimer);
261
281
  window.removeEventListener("click", onClick, true);
262
282
  window.removeEventListener("resize", onResize);
263
283
  nav?.remove();
@@ -266,16 +286,40 @@ const NAVIGATION_SOURCE = String.raw`(() => {
266
286
  delete window.__impelDesktopTasksSetActive;
267
287
  };
268
288
  const shellControls = controls().filter((element) => visible(element) && Boolean(label(element)));
289
+ const namedControl = (name) => shellControls.find((element) => [
290
+ element.getAttribute("aria-label"),
291
+ element.getAttribute("title"),
292
+ element.textContent,
293
+ ].some((value) => (value || "").replace(/\s+/g, " ").trim() === name)) || null;
294
+ const bounds = (element) => {
295
+ if (!element) return null;
296
+ const value = element.getBoundingClientRect();
297
+ return {
298
+ top: Math.round(value.top),
299
+ bottom: Math.round(value.bottom),
300
+ left: Math.round(value.left),
301
+ right: Math.round(value.right),
302
+ };
303
+ };
269
304
  return {
270
305
  anchor: lastAnchor ? label(lastAnchor) : null,
271
306
  connected: Boolean(nav?.isConnected),
272
307
  shellReady: shellControls.length >= 2,
308
+ placement: {
309
+ tasks: bounds(nav),
310
+ code: bounds(namedControl("Code")),
311
+ new: bounds(namedControl("New")),
312
+ customize: bounds(namedControl("Customize")),
313
+ },
273
314
  };
274
315
  })();`;
275
316
 
276
317
  const NATIVE_NAVIGATION_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none';script-src 'unsafe-inline';style-src 'unsafe-inline'"><style>:root{color-scheme:light dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}button{width:100%;height:100%;display:flex;align-items:center;gap:9px;border:0;border-radius:8px;padding:0 12px;background:color-mix(in srgb,Canvas 92%,CanvasText 8%);color:CanvasText;cursor:pointer}svg{width:16px;height:16px}</style></head><body><button id="tasks" type="button" aria-label="Tasks">${TASKS_ICON}<span>Tasks</span></button><script>const button=document.getElementById("tasks");button.addEventListener("click",()=>{button.dataset.active="true";window.impelDesktopTasks.show()});window.__impelDesktopTasksSetActive=active=>{button.dataset.active=String(Boolean(active))}</script></body></html>`;
277
318
 
278
- export function desktopTasksMainPreload(appUrl, { enableEmbed = false } = {}) {
319
+ export function desktopTasksMainPreload(appUrl, {
320
+ enableEmbed = false,
321
+ enableNativeFallback = false,
322
+ } = {}) {
279
323
  const appOrigin = desktopTasksAppOrigin(appUrl);
280
324
  if (!enableEmbed) return `"use strict";\n`;
281
325
  const parserSource = parseDesktopTasksCredential.toString();
@@ -294,22 +338,33 @@ if (
294
338
  const fs = require("node:fs");
295
339
  const path = require("node:path");
296
340
  const { spawn } = require("node:child_process");
341
+ const { randomBytes } = require("node:crypto");
297
342
  const navSource = ${JSON.stringify(NAVIGATION_SOURCE)};
343
+ const navigationNoncePlaceholder = ${JSON.stringify(NAVIGATION_NONCE_PLACEHOLDER)};
298
344
  const nativeNavUrl = "data:text/html;charset=utf-8," + encodeURIComponent(${JSON.stringify(NATIVE_NAVIGATION_HTML)});
299
345
  const appOrigin = ${JSON.stringify(appOrigin)};
300
346
  const bootstrapUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOOTSTRAP_PATH)};
301
347
  const tasksUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOARD_PATH)};
302
348
  const readyMarker = ${JSON.stringify(DESKTOP_TASKS_READY_MARKER)};
349
+ const nativeFallbackEnabled = ${JSON.stringify(enableNativeFallback)};
350
+ const smokeMode = process.env.IMPEL_DESKTOP_TASKS_SMOKE === "1";
351
+ const smokeDirectory = process.env.IMPEL_DESKTOP_TASKS_SMOKE_DIR || "";
352
+ const boardSessionMaxAgeMs = 7 * 60 * 60 * 1000;
303
353
  const tenantId = process.env.IMPEL_DESKTOP_TASKS_TENANT;
304
354
  const nativeShowChannel = "impel-desktop-tasks:native-show";
305
355
  const runtimeStatusPath = path.join(__dirname, "runtime-status.json");
306
356
  const escapeRegex = ${escapeRegexSource};
307
357
  const parseCredential = ${parserSource};
308
- const record = (stage) => {
358
+ const runtimeHistory = [];
359
+ const record = (stage, details = null) => {
309
360
  try {
361
+ runtimeHistory.push({ stage, details, at: new Date().toISOString() });
362
+ if (runtimeHistory.length > 32) runtimeHistory.shift();
310
363
  fs.writeFileSync(runtimeStatusPath, JSON.stringify({
311
364
  schemaVersion: 2,
312
365
  stage,
366
+ details,
367
+ history: runtimeHistory,
313
368
  processType: process.type || null,
314
369
  electronVersion: process.versions.electron || null,
315
370
  pid: process.pid,
@@ -317,6 +372,14 @@ if (
317
372
  }, null, 2) + "\\n", { mode: 0o600 });
318
373
  } catch {}
319
374
  };
375
+ const captureSmokeProof = async (name, capturer) => {
376
+ if (!smokeMode || !smokeDirectory || typeof capturer?.capturePage !== "function") return;
377
+ try {
378
+ fs.mkdirSync(smokeDirectory, { recursive: true });
379
+ const image = await capturer.capturePage();
380
+ fs.writeFileSync(path.join(smokeDirectory, name), image.toPNG(), { mode: 0o600 });
381
+ } catch { record("smoke-capture-failed", { name }); }
382
+ };
320
383
  const token = () => new Promise((resolve, reject) => {
321
384
  const childEnvironment = { ...process.env };
322
385
  for (const name of ["ANTHROPIC_API_KEY", "CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY"]) {
@@ -383,7 +446,7 @@ if (
383
446
  const stateFor = (window) => {
384
447
  let state = windows.get(window.id);
385
448
  if (!state) {
386
- state = { view: null, bounds: null, visible: false, nativeNav: null, hasDomNavigation: false, loading: null, bootstrapping: false };
449
+ state = { window, view: null, bounds: null, visible: false, nativeNav: null, hasDomNavigation: false, loaded: false, loadedAt: 0, loading: null, bootstrapping: false, smokeStarted: false };
387
450
  windows.set(window.id, state);
388
451
  }
389
452
  return state;
@@ -425,7 +488,16 @@ if (
425
488
  const errorDocument = "data:text/html;charset=utf-8," + encodeURIComponent(
426
489
  "<!doctype html><html><body style='font:14px system-ui;padding:32px'>Impel Tasks could not be loaded. Hide Tasks, check your connection, and try again.</body></html>",
427
490
  );
491
+ const loadErrorDocument = async (state) => {
492
+ const contents = state.view?.webContents;
493
+ if (!contents || contents.isDestroyed()) return;
494
+ await contents.loadURL(errorDocument).catch(() => {});
495
+ };
428
496
  const loadBoard = async (state) => {
497
+ const contents = state.view?.webContents;
498
+ if (!contents || contents.isDestroyed()) return;
499
+ state.loaded = false;
500
+ state.loadedAt = 0;
429
501
  state.bootstrapping = true;
430
502
  let pat = null;
431
503
  try {
@@ -439,27 +511,61 @@ if (
439
511
  ].join("\\n"),
440
512
  postData: [{ type: "rawData", bytes: Buffer.alloc(0) }],
441
513
  };
442
- await state.view.webContents.loadURL(bootstrapUrl, request);
443
- if (state.view.webContents.getURL() !== bootstrapUrl) throw new Error("bootstrap redirected");
444
- const ready = await state.view.webContents.executeJavaScript(
514
+ await contents.loadURL(bootstrapUrl, request);
515
+ if (contents.isDestroyed() || contents.getURL() !== bootstrapUrl) throw new Error("bootstrap redirected");
516
+ const ready = await contents.executeJavaScript(
445
517
  "document.body?.textContent?.trim() === " + JSON.stringify(readyMarker),
446
518
  true,
447
519
  );
448
520
  if (!ready) throw new Error("bootstrap marker missing");
449
521
  } catch {
450
522
  record("board-bootstrap-failed");
451
- await state.view.webContents.loadURL(errorDocument).catch(() => {});
523
+ await loadErrorDocument(state);
452
524
  return;
453
525
  } finally {
454
526
  pat = null;
455
527
  state.bootstrapping = false;
456
528
  }
457
529
  try {
458
- await state.view.webContents.loadURL(tasksUrl);
530
+ await contents.loadURL(tasksUrl);
531
+ if (contents.isDestroyed()) return;
532
+ state.loaded = true;
533
+ state.loadedAt = Date.now();
459
534
  record("board-loaded");
535
+ if (smokeMode && state.smokeStarted) {
536
+ let boardProof = null;
537
+ const proofDeadline = Date.now() + 15000;
538
+ while (!contents.isDestroyed() && Date.now() < proofDeadline) {
539
+ try {
540
+ boardProof = await contents.executeJavaScript(
541
+ "(() => { const ready = document.querySelector('[data-impel-desktop-board-ready=\\\"true\\\"]') !== null; const action = document.getElementById('board-action'); if (action) action.click(); return {ready,actionClicked:Boolean(action),interactive:Boolean(document.getElementById('board-result')?.textContent?.trim())}; })()",
542
+ true,
543
+ );
544
+ } catch {}
545
+ if (boardProof?.ready && boardProof?.interactive) break;
546
+ await new Promise((resolve) => setTimeout(resolve, 100));
547
+ }
548
+ record("board-smoke-verified", boardProof);
549
+ await captureSmokeProof("claude-tasks-board.png", contents);
550
+ let hostClick = null;
551
+ try {
552
+ hostClick = await state.window.webContents.mainFrame.executeJavaScript(
553
+ "(() => { const norm = value => (value || '').replace(/\\s+/g, ' ').trim(); const visible = element => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }; const controls = [...document.querySelectorAll('button, a, [role=\\\"button\\\"], [role=\\\"link\\\"], [tabindex]')]; const hit = controls.find(element => visible(element) && [element.getAttribute('aria-label'), element.getAttribute('title'), element.textContent].some(value => norm(value) === 'New')); if (!hit) return {clicked:false}; hit.click(); return {clicked:true}; })()",
554
+ true,
555
+ );
556
+ } catch {}
557
+ const restoreDeadline = Date.now() + 3000;
558
+ while (state.visible && Date.now() < restoreDeadline) {
559
+ await new Promise((resolve) => setTimeout(resolve, 50));
560
+ }
561
+ record("navigation-smoke-restored", { hostClick, boardHidden: !state.visible });
562
+ await captureSmokeProof("claude-restored.png", state.window);
563
+ }
460
564
  } catch {
565
+ state.loaded = false;
566
+ state.loadedAt = 0;
461
567
  record("board-load-failed");
462
- await state.view.webContents.loadURL(errorDocument).catch(() => {});
568
+ await loadErrorDocument(state);
463
569
  }
464
570
  };
465
571
  const deactivateNavigation = () => {
@@ -468,9 +574,10 @@ if (
468
574
  }
469
575
  for (const contents of webContents.getAllWebContents()) {
470
576
  if (internalContents.has(contents) || contents.isDestroyed()) continue;
471
- for (const frame of contents.mainFrame?.framesInSubtree || []) {
472
- frame.executeJavaScript("window.__impelDesktopTasksSetActive?.(false)", true).catch(() => {});
473
- }
577
+ contents.mainFrame?.executeJavaScript?.(
578
+ "window.__impelDesktopTasksSetActive?.(false)",
579
+ true,
580
+ )?.catch(() => {});
474
581
  }
475
582
  };
476
583
  const isHostWindow = (window) => {
@@ -498,6 +605,7 @@ if (
498
605
  state.view?.setVisible(false);
499
606
  window.webContents.focus();
500
607
  deactivateNavigation();
608
+ record("board-hidden");
501
609
  };
502
610
  const isPrimaryWindow = (window) => {
503
611
  return isHostWindow(window) && window === primaryHostWindow() && window.isVisible?.() !== false;
@@ -520,6 +628,8 @@ if (
520
628
  },
521
629
  });
522
630
  state.view = view;
631
+ state.loaded = false;
632
+ state.loadedAt = 0;
523
633
  internalContents.add(view.webContents);
524
634
  configureSession(view.webContents.session);
525
635
  window.contentView.addChildView(view);
@@ -547,7 +657,12 @@ if (
547
657
  view.webContents.on("destroyed", () => {
548
658
  internalContents.delete(view.webContents);
549
659
  try { window.contentView.removeChildView(view); } catch {}
550
- if (state.view === view) state.view = null;
660
+ if (state.view === view) {
661
+ state.view = null;
662
+ state.loaded = false;
663
+ state.loadedAt = 0;
664
+ state.loading = null;
665
+ }
551
666
  });
552
667
  return state;
553
668
  };
@@ -560,7 +675,20 @@ if (
560
675
  state.view.webContents.focus();
561
676
  if (state.visible) return;
562
677
  state.visible = true;
563
- state.loading = loadBoard(state).finally(() => { state.loading = null; });
678
+ let boardRoute = false;
679
+ try {
680
+ const current = new URL(state.view.webContents.getURL());
681
+ boardRoute = current.origin === appOrigin
682
+ && (current.pathname === tasksUrl.slice(appOrigin.length)
683
+ || current.pathname.startsWith(tasksUrl.slice(appOrigin.length) + "/"));
684
+ } catch {}
685
+ const reusableBoard = state.loaded
686
+ && Date.now() - state.loadedAt < boardSessionMaxAgeMs
687
+ && boardRoute;
688
+ if (!reusableBoard) state.loaded = false;
689
+ if (!state.loaded && !state.loading) {
690
+ state.loading = loadBoard(state).finally(() => { state.loading = null; });
691
+ }
564
692
  record("board-shown");
565
693
  };
566
694
  const layoutBoard = (window, bounds = null) => {
@@ -661,43 +789,132 @@ if (
661
789
  const attachContents = (contents) => {
662
790
  if (!contents || contents.isDestroyed() || contents.__impelDesktopTasksAttached || internalContents.has(contents)) return;
663
791
  contents.__impelDesktopTasksAttached = true;
664
- const inject = (attempt = 0) => {
665
- if (internalContents.has(contents)) return;
666
- Promise.all((contents.mainFrame?.framesInSubtree || []).map((frame) => frame.executeJavaScript(navSource, true)))
667
- .then((results) => {
792
+ record("renderer-attached", {
793
+ type: typeof contents.getType === "function" ? contents.getType() : null,
794
+ hasMainFrame: Boolean(contents.mainFrame),
795
+ });
796
+ const navigationNonce = randomBytes(32).toString("base64url");
797
+ const contentsNavSource = navSource.replace(
798
+ JSON.stringify(navigationNoncePlaceholder),
799
+ JSON.stringify(navigationNonce),
800
+ );
801
+ let injectionPending = false;
802
+ let navigationReady = false;
803
+ let injectionAttempts = 0;
804
+ let injectionTimer = null;
805
+ const inject = () => {
806
+ if (navigationReady || injectionPending || internalContents.has(contents) || contents.isDestroyed()) return;
807
+ injectionAttempts += 1;
808
+ const frame = contents.mainFrame;
809
+ if (!frame || typeof frame.executeJavaScript !== "function") {
810
+ if (injectionAttempts < 120) scheduleInject(500);
811
+ return;
812
+ }
813
+ injectionPending = true;
814
+ frame.executeJavaScript(contentsNavSource, true)
815
+ .then(async (result) => {
668
816
  const window = ownerWindow(contents);
669
817
  if (!window || window !== primaryHostWindow()) return;
670
818
  const state = stateFor(window);
671
- const shellReady = results.some((result) => result?.shellReady);
672
- if (results.some((result) => result?.anchor)) {
819
+ record("navigation-probed", result || null);
820
+ const placementReady = Boolean(
821
+ result?.placement?.tasks
822
+ && result?.placement?.code
823
+ && result?.placement?.new
824
+ && result?.placement?.customize,
825
+ );
826
+ navigationReady = Boolean(result?.connected && (!smokeMode || placementReady));
827
+ if (result?.anchor) {
673
828
  markDomNavigation(window);
674
- } else if (!shellReady && attempt < 30) setTimeout(() => inject(attempt + 1), 1000);
675
- else if (shellReady && !state.hasDomNavigation && !hasDomNavigation) ensureNativeNav(window);
676
- else if (!shellReady) record("navigation-shell-unready");
829
+ } else if (result?.shellReady && !state.hasDomNavigation && !hasDomNavigation && nativeFallbackEnabled) {
830
+ ensureNativeNav(window);
831
+ }
832
+ if (
833
+ smokeMode
834
+ && placementReady
835
+ && !state.smokeStarted
836
+ ) {
837
+ state.smokeStarted = true;
838
+ await captureSmokeProof("claude-navigation.png", window);
839
+ let clicked = false;
840
+ try {
841
+ clicked = await frame.executeJavaScript(
842
+ "Boolean(document.getElementById('impel-desktop-tasks-nav')?.click?.() || true)",
843
+ true,
844
+ );
845
+ } catch {}
846
+ record("navigation-smoke-clicked", { clicked: Boolean(clicked), placement: result.placement || null });
847
+ }
677
848
  })
678
- .catch(() => record("navigation-injection-failed"));
849
+ .catch(() => record("navigation-injection-failed"))
850
+ .finally(() => {
851
+ injectionPending = false;
852
+ if (!navigationReady && injectionAttempts < 120 && !contents.isDestroyed()) scheduleInject(500);
853
+ });
854
+ };
855
+ // Claude can create its renderer while this main preload is still being
856
+ // installed. In that race isLoadingMainFrame() is true but the one
857
+ // did-finish-load edge has already crossed, leaving the injection dormant
858
+ // forever. Probe immediately and at each renderer-ready edge; navSource
859
+ // is idempotent and its MutationObserver handles the later SPA mount.
860
+ const scheduleInject = (delay = 0) => {
861
+ if (navigationReady || injectionPending || injectionTimer !== null) return;
862
+ injectionTimer = setTimeout(() => {
863
+ injectionTimer = null;
864
+ inject();
865
+ }, delay);
679
866
  };
680
- contents.on("did-finish-load", () => inject());
681
- contents.on("did-frame-finish-load", () => inject());
682
- if (!contents.isLoadingMainFrame()) setTimeout(() => inject(), 0);
683
- let handledNavigation = null;
867
+ contents.on("dom-ready", () => scheduleInject());
868
+ contents.on("did-finish-load", () => scheduleInject());
869
+ contents.once("destroyed", () => {
870
+ if (injectionTimer !== null) clearTimeout(injectionTimer);
871
+ injectionTimer = null;
872
+ });
873
+ scheduleInject();
874
+ const handledNavigationEvents = new Set();
875
+ let handledLegacyNavigation = null;
684
876
  const handleNavigation = (event, url) => {
685
877
  if (!String(url).startsWith("impel-tasks://")) return;
686
878
  event.preventDefault();
687
879
  const window = ownerWindow(contents);
688
880
  if (!window || window !== primaryHostWindow()) return;
689
- if (handledNavigation === url) return;
690
- handledNavigation = url;
691
- queueMicrotask(() => { if (handledNavigation === url) handledNavigation = null; });
692
- let action;
693
- try { action = new URL(url).hostname; } catch { return; }
881
+ let navigation;
882
+ try { navigation = new URL(url); } catch { return; }
883
+ if (navigation.searchParams.get("nonce") !== navigationNonce) return;
884
+ const action = navigation.hostname;
885
+ const eventId = navigation.searchParams.get("event");
886
+ if (eventId) {
887
+ const navigationKey = action + ":" + eventId;
888
+ if (handledNavigationEvents.has(navigationKey)) return;
889
+ handledNavigationEvents.add(navigationKey);
890
+ if (handledNavigationEvents.size > 128) {
891
+ handledNavigationEvents.delete(handledNavigationEvents.values().next().value);
892
+ }
893
+ } else {
894
+ if (handledLegacyNavigation === url) return;
895
+ handledLegacyNavigation = url;
896
+ queueMicrotask(() => { if (handledLegacyNavigation === url) handledLegacyNavigation = null; });
897
+ }
694
898
  if (action === "ready") markDomNavigation(window);
695
899
  else if (action === "hide") hide(window);
696
900
  else if (action === "show") show(window, parseBounds(url, window));
697
901
  else if (action === "layout") layoutBoard(window, parseBounds(url, window));
698
902
  };
699
- contents.on("will-navigate", handleNavigation);
700
- contents.on("will-frame-navigate", handleNavigation);
903
+ const eventUrl = (event, deprecatedUrl) => (
904
+ typeof event?.url === "string" ? event.url : String(deprecatedUrl || "")
905
+ );
906
+ contents.on("will-navigate", (event, deprecatedUrl) => {
907
+ handleNavigation(event, eventUrl(event, deprecatedUrl));
908
+ });
909
+ contents.on("will-frame-navigate", (event, deprecatedUrl, _isInPlace, deprecatedIsMainFrame) => {
910
+ const url = eventUrl(event, deprecatedUrl);
911
+ if (!url.startsWith("impel-tasks://")) return;
912
+ event.preventDefault();
913
+ const isMainFrame = typeof event?.isMainFrame === "boolean"
914
+ ? event.isMainFrame
915
+ : deprecatedIsMainFrame === true;
916
+ if (isMainFrame) handleNavigation(event, url);
917
+ });
701
918
  };
702
919
  const attachWindow = (window) => {
703
920
  if (!window || window.isDestroyed() || window.getParentWindow?.() || window.__impelDesktopTasksWindowAttached) return;
@@ -1,6 +1,7 @@
1
1
  // Fleet generation shared by managed-profile writers and upstream clients.
2
2
  // Keep this isolated from apps.js so latency-sensitive transports do not load
3
3
  // desktop bundle machinery just to identify their managed config contract.
4
- // v40 renders the Tasks fallback inside the vendor window and waits for the app
5
- // shell, so no child BrowserWindow can pin ChatGPT's transient startup splash.
6
- export const CURRENT_CONFIG_VERSION = 40;
4
+ // v42 restores Claude's embedded Tasks entry through its real sidebar, using
5
+ // the same fallback-free, tenant-bound board lifecycle already enabled for
6
+ // managed ChatGPT profiles.
7
+ export const CURRENT_CONFIG_VERSION = 42;