diff --git a/.gitignore b/.gitignore index 09de5ae..0493390 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ DerivedData/ *.xcuserstate node_modules/ .clawpatch/ +build/ diff --git a/Config/Info.plist b/Config/Info.plist index b0d3c72..d6bc3bd 100644 --- a/Config/Info.plist +++ b/Config/Info.plist @@ -14,10 +14,10 @@ CFBundleURLName - com.matt-nz.conductormobile.pair + com.matt-nz.pimobile.pair CFBundleURLSchemes - conductor-companion + pi-companion diff --git a/PRIVACY.md b/PRIVACY.md index c683ccd..e1eb20c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,9 +1,9 @@ -# Privacy Policy — Conductor Companion +# Privacy Policy — Pi Mobile -Conductor Companion does not collect, store, or transmit any personal data to us or any third party. +Pi Mobile does not collect, store, or transmit any personal data to us or any third party. The app connects directly and exclusively to a companion server that you run on your own Mac, using an address and auth token you provide. Your chat history, project names, and code diffs travel only between your phone and your Mac over your own network (LAN or your private Tailscale tailnet). There are no analytics, no tracking, no third-party SDKs, and no accounts. The auth token is stored in the iOS Keychain on your device. -Questions: open an issue at https://github.com/MRL-00/conductor-mobile +Questions: open an issue at https://github.com/MRL-00/pi-mobile diff --git a/ConductorMobile.xcodeproj/project.pbxproj b/PiMobile.xcodeproj/project.pbxproj similarity index 83% rename from ConductorMobile.xcodeproj/project.pbxproj rename to PiMobile.xcodeproj/project.pbxproj index cf91182..c372112 100644 --- a/ConductorMobile.xcodeproj/project.pbxproj +++ b/PiMobile.xcodeproj/project.pbxproj @@ -7,9 +7,9 @@ objects = { /* Begin PBXFileSystemSynchronizedRootGroup section */ - A1000001 /* ConductorMobile */ = { + A1000001 /* PiMobile */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = ConductorMobile; + path = PiMobile; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -18,7 +18,7 @@ A1000002 = { isa = PBXGroup; children = ( - A1000001 /* ConductorMobile */, + A1000001 /* PiMobile */, A1000003 /* Products */, ); sourceTree = ""; @@ -26,7 +26,7 @@ A1000003 /* Products */ = { isa = PBXGroup; children = ( - A1000004 /* ConductorMobile.app */, + A1000004 /* PiMobile.app */, ); name = Products; sourceTree = ""; @@ -34,13 +34,13 @@ /* End PBXGroup section */ /* Begin PBXFileReference section */ - A1000004 /* ConductorMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ConductorMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1000004 /* PiMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PiMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXNativeTarget section */ - A1000005 /* ConductorMobile */ = { + A1000005 /* PiMobile */ = { isa = PBXNativeTarget; - buildConfigurationList = A1000006 /* Build configuration list for PBXNativeTarget "ConductorMobile" */; + buildConfigurationList = A1000006 /* Build configuration list for PBXNativeTarget "PiMobile" */; buildPhases = ( A1000007 /* Sources */, A1000008 /* Resources */, @@ -50,13 +50,13 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - A1000001 /* ConductorMobile */, + A1000001 /* PiMobile */, ); - name = ConductorMobile; + name = PiMobile; packageProductDependencies = ( ); - productName = ConductorMobile; - productReference = A1000004 /* ConductorMobile.app */; + productName = PiMobile; + productReference = A1000004 /* PiMobile.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -74,7 +74,7 @@ }; }; }; - buildConfigurationList = A100000A /* Build configuration list for PBXProject "ConductorMobile" */; + buildConfigurationList = A100000A /* Build configuration list for PBXProject "PiMobile" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -88,7 +88,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - A1000005 /* ConductorMobile */, + A1000005 /* PiMobile */, ); }; /* End PBXProject section */ @@ -159,11 +159,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = U5LM2CZXRN; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 10; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Connects to the Conductor companion server on your Mac."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Connects to the Pi companion server on your Mac."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -172,8 +172,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.matt-nz.conductormobile"; + MARKETING_VERSION = 0.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.matt-nz.pimobile"; + INFOPLIST_KEY_CFBundleDisplayName = "Pi Companion"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 1; @@ -187,11 +188,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = U5LM2CZXRN; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 10; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Connects to the Conductor companion server on your Mac."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Connects to the Pi companion server on your Mac."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -200,8 +201,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.matt-nz.conductormobile"; + MARKETING_VERSION = 0.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.matt-nz.pimobile"; + INFOPLIST_KEY_CFBundleDisplayName = "Pi Companion"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 1; @@ -211,7 +213,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - A1000006 /* Build configuration list for PBXNativeTarget "ConductorMobile" */ = { + A1000006 /* Build configuration list for PBXNativeTarget "PiMobile" */ = { isa = XCConfigurationList; buildConfigurations = ( A100000D /* Debug */, @@ -220,7 +222,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A100000A /* Build configuration list for PBXProject "ConductorMobile" */ = { + A100000A /* Build configuration list for PBXProject "PiMobile" */ = { isa = XCConfigurationList; buildConfigurations = ( A100000B /* Debug */, diff --git a/ConductorMobile/APIClient.swift b/PiMobile/APIClient.swift similarity index 88% rename from ConductorMobile/APIClient.swift rename to PiMobile/APIClient.swift index 7472bba..a98cf1d 100644 --- a/ConductorMobile/APIClient.swift +++ b/PiMobile/APIClient.swift @@ -38,7 +38,7 @@ struct MacServer: Codable, Identifiable, Hashable { enum TokenStore { private static func query(_ id: UUID) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "conductor-mobile.mac-token", + kSecAttrService as String: "pi-mobile.mac-token", kSecAttrAccount as String: id.uuidString] } @@ -177,6 +177,32 @@ final class APIClient { func stop(sessionId: String) async throws { _ = try await post("/sessions/\(sessionId)/stop") } + private func delete(_ path: String) async throws { + guard let mac = activeMac, let url = URL(string: mac.baseURL + path) else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("Bearer \(mac.token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw URLError(.badServerResponse) + } + } + + func deleteSession(_ id: String) async throws { try await delete("/sessions/\(id)") } + func deleteWorkspace(_ id: String) async throws { try await delete("/workspaces/\(id)") } + + func addProject(path: String) async throws { + _ = try await post("/projects", body: ["path": path]) + } + + func browse(path: String?) async throws -> FolderListing { + var p = "/browse" + if let path, let enc = path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { + p += "?path=\(enc)" + } + return try await get(p) + } + func createWorkspace(repoId: String) async throws -> Workspace { try decoder.decode(Workspace.self, from: try await post("/repos/\(repoId)/workspaces")) } diff --git a/ConductorMobile/Assets.xcassets/AppIcon.appiconset/1024.png b/PiMobile/Assets.xcassets/AppIcon.appiconset/1024.png similarity index 100% rename from ConductorMobile/Assets.xcassets/AppIcon.appiconset/1024.png rename to PiMobile/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/ConductorMobile/Assets.xcassets/AppIcon.appiconset/Contents.json b/PiMobile/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from ConductorMobile/Assets.xcassets/AppIcon.appiconset/Contents.json rename to PiMobile/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/ConductorMobile/Assets.xcassets/Contents.json b/PiMobile/Assets.xcassets/Contents.json similarity index 100% rename from ConductorMobile/Assets.xcassets/Contents.json rename to PiMobile/Assets.xcassets/Contents.json diff --git a/ConductorMobile/Models.swift b/PiMobile/Models.swift similarity index 71% rename from ConductorMobile/Models.swift rename to PiMobile/Models.swift index bedecfc..e7c085f 100644 --- a/ConductorMobile/Models.swift +++ b/PiMobile/Models.swift @@ -1,6 +1,6 @@ import Foundation -// Shapes mirror the companion server's JSON API, which mirrors conductor.db. +// Shapes mirror the companion server's JSON API over Pi's session files. struct Repo: Identifiable, Codable, Hashable { let id: String @@ -31,27 +31,23 @@ struct ChatSession: Identifiable, Codable, Hashable { let agentType: String? let updatedAt: Date - var isClaude: Bool { agentType == nil || agentType == "claude" } var modelLabel: String { prettyModel(model) } } -// One picker section, as Conductor model ids. Picking a model from a different -// harness switches the chat's agent, like the desktop. +// One picker section per Pi provider; ids are "provider/model" and pass +// straight through to `pi --model`. struct ModelGroup: Codable, Hashable { let title: String let models: [String] } -// The live groups come from the server's /models (scraped from the desktop -// app's bundle). This static list is only the fallback for older servers or -// before the fetch lands. +// The live groups come from the server's /models (Pi's own model catalog). +// This static list is only the fallback before the fetch lands. enum HarnessModels { static let fallback: [ModelGroup] = [ - ModelGroup(title: "Claude Code", models: ["fable-5", "opus-4-8-1m", "opus-4-7-1m", "opus-4-6-1m", - "sonnet-5-1m", "sonnet-4-6-1m", "sonnet-4-6", "haiku-4-5"]), - ModelGroup(title: "Codex", models: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4"]), - ModelGroup(title: "Cursor", models: ["auto", "composer-2.5", "grok-4.5"]), - ModelGroup(title: "OpenCode", models: ["opencode:openrouter/moonshotai/kimi-k2.7-code", "opencode:openrouter/z-ai/glm-5.2"]), + ModelGroup(title: "anthropic", models: ["anthropic/claude-fable-5", "anthropic/claude-opus-4-8", + "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4-5"]), + ModelGroup(title: "openai-codex", models: ["openai-codex/gpt-5.6-sol", "openai-codex/gpt-5.5", "openai-codex/gpt-5.4"]), ] } @@ -89,6 +85,12 @@ struct WorkspaceDiff: Codable { let diff: String } +struct FolderListing: Codable { + let path: String + let parent: String? + let dirs: [String] +} + struct AgentStatus: Codable { let running: Bool let activity: String diff --git a/ConductorMobile/ConductorMobileApp.swift b/PiMobile/PiMobileApp.swift similarity index 90% rename from ConductorMobile/ConductorMobileApp.swift rename to PiMobile/PiMobileApp.swift index d151234..49cb2b1 100644 --- a/ConductorMobile/ConductorMobileApp.swift +++ b/PiMobile/PiMobileApp.swift @@ -21,7 +21,7 @@ struct DebugChatPush: View { #endif @main -struct ConductorMobileApp: App { +struct PiMobileApp: App { @State private var api = APIClient() var body: some Scene { @@ -42,9 +42,9 @@ struct ConductorMobileApp: App { } .environment(api) .preferredColorScheme(.dark) - // conductor-companion://pair?name=…&addr=…&token=… (QR printed by the server) + // pi-companion://pair?name=…&addr=…&token=… (QR printed by the server) .onOpenURL { url in - guard url.scheme == "conductor-companion", url.host() == "pair", + guard url.scheme == "pi-companion", url.host() == "pair", let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, let addr = items.first(where: { $0.name == "addr" })?.value, let token = items.first(where: { $0.name == "token" })?.value else { return } diff --git a/ConductorMobile/Theme.swift b/PiMobile/Theme.swift similarity index 98% rename from ConductorMobile/Theme.swift rename to PiMobile/Theme.swift index c3f4678..3bd5242 100644 --- a/ConductorMobile/Theme.swift +++ b/PiMobile/Theme.swift @@ -1,6 +1,6 @@ import SwiftUI -// Design tokens from "Conductor Mobile.dc.html" (claude.ai/design). +// Design tokens from "Pi Mobile.dc.html" (claude.ai/design). enum Theme { static let bg = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B static let card = Color(red: 0.11, green: 0.11, blue: 0.125).opacity(0.6) diff --git a/ConductorMobile/Views/ChatView.swift b/PiMobile/Views/ChatView.swift similarity index 94% rename from ConductorMobile/Views/ChatView.swift rename to PiMobile/Views/ChatView.swift index e6501b0..2559f0c 100644 --- a/ConductorMobile/Views/ChatView.swift +++ b/PiMobile/Views/ChatView.swift @@ -12,7 +12,7 @@ struct ChatView: View { private var sessionId: String? { session?.id } @State private var running = false @State private var activity = "" - @State private var model: String? // Conductor model id; nil = session default + @State private var model: String? // Pi "provider/model" id; nil = session default var body: some View { VStack(spacing: 0) { @@ -90,6 +90,19 @@ struct ChatView: View { .padding(.horizontal, 12).padding(.vertical, 6) .background(s.id == sessionId ? Color.white.opacity(0.09) : Color.white.opacity(0.03), in: Capsule()) } + .contextMenu { + Button("Delete Chat", systemImage: "trash", role: .destructive) { + Task { + try? await api.deleteSession(s.id) + sessions.removeAll { $0.id == s.id } + if s.id == sessionId { + session = nil + messages = [] + if let next = sessions.first { select(next) } + } + } + } + } } Button { Task { @@ -158,8 +171,10 @@ struct ChatView: View { private var modelPill: some View { Menu { + // One submenu per provider — Menu drops Section titles on iOS 26, + // and providers like openrouter have hundreds of models. ForEach(api.modelGroups ?? HarnessModels.fallback, id: \.title) { group in - Section(group.title) { + Menu(group.title) { Picker(group.title, selection: $model) { ForEach(group.models, id: \.self) { m in Text(prettyModel(m)).tag(String?.some(m)) @@ -249,7 +264,7 @@ struct ChatView: View { } } -// Conductor embeds pasted files in message text as "@⟦name⟧(url-encoded-relative-path)". +// Desktop-pasted files may appear in message text as "@⟦name⟧(url-encoded-relative-path)". struct Attachment: Identifiable { let name: String let path: String diff --git a/ConductorMobile/Views/DiffView.swift b/PiMobile/Views/DiffView.swift similarity index 100% rename from ConductorMobile/Views/DiffView.swift rename to PiMobile/Views/DiffView.swift diff --git a/ConductorMobile/Views/ProjectsView.swift b/PiMobile/Views/ProjectsView.swift similarity index 57% rename from ConductorMobile/Views/ProjectsView.swift rename to PiMobile/Views/ProjectsView.swift index 2ad400a..3d9846a 100644 --- a/ConductorMobile/Views/ProjectsView.swift +++ b/PiMobile/Views/ProjectsView.swift @@ -5,6 +5,7 @@ struct ProjectsView: View { @State private var repos: [Repo] = [] @State private var onlineMacs: Set = [] @State private var showSettings = false + @State private var showAddProject = false private var connected: Bool { !onlineMacs.isEmpty } @@ -14,6 +15,12 @@ struct ProjectsView: View { header ScrollView { VStack(alignment: .leading, spacing: 18) { + if repos.isEmpty && connected { + Text("No projects yet. Tap + to pick a folder on your Mac to run Pi in.") + .font(.system(size: 13)) + .foregroundStyle(Theme.textMuted) + .padding(.horizontal, 6) + } repoCard Text("Workspaces run on your Mac. This phone is a remote — closing the app won't stop your agents.") .font(.system(size: 12)) @@ -29,6 +36,11 @@ struct ProjectsView: View { .background(Theme.bg) .navigationDestination(for: Repo.self) { WorkspacesView(repo: $0) } .sheet(isPresented: $showSettings) { SettingsView() } + .sheet(isPresented: $showAddProject) { + FolderPickerView { path in + Task { try? await api.addProject(path: path); await load() } + } + } .task { await load() } } .tint(Theme.accent) @@ -37,10 +49,10 @@ struct ProjectsView: View { private var header: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 0) { - Text("Conductor") - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(Theme.text) + VStack(alignment: .leading, spacing: 5) { + PiLogo() + .fill(Theme.text, style: FillStyle(eoFill: true)) + .frame(width: 30, height: 30) Text("Companion") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Theme.accent) @@ -48,6 +60,14 @@ struct ProjectsView: View { .textCase(.uppercase) } Spacer() + Button { showAddProject = true } label: { + Image(systemName: "plus") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + .padding(8) + .background(Color(red: 0.47, green: 0.47, blue: 0.5).opacity(0.16), in: Circle()) + .overlay(Circle().strokeBorder(Color.white.opacity(0.13), lineWidth: 0.5)) + } Button { showSettings = true } label: { HStack(spacing: 7) { Circle() @@ -142,3 +162,76 @@ struct ProjectsView: View { onlineMacs = online } } + +// The Pi mark from pi.dev/press-kit (logo.svg), drawn natively — it's all +// rectangles on an 800×800 grid, so no asset needed. +struct PiLogo: Shape { + func path(in rect: CGRect) -> Path { + // The svg's marks span 165.29…634.72 on its 800 canvas; normalize to the + // frame so the glyph's left edge sits flush with whatever it aligns to. + let s = min(rect.width, rect.height) / 469.43 + func pt(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: rect.minX + (x - 165.29) * s, y: rect.minY + (y - 165.29) * s) } + var p = Path() + // P: outer boundary… + p.move(to: pt(165.29, 165.29)) + p.addLine(to: pt(517.36, 165.29)) + p.addLine(to: pt(517.36, 400)) + p.addLine(to: pt(400, 400)) + p.addLine(to: pt(400, 517.36)) + p.addLine(to: pt(282.65, 517.36)) + p.addLine(to: pt(282.65, 634.72)) + p.addLine(to: pt(165.29, 634.72)) + p.closeSubpath() + // …with its counter hole (even-odd fill) + p.addRect(CGRect(origin: pt(282.65, 282.65), size: CGSize(width: 117.35 * s, height: 117.35 * s))) + // i dot + p.addRect(CGRect(origin: pt(517.36, 400), size: CGSize(width: 117.36 * s, height: 234.72 * s))) + return p + } +} + +// Browse the Mac's folders (via the server's /browse) and pick a project root. +struct FolderPickerView: View { + @Environment(APIClient.self) private var api + @Environment(\.dismiss) private var dismiss + let onPick: (String) -> Void + + @State private var listing: FolderListing? + + var body: some View { + NavigationStack { + List { + if let parent = listing?.parent { + Button { Task { await open(parent) } } label: { + Label("..", systemImage: "arrow.turn.left.up") + .foregroundStyle(Theme.textSecondary) + } + } + ForEach(listing?.dirs ?? [], id: \.self) { dir in + Button { Task { await open("\(listing?.path ?? "")/\(dir)") } } label: { + Label(dir, systemImage: "folder") + .foregroundStyle(Theme.text) + } + } + } + .listStyle(.plain) + .navigationTitle(listing.map { ($0.path as NSString).lastPathComponent } ?? "Browse") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Add This Folder") { + if let path = listing?.path { onPick(path); dismiss() } + } + .disabled(listing == nil) + } + } + .task { await open(nil) } + } + .tint(Theme.accent) + } + + private func open(_ path: String?) async { + if let l = try? await api.browse(path: path) { listing = l } + } +} diff --git a/ConductorMobile/Views/SettingsView.swift b/PiMobile/Views/SettingsView.swift similarity index 91% rename from ConductorMobile/Views/SettingsView.swift rename to PiMobile/Views/SettingsView.swift index 519807e..009c548 100644 --- a/ConductorMobile/Views/SettingsView.swift +++ b/PiMobile/Views/SettingsView.swift @@ -41,7 +41,7 @@ struct SettingsView: View { Section("Set up a Mac") { VStack(alignment: .leading, spacing: 12) { - setupStep(1, "Install [Conductor](https://www.conductor.build) and [Bun](https://bun.sh) on your Mac, and run at least one agent session in it.") + setupStep(1, "Install [Pi](https://pi.dev) and [Bun](https://bun.sh) on your Mac, and run at least one `pi` session in a project folder.") setupStep(2, "In Terminal on the Mac, run:") HStack { Text(Self.installCommand) @@ -72,10 +72,11 @@ struct SettingsView: View { } Section { - Link("Conductor Mobile on GitHub", destination: URL(string: "https://github.com/MRL-00/conductor-mobile")!) + Link("Pi Mobile on GitHub", destination: URL(string: "https://github.com/MRL-00/pi-mobile")!) .foregroundStyle(.secondary) DisclosureGroup("Changelog") { + changelogEntry("0.2.0 (10)", "The app is now Pi Companion: works with the Pi coding agent (pi.dev) — browse Pi sessions, send messages, switch between 15+ providers' models, add projects with a folder browser, and delete chats and workspaces.") changelogEntry("0.1.0 (8)", "Model choices now come from Conductor on your Mac, including your configured OpenCode models.") changelogEntry("0.1.0 (7)", "Moved the new-workspace button into the navigation bar.") changelogEntry("0.1.0 (6)", "Added workspace creation from your iPhone.") @@ -92,7 +93,7 @@ struct SettingsView: View { .tint(Theme.accent) } - static let installCommand = "curl -fsSL https://raw.githubusercontent.com/MRL-00/conductor-mobile/main/server/install.sh | bash" + static let installCommand = "curl -fsSL https://raw.githubusercontent.com/MRL-00/pi-mobile/main/server/install.sh | bash" private func setupStep(_ n: Int, _ text: LocalizedStringKey) -> some View { HStack(alignment: .top, spacing: 10) { @@ -146,7 +147,7 @@ struct MacEditView: View { .autocorrectionDisabled() } Section { - Text("The token is printed when the companion server starts, and stored in ~/.conductor-companion/token on that Mac.") + Text("The token is printed when the companion server starts, and stored in ~/.pi-companion/token on that Mac.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/ConductorMobile/Views/WorkspacesView.swift b/PiMobile/Views/WorkspacesView.swift similarity index 89% rename from ConductorMobile/Views/WorkspacesView.swift rename to PiMobile/Views/WorkspacesView.swift index 43fdff2..f680a0f 100644 --- a/ConductorMobile/Views/WorkspacesView.swift +++ b/PiMobile/Views/WorkspacesView.swift @@ -18,6 +18,14 @@ struct WorkspacesView: View { LazyVStack(spacing: 0) { ForEach(workspaces) { ws in NavigationLink(value: ws) { WorkspaceRow(ws: ws) } + .contextMenu { + Button("Remove from app", systemImage: "trash", role: .destructive) { + Task { + try? await api.deleteWorkspace(ws.id) + workspaces.removeAll { $0.id == ws.id } + } + } + } Divider().overlay(Color.white.opacity(0.05)) } } @@ -73,7 +81,7 @@ struct WorkspacesView: View { Text("No active workspaces") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Color(red: 0.63, green: 0.63, blue: 0.67)) - Text("Start an agent from Conductor on your Mac and it will show up here.") + Text("Run `pi` in a project folder on your Mac and it will show up here.") .font(.system(size: 13)) .foregroundStyle(Theme.textMuted) .multilineTextAlignment(.center) diff --git a/README.md b/README.md index 986d677..f2c6c7f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,23 @@

- Conductor Mobile app icon + Pi Mobile app icon

-# Conductor Mobile +# Pi Mobile -An unofficial iOS companion app for [Conductor](https://www.conductor.build/) — browse your projects, workspaces, and chat history from your phone, and keep talking to your agents while away from your Mac. +An unofficial iOS companion app for the [Pi coding agent](https://pi.dev) — browse your projects and sessions, read full chat history, and keep talking to your agents while away from your Mac. -Conductor doesn't have a mobile app, so this one works by pairing a small companion server on your Mac (which reads Conductor's local SQLite database and drives the agent CLIs directly) with a native SwiftUI app on the phone. +Pi is a terminal agent, so this app pairs a small companion server on your Mac (which reads Pi's session files and drives Pi over its RPC protocol) with a native SwiftUI app on the phone. Everything it touches is documented Pi surface — the session JSONL format and the RPC mode — no undocumented internals. -

Start a task on your laptop → go for a walk → keep directing the agent from your phone → come back and continue on the desktop.

- -## Screenshots - -

- Browse your Conductor projects and workspaces - Chat with an agent from your phone - Watch agent work arrive in real time -

- -

- Review workspace diffs on your phone - Switch between connected Macs -

+

Start a task in your terminal → go for a walk → keep directing the agent from your phone → come back and `pi -c` to continue on the desktop.

## Features -- **Projects & workspaces** — all your Conductor repos and worktrees, with live status badges, branches, and unread indicators -- **Full chat history** — messages, thinking blocks, tool calls, durations, inline images -- **Send messages** — continue any session from your phone; replies stream in with a live activity line and stop button -- **All harnesses** — Claude Code, Codex, Cursor (Grok/Composer), and OpenCode sessions are all supported -- **Desktop sync** — turns sent from the phone are written into Conductor's database in its native format, so they appear in the desktop app (after a Conductor restart) and the conversation never forks -- **Model picker** — override the model per-send on Claude Code sessions (including 1M-context variants) +- **Projects & workspaces** — every folder you've run `pi` in, grouped by git repo (worktrees appear under their main checkout), with branches and live status badges +- **Full chat history** — messages, thinking blocks, tool calls, inline images +- **Send messages** — continue any session from your phone; because the phone and the terminal share the same session file, `pi -c` on the Mac picks up exactly where you left off — no restarts, no forked conversations +- **New sessions & workspaces** — start a fresh session, or spin up a new git worktree, from the phone +- **Model picker** — Pi's full model catalog (15+ providers), switchable per-send, mid-session +- **Diffs** — review each workspace's uncommitted/branch changes on your phone ## How it works @@ -39,45 +26,39 @@ iPhone app (SwiftUI) │ HTTP + bearer token (use Tailscale to reach your Mac from anywhere) ▼ Companion server on the Mac (Bun, single file) - ├─ READ: Conductor's SQLite db → repos, workspaces, sessions, messages - ├─ WRITE: phone-sent turns → same db, Conductor's native envelope format - └─ RUN: claude / codex / cursor-agent / opencode CLIs, resuming the - session in the workspace's worktree + ├─ READ: ~/.pi/agent/sessions/*.jsonl → projects, sessions, messages + └─ RUN: pi --mode rpc, resuming the session file in its project folder + (pi appends the new turns to the same file itself) ``` ## Setup ### Mac (companion server) -Requires [Bun](https://bun.sh) and Conductor. +Requires [Bun](https://bun.sh) and [Pi](https://pi.dev). ```sh -curl -fsSL https://raw.githubusercontent.com/MRL-00/conductor-mobile/main/server/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/MRL-00/pi-mobile/main/server/install.sh | bash ``` (Or from a checkout: `./server/install.sh`.) -This installs a login LaunchAgent that keeps the server running (and the Mac awake via `caffeinate -s`), auto-restarts it, and prints the auth token for the phone app. Token persists in `~/.conductor-companion/token`; logs in `~/.conductor-companion/server.log`. Uninstall with `./server/install.sh --uninstall`. (Or just run it manually: `cd server && bun run server.ts`.) +This installs a login LaunchAgent that keeps the server running (and the Mac awake via `caffeinate -s`), auto-restarts it, and prints the auth token for the phone app. Token persists in `~/.pi-companion/token`; logs in `~/.pi-companion/server.log`. Uninstall with `./server/install.sh --uninstall`. (Or just run it manually: `cd server && bun run server.ts`.) ### iPhone -Open `ConductorMobile.xcodeproj` in Xcode (26+), build to your device (iOS 26+). In the app's Settings, enter: +Open `PiMobile.xcodeproj` in Xcode (26+), build to your device (iOS 26+). Then either: -- Or scan the QR code the server prints with the iPhone camera — it fills in the address and token automatically. Manual entry still works: +- Scan the QR code the server prints with the iPhone camera — it fills in the address and token automatically. Or manually, in the app's Settings: - **Server address** — `http://:8940` (a [Tailscale](https://tailscale.com) hostname makes this work from anywhere) - **Auth token** — the token the server printed ## Caveats -- Conductor's database schema is **undocumented and unofficial** — a Conductor update may change it and break things (loudly, not silently). Back up `~/Library/Application Support/com.conductor.app/conductor.db` if you're cautious. -- The desktop app shows phone-sent turns after a restart (it reads the db on launch). -- Claude Code sessions run with `--permission-mode acceptEdits`; riskier actions are auto-denied rather than prompted. Cursor sessions run with `--trust`. -- Non-Claude harness adapters are built against current CLI output formats (codex 0.144, cursor-agent, opencode) and may need tweaks as those CLIs evolve. +- Turns started from the phone run with Pi's default tool permissions — there's no remote approval UI yet. +- A session that's open in a terminal `pi` and driven from the phone at the same time can race; finish one before the other (Pi's file format keeps history safe either way). +- Session titles are derived from the first user message (Pi sessions have no title field). ## Security -The server exposes your chat history and can run agents in your repos. It requires a bearer token on every request, serves attachments only from within workspace directories, and should only be reachable over a private network (LAN or Tailscale). Don't port-forward it to the open internet. - -## Status - -Personal project, built for our own workflow. PRs and issues welcome, but no promises. +The server exposes your chat history and can run agents in your repos. It requires a bearer token on every request, serves attachments only from within project directories, and should only be reachable over a private network (LAN or Tailscale). Don't port-forward it to the open internet. diff --git a/docs/images/conductor-mobile-icon.png b/docs/images/pi-mobile-icon.png similarity index 100% rename from docs/images/conductor-mobile-icon.png rename to docs/images/pi-mobile-icon.png diff --git a/server/install.sh b/server/install.sh index 8dbf2d9..c6cfbb6 100755 --- a/server/install.sh +++ b/server/install.sh @@ -1,12 +1,12 @@ #!/bin/bash -# Install the Conductor companion server as a login LaunchAgent (auto-restart, survives reboots). +# Install the Pi companion server as a login LaunchAgent (auto-restart, survives reboots). # Usage: ./install.sh Uninstall: ./install.sh --uninstall set -euo pipefail -LABEL="co.bungy.conductor-companion" +LABEL="co.bungy.pi-companion" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" DIR="$(cd "$(dirname "$0")" && pwd)" -LOG_DIR="$HOME/.conductor-companion" +LOG_DIR="$HOME/.pi-companion" if [[ "${1:-}" == "--uninstall" ]]; then launchctl bootout "gui/$(id -u)" "$PLIST" 2>/dev/null || true @@ -24,7 +24,7 @@ mkdir -p "$LOG_DIR" "$HOME/Library/LaunchAgents" if [[ ! -f "$DIR/server.ts" ]]; then DIR="$LOG_DIR" echo "Downloading server.ts…" - curl -fsSL "https://raw.githubusercontent.com/MRL-00/conductor-mobile/main/server/server.ts" -o "$DIR/server.ts" + curl -fsSL "https://raw.githubusercontent.com/MRL-00/pi-mobile/main/server/server.ts" -o "$DIR/server.ts" fi # caffeinate -s keeps the Mac awake (on AC power) so agents can run while you're away diff --git a/server/server.ts b/server/server.ts index 8b318ec..dc8b05f 100644 --- a/server/server.ts +++ b/server/server.ts @@ -1,15 +1,16 @@ -// Conductor companion server: read-only JSON API over Conductor's local SQLite db. +// Pi companion server: JSON API over Pi's session files (~/.pi/agent/sessions) +// plus turn-running via `pi --mode rpc`. Everything it touches is documented +// Pi surface (session JSONL v3, RPC protocol) — no undocumented internals. // Run: bun run server.ts (prints the auth token to give the phone app) -import { Database } from "bun:sqlite"; import { homedir } from "os"; -import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs"; -import { resolve } from "path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync } from "fs"; +import { resolve, basename, dirname } from "path"; -const DB_PATH = `${homedir()}/Library/Application Support/com.conductor.app/conductor.db`; -const PORT = 8940; +const SESSIONS_ROOT = `${homedir()}/.pi/agent/sessions`; +const PORT = Number(process.env.PORT ?? 8940); // Persistent bearer token — server can expose chat history, so auth is required. -const tokenDir = `${homedir()}/.conductor-companion`; +const tokenDir = `${homedir()}/.pi-companion`; const tokenPath = `${tokenDir}/token`; if (!existsSync(tokenPath)) { mkdirSync(tokenDir, { recursive: true }); @@ -17,340 +18,319 @@ if (!existsSync(tokenPath)) { } const TOKEN = readFileSync(tokenPath, "utf8").trim(); -const db = new Database(DB_PATH, { readonly: true }); - -// ── v2: sending messages ──────────────────────────────────────────────────── -// Turns sent from the phone resume the workspace's Claude Code session and are -// written into Conductor's own session_messages in its native envelope format, -// so the desktop app shows them (after a Conductor restart — it reads the db on -// launch) and both apps stay on one conversation branch. Undocumented schema: -// if a Conductor update changes it, these writes fail loud, not silent. -const wdb = new Database(DB_PATH); // separate read-write connection; WAL handles concurrency -const insertMsg = wdb.prepare( - `INSERT INTO session_messages (id, session_id, role, content, created_at, sent_at, model, turn_id, queue_order, sender_id, sdk_message_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)` -); -const updateClaudeSessionId = wdb.prepare(`UPDATE sessions SET claude_session_id = ? WHERE id = ?`); -const updateSessionModel = wdb.prepare(`UPDATE sessions SET model = ? WHERE id = ?`); - -// ── Model picker groups ───────────────────────────────────────────────────── -// The phone's picker fetches GET /models instead of hardcoding ids. The list is -// scraped from the desktop app's own bundle (string-scanning its JS for model -// ids), so new models reach the phone as soon as Conductor updates — no app -// release needed. Best-effort over undocumented internals: any harness that -// yields nothing plausible falls back to the static list below. -const FALLBACK_GROUPS = [ - { title: "Claude Code", models: ["fable-5", "opus-4-8-1m", "opus-4-7-1m", "opus-4-6-1m", - "sonnet-5-1m", "sonnet-4-6-1m", "sonnet-4-6", "haiku-4-5"] }, - { title: "Codex", models: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4"] }, - { title: "Cursor", models: ["auto", "composer-2.5", "grok-4.5"] }, - { title: "OpenCode", models: ["opencode:openrouter/moonshotai/kimi-k2.7-code", "opencode:openrouter/z-ai/glm-5.2"] }, -]; - -const APP_BUNDLES = ["/Applications/Conductor.app", `${homedir()}/Applications/Conductor.app`]; -// Conductor picker ids as string literals in the bundle. A leading "claude-" -// means it's a CLI id ("claude-opus-4-8[1m]"), not a picker id — skipped. -const MODEL_RE = /(claude-)?\b(?:(?:fable|opus|sonnet|haiku|composer|grok|gpt)-\d(?:[\w.-]*\w)?|opencode:[\w./-]+)/g; - -const groupForModel = (id: string) => - id.startsWith("gpt-") ? "Codex" - : id.startsWith("opencode:") ? "OpenCode" - : id.startsWith("composer") || id.startsWith("grok") ? "Cursor" - : "Claude Code"; - -// The bundle mentions model-ish strings all over (deps, changelogs); the file -// that defines a harness's picker is the one mentioning ids from the most -// harnesses. Per group, keep the ids from the best-scoring file that has any -// for that group (harness definitions may be split across bundle chunks), in -// the order they appear there (≈ picker order). -function scanBundleModels(): Map | null { - const files: string[] = []; - for (const app of APP_BUNDLES) { - for (const dir of [`${app}/Contents/Resources`, `${app}/Contents/MacOS`]) { +// Extra project cwds with no Pi sessions yet (e.g. fresh worktrees made from +// the phone). Pi discovers projects implicitly by running in a folder; this +// file covers the ones the phone created before their first session. +const projectsPath = `${tokenDir}/projects.json`; +// Entries are {path, added} (older versions stored bare path strings). +const projectEntries = (): { path: string; added: number }[] => { + try { + return JSON.parse(readFileSync(projectsPath, "utf8")).map((e: any) => + typeof e === "string" ? { path: e, added: 0 } : e); + } catch { return []; } +}; +const extraCwds = () => projectEntries().map((e) => e.path); +const addedAt = (cwd: string) => projectEntries().find((e) => e.path === cwd)?.added ?? 0; +const rememberCwd = (cwd: string) => { + const list = projectEntries(); + if (!list.some((e) => e.path === cwd)) + writeFileSync(projectsPath, JSON.stringify([...list, { path: cwd, added: Date.now() }], null, 2)); +}; +const forgetCwd = (cwd: string) => { + writeFileSync(projectsPath, JSON.stringify(projectEntries().filter((e) => e.path !== cwd), null, 2)); +}; + +const git = (cwd: string, ...a: string[]) => { + const p = Bun.spawnSync(["git", ...a], { cwd, stdout: "pipe", stderr: "pipe" }); + return p.exitCode === 0 ? p.stdout.toString().trim() : null; +}; + +// ── Project/workspace scan ────────────────────────────────────────────────── +// Pi groups sessions by cwd: one directory per project path under +// SESSIONS_ROOT, named "----" (lossy — real cwd comes from the +// header line inside each session file). We map onto the phone's existing +// three-level navigation: repo = the git repo's main checkout, workspace = a +// cwd inside that repo (main checkout or any worktree), session = a jsonl file. +// +// Projects are opt-in: only folders the user added from the phone (plus +// worktrees created from the phone) appear — Pi's session dir also collects +// runs from other tools built on Pi (emdash etc.) and scratch folders, which +// would drown the list. Sessions for an added folder are picked up automatically. +const encodeCwd = (cwd: string) => `-${cwd.replaceAll("/", "-")}--`; + +type Ws = { cwd: string; dir: string | null; mtime: number; sessionCount: number }; +type Scan = { workspaces: Map; repoOf: Map; repoRoots: Map }; + +function scan(): Scan { + const workspaces = new Map(); // workspace id (encoded cwd) → info + const repoOf = new Map(); // workspace id → repo id + const repoRoots = new Map(); // repo id → root path + const cwds = new Map(); + + const wanted = new Set(extraCwds().filter((c) => existsSync(c))); + let dirs: string[] = []; + try { dirs = readdirSync(SESSIONS_ROOT); } catch {} + for (const dir of dirs) { + const full = `${SESSIONS_ROOT}/${dir}`; + let files: string[]; + try { files = readdirSync(full).filter((f) => f.endsWith(".jsonl")); } catch { continue; } + if (!files.length) continue; + files.sort(); + const newest = files[files.length - 1]; + let cwd: string; + try { + const header = JSON.parse(readFileSync(`${full}/${newest}`, "utf8").split("\n", 1)[0]); + cwd = header.cwd; + } catch { continue; } + if (!wanted.has(cwd)) continue; // not a folder the user added + cwds.set(cwd, { dir: full, mtime: statSync(`${full}/${newest}`).mtimeMs, sessionCount: files.length }); + } + // No sessions yet → "last activity" is the moment the user added the folder. + for (const cwd of wanted) + if (!cwds.has(cwd)) cwds.set(cwd, { dir: null, mtime: addedAt(cwd) || Date.now(), sessionCount: 0 }); + + for (const [cwd, info] of cwds) { + // Group worktrees with their main checkout: --git-common-dir points at the + // primary .git for every worktree of a repo. Non-git folders stand alone. + const common = git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"); + const root = common ? dirname(common) : cwd; + const wsId = encodeCwd(cwd); + const repoId = encodeCwd(root); + workspaces.set(wsId, { cwd, ...info }); + repoOf.set(wsId, repoId); + if (!repoRoots.has(repoId)) repoRoots.set(repoId, root); + } + return { workspaces, repoOf, repoRoots }; +} + +// Scan shells out to git per project, so cache briefly; phone navigation +// re-fetches often. ponytail: 15s TTL, event-driven invalidation if it lags. +let scanCache: { at: number; data: Scan } | null = null; +const scanned = () => { + if (!scanCache || Date.now() - scanCache.at > 15_000) scanCache = { at: Date.now(), data: scan() }; + return scanCache.data; +}; +const wsById = (id: string) => scanned().workspaces.get(id); + +// ── Session files ─────────────────────────────────────────────────────────── +type Entry = any; // Pi session JSONL v3 entries +const readEntries = (file: string): Entry[] => + readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => { try { return JSON.parse(l); } catch { return null; } }) + .filter(Boolean); + +// Sessions form a tree via id/parentId; the live conversation is the path +// from the last-written entry back to the root. +function activeBranch(entries: Entry[]): Entry[] { + const byId = new Map(entries.filter((e) => e.id).map((e) => [e.id, e])); + const branch: Entry[] = []; + let cur = entries[entries.length - 1]; + while (cur) { + branch.push(cur); + cur = cur.parentId ? byId.get(cur.parentId) : null; + } + return branch.reverse(); +} + +const sessionFiles = (dir: string) => + readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); // newest first (timestamp prefix) + +const sessionIdOf = (file: string) => basename(file).replace(".jsonl", "").split("_")[1] ?? basename(file); + +// Find a session file by uuid across all project dirs. +function findSessionFile(sessionId: string): { file: string; cwd: string } | null { + let dirs: string[] = []; + try { dirs = readdirSync(SESSIONS_ROOT); } catch {} + for (const dir of dirs) { + const full = `${SESSIONS_ROOT}/${dir}`; + let files: string[] = []; + try { files = readdirSync(full); } catch { continue; } + const hit = files.find((f) => f.endsWith(`_${sessionId}.jsonl`)); + if (hit) { try { - for (const name of readdirSync(dir, { recursive: true }) as string[]) { - if (!/\.(js|mjs|cjs|asar|html|json)$/.test(name) && name.includes(".")) continue; - const full = `${dir}/${name}`; - try { - const st = statSync(full); - if (st.isFile() && st.size < 256 * 1024 * 1024) files.push(full); - } catch {} - } + const header = JSON.parse(readFileSync(`${full}/${hit}`, "utf8").split("\n", 1)[0]); + return { file: `${full}/${hit}`, cwd: header.cwd }; } catch {} } } - const best = new Map(); - const bestScore = new Map(); - for (const file of files) { - let text: string; - try { text = readFileSync(file, "latin1"); } catch { continue; } - const byGroup = new Map(); - for (const m of text.matchAll(MODEL_RE)) { - if (m[1]) continue; - const list = byGroup.get(groupForModel(m[0])) ?? []; - if (!list.includes(m[0])) list.push(m[0]); - byGroup.set(groupForModel(m[0]), list); - } - const ids = [...byGroup.values()].reduce((n, l) => n + l.length, 0); - const score = byGroup.size * 100 + ids; // harness coverage first, then id count - for (const [group, list] of byGroup) { - if (score > (bestScore.get(group) ?? 0)) { bestScore.set(group, score); best.set(group, list); } + return null; +} + +const textOf = (msg: any) => + (msg?.content ?? []) + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join("") + .trim(); + +function sessionSummary(file: string, workspaceId: string) { + const entries = readEntries(file); + let title = "Untitled"; + let model: string | null = null; + for (const e of entries) { + if (title === "Untitled" && e.type === "message" && e.message?.role === "user") { + const t = textOf(e.message); + if (t) title = t.slice(0, 80); } + if (e.type === "model_change") model = `${e.provider}/${e.modelId}`; } - return best.size ? best : null; + return { + id: sessionIdOf(file), + workspace_id: workspaceId, + title, + model, + agent_type: "pi", + updated_at: new Date(statSync(file).mtimeMs).toISOString(), + }; } -// User-configured OpenCode models: providers declared in ~/.config/opencode/ -// opencode.json plus any opencode:* ids already used in past Conductor sessions -// (covers dynamic providers like openrouter whose models aren't in the config). -function opencodeModels(): string[] { - const ids: string[] = []; - try { - const cfg = JSON.parse(readFileSync(`${homedir()}/.config/opencode/opencode.json`, "utf8")); - for (const [prov, p] of Object.entries(cfg.provider ?? {})) - for (const model of Object.keys(p?.models ?? {})) ids.push(`opencode:${prov}/${model}`); - } catch {} - try { - for (const r of db.prepare(`SELECT DISTINCT model FROM sessions WHERE model LIKE 'opencode:%'`).all() as { model: string }[]) - if (!ids.includes(r.model)) ids.push(r.model); - } catch {} - return ids; +// ── Display messages ──────────────────────────────────────────────────────── +// Same row shapes the phone already renders: user / assistant / thinking / +// tool / duration rows in chronological order. +const summarizeInput = (input: any) => { + const v = input?.file_path ?? input?.path ?? input?.command ?? input?.pattern ?? input?.description ?? ""; + return String(v).slice(0, 80); +}; + +function displayMessages(sessionId: string) { + const found = findSessionFile(sessionId); + if (!found) return { error: "session not found", status: 404 }; + const out: { id: string; role: string; content: string; created_at: string }[] = []; + let turnStart: number | null = null; + for (const e of activeBranch(readEntries(found.file))) { + if (e.type !== "message") continue; + const msg = e.message; + const createdAt = e.timestamp; + if (msg.role === "user") { + turnStart = new Date(e.timestamp).getTime(); + const t = textOf(msg); + if (t) out.push({ id: e.id, role: "user", content: t, created_at: createdAt }); + } else if (msg.role === "assistant") { + for (const [i, b] of (msg.content ?? []).entries()) { + if (b.type === "text" && b.text?.trim()) + out.push({ id: `${e.id}-${i}`, role: "assistant", content: b.text, created_at: createdAt }); + else if (b.type === "thinking" && b.thinking?.trim()) + out.push({ id: `${e.id}-${i}`, role: "thinking", content: b.thinking, created_at: createdAt }); + else if (b.type === "toolCall") + out.push({ id: `${e.id}-${i}`, role: "tool", content: `${b.name} ${summarizeInput(b.arguments)}`, created_at: createdAt }); + } + if (msg.stopReason === "error" && msg.errorMessage) + out.push({ id: `${e.id}-err`, role: "assistant", content: `⚠️ ${String(msg.errorMessage).slice(0, 300)}`, created_at: createdAt }); + if (msg.stopReason === "stop" && turnStart) { + const s = Math.round((new Date(e.timestamp).getTime() - turnStart) / 1000); + if (s >= 5) out.push({ id: `${e.id}-dur`, role: "duration", content: s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`, created_at: createdAt }); + turnStart = null; + } + } + // toolResult rows are skipped — the tool row already shows the call. + } + return out; } -// The bundle scan reads big files synchronously, so it runs at startup and on -// a 10-minute timer — never on the request path. /models only assembles from -// the cached scan (opencodeModels is a cheap config read + indexed query). -let scannedGroups: Map | null = null; -const refreshScan = () => { try { scannedGroups = scanBundleModels(); } catch {} }; -refreshScan(); -setInterval(refreshScan, 10 * 60_000); - -function modelGroups() { - return FALLBACK_GROUPS.map(({ title, models }) => { - let found = title === "OpenCode" ? opencodeModels() : scannedGroups?.get(title) ?? []; - // "auto" is a real Cursor picker entry but too generic a string to scan for. - if (title === "Cursor" && found.length && !found.includes("auto")) found = ["auto", ...found]; - return { title, models: found.length ? found : models }; - }); +// ── Models ────────────────────────────────────────────────────────────────── +// Pi's model catalog is first-class: `pi --list-models` prints provider/model +// rows. Ids are "provider/model" and pass straight to `pi --model`. +let modelCache: { title: string; models: string[] }[] = []; +function refreshModels() { + const p = Bun.spawnSync(["pi", "--list-models"], { stdout: "pipe" }); + if (p.exitCode !== 0) return; + const groups = new Map(); + for (const line of p.stdout.toString().split("\n").slice(1)) { + const m = line.match(/^(\S+)\s+(\S+)/); + if (!m) continue; + const list = groups.get(m[1]) ?? []; + list.push(`${m[1]}/${m[2]}`); + groups.set(m[1], list); + } + if (groups.size) modelCache = [...groups].map(([title, models]) => ({ title, models })); } +refreshModels(); +setInterval(refreshModels, 10 * 60_000); -// Conductor model id ("opus-4-8-1m") → claude CLI id ("claude-opus-4-8[1m]") -const claudeCliModel = (m: string) => - `claude-${m.replace(/-1m$/, "")}` + (m.endsWith("-1m") ? "[1m]" : ""); +// ── Creating workspaces & sessions ────────────────────────────────────────── +// A session id minted here materializes on disk on first send (pi --session-id +// creates it if missing). Until then remember which cwd it belongs to. +const pendingSessions = new Map(); // session id → cwd (ponytail: in-memory; re-create from the phone if the server restarts) function createSession(workspaceId: string) { - const ws = db.query(`SELECT id FROM workspaces WHERE id = ?`).get(workspaceId); + const ws = wsById(workspaceId); if (!ws) return { error: "workspace not found", status: 404 }; - const lastModel: any = db - .query(`SELECT model FROM sessions WHERE agent_type IS NULL OR agent_type = 'claude' ORDER BY updated_at DESC LIMIT 1`) - .get(); const id = crypto.randomUUID(); - wdb.prepare( - `INSERT INTO sessions (id, workspace_id, agent_type, model, title, status) VALUES (?, ?, 'claude', ?, 'Untitled', 'idle')` - ).run(id, workspaceId, lastModel?.model ?? "opus-4-8-1m"); - return { id, workspace_id: workspaceId, title: "Untitled", model: lastModel?.model ?? "opus-4-8-1m", - agent_type: "claude", updated_at: new Date().toISOString() }; + pendingSessions.set(id, ws.cwd); + return { id, workspace_id: workspaceId, title: "Untitled", model: null, agent_type: "pi", + updated_at: new Date().toISOString() }; } -// New workspace from the phone: a real git worktree in Conductor's own layout -// (~/conductor/workspaces//) plus a workspaces row, so the desktop -// app picks it up like any other. Undocumented schema — fails loud if it drifts. const CITIES = ["lisbon","porto","quito","nairobi","hanoi","tbilisi","perth","leipzig","malmo","bergen","cusco","davao","hobart","tampere","galway","split"]; function createWorkspace(repoId: string) { - const repo: any = db.query(`SELECT id, name, root_path, default_branch FROM repos WHERE id = ?`).get(repoId); - if (!repo?.root_path || !existsSync(repo.root_path)) return { error: "repo not found", status: 404 }; - const taken = new Set( - db.query(`SELECT directory_name FROM workspaces WHERE repository_id = ?`).all(repoId).map((w: any) => w.directory_name) - ); - const city = CITIES.find((c) => !taken.has(c)) ?? `mobile-${Date.now()}`; - const path = `${homedir()}/conductor/workspaces/${repo.name}/${city}`; + const root = scanned().repoRoots.get(repoId); + if (!root || !existsSync(root)) return { error: "repo not found", status: 404 }; + if (!git(root, "rev-parse", "--git-dir")) return { error: "not a git repo", status: 400 }; + const name = basename(root); + const city = CITIES.find((c) => !existsSync(`${homedir()}/pi-workspaces/${name}/${c}`)) ?? `mobile-${Date.now()}`; + const path = `${homedir()}/pi-workspaces/${name}/${city}`; const branch = `mobile/${city}`; - const git = Bun.spawnSync(["git", "worktree", "add", "-b", branch, path], { cwd: repo.root_path }); - if (git.exitCode !== 0) - return { error: `git worktree failed: ${git.stderr.toString().trim()}`, status: 500 }; - const id = crypto.randomUUID(); - wdb.prepare( - `INSERT INTO workspaces (id, repository_id, directory_name, workspace_name, branch, workspace_path, - initialization_parent_branch, state, derived_status) - VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'in-progress')` - ).run(id, repoId, city, city, branch, path, repo.default_branch ?? "main"); - const session = createSession(id); - return { id, repository_id: repoId, name: city, branch, status: "in-progress", unread: false, - updated_at: new Date().toISOString(), last_message_snippet: null, session }; + const wt = Bun.spawnSync(["git", "worktree", "add", "-b", branch, path], { cwd: root, stdout: "pipe", stderr: "pipe" }); + if (wt.exitCode !== 0) + return { error: `git worktree failed: ${wt.stderr.toString().trim()}`, status: 500 }; + rememberCwd(path); + scanCache = null; + const id = encodeCwd(path); + return { id, repository_id: repoId, name: city, branch, status: "done", unread: false, + updated_at: new Date().toISOString(), last_message_snippet: null, session: createSession(id) }; } -async function workspaceDiff(workspaceId: string) { - const ws: any = db - .query(`SELECT w.workspace_path, coalesce(w.initialization_parent_branch, r.default_branch, 'main') AS base - FROM workspaces w LEFT JOIN repos r ON w.repository_id = r.id WHERE w.id = ?`) - .get(workspaceId); - if (!ws?.workspace_path || !existsSync(ws.workspace_path)) return { error: "workspace not found", status: 404 }; - const git = (...a: string[]) => - new Response(Bun.spawn(["git", ...a], { cwd: ws.workspace_path, stdout: "pipe" }).stdout as any).text(); - const mergeBase = (await git("merge-base", ws.base, "HEAD").catch(() => "")).trim(); - const ref = mergeBase || ws.base; - const [stat, diff] = await Promise.all([git("diff", "--stat", ref), git("diff", ref)]); - return { base: ws.base, stat, diff }; -} - -type Turn = { proc: ReturnType | null; running: boolean; activity: string }; +// ── Running turns via Pi RPC ──────────────────────────────────────────────── +type Turn = { proc: ReturnType | null; running: boolean; activity: string; cwd: string }; const turns = new Map(); function sendMessage(sessionId: string, text: string, model?: string) { const existing = turns.get(sessionId); if (existing?.running) return { error: "agent is already working", status: 409 }; - const session: any = db - .query(`SELECT s.*, w.workspace_path FROM sessions s JOIN workspaces w ON s.workspace_id = w.id WHERE s.id = ?`) - .get(sessionId); - if (!session) return { error: "session not found", status: 404 }; - // Picking a model from a different harness switches the session's agent and - // starts a fresh thread for it — same behavior as the desktop picker. - const agentForModel = (m: string) => - m.startsWith("gpt-") ? "codex" - : m.startsWith("opencode:") ? "acp" - : ["auto", "composer", "grok"].some(p => m.startsWith(p)) ? "cursor" - : "claude"; - const agent = model ? agentForModel(model) : (session.agent_type ?? "claude"); - if (!["claude", "codex", "cursor", "acp"].includes(agent)) - return { error: `unsupported agent type: ${agent}`, status: 400 }; - if (model && agent !== (session.agent_type ?? "claude")) { - wdb.prepare(`UPDATE sessions SET agent_type = ?, claude_session_id = NULL WHERE id = ?`).run(agent, sessionId); - session.agent_type = agent; - session.claude_session_id = null; - } - if (!session.workspace_path || !existsSync(session.workspace_path)) - return { error: "workspace directory not found on this Mac", status: 400 }; + const found = findSessionFile(sessionId); + const cwd = found?.cwd ?? pendingSessions.get(sessionId); + if (!cwd) return { error: "session not found", status: 404 }; + if (!existsSync(cwd)) return { error: "workspace directory not found on this Mac", status: 400 }; - const turn: Turn = { proc: null, running: true, activity: "Starting agent…" }; - turns.set(sessionId, turn); - const turnId = crypto.randomUUID(); - const senderId: any = db - .query(`SELECT sender_id FROM session_messages WHERE session_id = ? AND sender_id IS NOT NULL LIMIT 1`) - .get(sessionId); - // Mirror Conductor's row shape exactly: user rows carry model + sender; assistant - // rows carry the stream event's uuid as sdk_message_id and no model. - const push = (role: string, content: string, sdkId: string | null = null) => { - const now = new Date().toISOString(); - const id = role === "user" ? turnId : crypto.randomUUID(); - insertMsg.run(id, sessionId, role, content, now, now, - role === "user" ? session.model : null, turnId, - role === "user" ? senderId?.sender_id ?? null : null, sdkId); - }; - push("user", text); - - const resumeId = session.claude_session_id; - // Conductor normalizes every harness into claude-style envelopes; for non-claude - // agents we synthesize them from each CLI's own output format. - const envelope = (blocks: any[]) => - JSON.stringify({ type: "assistant", session_id: resumeId, message: { role: "assistant", content: blocks } }); - const pushEnv = (blocks: any[]) => push("assistant", envelope(blocks), crypto.randomUUID()); - const resultEnv = (extra: any = {}) => - push("assistant", JSON.stringify({ type: "result", session_id: resumeId, subtype: "finished", is_error: false, ...extra }), crypto.randomUUID()); - - let args: string[]; - let handleLine: (line: string) => void; - let cursorText = ""; // cursor streams text deltas; final text arrives in its result event - - if (model) updateSessionModel.run(model, sessionId); // persist the switch, desktop sees it too - - if (agent === "claude") { - args = ["claude", "-p", text, "--output-format", "stream-json", "--verbose", - "--permission-mode", "acceptEdits"]; // ponytail: no remote approval UI yet - if (resumeId) args.push("--resume", resumeId); - const m = model ?? session.model; - if (m) args.push("--model", claudeCliModel(m)); - handleLine = (line) => { - const ev = JSON.parse(line); - if (ev.type === "assistant") { - push("assistant", line, ev.uuid ?? null); // claude's output IS the native format - for (const b of ev.message?.content ?? []) - if (b.type === "tool_use") turn.activity = `${b.name} ${summarizeInput(b.input)}`; - } else if (ev.type === "result") { - push("assistant", line, ev.uuid ?? null); - if (ev.session_id) updateClaudeSessionId.run(ev.session_id, sessionId); - } - }; - } else if (agent === "codex") { - // Conductor's bundled codex — the nvm-installed one on PATH is broken. - const codexBin = `${homedir()}/Library/Application Support/com.conductor.app/bin/codex`; - args = resumeId - ? [existsSync(codexBin) ? codexBin : "codex", "exec", "resume", resumeId, "--json", text] - : [existsSync(codexBin) ? codexBin : "codex", "exec", "--json", text]; - if (model) args.push("-m", model); - handleLine = (line) => { - const ev = JSON.parse(line); - const item = ev.item; - if (ev.type === "item.completed" && item) { - if (item.type === "agent_message" && item.text) pushEnv([{ type: "text", text: item.text }]); - else if (item.type === "reasoning" && item.text) pushEnv([{ type: "thinking", thinking: item.text }]); - else if (item.type === "command_execution") { - turn.activity = item.command ?? "Running command…"; - pushEnv([{ type: "tool_use", id: crypto.randomUUID(), name: "Bash", input: { command: item.command } }]); - } else if (item.type === "file_change") { - turn.activity = "Editing files…"; - pushEnv([{ type: "tool_use", id: crypto.randomUUID(), name: "Edit", - input: { file_path: item.changes?.map((c: any) => c.path).join(", ") ?? "" } }]); - } - } else if (ev.type === "thread.started" && ev.thread_id) { - updateClaudeSessionId.run(ev.thread_id, sessionId); // fresh codex thread — remember it - } else if (ev.type === "turn.completed") resultEnv({ usage: ev.usage }); - else if (ev.type === "error") pushEnv([{ type: "text", text: `⚠️ ${ev.message}` }]); - }; - } else if (agent === "cursor") { - // --trust: worktrees Conductor creates aren't in cursor's trusted list; same - // trust the user already granted this repo on the desktop. - args = ["cursor-agent", "-p", text, "--output-format", "stream-json", "--trust"]; - if (resumeId) args.push("--resume", resumeId); - const m = model ?? session.model; - if (m) args.push("--model", m); - handleLine = (line) => { - const ev = JSON.parse(line); - if (ev.type === "assistant") - for (const b of ev.message?.content ?? []) if (b.type === "text") cursorText += b.text; - if (ev.type === "tool_call" || ev.type === "tool_use") turn.activity = "Using tools…"; - if (ev.type === "result") { - const final = ev.result || cursorText; - if (final) pushEnv([{ type: "text", text: final }]); - push("assistant", line, crypto.randomUUID()); - if (!resumeId && ev.session_id) updateClaudeSessionId.run(ev.session_id, sessionId); - } - }; - } else { - // acp = opencode. Plain-text output; one assistant envelope at the end. - if (!resumeId) - return { error: "OpenCode chats have to be started from the desktop for now", status: 400 }; - args = ["opencode", "run", "--session", resumeId, text]; - const m = (model ?? session.model)?.replace(/^opencode:/, ""); - if (m) args.push("--model", m); - handleLine = () => {}; // buffered below instead - } + const args = ["pi", "--mode", "rpc"]; + if (found) args.push("--session", found.file); + else args.push("--session-id", sessionId); + if (model) args.push("--model", model); - turn.proc = Bun.spawn(args, { cwd: session.workspace_path, stdout: "pipe", stderr: "pipe" }); + const turn: Turn = { proc: null, running: true, activity: "Starting agent…", cwd }; + turns.set(sessionId, turn); + const proc = Bun.spawn(args, { cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + turn.proc = proc; + proc.stdin.write(JSON.stringify({ id: "1", type: "prompt", message: text }) + "\n"); + proc.stdin.flush(); (async () => { let buf = ""; - let all = ""; - for await (const chunk of turn.proc!.stdout as any) { - const s = new TextDecoder().decode(chunk); - all += s; - buf += s; + for await (const chunk of proc.stdout as any) { + buf += new TextDecoder().decode(chunk); const lines = buf.split("\n"); buf = lines.pop()!; for (const line of lines) { if (!line.trim()) continue; - try { handleLine(line); } catch {} + let ev: any; + try { ev = JSON.parse(line); } catch { continue; } + if (ev.type === "tool_execution_start") + turn.activity = `${ev.toolName ?? "tool"} ${summarizeInput(ev.args)}`; + else if (ev.type === "message_start" && ev.message?.role === "assistant") + turn.activity = "Thinking…"; + else if (ev.type === "response" && ev.success === false) + turn.activity = `⚠️ ${ev.error ?? "command failed"}`; + else if (ev.type === "agent_end") { + proc.stdin.end(); + proc.kill(); // pi stays resident waiting for more commands; the turn is done + } } } - const code = await turn.proc!.exited; - if (agent === "acp" && all.trim()) { - pushEnv([{ type: "text", text: all.trim() }]); - resultEnv(); - } - if (code !== 0) { - const err = await new Response(turn.proc!.stderr as any).text().catch(() => ""); - pushEnv([{ type: "text", text: `⚠️ ${agent} exited with code ${code}. ${err.slice(0, 300)}` }]); - } + await proc.exited; + pendingSessions.delete(sessionId); // it's on disk now turn.running = false; turn.activity = ""; })(); @@ -358,101 +338,57 @@ function sendMessage(sessionId: string, text: string, model?: string) { return { ok: true }; } -// conductor.db stores 'YYYY-MM-DD HH:MM:SS' (UTC) or ISO strings; normalize to ISO8601. -const iso = (d: string | null) => - d ? new Date(d.includes("T") ? d : d.replace(" ", "T") + "Z").toISOString() : null; - -const repos = () => - db - .query( - `SELECT r.id, r.name, r.default_branch, - (SELECT count(*) FROM workspaces w - WHERE w.repository_id = r.id AND w.state != 'archived') AS active_workspace_count - FROM repos r WHERE r.hidden = 0 ORDER BY r.display_order, r.name` - ) - .all(); - -const workspaces = (repoId: string) => - db - .query( - `SELECT w.id, w.repository_id, coalesce(w.workspace_name, w.directory_name) AS name, - w.branch, coalesce(w.manual_status, w.derived_status) AS status, - w.unread, w.updated_at, - (SELECT s.title FROM sessions s WHERE s.workspace_id = w.id - ORDER BY s.updated_at DESC LIMIT 1) AS last_message_snippet - FROM workspaces w - WHERE w.repository_id = ? AND w.state != 'archived' - ORDER BY w.updated_at DESC` - ) - .all(repoId) - .map((w: any) => ({ ...w, unread: !!w.unread, updated_at: iso(w.updated_at) })); - -const sessions = (workspaceId: string) => - db - .query( - `SELECT id, workspace_id, title, model, agent_type, updated_at FROM sessions - WHERE workspace_id = ? AND is_hidden = 0 ORDER BY updated_at DESC` - ) - .all(workspaceId) - .map((s: any) => ({ ...s, updated_at: iso(s.updated_at) })); - -// Flatten Claude Code stream-JSON envelopes into displayable messages. -function displayMessages(sessionId: string) { - const rows = db - .query( - `SELECT id, role, content, created_at FROM session_messages - WHERE session_id = ? AND content IS NOT NULL AND cancelled_at IS NULL - ORDER BY created_at, id` - ) - .all(sessionId) as any[]; +// ── HTTP API (same shapes the phone already speaks) ───────────────────────── +const repos = () => { + const { workspaces, repoOf, repoRoots } = scanned(); + return [...repoRoots] + .map(([id, root]) => ({ + id, + name: basename(root), + default_branch: git(root, "symbolic-ref", "--short", "HEAD") ?? "main", + active_workspace_count: [...repoOf.values()].filter((r) => r === id).length, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +}; - const out: { id: string; role: string; content: string; created_at: string }[] = []; - for (const row of rows) { - const createdAt = iso(row.created_at)!; - let env: any; - try { - env = JSON.parse(row.content); - } catch { - // Plain text (typically the user's typed message). - out.push({ id: row.id, role: row.role, content: row.content, created_at: createdAt }); - continue; - } - const blocks = env?.message?.content; - if (env?.type === "assistant" && Array.isArray(blocks)) { - for (const [i, b] of blocks.entries()) { - if (b.type === "text" && b.text.trim()) - out.push({ id: `${row.id}-${i}`, role: "assistant", content: b.text, created_at: createdAt }); - else if (b.type === "thinking" && b.thinking?.trim()) - out.push({ id: `${row.id}-${i}`, role: "thinking", content: b.thinking, created_at: createdAt }); - else if (b.type === "tool_use") - out.push({ - id: `${row.id}-${i}`, - role: "tool", - content: `${b.name} ${summarizeInput(b.input)}`, - created_at: createdAt, - }); - } - } else if (env?.type === "user") { - const c = env.message?.content; - if (typeof c === "string" && c.trim()) - out.push({ id: row.id, role: "user", content: c, created_at: createdAt }); - // tool_result blocks are skipped for v1; the tool row already shows the call. - } - else if (env?.type === "result" && env.duration_ms) { - const s = Math.round(env.duration_ms / 1000); - const dur = s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`; - out.push({ id: row.id, role: "duration", content: dur, created_at: createdAt }); - } - // system envelopes are skipped — internal. - } - return out; -} +const workspacesOf = (repoId: string) => { + const { workspaces, repoOf } = scanned(); + return [...workspaces] + .filter(([id]) => repoOf.get(id) === repoId) + .map(([id, ws]) => { + const newest = ws.dir ? sessionFiles(ws.dir)[0] : null; + return { + id, + repository_id: repoId, + name: basename(ws.cwd), + branch: git(ws.cwd, "branch", "--show-current"), + status: [...turns.values()].some((t) => t.running && t.cwd === ws.cwd) ? "in-progress" : "done", + unread: false, + updated_at: new Date(ws.mtime).toISOString(), + last_message_snippet: newest ? sessionSummary(`${ws.dir}/${newest}`, id).title : null, + }; + }) + .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1)); +}; -const summarizeInput = (input: any) => { - const v = input?.file_path ?? input?.command ?? input?.pattern ?? input?.description ?? ""; - return String(v).slice(0, 80); +const sessionsOf = (workspaceId: string) => { + const ws = wsById(workspaceId); + if (!ws?.dir) return []; + return sessionFiles(ws.dir).map((f) => sessionSummary(`${ws.dir}/${f}`, workspaceId)); }; +async function workspaceDiff(workspaceId: string) { + const ws = wsById(workspaceId); + if (!ws || !existsSync(ws.cwd)) return { error: "workspace not found", status: 404 }; + const g = (...a: string[]) => + new Response(Bun.spawn(["git", ...a], { cwd: ws.cwd, stdout: "pipe" }).stdout as any).text(); + const base = git(ws.cwd, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")?.replace(/^origin\//, "") ?? "main"; + const mergeBase = (await g("merge-base", base, "HEAD").catch(() => "")).trim(); + const ref = mergeBase || base; + const [stat, diff] = await Promise.all([g("diff", "--stat", ref), g("diff", ref)]); + return { base, stat, diff }; +} + Bun.serve({ port: PORT, hostname: "0.0.0.0", @@ -469,25 +405,63 @@ Bun.serve({ const r = sendMessage(m[1], text.trim(), model); return Response.json(r, { status: "status" in r ? (r.status as number) : 200 }); } + // Deleting a chat moves its session file to ~/.pi-companion/trash (recoverable). + if (req.method === "DELETE" && (m = path.match(/^\/sessions\/([^/]+)$/))) { + const found = findSessionFile(m[1]); + if (!found) return Response.json({ error: "session not found" }, { status: 404 }); + const trash = `${tokenDir}/trash`; + mkdirSync(trash, { recursive: true }); + renameSync(found.file, `${trash}/${basename(found.file)}`); + scanCache = null; + return Response.json({ ok: true }); + } + // "Deleting" a workspace unregisters the folder from the app; nothing on + // disk is touched (worktrees and session files stay). + if (req.method === "DELETE" && (m = path.match(/^\/workspaces\/([^/]+)$/))) { + const ws = wsById(m[1]); + if (!ws) return Response.json({ error: "workspace not found" }, { status: 404 }); + forgetCwd(ws.cwd); + scanCache = null; + return Response.json({ ok: true }); + } if (req.method === "POST" && (m = path.match(/^\/sessions\/([^/]+)\/stop$/))) { const t = turns.get(m[1]); + try { t?.proc?.stdin?.write(JSON.stringify({ id: "stop", type: "abort" }) + "\n"); } catch {} t?.proc?.kill(); return Response.json({ ok: true }); } - // Serve workspace-relative attachment files (images pasted into chats live - // under /.context/attachments/). if ((m = path.match(/^\/sessions\/([^/]+)\/attachments$/))) { const rel = new URL(req.url).searchParams.get("path") ?? ""; - const ws: any = db - .query(`SELECT w.workspace_path FROM sessions s JOIN workspaces w ON s.workspace_id = w.id WHERE s.id = ?`) - .get(m[1]); - if (!ws?.workspace_path) return Response.json({ error: "not found" }, { status: 404 }); - const full = resolve(ws.workspace_path, decodeURIComponent(rel)); - if (!full.startsWith(resolve(ws.workspace_path) + "/")) + const found = findSessionFile(m[1]); + if (!found) return Response.json({ error: "not found" }, { status: 404 }); + const full = resolve(found.cwd, decodeURIComponent(rel)); + if (!full.startsWith(resolve(found.cwd) + "/")) return Response.json({ error: "forbidden" }, { status: 403 }); if (!existsSync(full)) return Response.json({ error: "not found" }, { status: 404 }); return new Response(Bun.file(full)); } + // Browse folders on the Mac for the phone's project picker. + if (path === "/browse") { + const q = new URL(req.url).searchParams.get("path") || homedir(); + const dir = resolve(q.startsWith("~/") ? `${homedir()}/${q.slice(2)}` : q); + if (!existsSync(dir) || !statSync(dir).isDirectory()) + return Response.json({ error: "not a folder" }, { status: 404 }); + const dirs = readdirSync(dir) + .filter((n) => !n.startsWith(".") && n !== "node_modules") + .filter((n) => { try { return statSync(`${dir}/${n}`).isDirectory(); } catch { return false; } }) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + return Response.json({ path: dir, parent: dir === "/" ? null : dirname(dir), dirs }); + } + // Register a project folder that has no Pi sessions yet. + if (req.method === "POST" && path === "/projects") { + const { path: dir } = await req.json(); + const full = dir?.startsWith("~/") ? `${homedir()}/${dir.slice(2)}` : dir; + if (!full || !existsSync(full) || !statSync(full).isDirectory()) + return Response.json({ error: "folder not found on this Mac" }, { status: 404 }); + rememberCwd(resolve(full)); + scanCache = null; + return Response.json({ ok: true }); + } if (req.method === "POST" && (m = path.match(/^\/repos\/([^/]+)\/workspaces$/))) { const r = createWorkspace(m[1]); return "error" in r ? Response.json({ error: r.error }, { status: r.status }) : Response.json(r); @@ -497,18 +471,13 @@ Bun.serve({ return Response.json(r, { status: "status" in r ? (r.status as number) : 200 }); } if ((m = path.match(/^\/workspaces\/([^/]+)\/diffstat$/))) { - const ws: any = db - .query(`SELECT w.workspace_path, coalesce(w.initialization_parent_branch, r.default_branch, 'main') AS base - FROM workspaces w LEFT JOIN repos r ON w.repository_id = r.id WHERE w.id = ?`) - .get(m[1]); - if (!ws?.workspace_path || !existsSync(ws.workspace_path)) return Response.json({ error: "not found" }, { status: 404 }); - const git = (...a: string[]) => - new Response(Bun.spawn(["git", ...a], { cwd: ws.workspace_path, stdout: "pipe" }).stdout as any).text(); - const mb = (await git("merge-base", ws.base, "HEAD").catch(() => "")).trim(); - const short = await git("diff", "--shortstat", mb || ws.base); - const ins = Number(short.match(/(\d+) insertion/)?.[1] ?? 0); - const del = Number(short.match(/(\d+) deletion/)?.[1] ?? 0); - return Response.json({ insertions: ins, deletions: del }); + const ws = wsById(m[1]); + if (!ws) return Response.json({ error: "not found" }, { status: 404 }); + const short = git(ws.cwd, "diff", "--shortstat", "HEAD") ?? ""; + return Response.json({ + insertions: Number(short.match(/(\d+) insertion/)?.[1] ?? 0), + deletions: Number(short.match(/(\d+) deletion/)?.[1] ?? 0), + }); } if ((m = path.match(/^\/workspaces\/([^/]+)\/diff$/))) { const r = await workspaceDiff(m[1]); @@ -518,11 +487,14 @@ Bun.serve({ const t = turns.get(m[1]); return Response.json({ running: !!t?.running, activity: t?.activity ?? "" }); } - if (path === "/models") return Response.json(modelGroups()); + if (path === "/models") return Response.json(modelCache); if (path === "/repos") return Response.json(repos()); - if ((m = path.match(/^\/repos\/([^/]+)\/workspaces$/))) return Response.json(workspaces(m[1])); - if ((m = path.match(/^\/workspaces\/([^/]+)\/sessions$/))) return Response.json(sessions(m[1])); - if ((m = path.match(/^\/sessions\/([^/]+)\/messages$/))) return Response.json(displayMessages(m[1])); + if ((m = path.match(/^\/repos\/([^/]+)\/workspaces$/))) return Response.json(workspacesOf(m[1])); + if ((m = path.match(/^\/workspaces\/([^/]+)\/sessions$/))) return Response.json(sessionsOf(m[1])); + if ((m = path.match(/^\/sessions\/([^/]+)\/messages$/))) { + const r = displayMessages(m[1]); + return Response.json(r, { status: "status" in r && !Array.isArray(r) ? (r as any).status : 200 }); + } } catch (e) { return Response.json({ error: String(e) }, { status: 500 }); } @@ -530,16 +502,16 @@ Bun.serve({ }, }); -console.log(`Conductor companion listening on http://0.0.0.0:${PORT}`); +console.log(`Pi companion listening on http://0.0.0.0:${PORT}`); console.log(`Auth token: ${TOKEN}`); -// Pairing QR: scan with the iPhone Camera app to open Conductor Companion -// with the address + token pre-filled. +// Pairing QR: scan with the iPhone Camera app to open the app with the +// address + token pre-filled. import { hostname } from "os"; import qrcode from "qrcode-terminal"; // bun auto-installs on first run const host = hostname().replace(/\.local$/, ""); const pairURL = - `conductor-companion://pair?name=${encodeURIComponent(host)}` + + `pi-companion://pair?name=${encodeURIComponent(host)}` + `&addr=${encodeURIComponent(`http://${host}.local:${PORT}`)}&token=${TOKEN}`; qrcode.generate(pairURL, { small: true }); console.log(`Scan with the iPhone camera to pair (same network), or enter the token manually.`);