feat(macos): add attach-only launchd override
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -4,6 +4,23 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
## 2026.1.22 (unreleased)
|
## 2026.1.22 (unreleased)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
|
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||||
|
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||||
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
|
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||||
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
|
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||||
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
|
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||||
|
- macOS: add attach-only debug toggle + `--attach-only`/`--no-launchd` flag to skip launchd installs.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct DebugSettings: View {
|
|||||||
@State private var modelsError: String?
|
@State private var modelsError: String?
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
|
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||||
|
@State private var launchAgentWriteError: String?
|
||||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||||
@State private var sessionStoreSaveError: String?
|
@State private var sessionStoreSaveError: String?
|
||||||
@@ -47,6 +49,7 @@ struct DebugSettings: View {
|
|||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
self.header
|
self.header
|
||||||
|
|
||||||
|
self.launchdSection
|
||||||
self.appInfoSection
|
self.appInfoSection
|
||||||
self.gatewaySection
|
self.gatewaySection
|
||||||
self.logsSection
|
self.logsSection
|
||||||
@@ -79,6 +82,39 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var launchdSection: some View {
|
||||||
|
GroupBox("Gateway startup") {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
|
||||||
|
.onChange(of: self.launchAgentWriteDisabled) { _, newValue in
|
||||||
|
self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
|
||||||
|
if self.launchAgentWriteError != nil {
|
||||||
|
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newValue {
|
||||||
|
Task {
|
||||||
|
_ = await GatewayLaunchAgentManager.set(
|
||||||
|
enabled: false,
|
||||||
|
bundlePath: Bundle.main.bundlePath,
|
||||||
|
port: GatewayEnvironment.gatewayPort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let launchAgentWriteError {
|
||||||
|
Text(launchAgentWriteError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Debug")
|
Text("Debug")
|
||||||
|
|||||||
@@ -4,11 +4,46 @@ enum GatewayLaunchAgentManager {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
|
private static var disableLaunchAgentMarkerURL: URL {
|
||||||
|
FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
|
}
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager().homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func isLaunchAgentWriteDisabled() -> Bool {
|
||||||
|
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
|
||||||
|
let marker = self.disableLaunchAgentMarkerURL
|
||||||
|
if disabled {
|
||||||
|
do {
|
||||||
|
try FileManager().createDirectory(
|
||||||
|
at: marker.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
if !FileManager().fileExists(atPath: marker.path) {
|
||||||
|
FileManager().createFile(atPath: marker.path, contents: nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if FileManager().fileExists(atPath: marker.path) {
|
||||||
|
do {
|
||||||
|
try FileManager().removeItem(at: marker)
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
static func isLoaded() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||||
return loaded
|
return loaded
|
||||||
@@ -66,12 +101,6 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension GatewayLaunchAgentManager {
|
extension GatewayLaunchAgentManager {
|
||||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
|
||||||
let marker = FileManager().homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
|
||||||
return FileManager().fileExists(atPath: marker.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
let result = await self.runDaemonCommandResult(
|
let result = await self.runDaemonCommandResult(
|
||||||
["status", "--json", "--no-probe"],
|
["status", "--json", "--no-probe"],
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||||
|
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||||
|
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
||||||
|
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
||||||
|
return
|
||||||
|
}
|
||||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||||
guard !enabled else { return }
|
guard !enabled else { return }
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
@@ -308,6 +313,15 @@ final class GatewayProcessManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||||
|
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
|
||||||
|
self.status = .failed(message)
|
||||||
|
self.lastFailureReason = "launchd disabled"
|
||||||
|
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
|
||||||
|
self.logger.info("gateway launchd enable skipped (disable marker set)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Darwin
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MenuBarExtraAccess
|
import MenuBarExtraAccess
|
||||||
import Observation
|
import Observation
|
||||||
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ import SwiftUI
|
|||||||
struct ClawdbotApp: App {
|
struct ClawdbotApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@State private var state: AppState
|
@State private var state: AppState
|
||||||
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
private let controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
@@ -31,6 +33,7 @@ struct ClawdbotApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
ClawdbotLogging.bootstrapIfNeeded()
|
ClawdbotLogging.bootstrapIfNeeded()
|
||||||
|
Self.applyAttachOnlyOverrideIfNeeded()
|
||||||
_state = State(initialValue: AppStateStore.shared)
|
_state = State(initialValue: AppStateStore.shared)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +94,22 @@ struct ClawdbotApp: App {
|
|||||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
|
||||||
|
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
|
||||||
|
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
_ = await GatewayLaunchAgentManager.set(
|
||||||
|
enabled: false,
|
||||||
|
bundlePath: Bundle.main.bundlePath,
|
||||||
|
port: GatewayEnvironment.gatewayPort())
|
||||||
|
}
|
||||||
|
Self.logger.info("attach-only flag enabled")
|
||||||
|
}
|
||||||
|
|
||||||
private var isGatewaySleeping: Bool {
|
private var isGatewaySleeping: Bool {
|
||||||
if self.state.isPaused { return false }
|
if self.state.isPaused { return false }
|
||||||
switch self.state.connectionMode {
|
switch self.state.connectionMode {
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ present. To reset manually:
|
|||||||
rm ~/.clawdbot/disable-launchagent
|
rm ~/.clawdbot/disable-launchagent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Attach-only mode
|
||||||
|
|
||||||
|
To force the macOS app to **never install or manage launchd**, launch it with
|
||||||
|
`--attach-only` (or `--no-launchd`). This sets `~/.clawdbot/disable-launchagent`,
|
||||||
|
so the app only attaches to an already running Gateway. You can toggle the same
|
||||||
|
behavior in Debug Settings.
|
||||||
|
|
||||||
## Remote mode
|
## Remote mode
|
||||||
|
|
||||||
Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
|
Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
|
||||||
|
|||||||
Reference in New Issue
Block a user