Compare commits

...

5 Commits

Author SHA1 Message Date
Peter Steinberger
990a778d00 docs: update changelog for #166 2026-01-04 06:09:33 +01:00
Peter Steinberger
56274eb76e style: fix lint formatting 2026-01-04 06:09:04 +01:00
Peter Steinberger
d9987a65b2 fix(macos): bridge wizard option values 2026-01-04 06:04:32 +01:00
Peter Steinberger
b59c9c38c2 chore: update Peekaboo submodule 2026-01-04 06:04:30 +01:00
Tu Nombre Real
d32b77b330 feat(macos): add Swift 6 strict concurrency compatibility
Prepares the macOS app for Swift 6 strict concurrency mode by:

1. Adding Sendable conformance to WizardNextResult, WizardStartResult,
   and WizardStatusResult in GatewayModels.swift

2. Adding AnyCodable bridging helpers in OnboardingWizard.swift to
   handle type conflicts between ClawdisProtocol and local module

3. Making CLLocationManagerDelegate methods nonisolated in:
   - MacNodeLocationService.swift
   - PermissionManager.swift (LocationPermissionRequester)

   Using Task { @MainActor in } pattern to safely access MainActor
   state from nonisolated protocol requirements.

These changes are forward-compatible and don't affect behavior on
current Swift versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:57:08 +01:00
11 changed files with 140 additions and 77 deletions

View File

@ -23,6 +23,7 @@
- Sessions: add agenttoagent post step with `ANNOUNCE_SKIP` to suppress channel announcements. - Sessions: add agenttoagent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
### Fixes ### Fixes
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete. - CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow. - CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway. - WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.

@ -1 +1 @@
Subproject commit 9db365b73c7027485cf17507dff8fd59fbd02584 Subproject commit b69e4e8dc0f34fca488e034c6cb1373f1259589d

View File

@ -91,19 +91,26 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
} }
} }
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { // MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let latest = locations.last { Task { @MainActor in
cont.resume(returning: latest) guard let cont = self.locationContinuation else { return }
} else { self.locationContinuation = nil
cont.resume(throwing: Error.unavailable) if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
} }
} }
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) { nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return } let errorCopy = error // Capture error for Sendable compliance
self.locationContinuation = nil Task { @MainActor in
cont.resume(throwing: error) guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: errorCopy)
}
} }
} }

View File

@ -6,6 +6,25 @@ import SwiftUI
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard") private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
// MARK: - Swift 6 AnyCodable Bridging Helpers
// Bridge between ClawdisProtocol.AnyCodable and the local module to avoid
// Swift 6 strict concurrency type conflicts.
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable {
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data)
{
return decoded
}
return AnyCodable(value.value)
}
private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? {
value.map(bridgeToLocal)
}
@MainActor @MainActor
@Observable @Observable
final class OnboardingWizardModel { final class OnboardingWizardModel {
@ -285,11 +304,11 @@ struct OnboardingWizardStepView: View {
return return
} }
let option = optionItems[selectedIndex].option let option = optionItems[selectedIndex].option
onSubmit(option.value ?? AnyCodable(option.label)) onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect": case "multiselect":
let values = optionItems let values = optionItems
.filter { selectedIndices.contains($0.index) } .filter { selectedIndices.contains($0.index) }
.map { $0.option.value ?? AnyCodable($0.option.label) } .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
onSubmit(AnyCodable(values)) onSubmit(AnyCodable(values))
case "action": case "action":
onSubmit(AnyCodable(true)) onSubmit(AnyCodable(true))
@ -307,12 +326,12 @@ private struct WizardOptionItem: Identifiable {
} }
private struct WizardOption { private struct WizardOption {
let value: AnyCodable? let value: ProtocolAnyCodable?
let label: String let label: String
let hint: String? let hint: String?
} }
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
guard let raw else { return nil } guard let raw else { return nil }
do { do {
let data = try JSONEncoder().encode(raw) let data = try JSONEncoder().encode(raw)
@ -323,7 +342,7 @@ private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
} }
} }
private func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] { private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
guard let raw else { return [] } guard let raw else { return [] }
return raw.map { entry in return raw.map { entry in
let value = entry["value"] let value = entry["value"]
@ -337,7 +356,7 @@ private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? "" (step.type.value as? String) ?? ""
} }
private func anyCodableString(_ value: AnyCodable?) -> String { private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value { switch value?.value {
case let string as String: case let string as String:
return string return string
@ -352,11 +371,11 @@ private func anyCodableString(_ value: AnyCodable?) -> String {
} }
} }
private func anyCodableStringValue(_ value: AnyCodable?) -> String? { private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
value?.value as? String value?.value as? String
} }
private func anyCodableBool(_ value: AnyCodable?) -> Bool { private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value { switch value?.value {
case let bool as Bool: case let bool as Bool:
return bool return bool
@ -367,18 +386,18 @@ private func anyCodableBool(_ value: AnyCodable?) -> Bool {
} }
} }
private func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
switch value?.value { switch value?.value {
case let arr as [AnyCodable]: case let arr as [ProtocolAnyCodable]:
return arr return arr
case let arr as [Any]: case let arr as [Any]:
return arr.map { AnyCodable($0) } return arr.map { ProtocolAnyCodable($0) }
default: default:
return [] return []
} }
} }
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool { private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) { switch (lhs?.value, rhs?.value) {
case let (l as String, r as String): case let (l as String, r as String):
return l == r return l == r

View File

@ -289,10 +289,14 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
} }
} }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { // nonisolated for Swift 6 strict concurrency compatibility
guard let cont = self.continuation else { return } nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
self.continuation = nil let status = manager.authorizationStatus
cont.resume(returning: manager.authorizationStatus) Task { @MainActor in
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
} }
} }

View File

@ -871,7 +871,7 @@ public struct WizardStep: Codable {
} }
} }
public struct WizardNextResult: Codable { public struct WizardNextResult: Codable, Sendable {
public let done: Bool public let done: Bool
public let step: [String: AnyCodable]? public let step: [String: AnyCodable]?
public let status: AnyCodable? public let status: AnyCodable?
@ -896,7 +896,7 @@ public struct WizardNextResult: Codable {
} }
} }
public struct WizardStartResult: Codable { public struct WizardStartResult: Codable, Sendable {
public let sessionid: String public let sessionid: String
public let done: Bool public let done: Bool
public let step: [String: AnyCodable]? public let step: [String: AnyCodable]?
@ -925,7 +925,7 @@ public struct WizardStartResult: Codable {
} }
} }
public struct WizardStatusResult: Codable { public struct WizardStatusResult: Codable, Sendable {
public let status: AnyCodable public let status: AnyCodable
public let error: String? public let error: String?

View File

@ -125,7 +125,7 @@ describe("sessions tools", () => {
callGatewayMock.mockReset(); callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = []; const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0; let agentCallCount = 0;
let historyCallCount = 0; let _historyCallCount = 0;
let sendCallCount = 0; let sendCallCount = 0;
let waitRunId: string | undefined; let waitRunId: string | undefined;
let nextHistoryIsWaitReply = false; let nextHistoryIsWaitReply = false;
@ -153,7 +153,7 @@ describe("sessions tools", () => {
return { runId: params?.runId ?? "run-1", status: "ok" }; return { runId: params?.runId ?? "run-1", status: "ok" };
} }
if (request.method === "chat.history") { if (request.method === "chat.history") {
historyCallCount += 1; _historyCallCount += 1;
const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP"; const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP";
nextHistoryIsWaitReply = false; nextHistoryIsWaitReply = false;
return { return {
@ -219,8 +219,9 @@ describe("sessions tools", () => {
(call) => (call) =>
typeof (call.params as { extraSystemPrompt?: string }) typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string }) (
?.extraSystemPrompt?.includes("Agent-to-agent message context"), call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent message context"),
), ),
).toBe(true); ).toBe(true);
expect( expect(
@ -228,8 +229,9 @@ describe("sessions tools", () => {
(call) => (call) =>
typeof (call.params as { extraSystemPrompt?: string }) typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string }) (
?.extraSystemPrompt?.includes("Agent-to-agent post step"), call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent post step"),
), ),
).toBe(true); ).toBe(true);
expect(waitCalls).toHaveLength(3); expect(waitCalls).toHaveLength(3);

View File

@ -2774,7 +2774,9 @@ function buildAgentToAgentPostContext(params: {
? `Requester surface: ${params.requesterSurface}.` ? `Requester surface: ${params.requesterSurface}.`
: undefined, : undefined,
`Target session: ${params.targetSessionKey}.`, `Target session: ${params.targetSessionKey}.`,
params.targetChannel ? `Target surface: ${params.targetChannel}.` : undefined, params.targetChannel
? `Target surface: ${params.targetChannel}.`
: undefined,
`Original request: ${params.originalMessage}`, `Original request: ${params.originalMessage}`,
params.roundOneReply params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}` ? `Round 1 reply: ${params.roundOneReply}`
@ -2840,34 +2842,35 @@ function createSessionsSendTool(opts?: {
extraSystemPrompt: agentMessageContext, extraSystemPrompt: agentMessageContext,
}; };
const resolveAnnounceTarget = async (): Promise<AnnounceTarget | null> => { const resolveAnnounceTarget =
const parsed = resolveAnnounceTargetFromKey(resolvedKey); async (): Promise<AnnounceTarget | null> => {
if (parsed) return parsed; const parsed = resolveAnnounceTargetFromKey(resolvedKey);
try { if (parsed) return parsed;
const list = (await callGateway({ try {
method: "sessions.list", const list = (await callGateway({
params: { method: "sessions.list",
includeGlobal: true, params: {
includeUnknown: true, includeGlobal: true,
limit: 200, includeUnknown: true,
}, limit: 200,
})) as { sessions?: Array<Record<string, unknown>> }; },
const sessions = Array.isArray(list?.sessions) ? list.sessions : []; })) as { sessions?: Array<Record<string, unknown>> };
const match = const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
sessions.find((entry) => entry?.key === resolvedKey) ?? const match =
sessions.find((entry) => entry?.key === displayKey); sessions.find((entry) => entry?.key === resolvedKey) ??
const channel = sessions.find((entry) => entry?.key === displayKey);
typeof match?.lastChannel === "string" const channel =
? match.lastChannel typeof match?.lastChannel === "string"
: undefined; ? match.lastChannel
const to = : undefined;
typeof match?.lastTo === "string" ? match.lastTo : undefined; const to =
if (channel && to) return { channel, to }; typeof match?.lastTo === "string" ? match.lastTo : undefined;
} catch { if (channel && to) return { channel, to };
// ignore; fall through to null } catch {
} // ignore; fall through to null
return null; }
}; return null;
};
const runAgentToAgentPost = async (roundOneReply?: string) => { const runAgentToAgentPost = async (roundOneReply?: string) => {
const announceTarget = await resolveAnnounceTarget(); const announceTarget = await resolveAnnounceTarget();
@ -2917,9 +2920,7 @@ function createSessionsSendTool(opts?: {
params: { sessionKey: resolvedKey, limit: 50 }, params: { sessionKey: resolvedKey, limit: 50 },
})) as { messages?: unknown[] }; })) as { messages?: unknown[] };
const postFiltered = stripToolMessages( const postFiltered = stripToolMessages(
Array.isArray(postHistory?.messages) Array.isArray(postHistory?.messages) ? postHistory.messages : [],
? postHistory.messages
: [],
); );
const postLast = const postLast =
postFiltered.length > 0 postFiltered.length > 0

View File

@ -3,7 +3,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js"; import type { WizardPrompter } from "../wizard/prompts.js";
import { resolveNodeManagerOptions } from "./onboard-helpers.js"; import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js";
function summarizeInstallFailure(message: string): string | undefined { function summarizeInstallFailure(message: string): string | undefined {
const cleaned = message const cleaned = message
@ -59,6 +59,13 @@ export async function setupSkills(
); );
const blocked = report.skills.filter((s) => s.blockedByAllowlist); const blocked = report.skills.filter((s) => s.blockedByAllowlist);
const needsBrewPrompt =
process.platform !== "win32" &&
report.skills.some((skill) =>
skill.install.some((option) => option.kind === "brew"),
) &&
!(await detectBinary("brew"));
await prompter.note( await prompter.note(
[ [
`Eligible: ${eligible.length}`, `Eligible: ${eligible.length}`,
@ -74,6 +81,29 @@ export async function setupSkills(
}); });
if (!shouldConfigure) return cfg; if (!shouldConfigure) return cfg;
if (needsBrewPrompt) {
await prompter.note(
[
"Many skill dependencies are shipped via Homebrew.",
"Without brew, you'll need to build from source or download releases manually.",
].join("\n"),
"Homebrew recommended",
);
const showBrewInstall = await prompter.confirm({
message: "Show Homebrew install command?",
initialValue: true,
});
if (showBrewInstall) {
await prompter.note(
[
"Run:",
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
].join("\n"),
"Homebrew install",
);
}
}
const nodeManager = (await prompter.select({ const nodeManager = (await prompter.select({
message: "Preferred node manager for skill installs", message: "Preferred node manager for skill installs",
options: resolveNodeManagerOptions(), options: resolveNodeManagerOptions(),

View File

@ -57,7 +57,10 @@ import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
import { shouldLogVerbose } from "../globals.js"; import { shouldLogVerbose } from "../globals.js";
import { sendMessageIMessage } from "../imessage/index.js"; import { sendMessageIMessage } from "../imessage/index.js";
import { type IMessageProbe, probeIMessage } from "../imessage/probe.js"; import { type IMessageProbe, probeIMessage } from "../imessage/probe.js";
import { onAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import {
onAgentEvent,
registerAgentRunContext,
} from "../infra/agent-events.js";
import type { startNodeBridgeServer } from "../infra/bridge/server.js"; import type { startNodeBridgeServer } from "../infra/bridge/server.js";
import { getLastHeartbeatEvent } from "../infra/heartbeat-events.js"; import { getLastHeartbeatEvent } from "../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js"; import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js";

View File

@ -214,9 +214,7 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
}); });
test( test("enables cron scheduler by default and runs due jobs automatically", async () => {
"enables cron scheduler by default and runs due jobs automatically",
async () => {
const dir = await fs.mkdtemp( const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdis-gw-cron-default-on-"), path.join(os.tmpdir(), "clawdis-gw-cron-default-on-"),
); );
@ -307,7 +305,5 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
} }
}, }, 15_000);
15_000,
);
}); });