diff --git a/PiMobile/APIClient.swift b/PiMobile/APIClient.swift index d8e9f78..c2d3ab3 100644 --- a/PiMobile/APIClient.swift +++ b/PiMobile/APIClient.swift @@ -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. @@ -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). diff --git a/PiMobile/Views/SettingsView.swift b/PiMobile/Views/SettingsView.swift index d575c39..a51d16d 100644 --- a/PiMobile/Views/SettingsView.swift +++ b/PiMobile/Views/SettingsView.swift @@ -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 = [] + @State private var piUpdateErrors: [UUID: String] = [:] private var macsNeedingPiUpdate: [(mac: MacServer, info: PiVersionInfo)] { api.macs.compactMap { mac in @@ -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.") } } @@ -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 { diff --git a/server/install.sh b/server/install.sh index 887d06b..7452b7e 100755 --- a/server/install.sh +++ b/server/install.sh @@ -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 diff --git a/server/server.ts b/server/server.ts index ece0010..da7209d 100644 --- a/server/server.ts +++ b/server/server.ts @@ -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", @@ -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()) : ""; @@ -717,12 +717,58 @@ type Turn = { pendingUI: PendingUI | null; }; const turns = new Map(); +let piUpdateInProgress = false; + +type PiUpdateError = { error: string; status: number }; + +async function updatePiInstall(): Promise { + 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 }; @@ -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;