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
20 changes: 18 additions & 2 deletions PiMobile/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,17 @@ final class APIClient {
return try decoder.decode(T.self, from: data)
}

private func post(_ path: String, body: some Encodable) async throws -> Data {
guard let mac = activeMac, let url = URL(string: mac.baseURL + path) else { throw APIError.badURL }
private func post(
_ path: String,
on requestedMac: MacServer? = nil,
body: some Encodable,
timeoutInterval: TimeInterval = 60
) async throws -> Data {
guard let mac = requestedMac ?? activeMac,
let url = URL(string: mac.baseURL + path) else { throw APIError.badURL }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = timeoutInterval
request.setValue("Bearer \(mac.token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Request bodies stay camelCase to match the companion server's JSON.
Expand All @@ -206,6 +213,15 @@ final class APIClient {

func repos(on mac: MacServer) async throws -> [Repo] { try await get("/repos", on: mac) }
func piVersion(on mac: MacServer) async throws -> PiVersionInfo { try await get("/pi-version", on: mac) }
func updatePi(on mac: MacServer) async throws -> PiVersionInfo {
let data = try await post(
"/pi-update",
on: mac,
body: [String: String](),
timeoutInterval: 180
)
return try decoder.decode(PiVersionInfo.self, from: data)
}

func loadModelGroups() async {
// Keep the last good list on failure (older server without /models, offline).
Expand Down
61 changes: 47 additions & 14 deletions PiMobile/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var statuses: [UUID: Bool] = [:]
@State private var piVersions: [UUID: PiVersionInfo] = [:]
@State private var piUpdatesInFlight: Set<UUID> = []
@State private var piUpdateErrors: [UUID: String] = [:]

private var macsNeedingPiUpdate: [(mac: MacServer, info: PiVersionInfo)] {
api.macs.compactMap { mac in
Expand All @@ -22,28 +24,44 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 10) {
Text("\(item.mac.name) is on Pi \(item.info.current ?? "?"); \(item.info.latest ?? "?") is available.")
.font(.subheadline)
HStack {
Text(item.info.updateCommand)
.font(.caption.monospaced())
.foregroundStyle(Theme.accent)
.textSelection(.enabled)
Spacer()
Button {
UIPasteboard.general.string = item.info.updateCommand
} label: {
Image(systemName: "doc.on.doc")
Button {
Task { await updatePi(on: item.mac) }
} label: {
HStack(spacing: 8) {
if piUpdatesInFlight.contains(item.mac.id) {
ProgressView()
.controlSize(.small)
Text("Updating…")
} else {
Label(
piUpdateErrors[item.mac.id] == nil ? "Update Pi" : "Try Again",
systemImage: "arrow.down.circle"
)
}
}
.buttonStyle(.borderless)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(piUpdatesInFlight.contains(item.mac.id))
.accessibilityLabel(
piUpdatesInFlight.contains(item.mac.id)
? "Updating Pi on \(item.mac.name)"
: "Update Pi on \(item.mac.name)"
)

if let error = piUpdateErrors[item.mac.id] {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
.accessibilityLabel("Pi update failed: \(error)")
}
.padding(10)
.background(Theme.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
}
.padding(.vertical, 2)
}
} header: {
Text("Update Pi")
} footer: {
Text("Run this in Terminal on that Mac — Pi updates there, not in this app.")
Text("Pi Companion updates Pi directly on that Mac. Finish any running Pi turn first.")
}
}

Expand Down Expand Up @@ -172,6 +190,21 @@ struct SettingsView: View {
}
}
}

private func updatePi(on mac: MacServer) async {
piUpdateErrors[mac.id] = nil
piUpdatesInFlight.insert(mac.id)
defer { piUpdatesInFlight.remove(mac.id) }

do {
let info = try await api.updatePi(on: mac)
withAnimation(.easeOut(duration: 0.2)) {
piVersions[mac.id] = info
}
} catch {
piUpdateErrors[mac.id] = error.localizedDescription
}
}
}

struct MacEditView: View {
Expand Down
2 changes: 1 addition & 1 deletion server/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ AGENT_PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/us
# Refuse to run from inside the companion service itself (e.g. a Pi agent turn
# spawned by the server): bootout would kill this script's own process tree
# mid-install, leaving the service unloaded and the turn dead.
SVC_PID="$(launchctl print "gui/$(id -u)/$LABEL" 2>/dev/null | awk '/pid =/{print $3; exit}')"
SVC_PID="$(launchctl print "gui/$(id -u)/$LABEL" 2>/dev/null | awk '/pid =/{print $3; exit}' || true)"
p=$$
while [[ -n "${SVC_PID:-}" && "$p" -gt 1 ]]; do
if [[ "$p" == "$SVC_PID" ]]; then
Expand Down
56 changes: 54 additions & 2 deletions server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const SESSIONS_ROOT = `${homedir()}/.pi/agent/sessions`;
const PORT = Number(process.env.PORT ?? 8940);
// LaunchAgents get a tiny PATH — resolve `pi` explicitly so model listing and
// RPC turns work even when ~/.local/bin isn't on it.
const PI = [
const PI = process.env.PI_PATH ?? [
`${homedir()}/.local/bin/pi`,
"/opt/homebrew/bin/pi",
"/usr/local/bin/pi",
Expand Down Expand Up @@ -620,7 +620,7 @@ function isOlder(current: string, latest: string): boolean {
}

async function refreshPiVersion() {
const p = Bun.spawnSync(["pi", "--version"], { stdout: "pipe", stderr: "pipe" });
const p = Bun.spawnSync([PI, "--version"], { stdout: "pipe", stderr: "pipe", env: piEnv() });
const raw = p.exitCode === 0
? (p.stdout.toString().trim() || p.stderr.toString().trim())
: "";
Expand Down Expand Up @@ -717,12 +717,58 @@ type Turn = {
pendingUI: PendingUI | null;
};
const turns = new Map<string, Turn>();
let piUpdateInProgress = false;

type PiUpdateError = { error: string; status: number };

async function updatePiInstall(): Promise<PiVersionInfo | PiUpdateError> {
if (piUpdateInProgress) return { error: "Pi is already updating", status: 409 };
if ([...turns.values()].some((turn) => turn.running))
return { error: "Wait for active Pi turns to finish before updating", status: 409 };

piUpdateInProgress = true;
try {
const proc = Bun.spawn([PI, "update", "--self", "--no-approve"], {
cwd: homedir(),
stdout: "pipe",
stderr: "pipe",
env: piEnv(),
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout as any).text(),
new Response(proc.stderr as any).text(),
proc.exited,
]);
if (exitCode !== 0) {
const detail = (stderr.trim() || stdout.trim() || `pi update exited with status ${exitCode}`)
.replace(/\x1b\[[0-9;]*m/g, "")
.slice(0, 2000);
return { error: detail, status: 500 };
}

await refreshPiVersion();
if (!piVersionCache.current)
return { error: "Pi updated, but its installed version could not be verified", status: 500 };
if (piVersionCache.update_available)
return {
error: `Pi update finished, but ${piVersionCache.current} is still installed`,
status: 500,
};
await Promise.all([refreshModels(), refreshSkills()]);
return piVersionCache;
} catch (error) {
return { error: `Failed to update Pi: ${String(error)}`, status: 500 };
} finally {
piUpdateInProgress = false;
}
}

function sendMessage(
sessionId: string,
text: string,
opts: { model?: string; thinking?: string; approvalMode?: string; images?: PromptImage[] } = {},
) {
if (piUpdateInProgress) return { error: "Pi is updating; try again when it finishes", status: 409 };
const existing = turns.get(sessionId);
if (existing?.running) return { error: "agent is already working", status: 409 };

Expand Down Expand Up @@ -930,6 +976,12 @@ Bun.serve({
const path = new URL(req.url).pathname;
let m: RegExpMatchArray | null;
try {
if (req.method === "POST" && path === "/pi-update") {
const result = await updatePiInstall();
return "error" in result
? Response.json({ error: result.error }, { status: result.status })
: Response.json(result);
}
if (req.method === "POST" && (m = path.match(/^\/sessions\/([^/]+)\/send$/))) {
const { text, model, thinking, approvalMode, images } = await req.json();
const hasImages = Array.isArray(images) && images.length > 0;
Expand Down