impel-cli 0.20.24 → 0.20.26

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,24 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.26 — Native task-board navigation
4
+
5
+ - Embeds Tasks in each managed desktop app's content view instead of a
6
+ separate always-on-top window, so selecting another sidebar destination
7
+ reliably dismisses the tenant board and completes the vendor navigation.
8
+ - Removes the redundant desktop frame around the MCP App so the task-board
9
+ toolbar and workspace occupy the complete host content surface.
10
+ - Rebuilds existing managed bundles onto the corrected embedded-view contract.
11
+
12
+ ## 0.20.25 — Embedded tenant task boards
13
+
14
+ - Adds a persistent Tasks destination to the exact managed Claude and
15
+ ChatGPT/Codex desktop wrappers and keeps the native sidebar visible while the
16
+ tenant board is open.
17
+ - Loads the existing Impel MCP App through a sandboxed desktop host while all
18
+ credentials and task RPC stay in the Electron main process.
19
+ - Rebuilds existing managed bundles onto the reviewed preload contract and
20
+ hides auxiliary task windows when the tenant app loses focus.
21
+
3
22
  ## 0.20.24 — Keep Codex adapters silent until terminal
4
23
 
5
24
  - Explicitly forbids every collaboration tool inside active and recovery Codex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.24",
3
+ "version": "0.20.26",
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
@@ -24,6 +24,12 @@ import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSignin
24
24
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
25
25
  import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
26
26
  import { IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS } from "./commands/launch.js";
27
+ import {
28
+ desktopTasksAssetPaths,
29
+ desktopTasksHostHtml,
30
+ desktopTasksMainPreload,
31
+ desktopTasksViewPreload,
32
+ } from "./desktopTasks.js";
27
33
 
28
34
  export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
29
35
  const CHATGPT_CONFIG_START = `# >>> ${RUNTIME_BRAND.cli.command} app managed gateway >>>`;
@@ -284,7 +290,9 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
284
290
  // namespaces for fixed native-agent bindings.
285
291
  // 31: enable the reviewed MCP Apps renderer in pinned Codex Desktop profiles
286
292
  // and install Claude task, notification, and permission lifecycle hooks.
287
- export const CURRENT_CONFIG_VERSION = 31;
293
+ // 32: install a persistent tenant-bound Tasks navigation surface in the
294
+ // managed Claude and ChatGPT/Codex desktop wrappers.
295
+ export const CURRENT_CONFIG_VERSION = 32;
288
296
 
289
297
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
290
298
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -293,7 +301,7 @@ export const CURRENT_CONFIG_VERSION = 31;
293
301
  // — which is what made every `impel update` re-trigger macOS permission
294
302
  // prompts. Bump this ONLY when a code change alters the bytes of a built
295
303
  // bundle; leave it alone for changes that don't touch bundle contents.
296
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-08-01.1";
304
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-08-08.2";
297
305
 
298
306
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
299
307
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -364,6 +372,7 @@ export function bundleIsCurrent(status, {
364
372
  compatibility.patches?.followupBoundAgentEnable === 1
365
373
  && compatibility.patches?.followupBoundAgentFilter === 1
366
374
  && compatibility.patches?.followupBoundAgentChip === 1
375
+ && compatibility.patches?.desktopTasksPreload === 1
367
376
  && claudePatchSetMatchesCompatibility(compatibility)
368
377
  && managedClaudeRendererMatchesCompatibility(status, compatibility)
369
378
  ))
@@ -376,6 +385,7 @@ export function bundleIsCurrent(status, {
376
385
  && compatibility.patches?.threadResumeProviderOverride === 1
377
386
  && compatibility.patches?.structuredProviderFallbackDisabled === 2
378
387
  && compatibility.patches?.tenantDisplayNamePreload === 2
388
+ && compatibility.patches?.desktopTasksPreload === 1
379
389
  )),
380
390
  );
381
391
  }
@@ -414,6 +424,7 @@ const CLAUDE_COMPATIBILITY_PATCH_KEYS = Object.freeze([
414
424
  "followupBoundAgentEnable",
415
425
  "followupBoundAgentFilter",
416
426
  "followupBoundAgentChip",
427
+ "desktopTasksPreload",
417
428
  ]);
418
429
  const CLAUDE_COMPATIBILITY_RENDERER_GROUPS = Object.freeze(["agentMentions"]);
419
430
 
@@ -589,6 +600,14 @@ export function appPaths(homeDir = os.homedir(), tenantId = null, {
589
600
  };
590
601
  }
591
602
 
603
+ function writeDesktopTasksProfileAssets(root) {
604
+ const assets = desktopTasksAssetPaths(root);
605
+ writeAtomic(assets.mainPreload, desktopTasksMainPreload(), 0o600);
606
+ writeAtomic(assets.viewPreload, desktopTasksViewPreload(), 0o600);
607
+ writeAtomic(assets.hostHtml, desktopTasksHostHtml(), 0o600);
608
+ return assets;
609
+ }
610
+
592
611
  function readClaudeSafeStorageMetadata(paths) {
593
612
  const metadataPath = path.join(paths.claude.root, CLAUDE_SAFE_STORAGE_METADATA);
594
613
  try {
@@ -794,6 +813,7 @@ export function installManagedAppFiles({
794
813
 
795
814
  const installed = [];
796
815
  for (const target of targets) {
816
+ writeDesktopTasksProfileAssets(paths[target].root);
797
817
  // Non-bundle targets are the background-refresh / fast-open path: configs,
798
818
  // token helper, catalog, and manifest only. Bundle swaps require the app
799
819
  // to be closed (see quitBlockingApps), so those paths never attempt one.
@@ -810,12 +830,14 @@ export function installManagedAppFiles({
810
830
  resolvedVendorPaths.chatgpt,
811
831
  config.gatewayUrl,
812
832
  homeDir,
833
+ config.tenantId,
813
834
  );
814
835
  else writeVendoredClaudeBundle(
815
836
  paths,
816
837
  resolvedVendorPaths.claude,
817
838
  config.gatewayUrl,
818
839
  claudeSafeStorageName,
840
+ config.tenantId,
819
841
  );
820
842
  if (config.tenantId) {
821
843
  // A successful tenant-specific rebuild supersedes the old global
@@ -1700,13 +1722,13 @@ exec "$NODE" "$CLI" token${tenantArgs}
1700
1722
  writeAtomic(target, script, 0o700);
1701
1723
  }
1702
1724
 
1703
- function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageName) {
1725
+ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageName, tenantId) {
1704
1726
  return withBundleMutationLock(paths.claude.launcher, () => (
1705
- writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName)
1727
+ writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName, tenantId)
1706
1728
  ));
1707
1729
  }
1708
1730
 
1709
- function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName) {
1731
+ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName, tenantId) {
1710
1732
  if (!vendorPath) throw new Error("Claude vendor app is unavailable");
1711
1733
  if (!isVendableVendorApp("claude", vendorPath)) {
1712
1734
  const bundleId = readBundleIdentifier(vendorPath);
@@ -1747,6 +1769,7 @@ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStor
1747
1769
  planUsageRequestPatchCount = patchClaudePlanUsageRequest(asarPath);
1748
1770
  }
1749
1771
  const safeStorageNamePatchCount = patchClaudeSafeStorageName(asarPath);
1772
+ const desktopTasksPreloadPatchCount = patchDesktopTasksPreload(asarPath, "claude");
1750
1773
  const newAsarHash = asarHeaderHash(asarPath);
1751
1774
 
1752
1775
  updateBundleIdentity(plistPath, {
@@ -1765,6 +1788,13 @@ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStor
1765
1788
  setPlistEnvironmentString(plistPath, "CLAUDE_USER_DATA_DIR", paths.claude.userData);
1766
1789
  setPlistEnvironmentString(plistPath, "CLAUDE_CONFIG_DIR", paths.claude.userData);
1767
1790
  setPlistEnvironmentString(plistPath, "IMPEL_CN", safeStorageName);
1791
+ const desktopTasks = desktopTasksAssetPaths(paths.claude.root);
1792
+ setPlistEnvironmentString(plistPath, "IMPEL_DESKTOP_TASKS_NODE", process.execPath);
1793
+ setPlistEnvironmentString(plistPath, "IMPEL_DESKTOP_TASKS_CLI", IMPEL_CLI_ENTRYPOINT);
1794
+ setPlistEnvironmentString(plistPath, "IMPEL_DESKTOP_TASKS_TENANT", tenantId || "");
1795
+ setPlistEnvironmentString(plistPath, "IMPEL_DESKTOP_TASKS_HOST_HTML", desktopTasks.hostHtml);
1796
+ setPlistEnvironmentString(plistPath, "IMPEL_DESKTOP_TASKS_VIEW_PRELOAD", desktopTasks.viewPreload);
1797
+ setPlistEnvironmentString(plistPath, "IMPEL_DT", desktopTasks.mainPreload);
1768
1798
  rebrandElectronHelpers(staging, {
1769
1799
  fromName: "Claude",
1770
1800
  toName: paths.claude.displayName,
@@ -1795,6 +1825,7 @@ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStor
1795
1825
  followupBoundAgentEnable: agentMentionPatches.enable,
1796
1826
  followupBoundAgentFilter: agentMentionPatches.filter,
1797
1827
  followupBoundAgentChip: agentMentionPatches.chip,
1828
+ desktopTasksPreload: desktopTasksPreloadPatchCount,
1798
1829
  },
1799
1830
  rendererAssets: {
1800
1831
  agentMentions: agentMentionPatches.assets,
@@ -1818,13 +1849,13 @@ function claudeUsageCompatibilityEnabled(gatewayUrl) {
1818
1849
  }
1819
1850
  }
1820
1851
 
1821
- function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1852
+ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir, tenantId) {
1822
1853
  return withBundleMutationLock(paths.chatgpt.launcher, () => (
1823
- writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir)
1854
+ writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir, tenantId)
1824
1855
  ));
1825
1856
  }
1826
1857
 
1827
- function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir) {
1858
+ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir, tenantId) {
1828
1859
  if (!vendorPath) throw new Error("ChatGPT/Codex vendor app is unavailable");
1829
1860
  const definition = APP_DEFINITIONS.chatgpt;
1830
1861
  const executableName = definition.executableNames.find((name) => (
@@ -1861,6 +1892,7 @@ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir
1861
1892
  const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
1862
1893
  const threadProviderPatches = patchChatGPTThreadRouting(runtimeAsarPath);
1863
1894
  const structuredFallbackPatchCount = patchChatGPTStructuredProviderFallback(runtimeAsarPath);
1895
+ const desktopTasksPreloadPatchCount = patchDesktopTasksPreload(runtimeAsarPath, "chatgpt");
1864
1896
  const asarHash = asarHeaderHash(runtimeAsarPath);
1865
1897
  const vendorExecutable = path.join(vendorBundle, "Contents", "MacOS", executableName);
1866
1898
  const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
@@ -1877,7 +1909,7 @@ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir
1877
1909
  // the script stays in MacOS as the executable (pre-Tahoe layout).
1878
1910
  writeAtomic(
1879
1911
  path.join(launchHost ? path.join(staging, "Contents", "Resources") : macos, "launch"),
1880
- vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
1912
+ vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl, tenantId),
1881
1913
  0o755,
1882
1914
  );
1883
1915
  if (launchHost) {
@@ -1911,6 +1943,7 @@ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir
1911
1943
  structuredProviderFallbackDisabled: structuredFallbackPatchCount,
1912
1944
  desktopAPIForGatewayAuth: 0,
1913
1945
  tenantDisplayNamePreload: 2,
1946
+ desktopTasksPreload: desktopTasksPreloadPatchCount,
1914
1947
  },
1915
1948
  vendorSignaturesPreserved: true,
1916
1949
  resourceRoot: CHATGPT_RESOURCE_ROOT,
@@ -2131,6 +2164,24 @@ function patchFastModeAuthGate(asarPath) {
2131
2164
  return applyFixedWidthAsarPatches(asarPath, archive, patches, "ChatGPT/Codex Fast-mode eligibility contract");
2132
2165
  }
2133
2166
 
2167
+ function patchDesktopTasksPreload(asarPath, target) {
2168
+ const footer = target === "claude"
2169
+ ? "//# sourceMappingURL=index.pre.js.map\n"
2170
+ : "//# sourceMappingURL=early-bootstrap.js.map";
2171
+ const loader = "require(process.env.IMPEL_DT)";
2172
+ const trailingNewline = footer.endsWith("\n") ? "\n" : "";
2173
+ if (loader.length >= footer.length - trailingNewline.length) {
2174
+ throw new Error("desktop Tasks preload no longer fits the vendor ASAR contract");
2175
+ }
2176
+ const replacement = `${loader.padEnd(footer.length - trailingNewline.length, " ")}${trailingNewline}`;
2177
+ return patchFixedWidthAsarString(
2178
+ asarPath,
2179
+ footer,
2180
+ replacement,
2181
+ `${target === "claude" ? "Claude" : "ChatGPT/Codex"} desktop Tasks preload`,
2182
+ );
2183
+ }
2184
+
2134
2185
  /**
2135
2186
  * Force every desktop app-server thread request onto the tenant's configured
2136
2187
  * Impel provider. The pinned renderer passes null for internal metadata turns,
@@ -2497,8 +2548,9 @@ function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity, executableN
2497
2548
  `;
2498
2549
  }
2499
2550
 
2500
- function vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl) {
2551
+ function vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl, tenantId) {
2501
2552
  const codexAccountBaseUrl = `${gatewayUrl}/chatgpt_passthrough/backend-api`;
2553
+ const desktopTasks = desktopTasksAssetPaths(paths.chatgpt.root);
2502
2554
  return `#!/bin/sh
2503
2555
  set -eu
2504
2556
  umask 077
@@ -2531,6 +2583,12 @@ BROWSER_DATA=${shellQuote(paths.chatgpt.browserData)}
2531
2583
  mkdir -p "$BROWSER_DATA"
2532
2584
  export IMPEL_APP_DISPLAY_NAME=${shellQuote(paths.chatgpt.displayName)}
2533
2585
  export IMPEL_APP_BUNDLE_ID=${shellQuote(paths.chatgpt.bundleIdentifier)}
2586
+ export IMPEL_DESKTOP_TASKS_NODE=${shellQuote(process.execPath)}
2587
+ export IMPEL_DESKTOP_TASKS_CLI=${shellQuote(IMPEL_CLI_ENTRYPOINT)}
2588
+ export IMPEL_DESKTOP_TASKS_TENANT=${shellQuote(tenantId || "")}
2589
+ export IMPEL_DESKTOP_TASKS_HOST_HTML=${shellQuote(desktopTasks.hostHtml)}
2590
+ export IMPEL_DESKTOP_TASKS_VIEW_PRELOAD=${shellQuote(desktopTasks.viewPreload)}
2591
+ export IMPEL_DT=${shellQuote(desktopTasks.mainPreload)}
2534
2592
  PRELOAD="$HERE/../Resources/impel-chatgpt-preload.cjs"
2535
2593
  export NODE_OPTIONS="--require=\\\"$PRELOAD\\\""
2536
2594
  # Electron resolves resources from the Impel wrapper when the nested app is
@@ -2671,6 +2729,7 @@ if (process.type === "browser" && displayName && bundleIdentifier) {
2671
2729
  return loaded;
2672
2730
  };
2673
2731
  }
2732
+
2674
2733
  `;
2675
2734
  }
2676
2735
 
@@ -0,0 +1,1019 @@
1
+ import path from "node:path";
2
+
3
+ export const DESKTOP_TASKS_RESOURCE_URI = "ui://impel/tasks/v1/index.html";
4
+
5
+ export function desktopTasksAssetPaths(root) {
6
+ const directory = path.join(root, "desktop-tasks");
7
+ return {
8
+ directory,
9
+ mainPreload: path.join(directory, "main-preload.cjs"),
10
+ viewPreload: path.join(directory, "view-preload.cjs"),
11
+ hostHtml: path.join(directory, "host.html"),
12
+ };
13
+ }
14
+
15
+ const NAVIGATION_SOURCE = String.raw`(() => {
16
+ const NAV_ID = "impel-desktop-tasks-nav";
17
+ const NAV_SLOT_ID = "impel-desktop-tasks-nav-slot";
18
+ const ACTIVE_CLASS = "impel-desktop-tasks-active";
19
+ 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>';
20
+ if (window.__impelDesktopTasksCleanup) window.__impelDesktopTasksCleanup();
21
+
22
+ let nav = null;
23
+ let slot = null;
24
+ let active = false;
25
+ let lastAnchor = null;
26
+
27
+ const label = (element) => [
28
+ element.getAttribute("aria-label"),
29
+ element.getAttribute("title"),
30
+ element.textContent,
31
+ ].filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
32
+
33
+ const visible = (element) => {
34
+ const rect = element.getBoundingClientRect();
35
+ const style = getComputedStyle(element);
36
+ return rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0
37
+ && rect.left < innerWidth && rect.top < innerHeight
38
+ && style.display !== "none" && style.visibility !== "hidden";
39
+ };
40
+
41
+ const roots = () => {
42
+ const found = [document];
43
+ for (let index = 0; index < found.length; index += 1) {
44
+ for (const element of found[index].querySelectorAll("*")) {
45
+ if (element.shadowRoot && !found.includes(element.shadowRoot)) found.push(element.shadowRoot);
46
+ }
47
+ }
48
+ return found;
49
+ };
50
+
51
+ const queryAll = (selector) => roots().flatMap((root) => [...root.querySelectorAll(selector)]);
52
+ const queryFirst = (selector) => queryAll(selector)[0] || null;
53
+
54
+ const controls = () => queryAll("button, a, [role=\"button\"], [role=\"link\"], [tabindex]")
55
+ .filter((element) => !element.closest("#" + NAV_ID + ", #" + NAV_SLOT_ID));
56
+
57
+ const findControl = (pattern) => {
58
+ const matches = controls().filter((element) => pattern.test(label(element)));
59
+ return matches.find(visible) || matches[0] || null;
60
+ };
61
+
62
+ const findSidebar = () => {
63
+ const explicit = queryFirst("aside.app-shell-left-panel, aside, [data-app-shell-left-panel], nav[aria-label]");
64
+ if (explicit && visible(explicit)) return explicit;
65
+ return null;
66
+ };
67
+
68
+ const findMain = () => {
69
+ const candidates = [
70
+ queryFirst("main[data-app-shell-main-surface]"),
71
+ queryFirst("main.main-surface"),
72
+ queryFirst("main"),
73
+ queryFirst("[role=\"main\"]"),
74
+ ].filter(Boolean);
75
+ return candidates.find(visible) || candidates[0] || null;
76
+ };
77
+
78
+ const mainBounds = () => {
79
+ const main = findMain();
80
+ let rect = main?.getBoundingClientRect();
81
+ const sidebar = findSidebar();
82
+ const side = sidebar?.getBoundingClientRect();
83
+ const anchor = lastAnchor?.getBoundingClientRect();
84
+ const inferred = anchor
85
+ && anchor.left < 48
86
+ && anchor.right >= 180
87
+ && anchor.right < innerWidth * 0.55
88
+ ? anchor.right + 8
89
+ : 0;
90
+ const sidebarRight = side && side.left <= 8 && side.width < innerWidth * 0.55
91
+ ? side.right
92
+ : inferred;
93
+ if (!rect || rect.width < 240 || rect.height < 240) {
94
+ rect = { left: sidebarRight, top: 0, width: innerWidth - sidebarRight, height: innerHeight };
95
+ } else if (sidebarRight > rect.left + 8 && rect.width > innerWidth * 0.7) {
96
+ rect = {
97
+ left: sidebarRight,
98
+ top: rect.top,
99
+ width: innerWidth - sidebarRight,
100
+ height: rect.height,
101
+ };
102
+ }
103
+ return {
104
+ x: Math.max(0, Math.round(rect.left)),
105
+ y: Math.max(0, Math.round(rect.top)),
106
+ width: Math.max(320, Math.round(rect.width)),
107
+ height: Math.max(320, Math.round(rect.height)),
108
+ };
109
+ };
110
+
111
+ const signal = (action) => {
112
+ const bounds = mainBounds();
113
+ const query = new URLSearchParams({
114
+ x: String(bounds.x),
115
+ y: String(bounds.y),
116
+ width: String(bounds.width),
117
+ height: String(bounds.height),
118
+ });
119
+ location.href = "impel-tasks://" + action + "?" + query.toString();
120
+ };
121
+
122
+ const select = (selected) => {
123
+ active = selected;
124
+ if (!nav) return;
125
+ nav.dataset.selected = String(selected);
126
+ nav.setAttribute("aria-current", selected ? "page" : "false");
127
+ nav.classList.toggle("bg-token-list-hover-background", selected);
128
+ nav.classList.toggle(ACTIVE_CLASS, selected);
129
+ };
130
+ window.__impelDesktopTasksSetActive = select;
131
+
132
+ const replaceText = (element) => {
133
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
134
+ let changed = false;
135
+ while (walker.nextNode()) {
136
+ const text = walker.currentNode.nodeValue || "";
137
+ if (!/(?:new\s+(?:chat|thread)|pull requests?|scheduled|plugins?|skills?|cowork|code)\b/i.test(text)) continue;
138
+ walker.currentNode.nodeValue = text.replace(
139
+ /(?:new\s+(?:chat|thread)|pull requests?|scheduled|plugins?|skills?|cowork|code)\b/i,
140
+ "Tasks",
141
+ );
142
+ changed = true;
143
+ }
144
+ return changed;
145
+ };
146
+
147
+ const stripVendorActions = (element) => {
148
+ for (const candidate of [element, ...element.querySelectorAll("*")]) {
149
+ for (const attribute of [...candidate.attributes]) {
150
+ if (attribute.name.startsWith("data-app-action-") || attribute.name === "data-testid") {
151
+ candidate.removeAttribute(attribute.name);
152
+ }
153
+ }
154
+ }
155
+ element.removeAttribute("href");
156
+ element.removeAttribute("target");
157
+ element.querySelectorAll("[id]").forEach((candidate) => candidate.removeAttribute("id"));
158
+ element.querySelectorAll("kbd, [data-slot=\"shortcut\"]").forEach((candidate) => candidate.remove());
159
+ };
160
+
161
+ const makeNav = (template) => {
162
+ const button = template ? template.cloneNode(true) : document.createElement("button");
163
+ stripVendorActions(button);
164
+ button.id = NAV_ID;
165
+ button.type = "button";
166
+ button.setAttribute("aria-label", "Tasks");
167
+ button.setAttribute("title", "Tasks");
168
+ button.classList.add("impel-desktop-tasks-nav");
169
+ if (!replaceText(button)) button.textContent = "Tasks";
170
+ const icon = button.querySelector("svg");
171
+ if (icon) {
172
+ icon.setAttribute("viewBox", "0 0 24 24");
173
+ icon.setAttribute("fill", "none");
174
+ icon.setAttribute("stroke", "currentColor");
175
+ icon.setAttribute("stroke-width", "2");
176
+ icon.setAttribute("stroke-linecap", "round");
177
+ icon.setAttribute("stroke-linejoin", "round");
178
+ icon.innerHTML = '<rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M9 3v18"></path><path d="M15 3v18"></path>';
179
+ }
180
+ else button.insertAdjacentHTML("afterbegin", TASKS_ICON);
181
+ button.addEventListener("click", (event) => {
182
+ event.preventDefault();
183
+ event.stopPropagation();
184
+ select(true);
185
+ signal("show");
186
+ });
187
+ button.addEventListener("keydown", (event) => {
188
+ if (event.key !== "Enter" && event.key !== " ") return;
189
+ event.preventDefault();
190
+ select(true);
191
+ signal("show");
192
+ });
193
+ return button;
194
+ };
195
+
196
+ const placement = (control) => {
197
+ if (!control) return null;
198
+ let child = control;
199
+ let parent = control.parentElement;
200
+ while (parent && parent !== document.body) {
201
+ const style = getComputedStyle(parent);
202
+ if (style.display === "flex" && style.flexDirection === "column") {
203
+ return { stack: parent, child };
204
+ }
205
+ child = parent;
206
+ parent = parent.parentElement;
207
+ }
208
+ return null;
209
+ };
210
+
211
+ const ensureNav = () => {
212
+ const anchor = findControl(/^pull requests?\b/i)
213
+ || findControl(/^(?:start\s+)?new\s+(?:chat|thread)\b/i)
214
+ || findControl(/^scheduled\b/i)
215
+ || findControl(/^plugins?\b/i)
216
+ || findControl(/^cowork\b/i)
217
+ || findControl(/^code\b/i);
218
+ lastAnchor = anchor;
219
+ if (!nav?.isConnected) nav = makeNav(anchor);
220
+ const target = placement(anchor);
221
+ if (target) {
222
+ if (!slot?.isConnected || (slot !== nav && !slot.contains(nav))) {
223
+ slot = target.child === anchor ? nav : target.child.cloneNode(false);
224
+ if (slot !== nav) {
225
+ slot.id = NAV_SLOT_ID;
226
+ slot.removeAttribute("aria-label");
227
+ slot.removeAttribute("title");
228
+ slot.append(nav);
229
+ }
230
+ }
231
+ if (slot.parentElement !== target.stack || slot.nextElementSibling !== target.child) {
232
+ target.stack.insertBefore(slot, target.child);
233
+ }
234
+ } else if (anchor?.parentElement) {
235
+ anchor.insertAdjacentElement("afterend", nav);
236
+ slot = nav;
237
+ } else if (!nav.isConnected) {
238
+ slot = null;
239
+ }
240
+ select(active);
241
+ };
242
+
243
+ const style = document.createElement("style");
244
+ style.id = "impel-desktop-tasks-nav-style";
245
+ style.textContent = [
246
+ "#" + NAV_ID + " svg { width: 16px; height: 16px; flex: 0 0 auto; }",
247
+ "#" + NAV_ID + "." + ACTIVE_CLASS + " { background: color-mix(in srgb, CanvasText 10%, transparent); }",
248
+ "#" + NAV_ID + ":focus-visible { outline: 2px solid #7c8cff; outline-offset: 2px; }",
249
+ ].join("\n");
250
+ document.head.append(style);
251
+
252
+ const onClick = (event) => {
253
+ if (!active || event.target instanceof Element && event.target.closest("#" + NAV_ID)) return;
254
+ const sidebar = findSidebar();
255
+ if (!sidebar || !(event.target instanceof Node) || !sidebar.contains(event.target)) return;
256
+ select(false);
257
+ setTimeout(() => signal("hide"), 0);
258
+ };
259
+ const onResize = () => {
260
+ if (active) signal("layout");
261
+ };
262
+ window.addEventListener("click", onClick, true);
263
+ window.addEventListener("resize", onResize);
264
+
265
+ const observer = new MutationObserver((records) => {
266
+ const external = records.some((record) => {
267
+ const target = record.target instanceof Element ? record.target : record.target?.parentElement;
268
+ return target && !target.closest("#" + NAV_ID + ", #" + NAV_SLOT_ID);
269
+ });
270
+ if (external) ensureNav();
271
+ });
272
+ observer.observe(document.body, { childList: true, subtree: true });
273
+ ensureNav();
274
+
275
+ const navRect = nav?.getBoundingClientRect();
276
+ const result = {
277
+ connected: Boolean(nav?.isConnected),
278
+ visible: Boolean(nav && visible(nav)),
279
+ text: nav?.textContent?.replace(/\s+/g, " ").trim() || null,
280
+ anchor: lastAnchor ? label(lastAnchor) : null,
281
+ bounds: navRect ? {
282
+ x: Math.round(navRect.x),
283
+ y: Math.round(navRect.y),
284
+ width: Math.round(navRect.width),
285
+ height: Math.round(navRect.height),
286
+ } : null,
287
+ };
288
+
289
+ window.__impelDesktopTasksCleanup = () => {
290
+ observer.disconnect();
291
+ window.removeEventListener("click", onClick, true);
292
+ window.removeEventListener("resize", onResize);
293
+ nav?.remove();
294
+ if (slot && slot !== nav) slot.remove();
295
+ style.remove();
296
+ delete window.__impelDesktopTasksSetActive;
297
+ };
298
+ return result;
299
+ })();`;
300
+
301
+ const NATIVE_NAVIGATION_HTML = `<!doctype html>
302
+ <html lang="en">
303
+ <head>
304
+ <meta charset="utf-8">
305
+ <meta name="viewport" content="width=device-width,initial-scale=1">
306
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'">
307
+ <style>
308
+ :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
309
+ * { box-sizing: border-box; }
310
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
311
+ button {
312
+ width: 100%; height: 100%; display: flex; align-items: center; gap: 9px;
313
+ border: 0; border-radius: 8px; padding: 0 12px;
314
+ background: color-mix(in srgb, Canvas 92%, CanvasText 8%); color: CanvasText;
315
+ font: 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
316
+ text-align: left; cursor: pointer;
317
+ }
318
+ button:hover, button[data-active="true"] { background: color-mix(in srgb, CanvasText 13%, Canvas); }
319
+ button:focus-visible { outline: 2px solid #7c8cff; outline-offset: -2px; }
320
+ svg { width: 16px; height: 16px; flex: 0 0 auto; }
321
+ </style>
322
+ </head>
323
+ <body>
324
+ <button id="tasks" type="button" aria-label="Tasks">
325
+ <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>
326
+ <span>Tasks</span>
327
+ </button>
328
+ <script>
329
+ const button = document.getElementById("tasks");
330
+ button.addEventListener("pointerdown", (event) => {
331
+ if (event.button !== 0) return;
332
+ event.preventDefault();
333
+ button.dataset.active = "true";
334
+ window.impelDesktopTasks.show();
335
+ });
336
+ window.__impelDesktopTasksSetActive = (active) => { button.dataset.active = String(Boolean(active)); };
337
+ </script>
338
+ </body>
339
+ </html>`;
340
+
341
+ export function desktopTasksMainPreload() {
342
+ return `"use strict";
343
+
344
+ if (
345
+ process.type === "browser"
346
+ && process.env.IMPEL_DESKTOP_TASKS_NODE
347
+ && process.env.IMPEL_DESKTOP_TASKS_CLI
348
+ && process.env.IMPEL_DESKTOP_TASKS_TENANT
349
+ && process.env.IMPEL_DESKTOP_TASKS_HOST_HTML
350
+ && process.env.IMPEL_DESKTOP_TASKS_VIEW_PRELOAD
351
+ && !globalThis.__impelDesktopTasksMainInstalled
352
+ ) {
353
+ globalThis.__impelDesktopTasksMainInstalled = true;
354
+ const fs = require("node:fs");
355
+ const path = require("node:path");
356
+ const readline = require("node:readline");
357
+ const { spawn } = require("node:child_process");
358
+ const { pathToFileURL } = require("node:url");
359
+ const navSource = ${JSON.stringify(NAVIGATION_SOURCE)};
360
+ const nativeNavUrl = "data:text/html;charset=utf-8," + encodeURIComponent(${JSON.stringify(NATIVE_NAVIGATION_HTML)});
361
+ const nodePath = process.env.IMPEL_DESKTOP_TASKS_NODE;
362
+ const cliPath = process.env.IMPEL_DESKTOP_TASKS_CLI;
363
+ const tenantId = process.env.IMPEL_DESKTOP_TASKS_TENANT;
364
+ const hostHtml = process.env.IMPEL_DESKTOP_TASKS_HOST_HTML;
365
+ const viewPreload = process.env.IMPEL_DESKTOP_TASKS_VIEW_PRELOAD;
366
+ const resourceUri = ${JSON.stringify(DESKTOP_TASKS_RESOURCE_URI)};
367
+ const rpcChannel = "impel-desktop-tasks:rpc";
368
+ const openLinkChannel = "impel-desktop-tasks:open-link";
369
+ const closeChannel = "impel-desktop-tasks:close";
370
+ const nativeShowChannel = "impel-desktop-tasks:native-show";
371
+ const runtimeStatusPath = path.join(path.dirname(hostHtml), "runtime-status.json");
372
+ const record = (stage, error = null) => {
373
+ try {
374
+ const detail = error instanceof Error ? error.message : error == null ? null : String(error);
375
+ fs.writeFileSync(runtimeStatusPath, JSON.stringify({
376
+ schemaVersion: 1,
377
+ stage,
378
+ detail,
379
+ processType: process.type || null,
380
+ electronVersion: process.versions.electron || null,
381
+ pid: process.pid,
382
+ updatedAt: new Date().toISOString(),
383
+ }, null, 2) + "\\n", { mode: 0o600 });
384
+ } catch {}
385
+ };
386
+
387
+ class TasksMcpClient {
388
+ constructor() {
389
+ this.child = null;
390
+ this.starting = null;
391
+ this.pending = new Map();
392
+ this.sequence = 0;
393
+ }
394
+
395
+ failPending(message) {
396
+ for (const entry of this.pending.values()) entry.reject(new Error(message));
397
+ this.pending.clear();
398
+ }
399
+
400
+ send(method, params) {
401
+ if (!this.child?.stdin?.writable) return Promise.reject(new Error("Tasks bridge is unavailable."));
402
+ const id = ++this.sequence;
403
+ return new Promise((resolve, reject) => {
404
+ const timer = setTimeout(() => {
405
+ this.pending.delete(id);
406
+ try {
407
+ this.child?.stdin?.write(JSON.stringify({
408
+ jsonrpc: "2.0",
409
+ method: "notifications/cancelled",
410
+ params: { requestId: id, reason: "desktop Tasks request timed out" },
411
+ }) + "\\n");
412
+ } catch {}
413
+ reject(new Error("Tasks request timed out."));
414
+ }, 75000);
415
+ this.pending.set(id, {
416
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
417
+ reject: (error) => { clearTimeout(timer); reject(error); },
418
+ });
419
+ this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\\n");
420
+ });
421
+ }
422
+
423
+ async ensureStarted() {
424
+ if (this.child?.stdin?.writable) return;
425
+ if (this.starting) return this.starting;
426
+ this.starting = (async () => {
427
+ if (!nodePath || !cliPath || !tenantId) throw new Error("Tasks bridge configuration is incomplete.");
428
+ const child = spawn(nodePath, [cliPath, "mcp", "--target", "tasks", "--tenant", tenantId], {
429
+ stdio: ["pipe", "pipe", "pipe"],
430
+ env: { ...process.env, IMPEL_MANAGED_MCP: "1" },
431
+ });
432
+ this.child = child;
433
+ child.stderr.resume();
434
+ const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
435
+ lines.on("line", (line) => {
436
+ let message;
437
+ try { message = JSON.parse(line); } catch { return; }
438
+ const entry = this.pending.get(message.id);
439
+ if (!entry) return;
440
+ this.pending.delete(message.id);
441
+ if (message.error) entry.reject(new Error(message.error.message || "Tasks request failed."));
442
+ else entry.resolve(message.result);
443
+ });
444
+ const closed = () => {
445
+ if (this.child === child) this.child = null;
446
+ this.failPending("Tasks bridge exited.");
447
+ };
448
+ child.once("error", closed);
449
+ child.once("exit", closed);
450
+ try {
451
+ await this.send("initialize", {
452
+ protocolVersion: "2025-06-18",
453
+ capabilities: {},
454
+ clientInfo: { name: "impel-desktop-tasks", version: "1.0.0" },
455
+ });
456
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\\n");
457
+ } catch (error) {
458
+ if (this.child === child) this.child = null;
459
+ try { child.kill(); } catch {}
460
+ throw error;
461
+ }
462
+ })().finally(() => { this.starting = null; });
463
+ return this.starting;
464
+ }
465
+
466
+ async request(method, params) {
467
+ await this.ensureStarted();
468
+ return this.send(method, params);
469
+ }
470
+
471
+ close() {
472
+ const child = this.child;
473
+ this.child = null;
474
+ this.failPending("Tasks bridge closed.");
475
+ try { child?.kill(); } catch {}
476
+ }
477
+ }
478
+
479
+ const install = (electron) => {
480
+ const {
481
+ app,
482
+ BrowserWindow,
483
+ WebContentsView,
484
+ ipcMain,
485
+ shell,
486
+ webContents: electronWebContents,
487
+ } = electron;
488
+ if (!app || !BrowserWindow || !WebContentsView || !ipcMain || !electronWebContents) {
489
+ record("unsupported-electron-surface");
490
+ return;
491
+ }
492
+ const client = new TasksMcpClient();
493
+ const windows = new Map();
494
+ const allowedViewContents = new Set();
495
+ const internalViewContents = new Set();
496
+ const nativeNavOwners = new Map();
497
+ const hostUrl = pathToFileURL(hostHtml).href;
498
+ let hasAnyDomNavigation = false;
499
+ let creatingInternalWindow = false;
500
+ const createInternalWindow = (options) => {
501
+ creatingInternalWindow = true;
502
+ try {
503
+ return new BrowserWindow(options);
504
+ } finally {
505
+ creatingInternalWindow = false;
506
+ }
507
+ };
508
+
509
+ const parseBounds = (url, window) => {
510
+ const parsed = new URL(url);
511
+ const content = window.getContentBounds();
512
+ const number = (name, fallback) => {
513
+ const value = Number.parseInt(parsed.searchParams.get(name) || "", 10);
514
+ return Number.isFinite(value) ? value : fallback;
515
+ };
516
+ const x = Math.max(0, Math.min(content.width - 240, number("x", 0)));
517
+ const y = Math.max(0, Math.min(content.height - 240, number("y", 0)));
518
+ return {
519
+ x,
520
+ y,
521
+ width: Math.max(240, Math.min(content.width - x, number("width", content.width - x))),
522
+ height: Math.max(240, Math.min(content.height - y, number("height", content.height - y))),
523
+ };
524
+ };
525
+
526
+ const stateFor = (window) => {
527
+ let state = windows.get(window.id);
528
+ if (!state) {
529
+ state = {
530
+ view: null,
531
+ bounds: null,
532
+ visible: false,
533
+ nativeNav: null,
534
+ hasDomNavigation: false,
535
+ };
536
+ windows.set(window.id, state);
537
+ }
538
+ return state;
539
+ };
540
+
541
+ const createView = (window) => {
542
+ const state = stateFor(window);
543
+ if (state.view && !state.view.webContents.isDestroyed()) return state;
544
+ const view = new WebContentsView({
545
+ webPreferences: {
546
+ preload: viewPreload,
547
+ contextIsolation: true,
548
+ nodeIntegration: false,
549
+ sandbox: true,
550
+ spellcheck: false,
551
+ },
552
+ });
553
+ window.contentView.addChildView(view);
554
+ view.setVisible(false);
555
+ view.setBackgroundColor("#181818");
556
+ allowedViewContents.add(view.webContents);
557
+ internalViewContents.add(view.webContents);
558
+ view.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
559
+ view.webContents.on("will-navigate", (event, url) => {
560
+ if (url !== hostUrl) event.preventDefault();
561
+ });
562
+ view.webContents.on("destroyed", () => {
563
+ allowedViewContents.delete(view.webContents);
564
+ internalViewContents.delete(view.webContents);
565
+ try { window.contentView.removeChildView(view); } catch {}
566
+ if (state.view === view) state.view = null;
567
+ });
568
+ view.webContents.loadURL(hostUrl).catch((error) => record("view-load-failed", error));
569
+ state.view = view;
570
+ return state;
571
+ };
572
+
573
+ const show = (window, bounds) => {
574
+ const state = createView(window);
575
+ state.bounds = bounds;
576
+ state.visible = true;
577
+ state.view.setBounds(bounds);
578
+ state.view.setVisible(true);
579
+ state.view.webContents.focus();
580
+ const boardStatus = () => ({
581
+ parentBounds: window.getBounds(),
582
+ parentContentBounds: window.getContentBounds(),
583
+ boardBounds: state.view.getBounds(),
584
+ boardVisible: state.visible,
585
+ boardFocused: state.view.webContents.isFocused(),
586
+ });
587
+ record("board-shown", JSON.stringify(boardStatus()));
588
+ setTimeout(() => {
589
+ if (state.view && !state.view.webContents.isDestroyed()) record("board-visible-check", JSON.stringify(boardStatus()));
590
+ }, 2000);
591
+ };
592
+ const hide = (window) => {
593
+ const state = windows.get(window.id);
594
+ if (!state) return;
595
+ state.visible = false;
596
+ state.view?.setVisible(false);
597
+ window.webContents.focus();
598
+ };
599
+
600
+ const layoutBoard = (window) => {
601
+ const state = windows.get(window.id);
602
+ if (!state?.visible || !state.view || !state.bounds || state.view.webContents.isDestroyed()) return;
603
+ state.view.setBounds(state.bounds);
604
+ };
605
+
606
+ const nativeBoardBounds = (window) => {
607
+ const content = window.getContentBounds();
608
+ const x = Math.min(296, Math.max(0, content.width - 240));
609
+ return { x, y: 0, width: Math.max(240, content.width - x), height: Math.max(240, content.height) };
610
+ };
611
+
612
+ const layoutNativeNav = (window) => {
613
+ const state = windows.get(window.id);
614
+ if (!state?.nativeNav || state.nativeNav.isDestroyed()) return;
615
+ const content = window.getContentBounds();
616
+ state.nativeNav.setBounds({
617
+ x: content.x + 12,
618
+ y: content.y + Math.max(48, content.height - 92),
619
+ width: Math.min(120, Math.max(80, content.width - 24)),
620
+ height: 38,
621
+ });
622
+ };
623
+
624
+ const ensureNativeNav = (window) => {
625
+ if (!window || window.isDestroyed()) return;
626
+ const state = stateFor(window);
627
+ if (state.nativeNav && !state.nativeNav.isDestroyed()) return;
628
+ const navView = createInternalWindow({
629
+ parent: window,
630
+ frame: false,
631
+ transparent: false,
632
+ show: false,
633
+ resizable: false,
634
+ movable: false,
635
+ minimizable: false,
636
+ maximizable: false,
637
+ fullscreenable: false,
638
+ skipTaskbar: true,
639
+ hasShadow: false,
640
+ acceptFirstMouse: true,
641
+ backgroundColor: "#292929",
642
+ webPreferences: {
643
+ preload: viewPreload,
644
+ contextIsolation: true,
645
+ nodeIntegration: false,
646
+ sandbox: true,
647
+ spellcheck: false,
648
+ },
649
+ });
650
+ navView.setAlwaysOnTop(true, "floating");
651
+ state.nativeNav = navView;
652
+ internalViewContents.add(navView.webContents);
653
+ nativeNavOwners.set(navView.webContents, window);
654
+ navView.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
655
+ navView.webContents.on("will-navigate", (event, url) => {
656
+ if (url !== nativeNavUrl) event.preventDefault();
657
+ });
658
+ navView.webContents.on("destroyed", () => {
659
+ internalViewContents.delete(navView.webContents);
660
+ nativeNavOwners.delete(navView.webContents);
661
+ });
662
+ navView.webContents.on("did-finish-load", () => {
663
+ navView.webContents.executeJavaScript(
664
+ "typeof window.impelDesktopTasks?.show === 'function'",
665
+ true,
666
+ ).then((ready) => record(ready ? "native-navigation-ready" : "native-navigation-bridge-missing"))
667
+ .catch((error) => record("native-navigation-check-failed", error));
668
+ });
669
+ navView.webContents.loadURL(nativeNavUrl).catch((error) => record("native-navigation-load-failed", error));
670
+ layoutNativeNav(window);
671
+ navView.showInactive();
672
+ navView.moveTop();
673
+ record("native-navigation-installed");
674
+ };
675
+
676
+ const removeNativeNav = (window) => {
677
+ const state = windows.get(window.id);
678
+ if (!state?.nativeNav) return;
679
+ const navView = state.nativeNav;
680
+ state.nativeNav = null;
681
+ internalViewContents.delete(navView.webContents);
682
+ nativeNavOwners.delete(navView.webContents);
683
+ try {
684
+ navView.destroy();
685
+ } catch {}
686
+ };
687
+
688
+ const ownerWindow = (contents) => {
689
+ const direct = BrowserWindow.fromWebContents(contents);
690
+ if (direct && !direct.isDestroyed()) return direct;
691
+ const owner = contents.getOwnerBrowserWindow?.();
692
+ if (owner && !owner.isDestroyed()) return owner;
693
+ const focused = BrowserWindow.getFocusedWindow();
694
+ if (focused && !focused.isDestroyed()) return focused;
695
+ return BrowserWindow.getAllWindows().find((window) => !window.isDestroyed() && window.isVisible())
696
+ || BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
697
+ || null;
698
+ };
699
+
700
+ const attachContents = (contents) => {
701
+ if (
702
+ !contents
703
+ || contents.isDestroyed()
704
+ || contents.__impelDesktopTasksAttached
705
+ || internalViewContents.has(contents)
706
+ ) return;
707
+ contents.__impelDesktopTasksAttached = true;
708
+ const surface = () => ({
709
+ id: contents.id,
710
+ type: contents.getType?.() || null,
711
+ url: contents.getURL?.() || null,
712
+ });
713
+ const inject = (attempt = 0) => {
714
+ if (internalViewContents.has(contents) || contents.getURL?.() === hostUrl) return;
715
+ const frames = contents.mainFrame?.framesInSubtree || [];
716
+ Promise.all(frames.map(async (frame) => ({
717
+ frameUrl: frame.url,
718
+ result: await frame.executeJavaScript(navSource, true),
719
+ })))
720
+ .then((results) => {
721
+ record("navigation-injected", JSON.stringify({ ...surface(), frames: results }));
722
+ const window = ownerWindow(contents);
723
+ if (!window) return;
724
+ const state = stateFor(window);
725
+ if (results.some(({ result }) => result?.anchor)) {
726
+ hasAnyDomNavigation = true;
727
+ state.hasDomNavigation = true;
728
+ for (const owner of BrowserWindow.getAllWindows()) removeNativeNav(owner);
729
+ } else if (attempt < 3) setTimeout(() => inject(attempt + 1), 1000);
730
+ else if (!state.hasDomNavigation && !hasAnyDomNavigation) ensureNativeNav(window);
731
+ })
732
+ .catch((error) => record(
733
+ "navigation-injection-failed",
734
+ JSON.stringify(surface()) + ": " + String(error),
735
+ ));
736
+ };
737
+ contents.on("did-finish-load", () => inject());
738
+ contents.on("did-frame-finish-load", () => inject());
739
+ if (!contents.isLoadingMainFrame()) setTimeout(() => inject(), 0);
740
+ const handleNavigation = (event, url) => {
741
+ if (!String(url).startsWith("impel-tasks://")) return;
742
+ event.preventDefault();
743
+ const window = ownerWindow(contents);
744
+ if (!window) return;
745
+ let action;
746
+ try { action = new URL(url).hostname; } catch { return; }
747
+ if (action === "hide") hide(window);
748
+ else if (action === "show") show(window, parseBounds(url, window));
749
+ else if (action === "layout") {
750
+ const state = windows.get(window.id);
751
+ if (state?.visible) show(window, parseBounds(url, window));
752
+ }
753
+ };
754
+ contents.on("will-navigate", handleNavigation);
755
+ contents.on("will-frame-navigate", handleNavigation);
756
+ };
757
+
758
+ const attachWindow = (window) => {
759
+ if (
760
+ !window
761
+ || window.isDestroyed()
762
+ || creatingInternalWindow
763
+ || window.getParentWindow?.()
764
+ || window.__impelDesktopTasksWindowAttached
765
+ ) return;
766
+ window.__impelDesktopTasksWindowAttached = true;
767
+ attachContents(window.webContents);
768
+ window.on("resize", () => {
769
+ layoutNativeNav(window);
770
+ layoutBoard(window);
771
+ });
772
+ window.on("move", () => layoutBoard(window));
773
+ window.on("focus", () => {
774
+ const state = windows.get(window.id);
775
+ if (state?.nativeNav && !state.nativeNav.isDestroyed()) {
776
+ state.nativeNav.showInactive();
777
+ state.nativeNav.moveTop();
778
+ }
779
+ if (state?.visible && state.view && !state.view.webContents.isDestroyed()) state.view.setVisible(true);
780
+ });
781
+ window.on("blur", () => setTimeout(() => {
782
+ const state = windows.get(window.id);
783
+ const focused = BrowserWindow.getFocusedWindow();
784
+ if (focused === state?.nativeNav) return;
785
+ state?.nativeNav?.hide();
786
+ state?.view?.setVisible(false);
787
+ }, 1000));
788
+ window.on("minimize", () => {
789
+ const state = windows.get(window.id);
790
+ state?.nativeNav?.hide();
791
+ state?.view?.setVisible(false);
792
+ });
793
+ window.on("restore", () => {
794
+ const state = windows.get(window.id);
795
+ state?.nativeNav?.showInactive();
796
+ if (state?.visible) state.view?.setVisible(true);
797
+ });
798
+ window.on("closed", () => {
799
+ const state = windows.get(window.id);
800
+ if (state) {
801
+ if (state.view) {
802
+ allowedViewContents.delete(state.view.webContents);
803
+ internalViewContents.delete(state.view.webContents);
804
+ }
805
+ if (state.nativeNav) internalViewContents.delete(state.nativeNav.webContents);
806
+ if (state.nativeNav) nativeNavOwners.delete(state.nativeNav.webContents);
807
+ try {
808
+ if (state.view) {
809
+ window.contentView.removeChildView(state.view);
810
+ state.view.webContents.close();
811
+ }
812
+ if (state.nativeNav) {
813
+ state.nativeNav.destroy();
814
+ }
815
+ } catch {}
816
+ windows.delete(window.id);
817
+ }
818
+ });
819
+ };
820
+
821
+ ipcMain.handle(rpcChannel, async (event, request) => {
822
+ if (!allowedViewContents.has(event.sender)) throw new Error("Untrusted Tasks view.");
823
+ if (request?.method === "resources/read" && request?.params?.uri === resourceUri) {
824
+ return client.request("resources/read", { uri: resourceUri });
825
+ }
826
+ if (request?.method === "tools/call") {
827
+ const name = request?.params?.name;
828
+ const allowed = new Set(["show_tasks", "list_tasks", "get_task", "list_members", "create_task", "update_task"]);
829
+ if (!allowed.has(name)) throw new Error("Unsupported Tasks tool.");
830
+ const args = request?.params?.arguments;
831
+ if (args != null && (typeof args !== "object" || Array.isArray(args))) {
832
+ throw new Error("Invalid Tasks tool arguments.");
833
+ }
834
+ return client.request("tools/call", { name, arguments: args || {} });
835
+ }
836
+ throw new Error("Unsupported Tasks request.");
837
+ });
838
+ ipcMain.handle(openLinkChannel, async (event, rawUrl) => {
839
+ if (!allowedViewContents.has(event.sender)) throw new Error("Untrusted Tasks view.");
840
+ const url = new URL(String(rawUrl));
841
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error("Invalid Tasks link.");
842
+ await shell.openExternal(url.href);
843
+ return {};
844
+ });
845
+ ipcMain.on(closeChannel, (event) => {
846
+ if (!allowedViewContents.has(event.sender)) return;
847
+ for (const window of BrowserWindow.getAllWindows()) {
848
+ if (windows.get(window.id)?.view?.webContents === event.sender) {
849
+ hide(window);
850
+ const nativeNav = windows.get(window.id)?.nativeNav;
851
+ nativeNav?.webContents.executeJavaScript(
852
+ "window.__impelDesktopTasksSetActive?.(false);",
853
+ true,
854
+ ).catch(() => {});
855
+ for (const contents of electronWebContents.getAllWebContents()) {
856
+ if (internalViewContents.has(contents) || contents.isDestroyed()) continue;
857
+ for (const frame of contents.mainFrame?.framesInSubtree || []) {
858
+ frame.executeJavaScript(
859
+ "window.__impelDesktopTasksSetActive?.(false);",
860
+ true,
861
+ ).catch(() => {});
862
+ }
863
+ }
864
+ break;
865
+ }
866
+ }
867
+ });
868
+ ipcMain.on(nativeShowChannel, (event) => {
869
+ const window = nativeNavOwners.get(event.sender);
870
+ if (!window || window.isDestroyed()) return;
871
+ record("native-navigation-selected");
872
+ show(window, nativeBoardBounds(window));
873
+ });
874
+
875
+ const hideAuxiliaryOutsideApp = () => {
876
+ const focused = BrowserWindow.getFocusedWindow();
877
+ if (focused && (windows.has(focused.id) || internalViewContents.has(focused.webContents))) return;
878
+ for (const state of windows.values()) {
879
+ state.nativeNav?.hide();
880
+ state.view?.setVisible(false);
881
+ }
882
+ };
883
+
884
+ app.on("browser-window-created", (_event, window) => attachWindow(window));
885
+ app.on("browser-window-blur", () => setTimeout(hideAuxiliaryOutsideApp, 150));
886
+ app.on("hide", hideAuxiliaryOutsideApp);
887
+ app.on("web-contents-created", (_event, contents) => setTimeout(() => attachContents(contents), 0));
888
+ app.on("before-quit", () => client.close());
889
+ for (const window of BrowserWindow.getAllWindows()) attachWindow(window);
890
+ for (const contents of electronWebContents.getAllWebContents()) attachContents(contents);
891
+ record("main-installed");
892
+ };
893
+
894
+ record("preload-loaded");
895
+ try {
896
+ install(require("electron"));
897
+ } catch (error) {
898
+ record("main-install-failed", error);
899
+ }
900
+ }
901
+ `;
902
+ }
903
+
904
+ export function desktopTasksViewPreload() {
905
+ return `"use strict";
906
+
907
+ const { contextBridge, ipcRenderer } = require("electron");
908
+ contextBridge.exposeInMainWorld("impelDesktopTasks", Object.freeze({
909
+ rpc: (request) => ipcRenderer.invoke("impel-desktop-tasks:rpc", request),
910
+ openLink: (url) => ipcRenderer.invoke("impel-desktop-tasks:open-link", url),
911
+ close: () => ipcRenderer.send("impel-desktop-tasks:close"),
912
+ show: () => ipcRenderer.send("impel-desktop-tasks:native-show"),
913
+ }));
914
+ `;
915
+ }
916
+
917
+ export function desktopTasksHostHtml() {
918
+ return `<!doctype html>
919
+ <html lang="en">
920
+ <head>
921
+ <meta charset="utf-8">
922
+ <meta name="viewport" content="width=device-width,initial-scale=1">
923
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self' data:; img-src data:;">
924
+ <title>Impel Tasks</title>
925
+ <style>
926
+ :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
927
+ * { box-sizing: border-box; }
928
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: Canvas; color: CanvasText; }
929
+ body { display: grid; grid-template-rows: minmax(0, 1fr); }
930
+ iframe { width: 100%; height: 100%; border: 0; background: Canvas; }
931
+ #status { display: grid; place-items: center; padding: 24px; color: color-mix(in srgb, CanvasText 65%, transparent); text-align: center; }
932
+ #status[hidden] { display: none; }
933
+ </style>
934
+ </head>
935
+ <body>
936
+ <div id="status" role="status">Loading Impel Tasks…</div>
937
+ <iframe id="widget" title="Impel Tasks" sandbox="allow-scripts allow-forms" hidden></iframe>
938
+ <script>
939
+ (() => {
940
+ const bridge = window.impelDesktopTasks;
941
+ const frame = document.getElementById("widget");
942
+ const status = document.getElementById("status");
943
+ let initialResult = null;
944
+ let initialized = false;
945
+ const send = (message) => frame.contentWindow?.postMessage(message, "*");
946
+ const reply = (id, result, error) => send(error
947
+ ? { jsonrpc: "2.0", id, error: { code: -32000, message: error } }
948
+ : { jsonrpc: "2.0", id, result });
949
+
950
+ window.addEventListener("message", async (event) => {
951
+ if (event.source !== frame.contentWindow) return;
952
+ const message = event.data;
953
+ if (!message || message.jsonrpc !== "2.0") return;
954
+ if (message.method === "ui/initialize") {
955
+ reply(message.id, {
956
+ protocolVersion: "2026-01-26",
957
+ hostInfo: { name: "Impel Desktop Tasks", version: "1.0.0" },
958
+ hostCapabilities: { openLinks: {}, serverTools: {} },
959
+ hostContext: {
960
+ theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
961
+ platform: "desktop",
962
+ displayMode: "fullscreen",
963
+ availableDisplayModes: ["fullscreen"],
964
+ },
965
+ });
966
+ return;
967
+ }
968
+ if (message.method === "ui/notifications/initialized") {
969
+ if (!initialized && initialResult) {
970
+ initialized = true;
971
+ send({ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: initialResult });
972
+ }
973
+ return;
974
+ }
975
+ if (message.method === "tools/call") {
976
+ try {
977
+ const result = await bridge.rpc({ method: "tools/call", params: message.params || {} });
978
+ reply(message.id, result);
979
+ } catch (error) {
980
+ reply(message.id, null, error instanceof Error ? error.message : "Tasks request failed.");
981
+ }
982
+ return;
983
+ }
984
+ if (message.method === "ui/open-link") {
985
+ try {
986
+ await bridge.openLink(message.params?.url);
987
+ reply(message.id, {});
988
+ } catch (error) {
989
+ reply(message.id, null, error instanceof Error ? error.message : "Tasks link failed.");
990
+ }
991
+ return;
992
+ }
993
+ if (message.method === "ui/request-display-mode") {
994
+ reply(message.id, { mode: "fullscreen" });
995
+ }
996
+ });
997
+
998
+ window.addEventListener("keydown", (event) => {
999
+ if (event.key === "Escape") bridge.close();
1000
+ });
1001
+ Promise.all([
1002
+ bridge.rpc({ method: "resources/read", params: { uri: ${JSON.stringify(DESKTOP_TASKS_RESOURCE_URI)} } }),
1003
+ bridge.rpc({ method: "tools/call", params: { name: "show_tasks", arguments: { scope: "visible", limit: 50 } } }),
1004
+ ]).then(([resource, result]) => {
1005
+ const html = resource?.contents?.find((entry) => entry?.uri === ${JSON.stringify(DESKTOP_TASKS_RESOURCE_URI)})?.text;
1006
+ if (typeof html !== "string" || !html.includes("<html")) throw new Error("Tasks widget resource is unavailable.");
1007
+ initialResult = result;
1008
+ status.hidden = true;
1009
+ frame.hidden = false;
1010
+ frame.srcdoc = html;
1011
+ }).catch((error) => {
1012
+ status.textContent = error instanceof Error ? error.message : "Impel Tasks could not be loaded.";
1013
+ });
1014
+ })();
1015
+ </script>
1016
+ </body>
1017
+ </html>
1018
+ `;
1019
+ }