# Desktop Extension And Model Capability Fixes Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make Desktop show Pi's effective extensions, provide accurate and explicit GPT-5.6 capability guidance, and make the footer thinking control reflect only the active model's supported levels.

**Architecture:** Put extension resolution, model recommendation state, DOM patching, and thinking-level state into focused testable modules. `index.ts` remains the adapter between Pi APIs and the webview; it captures immutable session context, delegates package resolution to Pi, and sends normalized snapshots. `web/app.js` renders those snapshots and invokes pure helpers without owning provider protocol rules.

**Tech Stack:** TypeScript extension entry point, ESM JavaScript helpers, Pi 0.80.4 `SettingsManager`/`DefaultPackageManager`, browser JavaScript, Node.js assertion scripts, jsdom 29.1.1.

---

## File Map And Boundaries

- Create `extension-discovery.js`: context-keyed cache, normal/forced refresh scheduling, Pi resolver adapter, settings-error handling, and sanitized snapshots.
- Create `extension-view-bridge.js`: active-context gating and guaranteed initial/final webview snapshot delivery.
- Rewrite `extension-package-utils.js`: presentation-only conversion from Pi `ResolvedResource` records and URL credential redaction; no package identity/resolution parsing or filesystem discovery.
- Create `thinking-level-utils.js`: provider-facing label and supported-level cycling helpers shared by `index.ts` and the inlined browser runtime.
- Rewrite `web/model-provider-state.js`: GPT-5.6 capability facts, API-specific transport recommendations, applied/pending state, and explicit apply operations.
- Create `web/model-provider-view.js`: mutate the existing model draft and patch only stable recommendation containers.
- Modify `index.ts`: wire the new modules to Pi, remove independent package parsing, send extension/thinking snapshots, and preserve active-context isolation.
- Modify `web/app.js`: render extension loading/errors, capability and transport actions, stable model recommendations, and supported footer thinking states.
- Do not modify `host-ui-utils.js`, `scripts/test-host-ui-utils.mjs`, or Host UI bridge blocks already changed in `index.ts`, `web/app.js`, `web/index.html`, and `scripts/test-pi-0803-event-bridge.mjs` except where a thinking-state assertion is explicitly listed below.

## Dirty Worktree Rule

The implementation starts from a worktree containing pre-existing edits. Before editing a shared file, save its current `git diff -- <path>` output outside the repository as the comparison baseline for that task. Before every commit, inspect `git diff` and stage only task-owned hunks. Never reset, restore, or overwrite unrelated Host UI changes. For shared files (`index.ts`, `web/app.js`, `package.json`, and `scripts/test-pi-0803-event-bridge.mjs`), use selective staging and verify both `git diff --cached` and the saved baseline before committing. `package-lock.json` may be staged as a whole only when it was clean before `npm install` and its diff contains only the exact jsdom dependency graph.

Use this preflight before Task 3, Task 5, Task 6, and Task 8, changing the task number in `$task`:

```powershell
$task = 'task3'
$baselineDir = Join-Path $env:TEMP 'pi-desktop-plan-baselines'
New-Item -ItemType Directory -Force -Path $baselineDir | Out-Null
foreach ($path in @('index.ts', 'web/app.js', 'package.json', 'scripts/test-pi-0803-event-bridge.mjs')) {
  $safeName = $path -replace '[\\/]', '-'
  git diff -- $path | Set-Content -LiteralPath (Join-Path $baselineDir "$task-$safeName.diff")
}
```

The baseline files are read-only review aids. They are never applied back to the worktree.

---

### Task 1: Resolved Extension Presentation

**Files:**
- Modify: `extension-package-utils.js`
- Modify: `scripts/test-extension-package-utils.mjs`

- [x] **Step 1: Replace parser-oriented tests with failing resolver-presentation tests**

Test records shaped like Pi's exported `ResolvedResource`:

```js
import assert from "node:assert/strict";
import { join } from "node:path";
import { toExtensionDisplayRecord } from "../extension-package-utils.js";

const packageRecord = toExtensionDisplayRecord({
  path: join("C:\\pi", "npm", "node_modules", "@hhyy668", "claude-workflow-for-pi", "extensions", "claude-workflow", "index.ts"),
  enabled: true,
  metadata: { source: "npm:@hhyy668/claude-workflow-for-pi@0.1.0", scope: "user", origin: "package" },
});
assert.deepEqual(packageRecord, {
  name: "claude-workflow",
  source: "npm:@hhyy668/claude-workflow-for-pi@0.1.0",
  sourceKind: "package",
  type: "package",
  scope: "user",
  path: packageRecord.path,
});

assert.deepEqual(toExtensionDisplayRecord({
  path: join("C:\\repo", ".pi", "extensions", "review.js"),
  enabled: true,
  metadata: { source: "auto", scope: "project", origin: "top-level", baseDir: join("C:\\repo", ".pi") },
}), {
  name: "review",
  source: null,
  sourceKind: "auto",
  type: "auto",
  scope: "project",
  path: join("C:\\repo", ".pi", "extensions", "review.js"),
});

assert.deepEqual(toExtensionDisplayRecord({
  path: join("C:\\agent", "extensions", "cmux", "index.ts"),
  enabled: true,
  metadata: { source: "local", scope: "user", origin: "top-level" },
}), {
  name: "cmux",
  source: null,
  sourceKind: "settings",
  type: "local",
  scope: "user",
  path: join("C:\\agent", "extensions", "cmux", "index.ts"),
});

assert.equal(toExtensionDisplayRecord({
  path: join("C:\\agent", "git", "private", "extension.ts"),
  enabled: true,
  metadata: { source: "git:https://token@example.test/private/repo.git?access=secret#main", scope: "user", origin: "package" },
}).source, "git:https://example.test/private/repo.git");

assert.equal(toExtensionDisplayRecord({
  path: join("C:\\agent", "git", "private-ssh", "extension.ts"),
  enabled: true,
  metadata: { source: "git:ssh://deploy:top-secret@example.test/private/repo.git?access=secret#main", scope: "user", origin: "package" },
}).source, "git:ssh://example.test/private/repo.git");
```

Also assert that `discoverPackageExtensions`, `getNpmPackageName`, and `getPackageDisplaySource` are no longer exported.

- [x] **Step 2: Run the focused test and confirm the old implementation fails**

Run:

```powershell
node scripts/test-extension-package-utils.mjs
```

Expected: FAIL because `toExtensionDisplayRecord` is not exported and the old parsing helpers still exist.

- [x] **Step 3: Reduce the helper to presentation-only logic**

Implement this public surface:

```js
import { basename, dirname, extname, normalize } from "node:path";

function extensionName(filePath) {
  const leaf = basename(filePath);
  const owner = /^index\.(?:ts|js)$/i.test(leaf) ? basename(dirname(filePath)) : basename(filePath, extname(filePath));
  return owner || leaf;
}

function sanitizePackageSource(source) {
  const value = String(source || "");
  const prefix = value.startsWith("git:") ? "git:" : "";
  const candidate = prefix ? value.slice(prefix.length) : value;
  if (!/^(?:https?|ssh|git):\/\//i.test(candidate)) return value;
  try {
    const url = new URL(candidate);
    url.username = "";
    url.password = "";
    url.search = "";
    url.hash = "";
    return `${prefix}${url.toString().replace(/\/$/, "")}`;
  } catch {
    return `${prefix}[invalid-url]`;
  }
}

export function toExtensionDisplayRecord(resource) {
  return {
    name: extensionName(resource.path),
    source: resource.metadata.origin === "package" ? sanitizePackageSource(resource.metadata.source) : null,
    sourceKind: resource.metadata.origin === "package" ? "package" : resource.metadata.source === "auto" ? "auto" : "settings",
    type: resource.metadata.origin === "package" ? "package" : resource.metadata.source === "auto" ? "auto" : "local",
    scope: resource.metadata.scope,
    path: normalize(resource.path),
  };
}
```

Do not read package manifests, inspect install directories, interpret package identity/version syntax, or apply filters in this file. URL handling is limited to structured credential/query/fragment removal for display safety. Apply the same structured sanitization to every URL protocol accepted by Pi 0.80.4 package sources (`http://`, `https://`, `ssh://`, and `git://`), both with and without the `git:` package prefix. Historical shorthand and SCP-like sources that are not structured URLs remain unchanged.

- [x] **Step 4: Run the focused test**

Run: `node scripts/test-extension-package-utils.mjs`

Expected: `extension package utility checks passed` and exit code `0`.

- [x] **Step 5: Commit the presentation helper**

```powershell
git add extension-package-utils.js scripts/test-extension-package-utils.mjs
git diff --cached --check
git commit -m "refactor: derive extension labels from pi resources"
```

---

### Task 2: Context-Keyed Extension Discovery Coordinator

**Files:**
- Create: `extension-discovery.js`
- Create: `scripts/test-extension-discovery.mjs`

- [x] **Step 1: Write failing adapter and cache tests**

Build fake settings/package managers and deferred resolver promises. Cover these exact cases:

```js
import { createExtensionContext, createExtensionDiscovery } from "../extension-discovery.js";

const calls = [];
const coordinator = createExtensionDiscovery({
  agentDir: "C:\\agent",
  createSettingsManager(cwd, agentDir, options) {
    calls.push(["settings", cwd, agentDir, options]);
    return { drainErrors: () => [] };
  },
  createPackageManager(options) {
    calls.push(["manager", options.cwd, options.agentDir, options.settingsManager]);
    return {
      async resolve(onMissing) {
        calls.push(["missing", await onMissing("npm:missing")]);
        return { extensions: resolvedExtensions, skills: [], prompts: [], themes: [] };
      },
    };
  },
});

const context = createExtensionContext("C:\\repo\\.", true);
await coordinator.refresh(context);
assert.deepEqual(calls[0].slice(1), ["C:\\repo", "C:\\agent", { projectTrusted: true }]);
assert.deepEqual(calls.find(call => call[0] === "missing"), ["missing", "skip"]);
assert.equal(coordinator.getSnapshot(context).extensions.length, 1);
```

Use a `deferred()` helper to assert:

- Two normal same-key calls return the same promise and invoke `resolve()` once.
- A forced call during an in-flight run returns a different promise and starts its resolver only after the first settles.
- Two forced calls made before the queued run starts share that queued promise.
- A forced call made after the queued run starts schedules a third run.
- A package-resolution failure resolves, rather than rejects, with `loading: false`, keeps the last successful list for that exact key, and exposes a sanitized error.
- An error containing `C:\\repo`, slash/case path variants, `https://token@example.test/repo?key=secret`, `git:ssh://deploy:top-secret@example.test/repo?key=secret#main`, or `Bearer top-secret` exposes none of those sensitive values.
- A trusted cache entry is not returned for the same cwd with `projectTrusted: false`.
- Global settings errors always fail; project settings errors fail only for trusted contexts.
- `generation` increases only when an actual resolver run starts.

- [x] **Step 2: Run the coordinator test and confirm it fails**

Run: `node scripts/test-extension-discovery.mjs`

Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `extension-discovery.js`.

- [x] **Step 3: Implement the coordinator public API**

Export:

```js
export function createExtensionContext(cwd, projectTrusted) {
  const normalizedCwd = resolve(String(cwd || "."));
  return { normalizedCwd, projectTrusted: projectTrusted === true, key: `${normalizedCwd}\0${projectTrusted === true ? "trusted" : "untrusted"}` };
}

export function createExtensionDiscovery({ agentDir, createSettingsManager, createPackageManager }) {
  return {
    getSnapshot(context),
    refresh(context, { force = false } = {}),
  };
}
```

Each cache entry has:

```js
{
  extensions: [],
  loading: true,
  error: null,
  generation: 0,
  inFlight: null,
  queuedForce: null,
}
```

Implement actual runs with this order:

1. Increment `generation`, set `loading: true`, clear the view error, and retain the previous extension list.
2. Call `createSettingsManager(normalizedCwd, agentDir, { projectTrusted })`.
3. Drain and reject relevant settings errors.
4. Call `createPackageManager({ cwd: normalizedCwd, agentDir, settingsManager })`.
5. Await `resolve(async () => "skip")`.
6. Drain settings errors again.
7. Filter `resolved.extensions` by `enabled === true` and map with `toExtensionDisplayRecord`.
8. Commit only if the entry still owns that generation.
9. Catch settings, adapter, and resolver errors; commit the retained list plus sanitized error for the current generation and return the final snapshot. `refresh()` must not reject for these operational failures.

Use this scheduling rule in `refresh()`:

```js
if (!force) return entry.queuedForce || entry.inFlight || startRun(context, entry);
if (entry.queuedForce) return entry.queuedForce;
if (!entry.inFlight) return startRun(context, entry);

const previous = entry.inFlight;
let queued;
queued = previous.catch(() => undefined).then(() => {
  if (entry.queuedForce === queued) entry.queuedForce = null;
  return startRun(context, entry);
});
entry.queuedForce = queued;
return queued;
```

`startRun()` must clear `entry.inFlight` only when the finishing promise is still the current one and must resolve with a cloned final snapshot on both success and operational failure. Sanitize errors with a dedicated helper that:

```js
function sanitizeUrlToken(token) {
  const value = String(token || "");
  const prefix = value.startsWith("git:") ? "git:" : "";
  const candidate = prefix ? value.slice(prefix.length) : value;
  try {
    const url = new URL(candidate);
    url.username = "";
    url.password = "";
    url.search = "";
    url.hash = "";
    return `${prefix}${url.toString()}`;
  } catch {
    return "[url]";
  }
}

function replaceKnownPath(message, knownPath, replacement) {
  const variants = [knownPath, knownPath.replaceAll("\\", "/"), knownPath.replaceAll("/", "\\")];
  return variants.reduce((result, value) => value
    ? result.replace(new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), replacement)
    : result, message);
}

function sanitizeExtensionError(error, context, agentDir) {
  let message = error instanceof Error ? error.message : String(error);
  message = message.replace(/\b(?:git:)?(?:https?|ssh|git):\/\/[^\s]+/gi, sanitizeUrlToken);
  message = message.replace(/\b(Bearer|token|password|api[_-]?key)\s*[:=]?\s*\S+/gi, "$1 [REDACTED]");
  message = replaceKnownPath(message, context.normalizedCwd, "[project]");
  message = replaceKnownPath(message, agentDir, "[agent-dir]");
  return message.replace(/\s+/g, " ").trim().slice(0, 512) || "Extension resolution failed";
}
```

Do not include stack traces in the webview snapshot.

- [x] **Step 4: Run all fake-coordinator cases**

Run: `node scripts/test-extension-discovery.mjs`

Expected: all scheduling, cache isolation, error, and adapter assertions pass.

- [x] **Step 5: Add a real Pi resolver smoke case**

In the same test, create temporary `agentDir` and `cwd` directories, write `cwd/.pi/extensions/smoke.js`, and instantiate the coordinator with real adapters:

```js
createSettingsManager: (cwd, agentDir, options) => SettingsManager.create(cwd, agentDir, options),
createPackageManager: options => new DefaultPackageManager(options),
```

Assert trusted resolution includes `smoke`, while an untrusted context for the same cwd does not include the project extension. Always remove the temporary root in `finally`.

- [x] **Step 6: Run the real smoke case**

Run: `node scripts/test-extension-discovery.mjs`

Expected: `extension discovery tests passed` and exit code `0` without touching the real Pi agent directory.

- [x] **Step 7: Commit the coordinator**

```powershell
git add extension-discovery.js scripts/test-extension-discovery.mjs
git diff --cached --check
git commit -m "feat: resolve desktop extensions through pi"
```

---

### Task 3: Desktop Extension Snapshot Integration

**Files:**
- Create: `extension-view-bridge.js`
- Create: `scripts/test-extension-view-bridge.mjs`
- Modify: `index.ts`
- Modify: `web/app.js`
- Modify: `scripts/test-skills-view-state.mjs`

- [x] **Step 1: Write failing behavioral bridge tests**

Create a real `createExtensionDiscovery` coordinator with deferred fake resolver runs, then wrap it in `createExtensionViewBridge`. Capture calls to the injected `send()` function and control `activeKey` through the injected `getActiveKey()`.

Test the installation race:

```js
const initialRefresh = bridge.refresh(context);
const forcedRefresh = bridge.refresh(context, { force: true });
let installReply;
const reply = forcedRefresh.then(snapshot => {
  if (bridge.isActive(context)) {
    installReply = { type: "skill-install-result", extensions: snapshot.extensions };
    sends.push(installReply);
  }
});

firstResolver.resolve(pathsFor("pre-install"));
await initialRefresh;
await Promise.resolve();
assert.equal(installReply, undefined, "install reply must wait for the queued post-install run");

secondResolver.resolve(pathsFor("post-install"));
await reply;
assert.equal(installReply.extensions[0].name, "post-install");
```

Test stale-context suppression twice: switch `activeKey` from the trusted key to a different cwd key, and from trusted to untrusted for the same cwd, before resolving. In both cases, assert no final `update-skills` or `skill-install-result` carrying the old key's records is sent.

Also test the full install-context race with a deferred install promise: capture the trusted project context before awaiting installation, switch `activeKey` to another cwd while installation is pending, then resolve installation and run the forced refresh against the captured context. Assert the resolver receives the original normalized cwd, the coordinator cache for the original key receives the post-install snapshot, and neither `update-skills` nor `skill-install-result` sends those records to the new active key. Add static assertions that `activateExtensionContext(installCtx)` occurs before `await installSkillPackage(...)` and that `activateExtensionContext()` synchronously delegates to `extensionContextFor()`.

Test extension-instance replacement separately. Start a refresh through an old bridge whose `getActiveKey()` throws after a `contextInvalidated` flag is set. Call `oldBridge.deactivate()`, assert `oldBridge.isEnabled() === false`, set `contextInvalidated = true`, resolve the pending resolver, and await the refresh. Assert it resolves without calling the throwing getter and sends no terminal snapshot. Then create a new bridge over the same discovery coordinator and active key, assert `newBridge.isEnabled() === true`, assert it can receive the cached terminal snapshot, and prove deactivating the old instance does not deactivate the new one.

Cover the shutdown-to-adoption message window with a static ordering assertion over `handleWindowMessage`: the `if (!extensionViewBridge.isEnabled()) return;` guard must occur before its `switch (msg.type)` and before any reference to `lastCtx`, `extensionContextFor`, or `activateExtensionContext`. This proves a message delivered to an old Glimpse listener after `session_shutdown` cannot touch a stale Pi context.

Finally, reject a deferred installation after switching `activeKey`. Apply the same captured-key guard shown in the integration snippet and assert no failure `skill-install-result` reaches the new key. Repeat without switching keys and assert exactly one failure reply is sent. These cases require both success and failure results to use `bridge.isActive(captured)`.

Finally, reject a resolver for the active key and `await bridge.refresh(context)`. Assert the promise resolves and the last `update-skills` message has `extensionsLoading: false`, retains the previous successful records, and contains the sanitized `extensionsError`. This is the regression for the terminal error snapshot.

- [x] **Step 2: Run the behavioral bridge test and confirm it fails**

Run: `node scripts/test-extension-view-bridge.mjs`

Expected: FAIL because `extension-view-bridge.js` does not exist.

- [x] **Step 3: Extend static bridge tests before changing integration code**

Add assertions that `index.ts`:

- Imports `SettingsManager`, `DefaultPackageManager`, `createExtensionDiscovery`, and `createExtensionViewBridge`.
- No longer imports `discoverPackageExtensions`, `getPackageDisplaySource`, or reads package manifests in `getExtensions()`.
- Uses `{ force: true }` in `refresh-skills` and after `installSkillPackage` completes.
- Sends `extensionsLoading` and `extensionsError` with `update-skills`.
- Stores the active extension key as a primitive string, deactivates the bridge in `session_shutdown`, and never calls `extensionContextFor(lastCtx)` from `getActiveKey()`.
- Guards `handleWindowMessage` with `extensionViewBridge.isEnabled()` before dispatch or context access.
- Gates both successful and failed `skill-install-result` messages with `extensionViewBridge.isActive(captured)`.

Add assertions that `web/app.js`:

- Renders extension loading and sanitized error states.
- Disables the refresh button while loading.
- Uses `Array.isArray(message.extensions)` rather than `message.extensions || oldValue`, so a valid empty result clears stale entries.

- [x] **Step 4: Run the skills-view test and confirm it fails**

Run: `node scripts/test-skills-view-state.mjs`

Expected: FAIL on missing extension loading/error and forced-refresh bridge tokens.

- [x] **Step 5: Implement the active-context bridge**

Create:

```js
export function createExtensionViewBridge({ discovery, getActiveKey, getSkills, send }) {
  let active = true;

  function isActive(context) {
    return active && getActiveKey() === context.key;
  }

  function getMessage(context) {
    const snapshot = discovery.getSnapshot(context);
    return {
      type: "update-skills",
      skills: getSkills(context.normalizedCwd),
      extensions: snapshot.extensions,
      extensionsLoading: snapshot.loading,
      extensionsError: snapshot.error,
    };
  }

  function sendSnapshot(context) {
    if (!isActive(context)) return false;
    send(getMessage(context));
    return true;
  }

  async function refresh(context, options = {}) {
    const pending = discovery.refresh(context, options);
    sendSnapshot(context);
    try {
      await pending;
    } finally {
      sendSnapshot(context);
    }
    return discovery.getSnapshot(context);
  }

  function deactivate() {
    active = false;
  }

  function isEnabled() {
    return active;
  }

  return { deactivate, getMessage, isActive, isEnabled, refresh, sendSnapshot };
}
```

The `finally` is mandatory even though the coordinator resolves operational failures; it preserves terminal-state delivery if the coordinator later gains an unexpected rejection path. `isActive()` must test the local `active` flag before invoking `getActiveKey()`, so a deactivated instance never touches an invalidated Pi context.

- [x] **Step 6: Replace the synchronous extension reader in `index.ts`**

Import the Pi classes and coordinator:

```ts
import { getAgentDir, SettingsManager, DefaultPackageManager, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@hhyy668/pi-coding-agent";
import { createExtensionContext, createExtensionDiscovery } from "./extension-discovery.js";
import { createExtensionViewBridge } from "./extension-view-bridge.js";
```

Create one coordinator outside the extension factory:

```ts
const extensionDiscovery = createExtensionDiscovery({
  agentDir: getAgentDir(),
  createSettingsManager: (cwd, agentDir, options) => SettingsManager.create(cwd, agentDir, options),
  createPackageManager: options => new DefaultPackageManager(options),
});
```

Delete `getExtensions()` and all direct extension/package filesystem parsing. Preserve `getSkills()` unchanged.

- [x] **Step 7: Add immutable context and bridge wiring**

Inside `desktopTuiExtension`, keep the active key as an immutable string. Never implement `getActiveKey` by calling `extensionContextFor(lastCtx)`, because `lastCtx.isProjectTrusted()` throws after Pi invalidates an extension context during reload:

```ts
function extensionContextFor(ctx: ExtensionContext) {
  return createExtensionContext(ctx.cwd, ctx.isProjectTrusted());
}

let activeExtensionKey: string | null = null;

const extensionViewBridge = createExtensionViewBridge({
  discovery: extensionDiscovery,
  getActiveKey: () => activeExtensionKey,
  getSkills,
  send: sendToWindow,
});

function activateExtensionContext(ctx: ExtensionContext) {
  const captured = extensionContextFor(ctx);
  activeExtensionKey = captured.key;
  return captured;
}

function refreshExtensionsForContext(ctx: ExtensionContext, force = false) {
  const captured = extensionContextFor(ctx);
  return extensionViewBridge.refresh(captured, { force });
}
```

Call `activateExtensionContext(ctx)` synchronously whenever `session_start`, `session_tree`, or `session_info_changed` establishes the active context, before starting its refresh. Capture `ctx`, cwd, and trust before every await; do not reconstruct the key from mutable `lastCtx` when sending the completed result.

In every `session_shutdown` handler, before any await or transition branching, set `activeExtensionKey = null` and call `extensionViewBridge.deactivate()`. Pi creates a new extension instance for reload/new/resume/fork, so the old bridge must remain permanently deactivated while the new instance owns its own bridge and key. Add static assertions that the shutdown handler contains both operations and that `getActiveKey` returns only the primitive `activeExtensionKey`.

At the beginning of `handleWindowMessage`, immediately after validating that `msg` is an object and before the message-type switch, stop all messages owned by a shut-down extension instance:

```ts
if (!extensionViewBridge.isEnabled()) return;
```

This guard must precede every read of `lastCtx` or call to a context-capturing helper. The surviving window is intentionally inert for the short shutdown-to-new-`session_start` interval; the new extension instance reattaches its own message listener during adoption.

- [x] **Step 8: Put the synchronous snapshot into initial window data**

Extend `DesktopWindowData` with:

```ts
extensions: Array<{
  name: string;
  source: string | null;
  sourceKind: "package" | "auto" | "settings";
  type: "package" | "auto" | "local";
  scope: "user" | "project";
  path: string;
}>;
extensionsLoading: boolean;
extensionsError: string | null;
```

In `collectWindowData(ctx)`, call `extensionDiscovery.getSnapshot(extensionContextFor(ctx))` and copy its three view fields. The initial snapshot is `{ extensions: [], loading: true, error: null }` when no run has completed, so HTML construction remains synchronous.

After window event handlers are attached in `openDesktopWindow`, start `void refreshExtensionsForContext(ctx)`.

- [x] **Step 9: Wire explicit refresh, install completion, and session transitions**

Change `refresh-skills` to capture and activate the current context before awaiting the forced run:

```ts
const refreshCtx = lastCtx;
if (!refreshCtx) break;
const captured = activateExtensionContext(refreshCtx);
await extensionViewBridge.refresh(captured, { force: true });
```

Do not reference an undefined `ctx` variable in the window-message handler and do not read `lastCtx` again after the await.

Before calling `installSkillPackage()`, capture both the active `ExtensionContext` and immutable extension context. Use the captured normalized cwd for the installation and the same captured key for the forced refresh; never read `lastCtx` again after the first await. Gate both success and failure replies through `extensionViewBridge.isActive(captured)`:

```ts
const installCtx = lastCtx;
if (!installCtx) {
  sendToWindow({ type: "skill-install-result", package: pkg, success: false, error: "No active workspace for package installation." });
  break;
}
const captured = activateExtensionContext(installCtx);
try {
  if (!isSkillInstallAllowed(pkg)) throw new Error("Install package must be selected from current search results.");
  const output = await installSkillPackage(pkg, scope, captured.normalizedCwd);
  const snapshot = await extensionViewBridge.refresh(captured, { force: true });
  if (extensionViewBridge.isActive(captured)) {
    sendToWindow({
      type: "skill-install-result",
      package: pkg,
      success: true,
      output: output.slice(-2000),
      skills: getSkills(captured.normalizedCwd),
      extensions: snapshot.extensions,
      extensionsLoading: snapshot.loading,
      extensionsError: snapshot.error,
    });
  }
} catch (error) {
  if (extensionViewBridge.isActive(captured)) {
    sendToWindow({
      type: "skill-install-result",
      package: pkg,
      success: false,
      error: String((error as Error).message || error),
    });
  }
}
```

This ordering is mandatory for project installs: a workspace, trust, or extension-instance transition while installation is pending must not retarget either the installation refresh or its success/failure reply.

When session start/tree/info transitions establish a new context, start a normal refresh for that captured context. The bridge must suppress old-cwd and old-trust completions.

- [x] **Step 10: Render loading and error states in the Skills and Extensions view**

Add English and Chinese strings for `extensions.loading`, `extensions.refreshFailed`, `extensions.auto`, `extensions.configured`, `extensions.sourceProjectAuto`, `extensions.sourceUserAuto`, `extensions.sourceProjectSettings`, and `extensions.sourceUserSettings`.

In `renderSkillsView()`:

```js
const extensionsLoading = data.extensionsLoading === true;
const extensionsError = typeof data.extensionsError === "string" ? data.extensionsError : "";
```

Render a compact loading row without clearing cached extension cards. Render a sanitized error row above cached cards. Label `type === "auto"` as auto-discovered and `type === "local"` as configured. Derive non-package source text from `sourceKind` plus `scope` through the translated keys; never render backend-authored English labels. Package cards render only the sanitized `source` value. Set `disabled` and `aria-busy` on `#btn-refresh-skills` while loading.

Use:

```js
function extensionSourceLabel(extension) {
  if (extension.sourceKind === "package") return extension.source || t("extensions.package");
  const project = extension.scope === "project";
  if (extension.sourceKind === "auto") return t(project ? "extensions.sourceProjectAuto" : "extensions.sourceUserAuto");
  return t(project ? "extensions.sourceProjectSettings" : "extensions.sourceUserSettings");
}
```

On click, set `data.extensionsLoading = true`, clear `data.extensionsError`, rerender, and then send `refresh-skills`.

For `update-skills` and `skill-install-result`, assign arrays and state explicitly:

```js
if (Array.isArray(message.extensions)) data.extensions = message.extensions;
data.extensionsLoading = message.extensionsLoading === true;
data.extensionsError = message.extensionsError || null;
```

- [x] **Step 11: Run focused integration checks**

Run:

```powershell
node scripts/test-extension-package-utils.mjs
node scripts/test-extension-discovery.mjs
node scripts/test-extension-view-bridge.mjs
node scripts/test-skills-view-state.mjs
npx tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --skipLibCheck index.ts
```

Expected: all commands exit `0`.

- [x] **Step 12: Commit only extension-integration hunks**

Stage `index.ts` and `web/app.js` selectively so Host UI changes are not included, then stage the test:

```powershell
git add extension-view-bridge.js scripts/test-extension-view-bridge.mjs scripts/test-skills-view-state.mjs
git add -p index.ts web/app.js
git diff --cached --check
git diff --cached
git commit -m "feat: surface effective pi extensions in desktop"
```

---

### Task 4: GPT-5.6 Capability And Transport Recommendation State

**Files:**
- Modify: `web/model-provider-state.js`
- Modify: `scripts/test-model-provider-view-state.mjs`

- [x] **Step 1: Replace superseded preset expectations with failing design-contract tests**

Assert all four model IDs are recognized:

```js
for (const id of ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) {
  assert.deepEqual(getModelCapabilityPreset(id), {
    reasoning: true,
    input: ["text", "image"],
    contextWindow: 1050000,
    maxTokens: 128000,
  });
}
```

Assert exact transport results:

```js
assert.deepEqual(getModelTransportRecommendation(
  { id: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1" },
  { id: "gpt-5.6-sol" },
).map, { off: "none", minimal: null, low: "low", medium: "medium", high: "high", xhigh: "max" });

assert.deepEqual(getModelTransportRecommendation(
  { id: "openai-chat", api: "openai-completions", baseUrl: "https://api.openai.com/v1/" },
  { id: "gpt-5.6-sol" },
).map, { off: "none", minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh" });

assert.deepEqual(getModelTransportRecommendation(
  { id: "lookfor-gpt", api: "openai-completions", baseUrl: "https://lookfor.cc/v1" },
  { id: "gpt-5.6-sol" },
).map, { off: null, minimal: null, xhigh: null });
```

Also assert:

- Non-OpenAI `compat.thinkingFormat` returns a preserved/custom state with no apply action.
- A nonmatching existing `thinkingLevelMap` is preserved and suppresses an apply action.
- An exact recommended map reports `applied`, not `pending`.
- Capability application replaces all four capability-owned fields and preserves `cost`, `compat`, `thinkingLevelMap`, identity, and unrelated metadata.
- Transport application replaces only `thinkingLevelMap`.
- `mergeProviderModelIds()` adds neutral defaults and does not apply any capability or transport recommendation.
- Provider-level pending count includes only recognized models whose capability fields differ.

- [x] **Step 2: Run the state test and confirm old behavior fails**

Run: `node scripts/test-model-provider-view-state.mjs`

Expected: FAIL because the current implementation injects `compat`, uses incorrect limits, omits the base alias, and mutates fetched model rows with a preset.

- [x] **Step 3: Implement capability-owned pure functions**

Export these constants and functions:

```js
export const GPT56_CAPABILITY_PRESET = Object.freeze({
  reasoning: true,
  input: ["text", "image"],
  contextWindow: 1050000,
  maxTokens: 128000,
});

const GPT56_ID = /^gpt-5\.6(?:-(?:sol|terra|luna))?$/i;

export function getModelCapabilityPreset(modelId) {
  if (!GPT56_ID.test(String(modelId || "").trim())) return null;
  return { ...GPT56_CAPABILITY_PRESET, input: [...GPT56_CAPABILITY_PRESET.input] };
}

export function isModelCapabilityApplied(model, preset = getModelCapabilityPreset(model?.id)) {
  return !!preset
    && model?.reasoning === preset.reasoning
    && model?.contextWindow === preset.contextWindow
    && model?.maxTokens === preset.maxTokens
    && Array.isArray(model?.input)
    && model.input.length === preset.input.length
    && preset.input.every((value, index) => model.input[index] === value);
}

export function applyModelCapabilityPreset(model) {
  const preset = getModelCapabilityPreset(model?.id);
  if (!preset) return structuredClone(model || {});
  return { ...structuredClone(model || {}), ...preset, input: [...preset.input] };
}

export function getProviderCapabilityState(provider) {
  const matching = (provider?.models || []).filter(model => getModelCapabilityPreset(model.id));
  const pending = matching.filter(model => !isModelCapabilityApplied(model));
  return { matching: matching.length, pending: pending.length, applied: matching.length - pending.length };
}
```

Use `/^gpt-5\.6(?:-(?:sol|terra|luna))?$/i`. Clone arrays on return and application. Do not add `compat` or `thinkingLevelMap` from capability functions.

- [x] **Step 4: Implement API-specific transport recommendations**

Export frozen maps:

```js
export const OPENAI_RESPONSES_THINKING_MAP = { off: "none", minimal: null, low: "low", medium: "medium", high: "high", xhigh: "max" };
export const OPENAI_COMPLETIONS_THINKING_MAP = { off: "none", minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh" };
export const CONSERVATIVE_GATEWAY_THINKING_MAP = { off: null, minimal: null, xhigh: null };
```

`getModelTransportRecommendation(provider, model)` must:

1. Return `null` for unknown model IDs.
2. Compute effective transport from `model.api || provider.api` and effective URL from `model.baseUrl || provider.baseUrl`.
3. Treat provider id `openai` or normalized effective URL `https://api.openai.com/v1` as direct OpenAI.
4. Permit absent `model.compat.thinkingFormat` or explicit `thinkingFormat: "openai"`; preserve and suppress for all other formats.
5. Select the Responses or Chat table by effective API.
6. Select the conservative guard for unknown custom gateways.
7. Report `applied` for an exact map, `custom` with no action for a different existing map, and `pending` only when applying is allowed.

`applyModelTransportRecommendation(model, recommendation)` replaces only `thinkingLevelMap` and returns a cloned model.

Extend the existing `window.ModelProviderState` assignment with every function consumed by `web/app.js`: `getModelCapabilityPreset`, `isModelCapabilityApplied`, `applyModelCapabilityPreset`, `getProviderCapabilityState`, `getModelTransportRecommendation`, and `applyModelTransportRecommendation`.

- [x] **Step 5: Make fetched rows neutral**

Change `mergeProviderModelIds()` so new rows contain only the existing neutral defaults:

```js
{
  id,
  contextWindow: 128000,
  maxTokens: 16384,
  input: ["text"],
  reasoning: false,
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}
```

Recommendations are derived at render time and applied only by explicit actions.

- [x] **Step 6: Run the pure state tests**

Run: `node scripts/test-model-provider-view-state.mjs`

Expected: `model provider view state tests passed` and exit code `0`.

- [x] **Step 7: Commit the recommendation state module**

```powershell
git add web/model-provider-state.js scripts/test-model-provider-view-state.mjs
git diff --cached --check
git commit -m "feat: separate model capability and transport guidance"
```

---

### Task 5: Stable Model Editor Recommendation DOM

**Files:**
- Create: `web/model-provider-view.js`
- Create: `scripts/test-model-provider-view-dom.mjs`
- Modify: `web/app.js`
- Modify: `index.ts`
- Modify: `package.json`
- Modify: `package-lock.json`
- Modify: `scripts/verify-runtime-html.mjs`

- [x] **Step 1: Install the exact jsdom development dependency**

Run:

```powershell
$baselineDir = Join-Path $env:TEMP 'pi-desktop-plan-baselines'
New-Item -ItemType Directory -Force -Path $baselineDir | Out-Null
git diff -- package.json | Set-Content -LiteralPath (Join-Path $baselineDir 'task5-package.diff')
if (git status --short -- package-lock.json) { throw 'package-lock.json must be clean before installing jsdom' }
npm install --save-dev --save-exact jsdom@29.1.1
```

Expected: `package.json` contains `"jsdom": "29.1.1"` and the lockfile is updated without unrelated dependency upgrades.

- [x] **Step 2: Write the failing DOM identity regression**

Create a jsdom document with a permanent provider recommendation container and an open model `<details>` containing an ID input and row recommendation container. Import and bind `bindModelProviderView`, then capture the original input, details, and model object identities. Use render callbacks that create all three dynamic actions:

```js
const actionCalls = [];
let actionRenderCount = 0;
function rerenderAfterAction() {
  actionRenderCount += 1;
  const id = draft.models[0].id;
  root.innerHTML = `
    <div data-provider-recommendations>
      <button data-apply-provider-capabilities>all ${id}</button>
    </div>
    <details open data-model-row="0">
      <summary><span data-model-summary>${id}</span></summary>
      <input class="provider-field" name="model.0.id" value="${id}">
      <div data-model-recommendations>
        <span>${id}</span>
        <button data-apply-model-capability="0">capability</button>
        <button data-apply-model-transport="0">transport</button>
      </div>
    </details>`;
}

const dispose = bindModelProviderView({
  root,
  getDraft: () => draft,
  renderRow: model => `<span>${model.id}</span><button data-apply-model-capability="0">capability</button><button data-apply-model-transport="0">transport</button>`,
  renderProvider: provider => `<button data-apply-provider-capabilities>all ${provider.models[0].id}</button>`,
  renderSummary: model => model.name || model.id || "Add model",
  onApplyCapability: index => { actionCalls.push(["capability", index]); rerenderAfterAction(); },
  onApplyTransport: index => { actionCalls.push(["transport", index]); rerenderAfterAction(); },
  onApplyProviderCapabilities: () => { actionCalls.push(["provider"]); rerenderAfterAction(); },
});
```

For each character in `gpt-5.6-sol`:

```js
input.value += char;
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
assert.strictEqual(root.querySelector('[name="model.0.id"]'), input);
assert.strictEqual(root.querySelector('[data-model-row="0"]'), details);
assert.strictEqual(draft.models[0], originalModel);
assert.strictEqual(document.activeElement, input);
assert.equal(input.selectionStart, input.value.length);
assert.equal(input.selectionEnd, input.value.length);
assert.equal(details.open, true);
```

After entering the recognized ID, clear it with another real bubbling input event and assert the same input/details/model objects remain while `[data-model-summary]` reads `Add model`. Re-enter `gpt-5.6-sol` through one more bubbling event before exercising the action buttons.

```js
input.value = "";
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
assert.strictEqual(root.querySelector('[name="model.0.id"]'), input);
assert.strictEqual(root.querySelector('[data-model-row="0"]'), details);
assert.strictEqual(draft.models[0], originalModel);
assert.equal(root.querySelector("[data-model-summary]").textContent, "Add model");

input.value = "gpt-5.6-sol";
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
assert.equal(root.querySelector("[data-model-summary]").textContent, "gpt-5.6-sol");
```

After the final character, dispatch bubbling clicks on the newly generated capability, transport, and provider-level buttons and assert:

```js
root.querySelector("[data-apply-model-capability]").click();
root.querySelector("[data-apply-model-transport]").click();
root.querySelector("[data-apply-provider-capabilities]").click();
assert.deepEqual(actionCalls, [["capability", 0], ["transport", 0], ["provider"]]);
assert.equal(actionRenderCount, 3);
dispose();
```

The three `root.querySelector(...).click()` calls intentionally reacquire each button after the previous callback replaces every child of the stable root. Assert both recommendation containers contain the latest ID before the first action, no full-render callback is accepted or invoked on the input path, the actual bubbling `input` event preserves node identity/focus/selection/expanded state, and all three delegated actions still fire exactly once across full child-tree replacements.

In the same test, read `web/app.js` and assert there is exactly one `ModelProviderView.bindModelProviderView(` call, model ID inputs are explicitly skipped by the generic `.provider-field` binder, and no `field === "id"` branch calls `renderModelProvidersView()`. Also assert `bindModelProviderEvents()` contains no direct `addEventListener` binding for `data-apply-model-capability`, `data-apply-model-transport`, `data-apply-provider-capabilities`, or the superseded `#provider-apply-model-presets` button. These assertions prove app wiring uses the behavior-tested delegated path exactly once.

- [x] **Step 3: Run the DOM test and confirm it fails**

Run: `node scripts/test-model-provider-view-dom.mjs`

Expected: FAIL because `web/model-provider-view.js` does not exist.

- [x] **Step 4: Implement the focused DOM patch helper**

Export both the patch operation and one stable-root event binding:

```js
export function updateModelIdRecommendations({ root, draft, index, value, renderRow, renderProvider, renderSummary }) {
  const model = draft?.models?.[index];
  if (!model) return false;
  model.id = value;
  const row = root.querySelector(`[data-model-row="${index}"]`);
  const rowContainer = row?.querySelector("[data-model-recommendations]");
  const providerContainer = root.querySelector("[data-provider-recommendations]");
  if (!rowContainer || !providerContainer) return false;
  rowContainer.innerHTML = renderRow(model, draft, index);
  providerContainer.innerHTML = renderProvider(draft);
  const summary = row.querySelector("[data-model-summary]");
  if (summary) summary.textContent = renderSummary(model, draft, index);
  return true;
}

export function bindModelProviderView({
  root, getDraft, renderRow, renderProvider, renderSummary,
  onApplyCapability, onApplyTransport, onApplyProviderCapabilities,
}) {
  const handleInput = event => {
    const input = event.target?.closest?.('input[name^="model."][name$=".id"]');
    if (!input || !root.contains(input)) return;
    const match = /^model\.(\d+)\.id$/.exec(input.name);
    if (!match) return;
    updateModelIdRecommendations({
      root,
      draft: getDraft(),
      index: Number(match[1]),
      value: input.value,
      renderRow,
      renderProvider,
      renderSummary,
    });
  };

  const handleClick = event => {
    const button = event.target?.closest?.("button");
    if (!button || !root.contains(button)) return;
    if (button.hasAttribute("data-apply-provider-capabilities")) return onApplyProviderCapabilities();
    if (button.dataset.applyModelCapability !== undefined) return onApplyCapability(Number(button.dataset.applyModelCapability));
    if (button.dataset.applyModelTransport !== undefined) return onApplyTransport(Number(button.dataset.applyModelTransport));
  };

  root.addEventListener("input", handleInput);
  root.addEventListener("click", handleClick);
  return () => {
    root.removeEventListener("input", handleInput);
    root.removeEventListener("click", handleClick);
  };
}

if (typeof window !== "undefined") window.ModelProviderView = { bindModelProviderView, updateModelIdRecommendations };
```

Do not accept or replace the input/details nodes in this helper.

- [x] **Step 5: Run the jsdom identity test**

Run: `node scripts/test-model-provider-view-dom.mjs`

Expected: all real-input node identity/focus/selection assertions, three full child-tree replacements, and all post-replacement delegated click assertions pass.

- [x] **Step 6: Render permanent recommendation containers in `web/app.js`**

Add rendering functions that consume `ModelProviderState`:

- `renderModelRecommendation(model, provider, index)` shows capability state and a separate transport state/action.
- `renderProviderRecommendations(draft)` shows the bulk capability action only when `pending > 0`.

Always render:

```js
html += `<div data-provider-recommendations>${renderProviderRecommendations(draft)}</div>`;
html += `<details class="provider-model-row" data-model-row="${index}">
  <summary>
    <span data-model-summary>${escapeHtml(model.name || model.id || t("providers.addModel"))}</span>
    <button type="button" data-remove-model="${index}" title="${t("providers.delete")}">
      <span class="material-symbols-outlined msym-sm">close</span>
    </button>
  </summary>
  <div class="provider-model-fields">${renderModelFields(model, index)}</div>
  <div data-model-recommendations>${renderModelRecommendation(model, draft, index)}</div>
</details>`;
```

Render these action attributes; the single delegated controller added in Step 7 handles their clicks even after recommendation `innerHTML` is replaced:

- `data-apply-model-capability="INDEX"` calls `applyModelCapabilityPreset` and rerenders.
- `data-apply-model-transport="INDEX"` calls `applyModelTransportRecommendation` and rerenders.
- Provider bulk capability applies only capability-pending recognized models.

Keep the generic reasoning checkbox editable for both known and unknown models. Recommendations describe and explicitly apply values; they do not disable manual configuration.

Add matching English and Chinese strings for `providers.capabilityRecommendation`, `providers.transportRecommendation`, `providers.applyCapability`, `providers.applyTransport`, `providers.capabilitiesApplied`, `providers.transportApplied`, and `providers.customTransportPreserved`. Recommendation rendering must use `t()` for every visible label and action.

Do not silently apply any recommendation during fetch or input.

- [x] **Step 7: Bind one delegated model-provider controller**

Call `window.ModelProviderView.bindModelProviderView(...)` exactly once after both `state` and the stable `messagesEl` are initialized, not from `renderModelProvidersView()` or `bindModelProviderEvents()`. Supply callbacks that apply the requested state change and then call `renderModelProvidersView()` for explicit clicks only:

```js
window.ModelProviderView.bindModelProviderView({
  root: messagesEl,
  getDraft: () => state.modelProviderDraft,
  renderRow: (model, draft, index) => renderModelRecommendation(model, draft, index),
  renderProvider: draft => renderProviderRecommendations(draft),
  renderSummary: model => model.name || model.id || t("providers.addModel"),
  onApplyCapability: index => {
    state.modelProviderDraft.models[index] = ModelProviderState.applyModelCapabilityPreset(state.modelProviderDraft.models[index]);
    renderModelProvidersView();
  },
  onApplyTransport: index => {
    const model = state.modelProviderDraft.models[index];
    const recommendation = ModelProviderState.getModelTransportRecommendation(state.modelProviderDraft, model);
    state.modelProviderDraft.models[index] = ModelProviderState.applyModelTransportRecommendation(model, recommendation);
    renderModelProvidersView();
  },
  onApplyProviderCapabilities: () => {
    state.modelProviderDraft.models = state.modelProviderDraft.models.map(model => {
      const preset = ModelProviderState.getModelCapabilityPreset(model.id);
      return !preset || ModelProviderState.isModelCapabilityApplied(model, preset)
        ? model
        : ModelProviderState.applyModelCapabilityPreset(model);
    });
    renderModelProvidersView();
  },
});
```

Exclude model ID inputs from the existing per-field `input` bindings in `bindModelProviderEvents()` so the delegated handler is the only model ID mutation path:

```js
messagesEl.querySelectorAll(".provider-field").forEach(element => {
  if (/^model\.\d+\.id$/.test(element.name || "")) return;
  element.addEventListener("input", () => updateProviderDraftFromField(element));
});
```

Delete the existing `#provider-apply-model-presets` direct click binding and any direct bindings added for the three recommendation action attributes. Recommendation actions must be handled only by the stable-root delegated controller. This prevents a target listener from rerendering and detaching its button before the event reaches `messagesEl`, which would make `root.contains(button)` suppress the delegated callback.

`renderProviderRecommendations` must compute against the already updated model object. No `input` path may call `renderModelProvidersView()`.

- [x] **Step 8: Inline the browser helper before `app.js`**

In `buildDesktopHtml`, read `web/model-provider-view.js`, strip `export`, and prepend it to the existing `appJs` string. Include its length in the WebView2 size calculation. Update `scripts/verify-runtime-html.mjs` to assert the generated helper assignment occurs before the first `bindModelProviderView` call in app code. This avoids changing the unrelated script-marker area in `web/index.html`.

- [x] **Step 9: Run focused UI tests**

Run:

```powershell
node scripts/test-model-provider-view-state.mjs
node scripts/test-model-provider-view-dom.mjs
node scripts/verify-runtime-html.mjs
node --check web/app.js
```

Expected: all commands exit `0`; runtime HTML contains `window.ModelProviderView` before app code.

- [x] **Step 10: Commit the DOM and UI changes**

Stage new files and dependency files normally; stage shared files selectively:

```powershell
git add web/model-provider-view.js scripts/test-model-provider-view-dom.mjs scripts/verify-runtime-html.mjs package-lock.json
git add -p package.json web/app.js index.ts
git diff --cached --check
git diff --cached
git commit -m "fix: preserve model editor state while showing guidance"
```

---

### Task 6: Supported Thinking Levels And Provider-Facing Footer Labels

**Files:**
- Create: `thinking-level-utils.js`
- Create: `scripts/test-thinking-level-utils.mjs`
- Modify: `index.ts`
- Modify: `web/app.js`
- Modify: `scripts/test-pi-0803-event-bridge.mjs`

- [x] **Step 1: Write failing thinking helper tests**

Assert:

```js
assert.equal(getThinkingDisplayLevel({ thinkingLevelMap: { xhigh: "max" } }, "xhigh"), "max");
assert.equal(getThinkingDisplayLevel({ thinkingLevelMap: { off: "none" } }, "off"), "none");
assert.equal(getThinkingDisplayLevel({ thinkingLevelMap: { xhigh: null } }, "xhigh"), "xhigh");
assert.equal(getNextThinkingLevel("high", ["low", "medium", "high"]), "low");
assert.equal(getNextThinkingLevel("off", ["low", "medium", "high"]), "low");
assert.equal(getNextThinkingLevel("off", ["off"]), null);
```

Extend the static event bridge test to require `thinkingDisplayLevel`, `thinkingLevels`, `getSupportedThinkingLevels`, and an explicit backend echo in `set-thinking-level`.

- [x] **Step 2: Run tests and confirm they fail**

Run:

```powershell
node scripts/test-thinking-level-utils.mjs
node scripts/test-pi-0803-event-bridge.mjs
```

Expected: FAIL because the helper and three-field message contract are absent.

- [x] **Step 3: Implement shared pure thinking helpers**

Create:

```js
export const PI_THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh"]);

export function getThinkingDisplayLevel(model, level) {
  const mapped = model?.thinkingLevelMap?.[level];
  return typeof mapped === "string" ? mapped : level;
}

export function getNextThinkingLevel(current, availableLevels) {
  if (!Array.isArray(availableLevels) || availableLevels.length <= 1) return null;
  const index = availableLevels.indexOf(current);
  return availableLevels[(index + 1 + availableLevels.length) % availableLevels.length];
}
```

- [x] **Step 4: Build one backend thinking snapshot helper**

Import `getSupportedThinkingLevels` and `ModelThinkingLevel` from `@hhyy668/pi-ai`, plus the shared display helper.

Inside the extension factory:

```ts
function getThinkingSnapshot(ctx: ExtensionContext, level = pi.getThinkingLevel()) {
  const thinkingLevels = ctx.model ? getSupportedThinkingLevels(ctx.model) : ["off"];
  return {
    thinkingLevel: level,
    thinkingDisplayLevel: getThinkingDisplayLevel(ctx.model, level),
    thinkingLevels,
  };
}

function sendThinkingSnapshot(ctx: ExtensionContext, level = pi.getThinkingLevel()): void {
  sendToWindow({ type: "thinking-level", ...getThinkingSnapshot(ctx, level) });
}
```

Extend `DesktopWindowData` and initial/session snapshots with all three fields. Recompute and send them from `before_provider_request`, `model_select`, and `thinking_level_select`; use the event model/context captured by that event, not stale `lastCtx.model`.

- [x] **Step 5: Validate requested levels against the active model and always echo**

Replace the fixed validation in `set-thinking-level`:

```ts
case "set-thinking-level": {
  if (!lastCtx) break;
  const snapshot = getThinkingSnapshot(lastCtx);
  if (snapshot.thinkingLevels.includes(msg.level)) pi.setThinkingLevel(msg.level);
  sendThinkingSnapshot(lastCtx);
  break;
}
```

The explicit echo is required because Pi emits no selection event when a request clamps to or remains at the current level.

- [x] **Step 6: Cycle only supported levels in the frontend**

Prepend the stripped `thinking-level-utils.js` source to `appJs` in `buildDesktopHtml`, before `web/app.js`, and include its size in the HTML budget.

Replace `THINKING_LEVELS` usage with:

```js
const next = getNextThinkingLevel(data.thinkingLevel, data.thinkingLevels);
if (next) send({ type: "set-thinking-level", level: next });
```

Add `applyThinkingSnapshot(message)` to copy all three fields from initial/session/event payloads. `renderStats()` displays `data.thinkingDisplayLevel || data.thinkingLevel`.

When `thinkingLevels.length <= 1`, set `thinkingLabelEl.disabled = true`, `aria-disabled="true"`, remove the pointer cursor, and use a translated title stating that the active model has no configurable thinking level. Otherwise restore click styling and the cycle title.

- [x] **Step 7: Run focused thinking and event tests**

Run:

```powershell
node scripts/test-thinking-level-utils.mjs
node scripts/test-pi-0803-event-bridge.mjs
npx tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --skipLibCheck index.ts
node --check web/app.js
```

Expected: all commands exit `0`.

- [x] **Step 8: Commit thinking-level changes only**

```powershell
git add thinking-level-utils.js scripts/test-thinking-level-utils.mjs
git add -p index.ts web/app.js scripts/test-pi-0803-event-bridge.mjs
git diff --cached --check
git diff --cached
git commit -m "fix: cycle only supported desktop thinking levels"
```

---

### Task 7: Remediate The Local `lookfor-gpt` GPT-5.6 Entries

**Files:**
- Modify outside repository: `%USERPROFILE%\.pi\agent\models.json`
- Create outside repository: `%USERPROFILE%\.pi\agent\models.json.bak-YYYYMMDD-HHmmss`
- Create then remove outside repository: `%USERPROFILE%\.pi\agent\models.json.remediation-state.json`
- Create then remove outside repository: `%USERPROFILE%\.pi\agent\models.json.lock`
- Create then remove on rollback only: `%USERPROFILE%\.pi\agent\models.json.restore-GUID`

- [x] **Step 1: Verify the historical backup contract before writing**

Run:

```powershell
@'
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { parseJsonDocument } from "./model-provider-utils.js";
const backup = path.join(process.env.USERPROFILE, ".pi", "agent", "models.json.bak-20260714-003007");
const document = parseJsonDocument(fs.readFileSync(backup, "utf8"));
const targets = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
const models = document.providers?.["lookfor-gpt"]?.models?.filter(model => targets.has(model.id)) || [];
assert.equal(models.length, 3);
for (const model of models) assert.deepEqual(model, { id: model.id });
console.log("historical GPT-5.6 model shape verified");
'@ | node --input-type=module
```

Expected: `historical GPT-5.6 model shape verified`. Abort remediation if the command fails.

- [x] **Step 2: Recover or reject interrupted remediation state**

Run this preflight before creating a new backup. It handles two provable interrupted states and leaves ambiguous state untouched for manual recovery:

```powershell
@'
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { parseJsonDocument } from "./model-provider-utils.js";

const agentDir = path.join(process.env.USERPROFILE, ".pi", "agent");
const modelsPath = path.join(agentDir, "models.json");
const stagedPath = `${modelsPath}.remediated`;
const statePath = `${modelsPath}.remediation-state.json`;
const targets = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
const hash = bytes => createHash("sha256").update(bytes).digest("hex").toUpperCase();
const stripTargets = document => {
  const clone = structuredClone(document);
  clone.providers["lookfor-gpt"].models = clone.providers["lookfor-gpt"].models.filter(model => !targets.has(model.id));
  return clone;
};

if (!fs.existsSync(statePath)) {
  assert.equal(fs.existsSync(stagedPath), false, `orphan staging file requires manual inspection: ${stagedPath}`);
  console.log("no interrupted models.json remediation found");
  process.exit(0);
}

const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
assert.equal(typeof state.backupPath, "string");
assert.equal(typeof state.originalHash, "string");
assert.ok(fs.existsSync(state.backupPath), `recorded backup is missing: ${state.backupPath}`);
assert.ok(fs.existsSync(modelsPath), `current models.json is missing: ${modelsPath}`);
const backupBytes = fs.readFileSync(state.backupPath);
assert.equal(hash(backupBytes), state.originalHash.toUpperCase(), "recorded backup hash mismatch");
const currentBytes = fs.readFileSync(modelsPath);

if (hash(currentBytes) === state.originalHash.toUpperCase()) {
  fs.rmSync(stagedPath, { force: true });
  fs.rmSync(statePath);
  console.log("recovered interrupted remediation before replacement; original file retained");
  process.exit(0);
}

const backup = parseJsonDocument(backupBytes.toString("utf8"));
const current = parseJsonDocument(currentBytes.toString("utf8"));
assert.deepEqual(stripTargets(current), stripTargets(backup), "current file has unrelated changes; recovery state retained");
const remediated = current.providers?.["lookfor-gpt"]?.models?.filter(model => targets.has(model.id)) || [];
assert.equal(remediated.length, 3);
for (const model of remediated) {
  assert.deepEqual(Object.keys(model).sort(), ["id", "reasoning", "thinkingLevelMap"]);
  assert.equal(model.reasoning, true);
  assert.deepEqual(model.thinkingLevelMap, { off: null, minimal: null, xhigh: null });
}
fs.rmSync(stagedPath, { force: true });
fs.rmSync(statePath);
console.log("finalized interrupted remediation after verified replacement; backup retained");
'@ | node --input-type=module
if ($LASTEXITCODE -ne 0) {
  throw 'Interrupted remediation is ambiguous; models.json, backup, staging, and state files were left untouched for manual inspection'
}
```

Expected: one of `no interrupted models.json remediation found`, `recovered interrupted remediation before replacement; original file retained`, or `finalized interrupted remediation after verified replacement; backup retained`. An orphan staging file, missing or altered backup, missing current file, unrelated current-file change, or partially remediated target set must fail without deleting any artifact.

- [x] **Step 3: Create a fresh byte-for-byte backup**

Run:

```powershell
$ErrorActionPreference = 'Stop'
$modelsPath = Join-Path $env:USERPROFILE '.pi\agent\models.json'
$statePath = "$modelsPath.remediation-state.json"
$stagedPath = "$modelsPath.remediated"
if (Test-Path -LiteralPath $statePath) { throw "Existing remediation state requires manual inspection: $statePath" }
if (Test-Path -LiteralPath $stagedPath) { throw "Orphan remediation staging file requires manual inspection: $stagedPath" }
$backupPath = "$modelsPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item -LiteralPath $modelsPath -Destination $backupPath
$originalHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $modelsPath).Hash
if ($originalHash -ne (Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash) {
  throw 'models.json backup hash mismatch'
}
$stateJson = [pscustomobject]@{
  backupPath = $backupPath
  originalHash = $originalHash
} | ConvertTo-Json -Compress
$stateBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($stateJson)
$stateStream = [System.IO.File]::Open(
  $statePath,
  [System.IO.FileMode]::CreateNew,
  [System.IO.FileAccess]::Write,
  [System.IO.FileShare]::None
)
$stateWritten = $false
try {
  $stateStream.Write($stateBytes, 0, $stateBytes.Length)
  $stateWritten = $true
} finally {
  $stateStream.Dispose()
  if (-not $stateWritten) {
    Remove-Item -LiteralPath $statePath -ErrorAction SilentlyContinue
  }
}
Write-Output $backupPath
```

Expected: the backup exists and has the same SHA-256 hash as the pre-remediation `models.json`.

- [x] **Step 4: Write a staged remediated document**

Use this one-shot Node ESM script. It rechecks the historical contract, verifies that `models.json` is still byte-for-byte identical to the Step 3 backup, and writes a fixed staging path only when that path does not already exist. If the command fails, remove only the staging/state files; keep the current `models.json` and timestamped backup untouched:

```powershell
@'
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { parseJsonDocument } from "./model-provider-utils.js";

const agentDir = path.join(process.env.USERPROFILE, ".pi", "agent");
const modelsPath = path.join(agentDir, "models.json");
const historicalPath = path.join(agentDir, "models.json.bak-20260714-003007");
const stagedPath = path.join(agentDir, "models.json.remediated");
const statePath = `${modelsPath}.remediation-state.json`;
const targets = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);

const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
const sourceBytes = fs.readFileSync(modelsPath);
const sourceHash = createHash("sha256").update(sourceBytes).digest("hex").toUpperCase();
assert.equal(sourceHash, String(state.originalHash).toUpperCase(), "models.json changed after remediation backup");

const historical = parseJsonDocument(fs.readFileSync(historicalPath, "utf8"));
const historicalTargets = historical.providers?.["lookfor-gpt"]?.models?.filter(model => targets.has(model.id)) || [];
assert.equal(historicalTargets.length, 3);
for (const model of historicalTargets) assert.deepEqual(model, { id: model.id });

const document = parseJsonDocument(sourceBytes.toString("utf8"));
const provider = document.providers?.["lookfor-gpt"];
assert.ok(provider && Array.isArray(provider.models), "lookfor-gpt models are required");
assert.equal(provider.models.filter(model => targets.has(model.id)).length, 3);
provider.models = provider.models.map(model => targets.has(model.id) ? {
  id: model.id,
  reasoning: true,
  thinkingLevelMap: { off: null, minimal: null, xhigh: null },
} : model);

fs.writeFileSync(stagedPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
console.log(stagedPath);
'@ | node --input-type=module
if ($LASTEXITCODE -ne 0) {
  $modelsPath = Join-Path $env:USERPROFILE '.pi\agent\models.json'
  Remove-Item -LiteralPath "$modelsPath.remediated" -ErrorAction SilentlyContinue
  Remove-Item -LiteralPath "$modelsPath.remediation-state.json" -ErrorAction SilentlyContinue
  throw 'models.json staging failed; current configuration was left untouched and the backup was retained'
}
```

Preserve provider API, base URL, credentials, headers, unrelated provider fields, model order, and every unrelated model value after parsing. Removing the target entries' injected `name`, zero `cost`, `input`, limits, `compat`, and old map is intentional because the historical backup proves those fields were absent.

- [x] **Step 5: Validate the staged document before replacement**

Run:

```powershell
@'
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { parseJsonDocument } from "./model-provider-utils.js";
const dir = path.join(process.env.USERPROFILE, ".pi", "agent");
const modelsPath = path.join(dir, "models.json");
const statePath = `${modelsPath}.remediation-state.json`;
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
const beforeBytes = fs.readFileSync(modelsPath);
const beforeHash = createHash("sha256").update(beforeBytes).digest("hex").toUpperCase();
assert.equal(beforeHash, String(state.originalHash).toUpperCase(), "models.json changed before staged validation");
const before = parseJsonDocument(beforeBytes.toString("utf8"));
const staged = parseJsonDocument(fs.readFileSync(path.join(dir, "models.json.remediated"), "utf8"));
const targets = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
const stripTargets = document => {
  const clone = structuredClone(document);
  clone.providers["lookfor-gpt"].models = clone.providers["lookfor-gpt"].models.filter(model => !targets.has(model.id));
  return clone;
};
assert.deepEqual(stripTargets(staged), stripTargets(before));
const changed = staged.providers["lookfor-gpt"].models.filter(model => targets.has(model.id));
assert.equal(changed.length, 3);
for (const model of changed) {
  assert.deepEqual(Object.keys(model).sort(), ["id", "reasoning", "thinkingLevelMap"]);
  assert.equal(model.reasoning, true);
  assert.deepEqual(model.thinkingLevelMap, { off: null, minimal: null, xhigh: null });
}
console.log("staged models.json remediation verified");
'@ | node --input-type=module
if ($LASTEXITCODE -ne 0) {
  $modelsPath = Join-Path $env:USERPROFILE '.pi\agent\models.json'
  Remove-Item -LiteralPath "$modelsPath.remediated" -ErrorAction SilentlyContinue
  Remove-Item -LiteralPath "$modelsPath.remediation-state.json" -ErrorAction SilentlyContinue
  throw 'staged models.json validation failed; current configuration was left untouched and the backup was retained'
}
```

Expected: `staged models.json remediation verified`.

- [x] **Step 6: Atomically replace and re-read**

Acquire the same `${modelsPath}.lock` used by Desktop's `withFileLock()` before the final hash check, replacement, and installed-file validation. Hold it through any rollback, then release it in `finally`. Read the exact backup path and hash recorded by Step 3; never discover a backup by timestamp ordering. A current-file hash mismatch while holding the lock means another writer changed the file before this operation acquired ownership: delete only the staging/state files, retain the timestamped backup, leave the current file untouched, and abort. Use `[System.IO.File]::Replace()` for same-directory atomic replacement rather than `Move-Item -Force`:

```powershell
$ErrorActionPreference = 'Stop'
$agentDir = Join-Path $env:USERPROFILE '.pi\agent'
$modelsPath = Join-Path $agentDir 'models.json'
$stagedPath = Join-Path $agentDir 'models.json.remediated'
$statePath = "$modelsPath.remediation-state.json"
$lockPath = "$modelsPath.lock"
$lockStream = $null
$lockDeadline = [DateTime]::UtcNow.AddSeconds(10)

while ($null -eq $lockStream) {
  try {
    $lockStream = [System.IO.File]::Open(
      $lockPath,
      [System.IO.FileMode]::CreateNew,
      [System.IO.FileAccess]::Write,
      [System.IO.FileShare]::None
    )
  } catch [System.IO.IOException] {
    if ([DateTime]::UtcNow -ge $lockDeadline) {
      throw "Could not acquire configuration lock for $modelsPath"
    }
    if (Test-Path -LiteralPath $lockPath) {
      $lockAge = [DateTime]::UtcNow - (Get-Item -LiteralPath $lockPath).LastWriteTimeUtc
      if ($lockAge.TotalSeconds -gt 30) {
        Remove-Item -LiteralPath $lockPath -ErrorAction SilentlyContinue
      }
    }
    Start-Sleep -Milliseconds 25
  }
}

try {
  $state = Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json
  $backupPath = [string]$state.backupPath
  if (-not (Test-Path -LiteralPath $backupPath)) { throw "Recorded remediation backup is missing: $backupPath" }
  if ((Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash -ne [string]$state.originalHash) {
    throw 'Recorded remediation backup hash mismatch'
  }
  $currentHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $modelsPath).Hash
  if ($currentHash -ne [string]$state.originalHash) {
    Remove-Item -LiteralPath $stagedPath -ErrorAction SilentlyContinue
    Remove-Item -LiteralPath $statePath -ErrorAction SilentlyContinue
    throw 'models.json changed before the remediation lock was acquired; current configuration was left untouched and the backup was retained'
  }

  try {
    [System.IO.File]::Replace($stagedPath, $modelsPath, $null)
    if (-not (Test-Path -LiteralPath $modelsPath)) { throw 'models.json replacement missing' }
    @'
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { parseJsonDocument } from "./model-provider-utils.js";
const modelsPath = path.join(process.env.USERPROFILE, ".pi", "agent", "models.json");
const document = parseJsonDocument(fs.readFileSync(modelsPath, "utf8"));
const targets = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
const models = document.providers?.["lookfor-gpt"]?.models?.filter(model => targets.has(model.id)) || [];
assert.equal(models.length, 3);
for (const model of models) {
  assert.deepEqual(Object.keys(model).sort(), ["id", "reasoning", "thinkingLevelMap"]);
  assert.equal(model.reasoning, true);
  assert.deepEqual(model.thinkingLevelMap, { off: null, minimal: null, xhigh: null });
}
console.log("installed models.json remediation verified");
'@ | node --input-type=module
    if ($LASTEXITCODE -ne 0) { throw 'installed models.json validation failed' }
    Remove-Item -LiteralPath $statePath -ErrorAction SilentlyContinue
  } catch {
    $replacementError = $_
    $restorePath = "$modelsPath.restore-$([Guid]::NewGuid().ToString('N'))"
    try {
      Copy-Item -LiteralPath $backupPath -Destination $restorePath
      if (Test-Path -LiteralPath $modelsPath) {
        [System.IO.File]::Replace($restorePath, $modelsPath, $null)
      } else {
        [System.IO.File]::Move($restorePath, $modelsPath)
      }
      if ((Get-FileHash -Algorithm SHA256 -LiteralPath $modelsPath).Hash -ne [string]$state.originalHash) {
        throw "models.json restore hash mismatch; recovery state retained at $statePath"
      }
      Remove-Item -LiteralPath $stagedPath -ErrorAction SilentlyContinue
      Remove-Item -LiteralPath $statePath -ErrorAction SilentlyContinue
    } finally {
      Remove-Item -LiteralPath $restorePath -ErrorAction SilentlyContinue
    }
    throw $replacementError
  }
} finally {
  if ($null -ne $lockStream) { $lockStream.Dispose() }
  Remove-Item -LiteralPath $lockPath -ErrorAction SilentlyContinue
}
```

The lock acquisition and current-file hash check together form the compare-before-write guard. Never move the hash check outside the lock and never restore the backup when that check fails, because the differing current file may contain newer user-authored configuration. The lock name, 10-second acquisition deadline, and 30-second stale-lock threshold intentionally match Desktop's existing `withFileLock()` contract.

- [x] **Step 7: Confirm runtime model state after Pi reload**

Restart/reload the Pi session so `ModelRegistry` re-reads `models.json`. Select each `lookfor-gpt` GPT-5.6 model and verify available thinking levels are exactly `low`, `medium`, and `high`. Do not infer provider support for `off`, `minimal`, `xhigh`, or `max` from the model ID.

No repository commit is created for this machine-local configuration change.

---

### Task 8: Register Tests, Full Verification, And Manual Acceptance

**Files:**
- Modify: `package.json`
- Modify: `package-lock.json` only if npm normalizes dependency metadata
- Review: all task-owned files

- [x] **Step 1: Register focused scripts**

Add:

```json
"verify:extension-discovery": "node scripts/test-extension-discovery.mjs",
"verify:extension-view-bridge": "node scripts/test-extension-view-bridge.mjs",
"verify:model-provider-view-dom": "node scripts/test-model-provider-view-dom.mjs",
"verify:thinking-level-utils": "node scripts/test-thinking-level-utils.mjs"
```

Add `node --check` entries for every new `.js`/`.mjs` file and add the four verification scripts to `npm run check`. Keep all existing Host UI, i18n, model-provider, skills, and Pi event checks.

- [x] **Step 2: Run focused suites together**

Run:

```powershell
npm run verify:extension-packages
npm run verify:extension-discovery
npm run verify:extension-view-bridge
npm run verify:skills-view
npm run verify:model-provider-view-state
npm run verify:model-provider-view-dom
npm run verify:thinking-level-utils
npm run verify:pi-0803-events
```

Expected: every script exits `0` and prints its success line.

- [x] **Step 3: Run the full repository check**

Run:

```powershell
npm run check
```

Expected: TypeScript, syntax checks, all existing regressions, and all new regressions exit `0` with no failed assertion.

- [x] **Step 4: Review the final diff for scope and superseded behavior**

Run:

```powershell
git diff --check
git diff --stat
git diff -- extension-package-utils.js extension-discovery.js extension-view-bridge.js thinking-level-utils.js web/model-provider-state.js web/model-provider-view.js scripts/test-extension-package-utils.mjs scripts/test-extension-discovery.mjs scripts/test-extension-view-bridge.mjs scripts/test-model-provider-view-state.mjs scripts/test-model-provider-view-dom.mjs scripts/test-thinking-level-utils.mjs
git diff -- index.ts web/app.js package.json package-lock.json scripts/test-skills-view-state.mjs scripts/test-pi-0803-event-bridge.mjs
```

Confirm:

- No Desktop-side package source parser or package install-path calculation remains.
- No GPT-5.6 capability application injects `compat` or `thinkingLevelMap`.
- No fetched model ID is silently enriched.
- No model ID input handler rerenders `messagesEl` or replaces `<details>`.
- Footer cycling never uses a fixed six-level array.
- Host UI bridge changes remain intact and are not mixed into task commits.

- [x] **Step 5: Perform native Desktop acceptance checks**

Open Desktop in a trusted project and verify:

1. Skills and Extensions opens immediately with loading state and then shows extension name `claude-workflow` with a sanitized package source containing `npm:@hhyy668/claude-workflow-for-pi` from Pi resolver output.
2. Explicit refresh keeps cached cards visible, shows loading, and reports sanitized failures without replacing the last successful list.
3. The same project opened untrusted does not display trusted project extensions.
4. Typing `gpt-5.6-sol` continuously preserves focus, caret, and expanded model row.
5. After typing a recognized ID, click the newly generated capability, transport, and provider bulk actions; each responds, updates only its owned fields, and disappears when it becomes a no-op.
6. Direct Responses recommends `xhigh -> max`; direct Chat Completions recommends `xhigh -> xhigh`; `lookfor-gpt` reports the conservative guard as applied.
7. A `lookfor-gpt` GPT-5.6 model cycles `low -> medium -> high -> low` and never displays `off`, `minimal`, `xhigh`, or `max`.
8. A non-reasoning model shows `off` with a disabled thinking control and a correct title.

- [x] **Step 6: Commit test registration and any final task-owned corrections**

```powershell
git add package-lock.json
git add -p package.json
git add -p index.ts web/app.js scripts/test-skills-view-state.mjs scripts/test-pi-0803-event-bridge.mjs
git diff --cached --check
git diff --cached
git commit -m "test: cover desktop extension and thinking workflows"
```

Do not create an empty commit if all registration changes were already committed in earlier tasks.
