Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
9fd9693
fix(composer): align the reasoning slider stops to their labels
zeroy1024 Sep 20, 2026
e5d2e3d
fix(composer): put the reasoning slider track dots on the rail
zeroy1024 Sep 20, 2026
7d2e30c
feat(composer): animate the reasoning slider settle
zeroy1024 Sep 20, 2026
fe25926
fix(composer): stabilize and animate reasoning slider interactions
zeroy1024 Sep 20, 2026
24b45a6
fix(agent-runtime): reset retry budgets after successful responses
hui455 Sep 20, 2026
2b9d34b
fix(agent-runtime): cap output tokens to the model window
angeiv Sep 20, 2026
25887e5
chore(release): v0.15.2-beta.1
vastsa Sep 20, 2026
1837c4b
feat(transcript): 支持思考过程多级折叠
deqiying Sep 20, 2026
9ea66b9
fix(network): trust system certificates and stop certificate retries
hui455 Sep 20, 2026
1ab912e
Merge pull request #716 from angeiv/fix/problem-b-output-cap
vastsa Sep 20, 2026
21621a3
fix(plugins): pass HOME/USER into plugin child processes
vastsa Sep 20, 2026
f158988
fix(agent-runtime): enforce published context safety ceiling
vastsa Sep 20, 2026
1b7269c
Merge follow-up: enforce published context safety ceiling
vastsa Sep 20, 2026
34d86be
Merge pull request #722 from vastsa/fix/plugin-process-env-home
vastsa Sep 20, 2026
f4ca319
fix(e2e): close subagent model stream reader
vastsa Sep 20, 2026
c30b7b9
Merge E2E cleanup fix
vastsa Sep 20, 2026
8e3b248
fix(network): trust system certificates and stop certificate retries
vastsa Sep 20, 2026
6eadcfc
fix(composer): preserve workspace references across remounts
yuxino Sep 20, 2026
623d526
fix(composer): preserve existing attachments during pending paste
yuxino Sep 20, 2026
32796da
fix(composer): occlude transcript below dock
Blue-Berrys Sep 20, 2026
8327024
refactor(agent-runtime): extract the context budget formula
xiaobaZeo Sep 20, 2026
8a478e5
docs(adr): adopt ADR 0299 subagent context budget
xiaobaZeo Sep 20, 2026
56fd864
fix(agent-runtime): give delegates the session's context budget
xiaobaZeo Sep 20, 2026
c19f707
fix(settings): preserve selected default model when saving providers
yuxino Sep 20, 2026
c9db306
chore(pi): sync pi 0.86.1 and enable Meta OAuth
vastsa Sep 20, 2026
e6b6e0e
fix(pi): propagate one-shot provider failures
vastsa Sep 20, 2026
9218657
Merge pull request #733 from vastsa/fix/subagent-context-budget
vastsa Sep 20, 2026
6730281
Merge remote-tracking branch 'origin/main' into chore/sync-pi-0861
vastsa Sep 20, 2026
652dce6
chore(pi): sync pi 0.86.1 and enable Meta OAuth
vastsa Sep 20, 2026
af44fd2
Merge remote-tracking branch 'origin/main'
vastsa Sep 20, 2026
0ec874b
Merge remote-tracking branch 'refs/remotes/origin/pr-719' into feat/t…
vastsa Sep 20, 2026
655626c
fix(transcript): finish disclosure follow-up
vastsa Sep 20, 2026
5317775
fix(transcript): scope disclosure landing to message-level search
vastsa Sep 20, 2026
611e4b7
test(e2e): align transcript probes with nested disclosure
vastsa Sep 20, 2026
0376078
chore: retrigger docs deployment
vastsa Sep 20, 2026
2fc5bcb
Merge pull request #740 from vastsa/feat/transcript-disclosure
vastsa Sep 20, 2026
924bcee
Merge pull request #707 from zeroy1024/fix/composer-reasoning-slider-…
vastsa Sep 20, 2026
7c3d3aa
Merge pull request #729 from Blue-Berrys/fix/issue-728-opaque-composer
vastsa Sep 20, 2026
02f9783
Merge pull request #734 from febug/codex/preserve-default-model
vastsa Sep 20, 2026
8039542
Merge pull request #732 from febug/fix/paste-session-snapshot
vastsa Sep 20, 2026
21d3a27
fix(chat): make tool calls follow conversation width
vastsa Sep 20, 2026
8b579b0
fix(host-core): isolate marketplace fallback data paths
vastsa Sep 20, 2026
f4b73ff
fix(release): validate prerelease documentation versions
vastsa Sep 20, 2026
7d6ea94
chore(release): prepare v0.15.2-beta.2
vastsa Sep 20, 2026
79cd7ae
Merge PR #741: stabilize marketplace fallback and beta release
vastsa Sep 20, 2026
f60e498
merge: resolve composer regressions against latest main
yuxino Sep 21, 2026
efafd7d
fix(composer): retain draft workspace ownership across remounts
vastsa Sep 21, 2026
5b03116
Merge pull request #749 from vastsa/fix/composer-reference-remount-726
vastsa Sep 21, 2026
b31cbf4
fix(agent-runtime): integrate retry recovery with current main
hui455 Sep 21, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/docs-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ name: Docs check
# This workflow runs the documentation checks: the English/Chinese pair check,
# the whole-tree page/ADR check, the AGENTS.md/CLAUDE.md policy-sync gate, and
# a VitePress build, which fails on a dead link anywhere in the tree.
# `check:release-docs` stays out of CI on purpose because it intentionally
# fails on rc versions.
# `check:release-docs` stays out of CI on purpose because prerelease branches
# must supply the stable documentation version explicitly.

on:
push:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "LGPL-3.0-or-later"
version = "0.15.1"
version = "0.15.2-beta.2"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Local-first · Model-agnostic · Plugin-powered · macOS / Windows / Linux

</div>

> **Current release line: 0.15.x (Early Preview).**

---

## Why PI-Desktop?
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ Subagent 与 Worker Session 可以承担独立任务并行工作。
> [!NOTE]
> **PI-Desktop 目前仍处于 Early Preview。** 已可用于真实开发工作流,部分 API、插件接口与桌面能力仍在持续演进。

> **当前发布线:0.15.x(Early Preview)。**

---

## 插件不是附加功能,而是工作台的一部分
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/electron/main/agent-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export class AgentSidecar extends RuntimeAgentSidecar {
super({
launch: {
command: process.execPath,
args: [resolveSidecarEntry()],
// Electron 43's Node supports the OS trust store. Keep bundled roots
// and inherited NODE_EXTRA_CA_CERTS; never bypass TLS verification.
args: ["--use-system-ca", resolveSidecarEntry()],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
Expand Down
64 changes: 64 additions & 0 deletions apps/desktop/electron/main/child-process-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* The environment a restricted child process is allowed to inherit.
*
* Not the host's `process.env`: that carries provider keys and shell secrets a
* plugin has no business reading (ADR 0008, `07-plugins/04-plugin-security.md`
* §11.7). The list below is closed on purpose, and every entry has to earn its
* place.
*
* `HOME` / `USER` / `USERPROFILE` earn theirs because the consumer is not only
* the plugin: a plugin that spawns a third-party binary hands this environment
* to code we do not own, and that code resolves `~` through `$HOME` rather than
* calling `os.homedir()` (issue #717). Without an identity variable it falls
* back to `TMPDIR`, looks for its state somewhere that never exists, and fails
* as `runtime exited (1)` — an error the user cannot act on. Forwarding the
* value crosses no boundary the plugin cannot already cross itself: it can read
* the same path from `os.homedir()` (the `getpwuid` fallback), so this only
* stops plugin authors from having to restore it by hand in every plugin.
*
* `USERPROFILE` is the Windows counterpart, and is what shipped plugins already
* probe for.
*/

/**
* `PATH` is here so a bare command name stays findable. A caller that needs the
* login-shell PATH instead (ADR 0045) overrides it after the fact.
*/
const INHERITED_ENV_KEYS = [
"PATH",
"SystemRoot",
"windir",
"TEMP",
"TMP",
"TMPDIR",
"LANG",
"HOME",
"USER",
"USERPROFILE",
] as const;

/**
* The host variables that may cross into a plugin-owned child process.
*
* A variable the host does not have stays absent rather than being written as
* an empty string: `HOME=""` is worse than no `HOME` at all, because a binary
* that would otherwise fall back to `getpwuid` resolves `~` against the working
* directory instead.
*/
export function minimalChildEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const key of INHERITED_ENV_KEYS) {
const value = process.env[key];
if (value) env[key] = value;
}
return env;
}

/** The environment of a plugin's own utility process: minimal, plus its identity. */
export function pluginChildEnv(pluginId: string): Record<string, string> {
return {
...minimalChildEnv(),
PI_PLUGIN_ID: pluginId,
NODE_ENV: process.env.NODE_ENV ?? "production",
};
}
2 changes: 1 addition & 1 deletion apps/desktop/electron/main/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Vendor-account (OAuth) login for model providers.
*
* pi-ai owns the seven login flows and the locked token refresh; persistence
* pi-ai owns all supported login flows and the locked token refresh; persistence
* and the user-facing half of the conversation are the app's job (see
* `auth/types.d.ts`: "Login/account-removal orchestration is app-owned"). This module is
* that half:
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/electron/main/plugin-mcp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import { isAbsolute, resolve, sep } from "node:path";
import type { PluginMcpServerContrib } from "@pi-desktop/plugin-sdk";
import { minimalChildEnv } from "./child-process-env.ts";
import { userLookupPath } from "./user-login-path.ts";

/** MCP revision we advertise during the handshake. */
Expand Down Expand Up @@ -71,7 +72,9 @@ function mcpError(code: string, message: string): McpError {
* plugin identity to announce.
*
* PATH is the login-shell PATH (ADR 0045 / D600), not the Finder/Dock GUI
* PATH, so a market-installed `uvx`/`npx` server can spawn (issue #571).
* PATH, so a market-installed `uvx`/`npx` server can spawn (issue #571). The
* identity variables cross for the same reason the toolchain ones do: the child
* is third-party code that resolves `~` through `$HOME` (issue #717).
*/
export function mcpProcessEnv(
pluginId: string | undefined,
Expand All @@ -80,11 +83,8 @@ export function mcpProcessEnv(
const env: Record<string, string> = {
...(pluginId ? { PI_PLUGIN_ID: pluginId } : {}),
NODE_ENV: process.env.NODE_ENV ?? "production",
...minimalChildEnv(),
};
for (const key of ["SystemRoot", "windir", "TEMP", "TMP", "TMPDIR", "LANG"]) {
const value = process.env[key];
if (value) env[key] = value;
}
const path = userLookupPath(process.env.PATH ?? "");
if (path) env.PATH = path;
return { ...env, ...values };
Expand Down
19 changes: 2 additions & 17 deletions apps/desktop/electron/main/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
resolveRealPathWithinRoot,
resolveWithinRoot,
} from "@pi-desktop/host-runtime";
import { pluginChildEnv } from "./child-process-env";
import { desktopDataDir } from "./data-paths";
import { McpServerClient, type McpServerClientOptions } from "./plugin-mcp";
import { PluginToolInvocations, type PluginToolInvocation } from "./plugin-tool-invocations";
Expand Down Expand Up @@ -1154,29 +1155,13 @@ function resolveWindowBackground(
return result.light || result.dark ? result : undefined;
}

/**
* Minimal environment for a plugin process: the host's own env may carry
* provider keys and shell secrets, and plugins have no business seeing them.
*/
function pluginProcessEnv(pluginId: string): Record<string, string> {
const env: Record<string, string> = {
PI_PLUGIN_ID: pluginId,
NODE_ENV: process.env.NODE_ENV ?? "production",
};
for (const key of ["PATH", "SystemRoot", "windir", "TEMP", "TMP", "TMPDIR", "LANG"]) {
const value = process.env[key];
if (value) env[key] = value;
}
return env;
}

/** Default spawner: an Electron utilityProcess per plugin. */
const spawnUtilityProcess: PluginProcessSpawner = async ({ pluginId, entry }) => {
const { utilityProcess } = await import("electron");
const child = utilityProcess.fork(entry, [], {
serviceName: `pi-plugin-${pluginId.replace(/[^a-zA-Z0-9._-]/g, "_")}`,
stdio: "pipe",
env: pluginProcessEnv(pluginId),
env: pluginChildEnv(pluginId),
});
return {
postMessage: (message) => child.postMessage(message),
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pi-desktop/desktop",
"version": "0.15.1",
"version": "0.15.2-beta.2",
"desktopName": "pi-desktop.desktop",
"private": true,
"description": "PI-Desktop Electron application",
Expand Down Expand Up @@ -34,7 +34,7 @@
"electron-updater": "^6.8.9"
},
"devDependencies": {
"@earendil-works/pi-ai": "0.85.1",
"@earendil-works/pi-ai": "0.86.1",
"@pi-desktop/agent-host": "workspace:*",
"@pi-desktop/agent-runtime": "workspace:*",
"@pi-desktop/host-runtime": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/resources/models.dev/api.json

Large diffs are not rendered by default.

53 changes: 32 additions & 21 deletions apps/desktop/src/components/SessionPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useAppStore } from "../stores/app-store";
import { headPermission, sessionPermissions } from "../lib/pending-permissions";
import { headAsk } from "../lib/pending-asks";
import { useTranscriptView } from "../hooks/use-transcript-view";
import { TranscriptDisclosureProvider } from "../features/chat/transcript/disclosure";

/**
* One retained conversation pane (ADR 0137).
Expand Down Expand Up @@ -41,9 +42,17 @@ export const SessionPane = memo(function SessionPane({
Boolean(headAsk(state.pendingAsks, sessionId)),
);
const planningState = useAppStore((state) => state.planningStates[sessionId]);
const searchTarget = useMemo(() => transcript.focus && transcript.parentMessage
? { ...transcript.focus, messageId: transcript.parentMessage.id, query: "" }
: transcript.focus, [transcript.focus, transcript.parentMessage]);
const searchTarget = useMemo(
() =>
transcript.focus && transcript.parentMessage
? {
...transcript.focus,
messageId: transcript.parentMessage.id,
query: "",
}
: transcript.focus,
[transcript.focus, transcript.parentMessage],
);

return (
<div
Expand All @@ -56,24 +65,26 @@ export const SessionPane = memo(function SessionPane({
aria-hidden={visible ? undefined : true}
inert={visible ? undefined : true}
>
<ChatTranscript
sessionId={sessionId}
messages={transcript.messages}
hasMoreBefore={transcript.hasMoreBefore}
onLoadOlder={() => loadTranscriptPage(sessionId, "before")}
searchTarget={searchTarget}
readingWindow={transcript.historical}
hasMoreAfter={transcript.hasMoreAfter}
onLoadNewer={() => loadTranscriptPage(sessionId, "after")}
onReturnToLatest={() => returnToLatest(sessionId)}
navigationLoading={transcript.loading}
isRunning={isRunning}
pendingPermission={transcript.historical ? undefined : pendingPermission}
queuedPermissions={queuedPermissions}
askPending={transcript.historical ? false : askPending}
planningState={transcript.historical ? undefined : planningState}
paneVisible={visible}
/>
<TranscriptDisclosureProvider>
<ChatTranscript
sessionId={sessionId}
messages={transcript.messages}
hasMoreBefore={transcript.hasMoreBefore}
onLoadOlder={() => loadTranscriptPage(sessionId, "before")}
searchTarget={searchTarget}
readingWindow={transcript.historical}
hasMoreAfter={transcript.hasMoreAfter}
onLoadNewer={() => loadTranscriptPage(sessionId, "after")}
onReturnToLatest={() => returnToLatest(sessionId)}
navigationLoading={transcript.loading}
isRunning={isRunning}
pendingPermission={transcript.historical ? undefined : pendingPermission}
queuedPermissions={queuedPermissions}
askPending={transcript.historical ? false : askPending}
planningState={transcript.historical ? undefined : planningState}
paneVisible={visible}
/>
</TranscriptDisclosureProvider>
</div>
);
});
4 changes: 2 additions & 2 deletions apps/desktop/src/components/settings/AnchoredMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ export function AnchoredMenu({
const onViewportChange = (event: Event) => {
// Scrolling inside the menu cannot move a fixed layer, so skip it rather
// than forcing a layout read on every scroll tick.
const target = event.target as Node | null;
if (target && menuRef.current?.contains(target)) return;
const target = event.target;
if (target instanceof Node && menuRef.current?.contains(target)) return;
updatePosition();
};
window.addEventListener("resize", onViewportChange);
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/components/settings/ModelConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* The default picker lists each configured model, while provider rows use
* `models[0]` as the provider's quick default. Editing the default provider
* re-syncs `settings.defaultModelId` when that first model changes.
* preserves `settings.defaultModelId` while that model remains configured.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -159,8 +159,7 @@ export function ModelConfigPage() {
};

/**
* A saved provider that is also the global default may have changed its first
* model, which is what `settings.defaultModelId` points at.
* Preserve the selected app default unless it was removed from the provider.
*/
const afterSaved = async (saved: ProviderPublic, models: ModelBinding[]) => {
const firstModelId = models[0]?.id;
Expand All @@ -175,7 +174,10 @@ export function ModelConfigPage() {
});
showToast(t("settings.providerSaved"), { variant: "success" });
} else {
if (settings.defaultProviderId === saved.id && firstModelId) {
if (
settings.defaultProviderId === saved.id && firstModelId &&
!models.some((model) => modelIdsMatch(model.id, settings.defaultModelId ?? ""))
) {
await api.setSettings({ ...settings, defaultModelId: firstModelId });
}
showToast(t("settings.providerUpdated"), { variant: "success" });
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/components/workpanel/SubagentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useTranscriptView } from "../../hooks/use-transcript-view";
import { useTranscriptSearchFocus } from "../../hooks/use-transcript-search-focus";
import { IconArrowDown } from "../icons";
import { DisclosureAnchorContext } from "../../lib/disclosure-anchor-context";
import { TranscriptDisclosureProvider } from "../../features/chat/transcript/disclosure";
import { TooltipButton } from "../ui";
import { SubagentDetail } from "../ChatTranscript";

Expand Down Expand Up @@ -51,6 +52,14 @@ function findSelectedSubagent(
}

export function SubagentPanel({ selection }: { selection: SubagentPanelSelection }) {
return (
<TranscriptDisclosureProvider key={`${selection.sessionId}:${selection.delegationId}`}>
<SubagentPanelSurface selection={selection} />
</TranscriptDisclosureProvider>
);
}

function SubagentPanelSurface({ selection }: { selection: SubagentPanelSelection }) {
const { t } = useTranslation();
const activeSessionId = useAppStore((state) => state.activeSessionId);
const transcript = useTranscriptView(selection.sessionId);
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/components/workpanel/WorkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,17 @@ export function WorkPanel({
},
[closeTab, tabs],
);
const closeSubagentPanelAndFocus = useCallback(() => {
const delegationId = subagentPanel?.delegationId;
onCloseSubagentPanel?.();
if (!delegationId) return;
requestAnimationFrame(() => {
const trigger = [...document.querySelectorAll<HTMLElement>("[data-subagent-trigger]")].find(
(candidate) => candidate.dataset.subagentTrigger === delegationId,
);
trigger?.focus({ preventScroll: true });
});
}, [onCloseSubagentPanel, subagentPanel?.delegationId]);

const onTabKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>, tabId: string) => {
Expand Down Expand Up @@ -564,7 +575,7 @@ export function WorkPanel({
className="work-panel-subagent-back"
tooltip={t("panel.subagentClose")}
ariaLabel={t("panel.subagentClose")}
onClick={onCloseSubagentPanel}
onClick={closeSubagentPanelAndFocus}
>
<IconChevronLeft size={15} />
</TooltipButton>
Expand Down
Loading
Loading