Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/webapp/src/lody/local-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ export function publishLodyLocalBridge(
): () => void {
target.ipc = bridge.ipc;
target.__LODY_LOCAL_BRIDGE__ = true;
target.__BLITZ_BUILTIN_DEFAULT_MODE_IDS__ = { claude: "bypassPermissions" };
return () => {
// ONLY CLEAR THE GLOBAL IF IT IS STILL OURS.
//
Expand All @@ -628,6 +629,7 @@ export function publishLodyLocalBridge(
if (target.ipc === bridge.ipc) {
delete target.ipc;
delete target.__LODY_LOCAL_BRIDGE__;
delete target.__BLITZ_BUILTIN_DEFAULT_MODE_IDS__;
}
};
}
5 changes: 3 additions & 2 deletions packages/webapp/src/lody/window-globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* The two window globals the Lody local bridge owns.
* The three window globals the Lody local bridge owns.
*
* `vendor/lody/packages/components/src/window-globals.d.ts` declares both, but
* The vendored components declare `ipc` and `__LODY_LOCAL_BRIDGE__`, but
* `vendor-modules.d.ts` deliberately keeps the vendor tree out of our
* typecheck, so its ambient declarations never reach us. These are the BlitzOS
* side of the same seam, and they are narrower on purpose: `ipc` is exactly the
Expand Down Expand Up @@ -30,6 +30,7 @@ declare global {
send: (channel: string, payload?: LodyIpcSendPayload) => void;
};
__LODY_LOCAL_BRIDGE__?: true;
__BLITZ_BUILTIN_DEFAULT_MODE_IDS__?: { claude?: string };
}
}

Expand Down
37 changes: 37 additions & 0 deletions packages/webapp/test/lody-default-permission-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it } from "vitest";
import { getBuiltinDefaultModeId } from "@lody/shared/ai";
import {
createLodyLocalBridge,
publishLodyLocalBridge,
} from "../src/lody/local-bridge.js";

afterEach(() => {
delete window.ipc;
delete window.__LODY_LOCAL_BRIDGE__;
delete window.__BLITZ_BUILTIN_DEFAULT_MODE_IDS__;
});

describe("the builtin permission-mode host override", () => {
it("uses the BlitzOS Claude default only while its local bridge is published", () => {
expect(getBuiltinDefaultModeId("builtin", "claude")).toBe("auto");

const bridge = createLodyLocalBridge({
syncUrl: "wss://box.invalid/webapp/7445/lody/sync",
rpcUrl: "https://box.invalid/webapp/7445/lody/rpc",
controlUrl: "https://box.invalid/webapp/7445/lody/control",
projectUrl: "https://box.invalid/webapp/7445/lody/project",
platformUrl: "https://box.invalid/webapp/7445/lody/platform",
filesBase: "https://box.invalid/webapp/5000/",
});
const unpublish = publishLodyLocalBridge(bridge);
try {
expect(getBuiltinDefaultModeId("builtin", "claude")).toBe("bypassPermissions");
expect(getBuiltinDefaultModeId("builtin", "codex")).toBe("agent-auto-review");
} finally {
unpublish();
bridge.dispose();
}

expect(getBuiltinDefaultModeId("builtin", "claude")).toBe("auto");
});
});
88 changes: 88 additions & 0 deletions packages/webapp/test/lody-unsent-run-config.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { act } from "react";
import { describe, expect, it } from "vitest";
import { useAcpSessionConfigSelectionState } from "@lody/components/hooks/use-acp-session-config-selection";
import { render } from "./dom.js";

function Harness({
targetKey,
preserveUnsentUserEdits,
preferenceModeId,
}: {
targetKey: string;
preserveUnsentUserEdits?: boolean;
preferenceModeId?: string;
}) {
const selection = useAcpSessionConfigSelectionState({
targetKey,
preferenceRevision: `revision:${preferenceModeId ?? "none"}`,
preferences: preferenceModeId === undefined ? {} : { modeId: preferenceModeId },
preserveUnsentUserEdits,
});
return (
<>
<button type="button" onClick={() => selection.selectMode("bypassPermissions")}>Pick</button>
<output data-user-mode>{selection.selection.edits.mode?.value ?? "none"}</output>
</>
);
}

function userMode(container: HTMLElement): string | undefined {
return container.querySelector("[data-user-mode]")?.textContent ?? undefined;
}

async function pickMode(container: HTMLElement): Promise<void> {
await act(async () => container.querySelector("button")?.click());
}

describe("unsent run-configuration retention", () => {
it("retains only opted-in edits per target and drops captured preferences", async () => {
let view = await render(<Harness targetKey="remount-a" preserveUnsentUserEdits />);
await pickMode(view.container);
await view.unmount();
view = await render(<Harness targetKey="remount-a" preserveUnsentUserEdits />);
expect(userMode(view.container)).toBe("bypassPermissions");
await view.unmount();

view = await render(<Harness targetKey="switch-a" preserveUnsentUserEdits />);
await pickMode(view.container);
await act(async () => view.root.render(
<Harness targetKey="switch-b" preserveUnsentUserEdits />,
));
expect(userMode(view.container)).toBe("none");
await act(async () => view.root.render(
<Harness targetKey="switch-a" preserveUnsentUserEdits />,
));
expect(userMode(view.container)).toBe("bypassPermissions");
await view.unmount();

const retentionCases: Array<readonly [string, boolean | undefined]> = [
["absent-retention", undefined],
["false-retention", false],
];
for (const [targetKey, preserveUnsentUserEdits] of retentionCases) {
view = await render(
<Harness targetKey={targetKey} preserveUnsentUserEdits={preserveUnsentUserEdits} />,
);
await pickMode(view.container);
await view.unmount();
view = await render(
<Harness targetKey={targetKey} preserveUnsentUserEdits={preserveUnsentUserEdits} />,
);
expect(userMode(view.container)).toBe("none");
await view.unmount();
}

view = await render(<Harness targetKey="captured" preserveUnsentUserEdits />);
await pickMode(view.container);
await view.unmount();
view = await render(
<Harness
targetKey="captured"
preserveUnsentUserEdits
preferenceModeId="bypassPermissions"
/>,
);
expect(userMode(view.container)).toBe("none");
await view.unmount();
});
});
44 changes: 44 additions & 0 deletions vendor/lody/BLITZ-PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2162,6 +2162,50 @@ the same file fallback and limit rule.
hunks. If the landing gains the prop itself, keep only the host wiring. If
image and file limits merge, preserve file access when cloud upload is absent.

### 28. A host may name its own builtin default mode (declared 2026-09-05)

**One idea, two hunks, one file.** Lody chooses `auto` for new builtin Claude
sessions. BlitzOS requires `bypassPermissions` for those sessions. Without this
seam, the Lody table overrides the host's agent configuration.

`packages/shared/src/ai.ts`

| # | File | Line (at `f4b1ba25`) | Upstream anchor | What it does |
|---|---|---|---|---|
| 1 | `packages/shared/src/ai.ts` | 405 | immediately after the `BUILTIN_DEFAULT_MODE_IDS` object | declares an optional host override on `globalThis` |
| 2 | same | 412 | the builtin branch in `getBuiltinDefaultModeId` | prefers the host value before the Lody value |

The override is optional. An absent value preserves every upstream default.
The BlitzOS bridge publishes one Claude override. Its guarded disposer removes
that override during the existing bridge hand-over.

Open upstream as "allow an embedding host to name builtin mode defaults".

### 29. An unsent run-config pick survives leaving the session (declared 2026-09-05)

**One idea, four hunks, one file.** A session picker stores unsent run
configuration only in mounted React state. Leaving the session unmounts that
state. Without this seam, returning restores only the latest sent turn.

`packages/components/src/hooks/use-acp-session-config-selection.ts`

| # | File | Line (at `f4b1ba25`) | Upstream anchor | What it does |
|---|---|---|---|---|
| 1 | `packages/components/src/hooks/use-acp-session-config-selection.ts` | 1 | the React hook import | imports `useEffect` for cleanup only |
| 2 | same | 74 | after `AcpSessionConfigSelectionHandle` | adds a bounded cache for non-empty unsent edits |
| 3 | same | 92 | the render-phase target fence | stashes the outgoing edits, then seeds the incoming target from the cache |
| 4 | same | 101 | after the fence, above the preference-stabilizing refs | holds the latest fence in a ref, and stashes it from one cleanup-only effect |

Only callers passing `preserveUnsentUserEdits: true` use the cache. Other
callers keep the upstream behavior. The cache retains eight targets and evicts
the oldest target first. Incoming edits pass through the existing fence. The
fence drops a pick when durable preferences capture it.

The cleanup writes only the module cache. It cannot update or oscillate React
state.

Open upstream as "retain opted-in unsent run configuration across session mounts".

## Retired compiled-bundle patches

| Retired script | Disposition | Evidence |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AcpConfigOptionValue } from '@lody/shared';
import {
areAcpSessionConfigPreferencesEqual,
Expand Down Expand Up @@ -74,6 +74,21 @@ export type AcpSessionConfigSelectionHandle = {
replaceConfigOptions: (values: Record<string, AcpConfigOptionValue>) => void;
};

// Blitz seam 29: see vendor/lody/BLITZ-PATCHES.md.
const retainedUnsentUserEdits = new Map<string, AcpSessionUserConfigEdits>();

function retainUnsentUserEdits(targetKey: string | null, edits: AcpSessionUserConfigEdits): void {
if (targetKey === null) return;
retainedUnsentUserEdits.delete(targetKey);
if (!edits.mode && !edits.model && Object.keys(edits.configOptions).length === 0) return;
retainedUnsentUserEdits.set(targetKey, edits);
if (retainedUnsentUserEdits.size <= 8) return;
for (const oldestTargetKey of retainedUnsentUserEdits.keys()) {
retainedUnsentUserEdits.delete(oldestTargetKey);
break;
}
}

export function useAcpSessionConfigSelectionState({
enabled = true,
targetKey,
Expand All @@ -92,17 +107,37 @@ export function useAcpSessionConfigSelectionState({
enabled &&
(fence.targetKey !== targetKey || fence.preferenceRevision !== preferenceRevision)
) {
const targetChanged = fence.targetKey !== targetKey;
if (targetChanged && preserveUnsentUserEdits) {
retainUnsentUserEdits(fence.targetKey, fence.edits);
}
const seededEdits =
targetChanged && preserveUnsentUserEdits
? targetKey === null
? EMPTY_ACP_SESSION_USER_CONFIG_EDITS
: (retainedUnsentUserEdits.get(targetKey) ?? EMPTY_ACP_SESSION_USER_CONFIG_EDITS)
: fence.edits;
setFence({
targetKey,
preferenceRevision,
edits: fenceAcpSessionUserEdits(fence.edits, {
targetChanged: fence.targetKey !== targetKey,
edits: fenceAcpSessionUserEdits(seededEdits, {
targetChanged: targetChanged && !preserveUnsentUserEdits,
preserveUnsentUserEdits,
preferences,
}),
});
}

const latestFenceRef = useRef({ fence, preserveUnsentUserEdits });
latestFenceRef.current = { fence, preserveUnsentUserEdits };
useEffect(() => () => {
const latest = latestFenceRef.current;
if (latest.preserveUnsentUserEdits) {
retainUnsentUserEdits(latest.fence.targetKey, latest.fence.edits);
}
// This cleanup only writes the module cache, so it cannot oscillate React state.
}, []);

/* VALUE-stabilize the preference inputs. `preferences`/`runtimePreferences`
are object literals resolved from `sessionDoc.history`, and the doc mirror
rebuilds `history` with unchanged values on every merge frame while an
Expand Down
10 changes: 9 additions & 1 deletion vendor/lody/packages/shared/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,13 @@ const BUILTIN_DEFAULT_MODE_IDS: Record<BuiltinAgentType, string> = {
deepseek: 'workspace-write',
};

declare global {
// Blitz seam 28: see vendor/lody/BLITZ-PATCHES.md.
var __BLITZ_BUILTIN_DEFAULT_MODE_IDS__:
| Partial<Record<BuiltinAgentType, string>>
| undefined;
}

/**
* Lody-owned mode default for builtin agents when a turn has no
* persisted selection. Capability-aware callers should use it only when the
Expand All @@ -414,7 +421,8 @@ export const getBuiltinDefaultModeId = (
agentType: AgentType | null | undefined
): string | undefined =>
cliType === 'builtin' && agentType && isBuiltinAgentType(agentType)
? BUILTIN_DEFAULT_MODE_IDS[agentType]
? (globalThis.__BLITZ_BUILTIN_DEFAULT_MODE_IDS__?.[agentType] ??
BUILTIN_DEFAULT_MODE_IDS[agentType])
: undefined;

const DEEPSEEK_HARNESS_CONFIG_OPTIONS: AcpConfigOptionSummary[] = [
Expand Down
Loading