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.
### Fixes
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- 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]) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: error)
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let errorCopy = error // Capture error for Sendable compliance
Task { @MainActor in
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")
// 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
@Observable
final class OnboardingWizardModel {
@ -285,11 +304,11 @@ struct OnboardingWizardStepView: View {
return
}
let option = optionItems[selectedIndex].option
onSubmit(option.value ?? AnyCodable(option.label))
onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect":
let values = optionItems
.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))
case "action":
onSubmit(AnyCodable(true))
@ -307,12 +326,12 @@ private struct WizardOptionItem: Identifiable {
}
private struct WizardOption {
let value: AnyCodable?
let value: ProtocolAnyCodable?
let label: String
let hint: String?
}
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
guard let raw else { return nil }
do {
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 [] }
return raw.map { entry in
let value = entry["value"]
@ -337,7 +356,7 @@ private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? ""
}
private func anyCodableString(_ value: AnyCodable?) -> String {
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value {
case let string as 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
}
private func anyCodableBool(_ value: AnyCodable?) -> Bool {
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value {
case let bool as 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 {
case let arr as [AnyCodable]:
case let arr as [ProtocolAnyCodable]:
return arr
case let arr as [Any]:
return arr.map { AnyCodable($0) }
return arr.map { ProtocolAnyCodable($0) }
default:
return []
}
}
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool {
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) {
case let (l as String, r as String):
return l == r

View File

@ -289,10 +289,14 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: manager.authorizationStatus)
// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = 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 step: [String: 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 done: Bool
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 error: String?

View File

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

View File

@ -2774,7 +2774,9 @@ function buildAgentToAgentPostContext(params: {
? `Requester surface: ${params.requesterSurface}.`
: undefined,
`Target session: ${params.targetSessionKey}.`,
params.targetChannel ? `Target surface: ${params.targetChannel}.` : undefined,
params.targetChannel
? `Target surface: ${params.targetChannel}.`
: undefined,
`Original request: ${params.originalMessage}`,
params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}`
@ -2840,34 +2842,35 @@ function createSessionsSendTool(opts?: {
extraSystemPrompt: agentMessageContext,
};
const resolveAnnounceTarget = async (): Promise<AnnounceTarget | null> => {
const parsed = resolveAnnounceTargetFromKey(resolvedKey);
if (parsed) return parsed;
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: true,
includeUnknown: true,
limit: 200,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const match =
sessions.find((entry) => entry?.key === resolvedKey) ??
sessions.find((entry) => entry?.key === displayKey);
const channel =
typeof match?.lastChannel === "string"
? match.lastChannel
: undefined;
const to =
typeof match?.lastTo === "string" ? match.lastTo : undefined;
if (channel && to) return { channel, to };
} catch {
// ignore; fall through to null
}
return null;
};
const resolveAnnounceTarget =
async (): Promise<AnnounceTarget | null> => {
const parsed = resolveAnnounceTargetFromKey(resolvedKey);
if (parsed) return parsed;
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: true,
includeUnknown: true,
limit: 200,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const match =
sessions.find((entry) => entry?.key === resolvedKey) ??
sessions.find((entry) => entry?.key === displayKey);
const channel =
typeof match?.lastChannel === "string"
? match.lastChannel
: undefined;
const to =
typeof match?.lastTo === "string" ? match.lastTo : undefined;
if (channel && to) return { channel, to };
} catch {
// ignore; fall through to null
}
return null;
};
const runAgentToAgentPost = async (roundOneReply?: string) => {
const announceTarget = await resolveAnnounceTarget();
@ -2917,9 +2920,7 @@ function createSessionsSendTool(opts?: {
params: { sessionKey: resolvedKey, limit: 50 },
})) as { messages?: unknown[] };
const postFiltered = stripToolMessages(
Array.isArray(postHistory?.messages)
? postHistory.messages
: [],
Array.isArray(postHistory?.messages) ? postHistory.messages : [],
);
const postLast =
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 { RuntimeEnv } from "../runtime.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 {
const cleaned = message
@ -59,6 +59,13 @@ export async function setupSkills(
);
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(
[
`Eligible: ${eligible.length}`,
@ -74,6 +81,29 @@ export async function setupSkills(
});
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({
message: "Preferred node manager for skill installs",
options: resolveNodeManagerOptions(),

View File

@ -57,7 +57,10 @@ import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
import { shouldLogVerbose } from "../globals.js";
import { sendMessageIMessage } from "../imessage/index.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 { getLastHeartbeatEvent } from "../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js";

View File

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