impel-cli 0.18.12 → 0.18.14-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ impel update
14
14
 
15
15
  ## Install
16
16
 
17
- Install the public npm package once per machine:
17
+ On macOS and Linux, install the public npm package once per machine:
18
18
 
19
19
  ```sh
20
20
  npm install --global impel-cli
@@ -22,17 +22,31 @@ npm install --global impel-cli
22
22
 
23
23
  Node.js 18 or newer is required.
24
24
 
25
+ On Windows PowerShell, invoke npm's command shims explicitly:
26
+
27
+ ```powershell
28
+ npm.cmd install --global impel-cli
29
+ impel.cmd setup
30
+ ```
31
+
32
+ The explicit `.cmd` suffix prevents PowerShell from selecting npm's `.ps1`
33
+ shim when script execution is restricted. Impel does not change the machine's
34
+ or current user's PowerShell execution policy.
35
+
25
36
  On macOS, setup and update verify Apple's Xcode Command Line Tools before any
26
37
  profile, app, or skills work begins. If they are missing, Impel opens the
27
38
  system installer with `xcode-select --install` and stops safely; finish that
28
39
  installation, then rerun the same Impel command.
29
40
 
30
- Then run setup and paste an Impel Personal Access Token when prompted:
41
+ Then run setup and paste an Impel Personal Access Token when prompted. On
42
+ macOS and Linux:
31
43
 
32
44
  ```sh
33
45
  impel setup
34
46
  ```
35
47
 
48
+ On Windows PowerShell, use `impel.cmd setup` as shown above.
49
+
36
50
  Setup discovers every tenant available to the PAT and prepares all supported
37
51
  surfaces for each tenant. You do not need to repeat setup tenant by tenant.
38
52
 
@@ -103,6 +117,12 @@ impel claude
103
117
  impel codex
104
118
  ```
105
119
 
120
+ Before spawning the vendor CLI, Impel verifies that the selected tenant's live
121
+ model catalogue has a healthy, routable provider pool. A short-lived successful
122
+ check is reused for one minute. If a provider is unavailable, the command stops
123
+ with an actionable gateway message instead of opening a client that cannot
124
+ complete inference.
125
+
106
126
  Arguments after the command are passed to the vendor CLI:
107
127
 
108
128
  ```sh
@@ -122,6 +142,12 @@ Run one command for routine maintenance:
122
142
  impel update
123
143
  ```
124
144
 
145
+ On Windows PowerShell, keep the command shim explicit:
146
+
147
+ ```powershell
148
+ impel.cmd update
149
+ ```
150
+
125
151
  Update:
126
152
 
127
153
  1. On macOS, verifies the Xcode Command Line Tools and opens Apple's installer
@@ -255,6 +281,21 @@ compatibility for one release, but no command can enable native routing.
255
281
  ### Windows
256
282
 
257
283
  - Missing official Claude Code and Codex CLIs can be installed during setup.
284
+ - Each CLI prompt explains that Impel will download, verify, and install the
285
+ official vendor CLI for the current user; each defaults to No.
286
+ - Run `impel.cmd setup` or `impel.cmd update` from PowerShell so restricted
287
+ script policy cannot select the npm-generated `impel.ps1` shim. Impel does
288
+ not modify persistent PowerShell execution policy.
289
+ - Claude Chat and Code remain available through the per-user managed profile.
290
+ Cowork is enabled only when a read-only check confirms Virtual Machine
291
+ Platform, the HCS services and `vfpext` driver, nested virtualization when
292
+ applicable, and machine-wide provisioning of the exact pinned Claude MSIX.
293
+ - When those Cowork prerequisites are missing or cannot be read, Impel hides
294
+ the Cowork tab and reports the reason and remediation in setup, update, and
295
+ status. Impel never enables Windows features, restarts Windows, provisions a
296
+ machine-wide package, or elevates itself.
297
+ - Machine-wide deployment requirements are documented by Anthropic in
298
+ [Deploy Claude Desktop for Windows](https://support.claude.com/en/articles/12622703-deploy-claude-desktop-for-windows).
258
299
  - Claude Desktop is downloaded from Anthropic as the exact x64 or arm64 MSIX
259
300
  pinned by this CLI. Its SHA-256, Authenticode signature, package identity,
260
301
  version, architecture, and required layout are checked before installation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.18.12",
3
+ "version": "0.18.14-beta.0",
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
@@ -57,6 +57,7 @@ export const PINNED_VENDOR_APPS = Object.freeze({
57
57
  packageName: "Claude",
58
58
  packageVersion: "1.24012.9.0",
59
59
  publisher: 'CN="Anthropic, PBC", O="Anthropic, PBC", L=San Francisco, S=California, C=US, SERIALNUMBER=4860621, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US',
60
+ publisherId: "pzs8sxrjxfjjc",
60
61
  executable: "app\\Claude.exe",
61
62
  downloads: Object.freeze({
62
63
  arm64: Object.freeze({
@@ -232,7 +233,9 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
232
233
  // 22: refresh GPT-5.6 model windows from the pinned ChatGPT Codex catalog.
233
234
  // 23: select Codex's unelevated native Windows sandbox for managed profiles so
234
235
  // enterprise machines do not enter the administrator-only setup flow.
235
- export const CURRENT_CONFIG_VERSION = 23;
236
+ // 24: thread the inspected Windows Cowork host capability into Claude config
237
+ // and the tenant manifest so unsupported hosts keep Chat and Code usable.
238
+ export const CURRENT_CONFIG_VERSION = 24;
236
239
 
237
240
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
238
241
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -710,6 +713,7 @@ export function installManagedAppFiles({
710
713
  writeTokenHelperFile = true,
711
714
  claudeUserData = null,
712
715
  chatgptInvocations = null,
716
+ windowsCoworkCapability = null,
713
717
  generatedAt = new Date().toISOString(),
714
718
  }) {
715
719
  // true = rebuild every target's bundle; false = configs only; an array
@@ -768,7 +772,7 @@ export function installManagedAppFiles({
768
772
  migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
769
773
  writeClaudeNative3PSelection(paths);
770
774
  writeClaudeCodeSettings(paths);
771
- writeClaudeConfig(paths, config, models);
775
+ writeClaudeConfig(paths, config, models, windowsCoworkCapability);
772
776
  if (RUNTIME_BRAND.features.sessions) ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
773
777
  }
774
778
  else {
@@ -789,8 +793,33 @@ export function installManagedAppFiles({
789
793
  });
790
794
  }
791
795
 
796
+ const manifestCoworkCapability = windowsCoworkCapability
797
+ ? {
798
+ schemaVersion: 1,
799
+ status: ["available", "unavailable", "unknown"].includes(windowsCoworkCapability.status)
800
+ ? windowsCoworkCapability.status
801
+ : "unknown",
802
+ enabled: windowsCoworkCapability.enabled === true,
803
+ checks: {
804
+ virtualMachinePlatform: windowsCoworkCapability.checks?.virtualMachinePlatform ?? "unknown",
805
+ restartPending: windowsCoworkCapability.checks?.restartPending ?? null,
806
+ vmcompute: windowsCoworkCapability.checks?.vmcompute ?? "unknown",
807
+ hns: windowsCoworkCapability.checks?.hns ?? "unknown",
808
+ vfpext: windowsCoworkCapability.checks?.vfpext ?? "unknown",
809
+ machineProvisioned: windowsCoworkCapability.checks?.machineProvisioned ?? null,
810
+ virtualMachine: windowsCoworkCapability.checks?.virtualMachine ?? null,
811
+ nestedVirtualization: windowsCoworkCapability.checks?.nestedVirtualization ?? null,
812
+ },
813
+ reasonCodes: Array.isArray(windowsCoworkCapability.reasonCodes)
814
+ ? [...windowsCoworkCapability.reasonCodes]
815
+ : ["inspector-malformed-output"],
816
+ remediation: Array.isArray(windowsCoworkCapability.remediation)
817
+ ? [...windowsCoworkCapability.remediation]
818
+ : [],
819
+ }
820
+ : null;
792
821
  writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
793
- schemaVersion: 4,
822
+ schemaVersion: 5,
794
823
  configVersion: CURRENT_CONFIG_VERSION,
795
824
  modelCatalogVersion: 3,
796
825
  gatewayUrl: config.gatewayUrl,
@@ -803,6 +832,7 @@ export function installManagedAppFiles({
803
832
  models,
804
833
  config,
805
834
  ),
835
+ ...(manifestCoworkCapability ? { capabilities: { claudeCowork: manifestCoworkCapability } } : {}),
806
836
  models: models.map((model) => model.id),
807
837
  updatedAt: generatedAt,
808
838
  }, null, 2) + "\n", 0o600);
@@ -1029,7 +1059,7 @@ export function removeManagedApps(targets, {
1029
1059
  }
1030
1060
  }
1031
1061
 
1032
- function writeClaudeConfig(paths, config, models) {
1062
+ function writeClaudeConfig(paths, config, models, windowsCoworkCapability = null) {
1033
1063
  const dir = path.join(paths.claude.userData, "configLibrary");
1034
1064
  const experimental = crossAppModelsEnabled(config);
1035
1065
  const claudeModels = experimental
@@ -1044,10 +1074,13 @@ function writeClaudeConfig(paths, config, models) {
1044
1074
  inferenceGatewayAuthScheme: "bearer",
1045
1075
  modelDiscoveryEnabled: false,
1046
1076
  inferenceModels: claudeModels,
1047
- // These are Anthropic's native managed 3P surface controls. Cowork and
1048
- // Code default to enabled in the pinned build, but state them explicitly
1049
- // so an older generated profile cannot leave either surface disabled.
1050
- coworkTabEnabled: true,
1077
+ // These are Anthropic's native managed 3P surface controls. Windows
1078
+ // enables Cowork only after a separate read-only host inspection proves
1079
+ // the VMP/HCS/machine-provisioning prerequisites. Other platforms and
1080
+ // legacy callers without a Windows observation retain the reviewed true.
1081
+ coworkTabEnabled: windowsCoworkCapability
1082
+ ? windowsCoworkCapability.enabled === true
1083
+ : true,
1051
1084
  isClaudeCodeForDesktopEnabled: true,
1052
1085
  // Chat is the only opt-in surface in the pinned build.
1053
1086
  chatTabEnabled: true,
@@ -60,6 +60,11 @@ import {
60
60
  windowsManagedChatGPTRoot,
61
61
  } from "../windowsApps.js";
62
62
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
63
+ import {
64
+ formatWindowsClaudeCoworkCapability,
65
+ inspectWindowsClaudeCoworkCapability,
66
+ unknownWindowsClaudeCoworkCapability,
67
+ } from "../windowsCapabilities.js";
63
68
 
64
69
  const CLAUDE_KEYCHAIN_NOTICE = "Claude Keychain: enter your Mac login password and choose Always Allow on the first prompt for this tenant; Allow is temporary.";
65
70
 
@@ -319,6 +324,7 @@ function configureWindowsApps(config, targets, vendorPaths, catalog, io) {
319
324
  writeTokenHelperFile: false,
320
325
  claudeUserData: userData,
321
326
  chatgptInvocations: { auth, mcp },
327
+ windowsCoworkCapability: io.windowsCoworkCapability || null,
322
328
  });
323
329
  return { catalog, userData };
324
330
  }
@@ -353,7 +359,10 @@ export async function reconcileWindowsTenantApps({
353
359
  confirmVendorInstall = async () => true,
354
360
  homeDir = os.homedir(),
355
361
  environment = process.env,
362
+ windowsCoworkCapability = null,
356
363
  } = {}, overrides = {}) {
364
+ const effectiveCoworkCapability = windowsCoworkCapability
365
+ || unknownWindowsClaudeCoworkCapability();
357
366
  const io = {
358
367
  findClaudeApp: findWindowsClaudeApp,
359
368
  ensureClaudeApp: ensureWindowsClaudeApp,
@@ -435,6 +444,7 @@ export async function reconcileWindowsTenantApps({
435
444
  ...io,
436
445
  homeDir,
437
446
  environment,
447
+ windowsCoworkCapability: effectiveCoworkCapability,
438
448
  });
439
449
  }
440
450
  const agentTargets = [];
@@ -628,6 +638,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
628
638
  syncSkills: syncSkillsSafe,
629
639
  syncAgents: syncAgentProfilesSafe,
630
640
  selectedConfig: selectedAppConfig,
641
+ inspectCoworkCapability: inspectWindowsClaudeCoworkCapability,
631
642
  ...overrides,
632
643
  };
633
644
  // Preserve the original Windows-Claude override names used by embedders and
@@ -656,6 +667,10 @@ export async function cmdWindowsApps(argv, overrides = {}) {
656
667
  console.log(` profile: ${fs.existsSync(claudeConfigPath) ? paths.claude.userData : "not installed"}`);
657
668
  console.log(` vendor: ${io.findClaudeApp(io.environment) || "not installed"}`);
658
669
  console.log(" open: impel app open claude");
670
+ const manifest = localTenantId ? readTenantManifest(io.homeDir, localTenantId) : null;
671
+ for (const line of formatWindowsClaudeCoworkCapability(manifest?.capabilities?.claudeCowork)) {
672
+ console.log(` ${line}`);
673
+ }
659
674
  }
660
675
  if (targets.includes("chatgpt")) {
661
676
  console.log("Impel ChatGPT:");
@@ -777,7 +792,14 @@ export async function cmdWindowsApps(argv, overrides = {}) {
777
792
  managedChatGPT = io.stageChatGPTApp(vendorPaths.chatgpt, actionPaths.root);
778
793
  }
779
794
 
780
- const { userData } = configureWindowsApps(config, actionTargets, vendorPaths, catalog, io);
795
+ const windowsCoworkCapability = io.inspectCoworkCapability({
796
+ environment: io.environment,
797
+ platform: "win32",
798
+ });
799
+ const { userData } = configureWindowsApps(config, actionTargets, vendorPaths, catalog, {
800
+ ...io,
801
+ windowsCoworkCapability,
802
+ });
781
803
  const tenantPaths = appPaths(io.homeDir, config.tenantId, {
782
804
  claudeUserData: userData,
783
805
  tenantName: config.tenantName,
@@ -123,6 +123,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
123
123
  confirmVendorInstall: confirmInstall,
124
124
  }, overrides.reconcileOverrides || {});
125
125
 
126
+ const cliInstallDecisions = new Map();
126
127
  let sharedFailure = null;
127
128
  const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
128
129
  try {
@@ -141,11 +142,20 @@ export async function cmdConverge(argv = [], overrides = {}) {
141
142
  for (const tool of inspected.missingAfter) {
142
143
  if (!inspected.installCommands?.[tool]) continue;
143
144
  const label = tool === "claude" ? "Claude Code" : "Codex";
144
- if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
145
+ if (confirmed(await io.promptText(`Impel will download, verify, and install the official ${label} CLI for the current user. Continue? [y/N] `))) {
145
146
  installTools.push(tool);
147
+ cliInstallDecisions.set(tool, "approved");
148
+ } else {
149
+ cliInstallDecisions.set(tool, "declined");
146
150
  }
147
151
  }
152
+ } else {
153
+ for (const tool of inspected.missingAfter) {
154
+ if (inspected.installCommands?.[tool]) cliInstallDecisions.set(tool, "confirmation-required");
155
+ }
148
156
  }
157
+ } else {
158
+ for (const tool of installTools) cliInstallDecisions.set(tool, "approved");
149
159
  }
150
160
  if (installTools.length) {
151
161
  prepared = await io.preparePlatformClis({
@@ -157,7 +167,15 @@ export async function cmdConverge(argv = [], overrides = {}) {
157
167
  });
158
168
  }
159
169
  }
160
- sharedFailure = prepared.missingAfter.length ? describeCliFailure(prepared) : null;
170
+ sharedFailure = prepared.missingAfter.length
171
+ ? describeCliFailure(prepared, {
172
+ commandName: "update",
173
+ cliExecutable: io.platform === "win32" ? "impel.cmd" : "impel",
174
+ isTTY: io.isTTY,
175
+ decisions: cliInstallDecisions,
176
+ skipClis,
177
+ })
178
+ : null;
161
179
  return prepared;
162
180
  } catch (error) {
163
181
  sharedFailure = redactSecretText(error?.message || error);
@@ -12,6 +12,7 @@ import {
12
12
  normalizeGatewayUrl,
13
13
  redactSecretText,
14
14
  resolveDefaultGateway,
15
+ saveConfig,
15
16
  } from "../config.js";
16
17
  import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
17
18
  import { withGitEnvironment } from "../skills.js";
@@ -22,6 +23,12 @@ import {
22
23
  resolveNativeBinary,
23
24
  } from "../nativeProcess.js";
24
25
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
26
+ import {
27
+ cacheProviderReadiness,
28
+ cachedProviderReadiness,
29
+ probeGatewayProviderReadiness,
30
+ providerAvailable,
31
+ } from "../providerReadiness.js";
25
32
 
26
33
  const CLAUDE_DIRECT_AUTH_ENV = [
27
34
  "ANTHROPIC_API_KEY",
@@ -81,6 +88,32 @@ function childExitCode(code, signal) {
81
88
  return Number.isInteger(signalNumber) ? 128 + signalNumber : 1;
82
89
  }
83
90
 
91
+ function localInformationRequest(argv) {
92
+ return argv.length === 1 && ["--help", "-h", "--version", "-V", "-v", "help"].includes(argv[0]);
93
+ }
94
+
95
+ async function assertLiveProviderReadiness({ config, gatewayUrl, credential, tenantId, tool, crossAppModels }) {
96
+ let readiness = cachedProviderReadiness(config, tenantId);
97
+ if (!readiness) {
98
+ readiness = await probeGatewayProviderReadiness(gatewayUrl, credential, tenantId);
99
+ if (cacheProviderReadiness(config, tenantId, readiness)) saveConfig(config);
100
+ }
101
+ const label = tool === "claude" ? "Claude" : "Codex";
102
+ if (!readiness.reachable) {
103
+ throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || "gateway unreachable"})`);
104
+ }
105
+ if (readiness.rejected) throw new Error(`gateway credential was rejected (HTTP ${readiness.status})`);
106
+ if (!readiness.healthy) {
107
+ throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || `HTTP ${readiness.status}`})`);
108
+ }
109
+ if (!providerAvailable(tool, readiness.providers, { crossAppModels })) {
110
+ throw new Error(
111
+ `${label} is currently unavailable for tenant "${tenantId}": the gateway advertises no ${label} models. `
112
+ + `Ask a gateway administrator to restore the ${label} provider pool, then rerun \`${RUNTIME_BRAND.cli.command} setup\`.`,
113
+ );
114
+ }
115
+ }
116
+
84
117
  function runNativeCli(tool, argv, environment) {
85
118
  const binary = resolveNativeBinary(tool, environment);
86
119
  let invocation;
@@ -141,6 +174,22 @@ export async function cmdLaunch(tool, argv) {
141
174
  return;
142
175
  }
143
176
  const gatewayCredential = tenantCredential(config.pat, tenantId);
177
+ if (!localInformationRequest(argv)) {
178
+ try {
179
+ await assertLiveProviderReadiness({
180
+ config,
181
+ gatewayUrl,
182
+ credential: gatewayCredential,
183
+ tenantId,
184
+ tool,
185
+ crossAppModels,
186
+ });
187
+ } catch (error) {
188
+ console.error(`${RUNTIME_BRAND.cli.command} ${tool}: ${redactSecretText(error?.message || error)}`);
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+ }
144
193
  const environment = { ...process.env };
145
194
  environment.IMPEL_TENANT_ID = tenantId;
146
195
  let agentProfile;
@@ -23,6 +23,11 @@ import { runInstallRecovery } from "../installRecovery/engine.js";
23
23
  import { refreshUpdateCache, updateNoticeLine } from "../updates.js";
24
24
  import { restoreNativeProfiles } from "./use.js";
25
25
  import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
26
+ import {
27
+ cacheProviderReadiness,
28
+ probeGatewayProviderReadiness,
29
+ providerAvailable,
30
+ } from "../providerReadiness.js";
26
31
 
27
32
  const HELP = brandedText(`impel setup - prepare every accessible Impel tenant
28
33
 
@@ -52,52 +57,33 @@ export function resolveTenantChoice(listing, { requested = null, answer = null,
52
57
  return selectDefaultTenant(listing, { currentTenantId });
53
58
  }
54
59
 
55
- async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
56
- const controller = new AbortController();
57
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
58
- try {
59
- const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
60
- headers: {
61
- accept: "application/json",
62
- authorization: `Bearer ${tenantCredential(pat, tenantId)}`,
63
- },
64
- signal: controller.signal,
65
- });
66
- return {
67
- reachable: true,
68
- healthy: response.ok,
69
- status: response.status,
70
- rejected: [401, 403].includes(response.status),
71
- };
72
- } catch (error) {
73
- return {
74
- reachable: false,
75
- error: error?.name === "AbortError"
76
- ? `timed out after ${Math.round(timeoutMs / 1000)}s`
77
- : redactSecretText(error?.message || error),
78
- };
79
- } finally {
80
- clearTimeout(timeout);
81
- }
82
- }
83
-
84
- // A single short probe misreports a cold gateway (serverless cold start plus
85
- // long-haul latency) as unreachable, and that transient verdict gets baked
86
- // into the convergence failure message. Retry once with a longer deadline
87
- // before concluding the gateway is down.
88
60
  export async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch, timeoutsMs = [5_000, 10_000]) {
89
- let probe = { reachable: false, error: "not probed" };
90
- for (const timeoutMs of timeoutsMs) {
91
- probe = await probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs);
92
- if (probe.reachable) return probe;
93
- }
94
- return probe;
61
+ return probeGatewayProviderReadiness(
62
+ gatewayUrl,
63
+ tenantCredential(pat, tenantId),
64
+ tenantId,
65
+ fetchImpl,
66
+ timeoutsMs,
67
+ );
95
68
  }
96
69
 
97
- function markProbeFailures(report, probes) {
70
+ function markProbeFailures(report, probes, { crossAppModels = false } = {}) {
98
71
  probes.forEach((probe, index) => {
99
- if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
100
72
  const tenant = report.tenants[index];
73
+ if (probe.reachable && !probe.rejected && probe.healthy !== false) {
74
+ if (!Array.isArray(probe.providers)) return;
75
+ for (const client of ["claude", "codex"]) {
76
+ if (tenant.clients[client].cli !== "ready") continue;
77
+ if (!providerAvailable(client, probe.providers, { crossAppModels })) {
78
+ tenant.clients[client].cli = "unavailable";
79
+ }
80
+ }
81
+ const states = [tenant.clients.claude.cli, tenant.clients.codex.cli];
82
+ tenant.cli = states.includes("failed")
83
+ ? "failed"
84
+ : states.includes("ready") ? "ready" : "unavailable";
85
+ return;
86
+ }
101
87
  tenant.cli = "failed";
102
88
  tenant.status = "failed";
103
89
  for (const client of Object.values(tenant.clients)) {
@@ -267,6 +253,7 @@ export async function cmdSetup(argv, overrides = {}) {
267
253
  return allowed;
268
254
  };
269
255
 
256
+ const cliInstallDecisions = new Map();
270
257
  let sharedFailure = null;
271
258
  const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
272
259
  try {
@@ -285,11 +272,20 @@ export async function cmdSetup(argv, overrides = {}) {
285
272
  for (const tool of inspected.missingAfter) {
286
273
  if (!inspected.installCommands?.[tool]) continue;
287
274
  const label = tool === "claude" ? "Claude Code" : "Codex";
288
- if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
275
+ if (confirmed(await io.promptText(`Impel will download, verify, and install the official ${label} CLI for the current user. Continue? [y/N] `))) {
289
276
  installTools.push(tool);
277
+ cliInstallDecisions.set(tool, "approved");
278
+ } else {
279
+ cliInstallDecisions.set(tool, "declined");
290
280
  }
291
281
  }
282
+ } else {
283
+ for (const tool of inspected.missingAfter) {
284
+ if (inspected.installCommands?.[tool]) cliInstallDecisions.set(tool, "confirmation-required");
285
+ }
292
286
  }
287
+ } else {
288
+ for (const tool of installTools) cliInstallDecisions.set(tool, "approved");
293
289
  }
294
290
  if (installTools.length) {
295
291
  prepared = await io.preparePlatformClis({
@@ -302,7 +298,13 @@ export async function cmdSetup(argv, overrides = {}) {
302
298
  }
303
299
  }
304
300
  if (prepared.missingAfter.length) {
305
- sharedFailure = describeCliFailure(prepared);
301
+ sharedFailure = describeCliFailure(prepared, {
302
+ commandName: "setup",
303
+ cliExecutable: io.platform === "win32" ? "impel.cmd" : "impel",
304
+ isTTY: io.isTTY,
305
+ decisions: cliInstallDecisions,
306
+ skipClis: flags["skip-clis"],
307
+ });
306
308
  return prepared;
307
309
  }
308
310
  sharedFailure = null;
@@ -344,7 +346,12 @@ export async function cmdSetup(argv, overrides = {}) {
344
346
  ) => {
345
347
  const nextReport = await runConvergence(includeApps, tenants, confirmInstall);
346
348
  const probes = await Promise.all(tenants.map((tenant) => io.probe(gatewayUrl, pat, tenant.id)));
347
- return markProbeFailures(nextReport, probes);
349
+ let cacheChanged = false;
350
+ probes.forEach((probe, index) => {
351
+ cacheChanged = cacheProviderReadiness(config, tenants[index].id, probe) || cacheChanged;
352
+ });
353
+ if (cacheChanged) io.saveConfig(config);
354
+ return markProbeFailures(nextReport, probes, { crossAppModels: config.experimental?.crossAppModels === true });
348
355
  };
349
356
  let report = await verifyConvergence();
350
357
  if (sharedFailure) report.passed = false;
@@ -476,10 +483,17 @@ export async function cmdSetup(argv, overrides = {}) {
476
483
  printReconciliationSummary(report);
477
484
  console.log(`CLI tenant: ${selected.id}`);
478
485
  if (!RUNTIME_BRAND.tenant.defaultId) console.log("Change it: impel tenant use <tenant>");
479
- console.log("Launch: impel claude | impel codex");
486
+ const selectedReport = report.tenants.find((tenant) => tenant.tenantId === selected.id);
487
+ const launchable = ["claude", "codex"]
488
+ .filter((client) => selectedReport?.clients?.[client]?.cli === "ready")
489
+ .map((client) => `${RUNTIME_BRAND.cli.command} ${client}`);
490
+ console.log(launchable.length
491
+ ? `Launch: ${launchable.join(" | ")}`
492
+ : "Launch: no provider CLI is currently available for this tenant");
480
493
  if (!report.passed || sharedFailure) {
481
494
  if (sharedFailure) console.error(`Shared setup failure: ${sharedFailure}`);
482
- console.error("Setup is incomplete. Fix the reported issue or rerun `impel setup`.");
495
+ const setupCommand = io.platform === "win32" ? "impel.cmd setup" : "impel setup";
496
+ console.error(`Setup is incomplete. Fix the reported issue or rerun \`${setupCommand}\`.`);
483
497
  process.exitCode = 1;
484
498
  return report;
485
499
  }
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
5
  import { appPaths, CLAUDE_CONFIG_ID, readTenantManifest } from "../apps.js";
6
- import { loadConfig, maskSecret } from "../config.js";
6
+ import { crossAppModelsEnabled, loadConfig, maskSecret } from "../config.js";
7
7
  import { environmentValue, findNativeBinary } from "../nativeProcess.js";
8
8
  import { tenantCliClientReadiness } from "../provisioning.js";
9
9
  import { windowsTenantShortcutName } from "../shellEntries.js";
@@ -16,6 +16,8 @@ import {
16
16
  import { installedVersion, maybePrintUpdateNotice } from "../updates.js";
17
17
  import { windowsClaudeUserData } from "../windowsApps.js";
18
18
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
19
+ import { cachedProviderReadiness, providerAvailable } from "../providerReadiness.js";
20
+ import { formatWindowsClaudeCoworkCapability } from "../windowsCapabilities.js";
19
21
 
20
22
  function readShellManifest(paths) {
21
23
  try {
@@ -26,7 +28,7 @@ function readShellManifest(paths) {
26
28
  }
27
29
  }
28
30
 
29
- function desktopReadiness(tenant, {
31
+ export function desktopReadiness(tenant, {
30
32
  platform = process.platform,
31
33
  homeDir = os.homedir(),
32
34
  environment = process.env,
@@ -36,6 +38,7 @@ function desktopReadiness(tenant, {
36
38
  return {
37
39
  claude: { app: "unavailable", shell: "unavailable" },
38
40
  codex: { app: "unavailable", shell: "unavailable" },
41
+ cowork: null,
39
42
  };
40
43
  }
41
44
  const claudeUserData = platform === "win32" ? windowsClaudeUserData(environment, tenant.id) : null;
@@ -69,11 +72,13 @@ function desktopReadiness(tenant, {
69
72
  shell: shellReady ? "ready" : "missing",
70
73
  };
71
74
  }
75
+ states.cowork = manifest?.capabilities?.claudeCowork || null;
72
76
  return states;
73
77
  }
74
78
 
75
- function cliState({ supported, profileReady, binaryReady }) {
79
+ function cliState({ supported, available = true, profileReady, binaryReady }) {
76
80
  if (!supported) return "unsupported";
81
+ if (!available) return "unavailable";
77
82
  return profileReady && binaryReady ? "ready" : "missing";
78
83
  }
79
84
 
@@ -123,27 +128,37 @@ export async function cmdStatus(overrides = {}) {
123
128
  };
124
129
  console.log("Tenant readiness:");
125
130
  let incomplete = false;
131
+ let currentCowork = null;
132
+ let fallbackCowork = null;
126
133
  for (const tenant of [...selected.tenants].sort((left, right) => left.id.localeCompare(right.id))) {
127
134
  const profiles = io.cliReadiness(tenant.id);
128
135
  const desktop = io.desktopReadiness(tenant, overrides);
136
+ const providerReadiness = cachedProviderReadiness(config, tenant.id);
129
137
  const claudeCli = cliState({
130
138
  supported: !scopeKnown || scopes.has(PAT_SCOPE_CLAUDE),
139
+ available: !providerReadiness || providerAvailable("claude", providerReadiness.providers, {
140
+ crossAppModels: crossAppModelsEnabled(config),
141
+ }),
131
142
  profileReady: profiles.claude,
132
143
  binaryReady: binaries.claude,
133
144
  });
134
145
  const codexCli = cliState({
135
146
  supported: !scopeKnown || scopes.has(PAT_SCOPE_CODEX),
147
+ available: !providerReadiness || providerAvailable("codex", providerReadiness.providers),
136
148
  profileReady: profiles.codex,
137
149
  binaryReady: binaries.codex,
138
150
  });
139
151
  const states = [claudeCli, codexCli, desktop.claude.app, desktop.codex.app, desktop.claude.shell, desktop.codex.shell];
140
152
  if (states.includes("missing")) incomplete = true;
153
+ fallbackCowork ||= desktop.cowork || null;
154
+ if (tenant.id === selected.tenantId) currentCowork = desktop.cowork || null;
141
155
  console.log(
142
156
  ` ${tenant.id}${tenant.id === selected.tenantId ? " (current)" : ""}: `
143
157
  + `Claude CLI ${claudeCli}, Codex CLI ${codexCli}, `
144
158
  + `Claude app ${desktop.claude.app}, ChatGPT app ${desktop.codex.app}`,
145
159
  );
146
160
  }
161
+ for (const line of formatWindowsClaudeCoworkCapability(currentCowork || fallbackCowork)) console.log(line);
147
162
  if (incomplete) {
148
163
  console.log(RUNTIME_BRAND.tenant.defaultId
149
164
  ? `Repair or finish missing tenant surfaces with: ${RUNTIME_BRAND.cli.command} setup`
@@ -31,19 +31,39 @@ export async function preparePlatformClis(options = {}, dependencies = {}) {
31
31
  };
32
32
  }
33
33
 
34
- export function describeCliFailure(prepared) {
34
+ export function describeCliFailure(prepared, {
35
+ commandName = "setup",
36
+ cliExecutable = "impel",
37
+ isTTY = true,
38
+ decisions = {},
39
+ skipClis = false,
40
+ } = {}) {
35
41
  const labels = { claude: "Claude Code", codex: "Codex" };
36
42
  const details = [];
37
43
  for (const tool of prepared.missingAfter || []) {
38
44
  const installation = prepared.installations?.[tool];
39
45
  const failure = installation?.failure;
46
+ const decision = decisions instanceof Map ? decisions.get(tool) : decisions?.[tool];
47
+ const attempted = Boolean(
48
+ installation?.attempted
49
+ || failure
50
+ || installation?.succeeded === false,
51
+ );
40
52
  const reason = failure?.message || failure?.code
41
53
  || (Number.isInteger(failure?.status)
42
54
  ? `exit ${failure.status}`
43
- : installation ? "not discoverable after installation" : "not installed or discoverable");
55
+ : attempted
56
+ ? "not discoverable after installation"
57
+ : decision === "declined"
58
+ ? "automatic installation was declined"
59
+ : skipClis
60
+ ? "installation was skipped by --skip-clis"
61
+ : !isTTY && prepared.installCommands?.[tool]
62
+ ? `interactive confirmation required; rerun \`${cliExecutable} ${commandName}\` interactively (or use \`--skip-clis\` to leave it uninstalled)`
63
+ : "not installed or discoverable");
44
64
  details.push(`${labels[tool] || tool}: ${redactSecretText(reason)}`);
45
65
  const command = installation?.command || prepared.installCommands?.[tool];
46
- if (command) details.push(`manual ${tool} installer: ${redactSecretText(command)}`);
66
+ if (attempted && command) details.push(`manual ${tool} recovery command: ${redactSecretText(command)}`);
47
67
  }
48
68
  return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
49
69
  }
@@ -0,0 +1,122 @@
1
+ import { redactSecretText } from "./config.js";
2
+
3
+ const PROVIDERS = new Set(["claude", "codex"]);
4
+ const POOL_STATES = new Set(["healthy", "exhausted", "rate_limited", "expired", "no_seat"]);
5
+ export const PROVIDER_READINESS_CACHE_MS = 60_000;
6
+
7
+ function providersFromPayload(payload) {
8
+ if (!Array.isArray(payload?.data)) return { error: "gateway model catalog has no model data array" };
9
+ const statuses = {};
10
+ if (Object.hasOwn(payload, "provider_status")) {
11
+ const raw = payload.provider_status;
12
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
13
+ return { error: "gateway model catalog has malformed provider status metadata" };
14
+ }
15
+ for (const provider of PROVIDERS) {
16
+ if (!Object.hasOwn(raw, provider)) continue;
17
+ const status = raw[provider];
18
+ if (!status
19
+ || typeof status !== "object"
20
+ || Array.isArray(status)
21
+ || !POOL_STATES.has(status.state)
22
+ || typeof status.routable !== "boolean") {
23
+ return { error: "gateway model catalog has malformed provider status metadata" };
24
+ }
25
+ statuses[provider] = status;
26
+ }
27
+ }
28
+ const providers = [...new Set(payload.data
29
+ .filter((model) => model?.available !== false && model?.ready !== false && model?.readiness?.ready !== false)
30
+ .map((model) => model?.provider)
31
+ .filter((provider) => PROVIDERS.has(provider))
32
+ .filter((provider) => !statuses[provider]
33
+ || (statuses[provider].state === "healthy" && statuses[provider].routable === true)))]
34
+ .sort();
35
+ return { providers };
36
+ }
37
+
38
+ async function probeGatewayOnce(gatewayUrl, credential, tenantId, fetchImpl, timeoutMs) {
39
+ const controller = new AbortController();
40
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
41
+ try {
42
+ const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
43
+ headers: {
44
+ accept: "application/json",
45
+ authorization: `Bearer ${credential}`,
46
+ },
47
+ signal: controller.signal,
48
+ });
49
+ const result = {
50
+ reachable: true,
51
+ healthy: response.ok,
52
+ status: response.status,
53
+ rejected: [401, 403].includes(response.status),
54
+ };
55
+ if (!response.ok || typeof response.json !== "function") return result;
56
+
57
+ let payload;
58
+ try {
59
+ payload = await response.json();
60
+ } catch {
61
+ return { ...result, healthy: false, error: "gateway model catalog was not valid JSON" };
62
+ }
63
+ const catalog = providersFromPayload(payload);
64
+ if (catalog.error) {
65
+ return { ...result, healthy: false, error: catalog.error };
66
+ }
67
+ if (payload.org_id && payload.org_id !== tenantId) {
68
+ return { ...result, healthy: false, error: "gateway model catalog tenant did not match the selected tenant" };
69
+ }
70
+ return { ...result, providers: catalog.providers };
71
+ } catch (error) {
72
+ return {
73
+ reachable: false,
74
+ error: error?.name === "AbortError"
75
+ ? `timed out after ${Math.round(timeoutMs / 1000)}s`
76
+ : redactSecretText(error?.message || error),
77
+ };
78
+ } finally {
79
+ clearTimeout(timeout);
80
+ }
81
+ }
82
+
83
+ // Retry one cold/unreachable gateway probe with a longer deadline, but never
84
+ // retry an authoritative HTTP or model-catalog response.
85
+ export async function probeGatewayProviderReadiness(
86
+ gatewayUrl,
87
+ credential,
88
+ tenantId,
89
+ fetchImpl = fetch,
90
+ timeoutsMs = [5_000, 10_000],
91
+ ) {
92
+ let probe = { reachable: false, error: "not probed" };
93
+ for (const timeoutMs of timeoutsMs) {
94
+ probe = await probeGatewayOnce(gatewayUrl, credential, tenantId, fetchImpl, timeoutMs);
95
+ if (probe.reachable) return probe;
96
+ }
97
+ return probe;
98
+ }
99
+
100
+ export function cacheProviderReadiness(config, tenantId, probe, checkedAt = new Date()) {
101
+ if (!probe?.healthy || !Array.isArray(probe.providers)) return false;
102
+ config.providerReadiness ||= {};
103
+ config.providerReadiness[tenantId] = {
104
+ checkedAt: checkedAt.toISOString(),
105
+ providers: [...new Set(probe.providers.filter((provider) => PROVIDERS.has(provider)))].sort(),
106
+ };
107
+ return true;
108
+ }
109
+
110
+ export function cachedProviderReadiness(config, tenantId, now = Date.now()) {
111
+ const record = config?.providerReadiness?.[tenantId];
112
+ const checkedAt = Date.parse(record?.checkedAt || "");
113
+ if (!Number.isFinite(checkedAt) || checkedAt > now || now - checkedAt > PROVIDER_READINESS_CACHE_MS) return null;
114
+ if (!Array.isArray(record.providers) || record.providers.some((provider) => !PROVIDERS.has(provider))) return null;
115
+ return { healthy: true, reachable: true, providers: [...new Set(record.providers)].sort(), cached: true };
116
+ }
117
+
118
+ export function providerAvailable(tool, providers, { crossAppModels = false } = {}) {
119
+ if (!Array.isArray(providers)) return false;
120
+ if (tool === "claude" && crossAppModels) return providers.length > 0;
121
+ return providers.includes(tool);
122
+ }
@@ -17,6 +17,10 @@ import {
17
17
  } from "./tenants.js";
18
18
  import { reconcileTenantApps } from "./commands/apps.js";
19
19
  import { RUNTIME_BRAND } from "./runtimeBrand.js";
20
+ import {
21
+ formatWindowsClaudeCoworkCapability,
22
+ inspectWindowsClaudeCoworkCapability,
23
+ } from "./windowsCapabilities.js";
20
24
 
21
25
  export function selectDefaultTenant(listing, { requested = null, currentTenantId = null } = {}) {
22
26
  const normalized = requested ? normalizeTenantId(requested) : null;
@@ -185,6 +189,7 @@ export async function reconcileAllTenants({
185
189
  reconcileApps: reconcileTenantApps,
186
190
  registerShellEntries: registerTenantShellEntries,
187
191
  knownTenantIds: locallyKnownTenantIds,
192
+ inspectCoworkCapability: inspectWindowsClaudeCoworkCapability,
188
193
  ...overrides,
189
194
  };
190
195
  // Dependency-injection compatibility for older embedders/tests. Production
@@ -208,6 +213,9 @@ export async function reconcileAllTenants({
208
213
  claude: io.findBinary("claude", environment, platform),
209
214
  codex: io.findBinary("codex", environment, platform),
210
215
  };
216
+ const windowsCoworkCapability = includeApps && platform === "win32"
217
+ ? io.inspectCoworkCapability({ environment, platform })
218
+ : null;
211
219
  const results = new Map(liveTenants.map((tenant) => [tenant.id, {
212
220
  tenantId: tenant.id,
213
221
  tenantName: tenant.name,
@@ -262,6 +270,7 @@ export async function reconcileAllTenants({
262
270
  homeDir,
263
271
  environment,
264
272
  confirmVendorInstall,
273
+ windowsCoworkCapability,
265
274
  }, overrides.appOverrides || {});
266
275
  supported = new Set(installed.targets || []);
267
276
  const failedApps = installed.failed || [];
@@ -333,6 +342,7 @@ export async function reconcileAllTenants({
333
342
  selectedTenantId: config.tenantId,
334
343
  tenants: values,
335
344
  inaccessible,
345
+ ...(windowsCoworkCapability ? { capabilities: { claudeCowork: windowsCoworkCapability } } : {}),
336
346
  passed: values.every((result) => (
337
347
  (result.cli === "ready" || result.cli === "unavailable")
338
348
  && (result.apps === "ready" || result.apps === "unavailable")
@@ -366,6 +376,7 @@ export function printReconciliationSummary(report, log = console.log) {
366
376
  if (report.inaccessible.length) {
367
377
  log(` Retained local data for inaccessible tenants: ${report.inaccessible.join(", ")}`);
368
378
  }
379
+ for (const line of formatWindowsClaudeCoworkCapability(report.capabilities?.claudeCowork)) log(line);
369
380
  }
370
381
 
371
382
  export function tenantCliPathsReady(tenantId, existsSync = fs.existsSync) {
@@ -8,7 +8,11 @@ import { redactSecretText } from "./config.js";
8
8
  import { environmentValue, nativeCommandInvocation, resolveNativeBinary } from "./nativeProcess.js";
9
9
  import { managedCliEntrypoint } from "./selfInvocation.js";
10
10
  import { normalizeTenantId } from "./tenants.js";
11
- import { windowsClaudeUserData } from "./windowsApps.js";
11
+ import {
12
+ findManagedWindowsChatGPTApp,
13
+ findWindowsClaudeApp,
14
+ windowsClaudeUserData,
15
+ } from "./windowsApps.js";
12
16
  import { RUNTIME_BRAND } from "./runtimeBrand.js";
13
17
 
14
18
  const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
@@ -151,6 +155,8 @@ export function registerTenantShellEntries({
151
155
  run = spawnSync,
152
156
  writeManifest = writeShellEntryManifest,
153
157
  shortcut = registerWindowsTenantShortcut,
158
+ findClaudeApp = findWindowsClaudeApp,
159
+ findChatGPTApp = findManagedWindowsChatGPTApp,
154
160
  } = {}) {
155
161
  const id = normalizeTenantId(tenantId);
156
162
  const paths = appPaths(homeDir, id, {
@@ -175,9 +181,27 @@ export function registerTenantShellEntries({
175
181
  };
176
182
  for (const product of targets) {
177
183
  if (!available[product]) throw new Error(`managed ${product} profile is missing for tenant ${id}`);
184
+ let iconExecutable;
185
+ try {
186
+ iconExecutable = product === "claude"
187
+ ? findClaudeApp(environment)
188
+ : findChatGPTApp(paths.root);
189
+ } catch (error) {
190
+ throw new Error(`could not resolve ${product} shortcut icon (${redactSecretText(error?.message || String(error))})`);
191
+ }
192
+ if (!iconExecutable || !path.win32.isAbsolute(iconExecutable)) {
193
+ throw new Error(`managed ${product} shortcut icon executable is unavailable for tenant ${id}`);
194
+ }
178
195
  registered.push({
179
196
  product,
180
- path: shortcut({ product, tenantId: id, tenantName, environment, run }),
197
+ path: shortcut({
198
+ product,
199
+ tenantId: id,
200
+ tenantName,
201
+ environment,
202
+ run,
203
+ iconPath: `${path.win32.normalize(iconExecutable)},0`,
204
+ }),
181
205
  status: "ready",
182
206
  });
183
207
  }
@@ -0,0 +1,334 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+
4
+ import { PINNED_VENDOR_APPS } from "./apps.js";
5
+ import { environmentValue, nativeCommandInvocation } from "./nativeProcess.js";
6
+
7
+ export const WINDOWS_COWORK_REMEDIATION = Object.freeze({
8
+ enableVirtualMachinePlatform: "Enable the Windows Virtual Machine Platform optional feature, then restart Windows.",
9
+ restartWindows: "Restart Windows to finish Virtual Machine Platform and service provisioning.",
10
+ restoreHcs: "Ask an administrator or endpoint manager to restore the vmcompute, hns, and vfpext Windows components.",
11
+ provisionClaude: "Ask an administrator or endpoint manager to machine-provision the exact pinned Claude MSIX with Add-AppxProvisionedPackage.",
12
+ enableNestedVirtualization: "Enable nested virtualization for this VM, or use a physical Windows host for Claude Cowork.",
13
+ inspectHost: "Ask an administrator to verify Virtual Machine Platform, HCS services, vfpext, and machine-wide Claude provisioning.",
14
+ });
15
+
16
+ const WINDOWS_PACKAGE_BASE32 = "0123456789abcdefghjkmnpqrstvwxyz";
17
+
18
+ /** Derive the stable Package Family Name publisher id from the exact DN. */
19
+ export function windowsPackagePublisherId(publisher) {
20
+ const hash = crypto.createHash("sha256").update(Buffer.from(String(publisher), "utf16le")).digest();
21
+ let bits = "";
22
+ for (const byte of hash.subarray(0, 8)) bits += byte.toString(2).padStart(8, "0");
23
+ bits += "0";
24
+ let publisherId = "";
25
+ for (let offset = 0; offset < 65; offset += 5) {
26
+ publisherId += WINDOWS_PACKAGE_BASE32[Number.parseInt(bits.slice(offset, offset + 5), 2)];
27
+ }
28
+ return publisherId;
29
+ }
30
+
31
+ export function windowsPackageFullName({ name, version, architecture, publisherId }) {
32
+ return `${name}_${version}_${String(architecture).toLowerCase()}__${publisherId}`;
33
+ }
34
+
35
+ export function windowsProvisionedPackageIdentityMatches(candidate, expected) {
36
+ if (!candidate || !expected) return false;
37
+ const expectedFullName = windowsPackageFullName(expected);
38
+ return candidate.displayName === expected.name
39
+ && candidate.version === expected.version
40
+ && String(candidate.architecture).toLowerCase() === String(expected.architecture).toLowerCase()
41
+ && candidate.packageName === expectedFullName;
42
+ }
43
+
44
+ // Fixed, read-only host observation. Expected package metadata is passed only
45
+ // through fixed environment keys; no tenant, user, or path is interpolated
46
+ // into PowerShell source.
47
+ export const WINDOWS_COWORK_INSPECTION_SCRIPT = String.raw`
48
+ $ErrorActionPreference = 'Stop'
49
+ function Read-ServiceState([string]$Name) {
50
+ try {
51
+ $service = Get-Service -Name $Name -ErrorAction SilentlyContinue
52
+ if ($null -eq $service) { return 'missing' }
53
+ if ($service.Status -eq [System.ServiceProcess.ServiceControllerStatus]::Running) { return 'present-running' }
54
+ return 'present-stopped'
55
+ } catch { return 'unknown' }
56
+ }
57
+ function Read-DriverState([string]$Name) {
58
+ try {
59
+ $driver = Get-CimInstance -ClassName Win32_SystemDriver -Filter "Name='$Name'" -ErrorAction SilentlyContinue
60
+ if ($null -eq $driver) { return 'missing' }
61
+ if ($driver.State -eq 'Running') { return 'present-running' }
62
+ return 'present-stopped'
63
+ } catch { return 'unknown' }
64
+ }
65
+ $vmp = 'unknown'
66
+ try {
67
+ $feature = Get-CimInstance -ClassName Win32_OptionalFeature -Filter "Name='VirtualMachinePlatform'" -ErrorAction Stop
68
+ if ($null -eq $feature) { $vmp = 'absent' }
69
+ elseif ($feature.InstallState -eq 1) { $vmp = 'enabled' }
70
+ elseif ($feature.InstallState -eq 2) { $vmp = 'disabled' }
71
+ elseif ($feature.InstallState -eq 3) { $vmp = 'absent' }
72
+ } catch { $vmp = 'unknown' }
73
+ $restartPending = $null
74
+ try {
75
+ $cbs = Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
76
+ $wu = Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
77
+ $restartPending = [bool]($cbs -or $wu)
78
+ } catch { $restartPending = $null }
79
+ $machineProvisioned = $null
80
+ try {
81
+ $expectedName = $env:IMPEL_COWORK_EXPECTED_PACKAGE_NAME
82
+ $expectedVersion = $env:IMPEL_COWORK_EXPECTED_PACKAGE_VERSION
83
+ $expectedArchitecture = $env:IMPEL_COWORK_EXPECTED_PACKAGE_ARCHITECTURE
84
+ $expectedPublisherId = $env:IMPEL_COWORK_EXPECTED_PACKAGE_PUBLISHER_ID
85
+ $expectedPackageName = $expectedName + '_' + $expectedVersion + '_' + $expectedArchitecture.ToLowerInvariant() + '__' + $expectedPublisherId
86
+ $packages = Get-AppxProvisionedPackage -Online -ErrorAction Stop
87
+ $exact = $packages | Where-Object {
88
+ $displayMatches = ([string]$_.DisplayName -ceq $expectedName)
89
+ $versionMatches = ([string]$_.Version -ceq $expectedVersion)
90
+ $architectureMatches = ([string]$_.Architecture -ieq $expectedArchitecture)
91
+ $packageNameMatches = ([string]$_.PackageName -ceq $expectedPackageName)
92
+ $displayMatches -and $versionMatches -and $architectureMatches -and $packageNameMatches
93
+ } | Select-Object -First 1
94
+ $machineProvisioned = [bool]($null -ne $exact)
95
+ } catch { $machineProvisioned = $null }
96
+ $virtualMachine = $null
97
+ $nestedVirtualization = $null
98
+ try {
99
+ $computer = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
100
+ $identity = (([string]$computer.Manufacturer) + ' ' + ([string]$computer.Model))
101
+ $virtualMachine = [bool]($identity -match 'Virtual|VMware|KVM|HVM|Hyper-V|Parallels|Xen')
102
+ if ($virtualMachine) {
103
+ $processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
104
+ $nestedVirtualization = [bool]($processors.Count -gt 0 -and ($processors | Where-Object {
105
+ -not ($_.VirtualizationFirmwareEnabled -and $_.VMMonitorModeExtensions)
106
+ }).Count -eq 0)
107
+ } else {
108
+ $nestedVirtualization = $true
109
+ }
110
+ } catch {
111
+ $virtualMachine = $null
112
+ $nestedVirtualization = $null
113
+ }
114
+ [ordered]@{
115
+ schemaVersion = 1
116
+ virtualMachinePlatform = $vmp
117
+ restartPending = $restartPending
118
+ vmcompute = Read-ServiceState 'vmcompute'
119
+ hns = Read-ServiceState 'hns'
120
+ vfpext = Read-DriverState 'vfpext'
121
+ machineProvisioned = $machineProvisioned
122
+ virtualMachine = $virtualMachine
123
+ nestedVirtualization = $nestedVirtualization
124
+ } | ConvertTo-Json -Compress -Depth 3
125
+ `.trim();
126
+
127
+ const VMP_STATES = new Set(["enabled", "disabled", "absent", "unknown"]);
128
+ const COMPONENT_STATES = new Set(["present-running", "present-stopped", "missing", "unknown"]);
129
+
130
+ function emptyChecks() {
131
+ return {
132
+ virtualMachinePlatform: "unknown",
133
+ restartPending: null,
134
+ vmcompute: "unknown",
135
+ hns: "unknown",
136
+ vfpext: "unknown",
137
+ machineProvisioned: null,
138
+ virtualMachine: null,
139
+ nestedVirtualization: null,
140
+ };
141
+ }
142
+
143
+ function normalizedFailure(reasonCode) {
144
+ return {
145
+ schemaVersion: 1,
146
+ status: "unknown",
147
+ enabled: false,
148
+ checks: emptyChecks(),
149
+ reasonCodes: [reasonCode],
150
+ remediation: [WINDOWS_COWORK_REMEDIATION.inspectHost],
151
+ };
152
+ }
153
+
154
+ export function unknownWindowsClaudeCoworkCapability(reasonCode = "inspector-not-run") {
155
+ return normalizedFailure(reasonCode);
156
+ }
157
+
158
+ function nullableBoolean(value) {
159
+ return value === null || typeof value === "boolean";
160
+ }
161
+
162
+ export function normalizeWindowsClaudeCoworkCapability(observation) {
163
+ if (
164
+ !observation
165
+ || observation.schemaVersion !== 1
166
+ || !VMP_STATES.has(observation.virtualMachinePlatform)
167
+ || !COMPONENT_STATES.has(observation.vmcompute)
168
+ || !COMPONENT_STATES.has(observation.hns)
169
+ || !COMPONENT_STATES.has(observation.vfpext)
170
+ || !nullableBoolean(observation.restartPending)
171
+ || !nullableBoolean(observation.machineProvisioned)
172
+ || !nullableBoolean(observation.virtualMachine)
173
+ || !nullableBoolean(observation.nestedVirtualization)
174
+ ) return normalizedFailure("inspector-malformed-output");
175
+
176
+ const checks = {
177
+ virtualMachinePlatform: observation.virtualMachinePlatform,
178
+ restartPending: observation.restartPending,
179
+ vmcompute: observation.vmcompute,
180
+ hns: observation.hns,
181
+ vfpext: observation.vfpext,
182
+ machineProvisioned: observation.machineProvisioned,
183
+ virtualMachine: observation.virtualMachine,
184
+ nestedVirtualization: observation.nestedVirtualization,
185
+ };
186
+ const reasonCodes = [];
187
+ const knownUnavailable = [];
188
+ const unknown = [];
189
+ const addKnown = (reason) => {
190
+ reasonCodes.push(reason);
191
+ knownUnavailable.push(reason);
192
+ };
193
+ const addUnknown = (reason) => {
194
+ reasonCodes.push(reason);
195
+ unknown.push(reason);
196
+ };
197
+
198
+ if (checks.virtualMachinePlatform === "disabled") addKnown("virtual-machine-platform-disabled");
199
+ else if (checks.virtualMachinePlatform === "absent") addKnown("virtual-machine-platform-absent");
200
+ else if (checks.virtualMachinePlatform === "unknown") addUnknown("virtual-machine-platform-unknown");
201
+
202
+ if (checks.virtualMachinePlatform === "enabled") {
203
+ for (const component of ["vmcompute", "hns", "vfpext"]) {
204
+ if (checks[component] === "missing") addKnown(`${component}-missing`);
205
+ else if (checks[component] === "unknown") addUnknown(`${component}-unknown`);
206
+ }
207
+ }
208
+ const componentsReady = [checks.vmcompute, checks.hns, checks.vfpext]
209
+ .every((state) => state.startsWith("present-"));
210
+ if (checks.restartPending === true && (
211
+ checks.virtualMachinePlatform !== "enabled" || !componentsReady
212
+ )) addKnown("restart-required");
213
+ else if (checks.restartPending === null && (
214
+ checks.virtualMachinePlatform !== "enabled" || !componentsReady
215
+ )) addUnknown("restart-state-unknown");
216
+
217
+ if (checks.machineProvisioned === false) addKnown("claude-machine-provisioning-missing");
218
+ else if (checks.machineProvisioned === null) addUnknown("claude-machine-provisioning-unknown");
219
+
220
+ if (checks.virtualMachine === null) addUnknown("virtualization-environment-unknown");
221
+ else if (checks.virtualMachine && checks.nestedVirtualization === false) addKnown("nested-virtualization-unavailable");
222
+ else if (checks.virtualMachine && checks.nestedVirtualization === null) addUnknown("nested-virtualization-unknown");
223
+
224
+ const enabled = checks.virtualMachinePlatform === "enabled"
225
+ && componentsReady
226
+ && checks.machineProvisioned === true
227
+ && checks.virtualMachine !== null
228
+ && (!checks.virtualMachine || checks.nestedVirtualization === true);
229
+ if (enabled) {
230
+ return {
231
+ schemaVersion: 1,
232
+ status: "available",
233
+ enabled: true,
234
+ checks,
235
+ reasonCodes: [],
236
+ remediation: [],
237
+ };
238
+ }
239
+
240
+ const remediation = [];
241
+ const addRemediation = (message) => {
242
+ if (!remediation.includes(message)) remediation.push(message);
243
+ };
244
+ if (reasonCodes.some((reason) => reason.startsWith("virtual-machine-platform-"))) {
245
+ addRemediation(WINDOWS_COWORK_REMEDIATION.enableVirtualMachinePlatform);
246
+ }
247
+ if (reasonCodes.includes("restart-required")) addRemediation(WINDOWS_COWORK_REMEDIATION.restartWindows);
248
+ if (reasonCodes.some((reason) => /^(?:vmcompute|hns|vfpext)-missing$/u.test(reason))) {
249
+ addRemediation(WINDOWS_COWORK_REMEDIATION.restoreHcs);
250
+ }
251
+ if (reasonCodes.includes("claude-machine-provisioning-missing")) {
252
+ addRemediation(WINDOWS_COWORK_REMEDIATION.provisionClaude);
253
+ }
254
+ if (reasonCodes.includes("nested-virtualization-unavailable")) {
255
+ addRemediation(WINDOWS_COWORK_REMEDIATION.enableNestedVirtualization);
256
+ }
257
+ if (unknown.length) addRemediation(WINDOWS_COWORK_REMEDIATION.inspectHost);
258
+
259
+ return {
260
+ schemaVersion: 1,
261
+ status: knownUnavailable.length ? "unavailable" : "unknown",
262
+ enabled: false,
263
+ checks,
264
+ reasonCodes,
265
+ remediation,
266
+ };
267
+ }
268
+
269
+ export function inspectWindowsClaudeCoworkCapability({
270
+ environment = process.env,
271
+ platform = process.platform,
272
+ run = spawnSync,
273
+ architecture = process.arch,
274
+ } = {}) {
275
+ if (platform !== "win32") return normalizedFailure("inspector-not-windows");
276
+ const expectedArchitecture = /arm64/iu.test(
277
+ environmentValue(environment, "PROCESSOR_ARCHITEW6432")
278
+ || environmentValue(environment, "PROCESSOR_ARCHITECTURE")
279
+ || architecture,
280
+ ) ? "arm64" : "x64";
281
+ const pin = PINNED_VENDOR_APPS.claude.windows;
282
+ if (windowsPackagePublisherId(pin.publisher) !== pin.publisherId) {
283
+ return normalizedFailure("inspector-pinned-publisher-invalid");
284
+ }
285
+ let invocation;
286
+ try {
287
+ invocation = nativeCommandInvocation("powershell", [
288
+ "-NoLogo",
289
+ "-NoProfile",
290
+ "-NonInteractive",
291
+ "-ExecutionPolicy",
292
+ "Bypass",
293
+ "-Command",
294
+ WINDOWS_COWORK_INSPECTION_SCRIPT,
295
+ ], environment, "win32");
296
+ } catch {
297
+ return normalizedFailure("inspector-powershell-failed");
298
+ }
299
+ let result;
300
+ try {
301
+ result = run(invocation.command, invocation.args, {
302
+ encoding: "utf8",
303
+ env: {
304
+ ...environment,
305
+ IMPEL_COWORK_EXPECTED_PACKAGE_NAME: pin.packageName,
306
+ IMPEL_COWORK_EXPECTED_PACKAGE_VERSION: pin.packageVersion,
307
+ IMPEL_COWORK_EXPECTED_PACKAGE_ARCHITECTURE: expectedArchitecture,
308
+ IMPEL_COWORK_EXPECTED_PACKAGE_PUBLISHER_ID: pin.publisherId,
309
+ },
310
+ stdio: ["ignore", "pipe", "pipe"],
311
+ windowsHide: true,
312
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
313
+ });
314
+ } catch {
315
+ return normalizedFailure("inspector-powershell-failed");
316
+ }
317
+ if (result?.error || result?.status !== 0) return normalizedFailure("inspector-powershell-failed");
318
+ try {
319
+ return normalizeWindowsClaudeCoworkCapability(JSON.parse(String(result.stdout || "").trim()));
320
+ } catch {
321
+ return normalizedFailure("inspector-malformed-output");
322
+ }
323
+ }
324
+
325
+ export function formatWindowsClaudeCoworkCapability(capability) {
326
+ if (!capability || capability.enabled === true) return [];
327
+ const reasons = Array.isArray(capability.reasonCodes) && capability.reasonCodes.length
328
+ ? capability.reasonCodes.join(", ")
329
+ : "host-prerequisites-unknown";
330
+ return [
331
+ `Claude Cowork ${capability.status || "unknown"} (${reasons}). Chat and Code remain available.`,
332
+ ...(Array.isArray(capability.remediation) ? capability.remediation.map((message) => ` ${message}`) : []),
333
+ ];
334
+ }