Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ee31195
feat(subagents): configure thinking per fallback model
inorilzy Sep 24, 2026
20baa96
fix(styles): use the checkbox radius token to unblock CI
inorilzy Sep 24, 2026
b8d36c6
Merge upstream main to fix PR #1013 checks
inorilzy Sep 24, 2026
9be95e5
fix(styles): tokenize model menu metrics and light-theme shadow
inorilzy Sep 24, 2026
5870f4e
feat(shell): Mid-Autumn easter egg on first launch, replayable from S…
Tioit-Wang Sep 24, 2026
100d4d9
fix(composer): fade the transcript instead of painting an opaque dock…
Tioit-Wang Sep 24, 2026
c807dfe
refactor(agent-runtime): trim system prompt — prefer self-execution o…
vastsa Sep 24, 2026
d9f6e02
fix(chat): wrap long slash command chips in the transcript (#1019)
yuxino Sep 24, 2026
6691fca
fix(agent-host): keep the next queued message cancelable during startup
zszz3 Sep 24, 2026
41d15d5
fix(catalog): resolve gateway model metadata from other publishers
lenmei233 Sep 24, 2026
0f07554
feat(desktop): show unread task result badge on Windows taskbar
caulif Sep 24, 2026
fc48511
feat(chat): browse generated image batches in one card
hui455 Sep 24, 2026
9d9745c
fix(agent-runtime): update prompt assertions after system prompt trim
vastsa Sep 24, 2026
a5c73ff
fix(shell): redesign mid-autumn egg animation flow
vastsa Sep 24, 2026
25d54c9
chore(release): v0.15.7-beta.1
vastsa Sep 24, 2026
9711fda
fix(shell): wrap egg overlay background in custom property for lint
vastsa Sep 24, 2026
63d518d
feat(build): add Windows portable single-exe target
vastsa Sep 24, 2026
dc4e5f9
fix(mid-autumn-egg): wait for Chinese font before text sampling
vastsa Sep 24, 2026
ed2f521
feat(settings): gate voice settings behind developer mode
vastsa Sep 24, 2026
c2bfe35
fix(test): update auto-update test for Windows portable target
vastsa Sep 24, 2026
e0d142b
fix(skills): ignore hidden scoped package entries (#1042)
vastsa Sep 25, 2026
9bb469e
fix(subagents): reconcile PR 1013 with current main
inorilzy Sep 25, 2026
c49fc68
fix(ci): compare PR budgets with integration base
inorilzy Sep 25, 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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ jobs:

- name: Check architecture budgets
env:
ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}
# The PR checkout is a synthetic merge: its first parent is the target branch.
# The webhook base SHA may lag behind that parent after main moves.
ARCHITECTURE_BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.before || 'HEAD^' }}
run: node scripts/check-architecture.mjs

- name: Unit tests
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.6"
version = "0.15.7-beta.1"
11 changes: 11 additions & 0 deletions apps/desktop/electron/main/bootstrap/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { catalogs, resolveLocale } from "@pi-desktop/i18n";
import { installApplicationMenu } from "../application-menu";
import { createTraySessions } from "../tray-sessions";
import { createTaskbarUnreadBadge } from "../taskbar-unread-badge";
import { createWindow, type WindowLifecycleState } from "./window";
import { windowToggleAction } from "./window-visibility";
import type { BrowserPane } from "../browser-view";
Expand Down Expand Up @@ -116,6 +117,12 @@ export function createApplicationLifecycle({
onChanged: () => updateTrayMenu(),
logger,
});
const taskbarUnreadBadge = createTaskbarUnreadBadge({
getHost,
getMainWindow: () => state.mainWindow,
isQuitting: () => state.quitting,
logger,
});
let trayActivationGeneration = 0;

function applyDevelopmentBranding() {
Expand Down Expand Up @@ -276,6 +283,9 @@ export function createApplicationLifecycle({
state.tray.on("double-click", restoreMainWindow);
updateTrayMenu();
void traySessions.refresh();
void taskbarUnreadBadge.refresh();
// Window is live here; force-paint any count learned before the BrowserWindow existed.
taskbarUnreadBadge.replay();
}


Expand Down Expand Up @@ -661,6 +671,7 @@ export function createApplicationLifecycle({

return {
traySessions,
taskbarUnreadBadge,
applyDevelopmentBranding,
hasVisibleWindow,
restoreMainWindow,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,7 @@ function describeError(error: unknown): string {
/** Pull the user's MCP server records from host-core into the local runtime. */
function sendToRenderer(channel: string, payload: unknown) {
applicationLifecycle?.traySessions.observeEvent(channel, payload);
applicationLifecycle?.taskbarUnreadBadge.observeEvent(channel, payload);
if (channel === IPC.event.pluginChanged) {
applicationLifecycle?.applyNativeThemeSource({
theme: applicationAppearanceState.appThemePreference,
Expand Down Expand Up @@ -1239,6 +1240,7 @@ const voiceService = createVoiceService(dataDir + "/voice-models", () => mainWin
function registerIpc() {
return registerIpcHandlers({
traySessions: applicationLifecycle!.traySessions,
taskbarUnreadBadge: applicationLifecycle!.taskbarUnreadBadge,
ipcMain,
getMainWindow: () => mainWindow,
getHost: () => host,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/electron/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ import { registerSpeechIpc } from "./speech-ipc";
import { registerVoiceIpc } from "./voice-ipc";
import type { IpcRegistrar } from "./types";
import type { createTraySessions } from "../tray-sessions";
import type { createTaskbarUnreadBadge } from "../taskbar-unread-badge";

export type RegisterIpcDependencies = {
isQuitting: () => boolean;
ipcMain: IpcMain;
getMainWindow: () => BrowserWindow | null;
getHost: () => HostProcess | null;
traySessions: ReturnType<typeof createTraySessions>;
taskbarUnreadBadge: ReturnType<typeof createTaskbarUnreadBadge>;
getSidecar: () => AgentSidecar | null;
getAgentHostBridge: () => AgentHostBridge | null;
/**
Expand Down Expand Up @@ -125,6 +127,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) {
getCloseBehavior,
markMenuRendererReady,
traySessions,
taskbarUnreadBadge,
executeNativeMenuAction,
scheduledRunsBySession,
isDevelopmentBuild,
Expand Down Expand Up @@ -165,6 +168,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) {
const handler = async (...args: any[]) => {
const result = await fn(...args);
traySessions.observeInvoke(channel);
taskbarUnreadBadge.observeInvoke(channel);
return result;
};
ipcHandlers.set(channel, handler);
Expand Down
152 changes: 144 additions & 8 deletions apps/desktop/electron/main/models-dev-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,85 @@ const EMPTY_CANDIDATES: readonly IndexedModel[] = [];



/**
* Middle value of the numbers the publishers state. Even counts take the lower
* of the two middles, so a borrow never rounds a window up on its own.
*/
function medianOf(values: readonly (number | undefined)[]): number | undefined {
const present = values.filter((value): value is number => value !== undefined);
if (present.length === 0) return undefined;
const sorted = [...present].sort((left, right) => left - right);
const middle = sorted.length >> 1;
return sorted.length % 2 === 1
? sorted[middle]
: sorted[middle - 1];
}

/**
* Record to borrow for an id the row's own catalog provider does not publish.
*
* models.dev indexes a gateway's copy of a model under the vendor that owns
* the weights, so an endpoint serving `Vendor/Model` ids can have no record of
* its own while several other publishers state the identical id. Borrowing
* that record is what keeps such a model's context window, tool support and
* vision visible instead of dropping the row to the generic 128k text-only
* shape.
*
* Two rules keep the result honest about what the endpoint will accept:
*
* - Tool support is the gate. Every publisher of the id must agree, because a
* wrong `true` puts tool declarations on the wire that the endpoint may
* reject and break the turn. Publishers that split on this id are describing
* different deployments, so nothing is borrowed.
* - Every other capability is the *intersection*, so the borrow may only
* under-claim. Reasoning, image input and image/PDF attachment are reported
* only when every publisher states them, and a user who knows the endpoint
* does more can still turn them on in Advanced. Limits are the medians the
* publishers state, so neither one host's cap nor one host's round-up
* decides them.
*/
function borrowedModel(entries: readonly ModelsDevModel[]): ModelsDevModel | undefined {
if (entries.length === 0) return undefined;
const toolCall = entries[0].toolCall;
if (entries.some((model) => model.toolCall !== toolCall)) return undefined;
const every = (pick: (model: ModelsDevModel) => boolean | undefined): boolean | undefined =>
entries.every((model) => pick(model) === true) ? true
: entries.every((model) => pick(model) === false) ? false
: undefined;
const base = entries[0];
const keptModality = (modality: ModelModality): boolean =>
entries.every((model) => model.modalities.input.includes(modality));
const inputModalities = base.modalities.input.filter(keptModality);
const outputModalities = base.modalities.output.filter((modality) =>
entries.every((model) => model.modalities.output.includes(modality)),
);
const context = medianOf(entries.map((model) => model.limit.context));
const output = medianOf(entries.map((model) => model.limit.output));
const source = entries.find(
(model) =>
(context === undefined || model.limit.context === context) &&
(output === undefined || model.limit.output === output),
) ?? base;
const reasoning = every((model) => model.reasoning) === true;
return {
...source,
// Capabilities that must never be asserted on one publisher's word alone.
toolCall,
reasoning,
thinkingLevels: reasoning ? source.thinkingLevels : [],
structuredOutput: every((model) => model.structuredOutput),
attachment: every((model) => model.attachment),
modalities: {
input: inputModalities.length > 0 ? inputModalities : ["text"],
output: outputModalities.length > 0 ? outputModalities : ["text"],
},
limit: {
...(context !== undefined ? { context } : {}),
...(output !== undefined ? { output } : {}),
},
};
}

export class ModelsDevCatalog {
private providers = new Map<string, ModelsDevProvider>();
private lookupIndex: ModelsDevLookupIndex | undefined;
Expand Down Expand Up @@ -1030,17 +1109,74 @@ export class ModelsDevCatalog {
candidates.sort((left, right) =>
right.score - left.score || left.model.modelId.length - right.model.modelId.length,
);
// An unknown endpoint can use a unique catalog match (including supported
// proxy aliases), but scores are not proof of provider identity. If two
// providers publish the same ID, do not borrow either one's metadata.
const result = preferredProvider || candidates.length === 1
? candidates[0]?.model
: undefined;
/* Within the row's own catalog provider this is an exact-provider lookup:
the provider identity is known, so its record for the id — a direct hit
or a supported alias — is authoritative.

With no provider identity the scores prove nothing about identity, so a
catalog answer is only usable when it is unambiguous. Two providers
publishing the same id must not have one of them chosen for the other. */
const resolved = candidates[0]?.model;
const result = preferredProvider
? resolved
: candidates.length === 1 ? resolved : undefined;
/* The row's own catalog provider did not publish this id. Borrowing needs a
known provider identity to anchor on: the row resolved to a catalog
provider whose own records are authoritative, so anything missing from it
can be checked against the other publishers of the exact id instead of
dropping the model to the generic shape.

This only fills a miss the lookup already had — a record the preferred
provider does publish stays authoritative. Without that anchor the scores
prove nothing about identity, and an unknown endpoint keeps the existing
behaviour: a unique unambiguous match, or nothing. */
const borrowed = result ??
(preferredProvider ? this.borrowedAcrossProviders(input) : undefined);
// Cache the result (a miss included) so a repeated miss is also O(1) and
// cannot grow the candidate index with query-dependent keys.
this.lookupMemo.set(memoKey, result);
this.lookupMemo.set(memoKey, borrowed);
if (this.lookupMemo.size > LOOKUP_MEMO_LIMIT) this.lookupMemo.clear();
return result;
return borrowed;
}

/**
* Exact-id fallback across catalog providers, used only when the row resolved
* to a catalog provider that published nothing for the id.
*
* A gateway's copy of a model is indexed under the vendor that owns the
* weights, so an endpoint serving `Vendor/Model` ids routinely has no record
* of its own while other publishers state the identical id. Their record is
* what keeps that model's window, tool support and vision visible.
*
* Two exclusions keep this from borrowing anything identity-sensitive:
*
* - A provider sharing the row's own endpoint is an alias for the row, not an
* independent source. Its failure to publish the id is an answer about this
* deployment, so nothing is borrowed past it.
* - Only an exactly-identical id transfers. The index also reaches a record
* through aliases — a bare route leaf behind a prefix, a vendor-prefixed
* variant — and those describe a *different* id, whose limits and
* capabilities are not this model's.
*/
private borrowedAcrossProviders(
input: { vendorKey?: string; baseUrl?: string; modelId: string },
): ModelsDevModel | undefined {
// `findModel` already rejected an empty id; normalize here so the compare
// below is case-insensitive against the catalog's own normalization.
const requested = normalizedModelId(input.modelId);
const matches: ModelsDevModel[] = [];
const seen = new Set<string>();
for (const candidate of this.lookupIndex?.candidates(requested) ?? []) {
const { model, provider } = candidate;
// The row's endpoint owns its own answers, including a negative one.
if (apiMatches(input.baseUrl, provider.api)) continue;
if (normalizedModelId(model.modelId) !== requested) continue;
const key = `${provider.providerKey}\u0000${model.modelId}`;
if (seen.has(key)) continue;
seen.add(key);
matches.push(model);
}
return borrowedModel(matches);
}

modelsForProvider(input: { vendorKey?: string; baseUrl?: string; providerId: string }): ModelInfo[] {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/electron/main/pi-skill-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export async function discoverPiSkillPackages(modules: string, importedDescripti
}
try {
for (const child of await readdir(path, { withFileTypes: true })) {
if (child.isDirectory() && !child.isSymbolicLink()) paths.push(join(path, child.name));
if (child.isDirectory() && !child.isSymbolicLink() && !child.name.startsWith(".")) {
paths.push(join(path, child.name));
}
}
} catch (error) {
errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
Expand Down
Loading
Loading