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
98 changes: 97 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
# exported as a system-Electron repackaging asset, and all outputs — with their
# electron-updater latest*.yml feeds — are published to the GitHub Release
# (D126/D285/D364).
#
# It also builds the headless `pi-host` bundle for every Linux platform the
# desktop publishes (currently x64) and publishes it as
# pi-host-<version>-linux-<arch>.tar.gz plus a .sha256 sidecar. The desktop's
# SSH bootstrap downloads that asset at the desktop's version and verifies the
# published checksum before installing on the remote machine (rollout
# 06-delivery/07-remote-control-rollout.md §6 acceptance 4; D375 / ADR 0284).

name: Release

Expand Down Expand Up @@ -275,10 +282,99 @@ jobs:
apps/desktop/release/*.blockmap
apps/desktop/release/latest*.yml

pi-host-bundle:
name: Bundle pi-host (Linux ${{ matrix.arch }})
needs: verify
strategy:
fail-fast: false
matrix:
include:
# Every Linux platform the desktop release publishes. The desktop
# matrix ships Linux x64 only today; add arm64 here in lockstep if
# the desktop starts publishing it.
- arch: x64
os: ubuntu-22.04
runs-on: ${{ matrix.os }}
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7

- name: Compare tag with apps/desktop/package.json
if: startsWith(github.ref, 'refs/tags/v')
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
APP_VERSION="$(node -p "require('./apps/desktop/package.json').version")"
if [ "$TAG_VERSION" != "$APP_VERSION" ]; then
echo "::error::Tag v$TAG_VERSION does not match apps/desktop/package.json version $APP_VERSION."
exit 1
fi

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2

- name: Build inputs (host-core, workspace, sidecar bundle)
run: |
set -euo pipefail
# Ubuntu 22.04 keeps the host-core glibc floor at 2.35, matching the
# desktop Linux build so the bundled binary loads on the same distros.
cargo build --release --locked -p host-core &
host_build_pid=$!
trap 'kill "$host_build_pid" 2>/dev/null || true' EXIT

pnpm install --frozen-lockfile
pnpm build:js
pnpm --filter @pi-desktop/agent-runtime bundle
pnpm --filter @pi-desktop/pi-host build

wait "$host_build_pid"
trap - EXIT

- name: Verify host-core glibc floor
run: node scripts/check-linux-host-glibc.mjs target/release/pi-desktop-host-core

- name: Assemble pi-host bundle
run: node apps/pi-host/scripts/bundle.mjs --platform linux --arch ${{ matrix.arch }}

- name: Archive bundle with checksum
run: |
set -euo pipefail
VERSION="$(node -p "require('./apps/pi-host/package.json').version")"
name="pi-host-${VERSION}-linux-${{ matrix.arch }}"
out="apps/pi-host/dist-bundle"
if [ ! -d "$out/$name" ]; then
echo "::error::expected bundle directory $out/$name"
ls -la "$out" || true
exit 1
fi
tar -czf "$out/${name}.tar.gz" -C "$out" "$name"
( cd "$out" && sha256sum "${name}.tar.gz" > "${name}.tar.gz.sha256" )
cat "$out/${name}.tar.gz.sha256"

- name: Upload pi-host bundle
uses: actions/upload-artifact@v4
with:
name: pi-host-linux-${{ matrix.arch }}
if-no-files-found: error
compression-level: 0
path: |
apps/pi-host/dist-bundle/pi-host-*-linux-${{ matrix.arch }}.tar.gz
apps/pi-host/dist-bundle/pi-host-*-linux-${{ matrix.arch }}.tar.gz.sha256

publish:
name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
needs: build
needs: [build, pi-host-bundle]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ apps/desktop/dist-electron/
apps/desktop/release/
packages/*/dist/
packages/*/dist-bundle/
apps/*/dist-bundle/

# Local design-source exports
docs/image/pi_logo.png
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ packages/
shared/ IPC/protocol contracts, error codes
i18n/ UI catalogs
agent-runtime/ pi sidecar wrapper
agent-host/ headless Agent Host module (admission, queue, approvals, events)
host-runtime/ Electron-independent runtime (transports, supervisor, turn lifecycle)
racp/ RACP-WS server/client and device pairing
plugin-sdk/ plugin author types/validators
plugin-devkit/ pi-plugin CLI
examples/plugins/ sample plugins
Expand Down
169 changes: 10 additions & 159 deletions apps/desktop/electron/main/agent-host-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
import { basename } from "node:path";

import {
AgentHost,
RacpError,
type ApprovalPort,
type PendingToolRequest,
type Principal,
type QueueEntryView,
type QueueStore,
type QueuedTurnRecord,
type RuntimePort,
type SessionPort,
type SessionSummary,
type TurnStartRequest,
type TurnSteerRequest,
} from "@pi-desktop/agent-host";
import {
createHostQueueStore,
createHostSessionPort,
listPendingToolRequests,
} from "@pi-desktop/host-runtime";
import type {
AgentEventEnvelope,
AgentQueueChangedEvent,
AgentQueuePushRequest,
AskToolResolution,
QueuedTurnSummary,
RacpApprovalResult,
RacpItemSummary,
RacpPermissionMode,
UiMessage,
} from "@pi-desktop/shared";
import { IPC, isGlobalPermissionMode } from "@pi-desktop/shared";

Expand Down Expand Up @@ -53,19 +49,6 @@ export const DESKTOP_PRINCIPAL: Principal = {
pairedDevice: true,
};

type HostSessionRecord = {
id: string;
title?: string;
projectId?: string;
projectPath?: string;
mode?: string;
permissionMode?: string;
planningState?: string;
createdAt?: string;
updatedAt?: string;
messages?: UiMessage[];
};

/**
* Electron Main's adapter around the headless Agent Host module (rollout R1,
* D374/D375). The module wraps the existing registered IPC handlers through
Expand Down Expand Up @@ -209,77 +192,13 @@ export function createAgentHostBridge(options: AgentHostBridgeOptions) {
resolvingViaModule.delete(input.proposalId);
}
},
async listPendingTools(sessionId) {
const host = options.getHost();
if (!host) return [];
const result = await host.call<{ requests?: PendingToolRequest[] }>("permissions.pending", {
...(sessionId ? { sessionId } : {}),
});
return result.requests ?? [];
},
};

const sessions: SessionPort = {
async get(sessionId) {
const record = await fetchSession(sessionId);
return record ? toSummary(record) : null;
},
async history(sessionId, { limit, beforeItemId }) {
const record = await fetchSession(sessionId);
const messages = record?.messages ?? [];
const end = beforeItemId ? messages.findIndex((message) => message.id === beforeItemId) : messages.length;
const cut = end === -1 ? messages.length : end;
const start = Math.max(0, cut - limit);
return {
items: messages.slice(start, cut).map(toItem),
hasMore: start > 0,
};
},
listPendingTools: (sessionId) => listPendingToolRequests(options.getHost, sessionId),
};

async function fetchSession(sessionId: string): Promise<HostSessionRecord | null> {
const host = options.getHost();
if (!host) return null;
const result = await host.call<{ session?: HostSessionRecord | null }>("session.get", { id: sessionId });
return result.session ?? null;
}

/** The Host-owned turn queue persisted by host-core (schema v15, ADR 0213). */
const queueStore: QueueStore = {
async listAll() {
const host = options.getHost();
if (!host) return [];
const result = await host.call<{ entries?: HostQueueEntry[] }>("session.queueList", {});
return (result.entries ?? []).map(fromHostQueueEntry);
},
async push(record) {
await requireHost().call("session.queuePush", {
id: record.id,
sessionId: record.sessionId,
principal: record.principalSubject,
...(record.idempotencyKey ? { idempotencyKey: record.idempotencyKey } : {}),
inputHash: record.inputHash,
content: record.content,
...(record.sessionMessageId ? { sessionMessageId: record.sessionMessageId } : {}),
...(record.attachments ? { attachments: record.attachments } : {}),
permissionMode: record.effectivePermissionMode,
});
},
async remove(id) {
const result = await requireHost().call<{ removed?: boolean }>("session.queueRemove", { id });
return result.removed === true;
},
async prioritize(id) {
await requireHost().call("session.queuePrioritize", { id });
},
async reorder(id, direction) {
const result = await requireHost().call<{ moved?: boolean }>("session.queueReorder", {
id,
direction,
});
return result.moved === true;
},
};
// Session reads and the persisted turn queue (schema v15, ADR 0213) go
// straight to host-core; the same ports serve the headless Host.
const sessions = createHostSessionPort(options.getHost);
const queueStore = createHostQueueStore(options.getHost);

const agentHost = new AgentHost({
runtime,
Expand Down Expand Up @@ -450,71 +369,3 @@ function toQueueSummary(entry: QueueEntryView): QueuedTurnSummary {
createdAt: entry.turn.startedAt ?? new Date().toISOString(),
};
}

type HostQueueEntry = {
id: string;
sessionId: string;
principal: string;
idempotencyKey?: string;
inputHash: string;
content: string;
sessionMessageId?: string;
attachments?: unknown;
permissionMode: string;
position: number;
priority?: number;
createdAt: string;
};

function fromHostQueueEntry(entry: HostQueueEntry): QueuedTurnRecord {
const permissionMode: RacpPermissionMode =
entry.permissionMode === "accept-edits" || entry.permissionMode === "auto" ? entry.permissionMode : "ask";
return {
id: entry.id,
sessionId: entry.sessionId,
principalSubject: entry.principal,
content: entry.content,
...(entry.sessionMessageId ? { sessionMessageId: entry.sessionMessageId } : {}),
...(Array.isArray(entry.attachments) ? { attachments: entry.attachments as QueuedTurnRecord["attachments"] } : {}),
effectivePermissionMode: permissionMode,
...(entry.idempotencyKey ? { idempotencyKey: entry.idempotencyKey } : {}),
inputHash: entry.inputHash,
...(entry.priority !== undefined ? { priority: entry.priority } : {}),
createdAt: Date.parse(entry.createdAt) || 0,
};
}

function toSummary(record: HostSessionRecord): SessionSummary {
const mode = record.mode === "plan" || record.mode === "goal" ? record.mode : "agent";
const permissionMode: RacpPermissionMode =
record.permissionMode === "accept-edits" || record.permissionMode === "auto" ? record.permissionMode : "ask";
const planningState =
record.planningState === "planning" || record.planningState === "awaiting_approval"
? record.planningState
: "inactive";
return {
id: record.id,
title: record.title ?? "",
...(record.projectId ? { projectId: record.projectId } : {}),
...(record.projectPath ? { workspaceLabel: basename(record.projectPath) } : {}),
mode,
permissionMode,
planningState,
createdAt: record.createdAt ?? new Date(0).toISOString(),
updatedAt: record.updatedAt ?? record.createdAt ?? new Date(0).toISOString(),
};
}

function toItem(message: UiMessage): RacpItemSummary {
const turnId = (message as { turnId?: string }).turnId ?? "";
return {
id: message.id,
turnId,
itemType: "message",
status: message.status === "streaming" ? "streaming" : "completed",
createdAt: message.createdAt,
...(message.parentToolCallId ? { parentToolCallId: message.parentToolCallId } : {}),
...(message.agentName ? { agentName: message.agentName } : {}),
content: message,
};
}
Loading
Loading