53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
8.1 KiB
Swift
183 lines
8.1 KiB
Swift
#if PHONE_LINK && !APPSTORE
|
|
import AppKit
|
|
import CloudKit
|
|
|
|
// MARK: - Instructions from the iPhone
|
|
//
|
|
// The iPhone writes an `Instruction` record (text encrypted) for a session.
|
|
// When the user turned it on (Settings → General → iPhone, off by default),
|
|
// this Mac checks every 15 s, takes each instruction once (the record is
|
|
// deleted on read), and continues that same Claude Code conversation in the
|
|
// background: `claude -p <text> --resume <session id>`, in the session's own
|
|
// folder. The folder and the session come from what this Mac saw in the
|
|
// hooks, never from the iPhone. Hooks keep working, so the notch, the iPhone
|
|
// and the permission requests (approved by hand, with Face ID on the phone)
|
|
// follow the run like any other turn.
|
|
// GitHub build only: the App Store build is sandboxed and can't start `claude`.
|
|
|
|
@MainActor
|
|
final class InstructionRunner {
|
|
static let shared = InstructionRunner()
|
|
|
|
nonisolated static let enabledKey = "iPhoneInstructionsEnabled"
|
|
nonisolated static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
|
|
|
|
private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase }
|
|
private var pollTask: Task<Void, Never>?
|
|
private var changeToken: CKServerChangeToken?
|
|
private var running: [String: Process] = [:] // by session id
|
|
|
|
/// Instructions older than this are dropped instead of run.
|
|
private let maxAge: TimeInterval = 10 * 60
|
|
|
|
func setEnabled(_ on: Bool) {
|
|
UserDefaults.standard.set(on, forKey: Self.enabledKey)
|
|
if on && CloudProbe.isEnabled { start() } else { stop() }
|
|
}
|
|
|
|
func startIfEnabled() {
|
|
if Self.isEnabled { start() }
|
|
}
|
|
|
|
func start() {
|
|
guard pollTask == nil else { return }
|
|
log("on: checking for instructions every 15 s")
|
|
pollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
await self?.check()
|
|
try? await Task.sleep(for: .seconds(15))
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
guard pollTask != nil else { return }
|
|
pollTask?.cancel()
|
|
pollTask = nil
|
|
log("off")
|
|
}
|
|
|
|
// MARK: Reading
|
|
|
|
private func check() async {
|
|
var found: [CKRecord] = []
|
|
do {
|
|
var more = true
|
|
while more {
|
|
let changes = try await database.recordZoneChanges(inZoneWith: SessionSnapshot.zoneID, since: changeToken)
|
|
for (_, result) in changes.modificationResultsByID {
|
|
if case .success(let mod) = result, mod.record.recordType == "Instruction" { found.append(mod.record) }
|
|
}
|
|
changeToken = changes.changeToken
|
|
more = changes.moreComing
|
|
}
|
|
} catch let error as CKError where error.code == .changeTokenExpired {
|
|
changeToken = nil
|
|
return
|
|
} catch {
|
|
return
|
|
}
|
|
guard !found.isEmpty else { return }
|
|
// Single use: gone from iCloud before anything runs.
|
|
_ = try? await database.modifyRecords(saving: [], deleting: found.map(\.recordID))
|
|
for record in found.sorted(by: { ($0["createdAt"] as? Date ?? .distantPast) < ($1["createdAt"] as? Date ?? .distantPast) }) {
|
|
handle(record)
|
|
}
|
|
}
|
|
|
|
private func handle(_ record: CKRecord) {
|
|
let pillId = record["pillId"] as? String ?? ""
|
|
let createdAt = record["createdAt"] as? Date ?? .distantPast
|
|
let text = (record.encryptedValues["text"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard Self.isEnabled else { log("ignored: instructions are off"); return }
|
|
guard Date().timeIntervalSince(createdAt) < maxAge else { log("ignored: older than 10 min"); return }
|
|
guard !text.isEmpty, text.count <= 8000 else { log("ignored: empty or too long"); return }
|
|
guard pillId == "integration_claude" || pillId == "agent_cursor" else { log("ignored: \(pillId) can't take instructions"); return }
|
|
guard let session = TurnRecorder.shared.lastSession(for: pillId) else {
|
|
log("ignored: no Claude Code session seen for \(pillId) yet")
|
|
return
|
|
}
|
|
var isDirectory: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: session.cwd, isDirectory: &isDirectory), isDirectory.boolValue else {
|
|
log("ignored: the session's folder is gone")
|
|
return
|
|
}
|
|
guard running[session.sessionId] == nil else {
|
|
log("ignored: an instruction is already running for this session")
|
|
return
|
|
}
|
|
guard let claude = Self.claudeExecutable() else {
|
|
log("can't find the claude command (looked in ~/.claude/local, Homebrew, /usr/local/bin, ~/.npm-global/bin)")
|
|
return
|
|
}
|
|
run(claude: claude, text: text, sessionId: session.sessionId, cwd: session.cwd, pillId: pillId)
|
|
}
|
|
|
|
// MARK: Running
|
|
|
|
private func run(claude: String, text: String, sessionId: String, cwd: String, pillId: String) {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: claude)
|
|
process.arguments = ["-p", text, "--resume", sessionId]
|
|
process.currentDirectoryURL = URL(fileURLWithPath: cwd)
|
|
var env = ProcessInfo.processInfo.environment
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
env["PATH"] = [URL(fileURLWithPath: claude).deletingLastPathComponent().path, "/opt/homebrew/bin", "/usr/local/bin",
|
|
"/usr/bin", "/bin", "/usr/sbin", "/sbin", "\(home)/.local/bin", env["PATH"] ?? ""].joined(separator: ":")
|
|
// The hooks route events by editor: keep them on the same pill.
|
|
if pillId == "agent_cursor" {
|
|
env["__CFBundleIdentifier"] = "com.todesktop.230313mzl4w4u92"
|
|
} else {
|
|
env["TERM_PROGRAM"] = "vscode"
|
|
}
|
|
process.environment = env
|
|
process.standardInput = FileHandle.nullDevice
|
|
// Output goes to a file (a pipe could fill up and stall claude); its
|
|
// end is logged if the run fails.
|
|
let logURL = FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Library/Logs/NotchBuddy/instruction-last.log")
|
|
try? FileManager.default.createDirectory(at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
FileManager.default.createFile(atPath: logURL.path, contents: nil)
|
|
let output = try? FileHandle(forWritingTo: logURL)
|
|
process.standardOutput = output ?? FileHandle.nullDevice
|
|
process.standardError = output ?? FileHandle.nullDevice
|
|
process.terminationHandler = { [weak self] finished in
|
|
try? output?.close()
|
|
let data = (try? Data(contentsOf: logURL)) ?? Data()
|
|
let tail = String(decoding: data.suffix(400), as: UTF8.self)
|
|
.replacingOccurrences(of: "\n", with: " ")
|
|
let status = finished.terminationStatus
|
|
Task { @MainActor in
|
|
self?.running[sessionId] = nil
|
|
self?.log(status == 0 ? "finished (\(sessionId.prefix(8)))" : "ended with \(status): \(tail)")
|
|
}
|
|
}
|
|
do {
|
|
try process.run()
|
|
running[sessionId] = process
|
|
log("running in \(URL(fileURLWithPath: cwd).lastPathComponent) (\(sessionId.prefix(8))): \(text.count) chars")
|
|
} catch {
|
|
log("couldn't start claude: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// Where `claude` usually lives; the app doesn't get the shell's PATH.
|
|
static func claudeExecutable() -> String? {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
let candidates = [
|
|
"\(home)/.claude/local/claude",
|
|
"\(home)/.local/bin/claude",
|
|
"/opt/homebrew/bin/claude",
|
|
"/usr/local/bin/claude",
|
|
"\(home)/.npm-global/bin/claude",
|
|
"\(home)/.bun/bin/claude",
|
|
]
|
|
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
|
}
|
|
|
|
private func log(_ message: String) {
|
|
CloudProbe.shared.log("[instruction] \(message)")
|
|
}
|
|
}
|
|
#endif
|